From 6903ad04d2b39554dfbc71427e2160b261413940 Mon Sep 17 00:00:00 2001 From: Viktor Maigaard Date: Wed, 10 Jun 2026 19:44:55 +0200 Subject: [PATCH 1/3] perf(electric-db-collection): compile array-column membership to @> (GIN-eligible) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inArray(value, arrayColumn) compiled to `value = ANY(arrayColumn)`, which Postgres cannot satisfy with a GIN index. The equivalent containment form `arrayColumn @> ARRAY[value]` can, so the planner index-seeks instead of scanning — a large win for low-cardinality values where ORDER BY ... LIMIT otherwise walks the whole table. Only the array-*column* case changes (RHS is a column ref); membership in a literal value list (inArray(column, [a, b])) still compiles to = ANY. --- .changeset/array-column-containment.md | 5 +++ .../src/sql-compiler.ts | 7 +++- .../tests/sql-compiler.test.ts | 32 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .changeset/array-column-containment.md diff --git a/.changeset/array-column-containment.md b/.changeset/array-column-containment.md new file mode 100644 index 000000000..31f1ee89e --- /dev/null +++ b/.changeset/array-column-containment.md @@ -0,0 +1,5 @@ +--- +"@tanstack/electric-db-collection": patch +--- + +Compile array-column membership (`inArray(value, arrayColumn)`) to `arrayColumn @> ARRAY[value]` instead of `value = ANY(arrayColumn)`. The two are equivalent, but only the containment form can use a GIN index, letting Postgres index-seek rather than scan. Membership in a literal value list (`inArray(column, [a, b])`) is unchanged and still compiles to `= ANY`. diff --git a/packages/electric-db-collection/src/sql-compiler.ts b/packages/electric-db-collection/src/sql-compiler.ts index 698f38c83..87824dd64 100644 --- a/packages/electric-db-collection/src/sql-compiler.ts +++ b/packages/electric-db-collection/src/sql-compiler.ts @@ -356,8 +356,13 @@ function compileFunction( } } - // Special case for = ANY operator which needs parentheses around the array parameter if (name === `in`) { + // `value = ANY(arrayColumn)` and `arrayColumn @> ARRAY[value]` are + // equivalent, but only containment can use a GIN index. Emit @> when the + // array operand is a column; keep = ANY for a literal value list. + if (args[1]?.type === `ref`) { + return `${rhs} @> ARRAY[${lhs}]` + } return `${lhs} ${opName}(${rhs})` } return `${lhs} ${opName} ${rhs}` diff --git a/packages/electric-db-collection/tests/sql-compiler.test.ts b/packages/electric-db-collection/tests/sql-compiler.test.ts index 508449613..8bc615a4b 100644 --- a/packages/electric-db-collection/tests/sql-compiler.test.ts +++ b/packages/electric-db-collection/tests/sql-compiler.test.ts @@ -156,6 +156,38 @@ describe(`sql-compiler`, () => { }) }) + describe(`in operator`, () => { + it(`should compile membership in a literal value list as = ANY`, () => { + const result = compileSQL({ + where: func(`in`, [ref(`status`), val([`active`, `pending`])]), + }) + expect(result.where).toBe(`"status" = ANY($1)`) + expect(result.params).toEqual({ '1': `{"active","pending"}` }) + }) + + it(`should compile membership in an array column as @> (GIN-eligible)`, () => { + const result = compileSQL({ + where: func(`in`, [val(`admin`), ref(`roles`)]), + }) + expect(result.where).toBe(`"roles" @> ARRAY[$1]`) + expect(result.params).toEqual({ '1': `admin` }) + }) + + it(`should compile array-column membership inside AND`, () => { + const result = compileSQL({ + where: func(`and`, [ + func(`in`, [val(`admin`), ref(`roles`)]), + func(`in`, [ref(`status`), val([`active`, `pending`])]), + ]), + }) + expect(result.where).toBe(`"roles" @> ARRAY[$1] AND "status" = ANY($2)`) + expect(result.params).toEqual({ + '1': `admin`, + '2': `{"active","pending"}`, + }) + }) + }) + describe(`null/undefined value handling`, () => { it(`should throw error for eq(col, null)`, () => { // Users should use isNull() instead of eq(col, null) From 18ea87554e022b044022189d1a4adbd12979db75 Mon Sep 17 00:00:00 2001 From: Viktor Maigaard Date: Wed, 10 Jun 2026 20:09:40 +0200 Subject: [PATCH 2/3] test: cover in-operator corner cases; guard null/undefined operands Address review: add empty-array, single-element, and null/undefined cases. `in` now goes through the same null-operand guard as the other comparison operators, so inArray(null/undefined, ...) throws instead of emitting an unbound parameter. --- .../src/sql-compiler.ts | 2 +- .../tests/sql-compiler.test.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/electric-db-collection/src/sql-compiler.ts b/packages/electric-db-collection/src/sql-compiler.ts index 87824dd64..a26c38071 100644 --- a/packages/electric-db-collection/src/sql-compiler.ts +++ b/packages/electric-db-collection/src/sql-compiler.ts @@ -393,7 +393,7 @@ function isBinaryOp(name: string): boolean { * (null comparisons in SQL always evaluate to UNKNOWN) */ function isComparisonOp(name: string): boolean { - const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`] + const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`] return comparisonOps.includes(name) } diff --git a/packages/electric-db-collection/tests/sql-compiler.test.ts b/packages/electric-db-collection/tests/sql-compiler.test.ts index 8bc615a4b..a7d4a12b2 100644 --- a/packages/electric-db-collection/tests/sql-compiler.test.ts +++ b/packages/electric-db-collection/tests/sql-compiler.test.ts @@ -186,6 +186,34 @@ describe(`sql-compiler`, () => { '2': `{"active","pending"}`, }) }) + + it(`should compile membership in an empty value list`, () => { + const result = compileSQL({ + where: func(`in`, [ref(`status`), val([])]), + }) + expect(result.where).toBe(`"status" = ANY($1)`) + expect(result.params).toEqual({ '1': `{}` }) + }) + + it(`should compile membership in a single-element value list`, () => { + const result = compileSQL({ + where: func(`in`, [ref(`status`), val([`active`])]), + }) + expect(result.where).toBe(`"status" = ANY($1)`) + expect(result.params).toEqual({ '1': `{"active"}` }) + }) + + it(`should throw for a null operand`, () => { + expect(() => + compileSQL({ where: func(`in`, [val(null), ref(`roles`)]) }), + ).toThrow(`Cannot use null/undefined value with 'in' operator`) + }) + + it(`should throw for an undefined operand`, () => { + expect(() => + compileSQL({ where: func(`in`, [val(undefined), ref(`roles`)]) }), + ).toThrow(`Cannot use null/undefined value with 'in' operator`) + }) }) describe(`null/undefined value handling`, () => { From b4ef909fdd7e7fca19d3ee88cfbe2c2a7f518ce6 Mon Sep 17 00:00:00 2001 From: Viktor Maigaard Date: Thu, 11 Jun 2026 13:19:01 +0200 Subject: [PATCH 3/3] fix(electric-db-collection): reject array-valued left operand of `in` against an array column The `arrayColumn @> ARRAY[value]` form wraps a single scalar; passing an array value would nest into `ARRAY[]` and silently change the membership semantics. Throw a clear error instead, and cover it with a test. --- packages/electric-db-collection/src/sql-compiler.ts | 11 +++++++++++ .../electric-db-collection/tests/sql-compiler.test.ts | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/electric-db-collection/src/sql-compiler.ts b/packages/electric-db-collection/src/sql-compiler.ts index a26c38071..c44771a97 100644 --- a/packages/electric-db-collection/src/sql-compiler.ts +++ b/packages/electric-db-collection/src/sql-compiler.ts @@ -361,6 +361,17 @@ function compileFunction( // equivalent, but only containment can use a GIN index. Emit @> when the // array operand is a column; keep = ANY for a literal value list. if (args[1]?.type === `ref`) { + // @> wraps a single scalar into ARRAY[...]; an array-valued left + // operand would nest into ARRAY[] and silently change the + // membership semantics, so reject it. + const valueArg = args[0] + if (valueArg && valueArg.type === `val` && Array.isArray(valueArg.value)) { + throw new Error( + `Cannot use an array value as the left operand of 'in' against an ` + + `array column. Pass a scalar value to test membership ` + + `(e.g. inArray(value, arrayColumn)).`, + ) + } return `${rhs} @> ARRAY[${lhs}]` } return `${lhs} ${opName}(${rhs})` diff --git a/packages/electric-db-collection/tests/sql-compiler.test.ts b/packages/electric-db-collection/tests/sql-compiler.test.ts index a7d4a12b2..d8555f284 100644 --- a/packages/electric-db-collection/tests/sql-compiler.test.ts +++ b/packages/electric-db-collection/tests/sql-compiler.test.ts @@ -214,6 +214,14 @@ describe(`sql-compiler`, () => { compileSQL({ where: func(`in`, [val(undefined), ref(`roles`)]) }), ).toThrow(`Cannot use null/undefined value with 'in' operator`) }) + + it(`should throw for an array left operand against an array column`, () => { + expect(() => + compileSQL({ + where: func(`in`, [val([`admin`, `user`]), ref(`roles`)]), + }), + ).toThrow(`Cannot use an array value as the left operand of 'in'`) + }) }) describe(`null/undefined value handling`, () => {