turbine-orm 0.27.0 → 0.28.0

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.
Files changed (52) hide show
  1. package/README.md +17 -13
  2. package/dist/cjs/cli/config.js +20 -3
  3. package/dist/cjs/cli/destructive.js +47 -31
  4. package/dist/cjs/cli/index.js +273 -71
  5. package/dist/cjs/cli/mcp.js +788 -0
  6. package/dist/cjs/cli/migrate.js +95 -20
  7. package/dist/cjs/cli/studio.js +3 -2
  8. package/dist/cjs/client.js +267 -34
  9. package/dist/cjs/dialect.js +2 -0
  10. package/dist/cjs/generate.js +171 -7
  11. package/dist/cjs/index.js +4 -1
  12. package/dist/cjs/introspect.js +177 -4
  13. package/dist/cjs/query/batched-loader.js +148 -0
  14. package/dist/cjs/query/builder.js +714 -133
  15. package/dist/cjs/schema-builder.js +59 -4
  16. package/dist/cjs/schema-sql.js +315 -6
  17. package/dist/cjs/seed.js +66 -0
  18. package/dist/cli/config.d.ts +9 -2
  19. package/dist/cli/config.js +19 -3
  20. package/dist/cli/destructive.js +47 -31
  21. package/dist/cli/index.d.ts +52 -1
  22. package/dist/cli/index.js +272 -74
  23. package/dist/cli/mcp.d.ts +17 -0
  24. package/dist/cli/mcp.js +781 -0
  25. package/dist/cli/migrate.d.ts +37 -0
  26. package/dist/cli/migrate.js +92 -20
  27. package/dist/cli/studio.d.ts +3 -2
  28. package/dist/cli/studio.js +3 -2
  29. package/dist/client.d.ts +136 -1
  30. package/dist/client.js +267 -34
  31. package/dist/dialect.d.ts +17 -0
  32. package/dist/dialect.js +2 -0
  33. package/dist/generate.d.ts +17 -0
  34. package/dist/generate.js +171 -10
  35. package/dist/index.d.ts +4 -3
  36. package/dist/index.js +2 -0
  37. package/dist/introspect.d.ts +20 -1
  38. package/dist/introspect.js +175 -4
  39. package/dist/query/batched-loader.d.ts +29 -2
  40. package/dist/query/batched-loader.js +148 -1
  41. package/dist/query/builder.d.ts +156 -8
  42. package/dist/query/builder.js +715 -134
  43. package/dist/query/index.d.ts +1 -1
  44. package/dist/query/types.d.ts +113 -8
  45. package/dist/schema-builder.d.ts +73 -8
  46. package/dist/schema-builder.js +59 -4
  47. package/dist/schema-sql.d.ts +67 -0
  48. package/dist/schema-sql.js +310 -6
  49. package/dist/schema.d.ts +53 -0
  50. package/dist/seed.d.ts +4 -0
  51. package/dist/seed.js +63 -0
  52. package/package.json +2 -3
@@ -46,7 +46,7 @@
46
46
  *
47
47
  * @module
48
48
  */
49
- import { CircularRelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
49
+ import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
50
50
  import { normalizeKeyColumns } from '../schema.js';
51
51
  /**
52
52
  * Max parent keys per follow-up query. On Postgres the whole key set travels as
@@ -114,6 +114,14 @@ export function neededParentKeyFields(parentMeta, withClause) {
114
114
  for (const [relName, spec] of Object.entries(withClause)) {
115
115
  if (!spec)
116
116
  continue;
117
+ // `_count` needs each counted relation's parent-side key to stitch counts.
118
+ if (relName === '_count') {
119
+ for (const rel of resolveCountRelations(parentMeta, spec)) {
120
+ for (const col of localKeyColumns(rel))
121
+ fields.add(parentMeta.reverseColumnMap[col] ?? col);
122
+ }
123
+ continue;
124
+ }
117
125
  const rel = parentMeta.relations[relName];
118
126
  if (!rel)
119
127
  continue; // unknown relation — the join path throws; let the loader surface it
@@ -134,6 +142,37 @@ function localKeyColumns(rel) {
134
142
  return normalizeKeyColumns(rel.foreignKey);
135
143
  return normalizeKeyColumns(rel.referenceKey);
136
144
  }
145
+ /**
146
+ * Resolve the set of to-many relations a `_count` spec selects. `true` counts
147
+ * every to-many relation (hasMany + manyToMany) of the table; the record form
148
+ * counts only the enabled names. Shared by the join builder and the batched
149
+ * loader so both count the exact same relations.
150
+ *
151
+ * Errors: E005 ({@link RelationError}) for an unknown relation name, E003
152
+ * ({@link ValidationError}) when a named relation is to-one.
153
+ */
154
+ export function resolveCountRelations(parentMeta, countSpec) {
155
+ const isToMany = (r) => r.type === 'hasMany' || r.type === 'manyToMany';
156
+ if (countSpec === true) {
157
+ return Object.values(parentMeta.relations).filter(isToMany);
158
+ }
159
+ const out = [];
160
+ for (const [relName, enabled] of Object.entries(countSpec)) {
161
+ if (!enabled)
162
+ continue;
163
+ const rel = parentMeta.relations[relName];
164
+ if (!rel) {
165
+ throw new RelationError(`[turbine] Unknown relation "${relName}" in _count on table "${parentMeta.name}". ` +
166
+ `Available: ${Object.keys(parentMeta.relations).join(', ')}`);
167
+ }
168
+ if (!isToMany(rel)) {
169
+ throw new ValidationError(`[turbine] _count is only supported for to-many relations; "${relName}" on ` +
170
+ `"${parentMeta.name}" is a to-one relation.`);
171
+ }
172
+ out.push(rel);
173
+ }
174
+ return out;
175
+ }
137
176
  /** Stringified stitch key — robust to number/uuid/bigint type drift across a join. */
138
177
  function keyOf(value) {
139
178
  return String(value);
@@ -155,6 +194,11 @@ export async function loadRelationsBatched(ctx, parents, withClause, timeout, de
155
194
  for (const [relName, spec] of Object.entries(withClause)) {
156
195
  if (!spec)
157
196
  continue;
197
+ // Reserved `_count` key — one grouped COUNT(*) follow-up per counted relation.
198
+ if (relName === '_count') {
199
+ loads.push(loadCounts(ctx, parents, spec));
200
+ continue;
201
+ }
158
202
  const rel = ctx.parentMeta.relations[relName];
159
203
  if (!rel) {
160
204
  throw new ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
@@ -209,6 +253,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
209
253
  select: proj.select,
210
254
  omit: proj.omit,
211
255
  orderBy: options.orderBy,
256
+ skipGlobalFilters: ctx.skipGlobalFilters,
212
257
  });
213
258
  const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
214
259
  return deferred.transform(result);
@@ -307,6 +352,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
307
352
  select: proj.select,
308
353
  omit: proj.omit,
309
354
  orderBy: options.orderBy,
355
+ skipGlobalFilters: ctx.skipGlobalFilters,
310
356
  });
311
357
  const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
312
358
  return deferred.transform(result);
@@ -341,6 +387,107 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
341
387
  }
342
388
  stripFields(targetsInOrder, proj.strip);
343
389
  }
390
+ /**
391
+ * Load correlated `_count` values for the counted relations. One grouped
392
+ * follow-up per relation (`SELECT key, COUNT(*) … WHERE key = ANY($1) GROUP BY
393
+ * key`), attached onto each parent's `_count` object (0 when a parent has no
394
+ * matching rows) — byte-identical to the join strategy's `_count` output.
395
+ */
396
+ async function loadCounts(ctx, parents, countSpec) {
397
+ const rels = resolveCountRelations(ctx.parentMeta, countSpec);
398
+ // Initialise every parent's `_count` up-front so the concurrent per-relation
399
+ // loads below (each writing its own key) never race on the object creation.
400
+ for (const parent of parents) {
401
+ if (parent._count === undefined)
402
+ parent._count = {};
403
+ }
404
+ await Promise.all(rels.map((rel) => loadOneCount(ctx, parents, rel)));
405
+ }
406
+ /** One grouped COUNT(*) follow-up for a single to-many relation. */
407
+ async function loadOneCount(ctx, parents, rel) {
408
+ let parentKeyCol;
409
+ let childTable;
410
+ let childKeyCol;
411
+ if (rel.type === 'manyToMany') {
412
+ const through = rel.through;
413
+ if (!through) {
414
+ throw new ValidationError(`[turbine] manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
415
+ }
416
+ const sourceRef = normalizeKeyColumns(rel.referenceKey);
417
+ const sourceJ = normalizeKeyColumns(through.sourceKey);
418
+ if (sourceRef.length > 1 || sourceJ.length > 1) {
419
+ throw new UnsupportedFeatureError('composite-key batched _count', 'relationLoadStrategy: "batched"', `relation "${rel.name}" — use the default 'join' strategy for composite-key m2m _count`);
420
+ }
421
+ parentKeyCol = sourceRef[0];
422
+ childTable = through.table;
423
+ childKeyCol = sourceJ[0];
424
+ }
425
+ else {
426
+ // hasMany: child FK correlates to the parent reference key.
427
+ const fk = normalizeKeyColumns(rel.foreignKey);
428
+ const rk = normalizeKeyColumns(rel.referenceKey);
429
+ if (fk.length > 1 || rk.length > 1) {
430
+ throw new UnsupportedFeatureError('composite-key batched _count', 'relationLoadStrategy: "batched"', `relation "${rel.name}" — use the default 'join' strategy for composite-key _count`);
431
+ }
432
+ parentKeyCol = rk[0];
433
+ childTable = rel.to;
434
+ childKeyCol = fk[0];
435
+ }
436
+ const parentKeyField = ctx.parentMeta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
437
+ const keys = uniqueKeys(parents, parentKeyField);
438
+ const counts = new Map();
439
+ if (keys.length > 0) {
440
+ const qChild = ctx.quote(childTable);
441
+ const qKey = ctx.quote(childKeyCol);
442
+ const chunks = [];
443
+ for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
444
+ chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
445
+ // Global filter on the counted target, matching the join strategy so the
446
+ // two strategies return identical counts under a filter. hasMany filters
447
+ // the counted table directly; m2m counts junction rows but restricts them
448
+ // to junction rows whose TARGET survives the target table's filter via
449
+ // EXISTS — mirroring buildRelationCountExpr's EXISTS-on-target (which also
450
+ // skips the filter when the junction targetKey arity doesn't match the
451
+ // target PK). Rendered after the $1 key array.
452
+ let gf = null;
453
+ if (ctx.tableGlobalFilter) {
454
+ if (rel.type === 'manyToMany' && rel.through) {
455
+ const targetKeys = normalizeKeyColumns(rel.through.targetKey);
456
+ const targetMeta = ctx.schema.tables[rel.to];
457
+ const pk = targetMeta?.primaryKey ?? [];
458
+ if (targetMeta && pk.length > 0 && pk.length === targetKeys.length) {
459
+ const targetGf = ctx.tableGlobalFilter(rel.to, 't', 1);
460
+ if (targetGf) {
461
+ const join = targetKeys.map((jc, i) => `t.${ctx.quote(pk[i])} = ${qChild}.${ctx.quote(jc)}`).join(' AND ');
462
+ gf = {
463
+ clause: `EXISTS (SELECT 1 FROM ${ctx.quote(rel.to)} t WHERE ${join} AND ${targetGf.clause})`,
464
+ params: targetGf.params,
465
+ };
466
+ }
467
+ }
468
+ }
469
+ else if (rel.type !== 'manyToMany') {
470
+ gf = ctx.tableGlobalFilter(childTable, qChild, 1);
471
+ }
472
+ }
473
+ const gfAnd = gf ? ` AND ${gf.clause}` : '';
474
+ const results = await Promise.all(chunks.map((chunk) => {
475
+ const params = [ctx.inClauseParam(chunk), ...(gf ? gf.params : [])];
476
+ const predicate = ctx.buildInClause(`${qChild}.${qKey}`, ctx.paramPlaceholder(1), false);
477
+ const sql = `SELECT ${qChild}.${qKey} AS "k", COUNT(*) AS "c" FROM ${qChild} ` +
478
+ `WHERE ${predicate}${gfAnd} GROUP BY ${qChild}.${qKey}`;
479
+ return ctx.exec(sql, params);
480
+ }));
481
+ for (const { rows } of results) {
482
+ for (const row of rows) {
483
+ counts.set(keyOf(row.k), Number(row.c));
484
+ }
485
+ }
486
+ }
487
+ for (const parent of parents) {
488
+ parent._count[rel.name] = counts.get(keyOf(parent[parentKeyField])) ?? 0;
489
+ }
490
+ }
344
491
  // ---------------------------------------------------------------------------
345
492
  // Small helpers
346
493
  // ---------------------------------------------------------------------------
@@ -13,7 +13,7 @@
13
13
  import type pg from 'pg';
14
14
  import type { Dialect } from '../dialect.js';
15
15
  import type { SchemaMetadata } from '../schema.js';
16
- import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, QueryResult, RelationLoadStrategy, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
16
+ import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GlobalFilters, GroupByArgs, QueryResult, RelationLoadStrategy, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
17
17
  /**
18
18
  * Runs a SQL statement and resolves its raw result. Passed to a
19
19
  * {@link DeferredQuery.reselect} plan so it can run the write and the follow-up
@@ -114,6 +114,13 @@ export interface QueryInterfaceOptions {
114
114
  * `with` clause on any other dialect throws `UnsupportedFeatureError` (E017).
115
115
  */
116
116
  jsonEncoding?: 'object' | 'positional';
117
+ /**
118
+ * Automatic WHERE filters keyed by table accessor, AND-merged into every
119
+ * query on that table and every relation subquery targeting it (soft-delete /
120
+ * multi-tenancy). Function values are evaluated at query-build time. See
121
+ * {@link GlobalFilters}.
122
+ */
123
+ globalFilters?: GlobalFilters;
117
124
  /** @internal Set by TransactionClient — signals that this QI runs inside an active transaction. */
118
125
  _txScoped?: boolean;
119
126
  /** @internal Callback from TurbineClient for query event emission. */
@@ -145,6 +152,13 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
145
152
  private readonly relationLoadStrategy;
146
153
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
147
154
  private readonly jsonEncoding;
155
+ /**
156
+ * Client-level automatic WHERE filters keyed by table accessor (soft-delete /
157
+ * multi-tenancy). AND-merged into every query on the keyed table and every
158
+ * relation subquery targeting it. Undefined when none are configured, in
159
+ * which case every path is byte-identical to the pre-0.28 behavior.
160
+ */
161
+ private readonly globalFilters?;
148
162
  /**
149
163
  * Tracks tables that have already triggered an unlimited-query warning so
150
164
  * the user is not spammed once per row. Per-instance state — each
@@ -176,6 +190,15 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
176
190
  private readonly options?;
177
191
  /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
178
192
  private currentAction;
193
+ /**
194
+ * The active query's `skipGlobalFilters` opt-out, set at the top of each
195
+ * `build*` method and read deep in the (synchronous) SQL-build + param-collect
196
+ * tree — so relation subqueries, relation filters, `_count`, and relation
197
+ * `orderBy` all see it without threading it through dozens of signatures.
198
+ * Only load-bearing when {@link globalFilters} is configured; build+collect are
199
+ * synchronous per call, so this transient is never observed across an await.
200
+ */
201
+ private currentSkip;
179
202
  constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
180
203
  /** Quote an identifier through the active SQL dialect. */
181
204
  private q;
@@ -451,6 +474,21 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
451
474
  * Returns null if neither is provided (meaning all columns).
452
475
  */
453
476
  private resolveColumns;
477
+ /**
478
+ * Reject any write against a view (H4). Views are introspected with
479
+ * `isView: true` and are read-only in every engine; a write raises a
480
+ * {@link ValidationError} (E003) rather than emitting SQL Postgres would
481
+ * reject (or, worse, silently applying to an updatable view).
482
+ */
483
+ private assertWritable;
484
+ /**
485
+ * Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
486
+ * column (H3). Postgres computes these from other columns and errors if you
487
+ * try to write them; we fail early with a clear {@link ValidationError} (E003)
488
+ * instead of surfacing a cryptic driver error. Undefined values are ignored
489
+ * (they're stripped from the statement anyway).
490
+ */
491
+ private assertNoGeneratedColumns;
454
492
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
455
493
  private toColumn;
456
494
  /** Convert camelCase field name to a double-quoted SQL identifier */
@@ -496,7 +534,17 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
496
534
  * @internal Exposed as package-private for testing.
497
535
  */
498
536
  collectWhereParams(where: Record<string, unknown>, params: unknown[]): void;
499
- /** Collect params from a relation filter sub-where. Mirrors buildSubWhereForRelation. */
537
+ /**
538
+ * Param-collect mirror of {@link buildRelationFilter} for one relation-filter
539
+ * object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
540
+ * present branch and in the canonical order some→none→every→is→isNot, the
541
+ * branch's sub-where params THEN the target table's global-filter params —
542
+ * exactly the order buildRelationFilter emits. When no global filter applies
543
+ * the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
544
+ * Shared by every collect site that mirrors buildRelationFilter
545
+ * (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
546
+ */
547
+ private collectRelationFilterParams;
500
548
  private collectRelFilterParams;
501
549
  /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
502
550
  private collectOperatorParams;
@@ -545,13 +593,55 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
545
593
  /** Build WHERE clause from a where object (supports operators, NULL, OR) */
546
594
  private buildWhere;
547
595
  /**
548
- * Refuse mutations with an empty predicate unless explicitly opted in.
549
- *
550
- * An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
551
- * mutation with no filter — a common footgun when a caller's filter
552
- * value accidentally resolves to `undefined`. This guard throws
553
- * `ValidationError` in that case unless `allowFullTableScan: true`.
596
+ * Resolve the configured global filter for `table`, evaluating a function
597
+ * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
598
+ * no filter applies, the query opted out, or the filter is empty.
599
+ */
600
+ private resolveGlobalFilter;
601
+ /**
602
+ * AND-merge this table's resolved global filter into a user `where`. Either
603
+ * side may be absent. When no filter applies the user where is returned by
604
+ * reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
554
605
  */
606
+ private mergeGlobalFilter;
607
+ /**
608
+ * SQL clause for `targetTable`'s global filter rendered against `alias`
609
+ * (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
610
+ * `params`; returns `''` when no filter applies. Mirror:
611
+ * {@link collectTargetGlobalFilterAlias}.
612
+ */
613
+ private targetGlobalFilterAlias;
614
+ /** Param-collect mirror of {@link targetGlobalFilterAlias}. */
615
+ private collectTargetGlobalFilterAlias;
616
+ /**
617
+ * SQL clause for `targetTable`'s global filter rendered against the bare
618
+ * (unaliased) table name — the form used inside relation-filter `EXISTS`
619
+ * subqueries. Pushes its params; `''` when none. Mirror:
620
+ * {@link collectTargetGlobalFilterExists}.
621
+ */
622
+ private targetGlobalFilterExists;
623
+ /** Param-collect mirror of {@link targetGlobalFilterExists}. */
624
+ private collectTargetGlobalFilterExists;
625
+ /**
626
+ * Value-invariant SQL-cache-key segment for the active global-filter
627
+ * environment. Relation-subquery / relation-filter / `_count` / relation-
628
+ * `orderBy` global filters are rendered at build time but their SHAPE is not
629
+ * otherwise in the where/with fingerprint, so this segment guards the cache:
630
+ * two different filter shapes never collide on one cached SQL text, while two
631
+ * function-filter results of the SAME shape (differing only in values) share
632
+ * the entry and bind their own params. Empty (`''`) when no filter applies, so
633
+ * cache keys stay byte-identical when the feature is unused.
634
+ */
635
+ private globalFilterCacheSegment;
636
+ /**
637
+ * True when the USER-supplied `where` compiles to no predicate (`{}`,
638
+ * `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
639
+ * signal the empty-`where` guard needs — the compiled emptiness, NOT the
640
+ * fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
641
+ * any configured global filter, so a global filter never lets an unguarded
642
+ * mass mutation through.
643
+ */
644
+ private userPredicateIsEmpty;
555
645
  private assertMutationHasPredicate;
556
646
  /**
557
647
  * Build the inner WHERE expression (without the WHERE keyword).
@@ -619,7 +709,65 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
619
709
  * findMany path). When `params` is omitted (groupBy / relation path) a vector
620
710
  * ordering throws — KNN ordering is only supported at the top level.
621
711
  */
712
+ /**
713
+ * Value-shape fingerprint for a single orderBy entry, so two queries whose
714
+ * ORDER BY differs only in nulls placement, vector metric, or relation-count
715
+ * vs relation-column never collide on one cached SQL string. Captures the
716
+ * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
717
+ */
718
+ private orderByEntryFingerprint;
622
719
  private buildOrderBy;
720
+ /**
721
+ * True when an orderBy value is a relation-ordering object: a plain object
722
+ * that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
723
+ * in the orderBy clause is a relation name.
724
+ */
725
+ private isRelationOrderByValue;
726
+ /**
727
+ * Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
728
+ * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
729
+ * other engine a caller asking for explicit nulls placement gets a clear
730
+ * {@link UnsupportedFeatureError} (E017) instead of broken SQL.
731
+ */
732
+ private nullsSuffix;
733
+ /**
734
+ * Compile a relation ordering term. For a to-many relation the only allowed
735
+ * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
736
+ * to-one relation each entry names a target column and becomes a correlated
737
+ * scalar subquery (supporting {@link OrderBySpec} nulls placement).
738
+ *
739
+ * Validation: relation must exist (E005); to-many only allows `_count`, and
740
+ * to-one only allows real target columns (E003).
741
+ */
742
+ private buildRelationOrderBy;
743
+ /**
744
+ * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
745
+ * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
746
+ * manyToMany counts junction rows via the source key. Shared by the `_count`
747
+ * `with` key and to-many relation orderBy.
748
+ *
749
+ * When `params` is supplied and the target has a global filter, it is
750
+ * AND-merged so the count only sees surviving rows (a soft-deleted child is
751
+ * not counted): hasMany filters the counted rows directly; manyToMany adds an
752
+ * `EXISTS` on the target through the junction (the junction rows themselves
753
+ * carry no filter). Params are mirrored by {@link collectRelationCountParams}.
754
+ */
755
+ private buildRelationCountExpr;
756
+ /**
757
+ * `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
758
+ * a manyToMany `_count` to targets that survive their global filter. `''` when
759
+ * the target has no filter. Pushes gf params; mirror:
760
+ * {@link collectManyToManyTargetGlobalFilter}.
761
+ */
762
+ private manyToManyTargetGlobalFilterExists;
763
+ /** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
764
+ private collectManyToManyTargetGlobalFilter;
765
+ /**
766
+ * Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
767
+ * params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
768
+ * when a filter applies — no-op otherwise.
769
+ */
770
+ private collectRelationCountParams;
623
771
  /**
624
772
  * Resolve a {@link VectorMetric} to its pgvector distance operator from a
625
773
  * fixed allow-list, validating the target column is actually a `vector`