first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { Container } from '../di';
|
||||
import { ServiceA } from './fixtures/service-a';
|
||||
import { ServiceB } from './fixtures/service-b';
|
||||
|
||||
describe('DI Container', () => {
|
||||
describe('circular dependency', () => {
|
||||
it('should detect multilevel circular dependencies', () => {
|
||||
expect(() => Container.get(ServiceA)).toThrow(
|
||||
'[DI] Circular dependency detected in ServiceB at index 0.\nServiceA -> ServiceB',
|
||||
);
|
||||
|
||||
expect(() => Container.get(ServiceB)).toThrow(
|
||||
'[DI] Circular dependency detected in ServiceB at index 0.\nServiceB',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Container, Service } from '../di';
|
||||
|
||||
@Service()
|
||||
class SimpleService {
|
||||
getValue() {
|
||||
return 'simple';
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
class DependentService {
|
||||
constructor(readonly simple: SimpleService) {}
|
||||
|
||||
getValue() {
|
||||
return this.simple.getValue() + '-dependent';
|
||||
}
|
||||
}
|
||||
|
||||
class CustomFactory {
|
||||
getValue() {
|
||||
return 'factory-made';
|
||||
}
|
||||
}
|
||||
|
||||
@Service({ factory: () => new CustomFactory() })
|
||||
class FactoryService {
|
||||
getValue() {
|
||||
return 'should-not-be-called';
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AbstractService {
|
||||
abstract getValue(): string;
|
||||
}
|
||||
|
||||
@Service()
|
||||
class ConcreteService extends AbstractService {
|
||||
getValue(): string {
|
||||
return 'concrete';
|
||||
}
|
||||
}
|
||||
|
||||
describe('DI Container', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Container.reset();
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should create a simple instance', () => {
|
||||
const instance = Container.get(SimpleService);
|
||||
expect(instance).toBeInstanceOf(SimpleService);
|
||||
expect(instance.getValue()).toBe('simple');
|
||||
});
|
||||
|
||||
it('should return same instance on multiple gets', () => {
|
||||
const instance1 = Container.get(SimpleService);
|
||||
const instance2 = Container.get(SimpleService);
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should handle classes with no dependencies (empty constructor)', () => {
|
||||
@Service()
|
||||
class EmptyConstructorService {}
|
||||
|
||||
const instance = Container.get(EmptyConstructorService);
|
||||
expect(instance).toBeInstanceOf(EmptyConstructorService);
|
||||
});
|
||||
|
||||
it('should throw when trying to resolve an undecorated class', () => {
|
||||
class UnDecoratedService {}
|
||||
|
||||
expect(() => Container.get(UnDecoratedService)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dependency injection', () => {
|
||||
it('should inject dependencies correctly', () => {
|
||||
const dependent = Container.get(DependentService);
|
||||
expect(dependent).toBeInstanceOf(DependentService);
|
||||
expect(dependent.getValue()).toBe('simple-dependent');
|
||||
expect(dependent.simple).toBeInstanceOf(SimpleService);
|
||||
});
|
||||
|
||||
it('should handle deep dependency chains', () => {
|
||||
@Service()
|
||||
class ServiceC {
|
||||
getValue() {
|
||||
return 'C';
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
class ServiceB {
|
||||
constructor(private c: ServiceC) {}
|
||||
|
||||
getValue() {
|
||||
return this.c.getValue() + 'B';
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
class ServiceA {
|
||||
constructor(private b: ServiceB) {}
|
||||
|
||||
getValue() {
|
||||
return this.b.getValue() + 'A';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = Container.get(ServiceA);
|
||||
expect(instance.getValue()).toBe('CBA');
|
||||
});
|
||||
|
||||
it('should return undefined for non-decorated dependencies in resolution chain', () => {
|
||||
class NonDecoratedDep {}
|
||||
|
||||
@Service()
|
||||
class ServiceWithNonDecoratedDep {
|
||||
constructor(readonly dep: NonDecoratedDep) {}
|
||||
}
|
||||
|
||||
const instance = Container.get(ServiceWithNonDecoratedDep);
|
||||
expect(instance).toBeInstanceOf(ServiceWithNonDecoratedDep);
|
||||
expect(instance.dep).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('factory handling', () => {
|
||||
it('should use factory when provided', () => {
|
||||
const instance = Container.get(FactoryService);
|
||||
expect(instance).toBeInstanceOf(CustomFactory);
|
||||
expect(instance.getValue()).toBe('factory-made');
|
||||
});
|
||||
|
||||
it('should preserve factory metadata when setting instance', () => {
|
||||
const customInstance = new CustomFactory();
|
||||
Container.set(FactoryService, customInstance);
|
||||
const instance = Container.get(FactoryService);
|
||||
expect(instance).toBe(customInstance);
|
||||
});
|
||||
|
||||
it('should preserve factory when resetting container', () => {
|
||||
const factoryInstance1 = Container.get(FactoryService);
|
||||
Container.reset();
|
||||
const factoryInstance2 = Container.get(FactoryService);
|
||||
|
||||
expect(factoryInstance1).not.toBe(factoryInstance2);
|
||||
expect(factoryInstance2.getValue()).toBe('factory-made');
|
||||
});
|
||||
|
||||
it('should throw error when factory throws', () => {
|
||||
@Service({
|
||||
factory: () => {
|
||||
throw new Error('Factory error');
|
||||
},
|
||||
})
|
||||
class ErrorFactoryService {}
|
||||
|
||||
expect(() => Container.get(ErrorFactoryService)).toThrow('Factory error');
|
||||
});
|
||||
|
||||
it('should handle factory with dependencies', () => {
|
||||
const factory = jest.fn().mockReturnValue({});
|
||||
|
||||
@Service({ factory })
|
||||
class FactoryWithDependencies {
|
||||
constructor(readonly simpleService: SimpleService) {}
|
||||
}
|
||||
|
||||
const instance = Container.get(FactoryWithDependencies);
|
||||
expect(instance.simpleService).toBeUndefined();
|
||||
expect(factory).toHaveBeenCalledWith(Container.get(SimpleService));
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance management', () => {
|
||||
it('should allow manual instance setting', () => {
|
||||
const customInstance = new SimpleService();
|
||||
Container.set(SimpleService, customInstance);
|
||||
const instance = Container.get(SimpleService);
|
||||
expect(instance).toBe(customInstance);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abstract classes', () => {
|
||||
it('should throw when trying to instantiate an abstract class directly', () => {
|
||||
@Service()
|
||||
abstract class TestAbstractClass {
|
||||
abstract doSomething(): void;
|
||||
|
||||
// Add a concrete method to make the class truly abstract at runtime
|
||||
constructor() {
|
||||
if (this.constructor === TestAbstractClass) {
|
||||
throw new TypeError('Abstract class "TestAbstractClass" cannot be instantiated');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => Container.get(TestAbstractClass)).toThrow(
|
||||
'[DI] TestAbstractClass is an abstract class, and cannot be instantiated',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow setting an implementation for an abstract class', () => {
|
||||
const concrete = new ConcreteService();
|
||||
Container.set(AbstractService, concrete);
|
||||
|
||||
const instance = Container.get(AbstractService);
|
||||
expect(instance).toBe(concrete);
|
||||
expect(instance.getValue()).toBe('concrete');
|
||||
});
|
||||
|
||||
it('should allow factory for abstract class', () => {
|
||||
@Service({ factory: () => new ConcreteService() })
|
||||
abstract class FactoryAbstractService {
|
||||
abstract getValue(): string;
|
||||
}
|
||||
|
||||
const instance = Container.get(FactoryAbstractService);
|
||||
expect(instance).toBeInstanceOf(ConcreteService);
|
||||
expect(instance.getValue()).toBe('concrete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inheritance', () => {
|
||||
it('should handle inheritance in injectable classes', () => {
|
||||
@Service()
|
||||
class BaseService {
|
||||
getValue() {
|
||||
return 'base';
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
class DerivedService extends BaseService {
|
||||
getValue() {
|
||||
return 'derived-' + super.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
const instance = Container.get(DerivedService);
|
||||
expect(instance.getValue()).toBe('derived-base');
|
||||
});
|
||||
|
||||
it('should maintain separate instances for base and derived classes', () => {
|
||||
@Service()
|
||||
class BaseService {
|
||||
getValue() {
|
||||
return 'base';
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
class DerivedService extends BaseService {}
|
||||
|
||||
const baseInstance = Container.get(BaseService);
|
||||
const derivedInstance = Container.get(DerivedService);
|
||||
|
||||
expect(baseInstance).not.toBe(derivedInstance);
|
||||
expect(baseInstance).toBeInstanceOf(BaseService);
|
||||
expect(derivedInstance).toBeInstanceOf(DerivedService);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type registration checking', () => {
|
||||
it('should return true for registered classes', () => {
|
||||
expect(Container.has(SimpleService)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unregistered classes', () => {
|
||||
class UnregisteredService {}
|
||||
expect(Container.has(UnregisteredService)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for abstract classes with implementations', () => {
|
||||
const concrete = new ConcreteService();
|
||||
Container.set(AbstractService, concrete);
|
||||
expect(Container.has(AbstractService)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for factory-provided services before instantiation', () => {
|
||||
expect(Container.has(FactoryService)).toBe(true);
|
||||
});
|
||||
|
||||
it('should maintain registration after reset', () => {
|
||||
expect(Container.has(SimpleService)).toBe(true);
|
||||
Container.reset();
|
||||
expect(Container.has(SimpleService)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true after manual instance setting', () => {
|
||||
class ManualService {}
|
||||
expect(Container.has(ManualService)).toBe(false);
|
||||
|
||||
Container.set(ManualService, new ManualService());
|
||||
expect(Container.has(ManualService)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// eslint-disable-next-line import-x/no-cycle
|
||||
import { ServiceB } from './service-b';
|
||||
import { Service } from '../../di';
|
||||
|
||||
@Service()
|
||||
export class ServiceA {
|
||||
constructor(readonly b: ServiceB) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// eslint-disable-next-line import-x/no-cycle
|
||||
import { ServiceA } from './service-a';
|
||||
import { Service } from '../../di';
|
||||
|
||||
@Service()
|
||||
export class ServiceB {
|
||||
constructor(readonly a: ServiceA) {}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
/**
|
||||
* Represents a class constructor type that can be instantiated with 'new'
|
||||
* @template T The type of instance the constructor creates
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type Constructable<T = unknown> = new (...args: any[]) => T;
|
||||
|
||||
type AbstractConstructable<T = unknown> = abstract new (...args: unknown[]) => T;
|
||||
|
||||
type ServiceIdentifier<T = unknown> = Constructable<T> | AbstractConstructable<T>;
|
||||
|
||||
type Factory<T = unknown> = (...args: unknown[]) => T;
|
||||
|
||||
interface Metadata<T = unknown> {
|
||||
instance?: T;
|
||||
factory?: Factory<T>;
|
||||
}
|
||||
|
||||
interface Options<T> {
|
||||
factory?: Factory<T>;
|
||||
}
|
||||
|
||||
const instances = new Map<ServiceIdentifier, Metadata>();
|
||||
|
||||
/**
|
||||
* Decorator that marks a class as available for dependency injection.
|
||||
* @param options Configuration options for the injectable class
|
||||
* @param options.factory Optional factory function to create instances of this class
|
||||
* @returns A class decorator to be applied to the target class
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
||||
export function Service<T = unknown>(): Function;
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
||||
export function Service<T = unknown>(options: Options<T>): Function;
|
||||
export function Service<T>({ factory }: Options<T> = {}) {
|
||||
return function (target: Constructable<T>) {
|
||||
instances.set(target, { factory });
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
class DIError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`[DI] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
class ContainerClass {
|
||||
/** Stack to track types being resolved to detect circular dependencies */
|
||||
private readonly resolutionStack: ServiceIdentifier[] = [];
|
||||
|
||||
/**
|
||||
* Checks if a type is registered in the container
|
||||
* @template T The type to check for
|
||||
* @param type The constructor of the type to check
|
||||
* @returns True if the type is registered (has metadata), false otherwise
|
||||
*/
|
||||
has<T>(type: ServiceIdentifier<T>): boolean {
|
||||
return instances.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves or creates an instance of the specified type from the container
|
||||
* @template T The type of instance to retrieve
|
||||
* @param type The constructor of the type to retrieve
|
||||
* @returns An instance of the specified type with all dependencies injected
|
||||
* @throws {DIError} If circular dependencies are detected or if the type is not injectable
|
||||
*/
|
||||
get<T>(type: ServiceIdentifier<T>): T {
|
||||
const { resolutionStack } = this;
|
||||
const metadata = instances.get(type) as Metadata<T>;
|
||||
if (!metadata) {
|
||||
// Special case: Allow undefined returns for non-decorated constructor params
|
||||
// when resolving a dependency chain (i.e., resolutionStack not empty)
|
||||
if (resolutionStack.length) return undefined as T;
|
||||
throw new DIError(`${type.name} is not decorated with ${Service.name}`);
|
||||
}
|
||||
|
||||
if (metadata?.instance) return metadata.instance as T;
|
||||
|
||||
// Add current type to resolution stack before resolving dependencies
|
||||
resolutionStack.push(type);
|
||||
|
||||
try {
|
||||
let instance: T;
|
||||
|
||||
const paramTypes = (Reflect.getMetadata('design:paramtypes', type) ?? []) as Constructable[];
|
||||
|
||||
const dependencies = paramTypes.map(<P>(paramType: Constructable<P>, index: number) => {
|
||||
if (paramType === undefined) {
|
||||
throw new DIError(
|
||||
`Circular dependency detected in ${type.name} at index ${index}.\n${resolutionStack.map((t) => t.name).join(' -> ')}\n`,
|
||||
);
|
||||
}
|
||||
return this.get(paramType);
|
||||
});
|
||||
|
||||
if (metadata?.factory) {
|
||||
instance = metadata.factory(...dependencies);
|
||||
} else {
|
||||
// Create new instance with resolved dependencies
|
||||
instance = new (type as Constructable)(...dependencies) as T;
|
||||
}
|
||||
|
||||
instances.set(type, { ...metadata, instance });
|
||||
return instance;
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.toLowerCase().includes('abstract')) {
|
||||
throw new DIError(`${type.name} is an abstract class, and cannot be instantiated`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
resolutionStack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually sets an instance for a specific type in the container
|
||||
* @template T The type of instance being set
|
||||
* @param type The constructor of the type to set. This can also be an abstract class
|
||||
* @param instance The instance to store in the container
|
||||
*/
|
||||
set<T>(type: ServiceIdentifier<T>, instance: T): void {
|
||||
// Preserve any existing metadata (like factory) when setting new instance
|
||||
const metadata = instances.get(type) ?? {};
|
||||
instances.set(type, { ...metadata, instance });
|
||||
}
|
||||
|
||||
/** Clears all instantiated instances from the container while preserving type registrations */
|
||||
reset(): void {
|
||||
for (const metadata of instances.values()) {
|
||||
delete metadata.instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global dependency injection container instance
|
||||
* Used to retrieve and manage class instances and their dependencies
|
||||
*/
|
||||
export const Container = new ContainerClass();
|
||||
Reference in New Issue
Block a user