Skip to content

Commit 99e28c9

Browse files
committed
feat(electric-db-collection): support nested property references in SQL queries
- Implemented support for nested property references in WHERE and ORDER BY clauses by compiling to PostgreSQL JSON operators. - Added new utility functions for escaping and quoting SQL string literals. - Updated tests to cover various scenarios for nested JSON/jsonb references.
1 parent c28ab1e commit 99e28c9

3 files changed

Lines changed: 130 additions & 8 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@tanstack/electric-db-collection': patch
3+
---
4+
5+
Support nested property refs in WHERE/ORDER BY by compiling to PostgreSQL JSON/`jsonb` operators (`->` / `->>`).
6+
7+
Previously, multi-segment IR refs threw during SQL compilation. They now compile to safe JSON traversal: intermediate keys use `->`, the final key uses `->>` (text). Keys are emitted as SQL string literals with proper quote escaping.
8+
9+
**Limitation:** Nested paths apply only to JSON/`jsonb` extraction from a single root column—not Postgres composite types or dotted column names. If the physical column is not `json`/`jsonb`, the query may fail at runtime.
10+
11+
**Consumer impact:** No API changes. Queries that already used nested field refs against Electric subset loading can now generate valid SQL when the backing column is JSON/`jsonb`.

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

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,51 @@ function quoteIdentifier(
9191
return `"${columnName}"`
9292
}
9393

94+
/**
95+
* Escape content for use inside a PostgreSQL single-quoted string literal (`'...'`).
96+
*/
97+
const escapeSqlSingleQuotedString = (value: string): string =>
98+
value.replace(/'/g, `''`)
99+
100+
/**
101+
* A SQL single-quoted string literal with standard quote doubling (e.g. `O'Brien` → `'O''Brien'`).
102+
*/
103+
const quoteSqlStringLiteral = (value: string): string =>
104+
`'${escapeSqlSingleQuotedString(value)}'`
105+
106+
/**
107+
* Compile a property reference path to SQL.
108+
*
109+
* - `path.length === 1`: quoted identifier for the column (with optional `encodeColumnName`).
110+
* - `path.length >= 2`: JSON/jsonb traversal from the root column: intermediate segments use `->`
111+
* (json/jsonb), the final segment uses `->>` (text). Keys are string/array indices as emitted by the IR
112+
* (e.g. `'0'` for the first array element).
113+
*
114+
* Non-goals: nested paths do not represent Postgres composite types or dotted SQL identifiers—only
115+
* JSON/jsonb extraction from a single root column. If that column is not `json`/`jsonb`, execution may fail
116+
* at runtime.
117+
*/
118+
export const compileRefPath = (
119+
path: Array<string>,
120+
encodeColumnName?: ColumnEncoder,
121+
): string => {
122+
if (path.length === 0) {
123+
throw new Error(`Ref path must have at least one segment`)
124+
}
125+
if (path.length === 1) {
126+
return quoteIdentifier(path[0]!, encodeColumnName)
127+
}
128+
129+
let sql = quoteIdentifier(path[0]!, encodeColumnName)
130+
const keys = path.slice(1)
131+
for (let i = 0; i < keys.length; i++) {
132+
const keyLit = quoteSqlStringLiteral(keys[i]!)
133+
const isLast = i === keys.length - 1
134+
sql += isLast ? `->>${keyLit}` : `->${keyLit}`
135+
}
136+
return sql
137+
}
138+
94139
/**
95140
* Compiles the expression to a SQL string and mutates the params array with the values.
96141
* @param exp - The expression to compile
@@ -108,13 +153,7 @@ function compileBasicExpression(
108153
params.push(exp.value)
109154
return `$${params.length}`
110155
case `ref`:
111-
// TODO: doesn't yet support JSON(B) values which could be accessed with nested props
112-
if (exp.path.length !== 1) {
113-
throw new Error(
114-
`Compiler can't handle nested properties: ${exp.path.join(`.`)}`,
115-
)
116-
}
117-
return quoteIdentifier(exp.path[0]!, encodeColumnName)
156+
return compileRefPath(exp.path, encodeColumnName)
118157
case `func`:
119158
return compileFunction(exp, params, encodeColumnName)
120159
default:

packages/electric-db-collection/tests/sql-compiler.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { compileSQL } from '../src/sql-compiler'
2+
import { compileRefPath, compileSQL } from '../src/sql-compiler'
33
import type { IR } from '@tanstack/db'
44

55
// Helper to create a value expression
@@ -470,6 +470,78 @@ describe(`sql-compiler`, () => {
470470
// snake_case input remains snake_case
471471
expect(result.where).toBe(`"user_id" = $1`)
472472
})
473+
474+
it(`encodeColumnName applies only to the root column for nested refs`, () => {
475+
const result = compileSQL(
476+
{
477+
where: func(`eq`, [ref(`metaData`, `nestedKey`), val(`x`)]),
478+
},
479+
{ encodeColumnName: camelToSnake },
480+
)
481+
expect(result.where).toBe(`"meta_data"->>'nestedKey' = $1`)
482+
expect(result.params).toEqual({ '1': `x` })
483+
})
484+
})
485+
486+
describe(`nested JSON/jsonb ref paths`, () => {
487+
it(`compileRefPath: single segment matches plain column quoting`, () => {
488+
expect(compileRefPath([`name`])).toBe(`"name"`)
489+
})
490+
491+
it(`compileRefPath: two segments use final ->>`, () => {
492+
expect(compileRefPath([`doc`, `title`])).toBe(`"doc"->>'title'`)
493+
})
494+
495+
it(`compileRefPath: three segments chain -> then ->>`, () => {
496+
expect(compileRefPath([`a`, `b`, `c`])).toBe(`"a"->'b'->>'c'`)
497+
})
498+
499+
it(`compileRefPath: apostrophe in key is escaped`, () => {
500+
expect(compileRefPath([`col`, `O'Brien`])).toBe(`"col"->>'O''Brien'`)
501+
})
502+
503+
it(`compileRefPath: numeric string key for arrays`, () => {
504+
expect(compileRefPath([`items`, `0`, `id`])).toBe(`"items"->'0'->>'id'`)
505+
})
506+
507+
it(`WHERE eq with nested ref adds no extra params`, () => {
508+
const result = compileSQL({
509+
where: func(`eq`, [ref(`payload`, `status`), val(`active`)]),
510+
})
511+
expect(result.where).toBe(`"payload"->>'status' = $1`)
512+
expect(result.params).toEqual({ '1': `active` })
513+
})
514+
515+
it(`ORDER BY with nested ref`, () => {
516+
const result = compileSQL({
517+
orderBy: [
518+
{
519+
expression: ref(`payload`, `sortKey`),
520+
compareOptions: { direction: `asc`, nulls: `first` },
521+
},
522+
],
523+
})
524+
expect(result.orderBy).toBe(`"payload"->>'sortKey' NULLS FIRST`)
525+
expect(result.params).toEqual({})
526+
})
527+
528+
it(`NOT isNull with nested ref`, () => {
529+
const result = compileSQL({
530+
where: func(`not`, [func(`isNull`, [ref(`data`, `email`)])]),
531+
})
532+
expect(result.where).toBe(`"data"->>'email' IS NOT NULL`)
533+
})
534+
535+
it(`nested ref as leaf inside AND preserves compileFunction behavior`, () => {
536+
const result = compileSQL({
537+
where: func(`and`, [
538+
func(`eq`, [ref(`id`), val(`1`)]),
539+
func(`gt`, [ref(`meta`, `score`), val(10)]),
540+
]),
541+
})
542+
expect(result.where).toBe(`"id" = $1 AND "meta"->>'score' > $2`)
543+
expect(result.params).toEqual({ '1': `1`, '2': `10` })
544+
})
473545
})
474546
})
475547
})

0 commit comments

Comments
 (0)