Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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`.
7 changes: 6 additions & 1 deletion packages/electric-db-collection/src/sql-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`
}
Comment thread
viktor89 marked this conversation as resolved.
return `${lhs} ${opName} ${rhs}`
Expand Down
32 changes: 32 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,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"}`,
})
})
})
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