uql-orm 0.47.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.
@@ -1,4 +1,4 @@
1
- import type { DialectFeatures, DialectName, EntityMeta, ExtraOptions, FieldOptions, InsertIdSource, NamingStrategy, QueryOptions, QueryWhere, QueryWhereMap } from '../type/index.js';
1
+ import type { DialectFeatures, DialectName, EntityMeta, ExtraOptions, FieldOptions, InsertIdSource, NamingStrategy, QueryGroupOp, QueryOptions, QueryWhere, QueryWhereArray, QueryWhereMap } from '../type/index.js';
2
2
  /**
3
3
  * Options for initializing a dialect.
4
4
  */
@@ -77,4 +77,38 @@ export declare abstract class AbstractDialect {
77
77
  * filters skipped. Recursion within one scope renders the returned map directly instead.
78
78
  */
79
79
  protected scopedWhereMap<E>(meta: EntityMeta<E>, where?: QueryWhere<E>, opts?: QueryOptions): QueryWhereMap<E>;
80
+ /**
81
+ * How each clause-grouping operator renders: which operator joins its clauses, and whether the
82
+ * group is negated afterwards - so `$not` is `NOT (a AND b)` and `$nor` is `NOT (a OR b)`. SQL
83
+ * negates the rendered group; MongoDB spells the same thing with its own `$nor`.
84
+ *
85
+ * Total over {@link QueryGroupOp}, so a fifth operator cannot reach a dialect without both being
86
+ * told how to render it.
87
+ */
88
+ protected static readonly GROUP_OPS: {
89
+ readonly $and: {
90
+ readonly join: '$and';
91
+ readonly negate: false;
92
+ };
93
+ readonly $or: {
94
+ readonly join: '$or';
95
+ readonly negate: false;
96
+ };
97
+ readonly $not: {
98
+ readonly join: '$and';
99
+ readonly negate: true;
100
+ };
101
+ readonly $nor: {
102
+ readonly join: '$or';
103
+ readonly negate: true;
104
+ };
105
+ };
106
+ /** Whether a `$where` key groups clauses, narrowing it for the renderers that read {@link GROUP_OPS}. */
107
+ protected static isGroupOp(key: string): key is QueryGroupOp;
108
+ /**
109
+ * A group operator's clauses, rejecting what the types do not cover: `/http` casts client JSON
110
+ * straight to `Query`, so a scalar can arrive where an array belongs. Shared so both backends
111
+ * refuse the same payload rather than one throwing and the other failing further in.
112
+ */
113
+ protected static groupClauses<E>(key: QueryGroupOp, val: QueryWhereArray<E>): QueryWhereArray<E>;
80
114
  }
@@ -88,4 +88,33 @@ export class AbstractDialect {
88
88
  scopedWhereMap(meta, where = {}, opts) {
89
89
  return applyFilters(meta, buildQueryWhereAsMap(meta, where), opts);
90
90
  }
91
+ /**
92
+ * How each clause-grouping operator renders: which operator joins its clauses, and whether the
93
+ * group is negated afterwards - so `$not` is `NOT (a AND b)` and `$nor` is `NOT (a OR b)`. SQL
94
+ * negates the rendered group; MongoDB spells the same thing with its own `$nor`.
95
+ *
96
+ * Total over {@link QueryGroupOp}, so a fifth operator cannot reach a dialect without both being
97
+ * told how to render it.
98
+ */
99
+ static GROUP_OPS = {
100
+ $and: { join: '$and', negate: false },
101
+ $or: { join: '$or', negate: false },
102
+ $not: { join: '$and', negate: true },
103
+ $nor: { join: '$or', negate: true },
104
+ };
105
+ /** Whether a `$where` key groups clauses, narrowing it for the renderers that read {@link GROUP_OPS}. */
106
+ static isGroupOp(key) {
107
+ return Object.hasOwn(AbstractDialect.GROUP_OPS, key);
108
+ }
109
+ /**
110
+ * A group operator's clauses, rejecting what the types do not cover: `/http` casts client JSON
111
+ * straight to `Query`, so a scalar can arrive where an array belongs. Shared so both backends
112
+ * refuse the same payload rather than one throwing and the other failing further in.
113
+ */
114
+ static groupClauses(key, val) {
115
+ if (val !== undefined && !Array.isArray(val)) {
116
+ throw TypeError(`${key} expects an array, got ${val === null ? 'null' : typeof val}`);
117
+ }
118
+ return val ?? [];
119
+ }
91
120
  }
@@ -1,4 +1,4 @@
1
- import { type EntityMeta, type FieldKey, type FieldOptions, type IsolationLevel, type JsonColumnType, type JsonUpdateOp, type Query, type QueryAggMap, type QueryAggregate, type QueryBuildFn, type QueryComparisonOptions, type QueryConflictPaths, type QueryContext, type QueryDialect, type QueryExclude, type QueryFilter, type QueryGroupMap, type QueryHavingMap, type QueryOptions, type QueryPager, QueryRaw, type QueryRawFnOptions, type QuerySearch, type QuerySelectOptions, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryVectorNear, type QueryWhere, type QueryWhereArray, type QueryWhereFieldOperatorMap, type QueryWhereMap, type QueryWhereOptions, type RelationMeta, type SqlDialectName, type SqlQueryDialect, type Type, type UpdatePayload } from '../type/index.js';
1
+ import { type EntityMeta, type FieldKey, type FieldOptions, type IsolationLevel, type JsonColumnType, type JsonUpdateOp, type Query, type QueryAggMap, type QueryAggregate, type QueryBuildFn, type QueryComparisonOptions, type QueryConflictPaths, type QueryContext, type QueryDialect, type QueryExclude, type QueryFilter, type QueryGroupMap, type QueryGroupOp, type QueryHavingMap, type QueryOptions, type QueryPager, QueryRaw, type QueryRawFnOptions, type QuerySearch, type QuerySelectOptions, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryVectorNear, type QueryWhere, type QueryWhereArray, type QueryWhereFieldOperatorMap, type QueryWhereMap, type QueryWhereOptions, type RelationMeta, type SqlDialectName, type SqlQueryDialect, type Type, type UpdatePayload } from '../type/index.js';
2
2
  import { type ParentPartition } from '../util/index.js';
3
3
  import type { HydrateKind } from './hydrateColumn.js';
4
4
  import { type JsonAccessMode } from './jsonSql.js';
@@ -76,6 +76,15 @@ export declare abstract class AbstractSqlDialect extends VectorSqlDialect implem
76
76
  * shared for the same reason - see {@link SqlQueryContext}.
77
77
  */
78
78
  protected buildFragment(ctx: QueryContext, build: QueryBuildFn): string;
79
+ /**
80
+ * Each operand rendered into its own fragment, keeping only those that emitted SQL.
81
+ *
82
+ * Nothing reaches `ctx` until every one has rendered, because an operand that emits nothing - an
83
+ * empty `$and`, an `{}` entry - must leave behind neither a dangling separator nor a clause with no
84
+ * condition after it. How many terms really emit is also what decides the parentheses, which is why
85
+ * the caller counts what comes back rather than what it passed in.
86
+ */
87
+ protected renderOperands<T>(ctx: QueryContext, operands: readonly T[], render: (ctx: QueryContext, operand: T) => void): string[];
79
88
  addValue(values: unknown[], value: unknown): string;
80
89
  /**
81
90
  * Normalizes a parameter value for the database driver.
@@ -134,13 +143,12 @@ export declare abstract class AbstractSqlDialect extends VectorSqlDialect implem
134
143
  protected selectRelationFields(ctx: QueryContext, joins: QueryJoins): void;
135
144
  protected selectRelationJoins<E>(ctx: QueryContext, meta: EntityMeta<E>, rootAlias: string, joins: QueryJoins): void;
136
145
  where<E>(ctx: QueryContext, entity: Type<E>, where?: QueryWhere<E>, opts?: QueryWhereOptions): void;
137
- /** Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or` recursion). */
146
+ /** Renders a `$where` tree without applying entity filters (used for same-scope group-operator recursion). */
138
147
  protected renderWhere<E>(ctx: QueryContext, entity: Type<E>, where?: QueryWhere<E>, opts?: QueryWhereOptions): void;
139
148
  compare<E>(ctx: QueryContext, entity: Type<E>, key: string, val: unknown, opts?: QueryComparisonOptions): void;
140
- protected compareLogicalOperator<E>(ctx: QueryContext, entity: Type<E>, key: '$and' | '$or' | '$not' | '$nor', val: QueryWhereArray<E>, opts: QueryComparisonOptions): void;
149
+ protected compareLogicalOperator<E>(ctx: QueryContext, entity: Type<E>, key: QueryGroupOp, val: QueryWhereArray<E>, opts: QueryComparisonOptions): void;
141
150
  /** Memoizes {@link escapedColumnName}; see there for why it is per dialect instance. */
142
151
  private readonly escapedColumns;
143
- private static readonly NEGATE_OP_MAP;
144
152
  private static readonly COMPARE_OP_MAP;
145
153
  /** What a `$near` says about the search itself; everything else in it is a bound. */
146
154
  private static readonly VECTOR_QUERY_KEYS;
@@ -100,6 +100,19 @@ export class AbstractSqlDialect extends VectorSqlDialect {
100
100
  build(fragmentCtx);
101
101
  return fragmentCtx.sql;
102
102
  }
103
+ /**
104
+ * Each operand rendered into its own fragment, keeping only those that emitted SQL.
105
+ *
106
+ * Nothing reaches `ctx` until every one has rendered, because an operand that emits nothing - an
107
+ * empty `$and`, an `{}` entry - must leave behind neither a dangling separator nor a clause with no
108
+ * condition after it. How many terms really emit is also what decides the parentheses, which is why
109
+ * the caller counts what comes back rather than what it passed in.
110
+ */
111
+ renderOperands(ctx, operands, render) {
112
+ return operands
113
+ .map((operand) => this.buildFragment(ctx, (fragmentCtx) => render(fragmentCtx, operand)))
114
+ .filter((part) => part !== '');
115
+ }
103
116
  addValue(values, value) {
104
117
  values.push(this.normalizeValue(value));
105
118
  return this.placeholder(values.length);
@@ -306,40 +319,29 @@ export class AbstractSqlDialect extends VectorSqlDialect {
306
319
  // Filters are applied once, here at the scope entry point; recursion uses `renderWhere`.
307
320
  this.renderWhere(ctx, entity, this.scopedWhereMap(meta, where, opts), opts);
308
321
  }
309
- /** Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or` recursion). */
322
+ /** Renders a `$where` tree without applying entity filters (used for same-scope group-operator recursion). */
310
323
  renderWhere(ctx, entity, where = {}, opts = {}) {
311
324
  const meta = getMeta(entity);
312
325
  const { clause = 'WHERE' } = opts;
313
- where = buildQueryWhereAsMap(meta, where);
326
+ const whereMap = buildQueryWhereAsMap(meta, where);
314
327
  // An `undefined` value emits nothing, so it must not count towards the terms either: it decides
315
- // both where the `AND`s go and whether this fragment needs parentheses.
316
- const whereKeys = getKeys(where).filter((key) => where[key] !== undefined);
317
- if (!whereKeys.length) {
328
+ // whether the keys below render as operands of an `AND`.
329
+ const whereKeys = getKeys(whereMap).filter((key) => whereMap[key] !== undefined);
330
+ // Each key is an operand of the `AND` joining them; a lone key emits this fragment verbatim, so
331
+ // it inherits this one's position instead.
332
+ const childOperand = whereKeys.length > 1 || opts.operand || clause === 'AND';
333
+ const childOpts = opts.operand === childOperand ? opts : { ...opts, operand: childOperand };
334
+ const parts = this.renderOperands(ctx, whereKeys, (fragmentCtx, key) => this.compare(fragmentCtx, entity, key, whereMap[key], childOpts));
335
+ if (!parts.length) {
318
336
  return;
319
337
  }
320
338
  if (clause) {
321
339
  ctx.append(` ${clause} `);
322
340
  }
323
- const multipleKeys = whereKeys.length > 1;
324
341
  // This fragment joins its own keys with `AND`, so appending it after one (a JOIN's `ON`) needs no
325
342
  // parentheses - but anything nested in it is still an operand, since that may be an `OR`.
326
- const parenthesize = multipleKeys && opts.operand;
327
- if (parenthesize) {
328
- ctx.append('(');
329
- }
330
- // Each key is an operand of the `AND` joining them; a lone key emits this fragment verbatim, so
331
- // it inherits this one's position instead.
332
- const childOperand = multipleKeys || opts.operand || clause === 'AND';
333
- const childOpts = opts.operand === childOperand ? opts : { ...opts, operand: childOperand };
334
- whereKeys.forEach((key, index) => {
335
- if (index > 0) {
336
- ctx.append(' AND ');
337
- }
338
- this.compare(ctx, entity, key, where[key], childOpts);
339
- });
340
- if (parenthesize) {
341
- ctx.append(')');
342
- }
343
+ const body = parts.join(' AND ');
344
+ ctx.append(parts.length > 1 && opts.operand ? `(${body})` : body);
343
345
  }
344
346
  compare(ctx, entity, key, val, opts = {}) {
345
347
  const meta = getMeta(entity);
@@ -366,7 +368,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
366
368
  this.appendTextSearch(ctx, entity, meta, val);
367
369
  return;
368
370
  }
369
- if (key === '$and' || key === '$or' || key === '$not' || key === '$nor') {
371
+ if (AbstractSqlDialect.isGroupOp(key)) {
370
372
  this.compareLogicalOperator(ctx, entity, key, val, opts);
371
373
  return;
372
374
  }
@@ -405,43 +407,29 @@ export class AbstractSqlDialect extends VectorSqlDialect {
405
407
  }
406
408
  }
407
409
  compareLogicalOperator(ctx, entity, key, val, opts) {
408
- const op = AbstractSqlDialect.NEGATE_OP_MAP.get(key) ?? key;
409
- const negate = AbstractSqlDialect.NEGATE_OP_MAP.has(key);
410
- if (val !== undefined && !Array.isArray(val)) {
411
- // Not covered by the types: `/http` casts client JSON straight to `Query`, so this arrives untyped.
412
- throw TypeError(`${key} expects an array, got ${val === null ? 'null' : typeof val}`);
413
- }
414
- const items = val ?? [];
410
+ const { join, negate } = AbstractSqlDialect.GROUP_OPS[key];
411
+ const items = AbstractSqlDialect.groupClauses(key, val);
415
412
  // With more than one item each is an operand of the operator joining them, so a compound item
416
413
  // parenthesizes itself and precedence never applies; a lone item is this group verbatim, so it
417
414
  // inherits the group's own position. A negation always makes its subject an operand.
418
415
  const childOperand = items.length > 1 || negate || opts.operand;
419
- // Rendered before anything is appended, because an item that contributes no SQL (`{}`, an
420
- // `undefined` entry) must leave no dangling separator behind, and how many terms this fragment
421
- // really emits is what decides whether it needs parentheses.
422
- const parts = items
423
- .map((entry) => this.buildFragment(ctx, (fragmentCtx) => {
416
+ const parts = this.renderOperands(ctx, items, (fragmentCtx, entry) => {
424
417
  if (entry instanceof QueryRaw) {
425
418
  this.getRawValue(fragmentCtx, { value: entry });
426
419
  }
427
420
  else if (entry) {
428
421
  this.renderWhere(fragmentCtx, entity, entry, { prefix: opts.prefix, operand: childOperand, clause: false });
429
422
  }
430
- }))
431
- .filter((part) => part !== '');
423
+ });
432
424
  if (!parts.length) {
433
425
  return;
434
426
  }
435
- const body = parts.join(op === '$or' ? ' OR ' : ' AND ');
427
+ const body = parts.join(join === '$or' ? ' OR ' : ' AND ');
436
428
  const parenthesize = parts.length > 1 && (opts.operand || negate);
437
429
  ctx.append((negate ? 'NOT ' : '') + (parenthesize ? `(${body})` : body));
438
430
  }
439
431
  /** Memoizes {@link escapedColumnName}; see there for why it is per dialect instance. */
440
432
  escapedColumns = new WeakMap();
441
- static NEGATE_OP_MAP = new Map([
442
- ['$not', '$and'],
443
- ['$nor', '$or'],
444
- ]);
445
433
  static COMPARE_OP_MAP = new Map([
446
434
  ['$gt', ' > '],
447
435
  ['$gte', ' >= '],
@@ -43,11 +43,21 @@ export declare class MongoDialect extends AbstractDialect {
43
43
  /** Whether a `$where` constrains any relation, and so needs the aggregation path rather than a cursor. */
44
44
  constrainsRelations<E extends Document>(entity: Type<E>, where: QueryWhere<E> | undefined): boolean;
45
45
  /**
46
- * Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or`
46
+ * Renders a `$where` tree without applying entity filters (used for same-scope group-operator
47
47
  * recursion). Relation keys need `$lookup` stages, so they are only accepted when `lookups` is
48
48
  * given - a plain `find`/`updateMany` filter has nowhere to put them.
49
49
  */
50
50
  private renderFilter;
51
+ /**
52
+ * Renders `$and`/`$or`/`$not`/`$nor` into `filter`. MongoDB has no root-level `$not`, so both
53
+ * negating operators become its `$nor`, which is exactly `NOT (a OR b)` - and by De Morgan that
54
+ * makes a `$nor` list its clauses directly while a `$not` wraps them in one `$and` first.
55
+ *
56
+ * Clauses that render to nothing are dropped and an empty operator emits no key at all: MongoDB
57
+ * rejects an empty `$and`/`$or`/`$nor` outright, where the SQL dialects contribute no term.
58
+ * Negations accumulate into the one `$nor`, since `NOT a AND NOT b` is `$nor: [a, b]`.
59
+ */
60
+ private appendLogicalOperator;
51
61
  /**
52
62
  * Emits the correlated `$lookup` for one relation condition and returns the condition that tests its
53
63
  * result: presence of a row for a plain relation filter, a comparison against the row count for
@@ -83,12 +83,12 @@ export class MongoDialect extends AbstractDialect {
83
83
  }
84
84
  const meta = getMeta(entity);
85
85
  const whereMap = buildQueryWhereAsMap(meta, where);
86
- return someKey(whereMap, (key) => key === '$and' || key === '$or'
87
- ? whereMap[key].some((it) => this.constrainsRelations(entity, it))
86
+ return someKey(whereMap, (key) => MongoDialect.isGroupOp(key)
87
+ ? (whereMap[key] ?? []).some((it) => this.constrainsRelations(entity, it))
88
88
  : Boolean(meta.relations[key]));
89
89
  }
90
90
  /**
91
- * Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or`
91
+ * Renders a `$where` tree without applying entity filters (used for same-scope group-operator
92
92
  * recursion). Relation keys need `$lookup` stages, so they are only accepted when `lookups` is
93
93
  * given - a plain `find`/`updateMany` filter has nowhere to put them.
94
94
  */
@@ -99,13 +99,8 @@ export class MongoDialect extends AbstractDialect {
99
99
  for (const [rawKey, rawVal] of Object.entries(whereMap)) {
100
100
  let key = rawKey;
101
101
  let val = rawVal;
102
- if (key === '$and' || key === '$or') {
103
- filter[key] = val.map((filterIt) => {
104
- // A `QueryRaw` here would recurse forever: `buildQueryWhereAsMap` re-wraps it as
105
- // `{ $and: [raw] }`, which lands back on this branch.
106
- this.assertNoRaw(filterIt);
107
- return this.renderFilter(entity, filterIt, opts, lookups);
108
- });
102
+ if (MongoDialect.isGroupOp(key)) {
103
+ this.appendLogicalOperator(filter, entity, key, val, opts, lookups);
109
104
  }
110
105
  else if (key === '$text') {
111
106
  // MongoDB's text index declares which fields it covers, so `$fields` cannot narrow the search
@@ -137,6 +132,35 @@ export class MongoDialect extends AbstractDialect {
137
132
  }
138
133
  return filter;
139
134
  }
135
+ /**
136
+ * Renders `$and`/`$or`/`$not`/`$nor` into `filter`. MongoDB has no root-level `$not`, so both
137
+ * negating operators become its `$nor`, which is exactly `NOT (a OR b)` - and by De Morgan that
138
+ * makes a `$nor` list its clauses directly while a `$not` wraps them in one `$and` first.
139
+ *
140
+ * Clauses that render to nothing are dropped and an empty operator emits no key at all: MongoDB
141
+ * rejects an empty `$and`/`$or`/`$nor` outright, where the SQL dialects contribute no term.
142
+ * Negations accumulate into the one `$nor`, since `NOT a AND NOT b` is `$nor: [a, b]`.
143
+ */
144
+ appendLogicalOperator(filter, entity, key, val, opts, lookups) {
145
+ const { join, negate } = MongoDialect.GROUP_OPS[key];
146
+ const parts = MongoDialect.groupClauses(key, val)
147
+ .map((filterIt) => {
148
+ // A `QueryRaw` here would recurse forever: `buildQueryWhereAsMap` re-wraps it as
149
+ // `{ $and: [raw] }`, which lands back on this branch.
150
+ this.assertNoRaw(filterIt);
151
+ return this.renderFilter(entity, filterIt, opts, lookups);
152
+ })
153
+ .filter((part) => Object.keys(part).length > 0);
154
+ if (!parts.length) {
155
+ return;
156
+ }
157
+ if (!negate) {
158
+ filter[key] = parts;
159
+ return;
160
+ }
161
+ const negated = join === '$and' && parts.length > 1 ? [{ $and: parts }] : parts;
162
+ filter['$nor'] = [...(filter['$nor'] ?? []), ...negated];
163
+ }
140
164
  /**
141
165
  * Emits the correlated `$lookup` for one relation condition and returns the condition that tests its
142
166
  * result: presence of a row for a plain relation filter, a comparison against the row count for
@@ -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, isBoundedPerParent, parentJoins, parentRowKey, queryChildrenOfAll, 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
  /**
@@ -377,17 +377,19 @@ export class AbstractQuerier {
377
377
  return founds;
378
378
  }
379
379
  putChildrenInParents(parents, children, joins, relKey) {
380
- 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');
381
385
  for (const child of children) {
382
- // Every joined column, so two children agreeing on one column of a composite key are not
383
- // gathered under the same parent.
384
- (childrenByParentId[joinedRowKey(joins, child)] ??= []).push(child);
386
+ (childrenByParentId[rowKey(child, joinedKeys)] ??= []).push(child);
385
387
  }
386
388
  for (const parent of parents) {
387
389
  // `[]` rather than nothing for a parent with no children: a populated to-many is a list the
388
390
  // caller asked for, so it maps and counts without a guard, and its type can say so. An
389
391
  // unpopulated one stays absent, which is what tells the two apart.
390
- parent[relKey] = (childrenByParentId[parentRowKey(joins, parent)] ?? []);
392
+ parent[relKey] = (childrenByParentId[rowKey(parent, parentKeys)] ?? []);
391
393
  }
392
394
  }
393
395
  async insertRelations(entity, payload) {
@@ -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
  /**
@@ -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.
@@ -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'
@@ -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.47.0",
6
+ "version": "0.47.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"