Skip to content

Commit 0cd9cd2

Browse files
authored
feat: implement schema-object for schema-record (#9467)
1 parent f2a8fbf commit 0cd9cd2

File tree

8 files changed

+944
-10
lines changed

8 files changed

+944
-10
lines changed

packages/schema-record/src/-private/compute.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
LocalField,
1717
ObjectField,
1818
SchemaArrayField,
19+
SchemaObjectField,
1920
} from '@warp-drive/core-types/schema/fields';
2021
import type { Link, Links } from '@warp-drive/core-types/spec/json-api-raw';
2122
import { RecordStore } from '@warp-drive/core-types/symbols';
@@ -120,8 +121,9 @@ export function computeObject(
120121
cache: Cache,
121122
record: SchemaRecord,
122123
identifier: StableRecordIdentifier,
123-
field: ObjectField,
124-
prop: string
124+
field: ObjectField | SchemaObjectField,
125+
prop: string,
126+
isSchemaObject = false
125127
) {
126128
const managedObjectMapForRecord = ManagedObjectMap.get(record);
127129
let managedObject;
@@ -141,7 +143,7 @@ export function computeObject(
141143
rawValue = transform.hydrate(rawValue as ObjectValue, field.options ?? null, record) as object;
142144
}
143145
}
144-
managedObject = new ManagedObject(store, schema, cache, field, rawValue, identifier, prop, record);
146+
managedObject = new ManagedObject(store, schema, cache, field, rawValue, identifier, prop, record, isSchemaObject);
145147
if (!managedObjectMapForRecord) {
146148
ManagedObjectMap.set(record, new Map([[field, managedObject]]));
147149
} else {

packages/schema-record/src/-private/managed-object.ts

+35-6
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { addToTransaction, createSignal, subscribe } from '@ember-data/tracking/
44
import type { StableRecordIdentifier } from '@warp-drive/core-types';
55
import type { Cache } from '@warp-drive/core-types/cache';
66
import type { ObjectValue, Value } from '@warp-drive/core-types/json/raw';
7-
import type { ObjectField } from '@warp-drive/core-types/schema/fields';
7+
import type { ObjectField, SchemaObjectField } from '@warp-drive/core-types/schema/fields';
88

99
import type { SchemaRecord } from '../record';
1010
import type { SchemaService } from '../schema';
@@ -15,7 +15,7 @@ export function notifyObject(obj: ManagedObject) {
1515
}
1616

1717
type KeyType = string | symbol | number;
18-
18+
const ignoredGlobalFields = new Set<string>(['constructor', 'setInterval', 'nodeType', 'length']);
1919
export interface ManagedObject {
2020
[MUTATE]?(
2121
target: unknown[],
@@ -37,11 +37,12 @@ export class ManagedObject {
3737
store: Store,
3838
schema: SchemaService,
3939
cache: Cache,
40-
field: ObjectField,
40+
field: ObjectField | SchemaObjectField,
4141
data: object,
4242
address: StableRecordIdentifier,
4343
key: string,
44-
owner: SchemaRecord
44+
owner: SchemaRecord,
45+
isSchemaObject: boolean
4546
) {
4647
// eslint-disable-next-line @typescript-eslint/no-this-alias
4748
const self = this;
@@ -68,20 +69,42 @@ export class ManagedObject {
6869
if (prop === 'owner') {
6970
return self.owner;
7071
}
72+
if (prop === Symbol.toStringTag) {
73+
return `ManagedObject<${address.type}:${address.id} (${address.lid})>`;
74+
}
7175

76+
if (prop === 'toString') {
77+
return function () {
78+
return `ManagedObject<${address.type}:${address.id} (${address.lid})>`;
79+
};
80+
}
81+
82+
if (prop === 'toHTML') {
83+
return function () {
84+
return '<div>ManagedObject</div>';
85+
};
86+
}
7287
if (_SIGNAL.shouldReset) {
7388
_SIGNAL.t = false;
7489
_SIGNAL.shouldReset = false;
7590
let newData = cache.getAttr(self.address, self.key);
7691
if (newData && newData !== self[SOURCE]) {
77-
if (field.type) {
92+
if (!isSchemaObject && field.type) {
7893
const transform = schema.transformation(field);
7994
newData = transform.hydrate(newData as ObjectValue, field.options ?? null, self.owner) as ObjectValue;
8095
}
8196
self[SOURCE] = { ...(newData as ObjectValue) }; // Add type assertion for newData
8297
}
8398
}
8499

100+
if (isSchemaObject) {
101+
const fields = schema.fields({ type: field.type! });
102+
// TODO: is there a better way to do this?
103+
if (typeof prop === 'string' && !ignoredGlobalFields.has(prop) && !fields.has(prop)) {
104+
throw new Error(`Field ${prop} does not exist on schema object ${field.type}`);
105+
}
106+
}
107+
85108
if (prop in self[SOURCE]) {
86109
if (!transaction) {
87110
subscribe(_SIGNAL);
@@ -108,10 +131,16 @@ export class ManagedObject {
108131
self.owner = value;
109132
return true;
110133
}
134+
if (isSchemaObject) {
135+
const fields = schema.fields({ type: field.type! });
136+
if (typeof prop === 'string' && !ignoredGlobalFields.has(prop) && !fields.has(prop)) {
137+
throw new Error(`Field ${prop} does not exist on schema object ${field.type}`);
138+
}
139+
}
111140
const reflect = Reflect.set(target, prop, value, receiver);
112141

113142
if (reflect) {
114-
if (!field.type) {
143+
if (isSchemaObject || !field.type) {
115144
cache.setAttr(self.address, self.key, self[SOURCE] as Value);
116145
_SIGNAL.shouldReset = true;
117146
return true;

packages/schema-record/src/record.ts

+32-1
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ export class SchemaRecord {
243243
case 'schema-object':
244244
// validate any access off of schema, no transform to run
245245
// use raw cache value as the object to manage
246-
throw new Error(`Not Implemented`);
246+
entangleSignal(signals, receiver, field.name);
247+
return computeObject(store, schema, cache, target, identifier, field, prop as string, true);
247248
case 'object':
248249
assert(
249250
`SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`,
@@ -387,6 +388,7 @@ export class SchemaRecord {
387388
}
388389
return true;
389390
}
391+
390392
const transform = schema.transformation(field);
391393
const rawValue = transform.serialize({ ...(value as ObjectValue) }, field.options ?? null, target);
392394

@@ -398,6 +400,27 @@ export class SchemaRecord {
398400
}
399401
return true;
400402
}
403+
case 'schema-object': {
404+
let newValue = value;
405+
if (value !== null) {
406+
newValue = { ...(value as ObjectValue) };
407+
const schemaFields = schema.fields({ type: field.type });
408+
for (const key of Object.keys(newValue as ObjectValue)) {
409+
if (!schemaFields.has(key)) {
410+
throw new Error(`Field ${key} does not exist on schema object ${field.type}`);
411+
}
412+
}
413+
} else {
414+
ManagedObjectMap.delete(target);
415+
}
416+
cache.setAttr(identifier, propArray, newValue as Value);
417+
const peeked = peekManagedObject(self, field);
418+
if (peeked) {
419+
const objSignal = peeked[OBJECT_SIGNAL];
420+
objSignal.shouldReset = true;
421+
}
422+
return true;
423+
}
401424
case 'derived': {
402425
throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because it is derived`);
403426
}
@@ -488,6 +511,14 @@ export class SchemaRecord {
488511
addToTransaction(arrSignal);
489512
}
490513
}
514+
if (field?.kind === 'object' || field?.kind === 'schema-object') {
515+
const peeked = peekManagedObject(self, field);
516+
if (peeked) {
517+
const objSignal = peeked[OBJECT_SIGNAL];
518+
objSignal.shouldReset = true;
519+
addToTransaction(objSignal);
520+
}
521+
}
491522
}
492523
}
493524
break;

tests/warp-drive__schema-record/tests/-utils/reactive-context.ts

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export async function reactiveContext<T extends OpaqueRecordInstance>(
5353
field.kind === 'array' ||
5454
field.kind === 'object' ||
5555
field.kind === 'schema-array' ||
56+
field.kind === 'schema-object' ||
5657
field.kind === '@id' ||
5758
// @ts-expect-error we secretly allow this
5859
field.kind === '@hash'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { rerender } from '@ember/test-helpers';
2+
3+
import { module, test } from 'qunit';
4+
5+
import { setupRenderingTest } from 'ember-qunit';
6+
7+
import type Store from '@ember-data/store';
8+
import { registerDerivations, withDefaults } from '@warp-drive/schema-record/schema';
9+
10+
import { reactiveContext } from '../-utils/reactive-context';
11+
12+
interface Address {
13+
street: string;
14+
city: string;
15+
state: string;
16+
zip: string;
17+
}
18+
interface User {
19+
id: string | null;
20+
$type: 'user';
21+
name: string;
22+
favoriteNumbers: string[];
23+
address: Address;
24+
age: number;
25+
netWorth: number;
26+
coolometer: number;
27+
rank: number;
28+
}
29+
30+
module('Reactivity | object fields can receive remote updates', function (hooks) {
31+
setupRenderingTest(hooks);
32+
33+
test('we can use simple fields with no `type`', async function (assert) {
34+
const store = this.owner.lookup('service:store') as Store;
35+
const { schema } = store;
36+
registerDerivations(schema);
37+
38+
schema.registerResource(
39+
withDefaults({
40+
type: 'user',
41+
fields: [
42+
{
43+
name: 'address',
44+
kind: 'object',
45+
},
46+
],
47+
})
48+
);
49+
const resource = schema.resource({ type: 'user' });
50+
const record = store.push({
51+
data: {
52+
type: 'user',
53+
id: '1',
54+
attributes: {
55+
address: {
56+
street: '123 Main St',
57+
city: 'Anytown',
58+
state: 'NY',
59+
zip: '12345',
60+
},
61+
},
62+
},
63+
}) as User;
64+
65+
assert.strictEqual(record.id, '1', 'id is accessible');
66+
assert.strictEqual(record.$type, 'user', '$type is accessible');
67+
assert.deepEqual(
68+
record.address,
69+
{
70+
street: '123 Main St',
71+
city: 'Anytown',
72+
state: 'NY',
73+
zip: '12345',
74+
},
75+
'address is accessible'
76+
);
77+
78+
const { counters } = await reactiveContext.call(this, record, resource);
79+
// TODO: actually render the address object and verify
80+
// const addressIndex = fieldOrder.indexOf('address');
81+
82+
assert.strictEqual(counters.id, 1, 'idCount is 1');
83+
assert.strictEqual(counters.$type, 1, '$typeCount is 1');
84+
assert.strictEqual(counters.address, 1, 'addressCount is 1');
85+
86+
// remote update
87+
store.push({
88+
data: {
89+
type: 'user',
90+
id: '1',
91+
attributes: {
92+
address: {
93+
street: '456 Elm St',
94+
city: 'Anytown',
95+
state: 'NJ',
96+
zip: '23456',
97+
},
98+
},
99+
},
100+
});
101+
102+
assert.strictEqual(record.id, '1', 'id is accessible');
103+
assert.strictEqual(record.$type, 'user', '$type is accessible');
104+
assert.deepEqual(
105+
record.address,
106+
{
107+
street: '456 Elm St',
108+
city: 'Anytown',
109+
state: 'NJ',
110+
zip: '23456',
111+
},
112+
'address is accessible'
113+
);
114+
115+
await rerender();
116+
117+
assert.strictEqual(counters.id, 1, 'idCount is 1');
118+
assert.strictEqual(counters.$type, 1, '$typeCount is 1');
119+
assert.strictEqual(counters.address, 2, 'addressCount is 2');
120+
});
121+
});

0 commit comments

Comments
 (0)