@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.
- package/dist/cjs/electric.cjs +45 -4
- package/dist/cjs/electric.cjs.map +1 -1
- package/dist/cjs/errors.cjs.map +1 -1
- package/dist/cjs/index.cjs +9 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +1 -1
- package/dist/cjs/sql-compiler.cjs +92 -0
- package/dist/cjs/sql-compiler.cjs.map +1 -1
- package/dist/esm/electric.js +46 -5
- package/dist/esm/electric.js.map +1 -1
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +4 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/sql-compiler.js +92 -0
- package/dist/esm/sql-compiler.js.map +1 -1
- package/package.json +33 -33
- package/src/electric.ts +133 -60
- package/src/errors.ts +1 -1
- package/src/index.ts +4 -2
- package/src/sql-compiler.ts +169 -10
package/src/sql-compiler.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { serialize } from
|
|
2
|
-
import type { SubsetParams } from
|
|
3
|
-
import type { IR, LoadSubsetOptions } from
|
|
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: `=`,
|