Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions docs/rest/api/Lazy.md
Original file line number Diff line number Diff line change
@@ -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 (
<ul>
{buildings.map(b => <li key={b.id}>{b.name}</li>)}
</ul>
);
}
```

### 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.
1 change: 1 addition & 0 deletions packages/endpoint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
3 changes: 2 additions & 1 deletion packages/endpoint/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from './schemas/EntityMixin.js';
import { default as Invalidate } from './schemas/Invalidate.js';
import { default as Query } from './schemas/Query.js';
import { default as Lazy } from './schemas/Lazy.js';
import type {
CollectionConstructor,
DefaultArgs,
Expand All @@ -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 };

Expand Down
1 change: 1 addition & 0 deletions packages/endpoint/src/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
118 changes: 118 additions & 0 deletions packages/endpoint/src/schemas/Lazy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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<S extends Schema>
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<S> {
if (!this._query) {
this._query = new LazyQuery(this.schema);
}
return this._query;
}

private _query: LazyQuery<S> | undefined;

declare _denormalizeNullable: (
input: {},
args: readonly any[],
unvisit: (schema: any, input: any) => any,
) => any;

declare _normalizeNullable: () => NormalizeNullable<S>;
}

/**
* 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<S extends Schema>
implements SchemaSimple<Denormalize<S>, 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<S> {
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') {
return schema.queryKey(args, unvisit, delegate);
}
return args[0];
}

declare _denormalizeNullable: (
input: {},
args: readonly any[],
unvisit: (schema: any, input: any) => any,
) => DenormalizeNullable<S>;

declare _normalizeNullable: () => NormalizeNullable<S>;
}
Loading
Loading