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.
Files changed (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
@@ -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) !== null) {
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
- collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta), subWhere, params, depth);
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
- if (op.in !== undefined)
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
- if (op.notIn !== undefined)
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 the bare
455
- * (unaliased) table name, the form used inside relation-filter `EXISTS`
456
- * subqueries. Pushes its params; `''` when none. Mirror:
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
- /** Build the scope for a relation-filter EXISTS sub-where over the bare target table. */
685
- export function relationWhereScope(qi, targetTable, meta) {
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: `${qi.q(targetTable)}.`,
690
- relationParent: targetTable,
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
- /** Build the scope for a relation `with`-clause `where` compiled against `alias`. */
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
- relationParent: alias,
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, parentTable,
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 qt = qi.q(targetTable);
918
- const qSelf = qi.q(parentTable ?? qi.table);
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
- // All bare table names (no aliases), so the scoped sub-where machinery and
929
- // nested relation filters inside the branch keep their qualification. The
930
- // fragment binds no params, so collectRelationFilterParams needs no mirror.
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 qJunction = qi.q(relDef.through.table);
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 ${qJunction} WHERE ${targetLink} AND ${parentLink})`;
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. `qt` is the bare
973
- // target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
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 ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
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 ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
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 ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
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 ${qt} WHERE ${correlation}${gfAnd()})`);
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 ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
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 ${qt} WHERE ${correlation}${gfAnd()})`);
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 ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
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(`${column} = ${qi.p(params.length)}`);
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(`${column} != ${qi.p(params.length)}`);
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(qi.inClause(column, qi.p(params.length), false));
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(qi.inClause(column, qi.p(params.length), true));
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.
@@ -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
- export declare function buildSetClause(qi: BuilderCtx, key: string, value: unknown, params: unknown[]): string;
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