@tanstack/electric-db-collection 0.1.44 → 0.2.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.
@@ -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
+ }