diff --git a/package-lock.json b/package-lock.json index 1b84320..0a3f789 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.21.4", "hasInstallScript": true, "dependencies": { + "@ehmpathy/error-fns": "^1.1.0", "@types/joi": "^17.2.3", "@types/lodash.omit": "^4.5.6", "@types/yup": "^0.29.6", @@ -1292,9 +1293,9 @@ } }, "node_modules/@ehmpathy/error-fns": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@ehmpathy/error-fns/-/error-fns-1.0.2.tgz", - "integrity": "sha512-v3aJIqUvD9a3drx1pyS8La+9u9WTTvNE35NksiD4Oo3VanNe8Rmue/atRHPg4nNYQ/xPv4+RoqC+OBj6cAY8VA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ehmpathy/error-fns/-/error-fns-1.1.0.tgz", + "integrity": "sha512-s6XzgIhiITpXapOxkoRYMpQ/3ot6cO1paoabr1aeJbvT2f34TtOGgaubfXllzFUznWirU7T+6fNR24asnhrp9g==", "hasInstallScript": true, "dependencies": { "type-fns": "0.9.0" @@ -17734,6 +17735,26 @@ "node": ">=8.0.0" } }, + "node_modules/test-fns/node_modules/@ehmpathy/error-fns": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@ehmpathy/error-fns/-/error-fns-1.0.2.tgz", + "integrity": "sha512-v3aJIqUvD9a3drx1pyS8La+9u9WTTvNE35NksiD4Oo3VanNe8Rmue/atRHPg4nNYQ/xPv4+RoqC+OBj6cAY8VA==", + "hasInstallScript": true, + "dependencies": { + "type-fns": "0.9.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/test-fns/node_modules/type-fns": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/type-fns/-/type-fns-0.9.0.tgz", + "integrity": "sha512-ndhY4JBIbKix0LuGA5smh/XhFFnbeudnih++WxVoGTfdrITsZe/s3qje9GZNdWwsO+YWGyQkNXwAjnWyM/dipw==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", @@ -18326,6 +18347,26 @@ "node": ">=8.0.0" } }, + "node_modules/type-fns/node_modules/@ehmpathy/error-fns": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@ehmpathy/error-fns/-/error-fns-1.0.2.tgz", + "integrity": "sha512-v3aJIqUvD9a3drx1pyS8La+9u9WTTvNE35NksiD4Oo3VanNe8Rmue/atRHPg4nNYQ/xPv4+RoqC+OBj6cAY8VA==", + "hasInstallScript": true, + "dependencies": { + "type-fns": "0.9.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/type-fns/node_modules/type-fns": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/type-fns/-/type-fns-0.9.0.tgz", + "integrity": "sha512-ndhY4JBIbKix0LuGA5smh/XhFFnbeudnih++WxVoGTfdrITsZe/s3qje9GZNdWwsO+YWGyQkNXwAjnWyM/dipw==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", @@ -21498,9 +21539,9 @@ } }, "@ehmpathy/error-fns": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@ehmpathy/error-fns/-/error-fns-1.0.2.tgz", - "integrity": "sha512-v3aJIqUvD9a3drx1pyS8La+9u9WTTvNE35NksiD4Oo3VanNe8Rmue/atRHPg4nNYQ/xPv4+RoqC+OBj6cAY8VA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ehmpathy/error-fns/-/error-fns-1.1.0.tgz", + "integrity": "sha512-s6XzgIhiITpXapOxkoRYMpQ/3ot6cO1paoabr1aeJbvT2f34TtOGgaubfXllzFUznWirU7T+6fNR24asnhrp9g==", "requires": { "type-fns": "0.9.0" }, @@ -33961,6 +34002,21 @@ "integrity": "sha512-3PQtEhIM3Q5+ZanixJiF0HdQge7Jape52L+QlIueDwwuXdLXGv0MTVh1k23DYOEsBT2vJuT+3r7baJ40brqrag==", "requires": { "@ehmpathy/error-fns": "1.0.2" + }, + "dependencies": { + "@ehmpathy/error-fns": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@ehmpathy/error-fns/-/error-fns-1.0.2.tgz", + "integrity": "sha512-v3aJIqUvD9a3drx1pyS8La+9u9WTTvNE35NksiD4Oo3VanNe8Rmue/atRHPg4nNYQ/xPv4+RoqC+OBj6cAY8VA==", + "requires": { + "type-fns": "0.9.0" + } + }, + "type-fns": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/type-fns/-/type-fns-0.9.0.tgz", + "integrity": "sha512-ndhY4JBIbKix0LuGA5smh/XhFFnbeudnih++WxVoGTfdrITsZe/s3qje9GZNdWwsO+YWGyQkNXwAjnWyM/dipw==" + } } }, "text-extensions": { @@ -34389,6 +34445,21 @@ "integrity": "sha512-k5OEgcxw01hr8/qBmtWiU+1WqQ5PVGk30m7tBOLdLs+AECQH0czCJYR4BbjmkDEOaZFxSjjI6m3GK/ipobCxTg==", "requires": { "@ehmpathy/error-fns": "1.0.2" + }, + "dependencies": { + "@ehmpathy/error-fns": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@ehmpathy/error-fns/-/error-fns-1.0.2.tgz", + "integrity": "sha512-v3aJIqUvD9a3drx1pyS8La+9u9WTTvNE35NksiD4Oo3VanNe8Rmue/atRHPg4nNYQ/xPv4+RoqC+OBj6cAY8VA==", + "requires": { + "type-fns": "0.9.0" + } + }, + "type-fns": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/type-fns/-/type-fns-0.9.0.tgz", + "integrity": "sha512-ndhY4JBIbKix0LuGA5smh/XhFFnbeudnih++WxVoGTfdrITsZe/s3qje9GZNdWwsO+YWGyQkNXwAjnWyM/dipw==" + } } }, "typed-array-buffer": { diff --git a/package.json b/package.json index cbfbce1..1074fbb 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "postinstall": "[ -d .git ] && npx husky install || exit 0" }, "dependencies": { + "@ehmpathy/error-fns": "^1.1.0", "@types/joi": "^17.2.3", "@types/lodash.omit": "^4.5.6", "@types/yup": "^0.29.6", diff --git a/src/manipulation/getPrimaryIdentifier.ts b/src/manipulation/getPrimaryIdentifier.ts index 919bb84..1e17529 100644 --- a/src/manipulation/getPrimaryIdentifier.ts +++ b/src/manipulation/getPrimaryIdentifier.ts @@ -1,3 +1,4 @@ +import { UnexpectedCodePathError } from '@ehmpathy/error-fns'; import pick from 'lodash.pick'; import { assertDomainObjectIsSafeToManipulate } from '../constraints/assertDomainObjectIsSafeToManipulate'; @@ -5,8 +6,6 @@ import { DomainEntity } from '../instantiation/DomainEntity'; import { DomainEvent } from '../instantiation/DomainEvent'; import { DomainLiteral } from '../instantiation/DomainLiteral'; import { DomainObject } from '../instantiation/DomainObject'; -import { Ref } from '../reference/DomainReference'; -import { UnexpectedCodePathError } from '../utils/errors/UnexpectedCodePathError'; import { DomainEntityPrimaryKeysMustBeDefinedError } from './DomainEntityPrimaryKeysMustBeDefinedError'; /** diff --git a/src/manipulation/getUniqueIdentifier.ts b/src/manipulation/getUniqueIdentifier.ts index 8d1598b..9e371b3 100644 --- a/src/manipulation/getUniqueIdentifier.ts +++ b/src/manipulation/getUniqueIdentifier.ts @@ -1,3 +1,4 @@ +import { UnexpectedCodePathError } from '@ehmpathy/error-fns'; import pick from 'lodash.pick'; import { assertDomainObjectIsSafeToManipulate } from '../constraints/assertDomainObjectIsSafeToManipulate'; @@ -5,7 +6,6 @@ import { DomainEntity } from '../instantiation/DomainEntity'; import { DomainEvent } from '../instantiation/DomainEvent'; import { DomainLiteral } from '../instantiation/DomainLiteral'; import { DomainObject } from '../instantiation/DomainObject'; -import { UnexpectedCodePathError } from '../utils/errors/UnexpectedCodePathError'; import { DomainEntityUniqueKeysMustBeDefinedError } from './DomainEntityUniqueKeysMustBeDefinedError'; /** diff --git a/src/manipulation/getUpdatableProperties.ts b/src/manipulation/getUpdatableProperties.ts index a8dff23..5bd3986 100644 --- a/src/manipulation/getUpdatableProperties.ts +++ b/src/manipulation/getUpdatableProperties.ts @@ -1,9 +1,9 @@ +import { UnexpectedCodePathError } from '@ehmpathy/error-fns'; import pick from 'lodash.pick'; import { assertDomainObjectIsSafeToManipulate } from '../constraints/assertDomainObjectIsSafeToManipulate'; import { DomainEntity } from '../instantiation/DomainEntity'; import { DomainObject } from '../instantiation/DomainObject'; -import { UnexpectedCodePathError } from '../utils/errors/UnexpectedCodePathError'; import { DomainEntityUpdatablePropertiesMustBeDefinedError } from './DomainEntityUpdatablePropertiesMustBeDefinedError'; /** diff --git a/src/manipulation/relate/__snapshots__/dedupe.test.ts.snap b/src/manipulation/relate/__snapshots__/dedupe.test.ts.snap index 32ecf74..af433fc 100644 --- a/src/manipulation/relate/__snapshots__/dedupe.test.ts.snap +++ b/src/manipulation/relate/__snapshots__/dedupe.test.ts.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`dedupe should fail fast with a helpful error if there are multiple versions of the same entity, by default 1`] = ` -"UnexpectedCodePath: More than one version of the same entity found in the array. Can not safely dedupe, since we don't know which version should be kept. +"BadRequestError: Two different versions of the same entity were asked to be deduped. Options.onMultipleEntityVersions !== 'CHOOSE_FIRST_OCCURRENCE', so we're failing fast here, since we don't know which version should be kept. -{"firstOccurrence":{"saltwaterSecurityNumber":"821","name":"Shelly C."},"nextOccurrence":{"saltwaterSecurityNumber":"821","name":"Shellina C."}}" +{"thisObj":{"saltwaterSecurityNumber":"821","name":"Shellina C."},"versionCurrSeen":"{\\"_dobj\\":\\"SeaTurtle\\",\\"name\\":\\"Shellina C.\\",\\"saltwaterSecurityNumber\\":\\"821\\"}","versionPrevSeen":"{\\"_dobj\\":\\"SeaTurtle\\",\\"name\\":\\"Shelly C.\\",\\"saltwaterSecurityNumber\\":\\"821\\"}"}" `; diff --git a/src/manipulation/relate/dedupe.test.ts b/src/manipulation/relate/dedupe.test.ts index a5b3896..44e334a 100644 --- a/src/manipulation/relate/dedupe.test.ts +++ b/src/manipulation/relate/dedupe.test.ts @@ -77,7 +77,7 @@ describe('dedupe', () => { }); const error = getError(() => dedupe([turtleOne, turtleOneWithDiffName])); expect(error.message).toContain( - 'More than one version of the same entity found in the array', + 'Two different versions of the same entity were asked to be deduped.', ); expect(error.message).toMatchSnapshot(); }); diff --git a/src/manipulation/relate/dedupe.ts b/src/manipulation/relate/dedupe.ts index 00f63fc..1e0a237 100644 --- a/src/manipulation/relate/dedupe.ts +++ b/src/manipulation/relate/dedupe.ts @@ -1,10 +1,25 @@ +import { BadRequestError, UnexpectedCodePathError } from '@ehmpathy/error-fns'; + import { DomainEntity } from '../../instantiation/DomainEntity'; import { DomainObject } from '../../instantiation/DomainObject'; -import { UnexpectedCodePathError } from '../../utils/errors/UnexpectedCodePathError'; import { getUniqueIdentifier } from '../getUniqueIdentifier'; import { omitMetadataValues } from '../omitMetadataValues'; import { serialize } from '../serde/serialize'; +// define how to get the dedupe identity key for any object +const toDedupeIdentity = (obj: T) => + serialize(obj instanceof DomainObject ? getUniqueIdentifier(obj) : obj); + +const toVersionIdentity = (obj: T) => + obj instanceof DomainEntity ? serialize(omitMetadataValues(obj)) : undefined; // if not an entity, there is no version identity + +/** + * a method which deduplicates objects by their identity from within an array + * + * note + * - when it operates on dobj instances, it extracts their identity via getUniqueIdentifier + * - when it operates on anything else, it simply serializes the object + */ export const dedupe = ( objs: T[], options?: { @@ -20,54 +35,68 @@ export const dedupe = ( */ onMultipleEntityVersions: 'FAIL_FAST' | 'CHOOSE_FIRST_OCCURRENCE'; }, -): T[] => - objs.filter((thisObj, thisIndex) => { - // determine whether this is the first occurrence of this dobj in the array - const indexOfFirstOccurrence = objs.findIndex( - (otherObj) => - serialize( - thisObj instanceof DomainObject - ? getUniqueIdentifier(thisObj) - : thisObj, - ) === - serialize( - otherObj instanceof DomainObject - ? getUniqueIdentifier(otherObj) - : otherObj, - ), - ); - const isFirstOccurrence = indexOfFirstOccurrence === thisIndex; +): T[] => { + // track the objects we have seen + const objsSeenMetadata: Record = {}; + + // track the ordered, deduped objs array, which we will build + const objsDedupedList: T[] = []; + + // define how to check whether an obj has been seen already + const getObjSeenMetadata = ( + obj: T, + ): { seen: true; version?: string } | null => + objsSeenMetadata[toDedupeIdentity(obj)] ?? null; + + // define how to add a new distinct item + const addNewDistinctObj = (obj: T): void => { + // add to the objs seen lookup table + objsSeenMetadata[toDedupeIdentity(obj)] = { + seen: true, + version: toVersionIdentity(obj), + }; - // if this dobj is the first occurrence, then defo not a dupe - if (isFirstOccurrence) return true; + // add to the objs deduped list + objsDedupedList.push(obj); + }; - // if this dobj is not the first occurrence and it is an entity, then sanity check that there are no changes between the updatable attributes + // iterate through each object and add it to the deduped list as needed + objs.forEach((thisObj) => { + // determine if its been seen before + const prevSeenMetadata = getObjSeenMetadata(thisObj); + const hasBeenPrevSeen = prevSeenMetadata !== null; + + // if it has been seen, is an entity, and the caller didn't ask to CHOOSE_FIRST_OCCURRENCE, then check whether we should fail fast if ( + hasBeenPrevSeen && thisObj instanceof DomainEntity && - options?.onMultipleEntityVersions !== 'CHOOSE_FIRST_OCCURRENCE' // if they didn't explicitly ask to choose first occurrence, then check for versions + options?.onMultipleEntityVersions !== 'CHOOSE_FIRST_OCCURRENCE' ) { - const firstOccurrence = objs[indexOfFirstOccurrence]; - const foundDifferentAttributes = - serialize( - firstOccurrence instanceof DomainObject - ? omitMetadataValues(firstOccurrence) - : firstOccurrence, - ) !== - serialize( - thisObj instanceof DomainObject - ? omitMetadataValues(thisObj) - : thisObj, - ); - if (foundDifferentAttributes) + const versionPrevSeen = prevSeenMetadata.version; + if (!versionPrevSeen) throw new UnexpectedCodePathError( - `More than one version of the same entity found in the array. Can not safely dedupe, since we don't know which version should be kept.`, + 'should have had prev seen metadata declared for a domain entity', + { thisObj }, + ); + const versionCurrSeen = toVersionIdentity(thisObj); + if (versionCurrSeen !== versionPrevSeen) + throw new BadRequestError( + `Two different versions of the same entity were asked to be deduped. Options.onMultipleEntityVersions !== 'CHOOSE_FIRST_OCCURRENCE', so we're failing fast here, since we don't know which version should be kept.`, { - firstOccurrence, - nextOccurrence: thisObj, + thisObj, + versionCurrSeen, + versionPrevSeen, }, ); } - // otherwise, this is a dupe, and should be removed - return false; + // if it's been previously seen otherwise, then we can exit here as its a dupe + if (hasBeenPrevSeen) return; + + // otherwise, since its not been previously seen, add it as a new distinct obj + addNewDistinctObj(thisObj); }); + + // return all the distinct objs + return objsDedupedList; +}; diff --git a/src/reference/isPrimaryKeyRef.ts b/src/reference/isPrimaryKeyRef.ts index ec9c4a5..05f784e 100644 --- a/src/reference/isPrimaryKeyRef.ts +++ b/src/reference/isPrimaryKeyRef.ts @@ -1,6 +1,6 @@ +import { UnexpectedCodePathError } from '@ehmpathy/error-fns'; import { isPresent } from 'type-fns'; -import { UnexpectedCodePathError } from '../utils/errors/UnexpectedCodePathError'; import { DomainPrimaryKeyShape } from './DomainPrimaryKeyShape'; import { DomainObjectShape, Refable } from './DomainReferenceable'; import { DomainUniqueKeyShape } from './DomainUniqueKeyShape'; diff --git a/src/reference/isUniqueKeyRef.ts b/src/reference/isUniqueKeyRef.ts index c6a7fb3..beec497 100644 --- a/src/reference/isUniqueKeyRef.ts +++ b/src/reference/isUniqueKeyRef.ts @@ -1,6 +1,6 @@ +import { UnexpectedCodePathError } from '@ehmpathy/error-fns'; import { isPresent } from 'type-fns'; -import { UnexpectedCodePathError } from '../utils/errors/UnexpectedCodePathError'; import { DomainPrimaryKeyShape } from './DomainPrimaryKeyShape'; import { DomainObjectShape, Refable } from './DomainReferenceable'; import { DomainUniqueKeyShape } from './DomainUniqueKeyShape'; diff --git a/src/utils/errors/UnexpectedCodePathError.ts b/src/utils/errors/UnexpectedCodePathError.ts deleted file mode 100644 index ea15d60..0000000 --- a/src/utils/errors/UnexpectedCodePathError.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * UnexpectedCodePath errors are used to indicate that we've reached a code path that should never have been reached - * - * Purpose of having a dedicated class for this type of error is to allow us to easily add metadata about what was going on when we reached this code path - * - e.g., the variables in memory at the time - */ -export class UnexpectedCodePathError extends Error { - constructor(message: string, metadata?: Record) { - const fullMessage = `UnexpectedCodePath: ${message}${ - metadata ? `\n\n${JSON.stringify(metadata)}` : '' - }`; - super(fullMessage); - } -}