diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c65be63..2efaa880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* Concerns - an alternative to mixins, in `@aedart/contracts/support/concerns`. +* `@aedart/contracts/support/exceptions` and `@aedart/support/exceptions` submodules. +* `@aedart/contracts/support/objects` submodule. +* `@aedart/contracts/support/arrays` and `@aedart/support/arrays` submodules. +* `ConcatSpreadable` (_extends TypeScript's `ArrayLike` interface_) interface in `@aedart/contracts/support/arrays`. +* `Throwable` (_extends TypeScript's `Error` interface_) interface in `@aedart/contracts/support/exceptions`. +* `Cloneable` and `Populatable` interfaces in `@aedart/contracts/support/objects`. +* `LogicalError` and `AbstractClassError` exceptions in `@aedart/support/exceptions`. +* `getErrorMessage()`, `configureCustomError()` and `configureStackTrace()` in `@aedart/support/exceptions`. +* `FUNCTION_PROTOTYPE` and `TYPED_ARRAY_PROTOTYPE` constants in `@aedart/contracts/support/reflections`. +* `ClassBlueprint` interface in `@aedart/contracts/support/reflections`. +* `DANGEROUS_PROPERTIES` constant in `@aedart/contracts/support/objects`. +* `hasPrototypeProperty()` and `assertHasPrototypeProperty()` in `@aedart/support/reflections`. +* `getParentOfClass()` and `getAllParentsOfClass()` in `@aedart/support/reflections`. +* `getClassPropertyDescriptor()` and `getClassPropertyDescriptors()` in `@aedart/support/reflections`. +* `isWeakKind()` in `@aedart/support/reflections`. +* `isKeySafe()` and `isKeyUnsafe()` in `@aedart/support/reflections`. +* `getConstructorName()` and `getNameOrDesc()` in `@aedart/support/reflections`. +* `isSubclass()`, `classLooksLike()`, `isSubclassOrLooksLike()`, `classOwnKeys()`, `hasMethod()` and `hasAllMethods()` in `@aedart/support/reflections`. +* `merge()`, `populate()`, `isCloneable()` and `isPopulatable()` in `@aedart/support/objects`. +* Objects `Merger` (_underlying component for the objects `merge()` util_) in `@aedart/support/objects`. +* `merge()`, `isTypedArray()`, `isArrayLike()`, `isSafeArrayLike()`, `isTypedArray()` and `isConcatSpreadable()` in `@aedart/support/arrays`. +* `includesAny()` and `includesAll()` in `@aedart/support/arrays`. + +### Changed + +* Split the "support" submodules docs into several smaller pages. + +### Fixed + +* Lodash JSDoc references in `get()`, `set()`, `unset()` and `forget()`, in `@aedart/support/objects`. +* `jasmine-core` `v4.x` was used by `karma-jasmine`, which caused thrown exceptions that contained `ErrorOptions` to not being rendered correctly in the CLI. [_See GitHub issue for details_](https://github.com/jasmine/jasmine/issues/2028). +* Default `defaultTheme` deprecated, replaced with named import, in docs `config.ts`. +* `defineClientConfig()` does not exist in docs `client.ts` (_replaced with an object of the type `ClientConfig`_). + ## [0.8.0] - 2024-02-12 ### Added diff --git a/aliases.js b/aliases.js index 40dbdbed..b5f9cc7b 100644 --- a/aliases.js +++ b/aliases.js @@ -19,8 +19,12 @@ module.exports = { alias: { // contracts + '@aedart/contracts/support/arrays': path.resolve(__dirname, './packages/contracts/support/arrays'), + '@aedart/contracts/support/concerns': path.resolve(__dirname, './packages/contracts/support/concerns'), + '@aedart/contracts/support/exceptions': path.resolve(__dirname, './packages/contracts/support/exceptions'), '@aedart/contracts/support/meta': path.resolve(__dirname, './packages/contracts/support/meta'), '@aedart/contracts/support/mixins': path.resolve(__dirname, './packages/contracts/support/mixins'), + '@aedart/contracts/support/objects': path.resolve(__dirname, './packages/contracts/support/objects'), '@aedart/contracts/support/reflections': path.resolve(__dirname, './packages/contracts/support/reflections'), '@aedart/contracts/support': path.resolve(__dirname, './packages/contracts/support'), '@aedart/contracts': path.resolve(__dirname, './packages/contracts/src'), diff --git a/docs/.vuepress/archive/Version0x.ts b/docs/.vuepress/archive/Version0x.ts index a65b3c06..93955b40 100644 --- a/docs/.vuepress/archive/Version0x.ts +++ b/docs/.vuepress/archive/Version0x.ts @@ -36,11 +36,134 @@ export default PagesCollection.make('v0.x', '/v0x', [ children: [ 'packages/support/', 'packages/support/install', - 'packages/support/mixins', - 'packages/support/meta', - 'packages/support/objects', - 'packages/support/reflections', - 'packages/support/misc', + { + text: 'Arrays', + collapsible: true, + children: [ + 'packages/support/arrays/', + 'packages/support/arrays/includesAll', + 'packages/support/arrays/includesAny', + 'packages/support/arrays/isArrayLike', + 'packages/support/arrays/isConcatSpreadable', + 'packages/support/arrays/isSafeArrayLike', + 'packages/support/arrays/isTypedArray', + 'packages/support/arrays/merge', + ] + }, + { + text: 'Concerns', + collapsible: true, + children: [ + 'packages/support/concerns/', + 'packages/support/concerns/prerequisites', + 'packages/support/concerns/concernClass', + 'packages/support/concerns/usage', + 'packages/support/concerns/aliases', + 'packages/support/concerns/conflictResolution', + 'packages/support/concerns/booting', + 'packages/support/concerns/hooks', + 'packages/support/concerns/edgeCases', + 'packages/support/concerns/jsdoc', + ] + }, + { + text: 'Exceptions', + collapsible: true, + children: [ + 'packages/support/exceptions/', + 'packages/support/exceptions/configureCustomError', + 'packages/support/exceptions/configureStackTrace', + 'packages/support/exceptions/getErrorMessage', + 'packages/support/exceptions/customErrors', + ] + }, + { + text: 'Meta', + collapsible: true, + children: [ + 'packages/support/meta/', + 'packages/support/meta/prerequisites', + 'packages/support/meta/supported', + 'packages/support/meta/setAndGet', + 'packages/support/meta/inheritance', + 'packages/support/meta/outsideChanges', + 'packages/support/meta/tc39', + 'packages/support/meta/targetMeta', + ] + }, + { + text: 'Mixins', + collapsible: true, + children: [ + 'packages/support/mixins/', + 'packages/support/mixins/newMixin', + 'packages/support/mixins/apply', + 'packages/support/mixins/instanceof', + 'packages/support/mixins/inheritance', + 'packages/support/mixins/onward', + ] + }, + { + text: 'Object', + collapsible: true, + children: [ + 'packages/support/objects/', + 'packages/support/objects/forget', + 'packages/support/objects/forgetAll', + 'packages/support/objects/get', + 'packages/support/objects/has', + 'packages/support/objects/hasAll', + 'packages/support/objects/hasAny', + 'packages/support/objects/hasUniqueId', + 'packages/support/objects/isCloneable', + 'packages/support/objects/isPopulatable', + 'packages/support/objects/isset', + 'packages/support/objects/merge', + 'packages/support/objects/populate', + 'packages/support/objects/set', + 'packages/support/objects/uniqueId', + ] + }, + { + text: 'Reflections', + collapsible: true, + children: [ + 'packages/support/reflections/', + 'packages/support/reflections/assertHasPrototypeProperty', + 'packages/support/reflections/classLooksLike', + 'packages/support/reflections/classOwnKeys', + 'packages/support/reflections/getAllParentsOfClass', + 'packages/support/reflections/getClassPropertyDescriptor', + 'packages/support/reflections/getClassPropertyDescriptors', + 'packages/support/reflections/getConstructorName', + 'packages/support/reflections/getNameOrDesc', + 'packages/support/reflections/getParentOfClass', + 'packages/support/reflections/hasAllMethods', + 'packages/support/reflections/hasMethod', + 'packages/support/reflections/hasPrototypeProperty', + 'packages/support/reflections/isConstructor', + 'packages/support/reflections/isKeySafe', + 'packages/support/reflections/isKeyUnsafe', + 'packages/support/reflections/isSubclass', + 'packages/support/reflections/isSubclassOrLooksLike', + 'packages/support/reflections/isWeakKind', + ] + }, + { + text: 'Misc', + collapsible: true, + children: [ + 'packages/support/misc/', + 'packages/support/misc/descTag', + 'packages/support/misc/empty', + 'packages/support/misc/isKey', + 'packages/support/misc/isPrimitive', + 'packages/support/misc/isPropertyKey', + 'packages/support/misc/isset', + 'packages/support/misc/mergeKeys', + 'packages/support/misc/toWeakRef', + ] + }, ] }, { diff --git a/docs/.vuepress/client.ts b/docs/.vuepress/client.ts index 1f1dc711..f3889842 100644 --- a/docs/.vuepress/client.ts +++ b/docs/.vuepress/client.ts @@ -1,12 +1,12 @@ -import { defineClientConfig } from '@vuepress/client'; +import type { ClientConfig } from '@vuepress/client'; // @ts-ignore import Layout from "./layouts/Layout.vue"; -export default defineClientConfig({ +export default { enhance({ app, router, siteData }) {}, setup() {}, rootComponents: [], layouts: { Layout } -}) +} as ClientConfig; diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index d01a3eee..aeea721d 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -1,5 +1,5 @@ import {defineUserConfig, Page} from 'vuepress'; -import defaultTheme from "@vuepress/theme-default" +import { defaultTheme } from "@vuepress/theme-default" import { webpackBundler } from "@vuepress/bundler-webpack" import {backToTopPlugin} from "@vuepress/plugin-back-to-top"; import {searchPlugin} from "@vuepress/plugin-search"; diff --git a/docs/archive/current/README.md b/docs/archive/current/README.md index c7e22267..d57681aa 100644 --- a/docs/archive/current/README.md +++ b/docs/archive/current/README.md @@ -8,7 +8,7 @@ sidebarDepth: 0 ::: danger Ion is still in development. -You _SHOULD NOT_ use any of the packages in production. +You _SHOULD NOT_ use any of the packages in a production environment. Breaking changes _**MUST**_ be expected for all `v0.x` releases! _Please review the [`CHANGELOG.md`](https://github.com/aedart/ion/blob/main/CHANGELOG.md) for additional details._ @@ -29,6 +29,51 @@ _TBD: "To be decided"._ ## `v0.x` Highlights +### Concerns + +Intended as an alternative to mixins, the [Concerns](./packages/support/concerns/README.md) submodule offers a different +way to overcome some of the limitations of single inheritance. + +```js +import { use, AbstractConcern } from "@aedart/support/concerns"; + +// A concern class... +class Role extends AbstractConcern { + addRole(name) { + /* ...not shown... */ + } +} + +// Use concern in target class... +@use(Role) +class User {} + +// Later in your application... +const user = new User(); +user.addRole('maintainer'); +user.addRole('supporter'); +``` + +### Merge + +Objects [merge](./packages/support/objects/merge.md) utility, using [deep copy](https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy). + +```js +import { merge } from "@aedart/support/objects"; + +const a = { + 'name': 'Alin', +}; + +const b = { + 'address': { + 'street': 'Northern Street 1' + }, +}; + +const result = merge(a, b); // { 'name': 'Alin', 'address': { 'street': '...' } } +``` + ### Mixins Adaptation of Justin Fagnani's [`mixwith.js`](https://github.com/justinfagnani/mixwith.js). @@ -59,12 +104,12 @@ item.name = 'My Item'; console.log(item.name); // My Item ``` -See details and more examples in the [`@aedart/support/mixins` documentation](./packages/support/mixins.md). +See details and more examples in the [`@aedart/support/mixins` documentation](./packages/support/mixins/README.md). ### "Target" Meta Decorator Associate arbitrary metadata directly with the target element that is being decorated. -_See [target meta decorator](./packages/support/meta.md) fro additional details._ +_See [target meta decorator](./packages/support/meta/targetMeta.md) fro additional details._ ```js import {targetMeta, getTargetMeta} from '@aedart/support/meta'; @@ -85,7 +130,7 @@ getTargetMeta(instance.search, 'desc'); // Seaches for cities ### Meta Decorator -The [meta decorator](./packages/support/meta.md) is able to associate arbitrary metadata with a class and its elements. +The [meta decorator](./packages/support/meta/README.md) is able to associate arbitrary metadata with a class and its elements. ```js import {meta, getMeta} from '@aedart/support/meta'; diff --git a/docs/archive/current/packages/support/arrays/README.md b/docs/archive/current/packages/support/arrays/README.md new file mode 100644 index 00000000..72426676 --- /dev/null +++ b/docs/archive/current/packages/support/arrays/README.md @@ -0,0 +1,8 @@ +--- +title: About Arrays +description: Array utilities. +--- + +# About Arrays + +`@aedart/support/arrays` contains [array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) related utilities. \ No newline at end of file diff --git a/docs/archive/current/packages/support/arrays/includesAll.md b/docs/archive/current/packages/support/arrays/includesAll.md new file mode 100644 index 00000000..6a30719f --- /dev/null +++ b/docs/archive/current/packages/support/arrays/includesAll.md @@ -0,0 +1,18 @@ +--- +title: Includes All +description: Determine if array contains all values. +sidebarDepth: 0 +--- + +# `includesAll` + +Determines if an array includes all values. + +```js +import { includesAll } from '@aedart/support/arrays'; + +const arr = [ 1, 2, 3 ]; + +includesAll(arr, [ 1, 2 ]); // true +includesAll(arr, [ 1, 4 ]); // false +``` diff --git a/docs/archive/current/packages/support/arrays/includesAny.md b/docs/archive/current/packages/support/arrays/includesAny.md new file mode 100644 index 00000000..02aa63e5 --- /dev/null +++ b/docs/archive/current/packages/support/arrays/includesAny.md @@ -0,0 +1,18 @@ +--- +title: Includes Any +description: Determine if array contains some values. +sidebarDepth: 0 +--- + +# `includesAny` + +Determines if an array includes some values. + +```js +import { includesAny } from '@aedart/support/arrays'; + +const arr = [ 1, 2, 3 ]; + +includesAll(arr, [ 4, 2 ]); // true +includesAll(arr, [ 5, 5 ]); // false +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/arrays/isArrayLike.md b/docs/archive/current/packages/support/arrays/isArrayLike.md new file mode 100644 index 00000000..1d7961fc --- /dev/null +++ b/docs/archive/current/packages/support/arrays/isArrayLike.md @@ -0,0 +1,30 @@ +--- +title: Is Array Like +description: Determine if value is array-like. +sidebarDepth: 0 +--- + +# `isArrayLike` + +Determines if a value is ["array-like"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#array-like_objects). + +(_`isArrayLike()` is an alias for Lodash's [isArrayLike](https://lodash.com/docs/4.17.15#isArrayLike)._) + +```js +import { isArrayLike } from '@aedart/support/arrays'; + +isArrayLike([]); // true +isArrayLike('abc'); // true +isArrayLike(new String('abc')); // true +isArrayLike({ length: 0 }); // true +isArrayLike(new Int8Array()); // true + +isArrayLike({}); // false +isArrayLike(function() {}); // false +isArrayLike(new Boolean(true)); // false +isArrayLike(123); // false +isArrayLike(new Number(123)); // false +// ...etc +``` + +See also [_`isSafeArrayLike()`_](./issafearraylike.md). \ No newline at end of file diff --git a/docs/archive/current/packages/support/arrays/isConcatSpreadable.md b/docs/archive/current/packages/support/arrays/isConcatSpreadable.md new file mode 100644 index 00000000..4dfc2dd0 --- /dev/null +++ b/docs/archive/current/packages/support/arrays/isConcatSpreadable.md @@ -0,0 +1,48 @@ +--- +title: Is Concat Spreadable +description: Determine if object is concat spreadable. +sidebarDepth: 0 +--- + +# `isConcatSpreadable` + +Determines if object contains the [well-known](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol#well-known_symbols) +symbol [`Symbol.isConcatSpreadable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/isConcatSpreadable). + +```js +import { isConcatSpreadable } from '@aedart/support/arrays'; + +isConcatSpreadable(null); // false +isConcatSpreadable([ 1, 2, 3 ]); // false +isConcatSpreadable({}); // false + +// ------------------------------------------------------------------------- + +const arr = [ 1, 2, 3 ]; +arr[Symbol.isConcatSpreadable] = true; +isConcatSpreadable(arr); // true + +// ------------------------------------------------------------------------- + +const obj = { + [Symbol.isConcatSpreadable]: true, + + // NOTE: length should be present, if Symbol.isConcatSpreadable + // set to `true` However, isConcatSpreadable() does not check + // if `length` is set! + length: 3, + 0: 'a', + 1: 'b', + 2: 'c' +}; +isConcatSpreadable(obj); // true + +// ------------------------------------------------------------------------- + +class A {} +class B { + [Symbol.isConcatSpreadable] = false; +} +isConcatSpreadable(new A()); // false +isConcatSpreadable(new B()); // true +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/arrays/isSafeArrayLike.md b/docs/archive/current/packages/support/arrays/isSafeArrayLike.md new file mode 100644 index 00000000..895bdf44 --- /dev/null +++ b/docs/archive/current/packages/support/arrays/isSafeArrayLike.md @@ -0,0 +1,23 @@ +--- +title: Is Safe Array Like +description: Determine if value is "safe" array-like. +sidebarDepth: 0 +--- + +# `isSafeArrayLike` + +Determines if value is "safe" [array-like](#isarraylike) object. +In this context "safe" means that value is not a string, not an instance of [`String`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/String) object, +and not a [Typed Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) object. + +```js +import { isSafeArrayLike } from '@aedart/support/arrays'; + +isSafeArrayLike([]); // true +isSafeArrayLike({ length: 0 }); // true + +isSafeArrayLike('abc'); // false +isSafeArrayLike(new String('abc')); // false +isSafeArrayLike(new Int8Array()); // false +// ...etc +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/arrays/isTypedArray.md b/docs/archive/current/packages/support/arrays/isTypedArray.md new file mode 100644 index 00000000..3c4835ef --- /dev/null +++ b/docs/archive/current/packages/support/arrays/isTypedArray.md @@ -0,0 +1,30 @@ +--- +title: Is Typed Array +description: Determine if object is a typed array. +sidebarDepth: 0 +--- + +# `isTypedArray` + +Determines if target is an instance of a [`TypedArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray). + +```js +import { isTypedArray } from '@aedart/support/arrays'; + +isTypedArray(null); // false +isTypedArray({}); // false +isTypedArray([]); // false +isTypedArray(new Map()); // false + +isTypedArray(new Int8Array()); // true +isTypedArray(new Uint8Array()); // true +isTypedArray(new Uint8ClampedArray()); // true +isTypedArray(new Int16Array()); // true +isTypedArray(new Uint16Array()); // true +isTypedArray(new Int32Array()); // true +isTypedArray(new Uint32Array()); // true +isTypedArray(new Float32Array()); // true +isTypedArray(new Float64Array()); // true +isTypedArray(new BigInt64Array()); // true +isTypedArray(new BigUint64Array()); // true +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/arrays/merge.md b/docs/archive/current/packages/support/arrays/merge.md new file mode 100644 index 00000000..c7a54b64 --- /dev/null +++ b/docs/archive/current/packages/support/arrays/merge.md @@ -0,0 +1,51 @@ +--- +title: Merge +description: Merge multiple arrays into a new array. +sidebarDepth: 0 +--- + +# `merge` + +Merges arrays into a new array. +This function attempts to deep copy values, using [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone). + +```js +import { merge } from '@aedart/support/arrays'; + +const a = [ 1, 2, 3 ]; +const b = [ 4, 5, 6 ]; +const c = [ 7, 8, 9 ]; + +merge(a, b, c); // [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ] +``` + +## Deep Copy Objects + +Simple (_or "plain"_) objects [deep copied](https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy). +This means that new objects are returned in the resulting array. + +See [Mozilla's documentation](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) for additional +information about what data types are supported. + +```js +const a = { foo: 'foo' }; +const b = { bar: 'bar' }; +const c = { ping: 'pong' }; + +const result = merge([ a ], [ b, c ]); + +console.log(result[0] === a); // false +console.log(result[1] === b); // false +console.log(result[2] === c); // false +``` + +## When unable to merge values + +In situations when values cannot be copied via `structuredClone`, an `ArrayMergeError` is thrown. + +```js +const a = [ 1, 2, 3 ]; +const b = [ function() {} ]; // A function cannot be deep copied... + +merge(a, b); // ArrayMergeError +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/concerns/README.md b/docs/archive/current/packages/support/concerns/README.md new file mode 100644 index 00000000..b8a7ee8d --- /dev/null +++ b/docs/archive/current/packages/support/concerns/README.md @@ -0,0 +1,46 @@ +--- +title: About Concerns +description: Alternative mixin utility. +sidebarDepth: 0 +--- + +# About Concerns + +Inspired by PHP's [Traits](https://www.php.net/manual/en/language.oop5.traits.php), traditional [mixins](https://javascript.info/mixins), and a few concepts from [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection), +the `@aedart/support/concerns` submodule offers an alternative approach to reducing some of the limitations of [single inheritance](https://en.wikipedia.org/wiki/Inheritance_(object-oriented_programming)#Types). + +In this context, a "concern" is a class that can be injected into a target class, by means of the `use()` class decorator. +The public properties and methods of the concern class are then _"aliased"_ into the target class' prototype. In other words, +"proxy" properties and methods are defined in the target class. They forward any interaction to the original properties and methods +in the concern class instance. + +## Example + +```js +import { use, AbstractConcern } from "@aedart/support/concerns"; + +// A concern class... +class ConsolePrinter extends AbstractConcern { + print(message) { + console.log(message); + } +} + +// Taget class that uses a concern... +@use(ConsolePrinter) +class Person { + + sayHi(name) { + // Call method in concern + this.print(`Hi ${name}`); + } +} + +// Later in your application... +const person = new Person(); +person.sayHi('Atrid'); // Hi Astrid + +person.print('Ho ho ho...'); // Ho ho ho... +``` + +See also ["Real" mixins](../mixins/README.md) as an alternative. \ No newline at end of file diff --git a/docs/archive/current/packages/support/concerns/aliases.md b/docs/archive/current/packages/support/concerns/aliases.md new file mode 100644 index 00000000..32d5b09f --- /dev/null +++ b/docs/archive/current/packages/support/concerns/aliases.md @@ -0,0 +1,94 @@ +--- +title: Aliases +description: What are aliases +sidebarDepth: 0 +--- + +# Aliases + +In this context, an "alias" is a proxy property or method inside a target class. +It is responsible for forwarding interaction to the original property or method, inside the concern class instance. +Aliases are created automatically by the `use()` class decorator. + +[[TOC]] + +## Properties & Methods + +When injecting a concern into a target class, the concern's public properties and methods are defined as "aliases" +(_aka. proxy properties or methods_), in the target class' prototype (_see [`PROVIDES` symbol](./concernClass.md#customise-alias-members) for additional details_). + +Consider the following example: + +```js +import { use, AbstractConcern } from "@aedart/support/concerns"; + +class Levels extends AbstractConcern { + get level() { /* ...not shown */ } + set level(value) { /* ...not shown */ } + clear() { /* ...not shown */ } +} + +@use(Levels) +class Recorder {} +``` + +The aliasing mechanism will transform the target class into something that _**very roughly**_ corresponds to this: + +```js +import { + use, + CONCERNS, + AbstractConcern +} from "@aedart/support/concerns"; + +class Levels extends AbstractConcern { + get level() { /* ...not shown */ } + set level(value) { /* ...not shown */ } + clear(level) { /* ...not shown */ } +} + +class Recorder { + // ...private concerns container not shown... + + // get level "alias" + get level() { + return this[CONCERNS].get(Levels)['level']; + } + + // set level "alias" + set level(value) { + this[CONCERNS].get(Levels)['level'] = value; + } + + // method clear "alias" + clear(...args) { + return this[CONCERNS].get(Levels)['clear'](...args); + } +} +``` + +See [Manual interaction](./usage.md#manual-interaction) and [Conflict Resolution](./conflictResolution.md) for additional details. + +## If property or method already exists + +When a property or method from a concern already exists in the target class' prototype chain¹, then **NO Alias** is +defined. Said differently, the `use()` class decorator does **NOT** overwrite a target class' properties or methods. + +```js +class Label extends AbstractConcern { + get name() { /* ...not shown.. */ } + set name(v) { /* ...not shown.. */ } +} + +@use(Label) // Label's "name" property is NOT aliased +class Battery { + + // Battery's get/set "name" remains untouched by concern + get name() { /* ...not shown.. */ } + set name(v) { /* ...not shown.. */ } +} +``` + +¹: _Inherited properties and methods are also respected._ + +See [Conflict Resolution](./conflictResolution.md) for additional details. \ No newline at end of file diff --git a/docs/archive/current/packages/support/concerns/booting.md b/docs/archive/current/packages/support/concerns/booting.md new file mode 100644 index 00000000..6dddeb63 --- /dev/null +++ b/docs/archive/current/packages/support/concerns/booting.md @@ -0,0 +1,93 @@ +--- +title: Booting +description: How concerns are booted +sidebarDepth: 0 +--- + +# Booting + +By default, a concern class is _ONLY_ instantiated when you interact with its properties or methods, which have been "aliased" +into a target class (_aka. [lazy booting](https://en.wikipedia.org/wiki/Lazy_initialization)_). + +```js +class ContactsApi extends AbstractConcern { + get users() { /* ...not shown here... */} +} + +@use(ContactsApi) // Concern is NOT instantiated +class UsersRegistry {} + +const users = (new UsersRegistry()).users; // Concern is instantiated +``` + +## Manual Booting + +You can use the `bootConcerns()` utility to manually boot concerns. +It accepts the following arguments: + +- `instance: object|Owner` - The target class instance that uses the concerns. +- `...concerns: ConcernConstructor[]` - List of concern classes to instantiate (_aka. boot_). + +```js +import { use, bootConcerns } from "@aedart/support/concerns"; + +@use( + ConcernA, + ConcernB, + ConcernC, +) +class Target { + constructor() { + bootConcerns(this, ConcernA, ConcernB); + } +} + +const instance = new Target(); // ConcernA and ConcernB are instantiated +``` + +::: warning +If you attempt to boot a concern that has already been booted, a `BootError` will be thrown! + +To determine if a concern has already been booted, use the concern container's `hasBooted()` method. + +```js +import { + getContainer, + bootConcerns +} from "@aedart/support/concerns"; + +class Record extends ApiService { + constructor() { + super(); + + if (!getContainer(this).hasBooted(ApiConnection)) { + bootConcerns(this, ApiConnection); + } + } +} +``` + +See [Manual interaction](./usage.md#manual-interaction) for details. + +::: + +### Boot All Concerns + +If you wish to boot all concerns, use the `bootAllConcerns()` utility. + +```js +import { use, bootAllConcerns } from "@aedart/support/concerns"; + +@use( + ConcernA, + ConcernB, + ConcernC, +) +class Target { + constructor() { + bootAllConcerns(this); + } +} + +const instance = new Target(); // All concerns are initialised +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/concerns/concernClass.md b/docs/archive/current/packages/support/concerns/concernClass.md new file mode 100644 index 00000000..f0b41028 --- /dev/null +++ b/docs/archive/current/packages/support/concerns/concernClass.md @@ -0,0 +1,168 @@ +--- +title: Concern Class +description: How to define a new concern class. +sidebarDepth: 0 +--- + +# Concern Class + +This chapter shows how to create a new concern class. + +[[TOC]] + +## Inherit from `AbstractConcern` + +To create a new concern class, you can inherit from the `AbstractConcern` class. + +```js +import { AbstractConcern } from "@aedart/support/concerns"; + +class MyConcern extends AbstractConcern { + + get message() { /* ...not shown */ } + set message(message) { /* ...not shown */ } + + foo() { /* ...not shown */ } + + [MY_SYMBOL]() { /* ...not shown */ } +} +``` + +By default, all the public properties and methods (_all property keys_) are made available for ["aliasing"](./aliases.md) into a target class. +To configure which members should be made available for aliasing, see [Customise "alias" members](#customise-alias-members). + +### Private Members + +::: warning Note +Private methods and properties are **NEVER** "aliased" into a target class. +::: + +### Static Members + +::: warning Note +Static methods and properties are **NOT** "aliased" into target class. + +_At the moment, such a feature is not supported by the concerns' mechanism._ +_This may become available in the future, but presently you SHOULD NOT rely on static members becoming available for aliasing._ +::: + +### Transpilers + +::: warning Caution +Some transpilers, like [Babel](https://babeljs.io/) and [TypeScript](https://www.typescriptlang.org/), automatically move property declarations into the class' `constructor`. +For instance: + +```js +class A { + foo = 'bar'; +} +``` + +becomes like the following, after it has been transpiled: + +```js +class A { + constructor() { + this.foo = 'bar'; + } +} +``` + + +When this happens, properties cannot be "aliased". The concern mechanisms relies on the class' prototype for reading +what properties are available. To overcome such an issue, you can use [getters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) and [setters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/set) instead. + +```js +class A { + #foo = 'bar'; + + get foo() { + return this.#foo + } + + set foo(v) { + this.#foo = v; + } +} +``` + +::: + +## Customise "alias" members + +If you wish to customise what properties and methods _should_ be available for [aliasing](./aliases.md) when used by a +target class, overwrite the static `PROVIDES` method. + +```js +import { AbstractConcern, PROVIDES } from "@aedart/support/concerns"; + +class MyConcern extends AbstractConcern { + + get message() { /* ...not shown */ } + set message(message) { /* ...not shown */ } + foo() { /* ...not shown */ } + [MY_SYMBOL]() { /* ...not shown */ } + + static [PROVIDES]() { + // Make "message" and "foo" available for aliasing... + return [ + 'message', + 'foo' + ]; + } +} +``` + +::: warning +Even if you do customise what properties and methods are available for aliasing, the returned property keys of the +`PROVIDES` method can be overruled, via [conflict resolution](./conflictResolution.md)! + +If you truly wish to prevent certain properties or methods from being aliased into a target class, then you _should_ +declare them as [private](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties). +::: + +## Concern Owner instance + +The `concernOwner` property gives you direct access to the target class instance, in your concern. +This allows you to create interaction between the target instance and your concern, which can be usefully in any +number of situations. For instance, you can use the `concernOwner` to create a [fluent design](https://en.wikipedia.org/wiki/Fluent_interface) of your utilities. + +```js +class ConcernsPeople extends AbstractConcern { + with(value) { + // ...not shown here... + + return this.concernOwner; + } +} + +@use(ConcernsPeople) +class Invitation { + invite() { /* ...not shown here... */ } +} + +const party = new Invitation(); +party + .with('Anna') + .with('Anders') + .with('Ulla') + .with('Jimmy') + .invite(); +``` + +## Constructor + +Should you require initialisation logic, then you can overwrite the `constructor` in your concern class. +A concern's constructor is only given the target class instance as argument. + +_See [booting](./booting.md) for additional information._ + +```js +class Recording extends AbstractConcern { + constructor(owner) { + super(owner); + + // ...perform your initialisation here... + } +} +``` diff --git a/docs/archive/current/packages/support/concerns/conflictResolution.md b/docs/archive/current/packages/support/concerns/conflictResolution.md new file mode 100644 index 00000000..947583af --- /dev/null +++ b/docs/archive/current/packages/support/concerns/conflictResolution.md @@ -0,0 +1,111 @@ +--- +title: Conflict Resolution +description: How to deal with naming conflicts +sidebarDepth: 0 +--- + +# Conflict Resolution + +[[TOC]] + +## Naming Conflicts + +A concern class may _**ONLY**_ occur once in a target class' prototype chain. This is a core feature of the concerns +mechanism and cannot be circumvented. However, sometimes you may find yourself in situations where +different injected concern classes define the same property or method name. When this happens an `AliasConflictError` +is thrown. + +```js +class Label extends AbstractConcern { + get name() { /* ...not shown.. */ } + set name(v) { /* ...not shown.. */ } +} + +class Category extends AbstractConcern { + get name() { /* ...not shown.. */ } + set name(v) { /* ...not shown.. */ } +} + +@use( + Label, + Category // AliasConflictError: Alias "name" for property ... +) +class Battery {} +``` + +## Resolve Naming Conflicts + +To resolve the previous shown naming conflict, you can specify custom "aliases" when +injecting a concern class, via an injection configuration object. + +```js +// ... Label and Category concerns not shown ... + +@use( + Label, + { + concern: Category, + aliases: { + 'name': 'category' // Alias Category's "name" property as "category" + } + } +) +class Battery {} + +const instance = new Battery(); +instance.name = 'AAA'; +instance.category = 'Rechargeable'; +``` + +The `aliases` option is key-value record, where; +- key = property key in the concern class. +- value = property key (_alias_) to define in the target class. + +## Prevent Aliases + +To prevent a concern class from defining any aliases inside a target class, set the `allowAliases` option to `false`. + +```js +import { getConcernsContainer } from "@aedart/support/concerns"; + +@use( + Label, + { + concern: Category, + allowAliases: false // Category's "name" is NOT aliased in target + } +) +class Battery {} + +const instance = new Battery(); +instance.name = 'AA'; + +// Interact with Category concern to set "name" +getConcernsContainer(instance).get(Category).name = 'Rechargeable'; +``` + +## Shorthand Configuration + +You can also use a shorthand version to specify a concern injection configuration, via an array. +The first array value must always be the concern class that must be injected. +The second value can either be an `aliases` object, or boolean value for setting the `allowAliases` option. + +```js +@use( + Label, + [Category, { + 'name': 'category' + }] +) +class Battery {} +``` + +And to prevent a concern from defining aliases in a target: + +```js +@use( + Label, + [Category, false] +) +class Battery {} +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/concerns/edgeCases.md b/docs/archive/current/packages/support/concerns/edgeCases.md new file mode 100644 index 00000000..21a9d06c --- /dev/null +++ b/docs/archive/current/packages/support/concerns/edgeCases.md @@ -0,0 +1,177 @@ +--- +title: Edge Cases +description: A few edge cases when making or using concerns +sidebarDepth: 0 +--- + +# Edge Cases + +[[TOC]] + +## Getter & Setter declared in different concerns + +It is not possible to define a property's getter and setter methods in separate concerns, and thereafter use them in +a target class. Despite serving different purposes, the getter and setter share the same property name and are therefore +treated as being one and the same property key. The following example will therefore always lead to an +`AliasConflictError` being thrown. + +```js +import { AbstractConcern, use } from "@aedart/support/concerns"; + +class A extends AbstractConcern { + get title() { /* ...not shown.. */ } +} + +class B extends AbstractConcern { + set title(value) { /* ...not shown.. */ } +} + +@use( + A, + B // AliasConflictError - "title" property from A! +) +class Person {} +``` + +You can resolve the above shown issue via a [custom alias](./conflictResolution.md#resolve-naming-conflicts). But, it is +advisable to design your concerns such that the offer appropriate getter and setter methods for a property, in one and +the same concern - _if you intend for such a property to be readable and writable._ + +## Inheritance vs. Concern members + +The concerns mechanism will never overwrite existing methods or properties inside a target class - not even when those +methods or properties are inherited from a parent class. + +```js +import { AbstractConcern, use } from "@aedart/support/concerns"; + +class Connection extends AbstractConcern { + driver() { + return 'special'; + } +} + +class Api { + driver() { + return 'default'; + } +} + +@user(Connection) // driver() is NOT aliased - method inherited from Api class! +class SailBoat extends Api {} + +const instance = new SailBoat(); +instance.driver(); // default +``` + +The only way to resolve the above shown issue, is by making use of a [custom alias](./conflictResolution.md#resolve-naming-conflicts) +and manually overwrite the inherited method. E.g. + +```js +import { AbstractConcern, use } from "@aedart/support/concerns"; + +class Connection extends AbstractConcern { + driver() { + return 'special'; + } +} + +class Api { + driver() { + return 'default'; + } +} + +@user( + [Connection, { + 'driver': 'specialDriver' // alias "driver" as "specialDriver" + }] +) +class SailBoat extends Api { + + // Overwrite inherited method + driver() { + // Invoke the "specialDriver"... + return this.specialDriver(); + } +} + +const instance = new SailBoat(); +instance.driver(); // special +``` + +## Concerns using other concerns + +A concern can use other concerns classes. However, depending on your complexity, doing so may impact performance. +Consider the following example: + +```js +import { AbstractConcern, use } from "@aedart/support/concerns"; + +class Ping extends AbstractConcern { + ping() { + return 'ping'; + } +} + +@use(Ping) +class Pong extends AbstractConcern { + pong() { + return 'pong'; + } +} + +@use(Pong) +class Game {} + +const instance = new Game(); + +instance.ping(); // ping +instance.pong(); // pong +``` + +In the above shown example, whenever the `ping()` method is invoked, the call stack will be similar to the following: + +``` +Game (instance).ping() -> Pong (instance).ping() -> Ping (instance).ping() + +("->" represents concerns container instance) +``` + +In some isolated cases, this might be acceptable for you. Nevertheless, if your application makes heavy use of concerns +using other concerns, then your application's overall performance could suffer. You should consider merging multiple +concern classes into a single class, if it is reasonable and possible. Alternatively, you can also consider extending +existing concern classes. For instance: + +```js +import { AbstractConcern, use } from "@aedart/support/concerns"; + +class Ping extends AbstractConcern { + ping() { + return 'ping'; + } +} + +// Extend Ping concern... +class Pong extends Ping { + pong() { + return 'pong'; + } +} + +@use(Pong) +class Game {} + +const instance = new Game(); + +instance.ping(); // ping +instance.pong(); // pong +``` + +Now, whenever the `ping()` method is invoked, the call stack is slightly reduced: + +``` +Game (instance).ping() -> Pong (instance).ping() + +("->" represents concerns container instance) +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/concerns/hooks.md b/docs/archive/current/packages/support/concerns/hooks.md new file mode 100644 index 00000000..8d5dc02a --- /dev/null +++ b/docs/archive/current/packages/support/concerns/hooks.md @@ -0,0 +1,62 @@ +--- +title: Hooks +description: How to use registration hooks +sidebarDepth: 0 +--- + +# Hooks + +Concerns offer a few hook methods. These can be used to perform advanced setup or initialisation logic. + +[[TOC]] + +## `BEFORE` Registration + +To perform pre-registration logic, use the static `BEFORE` method in your concern class. +This hook method is invoked before the concern container and aliases are defined in the target class. + +The method accepts the following arguments: + +- `target: UsesConcerns` - the target class (_class constructor!_). + +```js +import { BEFORE } from "@aedart/contracts/support/concerns"; +import { AbstractConcern } from "@aedart/support/concerns"; +import { isSubclass } from '@aedart/support/reflections'; + +import { JobHandler } from '@acme/jobs'; + +class RecordsJobs extends AbstractConcern { + + static [BEFORE](target) { + // E.g. prevent this concern from being used by all kinds of targets... + if (!isSubclass(target, JobHandler)) { + throw new TypeError('RecordsJobs can only be used by JobHandler'); + } + } +} +``` + +## `AFTER` Registration + +To perform post-registration logic, use the static `AFTER` method in your concern class. +This method is invoked after the concern container and aliases have been defined in target's prototype. + +The method accepts the following arguments: + +- `target: UsesConcerns` - the target class (_class constructor!_). + +```js +import { AFTER } from "@aedart/contracts/support/concerns"; +import { AbstractConcern } from "@aedart/support/concerns"; + +import { ApiConnection } from '@acme/api'; + +class RecordsJobs extends AbstractConcern { + + static [AFTER](target) { + // E.g. init or setup static resources... + ApiConnection.init(); + } +} +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/concerns/jsdoc.md b/docs/archive/current/packages/support/concerns/jsdoc.md new file mode 100644 index 00000000..1546aa5a --- /dev/null +++ b/docs/archive/current/packages/support/concerns/jsdoc.md @@ -0,0 +1,211 @@ +--- +title: JSDoc +description: A wat to document what concerns a targer class uses. +sidebarDepth: 0 +--- + +# JSDoc + +Most modern IDEs support [JSDoc](https://jsdoc.app/). They can improve your coding experience via code suggestions, +highlights and other features. In this chapter, you will find a few ways that you can document your concerns and +target class, via JSDoc. + +[[TOC]] + +::: tip Help wanted! +_If you are an expert in JSDoc, then you are most welcome to help improve this chapter. +Please see the [contribution guide](../../../contribution-guide.md) for details on how you can contribute._ +::: + +## `@mixin` and `@mixes` + +Possibly the easiest way to document your concern and target class that uses the concern, is via the +[`@mixin` and `@mixes`](https://jsdoc.app/tags-mixes) tags. + +**Downside**: _Documenting "aliases" (known as virtual fields in the context of JSDoc) is not possible via `@mixin` and `@mixes`. +You can describe an alias via [`@function`](https://jsdoc.app/tags-function) or [`@member`](https://jsdoc.app/tags-member) tag._ + +```js +import { use, AbstractConcern } from "@aedart/support/concerns"; + +/** + * @mixin + * @extends AbstractConcern + */ +class Shield extends AbstractConcern { + + /** + * Returns the armor level + * + * @returns {number} + */ + get armor() { + return 8; + } + + /** + * Throw shield towards a target + * + * @param {object} target + * + * @returns {number} Damage given to target + */ + throw(target) { + // target ignored here... + return 3; + } +} + +/** + * @mixes Shield + */ +@use([Shield, { + 'throw': 'fight' +}]) +class Monster { + + /** + * Alias for {@link Shield#throw} + * + * @function fight + * @param {object} target The target to throw at... + * @return {number} Damage taken by target + * @instance + * @memberof Monster + */ + + /** + * Do stuff... + */ + do() { + this.fight({}); + } +} +``` + +## `@property` + +Another possibility is to describe properties and methods available in a target, via the [`@property`](https://jsdoc.app/tags-property) tag. +Doing so allows you to immediately describe the "alias" name. + +**Downside**: _Properties and methods described via `@property` are listed as "static" on the target class. +Also, it is not possible to reuse existing JSDoc from your concern._ + +```js +import { use, AbstractConcern } from "@aedart/support/concerns"; + +class Armor extends AbstractConcern { + + /** + * Returns the armor level + * + * @returns {number} + */ + get level() { + return 8; + } +} + +/** + * @property {number} armor Returns the armor level + */ +@use([Armor, { + 'level': 'armor' +}]) +class Hero {} +``` + +## `@borrows` + +The [`@borrows`](https://jsdoc.app/tags-borrows) tag does also offer a possible way to describe aliases. + +**Downside**: _You are still required to use [`@member`](https://jsdoc.app/tags-member) tag to describe the actual +aliases inside your target class._ + +```js +import { use, AbstractConcern } from "@aedart/support/concerns"; + +/** + * @extends AbstractConcern + */ +class Spell extends AbstractConcern { + + /** + * Cast the spell + * + * @name cast + * + * @returns {number} Damage done + */ + cast() { + return 7; + } +} + +/** + * @borrows Spell#cast as damage + */ +@use([Spell, { + 'cast': 'damage' +}]) +class Mage { + + /** + * @function damage + * @return {number} + * @instance + * @memberof Npc + */ +} +``` + +## `@member` + +Lastly, you can use [`@member`](https://jsdoc.app/tags-member) to describe all aliases directly, without relying on the [`@borrows`](https://jsdoc.app/tags-borrows) tag. + +**Downside**: _This approach can be very cumbersome. Also, reuse of JSDoc is not possible._ + +```js +import { use, AbstractConcern } from "@aedart/support/concerns"; + +class Sword extends AbstractConcern { + + /** + * Returns amount of damage + * + * @returns {number} + */ + get slash() { + return 3; + } + + /** + * Returns the sword type + * + * @name type + * @return {string} + */ + get type() { + return 'unique'; + } +} + + +@use([Sword, { + 'slash': 'damage' +}]) +class Enemy { + + /** + * @public + * @member {number} damage Alias for {@link Sword#slash} + * @memberof Enemy + */ + + /** + * @public + * @member {string} type Alias for {@link Sword#type} + * @memberof Enemy + */ +} +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/concerns/prerequisites.md b/docs/archive/current/packages/support/concerns/prerequisites.md new file mode 100644 index 00000000..e471f2f8 --- /dev/null +++ b/docs/archive/current/packages/support/concerns/prerequisites.md @@ -0,0 +1,10 @@ +--- +title: Prerequisites +description: Prerequisites for using concerns. +sidebarDepth: 0 +--- + +# Prerequisites + +At the time of this writing, [decorators](https://github.com/tc39/proposal-decorators) are still in a proposal phase. +To use concerns, you must either use [@babel/plugin-proposal-decorators](https://babeljs.io/docs/babel-plugin-proposal-decorators), or use [TypeScript 5 decorators](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#decorators). \ No newline at end of file diff --git a/docs/archive/current/packages/support/concerns/usage.md b/docs/archive/current/packages/support/concerns/usage.md new file mode 100644 index 00000000..14508344 --- /dev/null +++ b/docs/archive/current/packages/support/concerns/usage.md @@ -0,0 +1,189 @@ +--- +title: Using Concerns +description: How to use concerns. +sidebarDepth: 0 +--- + +# How to use Concerns + +[[TOC]] + +## Using Concerns + +The [class decorator](https://github.com/tc39/proposal-decorators) `use()` is used to inject one or more concern classes into a target class. + +```js +import { use } from "@aedart/support/concerns"; + +@use( + ApiConnection, + Serialization, + Collections +) +class Flight {} +``` + +When concern classes are injected, the target class is transformed and all concerns are made available inside a private `CONCERNS` property. +See [Manual interaction](#manual-interaction) and [Aliases](./aliases.md) for additional details. + +## Inheritance + +All concerns that are used by a parent class are automatically available (_inherited_), by child classes. + +```js +@use( + ApiConnection, + Serialization, + Collections +) +class ApiService {} + +class Flight extends ApiService {} // Uses ApiConnection, Serialization, ...etc +``` + +::: warning + +A concern class may _**ONLY**_ occur once in a target class' prototype chain. +An `InjectionError` is thrown, if this is violated! + +```js +@use( + ApiConnection, + Serialization, + Collections +) +class ApiService {} + +@use(Serialization) // InjectionError +class Flight extends ApiService {} +``` + +See also [Conflict Resolution](./conflictResolution.md) for additional details. +::: + +## Manual interaction + +When concerns are injected into a target, they are defined inside a "Concerns Container", which is available in the target instance via the `CONCERNS` symbol. +Should you require to perform more advanced interaction with a concern class instance, then you can obtain a concern instance via the container's `get()` method. +It will automatically ensure to [boot](./booting.md) a concern, if not already booted. + +```js +import { + use, + CONCERNS, + AbstractConcern +} from "@aedart/support/concerns"; + +class Encryption extends AbstractConcern { + encrypt(value) { /* ...not shown... */ } +} + +@use(Encryption) +class CookieStore { + constructor() { + const container = this[CONCERNS]; + const value = container.get(Encryption).encrypt('Lorum lipsum'); + + // ...remaining not shown... + } +} +``` + +You can achieve the same result by using the `getContainer()` utility method. + +```js +import { use, getContainer } from "@aedart/support/concerns"; + +// ...Encryption concern not shown... + +@use(Encryption) +class CookieStore { + constructor() { + const value = getContainer(this) + .get(Encryption) + .encrypt('Lorum lipsum'); + + // ...remaining not shown... + } +} +``` + +::: details CONCERNS symbol, getContainer(), and getConcernsContainer() + +There are 3 ways to obtain the concerns container instance: + +**A) `CONCERNS` symbol** + +Inside your target class, if you know that concerns are used (_if target is a "concern owner"_), +then you can use the `CONCERNS` symbol to gain access to the container. + +```js +import { CONCERNS } from "@aedart/support/concerns"; + +// Inside your target class... +const container = this[CONCERNS]; +``` + +**B) `getContainer()`** + +`getContainer()` is essentially a just a wrapper for: `return this[CONCERNS]`. + +```js +import { getContainer } from "@aedart/support/concerns"; + +// Inside your target class... +const container = getContainer(this); +``` + +**C) `getConcernsContainer()`** + +The `getConcernsContainer()` achieves the same result as the previous shown methods. However, it does perform a +check of the provided target instance, which ensures that it is a "concern owner". +If the target does not pass this test, then a `TypeError` is thrown. +This might can be useful in situations when you might now know if the target is a concern owner, e.g. when situated +in a child class or outside a target class. + +```js +import { getConcernsContainer } from "@aedart/support/concerns"; + +// Inside your target class... +const container = getConcernsContainer(this); +``` +::: + +### Determine if target uses concerns + +To determine if a target uses one or more concerns, use the `usesConcerns()` method. +It accepts the following arguments: + +- `instance: object|Owner` - The target class instance. +- `...concerns: ConcernConstructor[]` - Concern classes to test for. + +```js +import { + use, + AbstractConcern, + usesConcerns +} from "@aedart/support/concerns"; + +class A extends AbstractConcern {} +class B extends AbstractConcern {} +class C extends AbstractConcern {} + +@use( + A, + B +) +class Game {} + +const instance = new Game(); + +usesConcerns(instance, A); // true +usesConcerns(instance, B); // true +usesConcerns(instance, A, B); // true + +usesConcerns(instance, C); // false +usesConcerns(instance, A, C); // false +usesConcerns(instance, B, C); // false +usesConcerns(instance, A, B, C); // false +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/exceptions/README.md b/docs/archive/current/packages/support/exceptions/README.md new file mode 100644 index 00000000..51cdc484 --- /dev/null +++ b/docs/archive/current/packages/support/exceptions/README.md @@ -0,0 +1,8 @@ +--- +title: About Exceptions +description: Error & Exceptions utilities. +--- + +# Exceptions + +`@aedart/support/exceptions` offers a few utilities for working with [Custom Errors](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#custom_error_types). \ No newline at end of file diff --git a/docs/archive/current/packages/support/exceptions/configureCustomError.md b/docs/archive/current/packages/support/exceptions/configureCustomError.md new file mode 100644 index 00000000..8de90971 --- /dev/null +++ b/docs/archive/current/packages/support/exceptions/configureCustomError.md @@ -0,0 +1,32 @@ +--- +title: Configure Custom Error +description: Configuration of custom errors. +sidebarDepth: 0 +--- + +# `configureCustomError` + +Configures a custom error by automatically setting the error's `name` property to the class' constructor name. + +## Arguments + +`configureCustomError`() accepts the following arguments: + +* `error: Error` - the custom error instance +* `captureStackTrace: boolean = false` (_optional_) Captures and sets error's stack trace¹. + +¹: _See [`configureStackTrace()`](./configurestacktrace.md) for details._ + +```js +import { configureCustomError } from "@aedart/support/exceptions"; + +class MyError extends Error { + constructor(message, options) { + super(message, options); + + configureCustomError(this); + } +} +``` + +See [Mozilla's documentation on Custom Error Types](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#custom_error_types) for additional information. diff --git a/docs/archive/current/packages/support/exceptions/configureStackTrace.md b/docs/archive/current/packages/support/exceptions/configureStackTrace.md new file mode 100644 index 00000000..0e743c85 --- /dev/null +++ b/docs/archive/current/packages/support/exceptions/configureStackTrace.md @@ -0,0 +1,31 @@ +--- +title: Configure Stack Trace +description: Configuration error stack trace. +sidebarDepth: 0 +--- + +# `configureStackTrace` + +Captures a new stack trace and sets given Error's [`stack`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/stack) property. + +## Arguments + +The function accepts an `Error` as argument. + +```js +import { configureStackTrace } from "@aedart/support/exceptions"; + +class MyError extends Error { + constructor(message, options) { + super(message, options); + + configureStackTrace(this); + } +} +``` + +::: warning +The `stack` is [not yet an official feature](https://github.com/tc39/proposal-error-stacks), even though it's supported by many major browsers and Node.js. +If you are working with custom errors, you might not need to capture and set the `stack` property. +Therefore, you should only use `configureStackTrace()` in situations when your JavaScript environment does not support stack traces in custom errors. +::: \ No newline at end of file diff --git a/docs/archive/current/packages/support/exceptions/customErrors.md b/docs/archive/current/packages/support/exceptions/customErrors.md new file mode 100644 index 00000000..c156c052 --- /dev/null +++ b/docs/archive/current/packages/support/exceptions/customErrors.md @@ -0,0 +1,46 @@ +--- +title: Custom Errors +description: Predefined custom errors. +sidebarDepth: 0 +--- + +# Custom Errors + +[[TOC]] + +## `AbstractClassError` + +The `AbstractClassError` is intended to be thrown whenever an abstract class is attempted instantiated directly. + +```js +import { AbstractClassError } from "@aedart/support/exceptions"; + +/** + * @abstract + */ +class MyAbstractClass { + constructor() { + if (new.target === MyAbstractClass) { + throw new AbstractClassError(MyAbstractClass); + } + } +} + +const instance = new MyAbstractClass(); // AbstractClassError +``` + +## `LogicalError` + +To be thrown whenever there is an error in the programming logic. + +_Inspired by PHP's [`LogicException`](https://www.php.net/manual/en/class.logicexception)_ + +```js +import { LogicalError } from "@aedart/support/exceptions"; + +function print(person) { + if (printer === undefined) { + throw new LogicalError('Printer is missing, unable to print people'); + } +} +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/exceptions/getErrorMessage.md b/docs/archive/current/packages/support/exceptions/getErrorMessage.md new file mode 100644 index 00000000..9251f12f --- /dev/null +++ b/docs/archive/current/packages/support/exceptions/getErrorMessage.md @@ -0,0 +1,28 @@ +--- +title: Get Error Message +description: Obtain error or default message. +sidebarDepth: 0 +--- + +# `getErrorMessage` + +Returns an Error's `message`, if an [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) +instance is provided. Otherwise, a default message is returned. + +```js +import { getErrorMessage } from "@aedart/support/exceptions"; + +try { + throw new Error('Something went wrong!'); +} catch(e) { + const msg = getErrorMessage(e, 'unknown error'); // Something went wrong! +} + +// --------------------------------------------------------------------------- + +try { + throw 'Something went wrong!'; +} catch(e) { + const msg = getErrorMessage(e, 'unknown error'); // unknown error +} +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/meta.md b/docs/archive/current/packages/support/meta.md deleted file mode 100644 index 0d1bcc04..00000000 --- a/docs/archive/current/packages/support/meta.md +++ /dev/null @@ -1,426 +0,0 @@ ---- -title: Meta -description: Add arbitrary metadata on classes, methods and properties. -sidebarDepth: 0 ---- - -# Meta - -Provides a decorator that is able to associate metadata with a class, its methods and properties. - -```js -import {meta, getMeta} from '@aedart/support/meta'; - -@meta('service_alias', 'locationSearcher') -class Service {} - -getMeta(Service, 'service_alias'); // locationSearcher -``` - -[[TOC]] - -## Prerequisites - -At the time of this writing, [decorators](https://github.com/tc39/proposal-decorators) are still in a proposal phase. -To use the meta decorator, you must either use [@babel/plugin-proposal-decorators](https://babeljs.io/docs/babel-plugin-proposal-decorators), or use [TypeScript 5 decorators](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#decorators). - -## Supported Elements - -The `meta` decorator supports the following elements¹: - -* `class` -* `method` -* `getter` -* `setter` -* `field` -* `accessor` - -¹: _An element is determined by the decorator's [`context.kind`](https://github.com/tc39/proposal-decorators#2-calling-decorators) property._ - -## Defining and Retrieving Metadata - -To define metadata on a class or its elements, use `meta()`. -It accepts the following arguments: - -* `key`: _name of metadata identifier. Can also be a path (_see [`set`](./objects.md#set)_)._ -* `value`: _arbitrary data. Can be a [primitive value](https://developer.mozilla.org/en-US/docs/Glossary/Primitive), an [object](https://developer.mozilla.org/en-US/docs/Glossary/Object), or a [function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function)._ - -To obtain metadata, use the `getMeta()` method. -You can also use `getAllMeta()`, if you wish to obtain all available metadata for a target class. - -```js -import {meta, getMeta, getAllMeta} from '@aedart/support/meta'; - -@meta('service_alias', 'locationSearcher') -class Service -{ - @meta('name', 'Name of service') name; - - @meta('fetch.desc', 'Fetches resource via a gateway') - @meta('fetch.dependencies', [ 'my-gateway' ]) - async fetch(gateway) - { - // ...implementation not shown... - } -} - -// Later in your application... -const service = new Service(); - -const desc = getMeta(Service, 'fetch.desc'); -const dependencies = getMeta(Service, 'fetch.dependencies'); - -// Or, obtain all metadata -const allMeta = getAllMeta(Service); -``` - -::: tip Metadata Availability -Depending on the kind of element that is decorated, metadata might only **_become available_** for reading, **_after_** a new class instance has been instantiated. -This is true for the following elements: -* `method` -* `getter` -* `setter` -* `field` -* `accessor` - -**Static Elements** - -If an element is declared as [`static`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/static), then it's metadata becomes available as soon as the class has been defined. -::: - -### Default Value - -The `getMeta()` method also offers a `defaultValue` argument, which is returned, in case that a metadata value does not exist for a given identifier. - -```js -const description = getMeta(Service, 'fetch.desc', 'N/A - method has no description'); -``` - -### Callback - -If you need to create more advanced metadata, you can specify a callback as the first argument for the `meta()` decorator method. -When using a callback you gain access to the `target` that is being decorated, as well as the decorator `context`. -The callback **MUST** return an object that contains a `key` and a `value` property. - -```js -import {meta} from '@aedart/support/meta'; - -class Service { - - @meta((target, context) => { - return { - key: context.name, - value: '...' - } - }) - delegateTo(gateway) { - // ...not shown... - } -} -``` - -Although the above example is a bit cumbersome to read, it shows a simple way to defined metadata for a method, which utilises the decorator `context`. -If you wish, you can use this approach to create your own specialised meta decorators. Doing so can also improve the readability of your class. -Consider the following example: - -```js -import {meta} from '@aedart/support/meta'; - -function delegateMeta() { - return meta((target, context) => { - return { - key: context.name, - value: '...' - } - }); -} - -class Service { - - @delegateMeta() - delegateTo(gateway) { - // ...not shown... - } -} -``` - -## Inheritance - -Metadata is automatically inherited by subclasses. - -```js -import {meta, getMeta} from '@aedart/support/meta'; - -@meta('service_alias', 'locationSearcher') -class Service {} - -class CitySearcher extends Service {} - -getMeta(CitySearcher, 'service_alias'); // locationSearcher -``` - -### Overwrites - -You can also overwrite the inherited metadata. The subclass that defines the metadata creates its own copy of the inherited metadata. -The parent class' metadata remains untouched. - -```js -import {meta, getMeta} from '@aedart/support/meta'; - -class Service { - - @meta('search.desc', 'Searches for countries') - search() { - // ...not shown... - } -} - -class CitySearcher extends Service { - - @meta('search.desc', 'Searches for cities') - search() { - // ...not shown... - } -} - -const service = new CitySearcher(); - -getMeta(CitySearcher, 'search.desc'); // Searches for cities -getMeta(Service, 'search.desc'); // Searches for countries -``` - -## Changes outside the decorator - -Whenever you read metadata, **_a copy_** is returned by the `getMeta()` method. -This means that you can change the data, in your given context, but the original metadata remains the same. - -```js -import {meta, getMeta} from '@aedart/support/meta'; - -@meta('description', { name: 'Search Service', alias: 'Location Sercher' }) -class Service {} - -// Obtain "copy" and change it... -let desc = getMeta(Service, 'description'); -desc.name = 'Country Searcher'; - -// Original remains unchanged -getMeta(Service, 'description').name; // Search Service -``` - -::: warning Caution -Only the `meta` decorator is intended to alter existing metadata - _even if the value is an object_. -Please be mindful of this behaviour, whenever you change retrieved metadata using the `getMeta()` and `getAllMeta()` methods. -::: - -## TC39 Decorator Metadata - -In relation to the [Decorator Metadata proposal](https://github.com/tc39/proposal-decorator-metadata), this decorator _"mimics"_ a similar behaviour as the one defined by the proposal. -Defining and retrieving metadata relies on a decorator's `context.metadata` object, and the `Symbol.metadata` property of a class. - -**Example:** - -```js -import {meta, getMeta} from '@aedart/support/meta'; - -@meta('service_alias', 'locationSearcher') -class Service {} - -getMeta(Service, 'service_alias'); // locationSearcher -``` - -**Roughly "desugars" to the following:** -```js -function meta(key, value) { - return (target, context) => { - context.metadata[key] = value; - } -} - -@meta('service_alias', 'locationSearcher') -class Service {} - -Service[Symbol.metadata].service_alias; // locationSearcher -``` -(_Above shown example is very simplified. Actual implementation is a bit more complex..._) - -At present, the internal mechanisms of the `meta` decorator must rely on a [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) to associate metadata with the intended class. -When the [Decorator Metadata proposal](https://github.com/tc39/proposal-decorator-metadata) becomes more mature and transpilers offer the `context.metadata` object (_or when browsers support it_), -then this decorator will be updated respectfully to use the available metadata object. - -## Target Meta - -The `targetMeta()` decorator offers the ability to associate metadata directly with a class instance or class method reference. -This can be useful in situations when you do not know the class that owns the metadata. - -Behind the scene, `targetMeta()` uses the `meta()` decorator and stores a reference to the target that is decorated inside a [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap). - -::: tip Supported Elements - -Unlike the [`meta()` decorator](#supported-elements), `targetMeta()` only supports the following elements: - -* `class` -* `method` - -::: - -**Example: class instance** - -```js -import {targetMeta, getTargetMeta} from '@aedart/support/meta'; - -@targetMeta('description', { type: 'Search Service', alias: 'Location Sercher' }) -class LocationSearcherService {} - -const instance = new LocationSearcherService(); - -// ...later in your application... -getTargetMeta(instance, 'description')?.type; // Search Service -``` - -**Example: method reference** - -```js -import {targetMeta, getTargetMeta} from '@aedart/support/meta'; - -class LocationSearcherService { - - @targetMeta('dependencies', [ 'httpClient' ]) - search(apiClient) {} -} - -const instance = new LocationSearcherService(); - -// ...later in your application... -getTargetMeta(instance.search, 'dependencies'); // [ 'httpClient' ] -``` - -### Inheritance - -Target meta is automatically inherited by subclasses and can also be overwritten, similar to that of the [`meta()` decorator](#inheritance). - -**Example: classes** - -```js -import {targetMeta, getTargetMeta} from '@aedart/support/meta'; - -@meta('service_alias', 'locationSearcher') -class Service {} - -class CitySearcher extends Service {} - -const instance = new CitySearcher(); - -// ...later in your application... -getTargetMeta(instance, 'service_alias'); // locationSearcher -``` - -**Example: methods** - -```js -import {targetMeta, getTargetMeta} from '@aedart/support/meta'; - -class Service { - - @targetMeta('dependencies', [ 'countrySearchApiClient' ]) - search(apiClient) { - // ...not shown... - } -} - -class CountrySearcher extends Service { - // ... not method overwrite here... -} - -class CitySearcher extends Service { - - @targetMeta('dependencies', [ 'citySearchApiClient' ]) - search(apiClient) { - // ...not shown... - } -} - -const instanceA = new Service(); -const instanceB = new CountrySearcher(); -const instanceC = new CitySearcher(); - -// ...later in your application... -getTargetMeta(instanceA.search, 'dependencies'); // [ 'countrySearchApiClient' ] -getTargetMeta(instanceB.search, 'dependencies'); // [ 'countrySearchApiClient' ] -getTargetMeta(instanceC.search, 'dependencies'); // [ 'citySearchApiClient' ] -``` - -#### Static Methods - -Inheritance for static methods works a bit differently. By default, any subclass will automatically inherit target metadata, even for static methods. -However, if you overwrite the given static method, the metadata is lost. - -::: tip Limitation - -_When a static method is overwritten, the parent's "target" metadata cannot be obtained due to a general limitation of the `meta()` decorator. -The decorator has no late `this` binding available to the overwritten static method. -This makes it impossible to associate the overwritten static method with metadata from the parent._ - -::: - -**Example: inheritance for static methods** - -```js -import {targetMeta, getTargetMeta} from '@aedart/support/meta'; - -class Service { - - @targetMeta('dependencies', [ 'xmlClient' ]) - static search(client) { - // ...not shown... - } -} - -class CountrySearcher extends Service { - // ... not method overwrite here... -} - -class CitySearcher extends Service { - - // Overwite of static method - target meta is lost - static search(client) {} -} - -// ...later in your application... -getTargetMeta(CountrySearcher.search, 'dependencies'); // [ 'xmlClient' ] -getTargetMeta(CitySearcher.search, 'dependencies'); // undefined -``` - -To overcome the above shown issue, you can use the `inheritTargetMeta()` decorator. It forces the static method to "copy" metadata from its parent, if available. - -**Example: force inheritance for static methods** - -```js -import { - targetMeta, - getTargetMeta, - inheritTargetMeta -} from '@aedart/support/meta'; - -class Service { - - @targetMeta('dependencies', [ 'xmlClient' ]) - static search(client) { - // ...not shown... - } -} - -class CountrySearcher extends Service { - // ... not method overwrite here... -} - -class CitySearcher extends Service { - - @inheritTargetMeta() - static search(client) {} -} - -// ...later in your application... -getTargetMeta(CountrySearcher.search, 'dependencies'); // [ 'xmlClient' ] -getTargetMeta(CitySearcher.search, 'dependencies'); // [ 'xmlClient' ] -``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/meta/README.md b/docs/archive/current/packages/support/meta/README.md new file mode 100644 index 00000000..dca7ad73 --- /dev/null +++ b/docs/archive/current/packages/support/meta/README.md @@ -0,0 +1,18 @@ +--- +title: About Meta +description: Add arbitrary metadata on classes, methods and properties. +--- + + +# About Meta + +Provides a decorator that is able to associate metadata with a class, its methods and properties. + +```js +import { meta, getMeta } from '@aedart/support/meta'; + +@meta('service_alias', 'locationSearcher') +class Service {} + +getMeta(Service, 'service_alias'); // locationSearcher +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/meta/inheritance.md b/docs/archive/current/packages/support/meta/inheritance.md new file mode 100644 index 00000000..591a187f --- /dev/null +++ b/docs/archive/current/packages/support/meta/inheritance.md @@ -0,0 +1,51 @@ +--- +title: Inheritance +description: About metadata inheritance and overwrites. +sidebarDepth: 0 +--- + +# Inheritance + +Metadata is automatically inherited by subclasses. + +```js +import { meta, getMeta } from '@aedart/support/meta'; + +@meta('service_alias', 'locationSearcher') +class Service {} + +class CitySearcher extends Service {} + +getMeta(CitySearcher, 'service_alias'); // locationSearcher +``` + + +## Overwrites + +You can also overwrite the inherited metadata. The subclass that defines the metadata creates its own copy of the inherited metadata. +The parent class' metadata remains untouched. + +```js +import { meta, getMeta } from '@aedart/support/meta'; + +class Service { + + @meta('search.desc', 'Searches for countries') + search() { + // ...not shown... + } +} + +class CitySearcher extends Service { + + @meta('search.desc', 'Searches for cities') + search() { + // ...not shown... + } +} + +const service = new CitySearcher(); + +getMeta(CitySearcher, 'search.desc'); // Searches for cities +getMeta(Service, 'search.desc'); // Searches for countries +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/meta/outsideChanges.md b/docs/archive/current/packages/support/meta/outsideChanges.md new file mode 100644 index 00000000..eadd59d4 --- /dev/null +++ b/docs/archive/current/packages/support/meta/outsideChanges.md @@ -0,0 +1,29 @@ +--- +title: Outside Changes +description: About changes to metadata outside decorator scope. +sidebarDepth: 0 +--- + +# Changes outside the decorator + +Whenever you read metadata, **_a copy_** is returned by the `getMeta()` method. +This means that you can change the data, in your given context, but the original metadata remains the same. + +```js +import { meta, getMeta } from '@aedart/support/meta'; + +@meta('description', { name: 'Search Service', alias: 'Location Sercher' }) +class Service {} + +// Obtain "copy" and change it... +let desc = getMeta(Service, 'description'); +desc.name = 'Country Searcher'; + +// Original remains unchanged +getMeta(Service, 'description').name; // Search Service +``` + +::: warning Caution +Only the `meta` decorator is intended to alter existing metadata - _even if the value is an object_. +Please be mindful of this behaviour, whenever you change retrieved metadata using the `getMeta()` and `getAllMeta()` methods. +::: \ No newline at end of file diff --git a/docs/archive/current/packages/support/meta/prerequisites.md b/docs/archive/current/packages/support/meta/prerequisites.md new file mode 100644 index 00000000..6e54aae8 --- /dev/null +++ b/docs/archive/current/packages/support/meta/prerequisites.md @@ -0,0 +1,10 @@ +--- +title: Prerequisites +description: Prerequisites for using meta decorators. +sidebarDepth: 0 +--- + +# Prerequisites + +At the time of this writing, [decorators](https://github.com/tc39/proposal-decorators) are still in a proposal phase. +To use the meta decorator, you must either use [@babel/plugin-proposal-decorators](https://babeljs.io/docs/babel-plugin-proposal-decorators), or use [TypeScript 5 decorators](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#decorators). \ No newline at end of file diff --git a/docs/archive/current/packages/support/meta/setAndGet.md b/docs/archive/current/packages/support/meta/setAndGet.md new file mode 100644 index 00000000..63634330 --- /dev/null +++ b/docs/archive/current/packages/support/meta/setAndGet.md @@ -0,0 +1,123 @@ +--- +title: Set & Get +description: Defining and retrieving metadata. +sidebarDepth: 0 +--- + +# Set and Get Metadata + +[[TOC]] + +## Set Metadata + +To define metadata on a class or its elements, use `meta()`. +It accepts the following arguments: + +* `key`: _name of metadata identifier. Can also be a path (_see [`set`](../objects/set.md)_)._ +* `value`: _arbitrary data. Can be a [primitive value](https://developer.mozilla.org/en-US/docs/Glossary/Primitive), an [object](https://developer.mozilla.org/en-US/docs/Glossary/Object), or a [function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function)._ + +To obtain metadata, use the `getMeta()` method. +You can also use `getAllMeta()`, if you wish to obtain all available metadata for a target class. + +```js +import { meta } from '@aedart/support/meta'; + +@meta('service_alias', 'locationSearcher') +class Service +{ + @meta('name', 'Name of service') name; + + @meta('fetch.desc', 'Fetches resource via a gateway') + @meta('fetch.dependencies', [ 'my-gateway' ]) + async fetch(gateway) + { + // ...implementation not shown... + } +} +``` + +## Get Metadata + +Use `getMeta()` or `getAllMeta()` to retrieve metadata. + +```js +import { getMeta, getAllMeta } from '@aedart/support/meta'; + +const service = new Service(); + +const desc = getMeta(Service, 'fetch.desc'); +const dependencies = getMeta(Service, 'fetch.dependencies'); + +// Or, obtain all metadata +const allMeta = getAllMeta(Service); +``` + +::: tip Metadata Availability +Depending on the kind of element that is decorated, metadata might only **_become available_** for reading, **_after_** a new class instance has been instantiated. +This is true for the following elements: +* `method` +* `getter` +* `setter` +* `field` +* `accessor` + +**Static Elements** + +If an element is declared as [`static`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/static), then it's metadata becomes available as soon as the class has been defined. +::: + +### Default Value + +The `getMeta()` method also offers a `defaultValue` argument, which is returned, in case that a metadata value does not exist for a given identifier. + +```js +const description = getMeta(Service, 'fetch.desc', 'N/A'); +``` + +## Callback + +If you need to create more advanced metadata, you can specify a callback as the first argument for the `meta()` decorator method. +When using a callback you gain access to the `target` that is being decorated, as well as the decorator `context`. +The callback **MUST** return an object that contains a `key` and a `value` property. + +```js +import { meta } from '@aedart/support/meta'; + +class Service { + + @meta((target, context) => { + return { + key: context.name, + value: '...' + } + }) + delegateTo(gateway) { + // ...not shown... + } +} +``` + +Although the above example is a bit cumbersome to read, it shows a simple way to defined metadata for a method, which utilises the decorator `context`. +If you wish, you can use this approach to create your own specialised meta decorators. Doing so can also improve the readability of your class. +Consider the following example: + +```js +import { meta } from '@aedart/support/meta'; + +function delegateMeta() { + return meta((target, context) => { + return { + key: context.name, + value: '...' + } + }); +} + +class Service { + + @delegateMeta() + delegateTo(gateway) { + // ...not shown... + } +} +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/meta/supported.md b/docs/archive/current/packages/support/meta/supported.md new file mode 100644 index 00000000..65e7e580 --- /dev/null +++ b/docs/archive/current/packages/support/meta/supported.md @@ -0,0 +1,18 @@ +--- +title: Supported Elements +description: Supported elements by meta decorators. +sidebarDepth: 0 +--- + +# Supported Elements + +The `meta` decorator supports the following elements¹: + +* `class` +* `method` +* `getter` +* `setter` +* `field` +* `accessor` + +¹: _An element is determined by the decorator's [`context.kind`](https://github.com/tc39/proposal-decorators#2-calling-decorators) property._ \ No newline at end of file diff --git a/docs/archive/current/packages/support/meta/targetMeta.md b/docs/archive/current/packages/support/meta/targetMeta.md new file mode 100644 index 00000000..41ba2268 --- /dev/null +++ b/docs/archive/current/packages/support/meta/targetMeta.md @@ -0,0 +1,186 @@ +--- +title: Target Meta +description: Associate metadata with a class instance or class method reference. +sidebarDepth: 0 +--- + +# Target Meta + +The `targetMeta()` decorator offers the ability to associate metadata directly with a class instance or class method reference. +This can be useful in situations when you do not know the class that owns the metadata. + +Behind the scene, `targetMeta()` uses the `meta()` decorator and stores a reference to the target that is decorated inside a [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap). + +[[TOC]] + +## Supported Elements + +Unlike the [`meta()` decorator](./supported.md), `targetMeta()` only supports the following elements: + +* `class` +* `method` + +## Class Instance + +The following shows how to define target meta for a class and retrieve it. + +```js +import { targetMeta, getTargetMeta } from '@aedart/support/meta'; + +@targetMeta('description', { type: 'Search Service', alias: 'Location Sercher' }) +class LocationSearcherService {} + +const instance = new LocationSearcherService(); + +// ...later in your application... +getTargetMeta(instance, 'description')?.type; // Search Service +``` + +## Method Reference + +The following shows how to define target meta for a class method and retrieve it. + +```js +import { targetMeta, getTargetMeta } from '@aedart/support/meta'; + +class LocationSearcherService { + + @targetMeta('dependencies', [ 'httpClient' ]) + search(apiClient) {} +} + +const instance = new LocationSearcherService(); + +// ...later in your application... +getTargetMeta(instance.search, 'dependencies'); // [ 'httpClient' ] +``` + +## Inheritance + +Target meta is automatically inherited by subclasses and can also be overwritten, similar to that of the [`meta()` decorator](#inheritance). + +**Example: classes** + +```js +import {targetMeta, getTargetMeta} from '@aedart/support/meta'; + +@meta('service_alias', 'locationSearcher') +class Service {} + +class CitySearcher extends Service {} + +const instance = new CitySearcher(); + +// ...later in your application... +getTargetMeta(instance, 'service_alias'); // locationSearcher +``` + +**Example: methods** + +```js +import {targetMeta, getTargetMeta} from '@aedart/support/meta'; + +class Service { + + @targetMeta('dependencies', [ 'countrySearchApiClient' ]) + search(apiClient) { + // ...not shown... + } +} + +class CountrySearcher extends Service { + // ... not method overwrite here... +} + +class CitySearcher extends Service { + + @targetMeta('dependencies', [ 'citySearchApiClient' ]) + search(apiClient) { + // ...not shown... + } +} + +const instanceA = new Service(); +const instanceB = new CountrySearcher(); +const instanceC = new CitySearcher(); + +// ...later in your application... +getTargetMeta(instanceA.search, 'dependencies'); // [ 'countrySearchApiClient' ] +getTargetMeta(instanceB.search, 'dependencies'); // [ 'countrySearchApiClient' ] +getTargetMeta(instanceC.search, 'dependencies'); // [ 'citySearchApiClient' ] +``` + +### Static Methods + +Inheritance for static methods works a bit differently. By default, any subclass will automatically inherit target metadata, even for static methods. +However, if you overwrite the given static method, the metadata is lost. + +::: tip Limitation + +_When a static method is overwritten, the parent's "target" metadata cannot be obtained due to a general limitation of the `meta()` decorator. +The decorator has no late `this` binding available to the overwritten static method. +This makes it impossible to associate the overwritten static method with metadata from the parent._ + +::: + +**Example: inheritance for static methods** + +```js +import {targetMeta, getTargetMeta} from '@aedart/support/meta'; + +class Service { + + @targetMeta('dependencies', [ 'xmlClient' ]) + static search(client) { + // ...not shown... + } +} + +class CountrySearcher extends Service { + // ... not method overwrite here... +} + +class CitySearcher extends Service { + + // Overwite of static method - target meta is lost + static search(client) {} +} + +// ...later in your application... +getTargetMeta(CountrySearcher.search, 'dependencies'); // [ 'xmlClient' ] +getTargetMeta(CitySearcher.search, 'dependencies'); // undefined +``` + +To overcome the above shown issue, you can use the `inheritTargetMeta()` decorator. It forces the static method to "copy" metadata from its parent, if available. + +**Example: force inheritance for static methods** + +```js +import { + targetMeta, + getTargetMeta, + inheritTargetMeta +} from '@aedart/support/meta'; + +class Service { + + @targetMeta('dependencies', [ 'xmlClient' ]) + static search(client) { + // ...not shown... + } +} + +class CountrySearcher extends Service { + // ... not method overwrite here... +} + +class CitySearcher extends Service { + + @inheritTargetMeta() + static search(client) {} +} + +// ...later in your application... +getTargetMeta(CountrySearcher.search, 'dependencies'); // [ 'xmlClient' ] +getTargetMeta(CitySearcher.search, 'dependencies'); // [ 'xmlClient' ] +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/meta/tc39.md b/docs/archive/current/packages/support/meta/tc39.md new file mode 100644 index 00000000..fce4b579 --- /dev/null +++ b/docs/archive/current/packages/support/meta/tc39.md @@ -0,0 +1,40 @@ +--- +title: TC39 Proposal +description: In relation to TC39 Decorator Metadata proposal +sidebarDepth: 0 +--- + +# TC39 Decorator Metadata + +In relation to the [Decorator Metadata proposal](https://github.com/tc39/proposal-decorator-metadata), this decorator _"mimics"_ a similar behaviour as the one defined by the proposal. +Defining and retrieving metadata relies on a decorator's `context.metadata` object, and the `Symbol.metadata` property of a class. + +**Example:** + +```js +import { meta, getMeta } from '@aedart/support/meta'; + +@meta('service_alias', 'locationSearcher') +class Service {} + +getMeta(Service, 'service_alias'); // locationSearcher +``` + +**Roughly "desugars" to the following:** +```js +function meta(key, value) { + return (target, context) => { + context.metadata[key] = value; + } +} + +@meta('service_alias', 'locationSearcher') +class Service {} + +Service[Symbol.metadata].service_alias; // locationSearcher +``` +(_Above shown example is very simplified. Actual implementation is a bit more complex..._) + +At present, the internal mechanisms of the `meta` decorator must rely on a [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) to associate metadata with the intended class. +When the [Decorator Metadata proposal](https://github.com/tc39/proposal-decorator-metadata) becomes more mature and transpilers offer the `context.metadata` object (_or when browsers support it_), +then this decorator will be updated respectfully to use the available metadata object. \ No newline at end of file diff --git a/docs/archive/current/packages/support/misc.md b/docs/archive/current/packages/support/misc.md deleted file mode 100644 index ae09942e..00000000 --- a/docs/archive/current/packages/support/misc.md +++ /dev/null @@ -1,199 +0,0 @@ ---- -title: Misc. -description: Misc. utilities -sidebarDepth: 0 ---- - -# Misc. - -`@aedart/support/misc` offers miscellaneous utility methods. - -[[TOC]] - -## `descTag` - -Return the default string description of an object. - -```js -import {descTag} from '@aedart/support/misc'; - -descTag('foo'); // [object String] -descTag(3); // [object Number] -descTag([1, 2, 3]); // [object Array] -descTag(true); // [object Boolean] -// ... etc -``` - -The method is a shorthand for the following: - -```js -Object.prototype.toString.call(/* your value */); -``` - -See [Mozilla's documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toStringTag) for additional information. - - -## `empty` - -Determine if value is empty. - -_See also [`isset()`](#isset)._ - -```js -import {empty} from '@aedart/support/misc'; - -empty(''); // true -empty(false); // true -empty(0); // true -empty(0n); // true -empty(NaN); // true -empty(null); // true -empty(undefined); // true -empty([]); // true -empty({}); // true -empty(new Set()); // true -empty(new Map()); // true -empty(new Int8Array()); // true - -empty(' '); // false -empty('a'); // false -empty(true); // false -empty(1); // false -empty(1n); // false -empty(-1); // false -empty(Infinity); // false -empty([ 1 ]); // false -empty({ name: 'Jimmy' }); // false -empty((new Set()).add('a')); // false -empty((new Map).set('foo', 'bar')); // false -empty(new Date()); // false -empty(function() {}); // false -empty(Symbol('my-symbol')); // false - -let typedArr = new Int8Array(1); -typedArr[0] = 1; -empty(typedArr); // false -``` - -::: warning WeakMap and WeakSet -`empty()` is not able to determine if a [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) or [`WeakSet`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakSet) is empty. -::: - -## `isKey` - -Determine if given is a valid [key](#ispropertykey) or [property path identifier](./objects.md#has). - -```js -import {isKey} from '@aedart/support/misc'; - -isKey('foo'); // true -isKey(12); // true -isKey(Symbol('my-symbol')); // true -isKey([ 'a', 'b.c', Symbol('my-other-symbol')]); // true - -isKey(true); // false -isKey([]); // false -isKey(null); // false -isKey(undefined); // false -isKey(() => true); // false -``` - -## `isPrimitive` - -Determine if a value is a [primitive value](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#primitive_values). - -```js -import {isPrimitive} from '@aedart/support/misc'; - -isPrimitive(null); // true -isPrimitive(undefined); // true -isPrimitive(true); // true -isPrimitive(1); // true -isPrimitive(1n); // true -isPrimitive('foo'); // true -isPrimitive(Symbol('my-symbol')); // true - -isPrimitive([1, 2, 3]); // false -isPrimitive({ name: 'Rian' }); // false -isPrimitive(function() {}); // false -``` - -## `isPropertyKey` - -Determine if a key a valid property key name. - -```js -import {isPropertyKey} from '@aedart/support/misc'; - -isPropertyKey('foo'); // true -isPropertyKey(12); // true -isPropertyKey(Symbol('my-symbol')); // true - -isPropertyKey(true); // false -isPropertyKey(['a', 'b', 'c']); // false -isPropertyKey(null); // false -isPropertyKey(undefined); // false -isPropertyKey(() => true); // false -``` - -## `isset` - -Determine if value is different from `undefined` and `null`. - -_See also [`empty()`](#empty)._ - -```js -import {isset} from '@aedart/support/misc'; - -isset('foo'); // true -isset(''); // true -isset(true); // true -isset(false); // true -isset(1234); // true -isset(1.234); // true -isset([]); // true -isset({}); // true -isset(() => true); // true - -isset(undefined); // false -isset(null); // false -``` - -You can also determine if multiple values differ from `undefined` and `null`. - -**Note**: _All given values must differ from `undefined` and `null`, before method returns `true`._ - -```js -isset('foo', { name: 'Jane' }, [ 1, 2, 3 ]); // true - -isset('foo', null, [ 1, 2, 3 ]); // false -isset('foo', { name: 'Jane' }, undefined); // false -``` - -## `mergeKeys` - -The `mergeKeys()` method is able to merge two or more keys into a single key (_see [`isKey()`](#iskey)_). - -```js -import { mergeKeys } from "@aedart/support/misc"; - -const key = mergeKeys(Symbol('my-symbol'), [ 'b', 'c.d' ], 23); - -console.log(key); // [ Symbol('my-symbol'), 'b', 'c.d', 23 ]; -``` - -## `toWeakRef` - -Wraps a target object into a [`WeakRef`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef), if not already instance of a weak reference. - -```js -import { toWeakRef } from "@aedart/support/misc"; - -const person = { name: 'Sine' }; - -const a = toWeakRef(person); // new WeakRef of "person" -const b = toWeakRef(a); // same WeakRef instance as "a" - -toWeakRef(null); // undefined -toWeakRef(undefined); // undefined -``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/misc/README.md b/docs/archive/current/packages/support/misc/README.md new file mode 100644 index 00000000..d834dd6f --- /dev/null +++ b/docs/archive/current/packages/support/misc/README.md @@ -0,0 +1,8 @@ +--- +title: About Misc. +description: Miscellaneous utility functions. +--- + +# About Misc. + +`@aedart/support/misc` offers miscellaneous utility functions. \ No newline at end of file diff --git a/docs/archive/current/packages/support/misc/descTag.md b/docs/archive/current/packages/support/misc/descTag.md new file mode 100644 index 00000000..d7d87668 --- /dev/null +++ b/docs/archive/current/packages/support/misc/descTag.md @@ -0,0 +1,28 @@ +--- +title: Desc. Tag +description: Object description tag +sidebarDepth: 0 +--- + +# `descTag` + +Return the default string description of an object. + +```js +import { descTag } from '@aedart/support/misc'; + +descTag('foo'); // [object String] +descTag(3); // [object Number] +descTag([1, 2, 3]); // [object Array] +descTag(true); // [object Boolean] +// ... etc +``` + +The method is a shorthand for the following: + +```js +Object.prototype.toString.call(/* your value */); +``` + +See [Mozilla's documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toStringTag) for additional information. + diff --git a/docs/archive/current/packages/support/misc/empty.md b/docs/archive/current/packages/support/misc/empty.md new file mode 100644 index 00000000..1707b7ec --- /dev/null +++ b/docs/archive/current/packages/support/misc/empty.md @@ -0,0 +1,53 @@ +--- +title: Empty +description: Determine if value is empty +sidebarDepth: 0 +--- + +# `empty` + +Determine if value is empty. + +_See also [`isset()`](./isset.md)._ + +```js +import { empty } from '@aedart/support/misc'; + +empty(''); // true +empty(false); // true +empty(0); // true +empty(0n); // true +empty(NaN); // true +empty(null); // true +empty(undefined); // true +empty([]); // true +empty({}); // true +empty(new Set()); // true +empty(new Map()); // true +empty(new Int8Array()); // true + +empty(' '); // false +empty('a'); // false +empty(true); // false +empty(1); // false +empty(1n); // false +empty(-1); // false +empty(Infinity); // false +empty([ 1 ]); // false +empty({ name: 'Jimmy' }); // false +empty((new Set()).add('a')); // false +empty((new Map).set('foo', 'bar')); // false +empty(new Date()); // false +empty(function() {}); // false +empty(Symbol('my-symbol')); // false + +let typedArr = new Int8Array(1); +typedArr[0] = 1; +empty(typedArr); // false +``` + +## WeakMap and WeakSet + +::: warning Caution +`empty()` is not able to determine if a [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) or [`WeakSet`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakSet) is empty. +::: diff --git a/docs/archive/current/packages/support/misc/isKey.md b/docs/archive/current/packages/support/misc/isKey.md new file mode 100644 index 00000000..3150b865 --- /dev/null +++ b/docs/archive/current/packages/support/misc/isKey.md @@ -0,0 +1,24 @@ +--- +title: Is Key +description: Determine if is a key or path identifier +sidebarDepth: 0 +--- + +# `isKey` + +Determine if given is a valid [key](./isPropertyKey.md) or [property path identifier](../objects/has.md). + +```js +import { isKey } from '@aedart/support/misc'; + +isKey('foo'); // true +isKey(12); // true +isKey(Symbol('my-symbol')); // true +isKey([ 'a', 'b.c', Symbol('my-other-symbol')]); // true + +isKey(true); // false +isKey([]); // false +isKey(null); // false +isKey(undefined); // false +isKey(() => true); // false +``` diff --git a/docs/archive/current/packages/support/misc/isPrimitive.md b/docs/archive/current/packages/support/misc/isPrimitive.md new file mode 100644 index 00000000..772cdcdc --- /dev/null +++ b/docs/archive/current/packages/support/misc/isPrimitive.md @@ -0,0 +1,25 @@ +--- +title: Is Primitive +description: Determine if value is a primitive. +sidebarDepth: 0 +--- + +# `isPrimitive` + +Determine if a value is a [primitive value](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#primitive_values). + +```js +import { isPrimitive } from '@aedart/support/misc'; + +isPrimitive(null); // true +isPrimitive(undefined); // true +isPrimitive(true); // true +isPrimitive(1); // true +isPrimitive(1n); // true +isPrimitive('foo'); // true +isPrimitive(Symbol('my-symbol')); // true + +isPrimitive([1, 2, 3]); // false +isPrimitive({ name: 'Rian' }); // false +isPrimitive(function() {}); // false +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/misc/isPropertyKey.md b/docs/archive/current/packages/support/misc/isPropertyKey.md new file mode 100644 index 00000000..094584b7 --- /dev/null +++ b/docs/archive/current/packages/support/misc/isPropertyKey.md @@ -0,0 +1,23 @@ +--- +title: Is Property Key +description: Determine if key is a valid property key name. +sidebarDepth: 0 +--- + +# `isPropertyKey` + +Determine if a key a valid property key name (_string, number, or symbol_). + +```js +import { isPropertyKey } from '@aedart/support/misc'; + +isPropertyKey('foo'); // true +isPropertyKey(12); // true +isPropertyKey(Symbol('my-symbol')); // true + +isPropertyKey(true); // false +isPropertyKey(['a', 'b', 'c']); // false +isPropertyKey(null); // false +isPropertyKey(undefined); // false +isPropertyKey(() => true); // false +``` diff --git a/docs/archive/current/packages/support/misc/isset.md b/docs/archive/current/packages/support/misc/isset.md new file mode 100644 index 00000000..eadaa16d --- /dev/null +++ b/docs/archive/current/packages/support/misc/isset.md @@ -0,0 +1,41 @@ +--- +title: Isset +description: Determine if value isset. +sidebarDepth: 0 +--- + +# `isset` + +Determine if value is different from `undefined` and `null`. + +_See also [`empty()`](./empty.md)._ + +```js +import { isset } from '@aedart/support/misc'; + +isset('foo'); // true +isset(''); // true +isset(true); // true +isset(false); // true +isset(1234); // true +isset(1.234); // true +isset([]); // true +isset({}); // true +isset(() => true); // true + +isset(undefined); // false +isset(null); // false +``` + +## Multiple values + +You can also determine if multiple values differ from `undefined` and `null`. + +**Note**: _All given values must differ from `undefined` and `null`, before method returns `true`._ + +```js +isset('foo', { name: 'Jane' }, [ 1, 2, 3 ]); // true + +isset('foo', null, [ 1, 2, 3 ]); // false +isset('foo', { name: 'Jane' }, undefined); // false +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/misc/mergeKeys.md b/docs/archive/current/packages/support/misc/mergeKeys.md new file mode 100644 index 00000000..96a3a798 --- /dev/null +++ b/docs/archive/current/packages/support/misc/mergeKeys.md @@ -0,0 +1,17 @@ +--- +title: Merge Keys +description: Merge keys into a single key. +sidebarDepth: 0 +--- + +# `mergeKeys` + +The `mergeKeys()` method is able to merge two or more keys into a single key (_see [`isKey()`](./isKey.md)_). + +```js +import { mergeKeys } from "@aedart/support/misc"; + +const key = mergeKeys(Symbol('my-symbol'), [ 'b', 'c.d' ], 23); + +console.log(key); // [ Symbol('my-symbol'), 'b', 'c.d', 23 ]; +``` diff --git a/docs/archive/current/packages/support/misc/toWeakRef.md b/docs/archive/current/packages/support/misc/toWeakRef.md new file mode 100644 index 00000000..8f05af1e --- /dev/null +++ b/docs/archive/current/packages/support/misc/toWeakRef.md @@ -0,0 +1,21 @@ +--- +title: To Weak Ref. +description: Wrap object into a Weak Reference. +sidebarDepth: 0 +--- + +# `toWeakRef` + +Wraps a target object into a [`WeakRef`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef), if not already instance of a weak reference. + +```js +import { toWeakRef } from "@aedart/support/misc"; + +const person = { name: 'Sine' }; + +const a = toWeakRef(person); // new WeakRef of "person" +const b = toWeakRef(a); // same WeakRef instance as "a" + +toWeakRef(null); // undefined +toWeakRef(undefined); // undefined +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/mixins.md b/docs/archive/current/packages/support/mixins.md deleted file mode 100644 index 53799188..00000000 --- a/docs/archive/current/packages/support/mixins.md +++ /dev/null @@ -1,234 +0,0 @@ ---- -title: Mixins -description: Abstract subclasses ("Mixins") utilities -sidebarDepth: 0 ---- - -# Mixins - -`@aedart/support/mixins` offers an adaptation of [Justin Fagnani's](https://justinfagnani.com/author/justinfagnani/) -[`mixwith.js`](https://github.com/justinfagnani/mixwith.js) package (_originally licensed under [Apache License 2.0](https://github.com/justinfagnani/mixwith.js?tab=Apache-2.0-1-ov-file#readme)_). - -```js -import { mix, Mixin } from "@aedart/support/mixins"; - -// Define mixin -const NameMixin = Mixin((superclass) => class extends superclass { - - #name; - - set name(value) { - this.#name = value; - } - - get name() { - return this.#name; - } -}); - -// Apply mixin... -class Item extends mix().with( - NameMixin -) { - // ...not shown... -} - -// ...Later in your application -const item = new Item(); -item.name = 'My Item'; - -console.log(item.name); // My Item -``` - -[[TOC]] - -## Define Mixin - -You can use the `Mixin` decorator to define a new mixin. -Amongst other things, the decorator will enable support for [`instanceof`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof) checks. -See [`instanceof` Operator](#instanceof-operator) for additional information. - -```js -import { Mixin } from "@aedart/support/mixins"; - -export const RectangleMixin = Mixin((superclass) => class extends superclass { - length = 0 - width = 0; - - area() { - return this.length * this.width; - } -}); -``` - -### Constructor - -If you need to perform initialisation logic in your mixins, then you can do so by implementing a class [`constructor`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor). -When doing so, it is important to invoke the parent constructor via [`super()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/super) and pass on eventual arguments. - -```js -import { Mixin } from "@aedart/support/mixins"; - -export const RectangleMixin = Mixin((superclass) => class extends superclass { - - constructor(...args) { - super(...args); // Invoke parent constructor and pass on arugments! - - // Perform your initialisaiton logic... - } - - // ...remaining not shown... -}); -``` - -## Applying Mixins - -To apply one or more mixins, use the `mix()` function and call `width()` with the mixins you wish to apply to a superclass. - -```js -import { mix } from "@aedart/support/mixins"; -import { - RectangleMixin, - DescMixin -} from "@acme/mixins"; - -class Box extends mix().with( - RectangleMixin, - DescMixin -) { - // ...remaining not shown... -} -``` - -### Extending Superclass - -To extend a superclass and apply mixins onto it, pass the superclass as argument for the `mix()` function. - -```js -class Shape { - // ...not shown... -} - -class Box extends mix(Shape).with( - RectangleMixin, - DescMixin -) { - // ...remaining not shown... -} -``` - -::: tip Note -By default, if you do not provide `mix()` with a superclass, an empty class is automatically created. -It is the equivalent of the following: - -```js -class Box extends mix(class {}).with( - MyMixinA, - MyMixinB, - MyMixinC, -) { - // ... -} -``` -::: - -## `instanceof` Operator - -When you defined your mixins using the [`Mixin()` decorator function](#define-mixin), then it will support `instanceof` checks. -Consider the following example: - -```js -// A regular mixin without "Mixin" decorator -const MixinA = (superclass) => class extends superclas { - // ...not shown... -}; - -// Mixin with "Mixin" decorator -const MixinB = Mixin((superclass) => class extends superclass { - // ...not shown... -}); - -// -------------------------------------------------------------------- // - -class A {} - -class B extends mix(A).with( - MixinA, - MixinB -) {} - -// -------------------------------------------------------------------- // - -const instance = new B(); - -console.log(instance instanceof A); // true -console.log(instance instanceof B); // true -console.log(instance instanceof MixinA); // false -console.log(instance instanceof MixinB); // true -``` - -## How inheritance works - -To gain an overview of how inheritance works when applying mixins onto a superclass, consider the following example: - -```js -const MyMixin = Mixin((superclass) => class extends superclass { - constructor(...args) { - super(...args); // Invokes A's constructor - } - - // Overwrites A's foo() method - foo() { - return 'zam'; - } - - // Overwrites A's bar() method - bar() { - return super.bar(); // Invoke A's bar() method - } -}); - -// -------------------------------------------------------------------- // - -class A { - foo() { - return 'foo'; - } - - bar() { - return 'bar'; - } -} - -// -------------------------------------------------------------------- // - -class B extends mix(A).with( - MyMixin -) { - constructor(...args) { - super(...args); // Invokes MyMixin's constructor - } - - // Overwrite MyMixin's foo() - foo() { - const msg = super.foo(); // Invoke MyMixin's bar() method - - return `<${msg}>`; - } -} - -// -------------------------------------------------------------------- // - -const instance = new B(); - -console.log(instance.foo()); // -console.log(instance.bar()); // bar -``` - -## Onward - -For more information and examples, please read Mozilla's documentation about [_"Mix-ins"_](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/extends#mix-ins), -and Justin Fagnani's blog posts: - -* [_"Real" Mixins with JavaScript Classes_](https://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/) -* [_Enhancing Mixins with Decorator Functions_](https://justinfagnani.com/2016/01/07/enhancing-mixins-with-decorator-functions/) \ No newline at end of file diff --git a/docs/archive/current/packages/support/mixins/README.md b/docs/archive/current/packages/support/mixins/README.md new file mode 100644 index 00000000..26b3be95 --- /dev/null +++ b/docs/archive/current/packages/support/mixins/README.md @@ -0,0 +1,43 @@ +--- +title: About Mixins +description: Abstract subclasses ("Mixins") utilities +sidebarDepth: 0 +--- + +# Mixins + +`@aedart/support/mixins` offers an adaptation of [Justin Fagnani's](https://justinfagnani.com/author/justinfagnani/) +[`mixwith.js`](https://github.com/justinfagnani/mixwith.js) package (_originally licensed under [Apache License 2.0](https://github.com/justinfagnani/mixwith.js?tab=Apache-2.0-1-ov-file#readme)_). + +```js +import { mix, Mixin } from "@aedart/support/mixins"; + +// Define mixin +const NameMixin = Mixin((superclass) => class extends superclass { + + #name; + + set name(value) { + this.#name = value; + } + + get name() { + return this.#name; + } +}); + +// Apply mixin... +class Item extends mix().with( + NameMixin +) { + // ...not shown... +} + +// ...Later in your application +const item = new Item(); +item.name = 'My Item'; + +console.log(item.name); // My Item +``` + +See also [Concerns](../concerns/README.md) as an alternative. \ No newline at end of file diff --git a/docs/archive/current/packages/support/mixins/apply.md b/docs/archive/current/packages/support/mixins/apply.md new file mode 100644 index 00000000..74af6d03 --- /dev/null +++ b/docs/archive/current/packages/support/mixins/apply.md @@ -0,0 +1,56 @@ +--- +title: Apply Mixins +description: How to apply mixins. +sidebarDepth: 0 +--- + +# Applying Mixins + +To apply one or more mixins, use the `mix()` function and call `width()` with the mixins you wish to apply to a superclass. + +```js +import { mix } from "@aedart/support/mixins"; +import { + RectangleMixin, + DescMixin +} from "@acme/mixins"; + +class Box extends mix().with( + RectangleMixin, + DescMixin +) { + // ...remaining not shown... +} +``` + +## Extending Superclass + +To extend a superclass and apply mixins onto it, pass the superclass as argument for the `mix()` function. + +```js +class Shape { + // ...not shown... +} + +class Box extends mix(Shape).with( + RectangleMixin, + DescMixin +) { + // ...remaining not shown... +} +``` + +::: tip Note +By default, if you do not provide `mix()` with a superclass, an empty class is automatically created. +It is the equivalent of the following: + +```js +class Box extends mix(class {}).with( + MyMixinA, + MyMixinB, + MyMixinC, +) { + // ... +} +``` +::: \ No newline at end of file diff --git a/docs/archive/current/packages/support/mixins/inheritance.md b/docs/archive/current/packages/support/mixins/inheritance.md new file mode 100644 index 00000000..b39eb4f2 --- /dev/null +++ b/docs/archive/current/packages/support/mixins/inheritance.md @@ -0,0 +1,63 @@ +--- +title: Inheritance +description: How inheritance works. +sidebarDepth: 0 +--- + +# How inheritance works + +To gain an overview of how inheritance works when applying mixins onto a superclass, consider the following example: + +```js +const MyMixin = Mixin((superclass) => class extends superclass { + constructor(...args) { + super(...args); // Invokes A's constructor + } + + // Overwrites A's foo() method + foo() { + return 'zam'; + } + + // Overwrites A's bar() method + bar() { + return super.bar(); // Invoke A's bar() method + } +}); + +// -------------------------------------------------------------------- // + +class A { + foo() { + return 'foo'; + } + + bar() { + return 'bar'; + } +} + +// -------------------------------------------------------------------- // + +class B extends mix(A).with( + MyMixin +) { + constructor(...args) { + super(...args); // Invokes MyMixin's constructor + } + + // Overwrite MyMixin's foo() + foo() { + const msg = super.foo(); // Invoke MyMixin's bar() method + + return `<${msg}>`; + } +} + +// -------------------------------------------------------------------- // + +const instance = new B(); + +console.log(instance.foo()); // +console.log(instance.bar()); // bar +``` diff --git a/docs/archive/current/packages/support/mixins/instanceof.md b/docs/archive/current/packages/support/mixins/instanceof.md new file mode 100644 index 00000000..132fb6dc --- /dev/null +++ b/docs/archive/current/packages/support/mixins/instanceof.md @@ -0,0 +1,40 @@ +--- +title: Instanceof +description: Using instanceof operator. +sidebarDepth: 0 +--- + +# `instanceof` Operator + +When you defined your mixins using the [`Mixin()` decorator function](./newMixin.md), then it will support `instanceof` checks. +Consider the following example: + +```js +// A regular mixin without "Mixin" decorator +const MixinA = (superclass) => class extends superclas { + // ...not shown... +}; + +// Mixin with "Mixin" decorator +const MixinB = Mixin((superclass) => class extends superclass { + // ...not shown... +}); + +// -------------------------------------------------------------------- // + +class A {} + +class B extends mix(A).with( + MixinA, + MixinB +) {} + +// -------------------------------------------------------------------- // + +const instance = new B(); + +console.log(instance instanceof A); // true +console.log(instance instanceof B); // true +console.log(instance instanceof MixinA); // false +console.log(instance instanceof MixinB); // true +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/mixins/newMixin.md b/docs/archive/current/packages/support/mixins/newMixin.md new file mode 100644 index 00000000..3a9b4dbe --- /dev/null +++ b/docs/archive/current/packages/support/mixins/newMixin.md @@ -0,0 +1,44 @@ +--- +title: New Mixin +description: How to defined a new Mixin class. +sidebarDepth: 0 +--- + +# Define a new Mixin + +You can use the `Mixin` decorator to define a new mixin. +Amongst other things, the decorator will enable support for [`instanceof`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof) checks. +See [`instanceof` Operator](./instanceof.md) for additional information. + +```js +import { Mixin } from "@aedart/support/mixins"; + +export const RectangleMixin = Mixin((superclass) => class extends superclass { + length = 0 + width = 0; + + area() { + return this.length * this.width; + } +}); +``` + +## Constructor + +If you need to perform initialisation logic in your mixins, then you can do so by implementing a class [`constructor`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor). +When doing so, it is important to invoke the parent constructor via [`super()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/super) and pass on eventual arguments. + +```js +import { Mixin } from "@aedart/support/mixins"; + +export const RectangleMixin = Mixin((superclass) => class extends superclass { + + constructor(...args) { + super(...args); // Invoke parent constructor and pass on arugments! + + // Perform your initialisaiton logic... + } + + // ...remaining not shown... +}); +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/mixins/onward.md b/docs/archive/current/packages/support/mixins/onward.md new file mode 100644 index 00000000..af555cfc --- /dev/null +++ b/docs/archive/current/packages/support/mixins/onward.md @@ -0,0 +1,13 @@ +--- +title: Onward +description: Where to read more about mixins. +sidebarDepth: 0 +--- + +# Onward + +For more information and examples, please read Mozilla's documentation about [_"Mix-ins"_](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/extends#mix-ins), +and Justin Fagnani's blog posts: + +* [_"Real" Mixins with JavaScript Classes_](https://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/) +* [_Enhancing Mixins with Decorator Functions_](https://justinfagnani.com/2016/01/07/enhancing-mixins-with-decorator-functions/) \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects.md b/docs/archive/current/packages/support/objects.md deleted file mode 100644 index 5fc889df..00000000 --- a/docs/archive/current/packages/support/objects.md +++ /dev/null @@ -1,270 +0,0 @@ ---- -title: Objects -description: Objects related utilities -sidebarDepth: 0 ---- - -# Object Utility Methods - -The `@aedart/support/objects` submodule offers object related utilities. - -[[TOC]] - -## `forget` - -Remove (_delete_) a value in object at given path. -_Method is an alias for [Lodash `unset`](https://lodash.com/docs/4.17.15#unset)._ - -```js -import {forget} from "@aedart/support/objects"; - -const target = { - a: 1234, - b: { - c: { - age: 24 - } - }, -}; - -forget(target, 'b.c'); - -console.log(target); // { a: 1234, b: {} } -``` - -## `forgetAll` - -Remove (_deletes_) all values in object, at given paths. - -```js -import {forgetAll} from "@aedart/support/objects"; - -const target = { - a: 1234, - b: { - c: { - age: 24 - } - }, -}; - -forgetAll(target, [ 'a', 'b.c.age' ]); - -console.log(target); // { b: { c: {} } } -``` - -## `get` - -Get value in object at given path. -_Method is an alias for [Lodash `get`](https://lodash.com/docs/4.17.15#get)._ - -_See also [`set()`](#set)._ - -```js -import {get} from "@aedart/support/objects"; - -const target = { - a: 1234, - b: { - c: { - age: 24 - } - }, -}; - -let age = get(target, 'b.c.age'); -console.log(age); // 24 -``` - -You can also specify a default value to be returned, if the resolved value is `undefined`. - -```js -const target = { - a: 1234, - b: { - c: { - age: undefined - } - }, -}; - -// Returns default value... -let age = get(target, 'b.c.age', 20); -console.log(age); // 20 -``` - -## `has` - -Determine if path is a property of given object. -_Method is an alias for [Lodash `hasIn`](https://lodash.com/docs/4.17.15#hasIn)._ - -_See also [`isset()`](#isset)._ - -```js -import {has} from "@aedart/support/objects"; - -const target = { - a: 1234, - b: { - c: { - age: 24 - } - }, -}; - -let result = has(target, 'b.c.age'); -console.log(result); // true -``` - -## `hasAll` - -Determine if all paths are properties of given object. - -_See also [`isset()`](#isset)._ - -```js -import {hasAll} from "@aedart/support/objects"; - -const mySymbol = Symbol('my-symbol'); -const target = { - a: 1234, - b: { - name: 'Sven', - c: { - age: 24, - [mySymbol]: true - } - }, - d: [ - { name: 'Jane'}, - { name: 'Ashley'}, - ], -}; - -const paths = [ - 'a', - 'b.name', - 'b.c.age', - ['b', 'c', mySymbol], - 'd[0]', - 'd[1].name', -]; - -let result = hasAll(target, paths); -console.log(result); // true -``` - -## `hasAny` - -Determine if any paths are properties of given object. - -```js -import {hasAny} from "@aedart/support/objects"; - -const target = { - a: 1234, - b: { - name: 'Sven', - c: { - age: 24 - } - } -}; - -const paths = [ - 'z', // does not exist - 'b.c.name', // does not exist - 'b.c.age', // exist -]; - -let result = hasAny(target, paths); -console.log(result); // true -``` - -## `hasUniqueId` - -Determine if an object has a unique id. - -_See [`uniqueId`](#uniqueid) for additional details._ - -```js -import {hasUniqueId} from "@aedart/support/objects"; - -const target = { - name: 'Ursula' -}; - -console.log(hasUniqueId(target)); // false -``` - -## `isset` - -Determine if paths are properties of given object and have values. -This method differs from [`has()`](#has), in that it only returns true if properties' values are not `undefined` and not `null`. - -_See also [misc. `isset()`](./misc/README.md#isset)._ - -```js -import {isset} from "@aedart/support/objects"; - -const target = { - a: 1234, - b: { - name: undefined, - c: { - age: null - } - }, -}; - -console.log(isset(target, 'a')); // true -console.log(isset(target, 'b')); // true -console.log(isset(target, 'b.name')); // false -console.log(isset(target, 'b.c')); // true -console.log(isset(target, 'b.c.age')); // false -``` - -You can also check if multiple paths are set. - -```js -console.log(isset(target, 'a', 'b')); // true -console.log(isset(target, 'b.c', 'b.name')); // false -console.log(isset(target, 'a', 'b.name', 'b.c.age')); // false -``` - -## `set` - -Set a value in object at given path. -_Method is an alias for [Lodash `set`](https://lodash.com/docs/4.17.15#set)._ - -```js -import {set} from "@aedart/support/objects"; - -const target = {}; - -set(target, 'a.foo', 'bar'); - -console.log(target); // { a: { foo: 'bar } } -``` - -## `uniqueId` - -The `uniqueId()` is able to return a "unique¹" reference identifier for any given object. - -```js -import {uniqueId, hasUniqueId} from "@aedart/support/objects"; - -const target = { - name: 'Ursula' -}; - -console.log(uniqueId(target)); // 27 - -// ...later in your application -console.log(hasUniqueId(target)); // true -console.log(uniqueId(target)); // 27 -``` - -The source code is heavily inspired by [Nicolas Gehlert's](https://github.com/ngehlert) blog post: ["_Get object reference IDs in JavaScript/TypeScript_" (September 28, 2022)](https://developapa.com/object-ids/) - -¹: _In this context, the returned number is unique in the current session. The number will NOT be unique across multiple sessions, nor guarantee that an object will receive the exact same identifier as in a previous session!_ \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects/README.md b/docs/archive/current/packages/support/objects/README.md new file mode 100644 index 00000000..cfecc3a7 --- /dev/null +++ b/docs/archive/current/packages/support/objects/README.md @@ -0,0 +1,8 @@ +--- +title: About Objects +description: Objects related utilities +--- + +# About Objects + +The `@aedart/support/objects` submodule offers object related utilities. \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects/forget.md b/docs/archive/current/packages/support/objects/forget.md new file mode 100644 index 00000000..09a56233 --- /dev/null +++ b/docs/archive/current/packages/support/objects/forget.md @@ -0,0 +1,27 @@ +--- +title: Forget +description: Forget object key +sidebarDepth: 0 +--- + +# `forget` + +Remove (_delete_) a value in object at given path. +_Method is an alias for [Lodash `unset`](https://lodash.com/docs/4.17.15#unset)._ + +```js +import { forget } from "@aedart/support/objects"; + +const target = { + a: 1234, + b: { + c: { + age: 24 + } + }, +}; + +forget(target, 'b.c'); + +console.log(target); // { a: 1234, b: {} } +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects/forgetAll.md b/docs/archive/current/packages/support/objects/forgetAll.md new file mode 100644 index 00000000..2cad2e64 --- /dev/null +++ b/docs/archive/current/packages/support/objects/forgetAll.md @@ -0,0 +1,26 @@ +--- +title: Forget All +description: Forget object all keys +sidebarDepth: 0 +--- + +# `forgetAll` + +Remove (_deletes_) all values in object, at given paths. + +```js +import { forgetAll } from "@aedart/support/objects"; + +const target = { + a: 1234, + b: { + c: { + age: 24 + } + }, +}; + +forgetAll(target, [ 'a', 'b.c.age' ]); + +console.log(target); // { b: { c: {} } } +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects/get.md b/docs/archive/current/packages/support/objects/get.md new file mode 100644 index 00000000..7543d2d6 --- /dev/null +++ b/docs/archive/current/packages/support/objects/get.md @@ -0,0 +1,47 @@ +--- +title: Get +description: Get object value from given path. +sidebarDepth: 0 +--- + +# `get` + +Get value in object at given path. +_Method is an alias for [Lodash `get`](https://lodash.com/docs/4.17.15#get)._ + +_See also [`set()`](./set.md)._ + +```js +import { get } from "@aedart/support/objects"; + +const target = { + a: 1234, + b: { + c: { + age: 24 + } + }, +}; + +let age = get(target, 'b.c.age'); +console.log(age); // 24 +``` + +## Default Value + +You can also specify a default value to be returned, if the resolved value is `undefined`. + +```js +const target = { + a: 1234, + b: { + c: { + age: undefined + } + }, +}; + +// Returns default value... +let age = get(target, 'b.c.age', 20); +console.log(age); // 20 +``` diff --git a/docs/archive/current/packages/support/objects/has.md b/docs/archive/current/packages/support/objects/has.md new file mode 100644 index 00000000..3af13231 --- /dev/null +++ b/docs/archive/current/packages/support/objects/has.md @@ -0,0 +1,28 @@ +--- +title: Has +description: Determine if object path is a property. +sidebarDepth: 0 +--- + +# `has` + +Determine if path is a property of given object. +_Method is an alias for [Lodash `hasIn`](https://lodash.com/docs/4.17.15#hasIn)._ + +_See also [`isset()`](./isset.md)._ + +```js +import { has } from "@aedart/support/objects"; + +const target = { + a: 1234, + b: { + c: { + age: 24 + } + }, +}; + +let result = has(target, 'b.c.age'); +console.log(result); // true +``` diff --git a/docs/archive/current/packages/support/objects/hasAll.md b/docs/archive/current/packages/support/objects/hasAll.md new file mode 100644 index 00000000..08c0a746 --- /dev/null +++ b/docs/archive/current/packages/support/objects/hasAll.md @@ -0,0 +1,43 @@ +--- +title: Has All +description: Determine if all object paths are properties. +sidebarDepth: 0 +--- + +# `hasAll` + +Determine if all paths are properties of given object. + +_See also [`isset()`](./isset.md)._ + +```js +import { hasAll } from "@aedart/support/objects"; + +const mySymbol = Symbol('my-symbol'); +const target = { + a: 1234, + b: { + name: 'Sven', + c: { + age: 24, + [mySymbol]: true + } + }, + d: [ + { name: 'Jane'}, + { name: 'Ashley'}, + ], +}; + +const paths = [ + 'a', + 'b.name', + 'b.c.age', + ['b', 'c', mySymbol], + 'd[0]', + 'd[1].name', +]; + +let result = hasAll(target, paths); +console.log(result); // true +``` diff --git a/docs/archive/current/packages/support/objects/hasAny.md b/docs/archive/current/packages/support/objects/hasAny.md new file mode 100644 index 00000000..5af679db --- /dev/null +++ b/docs/archive/current/packages/support/objects/hasAny.md @@ -0,0 +1,32 @@ +--- +title: Has Any +description: Determine if any object paths are properties. +sidebarDepth: 0 +--- + +# `hasAny` + +Determine if any paths are properties of given object. + +```js +import { hasAny } from "@aedart/support/objects"; + +const target = { + a: 1234, + b: { + name: 'Sven', + c: { + age: 24 + } + } +}; + +const paths = [ + 'z', // does not exist + 'b.c.name', // does not exist + 'b.c.age', // exist +]; + +let result = hasAny(target, paths); +console.log(result); // true +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects/hasUniqueId.md b/docs/archive/current/packages/support/objects/hasUniqueId.md new file mode 100644 index 00000000..9544afe2 --- /dev/null +++ b/docs/archive/current/packages/support/objects/hasUniqueId.md @@ -0,0 +1,21 @@ +--- +title: Has Unique ID +description: Determine if object has a unique id. +sidebarDepth: 0 +--- + +# `hasUniqueId` + +Determine if an object has a unique id. + +_See [`uniqueId`](./uniqueId.md) for additional details._ + +```js +import { hasUniqueId } from "@aedart/support/objects"; + +const target = { + name: 'Ursula' +}; + +console.log(hasUniqueId(target)); // false +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects/isCloneable.md b/docs/archive/current/packages/support/objects/isCloneable.md new file mode 100644 index 00000000..77c4462c --- /dev/null +++ b/docs/archive/current/packages/support/objects/isCloneable.md @@ -0,0 +1,30 @@ +--- +title: Is Cloneable +description: Determine if object is cloneable. +sidebarDepth: 0 +--- + +# `isCloneable` + +Determines if given object is "cloneable". +In this context "cloneable" means that an object implements the `Cloneable` interface, and offers a `clone()` method. + +_See `@aedart/constracts/support/objects/Cloneable` for details._ + +```js +import { isCloneable } from "@aedart/support/objects"; + +class A {}; + +class B { + clone() { + return new this(); + } +} + +isCloneable(null); // false +isCloneable([]); // false +isCloneable({}); // false +isCloneable(new A()); // false +isCloneable(new B()); // true +``` diff --git a/docs/archive/current/packages/support/objects/isPopulatable.md b/docs/archive/current/packages/support/objects/isPopulatable.md new file mode 100644 index 00000000..fdf7ddde --- /dev/null +++ b/docs/archive/current/packages/support/objects/isPopulatable.md @@ -0,0 +1,32 @@ +--- +title: Is Populatable +description: Determine if object is populatable. +sidebarDepth: 0 +--- + +# `isPopulatable` + +Determines if given object is "populatable". +Here, "populatable" means that an object implements the `Populatable` interface, and offers a `populate()` method. + +_See `@aedart/constracts/support/objects/Populatable` for details._ + +```js +import { isPopulatable } from "@aedart/support/objects"; + +class A {}; + +class B { + populate(data) { + // ...not shown here... + + return this; + } +} + +isPopulatable(null); // false +isPopulatable([]); // false +isPopulatable({}); // false +isPopulatable(new A()); // false +isPopulatable(new B()); // true +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects/isset.md b/docs/archive/current/packages/support/objects/isset.md new file mode 100644 index 00000000..fff37807 --- /dev/null +++ b/docs/archive/current/packages/support/objects/isset.md @@ -0,0 +1,40 @@ +--- +title: Isset +description: Determine if object object paths are set and have values. +sidebarDepth: 0 +--- + +# `isset` + +Determine if paths are properties of given object and have values. +This method differs from [`has()`](./has.md), in that it only returns true if properties' values are not `undefined` and not `null`. + +_See also [misc. `isset()`](../misc/isset.md)._ + +```js +import { isset } from "@aedart/support/objects"; + +const target = { + a: 1234, + b: { + name: undefined, + c: { + age: null + } + }, +}; + +console.log(isset(target, 'a')); // true +console.log(isset(target, 'b')); // true +console.log(isset(target, 'b.name')); // false +console.log(isset(target, 'b.c')); // true +console.log(isset(target, 'b.c.age')); // false +``` + +You can also check if multiple paths are set. + +```js +console.log(isset(target, 'a', 'b')); // true +console.log(isset(target, 'b.c', 'b.name')); // false +console.log(isset(target, 'a', 'b.name', 'b.c.age')); // false +``` diff --git a/docs/archive/current/packages/support/objects/merge.md b/docs/archive/current/packages/support/objects/merge.md new file mode 100644 index 00000000..bc3e00b4 --- /dev/null +++ b/docs/archive/current/packages/support/objects/merge.md @@ -0,0 +1,375 @@ +--- +title: Merge +description: Merge multiple objects into a new object. +sidebarDepth: 0 +--- + +# `merge` + +Merges objects recursively into a new object. The properties and values of the source objects are copied, using [deep copy techniques](https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy), when possible. +Behind the scene, most value types are deep copied via [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone). + +[[TOC]] + +**Example** + +```js +import { merge } from "@aedart/support/objects"; + +const person = { + 'name': 'Alice', +}; + +const address = { + 'address': { + 'street': 'Somewhere Street 43' + }, +}; + +const result = merge(person, address); + +console.log(result); +``` + +The above shown example results in a new object that looks like this: + +```json +{ + "name": "Alice", + "address": { + "street": "Somewhere Street 43" + } +} +``` + +## Shallow Copied Types + +Be default, the following value types are only [shallow copied](https://developer.mozilla.org/en-US/docs/Glossary/Shallow_copy): + +- [function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function) +- [Symbol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) + +```js +const a = { + 'foo': null, + 'bar': Symbol('my_symbol') +}; + +const b = { + 'foo': function() {}, +}; + +const result = merge(a, b); + +console.log(result.foo === b.foo); // true +console.log(result.bar === a.bar); // true +``` + +## Unsafe Keys + +Property keys that are considered "unsafe", are never copied. + +```js +const a = { + 'foo': 'bar' +}; +const b = { + __proto__: { 'is_admin': true } +} + +const result = merge(a, b); + +console.log(result); // { 'foo': 'bar' } +console.log(Reflect.has(result, '__proto__')); // false +``` + +_See [`isUnsafeKey()`](../reflections/isKeyUnsafe.md) for additional details._ + +## Merge Options + +`merge()` supports a number of options. To specify thom, use the `using()` method. + +```js +merge() + .using({ /** option: value */ }) + .of(objA, objB, objC); +``` + +::: tip Note +When invoking `merge()` without any arguments, an underlying objects `Merger` instance is returned. +::: + +### `depth` + +The `depth` option specifies the maximum merge depth. + +* Default maximum depth: `512` + +A `MergeError` is thrown, if the maximum depth is exceeded. + +```js +const a = { + 'person': { + 'name': 'Una' + } +}; + +const b = { + 'person': { // Level 0 + 'age': 24, // Level 1 + 'address': { + 'street': 'Somewhere Str. 654' // Level 2 + } + } +}; + +const result = merge() + .using({ + depth: 1 + }) + .of(a, b); // MergeError - Maximum merge depth (1) has been exceeded +``` + +### `skip` + +`skip` defines property keys that must not be merged. + +It accepts an array of property keys or a callback. + +#### List of property keys + +```js +const a = { + 'person': { + 'name': 'Ulrik' + } +}; + +const b = { + 'person': { + 'age': 36, + 'address': { + 'street': 'Nowhere Str. 12' + } + } +}; + +const result = merge() + .using({ + skip: [ 'age' ] + }) + .of(a, b); +``` + +The above example results in the following new object: + +```json +{ + "person": { + "name": "Ulrik", + "address": { + "street": "Nowhere Str. 12" + } + } +} +``` + +::: tip Note +When specifying a list of property keys, then the depth level in which the property key is found does not matter. +::: + +#### Skip Callback + +You can use a callback, if you need to handle more advanced skip logic. +The callback accepts the the following arguments: + +- `key: PropertyKey` - The current property that is being processed. +- `source: object` - The source object that contains the key. +- `result: object` - The resulting object (_relative to the current depth that is being processed_). + +The callback MUST return a boolean value; `true` if given key must be skipped, `false` otherwise. + +```js +const a = { + 'person': { + 'name': 'Jane' + } +}; + +const b = { + 'person': { + 'name': 'James', + 'address': { + 'street': 'Sunview Palace 88' + } + } +}; + +const b = { + 'person': { + 'name': 'White', + } +}; + +const result = merge() + .using({ + skip: (key, source, result) => { + return key === 'name' + && source[key] !== null + && !Reflect.has(result, key); + } + }) + .of(a, b); +``` + +The above example results in the following new object: + +```json +{ + "person": { + "name": "Jane", + "address": { + "street": "Sunview Palace 88" + } + } +} +``` + +### `overwriteWithUndefined` + +Determines if a property value should be overwritten with `undefined`. + +**Note**: _By default, all values are overwritten, even when they are `undefined`!_ + +```js +const a = { 'foo': true }; +const b = { 'foo': undefined }; + +merge(a, b); // { 'foo': undefined } + +merge() + .using({ overwriteWithUndefined: false }) + .of(a, b) // { 'foo': true } +``` + +### `useCloneable` + +Determines if an object's return value from a `clone()` method (_see [`Cloneable`](../objects/isCloneable.md)_) should be used for merging, +rather than the source object itself. + +**Note**: _By default, if an object is cloneable, then its return value from `clone()` is used._ + +```js +const a = { 'foo': { 'name': 'John Doe' } }; +const b = { 'foo': { + 'name': 'Jane Doe', + clone() { + return { + 'name': 'Rick Doe', + 'age': 26 + } + } +} }; + +merge(a, b); // { 'foo': { 'name': 'Rick Doe', 'age': 26 } } + +merge() + .using({ useCloneable: false }) + .of(a, b); // { 'foo': { 'name': 'Jane Doe', clone() {...} } } +``` + +### `mergeArrays` + +When enabled, arrays, [array-like](../arrays/isArrayLike.md), and [concat spreadable](../arrays/isConcatSpreadable.md) objects are merged. + +**Note**: _By default, existing array values are NOT merged._ + +```js +const a = { 'foo': [ 1, 2, 3 ] }; +const b = { 'foo': [ 4, 5, 6 ] }; + +merge(a, b); // { 'foo': [ 4, 5, 6 ] } + +merge() + .using({ mergeArrays: true }) + .of(a, b); // { 'foo': [ 1, 2, 3, 4, 5, 6 ] } +``` + +Behind the scene, the [array merge](../arrays/merge.md) utility is used for merging arrays. + +### `callback` + +In situations when you need more advanced merge logic, you may specify a custom callback. + +The callback is _**responsible**_ for returning the value to be merged, from a given source object. + +```js +const a = { + 'a': 1 +}; + +const b = { + 'b': 2 +}; + +const result = merge() + .using({ + callback: (target, next, options) => { + const { key, value } = target; + if (key === 'b') { + return value + 1; + } + + return value; + } + }) + .of(a, b); // { 'a': 1, 'b': 3 } +``` + +If you do not have other merge options to specify, then you can simply provide a merge callback directly as argument for +the `using()` method. + +```js +const result = merge() + .using((target, next, options) => { + const { key, value } = target; + if (key === 'b') { + return value + 1; + } + + return value; + }) + .of(a, b); +``` + +#### Arguments + +The merge callback is given the following arguments: + +- `target: MergeSourceInfo` - The source target information (_see below_). +- `next: NextCallback` - Callback to invoke for merging nested objects (_next depth level_). +- `options: Readonly` - The merge options to be applied. + +**`target: MergeSourceInfo`** + +The source target information object contains the following properties: + +- `result: object` - The resulting object (_relative to object depth_) +- `key: PropertyKey` - The target property key in source object to. +- `value: any` - Value of the property in source object. +- `source: object` - The source object that holds the property key and value. +- `sourceIndex: number` - Source object's index (_relative to object depth_). +- `depth: number` - The current recursion depth. + +**`next: NextCallback`** + +The callback to perform the merging of nested objects. +It accepts the following arguments: + +- `sources: object[]` - The nested objects to be merged. +- `options: Readonly` - The merge options to be applied. +- `nextDepth: number` - The next recursion depth number. + +#### Onward + +For additional information about the merge callback, please review the source code of the `defaultMergeCallback()`, inside `@aedart/support/objects`. \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects/populate.md b/docs/archive/current/packages/support/objects/populate.md new file mode 100644 index 00000000..e5450d11 --- /dev/null +++ b/docs/archive/current/packages/support/objects/populate.md @@ -0,0 +1,126 @@ +--- +title: Populate +description: Populate target object. +sidebarDepth: 0 +--- + +# `populate` + +The `populate()` allows you to populate a target object's properties with those from a source object. +The values are [shallow copied](https://developer.mozilla.org/en-US/docs/Glossary/Shallow_copy). + +[[TOC]] + +## Arguments + +`populate()` accepts the following arguments: + +* `target: object` + +* `source: object` + +* `keys: PropertyKey | PropertyKey[] | SourceKeysCallback = '*'` - The keys to select and copy from `source` object. If wildcard (`*`) given, then all properties from the `source` are selected. + If a callback is given, then that callback must return key or keys to select from `source`. + +* `safe: boolean = true` - When `true`, properties must exist in target (_must be defined in target_), before they are shallow copied. + +::: warning Caution +The `target` object is mutated by this function. +::: + +::: tip Note +["Unsafe" properties](./reflections.md#iskeyunsafe) are disregarded, regardless of what `keys` are given. +::: + +```js +import { populate } from "@aedart/support/objects"; + +class Person { + name = null; + age = null; + + constructor(data) { + populate(this, data); + } +} + +const instance = new Person({ name: 'Janine', age: 36 }); +instance.name // Janine +instance.age // 36 +``` + +## Limit keys to populate + +By default, all keys (_`*`_) from the `source` object are attempted populated into the `target`. +You can limit what properties can be populated, by specifying what keys are allowed to be populated. + +```js +class Person { + name = null; + age = null; + phone = null; + + constructor(data) { + populate(this, data, [ 'name', 'age' ]); + } +} + +const instance = new Person({ name: 'Janine', age: 36, phone: '555 555 555' }); +instance.name // Janine +instance.age // 36 +instance.phone // null +``` + +## Source Keys Callback + +If you need a more advanced way to determine what keys to populate, then you can specify a callback as the `keys` argument. + +```js +populate(target, source, (source, target) => { + if (Reflect.has(source, 'phone') && Reflect.has(target, 'phone')) { + return [ 'name', 'age', 'phone' ]; + } + + return [ 'name', 'age' ]; +}); +``` + +## When keys do not exist + +When the `safe` argument is set to `true` (_default behavior_), and a property key does not exist in the `target` object, +then a `TypeError` is thrown. + +```js +class Person { + name = null; + age = null; + + constructor(data) { + populate(this, data, [ 'name', 'age', 'phone' ]); + } +} + +const instance = new Person({ + name: 'Janine', + age: 36, + phone: '555 555 555' +}); // TypeError - phone does not exist in target +``` + +However, if a requested key does not exist in the source object, then a `TypeError` is thrown regardless of the `safe` argument value. + +```js +class Person { + name = null; + age = null; + + constructor(data) { + populate(this, data, [ 'name', 'age', 'phone' ], false); + } +} + +const instance = new Person({ + name: 'Janine', + age: 36 +}); // TypeError - phone does not exist in source +``` diff --git a/docs/archive/current/packages/support/objects/set.md b/docs/archive/current/packages/support/objects/set.md new file mode 100644 index 00000000..7910d7a0 --- /dev/null +++ b/docs/archive/current/packages/support/objects/set.md @@ -0,0 +1,20 @@ +--- +title: Set +description: Set value in object path. +sidebarDepth: 0 +--- + +# `set` + +Set a value in object at given path. +_Method is an alias for [Lodash `set`](https://lodash.com/docs/4.17.15#set)._ + +```js +import { set } from "@aedart/support/objects"; + +const target = {}; + +set(target, 'a.foo', 'bar'); + +console.log(target); // { a: { foo: 'bar } } +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/objects/uniqueId.md b/docs/archive/current/packages/support/objects/uniqueId.md new file mode 100644 index 00000000..e6d8d09d --- /dev/null +++ b/docs/archive/current/packages/support/objects/uniqueId.md @@ -0,0 +1,27 @@ +--- +title: Unique ID +description: Set value in object path. +sidebarDepth: 0 +--- + +# `uniqueId` + +The `uniqueId()` is able to return a _"unique¹"_ reference identifier for any given object. + +```js +import { uniqueId, hasUniqueId } from "@aedart/support/objects"; + +const target = { + name: 'Ursula' +}; + +console.log(uniqueId(target)); // 27 + +// ...later in your application +console.log(hasUniqueId(target)); // true +console.log(uniqueId(target)); // 27 +``` + +The source code is heavily inspired by [Nicolas Gehlert's](https://github.com/ngehlert) blog post: ["_Get object reference IDs in JavaScript/TypeScript_" (September 28, 2022)](https://developapa.com/object-ids/) + +¹: _In this context, the returned number is unique in the current session. The number will NOT be unique across multiple sessions, nor guarantee that an object will receive the exact same identifier as in a previous session!_ \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/README.md b/docs/archive/current/packages/support/reflections/README.md new file mode 100644 index 00000000..0d1808f0 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/README.md @@ -0,0 +1,9 @@ +--- +title: About reflections +description: Reflection utilities. +sidebarDepth: 0 +--- + +# Reflections + +The `@aedart/support/reflections` submodule offers a few reflection related utilities. \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/assertHasPrototypeProperty.md b/docs/archive/current/packages/support/reflections/assertHasPrototypeProperty.md new file mode 100644 index 00000000..778c8992 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/assertHasPrototypeProperty.md @@ -0,0 +1,18 @@ +--- +title: Assert Has Prototype Prop. +description: Assert that object has a "prototype" property. +sidebarDepth: 0 +--- + +# `assertHasPrototypeProperty` + +Assert that given target object has a `prototype` property defined. +Throws a `TypeError` if target object does not have a `prototype` property + +_See [`hasPrototypeProperty`](./hasPrototypeProperty.md) for details._ + +```js +import { assertHasPrototypeProperty } from '@aedart/support/reflections'; + +assertHasPrototypeProperty({ __proto__: null }); // TypeError +``` diff --git a/docs/archive/current/packages/support/reflections/classLooksLike.md b/docs/archive/current/packages/support/reflections/classLooksLike.md new file mode 100644 index 00000000..e06508ce --- /dev/null +++ b/docs/archive/current/packages/support/reflections/classLooksLike.md @@ -0,0 +1,84 @@ +--- +title: Class Looks Like +description: Determine if a class looks like blueprint. +sidebarDepth: 0 +--- + +# `classLooksLike` + +Determines if a target class _"looks like"_ the provided class "blueprint". + +[[TOC]] + +## Arguments + +`classLooksLike()` accepts the following arguments: + +- `target: object` - the target class object. +- `blueprint: ClassBlueprint` - a blueprint that defines the expected members of a class (_see [Class Blueprint](#class-blueprint) for details._). + +```js +import { classLooksLike } from '@aedart/support/reflections'; + +class A {} + +class B { + foo() {} +} + +const blueprint = { members: [ 'foo' ] }; + +classLooksLike(A, blueprint); // false +classLooksLike(B, blueprint); // true +``` + +## Class Blueprint + +The class "blueprint" is an object that defines the expected members (_property keys_) of a target class. +All defined members must exist in target class' prototype, before the `classLooksLike()` returns `true`. + +You can specify either or both of the following properties in a class blueprint object: + +- `members: PropertyKey[]` - (_optional_) Properties or methods expected to exist in class' prototype. +- `staticMembers: PropertyKey[]` - (_optional_) Properties or methods expected to exist in class as static members. + +**Note:** _If you do not specify either `members` or `staticMembers`, then a `TypeError` is thrown._ + +```js +class A { + foo() {} + + bar() {} +} + +class B { + foo() {} + + static bar() {} +} + +const blueprint = { members: [ 'foo' ], staticMembers: [ 'bar' ] }; + +classLooksLike(A, blueprint); // false +classLooksLike(B, blueprint); // true +``` + +## Recursive + +`classLooksLike()` traverses target class' prototype chain. This means that you can compare a subclass against a blueprint +and inherited members will automatically be included in the check. + +```js +class A { + foo() {} +} + +class B extends A { + bar() {} +} + +const blueprint = { members: [ 'foo', 'bar' ]}; + +classLooksLike(A, blueprint); // false +classLooksLike(B, blueprint); // true +``` diff --git a/docs/archive/current/packages/support/reflections/classOwnKeys.md b/docs/archive/current/packages/support/reflections/classOwnKeys.md new file mode 100644 index 00000000..1457cad6 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/classOwnKeys.md @@ -0,0 +1,36 @@ +--- +title: Class Own Keys +description: Get property keys of target class. +sidebarDepth: 0 +--- + +# `classOwnKeys` + +Returns property keys that are defined target class's prototype. +It accepts the following arguments: + +- `target: ConstructorOrAbstractConstructor` - The target class +- `recursive: boolean = false` - (_optional_) If `true`, then target's prototype chain is traversed and all property keys are returned. + +```js +import { classOwnKeys } from '@aedart/support/reflections'; + +class A { + foo() {} +} + +class B extends A { + get bar() {} +} + +classOwnKeys(B); // [ 'constructor', 'bar' ] +classOwnKeys(B, true); // [ 'constructor', 'foo', 'bar' ] +``` + +::: warning Caution +`classOwnKeys()` throws `TypeError` if target does not have a `prototype` property. +::: + +## Limitation + +The `classOwnKeys()` function does not return static members of a target class. diff --git a/docs/archive/current/packages/support/reflections/getAllParentsOfClass.md b/docs/archive/current/packages/support/reflections/getAllParentsOfClass.md new file mode 100644 index 00000000..a41450b5 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/getAllParentsOfClass.md @@ -0,0 +1,28 @@ +--- +title: Get All Parents Of Class +description: Get all parents of a class +sidebarDepth: 0 +--- + +# `getAllParentsOfClass` + +Returns all parents of target class. +It accepts the following arguments: + +- `target: ConstructorOrAbstractConstructor` - The target class. +- `includeTarget: boolean = false` - (_optional_) If `true`, then given target is included in the output as the first element. + +```js +import { getAllParentsOfClass } from '@aedart/support/reflections'; + +class A {} + +class B extends A {} + +class C extends B {} + +getAllParentsOfClass(C); // [ B, A ] +getAllParentsOfClass(C, true); // [ C, B, A ] +``` + +_See also [`getParentOfClass()`](./getParentOfClass.md)._ \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/getClassPropertyDescriptor.md b/docs/archive/current/packages/support/reflections/getClassPropertyDescriptor.md new file mode 100644 index 00000000..14835736 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/getClassPropertyDescriptor.md @@ -0,0 +1,45 @@ +--- +title: Get Class Prop. Descriptor +description: Get property descriptor of target class property. +sidebarDepth: 0 +--- + +# `getClassPropertyDescriptor` + +Returns [`PropertyDescriptor`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/getOwnPropertyDescriptor), +from target's prototype that matches given property key. + +It accepts the following arguments: + +- `target: ConstructorOrAbstractConstructor` - The target class. +- `key: PropertyKey` - Name of the property. + +```js +import { getClassPropertyDescriptor } from '@aedart/support/reflections'; + +class A { + set name(v) {} + get name() {} +} + +getClassPropertyDescriptor(A, 'name'); // see "output"... +``` + +The above show example results in the given output: + +```js +const output = { + get: function () { /* ...Not shown... */ }, + set: function (v) { /* ..Not shown... */ }, + enumerable: false, + configurable: true +}; +``` + +::: tip Note +`getClassPropertyDescriptor()` returns `undefined` if requested key does not exist in class' prototype. +::: + +::: warning Caution +`getClassPropertyDescriptor()` throws `TypeError` if target does not have a `prototype` property. +::: \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/getClassPropertyDescriptors.md b/docs/archive/current/packages/support/reflections/getClassPropertyDescriptors.md new file mode 100644 index 00000000..2abdbbec --- /dev/null +++ b/docs/archive/current/packages/support/reflections/getClassPropertyDescriptors.md @@ -0,0 +1,52 @@ +--- +title: Get Class Prop. Descriptors +description: Get all property descriptors of target class. +sidebarDepth: 0 +--- + +# `getClassPropertyDescriptors` + +Returns all property [`descriptors`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/getOwnPropertyDescriptor) that are defined target's prototype. + +It accepts the following arguments: + +- `target: ConstructorOrAbstractConstructor` - The target class. +- `recursive: boolean = false` - (_optional_) If `true`, then target's parent prototypes are traversed. Descriptors are merged, such that the top-most class' descriptors are returned. + +```js +import { getClassPropertyDescriptors } from '@aedart/support/reflections'; + +class A { + set name(v) {} + get name() {} + bar() {} + [MY_SYMBOL]() {} +} + +getClassPropertyDescriptors(A); // { bar: {...}, name: {...}, [MY_SYMBOL]: {...} } +``` + +When `recursive` is set to `true`, then all property descriptors are returned from the target class' prototype chain. + +```js +import { getClassPropertyDescriptors } from '@aedart/support/reflections'; + +class A { + set name(v) {} + get name() {} + foo() {} + [MY_SYMBOL]() {} +} + +class B extends A { + set bar(v) {} + get bar() {} +} + +getClassPropertyDescriptors(B, true); +// { bar: {...}, foo: {...}, name: {...}, [MY_SYMBOL]: {...} } +``` + +::: warning Caution +`getClassPropertyDescriptors()` throws `TypeError` if target does not have a `prototype` property. +::: \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/getConstructorName.md b/docs/archive/current/packages/support/reflections/getConstructorName.md new file mode 100644 index 00000000..2fb8adcd --- /dev/null +++ b/docs/archive/current/packages/support/reflections/getConstructorName.md @@ -0,0 +1,24 @@ +--- +title: Get Constructor Name +description: Get name of target class' constructor +sidebarDepth: 0 +--- + +# `getConstructorName` + +Returns target class' constructor name, if available. + +It accepts the following arguments: + +- `target: ConstructorOrAbstractConstructor` - The target class +- `defaultValue: string|null = null` - (_optional_) A default string value to return if target has no constructor name. + +```js +import { getConstructorName } from '@aedart/support/reflections'; + +class Box {} + +getConstructorName(Box); // Box +getConstructorName(class {}); // null +getConstructorName(class {}, 'MyBox'); // MyBox +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/getNameOrDesc.md b/docs/archive/current/packages/support/reflections/getNameOrDesc.md new file mode 100644 index 00000000..6f8cd734 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/getNameOrDesc.md @@ -0,0 +1,18 @@ +--- +title: Get Name Or Desc. Tag +description: Get name of target class' constructor, or description tag +sidebarDepth: 0 +--- + +# `getNameOrDesc` + +Returns target class' [constructor name](./getConstructorName.md), or [description tag](../misc/descTag.md) if name is not available. + +```js +import { getNameOrDesc } from '@aedart/support/reflections'; + +class ApiService {} + +getNameOrDesc(ApiService); // ApiService +getNameOrDesc(class {}); // [object Function] +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/getParentOfClass.md b/docs/archive/current/packages/support/reflections/getParentOfClass.md new file mode 100644 index 00000000..5248e5db --- /dev/null +++ b/docs/archive/current/packages/support/reflections/getParentOfClass.md @@ -0,0 +1,25 @@ +--- +title: Get Parent Of Class +description: Get parent class. +sidebarDepth: 0 +--- + +# `getParentOfClass` + +Returns the parent class of given target class, or `null` if class does not have a parent. + +```js +import { getParentOfClass } from '@aedart/support/reflections'; + +class A {} + +class B extends A {} + +class C extends B {} + +getParentOfClass(A); // null +getParentOfClass(B); // A +getParentOfClass(C); // B +``` + +_See also [`getAllParentsOfClass()`](./getAllParentsOfClass.md)._ \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/hasAllMethods.md b/docs/archive/current/packages/support/reflections/hasAllMethods.md new file mode 100644 index 00000000..c8cc4d71 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/hasAllMethods.md @@ -0,0 +1,26 @@ +--- +title: Has All Methods +description: Determine if target has all methods +sidebarDepth: 0 +--- + +# `hasAllMethods` + +Determine if given target object contains all given methods. + +It accepts the following arguments: + +- `target: object` - The target. +- `...methods: PropertyKey[]` - Names of the methods to check for. + +```js +import { hasAllMethods } from '@aedart/support/reflections'; + +const a = { + foo: () => { /* ...not shown... */ }, + bar: () => { /* ...not shown... */ }, +} + +hasAllMethods(a, 'foo', 'bar'); // true +hasAllMethods(a, 'foo', 'bar', 'zar'); // false +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/hasMethod.md b/docs/archive/current/packages/support/reflections/hasMethod.md new file mode 100644 index 00000000..5aec48d3 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/hasMethod.md @@ -0,0 +1,27 @@ +--- +title: Has Method +description: Determine if target has method +sidebarDepth: 0 +--- + +# `hasMethod` + +Determine if given target object contains method. + +It accepts the following arguments: + +- `target: object` - The target. +- `method: PropertyKey` - Name of the method to check for. + +```js +import { hasMethod } from '@aedart/support/reflections'; + +const a = { + foo: () => { /* ...not shown... */ }, + bar: () => { /* ...not shown... */ }, +} + +hasMethod(a, 'foo'); // true +hasMethod(a, 'bar'); // true +hasMethod(a, 'zar'); // false +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/hasPrototypeProperty.md b/docs/archive/current/packages/support/reflections/hasPrototypeProperty.md new file mode 100644 index 00000000..2ba3d5c9 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/hasPrototypeProperty.md @@ -0,0 +1,24 @@ +--- +title: Has Prototype Property +description: Determine if "prototype" property exists. +sidebarDepth: 0 +--- + +# `hasPrototypeProperty` + +Determines if object has a `prototype` property defined and that it is not `null` or `undefined`. + +```js +import { hasPrototypeProperty } from '@aedart/support/reflections'; + +hasPrototypeProperty(null); // false +hasPrototypeProperty(Object.create(null)); // false +hasPrototypeProperty({ __proto__: undefined }); // false +hasPrototypeProperty({ prototype: null }); // false +hasPrototypeProperty(() => true); // false + +hasPrototypeProperty(Object.create({ prototype: {} })); // true +hasPrototypeProperty({ __proto__: function() {} }); // true +hasPrototypeProperty(function() {}); // true +hasPrototypeProperty(class {}); // true +``` diff --git a/docs/archive/current/packages/support/reflections.md b/docs/archive/current/packages/support/reflections/isConstructor.md similarity index 77% rename from docs/archive/current/packages/support/reflections.md rename to docs/archive/current/packages/support/reflections/isConstructor.md index fb0b795d..ae7b7ff4 100644 --- a/docs/archive/current/packages/support/reflections.md +++ b/docs/archive/current/packages/support/reflections/isConstructor.md @@ -1,16 +1,10 @@ --- -title: Reflections -description: Reflection utilities +title: Is Constructor +description: Determine if value is a constructor. sidebarDepth: 0 --- -# Reflections - -The `@aedart/support/reflections` submodule offers a few reflection related utilities. - -[[TOC]] - -## `isConstructor` +# `isConstructor` Based on the [TC39 `Function.isCallable() / Function.isConstructor()`](https://github.com/caitp/TC39-Proposals/blob/trunk/tc39-reflect-isconstructor-iscallable.md) proposal, the `isConstructor()` can determine if given argument is a constructor. diff --git a/docs/archive/current/packages/support/reflections/isKeySafe.md b/docs/archive/current/packages/support/reflections/isKeySafe.md new file mode 100644 index 00000000..bfc297a8 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/isKeySafe.md @@ -0,0 +1,18 @@ +--- +title: Is Key Safe +description: Determine if a property key is safe. +sidebarDepth: 0 +--- + +## `isKeySafe` + +Opposite of [`isKeyUnsafe()`](./isKeyUnsafe.md). + +```js +import { isKeySafe } from '@aedart/support/reflections'; + +isKeySafe('name'); // true +isKeySafe('length'); // true +isKeySafe('constructor'); // true +isKeySafe('__proto__'); // false +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/isKeyUnsafe.md b/docs/archive/current/packages/support/reflections/isKeyUnsafe.md new file mode 100644 index 00000000..bbbd1abb --- /dev/null +++ b/docs/archive/current/packages/support/reflections/isKeyUnsafe.md @@ -0,0 +1,27 @@ +--- +title: Is Key Unsafe +description: Determine if a property key is unsafe. +sidebarDepth: 0 +--- + +# `isKeyUnsafe` + +Determines if a property key is considered "unsafe". + +```js +import { isKeyUnsafe } from '@aedart/support/reflections'; + +isKeyUnsafe('name'); // false +isKeyUnsafe('length'); // false +isKeyUnsafe('constructor'); // false +isKeyUnsafe('__proto__'); // true +``` + +::: tip Note +Behind the scene, the `isKeyUnsafe()` function matches the given key against values from the predefined `DANGEROUS_PROPERTIES` list, +which is defined in the `@aedart/contracts/support/objects` submodule; + +```js +import { DANGEROUS_PROPERTIES } from "@aedart/contracts/support/objects"; +``` +::: \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/isSubclass.md b/docs/archive/current/packages/support/reflections/isSubclass.md new file mode 100644 index 00000000..cf8dbac5 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/isSubclass.md @@ -0,0 +1,28 @@ +--- +title: Is Subclass +description: Determine if target is a subclass of another class. +sidebarDepth: 0 +--- + +# `isSubclass` + +Determine if target class is a subclass (_child class_) of given superclass (_parent class_). + +It accepts the following arguments: + +- `target: object` - The target. +- `superclass: ConstructorOrAbstractConstructor` - The superclass. + +```js +import { isSubclass } from '@aedart/support/reflections'; + +class A {} + +class B extends A {} + +isSubclass({}, A); // false +isSubclass(A, A); // false +isSubclass(A, B); // false + +isSubclass(B, A); // true +``` \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/isSubclassOrLooksLike.md b/docs/archive/current/packages/support/reflections/isSubclassOrLooksLike.md new file mode 100644 index 00000000..57376450 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/isSubclassOrLooksLike.md @@ -0,0 +1,34 @@ +--- +title: Is Subclass Or Looks Like +description: Determine if target is a subclass of another class, or looks like blueprint +sidebarDepth: 0 +--- + +# `isSubclassOrLooksLike` + +Determine if target class is a subclass of given superclass, or if it looks like given blueprint. + +It accepts the following arguments: + +- `target: object` - The target. +- `superclass: ConstructorOrAbstractConstructor` - The superclass. +- `blueprint: ClassBlueprint` - Class Blueprint (_See [`classLooksLike`](./classLooksLike.md#class-blueprint)_). + +```js +import { isSubclassOrLooksLike } from '@aedart/support/reflections'; + +class A { + foo() {} +} +class B extends A {} + +class C { + foo() {} +} + +isSubclassOrLooksLike(B, A, { members: [] }); // true +isSubclassOrLooksLike(C, A, { members: [] }); // false +isSubclassOrLooksLike(C, A, { members: [ 'foo' ] }); // true +``` + +_See [`isSubclass()`](./isSubclass.md) and [`classLooksLike()`](./classLooksLike.md) for additional details._ \ No newline at end of file diff --git a/docs/archive/current/packages/support/reflections/isWeakKind.md b/docs/archive/current/packages/support/reflections/isWeakKind.md new file mode 100644 index 00000000..3abab271 --- /dev/null +++ b/docs/archive/current/packages/support/reflections/isWeakKind.md @@ -0,0 +1,26 @@ +--- +title: Is WeakKind +description: Determine if object is of a "weak" kind. +sidebarDepth: 0 +--- + +# `isWeakKind` + +Determine if object of a "weak" kind, e.g. [`WeakRef`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef), +[`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap), +or [`WeakSet`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakSet). + +```js +import { isWeakKind } from '@aedart/support/reflections'; + +const a = {}; + +isWeakKind(null); // false +isWeakKind(a); // false +isWeakKind(new Map()); // false +isWeakKind(new Set()); // false + +isWeakKind(new WeakRef(a)); // true +isWeakKind(new WeakMap()); // true +isWeakKind(new WeakSet()); // true +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 24e59e4d..a398895c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11413,12 +11413,6 @@ "karma": "^6.0.0" } }, - "node_modules/karma-jasmine/node_modules/jasmine-core": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.0.tgz", - "integrity": "sha512-O236+gd0ZXS8YAjFx8xKaJ94/erqUliEkJTDedyE7iHvv4ZVqi+q+8acJxu05/WJDKm512EUNn809In37nWlAQ==", - "dev": true - }, "node_modules/karma-spec-reporter": { "version": "0.0.36", "resolved": "https://registry.npmjs.org/karma-spec-reporter/-/karma-spec-reporter-0.0.36.tgz", diff --git a/package.json b/package.json index 44ca5dd9..97b16e80 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,11 @@ "webpack": "^5.90.0", "webpack-cli": "^5.1.4" }, + "overrides": { + "karma-jasmine": { + "jasmine-core": "^5.1.2" + } + }, "scripts": { "bootstrap": "lerna bootstrap", "clean": "lerna clean && sh clean.sh", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index ba3d0e94..14d2d884 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -36,6 +36,21 @@ "import": "./dist/esm/support.js", "require": "./dist/cjs/support.cjs" }, + "./support/arrays": { + "types": "./dist/types/support/arrays.d.ts", + "import": "./dist/esm/support/arrays.js", + "require": "./dist/cjs/support/arrays.cjs" + }, + "./support/concerns": { + "types": "./dist/types/support/concerns.d.ts", + "import": "./dist/esm/support/concerns.js", + "require": "./dist/cjs/support/concerns.cjs" + }, + "./support/exceptions": { + "types": "./dist/types/support/exceptions.d.ts", + "import": "./dist/esm/support/exceptions.js", + "require": "./dist/cjs/support/exceptions.cjs" + }, "./support/meta": { "types": "./dist/types/support/meta.d.ts", "import": "./dist/esm/support/meta.js", @@ -46,6 +61,11 @@ "import": "./dist/esm/support/mixins.js", "require": "./dist/cjs/support/mixins.cjs" }, + "./support/objects": { + "types": "./dist/types/support/objects.d.ts", + "import": "./dist/esm/support/objects.js", + "require": "./dist/cjs/support/objects.cjs" + }, "./support/reflections": { "types": "./dist/types/support/reflections.d.ts", "import": "./dist/esm/support/reflections.js", diff --git a/packages/contracts/rollup.config.mjs b/packages/contracts/rollup.config.mjs index a584e216..066e3fd3 100644 --- a/packages/contracts/rollup.config.mjs +++ b/packages/contracts/rollup.config.mjs @@ -4,6 +4,9 @@ export default createConfig({ baseDir: new URL('.', import.meta.url), external: [ '@aedart/contracts/support', + '@aedart/contracts/support/arrays', + '@aedart/contracts/support/concerns', + '@aedart/contracts/support/exceptions', '@aedart/contracts/support/meta', '@aedart/contracts/support/mixins', '@aedart/contracts/support/reflections', diff --git a/packages/contracts/src/support/arrays/ConcatSpreadable.ts b/packages/contracts/src/support/arrays/ConcatSpreadable.ts new file mode 100644 index 00000000..feee2dd4 --- /dev/null +++ b/packages/contracts/src/support/arrays/ConcatSpreadable.ts @@ -0,0 +1,30 @@ +/** + * Concat Spreadable + * + * Controls the behaviour of to treat this object's properties, when the object is concatenated via [`Array.concat()`]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat} + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/isConcatSpreadable + */ +export default interface ConcatSpreadable extends ArrayLike +{ + /** + * Determines how [`Array.concat()`]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat} + * should treat this object as an array-like object and flattened to its array elements, or not. + * + * For array objects, the default behavior is to spread (flatten) elements (`[Symbol.isConcatSpreadable] === true`). + * + * For array-like objects, the default behavior is no spreading or flattening (`[Symbol.isConcatSpreadable] === false`). + * + * @type {boolean} + */ + [Symbol.isConcatSpreadable]: boolean; + + /** + * Length controls the number of properties to be added + * when this object is concatenated via [`Array.concat()`]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat}, + * if [Symbol.isConcatSpreadable]{@link Symbol.isConcatSpreadable} is `true`. + * + * @type {number} Must be greater than or equal to 0 + */ + readonly length: number; +} \ No newline at end of file diff --git a/packages/contracts/src/support/arrays/index.ts b/packages/contracts/src/support/arrays/index.ts new file mode 100644 index 00000000..09aac224 --- /dev/null +++ b/packages/contracts/src/support/arrays/index.ts @@ -0,0 +1,11 @@ +/** + * Support Arrays identifier + * + * @type {Symbol} + */ +export const SUPPORT_ARRAYS: unique symbol = Symbol('@aedart/contracts/support/arrays'); + +import ConcatSpreadable from "./ConcatSpreadable"; +export { + type ConcatSpreadable +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/AliasDescriptorFactory.ts b/packages/contracts/src/support/concerns/AliasDescriptorFactory.ts new file mode 100644 index 00000000..8880d3fe --- /dev/null +++ b/packages/contracts/src/support/concerns/AliasDescriptorFactory.ts @@ -0,0 +1,18 @@ +import ConcernConstructor from './ConcernConstructor'; + +/** + * Alias Descriptor Factory + */ +export default interface AliasDescriptorFactory +{ + /** + * Makes a property descriptor to be used for an "alias" (proxy) property or method + * + * @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` concern + * + * @returns {PropertyDescriptor} Descriptor to be used for defining alias in a target class + */ + make(key: PropertyKey, source: ConcernConstructor, keyDescriptor: PropertyDescriptor): PropertyDescriptor; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/Concern.ts b/packages/contracts/src/support/concerns/Concern.ts new file mode 100644 index 00000000..ecf447ec --- /dev/null +++ b/packages/contracts/src/support/concerns/Concern.ts @@ -0,0 +1,20 @@ +/** + * Concern + * + * A "concern" is component that can be injected into a target class (concern owner). + * The concern itself is NOT responsible for performing the actual injection logic. + * + * @interface + */ +export default interface Concern +{ + /** + * The owner class instance this concern is injected into, + * or `this` concern instance if no owner was set. + * + * @readonly + * + * @type {object} + */ + get concernOwner(): object; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/ConcernConstructor.ts b/packages/contracts/src/support/concerns/ConcernConstructor.ts new file mode 100644 index 00000000..f336c64f --- /dev/null +++ b/packages/contracts/src/support/concerns/ConcernConstructor.ts @@ -0,0 +1,35 @@ +import Concern from "./Concern"; +import { PROVIDES } from "./index"; + +/** + * Concern Constructor + * + * @template T extends Concern + * + * @see Concern + */ +export default interface ConcernConstructor +{ + /** + * 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. + */ + new (owner?: object): T; + + /** + * 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[]} + */ + [PROVIDES](): PropertyKey[]; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/Configuration.ts b/packages/contracts/src/support/concerns/Configuration.ts new file mode 100644 index 00000000..09a88be9 --- /dev/null +++ b/packages/contracts/src/support/concerns/Configuration.ts @@ -0,0 +1,54 @@ +import Concern from "./Concern"; +import ConcernConstructor from "./ConcernConstructor"; +import type { + Aliases +} from "./types"; + +/** + * Concern Injection Configuration + * + * Defines the Concern class that must be injected into a target class, + * along with other aspects of the injection, such as what aliases to use + * and what properties or methods should not be aliased. + * + * @template T extends Concern = Concern + * + * @see {Concern} + * @see {Aliases} + */ +export default interface Configuration +{ + /** + * The Concern Class that must be injected into a target class + * + * @template T extends Concern = Concern + * + * @type {ConcernConstructor} + */ + concern: ConcernConstructor; + + /** + * Aliases for Concern's properties or methods. + * + * **Note**: _Defaults to same property and method names as "aliases", as those defined by + * a concern class' [PROVIDES]{@link import('@aedart/contracts/support/concerns').PROVIDES}, + * if this property is empty or `undefined`._ + * + * @template T extends Concern = Concern + * + * @type {Aliases|undefined} + */ + aliases?: Aliases; + + /** + * Flag that indicates whether an "injector" is allowed to create + * "aliases" (proxy) properties and methods into a target class's prototype. + * + * **Note**: _Defaults to `true` if this property is `undefined`._ + * + * **Note**: _If set to `false`, then {@link aliases} are ignored._ + * + * @type {boolean} + */ + allowAliases?: boolean; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/Container.ts b/packages/contracts/src/support/concerns/Container.ts new file mode 100644 index 00000000..cc5ab1f3 --- /dev/null +++ b/packages/contracts/src/support/concerns/Container.ts @@ -0,0 +1,153 @@ +import ConcernConstructor from "./ConcernConstructor"; +import Concern from "./Concern"; +import Owner from "./Owner"; + +/** + * Concerns Container + */ +export default interface Container +{ + /** + * The amount of concerns in this container + * + * @readonly + * + * @type {number} + */ + readonly size: number; + + /** + * Get the concerns container owner + * + * @type {Owner} + */ + get owner(): Owner; + + /** + * Determine if concern class is registered in this container + * + * @param {ConcernConstructor} concern + * + * @return {boolean} + */ + has(concern: ConcernConstructor): boolean; + + /** + * 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} concern + * + * @return {T} The booted instance of the concern class. If concern class was + * previously booted, then that instance is returned. + * + * @throws {ConcernException} + */ + get(concern: ConcernConstructor): T; + + /** + * Determine if concern class has been booted + * + * @param {ConcernConstructor} concern + * + * @return {boolean} + */ + hasBooted(concern: ConcernConstructor): boolean + + /** + * Boot concern class + * + * @template T extends {@link Concern} + * + * @param {ConcernConstructor} concern + * + * @return {T} New concern instance + * + * @throws {NotRegisteredException} If concern class is not registered in this container + * @throws {BootException} If concern is unable to be booted, e.g. if already booted + */ + boot(concern: ConcernConstructor): T; + + /** + * Boots all registered concern classes + * + * @throws {ConcernException} + */ + bootAll(): void; + + /** + * Determine if this container is empty + * + * @return {boolean} + */ + isEmpty(): boolean; + + /** + * Opposite of {@link isEmpty} + * + * @return {boolean} + */ + isNotEmpty(): boolean; + + /** + * Returns all concern classes + * + * @return {IterableIterator} + */ + all(): IterableIterator; + + /** + * Invoke a method with given arguments in concern instance + * + * @param {ConcernConstructor} concern + * @param {PropertyKey} method + * @param {...any} [args] + * + * @return {any} + * + * @throws {ConcernException} + * @throws {Error} + */ + call( + concern: ConcernConstructor, + method: PropertyKey, + ...args: any[] /* eslint-disable-line @typescript-eslint/no-explicit-any */ + ): any; /* eslint-disable-line @typescript-eslint/no-explicit-any */ + + /** + * Set the value of given property in concern instance + * + * @param {ConcernConstructor} concern + * @param {PropertyKey} property + * @param {any} value + * + * @throws {ConcernException} + * @throws {Error} + */ + setProperty( + concern: ConcernConstructor, + property: PropertyKey, + value: any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + ): void; + + /** + * Get value of given property in concern instance + * + * @param {ConcernConstructor} concern + * @param {PropertyKey} property + * + * @return {any} + * + * @throws {ConcernException} + * @throws {Error} + */ + getProperty( + concern: ConcernConstructor, + property: PropertyKey + ): any; /* eslint-disable-line @typescript-eslint/no-explicit-any */ +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/DescriptorsRepository.ts b/packages/contracts/src/support/concerns/DescriptorsRepository.ts new file mode 100644 index 00000000..f3ac87e3 --- /dev/null +++ b/packages/contracts/src/support/concerns/DescriptorsRepository.ts @@ -0,0 +1,67 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import ConcernConstructor from './ConcernConstructor'; +import UsesConcerns from './UsesConcerns'; + +/** + * Descriptors Repository + * + * Utility for obtaining property descriptors for a target class or concern. + */ +export default interface DescriptorsRepository +{ + /** + * Returns property descriptors for given target class (recursively) + * + * @param {ConstructorOrAbstractConstructor | 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} + */ + get( + target: ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor, + force?: boolean, + cache?: boolean + ): Record; + + /** + * Caches property descriptors for target during the execution of callback. + * + * @param {ConstructorOrAbstractConstructor | 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 + * + * @return {any} + */ + rememberDuring( + target: ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor, + callback: () => any, /* eslint-disable-line @typescript-eslint/no-explicit-any */ + forgetAfter?: boolean + ): any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + + /** + * Retrieves the property descriptors for given target and caches them + * + * @param {ConstructorOrAbstractConstructor | 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} + */ + remember(target: ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor, force?: boolean): Record; + + /** + * Deletes cached descriptors for target + * + * @param {ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor} target + * + * @return {boolean} + */ + forget(target: ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor): boolean; + + /** + * Clears all cached descriptors + * + * @return {this} + */ + clear(): this +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/Factory.ts b/packages/contracts/src/support/concerns/Factory.ts new file mode 100644 index 00000000..c6054725 --- /dev/null +++ b/packages/contracts/src/support/concerns/Factory.ts @@ -0,0 +1,34 @@ +import ConcernConstructor from "./ConcernConstructor"; +import Configuration from './Configuration'; +import { ShorthandConfiguration } from "./types"; + +/** + * Concern Configuration Factory + * + * Able to make new {@link Configuration} from a given concern "entry". + */ +export default interface Factory +{ + /** + * 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: object, entry: ConcernConstructor | Configuration | ShorthandConfiguration): Configuration; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/Injector.ts b/packages/contracts/src/support/concerns/Injector.ts new file mode 100644 index 00000000..2a0346c3 --- /dev/null +++ b/packages/contracts/src/support/concerns/Injector.ts @@ -0,0 +1,124 @@ +import ConcernConstructor from "./ConcernConstructor"; +import Configuration from "./Configuration"; +import UsesConcerns from "./UsesConcerns"; +import { ShorthandConfiguration } from "./types"; + +/** + * Concerns Injector + * + * Able to inject concerns into a target class and create alias (proxy) properties + * and methods to the provided concerns' properties and methods, in the target class. + * + * @template T = object The target class that concern classes must be injected into + * + * @see {Concern} + */ +export default interface Injector +{ + /** + * The target class + * + * @returns {T} + */ + get target(): T; + + /** + * 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} The modified target class + * + * @throws {InjectionException} + */ + inject(...concerns: (ConcernConstructor|Configuration|ShorthandConfiguration)[]): UsesConcerns; + + /** + * 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[]} concerns List of concern classes + * + * @returns {UsesConcerns} The modified target class + * + * @throws {AlreadyRegisteredException} + * @throws {InjectionException} + */ + defineConcerns(target: T, concerns: ConcernConstructor[]): UsesConcerns; + + /** + * 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} target The target in which a concerns container must be defined + * + * @returns {UsesConcerns} The modified target class + * + * @throws {InjectionException} If unable to define concerns container in target class + */ + defineContainer(target: UsesConcerns): UsesConcerns; + + /** + * 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} target The target in which "aliases" must be defined in + * @param {Configuration[]} configurations List of concern injection configurations + * + * @returns {UsesConcerns} The modified target class + * + * @throws {AliasConflictException} If case of alias naming conflicts. + * @throws {InjectionException} If unable to define aliases in target class. + */ + defineAliases(target: UsesConcerns, configurations: Configuration[]): UsesConcerns; + + /** + * 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} 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 {UnsafeAliasException} 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: UsesConcerns, + alias: PropertyKey, + key: PropertyKey, + source: ConcernConstructor + ): boolean; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/Owner.ts b/packages/contracts/src/support/concerns/Owner.ts new file mode 100644 index 00000000..76d915f2 --- /dev/null +++ b/packages/contracts/src/support/concerns/Owner.ts @@ -0,0 +1,19 @@ +import Container from "./Container"; +import { CONCERNS } from "./index"; + +/** + * Concerns Owner + * + * An owner is an object, e.g. instance of a class, that offers a concerns {@link Container}. + */ +export default interface Owner +{ + /** + * The concerns container for this class + * + * @readonly + * + * @type {Container} + */ + readonly [CONCERNS]: Container; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/RegistrationAware.ts b/packages/contracts/src/support/concerns/RegistrationAware.ts new file mode 100644 index 00000000..7ba8c1c6 --- /dev/null +++ b/packages/contracts/src/support/concerns/RegistrationAware.ts @@ -0,0 +1,46 @@ +import { BEFORE, AFTER } from "./index"; +import UsesConcerns from "./UsesConcerns"; + +/** + * Registration Aware + * + * Concern class is aware of when it is being registered by a target class + * and is able to performs pre-/post-registration logic. + */ +export default interface RegistrationAware +{ + /** + * 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} + */ + [BEFORE](target: UsesConcerns): void; + + /** + * 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} + */ + [AFTER](target: UsesConcerns): void; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/UsesConcerns.ts b/packages/contracts/src/support/concerns/UsesConcerns.ts new file mode 100644 index 00000000..61a043c5 --- /dev/null +++ b/packages/contracts/src/support/concerns/UsesConcerns.ts @@ -0,0 +1,55 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { CONCERN_CLASSES, ALIASES, Alias } from "./index"; +import ConcernConstructor from "./ConcernConstructor"; +import Owner from "./Owner"; + +/** + * Uses Concerns + * + * A target class that uses one or more concern classes. + * + * @template T = object + */ +export default interface UsesConcerns +{ + /** + * Constructor + * + * @template T = object + * + * @param {...any} [args] + * + * @returns {ConstructorOrAbstractConstructor & Owner} + */ + new( + ...args: any[] /* eslint-disable-line @typescript-eslint/no-explicit-any */ + ): ConstructorOrAbstractConstructor; + + /** + * A list of the concern classes to be used by this target class. + * + * **Note**: _If this class' parent class also uses concerns, then those + * concern classes are included recursively in this list, in the order + * that they have been registered._ + * + * **Note**: _This property is usually automatically defined and populated + * by an {@link Injector}, and used to prevent duplicate concern injections._ + * + * @static + * + * @type {ConcernConstructor[]} + */ + [CONCERN_CLASSES]: ConcernConstructor[]; + + /** + * List of aliases applied in this target class. + * + * **Note**: _This property is usually automatically defined and populated + * by an {@link Injector}, and used to prevent alias conflicts._ + * + * @static + * + * @type {Alias[]} + */ + [ALIASES]: Alias[]; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/exceptions/AliasConflictException.ts b/packages/contracts/src/support/concerns/exceptions/AliasConflictException.ts new file mode 100644 index 00000000..e9214934 --- /dev/null +++ b/packages/contracts/src/support/concerns/exceptions/AliasConflictException.ts @@ -0,0 +1,40 @@ +import { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import InjectionException from "./InjectionException"; +import UsesConcerns from "../UsesConcerns"; +import { Alias } from '../types' + +/** + * Alias Conflict Exception + * + * To be thrown if an alias conflicts with another alias. + */ +export default interface AliasConflictException extends InjectionException +{ + /** + * The requested alias that conflicts with another alias + * of the same name. + * + * @readonly + * + * @type {Alias} + */ + readonly alias: Alias; + + /** + * the property key that the conflicting alias points to + * + * @readonly + * + * @type {Alias} + */ + readonly key: PropertyKey; + + /** + * The source class (e.g. parent class) that defines that originally defined the alias + * + * @readonly + * + * @type {ConstructorOrAbstractConstructor | UsesConcerns} + */ + readonly source: ConstructorOrAbstractConstructor | UsesConcerns; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/exceptions/AlreadyRegisteredException.ts b/packages/contracts/src/support/concerns/exceptions/AlreadyRegisteredException.ts new file mode 100644 index 00000000..a514b947 --- /dev/null +++ b/packages/contracts/src/support/concerns/exceptions/AlreadyRegisteredException.ts @@ -0,0 +1,22 @@ +import { InjectionException } from "@aedart/contracts/support/concerns"; +import { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import UsesConcerns from "../UsesConcerns"; + +/** + * Already Registered Exception + * + * To be thrown when a concern class is attempted registered in a target, but + * was previously registered in the target or target's parent. + */ +export default interface AlreadyRegisteredException extends InjectionException +{ + /** + * The source, e.g. a parent class, in which a concern class + * was already registered. + * + * @readonly + * + * @type {ConstructorOrAbstractConstructor|UsesConcerns} + */ + readonly source: ConstructorOrAbstractConstructor | UsesConcerns; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/exceptions/BootException.ts b/packages/contracts/src/support/concerns/exceptions/BootException.ts new file mode 100644 index 00000000..8304c0a9 --- /dev/null +++ b/packages/contracts/src/support/concerns/exceptions/BootException.ts @@ -0,0 +1,10 @@ +import ConcernException from './ConcernException' + +/** + * Concern Boot Exception + * + * To be thrown when a concern class is unable to boot. + * + * @see ConcernException + */ +export default interface BootException extends ConcernException {} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/exceptions/ConcernException.ts b/packages/contracts/src/support/concerns/exceptions/ConcernException.ts new file mode 100644 index 00000000..2a58412e --- /dev/null +++ b/packages/contracts/src/support/concerns/exceptions/ConcernException.ts @@ -0,0 +1,19 @@ +import { Throwable } from "@aedart/contracts/support/exceptions"; +import ConcernConstructor from '../ConcernConstructor' + +/** + * Concern Exception + * + * To be thrown when a concern class is the cause of an error. + */ +export default interface ConcernException extends Throwable { + + /** + * The Concern class that caused this error or exception + * + * @readonly + * + * @type {ConcernConstructor | null} + */ + readonly concern: ConcernConstructor | null; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/exceptions/InjectionException.ts b/packages/contracts/src/support/concerns/exceptions/InjectionException.ts new file mode 100644 index 00000000..a2bac41b --- /dev/null +++ b/packages/contracts/src/support/concerns/exceptions/InjectionException.ts @@ -0,0 +1,20 @@ +import { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import ConcernException from "./ConcernException"; +import UsesConcerns from "../UsesConcerns"; + +/** + * Concern Injection Exception + * + * To be thrown when a concern class cannot be injected into a target class. + */ +export default interface InjectionException extends ConcernException +{ + /** + * The target class + * + * @readonly + * + * @type {ConstructorOrAbstractConstructor|UsesConcerns} + */ + readonly target: ConstructorOrAbstractConstructor | UsesConcerns; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/exceptions/NotRegisteredException.ts b/packages/contracts/src/support/concerns/exceptions/NotRegisteredException.ts new file mode 100644 index 00000000..b1fb56d4 --- /dev/null +++ b/packages/contracts/src/support/concerns/exceptions/NotRegisteredException.ts @@ -0,0 +1,11 @@ +import ConcernException from './ConcernException'; + +/** + * Concern Not Registered Exception + * + * To be thrown when a given concern is expected registered in a [Container]{@link import('@aedart/contracts/support/concerns').Container}, + * but isn't. + * + * @see ConcernException + */ +export default interface NotRegisteredException extends ConcernException {} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/exceptions/UnsafeAliasException.ts b/packages/contracts/src/support/concerns/exceptions/UnsafeAliasException.ts new file mode 100644 index 00000000..f2120789 --- /dev/null +++ b/packages/contracts/src/support/concerns/exceptions/UnsafeAliasException.ts @@ -0,0 +1,27 @@ +import InjectionException from "./InjectionException"; + +/** + * Unsafe Alias Exception + * + * To be thrown when an alias points to an "unsafe" property or method inside a concern. + */ +export default interface UnsafeAliasException extends InjectionException +{ + /** + * The alias that points to an "unsafe" property or method + * + * @readonly + * + * @type {PropertyKey} + */ + readonly alias: PropertyKey; + + /** + * The "unsafe" property or method that an alias points to + * + * @readonly + * + * @type {PropertyKey} + */ + readonly key: PropertyKey; +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/exceptions/index.ts b/packages/contracts/src/support/concerns/exceptions/index.ts new file mode 100644 index 00000000..74fff8dd --- /dev/null +++ b/packages/contracts/src/support/concerns/exceptions/index.ts @@ -0,0 +1,16 @@ +import AliasConflictException from "./AliasConflictException"; +import AlreadyRegisteredException from "./AlreadyRegisteredException"; +import BootException from "./BootException"; +import ConcernException from "./ConcernException"; +import InjectionException from "./InjectionException"; +import NotRegisteredException from "./NotRegisteredException"; +import UnsafeAliasException from "./UnsafeAliasException"; +export { + type AliasConflictException, + type AlreadyRegisteredException, + type BootException, + type ConcernException, + type InjectionException, + type NotRegisteredException, + type UnsafeAliasException +} \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/index.ts b/packages/contracts/src/support/concerns/index.ts new file mode 100644 index 00000000..d045c23c --- /dev/null +++ b/packages/contracts/src/support/concerns/index.ts @@ -0,0 +1,109 @@ +/** + * Support Concerns identifier + * + * @type {Symbol} + */ +export const SUPPORT_CONCERNS: unique symbol = Symbol('@aedart/contracts/support/concerns'); + +/** + * Symbol used by a [concern class]{@link ConcernConstructor} to indicate what properties + * and methods can be aliased into a target class. + * + * **Note**: _Symbol MUST be used to as name for a "static" method in the desired Concern class._ + * + * **Example**: + * ```ts + * class MyConcern implements Concern + * { + * static [PROVIDES](): PropertyKey[] + * { + * // ...not shown... + * } + * + * // ...remaining not shown... + * } + * ``` + * + * @see ConcernConstructor + * + * @type {symbol} + */ +export const PROVIDES : unique symbol = Symbol('concern_provides'); + +/** + * Symbol that can be used by a [concern class]{@link ConcernConstructor} to perform + * pre-registration logic + * + * @see RegistrationAware + * @see ConcernConstructor + * + * @type {symbol} + */ +export const BEFORE: unique symbol = Symbol('concern_before_registration'); + +/** + * Symbol that can be used by a [concern class]{@link ConcernConstructor} to perform + * post-registration logic. + * + * @see RegistrationAware + * @see ConcernConstructor + * + * @type {symbol} + */ +export const AFTER: unique symbol = Symbol('concern_after_registration'); + +/** + * Symbol used to define a list of the concern classes to be used by a target class. + * + * @see {UsesConcerns} + * + * @type {Symbol} + */ +export const CONCERN_CLASSES: unique symbol = Symbol('concern_classes'); + +/** + * Symbol used to list the aliases applied in a target class + * + * @see {UsesConcerns} + * + * @type {Symbol} + */ +export const ALIASES: unique symbol = Symbol('aliases'); + +/** + * Symbol used to define a "concerns container" property inside a target class' prototype + * + * @see {Owner} + * @see {Container} + * + * @type {Symbol} + */ +export const CONCERNS: unique symbol = Symbol('concerns'); + +import Concern from "./Concern"; +import ConcernConstructor from "./ConcernConstructor"; +import Configuration from "./Configuration"; +import Container from "./Container"; +import DescriptorsRepository from "./DescriptorsRepository"; +import Factory from "./Factory"; +import Injector from "./Injector"; +import RegistrationAware from "./RegistrationAware"; +import Owner from "./Owner"; +import AliasDescriptorFactory from "./AliasDescriptorFactory"; +import UsesConcerns from "./UsesConcerns"; +export { + type Concern, + type ConcernConstructor, + type Configuration, + type Container, + type DescriptorsRepository, + type Factory, + type Injector, + type RegistrationAware, + type Owner, + type AliasDescriptorFactory, + type UsesConcerns +} + +export * from './exceptions/index'; +export * from './types'; \ No newline at end of file diff --git a/packages/contracts/src/support/concerns/types.ts b/packages/contracts/src/support/concerns/types.ts new file mode 100644 index 00000000..a7411cdc --- /dev/null +++ b/packages/contracts/src/support/concerns/types.ts @@ -0,0 +1,27 @@ +import ConcernConstructor from "./ConcernConstructor"; +import Concern from "./Concern"; + +/** + * An alias for a property or method in a {@link Concern} class + */ +export type Alias = PropertyKey; + +/** + * Key-value pair where the key corresponds to a property or method inside the {@link Concern} instance, + * and value is an "alias" for that property or method. + * + * In this context an "alias" means a property or method that is added onto a target + * class' prototype and acts as a proxy to the original property or method inside the + * concern class instance. + */ +export type Aliases = { [key in keyof T]: Alias } | { [key: PropertyKey]: Alias }; + +/** + * Shorthand Concern Injection Configuration + * + * @see [Configuration]{@link import('@aedart/contracts/support/concerns').Configuration} + */ +export type ShorthandConfiguration = + [ ConcernConstructor ] // concern class + | [ ConcernConstructor, Aliases ] // concern class with aliases + | [ ConcernConstructor, boolean ] // concern class with "allowAliases" flag \ No newline at end of file diff --git a/packages/contracts/src/support/exceptions/Throwable.ts b/packages/contracts/src/support/exceptions/Throwable.ts new file mode 100644 index 00000000..d4e4714f --- /dev/null +++ b/packages/contracts/src/support/exceptions/Throwable.ts @@ -0,0 +1,12 @@ +/** + * Throwable + * + * Base interface for a custom {@link Error} that can be thrown. + * + * This interface is inspired by PHP's [`Throwable` interface]{@link https://www.php.net/manual/en/class.throwable.php}. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#custom_error_types + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error + * @see Error + */ +export default interface Throwable extends Error {} \ No newline at end of file diff --git a/packages/contracts/src/support/exceptions/index.ts b/packages/contracts/src/support/exceptions/index.ts new file mode 100644 index 00000000..3c4481b0 --- /dev/null +++ b/packages/contracts/src/support/exceptions/index.ts @@ -0,0 +1,11 @@ +/** + * Exceptions identifier + * + * @type {Symbol} + */ +export const SUPPORT_EXCEPTIONS: unique symbol = Symbol('@aedart/contracts/support/exceptions'); + +import Throwable from "./Throwable"; +export { + type Throwable +} \ No newline at end of file diff --git a/packages/contracts/src/support/mixins/types.ts b/packages/contracts/src/support/mixins/types.ts index fd92fe98..01e2c100 100644 --- a/packages/contracts/src/support/mixins/types.ts +++ b/packages/contracts/src/support/mixins/types.ts @@ -14,6 +14,6 @@ import type { AbstractConstructor } from "@aedart/contracts"; * ``` */ export type MixinFunction< - SuperClass extends AbstractConstructor = object, - AbstractSubclass extends AbstractConstructor = object + SuperClass extends AbstractConstructor = AbstractConstructor, + AbstractSubclass extends AbstractConstructor = AbstractConstructor > = (superclass: SuperClass) => AbstractSubclass & SuperClass; \ No newline at end of file diff --git a/packages/contracts/src/support/objects/Cloneable.ts b/packages/contracts/src/support/objects/Cloneable.ts new file mode 100644 index 00000000..5203cc54 --- /dev/null +++ b/packages/contracts/src/support/objects/Cloneable.ts @@ -0,0 +1,14 @@ +/** + * Cloneable + */ +export default interface Cloneable +{ + /** + * Returns a clone (new instance) of this object + * + * @return {this} + * + * @throws {Error} + */ + clone(): this; +} \ No newline at end of file diff --git a/packages/contracts/src/support/objects/Populatable.ts b/packages/contracts/src/support/objects/Populatable.ts new file mode 100644 index 00000000..60f3624a --- /dev/null +++ b/packages/contracts/src/support/objects/Populatable.ts @@ -0,0 +1,22 @@ +/** + * Populatable + * + * Able to be populated (hydrated) with data + */ +export default interface Populatable +{ + /** + * Populate this component with data + * + * **Note**: _When no `data` is provided, then nothing is populated_ + * + * @param {any} [data] E.g. key-value pair (object), array, or any other kind + * of data. + * + * @throws {TypeError} When unable to populate with given data. Or, if type of data is + * not supported. + */ + populate( + data?: any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + ): this +} \ No newline at end of file diff --git a/packages/contracts/src/support/objects/index.ts b/packages/contracts/src/support/objects/index.ts new file mode 100644 index 00000000..cc2e588e --- /dev/null +++ b/packages/contracts/src/support/objects/index.ts @@ -0,0 +1,28 @@ +/** + * Support Objects identifier + * + * @type {Symbol} + */ +export const SUPPORT_OBJECTS: unique symbol = Symbol('@aedart/contracts/support/objects'); + +/** + * Properties that are considered dangerous and should be avoided when merging + * objects or assigning properties. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf#description + * @see https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html + * @see https://medium.com/@king.amit95/prototype-pollution-a-deeper-inspection-82a226796966 + * + * @type {PropertyKey[]} + */ +export const DANGEROUS_PROPERTIES: PropertyKey[] = [ '__proto__' ]; + +import Cloneable from "./Cloneable"; +import Populatable from "./Populatable"; +export { + type Cloneable, + type Populatable, +} + +export * from './merge'; +export * from './types'; \ No newline at end of file diff --git a/packages/contracts/src/support/objects/merge/MergeException.ts b/packages/contracts/src/support/objects/merge/MergeException.ts new file mode 100644 index 00000000..8bf49a68 --- /dev/null +++ b/packages/contracts/src/support/objects/merge/MergeException.ts @@ -0,0 +1,8 @@ +import type { Throwable } from "@aedart/contracts/support/exceptions"; + +/** + * Merge Exception + * + * To be thrown when two or more objects are unable to be merged. + */ +export default interface MergeException extends Throwable {} \ No newline at end of file diff --git a/packages/contracts/src/support/objects/merge/MergeOptions.ts b/packages/contracts/src/support/objects/merge/MergeOptions.ts new file mode 100644 index 00000000..2d1dd6b4 --- /dev/null +++ b/packages/contracts/src/support/objects/merge/MergeOptions.ts @@ -0,0 +1,140 @@ +import type { + MergeCallback, + SkipKeyCallback +} from "./types"; + +/** + * Merge Options + */ +export default interface MergeOptions +{ + /** + * The maximum merge depth + * + * **Note**: _Value must be greater than or equal zero._ + * + * **Note**: _Defaults to [DEFAULT_MAX_MERGE_DEPTH]{@link import('@aedart/contracts/support/objects').DEFAULT_MAX_MERGE_DEPTH} + * when not specified._ + * + * @type {number} + */ + depth?: number; + + /** + * Property Keys that must not be merged. + * + * **Note**: [DANGEROUS_PROPERTIES]{@link import('@aedart/contracts/support/objects').DANGEROUS_PROPERTIES} + * are always skipped, regardless of specified keys._ + * + * **Callback**: _A callback can be specified to determine if a given key, + * in a source object should be skipped._ + * + * **Example:** + * ```js + * const a = { 'foo': true }; + * const b = { 'bar': true, 'zar': true }; + * + * merge().using({ skip: [ 'zar' ] }).of(a, b); // { 'foo': true, 'bar': true } + * + * merge().using({ skip: (key, source) => { + * return key === 'bar' && Reflect.has(source, key); + * } }).of(a, b); // { 'foo': true, 'zar': true } + * ``` + * + * @type {PropertyKey[] | SkipKeyCallback} + */ + skip?: PropertyKey[] | SkipKeyCallback; + + /** + * Flag, overwrite property values with `undefined`. + * + * **When `true` (_default behaviour_)**: _If an existing property value is not `undefined`, it will be overwritten + * with new value, even if the new value is `undefined`._ + * + * **When `false`**: _If an existing property value is not `undefined`, it will NOT be overwritten + * with new value, if the new value is `undefined`._ + * + * **Example:** + * ```js + * const a = { 'foo': true }; + * const b = { 'foo': undefined }; + * + * merge(a, b); // { 'foo': undefined } + * + * merge().using({ overwriteWithUndefined: false }).of(a, b) // { 'foo': true } + * ``` + * + * @type {boolean} + */ + overwriteWithUndefined?: boolean; + + /** + * Flag, if source object is [`Cloneable`]{@link import('@aedart/contracts/support/objects').Cloneable}, then the + * resulting object from the `clone()` method is used. + * + * **When `true` (_default behaviour_)**: _If source object is cloneable then the resulting object from `clone()` + * method is used. Its properties are then iterated by the merge function._ + * + * **When `false`**: _Cloneable objects are treated like any other objects, the `clone()` method is ignored._ + * + * **Example:** + * ```js + * const a = { 'foo': { 'name': 'John Doe' } }; + * const b = { 'foo': { + * 'name': 'Jane Doe', + * clone() { + * return { + * 'name': 'Rick Doe', + * 'age': 26 + * } + * } + * } }; + * + * merge(a, b); // { 'foo': { 'name': 'Rick Doe', 'age': 26 } } + * + * merge().using({ useCloneable: false }).of(a, b); // { 'foo': { 'name': 'Jane Doe', clone() {...} } } + * ``` + * + * @see [`Cloneable`]{@link import('@aedart/contracts/support/objects').Cloneable} + * + * @type {boolean} + */ + useCloneable?: boolean; + + /** + * Flag, whether to merge array, array-like, and [concat spreadable]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/isConcatSpreadable} + * properties or not. + * + * **When `true`**: _existing property is merged with new property value._ + * + * **When `false` (_default behaviour_)**: _existing property is overwritten with new property value_ + * + * **Example:** + * ```js + * const a = { 'foo': [ 1, 2, 3 ] }; + * const b = { 'foo': [ 4, 5, 6 ] }; + * + * merge(a, b); // { 'foo': [ 4, 5, 6 ] } + * merge().using({ mergeArrays: true }).of(a, b); // { 'foo': [ 1, 2, 3, 4, 5, 6 ] } + * ``` + * + * **Note**: _`String()` (object) and [Typed Arrays]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray} + * are not merged, even though they are considered to be "array-like" (they offer a `length` property). + * You need to manually handle these, via a custom [callback]{@link MergeCallback}, if such value types must be merged._ + * + * @see [merge (array)]{@link import('@aedart/support/arrays').merge} + * + * @type {boolean} + */ + mergeArrays?: boolean; + + /** + * The merge callback that must be applied + * + * **Note**: _When no callback is provided, then the merge function's default + * callback is used._ + * + * @type {MergeCallback} + */ + callback?: MergeCallback; +} \ No newline at end of file diff --git a/packages/contracts/src/support/objects/merge/MergeSourceInfo.ts b/packages/contracts/src/support/objects/merge/MergeSourceInfo.ts new file mode 100644 index 00000000..51521bf8 --- /dev/null +++ b/packages/contracts/src/support/objects/merge/MergeSourceInfo.ts @@ -0,0 +1,48 @@ +/** + * Merge Source Info + * + * Contains information about what key-value must be merged from a target source object, + * along with other meta information. + */ +export default interface MergeSourceInfo +{ + /** + * The resulting object (relative to object depth) + * + * @type {object} + */ + result: object, + + /** + * The target property key in source object to + * + * @type {PropertyKey} + */ + key: PropertyKey, + + /** + * Value of the property in source object + * + * @type {any} + */ + value: any, /* eslint-disable-line @typescript-eslint/no-explicit-any */ + + /** + * The source object that holds the property key and value + * + * @type {object} + */ + source: object, + + /** + * Source object's index (relative to object depth) + * + * @type {number} + */ + sourceIndex: number, + + /** + * The current recursion depth + */ + depth: number, +} \ No newline at end of file diff --git a/packages/contracts/src/support/objects/merge/ObjectsMerger.ts b/packages/contracts/src/support/objects/merge/ObjectsMerger.ts new file mode 100644 index 00000000..51ee9977 --- /dev/null +++ b/packages/contracts/src/support/objects/merge/ObjectsMerger.ts @@ -0,0 +1,188 @@ +import type { MergeCallback } from "./types"; +import type MergeOptions from "./MergeOptions"; + +/** + * Objects Merger + * + * Able to merge (deep merge) multiple source objects into a single new object. + */ +export default interface ObjectsMerger +{ + /** + * Use the following merge options or merge callback + * + * @param {MergeCallback | MergeOptions} [options] Merge callback or merge options. + * + * @return {this} + * + * @throws {MergeException} + */ + using(options?: MergeCallback | MergeOptions): this; + + /** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * + * @param {SourceA} a + * + * @returns {SourceA} + * + * @throws {MergeException} + */ + of< + SourceA extends object + >(a: SourceA): SourceA; + + /** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * + * @param {SourceA} a + * @param {SourceB} b + * + * @returns {SourceA & SourceB} + * + * @throws {MergeException} + */ + of< + SourceA extends object, + SourceB extends object, + >(a: SourceA, b: SourceB): SourceA & SourceB; + + /** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * @template SourceC extends object + * + * @param {SourceA} a + * @param {SourceB} b + * @param {SourceC} c + * + * @returns {SourceA & SourceB & SourceC} + * + * @throws {MergeException} + */ + of< + SourceA extends object, + SourceB extends object, + SourceC extends object, + >(a: SourceA, b: SourceB, c: SourceC): SourceA & SourceB & SourceC; + + /** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * @template SourceC extends object + * @template SourceD extends object + * + * @param {SourceA} a + * @param {SourceB} b + * @param {SourceC} c + * @param {SourceD} d + * + * @returns {SourceA & SourceB & SourceC & SourceD} + * + * @throws {MergeException} + */ + of< + SourceA extends object, + SourceB extends object, + SourceC extends object, + SourceD extends object, + >(a: SourceA, b: SourceB, c: SourceC, d: SourceD): SourceA & SourceB & SourceC & SourceD; + + /** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * @template SourceC extends object + * @template SourceD extends object + * @template SourceE extends object + * + * @param {SourceA} a + * @param {SourceB} b + * @param {SourceC} c + * @param {SourceD} d + * @param {SourceE} e + * + * @returns {SourceA & SourceB & SourceC & SourceD & SourceE} + * + * @throws {MergeException} + */ + of< + SourceA extends object, + SourceB extends object, + SourceC extends object, + SourceD extends object, + SourceE extends object, + >(a: SourceA, b: SourceB, c: SourceC, d: SourceD, e: SourceE): SourceA & SourceB & SourceC & SourceD & SourceE; + + /** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * @template SourceC extends object + * @template SourceD extends object + * @template SourceE extends object + * @template SourceF extends object + * + * @param {SourceA} a + * @param {SourceB} b + * @param {SourceC} c + * @param {SourceD} d + * @param {SourceE} e + * @param {SourceF} f + * + * @returns {SourceA & SourceB & SourceC & SourceD & SourceE & SourceF} + * + * @throws {MergeException} + */ + of< + SourceA extends object, + SourceB extends object, + SourceC extends object, + SourceD extends object, + SourceE extends object, + SourceF extends object, + >(a: SourceA, b: SourceB, c: SourceC, d: SourceD, e: SourceE, f: SourceF): SourceA & SourceB & SourceC & SourceD & SourceE & SourceF; + + /** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @param {object[]} sources + * + * @returns {object} + * + * @throws {MergeException} + */ + of(...sources: object[]): object; +} \ No newline at end of file diff --git a/packages/contracts/src/support/objects/merge/index.ts b/packages/contracts/src/support/objects/merge/index.ts new file mode 100644 index 00000000..66ac718c --- /dev/null +++ b/packages/contracts/src/support/objects/merge/index.ts @@ -0,0 +1,19 @@ +/** + * Default maximum merge depth + * + * @type {number} + */ +export const DEFAULT_MAX_MERGE_DEPTH: number = 512; + +import MergeException from "./MergeException"; +import MergeSourceInfo from "./MergeSourceInfo"; +import MergeOptions from "./MergeOptions"; +import ObjectsMerger from "./ObjectsMerger"; +export { + type MergeException, + type MergeOptions, + type MergeSourceInfo, + type ObjectsMerger +} + +export * from './types'; \ No newline at end of file diff --git a/packages/contracts/src/support/objects/merge/types.ts b/packages/contracts/src/support/objects/merge/types.ts new file mode 100644 index 00000000..462fddb3 --- /dev/null +++ b/packages/contracts/src/support/objects/merge/types.ts @@ -0,0 +1,75 @@ +import type MergeOptions from './MergeOptions'; +import type MergeSourceInfo from './MergeSourceInfo'; + +/** + * Callback to perform the merging of nested objects. + * Invoking this callback results in the merge callback to + * be repeated, for the given source objects. + * + * @type {function} + */ +export type NextCallback = ( + + /** + * The nested objects to be merged + * + * @type {object[]} + */ + sources: object[], + + /** + * The merge options to be applied + * + * @type {Readonly} + */ + options: Readonly, + + /** + * The next recursion depth number + * + * @type {number} + */ + nextDepth: number +) => any; /* eslint-disable-line @typescript-eslint/no-explicit-any */ + +/** + * Merge callback function + * + * The callback is responsible for resolving the value to be merged into the resulting object. + * + * **Note**: _[Skipped keys]{@link MergeOptions.skip} are NOT provided to the callback._ + * + * **Note**: _The callback is responsible for respecting the given [options]{@link MergeOptions}, + * ([keys to be skipped]{@link MergeOptions.skip} and [depth]{@link MergeOptions.depth} excluded)_ + */ +export type MergeCallback = ( + + /** + * Source target information + * + * @type {MergeSourceInfo} + */ + target: MergeSourceInfo, + + /** + * Callback to invoke for merging nested objects + * + * @type {function} + */ + next: NextCallback, + + /** + * The merge options to be applied + */ + options: Readonly +) => any; /* eslint-disable-line @typescript-eslint/no-explicit-any */ + +/** + * A callback that determines if property key should be skipped + * + * If the callback returns `true`, then the given key will NOT be merged + * from the given source object. + * + * @see {MergeOptions} + */ +export type SkipKeyCallback = (key: PropertyKey, source: object, result: object) => boolean; \ No newline at end of file diff --git a/packages/contracts/src/support/objects/types.ts b/packages/contracts/src/support/objects/types.ts new file mode 100644 index 00000000..cf97eac1 --- /dev/null +++ b/packages/contracts/src/support/objects/types.ts @@ -0,0 +1,9 @@ +/** + * Callback that returns properties to be selected from the source object. + * The target is the object intended to have source properties merged or assigned + * into. + */ +export type SourceKeysCallback< + SourceObj extends object = object, + TargetObj extends object = object +> = (source: SourceObj, target: TargetObj) => PropertyKey|PropertyKey[]; \ No newline at end of file diff --git a/packages/contracts/src/support/reflections/ClassBlueprint.ts b/packages/contracts/src/support/reflections/ClassBlueprint.ts new file mode 100644 index 00000000..a63b565d --- /dev/null +++ b/packages/contracts/src/support/reflections/ClassBlueprint.ts @@ -0,0 +1,19 @@ +/** + * Class Blueprint + */ +export default interface ClassBlueprint +{ + /** + * Properties or methods expected to exist in class as static members. + * + * @type {PropertyKey[]} + */ + staticMembers?: PropertyKey[]; + + /** + * Properties or methods expected to exist in class' prototype + * + * @type {PropertyKey[]} + */ + members?: PropertyKey[]; +} \ No newline at end of file diff --git a/packages/contracts/src/support/reflections/index.ts b/packages/contracts/src/support/reflections/index.ts index a6fdf909..e91fe56c 100644 --- a/packages/contracts/src/support/reflections/index.ts +++ b/packages/contracts/src/support/reflections/index.ts @@ -4,3 +4,30 @@ * @type {Symbol} */ export const SUPPORT_REFLECTIONS: unique symbol = Symbol('@aedart/contracts/support/reflections'); + +/** + * The prototype of {@link Function} + * + * **Note**: _Prototype is obtained via `Reflect.getPrototypeOf(Function)`_ + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/prototype + * + * @type {object} + */ +export const FUNCTION_PROTOTYPE: object = Reflect.getPrototypeOf(Function) as object; + +/** + * `TypedArray` prototype + * + * **Note**: _Prototype is obtained via `Reflect.getPrototypeOf(Int8Array)`_ + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray + * + * @type {object} + */ +export const TYPED_ARRAY_PROTOTYPE: object = Reflect.getPrototypeOf(Int8Array) as object; + +import ClassBlueprint from "./ClassBlueprint"; +export { + type ClassBlueprint +} diff --git a/packages/support/package.json b/packages/support/package.json index 00923bff..f48dcf45 100644 --- a/packages/support/package.json +++ b/packages/support/package.json @@ -28,6 +28,21 @@ "import": "./dist/esm/support.js", "require": "./dist/cjs/support.cjs" }, + "./arrays": { + "types": "./dist/types/arrays.d.ts", + "import": "./dist/esm/arrays.js", + "require": "./dist/cjs/arrays.cjs" + }, + "./concerns": { + "types": "./dist/types/concerns.d.ts", + "import": "./dist/esm/concerns.js", + "require": "./dist/cjs/concerns.cjs" + }, + "./exceptions": { + "types": "./dist/types/exceptions.d.ts", + "import": "./dist/esm/exceptions.js", + "require": "./dist/cjs/exceptions.cjs" + }, "./meta": { "types": "./dist/types/meta.d.ts", "import": "./dist/esm/meta.js", diff --git a/packages/support/rollup.config.mjs b/packages/support/rollup.config.mjs index a584e216..299cbe5b 100644 --- a/packages/support/rollup.config.mjs +++ b/packages/support/rollup.config.mjs @@ -3,9 +3,14 @@ import { createConfig } from '../../shared/rollup.config.mjs'; export default createConfig({ baseDir: new URL('.', import.meta.url), external: [ + '@aedart/contracts', '@aedart/contracts/support', + '@aedart/contracts/support/arrays', + '@aedart/contracts/support/concerns', + '@aedart/contracts/support/exceptions', '@aedart/contracts/support/meta', '@aedart/contracts/support/mixins', + '@aedart/contracts/support/objects', '@aedart/contracts/support/reflections', ] }); diff --git a/packages/support/src/arrays/exceptions/ArrayMergeError.ts b/packages/support/src/arrays/exceptions/ArrayMergeError.ts new file mode 100644 index 00000000..30bb962c --- /dev/null +++ b/packages/support/src/arrays/exceptions/ArrayMergeError.ts @@ -0,0 +1,23 @@ +import type { Throwable } from "@aedart/contracts/support/exceptions"; +import { configureCustomError } from "@aedart/support/exceptions"; + +/** + * Array Merge Error + * + * To be thrown when two or more arrays are unable to be merged. + */ +export default class ArrayMergeError extends Error implements Throwable +{ + /** + * Create a new Array Merge Error instance + * + * @param {string} [message] + * @param {ErrorOptions} [options] + */ + constructor(message?: string, options?: ErrorOptions) + { + super(message, options); + + configureCustomError(this); + } +} \ No newline at end of file diff --git a/packages/support/src/arrays/exceptions/index.ts b/packages/support/src/arrays/exceptions/index.ts new file mode 100644 index 00000000..f41270c5 --- /dev/null +++ b/packages/support/src/arrays/exceptions/index.ts @@ -0,0 +1,4 @@ +import ArrayMergeError from "./ArrayMergeError"; +export { + ArrayMergeError +} \ No newline at end of file diff --git a/packages/support/src/arrays/includesAll.ts b/packages/support/src/arrays/includesAll.ts new file mode 100644 index 00000000..e052e60b --- /dev/null +++ b/packages/support/src/arrays/includesAll.ts @@ -0,0 +1,15 @@ +/** + * Determine if array includes all given values + * + * @param {any[]} arr + * @param {any[]} values + * + * @return {boolean} + */ +export function includesAll( + arr: any[], /* eslint-disable-line @typescript-eslint/no-explicit-any */ + values: any[] /* eslint-disable-line @typescript-eslint/no-explicit-any */ +): boolean +{ + return values.every((value) => arr.includes(value)); +} \ No newline at end of file diff --git a/packages/support/src/arrays/includesAny.ts b/packages/support/src/arrays/includesAny.ts new file mode 100644 index 00000000..7fa46a25 --- /dev/null +++ b/packages/support/src/arrays/includesAny.ts @@ -0,0 +1,15 @@ +/** + * Determine if array includes any (_some_) of the given values + * + * @param {any[]} arr + * @param {any[]} values + * + * @return {boolean} + */ +export function includesAny( + arr: any[], /* eslint-disable-line @typescript-eslint/no-explicit-any */ + values: any[] /* eslint-disable-line @typescript-eslint/no-explicit-any */ +): boolean +{ + return values.some((value) => arr.includes(value)); +} \ No newline at end of file diff --git a/packages/support/src/arrays/index.ts b/packages/support/src/arrays/index.ts new file mode 100644 index 00000000..5f3aed30 --- /dev/null +++ b/packages/support/src/arrays/index.ts @@ -0,0 +1,9 @@ +export * from './includesAll'; +export * from './includesAny'; +export * from './isArrayLike'; +export * from './isConcatSpreadable'; +export * from './isSafeArrayLike'; +export * from './isTypedArray'; +export * from './merge'; + +export * from './exceptions'; \ No newline at end of file diff --git a/packages/support/src/arrays/isArrayLike.ts b/packages/support/src/arrays/isArrayLike.ts new file mode 100644 index 00000000..5ec95296 --- /dev/null +++ b/packages/support/src/arrays/isArrayLike.ts @@ -0,0 +1,14 @@ +import { isArrayLike as _isArrayLike } from "lodash-es"; + +/** + * Determine if value is "array-like". + * (Alias for Lodash's [`isArrayLike`]{@link import('lodash').isArrayLike}) method. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#array-like_objects + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Indexed_collections#working_with_array-like_objects + * + * @param {any} value + * + * @return {boolean} + */ +export const isArrayLike = _isArrayLike; \ No newline at end of file diff --git a/packages/support/src/arrays/isConcatSpreadable.ts b/packages/support/src/arrays/isConcatSpreadable.ts new file mode 100644 index 00000000..4e6824cd --- /dev/null +++ b/packages/support/src/arrays/isConcatSpreadable.ts @@ -0,0 +1,15 @@ +/** + * Determine if target object contains the well-known symbol {@link Symbol.isConcatSpreadable} + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/isConcatSpreadable + * + * @param {object} target + * + * @return {boolean} + */ +export function isConcatSpreadable(target: object): boolean +{ + return typeof target == 'object' + && target !== null + && Reflect.has(target, Symbol.isConcatSpreadable); +} \ No newline at end of file diff --git a/packages/support/src/arrays/isSafeArrayLike.ts b/packages/support/src/arrays/isSafeArrayLike.ts new file mode 100644 index 00000000..92a0d1c3 --- /dev/null +++ b/packages/support/src/arrays/isSafeArrayLike.ts @@ -0,0 +1,25 @@ +import { isArrayLike } from './isArrayLike'; +import { isTypedArray } from './isTypedArray'; + +/** + * Determine if value is "safe" array-like object + * + * **Note**: _In this context "safe" means that given object passes {@link isArrayLike}, + * but value is:_ + * - not a string. + * - not instance of a {@link String} object. + * - not a [Typed Array]{@link isTypedArray} object. + * + * @param {object} value + * + * @return {boolean} + */ +export function isSafeArrayLike( + value: any /* eslint-disable-line @typescript-eslint/no-explicit-any */ +): boolean +{ + return typeof value != 'string' + && !(value instanceof String) + && !isTypedArray(value) + && isArrayLike(value); +} \ No newline at end of file diff --git a/packages/support/src/arrays/isTypedArray.ts b/packages/support/src/arrays/isTypedArray.ts new file mode 100644 index 00000000..19a721f8 --- /dev/null +++ b/packages/support/src/arrays/isTypedArray.ts @@ -0,0 +1,16 @@ +import { TYPED_ARRAY_PROTOTYPE } from "@aedart/contracts/support/reflections"; +import { Constructor } from "@aedart/contracts"; + +/** + * Determine if given target is an instance of a `TypedArray` + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray + * + * @param {object} target + * + * @return {boolean} + */ +export function isTypedArray(target: object): boolean +{ + return target instanceof (TYPED_ARRAY_PROTOTYPE as Constructor); +} \ No newline at end of file diff --git a/packages/support/src/arrays/merge.ts b/packages/support/src/arrays/merge.ts new file mode 100644 index 00000000..d0dea0ab --- /dev/null +++ b/packages/support/src/arrays/merge.ts @@ -0,0 +1,38 @@ +import { ArrayMergeError } from "./exceptions"; +import { getErrorMessage } from "@aedart/support/exceptions"; + +/** + * Merge two or more arrays + * + * **Note**: _Method attempts to deep copy array values, via [structuredClone]{@link https://developer.mozilla.org/en-US/docs/Web/API/structuredClone}_ + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/structuredClone + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/isConcatSpreadable + * + * @param {...any[]} sources + * + * @return {any[]} + * + * @throws {ArrayMergeError} If unable to merge arrays, e.g. if a value cannot be cloned via `structuredClone()` + */ +export function merge( + ...sources: any[] /* eslint-disable-line @typescript-eslint/no-explicit-any */ +): any[] /* eslint-disable-line @typescript-eslint/no-explicit-any */ +{ + try { + // Array.concat only performs shallow copies of the array values, which might + // fine in some situations. However, this version must ensure to perform a + // deep copy of the values... + + return structuredClone([].concat(...sources)); + } catch (e) { + const reason = getErrorMessage(e); + + throw new ArrayMergeError('Unable to merge arrays: ' + reason, { + cause: { + previous: e, + sources: sources + } + }); + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/AbstractConcern.ts b/packages/support/src/concerns/AbstractConcern.ts new file mode 100644 index 00000000..aa055489 --- /dev/null +++ b/packages/support/src/concerns/AbstractConcern.ts @@ -0,0 +1,133 @@ +import type { Concern, UsesConcerns } from "@aedart/contracts/support/concerns"; +import { PROVIDES, BEFORE, AFTER } from "@aedart/contracts/support/concerns"; +import { AbstractClassError } from "@aedart/support/exceptions"; +import { classOwnKeys } from "@aedart/support/reflections"; + +/** + * 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 + */ +export default abstract class AbstractConcern implements Concern +{ + /** + * The owner class instance this concern is injected into, + * or `this` concern instance. + * + * @readonly + * @private + * + * @type {object} + */ + readonly #concernOwner: object; + + /** + * 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. + */ + public constructor(owner?: object) + { + 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} + */ + public get concernOwner(): object + { + 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[]} + */ + public static [PROVIDES](): PropertyKey[] + { + // 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: UsesConcerns /* eslint-disable-line @typescript-eslint/no-unused-vars */ + ): void + { + // 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: UsesConcerns /* eslint-disable-line @typescript-eslint/no-unused-vars */ + ): void + { + // 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 + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/ConcernClassBlueprint.ts b/packages/support/src/concerns/ConcernClassBlueprint.ts new file mode 100644 index 00000000..603aff95 --- /dev/null +++ b/packages/support/src/concerns/ConcernClassBlueprint.ts @@ -0,0 +1,21 @@ +import type { ClassBlueprint } from "@aedart/contracts/support/reflections"; +import { PROVIDES } from "@aedart/contracts/support/concerns"; + +/** + * 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 + */ +export const ConcernClassBlueprint: ClassBlueprint = { + staticMembers: [ + 'constructor', + PROVIDES + ], + + members: [ + 'concernOwner' + ] +}; \ No newline at end of file diff --git a/packages/support/src/concerns/ConcernsContainer.ts b/packages/support/src/concerns/ConcernsContainer.ts new file mode 100644 index 00000000..e263ffe3 --- /dev/null +++ b/packages/support/src/concerns/ConcernsContainer.ts @@ -0,0 +1,275 @@ +import type { + Container, + Concern, + ConcernConstructor, + Owner +} from "@aedart/contracts/support/concerns"; +import { getNameOrDesc } from "@aedart/support/reflections"; +import { getErrorMessage } from "@aedart/support/exceptions"; +import BootError from "./exceptions/BootError"; +import NotRegisteredError from "./exceptions/NotRegisteredError"; + +/** + * Concerns Container + * + * @see Container + */ +export default class ConcernsContainer implements Container +{ + /** + * Map of concern class constructors and actual concern instances + * + * @protected + * @readonly + * + * @type {Map} + */ + protected readonly map: Map; + + /** + * The concerns owner of this container + * + * @private + * @readonly + * + * @type {Owner} + */ + readonly #owner: Owner; + + /** + * Create a new Concerns Container instance + * + * @param {Owner} owner + * @param {ConcernConstructor[]} concerns + */ + public constructor(owner: Owner, concerns: ConcernConstructor[]) { + 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} + */ + public get size(): number + { + return this.map.size; + } + + /** + * Get the concerns container owner + * + * @readonly + * + * @type {Owner} + */ + public get owner(): Owner + { + return this.#owner; + } + + /** + * Determine if concern class is registered in this container + * + * @param {ConcernConstructor} concern + * + * @return {boolean} + */ + public has(concern: ConcernConstructor): boolean + { + 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} concern + * + * @return {T} The booted instance of the concern class. If concern class was + * previously booted, then that instance is returned. + * + * @throws {ConcernError} + */ + public get(concern: ConcernConstructor): T + { + if (!this.hasBooted(concern)) { + return this.boot(concern); + } + + return this.map.get(concern) as T; + } + + /** + * Determine if concern class has been booted + * + * @param {ConcernConstructor} concern + * + * @return {boolean} + */ + public hasBooted(concern: ConcernConstructor): boolean + { + return this.has(concern) && this.map.get(concern) !== undefined; + } + + /** + * Boot concern class + * + * @template T extends {@link Concern} + * + * @param {ConcernConstructor} 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 + */ + public boot(concern: ConcernConstructor): T + { + // 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: T | undefined = this.map.get(concern) as T | undefined; + 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} + */ + public bootAll(): void + { + const concerns = this.all(); + for (const concern of concerns) { + this.boot(concern); + } + } + + /** + * Determine if this container is empty + * + * @return {boolean} + */ + public isEmpty(): boolean + { + return this.size === 0; + } + + /** + * Opposite of {@link isEmpty} + * + * @return {boolean} + */ + public isNotEmpty(): boolean + { + return !this.isEmpty(); + } + + /** + * Returns all concern classes + * + * @return {IterableIterator} + */ + public all(): IterableIterator + { + 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} + */ + public call( + concern: ConcernConstructor, + method: PropertyKey, + ...args: any[] /* eslint-disable-line @typescript-eslint/no-explicit-any */ + ): any /* 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} + */ + public setProperty( + concern: ConcernConstructor, + property: PropertyKey, + value: any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + ): void + { + // @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} + */ + public getProperty( + concern: ConcernConstructor, + property: PropertyKey + ): any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + { + // @ts-expect-error Can fail when dynamically setting property in concern instance... + return this.get(concern)[property]; + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/ConcernsInjector.ts b/packages/support/src/concerns/ConcernsInjector.ts new file mode 100644 index 00000000..6d72ca7b --- /dev/null +++ b/packages/support/src/concerns/ConcernsInjector.ts @@ -0,0 +1,666 @@ +import type { + ConcernConstructor, + Injector, + UsesConcerns, + Configuration, + ShorthandConfiguration, + Owner, + Container, + DescriptorsRepository, + Factory, + Alias, + Aliases, + AliasDescriptorFactory, + RegistrationAware +} from "@aedart/contracts/support/concerns"; +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { + CONCERN_CLASSES, + ALIASES, + CONCERNS, + BEFORE, + AFTER +} from "@aedart/contracts/support/concerns"; +import { + getAllParentsOfClass, + getNameOrDesc +} from "@aedart/support/reflections"; +import { getErrorMessage } from "@aedart/support/exceptions"; +import AliasConflictError from './exceptions/AliasConflictError'; +import AlreadyRegisteredError from './exceptions/AlreadyRegisteredError'; +import InjectionError from './exceptions/InjectionError'; +import UnsafeAliasError from './exceptions/UnsafeAliasError'; +import ConcernsContainer from './ConcernsContainer'; +import ConfigurationFactory from "./ConfigurationFactory"; +import Repository from "./Repository"; +import DescriptorFactory from "./DescriptorFactory"; +import { isUnsafeKey } from "./isUnsafeKey"; + +/** + * A map of the concern owner instances and their concerns container + * + * @internal + * + * @type {WeakMap} + */ +const CONTAINERS_REGISTRY: WeakMap = new WeakMap(); + +/** + * Concerns Injector + * + * @see Injector + */ +export default class ConcernsInjector implements Injector +{ + /** + * The target class + * + * @template T = object + * @type {T} + * + * @private + */ + readonly #target: T; + + /** + * Concern Configuration Factory + * + * @type {Factory} + * + * @protected + */ + protected configFactory: Factory; + + /** + * Descriptors Repository + * + * @type {DescriptorsRepository} + * + * @protected + */ + protected repository: DescriptorsRepository; + + /** + * Alias Descriptor Factory + * + * @type {AliasDescriptorFactory} + * + * @protected + */ + protected descriptorFactory: AliasDescriptorFactory; + + /** + * 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] + */ + public constructor( + target: T, + configFactory?: Factory, + descriptorFactory?: AliasDescriptorFactory, + repository?: DescriptorsRepository + ) + { + 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} + */ + public get target(): T + { + 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} The modified target class + * + * @throws {InjectionException} + */ + inject(...concerns: (ConcernConstructor|Configuration|ShorthandConfiguration)[]): UsesConcerns + { + const configurations: Configuration[] = this.normalise(concerns); + const concernClasses: ConcernConstructor[] = configurations.map((configuration) => configuration.concern); + + // Define the concerns classes in target. + let modifiedTarget: UsesConcerns = this.defineConcerns(this.target, concernClasses); + + // Run before registration hook + this.callBeforeRegistration(this.target as UsesConcerns, modifiedTarget[CONCERN_CLASSES]); + + // Define concerns, container and aliases + modifiedTarget = this.defineAliases( + this.defineContainer( + modifiedTarget + ), + configurations + ); + + // Run after registration hook + this.callAfterRegistration(modifiedTarget as UsesConcerns, 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[]} concerns List of concern classes + * + * @returns {UsesConcerns} The modified target class + * + * @throws {AlreadyRegisteredError} + * @throws {InjectionError} + */ + public defineConcerns(target: T, concerns: ConcernConstructor[]): UsesConcerns + { + const registry = this.resolveConcernsRegistry(target as object, concerns); + + return this.definePropertyInTarget(target, CONCERN_CLASSES, { + get: function() { + return registry; + } + }) as UsesConcerns; + } + + /** + * 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} target The target in which a concerns container must be defined + * + * @returns {UsesConcerns} The modified target class + * + * @throws {InjectionError} If unable to define concerns container in target class + */ + public defineContainer(target: UsesConcerns): UsesConcerns + { + const concerns: ConcernConstructor[] = 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: T & Owner = 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} target The target in which "aliases" must be defined in + * @param {Configuration[]} configurations List of concern injection configurations + * + * @returns {UsesConcerns} The modified target class + * + * @throws {AliasConflictError} If case of alias naming conflicts. + * @throws {InjectionError} If unable to define aliases in target class. + */ + defineAliases(target: UsesConcerns, configurations: Configuration[]): UsesConcerns + { + const applied: Alias[] = []; + + // Obtain previous applied aliases, form the target's parents. + const appliedByParents: Map = this.getAllAppliedAliases(target as UsesConcerns); + + 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: Alias[] = this.processAliases( + target as UsesConcerns, + 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} 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. + */ + public defineAlias( + target: UsesConcerns, + alias: PropertyKey, + key: PropertyKey, + source: ConcernConstructor + ): boolean + { + // 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} + */ + public normalise(concerns: (ConcernConstructor|Configuration|ShorthandConfiguration)[]): Configuration[] + { + const output: Configuration[] = []; + + 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 + */ + protected resolveConcernsRegistry(target: object, concerns: ConcernConstructor[]): ConcernConstructor[] + { + // Obtain evt. previous defined concern classes in target. + const alreadyRegistered: ConcernConstructor[] = (Reflect.has(target as object, CONCERN_CLASSES)) + ? target[CONCERN_CLASSES as keyof typeof target] as ConcernConstructor[] + : []; + + // Make a registry of concern classes to be registered in given target + const registry: ConcernConstructor[] = [ ...alreadyRegistered ]; + for (const concern of concerns) { + + // Fail if concern is already registered + if (registry.includes(concern)) { + const source = this.findSourceOf(concern, target as object, true); + throw new AlreadyRegisteredError(target as ConstructorOrAbstractConstructor, concern, source as ConstructorOrAbstractConstructor); + } + + registry.push(concern); + } + + return registry; + } + + /** + * Normalises the given entry into a concern configuration + * + * @param {ConcernConstructor | Configuration | ShorthandConfiguration} entry + * + * @returns {Configuration} + * + * @throws {InjectionError} + * + * @protected + */ + protected normaliseEntry(entry: ConcernConstructor|Configuration|ShorthandConfiguration): Configuration + { + return this.configFactory.make(this.target as object, 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 + */ + protected definePropertyInTarget( + target: T, + property: PropertyKey, + descriptor: PropertyDescriptor, + failMessage?: string + ): T + { + const wasDefined: boolean = Reflect.defineProperty((target as object), property, descriptor); + + if (!wasDefined) { + const reason: string = failMessage || `Unable to define "${property.toString()}" property in target ${getNameOrDesc(target as ConstructorOrAbstractConstructor)}`; + throw new InjectionError(target as ConstructorOrAbstractConstructor, 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 + */ + protected findSourceOf(concern: ConcernConstructor, target: object, includeTarget: boolean = false): object | null + { + const parents = getAllParentsOfClass(target as ConstructorOrAbstractConstructor, includeTarget).reverse(); + + for (const parent of parents) { + if (Reflect.has(parent, CONCERN_CLASSES) && (parent[CONCERN_CLASSES as keyof typeof parent] as ConcernConstructor[]).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} + * + * @protected + */ + protected getAllAppliedAliases(target: UsesConcerns, includeTarget: boolean = false): Map + { + const output: Map = new Map(); + + const parents = getAllParentsOfClass(target as ConstructorOrAbstractConstructor, includeTarget).reverse(); + for (const parent of parents) { + if (!Reflect.has(parent, ALIASES)) { + continue; + } + + (parent as UsesConcerns)[ALIASES].forEach((alias) => { + output.set(alias, (parent as UsesConcerns)); + }); + } + + return output; + } + + /** + * Processes given configuration's aliases by defining them + * + * @param {UsesConcerns} target + * @param {Configuration} configuration + * @param {Alias[]} applied + * @param {Map} appliedByParents + * + * @return {Alias[]} New applied aliases (does not include aliases from `applied` argument) + * + * @protected + * + * @throws {AliasConflictError} + */ + protected processAliases( + target: UsesConcerns, + configuration: Configuration, + applied: Alias[], + appliedByParents: Map + ): Alias[] + { + // Aliases that have been applied by this method... + const output: Alias[] = []; + + // Already applied aliases in target + aliases applied by this method + const alreadyApplied: Alias[] = [...applied]; + + const aliases: Aliases = configuration.aliases as Aliases; + const properties: PropertyKey[] = Reflect.ownKeys(aliases); + + for (const key of properties) { + const alias: Alias = aliases[key as keyof typeof aliases] as Alias; + + // Ensure that alias does not conflict with previous applied aliases. + this.assertAliasDoesNotConflict( + target as UsesConcerns, + 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} appliedByParents Aliases that are applied in target's parents + * + * @protected + * + * @throws {AliasConflictError} + */ + protected assertAliasDoesNotConflict( + target: UsesConcerns, + concern: ConcernConstructor, + alias: Alias, + key: PropertyKey, + applied: Alias[], + appliedByParents: Map + ): void + { + const isAppliedByTarget: boolean = applied.includes(alias); + const isAppliedByParents: boolean = appliedByParents.has(alias); + + if (isAppliedByTarget || isAppliedByParents) { + const source: UsesConcerns = (isAppliedByTarget) + ? target as UsesConcerns + : appliedByParents.get(alias) as UsesConcerns; + + throw new AliasConflictError(target, concern, alias, key, source); + } + } + + /** + * Determine if key is "unsafe" + * + * @param {PropertyKey} key + * + * @returns {boolean} + * + * @protected + */ + protected isUnsafe(key: PropertyKey): boolean + { + return isUnsafeKey(key); + } + + /** + * Invokes the {@link BEFORE} hook method in concern classes + * + * @param {UsesConcerns} target + * @param {ConcernConstructor[]} concerns All concern classes in target's prototype chain + * + * @protected + * + * @throws {InjectionError} + */ + protected callBeforeRegistration(target: UsesConcerns, concerns: ConcernConstructor[]): void + { + this.callRegistrationHook(target, concerns, BEFORE, 'Before'); + } + + /** + * Invokes the {@link AFTER} hook method in concern classes + * + * @param {UsesConcerns} target + * @param {ConcernConstructor[]} concerns All concern classes in target's prototype chain + * + * @protected + * + * @throws {InjectionError} + */ + protected callAfterRegistration(target: UsesConcerns, concerns: ConcernConstructor[]): void + { + this.callRegistrationHook(target, concerns, AFTER, 'After'); + } + + /** + * Invokes the registration hook in given concern classes + * + * @param {UsesConcerns} target + * @param {ConcernConstructor} concerns + * @param {symbol} hook + * @param {string} name + * + * @protected + * + * @throws {InjectionError} + */ + protected callRegistrationHook( + target: UsesConcerns, + concerns: ConcernConstructor[], + hook: symbol, + name: string + ): void + { + for (const concern of concerns) { + if (!Reflect.has(concern, hook)) { + continue; + } + + try { + (concern as ConcernConstructor & RegistrationAware)[hook as keyof RegistrationAware](target); + } catch (e) { + const reason = getErrorMessage(e); + throw new InjectionError(target, concern, `${name} registration failure: ${reason}`, { cause: { previous: e }}); + } + } + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/ConfigurationFactory.ts b/packages/support/src/concerns/ConfigurationFactory.ts new file mode 100644 index 00000000..0d8746b0 --- /dev/null +++ b/packages/support/src/concerns/ConfigurationFactory.ts @@ -0,0 +1,182 @@ +import { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import type { + Aliases, + ConcernConstructor, + Configuration, + ShorthandConfiguration, + Factory +} from "@aedart/contracts/support/concerns"; +import { PROVIDES } from "@aedart/contracts/support/concerns"; +import { getNameOrDesc } from "@aedart/support/reflections"; +import { merge } from "@aedart/support/objects"; +import InjectionError from "./exceptions/InjectionError"; +import { isConcernConfiguration } from "./isConcernConfiguration"; +import { isShorthandConfiguration } from "./isShorthandConfiguration"; +import { isConcernConstructor } from "./isConcernConstructor"; +import { isUnsafeKey } from "./isUnsafeKey"; + +/** + * Concern Configuration Factory + * + * @see Factory + */ +export default class ConfigurationFactory implements Factory +{ + /** + * 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: object, entry: ConcernConstructor | Configuration | ShorthandConfiguration): Configuration { + // A) Make new configuration when concern class is given + if (isConcernConstructor(entry)) { + return this.makeConfiguration(entry as ConcernConstructor); + } + + if (isShorthandConfiguration(entry)) { + entry = this.makeFromShorthand(entry as ShorthandConfiguration); + } + + // 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: Configuration = merge() + .using({ overwriteWithUndefined: false }) + .of( + this.makeConfiguration((entry as Configuration).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: string = `${getNameOrDesc(entry as ConstructorOrAbstractConstructor)} must be a valid Concern class or Concern Configuration` + throw new InjectionError(target as ConstructorOrAbstractConstructor, null, reason, { cause: { entry: entry } }); + } + + /** + * Casts the shorthand configuration to a configuration object + * + * @param {ShorthandConfiguration} config + * + * @return {Configuration} + * + * @protected + */ + protected makeFromShorthand(config: ShorthandConfiguration): Configuration + { + 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 + */ + protected makeConfiguration(concern: ConcernConstructor): Configuration + { + 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 + */ + protected makeDefaultAliases(concern: ConcernConstructor): Aliases + { + const provides: PropertyKey[] = concern[PROVIDES]() + .filter((key: PropertyKey) => !this.isUnsafe(key)); + + const aliases: 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 + */ + protected removeUnsafeKeys(configuration: Configuration): Configuration + { + const keys: PropertyKey[] = Reflect.ownKeys(configuration.aliases as Aliases); + for (const key of keys) { + if (this.isUnsafe(key)) { + delete (configuration.aliases as Aliases)[key as keyof typeof configuration.aliases]; + } + } + + return configuration; + } + + /** + * Determine if key is "unsafe" + * + * @param {PropertyKey} key + * + * @returns {boolean} + * + * @protected + */ + protected isUnsafe(key: PropertyKey): boolean + { + return isUnsafeKey(key); + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/DescriptorFactory.ts b/packages/support/src/concerns/DescriptorFactory.ts new file mode 100644 index 00000000..7b375bf0 --- /dev/null +++ b/packages/support/src/concerns/DescriptorFactory.ts @@ -0,0 +1,126 @@ +import type { + AliasDescriptorFactory, + ConcernConstructor, + Owner +} from "@aedart/contracts/support/concerns"; +import { CONCERNS } from "@aedart/contracts/support/concerns"; + +/** + * Alias Descriptor Factory + * + * @see AliasDescriptorFactory + */ +export default class DescriptorFactory implements AliasDescriptorFactory +{ + /** + * 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: PropertyKey, source: ConcernConstructor, keyDescriptor: PropertyDescriptor): PropertyDescriptor + { + const proxy: PropertyDescriptor = 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 + */ + protected makeMethodProxy(method: PropertyKey, concern: ConcernConstructor) + { + return function( + ...args: any[] /* eslint-disable-line @typescript-eslint/no-explicit-any */ + ): any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + { + // @ts-expect-error This = concern instance + return (this as Owner)[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 + */ + protected makeGetPropertyProxy(property: PropertyKey, concern: ConcernConstructor) + { + return function(): any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + { + // @ts-expect-error This = concern instance + return (this as Owner)[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 + */ + protected makeSetPropertyProxy(property: PropertyKey, concern: ConcernConstructor) + { + return function( + value: any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + ): void + { + // @ts-expect-error This = concern instance + (this as Owner)[CONCERNS].setProperty(concern, property, value); + } + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/Repository.ts b/packages/support/src/concerns/Repository.ts new file mode 100644 index 00000000..4a4626c8 --- /dev/null +++ b/packages/support/src/concerns/Repository.ts @@ -0,0 +1,118 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import type { ConcernConstructor, UsesConcerns, DescriptorsRepository } from "@aedart/contracts/support/concerns"; +import { getClassPropertyDescriptors } from "@aedart/support/reflections"; + +/** + * Repository + * + * @see DescriptorsRepository + */ +export default class Repository implements DescriptorsRepository +{ + /** + * In-memory cache property descriptors for target class and concern classes + * + * @type {WeakMap>} + * + * @private + */ + #store: WeakMap< + ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor, + Record + >; + + /** + * Create new Descriptors instance + */ + constructor() { + this.#store = new WeakMap(); + } + + /** + * Returns property descriptors for given target class (recursively) + * + * @param {ConstructorOrAbstractConstructor | 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} + */ + public get( + target: ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor, + force: boolean = false, + cache: boolean = false + ): Record + { + if (!force && this.#store.has(target)) { + return this.#store.get(target) as Record; + } + + 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 {ConstructorOrAbstractConstructor | 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 + */ + public rememberDuring( + target: ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor, + callback: () => any, /* eslint-disable-line @typescript-eslint/no-explicit-any */ + forgetAfter: boolean = true + ): any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + { + this.remember(target); + + const output = callback(); + + if (forgetAfter) { + this.forget(target); + } + + return output; + } + + /** + * Retrieves the property descriptors for given target and caches them + * + * @param {ConstructorOrAbstractConstructor | 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} + */ + public remember(target: ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor, force: boolean = false): Record + { + return this.get(target, force, true); + } + + /** + * Deletes cached descriptors for target + * + * @param {ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor} target + * + * @return {boolean} + */ + public forget(target: ConstructorOrAbstractConstructor | UsesConcerns | ConcernConstructor): boolean + { + return this.#store.delete(target); + } + + /** + * Clears all cached descriptors + * + * @return {this} + */ + public clear(): this + { + this.#store = new WeakMap(); + + return this; + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/assertIsConcernsOwner.ts b/packages/support/src/concerns/assertIsConcernsOwner.ts new file mode 100644 index 00000000..73aff60d --- /dev/null +++ b/packages/support/src/concerns/assertIsConcernsOwner.ts @@ -0,0 +1,20 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { getNameOrDesc } from "@aedart/support/reflections"; +import { isConcernsOwner } from "./isConcernsOwner"; + +/** + * Assert that given instance is of the type [Concerns Owner]{@link import('@aedart/contracts/support/concerns').Owner} + * + * @see isConcernsOwner + * + * @param {object} instance + * + * @throws {TypeError} If `instance` is not of the type [Concerns Owner]{@link import('@aedart/contracts/support/concerns').Owner} + */ +export function assertIsConcernsOwner(instance: object): void +{ + if (!isConcernsOwner(instance)) { + const msg: string = `${getNameOrDesc(instance as ConstructorOrAbstractConstructor)} is not a concerns owner`; + throw new TypeError(msg, { cause: { instance: instance } }); + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/bootAllConcerns.ts b/packages/support/src/concerns/bootAllConcerns.ts new file mode 100644 index 00000000..3538f4a5 --- /dev/null +++ b/packages/support/src/concerns/bootAllConcerns.ts @@ -0,0 +1,18 @@ +import type { Owner } from "@aedart/contracts/support/concerns"; +import { getConcernsContainer } from "@aedart/support/concerns/getConcernsContainer"; + +/** + * Boot all concerns for [owner]{@link Owner} instance + * + * @param {object|Owner} instance + * + * @return {void} + * + * @throws {TypeError} If `instance` is not of the type [Concerns Owner]{@link import('@aedart/contracts/support/concerns').Owner} + * @throws {NotRegisteredError} If a concern class is not registered in this container + * @throws {BootError} If a concern is unable to be booted, e.g. if already booted + */ +export function bootAllConcerns(instance: object|Owner): void +{ + getConcernsContainer(instance).bootAll(); +} \ No newline at end of file diff --git a/packages/support/src/concerns/bootConcerns.ts b/packages/support/src/concerns/bootConcerns.ts new file mode 100644 index 00000000..e84bd319 --- /dev/null +++ b/packages/support/src/concerns/bootConcerns.ts @@ -0,0 +1,22 @@ +import type { ConcernConstructor, Container, Owner } from "@aedart/contracts/support/concerns"; +import { getConcernsContainer } from "./getConcernsContainer"; + +/** + * Boot given concerns for [owner]{@link Owner} instance + * + * @param {object|Owner} instance + * @param {...ConcernConstructor[]} concerns + * + * @return {void} + * + * @throws {TypeError} If `instance` is not of the type [Concerns Owner]{@link import('@aedart/contracts/support/concerns').Owner} + * @throws {NotRegisteredError} If a concern class is not registered in this container + * @throws {BootError} If a concern is unable to be booted, e.g. if already booted + */ +export function bootConcerns(instance: object|Owner, ...concerns: ConcernConstructor[]): void +{ + const container: Container = getConcernsContainer(instance); + for (const concern of concerns) { + container.boot(concern); + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/exceptions/AliasConflictError.ts b/packages/support/src/concerns/exceptions/AliasConflictError.ts new file mode 100644 index 00000000..0d94f757 --- /dev/null +++ b/packages/support/src/concerns/exceptions/AliasConflictError.ts @@ -0,0 +1,115 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import type { AliasConflictException, ConcernConstructor, UsesConcerns, Alias } from "@aedart/contracts/support/concerns"; +import InjectionError from "./InjectionError"; +import { getNameOrDesc } from "@aedart/support/reflections"; +import { configureCustomError } from "@aedart/support/exceptions"; + +/** + * Alias Conflict Error + * + * @see AliasConflictException + */ +export default class AliasConflictError extends InjectionError implements AliasConflictException +{ + /** + * The requested alias that conflicts with another alias + * of the same name. + * + * @readonly + * @private + * + * @type {Alias} + */ + readonly #alias: Alias; + + /** + * the property key that the conflicting alias points to + * + * @readonly + * @private + * + * @type {PropertyKey} + */ + readonly #key: PropertyKey; + + /** + * The source class (e.g. parent class) that defines that originally defined the alias + * + * @readonly + * @private + * + * @type {ConstructorOrAbstractConstructor | UsesConcerns} + */ + readonly #source: ConstructorOrAbstractConstructor | UsesConcerns; + + /** + * Create a new Alias Conflict Error instance + * + * @param {ConstructorOrAbstractConstructor | UsesConcerns} target + * @param {ConcernConstructor} concern + * @param {Alias} alias + * @param {PropertyKey} key + * @param {ConstructorOrAbstractConstructor | UsesConcerns} source + * @param {ErrorOptions} [options] + */ + constructor( + target: ConstructorOrAbstractConstructor | UsesConcerns, + concern: ConcernConstructor, + alias: Alias, + key: PropertyKey, + source: ConstructorOrAbstractConstructor | UsesConcerns, + options?: ErrorOptions + ) { + const reason: string = (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 as Record).alias = alias; + (this.cause as Record).source = source; + } + + /** + * The requested alias that conflicts with another alias + * of the same name. + * + * @readonly + * + * @type {Alias} + */ + get alias(): Alias + { + return this.#alias; + } + + /** + * the property key that the conflicting alias points to + * + * @readonly + * + * @type {PropertyKey} + */ + get key(): PropertyKey + { + return this.#key; + } + + /** + * The source class (e.g. parent class) that defines that originally defined the alias + * + * @readonly + * + * @type {ConstructorOrAbstractConstructor | UsesConcerns} + */ + get source(): ConstructorOrAbstractConstructor | UsesConcerns + { + return this.#source; + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/exceptions/AlreadyRegisteredError.ts b/packages/support/src/concerns/exceptions/AlreadyRegisteredError.ts new file mode 100644 index 00000000..28d465e8 --- /dev/null +++ b/packages/support/src/concerns/exceptions/AlreadyRegisteredError.ts @@ -0,0 +1,62 @@ +import type { + AlreadyRegisteredException, + ConcernConstructor, + UsesConcerns +} from "@aedart/contracts/support/concerns"; +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { configureCustomError } from "@aedart/support/exceptions"; +import { getNameOrDesc } from "@aedart/support/reflections"; +import InjectionError from "./InjectionError"; + +/** + * Already Registered Error + * + * @see AlreadyRegisteredException + */ +export default class AlreadyRegisteredError extends InjectionError implements AlreadyRegisteredException +{ + /** + * The source, e.g. a parent class, in which a concern class + * was already registered. + * + * @readonly + * @private + * + * @type {ConstructorOrAbstractConstructor|UsesConcerns} + */ + readonly #source: ConstructorOrAbstractConstructor | UsesConcerns; + + constructor( + target: ConstructorOrAbstractConstructor | UsesConcerns, + concern: ConcernConstructor, + source: ConstructorOrAbstractConstructor | UsesConcerns, + message?: string, + options?: ErrorOptions + ) { + 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 as Record).source = source; + } + + /** + * The source, e.g. a parent class, in which a concern class + * was already registered. + * + * @readonly + * + * @returns {ConstructorOrAbstractConstructor | UsesConcerns} + */ + get source(): ConstructorOrAbstractConstructor | UsesConcerns + { + return this.#source; + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/exceptions/BootError.ts b/packages/support/src/concerns/exceptions/BootError.ts new file mode 100644 index 00000000..b81ff0e0 --- /dev/null +++ b/packages/support/src/concerns/exceptions/BootError.ts @@ -0,0 +1,25 @@ +import type { BootException, ConcernConstructor } from "@aedart/contracts/support/concerns"; +import ConcernError from "./ConcernError"; +import { configureCustomError } from "@aedart/support/exceptions"; + +/** + * Concern Boot Error + * + * @see BootException + */ +export default class BootError extends ConcernError implements BootException +{ + /** + * Create a new Concern Boot Error instance + * + * @param {ConcernConstructor} concern + * @param {string} message + * @param {ErrorOptions} [options] + */ + constructor(concern: ConcernConstructor, message: string, options?: ErrorOptions) + { + super(concern, message, options); + + configureCustomError(this); + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/exceptions/ConcernError.ts b/packages/support/src/concerns/exceptions/ConcernError.ts new file mode 100644 index 00000000..0d69042a --- /dev/null +++ b/packages/support/src/concerns/exceptions/ConcernError.ts @@ -0,0 +1,50 @@ +import type { ConcernException, ConcernConstructor } from "@aedart/contracts/support/concerns"; +import { configureCustomError } from "@aedart/support/exceptions"; + +/** + * Concern Error + * + * @see ConcernException + */ +export default class ConcernError extends Error implements ConcernException +{ + /** + * The Concern class that caused this error or exception + * + * @private + * + * @type {ConcernConstructor | null} + */ + readonly #concern: ConcernConstructor | null + + /** + * Create a new Concern Error instance + * + * @param {ConcernConstructor | null} concern + * @param {string} message + * @param {ErrorOptions} [options] + */ + constructor(concern: ConcernConstructor | null, message: string, options?: ErrorOptions) + { + super(message, options || { cause: {} }); + + configureCustomError(this); + + this.#concern = concern; + + // Force set the concern in the cause (in case custom was provided) + (this.cause as Record).concern = concern; + } + + /** + * The Concern class that caused this error or exception + * + * @readonly + * + * @type {ConcernConstructor | null} + */ + get concern(): ConcernConstructor | null + { + return this.#concern; + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/exceptions/InjectionError.ts b/packages/support/src/concerns/exceptions/InjectionError.ts new file mode 100644 index 00000000..e5c31436 --- /dev/null +++ b/packages/support/src/concerns/exceptions/InjectionError.ts @@ -0,0 +1,57 @@ +import type { ConcernConstructor, InjectionException, UsesConcerns } from "@aedart/contracts/support/concerns"; +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { configureCustomError } from "@aedart/support/exceptions"; +import ConcernError from "./ConcernError"; + +/** + * Injection Error + * + * @see InjectionException + */ +export default class InjectionError extends ConcernError implements InjectionException +{ + /** + * The target class + * + * @readonly + * + * @type {ConstructorOrAbstractConstructor|UsesConcerns} + */ + readonly #target: ConstructorOrAbstractConstructor | UsesConcerns; + + /** + * Create a new Injection Error instance + * + * @param {ConstructorOrAbstractConstructor | UsesConcerns} target + * @param {ConcernConstructor | null} concern + * @param {string} message + * @param {ErrorOptions} [options] + */ + constructor( + target: ConstructorOrAbstractConstructor | UsesConcerns, + concern: ConcernConstructor | null, + message: string, + options?: ErrorOptions + ) { + super(concern, message, options); + + configureCustomError(this); + + this.#target = target; + + // Force set the target in the cause + (this.cause as Record).target = target; + } + + /** + * The target class + * + * @readonly + * + * @returns {ConstructorOrAbstractConstructor | UsesConcerns} + */ + get target(): ConstructorOrAbstractConstructor | UsesConcerns + { + return this.#target; + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/exceptions/NotRegisteredError.ts b/packages/support/src/concerns/exceptions/NotRegisteredError.ts new file mode 100644 index 00000000..dbfc938f --- /dev/null +++ b/packages/support/src/concerns/exceptions/NotRegisteredError.ts @@ -0,0 +1,25 @@ +import type { ConcernConstructor, NotRegisteredException } from "@aedart/contracts/support/concerns"; +import ConcernError from './ConcernError'; +import { getNameOrDesc } from "@aedart/support/reflections"; +import { configureCustomError } from "@aedart/support/exceptions"; + +/** + * Concern Not Registered Error + * + * @see NotRegisteredException + */ +export default class NotRegisteredError extends ConcernError implements NotRegisteredException +{ + /** + * Create a new Concern Not Registered Error instance + * + * @param {ConcernConstructor} concern + * @param {ErrorOptions} [options] + */ + constructor(concern: ConcernConstructor, options?: ErrorOptions) + { + super(concern, `Concern ${getNameOrDesc(concern)} is not registered in concerns container`, options); + + configureCustomError(this); + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/exceptions/UnsafeAliasError.ts b/packages/support/src/concerns/exceptions/UnsafeAliasError.ts new file mode 100644 index 00000000..5d30e3bd --- /dev/null +++ b/packages/support/src/concerns/exceptions/UnsafeAliasError.ts @@ -0,0 +1,87 @@ +import type { + ConcernConstructor, + UsesConcerns, UnsafeAliasException +} from "@aedart/contracts/support/concerns"; +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { configureCustomError } from "@aedart/support/exceptions"; +import InjectionError from "./InjectionError"; +import {getNameOrDesc} from "@aedart/support/reflections"; + +/** + * Unsafe Alias Error + */ +export default class UnsafeAliasError extends InjectionError implements UnsafeAliasException +{ + /** + * The alias that points to an "unsafe" property or method + * + * @readonly + * + * @type {PropertyKey} + */ + readonly #alias: PropertyKey; + + /** + * The "unsafe" property or method that an alias points to + * + * @readonly + * + * @type {PropertyKey} + */ + readonly #key: PropertyKey; + + /** + * Create a new Unsafe Alias Error instance + * + * @param {ConstructorOrAbstractConstructor | UsesConcerns} target + * @param {ConcernConstructor} concern + * @param {PropertyKey} alias + * @param {PropertyKey} key + * @param {string} [message] + * @param {ErrorOptions} [options] + */ + constructor( + target: ConstructorOrAbstractConstructor | UsesConcerns, + concern: ConcernConstructor, + alias: PropertyKey, + key: PropertyKey, + message?: string, + options?: ErrorOptions + ) { + const reason: string = 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 as Record).alias = alias; + (this.cause as Record).key = key; + } + + /** + * The alias that points to an "unsafe" property or method + * + * @readonly + * + * @type {PropertyKey} + */ + get alias(): PropertyKey + { + return this.#alias; + } + + /** + * The "unsafe" property or method that an alias points to + * + * @readonly + * + * @type {PropertyKey} + */ + get key(): PropertyKey + { + return this.#key; + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/exceptions/index.ts b/packages/support/src/concerns/exceptions/index.ts new file mode 100644 index 00000000..b91e1d80 --- /dev/null +++ b/packages/support/src/concerns/exceptions/index.ts @@ -0,0 +1,16 @@ +import AliasConflictError from "./AliasConflictError"; +import AlreadyRegisteredError from "./AlreadyRegisteredError"; +import BootError from "./BootError"; +import ConcernError from "./ConcernError"; +import InjectionError from "./InjectionError"; +import NotRegisteredError from "./NotRegisteredError"; +import UnsafeAliasError from "./UnsafeAliasError"; +export { + AliasConflictError, + AlreadyRegisteredError, + BootError, + ConcernError, + InjectionError, + NotRegisteredError, + UnsafeAliasError +}; \ No newline at end of file diff --git a/packages/support/src/concerns/getConcernsContainer.ts b/packages/support/src/concerns/getConcernsContainer.ts new file mode 100644 index 00000000..30db723c --- /dev/null +++ b/packages/support/src/concerns/getConcernsContainer.ts @@ -0,0 +1,22 @@ +import type { Container, Owner } from "@aedart/contracts/support/concerns"; +import { assertIsConcernsOwner } from "./assertIsConcernsOwner"; +import { getContainer } from "./getContainer"; + +/** + * Returns [owner's]{@link Owner} [concerns container]{@link Container} + * + * @see assertIsConcernsOwner + * @see getContainer + * + * @param {object|Owner} instance + * + * @return {Container} + * + * @throws {TypeError} If `instance` is not a [concerns owner]{@link Owner} + */ +export function getConcernsContainer(instance: object|Owner): Container +{ + assertIsConcernsOwner(instance); + + return getContainer(instance as Owner); +} \ No newline at end of file diff --git a/packages/support/src/concerns/getContainer.ts b/packages/support/src/concerns/getContainer.ts new file mode 100644 index 00000000..961d55a1 --- /dev/null +++ b/packages/support/src/concerns/getContainer.ts @@ -0,0 +1,18 @@ +import type { Container, Owner } from "@aedart/contracts/support/concerns"; +import { CONCERNS } from "@aedart/contracts/support/concerns"; + +/** + * Returns [owner's]{@link Owner} [concerns container]{@link Container} + * + * **Caution**: _Method expects that given `owner` is type {@link Owner}!_ + * + * @see getConcernsContainer + * + * @param {Owner} owner + * + * @return {Container} + */ +export function getContainer(owner: Owner): Container +{ + return owner[CONCERNS]; +} \ No newline at end of file diff --git a/packages/support/src/concerns/index.ts b/packages/support/src/concerns/index.ts new file mode 100644 index 00000000..ca820314 --- /dev/null +++ b/packages/support/src/concerns/index.ts @@ -0,0 +1,31 @@ +import AbstractConcern from "./AbstractConcern"; +import { ConcernClassBlueprint } from "./ConcernClassBlueprint"; +import ConcernsContainer from "./ConcernsContainer"; +import ConcernsInjector from "./ConcernsInjector"; +import ConfigurationFactory from "./ConfigurationFactory"; +import Repository from "./Repository"; +import DescriptorFactory from "./DescriptorFactory"; + +export { + AbstractConcern, + ConcernClassBlueprint, + ConcernsContainer, + ConcernsInjector, + ConfigurationFactory, + Repository, + DescriptorFactory +}; + +export * from './exceptions'; +export * from './assertIsConcernsOwner'; +export * from './bootAllConcerns'; +export * from './bootConcerns'; +export * from './getConcernsContainer'; +export * from './getContainer'; +export * from './isConcernConfiguration'; +export * from './isConcernConstructor'; +export * from './isConcernsOwner'; +export * from './isShorthandConfiguration'; +export * from './isUnsafeKey'; +export * from './use'; +export * from './usesConcerns'; \ No newline at end of file diff --git a/packages/support/src/concerns/isConcernConfiguration.ts b/packages/support/src/concerns/isConcernConfiguration.ts new file mode 100644 index 00000000..0a881d09 --- /dev/null +++ b/packages/support/src/concerns/isConcernConfiguration.ts @@ -0,0 +1,21 @@ +import type { Configuration } from "@aedart/contracts/support/concerns"; +import { isConcernConstructor } from "./isConcernConstructor"; + +/** + * 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} + */ +export function isConcernConfiguration(target: object, force: boolean = false): boolean +{ + // 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 as Configuration).concern, force); +} \ No newline at end of file diff --git a/packages/support/src/concerns/isConcernConstructor.ts b/packages/support/src/concerns/isConcernConstructor.ts new file mode 100644 index 00000000..0def1244 --- /dev/null +++ b/packages/support/src/concerns/isConcernConstructor.ts @@ -0,0 +1,42 @@ +import { isSubclassOrLooksLike} from "@aedart/support/reflections"; +import { isset } from "@aedart/support/misc"; +import AbstractConcern from "./AbstractConcern"; +import { ConcernClassBlueprint } from "./ConcernClassBlueprint"; + +/** + * In-memory cache of classes that are determined to be of the type + * [Concern Constructor]{@link import('@aedart/contracts/support/concerns').ConcernConstructor}. + * + * @type {WeakSet} + */ +const concernConstructorsCache: WeakSet = 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} + */ +export function isConcernConstructor( + target: any, /* eslint-disable-line @typescript-eslint/no-explicit-any */ + force: boolean = false +): boolean +{ + 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; +} \ No newline at end of file diff --git a/packages/support/src/concerns/isConcernsOwner.ts b/packages/support/src/concerns/isConcernsOwner.ts new file mode 100644 index 00000000..8fb4f6ba --- /dev/null +++ b/packages/support/src/concerns/isConcernsOwner.ts @@ -0,0 +1,14 @@ +import { CONCERNS } from "@aedart/contracts/support/concerns"; +import { isset } from "@aedart/support/misc"; + +/** + * Determine if object is of the type [Concerns Owner]{@link import('@aedart/contracts/support/concerns').Owner} + * + * @param {object} instance + * + * @return {boolean} + */ +export function isConcernsOwner(instance: object): boolean +{ + return isset(instance) && Reflect.has(instance, CONCERNS); +} \ No newline at end of file diff --git a/packages/support/src/concerns/isShorthandConfiguration.ts b/packages/support/src/concerns/isShorthandConfiguration.ts new file mode 100644 index 00000000..82cea2f9 --- /dev/null +++ b/packages/support/src/concerns/isShorthandConfiguration.ts @@ -0,0 +1,20 @@ +import type { ShorthandConfiguration } from "@aedart/contracts/support/concerns"; +import { isConcernConstructor } from "./isConcernConstructor"; + +/** + * 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} + */ +export function isShorthandConfiguration(target: object, force: boolean = false): boolean +{ + // Note: A Concern Configuration (shorthand) only requires a + // Concern Constructor as its first value. + + return Array.isArray(target) + && target.length > 0 + && isConcernConstructor((target as ShorthandConfiguration)[0], force); +} \ No newline at end of file diff --git a/packages/support/src/concerns/isUnsafeKey.ts b/packages/support/src/concerns/isUnsafeKey.ts new file mode 100644 index 00000000..dc51b591 --- /dev/null +++ b/packages/support/src/concerns/isUnsafeKey.ts @@ -0,0 +1,56 @@ +import { DANGEROUS_PROPERTIES } from "@aedart/contracts/support/objects"; +import { + CONCERN_CLASSES, + CONCERNS, + PROVIDES, + ALIASES, + BEFORE, + AFTER +} from "@aedart/contracts/support/concerns"; + +/** + * List of property keys that are considered "unsafe" to alias (proxy to) + */ +export 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} + */ +export function isUnsafeKey(key: PropertyKey): boolean +{ + return UNSAFE_PROPERTY_KEYS.includes(key); +} \ No newline at end of file diff --git a/packages/support/src/concerns/use.ts b/packages/support/src/concerns/use.ts new file mode 100644 index 00000000..f148f38a --- /dev/null +++ b/packages/support/src/concerns/use.ts @@ -0,0 +1,41 @@ +import type { + ConcernConstructor, + Configuration, + ShorthandConfiguration +} from "@aedart/contracts/support/concerns"; +import ConcernsInjector from "./ConcernsInjector"; + +/** + * Injects the concern classes into the target class + * + * **Note**: _Method is intended to be used as a class decorator!_ + * + * **Example**: + * ``` + * @use( + * MyConcernA, + * MyConcernB, + * { concern: MyConcernC, aliases: { 'foo': 'bar' } }, + * ) + * class MyClass {} + * ``` + * + * @see Injector + * @see ConcernConstructor + * @see Configuration + * @see UsesConcerns + * + * @template T = object + * + * @param {...Constructor | Configuration | ShorthandConfiguration} concerns + * + * @returns {(target: T) => UsesConcerns} + * + * @throws {InjectionException} + */ +export function use(...concerns: (ConcernConstructor|Configuration|ShorthandConfiguration)[]) +{ + return (target: object) => { + return (new ConcernsInjector(target)).inject(...concerns); + } +} \ No newline at end of file diff --git a/packages/support/src/concerns/usesConcerns.ts b/packages/support/src/concerns/usesConcerns.ts new file mode 100644 index 00000000..e0154310 --- /dev/null +++ b/packages/support/src/concerns/usesConcerns.ts @@ -0,0 +1,27 @@ +import type { ConcernConstructor, Container, Owner } from "@aedart/contracts/support/concerns"; +import { isConcernsOwner } from "./isConcernsOwner"; +import { getContainer } from "./getContainer"; + +/** + * Determine if [concerns owner]{@link Owner} uses the given concerns + * + * @param {object|Owner} instance + * @param {...ConcernConstructor[]} concerns + * + * @return {boolean} `true` if owner uses all given concerns, `false` otherwise. + */ +export function usesConcerns(instance: object|Owner, ...concerns: ConcernConstructor[]): boolean +{ + if (!isConcernsOwner(instance) || concerns.length == 0) { + return false; + } + + const container: Container = getContainer(instance as Owner); + for (const concern of concerns){ + if (!container.has(concern)) { + return false; + } + } + + return true; +} \ No newline at end of file diff --git a/packages/support/src/exceptions/AbstractClassError.ts b/packages/support/src/exceptions/AbstractClassError.ts new file mode 100644 index 00000000..dd24f697 --- /dev/null +++ b/packages/support/src/exceptions/AbstractClassError.ts @@ -0,0 +1,33 @@ +import type { AbstractConstructor } from "@aedart/contracts"; +import LogicalError from "./LogicalError"; +import { getNameOrDesc } from "@aedart/support/reflections"; +import { configureCustomError } from "./configureCustomError"; + +/** + * Abstract Class Error + * + * To be thrown whenever an abstract class is attempted instantiated directly. + */ +export default class AbstractClassError extends LogicalError +{ + /** + * The abstract class that was attempted instantiated + * + * @type {AbstractConstructor} + */ + target: AbstractConstructor; + + /** + * Create new instance of Abstract Class Error + * + * @param {AbstractConstructor} target The abstract class that was attempted instantiated directly + * @param {ErrorOptions} [options] + */ + constructor(target: AbstractConstructor, options?: ErrorOptions) { + super(`Unable to create new instance of abstract class ${getNameOrDesc(target)}`, options || { cause: { target: target } }); + + configureCustomError(this); + + this.target = target; + } +} \ No newline at end of file diff --git a/packages/support/src/exceptions/LogicalError.ts b/packages/support/src/exceptions/LogicalError.ts new file mode 100644 index 00000000..ef25e145 --- /dev/null +++ b/packages/support/src/exceptions/LogicalError.ts @@ -0,0 +1,24 @@ +import type { Throwable } from "@aedart/contracts/support/exceptions"; +import { configureCustomError } from "./configureCustomError"; + +/** + * Logical Error + * + * To be thrown whenever there is an error in the programming logic. + * + * This error is inspired by PHP's [`LogicException`]{@link https://www.php.net/manual/en/class.logicexception} + */ +export default class LogicalError extends Error implements Throwable +{ + /** + * Create a new logical error instance + * + * @param {string} [message] + * @param {ErrorOptions} [options] + */ + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + + configureCustomError(this); + } +} \ No newline at end of file diff --git a/packages/support/src/exceptions/configureCustomError.ts b/packages/support/src/exceptions/configureCustomError.ts new file mode 100644 index 00000000..bf64a7c5 --- /dev/null +++ b/packages/support/src/exceptions/configureCustomError.ts @@ -0,0 +1,38 @@ +import { configureStackTrace } from "./configureStackTrace"; + +/** + * Configures the custom error + * + * **Note**: _Method sets the custom error's name and optionally captures a [stack trace]{@link configureStackTrace} + * and sets it as the custom error's stack._ + * + * **Example**: + * ``` + * class MyCustomError extends Error + * { + * constructor(message, options) + * { + * super(message, options) + * + * configureCustomError(this); + * } + * } + * ``` + * + * @template T extends Error + * + * @param {Error} error + * @param {boolean} [captureStackTrace=false] + * + * @return {Error} + */ +export function configureCustomError(error: T, captureStackTrace: boolean = false): T +{ + if (captureStackTrace) { + configureStackTrace(error); + } + + error.name = Reflect.getPrototypeOf(error)?.constructor.name as string; + + return error; +} \ No newline at end of file diff --git a/packages/support/src/exceptions/configureStackTrace.ts b/packages/support/src/exceptions/configureStackTrace.ts new file mode 100644 index 00000000..d849ad55 --- /dev/null +++ b/packages/support/src/exceptions/configureStackTrace.ts @@ -0,0 +1,29 @@ +import {isset} from "@aedart/support/misc"; + +/** + * Captures stack trace and sets stack trace for given error + * + * **Caution**: _Method will mutate given `error` by setting the `stack` property_ + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/stack + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#custom_error_types + * + * @param {Error} error + * + * @return {string|undefined} Captured stack trace + */ +export function configureStackTrace(error: Error): string | undefined +{ + if (!isset(error)){ + return undefined; + } + + // @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#custom_error_types + if (Reflect.has(Error, 'captureStackTrace') && typeof Error['captureStackTrace'] == 'function') { + (Error.captureStackTrace as (err: Error, errorConstructor: ErrorConstructor) => void)(error, Reflect.getPrototypeOf(error) as ErrorConstructor); + } else { + error.stack = (new Error()).stack; + } + + return error.stack; +} \ No newline at end of file diff --git a/packages/support/src/exceptions/getErrorMessage.ts b/packages/support/src/exceptions/getErrorMessage.ts new file mode 100644 index 00000000..36610ca1 --- /dev/null +++ b/packages/support/src/exceptions/getErrorMessage.ts @@ -0,0 +1,17 @@ +/** + * Returns error message from {@link Error}, if possible + * + * @param {any} error Error or value that was thrown + * @param {string} [defaultMessage] A default message to return if unable to resolve error message + * + * @return {string} + */ +export function getErrorMessage( + error: any, /* eslint-disable-line @typescript-eslint/no-explicit-any */ + defaultMessage: string = 'unknown reason' +): string +{ + return (typeof error == 'object' && error instanceof Error && Reflect.has(error, 'message')) + ? error.message + : defaultMessage; +} \ No newline at end of file diff --git a/packages/support/src/exceptions/index.ts b/packages/support/src/exceptions/index.ts new file mode 100644 index 00000000..2db4cc3b --- /dev/null +++ b/packages/support/src/exceptions/index.ts @@ -0,0 +1,10 @@ +import AbstractClassError from "./AbstractClassError"; +import LogicalError from "./LogicalError"; +export { + AbstractClassError, + LogicalError +} + +export * from './configureCustomError'; +export * from './configureStackTrace'; +export * from './getErrorMessage'; \ No newline at end of file diff --git a/packages/support/src/meta/targetMeta.ts b/packages/support/src/meta/targetMeta.ts index 931a1970..a89390a5 100644 --- a/packages/support/src/meta/targetMeta.ts +++ b/packages/support/src/meta/targetMeta.ts @@ -5,6 +5,7 @@ import type { MetaEntry, MetaAddress, } from "@aedart/contracts/support/meta"; +import { FUNCTION_PROTOTYPE } from "@aedart/contracts/support/reflections"; import { METADATA, TARGET_METADATA, Kind } from "@aedart/contracts/support/meta"; import { mergeKeys, empty } from "@aedart/support/misc"; import { meta, getMeta } from './meta' @@ -244,15 +245,12 @@ function findAddress(target: object): MetaAddress | undefined target = target.constructor; } - // 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) { + if (parent === null || parent === FUNCTION_PROTOTYPE) { break; } diff --git a/packages/support/src/misc/index.ts b/packages/support/src/misc/index.ts index 71688fc5..761965f2 100644 --- a/packages/support/src/misc/index.ts +++ b/packages/support/src/misc/index.ts @@ -1,8 +1,8 @@ +export * from './descTag'; export * from './empty'; export * from './isKey'; export * from './isPrimitive'; export * from './isPropertyKey'; export * from './isset'; -export * from './descTag'; export * from './mergeKeys'; export * from './toWeakRef'; diff --git a/packages/support/src/misc/mergeKeys.ts b/packages/support/src/misc/mergeKeys.ts index 80580e8b..500c209c 100644 --- a/packages/support/src/misc/mergeKeys.ts +++ b/packages/support/src/misc/mergeKeys.ts @@ -17,17 +17,20 @@ export function mergeKeys(...keys: Key[]): Key return []; } - keys = keys.map((key: Key, index: number) => { - if (!isKey(key)) { + const mapped = keys.map((key: Key, index: number) => { + let modifiedKey = key; + + if (!isKey(modifiedKey)) { throw new TypeError(`mergeKeys(): Argument #${index} must be a valid "key", ${typeof key} given`); } - if (!Array.isArray(key)) { - key = [ key ]; + if (!Array.isArray(modifiedKey)) { + modifiedKey = [ modifiedKey ] as Key; } - return key; + return modifiedKey; }); - return [].concat(...keys); + // @ts-expect-error This should be fine here. TS does not understand this merge... + return [].concat(...mapped) as Key; } \ No newline at end of file diff --git a/packages/support/src/mixins/Builder.ts b/packages/support/src/mixins/Builder.ts index 8696ae54..744fc76d 100644 --- a/packages/support/src/mixins/Builder.ts +++ b/packages/support/src/mixins/Builder.ts @@ -28,7 +28,7 @@ export default class Builder * * @param {ConstructorOrAbstractConstructor} [superclass=class {}] */ - constructor(superclass:ConstructorOrAbstractConstructor = class {}) { + constructor(superclass:ConstructorOrAbstractConstructor = class {} as ConstructorOrAbstractConstructor) { this.#superclass = superclass; } @@ -42,7 +42,7 @@ export default class Builder public with(...mixins: MixinFunction[]): ConstructorOrAbstractConstructor { return mixins.reduce(( - superclass: T, + superclass: ConstructorOrAbstractConstructor, mixin: MixinFunction ) => { // Return superclass, when mixin isn't a function. @@ -52,7 +52,7 @@ export default class Builder // Apply the mixin... return mixin(superclass); - }, this.#superclass); + }, this.#superclass as ConstructorOrAbstractConstructor) as ConstructorOrAbstractConstructor; } /** diff --git a/packages/support/src/objects/exceptions/MergeError.ts b/packages/support/src/objects/exceptions/MergeError.ts new file mode 100644 index 00000000..413127bb --- /dev/null +++ b/packages/support/src/objects/exceptions/MergeError.ts @@ -0,0 +1,23 @@ +import type { MergeException } from "@aedart/contracts/support/objects"; +import { configureCustomError } from "@aedart/support/exceptions"; + +/** + * Merge Error + * + * @see MergeException + */ +export default class MergeError extends Error implements MergeException +{ + /** + * Create a new Merge Error instance + * + * @param {string} [message] + * @param {ErrorOptions} [options] + */ + constructor(message?: string, options?: ErrorOptions) + { + super(message, options); + + configureCustomError(this); + } +} \ No newline at end of file diff --git a/packages/support/src/objects/exceptions/index.ts b/packages/support/src/objects/exceptions/index.ts new file mode 100644 index 00000000..4d7f6520 --- /dev/null +++ b/packages/support/src/objects/exceptions/index.ts @@ -0,0 +1,4 @@ +import MergeError from "./MergeError"; +export { + MergeError +} \ No newline at end of file diff --git a/packages/support/src/objects/forget.ts b/packages/support/src/objects/forget.ts index 2a24808b..22bdb1ed 100644 --- a/packages/support/src/objects/forget.ts +++ b/packages/support/src/objects/forget.ts @@ -2,7 +2,7 @@ import { unset as _unset } from 'lodash-es'; /** * Remove value in object at given path - * (Alias for Lodash' {@link import('lodash').unset unset}) method + * (Alias for Lodash' [unset]{@link import('lodash').unset}) method * * @type {(object: any, path: import('@aedart/contracts/support').Key) => boolean} */ diff --git a/packages/support/src/objects/get.ts b/packages/support/src/objects/get.ts index 53ce99fa..d864fb1f 100644 --- a/packages/support/src/objects/get.ts +++ b/packages/support/src/objects/get.ts @@ -8,7 +8,7 @@ import {get as _get} from 'lodash-es'; /** * Get value from object that matches given path - * (Alias for Lodash' {@link import('lodash').get get}) method + * (Alias for Lodash' [get]{@link import('lodash').get}) method * * @type {{(object: TObject, path: ([TKey] | TKey)): TObject[TKey], (object: (TObject | null | undefined), path: ([TKey] | TKey)): (TObject[TKey] | undefined), (object: (TObject | null | undefined), path: ([TKey] | TKey), defaultValue: TDefault): (Exclude | TDefault), (object: TObject, path: [TKey1, TKey2]): TObject[TKey1][TKey2], (object: (TObject | null | undefined), path: [TKey1, TKey2]): (TObject[TKey1][TKey2] | undefined), (object: (TObject | null | undefined), path: [TKey1, TKey2], defaultValue: TDefault): (Exclude | TDefault), (object: TObject, path: [TKey1, TKey2, TKey3]): TObject[TKey1][TKey2][TKey3], (object: (TObject | null | undefined), path: [TKey1, TKey2, TKey3]): (TObject[TKey1][TKey2][TKey3] | undefined), (object: (TObject | null | undefined), path: [TKey1, TKey2, TKey3], defaultValue: TDefault): (Exclude | TDefault), (object: TObject, path: [TKey1, TKey2, TKey3, TKey4]): TObject[TKey1][TKey2][TKey3][TKey4], (object: (TObject | null | undefined), path: [TKey1, TKey2, TKey3, TKey4]): (TObject[TKey1][TKey2][TKey3][TKey4] | undefined), (object: (TObject | null | undefined), path: [TKey1, TKey2, TKey3, TKey4], defaultValue: TDefault): (Exclude | TDefault), (object: NumericDictionary, path: number): T, (object: (NumericDictionary | null | undefined), path: number): (T | undefined), (object: (NumericDictionary | null | undefined), path: number, defaultValue: TDefault): (T | TDefault), (object: (null | undefined), path: PropertyPath, defaultValue: TDefault): TDefault, (object: (null | undefined), path: PropertyPath): undefined, (data: TObject, path: TPath): string extends TPath ? any : GetFieldType, >(data: TObject, path: TPath, defaultValue: TDefault): (Exclude, null | undefined> | TDefault), (object: any, path: PropertyPath, defaultValue?: any): any}} */ diff --git a/packages/support/src/objects/has.ts b/packages/support/src/objects/has.ts index 9246727d..11d222da 100644 --- a/packages/support/src/objects/has.ts +++ b/packages/support/src/objects/has.ts @@ -3,7 +3,7 @@ import {hasIn as _hasIn} from 'lodash-es'; /** * Determine if path is a property of given object * - * (Alias for Lodash' {@link import('lodash').hasIn hasIn}) method + * (Alias for Lodash' [hasIn]{@link import('lodash').hasIn}) method * * @type {(object: T, path: import('@aedart/contracts/support').Key) => boolean} */ diff --git a/packages/support/src/objects/index.ts b/packages/support/src/objects/index.ts index 305dd627..ffcc75d2 100644 --- a/packages/support/src/objects/index.ts +++ b/packages/support/src/objects/index.ts @@ -5,11 +5,18 @@ export * from './has'; export * from './hasAll'; export * from './hasAny'; export * from './hasUniqueId'; +export * from './isCloneable'; +export * from './isPopulatable'; export * from './isset'; +export * from './merge'; +export * from './populate'; export * from './set'; export * from './uniqueId'; import ObjectId from "./ObjectId"; export { ObjectId -} \ No newline at end of file +} + +export * from './exceptions'; +export * from './merge/index'; \ No newline at end of file diff --git a/packages/support/src/objects/isCloneable.ts b/packages/support/src/objects/isCloneable.ts new file mode 100644 index 00000000..7996e72c --- /dev/null +++ b/packages/support/src/objects/isCloneable.ts @@ -0,0 +1,16 @@ +import { hasMethod } from "@aedart/support/reflections"; + +/** + * Determine if target object is cloneable. + * + * **Note**: _Method assumes that target is cloneable if it implements the + * [Cloneable]{@link import('@aedart/constracts/support/objects').Cloneable} interface._ + * + * @param {object} target + * + * @return {boolean} + */ +export function isCloneable(target: object): boolean +{ + return hasMethod(target, 'clone'); +} \ No newline at end of file diff --git a/packages/support/src/objects/isPopulatable.ts b/packages/support/src/objects/isPopulatable.ts new file mode 100644 index 00000000..81d8fa1b --- /dev/null +++ b/packages/support/src/objects/isPopulatable.ts @@ -0,0 +1,16 @@ +import { hasMethod } from "@aedart/support/reflections"; + +/** + * Determine if target is populatable + * + * **Note**: _Method assumes that target is populatable if it implements the + * [Populatable]{@link import('@aedart/constracts/support/objects').Populatable} interface._ + * + * @param {object} target + * + * @return {boolean} + */ +export function isPopulatable(target: object): boolean +{ + return hasMethod(target, 'populate'); +} \ No newline at end of file diff --git a/packages/support/src/objects/merge.ts b/packages/support/src/objects/merge.ts new file mode 100644 index 00000000..b7fa4b82 --- /dev/null +++ b/packages/support/src/objects/merge.ts @@ -0,0 +1,185 @@ +import type { ObjectsMerger } from "@aedart/contracts/support/objects"; +import Merger from "./merge/Merger"; + +/** + * Returns a new Object Merger instance + * + * @return {ObjectsMerger} + */ +export function merge(): ObjectsMerger; + +/** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * + * @param {SourceA} a + * + * @returns {SourceA} + * + * @throws {MergeError} + */ +export function merge< + SourceA extends object +>(a: SourceA): SourceA; + +/** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * + * @param {SourceA} a + * @param {SourceB} b + * + * @returns {SourceA & SourceB} + * + * @throws {MergeError} + */ +export function merge< + SourceA extends object, + SourceB extends object, +>(a: SourceA, b: SourceB): SourceA & SourceB; + +/** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * @template SourceC extends object + * + * @param {SourceA} a + * @param {SourceB} b + * @param {SourceC} c + * + * @returns {SourceA & SourceB & SourceC} + * + * @throws {MergeError} + */ +export function merge< + SourceA extends object, + SourceB extends object, + SourceC extends object, +>(a: SourceA, b: SourceB, c: SourceC): SourceA & SourceB & SourceC; + +/** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * @template SourceC extends object + * @template SourceD extends object + * + * @param {SourceA} a + * @param {SourceB} b + * @param {SourceC} c + * @param {SourceD} d + * + * @returns {SourceA & SourceB & SourceC & SourceD} + * + * @throws {MergeError} + */ +export function merge< + SourceA extends object, + SourceB extends object, + SourceC extends object, + SourceD extends object, +>(a: SourceA, b: SourceB, c: SourceC, d: SourceD): SourceA & SourceB & SourceC & SourceD; + +/** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * @template SourceC extends object + * @template SourceD extends object + * @template SourceE extends object + * + * @param {SourceA} a + * @param {SourceB} b + * @param {SourceC} c + * @param {SourceD} d + * @param {SourceE} e + * + * @returns {SourceA & SourceB & SourceC & SourceD & SourceE} + * + * @throws {MergeError} + */ +export function merge< + SourceA extends object, + SourceB extends object, + SourceC extends object, + SourceD extends object, + SourceE extends object, +>(a: SourceA, b: SourceB, c: SourceC, d: SourceD, e: SourceE): SourceA & SourceB & SourceC & SourceD & SourceE; + +/** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @template SourceA extends object + * @template SourceB extends object + * @template SourceC extends object + * @template SourceD extends object + * @template SourceE extends object + * @template SourceF extends object + * + * @param {SourceA} a + * @param {SourceB} b + * @param {SourceC} c + * @param {SourceD} d + * @param {SourceE} e + * @param {SourceF} f + * + * @returns {SourceA & SourceB & SourceC & SourceD & SourceE & SourceF} + * + * @throws {MergeError} + */ +export function merge< + SourceA extends object, + SourceB extends object, + SourceC extends object, + SourceD extends object, + SourceE extends object, + SourceF extends object, +>(a: SourceA, b: SourceB, c: SourceC, d: SourceD, e: SourceE, f: SourceF): SourceA & SourceB & SourceC & SourceD & SourceE & SourceF; + +/** + * Returns a merger of given source objects + * + * **Note**: _This method is responsible for returning [deep copy]{@link https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy} + * of all given sources._ + * + * @param {....object} [sources] + * + * @return {ObjectsMerger|object} + * + * @throws {MergeError} + */ +export function merge(...sources: object[]) +{ + const merger: ObjectsMerger = new Merger(); + + if (sources.length == 0) { + return merger; + } + + return merger.of(...sources); +} \ No newline at end of file diff --git a/packages/support/src/objects/merge/DefaultMergeOptions.ts b/packages/support/src/objects/merge/DefaultMergeOptions.ts new file mode 100644 index 00000000..a96776a3 --- /dev/null +++ b/packages/support/src/objects/merge/DefaultMergeOptions.ts @@ -0,0 +1,196 @@ +import type { + MergeCallback, + MergeOptions, + SkipKeyCallback +} from "@aedart/contracts/support/objects"; +import { DEFAULT_MAX_MERGE_DEPTH } from "@aedart/contracts/support/objects"; +import { MergeError } from "../exceptions"; +import { defaultMergeCallback } from "./defaultMergeCallback"; +import { makeSkipCallback } from "./makeSkipCallback"; +import { populate } from "@aedart/support/objects"; + +/** + * Default Merge Options + * + * @see MergeOptions + */ +export default class DefaultMergeOptions implements MergeOptions +{ + /** + * The maximum merge depth + * + * **Note**: _Value must be greater than or equal zero._ + * + * **Note**: _Defaults to [DEFAULT_MAX_MERGE_DEPTH]{@link import('@aedart/contracts/support/objects').DEFAULT_MAX_MERGE_DEPTH} + * when not specified._ + * + * @type {number} + */ + depth: number = DEFAULT_MAX_MERGE_DEPTH; + + /** + * Property Keys that must not be merged. + * + * **Note**: [DANGEROUS_PROPERTIES]{@link import('@aedart/contracts/support/objects').DANGEROUS_PROPERTIES} + * are always skipped, regardless of specified keys._ + * + * **Callback**: _A callback can be specified to determine if a given key, + * in a source object should be skipped._ + * + * **Example:** + * ```js + * const a = { 'foo': true }; + * const b = { 'bar': true, 'zar': true }; + * + * merge().using({ skip: [ 'zar' ] }).of(a, b); // { 'foo': true, 'bar': true } + * + * merge().using({ skip: (key, source) => { + * return key === 'bar' && Reflect.has(source, key); + * } }).of(a, b); // { 'foo': true, 'zar': true } + * ``` + * + * @type {PropertyKey[] | SkipKeyCallback} + */ + skip: PropertyKey[] | SkipKeyCallback = []; + + /** + * Flag, overwrite property values with `undefined`. + * + * **When `true` (_default behaviour_)**: _If an existing property value is not `undefined`, it will be overwritten + * with new value, even if the new value is `undefined`._ + * + * **When `false`**: _If an existing property value is not `undefined`, it will NOT be overwritten + * with new value, if the new value is `undefined`._ + * + * **Example:** + * ```js + * const a = { 'foo': true }; + * const b = { 'foo': undefined }; + * + * merge(a, b); // { 'foo': undefined } + * + * merge().using({ overwriteWithUndefined: false }).of(a, b) // { 'foo': true } + * ``` + * + * @type {boolean} + */ + overwriteWithUndefined: boolean = true; + + /** + * Flag, if source object is [`Cloneable`]{@link import('@aedart/contracts/support/objects').Cloneable}, then the + * resulting object from the `clone()` method is used. + * + * **When `true` (_default behaviour_)**: _If source object is cloneable then the resulting object from `clone()` + * method is used. Its properties are then iterated by the merge function._ + * + * **When `false`**: _Cloneable objects are treated like any other objects, the `clone()` method is ignored._ + * + * **Example:** + * ```js + * const a = { 'foo': { 'name': 'John Doe' } }; + * const b = { 'foo': { + * 'name': 'Jane Doe', + * clone() { + * return { + * 'name': 'Rick Doe', + * 'age': 26 + * } + * } + * } }; + * + * merge(a, b); // { 'foo': { 'name': 'Rick Doe', 'age': 26 } } + * + * merge().using({ useCloneable: false }).of(a, b); // { 'foo': { 'name': 'Jane Doe', clone() {...} } } + * ``` + * + * @see [`Cloneable`]{@link import('@aedart/contracts/support/objects').Cloneable} + * + * @type {boolean} + */ + useCloneable: boolean = true; + + /** + * Flag, whether to merge array, array-like, and [concat spreadable]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/isConcatSpreadable} + * properties or not. + * + * **When `true`**: _existing property is merged with new property value._ + * + * **When `false` (_default behaviour_)**: _existing property is overwritten with new property value_ + * + * **Example:** + * ```js + * const a = { 'foo': [ 1, 2, 3 ] }; + * const b = { 'foo': [ 4, 5, 6 ] }; + * + * merge([ a, b ]); // { 'foo': [ 4, 5, 6 ] } + * merge([ a, b ], { mergeArrays: true }); // { 'foo': [ 1, 2, 3, 4, 5, 6 ] } + * ``` + * + * **Note**: _`String()` (object) and [Typed Arrays]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray} + * are not merged, even though they are considered to be "array-like" (they offer a `length` property). + * You need to manually handle these, via a custom [callback]{@link MergeCallback}, if such value types must be merged._ + * + * @see [merge (array)]{@link import('@aedart/support/arrays').merge} + * + * @type {boolean} + */ + mergeArrays: boolean = false; + + /** + * The merge callback that must be applied + * + * **Note**: _When no callback is provided, then the merge function's default + * callback is used._ + * + * @type {MergeCallback} + */ + callback: MergeCallback; + + /** + * Creates a new Merge Options instance + * + * @param {MergeCallback | MergeOptions} [options] + */ + public constructor(options?: MergeCallback | MergeOptions) { + // Merge provided options, if any given + if (options && typeof options == 'object') { + populate(this, options); + } + + // Abort in case of invalid maximum depth - other options can also be asserted, but they are less important. + // The Browser / Node.js Engine will throw an error in case that they maximum recursion level is reached! + if (this.depth < 0) { + throw new MergeError('Invalid maximum "depth" merge option value', { + cause: { + options: this + } + }); + } + + // Resolve merge callback + this.callback = (options && typeof options == 'function') + ? options + : defaultMergeCallback + + // Resolve skip callback + if (typeof this.skip != 'function') { + this.skip = makeSkipCallback(this.skip as PropertyKey[]); + } + } + + /** + * Create new default merge options from given options + * + * @param {MergeCallback | MergeOptions} [options] + * + * @return {DefaultMergeOptions} + * + * @throws {MergeError} + */ + public static from(options?: MergeCallback | MergeOptions): Readonly + { + const resoled = new this(options); + + return Object.freeze(resoled) as Readonly; + } +} \ No newline at end of file diff --git a/packages/support/src/objects/merge/Merger.ts b/packages/support/src/objects/merge/Merger.ts new file mode 100644 index 00000000..47dd696b --- /dev/null +++ b/packages/support/src/objects/merge/Merger.ts @@ -0,0 +1,286 @@ +import type { + Cloneable, + MergeCallback, + MergeOptions, + MergeSourceInfo, + NextCallback, + SkipKeyCallback, + ObjectsMerger +} from "@aedart/contracts/support/objects"; +import DefaultMergeOptions from "./DefaultMergeOptions"; +import { MergeError } from "../exceptions"; +import { getErrorMessage } from "@aedart/support/exceptions"; +import { isCloneable } from "@aedart/support/objects"; +import { descTag } from "@aedart/support/misc"; +import { isKeyUnsafe } from "@aedart/support/reflections"; + +/** + * Objects Merger + * + * @see ObjectsMerger + */ +export default class Merger implements ObjectsMerger +{ + /** + * The merge options to be applied + * + * @private + * + * @type {Readonly} + */ + #options: Readonly; + + /** + * Callback to perform the merging of nested objects. + * + * @private + * + * @type {NextCallback} + */ + readonly #next: NextCallback; + + /** + * Create a new objects merger instance + * + * @param {MergeCallback | MergeOptions} [options] + * + * @throws {MergeError} + */ + public constructor(options?: MergeCallback | MergeOptions) { + // @ts-expect-error Need to init options, however they are resolved via "using". + this.#options = null; + this.#next = this.merge; + + this.using(options); + } + + /** + * Returns the merge options that are to be applied + * + * @return {Readonly} + */ + get options(): Readonly + { + return this.#options; + } + + /** + * Returns the "next" callback that performs merging of nested objects. + */ + get nextCallback(): NextCallback + { + return this.#next; + } + + /** + * Use the following merge options or merge callback + * + * @param {MergeCallback | MergeOptions} [options] + * + * @return {this} + * + * @throws {MergeError} + */ + public using(options?: MergeCallback | MergeOptions): this + { + this.#options = this.resolveOptions(options); + + return this; + } + + public of(...sources: object[]): object + { + try { + return this.nextCallback(sources, this.options, 0); + } catch (error) { + if (error instanceof MergeError) { + // @ts-expect-error Error SHOULD have a cause object set - support by all browsers now! + error.cause.sources = sources; + // @ts-expect-error Error SHOULD have a cause object set - support by all browsers now! + error.cause.options = this.options; + + throw error; + } + + const reason: string = getErrorMessage(error); + throw new MergeError(`Unable to merge objects: ${reason}`, { + cause: { + previous: error, + sources: sources, + options: this.options + } + }); + } + } + + /** + * Merge given source objects into a single object + * + * @param {object[]} sources + * @param {Readonly} options + * @param {number} [depth] Current recursion depth + * + * @return {object} + * + * @throws {MergeError} + */ + public merge(sources: object[], options: Readonly, depth: number = 0): object + { + // Abort if maximum depth has been reached + this.assertMaxDepthNotExceeded(depth, sources, options); + + // Resolve callbacks to apply + const nextCallback: NextCallback = this.nextCallback.bind(this); + const skipCallback: SkipKeyCallback = (options.skip as SkipKeyCallback).bind(this); + const mergeCallback: MergeCallback = (options.callback as MergeCallback).bind(this); + + // Loop through the sources and merge them into a single object + return sources.reduce((result: object, source: object, index: number) => { + // Abort if source is invalid... + this.assertSourceObject(source, index, depth); + + // If allowed and source implements "Cloneable" interface, favour "clone()" method's resulting object. + const resolved: object = this.resolveSourceObject(source, options); + + // Loop through all the source's properties, including symbols + const keys: PropertyKey[] = Reflect.ownKeys(resolved); + for (const key of keys){ + // Skip key if needed ... + if (isKeyUnsafe(key) || skipCallback(key, resolved, result)) { + continue; + } + + // Resolve the value via callback and set it in resulting object. + // @ts-expect-error Safe to set the value in result object! + result[key] = mergeCallback( + { + result, + key, + // @ts-expect-error Value can be of any type + value: resolved[key], + source: resolved, + sourceIndex: index, + depth: depth, + } as MergeSourceInfo, + nextCallback, + options + ); + } + + return result; + }, Object.create(null)); + } + + /** + * Resolves the source object + * + * @param {object} source + * @param {MergeOptions} options + * + * @protected + * + * @return {object} + */ + protected resolveSourceObject(source: object, options: MergeOptions): object + { + let output: object = source; + if (options.useCloneable && isCloneable(source)) { + output = this.cloneSource(source as Cloneable); + } + + return output; + } + + /** + * Invokes the "clone()" method on given cloneable object + * + * @param {Cloneable} source + * + * @protected + * + * @return {object} + * + * @return {MergeError} If unable to + */ + protected cloneSource(source: Cloneable): object + { + const clone: object = source.clone(); + + // Abort if resulting value from "clone()" is not a valid value... + if (!clone || typeof clone != 'object' || Array.isArray(clone)) { + throw new MergeError(`Expected clone() method to return object for source, ${descTag(clone)} was returned`, { + cause: { + source: source, + clone: clone, + } + }); + } + + return clone; + } + + /** + * Resolves provided merge options + * + * @param {MergeCallback | MergeOptions} [options] + * + * @protected + * + * @return {Readonly} + * + * @throws {MergeError} + */ + protected resolveOptions(options?: MergeCallback | MergeOptions): Readonly + { + return DefaultMergeOptions.from(options); + } + + /** + * Assert that current recursion depth has now exceeded the maximum depth + * + * @param {number} currentDepth + * @param {object[]} sources + * @param {MergeOptions} [options] Defaults to this Merger's options when none given + * + * @protected + * + * @throws {MergeError} + */ + protected assertMaxDepthNotExceeded(currentDepth: number, sources: object[], options?: MergeOptions): void + { + const max: number | undefined = options?.depth || this.options.depth; + + if (max && currentDepth > max) { + throw new MergeError(`Maximum merge depth (${max}) has been exceeded`, { + cause: { + source: sources, + depth: currentDepth + } + }); + } + } + + /** + * Assert given source is a valid object + * + * @param {unknown} source + * @param {number} index + * @param {number} currentDepth + * + * @protected + * + * @throws {MergeError} + */ + protected assertSourceObject(source: unknown, index: number, currentDepth: number): void + { + if (!source || typeof source != 'object' || Array.isArray(source)) { + throw new MergeError(`Unable to merge source of invalid type "${descTag(source)}" (source index: ${index})`, { + cause: { + source: source, + index: index, + depth: currentDepth + } + }); + } + } +} \ No newline at end of file diff --git a/packages/support/src/objects/merge/canCloneUsingStructuredClone.ts b/packages/support/src/objects/merge/canCloneUsingStructuredClone.ts new file mode 100644 index 00000000..da1a5f08 --- /dev/null +++ b/packages/support/src/objects/merge/canCloneUsingStructuredClone.ts @@ -0,0 +1,41 @@ +import type { Constructor } from "@aedart/contracts"; +import { TYPED_ARRAY_PROTOTYPE } from "@aedart/contracts/support/reflections"; + +/** + * Determine if an object value can be cloned via `structuredClone()` + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/structuredClone + * + * @internal + * + * @param {object} value + * + * @return {boolean} + */ +export function canCloneUsingStructuredClone(value: object): boolean +{ + const supported = [ + // Array, // Handled by array, with evt. array value merges + ArrayBuffer, + Boolean, + DataView, + Date, + Error, + Map, + Number, + // Object, // Handled by "basic" objects merging... + // (Primitive Types), // Also handled elsewhere... + RegExp, + Set, + String, + TYPED_ARRAY_PROTOTYPE + ]; + + for (const constructor of supported) { + if (value instanceof (constructor as Constructor)) { + return true; + } + } + + return false; +} \ No newline at end of file diff --git a/packages/support/src/objects/merge/defaultMergeCallback.ts b/packages/support/src/objects/merge/defaultMergeCallback.ts new file mode 100644 index 00000000..a2fb4e48 --- /dev/null +++ b/packages/support/src/objects/merge/defaultMergeCallback.ts @@ -0,0 +1,134 @@ +import { MergeCallback, MergeOptions, MergeSourceInfo, NextCallback } from "@aedart/contracts/support/objects"; +import { isConcatSpreadable, isSafeArrayLike, merge as mergeArrays } from "@aedart/support/arrays"; +import { canCloneUsingStructuredClone, MergeError } from "@aedart/support/objects"; +import { isWeakKind } from "@aedart/support/reflections"; +import { descTag } from "@aedart/support/misc"; + +/** + * The default merge callback + * + * @type {MergeCallback} + */ +export const defaultMergeCallback: MergeCallback = function(target: MergeSourceInfo, next: NextCallback, options: Readonly): any /* eslint-disable-line @typescript-eslint/no-explicit-any */ +{ + const { + result, + key, + value, + source, + sourceIndex, + depth + } = target; + + // Determine if result contains key + const hasExisting: boolean = Reflect.has(result, key); + + // The existing value, if any + // @ts-expect-error Existing value can be of any type here... + const existingValue: any /* eslint-disable-line @typescript-eslint/no-explicit-any */ = result[key]; + + // Determine the type and resolve value based on it... + const type: string = typeof value; + + switch (type) { + + // -------------------------------------------------------------------------------------------------------- // + // Primitives + // @see https://developer.mozilla.org/en-US/docs/Glossary/Primitive + + case 'undefined': + // Do not overwrite existing value with `undefined`, if options do not allow it... + if (value === undefined + && options.overwriteWithUndefined === false + && hasExisting + && existingValue !== undefined + ) { + return existingValue; + } + + return value; + + case 'string': + case 'number': + case 'bigint': + case 'boolean': + case 'symbol': + return value; + + // -------------------------------------------------------------------------------------------------------- // + // Functions + + case 'function': + return value; + + // -------------------------------------------------------------------------------------------------------- // + // Null, Arrays and Objects + case 'object': + // Null (primitive) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + if (value === null) { + return value; + } + + // Arrays - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + const isArray: boolean = Array.isArray(value); /* eslint-disable-line no-case-declarations */ + + if (isArray || isConcatSpreadable(value) || isSafeArrayLike(value)) { + + // If required to merge with existing value, if one exists... + if (options.mergeArrays === true + && hasExisting + && (isArray || Array.isArray(existingValue)) + ) { + // If either existing or new value is of the type array, merge values into + // a new array. + return mergeArrays(existingValue, value); + } else if (isArray) { + // When not requested merged, just overwrite existing value with a new array, + // if new value is an array. + return mergeArrays(value); + } + + // For concat spreadable objects or array-like objects, the "basic object" merge logic + // will deal with them. + } + + // Objects (of "native" kind) - - - - - - - - - - - - - - - - - - - - - - - - + // Clone the object of a "native" kind value, if supported. + if (canCloneUsingStructuredClone(value)) { + return structuredClone(value); + } + + // Objects (WeakRef, WeakMap and WeakSet) - - - - - - - - - - - - - - - - - - + // "Weak Reference" kind of objects cannot, nor should they, be cloned. + if (isWeakKind(value)) { + return value; + } + + // Objects (basic)- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // Merge with existing, if existing value is not null... + if (hasExisting + && typeof existingValue == 'object' + && existingValue !== null + && !(Array.isArray(existingValue)) + ) { + return next([ existingValue, value ], options, depth + 1); + } + + // Otherwise, create a new object and merge it. + return next([ Object.create(null), value ], options, depth + 1); + + // -------------------------------------------------------------------------------------------------------- // + // If for some reason this point is reached, it means that we are unable to merge "something". + default: + throw new MergeError(`Unable to merge value of type ${type} (${descTag(value)}) at source index ${sourceIndex}`, { + cause: { + key: key, + value: value, + source: source, + sourceIndex: sourceIndex, + depth: depth, + options: options + } + }); + } +}; \ No newline at end of file diff --git a/packages/support/src/objects/merge/index.ts b/packages/support/src/objects/merge/index.ts new file mode 100644 index 00000000..9086a88f --- /dev/null +++ b/packages/support/src/objects/merge/index.ts @@ -0,0 +1,10 @@ +import DefaultMergeOptions from "./DefaultMergeOptions"; +import Merger from "./Merger"; +export { + DefaultMergeOptions, + Merger +} + +export * from './canCloneUsingStructuredClone'; +export * from './defaultMergeCallback'; +export * from './makeSkipCallback'; \ No newline at end of file diff --git a/packages/support/src/objects/merge/makeSkipCallback.ts b/packages/support/src/objects/merge/makeSkipCallback.ts new file mode 100644 index 00000000..8a7049f0 --- /dev/null +++ b/packages/support/src/objects/merge/makeSkipCallback.ts @@ -0,0 +1,19 @@ +import type { SkipKeyCallback } from "@aedart/contracts/support/objects"; + +/** + * Returns a new skip callback for given property keys + * + * @param {PropertyKey[]} keys + * + * @return {SkipKeyCallback} + */ +export function makeSkipCallback(keys: PropertyKey[]): SkipKeyCallback +{ + return ( + key: PropertyKey, + source: object, + result: object /* eslint-disable-line @typescript-eslint/no-unused-vars */ + ) => { + return keys.includes(key) && Reflect.has(source, key); + } +} \ No newline at end of file diff --git a/packages/support/src/objects/populate.ts b/packages/support/src/objects/populate.ts new file mode 100644 index 00000000..d44f1ce6 --- /dev/null +++ b/packages/support/src/objects/populate.ts @@ -0,0 +1,70 @@ +import {isKeySafe} from "@aedart/support/reflections"; +import type {SourceKeysCallback} from "@aedart/contracts/support/objects"; + +/** + * Populate target object with the properties from source object + * + * **Warning**: _This method performs a shallow copy of properties in source object!_ + * + * **Warning**: _`target` object is mutated!_ + * + * **Note**: _Properties that are [unsafe]{@link import('@aedart/support/reflections').isKeyUnsafe} are always disregarded!_ + * + * @template TargetObj extends object = object + * @template SourceObj extends object = object + * + * @param {object} target + * @param {object} source + * @param {PropertyKey | PropertyKey[] | SourceKeysCallback} [keys='*'] Keys to select and copy from `source` object. + * If wildcard (`*`) given, then all properties from the `source` + * are selected. If a callback is given, then that callback must return + * key or keys to select from `source`. + * @param {boolean} [safe=true] When `true`, properties must exist in target (_must be defined in target_), + * before they are shallow copied. + * + * @returns {object} The populated target + * + * @throws {TypeError} If a key does not exist in `target` (_when `safe = true`_). + * Or, if key does not exist in `source` (_regardless of `safe` flag_). + */ +export function populate< + TargetObj extends object = object, + SourceObj extends object = object +>( + target: TargetObj, + source: SourceObj, + keys: PropertyKey | PropertyKey[] | SourceKeysCallback = '*', + safe: boolean = true +): TargetObj +{ + if (keys === '*') { + keys = Reflect.ownKeys(source); + } else if (typeof keys == 'function') { + keys = (keys as SourceKeysCallback)(source, target); + } + + if (!Array.isArray(keys)) { + keys = [ keys as PropertyKey ]; + } + + // Always remove dangerous keys, regardless of "safe" flag. + keys = (keys as PropertyKey[]).filter((key: PropertyKey) => isKeySafe(key)); + + // Populate... + for (const key of keys) { + // If "safe" is enabled, then only keys that are already defined in target are allowed. + if (safe && !Reflect.has(target, key)) { + throw new TypeError(`Key "${key.toString()}" does not exist in target object`); + } + + // However, fail if property does not exist in source, regardless of "safe" flag. + if (!Reflect.has(source, key)) { + throw new TypeError(`Key "${key.toString()}" does not exist in source object`); + } + + // @ts-expect-error At this point, all should be safe... + target[key] = source[key]; + } + + return target; +} \ No newline at end of file diff --git a/packages/support/src/objects/set.ts b/packages/support/src/objects/set.ts index 63bddb0f..566d3bda 100644 --- a/packages/support/src/objects/set.ts +++ b/packages/support/src/objects/set.ts @@ -6,7 +6,7 @@ import { set as _set } from 'lodash-es'; /** * Set value in object at given path - * (Alias for Lodash' {@link import('lodash').set set}) method + * (Alias for Lodash' [set]{@link import('lodash').set}) method * * @type {{(object: T, path: Key, value: any): T, (object: object, path: Key, value: any): TResult}} */ diff --git a/packages/support/src/reflections/assertHasPrototypeProperty.ts b/packages/support/src/reflections/assertHasPrototypeProperty.ts new file mode 100644 index 00000000..cf5e0006 --- /dev/null +++ b/packages/support/src/reflections/assertHasPrototypeProperty.ts @@ -0,0 +1,18 @@ +import { hasPrototypeProperty } from "./hasPrototypeProperty"; + +/** + * Assert that given target object has a "prototype" property defined + * + * @see hasPrototypeProperty + * + * @param {object} target + * @param {string} [message] + * + * @throws {TypeError} If target object does not have "prototype" property + */ +export function assertHasPrototypeProperty(target: object, message: string = 'target object has no "prototype" property'): void +{ + if (!hasPrototypeProperty(target)) { + throw new TypeError(message); + } +} \ No newline at end of file diff --git a/packages/support/src/reflections/classLooksLike.ts b/packages/support/src/reflections/classLooksLike.ts new file mode 100644 index 00000000..e37c9df1 --- /dev/null +++ b/packages/support/src/reflections/classLooksLike.ts @@ -0,0 +1,59 @@ +import type { ClassBlueprint } from "@aedart/contracts/support/reflections"; +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { includesAll } from "@aedart/support/arrays"; +import { hasPrototypeProperty } from "./hasPrototypeProperty"; +import { classOwnKeys } from "./classOwnKeys"; + +/** + * Determine if target class look like given blueprint. + * + * @param {object} target + * @param {ClassBlueprint} blueprint + * + * @throws {TypeError} If target object does not have "prototype" property. Or, if blueprint does not contain at least + * one member or static member. + */ +export function classLooksLike(target: object, blueprint: ClassBlueprint): boolean +{ + if (!hasPrototypeProperty(target)) { + return false; + } + + // Abort if both blueprint does not define either members or static members property + const hasBlueprintStaticMembers: boolean = Reflect.has(blueprint, 'staticMembers'); + const hasBlueprintMembers: boolean = Reflect.has(blueprint, 'members'); + + if (!hasBlueprintStaticMembers && !hasBlueprintMembers) { + throw new TypeError('Blueprint must at least have a "members" or "staticMembers" property defined'); + } + + // Abort if both members and static members properties are empty + const amountStaticMembers = blueprint?.staticMembers?.length || 0; + const amountMembers = blueprint?.members?.length || 0; + + // Check for static members + let hasAllStaticMembers: boolean = false; + if (amountStaticMembers > 0) { + for (const staticMember of (blueprint.staticMembers as PropertyKey[])) { + if (!Reflect.has(target as object, staticMember)) { + return false; + } + } + + hasAllStaticMembers = true; + } + + // Check for members + if (amountMembers > 0) { + // We can return here, because static members have been checked and code aborted if a member + // was missing... + return includesAll( + classOwnKeys(target as ConstructorOrAbstractConstructor, true), + (blueprint.members as PropertyKey[]) + ); + } + + // Otherwise, if there were any static members and all a present in target, then + // check passes. + return amountStaticMembers > 0 && hasAllStaticMembers; +} \ No newline at end of file diff --git a/packages/support/src/reflections/classOwnKeys.ts b/packages/support/src/reflections/classOwnKeys.ts new file mode 100644 index 00000000..e117df3d --- /dev/null +++ b/packages/support/src/reflections/classOwnKeys.ts @@ -0,0 +1,36 @@ +import type {ConstructorOrAbstractConstructor} from "@aedart/contracts"; +import { assertHasPrototypeProperty } from "@aedart/support/reflections/assertHasPrototypeProperty"; +import { getAllParentsOfClass } from "@aedart/support/reflections/getAllParentsOfClass"; + +/** + * Returns property keys that are defined target's prototype + * + * @param {ConstructorOrAbstractConstructor} target + * @param {boolean} [recursive=false] If `true`, then target's parent prototypes are traversed and all + * property keys are returned. + * + * @returns {PropertyKey[]} + * + * @throws {TypeError} If target object does not have "prototype" property + */ +export function classOwnKeys(target: ConstructorOrAbstractConstructor, recursive: boolean = false): PropertyKey[] +{ + assertHasPrototypeProperty(target); + + if (!recursive) { + return Reflect.ownKeys(target.prototype); + } + + // Obtain target's parent classes... + const parents = getAllParentsOfClass(target, true).reverse(); + + const ownKeys: Set = new Set(); + for (const parent of parents) { + const keys: PropertyKey[] = Reflect.ownKeys(parent.prototype); + for (const key of keys) { + ownKeys.add(key); + } + } + + return Array.from(ownKeys); +} \ No newline at end of file diff --git a/packages/support/src/reflections/getAllParentsOfClass.ts b/packages/support/src/reflections/getAllParentsOfClass.ts new file mode 100644 index 00000000..bb79372b --- /dev/null +++ b/packages/support/src/reflections/getAllParentsOfClass.ts @@ -0,0 +1,36 @@ +import { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { getParentOfClass } from "./getParentOfClass"; +import { isset } from "@aedart/support/misc"; + +/** + * Returns all parent classes of given target + * + * @see {getParentOfClass} + * + * @param {ConstructorOrAbstractConstructor} target The target class. + * @param {boolean} [includeTarget=false] If `true`, then given target is included in the output as the first element. + * + * @returns {ConstructorOrAbstractConstructor[]} List of parent classes, ordered by the top-most parent class first. + * + * @throws {TypeError} + */ +export function getAllParentsOfClass(target: ConstructorOrAbstractConstructor, includeTarget: boolean = false): ConstructorOrAbstractConstructor[] +{ + if (!isset(target)) { + throw new TypeError('getAllParentsOfClass() expects a target class as argument, undefined given'); + } + + const output: ConstructorOrAbstractConstructor[] = []; + if (includeTarget) { + output.push(target); + } + + let parent: ConstructorOrAbstractConstructor | null = getParentOfClass(target); + while (parent !== null) { + output.push(parent); + + parent = getParentOfClass(parent); + } + + return output; +} \ No newline at end of file diff --git a/packages/support/src/reflections/getClassPropertyDescriptor.ts b/packages/support/src/reflections/getClassPropertyDescriptor.ts new file mode 100644 index 00000000..839a1a3d --- /dev/null +++ b/packages/support/src/reflections/getClassPropertyDescriptor.ts @@ -0,0 +1,25 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { assertHasPrototypeProperty } from "./assertHasPrototypeProperty"; + +/** + * Returns a {@link PropertyDescriptor} object, from target's prototype that matches given property key + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/getOwnPropertyDescriptor + * + * @param {ConstructorOrAbstractConstructor} target Class that contains property in its prototype + * @param {PropertyKey} key Name of the property + * + * @return {PropertyDescriptor|undefined} Property descriptor or `undefined` if property does + * not exist in target's prototype. + * + * @throws {TypeError} If target is not an object or has no prototype + */ +export function getClassPropertyDescriptor(target: ConstructorOrAbstractConstructor, key: PropertyKey): PropertyDescriptor|undefined +{ + assertHasPrototypeProperty(target); + + return Reflect.getOwnPropertyDescriptor( + target.prototype, + key + ); +} \ No newline at end of file diff --git a/packages/support/src/reflections/getClassPropertyDescriptors.ts b/packages/support/src/reflections/getClassPropertyDescriptors.ts new file mode 100644 index 00000000..7a19f3d5 --- /dev/null +++ b/packages/support/src/reflections/getClassPropertyDescriptors.ts @@ -0,0 +1,60 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { getClassPropertyDescriptor } from "./getClassPropertyDescriptor"; +import { assertHasPrototypeProperty } from "./assertHasPrototypeProperty"; +import { getAllParentsOfClass } from "./getAllParentsOfClass"; +import { merge } from "@aedart/support/objects"; + +/** + * Returns all property descriptors that are defined target's prototype + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/getOwnPropertyDescriptor + * + * @param {ConstructorOrAbstractConstructor} target The target class + * @param {boolean} [recursive=false] If `true`, then target's parent prototypes are traversed. + * Descriptors are merged, such that the top-most class' descriptors + * are returned. + * + * @return {Record} Object with the property descriptors, or empty object of target has + * properties defined. + * + * @throws {TypeError} If target is not an object or has no prototype property + */ +export function getClassPropertyDescriptors(target: ConstructorOrAbstractConstructor, recursive: boolean = false): Record +{ + assertHasPrototypeProperty(target); + + // Define list of targets... + let targets = [target.prototype]; + + // Obtain target's parent classes, such that top-most descriptors can be returned, if needed. + if (recursive) { + targets = getAllParentsOfClass(target.prototype, true).reverse(); + } + + const output: Record = Object.create(null); + + // Obtain property descriptors for all targets + for (const t of targets) { + const keys: PropertyKey[] = Reflect.ownKeys(t); + for (const key of keys) { + const descriptor: PropertyDescriptor | undefined = getClassPropertyDescriptor(t.constructor, key); + + // If for some reason we are unable to obtain a descriptor, then skip it. + if (descriptor === undefined) { + continue; + } + + // Merge evt. existing descriptor object with the one obtained from target. + if (Reflect.has(output, key)) { + output[key] = merge() + .using({ overwriteWithUndefined: false }) + .of(output[key], descriptor); + continue; + } + + output[key] = descriptor; + } + } + + return output; +} \ No newline at end of file diff --git a/packages/support/src/reflections/getConstructorName.ts b/packages/support/src/reflections/getConstructorName.ts new file mode 100644 index 00000000..d9b20ee6 --- /dev/null +++ b/packages/support/src/reflections/getConstructorName.ts @@ -0,0 +1,23 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { isset } from "@aedart/support/objects"; + +/** + * Returns target class' constructor name, if available + * + * @param {ConstructorOrAbstractConstructor} target + * @param {string|null} [defaultValue=null] A default string value to return if target has no constructor name + * + * @return {string|null} Constructor name, or default value + */ +export function getConstructorName(target: ConstructorOrAbstractConstructor, defaultValue: string|null = null): string|null +{ + if (!isset(target, [ 'prototype', 'constructor', 'name' ])) { + return defaultValue; + } + + const name: string = target.prototype.constructor.name; + + return name.length > 0 + ? name + : defaultValue; +} \ No newline at end of file diff --git a/packages/support/src/reflections/getNameOrDesc.ts b/packages/support/src/reflections/getNameOrDesc.ts new file mode 100644 index 00000000..a6a865ce --- /dev/null +++ b/packages/support/src/reflections/getNameOrDesc.ts @@ -0,0 +1,23 @@ +import type {ConstructorOrAbstractConstructor} from "@aedart/contracts"; +import { descTag } from "@aedart/support/misc"; +import { getConstructorName } from "./getConstructorName"; + +/** + * Return target class' constructor name or default to target's description tag if a name is unavailable + * + * **Note**: _Method is a shortcut for the following:_ + * ```js + * getConstructorName(target, descTag(target)); + * ``` + * + * @see getConstructorName + * @see descTag + * + * @param {ConstructorOrAbstractConstructor} target + * + * @return {string} + */ +export function getNameOrDesc(target: ConstructorOrAbstractConstructor): string +{ + return getConstructorName(target, descTag(target)) as string; +} \ No newline at end of file diff --git a/packages/support/src/reflections/getParentOfClass.ts b/packages/support/src/reflections/getParentOfClass.ts new file mode 100644 index 00000000..c84acffb --- /dev/null +++ b/packages/support/src/reflections/getParentOfClass.ts @@ -0,0 +1,29 @@ +import { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { FUNCTION_PROTOTYPE } from "@aedart/contracts/support/reflections"; +import {isset} from "@aedart/support/misc"; + +/** + * Returns the parent class of given target class + * + * **Note**: _If target has a parent that matches + * [FUNCTION_PROTOTYPE]{@link import('@aedart/contracts/support/reflections').FUNCTION_PROTOTYPE}, then `null` is returned!_ + * + * @param {ConstructorOrAbstractConstructor} target The target class + * + * @returns {ConstructorOrAbstractConstructor | null} Parent class or `null`, if target has no parent class. + * + * @throws {TypeError} + */ +export function getParentOfClass(target: ConstructorOrAbstractConstructor): ConstructorOrAbstractConstructor | null +{ + if (!isset(target)) { + throw new TypeError('getParentOfClass() expects a target class as argument, undefined given'); + } + + const parent: object | null = Reflect.getPrototypeOf(target); + if (parent === FUNCTION_PROTOTYPE) { + return null; + } + + return parent as ConstructorOrAbstractConstructor; +} \ No newline at end of file diff --git a/packages/support/src/reflections/hasAllMethods.ts b/packages/support/src/reflections/hasAllMethods.ts new file mode 100644 index 00000000..0f960ab8 --- /dev/null +++ b/packages/support/src/reflections/hasAllMethods.ts @@ -0,0 +1,24 @@ +import { isset } from "@aedart/support/misc"; + +/** + * Determine if given target object contains all given methods + * + * @param {object} target + * @param {...PropertyKey} [methods] + * + * @return {boolean} + */ +export function hasAllMethods(target: object, ...methods: PropertyKey[]): boolean +{ + if (!isset(target) || typeof target != 'object' || Array.isArray(target) || methods.length === 0) { + return false; + } + + for (const method of methods) { + if (!Reflect.has(target, method) || typeof (target as Record)[method] != 'function') { + return false; + } + } + + return true; +} \ No newline at end of file diff --git a/packages/support/src/reflections/hasMethod.ts b/packages/support/src/reflections/hasMethod.ts new file mode 100644 index 00000000..2d08635b --- /dev/null +++ b/packages/support/src/reflections/hasMethod.ts @@ -0,0 +1,14 @@ +import { hasAllMethods } from "./hasAllMethods"; + +/** + * Determine if given target object contains method + * + * @param {object} target + * @param {PropertyKey} method + * + * @return {boolean} + */ +export function hasMethod(target: object, method: PropertyKey): boolean +{ + return hasAllMethods(target, method); +} \ No newline at end of file diff --git a/packages/support/src/reflections/hasPrototypeProperty.ts b/packages/support/src/reflections/hasPrototypeProperty.ts new file mode 100644 index 00000000..7d553e82 --- /dev/null +++ b/packages/support/src/reflections/hasPrototypeProperty.ts @@ -0,0 +1,22 @@ +import {isset} from "@aedart/support/misc"; + + +/** + * Determine if target object has a prototype property defined + * + * **Warning**: _This method is NOT the same as checking if {@link Reflect.getPrototypeOf} of an object is `null`!_ + * _The method literally checks if a "prototype" property is defined in target, that it is not `null` or `undefined`, + * and that its of the [type]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof} 'object'!_ + * + * **Note**: _Method returns `false` if `null` given as argument!_ + * + * @param {object} target + * + * @returns {boolean} + */ +export function hasPrototypeProperty(target: object): boolean +{ + return isset(target) + && typeof (target as Record)['prototype'] == 'object' + && (target as Record)['prototype'] !== null; +} \ No newline at end of file diff --git a/packages/support/src/reflections/index.ts b/packages/support/src/reflections/index.ts index 452ca9ca..9a784b0b 100644 --- a/packages/support/src/reflections/index.ts +++ b/packages/support/src/reflections/index.ts @@ -1,3 +1,20 @@ +export * from './assertHasPrototypeProperty'; +export * from './classLooksLike'; +export * from './classOwnKeys'; +export * from './getAllParentsOfClass'; +export * from './getClassPropertyDescriptor'; +export * from './getClassPropertyDescriptors'; +export * from './getConstructorName'; +export * from './getNameOrDesc'; +export * from './getParentOfClass'; +export * from './hasAllMethods'; +export * from './hasMethod'; +export * from './hasPrototypeProperty'; export * from './isCallable'; export * from './isClassConstructor'; -export * from './isConstructor'; \ No newline at end of file +export * from './isConstructor'; +export * from './isKeySafe'; +export * from './isKeyUnsafe'; +export * from './isSubclass'; +export * from './isSubclassOrLooksLike'; +export * from './isWeakKind'; \ No newline at end of file diff --git a/packages/support/src/reflections/isKeySafe.ts b/packages/support/src/reflections/isKeySafe.ts new file mode 100644 index 00000000..ce302d67 --- /dev/null +++ b/packages/support/src/reflections/isKeySafe.ts @@ -0,0 +1,13 @@ +import {isKeyUnsafe} from "@aedart/support/reflections/isKeyUnsafe"; + +/** + * Opposite of {@link isKeyUnsafe} + * + * @param {PropertyKey} key + * + * @returns {boolean} + */ +export function isKeySafe(key: PropertyKey): boolean +{ + return !isKeyUnsafe(key); +} \ No newline at end of file diff --git a/packages/support/src/reflections/isKeyUnsafe.ts b/packages/support/src/reflections/isKeyUnsafe.ts new file mode 100644 index 00000000..71d7c269 --- /dev/null +++ b/packages/support/src/reflections/isKeyUnsafe.ts @@ -0,0 +1,15 @@ +import { DANGEROUS_PROPERTIES } from "@aedart/contracts/support/objects"; + +/** + * Determine if property key is unsafe + * + * @see DANGEROUS_PROPERTIES + * + * @param {PropertyKey} key + * + * @returns {boolean} + */ +export function isKeyUnsafe(key: PropertyKey): boolean +{ + return DANGEROUS_PROPERTIES.includes(key); +} \ No newline at end of file diff --git a/packages/support/src/reflections/isSubclass.ts b/packages/support/src/reflections/isSubclass.ts new file mode 100644 index 00000000..b8e60d88 --- /dev/null +++ b/packages/support/src/reflections/isSubclass.ts @@ -0,0 +1,23 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import { hasPrototypeProperty } from "./hasPrototypeProperty"; + +/** + * Determine if target class is a subclass (_child class_) of given superclass (_parent class_) + * + * **Note**: _Method determines if target is a child of given superclass, by checking if the `target.prototype` + * is an instance of given superclass (`target.prototype instanceof superclass`) + * However, if given target or superclass does not have a prototype property, then `false` is returned._ + * + * @param {object} target + * @param {ConstructorOrAbstractConstructor} superclass + * + * @returns {boolean} `true` if target is a subclass of given superclass, `false` otherwise. + */ +export function isSubclass(target: object, superclass: ConstructorOrAbstractConstructor): boolean +{ + if (!hasPrototypeProperty(target) || !hasPrototypeProperty(superclass) || target === superclass) { + return false; + } + + return (target as Record).prototype instanceof superclass; +} \ No newline at end of file diff --git a/packages/support/src/reflections/isSubclassOrLooksLike.ts b/packages/support/src/reflections/isSubclassOrLooksLike.ts new file mode 100644 index 00000000..ef8d7e05 --- /dev/null +++ b/packages/support/src/reflections/isSubclassOrLooksLike.ts @@ -0,0 +1,27 @@ +import type { ConstructorOrAbstractConstructor } from "@aedart/contracts"; +import type { ClassBlueprint } from "@aedart/contracts/support/reflections"; +import { isSubclass } from "./isSubclass"; +import { classLooksLike } from "./classLooksLike"; + +/** + * Determine if target class is a subclass of given superclass, or if it looks like given blueprint + * + * **Note**: _Method is an alias for `isSubclass(target, superclass) || classLooksLike(target, blueprint)`._ + * + * @see isSubclass + * @see classLooksLike + * + * @param {object} target + * @param {ConstructorOrAbstractConstructor} superclass + * @param {ClassBlueprint} blueprint + * + * @throws {TypeError} + */ +export function isSubclassOrLooksLike( + target: object, + superclass: ConstructorOrAbstractConstructor, + blueprint: ClassBlueprint +): boolean +{ + return isSubclass(target, superclass) || classLooksLike(target, blueprint); +} \ No newline at end of file diff --git a/packages/support/src/reflections/isWeakKind.ts b/packages/support/src/reflections/isWeakKind.ts new file mode 100644 index 00000000..8c3e645f --- /dev/null +++ b/packages/support/src/reflections/isWeakKind.ts @@ -0,0 +1,15 @@ +/** + * Determine if object of a "weak" kind, e.g. `WeakRef`, `WeakMap` or `WeakSet` + * + * @param {object} value + * + * @return {boolean} + */ +export function isWeakKind(value: object): boolean +{ + return value && ( + value instanceof WeakRef + || value instanceof WeakMap + || value instanceof WeakSet + ); +} \ No newline at end of file diff --git a/tests/browser/packages/support/arrays/includes-any-all.test.js b/tests/browser/packages/support/arrays/includes-any-all.test.js new file mode 100644 index 00000000..3ba30b4f --- /dev/null +++ b/tests/browser/packages/support/arrays/includes-any-all.test.js @@ -0,0 +1,55 @@ +import { includesAll, includesAny } from "@aedart/support/arrays"; + +describe('@aedart/support/arrays', () => { + describe('includesAll()', () => { + + it('can determine if array includes all values', () => { + const data = [ + { + arr: [ 1, 2, 3], + values: [ 1, 2 ], + expected: true, + name: 'A' + }, + { + arr: [ 1, 2, 3], + values: [ 1, 4 ], + expected: false, + name: 'B' + }, + ]; + + for (const entry of data) { + expect(includesAll(entry.arr, entry.values)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + }); + }); + + describe('includesAny()', () => { + + it('can determine if array includes any (some) values', () => { + const data = [ + { + arr: [ 1, 2, 3], + values: [ 4, 2 ], + expected: true, + name: 'A' + }, + { + arr: [ 1, 2, 3], + values: [ 4, 5 ], + expected: false, + name: 'B' + }, + ]; + + for (const entry of data) { + expect(includesAny(entry.arr, entry.values)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/arrays/isArrayLike.test.js b/tests/browser/packages/support/arrays/isArrayLike.test.js new file mode 100644 index 00000000..a410de6d --- /dev/null +++ b/tests/browser/packages/support/arrays/isArrayLike.test.js @@ -0,0 +1,74 @@ +import { isArrayLike, isSafeArrayLike } from "@aedart/support/arrays"; + +describe('@aedart/support/arrays', () => { + describe('isArrayLike()', () => { + + it('can determine if is array-like', () => { + + const dataSet = [ + { value: [], expected: true, name: 'Array' }, + { value: 'abc', expected: true, name: 'string' }, + { value: { length: 0 }, expected: true, name: 'Object (with length property)' }, + + // Well, String() object does have a `length` property + { value: new String('abc'), expected: true, name: 'String (object)' }, + + // TypeArrays have a length property too! + { value: new Int8Array(), expected: true, name: 'TypedArray' }, + + // -------------------------------------------------------------------------------- // + // These should never be considered array-like... + + { value: new Boolean(true), expected: false, name: 'Boolean' }, + { value: new Number(123), expected: false, name: 'Number' }, + { value: {}, expected: false, name: 'Object (without length property)' }, + { value: new Map(), expected: false, name: 'Map' }, + { value: new Set(), expected: false, name: 'Set' }, + { value: function() {}, expected: false, name: 'Function' }, + { value: new Date(), expected: false, name: 'Date' }, + { value: new ArrayBuffer(2), expected: false, name: 'ArrayBuffer' }, + { value: new DataView(new ArrayBuffer(2)), expected: false, name: 'DataView' }, + { value: new RegExp(/ab/g), expected: false, name: 'RegExp' }, + ]; + + for (const data of dataSet) { + expect(isArrayLike(data.value)) + .withContext(`${data.name} was expected to ${data.expected.toString()}`) + .toBe(data.expected); + } + }); + + it('can determine if is "safe" array-like', () => { + + const dataSet = [ + { value: [], expected: true, name: 'Array' }, + { value: { length: 0 }, expected: true, name: 'Object (with length property)' }, + + // -------------------------------------------------------------------------------- // + { value: 'abc', expected: false, name: 'string' }, + { value: new String('abc'), expected: false, name: 'String (object)' }, + { value: new Int8Array(), expected: false, name: 'TypedArray' }, + + // -------------------------------------------------------------------------------- // + // These should never be considered array-like... + + { value: new Boolean(true), expected: false, name: 'Boolean' }, + { value: new Number(123), expected: false, name: 'Number' }, + { value: {}, expected: false, name: 'Object (without length property)' }, + { value: new Map(), expected: false, name: 'Map' }, + { value: new Set(), expected: false, name: 'Set' }, + { value: function() {}, expected: false, name: 'Function' }, + { value: new Date(), expected: false, name: 'Date' }, + { value: new ArrayBuffer(2), expected: false, name: 'ArrayBuffer' }, + { value: new DataView(new ArrayBuffer(2)), expected: false, name: 'DataView' }, + { value: new RegExp(/ab/g), expected: false, name: 'RegExp' }, + ]; + + for (const data of dataSet) { + expect(isSafeArrayLike(data.value)) + .withContext(`${data.name} was expected to ${data.expected.toString()}`) + .toBe(data.expected); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/arrays/isConcatSpreadable.test.js b/tests/browser/packages/support/arrays/isConcatSpreadable.test.js new file mode 100644 index 00000000..2656b843 --- /dev/null +++ b/tests/browser/packages/support/arrays/isConcatSpreadable.test.js @@ -0,0 +1,68 @@ +import { isConcatSpreadable } from "@aedart/support/arrays"; + +describe('@aedart/support/arrays', () => { + describe('isConcatSpreadable()', () => { + + it('can determine if object contains Symbol.isConcatSpreadable', () => { + + const concatSpreadableArr = [ 1, 2, 3 ]; + concatSpreadableArr[Symbol.isConcatSpreadable] = true; + + class A {} + + class B { + [Symbol.isConcatSpreadable] = false; + } + + const dataSet = [ + { value: null, expected: false, name: 'Null' }, + + // Now this is funny... an array does Symbol.isConcatSpreadable ! + { value: [ 1, 2, 3 ], expected: false, name: 'Array' }, + + { value: concatSpreadableArr, expected: true, name: 'Array with Symbol.isConcatSpreadable' }, + { + value: { + 0: 'a', + 1: 'b', + 2: 'c' + }, + expected: false, + name: 'Object without Symbol.isConcatSpreadable' + }, + { + value: { + [Symbol.isConcatSpreadable]: true, + //length: 3, // NOTE: length should be implemented when Symbol.isConcatSpreadable set to true! + 0: 'a', + 1: 'b', + 2: 'c' + }, + expected: true, + name: 'Object with Symbol.isConcatSpreadable' + }, + { + value: new A(), + expected: false, + name: 'Class instance without Symbol.isConcatSpreadable' + }, + { + value: new B(), + expected: true, + name: 'Class instance with Symbol.isConcatSpreadable' + }, + ]; + + for (const data of dataSet) { + const result = isConcatSpreadable(data.value); + + // Debug + // console.log('result', data.name, result); + + expect(result) + .withContext(`${data.name} was expected to ${data.expected.toString()}`) + .toBe(data.expected); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/arrays/isTypedArray.test.js b/tests/browser/packages/support/arrays/isTypedArray.test.js new file mode 100644 index 00000000..bca5ed04 --- /dev/null +++ b/tests/browser/packages/support/arrays/isTypedArray.test.js @@ -0,0 +1,36 @@ +import { isTypedArray } from "@aedart/support/arrays"; + +describe('@aedart/support/arrays', () => { + describe('isTypedArray()', () => { + + it('can determine if object is Typed Array', () => { + const dataSet = [ + { value: undefined, expected: false, name: 'Undefined' }, + { value: null, expected: false, name: 'Null' }, + { value: [], expected: false, name: 'Array' }, + { value: {}, expected: false, name: 'Object (empty)' }, + { value: 123, expected: false, name: 'Number' }, + { value: 'foo', expected: false, name: 'String' }, + { value: new Map(), expected: false, name: 'Map' }, + + { value: new Int8Array(), expected: true, name: 'Int8Array' }, + { value: new Uint8Array(), expected: true, name: 'Uint8Array' }, + { value: new Uint8ClampedArray(), expected: true, name: 'Uint8ClampedArray' }, + { value: new Int16Array(), expected: true, name: 'Int16Array' }, + { value: new Uint16Array(), expected: true, name: 'Uint16Array' }, + { value: new Int32Array(), expected: true, name: 'Int32Array' }, + { value: new Uint32Array(), expected: true, name: 'Uint32Array' }, + { value: new Float32Array(), expected: true, name: 'Float32Array' }, + { value: new Float64Array(), expected: true, name: 'Float64Array' }, + { value: new BigInt64Array(), expected: true, name: 'BigInt64Array' }, + { value: new BigUint64Array(), expected: true, name: 'BigUint64Array' }, + ]; + + for (const data of dataSet) { + expect(isTypedArray(data.value)) + .withContext(`${data.name} was expected to ${data.expected.toString()}`) + .toBe(data.expected); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/arrays/merge.test.js b/tests/browser/packages/support/arrays/merge.test.js new file mode 100644 index 00000000..c25f5712 --- /dev/null +++ b/tests/browser/packages/support/arrays/merge.test.js @@ -0,0 +1,64 @@ +import { ArrayMergeError, merge } from "@aedart/support/arrays"; + +describe('@aedart/support/arrays', () => { + describe('merge()', () => { + + it('can merge multiple arrays', () => { + + const a = [ 1, 2, 3 ]; + const b = [ 4, 5, 6 ]; + const c = [ 7, 8, 9 ]; + + // --------------------------------------------------------------- // + + const result = merge(a, b, c); + + // Debug + // console.log('result', result); + + expect(result) + .toEqual([ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]); + }); + + it('does not shallow copy simple object values', () => { + + const objA = { foo: true }; + const objB = { bar: true }; + + const a = [ objA ]; + const b = [ objB ]; + + // --------------------------------------------------------------- // + + const result = merge(a, b); + + // Debug + // console.log('result', result); + + expect(result[0]) + .withContext('Object a is a shallow copy!') + .not + .toBe(objA); + + expect(result[1]) + .withContext('Object b is a shallow copy!') + .not + .toBe(objB); + }); + + it('fails when attempting to merge arrays with non-cloneable values', () => { + + const a = [ 1, 2, 3 ]; + const b = [ function() {} ]; + + // --------------------------------------------------------------- // + + const callback = () => { + merge(a, b); + } + + expect(callback) + .toThrowError(ArrayMergeError); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/AbstractConcern.test.js b/tests/browser/packages/support/concerns/AbstractConcern.test.js new file mode 100644 index 00000000..d59d9b3a --- /dev/null +++ b/tests/browser/packages/support/concerns/AbstractConcern.test.js @@ -0,0 +1,77 @@ +import { AbstractConcern } from "@aedart/support/concerns"; +import { PROVIDES } from "@aedart/contracts/support/concerns"; +import { AbstractClassError } from "@aedart/support/exceptions"; + +describe('@aedart/support/concerns', () => { + + describe('AbstractConcern', () => { + + it('fails when attempting to create direct instance of Abstract Concern', () => { + const callback = () => { + return new AbstractConcern(class {}); + } + + expect(callback) + .toThrowError(AbstractClassError); + }); + + it('can obtain concern owner instance', () => { + + class Owner {} + + class MyConcern extends AbstractConcern {} + + const owner = new Owner(); + const concern = new MyConcern(owner); + + const result = concern.concernOwner; + expect(result) + .toBe(owner); + }); + + it('defaults to concern instance when no owner given', () => { + + class MyConcern extends AbstractConcern {} + + const concern = new MyConcern(); + + const result = concern.concernOwner; + expect(result) + .toBe(concern); + }); + + it('can obtain provided properties and methods', () => { + + class MyConcern extends AbstractConcern { + foo() {} + + get bar() {} + } + + class MyOtherConcern extends MyConcern { + sayHi() {} + } + + // const concern = new MyConcern(); + + // --------------------------------------------------------------------------------------- // + + const resultA = MyOtherConcern[PROVIDES](); + const resultB = MyConcern[PROVIDES](); // First concern class + + // Debug + // console.log('result', resultA, resultB); + + // Note: Abstract Concern is NOT responsible for filtering out unsafe (ALWAYS_HIDDEN) + // properties and methods! + + expect(resultA) + .withContext('Incorrect properties for a') + .toEqual([ 'constructor', 'concernOwner', 'foo', 'bar', 'sayHi' ]); + + expect(resultB) + .withContext('Incorrect properties for b') + .toEqual([ 'constructor', 'concernOwner', 'foo', 'bar' ]); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/ConcernsContainer.test.js b/tests/browser/packages/support/concerns/ConcernsContainer.test.js new file mode 100644 index 00000000..3c4f36d7 --- /dev/null +++ b/tests/browser/packages/support/concerns/ConcernsContainer.test.js @@ -0,0 +1,393 @@ +import { + ConcernsContainer, + AbstractConcern, + NotRegisteredError, BootError +} from "@aedart/support/concerns"; +import { getNameOrDesc } from "@aedart/support/reflections"; + +describe('@aedart/support/concerns', () => { + describe('ConcernsContainer', () => { + + it('can create new instance', () => { + const owner = new class {}; + const container = new ConcernsContainer(owner, []); + + // ----------------------------------------------------------------------------------- // + + expect(container) + .toBeInstanceOf(ConcernsContainer); + + expect(container.size) + .withContext('No concerns should be available') + .toBe(0); + + expect(container.isEmpty()) + .withContext('Should be empty') + .toBeTrue(); + + expect(container.isNotEmpty()) + .withContext('Should be empty (via is not empty)') + .toBeFalse(); + + expect(container.owner) + .withContext('Incorrect owner') + .toBe(owner); + }); + + it('can create instance with concerns registered', () => { + + class A extends AbstractConcern {} + class B extends AbstractConcern {} + class C extends AbstractConcern {} + const concerns = [ A, B, C ]; + + const container = new ConcernsContainer(new class {}, concerns); + + // ----------------------------------------------------------------------------------- // + + expect(container.size) + .withContext('Incorrect size') + .toBe(concerns.length); + + expect(container.isEmpty()) + .withContext('Should NOT be empty') + .toBeFalse() + + expect(container.isNotEmpty()) + .withContext('Should NOT be empty (via is not empty)') + .toBeTrue() + }); + + it('can obtain all registered concern classes', () => { + + class A extends AbstractConcern {} + class B extends AbstractConcern {} + class C extends AbstractConcern {} + const concerns = [ A, B, C ]; + + const container = new ConcernsContainer(new class {}, concerns); + + // ----------------------------------------------------------------------------------- // + + const all = container.all(); + let c = 0; + for (const concern of all) { + expect(concerns.includes(concern)) + .withContext(`Unknown concern ${getNameOrDesc(concern)} in container`) + .toBeTrue(); + + c++; + } + + expect(c) + .withContext('Invalid amount of concern classes returned by all()') + .toBe(concerns.length) + }); + + it('can determine if concern class is registered', () => { + class A extends AbstractConcern {} + class B extends AbstractConcern {} + class C extends AbstractConcern {} + const concerns = [ A, B, C ]; + + const container = new ConcernsContainer(new class {}, concerns); + + // ----------------------------------------------------------------------------------- // + + // Determine if exists in container + for (const concern of concerns) { + expect(container.has(concern)) + .withContext(`${getNameOrDesc(concern)} SHOULD be in container`) + .toBeTrue(); + } + }); + + it('can boot concern', () => { + class A extends AbstractConcern {} + const owner = new class {}; + + const container = new ConcernsContainer(owner, [ A ]); + + // ----------------------------------------------------------------------------------- // + + expect(container.hasBooted(A)) + .withContext('Concern should not (yet) be booted') + .toBeFalse(); + + // ----------------------------------------------------------------------------------- // + + const concern = container.boot(A); + + expect(concern) + .withContext('Incorrect boot of concern class') + .toBeInstanceOf(A); + + expect(concern.concernOwner) + .withContext('Concern owner not set') + .toBe(owner); + + expect(container.hasBooted(A)) + .withContext('Concern SHOULD be booted') + .toBeTrue(); + }); + + it('fails booting concern if not registered', () => { + class A extends AbstractConcern {} + + const container = new ConcernsContainer(new class {}, []); + + // ----------------------------------------------------------------------------------- // + + const callback = () => { + return container.boot(A); + } + + // ----------------------------------------------------------------------------------- // + + expect(callback) + .toThrowError(NotRegisteredError); + }); + + it('fails booting concern if already booted', () => { + class A extends AbstractConcern {} + + const container = new ConcernsContainer(new class {}, [ A ]); + container.boot(A); + + // ----------------------------------------------------------------------------------- // + + const callback = () => { + // Concern should already be booted at this point and thus exception is expected + // to be thrown... + container.boot(A); + } + + // ----------------------------------------------------------------------------------- // + + expect(callback) + .toThrowError(BootError); + }); + + it('fails if concern throws exception when instantiated', () => { + const msg = 'Test'; + class A extends AbstractConcern { + constructor(owner) { + super(owner); + + throw new Error(msg); + } + } + + const container = new ConcernsContainer(new class {}, [ A ]); + + // ----------------------------------------------------------------------------------- // + + let wasThrown = false; + try { + container.boot(A); + } catch (error) { + wasThrown = true; + + // Debug + // console.log(error.toString()); + // console.log(error.cause); + + expect(error) + .withContext('Should be instance of BootError') + .toBeInstanceOf(BootError); + + expect(error?.cause?.previous) + .withContext('Custom error cause - previous - not set!') + .not + .toBeUndefined(); + + expect(error?.cause?.owner) + .withContext('Custom error cause - owner - not set!') + .not + .toBeUndefined(); + + expect(error?.cause?.concern) + .withContext('Custom error cause - concern - incorrect concern!') + .toBe(A); + } + + expect(wasThrown) + .withContext('Exception was expected thrown during concern initialisation') + .toBeTrue(); + }); + + it('can boot all concerns', () => { + + class A extends AbstractConcern {} + class B extends AbstractConcern {} + class C extends AbstractConcern {} + const concerns = [ A, B, C ]; + + const container = new ConcernsContainer(new class {}, concerns); + + // ----------------------------------------------------------------------------------- // + + container.bootAll(); + + // ----------------------------------------------------------------------------------- // + + for (const concern of concerns) { + expect(container.hasBooted(concern)) + .withContext(`${getNameOrDesc(concern)} not booted`) + .toBeTrue(); + } + }); + + it('boots concern when obtained via get()', () => { + class A extends AbstractConcern {} + const owner = new class {}; + + const container = new ConcernsContainer(owner, [ A ]); + + // ----------------------------------------------------------------------------------- // + + expect(container.hasBooted(A)) + .withContext('Concern should not (yet) be booted') + .toBeFalse(); + + // ----------------------------------------------------------------------------------- // + + const concern = container.get(A); + + expect(concern) + .withContext('Incorrect boot of concern class') + .toBeInstanceOf(A); + + expect(container.hasBooted(A)) + .withContext('Concern SHOULD be booted') + .toBeTrue(); + }); + + it('returns booted concern, if already booted, when obtained via get()', () => { + class A extends AbstractConcern {} + const container = new ConcernsContainer(new class {}, [ A ]); + + // ----------------------------------------------------------------------------------- // + + const previousBooted = container.boot(A); + + expect(container.hasBooted(A)) + .withContext('Concern SHOULD be booted') + .toBeTrue(); + + // ----------------------------------------------------------------------------------- // + + const concern = container.get(A); + expect(concern) + .withContext('Incorrect instance obtained') + .toBeInstanceOf(A); + + expect(concern) + .withContext('Concern instance SHOULD match previous booted instance') + .toBe(previousBooted); + }); + + it('fails obtaining concern instance via get(), if not registered', () => { + class A extends AbstractConcern {} + const container = new ConcernsContainer(new class {}, []); + + const callback = () => { + return container.get(A); + } + + expect(callback) + .toThrowError(NotRegisteredError); + }); + + it('can call method in concern instance', () => { + + let wasInvoked = false; + const values = [ 1, 2, 3 ]; + + class A extends AbstractConcern { + sum(...args) { + wasInvoked = true; + + return args.reduce((x, currentValue) => { + return x + currentValue + }, 0); + } + } + const container = new ConcernsContainer(new class {}, [ A ]); + + // ----------------------------------------------------------------------------------- // + + const result = container.call(A, 'sum', ...values); + const expected = 6; + + expect(wasInvoked) + .withContext('Method was not called') + .toBeTrue(); + + expect(result) + .withContext('Incorrect method return value') + .toEqual(expected); + }); + + it('can call method without return in concern instance', () => { + + let wasInvoked = false; + class A extends AbstractConcern { + foo() { + wasInvoked = true; + } + } + const container = new ConcernsContainer(new class {}, [ A ]); + + // ----------------------------------------------------------------------------------- // + + const result = container.call(A, 'foo'); + + expect(wasInvoked) + .withContext('Method was not called') + .toBeTrue(); + + expect(result) + .withContext('No return value was expected') + .toBeUndefined(); + }); + + it('can set and get property value in concern instance', () => { + + class A extends AbstractConcern { + + #msg = null; + + set message(value) + { + this.#msg = value; + } + + get message() + { + return this.#msg + } + } + const container = new ConcernsContainer(new class {}, [ A ]); + + // ----------------------------------------------------------------------------------- // + + const message = 'Hallo World'; + + container.setProperty(A, 'message', message); + const result = container.getProperty(A, 'message'); + + expect(result) + .withContext('Property either not set or incorrect value returned') + .toBe(message); + + // ----------------------------------------------------------------------------------- // + + const instance = container.get(A); + + expect(instance.message) + .withContext('Fatal: concern instance has changed, property value no longer retained') + .toEqual(message); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/ConcernsInjector.test.js b/tests/browser/packages/support/concerns/ConcernsInjector.test.js new file mode 100644 index 00000000..be841df5 --- /dev/null +++ b/tests/browser/packages/support/concerns/ConcernsInjector.test.js @@ -0,0 +1,18 @@ +import { ConcernsInjector } from "@aedart/support/concerns"; +import makeConcernsInjector from "./helpers/makeConcernsInjector"; + +describe('@aedart/support/concerns', () => { + describe('ConcernsInjector', () => { + + it('can make new instance', () => { + const injector = makeConcernsInjector(class {}); + + // Debug + // console.log('result', injector); + + expect(injector) + .toBeInstanceOf(ConcernsInjector); + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/ConfigurationFactory.test.js b/tests/browser/packages/support/concerns/ConfigurationFactory.test.js new file mode 100644 index 00000000..557a65a8 --- /dev/null +++ b/tests/browser/packages/support/concerns/ConfigurationFactory.test.js @@ -0,0 +1,247 @@ +import makeConfigurationFactory from "./helpers/makeConfigurationFactory"; +import {AbstractConcern, InjectionError, isUnsafeKey} from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('ConfigurationFactory', () => { + + it('fails when entry is neither a concern class or configuration', () => { + + const entry = {}; + + const factory = makeConfigurationFactory(); + + // ------------------------------------------------------------------ // + + const callback = () => factory.make(class {}, entry); + + expect(callback) + .toThrowError(InjectionError); + }); + + it('returns default configuration for a concern class', () => { + + class MyConcern extends AbstractConcern { + foo() {} + } + + const factory = makeConfigurationFactory(); + + // ------------------------------------------------------------------ // + + const configuration = factory.make(class {}, MyConcern); + + // Debug + // console.log('configuration', configuration); + + // concern + expect(Reflect.has(configuration, 'concern')) + .withContext('Concern property does not exist in configuration') + .toBeTrue(); + expect(configuration.concern) + .withContext('Incorrect concern class in configuration') + .toBe(MyConcern); + + // aliases + expect(Reflect.has(configuration, 'aliases')) + .withContext('Aliases property does not exist in configuration') + .toBeTrue(); + expect(configuration.aliases) + .withContext('Incorrect aliases object in configuration') + .toEqual({ 'foo': 'foo' }); + + // allowAliases + expect(Reflect.has(configuration, 'allowAliases')) + .withContext('AllowAliases property does not exist in configuration') + .toBeTrue(); + expect(configuration.allowAliases) + .withContext('Incorrect allowAliases value configuration') + .toBeTrue(); + }); + + it('populates aliases when not given in configuration', () => { + class MyConcern extends AbstractConcern { + zoom() {} + } + + const entry = { concern: MyConcern } + + const factory = makeConfigurationFactory(); + + // ------------------------------------------------------------------ // + + const configuration = factory.make(class {}, entry); + + // Debug + // console.log('configuration', configuration); + + // concern + expect(Reflect.has(configuration, 'concern')) + .withContext('Concern property does not exist in configuration') + .toBeTrue(); + expect(configuration.concern) + .withContext('Incorrect concern class in configuration') + .toBe(MyConcern); + + // aliases + expect(Reflect.has(configuration, 'aliases')) + .withContext('Aliases property does not exist in configuration') + .toBeTrue(); + expect(configuration.aliases) + .withContext('Incorrect aliases object in configuration') + .toEqual({ 'zoom': 'zoom' }); + + // allowAliases + expect(Reflect.has(configuration, 'allowAliases')) + .withContext('AllowAliases property does not exist in configuration') + .toBeTrue(); + expect(configuration.allowAliases) + .withContext('Incorrect allowAliases value configuration') + .toBeTrue(); + }); + + it('can specify custom aliases', () => { + class MyConcern extends AbstractConcern { + foo() {} + + bar() {} + } + + const aliases = { + 'foo': 'a', + 'bar': 'b' + }; + const entry = { concern: MyConcern, aliases: aliases }; + + const factory = makeConfigurationFactory(); + + // ------------------------------------------------------------------ // + + const configuration = factory.make(class {}, entry); + + // Debug + // console.log('configuration', configuration); + + expect(configuration.aliases) + .withContext('Incorrect aliases object in configuration') + .toEqual(aliases); + }); + + it('merges default aliases with custom aliases', () => { + class MyConcern extends AbstractConcern { + foo() {} + + bar() {} + } + + const aliases = { + 'bar': 'a' + }; + const entry = { concern: MyConcern, aliases: aliases }; + + const factory = makeConfigurationFactory(); + + // ------------------------------------------------------------------ // + + const configuration = factory.make(class {}, entry); + + // Debug + // console.log('configuration', configuration); + + expect(configuration.aliases) + .withContext('Incorrect aliases object in configuration') + .toEqual({ 'foo': 'foo', ...aliases }); + }); + + it('can disable aliases', () => { + class MyConcern extends AbstractConcern { + sayGoodBye() {} + + sayHi() {} + } + + + const entry = { concern: MyConcern, allowAliases: false }; + + const factory = makeConfigurationFactory(); + + // ------------------------------------------------------------------ // + + const configuration = factory.make(class {}, entry); + + // Debug + // console.log('configuration', configuration); + + expect(configuration.allowAliases) + .withContext('Incorrect allowAliases value configuration') + .toBeFalse(); + + expect(configuration.aliases) + .withContext('Incorrect aliases object in configuration') + .toEqual({}); + }); + + it('removes unsafe keys / aliases', () => { + class MyConcern extends AbstractConcern { + foo() {} + } + + const aliases = { + 'foo': 'a', + + 'constructor': 'danger' // This should NOT be part of normalised aliases + }; + const entry = { concern: MyConcern, aliases: aliases }; + + const factory = makeConfigurationFactory(); + + // ------------------------------------------------------------------ // + + const configuration = factory.make(class {}, entry); + + // Debug + // console.log('configuration', configuration); + + expect(configuration.aliases) + .withContext('Incorrect aliases object in configuration') + .not + .toEqual({ 'foo': 'a', ...aliases }); + + const keys = Reflect.ownKeys(configuration.aliases); + for (const key of keys) { + expect(isUnsafeKey(key)) + .withContext(`Unsafe key "${key.toString()}" is part of aliases`) + .toBeFalse(); + } + }); + + it('can make configuration from shorthand', () => { + + class MyConcern extends AbstractConcern { + foo() {} + } + + const aliases = { + 'foo': 'a', + }; + const entry = [ MyConcern, aliases ]; + + const factory = makeConfigurationFactory(); + + // ------------------------------------------------------------------ // + + const configuration = factory.make(class {}, entry); + + expect(configuration.concern) + .withContext('Incorrect aliases object in configuration') + .toEqual(MyConcern); + + expect(configuration.aliases) + .withContext('Incorrect aliases object in configuration') + .toEqual({ ...aliases }); + + expect(configuration.allowAliases) + .withContext('Incorrect allowAliases in configuration') + .toBeTrue(); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/bootConcerns.test.js b/tests/browser/packages/support/concerns/bootConcerns.test.js new file mode 100644 index 00000000..16b47f89 --- /dev/null +++ b/tests/browser/packages/support/concerns/bootConcerns.test.js @@ -0,0 +1,83 @@ +import {bootConcerns, bootAllConcerns, getConcernsContainer, AbstractConcern, use} from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('bootConcerns()', () => { + it('can boot concerns', () => { + class ConcernA extends AbstractConcern {} + class ConcernB extends AbstractConcern {} + class ConcernC extends AbstractConcern {} + + @use( + ConcernA, + ConcernB + ) + class A {} + + @use(ConcernC) + class B extends A { + constructor() { + super(); + + bootConcerns(this, ConcernA, ConcernB); + } + } + + // ------------------------------------------------------------------------------------ // + + const instance = new B(); + const container = getConcernsContainer(instance); + + expect(container.hasBooted(ConcernA)) + .withContext('Concern A not booted') + .toBeTrue(); + expect(container.hasBooted(ConcernB)) + .withContext('Concern B not booted') + .toBeTrue(); + + expect(container.hasBooted(ConcernC)) + .withContext('Concern C SHOULD NOT have booted') + .toBeFalse(); + }); + + }); + + describe('bootAllConcerns()', () => { + it('can boot all concerns', () => { + + class ConcernA extends AbstractConcern {} + class ConcernB extends AbstractConcern {} + class ConcernC extends AbstractConcern {} + + @use( + ConcernA, + ConcernB + ) + class A {} + + @use(ConcernC) + class B extends A { + constructor() { + super(); + + bootAllConcerns(this); + } + } + + + // ------------------------------------------------------------------------------------ // + + const instance = new B(); + const container = getConcernsContainer(instance); + + expect(container.hasBooted(ConcernA)) + .withContext('Concern A not booted') + .toBeTrue(); + expect(container.hasBooted(ConcernB)) + .withContext('Concern B not booted') + .toBeTrue(); + expect(container.hasBooted(ConcernC)) + .withContext('Concern C not booted') + .toBeTrue(); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/getConcernsContainer.test.js b/tests/browser/packages/support/concerns/getConcernsContainer.test.js new file mode 100644 index 00000000..759fff91 --- /dev/null +++ b/tests/browser/packages/support/concerns/getConcernsContainer.test.js @@ -0,0 +1,35 @@ + +import { AbstractConcern, ConcernsContainer, getConcernsContainer, use } from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('getConcernsContainer()', () => { + + it('fails of target instance is not a concerns owner', () => { + class A {} + + // ------------------------------------------------------------------------------- // + + const callback = () => { + getConcernsContainer(new A()); + } + + expect(callback) + .toThrowError(TypeError) + }); + + it('returns concerns container', () => { + + class MyConcern extends AbstractConcern {} + + @use(MyConcern) + class A {} + + // ------------------------------------------------------------------------------- // + + const result = getConcernsContainer(new A()); + + expect(result) + .toBeInstanceOf(ConcernsContainer); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/helpers/makeConcernsInjector.js b/tests/browser/packages/support/concerns/helpers/makeConcernsInjector.js new file mode 100644 index 00000000..79379084 --- /dev/null +++ b/tests/browser/packages/support/concerns/helpers/makeConcernsInjector.js @@ -0,0 +1,13 @@ +import { ConcernsInjector } from "@aedart/support/concerns"; + +/** + * Returns a new Concerns Injector instance + * + * @param {object} target + * + * @returns {ConcernsInjector} + */ +export default function makeConcernsInjector(target) +{ + return new ConcernsInjector(target); +} \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/helpers/makeConfigurationFactory.js b/tests/browser/packages/support/concerns/helpers/makeConfigurationFactory.js new file mode 100644 index 00000000..80dbce98 --- /dev/null +++ b/tests/browser/packages/support/concerns/helpers/makeConfigurationFactory.js @@ -0,0 +1,11 @@ +import { ConfigurationFactory } from "@aedart/support/concerns"; + +/** + * Returns a new concern configuration factory instance + * + * @returns {ConfigurationFactory} + */ +export default function makeConfigurationFactory() +{ + return new ConfigurationFactory(); +} \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/injector/defineAlias.test.js b/tests/browser/packages/support/concerns/injector/defineAlias.test.js new file mode 100644 index 00000000..b6c4e53b --- /dev/null +++ b/tests/browser/packages/support/concerns/injector/defineAlias.test.js @@ -0,0 +1,279 @@ +import makeConcernsInjector from "../helpers/makeConcernsInjector"; +import { AbstractConcern, InjectionError, UnsafeAliasError } from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('ConcernsInjector', () => { + describe('defineAlias()', () => { + + it('fails if key is "unsafe"', () => { + class ConcernA extends AbstractConcern {} + + class A {} + + const injector = makeConcernsInjector(A); + + // --------------------------------------------------------------------------- // + + const callback = () => { + injector.defineAlias(A, 'foo', 'constructor', ConcernA); + } + + expect(callback) + .toThrowError(UnsafeAliasError); + }); + + it('does not define alias if property or method already exists in target"', () => { + class ConcernA extends AbstractConcern {} + + class A { + get foo() {} + + bar() {} + } + + const injector = makeConcernsInjector(A); + + // --------------------------------------------------------------------------- // + + const resultA = injector.defineAlias(A, 'foo', 'foo', ConcernA); + const resultB = injector.defineAlias(A, 'bar', 'bar', ConcernA); + + expect(resultA) + .withContext('Property alias was defined when it SHOULD NOT be') + .toBeFalse(); + + expect(resultB) + .withContext('Method alias was defined when it SHOULD NOT be') + .toBeFalse(); + }); + + it('fails if key does not exist in concern', () => { + class ConcernA extends AbstractConcern {} + + class A {} + + const injector = makeConcernsInjector(A); + + // --------------------------------------------------------------------------- // + + const callback = () => { + injector.defineAlias(A, 'foo', 'bar', ConcernA); + } + + expect(callback) + .toThrowError(InjectionError); + }); + + it('can define proxies in target prototype', () => { + class ConcernA extends AbstractConcern { + foo() {} + + get message() {} + set message(value) {} + + // Writable attribute + //title = 'ABC' // NOTE: This is defined below. Transpilers move this into the constructor (...uh) + } + Reflect.defineProperty(ConcernA.prototype, 'title', { + value: 'ABC', + writable: true, + enumerable: true, + configurable: true + }); + + class A {} + + const injector = makeConcernsInjector(A); + + // --------------------------------------------------------------------------- // + + const resultA = injector.defineAlias(A, 'foo', 'foo', ConcernA); + const resultB = injector.defineAlias(A, 'message', 'message', ConcernA); + const resultC = injector.defineAlias(A, 'title', 'title', ConcernA); + + // Debug + // console.log('A.prototype', Reflect.ownKeys(A.prototype)); + + expect(resultA) + .withContext('Method "foo" alias failed?!') + .toBeTrue(); + expect(Reflect.has(A.prototype, 'foo')) + .withContext('Method "foo" was not defined in target prototype') + .toBeTrue(); + + expect(resultB) + .withContext('property "message" alias failed?!') + .toBeTrue(); + expect(Reflect.has(A.prototype, 'foo')) + .withContext('property "message" was not defined in target prototype') + .toBeTrue(); + + expect(resultC) + .withContext('writable property "title" alias failed?!') + .toBeTrue(); + expect(Reflect.has(A.prototype, 'foo')) + .withContext('writable property "title" was not defined in target prototype') + .toBeTrue(); + }); + + it('can interact with proxy properties and methods in target instance', () => { + + /** + * @ mixin Uhm, only works on objects! + */ + class ConcernA extends AbstractConcern { + + /** + * Say hi to ... + * + * @param {string} name + * + * @returns {string} + * + * @memberof ConcernA + */ + greetings(name) { + return `Hi ${name}`; + } + + #msg = null; + get message() { + return this.#msg; + } + set message(value) { + this.#msg = value + } + + // Writable attribute + //title = 'ABC' // NOTE: This is defined below. Transpilers move this into the constructor (...uh) + } + Reflect.defineProperty(ConcernA.prototype, 'title', { + value: 'ABC', + writable: true, + enumerable: true, + configurable: true + }); + + /** + * A (with a bit of JSDoc experiment) + * + * @ mixes ConcernA Uhm, only works on objects! + * @ borrows ConcernA#greetings as greetings Does not seem to work + * @ borrows ConcernA#greetings as A#greetings Does not seem to work + * @ property {ConcernA.greetings} greetings Does not seem to work + * @ property {typeof ConcernA.greetings} greetings Does not seem to work + * @property {(name: string) => string} greetings Say hi + * @property {string|null} message Set a message + * @property {string} title A title + */ + class A { + } + + const injector = makeConcernsInjector(A); + const target = injector.defineContainer( + injector.defineConcerns(A, [ + ConcernA, + ]) + ); + + // --------------------------------------------------------------------------- // + + injector.defineAlias(target, 'greetings', 'greetings', ConcernA); + injector.defineAlias(target, 'message', 'message', ConcernA); + injector.defineAlias(target, 'title', 'title', ConcernA); + + // Debug + // console.log('A.prototype', Reflect.ownKeys(A.prototype)); + + // --------------------------------------------------------------------------- // + + const instance = new A(); + + // Method + const result = instance.greetings('Timmy'); + expect(result) + .withContext('Method interaction failed') + .toBe('Hi Timmy'); + + // Getter / Setter (Accessor) + const message = 'There goes an elephant...'; + instance.message = message; + expect(instance.message) + .withContext('getter/setter interaction failed') + .toBe(message); + + + // "Writable" property, defined on prototype + expect(instance.title) + .withContext('getter/setter interaction for "writable" property failed (A)') + .toBe('ABC'); + + const newTitle = 'Zookeeper'; + instance.title = newTitle; + expect(instance.title) + .withContext('getter/setter interaction for "writable" property failed (B)') + .toBe(newTitle); + }); + + it('alias remains for new target instance', () => { + + const defaultCategory = 'N/A'; + + class ConcernA extends AbstractConcern { + + #category = defaultCategory; + get category() { + return this.#category; + } + set category(value) { + this.#category = value + } + } + + /** + * @property {string} category A category name + */ + class A {} + + class B extends A {} + + const injector = makeConcernsInjector(A); + const target = injector.defineContainer( + injector.defineConcerns(A, [ + ConcernA, + ]) + ); + + // --------------------------------------------------------------------------- // + + injector.defineAlias(target, 'category', 'category', ConcernA); + + // Debug + // console.log('A.prototype', Reflect.ownKeys(A.prototype)); + + const instanceA = new A(); + expect(instanceA.category) + .withContext('Incorrect default title for A') + .toBe(defaultCategory); + + const newCategoryA = 'Fish'; + instanceA.category = newCategoryA; + expect(instanceA.category) + .withContext('Incorrect changed title for A') + .toBe(newCategoryA); + + const instanceB = new B(); + expect(instanceB.category) + .withContext('Incorrect default title for B') + .toBe(defaultCategory); + + const newCategoryB = 'Dogs'; + instanceB.category = newCategoryB; + expect(instanceB.category) + .withContext('Incorrect changed title for B') + .toBe(newCategoryB); + + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/injector/defineAliases.test.js b/tests/browser/packages/support/concerns/injector/defineAliases.test.js new file mode 100644 index 00000000..33e91b62 --- /dev/null +++ b/tests/browser/packages/support/concerns/injector/defineAliases.test.js @@ -0,0 +1,386 @@ +import makeConcernsInjector from "../helpers/makeConcernsInjector"; +import { ALIASES } from "@aedart/contracts/support/concerns"; +import { AbstractConcern, AliasConflictError } from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('ConcernsInjector', () => { + describe('defineAliases()', () => { + + it('defines default aliases in target', () => { + + class ConcernA extends AbstractConcern { + get foo() { + return 'bar'; + } + + greetings(name) { + return `Hi ${name}`; + } + } + + /** + * @property {(name: string) => string} greetings + * @property {string} foo + */ + class A {} + + const concerns = [ ConcernA ]; + + const injector = makeConcernsInjector(A); + const target = injector.defineContainer( + injector.defineConcerns(A, concerns) + ); + + const configurations = injector.normalise(concerns); + injector.defineAliases(target, configurations); + + // --------------------------------------------------------------------------- // + + expect(Reflect.has(A, ALIASES)) + .withContext('ALIASES static property not defined') + .toBeTrue(); + + expect(A[ALIASES]) + .withContext('Incorrect aliases defined in static ALIASES property') + .toEqual([ 'foo', 'greetings' ]); + + // --------------------------------------------------------------------------- // + + const instance = new A(); + expect(instance.foo) + .withContext('Interaction with alias "foo" failed') + .toBe('bar'); + + expect(instance.greetings('John')) + .withContext('Interaction with alias "greetings" failed') + .toBe('Hi John'); + }); + + it('can define custom aliases in target', () => { + + class ConcernA extends AbstractConcern { + get foo() { + return 'bar'; + } + + greetings(name) { + return `Hi ${name}`; + } + } + + /** + * @property {(name: string) => string} sayHi + * @property {string} ping + */ + class A {} + + const concerns = [ ConcernA ]; + + const injector = makeConcernsInjector(A); + const target = injector.defineContainer( + injector.defineConcerns(A, concerns) + ); + + const configurations = injector.normalise([ + { + concern: ConcernA, + aliases: { + 'foo': 'ping', + 'greetings': 'sayHi' + } + } + ]); + injector.defineAliases(target, configurations); + + // --------------------------------------------------------------------------- // + + expect(A[ALIASES]) + .withContext('Incorrect aliases defined in static ALIASES property') + .toEqual([ 'ping', 'sayHi' ]); + + // --------------------------------------------------------------------------- // + + const instance = new A(); + expect(instance.ping) + .withContext('Interaction with alias "ping" failed') + .toBe('bar'); + + expect(instance.sayHi('Siw')) + .withContext('Interaction with alias "sayHi" failed') + .toBe('Hi Siw'); + }); + + it('does not define aliases if "allowAliases" setting set to false', () => { + + class ConcernA extends AbstractConcern { + get foo() {} + greetings() {} + } + + class A {} + + const concerns = [ ConcernA ]; + + const injector = makeConcernsInjector(A); + const target = injector.defineContainer( + injector.defineConcerns(A, concerns) + ); + + const configurations = injector.normalise([ + { + concern: ConcernA, + allowAliases: false + } + ]); + injector.defineAliases(target, configurations); + + // --------------------------------------------------------------------------- // + + expect(A[ALIASES].length) + .withContext('static ALIASES property should be empty') + .toBe(0); + }); + + it('conflicts when same alias is used multiple times', () => { + + class ConcernA extends AbstractConcern { + get foo() {} + greetings() {} + } + + class A {} + + const concerns = [ ConcernA ]; + + const injector = makeConcernsInjector(A); + const target = injector.defineContainer( + injector.defineConcerns(A, concerns) + ); + + const configurations = injector.normalise([ + { + concern: ConcernA, + aliases: { + 'foo': 'bar', + 'greetings': 'bar', // This should result in alias conflict + } + } + ]); + + // --------------------------------------------------------------------------- // + + const callback = () => { + injector.defineAliases(target, configurations); + } + + // Debug + // console.log('A aliases', A[ALIASES]); + // console.log('A keys', Reflect.ownKeys(A.prototype)); + + expect(callback) + .toThrowError(AliasConflictError); + }); + + it('conflicts when default applied alias is already applied by another concern', () => { + + class ConcernA extends AbstractConcern { + get foo() {} + greetings() {} + } + + class ConcernB extends AbstractConcern { + greetings() {} // This will result in a conflict + } + + class A {} + + const concerns = [ ConcernA, ConcernB ]; + + const injector = makeConcernsInjector(A); + const target = injector.defineContainer( + injector.defineConcerns(A, concerns) + ); + + const configurations = injector.normalise([ + ConcernA, + ConcernB + ]); + + // --------------------------------------------------------------------------- // + + const callback = () => { + injector.defineAliases(target, configurations); + } + + expect(callback) + .toThrowError(AliasConflictError); + }); + + it('inherits previous applied aliases from parent class', () => { + + class ConcernA extends AbstractConcern { + get ping() { + return 'ping'; + } + } + + class ConcernB extends AbstractConcern { + get pong() { + return 'pong'; + } + } + + /** + * @property {string} ping + */ + class A {} + + /** + * @property {string} pong + */ + class B extends A {} + + const concernsA = [ ConcernA ]; + const concernsB = [ ConcernB ]; + + const injectorA = makeConcernsInjector(A); + const targetA = injectorA.defineContainer( + injectorA.defineConcerns(A, concernsA) + ); + + const injectorB = makeConcernsInjector(B); + const targetB = injectorB.defineContainer( + injectorB.defineConcerns(B, concernsB) + ); + + injectorA.defineAliases(targetA, injectorA.normalise(concernsA)); + injectorB.defineAliases(targetB, injectorB.normalise(concernsB)); + + // --------------------------------------------------------------------------- // + + // Debug + // console.log('A', A[ALIASES]); + // console.log('B', B[ALIASES]); + + expect(A[ALIASES]) + .withContext('Incorrect aliases defined A') + .toEqual([ 'ping' ]); + + expect(B[ALIASES]) + .withContext('Incorrect aliases defined A') + .toEqual([ 'pong' ]); + + // --------------------------------------------------------------------------- // + + const instance = new B(); + expect(instance.ping) + .withContext('Interaction with alias "ping" failed') + .toBe('ping'); + + expect(instance.pong) + .withContext('Interaction with alias "pong" failed') + .toBe('pong'); + }); + + it('conflicts when alias already defined in parent class', () => { + + class ConcernA extends AbstractConcern { + get ping() {} + } + + class ConcernB extends AbstractConcern { + get ping() {} // Will result in conflict + } + + + class A {} + class B extends A {} + + const concernsA = [ ConcernA ]; + const concernsB = [ ConcernB ]; + + const injectorA = makeConcernsInjector(A); + const targetA = injectorA.defineContainer( + injectorA.defineConcerns(A, concernsA) + ); + + const injectorB = makeConcernsInjector(B); + const targetB = injectorB.defineContainer( + injectorB.defineConcerns(B, concernsB) + ); + + // --------------------------------------------------------------------------- // + + injectorA.defineAliases(targetA, injectorA.normalise(concernsA)); + + const callback = () => { + injectorB.defineAliases(targetB, injectorB.normalise(concernsB)); + } + + expect(callback) + .toThrowError(AliasConflictError); + }); + + it('can prevent conflict via a custom alias in subclass', () => { + + class ConcernA extends AbstractConcern { + get ping() { + return 'ping' + } + } + + class ConcernB extends AbstractConcern { + get ping() { + return 'pong' + } + } + + /** + * @property {string} ping + */ + class A {} + + /** + * @property {string} pong + */ + class B extends A {} + + const concernsA = [ ConcernA ]; + const concernsB = [ ConcernB ]; + + const injectorA = makeConcernsInjector(A); + const targetA = injectorA.defineContainer( + injectorA.defineConcerns(A, concernsA) + ); + + const injectorB = makeConcernsInjector(B); + const targetB = injectorB.defineContainer( + injectorB.defineConcerns(B, concernsB) + ); + + // --------------------------------------------------------------------------- // + + injectorA.defineAliases(targetA, injectorA.normalise(concernsA)); + injectorB.defineAliases(targetB, injectorB.normalise([ + { + concern: ConcernB, + aliases: { + 'ping': 'pong' // This should prevent alias conflict + } + } + ])); + + // --------------------------------------------------------------------------- // + + const instance = new B(); + expect(instance.ping) + .withContext('Interaction with alias "ping" failed') + .toBe('ping'); + + expect(instance.pong) + .withContext('Interaction with alias "pong" failed') + .toBe('pong'); + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/injector/defineConcerns.test.js b/tests/browser/packages/support/concerns/injector/defineConcerns.test.js new file mode 100644 index 00000000..dcc7c960 --- /dev/null +++ b/tests/browser/packages/support/concerns/injector/defineConcerns.test.js @@ -0,0 +1,158 @@ +import { AbstractConcern, AlreadyRegisteredError, InjectionError } from "@aedart/support/concerns"; +import { CONCERN_CLASSES } from "@aedart/contracts/support/concerns"; +import makeConcernsInjector from "../helpers/makeConcernsInjector"; + +describe('@aedart/support/concerns', () => { + describe('ConcernsInjector', () => { + describe('defineConcerns()', () => { + + it('can define concerns classes in target', () => { + class ConcernA extends AbstractConcern {} + class ConcernB extends AbstractConcern {} + class ConcernC extends AbstractConcern {} + + class A {} + + const injector = makeConcernsInjector(A); + + injector.defineConcerns(A, [ + ConcernA, + ConcernB, + ConcernC, + ]); + + // ------------------------------------------------------------------- // + + expect(Reflect.has(A, CONCERN_CLASSES)) + .withContext('Target does not have static concern classes property defined') + .toBeTrue(); + + const registered = A[CONCERN_CLASSES]; + expect(Array.isArray(registered)) + .withContext('static concern classes property is not an array') + .toBeTrue(); + + expect(registered) + .withContext('incorrect concern classes registered') + .toEqual([ ConcernA, ConcernB, ConcernC ]); + }); + + it('inherits already registered concerns from parent', () => { + class ConcernA extends AbstractConcern {} + class ConcernB extends AbstractConcern {} + class ConcernC extends AbstractConcern {} + + class A {} + + class B extends A {} + + const injector = makeConcernsInjector(A); + + // Define for A + injector.defineConcerns(A, [ + ConcernA, + ConcernB + ]); + + // Define for B + injector.defineConcerns(B, [ + ConcernC + ]); + + // ------------------------------------------------------------------- // + + expect(Reflect.has(B, CONCERN_CLASSES)) + .withContext('Target B does not have static concern classes property defined') + .toBeTrue(); + + const registryB = B[CONCERN_CLASSES]; + + // Debug + // console.log('registry b', registryB); + + expect(registryB) + .withContext('Target B did not inherit concern classes from parent class') + .toEqual([ ConcernA, ConcernB, ConcernC ]); + + // ------------------------------------------------------------------- // + + // Ensure that A's concern classes have not been altered + const registryA = A[CONCERN_CLASSES]; + + // Debug + // console.log('registry a', registryA); + + expect(registryA) + .withContext('Target A concern classes has been modified - BUT SHOULD NOT BE!') + .toEqual([ ConcernA, ConcernB ]); + }); + + it('fails when attempting to register same concern twice in target', () => { + class ConcernA extends AbstractConcern {} + + class A {} + + const injector = makeConcernsInjector(A); + + const callback = () => { + injector.defineConcerns(A, [ + ConcernA, + ConcernA, // Should cause error + ]); + } + + // ------------------------------------------------------------------- // + + expect(callback) + .toThrowError(AlreadyRegisteredError); + }); + + it('fails to register concern class when already registered in parent', () => { + class ConcernA extends AbstractConcern {} + + class A {} + + class B extends A {} + + const injector = makeConcernsInjector(A); + + // Define for A + injector.defineConcerns(A, [ + ConcernA + ]); + + // Define for B + const callback = () => { + injector.defineConcerns(B, [ + ConcernA // Should cause error + ]); + } + + // ------------------------------------------------------------------- // + + expect(callback) + .toThrowError(AlreadyRegisteredError); + }); + + it('fails if unable to define static concern classes property', () => { + class ConcernA extends AbstractConcern {} + + class A {} + Object.freeze(A); // This can prevent static property from being added + + const injector = makeConcernsInjector(A); + + const callback = () => { + injector.defineConcerns(A, [ + ConcernA + ]); + } + + // ------------------------------------------------------------------- // + + expect(callback) + .toThrowError(InjectionError); + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/injector/defineContainer.test.js b/tests/browser/packages/support/concerns/injector/defineContainer.test.js new file mode 100644 index 00000000..03d2d379 --- /dev/null +++ b/tests/browser/packages/support/concerns/injector/defineContainer.test.js @@ -0,0 +1,199 @@ +import { CONCERNS } from "@aedart/contracts/support/concerns"; +import { AbstractConcern, ConcernsContainer, InjectionError } from "@aedart/support/concerns"; +import makeConcernsInjector from "../helpers/makeConcernsInjector"; + +describe('@aedart/support/concerns', () => { + describe('ConcernsInjector', () => { + describe('defineContainer()', () => { + + it('can define concerns container in target prototype', () => { + class ConcernA extends AbstractConcern {} + + class A {} + + const injector = makeConcernsInjector(A); + + const target = injector.defineContainer( + injector.defineConcerns(A, [ + ConcernA, + ]) + ); + + // ------------------------------------------------------------------------------------------ // + + expect(Reflect.has(target.prototype, CONCERNS)) + .withContext('Concerns container property not defined in target prototype') + .toBeTrue(); + }); + + it('container instance is created when obtained from target instance', () => { + class ConcernA extends AbstractConcern {} + class ConcernB extends AbstractConcern {} + class ConcernC extends AbstractConcern {} + + class A {} + + const injector = makeConcernsInjector(A); + + const target = injector.defineContainer( + injector.defineConcerns(A, [ + ConcernA, + ConcernB, + ConcernC, + ]) + ); + + // ------------------------------------------------------------------------------------------ // + + const instance = new target(); + const container = instance[CONCERNS]; + + // Debug + // console.log('container', container); + + expect(container) + .withContext('Invalid concerns container instance') + .toBeInstanceOf(ConcernsContainer); + + expect(container.isNotEmpty()) + .withContext('Container has no concerns registered') + .toBeTrue(); + + expect(container.has(ConcernA)) + .withContext('Concern A not registered in container') + .toBeTrue(); + expect(container.has(ConcernB)) + .withContext('Concern B not registered in container') + .toBeTrue(); + expect(container.has(ConcernC)) + .withContext('Concern C not registered in container') + .toBeTrue(); + }); + + it('does not inherit container from parent instance', () => { + class ConcernA extends AbstractConcern {} + class ConcernB extends AbstractConcern {} + class ConcernC extends AbstractConcern {} + + class A {} + class B extends A {} + + const injector = makeConcernsInjector(A); + + const targetA = injector.defineContainer( + injector.defineConcerns(A, [ + ConcernA, + ConcernB, + ]) + ); + + const targetB = injector.defineContainer( + injector.defineConcerns(B, [ + ConcernC + ]) + ); + + // ------------------------------------------------------------------------------------------ // + + const instanceA = new targetA(); + const instanceB = new targetB(); + + const containerA = instanceA[CONCERNS]; + const containerB = instanceB[CONCERNS]; + + expect(containerA === containerB) + .withContext('Same container instance returned for different target instances!') + .toBeFalse(); + + expect(containerA.size) + .withContext('Incorrect amount of concerns in container a') + .toBe(2); + + expect(containerB.size) + .withContext('Incorrect amount of concerns in container b') + .toBe(3); + }); + + it('can interact with concerns in container', () => { + class ConcernA extends AbstractConcern { + foo() { + return 'foo'; + } + } + class ConcernB extends AbstractConcern { + get bar() { + return 'bar' + } + } + class ConcernC extends AbstractConcern { + #msg = null; + + set message(value) { + this.#msg = value; + } + + get message() { + return this.#msg; + } + } + + class A {} + + const injector = makeConcernsInjector(A); + + const target = injector.defineContainer( + injector.defineConcerns(A, [ + ConcernA, + ConcernB, + ConcernC, + ]) + ); + + // ------------------------------------------------------------------------------------------ // + + const instance = new target(); + const container = instance[CONCERNS]; + + // Debug + // console.log('container', container); + + expect(container.call(ConcernA, 'foo')) + .withContext('Unable to call method in concern a') + .toBe('foo'); + + expect(container.getProperty(ConcernB, 'bar')) + .withContext('Unable to get property from concern b') + .toBe('bar'); + + const msg = 'Sweet...'; + container.setProperty(ConcernC, 'message', msg); + expect(container.getProperty(ConcernC, 'message')) + .withContext('Unable to set and get property in concern c') + .toBe(msg); + }); + + it('fails if unable to define concerns container property in target prototype', () => { + class ConcernA extends AbstractConcern {} + + class A {} + + const injector = makeConcernsInjector(A); + + const target = injector.defineConcerns(A, [ + ConcernA, + ]) + + Object.freeze(target.prototype); // This should cause an error whe attempting to define container... + + const callback = () => { + injector.defineContainer(target); + } + + // ------------------------------------------------------------------------------------------ // + + expect(callback) + .toThrowError(InjectionError); + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/injector/inject.test.js b/tests/browser/packages/support/concerns/injector/inject.test.js new file mode 100644 index 00000000..8abc4283 --- /dev/null +++ b/tests/browser/packages/support/concerns/injector/inject.test.js @@ -0,0 +1,112 @@ +import makeConcernsInjector from "../helpers/makeConcernsInjector"; +import { AbstractConcern } from "@aedart/support/concerns"; +import { BEFORE, AFTER } from "@aedart/contracts/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('ConcernsInjector', () => { + describe('inject()', () => { + + it('can inject concerns into target class', () => { + + class ConcernA extends AbstractConcern { + foo() { + return 'foo'; + } + } + class ConcernB extends AbstractConcern { + bar() { + return 'bar' + } + } + class ConcernC extends AbstractConcern { + get message() { + return 'Hi...' + } + } + + /** + * @property {() => string} foo + * @property {() => string} bar + * @property {string} greeting + */ + class A {} + + const injector = makeConcernsInjector(A); + + // --------------------------------------------------------------------------- // + + const target = injector.inject( + ConcernA, + ConcernB, + { concern: ConcernC, aliases: { 'message': 'greeting' } } + ); + + // --------------------------------------------------------------------------- // + + const instance = new A(); + + expect(instance.foo()) + .toBe('foo'); + expect(instance.bar()) + .toBe('bar'); + expect(instance.greeting) + .toBe('Hi...'); + }); + + it('invokes before and after registration hooks', () => { + const called = []; + + class ConcernA extends AbstractConcern + { + static [BEFORE](target) { + // console.log('before (a)', target); + called.push('before a'); + } + + static [AFTER](target) { + // console.log('after (a)', target); + called.push('after a'); + } + } + class ConcernB extends AbstractConcern + { + static [BEFORE](target) { + // console.log('before (b)', target); + called.push('before b'); + } + + static [AFTER](target) { + // console.log('after (b)', target); + called.push('after b'); + } + } + + + class A {} + class B extends A {} + + // --------------------------------------------------------------------------- // + + makeConcernsInjector(A) + .inject(ConcernA); + makeConcernsInjector(B) + .inject(ConcernB); + + expect(called) + .withContext('Incorrect order of registration hooks execution') + .toEqual([ + // class A concerns + 'before a', + 'after a', + + // class B concerns (inherited from A) + 'before a', + 'before b', + 'after a', + 'after b', + ]); + }); + + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/isConcernConfiguration.test.js b/tests/browser/packages/support/concerns/isConcernConfiguration.test.js new file mode 100644 index 00000000..b0da9055 --- /dev/null +++ b/tests/browser/packages/support/concerns/isConcernConfiguration.test.js @@ -0,0 +1,48 @@ +import { PROVIDES } from "@aedart/contracts/support/concerns"; +import { isConcernConfiguration, AbstractConcern } from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('isConcernConfiguration()', () => { + + it('can determine if target is a concern configuration', () => { + + class A {} + + class B { + get concernOwner() { + return false; + } + + static [PROVIDES]() {} + } + + class C extends B {} + + class D extends AbstractConcern {} + + class E extends D {} + + // ------------------------------------------------------------------------------------ // + + const data = [ + { value: null, expected: false, name: 'Null' }, + { value: [], expected: false, name: 'Array' }, + { value: {}, expected: false, name: 'Object (empty)' }, + { value: A, expected: false, name: 'Class A (empty)' }, + { value: { concern: A }, expected: false, name: 'Configuration with Class A (not a concern)' }, + + { value: { concern: B }, expected: true, name: 'Configuration with Class B (custom implementation of concern)' }, + { value: { concern: C }, expected: true, name: 'Configuration with Class C (inherits from custom implementation)' }, + { value: { concern: D }, expected: true, name: 'Configuration with Class D (inherits from AbstractConcern)' }, + { value: { concern: E }, expected: true, name: 'Configuration with Class E (inherits from a base that inherits from AbstractConcern)' }, + ]; + + for (const entry of data) { + expect(isConcernConfiguration(entry.value)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/isConcernConstructor.test.js b/tests/browser/packages/support/concerns/isConcernConstructor.test.js new file mode 100644 index 00000000..ca03d84f --- /dev/null +++ b/tests/browser/packages/support/concerns/isConcernConstructor.test.js @@ -0,0 +1,47 @@ +import { PROVIDES } from "@aedart/contracts/support/concerns"; +import { isConcernConstructor, AbstractConcern } from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('isConcernConstructor()', () => { + + it('can determine if target is a concern constructor', () => { + + class A {} + + class B { + get concernOwner() { + return false; + } + + static [PROVIDES]() {} + } + + class C extends B {} + + class D extends AbstractConcern {} + + class E extends D {} + + // ------------------------------------------------------------------------------------ // + + const data = [ + { value: null, expected: false, name: 'Null' }, + { value: [], expected: false, name: 'Array' }, + { value: {}, expected: false, name: 'Object (empty)' }, + { value: A, expected: false, name: 'Class A (empty)' }, + + { value: B, expected: true, name: 'Class B (custom implementation of concern)' }, + { value: C, expected: true, name: 'Class C (inherits from custom implementation)' }, + { value: D, expected: true, name: 'Class D (inherits from AbstractConcern)' }, + { value: E, expected: true, name: 'Class E (inherits from a base that inherits from AbstractConcern)' }, + ]; + + for (const entry of data) { + expect(isConcernConstructor(entry.value)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/isConcernsOwner.test.js b/tests/browser/packages/support/concerns/isConcernsOwner.test.js new file mode 100644 index 00000000..f06bc36e --- /dev/null +++ b/tests/browser/packages/support/concerns/isConcernsOwner.test.js @@ -0,0 +1,37 @@ +import { CONCERNS } from "@aedart/contracts/support/concerns"; +import { isConcernsOwner } from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('isConcernsOwner()', () => { + + it('can determine if target instance is a concern owner', () => { + + class A {} + + class B { + get [CONCERNS]() {} + } + + class C extends B {} + + // ------------------------------------------------------------------------------------ // + + const data = [ + { value: null, expected: false, name: 'Null' }, + { value: [], expected: false, name: 'Array' }, + { value: {}, expected: false, name: 'Object (empty)' }, + { value: A, expected: false, name: 'Class A (empty)' }, + + { value: new B(), expected: true, name: 'Class B instance' }, + { value: new C(), expected: true, name: 'Class C instance (inherits Class B)' }, + ]; + + for (const entry of data) { + expect(isConcernsOwner(entry.value)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/isShorthandConfiguration.test.js b/tests/browser/packages/support/concerns/isShorthandConfiguration.test.js new file mode 100644 index 00000000..733ee75b --- /dev/null +++ b/tests/browser/packages/support/concerns/isShorthandConfiguration.test.js @@ -0,0 +1,48 @@ +import { PROVIDES } from "@aedart/contracts/support/concerns"; +import { isShorthandConfiguration, AbstractConcern } from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('isShorthandConfiguration()', () => { + + it('can determine if target is a shorthand concern configuration', () => { + + class A {} + + class B { + get concernOwner() { + return false; + } + + static [PROVIDES]() {} + } + + class C extends B {} + + class D extends AbstractConcern {} + + class E extends D {} + + // ------------------------------------------------------------------------------------ // + + const data = [ + { value: null, expected: false, name: 'Null' }, + { value: [], expected: false, name: 'Array (empty)' }, + { value: {}, expected: false, name: 'Object (empty)' }, + { value: A, expected: false, name: 'Class A (empty)' }, + { value: [ A ], expected: false, name: 'Array with Class A (not a concern)' }, + + { value: [ B ], expected: true, name: 'Array with Class B (custom implementation of concern)' }, + { value: [ C ], expected: true, name: 'Array with Class C (inherits from custom implementation)' }, + { value: [ D ], expected: true, name: 'Array with Class D (inherits from AbstractConcern)' }, + { value: [ E ], expected: true, name: 'Array with Class E (inherits from a base that inherits from AbstractConcern)' }, + ]; + + for (const entry of data) { + expect(isShorthandConfiguration(entry.value)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/use-edge-cases.test.js b/tests/browser/packages/support/concerns/use-edge-cases.test.js new file mode 100644 index 00000000..bbbc6a69 --- /dev/null +++ b/tests/browser/packages/support/concerns/use-edge-cases.test.js @@ -0,0 +1,127 @@ +import {AbstractConcern, AliasConflictError, use} from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('Edge Cases', () => { + + it('concern can use other concern', () => { + + /** + * @mixin + * @extends AbstractConcern + */ + class ConcernA extends AbstractConcern { + ping() { + return 'pong'; + } + } + + /** + * @mixes ConcernA + * @extends AbstractConcern + */ + @use(ConcernA) + class ConcernB extends AbstractConcern { + pong() { + return 'ping'; + } + } + + /** + * @mixes ConcernB + */ + @use(ConcernB) + class Game {} + + // --------------------------------------------------------------------------- // + + const instance = new Game(); + + // Game (instance).ping() -> Concern B (instance).ping() -> Concern A (instance).ping() + + expect(instance.ping()) + .toBe('pong'); + expect(instance.pong()) + .toBe('ping'); + }); + + it('fails when getter and setter are declared in different concerns', () => { + + class ConcernA extends AbstractConcern { + get title() {} + } + + class ConcernB extends AbstractConcern { + set title(value) {} + } + + const callback = () => { + @use( + ConcernA, + ConcernB // Conflicts with "title" from Concern A - same property key! + ) + class A {} + } + + expect(callback) + .toThrowError(AliasConflictError); + }); + + // Note: this isn't really an edge case, - its more a test of intended behaviour! + it('can make "fluent" methods', () => { + + class ConcernA extends AbstractConcern { + with(value) { + // ...value ignored here... + + return this.concernOwner; + } + } + + /** + * @property {(value: string) => this} with Add a value to the request... + */ + @use(ConcernA) + class Service { + request() { + return 'done'; + } + } + + const instance = new Service(); + + const result = instance + .with('a') + .with('b') + .with('c') + .request(); + + expect(result) + .toBe('done'); + }); + + it('does not alias property key inherited by parent', () => { + + class A { + driver() { + return 'xyz'; + } + } + + class ConcernsDriver extends AbstractConcern { + driver() { + return 'special'; + } + } + + @use(ConcernsDriver) // driver() is NOT aliased - method inherited from class A! + class B extends A {} + + // --------------------------------------------------------------------------- // + + const instance = new B(); + + expect(instance.driver()) + .toBe('xyz'); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/use.test.js b/tests/browser/packages/support/concerns/use.test.js new file mode 100644 index 00000000..0d42eed6 --- /dev/null +++ b/tests/browser/packages/support/concerns/use.test.js @@ -0,0 +1,71 @@ +import { AbstractConcern, use } from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('use()', () => { + + it('can inject concerns into target via @use() class decorator', () => { + class ConcernA extends AbstractConcern { + ping() { + return 'pong'; + } + } + class ConcernB extends AbstractConcern { + foo() { + return 'bar'; + } + } + class ConcernC extends AbstractConcern { + get hi() { + return 'Hi...' + } + + sayHi(name) { + const hi = this.hi; + return `${hi} ${name}`; + } + } + + /** @type {Configuration} */ + const config = { + concern: ConcernC, + aliases: { + 'hi': 'message', + 'sayHi': 'write' + } + }; + + /** + * @property {() => string} ping + * @property {() => string} bar + * @property {string} message + * @property {(name: string) => string} write + */ + @use( + ConcernA, + [ConcernB, { + 'foo': 'bar' + }], + config + ) + class MyService { + sayHi(name) { + return this.write(name) + '!'; + } + } + + // --------------------------------------------------------------------------- // + + const instance = new MyService(); + + expect(instance.ping()) + .toBe('pong'); + expect(instance.bar()) + .toBe('bar'); + expect(instance.message) + .toBe('Hi...'); + expect(instance.sayHi('Hans')) + .toBe('Hi... Hans!'); + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/usesConcerns.test.js b/tests/browser/packages/support/concerns/usesConcerns.test.js new file mode 100644 index 00000000..34bc0459 --- /dev/null +++ b/tests/browser/packages/support/concerns/usesConcerns.test.js @@ -0,0 +1,52 @@ +import {usesConcerns, AbstractConcern, use} from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('usesConcerns()', () => { + + it('can determine if target instance uses concerns', () => { + + class ConcernA extends AbstractConcern {} + class ConcernB extends AbstractConcern {} + class ConcernC extends AbstractConcern {} + + class ConcernD extends AbstractConcern {} + + class A {} + + @use( + ConcernA, + ConcernB + ) + class B {} + + @use(ConcernC) + class C extends B {} + + // ------------------------------------------------------------------------------------ // + + const data = [ + { instance: null, concerns: [], expected: false, name: 'Null' }, + { instance: [], concerns: [], expected: false, name: 'Array' }, + { instance: {}, concerns: [], expected: false, name: 'Object (empty)' }, + { instance: A, concerns: [], expected: false, name: 'Class A (empty)' }, + { instance: new A(), concerns: [ ConcernA ], expected: false, name: 'A' }, + { instance: new C(), concerns: [], expected: false, name: 'C, concerns: (empty)' }, + + { instance: new C(), concerns: [ ConcernA ], expected: true, name: 'C, concerns: a' }, + { instance: new C(), concerns: [ ConcernB ], expected: true, name: 'C, concerns: b' }, + { instance: new C(), concerns: [ ConcernC ], expected: true, name: 'C, concerns: c' }, + { instance: new C(), concerns: [ ConcernA, ConcernB ], expected: true, name: 'C, concerns: a, b' }, + { instance: new C(), concerns: [ ConcernA, ConcernB, ConcernC ], expected: true, name: 'C, concerns: a, b, c' }, + + { instance: new C(), concerns: [ ConcernA, ConcernB, ConcernC, ConcernD ], expected: false, name: 'C instance, concerns: a, b, c and d (not used)' }, + ]; + + for (const entry of data) { + expect(usesConcerns(entry.instance, ...entry.concerns)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/concerns/x-jsdoc.test.js b/tests/browser/packages/support/concerns/x-jsdoc.test.js new file mode 100644 index 00000000..a8462e34 --- /dev/null +++ b/tests/browser/packages/support/concerns/x-jsdoc.test.js @@ -0,0 +1,286 @@ +import {AbstractConcern, use} from "@aedart/support/concerns"; + +describe('@aedart/support/concerns', () => { + describe('JSDoc ', () => { + + // NOTE: These test are NOT produce any JSDOC - they are just for tinkering + // in the IDE (PHPStorm / IntelliJ) to see what works in plain JavaScript. + + it('@property', () => { + + // @property = documentation of static properties of a class, namespace or other object! + // @see https://jsdoc.app/tags-property + + class Armor extends AbstractConcern { + + /** + * Returns the armor level + * + * @returns {number} + */ + get armor() { + return 8; + } + } + + /** + * @property {number} armor + */ + @use(Armor) + class Hero {} + + // --------------------------------------------------------------------------- // + + const instance = new Hero(); + + // Works... but "armor" is now static on Hero, and not the instance of Hero! + // E.g. Hero.armor !!! + // Another downside is that we cannot reuse JSDoc defined in the concern class. + + expect(instance.armor) + .toBe(8); + }); + + it('@member / @var', () => { + // @var and @member can be used to document "virtual" properties + // @see https://jsdoc.app/tags-member + + class Sword extends AbstractConcern { + + /** + * Returns amount of damage + * + * @returns {number} + */ + get damage() { + return 3; + } + + /** + * Returns the sword type + * + * @name type + * @return {string} + */ + get type() { + return 'unique'; + } + } + + + @use(Sword) + class Enemy { + + /** + * Virtual property + * + * @public + * @member {number} damage Alias for {@link Sword#damage} + * @memberof Enemy.prototype + */ + + /** + * @public + * @var {string} type Alias for {@link Sword#type} + * @instance + * @memberof Enemy + */ + + /** + * Fight + * + * @return {void} + */ + fight() { + // this.damage; // works + // this.type; // works also + } + } + + // --------------------------------------------------------------------------- // + + const instance = new Enemy(); + + // Works, but can be much more cumbersome do define. + // Notice the "@name" usage in the concern. That is important so that @link can reference the element! + // Downside, concern's JSDoc is not used. It's only referenced to via a @link + + // instance.type = 321; // Type check will complain here... + + expect(instance.damage) + .toBe(3); + expect(instance.type) + .toBe('unique'); + }); + + it('@borrows', () => { + // Using @borrows in combination with @member or @var, "virtual" properties can also be documented + // @see https://jsdoc.app/tags-borrows + + /** + * @extends AbstractConcern + */ + class Spell extends AbstractConcern { + + /** + * Returns amount of damage + * + * @name damage + * @returns {number} + */ + get damage() { + return 1; + } + + /** + * Returns the spell's name + * + * @name name + * @return {string} + */ + get name() { + return 'sleep'; + } + + /** + * Cast the spell + * + * @name cast + * @returns {number} Damage done + */ + cast() { + return this.damage; + } + } + + /** + * @borrows Spell#damage as damage Spell damage + */ + @use(Spell) + class Npc { + + /** + * Alias for {@link Spell#damage} + * @var damage + * @readonly + * @instance + * @memberof Npc + */ + + /** + * @borrows Spell#name as name + * @member {string} name Alias for {@link Spell#name} + * @instance + * @memberof Npc + */ + + /** + * Alias for {@link Spell#cast} + * + * @borrows Spell#cast as cast + * @function cast + * @return {number} + * @instance + * @memberof Npc + */ + } + + /** + * @extends Npc + */ + class MageNpc extends Npc {} + + // --------------------------------------------------------------------------- // + + const instance = new MageNpc(); + + // Works... but still cumbersome. + // Downside, concern's JSDoc is not used. It's only referenced to via a @link. + // Also, the {type} MUST still be provided or no type assistance is given. + + // instance.damage = 'fish'; // Ah... somehow the property is now lost is lost. + + expect(instance.damage) + .toBe(1); + expect(instance.name) + .toBe('sleep'); + expect(instance.cast()) + .toBe(instance.damage); + }); + + it('@mixin and @mixes', () => { + // @see https://jsdoc.app/tags-mixes + + /** + * @mixin + * @extends AbstractConcern (bad here, because `concernOwner` is suggested by IDE. But @extend required for some reason!!!) + */ + class Shield extends AbstractConcern { + + /** + * Returns the armor level + * + * @returns {number} + */ + get armor() { + return 8; + } + + /** + * Throw shield towards a target + * + * @param {object} target + * + * @returns {number} Damage given to target + */ + throw(target) { + // target ignored here... + return 3; + } + } + + /** + * @mixes Shield + */ + @use({ + concern: Shield, + aliases: { + 'throw': 'fight' + } + }) + class Monster { + /** + * Alias for {@link Shield#throw} + * + * @function fight + * @param {object} target The target to throw at... + * @return {number} Damage taken by target + * @instance + * @memberof Monster + */ + + /** + * Do stuff... + */ + do() { + this.fight({}); + } + } + + // --------------------------------------------------------------------------- // + + const instance = new Monster(); + + // Works, but it seems that all props / methods are also available statically. + // JSDoc is automatically inherited, so less cumbersome. + // Downside: + // - @extends AbstractConcern MUST be stated or IDE fails to understand!? + // - Cumbersome to define alias! + + expect(instance.armor) + .toBe(8); + expect(instance.fight({})) + .toBe(3); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/exceptions/AbstractClassError.test.js b/tests/browser/packages/support/exceptions/AbstractClassError.test.js new file mode 100644 index 00000000..3328981c --- /dev/null +++ b/tests/browser/packages/support/exceptions/AbstractClassError.test.js @@ -0,0 +1,53 @@ +import { AbstractClassError, LogicalError } from "@aedart/support/exceptions"; + +describe('@aedart/support/exceptions', () => { + + describe('AbstractClassError', () => { + + it('can throw new Abstract Class Error', () => { + + class A {} + let wasThrown = false; + + try { + throw new AbstractClassError(A); + } catch (error) { + // Debug + // console.log(error.toString(), error.cause, error.stack); + + wasThrown = true; + + expect(error.target) + .withContext('Target not set') + .toBe(A); + + expect(error) + .withContext('Should be instance of AbstractClassError') + .toBeInstanceOf(AbstractClassError); + + expect(error) + .withContext('Should also be instance of LogicalError') + .toBeInstanceOf(LogicalError); + + expect(error) + .withContext('Should also be instance of Error') + .toBeInstanceOf(Error); + } + + expect(wasThrown) + .withContext('Custom error was not thrown') + .toBeTrue(); + }); + + it('can capture via expect', () => { + const callback = () => { + throw new AbstractClassError(class {}); + } + + expect(callback) + .toThrowError(AbstractClassError); + }); + + }); + +}); \ No newline at end of file diff --git a/tests/browser/packages/support/exceptions/LogicalError.test.js b/tests/browser/packages/support/exceptions/LogicalError.test.js new file mode 100644 index 00000000..0a6209b6 --- /dev/null +++ b/tests/browser/packages/support/exceptions/LogicalError.test.js @@ -0,0 +1,64 @@ +import { LogicalError } from "@aedart/support/exceptions"; + +describe('@aedart/support/exceptions', () => { + + describe('LogicalError', () => { + + it('can throw new Logical Error', () => { + + // Although we can use Jasmine's builtin helpers to capture, we do so manually + // for this custom error/exception to assert other aspects of it... + + const msg = 'Lorum Lipsum'; + const options = { cause: { details: 'Morus Esto buno' }}; + let wasThrown = false; + + try { + throw new LogicalError(msg, options); + } catch (error) { + // Debug + // console.log(error.toString(), error.cause, error.stack); + + wasThrown = true; + + expect(error.name) + .toBe('LogicalError'); + + expect(error.message) + .toBe(msg); + + expect(error.cause) + .withContext('Error cause does not appear to be defined') + .toBe(options.cause); + + expect(error.stack) + .withContext('Error stack does not appear to be defined') + .not + .toBeUndefined(); + + expect(error) + .withContext('Should be instance of LogicalError') + .toBeInstanceOf(LogicalError); + + expect(error) + .withContext('Should also be instance of Error') + .toBeInstanceOf(Error); + } + + expect(wasThrown) + .withContext('Custom error was not thrown') + .toBeTrue(); + }); + + it('can capture via expect', () => { + const callback = () => { + throw new LogicalError(); + } + + expect(callback) + .toThrowError(LogicalError); + }); + + }); + +}); \ No newline at end of file diff --git a/tests/browser/packages/support/exceptions/configureCustomError.test.js b/tests/browser/packages/support/exceptions/configureCustomError.test.js new file mode 100644 index 00000000..c3f1cc42 --- /dev/null +++ b/tests/browser/packages/support/exceptions/configureCustomError.test.js @@ -0,0 +1,36 @@ +import { configureCustomError } from "@aedart/support/exceptions"; + +describe('@aedart/support/exceptions', () => { + + describe('configureCustomError()', () => { + + it('can configure custom error instance', () => { + class MyCustomError extends Error + { + constructor(name, options) { + super(name, options); + + configureCustomError(this); + } + } + + // ---------------------------------------------------------------------------- // + + const error = new MyCustomError('Oh my...'); + + expect(error.name) + .withContext('Custom Error name incorrect') + .toBe(MyCustomError.name) + + // Perhaps a bit redundant, ... + expect(Reflect.has(error, 'stack')) + .withContext('stack property not in error') + .toBeTrue(); + + expect(error.stack) + .withContext('stack property is undefined') + .not + .toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/exceptions/configureStackTrace.test.js b/tests/browser/packages/support/exceptions/configureStackTrace.test.js new file mode 100644 index 00000000..90b5e8bc --- /dev/null +++ b/tests/browser/packages/support/exceptions/configureStackTrace.test.js @@ -0,0 +1,33 @@ +import { configureStackTrace } from "@aedart/support/exceptions"; + +describe('@aedart/support/exceptions', () => { + + describe('configureStackTrace()', () => { + + it('can capture stack trace and set it in error instance', () => { + class MyCustomError extends Error + { + constructor(name, options) { + super(name, options); + + configureStackTrace(this); + + this.name = "MyCustomError"; + } + } + + // ---------------------------------------------------------------------------- // + + const error = new MyCustomError('Oh my...'); + + expect(Reflect.has(error, 'stack')) + .withContext('stack property not in error') + .toBeTrue(); + + expect(error.stack) + .withContext('stack property is undefined') + .not + .toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/exceptions/getErrorMessage.test.js b/tests/browser/packages/support/exceptions/getErrorMessage.test.js new file mode 100644 index 00000000..74682ee3 --- /dev/null +++ b/tests/browser/packages/support/exceptions/getErrorMessage.test.js @@ -0,0 +1,43 @@ +import { getErrorMessage } from "@aedart/support/exceptions"; + +describe('@aedart/support/exceptions', () => { + + describe('getErrorMessage()', () => { + + it('returns error message', () => { + + const err = new Error('Lorum lipsum'); + + const result = getErrorMessage(err); + expect(result) + .toBe(err.message); + }); + + it('returns default message, when unable to resolve message from error', () => { + + const defaultMessage = 'My other failure reason'; + + const err = {}; + + const result = getErrorMessage(err, defaultMessage); + expect(result) + .toBe(defaultMessage); + }); + + it('defaults when expression is given', () => { + + let result; + const defaultMessage = 'Everyone just loves the saltyness of popcorn kebab brushd with sugar.'; + + try { + throw 'Some expression'; + } catch (e) { + result = getErrorMessage(e, defaultMessage); + } + + expect(result) + .toBe(defaultMessage); + }); + }); + +}); \ No newline at end of file diff --git a/tests/browser/packages/support/objects/isCloneable.test.js b/tests/browser/packages/support/objects/isCloneable.test.js new file mode 100644 index 00000000..e215d0cd --- /dev/null +++ b/tests/browser/packages/support/objects/isCloneable.test.js @@ -0,0 +1,25 @@ +import { isCloneable } from "@aedart/support/objects"; + +describe('@aedart/support/objects', () => { + describe('isCloneable', () => { + + it('can determine if is cloneable', () => { + + const dataSet = [ + { value: [], expected: false, name: 'Array' }, + { value: null, expected: false, name: 'Null' }, + { value: {}, expected: false, name: 'Object' }, + { value: { clone: false }, expected: false, name: 'Object with clone property' }, + + { value: { clone: () => true }, expected: true, name: 'Object with clone function' }, + ]; + + for (const data of dataSet) { + expect(isCloneable(data.value)) + .withContext(`${data.name} was expected to ${data.expected.toString()}`) + .toBe(data.expected); + } + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/objects/isPopulatable.test.js b/tests/browser/packages/support/objects/isPopulatable.test.js new file mode 100644 index 00000000..0b95080b --- /dev/null +++ b/tests/browser/packages/support/objects/isPopulatable.test.js @@ -0,0 +1,25 @@ +import { isPopulatable } from "@aedart/support/objects"; + +describe('@aedart/support/objects', () => { + describe('isPopulatable', () => { + + it('can determine if is populatable', () => { + + const dataSet = [ + { value: [], expected: false, name: 'Array' }, + { value: null, expected: false, name: 'Null' }, + { value: {}, expected: false, name: 'Object' }, + { value: { populate: false }, expected: false, name: 'Object with populate property' }, + + { value: { populate: () => true }, expected: true, name: 'Object with populate function' }, + ]; + + for (const data of dataSet) { + expect(isPopulatable(data.value)) + .withContext(`${data.name} was expected to ${data.expected.toString()}`) + .toBe(data.expected); + } + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/objects/merge.test.js b/tests/browser/packages/support/objects/merge.test.js new file mode 100644 index 00000000..0af3fcba --- /dev/null +++ b/tests/browser/packages/support/objects/merge.test.js @@ -0,0 +1,933 @@ +import { DANGEROUS_PROPERTIES } from "@aedart/contracts/support/objects"; +import { TYPED_ARRAY_PROTOTYPE } from "@aedart/contracts/support/reflections"; +import { + merge, + Merger, + MergeError +} from "@aedart/support/objects"; + +describe('@aedart/support/objects', () => { + describe('merge', () => { + + it('returns object merger instance when no arguments given', () => { + + const result = merge(); + + expect(result) + .toBeInstanceOf(Merger); + }); + + it('can merge primitive values', () => { + + const MY_SYMBOL_A = Symbol('a'); + const MY_SYMBOL_B = Symbol('b'); + + const a = { + 'string': 'hi', + 'int': 1, + 'float': 1.5, + 'bigint': BigInt(9000000000000000), + 'boolean': true, + 'undefined': undefined, // Redundant... + 'symbol': MY_SYMBOL_A, + 'null': null, // Redundant... + }; + const b = { + 'string': 'there', + 'int': 2, + 'float': 2.3, + 'bigint': BigInt(9000000000000001), + 'boolean': false, + 'undefined': undefined, // Redundant... + 'symbol': MY_SYMBOL_B, + 'null': null, // Redundant... + }; + + // --------------------------------------------------------------------- // + + const result = merge(a, b); + + // Debug + //console.log('result', result); + + const keys = Reflect.ownKeys(result); + expect(keys.length) + .withContext('Incorrect amount of keys merged') + .toBe(Reflect.ownKeys(b).length); + + for (const key of keys) { + const expected = b[key]; + + expect(result[key]) + .withContext(`Incorrect value for key ${key}`) + .toBe(expected); + } + }); + + it('overwrites existing values with undefined be default', () => { + const a = { + 'foo': 'bar' + }; + const b = { + 'foo': undefined + }; + + // --------------------------------------------------------------------- // + + const result = merge(a, b); + + // Debug + //console.log('result', result); + + expect(result['foo']) + .withContext('Value should be undefined') + .toBeUndefined(); + }); + + it('can avoid overwriting existing values with undefined', () => { + const a = { + 'foo': 'bar' + }; + const b = { + 'foo': undefined + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ overwriteWithUndefined: false }) + .of(a, b); + + // Debug + // console.log('result', result); + + expect(result['foo']) + .withContext('Value should NOT be undefined') + .toBe(a['foo']) + }); + + it('can apply custom merge callback', () => { + const a = { + 'a': 1 + }; + const b = { + 'b': 2 + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using((target, next, options) => { + const { key, value } = target; + if (key === 'b') { + return value + 1; + } + + return value; + }) + .of(a, b); + + // --------------------------------------------------------------------- // + + expect(Reflect.has(result, 'a')) + .withContext('a missing') + .toBeTrue(); + expect(result['a']) + .withContext('Merge callback not applied for a') + .toBe(a['a']); + + expect(Reflect.has(result, 'b')) + .withContext('b missing') + .toBeTrue() + expect(result['b']) + .withContext('Merge callback not applied for b') + .toBe(b['b'] + 1); + }); + + it('skips dangerous keys', () => { + const a = { + 'foo': 'bar' + }; + const b = { + prototype: { 'bar': true } + }; + const c = { + __proto__: { 'zar': false } + } + + // --------------------------------------------------------------------- // + + const result = merge(a, b, c); + + // Debug + // console.log('result', result); + + for (const key of DANGEROUS_PROPERTIES) { + expect(Reflect.has(result, key)) + .withContext(`Dangerous key (${key}) is not skipped`) + .toBeFalse(); + } + }); + + it('can skip custom provided keys', () => { + const a = { + 'foo': 'bar' + }; + const b = { + 'bar': 'foo', + __proto__: { 'admin': true } + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ skip: [ 'foo' ] }) + .of(a, b); + + // Debug + // console.log('result', result); + + expect(Reflect.has(result, 'bar')) + .withContext('None skipped key missing') + .toBeTrue(); + + expect(Reflect.has(result, 'foo')) + .withContext('Skipped key is in output') + .toBeFalse(); + + for (const key of DANGEROUS_PROPERTIES) { + expect(Reflect.has(result, key)) + .withContext(`Dangerous key (${key}) is not skipped`) + .toBeFalse(); + } + }); + + it('can skip keys via callback', () => { + const a = { + 'foo': 'bar' + }; + const b = { + 'bar': 'foo', + 'ab': 'ba' + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ + skip: (key, source) => { + return key === 'ab' && Reflect.has(source, key); + } + }) + .of(a, b); + + // Debug + // console.log('result', result); + + expect(Reflect.has(result, 'foo')) + .withContext('Incorrect key (foo) skipped') + .toBeTrue(); + expect(Reflect.has(result, 'bar')) + .withContext('Incorrect key (bar) skipped') + .toBeTrue(); + + expect(Reflect.has(result, 'ab')) + .withContext('Skipped key is in output') + .toBeFalse(); + }); + + it('can merge values of symbol keys', () => { + + const MY_SYMBOL_KEY = Symbol('a'); + + const a = { + [MY_SYMBOL_KEY]: true + }; + const b = { + [MY_SYMBOL_KEY]: 'Wee' + }; + + // --------------------------------------------------------------------- // + + const result = merge(a, b); + + // Debug + //console.log('result', result); + + const keys = Reflect.ownKeys(result); + expect(keys.length) + .withContext('Incorrect amount of keys merged') + .toBe(Reflect.ownKeys(b).length); + + for (const key of keys) { + const expected = b[key]; + + expect(result[key]) + .withContext(`Incorrect value for symbol key`) + .toBe(expected); + } + }); + + it('overwrites array properties by default', () => { + + const a = { + 'arr': [ 1, 2, 3 ] + }; + const b = { + 'arr': [ 4, 5, 6 ] + }; + + // --------------------------------------------------------------------- // + + const result = merge(a, b); + + // Debug + // console.log('result', result); + + const expected = JSON.stringify(b); + expect(JSON.stringify(result)) + .withContext('Array value not overwritten by default') + .toBe(expected); + + expect(result['arr'] === b['arr']) + .withContext('Array property is not copied (structured copy was expected)') + .toBeFalse(); + }); + + it('can merge array values', () => { + + const a = { + 'arr': [ 1, 2, 3 ] + }; + const b = { + 'arr': [ 4, 5, 6 ] + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ + mergeArrays: true + }) + .of(a, b); + + // Debug + // console.log('result', result); + + const expected = JSON.stringify({ + 'arr': [ ...a['arr'], ...b['arr'] ] + }); + expect(JSON.stringify(result)) + .withContext('Array value not merged') + .toBe(expected); + }); + + it('fails when array values contain none-cloneable values', () => { + + const callback = () => { + const a = { + 'arr': [ 1, 2, 3 ] + }; + const b = { + 'arr': [ function() {} ] + }; + + return merge(a, b); + } + + // --------------------------------------------------------------------- // + + expect(callback) + .toThrowError(MergeError); + }); + + it('can merge concat spreadable object values', () => { + + const a = { + 'a': [ 1, 2, 3 ], + 'b': [ 'foo' ], + 'c': { + [Symbol.isConcatSpreadable]: true, + length: 1, + 0: 'bar', + }, + }; + const b = { + 'a': { + [Symbol.isConcatSpreadable]: true, + length: 3, + 0: 'a', + 1: 'b', + 2: 'c' + }, + 'b': { + [Symbol.isConcatSpreadable]: false, + length: 2, + 0: 'bar', + 1: 'zar', + }, + 'c': [ 'foo' ] + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ + mergeArrays: true + }) + .of(a, b); + + // Debug + // console.log('result', result); + + expect(result.a) + .withContext('a) Array was not merged correctly with concat spreadable set to true') + .toEqual([ 1, 2, 3, 'a', 'b', 'c' ]); + + expect(JSON.stringify(result.b)) + .withContext('b) Array was not merged correctly with concat spreadable set to false') + .toBe(JSON.stringify([ 'foo', b['b'] ])); + + expect(result.c) + .withContext('c) Merged failed on top of object with concat spreadable set to true') + .toEqual([ 'bar', 'foo' ]); + }); + + it('does not merge array-like objects by default', () => { + + const a = { + 'a': [ 1, 2, 3 ], + 'b': { + length: 2, + 0: 'a', + 1: 'b', + }, + 'c': { + length: 1, + 0: 'foo', + }, + 'd': [ 3, 4, 5 ], + 'e': [ 6, 7, 8 ], + }; + const b = { + 'a': { + length: 2, + 0: 'a', + 1: 'b', + }, + 'b': [ 'foo' ], + 'c': { + length: 2, + 1: 'bar', + }, + 'd': new String('foo'), + 'e': new Int8Array(2) + }; + + // --------------------------------------------------------------------- // + + const result = merge(a, b); + + // Debug + // console.log('result', result); + + expect(JSON.stringify(result.a)) + .withContext('a) failed to overwrite existing value with array-like object') + .toBe(JSON.stringify({ 0: 'a', 1: 'b', length: 2 })); + + expect(JSON.stringify(result.b)) + .withContext('b) failed to overwrite existing array-like value with array') + .toBe(JSON.stringify([ 'foo' ])); + + expect(JSON.stringify(result.c)) + .withContext('c) failed to merge existing array-like value with array-like object') + .toBe(JSON.stringify({ 0: 'foo', 1: 'bar', length: 2 })); + + expect(result.d) + .withContext('d) String object should not be considered array-like (in this context)') + .toBeInstanceOf(String); + + expect(result.e) + .withContext('e) Typed Array object should not be considered array-like (in this context)') + .toBeInstanceOf(Int8Array); + }); + + it('can merge array-like objects', () => { + + const a = { + 'a': [ 1, 2, 3 ], + 'b': { + // NOTE: Object is not concat spreadable, so entire object should be expected! + length: 2, + 0: 'a', + 1: 'b', + }, + 'c': { + length: 1, + 0: 'foo', + }, + 'd': [ 3, 4, 5 ], + 'e': [ 6, 7, 8 ], + }; + const b = { + 'a': { + // NOTE: Object is not concat spreadable, so entire object should be expected! + length: 2, + 0: 'a', + 1: 'b', + }, + 'b': [ 'foo' ], + 'c': { + length: 2, + 1: 'bar', + }, + 'd': new String('foo'), + 'e': new Int8Array(2) + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ + mergeArrays: true + }) + .of(a, b); + + // Debug + // console.log('result', result); + + expect(JSON.stringify(result.a)) + .withContext('a) should have merged existing array with array-like object') + .toBe(JSON.stringify([1, 2, 3, {0: 'a', 1: 'b', length: 2}])); + + expect(JSON.stringify(result.b)) + .withContext('b) should have merged array-like object with an array') + .toBe(JSON.stringify([{0: 'a', 1: 'b', length: 2}, 'foo'])); + + expect(JSON.stringify(result.c)) + .withContext('c) failed to merge array-like value with array-like object') + .toBe(JSON.stringify({0: 'foo', 1: 'bar', length: 2})); + + expect(result.d) + .withContext('d) String object should not be considered array-like (in this context)') + .toBeInstanceOf(String); + + expect(result.e) + .withContext('e) Typed Array object should not be considered array-like (in this context)') + .toBeInstanceOf(Int8Array); + }); + + it('creates shallow copy of functions', () => { + const a = { + 'foo': null, + }; + const b = { + 'foo': function() {} + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ + mergeArrays: true + }) + .of(a, b); + + expect(Reflect.has(result, 'foo')) + .withContext('Key with function value not merged') + .toBeTrue(); + + expect(result['foo']) + .withContext('Function not referenced in result') + .toBe(b['foo']) + }); + + it('can merge nested objects', () => { + const a = { + 'foo': null, + 'bar': { + 'name': 'Risk' + } + }; + const b = { + 'foo': { + 'name': 'John' + }, + 'bar': { + 'age': 31, + 'address': { + 'street': 'Somewhere Str. 654' + } + } + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ + mergeArrays: true + }) + .of(a, b); + + // Debug + // console.log('result', result) + + const expected = JSON.stringify({ + 'foo': { + 'name': 'John' + }, + 'bar': { + 'name': 'Risk', + 'age': 31, + 'address': { + 'street': 'Somewhere Str. 654' + } + } + }); + + expect(JSON.stringify(result)) + .withContext('Incorrect merge of nested objects') + .toBe(expected) + }); + + it('fails when invalid maximum depth option provided', () => { + + const a = { + 'foo': 'bar', + }; + const b = { + 'foo': true + }; + + // --------------------------------------------------------------------- // + + const callback = () => { + return merge() + .using({ + depth: -1 + }) + .of(a, b); + } + + // --------------------------------------------------------------------- // + + expect(callback) + .toThrowError(MergeError); + }); + + it('fails when maximum depth has been exceeded', () => { + + const a = { + 'person': { + 'name': 'Risk' + } + }; + const b = { + 'person': { + 'age': 31, + 'address': { + 'street': 'Somewhere Str. 654' // This depth level should cause failure... + } + } + }; + + // --------------------------------------------------------------------- // + + const callback = () => { + return merge() + .using({ + depth: 1 + }) + .of(a, b); + } + + // --------------------------------------------------------------------- // + + expect(callback) + .toThrowError(MergeError); + }); + + it('can merge with maximum depth set to zero', () => { + + const a = { + 'foo': false, + }; + const b = { + 'foo': true, + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ + depth: 0 + }) + .of(a, b); + + // --------------------------------------------------------------------- // + + expect(Reflect.has(result, 'foo') && result['foo'] === true) + .withContext('Failed to merge with maximum depth option set to zero') + .toBeTrue() + }); + + it('can clones objects of native kind', () => { + + const now = new Date(); + + const dataSet = [ + { + name: 'ArrayBuffer', + source: { value: new ArrayBuffer(8) }, + expectedInstanceOf: ArrayBuffer, + match: (cloned) => { + return cloned.byteLength === 8; + } + }, + { + name: 'Boolean', + source: { value: new Boolean(true) }, + expectedInstanceOf: Boolean, + match: (cloned) => { + return cloned.valueOf() === true; + } + }, + { + name: 'DataView', + source: { value: new DataView(new ArrayBuffer(16)) }, + expectedInstanceOf: DataView, + match: (cloned) => { + return cloned.buffer.byteLength === 16; + } + }, + { + name: 'Date', + source: { value: now }, + expectedInstanceOf: Date, + match: (cloned) => { + return cloned.valueOf() === now.valueOf(); // milliseconds for since the epoch for date + } + }, + { + name: 'Error', + source: { value: new TypeError('foo') }, + expectedInstanceOf: Error, + match: (cloned) => { + return cloned.message === 'foo'; + } + }, + { + name: 'Map', + source: { value: new Map([ + [ 'a', 1 ], + [ 'b', 2 ], + [ 'c', 3 ], + ]) }, + expectedInstanceOf: Map, + match: (cloned) => { + return cloned.has('a') && cloned.get('a') === 1 + && cloned.has('b') && cloned.get('b') === 2 + && cloned.has('c') && cloned.get('c') === 3 + } + }, + { + name: 'Number', + source: { value: new Number(42) }, + expectedInstanceOf: Number, + match: (cloned) => { + return cloned.valueOf() === 42; + } + }, + { + name: 'RegEx', + source: { value: new RegExp("bar", "g") }, + expectedInstanceOf: RegExp, + match: (cloned) => { + return cloned.toString() === '/bar/g' + } + }, + { + name: 'Set', + source: { value: new Set([ 1, 2, 3 ]) }, + expectedInstanceOf: Set, + match: (cloned) => { + return cloned.has(1) + && cloned.has(2) + && cloned.has(3); + } + }, + { + name: 'String', + source: { value: new String('John Doe') }, + expectedInstanceOf: String, + match: (cloned) => { + return cloned.valueOf() === 'John Doe'; + } + }, + { + name: 'TypedArray', + source: { value: new Int16Array(new ArrayBuffer(16)) }, + expectedInstanceOf: TYPED_ARRAY_PROTOTYPE, + match: (cloned) => { + return cloned.byteLength === 16; + } + }, + ]; + + // --------------------------------------------------------------------- // + + for (const entry of dataSet) { + const target = {}; + + const result = merge(target, entry.source); + + expect(Reflect.has(result, 'value')) + .withContext(`No value property in result for ${entry.name}`) + .toBeTrue(); + + expect(result.value instanceof entry.expectedInstanceOf) + .withContext(`Invalid instanceof for ${entry.name}`) + .toBeTrue(); + + expect(result.value === entry.source.value) + .withContext(`Shallow copy was made for ${entry.name}`) + .toBeFalse(); + + expect(entry.match(result.value)) + .withContext(`Custom value match failed for ${entry.name}`) + .toBeTrue(); + } + }); + + it('does not clone objects of "Weak Reference" kind', () => { + + class A {} + + const a = {}; + const b = { + 'a' : new WeakRef(new A()), + 'b' : new WeakMap([ + [ new A(), 'foo' ] + ]), + 'c' : new WeakSet([ new A() ]) + } + + // --------------------------------------------------------------------- // + + const result = merge(a, b); + + // Debug + // console.log('result', result); + + expect(result['a'] === b['a']) + .withContext('a) WeakRef not same instance') + .toBeTrue(); + + expect(result['b'] === b['b']) + .withContext('b) WeakMap not same instance') + .toBeTrue(); + + expect(result['c'] === b['c']) + .withContext('c) WeakSet not same instance') + .toBeTrue(); + }); + + it('favours cloneable object\'s clone() method', () => { + + const a = { + a: { + name: 'John', + age: 42 + } + }; + + const b = { + a: { + name: 'John', // Property should be ignored, due to clone() + + clone: () => { + return { + name: 'Rick' + } + } + } + }; + + // --------------------------------------------------------------------- // + + const result = merge(a, b); + + // Debug + // console.log('result', result); + + expect(result.a.name) + .withContext('Clone method not favoured') + .toBe('Rick'); + expect(result.a.age) + .withContext('Other properties are not merged in correctly') + .toBe(42) + }); + + it('can disable cloneable behaviour', () => { + + const a = { + a: { + name: 'John', + } + }; + + const b = { + a: { + name: 'Jim', + clone: () => { + return { + name: 'Rick' + } + } + } + }; + + // --------------------------------------------------------------------- // + + const result = merge() + .using({ + useCloneable: false + }) + .of(a, b); + + // Debug + // console.log('result', result); + + expect(result.a.name) + .withContext('Clone was not disabled') + .toBe('Jim'); + }); + + it('fails when cloneable source returns invalid value', () => { + + const a = { + a: { + name: 'John', + } + }; + + const b = { + a: { + name: 'Jim', + clone: () => { + return null; // Should cause error + } + } + }; + + // --------------------------------------------------------------------- // + + const callback = () => { + return merge(a, b); + } + + expect(callback) + .toThrowError(MergeError); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/objects/populate.test.js b/tests/browser/packages/support/objects/populate.test.js new file mode 100644 index 00000000..04cc47b4 --- /dev/null +++ b/tests/browser/packages/support/objects/populate.test.js @@ -0,0 +1,206 @@ +import { populate } from "@aedart/support/objects"; + +describe('@aedart/support/objects', () => { + describe('populate()', () => { + + it('populates all source properties into target', () => { + + class A { + name = null; + age = null; + + constructor(data) { + populate(this, data); + } + } + + const data = { + name: 'Ally', + age: 28 + }; + + // --------------------------------------------------------------------------- // + + const result = new A(data); + + expect(result.name) + .withContext('Property "name" not populated correctly') + .toBe(data.name); + + expect(result.age) + .withContext('Property "age" not populated correctly') + .toBe(data.age); + }); + + it('populates selected source properties into target', () => { + + class A { + name = null; + age = null; + + constructor(data) { + populate(this, data, 'name'); + } + } + + const data = { + name: 'Ally', + age: 28 + }; + + // --------------------------------------------------------------------------- // + + const result = new A(data); + + expect(result.name) + .withContext('Property "name" not populated correctly') + .toBe(data.name); + + expect(result.age) + .withContext('Property "age" not populated correctly') + .toBeNull(); + }); + + it('can select source keys via callback', () => { + + const a = { + name: null, + age: null, + }; + + const b = { + name: 'John Doe', age: 29 + } + + const c = { + name: 'Gwen', age: 42 + }; + + // --------------------------------------------------------------------------- // + + const callback = (source) => { + const keys = [ 'name' ]; + + if (Reflect.has(source, 'age') && source.age < 40) { + keys.push('age'); + } + + return keys; + } + + // Populate with b... + const result = populate(a, b, callback); + + // Populate again, but with c + populate(a, c, callback); + + expect(result.name) + .withContext('Property "name" (from c) not populated correctly') + .toBe(c.name); + + expect(result.age) + .withContext('Property "age" (from b) not populated correctly') + .toBe(b.age); + }); + + it('prevents prototype pollution', () => { + + const A = { + name: null, + age: null, + } + + const data = { + __proto__: { + admin: true + }, + name: 'Ally', + age: 28 + }; + + // --------------------------------------------------------------------------- // + + // NOTE: Reflect.ownKeys never returns '__proto__', so here we attempt to force + // populate() to inject it (which should be prevented) + const result = populate(A, data, [ + 'name', + 'age', + '__proto__' // Attempt to trick populate... + ]); + + // Debug + // console.log('__proto__', result, result.__proto__); + + const prototype = Reflect.getPrototypeOf(result); + + expect(Reflect.has(prototype, 'admin')) + .withContext('Prototype pollution NOT prevented') + .toBeFalse(); + + expect(result.name) + .withContext('Property "name" not populated correctly') + .toBe(data.name); + + expect(result.age) + .withContext('Property "age" not populated correctly') + .toBe(data.age); + }); + + it('fails if key does not exist in target', () => { + + class A { + name = null; + age = null; + + constructor(data) { + populate(this, data); + } + } + + const data = { + name: 'Sweeney', + age: 36, + title: 'Gardner' + }; + + // --------------------------------------------------------------------------- // + + const callback = () => { + return new A(data); + } + + expect(callback) + .toThrowError(TypeError); + }); + + it('can inject properties that target does not have, when "safe" mode disabled', () => { + + class A { + name = null; + age = null; + + constructor(data) { + populate(this, data, '*', false); + } + } + + const data = { + name: 'Sweeney', + age: 36, + title: 'Gardner' + }; + + // --------------------------------------------------------------------------- // + + const result = new A(data); + + expect(Reflect.has(result, 'title')) + .withContext('Property "title" not populated') + .toBeTrue(); + + expect(result.title) + .withContext('Property "title" not populated correctly') + .toBe(data.title); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/assertHasPrototypeProperty.test.js b/tests/browser/packages/support/reflections/assertHasPrototypeProperty.test.js new file mode 100644 index 00000000..6d32416f --- /dev/null +++ b/tests/browser/packages/support/reflections/assertHasPrototypeProperty.test.js @@ -0,0 +1,31 @@ +import { assertHasPrototypeProperty } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('assertHasPrototypeProperty()', () => { + + it('does not throw when object has prototype property', () => { + + const callback = () => { + const obj = { __proto__: function() {} }; + + assertHasPrototypeProperty(obj); + } + + expect(callback) + .not + .toThrowError(TypeError) + }); + + it('throws when object has no prototype property', () => { + + const callback = () => { + const obj = { __proto__: null }; + + assertHasPrototypeProperty(obj); + } + + expect(callback) + .toThrowError(TypeError) + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/classLooksLike.test.js b/tests/browser/packages/support/reflections/classLooksLike.test.js new file mode 100644 index 00000000..348436d3 --- /dev/null +++ b/tests/browser/packages/support/reflections/classLooksLike.test.js @@ -0,0 +1,160 @@ +import { classLooksLike } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('classLooksLike()', () => { + + it('fails if blueprint has no members or static members property defined', () => { + class A {} + + const callback = () => { + classLooksLike(A, { }); + } + + expect(callback) + .toThrowError(TypeError); + }); + + it('does not fail when members property is defined in blueprint', () => { + class A {} + + const callback = () => { + classLooksLike(A, { members: [] }); + } + + expect(callback) + .not + .toThrowError(TypeError); + }); + + it('does not fail when static members property is defined in blueprint', () => { + class A {} + + const callback = () => { + classLooksLike(A, { staticMembers: [] }); + } + + expect(callback) + .not + .toThrowError(TypeError); + }); + + it('can determine if class looks like blueprint', () => { + + class A { + foo() {} + + bar() {} + + static sayHi() {} + } + + class B extends A { + + get zim() {} + + static goodBye() {} + } + + // --------------------------------------------------------------------------------------- // + + const data = [ + { + target: A, + blueprint: { + members: [ + 'foo', + 'bar', + 'zim' // does not exist in A + ] + }, + expected: false, + name: 'A (member that does not exist)' + }, + { + target: A, + blueprint: { + members: [ + 'bar', + 'foo', + ] + }, + expected: true, + name: 'A (all members that exist)' + }, + { + target: A, + blueprint: { + staticMembers: [ + 'sayHi', + 'goodBye' // does not exist in A + ], + members: [ + 'bar', + 'foo', + ] + }, + expected: false, + name: 'A (static member that does not exist)' + }, + { + target: B, + blueprint: { + members: [ + 'foo', // inherited + 'bar', // inherited + 'zim' + ] + }, + expected: true, + name: 'B (inherited members that exist)' + }, + { + target: B, + blueprint: { + staticMembers: [ + 'sayHi', // inherited + 'goodBye' + ], + members: [ + // Should just be ignored + ] + }, + expected: true, + name: 'B (inherited static members that exist)' + }, + { + target: B, + blueprint: { + staticMembers: [ + 'sayHi', // inherited + 'goodBye' + ], + members: [ + 'foo', // inherited + 'bar', // inherited + 'zim' + ] + }, + expected: true, + name: 'B (inherited all members that exist)' + }, + { + target: B, + blueprint: { + staticMembers: [], + members: [] + }, + expected: false, + name: 'B (empty blueprint)' + }, + ]; + + for (const entry of data) { + expect(classLooksLike(entry.target, entry.blueprint)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/classOwnKeys.test.js b/tests/browser/packages/support/reflections/classOwnKeys.test.js new file mode 100644 index 00000000..dbec3cb1 --- /dev/null +++ b/tests/browser/packages/support/reflections/classOwnKeys.test.js @@ -0,0 +1,83 @@ +import { classOwnKeys } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('classOwnKeys()', () => { + + it('can return all class property keys', () => { + + class A { + foo() {} + + get bar() {} + } + + // ----------------------------------------------------------------------- // + + const result = classOwnKeys(A); + + // Debug + // console.log('result', result); + + expect(result) + .withContext('Incorrect property keys returned') + .toEqual([ 'constructor', 'foo', 'bar' ]); + }); + + it('can return class property keys recursively', () => { + + class A { + foo() {} + } + + class B extends A { + get bar() {} + } + + class C extends B { + zar() {} + } + + // ----------------------------------------------------------------------- // + + const result = classOwnKeys(C, true); + + // Debug + // console.log('result', result); + + expect(result) + .withContext('Incorrect property keys returned') + .toEqual([ 'constructor', 'foo', 'bar', 'zar' ]); + }); + + it('can return class property keys recursively (via class static method)', () => { + + class A { + a() {} + } + + class B extends A { + get b() {} + } + + class C extends B { + c() {} + + static keys() { + return classOwnKeys(this, true); + } + } + + // ----------------------------------------------------------------------- // + + const result = C.keys(); + + // Debug + // console.log('result', result); + + expect(result) + .withContext('Incorrect property keys returned') + .toEqual([ 'constructor', 'a', 'b', 'c' ]); + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/getAllParentsOfClass.test.js b/tests/browser/packages/support/reflections/getAllParentsOfClass.test.js new file mode 100644 index 00000000..07d4aac3 --- /dev/null +++ b/tests/browser/packages/support/reflections/getAllParentsOfClass.test.js @@ -0,0 +1,78 @@ +import { getAllParentsOfClass } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('getAllParentsOfClass()', () => { + + it('fails when no arguments given', () => { + const callback = () => { + return getAllParentsOfClass(); + } + + expect(callback) + .toThrowError(TypeError); + }); + + it('returns all parent classes', () => { + + class A {} + class B extends A {} + class C extends B {} + + const parents = getAllParentsOfClass(C); + + // Debug + // console.log('parents of C', parents); + + expect(parents.length) + .withContext('Incorrect amount of parents returned') + .toEqual(2); + + expect(parents[0]) + .withContext('Incorrect parent of C') + .toEqual(B); + + expect(parents[1]) + .withContext('Incorrect parent of B') + .toEqual(A); + }); + + it('includes target in output', () => { + + class A {} + class B extends A {} + class C extends B {} + + const parents = getAllParentsOfClass(C, true); + + // Debug + // console.log('parents', parents); + + expect(parents.length) + .withContext('Incorrect amount of parents returned') + .toEqual(3); + + expect(parents[0]) + .withContext('First element should be C') + .toEqual(C); + + expect(parents[1]) + .withContext('Incorrect parent of C') + .toEqual(B); + + expect(parents[2]) + .withContext('Incorrect parent of B') + .toEqual(A); + }); + + it('returns empty array when target has no parents', () => { + + class A {} + + const parents = getAllParentsOfClass(A); + + expect(parents.length) + .withContext('A should not have any parents') + .toEqual(0); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/getClassPropertyDescriptor.test.js b/tests/browser/packages/support/reflections/getClassPropertyDescriptor.test.js new file mode 100644 index 00000000..0218777d --- /dev/null +++ b/tests/browser/packages/support/reflections/getClassPropertyDescriptor.test.js @@ -0,0 +1,78 @@ +import {getClassPropertyDescriptor} from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + + describe('getClassPropertyDescriptor()', () => { + + it('fails when target has no prototype', () => { + const callback = () => { + const obj = Object.create(null); + + getClassPropertyDescriptor(obj, 'name'); + } + + expect(callback) + .withContext('Should not be able to obtain anything from object without prototype') + .toThrowError(TypeError); + }); + + it('returns undefined if property does not exist', () => { + class A {} + + const descriptor = getClassPropertyDescriptor(A, 'unknown_property'); + expect(descriptor) + .toBeUndefined(); + }); + + it('can get property descriptor from target prototype', () => { + + const MY_SYMBOL = Symbol('my_symbol'); + + class A { + set name(v) {} + get name() {} + foo() {} + [MY_SYMBOL]() {} + } + + const properties = [ + 'name', + 'foo', + MY_SYMBOL + ]; + + for (const key of properties) { + const descriptor = getClassPropertyDescriptor(A, key); + + // Debug + // console.log(descriptor); + + let k = (typeof key == "symbol") + ? key.toString() + : key + + expect(descriptor) + .withContext('No descriptor returned for ' + k) + .not + .toBeUndefined() + } + }); + + it('returns undefined if property is private', () => { + class A { + #foo() {} + } + + const a = getClassPropertyDescriptor(A, 'foo'); + expect(a) + .withContext('Returned descriptor for "foo"') + .toBeUndefined(); + + const b = getClassPropertyDescriptor(A, '#foo'); + expect(b) + .withContext('Returned descriptor for "#foo"') + .toBeUndefined(); + }); + }); + +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/getClassPropertyDescriptors.test.js b/tests/browser/packages/support/reflections/getClassPropertyDescriptors.test.js new file mode 100644 index 00000000..05e77b4c --- /dev/null +++ b/tests/browser/packages/support/reflections/getClassPropertyDescriptors.test.js @@ -0,0 +1,214 @@ +import {getClassPropertyDescriptor, getClassPropertyDescriptors} from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + + describe('getClassPropertyDescriptors()', () => { + + it('fails when target has no prototype', () => { + const callback = () => { + const obj = Object.create(null); + + getClassPropertyDescriptors(obj); + } + + expect(callback) + .withContext('Should not be able to obtain anything from object without prototype') + .toThrowError(TypeError); + }); + + it('can get property descriptors for class', () => { + + const MY_SYMBOL = Symbol('my_symbol'); + + class A { + set name(v) {} + get name() {} + bar() {} + [MY_SYMBOL]() {} + } + + // -------------------------------------------------------------------------------- // + + const expected = [ + 'constructor', + 'name', + 'bar', + MY_SYMBOL + ]; + + const descriptors = getClassPropertyDescriptors(A); + + // Debug + // console.log(descriptors); + + for (const key of expected) { + let k = (typeof key == "symbol") + ? key.toString() + : key + + expect(Reflect.has(descriptors, key)) + .withContext('Key ' + k + ' not in descriptors record') + .toBeTrue(); + + const descriptor = descriptors[key]; + + // Debug + // console.log(key, descriptor); + + expect(descriptor) + .withContext('No descriptor returned for ' + k) + .not + .toBeUndefined(); + } + }); + + it('can get property descriptors for class recursively', () => { + + const MY_SYMBOL = Symbol('my_symbol'); + + class A { + set name(v) {} + get name() {} + foo() {} + [MY_SYMBOL]() {} + } + + class B extends A { + set bar(v) {} + get bar() {} + } + + // -------------------------------------------------------------------------------- // + + const expected = [ + 'constructor', + 'name', + 'foo', + 'bar', + MY_SYMBOL + ]; + + const descriptors = getClassPropertyDescriptors(B, true); + for (const key of expected) { + let k = (typeof key == "symbol") + ? key.toString() + : key + + expect(Reflect.has(descriptors, key)) + .withContext('Key ' + k + ' not in descriptors record') + .toBeTrue(); + + const descriptor = descriptors[key]; + expect(descriptor) + .withContext('No descriptor returned for ' + k) + .not + .toBeUndefined(); + } + }); + + it('returns top-most property descriptors', () => { + + const MY_SYMBOL = Symbol('my_symbol'); + + class A { + set name(v) {} + get name() {} + foo() {} + [MY_SYMBOL]() {} + } + + class B extends A { + set name(v) {} + get name() {} + foo() {} + [MY_SYMBOL]() { + return false; + } + } + + // -------------------------------------------------------------------------------- // + + const expected = [ + 'constructor', + 'name', + 'foo', + MY_SYMBOL + ]; + + const descriptors = getClassPropertyDescriptors(B, true); + for (const key of expected) { + let k = (typeof key == "symbol") + ? key.toString() + : key + + const parentDescriptor = getClassPropertyDescriptor(A, key); + const targetDescriptor = getClassPropertyDescriptor(B, key); + const descriptor = descriptors[key]; + + // Debug + // console.log('parent', key, parentDescriptor); + // console.log('target', key, descriptor); + + // Value, get, set... check of descriptor + const props = Reflect.ownKeys(descriptor); + for (const p of props) { + + // Debug + // console.log(' - parent', p, parentDescriptor[p]); + // console.log(' - target', p, descriptor[p]); + + // Skip assert if not "value", "get" or "set + if (!['value', 'get', 'set'].includes(p)) { + continue; + } + + // Ensure does not match parent's descriptor... + expect(descriptor[p] !== parentDescriptor[p]) + .withContext(`${k}[${p}] matches parent descriptor property, but SHOULD NOT do so`) + .toBeTrue(); + + // Double check... + expect(descriptor[p] === targetDescriptor[p]) + .withContext(`${k}[${p}] does NOT match target property descriptor property!`) + .toBeTrue(); + } + + // Debug + // console.log('- - - '.repeat(15)); + } + }); + + it('merges property descriptors', () => { + + class A { + get age() {} + } + + class B extends A { + set age(value) {} + } + + // -------------------------------------------------------------------------------- // + + const descriptors = getClassPropertyDescriptors(B, true); + + expect(Reflect.has(descriptors, 'age')) + .withContext('age property descriptor not in output') + .toBeTrue(); + + const ageDesc = descriptors['age']; + + // Debug + // console.log('Age property descriptor', ageDesc); + + expect(typeof ageDesc.set !== 'undefined') + .withContext('set() function not specified in descriptor') + .toBeTrue(); + + expect(typeof ageDesc.get !== 'undefined') + .withContext('get() function not specified in descriptor') + .toBeTrue(); + }); + }); + +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/getConstructorName.test.js b/tests/browser/packages/support/reflections/getConstructorName.test.js new file mode 100644 index 00000000..c4430bd9 --- /dev/null +++ b/tests/browser/packages/support/reflections/getConstructorName.test.js @@ -0,0 +1,40 @@ +import { getConstructorName } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('getConstructorName()', () => { + + it('can obtain class constructor name', () => { + class Box {} + + const result = getConstructorName(Box); + + // Debug + // console.log(result); + + expect(result) + .toBe('Box'); + }); + + it('returns null for anonymous class', () => { + const result = getConstructorName(class {}); + + // Debug + // console.log(result); + + expect(result) + .toBeNull(); + }); + + it('returns default value', () => { + const defaultValue = 'MyBox'; + + const result = getConstructorName(class {}, defaultValue); + + // Debug + // console.log(result); + + expect(result) + .toBe(defaultValue) + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/getNameOrDesc.test.js b/tests/browser/packages/support/reflections/getNameOrDesc.test.js new file mode 100644 index 00000000..b4a8bc52 --- /dev/null +++ b/tests/browser/packages/support/reflections/getNameOrDesc.test.js @@ -0,0 +1,28 @@ +import { getNameOrDesc } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('getNameOrDesc()', () => { + + it('can obtain class constructor name', () => { + class ApiService {} + + const result = getNameOrDesc(ApiService); + + // Debug + // console.log(result); + + expect(result) + .toBe('ApiService'); + }); + + it('returns description tag for anonymous class', () => { + const result = getNameOrDesc(class {}); + + // Debug + // console.log(result); + + expect(result) + .toBe('[object Function]'); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/getParentOfClass.test.js b/tests/browser/packages/support/reflections/getParentOfClass.test.js new file mode 100644 index 00000000..d82f2eee --- /dev/null +++ b/tests/browser/packages/support/reflections/getParentOfClass.test.js @@ -0,0 +1,43 @@ +import { getParentOfClass } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('getParentOfClass()', () => { + + it('fails when no arguments given', () => { + const callback = () => { + return getParentOfClass(); + } + + expect(callback) + .toThrowError(TypeError); + }); + + it('can return parent class', () => { + + class A {} + class B extends A {} + class C extends B {} + + const parentOfC = getParentOfClass(C); + const parentOfB = getParentOfClass(B); + const parentOfA = getParentOfClass(A); + + // Debug + // console.log('Parent of C', parentOfC); + // console.log('Parent of B', parentOfB); + // console.log('Parent of A', parentOfA); + + expect(parentOfC) + .withContext('Incorrect parent of C') + .toEqual(B); + + expect(parentOfB) + .withContext('Incorrect parent of B') + .toEqual(A); + + expect(parentOfA) + .withContext('A should not have a parent') + .toBeNull() + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/has-method-all-methods.test.js b/tests/browser/packages/support/reflections/has-method-all-methods.test.js new file mode 100644 index 00000000..a7769bcb --- /dev/null +++ b/tests/browser/packages/support/reflections/has-method-all-methods.test.js @@ -0,0 +1,105 @@ +import { hasAllMethods, hasMethod } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('hasAllMethods()', () => { + + it('can determine if target has methods', () => { + + const data = [ + { + value: null, + methods: [ 'foo', 'bar' ], + expected: false, + name: 'NUll' + }, + { + value: [], + methods: [ 'foo', 'bar' ], + expected: false, + name: 'Array' + }, + { + value: {}, + methods: [ 'foo', 'bar' ], + expected: false, + name: 'Object (empty)' + }, + { + value: { + foo: () => true, + }, + methods: [ 'foo', 'bar' ], + expected: false, + name: 'Object (with some methods)' + }, + { + value: { + foo: () => true, + bar: () => true, + }, + methods: [ 'foo', 'bar' ], + expected: true, + name: 'Object (with all methods)' + }, + ]; + + for (const entry of data) { + expect(hasAllMethods(entry.value, ...entry.methods)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + + }); + }); + + describe('hasMethod()', () => { + + it('can determine if target has method', () => { + + const data = [ + { + value: null, + method: 'foo', + expected: false, + name: 'NUll' + }, + { + value: [], + method: 'foo', + expected: false, + name: 'Array' + }, + { + value: {}, + method: 'foo', + expected: false, + name: 'Object (empty)' + }, + { + value: { + foo: () => true, + }, + method: 'bar', + expected: false, + name: 'Object (with some methods)' + }, + { + value: { + foo: () => true, + bar: () => true, + }, + method: 'bar', + expected: true, + name: 'Object (with all methods)' + }, + ]; + + for (const entry of data) { + expect(hasMethod(entry.value, entry.method)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/hasPrototypeProperty.test.js b/tests/browser/packages/support/reflections/hasPrototypeProperty.test.js new file mode 100644 index 00000000..a7d5a151 --- /dev/null +++ b/tests/browser/packages/support/reflections/hasPrototypeProperty.test.js @@ -0,0 +1,63 @@ +import {hasPrototypeProperty} from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('hasPrototypeProperty()', () => { + + it('can determine if object has prototype', () => { + const obj = Object.create({ prototype: {} }); + const objWithProto = { __proto__: function() {} }; + + const nullObj = Object.create(null); + const objWithUndefinedProto = { __proto__: undefined }; + const objWithPrototypeNull = { prototype: null }; + + expect(hasPrototypeProperty(obj)) + .withContext('object from Object.create({ prototype: {} }) should have a prototype') + .toBeTrue(); + + expect(hasPrototypeProperty(objWithProto)) + .withContext('object with __proto__ should have a prototype') + .toBeTrue(); + + expect(hasPrototypeProperty(nullObj)) + .withContext('object Object.create(null) should NOT have a prototype') + .toBeFalse(); + + expect(hasPrototypeProperty(objWithUndefinedProto)) + .withContext('object with __proto__:undefined should NOT have a prototype') + .toBeFalse(); + + expect(hasPrototypeProperty(objWithPrototypeNull)) + .withContext('object with prototype:null should NOT have a prototype') + .toBeFalse(); + }); + + it('can determine if function has prototype', () => { + const fn = function() {}; + const arrowFn = () => true; + + expect(hasPrototypeProperty(fn)) + .withContext('function should have a prototype') + .toBeTrue(); + + expect(hasPrototypeProperty(arrowFn)) + .withContext('arrow function should NOT have a prototype') + .toBeFalse(); + }); + + it('can determine if class has prototype', () => { + class A {} + + expect(hasPrototypeProperty(A)) + .withContext('Class A should have a prototype') + .toBeTrue(); + }); + + it('returns false when null given', () => { + + expect(hasPrototypeProperty(null)) + .withContext('null does not have prototype') + .toBeFalse(); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/isKey-Safe-Unsafe.test.js b/tests/browser/packages/support/reflections/isKey-Safe-Unsafe.test.js new file mode 100644 index 00000000..d4d67998 --- /dev/null +++ b/tests/browser/packages/support/reflections/isKey-Safe-Unsafe.test.js @@ -0,0 +1,42 @@ +import { DANGEROUS_PROPERTIES } from "@aedart/contracts/support/objects"; +import { isKeySafe, isKeyUnsafe } from "@aedart/support/reflections"; + +describe('@aedart/support/objects', () => { + + const dataSet = [ + { value: 'name', safe: true, name: 'name' }, + { value: 'prototype', safe: true, name: 'prototype' }, + ]; + + for (const key of DANGEROUS_PROPERTIES) { + dataSet.push( + { + value: key, + safe: false, + name: typeof key == 'symbol' + ? key.description + : key + }, + ); + } + + describe('isKeySafe()', () => { + it('can determine if key is safe', () => { + for (const data of dataSet) { + expect(isKeySafe(data.value)) + .withContext(`${data.name} was expected to ${data.safe.toString()}`) + .toBe(data.safe); + } + }); + }); + + describe('isKeyUnsafe()', () => { + it('can determine if key is unsafe', () => { + for (const data of dataSet) { + expect(isKeyUnsafe(data.value)) + .withContext(`${data.name} was expected to ${data.safe.toString()}`) + .toBe(!data.safe); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/isSubclass.test.js b/tests/browser/packages/support/reflections/isSubclass.test.js new file mode 100644 index 00000000..61b2f1d7 --- /dev/null +++ b/tests/browser/packages/support/reflections/isSubclass.test.js @@ -0,0 +1,46 @@ +import { isSubclass } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('isSubclass()', () => { + + it('returns false if target has no prototype property', () => { + + const target = Object.create(null); + class A {} + + expect(isSubclass(target, A)) + .toBeFalse(); + }); + + it('returns false if superclass has no prototype property', () => { + + const target = Object.create(null); + class A {} + + expect(isSubclass(A, target)) + .toBeFalse(); + }); + + it('returns false when target and subclass params are the same', () => { + class A {} + + expect(isSubclass(A, A)) + .toBeFalse(); + }); + + it('can determine if target is a subclass', () => { + + class A {} + class B extends A {} + class C extends B {} + class D extends A {} + + expect(isSubclass(C, A)) + .toBeTrue(); + + expect(isSubclass(D, B)) + .withContext('D should not be a subclass of B') + .toBeFalse(); + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/support/reflections/isSubclassOrLooksLike.test.js b/tests/browser/packages/support/reflections/isSubclassOrLooksLike.test.js new file mode 100644 index 00000000..ae680833 --- /dev/null +++ b/tests/browser/packages/support/reflections/isSubclassOrLooksLike.test.js @@ -0,0 +1,59 @@ +import { isSubclassOrLooksLike } from "@aedart/support/reflections"; + +describe('@aedart/support/reflections', () => { + describe('isSubclassOrLooksLike()', () => { + + it('can determine if target is subclass or looks like blueprint', () => { + + class A { + foo() {} + } + class B extends A{} + + class C { + foo() {} + } + + // --------------------------------------------------------------------------------------- // + + const data = [ + { + target: B, + superclass: A, + blueprint: { + staticMembers: [], + members: [] + }, + expected: true, + name: 'B (should be superclass of A)' + }, + { + target: C, + superclass: A, + blueprint: { + staticMembers: [], + members: [ 'foo' ] + }, + expected: true, + name: 'C (should "look like" class A)' + }, + { + target: C, + superclass: B, + blueprint: { + staticMembers: [], + members: [ 'bar' ] + }, + expected: false, + name: 'C (should "look like" class B or contain member)' + }, + ]; + + for (const entry of data) { + expect(isSubclassOrLooksLike(entry.target, entry.superclass, entry.blueprint)) + .withContext(`${entry.name} was expected to ${entry.expected.toString()}`) + .toBe(entry.expected); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/xyz/laste-static-binding.test.js b/tests/browser/packages/xyz/laste-static-binding.test.js new file mode 100644 index 00000000..754bb64a --- /dev/null +++ b/tests/browser/packages/xyz/laste-static-binding.test.js @@ -0,0 +1,25 @@ +xdescribe('@aedart/xyz', () => { + describe('late static binding', () => { + + it('can obtain constructor', () => { + class A { + + static foo() { + return this; // "this" SHOULD be late static, when invoked via B + } + } + + class B extends A {} + + // ---------------------------------------------------------------------- // + + const constructor = B.foo(); + + expect(constructor) + .withContext('Incorrect constructor') + .toBe(B) + + }); + + }); +}); \ No newline at end of file diff --git a/tests/browser/packages/xyz/throws-exception-with-cause.test.js b/tests/browser/packages/xyz/throws-exception-with-cause.test.js new file mode 100644 index 00000000..f085fe44 --- /dev/null +++ b/tests/browser/packages/xyz/throws-exception-with-cause.test.js @@ -0,0 +1,12 @@ +/** + * @see https://github.com/jasmine/jasmine/issues/2028 + */ +xdescribe('@aedart/xyz', () => { + it('fails...', () => { + + throw new Error('Oh my...', { + cause: { foo: 'bar' } + }); + + }); +}); \ No newline at end of file