Skip to content

Commit

Permalink
feat(metadata): add DomainObject.metadata prop, expose getMetadataKey…
Browse files Browse the repository at this point in the history
…s and omitMetadataValues methods
  • Loading branch information
uladkasach committed Dec 7, 2022
1 parent e39da1e commit 34b38f5
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 117 deletions.
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { DomainEvent } from './instantiation/DomainEvent';
export { HelpfulJoiValidationError } from './instantiation/validate/HelpfulJoiValidationError';
export { HelpfulYupValidationError } from './instantiation/validate/HelpfulYupValidationError';
export { getUniqueIdentifier } from './manipulation/getUniqueIdentifier';
export { omitAutogeneratedValues } from './manipulation/omitAutogeneratedValues';
export { getMetadataKeys } from './manipulation/getMetadataKeys';
export { omitMetadataValues } from './manipulation/omitMetadataValues';
export { serialize } from './manipulation/serde/serialize';
export { deserialize } from './manipulation/serde/deserialize';
19 changes: 19 additions & 0 deletions src/instantiation/DomainObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,23 @@ export class DomainObject<T extends Record<string, any>> {
* ```
*/
public static nested?: Record<string, DomainObject<any>>; // TODO: find a way to make this Record<keyof string>

/**
* DomainObject.metadata
*
* When set, customizes the keys that are considered as metadata of this domain object.
*
* Context,
* - domain objects are often persisted inside of storage mechanisms that assign metadata to them, such as ids or timestamps
* - metadata simply adds information _about_ the object, without contributing to _defining_ the object
*
* Relevance,
* - metadata properties do not contribute to the unique key of a DomainValueObject
* - metadata properties can be easily stripped from an object by using the `omitMetadataValues` method
*
* By default,
* - `id`, `createdAt`, `updatedAt`, and `effectiveAt` are considered metadata keys
* - `uuid` is also considered a metadata key, if it is not included in the unique key of the DomainEntity or DomainEvent
*/
public static metadata: readonly string[];
}
18 changes: 1 addition & 17 deletions src/instantiation/DomainValueObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,4 @@ import { DomainObject } from './DomainObject';
* - A `Address { street, city, state, country }` is a value object. Changing any of the properties, like `street`, produces a completely new address
* - A `Geocode { latitude, longitude }` is a value object. Changing either property means you are dealing with a new name.
*/
export abstract class DomainValueObject<T> extends DomainObject<T> {
/**
* `DomainValueObject.metadata` defines all of the properties of the value object that are exclusively metadata and do not contribute to the value object's definition
*
* Relevance,
* - metadata properties do not contribute to the value object's unique key, since they are not part of the value object's definition
* - metadata simply adds information _about_ the value object, without contributing to _defining_ the value object
*
* By default,
* - 'id' and 'uuid' are considered the metadata keys
*
* For example,
* - an `Address { uuid, street, city, state, country }` likely has a database generated metadata property of `['uuid']`
* - an `Geocode { id, createdAt, latitude, longitude }` likely has the database generated metadata properties of `['id', 'createdAt']`
*/
public static metadata: readonly string[];
}
export abstract class DomainValueObject<T> extends DomainObject<T> {}
41 changes: 41 additions & 0 deletions src/manipulation/getMetadataKeys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { getMetadataKeys } from './getMetadataKeys';
import { DomainObject } from '../instantiation/DomainObject';
import { DomainEntity } from '../instantiation/DomainEntity';

describe('getMetadataKeys', () => {
it('should return the defaults, if not explicitly defined', () => {
interface Mineral {
id?: number;
name: string;
}
class Mineral extends DomainObject<Mineral> implements Mineral {}
const mineral = new Mineral({ name: 'magnesium' });
const metadataKeys = getMetadataKeys(mineral);
expect(metadataKeys).toEqual(['id', 'uuid', 'createdAt', 'updatedAt', 'effectiveAt']);
});
it('should return the explicitly defined metadata keys, if defined', () => {
interface Mineral {
id?: number;
uuid?: string;
name: string;
}
class Mineral extends DomainObject<Mineral> implements Mineral {
public static metadata = ['id', 'uuid'];
}
const mineral = new Mineral({ name: 'magnesium' });
const metadataKeys = getMetadataKeys(mineral);
expect(metadataKeys).toEqual(['id', 'uuid']);
});
it('should not include uuid in the default metadata keys of a DomainEntity which specified uuid as part of its unique key', () => {
interface Mineral {
uuid: string;
name: string;
}
class Mineral extends DomainEntity<Mineral> implements Mineral {
public static unique = ['uuid'];
}
const mineral = new Mineral({ uuid: '__UUID__', name: 'magnesium' });
const metadataKeys = getMetadataKeys(mineral);
expect(metadataKeys).toEqual(['id', 'createdAt', 'updatedAt', 'effectiveAt']);
});
});
34 changes: 34 additions & 0 deletions src/manipulation/getMetadataKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { DomainObject } from '../instantiation/DomainObject';
import { DomainEntity } from '../instantiation/DomainEntity';
import { DomainEvent } from '../instantiation/DomainEvent';
import { DomainEntityUniqueKeysMustBeDefinedError } from './DomainEntityUniqueKeysMustBeDefinedError';

const DEFAULT_METADATA_KEYS = ['id', 'uuid', 'createdAt', 'updatedAt', 'effectiveAt'];

/**
* returns the metadata keys defined on the class of the domain object
*/
export const getMetadataKeys = (obj: DomainObject<any>, options?: { nameOfFunctionNeededFor?: string }): string[] => {
// make sure its an instance of DomainObject
if (!(obj instanceof DomainObject))
throw new Error('getMetadataKeys called on object that is not an instance of a DomainObject. Are you sure you instantiated the object?');

// see if metadata was explicitly defined
const metadataKeysDeclared = (obj.constructor as typeof DomainObject).metadata as string[];
if (metadataKeysDeclared) return metadataKeysDeclared;

// if it wasn't explicitly declared and its a DomainEntity or DomainEvent, then check to see if uuid is part of the unique key and augment default keys based on that
if (obj instanceof DomainEntity || obj instanceof DomainEvent) {
const className = (obj.constructor as typeof DomainEntity).name;
const uniqueKeys = (obj.constructor as typeof DomainEntity).unique;
if (!uniqueKeys)
throw new DomainEntityUniqueKeysMustBeDefinedError({
entityName: className,
nameOfFunctionNeededFor: options?.nameOfFunctionNeededFor ?? 'getMetadataKeys',
});
if (uniqueKeys.flat().includes('uuid')) return DEFAULT_METADATA_KEYS.filter((key) => key !== 'uuid'); // if the unique key includes uuid, then uuid is not metadata
}

// otherwise, return the defaults
return DEFAULT_METADATA_KEYS;
};
85 changes: 0 additions & 85 deletions src/manipulation/omitAutogeneratedValues.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import Joi, { State } from 'joi';
import Joi from 'joi';
import { DomainEntity } from '../instantiation/DomainEntity';
import { DomainValueObject } from '../instantiation/DomainValueObject';
import { DomainEntityUniqueKeysMustBeDefinedError } from './DomainEntityUniqueKeysMustBeDefinedError';
import { omitAutogeneratedValues } from './omitAutogeneratedValues';
import { omitMetadataValues } from './omitMetadataValues';

describe('omitAutogeneratedValues', () => {
describe('omitMetadataValues', () => {
describe('value object', () => {
// define domain object with all autogenerated values that we support
interface Tool {
Expand All @@ -29,7 +29,7 @@ describe('omitAutogeneratedValues', () => {
});

// check none of them are defined after omitting
const hammerWithoutAutogeneratedValues = omitAutogeneratedValues(hammer);
const hammerWithoutAutogeneratedValues = omitMetadataValues(hammer);
expect(hammerWithoutAutogeneratedValues.id).toEqual(undefined);
expect(hammerWithoutAutogeneratedValues.uuid).toEqual(undefined);
expect(hammerWithoutAutogeneratedValues.createdAt).toEqual(undefined);
Expand All @@ -50,7 +50,7 @@ describe('omitAutogeneratedValues', () => {
});

// check none of them are defined after omitting
const hammerWithoutAutogeneratedValues = omitAutogeneratedValues(hammer);
const hammerWithoutAutogeneratedValues = omitMetadataValues(hammer);
expect(hammerWithoutAutogeneratedValues.id).toEqual(undefined);
expect(hammerWithoutAutogeneratedValues.uuid).toEqual(undefined);
expect(hammerWithoutAutogeneratedValues.createdAt).toEqual(undefined);
Expand All @@ -70,7 +70,7 @@ describe('omitAutogeneratedValues', () => {
});

// check none of them are defined after omitting
const hammerWithoutAutogeneratedValues = omitAutogeneratedValues(hammer);
const hammerWithoutAutogeneratedValues = omitMetadataValues(hammer);
expect(hammerWithoutAutogeneratedValues.id).toEqual(undefined);
expect(hammerWithoutAutogeneratedValues.uuid).toEqual(undefined);
expect(hammerWithoutAutogeneratedValues.createdAt).toEqual(undefined);
Expand Down Expand Up @@ -113,7 +113,7 @@ describe('omitAutogeneratedValues', () => {
});

// check none of them are defined after omitting
const boosterWithoutAutogeneratedValues = omitAutogeneratedValues(booster);
const boosterWithoutAutogeneratedValues = omitMetadataValues(booster);
expect(boosterWithoutAutogeneratedValues.id).toBeUndefined();
expect(boosterWithoutAutogeneratedValues.uuid).toBeUndefined();
expect(boosterWithoutAutogeneratedValues.createdAt).toBeUndefined();
Expand All @@ -139,7 +139,7 @@ describe('omitAutogeneratedValues', () => {
});

// check none of them are defined after omitting
const boosterWithoutAutogeneratedValues = omitAutogeneratedValues(booster);
const boosterWithoutAutogeneratedValues = omitMetadataValues(booster);
expect(boosterWithoutAutogeneratedValues.id).toBeUndefined();
expect(boosterWithoutAutogeneratedValues.uuid).toBeUndefined();
expect(boosterWithoutAutogeneratedValues.createdAt).toBeUndefined();
Expand All @@ -162,7 +162,7 @@ describe('omitAutogeneratedValues', () => {
});

// check none of them are defined after omitting
const boosterWithoutAutogeneratedValues = omitAutogeneratedValues(booster);
const boosterWithoutAutogeneratedValues = omitMetadataValues(booster);
expect(boosterWithoutAutogeneratedValues.id).toBeUndefined();
expect(boosterWithoutAutogeneratedValues.uuid).toBeUndefined();
expect(boosterWithoutAutogeneratedValues.createdAt).toBeUndefined();
Expand Down Expand Up @@ -195,7 +195,7 @@ describe('omitAutogeneratedValues', () => {
});

// check none of them are defined after omitting
const ownerWithoutAutogeneratedValues = omitAutogeneratedValues(owner);
const ownerWithoutAutogeneratedValues = omitMetadataValues(owner);
expect(ownerWithoutAutogeneratedValues.id).toBeUndefined();
expect(ownerWithoutAutogeneratedValues.uuid).toBeDefined();

Expand Down Expand Up @@ -232,7 +232,7 @@ describe('omitAutogeneratedValues', () => {
});

// check none of them are defined after omitting
const ownerWithoutAutogeneratedValues = omitAutogeneratedValues(owner);
const ownerWithoutAutogeneratedValues = omitMetadataValues(owner);
expect(ownerWithoutAutogeneratedValues.id).toBeUndefined();
expect(ownerWithoutAutogeneratedValues.uuid).toBeDefined();

Expand Down Expand Up @@ -261,10 +261,10 @@ describe('omitAutogeneratedValues', () => {

// check that error is thrown, because we cant figure out whether uuid is autogenerated or not in this case
try {
omitAutogeneratedValues(dweller);
omitMetadataValues(dweller);
throw new Error('should not reach here');
} catch (error) {
expect(error.message).toContain('`LotDweller.unique` must be defined, to be able to `omitAutogeneratedValues`');
expect(error.message).toContain('`LotDweller.unique` must be defined, to be able to `omitMetadataValues`');
expect(error).toBeInstanceOf(DomainEntityUniqueKeysMustBeDefinedError);
}
});
Expand Down Expand Up @@ -313,7 +313,7 @@ describe('omitAutogeneratedValues', () => {
}),
photos: [new Photo({ id: 1, uuid: '__uuid__', url: 'https://...' })],
});
const starbaseWithoutAutogeneratedValues = omitAutogeneratedValues(starbase);
const starbaseWithoutAutogeneratedValues = omitMetadataValues(starbase);
expect(starbaseWithoutAutogeneratedValues.id).toBeUndefined();
expect(starbaseWithoutAutogeneratedValues.uuid).toBeUndefined();
expect(starbaseWithoutAutogeneratedValues.address.id).toBeUndefined();
Expand Down
55 changes: 55 additions & 0 deletions src/manipulation/omitMetadataValues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import omit from 'lodash.omit';

import { assertDomainObjectIsSafeToManipulate } from '../constraints/assertDomainObjectIsSafeToManipulate';
import { DomainObject } from '../instantiation/DomainObject';
import { getMetadataKeys } from './getMetadataKeys';

/**
* exposes a function which properly handles any value that can could have been defined for an object property
* - if domain object, omits autogenerated values
* - if array, recursively omits on each item in the array
* - if neither of the above, then its the terminal condition - return it, its fully omitted
*/
const recursivelyOmitMetadataValuesFromObjectValue: any = (thisValue: any) => {
// handle directly nested domain object
if (thisValue instanceof DomainObject) return omitMetadataValues(thisValue); // eslint-disable-line @typescript-eslint/no-use-before-define

// handle an array of one level deep (doesn't handle Array of Array, for simplicity)
if (Array.isArray(thisValue)) return thisValue.map(recursivelyOmitMetadataValuesFromObjectValue); // run self on each item in the array, (i.e., recursively)

// handle any other value type
return thisValue;
};

/**
* omits all metadata values on a domain object
*
* features:
* - utilizes the `.metadata` property of the domain object definition to identify metadata keys
* - recursive, applies omission deeply
*/
export const omitMetadataValues = <T extends DomainObject<Record<string, any>>>(obj: T): T => {
// make sure its an instance of DomainObject
if (!(obj instanceof DomainObject))
throw new Error(
'omitMetadataValues called on object that is not an instance of a DomainObject. Are you sure you instantiated the object? (Related: see `DomainObject.nested`)',
);

// determine if its an entity or a value object
const Constructor = (obj.constructor as any) as { new (...args: any): T }; // https://stackoverflow.com/a/61444747/3068233
const metadataKeys = getMetadataKeys(obj, { nameOfFunctionNeededFor: 'omitMetadataValues' });

// make sure that its safe to manipulate
assertDomainObjectIsSafeToManipulate(obj);

// object with omit applied recursively on each property
const objectWithEachDomainObjectKeyRecursivelyOmitted: typeof obj = Object.entries(obj).reduce((summary, [thisKey, thisValue]) => {
return { ...summary, [thisKey]: recursivelyOmitMetadataValuesFromObjectValue(thisValue) };
}, {} as typeof obj);

// omit all of the metadata keys
const objWithoutBaseCaseAutogeneratedValues = omit(objectWithEachDomainObjectKeyRecursivelyOmitted, metadataKeys);

// return the instantiated object
return new Constructor(objWithoutBaseCaseAutogeneratedValues);
};

0 comments on commit 34b38f5

Please sign in to comment.