@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
JavaScript
"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);