Introduce package for Features (#7242)

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen authored and GitHub committed 2023-02-28 08:37:07 -05:00
1 parent 1b808cf7df
commit c174965708
13 files changed
+730

No files matched your search

@@ -0,0 +1,82 @@
import type { DiContainer } from "@ogre-tools/injectable";
import type { Feature } from "./feature";
import { featureContextMapInjectable } from "./feature-context-map-injectable";
export const deregisterFeature = (di: DiContainer, ...features: Feature[]) => {
features.forEach((feature) => {
deregisterFeatureRecursed(di, feature);
});
};
const deregisterFeatureRecursed = (
di: DiContainer,
feature: Feature,
dependedBy?: Feature
) => {
const featureContextMap = di.inject(featureContextMapInjectable);
const featureContext = featureContextMap.get(feature);
if (!featureContext) {
throw new Error(
`Tried to deregister feature "${feature.id}", but it was not registered.`
);
}
featureContext.numberOfRegistrations--;
const getDependingFeatures = getDependingFeaturesFor(featureContextMap);
const dependingFeatures = getDependingFeatures(feature);
if (!dependedBy && dependingFeatures.length) {
throw new Error(
`Tried to deregister Feature "${
feature.id
}", but it is the dependency of Features "${dependingFeatures.join(
", "
)}"`
);
}
if (dependedBy) {
const oldNumberOfDependents = featureContext.dependedBy.get(dependedBy)!;
const newNumberOfDependants = oldNumberOfDependents - 1;
featureContext.dependedBy.set(dependedBy, newNumberOfDependants);
if (newNumberOfDependants === 0) {
featureContext.dependedBy.delete(dependedBy);
}
}
if (featureContext.numberOfRegistrations === 0) {
featureContextMap.delete(feature);
featureContext.deregister();
}
feature.dependencies?.forEach((dependency) => {
deregisterFeatureRecursed(di, dependency, feature);
});
};
const getDependingFeaturesFor = (
featureContextMap: Map<Feature, { dependedBy: Map<Feature, number> }>
) => {
const getDependingFeaturesForRecursion = (
feature: Feature,
atRoot = true
): string[] => {
const context = featureContextMap.get(feature);
if (context?.dependedBy.size) {
return [...context!.dependedBy.entries()].flatMap(([dependant]) =>
getDependingFeaturesForRecursion(dependant, false)
);
}
return atRoot ? [] : [feature.id];
};
return getDependingFeaturesForRecursion;
};
@@ -0,0 +1,27 @@
import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
import type { Feature } from "./feature";
export type FeatureContextMap = Map<
Feature,
{
register: () => void;
deregister: () => void;
dependedBy: Map<Feature, number>;
numberOfRegistrations: number;
}
>;
export const featureContextMapInjectionToken =
getInjectionToken<FeatureContextMap>({
id: "feature-context-map-injection-token",
});
const featureContextMapInjectable = getInjectable({
id: "feature-store",
instantiate: (): FeatureContextMap => new Map(),
injectionToken: featureContextMapInjectionToken,
});
export { featureContextMapInjectable };
@@ -0,0 +1,281 @@
import {
createContainer,
DiContainer,
getInjectable,
Injectable,
} from "@ogre-tools/injectable";
import type { Feature } from "./feature";
import { registerFeature } from "./register-feature";
import { deregisterFeature } from "./deregister-feature";
import { getFeature } from "./feature" ;
describe("feature-dependencies", () => {
describe("given a parent Feature with another Features as dependency", () => {
let di: DiContainer;
let someInjectable: Injectable<string>;
let someInjectableInDependencyFeature: Injectable<string>;
let someParentFeature: Feature;
let someDependencyFeature: Feature;
beforeEach(() => {
di = createContainer("irrelevant");
someInjectable = getInjectable({
id: "some-injectable-2",
instantiate: () => "some-instance",
});
someInjectableInDependencyFeature = getInjectable({
id: "some-injectable",
instantiate: () => "some-instance-2",
});
someDependencyFeature = getFeature({
id: "some-dependency-feature",
register: (di) => di.register(someInjectableInDependencyFeature),
});
someParentFeature = getFeature({
id: "some-feature",
register: (di) => di.register(someInjectable),
dependencies: [someDependencyFeature],
});
registerFeature(di, someParentFeature);
});
it("when an injectable from the dependency Feature is injected, does so", () => {
const actual = di.inject(someInjectableInDependencyFeature);
expect(actual).toBe("some-instance-2");
});
it("when the dependency Feature is deregistered, throws", () => {
expect(() => {
deregisterFeature(di, someDependencyFeature);
}).toThrow(
'Tried to deregister Feature "some-dependency-feature", but it is the dependency of Features "some-feature"'
);
});
it("given the parent Feature is already deregistered, when also the dependency Feature is deregistered, throws", () => {
deregisterFeature(di, someParentFeature);
expect(() => {
deregisterFeature(di, someDependencyFeature);
}).toThrow(
'Tried to deregister feature "some-dependency-feature", but it was not registered.'
);
});
it("given the parent Feature is deregistered, when injecting an injectable from the dependency Feature, throws", () => {
deregisterFeature(di, someParentFeature);
expect(() => {
di.inject(someInjectableInDependencyFeature);
}).toThrow(
'Tried to inject non-registered injectable "irrelevant" -> "some-injectable".'
);
});
});
describe("given a first Feature is registered, when second Feature using the first Feature as dependency gets registered", () => {
let di: DiContainer;
let someInjectable: Injectable<string>;
let someFeature2: Feature;
let someFeature1: Feature;
beforeEach(() => {
di = createContainer("irrelevant");
someInjectable = getInjectable({
id: "some-injectable",
instantiate: () => "some-instance",
});
someFeature1 = getFeature({
id: "some-feature-1",
register: (di) => di.register(someInjectable),
});
someFeature2 = getFeature({
id: "some-feature-2",
register: () => {},
dependencies: [someFeature1],
});
registerFeature(di, someFeature1, someFeature2);
});
it("when the first Feature is deregistered, throws", () => {
expect(() => {
deregisterFeature(di, someFeature1);
}).toThrow(
'Tried to deregister Feature "some-feature-1", but it is the dependency of Features "some-feature-2"'
);
});
it("given the second Feature is deregistered, when injecting an injectable from the first Feature, still does so", () => {
deregisterFeature(di, someFeature2);
const actual = di.inject(someInjectable);
expect(actual).toBe("some-instance");
});
});
describe("given parent Features with a shared Feature as dependency", () => {
let di: DiContainer;
let someInjectableInDependencyFeature: Injectable<string>;
let someFeature1: Feature;
let someFeature2: Feature;
let someSharedDependencyFeature: Feature;
beforeEach(() => {
di = createContainer("irrelevant");
someInjectableInDependencyFeature = getInjectable({
id: "some-injectable-in-dependency-feature",
instantiate: () => "some-instance",
});
someSharedDependencyFeature = getFeature({
id: "some-dependency-feature",
register: (di) => di.register(someInjectableInDependencyFeature),
});
const someFeatureForAdditionalHierarchy = getFeature({
id: "some-feature-for-additional-hierarchy",
register: () => {},
dependencies: [someSharedDependencyFeature],
});
someFeature1 = getFeature({
id: "some-feature-1",
register: () => {},
dependencies: [someFeatureForAdditionalHierarchy],
});
someFeature2 = getFeature({
id: "some-feature-2",
register: () => {},
dependencies: [someFeatureForAdditionalHierarchy],
});
registerFeature(di, someFeature1, someFeature2);
});
it("when the shared Feature is deregistered, throws", () => {
expect(() => {
deregisterFeature(di, someSharedDependencyFeature);
}).toThrow(
'Tried to deregister Feature "some-dependency-feature", but it is the dependency of Features "some-feature-1, some-feature-2"'
);
});
it("given only part of the parent Features get deregistered, when injecting an injectable from the shared Feature, does so", () => {
deregisterFeature(di, someFeature1);
const actual = di.inject(someInjectableInDependencyFeature);
expect(actual).toBe("some-instance");
});
it("given all of the parent Features get deregistered, when injecting an injectable from the shared Feature, throws", () => {
deregisterFeature(di, someFeature1, someFeature2);
expect(() => {
di.inject(someInjectableInDependencyFeature);
}).toThrow(
'Tried to inject non-registered injectable "irrelevant" -> "some-injectable-in-dependency-feature".'
);
});
});
describe("given parent Features with a shared Feature as dependency and registered, when the shared Feature gets registered again", () => {
let di: DiContainer;
let someInjectableInDependencyFeature: Injectable<string>;
let someFeature1: Feature;
let someFeature2: Feature;
let someSharedDependencyFeature: Feature;
beforeEach(() => {
di = createContainer("irrelevant");
someInjectableInDependencyFeature = getInjectable({
id: "some-injectable-in-dependency-feature",
instantiate: () => "some-instance",
});
someSharedDependencyFeature = getFeature({
id: "some-dependency-feature",
register: (di) => di.register(someInjectableInDependencyFeature),
});
const someFeatureForAdditionalHierarchy = getFeature({
id: "some-feature-for-additional-hierarchy",
register: () => {},
dependencies: [someSharedDependencyFeature],
});
someFeature1 = getFeature({
id: "some-feature-1",
register: () => {},
dependencies: [someFeatureForAdditionalHierarchy],
});
someFeature2 = getFeature({
id: "some-feature-2",
register: () => {},
dependencies: [someFeatureForAdditionalHierarchy],
});
registerFeature(
di,
someFeature1,
someFeature2,
someSharedDependencyFeature
);
});
it("when the shared Feature is deregistered, throws", () => {
expect(() => {
deregisterFeature(di, someSharedDependencyFeature);
}).toThrow(
'Tried to deregister Feature "some-dependency-feature", but it is the dependency of Features "some-feature-1, some-feature-2"'
);
});
it("given only part of the parent Features get deregistered, when injecting an injectable from the shared Feature, does so", () => {
deregisterFeature(di, someFeature1);
const actual = di.inject(someInjectableInDependencyFeature);
expect(actual).toBe("some-instance");
});
it("given all of the parent Features get deregistered, when injecting an injectable from the shared Feature, still does so", () => {
deregisterFeature(di, someFeature1, someFeature2);
const actual = di.inject(someInjectableInDependencyFeature);
expect(actual).toBe("some-instance");
});
it("given all of the Features get deregistered, when injecting an injectable from the shared Feature, throws", () => {
deregisterFeature(
di,
someFeature1,
someFeature2,
someSharedDependencyFeature
);
expect(() => {
di.inject(someInjectableInDependencyFeature);
}).toThrow(
'Tried to inject non-registered injectable "irrelevant" -> "some-injectable-in-dependency-feature".'
);
});
});
});
@@ -0,0 +1,12 @@
import type { DiContainerForInjection } from "@ogre-tools/injectable";
export interface Feature {
id: string;
register: (di: DiContainerForInjection) => void;
dependencies?: Feature[];
}
export interface GetFeatureArgs extends Feature {}
export const getFeature = (getFeatureArgs: GetFeatureArgs): Feature =>
getFeatureArgs;
@@ -0,0 +1,90 @@
import type { DiContainer } from "@ogre-tools/injectable";
import { getInjectable } from "@ogre-tools/injectable";
import type { Feature } from "./feature";
import {
featureContextMapInjectable,
featureContextMapInjectionToken,
} from "./feature-context-map-injectable";
export const registerFeature = (di: DiContainer, ...features: Feature[]) => {
features.forEach((feature) => {
registerFeatureRecursed(di, feature);
});
};
const registerFeatureRecursed = (
di: DiContainer,
feature: Feature,
dependedBy?: Feature
) => {
const featureContextMaps = di.injectMany(featureContextMapInjectionToken);
if (featureContextMaps.length === 0) {
di.register(featureContextMapInjectable);
}
const featureContextMap = di.inject(featureContextMapInjectable);
const existingFeatureContext = featureContextMap.get(feature);
if (
!dependedBy &&
existingFeatureContext &&
existingFeatureContext.dependedBy.size === 0
) {
throw new Error(
`Tried to register feature "${feature.id}", but it was already registered.`
);
}
const featureContext =
existingFeatureContext || createFeatureContext(feature, di);
featureContext.numberOfRegistrations++;
if (dependedBy) {
const oldNumberOfDependents =
featureContext.dependedBy.get(dependedBy) || 0;
const newNumberOfDependants = oldNumberOfDependents + 1;
featureContext.dependedBy.set(dependedBy, newNumberOfDependants);
}
if (!existingFeatureContext) {
featureContext.register();
}
feature.dependencies?.forEach((dependency) => {
registerFeatureRecursed(di, dependency, feature);
});
};
const createFeatureContext = (feature: Feature, di: DiContainer) => {
const featureContextInjectable = getInjectable({
id: feature.id,
instantiate: (diForContextOfFeature) => ({
register: () => {
feature.register(diForContextOfFeature);
},
deregister: () => {
diForContextOfFeature.deregister(featureContextInjectable);
},
dependedBy: new Map<Feature, number>(),
numberOfRegistrations: 0,
}),
scope: true,
});
di.register(featureContextInjectable);
const featureContextMap = di.inject(featureContextMapInjectable);
const featureContext = di.inject(featureContextInjectable);
featureContextMap.set(feature, featureContext);
return featureContext;
};
@@ -0,0 +1,147 @@
import { registerFeature } from "./register-feature";
import {
createContainer,
DiContainer,
getInjectable,
Injectable,
} from "@ogre-tools/injectable";
import type { Feature } from "./feature";
import { getFeature } from "./feature";
import { deregisterFeature } from "./deregister-feature";
describe("register-feature", () => {
describe("given di-container and a Features with injectables, and given Features are registered", () => {
let di: DiContainer;
let someInjectable: Injectable<string>;
let someInjectable2: Injectable<string>;
let someFeature: Feature;
let someFeature2: Feature;
let instance: string;
beforeEach(() => {
di = createContainer("irrelevant");
someInjectable = getInjectable({
id: "some-injectable",
instantiate: () => "some-instance",
});
someInjectable2 = getInjectable({
id: "some-injectable-2",
instantiate: () => "some-instance-2",
});
someFeature = getFeature({
id: "some-feature-1",
register: (di) => di.register(someInjectable),
});
someFeature2 = getFeature({
id: "some-feature-2",
register: (di) => di.register(someInjectable2),
});
registerFeature(di, someFeature);
registerFeature(di, someFeature2);
});
it("when an injectable is injected, does so", () => {
instance = di.inject(someInjectable);
expect(instance).toBe("some-instance");
});
describe("given a Feature is deregistered", () => {
beforeEach(() => {
deregisterFeature(di, someFeature);
});
it("when injecting a related injectable, throws", () => {
expect(() => {
di.inject(someInjectable);
}).toThrow();
});
it("when injecting an unrelated injectable, does so", () => {
const instance = di.inject(someInjectable2);
expect(instance).toBe("some-instance-2");
});
describe("given the Feature is registered again", () => {
beforeEach(() => {
registerFeature(di, someFeature);
});
it("when injecting a related injectable, does so", () => {
const instance = di.inject(someInjectable);
expect(instance).toBe("some-instance");
});
it("when injecting an unrelated injectable, does so", () => {
const instance = di.inject(someInjectable2);
expect(instance).toBe("some-instance-2");
});
});
});
it("when a Feature is registered again, throws", () => {
expect(() => {
registerFeature(di, someFeature);
}).toThrow(
'Tried to register feature "some-feature-1", but it was already registered.'
);
});
it("given a Feature deregistered, when deregistered again, throws", () => {
deregisterFeature(di, someFeature);
expect(() => {
deregisterFeature(di, someFeature);
}).toThrow(
'Tried to deregister feature "some-feature-1", but it was not registered.'
);
});
});
it("given di-container and registered Features with injectables forming a cycle, when an injectable is injected, throws with namespaced error about cycle", () => {
const someInjectable: Injectable<any> = getInjectable({
id: "some-injectable-1",
instantiate: (di) => di.inject(someInjectable2),
});
const someInjectable2: Injectable<any> = getInjectable({
id: "some-injectable-2",
instantiate: (di) => di.inject(someInjectable),
});
const di = createContainer("some-container");
const someFeature = getFeature({
id: "some-feature-1",
register: (di) => {
di.register(someInjectable);
},
});
const someFeature2 = getFeature({
id: "some-feature-2",
register: (di) => {
di.register(someInjectable2);
},
});
registerFeature(di, someFeature, someFeature2);
expect(() => {
di.inject(someInjectable);
}).toThrow(
// 'Cycle of injectables encountered: "some-container" -> "some-feature-1:some-injectable-1" -> "some-feature-2:some-injectable-2" -> "some-feature-1:some-injectable-1"'
'Maximum call stack size exceeded'
);
});
});