turbine-orm 0.81.0 → 0.90.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 (295) hide show
  1. package/README.md +26 -25
  2. package/dist/checkout.d.ts +27 -2
  3. package/dist/checkout.js +75 -4
  4. package/dist/cjs/checkout.d.ts +27 -2
  5. package/dist/cjs/checkout.js +76 -3
  6. package/dist/cjs/cli/code-first-tags.d.ts +62 -0
  7. package/dist/cjs/cli/code-first-tags.js +124 -0
  8. package/dist/cjs/cli/compile-query.js +9 -0
  9. package/dist/cjs/cli/config.d.ts +35 -6
  10. package/dist/cjs/cli/config.js +17 -1
  11. package/dist/cjs/cli/destructive.d.ts +7 -4
  12. package/dist/cjs/cli/destructive.js +224 -20
  13. package/dist/cjs/cli/error-catalog.js +36 -0
  14. package/dist/cjs/cli/error-text.d.ts +15 -0
  15. package/dist/cjs/cli/error-text.js +40 -0
  16. package/dist/cjs/cli/index.d.ts +62 -4
  17. package/dist/cjs/cli/index.js +568 -108
  18. package/dist/cjs/cli/local-server-guard.d.ts +53 -0
  19. package/dist/cjs/cli/local-server-guard.js +119 -0
  20. package/dist/cjs/cli/local-server-output.d.ts +41 -0
  21. package/dist/cjs/cli/local-server-output.js +103 -0
  22. package/dist/cjs/cli/mcp.d.ts +8 -0
  23. package/dist/cjs/cli/mcp.js +121 -32
  24. package/dist/cjs/cli/migrate.d.ts +113 -5
  25. package/dist/cjs/cli/migrate.js +270 -21
  26. package/dist/cjs/cli/observe.d.ts +6 -1
  27. package/dist/cjs/cli/observe.js +85 -55
  28. package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
  29. package/dist/cjs/cli/pii-predicate-guard.js +98 -37
  30. package/dist/cjs/cli/prisma-resolve.js +17 -13
  31. package/dist/cjs/cli/prisma-schema.js +12 -4
  32. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  33. package/dist/cjs/cli/studio.d.ts +14 -2
  34. package/dist/cjs/cli/studio.js +244 -69
  35. package/dist/cjs/cli/ui.d.ts +1 -1
  36. package/dist/cjs/client.d.ts +558 -85
  37. package/dist/cjs/client.js +1270 -395
  38. package/dist/cjs/connection-guard.d.ts +13 -0
  39. package/dist/cjs/connection-guard.js +28 -0
  40. package/dist/cjs/connection-url.d.ts +51 -16
  41. package/dist/cjs/connection-url.js +67 -34
  42. package/dist/cjs/diagnostics.d.ts +81 -0
  43. package/dist/cjs/diagnostics.js +147 -0
  44. package/dist/cjs/dialect.d.ts +449 -20
  45. package/dist/cjs/dialect.js +117 -76
  46. package/dist/cjs/engine-config.d.ts +7 -6
  47. package/dist/cjs/errors.d.ts +126 -6
  48. package/dist/cjs/errors.js +256 -13
  49. package/dist/cjs/generate.d.ts +57 -7
  50. package/dist/cjs/generate.js +635 -279
  51. package/dist/cjs/global-filters-config.d.ts +59 -0
  52. package/dist/cjs/global-filters-config.js +367 -0
  53. package/dist/cjs/index.d.ts +9 -4
  54. package/dist/cjs/index.js +10 -2
  55. package/dist/cjs/introspect.d.ts +31 -6
  56. package/dist/cjs/introspect.js +108 -50
  57. package/dist/cjs/log-codes.d.ts +12 -0
  58. package/dist/cjs/log-codes.js +77 -0
  59. package/dist/cjs/logger.d.ts +87 -0
  60. package/dist/cjs/logger.js +98 -0
  61. package/dist/cjs/mssql.d.ts +8 -5
  62. package/dist/cjs/mssql.js +352 -65
  63. package/dist/cjs/mysql.d.ts +3 -5
  64. package/dist/cjs/mysql.js +420 -70
  65. package/dist/cjs/nested-write.d.ts +3 -0
  66. package/dist/cjs/nested-write.js +179 -114
  67. package/dist/cjs/pipeline-submittable.d.ts +12 -0
  68. package/dist/cjs/pipeline-submittable.js +118 -44
  69. package/dist/cjs/pipeline.js +21 -26
  70. package/dist/cjs/powdb-introspect.d.ts +5 -2
  71. package/dist/cjs/powdb-introspect.js +20 -15
  72. package/dist/cjs/powdb-shared.d.ts +36 -5
  73. package/dist/cjs/powdb-shared.js +67 -8
  74. package/dist/cjs/powdb.d.ts +4 -0
  75. package/dist/cjs/powdb.js +183 -51
  76. package/dist/cjs/powql.d.ts +394 -51
  77. package/dist/cjs/powql.js +1829 -284
  78. package/dist/cjs/prisma-compat.d.ts +23 -15
  79. package/dist/cjs/prisma-compat.js +507 -372
  80. package/dist/cjs/query/aggregates.d.ts +17 -1
  81. package/dist/cjs/query/aggregates.js +261 -29
  82. package/dist/cjs/query/batched-loader.d.ts +25 -6
  83. package/dist/cjs/query/batched-loader.js +103 -20
  84. package/dist/cjs/query/builder.d.ts +325 -184
  85. package/dist/cjs/query/builder.js +1452 -490
  86. package/dist/cjs/query/column-ref.d.ts +61 -0
  87. package/dist/cjs/query/column-ref.js +73 -0
  88. package/dist/cjs/query/compound-unique.d.ts +21 -0
  89. package/dist/cjs/query/compound-unique.js +98 -18
  90. package/dist/cjs/query/deferred.d.ts +53 -16
  91. package/dist/cjs/query/expr.d.ts +217 -0
  92. package/dist/cjs/query/expr.js +859 -0
  93. package/dist/cjs/query/filters.d.ts +155 -53
  94. package/dist/cjs/query/filters.js +613 -76
  95. package/dist/cjs/query/index.d.ts +2 -0
  96. package/dist/cjs/query/index.js +7 -1
  97. package/dist/cjs/query/json-value.d.ts +43 -0
  98. package/dist/cjs/query/json-value.js +59 -0
  99. package/dist/cjs/query/normalize-args.d.ts +87 -0
  100. package/dist/cjs/query/normalize-args.js +527 -0
  101. package/dist/cjs/query/option-surface.d.ts +16 -4
  102. package/dist/cjs/query/option-surface.js +57 -14
  103. package/dist/cjs/query/relation-names.d.ts +14 -0
  104. package/dist/cjs/query/relation-names.js +77 -18
  105. package/dist/cjs/query/relations.d.ts +30 -3
  106. package/dist/cjs/query/relations.js +219 -63
  107. package/dist/cjs/query/row-lock.d.ts +49 -0
  108. package/dist/cjs/query/row-lock.js +138 -0
  109. package/dist/cjs/query/sql-comment.d.ts +161 -0
  110. package/dist/cjs/query/sql-comment.js +381 -0
  111. package/dist/cjs/query/stream-idle.d.ts +132 -0
  112. package/dist/cjs/query/stream-idle.js +212 -0
  113. package/dist/cjs/query/table-client.d.ts +101 -0
  114. package/dist/cjs/query/table-client.js +74 -0
  115. package/dist/cjs/query/types.d.ts +677 -298
  116. package/dist/cjs/query/utils.d.ts +105 -6
  117. package/dist/cjs/query/utils.js +426 -19
  118. package/dist/cjs/query/warn-registry.d.ts +18 -0
  119. package/dist/cjs/query/warn-registry.js +18 -0
  120. package/dist/cjs/query/where-compile.d.ts +138 -14
  121. package/dist/cjs/query/where-compile.js +513 -29
  122. package/dist/cjs/query/where.d.ts +159 -59
  123. package/dist/cjs/query/where.js +716 -246
  124. package/dist/cjs/query/writes.d.ts +112 -50
  125. package/dist/cjs/query/writes.js +1498 -200
  126. package/dist/cjs/query-events.d.ts +4 -2
  127. package/dist/cjs/query-events.js +3 -1
  128. package/dist/cjs/realtime.d.ts +7 -3
  129. package/dist/cjs/realtime.js +13 -2
  130. package/dist/cjs/schema-builder.d.ts +55 -4
  131. package/dist/cjs/schema-builder.js +382 -18
  132. package/dist/cjs/schema-metadata.d.ts +21 -1
  133. package/dist/cjs/schema-metadata.js +29 -7
  134. package/dist/cjs/schema-sql.d.ts +75 -5
  135. package/dist/cjs/schema-sql.js +497 -45
  136. package/dist/cjs/schema.d.ts +26 -5
  137. package/dist/cjs/schema.js +110 -13
  138. package/dist/cjs/sql-placeholders.d.ts +56 -0
  139. package/dist/cjs/sql-placeholders.js +204 -0
  140. package/dist/cjs/sqlite.d.ts +75 -20
  141. package/dist/cjs/sqlite.js +593 -95
  142. package/dist/cjs/supplied-pool-types.d.ts +48 -0
  143. package/dist/cjs/supplied-pool-types.js +256 -0
  144. package/dist/cjs/transaction-control.d.ts +51 -0
  145. package/dist/cjs/transaction-control.js +316 -0
  146. package/dist/cjs/transaction-state.d.ts +342 -0
  147. package/dist/cjs/transaction-state.js +707 -0
  148. package/dist/cjs/typed-sql.d.ts +37 -3
  149. package/dist/cjs/typed-sql.js +102 -8
  150. package/dist/cli/code-first-tags.d.ts +62 -0
  151. package/dist/cli/code-first-tags.js +120 -0
  152. package/dist/cli/compile-query.js +9 -0
  153. package/dist/cli/config.d.ts +35 -6
  154. package/dist/cli/config.js +16 -1
  155. package/dist/cli/destructive.d.ts +7 -4
  156. package/dist/cli/destructive.js +224 -20
  157. package/dist/cli/error-catalog.js +36 -0
  158. package/dist/cli/error-text.d.ts +15 -0
  159. package/dist/cli/error-text.js +37 -0
  160. package/dist/cli/index.d.ts +62 -4
  161. package/dist/cli/index.js +569 -112
  162. package/dist/cli/local-server-guard.d.ts +53 -0
  163. package/dist/cli/local-server-guard.js +113 -0
  164. package/dist/cli/local-server-output.d.ts +41 -0
  165. package/dist/cli/local-server-output.js +98 -0
  166. package/dist/cli/mcp.d.ts +8 -0
  167. package/dist/cli/mcp.js +121 -34
  168. package/dist/cli/migrate.d.ts +113 -5
  169. package/dist/cli/migrate.js +261 -21
  170. package/dist/cli/observe.d.ts +6 -1
  171. package/dist/cli/observe.js +84 -54
  172. package/dist/cli/pii-predicate-guard.d.ts +43 -23
  173. package/dist/cli/pii-predicate-guard.js +100 -35
  174. package/dist/cli/prisma-resolve.js +15 -11
  175. package/dist/cli/prisma-schema.js +12 -4
  176. package/dist/cli/studio-ui.generated.js +1 -1
  177. package/dist/cli/studio.d.ts +14 -2
  178. package/dist/cli/studio.js +246 -71
  179. package/dist/cli/ui.d.ts +1 -1
  180. package/dist/client.d.ts +558 -85
  181. package/dist/client.js +1275 -400
  182. package/dist/connection-guard.d.ts +13 -0
  183. package/dist/connection-guard.js +27 -0
  184. package/dist/connection-url.d.ts +51 -16
  185. package/dist/connection-url.js +63 -34
  186. package/dist/diagnostics.d.ts +81 -0
  187. package/dist/diagnostics.js +144 -0
  188. package/dist/dialect.d.ts +449 -20
  189. package/dist/dialect.js +115 -43
  190. package/dist/engine-config.d.ts +7 -6
  191. package/dist/errors.d.ts +126 -6
  192. package/dist/errors.js +252 -12
  193. package/dist/generate.d.ts +57 -7
  194. package/dist/generate.js +635 -281
  195. package/dist/global-filters-config.d.ts +59 -0
  196. package/dist/global-filters-config.js +361 -0
  197. package/dist/index.d.ts +9 -4
  198. package/dist/index.js +4 -2
  199. package/dist/introspect.d.ts +31 -6
  200. package/dist/introspect.js +106 -50
  201. package/dist/log-codes.d.ts +12 -0
  202. package/dist/log-codes.js +74 -0
  203. package/dist/logger.d.ts +87 -0
  204. package/dist/logger.js +93 -0
  205. package/dist/mssql.d.ts +8 -5
  206. package/dist/mssql.js +354 -67
  207. package/dist/mysql.d.ts +3 -5
  208. package/dist/mysql.js +423 -73
  209. package/dist/nested-write.d.ts +3 -0
  210. package/dist/nested-write.js +177 -112
  211. package/dist/pipeline-submittable.d.ts +12 -0
  212. package/dist/pipeline-submittable.js +117 -44
  213. package/dist/pipeline.js +21 -26
  214. package/dist/powdb-introspect.d.ts +5 -2
  215. package/dist/powdb-introspect.js +21 -16
  216. package/dist/powdb-shared.d.ts +36 -5
  217. package/dist/powdb-shared.js +65 -8
  218. package/dist/powdb.d.ts +4 -0
  219. package/dist/powdb.js +183 -51
  220. package/dist/powql.d.ts +394 -51
  221. package/dist/powql.js +1836 -291
  222. package/dist/prisma-compat.d.ts +23 -15
  223. package/dist/prisma-compat.js +508 -373
  224. package/dist/query/aggregates.d.ts +17 -1
  225. package/dist/query/aggregates.js +263 -32
  226. package/dist/query/batched-loader.d.ts +25 -6
  227. package/dist/query/batched-loader.js +101 -21
  228. package/dist/query/builder.d.ts +325 -184
  229. package/dist/query/builder.js +1458 -495
  230. package/dist/query/column-ref.d.ts +61 -0
  231. package/dist/query/column-ref.js +66 -0
  232. package/dist/query/compound-unique.d.ts +21 -0
  233. package/dist/query/compound-unique.js +97 -19
  234. package/dist/query/deferred.d.ts +53 -16
  235. package/dist/query/expr.d.ts +217 -0
  236. package/dist/query/expr.js +848 -0
  237. package/dist/query/filters.d.ts +155 -53
  238. package/dist/query/filters.js +597 -72
  239. package/dist/query/index.d.ts +2 -0
  240. package/dist/query/index.js +4 -0
  241. package/dist/query/json-value.d.ts +43 -0
  242. package/dist/query/json-value.js +54 -0
  243. package/dist/query/normalize-args.d.ts +87 -0
  244. package/dist/query/normalize-args.js +517 -0
  245. package/dist/query/option-surface.d.ts +16 -4
  246. package/dist/query/option-surface.js +56 -13
  247. package/dist/query/relation-names.d.ts +14 -0
  248. package/dist/query/relation-names.js +77 -19
  249. package/dist/query/relations.d.ts +30 -3
  250. package/dist/query/relations.js +220 -67
  251. package/dist/query/row-lock.d.ts +49 -0
  252. package/dist/query/row-lock.js +131 -0
  253. package/dist/query/sql-comment.d.ts +161 -0
  254. package/dist/query/sql-comment.js +374 -0
  255. package/dist/query/stream-idle.d.ts +132 -0
  256. package/dist/query/stream-idle.js +203 -0
  257. package/dist/query/table-client.d.ts +101 -0
  258. package/dist/query/table-client.js +70 -0
  259. package/dist/query/types.d.ts +677 -298
  260. package/dist/query/utils.d.ts +105 -6
  261. package/dist/query/utils.js +414 -18
  262. package/dist/query/warn-registry.d.ts +18 -0
  263. package/dist/query/warn-registry.js +18 -0
  264. package/dist/query/where-compile.d.ts +138 -14
  265. package/dist/query/where-compile.js +508 -31
  266. package/dist/query/where.d.ts +159 -59
  267. package/dist/query/where.js +711 -248
  268. package/dist/query/writes.d.ts +112 -50
  269. package/dist/query/writes.js +1496 -202
  270. package/dist/query-events.d.ts +4 -2
  271. package/dist/query-events.js +3 -1
  272. package/dist/realtime.d.ts +7 -3
  273. package/dist/realtime.js +13 -2
  274. package/dist/schema-builder.d.ts +55 -4
  275. package/dist/schema-builder.js +381 -18
  276. package/dist/schema-metadata.d.ts +21 -1
  277. package/dist/schema-metadata.js +28 -7
  278. package/dist/schema-sql.d.ts +75 -5
  279. package/dist/schema-sql.js +494 -44
  280. package/dist/schema.d.ts +26 -5
  281. package/dist/schema.js +109 -13
  282. package/dist/sql-placeholders.d.ts +56 -0
  283. package/dist/sql-placeholders.js +200 -0
  284. package/dist/sqlite.d.ts +75 -20
  285. package/dist/sqlite.js +597 -99
  286. package/dist/supplied-pool-types.d.ts +48 -0
  287. package/dist/supplied-pool-types.js +247 -0
  288. package/dist/transaction-control.d.ts +51 -0
  289. package/dist/transaction-control.js +311 -0
  290. package/dist/transaction-state.d.ts +342 -0
  291. package/dist/transaction-state.js +696 -0
  292. package/dist/typed-sql.d.ts +37 -3
  293. package/dist/typed-sql.js +100 -8
  294. package/package.json +7 -4
  295. package/skills/turbine-orm/SKILL.md +56 -6
@@ -10,11 +10,29 @@
10
10
  * instance (built once in its constructor) exposing exactly the class-resident
11
11
  * primitives this module needs. See builder.ts for the thin delegating methods.
12
12
  */
13
- import { getErrorMessageMode, UnsupportedFeatureError, ValidationError } from '../errors.js';
14
- import { camelToSnake, normalizeKeyColumns } from '../schema.js';
15
- import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, isArrayFilter, isColumnRef, isJsonFilter, isUnmatchedPlainObject, isWhereOperator, JSON_FILTER_KEYS, JSON_RANGE_OPERATORS, JSON_STRING_OPERATORS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
16
- import { coerceTemporalValue, escapeLike, isInternalCombinator, markInternalCombinator, OPERATOR_KEYS, ownLookup, resolveColumnName, } from './utils.js';
17
- import { assertWhereDepth, classifyScalarForSql, fingerprintScalarToken, walkWhere, } from './where-compile.js';
13
+ import { resolveDiagnostics } from '../diagnostics.js';
14
+ import { getErrorMessageMode, InvariantError, UnsupportedFeatureError, ValidationError } from '../errors.js';
15
+ import { assertSkipListNames, skipListNamesTable } from '../global-filters-config.js';
16
+ import { consoleLogger } from '../logger.js';
17
+ import { camelToSnake, localDateTimeKind, normalizeKeyColumns } from '../schema.js';
18
+ import { assertArrayFilter, assertEqualityOperand, assertJsonFilterKeys, assertOperatorOperands, findArrayUniqueKey, findJsonUniqueKey, findUndefinedPath, holdsUndefinedValue, isArrayFilter, isColumnRef, isJsonFilter, isPlainRecord, isWhereOperator, JSON_RANGE_OPERATORS, JSON_STRING_OPERATORS, operandColumnKind, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
19
+ import { coerceTemporalValue, escapeLike, isInternalCombinator, markInternalCombinator, ownLookup, resolveColumnName, } from './utils.js';
20
+ import { assertWhereDepth, classifyScalarForSql, fingerprintScalarToken, foldWhere, normalizeToOneRelationFilter, walkWhere, } from './where-compile.js';
21
+ // The JSON filter key check lives with the other operand checks in filters.ts;
22
+ // re-exported so the engines keep one import site for it.
23
+ export { assertJsonFilterKeys } from './filters.js';
24
+ /**
25
+ * Where per-query advice goes for this context: the client's logger when its
26
+ * `diagnostics.advice` is on, else `undefined`. A site calls this first and
27
+ * builds its message only on a logger.
28
+ *
29
+ * A context without the client's diagnostics (one built by hand) resolves
30
+ * them here; every context a QueryInterface builds carries them, so on a query
31
+ * path this never reads the environment.
32
+ */
33
+ export function adviceLogger(qi) {
34
+ return (qi.diagnostics ?? resolveDiagnostics()).advice ? (qi.logger ?? consoleLogger) : undefined;
35
+ }
18
36
  /**
19
37
  * LIKE-escape one bound operand through the ACTIVE dialect, falling back to the
20
38
  * shared {@link escapeLike} (`\`, `%`, `_`) when the dialect declares no
@@ -50,6 +68,21 @@ function likeOperand(qi, value) {
50
68
  function valueForMessage(value) {
51
69
  return getErrorMessageMode() === 'verbose' ? JSON.stringify(value) : '<value>';
52
70
  }
71
+ /** The operand-validation context for an operator object on `refCtx`'s column. */
72
+ function operandContext(qi, column, refCtx) {
73
+ if (!refCtx)
74
+ return { column, table: qi.table, kind: 'scalar' };
75
+ return { column: refCtx.rawColumn, table: refCtx.table, kind: operandColumnKind(refCtx.pgType) };
76
+ }
77
+ /**
78
+ * The inner operator object of `not: { ... }`, with this object's `mode`
79
+ * inherited when the inner one sets none. The fingerprint reads the same
80
+ * effective mode (`fingerprintOperatorShape`).
81
+ */
82
+ function notOperand(op) {
83
+ const inner = op.not;
84
+ return inner.mode === undefined && op.mode !== undefined ? { ...inner, mode: op.mode } : inner;
85
+ }
53
86
  /**
54
87
  * Produce a value-invariant fingerprint of a where clause.
55
88
  * Same keys + same operator shapes + same combinator structure => same string.
@@ -86,25 +119,20 @@ export function fingerprintWhere(qi, where, depth = 0) {
86
119
  }
87
120
  /**
88
121
  * Fingerprint the present branches of a normalized relation filter, in the
89
- * fixed order some→every→none→is→isNot. A `null` branch tokenizes as
90
- * `<branch>(null)`; a present branch recurses through
122
+ * fixed order some→every→none→is→isNot. A present branch recurses through
91
123
  * {@link fingerprintRelFilter} so the FULL inner shape is captured (two
92
- * different sub-wheres must never collide on one cached SQL text).
124
+ * different sub-wheres must never collide on one cached SQL text). `is: null`
125
+ * and `isNot: null` tokenize as `<branch>(null)`; the where walk refuses any
126
+ * other non-object value before this runs.
93
127
  */
94
128
  export function fingerprintRelationParts(qi, relDef, filterObj, depth = 0) {
95
129
  const relParts = [];
96
130
  if (filterObj.some !== undefined)
97
- relParts.push(filterObj.some === null
98
- ? 'some(null)'
99
- : `some(${fingerprintRelFilter(qi, relDef.to, filterObj.some, depth + 1)})`);
131
+ relParts.push(`some(${fingerprintRelFilter(qi, relDef.to, filterObj.some, depth + 1)})`);
100
132
  if (filterObj.every !== undefined)
101
- relParts.push(filterObj.every === null
102
- ? 'every(null)'
103
- : `every(${fingerprintRelFilter(qi, relDef.to, filterObj.every, depth + 1)})`);
133
+ relParts.push(`every(${fingerprintRelFilter(qi, relDef.to, filterObj.every, depth + 1)})`);
104
134
  if (filterObj.none !== undefined)
105
- relParts.push(filterObj.none === null
106
- ? 'none(null)'
107
- : `none(${fingerprintRelFilter(qi, relDef.to, filterObj.none, depth + 1)})`);
135
+ relParts.push(`none(${fingerprintRelFilter(qi, relDef.to, filterObj.none, depth + 1)})`);
108
136
  if (filterObj.is !== undefined)
109
137
  relParts.push(filterObj.is === null
110
138
  ? 'is(null)'
@@ -129,32 +157,43 @@ export function fingerprintRelFilter(qi, targetTable, subWhere, depth = 0) {
129
157
  /**
130
158
  * Walk a where clause and push ONLY values into `params`, in the EXACT same
131
159
  * order that `buildWhereClause` pushes them. Used on cache hit to fill params
132
- * without rebuilding SQL.
160
+ * without rebuilding SQL. Returns whether the clause compiles to a condition,
161
+ * i.e. whether `buildWhereClause` returns non-null for it.
133
162
  *
134
163
  * @internal Exposed as package-private for testing.
135
164
  */
136
165
  export function collectWhereParams(qi, where, params, depth = 0) {
137
166
  assertWhereDepth(depth);
167
+ let emitted = false;
138
168
  // ONE canonical walk (shared with fingerprintWhere + buildWhereClause), so
139
169
  // the key order + combinator structure cannot drift out of lockstep.
140
170
  for (const event of walkWhere(qi.whereHost, where)) {
141
171
  switch (event.kind) {
142
172
  case 'or':
173
+ collectOrParams(event.conditions, params, (cond) => collectWhereParams(qi, cond, params, depth + 1));
174
+ emitted = true;
175
+ break;
143
176
  case 'and':
144
- for (const cond of event.conditions)
145
- collectWhereParams(qi, cond, params, depth + 1);
177
+ for (const cond of event.conditions) {
178
+ if (collectWhereParams(qi, cond, params, depth + 1))
179
+ emitted = true;
180
+ }
146
181
  break;
147
182
  case 'not':
148
- collectWhereParams(qi, event.condition, params, depth + 1);
183
+ if (collectWhereParams(qi, event.condition, params, depth + 1))
184
+ emitted = true;
149
185
  break;
150
186
  case 'relation':
151
- collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth + 1);
187
+ if (collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth + 1))
188
+ emitted = true;
152
189
  break;
153
190
  case 'scalar':
154
191
  collectScalarParams(qi, event.key, event.value, params);
192
+ emitted = true;
155
193
  break;
156
194
  }
157
195
  }
196
+ return emitted;
158
197
  }
159
198
  /**
160
199
  * Push a scalar WHERE value's params, mirroring {@link buildScalarClause}'s
@@ -182,7 +221,7 @@ export function collectScalarParams(qi, key, value, params) {
182
221
  collectJsonFilterParams(qi, value, params, qi.q(rawColumn));
183
222
  return;
184
223
  case 'array':
185
- collectArrayFilterParams(qi, value, params);
224
+ collectArrayFilterParams(qi, value, params, qi.q(rawColumn));
186
225
  return;
187
226
  case 'textsearch':
188
227
  // Same gate the build path applies, so the collect path never diverges
@@ -196,15 +235,22 @@ export function collectScalarParams(qi, key, value, params) {
196
235
  table: qi.table,
197
236
  prefix: '',
198
237
  rawColumn,
238
+ pgType: getColumnPgType(qi, rawColumn),
199
239
  });
200
240
  return;
201
- default:
241
+ default: {
202
242
  // 'equality' | 'jsonThrow' | 'arrayThrow': same strict validation as
203
243
  // the build path, so a cache hit can never silently bind a
204
244
  // misspelled-operator object.
205
- assertBindableEqualityValue(qi, rawColumn, value, getColumnPgType(qi, rawColumn), qi.table);
245
+ const pgType = getColumnPgType(qi, rawColumn);
246
+ assertBindableEqualityValue(qi, rawColumn, value, pgType, qi.table);
247
+ if (isBareJsonDocument(qi, pgType, value)) {
248
+ jsonDocumentEquals(qi, '', rawColumn, value, collectOnly(params));
249
+ return;
250
+ }
206
251
  params.push(coerceWhereOperand(qi, qi.tableMeta, rawColumn, value));
207
252
  return;
253
+ }
208
254
  }
209
255
  }
210
256
  /**
@@ -216,52 +262,69 @@ export function collectScalarParams(qi, key, value, params) {
216
262
  * the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
217
263
  * Shared by every collect site that mirrors buildRelationFilter
218
264
  * (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
265
+ * Returns whether {@link buildRelationFilter} emits a clause for it.
219
266
  */
220
267
  export function collectRelationFilterParams(qi, relDef, filterObj, params, depth = 0) {
221
268
  const target = relDef.to;
222
- if (filterObj.some !== undefined && filterObj.some !== null) {
269
+ if (!qi.schema.tables[target])
270
+ return false;
271
+ let emitted = false;
272
+ if (filterObj.some !== undefined) {
223
273
  collectRelFilterParams(qi, target, filterObj.some, params, depth + 1);
224
274
  collectTargetGlobalFilterExists(qi, target, params);
275
+ emitted = true;
225
276
  }
226
- if (filterObj.none !== undefined && filterObj.none !== null) {
277
+ if (filterObj.none !== undefined) {
227
278
  collectRelFilterParams(qi, target, filterObj.none, params, depth + 1);
228
279
  collectTargetGlobalFilterExists(qi, target, params);
280
+ emitted = true;
229
281
  }
230
- if (filterObj.every !== undefined && filterObj.every !== null) {
231
- // gf is only emitted (build) when the `every` sub-where compiles to a
232
- // filter, otherwise `every` is trivially true and no subquery is built.
233
- if (buildSubWhereForRelation(qi, target, qi.q(target), filterObj.every, [], depth + 1) !==
234
- null) {
235
- collectRelFilterParams(qi, target, filterObj.every, params, depth + 1);
236
- collectTargetGlobalFilterExists(qi, target, params);
237
- }
282
+ // gf is only emitted (build) when the `every` sub-where compiles to a
283
+ // filter, otherwise `every` is trivially true and no subquery is built. A
284
+ // sub-where that compiles to nothing pushes nothing.
285
+ if (filterObj.every !== undefined &&
286
+ collectRelFilterParams(qi, target, filterObj.every, params, depth + 1)) {
287
+ collectTargetGlobalFilterExists(qi, target, params);
288
+ emitted = true;
238
289
  }
239
290
  if (filterObj.is !== undefined) {
240
291
  if (filterObj.is !== null)
241
292
  collectRelFilterParams(qi, target, filterObj.is, params, depth + 1);
242
293
  collectTargetGlobalFilterExists(qi, target, params);
294
+ emitted = true;
243
295
  }
244
296
  if (filterObj.isNot !== undefined) {
245
297
  if (filterObj.isNot !== null)
246
298
  collectRelFilterParams(qi, target, filterObj.isNot, params, depth + 1);
247
299
  collectTargetGlobalFilterExists(qi, target, params);
300
+ emitted = true;
248
301
  }
302
+ return emitted;
249
303
  }
304
+ /** Collect a relation sub-where's params; returns whether it compiles to a condition. */
250
305
  export function collectRelFilterParams(qi, targetTable, subWhere, params, depth = 0) {
251
306
  const meta = qi.schema.tables[targetTable];
252
307
  if (!meta)
253
- return;
308
+ return false;
254
309
  // The FROM-item reference only shapes SQL text, never params, so the bare
255
310
  // 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);
311
+ return collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta, qi.q(targetTable)), subWhere, params, depth);
257
312
  }
258
313
  /**
259
314
  * Collect params from operator clauses. Mirrors buildOperatorClauses:
260
315
  * {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
261
316
  * but they re-run the same validation (unknown ref / insensitive mode) so a
262
- * warmed cache can never skip a check the build path enforces.
317
+ * warmed cache can never skip a check the build path enforces. The operand
318
+ * validation ({@link assertOperatorOperands}) runs here too, for the same
319
+ * reason.
263
320
  */
264
321
  export function collectOperatorParams(qi, column, op, params, refCtx) {
322
+ const operand = operandContext(qi, column, refCtx);
323
+ assertOperatorOperands(op, operand);
324
+ collectOperatorParamsChecked(qi, column, op, params, refCtx, operand);
325
+ }
326
+ /** {@link collectOperatorParams} after validation; recursive for a `not` operator object. */
327
+ function collectOperatorParamsChecked(qi, column, op, params, refCtx, operand) {
265
328
  const skipRef = (v) => {
266
329
  if (!isColumnRef(v))
267
330
  return false;
@@ -273,7 +336,6 @@ export function collectOperatorParams(qi, column, op, params, refCtx) {
273
336
  const cv = (v) => (refCtx ? coerceWhereOperand(qi, refCtx.meta, refCtx.rawColumn, v) : v);
274
337
  const insensitive = op.mode === 'insensitive';
275
338
  if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
276
- assertBindableEqualsOperand(op.equals, `"${column}"`);
277
339
  if (insensitive)
278
340
  assertInsensitiveOperand(op.equals, 'equals', column);
279
341
  params.push(cv(op.equals));
@@ -287,9 +349,18 @@ export function collectOperatorParams(qi, column, op, params, refCtx) {
287
349
  if (op.lte !== undefined && !skipRef(op.lte))
288
350
  params.push(cv(op.lte));
289
351
  if (op.not !== undefined && op.not !== null && !skipRef(op.not)) {
290
- if (insensitive)
291
- assertInsensitiveOperand(op.not, 'not', column);
292
- params.push(cv(op.not));
352
+ const negatedDocument = jsonNegatedDocument(qi, op.not, operand);
353
+ if (negatedDocument !== NO_JSON_NEGATION) {
354
+ jsonDocumentEquals(qi, '', column, negatedDocument, collectOnly(params));
355
+ }
356
+ else if (isNegatedOperatorObject(op.not, operand)) {
357
+ collectOperatorParamsChecked(qi, column, notOperand(op), params, refCtx, operand);
358
+ }
359
+ else {
360
+ if (insensitive)
361
+ assertInsensitiveOperand(op.not, 'not', column);
362
+ params.push(cv(op.not));
363
+ }
293
364
  }
294
365
  if (op.in !== undefined) {
295
366
  if (insensitive) {
@@ -312,6 +383,14 @@ export function collectOperatorParams(qi, column, op, params, refCtx) {
312
383
  if (op.endsWith !== undefined)
313
384
  params.push(`%${likeOperand(qi, op.endsWith)}`);
314
385
  }
386
+ /**
387
+ * Is this `not` operand an operator object to negate as a whole, rather than a
388
+ * value? On a JSON column an object is a JSON VALUE (`not: { a: 1 }` compares
389
+ * documents), so only non-JSON columns read it as an operator object.
390
+ */
391
+ function isNegatedOperatorObject(value, operand) {
392
+ return operand.kind !== 'json' && isWhereOperator(value);
393
+ }
315
394
  /**
316
395
  * Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
317
396
  * the `path` is bound at most once (its placeholder is shared by every
@@ -330,14 +409,18 @@ export function collectJsonFilterParams(qi, filter, params, column) {
330
409
  };
331
410
  if (filter.path !== undefined && filter.equals !== undefined) {
332
411
  pushPathOnce();
333
- params.push(String(filter.equals));
412
+ const hook = jsonPathEqualsHook(qi);
413
+ if (hook) {
414
+ hook.call(qi.dialect, jsonPathEqualsInput(qi, filter, column, '', '', collectOnly(params)));
415
+ }
416
+ else {
417
+ params.push(jsonPathEqualsOperand(qi, filter.equals, column));
418
+ }
334
419
  }
335
420
  else if (filter.equals !== undefined) {
336
- // Mirrors the build path's refusal, and thrown before any param is pushed,
337
- // like the text-search gate a few cases above. Not defensive: it is what
338
- // stops a warmed template serving a shape the cold build refuses.
339
- requireJsonContains(qi, 'equals');
340
- params.push(JSON.stringify(filter.equals));
421
+ // Mirrors the build path, refusals included: a warmed template must never
422
+ // serve a shape the cold build refuses.
423
+ jsonDocumentEquals(qi, '', column, filter.equals, collectOnly(params));
341
424
  }
342
425
  if (filter.contains !== undefined) {
343
426
  requireJsonContains(qi, 'contains');
@@ -356,8 +439,9 @@ export function collectJsonFilterParams(qi, filter, params, column) {
356
439
  }
357
440
  }
358
441
  /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
359
- export function collectArrayFilterParams(qi, filter, params) {
442
+ export function collectArrayFilterParams(qi, filter, params, column) {
360
443
  requireArrayColumns(qi);
444
+ assertArrayFilter(filter, column);
361
445
  if (filter.has !== undefined)
362
446
  params.push(filter.has);
363
447
  if (filter.hasEvery !== undefined)
@@ -391,34 +475,57 @@ export function buildWhere(qi, where) {
391
475
  * THE global-filter resolution rule, with no dependency on a `BuilderCtx`.
392
476
  *
393
477
  * Split out from {@link resolveGlobalFilter} so `PowqlInterface` can consume
394
- * the SAME rule rather than a transcription of it. It is a parallel
395
- * implementation of the public surface, and this repository's history is
396
- * unambiguous that a rule written twice is how two engines come to disagree
397
- * (the 0.64 projection resolver, the 0.73 findUnique guard). Every clause here
398
- * is load-bearing on both engines: a function filter is evaluated PER BUILD so
399
- * per-request tenancy works, an all-undefined filter is treated as absent so it
400
- * cannot emit a dangling clause, and `skip` is honoured in both its whole-query
401
- * and per-table forms.
402
- */
403
- export function resolveGlobalFilterFrom(filters, table, skip) {
404
- if (!filters)
405
- return null;
478
+ * the SAME rule rather than a transcription of it: a rule written twice is how
479
+ * two engines come to disagree. A function filter is evaluated PER BUILD so
480
+ * per-request tenancy works, and `skip` is honoured in both its whole-query and
481
+ * per-table forms.
482
+ *
483
+ * FAIL CLOSED. A filter that resolves to `undefined`, or to an object holding
484
+ * an `undefined` anywhere inside it, throws {@link ValidationError} (E003). A
485
+ * missing tenant context is exactly what produces `{ tenantId: undefined }`,
486
+ * and treating that as "no filter" read and wrote every tenant's rows. The
487
+ * explicit values `null` and `{}` still mean "no filter for this call": neither
488
+ * is what a missing value produces.
489
+ *
490
+ * A `skipGlobalFilters` list names tables by table name or client accessor,
491
+ * the two spellings `globalFilters` keys take. With `tables` (the schema's
492
+ * table map), an entry that names no table is refused (E003): it would skip
493
+ * nothing, so the caller who meant to lift a filter would silently keep it.
494
+ */
495
+ export function resolveGlobalFilterFrom(filters, table, skip, tables) {
406
496
  if (skip === true)
407
497
  return null;
408
- if (Array.isArray(skip) && skip.includes(table))
498
+ if (Array.isArray(skip)) {
499
+ if (tables !== undefined)
500
+ assertSkipListNames(skip, tables);
501
+ if (skipListNamesTable(skip, table, tables))
502
+ return null;
503
+ }
504
+ if (!filters)
409
505
  return null;
410
- const raw = filters[table];
411
- if (raw === undefined)
506
+ if (!Object.hasOwn(filters, table))
412
507
  return null;
508
+ const raw = filters[table];
413
509
  const resolved = typeof raw === 'function' ? raw() : raw;
414
- if (resolved === null || resolved === undefined)
415
- return null;
416
- const obj = resolved;
417
- // An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
418
- // nothing, treat it as absent so it never emits a dangling clause.
419
- if (Object.keys(obj).every((k) => obj[k] === undefined))
510
+ if (resolved === null)
420
511
  return null;
421
- return obj;
512
+ if (resolved === undefined)
513
+ throw unresolvedGlobalFilter(table, 'resolved to undefined');
514
+ if (typeof resolved !== 'object' || Array.isArray(resolved)) {
515
+ throw new ValidationError(`Global filter for "${table}" must be a where object (or a function returning one), received ` +
516
+ `${Array.isArray(resolved) ? 'an array' : typeof resolved}.`);
517
+ }
518
+ // The boolean scan allocates nothing; the path string is built only on the throw path.
519
+ const hole = holdsUndefinedValue(resolved, 0) ? findUndefinedPath(resolved, '', 0) : null;
520
+ if (hole !== null)
521
+ throw unresolvedGlobalFilter(table, `has an undefined value at "${hole}"`);
522
+ return Object.keys(resolved).length === 0 ? null : resolved;
523
+ }
524
+ function unresolvedGlobalFilter(table, what) {
525
+ return new ValidationError(`Global filter for "${table}" ${what}. A global filter is never dropped because its input is missing: ` +
526
+ "an undefined tenant id would otherwise read and write every tenant's rows. Supply the value it reads " +
527
+ '(for example the current tenant) before this query runs, or pass `skipGlobalFilters: UNSAFE` ' +
528
+ "(import { UNSAFE } from 'turbine-orm') on a query that must see every row.");
422
529
  }
423
530
  /**
424
531
  * Resolve the configured global filter for `table`, evaluating a function
@@ -426,7 +533,7 @@ export function resolveGlobalFilterFrom(filters, table, skip) {
426
533
  * no filter applies, the query opted out, or the filter is empty.
427
534
  */
428
535
  export function resolveGlobalFilter(qi, table, skip = qi.currentSkip) {
429
- return resolveGlobalFilterFrom(qi.globalFilters, table, skip);
536
+ return resolveGlobalFilterFrom(qi.globalFilters, table, skip, qi.schema.tables);
430
537
  }
431
538
  /**
432
539
  * AND-merge this table's resolved global filter into a user `where`. Either
@@ -444,6 +551,45 @@ export function mergeGlobalFilter(qi, userWhere) {
444
551
  // {@link markInternalCombinator}.
445
552
  return markInternalCombinator({ AND: [userWhere, gf] });
446
553
  }
554
+ /**
555
+ * The tables whose global filter is being compiled right now, per builder
556
+ * context. A table's filter applies to every relation target a query reaches,
557
+ * the targets inside another table's filter included, so two filters that
558
+ * reach each other through relations (or one that reaches its own table) would
559
+ * apply each other without end. While a table's filter is being compiled, a
560
+ * relation that reaches that table again reads its rows unfiltered, which ends
561
+ * the chain. A static filter that forms such a cycle is refused when the client
562
+ * is built (`normalizeGlobalFilters`); this covers function filters, whose
563
+ * relations are known only per query. Compilation is synchronous, so the stack
564
+ * is pushed and popped around each application, on the build and the collect
565
+ * pass alike.
566
+ */
567
+ const filtersBeingApplied = new WeakMap();
568
+ /**
569
+ * Apply `table`'s global filter as a relation target: `apply(gf)` with the
570
+ * table on the stack, or `none` when no filter applies or the table's filter
571
+ * is already being compiled further out (see {@link filtersBeingApplied}).
572
+ * Every target application goes through here, build and collect alike.
573
+ */
574
+ export function withTargetGlobalFilter(qi, table, none, apply) {
575
+ let stack = filtersBeingApplied.get(qi);
576
+ if (stack?.includes(table))
577
+ return none;
578
+ const gf = resolveGlobalFilter(qi, table);
579
+ if (!gf)
580
+ return none;
581
+ if (!stack) {
582
+ stack = [];
583
+ filtersBeingApplied.set(qi, stack);
584
+ }
585
+ stack.push(table);
586
+ try {
587
+ return apply(gf);
588
+ }
589
+ finally {
590
+ stack.pop();
591
+ }
592
+ }
447
593
  /**
448
594
  * SQL clause for `targetTable`'s global filter rendered against `alias`
449
595
  * (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
@@ -451,23 +597,17 @@ export function mergeGlobalFilter(qi, userWhere) {
451
597
  * {@link collectTargetGlobalFilterAlias}.
452
598
  */
453
599
  export function targetGlobalFilterAlias(qi, targetTable, alias, params) {
454
- const gf = resolveGlobalFilter(qi, targetTable);
455
- if (!gf)
456
- return '';
457
600
  const meta = qi.schema.tables[targetTable];
458
601
  if (!meta)
459
602
  return '';
460
- return buildAliasWhere(qi, targetTable, meta, alias, gf, params) ?? '';
603
+ return withTargetGlobalFilter(qi, targetTable, '', (gf) => buildAliasWhere(qi, targetTable, meta, alias, gf, params) ?? '');
461
604
  }
462
605
  /** Param-collect mirror of {@link targetGlobalFilterAlias}. */
463
606
  export function collectTargetGlobalFilterAlias(qi, targetTable, params) {
464
- const gf = resolveGlobalFilter(qi, targetTable);
465
- if (!gf)
466
- return;
467
607
  const meta = qi.schema.tables[targetTable];
468
608
  if (!meta)
469
609
  return;
470
- collectAliasWhereParams(qi, targetTable, meta, gf, params);
610
+ withTargetGlobalFilter(qi, targetTable, false, (gf) => collectAliasWhereParams(qi, targetTable, meta, gf, params));
471
611
  }
472
612
  /**
473
613
  * SQL clause for `targetTable`'s global filter rendered against `targetRef`,
@@ -476,17 +616,11 @@ export function collectTargetGlobalFilterAlias(qi, targetTable, params) {
476
616
  * params; `''` when none. Mirror: {@link collectTargetGlobalFilterExists}.
477
617
  */
478
618
  export function targetGlobalFilterExists(qi, targetTable, targetRef, params) {
479
- const gf = resolveGlobalFilter(qi, targetTable);
480
- if (!gf)
481
- return '';
482
- return buildSubWhereForRelation(qi, targetTable, targetRef, gf, params) ?? '';
619
+ return withTargetGlobalFilter(qi, targetTable, '', (gf) => buildSubWhereForRelation(qi, targetTable, targetRef, gf, params) ?? '');
483
620
  }
484
621
  /** Param-collect mirror of {@link targetGlobalFilterExists}. */
485
622
  export function collectTargetGlobalFilterExists(qi, targetTable, params) {
486
- const gf = resolveGlobalFilter(qi, targetTable);
487
- if (!gf)
488
- return;
489
- collectRelFilterParams(qi, targetTable, gf, params);
623
+ withTargetGlobalFilter(qi, targetTable, false, (gf) => collectRelFilterParams(qi, targetTable, gf, params));
490
624
  }
491
625
  /**
492
626
  * Value-invariant SQL-cache-key segment for the active global-filter
@@ -531,16 +665,41 @@ export function globalFilterCacheSegment(qi) {
531
665
  return parts.length ? `|gf=${parts.join(';')}` : '';
532
666
  }
533
667
  /**
534
- * True when the USER-supplied `where` compiles to no predicate (`{}`,
535
- * `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
536
- * signal the empty-`where` guard needs, the compiled emptiness, NOT the
537
- * fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
538
- * any configured global filter, so a global filter never lets an unguarded
539
- * mass mutation through.
668
+ * True when the USER-supplied `where` narrows no row: it compiles to no
669
+ * predicate (`{}`, `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …), or
670
+ * to one that is TRUE for every row (`{ NOT: { OR: [] } }`,
671
+ * `{ id: { notIn: [] } }`, and on a NOT NULL column `{ email: { contains: '' } }`
672
+ * or `{ id: { not: null } }`, see {@link foldWhere}). Either way a mutation
673
+ * guarded by it would write every row. It reads the compiled emptiness, NOT
674
+ * the fingerprint (which is non-empty for an all-undefined `OR`/`AND`), and it
675
+ * ignores any configured global filter, so a global filter never lets an
676
+ * unguarded mass mutation through.
540
677
  */
541
678
  export function userPredicateIsEmpty(qi, userWhere) {
542
679
  const throwaway = [];
543
- return buildWhereClause(qi, userWhere, throwaway) === null;
680
+ if (buildWhereClause(qi, userWhere, throwaway) === null)
681
+ return true;
682
+ return foldWhere(qi.whereHost, userWhere, (table) => tableWhereHost(qi, table), 0, guardFoldOptions(qi)) === 'true';
683
+ }
684
+ /**
685
+ * The guard's fold reads NOT NULL columns, and trusts a declared foreign key
686
+ * except on SQLite, which enforces one only when the connection turned
687
+ * `foreign_keys` on, so a row there can point at a row that does not exist.
688
+ */
689
+ function guardFoldOptions(qi) {
690
+ return {
691
+ notNull: {
692
+ foreignKeys: qi.dialect.capabilities.enforcesForeignKeys,
693
+ hasGlobalFilter: (table) => qi.globalFilters !== undefined && Object.hasOwn(qi.globalFilters, table),
694
+ },
695
+ };
696
+ }
697
+ /** The where host of `table` in this client's schema, or `undefined` when the schema has no such table. */
698
+ function tableWhereHost(qi, table) {
699
+ const meta = ownLookup(qi.schema.tables, table);
700
+ if (!meta)
701
+ return undefined;
702
+ return meta.name === qi.tableMeta.name ? qi.whereHost : scopedWhereHost(qi, meta);
544
703
  }
545
704
  export function assertMutationHasPredicate(qi, operation, whereSql,
546
705
  // Already RESOLVED by the caller (writes.ts) through `resolveUnsafeFlag`, so
@@ -552,9 +711,18 @@ allowFullTableScan) {
552
711
  return;
553
712
  if (allowFullTableScan === true)
554
713
  return;
555
- throw new ValidationError(`${operation} on "${qi.table}" refused: the \`where\` clause is empty. ` +
714
+ throw new ValidationError(emptyMutationWhereMessage(operation, qi.table));
715
+ }
716
+ /**
717
+ * The mass-mutation refusal, one text for every engine. "Empty" covers a where
718
+ * that is always true (see {@link userPredicateIsEmpty}), which writes every
719
+ * row just the same.
720
+ */
721
+ export function emptyMutationWhereMessage(operation, table) {
722
+ return (`${operation} on "${table}" refused: the \`where\` clause is empty or always true ` +
723
+ '(for example `NOT: { OR: [] }` or `{ id: { notIn: [] } }`), so it would apply to every row. ' +
556
724
  "Pass `allowFullTableScan: UNSAFE` to opt in (import { UNSAFE } from 'turbine-orm'), " +
557
- 'or check that your filter values are defined.');
725
+ 'or check that your filter values are defined and your lists are not empty.');
558
726
  }
559
727
  /**
560
728
  * Build the inner WHERE expression (without the WHERE keyword).
@@ -578,18 +746,12 @@ export function buildWhereClause(qi, where, params, depth = 0) {
578
746
  case 'or': {
579
747
  if (!internal)
580
748
  qi.markVariableArity();
581
- const orClauses = [];
582
- for (const orCond of event.conditions) {
583
- const sub = buildWhereClause(qi, orCond, params, depth + 1);
584
- if (sub)
585
- orClauses.push(sub);
586
- }
587
- if (orClauses.length > 0)
588
- andClauses.push(`(${orClauses.join(' OR ')})`);
749
+ andClauses.push(buildOrClause(event.conditions, params, (cond) => buildWhereClause(qi, cond, params, depth + 1)));
589
750
  break;
590
751
  }
591
752
  case 'and':
592
- if (!internal)
753
+ // `AND: { ... }` is one branch whatever the caller sends (fixedArity).
754
+ if (!internal && !event.fixedArity)
593
755
  qi.markVariableArity();
594
756
  for (const andCond of event.conditions) {
595
757
  const sub = buildWhereClause(qi, andCond, params, depth + 1);
@@ -619,6 +781,46 @@ export function buildWhereClause(qi, where, params, depth = 0) {
619
781
  return null;
620
782
  return andClauses.join(' AND ');
621
783
  }
784
+ /**
785
+ * The SQL an `OR` compiles to when one of its branches compiles to nothing.
786
+ * Written out rather than left empty, because `NOT` of an empty clause applies
787
+ * no condition while `NOT` of this matches no row.
788
+ */
789
+ const ALWAYS_TRUE_SQL = '(1 = 1)';
790
+ /**
791
+ * Compile an `OR` from its branches. A branch with no condition (`{}`,
792
+ * `{ AND: [] }`) is TRUE for every row, so the whole `OR` is TRUE: the clause
793
+ * is {@link ALWAYS_TRUE_SQL} and the params the other branches pushed are
794
+ * taken back. Every branch is still built, so each is validated exactly as the
795
+ * param collect validates it. {@link collectOrParams} is the mirror.
796
+ */
797
+ function buildOrClause(branches, params, build) {
798
+ const mark = params.length;
799
+ const parts = [];
800
+ let alwaysTrue = false;
801
+ for (const branch of branches) {
802
+ const sub = build(branch);
803
+ if (sub === null)
804
+ alwaysTrue = true;
805
+ else
806
+ parts.push(sub);
807
+ }
808
+ if (!alwaysTrue)
809
+ return `(${parts.join(' OR ')})`;
810
+ params.length = mark;
811
+ return ALWAYS_TRUE_SQL;
812
+ }
813
+ /** Param-collect mirror of {@link buildOrClause}; `collect` reports whether the branch compiled to a condition. */
814
+ function collectOrParams(branches, params, collect) {
815
+ const mark = params.length;
816
+ let alwaysTrue = false;
817
+ for (const branch of branches) {
818
+ if (!collect(branch))
819
+ alwaysTrue = true;
820
+ }
821
+ if (alwaysTrue)
822
+ params.length = mark;
823
+ }
622
824
  /**
623
825
  * Emit the SQL clause(s) for one scalar WHERE key onto `andClauses`, pushing
624
826
  * any params. The shape decision comes from the shared
@@ -661,18 +863,52 @@ export function buildScalarClause(qi, key, value, params, andClauses) {
661
863
  table: qi.table,
662
864
  prefix: '',
663
865
  rawColumn,
866
+ pgType: getColumnPgType(qi, rawColumn),
664
867
  }));
665
868
  return;
666
- default:
869
+ default: {
667
870
  // 'equality': a plain object literal that matched no known filter shape
668
871
  // is almost always a misspelled operator (`startWith` for `startsWith`);
669
872
  // the guard also runs on the cache-hit param-collect path.
670
- assertBindableEqualityValue(qi, rawColumn, value, getColumnPgType(qi, rawColumn), qi.table);
873
+ const pgType = getColumnPgType(qi, rawColumn);
874
+ assertBindableEqualityValue(qi, rawColumn, value, pgType, qi.table);
875
+ if (isBareJsonDocument(qi, pgType, value)) {
876
+ andClauses.push(jsonDocumentEquals(qi, column, rawColumn, value, bindInto(qi, params)));
877
+ return;
878
+ }
671
879
  params.push(coerceWhereOperand(qi, qi.tableMeta, rawColumn, value));
672
880
  andClauses.push(`${column} = ${qi.p(params.length)}`);
673
881
  return;
882
+ }
674
883
  }
675
884
  }
885
+ /**
886
+ * A bare object on a JSON column is a whole-document equality, the same thing
887
+ * `equals` is. PostgreSQL's `jsonb = $1` already reads it so. MySQL compared
888
+ * the column with the bound TEXT, which matched no row (and its `NOT` every
889
+ * row), so there it compiles to the typed comparison `equals` uses. SQLite and
890
+ * SQL Server compared the stored text, so key order and whitespace decided the
891
+ * match; they refuse it (E017) as they refuse `equals`, and a bare array with
892
+ * it. Decided from the dialect, the column and the value's shape, all of which
893
+ * the cache key already carries (a bare object fingerprints as `obj(...)`; a
894
+ * bare array only ever refuses, so it changes no SQL).
895
+ */
896
+ function isBareJsonDocument(qi, pgType, value) {
897
+ if (qi.dialect.capabilities.jsonDocumentEquality === 'jsonb' || operandColumnKind(pgType) !== 'json')
898
+ return false;
899
+ return isPlainRecord(value) || (Array.isArray(value) && !comparesJsonDocuments(qi));
900
+ }
901
+ /** Whether the engine compares two whole JSON documents exactly (key order and whitespace aside). */
902
+ function comparesJsonDocuments(qi) {
903
+ return qi.dialect.capabilities.jsonDocumentEquality !== 'text';
904
+ }
905
+ /** A bind callback for the build passes: pushes the value and returns its placeholder. */
906
+ function bindInto(qi, params) {
907
+ return (value) => {
908
+ params.push(value);
909
+ return qi.p(params.length);
910
+ };
911
+ }
676
912
  /**
677
913
  * A {@link WhereHost} with no relations, used to fingerprint a sub-where
678
914
  * whose target table is unknown (`schema.tables[t]` miss). `walkWhere` reads
@@ -732,8 +968,8 @@ export function relationWhereScope(qi, targetTable, meta, targetRef) {
732
968
  */
733
969
  export function aliasWhereScope(qi, targetTable, meta, alias) {
734
970
  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.');
971
+ throw new InvariantError('where-scope-reference', `aliasWhereScope was given the rendered reference ${alias} for table "${targetTable}", but it takes a ` +
972
+ 'bare alias and quotes it itself. Use renderedRefWhereScope for an already-quoted reference.');
737
973
  }
738
974
  return {
739
975
  meta,
@@ -789,17 +1025,24 @@ export function buildScopedWhere(qi, scope, where, params, depth = 0) {
789
1025
  for (const event of walkWhere(scope.host, where)) {
790
1026
  switch (event.kind) {
791
1027
  case 'or':
792
- case 'and': {
793
1028
  // Same variable-arity rule as the top level: a nested relation filter
794
1029
  // or a relation `with` where can carry a caller-sized OR just as easily.
795
1030
  if (!internal)
796
1031
  qi.markVariableArity();
1032
+ clauses.push(buildOrClause(event.conditions, params, (cond) => {
1033
+ const sub = buildScopedWhere(qi, scope, cond, params, depth + 1);
1034
+ return sub === null ? null : `(${sub})`;
1035
+ }));
1036
+ break;
1037
+ case 'and': {
1038
+ if (!internal && !event.fixedArity)
1039
+ qi.markVariableArity();
797
1040
  const parts = event.conditions
798
1041
  .map((cond) => buildScopedWhere(qi, scope, cond, params, depth + 1))
799
1042
  .filter((s) => s !== null)
800
1043
  .map((s) => `(${s})`);
801
1044
  if (parts.length > 0)
802
- clauses.push(`(${parts.join(event.kind === 'or' ? ' OR ' : ' AND ')})`);
1045
+ clauses.push(`(${parts.join(' AND ')})`);
803
1046
  break;
804
1047
  }
805
1048
  case 'not': {
@@ -869,10 +1112,16 @@ export function buildScopedScalarClause(qi, scope, field, value, params, clauses
869
1112
  table: scope.table,
870
1113
  prefix: scope.qualifier,
871
1114
  rawColumn: col,
1115
+ pgType: pgTypeForColumn(qi, meta, col),
872
1116
  }));
873
1117
  return;
874
1118
  }
875
- assertBindableEqualityValue(qi, col, value, pgTypeForColumn(qi, meta, col), scope.table);
1119
+ const pgType = pgTypeForColumn(qi, meta, col);
1120
+ assertBindableEqualityValue(qi, col, value, pgType, scope.table);
1121
+ if (isBareJsonDocument(qi, pgType, value)) {
1122
+ clauses.push(jsonDocumentEquals(qi, qCol, col, value, bindInto(qi, params)));
1123
+ return;
1124
+ }
876
1125
  params.push(coerceWhereOperand(qi, meta, col, value));
877
1126
  clauses.push(`${qCol} = ${qi.p(params.length)}`);
878
1127
  }
@@ -880,30 +1129,41 @@ export function buildScopedScalarClause(qi, scope, field, value, params, clauses
880
1129
  * Cache-hit param-collect mirror of {@link buildScopedWhere}: pushes the exact
881
1130
  * same params in the exact same order (driven by the same {@link walkWhere}),
882
1131
  * without rebuilding SQL. Serves both {@link collectRelFilterParams} and
883
- * {@link collectAliasWhereParams}.
1132
+ * {@link collectAliasWhereParams}. Returns whether the clause compiles to a
1133
+ * condition (`buildScopedWhere` non-null).
884
1134
  */
885
1135
  export function collectScopedWhereParams(qi, scope, where, params, depth = 0) {
886
1136
  assertWhereDepth(depth);
1137
+ let emitted = false;
887
1138
  for (const event of walkWhere(scope.host, where)) {
888
1139
  switch (event.kind) {
889
1140
  case 'or':
1141
+ collectOrParams(event.conditions, params, (cond) => collectScopedWhereParams(qi, scope, cond, params, depth + 1));
1142
+ emitted = true;
1143
+ break;
890
1144
  case 'and':
891
- for (const cond of event.conditions)
892
- collectScopedWhereParams(qi, scope, cond, params, depth + 1);
1145
+ for (const cond of event.conditions) {
1146
+ if (collectScopedWhereParams(qi, scope, cond, params, depth + 1))
1147
+ emitted = true;
1148
+ }
893
1149
  break;
894
1150
  case 'not':
895
- collectScopedWhereParams(qi, scope, event.condition, params, depth + 1);
1151
+ if (collectScopedWhereParams(qi, scope, event.condition, params, depth + 1))
1152
+ emitted = true;
896
1153
  break;
897
1154
  case 'relation':
898
1155
  // Same some→none→every→is→isNot (each: sub-where params then target
899
1156
  // global-filter params) as buildRelationFilter emits.
900
- collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth);
1157
+ if (collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth))
1158
+ emitted = true;
901
1159
  break;
902
1160
  case 'scalar':
903
1161
  collectScopedScalarParams(qi, scope, event.key, event.value, params);
1162
+ emitted = true;
904
1163
  break;
905
1164
  }
906
1165
  }
1166
+ return emitted;
907
1167
  }
908
1168
  /** Param-collect mirror of {@link buildScopedScalarClause}. */
909
1169
  export function collectScopedScalarParams(qi, scope, field, value, params) {
@@ -925,15 +1185,26 @@ export function collectScopedScalarParams(qi, scope, field, value, params) {
925
1185
  if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
926
1186
  const colType = pgTypeForColumn(qi, meta, col);
927
1187
  if (colType.startsWith('_')) {
928
- collectArrayFilterParams(qi, value, params);
1188
+ collectArrayFilterParams(qi, value, params, `${qi.q(scope.table)}.${qi.q(col)}`);
929
1189
  return;
930
1190
  }
931
1191
  }
932
1192
  if (isWhereOperator(value)) {
933
- collectOperatorParams(qi, col, value, params, { meta, table: scope.table, prefix: '', rawColumn: col });
1193
+ collectOperatorParams(qi, col, value, params, {
1194
+ meta,
1195
+ table: scope.table,
1196
+ prefix: '',
1197
+ rawColumn: col,
1198
+ pgType: pgTypeForColumn(qi, meta, col),
1199
+ });
1200
+ return;
1201
+ }
1202
+ const pgType = pgTypeForColumn(qi, meta, col);
1203
+ assertBindableEqualityValue(qi, col, value, pgType, scope.table);
1204
+ if (isBareJsonDocument(qi, pgType, value)) {
1205
+ jsonDocumentEquals(qi, '', col, value, collectOnly(params));
934
1206
  return;
935
1207
  }
936
- assertBindableEqualityValue(qi, col, value, pgTypeForColumn(qi, meta, col), scope.table);
937
1208
  params.push(coerceWhereOperand(qi, meta, col, value));
938
1209
  }
939
1210
  /**
@@ -1088,24 +1359,23 @@ depth = 0) {
1088
1359
  return gf ? ` AND ${gf}` : '';
1089
1360
  };
1090
1361
  // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
1091
- // A `null` branch is skipped (never reaches buildSubWhereForRelation, which
1092
- // would throw on Object.keys(null)), matching collectRelationFilterParams,
1093
- // which also skips null. Unreachable via normalization today, guarded anyway.
1094
- if (filterObj.some !== undefined && filterObj.some !== null) {
1362
+ // `some` / `none` / `every` hold a where object here: the where walk refuses
1363
+ // any other value (relationFilterValueProblem), so none compiles to nothing.
1364
+ if (filterObj.some !== undefined) {
1095
1365
  const subWhere = filterObj.some;
1096
1366
  const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
1097
1367
  const filterAnd = filterClause ? ` AND ${filterClause}` : '';
1098
1368
  clauses.push(`EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1099
1369
  }
1100
1370
  // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
1101
- if (filterObj.none !== undefined && filterObj.none !== null) {
1371
+ if (filterObj.none !== undefined) {
1102
1372
  const subWhere = filterObj.none;
1103
1373
  const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
1104
1374
  const filterAnd = filterClause ? ` AND ${filterClause}` : '';
1105
1375
  clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
1106
1376
  }
1107
1377
  // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
1108
- if (filterObj.every !== undefined && filterObj.every !== null) {
1378
+ if (filterObj.every !== undefined) {
1109
1379
  const subWhere = filterObj.every;
1110
1380
  const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
1111
1381
  if (filterClause) {
@@ -1176,16 +1446,96 @@ export function pgTypeForColumn(_qi, meta, column) {
1176
1446
  * SQL-template cache is unaffected, and it is applied on the cache-hit
1177
1447
  * param-collect path as well as the build path.
1178
1448
  *
1449
+ * On MySQL a number, bigint or boolean compared with a text column binds as
1450
+ * text ({@link textComparedOperand}), and so does each such element of a list.
1451
+ *
1179
1452
  * `timestamptz` and every non-temporal column are returned by identity.
1180
1453
  */
1181
1454
  export function coerceWhereOperand(qi, meta, column, value) {
1182
- if (!(value instanceof Date) && !Array.isArray(value))
1455
+ if (value instanceof Date || Array.isArray(value)) {
1456
+ const dbType = pgTypeForColumn(qi, meta, column);
1457
+ const coerced = coerceTemporalValue(dbType, value, utcDateTimeRewrite(qi, dbType));
1458
+ return Array.isArray(coerced) ? textComparedList(qi, dbType, coerced) : coerced;
1459
+ }
1460
+ if (!qi.dialect.capabilities.numericTextComparison || typeof value === 'string' || typeof value === 'object') {
1461
+ return value;
1462
+ }
1463
+ return textComparedOperand(qi, pgTypeForColumn(qi, meta, column), value);
1464
+ }
1465
+ /**
1466
+ * Column types MySQL compares with a NUMBER numerically, converting each
1467
+ * stored text to a number: `'0zz' = 0` and `'abc' = 0` are both true, and a
1468
+ * boolean is the number 1 or 0 there. A request body that carries `0` or
1469
+ * `false` where a token string belongs would match the first row whose token
1470
+ * does not start with a digit. Exported so the write path's re-read keys use
1471
+ * this one list rather than a copy.
1472
+ */
1473
+ export const TEXT_COMPARED_TYPES = new Set([
1474
+ 'char',
1475
+ 'varchar',
1476
+ 'character',
1477
+ 'character varying',
1478
+ 'bpchar',
1479
+ 'nchar',
1480
+ 'nvarchar',
1481
+ 'text',
1482
+ 'tinytext',
1483
+ 'mediumtext',
1484
+ 'longtext',
1485
+ 'binary',
1486
+ 'varbinary',
1487
+ 'tinyblob',
1488
+ 'blob',
1489
+ 'mediumblob',
1490
+ 'longblob',
1491
+ 'uuid',
1492
+ 'citext',
1493
+ ]);
1494
+ /**
1495
+ * A value bound against a column of `columnType`, as MySQL must receive it to
1496
+ * compare it as the value it is: a number or bigint becomes its decimal text
1497
+ * and a boolean `'1'` / `'0'` (what MySQL stores when a boolean is written to
1498
+ * a text column), so the comparison is text with text. Every other engine,
1499
+ * value and column type is returned unchanged. THE rule for every value Turbine
1500
+ * binds against a column on MySQL: the where compiler applies it to each
1501
+ * operand, and the writes apply it to the keys they re-read a row by.
1502
+ */
1503
+ export function textComparedOperand(qi, columnType, value) {
1504
+ if (!qi.dialect.capabilities.numericTextComparison)
1505
+ return value;
1506
+ if (typeof value !== 'number' && typeof value !== 'bigint' && typeof value !== 'boolean')
1183
1507
  return value;
1184
- return coerceTemporalValue(pgTypeForColumn(qi, meta, column), value,
1185
- // Same PostgreSQL + `utcTimestamps` gate as the write path (see
1186
- // `utcDateTimeWrites` in writes.ts): only the read/write-symmetric engine
1187
- // gets the zone-less rewrite. Time-of-day always rewrites.
1188
- qi.dialect.name === 'postgresql' && qi.utcTimestamps !== false);
1508
+ if (columnType === undefined || !TEXT_COMPARED_TYPES.has(columnType.toLowerCase()))
1509
+ return value;
1510
+ return typeof value === 'boolean' ? (value ? '1' : '0') : String(value);
1511
+ }
1512
+ /** {@link textComparedOperand} for each element of an `in` / `notIn` list; the list itself when none changes. */
1513
+ function textComparedList(qi, columnType, list) {
1514
+ if (!qi.dialect.capabilities.numericTextComparison)
1515
+ return list;
1516
+ let out;
1517
+ for (let i = 0; i < list.length; i++) {
1518
+ const next = textComparedOperand(qi, columnType, list[i]);
1519
+ if (next === list[i])
1520
+ continue;
1521
+ out ??= list.slice();
1522
+ out[i] = next;
1523
+ }
1524
+ return out ?? list;
1525
+ }
1526
+ /**
1527
+ * Whether a `Date` bound against `dbType` gets the zone-less UTC rewrite (the
1528
+ * write path's gate). A `date` column takes the UTC date part on every SQL
1529
+ * engine: the write path stores `YYYY-MM-DD` there, and a full timestamp
1530
+ * compared with that text matches nothing. A zone-less `timestamp` is
1531
+ * rewritten on PostgreSQL only, because MySQL reads a zone-less literal in the
1532
+ * session time zone. `utcTimestamps: false` turns both off. Time-of-day
1533
+ * columns always rewrite.
1534
+ */
1535
+ export function utcDateTimeRewrite(qi, dbType) {
1536
+ if (qi.utcTimestamps === false)
1537
+ return false;
1538
+ return qi.dialect.capabilities.utcTimestampRewrite || localDateTimeKind(dbType) === 'date';
1189
1539
  }
1190
1540
  /**
1191
1541
  * The Postgres enum type name for a column, when the schema knows one.
@@ -1197,13 +1547,13 @@ export function coerceWhereOperand(qi, meta, column, value) {
1197
1547
  * value as text and Postgres refuses the implicit text→enum coercion
1198
1548
  * ("column X is of type Y but expression is of type text").
1199
1549
  *
1200
- * Postgres-only by construction: gated on the active dialect being
1201
- * `postgresql` AND on `schema.enums` having entries (only PG introspection
1202
- * produces them, `defineSchema` and the other engines leave it empty), so
1203
- * SQLite/MySQL/MSSQL/PowDB output is byte-identical.
1550
+ * Postgres-only by construction: gated on the engine having named enum types
1551
+ * (`namedEnumTypes`) AND on `schema.enums` having entries (only PG
1552
+ * introspection produces them, `defineSchema` and the other engines leave it
1553
+ * empty), so SQLite/MySQL/MSSQL/PowDB output is byte-identical.
1204
1554
  */
1205
1555
  export function enumTypeForColumn(qi, column) {
1206
- if (qi.dialect.name !== 'postgresql')
1556
+ if (!qi.dialect.capabilities.namedEnumTypes)
1207
1557
  return null;
1208
1558
  const enums = qi.schema.enums;
1209
1559
  if (!enums)
@@ -1232,25 +1582,14 @@ export function enumCastSuffix(qi, column) {
1232
1582
  }
1233
1583
  /**
1234
1584
  * Equality-fallthrough guard shared by every SQL-build path AND every
1235
- * cache-hit param-collect path. A plain object literal that matched no known
1236
- * filter shape on a non-JSON column is almost always a misspelled operator
1237
- * (`startWith` for `startsWith`); binding it as `col = $1` silently returns
1238
- * wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
1239
- * legitimate bind values and pass through, as do objects on json/jsonb
1240
- * columns (object equality).
1241
- */
1242
- export function assertBindableEqualityValue(qi, rawColumn, value, columnPgType, table) {
1243
- if (!isUnmatchedPlainObject(value))
1244
- return;
1245
- if (isJsonColumnType(qi, columnPgType))
1246
- return;
1247
- const badKeys = Object.keys(value);
1248
- throw new ValidationError(badKeys.length === 0
1249
- ? `Empty filter object on "${rawColumn}" for table "${table}". ` +
1250
- `Provide a value or an operator like { gt: 1 }.`
1251
- : `Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
1252
- `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
1253
- `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
1585
+ * cache-hit param-collect path: a bare value must fit its column
1586
+ * ({@link assertEqualityOperand}). A plain object literal that matched no
1587
+ * known filter shape on a non-JSON column is almost always a misspelled
1588
+ * operator (`startWith` for `startsWith`), and binding it as `col = $1` would
1589
+ * silently return wrong rows. Objects on json/jsonb columns are JSON values.
1590
+ */
1591
+ export function assertBindableEqualityValue(_qi, rawColumn, value, columnPgType, table) {
1592
+ assertEqualityOperand(value, { column: rawColumn, table, kind: operandColumnKind(columnPgType) });
1254
1593
  }
1255
1594
  /**
1256
1595
  * Build the user-supplied `where` filter of a relation `with` clause against
@@ -1269,7 +1608,7 @@ export function buildAliasWhere(qi, targetTable, targetMeta, alias, where, param
1269
1608
  export function collectAliasWhereParams(qi, targetTable, targetMeta, where, params) {
1270
1609
  // The alias identifier is irrelevant to param collection (it only shapes SQL
1271
1610
  // text), so reuse the relation scope's host binding for `targetMeta`.
1272
- collectScopedWhereParams(qi, aliasWhereScope(qi, targetTable, targetMeta, ''), where, params);
1611
+ return collectScopedWhereParams(qi, aliasWhereScope(qi, targetTable, targetMeta, ''), where, params);
1273
1612
  }
1274
1613
  /**
1275
1614
  * Value-invariant, shape-aware fingerprint for a relation `with` clause's
@@ -1318,11 +1657,20 @@ export function columnRefSql(qi, ref, ctx, mode) {
1318
1657
  * Each operator key becomes its own clause, all ANDed together.
1319
1658
  *
1320
1659
  * `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
1321
- * (`{ col: 'otherField' }`) which compiles to a column-to-column comparison
1660
+ * (`col('otherField')`) which compiles to a column-to-column comparison
1322
1661
  * against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
1323
1662
  * pushing nothing and the referenced name lives in the fingerprint.
1663
+ *
1664
+ * `not` also accepts an operator object, compiled as `NOT (<inner clauses>)`
1665
+ * (see {@link WhereOperator.not}).
1324
1666
  */
1325
1667
  export function buildOperatorClauses(qi, column, op, params, refCtx) {
1668
+ const operand = operandContext(qi, column, refCtx);
1669
+ assertOperatorOperands(op, operand);
1670
+ return buildOperatorClausesChecked(qi, column, op, params, refCtx, operand);
1671
+ }
1672
+ /** {@link buildOperatorClauses} after validation; recursive for a `not` operator object. */
1673
+ function buildOperatorClausesChecked(qi, column, op, params, refCtx, operand) {
1326
1674
  const clauses = [];
1327
1675
  // Temporal bind rewrite, identical to `collectOperatorParams`. Value-only, so
1328
1676
  // the emitted SQL (and therefore the template cache) is untouched.
@@ -1336,7 +1684,6 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
1336
1684
  clauses.push(`${column} = ${columnRefSql(qi, op.equals, refCtx, op.mode)}`);
1337
1685
  }
1338
1686
  else {
1339
- assertBindableEqualsOperand(op.equals, column);
1340
1687
  if (insensitive)
1341
1688
  assertInsensitiveOperand(op.equals, 'equals', column);
1342
1689
  params.push(cv(op.equals));
@@ -1380,12 +1727,25 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
1380
1727
  }
1381
1728
  }
1382
1729
  if (op.not !== undefined) {
1730
+ const negatedDocument = op.not === null || isColumnRef(op.not) ? NO_JSON_NEGATION : jsonNegatedDocument(qi, op.not, operand);
1383
1731
  if (op.not === null) {
1384
1732
  clauses.push(`${column} IS NOT NULL`);
1385
1733
  }
1386
1734
  else if (isColumnRef(op.not)) {
1387
1735
  clauses.push(`${column} != ${columnRefSql(qi, op.not, refCtx, op.mode)}`);
1388
1736
  }
1737
+ else if (negatedDocument !== NO_JSON_NEGATION) {
1738
+ const equality = jsonDocumentEquals(qi, column, column, negatedDocument, (v) => {
1739
+ params.push(v);
1740
+ return qi.p(params.length);
1741
+ });
1742
+ clauses.push(`NOT (${equality})`);
1743
+ }
1744
+ else if (isNegatedOperatorObject(op.not, operand)) {
1745
+ const inner = buildOperatorClausesChecked(qi, column, notOperand(op), params, refCtx, operand);
1746
+ if (inner.length > 0)
1747
+ clauses.push(`NOT (${inner.join(' AND ')})`);
1748
+ }
1389
1749
  else {
1390
1750
  if (insensitive)
1391
1751
  assertInsensitiveOperand(op.not, 'not', column);
@@ -1442,9 +1802,8 @@ export function requireFullTextSearch(qi) {
1442
1802
  'Use `contains` (LIKE) on this engine, or run the query on PostgreSQL.');
1443
1803
  }
1444
1804
  /**
1445
- * Gate the pathless JSON containment filters (`contains`, and the `equals`
1446
- * spelling that compiles to the same expression) on
1447
- * {@link Dialect.supportsJsonContains}.
1805
+ * Gate the pathless JSON filters, `contains` (containment) and `equals`
1806
+ * (whole-document equality), on {@link Dialect.supportsJsonContains}.
1448
1807
  *
1449
1808
  * The engine this exists for is SQLite, whose emulation compares a decoded
1450
1809
  * `json_each.value` against a param bound as JSON TEXT and therefore matched
@@ -1463,11 +1822,12 @@ export function requireFullTextSearch(qi) {
1463
1822
  export function requireJsonContains(qi, clause) {
1464
1823
  if (qi.dialect.supportsJsonContains)
1465
1824
  return;
1466
- throw new UnsupportedFeatureError(`JSON containment (\`${clause}\` without \`path\`)`, qi.dialect.name, `This engine has no equivalent of PostgreSQL's \`@>\`, and the emulation Turbine used for ` +
1467
- `\`${clause}\` matched no rows for any operand rather than failing, so it is refused instead. ` +
1825
+ const what = clause === 'equals' ? 'way to compare two whole JSON documents' : "JSON containment (PostgreSQL's `@>`)";
1826
+ throw new UnsupportedFeatureError(clause === 'equals'
1827
+ ? 'JSON document equality (`equals` without `path`)'
1828
+ : 'JSON containment (`contains` without `path`)', qi.dialect.name, `This engine has no ${what}, so the filter is refused rather than emulated. ` +
1468
1829
  'Filter on the specific value instead: `{ path: [...], equals: ... }` compiles to a real JSON ' +
1469
- 'extraction (`json_extract`) rather than an emulation. For structural containment, run the ' +
1470
- 'query on PostgreSQL or MySQL.');
1830
+ 'extraction (`json_extract`). To compare or match whole documents, run the query on PostgreSQL or MySQL.');
1471
1831
  }
1472
1832
  /**
1473
1833
  * Gate the array filter operators (`has` / `hasEvery` / `hasSome` / `isEmpty`)
@@ -1541,15 +1901,7 @@ export function pushVectorParam(qi, field, _rawColumn, to, params) {
1541
1901
  * ambiguous and was never valid in Prisma either).
1542
1902
  */
1543
1903
  export function normalizeRelationFilter(_qi, relDef, filterObj) {
1544
- if ((relDef.type === 'belongsTo' || relDef.type === 'hasOne') &&
1545
- !('some' in filterObj) &&
1546
- !('every' in filterObj) &&
1547
- !('none' in filterObj) &&
1548
- !('is' in filterObj) &&
1549
- !('isNot' in filterObj)) {
1550
- return { is: filterObj };
1551
- }
1552
- return filterObj;
1904
+ return normalizeToOneRelationFilter(relDef, filterObj);
1553
1905
  }
1554
1906
  /**
1555
1907
  * Case-insensitive json/jsonb column-type check. Postgres reports lowercase
@@ -1597,7 +1949,11 @@ export function getArrayElementType(_qi, pgType) {
1597
1949
  */
1598
1950
  /**
1599
1951
  * One LIKE comparison, honoring `mode: 'insensitive'` through the dialect and
1600
- * always carrying the `ESCAPE '\'` clause that pairs with {@link escapeLike}.
1952
+ * always carrying the `ESCAPE` clause that pairs with {@link likeOperand}: the
1953
+ * dialect's `likeEscapeCharacter`, else `\` (the character {@link escapeLike}
1954
+ * escapes with). MySQL names `!`, because `'\'` is a syntax error there unless
1955
+ * the session runs with `NO_BACKSLASH_ESCAPES`, which a caller-supplied pool
1956
+ * need not.
1601
1957
  *
1602
1958
  * Shared by the scalar `contains` / `startsWith` / `endsWith` operators and by
1603
1959
  * the JSON substring operators, so the two can never drift into escaping their
@@ -1605,7 +1961,7 @@ export function getArrayElementType(_qi, pgType) {
1605
1961
  */
1606
1962
  function buildLikeClause(qi, column, paramRef, insensitive) {
1607
1963
  const base = insensitive ? qi.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
1608
- return `${base} ESCAPE '\\'`;
1964
+ return `${base} ESCAPE '${qi.dialect.likeEscapeCharacter ?? '\\'}'`;
1609
1965
  }
1610
1966
  /**
1611
1967
  * One equality-family comparison (`=` / `!=`) honoring `mode: 'insensitive'`.
@@ -1655,11 +2011,12 @@ function insensitiveInClause(qi, column, paramRef, negated, operator) {
1655
2011
  * on that engine.
1656
2012
  *
1657
2013
  * 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
2014
+ * dialects: each unpacks a bound IN list in its OWN way (`json_each` on SQLite,
2015
+ * `OPENJSON` on SQL Server, one placeholder per element expanded by the MySQL
2016
+ * pool shim after the statement leaves the builder), that shape is the
2017
+ * dialect's and not this module's, and the length-independence the
2018
+ * SQL-template cache depends on rules out emitting one placeholder per element
2019
+ * here. Restoring the operator on those engines means a dialect hook that
1663
2020
  * projects the folded element, one correct implementation per engine. Until
1664
2021
  * then a typed refusal is the honest outcome. `equals`, `not` and the LIKE
1665
2022
  * operators are unaffected: they fold a single operand the engine can reach.
@@ -1668,7 +2025,7 @@ function insensitiveInClause(qi, column, paramRef, negated, operator) {
1668
2025
  * every other gate in this module.
1669
2026
  */
1670
2027
  function requireInsensitiveList(qi, column, operator) {
1671
- if (qi.dialect.name === 'postgresql')
2028
+ if (qi.dialect.capabilities.insensitiveInList)
1672
2029
  return;
1673
2030
  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
2031
  'IN-list form cannot express. Lowering them in JavaScript instead would fold the list and the ' +
@@ -1692,54 +2049,6 @@ function assertInsensitiveOperand(value, operator, column) {
1692
2049
  `(received ${Array.isArray(value) ? 'a list with a non-string element' : typeof value}). ` +
1693
2050
  `Case folding applies to text; drop the mode or compare a string.`);
1694
2051
  }
1695
- /**
1696
- * Refuse a {@link JsonFilter} carrying a key that is not a JSON operator, and
1697
- * refuse a filter that selects a `path` but never compares it.
1698
- *
1699
- * Both shapes used to compile to NOTHING. `buildJsonFilterClauses` only ever
1700
- * emitted a clause for a key it recognized, so `{ path: ['title'],
1701
- * string_contains: 'x' }`, the Prisma spelling, and an easy typo besides -
1702
- * produced an empty clause list, the predicate vanished, and the query
1703
- * returned EVERY row. Inside an `AND` it silently dropped that conjunct, so a
1704
- * tenant scope written this way widened to the whole table. The equivalent
1705
- * typo on a scalar column has always thrown; this closes the inconsistency.
1706
- *
1707
- * Called from both the SQL-build path and the cache-hit param-collect path, so
1708
- * a warmed SQL cache cannot skip the check.
1709
- */
1710
- export function assertJsonFilterKeys(filter, column) {
1711
- const obj = filter;
1712
- const present = Object.keys(obj).filter((k) => obj[k] !== undefined);
1713
- for (const key of present) {
1714
- if (JSON_FILTER_KEYS.has(key))
1715
- continue;
1716
- const suggestion = JSON_PRISMA_SPELLINGS[key];
1717
- throw new ValidationError(`Unknown JSON filter operator "${key}" on ${column}.` +
1718
- (suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
1719
- ` Supported operators: ${[...JSON_FILTER_KEYS].sort().join(', ')}.`);
1720
- }
1721
- // `mode` and `path` are modifiers, not comparisons: a filter made only of
1722
- // those compares nothing, which is exactly the silent no-op shape above.
1723
- if (present.length > 0 && present.every((k) => k === 'path' || k === 'mode')) {
1724
- throw new ValidationError(`JSON filter on ${column} selects a \`path\` but has no comparison. ` +
1725
- `Add one of: ${[...JSON_FILTER_KEYS]
1726
- .filter((k) => k !== 'path' && k !== 'mode')
1727
- .sort()
1728
- .join(', ')}.`);
1729
- }
1730
- }
1731
- /**
1732
- * The Prisma spelling of each JSON operator, so a migrator who writes the
1733
- * name they already know gets told the turbine one instead of a bare list.
1734
- */
1735
- const JSON_PRISMA_SPELLINGS = {
1736
- string_contains: 'stringContains',
1737
- string_starts_with: 'stringStartsWith',
1738
- string_ends_with: 'stringEndsWith',
1739
- array_contains: 'contains',
1740
- startsWith: 'stringStartsWith',
1741
- endsWith: 'stringEndsWith',
1742
- };
1743
2052
  /**
1744
2053
  * Validate and enumerate the substring comparisons on a JSON filter, in the
1745
2054
  * fixed {@link JSON_STRING_OPERATORS} order. Shared by the build and collect
@@ -1786,6 +2095,136 @@ export function jsonRangeEntries(_qi, filter, column) {
1786
2095
  }
1787
2096
  return entries;
1788
2097
  }
2098
+ /**
2099
+ * The `{ path, equals }` operand after the checks every engine shares: a
2100
+ * function, symbol or column reference is refused, a non-finite number is
2101
+ * refused (JSON.stringify would turn it into `null`, which matches JSON null),
2102
+ * and a Date becomes the ISO string JSON stores it as.
2103
+ */
2104
+ function jsonPathEqualsValue(value, column) {
2105
+ if (typeof value === 'function' || typeof value === 'symbol' || isColumnRef(value)) {
2106
+ throw new ValidationError(`JSON path 'equals' on ${column} takes a JSON value (string, number, boolean, null, array or object), ` +
2107
+ `received ${isColumnRef(value) ? 'a column reference' : `a ${typeof value}`}.`);
2108
+ }
2109
+ const scalar = value instanceof Date ? value.toISOString() : value;
2110
+ if (typeof scalar === 'number' && !Number.isFinite(scalar)) {
2111
+ throw new ValidationError(`JSON path 'equals' on ${column} takes a finite number: JSON has no form for ${scalar}.`);
2112
+ }
2113
+ return scalar;
2114
+ }
2115
+ /** The engine's typed `{ path, equals }` hook; the jsonb form lives here, so an engine using it never takes one. */
2116
+ function jsonPathEqualsHook(qi) {
2117
+ return qi.dialect.capabilities.jsonDocumentEquality === 'jsonb' ? undefined : qi.dialect.buildJsonPathEquals;
2118
+ }
2119
+ /** The hook input, identical on the build and collect passes but for `column`, `pathRef` and `bind`. */
2120
+ function jsonPathEqualsInput(qi, filter, label, column, pathRef, bind) {
2121
+ return {
2122
+ column,
2123
+ pathRef,
2124
+ path: filter.path,
2125
+ value: jsonPathEqualsValue(filter.equals, label),
2126
+ label,
2127
+ bind,
2128
+ encodePath: (path) => jsonPathParam(qi, path),
2129
+ };
2130
+ }
2131
+ /**
2132
+ * A whole-document JSON `equals` (no `path`): the column equals the operand as
2133
+ * a JSON value, on the build and collect passes alike (`bind` pushes; on the
2134
+ * collect pass the returned SQL is discarded). PostgreSQL compares jsonb, which
2135
+ * ignores object key order and whitespace; the cast makes a `json` column
2136
+ * comparable too (it has no `=` operator). MySQL compares through its typed
2137
+ * hook; an engine without one refuses (E017) through
2138
+ * {@link requireJsonContains}.
2139
+ */
2140
+ function jsonDocumentEquals(qi, column, label, value, bind) {
2141
+ const v = jsonPathEqualsValue(value, label);
2142
+ if (qi.dialect.capabilities.jsonDocumentEquality === 'jsonb') {
2143
+ return `${column}::jsonb = ${bind(typeof v === 'bigint' ? v.toString() : JSON.stringify(v))}::jsonb`;
2144
+ }
2145
+ const hook = qi.dialect.buildJsonPathEquals;
2146
+ if (hook) {
2147
+ return hook.call(qi.dialect, {
2148
+ column,
2149
+ pathRef: undefined,
2150
+ path: [],
2151
+ value: v,
2152
+ label,
2153
+ bind,
2154
+ encodePath: (path) => jsonPathParam(qi, path),
2155
+ });
2156
+ }
2157
+ requireJsonContains(qi, 'equals');
2158
+ throw new UnsupportedFeatureError(`JSON \`equals\` on the whole document (${label})`, qi.dialect.name, 'This engine has no exact comparison of JSON documents. Compare a value with `{ path: [...], equals }`.');
2159
+ }
2160
+ /** A bind callback for the collect passes: pushes the value, emits no SQL. */
2161
+ function collectOnly(params) {
2162
+ return (value) => {
2163
+ params.push(value);
2164
+ return '';
2165
+ };
2166
+ }
2167
+ /**
2168
+ * The operand of a `not` on a JSON column that negates a whole-document
2169
+ * equality, or `NO_JSON_NEGATION` when the `not` keeps its bound-value form.
2170
+ * `not: { equals: X }` is the negation of `equals: X` everywhere it compiles
2171
+ * (an engine without an exact comparison refuses it rather than comparing a
2172
+ * document `{"equals": X}`). A plain `not: X` means the same thing on the
2173
+ * engines that compare whole documents exactly (PostgreSQL, MySQL). On SQLite
2174
+ * and SQL Server a document (object or array) is refused the same way, since
2175
+ * comparing it as text lets key order decide; a scalar keeps comparing as the
2176
+ * bound value, as before.
2177
+ */
2178
+ function jsonNegatedDocument(qi, not, operand) {
2179
+ if (operand.kind !== 'json' || not === null || not === undefined || isColumnRef(not))
2180
+ return NO_JSON_NEGATION;
2181
+ if (isWhereOperator(not)) {
2182
+ const keys = Object.keys(not).filter((k) => not[k] !== undefined);
2183
+ if (keys.length === 1 && keys[0] === 'equals')
2184
+ return not.equals;
2185
+ }
2186
+ if (comparesJsonDocuments(qi))
2187
+ return not;
2188
+ return isPlainRecord(not) || Array.isArray(not) ? not : NO_JSON_NEGATION;
2189
+ }
2190
+ const NO_JSON_NEGATION = Symbol('no JSON negation');
2191
+ /**
2192
+ * The bound operand of `{ path, equals }`, shared by the build and collect
2193
+ * paths so both bind (and refuse) identically.
2194
+ *
2195
+ * PostgreSQL compares JSON to JSON, so the operand is its JSON text: `5`,
2196
+ * `"5"`, `null`, `[1,2]` and `{"a":1}` each match only that JSON value at the
2197
+ * path (numbers compare numerically, so `5` also matches `5.0`). A Date is the
2198
+ * ISO string JSON would store, and a bigint is a JSON number.
2199
+ *
2200
+ * MySQL and SQL Server compare typed through `Dialect.buildJsonPathEquals`
2201
+ * and never reach this function. SQLite compares the value its path
2202
+ * extraction returns, so only a value with a text form can match: a string,
2203
+ * number, boolean, bigint or Date.
2204
+ * There a number and a numeric string match the same rows, except on SQLite,
2205
+ * whose extraction returns typed values: a number binds as a number there, and
2206
+ * a boolean is refused because SQLite reads `true` and `1` alike. `null`,
2207
+ * objects and arrays matched no row on those engines and are refused (E017).
2208
+ */
2209
+ export function jsonPathEqualsOperand(qi, value, column) {
2210
+ const scalar = jsonPathEqualsValue(value, column);
2211
+ if (qi.dialect.capabilities.jsonDocumentEquality === 'jsonb') {
2212
+ return typeof scalar === 'bigint' ? scalar.toString() : JSON.stringify(scalar);
2213
+ }
2214
+ if (scalar === null || typeof scalar === 'object') {
2215
+ throw new UnsupportedFeatureError(`JSON path \`equals\` with ${scalar === null ? 'null' : Array.isArray(scalar) ? 'an array' : 'an object'} (${column})`, qi.dialect.name, 'This engine compares the text extracted at the path, which has no form for null, arrays or objects, so ' +
2216
+ 'the comparison would match no row. Compare a scalar at a deeper path, or run the query on PostgreSQL.');
2217
+ }
2218
+ if (qi.dialect.capabilities.jsonExtractTyped) {
2219
+ if (typeof scalar === 'number')
2220
+ return scalar;
2221
+ if (typeof scalar === 'boolean') {
2222
+ throw new UnsupportedFeatureError(`JSON path \`equals\` with a boolean (${column})`, qi.dialect.name, 'SQLite extracts JSON true and false as the integers 1 and 0, so a boolean comparison would also match ' +
2223
+ 'those numbers. Compare with 1 / 0 if the documents hold only booleans there.');
2224
+ }
2225
+ }
2226
+ return String(scalar);
2227
+ }
1789
2228
  /**
1790
2229
  * Build SQL clauses for JSONB filter operators on a column.
1791
2230
  * Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
@@ -1799,29 +2238,45 @@ export function buildJsonFilterClauses(qi, column, filter, params) {
1799
2238
  const clauses = [];
1800
2239
  // Lazily bind the path once; reuse the same $N in every extraction clause.
1801
2240
  let pathParamIdx = null;
1802
- const pathExtract = () => {
2241
+ const pathRef = () => {
1803
2242
  if (pathParamIdx === null) {
1804
2243
  // Only reached when a path-requiring clause validated filter.path.
1805
2244
  params.push(jsonPathParam(qi, filter.path, filter.path));
1806
2245
  pathParamIdx = params.length;
1807
2246
  }
1808
- return qi.dialect.buildJsonPathExtract(column, qi.p(pathParamIdx));
2247
+ return qi.p(pathParamIdx);
1809
2248
  };
2249
+ const pathExtract = () => qi.dialect.buildJsonPathExtract(column, pathRef());
1810
2250
  if (filter.path !== undefined && filter.equals !== undefined) {
1811
- // Path access + equals: column #>> $N::text[] = $M
1812
- const extract = pathExtract();
1813
- params.push(String(filter.equals));
1814
- clauses.push(`${extract} = ${qi.p(params.length)}`);
2251
+ // Path access + equals. PostgreSQL compares the JSON VALUE at the path,
2252
+ // typed: `(column #> $N::text[])::jsonb = $M::jsonb`, so 5 and "5" differ,
2253
+ // and null, arrays and objects compare as JSON. MySQL and SQL Server bring
2254
+ // their own typed form (Dialect.buildJsonPathEquals); SQLite compares the
2255
+ // extracted value (see jsonPathEqualsOperand).
2256
+ const ref = pathRef();
2257
+ const hook = jsonPathEqualsHook(qi);
2258
+ if (hook) {
2259
+ // The engine's typed comparison (MySQL, SQL Server).
2260
+ clauses.push(hook.call(qi.dialect, jsonPathEqualsInput(qi, filter, column, column, ref, (v) => {
2261
+ params.push(v);
2262
+ return qi.p(params.length);
2263
+ })));
2264
+ }
2265
+ else {
2266
+ const value = jsonPathEqualsOperand(qi, filter.equals, column);
2267
+ params.push(value);
2268
+ clauses.push(qi.dialect.capabilities.jsonDocumentEquality === 'jsonb'
2269
+ ? `(${column} #> ${ref}::text[])::jsonb = ${qi.p(params.length)}::jsonb`
2270
+ : `${qi.dialect.buildJsonPathExtract(column, ref)} = ${qi.p(params.length)}`);
2271
+ }
1815
2272
  }
1816
2273
  else if (filter.equals !== undefined) {
1817
- // Containment equality: column @> $N::jsonb. A pathless `equals` compiles
1818
- // to the SAME containment expression `contains` does, so it inherits the
1819
- // same engine limitation and is refused under its own clause name (the two
1820
- // spellings are far enough apart that naming the wrong one would send the
1821
- // reader to the wrong line).
1822
- requireJsonContains(qi, 'equals');
1823
- params.push(JSON.stringify(filter.equals));
1824
- clauses.push(qi.dialect.buildJsonContains(column, qi.p(params.length)));
2274
+ // Whole-document equality, NOT containment (`@>` matched every document
2275
+ // holding the operand's keys).
2276
+ clauses.push(jsonDocumentEquals(qi, column, column, filter.equals, (v) => {
2277
+ params.push(v);
2278
+ return qi.p(params.length);
2279
+ }));
1825
2280
  }
1826
2281
  if (filter.contains !== undefined) {
1827
2282
  // Containment: column @> $N::jsonb
@@ -1862,12 +2317,19 @@ export function buildJsonFilterClauses(qi, column, filter, params) {
1862
2317
  * params) and fail at runtime with the engine's bad-JSON-path error. The
1863
2318
  * encoded path stays a bound parameter, never spliced into SQL text, so
1864
2319
  * the build/collect param mirrors stay in lockstep and injection-safe.
2320
+ *
2321
+ * A key segment is written as a JSON string literal (`JSON.stringify`), the
2322
+ * form the engines' JSONPath grammars read a quoted key in: a backslash, a
2323
+ * quote and a control character are each escaped. Escaping only the quote
2324
+ * read a key holding a backslash as an escape sequence, so the key `a\b` was
2325
+ * looked up as a different key and matched nothing, a key ending in a
2326
+ * backslash was a bad path, and MySQL refused a key holding a newline.
1865
2327
  */
1866
2328
  export function jsonPathParam(qi, path, nativeForm) {
1867
2329
  if (qi.dialect.jsonPathSupport === 'native')
1868
2330
  return nativeForm ?? path.map(String);
1869
2331
  return `$${path
1870
- .map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `."${String(seg).replace(/"/g, '\\"')}"`)
2332
+ .map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `.${JSON.stringify(String(seg))}`)
1871
2333
  .join('')}`;
1872
2334
  }
1873
2335
  /**
@@ -1878,7 +2340,7 @@ export function jsonPathParam(qi, path, nativeForm) {
1878
2340
  * SQL Server have no `::` operator) as a float cast.
1879
2341
  */
1880
2342
  export function castJsonNumeric(qi, extract) {
1881
- if (qi.dialect.name === 'postgresql')
2343
+ if (qi.dialect.capabilities.jsonExactNumericCast)
1882
2344
  return `(${extract})::numeric`;
1883
2345
  return qi.dialect.castAggregate ? qi.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
1884
2346
  }
@@ -1888,6 +2350,7 @@ export function castJsonNumeric(qi, extract) {
1888
2350
  */
1889
2351
  export function buildArrayFilterClauses(qi, column, filter, params, pgType) {
1890
2352
  requireArrayColumns(qi);
2353
+ assertArrayFilter(filter, column);
1891
2354
  const clauses = [];
1892
2355
  const elementType = getArrayElementType(qi, pgType);
1893
2356
  if (filter.has !== undefined) {