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
5 changes: 5 additions & 0 deletions .changeset/array-column-containment.md
Original file line number Diff line number Diff line change
@@ -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`.
20 changes: 18 additions & 2 deletions packages/electric-db-collection/src/sql-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,24 @@ 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`) {
// @> wraps a single scalar into ARRAY[...]; an array-valued left
// operand would nest into ARRAY[<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})`
}
Comment thread
viktor89 marked this conversation as resolved.
return `${lhs} ${opName} ${rhs}`
Expand Down Expand Up @@ -388,7 +404,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)
}

Expand Down
68 changes: 68 additions & 0 deletions packages/electric-db-collection/tests/sql-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,74 @@ 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"}`,
})
})

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`)
})

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'`)
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe(`null/undefined value handling`, () => {
it(`should throw error for eq(col, null)`, () => {
// Users should use isNull() instead of eq(col, null)
Expand Down