diff --git a/packages/schema-record/rollup.config.mjs b/packages/schema-record/rollup.config.mjs index 91ece180929..efab9538ee8 100644 --- a/packages/schema-record/rollup.config.mjs +++ b/packages/schema-record/rollup.config.mjs @@ -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({ diff --git a/packages/schema-record/src/hooks.ts b/packages/schema-record/src/hooks.ts new file mode 100644 index 00000000000..04f12f3ec0c --- /dev/null +++ b/packages/schema-record/src/hooks.ts @@ -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): 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](); +} diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts new file mode 100644 index 00000000000..bfc1aaf7d58 --- /dev/null +++ b/packages/schema-record/src/record.ts @@ -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 {} +} diff --git a/packages/schema-record/src/index.ts b/packages/schema-record/src/schema.ts similarity index 50% rename from packages/schema-record/src/index.ts rename to packages/schema-record/src/schema.ts index 409a7a28d4c..69f90cd1e82 100644 --- a/packages/schema-record/src/index.ts +++ b/packages/schema-record/src/schema.ts @@ -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'); @@ -28,19 +28,27 @@ export type Transform = { defaultValue?(options: Record | null, identifier: StableRecordIdentifier): T; }; +export type Derivation = (record: R, options: Record | null, prop: string) => T; + export class SchemaService { declare schemas: Map; declare transforms: Map; + declare derivations: Map>; constructor() { this.schemas = new Map(); this.transforms = new Map(); + this.derivations = new Map(); } registerTransform(type: string, transform: Transform): void { this.transforms.set(type, transform); } + registerDerivation(type: string, derivation: Derivation): void { + this.derivations.set(type, derivation as Derivation); + } + defineSchema(name: string, fields: FieldSchema[]): void { const fieldSpec: FieldSpec = { attributes: {}, @@ -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}`); } }); @@ -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): 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](); -} diff --git a/tests/schema-record/app/services/store.ts b/tests/schema-record/app/services/store.ts index 399ed487c22..36ef09eb542 100644 --- a/tests/schema-record/app/services/store.ts +++ b/tests/schema-record/app/services/store.ts @@ -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'; diff --git a/tests/schema-record/tests/reads/basic-fields-test.ts b/tests/schema-record/tests/reads/basic-fields-test.ts index fac4ecc4c0e..1f711aefeff 100644 --- a/tests/schema-record/tests/reads/basic-fields-test.ts +++ b/tests/schema-record/tests/reads/basic-fields-test.ts @@ -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'; diff --git a/tests/schema-record/tests/reads/derivation-test.ts b/tests/schema-record/tests/reads/derivation-test.ts new file mode 100644 index 00000000000..9d6cb18684a --- /dev/null +++ b/tests/schema-record/tests/reads/derivation-test.ts @@ -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 | 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'); + }); +});