@aedart/support
Version:
The Ion support package
1,545 lines (1,525 loc) • 56.1 kB
JavaScript
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