-
Notifications
You must be signed in to change notification settings - Fork 143
Fix serializing null/undefined when generating subset queries #951
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
KyleAMathews
merged 13 commits into
main
from
claude/fix-electric-proxy-queries-01C2vhzTAzg2myxC269rptUA
Dec 16, 2025
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
90889ae
Fix invalid Electric proxy queries with missing params for null values
claude 0f4c7bc
chore: add changeset for Electric null params fix
claude 3ba00ec
fix: use type assertions in sql-compiler tests for phantom type compa…
claude d344838
fix: handle edge cases for null comparisons in sql-compiler
claude 222d529
test: add e2e tests for eq(col, null) transformation to IS NULL
claude 3c839d6
test: improve e2e tests for eq(col, null) with longer timeout and bas…
claude 3006358
fix: handle eq(col, null) in local evaluator to match SQL IS NULL sem…
claude 3f466fe
docs: explain eq(col, null) handling and 3-valued logic reasoning
claude 9401b42
fix: throw error for eq(col, null) - use isNull() instead
claude e107101
fix: update changeset and remove design doc per review feedback
claude 1fc853e
Merge origin/main into feature branch
claude 917d7af
ci: apply automated fixes
autofix-ci[bot] 6404a53
Merge remote-tracking branch 'origin/main' into claude/fix-electric-p…
KyleAMathews File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@tanstack/electric-db-collection': patch | ||
| --- | ||
|
|
||
| Fix invalid Electric proxy queries with missing params for null/undefined values | ||
|
|
||
| When comparison operators were used with null/undefined values, the SQL compiler would generate placeholders ($1, $2) in the WHERE clause but skip adding the params to the dictionary. This resulted in invalid queries being sent to Electric. | ||
|
|
||
| Now all comparison operators (eq, gt, lt, gte, lte, like, ilike) throw a clear error when used with null/undefined values, since comparisons with NULL always evaluate to UNKNOWN in SQL. Users should use `isNull()` or `isUndefined()` to check for null values instead. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
312 changes: 312 additions & 0 deletions
312
packages/electric-db-collection/tests/sql-compiler.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,312 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { compileSQL } from '../src/sql-compiler' | ||
| import type { IR } from '@tanstack/db' | ||
|
|
||
| // Helper to create a value expression | ||
| function val<T>(value: T): IR.BasicExpression<T> { | ||
| return { type: `val`, value } as IR.BasicExpression<T> | ||
| } | ||
|
|
||
| // Helper to create a reference expression | ||
| function ref(...path: Array<string>): IR.BasicExpression { | ||
| return { type: `ref`, path } as IR.BasicExpression | ||
| } | ||
|
|
||
| // Helper to create a function expression | ||
|
|
||
| function func(name: string, args: Array<any>): IR.BasicExpression<boolean> { | ||
| return { type: `func`, name, args } as IR.BasicExpression<boolean> | ||
| } | ||
|
|
||
| describe(`sql-compiler`, () => { | ||
| describe(`compileSQL`, () => { | ||
| describe(`basic where clauses`, () => { | ||
| it(`should compile eq with string value`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`eq`, [ref(`name`), val(`John`)]), | ||
| }) | ||
| expect(result.where).toBe(`"name" = $1`) | ||
| expect(result.params).toEqual({ '1': `John` }) | ||
| }) | ||
|
|
||
| it(`should compile eq with number value`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`eq`, [ref(`age`), val(25)]), | ||
| }) | ||
| expect(result.where).toBe(`"age" = $1`) | ||
| expect(result.params).toEqual({ '1': `25` }) | ||
| }) | ||
|
|
||
| it(`should compile gt operator`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`gt`, [ref(`age`), val(18)]), | ||
| }) | ||
| expect(result.where).toBe(`"age" > $1`) | ||
| expect(result.params).toEqual({ '1': `18` }) | ||
| }) | ||
|
|
||
| it(`should compile lt operator`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`lt`, [ref(`price`), val(100)]), | ||
| }) | ||
| expect(result.where).toBe(`"price" < $1`) | ||
| expect(result.params).toEqual({ '1': `100` }) | ||
| }) | ||
|
|
||
| it(`should compile gte operator`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`gte`, [ref(`quantity`), val(10)]), | ||
| }) | ||
| expect(result.where).toBe(`"quantity" >= $1`) | ||
| expect(result.params).toEqual({ '1': `10` }) | ||
| }) | ||
|
|
||
| it(`should compile lte operator`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`lte`, [ref(`rating`), val(5)]), | ||
| }) | ||
| expect(result.where).toBe(`"rating" <= $1`) | ||
| expect(result.params).toEqual({ '1': `5` }) | ||
| }) | ||
| }) | ||
|
|
||
| describe(`compound where clauses`, () => { | ||
| it(`should compile AND with two conditions`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`and`, [ | ||
| func(`eq`, [ref(`projectId`), val(`uuid-123`)]), | ||
| func(`gt`, [ref(`name`), val(`cursor-value`)]), | ||
| ]), | ||
| }) | ||
| // Note: 2-arg AND doesn't add parentheses around the operands | ||
| expect(result.where).toBe(`"projectId" = $1 AND "name" > $2`) | ||
| expect(result.params).toEqual({ '1': `uuid-123`, '2': `cursor-value` }) | ||
| }) | ||
|
|
||
| it(`should compile AND with more than two conditions`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`and`, [ | ||
| func(`eq`, [ref(`a`), val(`1`)]), | ||
| func(`eq`, [ref(`b`), val(`2`)]), | ||
| func(`eq`, [ref(`c`), val(`3`)]), | ||
| ]), | ||
| }) | ||
| // >2 args adds parentheses | ||
| expect(result.where).toBe(`("a" = $1) AND ("b" = $2) AND ("c" = $3)`) | ||
| expect(result.params).toEqual({ '1': `1`, '2': `2`, '3': `3` }) | ||
| }) | ||
|
|
||
| it(`should compile OR with two conditions`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`or`, [ | ||
| func(`eq`, [ref(`status`), val(`active`)]), | ||
| func(`eq`, [ref(`status`), val(`pending`)]), | ||
| ]), | ||
| }) | ||
| expect(result.where).toBe(`"status" = $1 OR "status" = $2`) | ||
| expect(result.params).toEqual({ '1': `active`, '2': `pending` }) | ||
| }) | ||
| }) | ||
|
|
||
| describe(`null/undefined value handling`, () => { | ||
| it(`should throw error for eq(col, null)`, () => { | ||
| // Users should use isNull() instead of eq(col, null) | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`eq`, [ref(`deletedAt`), val(null)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'eq' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for eq(col, undefined)`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`eq`, [ref(`deletedAt`), val(undefined)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'eq' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for eq(null, col) (reversed order)`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`eq`, [val(null), ref(`name`)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'eq' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for gt with null value`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`gt`, [ref(`age`), val(null)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'gt' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for lt with undefined value`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`lt`, [ref(`age`), val(undefined)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'lt' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for gte with null value`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`gte`, [ref(`price`), val(null)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'gte' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for lte with null value`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`lte`, [ref(`rating`), val(null)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'lte' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for like with null value`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`like`, [ref(`name`), val(null)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'like' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for ilike with null value`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`ilike`, [ref(`name`), val(null)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'ilike' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for eq(col, null) in AND clause`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`and`, [ | ||
| func(`eq`, [ref(`projectId`), val(`uuid-123`)]), | ||
| func(`eq`, [ref(`deletedAt`), val(null)]), | ||
| ]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'eq' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for eq(col, null) in mixed conditions`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`and`, [ | ||
| func(`eq`, [ref(`status`), val(`active`)]), | ||
| func(`eq`, [ref(`archivedAt`), val(null)]), | ||
| func(`gt`, [ref(`createdAt`), val(`2024-01-01`)]), | ||
| ]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'eq' operator`) | ||
| }) | ||
|
|
||
| it(`should not include params for null values in complex queries`, () => { | ||
| // This test simulates the bug scenario: a query with both valid params and null | ||
| // Before the fix, this would generate: | ||
| // where: "projectId" = $1 AND "name" > $2 | ||
| // params: { "1": "uuid" } // missing $2! | ||
| // After the fix, gt(name, null) throws an error | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`and`, [ | ||
| func(`eq`, [ref(`projectId`), val(`uuid`)]), | ||
| func(`gt`, [ref(`name`), val(null)]), | ||
| ]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'gt' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for eq(null, null)`, () => { | ||
| // Both args are null - this is nonsensical and would cause missing params | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`eq`, [val(null), val(null)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'eq' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for eq(null, literal)`, () => { | ||
| // Comparing null to a literal is nonsensical (always evaluates to UNKNOWN) | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`eq`, [val(null), val(42)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'eq' operator`) | ||
| }) | ||
|
|
||
| it(`should throw error for eq(col, null) - use isNull(col) instead`, () => { | ||
| // eq(col, null) should throw an error | ||
| // Users should use isNull(col) which works correctly | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`eq`, [ref(`email`), val(null)]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'eq' operator`) | ||
|
|
||
| // isNull(col) should work correctly | ||
| const isNullResult = compileSQL({ | ||
| where: func(`isNull`, [ref(`email`)]), | ||
| }) | ||
| expect(isNullResult.where).toBe(`"email" IS NULL`) | ||
| expect(isNullResult.params).toEqual({}) | ||
| }) | ||
|
|
||
| it(`should throw error for eq(col, null) in OR clause`, () => { | ||
| expect(() => | ||
| compileSQL({ | ||
| where: func(`or`, [ | ||
| func(`eq`, [ref(`deletedAt`), val(null)]), | ||
| func(`eq`, [ref(`status`), val(`active`)]), | ||
| ]), | ||
| }), | ||
| ).toThrow(`Cannot use null/undefined value with 'eq' operator`) | ||
| }) | ||
| }) | ||
|
|
||
| describe(`isNull/isUndefined operators`, () => { | ||
| it(`should compile isNull correctly`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`isNull`, [ref(`deletedAt`)]), | ||
| }) | ||
| expect(result.where).toBe(`"deletedAt" IS NULL`) | ||
| expect(result.params).toEqual({}) | ||
| }) | ||
|
|
||
| it(`should compile isUndefined correctly`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`isUndefined`, [ref(`field`)]), | ||
| }) | ||
| expect(result.where).toBe(`"field" IS NULL`) | ||
| expect(result.params).toEqual({}) | ||
| }) | ||
|
|
||
| it(`should compile NOT isNull correctly`, () => { | ||
| const result = compileSQL({ | ||
| where: func(`not`, [func(`isNull`, [ref(`name`)])]), | ||
| }) | ||
| expect(result.where).toBe(`"name" IS NOT NULL`) | ||
| expect(result.params).toEqual({}) | ||
| }) | ||
| }) | ||
|
|
||
| describe(`empty where clause`, () => { | ||
| it(`should add true = true when no where clause`, () => { | ||
| const result = compileSQL({}) | ||
| expect(result.where).toBe(`true = true`) | ||
| expect(result.params).toEqual({}) | ||
| }) | ||
| }) | ||
|
|
||
| describe(`limit`, () => { | ||
| it(`should include limit in result`, () => { | ||
| const result = compileSQL({ limit: 10 }) | ||
| expect(result.limit).toBe(10) | ||
| }) | ||
| }) | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This validation is happening within the sql compiler in the electric collection, and so is only applied when a query is pushed down to electric. Is that intended? Would it be better to make this a global validation on all collections? (I'm not sure at this point)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm yeah I dunno — this seems more like a you question haha — do we want this validation or not? For electric we need it because it breaks SQL compilation. So yeah, we could start here too and then add it later as the global validation, maybe.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, let's start with this as is, and then think about global validation.