@tanstack/electric-db-collection 0.4.8 → 0.5.1

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.
@@ -88,7 +88,7 @@ function quoteIdentifier(
88
88
  encodeColumnName?: ColumnEncoder,
89
89
  ): string {
90
90
  const columnName = encodeColumnName ? encodeColumnName(name) : name
91
- return `"${columnName}"`
91
+ return `"${columnName.replace(/"/g, `""`)}"`
92
92
  }
93
93
 
94
94
  /**
@@ -108,7 +108,6 @@ function compileBasicExpression(
108
108
  params.push(exp.value)
109
109
  return `$${params.length}`
110
110
  case `ref`:
111
- // TODO: doesn't yet support JSON(B) values which could be accessed with nested props
112
111
  if (exp.path.length !== 1) {
113
112
  throw new Error(
114
113
  `Compiler can't handle nested properties: ${exp.path.join(`.`)}`,
@@ -194,9 +193,68 @@ function compileFunction(
194
193
  }
195
194
  }
196
195
 
197
- const compiledArgs = args.map((arg: IR.BasicExpression) =>
198
- compileBasicExpression(arg, params, encodeColumnName),
196
+ if (name === `not`) {
197
+ if (args.length !== 1) throw new Error(`NOT expects 1 argument`)
198
+ const arg = args[0]
199
+ if (
200
+ arg?.type === `func` &&
201
+ (arg.name === `isNull` || arg.name === `isUndefined`)
202
+ ) {
203
+ if (arg.args.length !== 1) {
204
+ throw new Error(`${arg.name} expects 1 argument`)
205
+ }
206
+ const innerArg = arg.args[0]!
207
+ const compiled = compileBasicExpression(
208
+ innerArg,
209
+ params,
210
+ encodeColumnName,
211
+ )
212
+ return `${innerArg.type === `func` ? `(${compiled})` : compiled} IS NOT NULL`
213
+ }
214
+ }
215
+
216
+ const booleanLiteralIndex = args.findIndex(
217
+ (arg) => arg.type === `val` && typeof arg.value === `boolean`,
199
218
  )
219
+ if (
220
+ args.length === 2 &&
221
+ isBooleanComparisonOp(name) &&
222
+ booleanLiteralIndex !== -1
223
+ ) {
224
+ const literalValue = (args[booleanLiteralIndex] as IR.Value<boolean>).value
225
+ const valueArg = args[booleanLiteralIndex === 0 ? 1 : 0]!
226
+ const compiled = compileBasicExpression(valueArg, params, encodeColumnName)
227
+ const value = `(${compiled})`
228
+ const lessThan =
229
+ (name === `lt` || name === `lte`) === (booleanLiteralIndex === 1)
230
+ const inclusive = name === `lte` || name === `gte`
231
+
232
+ if (inclusive && literalValue === lessThan) {
233
+ return `${value} = ${value}`
234
+ }
235
+ if (!inclusive && literalValue !== lessThan) {
236
+ return `${value} <> ${value}`
237
+ }
238
+ return `${value} = ${lessThan ? `FALSE` : `TRUE`}`
239
+ }
240
+
241
+ const compiledArgs = args.map((arg: IR.BasicExpression) => {
242
+ const compiled = compileBasicExpression(arg, params, encodeColumnName)
243
+ // AND/OR group their children by precedence; NOT already wraps its operand.
244
+ // In value positions, preserve any nested operator as a single expression.
245
+ return arg.type === `func` &&
246
+ (arg.name === `and` ||
247
+ arg.name === `or` ||
248
+ (name !== `and` &&
249
+ name !== `or` &&
250
+ name !== `not` &&
251
+ (isBinaryOp(arg.name) ||
252
+ arg.name === `not` ||
253
+ arg.name === `isNull` ||
254
+ arg.name === `isUndefined`)))
255
+ ? `(${compiled})`
256
+ : compiled
257
+ })
200
258
 
201
259
  // Special case for IS NULL / IS NOT NULL - these are postfix operators
202
260
  if (name === `isNull` || name === `isUndefined`) {
@@ -208,22 +266,6 @@ function compileFunction(
208
266
 
209
267
  // Special case for NOT - unary prefix operator
210
268
  if (name === `not`) {
211
- if (compiledArgs.length !== 1) {
212
- throw new Error(`NOT expects 1 argument`)
213
- }
214
- // Check if the argument is IS NULL to generate IS NOT NULL
215
- const arg = args[0]
216
- if (arg && arg.type === `func`) {
217
- const funcArg = arg
218
- if (funcArg.name === `isNull` || funcArg.name === `isUndefined`) {
219
- const innerArg = compileBasicExpression(
220
- funcArg.args[0]!,
221
- params,
222
- encodeColumnName,
223
- )
224
- return `${innerArg} IS NOT NULL`
225
- }
226
- }
227
269
  return `${opName} (${compiledArgs[0]})`
228
270
  }
229
271
 
@@ -231,7 +273,7 @@ function compileFunction(
231
273
  // Special handling for AND/OR which can be variadic
232
274
  if ((name === `and` || name === `or`) && compiledArgs.length > 2) {
233
275
  // Chain multiple arguments: (a AND b AND c) or (a OR b OR c)
234
- return compiledArgs.map((arg) => `(${arg})`).join(` ${opName} `)
276
+ return compiledArgs.join(` ${opName} `)
235
277
  }
236
278
 
237
279
  if (compiledArgs.length !== 2) {
@@ -239,125 +281,20 @@ function compileFunction(
239
281
  }
240
282
  const [lhs, rhs] = compiledArgs
241
283
 
242
- // Special case for comparison operators with boolean values
243
- // PostgreSQL doesn't support < > <= >= on booleans
244
- // Transform to equivalent equality checks or constant expressions
245
- if (isBooleanComparisonOp(name)) {
246
- const lhsArg = args[0]
247
- const rhsArg = args[1]
248
-
249
- // Check if RHS is a boolean literal value
250
- if (
251
- rhsArg &&
252
- rhsArg.type === `val` &&
253
- typeof rhsArg.value === `boolean`
254
- ) {
255
- const boolValue = rhsArg.value
256
- // Remove the boolean param we just added since we'll transform the expression
257
- params.pop()
258
-
259
- // Transform based on operator and boolean value
260
- // Boolean ordering: false < true
261
- if (name === `lt`) {
262
- if (boolValue === true) {
263
- // lt(col, true) → col = false (only false is less than true)
264
- params.push(false)
265
- return `${lhs} = $${params.length}`
266
- } else {
267
- // lt(col, false) → nothing is less than false
268
- return `false`
269
- }
270
- } else if (name === `gt`) {
271
- if (boolValue === false) {
272
- // gt(col, false) → col = true (only true is greater than false)
273
- params.push(true)
274
- return `${lhs} = $${params.length}`
275
- } else {
276
- // gt(col, true) → nothing is greater than true
277
- return `false`
278
- }
279
- } else if (name === `lte`) {
280
- if (boolValue === true) {
281
- // lte(col, true) → everything is ≤ true
282
- return `true`
283
- } else {
284
- // lte(col, false) → col = false
285
- params.push(false)
286
- return `${lhs} = $${params.length}`
287
- }
288
- } else if (name === `gte`) {
289
- if (boolValue === false) {
290
- // gte(col, false) → everything is ≥ false
291
- return `true`
292
- } else {
293
- // gte(col, true) → col = true
294
- params.push(true)
295
- return `${lhs} = $${params.length}`
296
- }
297
- }
298
- }
284
+ if (name === `in`) {
285
+ const valueArg = args[0]!
286
+ const arrayArg = args[1]!
299
287
 
300
- // Check if LHS is a boolean literal value (less common but handle it)
301
- if (
302
- lhsArg &&
303
- lhsArg.type === `val` &&
304
- typeof lhsArg.value === `boolean`
305
- ) {
306
- const boolValue = lhsArg.value
307
- // Remove params for this expression and rebuild
308
- params.pop() // remove RHS
309
- params.pop() // remove LHS (boolean)
310
-
311
- // Recompile RHS to get fresh param
312
- const rhsCompiled = compileBasicExpression(
313
- rhsArg!,
314
- params,
315
- encodeColumnName,
316
- )
288
+ if (valueArg.type === `val` && Array.isArray(valueArg.value)) {
289
+ throw new Error(`Array-valued 'in' left operand; expected a scalar`)
290
+ }
317
291
 
318
- // Transform: flip the comparison (val op col → col flipped_op val)
319
- if (name === `lt`) {
320
- // lt(true, col) → gt(col, true) → col > true → nothing is greater than true
321
- if (boolValue === true) {
322
- return `false`
323
- } else {
324
- // lt(false, col) → gt(col, false) → col = true
325
- params.push(true)
326
- return `${rhsCompiled} = $${params.length}`
327
- }
328
- } else if (name === `gt`) {
329
- // gt(true, col) → lt(col, true) → col = false
330
- if (boolValue === true) {
331
- params.push(false)
332
- return `${rhsCompiled} = $${params.length}`
333
- } else {
334
- // gt(false, col) → lt(col, false) → nothing is less than false
335
- return `false`
336
- }
337
- } else if (name === `lte`) {
338
- if (boolValue === false) {
339
- // lte(false, col) → gte(col, false) → everything
340
- return `true`
341
- } else {
342
- // lte(true, col) → gte(col, true) → col = true
343
- params.push(true)
344
- return `${rhsCompiled} = $${params.length}`
345
- }
346
- } else if (name === `gte`) {
347
- if (boolValue === true) {
348
- // gte(true, col) → lte(col, true) → everything
349
- return `true`
350
- } else {
351
- // gte(false, col) → lte(col, false) → col = false
352
- params.push(false)
353
- return `${rhsCompiled} = $${params.length}`
354
- }
355
- }
292
+ if (arrayArg.type === `ref` && valueArg.type === `val`) {
293
+ // Resolve literal parameters from the array element type before containment.
294
+ return `${lhs} = ANY(${rhs}) AND ${rhs} @> ARRAY[${lhs}] AND ${rhs} IS NOT NULL`
356
295
  }
357
- }
358
296
 
359
- // Special case for = ANY operator which needs parentheses around the array parameter
360
- if (name === `in`) {
297
+ // Literal value lists and ref/ref membership retain the original = ANY form.
361
298
  return `${lhs} ${opName}(${rhs})`
362
299
  }
363
300
  return `${lhs} ${opName} ${rhs}`
@@ -388,7 +325,7 @@ function isBinaryOp(name: string): boolean {
388
325
  * (null comparisons in SQL always evaluate to UNKNOWN)
389
326
  */
390
327
  function isComparisonOp(name: string): boolean {
391
- const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`]
328
+ const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`]
392
329
  return comparisonOps.includes(name)
393
330
  }
394
331