From b17ceb12f8e78037cd3c956a21e19cc7ec713435 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Sun, 1 Sep 2024 11:57:02 -0700 Subject: [PATCH] fix schema-record ManagedObject writes --- .../schema-record/src/-private/compute.ts | 30 ++-- .../src/-private/managed-object.ts | 156 +++++------------- packages/schema-record/src/record.ts | 3 +- .../tests/writes/object-test.ts | 3 +- 4 files changed, 62 insertions(+), 130 deletions(-) diff --git a/packages/schema-record/src/-private/compute.ts b/packages/schema-record/src/-private/compute.ts index ca1d9f37b08..73c72d1fca6 100644 --- a/packages/schema-record/src/-private/compute.ts +++ b/packages/schema-record/src/-private/compute.ts @@ -121,13 +121,13 @@ export function computeArray( } export function computeObject( - store: Store, schema: SchemaService, cache: Cache, record: SchemaRecord, identifier: StableRecordIdentifier, - field: ObjectField | SchemaObjectField, - path: string[] + field: ObjectField, + path: string[], + editable: boolean ) { const managedObjectMapForRecord = ManagedObjectMap.get(record); let managedObject; @@ -141,18 +141,16 @@ export function computeObject( if (!rawValue) { return null; } - if (field.kind === 'object') { - if (field.type) { - const transform = schema.transformation(field); - rawValue = transform.hydrate(rawValue as ObjectValue, field.options ?? null, record) as object; - } - // for schema-object, this should likely be an embedded SchemaRecord now - managedObject = new ManagedObject(store, schema, cache, field, rawValue, identifier, path, record, false); - if (!managedObjectMapForRecord) { - ManagedObjectMap.set(record, new Map([[field, managedObject]])); - } else { - managedObjectMapForRecord.set(field, managedObject); - } + if (field.type) { + const transform = schema.transformation(field); + rawValue = transform.hydrate(rawValue as ObjectValue, field.options ?? null, record) as object; + } + managedObject = new ManagedObject(schema, cache, field, rawValue, identifier, path, record, editable); + + if (!managedObjectMapForRecord) { + ManagedObjectMap.set(record, new Map([[field, managedObject]])); + } else { + managedObjectMapForRecord.set(field, managedObject); } } return managedObject; @@ -163,7 +161,7 @@ export function computeSchemaObject( cache: Cache, record: SchemaRecord, identifier: StableRecordIdentifier, - field: ObjectField | SchemaObjectField, + field: SchemaObjectField, path: string[], legacy: boolean, editable: boolean diff --git a/packages/schema-record/src/-private/managed-object.ts b/packages/schema-record/src/-private/managed-object.ts index 01645831ea0..7e56b78ab84 100644 --- a/packages/schema-record/src/-private/managed-object.ts +++ b/packages/schema-record/src/-private/managed-object.ts @@ -1,22 +1,26 @@ -import type Store from '@ember-data/store'; import type { Signal } from '@ember-data/tracking/-private'; import { addToTransaction, createSignal, subscribe } from '@ember-data/tracking/-private'; +import { assert } from '@warp-drive/build-config/macros'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; import type { Cache } from '@warp-drive/core-types/cache'; import type { ObjectValue, Value } from '@warp-drive/core-types/json/raw'; -import { STRUCTURED } from '@warp-drive/core-types/request'; +// import { STRUCTURED } from '@warp-drive/core-types/request'; import type { ObjectField, SchemaObjectField } from '@warp-drive/core-types/schema/fields'; import type { SchemaRecord } from '../record'; import type { SchemaService } from '../schema'; -import { MUTATE, OBJECT_SIGNAL, SOURCE } from '../symbols'; +import { Editable, EmbeddedPath, MUTATE, OBJECT_SIGNAL, Parent, SOURCE } from '../symbols'; export function notifyObject(obj: ManagedObject) { addToTransaction(obj[OBJECT_SIGNAL]); } +type ObjectSymbol = typeof OBJECT_SIGNAL | typeof Parent | typeof SOURCE | typeof Editable | typeof EmbeddedPath; +const ObjectSymbols = new Set([OBJECT_SIGNAL, Parent, SOURCE, Editable, EmbeddedPath]); + type KeyType = string | symbol | number; -const ignoredGlobalFields = new Set(['setInterval', 'nodeType', 'nodeName', 'length', 'document', STRUCTURED]); +// const ignoredGlobalFields = new Set(['setInterval', 'nodeType', 'nodeName', 'length', 'document', STRUCTURED]); + export interface ManagedObject { [MUTATE]?( target: unknown[], @@ -28,14 +32,13 @@ export interface ManagedObject { } export class ManagedObject { - [SOURCE]: object; - declare identifier: StableRecordIdentifier; - declare path: string[]; - declare owner: SchemaRecord; + declare [SOURCE]: object; + declare [Parent]: StableRecordIdentifier; + declare [EmbeddedPath]: string[]; declare [OBJECT_SIGNAL]: Signal; + declare [Editable]: boolean; constructor( - store: Store, schema: SchemaService, cache: Cache, field: ObjectField | SchemaObjectField, @@ -43,86 +46,41 @@ export class ManagedObject { identifier: StableRecordIdentifier, path: string[], owner: SchemaRecord, - isSchemaObject: boolean + editable: boolean ) { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; this[SOURCE] = { ...data }; this[OBJECT_SIGNAL] = createSignal(this, 'length'); - const _SIGNAL = this[OBJECT_SIGNAL]; - // const boundFns = new Map(); - this.identifier = identifier; - this.path = path; - this.owner = owner; - const transaction = false; + this[Editable] = editable; + this[Parent] = identifier; + this[EmbeddedPath] = path; + const _SIGNAL = this[OBJECT_SIGNAL]; const proxy = new Proxy(this[SOURCE], { ownKeys() { - if (isSchemaObject) { - const fields = schema.fields({ type: field.type! }); - return Array.from(fields.keys()); - } - return Object.keys(self[SOURCE]); }, has(target: unknown, prop: string | number | symbol) { - if (isSchemaObject) { - const fields = schema.fields({ type: field.type! }); - return fields.has(prop as string); - } - return prop in self[SOURCE]; }, getOwnPropertyDescriptor(target, prop) { - if (!isSchemaObject) { - return { - writable: false, - enumerable: true, - configurable: true, - }; - } - const fields = schema.fields({ type: field.type! }); - if (!fields.has(prop as string)) { - throw new Error(`No field named ${String(prop)} on ${field.type}`); - } - const schemaForField = fields.get(prop as string)!; - switch (schemaForField.kind) { - case 'derived': - return { - writable: false, - enumerable: true, - configurable: true, - }; - case '@local': - case 'field': - case 'attribute': - case 'resource': - case 'belongsTo': - case 'hasMany': - case 'collection': - case 'schema-array': - case 'array': - case 'schema-object': - case 'object': - return { - writable: false, // IS_EDITABLE, - enumerable: true, - configurable: true, - }; - } + return { + writable: editable, + enumerable: true, + configurable: true, + }; }, get>(target: object, prop: keyof R, receiver: R) { - if (prop === OBJECT_SIGNAL) { - return _SIGNAL; + if (ObjectSymbols.has(prop as ObjectSymbol)) { + return self[prop as keyof typeof target]; } - if (prop === 'identifier') { - return self.identifier; - } - if (prop === 'owner') { - return self.owner; + + if (prop === Symbol.toPrimitive) { + return null; } if (prop === Symbol.toStringTag) { return `ManagedObject<${identifier.type}:${identifier.id} (${identifier.lid})>`; @@ -130,43 +88,32 @@ export class ManagedObject { if (prop === 'constructor') { return Object; } - if (prop === 'toString') { return function () { return `ManagedObject<${identifier.type}:${identifier.id} (${identifier.lid})>`; }; } - if (prop === 'toHTML') { return function () { return '
ManagedObject
'; }; } + if (_SIGNAL.shouldReset) { _SIGNAL.t = false; _SIGNAL.shouldReset = false; - let newData = cache.getAttr(self.identifier, self.path); + let newData = cache.getAttr(identifier, path); if (newData && newData !== self[SOURCE]) { - if (!isSchemaObject && field.type) { + if (field.type) { const transform = schema.transformation(field); - newData = transform.hydrate(newData as ObjectValue, field.options ?? null, self.owner) as ObjectValue; + newData = transform.hydrate(newData as ObjectValue, field.options ?? null, owner) as ObjectValue; } self[SOURCE] = { ...(newData as ObjectValue) }; // Add type assertion for newData } } - if (isSchemaObject) { - const fields = schema.fields({ type: field.type! }); - // TODO: is there a better way to do this? - if (typeof prop === 'string' && !ignoredGlobalFields.has(prop) && !fields.has(prop)) { - throw new Error(`Field ${prop} does not exist on schema object ${field.type}`); - } - } - if (prop in self[SOURCE]) { - if (!transaction) { - subscribe(_SIGNAL); - } + subscribe(_SIGNAL); return (self[SOURCE] as R)[prop]; } @@ -174,37 +121,22 @@ export class ManagedObject { }, set(target, prop: KeyType, value, receiver) { - if (prop === 'identifier') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - self.identifier = value; - return true; - } - if (prop === 'owner') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - self.owner = value; - return true; - } - if (isSchemaObject) { - const fields = schema.fields({ type: field.type! }); - if (typeof prop === 'string' && !ignoredGlobalFields.has(prop) && !fields.has(prop)) { - throw new Error(`Field ${prop} does not exist on schema object ${field.type}`); - } - } + assert(`Cannot set read-only property '${String(prop)}' on ManagedObject`, editable); const reflect = Reflect.set(target, prop, value, receiver); + if (!reflect) { + return false; + } - if (reflect) { - if (isSchemaObject || !field.type) { - cache.setAttr(self.identifier, self.path, self[SOURCE] as Value); - _SIGNAL.shouldReset = true; - return true; - } - + if (!field.type) { + cache.setAttr(identifier, path, self[SOURCE] as Value); + } else { const transform = schema.transformation(field); - const val = transform.serialize(self[SOURCE], field.options ?? null, self.owner); - cache.setAttr(self.identifier, self.path, val); - _SIGNAL.shouldReset = true; + const val = transform.serialize(self[SOURCE], field.options ?? null, owner); + cache.setAttr(identifier, path, val); } - return reflect; + + _SIGNAL.shouldReset = true; + return true; }, }) as ManagedObject; diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index eb92ea9de14..5491578874b 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -266,7 +266,7 @@ export class SchemaRecord { !target[Legacy] ); entangleSignal(signals, receiver, field.name); - return computeObject(store, schema, cache, target, identifier, field, propArray); + return computeObject(schema, cache, target, identifier, field, propArray, Mode[Editable]); case 'schema-object': assert( `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, @@ -435,6 +435,7 @@ export class SchemaRecord { case 'schema-object': { let newValue = value; if (value !== null) { + assert(`Expected value to be an object`, typeof value === 'object'); newValue = { ...(value as ObjectValue) }; const schemaFields = schema.fields({ type: field.type }); for (const key of Object.keys(newValue as ObjectValue)) { diff --git a/tests/warp-drive__schema-record/tests/writes/object-test.ts b/tests/warp-drive__schema-record/tests/writes/object-test.ts index 0ae86f297da..f4ed371b6c4 100644 --- a/tests/warp-drive__schema-record/tests/writes/object-test.ts +++ b/tests/warp-drive__schema-record/tests/writes/object-test.ts @@ -504,8 +504,9 @@ module('Writes | object fields', function (hooks) { 'We have the correct object members' ); assert.strictEqual(record.address, record.address, 'We have a stable object reference'); - assert.notStrictEqual(record.address, sourceAddress); + assert.notStrictEqual(record.address, sourceAddress, 'we do not keep the source object reference'); const address = record.address; + assert.strictEqual(record.address?.zip, '12345', 'zip is accessible'); record.address!.zip = '23456'; assert.deepEqual(