diff --git a/.changeset/add-lazy-schema.md b/.changeset/add-lazy-schema.md new file mode 100644 index 000000000000..d919ce7822ed --- /dev/null +++ b/.changeset/add-lazy-schema.md @@ -0,0 +1,25 @@ +--- +'@data-client/endpoint': minor +'@data-client/rest': minor +'@data-client/graphql': minor +--- + +Add [schema.Lazy](https://dataclient.io/rest/api/Lazy) for deferred relationship denormalization. + +`schema.Lazy` wraps a relationship field so denormalization returns raw primary keys +instead of resolved entities. Use `.query` with [useQuery](/docs/api/useQuery) to +resolve on demand in a separate memo/GC scope. + +New exports: `schema.Lazy`, `Lazy` + +```ts +class Department extends Entity { + buildings: string[] = []; + static schema = { + buildings: new schema.Lazy([Building]), + }; +} + +// dept.buildings = ['bldg-1', 'bldg-2'] (raw PKs) +const buildings = useQuery(Department.schema.buildings.query, dept.buildings); +``` diff --git a/docs/rest/api/Lazy.md b/docs/rest/api/Lazy.md new file mode 100644 index 000000000000..58fb3a140977 --- /dev/null +++ b/docs/rest/api/Lazy.md @@ -0,0 +1,134 @@ +--- +title: Lazy Schema - Deferred Relationship Denormalization +sidebar_label: Lazy +--- + +# Lazy + +`Lazy` wraps a schema to skip eager denormalization of relationship fields. During parent entity denormalization, the field retains its raw normalized value (primary keys/IDs). The relationship can then be resolved on demand via [useQuery](/docs/api/useQuery) using the `.query` accessor. + +This is useful for: +- **Large bidirectional graphs** that would overflow the call stack during recursive denormalization +- **Performance optimization** by deferring resolution of relationships that aren't always needed +- **Memoization isolation** — changes to lazy entities don't invalidate the parent's denormalized form + +## Constructor + +```typescript +new schema.Lazy(innerSchema) +``` + +- `innerSchema`: Any [Schema](/rest/api/schema) — an [Entity](./Entity.md), an array shorthand like `[MyEntity]`, a [Collection](./Collection.md), etc. + +## Usage + +### Array relationship (most common) + +```typescript +import { Entity, schema } from '@data-client/rest'; + +class Building extends Entity { + id = ''; + name = ''; +} + +class Department extends Entity { + id = ''; + name = ''; + buildings: string[] = []; + + static schema = { + buildings: new schema.Lazy([Building]), + }; +} +``` + +When a `Department` is denormalized, `dept.buildings` will contain raw primary keys (e.g., `['bldg-1', 'bldg-2']`) instead of resolved `Building` instances. + +To resolve the buildings, use [useQuery](/docs/api/useQuery) with the `.query` accessor: + +```tsx +function DepartmentBuildings({ dept }: { dept: Department }) { + // dept.buildings contains raw IDs: ['bldg-1', 'bldg-2'] + const buildings = useQuery(Department.schema.buildings.query, dept.buildings); + // buildings: Building[] | undefined + + if (!buildings) return null; + return ( + + ); +} +``` + +### Single entity relationship + +```typescript +class Department extends Entity { + id = ''; + name = ''; + mainBuilding = ''; + + static schema = { + mainBuilding: new schema.Lazy(Building), + }; +} +``` + +```tsx +// dept.mainBuilding is a raw PK string: 'bldg-1' +const building = useQuery( + Department.schema.mainBuilding.query, + { id: dept.mainBuilding }, +); +``` + +When the inner schema is an [Entity](./Entity.md) (or any schema with `queryKey`), `LazyQuery` delegates to its `queryKey` — so you pass the same args you'd use to query that entity directly. + +### Collection relationship + +```typescript +class Department extends Entity { + id = ''; + static schema = { + buildings: new schema.Lazy(buildingsCollection), + }; +} +``` + +```tsx +const buildings = useQuery( + Department.schema.buildings.query, + ...collectionArgs, +); +``` + +## `.query` + +Returns a `LazyQuery` instance suitable for [useQuery](/docs/api/useQuery). The `LazyQuery`: + +- **`queryKey(args)`** — If the inner schema has a `queryKey` (Entity, Collection, etc.), delegates to it. Otherwise returns `args[0]` directly (for array/object schemas where you pass the raw normalized value). +- **`denormalize(input, args, unvisit)`** — Delegates to the inner schema, resolving IDs into full entity instances. + +The `.query` getter always returns the same instance (cached). + +## How it works + +### Normalization + +`Lazy.normalize` delegates to the inner schema. Entities are stored in the normalized entity tables as usual — `Lazy` has no effect on normalization. + +### Denormalization (parent path) + +`Lazy.denormalize` is a **no-op** — it returns the input unchanged. When `EntityMixin.denormalize` iterates over schema fields and encounters a `Lazy` field, the `unvisit` dispatch calls `Lazy.denormalize`, which simply passes through the raw PKs. No nested entities are visited, no dependencies are registered in the cache. + +### Denormalization (useQuery path) + +When using `useQuery(lazyField.query, ...)`, `LazyQuery.denormalize` delegates to the inner schema via `unvisit`, resolving IDs into full entity instances through the normal denormalization pipeline. This runs in its own `MemoCache.query()` scope with independent dependency tracking and GC. + +## Performance characteristics + +- **Parent denormalization**: Fewer dependency hops (lazy entities excluded from deps). Faster cache hits. No invalidation when lazy entities change. +- **useQuery access**: Own memo scope with own `paths` and `countRef`. Changes to lazy entities only re-render components that called `useQuery`, not the parent. +- **No Proxy/getter overhead**: Raw IDs are plain values. Full resolution only happens through `useQuery`, using the normal denormalization path. diff --git a/packages/endpoint/src/index.ts b/packages/endpoint/src/index.ts index c25f73ebb747..e8f6a0258bf7 100644 --- a/packages/endpoint/src/index.ts +++ b/packages/endpoint/src/index.ts @@ -18,6 +18,7 @@ export { Query, Values, All, + Lazy, unshift, } from './schema.js'; // Without this we get 'cannot be named without a reference to' for resource()....why is this? diff --git a/packages/endpoint/src/schema.d.ts b/packages/endpoint/src/schema.d.ts index 13f16fbe7d5b..5a0c06507d3d 100644 --- a/packages/endpoint/src/schema.d.ts +++ b/packages/endpoint/src/schema.d.ts @@ -25,6 +25,7 @@ import { default as Entity, } from './schemas/EntityMixin.js'; import { default as Invalidate } from './schemas/Invalidate.js'; +import { default as Lazy } from './schemas/Lazy.js'; import { default as Query } from './schemas/Query.js'; import type { CollectionConstructor, @@ -34,7 +35,7 @@ import type { UnionResult, } from './schemaTypes.js'; -export { EntityMap, Invalidate, Query, EntityMixin, Entity }; +export { EntityMap, Invalidate, Query, Lazy, EntityMixin, Entity }; export type { SchemaClass }; diff --git a/packages/endpoint/src/schema.js b/packages/endpoint/src/schema.js index 8c9eceb1c1f6..03b5ccaca386 100644 --- a/packages/endpoint/src/schema.js +++ b/packages/endpoint/src/schema.js @@ -11,3 +11,4 @@ export { default as Entity, } from './schemas/EntityMixin.js'; export { default as Query } from './schemas/Query.js'; +export { default as Lazy } from './schemas/Lazy.js'; diff --git a/packages/endpoint/src/schemas/Lazy.ts b/packages/endpoint/src/schemas/Lazy.ts new file mode 100644 index 000000000000..e9948cdd976d --- /dev/null +++ b/packages/endpoint/src/schemas/Lazy.ts @@ -0,0 +1,121 @@ +import type { Schema, SchemaSimple } from '../interface.js'; +import type { + Denormalize, + DenormalizeNullable, + NormalizeNullable, +} from '../normal.js'; + +/** + * Skips eager denormalization of a relationship field. + * Raw normalized values (PKs/IDs) pass through unchanged. + * Use `.query` with `useQuery` to resolve lazily. + * + * @see https://dataclient.io/rest/api/Lazy + */ +export default class Lazy implements SchemaSimple { + declare schema: S; + + /** + * @param {Schema} schema - The inner schema (e.g., [Building], Building, Collection) + */ + constructor(schema: S) { + this.schema = schema; + } + + normalize( + input: any, + parent: any, + key: any, + args: any[], + visit: (...args: any) => any, + _delegate: any, + ): any { + return visit(this.schema, input, parent, key, args); + } + + denormalize(input: {}, _args: readonly any[], _unvisit: any): any { + return input; + } + + queryKey( + _args: readonly any[], + _unvisit: (...args: any) => any, + _delegate: any, + ): undefined { + return undefined; + } + + /** Queryable schema for use with useQuery() to resolve lazy relationships */ + get query(): LazyQuery { + if (!this._query) { + this._query = new LazyQuery(this.schema); + } + return this._query; + } + + private _query: LazyQuery | undefined; + + declare _denormalizeNullable: ( + input: {}, + args: readonly any[], + unvisit: (schema: any, input: any) => any, + ) => any; + + declare _normalizeNullable: () => NormalizeNullable; +} + +/** + * Resolves lazy relationships via useQuery(). + * + * queryKey delegates to inner schema's queryKey if available, + * otherwise passes through args[0] (the raw normalized value). + */ +export class LazyQuery implements SchemaSimple< + Denormalize, + readonly any[] +> { + declare schema: S; + + constructor(schema: S) { + this.schema = schema; + } + + normalize( + input: any, + _parent: any, + _key: any, + _args: any[], + _visit: (...args: any) => any, + _delegate: any, + ): any { + return input; + } + + denormalize( + input: {}, + args: readonly any[], + unvisit: (schema: any, input: any) => any, + ): Denormalize { + return unvisit(this.schema, input); + } + + queryKey( + args: readonly any[], + unvisit: (...args: any) => any, + delegate: { getEntity: any; getIndex: any }, + ): any { + const schema = this.schema as any; + if (typeof schema.queryKey === 'function' && schema.key) { + return schema.queryKey(args, unvisit, delegate); + } + return args[0]; + } + + declare _denormalizeNullable: ( + input: {}, + args: readonly any[], + unvisit: (schema: any, input: any) => any, + ) => DenormalizeNullable; + + declare _normalizeNullable: () => NormalizeNullable; +} diff --git a/packages/endpoint/src/schemas/__tests__/Lazy.test.ts b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts new file mode 100644 index 000000000000..38bf01b8a268 --- /dev/null +++ b/packages/endpoint/src/schemas/__tests__/Lazy.test.ts @@ -0,0 +1,792 @@ +// eslint-env jest +/// +import { normalize, MemoCache } from '@data-client/normalizr'; +import { denormalize as plainDenormalize } from '@data-client/normalizr'; +import { IDEntity } from '__tests__/new'; + +import { SimpleMemoCache } from './denormalize'; +import { schema } from '../..'; + +let dateSpy: jest.Spied; +beforeAll(() => { + dateSpy = jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date('2019-05-14T11:01:58.135Z').valueOf()); +}); +afterAll(() => { + dateSpy.mockRestore(); +}); + +class Building extends IDEntity { + readonly name: string = ''; + readonly floors: number = 1; +} + +class Manager extends IDEntity { + readonly name: string = ''; +} + +class Department extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + readonly manager: Manager = {} as any; + + static schema = { + buildings: new schema.Lazy([Building]), + manager: Manager, + }; +} + +class SingleRefDepartment extends IDEntity { + readonly name: string = ''; + readonly mainBuilding: string = ''; + + static schema = { + mainBuilding: new schema.Lazy(Building), + }; +} + +describe('Lazy schema', () => { + describe('normalize → denormalize round-trip', () => { + const apiResponse = { + id: 'dept-1', + name: 'Engineering', + manager: { id: 'mgr-1', name: 'Alice' }, + buildings: [ + { id: 'bldg-1', name: 'Building A', floors: 3 }, + { id: 'bldg-2', name: 'Building B', floors: 5 }, + ], + }; + + test('normalize stores entities correctly through Lazy', () => { + const result = normalize(Department, apiResponse, []); + + expect(result.result).toBe('dept-1'); + expect(Object.keys(result.entities.Building)).toEqual([ + 'bldg-1', + 'bldg-2', + ]); + expect(result.entities.Building['bldg-1']).toEqual({ + id: 'bldg-1', + name: 'Building A', + floors: 3, + }); + expect(result.entities.Building['bldg-2']).toEqual({ + id: 'bldg-2', + name: 'Building B', + floors: 5, + }); + expect(result.entities.Manager['mgr-1']).toEqual({ + id: 'mgr-1', + name: 'Alice', + }); + expect(result.entities.Department['dept-1'].buildings).toEqual([ + 'bldg-1', + 'bldg-2', + ]); + expect(result.entities.Department['dept-1'].manager).toBe('mgr-1'); + }); + + test('denormalize resolves non-Lazy fields but keeps Lazy fields as raw IDs', () => { + const { result, entities } = normalize(Department, apiResponse, []); + const dept: any = plainDenormalize(Department, result, entities); + + expect(dept.id).toBe('dept-1'); + expect(dept.name).toBe('Engineering'); + // non-Lazy field Manager is fully resolved + expect(dept.manager).toBeInstanceOf(Manager); + expect(dept.manager.id).toBe('mgr-1'); + expect(dept.manager.name).toBe('Alice'); + // Lazy field buildings stays as raw PK array + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(typeof dept.buildings[0]).toBe('string'); + expect(typeof dept.buildings[1]).toBe('string'); + }); + + test('full pipeline: normalize → parent denorm → LazyQuery resolves', () => { + const { result, entities } = normalize(Department, apiResponse, []); + const dept: any = plainDenormalize(Department, result, entities); + + // Parent has raw IDs + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + + // Use LazyQuery to resolve those IDs into full Building entities + const memo = new MemoCache(); + const state = { entities, indexes: {} }; + const queryResult = memo.query( + Department.schema.buildings.query, + [dept.buildings], + state, + ); + const buildings = queryResult.data as any[]; + expect(buildings).toHaveLength(2); + expect(buildings[0]).toBeInstanceOf(Building); + expect(buildings[0].id).toBe('bldg-1'); + expect(buildings[0].name).toBe('Building A'); + expect(buildings[0].floors).toBe(3); + expect(buildings[1]).toBeInstanceOf(Building); + expect(buildings[1].id).toBe('bldg-2'); + expect(buildings[1].name).toBe('Building B'); + expect(buildings[1].floors).toBe(5); + }); + }); + + describe('normalization', () => { + test('single entity ref normalizes correctly through Lazy', () => { + const result = normalize( + SingleRefDepartment, + { + id: 'dept-1', + name: 'Engineering', + mainBuilding: { id: 'bldg-1', name: 'HQ', floors: 10 }, + }, + [], + ); + expect(result.entities.SingleRefDepartment['dept-1'].mainBuilding).toBe( + 'bldg-1', + ); + expect(result.entities.Building['bldg-1']).toEqual({ + id: 'bldg-1', + name: 'HQ', + floors: 10, + }); + }); + + test('normalizing Lazy field with empty array', () => { + const result = normalize( + Department, + { + id: 'dept-empty', + name: 'Empty Dept', + manager: { id: 'mgr-1', name: 'Bob' }, + buildings: [], + }, + [], + ); + expect(result.entities.Department['dept-empty'].buildings).toEqual([]); + expect(result.entities.Building).toBeUndefined(); + }); + }); + + describe('denormalization preserves raw IDs', () => { + const entities = { + Department: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildings: ['bldg-1', 'bldg-2'], + manager: 'mgr-1', + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, + }, + Manager: { + 'mgr-1': { id: 'mgr-1', name: 'Alice' }, + }, + }; + + test('plainDenormalize keeps Lazy array as string IDs', () => { + const dept: any = plainDenormalize(Department, 'dept-1', entities); + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(dept.buildings[0]).not.toBeInstanceOf(Building); + // non-Lazy Manager IS resolved + expect(dept.manager).toBeInstanceOf(Manager); + expect(dept.manager.name).toBe('Alice'); + }); + + test('SimpleMemoCache keeps Lazy array as string IDs', () => { + const memo = new SimpleMemoCache(); + const dept: any = memo.denormalize(Department, 'dept-1', entities); + expect(typeof dept).toBe('object'); + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(dept.manager).toBeInstanceOf(Manager); + }); + + test('single entity Lazy field stays as string PK', () => { + const singleEntities = { + SingleRefDepartment: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + mainBuilding: 'bldg-1', + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'HQ', floors: 10 }, + }, + }; + const dept: any = plainDenormalize( + SingleRefDepartment, + 'dept-1', + singleEntities, + ); + expect(dept.mainBuilding).toBe('bldg-1'); + expect(typeof dept.mainBuilding).toBe('string'); + }); + + test('parent paths exclude lazy entity dependencies', () => { + const memo = new MemoCache(); + const result = memo.denormalize(Department, 'dept-1', entities); + expect(result.paths.some(p => p.key === 'Building')).toBe(false); + expect(result.paths.some(p => p.key === 'Department')).toBe(true); + // Manager IS in paths because it's a non-Lazy field + expect(result.paths.some(p => p.key === 'Manager')).toBe(true); + }); + }); + + describe('LazyQuery resolution via .query', () => { + const state = { + entities: { + Department: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildings: ['bldg-1', 'bldg-2'], + manager: 'mgr-1', + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, + 'bldg-3': { id: 'bldg-3', name: 'Building C', floors: 2 }, + }, + Manager: { + 'mgr-1': { id: 'mgr-1', name: 'Alice' }, + }, + SingleRefDepartment: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + mainBuilding: 'bldg-1', + }, + }, + }, + indexes: {}, + }; + + test('.query getter always returns the same instance', () => { + const lazy = Department.schema.buildings; + expect(lazy.query).toBe(lazy.query); + }); + + test('resolves array of IDs into Building instances', () => { + const memo = new MemoCache(); + const result = memo.query( + Department.schema.buildings.query, + [['bldg-1', 'bldg-2']], + state, + ); + const buildings = result.data as any[]; + expect(buildings).toHaveLength(2); + expect(buildings[0]).toBeInstanceOf(Building); + expect(buildings[0].id).toBe('bldg-1'); + expect(buildings[0].name).toBe('Building A'); + expect(buildings[0].floors).toBe(3); + expect(buildings[1]).toBeInstanceOf(Building); + expect(buildings[1].id).toBe('bldg-2'); + expect(buildings[1].name).toBe('Building B'); + expect(buildings[1].floors).toBe(5); + }); + + test('resolved entities track Building dependencies', () => { + const memo = new MemoCache(); + const result = memo.query( + Department.schema.buildings.query, + [['bldg-1', 'bldg-2']], + state, + ); + const buildingPaths = result.paths.filter(p => p.key === 'Building'); + expect(buildingPaths).toHaveLength(2); + expect(buildingPaths.map(p => p.pk).sort()).toEqual(['bldg-1', 'bldg-2']); + // Department should NOT be in paths — we're only resolving buildings + expect(result.paths.some(p => p.key === 'Department')).toBe(false); + }); + + test('subset of IDs resolves only those buildings', () => { + const memo = new MemoCache(); + const result = memo.query( + Department.schema.buildings.query, + [['bldg-3']], + state, + ); + const buildings = result.data as any[]; + expect(buildings).toHaveLength(1); + expect(buildings[0].id).toBe('bldg-3'); + expect(buildings[0].name).toBe('Building C'); + expect(buildings[0].floors).toBe(2); + }); + + test('empty IDs array resolves to empty array', () => { + const memo = new MemoCache(); + const result = memo.query(Department.schema.buildings.query, [[]], state); + expect(result.data).toEqual([]); + expect(result.paths).toEqual([]); + }); + + test('IDs referencing missing entities are filtered out', () => { + const memo = new MemoCache(); + const result = memo.query( + Department.schema.buildings.query, + [['bldg-1', 'nonexistent', 'bldg-2']], + state, + ); + const buildings = result.data as any[]; + expect(buildings).toHaveLength(2); + expect(buildings[0].id).toBe('bldg-1'); + expect(buildings[1].id).toBe('bldg-2'); + }); + + test('Entity inner schema: delegates to Building.queryKey for single entity lookup', () => { + const memo = new MemoCache(); + const result = memo.query( + SingleRefDepartment.schema.mainBuilding.query, + [{ id: 'bldg-1' }], + state, + ); + const building = result.data as any; + expect(building).toBeInstanceOf(Building); + expect(building.id).toBe('bldg-1'); + expect(building.name).toBe('Building A'); + expect(building.floors).toBe(3); + }); + + test('Entity inner schema: missing entity returns undefined', () => { + const memo = new MemoCache(); + const result = memo.query( + SingleRefDepartment.schema.mainBuilding.query, + [{ id: 'nonexistent' }], + state, + ); + expect(result.data).toBeUndefined(); + }); + }); + + describe('memoization isolation', () => { + test('parent referential equality is preserved when lazy entity updates', () => { + const entities1 = { + Department: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildings: ['bldg-1'], + manager: 'mgr-1', + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + }, + Manager: { + 'mgr-1': { id: 'mgr-1', name: 'Alice' }, + }, + }; + // Building entity changes, Department entity object stays the same ref + const entities2 = { + Department: entities1.Department, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A RENAMED', floors: 4 }, + }, + Manager: entities1.Manager, + }; + + const memo = new MemoCache(); + const result1 = memo.denormalize(Department, 'dept-1', entities1); + const result2 = memo.denormalize(Department, 'dept-1', entities2); + + // Parent entity denorm is referentially equal — Building change is invisible + expect(result1.data).toBe(result2.data); + const dept: any = result1.data; + expect(dept.name).toBe('Engineering'); + expect(dept.buildings).toEqual(['bldg-1']); + expect(dept.manager).toBeInstanceOf(Manager); + }); + + test('LazyQuery result DOES update when lazy entity changes', () => { + const lazyQuery = Department.schema.buildings.query; + const state1 = { + entities: { + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Original', floors: 3 }, + }, + }, + indexes: {}, + }; + const state2 = { + entities: { + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Updated', floors: 4 }, + }, + }, + indexes: {}, + }; + + const memo = new MemoCache(); + const r1 = memo.query(lazyQuery, [['bldg-1']], state1); + const r2 = memo.query(lazyQuery, [['bldg-1']], state2); + + expect((r1.data as any)[0].name).toBe('Original'); + expect((r1.data as any)[0].floors).toBe(3); + expect((r2.data as any)[0].name).toBe('Updated'); + expect((r2.data as any)[0].floors).toBe(4); + expect(r1.data).not.toBe(r2.data); + }); + + test('LazyQuery result maintains referential equality on unchanged state', () => { + const lazyQuery = Department.schema.buildings.query; + const state = { + entities: { + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + }, + }, + indexes: {}, + }; + + const memo = new MemoCache(); + const r1 = memo.query(lazyQuery, [['bldg-1']], state); + const r2 = memo.query(lazyQuery, [['bldg-1']], state); + expect(r1.data).toBe(r2.data); + }); + }); + + describe('nested Lazy fields', () => { + class Room extends IDEntity { + readonly label: string = ''; + } + + class LazyBuilding extends IDEntity { + readonly name: string = ''; + readonly rooms: string[] = []; + + static schema = { + rooms: new schema.Lazy([Room]), + }; + } + + class LazyDepartment extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + + static schema = { + buildings: new schema.Lazy([LazyBuilding]), + }; + } + + test('resolved entity still has its own Lazy fields as raw IDs', () => { + const state = { + entities: { + LazyBuilding: { + 'bldg-1': { + id: 'bldg-1', + name: 'Building A', + rooms: ['room-1', 'room-2'], + }, + }, + Room: { + 'room-1': { id: 'room-1', label: '101' }, + 'room-2': { id: 'room-2', label: '102' }, + }, + }, + indexes: {}, + }; + + const memo = new MemoCache(); + const result = memo.query( + LazyDepartment.schema.buildings.query, + [['bldg-1']], + state, + ); + const buildings = result.data as any[]; + expect(buildings).toHaveLength(1); + expect(buildings[0]).toBeInstanceOf(LazyBuilding); + expect(buildings[0].name).toBe('Building A'); + // Building's own Lazy field stays as raw IDs + expect(buildings[0].rooms).toEqual(['room-1', 'room-2']); + expect(typeof buildings[0].rooms[0]).toBe('string'); + }); + + test('second-level LazyQuery resolves deeper relationships', () => { + const state = { + entities: { + Room: { + 'room-1': { id: 'room-1', label: '101' }, + 'room-2': { id: 'room-2', label: '102' }, + }, + }, + indexes: {}, + }; + + const memo = new MemoCache(); + const result = memo.query( + LazyBuilding.schema.rooms.query, + [['room-1', 'room-2']], + state, + ); + const rooms = result.data as any[]; + expect(rooms).toHaveLength(2); + expect(rooms[0]).toBeInstanceOf(Room); + expect(rooms[0].label).toBe('101'); + expect(rooms[1].label).toBe('102'); + }); + }); + + describe('bidirectional Lazy prevents stack overflow', () => { + class BidirBuilding extends IDEntity { + readonly name: string = ''; + readonly departments: string[] = []; + } + class BidirDepartment extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + } + (BidirDepartment as any).schema = { + buildings: new schema.Lazy([BidirBuilding]), + }; + (BidirBuilding as any).schema = { + departments: new schema.Lazy([BidirDepartment]), + }; + + function buildChain(length: number) { + const departmentEntities: Record = {}; + const buildingEntities: Record = {}; + for (let i = 0; i < length; i++) { + departmentEntities[`dept-${i}`] = { + id: `dept-${i}`, + name: `Department ${i}`, + buildings: [`bldg-${i}`], + }; + buildingEntities[`bldg-${i}`] = { + id: `bldg-${i}`, + name: `Building ${i}`, + departments: i < length - 1 ? [`dept-${i + 1}`] : [], + }; + } + return { + BidirDepartment: departmentEntities, + BidirBuilding: buildingEntities, + }; + } + + test('1500-node chain does not overflow (plainDenormalize)', () => { + const entities = buildChain(1500); + expect(() => + plainDenormalize(BidirDepartment, 'dept-0', entities), + ).not.toThrow(); + + const dept: any = plainDenormalize(BidirDepartment, 'dept-0', entities); + expect(dept.id).toBe('dept-0'); + expect(dept.name).toBe('Department 0'); + expect(dept.buildings).toEqual(['bldg-0']); + }); + + test('1500-node chain does not overflow (SimpleMemoCache)', () => { + const entities = buildChain(1500); + const memo = new SimpleMemoCache(); + expect(() => + memo.denormalize(BidirDepartment, 'dept-0', entities), + ).not.toThrow(); + }); + + test('chain entities can still be resolved individually via LazyQuery', () => { + const entities = buildChain(5); + const state = { entities, indexes: {} }; + const memo = new MemoCache(); + + const deptBuildingsQuery = ( + (BidirDepartment as any).schema.buildings as schema.Lazy + ).query; + const bldgDeptsQuery = ( + (BidirBuilding as any).schema.departments as schema.Lazy + ).query; + + // Resolve dept-0's buildings + const r = memo.query(deptBuildingsQuery, [['bldg-0']], state); + const buildings = r.data as any[]; + expect(buildings).toHaveLength(1); + expect(buildings[0]).toBeInstanceOf(BidirBuilding); + expect(buildings[0].id).toBe('bldg-0'); + expect(buildings[0].name).toBe('Building 0'); + // Building's departments field is also Lazy — raw IDs + expect(buildings[0].departments).toEqual(['dept-1']); + + // Resolve building-0's departments + const r2 = memo.query(bldgDeptsQuery, [['dept-1']], state); + const depts = r2.data as any[]; + expect(depts).toHaveLength(1); + expect(depts[0]).toBeInstanceOf(BidirDepartment); + expect(depts[0].id).toBe('dept-1'); + expect(depts[0].buildings).toEqual(['bldg-1']); + }); + }); + + describe('explicit schema.Array inner schema', () => { + class ArrayDepartment extends IDEntity { + readonly name: string = ''; + readonly buildings: string[] = []; + + static schema = { + buildings: new schema.Lazy(new schema.Array(Building)), + }; + } + + const state = { + entities: { + ArrayDepartment: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildings: ['bldg-1', 'bldg-2'], + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, + }, + }, + indexes: {}, + }; + + test('normalize stores entities correctly through Lazy(schema.Array)', () => { + const result = normalize( + ArrayDepartment, + { + id: 'dept-1', + name: 'Engineering', + buildings: [ + { id: 'bldg-1', name: 'Building A', floors: 3 }, + { id: 'bldg-2', name: 'Building B', floors: 5 }, + ], + }, + [], + ); + expect(result.entities.ArrayDepartment['dept-1'].buildings).toEqual([ + 'bldg-1', + 'bldg-2', + ]); + expect(result.entities.Building['bldg-1']).toEqual({ + id: 'bldg-1', + name: 'Building A', + floors: 3, + }); + }); + + test('denormalize keeps Lazy(schema.Array) as raw IDs', () => { + const dept: any = plainDenormalize( + ArrayDepartment, + 'dept-1', + state.entities, + ); + expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); + expect(typeof dept.buildings[0]).toBe('string'); + }); + + test('LazyQuery resolves explicit schema.Array into Building instances', () => { + const memo = new MemoCache(); + const result = memo.query( + ArrayDepartment.schema.buildings.query, + [['bldg-1', 'bldg-2']], + state, + ); + const buildings = result.data as any[]; + expect(buildings).toHaveLength(2); + expect(buildings[0]).toBeInstanceOf(Building); + expect(buildings[0].id).toBe('bldg-1'); + expect(buildings[0].name).toBe('Building A'); + expect(buildings[1]).toBeInstanceOf(Building); + expect(buildings[1].id).toBe('bldg-2'); + expect(buildings[1].name).toBe('Building B'); + }); + + test('LazyQuery with empty array resolves to empty for schema.Array', () => { + const memo = new MemoCache(); + const result = memo.query( + ArrayDepartment.schema.buildings.query, + [[]], + state, + ); + expect(result.data).toEqual([]); + }); + }); + + describe('explicit schema.Values inner schema', () => { + class ValuesDepartment extends IDEntity { + readonly name: string = ''; + readonly buildingMap: Record = {}; + + static schema = { + buildingMap: new schema.Lazy(new schema.Values(Building)), + }; + } + + const state = { + entities: { + ValuesDepartment: { + 'dept-1': { + id: 'dept-1', + name: 'Engineering', + buildingMap: { north: 'bldg-1', south: 'bldg-2' }, + }, + }, + Building: { + 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, + 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, + }, + }, + indexes: {}, + }; + + test('normalize stores entities correctly through Lazy(schema.Values)', () => { + const result = normalize( + ValuesDepartment, + { + id: 'dept-1', + name: 'Engineering', + buildingMap: { + north: { id: 'bldg-1', name: 'Building A', floors: 3 }, + south: { id: 'bldg-2', name: 'Building B', floors: 5 }, + }, + }, + [], + ); + expect(result.entities.ValuesDepartment['dept-1'].buildingMap).toEqual({ + north: 'bldg-1', + south: 'bldg-2', + }); + }); + + test('denormalize keeps Lazy(schema.Values) as raw IDs', () => { + const dept: any = plainDenormalize( + ValuesDepartment, + 'dept-1', + state.entities, + ); + expect(dept.buildingMap).toEqual({ north: 'bldg-1', south: 'bldg-2' }); + expect(typeof dept.buildingMap.north).toBe('string'); + }); + + test('LazyQuery resolves explicit schema.Values into Building instances', () => { + const memo = new MemoCache(); + const result = memo.query( + ValuesDepartment.schema.buildingMap.query, + [{ north: 'bldg-1', south: 'bldg-2' }], + state, + ); + const buildingMap = result.data as any; + expect(buildingMap.north).toBeInstanceOf(Building); + expect(buildingMap.north.id).toBe('bldg-1'); + expect(buildingMap.north.name).toBe('Building A'); + expect(buildingMap.south).toBeInstanceOf(Building); + expect(buildingMap.south.id).toBe('bldg-2'); + expect(buildingMap.south.name).toBe('Building B'); + }); + }); + + describe('Lazy.queryKey returns undefined', () => { + test('Lazy itself is not queryable', () => { + const lazy = new schema.Lazy([Building]); + // eslint-disable-next-line @typescript-eslint/no-empty-function + expect(lazy.queryKey([], () => {}, {} as any)).toBeUndefined(); + }); + }); +});