Skip to content

Commit

Permalink
Add targetMeta() and getTargetMeta()
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
aedart committed Jan 31, 2024
1 parent cffb55d commit c1b01a3
Show file tree
Hide file tree
Showing 5 changed files with 569 additions and 2 deletions.
7 changes: 7 additions & 0 deletions packages/contracts/src/support/meta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion packages/contracts/src/support/meta/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -31,4 +32,17 @@ export type MetaCallback = (target: object, context: Context, owner: object) =>
/**
* Metadata Record
*/
export type MetadataRecord = DecoratorMetadata;
export type MetadataRecord = DecoratorMetadata;

/**
* Reference to the owner object that contains metadata
*/
export type MetaOwnerReference = WeakRef<object>;

/**
* A location (key or path) to a metadata entry, in a given owner object
*/
export type MetaAddress = [
MetaOwnerReference,
Key
];
3 changes: 2 additions & 1 deletion packages/support/src/meta/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './meta';
export * from './meta';
export * from './targetMeta';
252 changes: 252 additions & 0 deletions packages/support/src/meta/targetMeta.ts
Original file line number Diff line number Diff line change
@@ -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<object, MetaAddress> = new WeakMap<object, MetaAddress>();

/**
* 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<T, D = unknown>(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<T, D>(
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;
}
Loading

0 comments on commit c1b01a3

Please sign in to comment.