Skip to content

Commit 6cedd33

Browse files
kevin-dpsamwillis
authored andcommitted
Compile IR to SQL
1 parent c5eb1ec commit 6cedd33

File tree

3 files changed

+182
-6
lines changed

3 files changed

+182
-6
lines changed

packages/db/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export type SyncConfigRes = {
162162
cleanup?: CleanupFn
163163
onLoadMore?: (options: OnLoadMoreOptions) => void | Promise<void>
164164
}
165+
165166
export interface SyncConfig<
166167
T extends object = Record<string, unknown>,
167168
TKey extends string | number = string | number,

packages/electric-db-collection/src/electric.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
ExpectedNumberInAwaitTxIdError,
1313
TimeoutWaitingForTxIdError,
1414
} from "./errors"
15+
import { compileSQL } from "./sql-compiler"
1516
import type {
1617
BaseCollectionConfig,
1718
CollectionConfig,
1819
DeleteMutationFnParams,
1920
Fn,
2021
InsertMutationFnParams,
22+
OnLoadMoreOptions,
2123
SyncConfig,
2224
UpdateMutationFnParams,
2325
UtilsRecord,
@@ -416,15 +418,42 @@ function createElectricSync<T extends Row<unknown>>(
416418
}
417419
})
418420

419-
// Return the unsubscribe function
420-
return () => {
421-
// Unsubscribe from the stream
422-
unsubscribeStream()
423-
// Abort the abort controller to stop the stream
424-
abortController.abort()
421+
return {
422+
onLoadMore: (opts) => onLoadMore(params, opts),
423+
cleanup: () => {
424+
// Unsubscribe from the stream
425+
unsubscribeStream()
426+
// Abort the abort controller to stop the stream
427+
abortController.abort()
428+
},
425429
}
426430
},
427431
// Expose the getSyncMetadata function
428432
getSyncMetadata,
429433
}
430434
}
435+
436+
async function onLoadMore<T extends Row<unknown>>(
437+
syncParams: Parameters<SyncConfig<T>[`sync`]>[0],
438+
options: OnLoadMoreOptions
439+
) {
440+
const { begin, write, commit } = syncParams
441+
442+
// TODO: optimize this by keeping track of which snapshot have been loaded already
443+
// and only load this one if it's not a subset of the ones that have been loaded already
444+
445+
const snapshotParams = compileSQL<T>(options)
446+
447+
const snapshot = await requestSnapshot(snapshotParams)
448+
449+
begin()
450+
451+
snapshot.data.forEach((row) => {
452+
write({
453+
type: `insert`,
454+
value: row.value,
455+
})
456+
})
457+
458+
commit()
459+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import type { IR, OnLoadMoreOptions } from "@tanstack/db"
2+
3+
export function compileSQL<T>(
4+
options: OnLoadMoreOptions
5+
): ExternalSubsetParamsRecord {
6+
const { where, orderBy, limit } = options
7+
8+
const params: Array<T> = []
9+
const compiledSQL: ExternalSubsetParamsRecord = { params }
10+
11+
if (where) {
12+
// TODO: this only works when the where expression's PropRefs directly reference a column of the collection
13+
// 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)
14+
compiledSQL.where = compileBasicExpression(where, params)
15+
}
16+
17+
if (orderBy) {
18+
compiledSQL.orderBy = compileOrderBy(orderBy, params)
19+
}
20+
21+
if (limit) {
22+
compiledSQL.limit = limit
23+
}
24+
25+
return compiledSQL
26+
}
27+
28+
/**
29+
* Compiles the expression to a SQL string and mutates the params array with the values.
30+
* @param exp - The expression to compile
31+
* @param params - The params array
32+
* @returns The compiled SQL string
33+
*/
34+
function compileBasicExpression(
35+
exp: IR.BasicExpression<unknown>,
36+
params: Array<unknown>
37+
): string {
38+
switch (exp.type) {
39+
case `val`:
40+
params.push(exp.value)
41+
return `$${params.length}`
42+
case `ref`:
43+
if (exp.path.length !== 1) {
44+
throw new Error(
45+
`Compiler can't handle nested properties: ${exp.path.join(`.`)}`
46+
)
47+
}
48+
return exp.path[0]!
49+
case `func`:
50+
return compileFunction(exp, params)
51+
}
52+
}
53+
54+
function compileOrderBy(orderBy: IR.OrderBy, params: Array<unknown>): string {
55+
const compiledOrderByClauses = orderBy.map((clause: IR.OrderByClause) =>
56+
compileOrderByClause(clause, params)
57+
)
58+
return compiledOrderByClauses.join(`,`)
59+
}
60+
61+
function compileOrderByClause(
62+
clause: IR.OrderByClause,
63+
params: Array<unknown>
64+
): string {
65+
// TODO: what to do with stringSort and locale?
66+
// Correctly supporting them is tricky as it depends on Postgres' collation
67+
const { expression, compareOptions } = clause
68+
let sql = compileBasicExpression(expression, params)
69+
70+
if (compareOptions.direction === `desc`) {
71+
sql = `${sql} DESC`
72+
}
73+
74+
if (compareOptions.nulls === `first`) {
75+
sql = `${sql} NULLS FIRST`
76+
}
77+
78+
if (compareOptions.nulls === `last`) {
79+
sql = `${sql} NULLS LAST`
80+
}
81+
82+
return sql
83+
}
84+
85+
function compileFunction(
86+
exp: IR.Func<unknown>,
87+
params: Array<unknown> = []
88+
): string {
89+
const { name, args } = exp
90+
91+
const opName = getOpName(name)
92+
93+
const compiledArgs = args.map((arg: IR.BasicExpression) =>
94+
compileBasicExpression(arg, params)
95+
)
96+
97+
if (isBinaryOp(name)) {
98+
if (compiledArgs.length !== 2) {
99+
throw new Error(`Binary operator ${name} expects 2 arguments`)
100+
}
101+
const [lhs, rhs] = compiledArgs
102+
return `${lhs} ${opName} ${rhs}`
103+
}
104+
105+
return `${opName}(${compiledArgs.join(`,`)})`
106+
}
107+
108+
function isBinaryOp(name: string): boolean {
109+
const binaryOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `and`, `or`]
110+
return binaryOps.includes(name)
111+
}
112+
113+
function getOpName(name: string): string {
114+
const opNames = {
115+
eq: `=`,
116+
gt: `>`,
117+
gte: `>=`,
118+
lt: `<`,
119+
lte: `<=`,
120+
add: `+`,
121+
and: `AND`,
122+
or: `OR`,
123+
not: `NOT`,
124+
isUndefined: `IS NULL`,
125+
isNull: `IS NULL`,
126+
in: `IN`,
127+
like: `LIKE`,
128+
ilike: `ILIKE`,
129+
upper: `UPPER`,
130+
lower: `LOWER`,
131+
length: `LENGTH`,
132+
concat: `CONCAT`,
133+
coalesce: `COALESCE`,
134+
}
135+
return opNames[name as keyof typeof opNames] || name
136+
}
137+
138+
// TODO: remove this type once we rebase on top of Ilia's PR
139+
// that type will be exported by Ilia's PR
140+
export type ExternalSubsetParamsRecord = {
141+
where?: string
142+
params?: Record<string, any>
143+
limit?: number
144+
offset?: number
145+
orderBy?: string
146+
}

0 commit comments

Comments
 (0)