Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions docs/10.joined.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ declare userName: string;

Joined fields are always sorted without a database index (`@Sortable` is forced to non-indexed for them), since the value comes from a runtime lookup rather than a stored column.

## Deferred Resolution on Lists

`Query.List` only materializes the joined groups that the request's sort or filters reference before the count. The remaining display-only groups are deferred: the returned stream resolves them itself as soon as an operation can observe them, so destructuring `[query, total]` alone always yields rows with every `@Joined` field resolved.

- Paging the stream (`slice`, `nth`) appends the lookups after the page boundary, so they only run on the returned rows.
- A bare `count()` — and any field aggregate (`sum`, `min`, `distinct(field)`, ...) on a non-joined field — skips them entirely (left-join lookups never change the row count nor other fields).
- `changes()` attaches to the raw stream, since change feeds cannot carry the join lookups.
- Any other operation (`filter`, `orderBy`, `map`, ...) materializes them first, since it may read the joined values.

The page-only benefit therefore holds as long as paging is the first thing the caller does. An operation applied to the whole stream before paging — a filter, a sort, or the `lookup` that `Query.Foreign` stages for a `@Foreign` field — materializes the joined groups over the whole matched set instead. The lookups still stay out of the count pipeline, but a caller that wants them strictly on the page must pass `{ exposeDeferredJoined: true }` to `Query.List` and resolve them itself after the slice: the raw stream is then returned unchanged and the third tuple element names the joined fields left to resolve with `Query.Joined`. This is what the default list route does, which also lets it limit the page lookups to the plucked fields.

## Cross-Schema Joins

By default, the remote table is resolved in the same schema as the controller's table. Use the `schema` option to join against a table registered in a different schema; the join is resolved against that schema's default instance.
Expand Down
185 changes: 184 additions & 1 deletion src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,168 @@ export namespace Query {
);
}

interface DeferredJoinContext {
db: SchemaInstance<any>;
meta: DataAPIMeta;
names: Set<string>;
raw: Stream<any>;
}

/**
* Stream returned by {@link List} when the deferred joined fields are not
* exposed to the caller: it resolves them itself, so consumers that
* destructure `[query, total]` alone still observe every `@Joined` field.
* The lookups are appended after `slice`/`nth`, keeping them on the
* requested page only; bare `count()` calls and field aggregates on
* non-joined fields skip them (left-join lookups never change the row
* count nor other fields); `changes()` attaches to the raw stream, since
* change feeds cannot carry the lookups. Any other operation may observe
* the joined fields, so it materializes them first.
*/
class DeferredJoinedStream extends Stream<any> {
declare deferredJoin: DeferredJoinContext;
}

type DeferredJoinedOperation = (
this: DeferredJoinedStream,
...args: any[]
) => any;

function materializeDeferredJoin(stream: DeferredJoinedStream) {
const { db, meta, names, raw } = stream.deferredJoin;
return Joined(db, meta, raw, names);
}

function pagedDeferredJoinOperation(name: string): DeferredJoinedOperation {
return function (this: DeferredJoinedStream, ...args: any[]) {
const { db, meta, names, raw } = this.deferredJoin;
const paged = (raw as any)[name](...args);
return Joined(db, meta, paged, names);
};
}

/**
* Resolves the joined fields through the stream form of {@link Joined}
* rather than its datum form: only the datum form has no way to drop the
* temporary key it stages (neither `Datum` nor `ValueProxy` exposes
* `without`), so it would leave `__joined_orig_*` on the row.
*/
function nthDeferredJoinOperation(): DeferredJoinedOperation {
return function (this: DeferredJoinedStream, n: number) {
const { db, meta, names, raw } = this.deferredJoin;
return Joined(db, meta, raw.slice(n, 1), names).nth(0);
};
}

function rawDeferredJoinOperation(name: string): DeferredJoinedOperation {
return function (this: DeferredJoinedStream, ...args: any[]) {
return (this.deferredJoin.raw as any)[name](...args);
};
}

function fieldAggregateDeferredJoinOperation(
name: string,
bareCallOnRaw: boolean,
): DeferredJoinedOperation {
return function (this: DeferredJoinedStream, field?: string) {
const { db, meta, names, raw } = this.deferredJoin;
if (!field) {
const bareTarget = bareCallOnRaw ? raw : materializeDeferredJoin(this);
return (bareTarget as any)[name]();
}
const target = names.has(field)
? Joined(db, meta, raw, new Set([field]))
: raw;
return (target as any)[name](field);
};
}

function forwardingDeferredJoinOperation(
name: string,
): DeferredJoinedOperation {
return function (this: DeferredJoinedStream, ...args: any[]) {
return (materializeDeferredJoin(this) as any)[name](...args);
};
}

const DEFERRED_JOIN_OPERATION_FACTORIES: Record<
string,
(name: string) => DeferredJoinedOperation
> = {
slice: pagedDeferredJoinOperation,
nth: nthDeferredJoinOperation,
changes: rawDeferredJoinOperation,
count: (name) => fieldAggregateDeferredJoinOperation(name, true),
sum: (name) => fieldAggregateDeferredJoinOperation(name, false),
avg: (name) => fieldAggregateDeferredJoinOperation(name, false),
min: (name) => fieldAggregateDeferredJoinOperation(name, false),
max: (name) => fieldAggregateDeferredJoinOperation(name, false),
distinct: (name) => fieldAggregateDeferredJoinOperation(name, false),
};

const DEFERRED_JOIN_INHERITED_OPERATIONS = new Set(["constructor", "cast"]);

function wireDeferredJoinedStream() {
const operationNames = [
...Object.getOwnPropertyNames(Stream.prototype),
"run",
"cursor",
"build",
].filter((name) => !DEFERRED_JOIN_INHERITED_OPERATIONS.has(name));
for (const name of operationNames) {
const factory =
DEFERRED_JOIN_OPERATION_FACTORIES[name] ??
forwardingDeferredJoinOperation;
(DeferredJoinedStream.prototype as any)[name] = factory(name);
}
}
wireDeferredJoinedStream();

/**
* Wraps `raw` into a {@link DeferredJoinedStream}. The wrapper keeps the
* raw stages as its own (an unobservable fallback, since every operation
* is overridden) while `build()` serializes the materialized pipeline, so
* a wrapper embedded as an argument of another query never leaks rows
* without the joined fields.
*/
function wrapDeferredJoined(
db: SchemaInstance<any>,
meta: DataAPIMeta,
names: Set<string>,
raw: Stream<any>,
): Stream<any> {
const wrapped: DeferredJoinedStream = Object.create(
DeferredJoinedStream.prototype,
);
(wrapped as any).stages = raw.build();
wrapped.deferredJoin = { db, meta, names, raw };
return wrapped;
}

function listResultTuple<T extends Record<string, any>>(
tmpRequest: Stream<T>,
total: Datum<number>,
meta: DataAPIMeta,
joinedSplit: JoinedFieldSplit,
db?: SchemaInstance<any>,
options?: ListOptions,
): [sorted: Stream<T>, total: Datum<number>, deferredJoined: Set<string>] {
const deferredJoined = deferredJoinedFields(meta, joinedSplit);
if (options?.exposeDeferredJoined || !db || deferredJoined.size === 0) {
return [tmpRequest, total, deferredJoined];
}
return [
wrapDeferredJoined(
db,
meta,
deferredJoined,
tmpRequest as Stream<any>,
) as Stream<T>,
total,
new Set(),
];
}

function resolveSchemaDb(
sourceDb: SchemaInstance<any>,
schemaName: string | undefined,
Expand Down Expand Up @@ -784,6 +946,26 @@ export namespace Query {
: table.get(id as string);
}

export interface ListOptions {
/**
* Return the joined field names {@link List} did not materialize as the
* third tuple element instead of a self-resolving stream, leaving their
* page-time resolution to the caller. The default list route uses this
* to restrict the page lookups to the plucked fields.
*/
exposeDeferredJoined?: boolean;
}

/**
* Builds the filtered and sorted stream of a list request along with its
* total count. Joined fields that no filter or sort references are not
* materialized in either pipeline; by default the returned stream resolves
* them itself once the caller pages it (or otherwise observes rows), so the
* third tuple element is empty. With
* {@link ListOptions.exposeDeferredJoined} — or when no `db` is provided,
* since no lookup can be built without one — the raw stream is returned
* and the third element names the joined fields left for the caller.
*/
export function List<T extends Record<string, any>>(
obj: any,
meta: DataAPIMeta,
Expand All @@ -792,6 +974,7 @@ export namespace Query {
sorting?: [string, "asc" | "desc" | undefined],
filters?: Record<string, FilterValue>,
db?: SchemaInstance<any>,
options?: ListOptions,
): [sorted: Stream<T>, total: Datum<number>, deferredJoined: Set<string>] {
const filterList = Object.entries(meta.filters).filter(
([name]) => filters && name in filters,
Expand Down Expand Up @@ -881,7 +1064,7 @@ export namespace Query {
if (shouldSort && !shouldSort.indexed && sortField) {
tmpRequest = tmpRequest.orderBy(sortField, sorting?.[1] ?? "asc");
}
return [tmpRequest, total, deferredJoinedFields(meta, joinedSplit)];
return listResultTuple(tmpRequest, total, meta, joinedSplit, db, options);
}

export function Delete(table: Table<any>, id: string | string[]) {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export namespace DefaultRoutes {
sort,
params?.filters,
model.database,
{ exposeDeferredJoined: true },
);

const pluck: Set<string> | undefined =
Expand Down
Loading
Loading