@tanstack/electric-db-collection 0.2.12 → 0.2.14

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.
@@ -1,6 +1,6 @@
1
- import { serialize } from "./pg-serializer"
2
- import type { SubsetParams } from "@electric-sql/client"
3
- import type { IR, LoadSubsetOptions } from "@tanstack/db"
1
+ import { serialize } from './pg-serializer'
2
+ import type { SubsetParams } from '@electric-sql/client'
3
+ import type { IR, LoadSubsetOptions } from '@tanstack/db'
4
4
 
5
5
  export type CompiledSqlRecord = Omit<SubsetParams, `params`> & {
6
6
  params?: Array<unknown>
@@ -45,7 +45,7 @@ export function compileSQL<T>(options: LoadSubsetOptions): SubsetParams {
45
45
  }
46
46
  return acc
47
47
  },
48
- {} as Record<string, string>
48
+ {} as Record<string, string>,
49
49
  )
50
50
 
51
51
  return {
@@ -72,7 +72,7 @@ function quoteIdentifier(name: string): string {
72
72
  */
73
73
  function compileBasicExpression(
74
74
  exp: IR.BasicExpression<unknown>,
75
- params: Array<unknown>
75
+ params: Array<unknown>,
76
76
  ): string {
77
77
  switch (exp.type) {
78
78
  case `val`:
@@ -82,7 +82,7 @@ function compileBasicExpression(
82
82
  // TODO: doesn't yet support JSON(B) values which could be accessed with nested props
83
83
  if (exp.path.length !== 1) {
84
84
  throw new Error(
85
- `Compiler can't handle nested properties: ${exp.path.join(`.`)}`
85
+ `Compiler can't handle nested properties: ${exp.path.join(`.`)}`,
86
86
  )
87
87
  }
88
88
  return quoteIdentifier(exp.path[0]!)
@@ -95,14 +95,14 @@ function compileBasicExpression(
95
95
 
96
96
  function compileOrderBy(orderBy: IR.OrderBy, params: Array<unknown>): string {
97
97
  const compiledOrderByClauses = orderBy.map((clause: IR.OrderByClause) =>
98
- compileOrderByClause(clause, params)
98
+ compileOrderByClause(clause, params),
99
99
  )
100
100
  return compiledOrderByClauses.join(`,`)
101
101
  }
102
102
 
103
103
  function compileOrderByClause(
104
104
  clause: IR.OrderByClause,
105
- params: Array<unknown>
105
+ params: Array<unknown>,
106
106
  ): string {
107
107
  // FIXME: We should handle stringSort and locale.
108
108
  // Correctly supporting them is tricky as it depends on Postgres' collation
@@ -124,16 +124,43 @@ function compileOrderByClause(
124
124
  return sql
125
125
  }
126
126
 
127
+ /**
128
+ * Check if a BasicExpression represents a null/undefined value
129
+ */
130
+ function isNullValue(exp: IR.BasicExpression<unknown>): boolean {
131
+ return exp.type === `val` && (exp.value === null || exp.value === undefined)
132
+ }
133
+
127
134
  function compileFunction(
128
135
  exp: IR.Func<unknown>,
129
- params: Array<unknown> = []
136
+ params: Array<unknown> = [],
130
137
  ): string {
131
138
  const { name, args } = exp
132
139
 
133
140
  const opName = getOpName(name)
134
141
 
142
+ // Handle comparison operators with null/undefined values
143
+ // These would create invalid queries with missing params (e.g., "col = $1" with empty params)
144
+ // In SQL, all comparisons with NULL return UNKNOWN, so these are almost always mistakes
145
+ if (isComparisonOp(name)) {
146
+ const nullArgIndex = args.findIndex((arg: IR.BasicExpression) =>
147
+ isNullValue(arg),
148
+ )
149
+
150
+ if (nullArgIndex !== -1) {
151
+ // All comparison operators (including eq) throw an error for null values
152
+ // Users should use isNull() or isUndefined() to check for null values
153
+ throw new Error(
154
+ `Cannot use null/undefined value with '${name}' operator. ` +
155
+ `Comparisons with null always evaluate to UNKNOWN in SQL. ` +
156
+ `Use isNull() or isUndefined() to check for null values, ` +
157
+ `or filter out null values before building the query.`,
158
+ )
159
+ }
160
+ }
161
+
135
162
  const compiledArgs = args.map((arg: IR.BasicExpression) =>
136
- compileBasicExpression(arg, params)
163
+ compileBasicExpression(arg, params),
137
164
  )
138
165
 
139
166
  // Special case for IS NULL / IS NOT NULL - these are postfix operators
@@ -172,6 +199,120 @@ function compileFunction(
172
199
  throw new Error(`Binary operator ${name} expects 2 arguments`)
173
200
  }
174
201
  const [lhs, rhs] = compiledArgs
202
+
203
+ // Special case for comparison operators with boolean values
204
+ // PostgreSQL doesn't support < > <= >= on booleans
205
+ // Transform to equivalent equality checks or constant expressions
206
+ if (isBooleanComparisonOp(name)) {
207
+ const lhsArg = args[0]
208
+ const rhsArg = args[1]
209
+
210
+ // Check if RHS is a boolean literal value
211
+ if (
212
+ rhsArg &&
213
+ rhsArg.type === `val` &&
214
+ typeof rhsArg.value === `boolean`
215
+ ) {
216
+ const boolValue = rhsArg.value
217
+ // Remove the boolean param we just added since we'll transform the expression
218
+ params.pop()
219
+
220
+ // Transform based on operator and boolean value
221
+ // Boolean ordering: false < true
222
+ if (name === `lt`) {
223
+ if (boolValue === true) {
224
+ // lt(col, true) → col = false (only false is less than true)
225
+ params.push(false)
226
+ return `${lhs} = $${params.length}`
227
+ } else {
228
+ // lt(col, false) → nothing is less than false
229
+ return `false`
230
+ }
231
+ } else if (name === `gt`) {
232
+ if (boolValue === false) {
233
+ // gt(col, false) → col = true (only true is greater than false)
234
+ params.push(true)
235
+ return `${lhs} = $${params.length}`
236
+ } else {
237
+ // gt(col, true) → nothing is greater than true
238
+ return `false`
239
+ }
240
+ } else if (name === `lte`) {
241
+ if (boolValue === true) {
242
+ // lte(col, true) → everything is ≤ true
243
+ return `true`
244
+ } else {
245
+ // lte(col, false) → col = false
246
+ params.push(false)
247
+ return `${lhs} = $${params.length}`
248
+ }
249
+ } else if (name === `gte`) {
250
+ if (boolValue === false) {
251
+ // gte(col, false) → everything is ≥ false
252
+ return `true`
253
+ } else {
254
+ // gte(col, true) → col = true
255
+ params.push(true)
256
+ return `${lhs} = $${params.length}`
257
+ }
258
+ }
259
+ }
260
+
261
+ // Check if LHS is a boolean literal value (less common but handle it)
262
+ if (
263
+ lhsArg &&
264
+ lhsArg.type === `val` &&
265
+ typeof lhsArg.value === `boolean`
266
+ ) {
267
+ const boolValue = lhsArg.value
268
+ // Remove params for this expression and rebuild
269
+ params.pop() // remove RHS
270
+ params.pop() // remove LHS (boolean)
271
+
272
+ // Recompile RHS to get fresh param
273
+ const rhsCompiled = compileBasicExpression(rhsArg!, params)
274
+
275
+ // Transform: flip the comparison (val op col → col flipped_op val)
276
+ if (name === `lt`) {
277
+ // lt(true, col) → gt(col, true) → col > true → nothing is greater than true
278
+ if (boolValue === true) {
279
+ return `false`
280
+ } else {
281
+ // lt(false, col) → gt(col, false) → col = true
282
+ params.push(true)
283
+ return `${rhsCompiled} = $${params.length}`
284
+ }
285
+ } else if (name === `gt`) {
286
+ // gt(true, col) → lt(col, true) → col = false
287
+ if (boolValue === true) {
288
+ params.push(false)
289
+ return `${rhsCompiled} = $${params.length}`
290
+ } else {
291
+ // gt(false, col) → lt(col, false) → nothing is less than false
292
+ return `false`
293
+ }
294
+ } else if (name === `lte`) {
295
+ if (boolValue === false) {
296
+ // lte(false, col) → gte(col, false) → everything
297
+ return `true`
298
+ } else {
299
+ // lte(true, col) → gte(col, true) → col = true
300
+ params.push(true)
301
+ return `${rhsCompiled} = $${params.length}`
302
+ }
303
+ } else if (name === `gte`) {
304
+ if (boolValue === true) {
305
+ // gte(true, col) → lte(col, true) → everything
306
+ return `true`
307
+ } else {
308
+ // gte(false, col) → lte(col, false) → col = false
309
+ params.push(false)
310
+ return `${rhsCompiled} = $${params.length}`
311
+ }
312
+ }
313
+ }
314
+ }
315
+
175
316
  // Special case for = ANY operator which needs parentheses around the array parameter
176
317
  if (name === `in`) {
177
318
  return `${lhs} ${opName}(${rhs})`
@@ -198,6 +339,24 @@ function isBinaryOp(name: string): boolean {
198
339
  return binaryOps.includes(name)
199
340
  }
200
341
 
342
+ /**
343
+ * Check if operator is a comparison operator that takes two values
344
+ * These operators cannot accept null/undefined as values
345
+ * (null comparisons in SQL always evaluate to UNKNOWN)
346
+ */
347
+ function isComparisonOp(name: string): boolean {
348
+ const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`]
349
+ return comparisonOps.includes(name)
350
+ }
351
+
352
+ /**
353
+ * Checks if the operator is a comparison operator (excluding eq)
354
+ * These operators don't work on booleans in PostgreSQL without casting
355
+ */
356
+ function isBooleanComparisonOp(name: string): boolean {
357
+ return [`gt`, `gte`, `lt`, `lte`].includes(name)
358
+ }
359
+
201
360
  function getOpName(name: string): string {
202
361
  const opNames = {
203
362
  eq: `=`,