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..c44771a97 100644 --- a/packages/electric-db-collection/src/sql-compiler.ts +++ b/packages/electric-db-collection/src/sql-compiler.ts @@ -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[] 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})` } return `${lhs} ${opName} ${rhs}` @@ -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) } diff --git a/packages/electric-db-collection/tests/sql-compiler.test.ts b/packages/electric-db-collection/tests/sql-compiler.test.ts index 508449613..d8555f284 100644 --- a/packages/electric-db-collection/tests/sql-compiler.test.ts +++ b/packages/electric-db-collection/tests/sql-compiler.test.ts @@ -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'`) + }) + }) + describe(`null/undefined value handling`, () => { it(`should throw error for eq(col, null)`, () => { // Users should use isNull() instead of eq(col, null)