From 57ab9f01b0a31d097ed2a828f4853035960b5522 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Tue, 7 Nov 2023 15:35:14 -0800 Subject: [PATCH 01/10] Implement legacy SchemaRecord attributes behavior --- packages/json-api/src/-private/cache.ts | 23 +- .../model/src/-private/schema-provider.ts | 26 ++ packages/model/src/migration-support.ts | 8 +- packages/schema-record/src/-base-fields.ts | 3 +- packages/schema-record/src/record.ts | 45 ++- packages/schema-record/src/schema.ts | 48 ++- .../managers/cache-capabilities-manager.ts | 4 + .../store/src/-types/q/cache-store-wrapper.ts | 16 +- packages/store/src/-types/q/schema-service.ts | 20 + packages/tracking/src/-private.ts | 114 +++++- packages/tracking/src/index.ts | 2 + tests/warp-drive__schema-record/.eslintrc.cjs | 4 +- .../tests/-utils/normalize-payload.ts | 32 ++ .../tests/-utils/reactive-context.ts | 4 +- .../tests/legacy/mode-test.ts | 196 +++++++++- .../legacy/reactivity/basic-fields-test.ts | 355 ++++++++++++++++++ .../tests/legacy/reads/basic-fields-test.ts | 184 +++++++++ .../tests/reactivity/basic-fields-test.ts | 15 +- .../tests/reactivity/derivation-test.ts | 114 +++++- .../tests/reactivity/resource-test.ts | 2 +- .../tests/reads/basic-fields-test.ts | 16 +- .../tests/reads/derivation-test.ts | 4 +- .../tests/reads/resource-test.ts | 2 +- .../tests/schema-test.ts | 7 + 24 files changed, 1172 insertions(+), 72 deletions(-) create mode 100644 tests/warp-drive__schema-record/tests/-utils/normalize-payload.ts create mode 100644 tests/warp-drive__schema-record/tests/legacy/reactivity/basic-fields-test.ts create mode 100644 tests/warp-drive__schema-record/tests/legacy/reads/basic-fields-test.ts create mode 100644 tests/warp-drive__schema-record/tests/schema-test.ts diff --git a/packages/json-api/src/-private/cache.ts b/packages/json-api/src/-private/cache.ts index bbe994200f6..b612ecc5ece 100644 --- a/packages/json-api/src/-private/cache.ts +++ b/packages/json-api/src/-private/cache.ts @@ -18,6 +18,7 @@ import type { CacheCapabilitiesManager as InternalCapabilitiesManager } from '@e import type { MergeOperation } from '@ember-data/store/-types/q/cache'; import type { CacheCapabilitiesManager } from '@ember-data/store/-types/q/cache-store-wrapper'; import type { AttributesHash, JsonApiError, JsonApiResource } from '@ember-data/store/-types/q/record-data-json-api'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import type { Cache, ChangedAttributesHash, RelationshipDiff } from '@warp-drive/core-types/cache'; import type { ResourceBlob } from '@warp-drive/core-types/cache/aliases'; import type { Change } from '@warp-drive/core-types/cache/change'; @@ -34,7 +35,6 @@ import type { StructuredDocument, StructuredErrorDocument, } from '@warp-drive/core-types/request'; -import type { AttributeSchema, RelationshipSchema } from '@warp-drive/core-types/schema'; import type { CollectionResourceDataDocument, ResourceDataDocument, @@ -450,12 +450,11 @@ export default class JSONAPICache implements Cache { upgradeCapabilities(this._capabilities); const store = this._capabilities._store; - const attrs = this._capabilities.getSchemaDefinitionService().attributesDefinitionFor(identifier); - Object.keys(attrs).forEach((key) => { + const attrs = this._capabilities.schema.fields(identifier); + attrs.forEach((attr, key) => { if (key in attributes && attributes[key] !== undefined) { return; } - const attr = attrs[key]!; const defaultValue = getDefaultValue(attr, identifier, store); if (defaultValue !== undefined) { @@ -708,8 +707,7 @@ export default class JSONAPICache implements Cache { if (options !== undefined) { const storeWrapper = this._capabilities; - const attributeDefs = storeWrapper.getSchemaDefinitionService().attributesDefinitionFor(identifier); - const relationshipDefs = storeWrapper.getSchemaDefinitionService().relationshipsDefinitionFor(identifier); + const fields = storeWrapper.schema.fields(identifier); const graph = this.__graph; const propertyNames = Object.keys(options); @@ -721,8 +719,7 @@ export default class JSONAPICache implements Cache { continue; } - const fieldType: AttributeSchema | RelationshipSchema | undefined = - relationshipDefs[name] || attributeDefs[name]; + const fieldType: FieldSchema | undefined = fields.get(name); const kind = fieldType !== undefined ? ('kind' in fieldType ? fieldType.kind : 'attribute') : null; let relationship: ResourceEdge | CollectionEdge; @@ -1096,7 +1093,7 @@ export default class JSONAPICache implements Cache { } else if (cached.remoteAttrs && attr in cached.remoteAttrs) { return cached.remoteAttrs[attr]; } else { - const attrSchema = this._capabilities.getSchemaDefinitionService().attributesDefinitionFor(identifier)[attr]; + const attrSchema = this._capabilities.schema.fields(identifier).get(attr); upgradeCapabilities(this._capabilities); return getDefaultValue(attrSchema, identifier, this._capabilities._store); @@ -1452,7 +1449,7 @@ function getRemoteState(rel: CollectionEdge | ResourceEdge) { } function getDefaultValue( - schema: AttributeSchema | undefined, + schema: FieldSchema | undefined, identifier: StableRecordIdentifier, store: Store ): Value | undefined { @@ -1462,6 +1459,10 @@ function getDefaultValue( return; } + if (schema.kind !== 'attribute' && schema.kind !== 'field') { + return; + } + // legacy support for defaultValues that are functions if (typeof options?.defaultValue === 'function') { // If anyone opens an issue for args not working right, we'll restore + deprecate it via a Proxy @@ -1478,7 +1479,7 @@ function getDefaultValue( return defaultValue as Value; // new style transforms - } else if (schema.type) { + } else if (schema.kind !== 'attribute' && schema.type) { const transform = ( store.schema as unknown as { transforms?: Map< diff --git a/packages/model/src/-private/schema-provider.ts b/packages/model/src/-private/schema-provider.ts index 9f4b137e527..78a16ea241b 100644 --- a/packages/model/src/-private/schema-provider.ts +++ b/packages/model/src/-private/schema-provider.ts @@ -1,6 +1,7 @@ import { getOwner } from '@ember/application'; import type Store from '@ember-data/store'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import type { RecordIdentifier } from '@warp-drive/core-types/identifier'; import type { AttributesSchema, RelationshipsSchema } from '@warp-drive/core-types/schema'; @@ -13,11 +14,36 @@ export class ModelSchemaProvider { declare store: ModelStore; declare _relationshipsDefCache: Record; declare _attributesDefCache: Record; + declare _fieldsDefCache: Record>; constructor(store: ModelStore) { this.store = store; this._relationshipsDefCache = Object.create(null) as Record; this._attributesDefCache = Object.create(null) as Record; + this._fieldsDefCache = Object.create(null) as Record>; + } + + fields(identifier: RecordIdentifier | { type: string }): Map { + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; } // Following the existing RD implementation diff --git a/packages/model/src/migration-support.ts b/packages/model/src/migration-support.ts index 256d035d513..f434f9abaf8 100644 --- a/packages/model/src/migration-support.ts +++ b/packages/model/src/migration-support.ts @@ -1,6 +1,7 @@ import { assert } from '@ember/debug'; import { recordIdentifierFor } from '@ember-data/store'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import { Errors } from './-private'; import { @@ -19,13 +20,6 @@ import { } from './-private/model-methods'; import RecordState from './-private/record-state'; -interface FieldSchema { - type: string | null; - name: string; - kind: 'attribute' | 'resource' | 'collection' | 'derived' | 'object' | 'array' | '@id' | '@local'; - options?: Record; -} - type Derivation = (record: R, options: Record | null, prop: string) => T; type SchemaService = { registerDerivation(name: string, derivation: Derivation): void; diff --git a/packages/schema-record/src/-base-fields.ts b/packages/schema-record/src/-base-fields.ts index 5fab266aa40..e6b6a676533 100644 --- a/packages/schema-record/src/-base-fields.ts +++ b/packages/schema-record/src/-base-fields.ts @@ -1,9 +1,10 @@ import { assert } from '@ember/debug'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; import { Identifier, type SchemaRecord } from './record'; -import type { Derivation, FieldSchema, SchemaService } from './schema'; +import type { Derivation, SchemaService } from './schema'; export const SchemaRecordFields: FieldSchema[] = [ { diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index 6d4677eed0c..8183604f187 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -1,8 +1,11 @@ +import { assert } from '@ember/debug'; + import { DEBUG } from '@ember-data/env'; import type { Future } from '@ember-data/request'; import type Store from '@ember-data/store'; import type { StoreRequestInput } from '@ember-data/store/-private/cache-handler'; import type { NotificationType } from '@ember-data/store/-private/managers/notification-manager'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import { addToTransaction, defineSignal, @@ -10,6 +13,7 @@ import { getSignal, peekSignal, type Signal, + Signals, } from '@ember-data/tracking/-private'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; import type { Cache } from '@warp-drive/core-types/cache'; @@ -19,7 +23,7 @@ import { STRUCTURED } from '@warp-drive/core-types/request'; import type { Link, Links } from '@warp-drive/core-types/spec/raw'; import { RecordStore } from '@warp-drive/core-types/symbols'; -import type { FieldSchema, SchemaService } from './schema'; +import type { SchemaService } from './schema'; export const Destroy = Symbol('Destroy'); export const Identifier = Symbol('Identifier'); @@ -29,7 +33,7 @@ export const Checkout = Symbol('Checkout'); export const Legacy = Symbol('Legacy'); const IgnoredGlobalFields = new Set(['then', STRUCTURED]); -const RecordSymbols = new Set([Destroy, RecordStore, Identifier, Editable, Parent, Checkout, Legacy]); +const RecordSymbols = new Set([Destroy, RecordStore, Identifier, Editable, Parent, Checkout, Legacy, Signals]); function computeLocal(record: SchemaRecord, field: FieldSchema, prop: string): unknown { let signal = peekSignal(record, prop); @@ -42,7 +46,7 @@ function computeLocal(record: SchemaRecord, field: FieldSchema, prop: string): u return signal.lastValue; } -function computeAttribute( +function computeField( schema: SchemaService, cache: Cache, record: SchemaRecord, @@ -61,6 +65,10 @@ function computeAttribute( return transform.hydrate(rawValue, field.options ?? null, record); } +function computeAttribute(cache: Cache, identifier: StableRecordIdentifier, prop: string): unknown { + return cache.getAttr(identifier, prop); +} + function computeDerivation( schema: SchemaService, record: SchemaRecord, @@ -176,6 +184,7 @@ export class SchemaRecord { declare [Identifier]: StableRecordIdentifier; declare [Editable]: boolean; declare [Legacy]: boolean; + declare [Signals]: Map; declare ___notifications: object; constructor(store: Store, identifier: StableRecordIdentifier, Mode: { [Editable]: boolean; [Legacy]: boolean }) { @@ -189,6 +198,7 @@ export class SchemaRecord { const fields = schema.fields(identifier); const signals: Map = new Map(); + this[Signals] = signals; this.___notifications = store.notifications.subscribe( identifier, (_: StableRecordIdentifier, type: NotificationType, key?: string) => { @@ -234,14 +244,29 @@ export class SchemaRecord { case '@local': entangleSignal(signals, this, field.name); return computeLocal(target, field, prop as string); + case 'field': + assert( + `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, + !target[Legacy] + ); + entangleSignal(signals, receiver, field.name); + return computeField(schema, cache, target, identifier, field, prop as string); case 'attribute': - entangleSignal(signals, this, field.name); - return computeAttribute(schema, cache, target, identifier, field, prop as string); + entangleSignal(signals, receiver, field.name); + return computeAttribute(cache, identifier, prop as string); case 'resource': - entangleSignal(signals, this, field.name); + assert( + `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, + !target[Legacy] + ); + entangleSignal(signals, receiver, field.name); return computeResource(store, cache, target, identifier, field, prop as string); case 'derived': + assert( + `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, + !target[Legacy] + ); return computeDerivation(schema, receiver as unknown as SchemaRecord, identifier, field, prop as string); default: throw new Error(`Field '${String(prop)}' on '${identifier.type}' has the unknown kind '${field.kind}'`); @@ -264,10 +289,9 @@ export class SchemaRecord { signal.lastValue = value; addToTransaction(signal); } - return true; } - case 'attribute': { + case 'field': { if (field.type === null) { cache.setAttr(identifier, prop as string, value as Value); return true; @@ -282,10 +306,13 @@ export class SchemaRecord { cache.setAttr(identifier, prop as string, rawValue); return true; } + case 'attribute': { + cache.setAttr(identifier, prop as string, value as Value); + return true; + } case 'derived': { throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because it is derived`); } - default: throw new Error(`Unknown field kind ${field.kind}`); } diff --git a/packages/schema-record/src/schema.ts b/packages/schema-record/src/schema.ts index 7d850c86d8a..30dd320f74b 100644 --- a/packages/schema-record/src/schema.ts +++ b/packages/schema-record/src/schema.ts @@ -1,5 +1,8 @@ import { assert } from '@ember/debug'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; +import { createCache, getValue } from '@ember-data/tracking'; +import { type Signal, Signals } from '@ember-data/tracking/-private'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; import type { Value } from '@warp-drive/core-types/json/raw'; import type { AttributeSchema, RelationshipSchema } from '@warp-drive/core-types/schema'; @@ -8,13 +11,6 @@ import type { SchemaRecord } from './record'; export { withFields, registerDerivations } from './-base-fields'; -export interface FieldSchema { - type: string | null; - name: string; - kind: 'attribute' | 'resource' | 'collection' | 'derived' | 'object' | 'array' | '@id' | '@local'; - options?: Record; -} - /** * The full schema for a resource * @@ -55,6 +51,29 @@ export type Transform = { export type Derivation = (record: R, options: Record | null, prop: string) => T; +/** + * Wraps a derivation in a new function with Derivation signature but that looks + * up the value in the cache before recomputing. + * + * @param record + * @param options + * @param prop + */ +function makeCachedDerivation(derivation: Derivation): Derivation { + return (record: R, options: Record | null, prop: string): T => { + const signals = (record as { [Signals]: Map })[Signals]; + let signal = signals.get(prop); + if (!signal) { + signal = createCache(() => { + return derivation(record, options, prop); + }) as unknown as Signal; // a total lie, for convenience of reusing the storage + signals.set(prop, signal); + } + + return getValue(signal as unknown as ReturnType) as T; + }; +} + export class SchemaService { declare schemas: Map; declare transforms: Map>; @@ -71,7 +90,7 @@ export class SchemaService { } registerDerivation(type: string, derivation: Derivation): void { - this.derivations.set(type, derivation as Derivation); + this.derivations.set(type, makeCachedDerivation(derivation) as Derivation); } defineSchema(name: string, schema: { legacy?: boolean; fields: FieldSchema[] }): void { @@ -96,6 +115,19 @@ export class SchemaService { if (field.kind === '@id') { fieldSpec['@id'] = field; + } else if (field.kind === 'field') { + // We don't add 'field' fields to attributes in order to allow simpler + // migration between transformation behaviors + // serializers and things which call attributesDefinitionFor will + // only run on the things that are legacy attribute mode, while all fields + // will have their serialize/hydrate logic managed by the cache and record + // + // This means that if you want to normalize fields pre-cache insertion + // Or pre-api call you wil need to use the newer `schema.fields()` API + // To opt-in to that ability (which note, is now an anti-pattern) + // + // const attr = Object.assign({}, field, { kind: 'attribute' }) as AttributeSchema; + // fieldSpec.attributes[attr.name] = attr; } else if (field.kind === 'attribute') { fieldSpec.attributes[field.name] = field as AttributeSchema; } else if (field.kind === 'resource' || field.kind === 'collection') { diff --git a/packages/store/src/-private/managers/cache-capabilities-manager.ts b/packages/store/src/-private/managers/cache-capabilities-manager.ts index 2802b9cf3cf..7f16ff6ce8d 100644 --- a/packages/store/src/-private/managers/cache-capabilities-manager.ts +++ b/packages/store/src/-private/managers/cache-capabilities-manager.ts @@ -90,6 +90,10 @@ export class CacheCapabilitiesManager implements StoreWrapper { return this._store.getSchemaDefinitionService(); } + get schema() { + return this._store.schema; + } + setRecordId(identifier: StableRecordIdentifier, id: string) { assert(`Expected a stable identifier`, isStableIdentifier(identifier)); this._store._instanceCache.setRecordId(identifier, id); diff --git a/packages/store/src/-types/q/cache-store-wrapper.ts b/packages/store/src/-types/q/cache-store-wrapper.ts index 228f8e5909a..07ff453efac 100644 --- a/packages/store/src/-types/q/cache-store-wrapper.ts +++ b/packages/store/src/-types/q/cache-store-wrapper.ts @@ -35,10 +35,10 @@ export interface CacheCapabilitiesManager { identifierCache: IdentifierCache; /** - * Provides access to the SchemaDefinitionService instance + * Provides access to the SchemaService instance * for this Store instance. * - * The SchemaDefinitionService can be used to query for + * The SchemaService can be used to query for * information about the schema of a resource. * * @method getSchemaDefinitionService @@ -46,6 +46,18 @@ export interface CacheCapabilitiesManager { */ getSchemaDefinitionService(): SchemaService; + /** + * Provides access to the SchemaService instance + * for this Store instance. + * + * The SchemaService can be used to query for + * information about the schema of a resource. + * + * @property schema + * @public + */ + schema: SchemaService; + /** * Update the `id` for the record corresponding to the identifier * This operation can only be done for records whose `id` is `null`. diff --git a/packages/store/src/-types/q/schema-service.ts b/packages/store/src/-types/q/schema-service.ts index 121f6111c4c..34dc5d6687e 100644 --- a/packages/store/src/-types/q/schema-service.ts +++ b/packages/store/src/-types/q/schema-service.ts @@ -5,6 +5,24 @@ import type { RecordIdentifier } from '@warp-drive/core-types/identifier'; import type { AttributesSchema, RelationshipsSchema } from '@warp-drive/core-types/schema'; +export interface FieldSchema { + type: string | null; + name: string; + kind: + | 'attribute' + | 'hasMany' + | 'belongsTo' + | 'field' + | 'resource' + | 'collection' + | 'derived' + | 'object' + | 'array' + | '@id' + | '@local'; + options?: Record; +} + /** * A SchemaDefinitionService implementation provides the ability * to query for various information about a resource in an abstract manner. @@ -66,6 +84,8 @@ export interface SchemaService { */ doesTypeExist(type: string): boolean; + fields({ type }: { type: string }): Map; + /** * Returns definitions for all properties of the specified resource * that are considered "attributes". Generally these are properties diff --git a/packages/tracking/src/-private.ts b/packages/tracking/src/-private.ts index 9e4d267d730..0b370993260 100644 --- a/packages/tracking/src/-private.ts +++ b/packages/tracking/src/-private.ts @@ -54,6 +54,16 @@ function maybeDirty(tag: ReturnType | null): void { } } +/** + * If there is a current transaction, ensures that the relevant tag (and any + * array computed chains symbols, if applicable) will be consumed during the + * transaction. + * + * If there is no current transaction, will consume the tag(s) immediately. + * + * @internal + * @param obj + */ export function subscribe(obj: Tag | Signal): void { if (TRANSACTION) { TRANSACTION.sub.add(obj); @@ -259,6 +269,33 @@ export function memoTransact(method: T): (...args: unknown[] export const Signals = Symbol('Signals'); +/** + * use to add a signal property to the prototype of something. + * + * First arg is the thing to define on + * Second arg is the property name + * Third agg is the initial value of the property if any. + * + * for instance + * + * ```ts + * class Model {} + * defineSignal(Model.prototype, 'isLoading', false); + * ``` + * + * This is sort of like using a stage-3 decorator but works today + * while we are still on legacy decorators. + * + * e.g. it is equivalent to + * + * ```ts + * class Model { + * @signal accessor isLoading = false; + * } + * ``` + * + * @internal + */ export function defineSignal(obj: T, key: string, v?: unknown) { Object.defineProperty(obj, key, { enumerable: true, @@ -288,14 +325,57 @@ export function defineSignal(obj: T, key: string, v?: unknown) } export interface Signal { + /** + * Key on the associated object + * @internal + */ key: string; _debug_base?: string; + /** + * Whether this signal is part of an active transaction. + * @internal + */ t: boolean; + + /** + * Whether to "bust" the lastValue cache + * @internal + */ shouldReset: boolean; + + /** + * The framework specific "signal" e.g. glimmer "tracked" + * or starbeam "cell" to consume/invalidate when appropriate. + * + * @internal + */ tag: ReturnType; + + /** + * In classic ember, arrays must entangle a `[]` symbol + * in addition to any other tag in order for array chains to work. + * + * Note, this symbol MUST be the one that ember itself generates + * + * @internal + */ '[]': ReturnType | null; + /** + * In classic ember, arrays must entangle a `@length` symbol + * in addition to any other tag in order for array chains to work. + * + * Note, this symbol MUST be the one that ember itself generates + * + * @internal + */ '@length': ReturnType | null; + + /** + * The lastValue computed for this signal when + * a signal is also used for storage. + * @internal + */ lastValue: unknown; } @@ -306,6 +386,14 @@ export function createArrayTags(obj: T, signal: Signal) { } } +/** + * Create a signal for the key/object pairing. + * + * @internal + * @param obj Object we're creating the signal on + * @param key Key to create the signal for + * @returns the signal + */ export function createSignal(obj: T, key: string): Signal { const _signal: Signal = { key, @@ -319,17 +407,35 @@ export function createSignal(obj: T, key: string): Signal { }; if (DEBUG) { - // @ts-expect-error - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const modelName = obj.modelName ?? obj.constructor?.modelName ?? ''; + // eslint-disable-next-line no-inner-declarations + function tryGet(prop: string): T1 | undefined { + try { + return obj[prop as keyof typeof obj] as unknown as T1; + } catch { + return; + } + } + const modelName = + tryGet('$type') ?? tryGet('modelName') ?? tryGet<{ modelName?: string }>('constructor')?.modelName ?? ''; // eslint-disable-next-line @typescript-eslint/no-base-to-string const className = obj.constructor?.name ?? obj.toString?.() ?? 'unknown'; - _signal._debug_base = `${className}${modelName ? `:${modelName}` : ''}`; + _signal._debug_base = `${className}${modelName && !className.startsWith('SchemaRecord') ? `:${modelName}` : ''}`; } return _signal; } +/** + * Create a signal for the key/object pairing and subscribes to the signal. + * + * Use when you need to ensure a signal exists and is subscribed to. + * + * @internal + * @param signals Map of signals + * @param obj Object we're creating the signal on + * @param key Key to create the signal for + * @returns the signal + */ export function entangleSignal(signals: Map, obj: T, key: string): Signal { let _signal = signals.get(key); if (!_signal) { diff --git a/packages/tracking/src/index.ts b/packages/tracking/src/index.ts index ea3605d8d92..3829c9e273d 100644 --- a/packages/tracking/src/index.ts +++ b/packages/tracking/src/index.ts @@ -40,3 +40,5 @@ export function cached( return getValue(caches.get(this) as Parameters[0]); }; } + +export { createCache, getValue }; diff --git a/tests/warp-drive__schema-record/.eslintrc.cjs b/tests/warp-drive__schema-record/.eslintrc.cjs index 270782bd483..c2750b82c80 100644 --- a/tests/warp-drive__schema-record/.eslintrc.cjs +++ b/tests/warp-drive__schema-record/.eslintrc.cjs @@ -17,7 +17,7 @@ module.exports = { base.rules(), imports.rules(), isolation.rules({ - allowedImports: ['@ember/application'], + allowedImports: ['@ember/application', '@ember/object', '@ember/owner'], }), {} ), @@ -34,7 +34,7 @@ module.exports = { typescript.defaults(), qunit.defaults({ files: ['tests/**/*.{js,ts}'], - allowedImports: ['@glimmer/component'], + allowedImports: ['@glimmer/component', '@ember/object', '@ember/owner'], }), ], }; diff --git a/tests/warp-drive__schema-record/tests/-utils/normalize-payload.ts b/tests/warp-drive__schema-record/tests/-utils/normalize-payload.ts new file mode 100644 index 00000000000..b2766a2438b --- /dev/null +++ b/tests/warp-drive__schema-record/tests/-utils/normalize-payload.ts @@ -0,0 +1,32 @@ +import type Owner from '@ember/owner'; + +import type Store from '@ember-data/store'; +import { Value } from '@warp-drive/core-types/json/raw'; +import type { SingleResourceDocument } from '@warp-drive/core-types/spec/raw'; + +export function simplePayloadNormalize(owner: Owner, payload: SingleResourceDocument): SingleResourceDocument { + const store = owner.lookup('service:store') as Store; + const attrSchema = store.schema.attributesDefinitionFor(payload.data); + const attrs = payload.data.attributes; + + if (!attrs) { + return payload; + } + + Object.keys(attrs).forEach((key) => { + const schema = attrSchema[key]; + + if (schema) { + if (schema.type) { + const transform = owner.lookup(`transform:${schema.type}`) as { + deserialize(v: Value): Value; + }; + const value = attrs[key]; + + attrs[key] = transform.deserialize(value); + } + } + }); + + return payload; +} diff --git a/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts b/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts index d837c61fc01..e0df0fb6e2c 100644 --- a/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts +++ b/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts @@ -4,8 +4,8 @@ import Component from '@glimmer/component'; import { hbs } from 'ember-cli-htmlbars'; import type { RecordInstance } from '@ember-data/store/-types/q/record-instance'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import type { ResourceRelationship } from '@warp-drive/core-types/cache/relationship'; -import type { FieldSchema } from '@warp-drive/schema-record/schema'; export async function reactiveContext(this: TestContext, record: T, fields: FieldSchema[]) { const _fields: string[] = []; @@ -32,7 +32,7 @@ export async function reactiveContext(this: TestContex get() { counters[field.name]++; - if (field.kind === 'attribute' || field.kind === 'derived') { + if (field.kind === 'attribute' || field.kind === 'field' || field.kind === 'derived') { return record[field.name as keyof T] as unknown; } else if (field.kind === 'resource') { return (record[field.name as keyof T] as ResourceRelationship).data?.id; diff --git a/tests/warp-drive__schema-record/tests/legacy/mode-test.ts b/tests/warp-drive__schema-record/tests/legacy/mode-test.ts index 097d92c62b8..d61fcda423c 100644 --- a/tests/warp-drive__schema-record/tests/legacy/mode-test.ts +++ b/tests/warp-drive__schema-record/tests/legacy/mode-test.ts @@ -34,6 +34,8 @@ interface User { netWorth: number; coolometer: number; rank: number; + fullName: string; + bestFriend: unknown; currentState: RecordState; isDestroying: boolean; isDestroyed: boolean; @@ -101,7 +103,7 @@ module('Legacy Mode', function (hooks) { { name: 'name', type: null, - kind: 'attribute', + kind: 'field', }, ], }); @@ -126,6 +128,7 @@ module('Legacy Mode', function (hooks) { 'record.constructor.modelName throws' ); } + assert.strictEqual(record.constructor.name, 'SchemaRecord', 'it has a useful constructor name'); }); test('records in legacy mode set their constructor modelName value to the correct type', function (assert) { @@ -159,6 +162,193 @@ module('Legacy Mode', function (hooks) { 'user', 'record constructor modelName is correct' ); + + assert.strictEqual(record.constructor.name, 'Record', 'it has a useful constructor name'); + }); + + test('records not in legacy mode can access values with type "field"', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + + schema.defineSchema('user', { + legacy: false, + fields: [ + { + name: 'name', + type: null, + kind: 'field', + }, + ], + }); + + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { name: 'Rey Pupatine' }, + }, + }) as User; + + assert.false(record[Legacy], 'record is NOT in legacy mode'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + + assert.strictEqual(record.name, 'Rey Pupatine', 'can access name field'); + }); + + test('records in legacy mode cannot access values with type "field"', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + + schema.defineSchema('user', { + legacy: true, + fields: [ + { + name: 'name', + type: null, + kind: 'field', + }, + ], + }); + + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { name: 'Rey Pupatine' }, + }, + }) as User; + + assert.true(record[Legacy], 'record is in legacy mode'); + + try { + record.name; + assert.ok(false, 'record.name should throw'); + } catch (e) { + assert.strictEqual( + (e as Error).message, + "Assertion Failed: SchemaRecord.name is not available in legacy mode because it has type 'field'", + 'record.name throws' + ); + } + }); + + test('records in legacy mode cannot access derivations', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + + schema.defineSchema('user', { + legacy: true, + fields: [ + { + 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.push({ + data: { + type: 'user', + id: '1', + attributes: { + firstName: 'Rey', + lastName: 'Pupatine', + }, + }, + }) as User; + + assert.true(record[Legacy], 'record is in legacy mode'); + + try { + record.fullName; + assert.ok(false, 'record.fullName should throw'); + } catch (e) { + assert.strictEqual( + (e as Error).message, + "Assertion Failed: SchemaRecord.fullName is not available in legacy mode because it has type 'derived'", + 'record.fullName throws' + ); + } + }); + + test('records in legacy mode cannot access resources', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + + schema.defineSchema('user', { + legacy: true, + fields: [ + { + 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.true(record[Legacy], 'record is in legacy mode'); + + try { + record.bestFriend; + assert.ok(false, 'record.bestFriend should throw'); + } catch (e) { + assert.strictEqual( + (e as Error).message, + "Assertion Failed: SchemaRecord.bestFriend is not available in legacy mode because it has type 'resource'", + 'record.bestFriend throws' + ); + } }); test('we can access errors', function (assert) { @@ -191,7 +381,7 @@ module('Legacy Mode', function (hooks) { assert.ok(true, 'record.errors should be available'); const errors2 = record.errors; - assert.true(errors === errors2, 'record.errors should be stable'); + assert.strictEqual(errors, errors2, 'record.errors should be stable'); } catch (e) { assert.ok(false, `record.errors should be available: ${(e as Error).message}`); } @@ -227,7 +417,7 @@ module('Legacy Mode', function (hooks) { assert.ok(true, 'record.currentState should be available'); const currentState2 = record.currentState; - assert.true(currentState === currentState2, 'record.currentState should be stable'); + assert.strictEqual(currentState, currentState2, 'record.currentState should be stable'); assert.strictEqual(currentState.stateName, 'root.loaded.saved', 'currentState.stateName is correct'); } catch (e) { diff --git a/tests/warp-drive__schema-record/tests/legacy/reactivity/basic-fields-test.ts b/tests/warp-drive__schema-record/tests/legacy/reactivity/basic-fields-test.ts new file mode 100644 index 00000000000..92ee93d4802 --- /dev/null +++ b/tests/warp-drive__schema-record/tests/legacy/reactivity/basic-fields-test.ts @@ -0,0 +1,355 @@ +import EmberObject from '@ember/object'; +import { rerender } from '@ember/test-helpers'; + +import { module, test } from 'qunit'; + +import { setupRenderingTest } from 'ember-qunit'; + +import type Store from '@ember-data/store'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; +import type { StableRecordIdentifier } from '@warp-drive/core-types'; +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 { simplePayloadNormalize } from '../../-utils/normalize-payload'; +import { reactiveContext } from '../../-utils/reactive-context'; + +interface User { + id: string | null; + $type: 'user'; + name: string; + age: number; + netWorth: number; + coolometer: number; + rank: number; +} + +module('Legacy | Reactivity | basic fields can receive remote updates', function (hooks) { + setupRenderingTest(hooks); + + test('we can use simple fields with no `type`', async function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + + schema.defineSchema('user', { + legacy: true, + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + ], + }); + const fieldsMap = schema.schemas.get('user')!.fields; + const fields: FieldSchema[] = [...fieldsMap.values()]; + + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { name: 'Rey Pupatine' }, + }, + }) as User; + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.name, 'Rey Pupatine', 'name is accessible'); + + const { counters, fieldOrder } = await reactiveContext.call(this, record, fields); + const nameIndex = fieldOrder.indexOf('name'); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.$type, 0, '$typeCount is 0'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Pupatine', 'name is rendered'); + + // remote update + store.push({ + data: { + type: 'user', + id: '1', + attributes: { name: 'Rey Skybarker' }, + }, + }); + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.$type, 0, '$typeCount is 0'); + assert.strictEqual(counters.name, 2, 'nameCount is 1'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + }); + + test('we can use simple fields with a `type`', async function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + + this.owner.register( + 'transform:float', + class extends EmberObject { + serialize() { + assert.ok(false, 'unexpected legacy serialize'); + } + deserialize(v: number | string | null) { + assert.step(`legacy deserialize:${v}`); + return Number(v); + } + } + ); + + const FloatTransform: Transform = { + serialize(value: string | number, options: { precision?: number } | null, _record: SchemaRecord): never { + assert.ok(false, 'unexpected serialize'); + throw new Error('unexpected serialize'); + }, + hydrate(value: string, _options: { precision?: number } | null, _record: SchemaRecord): number { + assert.ok(false, 'unexpected hydrate'); + throw new Error('unexpected hydrate'); + }, + defaultValue(_options: { precision?: number } | null, _identifier: StableRecordIdentifier): string { + assert.ok(false, 'unexpected defaultValue'); + throw new Error('unexpected defaultValue'); + }, + }; + + schema.registerTransform('float', FloatTransform); + + schema.defineSchema('user', { + legacy: true, + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'rank', + type: 'float', + kind: 'attribute', + options: { precision: 0, defaultValue: 0 }, + }, + { + name: 'age', + type: 'float', + options: { precision: 0, defaultValue: 0 }, + kind: 'attribute', + }, + { + name: 'netWorth', + type: 'float', + options: { precision: 2, defaultValue: 0 }, + kind: 'attribute', + }, + { + name: 'coolometer', + type: 'float', + options: { defaultValue: 0 }, + kind: 'attribute', + }, + ], + }); + + const fieldsMap = schema.schemas.get('user')!.fields; + const fields: FieldSchema[] = [...fieldsMap.values()]; + + const record = store.push( + simplePayloadNormalize(this.owner, { + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Pupatine', + age: '3', + netWorth: '1000000.01', + coolometer: '100.000', + }, + }, + }) + ) as User; + + assert.verifySteps(['legacy deserialize:3', 'legacy deserialize:1000000.01', 'legacy deserialize:100.000']); + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.name, 'Rey Pupatine', 'name is accessible'); + assert.strictEqual(record.age, 3, 'age is accessible'); + assert.strictEqual(record.netWorth, 1_000_000.01, 'netWorth is accessible'); + assert.strictEqual(record.coolometer, 100, 'coolometer is accessible'); + assert.strictEqual(record.rank, 0, 'rank is accessible'); + + const { counters, fieldOrder } = await reactiveContext.call(this, record, fields); + const nameIndex = fieldOrder.indexOf('name'); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.age, 1, 'ageCount is 1'); + assert.strictEqual(counters.netWorth, 1, 'netWorthCount is 1'); + assert.strictEqual(counters.coolometer, 1, 'coolometerCount is 1'); + assert.strictEqual(counters.rank, 1, 'rankCount is 1'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Pupatine', 'name is rendered'); + assert.dom(`li:nth-child(${nameIndex + 3})`).hasText('rank: 0', 'rank is rendered'); + assert.dom(`li:nth-child(${nameIndex + 5})`).hasText('age: 3', 'age is rendered'); + assert.dom(`li:nth-child(${nameIndex + 7})`).hasText('netWorth: 1000000.01', 'netWorth is rendered'); + assert.dom(`li:nth-child(${nameIndex + 9})`).hasText('coolometer: 100', 'coolometer is rendered'); + + // remote update + store.push( + simplePayloadNormalize(this.owner, { + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + age: '4', + netWorth: '1000000.01', + coolometer: '100.001', + rank: '10', + }, + }, + }) + ); + + assert.verifySteps([ + 'legacy deserialize:4', + 'legacy deserialize:1000000.01', + 'legacy deserialize:100.001', + 'legacy deserialize:10', + ]); + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.strictEqual(record.age, 4, 'age is accessible'); + assert.strictEqual(record.netWorth, 1_000_000.01, 'netWorth is accessible'); + assert.strictEqual(record.coolometer, 100.001, 'coolometer is accessible'); + assert.strictEqual(record.rank, 10, 'rank is accessible'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 2, 'nameCount is 2'); + assert.strictEqual(counters.age, 2, 'ageCount is 2'); + assert.strictEqual(counters.netWorth, 1, 'netWorthCount is 1'); + assert.strictEqual(counters.coolometer, 2, 'coolometerCount is 2'); + assert.strictEqual(counters.rank, 2, 'rankCount is 2'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${nameIndex + 3})`).hasText('rank: 10', 'rank is rendered'); + assert.dom(`li:nth-child(${nameIndex + 5})`).hasText('age: 4', 'age is rendered'); + assert.dom(`li:nth-child(${nameIndex + 7})`).hasText('netWorth: 1000000.01', 'netWorth is rendered'); + assert.dom(`li:nth-child(${nameIndex + 9})`).hasText('coolometer: 100.001', 'coolometer is rendered'); + }); + + test('When attribute does not declare defaultValue but a matching new-style transform does, we ignore it', async function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + + this.owner.register( + 'transform:float', + class extends EmberObject { + serialize() { + assert.ok(false, 'unexpected legacy serialize'); + } + deserialize(v: number | string | null) { + assert.step(`legacy deserialize:${v}`); + return Number(v); + } + } + ); + + const FloatTransform: Transform = { + serialize(value: string | number, options: { precision?: number } | null, _record: SchemaRecord): never { + assert.ok(false, 'unexpected serialize'); + throw new Error('unexpected serialize'); + }, + hydrate(value: string, _options: { precision?: number } | null, _record: SchemaRecord): number { + assert.ok(false, 'unexpected hydrate'); + throw new Error('unexpected hydrate'); + }, + defaultValue(_options: { precision?: number } | null, _identifier: StableRecordIdentifier): string { + assert.ok(false, 'unexpected defaultValue'); + throw new Error('unexpected defaultValue'); + }, + }; + + schema.registerTransform('float', FloatTransform); + + schema.defineSchema('user', { + legacy: true, + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'coolometer', + type: 'float', + kind: 'attribute', + }, + ], + }); + + const fieldsMap = schema.schemas.get('user')!.fields; + const fields: FieldSchema[] = [...fieldsMap.values()]; + + const record = store.push( + simplePayloadNormalize(this.owner, { + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Pupatine', + }, + }, + }) + ) as User; + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.name, 'Rey Pupatine', 'name is accessible'); + assert.strictEqual(record.coolometer, undefined, 'coolometer is accessible'); + const { counters, fieldOrder } = await reactiveContext.call(this, record, fields); + const nameIndex = fieldOrder.indexOf('name'); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 1, 'nameCount is 1'); + assert.strictEqual(counters.coolometer, 1, 'coolometerCount is 1'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Pupatine', 'name is rendered'); + assert.dom(`li:nth-child(${nameIndex + 3})`).hasText('coolometer:', 'coolometer is rendered'); + + // remote update + store.push( + simplePayloadNormalize(this.owner, { + data: { + type: 'user', + id: '1', + attributes: { + name: 'Rey Skybarker', + }, + }, + }) + ); + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.strictEqual(record.coolometer, undefined, 'coolometer is accessible'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.name, 2, 'nameCount is 2'); + assert.strictEqual(counters.coolometer, 1, 'coolometerCount is 1'); + + assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); + assert.dom(`li:nth-child(${nameIndex + 3})`).hasText('coolometer:', 'coolometer is rendered'); + }); +}); diff --git a/tests/warp-drive__schema-record/tests/legacy/reads/basic-fields-test.ts b/tests/warp-drive__schema-record/tests/legacy/reads/basic-fields-test.ts new file mode 100644 index 00000000000..203b7758169 --- /dev/null +++ b/tests/warp-drive__schema-record/tests/legacy/reads/basic-fields-test.ts @@ -0,0 +1,184 @@ +import EmberObject from '@ember/object'; + +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import type Store from '@ember-data/store'; +import { recordIdentifierFor } from '@ember-data/store'; +import type { JsonApiResource } from '@ember-data/store/-types/q/record-data-json-api'; +import type { StableRecordIdentifier } from '@warp-drive/core-types'; +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'; + +interface User { + id: string | null; + $type: 'user'; + name: string; + age: number; + netWorth: number; + coolometer: number; + rank: number; +} + +module('Legacy | Reads | basic fields', 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); + + schema.defineSchema('user', { + legacy: true, + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + ], + }); + + const record = store.createRecord('user', { name: 'Rey Skybarker' }) as User; + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual( + (record.constructor as { modelName?: string }).modelName, + 'user', + 'constructor.modelName is accessible' + ); + + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + + try { + // @ts-expect-error intentionally accessing unknown field + record.lastName; + assert.ok(false, 'should error when accessing unknown field'); + } catch (e) { + assert.strictEqual( + (e as Error).message, + 'No field named lastName on user', + 'should error when accessing unknown field' + ); + } + }); + + test('we can use simple fields with a `type`', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + + this.owner.register( + 'transform:float', + class extends EmberObject { + serialize() { + assert.ok(false, 'unexpected legacy serialize'); + } + deserialize(v: number | string | null) { + assert.ok(false, 'unexpected legacy deserialize'); + } + } + ); + + const FloatTransform: Transform = { + serialize(value: string | number, options: { precision?: number } | null, _record: SchemaRecord): never { + assert.ok(false, 'unexpected serialize'); + throw new Error('unexpected serialize'); + }, + hydrate(value: string, _options: { precision?: number } | null, _record: SchemaRecord): number { + assert.ok(false, 'unexpected hydrate'); + throw new Error('unexpected hydrate'); + }, + defaultValue(_options: { precision?: number } | null, _identifier: StableRecordIdentifier): string { + assert.ok(false, 'unexpected defaultValue'); + throw new Error('unexpected defaultValue'); + }, + }; + + schema.registerTransform('float', FloatTransform); + + schema.defineSchema('user', { + legacy: true, + fields: [ + { + name: 'name', + type: null, + kind: 'attribute', + }, + { + name: 'lastName', + type: 'string', + kind: 'attribute', + }, + { + name: 'rank', + type: 'float', + kind: 'attribute', + options: { precision: 0, defaultValue: 0 }, + }, + { + name: 'age', + type: 'float', + options: { precision: 0, defaultValue: 0 }, + kind: 'attribute', + }, + { + name: 'netWorth', + type: 'float', + options: { precision: 2, defaultValue: 0 }, + kind: 'attribute', + }, + { + name: 'coolometer', + type: 'float', + options: { defaultValue: 0 }, + kind: 'attribute', + }, + ], + }); + + const record = store.createRecord('user', { + name: 'Rey Skybarker', + age: 42, + netWorth: 1_000_000.009, + coolometer: 100.0, + }) as User; + const identifier = recordIdentifierFor(record); + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual( + (record.constructor as { modelName?: string }).modelName, + 'user', + 'constructor.modelName is accessible' + ); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.strictEqual(record.age, 42, 'age is accessible'); + assert.strictEqual(record.netWorth, 1_000_000.009, 'netWorth is accessible'); + assert.strictEqual(record.coolometer, 100.0, 'coolometer is accessible'); + assert.strictEqual(record.rank, 0, 'rank is accessible'); + // @ts-expect-error intentionally have not typed the property on the record + assert.strictEqual(record.lastName, undefined, 'lastName is accessible even though its transform does not exist'); + + const resource = store.cache.peek(identifier) as JsonApiResource; + + assert.strictEqual(store.cache.getAttr(identifier, 'name'), 'Rey Skybarker', 'cache value for name is correct'); + assert.strictEqual(store.cache.getAttr(identifier, 'age'), 42, 'cache value for age is correct'); + assert.strictEqual( + store.cache.getAttr(identifier, 'netWorth'), + 1_000_000.009, + 'cache value for netWorth is correct' + ); + assert.strictEqual(store.cache.getAttr(identifier, 'coolometer'), 100.0, 'cache value for coolometer is correct'); + assert.strictEqual(store.cache.getAttr(identifier, 'rank'), 0, 'cache value for rank is correct'); + + assert.strictEqual(resource.type, 'user', 'resource cache type is correct'); + assert.strictEqual(resource.id, null, 'resource cache id is correct'); + assert.strictEqual(resource.attributes?.name, 'Rey Skybarker', 'resource cache value for name is correct'); + assert.strictEqual(resource.attributes?.age, 42, 'resource cache value for age is correct'); + assert.strictEqual(resource.attributes?.netWorth, 1_000_000.009, 'resource cache value for netWorth is correct'); + assert.strictEqual(resource.attributes?.coolometer, 100.0, 'resource cache value for coolometer is correct'); + assert.strictEqual(resource.attributes?.rank, 0, 'resource cache value for rank is correct'); + }); +}); diff --git a/tests/warp-drive__schema-record/tests/reactivity/basic-fields-test.ts b/tests/warp-drive__schema-record/tests/reactivity/basic-fields-test.ts index 549e216d951..308f312c4eb 100644 --- a/tests/warp-drive__schema-record/tests/reactivity/basic-fields-test.ts +++ b/tests/warp-drive__schema-record/tests/reactivity/basic-fields-test.ts @@ -5,9 +5,10 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import type Store from '@ember-data/store'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; import type { SchemaRecord } from '@warp-drive/schema-record/record'; -import type { FieldSchema, Transform } from '@warp-drive/schema-record/schema'; +import type { Transform } from '@warp-drive/schema-record/schema'; import { registerDerivations, SchemaService, withFields } from '@warp-drive/schema-record/schema'; import { reactiveContext } from '../-utils/reactive-context'; @@ -36,7 +37,7 @@ module('Reactivity | basic fields can receive remote updates', function (hooks) { name: 'name', type: null, - kind: 'attribute', + kind: 'field', }, ]), }); @@ -117,30 +118,30 @@ module('Reactivity | basic fields can receive remote updates', function (hooks) { name: 'name', type: null, - kind: 'attribute', + kind: 'field', }, { name: 'rank', type: 'float', - kind: 'attribute', + kind: 'field', options: { precision: 0 }, }, { name: 'age', type: 'float', options: { precision: 0 }, - kind: 'attribute', + kind: 'field', }, { name: 'netWorth', type: 'float', options: { precision: 2 }, - kind: 'attribute', + kind: 'field', }, { name: 'coolometer', type: 'float', - kind: 'attribute', + kind: 'field', }, ]), }); diff --git a/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts b/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts index e36cafc4b9d..733028b137c 100644 --- a/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts +++ b/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts @@ -5,8 +5,9 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import type Store from '@ember-data/store'; +import { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import { SchemaRecord } from '@warp-drive/schema-record/record'; -import { FieldSchema, registerDerivations, SchemaService, withFields } from '@warp-drive/schema-record/schema'; +import { registerDerivations, SchemaService, withFields } from '@warp-drive/schema-record/schema'; import { reactiveContext } from '../-utils/reactive-context'; @@ -44,12 +45,12 @@ module('Reactivity | derivation', function (hooks) { { name: 'firstName', type: null, - kind: 'attribute', + kind: 'field', }, { name: 'lastName', type: null, - kind: 'attribute', + kind: 'field', }, { name: 'fullName', @@ -112,7 +113,7 @@ module('Reactivity | derivation', function (hooks) { assert.strictEqual(counters.id, 1, 'id Count is 1'); assert.strictEqual(counters.$type, 1, '$type Count is 1'); - assert.strictEqual(counters.firstName, 1, 'firstName Count is 2'); + assert.strictEqual(counters.firstName, 1, 'firstName Count is 1'); assert.strictEqual(counters.lastName, 2, 'lastName Count is 2'); assert.strictEqual(counters.fullName, 2, 'fullName Count is 2'); @@ -120,4 +121,109 @@ module('Reactivity | derivation', function (hooks) { assert.dom(`li:nth-child(${nameIndex + 3})`).hasText('lastName: Skybarker', 'lastName is rendered'); assert.dom(`li:nth-child(${nameIndex + 5})`).hasText('fullName: Rey Skybarker', 'fullName is rendered'); }); + + test('derivations do not re-run unless the tracked state they consume is dirtied', async 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 }; + const result = opts.fields.map((field) => record[field]).join(opts.separator ?? ''); + assert.step(`concat: ${result}`); + return result; + } + + schema.registerDerivation('concat', concat); + + schema.defineSchema('user', { + fields: [ + { + name: 'age', + type: null, + kind: 'field', + }, + { + name: 'firstName', + type: null, + kind: 'field', + }, + { + name: 'lastName', + type: null, + kind: 'field', + }, + { + name: 'fullName', + type: 'concat', + options: { fields: ['firstName', 'lastName'], separator: ' ' }, + kind: 'derived', + }, + ], + }); + + const record = store.push({ + data: { + id: '1', + type: 'user', + attributes: { + age: 3, + firstName: 'Rey', + lastName: 'Pupatine', + }, + }, + }) as User; + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.firstName, 'Rey', 'firstName is accessible'); + assert.strictEqual(record.lastName, 'Pupatine', 'lastName is accessible'); + + assert.verifySteps([], 'no concat yet'); + + assert.strictEqual(record.fullName, 'Rey Pupatine', 'fullName is accessible'); + + assert.verifySteps(['concat: Rey Pupatine'], 'concat happened'); + + assert.strictEqual(record.fullName, 'Rey Pupatine', 'fullName is accessible'); + + assert.verifySteps([], 'no additional concat'); + + store.push({ + data: { + id: '1', + type: 'user', + attributes: { + age: 4, + firstName: 'Rey', + lastName: 'Pupatine', // NO CHANGE + }, + }, + }) as User; + + assert.strictEqual(record.fullName, 'Rey Pupatine', 'fullName is accessible'); + + assert.verifySteps([], 'no additional concat'); + + store.push({ + data: { + id: '1', + type: 'user', + attributes: { + age: 5, + firstName: 'Rey', + lastName: 'Porcupine', // NOW A CHANGE + }, + }, + }) as User; + + assert.strictEqual(record.fullName, 'Rey Porcupine', 'fullName is accessible'); + + assert.verifySteps(['concat: Rey Porcupine'], 'it recomputed!'); + }); }); diff --git a/tests/warp-drive__schema-record/tests/reactivity/resource-test.ts b/tests/warp-drive__schema-record/tests/reactivity/resource-test.ts index 150568c104d..b777a5ba86f 100644 --- a/tests/warp-drive__schema-record/tests/reactivity/resource-test.ts +++ b/tests/warp-drive__schema-record/tests/reactivity/resource-test.ts @@ -39,7 +39,7 @@ module('Reactivity | resource', function (hooks) { { name: 'name', type: null, - kind: 'attribute', + kind: 'field', }, { name: 'bestFriend', diff --git a/tests/warp-drive__schema-record/tests/reads/basic-fields-test.ts b/tests/warp-drive__schema-record/tests/reads/basic-fields-test.ts index 3bed8df0abc..b0917849878 100644 --- a/tests/warp-drive__schema-record/tests/reads/basic-fields-test.ts +++ b/tests/warp-drive__schema-record/tests/reads/basic-fields-test.ts @@ -34,7 +34,7 @@ module('Reads | basic fields', function (hooks) { { name: 'name', type: null, - kind: 'attribute', + kind: 'field', }, ]), }); @@ -90,35 +90,35 @@ module('Reads | basic fields', function (hooks) { { name: 'name', type: null, - kind: 'attribute', + kind: 'field', }, { name: 'lastName', type: 'string', - kind: 'attribute', + kind: 'field', }, { name: 'rank', type: 'float', - kind: 'attribute', + kind: 'field', options: { precision: 0 }, }, { name: 'age', type: 'float', options: { precision: 0 }, - kind: 'attribute', + kind: 'field', }, { name: 'netWorth', type: 'float', options: { precision: 2 }, - kind: 'attribute', + kind: 'field', }, { name: 'coolometer', type: 'float', - kind: 'attribute', + kind: 'field', }, ]), }); @@ -171,7 +171,7 @@ module('Reads | basic fields', function (hooks) { assert.strictEqual(resource.id, null, 'resource cache id is correct'); assert.strictEqual(resource.attributes?.name, 'Rey Skybarker', 'resource cache value for name is correct'); assert.strictEqual(resource.attributes?.age, '42', 'resource cache value for age is correct'); - assert.strictEqual(resource.attributes?.netWorth, '1000000.01', 'cresource ache value for netWorth is correct'); + assert.strictEqual(resource.attributes?.netWorth, '1000000.01', 'resource cache value for netWorth is correct'); assert.strictEqual(resource.attributes?.coolometer, '100.000', 'resource cache value for coolometer is correct'); assert.strictEqual(resource.attributes?.rank, '0', 'resource cache value for rank is correct'); }); diff --git a/tests/warp-drive__schema-record/tests/reads/derivation-test.ts b/tests/warp-drive__schema-record/tests/reads/derivation-test.ts index 7fe1cb06d1b..4e5a7719ea1 100644 --- a/tests/warp-drive__schema-record/tests/reads/derivation-test.ts +++ b/tests/warp-drive__schema-record/tests/reads/derivation-test.ts @@ -40,12 +40,12 @@ module('Reads | derivation', function (hooks) { { name: 'firstName', type: null, - kind: 'attribute', + kind: 'field', }, { name: 'lastName', type: null, - kind: 'attribute', + kind: 'field', }, { name: 'fullName', diff --git a/tests/warp-drive__schema-record/tests/reads/resource-test.ts b/tests/warp-drive__schema-record/tests/reads/resource-test.ts index e05437ad8b9..a7a545cf745 100644 --- a/tests/warp-drive__schema-record/tests/reads/resource-test.ts +++ b/tests/warp-drive__schema-record/tests/reads/resource-test.ts @@ -42,7 +42,7 @@ module('Reads | resource', function (hooks) { { name: 'name', type: null, - kind: 'attribute', + kind: 'field', }, { name: 'bestFriend', diff --git a/tests/warp-drive__schema-record/tests/schema-test.ts b/tests/warp-drive__schema-record/tests/schema-test.ts new file mode 100644 index 00000000000..675faec2d75 --- /dev/null +++ b/tests/warp-drive__schema-record/tests/schema-test.ts @@ -0,0 +1,7 @@ +// FIXME: attributesDefinitionFor does not return fields + +// FIXME: relationshipsDefinitionFor returns things that were resource or collection fields + +// FIXME: we can register a derivation + +// FIXME: we can register a transform From d5013be540879f7e369381310b729a2067799b07 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Mon, 13 Nov 2023 14:40:40 -0800 Subject: [PATCH 02/10] Fix test failures from rebase --- packages/model/src/migration-support.ts | 30 +++++-- packages/schema-record/src/-base-fields.ts | 25 ++++++ packages/schema-record/src/record.ts | 9 +- .../tests/legacy/mode-test.ts | 90 ++++++++++--------- .../legacy/reactivity/basic-fields-test.ts | 22 +++-- .../tests/legacy/reads/basic-fields-test.ts | 14 ++- .../tests/reactivity/derivation-test.ts | 5 +- 7 files changed, 129 insertions(+), 66 deletions(-) diff --git a/packages/model/src/migration-support.ts b/packages/model/src/migration-support.ts index f434f9abaf8..08f66788bf5 100644 --- a/packages/model/src/migration-support.ts +++ b/packages/model/src/migration-support.ts @@ -30,7 +30,6 @@ const LegacyFields = [ 'adapterError', 'belongsTo', 'changedAttributes', - 'constructor', 'currentState', 'deleteRecord', 'destroyRecord', @@ -71,11 +70,6 @@ function legacySupport(record: MinimalLegacyRecord, options: Record | null, + prop: string +): unknown { + let state = LegacySupport.get(record); + if (!state) { + state = {}; + LegacySupport.set(record, state); + } + + return (state._constructor = state._constructor || { + isModel: true, + name: `Record<${recordIdentifierFor(record).type}>`, + modelName: recordIdentifierFor(record).type, + }); +} + export function withFields(fields: FieldSchema[]) { LegacyFields.forEach((field) => { fields.push({ @@ -131,6 +143,11 @@ export function withFields(fields: FieldSchema[]) { kind: 'derived', }); }); + fields.push({ + type: '@legacyConstructor', + name: 'constructor', + kind: 'derived', + }); fields.push({ name: 'id', kind: '@id', @@ -159,4 +176,5 @@ export function withFields(fields: FieldSchema[]) { export function registerDerivations(schema: SchemaService) { schema.registerDerivation('@legacy', legacySupport as Derivation); + schema.registerDerivation('@legacyConstructor', legacyConstructor as Derivation); } diff --git a/packages/schema-record/src/-base-fields.ts b/packages/schema-record/src/-base-fields.ts index e6b6a676533..36d4f7e2928 100644 --- a/packages/schema-record/src/-base-fields.ts +++ b/packages/schema-record/src/-base-fields.ts @@ -1,12 +1,21 @@ import { assert } from '@ember/debug'; +import { recordIdentifierFor } from '@ember-data/store'; +import { RecordInstance } from '@ember-data/store/-types/q/record-instance'; import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; import { Identifier, type SchemaRecord } from './record'; import type { Derivation, SchemaService } from './schema'; +const Support = new WeakMap>(); + export const SchemaRecordFields: FieldSchema[] = [ + { + type: '@constructor', + name: 'constructor', + kind: 'derived', + }, { name: 'id', kind: '@id', @@ -20,6 +29,21 @@ export const SchemaRecordFields: FieldSchema[] = [ }, ]; +const _constructor: Derivation = function (record) { + let state = Support.get(record as WeakKey); + if (!state) { + state = {}; + Support.set(record as WeakKey, state); + } + + return (state._constructor = state._constructor || { + name: `SchemaRecord<${recordIdentifierFor(record).type}>`, + get modelName() { + throw new Error('Cannot access record.constructor.modelName on non-Legacy Schema Records.'); + }, + }); +}; + export function withFields(fields: FieldSchema[]) { fields.push(...SchemaRecordFields); return fields; @@ -50,4 +74,5 @@ export function registerDerivations(schema: SchemaService) { '@identity', fromIdentity as Derivation ); + schema.registerDerivation('@constructor', _constructor); } diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index 8183604f187..f7857e423e8 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -263,10 +263,11 @@ export class SchemaRecord { return computeResource(store, cache, target, identifier, field, prop as string); case 'derived': - assert( - `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, - !target[Legacy] - ); + // FIXME: + // assert( + // `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, + // !target[Legacy] + // ); return computeDerivation(schema, receiver as unknown as SchemaRecord, identifier, field, prop as string); default: throw new Error(`Field '${String(prop)}' on '${identifier.type}' has the unknown kind '${field.kind}'`); diff --git a/tests/warp-drive__schema-record/tests/legacy/mode-test.ts b/tests/warp-drive__schema-record/tests/legacy/mode-test.ts index d61fcda423c..2505ea7e838 100644 --- a/tests/warp-drive__schema-record/tests/legacy/mode-test.ts +++ b/tests/warp-drive__schema-record/tests/legacy/mode-test.ts @@ -6,11 +6,14 @@ import { adapterFor, LegacyNetworkHandler, serializeRecord, serializerFor } from import type { Snapshot } from '@ember-data/legacy-compat/-private'; import type Errors from '@ember-data/model/-private/errors'; import type RecordState from '@ember-data/model/-private/record-state'; -import { registerDerivations, withFields } from '@ember-data/model/migration-support'; +import { + registerDerivations as registerLegacyDerivations, + withFields as withLegacyFields, +} from '@ember-data/model/migration-support'; import RequestManager from '@ember-data/request'; import Store, { CacheHandler } from '@ember-data/store'; import { Editable, Legacy } from '@warp-drive/schema-record/record'; -import { SchemaService } from '@warp-drive/schema-record/schema'; +import { registerDerivations, SchemaService, withFields } from '@warp-drive/schema-record/schema'; interface User { [Legacy]: boolean; @@ -57,11 +60,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -99,13 +102,13 @@ module('Legacy Mode', function (hooks) { schema.defineSchema('user', { legacy: false, - fields: [ + fields: withFields([ { name: 'name', type: null, kind: 'field', }, - ], + ]), }); const record = store.push({ @@ -124,7 +127,7 @@ module('Legacy Mode', function (hooks) { } catch (e) { assert.strictEqual( (e as Error).message, - 'No field named constructor on user', + 'Cannot access record.constructor.modelName on non-Legacy Schema Records.', 'record.constructor.modelName throws' ); } @@ -135,11 +138,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -170,16 +173,17 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); + registerDerivations(schema); schema.defineSchema('user', { legacy: false, - fields: [ + fields: withFields([ { name: 'name', type: null, kind: 'field', }, - ], + ]), }); const record = store.push({ @@ -200,16 +204,17 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: [ + fields: withLegacyFields([ { name: 'name', type: null, kind: 'field', }, - ], + ]), }); const record = store.push({ @@ -241,7 +246,7 @@ module('Legacy Mode', function (hooks) { schema.defineSchema('user', { legacy: true, - fields: [ + fields: withLegacyFields([ { name: 'firstName', type: null, @@ -258,7 +263,7 @@ module('Legacy Mode', function (hooks) { options: { fields: ['firstName', 'lastName'], separator: ' ' }, kind: 'derived', }, - ], + ]), }); const record = store.push({ @@ -278,6 +283,7 @@ module('Legacy Mode', function (hooks) { record.fullName; assert.ok(false, 'record.fullName should throw'); } catch (e) { + // FIXME: assert.strictEqual( (e as Error).message, "Assertion Failed: SchemaRecord.fullName is not available in legacy mode because it has type 'derived'", @@ -290,10 +296,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: [ + fields: withLegacyFields([ { name: 'name', type: null, @@ -305,7 +312,7 @@ module('Legacy Mode', function (hooks) { kind: 'resource', options: { inverse: 'bestFriend', async: true }, }, - ], + ]), }); const record = store.push({ @@ -355,11 +362,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -391,11 +398,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -429,11 +436,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -464,11 +471,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -494,11 +501,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -526,11 +533,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -564,11 +571,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -585,6 +592,7 @@ module('Legacy Mode', function (hooks) { }, }) as User; + // FIXME: Is this assertion correct? assert.false(record.isDestroying, 'isDestroying is correct'); assert.false(record.isDestroyed, 'isDestroyed is correct'); }); @@ -618,11 +626,11 @@ module('Legacy Mode', function (hooks) { }; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -685,11 +693,11 @@ module('Legacy Mode', function (hooks) { store.requestManager.use([LegacyNetworkHandler]); const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -718,11 +726,11 @@ module('Legacy Mode', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -787,11 +795,11 @@ module('Legacy Mode', function (hooks) { store.requestManager.use([LegacyNetworkHandler]); const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, @@ -852,11 +860,11 @@ module('Legacy Mode', function (hooks) { store.requestManager.use([LegacyNetworkHandler]); const schema = new SchemaService(); store.registerSchema(schema); - registerDerivations(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: withFields([ + fields: withLegacyFields([ { name: 'name', type: null, diff --git a/tests/warp-drive__schema-record/tests/legacy/reactivity/basic-fields-test.ts b/tests/warp-drive__schema-record/tests/legacy/reactivity/basic-fields-test.ts index 92ee93d4802..a9d4dd5924b 100644 --- a/tests/warp-drive__schema-record/tests/legacy/reactivity/basic-fields-test.ts +++ b/tests/warp-drive__schema-record/tests/legacy/reactivity/basic-fields-test.ts @@ -5,6 +5,10 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; +import { + registerDerivations as registerLegacyDerivations, + withFields as withLegacyFields, +} from '@ember-data/model/migration-support'; import type Store from '@ember-data/store'; import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; @@ -17,7 +21,6 @@ import { reactiveContext } from '../../-utils/reactive-context'; interface User { id: string | null; - $type: 'user'; name: string; age: number; netWorth: number; @@ -32,16 +35,17 @@ module('Legacy | Reactivity | basic fields can receive remote updates', function const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: [ + fields: withLegacyFields([ { name: 'name', type: null, kind: 'attribute', }, - ], + ]), }); const fieldsMap = schema.schemas.get('user')!.fields; const fields: FieldSchema[] = [...fieldsMap.values()]; @@ -61,7 +65,6 @@ module('Legacy | Reactivity | basic fields can receive remote updates', function const nameIndex = fieldOrder.indexOf('name'); assert.strictEqual(counters.id, 1, 'idCount is 1'); - assert.strictEqual(counters.$type, 0, '$typeCount is 0'); assert.strictEqual(counters.name, 1, 'nameCount is 1'); assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Pupatine', 'name is rendered'); @@ -81,7 +84,6 @@ module('Legacy | Reactivity | basic fields can receive remote updates', function await rerender(); assert.strictEqual(counters.id, 1, 'idCount is 1'); - assert.strictEqual(counters.$type, 0, '$typeCount is 0'); assert.strictEqual(counters.name, 2, 'nameCount is 1'); assert.dom(`li:nth-child(${nameIndex + 1})`).hasText('name: Rey Skybarker', 'name is rendered'); @@ -91,6 +93,7 @@ module('Legacy | Reactivity | basic fields can receive remote updates', function const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); + registerLegacyDerivations(schema); this.owner.register( 'transform:float', @@ -124,7 +127,7 @@ module('Legacy | Reactivity | basic fields can receive remote updates', function schema.defineSchema('user', { legacy: true, - fields: [ + fields: withLegacyFields([ { name: 'name', type: null, @@ -154,7 +157,7 @@ module('Legacy | Reactivity | basic fields can receive remote updates', function options: { defaultValue: 0 }, kind: 'attribute', }, - ], + ]), }); const fieldsMap = schema.schemas.get('user')!.fields; @@ -251,6 +254,7 @@ module('Legacy | Reactivity | basic fields can receive remote updates', function const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); + registerLegacyDerivations(schema); this.owner.register( 'transform:float', @@ -284,7 +288,7 @@ module('Legacy | Reactivity | basic fields can receive remote updates', function schema.defineSchema('user', { legacy: true, - fields: [ + fields: withLegacyFields([ { name: 'name', type: null, @@ -295,7 +299,7 @@ module('Legacy | Reactivity | basic fields can receive remote updates', function type: 'float', kind: 'attribute', }, - ], + ]), }); const fieldsMap = schema.schemas.get('user')!.fields; diff --git a/tests/warp-drive__schema-record/tests/legacy/reads/basic-fields-test.ts b/tests/warp-drive__schema-record/tests/legacy/reads/basic-fields-test.ts index 203b7758169..2e3369d70f1 100644 --- a/tests/warp-drive__schema-record/tests/legacy/reads/basic-fields-test.ts +++ b/tests/warp-drive__schema-record/tests/legacy/reads/basic-fields-test.ts @@ -4,6 +4,10 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; +import { + registerDerivations as registerLegacyDerivations, + withFields as withLegacyFields, +} from '@ember-data/model/migration-support'; import type Store from '@ember-data/store'; import { recordIdentifierFor } from '@ember-data/store'; import type { JsonApiResource } from '@ember-data/store/-types/q/record-data-json-api'; @@ -29,16 +33,17 @@ module('Legacy | Reads | basic fields', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); + registerLegacyDerivations(schema); schema.defineSchema('user', { legacy: true, - fields: [ + fields: withLegacyFields([ { name: 'name', type: null, kind: 'attribute', }, - ], + ]), }); const record = store.createRecord('user', { name: 'Rey Skybarker' }) as User; @@ -69,6 +74,7 @@ module('Legacy | Reads | basic fields', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); + registerLegacyDerivations(schema); this.owner.register( 'transform:float', @@ -101,7 +107,7 @@ module('Legacy | Reads | basic fields', function (hooks) { schema.defineSchema('user', { legacy: true, - fields: [ + fields: withLegacyFields([ { name: 'name', type: null, @@ -136,7 +142,7 @@ module('Legacy | Reads | basic fields', function (hooks) { options: { defaultValue: 0 }, kind: 'attribute', }, - ], + ]), }); const record = store.createRecord('user', { diff --git a/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts b/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts index 733028b137c..7ef80a5644b 100644 --- a/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts +++ b/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts @@ -126,6 +126,7 @@ module('Reactivity | derivation', function (hooks) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); + registerDerivations(schema); function concat( record: SchemaRecord & { [key: string]: unknown }, @@ -142,7 +143,7 @@ module('Reactivity | derivation', function (hooks) { schema.registerDerivation('concat', concat); schema.defineSchema('user', { - fields: [ + fields: withFields([ { name: 'age', type: null, @@ -164,7 +165,7 @@ module('Reactivity | derivation', function (hooks) { options: { fields: ['firstName', 'lastName'], separator: ' ' }, kind: 'derived', }, - ], + ]), }); const record = store.push({ From 250ce349d991c3fcdd0ea0d1ea71dbf854d0c5c4 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Tue, 14 Nov 2023 09:42:39 -0800 Subject: [PATCH 03/10] Fix bug where @local defaults were not properly set --- packages/schema-record/src/record.ts | 22 ++++++++----------- packages/tracking/src/-private.ts | 8 ++++++- .../tests/legacy/mode-test.ts | 2 +- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index f7857e423e8..00a9a44f724 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -35,7 +35,7 @@ export const Legacy = Symbol('Legacy'); const IgnoredGlobalFields = new Set(['then', STRUCTURED]); const RecordSymbols = new Set([Destroy, RecordStore, Identifier, Editable, Parent, Checkout, Legacy, Signals]); -function computeLocal(record: SchemaRecord, field: FieldSchema, prop: string): unknown { +function computeLocal(record: typeof Proxy, field: FieldSchema, prop: string): unknown { let signal = peekSignal(record, prop); if (!signal) { @@ -239,11 +239,13 @@ export class SchemaRecord { switch (field.kind) { case '@id': - entangleSignal(signals, this, '@identity'); + entangleSignal(signals, receiver, '@identity'); return identifier.id; - case '@local': - entangleSignal(signals, this, field.name); - return computeLocal(target, field, prop as string); + case '@local': { + const lastValue = computeLocal(receiver, field, prop as string); + entangleSignal(signals, receiver, prop as string); + return lastValue; + } case 'field': assert( `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, @@ -261,19 +263,13 @@ export class SchemaRecord { ); entangleSignal(signals, receiver, field.name); return computeResource(store, cache, target, identifier, field, prop as string); - case 'derived': - // FIXME: - // assert( - // `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, - // !target[Legacy] - // ); return computeDerivation(schema, receiver as unknown as SchemaRecord, identifier, field, prop as string); default: throw new Error(`Field '${String(prop)}' on '${identifier.type}' has the unknown kind '${field.kind}'`); } }, - set(target: SchemaRecord, prop: string | number | symbol, value: unknown) { + set(target: SchemaRecord, prop: string | number | symbol, value: unknown, receiver: typeof Proxy) { if (!IS_EDITABLE) { throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because the record is not editable`); } @@ -285,7 +281,7 @@ export class SchemaRecord { switch (field.kind) { case '@local': { - const signal = getSignal(target, prop as string, true); + const signal = getSignal(receiver, prop as string, true); if (signal.lastValue !== value) { signal.lastValue = value; addToTransaction(signal); diff --git a/packages/tracking/src/-private.ts b/packages/tracking/src/-private.ts index 0b370993260..d0bb1a1d72e 100644 --- a/packages/tracking/src/-private.ts +++ b/packages/tracking/src/-private.ts @@ -451,7 +451,13 @@ interface Signaler { } export function getSignal(obj: T, key: string, initialState: boolean): Signal { - const signals = ((obj as Signaler)[Signals] = (obj as Signaler)[Signals] || new Map()); + let signals = (obj as Signaler)[Signals]; + + if (!signals) { + signals = new Map(); + (obj as Signaler)[Signals] = signals; + } + let _signal = signals.get(key); if (!_signal) { _signal = createSignal(obj, key); diff --git a/tests/warp-drive__schema-record/tests/legacy/mode-test.ts b/tests/warp-drive__schema-record/tests/legacy/mode-test.ts index 2505ea7e838..1d200ef4769 100644 --- a/tests/warp-drive__schema-record/tests/legacy/mode-test.ts +++ b/tests/warp-drive__schema-record/tests/legacy/mode-test.ts @@ -239,6 +239,7 @@ module('Legacy Mode', function (hooks) { } }); + // FIXME: We should allow this now but move this test elsewhere to test the derivation-not-found error test('records in legacy mode cannot access derivations', function (assert) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); @@ -592,7 +593,6 @@ module('Legacy Mode', function (hooks) { }, }) as User; - // FIXME: Is this assertion correct? assert.false(record.isDestroying, 'isDestroying is correct'); assert.false(record.isDestroyed, 'isDestroyed is correct'); }); From 7581fd59e4ef2f788914d59b7315259010034768 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Tue, 14 Nov 2023 09:50:35 -0800 Subject: [PATCH 04/10] Allow derivations in all the places --- .../tests/legacy/mode-test.ts | 54 ------------------- .../tests/reads/derivation-test.ts | 50 +++++++++++++++++ 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/tests/warp-drive__schema-record/tests/legacy/mode-test.ts b/tests/warp-drive__schema-record/tests/legacy/mode-test.ts index 1d200ef4769..2a3e7371855 100644 --- a/tests/warp-drive__schema-record/tests/legacy/mode-test.ts +++ b/tests/warp-drive__schema-record/tests/legacy/mode-test.ts @@ -239,60 +239,6 @@ module('Legacy Mode', function (hooks) { } }); - // FIXME: We should allow this now but move this test elsewhere to test the derivation-not-found error - test('records in legacy mode cannot access derivations', function (assert) { - const store = this.owner.lookup('service:store') as Store; - const schema = new SchemaService(); - store.registerSchema(schema); - - schema.defineSchema('user', { - legacy: true, - fields: withLegacyFields([ - { - 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.push({ - data: { - type: 'user', - id: '1', - attributes: { - firstName: 'Rey', - lastName: 'Pupatine', - }, - }, - }) as User; - - assert.true(record[Legacy], 'record is in legacy mode'); - - try { - record.fullName; - assert.ok(false, 'record.fullName should throw'); - } catch (e) { - // FIXME: - assert.strictEqual( - (e as Error).message, - "Assertion Failed: SchemaRecord.fullName is not available in legacy mode because it has type 'derived'", - 'record.fullName throws' - ); - } - }); - test('records in legacy mode cannot access resources', function (assert) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); diff --git a/tests/warp-drive__schema-record/tests/reads/derivation-test.ts b/tests/warp-drive__schema-record/tests/reads/derivation-test.ts index 4e5a7719ea1..437442f559c 100644 --- a/tests/warp-drive__schema-record/tests/reads/derivation-test.ts +++ b/tests/warp-drive__schema-record/tests/reads/derivation-test.ts @@ -65,4 +65,54 @@ module('Reads | derivation', function (hooks) { assert.strictEqual(record.lastName, 'Skybarker', 'lastName is accessible'); assert.strictEqual(record.fullName, 'Rey Skybarker', 'fullName is accessible'); }); + + test('throws an error if derivation is not found', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const schema = new SchemaService(); + store.registerSchema(schema); + registerDerivations(schema); + + schema.defineSchema('user', { + fields: withFields([ + { + 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.push({ + data: { + type: 'user', + id: '1', + attributes: { + firstName: 'Rey', + lastName: 'Pupatine', + }, + }, + }) as User; + + try { + record.fullName; + assert.ok(false, 'record.fullName should throw'); + } catch (e) { + assert.strictEqual( + (e as Error).message, + "No 'concat' derivation defined for use by user.fullName", + 'record.fullName throws' + ); + } + }); }); From f56db589d7de0a9fdd2039c3b043e27d799e6947 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Tue, 14 Nov 2023 10:02:56 -0800 Subject: [PATCH 05/10] Revert extraction of legacyConstructor derivation --- packages/model/src/migration-support.ts | 31 ++++++------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/packages/model/src/migration-support.ts b/packages/model/src/migration-support.ts index 08f66788bf5..60a39bfab55 100644 --- a/packages/model/src/migration-support.ts +++ b/packages/model/src/migration-support.ts @@ -30,6 +30,7 @@ const LegacyFields = [ 'adapterError', 'belongsTo', 'changedAttributes', + 'constructor', 'currentState', 'deleteRecord', 'destroyRecord', @@ -70,6 +71,12 @@ function legacySupport(record: MinimalLegacyRecord, options: Record`, + modelName: recordIdentifierFor(record).type, + }); case 'currentState': return (state.recordState = state.recordState || new RecordState(record)); case 'deleteRecord': @@ -117,24 +124,6 @@ function legacySupport(record: MinimalLegacyRecord, options: Record | null, - prop: string -): unknown { - let state = LegacySupport.get(record); - if (!state) { - state = {}; - LegacySupport.set(record, state); - } - - return (state._constructor = state._constructor || { - isModel: true, - name: `Record<${recordIdentifierFor(record).type}>`, - modelName: recordIdentifierFor(record).type, - }); -} - export function withFields(fields: FieldSchema[]) { LegacyFields.forEach((field) => { fields.push({ @@ -143,11 +132,6 @@ export function withFields(fields: FieldSchema[]) { kind: 'derived', }); }); - fields.push({ - type: '@legacyConstructor', - name: 'constructor', - kind: 'derived', - }); fields.push({ name: 'id', kind: '@id', @@ -176,5 +160,4 @@ export function withFields(fields: FieldSchema[]) { export function registerDerivations(schema: SchemaService) { schema.registerDerivation('@legacy', legacySupport as Derivation); - schema.registerDerivation('@legacyConstructor', legacyConstructor as Derivation); } From dd1b0255f6c5bcf9022853795f9418573ef63179 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Tue, 14 Nov 2023 10:04:57 -0800 Subject: [PATCH 06/10] Fix DX test --- tests/docs/fixtures/expected.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index 976a288a536..7bdafe17bfb 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -468,6 +468,7 @@ module.exports = { '(public) @ember-data/store CacheManager#rollbackRelationships', '(public) @ember-data/store CacheCapabilitiesManager#disconnectRecord', '(public) @ember-data/store CacheCapabilitiesManager#getSchemaDefinitionService', + '(public) @ember-data/store CacheCapabilitiesManager#schema', '(public) @ember-data/store CacheCapabilitiesManager#hasRecord', '(public) @ember-data/store CacheCapabilitiesManager#identifierCache', '(public) @ember-data/store CacheCapabilitiesManager#notifyChange', From dd05d902dff8445dadfcce14a0bbb389f7a6a3b5 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Tue, 14 Nov 2023 10:07:02 -0800 Subject: [PATCH 07/10] Fix lint --- .../tests/reactivity/derivation-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts b/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts index 7ef80a5644b..77e308f280f 100644 --- a/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts +++ b/tests/warp-drive__schema-record/tests/reactivity/derivation-test.ts @@ -122,7 +122,7 @@ module('Reactivity | derivation', function (hooks) { assert.dom(`li:nth-child(${nameIndex + 5})`).hasText('fullName: Rey Skybarker', 'fullName is rendered'); }); - test('derivations do not re-run unless the tracked state they consume is dirtied', async function (assert) { + test('derivations do not re-run unless the tracked state they consume is dirtied', function (assert) { const store = this.owner.lookup('service:store') as Store; const schema = new SchemaService(); store.registerSchema(schema); From 8e9105ff8a209e465ef820d93b3bcf69fff9bbb7 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Tue, 14 Nov 2023 10:41:37 -0800 Subject: [PATCH 08/10] Fix json-api tests --- .../cache/collection-data-documents-test.ts | 27 +++++++++++++++++++ .../cache/resource-data-documents-test.ts | 26 ++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/ember-data__json-api/tests/integration/cache/collection-data-documents-test.ts b/tests/ember-data__json-api/tests/integration/cache/collection-data-documents-test.ts index f15567e38cd..d74c67878e4 100644 --- a/tests/ember-data__json-api/tests/integration/cache/collection-data-documents-test.ts +++ b/tests/ember-data__json-api/tests/integration/cache/collection-data-documents-test.ts @@ -11,6 +11,7 @@ import type { CacheCapabilitiesManager } from '@ember-data/store/-types/q/cache- import type { CollectionResourceDocument } from '@warp-drive/core-types/spec/raw'; import { JsonApiResource } from '@ember-data/store/-types/q/record-data-json-api'; import { AttributesSchema, RelationshipsSchema } from '@warp-drive/core-types/schema'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; type FakeRecord = { [key: string]: unknown; destroy: () => void }; class TestStore extends Store { @@ -55,6 +56,31 @@ class TestSchema { return this.schemas[identifier.type]?.attributes || {}; } + _fieldsDefCache: Record> = {}; + + fields(identifier: StableRecordIdentifier | { type: string }): Map { + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; + } + relationshipsDefinitionFor(identifier: { type: T }): RelationshipsSchema { return this.schemas[identifier.type]?.relationships || {}; } @@ -135,6 +161,7 @@ module('Integration | @ember-data/json-api Cache.put()', test('resources are accessible via `peek`', function (assert) { const store = new TestStore(); store.registerSchema(new TestSchema()); + debugger; const responseDocument = store.cache.put({ content: { diff --git a/tests/ember-data__json-api/tests/integration/cache/resource-data-documents-test.ts b/tests/ember-data__json-api/tests/integration/cache/resource-data-documents-test.ts index 3ee98bceb69..45a79fb9bc5 100644 --- a/tests/ember-data__json-api/tests/integration/cache/resource-data-documents-test.ts +++ b/tests/ember-data__json-api/tests/integration/cache/resource-data-documents-test.ts @@ -12,6 +12,7 @@ import type { JsonApiResource } from '@ember-data/store/-types/q/record-data-jso import type { StableDocumentIdentifier } from '@warp-drive/core-types/identifier'; import type { AttributesSchema, RelationshipsSchema } from '@warp-drive/core-types/schema'; import type { SingleResourceDocument } from '@warp-drive/core-types/spec/raw'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; type FakeRecord = { [key: string]: unknown; destroy: () => void }; class TestStore extends Store { @@ -56,6 +57,31 @@ class TestSchema { return this.schemas[identifier.type]?.attributes || {}; } + _fieldsDefCache: Record> = {}; + + fields(identifier: StableRecordIdentifier | { type: string }): Map { + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; + } + relationshipsDefinitionFor(identifier: { type: T }): RelationshipsSchema { return this.schemas[identifier.type]?.relationships || {}; } From 451c4851c713a56353d1bdc55f14fe99d0046515 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Tue, 14 Nov 2023 10:50:53 -0800 Subject: [PATCH 09/10] Fix more tests --- .../integration/cache/meta-documents-test.ts | 26 ++++++++++ .../tests/integration/serialize-test.ts | 26 ++++++++++ .../tests/integration/model-for-test.ts | 47 +++++++++++++++++++ .../custom-class-model-test.ts | 24 ++++++++++ 4 files changed, 123 insertions(+) diff --git a/tests/ember-data__json-api/tests/integration/cache/meta-documents-test.ts b/tests/ember-data__json-api/tests/integration/cache/meta-documents-test.ts index da176baf205..3a6e62991fb 100644 --- a/tests/ember-data__json-api/tests/integration/cache/meta-documents-test.ts +++ b/tests/ember-data__json-api/tests/integration/cache/meta-documents-test.ts @@ -9,6 +9,7 @@ import type { CollectionResourceDataDocument, ResourceMetaDocument } from '@embe import type { CacheCapabilitiesManager } from '@ember-data/store/-types/q/cache-store-wrapper'; import type { StableDocumentIdentifier } from '@warp-drive/core-types/identifier'; import { AttributesSchema, RelationshipsSchema } from '@warp-drive/core-types/schema'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; class TestStore extends Store { createCache(wrapper: CacheCapabilitiesManager) { @@ -27,6 +28,31 @@ class TestSchema { return this.schemas[identifier.type]?.attributes || {}; } + _fieldsDefCache: Record> = {}; + + fields(identifier: StableRecordIdentifier | { type: string }): Map { + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; + } + relationshipsDefinitionFor(identifier: { type: T }): RelationshipsSchema { return this.schemas[identifier.type]?.relationships || {}; } diff --git a/tests/ember-data__json-api/tests/integration/serialize-test.ts b/tests/ember-data__json-api/tests/integration/serialize-test.ts index 47190e4d132..e75ca6fe649 100644 --- a/tests/ember-data__json-api/tests/integration/serialize-test.ts +++ b/tests/ember-data__json-api/tests/integration/serialize-test.ts @@ -8,6 +8,7 @@ import type { NotificationType } from '@ember-data/store/-private/managers/notif import type { CacheCapabilitiesManager } from '@ember-data/store/-types/q/cache-store-wrapper'; import type { JsonApiResource } from '@ember-data/store/-types/q/record-data-json-api'; import type { AttributesSchema, RelationshipsSchema } from '@warp-drive/core-types/schema'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; type FakeRecord = { [key: string]: unknown; destroy: () => void }; class TestStore extends Store { @@ -52,6 +53,31 @@ class TestSchema { return this.schemas[identifier.type]?.attributes || {}; } + _fieldsDefCache: Record> = {}; + + fields(identifier: StableRecordIdentifier | { type: string }): Map { + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; + } + relationshipsDefinitionFor(identifier: { type: T }): RelationshipsSchema { return this.schemas[identifier.type]?.relationships || {}; } diff --git a/tests/ember-data__model/tests/integration/model-for-test.ts b/tests/ember-data__model/tests/integration/model-for-test.ts index a47cb07d40a..04b60d2a9fb 100644 --- a/tests/ember-data__model/tests/integration/model-for-test.ts +++ b/tests/ember-data__model/tests/integration/model-for-test.ts @@ -1,4 +1,5 @@ import { module, test } from '@warp-drive/diagnostic'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; import Store from '@ember-data/store'; @@ -28,6 +29,29 @@ module('modelFor without @ember-data/model', function () { }, }; }, + _fieldsDefCache: {} as Record>, + fields(identifier: StableRecordIdentifier | { type: string }): Map { + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; + }, relationshipsDefinitionFor(identifier) { return {}; }, @@ -79,6 +103,29 @@ module('modelFor without @ember-data/model', function () { }, }; }, + _fieldsDefCache: {} as Record>, + fields(identifier: StableRecordIdentifier | { type: string }): Map { + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; + }, relationshipsDefinitionFor(identifier) { return {}; }, diff --git a/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts b/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts index ed77605d2da..992eec8bea9 100644 --- a/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts +++ b/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts @@ -10,6 +10,7 @@ import JSONAPISerializer from '@ember-data/serializer/json-api'; import { Cache } from '@ember-data/store/-types/q/cache'; import type { AttributesSchema, RelationshipsSchema } from '@warp-drive/core-types/schema'; import type { SchemaService } from '@ember-data/store/-types/q/schema-service'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; module('unit/model - Custom Class Model', function (hooks: NestedHooks) { class Person { @@ -37,6 +38,29 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { }; return schema; }, + _fieldsDefCache: {} as Record>, + fields(identifier: StableRecordIdentifier | { type: string }): Map { + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; + }, relationshipsDefinitionFor() { return {}; }, From 53b2890f3341181ebaa787d62877dcd5cf70d63f Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Tue, 14 Nov 2023 11:18:55 -0800 Subject: [PATCH 10/10] Fix 'attribute and relationship with custom schema definition' test --- .../cache-handler/store-package-setup-test.ts | 13 +++ .../explicit-polymorphic-belongs-to-test.js | 4 + .../explicit-polymorphic-has-many-test.js | 4 + .../custom-class-model-test.ts | 79 ++++++++++++++++++- tests/main/tests/unit/debug-test.js | 4 + 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/tests/main/tests/integration/cache-handler/store-package-setup-test.ts b/tests/main/tests/integration/cache-handler/store-package-setup-test.ts index 60a731e4f37..36e34d43843 100644 --- a/tests/main/tests/integration/cache-handler/store-package-setup-test.ts +++ b/tests/main/tests/integration/cache-handler/store-package-setup-test.ts @@ -30,6 +30,7 @@ import type { JsonApiResource } from '@ember-data/store/-types/q/record-data-jso import type { RecordInstance } from '@ember-data/store/-types/q/record-instance'; import type { StableDocumentIdentifier } from '@warp-drive/core-types/identifier'; import type { ResourceIdentifierObject } from '@warp-drive/core-types/spec/raw'; +import type { FieldSchema } from '@ember-data/store/-types/q/schema-service'; type FakeRecord = { [key: string]: unknown; destroy: () => void }; @@ -50,6 +51,9 @@ class TestStore extends Store { attributesDefinitionFor() { return {}; }, + fields(identifier: StableRecordIdentifier | { type: string }): Map { + return new Map(); + }, relationshipsDefinitionFor() { return {}; }, @@ -276,6 +280,9 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) { attributesDefinitionFor() { return {}; }, + fields(identifier: StableRecordIdentifier | { type: string }): Map { + return new Map(); + }, relationshipsDefinitionFor() { return {}; }, @@ -371,6 +378,9 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) { attributesDefinitionFor() { return {}; }, + fields(identifier: StableRecordIdentifier | { type: string }): Map { + return new Map(); + }, relationshipsDefinitionFor() { return {}; }, @@ -467,6 +477,9 @@ module('Store | CacheHandler - @ember-data/store', function (hooks) { attributesDefinitionFor() { return {}; }, + fields(identifier: StableRecordIdentifier | { type: string }): Map { + return new Map(); + }, relationshipsDefinitionFor() { return {}; }, diff --git a/tests/main/tests/integration/relationships/explicit-polymorphic-belongs-to-test.js b/tests/main/tests/integration/relationships/explicit-polymorphic-belongs-to-test.js index ccc6e02665f..6ed5bbf7af7 100644 --- a/tests/main/tests/integration/relationships/explicit-polymorphic-belongs-to-test.js +++ b/tests/main/tests/integration/relationships/explicit-polymorphic-belongs-to-test.js @@ -387,6 +387,10 @@ module('Integration | Relationships | Explicit Polymorphic BelongsTo', function return this._schema.attributesDefinitionFor(identifier); } + fields(identifier) { + return this._schema.fields(identifier); + } + relationshipsDefinitionFor(identifier) { const schema = AbstractSchemas.get(identifier.type); return schema || this._schema.relationshipsDefinitionFor(identifier); diff --git a/tests/main/tests/integration/relationships/explicit-polymorphic-has-many-test.js b/tests/main/tests/integration/relationships/explicit-polymorphic-has-many-test.js index 4dcf58a4c02..f0438dd38b8 100644 --- a/tests/main/tests/integration/relationships/explicit-polymorphic-has-many-test.js +++ b/tests/main/tests/integration/relationships/explicit-polymorphic-has-many-test.js @@ -407,6 +407,10 @@ module('Integration | Relationships | Explicit Polymorphic HasMany', function (h return this._schema.attributesDefinitionFor(identifier); } + fields(identifier) { + return this._schema.fields(identifier); + } + relationshipsDefinitionFor(identifier) { const schema = AbstractSchemas.get(identifier.type); return schema || this._schema.relationshipsDefinitionFor(identifier); diff --git a/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts b/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts index 992eec8bea9..a875dccb5dd 100644 --- a/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts +++ b/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts @@ -150,15 +150,17 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { }); test('attribute and relationship with custom schema definition', async function (assert) { - assert.expect(18); this.owner.register( 'adapter:application', JSONAPIAdapter.extend({ shouldBackgroundReloadRecord: () => false, createRecord: (store, type, snapshot: Snapshot) => { let count = 0; + assert.verifySteps(['Schema:attributesDefinitionFor', 'Schema:fields']); + assert.step('Adapter:createRecord'); snapshot.eachAttribute((attr, attrDef) => { if (count === 0) { + assert.step('Adapter:createRecord:attr:name'); assert.strictEqual(attr, 'name', 'attribute key is correct'); assert.deepEqual( attrDef, @@ -166,6 +168,7 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { 'attribute def matches schem' ); } else if (count === 1) { + assert.step('Adapter:createRecord:attr:age'); assert.strictEqual(attr, 'age', 'attribute key is correct'); assert.deepEqual( attrDef, @@ -178,6 +181,7 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { count = 0; snapshot.eachRelationship((rel, relDef) => { if (count === 0) { + assert.step('Adapter:createRecord:rel:boats'); assert.strictEqual(rel, 'boats', 'relationship key is correct'); assert.deepEqual( relDef, @@ -193,6 +197,7 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { 'relationships def matches schem' ); } else if (count === 1) { + assert.step('Adapter:createRecord:rel:house'); assert.strictEqual(rel, 'house', 'relationship key is correct'); assert.deepEqual( relDef, @@ -207,6 +212,15 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { } count++; }); + assert.verifySteps([ + 'Adapter:createRecord', + 'Schema:attributesDefinitionFor', + 'Adapter:createRecord:attr:name', + 'Adapter:createRecord:attr:age', + 'Schema:relationshipsDefinitionFor', + 'Adapter:createRecord:rel:boats', + 'Adapter:createRecord:rel:house', + ]); return Promise.resolve({ data: { type: 'person', id: '1' } }); }, }) @@ -222,6 +236,7 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { const store = this.owner.lookup('service:store') as Store; let schema: SchemaService = { attributesDefinitionFor(identifier: RecordIdentifier | { type: string }): AttributesSchema { + assert.step('Schema:attributesDefinitionFor'); if (typeof identifier === 'string') { assert.strictEqual(identifier, 'person', 'type passed in to the schema hooks'); } else { @@ -242,7 +257,33 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { }, }; }, + _fieldsDefCache: {} as Record>, + fields(identifier: StableRecordIdentifier | { type: string }): Map { + assert.step('Schema:fields'); + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + assert.step('Schema:fields(calc)'); + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; + }, relationshipsDefinitionFor(identifier: RecordIdentifier | { type: string }): RelationshipsSchema { + assert.step('Schema:relationshipsDefinitionFor'); if (typeof identifier === 'string') { assert.strictEqual(identifier, 'person', 'type passed in to the schema hooks'); } else { @@ -274,8 +315,21 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { }, }; store.registerSchemaDefinitionService(schema); + assert.verifySteps([]); let person = store.createRecord('person', { name: 'chris' }) as Person; + assert.verifySteps([ + 'Schema:relationshipsDefinitionFor', + 'Schema:fields', + 'Schema:fields(calc)', + 'Schema:attributesDefinitionFor', + 'Schema:relationshipsDefinitionFor', + ]); await person.save(); + assert.verifySteps([ + 'Schema:attributesDefinitionFor', + 'Schema:attributesDefinitionFor', + 'Schema:relationshipsDefinitionFor', + ]); }); test('store.saveRecord', async function (assert) { @@ -382,6 +436,29 @@ module('unit/model - Custom Class Model', function (hooks: NestedHooks) { return {}; } }, + _fieldsDefCache: {} as Record>, + fields(identifier: StableRecordIdentifier | { type: string }): Map { + const { type } = identifier; + let fieldDefs: Map | undefined = this._fieldsDefCache[type]; + + if (fieldDefs === undefined) { + fieldDefs = new Map(); + this._fieldsDefCache[type] = fieldDefs; + + const attributes = this.attributesDefinitionFor(identifier); + const relationships = this.relationshipsDefinitionFor(identifier); + + for (const attr of Object.values(attributes)) { + fieldDefs.set(attr.name, attr); + } + + for (const rel of Object.values(relationships)) { + fieldDefs.set(rel.name, rel); + } + } + + return fieldDefs; + }, relationshipsDefinitionFor(identifier: RecordIdentifier | { type: string }): RelationshipsSchema { let modelName = (identifier as RecordIdentifier).type || identifier; if (modelName === 'person') { diff --git a/tests/main/tests/unit/debug-test.js b/tests/main/tests/unit/debug-test.js index d8bce394ed0..637a3573ff9 100644 --- a/tests/main/tests/unit/debug-test.js +++ b/tests/main/tests/unit/debug-test.js @@ -86,6 +86,10 @@ if (HAS_DEBUG_PACKAGE) { return this._schema.attributesDefinitionFor(identifier); } + fields(identifier) { + return this._schema.fields(identifier); + } + relationshipsDefinitionFor(identifier) { const sup = this._schema.relationshipsDefinitionFor(identifier); if (identifier.type === 'user') {