Skip to content

Commit e657a7d

Browse files
authored
fix the use of like/ilike with an on-demand electric colleciton (#884)
* fix the use of like/ilike with an on-demand electric colleciton * changeset * fix tests for query collection
1 parent 2c2e4db commit e657a7d

File tree

4 files changed

+374
-2
lines changed

4 files changed

+374
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/electric-db-collection": patch
3+
---
4+
5+
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()`.

packages/db-collection-e2e/src/suites/predicates.suite.ts

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import {
1212
eq,
1313
gt,
1414
gte,
15+
ilike,
1516
inArray,
1617
isNull,
18+
like,
19+
lower,
1720
lt,
1821
lte,
1922
not,
@@ -258,6 +261,250 @@ export function createPredicatesTestSuite(
258261
})
259262
})
260263

264+
describe(`String Pattern Matching Operators`, () => {
265+
it(`should filter with like() operator (case-sensitive)`, async () => {
266+
const config = await getConfig()
267+
const usersCollection = config.collections.onDemand.users
268+
269+
const query = createLiveQueryCollection((q) =>
270+
q
271+
.from({ user: usersCollection })
272+
.where(({ user }) => like(user.name, `Alice%`))
273+
)
274+
275+
await query.preload()
276+
await waitForQueryData(query, { minSize: 1 })
277+
278+
const results = Array.from(query.state.values())
279+
expect(results.length).toBeGreaterThan(0)
280+
// Should match names starting with "Alice" (case-sensitive)
281+
assertAllItemsMatch(query, (u) => u.name.startsWith(`Alice`))
282+
283+
await query.cleanup()
284+
})
285+
286+
it(`should filter with ilike() operator (case-insensitive)`, async () => {
287+
const config = await getConfig()
288+
const usersCollection = config.collections.onDemand.users
289+
290+
const query = createLiveQueryCollection((q) =>
291+
q
292+
.from({ user: usersCollection })
293+
.where(({ user }) => ilike(user.name, `alice%`))
294+
)
295+
296+
await query.preload()
297+
await waitForQueryData(query, { minSize: 1 })
298+
299+
const results = Array.from(query.state.values())
300+
expect(results.length).toBeGreaterThan(0)
301+
// Should match names starting with "Alice" (case-insensitive)
302+
assertAllItemsMatch(query, (u) =>
303+
u.name.toLowerCase().startsWith(`alice`)
304+
)
305+
306+
await query.cleanup()
307+
})
308+
309+
it(`should filter with like() with wildcard pattern (% at end)`, async () => {
310+
const config = await getConfig()
311+
const usersCollection = config.collections.onDemand.users
312+
313+
const query = createLiveQueryCollection((q) =>
314+
q
315+
.from({ user: usersCollection })
316+
.where(({ user }) => like(user.email, `%@example.com`))
317+
)
318+
319+
await query.preload()
320+
await waitForQueryData(query, { minSize: 1 })
321+
322+
const results = Array.from(query.state.values())
323+
expect(results.length).toBeGreaterThan(0)
324+
// Should match emails ending with @example.com
325+
assertAllItemsMatch(
326+
query,
327+
(u) => u.email?.endsWith(`@example.com`) ?? false
328+
)
329+
330+
await query.cleanup()
331+
})
332+
333+
it(`should filter with like() with wildcard pattern (% in middle)`, async () => {
334+
const config = await getConfig()
335+
const usersCollection = config.collections.onDemand.users
336+
337+
const query = createLiveQueryCollection((q) =>
338+
q
339+
.from({ user: usersCollection })
340+
.where(({ user }) => like(user.email, `user%[email protected]`))
341+
)
342+
343+
await query.preload()
344+
await waitForQueryData(query, { minSize: 1 })
345+
346+
const results = Array.from(query.state.values())
347+
expect(results.length).toBeGreaterThan(0)
348+
// Should match emails like user0@example.com, [email protected], [email protected], etc.
349+
assertAllItemsMatch(
350+
query,
351+
(u) => (u.email?.match(/^user.*0@example\.com$/) ?? null) !== null
352+
)
353+
354+
await query.cleanup()
355+
})
356+
357+
it(`should filter with like() with lower() function`, async () => {
358+
const config = await getConfig()
359+
const usersCollection = config.collections.onDemand.users
360+
361+
const query = createLiveQueryCollection((q) =>
362+
q
363+
.from({ user: usersCollection })
364+
.where(({ user }) => like(lower(user.name), `%alice%`))
365+
)
366+
367+
await query.preload()
368+
await waitForQueryData(query, { minSize: 1 })
369+
370+
const results = Array.from(query.state.values())
371+
expect(results.length).toBeGreaterThan(0)
372+
// Should match names containing "alice" (case-insensitive via lower())
373+
assertAllItemsMatch(query, (u) =>
374+
u.name.toLowerCase().includes(`alice`)
375+
)
376+
377+
await query.cleanup()
378+
})
379+
380+
it(`should filter with ilike() with lower() function`, async () => {
381+
const config = await getConfig()
382+
const usersCollection = config.collections.onDemand.users
383+
384+
const query = createLiveQueryCollection((q) =>
385+
q
386+
.from({ user: usersCollection })
387+
.where(({ user }) => ilike(lower(user.name), `%bob%`))
388+
)
389+
390+
await query.preload()
391+
await waitForQueryData(query, { minSize: 1 })
392+
393+
const results = Array.from(query.state.values())
394+
expect(results.length).toBeGreaterThan(0)
395+
// Should match names containing "bob" (case-insensitive)
396+
assertAllItemsMatch(query, (u) => u.name.toLowerCase().includes(`bob`))
397+
398+
await query.cleanup()
399+
})
400+
401+
it(`should filter with or() combining multiple like() conditions (search pattern)`, async () => {
402+
const config = await getConfig()
403+
const postsCollection = config.collections.onDemand.posts
404+
405+
// This mimics the user's exact query pattern with multiple fields
406+
// User's pattern: like(lower(offers.title), `%${searchLower}%`) OR like(lower(offers.human_id), `%${searchLower}%`)
407+
const searchTerm = `Introduction`
408+
const searchLower = searchTerm.toLowerCase()
409+
410+
const query = createLiveQueryCollection((q) =>
411+
q
412+
.from({ post: postsCollection })
413+
.where(({ post }) =>
414+
or(
415+
like(lower(post.title), `%${searchLower}%`),
416+
like(lower(post.content ?? ``), `%${searchLower}%`)
417+
)
418+
)
419+
)
420+
421+
await query.preload()
422+
await waitForQueryData(query, { minSize: 1 })
423+
424+
const results = Array.from(query.state.values())
425+
expect(results.length).toBeGreaterThan(0)
426+
// Should match posts with title or content containing "introduction" (case-insensitive)
427+
assertAllItemsMatch(
428+
query,
429+
(p) =>
430+
p.title.toLowerCase().includes(searchLower) ||
431+
(p.content?.toLowerCase().includes(searchLower) ?? false)
432+
)
433+
434+
await query.cleanup()
435+
})
436+
437+
it(`should filter with like() and orderBy`, async () => {
438+
const config = await getConfig()
439+
const usersCollection = config.collections.onDemand.users
440+
441+
const query = createLiveQueryCollection((q) =>
442+
q
443+
.from({ user: usersCollection })
444+
.where(({ user }) => like(lower(user.name), `%alice%`))
445+
.orderBy(({ user }) => user.name, `asc`)
446+
)
447+
448+
await query.preload()
449+
await waitForQueryData(query, { minSize: 1 })
450+
451+
const results = Array.from(query.state.values())
452+
expect(results.length).toBeGreaterThan(0)
453+
assertAllItemsMatch(query, (u) =>
454+
u.name.toLowerCase().includes(`alice`)
455+
)
456+
457+
// Verify ordering
458+
const names = results.map((u) => u.name)
459+
const sortedNames = [...names].sort((a, b) => a.localeCompare(b))
460+
expect(names).toEqual(sortedNames)
461+
462+
await query.cleanup()
463+
})
464+
465+
it(`should filter with like() and limit`, async () => {
466+
const config = await getConfig()
467+
const usersCollection = config.collections.onDemand.users
468+
469+
const query = createLiveQueryCollection((q) =>
470+
q
471+
.from({ user: usersCollection })
472+
.where(({ user }) => like(lower(user.name), `%alice%`))
473+
.orderBy(({ user }) => user.name, `asc`) // Required when using LIMIT
474+
.limit(5)
475+
)
476+
477+
await query.preload()
478+
await waitForQueryData(query, { minSize: 1 })
479+
480+
const results = Array.from(query.state.values())
481+
// Should respect limit
482+
expect(results.length).toBeLessThanOrEqual(5)
483+
assertAllItemsMatch(query, (u) =>
484+
u.name.toLowerCase().includes(`alice`)
485+
)
486+
487+
await query.cleanup()
488+
})
489+
490+
it(`should handle like() with pattern matching no records`, async () => {
491+
const config = await getConfig()
492+
const usersCollection = config.collections.onDemand.users
493+
494+
const query = createLiveQueryCollection((q) =>
495+
q
496+
.from({ user: usersCollection })
497+
.where(({ user }) => like(user.name, `NonExistent%`))
498+
)
499+
500+
await query.preload()
501+
502+
assertCollectionSize(query, 0)
503+
504+
await query.cleanup()
505+
})
506+
})
507+
261508
describe(`In Operator`, () => {
262509
it(`should filter with inArray() on string array`, async () => {
263510
const config = await getConfig()

packages/electric-db-collection/src/sql-compiler.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,18 @@ function compileFunction(
183183
}
184184

185185
function isBinaryOp(name: string): boolean {
186-
const binaryOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `and`, `or`, `in`]
186+
const binaryOps = [
187+
`eq`,
188+
`gt`,
189+
`gte`,
190+
`lt`,
191+
`lte`,
192+
`and`,
193+
`or`,
194+
`in`,
195+
`like`,
196+
`ilike`,
197+
]
187198
return binaryOps.includes(name)
188199
}
189200

0 commit comments

Comments
 (0)