diff --git a/packages/schema-record/src/managed-object.ts b/packages/schema-record/src/managed-object.ts new file mode 100644 index 00000000000..a6ad58dc49a --- /dev/null +++ b/packages/schema-record/src/managed-object.ts @@ -0,0 +1,141 @@ +import type Store from '@ember-data/store'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; +import type { Signal } from '@ember-data/tracking/-private'; +import { addToTransaction, createSignal, subscribe } from '@ember-data/tracking/-private'; +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 type { SchemaRecord } from './record'; +import type { SchemaService } from './schema'; + +export const SOURCE = Symbol('#source'); +export const MUTATE = Symbol('#update'); +export const OBJECT_SIGNAL = Symbol('#signal'); +export const NOTIFY = Symbol('#notify'); + +export function notifyObject(obj: ManagedObject) { + addToTransaction(obj[OBJECT_SIGNAL]); +} + +type KeyType = string | symbol | number; + +export interface ManagedObject { + [MUTATE]?( + target: unknown[], + receiver: typeof Proxy, + prop: string, + args: unknown[], + _SIGNAL: Signal + ): unknown; +} + +export class ManagedObject { + [SOURCE]: object; + declare address: StableRecordIdentifier; + declare key: string; + declare owner: SchemaRecord; + declare [OBJECT_SIGNAL]: Signal; + + constructor( + store: Store, + schema: SchemaService, + cache: Cache, + field: FieldSchema, + data: object, + address: StableRecordIdentifier, + key: string, + owner: SchemaRecord + ) { + // 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.address = address; + this.key = key; + this.owner = owner; + const transaction = false; + + const proxy = new Proxy(this[SOURCE], { + get>(target: object, prop: keyof R, receiver: R) { + if (prop === OBJECT_SIGNAL) { + return _SIGNAL; + } + if (prop === 'address') { + return self.address; + } + if (prop === 'key') { + return self.key; + } + if (prop === 'owner') { + return self.owner; + } + + if (_SIGNAL.shouldReset) { + _SIGNAL.t = false; + _SIGNAL.shouldReset = false; + let newData = cache.getAttr(self.address, self.key); + if (newData && newData !== self[SOURCE]) { + if (field.type !== null) { + const transform = schema.transforms.get(field.type); + if (!transform) { + throw new Error(`No '${field.type}' transform defined for use by ${address.type}.${String(prop)}`); + } + newData = transform.hydrate(newData as ObjectValue, field.options ?? null, self.owner) as ObjectValue; + } + self[SOURCE] = { ...(newData as ObjectValue) }; // Add type assertion for newData + } + } + + if (prop in self[SOURCE]) { + if (!transaction) { + subscribe(_SIGNAL); + } + + return (self[SOURCE] as R)[prop]; + } + return Reflect.get(target, prop, receiver) as R; + }, + + 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') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + self.key = value; + return true; + } + if (prop === 'owner') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + self.owner = value; + return true; + } + const reflect = Reflect.set(target, prop, value, receiver); + + if (reflect) { + if (field.type === null) { + cache.setAttr(self.address, self.key, self[SOURCE] as Value); + _SIGNAL.shouldReset = true; + return true; + } + + const transform = schema.transforms.get(field.type); + if (!transform) { + throw new Error(`No '${field.type}' transform defined for use by ${address.type}.${String(prop)}`); + } + const val = transform.serialize(self[SOURCE], field.options ?? null, self.owner); + cache.setAttr(self.address, self.key, val); + _SIGNAL.shouldReset = true; + } + return reflect; + }, + }) as ManagedObject; + + return proxy; + } +} diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index 3c714c700c9..f9bd008bfe4 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -18,12 +18,13 @@ import { import type { StableRecordIdentifier } from '@warp-drive/core-types'; import type { Cache } from '@warp-drive/core-types/cache'; import type { ResourceRelationship as SingleResourceRelationship } from '@warp-drive/core-types/cache/relationship'; -import type { ArrayValue, Value } from '@warp-drive/core-types/json/raw'; +import type { ArrayValue, ObjectValue, Value } from '@warp-drive/core-types/json/raw'; import { STRUCTURED } from '@warp-drive/core-types/request'; import type { Link, Links } from '@warp-drive/core-types/spec/raw'; import { RecordStore } from '@warp-drive/core-types/symbols'; import { ARRAY_SIGNAL, ManagedArray } from './managed-array'; +import { ManagedObject, OBJECT_SIGNAL } from './managed-object'; import type { SchemaService } from './schema'; export const Destroy = Symbol('Destroy'); @@ -37,6 +38,7 @@ const IgnoredGlobalFields = new Set(['then', STRUCTURED]); const RecordSymbols = new Set([Destroy, RecordStore, Identifier, Editable, Parent, Checkout, Legacy, Signals]); const ManagedArrayMap = new Map>(); +const ManagedObjectMap = new Map>(); function computeLocal(record: typeof Proxy, field: FieldSchema, prop: string): unknown { let signal = peekSignal(record, prop); @@ -56,6 +58,13 @@ function peekManagedArray(record: SchemaRecord, field: FieldSchema): ManagedArra } } +function peekManagedObject(record: SchemaRecord, field: FieldSchema): ManagedObject | undefined { + const managedObjectMapForRecord = ManagedObjectMap.get(record); + if (managedObjectMapForRecord) { + return managedObjectMapForRecord.get(field); + } +} + function computeField( schema: SchemaService, cache: Cache, @@ -112,6 +121,46 @@ function computeArray( return managedArray; } +function computeObject( + store: Store, + schema: SchemaService, + cache: Cache, + record: SchemaRecord, + identifier: StableRecordIdentifier, + field: FieldSchema, + prop: string +) { + const managedObjectMapForRecord = ManagedObjectMap.get(record); + let managedObject; + if (managedObjectMapForRecord) { + managedObject = managedObjectMapForRecord.get(field); + } + if (managedObject) { + return managedObject; + } else { + let rawValue = cache.getAttr(identifier, prop) as object; + if (!rawValue) { + return null; + } + if (field.kind === 'object') { + if (field.type !== null) { + const transform = schema.transforms.get(field.type); + if (!transform) { + throw new Error(`No '${field.type}' transform defined for use by ${identifier.type}.${String(prop)}`); + } + rawValue = transform.hydrate(rawValue as ObjectValue, field.options ?? null, record) as object; + } + } + managedObject = new ManagedObject(store, schema, cache, field, rawValue, identifier, prop, record); + if (!managedObjectMapForRecord) { + ManagedObjectMap.set(record, new Map([[field, managedObject]])); + } else { + managedObjectMapForRecord.set(field, managedObject); + } + } + return managedObject; +} + function computeAttribute(cache: Cache, identifier: StableRecordIdentifier, prop: string): unknown { return cache.getAttr(identifier, prop); } @@ -323,6 +372,8 @@ export class SchemaRecord { return computeResource(store, cache, target, identifier, field, prop as string); case 'derived': return computeDerivation(schema, receiver as unknown as SchemaRecord, identifier, field, prop as string); + case 'schema-array': + throw new Error(`Not Implemented`); case 'array': assert( `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, @@ -330,6 +381,18 @@ export class SchemaRecord { ); entangleSignal(signals, receiver, field.name); return computeArray(store, schema, cache, target, identifier, field, prop as string); + case 'schema-object': + // validate any access off of schema, no transform to run + // use raw cache value as the object to manage + throw new Error(`Not Implemented`); + 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); + // run transform, then use that value as the object to manage + return computeObject(store, schema, cache, target, identifier, field, prop as string); default: throw new Error(`Field '${String(prop)}' on '${identifier.type}' has the unknown kind '${field.kind}'`); } @@ -402,6 +465,38 @@ export class SchemaRecord { } return true; } + case 'object': { + if (field.type === null) { + let newValue = value; + if (value !== null) { + newValue = { ...(value as ObjectValue) }; + } else { + ManagedObjectMap.delete(target); + } + + cache.setAttr(identifier, prop as string, newValue as Value); + + const peeked = peekManagedObject(self, field); + if (peeked) { + const objSignal = peeked[OBJECT_SIGNAL]; + objSignal.shouldReset = true; + } + return true; + } + const transform = schema.transforms.get(field.type); + if (!transform) { + throw new Error(`No '${field.type}' transform defined for use by ${identifier.type}.${String(prop)}`); + } + const rawValue = transform.serialize({ ...(value as ObjectValue) }, field.options ?? null, target); + + cache.setAttr(identifier, prop as string, rawValue); + const peeked = peekManagedObject(self, field); + if (peeked) { + const objSignal = peeked[OBJECT_SIGNAL]; + objSignal.shouldReset = true; + } + return true; + } case 'derived': { throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because it is derived`); } diff --git a/packages/schema-record/src/schema.ts b/packages/schema-record/src/schema.ts index 2623fddd36a..5a3ef0f9fdc 100644 --- a/packages/schema-record/src/schema.ts +++ b/packages/schema-record/src/schema.ts @@ -135,7 +135,12 @@ export class SchemaService { kind: field.kind === 'resource' ? 'belongsTo' : 'hasMany', }) as unknown as RelationshipSchema; fieldSpec.relationships[field.name] = relSchema; - } else if (field.kind !== 'derived' && field.kind !== '@local' && field.kind !== 'array') { + } else if ( + field.kind !== 'derived' && + field.kind !== '@local' && + field.kind !== 'array' && + field.kind !== 'object' + ) { throw new Error(`Unknown field kind ${field.kind}`); } }); diff --git a/packages/store/src/-types/q/schema-service.ts b/packages/store/src/-types/q/schema-service.ts index 6a7b47e61a2..513374c5ea2 100644 --- a/packages/store/src/-types/q/schema-service.ts +++ b/packages/store/src/-types/q/schema-service.ts @@ -17,7 +17,9 @@ export interface FieldSchema { | 'collection' | 'derived' | 'object' + | 'schema-object' | 'array' + | 'schema-array' | '@id' | '@local'; options?: Record; diff --git a/tests/warp-drive__schema-record/tests/reads/object-test.ts b/tests/warp-drive__schema-record/tests/reads/object-test.ts new file mode 100644 index 00000000000..bdcc51427de --- /dev/null +++ b/tests/warp-drive__schema-record/tests/reads/object-test.ts @@ -0,0 +1,187 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import type Store from '@ember-data/store'; +import { recordIdentifierFor } from '@ember-data/store'; +import type { JsonApiResource } from '@ember-data/store/-types/q/record-data-json-api'; +import type { ResourceType } from '@warp-drive/core-types/symbols'; +import type { Transform } from '@warp-drive/schema-record/schema'; +import { registerDerivations, SchemaService, withFields } from '@warp-drive/schema-record/schema'; + +type address = { + street: string; + city: string; + state: string; + zip: string | number; +}; + +interface CreateUserType { + id: string | null; + $type: 'user'; + name: string | null; + address: address | null; + [ResourceType]: 'user'; +} + +module('Reads | object fields', function (hooks) { + setupTest(hooks); + + test('we can use simple object fields with no `type`', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + registerDerivations(schema); + + schema.defineSchema('user', { + fields: withFields([ + { + name: 'name', + type: null, + kind: 'field', + }, + { + name: 'address', + type: null, + kind: 'object', + }, + ]), + }); + + const sourceAddress: address = { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const record = store.createRecord('user', { name: 'Rey Skybarker', address: sourceAddress }); + + 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( + 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); + + // 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 array field' + ); + }); + + test('we can use simple object fields with a `type`', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + registerDerivations(schema); + schema.defineSchema('user', { + fields: withFields([ + { + name: 'name', + type: null, + kind: 'field', + }, + { + name: 'address', + type: 'zip-string-from-int', + kind: 'object', + }, + ]), + }); + const ZipStringFromIntTransform: Transform = { + serialize(value: address, options, _record): address { + if (typeof value.zip === 'string') { + return { + street: value.street, + city: value.city, + state: value.state, + zip: parseInt(value.zip), + }; + } + return value; + }, + hydrate(value: address, _options, _record): address { + return { + street: value.street, + city: value.city, + state: value.state, + zip: value.zip?.toString(), + }; + }, + defaultValue(_options, _identifier) { + assert.ok(false, 'unexpected defaultValue'); + throw new Error('unexpected defaultValue'); + }, + }; + schema.registerTransform('zip-string-from-int', ZipStringFromIntTransform); + const sourceAddress = { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: 12345, + }; + const record = store.createRecord('user', { name: 'Rey Skybarker', address: sourceAddress }); + 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( + record.address, + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'We have the correct object members' + ); + assert.strictEqual(record.address, record.address, 'We have a stable object reference'); + assert.notStrictEqual(record.address, sourceAddress); + // 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 transform we will still divorce the array 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( + sourceAddress, + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: 12345, + }, + 'we did not mutate the source array' + ); + }); +}); diff --git a/tests/warp-drive__schema-record/tests/writes/object-test.ts b/tests/warp-drive__schema-record/tests/writes/object-test.ts new file mode 100644 index 00000000000..22d33b32722 --- /dev/null +++ b/tests/warp-drive__schema-record/tests/writes/object-test.ts @@ -0,0 +1,542 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import type Store from '@ember-data/store'; +import { recordIdentifierFor } from '@ember-data/store'; +import type { JsonApiResource } from '@ember-data/store/-types/q/record-data-json-api'; +import type { ResourceType } from '@warp-drive/core-types/symbols'; +import type { Transform } from '@warp-drive/schema-record/schema'; +import { registerDerivations, SchemaService, withFields } from '@warp-drive/schema-record/schema'; + +type address = { + street: string; + city: string; + state: string; + zip: string | number; +}; +interface User { + id: string; + $type: 'user'; + name: string; + address: address | null; + [ResourceType]: 'user'; +} +interface CreateUserType { + id: string | null; + $type: 'user'; + name: string | null; + address: address | null; + + [ResourceType]: 'user'; +} + +module('Writes | object fields', function (hooks) { + setupTest(hooks); + + test('we can update to a new object', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + registerDerivations(schema); + + schema.defineSchema('user', { + fields: withFields([ + { + name: 'name', + type: null, + kind: 'field', + }, + { + name: 'address', + type: null, + kind: 'object', + }, + ]), + }); + + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Pupatine', + address: { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + }, + }, + }); + + 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( + 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( + record.address, + { street: '456 Elm Street', city: 'Sometown', state: 'NJ', zip: '23456' }, + 'we have the correct Object members' + ); + assert.strictEqual(address, record.address, 'Object reference does not change'); + + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + + assert.deepEqual( + cachedResourceData?.attributes?.address, + { street: '456 Elm Street', city: 'Sometown', state: 'NJ', zip: '23456' }, + 'the cache values are correctly updated' + ); + }); + + test('we can update to null', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + registerDerivations(schema); + schema.defineSchema('user', { + fields: withFields([ + { + name: 'name', + type: null, + kind: 'field', + }, + { + name: 'address', + type: null, + kind: 'object', + }, + ]), + }); + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Pupatine', + address: { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + }, + }, + }); + 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( + record.address, + { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'We have the correct address object' + ); + record.address = null; + assert.strictEqual(record.address, null, 'The object is correctly set to null'); + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + assert.deepEqual(cachedResourceData?.attributes?.address, null, 'the cache values are correctly updated'); + record.address = { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + assert.deepEqual( + record.address, + { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'We have the correct address object' + ); + }); + + test('we can update a single value in the object', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + registerDerivations(schema); + schema.defineSchema('user', { + fields: withFields([ + { + name: 'name', + type: null, + kind: 'field', + }, + { + name: 'address', + type: null, + kind: 'object', + }, + ]), + }); + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Pupatine', + address: { street: '123 Main Street', city: 'Anytown', state: 'NY', zip: '12345' }, + }, + }, + }); + assert.deepEqual( + record.address, + { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'We have the correct address object' + ); + const address = record.address; + record.address!.state = 'NJ'; + assert.deepEqual( + record.address, + { + street: '123 Main Street', + city: 'Anytown', + state: 'NJ', + zip: '12345', + }, + 'We have the correct address object' + ); + assert.strictEqual(address, record.address, 'Object reference does not change'); + // 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 Street', city: 'Anytown', state: 'NJ', zip: '12345' }, + 'the cache values are correctly updated' + ); + }); + + test('we can assign an object value to another record', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + registerDerivations(schema); + schema.defineSchema('user', { + fields: withFields([ + { + name: 'name', + type: null, + kind: 'field', + }, + { + name: 'address', + type: null, + kind: 'object', + }, + ]), + }); + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Pupatine', + address: { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + }, + }, + }); + const record2 = store.push({ + data: { + type: 'user', + id: '2', + attributes: { name: 'Luke Skybarker' }, + }, + }); + 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.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( + record.address, + { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'We have the correct address object' + ); + assert.strictEqual(record.address, record.address, 'We have a stable object reference'); + const address = record.address; + record2.address = record.address; + assert.deepEqual( + record2.address, + { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'We have the correct address object' + ); + + assert.strictEqual(address, record.address, 'Object reference does not change'); + assert.notStrictEqual(address, record2.address, 'We have a new object reference'); + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record2); + const cachedResourceData = store.cache.peek(identifier); + assert.deepEqual( + cachedResourceData?.attributes?.address, + { + street: '123 Main Street', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'the cache values are correctly updated' + ); + }); + + test('we can edit simple object fields with a `type`', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + registerDerivations(schema); + schema.defineSchema('user', { + fields: withFields([ + { + name: 'name', + type: null, + kind: 'field', + }, + { + name: 'address', + type: 'zip-string-from-int', + kind: 'object', + }, + ]), + }); + + const ZipStringFromIntTransform: Transform = { + serialize(value: address, options, _record): address { + if (typeof value.zip === 'string') { + return { + street: value.street, + city: value.city, + state: value.state, + zip: parseInt(value.zip), + }; + } + return value; + }, + hydrate(value: address, _options, _record): address { + return { + street: value.street, + city: value.city, + state: value.state, + zip: value.zip?.toString(), + }; + }, + defaultValue(_options, _identifier) { + assert.ok(false, 'unexpected defaultValue'); + throw new Error('unexpected defaultValue'); + }, + }; + schema.registerTransform('zip-string-from-int', ZipStringFromIntTransform); + + const sourceAddress = { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const record = store.createRecord('user', { name: 'Rey Skybarker', address: sourceAddress }); + 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( + record.address, + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'We have the correct object members' + ); + assert.strictEqual(record.address, record.address, 'We have a stable object reference'); + assert.notStrictEqual(record.address, sourceAddress); + const address = record.address; + record.address = { + street: '456 Elm St', + city: 'Sometown', + state: 'NJ', + zip: '23456', + }; + assert.deepEqual( + record.address, + { + street: '456 Elm St', + city: 'Sometown', + state: 'NJ', + zip: '23456', + }, + 'We have the correct object members' + ); + assert.strictEqual(address, record.address, 'object reference does not change'); + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + assert.deepEqual( + cachedResourceData?.attributes?.address, + { + street: '456 Elm St', + city: 'Sometown', + state: 'NJ', + zip: 23456, + }, + 'the cache values are correct for the object field' + ); + assert.deepEqual( + sourceAddress, + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'we did not mutate the source object' + ); + }); + + test('we can edit single values in object fields with a `type`', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + registerDerivations(schema); + schema.defineSchema('user', { + fields: withFields([ + { + name: 'name', + type: null, + kind: 'field', + }, + { + name: 'address', + type: 'zip-string-from-int', + kind: 'object', + }, + ]), + }); + + const ZipStringFromIntTransform: Transform = { + serialize(value: address, options, _record): address { + if (typeof value.zip === 'string') { + return { + street: value.street, + city: value.city, + state: value.state, + zip: parseInt(value.zip), + }; + } + return value; + }, + hydrate(value: address, _options, _record): address { + return { + street: value.street, + city: value.city, + state: value.state, + zip: value.zip?.toString(), + }; + }, + defaultValue(_options, _identifier) { + assert.ok(false, 'unexpected defaultValue'); + throw new Error('unexpected defaultValue'); + }, + }; + schema.registerTransform('zip-string-from-int', ZipStringFromIntTransform); + + const sourceAddress = { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }; + const record = store.createRecord('user', { name: 'Rey Skybarker', address: sourceAddress }); + 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( + record.address, + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'We have the correct object members' + ); + assert.strictEqual(record.address, record.address, 'We have a stable object reference'); + assert.notStrictEqual(record.address, sourceAddress); + const address = record.address; + record.address!.zip = '23456'; + + assert.deepEqual( + record.address, + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '23456', + }, + 'We have the correct object members' + ); + assert.strictEqual(address, record.address, 'object reference does not change'); + // 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: 23456, + }, + 'the cache values are correct for the object field' + ); + assert.deepEqual( + sourceAddress, + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + 'we did not mutate the source object' + ); + }); +});