From c1b01a3a9dd5fc6c033632f61100d53cfdbc8c07 Mon Sep 17 00:00:00 2001 From: alin Date: Wed, 31 Jan 2024 11:39:03 +0100 Subject: [PATCH] Add targetMeta() and getTargetMeta() The new decorate is based on the experimental @reflect() decorator (which will be removed) and is able to associate a target with specific metadata, by storing a reference address to the owning class and metadata key (prefix), where it can be retrieved again. It works for classes and class methods, but sadly some inheritance will not work as desired for static methods, due to no late "this" binding in static blocks or members. --- packages/contracts/src/support/meta/index.ts | 7 + packages/contracts/src/support/meta/types.ts | 16 +- packages/support/src/meta/index.ts | 3 +- packages/support/src/meta/targetMeta.ts | 252 +++++++++++++++ .../packages/support/meta/targetMeta.test.js | 293 ++++++++++++++++++ 5 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 packages/support/src/meta/targetMeta.ts create mode 100644 tests/browser/packages/support/meta/targetMeta.test.js diff --git a/packages/contracts/src/support/meta/index.ts b/packages/contracts/src/support/meta/index.ts index d239817b..91815591 100644 --- a/packages/contracts/src/support/meta/index.ts +++ b/packages/contracts/src/support/meta/index.ts @@ -24,6 +24,13 @@ export const SUPPORT_META: unique symbol = Symbol('@aedart/contracts/support/met */ export const METADATA: unique symbol = Symbol.for('metadata'); +/** + * Symbol used for "target" metadata + * + * @type {symbol} + */ +export const TARGET_METADATA: unique symbol = Symbol('target_metadata'); + export { type ClassContext, type MethodContext, diff --git a/packages/contracts/src/support/meta/types.ts b/packages/contracts/src/support/meta/types.ts index 3cdf8d30..1d943e1b 100644 --- a/packages/contracts/src/support/meta/types.ts +++ b/packages/contracts/src/support/meta/types.ts @@ -4,6 +4,7 @@ import SetterContext from "./SetterContext"; import FieldContext from "./FieldContext"; import AccessorContext from "./AccessorContext"; import MetaEntry from "./MetaEntry"; +import type { Key } from "@aedart/contracts/support"; /** * Decorator context types for any decorator @@ -31,4 +32,17 @@ export type MetaCallback = (target: object, context: Context, owner: object) => /** * Metadata Record */ -export type MetadataRecord = DecoratorMetadata; \ No newline at end of file +export type MetadataRecord = DecoratorMetadata; + +/** + * Reference to the owner object that contains metadata + */ +export type MetaOwnerReference = WeakRef; + +/** + * A location (key or path) to a metadata entry, in a given owner object + */ +export type MetaAddress = [ + MetaOwnerReference, + Key +]; \ No newline at end of file diff --git a/packages/support/src/meta/index.ts b/packages/support/src/meta/index.ts index 787aadfa..4a73eb1c 100644 --- a/packages/support/src/meta/index.ts +++ b/packages/support/src/meta/index.ts @@ -1 +1,2 @@ -export * from './meta'; \ No newline at end of file +export * from './meta'; +export * from './targetMeta'; \ No newline at end of file diff --git a/packages/support/src/meta/targetMeta.ts b/packages/support/src/meta/targetMeta.ts new file mode 100644 index 00000000..309dbf53 --- /dev/null +++ b/packages/support/src/meta/targetMeta.ts @@ -0,0 +1,252 @@ +import type { Key } from "@aedart/contracts/support"; +import type { + Context, + MetaCallback, + MetaEntry, + MetaAddress, +} from "@aedart/contracts/support/meta"; +import { METADATA, TARGET_METADATA, Kind } from "@aedart/contracts/support/meta"; +import { mergeKeys } from "@aedart/support/misc"; +import { meta, getMeta } from './meta' + +/** + * Registry that contains the target object (e.g. a class or a method), + * along with a "meta address" to where the actual metadata is located. + * + * @see {MetaAddress} + */ +const addressesRegistry: WeakMap = new WeakMap(); + +/** + * Stores value for given key, and associates it directly with the target + * + * **Note**: _Method is intended to be used as a decorator!_ + * + * @example + * ```ts + * class A { + * @targetMeta('my-key', 'my-value) + * foo() {} + * } + * + * const a: A = new A(); + * getTargetMeta(a.foo, 'my-key'); // 'my-value' + * ``` + * + * @see getTargetMeta + * @see meta + * + * @param {Key | MetaCallback} key Key or path identifier. If callback is given, + * then its resulting {@link MetaEntry}'s `key` + * and `value` are stored. + * @param {unknown} [value] Value to store. Ignored if `key` argument is + * a callback. + * @returns {(target: object, context: Context) => (void | ((initialValue: unknown) => unknown) | undefined)} + */ +export function targetMeta( + key: Key | MetaCallback, + value?: unknown +) { + return meta((target: object, context: Context, owner: object) => { + + // Prevent unsupported kinds from being decorated... + if (!['class', 'method'].includes(context.kind)) { + throw new TypeError(`@targetMeta() does not support "${context.kind}" (only "class" and "method" are supported)`); + } + + // Make a "prefix" key, to be used in the final meta entry, + // and a meta address entry. + const prefixKey: Key = makePrefixKey(context); + const address: MetaAddress = [ + new WeakRef(owner), + prefixKey + ]; + + // Save the address in the registry... + saveAddress(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 members. + 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: object | undefined = owner.prototype; + + if (proto !== undefined && typeof proto[context.name] == 'function' && proto[context.name] !== target) { + saveAddress(proto[context.name], address); + } + } + + // Finally, return the meta key-value pair that will be stored in the owner's metadata. + return makeMetaEntry( + target, + context, + owner, + prefixKey, + key, + value + ); + }); +} + +/** + * 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} + */ +export function getTargetMeta(target: object, key: Key, defaultValue?: D): T | D | undefined +{ + // Find "target" meta address for given target object + // or return the default value if none is found. + const address: MetaAddress = findAddress(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: object | undefined = address[0]?.deref(); + if (owner === undefined) { + return defaultValue; + } + + // Finally, use getMeta to obtain desired key. + const prefixKey: Key = address[1]; + return getMeta( + owner, + mergeKeys(prefixKey, key), + defaultValue + ); +} + +/** + * Find the address where "target" meta is stored for the given target + * + * @param {object} target + * + * @return {MetaAddress|undefined} + */ +function findAddress(target: object): MetaAddress | undefined +{ + let address: MetaAddress | undefined = addressesRegistry.get(target); + if (address !== undefined) { + return address; + } + + // Obtain the prototype of Function... + const functionProto: object|null = Reflect.getPrototypeOf(Function); + + // When no address is found and the target is a class with metadata, + // then attempt to find address via its parent. + let parent:object|null = target; + while(address === undefined && METADATA in parent) { + parent = Reflect.getPrototypeOf(parent); + if (parent === null || parent === functionProto) { + break; + } + + // Attempt to get meta address from parent. + address = addressesRegistry.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 findAddress(parent); + // } + // } + + return address; +} + +/** + * 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 + */ +function saveAddress(target: object, address: MetaAddress): void +{ + addressesRegistry.set(target, address); +} + +/** + * Returns a "prefix" key (path) where "target" metadata must be stored + * + * @param {Context} context + * + * @return {Key} + */ +function makePrefixKey(context: Context): Key +{ + if (!Reflect.has(Kind, context.kind)) { + throw new TypeError(`context.kind: "${context.kind}" is unsupported`); + } + + const isStatic: number = (context.kind !== 'class' && context.static) + ? 1 // static element + : 0; // non-static element + + return [ + TARGET_METADATA, + Kind[context.kind], + isStatic, // Ensures that we do not overwrite static / none-static elements with same name! + context.name ?? 'anonymous' // "anonymous" is for anonymous classes (they do not have a name) + ] as Key; +} + +/** + * Returns a new metadata entry + * + * @param {object} target + * @param {Context} context + * @param {object} owner + * @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} + */ +function makeMetaEntry( + target: object, + context: Context, + owner: object, + prefixKey: Key, + key: Key | MetaCallback, + value?: unknown +): MetaEntry +{ + let resolvedKey: Key = key; + let resolvedValue: unknown = value; + + // When key is a callback, invoke it and use its resulting key-value pair. + if (typeof key == 'function') { + const entry: MetaEntry = (key as MetaCallback)(target, context, owner); + + resolvedKey = entry.key; + resolvedValue = entry.value; + } + + return { + key: mergeKeys(prefixKey, resolvedKey), + value: resolvedValue + } as MetaEntry; +} \ No newline at end of file diff --git a/tests/browser/packages/support/meta/targetMeta.test.js b/tests/browser/packages/support/meta/targetMeta.test.js new file mode 100644 index 00000000..ac28bd20 --- /dev/null +++ b/tests/browser/packages/support/meta/targetMeta.test.js @@ -0,0 +1,293 @@ +import { targetMeta, getTargetMeta } from "@aedart/support/meta"; + +describe('@aedart/support/meta', () => { + + describe('targetMeta()', () => { + + it('can decorate class with target meta', () => { + + const key = 'foo'; + const value = 'bar'; + + @targetMeta(key, value) + class A {} + + // ---------------------------------------------------------------------- // + + const result = getTargetMeta(A, key); + expect(result) + .withContext('Incorrect target meta') + .toEqual(value); + }); + + it('inherits class target meta', () => { + + const key = 'foo'; + const value = 'bar'; + + @targetMeta(key, value) + class A {} + class B extends A {} + class C extends B {} + + // ---------------------------------------------------------------------- // + + const result = getTargetMeta(C, key); + expect(result) + .withContext('Failed to inherit target meta') + .toEqual(value); + }); + + it('can overwrite class target meta', () => { + + const key = 'foo'; + const valueA = 'bar'; + const valueB = 'baz'; + + @targetMeta(key, valueA) + class A {} + class B extends A {} + + @targetMeta(key, valueB) + class C extends B {} + + // ---------------------------------------------------------------------- // + + const original = getTargetMeta(B, key); + expect(original) + .withContext('Failed to obtain original target meta') + .toEqual(valueA); + + const overwritten = getTargetMeta(C, key); + expect(overwritten) + .withContext('Failed to obtain overwritten target meta') + .toEqual(valueB); + }); + + it('can decorate method with target meta', () => { + + const key = 'baz'; + const value = 123; + + class A { + @targetMeta(key, value) + foo() {} + } + + // ---------------------------------------------------------------------- // + + const instance = new A(); + const result = getTargetMeta(instance.foo, key); + + expect(result) + .withContext('Incorrect method target meta') + .toEqual(value); + }); + + it('can inherit target method meta', () => { + + const key = 'baz'; + const value = 321; + + class A { + @targetMeta(key, value) + foo() {} + } + class B extends A {} + class C extends B {} + + // ---------------------------------------------------------------------- // + + const instance = new C(); + const result = getTargetMeta(instance.foo, key); + + expect(result) + .withContext('Incorrect method target meta') + .toEqual(value); + }); + + it('can inherit target method meta, when method overwritten', () => { + + const key = 'bar'; + const value = 321; + + class A { + @targetMeta(key, value) + foo() {} + } + class B extends A {} + class C extends B { + + // NOTE: meta SHOULD still apply, unless explicitly overwritten + foo() {} + } + + // ---------------------------------------------------------------------- // + + const instance = new C(); + const result = getTargetMeta(instance.foo, key); + + expect(result) + .withContext('Incorrect method target meta') + .toEqual(value); + }); + + it('can overwrite target method meta, when method overwritten', () => { + + const key = 'bar'; + const valueA = 321; + const valueB = 'baz'; + + class A { + @targetMeta(key, valueA) + foo() {} + } + class B extends A {} + class C extends B { + + @targetMeta(key, valueB) + foo() {} + } + + // ---------------------------------------------------------------------- // + + const instanceB = new B(); + const original = getTargetMeta(instanceB.foo, key); + + expect(original) + .withContext('Incorrect original method target meta') + .toEqual(valueA); + + const instanceC = new C(); + const overwritten = getTargetMeta(instanceC.foo, key); + + expect(overwritten) + .withContext('Incorrect overwritten method target meta') + .toEqual(valueB); + }); + + it('can decorate static method', () => { + + const key = 'baz'; + const value = 123; + + class A { + @targetMeta(key, value) + static foo() {} + } + + // ---------------------------------------------------------------------- // + + const result = getTargetMeta(A.foo, key); + + expect(result) + .withContext('Incorrect static method target meta') + .toEqual(value); + }); + + it('can inherit target static method meta', () => { + + const key = 'bar'; + const value = 'nice'; + + class A { + @targetMeta(key, value) + static foo() {} + } + class B extends A {} + class C extends B {} + + // ---------------------------------------------------------------------- // + + const result = getTargetMeta(C.foo, key); + + expect(result) + .withContext('Incorrect static method target meta') + .toEqual(value); + }); + + // TODO: Inheritance of metadata on overwritten static methods will NOT work. This is because @meta() (and decorators in general) + // TODO: do not resolve "this" as a late binding for static blocks or members. Thus, @meta() yields class A, instead of C as "this". + xit('can inherit target static method meta, when static method overwritten', () => { + + const key = 'zim'; + const value = 'zar'; + + class A { + @targetMeta(key, value) + static foo() {} + } + class B extends A {} + class C extends B { + + // NOTE: meta SHOULD still apply, unless explicitly overwritten... + // But sadly this fails because of no late "this" binding of static + // blocks or members. + static foo() {} + } + + // ---------------------------------------------------------------------- // + + const result = getTargetMeta(C.foo, key); + + expect(result) + .withContext('Incorrect static method target meta') + .toEqual(value); + }); + + it('can overwrite target static method meta, when static method overwritten', () => { + + const key = 'bar'; + const valueA= true; + const valueB = false; + + class A { + @targetMeta(key, valueA) + static foo() {} + } + class B extends A {} + class C extends B { + + @targetMeta(key, valueB) + static foo() {} + } + + // ---------------------------------------------------------------------- // + + const original = getTargetMeta(B.foo, key); + + expect(original) + .withContext('Incorrect original method target meta') + .toEqual(valueA); + + const overwritten = getTargetMeta(C.foo, key); + + expect(overwritten) + .withContext('Incorrect overwritten method target meta') + .toEqual(valueB); + }); + }); + + describe('misc', () => { + + it('fails when attempting to decorate unsupported element', () => { + const callback = function() { + + class A { + @targetMeta('foo', 'bar') + foo = 'bar'; + } + + return new A(); + } + + // ---------------------------------------------------------------------- // + + expect(callback) + .withContext('Should not support @targetMeta on unsupported element') + .toThrowError(TypeError); + }); + + }); + +}); \ No newline at end of file