From 02eb2f007d1a8d7ce40035b8a739e656b256f68b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 19 Oct 2021 14:12:28 +0200 Subject: [PATCH] refactor: use "Symbol" for internal entity properties --- src/db/Database.ts | 146 +++++++++++++------ src/db/drop.ts | 4 +- src/extensions/sync.ts | 87 +++++++++-- src/factory.ts | 67 +++------ src/glossary.ts | 32 ++-- src/model/createModel.ts | 20 ++- src/model/defineRelationalProperties.ts | 5 +- src/model/parseModelDefinition.ts | 3 +- src/model/updateEntity.ts | 37 +++-- src/query/executeQuery.ts | 33 +++-- src/query/paginateResults.ts | 8 +- src/query/sortResults.ts | 4 +- src/relations/Relation.ts | 38 +++-- src/utils/inheritInternalProperties.ts | 34 +++++ src/utils/isInternalEntity.ts | 15 -- src/utils/removeInternalProperties.ts | 85 ----------- test/db/events.test.ts | 51 +++++-- test/model/relationalProperties.test.ts | 10 +- test/model/update.test.ts | 5 + test/regressions/02-handlers-many-of.test.ts | 11 +- test/relations/Relation.test.ts | 46 +++--- test/relations/one-to-many.test.ts | 51 +++++++ test/relations/one-to-one.test.ts | 50 ++++++- test/tsconfig.json | 2 +- test/utils/inheritInternalProperties.test.ts | 49 +++++++ test/utils/isInternalEntity.test.ts | 30 ---- test/utils/removeInternalProperties.test.ts | 102 ------------- 27 files changed, 565 insertions(+), 460 deletions(-) create mode 100644 src/utils/inheritInternalProperties.ts delete mode 100644 src/utils/isInternalEntity.ts delete mode 100644 src/utils/removeInternalProperties.ts create mode 100644 test/utils/inheritInternalProperties.test.ts delete mode 100644 test/utils/isInternalEntity.test.ts delete mode 100644 test/utils/removeInternalProperties.test.ts diff --git a/src/db/Database.ts b/src/db/Database.ts index 43622460..92745e13 100644 --- a/src/db/Database.ts +++ b/src/db/Database.ts @@ -1,26 +1,55 @@ import md5 from 'md5' +import { invariant } from 'outvariant' import { StrictEventEmitter } from 'strict-event-emitter' import { - InternalEntity, - InternalEntityProperty, + Entity, + ENTITY_TYPE, + KeyType, ModelDictionary, PrimaryKeyType, + PRIMARY_KEY, } from '../glossary' +export const SERIALIZED_INTERNAL_PROPERTIES_KEY = + 'SERIALIZED_INTERNAL_PROPERTIES' + type Models = Record< - string, - Map> + keyof Dictionary, + Map> > -export type DatabaseMethodToEventFn any> = ( - id: string, - ...args: Parameters +export interface SerializedInternalEntityProperties { + entityType: string + primaryKey: PrimaryKeyType +} + +export interface SerializedEntity extends Entity { + [SERIALIZED_INTERNAL_PROPERTIES_KEY]: SerializedInternalEntityProperties +} + +export type DatabaseMethodToEventFn = ( + sourceId: string, + args: ArgsType, ) => void export interface DatabaseEventsMap { - create: DatabaseMethodToEventFn['create']> - update: DatabaseMethodToEventFn['update']> - delete: DatabaseMethodToEventFn['delete']> + create: DatabaseMethodToEventFn< + [ + modelName: KeyType, + entity: SerializedEntity, + customPrimaryKey?: PrimaryKeyType, + ] + > + update: DatabaseMethodToEventFn< + [ + modelName: KeyType, + prevEntity: SerializedEntity, + nextEntity: SerializedEntity, + ] + > + delete: DatabaseMethodToEventFn< + [modelName: KeyType, primaryKey: PrimaryKeyType] + > } let callOrder = 0 @@ -33,11 +62,11 @@ export class Database { constructor(dictionary: Dictionary) { this.events = new StrictEventEmitter() this.models = Object.keys(dictionary).reduce>( - (acc, modelName) => { - acc[modelName] = new Map>() + (acc, modelName: keyof Dictionary) => { + acc[modelName] = new Map>() return acc }, - {}, + {} as Models, ) callOrder++ @@ -56,46 +85,83 @@ export class Database { return md5(salt) } - getModel(name: ModelName) { + private serializeEntity(entity: Entity): SerializedEntity { + return { + ...entity, + [SERIALIZED_INTERNAL_PROPERTIES_KEY]: { + entityType: entity[ENTITY_TYPE], + primaryKey: entity[PRIMARY_KEY], + }, + } + } + + getModel(name: ModelName) { return this.models[name] } - create( + create( modelName: ModelName, - entity: InternalEntity, + entity: Entity, customPrimaryKey?: PrimaryKeyType, - ) { - const primaryKey = - customPrimaryKey || - (entity[entity[InternalEntityProperty.primaryKey]] as string) + ): Map> { + invariant( + entity[ENTITY_TYPE], + 'Failed to create a new "%s" record: provided entity has no type. %j', + modelName, + entity, + ) + invariant( + entity[PRIMARY_KEY], + 'Failed to create a new "%s" record: provided entity has no primary key. %j', + modelName, + entity, + ) - this.events.emit('create', this.id, modelName, entity, customPrimaryKey) + const primaryKey = + customPrimaryKey || (entity[entity[PRIMARY_KEY]] as string) + this.events.emit('create', this.id, [ + modelName, + this.serializeEntity(entity), + customPrimaryKey, + ]) return this.getModel(modelName).set(primaryKey, entity) } - update( + update( modelName: ModelName, - prevEntity: InternalEntity, - nextEntity: InternalEntity, - ) { - const prevPrimaryKey = - prevEntity[prevEntity[InternalEntityProperty.primaryKey]] - const nextPrimaryKey = - nextEntity[prevEntity[InternalEntityProperty.primaryKey]] + prevEntity: Entity, + nextEntity: Entity, + ): void { + const prevPrimaryKey = prevEntity[prevEntity[PRIMARY_KEY]] as PrimaryKeyType + const nextPrimaryKey = nextEntity[prevEntity[PRIMARY_KEY]] as PrimaryKeyType if (nextPrimaryKey !== prevPrimaryKey) { - this.delete(modelName, prevPrimaryKey as string) + this.delete(modelName, prevPrimaryKey) } - this.create(modelName, nextEntity, nextPrimaryKey as string) - this.events.emit('update', this.id, modelName, prevEntity, nextEntity) + this.getModel(modelName).set(nextPrimaryKey, nextEntity) + + // this.create(modelName, nextEntity, nextPrimaryKey) + this.events.emit('update', this.id, [ + modelName, + this.serializeEntity(prevEntity), + this.serializeEntity(nextEntity), + ]) + } + + delete( + modelName: ModelName, + primaryKey: PrimaryKeyType, + ): void { + this.getModel(modelName).delete(primaryKey) + this.events.emit('delete', this.id, [modelName, primaryKey]) } - has( + has( modelName: ModelName, primaryKey: PrimaryKeyType, - ) { + ): boolean { return this.getModel(modelName).has(primaryKey) } @@ -103,17 +169,9 @@ export class Database { return this.getModel(modelName).size } - delete( - modelName: ModelName, - primaryKey: PrimaryKeyType, - ) { - this.getModel(modelName).delete(primaryKey) - this.events.emit('delete', this.id, modelName, primaryKey) - } - - listEntities( + listEntities( modelName: ModelName, - ): InternalEntity[] { + ): Entity[] { return Array.from(this.getModel(modelName).values()) } } diff --git a/src/db/drop.ts b/src/db/drop.ts index 51fafc74..8f382b30 100644 --- a/src/db/drop.ts +++ b/src/db/drop.ts @@ -1,7 +1,7 @@ import { FactoryAPI } from '../glossary' -export function drop(db: FactoryAPI): void { - Object.values(db).forEach((model) => { +export function drop(factoryApi: FactoryAPI): void { + Object.values(factoryApi).forEach((model) => { model.deleteMany({ where: {} }) }) } diff --git a/src/extensions/sync.ts b/src/extensions/sync.ts index 651eaf40..90bb4576 100644 --- a/src/extensions/sync.ts +++ b/src/extensions/sync.ts @@ -1,17 +1,32 @@ -import { Database, DatabaseEventsMap } from '../db/Database' +import { ENTITY_TYPE, PRIMARY_KEY, Entity } from '../glossary' +import { + Database, + DatabaseEventsMap, + SerializedEntity, + SERIALIZED_INTERNAL_PROPERTIES_KEY, +} from '../db/Database' +import { inheritInternalProperties } from '../utils/inheritInternalProperties' -interface DatabaseMessageEventData< - OperationType extends keyof DatabaseEventsMap -> { - operationType: OperationType - payload: DatabaseEventsMap[OperationType] -} +export type DatabaseMessageEventData = + | { + operationType: 'create' + payload: Parameters + } + | { + operationType: 'update' + payload: Parameters + } + | { + operationType: 'delete' + payload: Parameters + } function removeListeners( event: Event, db: Database, ) { const listeners = db.events.listeners(event) as DatabaseEventsMap[Event][] + listeners.forEach((listener) => { db.events.removeListener(event, listener) }) @@ -23,6 +38,26 @@ function removeListeners( } } +/** + * Sets the serialized internal properties as symbols + * on the given entity. + * @note `Symbol` properties are stripped off when sending + * an object over an event emitter. + */ +function deserializeEntity(entity: SerializedEntity): Entity { + const { + [SERIALIZED_INTERNAL_PROPERTIES_KEY]: internalProperties, + ...publicProperties + } = entity + + inheritInternalProperties(publicProperties, { + [ENTITY_TYPE]: internalProperties.entityType, + [PRIMARY_KEY]: internalProperties.primaryKey, + }) + + return publicProperties +} + /** * Synchronizes database operations across multiple clients. */ @@ -38,9 +73,8 @@ export function sync(db: Database) { channel.addEventListener( 'message', - (event: MessageEvent>) => { - const { operationType, payload } = event.data - const [sourceId, ...args] = payload + (event: MessageEvent) => { + const [sourceId] = event.data.payload // Ignore messages originating from unrelated databases. // Useful in case of multiple databases on the same page. @@ -50,26 +84,47 @@ export function sync(db: Database) { // Remove database event listener for the signaled operation // to prevent an infinite loop when applying this operation. - const restoreListeners = removeListeners(operationType, db) + const restoreListeners = removeListeners(event.data.operationType, db) // Apply the database operation signaled from another client // to the current database instance. - // @ts-ignore - db[operationType](...args) + switch (event.data.operationType) { + case 'create': { + const [modelName, entity, customPrimaryKey] = event.data.payload[1] + db.create(modelName, deserializeEntity(entity), customPrimaryKey) + break + } + + case 'update': { + const [modelName, prevEntity, nextEntity] = event.data.payload[1] + db.update( + modelName, + deserializeEntity(prevEntity), + deserializeEntity(nextEntity), + ) + break + } + + default: { + db[event.data.operationType](...event.data.payload[1]) + } + } // Re-attach database event listeners. restoreListeners() }, ) + // Broadcast the emitted event from this client + // to all the other connected clients. function broadcastDatabaseEvent( operationType: Event, ) { - return (...args: Parameters) => { + return (...payload: Parameters) => { channel.postMessage({ operationType, - payload: args, - }) + payload, + } as DatabaseMessageEventData) } } diff --git a/src/factory.ts b/src/factory.ts index 4ae65064..40571e96 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -1,11 +1,11 @@ import { format } from 'outvariant' import { - InternalEntity, + Entity, FactoryAPI, ModelAPI, ModelDefinition, ModelDictionary, - InternalEntityProperty, + PRIMARY_KEY, } from './glossary' import { first } from './utils/first' import { executeQuery } from './query/executeQuery' @@ -20,7 +20,6 @@ import { generateGraphQLSchema, } from './model/generateGraphQLHandlers' import { sync } from './extensions/sync' -import { removeInternalProperties } from './utils/removeInternalProperties' /** * Create a database with the given models. @@ -67,9 +66,7 @@ function createModelApi< db, ) - const entityId = entity[ - entity[InternalEntityProperty.primaryKey] - ] as string + const entityId = entity[entity[PRIMARY_KEY]] as string if (!entityId) { throw new OperationError( @@ -91,13 +88,13 @@ function createModelApi< 'Failed to create a "%s" entity: an entity with the same primary key "%s" ("%s") already exists.', modelName, entityId, - entity[InternalEntityProperty.primaryKey], + entity[PRIMARY_KEY], ), ) } db.create(modelName, entity) - return removeInternalProperties(entity) + return entity }, count(query) { if (!query) { @@ -122,7 +119,7 @@ function createModelApi< ) } - return firstResult ? removeInternalProperties(firstResult) : null + return firstResult }, findMany(query) { const results = executeQuery(modelName, primaryKey, query, db) @@ -138,12 +135,10 @@ function createModelApi< ) } - return results.map((record) => removeInternalProperties(record)) + return results }, getAll() { - return db - .listEntities(modelName) - .map((entity) => removeInternalProperties(entity)) + return db.listEntities(modelName) }, update({ strict, ...query }) { const results = executeQuery(modelName, primaryKey, query, db) @@ -167,21 +162,16 @@ function createModelApi< const nextRecord = updateEntity(prevRecord, query.data, definition) if ( - nextRecord[prevRecord[InternalEntityProperty.primaryKey]] !== - prevRecord[prevRecord[InternalEntityProperty.primaryKey]] + nextRecord[prevRecord[PRIMARY_KEY]] !== + prevRecord[prevRecord[PRIMARY_KEY]] ) { - if ( - db.has( - modelName, - nextRecord[prevRecord[InternalEntityProperty.primaryKey]], - ) - ) { + if (db.has(modelName, nextRecord[prevRecord[PRIMARY_KEY]])) { throw new OperationError( OperationErrorType.DuplicatePrimaryKey, format( 'Failed to execute "update" on the "%s" model: the entity with a primary key "%s" ("%s") already exists.', modelName, - nextRecord[prevRecord[InternalEntityProperty.primaryKey]], + nextRecord[prevRecord[PRIMARY_KEY]], primaryKey, ), ) @@ -190,11 +180,11 @@ function createModelApi< db.update(modelName, prevRecord, nextRecord) - return removeInternalProperties(nextRecord) + return nextRecord }, updateMany({ strict, ...query }) { const records = executeQuery(modelName, primaryKey, query, db) - const updatedRecords: InternalEntity[] = [] + const updatedRecords: Entity[] = [] if (records.length === 0) { if (strict) { @@ -215,21 +205,16 @@ function createModelApi< const nextRecord = updateEntity(prevRecord, query.data, definition) if ( - nextRecord[prevRecord[InternalEntityProperty.primaryKey]] !== - prevRecord[prevRecord[InternalEntityProperty.primaryKey]] + nextRecord[prevRecord[PRIMARY_KEY]] !== + prevRecord[prevRecord[PRIMARY_KEY]] ) { - if ( - db.has( - modelName, - nextRecord[prevRecord[InternalEntityProperty.primaryKey]], - ) - ) { + if (db.has(modelName, nextRecord[prevRecord[PRIMARY_KEY]])) { throw new OperationError( OperationErrorType.DuplicatePrimaryKey, format( 'Failed to execute "updateMany" on the "%s" model: the entity with a primary key "%s" ("%s") already exists.', modelName, - nextRecord[prevRecord[InternalEntityProperty.primaryKey]], + nextRecord[prevRecord[PRIMARY_KEY]], primaryKey, ), ) @@ -240,7 +225,7 @@ function createModelApi< updatedRecords.push(nextRecord) }) - return updatedRecords.map((record) => removeInternalProperties(record)) + return updatedRecords }, delete({ strict, ...query }) { const results = executeQuery(modelName, primaryKey, query, db) @@ -261,11 +246,8 @@ function createModelApi< return null } - db.delete( - modelName, - record[record[InternalEntityProperty.primaryKey]] as string, - ) - return removeInternalProperties(record) + db.delete(modelName, record[record[PRIMARY_KEY]] as string) + return record }, deleteMany({ strict, ...query }) { const records = executeQuery(modelName, primaryKey, query, db) @@ -286,13 +268,10 @@ function createModelApi< } records.forEach((record) => { - db.delete( - modelName, - record[record[InternalEntityProperty.primaryKey]] as string, - ) + db.delete(modelName, record[record[PRIMARY_KEY]] as string) }) - return records.map((record) => removeInternalProperties(record)) + return records }, toHandlers(type: 'rest' | 'graphql', baseUrl: string): any { if (type === 'graphql') { diff --git a/src/glossary.ts b/src/glossary.ts index d17001b5..6bfccb12 100644 --- a/src/glossary.ts +++ b/src/glossary.ts @@ -15,15 +15,6 @@ export type PrimitiveValueType = string | number | boolean | Date export type ModelValueType = PrimitiveValueType | PrimitiveValueType[] export type ModelValueTypeGetter = () => ModelValueType -/** - * Minimal representation of an entity to look it up - * in the database and resolve upon reference. - */ -export type RelationRef = - InternalEntityProperties & { - [InternalEntityProperty.nodeId]: PrimaryKeyType - } - export type ModelDefinition = Record export type ModelDefinitionValue = @@ -55,26 +46,23 @@ export type Limit = { } } -export enum InternalEntityProperty { - type = '__type', - nodeId = '__nodeId', - primaryKey = '__primaryKey', -} +export const PRIMARY_KEY = Symbol('primaryKey') +export const ENTITY_TYPE = Symbol('type') export interface InternalEntityProperties { - readonly [InternalEntityProperty.type]: ModelName - readonly [InternalEntityProperty.primaryKey]: PrimaryKeyType + readonly [ENTITY_TYPE]: ModelName + readonly [PRIMARY_KEY]: PrimaryKeyType } export type Entity< Dictionary extends ModelDictionary, ModelName extends keyof Dictionary, -> = Value +> = PublicEntity & InternalEntityProperties -export type InternalEntity< +export type PublicEntity< Dictionary extends ModelDictionary, ModelName extends keyof Dictionary, -> = InternalEntityProperties & Entity +> = Value export type RequiredExactlyOne< ObjectType, @@ -178,7 +166,7 @@ export type UpdateManyValue< > = | Value | { - [Key in keyof Target]: Target[Key] extends PrimaryKey + [Key in keyof Target]?: Target[Key] extends PrimaryKey ? ( prevValue: ReturnType, entity: Value, @@ -204,9 +192,9 @@ export type Value< ? ReturnType : // Extract value type from relations. Target[Key] extends OneOf - ? Entity + ? PublicEntity : Target[Key] extends ManyOf - ? Entity[] + ? PublicEntity[] : // Account for primitive value getters because // native constructors (i.e. StringConstructor) satisfy // the "AnyObject" predicate below. diff --git a/src/model/createModel.ts b/src/model/createModel.ts index 7419ece4..2a58be25 100644 --- a/src/model/createModel.ts +++ b/src/model/createModel.ts @@ -4,11 +4,12 @@ import set from 'lodash/set' import isFunction from 'lodash/isFunction' import { Database } from '../db/Database' import { - InternalEntity, + ENTITY_TYPE, + Entity, InternalEntityProperties, - InternalEntityProperty, ModelDefinition, ModelDictionary, + PRIMARY_KEY, Value, } from '../glossary' import { ParsedModelDefinition } from './parseModelDefinition' @@ -28,7 +29,7 @@ export function createModel< parsedModel: ParsedModelDefinition, initialValues: Partial>, db: Database, -): InternalEntity { +): Entity { const { primaryKey, properties, relations } = parsedModel log( @@ -39,11 +40,9 @@ export function createModel< initialValues, ) - // Internal properties that allow identifying this model - // when referenced in other models (i.e. via relatioships). const internalProperties: InternalEntityProperties = { - [InternalEntityProperty.type]: modelName, - [InternalEntityProperty.primaryKey]: primaryKey, + [ENTITY_TYPE]: modelName, + [PRIMARY_KEY]: primaryKey, } const publicProperties = properties.reduce>( @@ -92,7 +91,12 @@ export function createModel< {}, ) - const entity = Object.assign({}, publicProperties, internalProperties) + const entity = Object.assign( + {}, + publicProperties, + internalProperties, + ) as Entity + defineRelationalProperties(entity, initialValues, relations, dictionary, db) log('created "%s" entity:', modelName, entity) diff --git a/src/model/defineRelationalProperties.ts b/src/model/defineRelationalProperties.ts index 96066666..f1f1aa8f 100644 --- a/src/model/defineRelationalProperties.ts +++ b/src/model/defineRelationalProperties.ts @@ -1,13 +1,13 @@ import { debug } from 'debug' import get from 'lodash/get' import { Database } from '../db/Database' -import { InternalEntity, ModelDictionary, Value } from '../glossary' +import { Entity, ModelDictionary, Value } from '../glossary' import { RelationsMap } from '../relations/Relation' const log = debug('defineRelationalProperties') export function defineRelationalProperties( - entity: InternalEntity, + entity: Entity, initialValues: Partial>, relations: RelationsMap, dictionary: ModelDictionary, @@ -30,6 +30,7 @@ export function defineRelationalProperties( initialValues, propertyPath, ) + relation.apply(entity, propertyPath, references, dictionary, db) } } diff --git a/src/model/parseModelDefinition.ts b/src/model/parseModelDefinition.ts index a8d260f0..8b7ad4dc 100644 --- a/src/model/parseModelDefinition.ts +++ b/src/model/parseModelDefinition.ts @@ -71,8 +71,7 @@ function deepParseModelDefinition( // Relations. if (value instanceof Relation) { - // Resolve a relation against the dictionary to collect - // the primary key names of the referenced models. + // Store the relations in a separate object. result.relations[propertyPath] = value continue } diff --git a/src/model/updateEntity.ts b/src/model/updateEntity.ts index 332a816c..0b5e8cf0 100644 --- a/src/model/updateEntity.ts +++ b/src/model/updateEntity.ts @@ -2,8 +2,15 @@ import { debug } from 'debug' import get from 'lodash/get' import { invariant } from 'outvariant' import { Relation } from '../relations/Relation' -import { InternalEntity, ModelDefinition, Value } from '../glossary' +import { + ENTITY_TYPE, + Entity, + ModelDefinition, + PRIMARY_KEY, + Value, +} from '../glossary' import { isObject } from '../utils/isObject' +import { inheritInternalProperties } from '../utils/inheritInternalProperties' const log = debug('updateEntity') @@ -12,29 +19,30 @@ const log = debug('updateEntity') * it based on the existing values. */ export function updateEntity( - entity: InternalEntity, + entity: Entity, data: any, definition: ModelDefinition, -): InternalEntity { +): Entity { log('updating entity: %j, with data: %s', entity, data) log('model definition:', definition) const updateRecursively = ( - entityChunk: InternalEntity, + entityChunk: Entity, data: any, parentPath: string = '', - ): InternalEntity => { - const result = Object.entries(data).reduce>( + ): Entity => { + const result = Object.entries(data).reduce>( (nextEntity, [propertyName, value]) => { const propertyPath = parentPath ? `${parentPath}.${propertyName}` : propertyName log( - 'updating propety "%s" ("%s") to "%s" on: %j', - propertyName, + 'updating propety "%s" to "%s" on "%s" ("%s"): %j', propertyPath, value, + entity[ENTITY_TYPE], + entity[entity[PRIMARY_KEY]], entityChunk, ) @@ -102,5 +110,16 @@ export function updateEntity( return result } - return updateRecursively(entity, data) + const result = updateRecursively(entity, data) + + /** + * @note Inherit the internal properties (type, primary key) + * from the source (previous) entity. + * Spreading the entity chunk strips off its symbols. + */ + inheritInternalProperties(result, entity) + + log('successfully updated to:', result) + + return result } diff --git a/src/query/executeQuery.ts b/src/query/executeQuery.ts index b2ea95a8..09291eff 100644 --- a/src/query/executeQuery.ts +++ b/src/query/executeQuery.ts @@ -1,9 +1,5 @@ import { debug } from 'debug' -import { - InternalEntity, - InternalEntityProperty, - PrimaryKeyType, -} from '../glossary' +import { Entity, PrimaryKeyType, PRIMARY_KEY } from '../glossary' import { compileQuery } from './compileQuery' import { BulkQueryOptions, @@ -14,21 +10,34 @@ import * as iteratorUtils from '../utils/iteratorUtils' import { paginateResults } from './paginateResults' import { Database } from '../db/Database' import { sortResults } from './sortResults' +import { invariant } from 'outvariant' const log = debug('executeQuery') function queryByPrimaryKey( - records: Map>, + records: Map>, query: QuerySelector, ) { log('querying by primary key') + log('query by primary key', { query, records }) + const matchPrimaryKey = compileQuery(query) - return iteratorUtils.filter((id, value) => { - return matchPrimaryKey({ - [value[InternalEntityProperty.primaryKey]]: id, - }) + const result = iteratorUtils.filter((id, value) => { + const primaryKey = value[PRIMARY_KEY] + + invariant( + primaryKey, + 'Failed to query by primary key using "%j": record (%j) has no primary key set.', + query, + value, + ) + + return matchPrimaryKey({ [primaryKey]: id }) }, records) + + log('result of querying by primary key:', result) + return result } /** @@ -40,7 +49,7 @@ export function executeQuery( primaryKey: PrimaryKeyType, query: WeakQuerySelector & BulkQueryOptions, db: Database, -): InternalEntity[] { +): Entity[] { log(`${JSON.stringify(query)} on "${modelName}"`) log('using primary key "%s"', primaryKey) @@ -52,7 +61,7 @@ export function executeQuery( log('primary key query', primaryKeyComparator) const scopedRecords = primaryKeyComparator - ? queryByPrimaryKey(db.getModel(modelName), { + ? queryByPrimaryKey(records, { where: { [primaryKey]: primaryKeyComparator }, }) : records diff --git a/src/query/paginateResults.ts b/src/query/paginateResults.ts index eed2610b..e8b27385 100644 --- a/src/query/paginateResults.ts +++ b/src/query/paginateResults.ts @@ -1,4 +1,4 @@ -import { InternalEntity, InternalEntityProperty } from '../glossary' +import { Entity, PRIMARY_KEY } from '../glossary' import { BulkQueryOptions, WeakQuerySelector } from './queryTypes' function getEndIndex(start: number, end?: number) { @@ -7,11 +7,11 @@ function getEndIndex(start: number, end?: number) { export function paginateResults( query: WeakQuerySelector & BulkQueryOptions, - data: InternalEntity[], -): InternalEntity[] { + data: Entity[], +): Entity[] { if (query.cursor) { const cursorIndex = data.findIndex((entity) => { - return entity[entity[InternalEntityProperty.primaryKey]] === query.cursor + return entity[entity[PRIMARY_KEY]] === query.cursor }) if (cursorIndex === -1) { diff --git a/src/query/sortResults.ts b/src/query/sortResults.ts index 9a5664eb..5c356ca3 100644 --- a/src/query/sortResults.ts +++ b/src/query/sortResults.ts @@ -1,6 +1,6 @@ import { debug } from 'debug' import get from 'lodash/get' -import { Entity, InternalEntity } from 'src/glossary' +import { Entity } from 'src/glossary' import { OrderBy, SortDirection } from './queryTypes' const log = debug('sortResults') @@ -52,7 +52,7 @@ function flattenSortCriteria>( */ export function sortResults>( orderBy: OrderBy | OrderBy[], - data: InternalEntity[], + data: Entity[], ): void { log('sorting data:', data) log('order by:', orderBy) diff --git a/src/relations/Relation.ts b/src/relations/Relation.ts index fccc67d6..6b4c3c3d 100644 --- a/src/relations/Relation.ts +++ b/src/relations/Relation.ts @@ -5,11 +5,11 @@ import { invariant } from 'outvariant' import { Database } from '../db/Database' import { Entity, - InternalEntity, - InternalEntityProperty, + ENTITY_TYPE, KeyType, ModelDictionary, PrimaryKeyType, + PRIMARY_KEY, Value, } from '../glossary' import { executeQuery } from '../query/executeQuery' @@ -93,13 +93,6 @@ export class Relation< private db: Database = null as any constructor(definition: RelationDefinition) { - log( - 'constructing a "%s" relation to "%s" with attributes: %o', - definition.kind, - definition.to, - definition.attributes, - ) - this.kind = definition.kind this.attributes = { ...DEFAULT_RELATION_ATTRIBUTES, @@ -109,6 +102,13 @@ export class Relation< modelName: definition.to.toString(), primaryKey: null as any, } + + log( + 'constructing a "%s" relation to "%s" with attributes: %o', + this.kind, + definition.to, + this.attributes, + ) } /** @@ -127,8 +127,8 @@ export class Relation< this.dictionary = dictionary this.db = db - const sourceModelName = entity[InternalEntityProperty.type] - const sourcePrimaryKey = entity[InternalEntityProperty.primaryKey] + const sourceModelName = entity[ENTITY_TYPE] + const sourcePrimaryKey = entity[PRIMARY_KEY] this.source = { modelName: sourceModelName, @@ -140,6 +140,7 @@ export class Relation< const targetPrimaryKey = findPrimaryKey( this.dictionary[this.target.modelName], ) + invariant( targetPrimaryKey, 'Failed to create a "%s" relation to "%s": referenced model does not exist or has no primary key.', @@ -155,15 +156,16 @@ export class Relation< * Updates the relation references (values) to resolve the relation with. */ public resolveWith( - entity: Entity, + entity: Entity, refs: ReferenceType, ): void { log( - 'resolving a "%s" relational property to "%s" on "%s.%s"', + 'resolving a "%s" relational property to "%s" on "%s.%s" ("%s")', this.kind, this.target.modelName, this.source.modelName, this.source.propertyPath, + entity[this.source.primaryKey], ) log('entity of this relation:', entity) @@ -282,13 +284,16 @@ export class Relation< configurable: true, get: () => { log( - 'GET "%s.%s"', + 'GET "%s.%s" on "%s" ("%s")', this.source.modelName, this.source.propertyPath, + this.source.modelName, + entity[this.source.primaryKey], this, ) + log('GET using referenced values', referencesList) - const queryResult = referencesList.reduce[]>( + const queryResult = referencesList.reduce[]>( (result, ref) => { return result.concat( executeQuery( @@ -309,10 +314,11 @@ export class Relation< ) log( - 'resolved "%s" relation at "%s.%s" to:', + 'resolved "%s" relation at "%s.%s" ("%s") to:', this.kind, this.source.modelName, this.source.propertyPath, + entity[this.source.primaryKey], queryResult, ) diff --git a/src/utils/inheritInternalProperties.ts b/src/utils/inheritInternalProperties.ts new file mode 100644 index 00000000..7bae6132 --- /dev/null +++ b/src/utils/inheritInternalProperties.ts @@ -0,0 +1,34 @@ +import { invariant } from 'outvariant' +import { ENTITY_TYPE, PRIMARY_KEY, Entity } from '../glossary' + +export function inheritInternalProperties( + target: Record, + source: Entity, +): void { + const entityType = source[ENTITY_TYPE] + const primaryKey = source[PRIMARY_KEY] + + invariant( + entityType, + 'Failed to inherit internal properties from (%j) to (%j): provided source entity has no entity type specified.', + source, + target, + ) + invariant( + primaryKey, + 'Failed to inherit internal properties from (%j) to (%j): provided source entity has no primary key specified.', + source, + target, + ) + + Object.defineProperties(target, { + [ENTITY_TYPE]: { + enumerable: true, + value: entityType, + }, + [PRIMARY_KEY]: { + enumerable: true, + value: primaryKey, + }, + }) +} diff --git a/src/utils/isInternalEntity.ts b/src/utils/isInternalEntity.ts deleted file mode 100644 index d120e7fd..00000000 --- a/src/utils/isInternalEntity.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { InternalEntity, InternalEntityProperty } from '../glossary' -import { isObject } from './isObject' - -/** - * Returns true if the given value is an internal entity object. - */ -export function isInternalEntity( - value: Record, -): value is InternalEntity { - return ( - isObject(value) && - InternalEntityProperty.type in value && - InternalEntityProperty.primaryKey in value - ) -} diff --git a/src/utils/removeInternalProperties.ts b/src/utils/removeInternalProperties.ts deleted file mode 100644 index 08cdc0fe..00000000 --- a/src/utils/removeInternalProperties.ts +++ /dev/null @@ -1,85 +0,0 @@ -import set from 'lodash/set' -import { - InternalEntity, - InternalEntityProperty, - Entity, - ModelDictionary, - Value, -} from '../glossary' -import { isInternalEntity } from './isInternalEntity' -import { isObject } from './isObject' - -function isOneOfRelation( - value: Value, -): value is InternalEntity { - return isInternalEntity(value) -} - -function isManyOfRelation( - value: Value, -): value is Array> { - return Array.isArray(value) && value.every(isInternalEntity) -} - -/** - * Removes internal properties from the given entity. - */ -export function removeInternalProperties< - Dictionary extends ModelDictionary, - ModelName extends keyof Dictionary, ->( - entity: InternalEntity, - result: Entity = {}, -): Entity { - for (const [propertyName, value] of Object.entries(entity)) { - // Remove the internal entity properties. - if ( - propertyName === InternalEntityProperty.type || - propertyName === InternalEntityProperty.primaryKey - ) { - continue - } - - // Remove the internal properties of a "oneOf" relation. - if ( - isOneOfRelation( - // @ts-ignore - value, - ) - ) { - const relationalEntity = removeInternalProperties(value) - set(result, propertyName, relationalEntity) - continue - } - - // Remove the internal properties of a "manyOf" relation. - if ( - isManyOfRelation( - // @ts-ignore - value, - ) - ) { - const relationalEntityList = value.map( - ( - // @ts-ignore - node, - ) => removeInternalProperties(node), - ) - set(result, propertyName, relationalEntityList) - continue - } - - if (isObject(value)) { - set( - result, - propertyName, - removeInternalProperties(value as any, result[propertyName]), - ) - continue - } - - set(result, propertyName, value) - } - - return result -} diff --git a/test/db/events.test.ts b/test/db/events.test.ts index c04db5c1..4e649e14 100644 --- a/test/db/events.test.ts +++ b/test/db/events.test.ts @@ -1,7 +1,12 @@ -import { Database } from '../../src/db/Database' +import { + Database, + SerializedEntity, + SERIALIZED_INTERNAL_PROPERTIES_KEY, +} from '../../src/db/Database' import { createModel } from '../../src/model/createModel' import { primaryKey } from '../../src/primaryKey' import { parseModelDefinition } from '../../src/model/parseModelDefinition' +import { ENTITY_TYPE, PRIMARY_KEY } from '../../src/glossary' test('emits the "create" event when a new entity is created', (done) => { const dictionary = { @@ -14,14 +19,25 @@ test('emits the "create" event when a new entity is created', (done) => { user: dictionary.user, }) - db.events.on('create', (id, modelName, entity, primaryKey) => { + db.events.on('create', (id, [modelName, entity, primaryKey]) => { expect(id).toEqual(db.id) expect(modelName).toEqual('user') expect(entity).toEqual({ - __type: 'user', - __primaryKey: 'id', + /** + * @note Entity reference in the database event listener + * contains its serialized internal properties. + * This allows for this listener to re-create the entity + * when the data is transferred over other channels + * (i.e. via "BroadcastChannel" which strips object symbols). + */ + [SERIALIZED_INTERNAL_PROPERTIES_KEY]: { + entityType: 'user', + primaryKey: 'id', + }, + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'abc-123', - }) + } as SerializedEntity) expect(primaryKey).toBeUndefined() done() }) @@ -53,21 +69,30 @@ test('emits the "update" event when an existing entity is updated', (done) => { user: dictionary.user, }) - db.events.on('update', (id, modelName, prevEntity, nextEntity) => { + db.events.on('update', (id, [modelName, prevEntity, nextEntity]) => { expect(id).toEqual(db.id) expect(modelName).toEqual('user') expect(prevEntity).toEqual({ - __type: 'user', - __primaryKey: 'id', + [SERIALIZED_INTERNAL_PROPERTIES_KEY]: { + entityType: 'user', + primaryKey: 'id', + }, + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'abc-123', firstName: 'John', - }) + } as SerializedEntity) + expect(nextEntity).toEqual({ - __type: 'user', - __primaryKey: 'id', + [SERIALIZED_INTERNAL_PROPERTIES_KEY]: { + entityType: 'user', + primaryKey: 'id', + }, + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'def-456', firstName: 'Kate', - }) + } as SerializedEntity) done() }) @@ -108,7 +133,7 @@ test('emits the "delete" event when an existing entity is deleted', (done) => { user: dictionary.user, }) - db.events.on('delete', (id, modelName, primaryKey) => { + db.events.on('delete', (id, [modelName, primaryKey]) => { expect(id).toEqual(db.id) expect(modelName).toEqual('user') expect(primaryKey).toEqual('abc-123') diff --git a/test/model/relationalProperties.test.ts b/test/model/relationalProperties.test.ts index 5b166fac..510b15df 100644 --- a/test/model/relationalProperties.test.ts +++ b/test/model/relationalProperties.test.ts @@ -5,7 +5,7 @@ import { RelationsMap, } from '../../src/relations/Relation' import { Database } from '../../src/db/Database' -import { InternalEntityProperty, ModelDictionary } from '../../src/glossary' +import { ENTITY_TYPE, ModelDictionary, PRIMARY_KEY } from '../../src/glossary' import { defineRelationalProperties } from '../../src/model/defineRelationalProperties' it('marks relational properties as enumerable', () => { @@ -24,8 +24,8 @@ it('marks relational properties as enumerable', () => { const db = new Database(dictionary) db.create('user', { - [InternalEntityProperty.primaryKey]: 'id', - [InternalEntityProperty.type]: 'user', + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'abc-123', name: 'Test User', }) @@ -38,8 +38,8 @@ it('marks relational properties as enumerable', () => { } const post = { - [InternalEntityProperty.primaryKey]: 'id', - [InternalEntityProperty.type]: 'post', + [ENTITY_TYPE]: 'post', + [PRIMARY_KEY]: 'id', id: '234', title: 'Test Post', } diff --git a/test/model/update.test.ts b/test/model/update.test.ts index 15a64174..b97a85ce 100644 --- a/test/model/update.test.ts +++ b/test/model/update.test.ts @@ -1,5 +1,6 @@ import { datatype, name } from 'faker' import { factory, primaryKey } from '@mswjs/data' +import { ENTITY_TYPE, PRIMARY_KEY } from '../../lib/glossary' import { OperationErrorType } from '../../src/errors/OperationError' import { getThrownError } from '../testUtils' @@ -63,6 +64,8 @@ test('updates a property that had no initial value', () => { }, }), ).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'abc-123', firstName: 'John', }) @@ -76,6 +79,8 @@ test('updates a property that had no initial value', () => { }, }), ).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'abc-123', firstName: 'John', }) diff --git a/test/regressions/02-handlers-many-of.test.ts b/test/regressions/02-handlers-many-of.test.ts index fe7e366f..7e43e431 100644 --- a/test/regressions/02-handlers-many-of.test.ts +++ b/test/regressions/02-handlers-many-of.test.ts @@ -2,6 +2,7 @@ import fetch from 'node-fetch' import { rest } from 'msw' import { setupServer } from 'msw/node' import { factory, manyOf, primaryKey } from '@mswjs/data' +import { ENTITY_TYPE, PRIMARY_KEY } from '../../lib/glossary' const server = setupServer() @@ -13,7 +14,7 @@ afterAll(() => { server.close() }) -it('updates database entity modified via a generated handler', async () => { +it('updates database entity modified via a generated request handler', async () => { const db = factory({ user: { id: primaryKey(String), @@ -86,6 +87,8 @@ it('updates database entity modified via a generated handler', async () => { }, }), ).toEqual({ + [ENTITY_TYPE]: 'note', + [PRIMARY_KEY]: 'id', id: 'note-2', title: 'Updated title', }) @@ -101,13 +104,19 @@ it('updates database entity modified via a generated handler', async () => { }, }), ).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'user-1', notes: [ { + [ENTITY_TYPE]: 'note', + [PRIMARY_KEY]: 'id', id: 'note-1', title: 'First note', }, { + [ENTITY_TYPE]: 'note', + [PRIMARY_KEY]: 'id', id: 'note-2', title: 'Updated title', }, diff --git a/test/relations/Relation.test.ts b/test/relations/Relation.test.ts index bfff5915..ff9ea8e9 100644 --- a/test/relations/Relation.test.ts +++ b/test/relations/Relation.test.ts @@ -1,6 +1,6 @@ import { primaryKey } from '../../src' +import { ENTITY_TYPE, PRIMARY_KEY, ModelDictionary } from '../../src/glossary' import { Database } from '../../src/db/Database' -import { InternalEntityProperty, ModelDictionary } from '../../src/glossary' import { Relation, RelationAttributes, @@ -54,15 +54,15 @@ it('applies a "ONE_OF" relation to an entity', () => { const db = new Database(dictionary) db.create('country', { - [InternalEntityProperty.type]: 'country', - [InternalEntityProperty.primaryKey]: 'code', + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'code', code: 'us', }) const country = db.getModel('country').get('us')! const users = db.create('user', { - [InternalEntityProperty.type]: 'user', - [InternalEntityProperty.primaryKey]: 'id', + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'user-1', }) const user = users.get('user-1')! @@ -95,20 +95,20 @@ it('applies a "MANY_OF" relation to an entity', () => { const db = new Database(dictionary) const users = db.create('user', { - [InternalEntityProperty.type]: 'user', - [InternalEntityProperty.primaryKey]: 'id', + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'user-1', }) const user = users.get('user-1')! db.create('post', { - [InternalEntityProperty.type]: 'post', - [InternalEntityProperty.primaryKey]: 'id', + [ENTITY_TYPE]: 'post', + [PRIMARY_KEY]: 'id', id: 'post-1', }) db.create('post', { - [InternalEntityProperty.type]: 'post', - [InternalEntityProperty.primaryKey]: 'id', + [ENTITY_TYPE]: 'post', + [PRIMARY_KEY]: 'id', id: 'post-2', }) const firstPost = db.getModel('post').get('post-1')! @@ -144,8 +144,8 @@ it('throws an exception when applying a relation that references a non-existing } const db = new Database(dictionary) const users = db.create('user', { - [InternalEntityProperty.type]: 'user', - [InternalEntityProperty.primaryKey]: 'id', + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'user-1', }) const user = users.get('user-1')! @@ -175,21 +175,21 @@ it('throws an exception when applying a unique relation that references an alrea } const db = new Database(dictionary) db.create('user', { - [InternalEntityProperty.type]: 'user', - [InternalEntityProperty.primaryKey]: 'id', + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'user-1', }) db.create('user', { - [InternalEntityProperty.type]: 'user', - [InternalEntityProperty.primaryKey]: 'id', + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'user-2', }) const firstUser = db.getModel('user').get('user-1')! const secondUser = db.getModel('user').get('user-2')! db.create('country', { - [InternalEntityProperty.type]: 'country', - [InternalEntityProperty.primaryKey]: 'code', + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'code', code: 'us', }) @@ -223,15 +223,15 @@ it('does not throw an exception when updating the relational reference to the sa } const db = new Database(dictionary) const users = db.create('user', { - [InternalEntityProperty.type]: 'user', - [InternalEntityProperty.primaryKey]: 'id', + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'user-1', }) const user = users.get('user-1')! const countries = db.create('country', { - [InternalEntityProperty.type]: 'country', - [InternalEntityProperty.primaryKey]: 'code', + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'code', code: 'us', }) const country = countries.get('us') diff --git a/test/relations/one-to-many.test.ts b/test/relations/one-to-many.test.ts index 72845739..c42ba7ac 100644 --- a/test/relations/one-to-many.test.ts +++ b/test/relations/one-to-many.test.ts @@ -1,5 +1,6 @@ import { random, datatype } from 'faker' import { factory, primaryKey, manyOf } from '@mswjs/data' +import { ENTITY_TYPE, PRIMARY_KEY } from '../../lib/glossary' test('supports one-to-many relation', () => { const db = factory({ @@ -29,6 +30,48 @@ test('supports one-to-many relation', () => { expect(posts).toEqual(['First post', 'Second post']) }) +test('supports a recusrive one-to-many relation', () => { + const db = factory({ + user: { + id: primaryKey(String), + firstName: String, + friends: manyOf('user'), + }, + }) + + const john = db.user.create({ + id: 'john', + firstName: 'John', + friends: [], + }) + + const kate = db.user.create({ + id: 'kate', + firstName: 'Kate', + friends: [john], + }) + + db.user.findFirst({ + where: { id: { equals: 'john' } }, + strict: true, + }) + + const updatedJohn = db.user.update({ + where: { + id: { + equals: john.id, + }, + }, + data: { + friends: [kate], + }, + strict: true, + })! + + expect(updatedJohn.friends).toHaveLength(1) + expect(updatedJohn.friends[0]).toHaveProperty('firstName', 'Kate') +}) + test('supports querying through one-to-many relation', () => { const db = factory({ user: { @@ -170,6 +213,8 @@ test('updates the relational value via the ".update()" model method', () => { expect(user.posts).toEqual([firstPost]) expect(refetchUser()).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'abc-123', posts: [firstPost], }) @@ -185,10 +230,14 @@ test('updates the relational value via the ".update()" model method', () => { }) expect(updatedUser).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'abc-123', posts: [secondPost], }) expect(refetchUser()).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'abc-123', posts: [secondPost], }) @@ -222,6 +271,8 @@ test('throws an exception when updating a relational value via a compatible obje expect(user.posts).toEqual([firstPost]) expect(refetchUser()).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', id: 'abc-123', posts: [firstPost], }) diff --git a/test/relations/one-to-one.test.ts b/test/relations/one-to-one.test.ts index a90c8aa6..a140cfd8 100644 --- a/test/relations/one-to-one.test.ts +++ b/test/relations/one-to-one.test.ts @@ -1,4 +1,5 @@ import { factory, primaryKey, oneOf } from '@mswjs/data' +import { ENTITY_TYPE, PRIMARY_KEY } from '../../lib/glossary' test('supports one-to-one relationship', () => { const db = factory({ @@ -23,9 +24,12 @@ test('supports one-to-one relationship', () => { }) expect(britain.capital).toEqual({ + [ENTITY_TYPE]: 'city', + [PRIMARY_KEY]: 'id', id: 'city-1', name: 'London', }) + expect( db.country.findFirst({ where: { @@ -35,9 +39,13 @@ test('supports one-to-one relationship', () => { }, }), ).toEqual({ + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'id', id: 'country-1', name: 'Great Britain', capital: { + [ENTITY_TYPE]: 'city', + [PRIMARY_KEY]: 'id', id: 'city-1', name: 'London', }, @@ -77,9 +85,13 @@ test('supports querying through a one-to-one relational property', () => { }, }), ).toEqual({ + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'id', id: 'country-1', name: 'Great Britain', capital: { + [ENTITY_TYPE]: 'city', + [PRIMARY_KEY]: 'id', id: 'city-1', name: 'London', }, @@ -157,6 +169,8 @@ test('allows creating an entity without specifying a value for the one-to-one re const result = db.country.create({ id: 'country-1' }) expect(result).toEqual({ + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'id', id: 'country-1', name: '', }) @@ -209,17 +223,25 @@ test('updates the relational property to the next value', () => { }) expect(updatedCountry).toEqual({ + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'id', id: 'country-1', name: 'Great Britain', capital: { + [ENTITY_TYPE]: 'city', + [PRIMARY_KEY]: 'id', id: 'city-2', name: 'New Hampshire', }, }) expect(refetchCountry()).toEqual({ + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'id', id: 'country-1', name: 'Great Britain', capital: { + [ENTITY_TYPE]: 'city', + [PRIMARY_KEY]: 'id', id: 'city-2', name: 'New Hampshire', }, @@ -380,9 +402,13 @@ test('supports updating the value of the relational property', () => { }) expect(country).toEqual({ + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'id', id: 'country-1', name: 'Great Britain', capital: { + [ENTITY_TYPE]: 'city', + [PRIMARY_KEY]: 'id', id: 'city-1', name: 'New Hampshire', }, @@ -427,13 +453,23 @@ test('supports updating the values of multiple relational properties', () => { }, }), ).toHaveProperty(['country', 'code'], 'uk') + expect( db.user.findFirst({ where: { id: { equals: 'user-1' }, }, }), - ).toEqual({ id: 'user-1', country: { code: 'uk' } }) + ).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', + id: 'user-1', + country: { + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'code', + code: 'uk', + }, + }) expect( db.user.update({ @@ -447,11 +483,21 @@ test('supports updating the values of multiple relational properties', () => { }, }), ).toHaveProperty(['country', 'code'], 'ua') + expect( db.user.findFirst({ where: { id: { equals: 'user-2' }, }, }), - ).toEqual({ id: 'user-2', country: { code: 'ua' } }) + ).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', + id: 'user-2', + country: { + [ENTITY_TYPE]: 'country', + [PRIMARY_KEY]: 'code', + code: 'ua', + }, + }) }) diff --git a/test/tsconfig.json b/test/tsconfig.json index 7736e3dc..daef3dfa 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "declaration": false, "noEmit": true, - "baseUrl": "../" + "baseUrl": "../", }, "include": ["**/*.ts"] } diff --git a/test/utils/inheritInternalProperties.test.ts b/test/utils/inheritInternalProperties.test.ts new file mode 100644 index 00000000..42766fd8 --- /dev/null +++ b/test/utils/inheritInternalProperties.test.ts @@ -0,0 +1,49 @@ +import { Entity, ENTITY_TYPE, PRIMARY_KEY } from '../../src/glossary' +import { inheritInternalProperties } from '../../src/utils/inheritInternalProperties' + +it('inherits internal properties from the given entity', () => { + const target = { + id: 'abc-123', + firstName: 'John', + } + const entity: Entity = { + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', + } + + inheritInternalProperties(target, entity) + + expect(Object.keys(target)).toEqual(['id', 'firstName']) + expect(Object.getOwnPropertySymbols(target)).toEqual([ + ENTITY_TYPE, + PRIMARY_KEY, + ]) + expect(target).toEqual({ + [ENTITY_TYPE]: 'user', + [PRIMARY_KEY]: 'id', + id: 'abc-123', + firstName: 'John', + }) +}) + +it('throws an exception given a corrupted source entity', () => { + expect(() => + inheritInternalProperties({ firstName: 'John' }, { id: 'abc-123' } as any), + ).toThrow( + 'Failed to inherit internal properties from ({"id":"abc-123"}) to ({"firstName":"John"}): provided source entity has no entity type specified.', + ) + + expect(() => + inheritInternalProperties( + { + firstName: 'John', + }, + { + [ENTITY_TYPE]: 'user', + id: 'abc-123', + } as any, + ), + ).toThrow( + 'Failed to inherit internal properties from ({"id":"abc-123"}) to ({"firstName":"John"}): provided source entity has no primary key specified.', + ) +}) diff --git a/test/utils/isInternalEntity.test.ts b/test/utils/isInternalEntity.test.ts deleted file mode 100644 index 425c0c2e..00000000 --- a/test/utils/isInternalEntity.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { isInternalEntity } from '../../src/utils/isInternalEntity' - -it('returns true given an internal entity object', () => { - expect( - isInternalEntity({ - __type: 'user', - __primaryKey: 'id', - id: 'abc-123', - }), - ).toEqual(true) -}) - -it('returns false given a corrupted internal entity object', () => { - expect( - isInternalEntity({ - __type: 'user', - // Purposefully missing the "__primaryKey" property. - id: 'abc-123', - }), - ).toEqual(false) -}) - -it('returns false given an arbitrary object', () => { - expect( - isInternalEntity({ - id: 'abc-123', - type: 'custom', - }), - ).toEqual(false) -}) diff --git a/test/utils/removeInternalProperties.test.ts b/test/utils/removeInternalProperties.test.ts deleted file mode 100644 index 982cfed9..00000000 --- a/test/utils/removeInternalProperties.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { InternalEntity } from '../../src/glossary' -import { removeInternalProperties } from '../../src/utils/removeInternalProperties' - -it('removes internal properties from a plain entity', () => { - const user: InternalEntity = { - __type: 'user', - __primaryKey: 'id', - id: 'abc-123', - firstName: 'John', - } - - expect(removeInternalProperties(user)).toEqual({ - id: 'abc-123', - firstName: 'John', - }) -}) - -it('removes internal properties from an entity with relations', () => { - const user: InternalEntity = { - __type: 'user', - __primaryKey: 'id', - id: 'abc-123', - firstName: 'John', - // "oneOf" relation. - address: { - __type: 'address', - __primaryKey: 'id', - id: 'addr-123', - street: 'Broadway', - }, - // "manyOf" relation. - contacts: [ - { - __type: 'contact', - __primaryKey: 'id', - id: 'contact-123', - type: 'home', - }, - { - __type: 'contact', - __primaryKey: 'id', - id: 'contact-456', - type: 'office', - }, - ], - } - - expect(removeInternalProperties(user)).toEqual({ - id: 'abc-123', - firstName: 'John', - address: { - id: 'addr-123', - street: 'Broadway', - }, - contacts: [ - { id: 'contact-123', type: 'home' }, - { id: 'contact-456', type: 'office' }, - ], - }) -}) - -it('preserves custom properties starting with the double underscore', () => { - const user: InternalEntity = { - __type: 'user', - __primaryKey: 'id', - __customProperty: true, - id: 'abc-123', - } - - expect(removeInternalProperties(user)).toEqual({ - __customProperty: true, - id: 'abc-123', - }) -}) - -it('removes internal properties from nested relational nodes', () => { - const user: InternalEntity = { - __primaryKey: 'id', - __type: 'user', - id: 'abc-123', - address: { - billing: { - country: { - __primaryKey: 'code', - __type: 'country', - code: 'us', - }, - }, - }, - } - - expect(removeInternalProperties(user)).toEqual({ - id: 'abc-123', - address: { - billing: { - country: { - code: 'us', - }, - }, - }, - }) -})