contexify
Version:
A TypeScript library providing a powerful dependency injection container with context-based IoC capabilities, inspired by LoopBack's Context system.
242 lines (226 loc) • 7.88 kB
text/typescript
import type { MapObject } from '../utils/value-promise.js';
import type { Binding, BindingTag } from './binding.js';
import type { BindingAddress } from './binding-key.js';
/**
* A function that filters bindings. It returns `true` to select a given
* binding.
*
* @remarks
* Originally, we allowed filters to be tied with a single value type.
* This actually does not make much sense - the filter function is typically
* invoked on all bindings to find those ones matching the given criteria.
* Filters must be prepared to handle bindings of any value type. We learned
* about this problem after enabling TypeScript's `strictFunctionTypes` check.
* This aspect is resolved by typing the input argument as `Binding<unknown>`.
*
* Ideally, `BindingFilter` should be declared as a type guard as follows:
* ```ts
* export type BindingFilterGuard<ValueType = unknown> = (
* binding: Readonly<Binding<unknown>>,
* ) => binding is Readonly<Binding<ValueType>>;
* ```
*
* But TypeScript treats the following types as incompatible and does not accept
* type 1 for type 2.
*
* 1. `(binding: Readonly<Binding<unknown>>) => boolean`
* 2. `(binding: Readonly<Binding<unknown>>) => binding is Readonly<Binding<ValueType>>`
*
* If we described BindingFilter as a type-guard, then all filter implementations
* would have to be explicitly typed as type-guards too, which would make it
* tedious to write quick filter functions like `b => b.key.startsWith('services')`.
*
* To keep things simple and easy to use, we use `boolean` as the return type
* of a binding filter function.
*/
export type BindingFilter = (binding: Readonly<Binding<unknown>>) => boolean;
/**
* Select binding(s) by key or a filter function
*/
export type BindingSelector<ValueType = unknown> =
| BindingAddress<ValueType>
| BindingFilter;
/**
* Check if an object is a `BindingKey` by duck typing
* @param selector Binding selector
*/
function isBindingKey(selector: BindingSelector) {
if (selector == null || typeof selector !== 'object') return false;
return (
typeof selector.key === 'string' &&
typeof selector.deepProperty === 'function'
);
}
/**
* Type guard for binding address
* @param bindingSelector - Binding key or filter function
*/
export function isBindingAddress(
bindingSelector: BindingSelector
): bindingSelector is BindingAddress {
return (
typeof bindingSelector !== 'function' &&
(typeof bindingSelector === 'string' ||
// Check for binding key by duck typing
// `bindingSelector instanceof BindingKey` is not always reliable as the
// `contexify` module might be loaded from multiple locations if
// `npm install` does not dedupe or there are mixed versions in the tree
isBindingKey(bindingSelector))
);
}
/**
* Binding filter function that holds a binding tag pattern. `Context.find()`
* uses the `bindingTagPattern` to optimize the matching of bindings by tag to
* avoid expensive check for all bindings.
*/
export interface BindingTagFilter extends BindingFilter {
/**
* A special property on the filter function to provide access to the binding
* tag pattern which can be utilized to optimize the matching of bindings by
* tag in a context.
*/
bindingTagPattern: BindingTag | RegExp;
}
/**
* Type guard for BindingTagFilter
* @param filter - A BindingFilter function
*/
export function isBindingTagFilter(
filter?: BindingFilter
): filter is BindingTagFilter {
if (filter == null || !('bindingTagPattern' in filter)) return false;
const tagPattern = (filter as BindingTagFilter).bindingTagPattern;
return (
tagPattern instanceof RegExp ||
typeof tagPattern === 'string' ||
typeof tagPattern === 'object'
);
}
/**
* A function to check if a given tag value is matched for `filterByTag`
*/
export type TagValueMatcher = (
tagValue: unknown,
tagName: string,
tagMap: MapObject<unknown>
) => boolean;
/**
* A symbol that can be used to match binding tags by name regardless of the
* value.
*
* @example
*
* The following code matches bindings with tag `{controller: 'A'}` or
* `{controller: 'controller'}`. But if the tag name 'controller' does not
* exist for a binding, the binding will NOT be included.
*
* ```ts
* ctx.findByTag({controller: ANY_TAG_VALUE})
* ```
*/
export const ANY_TAG_VALUE: TagValueMatcher = (_tagValue, tagName, tagMap) =>
tagName in tagMap;
/**
* Create a tag value matcher function that returns `true` if the target tag
* value equals to the item value or is an array that includes the item value.
* @param itemValues - A list of tag item value
*/
export function includesTagValue(...itemValues: unknown[]): TagValueMatcher {
return (tagValue) => {
return itemValues.some(
(itemValue) =>
// The tag value equals the item value
tagValue === itemValue ||
// The tag value contains the item value
(Array.isArray(tagValue) && tagValue.includes(itemValue))
);
};
}
/**
* Create a binding filter for the tag pattern
* @param tagPattern - Binding tag name, regexp, or object
*/
export function filterByTag(tagPattern: BindingTag | RegExp): BindingTagFilter {
let filter: BindingFilter;
let regex: RegExp | undefined;
if (tagPattern instanceof RegExp) {
// RegExp for tag names
regex = tagPattern;
}
if (
typeof tagPattern === 'string' &&
(tagPattern.includes('*') || tagPattern.includes('?'))
) {
// Wildcard tag name
regex = wildcardToRegExp(tagPattern);
}
if (regex != null) {
// RegExp or wildcard match
filter = (b) => b.tagNames.some((t) => regex?.test(t) ?? false);
} else if (typeof tagPattern === 'string') {
// Plain tag string match
filter = (b) => b.tagNames.includes(tagPattern);
} else {
// Match tag name/value pairs
const tagMap = tagPattern as MapObject<unknown>;
filter = (b) => {
for (const t in tagMap) {
if (!matchTagValue(tagMap[t], t, b.tagMap)) return false;
}
// All tag name/value pairs match
return true;
};
}
// Set up binding tag for the filter
const tagFilter = filter as BindingTagFilter;
tagFilter.bindingTagPattern = regex ?? tagPattern;
return tagFilter;
}
function matchTagValue(
tagValueOrMatcher: unknown,
tagName: string,
tagMap: MapObject<unknown>
) {
const tagValue = tagMap[tagName];
if (tagValue === tagValueOrMatcher) return true;
if (typeof tagValueOrMatcher === 'function') {
return (tagValueOrMatcher as TagValueMatcher)(tagValue, tagName, tagMap);
}
return false;
}
/**
* Create a binding filter from key pattern
* @param keyPattern - Binding key/wildcard, regexp, or a filter function
*/
export function filterByKey(
keyPattern?: string | RegExp | BindingFilter
): BindingFilter {
if (typeof keyPattern === 'string') {
const regex = wildcardToRegExp(keyPattern);
return (binding) => regex.test(binding.key);
}
if (keyPattern instanceof RegExp) {
return (binding) => keyPattern.test(binding.key);
}
if (typeof keyPattern === 'function') {
return keyPattern;
}
return () => true;
}
/**
* Convert a wildcard pattern to RegExp
* @param pattern - A wildcard string with `*` and `?` as special characters.
* - `*` matches zero or more characters except `.` and `:`
* - `?` matches exactly one character except `.` and `:`
*/
function wildcardToRegExp(pattern: string): RegExp {
// Escape reserved chars for RegExp:
// `- \ ^ $ + . ( ) | { } [ ] :`
// eslint-disable-next-line no-useless-escape
let regexp = pattern.replace(/[-[\]/{}()+.\\^$|:]/g, '\\$&');
// Replace wildcard chars `*` and `?`
// `*` matches zero or more characters except `.` and `:`
// `?` matches one character except `.` and `:`
regexp = regexp.replace(/\*/g, '[^.:]*').replace(/\?/g, '[^.:]');
return new RegExp(`^${regexp}$`);
}