uql-orm 0.46.0 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { COUNT_ALIAS } from '../dialect/aliases.js';
2
2
  import { hasRequiredJoin } from '../dialect/queryJoins.js';
3
3
  import { getMeta, idOf, soleIdOf } from '../entity/index.js';
4
4
  import { AbstractQuerier, enrichError } from '../querier/index.js';
5
- import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
5
+ import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, queryChildrenOf, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
6
6
  /**
7
7
  * `$limit: 0` asks for no rows, the way it does on every SQL dialect - but MongoDB reads `limit(0)`
8
8
  * as *unlimited*, so a read that passed it straight to the driver came back with the whole
@@ -11,6 +11,12 @@ import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys,
11
11
  function asksForNoRows(q) {
12
12
  return q.$limit === 0;
13
13
  }
14
+ /**
15
+ * What MongoDB accepts in one pipeline. Bisected against a real server: 1000 top-level stages are
16
+ * accepted and 1001 refused (`Pipeline length must be no longer than 1000 stages`), and a
17
+ * `$unionWith`'s own sub-pipeline stages do not count toward it.
18
+ */
19
+ const MAX_PIPELINE_STAGES = 1000;
14
20
  export class MongodbQuerier extends AbstractQuerier {
15
21
  dialect;
16
22
  conn;
@@ -63,6 +69,49 @@ export class MongodbQuerier extends AbstractQuerier {
63
69
  return documents;
64
70
  });
65
71
  }
72
+ /**
73
+ * Every parent's own bounded page. One `$unionWith` per parent after the first, so the whole page is
74
+ * one round trip - measured ~6x faster than a query each (11.0 ms -> 1.9 ms at 50 parents, 87.5 ms
75
+ * -> 14.1 ms at 500), because `execute` serializes on the session and a query each is N round trips
76
+ * rather than N concurrent ones.
77
+ *
78
+ * Both arms return documents with their own relations already filled, so this only chooses between
79
+ * them: leaving that to the caller once meant the arm that fills its own did it twice.
80
+ * [The design](../../../../architecture/populate-limits.md).
81
+ */
82
+ async internalFindManyPerParent(entity, q, { joins, parents }) {
83
+ const queries = parents.map((parent) => queryChildrenOf(q, joins, parent));
84
+ // A vector sort needs a pipeline of its own shape, which only `internalFindMany` builds.
85
+ if (this.dialect.extractVectorSort(q.$sort)) {
86
+ return this.readEachInTurn(entity, queries);
87
+ }
88
+ const pipelines = queries.map((it) => this.dialect.aggregationPipeline(entity, it));
89
+ // Counted, not estimated: the leading branch's own length grows with every `$lookup` a populate
90
+ // adds, so a fixed parent budget would let a richer query overflow at the server instead.
91
+ const stages = (pipelines[0]?.length ?? 0) + pipelines.length - 1;
92
+ return stages > MAX_PIPELINE_STAGES
93
+ ? this.readEachInTurn(entity, queries)
94
+ : this.readInOnePipeline(entity, q, pipelines);
95
+ }
96
+ /** Every parent's page as one `$unionWith` pipeline. */
97
+ async readInOnePipeline(entity, q, pipelines) {
98
+ const meta = getMeta(entity);
99
+ const [first, ...rest] = pipelines;
100
+ const documents = await this.runPipeline(entity, meta, [
101
+ ...first,
102
+ ...rest.map((pipeline) => ({ $unionWith: { coll: meta.name, pipeline } })),
103
+ ]);
104
+ await this.fillToManyRelations(entity, documents, q.$populate);
105
+ return documents;
106
+ }
107
+ /** A query each, for what one pipeline cannot carry. `internalFindMany` fills its own relations. */
108
+ async readEachInTurn(entity, queries) {
109
+ const documents = [];
110
+ for (const query of queries) {
111
+ documents.push(...(await this.internalFindMany(entity, query)));
112
+ }
113
+ return documents;
114
+ }
66
115
  async *internalFindManyStream(entity, q, opts) {
67
116
  if (asksForNoRows(q)) {
68
117
  return;
@@ -1,5 +1,5 @@
1
1
  import type { EntityData, EntityId, ExtraOptions, FieldKey, IdValue, Querier, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryPopulate, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RawRow, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
- import { LoggerWrapper, type ParentJoin } from '../util/index.js';
2
+ import { LoggerWrapper, type ParentJoin, type ParentPartition } from '../util/index.js';
3
3
  /**
4
4
  * Base class for all database queriers.
5
5
  * It provides a standardized way to execute tasks serially to prevent race conditions on database connections.
@@ -64,6 +64,14 @@ export declare abstract class AbstractQuerier implements Querier {
64
64
  $entity: Type<E>;
65
65
  }, opts?: QueryOptions): AsyncIterable<QueryFindResult<E, S, V, X, P>>;
66
66
  findManyStream<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never>(entity: Type<E>, q: QueryStreamProjected<E, S, V, X, P>, opts?: QueryOptions): AsyncIterable<QueryFindResult<E, S, V, X, P>>;
67
+ /**
68
+ * The children of every parent in `parents`, at most `$limit` each after `$skip` - what a to-many
69
+ * `$populate` carrying either one means. One statement, not one per parent.
70
+ *
71
+ * Abstract rather than defaulted: a default would be N queries, which is the N+1 that batched
72
+ * population exists to prevent, and it would be invisible to whichever backend forgot to override.
73
+ */
74
+ protected abstract internalFindManyPerParent<E extends object>(entity: Type<E>, q: Query<E>, partition: ParentPartition): Promise<E[]>;
67
75
  protected abstract internalFindManyStream<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): AsyncIterable<E>;
68
76
  /**
69
77
  * Find multiple records and return both the records and total count.
@@ -137,6 +145,15 @@ export declare abstract class AbstractQuerier implements Querier {
137
145
  protected fillToManyRelations<E>(entity: Type<E>, payload: E[], populate?: QueryPopulate<E>): Promise<void>;
138
146
  private fillToManyThroughRelation;
139
147
  private fillToManyOneToMany;
148
+ /**
149
+ * The children of a whole page of parents, however the relation asked for them: one bounded branch
150
+ * per parent when it wants a share of its own, otherwise a single flat statement over an `IN (...)`
151
+ * list, which is both correct and cheaper.
152
+ *
153
+ * The one place that decision is made - a one-to-many and the junction of a many-to-many differ in
154
+ * what they query, never in how the page is spread over its parents.
155
+ */
156
+ private findChildrenOf;
140
157
  protected putChildrenInParents<E>(parents: E[], children: RawRow[], joins: readonly ParentJoin[], relKey: keyof E & string): void;
141
158
  protected insertRelations<E extends object>(entity: Type<E>, payload: E[]): Promise<void>;
142
159
  protected updateRelations<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { assertSoleId, getMeta, idOf, soleIdOf } from '../entity/index.js';
2
- import { asSelectMap, augmentWhere, childrenOf, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, joinedColumns, joinedRowKey, LoggerWrapper, parentJoins, parentRowKey, parentsIn, parseRelationAtKey, parseRelationQueryValue, runHooks, someKey, targetKeyColumns, withoutSoftDeleteFilter, } from '../util/index.js';
2
+ import { asSelectMap, augmentWhere, childrenOf, clone, dataKeyed, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, joinedColumns, keyColumns, LoggerWrapper, isBoundedPerParent, parentJoins, queryChildrenOfAll, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, withoutSoftDeleteFilter, } from '../util/index.js';
3
3
  import { enrichError } from './queryError.js';
4
4
  import { fillRelationCounts, withIdForCounts } from './relationCount.js';
5
5
  /**
@@ -299,22 +299,44 @@ export class AbstractQuerier {
299
299
  const throughEntity = relOpts.through();
300
300
  const throughMeta = getMeta(throughEntity);
301
301
  const targetRelKey = getKeys(throughMeta.relations).find((key) => throughMeta.relations[key]?.references.some(({ local }) => local === targetColumn));
302
- // A relation query names the target's columns, not the join table's, so the projection and the
303
- // filter belong on the populate below - resolved there against the entity that has them. Spread
304
- // onto the through query they asked `ItemTag` for `Tag`'s columns: `$where`/`$sort` failed with
305
- // "no such column", and `$exclude` collided with the `$select` this builds.
306
- const { $select: _select, $exclude: _exclude, $where: _where, ...throughQuery } = relationQuery;
307
- const throughFounds = await this.findMany(throughEntity, {
308
- ...throughQuery,
302
+ if (!targetRelKey) {
303
+ // Asserted rather than assumed: used as a key regardless, it spells the literal string
304
+ // `undefined`, and the statement asks the junction for a relation of that name.
305
+ throw new TypeError(`'${meta.name}.${relKey}' goes through '${throughMeta.name}', which declares no relation on its ` +
306
+ `'${targetColumn}' column. Give it one, so the target's rows can be read through it.`);
307
+ }
308
+ // A relation query names the target's columns, not the junction's, so its projection and filter
309
+ // belong on the populate below, resolved against the entity that has them. Spread onto the
310
+ // junction query instead they asked `ItemTag` for `Tag`'s columns and failed with "no such
311
+ // column".
312
+ //
313
+ // Ordering and paging split the other way: they describe the statement with one row per pairing,
314
+ // which is the junction's. Left on the populate they reached a to-one join, which rejects all
315
+ // four by name - so a many-to-many carrying any of them threw rather than paging.
316
+ //
317
+ // Those four are not a coincidence: they are exactly the clauses a joined relation rejects, for
318
+ // the same reason - each needs a statement with many rows per parent, which only the junction's
319
+ // is. The `satisfies` ties the two lists together, so a fifth clause added there fails to compile
320
+ // here rather than quietly staying on the populate and throwing again.
321
+ const { $sort, $limit, $skip, $distinct, ...targetQuery } = relationQuery;
322
+ const junctionClauses = {
323
+ $limit,
324
+ $skip,
325
+ $distinct,
326
+ // Qualified by the relation that reaches them, since the columns it names are the target's.
327
+ $sort: $sort && { [targetRelKey]: $sort },
328
+ };
329
+ const junctionQuery = {
309
330
  $select: joinedColumns(joins),
331
+ ...junctionClauses,
310
332
  $populate: {
311
333
  [targetRelKey]: {
312
- ...relationQuery,
334
+ ...targetQuery,
313
335
  $required: true,
314
336
  },
315
337
  },
316
- $where: parentsIn(joins, payload),
317
- });
338
+ };
339
+ const throughFounds = await this.findChildrenOf(throughEntity, junctionQuery, joins, payload, meta.fields);
318
340
  // The junction's own columns carried onto the target's row, which is where `putChildrenInParents`
319
341
  // reads them back from - a junction row holds the parent's key under `joined`, not under `parent`.
320
342
  const founds = throughFounds.map((it) => ({
@@ -336,22 +358,38 @@ export class AbstractQuerier {
336
358
  }
337
359
  delete exclude?.[joined];
338
360
  }
339
- relationQuery.$where = { ...relationQuery.$where, ...parentsIn(joins, payload) };
340
- const founds = await this.findMany(relEntity, relationQuery);
341
- this.putChildrenInParents(payload, founds, joins, relKey);
361
+ this.putChildrenInParents(payload, await this.findChildrenOf(relEntity, relationQuery, joins, payload, meta.fields), joins, relKey);
362
+ }
363
+ /**
364
+ * The children of a whole page of parents, however the relation asked for them: one bounded branch
365
+ * per parent when it wants a share of its own, otherwise a single flat statement over an `IN (...)`
366
+ * list, which is both correct and cheaper.
367
+ *
368
+ * The one place that decision is made - a one-to-many and the junction of a many-to-many differ in
369
+ * what they query, never in how the page is spread over its parents.
370
+ */
371
+ async findChildrenOf(entity, query, joins, parents, parentFields) {
372
+ const founds = isBoundedPerParent(query)
373
+ ? await this.internalFindManyPerParent(entity, query, { joins, parents, parentFields })
374
+ : await this.findMany(entity, queryChildrenOfAll(query, joins, parents));
375
+ // Read back as rows rather than as the entity they hydrate to: what follows regroups them by the
376
+ // join columns, which a projected entity type does not carry.
377
+ return founds;
342
378
  }
343
379
  putChildrenInParents(parents, children, joins, relKey) {
344
- const childrenByParentId = {};
380
+ const childrenByParentId = dataKeyed();
381
+ // Every joined column, so two children agreeing on one column of a composite key are not
382
+ // gathered under the same parent. Both column lists are read once, not once per row.
383
+ const joinedKeys = keyColumns(joins, 'joined');
384
+ const parentKeys = keyColumns(joins, 'parent');
345
385
  for (const child of children) {
346
- // Every joined column, so two children agreeing on one column of a composite key are not
347
- // gathered under the same parent.
348
- (childrenByParentId[joinedRowKey(joins, child)] ??= []).push(child);
386
+ (childrenByParentId[rowKey(child, joinedKeys)] ??= []).push(child);
349
387
  }
350
388
  for (const parent of parents) {
351
389
  // `[]` rather than nothing for a parent with no children: a populated to-many is a list the
352
390
  // caller asked for, so it maps and counts without a guard, and its type can say so. An
353
391
  // unpopulated one stays absent, which is what tells the two apart.
354
- parent[relKey] = (childrenByParentId[parentRowKey(joins, parent)] ?? []);
392
+ parent[relKey] = (childrenByParentId[rowKey(parent, parentKeys)] ?? []);
355
393
  }
356
394
  }
357
395
  async insertRelations(entity, payload) {
@@ -1,5 +1,6 @@
1
1
  import type { AbstractSqlDialect } from '../dialect/index.js';
2
2
  import type { EntityData, ExtraOptions, IdValue, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryGroupMap, QueryOptions, QuerySearch, QueryUpdateResult, SqlQuerier, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
3
+ import { type ParentPartition } from '../util/index.js';
3
4
  import type { BuildUpdateResultPayload } from '../util/sql.util.js';
4
5
  import { AbstractQuerier } from './abstractQuerier.js';
5
6
  export declare abstract class AbstractSqlQuerier extends AbstractQuerier implements SqlQuerier {
@@ -57,6 +58,19 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
57
58
  */
58
59
  private applyVectorTuning;
59
60
  protected internalFindMany<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<E[]>;
61
+ /**
62
+ * One bounded subquery per parent, concatenated with `UNION ALL`, so each parent gets its own
63
+ * `$limit` rather than a share of one. Universal, and reads `parents x (skip + limit)` rows where a
64
+ * `ROW_NUMBER` window reads every matching child. [The design](../../../../architecture/populate-limits.md).
65
+ *
66
+ * Each branch is a wrapped derived table: SQLite rejects `ORDER BY`/`LIMIT` on a bare parenthesised
67
+ * compound branch, and the wrapper costs nothing elsewhere.
68
+ *
69
+ * Unlike {@link selectRows} this asserts no lock and tunes no vector search: `$lock` and
70
+ * `$candidates` describe the statement, and `parseRelationQueryValue` refuses both on a relation
71
+ * query, so neither can reach here.
72
+ */
73
+ protected internalFindManyPerParent<E extends object>(entity: Type<E>, q: Query<E>, partition: ParentPartition): Promise<E[]>;
60
74
  /**
61
75
  * One statement for both: the page carries its own unpaged total in an extra column. An empty page
62
76
  * has no row to carry it, which is the one case still needing a count of its own - a `$skip` past
@@ -104,6 +104,23 @@ export class AbstractSqlQuerier extends AbstractQuerier {
104
104
  async internalFindMany(entity, q, opts) {
105
105
  return this.hydrateRows(entity, q, await this.selectRows(entity, q, opts));
106
106
  }
107
+ /**
108
+ * One bounded subquery per parent, concatenated with `UNION ALL`, so each parent gets its own
109
+ * `$limit` rather than a share of one. Universal, and reads `parents x (skip + limit)` rows where a
110
+ * `ROW_NUMBER` window reads every matching child. [The design](../../../../architecture/populate-limits.md).
111
+ *
112
+ * Each branch is a wrapped derived table: SQLite rejects `ORDER BY`/`LIMIT` on a bare parenthesised
113
+ * compound branch, and the wrapper costs nothing elsewhere.
114
+ *
115
+ * Unlike {@link selectRows} this asserts no lock and tunes no vector search: `$lock` and
116
+ * `$candidates` describe the statement, and `parseRelationQueryValue` refuses both on a relation
117
+ * query, so neither can reach here.
118
+ */
119
+ async internalFindManyPerParent(entity, q, partition) {
120
+ const ctx = this.dialect.createContext();
121
+ this.dialect.findPerParent(ctx, entity, q, partition);
122
+ return this.hydrateRows(entity, q, await this.all(ctx.sql, ctx.values));
123
+ }
107
124
  /**
108
125
  * One statement for both: the page carries its own unpaged total in an extra column. An empty page
109
126
  * has no row to carry it, which is the one case still needing a count of its own - a `$skip` past
@@ -1,7 +1,7 @@
1
1
  import { COUNT_ALIAS } from '../dialect/aliases.js';
2
2
  import { getMeta, soleIdOf } from '../entity/index.js';
3
3
  import { COUNT_RESULT_KEY } from '../type/index.js';
4
- import { asSelectMap, getKeys, joinedColumns, joinedRowKey, parentJoins, parentRowKey, parentsIn, targetKeyColumns, } from '../util/index.js';
4
+ import { asSelectMap, dataKeyed, getKeys, joinedColumns, keyColumns, parentJoins, parentsIn, rowKey, targetKeyColumns, } from '../util/index.js';
5
5
  /**
6
6
  * A `$count` groups its tallies by the parent's id, so the id has to outlive the projection - the
7
7
  * same reason populating a relation keeps it. A whitelisting `$select` gains the key and an
@@ -50,7 +50,7 @@ export async function fillRelationCounts(querier, entity, payload, count) {
50
50
  return;
51
51
  }
52
52
  const meta = getMeta(entity);
53
- // The tallies come back keyed by the columns *this relation* joins from, so its `joins` are kept
53
+ // The tallies come back keyed by the columns *this relation* joins from, so those columns are kept
54
54
  // beside them: reading the parent through `meta.ids` instead matches only where the two coincide,
55
55
  // which is a to-many and nothing else.
56
56
  const counted = new Map();
@@ -62,14 +62,19 @@ export async function fillRelationCounts(querier, entity, payload, count) {
62
62
  }
63
63
  const where = typeof value === 'object' ? value.$where : undefined;
64
64
  const joins = parentJoins(relOpts, meta.ids.length);
65
- counted.set(relKey, { joins, byParent: await countPerParent(querier, relOpts, joins, payload, where) });
65
+ counted.set(relKey, {
66
+ parentKeys: keyColumns(joins, 'parent'),
67
+ byParent: await countPerParent(querier, relOpts, joins, payload, where),
68
+ });
66
69
  }
67
70
  for (const parent of payload) {
71
+ // A plain object, unlike the tallies below: this one is keyed by relation names the entity
72
+ // declares, not by data, and it is handed to the caller - who would meet a null prototype.
68
73
  const row = {};
69
- for (const [relKey, { joins, byParent }] of counted) {
74
+ for (const [relKey, { parentKeys, byParent }] of counted) {
70
75
  // A parent the grouped result has no row for matched nothing, which is a zero rather than a
71
76
  // gap: `_count` names what the caller asked to count, so every key it asked for is present.
72
- row[relKey] = byParent[parentRowKey(joins, parent)] ?? 0;
77
+ row[relKey] = byParent[rowKey(parent, parentKeys)] ?? 0;
73
78
  }
74
79
  parent[COUNT_RESULT_KEY] = row;
75
80
  }
@@ -105,11 +110,12 @@ async function groupedCount(querier, entity, joins, where) {
105
110
  const $agg = { [COUNT_ALIAS]: { $count: '*' } };
106
111
  const $group = joinedColumns(joins);
107
112
  const rows = await querier.aggregate(entity, { $group, $agg, $where: where });
108
- const byParent = {};
113
+ const byParent = dataKeyed();
114
+ // Keyed by every joined column, which is how a tally finds the one parent whose whole key it
115
+ // matches - and how the rows an over-selecting `IN` brought back find no parent at all.
116
+ const joinedKeys = keyColumns(joins, 'joined');
109
117
  for (const row of rows) {
110
- // Keyed by every joined column, which is how a tally finds the one parent whose whole key it
111
- // matches - and how the rows an over-selecting `IN` brought back find no parent at all.
112
- byParent[joinedRowKey(joins, row)] = Number(row[COUNT_ALIAS]);
118
+ byParent[rowKey(row, joinedKeys)] = Number(row[COUNT_ALIAS]);
113
119
  }
114
120
  return byParent;
115
121
  }
@@ -85,6 +85,16 @@ export type QueryWhereRootOperator<E> = {
85
85
  * {@link QueryWhereRootOperator} so a rename there breaks this union at compile time.
86
86
  */
87
87
  export type QueryNegateOp = keyof Pick<QueryWhereRootOperator<unknown>, '$not' | '$nor'>;
88
+ /**
89
+ * The root operators that join their clauses instead of negating them, tied back to
90
+ * {@link QueryWhereRootOperator} on the same terms as {@link QueryNegateOp}.
91
+ */
92
+ export type QueryJoinOp = keyof Pick<QueryWhereRootOperator<unknown>, '$and' | '$or'>;
93
+ /**
94
+ * Every root operator whose value is a {@link QueryWhereArray} rather than a field condition: the
95
+ * two that join their clauses and the two that negate the join.
96
+ */
97
+ export type QueryGroupOp = QueryJoinOp | QueryNegateOp;
88
98
  /**
89
99
  * Comparison operators accepted by `$size` for range queries: {@link QueryHavingOp} plus `$between`.
90
100
  * Strips `null` from picked operators since array size is always numeric.
@@ -320,7 +330,7 @@ type IsUntypedColumn<T> = [Scalar] extends [NonNullable<T>] ? true : false;
320
330
  */
321
331
  export type QueryWhereFieldValue<T> = T | (undefined extends T ? null : never) | (IsMany<T> extends true ? never : T[]) | QueryWhereFieldOperators<T> | QueryRaw;
322
332
  /**
323
- * query filter array - used for `$and`, `$or`, `$not`, `$nor` operators.
333
+ * query filter array - the value every {@link QueryGroupOp} takes.
324
334
  */
325
335
  export type QueryWhereArray<E> = (QueryWhereMap<E> | QueryRaw)[];
326
336
  /**
@@ -1,4 +1,4 @@
1
- import type { EntityMeta, Except, Query, QueryPopulate, RelationKey, RelationMeta } from '../type/index.js';
1
+ import type { EntityMeta, FieldMeta, Except, Query, QueryPopulate, RelationKey, RelationMeta } from '../type/index.js';
2
2
  export type RelationRequestSummary<E> = {
3
3
  readonly requestedKeys: RelationKey<E>[];
4
4
  readonly joinableKeys: RelationKey<E>[];
@@ -35,13 +35,16 @@ export declare function targetKeyColumns(relOpts: Pick<RelationMeta, 'references
35
35
  /** `{ joined column: true }`: the projection or grouping that keeps a parent's key on the rows read. */
36
36
  export declare function joinedColumns(joins: readonly ParentJoin[]): Record<string, true>;
37
37
  /**
38
- * A parent row keyed by the columns a relation joins *from*, and a child or tally row keyed by the
39
- * columns it carries that key in. The two halves of matching children to parents: they must agree on
40
- * every column, so each is read through `joins` rather than through the parent's own key list - which
41
- * is the same set only for a to-many, and silently a different one otherwise.
38
+ * One side's columns: `'parent'` for the columns a parent is keyed by, `'joined'` for the ones a
39
+ * child or tally row carries that key in. Matching the two halves means agreeing on every column, so
40
+ * each side is read through `joins` rather than through the parent's own key list - the same set only
41
+ * for a to-many, and silently a different one otherwise. `keyof ParentJoin` is what keeps the two
42
+ * sides from being spelled apart.
43
+ *
44
+ * Lifted out of `joins` once per relation, not once per row: {@link rowKey} takes the list and reads
45
+ * each row itself, so a page of children costs one key each and nothing else.
42
46
  */
43
- export declare function parentRowKey(joins: readonly ParentJoin[], parent: unknown): string;
44
- export declare function joinedRowKey(joins: readonly ParentJoin[], row: unknown): string;
47
+ export declare function keyColumns(joins: readonly ParentJoin[], side: keyof ParentJoin): string[];
45
48
  /**
46
49
  * `{ joined column: every parent's value for it }`, the filter that fetches a whole page of parents'
47
50
  * children in one statement.
@@ -51,6 +54,39 @@ export declare function joinedRowKey(joins: readonly ParentJoin[], row: unknown)
51
54
  * cheaper than the row-value comparison no engine spells the same way.
52
55
  */
53
56
  export declare function parentsIn(joins: readonly ParentJoin[], parents: readonly unknown[]): Record<string, unknown[]>;
57
+ /**
58
+ * The parents a bounded to-many read fans out over: the rows themselves, the columns matching them to
59
+ * their children.
60
+ */
61
+ export type ParentPartition = {
62
+ readonly joins: readonly ParentJoin[];
63
+ readonly parents: readonly unknown[];
64
+ /** The parent's own fields: a `LATERAL` row source has to spell its key column's type. */
65
+ readonly parentFields: Readonly<Record<string, FieldMeta | undefined>>;
66
+ };
67
+ /**
68
+ * Whether a to-many's own query asks for a share *per parent* rather than a slice of the whole page.
69
+ * Only `$limit`/`$skip` do: without one, a single flat statement over an `IN (...)` list is both
70
+ * correct and cheaper.
71
+ */
72
+ export declare function isBoundedPerParent(query: Pick<RelationQuery, '$limit' | '$skip'>): boolean;
73
+ /**
74
+ * `query` narrowed to one parent's children: what a single branch of a bounded per-parent read asks
75
+ * for. Shared by the backends so how the parent's filter merges into the relation's own is decided
76
+ * once - both spelled it out, and a rule that ever needs more than a spread would have to change twice.
77
+ */
78
+ export declare function queryChildrenOf<E>(query: Query<E>, joins: readonly ParentJoin[], parent: unknown): Query<E>;
79
+ /**
80
+ * `query` narrowed to the children of a whole page of parents, which is the flat read a relation with
81
+ * no share of its own takes. Over-selects on a composite key exactly as {@link parentsIn} does.
82
+ */
83
+ export declare function queryChildrenOfAll<E>(query: Query<E>, joins: readonly ParentJoin[], parents: readonly unknown[]): Query<E>;
84
+ /**
85
+ * `query` with `filter` merged into its own `$where`: the one rule for narrowing a relation's query to
86
+ * the parents it is being read for, whether the filter names their keys as values or, for a correlated
87
+ * shape, as a reference to a row source.
88
+ */
89
+ export declare function queryNarrowedTo<E>(query: Query<E>, filter: Record<string, unknown>): Query<E>;
54
90
  /**
55
91
  * The `$where` naming exactly the children of the rows `parentIds` identifies: an `IN` over the one
56
92
  * column a single key contributes, an OR of key maps for several.
@@ -1,6 +1,5 @@
1
1
  import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUERY_ROOT_NUMBER_CLAUSES, } from '../type/query.js';
2
2
  import { getKeys, someKey } from './object.util.js';
3
- import { rowKey } from './rowKey.util.js';
4
3
  /**
5
4
  * Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. Takes
6
5
  * the one field it reads, so it answers for a relation being declared as well as for a resolved one.
@@ -37,19 +36,20 @@ export function targetKeyColumns(relOpts, parentKeyCount) {
37
36
  }
38
37
  /** `{ joined column: true }`: the projection or grouping that keeps a parent's key on the rows read. */
39
38
  export function joinedColumns(joins) {
40
- return Object.fromEntries(joins.map(({ joined }) => [joined, true]));
39
+ return Object.fromEntries(keyColumns(joins, 'joined').map((column) => [column, true]));
41
40
  }
42
41
  /**
43
- * A parent row keyed by the columns a relation joins *from*, and a child or tally row keyed by the
44
- * columns it carries that key in. The two halves of matching children to parents: they must agree on
45
- * every column, so each is read through `joins` rather than through the parent's own key list - which
46
- * is the same set only for a to-many, and silently a different one otherwise.
42
+ * One side's columns: `'parent'` for the columns a parent is keyed by, `'joined'` for the ones a
43
+ * child or tally row carries that key in. Matching the two halves means agreeing on every column, so
44
+ * each side is read through `joins` rather than through the parent's own key list - the same set only
45
+ * for a to-many, and silently a different one otherwise. `keyof ParentJoin` is what keeps the two
46
+ * sides from being spelled apart.
47
+ *
48
+ * Lifted out of `joins` once per relation, not once per row: {@link rowKey} takes the list and reads
49
+ * each row itself, so a page of children costs one key each and nothing else.
47
50
  */
48
- export function parentRowKey(joins, parent) {
49
- return rowKey(joins.map(({ parent: key }) => read(parent, key)));
50
- }
51
- export function joinedRowKey(joins, row) {
52
- return rowKey(joins.map(({ joined }) => read(row, joined)));
51
+ export function keyColumns(joins, side) {
52
+ return joins.map((join) => join[side]);
53
53
  }
54
54
  /**
55
55
  * `{ joined column: every parent's value for it }`, the filter that fetches a whole page of parents'
@@ -62,6 +62,45 @@ export function joinedRowKey(joins, row) {
62
62
  export function parentsIn(joins, parents) {
63
63
  return Object.fromEntries(joins.map(({ parent, joined }) => [joined, parents.map((it) => read(it, parent))]));
64
64
  }
65
+ /**
66
+ * Whether a to-many's own query asks for a share *per parent* rather than a slice of the whole page.
67
+ * Only `$limit`/`$skip` do: without one, a single flat statement over an `IN (...)` list is both
68
+ * correct and cheaper.
69
+ */
70
+ export function isBoundedPerParent(query) {
71
+ return query.$limit !== undefined || query.$skip !== undefined;
72
+ }
73
+ /**
74
+ * The `$where` naming exactly one parent's children: every joined column equal to that parent's value.
75
+ * What a per-parent bounded read filters each of its branches by, and the composite half of
76
+ * {@link childrenOf}.
77
+ */
78
+ function childOf(joins, parent) {
79
+ return Object.fromEntries(joins.map(({ parent: key, joined }) => [joined, read(parent, key)]));
80
+ }
81
+ /**
82
+ * `query` narrowed to one parent's children: what a single branch of a bounded per-parent read asks
83
+ * for. Shared by the backends so how the parent's filter merges into the relation's own is decided
84
+ * once - both spelled it out, and a rule that ever needs more than a spread would have to change twice.
85
+ */
86
+ export function queryChildrenOf(query, joins, parent) {
87
+ return queryNarrowedTo(query, childOf(joins, parent));
88
+ }
89
+ /**
90
+ * `query` narrowed to the children of a whole page of parents, which is the flat read a relation with
91
+ * no share of its own takes. Over-selects on a composite key exactly as {@link parentsIn} does.
92
+ */
93
+ export function queryChildrenOfAll(query, joins, parents) {
94
+ return queryNarrowedTo(query, parentsIn(joins, parents));
95
+ }
96
+ /**
97
+ * `query` with `filter` merged into its own `$where`: the one rule for narrowing a relation's query to
98
+ * the parents it is being read for, whether the filter names their keys as values or, for a correlated
99
+ * shape, as a reference to a row source.
100
+ */
101
+ export function queryNarrowedTo(query, filter) {
102
+ return { ...query, $where: { ...query.$where, ...filter } };
103
+ }
65
104
  /**
66
105
  * The `$where` naming exactly the children of the rows `parentIds` identifies: an `IN` over the one
67
106
  * column a single key contributes, an OR of key maps for several.
@@ -74,9 +113,7 @@ export function childrenOf(joins, parentIds) {
74
113
  if (joins.length === 1) {
75
114
  return { [first.joined]: parentIds };
76
115
  }
77
- return {
78
- $or: parentIds.map((id) => Object.fromEntries(joins.map(({ parent, joined }) => [joined, read(id, parent)]))),
79
- };
116
+ return { $or: parentIds.map((id) => childOf(joins, id)) };
80
117
  }
81
118
  function read(row, key) {
82
119
  return row[key];
@@ -1,9 +1,23 @@
1
1
  /**
2
- * A row's key as a string, for matching rows to each other in a `Map`.
2
+ * A row's key as a string, for matching rows to each other in a {@link dataKeyed} lookup.
3
+ *
4
+ * Reads the columns off the row rather than taking their values, because every caller matches a
5
+ * whole page of rows against one fixed column list: taking an array would make each of them build
6
+ * one per row, which is what a page of 250 rows paid 56 KB for.
3
7
  *
4
8
  * Values are normalized before joining, not stringified: `String(date)` is locale- and
5
9
  * timezone-dependent, so two equal dates could key apart, and a `Uint8Array` stringifies to its
6
- * bytes with commas. Every part is included, so two rows agreeing on one column of a composite key
7
- * are not treated as one row.
10
+ * bytes with commas. Every column is included, so two rows agreeing on one column of a composite
11
+ * key are not treated as one row.
12
+ */
13
+ export declare function rowKey(row: unknown, columns: readonly string[]): string;
14
+ /**
15
+ * A lookup keyed by data rather than by a name this code chose, so a key that spells `__proto__` or
16
+ * `constructor` is an ordinary entry instead of the prototype: on `{}` those threw when a bucket was
17
+ * pushed to, and a `_count` tally under one silently read back as an object. Cheaper than a `Map`
18
+ * here, and faster than `{}`, which walks the prototype chain on every miss.
19
+ *
20
+ * It carries none of `Object.prototype`, which no type can say: index it and spread it, but calling
21
+ * `hasOwnProperty` on one type-checks and throws.
8
22
  */
9
- export declare function rowKey(values: readonly unknown[]): string;
23
+ export declare function dataKeyed<V>(): Record<string, V>;
@@ -1,15 +1,39 @@
1
1
  /** Separates the parts of a composite key: a unit separator, which no column value carries. */
2
2
  const KEY_SEPARATOR = '\u001f';
3
3
  /**
4
- * A row's key as a string, for matching rows to each other in a `Map`.
4
+ * A row's key as a string, for matching rows to each other in a {@link dataKeyed} lookup.
5
+ *
6
+ * Reads the columns off the row rather than taking their values, because every caller matches a
7
+ * whole page of rows against one fixed column list: taking an array would make each of them build
8
+ * one per row, which is what a page of 250 rows paid 56 KB for.
5
9
  *
6
10
  * Values are normalized before joining, not stringified: `String(date)` is locale- and
7
11
  * timezone-dependent, so two equal dates could key apart, and a `Uint8Array` stringifies to its
8
- * bytes with commas. Every part is included, so two rows agreeing on one column of a composite key
9
- * are not treated as one row.
12
+ * bytes with commas. Every column is included, so two rows agreeing on one column of a composite
13
+ * key are not treated as one row.
14
+ */
15
+ export function rowKey(row, columns) {
16
+ const values = row;
17
+ let key = '';
18
+ for (let i = 0; i < columns.length; i++) {
19
+ if (i) {
20
+ key += KEY_SEPARATOR;
21
+ }
22
+ key += keyPart(values[columns[i]]);
23
+ }
24
+ return key;
25
+ }
26
+ /**
27
+ * A lookup keyed by data rather than by a name this code chose, so a key that spells `__proto__` or
28
+ * `constructor` is an ordinary entry instead of the prototype: on `{}` those threw when a bucket was
29
+ * pushed to, and a `_count` tally under one silently read back as an object. Cheaper than a `Map`
30
+ * here, and faster than `{}`, which walks the prototype chain on every miss.
31
+ *
32
+ * It carries none of `Object.prototype`, which no type can say: index it and spread it, but calling
33
+ * `hasOwnProperty` on one type-checks and throws.
10
34
  */
11
- export function rowKey(values) {
12
- return values.map(keyPart).join(KEY_SEPARATOR);
35
+ export function dataKeyed() {
36
+ return Object.create(null);
13
37
  }
14
38
  function keyPart(value) {
15
39
  if (value instanceof Date) {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "The JSON-native TypeScript ORM for Bun, Browsers, Edge, Deno, Node, Workers. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.46.0",
6
+ "version": "0.47.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"