Skip to content

Commit

Permalink
feat (private): implement support for derivations (#8939)
Browse files Browse the repository at this point in the history
  • Loading branch information
runspired committed Sep 29, 2023
1 parent 4cf1a16 commit 8028693
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 100 deletions.
2 changes: 1 addition & 1 deletion packages/schema-record/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default {
plugins: [
// These are the modules that users should be able to import from your
// addon. Anything not listed here may get optimized away.
addon.publicEntrypoints(['index.js']),
addon.publicEntrypoints(['hooks.js', 'index.js', 'record.js', 'schema.js']),

nodeResolve({ extensions: ['.ts'] }),
babel({
Expand Down
17 changes: 17 additions & 0 deletions packages/schema-record/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type Store from '@ember-data/store';
import type { StableRecordIdentifier } from "@ember-data/types/q/identifier";
import { Destroy, SchemaRecord } from './record';

export function instantiateRecord(store: Store, identifier: StableRecordIdentifier, createArgs?: Record<string, unknown>): SchemaRecord {
if (createArgs) {
const editable = new SchemaRecord(store, identifier, true);
Object.assign(editable, createArgs);
return editable;
}

return new SchemaRecord(store, identifier, false);
}

export function teardownRecord(record: SchemaRecord): void {
record[Destroy]();
}
110 changes: 110 additions & 0 deletions packages/schema-record/src/record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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';

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

function computeAttribute(schema: SchemaService, cache: Cache, record: SchemaRecord, identifier: StableRecordIdentifier, field: FieldSchema, prop: string): unknown {
const rawValue = cache.getAttr(identifier, prop);
if (field.type === null) {
return rawValue;
}
const transform = schema.transforms.get(field.type);
if (!transform) {
throw new Error(`No '${field.type}' transform defined for use by ${identifier.type}.${String(prop)}`);
}
return transform.hydrate(rawValue, field.options ?? null, record);
}

function computeDerivation(schema: SchemaService, record: SchemaRecord, identifier: StableRecordIdentifier, field: FieldSchema, prop: string): unknown {
if (field.type === null) {
throw new Error(`The schema for ${identifier.type}.${String(prop)} is missing the type of the derivation`);
}

const derivation = schema.derivations.get(field.type);
if (!derivation) {
throw new Error(`No '${field.type}' derivation defined for use by ${identifier.type}.${String(prop)}`);
}
return derivation(record, field.options ?? null, prop);
}

export class SchemaRecord {
declare [RecordStore]: Store;
declare [Identifier]: StableRecordIdentifier;
declare [Editable]: boolean;

constructor(store: Store, identifier: StableRecordIdentifier, editable: boolean) {
this[RecordStore] = store;
this[Identifier] = identifier;
this[Editable] = editable;

const schema = store.schema as unknown as SchemaService;
const cache = store.cache;
const fields = schema.fields(identifier);

return new Proxy(this, {
get(target, prop, receiver) {
if (prop === Destroy) {
return target[Destroy];
}

if (prop === 'id') {
return identifier.id;
}
if (prop === '$type') {
return identifier.type;
}
const field = fields.get(prop as string);
if (!field) {
throw new Error(`No field named ${String(prop)} on ${identifier.type}`);
}

switch (field.kind) {
case 'attribute':
return computeAttribute(schema, cache, target, identifier, field, prop as string);
case 'derived':
return computeDerivation(schema, receiver, identifier, field, prop as string);
default:
throw new Error(`Field '${String(prop)}' on '${identifier.type}' has the unknown kind '${field.kind}'`);
}

},
set(target, prop, value) {
if (!target[Editable]) {
throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because the record is not editable`);
}

const field = fields.get(prop as string);
if (!field) {
throw new Error(`There is no field named ${String(prop)} on ${identifier.type}`);
}

if (field.kind === 'attribute') {
if (field.type === null) {
cache.setAttr(identifier, prop as string, value);
return true;
}
const transform = schema.transforms.get(field.type);

if (!transform) {
throw new Error(`No '${field.type}' transform defined for use by ${identifier.type}.${String(prop)}`);
}

const rawValue = transform.serialize(value, field.options ?? null, target);
cache.setAttr(identifier, prop as string, rawValue);
return true;
} else if (field.kind === 'derived') {
throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because it is derived`);
}

throw new Error(`Unknown field kind ${field.kind}`);
},
});
}

[Destroy](): void {}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type Store from '@ember-data/store';
import type { StableRecordIdentifier } from "@ember-data/types/q/identifier";
import type { AttributeSchema, RelationshipSchema } from '@ember-data/types/q/record-data-schemas';
import type { SchemaRecord } from "./record";

export const Destroy = Symbol('Destroy');
export const RecordStore = Symbol('Store');
Expand Down Expand Up @@ -28,19 +28,27 @@ export type Transform<T = unknown, PT = unknown> = {
defaultValue?(options: Record<string, unknown> | null, identifier: StableRecordIdentifier): T;
};

export type Derivation<R, T> = (record: R, options: Record<string, unknown> | null, prop: string) => T;

export class SchemaService {
declare schemas: Map<string, FieldSpec>;
declare transforms: Map<string, Transform>;
declare derivations: Map<string, Derivation<unknown, unknown>>;

constructor() {
this.schemas = new Map();
this.transforms = new Map();
this.derivations = new Map();
}

registerTransform<T = unknown, PT = unknown>(type: string, transform: Transform<T, PT>): void {
this.transforms.set(type, transform);
}

registerDerivation<R, T>(type: string, derivation: Derivation<R, T>): void {
this.derivations.set(type, derivation as Derivation<unknown, unknown>);
}

defineSchema(name: string, fields: FieldSchema[]): void {
const fieldSpec: FieldSpec = {
attributes: {},
Expand All @@ -58,7 +66,7 @@ export class SchemaService {
kind: field.kind === 'resource' ? 'belongsTo' : 'hasMany',
}) as unknown as RelationshipSchema;
fieldSpec.relationships[field.name] = relSchema;
} else {
} else if (field.kind !== 'derived') {
throw new Error(`Unknown field kind ${field.kind}`);
}
});
Expand Down Expand Up @@ -100,96 +108,3 @@ export class SchemaService {
return this.schemas.has(type);
}
}

export class SchemaRecord {
declare [RecordStore]: Store;
declare [Identifier]: StableRecordIdentifier;
declare [Editable]: boolean;

constructor(store: Store, identifier: StableRecordIdentifier, editable: boolean) {
this[RecordStore] = store;
this[Identifier] = identifier;
this[Editable] = editable;

const schema = store.schema as unknown as SchemaService;
const cache = store.cache;
const fields = schema.fields(identifier);

return new Proxy(this, {
get(target, prop) {
if (prop === Destroy) {
return target[Destroy];
}

if (prop === 'id') {
return identifier.id;
}
if (prop === '$type') {
return identifier.type;
}
const field = fields.get(prop as string);
if (!field) {
throw new Error(`No field named ${String(prop)} on ${identifier.type}`);
}

if (field.kind === 'attribute') {
const rawValue = cache.getAttr(identifier, prop as string);
if (field.type === null) {
return rawValue;
}
const transform = schema.transforms.get(field.type);
if (!transform) {
throw new Error(`No '${field.type}' transform defined for use by ${identifier.type}.${String(prop)}`);
}
return transform.hydrate(rawValue, field.options ?? null, target);
}

throw new Error(`Unknown field kind ${field.kind}`);
},
set(target, prop, value) {
if (!target[Editable]) {
throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because the record is not editable`);
}

const field = fields.get(prop as string);
if (!field) {
throw new Error(`There is no field named ${String(prop)} on ${identifier.type}`);
}

if (field.kind === 'attribute') {
if (field.type === null) {
cache.setAttr(identifier, prop as string, value);
return true;
}
const transform = schema.transforms.get(field.type);

if (!transform) {
throw new Error(`No '${field.type}' transform defined for use by ${identifier.type}.${String(prop)}`);
}

const rawValue = transform.serialize(value, field.options ?? null, target);
cache.setAttr(identifier, prop as string, rawValue);
return true;
}

throw new Error(`Unknown field kind ${field.kind}`);
},
});
}

[Destroy](): void {}
}

export function instantiateRecord(store: Store, identifier: StableRecordIdentifier, createArgs?: Record<string, unknown>): SchemaRecord {
if (createArgs) {
const editable = new SchemaRecord(store, identifier, true);
Object.assign(editable, createArgs);
return editable;
}

return new SchemaRecord(store, identifier, false);
}

export function teardownRecord(record: SchemaRecord): void {
record[Destroy]();
}
4 changes: 2 additions & 2 deletions tests/schema-record/app/services/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { SchemaRecord } from '@warp-drive/schema-record';
import { instantiateRecord, teardownRecord } from '@warp-drive/schema-record';
import { instantiateRecord, teardownRecord } from '@warp-drive/schema-record/hooks';
import type { SchemaRecord } from '@warp-drive/schema-record/record';

import JSONAPICache from '@ember-data/json-api';
import RequestManager from '@ember-data/request';
Expand Down
5 changes: 3 additions & 2 deletions tests/schema-record/tests/reads/basic-fields-test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SchemaRecord, Transform } from '@warp-drive/schema-record';
import { SchemaService } from '@warp-drive/schema-record';
import type { SchemaRecord } from '@warp-drive/schema-record/record';
import type { Transform } from '@warp-drive/schema-record/schema';
import { SchemaService } from '@warp-drive/schema-record/schema';
import { module, test } from 'qunit';

import { setupTest } from 'ember-qunit';
Expand Down
65 changes: 65 additions & 0 deletions tests/schema-record/tests/reads/derivation-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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';

interface User {
id: string | null;
$type: 'user';
firstName: string;
lastName: string;
readonly fullName: string;
}

module('Reads | derivation', 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: 'firstName',
type: null,
kind: 'attribute',
},
{
name: 'lastName',
type: null,
kind: 'attribute',
},
{
name: 'fullName',
type: 'concat',
options: { fields: ['firstName', 'lastName'], separator: ' ' },
kind: 'derived',
},
]);

const record = store.createRecord('user', { firstName: 'Rey', lastName: 'Skybarker' }) as User;

assert.strictEqual(record.id, null, 'id is accessible');
assert.strictEqual(record.$type, 'user', '$type is accessible');

assert.strictEqual(record.firstName, 'Rey', 'firstName is accessible');
assert.strictEqual(record.lastName, 'Skybarker', 'lastName is accessible');
assert.strictEqual(record.fullName, 'Rey Skybarker', 'fullName is accessible');
});
});

0 comments on commit 8028693

Please sign in to comment.