Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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/fix-like-ilike-on-demand-mode.md
Original file line number Diff line number Diff line change
@@ -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()`.
247 changes: 247 additions & 0 deletions packages/db-collection-e2e/src/suites/predicates.suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import {
eq,
gt,
gte,
ilike,
inArray,
isNull,
like,
lower,
lt,
lte,
not,
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 12 additions & 1 deletion packages/electric-db-collection/src/sql-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading
Loading