UNPKG

@aedart/support

Version:

The Ion support package

1,027 lines (1,006 loc) 31.9 kB
import { mergeKeys, empty, toWeakRef } from '@aedart/support/misc'; import { Kind, METADATA, TARGET_METADATA } from '@aedart/contracts/support/meta'; import { get, has, set, merge } from '@aedart/support/objects'; import { configureCustomError } from '@aedart/support/exceptions'; import { FUNCTION_PROTOTYPE } from '@aedart/contracts/support/reflections'; /** * @aedart/support * * BSD-3-Clause, Copyright (c) 2023-present Alin Eugen Deac <aedart@gmail.com>. */ /** * Meta Entry * * @see MetaEntry */ class Entry { /** * Key or path identifier * * @type {Key} */ key; /** * Value to store * * @type {unknown} */ value; /** * Create a new Meta Entry instance * * @param {Key} key * @param {unknown} value */ constructor(key, value) { this.key = key; this.value = value; } /** * Create a new Meta Entry instance * * @param {Key} key * @param {unknown} value * * @return {this|MetaEntry} * * @static */ static make(key, value) { return new this(key, value); } /** * Resolves given key and returns a new Meta Entry instance * * @param {MetaTargetContext} targetContext * @param {Key | MetaCallback} key * @param {any} [value] * * @return {this|MetaEntry} * * @static */ static resolve(targetContext, key, value) { if (typeof key === 'function') { return key(targetContext.target, targetContext.context, targetContext.owner); } return this.make(key, value); } /** * Resolves given key-value pair and returns a new Meta Entry instance, with prefixed key * * @param {MetaTargetContext} targetContext * @param {Key} prefixKey * @param {Key|MetaCallback} key * @param {unknown} [value] * * @return {this|MetaEntry} * * @static */ static resolveWithPrefix(targetContext, prefixKey, key, value) { const entry = this.resolve(targetContext, key, value); entry.key = mergeKeys(prefixKey, entry.key); return entry; } } /** * Meta Target Context * * @see MetaTargetContext */ class TargetContext { /** * The class that owns the meta * * @type {object} */ owner; /** * "This" argument * * @type {any} */ thisArg; /* eslint-disable-line @typescript-eslint/no-explicit-any */ /** * The target class, field, method... that is being decorated * * @type {object} */ target; /** * Decorator context * * @type {Context} */ context; /** * Create a new Meta Target Context instance * * @param {object} owner * @param {any} thisArg * @param {object} target * @param {Context} context */ constructor(owner, thisArg, /* eslint-disable-line @typescript-eslint/no-explicit-any */ target, context) { this.owner = owner; this.thisArg = thisArg; this.target = target; this.context = context; } /** * Returns a new Meta Target Context instance * * @param {object} owner * @param {any} thisArg * @param {object} target * @param {Context} context * * @return {this|MetaTargetContext} * * @static */ static make(owner, thisArg, /* eslint-disable-line @typescript-eslint/no-explicit-any */ target, context) { return new this(owner, thisArg, target, context); } /** * Resolves target's owner and returns a new Meta Target Instance * * @param {object} target * @param {object} thisArg * @param {Context} context * * @return {this|MetaTargetContext} * * @static */ static resolveOwner(target, thisArg, context) { // Resolve the target's "owner" // @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#class_context const owner = (context.kind === 'class' || context.static) ? thisArg // When target is not static, then it's obtainable via prototype : Reflect.getPrototypeOf(thisArg).constructor; return this.make(owner, thisArg, target, context); } } /** * Fallback registry that contains writable metadata (`context.metadata`). * * This registry is only to be used when the system / browser does not support * `context.metadata`. * * **Warning**: _This registry is **NOT intended** to be available for writing, * outside the scope of a "meta" decorator._ * * @type {WeakMap<object, MetadataRecord>} */ const registry = new WeakMap(); /** * Meta Repository * * @see Repository */ class MetaRepository { /** * The owner class * * @type {object} * * @protected * @readonly */ _owner; /** * Create a new Meta Repository instance * * @param {object} owner */ constructor(owner) { this._owner = owner; } /** * Create a new Meta Repository instance * * @param {object} owner * * @return {this|Repository} */ static make(owner) { return new this(owner); } /** * The owner class * * @type {object} */ get owner() { return this._owner; } /** * Set value for given key * * **Caution**: _Method is intended to be invoked inside a decorator!_ * * @param {object} target Decorator target, e.g. class, field, method...etc * @param {Context} context * @param {Key | MetaCallback} key * @param {any} [value] Value to be stored. Ignored if `key` argument is a callback. * * @return {DecoratorResult} */ set(target, context, key, value /* eslint-disable-line @typescript-eslint/no-explicit-any */) { const save = this.save.bind(this); const resolveTargetContext = this.resolveMetaTargetContext.bind(this); switch (context.kind) { // For a class target, the meta can be added directly. case 'class': return save(resolveTargetContext(target, target, context), key, value); // When a field is decorated, we need to rely on the value initialisation to // obtain correct owner... case 'field': return function (initialValue) { save( // @ts-expect-error: "this" corresponds to class instance. resolveTargetContext(target, this, context), key, value); return initialValue; }; // For all other kinds of targets, we need to use the initialisation logic // to obtain the correct owner. This is needed for current implementation // and until the TC39 proposal is approved and implemented. // @see https://github.com/tc39/proposal-decorator-metadata default: context.addInitializer(function () { save( // @ts-expect-error: "this" corresponds to class instance. resolveTargetContext(target, this, context), key, value); }); return; } } /** * Get value for given key * * @template T Return value type * @template D=undefined Type of default value * * @param {Key} key * @param {D} [defaultValue] * * @return {T | D} */ get(key, defaultValue) { return get(this.all(), key, defaultValue); } /** * Determine if value exists for key * * @param {Key} key */ has(key) { return has(this.all(), key); } /** * Get all metadata * * @return {MetadataRecord} */ all() { return this.owner[METADATA] || {}; } /** * Save metadata * * @param {MetaTargetContext} targetContext * @param {Key | MetaCallback} key * @param {any} [value] * * @return {void} * * @protected */ save(targetContext, key, value) { const context = targetContext.context; const metadata = this.resolveMetadataRecord(targetContext.owner, context); // Whenever the key is a "meta" callback, for any other kind than a class or a field, // we overwrite the "context.addInitializer" method, so init callbacks can be invoked // manually after meta has been defined. const callbacks = []; if (typeof key === 'function' && (context.kind !== 'class' && context.kind !== 'field')) { context.addInitializer = (callback) => { callbacks.push(callback); }; } // Resolve meta entry (key and value). When a "meta callback" is given, it is invoked // here. Afterward, set the resolved key-value. const entry = this.resolveEntry(targetContext, key, value); set(metadata, entry.key, entry.value); // When the metadata originates from the decorator context, we can stop here. // Otherwise, we need to save it in the internal registry... if (this.useMetadataFromContext(context)) { this.runInitCallbacks(targetContext, callbacks); return; } registry.set(targetContext.owner, metadata); // Lastly, define the owner[Symbol.metadata] property (only done once for the owner). // In case that owner is a subclass, then this ensures that it "overwrites" the parent's // [Symbol.metadata] property and offers its own version thereof. this.defineMetadataProperty(targetContext.owner); // Invoke evt. init callbacks... this.runInitCallbacks(targetContext, callbacks); } /** * Defines the {@link METADATA} property in given owner * * @param {object} owner * * @return {void} * * @protected */ defineMetadataProperty(owner) { Reflect.defineProperty(owner, METADATA, { get: () => { // To ensure that metadata cannot be changed outside the scope and context of a // meta decorator, a deep clone of the record is returned here. return merge() .using({ arrayMergeOptions: { transferFunctions: true } }) .of(Object.create(null), registry.get(owner) || Object.create(null)); }, // Ensure that the property cannot be deleted configurable: false }); } /** * Invokes the given initialisation callbacks * * @param {MetaTargetContext} targetContext * @param {InitializerCallback[]} callbacks * * @return {void} * * @protected */ runInitCallbacks(targetContext, callbacks) { callbacks.forEach((callback) => { callback.call(targetContext.thisArg); }); } /** * Determine if metadata record can be used from decorator context * * @param {Context} context * * @return {boolean} * * @protected */ useMetadataFromContext(context) { return Reflect.has(context, 'metadata') && typeof context.metadata == 'object'; } /** * Resolve the metadata record that must be used when writing new metadata * * @param {object} owner * @param {Context} context * * @protected */ resolveMetadataRecord(owner, context) { if (this.useMetadataFromContext(context)) { return context.metadata; } // Obtain record from registry, or create new empty object. let metadata = registry.get(owner) ?? Object.create(null); // In case that the owner has Symbol.metadata defined (e.g. from base class), // then merge it current metadata. This ensures that inheritance works as // intended, whilst a base class still keeping its original metadata. if (Reflect.has(owner, METADATA)) { metadata = Object.assign(metadata, owner[METADATA]); } return metadata; } /** * Resolve the "meta" entry's key and value * * @param {MetaTargetContext} targetContext * @param {Key | MetaCallback} key * @param {any} [value] * * @return {MetaEntry} * * @protected */ resolveEntry(targetContext, key, value) { return Entry.resolve(targetContext, key, value); } /** * Resolve the meta target context * * **Caution**: _`thisArg` should only be set from an "addInitializer" callback * function, via decorator context._ * * @param {object} target Target the is being decorated * @param {object} thisArg The bound "this" value, from "addInitializer" callback function. * @param {Context} context * * @return {MetaTargetContext} * * @protected */ resolveMetaTargetContext(target, thisArg, context) { return TargetContext.resolveOwner(target, thisArg, context); } } /** * Returns [Meta Repository]{@link Repository} for given owner * * @param {object} owner * * @return {Repository} */ function getMetaRepository(owner) { return MetaRepository.make(owner); } /** * Determine if owner has metadata for given key * * @param {object} owner * @param {Key} key * * @return {boolean} */ function hasMeta(owner, key) { return getMetaRepository(owner).has(key); } /** * Returns all registered metadata for given target, if available * * @see getMeta * * @param {object} owner Class that owns metadata * * @returns {Readonly<MetadataRecord>} */ function getAllMeta(owner) { return getMetaRepository(owner).all(); } /** * Return metadata that matches key, for given target * * @see getAllMeta * * @template T * @template D=unknown Type of default value * * @param {object} owner Class that owns metadata * @param {Key} key Key or path identifier * @param {D} [defaultValue=undefined] Default value to return, in case key does not exist * * @returns {T | D | undefined} */ function getMeta(owner, key, defaultValue) { return getMetaRepository(owner).get(key, defaultValue); } /** * Store value as metadata, for given key. * * **Note**: _Method is intended to be used as a decorator!_ * * @example * ```js * @meta('my-key', 'my-value) * class A {} * * getMeta(A, 'my-key'); // 'my-value' * ``` * * @see getMeta * @see getAllMeta * * @param {Key | MetaCallback} key Key or path identifier. If callback is given, * then its resulting [MetaEntry]{@link import('@aedart/contracts/support/meta').MetaEntry}'s `key` * and `value` are stored. * @param {unknown} [value] Value to store. Ignored if `key` argument is a callback. * * @returns {Decorator} */ function meta(key, value) { return (target, context) => { return getMetaRepository({}).set(target, context, key, value); }; } /** * Meta Error * * @see MetaException */ class MetaError extends Error { /** * Create a new Meta Error instance * * @param {string} message * @param {ErrorOptions} [options] */ constructor(message, options) { super(message, options); configureCustomError(this); } } /** * Element Kind Identifiers * * @type {Record<string, symbol>} */ const ELEMENT_KIND_IDENTIFIERS = { [Kind.class]: Symbol('class'), [Kind.method]: Symbol('methods'), [Kind.getter]: Symbol('getters'), [Kind.setter]: Symbol('setters'), [Kind.field]: Symbol('fields'), [Kind.accessor]: Symbol('accessors'), }; /** * Static element identifier * * @type {symbol} */ const STATIC_IDENTIFIER = Symbol('static'); /** * Registry that contains the target object (e.g. a class or a method), * along with a "meta address" that points to where the actual metadata * is located. * * @see {MetaAddress} * * @type {WeakMap<object, MetaAddress>} */ const addressRegistry = new WeakMap(); /** * Target Meta Repository * * @see TargetRepository */ class TargetMetaRepository { /** * Returns a new Target Meta Repository * * @return {this|TargetRepository} */ static make() { return new this(); } /** * Set value for given key, and associates it directly with the target * * **Caution**: _Method is intended to be invoked inside a decorator!_ * * @param {object} target Class or class method target * @param {Context} context * @param {Key | MetaCallback} key * @param {any} [value] Value to be stored. Ignored if `key` argument is a callback. * * @return {ClassDecoratorResult | ClassMethodDecoratorResult} * * @throws {MetaError} */ set(target, context, key, value /* eslint-disable-line @typescript-eslint/no-explicit-any */) { return this.makeRepository(target) .set(target, context, this.makeMetaCallback(key, value)); } /** * Get value for given key * * @template T Return value type * @template D=undefined Type of default value * * @param {object} target Class or class method target * @param {Key} key * @param {D} [defaultValue] * * @return {T | D} */ get(target, key, defaultValue) { // Find "target" meta address for given target object // or return the default value if none is found. const address = this.find(target); if (address === undefined) { return defaultValue; } // When an address was found, we must ensure that the meta // owner class still exists. If not, return default value. const owner = address[0]?.deref(); if (owner === undefined) { return defaultValue; } // Finally, use getMeta to obtain desired key. const prefixKey = address[1]; return this.makeRepository(owner).get(mergeKeys(prefixKey, key), defaultValue); } /** * Determine if value exists for key * * @param {object} target Class or class method target * @param {Key} key * * @return {boolean} */ has(target, key) { const address = this.find(target); if (address === undefined) { return false; } const owner = address[0]?.deref(); if (owner === undefined) { return false; } return this.makeRepository(owner).has(mergeKeys(address[1], key)); } /** * Determine there is any metadata associated with target * * @param {object} target * * @return {boolean} */ hasAny(target) { const address = this.find(target); return address !== undefined && address[0]?.deref() !== undefined; } /** * Inherit "target" meta from a base class. * * **Note**: _Method is intended to be used as a decorator for static class methods, * in situations where you overwrite static methods and wish to inherit * "target" meta from the parent method._ * * @param {object} target * @param {Context} context * * @return {ClassMethodDecoratorResult} * * @throws {MetaError} */ inherit(target, context) { const makePrefixKey = this.makePrefixKey.bind(this); const makeRepository = this.makeRepository.bind(this); return this.set(target, context, (target, context, owner) => { const name = context.name?.toString() || 'unknown'; // Obtain owner's parent or fail if no parent is available. if (Reflect.getPrototypeOf(owner) === null) { throw new MetaError(`Unable to inherit target meta for ${name}: Owner object does not have a parent class.`, { cause: { target: target, context: context } }); } // Obtain "target" meta from parent, so we can obtain a meta entry and re-set it, // which will cause the @targetMeta() and @meta() decorators to do the rest. const parent = Reflect.getPrototypeOf(owner); const prefixKey = makePrefixKey(context); const targetMeta = makeRepository(parent) .get(prefixKey); // Abort in case that there is nothing to inherit... if (empty(targetMeta)) { throw new MetaError(`Unable to inherit target meta for ${name}: parent ${context.kind} does not have target meta.`, { cause: { target: target, context: context } }); } // Get the first key-value pair (meta entry), from the "target" metadata const key = Reflect.ownKeys(targetMeta)[0]; const value = targetMeta[key]; // Finally, (re)set the meta-entry. This is needed so that we do not add a "null" entry, // other kind of useless metadata. All other meta entries are automatically handled by // the @meta() decorator. return Entry.make(key, value); }); } /** * Find the address where "target" meta is stored for the given target * * @param {object} target * * @return {MetaAddress|undefined} */ find(target) { // Return target meta address, if available for target... let address = addressRegistry.get(target); if (address !== undefined) { return address; } // When no address is found for the target, and when a class instance is given, the actual // target must be changed to the constructor if (typeof target == 'object' && Reflect.has(target, 'constructor')) { if (addressRegistry.has(target.constructor)) { return addressRegistry.get(target.constructor); } // Otherwise, change the target to the constructor. target = target.constructor; } // When no address is found and the target is a class with metadata, // then attempt to find address via its parent. let parent = target; while (address === undefined && METADATA in parent) { parent = Reflect.getPrototypeOf(parent); if (parent === null || parent === FUNCTION_PROTOTYPE) { break; } // Attempt to get meta address from parent. address = addressRegistry.get(parent); } // Recursive version... // if (address === undefined && METADATA in target) { // const parent: object | null = Reflect.getPrototypeOf(target); // // if (parent !== null && parent !== Reflect.getPrototypeOf(Function)) { // return this.find(parent); // } // } return address; } /** * Returns a new meta callback for given key-value pair. * * **Note**: _Callback is responsible for associating key-value pair with class * or class method._ * * @param {Key | MetaCallback} key * @param {any} [value] * * @protected */ makeMetaCallback(key, value /* eslint-disable-line @typescript-eslint/no-explicit-any */) { const makePrefixKey = this.makePrefixKey.bind(this); const makeMetaTargetContext = this.makeMetaTargetContext.bind(this); const makeMetaEntry = this.makeMetaEntry.bind(this); const makeMetaAddress = this.makeMetaAddress.bind(this); const save = this.save.bind(this); return (target, context, owner) => { // Prevent unsupported kinds from being decorated... if (!['class', 'method'].includes(context.kind)) { throw new MetaError(`@targetMeta() does not support "${context.kind}" (only "class" and "method" are supported)`, { cause: { target: target, context: context } }); } // Make a "prefix" key, to be used in the final meta entry, // and a meta address entry. const prefixKey = makePrefixKey(context); const address = makeMetaAddress(owner, prefixKey); // Save the address in the registry... save(target, address); // When a method in a base class is decorated, but the method is overwritten in // a subclass, then we must store another address entry, using the owner's // method in the registry. This will allow inheriting the meta, but will NOT work // on static methods. if (context.kind == 'method' && !context.static && Reflect.has(owner, 'prototype')) { // @ts-expect-error: TS2339 Owner has a prototype at this point, but Reflect.getPrototypeOf() returns undefined here! const proto = (owner).prototype; if (proto !== undefined && typeof proto[context.name] == 'function' && proto[context.name] !== target) { save(proto[context.name], address); } } // Finally, return the meta key-value pair that will be stored in the owner's metadata. return makeMetaEntry(makeMetaTargetContext(owner, null, target, context), prefixKey, key, value); }; } /** * Save metadata address in internal registry, for given target * * @param {object} target The target metadata is to be associated with * @param {MetaAddress} address Location where actual metadata is to be found * * @return {void} * * @protected */ save(target, address) { addressRegistry.set(target, address); } /** * Returns a "prefix" key (path) where "target" metadata must be stored * * @param {Context} context * * @return {Key} * * @throws {MetaError} If {@link Context.kind} is not supported * * @protected */ makePrefixKey(context) { if (!Reflect.has(Kind, context.kind)) { throw new MetaError(`context.kind: "${context.kind}" is unsupported`, { cause: { context: context } }); } const output = [ TARGET_METADATA, ELEMENT_KIND_IDENTIFIERS[Kind[context.kind]] ]; // Ensures that we do not overwrite static / none-static elements with same name! if (context.kind !== 'class' && context.static) { output.push(STATIC_IDENTIFIER); } // "anonymous" is for anonymous classes (they do not have a name) const name = context.name ?? 'anonymous'; output.push(name); return output; } /** * Returns a new Meta Target Context * * @param {object} owner * @param {any} thisArg * @param {object} target * @param {Context} context * * @return {MetaTargetContext} * * @protected */ makeMetaTargetContext(owner, thisArg, /* eslint-disable-line @typescript-eslint/no-explicit-any */ target, context) { return TargetContext.make(owner, thisArg, target, context); } /*** * Returns a new metadata entry, with prefixed key * * @param {MetaTargetContext} targetContext * @param {Key} prefixKey * @param {Key|MetaCallback} key User provided key or callback * @param {unknown} [value] Value to store. Ignored if `key` argument is * a callback. * * @return {MetaEntry} * * @protected */ makeMetaEntry(targetContext, prefixKey, key, value) { return Entry.resolveWithPrefix(targetContext, prefixKey, key, value); } /** * Returns a new meta address * * @param {object|MetaOwnerReference} owner * @param {Key} key * * @return {MetaAddress} * * @protected */ makeMetaAddress(owner, key) { return [ toWeakRef(owner), key ]; } /** * Returns a new Repository instance for given owner * * @param {object} owner * * @return {Repository} * * @protected */ makeRepository(owner) { return getMetaRepository(owner); } } /** * Returns a new Target Meta Repository * * @return {TargetRepository} */ function getTargetMetaRepository() { return TargetMetaRepository.make(); } /** * Determine there is any metadata associated with target * * @param {object} target * * @return {boolean} */ function hasAnyTargetMeta(target) { return getTargetMetaRepository().hasAny(target); } /** * Return metadata that matches key, that belongs to the given target * * **Note**: _Unlike the {@link getMeta} method, this method does not require you * to know the owner object (e.g. the class) that holds metadata, provided * that metadata has been associated with given target, via {@link targetMeta}._ * * @see targetMeta * @see getMeta * * @template T * @template D=unknown Type of default value * * @param {object} target Class or method that owns metadata * @param {Key} key Key or path identifier * @param {D} [defaultValue=undefined] Default value to return, in case key does not exist * * @returns {T | D | undefined} */ function getTargetMeta(target, key, defaultValue) { return getTargetMetaRepository().get(target, key, defaultValue); } /** * Determine if value exists for key, in given target * * @param {object} target * @param {Key} key * * @return {boolean} */ function hasTargetMeta(target, key) { return getTargetMetaRepository().has(target, key); } /** * Inherit "target" meta from a base class. * * **Note**: _Method is intended to be used as a static method decorator!_ * * **Note**: _To be used in situations where you overwrite static methods and wish to inherit * "target" meta from the parent method._ * * @see targetMeta * * @example * ```ts * class A { * @targetMeta('bar', 'zaz') * static foo() {} * } * * class B extends A { * * @inheritTargetMeta() * static foo() { * // ...overwritten static method...// * } * } * * getTargetMeta(B.foo, 'bar'); // 'zaz' * ``` * * @returns {ClassMethodDecorator} * * @throws {MetaError} When decorated element's owner class has no parent, or when no "target" metadata available * on parent element. */ function inheritTargetMeta() { return (target, context) => { return getTargetMetaRepository().inherit(target, context); }; } /** * Stores value for given key, and associates it directly with the target * * **Note**: _Method is intended to be used as a class or method decorator!_ * * @example * ```js * class A { * @targetMeta('my-key', 'my-value') * foo() {} * } * * const a: A = new A(); * getTargetMeta(a.foo, 'my-key'); // 'my-value' * ``` * * @see getTargetMeta * * @param {Key | MetaCallback} key Key or path identifier. If callback is given, * then its resulting [MetaEntry]{@link import('@aedart/contracts/support/meta').MetaEntry}'s `key` * and `value` are stored. * @param {unknown} [value] Value to store. Ignored if `key` argument is * a callback. * @returns {ClassDecorator | ClassMethodDecorator} * * @throws {MetaError} When decorated element is not supported */ function targetMeta(key, value) { return (target, context) => { return getTargetMetaRepository().set(target, context, key, value); }; } export { ELEMENT_KIND_IDENTIFIERS, Entry, MetaError, MetaRepository, STATIC_IDENTIFIER, TargetContext, TargetMetaRepository, getAllMeta, getMeta, getMetaRepository, getTargetMeta, getTargetMetaRepository, hasAnyTargetMeta, hasMeta, hasTargetMeta, inheritTargetMeta, meta, targetMeta };