Skip to content

Commit

Permalink
feat (private): implement resource relationships for SchemaRecord (#8946
Browse files Browse the repository at this point in the history
)

* feat (private): implement resource relationships for SchemaRecord

* more improvements
  • Loading branch information
runspired authored Oct 1, 2023
1 parent a8c23f5 commit 0b14fda
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 23 deletions.
4 changes: 2 additions & 2 deletions ember-data-types/q/ember-data-json-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export type Meta = Record<string, JSONValue>;
export type LinkObject = { href: string; meta?: Record<string, JSONValue> };
export type Link = string | LinkObject;
export interface Links {
related?: Link;
self?: Link;
related?: Link | null;
self?: Link | null;
}
export interface PaginationLinks extends Links {
first?: Link | null;
Expand Down
81 changes: 81 additions & 0 deletions packages/schema-record/src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ import type Store from '@ember-data/store';
import type { StableRecordIdentifier } from "@ember-data/types/q/identifier";
import type { FieldSchema, SchemaService } from './schema';
import { Cache } from '@ember-data/types/q/cache';
import { Document } from '@ember-data/store/-private/document';
import { tracked } from '@glimmer/tracking';
import { Link, Links, SingleResourceRelationship } from '@ember-data/types/q/ember-data-json-api';
import { StoreRequestInput } from '@ember-data/store/-private/cache-handler';
import { Future } from '@ember-data/request';
import { DEBUG } from '@ember-data/env';

export const Destroy = Symbol('Destroy');
export const RecordStore = Symbol('Store');
export const Identifier = Symbol('Identifier');
export const Editable = Symbol('Editable');
export const Parent = Symbol('Parent');
export const Checkout = Symbol('Checkout');

function computeAttribute(schema: SchemaService, cache: Cache, record: SchemaRecord, identifier: StableRecordIdentifier, field: FieldSchema, prop: string): unknown {
const rawValue = cache.getAttr(identifier, prop);
Expand All @@ -32,6 +40,72 @@ function computeDerivation(schema: SchemaService, record: SchemaRecord, identifi
return derivation(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<T extends SchemaRecord = SchemaRecord> {
declare lid: string;
declare [Parent]: SchemaRecord;
declare [RecordStore]: Store;
declare name: string;

@tracked declare data: T | null;
@tracked declare links: Links;
@tracked declare meta: Record<string, unknown>;

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 put lid on the relationship
this.lid = rawValue.lid ?? rawValue.links?.self ?? `relationship:${identifier.lid}.${name}`;
this.data = rawValue.data ? store.peekRecord<T>(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<T> {
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<T>(request);
}
}

function getHref(link?: Link | null): string | null {
if (!link) {
return null;
}
if (typeof link === 'string') {
return link;
}
return link.href;
}

function computeResource<T extends SchemaRecord>(store: Store, cache: Cache, parent: SchemaRecord, identifier: StableRecordIdentifier, field: FieldSchema, prop: string): ResourceRelationship<T> {
if (field.kind !== 'resource') {
throw new Error(`The schema for ${identifier.type}.${String(prop)} is not a resource relationship`);
}

return new ResourceRelationship<T>(store, cache, parent, identifier, field, prop);
}

export class SchemaRecord {
declare [RecordStore]: Store;
declare [Identifier]: StableRecordIdentifier;
Expand All @@ -52,6 +126,7 @@ export class SchemaRecord {
return target[Destroy];
}

// _, $, *
if (prop === 'id') {
return identifier.id;
}
Expand All @@ -66,6 +141,9 @@ export class SchemaRecord {
switch (field.kind) {
case 'attribute':
return computeAttribute(schema, cache, target, identifier, field, prop as string);
case 'resource':
return computeResource(store, cache, target, identifier, field, prop as string);

case 'derived':
return computeDerivation(schema, receiver, identifier, field, prop as string);
default:
Expand Down Expand Up @@ -107,4 +185,7 @@ export class SchemaRecord {
}

[Destroy](): void {}
[Checkout](): Promise<SchemaRecord> {
return Promise.resolve(this);
}
}
10 changes: 5 additions & 5 deletions packages/store/src/-private/store-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1300,15 +1300,15 @@ class Store extends EmberObject {
@param {String|Integer} id - optional only if the first param is a ResourceIdentifier, else the string id of the record to be retrieved.
@return {Model|null} record
*/
peekRecord(identifier: string, id: string | number): RecordInstance | null;
peekRecord(identifier: ResourceIdentifierObject): RecordInstance | null;
peekRecord(identifier: ResourceIdentifierObject | string, id?: string | number): RecordInstance | null {
peekRecord<T = RecordInstance>(identifier: string, id: string | number): T | null;
peekRecord<T = RecordInstance>(identifier: ResourceIdentifierObject): T | null;
peekRecord<T = RecordInstance>(identifier: ResourceIdentifierObject | string, id?: string | number): T | null {
if (arguments.length === 1 && isMaybeIdentifier(identifier)) {
const stableIdentifier = this.identifierCache.peekRecordIdentifier(identifier);
const isLoaded = stableIdentifier && this._instanceCache.recordIsLoaded(stableIdentifier);
// TODO come up with a better mechanism for determining if we have data and could peek.
// this is basically an "are we not empty" query.
return isLoaded ? this._instanceCache.getRecord(stableIdentifier) : null;
return isLoaded ? (this._instanceCache.getRecord(stableIdentifier) as T) : null;
}

if (DEBUG) {
Expand All @@ -1329,7 +1329,7 @@ class Store extends EmberObject {
const stableIdentifier = this.identifierCache.peekRecordIdentifier(resource);
const isLoaded = stableIdentifier && this._instanceCache.recordIsLoaded(stableIdentifier);

return isLoaded ? this._instanceCache.getRecord(stableIdentifier) : null;
return isLoaded ? (this._instanceCache.getRecord(stableIdentifier) as T) : null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
url: '/assets/users/1.json',
});
const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
const data = userDocument.content.data;

assert.strictEqual(record?.name, 'Chris Thoburn', 'record name is correct');
Expand Down Expand Up @@ -186,7 +186,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
url: '/assets/users/1.json',
});
const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
const data = userDocument.content.data;

assert.strictEqual(record?.name, 'Chris Thoburn', 'record name is correct');
Expand Down Expand Up @@ -324,7 +324,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
url: '/assets/users/1.json',
});
const identifier = recordIdentifierFor(userDocument.content.data);
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
assert.strictEqual(record?.name, 'Chris Thoburn');
assert.strictEqual(userDocument.content.data, record, 'we get a hydrated record back as data');

Expand Down Expand Up @@ -441,7 +441,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
'we get access to the document meta'
);

const record = store.peekRecord(userDocument.content.data!) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(userDocument.content.data!);
assert.strictEqual(record?.name, 'Chris Thoburn');
});

Expand Down Expand Up @@ -601,7 +601,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
url: '/assets/users/1.json',
});
const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
const data = userDocument.content.data;

assert.strictEqual(record?.name, 'Chris Thoburn', '<Initial> record name is correct');
Expand Down Expand Up @@ -657,7 +657,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {

const data3 = userDocument2.content.data;
const identifier2 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' });
const record2 = store.peekRecord(identifier2) as FakeRecord | null;
const record2 = store.peekRecord<FakeRecord | null>(identifier2);

assert.strictEqual(record2?.name, 'Wesley Thoburn', '<Updated> record2 name is correct');
assert.strictEqual(userDocument.content, userDocument2.content, '<Updated> documents are the same');
Expand Down Expand Up @@ -742,7 +742,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
url: '/assets/users/1.json',
});
const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
const data = userDocument.content.data;

assert.strictEqual(record?.name, 'Chris Thoburn', '<Initial> record name is correct');
Expand Down Expand Up @@ -800,7 +800,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {

// Assert the initial document was updated
const identifier2 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' });
const record2 = store.peekRecord(identifier2) as FakeRecord | null;
const record2 = store.peekRecord<FakeRecord | null>(identifier2);

assert.strictEqual(handlerCalls, 2, 'fetch handler should only be called twice');
assert.strictEqual(record2?.name, 'Wesley Thoburn', 'record2 name is correct');
Expand Down Expand Up @@ -893,7 +893,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
type: 'user',
id: '1',
}) as StableExistingRecordIdentifier;
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
const data = userDocument.content.data!;

assert.strictEqual(record?.name, 'Chris Thoburn', '<Initial> record name is correct');
Expand Down Expand Up @@ -1021,7 +1021,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
url: '/assets/users/list.json',
});
const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
const data = userDocument.content.data!;

assert.strictEqual(record?.name, 'Chris Thoburn', 'record name is correct');
Expand Down Expand Up @@ -1135,7 +1135,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
url: '/assets/users/list.json',
});
const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
const data = userDocument.content.data!;

assert.strictEqual(record?.name, 'Chris Thoburn', 'record name is correct');
Expand Down Expand Up @@ -1194,7 +1194,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
await store._getAllPending();

const identifier2 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' });
const record2 = store.peekRecord(identifier2) as FakeRecord | null;
const record2 = store.peekRecord<FakeRecord | null>(identifier2);

assert.strictEqual(record2?.name, 'Wesley Thoburn', 'record2 name is correct');
assert.strictEqual(data.length, 2, 'recordArray has two records');
Expand Down Expand Up @@ -1285,7 +1285,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
});
const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });
const data = userDocument.content.data!;
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);

assert.strictEqual(record?.name, 'Chris Thoburn', 'record name is correct');
assert.true(Array.isArray(data), 'recordArray was returned as data');
Expand Down Expand Up @@ -1347,7 +1347,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {

// Assert the initial document was updated
const identifier2 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' });
const record2 = store.peekRecord(identifier2) as FakeRecord | null;
const record2 = store.peekRecord<FakeRecord | null>(identifier2);

assert.strictEqual(handlerCalls, 2, 'fetch handler should only be called twice');
assert.strictEqual(record2?.name, 'Wesley Thoburn', 'record2 name is correct');
Expand Down Expand Up @@ -1993,7 +1993,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
request.abort();

const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
const data = userDocument.content.data;

assert.strictEqual(record?.name, 'Chris Thoburn', 'record name is correct');
Expand Down Expand Up @@ -2079,7 +2079,7 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) {
url: '/assets/users/1.json',
});
const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });
const record = store.peekRecord(identifier) as FakeRecord | null;
const record = store.peekRecord<FakeRecord | null>(identifier);
const data = userDocument.content.data;

assert.strictEqual(record?.name, 'Chris Thoburn', '<Initial> record name is correct');
Expand Down
87 changes: 87 additions & 0 deletions tests/schema-record/tests/reads/resource-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { SchemaRecord } from '@warp-drive/schema-record/record';
import { SchemaService } from '@warp-drive/schema-record/schema';
import { module, test } from 'qunit';

import { setupTest } from 'ember-qunit';

import type Store from '@ember-data/store';
import { Document } from '@ember-data/store/-private/document';

interface User {
id: string | null;
$type: 'user';
name: string;
bestFriend: Document<User | null>;
}

module('Reads | resource', function (hooks) {
setupTest(hooks);

test('we can use simple fields with no `type`', function (assert) {
const store = this.owner.lookup('service:store') as Store;
const schema = new SchemaService();
store.registerSchema(schema);

function concat(
record: SchemaRecord & { [key: string]: unknown },
options: Record<string, unknown> | null,
_prop: string
): string {
if (!options) throw new Error(`options is required`);
const opts = options as { fields: string[]; separator?: string };
return opts.fields.map((field) => record[field]).join(opts.separator ?? '');
}

schema.registerDerivation('concat', concat);

schema.defineSchema('user', [
{
name: 'name',
type: null,
kind: 'attribute',
},
{
name: 'bestFriend',
type: 'user',
kind: 'resource',
options: { inverse: 'bestFriend', async: true },
},
]);

const record = store.push({
data: {
type: 'user',
id: '1',
attributes: {
name: 'Chris',
},
relationships: {
bestFriend: {
data: { type: 'user', id: '2' },
},
},
},
included: [
{
type: 'user',
id: '2',
attributes: {
name: 'Rey',
},
relationships: {
bestFriend: {
data: { type: 'user', id: '1' },
},
},
},
],
}) as User;

assert.strictEqual(record.id, '1', 'id is accessible');
assert.strictEqual(record.$type, 'user', '$type is accessible');
assert.strictEqual(record.name, 'Chris', 'name is accessible');
assert.strictEqual(record.bestFriend.data?.id, '2', 'bestFriend.id is accessible');
assert.strictEqual(record.bestFriend.data?.$type, 'user', 'bestFriend.user is accessible');
assert.strictEqual(record.bestFriend.data?.name, 'Rey', 'bestFriend.name is accessible');
});
});

0 comments on commit 0b14fda

Please sign in to comment.