Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/electric-sql-nested-json-refs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/electric-db-collection': patch
---

Support nested property refs in WHERE/ORDER BY by compiling to PostgreSQL JSON/`jsonb` operators (`->` / `->>`).

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.

**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.

**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`.
53 changes: 46 additions & 7 deletions packages/electric-db-collection/src/sql-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,51 @@ function quoteIdentifier(
return `"${columnName}"`
}

/**
* Escape content for use inside a PostgreSQL single-quoted string literal (`'...'`).
*/
const escapeSqlSingleQuotedString = (value: string): string =>
value.replace(/'/g, `''`)

/**
* A SQL single-quoted string literal with standard quote doubling (e.g. `O'Brien` → `'O''Brien'`).
*/
const quoteSqlStringLiteral = (value: string): string =>
`'${escapeSqlSingleQuotedString(value)}'`

/**
* Compile a property reference path to SQL.
*
* - `path.length === 1`: quoted identifier for the column (with optional `encodeColumnName`).
* - `path.length >= 2`: JSON/jsonb traversal from the root column: intermediate segments use `->`
* (json/jsonb), the final segment uses `->>` (text). Keys are string/array indices as emitted by the IR
* (e.g. `'0'` for the first array element).
*
* Non-goals: nested paths do not represent Postgres composite types or dotted SQL identifiers—only
* JSON/jsonb extraction from a single root column. If that column is not `json`/`jsonb`, execution may fail
* at runtime.
*/
export const compileRefPath = (
path: Array<string>,
encodeColumnName?: ColumnEncoder,
): string => {
if (path.length === 0) {
throw new Error(`Ref path must have at least one segment`)
}
if (path.length === 1) {
return quoteIdentifier(path[0]!, encodeColumnName)
}

let sql = quoteIdentifier(path[0]!, encodeColumnName)
const keys = path.slice(1)
for (let i = 0; i < keys.length; i++) {
const keyLit = quoteSqlStringLiteral(keys[i]!)
const isLast = i === keys.length - 1
sql += isLast ? `->>${keyLit}` : `->${keyLit}`
}
return sql
}

/**
* Compiles the expression to a SQL string and mutates the params array with the values.
* @param exp - The expression to compile
Expand All @@ -108,13 +153,7 @@ function compileBasicExpression(
params.push(exp.value)
return `$${params.length}`
case `ref`:
// TODO: doesn't yet support JSON(B) values which could be accessed with nested props
if (exp.path.length !== 1) {
throw new Error(
`Compiler can't handle nested properties: ${exp.path.join(`.`)}`,
)
}
return quoteIdentifier(exp.path[0]!, encodeColumnName)
return compileRefPath(exp.path, encodeColumnName)
case `func`:
return compileFunction(exp, params, encodeColumnName)
default:
Expand Down
74 changes: 73 additions & 1 deletion packages/electric-db-collection/tests/sql-compiler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { compileSQL } from '../src/sql-compiler'
import { compileRefPath, compileSQL } from '../src/sql-compiler'
import type { IR } from '@tanstack/db'

// Helper to create a value expression
Expand Down Expand Up @@ -470,6 +470,78 @@ describe(`sql-compiler`, () => {
// snake_case input remains snake_case
expect(result.where).toBe(`"user_id" = $1`)
})

it(`encodeColumnName applies only to the root column for nested refs`, () => {
const result = compileSQL(
{
where: func(`eq`, [ref(`metaData`, `nestedKey`), val(`x`)]),
},
{ encodeColumnName: camelToSnake },
)
expect(result.where).toBe(`"meta_data"->>'nestedKey' = $1`)
expect(result.params).toEqual({ '1': `x` })
})
})

describe(`nested JSON/jsonb ref paths`, () => {
it(`compileRefPath: single segment matches plain column quoting`, () => {
expect(compileRefPath([`name`])).toBe(`"name"`)
})

it(`compileRefPath: two segments use final ->>`, () => {
expect(compileRefPath([`doc`, `title`])).toBe(`"doc"->>'title'`)
})

it(`compileRefPath: three segments chain -> then ->>`, () => {
expect(compileRefPath([`a`, `b`, `c`])).toBe(`"a"->'b'->>'c'`)
})

it(`compileRefPath: apostrophe in key is escaped`, () => {
expect(compileRefPath([`col`, `O'Brien`])).toBe(`"col"->>'O''Brien'`)
})

it(`compileRefPath: numeric string key for arrays`, () => {
expect(compileRefPath([`items`, `0`, `id`])).toBe(`"items"->'0'->>'id'`)
})

it(`WHERE eq with nested ref adds no extra params`, () => {
const result = compileSQL({
where: func(`eq`, [ref(`payload`, `status`), val(`active`)]),
})
expect(result.where).toBe(`"payload"->>'status' = $1`)
expect(result.params).toEqual({ '1': `active` })
})

it(`ORDER BY with nested ref`, () => {
const result = compileSQL({
orderBy: [
{
expression: ref(`payload`, `sortKey`),
compareOptions: { direction: `asc`, nulls: `first` },
},
],
})
expect(result.orderBy).toBe(`"payload"->>'sortKey' NULLS FIRST`)
expect(result.params).toEqual({})
})

it(`NOT isNull with nested ref`, () => {
const result = compileSQL({
where: func(`not`, [func(`isNull`, [ref(`data`, `email`)])]),
})
expect(result.where).toBe(`"data"->>'email' IS NOT NULL`)
})

it(`nested ref as leaf inside AND preserves compileFunction behavior`, () => {
const result = compileSQL({
where: func(`and`, [
func(`eq`, [ref(`id`), val(`1`)]),
func(`gt`, [ref(`meta`, `score`), val(10)]),
]),
})
expect(result.where).toBe(`"id" = $1 AND "meta"->>'score' > $2`)
expect(result.params).toEqual({ '1': `1`, '2': `10` })
})
})
})
})