@tanstack/powersync-db-collection 0.1.37 → 0.1.38

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,354 @@
1
+ import type { IR, LoadSubsetOptions } from '@tanstack/db'
2
+
3
+ /**
4
+ * Result of compiling LoadSubsetOptions to SQLite
5
+ */
6
+ export interface SQLiteCompiledQuery {
7
+ /** The WHERE clause (without "WHERE" keyword), e.g., "price > ?" */
8
+ where?: string
9
+ /** The ORDER BY clause (without "ORDER BY" keyword), e.g., "price DESC" */
10
+ orderBy?: string
11
+ /** The LIMIT value */
12
+ limit?: number
13
+ /** Parameter values in order, to be passed to SQLite query */
14
+ params: Array<unknown>
15
+ }
16
+
17
+ /**
18
+ * Options for controlling how SQL is compiled.
19
+ */
20
+ export interface CompileSQLiteOptions {
21
+ /**
22
+ * When set, column references emit `json_extract(<jsonColumn>, '$.<columnName>')`
23
+ * instead of `"<columnName>"`. The `id` column is excluded since it's stored
24
+ * as a direct column in the tracked table.
25
+ */
26
+ jsonColumn?: string
27
+ }
28
+
29
+ /**
30
+ * Compiles TanStack DB LoadSubsetOptions to SQLite query components.
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * const compiled = compileSQLite({
35
+ * where: { type: 'func', name: 'gt', args: [
36
+ * { type: 'ref', path: ['price'] },
37
+ * { type: 'val', value: 100 }
38
+ * ]},
39
+ * orderBy: [{ expression: { type: 'ref', path: ['price'] }, compareOptions: { direction: 'desc', nulls: 'last' } }],
40
+ * limit: 50
41
+ * })
42
+ * // Result: { where: '"price" > ?', orderBy: '"price" DESC', limit: 50, params: [100] }
43
+ * ```
44
+ */
45
+ export function compileSQLite(
46
+ options: LoadSubsetOptions,
47
+ compileOptions?: CompileSQLiteOptions,
48
+ ): SQLiteCompiledQuery {
49
+ const { where, orderBy, limit } = options
50
+
51
+ const params: Array<unknown> = []
52
+ const result: SQLiteCompiledQuery = { params }
53
+
54
+ if (where) {
55
+ result.where = compileExpression(where, params, compileOptions)
56
+ }
57
+
58
+ if (orderBy) {
59
+ result.orderBy = compileOrderBy(orderBy, params, compileOptions)
60
+ }
61
+
62
+ if (limit !== undefined) {
63
+ result.limit = limit
64
+ }
65
+
66
+ return result
67
+ }
68
+
69
+ /**
70
+ * Quote SQLite identifiers to handle column names correctly.
71
+ * SQLite uses double quotes for identifiers.
72
+ */
73
+ function quoteIdentifier(name: string): string {
74
+ // Escape any double quotes in the name by doubling them
75
+ const escaped = name.replace(/"/g, `""`)
76
+ return `"${escaped}"`
77
+ }
78
+
79
+ /**
80
+ * Compiles a BasicExpression to a SQL string, mutating the params array.
81
+ */
82
+ function compileExpression(
83
+ exp: IR.BasicExpression<unknown>,
84
+ params: Array<unknown>,
85
+ compileOptions?: CompileSQLiteOptions,
86
+ ): string {
87
+ switch (exp.type) {
88
+ case `val`:
89
+ params.push(exp.value)
90
+ return `?`
91
+ case `ref`: {
92
+ if (exp.path.length !== 1) {
93
+ throw new Error(
94
+ `SQLite compiler doesn't support nested properties: ${exp.path.join(`.`)}`,
95
+ )
96
+ }
97
+ const columnName = exp.path[0]!
98
+ if (compileOptions?.jsonColumn && columnName !== `id`) {
99
+ return `json_extract(${compileOptions.jsonColumn}, '$.${columnName}')`
100
+ }
101
+ return quoteIdentifier(columnName)
102
+ }
103
+ case `func`:
104
+ return compileFunction(exp, params, compileOptions)
105
+ default:
106
+ throw new Error(`Unknown expression type: ${(exp as any).type}`)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Compiles an OrderBy array to a SQL ORDER BY clause.
112
+ */
113
+ function compileOrderBy(
114
+ orderBy: IR.OrderBy,
115
+ params: Array<unknown>,
116
+ compileOptions?: CompileSQLiteOptions,
117
+ ): string {
118
+ const clauses = orderBy.map((clause: IR.OrderByClause) =>
119
+ compileOrderByClause(clause, params, compileOptions),
120
+ )
121
+ return clauses.join(`, `)
122
+ }
123
+
124
+ /**
125
+ * Compiles a single OrderByClause to SQL.
126
+ */
127
+ function compileOrderByClause(
128
+ clause: IR.OrderByClause,
129
+ params: Array<unknown>,
130
+ compileOptions?: CompileSQLiteOptions,
131
+ ): string {
132
+ const { expression, compareOptions } = clause
133
+ let sql = compileExpression(expression, params, compileOptions)
134
+
135
+ if (compareOptions.direction === `desc`) {
136
+ sql = `${sql} DESC`
137
+ }
138
+
139
+ // SQLite supports NULLS FIRST/LAST (since 3.30.0)
140
+ if (compareOptions.nulls === `first`) {
141
+ sql = `${sql} NULLS FIRST`
142
+ } else {
143
+ // Default to NULLS LAST (nulls === 'last')
144
+ sql = `${sql} NULLS LAST`
145
+ }
146
+
147
+ return sql
148
+ }
149
+
150
+ /**
151
+ * Check if a BasicExpression represents a null/undefined value
152
+ */
153
+ function isNullValue(exp: IR.BasicExpression<unknown>): boolean {
154
+ return exp.type === `val` && (exp.value === null || exp.value === undefined)
155
+ }
156
+
157
+ /**
158
+ * Compiles a function expression (operator) to SQL.
159
+ */
160
+ function compileFunction(
161
+ exp: IR.Func<unknown>,
162
+ params: Array<unknown>,
163
+ compileOptions?: CompileSQLiteOptions,
164
+ ): string {
165
+ const { name, args } = exp
166
+
167
+ // Check for null values in comparison operators
168
+ if (isComparisonOp(name)) {
169
+ const hasNullArg = args.some((arg: IR.BasicExpression) => isNullValue(arg))
170
+ if (hasNullArg) {
171
+ throw new Error(
172
+ `Cannot use null/undefined with '${name}' operator. ` +
173
+ `Use isNull() to check for null values.`,
174
+ )
175
+ }
176
+ }
177
+
178
+ // Compile arguments
179
+ const compiledArgs = args.map((arg: IR.BasicExpression) =>
180
+ compileExpression(arg, params, compileOptions),
181
+ )
182
+
183
+ // Handle different operator types
184
+ switch (name) {
185
+ // Binary comparison operators
186
+ case `eq`:
187
+ case `gt`:
188
+ case `gte`:
189
+ case `lt`:
190
+ case `lte`: {
191
+ if (compiledArgs.length !== 2) {
192
+ throw new Error(`${name} expects 2 arguments`)
193
+ }
194
+ const opSymbol = getComparisonOp(name)
195
+ return `${compiledArgs[0]} ${opSymbol} ${compiledArgs[1]}`
196
+ }
197
+
198
+ // Logical operators
199
+ case `and`:
200
+ case `or`: {
201
+ if (compiledArgs.length < 2) {
202
+ throw new Error(`${name} expects at least 2 arguments`)
203
+ }
204
+ const opKeyword = name === `and` ? `AND` : `OR`
205
+ return compiledArgs
206
+ .map((arg: string) => `(${arg})`)
207
+ .join(` ${opKeyword} `)
208
+ }
209
+
210
+ case `not`: {
211
+ if (compiledArgs.length !== 1) {
212
+ throw new Error(`not expects 1 argument`)
213
+ }
214
+ // Check if argument is isNull/isUndefined for IS NOT NULL
215
+ const arg = args[0]
216
+ if (arg && arg.type === `func`) {
217
+ if (arg.name === `isNull` || arg.name === `isUndefined`) {
218
+ const innerArg = compileExpression(
219
+ arg.args[0]!,
220
+ params,
221
+ compileOptions,
222
+ )
223
+ return `${innerArg} IS NOT NULL`
224
+ }
225
+ }
226
+ return `NOT (${compiledArgs[0]})`
227
+ }
228
+
229
+ // Null checking
230
+ case `isNull`:
231
+ case `isUndefined`: {
232
+ if (compiledArgs.length !== 1) {
233
+ throw new Error(`${name} expects 1 argument`)
234
+ }
235
+ return `${compiledArgs[0]} IS NULL`
236
+ }
237
+
238
+ // IN operator
239
+ case `in`: {
240
+ if (compiledArgs.length !== 2) {
241
+ throw new Error(`in expects 2 arguments (column and array)`)
242
+ }
243
+ // The second argument should be an array value
244
+ // We need to handle this specially - expand the array into multiple placeholders
245
+ const lastParamIndex = params.length - 1
246
+ const arrayValue = params[lastParamIndex]
247
+
248
+ if (!Array.isArray(arrayValue)) {
249
+ throw new Error(`in operator requires an array value`)
250
+ }
251
+
252
+ // Remove the array param and add individual values
253
+ params.pop()
254
+ const placeholders = arrayValue.map(() => {
255
+ params.push(arrayValue[params.length - lastParamIndex])
256
+ return `?`
257
+ })
258
+
259
+ // Re-add individual values properly
260
+ params.length = lastParamIndex // Reset to before array
261
+ for (const val of arrayValue) {
262
+ params.push(val)
263
+ }
264
+
265
+ return `${compiledArgs[0]} IN (${placeholders.join(`, `)})`
266
+ }
267
+
268
+ // String operators
269
+ case `like`: {
270
+ if (compiledArgs.length !== 2) {
271
+ throw new Error(`like expects 2 arguments`)
272
+ }
273
+ return `${compiledArgs[0]} LIKE ${compiledArgs[1]}`
274
+ }
275
+
276
+ case `ilike`: {
277
+ if (compiledArgs.length !== 2) {
278
+ throw new Error(`ilike expects 2 arguments`)
279
+ }
280
+ return `${compiledArgs[0]} LIKE ${compiledArgs[1]} COLLATE NOCASE`
281
+ }
282
+
283
+ // String case functions
284
+ case `upper`: {
285
+ if (compiledArgs.length !== 1) {
286
+ throw new Error(`upper expects 1 argument`)
287
+ }
288
+ return `UPPER(${compiledArgs[0]})`
289
+ }
290
+
291
+ case `lower`: {
292
+ if (compiledArgs.length !== 1) {
293
+ throw new Error(`lower expects 1 argument`)
294
+ }
295
+ return `LOWER(${compiledArgs[0]})`
296
+ }
297
+
298
+ case `length`: {
299
+ if (compiledArgs.length !== 1) {
300
+ throw new Error(`length expects 1 argument`)
301
+ }
302
+ return `LENGTH(${compiledArgs[0]})`
303
+ }
304
+
305
+ case `concat`: {
306
+ if (compiledArgs.length < 1) {
307
+ throw new Error(`concat expects at least 1 argument`)
308
+ }
309
+ return `CONCAT(${compiledArgs.join(`, `)})`
310
+ }
311
+
312
+ case `add`: {
313
+ if (compiledArgs.length !== 2) {
314
+ throw new Error(`add expects 2 arguments`)
315
+ }
316
+ return `${compiledArgs[0]} + ${compiledArgs[1]}`
317
+ }
318
+
319
+ // Null fallback
320
+ case `coalesce`: {
321
+ if (compiledArgs.length < 1) {
322
+ throw new Error(`coalesce expects at least 1 argument`)
323
+ }
324
+ return `COALESCE(${compiledArgs.join(`, `)})`
325
+ }
326
+
327
+ default:
328
+ throw new Error(
329
+ `Operator '${name}' is not supported in PowerSync on-demand sync. ` +
330
+ `Supported operators: eq, gt, gte, lt, lte, and, or, not, isNull, in, like, ilike, upper, lower, length, concat, add, coalesce`,
331
+ )
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Check if operator is a comparison operator
337
+ */
338
+ function isComparisonOp(name: string): boolean {
339
+ return [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`].includes(name)
340
+ }
341
+
342
+ /**
343
+ * Get the SQL symbol for a comparison operator
344
+ */
345
+ function getComparisonOp(name: string): string {
346
+ const ops: Record<string, string> = {
347
+ eq: `=`,
348
+ gt: `>`,
349
+ gte: `>=`,
350
+ lt: `<`,
351
+ lte: `<=`,
352
+ }
353
+ return ops[name]!
354
+ }