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
@@ -40,6 +40,8 @@ exports.emptyRelationsHost = emptyRelationsHost;
40
40
  exports.scopedWhereHost = scopedWhereHost;
41
41
  exports.relationWhereScope = relationWhereScope;
42
42
  exports.aliasWhereScope = aliasWhereScope;
43
+ exports.renderedRefWhereScope = renderedRefWhereScope;
44
+ exports.buildRenderedRefWhere = buildRenderedRefWhere;
43
45
  exports.buildScopedWhere = buildScopedWhere;
44
46
  exports.buildScopedScalarClause = buildScopedScalarClause;
45
47
  exports.collectScopedWhereParams = collectScopedWhereParams;
@@ -298,7 +300,8 @@ function collectRelationFilterParams(qi, relDef, filterObj, params, depth = 0) {
298
300
  if (filterObj.every !== undefined && filterObj.every !== null) {
299
301
  // gf is only emitted (build) when the `every` sub-where compiles to a
300
302
  // filter, otherwise `every` is trivially true and no subquery is built.
301
- if (buildSubWhereForRelation(qi, target, filterObj.every, [], depth + 1) !== null) {
303
+ if (buildSubWhereForRelation(qi, target, qi.q(target), filterObj.every, [], depth + 1) !==
304
+ null) {
302
305
  collectRelFilterParams(qi, target, filterObj.every, params, depth + 1);
303
306
  collectTargetGlobalFilterExists(qi, target, params);
304
307
  }
@@ -318,7 +321,9 @@ function collectRelFilterParams(qi, targetTable, subWhere, params, depth = 0) {
318
321
  const meta = qi.schema.tables[targetTable];
319
322
  if (!meta)
320
323
  return;
321
- collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta), subWhere, params, depth);
324
+ // The FROM-item reference only shapes SQL text, never params, so the bare
325
+ // name serves the collect mirror whether or not the build path aliased it.
326
+ collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta, qi.q(targetTable)), subWhere, params, depth);
322
327
  }
323
328
  /**
324
329
  * Collect params from operator clauses. Mirrors buildOperatorClauses:
@@ -336,8 +341,11 @@ function collectOperatorParams(qi, column, op, params, refCtx) {
336
341
  };
337
342
  // Mirrors buildOperatorClauses' temporal bind rewrite exactly.
338
343
  const cv = (v) => (refCtx ? coerceWhereOperand(qi, refCtx.meta, refCtx.rawColumn, v) : v);
344
+ const insensitive = op.mode === 'insensitive';
339
345
  if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
340
346
  (0, filters_js_1.assertBindableEqualsOperand)(op.equals, `"${column}"`);
347
+ if (insensitive)
348
+ assertInsensitiveOperand(op.equals, 'equals', column);
341
349
  params.push(cv(op.equals));
342
350
  }
343
351
  if (op.gt !== undefined && !skipRef(op.gt))
@@ -348,12 +356,25 @@ function collectOperatorParams(qi, column, op, params, refCtx) {
348
356
  params.push(cv(op.lt));
349
357
  if (op.lte !== undefined && !skipRef(op.lte))
350
358
  params.push(cv(op.lte));
351
- if (op.not !== undefined && op.not !== null && !skipRef(op.not))
359
+ if (op.not !== undefined && op.not !== null && !skipRef(op.not)) {
360
+ if (insensitive)
361
+ assertInsensitiveOperand(op.not, 'not', column);
352
362
  params.push(cv(op.not));
353
- if (op.in !== undefined)
363
+ }
364
+ if (op.in !== undefined) {
365
+ if (insensitive) {
366
+ assertInsensitiveOperand(op.in, 'in', column);
367
+ requireInsensitiveList(qi, column, 'in');
368
+ }
354
369
  params.push(qi.inParam(cv(op.in)));
355
- if (op.notIn !== undefined)
370
+ }
371
+ if (op.notIn !== undefined) {
372
+ if (insensitive) {
373
+ assertInsensitiveOperand(op.notIn, 'notIn', column);
374
+ requireInsensitiveList(qi, column, 'notIn');
375
+ }
356
376
  params.push(qi.inParam(cv(op.notIn)));
377
+ }
357
378
  if (op.contains !== undefined)
358
379
  params.push(`%${likeOperand(qi, op.contains)}%`);
359
380
  if (op.startsWith !== undefined)
@@ -519,16 +540,16 @@ function collectTargetGlobalFilterAlias(qi, targetTable, params) {
519
540
  collectAliasWhereParams(qi, targetTable, meta, gf, params);
520
541
  }
521
542
  /**
522
- * SQL clause for `targetTable`'s global filter rendered against the bare
523
- * (unaliased) table name, the form used inside relation-filter `EXISTS`
524
- * subqueries. Pushes its params; `''` when none. Mirror:
525
- * {@link collectTargetGlobalFilterExists}.
543
+ * SQL clause for `targetTable`'s global filter rendered against `targetRef`,
544
+ * the relation-filter `EXISTS` subquery's FROM item (the bare table name, or
545
+ * its alias when {@link buildRelationFilter} had to alias it). Pushes its
546
+ * params; `''` when none. Mirror: {@link collectTargetGlobalFilterExists}.
526
547
  */
527
- function targetGlobalFilterExists(qi, targetTable, params) {
548
+ function targetGlobalFilterExists(qi, targetTable, targetRef, params) {
528
549
  const gf = resolveGlobalFilter(qi, targetTable);
529
550
  if (!gf)
530
551
  return '';
531
- return buildSubWhereForRelation(qi, targetTable, gf, params) ?? '';
552
+ return buildSubWhereForRelation(qi, targetTable, targetRef, gf, params) ?? '';
532
553
  }
533
554
  /** Param-collect mirror of {@link targetGlobalFilterExists}. */
534
555
  function collectTargetGlobalFilterExists(qi, targetTable, params) {
@@ -749,29 +770,81 @@ function scopedWhereHost(qi, meta) {
749
770
  }
750
771
  return host;
751
772
  }
752
- /** Build the scope for a relation-filter EXISTS sub-where over the bare target table. */
753
- function relationWhereScope(qi, targetTable, meta) {
773
+ /**
774
+ * Build the scope for a relation-filter EXISTS sub-where. `targetRef` is the
775
+ * subquery's FROM item as the SQL refers to it: the quoted target table, or the
776
+ * alias {@link buildRelationFilter} gave it when the bare name would have been
777
+ * captured by the correlation. Every column the sub-where names, and every
778
+ * nested relation filter's correlation parent, is qualified by that same
779
+ * reference, so the two can never point at different rows.
780
+ */
781
+ function relationWhereScope(qi, targetTable, meta, targetRef) {
754
782
  return {
755
783
  meta,
756
784
  table: targetTable,
757
- qualifier: `${qi.q(targetTable)}.`,
758
- relationParent: targetTable,
785
+ qualifier: `${targetRef}.`,
786
+ relationParent: targetRef,
759
787
  host: scopedWhereHost(qi, meta),
760
788
  unknownColumn: (field) => new errors_js_1.ValidationError(`Unknown field "${field}" in relation filter for table "${targetTable}". ` +
761
789
  `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`),
762
790
  };
763
791
  }
764
- /** Build the scope for a relation `with`-clause `where` compiled against `alias`. */
792
+ /**
793
+ * Build the scope for a relation `with`-clause `where` compiled against
794
+ * `alias`. The alias is BARE (`t0`), never a rendered reference: this scope
795
+ * quotes it itself for the nested-relation correlation parent, so a caller
796
+ * that hands over an already-quoted name gets `"""posts"""` inside an EXISTS
797
+ * body and a 42P01 that names the table three times. That happened twice, on
798
+ * two independent paths, from the same one-parameter-two-meanings ambiguity,
799
+ * so the ambiguity is refused here rather than corrected at each call site.
800
+ * A caller that genuinely holds a rendered reference wants
801
+ * {@link renderedRefWhereScope}.
802
+ */
765
803
  function aliasWhereScope(qi, targetTable, meta, alias) {
804
+ if (alias.includes('"')) {
805
+ throw new errors_js_1.ValidationError(`Internal: aliasWhereScope was given the rendered reference ${alias} for table "${targetTable}", ` +
806
+ 'but it takes a bare alias and quotes it itself. Use renderedRefWhereScope for an already-quoted reference.');
807
+ }
766
808
  return {
767
809
  meta,
768
810
  table: targetTable,
769
811
  qualifier: `${alias}.`,
770
- relationParent: alias,
812
+ // Rendered through the quoter (`"t0"`), which is what a nested relation
813
+ // filter always emitted for this parent; PostgreSQL folds the unquoted
814
+ // alias to the same identifier. The empty alias the collect mirror passes
815
+ // never reaches SQL text.
816
+ relationParent: alias === '' ? alias : qi.q(alias),
771
817
  host: scopedWhereHost(qi, meta),
772
818
  unknownColumn: (field) => new errors_js_1.ValidationError(`Unknown column "${field}" in where for table "${targetTable}"`),
773
819
  };
774
820
  }
821
+ /**
822
+ * The scope for a `where` compiled against an ALREADY-RENDERED FROM-item
823
+ * reference: `"users"` inside `ON CONFLICT ... DO UPDATE`, or the quoted child
824
+ * table a batched follow-up selects from. One parameter with one meaning, used
825
+ * verbatim for the column qualifier AND for a nested relation filter's
826
+ * correlation parent, which is exactly the pair {@link aliasWhereScope} gets
827
+ * wrong when it is handed a rendered reference.
828
+ *
829
+ * It is {@link relationWhereScope} with the unknown-column wording of a plain
830
+ * `where`, because the filters compiled through it are `globalFilters` entries
831
+ * and a misconfigured one should read the same here as it does on the `update`
832
+ * path.
833
+ */
834
+ function renderedRefWhereScope(qi, targetTable, meta, targetRef) {
835
+ return {
836
+ ...relationWhereScope(qi, targetTable, meta, targetRef),
837
+ unknownColumn: (field) => new errors_js_1.ValidationError(`Unknown column "${field}" in where for table "${targetTable}"`),
838
+ };
839
+ }
840
+ /**
841
+ * Compile `where` against an already-rendered FROM-item reference. The seam
842
+ * both global-filter callers that hold one go through, so neither can reach
843
+ * {@link aliasWhereScope} by mistake again.
844
+ */
845
+ function buildRenderedRefWhere(qi, targetTable, targetMeta, targetRef, where, params) {
846
+ return buildScopedWhere(qi, renderedRefWhereScope(qi, targetTable, targetMeta, targetRef), where, params);
847
+ }
775
848
  /**
776
849
  * Compile a scoped sub-where to SQL. Serves BOTH the relation-filter EXISTS
777
850
  * body ({@link buildSubWhereForRelation}) and the relation `with`-clause
@@ -966,11 +1039,45 @@ function fingerprintScopedWhere(qi, host, where, depth = 0) {
966
1039
  }
967
1040
  return parts.join('&');
968
1041
  }
1042
+ /**
1043
+ * The alias a relation-filter subquery's FROM item takes when the bare table
1044
+ * name would be captured by the correlation (see {@link buildRelationFilter}).
1045
+ * Derived from the WHERE walk depth rather than from a counter: the depth is a
1046
+ * pure function of the where SHAPE, which the cache fingerprint already
1047
+ * encodes, so the same fingerprint always yields the same SQL text, and two
1048
+ * nested levels are one depth apart and therefore never share a name.
1049
+ */
1050
+ function relationFilterAlias(depth) {
1051
+ return `rf${depth}`;
1052
+ }
969
1053
  /**
970
1054
  * Build relation filter SQL: WHERE EXISTS / NOT EXISTS subquery
971
1055
  * Supports: some (EXISTS), every (NOT EXISTS ... NOT), none (NOT EXISTS)
1056
+ *
1057
+ * NAME CAPTURE, and the aliasing rule that closes it. The subquery correlates
1058
+ * its FROM item to the parent row (`target.fk = parent.pk`). When the target
1059
+ * is named bare and the parent reference is that same bare name, a
1060
+ * self-referencing relation (`comments.parent_id -> comments.id`) compiles
1061
+ * `"comments"."parent_id" = "comments"."id"`, and SQL resolves BOTH sides to
1062
+ * the inner row: `some: {}` matched nothing, `none: {}` matched everything,
1063
+ * `is: null` matched every row, and the defect propagated through nested
1064
+ * filters and into the batched loader's follow-up query (which is a top-level
1065
+ * findMany on the child table). So the FROM item is aliased EXACTLY when its
1066
+ * quoted name equals `parentRef`, and the alias is then used for the
1067
+ * correlation, the sub-where qualifier, the global-filter fragment and as the
1068
+ * parent reference of nested filters. Every other relation filter keeps the
1069
+ * bare-table template it always emitted, byte for byte. The rule is complete
1070
+ * rather than a heuristic: the only reference the inner FROM item can capture
1071
+ * is one that spells its own name, and a nested level whose parent is already
1072
+ * an alias (`rf0`, `t0`, `ord0`) has no such reference to capture. The
1073
+ * junction of a manyToMany branch gets the same treatment for the same reason.
1074
+ *
1075
+ * `parentRef` is the RENDERED reference of the row being correlated against:
1076
+ * the quoted table at the top level (`undefined` here), an alias inside a
1077
+ * relation `with` where (`"t0"`), or the enclosing filter's FROM reference for
1078
+ * a nested relation filter.
972
1079
  */
973
- function buildRelationFilter(qi, _relName, relDef, filterObj, params, parentTable,
1080
+ function buildRelationFilter(qi, _relName, relDef, filterObj, params, parentRef,
974
1081
  /**
975
1082
  * Nesting depth of the WHERE walk that reached this relation filter. Each
976
1083
  * relation descent is a level too: `{ posts: { some: { comments: { some:
@@ -982,8 +1089,12 @@ depth = 0) {
982
1089
  const targetMeta = qi.schema.tables[targetTable];
983
1090
  if (!targetMeta)
984
1091
  return null;
985
- const qt = qi.q(targetTable);
986
- const qSelf = qi.q(parentTable ?? qi.table);
1092
+ const qSelf = parentRef ?? qi.q(qi.table);
1093
+ const qTargetName = qi.q(targetTable);
1094
+ // `qt` is how the subquery REFERS to its FROM item (bare name or alias);
1095
+ // `fromTarget` is the FROM item itself (`"t"` or `"t" rf0`).
1096
+ const qt = qTargetName === qSelf ? relationFilterAlias(depth) : qTargetName;
1097
+ const fromTarget = qt === qTargetName ? qTargetName : `${qTargetName} ${qt}`;
987
1098
  const clauses = [];
988
1099
  // Correlation: link child table to parent table (supports composite FKs)
989
1100
  let correlation;
@@ -993,13 +1104,16 @@ depth = 0) {
993
1104
  // parent.pk and silently match nothing, so route through the junction:
994
1105
  // EXISTS (SELECT 1 FROM junction
995
1106
  // WHERE junction.targetKey = target.pk AND junction.sourceKey = parent.ref)
996
- // All bare table names (no aliases), so the scoped sub-where machinery and
997
- // nested relation filters inside the branch keep their qualification. The
998
- // fragment binds no params, so collectRelationFilterParams needs no mirror.
1107
+ // The target reference is `qt` (bare or aliased, see above), and the
1108
+ // junction is aliased by the same capture rule should its name coincide
1109
+ // with either side. The fragment binds no params, so
1110
+ // collectRelationFilterParams needs no mirror.
999
1111
  if (!relDef.through) {
1000
1112
  throw new errors_js_1.ValidationError(`manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
1001
1113
  }
1002
- const qJunction = qi.q(relDef.through.table);
1114
+ const qJunctionName = qi.q(relDef.through.table);
1115
+ const qJunction = qJunctionName === qSelf || qJunctionName === qt ? `rj${depth}` : qJunctionName;
1116
+ const fromJunction = qJunction === qJunctionName ? qJunctionName : `${qJunctionName} ${qJunction}`;
1003
1117
  const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
1004
1118
  const targetPk = targetMeta.primaryKey;
1005
1119
  if (targetPk.length === 0) {
@@ -1022,7 +1136,7 @@ depth = 0) {
1022
1136
  const parentLink = sourceKeys
1023
1137
  .map((jcol, i) => `${qJunction}.${qi.q(jcol)} = ${qSelf}.${qi.q(refKeys[i])}`)
1024
1138
  .join(' AND ');
1025
- correlation = `EXISTS (SELECT 1 FROM ${qJunction} WHERE ${targetLink} AND ${parentLink})`;
1139
+ correlation = `EXISTS (SELECT 1 FROM ${fromJunction} WHERE ${targetLink} AND ${parentLink})`;
1026
1140
  }
1027
1141
  else if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
1028
1142
  // parent.pk = child.fk
@@ -1037,10 +1151,10 @@ depth = 0) {
1037
1151
  // ignore filtered-out rows, and `every` quantifies over only the surviving
1038
1152
  // rows ("every NON-deleted related row matches P"). It is ANDed into the
1039
1153
  // correlation and its params pushed AFTER the per-branch filter, mirrored
1040
- // exactly in collectWhereParams' relation-filter branch. `qt` is the bare
1041
- // target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
1154
+ // exactly in collectWhereParams' relation-filter branch. Rendered against
1155
+ // `qt`, the same reference the FROM item and the sub-where use.
1042
1156
  const gfAnd = () => {
1043
- const gf = targetGlobalFilterExists(qi, targetTable, params);
1157
+ const gf = targetGlobalFilterExists(qi, targetTable, qt, params);
1044
1158
  return gf ? ` AND ${gf}` : '';
1045
1159
  };
1046
1160
  // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
@@ -1049,26 +1163,26 @@ depth = 0) {
1049
1163
  // which also skips null. Unreachable via normalization today, guarded anyway.
1050
1164
  if (filterObj.some !== undefined && filterObj.some !== null) {
1051
1165
  const subWhere = filterObj.some;
1052
- const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
1166
+ const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
1053
1167
  const filterAnd = filterClause ? ` AND ${filterClause}` : '';
1054
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1168
+ clauses.push(`EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1055
1169
  }
1056
1170
  // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
1057
1171
  if (filterObj.none !== undefined && filterObj.none !== null) {
1058
1172
  const subWhere = filterObj.none;
1059
- const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
1173
+ const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
1060
1174
  const filterAnd = filterClause ? ` AND ${filterClause}` : '';
1061
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1175
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1062
1176
  }
1063
1177
  // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
1064
1178
  if (filterObj.every !== undefined && filterObj.every !== null) {
1065
1179
  const subWhere = filterObj.every;
1066
- const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
1180
+ const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
1067
1181
  if (filterClause) {
1068
1182
  // gf params pushed AFTER filter params (collect mirrors this order), but
1069
1183
  // placed textually inside the domain so it restricts which rows count.
1070
1184
  const gf = gfAnd();
1071
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
1185
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
1072
1186
  }
1073
1187
  else {
1074
1188
  // "every" with empty filter = true (all match trivially), gf irrelevant.
@@ -1078,26 +1192,26 @@ depth = 0) {
1078
1192
  // `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
1079
1193
  if (filterObj.is !== undefined) {
1080
1194
  if (filterObj.is === null) {
1081
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
1195
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${gfAnd()})`);
1082
1196
  }
1083
1197
  else {
1084
1198
  const subWhere = filterObj.is;
1085
- const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
1199
+ const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
1086
1200
  const filterAnd = filterClause ? ` AND ${filterClause}` : '';
1087
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1201
+ clauses.push(`EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1088
1202
  }
1089
1203
  }
1090
1204
  // "isNot": NOT EXISTS, for to-one relations (same SQL as "none").
1091
1205
  // `isNot: null` = "a related row exists" → EXISTS.
1092
1206
  if (filterObj.isNot !== undefined) {
1093
1207
  if (filterObj.isNot === null) {
1094
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
1208
+ clauses.push(`EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${gfAnd()})`);
1095
1209
  }
1096
1210
  else {
1097
1211
  const subWhere = filterObj.isNot;
1098
- const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
1212
+ const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
1099
1213
  const filterAnd = filterClause ? ` AND ${filterClause}` : '';
1100
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1214
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1101
1215
  }
1102
1216
  }
1103
1217
  return clauses.length > 0 ? clauses.join(' AND ') : null;
@@ -1106,11 +1220,11 @@ depth = 0) {
1106
1220
  * Build WHERE clause conditions for a relation filter subquery.
1107
1221
  * Uses the target table's column mapping to resolve field names.
1108
1222
  */
1109
- function buildSubWhereForRelation(qi, targetTable, subWhere, params, depth = 0) {
1223
+ function buildSubWhereForRelation(qi, targetTable, targetRef, subWhere, params, depth = 0) {
1110
1224
  const meta = qi.schema.tables[targetTable];
1111
1225
  if (!meta)
1112
1226
  return null;
1113
- return buildScopedWhere(qi, relationWhereScope(qi, targetTable, meta), subWhere, params, depth);
1227
+ return buildScopedWhere(qi, relationWhereScope(qi, targetTable, meta, targetRef), subWhere, params, depth);
1114
1228
  }
1115
1229
  /**
1116
1230
  * Resolve a column's Postgres type from an arbitrary table's metadata
@@ -1283,6 +1397,7 @@ function buildOperatorClauses(qi, column, op, params, refCtx) {
1283
1397
  // Temporal bind rewrite, identical to `collectOperatorParams`. Value-only, so
1284
1398
  // the emitted SQL (and therefore the template cache) is untouched.
1285
1399
  const cv = (v) => (refCtx ? coerceWhereOperand(qi, refCtx.meta, refCtx.rawColumn, v) : v);
1400
+ const insensitive = op.mode === 'insensitive';
1286
1401
  if (op.equals !== undefined) {
1287
1402
  if (op.equals === null) {
1288
1403
  clauses.push(`${column} IS NULL`);
@@ -1292,8 +1407,10 @@ function buildOperatorClauses(qi, column, op, params, refCtx) {
1292
1407
  }
1293
1408
  else {
1294
1409
  (0, filters_js_1.assertBindableEqualsOperand)(op.equals, column);
1410
+ if (insensitive)
1411
+ assertInsensitiveOperand(op.equals, 'equals', column);
1295
1412
  params.push(cv(op.equals));
1296
- clauses.push(`${column} = ${qi.p(params.length)}`);
1413
+ clauses.push(insensitiveEquality(column, '=', qi.p(params.length), insensitive));
1297
1414
  }
1298
1415
  }
1299
1416
  if (op.gt !== undefined) {
@@ -1340,19 +1457,32 @@ function buildOperatorClauses(qi, column, op, params, refCtx) {
1340
1457
  clauses.push(`${column} != ${columnRefSql(qi, op.not, refCtx, op.mode)}`);
1341
1458
  }
1342
1459
  else {
1460
+ if (insensitive)
1461
+ assertInsensitiveOperand(op.not, 'not', column);
1343
1462
  params.push(cv(op.not));
1344
- clauses.push(`${column} != ${qi.p(params.length)}`);
1463
+ clauses.push(insensitiveEquality(column, '!=', qi.p(params.length), insensitive));
1345
1464
  }
1346
1465
  }
1347
1466
  if (op.in !== undefined) {
1467
+ if (insensitive) {
1468
+ assertInsensitiveOperand(op.in, 'in', column);
1469
+ requireInsensitiveList(qi, column, 'in');
1470
+ }
1348
1471
  params.push(qi.inParam(cv(op.in)));
1349
- clauses.push(qi.inClause(column, qi.p(params.length), false));
1472
+ clauses.push(insensitive
1473
+ ? insensitiveInClause(qi, column, qi.p(params.length), false, 'in')
1474
+ : qi.inClause(column, qi.p(params.length), false));
1350
1475
  }
1351
1476
  if (op.notIn !== undefined) {
1477
+ if (insensitive) {
1478
+ assertInsensitiveOperand(op.notIn, 'notIn', column);
1479
+ requireInsensitiveList(qi, column, 'notIn');
1480
+ }
1352
1481
  params.push(qi.inParam(cv(op.notIn)));
1353
- clauses.push(qi.inClause(column, qi.p(params.length), true));
1482
+ clauses.push(insensitive
1483
+ ? insensitiveInClause(qi, column, qi.p(params.length), true, 'notIn')
1484
+ : qi.inClause(column, qi.p(params.length), true));
1354
1485
  }
1355
- const insensitive = op.mode === 'insensitive';
1356
1486
  if (op.contains !== undefined) {
1357
1487
  params.push(`%${likeOperand(qi, op.contains)}%`);
1358
1488
  clauses.push(buildLikeClause(qi, column, qi.p(params.length), insensitive));
@@ -1547,6 +1677,91 @@ function buildLikeClause(qi, column, paramRef, insensitive) {
1547
1677
  const base = insensitive ? qi.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
1548
1678
  return `${base} ESCAPE '\\'`;
1549
1679
  }
1680
+ /**
1681
+ * One equality-family comparison (`=` / `!=`) honoring `mode: 'insensitive'`.
1682
+ *
1683
+ * `mode` is a sibling key of `equals` / `not` / `in` / `notIn` on
1684
+ * {@link WhereOperator}, and it used to be read by the three LIKE operators
1685
+ * ONLY, so `{ equals: 'dup name', mode: 'insensitive' }` compiled to a
1686
+ * case-sensitive `"name" = $1` and returned zero rows where `ILIKE` found the
1687
+ * variants, with no error. The fold is `LOWER(col) = LOWER($n)`: exact,
1688
+ * portable to every engine, and computed by the DATABASE on both sides, because
1689
+ * the JavaScript `toLowerCase()` and PostgreSQL's `LOWER` disagree on `İ` and
1690
+ * `ß`, so folding the operand client-side would compare two different
1691
+ * alphabets. Without the mode the emitted text is byte-identical to before.
1692
+ */
1693
+ function insensitiveEquality(column, operator, paramRef, insensitive) {
1694
+ return insensitive ? `LOWER(${column}) ${operator} LOWER(${paramRef})` : `${column} ${operator} ${paramRef}`;
1695
+ }
1696
+ /**
1697
+ * The `in` / `notIn` comparison under `mode: 'insensitive'`.
1698
+ *
1699
+ * The fold is the engine's on BOTH sides: the column is lowered by `LOWER`,
1700
+ * and so is every list element, inside `unnest`. The list stays ONE bound
1701
+ * `text[]`, so the statement text (and therefore its cache key) is independent
1702
+ * of the list length. `NOT IN` keeps SQL's NULL semantics, matching the plain
1703
+ * `!= ALL`.
1704
+ *
1705
+ * PostgreSQL only; {@link requireInsensitiveList} is the gate, and it runs on
1706
+ * the param path as well so a warm template cannot slip past it.
1707
+ */
1708
+ function insensitiveInClause(qi, column, paramRef, negated, operator) {
1709
+ requireInsensitiveList(qi, column, operator);
1710
+ return `LOWER(${column}) ${negated ? 'NOT IN' : 'IN'} (SELECT LOWER(v) FROM unnest(${paramRef}::text[]) AS v(v))`;
1711
+ }
1712
+ /**
1713
+ * Gate `mode: 'insensitive'` on `in` / `notIn` to PostgreSQL.
1714
+ *
1715
+ * The property the operator has to hold is that the COLUMN and the LIST
1716
+ * ELEMENTS are folded by the SAME function. Break that and `equals` and `in`
1717
+ * answer the same operand with DIFFERENT rows on the same engine, which is a
1718
+ * wrong row and not an error.
1719
+ *
1720
+ * The first cut broke it: the column went through the engine's `LOWER` while
1721
+ * the elements went through JavaScript's `toLowerCase()`. Those are two
1722
+ * alphabets. SQLite's `LOWER` is ASCII-only, so over the rows `CAFÉ` and
1723
+ * `café` the operand `'CAFÉ'` matched `CAFÉ` through `equals` and `café`
1724
+ * through `in` - not an exotic-codepoint edge case but every non-ASCII letter
1725
+ * on that engine.
1726
+ *
1727
+ * Folding the elements in SQL instead is not reachable from here for the other
1728
+ * dialects: each unpacks a bound IN list in a subquery of its OWN shape
1729
+ * (`json_each` / `JSON_TABLE` / `OPENJSON`) whose projection is written by the
1730
+ * dialect, not by this module, and the length-independence the SQL-template
1731
+ * cache depends on rules out the other portable form, one placeholder per
1732
+ * element. Restoring the operator on those engines means a dialect hook that
1733
+ * projects the folded element, one correct implementation per engine. Until
1734
+ * then a typed refusal is the honest outcome. `equals`, `not` and the LIKE
1735
+ * operators are unaffected: they fold a single operand the engine can reach.
1736
+ *
1737
+ * Called from BOTH the build and the cache-hit param-collect path, mirroring
1738
+ * every other gate in this module.
1739
+ */
1740
+ function requireInsensitiveList(qi, column, operator) {
1741
+ if (qi.dialect.name === 'postgresql')
1742
+ return;
1743
+ throw new errors_js_1.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 " +
1744
+ 'IN-list form cannot express. Lowering them in JavaScript instead would fold the list and the ' +
1745
+ `column by two different functions, so \`equals\` and \`${operator}\` would return different rows ` +
1746
+ 'for the same operand. Write the list as branches: ' +
1747
+ "`{ OR: [{ field: { equals: a, mode: 'insensitive' } }, ...] }`.");
1748
+ }
1749
+ /**
1750
+ * Refuse `mode: 'insensitive'` beside a non-string equality operand (E003).
1751
+ * `LOWER(5)` is a type error the engine would raise in its own words on the
1752
+ * build path and, worse, a warm template would bind the value without ever
1753
+ * reaching the engine's check for the SQL it was compiled against, so the
1754
+ * refusal runs on both the build and the collect side. The value itself stays
1755
+ * out of the message (safe error mode renders keys, never values).
1756
+ */
1757
+ function assertInsensitiveOperand(value, operator, column) {
1758
+ const ok = Array.isArray(value) ? value.every((v) => typeof v === 'string') : typeof value === 'string';
1759
+ if (ok)
1760
+ return;
1761
+ throw new errors_js_1.ValidationError(`mode: 'insensitive' on ${column} requires a string operand for '${operator}' ` +
1762
+ `(received ${Array.isArray(value) ? 'a list with a non-string element' : typeof value}). ` +
1763
+ `Case folding applies to text; drop the mode or compare a string.`);
1764
+ }
1550
1765
  /**
1551
1766
  * Refuse a {@link JsonFilter} carrying a key that is not a JSON operator, and
1552
1767
  * 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