@tanstack/electric-db-collection 0.1.44 → 0.2.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.
- package/dist/cjs/electric.cjs +44 -7
- package/dist/cjs/electric.cjs.map +1 -1
- package/dist/cjs/electric.d.cts +38 -6
- package/dist/cjs/pg-serializer.cjs +35 -0
- package/dist/cjs/pg-serializer.cjs.map +1 -0
- package/dist/cjs/pg-serializer.d.cts +12 -0
- package/dist/cjs/sql-compiler.cjs +151 -0
- package/dist/cjs/sql-compiler.cjs.map +1 -0
- package/dist/cjs/sql-compiler.d.cts +6 -0
- package/dist/esm/electric.d.ts +38 -6
- package/dist/esm/electric.js +44 -7
- package/dist/esm/electric.js.map +1 -1
- package/dist/esm/pg-serializer.d.ts +12 -0
- package/dist/esm/pg-serializer.js +35 -0
- package/dist/esm/pg-serializer.js.map +1 -0
- package/dist/esm/sql-compiler.d.ts +6 -0
- package/dist/esm/sql-compiler.js +151 -0
- package/dist/esm/sql-compiler.js.map +1 -0
- package/package.json +7 -4
- package/src/electric.ts +110 -18
- package/src/pg-serializer.ts +58 -0
- package/src/sql-compiler.ts +220 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { serialize } from "./pg-serializer"
|
|
2
|
+
import type { SubsetParams } from "@electric-sql/client"
|
|
3
|
+
import type { IR, LoadSubsetOptions } from "@tanstack/db"
|
|
4
|
+
|
|
5
|
+
export type CompiledSqlRecord = Omit<SubsetParams, `params`> & {
|
|
6
|
+
params?: Array<unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function compileSQL<T>(options: LoadSubsetOptions): SubsetParams {
|
|
10
|
+
const { where, orderBy, limit } = options
|
|
11
|
+
|
|
12
|
+
const params: Array<T> = []
|
|
13
|
+
const compiledSQL: CompiledSqlRecord = { params }
|
|
14
|
+
|
|
15
|
+
if (where) {
|
|
16
|
+
// TODO: this only works when the where expression's PropRefs directly reference a column of the collection
|
|
17
|
+
// doesn't work if it goes through aliases because then we need to know the entire query to be able to follow the reference until the base collection (cf. followRef function)
|
|
18
|
+
compiledSQL.where = compileBasicExpression(where, params)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (orderBy) {
|
|
22
|
+
compiledSQL.orderBy = compileOrderBy(orderBy, params)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (limit) {
|
|
26
|
+
compiledSQL.limit = limit
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// WORKAROUND for Electric bug: Empty subset requests don't load data
|
|
30
|
+
// Add dummy "true = true" predicate when there's no where clause
|
|
31
|
+
// This is always true so doesn't filter data, just tricks Electric into loading
|
|
32
|
+
if (!where) {
|
|
33
|
+
compiledSQL.where = `true = true`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Serialize the values in the params array into PG formatted strings
|
|
37
|
+
// and transform the array into a Record<string, string>
|
|
38
|
+
const paramsRecord = params.reduce(
|
|
39
|
+
(acc, param, index) => {
|
|
40
|
+
const serialized = serialize(param)
|
|
41
|
+
// Only include non-empty values in params
|
|
42
|
+
// Empty strings from null/undefined should be omitted
|
|
43
|
+
if (serialized !== ``) {
|
|
44
|
+
acc[`${index + 1}`] = serialized
|
|
45
|
+
}
|
|
46
|
+
return acc
|
|
47
|
+
},
|
|
48
|
+
{} as Record<string, string>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...compiledSQL,
|
|
53
|
+
params: paramsRecord,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Quote PostgreSQL identifiers to handle mixed case column names correctly.
|
|
59
|
+
* Electric/Postgres requires quotes for case-sensitive identifiers.
|
|
60
|
+
* @param name - The identifier to quote
|
|
61
|
+
* @returns The quoted identifier
|
|
62
|
+
*/
|
|
63
|
+
function quoteIdentifier(name: string): string {
|
|
64
|
+
return `"${name}"`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compiles the expression to a SQL string and mutates the params array with the values.
|
|
69
|
+
* @param exp - The expression to compile
|
|
70
|
+
* @param params - The params array
|
|
71
|
+
* @returns The compiled SQL string
|
|
72
|
+
*/
|
|
73
|
+
function compileBasicExpression(
|
|
74
|
+
exp: IR.BasicExpression<unknown>,
|
|
75
|
+
params: Array<unknown>
|
|
76
|
+
): string {
|
|
77
|
+
switch (exp.type) {
|
|
78
|
+
case `val`:
|
|
79
|
+
params.push(exp.value)
|
|
80
|
+
return `$${params.length}`
|
|
81
|
+
case `ref`:
|
|
82
|
+
// TODO: doesn't yet support JSON(B) values which could be accessed with nested props
|
|
83
|
+
if (exp.path.length !== 1) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Compiler can't handle nested properties: ${exp.path.join(`.`)}`
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
return quoteIdentifier(exp.path[0]!)
|
|
89
|
+
case `func`:
|
|
90
|
+
return compileFunction(exp, params)
|
|
91
|
+
default:
|
|
92
|
+
throw new Error(`Unknown expression type`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function compileOrderBy(orderBy: IR.OrderBy, params: Array<unknown>): string {
|
|
97
|
+
const compiledOrderByClauses = orderBy.map((clause: IR.OrderByClause) =>
|
|
98
|
+
compileOrderByClause(clause, params)
|
|
99
|
+
)
|
|
100
|
+
return compiledOrderByClauses.join(`,`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function compileOrderByClause(
|
|
104
|
+
clause: IR.OrderByClause,
|
|
105
|
+
params: Array<unknown>
|
|
106
|
+
): string {
|
|
107
|
+
// FIXME: We should handle stringSort and locale.
|
|
108
|
+
// Correctly supporting them is tricky as it depends on Postgres' collation
|
|
109
|
+
const { expression, compareOptions } = clause
|
|
110
|
+
let sql = compileBasicExpression(expression, params)
|
|
111
|
+
|
|
112
|
+
if (compareOptions.direction === `desc`) {
|
|
113
|
+
sql = `${sql} DESC`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (compareOptions.nulls === `first`) {
|
|
117
|
+
sql = `${sql} NULLS FIRST`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (compareOptions.nulls === `last`) {
|
|
121
|
+
sql = `${sql} NULLS LAST`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return sql
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function compileFunction(
|
|
128
|
+
exp: IR.Func<unknown>,
|
|
129
|
+
params: Array<unknown> = []
|
|
130
|
+
): string {
|
|
131
|
+
const { name, args } = exp
|
|
132
|
+
|
|
133
|
+
const opName = getOpName(name)
|
|
134
|
+
|
|
135
|
+
const compiledArgs = args.map((arg: IR.BasicExpression) =>
|
|
136
|
+
compileBasicExpression(arg, params)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// Special case for IS NULL / IS NOT NULL - these are postfix operators
|
|
140
|
+
if (name === `isNull` || name === `isUndefined`) {
|
|
141
|
+
if (compiledArgs.length !== 1) {
|
|
142
|
+
throw new Error(`${name} expects 1 argument`)
|
|
143
|
+
}
|
|
144
|
+
return `${compiledArgs[0]} ${opName}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Special case for NOT - unary prefix operator
|
|
148
|
+
if (name === `not`) {
|
|
149
|
+
if (compiledArgs.length !== 1) {
|
|
150
|
+
throw new Error(`NOT expects 1 argument`)
|
|
151
|
+
}
|
|
152
|
+
// Check if the argument is IS NULL to generate IS NOT NULL
|
|
153
|
+
const arg = args[0]
|
|
154
|
+
if (arg && arg.type === `func`) {
|
|
155
|
+
const funcArg = arg
|
|
156
|
+
if (funcArg.name === `isNull` || funcArg.name === `isUndefined`) {
|
|
157
|
+
const innerArg = compileBasicExpression(funcArg.args[0]!, params)
|
|
158
|
+
return `${innerArg} IS NOT NULL`
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return `${opName} (${compiledArgs[0]})`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (isBinaryOp(name)) {
|
|
165
|
+
// Special handling for AND/OR which can be variadic
|
|
166
|
+
if ((name === `and` || name === `or`) && compiledArgs.length > 2) {
|
|
167
|
+
// Chain multiple arguments: (a AND b AND c) or (a OR b OR c)
|
|
168
|
+
return compiledArgs.map((arg) => `(${arg})`).join(` ${opName} `)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (compiledArgs.length !== 2) {
|
|
172
|
+
throw new Error(`Binary operator ${name} expects 2 arguments`)
|
|
173
|
+
}
|
|
174
|
+
const [lhs, rhs] = compiledArgs
|
|
175
|
+
// Special case for = ANY operator which needs parentheses around the array parameter
|
|
176
|
+
if (name === `in`) {
|
|
177
|
+
return `${lhs} ${opName}(${rhs})`
|
|
178
|
+
}
|
|
179
|
+
return `${lhs} ${opName} ${rhs}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return `${opName}(${compiledArgs.join(`,`)})`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isBinaryOp(name: string): boolean {
|
|
186
|
+
const binaryOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `and`, `or`, `in`]
|
|
187
|
+
return binaryOps.includes(name)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getOpName(name: string): string {
|
|
191
|
+
const opNames = {
|
|
192
|
+
eq: `=`,
|
|
193
|
+
gt: `>`,
|
|
194
|
+
gte: `>=`,
|
|
195
|
+
lt: `<`,
|
|
196
|
+
lte: `<=`,
|
|
197
|
+
add: `+`,
|
|
198
|
+
and: `AND`,
|
|
199
|
+
or: `OR`,
|
|
200
|
+
not: `NOT`,
|
|
201
|
+
isUndefined: `IS NULL`,
|
|
202
|
+
isNull: `IS NULL`,
|
|
203
|
+
in: `= ANY`, // Use = ANY syntax for array parameters
|
|
204
|
+
like: `LIKE`,
|
|
205
|
+
ilike: `ILIKE`,
|
|
206
|
+
upper: `UPPER`,
|
|
207
|
+
lower: `LOWER`,
|
|
208
|
+
length: `LENGTH`,
|
|
209
|
+
concat: `CONCAT`,
|
|
210
|
+
coalesce: `COALESCE`,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const opName = opNames[name as keyof typeof opNames]
|
|
214
|
+
|
|
215
|
+
if (!opName) {
|
|
216
|
+
throw new Error(`Unknown operator/function: ${name}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return opName
|
|
220
|
+
}
|