UNPKG

@aedart/support

Version:

The Ion support package

1,545 lines (1,525 loc) 56.1 kB
import { PROVIDES, BEFORE, AFTER, CONCERN_CLASSES, CONCERNS, ALIASES } from '@aedart/contracts/support/concerns'; import { AbstractClassError, configureCustomError, getErrorMessage } from '@aedart/support/exceptions'; import { classOwnKeys, getNameOrDesc, isSubclassOrLooksLike, getClassPropertyDescriptors, getAllParentsOfClass } from '@aedart/support/reflections'; import { merge } from '@aedart/support/objects'; import { isset } from '@aedart/support/misc'; import { DANGEROUS_PROPERTIES } from '@aedart/contracts/support/objects'; /** * @aedart/support * * BSD-3-Clause, Copyright (c) 2023-present Alin Eugen Deac <aedart@gmail.com>. */ /** * Abstract Concern * * @see {Concern} * @see [ConcernConstructor]{@link import('@aedart/contracts/support/concerns').ConcernConstructor} * @see [RegistrationAware]{@link import('@aedart/contracts/support/concerns').RegistrationAware} * * @implements {Concern} * * @abstract */ class AbstractConcern { /** * The owner class instance this concern is injected into, * or `this` concern instance. * * @type {object} * * @readonly * @protected */ _concernOwner; /** * Creates a new concern instance * * @param {object} [owner] The owner class instance this concern is injected into. * Defaults to `this` concern instance if none given. * * @throws {Error} When concern is unable to preform initialisation, e.g. caused * by the owner or other circumstances. */ constructor(owner) { if (new.target === AbstractConcern) { throw new AbstractClassError(AbstractConcern); } this._concernOwner = owner || this; } /** * The owner class instance this concern is injected into, * or `this` concern instance if no owner was set. * * @readonly * * @type {object} */ get concernOwner() { return this._concernOwner; } /** * Returns list of property keys that this concern class offers. * * **Note**: _Only properties and methods returned by this method can be aliased * into a target class._ * * @static * * @return {PropertyKey[]} */ static [PROVIDES]() { // Feel free to overwrite this static method in your concern class and specify // the properties and methods that your concern offers (those that can be aliased). return classOwnKeys(this, true); } /** * Perform pre-registration logic. * * **Note**: _This hook method is intended to be invoked by an * [Injector]{@link import('@aedart/contracts/support/concerns').Injector}, before * the concern container and aliases are defined in the target class._ * * @static * * @param {UsesConcerns} target Target class constructor * * @return {void} * * @throws {Error} */ static [BEFORE](target /* eslint-disable-line @typescript-eslint/no-unused-vars */) { // Overwrite this method to perform pre-registration logic. This can be used, amongst other things, // to prevent your concern class from being used, e.g.: // - If your concern class is specialised and only supports specific kinds of target classes. // - If your concern will conflict if combined with another concern class (use target[CONCERN_CLASSES] for such). // - ...etc } /** * Perform post-registration logic. * * **Note**: _This hook method is intended to be invoked by an * [Injector]{@link import('@aedart/contracts/support/concerns').Injector}, after * this concern class has been registered in a target class and aliases have been * defined in target's prototype._ * * @static * * @param {UsesConcerns} target Target class constructor, after concerns and aliases defined * * @return {void} * * @throws {Error} */ static [AFTER](target /* eslint-disable-line @typescript-eslint/no-unused-vars */) { // Overwrite this method to perform post-registration logic. This can be used in situations // when you need to perform additional setup or configuration on the concern class level (static), // e.g.: // - Prepare specialised caching mechanisms, with or for the target class. // - Obtain other kinds static resources or meta information that must be used by the target or // concern, that otherwise cannot be done during concern class' constructor. // - ...etc } } /** * Concern Class Blueprint * * Defines the minimum members that a target class should contain, before it is * considered to "look like" a [Concern Class]{@link import('@aedart/contracts/support/concerns').ConcernConstructor} * * @see ClassBlueprint */ const ConcernClassBlueprint = { staticMembers: [ 'constructor', PROVIDES ], members: [ 'concernOwner' ] }; /** * Concern Error * * @see ConcernException */ class ConcernError extends Error { /** * The Concern class that caused this error or exception * * @type {ConcernConstructor | null} * * @protected * @readonly */ _concern; /** * Create a new Concern Error instance * * @param {ConcernConstructor | null} concern * @param {string} message * @param {ErrorOptions} [options] */ constructor(concern, message, options) { super(message, options || { cause: {} }); configureCustomError(this); this._concern = concern; // Force set the concern in the cause (in case custom was provided) this.cause.concern = concern; } /** * The Concern class that caused this error or exception * * @readonly * * @type {ConcernConstructor | null} */ get concern() { return this._concern; } } /** * Concern Boot Error * * @see BootException */ class BootError extends ConcernError { /** * Create a new Concern Boot Error instance * * @param {ConcernConstructor} concern * @param {string} message * @param {ErrorOptions} [options] */ constructor(concern, message, options) { super(concern, message, options); configureCustomError(this); } } /** * Concern Not Registered Error * * @see NotRegisteredException */ class NotRegisteredError extends ConcernError { /** * Create a new Concern Not Registered Error instance * * @param {ConcernConstructor} concern * @param {ErrorOptions} [options] */ constructor(concern, options) { super(concern, `Concern ${getNameOrDesc(concern)} is not registered in concerns container`, options); configureCustomError(this); } } /** * Concerns Container * * @see Container */ class ConcernsContainer { /** * Map of concern class constructors and actual concern instances * * @protected * @readonly * * @type {Map<ConcernConstructor, Concern|undefined>} */ map; /** * The concerns owner of this container * * @type {Owner} * * @protected * @readonly */ _owner; /** * Create a new Concerns Container instance * * @param {Owner} owner * @param {ConcernConstructor[]} concerns */ constructor(owner, concerns) { this._owner = owner; this.map = new Map(); for (const concern of concerns) { this.map.set(concern, undefined); } } /** * The amount of concerns in this container * * @readonly * * @type {number} */ get size() { return this.map.size; } /** * Get the concerns container owner * * @readonly * * @type {Owner} */ get owner() { return this._owner; } /** * Determine if concern class is registered in this container * * @param {ConcernConstructor} concern * * @return {boolean} */ has(concern) { return this.map.has(concern); } /** * Retrieve concern instance for given concern class * * **Note**: _If concern class is registered in this container, but not yet * booted, then this method will boot it via the {@link boot} method, and return * the resulting instance._ * * @template T extends {@link Concern} * * @param {ConcernConstructor<T>} concern * * @return {T} The booted instance of the concern class. If concern class was * previously booted, then that instance is returned. * * @throws {ConcernError} */ get(concern) { if (!this.hasBooted(concern)) { return this.boot(concern); } return this.map.get(concern); } /** * Determine if concern class has been booted * * @param {ConcernConstructor} concern * * @return {boolean} */ hasBooted(concern) { return this.has(concern) && this.map.get(concern) !== undefined; } /** * Boot concern class * * @template T extends {@link Concern} * * @param {ConcernConstructor<T>} concern * * @return {T} New concern instance * * @throws {NotRegisteredError} If concern class is not registered in this container * @throws {BootError} If concern is unable to be booted, e.g. if already booted */ boot(concern) { // Fail if given concern is not in this container if (!this.has(concern)) { throw new NotRegisteredError(concern, { cause: { owner: this.owner } }); } // Fail if concern instance already exists (has booted) let instance = this.map.get(concern); if (instance !== undefined) { throw new BootError(concern, `Concern ${getNameOrDesc(concern)} is already booted`, { cause: { owner: this.owner } }); } // Boot the concern (create new instance) and register it... try { instance = new concern(this.owner); this.map.set(concern, instance); } catch (error) { throw new BootError(concern, `Unable to boot concern ${getNameOrDesc(concern)}: ${getErrorMessage(error)}`, { cause: { previous: error, owner: this.owner } }); } return instance; } /** * Boots all registered concern classes * * @throws {ConcernError} */ bootAll() { const concerns = this.all(); for (const concern of concerns) { this.boot(concern); } } /** * Determine if this container is empty * * @return {boolean} */ isEmpty() { return this.size === 0; } /** * Opposite of {@link isEmpty} * * @return {boolean} */ isNotEmpty() { return !this.isEmpty(); } /** * Returns all concern classes * * @return {IterableIterator<ConcernConstructor>} */ all() { return this.map.keys(); } /** * Invoke a method with given arguments in concern instance * * @param {ConcernConstructor} concern * @param {PropertyKey} method * @param {...any} [args] * * @return {any} * * @throws {ConcernError} * @throws {Error} */ call(concern, method, ...args /* eslint-disable-line @typescript-eslint/no-explicit-any */) { // @ts-expect-error Can fail when dynamically invoking method in concern instance... return this.get(concern)[method](...args); } /** * Set the value of given property in concern instance * * @param {ConcernConstructor} concern * @param {PropertyKey} property * @param {any} value * * @throws {ConcernError} * @throws {Error} */ setProperty(concern, property, value /* eslint-disable-line @typescript-eslint/no-explicit-any */) { // @ts-expect-error Can fail when dynamically retrieving property in concern instance... this.get(concern)[property] = value; } /** * Get value of given property in concern instance * * @param {ConcernConstructor} concern * @param {PropertyKey} property * * @return {any} * * @throws {ConcernError} * @throws {Error} */ getProperty(concern, property) { // @ts-expect-error Can fail when dynamically setting property in concern instance... return this.get(concern)[property]; } } /** * Injection Error * * @see InjectionException */ class InjectionError extends ConcernError { /** * The target class * * @type {ConstructorLike|UsesConcerns} * * @protected * @readonly */ _target; /** * Create a new Injection Error instance * * @param {ConstructorLike | UsesConcerns} target * @param {ConcernConstructor | null} concern * @param {string} message * @param {ErrorOptions} [options] */ constructor(target, concern, message, options) { super(concern, message, options); configureCustomError(this); this._target = target; // Force set the target in the cause this.cause.target = target; } /** * The target class * * @readonly * * @returns {ConstructorLike | UsesConcerns} */ get target() { return this._target; } } /** * Alias Conflict Error * * @see AliasConflictException */ class AliasConflictError extends InjectionError { /** * The requested alias that conflicts with another alias * of the same name. * * @type {Alias} * * @readonly * @protected */ _alias; /** * the property key that the conflicting alias points to * * @type {PropertyKey} * * @readonly * @protected */ _key; /** * The source class (e.g. parent class) that defines that originally defined the alias * * @type {ConstructorLike | UsesConcerns} * * @readonly * @protected */ _source; /** * Create a new Alias Conflict Error instance * * @param {ConstructorLike | UsesConcerns} target * @param {ConcernConstructor} concern * @param {Alias} alias * @param {PropertyKey} key * @param {ConstructorLike | UsesConcerns} source * @param {ErrorOptions} [options] */ constructor(target, concern, alias, key, source, options) { const reason = (target === source) ? `Alias "${alias.toString()}" for property key "${key.toString()}" (concern ${getNameOrDesc(concern)}) conflicts with previous defined alias "${alias.toString()}", in target ${getNameOrDesc(target)}` : `Alias "${alias.toString()}" for property key "${key.toString()}" (concern ${getNameOrDesc(concern)}) conflicts with previous defined alias "${alias.toString()}" (defined in parent ${getNameOrDesc(source)}), in target ${getNameOrDesc(target)}`; super(target, concern, reason, options); configureCustomError(this); this._alias = alias; this._key = key; this._source = source; // Force set the properties in the cause this.cause.alias = alias; this.cause.source = source; } /** * The requested alias that conflicts with another alias * of the same name. * * @readonly * * @type {Alias} */ get alias() { return this._alias; } /** * the property key that the conflicting alias points to * * @readonly * * @type {PropertyKey} */ get key() { return this._key; } /** * The source class (e.g. parent class) that defines that originally defined the alias * * @readonly * * @type {ConstructorLike | UsesConcerns} */ get source() { return this._source; } } /** * Already Registered Error * * @see AlreadyRegisteredException */ class AlreadyRegisteredError extends InjectionError { /** * The source, e.g. a parent class, in which a concern class * was already registered. * * @type {ConstructorLike|UsesConcerns} * * @readonly * @protected */ _source; /** * Create a new "already registered" error instance * * @param {ConstructorLike | UsesConcerns} target * @param {ConcernConstructor} concern * @param {ConstructorLike | UsesConcerns} source * @param {string} [message] * @param {ErrorOptions} [options] */ constructor(target, concern, source, message, options) { const resolved = message || (target === source) ? `Concern ${getNameOrDesc(concern)} is already registered in class ${getNameOrDesc(target)}` : `Concern ${getNameOrDesc(concern)} is already registered in class ${getNameOrDesc(target)} (via parent class ${getNameOrDesc(source)})`; super(target, concern, resolved, options); configureCustomError(this); this._source = source; // Force set the source in the cause this.cause.source = source; } /** * The source, e.g. a parent class, in which a concern class * was already registered. * * @readonly * * @returns {ConstructorLike | UsesConcerns} */ get source() { return this._source; } } /** * Unsafe Alias Error */ class UnsafeAliasError extends InjectionError { /** * The alias that points to an "unsafe" property or method * * @type {PropertyKey} * * @protected * @readonly */ _alias; /** * The "unsafe" property or method that an alias points to * * @type {PropertyKey} * * @protected * @readonly */ _key; /** * Create a new Unsafe Alias Error instance * * @param {ConstructorLike | UsesConcerns} target * @param {ConcernConstructor} concern * @param {PropertyKey} alias * @param {PropertyKey} key * @param {string} [message] * @param {ErrorOptions} [options] */ constructor(target, concern, alias, key, message, options) { const reason = message || `Alias "${alias.toString()}" in target ${getNameOrDesc(target)} points to unsafe property or method: "${key.toString()}", in concern ${getNameOrDesc(concern)}.`; super(target, concern, reason, options); configureCustomError(this); this._alias = alias; this._key = key; // Force set the key and alias in the cause this.cause.alias = alias; this.cause.key = key; } /** * The alias that points to an "unsafe" property or method * * @readonly * * @type {PropertyKey} */ get alias() { return this._alias; } /** * The "unsafe" property or method that an alias points to * * @readonly * * @type {PropertyKey} */ get key() { return this._key; } } /** * In-memory cache of classes that are determined to be of the type * [Concern Constructor]{@link import('@aedart/contracts/support/concerns').ConcernConstructor}. * * @type {WeakSet<object>} */ const concernConstructorsCache = new WeakSet(); /** * Determine if given target is a * [Concern Constructor]{@link import('@aedart/contracts/support/concerns').ConcernConstructor}. * * @param {any} target * @param {boolean} [force=false] If `false` then cached result is returned if available. * * @returns {boolean} */ function isConcernConstructor(target, /* eslint-disable-line @typescript-eslint/no-explicit-any */ force = false) { if (!isset(target) || typeof target !== 'function') { return false; } if (!force && concernConstructorsCache.has(target)) { return true; } if (isSubclassOrLooksLike(target, AbstractConcern, ConcernClassBlueprint)) { concernConstructorsCache.add(target); return true; } return false; } /** * Determine if target a [Concern Configuration]{@link import('@aedart/contracts/support/concerns').Configuration} * * @param {object} target * @param {boolean} [force=false] If `false` then cached result is returned if available. * * @returns {boolean} */ function isConcernConfiguration(target, force = false) { // Note: A Concern Configuration only requires a `concern` property that // must be a Concern Constructor. return typeof target == 'object' && target !== null && Reflect.has(target, 'concern') && isConcernConstructor(target.concern, force); } /** * Determine if target a [Shorthand Concern Configuration]{@link import('@aedart/contracts/support/concerns').ShorthandConfiguration} * * @param {object} target * @param {boolean} [force=false] If `false` then cached result is returned if available. * * @returns {boolean} */ function isShorthandConfiguration(target, force = false) { // Note: A Concern Configuration (shorthand) only requires a // Concern Constructor as its first value. return Array.isArray(target) && target.length > 0 && isConcernConstructor(target[0], force); } /** * List of property keys that are considered "unsafe" to alias (proxy to) */ const UNSAFE_PROPERTY_KEYS = [ ...DANGEROUS_PROPERTIES, // ----------------------------------------------------------------- // // Defined by Concern interface / Abstract Concern: // ----------------------------------------------------------------- // // It is NOT possible, nor advised to attempt to alias a Concern's // constructor into a target class. 'constructor', // The concernOwner property (getter) shouldn't be aliased either 'concernOwner', // The static properties and methods (just in case...) PROVIDES, BEFORE, AFTER, // ----------------------------------------------------------------- // // Other properties and methods: // ----------------------------------------------------------------- // // In case that a concern class uses other concerns, prevent them // from being aliased. CONCERN_CLASSES, CONCERNS, ALIASES, ]; /** * Determine if given property key is considered "unsafe" * * @see UNSAFE_PROPERTY_KEYS * * @param {PropertyKey} key * * @returns {boolean} */ function isUnsafeKey(key) { return UNSAFE_PROPERTY_KEYS.includes(key); } /** * Concern Configuration Factory * * @see Factory */ class ConfigurationFactory { /** * Returns a new normalised concern configuration for given concern "entry" * * **Note**: _"normalised" in this context means:_ * * _**A**: If a concern class is given, then a new concern configuration made._ * * _**B**: If configuration is given, then a new concern configuration and given * configuration is merged into the new configuration._ * * _**C**: Configuration's `aliases` are automatically populated. When a concern * configuration is provided, its evt. aliases merged with the default ones, * unless `allowAliases` is set to `false`, in which case all aliases are removed._ * * @param {object} target * @param {ConcernConstructor | Configuration | ShorthandConfiguration} entry * * @returns {Configuration} * * @throws {InjectionException} If entry is unsupported or invalid */ make(target, entry) { // A) Make new configuration when concern class is given if (isConcernConstructor(entry)) { return this.makeConfiguration(entry); } if (isShorthandConfiguration(entry)) { entry = this.makeFromShorthand(entry); } // B) Make new configuration and merge provided configuration into it if (isConcernConfiguration(entry)) { // C) Merge given configuration with a new one, which has default aliases populated... const configuration = merge() .using({ overwriteWithUndefined: false }) .of(this.makeConfiguration(entry.concern), entry); // Clear all aliases, if not allowed if (!configuration.allowAliases) { configuration.aliases = Object.create(null); return configuration; } // Otherwise, filter off evt. "unsafe" keys. return this.removeUnsafeKeys(configuration); } // Fail if entry is neither a concern class nor a concern configuration const reason = `${getNameOrDesc(entry)} must be a valid Concern class or Concern Configuration`; throw new InjectionError(target, null, reason, { cause: { entry: entry } }); } /** * Casts the shorthand configuration to a configuration object * * @param {ShorthandConfiguration} config * * @return {Configuration} * * @protected */ makeFromShorthand(config) { const aliases = (typeof config[1] == 'object') ? config[1] : undefined; const allowAliases = (typeof config[1] == 'boolean') ? config[1] : undefined; return { concern: config[0], aliases: aliases, allowAliases: allowAliases }; } /** * Returns a new concern configuration for the given concern class * * @param {ConcernConstructor} concern * * @returns {Configuration} * * @protected */ makeConfiguration(concern) { return { concern: concern, aliases: this.makeDefaultAliases(concern), allowAliases: true }; } /** * Returns the default aliases that are provided by the given concern class * * @param {ConcernConstructor} concern * * @returns {Aliases} * * @protected */ makeDefaultAliases(concern) { const provides = concern[PROVIDES]() .filter((key) => !this.isUnsafe(key)); const aliases = Object.create(null); for (const key of provides) { // @ts-expect-error Type error is caused because we do not know the actual concern instance... aliases[key] = key; } return aliases; } /** * Removes evt. "unsafe" keys from configuration's aliases * * @param {Configuration} configuration * * @returns {Configuration} * * @protected */ removeUnsafeKeys(configuration) { const keys = Reflect.ownKeys(configuration.aliases); for (const key of keys) { if (this.isUnsafe(key)) { delete configuration.aliases[key]; } } return configuration; } /** * Determine if key is "unsafe" * * @param {PropertyKey} key * * @returns {boolean} * * @protected */ isUnsafe(key) { return isUnsafeKey(key); } } /** * Repository * * @see DescriptorsRepository */ class Repository { /** * In-memory cache property descriptors for target class and concern classes * * @type {WeakMap<ConstructorLike | UsesConcerns | ConcernConstructor, Record<PropertyKey, PropertyDescriptor>>} * * @protected */ _store; /** * Create new Descriptors instance */ constructor() { this._store = new WeakMap(); } /** * Returns property descriptors for given target class (recursively) * * @param {ConstructorLike | UsesConcerns | ConcernConstructor} target The target class, or concern class * @param {boolean} [force=false] If `true` then method will not return evt. cached descriptors. * @param {boolean} [cache=false] Caches the descriptors if `true`. * * @returns {Record<PropertyKey, PropertyDescriptor>} */ get(target, force = false, cache = false) { if (!force && this._store.has(target)) { return this._store.get(target); } const descriptors = getClassPropertyDescriptors(target, true); if (cache) { this._store.set(target, descriptors); } return descriptors; } /** * Caches property descriptors for target during the execution of callback. * * @param {ConstructorLike | UsesConcerns | ConcernConstructor} target The target class, or concern class * @param {() => any} callback Callback to invoke * @param {boolean} [forgetAfter=true] It `true`, cached descriptors are deleted after callback is invoked */ rememberDuring(target, callback, /* eslint-disable-line @typescript-eslint/no-explicit-any */ forgetAfter = true) { this.remember(target); const output = callback(); if (forgetAfter) { this.forget(target); } return output; } /** * Retrieves the property descriptors for given target and caches them * * @param {ConstructorLike | UsesConcerns | ConcernConstructor} target The target class, or concern class * @param {boolean} [force=false] If `true` then evt. previous cached result is not used. * * @returns {Record<PropertyKey, PropertyDescriptor>} */ remember(target, force = false) { return this.get(target, force, true); } /** * Deletes cached descriptors for target * * @param {ConstructorLike | UsesConcerns | ConcernConstructor} target * * @return {boolean} */ forget(target) { return this._store.delete(target); } /** * Clears all cached descriptors * * @return {this} */ clear() { this._store = new WeakMap(); return this; } } /** * Alias Descriptor Factory * * @see AliasDescriptorFactory */ class DescriptorFactory { /** * Returns a property descriptor to be used for an "alias" property or method in a target class * * @param {PropertyKey} key The property key in `source` concern * @param {ConcernConstructor} source The concern that holds the property key * @param {PropertyDescriptor} keyDescriptor Descriptor of `key` in `source` * * @returns {PropertyDescriptor} Descriptor to be used for defining alias in a target class */ make(key, source, keyDescriptor) { const proxy = Object.assign(Object.create(null), { configurable: keyDescriptor.configurable, enumerable: keyDescriptor.enumerable, // writable: keyDescriptor.writable // Do not specify here... }); // A descriptor can only have an accessor, a value or writable attribute. Depending on the "value" // a different kind of proxy must be defined. if (Reflect.has(keyDescriptor, 'value')) { // When value is a method... if (typeof keyDescriptor.value == 'function') { proxy.value = this.makeMethodProxy(key, source); return proxy; } // Otherwise, when value isn't a method, it can be a readonly property... proxy.get = this.makeGetPropertyProxy(key, source); // But, if the descriptor claims that its writable, then a setter must // also be defined. if (keyDescriptor.writable) { proxy.set = this.makeSetPropertyProxy(key, source); } return proxy; } // Otherwise, the property can a getter and or a setter... if (Reflect.has(keyDescriptor, 'get')) { proxy.get = this.makeGetPropertyProxy(key, source); } if (Reflect.has(keyDescriptor, 'set')) { proxy.set = this.makeSetPropertyProxy(key, source); } return proxy; } /** * Returns a new proxy "method" for given method in this concern * * @param {PropertyKey} method * @param {ConcernConstructor} concern * * @returns {(...args: any[]) => any} * * @protected */ makeMethodProxy(method, concern) { return function (...args /* eslint-disable-line @typescript-eslint/no-explicit-any */) { // @ts-expect-error This = concern instance return this[CONCERNS].call(concern, method, ...args); }; } /** * Returns a new proxy "get" for given property in this concern * * @param {PropertyKey} property * @param {ConcernConstructor} concern * * @returns {() => any} * * @protected */ makeGetPropertyProxy(property, concern) { return function () { // @ts-expect-error This = concern instance return this[CONCERNS].getProperty(concern, property); }; } /** * Returns a new proxy "set" for given property in this concern * * @param {PropertyKey} property * @param {ConcernConstructor} concern * * @returns {(value: any) => void} * * @protected */ makeSetPropertyProxy(property, concern) { return function (value /* eslint-disable-line @typescript-eslint/no-explicit-any */) { // @ts-expect-error This = concern instance this[CONCERNS].setProperty(concern, property, value); }; } } /** * A map of the concern owner instances and their concerns container * * @internal * * @type {WeakMap<Owner, Container>} */ const CONTAINERS_REGISTRY = new WeakMap(); /** * Concerns Injector * * @see Injector */ class ConcernsInjector { /** * The target class * * @template T = object * @type {T} * * @protected */ _target; /** * Concern Configuration Factory * * @type {Factory} * * @protected */ configFactory; /** * Descriptors Repository * * @type {DescriptorsRepository} * * @protected */ repository; /** * Alias Descriptor Factory * * @type {AliasDescriptorFactory} * * @protected */ descriptorFactory; /** * Create a new Concerns Injector instance * * @template T = object * * @param {T} target The target class that concerns must be injected into * @param {Factory} [configFactory] * @param {AliasDescriptorFactory} [descriptorFactory] * @param {DescriptorsRepository} [repository] */ constructor(target, configFactory, descriptorFactory, repository) { this._target = target; this.configFactory = configFactory || new ConfigurationFactory(); this.descriptorFactory = descriptorFactory || new DescriptorFactory(); this.repository = repository || new Repository(); } /** * The target class * * @template T = object * * @returns {T} */ get target() { return this._target; } /** * Injects concern classes into the target class and return the modified target. * * **Note**: _Method performs injection in the following way:_ * * _**A**: Defines the concern classes in target class, via {@link defineConcerns}._ * * _**B**: Defines a concerns container in target class' prototype, via {@link defineContainer}._ * * _**C**: Defines "aliases" (proxy properties and methods) in target class' prototype, via {@link defineAliases}._ * * @template T = object The target class that concern classes must be injected into * * @param {...ConcernConstructor | Configuration | ShorthandConfiguration} concerns List of concern classes / injection configurations * * @returns {UsesConcerns<T>} The modified target class * * @throws {InjectionException} */ inject(...concerns) { const configurations = this.normalise(concerns); const concernClasses = configurations.map((configuration) => configuration.concern); // Define the concerns classes in target. let modifiedTarget = this.defineConcerns(this.target, concernClasses); // Run before registration hook this.callBeforeRegistration(this.target, modifiedTarget[CONCERN_CLASSES]); // Define concerns, container and aliases modifiedTarget = this.defineAliases(this.defineContainer(modifiedTarget), configurations); // Run after registration hook this.callAfterRegistration(modifiedTarget, modifiedTarget[CONCERN_CLASSES]); // Clear evt. cached items. this.repository.clear(); // Finally, return the modified target return modifiedTarget; } /** * Defines the concern classes that must be used by the target class. * * **Note**: _Method changes the target class, such that it implements and respects the * {@link UsesConcerns} interface._ * * @template T = object * * @param {T} target The target class that must define the concern classes to be used * @param {Constructor<Concern>[]} concerns List of concern classes * * @returns {UsesConcerns<T>} The modified target class * * @throws {AlreadyRegisteredError} * @throws {InjectionError} */ defineConcerns(target, concerns) { const registry = this.resolveConcernsRegistry(target, concerns); return this.definePropertyInTarget(target, CONCERN_CLASSES, { get: function () { return registry; } }); } /** * Defines a concerns {@link Container} in target class' prototype. * * **Note**: _Method changes the target class, such that it implements and respects the * [Owner]{@link import('@aedart/contracts/support/concerns').Owner} interface!_ * * @template T = object * * @param {UsesConcerns<T>} target The target in which a concerns container must be defined * * @returns {UsesConcerns<T>} The modified target class * * @throws {InjectionError} If unable to define concerns container in target class */ defineContainer(target) { const concerns = target[CONCERN_CLASSES]; this.definePropertyInTarget(target.prototype, CONCERNS, { get: function () { // @ts-expect-error This = target instance. TypeScript just doesn't understand context here... const instance = this; /* eslint-disable-line @typescript-eslint/no-this-alias */ if (!CONTAINERS_REGISTRY.has(instance)) { CONTAINERS_REGISTRY.set(instance, new ConcernsContainer(instance, concerns)); } return CONTAINERS_REGISTRY.get(instance); } }); return target; } /** * Defines "aliases" (proxy properties and methods) in target class' prototype, such that they * point to the properties and methods available in the concern classes. * * **Note**: _Method defines each alias using the {@link defineAlias} method!_ * * @template T = object * * @param {UsesConcerns<T>} target The target in which "aliases" must be defined in * @param {Configuration[]} configurations List of concern injection configurations * * @returns {UsesConcerns<T>} The modified target class * * @throws {AliasConflictError} If case of alias naming conflicts. * @throws {InjectionError} If unable to define aliases in target class. */ defineAliases(target, configurations) { const applied = []; // Obtain previous applied aliases, form the target's parents. const appliedByParents = this.getAllAppliedAliases(target); this.repository.rememberDuring(target, () => { for (const configuration of configurations) { if (!configuration.allowAliases) { continue; } this.repository.rememberDuring(configuration.concern, () => { // Process the configuration aliases and define them. Merge returned aliases with the // applied aliases for the target class. const newApplied = this.processAliases(target, configuration, applied, appliedByParents); applied.push(...newApplied); }); } }); // (Re)define the "ALIASES" static property in target. return this.definePropertyInTarget(target, ALIASES, { get: function () { return applied; } }); } /** * Defines an "alias" (proxy property or method) in target class' prototype, which points to a property or method * in the given concern. * * **Note**: _Method will do nothing, if a property or method already exists in the target class' prototype * chain, with the same name as given "alias"._ * * @template T = object * * @param {UsesConcerns<T>} target The target in which "alias" must be defined in * @param {PropertyKey} alias Name of the "alias" in the target class (name of the proxy property or method) * @param {PropertyKey} key Name of the property or method that the "alias" points to, in the concern class (`source`) * @param {Constructor} source The source concern class that contains the property or methods that is pointed to (`key`) * * @returns {boolean} `true` if "alias" was in target class. `false` if a property or method already exists in the * target, with the same name as the "alias". * * @throws {UnsafeAliasError} If an alias points to an "unsafe" property or method in the source concern class. * @throws {InjectionException} If unable to define "alias" in target class. */ defineAlias(target, alias, key, source) { // Abort if key is "unsafe" if (this.isUnsafe(key)) { throw new UnsafeAliasError(target, source, alias, key); } // Skip if a property key already exists with same name as the "alias" const targetDescriptors = this.repository.get(target); if (Reflect.has(targetDescriptors, alias)) { return false; } // Abort if unable to find descriptor that matches given key in concern class. const concernDescriptors = this.repository.get(source); if (!Reflect.has(concernDescriptors, key)) { throw new InjectionError(target, source, `"${key.toString()}" does not exist in concern ${getNameOrDesc(source)} - attempted aliased as "${alias.toString()}" in target ${getNameOrDesc(target)}`); } // Define the proxy property or method, using the concern's property descriptor to determine what must be defined. const proxy = this.descriptorFactory.make(key, source, concernDescriptors[key]); return this.definePropertyInTarget(target.prototype, alias, proxy) !== undefined; } /** * Normalises given concerns into a list of concern configurations * * @param {(ConcernConstructor | Configuration | ShorthandConfiguration)[]} concerns * * @returns {Configuration[]} * * @throws {InjectionError} */ normalise(concerns) { const output = []; for (const entry of concerns) { output.push(this.normaliseEntry(entry)); } return output; } /***************************************************************** * Internals ****************************************************************/ /** * Resolves the concern classes to be registered (registry), for the given target * * **Note**: _Method ensures that if target already has concern classes defined, then those * are merged into the resulting list._ * * @param {object} target * @param {ConcernConstructor[]} concerns * * @returns {ConcernConstructor[]} Registry with concern classes that are ready to be registered in given target * * @throws {AlreadyRegisteredError} If a concern has already been registered in the target * * @protected */ resolveConcernsRegistry(target, concerns) { // Obtain evt. previous defined concern classes in target. const alreadyRegistered = (Reflect.has(target, CONCERN_CLASSES)) ? target[CONCERN_CLASSES] : []; // Make a registry of concern classes to be registered in given target const registry = [...alreadyRegistered]; for (const concern of concerns) { // Fail if concern is already registered if (registry.includes(concern)) { const source = this.findSourceOf(concern, target, true); throw new AlreadyRegisteredError(target, concern, source); } registry.push(concern); } return registry; } /** * Normalises the given entry into a concern configuration * * @param {ConcernConstructor | Configuration | ShorthandConfiguration} entry * * @returns {Configuration} * * @throws {InjectionError} * * @protected */ normaliseEntry(entry) { return this.configFactory.make(this.target, entry); } /** * Defines a property in given target * * @template T = object * * @param {T} target * @param {PropertyKey} property * @param {PropertyDescriptor} descriptor * @param {string} [failMessage] * * @returns {T} * * @throws {InjectionError} * * @protected */ definePropertyInTarget(target, property, descriptor, failMessage) { const wasDefined = Reflect.defineProperty(target, property, descriptor); if (!wasDefined) { const reason = failMessage || `Unable to define "${property.toString()}" property in target ${getNameOrDesc(target)}`; throw new InjectionError(target, null, reason); } return target; } /** * Find the source class where given concern is registered * * @param {ConcernConstructor} concern * @param {object} target * @param {boolean} [includeTarget=false] * * @returns {object | null} The source class, e.g. parent of target class, or `null` if concern is not registered * in target or target's parents. * * @protected */ findSourceOf(concern, target, includeTarget = false) { const parents = getAllParentsOfClass(target, includeTarget).reverse(); for (const parent of parents) { if (Reflect.has(parent, CONCERN_CLASSES) && parent[CONCERN_CLASSES].includes(concern)) { return parent; } } return null; } /** * Returns all applied aliases for given target and its parent classes * * @param {UsesConcerns} target * @param {boolean} [includeTarget=false] * * @return {Map<Alias, UsesConcerns>} * * @protected */ getAllAppliedAliases(target, includeTarget = false) { const output = new Map(); const parents = getAllParentsOfClass(target, includeTarget).reverse(); for (const parent of parents) { if (!Reflect.has(parent, ALIASES)) { continue; } parent[ALIASES].forEach((alias) => { output.set(alias, parent); }); } return output; } /** * Processes given configuration's aliases by defining them * * @param {UsesConcerns} target * @param {Configuration} configuration * @param {Alias[]} applied * @param {Map<Alias, UsesConcerns>} appliedByParents * * @return {Alias[]} New applied aliases (does not include aliases from `applied` argument) * * @protected * * @throws {AliasConflictError} */ processAliases(target, configuration, applied, appliedByParents) { // Aliases that have been applied by this method... const output = []; // Already applied aliases in target + aliases applied by this method const alreadyApplied = [...applied]; const aliases = configuration.aliases; const properties = Reflect.ownKeys(aliases); for (const key of properties) { const alias = aliases[key]; // Ensure that alias does not conflict with previous applied aliases. this.assertAliasDoesNotConflict(target, configuration.concern, alias, key, alreadyApplied, // Applied aliases in this context... appliedByParents); // Define the alias in target and mark it as "applied" this.defineAlias(target, alias, key, configuration.concern); alreadyApplied.push(alias); output.push(alias); } return output; } /** * Assert that given alias does not conflict with an already applied alias * * @param {UsesConcerns} target * @param {ConcernConstructor} concern * @param {Alias} alias * @param {PropertyKey} key * @param {Alias} applied Aliases that are applied directly in the target class * @param {Map<Alias, UsesConcerns>} appliedByParents Aliases that are applied in target's parents * * @protected * * @throws {AliasConflictError} */ assertAliasDoesNotConflict(target, concern, alias, key, applied, appliedByParents) { const isAppliedByTarget = applied.includes(alias); const isAppliedByParents = appliedByParents.has(alias); if (isAppliedByTarget || isAppliedByParents) { const source = (isAppliedByTarget) ? target : appliedByParents.get(alias); throw new AliasConflictError(target, concern, alias, key, source); } } /** * Determine if key is "unsafe" * * @param {PropertyKey} key * * @returns {boolean} * * @protected */ isUnsaf