turbine-orm 0.77.1 → 0.78.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 +13 -9
- package/dist/cjs/cli/config.d.ts +7 -1
- package/dist/cjs/cli/config.js +11 -2
- package/dist/cjs/cli/destructive.d.ts +1 -1
- package/dist/cjs/cli/destructive.js +307 -9
- package/dist/cjs/cli/index.js +252 -42
- package/dist/cjs/cli/mcp.d.ts +23 -0
- package/dist/cjs/cli/mcp.js +190 -152
- package/dist/cjs/cli/migrate.d.ts +243 -3
- package/dist/cjs/cli/migrate.js +432 -43
- package/dist/cjs/cli/sql-statements.js +27 -0
- package/dist/cjs/cli/studio.d.ts +0 -1
- package/dist/cjs/cli/studio.js +9 -7
- package/dist/cjs/client.d.ts +8 -1
- package/dist/cjs/client.js +7 -0
- package/dist/cjs/connection-url.d.ts +82 -0
- package/dist/cjs/connection-url.js +187 -1
- package/dist/cjs/errors.d.ts +112 -12
- package/dist/cjs/errors.js +558 -114
- package/dist/cjs/generate.js +47 -15
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/introspect.d.ts +33 -0
- package/dist/cjs/introspect.js +54 -1
- package/dist/cjs/mssql.js +21 -1
- package/dist/cjs/nested-write.js +85 -14
- package/dist/cjs/pipeline-submittable.d.ts +2 -0
- package/dist/cjs/pipeline-submittable.js +88 -3
- package/dist/cjs/pipeline.js +13 -1
- package/dist/cjs/powdb-introspect.d.ts +5 -1
- package/dist/cjs/powdb-introspect.js +5 -1
- package/dist/cjs/powql.d.ts +14 -0
- package/dist/cjs/powql.js +44 -4
- package/dist/cjs/prisma-compat.js +95 -8
- package/dist/cjs/query/aggregates.js +56 -6
- package/dist/cjs/query/builder.d.ts +76 -13
- package/dist/cjs/query/builder.js +188 -58
- package/dist/cjs/query/compound-unique.d.ts +76 -0
- package/dist/cjs/query/compound-unique.js +129 -0
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/types.d.ts +65 -11
- package/dist/cjs/query/where.d.ts +85 -19
- package/dist/cjs/query/where.js +262 -47
- package/dist/cjs/query/writes.d.ts +11 -2
- package/dist/cjs/query/writes.js +116 -21
- package/dist/cjs/seed.d.ts +16 -0
- package/dist/cjs/seed.js +16 -0
- package/dist/cli/config.d.ts +7 -1
- package/dist/cli/config.js +11 -2
- package/dist/cli/destructive.d.ts +1 -1
- package/dist/cli/destructive.js +307 -9
- package/dist/cli/index.js +254 -44
- package/dist/cli/mcp.d.ts +23 -0
- package/dist/cli/mcp.js +187 -150
- package/dist/cli/migrate.d.ts +243 -3
- package/dist/cli/migrate.js +423 -45
- package/dist/cli/sql-statements.js +27 -0
- package/dist/cli/studio.d.ts +0 -1
- package/dist/cli/studio.js +10 -7
- package/dist/client.d.ts +8 -1
- package/dist/client.js +7 -0
- package/dist/connection-url.d.ts +82 -0
- package/dist/connection-url.js +183 -0
- package/dist/errors.d.ts +112 -12
- package/dist/errors.js +558 -114
- package/dist/generate.js +47 -15
- package/dist/index.d.ts +1 -1
- package/dist/introspect.d.ts +33 -0
- package/dist/introspect.js +53 -1
- package/dist/mssql.js +21 -1
- package/dist/nested-write.js +85 -14
- package/dist/pipeline-submittable.d.ts +2 -0
- package/dist/pipeline-submittable.js +87 -3
- package/dist/pipeline.js +14 -2
- package/dist/powdb-introspect.d.ts +5 -1
- package/dist/powdb-introspect.js +5 -1
- package/dist/powql.d.ts +14 -0
- package/dist/powql.js +45 -5
- package/dist/prisma-compat.js +96 -9
- package/dist/query/aggregates.js +56 -6
- package/dist/query/builder.d.ts +76 -13
- package/dist/query/builder.js +188 -58
- package/dist/query/compound-unique.d.ts +76 -0
- package/dist/query/compound-unique.js +126 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +65 -11
- package/dist/query/where.d.ts +85 -19
- package/dist/query/where.js +260 -47
- package/dist/query/writes.d.ts +11 -2
- package/dist/query/writes.js +117 -22
- package/dist/seed.d.ts +16 -0
- package/dist/seed.js +16 -0
- package/package.json +3 -3
- package/skills/turbine-orm/SKILL.md +37 -10
package/dist/query/where.js
CHANGED
|
@@ -230,7 +230,8 @@ export function collectRelationFilterParams(qi, relDef, filterObj, params, depth
|
|
|
230
230
|
if (filterObj.every !== undefined && filterObj.every !== null) {
|
|
231
231
|
// gf is only emitted (build) when the `every` sub-where compiles to a
|
|
232
232
|
// filter, otherwise `every` is trivially true and no subquery is built.
|
|
233
|
-
if (buildSubWhereForRelation(qi, target, filterObj.every, [], depth + 1) !==
|
|
233
|
+
if (buildSubWhereForRelation(qi, target, qi.q(target), filterObj.every, [], depth + 1) !==
|
|
234
|
+
null) {
|
|
234
235
|
collectRelFilterParams(qi, target, filterObj.every, params, depth + 1);
|
|
235
236
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
236
237
|
}
|
|
@@ -250,7 +251,9 @@ export function collectRelFilterParams(qi, targetTable, subWhere, params, depth
|
|
|
250
251
|
const meta = qi.schema.tables[targetTable];
|
|
251
252
|
if (!meta)
|
|
252
253
|
return;
|
|
253
|
-
|
|
254
|
+
// The FROM-item reference only shapes SQL text, never params, so the bare
|
|
255
|
+
// name serves the collect mirror whether or not the build path aliased it.
|
|
256
|
+
collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta, qi.q(targetTable)), subWhere, params, depth);
|
|
254
257
|
}
|
|
255
258
|
/**
|
|
256
259
|
* Collect params from operator clauses. Mirrors buildOperatorClauses:
|
|
@@ -268,8 +271,11 @@ export function collectOperatorParams(qi, column, op, params, refCtx) {
|
|
|
268
271
|
};
|
|
269
272
|
// Mirrors buildOperatorClauses' temporal bind rewrite exactly.
|
|
270
273
|
const cv = (v) => (refCtx ? coerceWhereOperand(qi, refCtx.meta, refCtx.rawColumn, v) : v);
|
|
274
|
+
const insensitive = op.mode === 'insensitive';
|
|
271
275
|
if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
|
|
272
276
|
assertBindableEqualsOperand(op.equals, `"${column}"`);
|
|
277
|
+
if (insensitive)
|
|
278
|
+
assertInsensitiveOperand(op.equals, 'equals', column);
|
|
273
279
|
params.push(cv(op.equals));
|
|
274
280
|
}
|
|
275
281
|
if (op.gt !== undefined && !skipRef(op.gt))
|
|
@@ -280,12 +286,25 @@ export function collectOperatorParams(qi, column, op, params, refCtx) {
|
|
|
280
286
|
params.push(cv(op.lt));
|
|
281
287
|
if (op.lte !== undefined && !skipRef(op.lte))
|
|
282
288
|
params.push(cv(op.lte));
|
|
283
|
-
if (op.not !== undefined && op.not !== null && !skipRef(op.not))
|
|
289
|
+
if (op.not !== undefined && op.not !== null && !skipRef(op.not)) {
|
|
290
|
+
if (insensitive)
|
|
291
|
+
assertInsensitiveOperand(op.not, 'not', column);
|
|
284
292
|
params.push(cv(op.not));
|
|
285
|
-
|
|
293
|
+
}
|
|
294
|
+
if (op.in !== undefined) {
|
|
295
|
+
if (insensitive) {
|
|
296
|
+
assertInsensitiveOperand(op.in, 'in', column);
|
|
297
|
+
requireInsensitiveList(qi, column, 'in');
|
|
298
|
+
}
|
|
286
299
|
params.push(qi.inParam(cv(op.in)));
|
|
287
|
-
|
|
300
|
+
}
|
|
301
|
+
if (op.notIn !== undefined) {
|
|
302
|
+
if (insensitive) {
|
|
303
|
+
assertInsensitiveOperand(op.notIn, 'notIn', column);
|
|
304
|
+
requireInsensitiveList(qi, column, 'notIn');
|
|
305
|
+
}
|
|
288
306
|
params.push(qi.inParam(cv(op.notIn)));
|
|
307
|
+
}
|
|
289
308
|
if (op.contains !== undefined)
|
|
290
309
|
params.push(`%${likeOperand(qi, op.contains)}%`);
|
|
291
310
|
if (op.startsWith !== undefined)
|
|
@@ -451,16 +470,16 @@ export function collectTargetGlobalFilterAlias(qi, targetTable, params) {
|
|
|
451
470
|
collectAliasWhereParams(qi, targetTable, meta, gf, params);
|
|
452
471
|
}
|
|
453
472
|
/**
|
|
454
|
-
* SQL clause for `targetTable`'s global filter rendered against
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* {@link collectTargetGlobalFilterExists}.
|
|
473
|
+
* SQL clause for `targetTable`'s global filter rendered against `targetRef`,
|
|
474
|
+
* the relation-filter `EXISTS` subquery's FROM item (the bare table name, or
|
|
475
|
+
* its alias when {@link buildRelationFilter} had to alias it). Pushes its
|
|
476
|
+
* params; `''` when none. Mirror: {@link collectTargetGlobalFilterExists}.
|
|
458
477
|
*/
|
|
459
|
-
export function targetGlobalFilterExists(qi, targetTable, params) {
|
|
478
|
+
export function targetGlobalFilterExists(qi, targetTable, targetRef, params) {
|
|
460
479
|
const gf = resolveGlobalFilter(qi, targetTable);
|
|
461
480
|
if (!gf)
|
|
462
481
|
return '';
|
|
463
|
-
return buildSubWhereForRelation(qi, targetTable, gf, params) ?? '';
|
|
482
|
+
return buildSubWhereForRelation(qi, targetTable, targetRef, gf, params) ?? '';
|
|
464
483
|
}
|
|
465
484
|
/** Param-collect mirror of {@link targetGlobalFilterExists}. */
|
|
466
485
|
export function collectTargetGlobalFilterExists(qi, targetTable, params) {
|
|
@@ -681,29 +700,81 @@ export function scopedWhereHost(qi, meta) {
|
|
|
681
700
|
}
|
|
682
701
|
return host;
|
|
683
702
|
}
|
|
684
|
-
/**
|
|
685
|
-
|
|
703
|
+
/**
|
|
704
|
+
* Build the scope for a relation-filter EXISTS sub-where. `targetRef` is the
|
|
705
|
+
* subquery's FROM item as the SQL refers to it: the quoted target table, or the
|
|
706
|
+
* alias {@link buildRelationFilter} gave it when the bare name would have been
|
|
707
|
+
* captured by the correlation. Every column the sub-where names, and every
|
|
708
|
+
* nested relation filter's correlation parent, is qualified by that same
|
|
709
|
+
* reference, so the two can never point at different rows.
|
|
710
|
+
*/
|
|
711
|
+
export function relationWhereScope(qi, targetTable, meta, targetRef) {
|
|
686
712
|
return {
|
|
687
713
|
meta,
|
|
688
714
|
table: targetTable,
|
|
689
|
-
qualifier: `${
|
|
690
|
-
relationParent:
|
|
715
|
+
qualifier: `${targetRef}.`,
|
|
716
|
+
relationParent: targetRef,
|
|
691
717
|
host: scopedWhereHost(qi, meta),
|
|
692
718
|
unknownColumn: (field) => new ValidationError(`Unknown field "${field}" in relation filter for table "${targetTable}". ` +
|
|
693
719
|
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`),
|
|
694
720
|
};
|
|
695
721
|
}
|
|
696
|
-
/**
|
|
722
|
+
/**
|
|
723
|
+
* Build the scope for a relation `with`-clause `where` compiled against
|
|
724
|
+
* `alias`. The alias is BARE (`t0`), never a rendered reference: this scope
|
|
725
|
+
* quotes it itself for the nested-relation correlation parent, so a caller
|
|
726
|
+
* that hands over an already-quoted name gets `"""posts"""` inside an EXISTS
|
|
727
|
+
* body and a 42P01 that names the table three times. That happened twice, on
|
|
728
|
+
* two independent paths, from the same one-parameter-two-meanings ambiguity,
|
|
729
|
+
* so the ambiguity is refused here rather than corrected at each call site.
|
|
730
|
+
* A caller that genuinely holds a rendered reference wants
|
|
731
|
+
* {@link renderedRefWhereScope}.
|
|
732
|
+
*/
|
|
697
733
|
export function aliasWhereScope(qi, targetTable, meta, alias) {
|
|
734
|
+
if (alias.includes('"')) {
|
|
735
|
+
throw new ValidationError(`Internal: aliasWhereScope was given the rendered reference ${alias} for table "${targetTable}", ` +
|
|
736
|
+
'but it takes a bare alias and quotes it itself. Use renderedRefWhereScope for an already-quoted reference.');
|
|
737
|
+
}
|
|
698
738
|
return {
|
|
699
739
|
meta,
|
|
700
740
|
table: targetTable,
|
|
701
741
|
qualifier: `${alias}.`,
|
|
702
|
-
|
|
742
|
+
// Rendered through the quoter (`"t0"`), which is what a nested relation
|
|
743
|
+
// filter always emitted for this parent; PostgreSQL folds the unquoted
|
|
744
|
+
// alias to the same identifier. The empty alias the collect mirror passes
|
|
745
|
+
// never reaches SQL text.
|
|
746
|
+
relationParent: alias === '' ? alias : qi.q(alias),
|
|
703
747
|
host: scopedWhereHost(qi, meta),
|
|
704
748
|
unknownColumn: (field) => new ValidationError(`Unknown column "${field}" in where for table "${targetTable}"`),
|
|
705
749
|
};
|
|
706
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* The scope for a `where` compiled against an ALREADY-RENDERED FROM-item
|
|
753
|
+
* reference: `"users"` inside `ON CONFLICT ... DO UPDATE`, or the quoted child
|
|
754
|
+
* table a batched follow-up selects from. One parameter with one meaning, used
|
|
755
|
+
* verbatim for the column qualifier AND for a nested relation filter's
|
|
756
|
+
* correlation parent, which is exactly the pair {@link aliasWhereScope} gets
|
|
757
|
+
* wrong when it is handed a rendered reference.
|
|
758
|
+
*
|
|
759
|
+
* It is {@link relationWhereScope} with the unknown-column wording of a plain
|
|
760
|
+
* `where`, because the filters compiled through it are `globalFilters` entries
|
|
761
|
+
* and a misconfigured one should read the same here as it does on the `update`
|
|
762
|
+
* path.
|
|
763
|
+
*/
|
|
764
|
+
export function renderedRefWhereScope(qi, targetTable, meta, targetRef) {
|
|
765
|
+
return {
|
|
766
|
+
...relationWhereScope(qi, targetTable, meta, targetRef),
|
|
767
|
+
unknownColumn: (field) => new ValidationError(`Unknown column "${field}" in where for table "${targetTable}"`),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Compile `where` against an already-rendered FROM-item reference. The seam
|
|
772
|
+
* both global-filter callers that hold one go through, so neither can reach
|
|
773
|
+
* {@link aliasWhereScope} by mistake again.
|
|
774
|
+
*/
|
|
775
|
+
export function buildRenderedRefWhere(qi, targetTable, targetMeta, targetRef, where, params) {
|
|
776
|
+
return buildScopedWhere(qi, renderedRefWhereScope(qi, targetTable, targetMeta, targetRef), where, params);
|
|
777
|
+
}
|
|
707
778
|
/**
|
|
708
779
|
* Compile a scoped sub-where to SQL. Serves BOTH the relation-filter EXISTS
|
|
709
780
|
* body ({@link buildSubWhereForRelation}) and the relation `with`-clause
|
|
@@ -898,11 +969,45 @@ export function fingerprintScopedWhere(qi, host, where, depth = 0) {
|
|
|
898
969
|
}
|
|
899
970
|
return parts.join('&');
|
|
900
971
|
}
|
|
972
|
+
/**
|
|
973
|
+
* The alias a relation-filter subquery's FROM item takes when the bare table
|
|
974
|
+
* name would be captured by the correlation (see {@link buildRelationFilter}).
|
|
975
|
+
* Derived from the WHERE walk depth rather than from a counter: the depth is a
|
|
976
|
+
* pure function of the where SHAPE, which the cache fingerprint already
|
|
977
|
+
* encodes, so the same fingerprint always yields the same SQL text, and two
|
|
978
|
+
* nested levels are one depth apart and therefore never share a name.
|
|
979
|
+
*/
|
|
980
|
+
function relationFilterAlias(depth) {
|
|
981
|
+
return `rf${depth}`;
|
|
982
|
+
}
|
|
901
983
|
/**
|
|
902
984
|
* Build relation filter SQL: WHERE EXISTS / NOT EXISTS subquery
|
|
903
985
|
* Supports: some (EXISTS), every (NOT EXISTS ... NOT), none (NOT EXISTS)
|
|
986
|
+
*
|
|
987
|
+
* NAME CAPTURE, and the aliasing rule that closes it. The subquery correlates
|
|
988
|
+
* its FROM item to the parent row (`target.fk = parent.pk`). When the target
|
|
989
|
+
* is named bare and the parent reference is that same bare name, a
|
|
990
|
+
* self-referencing relation (`comments.parent_id -> comments.id`) compiles
|
|
991
|
+
* `"comments"."parent_id" = "comments"."id"`, and SQL resolves BOTH sides to
|
|
992
|
+
* the inner row: `some: {}` matched nothing, `none: {}` matched everything,
|
|
993
|
+
* `is: null` matched every row, and the defect propagated through nested
|
|
994
|
+
* filters and into the batched loader's follow-up query (which is a top-level
|
|
995
|
+
* findMany on the child table). So the FROM item is aliased EXACTLY when its
|
|
996
|
+
* quoted name equals `parentRef`, and the alias is then used for the
|
|
997
|
+
* correlation, the sub-where qualifier, the global-filter fragment and as the
|
|
998
|
+
* parent reference of nested filters. Every other relation filter keeps the
|
|
999
|
+
* bare-table template it always emitted, byte for byte. The rule is complete
|
|
1000
|
+
* rather than a heuristic: the only reference the inner FROM item can capture
|
|
1001
|
+
* is one that spells its own name, and a nested level whose parent is already
|
|
1002
|
+
* an alias (`rf0`, `t0`, `ord0`) has no such reference to capture. The
|
|
1003
|
+
* junction of a manyToMany branch gets the same treatment for the same reason.
|
|
1004
|
+
*
|
|
1005
|
+
* `parentRef` is the RENDERED reference of the row being correlated against:
|
|
1006
|
+
* the quoted table at the top level (`undefined` here), an alias inside a
|
|
1007
|
+
* relation `with` where (`"t0"`), or the enclosing filter's FROM reference for
|
|
1008
|
+
* a nested relation filter.
|
|
904
1009
|
*/
|
|
905
|
-
export function buildRelationFilter(qi, _relName, relDef, filterObj, params,
|
|
1010
|
+
export function buildRelationFilter(qi, _relName, relDef, filterObj, params, parentRef,
|
|
906
1011
|
/**
|
|
907
1012
|
* Nesting depth of the WHERE walk that reached this relation filter. Each
|
|
908
1013
|
* relation descent is a level too: `{ posts: { some: { comments: { some:
|
|
@@ -914,8 +1019,12 @@ depth = 0) {
|
|
|
914
1019
|
const targetMeta = qi.schema.tables[targetTable];
|
|
915
1020
|
if (!targetMeta)
|
|
916
1021
|
return null;
|
|
917
|
-
const
|
|
918
|
-
const
|
|
1022
|
+
const qSelf = parentRef ?? qi.q(qi.table);
|
|
1023
|
+
const qTargetName = qi.q(targetTable);
|
|
1024
|
+
// `qt` is how the subquery REFERS to its FROM item (bare name or alias);
|
|
1025
|
+
// `fromTarget` is the FROM item itself (`"t"` or `"t" rf0`).
|
|
1026
|
+
const qt = qTargetName === qSelf ? relationFilterAlias(depth) : qTargetName;
|
|
1027
|
+
const fromTarget = qt === qTargetName ? qTargetName : `${qTargetName} ${qt}`;
|
|
919
1028
|
const clauses = [];
|
|
920
1029
|
// Correlation: link child table to parent table (supports composite FKs)
|
|
921
1030
|
let correlation;
|
|
@@ -925,13 +1034,16 @@ depth = 0) {
|
|
|
925
1034
|
// parent.pk and silently match nothing, so route through the junction:
|
|
926
1035
|
// EXISTS (SELECT 1 FROM junction
|
|
927
1036
|
// WHERE junction.targetKey = target.pk AND junction.sourceKey = parent.ref)
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
// fragment binds no params, so
|
|
1037
|
+
// The target reference is `qt` (bare or aliased, see above), and the
|
|
1038
|
+
// junction is aliased by the same capture rule should its name coincide
|
|
1039
|
+
// with either side. The fragment binds no params, so
|
|
1040
|
+
// collectRelationFilterParams needs no mirror.
|
|
931
1041
|
if (!relDef.through) {
|
|
932
1042
|
throw new ValidationError(`manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
|
|
933
1043
|
}
|
|
934
|
-
const
|
|
1044
|
+
const qJunctionName = qi.q(relDef.through.table);
|
|
1045
|
+
const qJunction = qJunctionName === qSelf || qJunctionName === qt ? `rj${depth}` : qJunctionName;
|
|
1046
|
+
const fromJunction = qJunction === qJunctionName ? qJunctionName : `${qJunctionName} ${qJunction}`;
|
|
935
1047
|
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
936
1048
|
const targetPk = targetMeta.primaryKey;
|
|
937
1049
|
if (targetPk.length === 0) {
|
|
@@ -954,7 +1066,7 @@ depth = 0) {
|
|
|
954
1066
|
const parentLink = sourceKeys
|
|
955
1067
|
.map((jcol, i) => `${qJunction}.${qi.q(jcol)} = ${qSelf}.${qi.q(refKeys[i])}`)
|
|
956
1068
|
.join(' AND ');
|
|
957
|
-
correlation = `EXISTS (SELECT 1 FROM ${
|
|
1069
|
+
correlation = `EXISTS (SELECT 1 FROM ${fromJunction} WHERE ${targetLink} AND ${parentLink})`;
|
|
958
1070
|
}
|
|
959
1071
|
else if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
|
|
960
1072
|
// parent.pk = child.fk
|
|
@@ -969,10 +1081,10 @@ depth = 0) {
|
|
|
969
1081
|
// ignore filtered-out rows, and `every` quantifies over only the surviving
|
|
970
1082
|
// rows ("every NON-deleted related row matches P"). It is ANDed into the
|
|
971
1083
|
// correlation and its params pushed AFTER the per-branch filter, mirrored
|
|
972
|
-
// exactly in collectWhereParams' relation-filter branch.
|
|
973
|
-
//
|
|
1084
|
+
// exactly in collectWhereParams' relation-filter branch. Rendered against
|
|
1085
|
+
// `qt`, the same reference the FROM item and the sub-where use.
|
|
974
1086
|
const gfAnd = () => {
|
|
975
|
-
const gf = targetGlobalFilterExists(qi, targetTable, params);
|
|
1087
|
+
const gf = targetGlobalFilterExists(qi, targetTable, qt, params);
|
|
976
1088
|
return gf ? ` AND ${gf}` : '';
|
|
977
1089
|
};
|
|
978
1090
|
// "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
@@ -981,26 +1093,26 @@ depth = 0) {
|
|
|
981
1093
|
// which also skips null. Unreachable via normalization today, guarded anyway.
|
|
982
1094
|
if (filterObj.some !== undefined && filterObj.some !== null) {
|
|
983
1095
|
const subWhere = filterObj.some;
|
|
984
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
1096
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
|
|
985
1097
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
986
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${
|
|
1098
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
987
1099
|
}
|
|
988
1100
|
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
989
1101
|
if (filterObj.none !== undefined && filterObj.none !== null) {
|
|
990
1102
|
const subWhere = filterObj.none;
|
|
991
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
1103
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
|
|
992
1104
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
993
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${
|
|
1105
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
994
1106
|
}
|
|
995
1107
|
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
|
|
996
1108
|
if (filterObj.every !== undefined && filterObj.every !== null) {
|
|
997
1109
|
const subWhere = filterObj.every;
|
|
998
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
1110
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
|
|
999
1111
|
if (filterClause) {
|
|
1000
1112
|
// gf params pushed AFTER filter params (collect mirrors this order), but
|
|
1001
1113
|
// placed textually inside the domain so it restricts which rows count.
|
|
1002
1114
|
const gf = gfAnd();
|
|
1003
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${
|
|
1115
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
|
|
1004
1116
|
}
|
|
1005
1117
|
else {
|
|
1006
1118
|
// "every" with empty filter = true (all match trivially), gf irrelevant.
|
|
@@ -1010,26 +1122,26 @@ depth = 0) {
|
|
|
1010
1122
|
// `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
|
|
1011
1123
|
if (filterObj.is !== undefined) {
|
|
1012
1124
|
if (filterObj.is === null) {
|
|
1013
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${
|
|
1125
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${gfAnd()})`);
|
|
1014
1126
|
}
|
|
1015
1127
|
else {
|
|
1016
1128
|
const subWhere = filterObj.is;
|
|
1017
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
1129
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
|
|
1018
1130
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
1019
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${
|
|
1131
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
1020
1132
|
}
|
|
1021
1133
|
}
|
|
1022
1134
|
// "isNot": NOT EXISTS, for to-one relations (same SQL as "none").
|
|
1023
1135
|
// `isNot: null` = "a related row exists" → EXISTS.
|
|
1024
1136
|
if (filterObj.isNot !== undefined) {
|
|
1025
1137
|
if (filterObj.isNot === null) {
|
|
1026
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${
|
|
1138
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${gfAnd()})`);
|
|
1027
1139
|
}
|
|
1028
1140
|
else {
|
|
1029
1141
|
const subWhere = filterObj.isNot;
|
|
1030
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
1142
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
|
|
1031
1143
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
1032
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${
|
|
1144
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
1033
1145
|
}
|
|
1034
1146
|
}
|
|
1035
1147
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
@@ -1038,11 +1150,11 @@ depth = 0) {
|
|
|
1038
1150
|
* Build WHERE clause conditions for a relation filter subquery.
|
|
1039
1151
|
* Uses the target table's column mapping to resolve field names.
|
|
1040
1152
|
*/
|
|
1041
|
-
export function buildSubWhereForRelation(qi, targetTable, subWhere, params, depth = 0) {
|
|
1153
|
+
export function buildSubWhereForRelation(qi, targetTable, targetRef, subWhere, params, depth = 0) {
|
|
1042
1154
|
const meta = qi.schema.tables[targetTable];
|
|
1043
1155
|
if (!meta)
|
|
1044
1156
|
return null;
|
|
1045
|
-
return buildScopedWhere(qi, relationWhereScope(qi, targetTable, meta), subWhere, params, depth);
|
|
1157
|
+
return buildScopedWhere(qi, relationWhereScope(qi, targetTable, meta, targetRef), subWhere, params, depth);
|
|
1046
1158
|
}
|
|
1047
1159
|
/**
|
|
1048
1160
|
* Resolve a column's Postgres type from an arbitrary table's metadata
|
|
@@ -1215,6 +1327,7 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
|
|
|
1215
1327
|
// Temporal bind rewrite, identical to `collectOperatorParams`. Value-only, so
|
|
1216
1328
|
// the emitted SQL (and therefore the template cache) is untouched.
|
|
1217
1329
|
const cv = (v) => (refCtx ? coerceWhereOperand(qi, refCtx.meta, refCtx.rawColumn, v) : v);
|
|
1330
|
+
const insensitive = op.mode === 'insensitive';
|
|
1218
1331
|
if (op.equals !== undefined) {
|
|
1219
1332
|
if (op.equals === null) {
|
|
1220
1333
|
clauses.push(`${column} IS NULL`);
|
|
@@ -1224,8 +1337,10 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
|
|
|
1224
1337
|
}
|
|
1225
1338
|
else {
|
|
1226
1339
|
assertBindableEqualsOperand(op.equals, column);
|
|
1340
|
+
if (insensitive)
|
|
1341
|
+
assertInsensitiveOperand(op.equals, 'equals', column);
|
|
1227
1342
|
params.push(cv(op.equals));
|
|
1228
|
-
clauses.push(
|
|
1343
|
+
clauses.push(insensitiveEquality(column, '=', qi.p(params.length), insensitive));
|
|
1229
1344
|
}
|
|
1230
1345
|
}
|
|
1231
1346
|
if (op.gt !== undefined) {
|
|
@@ -1272,19 +1387,32 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
|
|
|
1272
1387
|
clauses.push(`${column} != ${columnRefSql(qi, op.not, refCtx, op.mode)}`);
|
|
1273
1388
|
}
|
|
1274
1389
|
else {
|
|
1390
|
+
if (insensitive)
|
|
1391
|
+
assertInsensitiveOperand(op.not, 'not', column);
|
|
1275
1392
|
params.push(cv(op.not));
|
|
1276
|
-
clauses.push(
|
|
1393
|
+
clauses.push(insensitiveEquality(column, '!=', qi.p(params.length), insensitive));
|
|
1277
1394
|
}
|
|
1278
1395
|
}
|
|
1279
1396
|
if (op.in !== undefined) {
|
|
1397
|
+
if (insensitive) {
|
|
1398
|
+
assertInsensitiveOperand(op.in, 'in', column);
|
|
1399
|
+
requireInsensitiveList(qi, column, 'in');
|
|
1400
|
+
}
|
|
1280
1401
|
params.push(qi.inParam(cv(op.in)));
|
|
1281
|
-
clauses.push(
|
|
1402
|
+
clauses.push(insensitive
|
|
1403
|
+
? insensitiveInClause(qi, column, qi.p(params.length), false, 'in')
|
|
1404
|
+
: qi.inClause(column, qi.p(params.length), false));
|
|
1282
1405
|
}
|
|
1283
1406
|
if (op.notIn !== undefined) {
|
|
1407
|
+
if (insensitive) {
|
|
1408
|
+
assertInsensitiveOperand(op.notIn, 'notIn', column);
|
|
1409
|
+
requireInsensitiveList(qi, column, 'notIn');
|
|
1410
|
+
}
|
|
1284
1411
|
params.push(qi.inParam(cv(op.notIn)));
|
|
1285
|
-
clauses.push(
|
|
1412
|
+
clauses.push(insensitive
|
|
1413
|
+
? insensitiveInClause(qi, column, qi.p(params.length), true, 'notIn')
|
|
1414
|
+
: qi.inClause(column, qi.p(params.length), true));
|
|
1286
1415
|
}
|
|
1287
|
-
const insensitive = op.mode === 'insensitive';
|
|
1288
1416
|
if (op.contains !== undefined) {
|
|
1289
1417
|
params.push(`%${likeOperand(qi, op.contains)}%`);
|
|
1290
1418
|
clauses.push(buildLikeClause(qi, column, qi.p(params.length), insensitive));
|
|
@@ -1479,6 +1607,91 @@ function buildLikeClause(qi, column, paramRef, insensitive) {
|
|
|
1479
1607
|
const base = insensitive ? qi.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
|
|
1480
1608
|
return `${base} ESCAPE '\\'`;
|
|
1481
1609
|
}
|
|
1610
|
+
/**
|
|
1611
|
+
* One equality-family comparison (`=` / `!=`) honoring `mode: 'insensitive'`.
|
|
1612
|
+
*
|
|
1613
|
+
* `mode` is a sibling key of `equals` / `not` / `in` / `notIn` on
|
|
1614
|
+
* {@link WhereOperator}, and it used to be read by the three LIKE operators
|
|
1615
|
+
* ONLY, so `{ equals: 'dup name', mode: 'insensitive' }` compiled to a
|
|
1616
|
+
* case-sensitive `"name" = $1` and returned zero rows where `ILIKE` found the
|
|
1617
|
+
* variants, with no error. The fold is `LOWER(col) = LOWER($n)`: exact,
|
|
1618
|
+
* portable to every engine, and computed by the DATABASE on both sides, because
|
|
1619
|
+
* the JavaScript `toLowerCase()` and PostgreSQL's `LOWER` disagree on `İ` and
|
|
1620
|
+
* `ß`, so folding the operand client-side would compare two different
|
|
1621
|
+
* alphabets. Without the mode the emitted text is byte-identical to before.
|
|
1622
|
+
*/
|
|
1623
|
+
function insensitiveEquality(column, operator, paramRef, insensitive) {
|
|
1624
|
+
return insensitive ? `LOWER(${column}) ${operator} LOWER(${paramRef})` : `${column} ${operator} ${paramRef}`;
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* The `in` / `notIn` comparison under `mode: 'insensitive'`.
|
|
1628
|
+
*
|
|
1629
|
+
* The fold is the engine's on BOTH sides: the column is lowered by `LOWER`,
|
|
1630
|
+
* and so is every list element, inside `unnest`. The list stays ONE bound
|
|
1631
|
+
* `text[]`, so the statement text (and therefore its cache key) is independent
|
|
1632
|
+
* of the list length. `NOT IN` keeps SQL's NULL semantics, matching the plain
|
|
1633
|
+
* `!= ALL`.
|
|
1634
|
+
*
|
|
1635
|
+
* PostgreSQL only; {@link requireInsensitiveList} is the gate, and it runs on
|
|
1636
|
+
* the param path as well so a warm template cannot slip past it.
|
|
1637
|
+
*/
|
|
1638
|
+
function insensitiveInClause(qi, column, paramRef, negated, operator) {
|
|
1639
|
+
requireInsensitiveList(qi, column, operator);
|
|
1640
|
+
return `LOWER(${column}) ${negated ? 'NOT IN' : 'IN'} (SELECT LOWER(v) FROM unnest(${paramRef}::text[]) AS v(v))`;
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Gate `mode: 'insensitive'` on `in` / `notIn` to PostgreSQL.
|
|
1644
|
+
*
|
|
1645
|
+
* The property the operator has to hold is that the COLUMN and the LIST
|
|
1646
|
+
* ELEMENTS are folded by the SAME function. Break that and `equals` and `in`
|
|
1647
|
+
* answer the same operand with DIFFERENT rows on the same engine, which is a
|
|
1648
|
+
* wrong row and not an error.
|
|
1649
|
+
*
|
|
1650
|
+
* The first cut broke it: the column went through the engine's `LOWER` while
|
|
1651
|
+
* the elements went through JavaScript's `toLowerCase()`. Those are two
|
|
1652
|
+
* alphabets. SQLite's `LOWER` is ASCII-only, so over the rows `CAFÉ` and
|
|
1653
|
+
* `café` the operand `'CAFÉ'` matched `CAFÉ` through `equals` and `café`
|
|
1654
|
+
* through `in` - not an exotic-codepoint edge case but every non-ASCII letter
|
|
1655
|
+
* on that engine.
|
|
1656
|
+
*
|
|
1657
|
+
* Folding the elements in SQL instead is not reachable from here for the other
|
|
1658
|
+
* dialects: each unpacks a bound IN list in a subquery of its OWN shape
|
|
1659
|
+
* (`json_each` / `JSON_TABLE` / `OPENJSON`) whose projection is written by the
|
|
1660
|
+
* dialect, not by this module, and the length-independence the SQL-template
|
|
1661
|
+
* cache depends on rules out the other portable form, one placeholder per
|
|
1662
|
+
* element. Restoring the operator on those engines means a dialect hook that
|
|
1663
|
+
* projects the folded element, one correct implementation per engine. Until
|
|
1664
|
+
* then a typed refusal is the honest outcome. `equals`, `not` and the LIKE
|
|
1665
|
+
* operators are unaffected: they fold a single operand the engine can reach.
|
|
1666
|
+
*
|
|
1667
|
+
* Called from BOTH the build and the cache-hit param-collect path, mirroring
|
|
1668
|
+
* every other gate in this module.
|
|
1669
|
+
*/
|
|
1670
|
+
function requireInsensitiveList(qi, column, operator) {
|
|
1671
|
+
if (qi.dialect.name === 'postgresql')
|
|
1672
|
+
return;
|
|
1673
|
+
throw new UnsupportedFeatureError(`mode: 'insensitive' on \`${operator}\` (${column})`, qi.dialect.name, "Case-folding a bound list needs the engine to lower every element in SQL, which this dialect's " +
|
|
1674
|
+
'IN-list form cannot express. Lowering them in JavaScript instead would fold the list and the ' +
|
|
1675
|
+
`column by two different functions, so \`equals\` and \`${operator}\` would return different rows ` +
|
|
1676
|
+
'for the same operand. Write the list as branches: ' +
|
|
1677
|
+
"`{ OR: [{ field: { equals: a, mode: 'insensitive' } }, ...] }`.");
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Refuse `mode: 'insensitive'` beside a non-string equality operand (E003).
|
|
1681
|
+
* `LOWER(5)` is a type error the engine would raise in its own words on the
|
|
1682
|
+
* build path and, worse, a warm template would bind the value without ever
|
|
1683
|
+
* reaching the engine's check for the SQL it was compiled against, so the
|
|
1684
|
+
* refusal runs on both the build and the collect side. The value itself stays
|
|
1685
|
+
* out of the message (safe error mode renders keys, never values).
|
|
1686
|
+
*/
|
|
1687
|
+
function assertInsensitiveOperand(value, operator, column) {
|
|
1688
|
+
const ok = Array.isArray(value) ? value.every((v) => typeof v === 'string') : typeof value === 'string';
|
|
1689
|
+
if (ok)
|
|
1690
|
+
return;
|
|
1691
|
+
throw new ValidationError(`mode: 'insensitive' on ${column} requires a string operand for '${operator}' ` +
|
|
1692
|
+
`(received ${Array.isArray(value) ? 'a list with a non-string element' : typeof value}). ` +
|
|
1693
|
+
`Case folding applies to text; drop the mode or compare a string.`);
|
|
1694
|
+
}
|
|
1482
1695
|
/**
|
|
1483
1696
|
* Refuse a {@link JsonFilter} carrying a key that is not a JSON operator, and
|
|
1484
1697
|
* refuse a filter that selects a `path` but never compares it.
|
package/dist/query/writes.d.ts
CHANGED
|
@@ -164,8 +164,17 @@ export declare function assertNoGeneratedColumns(qi: BuilderCtx, data: Record<st
|
|
|
164
164
|
* Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
|
|
165
165
|
* pushes any required params onto the shared params array so that WHERE
|
|
166
166
|
* clause numbering continues correctly afterward.
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
*
|
|
168
|
+
* `refQualifier` prefixes the column REFERENCE an arithmetic operator reads
|
|
169
|
+
* from (`"view_count" = <qualifier>"view_count" + $n`). A plain `UPDATE` has
|
|
170
|
+
* one table in scope and passes nothing; `upsert` passes the table name,
|
|
171
|
+
* because inside `ON CONFLICT ... DO UPDATE SET` the target table and
|
|
172
|
+
* `excluded` are BOTH in scope and a bare reference is 42702 on PostgreSQL.
|
|
173
|
+
* The assignment target on the left stays bare on every engine (it can only
|
|
174
|
+
* ever be the target table's column), and `set` / literal values reference no
|
|
175
|
+
* column at all, so their SQL is byte-identical with or without a qualifier.
|
|
176
|
+
*/
|
|
177
|
+
export declare function buildSetClause(qi: BuilderCtx, key: string, value: unknown, params: unknown[], refQualifier?: string): string;
|
|
169
178
|
/**
|
|
170
179
|
* Reject a `data` value that reached {@link buildSetClause}'s plain-value
|
|
171
180
|
* fallthrough as a plain object with exactly ONE key that is not a known
|