turbine-orm 0.65.0 → 0.66.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.
- package/README.md +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
package/dist/query/filters.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* and execution rather than filter-shape bookkeeping.
|
|
7
7
|
*/
|
|
8
8
|
import { ValidationError } from '../errors.js';
|
|
9
|
-
import {
|
|
9
|
+
import { assertDirectionToken, assertOrderDirection } from './types.js';
|
|
10
|
+
import { OPERATOR_KEYS, resolveColumnName } from './utils.js';
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Where-operator detection
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
@@ -42,6 +43,47 @@ export function isUnmatchedPlainObject(value) {
|
|
|
42
43
|
* take values only.
|
|
43
44
|
*/
|
|
44
45
|
export const COLUMN_REF_OPERATORS = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte']);
|
|
46
|
+
/**
|
|
47
|
+
* THE relation-filter wrappers: the keys whose body is a clause against the
|
|
48
|
+
* relation's TARGET table rather than against this one.
|
|
49
|
+
*
|
|
50
|
+
* One named list, here, because everything that walks a where clause has to
|
|
51
|
+
* agree about them and the copies had already spread. It lived inlined in
|
|
52
|
+
* `where-compile.ts` (`'some' in x || 'every' in x || …`, the SQL compiler's
|
|
53
|
+
* own answer), again in `normalizeRelationFilter` (where.ts) as the negated
|
|
54
|
+
* conjunction of the same five, again in `cli/pii-predicate-guard.ts`, and a
|
|
55
|
+
* fourth time in `prisma-compat.ts` SPLIT across a
|
|
56
|
+
* `RELATION_QUANTIFIERS` set (`some`/`every`/`none`) plus two inline
|
|
57
|
+
* `k === 'is' || k === 'isNot'` tests, which is the copy most likely to drift
|
|
58
|
+
* because half of it does not read as a list and a grep for the list does not
|
|
59
|
+
* find it.
|
|
60
|
+
*
|
|
61
|
+
* The failure mode is not cosmetic: a wrapper the SQL compiler treats as a
|
|
62
|
+
* relation filter but a WALKER does not is a wrapper whose body reaches the
|
|
63
|
+
* builder unwalked. That is precisely the operand-position channel this
|
|
64
|
+
* release closed elsewhere.
|
|
65
|
+
*
|
|
66
|
+
* `query/` is the right home rather than `cli/` because the direction of the
|
|
67
|
+
* dependency is fixed: `cli/` and the prisma-compat shim may import from the
|
|
68
|
+
* query path, and the query path may never import from `cli/`
|
|
69
|
+
* (`scripts/check-import-cycles.mjs`).
|
|
70
|
+
*/
|
|
71
|
+
export const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
|
|
72
|
+
/** {@link RELATION_FILTER_WRAPPERS} as a membership set, for the walkers. */
|
|
73
|
+
export const RELATION_FILTER_WRAPPER_SET = new Set(RELATION_FILTER_WRAPPERS);
|
|
74
|
+
/**
|
|
75
|
+
* True when a normalized relation-filter body carries at least one cardinality
|
|
76
|
+
* wrapper. THE predicate the SQL compiler branches on: a key that names a
|
|
77
|
+
* relation but whose value is not one of these falls through to the scalar
|
|
78
|
+
* path.
|
|
79
|
+
*/
|
|
80
|
+
export function hasRelationFilterWrapper(filterObj) {
|
|
81
|
+
for (const wrapper of RELATION_FILTER_WRAPPERS) {
|
|
82
|
+
if (wrapper in filterObj)
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
45
87
|
/**
|
|
46
88
|
* Check if an operator value is a column reference: a plain object whose ONLY
|
|
47
89
|
* key is `col` with a string value. Anything else (extra keys, non-string
|
|
@@ -417,6 +459,209 @@ export function orderByEntries(orderBy) {
|
|
|
417
459
|
}
|
|
418
460
|
return [];
|
|
419
461
|
}
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// ORDER BY key identity and arity
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
/**
|
|
466
|
+
* The longest `orderBy` whose statement keeps a NAMED prepared statement.
|
|
467
|
+
* Past this, the compile paths call `markVariableArity` and the statement is
|
|
468
|
+
* sent unnamed for the same reason a caller-written `OR` array is.
|
|
469
|
+
*
|
|
470
|
+
* WHY 3, and why a threshold at all. Refusing duplicate sort keys
|
|
471
|
+
* ({@link dedupeOrderEntries}) bounds an `orderBy` to the table's column
|
|
472
|
+
* count, which sounds like enough and is not: what is left is the PERMUTATION
|
|
473
|
+
* space. A 20-column table admits 20 one-key sorts, 380 two-key sorts, 6,840
|
|
474
|
+
* three-key sorts and 116,280 four-key sorts, and every one of those is a
|
|
475
|
+
* distinct SQL text and therefore a distinct un-reclaimable server-side
|
|
476
|
+
* prepared statement. The count is dominated by its longest term, so a cap on
|
|
477
|
+
* LENGTH is what actually bounds the total.
|
|
478
|
+
*
|
|
479
|
+
* 3 because that is where real sorts stop and generated ones start. The two
|
|
480
|
+
* shapes that show up in application code are a single sort key, and a sort key
|
|
481
|
+
* plus a tiebreaker for stable pagination (`[{createdAt:'desc'},{id:'asc'}]`).
|
|
482
|
+
* Three covers the widest genuinely hand-written form, a category or priority
|
|
483
|
+
* followed by recency followed by a primary-key tiebreak. A fourth key is
|
|
484
|
+
* effectively always a UI that lets the caller stack sort columns, which is
|
|
485
|
+
* exactly the shape that should not be minting named statements. The asymmetry
|
|
486
|
+
* of the two errors also points this way: naming a 4-key sort risks the 116,280
|
|
487
|
+
* above, while NOT naming one costs a single extra server-side parse per
|
|
488
|
+
* execution on a query that is already rare.
|
|
489
|
+
*/
|
|
490
|
+
export const MAX_NAMED_ORDER_KEYS = 3;
|
|
491
|
+
/**
|
|
492
|
+
* Canonical identity of ONE order term's sort EXPRESSION, or `null` when this
|
|
493
|
+
* rule declines to decide for that shape.
|
|
494
|
+
*
|
|
495
|
+
* DIRECTION IS DELIBERATELY EXCLUDED. `ORDER BY id ASC, id DESC` sorts exactly
|
|
496
|
+
* like `ORDER BY id ASC`: the first term already totally orders the rows it
|
|
497
|
+
* covers, so no later term on the same expression can move anything, whichever
|
|
498
|
+
* way it points. Including the direction would have missed the very case that
|
|
499
|
+
* motivated this, a pair of entries spelled differently AND pointing
|
|
500
|
+
* differently.
|
|
501
|
+
*
|
|
502
|
+
* The column is RESOLVED rather than compared as spelled, via the same
|
|
503
|
+
* `resolveColumnName` every SQL builder resolves keys through, so `userId` and
|
|
504
|
+
* `user_id` on an introspected schema are recognised as the one column they
|
|
505
|
+
* both compile to. Falls back to the raw key when the key resolves to nothing
|
|
506
|
+
* (metadata-less test schemas): the compile path a few lines later is what
|
|
507
|
+
* reports an unknown field, and this function must not pre-empt that error with
|
|
508
|
+
* a worse one.
|
|
509
|
+
*
|
|
510
|
+
* `null` (not compared) for two shapes:
|
|
511
|
+
* - VECTOR KNN ordering. Two distance terms on one column with DIFFERENT
|
|
512
|
+
* target vectors are genuinely different expressions and the second is not a
|
|
513
|
+
* no-op, so deciding would mean hashing the operand, which is a 1,536-float
|
|
514
|
+
* array on a typical embedding column. Not worth the per-build cost for a
|
|
515
|
+
* shape that {@link MAX_NAMED_ORDER_KEYS} already bounds.
|
|
516
|
+
* - RELATION ordering (`{posts:{_count:'desc'}}`, `{author:{name:'asc'}}`,
|
|
517
|
+
* pick-row). Same reason in a different key: the expression is a correlated
|
|
518
|
+
* subquery whose identity is its whole nested shape.
|
|
519
|
+
* Both are bounded by the length cap; neither can be refused wrongly here.
|
|
520
|
+
*/
|
|
521
|
+
export function orderKeyIdentity(meta, key, value) {
|
|
522
|
+
if (isVectorOrderBy(value))
|
|
523
|
+
return null;
|
|
524
|
+
if (isJsonPathOrderBy(value)) {
|
|
525
|
+
// Same column AND same path AND same cast is one expression; a different
|
|
526
|
+
// path or a numeric-vs-text cast is a different one, and both are ordinary
|
|
527
|
+
// in a multi-key sort over a document column.
|
|
528
|
+
const column = resolvedOrderColumn(meta, key);
|
|
529
|
+
return `json:${column}:${value.type ?? ''}:${JSON.stringify(value.path)}`;
|
|
530
|
+
}
|
|
531
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value) && !isOrderBySpec(value)) {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
return `col:${resolvedOrderColumn(meta, key)}`;
|
|
535
|
+
}
|
|
536
|
+
/** {@link resolveColumnName} with the raw key as the unresolvable fallback. */
|
|
537
|
+
function resolvedOrderColumn(meta, key) {
|
|
538
|
+
return (meta ? resolveColumnName(meta, key) : undefined) ?? key;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Remove `orderBy` entries that sort by an expression an earlier entry already
|
|
542
|
+
* sorted by. Returns `null` when nothing is redundant, which is the common case
|
|
543
|
+
* and the one that must stay allocation-light and byte-identical.
|
|
544
|
+
*
|
|
545
|
+
* WHY THIS DROPS RATHER THAN REFUSES, because the reverse was specified and the
|
|
546
|
+
* premise turned out not to hold. A repeated sort key is a no-op in SQL, and it
|
|
547
|
+
* is the only way to push an `orderBy` past the table's column count (measured:
|
|
548
|
+
* `[{id:'asc'}] x n` produced a distinct named prepared statement for every n,
|
|
549
|
+
* from ONE column, with no ceiling short of the request body size). Both of
|
|
550
|
+
* those are true. What is NOT true is that it is always a caller bug. The
|
|
551
|
+
* idiomatic stable-pagination shape is
|
|
552
|
+
*
|
|
553
|
+
* orderBy: [{ [sortField]: sortDir }, { id: 'asc' }]
|
|
554
|
+
*
|
|
555
|
+
* appending a primary-key tiebreak unconditionally, which is correct defensive
|
|
556
|
+
* code, and which produces a duplicate exactly when the caller sorts by the
|
|
557
|
+
* primary key. That is a column header a user clicks, so refusing would turn a
|
|
558
|
+
* working table into a 500 on one column and no others. This repo's own
|
|
559
|
+
* differential fuzz generator writes that pattern, comment included ("a random
|
|
560
|
+
* key first, the PK as final tiebreaker"), and picks the sort field from a pool
|
|
561
|
+
* that contains `id`, so a refusal would have failed the suite on 10-35% of
|
|
562
|
+
* generated cases depending on table width.
|
|
563
|
+
*
|
|
564
|
+
* Dropping has no such failure mode: the removed term provably cannot move a
|
|
565
|
+
* row, so the result set and its order are unchanged, and the caller is told
|
|
566
|
+
* about the redundancy through a dev-only warn-once instead of an exception.
|
|
567
|
+
* The security outcome is identical, because what actually bounds the emitted
|
|
568
|
+
* statement set is the length cap ({@link MAX_NAMED_ORDER_KEYS}), which is
|
|
569
|
+
* shape-blind and applies to repeats and permutations alike. This function's
|
|
570
|
+
* job is narrower: keep the ordinary two-key sort under that cap so it keeps
|
|
571
|
+
* its named statement.
|
|
572
|
+
*
|
|
573
|
+
* A DROPPED TERM IS STILL VALIDATED, see {@link assertDroppedDirection}. The
|
|
574
|
+
* drop is an optimization, and an optimization must not decide whether an input
|
|
575
|
+
* is legal.
|
|
576
|
+
*
|
|
577
|
+
* @param table the table the sort is against, so a refused direction reads
|
|
578
|
+
* exactly as it does when the compile path raises it. Defaults to the
|
|
579
|
+
* metadata's own name, which is what a caller holding only a `TableMetadata`
|
|
580
|
+
* would have passed anyway.
|
|
581
|
+
*/
|
|
582
|
+
export function dedupeOrderEntries(meta, entries, table = meta?.name) {
|
|
583
|
+
if (entries.length < 2)
|
|
584
|
+
return null;
|
|
585
|
+
const seen = new Map();
|
|
586
|
+
let dropped;
|
|
587
|
+
const kept = [];
|
|
588
|
+
for (const entry of entries) {
|
|
589
|
+
const identity = orderKeyIdentity(meta, entry[0], entry[1]);
|
|
590
|
+
const first = identity === null ? undefined : seen.get(identity);
|
|
591
|
+
if (first !== undefined) {
|
|
592
|
+
assertDroppedDirection(entry[0], entry[1], table);
|
|
593
|
+
// Resolved only on the dropping branch, so the advisory can name the one
|
|
594
|
+
// column two different spellings landed on without costing the hot path.
|
|
595
|
+
if (dropped === undefined)
|
|
596
|
+
dropped = [];
|
|
597
|
+
dropped.push({ key: entry[0], first, resolved: resolvedOrderColumn(meta, entry[0]) });
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (identity !== null)
|
|
601
|
+
seen.set(identity, entry[0]);
|
|
602
|
+
kept.push(entry);
|
|
603
|
+
}
|
|
604
|
+
return dropped ? { entries: kept, dropped } : null;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Validate the direction of a term this function is about to DROP.
|
|
608
|
+
*
|
|
609
|
+
* Without this the drop swallowed the check, because the direction guard lives
|
|
610
|
+
* on the COMPILE path (`buildOrderBy`) and a dropped term never reaches it:
|
|
611
|
+
* `orderBy: [{id:'asc'}, {id:'sideways'}]` was silently accepted while
|
|
612
|
+
* `orderBy: [{id:'sideways'}]` alone raised E003, and `groupBy` (which
|
|
613
|
+
* validates BEFORE its own dedupe) still refused the identical input, so the
|
|
614
|
+
* two surfaces disagreed about whether a query was valid. Whether a bad
|
|
615
|
+
* direction is reported must not depend on whether some earlier term happened
|
|
616
|
+
* to name the same column.
|
|
617
|
+
*
|
|
618
|
+
* Only two shapes can reach here, which is what makes the branch exhaustive:
|
|
619
|
+
* {@link orderKeyIdentity} returns `null` (never deduped, never dropped) for
|
|
620
|
+
* the vector and relation shapes, leaving the JSON-path form and the plain
|
|
621
|
+
* column / OrderBySpec form. Each is checked with exactly the guard
|
|
622
|
+
* `buildOrderBy` (relations.ts) would have applied to it, message included, so
|
|
623
|
+
* a dropped term is accepted or refused identically to a kept one.
|
|
624
|
+
*/
|
|
625
|
+
function assertDroppedDirection(key, value, table) {
|
|
626
|
+
if (isJsonPathOrderBy(value)) {
|
|
627
|
+
assertDirectionToken(value.direction, `JSON-path orderBy on "${key}"`);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
assertOrderDirection(value, table === undefined ? `orderBy "${key}"` : `orderBy "${key}" on table "${table}"`);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* The {@link dedupeOrderEntries} rule applied to a `DISTINCT ON` column list,
|
|
634
|
+
* the other caller-supplied list written into the SQL one term per element.
|
|
635
|
+
* `DISTINCT ON (a, a)` groups exactly as `DISTINCT ON (a)` does, so a repeat is
|
|
636
|
+
* a no-op here too, and the same generated-code shape produces it (a grouping
|
|
637
|
+
* column chosen by the caller, plus a fixed one appended by the code).
|
|
638
|
+
*
|
|
639
|
+
* No length cap to go with it, deliberately. Unlike `orderBy`, `DISTINCT ON`
|
|
640
|
+
* has a meaning that degrades as the list grows (every added column makes the
|
|
641
|
+
* result strictly less distinct, converging on the plain query), so a long list
|
|
642
|
+
* is self-limiting in a way a long sort is not.
|
|
643
|
+
*/
|
|
644
|
+
export function dedupeColumnList(meta, columns) {
|
|
645
|
+
if (columns.length < 2)
|
|
646
|
+
return null;
|
|
647
|
+
const seen = new Map();
|
|
648
|
+
let dropped;
|
|
649
|
+
const kept = [];
|
|
650
|
+
for (const key of columns) {
|
|
651
|
+
const column = typeof key === 'string' ? resolvedOrderColumn(meta, key) : null;
|
|
652
|
+
const first = column === null ? undefined : seen.get(column);
|
|
653
|
+
if (first !== undefined && column !== null) {
|
|
654
|
+
if (dropped === undefined)
|
|
655
|
+
dropped = [];
|
|
656
|
+
dropped.push({ key, first, resolved: column });
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (column !== null)
|
|
660
|
+
seen.set(column, key);
|
|
661
|
+
kept.push(key);
|
|
662
|
+
}
|
|
663
|
+
return dropped ? { columns: kept, dropped } : null;
|
|
664
|
+
}
|
|
420
665
|
/**
|
|
421
666
|
* Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
|
|
422
667
|
* direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
|
|
@@ -269,19 +269,19 @@ export declare function collectManyToManyTargetGlobalFilter(qi: BuilderCtx, relD
|
|
|
269
269
|
* when a filter applies, no-op otherwise.
|
|
270
270
|
*/
|
|
271
271
|
export declare function collectRelationCountParams(qi: BuilderCtx, relDef: RelationDef, params: unknown[]): void;
|
|
272
|
-
export declare function getCamelDateFields(qi: BuilderCtx, table: string, meta: TableMetadata): Set<string>;
|
|
273
272
|
/**
|
|
274
|
-
*
|
|
273
|
+
* Per-table memo of `[relationName, relDef]` pairs, alongside the existing
|
|
274
|
+
* camelCase-date memo and for the same reason.
|
|
275
275
|
*
|
|
276
|
-
*
|
|
277
|
-
* (
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* hand this function driver rows, but any relation still nested INSIDE one of
|
|
283
|
-
* those rows arrived as a correlated JSON subquery.
|
|
276
|
+
* {@link parseNestedRow} walks every relation on the table for EVERY row, and
|
|
277
|
+
* `Object.entries(meta.relations)` allocates a fresh array of fresh two-element
|
|
278
|
+
* arrays each time it does. Measured at 227 ns per row on a 12-relation table
|
|
279
|
+
* against 2 ns for a hoisted list, in the single largest ORM function in the
|
|
280
|
+
* live CPU profile. The relation map is immutable metadata, so one array per
|
|
281
|
+
* table is all that is ever needed.
|
|
284
282
|
*/
|
|
283
|
+
export declare function getRelationEntries(qi: BuilderCtx, table: string, meta: TableMetadata): [string, RelationDef][];
|
|
284
|
+
export declare function getCamelDateFields(qi: BuilderCtx, table: string, meta: TableMetadata): Set<string>;
|
|
285
285
|
export declare function parseNestedRow(qi: BuilderCtx, row: Record<string, unknown>, table: string, fromJson?: boolean): Record<string, unknown>;
|
|
286
286
|
/**
|
|
287
287
|
* Resolve the emitted column list for a relation, honoring `select` / `omit`.
|
package/dist/query/relations.js
CHANGED
|
@@ -16,9 +16,9 @@ import { CircularRelationError, RelationError, UnsupportedFeatureError, Validati
|
|
|
16
16
|
import { missingIndexForRelation } from '../index-advisor.js';
|
|
17
17
|
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
18
18
|
import { resolveCountRelations } from './batched-loader.js';
|
|
19
|
-
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
|
|
19
|
+
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, MAX_NAMED_ORDER_KEYS, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
|
|
20
20
|
import { assertDirectionToken, assertOrderDirection } from './types.js';
|
|
21
|
-
import { ownLookup, relationInProjectionMessage, resolveColumnName, selectNamesNothingMessage, selectOmitExclusiveMessage, unknownFieldMessage, } from './utils.js';
|
|
21
|
+
import { canonicalColumnOrder, ownLookup, relationInProjectionMessage, resolveColumnName, selectNamesNothingMessage, selectOmitExclusiveMessage, unknownFieldMessage, } from './utils.js';
|
|
22
22
|
import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
|
|
23
23
|
import * as whereMod from './where.js';
|
|
24
24
|
import * as writesMod from './writes.js';
|
|
@@ -114,9 +114,36 @@ export function resolveProjection(qi, table, meta, select, omit, includePii) {
|
|
|
114
114
|
}
|
|
115
115
|
// Only include columns where value is true. An explicit `select` naming a
|
|
116
116
|
// PII column IS the opt-in: it comes back regardless of `includePii`.
|
|
117
|
-
|
|
117
|
+
//
|
|
118
|
+
// Emitted in the TABLE's column order, not the caller's key order, which is
|
|
119
|
+
// the same order the `omit` and default branches below produce. Two reasons,
|
|
120
|
+
// and the first is a security bound:
|
|
121
|
+
//
|
|
122
|
+
// 1. The SELECT list is SQL TEXT, so a caller who reorders their `select`
|
|
123
|
+
// keys mints a distinct, permanently-cached, server-side prepared
|
|
124
|
+
// statement per permutation (measured: 5,040 statements and 39 MB of
|
|
125
|
+
// CachedPlanSource from ONE seven-column table). That is the same
|
|
126
|
+
// unbounded-statement failure the `markVariableArity` rule bounds for
|
|
127
|
+
// caller-chosen ARITY, reached through caller-chosen ORDER instead, which
|
|
128
|
+
// needs no array and no unusual input. `omit` never had it precisely
|
|
129
|
+
// because it derives from `allColumns`. See `canonicalColumnOrder`.
|
|
130
|
+
// 2. It removes a real lockstep hazard. `withFingerprint` already SORTS a
|
|
131
|
+
// relation's `select` keys, so the cache key was permutation-invariant
|
|
132
|
+
// while the SQL was not: the second permutation was served the first
|
|
133
|
+
// one's statement. Harmless for the keyed JSON encoding, NOT harmless for
|
|
134
|
+
// `jsonEncoding: 'positional'`, where the emitted array order comes from
|
|
135
|
+
// the cached SQL and the decoder (`buildRelationShape`) is rebuilt from
|
|
136
|
+
// the current args, so values landed under the wrong keys.
|
|
137
|
+
//
|
|
138
|
+
// Order is safe to change here because nothing downstream reads the
|
|
139
|
+
// projection positionally except through THIS list: the SELECT list, the
|
|
140
|
+
// relation `json_build_object` pairs and the positional `RelationShape`
|
|
141
|
+
// keys are all derived from it, so they move together. The one observable
|
|
142
|
+
// consequence is that a projected row's KEY INSERTION order now follows the
|
|
143
|
+
// table rather than the `select` literal.
|
|
144
|
+
return canonicalColumnOrder(meta, Object.entries(select)
|
|
118
145
|
.filter(([, v]) => v)
|
|
119
|
-
.map(([k]) => projectionColumn(table, meta, k, 'select'));
|
|
146
|
+
.map(([k]) => projectionColumn(table, meta, k, 'select')));
|
|
120
147
|
}
|
|
121
148
|
// Default / omit-only projection: PII-tagged columns are excluded unless the
|
|
122
149
|
// caller opted in with `includePii: UNSAFE`. An empty set (untagged schema) keeps the
|
|
@@ -421,8 +448,17 @@ export function buildOrderBy(qi, orderBy, params, lateralSink) {
|
|
|
421
448
|
// rules in step, so it is gone. See orderby-unknown-field.test.ts, which pins
|
|
422
449
|
// the refusal itself across that surface.
|
|
423
450
|
const meta = qi.schema.tables[qi.table];
|
|
451
|
+
const entries = orderByEntries(orderBy);
|
|
452
|
+
// A long ORDER BY writes one comma-separated term per entry into the SQL
|
|
453
|
+
// TEXT, so its length is an arity the caller chose and the statement gives up
|
|
454
|
+
// its server-side name. Marked HERE, on the compile path, because that is the
|
|
455
|
+
// window `buildCacheEntry` brackets; redundant terms have already been
|
|
456
|
+
// dropped upstream (buildFindMany), so this counts real ones. See
|
|
457
|
+
// MAX_NAMED_ORDER_KEYS in filters.ts for why the threshold is where it is.
|
|
458
|
+
if (entries.length > MAX_NAMED_ORDER_KEYS)
|
|
459
|
+
qi.markVariableArity();
|
|
424
460
|
let relOrdCounter = 0;
|
|
425
|
-
return
|
|
461
|
+
return entries
|
|
426
462
|
.map(([key, value]) => {
|
|
427
463
|
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
428
464
|
if (isVectorOrderBy(value)) {
|
|
@@ -966,6 +1002,16 @@ export function collectRelationPickOrderParams(qi, relName, relDef, spec, params
|
|
|
966
1002
|
* in the same order, by {@link collectRelationOrderParams}.
|
|
967
1003
|
*/
|
|
968
1004
|
export function buildRelationOrderClause(qi, targetTable, targetMeta, alias, orderEntries, params) {
|
|
1005
|
+
// Same length cap as the top-level `buildOrderBy`. A relation's orderBy comes
|
|
1006
|
+
// from the same request body and its terms are written into the same
|
|
1007
|
+
// statement, so it carries the same exposure. This is the shared choke point
|
|
1008
|
+
// for both to-many shapes (the wrapped hasMany subquery and the m2m junction
|
|
1009
|
+
// subquery). Redundant-term dropping is deliberately NOT mirrored here: it
|
|
1010
|
+
// would have to happen before the `with` fingerprint to stay in lockstep with
|
|
1011
|
+
// the collect path, and a no-op term inside a relation costs only itself
|
|
1012
|
+
// while the cap already bounds what matters.
|
|
1013
|
+
if (orderEntries.length > MAX_NAMED_ORDER_KEYS)
|
|
1014
|
+
qi.markVariableArity();
|
|
969
1015
|
let relOrdCounter = 0;
|
|
970
1016
|
const orders = orderEntries
|
|
971
1017
|
.map(([key, dirValue]) => {
|
|
@@ -1113,6 +1159,25 @@ export function collectRelationCountParams(qi, relDef, params) {
|
|
|
1113
1159
|
whereMod.collectTargetGlobalFilterAlias(qi, relDef.to, params);
|
|
1114
1160
|
}
|
|
1115
1161
|
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Per-table memo of `[relationName, relDef]` pairs, alongside the existing
|
|
1164
|
+
* camelCase-date memo and for the same reason.
|
|
1165
|
+
*
|
|
1166
|
+
* {@link parseNestedRow} walks every relation on the table for EVERY row, and
|
|
1167
|
+
* `Object.entries(meta.relations)` allocates a fresh array of fresh two-element
|
|
1168
|
+
* arrays each time it does. Measured at 227 ns per row on a 12-relation table
|
|
1169
|
+
* against 2 ns for a hoisted list, in the single largest ORM function in the
|
|
1170
|
+
* live CPU profile. The relation map is immutable metadata, so one array per
|
|
1171
|
+
* table is all that is ever needed.
|
|
1172
|
+
*/
|
|
1173
|
+
export function getRelationEntries(qi, table, meta) {
|
|
1174
|
+
let entries = qi.relationEntryCache.get(table);
|
|
1175
|
+
if (!entries) {
|
|
1176
|
+
entries = Object.entries(meta.relations);
|
|
1177
|
+
qi.relationEntryCache.set(table, entries);
|
|
1178
|
+
}
|
|
1179
|
+
return entries;
|
|
1180
|
+
}
|
|
1116
1181
|
export function getCamelDateFields(qi, table, meta) {
|
|
1117
1182
|
let camel = qi.camelDateFieldCache.get(table);
|
|
1118
1183
|
if (!camel) {
|
|
@@ -1136,6 +1201,9 @@ export function getCamelDateFields(qi, table, meta) {
|
|
|
1136
1201
|
* hand this function driver rows, but any relation still nested INSIDE one of
|
|
1137
1202
|
* those rows arrived as a correlated JSON subquery.
|
|
1138
1203
|
*/
|
|
1204
|
+
/** The reserved relation-`_count` column prefix, and its first char code. */
|
|
1205
|
+
const COUNT_PREFIX = '_count__';
|
|
1206
|
+
const UNDERSCORE_CHAR = 95;
|
|
1139
1207
|
export function parseNestedRow(qi, row, table, fromJson = false) {
|
|
1140
1208
|
const meta = qi.schema.tables[table];
|
|
1141
1209
|
if (!meta)
|
|
@@ -1143,18 +1211,30 @@ export function parseNestedRow(qi, row, table, fromJson = false) {
|
|
|
1143
1211
|
const parsed = qi.parseRow(fromJson ? decodeJsonWireRow(qi, row, table, meta) : row, table);
|
|
1144
1212
|
// Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
|
|
1145
1213
|
// parseRow copies these unknown columns through under their raw key.
|
|
1214
|
+
//
|
|
1215
|
+
// `for…in` with a first-character reject, rather than `Object.keys(...)`
|
|
1216
|
+
// plus `startsWith`: the old form allocated a fresh key array for EVERY row
|
|
1217
|
+
// of every query purely to discover that nothing started with `_count__`,
|
|
1218
|
+
// which is the case for every query that did not ask for a relation `_count`
|
|
1219
|
+
// (almost all of them). `for…in` allocates nothing, and `_` is a rare first
|
|
1220
|
+
// character on a real field, so the common row costs one charCode compare
|
|
1221
|
+
// per key. Deletion is deferred out of the loop so the scan stays on V8's
|
|
1222
|
+
// enum-cache fast path; the resulting key order is unchanged (the same keys
|
|
1223
|
+
// are removed, and `_count` is appended last either way).
|
|
1146
1224
|
let countObj;
|
|
1147
|
-
for (const key
|
|
1148
|
-
if (key.startsWith(
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
countObj
|
|
1152
|
-
|
|
1153
|
-
}
|
|
1225
|
+
for (const key in parsed) {
|
|
1226
|
+
if (key.charCodeAt(0) !== UNDERSCORE_CHAR || !key.startsWith(COUNT_PREFIX))
|
|
1227
|
+
continue;
|
|
1228
|
+
if (countObj === undefined)
|
|
1229
|
+
countObj = {};
|
|
1230
|
+
countObj[key.slice(COUNT_PREFIX.length)] = Number(parsed[key]);
|
|
1154
1231
|
}
|
|
1155
|
-
if (countObj)
|
|
1232
|
+
if (countObj) {
|
|
1233
|
+
for (const rel of Object.keys(countObj))
|
|
1234
|
+
delete parsed[`${COUNT_PREFIX}${rel}`];
|
|
1156
1235
|
parsed._count = countObj;
|
|
1157
|
-
|
|
1236
|
+
}
|
|
1237
|
+
for (const [relName, relDef] of getRelationEntries(qi, table, meta)) {
|
|
1158
1238
|
const rawValue = row[relName];
|
|
1159
1239
|
if (rawValue === undefined)
|
|
1160
1240
|
continue;
|
package/dist/query/types.d.ts
CHANGED
|
@@ -405,7 +405,20 @@ export type TypedOrderByClause<T, R extends object> = TypedOrderByObject<T, R> |
|
|
|
405
405
|
export type WithFieldFlags<NestedT> = [unknown] extends [NestedT] ? Record<string, boolean> : {
|
|
406
406
|
[K in keyof NestedT]?: boolean;
|
|
407
407
|
};
|
|
408
|
-
|
|
408
|
+
/**
|
|
409
|
+
* `Record<never, never>` rather than `{}` for the "no nested relations known"
|
|
410
|
+
* default, and the difference is load-bearing rather than cosmetic: the degrade
|
|
411
|
+
* branches in {@link TypedWithClause} and {@link TypedOrderByObject} test
|
|
412
|
+
* `[keyof R] extends [never]`, so the default has to be a type whose `keyof` IS
|
|
413
|
+
* `never`. `object` (biome's first suggestion) has `keyof object = never` too
|
|
414
|
+
* but admits any object, and `Record<string, never>` has `keyof = string`,
|
|
415
|
+
* which would silently flip every untyped `with` clause off the open escape
|
|
416
|
+
* hatch and onto key checking against no keys at all, i.e. reject everything. A
|
|
417
|
+
* mapped type over `never` resolves to exactly the empty object type, so this
|
|
418
|
+
* is the same type `{}` was, spelled in a way that says which property of it
|
|
419
|
+
* matters.
|
|
420
|
+
*/
|
|
421
|
+
export interface WithOptions<NestedR extends object = Record<never, never>, NestedT = unknown> {
|
|
409
422
|
with?: TypedWithClause<NestedR>;
|
|
410
423
|
/** Filter the related rows. Keys are checked against the relation target when it is known (see {@link WithWhere}). */
|
|
411
424
|
where?: WithWhere<NestedT, NestedR>;
|