From 0247ef5a90725df8407ba5ff68ae893e7c90150d Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Sat, 1 Jun 2024 13:45:53 -0700 Subject: [PATCH 1/5] chore: add tests for create case on legacy relationships --- .../tests/legacy/create/relationships-test.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/warp-drive__schema-record/tests/legacy/create/relationships-test.ts diff --git a/tests/warp-drive__schema-record/tests/legacy/create/relationships-test.ts b/tests/warp-drive__schema-record/tests/legacy/create/relationships-test.ts new file mode 100644 index 00000000000..abd29232d19 --- /dev/null +++ b/tests/warp-drive__schema-record/tests/legacy/create/relationships-test.ts @@ -0,0 +1,133 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import { PromiseBelongsTo, PromiseManyArray } from '@ember-data/model/-private'; +import { + registerDerivations as registerLegacyDerivations, + withDefaults as withLegacy, +} from '@ember-data/model/migration-support'; +import type { Type } from '@warp-drive/core-types/symbols'; + +import type Store from 'warp-drive__schema-record/services/store'; + +module('Legacy | Create | relationships', function (hooks) { + setupTest(hooks); + + test('we can create with a belongsTo', function (assert) { + type User = { + id: string | null; + $type: 'user'; + name: string; + bestFriend: User | null; + friends: User[]; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'bestFriend', + type: 'user', + kind: 'belongsTo', + options: { async: false, inverse: 'bestFriend' }, + }, + ], + }) + ); + const Matt = store.push({ + data: { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + bestFriend: { + data: null, + }, + }, + }, + }); + + const Rey = store.createRecord('user', { + name: 'Rey Skybarker', + bestFriend: Matt, + }); + + assert.strictEqual(Rey.id, null, 'id is accessible'); + assert.strictEqual(Rey.name, 'Rey Skybarker', 'name is accessible'); + assert.strictEqual(Rey.bestFriend, Matt, 'Rey has Matt as bestFriend'); + assert.strictEqual(Matt.bestFriend, Rey, 'Matt has Rey as bestFriend'); + }); + + test('we can create with a hasMany', function (assert) { + type User = { + id: string | null; + $type: 'user'; + name: string; + bestFriend: User | null; + friends: User[]; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'friends', + type: 'user', + kind: 'hasMany', + options: { async: false, inverse: 'friends' }, + }, + ], + }) + ); + + const Matt = store.push({ + data: { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + }); + + const Rey = store.createRecord('user', { + name: 'Rey Skybarker', + friends: [Matt], + }); + + assert.strictEqual(Rey.id, null, 'id is accessible'); + assert.strictEqual(Rey.name, 'Rey Skybarker', 'name is accessible'); + assert.strictEqual(Rey.friends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(Matt.friends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(Rey.friends[0], Matt, 'Rey has Matt as bestFriend'); + assert.strictEqual(Matt.friends[0], Rey, 'Matt has Rey as bestFriend'); + }); +}); From 128f3ff8cab71ecd85efbcee2022a5143514356f Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Sat, 1 Jun 2024 14:59:44 -0700 Subject: [PATCH 2/5] add replace and edit tests, edit functionality --- packages/schema-record/src/record.ts | 29 + .../tests/legacy/edit/relationships-test.ts | 619 ++++++++++++++++++ 2 files changed, 648 insertions(+) create mode 100644 tests/warp-drive__schema-record/tests/legacy/edit/relationships-test.ts diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index 07d1c475090..f9e5c5bcd87 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -664,6 +664,35 @@ export class SchemaRecord { case 'derived': { throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because it is derived`); } + case 'belongsTo': + if (!HAS_MODEL_PACKAGE) { + assert( + `Cannot use belongsTo fields in your schema unless @ember-data/model is installed to provide legacy model support. ${field.name} should likely be migrated to be a resource field.` + ); + } + assert(`Expected to have a getLegacySupport function`, getLegacySupport); + assert(`Can only use belongsTo fields when the resource is in legacy mode`, Mode[Legacy]); + store._join(() => { + getLegacySupport(receiver as unknown as MinimalLegacyRecord).setDirtyBelongsTo(field.name, value); + }); + return true; + case 'hasMany': + if (!HAS_MODEL_PACKAGE) { + assert( + `Cannot use hasMany fields in your schema unless @ember-data/model is installed to provide legacy model support. ${field.name} should likely be migrated to be a collection field.` + ); + } + assert(`Expected to have a getLegacySupport function`, getLegacySupport); + assert(`Can only use hasMany fields when the resource is in legacy mode`, Mode[Legacy]); + assert(`You must pass an array of records to set a hasMany relationship`, Array.isArray(value)); + store._join(() => { + const support = getLegacySupport(receiver as unknown as MinimalLegacyRecord); + const manyArray = support.getManyArray(field.name); + + manyArray.splice(0, manyArray.length, ...value); + }); + return true; + default: throw new Error(`Unknown field kind ${field.kind}`); } diff --git a/tests/warp-drive__schema-record/tests/legacy/edit/relationships-test.ts b/tests/warp-drive__schema-record/tests/legacy/edit/relationships-test.ts new file mode 100644 index 00000000000..3c5dbeaddd0 --- /dev/null +++ b/tests/warp-drive__schema-record/tests/legacy/edit/relationships-test.ts @@ -0,0 +1,619 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import { PromiseBelongsTo, PromiseManyArray } from '@ember-data/model/-private'; +import { + registerDerivations as registerLegacyDerivations, + withDefaults as withLegacy, +} from '@ember-data/model/migration-support'; +import type { Type } from '@warp-drive/core-types/symbols'; + +import type Store from 'warp-drive__schema-record/services/store'; + +module('Legacy | Edit | relationships', function (hooks) { + setupTest(hooks); + + test('we can edit a sync belongsTo', function (assert) { + type User = { + id: string | null; + $type: 'user'; + name: string; + bestFriend: User | null; + friends: User[]; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'bestFriend', + type: 'user', + kind: 'belongsTo', + options: { async: false, inverse: 'bestFriend' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + bestFriend: { + data: { type: 'user', id: '2' }, + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + bestFriend: { + data: { type: 'user', id: '1' }, + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + bestFriend: { + data: null, + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + + assert.strictEqual(Rey.id, '1', 'id is accessible'); + assert.strictEqual(Rey.name, 'Rey Skybarker', 'name is accessible'); + assert.strictEqual(Rey.bestFriend, Matt, 'Rey has Matt as bestFriend'); + assert.strictEqual(Matt.bestFriend, Rey, 'Matt has Rey as bestFriend'); + assert.strictEqual(Wes.bestFriend, null, 'Wesley has no bestFriend'); + + Rey.bestFriend = Wes; + + assert.strictEqual(Rey.bestFriend, Wes, 'Wes is now the bestFriend of Rey'); + assert.strictEqual(Wes.bestFriend, Rey, 'Rey is now the bestFriend of Wes'); + assert.strictEqual(Matt.bestFriend, null, 'Matt no longer has a bestFriend'); + }); + + test('we can replace a sync hasMany', function (assert) { + type User = { + id: string | null; + $type: 'user'; + name: string; + bestFriend: User | null; + friends: User[]; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'friends', + type: 'user', + kind: 'hasMany', + options: { async: false, inverse: 'friends' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '2' }], + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '1' }], + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + + assert.strictEqual(Rey.friends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(Matt.friends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(Wes.friends.length, 0, 'Wes has no friends :('); + assert.strictEqual(Rey.friends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(Matt.friends[0], Rey, 'Matt has Rey as a friend'); + assert.strictEqual(Wes.friends[0], undefined, 'Wes truly has no friends'); + + Rey.friends = [Wes]; + + assert.strictEqual(Rey.friends.length, 1, 'Rey still has only one friend'); + assert.strictEqual(Matt.friends.length, 0, 'Matt now has no friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(Rey.friends[0], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(Wes.friends[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(Matt.friends[0], undefined, 'Matt has no friends'); + }); + + test('we can edit a sync hasMany', function (assert) { + type User = { + id: string | null; + $type: 'user'; + name: string; + bestFriend: User | null; + friends: User[]; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'friends', + type: 'user', + kind: 'hasMany', + options: { async: false, inverse: 'friends' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '2' }], + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '1' }], + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + + assert.strictEqual(Rey.friends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(Matt.friends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(Wes.friends.length, 0, 'Wes has no friends :('); + assert.strictEqual(Rey.friends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(Matt.friends[0], Rey, 'Matt has Rey as a friend'); + assert.strictEqual(Wes.friends[0], undefined, 'Wes truly has no friends'); + + Rey.friends.push(Wes); + + assert.strictEqual(Rey.friends.length, 2, 'Rey now has two friends'); + assert.strictEqual(Matt.friends.length, 1, 'Matt still has one friend'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(Rey.friends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(Rey.friends[1], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(Wes.friends[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(Matt.friends[0], Rey, 'Matt has Rey as a friend'); + }); + + test('we can edit an async belongsTo', async function (assert) { + type User = { + id: string; + name: string; + bestFriend: PromiseBelongsTo; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'bestFriend', + type: 'user', + kind: 'belongsTo', + options: { async: true, inverse: 'bestFriend' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + bestFriend: { + data: { type: 'user', id: '2' }, + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + bestFriend: { + data: { type: 'user', id: '1' }, + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + bestFriend: { + data: null, + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + + const ReyBestFriend = await Rey.bestFriend; + const MattBestFriend = await Matt.bestFriend; + const WesBestFriend = await Wes.bestFriend; + + assert.strictEqual(Rey.id, '1', 'id is accessible'); + assert.strictEqual(Rey.name, 'Rey Skybarker', 'name is accessible'); + assert.true(Rey.bestFriend instanceof PromiseBelongsTo, 'Rey has an async bestFriend'); + assert.true(Matt.bestFriend instanceof PromiseBelongsTo, 'Matt has an async bestFriend'); + assert.true(Wes.bestFriend instanceof PromiseBelongsTo, 'Wes has an async bestFriend'); + + assert.strictEqual(ReyBestFriend, Matt, 'Rey has Matt as bestFriend'); + assert.strictEqual(MattBestFriend, Rey, 'Matt has Rey as bestFriend'); + assert.strictEqual(WesBestFriend, null, 'Wes has no bestFriend'); + + // @ts-expect-error setting an async belongsTo cannot be properly typed + Rey.bestFriend = Wes; + + const ReyBestFriend2 = await Rey.bestFriend; + const MattBestFriend2 = await Matt.bestFriend; + const WesBestFriend2 = await Wes.bestFriend; + + assert.strictEqual(ReyBestFriend2, Wes, 'Rey now has Wes as bestFriend'); + assert.strictEqual(MattBestFriend2, null, 'Matt now has no bestFriend'); + assert.strictEqual(WesBestFriend2, Rey, 'Wes is now the bestFriend of Rey'); + }); + + test('we can replace an async hasMany', async function (assert) { + type User = { + id: string; + name: string; + friends: PromiseManyArray; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'friends', + type: 'user', + kind: 'hasMany', + options: { async: true, inverse: 'friends' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '2' }], + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '1' }], + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + + const ReyFriends = await Rey.friends; + const MattFriends = await Matt.friends; + const WesFriends = await Wes.friends; + + assert.strictEqual(Rey.id, '1', 'id is accessible'); + assert.strictEqual(Rey.name, 'Rey Skybarker', 'name is accessible'); + + assert.strictEqual(Rey.friends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(Matt.friends.length, 1, 'Matt has only one friend :('); + assert.true(Rey.friends instanceof PromiseManyArray, 'Rey has async friends'); + assert.true(Matt.friends instanceof PromiseManyArray, 'Matt has async friends'); + assert.true(Wes.friends instanceof PromiseManyArray, 'Wes has async friends'); + + assert.strictEqual(ReyFriends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(MattFriends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(WesFriends.length, 0, 'Wes has no friends'); + assert.strictEqual(ReyFriends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(MattFriends[0], Rey, 'Matt has Rey as a friend'); + assert.strictEqual(WesFriends[0], undefined, 'Rey really has no friends'); + + // @ts-expect-error async hasMany cannot have a sync setter type :( + Rey.friends = [Wes]; + + assert.strictEqual(Rey.friends.length, 1, 'Rey still has only one friend'); + assert.strictEqual(Matt.friends.length, 0, 'Matt now has no friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(ReyFriends[0], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(WesFriends[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(MattFriends[0], undefined, 'Matt has no friends'); + + const ReyFriends2 = await Rey.friends; + const MattFriends2 = await Matt.friends; + const WesFriends2 = await Wes.friends; + + assert.strictEqual(Rey.friends.length, 1, 'Rey still has only one friend'); + assert.strictEqual(Matt.friends.length, 0, 'Matt now has no friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(ReyFriends2[0], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(WesFriends2[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(MattFriends2[0], undefined, 'Matt has no friends'); + }); + + test('we can edit an async hasMany', async function (assert) { + type User = { + id: string; + name: string; + friends: PromiseManyArray; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'friends', + type: 'user', + kind: 'hasMany', + options: { async: true, inverse: 'friends' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '2' }], + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '1' }], + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + + const ReyFriends = await Rey.friends; + const MattFriends = await Matt.friends; + const WesFriends = await Wes.friends; + + assert.strictEqual(Rey.id, '1', 'id is accessible'); + assert.strictEqual(Rey.name, 'Rey Skybarker', 'name is accessible'); + + assert.strictEqual(Rey.friends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(Matt.friends.length, 1, 'Matt has only one friend :('); + assert.true(Rey.friends instanceof PromiseManyArray, 'Rey has async friends'); + assert.true(Matt.friends instanceof PromiseManyArray, 'Matt has async friends'); + assert.true(Wes.friends instanceof PromiseManyArray, 'Wes has async friends'); + + assert.strictEqual(ReyFriends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(MattFriends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(WesFriends.length, 0, 'Wes has no friends'); + assert.strictEqual(ReyFriends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(MattFriends[0], Rey, 'Matt has Rey as a friend'); + assert.strictEqual(WesFriends[0], undefined, 'Rey really has no friends'); + + ReyFriends.push(Wes); + + assert.strictEqual(Rey.friends.length, 2, 'Rey now has two friends'); + assert.strictEqual(Matt.friends.length, 1, 'Matt still has one friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(ReyFriends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(ReyFriends[1], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(WesFriends[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(MattFriends[0], Rey, 'Matt has Rey as a friend'); + + const ReyFriends2 = await Rey.friends; + const MattFriends2 = await Matt.friends; + const WesFriends2 = await Wes.friends; + + assert.strictEqual(Rey.friends.length, 2, 'Rey now has two friends'); + assert.strictEqual(Matt.friends.length, 1, 'Matt still has one friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(ReyFriends2[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(ReyFriends2[1], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(WesFriends2[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(MattFriends2[0], Rey, 'Matt has Rey as a friend'); + }); +}); From b1f6115375232b54808fc3464b6a513da4e43583 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Sat, 1 Jun 2024 17:34:17 -0700 Subject: [PATCH 3/5] impl: add support for editing --- .../schema-record/src/-private/compute.ts | 260 +++++++++++ .../src/{ => -private}/managed-array.ts | 6 +- .../src/{ => -private}/managed-object.ts | 6 +- packages/schema-record/src/record.ts | 410 +++++------------- 4 files changed, 376 insertions(+), 306 deletions(-) create mode 100644 packages/schema-record/src/-private/compute.ts rename packages/schema-record/src/{ => -private}/managed-array.ts (98%) rename packages/schema-record/src/{ => -private}/managed-object.ts (96%) diff --git a/packages/schema-record/src/-private/compute.ts b/packages/schema-record/src/-private/compute.ts new file mode 100644 index 00000000000..63c231b0b39 --- /dev/null +++ b/packages/schema-record/src/-private/compute.ts @@ -0,0 +1,260 @@ +import { dependencySatisfies, importSync } from '@embroider/macros'; + +import type { Future } from '@ember-data/request'; +import type Store from '@ember-data/store'; +import type { StoreRequestInput } from '@ember-data/store'; +import { defineSignal, getSignal, peekSignal } from '@ember-data/tracking/-private'; +import { DEBUG } from '@warp-drive/build-config/env'; +import type { StableRecordIdentifier } from '@warp-drive/core-types'; +import { getOrSetGlobal } from '@warp-drive/core-types/-private'; +import type { Cache } from '@warp-drive/core-types/cache'; +import type { ResourceRelationship as SingleResourceRelationship } from '@warp-drive/core-types/cache/relationship'; +import type { ObjectValue } from '@warp-drive/core-types/json/raw'; +import type { + ArrayField, + DerivedField, + FieldSchema, + GenericField, + LocalField, + ObjectField, + SchemaArrayField, +} from '@warp-drive/core-types/schema/fields'; +import type { Link, Links } from '@warp-drive/core-types/spec/json-api-raw'; +import { RecordStore } from '@warp-drive/core-types/symbols'; + +import { ManagedArray } from './managed-array'; +import { ManagedObject } from './managed-object'; +import type { SchemaService } from '../schema'; +import { Identifier, Parent } from '../symbols'; +import { SchemaRecord } from '../record'; + +export const ManagedArrayMap = getOrSetGlobal( + 'ManagedArrayMap', + new Map>() +); +export const ManagedObjectMap = getOrSetGlobal( + 'ManagedObjectMap', + new Map>() +); + +export function computeLocal(record: typeof Proxy, field: LocalField, prop: string): unknown { + let signal = peekSignal(record, prop); + + if (!signal) { + signal = getSignal(record, prop, false); + signal.lastValue = field.options?.defaultValue ?? null; + } + + return signal.lastValue; +} + +export function peekManagedArray(record: SchemaRecord, field: FieldSchema): ManagedArray | undefined { + const managedArrayMapForRecord = ManagedArrayMap.get(record); + if (managedArrayMapForRecord) { + return managedArrayMapForRecord.get(field); + } +} + +export function peekManagedObject(record: SchemaRecord, field: FieldSchema): ManagedObject | undefined { + const managedObjectMapForRecord = ManagedObjectMap.get(record); + if (managedObjectMapForRecord) { + return managedObjectMapForRecord.get(field); + } +} + +export function computeField( + schema: SchemaService, + cache: Cache, + record: SchemaRecord, + identifier: StableRecordIdentifier, + field: GenericField, + prop: string | string[] +): unknown { + const rawValue = cache.getAttr(identifier, prop); + if (!field.type) { + return rawValue; + } + const transform = schema.transformation(field); + return transform.hydrate(rawValue, field.options ?? null, record); +} + +export function computeArray( + store: Store, + schema: SchemaService, + cache: Cache, + record: SchemaRecord, + identifier: StableRecordIdentifier, + field: ArrayField | SchemaArrayField, + path: string[], + isSchemaArray = false +) { + // 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) + // in the nested object case field name here is the full dot path from root resource to this value + // its "key" is the field on the parent record + // its "owner" is the parent record + + const managedArrayMapForRecord = ManagedArrayMap.get(record); + let managedArray; + if (managedArrayMapForRecord) { + managedArray = managedArrayMapForRecord.get(field); + } + if (managedArray) { + return managedArray; + } else { + const rawValue = cache.getAttr(identifier, path) as unknown[]; + if (!rawValue) { + return null; + } + managedArray = new ManagedArray(store, schema, cache, field, rawValue, identifier, path, record, isSchemaArray); + if (!managedArrayMapForRecord) { + ManagedArrayMap.set(record, new Map([[field, managedArray]])); + } else { + managedArrayMapForRecord.set(field, managedArray); + } + } + return managedArray; +} + +export function computeObject( + store: Store, + schema: SchemaService, + cache: Cache, + record: SchemaRecord, + identifier: StableRecordIdentifier, + field: ObjectField, + 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) { + 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); + if (!managedObjectMapForRecord) { + ManagedObjectMap.set(record, new Map([[field, managedObject]])); + } else { + managedObjectMapForRecord.set(field, managedObject); + } + } + return managedObject; +} + +export function computeAttribute(cache: Cache, identifier: StableRecordIdentifier, prop: string): unknown { + return cache.getAttr(identifier, prop); +} + +export function computeDerivation( + schema: SchemaService, + record: SchemaRecord, + identifier: StableRecordIdentifier, + field: DerivedField, + prop: string +): unknown { + return schema.derivation(field)(record, field.options ?? null, prop); +} + +// TODO probably this should just be a Document +// but its separate until we work out the lid situation +class ResourceRelationship { + declare lid: string; + declare [Parent]: SchemaRecord; + declare [RecordStore]: Store; + declare name: string; + + declare data: T | null; + declare links: Links; + declare meta: Record; + + constructor( + store: Store, + cache: Cache, + parent: SchemaRecord, + identifier: StableRecordIdentifier, + field: FieldSchema, + name: string + ) { + const rawValue = cache.getRelationship(identifier, name) as SingleResourceRelationship; + + // TODO setup true lids for relationship documents + // @ts-expect-error we need to give relationship documents a lid + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.lid = rawValue.lid ?? rawValue.links?.self ?? `relationship:${identifier.lid}.${name}`; + this.data = rawValue.data ? store.peekRecord(rawValue.data) : null; + this.name = name; + + if (DEBUG) { + this.links = Object.freeze(Object.assign({}, rawValue.links)); + this.meta = Object.freeze(Object.assign({}, rawValue.meta)); + } else { + this.links = rawValue.links ?? {}; + this.meta = rawValue.meta ?? {}; + } + + this[RecordStore] = store; + this[Parent] = parent; + } + + fetch(options?: StoreRequestInput): Future { + const url = options?.url ?? getHref(this.links.related) ?? getHref(this.links.self) ?? null; + + if (!url) { + throw new Error( + `Cannot ${options?.method ?? 'fetch'} ${this[Parent][Identifier].type}.${String( + this.name + )} because it has no related link` + ); + } + const request = Object.assign( + { + url, + method: 'GET', + }, + options + ); + + return this[RecordStore].request(request); + } +} + +defineSignal(ResourceRelationship.prototype, 'data'); +defineSignal(ResourceRelationship.prototype, 'links'); +defineSignal(ResourceRelationship.prototype, 'meta'); + +function getHref(link?: Link | null): string | null { + if (!link) { + return null; + } + if (typeof link === 'string') { + return link; + } + return link.href; +} + +export function computeResource( + store: Store, + cache: Cache, + parent: SchemaRecord, + identifier: StableRecordIdentifier, + field: FieldSchema, + prop: string +): ResourceRelationship { + if (field.kind !== 'resource') { + throw new Error(`The schema for ${identifier.type}.${String(prop)} is not a resource relationship`); + } + + return new ResourceRelationship(store, cache, parent, identifier, field, prop); +} diff --git a/packages/schema-record/src/managed-array.ts b/packages/schema-record/src/-private/managed-array.ts similarity index 98% rename from packages/schema-record/src/managed-array.ts rename to packages/schema-record/src/-private/managed-array.ts index 915d6bfe788..a2bdb29a035 100644 --- a/packages/schema-record/src/managed-array.ts +++ b/packages/schema-record/src/-private/managed-array.ts @@ -8,9 +8,9 @@ import type { ArrayValue, ObjectValue, Value } from '@warp-drive/core-types/json import type { OpaqueRecordInstance } from '@warp-drive/core-types/record'; import type { ArrayField, HashField, SchemaArrayField } from '@warp-drive/core-types/schema/fields'; -import { SchemaRecord } from './record'; -import type { SchemaService } from './schema'; -import { ARRAY_SIGNAL, Editable, Identifier, Legacy, MUTATE, SOURCE } from './symbols'; +import { SchemaRecord } from '../record'; +import type { SchemaService } from '../schema'; +import { ARRAY_SIGNAL, Editable, Identifier, Legacy, MUTATE, SOURCE } from '../symbols'; export function notifyArray(arr: ManagedArray) { addToTransaction(arr[ARRAY_SIGNAL]); diff --git a/packages/schema-record/src/managed-object.ts b/packages/schema-record/src/-private/managed-object.ts similarity index 96% rename from packages/schema-record/src/managed-object.ts rename to packages/schema-record/src/-private/managed-object.ts index 613887d3f4f..6bfa618f800 100644 --- a/packages/schema-record/src/managed-object.ts +++ b/packages/schema-record/src/-private/managed-object.ts @@ -6,9 +6,9 @@ import type { Cache } from '@warp-drive/core-types/cache'; import type { ObjectValue, Value } from '@warp-drive/core-types/json/raw'; import type { ObjectField } 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 type { SchemaRecord } from '../record'; +import type { SchemaService } from '../schema'; +import { MUTATE, OBJECT_SIGNAL, SOURCE } from '../symbols'; export function notifyObject(obj: ManagedObject) { addToTransaction(obj[OBJECT_SIGNAL]); diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index f9e5c5bcd87..80aa6d9d6b1 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -1,40 +1,16 @@ import { dependencySatisfies, importSync } from '@embroider/macros'; import type { MinimalLegacyRecord } from '@ember-data/model/-private/model-methods'; -import type { Future } from '@ember-data/request'; import type Store from '@ember-data/store'; -import type { NotificationType, StoreRequestInput } from '@ember-data/store'; -import { - addToTransaction, - defineSignal, - entangleSignal, - getSignal, - peekSignal, - type Signal, - Signals, -} from '@ember-data/tracking/-private'; -import { DEBUG } from '@warp-drive/build-config/env'; +import type { NotificationType } from '@ember-data/store'; +import { addToTransaction, entangleSignal, getSignal, type Signal, Signals } from '@ember-data/tracking/-private'; import { assert } from '@warp-drive/build-config/macros'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; -import { getOrSetGlobal } from '@warp-drive/core-types/-private'; -import type { Cache } from '@warp-drive/core-types/cache'; -import type { ResourceRelationship as SingleResourceRelationship } from '@warp-drive/core-types/cache/relationship'; import type { ArrayValue, ObjectValue, Value } from '@warp-drive/core-types/json/raw'; import { STRUCTURED } from '@warp-drive/core-types/request'; -import type { - ArrayField, - DerivedField, - FieldSchema, - GenericField, - LocalField, - ObjectField, - SchemaArrayField, -} from '@warp-drive/core-types/schema/fields'; -import type { Link, Links } from '@warp-drive/core-types/spec/json-api-raw'; +import type { FieldSchema } from '@warp-drive/core-types/schema/fields'; import { RecordStore } from '@warp-drive/core-types/symbols'; -import { ManagedArray } from './managed-array'; -import { ManagedObject } from './managed-object'; import type { SchemaService } from './schema'; import { ARRAY_SIGNAL, @@ -48,6 +24,19 @@ import { OBJECT_SIGNAL, Parent, } from './symbols'; +import { + ManagedArrayMap, + ManagedObjectMap, + computeArray, + computeAttribute, + computeDerivation, + computeField, + computeLocal, + computeObject, + computeResource, + peekManagedArray, + peekManagedObject, +} from './-private/compute'; const HAS_MODEL_PACKAGE = dependencySatisfies('@ember-data/model', '*'); const getLegacySupport = HAS_MODEL_PACKAGE @@ -72,235 +61,10 @@ const RecordSymbols = new Set(symbolList); type RecordSymbol = (typeof symbolList)[number]; -const ManagedArrayMap = getOrSetGlobal('ManagedArrayMap', new Map>()); -const ManagedObjectMap = getOrSetGlobal('ManagedObjectMap', new Map>()); - -function computeLocal(record: typeof Proxy, field: LocalField, prop: string): unknown { - let signal = peekSignal(record, prop); - - if (!signal) { - signal = getSignal(record, prop, false); - signal.lastValue = field.options?.defaultValue ?? null; - } - - return signal.lastValue; -} - -function peekManagedArray(record: SchemaRecord, field: FieldSchema): ManagedArray | undefined { - const managedArrayMapForRecord = ManagedArrayMap.get(record); - if (managedArrayMapForRecord) { - return managedArrayMapForRecord.get(field); - } -} - -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, - record: SchemaRecord, - identifier: StableRecordIdentifier, - field: GenericField, - prop: string | string[] -): unknown { - const rawValue = cache.getAttr(identifier, prop); - if (!field.type) { - return rawValue; - } - const transform = schema.transformation(field); - return transform.hydrate(rawValue, field.options ?? null, record); -} - -function computeArray( - store: Store, - schema: SchemaService, - cache: Cache, - record: SchemaRecord, - identifier: StableRecordIdentifier, - field: ArrayField | SchemaArrayField, - path: string[], - isSchemaArray = false -) { - // 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) - // in the nested object case field name here is the full dot path from root resource to this value - // its "key" is the field on the parent record - // its "owner" is the parent record - - const managedArrayMapForRecord = ManagedArrayMap.get(record); - let managedArray; - if (managedArrayMapForRecord) { - managedArray = managedArrayMapForRecord.get(field); - } - if (managedArray) { - return managedArray; - } else { - const rawValue = cache.getAttr(identifier, path) as unknown[]; - if (!rawValue) { - return null; - } - managedArray = new ManagedArray(store, schema, cache, field, rawValue, identifier, path, record, isSchemaArray); - if (!managedArrayMapForRecord) { - ManagedArrayMap.set(record, new Map([[field, managedArray]])); - } else { - managedArrayMapForRecord.set(field, managedArray); - } - } - return managedArray; -} - -function computeObject( - store: Store, - schema: SchemaService, - cache: Cache, - record: SchemaRecord, - identifier: StableRecordIdentifier, - field: ObjectField, - 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) { - 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); - 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); -} - -function computeDerivation( - schema: SchemaService, - record: SchemaRecord, - identifier: StableRecordIdentifier, - field: DerivedField, - prop: string -): unknown { - return schema.derivation(field)(record, field.options ?? null, prop); -} - -// TODO probably this should just be a Document -// but its separate until we work out the lid situation -class ResourceRelationship { - declare lid: string; - declare [Parent]: SchemaRecord; - declare [RecordStore]: Store; - declare name: string; - - declare data: T | null; - declare links: Links; - declare meta: Record; - - constructor( - store: Store, - cache: Cache, - parent: SchemaRecord, - identifier: StableRecordIdentifier, - field: FieldSchema, - name: string - ) { - const rawValue = cache.getRelationship(identifier, name) as SingleResourceRelationship; - - // TODO setup true lids for relationship documents - // @ts-expect-error we need to give relationship documents a lid - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - this.lid = rawValue.lid ?? rawValue.links?.self ?? `relationship:${identifier.lid}.${name}`; - this.data = rawValue.data ? store.peekRecord(rawValue.data) : null; - this.name = name; - - if (DEBUG) { - this.links = Object.freeze(Object.assign({}, rawValue.links)); - this.meta = Object.freeze(Object.assign({}, rawValue.meta)); - } else { - this.links = rawValue.links ?? {}; - this.meta = rawValue.meta ?? {}; - } - - this[RecordStore] = store; - this[Parent] = parent; - } - - fetch(options?: StoreRequestInput): Future { - const url = options?.url ?? getHref(this.links.related) ?? getHref(this.links.self) ?? null; - - if (!url) { - throw new Error( - `Cannot ${options?.method ?? 'fetch'} ${this[Parent][Identifier].type}.${String( - this.name - )} because it has no related link` - ); - } - const request = Object.assign( - { - url, - method: 'GET', - }, - options - ); - - return this[RecordStore].request(request); - } -} - -defineSignal(ResourceRelationship.prototype, 'data'); -defineSignal(ResourceRelationship.prototype, 'links'); -defineSignal(ResourceRelationship.prototype, 'meta'); - function isPathMatch(a: string[], b: string[]) { return a.length === b.length && a.every((v, i) => v === b[i]); } -function getHref(link?: Link | null): string | null { - if (!link) { - return null; - } - if (typeof link === 'string') { - return link; - } - return link.href; -} - -function computeResource( - store: Store, - cache: Cache, - parent: SchemaRecord, - identifier: StableRecordIdentifier, - field: FieldSchema, - prop: string -): ResourceRelationship { - if (field.kind !== 'resource') { - throw new Error(`The schema for ${identifier.type}.${String(prop)} is not a resource relationship`); - } - - return new ResourceRelationship(store, cache, parent, identifier, field, prop); -} - export class SchemaRecord { declare [RecordStore]: Store; declare [Identifier]: StableRecordIdentifier; @@ -348,55 +112,8 @@ export class SchemaRecord { const signals: Map = new Map(); this[Signals] = signals; - // what signal do we need for embedded record? - this.___notifications = store.notifications.subscribe( - identifier, - (_: StableRecordIdentifier, type: NotificationType, key?: string | string[]) => { - switch (type) { - case 'attributes': - if (key) { - if (Array.isArray(key)) { - if (!isEmbedded) return; // deep paths will be handled by embedded records - // TODO we should have the notification manager - // ensure it is safe for each callback to mutate this array - if (isPathMatch(embeddedPath!, key)) { - // handle the notification - // TODO we should likely handle this notification here - // also we should add a LOGGING flag - // eslint-disable-next-line no-console - console.warn(`Notification unhandled for ${key.join(',')} on ${identifier.type}`, self); - return; - } - // TODO we should add a LOGGING flag - // console.log(`Deep notification skipped for ${key.join('.')} on ${identifier.type}`, self); - // deep notify the key path - } else { - if (isEmbedded) return; // base paths never apply to embedded records - - // TODO determine what LOGGING flag to wrap this in if any - // console.log(`Notification for ${key} on ${identifier.type}`, self); - const signal = signals.get(key); - if (signal) { - addToTransaction(signal); - } - const field = fields.get(key); - if (field?.kind === 'array' || field?.kind === 'schema-array') { - const peeked = peekManagedArray(self, field); - if (peeked) { - const arrSignal = peeked[ARRAY_SIGNAL]; - arrSignal.shouldReset = true; - addToTransaction(arrSignal); - } - } - } - } - break; - } - } - ); - - return new Proxy(this, { + const proxy = new Proxy(this, { ownKeys() { return Array.from(fields.keys()); }, @@ -549,6 +266,7 @@ export class SchemaRecord { } assert(`Expected to have a getLegacySupport function`, getLegacySupport); assert(`Can only use hasMany fields when the resource is in legacy mode`, Mode[Legacy]); + entangleSignal(signals, receiver, field.name); return getLegacySupport(receiver as unknown as MinimalLegacyRecord).getHasMany(field.name); default: throw new Error(`Field '${String(prop)}' on '${identifier.type}' has the unknown kind '${field.kind}'`); @@ -698,6 +416,98 @@ export class SchemaRecord { } }, }); + + // what signal do we need for embedded record? + this.___notifications = store.notifications.subscribe( + identifier, + (_: StableRecordIdentifier, type: NotificationType, key?: string | string[]) => { + switch (type) { + case 'attributes': + if (key) { + if (Array.isArray(key)) { + if (!isEmbedded) return; // deep paths will be handled by embedded records + // TODO we should have the notification manager + // ensure it is safe for each callback to mutate this array + if (isPathMatch(embeddedPath!, key)) { + // handle the notification + // TODO we should likely handle this notification here + // also we should add a LOGGING flag + // eslint-disable-next-line no-console + console.warn(`Notification unhandled for ${key.join(',')} on ${identifier.type}`, self); + return; + } + + // TODO we should add a LOGGING flag + // console.log(`Deep notification skipped for ${key.join('.')} on ${identifier.type}`, self); + // deep notify the key path + } else { + if (isEmbedded) return; // base paths never apply to embedded records + + // TODO determine what LOGGING flag to wrap this in if any + // console.log(`Notification for ${key} on ${identifier.type}`, self); + const signal = signals.get(key); + if (signal) { + addToTransaction(signal); + } + const field = fields.get(key); + if (field?.kind === 'array' || field?.kind === 'schema-array') { + const peeked = peekManagedArray(self, field); + if (peeked) { + const arrSignal = peeked[ARRAY_SIGNAL]; + arrSignal.shouldReset = true; + addToTransaction(arrSignal); + } + } + } + } + break; + case 'relationships': + if (key) { + if (Array.isArray(key)) { + } else { + const field = fields.get(key); + assert(`Expected relationshp ${key} to be the name of a field`, field); + if (field.kind === 'belongsTo') { + // FIXME + } else if (field.kind === 'resource') { + // FIXME + } else if (field.kind === 'hasMany') { + assert(`Expected to have a getLegacySupport function`, getLegacySupport); + assert(`Can only use hasMany fields when the resource is in legacy mode`, Mode[Legacy]); + + const support = getLegacySupport(proxy as unknown as MinimalLegacyRecord); + const manyArray = support && support._manyArrayCache[key]; + const hasPromise = + support && (support._relationshipPromisesCache[key] as Promise | undefined); + + if (manyArray && hasPromise) { + // do nothing, we will notify the ManyArray directly + // once the fetch has completed. + return; + } + + if (manyArray) { + manyArray.notify(); + + assert(`Expected options to exist on relationship meta`, field.options); + assert(`Expected async to exist on relationship meta options`, 'async' in field.options); + if (field.options.async) { + const signal = signals.get(key); + if (signal) { + addToTransaction(signal); + } + } + } + } else if (field.kind === 'collection') { + // FIXME + } + } + } + } + } + ); + + return proxy; } [Destroy](): void { From f45f8204a240cd6587ed8acd99a5816cbd4c8c77 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Sat, 1 Jun 2024 18:55:34 -0700 Subject: [PATCH 4/5] add reactivity --- packages/schema-record/src/record.ts | 12 + .../tests/-utils/reactive-context.ts | 35 +- .../legacy/reactivity/relationships-test.ts | 820 ++++++++++++++++++ 3 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 tests/warp-drive__schema-record/tests/legacy/reactivity/relationships-test.ts diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index 80aa6d9d6b1..98b013a2cd0 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -138,6 +138,9 @@ export class SchemaRecord { case 'field': case 'attribute': case 'resource': + case 'belongsTo': + case 'hasMany': + case 'collection': case 'schema-array': case 'array': case 'schema-object': @@ -257,6 +260,7 @@ export class SchemaRecord { } assert(`Expected to have a getLegacySupport function`, getLegacySupport); assert(`Can only use belongsTo fields when the resource is in legacy mode`, Mode[Legacy]); + entangleSignal(signals, receiver, field.name); return getLegacySupport(receiver as unknown as MinimalLegacyRecord).getBelongsTo(field.name); case 'hasMany': if (!HAS_MODEL_PACKAGE) { @@ -465,9 +469,17 @@ export class SchemaRecord { if (key) { if (Array.isArray(key)) { } else { + if (isEmbedded) return; // base paths never apply to embedded records + const field = fields.get(key); assert(`Expected relationshp ${key} to be the name of a field`, field); if (field.kind === 'belongsTo') { + // TODO determine what LOGGING flag to wrap this in if any + // console.log(`Notification for ${key} on ${identifier.type}`, self); + const signal = signals.get(key); + if (signal) { + addToTransaction(signal); + } // FIXME } else if (field.kind === 'resource') { // FIXME diff --git a/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts b/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts index 9a94c003c99..564f4d70df3 100644 --- a/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts +++ b/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts @@ -8,10 +8,15 @@ import type { ResourceRelationship } from '@warp-drive/core-types/cache/relation import type { OpaqueRecordInstance } from '@warp-drive/core-types/record'; import type { FieldSchema, IdentityField, ResourceSchema } from '@warp-drive/core-types/schema/fields'; +type Template = { + [key in keyof T & string]?: string; +}; + export async function reactiveContext( this: TestContext, record: T, - resource: ResourceSchema + resource: ResourceSchema, + template?: Template ) { const _fields: string[] = []; const fields: Array = resource.fields.slice(); @@ -55,6 +60,34 @@ export async function reactiveContext( return record[field.name as keyof T] as unknown; } else if (field.kind === 'resource') { return (record[field.name as keyof T] as ResourceRelationship).data?.id; + } else if (field.kind === 'belongsTo') { + if (template && field.name in template) { + const key = template[field.name as keyof T & string]!; + let value = record[field.name as keyof T] as { [key: string]: string }; + + if (field.options.async) { + // @ts-expect-error promise proxy reach through + value = record[field.name].content as { [key: string]: string }; + } + + return value?.[key]; + } else { + return (record[field.name as keyof T] as { id: string })?.id; + } + } else if (field.kind === 'hasMany') { + if (template && field.name in template) { + const key = template[field.name as keyof T & string]!; + let arr = record[field.name as keyof T] as Array<{ [key: string]: string }>; + + if (field.options.async) { + // @ts-expect-error promise proxy reach through + arr = record[field.name].content as Array<{ [key: string]: string }>; + } + + return arr.map((v) => v[key]).join(', '); + } else { + return (record[field.name as keyof T] as { length: string })?.length; + } } }, }); diff --git a/tests/warp-drive__schema-record/tests/legacy/reactivity/relationships-test.ts b/tests/warp-drive__schema-record/tests/legacy/reactivity/relationships-test.ts new file mode 100644 index 00000000000..5a6b0e5e8ef --- /dev/null +++ b/tests/warp-drive__schema-record/tests/legacy/reactivity/relationships-test.ts @@ -0,0 +1,820 @@ +import { module, test } from 'qunit'; + +import { setupRenderingTest } from 'ember-qunit'; + +import { PromiseBelongsTo, PromiseManyArray } from '@ember-data/model/-private'; +import { + registerDerivations as registerLegacyDerivations, + withDefaults as withLegacy, +} from '@ember-data/model/migration-support'; +import type { Type } from '@warp-drive/core-types/symbols'; + +import type Store from 'warp-drive__schema-record/services/store'; +import { reactiveContext } from '../../-utils/reactive-context'; +import { rerender } from '@ember/test-helpers'; + +module('Legacy | Reactivity | relationships', function (hooks) { + setupRenderingTest(hooks); + + test('sync belongsTo is reactive', async function (assert) { + type User = { + id: string | null; + $type: 'user'; + name: string; + bestFriend: User | null; + friends: User[]; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'bestFriend', + type: 'user', + kind: 'belongsTo', + options: { async: false, inverse: 'bestFriend' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + bestFriend: { + data: { type: 'user', id: '2' }, + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + bestFriend: { + data: { type: 'user', id: '1' }, + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + bestFriend: { + data: null, + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + const resource = schema.resource({ type: 'user' }); + + const { counters, fieldOrder } = await reactiveContext.call(this, Rey, resource, { + bestFriend: 'name', + }); + + const nameIndex = fieldOrder.indexOf('name'); + const bestFriendIndex = fieldOrder.indexOf('bestFriend'); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.bestFriend, 1, 'bestFriendCount is 1'); + assert.strictEqual(Rey.bestFriend?.id, '2', 'id is accessible'); + assert.strictEqual(Rey.bestFriend?.name, 'Matt Seidel', 'name is accessible'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${bestFriendIndex + 1})`).hasText('bestFriend: Matt Seidel', 'name is rendered'); + + // remote update + store.push({ + data: { + type: 'user', + id: '1', + relationships: { + bestFriend: { + data: { type: 'user', id: '3' }, + }, + }, + }, + }); + + assert.strictEqual(Rey.bestFriend, Wes, 'Wes is now the bestFriend of Rey'); + assert.strictEqual(Wes.bestFriend, Rey, 'Rey is now the bestFriend of Wes'); + assert.strictEqual(Matt.bestFriend, null, 'Matt no longer has a bestFriend'); + assert.strictEqual(Rey.bestFriend?.id, '3', 'id is accessible'); + assert.strictEqual(Rey.bestFriend?.name, 'Wesley Thoburn', 'name is accessible'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.bestFriend, 2, 'bestFriendCount is 2'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${bestFriendIndex + 1})`).hasText('bestFriend: Wesley Thoburn', 'name is rendered'); + }); + + test('sync hasMany is reactive', async function (assert) { + type User = { + id: string | null; + $type: 'user'; + name: string; + bestFriend: User | null; + friends: User[]; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'friends', + type: 'user', + kind: 'hasMany', + options: { async: false, inverse: 'friends' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '2' }], + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '1' }], + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + const resource = schema.resource({ type: 'user' }); + + const { counters, fieldOrder } = await reactiveContext.call(this, Rey, resource, { + friends: 'name', + }); + + const nameIndex = fieldOrder.indexOf('name'); + const friendsIndex = fieldOrder.indexOf('friends'); + + assert.strictEqual(Rey.friends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(Matt.friends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(Wes.friends.length, 0, 'Wes has no friends :('); + assert.strictEqual(Rey.friends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(Matt.friends[0], Rey, 'Matt has Rey as a friend'); + assert.strictEqual(Wes.friends[0], undefined, 'Wes truly has no friends'); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.friends, 1, 'friendsCount is 1'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${friendsIndex + 1})`).hasText('friends: Matt Seidel', 'name is rendered'); + + // remote update + store.push({ + data: { + type: 'user', + id: '1', + relationships: { + friends: { + data: [{ type: 'user', id: '3' }], + }, + }, + }, + }); + + assert.strictEqual(Rey.friends.length, 1, 'Rey still has only one friend'); + assert.strictEqual(Matt.friends.length, 0, 'Matt now has no friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(Rey.friends[0], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(Wes.friends[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(Matt.friends[0], undefined, 'Matt has no friends'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.friends, 2, 'friendsCount is 2'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${friendsIndex + 1})`).hasText('friends: Wesley Thoburn', 'name is rendered'); + }); + + test('sync hasMany responds to updates', async function (assert) { + type User = { + id: string | null; + $type: 'user'; + name: string; + bestFriend: User | null; + friends: User[]; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'friends', + type: 'user', + kind: 'hasMany', + options: { async: false, inverse: 'friends' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '2' }], + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '1' }], + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + const resource = schema.resource({ type: 'user' }); + + const { counters, fieldOrder } = await reactiveContext.call(this, Rey, resource, { + friends: 'name', + }); + + const nameIndex = fieldOrder.indexOf('name'); + const friendsIndex = fieldOrder.indexOf('friends'); + + assert.strictEqual(Rey.friends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(Matt.friends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(Wes.friends.length, 0, 'Wes has no friends :('); + assert.strictEqual(Rey.friends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(Matt.friends[0], Rey, 'Matt has Rey as a friend'); + assert.strictEqual(Wes.friends[0], undefined, 'Wes truly has no friends'); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.friends, 1, 'friendsCount is 1'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${friendsIndex + 1})`).hasText('friends: Matt Seidel', 'name is rendered'); + + // remote update + store.push({ + data: { + type: 'user', + id: '1', + relationships: { + friends: { + data: [ + { type: 'user', id: '2' }, + { type: 'user', id: '3' }, + ], + }, + }, + }, + }); + + assert.strictEqual(Rey.friends.length, 2, 'Rey now has two friends'); + assert.strictEqual(Matt.friends.length, 1, 'Matt still has a friend'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(Rey.friends[0], Matt, 'Rey still has Matt as a friend'); + assert.strictEqual(Rey.friends[1], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(Wes.friends[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(Matt.friends[0], Rey, 'Matt still has friends'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.friends, 2, 'friendsCount is 2'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${friendsIndex + 1})`).hasText('friends: Matt Seidel, Wesley Thoburn', 'name is rendered'); + }); + + test('async belongsTo is reactive', async function (assert) { + type User = { + id: string; + name: string; + bestFriend: PromiseBelongsTo; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'bestFriend', + type: 'user', + kind: 'belongsTo', + options: { async: true, inverse: 'bestFriend' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + bestFriend: { + data: { type: 'user', id: '2' }, + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + bestFriend: { + data: { type: 'user', id: '1' }, + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + bestFriend: { + data: null, + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + const resource = schema.resource({ type: 'user' }); + + const { counters, fieldOrder } = await reactiveContext.call(this, Rey, resource, { + bestFriend: 'name', + }); + + const nameIndex = fieldOrder.indexOf('name'); + const bestFriendIndex = fieldOrder.indexOf('bestFriend'); + + const ReyBestFriend = await Rey.bestFriend; + const MattBestFriend = await Matt.bestFriend; + const WesBestFriend = await Wes.bestFriend; + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.bestFriend, 1, 'bestFriendCount is 1'); + assert.strictEqual(Rey.id, '1', 'id is accessible'); + assert.strictEqual(Rey.name, 'Rey Skybarker', 'name is accessible'); + assert.true(Rey.bestFriend instanceof PromiseBelongsTo, 'Rey has an async bestFriend'); + assert.true(Matt.bestFriend instanceof PromiseBelongsTo, 'Matt has an async bestFriend'); + assert.true(Wes.bestFriend instanceof PromiseBelongsTo, 'Wes has an async bestFriend'); + + assert.strictEqual(ReyBestFriend, Matt, 'Rey has Matt as bestFriend'); + assert.strictEqual(MattBestFriend, Rey, 'Matt has Rey as bestFriend'); + assert.strictEqual(WesBestFriend, null, 'Wes has no bestFriend'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${bestFriendIndex + 1})`).hasText('bestFriend: Matt Seidel', 'name is rendered'); + + // remote update + store.push({ + data: { + type: 'user', + id: '1', + relationships: { + bestFriend: { + data: { type: 'user', id: '3' }, + }, + }, + }, + }); + + const ReyBestFriend2 = await Rey.bestFriend; + const MattBestFriend2 = await Matt.bestFriend; + const WesBestFriend2 = await Wes.bestFriend; + + assert.strictEqual(ReyBestFriend2, Wes, 'Rey now has Wes as bestFriend'); + assert.strictEqual(MattBestFriend2, null, 'Matt now has no bestFriend'); + assert.strictEqual(WesBestFriend2, Rey, 'Wes is now the bestFriend of Rey'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.bestFriend, 2, 'bestFriendCount is 2'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${bestFriendIndex + 1})`).hasText('bestFriend: Wesley Thoburn', 'name is rendered'); + }); + + test('async hasMany is reactive', async function (assert) { + type User = { + id: string; + name: string; + friends: PromiseManyArray; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'friends', + type: 'user', + kind: 'hasMany', + options: { async: true, inverse: 'friends' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '2' }], + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '1' }], + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + const resource = schema.resource({ type: 'user' }); + + const { counters, fieldOrder } = await reactiveContext.call(this, Rey, resource, { + friends: 'name', + }); + + const nameIndex = fieldOrder.indexOf('name'); + const friendsIndex = fieldOrder.indexOf('friends'); + + const ReyFriends = await Rey.friends; + const MattFriends = await Matt.friends; + const WesFriends = await Wes.friends; + + assert.strictEqual(Rey.friends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(Matt.friends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(Wes.friends.length, 0, 'Wes has no friends :('); + assert.strictEqual(ReyFriends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(MattFriends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(WesFriends.length, 0, 'Wes has no friends'); + assert.strictEqual(ReyFriends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(MattFriends[0], Rey, 'Matt has Rey as a friend'); + assert.strictEqual(WesFriends[0], undefined, 'Rey really has no friends'); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.friends, 1, 'friendsCount is 1'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${friendsIndex + 1})`).hasText('friends: Matt Seidel', 'name is rendered'); + + // remote update + store.push({ + data: { + type: 'user', + id: '1', + relationships: { + friends: { + data: [{ type: 'user', id: '3' }], + }, + }, + }, + }); + + assert.strictEqual(Rey.friends.length, 1, 'Rey still has only one friend'); + assert.strictEqual(Matt.friends.length, 0, 'Matt now has no friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(ReyFriends[0], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(WesFriends[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(MattFriends[0], undefined, 'Matt has no friends'); + + const ReyFriends2 = await Rey.friends; + const MattFriends2 = await Matt.friends; + const WesFriends2 = await Wes.friends; + + assert.strictEqual(Rey.friends.length, 1, 'Rey still has only one friend'); + assert.strictEqual(Matt.friends.length, 0, 'Matt now has no friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(ReyFriends2[0], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(WesFriends2[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(MattFriends2[0], undefined, 'Matt has no friends'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.friends, 2, 'friendsCount is 2'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${friendsIndex + 1})`).hasText('friends: Wesley Thoburn', 'name is rendered'); + }); + + test('async hasMany responds to updates', async function (assert) { + type User = { + id: string; + name: string; + friends: PromiseManyArray; + [Type]: 'user'; + }; + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerLegacyDerivations(schema); + + schema.registerResource( + withLegacy({ + type: 'user', + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'friends', + type: 'user', + kind: 'hasMany', + options: { async: true, inverse: 'friends' }, + }, + ], + }) + ); + + const Rey = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '2' }], + }, + }, + }, + included: [ + { + type: 'user', + id: '2', + attributes: { + name: 'Matt Seidel', + }, + relationships: { + friends: { + data: [{ type: 'user', id: '1' }], + }, + }, + }, + { + type: 'user', + id: '3', + attributes: { + name: 'Wesley Thoburn', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + ], + }); + const Matt = store.peekRecord('user', '2')!; + const Wes = store.peekRecord('user', '3')!; + const resource = schema.resource({ type: 'user' }); + + const { counters, fieldOrder } = await reactiveContext.call(this, Rey, resource, { + friends: 'name', + }); + + const nameIndex = fieldOrder.indexOf('name'); + const friendsIndex = fieldOrder.indexOf('friends'); + + const ReyFriends = await Rey.friends; + const MattFriends = await Matt.friends; + const WesFriends = await Wes.friends; + + assert.strictEqual(Rey.friends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(Matt.friends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(Wes.friends.length, 0, 'Wes has no friends :('); + assert.strictEqual(ReyFriends.length, 1, 'Rey has only one friend :('); + assert.strictEqual(MattFriends.length, 1, 'Matt has only one friend :('); + assert.strictEqual(WesFriends.length, 0, 'Wes has no friends'); + assert.strictEqual(ReyFriends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(MattFriends[0], Rey, 'Matt has Rey as a friend'); + assert.strictEqual(WesFriends[0], undefined, 'Rey really has no friends'); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.friends, 1, 'friendsCount is 1'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${friendsIndex + 1})`).hasText('friends: Matt Seidel', 'name is rendered'); + + // remote update + store.push({ + data: { + type: 'user', + id: '1', + relationships: { + friends: { + data: [ + { type: 'user', id: '2' }, + { type: 'user', id: '3' }, + ], + }, + }, + }, + }); + + assert.strictEqual(Rey.friends.length, 2, 'Rey now has two friends'); + assert.strictEqual(Matt.friends.length, 1, 'Matt still has friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(ReyFriends[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(ReyFriends[1], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(WesFriends[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(MattFriends[0], Rey, 'Matt has friends'); + + const ReyFriends2 = await Rey.friends; + const MattFriends2 = await Matt.friends; + const WesFriends2 = await Wes.friends; + + assert.strictEqual(Rey.friends.length, 2, 'Rey now has 2 frienda'); + assert.strictEqual(Matt.friends.length, 1, 'Matt still has friends'); + assert.strictEqual(Wes.friends.length, 1, 'Wes now has one friend :)'); + assert.strictEqual(ReyFriends2[0], Matt, 'Rey has Matt as a friend'); + assert.strictEqual(ReyFriends2[1], Wes, 'Rey has Wes as a friend'); + assert.strictEqual(WesFriends2[0], Rey, 'Wes has Rey as a friend'); + assert.strictEqual(MattFriends2[0], Rey, 'Matt has friends'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.friends, 2, 'friendsCount is 2'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${friendsIndex + 1})`).hasText('friends: Matt Seidel, Wesley Thoburn', 'name is rendered'); + }); +}); From 5bac3040138aaae07c15077f9b9724bef973a6bc Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Sat, 1 Jun 2024 19:01:34 -0700 Subject: [PATCH 5/5] fix lint --- .../schema-record/src/-private/compute.ts | 8 ++--- packages/schema-record/src/record.ts | 29 ++++++++++--------- .../tests/-utils/reactive-context.ts | 2 ++ .../tests/legacy/create/relationships-test.ts | 1 - .../legacy/reactivity/relationships-test.ts | 7 +++-- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/schema-record/src/-private/compute.ts b/packages/schema-record/src/-private/compute.ts index 63c231b0b39..a467cdc6285 100644 --- a/packages/schema-record/src/-private/compute.ts +++ b/packages/schema-record/src/-private/compute.ts @@ -1,5 +1,3 @@ -import { dependencySatisfies, importSync } from '@embroider/macros'; - import type { Future } from '@ember-data/request'; import type Store from '@ember-data/store'; import type { StoreRequestInput } from '@ember-data/store'; @@ -22,11 +20,11 @@ import type { import type { Link, Links } from '@warp-drive/core-types/spec/json-api-raw'; import { RecordStore } from '@warp-drive/core-types/symbols'; -import { ManagedArray } from './managed-array'; -import { ManagedObject } from './managed-object'; +import type { SchemaRecord } from '../record'; import type { SchemaService } from '../schema'; import { Identifier, Parent } from '../symbols'; -import { SchemaRecord } from '../record'; +import { ManagedArray } from './managed-array'; +import { ManagedObject } from './managed-object'; export const ManagedArrayMap = getOrSetGlobal( 'ManagedArrayMap', diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index 98b013a2cd0..63e8822cd2e 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -11,6 +11,19 @@ import { STRUCTURED } from '@warp-drive/core-types/request'; import type { FieldSchema } from '@warp-drive/core-types/schema/fields'; import { RecordStore } from '@warp-drive/core-types/symbols'; +import { + computeArray, + computeAttribute, + computeDerivation, + computeField, + computeLocal, + computeObject, + computeResource, + ManagedArrayMap, + ManagedObjectMap, + peekManagedArray, + peekManagedObject, +} from './-private/compute'; import type { SchemaService } from './schema'; import { ARRAY_SIGNAL, @@ -24,19 +37,6 @@ import { OBJECT_SIGNAL, Parent, } from './symbols'; -import { - ManagedArrayMap, - ManagedObjectMap, - computeArray, - computeAttribute, - computeDerivation, - computeField, - computeLocal, - computeObject, - computeResource, - peekManagedArray, - peekManagedObject, -} from './-private/compute'; const HAS_MODEL_PACKAGE = dependencySatisfies('@ember-data/model', '*'); const getLegacySupport = HAS_MODEL_PACKAGE @@ -411,7 +411,7 @@ export class SchemaRecord { const support = getLegacySupport(receiver as unknown as MinimalLegacyRecord); const manyArray = support.getManyArray(field.name); - manyArray.splice(0, manyArray.length, ...value); + manyArray.splice(0, manyArray.length, ...(value as unknown[])); }); return true; @@ -468,6 +468,7 @@ export class SchemaRecord { case 'relationships': if (key) { if (Array.isArray(key)) { + // FIXME } else { if (isEmbedded) return; // base paths never apply to embedded records diff --git a/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts b/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts index 564f4d70df3..7bd964939f6 100644 --- a/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts +++ b/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts @@ -67,6 +67,7 @@ export async function reactiveContext( if (field.options.async) { // @ts-expect-error promise proxy reach through + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access value = record[field.name].content as { [key: string]: string }; } @@ -81,6 +82,7 @@ export async function reactiveContext( if (field.options.async) { // @ts-expect-error promise proxy reach through + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access arr = record[field.name].content as Array<{ [key: string]: string }>; } diff --git a/tests/warp-drive__schema-record/tests/legacy/create/relationships-test.ts b/tests/warp-drive__schema-record/tests/legacy/create/relationships-test.ts index abd29232d19..3b94102124c 100644 --- a/tests/warp-drive__schema-record/tests/legacy/create/relationships-test.ts +++ b/tests/warp-drive__schema-record/tests/legacy/create/relationships-test.ts @@ -2,7 +2,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; -import { PromiseBelongsTo, PromiseManyArray } from '@ember-data/model/-private'; import { registerDerivations as registerLegacyDerivations, withDefaults as withLegacy, diff --git a/tests/warp-drive__schema-record/tests/legacy/reactivity/relationships-test.ts b/tests/warp-drive__schema-record/tests/legacy/reactivity/relationships-test.ts index 5a6b0e5e8ef..0f9cbcbc523 100644 --- a/tests/warp-drive__schema-record/tests/legacy/reactivity/relationships-test.ts +++ b/tests/warp-drive__schema-record/tests/legacy/reactivity/relationships-test.ts @@ -1,8 +1,11 @@ +import { rerender } from '@ember/test-helpers'; + import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { PromiseBelongsTo, PromiseManyArray } from '@ember-data/model/-private'; +import type { PromiseManyArray } from '@ember-data/model/-private'; +import { PromiseBelongsTo } from '@ember-data/model/-private'; import { registerDerivations as registerLegacyDerivations, withDefaults as withLegacy, @@ -10,8 +13,8 @@ import { import type { Type } from '@warp-drive/core-types/symbols'; import type Store from 'warp-drive__schema-record/services/store'; + import { reactiveContext } from '../../-utils/reactive-context'; -import { rerender } from '@ember/test-helpers'; module('Legacy | Reactivity | relationships', function (hooks) { setupRenderingTest(hooks);