diff --git a/packages/schema-record/src/-private/compute.ts b/packages/schema-record/src/-private/compute.ts index eafcba98520..ca1d9f37b08 100644 --- a/packages/schema-record/src/-private/compute.ts +++ b/packages/schema-record/src/-private/compute.ts @@ -21,9 +21,9 @@ import type { import type { Link, Links } from '@warp-drive/core-types/spec/json-api-raw'; import { RecordStore } from '@warp-drive/core-types/symbols'; -import type { SchemaRecord } from '../record'; +import { SchemaRecord } from '../record'; import type { SchemaService } from '../schema'; -import { Identifier, Parent } from '../symbols'; +import { Editable, Identifier, Legacy, Parent } from '../symbols'; import { ManagedArray } from './managed-array'; import { ManagedObject } from './managed-object'; @@ -33,7 +33,7 @@ export const ManagedArrayMap = getOrSetGlobal( ); export const ManagedObjectMap = getOrSetGlobal( 'ManagedObjectMap', - new Map>() + new Map>() ); export function computeLocal(record: typeof Proxy, field: LocalField, prop: string): unknown { @@ -54,7 +54,12 @@ export function peekManagedArray(record: SchemaRecord, field: FieldSchema): Mana } } -export function peekManagedObject(record: SchemaRecord, field: FieldSchema): ManagedObject | undefined { +export function peekManagedObject(record: SchemaRecord, field: ObjectField): ManagedObject | undefined; +export function peekManagedObject(record: SchemaRecord, field: SchemaObjectField): SchemaRecord | undefined; +export function peekManagedObject( + record: SchemaRecord, + field: ObjectField | SchemaObjectField +): ManagedObject | SchemaRecord | undefined { const managedObjectMapForRecord = ManagedObjectMap.get(record); if (managedObjectMapForRecord) { return managedObjectMapForRecord.get(field); @@ -85,7 +90,7 @@ export function computeArray( identifier: StableRecordIdentifier, field: ArrayField | SchemaArrayField, path: string[], - isSchemaArray = false + isSchemaArray: boolean ) { // the thing we hand out needs to know its owner and path in a private manner // its "address" is the parent identifier (identifier) + field name (field.name) @@ -122,8 +127,7 @@ export function computeObject( record: SchemaRecord, identifier: StableRecordIdentifier, field: ObjectField | SchemaObjectField, - prop: string, - isSchemaObject = false + path: string[] ) { const managedObjectMapForRecord = ManagedObjectMap.get(record); let managedObject; @@ -133,7 +137,7 @@ export function computeObject( if (managedObject) { return managedObject; } else { - let rawValue = cache.getAttr(identifier, prop) as object; + let rawValue = cache.getAttr(identifier, path) as object; if (!rawValue) { return null; } @@ -142,17 +146,61 @@ export function computeObject( const transform = schema.transformation(field); rawValue = transform.hydrate(rawValue as ObjectValue, field.options ?? null, record) as object; } - } - managedObject = new ManagedObject(store, schema, cache, field, rawValue, identifier, prop, record, isSchemaObject); - if (!managedObjectMapForRecord) { - ManagedObjectMap.set(record, new Map([[field, managedObject]])); - } else { - managedObjectMapForRecord.set(field, managedObject); + // 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); + } } } return managedObject; } +export function computeSchemaObject( + store: Store, + cache: Cache, + record: SchemaRecord, + identifier: StableRecordIdentifier, + field: ObjectField | SchemaObjectField, + path: string[], + legacy: boolean, + editable: boolean +) { + const schemaObjectMapForRecord = ManagedObjectMap.get(record); + let schemaObject; + if (schemaObjectMapForRecord) { + schemaObject = schemaObjectMapForRecord.get(field); + } + if (schemaObject) { + return schemaObject; + } else { + const rawValue = cache.getAttr(identifier, path) as object; + if (!rawValue) { + return null; + } + const embeddedPath = path.slice(); + schemaObject = new SchemaRecord( + store, + identifier, + { + [Editable]: editable, + [Legacy]: legacy, + }, + true, + field.type, + embeddedPath + ); + } + if (!schemaObjectMapForRecord) { + ManagedObjectMap.set(record, new Map([[field, schemaObject]])); + } else { + schemaObjectMapForRecord.set(field, schemaObject); + } + return schemaObject; +} + export function computeAttribute(cache: Cache, identifier: StableRecordIdentifier, prop: string): unknown { return cache.getAttr(identifier, prop); } diff --git a/packages/schema-record/src/-private/managed-array.ts b/packages/schema-record/src/-private/managed-array.ts index a2bdb29a035..01ee6d83253 100644 --- a/packages/schema-record/src/-private/managed-array.ts +++ b/packages/schema-record/src/-private/managed-array.ts @@ -10,7 +10,7 @@ import type { ArrayField, HashField, SchemaArrayField } from '@warp-drive/core-t import { SchemaRecord } from '../record'; import type { SchemaService } from '../schema'; -import { ARRAY_SIGNAL, Editable, Identifier, Legacy, MUTATE, SOURCE } from '../symbols'; +import { ARRAY_SIGNAL, Editable, Identifier, Legacy, MUTATE, Parent, SOURCE } from '../symbols'; export function notifyArray(arr: ManagedArray) { addToTransaction(arr[ARRAY_SIGNAL]); @@ -105,7 +105,7 @@ export interface ManagedArray extends Omit, '[]'> { export class ManagedArray { [SOURCE]: unknown[]; - declare address: StableRecordIdentifier; + declare identifier: StableRecordIdentifier; declare path: string[]; declare owner: SchemaRecord; declare [ARRAY_SIGNAL]: Signal; @@ -116,7 +116,7 @@ export class ManagedArray { cache: Cache, field: ArrayField | SchemaArrayField, data: unknown[], - address: StableRecordIdentifier, + identifier: StableRecordIdentifier, path: string[], owner: SchemaRecord, isSchemaArray: boolean @@ -127,7 +127,7 @@ export class ManagedArray { this[ARRAY_SIGNAL] = createSignal(this, 'length'); const _SIGNAL = this[ARRAY_SIGNAL]; const boundFns = new Map(); - this.address = address; + this.identifier = identifier; this.path = path; this.owner = owner; let transaction = false; @@ -149,8 +149,8 @@ export class ManagedArray { if (prop === ARRAY_SIGNAL) { return _SIGNAL; } - if (prop === 'address') { - return self.address; + if (prop === 'identifier') { + return self.identifier; } if (prop === 'owner') { return self.owner; @@ -160,7 +160,7 @@ export class ManagedArray { if (_SIGNAL.shouldReset && (index !== null || SYNC_PROPS.has(prop) || isArrayGetter(prop))) { _SIGNAL.t = false; _SIGNAL.shouldReset = false; - const newData = cache.getAttr(address, path); + const newData = cache.getAttr(identifier, path); if (newData && newData !== self[SOURCE]) { self[SOURCE].length = 0; self[SOURCE].push(...(newData as ArrayValue)); @@ -216,9 +216,11 @@ export class ManagedArray { // same object reference from cache should result in same SchemaRecord // embedded object. recordPath.push(index as unknown as string); + const recordIdentifier = self.owner[Identifier] || self.owner[Parent]; + record = new SchemaRecord( store, - self.owner[Identifier], + recordIdentifier, { [Editable]: self.owner[Editable], [Legacy]: self.owner[Legacy] }, true, field.type, @@ -281,9 +283,9 @@ export class ManagedArray { return Reflect.get(target, prop, receiver); }, set(target, prop: KeyType, value, receiver) { - if (prop === 'address') { + if (prop === 'identifier') { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - self.address = value; + self.identifier = value; return true; } if (prop === 'owner') { @@ -295,7 +297,7 @@ export class ManagedArray { if (reflect) { if (!field.type) { - cache.setAttr(address, path, self[SOURCE] as Value); + cache.setAttr(identifier, path, self[SOURCE] as Value); _SIGNAL.shouldReset = true; return true; } @@ -304,13 +306,13 @@ export class ManagedArray { if (!isSchemaArray) { const transform = schema.transformation(field); if (!transform) { - throw new Error(`No '${field.type}' transform defined for use by ${address.type}.${String(prop)}`); + throw new Error(`No '${field.type}' transform defined for use by ${identifier.type}.${String(prop)}`); } rawValue = (self[SOURCE] as ArrayValue).map((item) => transform.serialize(item, field.options ?? null, self.owner) ); } - cache.setAttr(address, path, rawValue as Value); + cache.setAttr(identifier, path, rawValue as Value); _SIGNAL.shouldReset = true; } return reflect; diff --git a/packages/schema-record/src/-private/managed-object.ts b/packages/schema-record/src/-private/managed-object.ts index d56af86368d..01645831ea0 100644 --- a/packages/schema-record/src/-private/managed-object.ts +++ b/packages/schema-record/src/-private/managed-object.ts @@ -4,6 +4,7 @@ import { addToTransaction, createSignal, subscribe } from '@ember-data/tracking/ 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 type { ObjectField, SchemaObjectField } from '@warp-drive/core-types/schema/fields'; import type { SchemaRecord } from '../record'; @@ -15,7 +16,7 @@ export function notifyObject(obj: ManagedObject) { } type KeyType = string | symbol | number; -const ignoredGlobalFields = new Set(['constructor', 'setInterval', 'nodeType', 'length']); +const ignoredGlobalFields = new Set(['setInterval', 'nodeType', 'nodeName', 'length', 'document', STRUCTURED]); export interface ManagedObject { [MUTATE]?( target: unknown[], @@ -28,8 +29,8 @@ export interface ManagedObject { export class ManagedObject { [SOURCE]: object; - declare address: StableRecordIdentifier; - declare key: string; + declare identifier: StableRecordIdentifier; + declare path: string[]; declare owner: SchemaRecord; declare [OBJECT_SIGNAL]: Signal; @@ -39,8 +40,8 @@ export class ManagedObject { cache: Cache, field: ObjectField | SchemaObjectField, data: object, - address: StableRecordIdentifier, - key: string, + identifier: StableRecordIdentifier, + path: string[], owner: SchemaRecord, isSchemaObject: boolean ) { @@ -50,8 +51,8 @@ export class ManagedObject { this[OBJECT_SIGNAL] = createSignal(this, 'length'); const _SIGNAL = this[OBJECT_SIGNAL]; // const boundFns = new Map(); - this.address = address; - this.key = key; + this.identifier = identifier; + this.path = path; this.owner = owner; const transaction = false; @@ -117,22 +118,22 @@ export class ManagedObject { if (prop === OBJECT_SIGNAL) { return _SIGNAL; } - if (prop === 'address') { - return self.address; - } - if (prop === 'key') { - return self.key; + if (prop === 'identifier') { + return self.identifier; } if (prop === 'owner') { return self.owner; } if (prop === Symbol.toStringTag) { - return `ManagedObject<${address.type}:${address.id} (${address.lid})>`; + return `ManagedObject<${identifier.type}:${identifier.id} (${identifier.lid})>`; + } + if (prop === 'constructor') { + return Object; } if (prop === 'toString') { return function () { - return `ManagedObject<${address.type}:${address.id} (${address.lid})>`; + return `ManagedObject<${identifier.type}:${identifier.id} (${identifier.lid})>`; }; } @@ -144,7 +145,7 @@ export class ManagedObject { if (_SIGNAL.shouldReset) { _SIGNAL.t = false; _SIGNAL.shouldReset = false; - let newData = cache.getAttr(self.address, self.key); + let newData = cache.getAttr(self.identifier, self.path); if (newData && newData !== self[SOURCE]) { if (!isSchemaObject && field.type) { const transform = schema.transformation(field); @@ -173,14 +174,9 @@ export class ManagedObject { }, set(target, prop: KeyType, value, receiver) { - if (prop === 'address') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - self.address = value; - return true; - } - if (prop === 'key') { + if (prop === 'identifier') { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - self.key = value; + self.identifier = value; return true; } if (prop === 'owner') { @@ -198,14 +194,14 @@ export class ManagedObject { if (reflect) { if (isSchemaObject || !field.type) { - cache.setAttr(self.address, self.key, self[SOURCE] as Value); + cache.setAttr(self.identifier, self.path, self[SOURCE] as Value); _SIGNAL.shouldReset = true; return true; } const transform = schema.transformation(field); const val = transform.serialize(self[SOURCE], field.options ?? null, self.owner); - cache.setAttr(self.address, self.key, val); + cache.setAttr(self.identifier, self.path, val); _SIGNAL.shouldReset = true; } return reflect; diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index 6caf91ba427..9e604e576fa 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -19,6 +19,7 @@ import { computeLocal, computeObject, computeResource, + computeSchemaObject, ManagedArrayMap, ManagedObjectMap, peekManagedArray, @@ -44,7 +45,7 @@ const getLegacySupport = macroCondition(dependencySatisfies('@ember-data/model', : null; export { Editable, Legacy } from './symbols'; -const IgnoredGlobalFields = new Set(['length', 'nodeType', 'then', 'setInterval', STRUCTURED]); +const IgnoredGlobalFields = new Set(['length', 'nodeType', 'then', 'setInterval', 'document', STRUCTURED]); const symbolList = [ Destroy, RecordStore, @@ -168,6 +169,12 @@ export class SchemaRecord { }; } + if (prop === 'toHTML') { + return function () { + return `
SchemaRecord<${identifier.type}:${identifier.id} (${identifier.lid})>
`; + }; + } + if (prop === Symbol.toPrimitive) { return null; } @@ -196,7 +203,12 @@ export class SchemaRecord { if (typeof prop === 'symbol') { return undefined; } - throw new Error(`No field named ${String(prop)} on ${identifier.type}`); + let type = identifier.type; + if (isEmbedded) { + type = embeddedType!; + } + + throw new Error(`No field named ${String(prop)} on ${type}`); } switch (field.kind) { @@ -239,20 +251,31 @@ export class SchemaRecord { !target[Legacy] ); entangleSignal(signals, receiver, field.name); - return computeArray(store, schema, cache, target, identifier, field, propArray); - case 'schema-object': - // validate any access off of schema, no transform to run - // use raw cache value as the object to manage - entangleSignal(signals, receiver, field.name); - return computeObject(store, schema, cache, target, identifier, field, prop as string, true); + return computeArray(store, schema, cache, target, identifier, field, propArray, false); case 'object': + assert( + `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, + !target[Legacy] + ); + entangleSignal(signals, receiver, field.name); + return computeObject(store, schema, cache, target, identifier, field, propArray); + case 'schema-object': assert( `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, !target[Legacy] ); entangleSignal(signals, receiver, field.name); // run transform, then use that value as the object to manage - return computeObject(store, schema, cache, target, identifier, field, prop as string); + return computeSchemaObject( + store, + cache, + target, + identifier, + field, + propArray, + Mode[Legacy], + Mode[Editable] + ); case 'belongsTo': if (!HAS_MODEL_PACKAGE) { assert( @@ -287,7 +310,8 @@ export class SchemaRecord { const field = prop === identityField?.name ? identityField : fields.get(prop as string); if (!field) { - throw new Error(`There is no field named ${String(prop)} on ${identifier.type}`); + const type = isEmbedded ? embeddedType! : identifier.type; + throw new Error(`There is no field named ${String(prop)} on ${type}`); } switch (field.kind) { @@ -414,11 +438,11 @@ export class SchemaRecord { ManagedObjectMap.delete(target); } cache.setAttr(identifier, propArray, newValue as Value); - const peeked = peekManagedObject(self, field); - if (peeked) { - const objSignal = peeked[OBJECT_SIGNAL]; - objSignal.shouldReset = true; - } + // const peeked = peekManagedObject(self, field); + // if (peeked) { + // const objSignal = peeked[OBJECT_SIGNAL]; + // objSignal.shouldReset = true; + // } return true; } case 'derived': { @@ -511,7 +535,7 @@ export class SchemaRecord { addToTransaction(arrSignal); } } - if (field?.kind === 'object' || field?.kind === 'schema-object') { + if (field?.kind === 'object') { const peeked = peekManagedObject(self, field); if (peeked) { const objSignal = peeked[OBJECT_SIGNAL]; diff --git a/tests/warp-drive__schema-record/tests/reactivity/object-test.ts b/tests/warp-drive__schema-record/tests/reactivity/object-test.ts index 76bcfa6d724..c9652c365b8 100644 --- a/tests/warp-drive__schema-record/tests/reactivity/object-test.ts +++ b/tests/warp-drive__schema-record/tests/reactivity/object-test.ts @@ -64,7 +64,7 @@ module('Reactivity | object fields can receive remote updates', function (hooks) assert.strictEqual(record.id, '1', 'id is accessible'); assert.strictEqual(record.$type, 'user', '$type is accessible'); - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main St', diff --git a/tests/warp-drive__schema-record/tests/reactivity/schema-object-test.ts b/tests/warp-drive__schema-record/tests/reactivity/schema-object-test.ts index a3c4a3c6d95..8820e48ef9c 100644 --- a/tests/warp-drive__schema-record/tests/reactivity/schema-object-test.ts +++ b/tests/warp-drive__schema-record/tests/reactivity/schema-object-test.ts @@ -27,7 +27,7 @@ interface User { rank: number; } -module('Reactivity | object fields can receive remote updates', function (hooks) { +module('Reactivity | schema object fields can receive remote updates', function (hooks) { setupRenderingTest(hooks); test('we can use simple fields with no `type`', async function (assert) { @@ -87,7 +87,7 @@ module('Reactivity | object fields can receive remote updates', function (hooks) assert.strictEqual(record.id, '1', 'id is accessible'); assert.strictEqual(record.$type, 'user', '$type is accessible'); - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main St', @@ -124,7 +124,7 @@ module('Reactivity | object fields can receive remote updates', function (hooks) assert.strictEqual(record.id, '1', 'id is accessible'); assert.strictEqual(record.$type, 'user', '$type is accessible'); - assert.deepEqual( + assert.propEqual( record.address, { street: '456 Elm St', diff --git a/tests/warp-drive__schema-record/tests/reads/schema-array-test.ts b/tests/warp-drive__schema-record/tests/reads/schema-array-test.ts index 6352a46e5b8..6f2c8829410 100644 --- a/tests/warp-drive__schema-record/tests/reads/schema-array-test.ts +++ b/tests/warp-drive__schema-record/tests/reads/schema-array-test.ts @@ -15,11 +15,17 @@ interface address { zip: string | number; } +interface business { + name: string; + address?: address; + addresses?: address[]; +} interface CreateUserType { id: string | null; $type: 'user'; name: string | null; addresses: address[] | null; + businesses: business[] | null; [Type]: 'user'; } @@ -92,7 +98,7 @@ module('Reads | schema-array fields', function (hooks) { assert.strictEqual(record.id, null, 'id is accessible'); assert.strictEqual(record.$type, 'user', '$type is accessible'); assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); - assert.true(Array.isArray(record.addresses), 'we can access favoriteNumber array'); + assert.true(Array.isArray(record.addresses), 'we can access address array'); assert.propContains( record.addresses?.slice(), [ @@ -119,7 +125,7 @@ module('Reads | schema-array fields', function (hooks) { const cachedResourceData = store.cache.peek(identifier); assert.notStrictEqual( - cachedResourceData?.attributes?.favoriteNumbers, + cachedResourceData?.attributes?.addresses, sourceArray, 'with no transform we will still divorce the array reference' ); @@ -142,4 +148,154 @@ module('Reads | schema-array fields', function (hooks) { 'the cache values are correct for the array field' ); }); + + test('we can nest schema objects within schema-array fields', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource({ + identity: null, + type: 'business', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'address', + kind: 'schema-object', + type: 'address', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'businesses', + type: 'business', + kind: 'schema-array', + }, + ], + }) + ); + + const sourceArray = [ + { + name: 'Acme', + address: { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + }, + { + name: 'Globex', + address: { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + }, + ]; + const record = store.createRecord('user', { + name: 'Rey Skybarker', + businesses: sourceArray, + }); + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.true(Array.isArray(record.businesses), 'we can access businesses array'); + assert.propEqual( + record.businesses?.slice(), + [ + { + name: 'Acme', + address: { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + }, + { + name: 'Globex', + address: { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + }, + ], + 'We have the correct array members' + ); + assert.strictEqual(record.businesses, record.businesses, 'We have a stable array reference'); + assert.notStrictEqual(record.businesses, sourceArray); + + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + + assert.notStrictEqual( + cachedResourceData?.attributes?.businesses, + sourceArray, + 'with no transform we will still divorce the array reference' + ); + assert.deepEqual( + cachedResourceData?.attributes?.businesses, + [ + { + name: 'Acme', + address: { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + }, + { + name: 'Globex', + address: { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + }, + ], + 'the cache values are correct for the array field' + ); + }); }); diff --git a/tests/warp-drive__schema-record/tests/reads/schema-object-test.ts b/tests/warp-drive__schema-record/tests/reads/schema-object-test.ts index 2da1eb5df4d..ddb69e6bb7c 100644 --- a/tests/warp-drive__schema-record/tests/reads/schema-object-test.ts +++ b/tests/warp-drive__schema-record/tests/reads/schema-object-test.ts @@ -15,11 +15,18 @@ type address = { zip: string | number; }; +type business = { + name: string; + address?: address; + addresses?: address[]; +}; + interface CreateUserType { id: string | null; $type: 'user'; name: string | null; address: address | null; + business: business | null; [Type]: 'user'; } @@ -81,7 +88,7 @@ module('Reads | schema-object fields', function (hooks) { assert.strictEqual(record.id, null, 'id is accessible'); assert.strictEqual(record.$type, 'user', '$type is accessible'); assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }, 'we can access address object' @@ -109,6 +116,246 @@ module('Reads | schema-object fields', function (hooks) { 'the cache values are correct for the array field' ); // @ts-expect-error - assert.throws(() => record.address!.notField as unknown, /Field notField does not exist on schema object address/); + assert.throws(() => record.address!.notField as unknown, /No field named notField on address/); + }); + + test('we can use nested schema-object fields', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource({ + identity: null, + type: 'business', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'address', + type: 'address', + kind: 'schema-object', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'address', + type: 'address', + kind: 'schema-object', + }, + { + name: 'business', + type: 'business', + kind: 'schema-object', + }, + ], + }) + ); + + const sourceAddress: address = { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const sourceBusinessAddress: address = { + street: '456 Elm St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const record = store.createRecord('user', { + name: 'Rey Skybarker', + address: sourceAddress, + business: { name: 'Acme', address: sourceBusinessAddress }, + }); + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.propEqual( + record.address, + { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }, + 'we can access address object' + ); + assert.strictEqual(record.address, record.address, 'We have a stable object reference'); + assert.notStrictEqual(record.address, sourceAddress); + assert.strictEqual(record.business?.name, 'Acme'); + assert.propEqual(record.business?.address, { street: '456 Elm St', city: 'Anytown', state: 'NY', zip: '12345' }); + + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + + assert.notStrictEqual( + cachedResourceData?.attributes?.address, + sourceAddress, + 'with no transform we will still divorce the object reference' + ); + assert.deepEqual( + cachedResourceData?.attributes?.address, + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'the cache values are correct for the object field' + ); + assert.deepEqual( + cachedResourceData?.attributes?.business, + { + name: 'Acme', + address: { + street: '456 Elm St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + }, + 'the cache values are correct for a nested object field' + ); + // @ts-expect-error + assert.throws(() => record.address!.notField as unknown, /No field named notField on address/); + // @ts-expect-error + assert.throws(() => record.business!.address.notField as unknown, /No field named notField on address/); + }); + + test('we can nest schema-array fields inside a schema-object', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource({ + identity: null, + type: 'business', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'addresses', + type: 'address', + kind: 'schema-array', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'address', + type: 'address', + kind: 'schema-object', + }, + { + name: 'business', + type: 'business', + kind: 'schema-object', + }, + ], + }) + ); + const sourceAddress: address = { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const sourceBusinessAddress1: address = { + street: '456 Elm St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const sourceBusinessAddress2: address = { + street: '789 Oak St', + city: 'Sometown', + state: 'NJ', + zip: '23456', + }; + const record = store.createRecord('user', { + name: 'Rey Skybarker', + address: sourceAddress, + business: { name: 'Acme', addresses: [sourceBusinessAddress1, sourceBusinessAddress2] }, + }); + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.propEqual( + record.address, + { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }, + 'we can access address object' + ); + assert.strictEqual(record.address, record.address, 'We have a stable object reference'); + assert.notStrictEqual(record.address, sourceAddress); + assert.strictEqual(record.business?.name, 'Acme'); + assert.propEqual(record.business?.addresses, [ + { street: '456 Elm St', city: 'Anytown', state: 'NY', zip: '12345' }, + { street: '789 Oak St', city: 'Sometown', state: 'NJ', zip: '23456' }, + ]); + assert.strictEqual(record.business?.addresses, record.business?.addresses, 'We have a stable array reference'); + // @ts-expect-error + assert.throws(() => record.business!.addresses![0].notField as unknown, /No field named notField on address/); }); }); diff --git a/tests/warp-drive__schema-record/tests/writes/schema-object-test.ts b/tests/warp-drive__schema-record/tests/writes/schema-object-test.ts index 816322ca0be..f593969b71f 100644 --- a/tests/warp-drive__schema-record/tests/writes/schema-object-test.ts +++ b/tests/warp-drive__schema-record/tests/writes/schema-object-test.ts @@ -14,11 +14,18 @@ type address = { state: string; zip: string | number; }; + +type business = { + name: string; + address?: address; + addresses?: address[]; +}; interface User { id: string; $type: 'user'; name: string; address: address | null; + business: business | null; [Type]: 'user'; } @@ -88,14 +95,14 @@ module('Writes | schema-object fields', function (hooks) { assert.strictEqual(record.id, '1', 'id is accessible'); assert.strictEqual(record.$type, 'user', '$type is accessible'); assert.strictEqual(record.name, 'Rey Pupatine', 'name is accessible'); - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main Street', city: 'Anytown', state: 'NY', zip: '12345' }, 'We have the correct address object' ); const address = record.address; record.address = { street: '456 Elm Street', city: 'Sometown', state: 'NJ', zip: '23456' }; - assert.deepEqual( + assert.propEqual( record.address, { street: '456 Elm Street', city: 'Sometown', state: 'NJ', zip: '23456' }, 'we have the correct Object members' @@ -173,7 +180,7 @@ module('Writes | schema-object fields', function (hooks) { assert.strictEqual(record.id, '1', 'id is accessible'); assert.strictEqual(record.$type, 'user', '$type is accessible'); assert.strictEqual(record.name, 'Rey Pupatine', 'name is accessible'); - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main Street', @@ -195,7 +202,7 @@ module('Writes | schema-object fields', function (hooks) { state: 'NY', zip: '12345', }; - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main Street', @@ -259,7 +266,7 @@ module('Writes | schema-object fields', function (hooks) { }, }, }); - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main Street', @@ -271,7 +278,7 @@ module('Writes | schema-object fields', function (hooks) { ); const address = record.address; record.address!.state = 'NJ'; - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main Street', @@ -362,7 +369,7 @@ module('Writes | schema-object fields', function (hooks) { assert.strictEqual(record2.id, '2', 'id is accessible'); assert.strictEqual(record2.$type, 'user', '$type is accessible'); assert.strictEqual(record2.name, 'Luke Skybarker', 'name is accessible'); - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main Street', @@ -375,7 +382,7 @@ module('Writes | schema-object fields', function (hooks) { assert.strictEqual(record.address, record.address, 'We have a stable object reference'); const address = record.address; record2.address = record.address; - assert.deepEqual( + assert.propEqual( record2.address, { street: '123 Main Street', @@ -463,7 +470,7 @@ module('Writes | schema-object fields', function (hooks) { assert.strictEqual(record.id, '1', 'id is accessible'); assert.strictEqual(record.$type, 'user', '$type is accessible'); assert.strictEqual(record.name, 'Rey Pupatine', 'name is accessible'); - assert.deepEqual( + assert.propEqual( record.address, { street: '123 Main Street', @@ -477,7 +484,7 @@ module('Writes | schema-object fields', function (hooks) { assert.throws(() => { //@ts-expect-error record.address!.notAField = 'This should throw'; - }, /Field notAField does not exist on schema object address/); + }, /There is no field named notAField on address/); assert.throws(() => { record.address = { street: '456 Elm Street', @@ -489,4 +496,290 @@ module('Writes | schema-object fields', function (hooks) { }; }, /Field notAField does not exist on schema object address/); }); + + test('we can edit nested schema-object fields', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource({ + identity: null, + type: 'business', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'address', + type: 'address', + kind: 'schema-object', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'address', + type: 'address', + kind: 'schema-object', + }, + { + name: 'business', + type: 'business', + kind: 'schema-object', + }, + ], + }) + ); + + const sourceAddress: address = { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const sourceBusinessAddress: address = { + street: '456 Elm St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + address: sourceAddress, + business: { name: 'Acme', address: sourceBusinessAddress }, + }, + }, + }); + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.propEqual( + record.address, + { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }, + 'we can access address object' + ); + assert.strictEqual(record.address, record.address, 'We have a stable object reference'); + assert.notStrictEqual(record.address, sourceAddress); + assert.strictEqual(record.business?.name, 'Acme'); + assert.propEqual(record.business?.address, { street: '456 Elm St', city: 'Anytown', state: 'NY', zip: '12345' }); + + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + + assert.deepEqual( + cachedResourceData?.attributes?.address, + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'the cache values are correct for the object field' + ); + assert.deepEqual( + cachedResourceData?.attributes?.business, + { + name: 'Acme', + address: { + street: '456 Elm St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + }, + 'the cache values are correct for a nested object field' + ); + record.business!.address = { street: '789 Oak St', city: 'Sometown', state: 'NJ', zip: '23456' }; + assert.propEqual( + record.business?.address, + { street: '789 Oak St', city: 'Sometown', state: 'NJ', zip: '23456' }, + 'we can access nested address object' + ); + assert.strictEqual(record.business?.address, record.business?.address, 'We have a stable object reference'); + // Test that the data entered teh cache properly + const cachedResourceData2 = store.cache.peek(identifier); + assert.deepEqual( + cachedResourceData2?.attributes?.business, + { + name: 'Acme', + address: { + street: '789 Oak St', + city: 'Sometown', + state: 'NJ', + zip: '23456', + }, + }, + 'the cache values are correct for a nested object field' + ); + }); + + test('we can edit nested nest schema-array fields inside a schema-object', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource({ + identity: null, + type: 'business', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'addresses', + type: 'address', + kind: 'schema-array', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'address', + type: 'address', + kind: 'schema-object', + }, + { + name: 'business', + type: 'business', + kind: 'schema-object', + }, + ], + }) + ); + const sourceAddress: address = { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const sourceBusinessAddress1: address = { + street: '456 Elm St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const sourceBusinessAddress2: address = { + street: '789 Oak St', + city: 'Sometown', + state: 'NJ', + zip: '23456', + }; + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + address: sourceAddress, + business: { name: 'Acme', addresses: [sourceBusinessAddress1, sourceBusinessAddress2] }, + }, + }, + }); + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.propEqual( + record.address, + { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }, + 'we can access address object' + ); + assert.strictEqual(record.address, record.address, 'We have a stable object reference'); + assert.strictEqual(record.business?.name, 'Acme'); + assert.propEqual(record.business?.addresses, [ + { street: '456 Elm St', city: 'Anytown', state: 'NY', zip: '12345' }, + { street: '789 Oak St', city: 'Sometown', state: 'NJ', zip: '23456' }, + ]); + assert.strictEqual(record.business?.addresses, record.business?.addresses, 'We have a stable array reference'); + record.business!.addresses![0] = { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }; + assert.propEqual( + record.business?.addresses, + [ + { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }, + { street: '789 Oak St', city: 'Sometown', state: 'NJ', zip: '23456' }, + ], + 'we can access nested address object' + ); + assert.strictEqual(record.business?.addresses, record.business?.addresses, 'We have a stable array reference'); + // Test that the data entered teh cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + assert.deepEqual( + cachedResourceData?.attributes?.business, + { + name: 'Acme', + addresses: [ + { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }, + { street: '789 Oak St', city: 'Sometown', state: 'NJ', zip: '23456' }, + ], + }, + 'the cache values are correct for a nested object field' + ); + }); });