From e8fc3ea6a0d35cad937128f0b4bfd3e4524974dd Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Sun, 1 Sep 2024 13:32:41 -0700 Subject: [PATCH] feat: adds AliasField to allowed schema fields (#9531) * initial spike * fix schema-record ManagedObject writes * add tests * fix prettier --- packages/core-types/src/schema/fields.ts | 52 ++++++ .../schema-record/src/-private/compute.ts | 30 ++-- .../src/-private/managed-object.ts | 156 +++++------------- packages/schema-record/src/record.ts | 39 +++-- .../tests/reads/alias-test.ts | 116 +++++++++++++ .../tests/writes/object-test.ts | 3 +- 6 files changed, 256 insertions(+), 140 deletions(-) create mode 100644 tests/warp-drive__schema-record/tests/reads/alias-test.ts diff --git a/packages/core-types/src/schema/fields.ts b/packages/core-types/src/schema/fields.ts index 52fb1e70485..22b485841f2 100644 --- a/packages/core-types/src/schema/fields.ts +++ b/packages/core-types/src/schema/fields.ts @@ -35,6 +35,57 @@ export type GenericField = { options?: ObjectValue; }; +/** + * A field that can be used to alias one key to another + * key present in the cache version of the resource. + * + * Unlike DerivedField, an AliasField may write to its + * source when a record is in an editable mode. + * + * AliasFields may utilize a transform, specified by type, + * to pre/post process the field. + * + * An AliasField may also specify a `kind` via options. + * `kind` may be any other valid field kind other than + * + * - `@hash` + * - `@id` + * - `@local` + * - `derived` + * + * This allows an AliasField to rename any field in the cache. + * + * Alias fields are generally intended to be used to support migrating + * between different schemas, though there are times where they are useful + * as a form of advanced derivation when used with a transform. For instance, + * an AliasField could be used to expose both a string and a Date version of the + * same field, with both being capable of being written to. + * + * @typedoc + */ +export type AliasField = { + kind: 'alias'; + name: string; + type: null; // should always be null + + /** + * The field def for which this is an alias. + * + * @typedoc + */ + options: + | GenericField + | ObjectField + | SchemaObjectField + | ArrayField + | SchemaArrayField + | ResourceField + | CollectionField + | LegacyAttributeField + | LegacyBelongsToField + | LegacyHasManyField; +}; + /** * Represents a field whose value is the primary * key of the resource. @@ -787,6 +838,7 @@ export type LegacyHasManyField = { export type FieldSchema = | GenericField + | AliasField | LocalField | ObjectField | SchemaObjectField diff --git a/packages/schema-record/src/-private/compute.ts b/packages/schema-record/src/-private/compute.ts index ca1d9f37b08..73c72d1fca6 100644 --- a/packages/schema-record/src/-private/compute.ts +++ b/packages/schema-record/src/-private/compute.ts @@ -121,13 +121,13 @@ export function computeArray( } export function computeObject( - store: Store, schema: SchemaService, cache: Cache, record: SchemaRecord, identifier: StableRecordIdentifier, - field: ObjectField | SchemaObjectField, - path: string[] + field: ObjectField, + path: string[], + editable: boolean ) { const managedObjectMapForRecord = ManagedObjectMap.get(record); let managedObject; @@ -141,18 +141,16 @@ export function computeObject( 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; - } - // for schema-object, this should likely be an embedded SchemaRecord now - managedObject = new ManagedObject(store, schema, cache, field, rawValue, identifier, path, record, false); - if (!managedObjectMapForRecord) { - ManagedObjectMap.set(record, new Map([[field, managedObject]])); - } else { - managedObjectMapForRecord.set(field, managedObject); - } + if (field.type) { + const transform = schema.transformation(field); + rawValue = transform.hydrate(rawValue as ObjectValue, field.options ?? null, record) as object; + } + managedObject = new ManagedObject(schema, cache, field, rawValue, identifier, path, record, editable); + + if (!managedObjectMapForRecord) { + ManagedObjectMap.set(record, new Map([[field, managedObject]])); + } else { + managedObjectMapForRecord.set(field, managedObject); } } return managedObject; @@ -163,7 +161,7 @@ export function computeSchemaObject( cache: Cache, record: SchemaRecord, identifier: StableRecordIdentifier, - field: ObjectField | SchemaObjectField, + field: SchemaObjectField, path: string[], legacy: boolean, editable: boolean diff --git a/packages/schema-record/src/-private/managed-object.ts b/packages/schema-record/src/-private/managed-object.ts index 01645831ea0..7e56b78ab84 100644 --- a/packages/schema-record/src/-private/managed-object.ts +++ b/packages/schema-record/src/-private/managed-object.ts @@ -1,22 +1,26 @@ -import type Store from '@ember-data/store'; import type { Signal } from '@ember-data/tracking/-private'; import { addToTransaction, createSignal, subscribe } from '@ember-data/tracking/-private'; +import { assert } from '@warp-drive/build-config/macros'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; import type { Cache } from '@warp-drive/core-types/cache'; import type { ObjectValue, Value } from '@warp-drive/core-types/json/raw'; -import { STRUCTURED } from '@warp-drive/core-types/request'; +// import { STRUCTURED } from '@warp-drive/core-types/request'; import type { ObjectField, SchemaObjectField } 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 { Editable, EmbeddedPath, MUTATE, OBJECT_SIGNAL, Parent, SOURCE } from '../symbols'; export function notifyObject(obj: ManagedObject) { addToTransaction(obj[OBJECT_SIGNAL]); } +type ObjectSymbol = typeof OBJECT_SIGNAL | typeof Parent | typeof SOURCE | typeof Editable | typeof EmbeddedPath; +const ObjectSymbols = new Set([OBJECT_SIGNAL, Parent, SOURCE, Editable, EmbeddedPath]); + type KeyType = string | symbol | number; -const ignoredGlobalFields = new Set(['setInterval', 'nodeType', 'nodeName', 'length', 'document', STRUCTURED]); +// const ignoredGlobalFields = new Set(['setInterval', 'nodeType', 'nodeName', 'length', 'document', STRUCTURED]); + export interface ManagedObject { [MUTATE]?( target: unknown[], @@ -28,14 +32,13 @@ export interface ManagedObject { } export class ManagedObject { - [SOURCE]: object; - declare identifier: StableRecordIdentifier; - declare path: string[]; - declare owner: SchemaRecord; + declare [SOURCE]: object; + declare [Parent]: StableRecordIdentifier; + declare [EmbeddedPath]: string[]; declare [OBJECT_SIGNAL]: Signal; + declare [Editable]: boolean; constructor( - store: Store, schema: SchemaService, cache: Cache, field: ObjectField | SchemaObjectField, @@ -43,86 +46,41 @@ export class ManagedObject { identifier: StableRecordIdentifier, path: string[], owner: SchemaRecord, - isSchemaObject: boolean + editable: boolean ) { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; this[SOURCE] = { ...data }; this[OBJECT_SIGNAL] = createSignal(this, 'length'); - const _SIGNAL = this[OBJECT_SIGNAL]; - // const boundFns = new Map(); - this.identifier = identifier; - this.path = path; - this.owner = owner; - const transaction = false; + this[Editable] = editable; + this[Parent] = identifier; + this[EmbeddedPath] = path; + const _SIGNAL = this[OBJECT_SIGNAL]; const proxy = new Proxy(this[SOURCE], { ownKeys() { - if (isSchemaObject) { - const fields = schema.fields({ type: field.type! }); - return Array.from(fields.keys()); - } - return Object.keys(self[SOURCE]); }, has(target: unknown, prop: string | number | symbol) { - if (isSchemaObject) { - const fields = schema.fields({ type: field.type! }); - return fields.has(prop as string); - } - return prop in self[SOURCE]; }, getOwnPropertyDescriptor(target, prop) { - if (!isSchemaObject) { - return { - writable: false, - enumerable: true, - configurable: true, - }; - } - const fields = schema.fields({ type: field.type! }); - if (!fields.has(prop as string)) { - throw new Error(`No field named ${String(prop)} on ${field.type}`); - } - const schemaForField = fields.get(prop as string)!; - switch (schemaForField.kind) { - case 'derived': - return { - writable: false, - enumerable: true, - configurable: true, - }; - case '@local': - case 'field': - case 'attribute': - case 'resource': - case 'belongsTo': - case 'hasMany': - case 'collection': - case 'schema-array': - case 'array': - case 'schema-object': - case 'object': - return { - writable: false, // IS_EDITABLE, - enumerable: true, - configurable: true, - }; - } + return { + writable: editable, + enumerable: true, + configurable: true, + }; }, get>(target: object, prop: keyof R, receiver: R) { - if (prop === OBJECT_SIGNAL) { - return _SIGNAL; + if (ObjectSymbols.has(prop as ObjectSymbol)) { + return self[prop as keyof typeof target]; } - if (prop === 'identifier') { - return self.identifier; - } - if (prop === 'owner') { - return self.owner; + + if (prop === Symbol.toPrimitive) { + return null; } if (prop === Symbol.toStringTag) { return `ManagedObject<${identifier.type}:${identifier.id} (${identifier.lid})>`; @@ -130,43 +88,32 @@ export class ManagedObject { if (prop === 'constructor') { return Object; } - if (prop === 'toString') { return function () { return `ManagedObject<${identifier.type}:${identifier.id} (${identifier.lid})>`; }; } - if (prop === 'toHTML') { return function () { return '
ManagedObject
'; }; } + if (_SIGNAL.shouldReset) { _SIGNAL.t = false; _SIGNAL.shouldReset = false; - let newData = cache.getAttr(self.identifier, self.path); + let newData = cache.getAttr(identifier, path); if (newData && newData !== self[SOURCE]) { - if (!isSchemaObject && field.type) { + if (field.type) { const transform = schema.transformation(field); - newData = transform.hydrate(newData as ObjectValue, field.options ?? null, self.owner) as ObjectValue; + newData = transform.hydrate(newData as ObjectValue, field.options ?? null, owner) as ObjectValue; } self[SOURCE] = { ...(newData as ObjectValue) }; // Add type assertion for newData } } - if (isSchemaObject) { - const fields = schema.fields({ type: field.type! }); - // TODO: is there a better way to do this? - if (typeof prop === 'string' && !ignoredGlobalFields.has(prop) && !fields.has(prop)) { - throw new Error(`Field ${prop} does not exist on schema object ${field.type}`); - } - } - if (prop in self[SOURCE]) { - if (!transaction) { - subscribe(_SIGNAL); - } + subscribe(_SIGNAL); return (self[SOURCE] as R)[prop]; } @@ -174,37 +121,22 @@ export class ManagedObject { }, set(target, prop: KeyType, value, receiver) { - if (prop === 'identifier') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - self.identifier = value; - return true; - } - if (prop === 'owner') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - self.owner = value; - return true; - } - if (isSchemaObject) { - const fields = schema.fields({ type: field.type! }); - if (typeof prop === 'string' && !ignoredGlobalFields.has(prop) && !fields.has(prop)) { - throw new Error(`Field ${prop} does not exist on schema object ${field.type}`); - } - } + assert(`Cannot set read-only property '${String(prop)}' on ManagedObject`, editable); const reflect = Reflect.set(target, prop, value, receiver); + if (!reflect) { + return false; + } - if (reflect) { - if (isSchemaObject || !field.type) { - cache.setAttr(self.identifier, self.path, self[SOURCE] as Value); - _SIGNAL.shouldReset = true; - return true; - } - + if (!field.type) { + cache.setAttr(identifier, path, self[SOURCE] as Value); + } else { const transform = schema.transformation(field); - const val = transform.serialize(self[SOURCE], field.options ?? null, self.owner); - cache.setAttr(self.identifier, self.path, val); - _SIGNAL.shouldReset = true; + const val = transform.serialize(self[SOURCE], field.options ?? null, owner); + cache.setAttr(identifier, path, val); } - return reflect; + + _SIGNAL.shouldReset = true; + return true; }, }) as ManagedObject; diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index 9e604e576fa..dca9461faad 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -139,6 +139,7 @@ export class SchemaRecord { case 'field': case 'attribute': case 'resource': + case 'alias': case 'belongsTo': case 'hasMany': case 'collection': @@ -188,11 +189,8 @@ export class SchemaRecord { // for its own usage. // _, @, $, * - const propArray = isEmbedded ? embeddedPath!.slice() : []; - propArray.push(prop as string); - - const field = prop === identityField?.name ? identityField : fields.get(prop as string); - if (!field) { + const maybeField = prop === identityField?.name ? identityField : fields.get(prop as string); + if (!maybeField) { if (IgnoredGlobalFields.has(prop as string)) { return undefined; } @@ -211,6 +209,17 @@ export class SchemaRecord { throw new Error(`No field named ${String(prop)} on ${type}`); } + const field = maybeField.kind === 'alias' ? maybeField.options : maybeField; + assert( + `Alias fields cannot alias '@id' '@local' '@hash' or 'derived' fields`, + maybeField.kind !== 'alias' || !['@id', '@local', '@hash', 'derived'].includes(maybeField.options.kind) + ); + const propArray = isEmbedded ? embeddedPath!.slice() : []; + // we use the field.name instead of prop here because we want to use the cache-path not + // the record path. + propArray.push(field.name as string); + // propArray.push(prop as string); + switch (field.kind) { case '@id': entangleSignal(signals, receiver, '@identity'); @@ -258,7 +267,7 @@ export class SchemaRecord { !target[Legacy] ); entangleSignal(signals, receiver, field.name); - return computeObject(store, schema, cache, target, identifier, field, propArray); + return computeObject(schema, cache, target, identifier, field, propArray, Mode[Editable]); case 'schema-object': assert( `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, @@ -305,14 +314,21 @@ export class SchemaRecord { throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because the record is not editable`); } - const propArray = isEmbedded ? embeddedPath!.slice() : []; - propArray.push(prop as string); - - const field = prop === identityField?.name ? identityField : fields.get(prop as string); - if (!field) { + const maybeField = prop === identityField?.name ? identityField : fields.get(prop as string); + if (!maybeField) { const type = isEmbedded ? embeddedType! : identifier.type; throw new Error(`There is no field named ${String(prop)} on ${type}`); } + const field = maybeField.kind === 'alias' ? maybeField.options : maybeField; + assert( + `Alias fields cannot alias '@id' '@local' '@hash' or 'derived' fields`, + maybeField.kind !== 'alias' || !['@id', '@local', '@hash', 'derived'].includes(maybeField.options.kind) + ); + const propArray = isEmbedded ? embeddedPath!.slice() : []; + // we use the field.name instead of prop here because we want to use the cache-path not + // the record path. + propArray.push(field.name as string); + // propArray.push(prop as string); switch (field.kind) { case '@id': { @@ -427,6 +443,7 @@ export class SchemaRecord { case 'schema-object': { let newValue = value; if (value !== null) { + assert(`Expected value to be an object`, typeof value === 'object'); newValue = { ...(value as ObjectValue) }; const schemaFields = schema.fields({ type: field.type }); for (const key of Object.keys(newValue as ObjectValue)) { diff --git a/tests/warp-drive__schema-record/tests/reads/alias-test.ts b/tests/warp-drive__schema-record/tests/reads/alias-test.ts new file mode 100644 index 00000000000..15397d4ceef --- /dev/null +++ b/tests/warp-drive__schema-record/tests/reads/alias-test.ts @@ -0,0 +1,116 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import { recordIdentifierFor } from '@ember-data/store'; +import type { StableRecordIdentifier } from '@warp-drive/core-types'; +import { Type } from '@warp-drive/core-types/symbols'; +import type { SchemaRecord } from '@warp-drive/schema-record/record'; +import type { Transformation } from '@warp-drive/schema-record/schema'; +import { registerDerivations, withDefaults } from '@warp-drive/schema-record/schema'; + +import type Store from 'warp-drive__schema-record/services/store'; + +interface User { + id: string | null; + $type: 'user'; + rawNetWorth: string; + netWorth: number; + [Type]: 'user'; +} + +module('Reads | Alias fields', function (hooks) { + setupTest(hooks); + + test('we can use simple fields with a `type`', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + + const FloatTransform: Transformation = { + serialize(value: string | number, options: { precision?: number } | null, _record: SchemaRecord): string { + return typeof value === 'number' + ? value.toFixed(options?.precision ?? 3) + : Number(value).toFixed(options?.precision ?? 3); + }, + hydrate(value: string, _options: { precision?: number } | null, _record: SchemaRecord): number { + if (value === undefined || value === null) { + return 0; + } + return Number(value); + }, + defaultValue(_options: { precision?: number } | null, _identifier: StableRecordIdentifier): string { + const v = 0; + return v.toFixed(_options?.precision ?? 3); + }, + [Type]: 'float', + }; + + schema.registerTransformation(FloatTransform); + registerDerivations(schema); + + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'rawNetWorth', + kind: 'field', + }, + { + kind: 'alias', + name: 'netWorth', + type: null, + options: { + name: 'rawNetWorth', + kind: 'field', + type: 'float', + options: { precision: 2 }, + }, + }, + ], + }) + ); + + const record = store.createRecord('user', { + rawNetWorth: '1000000.009', + }); + const identifier = recordIdentifierFor(record); + const resource = store.cache.peek(identifier)!; + + assert.strictEqual(record.rawNetWorth, '1000000.009', 'netWorth is accessible in raw form'); + assert.strictEqual(record.netWorth, 1_000_000.009, 'netWorth is accessible in numeric form'); + assert.strictEqual( + store.cache.getAttr(identifier, 'rawNetWorth'), + '1000000.009', + 'cache value for netWorth is correct' + ); + assert.strictEqual(store.cache.getAttr(identifier, 'netWorth'), undefined, 'not caching the alias field'); + assert.strictEqual( + resource.attributes?.rawNetWorth, + '1000000.009', + 'resource cache value for rawNetWorth is correct' + ); + assert.strictEqual(resource.attributes?.netWorth, undefined, 'resource cache value for netWorth is correct'); + + const record2 = store.createRecord('user', { + netWorth: 1_000_000.009, + }); + const identifier2 = recordIdentifierFor(record2); + const resource2 = store.cache.peek(identifier2)!; + + assert.strictEqual(record2.rawNetWorth, '1000000.01', 'netWorth is accessible in raw form'); + assert.strictEqual(record2.netWorth, 1_000_000.01, 'netWorth is accessible in numeric form'); + assert.strictEqual( + store.cache.getAttr(identifier2, 'rawNetWorth'), + '1000000.01', + 'cache value for netWorth is correct' + ); + assert.strictEqual(store.cache.getAttr(identifier2, 'netWorth'), undefined, 'not caching the alias field'); + assert.strictEqual( + resource2.attributes?.rawNetWorth, + '1000000.01', + 'resource cache value for rawNetWorth is correct' + ); + assert.strictEqual(resource2.attributes?.netWorth, undefined, 'resource cache value for netWorth is correct'); + }); +}); diff --git a/tests/warp-drive__schema-record/tests/writes/object-test.ts b/tests/warp-drive__schema-record/tests/writes/object-test.ts index 0ae86f297da..f4ed371b6c4 100644 --- a/tests/warp-drive__schema-record/tests/writes/object-test.ts +++ b/tests/warp-drive__schema-record/tests/writes/object-test.ts @@ -504,8 +504,9 @@ module('Writes | object fields', function (hooks) { 'We have the correct object members' ); assert.strictEqual(record.address, record.address, 'We have a stable object reference'); - assert.notStrictEqual(record.address, sourceAddress); + assert.notStrictEqual(record.address, sourceAddress, 'we do not keep the source object reference'); const address = record.address; + assert.strictEqual(record.address?.zip, '12345', 'zip is accessible'); record.address!.zip = '23456'; assert.deepEqual(