UNPKG

@darlean/valueobjects

Version:

Library for DDD-like value objects that can be validated, serialized and represented in other programming languages as native objects with native naming.

399 lines (398 loc) 16.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.structvalidation = exports.validateStrictFieldName = exports.ensureStructDefForConstructor = exports.StructValue = exports.StructDef = exports.structvalue = void 0; const canonical_1 = require("@darlean/canonical"); const valueobject_1 = require("./valueobject"); const base_1 = require("./base"); const STRUCT_DEF = 'struct-def'; function structvalue(logicalName, options) { // eslint-disable-next-line @typescript-eslint/ban-types return function (constructor) { const name = logicalName === undefined ? (0, valueobject_1.deriveTypeName)(constructor.name) : logicalName; let names = Reflect.getOwnMetadata(base_1.LOGICAL_TYPES, constructor.prototype); if (!names) { if (name === '') { names = [...(Reflect.getMetadata(base_1.LOGICAL_TYPES, constructor.prototype) ?? [])]; } else { names = [...(Reflect.getMetadata(base_1.LOGICAL_TYPES, constructor.prototype) ?? []), name]; } Reflect.defineMetadata(base_1.LOGICAL_TYPES, names, constructor.prototype); } else { if (name !== '') { names.push(name); } } ensureStructDefForConstructor(constructor, options?.unknownFieldAction); }; } exports.structvalue = structvalue; class StructDef { // eslint-disable-next-line @typescript-eslint/ban-types constructor(parentDef) { this._unknownFieldAction = 'error'; this._slots = new Map(); this._requiredSlots = []; if (parentDef) { for (const slot of parentDef.getSlots()) { if (slot.required) { this.withRequiredField(slot.name, slot.clazz); } else { this.withOptionalField(slot.name, slot.clazz); } } } } withRequiredField(name, def) { validateStrictFieldName(name); this._slots.set(name, { name, required: true, clazz: def }); this._requiredSlots.push(name); return this; } withOptionalField(name, def) { validateStrictFieldName(name); this._slots.set(name, { name, required: false, clazz: def }); return this; } withUnknownFieldAction(action) { this._unknownFieldAction = action; return this; } getSlotDef(name) { const def = this._slots.get(name); if (!def) { if (this._unknownFieldAction === 'error') { throw new valueobject_1.ValidationError(`Unknown field: ${name}`); } else { return undefined; } } return def; } getRequiredSlots() { return this._requiredSlots; } getSlots() { return this._slots.values(); } get unknownFieldAction() { return this._unknownFieldAction; } } exports.StructDef = StructDef; class StructValue extends base_1.Value { static required() { return { required: true, clazz: this }; } static optional() { return { required: false, clazz: this }; } /** * Creates a new struct value from a value that contains all of the fields of T. The fields * should be in the exact (native) casing as used in T. They are internally converted into the canonical field names. * Their values must be value objects (like StringValue or derived classes); not native types (like string). */ static from(value) { const options = { value }; return (0, base_1.constructValue)(this, options); } /** * Creates a new struct value from a value that is a partial (subset of the fields) of T. The fields * should be in the exact casing as used in T. They are internally converted into the canonical field names. * Their values must be value objects (like StringValue or derived classes); not native types (like string). */ static fromPartial(value) { const options = { value }; return (0, base_1.constructValue)(this, options); } /** * Creates a new struct value from a base value (of the same type) that is enricheded with a partial * (subset of the fields) of T. The fields should be in the exact casing as used in T. They are internally converted into * the canonical field names. * Their values must be value objects (like StringValue or derived classes); not native types (like string). Defined values * (that is, not having the value `undefined`) override the corresponding value in the output struct; values that are explicitly * undefined remove cause the corresponding key not to be present in the output struct. */ static fromBase(base, value) { const map = new Map(base._entries()); for (const [key, v] of Object.entries(value)) { map.set((0, valueobject_1.deriveTypeName)(key), v); } const options = { value: map }; return (0, base_1.constructValue)(this, options); } static fromCanonical(value, options) { const valueoptions = { canonical: value, cacheCanonical: options?.cacheCanonical }; return (0, base_1.constructValue)(this, valueoptions); } static fromSlots(value) { const options = { value }; return (0, base_1.constructValue)(this, options); } /** * Creates a new struct value instance using the provided value. Regardless of whether the value is an ICanonical or * a Map, the field names must already be canonicalized. That also counts for field names of nested * values. When value is an object (`{..}`), the field names must *not* yet be canonicalized. * @param value */ constructor(options) { super(options); let v = new Map(); if (options.canonical) { const canonical = (0, canonical_1.toCanonical)(options.canonical); const logicalTypes = (0, base_1.checkLogicalTypes)(Object.getPrototypeOf(this)); if (!canonical.is(logicalTypes)) { throw new valueobject_1.ValidationError(`Incoming value of logical types '${canonical.logicalTypes.join('.')}' is not compatible with '${logicalTypes.join('.')}'`); } if ((0, base_1.shouldCacheCanonical)(canonical, logicalTypes, options?.cacheCanonical)) { this._canonical = canonical; } v = this._fromCanonical(canonical, options); } else if (options.value instanceof Map) { // We have a StructMap with already canonical named fields for (const [canonicalName, value] of options.value.entries()) { if (value === undefined) { continue; } v.set(canonicalName, value); } } else { for (const [name, value] of Object.entries(options.value)) { const canonicalName = (0, valueobject_1.deriveTypeName)(name); if (value === undefined) { continue; } v.set(canonicalName, value); } } const msgs = []; const validated = this._validate(v, (msg) => msgs.push(msg)); if (msgs.length > 0) { throw new valueobject_1.ValidationError(msgs.join('; ')); } this._slots = (validated ?? v); } _peekCanonicalRepresentation() { if (this._canonical) { return this._canonical; } this._canonical = this._toCanonical(this._checkSlots(), this._logicalTypes); return this._canonical; } get _() { return { extractSlots: () => this._extractSlots(), req: (name) => this._req(name), opt: (name) => this._opt(name), checkSlots: () => this._checkSlots(), get: (slot) => this._get(slot), entries: () => this._entries(), keys: () => this._keys(), values: () => this._values(), size: this._size }; } equals(other) { if (!(0, canonical_1.isCanonicalLike)(other)) { return false; } return this._peekCanonicalRepresentation().equals(other); } _keys() { return this._checkSlots().keys(); } _values() { return this._checkSlots().values(); } _entries() { return this._checkSlots().entries(); } get _size() { return this._checkSlots().size; } get _logicalTypes() { return (Reflect.getOwnMetadata(base_1.LOGICAL_TYPES, Object.getPrototypeOf(this)) ?? []); } _deriveCanonicalRepresentation() { const slots = this._checkSlots(); return canonical_1.MapCanonical.from(slots, this._logicalTypes); } _get(slot) { return this._checkSlots().get(slot); } _fromCanonical(canonical, options) { const def = Reflect.getOwnMetadata(STRUCT_DEF, Object.getPrototypeOf(this)); const result = new Map(); let entry = canonical.firstMappingEntry; while (entry) { const slotDef = def.getSlotDef(entry.key); if (!slotDef) { continue; } const entryCan = (0, canonical_1.toCanonical)(entry.value); const value = (0, base_1.constructValue)((0, base_1.toValueClass)(slotDef.clazz), { cacheCanonical: options?.cacheCanonical, canonical: entryCan }); result.set(entry.key, value); entry = entry.next(); } return result; } _toCanonical(value, logicalTypes) { return canonical_1.MapCanonical.from(value, logicalTypes); } _validate(v, fail) { // First, validate all slots for proper type and presence. const proto = Object.getPrototypeOf(this); const def = Reflect.getOwnMetadata(STRUCT_DEF, proto); if (!def) { throw new Error(`Instance of struct class '${this.constructor.name}' does not have a definition, possibly because no '@structvalue()' class decorator is present.`); } let ok = true; for (const [name, value] of v.entries()) { const slotDef = def.getSlotDef(name); if (!slotDef) { continue; } // This checks not only checks the proper class types (which may be too strict?), it also catches the case in which // the input is not a Value at all (but, for example, a ICanonical). if (!(value instanceof slotDef.clazz)) { fail(`Value '${name}' with class '${Object.getPrototypeOf(value).constructor.name}' is not an instance of '${slotDef.clazz.name}'`); ok = false; continue; } const slotLogicalTypes = (0, base_1.toValueClass)(slotDef.clazz).logicalTypes; const valueLogicalTypes = value._logicalTypes; if (!(0, base_1.aExtendsB)(valueLogicalTypes, slotLogicalTypes)) { fail(`Value '${name}' with logical types '${slotLogicalTypes.join('.')}' is not compatible with value with logical types '${valueLogicalTypes.join('.')}'`); ok = false; continue; } } for (const required of def.getRequiredSlots()) { if (!v.has(required)) { fail(`Required value '${required}' is missing`); ok = false; } } if (!ok) { return; } // Then, use this prevalidated map as input to custom validators for the struct. It makes little // sense to run such a validator on input that is invalid. That is why we do this after validation // of the individual slots. const validators = Reflect.getOwnMetadata(base_1.VALIDATORS, Object.getPrototypeOf(this)); if (validators) { let failed = false; for (const validator of validators) { validator(v, (msg) => { fail(msg); failed = true; }); if (failed) { break; } } } } /** * Extracts the current slots and their values. Slot names are returned as canonicalized names. */ _extractSlots() { const slots = this._checkSlots(); this._slots = undefined; return slots; } _req(name) { return this._checkSlots()?.get(name); } _opt(name) { return this._checkSlots().get(name); } _checkSlots() { if (this._slots === undefined) { throw new Error(`Not allowed to access unfrozen structure`); } return this._slots; } } exports.StructValue = StructValue; // eslint-disable-next-line @typescript-eslint/ban-types function ensureStructDefForConstructor(constructor, extensions) { let needsInit = false; const prototype = constructor.prototype; let def = Reflect.getOwnMetadata(STRUCT_DEF, prototype); if (!def) { const parentDef = Reflect.getMetadata(STRUCT_DEF, prototype); def = new StructDef(parentDef); if (extensions) { def.withUnknownFieldAction(extensions); } Reflect.defineMetadata(STRUCT_DEF, def, prototype); needsInit = true; } if (needsInit) { for (const name of Object.getOwnPropertyNames(prototype)) { // Ignore special properties like '_'. if (name.startsWith('_')) { continue; } const descriptor = Object.getOwnPropertyDescriptor(prototype, name); if (!descriptor) { continue; } const originalGetter = descriptor.get; if (!originalGetter) { continue; } // eslint-disable-next-line @typescript-eslint/ban-types let info; try { // eslint-disable-next-line @typescript-eslint/ban-types info = originalGetter(); } catch (e) { // As we do not provide a value for "this", most derived fields will simply throw a TypeError. // Just catch everything and assume there are no unintended side effects (it's a getter after all). if (e instanceof TypeError) { continue; } throw e; } const canonicalName = (0, valueobject_1.deriveTypeName)(name); if (info.required) { def.withRequiredField(canonicalName, info.clazz); } else { def.withOptionalField(canonicalName, info.clazz); } const required = info.required; descriptor.get = function () { return required ? this._.req(canonicalName) : this._.opt(canonicalName); }; Object.defineProperty(prototype, name, descriptor); } } return def; } exports.ensureStructDefForConstructor = ensureStructDefForConstructor; function validateStrictFieldName(name) { // TODO: Add a cache for this for (const char of name) { const ok = (char >= '0' && char <= '9') || (char >= 'a' && char <= 'z') || char === '-'; if (!ok) { throw new valueobject_1.ValidationError(`Field name "${name}" contains illegal characters (allowed are a-z, 9-0 and -)`); } } } exports.validateStrictFieldName = validateStrictFieldName; exports.structvalidation = (base_1.validation); structvalue('')(StructValue);