diff --git a/.changeset/electric-sql-nested-json-refs.md b/.changeset/electric-sql-nested-json-refs.md new file mode 100644 index 0000000000..fbed7cf0e4 --- /dev/null +++ b/.changeset/electric-sql-nested-json-refs.md @@ -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`. diff --git a/packages/electric-db-collection/src/sql-compiler.ts b/packages/electric-db-collection/src/sql-compiler.ts index 698f38c834..128968824e 100644 --- a/packages/electric-db-collection/src/sql-compiler.ts +++ b/packages/electric-db-collection/src/sql-compiler.ts @@ -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, + 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 @@ -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: diff --git a/packages/electric-db-collection/tests/sql-compiler.test.ts b/packages/electric-db-collection/tests/sql-compiler.test.ts index 508449613c..cb9e845aba 100644 --- a/packages/electric-db-collection/tests/sql-compiler.test.ts +++ b/packages/electric-db-collection/tests/sql-compiler.test.ts @@ -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 @@ -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` }) + }) }) }) })