diff --git a/README.md b/README.md index 21785e0..0fc053b 100644 --- a/README.md +++ b/README.md @@ -176,8 +176,9 @@ Most query operations are translated to PostgREST filters and run server-side. A | Operation | Notes | | ---------------------------------------------------- | --------------------------------------------------------------------------------- | | `FROM` | Maps to the PostgREST table endpoint. | -| `WHERE` (`eq`, `gt`, `gte`, `lt`, `lte`, `inArray`, `not`, `isNull`) | Translated to PostgREST filter syntax. | -| `AND` (multiple conditions or chained `.where`) | Translated to PostgREST filter syntax. | +| `WHERE` (`eq`, `gt`, `gte`, `lt`, `lte`, `inArray`, `like`, `ilike`, `not`, `isNull`) | Translated to PostgREST filter syntax. | +| `AND` (multiple conditions or chained `.where`) | Translated to PostgREST filter syntax. A conjunct that cannot be pushed is dropped on its own; the request returns a superset and the client re-filters it. | +| `OR` and nested `AND`/`OR` | Translated to PostgREST's `or=(…)` syntax. All-or-nothing: if any branch cannot be pushed the whole `or` is dropped, because dropping one branch would narrow the result and lose matching rows. | | `ORDER BY` (on source columns) | Translated to PostgREST filter syntax. | | `LIMIT` | Translated to PostgREST filter syntax. | | `JOIN` | Each table is fetched separately. The join key is pushed as an `in` filter on the second query. | @@ -215,4 +216,3 @@ This library targets Supabase and PostgREST tables. For custom backends, write y ## Roadmap - Generate collection definitions from your database schema via the Supabase CLI, keeping them in sync as your schema evolves. -- Add `OR` conditions and nested `AND`/`OR` support. diff --git a/src/functions.ts b/src/functions.ts index aa6a547..d478c6f 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -4,81 +4,245 @@ import { type DeleteMutationFnParams, extractSimpleComparisons, type InsertMutationFnParams, + type IR, type LoadSubsetOptions, - parseLoadSubsetOptions, parseOrderByExpression, - parseWhereExpression, type SimpleComparison, type UpdateMutationFnParams, } from "@tanstack/db" import type { QueryClient, QueryMeta } from "@tanstack/query-core" +type GenericPostgrestFilterBuilder = PostgrestFilterBuilder + +type Expression = IR.BasicExpression + +/** + * A pushed-down filter, as the PostgREST query parameter it becomes. + * + * A comparison is its own parameter, `column=operator.value`. Anything logical + * rides inside `or=(…)`: a one-element disjunction *is* that element, so a + * top-level `not.and(…)` reaches the server unchanged without having to + * synthesise a parameter name the postgrest-js builder cannot append. + */ +type PostgrestParam = + | { kind: "column"; column: string; operator: string; value: string } + | { kind: "group"; filter: string } + +type Comparison = { column: string; operator: string; value: string } + +/** + * PostgREST splits the parenthesised filter forms — `in.(…)`, `or=(…)` — on + * commas and parens, so a value carrying one (a search term typed by a reader, + * say) silently corrupts the filter around it unless it is quoted. + * + * Only those forms parse quotes. A top-level `col=eq."x"` matches the literal + * `"x"`, quotes included — and needs no escaping anyway, since the value runs + * to the end of the parameter and nothing can delimit it early. + */ +const NEEDS_QUOTES = /^$|^\s|\s$|[,()"\\]/ + +export const quoteValue = (value: unknown): string => { + const raw = `${value}` + if (!NEEDS_QUOTES.test(raw)) { + return raw + } + return `"${raw.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` +} + +const SCALAR_OPERATORS = ["eq", "gt", "gte", "lt", "lte", "like", "ilike"] + +/** + * Render one comparison, or null when it is not a plain column-against-literal + * test. `quoteScalars` picks between the two contexts above; a list is always + * parenthesised, so its members are quoted either way. + */ +const renderComparison = ( + name: string, + args: Array, + quoteScalars: boolean +): Comparison | null => { + const [left, right] = args + if (left?.type !== "ref") { + return null + } + const column = left.path.join(".") + + if (name === "isNull") { + return { column, operator: "is", value: "null" } + } + if (right?.type !== "val") { + return null + } + + // `inArray()` builds a func named `in`, not `inArray`. + if (name === "in") { + const values = Array.isArray(right.value) ? right.value : [right.value] + const unique = Array.from(new Set(values)) + return { + column, + operator: "in", + value: `(${unique.map(quoteValue).join(",")})`, + } + } + + if (!SCALAR_OPERATORS.includes(name)) { + return null + } + return { + column, + operator: name, + value: quoteScalars ? quoteValue(right.value) : `${right.value}`, + } +} + +/** + * Render an expression as an embedded filter — the form that goes inside + * `or(…)` / `and(…)`. + * + * Strict: null unless the whole subtree pushes. Dropping a disjunct would + * *narrow* the result and lose matching rows, and under a `not` dropping a + * conjunct does the same once the negation applies. Only the top level can + * afford to drop anything, and `toPostgrestParams` is where that happens. + */ +const toFilterString = (expr: Expression): string | null => { + if (expr.type !== "func") { + return null + } + + if (expr.name === "and" || expr.name === "or") { + const parts = expr.args.map(toFilterString) + if (parts.includes(null)) { + return null + } + return `${expr.name}(${parts.join(",")})` + } + + if (expr.name === "not") { + const [inner] = expr.args + if (inner?.type !== "func") { + return null + } + if (inner.name === "and" || inner.name === "or") { + const nested = toFilterString(inner) + return nested === null ? null : `not.${nested}` + } + const negated = renderComparison(inner.name, inner.args, true) + if (!negated) { + return null + } + return `${negated.column}.not.${negated.operator}.${negated.value}` + } + + const comparison = renderComparison(expr.name, expr.args, true) + if (!comparison) { + return null + } + return `${comparison.column}.${comparison.operator}.${comparison.value}` +} + +/** + * Split a WHERE expression into the PostgREST parameters it pushes down to. + * + * Top-level `and` conjuncts are independent parameters, so an unpushable one is + * simply dropped: the request comes back a superset and the client re-filters + * it. Everything below that point is all-or-nothing. + * + * The same walk feeds both the request and the query key, so the two cannot + * disagree about what was pushed — two live queries share a cache entry only + * when they issue the identical request. + */ +export const toPostgrestParams = ( + expr: Expression | undefined | null +): Array => { + if (!expr || expr.type !== "func") { + return [] + } + + if (expr.name === "and") { + return expr.args.flatMap(toPostgrestParams) + } + + if (expr.name === "or") { + const parts = expr.args.map(toFilterString) + if (parts.includes(null)) { + return [] + } + return [{ kind: "group", filter: parts.join(",") }] + } + + if (expr.name === "not") { + const [inner] = expr.args + const isLogical = + inner?.type === "func" && (inner.name === "and" || inner.name === "or") + const negated = + inner?.type === "func" && !isLogical + ? renderComparison(inner.name, inner.args, false) + : null + if (negated) { + return [ + { kind: "column", ...negated, operator: `not.${negated.operator}` }, + ] + } + const filter = toFilterString(expr) + return filter === null ? [] : [{ kind: "group", filter }] + } + + const comparison = renderComparison(expr.name, expr.args, false) + return comparison ? [{ kind: "column", ...comparison }] : [] +} + +const paramToString = (param: PostgrestParam): string => + param.kind === "column" + ? `${param.column}=${param.operator}.${param.value}` + : `or=(${param.filter})` + +const applyParam = ( + baseQuery: GenericPostgrestFilterBuilder, + param: PostgrestParam +): GenericPostgrestFilterBuilder => + param.kind === "column" + ? baseQuery.filter(param.column, param.operator as any, param.value) + : baseQuery.or(param.filter) + +/** Cursor filters arrive pre-flattened as `SimpleComparison`, not as IR. */ const buildQuery = ( - baseQuery: PostgrestFilterBuilder, + baseQuery: GenericPostgrestFilterBuilder, filter: SimpleComparison -) => { +): GenericPostgrestFilterBuilder => { + const field = filter.field.join(".") if (filter.operator === "eq") { - baseQuery = baseQuery.eq(filter.field?.join("."), filter.value) - } else if (filter.operator === "gt") { - baseQuery = baseQuery.gt(filter.field?.join("."), filter.value) - } else if (filter.operator === "gte") { - baseQuery = baseQuery.gte(filter.field?.join("."), filter.value) - } else if (filter.operator === "lt") { - baseQuery = baseQuery.lt(filter.field?.join("."), filter.value) - } else if (filter.operator === "lte") { - baseQuery = baseQuery.lte(filter.field?.join("."), filter.value) - } else if (filter.operator === "in") { - baseQuery = baseQuery.in(filter.field?.join("."), filter.value) - } else if (filter.operator === "isNull") { - baseQuery = baseQuery.is(filter.field?.join("."), null) - } else if (filter.operator === "not_eq") { - baseQuery = baseQuery.not(filter.field?.join("."), "eq", filter.value) - } else { - console.warn(`buildQuery: unsupported operator: ${filter.operator}`) + return baseQuery.eq(field, filter.value) + } + if (filter.operator === "gt") { + return baseQuery.gt(field, filter.value) + } + if (filter.operator === "gte") { + return baseQuery.gte(field, filter.value) + } + if (filter.operator === "lt") { + return baseQuery.lt(field, filter.value) + } + if (filter.operator === "lte") { + return baseQuery.lte(field, filter.value) } + if (filter.operator === "in") { + return baseQuery.in(field, filter.value) + } + if (filter.operator === "isNull") { + return baseQuery.is(field, null) + } + if (filter.operator === "not_eq") { + return baseQuery.not(field, "eq", filter.value) + } + console.warn(`buildQuery: unsupported operator: ${filter.operator}`) + return baseQuery } export const subsetOptionsToQueryKey = ( tableName: string, ctx: LoadSubsetOptions ) => { - const filters = parseWhereExpression(ctx.where, { - handlers: { - eq: (field, value) => { - return `${field.join(".")}=eq.${value}` - }, - or: (field, value) => { - return `or(${field},${value})` - }, - isNull: (field) => `${field.join(".")}=is.null`, - in: (field, value) => { - const uniqueValues = Array.from(new Set(value)) - return `${field.join(".")}=in.${uniqueValues}` - }, - and: (...filters) => { - return `${filters.map((filter) => filter).join("&")}` - }, - gt: (field, value) => { - return `${field.join(".")}=gt.${value}` - }, - gte: (field, value) => { - return `${field.join(".")}=gte.${value}` - }, - lt: (field, value) => { - return `${field.join(".")}=lt.${value}` - }, - lte: (field, value) => { - return `${field.join(".")}=lte.${value}` - }, - not: (field, operator, value) => { - return field - }, - }, - onUnknownOperator: (operator, args) => { - console.warn(`Unsupported operator: ${operator}`) - return null - }, - }) + const filters = toPostgrestParams(ctx.where).map(paramToString).join("&") const sorts = parseOrderByExpression(ctx.orderBy) const limit = ctx.limit @@ -118,36 +282,34 @@ export const supabaseQueryFn = async ( const { limit, orderBy, offset, where, cursor } = ctx.meta?.loadSubsetOptions || {} - let cursorFilters: SimpleComparison[] = [] + let cursorFilters: Array = [] if (cursor) { cursorFilters = [...extractSimpleComparisons(cursor.whereFrom)] } - // Parse the expressions into simple format - const parsed = parseLoadSubsetOptions({ orderBy, limit, where }) - // console.log(tableName, parsed); - // console.log(tableName, cursorFilters); + const sorts = parseOrderByExpression(orderBy) - let baseQuery = supabase.from(tableName).select("*") + let baseQuery: GenericPostgrestFilterBuilder = supabase + .from(tableName) + .select("*") - if (parsed.limit) { - baseQuery = baseQuery.limit(parsed.limit) + if (limit) { + baseQuery = baseQuery.limit(limit) } if (offset) { baseQuery = baseQuery.range(offset, offset + 5) } - if (parsed.sorts) { - parsed.sorts.forEach((sort) => { - baseQuery = baseQuery.order(sort.field.join("."), { - ascending: sort.direction === "asc", - }) + for (const sort of sorts) { + baseQuery = baseQuery.order(sort.field.join("."), { + ascending: sort.direction === "asc", }) } - if (parsed.filters) { - ;[...parsed.filters, ...cursorFilters].forEach((filter) => { - buildQuery(baseQuery, filter) - }) + for (const param of toPostgrestParams(where)) { + baseQuery = applyParam(baseQuery, param) + } + for (const filter of cursorFilters) { + baseQuery = buildQuery(baseQuery, filter) } const { data, error } = await baseQuery diff --git a/src/query-once.ts b/src/query-once.ts index 1ef5a7a..c81df5e 100644 --- a/src/query-once.ts +++ b/src/query-once.ts @@ -9,6 +9,7 @@ import { type QueryBuilder, queryOnce as queryOnceBase, } from "@tanstack/db" +import { quoteValue } from "./functions" import { type SerializedExpression, type SerializedFrom, @@ -313,31 +314,41 @@ function renderEmbedNode(node: EmbedNode): string { // ── Filter string conversion (for .or() and .not()) ──────────────── -/** Convert a comparison expression to a PostgREST filter string */ +/** Render an `in` list, quoting the members PostgREST would otherwise split on */ +function toInList(expr: SerializedExpression): string { + const values = extractValue(expr) as unknown[] + return `(${values.map(quoteValue).join(",")})` +} + +/** + * Convert a comparison expression to a PostgREST filter string. + * + * These strings only ever land inside `or(…)` / `and(…)` / `not.…`, which + * PostgREST parses on `,` `(` `)` — so values are quoted here, unlike the + * top-level `column=op.value` form `applyFilter` builds through postgrest-js. + */ function toFilterString(expr: SerializedExpression): string { if (expr.type !== "func") { throw new Error(`Expected func expression, got ${expr.type}`) } + const column = () => refToColumn(expr.args[0]) + const value = () => quoteValue(extractValue(expr.args[1])) + switch (expr.name) { case "eq": - return `${refToColumn(expr.args[0])}.eq.${extractValue(expr.args[1])}` - case "neq": - return `${refToColumn(expr.args[0])}.neq.${extractValue(expr.args[1])}` case "gt": - return `${refToColumn(expr.args[0])}.gt.${extractValue(expr.args[1])}` case "gte": - return `${refToColumn(expr.args[0])}.gte.${extractValue(expr.args[1])}` case "lt": - return `${refToColumn(expr.args[0])}.lt.${extractValue(expr.args[1])}` case "lte": - return `${refToColumn(expr.args[0])}.lte.${extractValue(expr.args[1])}` + case "like": + case "ilike": + return `${column()}.${expr.name}.${value()}` case "isNull": - return `${refToColumn(expr.args[0])}.is.null` - case "inArray": { - const values = extractValue(expr.args[1]) as unknown[] - return `${refToColumn(expr.args[0])}.in.(${values.join(",")})` - } + return `${column()}.is.null` + // `inArray()` builds a func named `in`, not `inArray`. + case "in": + return `${column()}.in.${toInList(expr.args[1])}` case "not": return toNotFilterString(expr.args[0]) case "and": @@ -354,27 +365,25 @@ function toNotFilterString(expr: SerializedExpression): string { if (expr.type !== "func") { throw new Error(`Expected func inside not, got ${expr.type}`) } + + const column = () => refToColumn(expr.args[0]) + const value = () => quoteValue(extractValue(expr.args[1])) + switch (expr.name) { case "eq": - return `${refToColumn(expr.args[0])}.not.eq.${extractValue(expr.args[1])}` - case "neq": - return `${refToColumn(expr.args[0])}.not.neq.${extractValue(expr.args[1])}` case "gt": - return `${refToColumn(expr.args[0])}.not.gt.${extractValue(expr.args[1])}` case "gte": - return `${refToColumn(expr.args[0])}.not.gte.${extractValue(expr.args[1])}` case "lt": - return `${refToColumn(expr.args[0])}.not.lt.${extractValue(expr.args[1])}` case "lte": - return `${refToColumn(expr.args[0])}.not.lte.${extractValue(expr.args[1])}` + case "like": + case "ilike": + return `${column()}.not.${expr.name}.${value()}` case "isNull": - return `${refToColumn(expr.args[0])}.not.is.null` - case "inArray": { - const values = extractValue(expr.args[1]) as unknown[] - return `${refToColumn(expr.args[0])}.not.in.(${values.join(",")})` - } + return `${column()}.not.is.null` + case "in": + return `${column()}.not.in.${toInList(expr.args[1])}` default: - return `${refToColumn(expr.args[0])}.not.${expr.name}.${extractValue(expr.args[1])}` + return `${column()}.not.${expr.name}.${value()}` } } @@ -393,8 +402,6 @@ function applyFilter( switch (expr.name) { case "eq": return query.eq(refToColumn(expr.args[0]), extractValue(expr.args[1])) - case "neq": - return query.neq(refToColumn(expr.args[0]), extractValue(expr.args[1])) case "gt": return query.gt(refToColumn(expr.args[0]), extractValue(expr.args[1])) case "gte": @@ -403,9 +410,19 @@ function applyFilter( return query.lt(refToColumn(expr.args[0]), extractValue(expr.args[1])) case "lte": return query.lte(refToColumn(expr.args[0]), extractValue(expr.args[1])) + case "like": + return query.like( + refToColumn(expr.args[0]), + extractValue(expr.args[1]) as string + ) + case "ilike": + return query.ilike( + refToColumn(expr.args[0]), + extractValue(expr.args[1]) as string + ) case "isNull": return query.is(refToColumn(expr.args[0]), null) - case "inArray": + case "in": return query.in( refToColumn(expr.args[0]), extractValue(expr.args[1]) as unknown[] @@ -442,10 +459,16 @@ function applyNotFilter( "eq", extractValue(inner.args[1]) as string ) - case "neq": + case "like": return query.not( refToColumn(inner.args[0]), - "neq", + "like", + extractValue(inner.args[1]) as string + ) + case "ilike": + return query.not( + refToColumn(inner.args[0]), + "ilike", extractValue(inner.args[1]) as string ) case "gt": @@ -474,14 +497,12 @@ function applyNotFilter( ) case "isNull": return query.not(refToColumn(inner.args[0]), "is", null as any) - case "inArray": { - const values = extractValue(inner.args[1]) as unknown[] + case "in": return query.not( refToColumn(inner.args[0]), "in", - `(${values.join(",")})` as any + toInList(inner.args[1]) as any ) - } default: return query.not( refToColumn(inner.args[0]), @@ -499,7 +520,7 @@ function applyNotFilter( * Pushes to PostgREST: * - **select**: column refs, aggregates (count/sum/avg/min/max) * - **joins**: resource embedding with `!inner` / `!left` hints - * - **where**: eq, neq, gt, gte, lt, lte, isNull, inArray, not, and, or + * - **where**: eq, gt, gte, lt, lte, like, ilike, isNull, in, not, and, or * - **orderBy**: real column refs (skips computed/$selected) * - **limit / offset** * diff --git a/tests/index.test.ts b/tests/index.test.ts index 633f429..20f2da2 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -8,10 +8,12 @@ import { eq, gt, gte, + ilike, inArray, isNull, isUndefined, length, + like, lower, lt, lte, @@ -162,18 +164,49 @@ describe("PostgREST query generation", () => { ]) }) - test.todo("OR: active = true OR id = 1", async () => { + test("WHERE name LIKE '*Ali*'", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(user.name, "*Ali*")) + ) + expectFetchUrls(mockFetch, ["/rest/v1/users?select=*&name=like.*Ali*"]) + }) + + test("WHERE name ILIKE '*ali*'", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => ilike(user.name, "*ali*")) + ) + expectFetchUrls(mockFetch, ["/rest/v1/users?select=*&name=ilike.*ali*"]) + }) + + test("OR: active = true OR id = 1", async () => { await queryResult((q) => q .from({ user: usersCollection }) .where(({ user }) => or(eq(user.active, true), eq(user.id, 1))) ) expectFetchUrls(mockFetch, [ - "/rest/v1/users?select=*&active=eq.true&id=eq.1", + "/rest/v1/users?select=*&or=(active.eq.true,id.eq.1)", ]) }) - test.todo("nested AND/OR: active = true AND (id > 5 OR name = 'admin')", async () => { + test("OR of ILIKEs (search across columns)", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + or(ilike(user.name, "*ali*"), ilike(user.email, "*ali*")) + ) + ) + expectFetchUrls(mockFetch, [ + "/rest/v1/users?select=*&or=(name.ilike.*ali*,email.ilike.*ali*)", + ]) + }) + + test("nested AND/OR: active = true AND (id > 5 OR name = 'admin')", async () => { await queryResult((q) => q .from({ user: usersCollection }) @@ -184,6 +217,71 @@ describe("PostgREST query generation", () => { ) ) ) + expectFetchUrls(mockFetch, [ + "/rest/v1/users?select=*&active=eq.true&or=(id.gt.5,name.eq.admin)", + ]) + }) + + test("nested AND/OR of ILIKEs", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and( + eq(user.active, true), + or(ilike(user.name, "*ali*"), ilike(user.email, "*ali*")) + ) + ) + ) + expectFetchUrls(mockFetch, [ + "/rest/v1/users?select=*&active=eq.true&or=(name.ilike.*ali*,email.ilike.*ali*)", + ]) + }) + + test("value containing a comma is quoted", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + or(ilike(user.name, "*a,b*"), eq(user.email, "x,y")) + ) + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&or=(name.ilike."*a,b*",email.eq."x,y")', + ]) + }) + + test("IN list quotes members containing reserved characters", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => inArray(user.name, ["plain", "a,b", "c(d)"])) + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&name=in.(plain,"a,b","c(d)")', + ]) + }) + + test("AND drops only the unpushable conjunct", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and(eq(user.active, true), eq(upper(user.name), "ALICE")) + ) + ) + expectFetchUrls(mockFetch, ["/rest/v1/users?select=*&active=eq.true"]) + }) + + test("OR containing an unpushable branch is dropped entirely", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + or(eq(user.active, true), eq(upper(user.name), "ALICE")) + ) + ) + expectFetchUrls(mockFetch, ["/rest/v1/users?select=*"]) }) }) diff --git a/tests/query-once.test.ts b/tests/query-once.test.ts index bc72d21..eec2b5f 100644 --- a/tests/query-once.test.ts +++ b/tests/query-once.test.ts @@ -9,9 +9,11 @@ import { eq, gt, gte, + ilike, inArray, isNull, length, + like, lower, lt, lte, @@ -200,7 +202,7 @@ describe("queryOnce PostgREST query generation", () => { ]) }) - test.todo("OR: active = true OR id = 1", async () => { + test("OR: active = true OR id = 1", async () => { await queryOnce( (q) => q @@ -213,7 +215,7 @@ describe("queryOnce PostgREST query generation", () => { ]) }) - test.todo("nested AND/OR: active = true AND (id > 5 OR name = 'admin')", async () => { + test("nested AND/OR: active = true AND (id > 5 OR name = 'admin')", async () => { await queryOnce( (q) => q @@ -437,6 +439,104 @@ describe("queryOnce PostgREST query generation", () => { "/rest/v1/users?select=totalUsers:id.count()&active=eq.true", ]) }) + + // Only the aggregate / groupBy / having path reaches this package's own + // translator; every other query falls through to the collection loader. + test("aggregate with LIKE / ILIKE", async () => { + await queryOnce( + (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(user.name, "*Ali*")) + .where(({ user }) => ilike(user.email, "*ali*")) + .select(({ user }) => ({ totalUsers: count(user.id) })), + supabase + ) + expectFetchUrls(mockFetch, [ + "/rest/v1/users?select=totalUsers:id.count()&name=like.*Ali*&email=ilike.*ali*", + ]) + }) + + test("aggregate with OR of ILIKEs", async () => { + await queryOnce( + (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + or(ilike(user.name, "*ali*"), ilike(user.email, "*ali*")) + ) + .select(({ user }) => ({ totalUsers: count(user.id) })), + supabase + ) + expectFetchUrls(mockFetch, [ + "/rest/v1/users?select=totalUsers:id.count()&or=(name.ilike.*ali*,email.ilike.*ali*)", + ]) + }) + + test("aggregate with nested AND/OR of ILIKEs", async () => { + await queryOnce( + (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and( + eq(user.active, true), + or(ilike(user.name, "*ali*"), ilike(user.email, "*ali*")) + ) + ) + .select(({ user }) => ({ totalUsers: count(user.id) })), + supabase + ) + expectFetchUrls(mockFetch, [ + "/rest/v1/users?select=totalUsers:id.count()&active=eq.true&or=(name.ilike.*ali*,email.ilike.*ali*)", + ]) + }) + + test("aggregate with an IN list inside an OR", async () => { + await queryOnce( + (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + or(inArray(user.id, [1, 2, 3]), eq(user.active, true)) + ) + .select(({ user }) => ({ totalUsers: count(user.id) })), + supabase + ) + expectFetchUrls(mockFetch, [ + "/rest/v1/users?select=totalUsers:id.count()&or=(id.in.(1,2,3),active.eq.true)", + ]) + }) + + test("aggregate quotes values containing a comma", async () => { + await queryOnce( + (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + or(ilike(user.name, "*a,b*"), eq(user.email, "x,y")) + ) + .select(({ user }) => ({ totalUsers: count(user.id) })), + supabase + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=totalUsers:id.count()&or=(name.ilike."*a,b*",email.eq."x,y")', + ]) + }) + + test("aggregate with NOT(name LIKE …)", async () => { + await queryOnce( + (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => not(like(user.name, "*Ali*"))) + .select(({ user }) => ({ totalUsers: count(user.id) })), + supabase + ) + expectFetchUrls(mockFetch, [ + "/rest/v1/users?select=totalUsers:id.count()&name=not.like.*Ali*", + ]) + }) }) describe("GROUP BY (falls back to *, aggregation client-side)", () => {