Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat (private): implement resource relationships for SchemaRecord #8946

Merged
merged 2 commits into from
Oct 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
});
});
Loading