From be4fdb26afc6e1e2b730f2eb2a08ab084f790574 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Fri, 21 Nov 2025 18:30:09 +0000 Subject: [PATCH 1/3] fix the use of like/ilike with an on-demand electric colleciton --- .../src/suites/predicates.suite.ts | 247 ++++++++++++++++++ .../src/sql-compiler.ts | 13 +- 2 files changed, 259 insertions(+), 1 deletion(-) diff --git a/packages/db-collection-e2e/src/suites/predicates.suite.ts b/packages/db-collection-e2e/src/suites/predicates.suite.ts index 6ece8331e..baab31cd3 100644 --- a/packages/db-collection-e2e/src/suites/predicates.suite.ts +++ b/packages/db-collection-e2e/src/suites/predicates.suite.ts @@ -12,8 +12,11 @@ import { eq, gt, gte, + ilike, inArray, isNull, + like, + lower, lt, lte, not, @@ -258,6 +261,250 @@ export function createPredicatesTestSuite( }) }) + describe(`String Pattern Matching Operators`, () => { + it(`should filter with like() operator (case-sensitive)`, async () => { + const config = await getConfig() + const usersCollection = config.collections.onDemand.users + + const query = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(user.name, `Alice%`)) + ) + + await query.preload() + await waitForQueryData(query, { minSize: 1 }) + + const results = Array.from(query.state.values()) + expect(results.length).toBeGreaterThan(0) + // Should match names starting with "Alice" (case-sensitive) + assertAllItemsMatch(query, (u) => u.name.startsWith(`Alice`)) + + await query.cleanup() + }) + + it(`should filter with ilike() operator (case-insensitive)`, async () => { + const config = await getConfig() + const usersCollection = config.collections.onDemand.users + + const query = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => ilike(user.name, `alice%`)) + ) + + await query.preload() + await waitForQueryData(query, { minSize: 1 }) + + const results = Array.from(query.state.values()) + expect(results.length).toBeGreaterThan(0) + // Should match names starting with "Alice" (case-insensitive) + assertAllItemsMatch(query, (u) => + u.name.toLowerCase().startsWith(`alice`) + ) + + await query.cleanup() + }) + + it(`should filter with like() with wildcard pattern (% at end)`, async () => { + const config = await getConfig() + const usersCollection = config.collections.onDemand.users + + const query = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(user.email, `%@example.com`)) + ) + + await query.preload() + await waitForQueryData(query, { minSize: 1 }) + + const results = Array.from(query.state.values()) + expect(results.length).toBeGreaterThan(0) + // Should match emails ending with @example.com + assertAllItemsMatch( + query, + (u) => u.email?.endsWith(`@example.com`) ?? false + ) + + await query.cleanup() + }) + + it(`should filter with like() with wildcard pattern (% in middle)`, async () => { + const config = await getConfig() + const usersCollection = config.collections.onDemand.users + + const query = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(user.email, `user%0@example.com`)) + ) + + await query.preload() + await waitForQueryData(query, { minSize: 1 }) + + const results = Array.from(query.state.values()) + expect(results.length).toBeGreaterThan(0) + // Should match emails like user0@example.com, user10@example.com, user20@example.com, etc. + assertAllItemsMatch( + query, + (u) => (u.email?.match(/^user.*0@example\.com$/) ?? null) !== null + ) + + await query.cleanup() + }) + + it(`should filter with like() with lower() function`, async () => { + const config = await getConfig() + const usersCollection = config.collections.onDemand.users + + const query = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(lower(user.name), `%alice%`)) + ) + + await query.preload() + await waitForQueryData(query, { minSize: 1 }) + + const results = Array.from(query.state.values()) + expect(results.length).toBeGreaterThan(0) + // Should match names containing "alice" (case-insensitive via lower()) + assertAllItemsMatch(query, (u) => + u.name.toLowerCase().includes(`alice`) + ) + + await query.cleanup() + }) + + it(`should filter with ilike() with lower() function`, async () => { + const config = await getConfig() + const usersCollection = config.collections.onDemand.users + + const query = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => ilike(lower(user.name), `%bob%`)) + ) + + await query.preload() + await waitForQueryData(query, { minSize: 1 }) + + const results = Array.from(query.state.values()) + expect(results.length).toBeGreaterThan(0) + // Should match names containing "bob" (case-insensitive) + assertAllItemsMatch(query, (u) => u.name.toLowerCase().includes(`bob`)) + + await query.cleanup() + }) + + it(`should filter with or() combining multiple like() conditions (search pattern)`, async () => { + const config = await getConfig() + const postsCollection = config.collections.onDemand.posts + + // This mimics the user's exact query pattern with multiple fields + // User's pattern: like(lower(offers.title), `%${searchLower}%`) OR like(lower(offers.human_id), `%${searchLower}%`) + const searchTerm = `Introduction` + const searchLower = searchTerm.toLowerCase() + + const query = createLiveQueryCollection((q) => + q + .from({ post: postsCollection }) + .where(({ post }) => + or( + like(lower(post.title), `%${searchLower}%`), + like(lower(post.content ?? ``), `%${searchLower}%`) + ) + ) + ) + + await query.preload() + await waitForQueryData(query, { minSize: 1 }) + + const results = Array.from(query.state.values()) + expect(results.length).toBeGreaterThan(0) + // Should match posts with title or content containing "introduction" (case-insensitive) + assertAllItemsMatch( + query, + (p) => + p.title.toLowerCase().includes(searchLower) || + (p.content?.toLowerCase().includes(searchLower) ?? false) + ) + + await query.cleanup() + }) + + it(`should filter with like() and orderBy`, async () => { + const config = await getConfig() + const usersCollection = config.collections.onDemand.users + + const query = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(lower(user.name), `%alice%`)) + .orderBy(({ user }) => user.name, `asc`) + ) + + await query.preload() + await waitForQueryData(query, { minSize: 1 }) + + const results = Array.from(query.state.values()) + expect(results.length).toBeGreaterThan(0) + assertAllItemsMatch(query, (u) => + u.name.toLowerCase().includes(`alice`) + ) + + // Verify ordering + const names = results.map((u) => u.name) + const sortedNames = [...names].sort((a, b) => a.localeCompare(b)) + expect(names).toEqual(sortedNames) + + await query.cleanup() + }) + + it(`should filter with like() and limit`, async () => { + const config = await getConfig() + const usersCollection = config.collections.onDemand.users + + const query = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(lower(user.name), `%alice%`)) + .orderBy(({ user }) => user.name, `asc`) // Required when using LIMIT + .limit(5) + ) + + await query.preload() + await waitForQueryData(query, { minSize: 1 }) + + const results = Array.from(query.state.values()) + // Should respect limit + expect(results.length).toBeLessThanOrEqual(5) + assertAllItemsMatch(query, (u) => + u.name.toLowerCase().includes(`alice`) + ) + + await query.cleanup() + }) + + it(`should handle like() with pattern matching no records`, async () => { + const config = await getConfig() + const usersCollection = config.collections.onDemand.users + + const query = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(user.name, `NonExistent%`)) + ) + + await query.preload() + + assertCollectionSize(query, 0) + + await query.cleanup() + }) + }) + describe(`In Operator`, () => { it(`should filter with inArray() on string array`, async () => { const config = await getConfig() diff --git a/packages/electric-db-collection/src/sql-compiler.ts b/packages/electric-db-collection/src/sql-compiler.ts index df1eb9b14..c941c7fef 100644 --- a/packages/electric-db-collection/src/sql-compiler.ts +++ b/packages/electric-db-collection/src/sql-compiler.ts @@ -183,7 +183,18 @@ function compileFunction( } function isBinaryOp(name: string): boolean { - const binaryOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `and`, `or`, `in`] + const binaryOps = [ + `eq`, + `gt`, + `gte`, + `lt`, + `lte`, + `and`, + `or`, + `in`, + `like`, + `ilike`, + ] return binaryOps.includes(name) } From d08e792793f1b5778fc987c961f0ece9d3ab1fb1 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Fri, 21 Nov 2025 18:31:33 +0000 Subject: [PATCH 2/3] changeset --- .changeset/fix-like-ilike-on-demand-mode.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-like-ilike-on-demand-mode.md diff --git a/.changeset/fix-like-ilike-on-demand-mode.md b/.changeset/fix-like-ilike-on-demand-mode.md new file mode 100644 index 000000000..c969c15bb --- /dev/null +++ b/.changeset/fix-like-ilike-on-demand-mode.md @@ -0,0 +1,5 @@ +--- +"@tanstack/electric-db-collection": patch +--- + +Fixed bug where `like()` and `ilike()` operators were not working in on-demand mode. The SQL compiler was incorrectly treating these operators as function calls (`LIKE(column, pattern)`) instead of binary operators (`column LIKE pattern`). Now `like()` and `ilike()` correctly compile to SQL binary operator syntax, enabling search queries with pattern matching in on-demand mode. This fix supports patterns like `like(lower(offers.title), '%search%')` and combining multiple conditions with `or()`. From 38da7f197c621ddf84e9eee1e8a52d61a933c44a Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Fri, 21 Nov 2025 19:15:45 +0000 Subject: [PATCH 3/3] fix tests for query collection --- .../query-db-collection/e2e/query-filter.ts | 111 +++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/query-db-collection/e2e/query-filter.ts b/packages/query-db-collection/e2e/query-filter.ts index ae45e9ac7..1dfd512d6 100644 --- a/packages/query-db-collection/e2e/query-filter.ts +++ b/packages/query-db-collection/e2e/query-filter.ts @@ -162,7 +162,62 @@ export function applyPredicates( ): Array { if (!options) return data - const { filters, sorts, limit } = parseLoadSubsetOptions(options) + // Parse options: try simple comparisons first (faster path), fall back to expression evaluation if needed + // extractSimpleComparisons (called by parseLoadSubsetOptions) intentionally throws for unsupported operators + // like 'like', 'ilike', 'or', etc. When that happens, we use buildExpressionPredicate instead. + let filters: Array = [] + let sorts: Array = [] + let limit: number | undefined = undefined + + // Check if where clause is simple before trying to parse + const hasComplexWhere = options.where && !isSimpleExpression(options.where) + + if (!hasComplexWhere) { + // Simple expression - parse everything at once + try { + const parsed = parseLoadSubsetOptions(options) + filters = parsed.filters + sorts = parsed.sorts + limit = parsed.limit + } catch (error) { + // This shouldn't happen for simple expressions, but handle it gracefully + if (DEBUG_SUMMARY) { + console.log( + `[query-filter] parseLoadSubsetOptions failed unexpectedly`, + error + ) + } + limit = options.limit + } + } else { + // Complex expression (like/ilike/or/etc.) - cannot use simple comparisons + // We'll filter using buildExpressionPredicate which evaluates the full expression tree + // filters stays empty - this signals buildFilterPredicate to use buildExpressionPredicate instead of buildSimplePredicate + // Note: Filtering still happens! Just via a different path (expression evaluation vs simple comparisons) + + limit = options.limit + + if (options.orderBy) { + try { + const orderByParsed = parseLoadSubsetOptions({ + orderBy: options.orderBy, + }) + sorts = orderByParsed.sorts + } catch { + // OrderBy parsing failed, will skip sorting + if (DEBUG_SUMMARY) { + console.log(`[query-filter] orderBy parsing failed, skipping sort`) + } + } + } + + if (DEBUG_SUMMARY) { + console.log( + `[query-filter] complex where clause detected, will filter using buildExpressionPredicate` + ) + } + } + if (DEBUG_SUMMARY) { const { limit: rawLimit, where, orderBy } = options const analysis = analyzeExpression(where) @@ -220,6 +275,10 @@ export function applyPredicates( /** * Build a predicate function from expression tree + * + * Two paths: + * 1. Simple expressions (eq, gt, etc.) with parsed filters -> buildSimplePredicate (faster) + * 2. Complex expressions (like, ilike, or, etc.) or empty filters -> buildExpressionPredicate (full expression evaluation) */ function buildFilterPredicate( where: IR.BasicExpression | undefined, @@ -229,10 +288,13 @@ function buildFilterPredicate( return undefined } + // Use simple predicate if we have parsed filters (fast path for eq, gt, etc.) if (filters.length > 0 && isSimpleExpression(where)) { return buildSimplePredicate(filters) } + // Otherwise, use expression predicate (handles like, ilike, or, etc.) + // This still filters! It just evaluates the expression tree directly instead of using parsed comparisons try { return buildExpressionPredicate(where) } catch (error) { @@ -433,11 +495,58 @@ function evaluateFunction(name: string, args: Array): any { return args[0] === undefined case `isNotUndefined`: return args[0] !== undefined + case `like`: + return evaluateLike(args[0], args[1], false) + case `ilike`: + return evaluateLike(args[0], args[1], true) + case `lower`: + return typeof args[0] === `string` ? args[0].toLowerCase() : args[0] + case `upper`: + return typeof args[0] === `string` ? args[0].toUpperCase() : args[0] default: throw new Error(`Unsupported predicate operator: ${name}`) } } +/** + * Evaluates LIKE/ILIKE patterns + * Converts SQL LIKE pattern to regex for JavaScript matching + * Returns null for 3-valued logic (UNKNOWN) when value or pattern is null/undefined + */ +function evaluateLike( + value: any, + pattern: any, + caseInsensitive: boolean +): boolean | null { + // In 3-valued logic, if value or pattern is null/undefined, return UNKNOWN (null) + if ( + value === null || + value === undefined || + pattern === null || + pattern === undefined + ) { + return null + } + + if (typeof value !== `string` || typeof pattern !== `string`) { + return false + } + + const searchValue = caseInsensitive ? value.toLowerCase() : value + const searchPattern = caseInsensitive ? pattern.toLowerCase() : pattern + + // Convert SQL LIKE pattern to regex + // First escape all regex special chars except % and _ + let regexPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`) + + // Then convert SQL wildcards to regex + regexPattern = regexPattern.replace(/%/g, `.*`) // % matches any sequence + regexPattern = regexPattern.replace(/_/g, `.`) // _ matches any single char + + const regex = new RegExp(`^${regexPattern}$`) + return regex.test(searchValue) +} + function compareBySorts(a: T, b: T, sorts: Array): number { for (const sort of sorts) { const aVal = getFieldValue(a, sort.field)