Skip to content

Commit

Permalink
prevent dependency overwrite
Browse files Browse the repository at this point in the history
  • Loading branch information
ikenox committed Oct 15, 2023
1 parent 8af0fb9 commit d521dad
Show file tree
Hide file tree
Showing 5 changed files with 84 additions and 80 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ jobs:
args: [--frozen-lockfile, --strict-peer-dependencies]
- run: pnpm prettier --check
- run: pnpm eslint
- run: pnpm typecheck
- run: pnpm test
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"build:esm": "tsc -p tsconfig.build.json --module esnext --outDir ./lib/esm",
"prettier": "prettier --ignore-path .prettierignore --ignore-path .gitignore .",
"eslint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --coverage",
"test:watch": "vitest",
"package-version": "node -p \"require('./package.json').version\""
Expand Down
9 changes: 0 additions & 9 deletions src/example.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,4 @@ test('example', () => {
} as unknown as Request;

requestHandler(request);

// let messages: string[] = [];
// requestScope.provide({
// logger: (): Logger => ({
// log: (msg) => (messages = [...messages, msg]),
// }),
// });
// requestHandler(request);
// expect(messages).toEqual(['']);
});
33 changes: 29 additions & 4 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test } from 'vitest';
import type { Infer } from './index';
import { describe, test } from 'vitest';
import type { Infer, Provider } from './index';

Check failure on line 2 in src/index.test.ts

View workflow job for this annotation

GitHub Actions / test

'Provider' is defined but never used
import { scope } from './index';

test('basic usage', ({ expect }) => {
Expand Down Expand Up @@ -81,7 +81,7 @@ test('cache', ({ expect }) => {
test("container instantiation processses doen't evaluate a passed another container dependency instances", ({
expect,
}) => {
const container1 = scope()
const container = scope()
.provide({
dep: () => {
throw new Error('this code should never be called');
Expand All @@ -90,6 +90,31 @@ test("container instantiation processses doen't evaluate a passed another contai
.instanciate({});

expect(() => {
scope().static(container1).instanciate({});
scope().static(container).instanciate({});
}).not.toThrowError();
});

describe('type-level tests', () => {
const testScope = scope().provide({
depA: () => 1,
});

test('`provide` function: cannot provide dependency that key already exists', () => {
type Arg = Parameters<typeof testScope.provide>[0];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const cannotOverwriteDepA: Eq<Arg['depA'], undefined> = true;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const canAddOtherKey: Extends<Arg['testKey'], unknown> = true;
});

test('`static` function: cannot provide dependency that key already exists', () => {
type Arg = Parameters<typeof testScope.static>[0];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const cannotOverwriteDepA: Eq<Arg['depA'], undefined> = true;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const canAddOtherKey: Extends<Arg['testKey'], unknown> = true;
});
});

type Eq<A, B> = A extends B ? (B extends A ? true : false) : false;
type Extends<A, B> = A extends B ? true : false;
120 changes: 53 additions & 67 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,71 @@
// TODO: prevent duplicate key that value's type is not match to existing one
export type ContainerScope<
export class ContainerScope<
Instances extends Record<string, unknown>,
ScopeArgs,
> = {
> {
constructor(readonly providers: Providers<Instances, Instances, ScopeArgs>) {}

/**
* Instanciates a container that provides dependency instances.
* Actually, each dependency instances are NOT instanciated yet at this point.
* It will be instanciated when actually used.
*/
instanciate(params: ScopeArgs): Instances;
instanciate(params: ScopeArgs): Instances {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const providers: Record<string | symbol, any> = this.providers;
const caches: Record<string | symbol, unknown> = {};

return new Proxy<Instances>({} as Instances, {
ownKeys: () => Reflect.ownKeys(providers),
getOwnPropertyDescriptor: function (target, key) {
return {
enumerable: true,
configurable: true,
// eslint-disable-next-line
value: (this as any)[key],
};
},
get(target, p, receiver) {
// eslint-disable-next-line
return (caches[p] ??= providers[p]?.(receiver, params));
},
});
}

/**
* Merges external static instances to provide via the container.
*/
static<P extends Record<string, unknown>>(
static<P extends Record<string, unknown> & WithoutReserved<Instances>>(
p: P
): ContainerScope<Instances & P, ScopeArgs>;
): ContainerScope<Instances & P, ScopeArgs> {
const added = Object.fromEntries(
// Don't use `Object.entries` because it causes immediate evaluation of the passed DI container's all members
Object.keys(p).map((k) => [k, () => p[k]])
) as unknown as Providers<P, Instances, ScopeArgs>;
const merged = {
...this.providers,
...added,
} as Providers<P & Instances, Instances, ScopeArgs>; // TODO: type safety
return new ContainerScope(merged);
}

/**
* Add providers of dependencies.
* The provider can use the other dependencies already provided to build the providing instance.
*/
provide<P extends Providers<Record<string, unknown>, Instances, ScopeArgs>>(
addedProviders: P
): ContainerScope<Instances & ProvidedBy<P>, ScopeArgs>;
};
provide<
P extends Providers<Record<string, unknown>, Instances, ScopeArgs> &
WithoutReserved<Instances>,
>(addedProviders: P): ContainerScope<Instances & ProvidedBy<P>, ScopeArgs> {
const merged = {
...this.providers,
...addedProviders,
} as Providers<Instances & ProvidedBy<P>, Instances, ScopeArgs>; // TODO: type safety
return new ContainerScope(merged);
}
}

type WithoutReserved<Instances> = Partial<Record<keyof Instances, never>>;

/**
* A set of providers.
Expand Down Expand Up @@ -58,64 +99,9 @@ export type Infer<C extends ContainerScope<Record<never, never>, never>> =
* Container scope is like a template, or a builder of the specific container instance.
* It's preferable that each container scopes are defined at only once and reused throughout the process.
*/
export function scope<D extends Record<string, unknown>>(): ContainerScope<
export function scope<ScopeArgs>(): ContainerScope<
Record<never, never>,
D
ScopeArgs
> {
return new ContainerScopeImpl({});
}

/**
* An implementation of the container scope interface.
*/
class ContainerScopeImpl<Instances extends Record<string, unknown>, ScopeArgs>
implements ContainerScope<Instances, ScopeArgs>
{
constructor(readonly providers: Providers<Instances, Instances, ScopeArgs>) {}

instanciate(params: ScopeArgs): Instances {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const providers: Record<string | symbol, any> = this.providers;
const caches: Record<string | symbol, unknown> = {};

return new Proxy<Instances>({} as Instances, {
ownKeys: () => Reflect.ownKeys(providers),
getOwnPropertyDescriptor: function (target, key) {
return {
enumerable: true,
configurable: true,
// eslint-disable-next-line
value: (this as any)[key],
};
},
get(target, p, receiver) {
// eslint-disable-next-line
return (caches[p] ??= providers[p]?.(receiver, params));
},
});
}

static<P extends Record<string, unknown>>(
p: P
): ContainerScope<Instances & P, ScopeArgs> {
const added = Object.fromEntries(
// Don't use `Object.entries` because it causes immediate evaluation of the passed DI container's all members
Object.keys(p).map((k) => [k, () => p[k]])
) as unknown as Providers<P, Instances, ScopeArgs>;
const merged = {
...this.providers,
...added,
} as Providers<P & Instances, Instances, ScopeArgs>; // TODO: type safety
return new ContainerScopeImpl(merged);
}

provide<P extends Providers<Record<string, unknown>, Instances, ScopeArgs>>(
addedProviders: P
): ContainerScope<Instances & ProvidedBy<P>, ScopeArgs> {
const merged = {
...this.providers,
...addedProviders,
} as Providers<Instances & ProvidedBy<P>, Instances, ScopeArgs>; // TODO: type safety
return new ContainerScopeImpl(merged);
}
return new ContainerScope({});
}

0 comments on commit d521dad

Please sign in to comment.