diff --git a/src/functions.ts b/src/functions.ts index aa6a547..8d5de2f 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -13,29 +13,43 @@ import { } from "@tanstack/db" import type { QueryClient, QueryMeta } from "@tanstack/query-core" +type GenericPostgrestFilterBuilder = PostgrestFilterBuilder + +// Default range size used when an offset is requested without an explicit +// limit, matching the one-shot queryOnce path. +const DEFAULT_PAGE_SIZE = 1000 + const buildQuery = ( - baseQuery: PostgrestFilterBuilder, + baseQuery: GenericPostgrestFilterBuilder, filter: SimpleComparison -) => { +): GenericPostgrestFilterBuilder => { + const column = 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(column, filter.value) + } + if (filter.operator === "gt") { + return baseQuery.gt(column, filter.value) + } + if (filter.operator === "gte") { + return baseQuery.gte(column, filter.value) + } + if (filter.operator === "lt") { + return baseQuery.lt(column, filter.value) } + if (filter.operator === "lte") { + return baseQuery.lte(column, filter.value) + } + if (filter.operator === "in") { + return baseQuery.in(column, filter.value) + } + if (filter.operator === "isNull") { + return baseQuery.is(column, null) + } + if (filter.operator === "not_eq") { + return baseQuery.not(column, "eq", filter.value) + } + console.warn(`buildQuery: unsupported operator: ${filter.operator}`) + return baseQuery } export const subsetOptionsToQueryKey = ( @@ -70,9 +84,10 @@ export const subsetOptionsToQueryKey = ( lte: (field, value) => { return `${field.join(".")}=lte.${value}` }, - not: (field, operator, value) => { - return field - }, + // `not` receives the already-parsed inner clause (e.g. "active=eq.false"). + // Wrap it so a negated clause produces a distinct cache key from the + // un-negated one instead of colliding with it. + not: (inner) => `not(${inner})`, }, onUnknownOperator: (operator, args) => { console.warn(`Unsupported operator: ${operator}`) @@ -124,8 +139,6 @@ export const supabaseQueryFn = async ( } // Parse the expressions into simple format const parsed = parseLoadSubsetOptions({ orderBy, limit, where }) - // console.log(tableName, parsed); - // console.log(tableName, cursorFilters); let baseQuery = supabase.from(tableName).select("*") @@ -134,7 +147,11 @@ export const supabaseQueryFn = async ( } if (offset) { - baseQuery = baseQuery.range(offset, offset + 5) + // range() is inclusive on both ends, so the end index is the offset plus + // the page size minus one. Without an explicit limit, fall back to a + // sensible default page size instead of a hard-coded window. + const end = offset + (parsed.limit ?? DEFAULT_PAGE_SIZE) - 1 + baseQuery = baseQuery.range(offset, end) } if (parsed.sorts) { parsed.sorts.forEach((sort) => { @@ -146,7 +163,7 @@ export const supabaseQueryFn = async ( if (parsed.filters) { ;[...parsed.filters, ...cursorFilters].forEach((filter) => { - buildQuery(baseQuery, filter) + baseQuery = buildQuery(baseQuery, filter) }) } diff --git a/tests/functions.test.ts b/tests/functions.test.ts new file mode 100644 index 0000000..b00dbdd --- /dev/null +++ b/tests/functions.test.ts @@ -0,0 +1,88 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js" +import type { BaseQueryBuilder, IR } from "@tanstack/db" +import { and, eq, gt, not, Query } from "@tanstack/db" +import { beforeEach, describe, expect, test } from "vitest" +import { subsetOptionsToQueryKey, supabaseQueryFn } from "../src/functions" +import { + createMockedUsersCollection, + createMockFetch, + expectFetchUrls, + SUPABASE_KEY, + SUPABASE_URL, +} from "./test.utils" + +// Pull a parsed WHERE expression out of the query builder so we can feed it to +// supabaseQueryFn / subsetOptionsToQueryKey the way the collection does. +function whereOf( + build: (q: ReturnType) => unknown +): IR.BasicExpression { + const built = build(usersFrom()) + const where = (built as unknown as BaseQueryBuilder)._getQuery().where?.[0] as + | IR.BasicExpression + | undefined + if (!where) { + throw new Error("expected a where clause") + } + return where +} + +function usersFrom() { + const usersCollection = createMockedUsersCollection(createMockFetch()) + return new Query().from({ user: usersCollection }) +} + +describe("supabaseQueryFn", () => { + let mockFetch: ReturnType + let supabase: SupabaseClient + + beforeEach(() => { + mockFetch = createMockFetch() + supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }) + }) + + function run(loadSubsetOptions: Record) { + return supabaseQueryFn(supabase, "users", { + client: {} as never, + queryKey: ["users"], + signal: new AbortController().signal, + meta: { loadSubsetOptions } as never, + }) + } + + // Regression: offset previously used a hard-coded range(offset, offset + 5), + // capping every paged request at 6 rows regardless of limit. + test("offset uses a limit-sized inclusive range, not a fixed window", async () => { + await run({ limit: 20, offset: 20 }) + expectFetchUrls(mockFetch, ["/rest/v1/users?select=*&limit=20&offset=20"]) + }) + + test("offset without an explicit limit falls back to the default page size", async () => { + await run({ offset: 10 }) + expectFetchUrls(mockFetch, ["/rest/v1/users?select=*&limit=1000&offset=10"]) + }) +}) + +describe("subsetOptionsToQueryKey", () => { + test("negated clauses produce a distinct cache key from un-negated ones", () => { + const negated = subsetOptionsToQueryKey("users", { + where: whereOf((q) => q.where(({ user }) => not(eq(user.active, false)))), + } as never) + const plain = subsetOptionsToQueryKey("users", { + where: whereOf((q) => q.where(({ user }) => eq(user.active, false))), + } as never) + + expect(negated).not.toEqual(plain) + }) + + test("AND of filters serializes both sides into the key", () => { + const key = subsetOptionsToQueryKey("users", { + where: whereOf((q) => + q.where(({ user }) => and(eq(user.active, true), gt(user.id, 5))) + ), + } as never) + expect(JSON.stringify(key)).toContain("active=eq.true") + expect(JSON.stringify(key)).toContain("id=gt.5") + }) +}) diff --git a/tests/index.test.ts b/tests/index.test.ts index 633f429..4284561 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -484,6 +484,9 @@ describe("PostgREST query generation", () => { }) describe("LIMIT + OFFSET", () => { + // TanStack DB collapses `.limit(n).offset(m)` into a single `limit=n+m` + // subset request, so offset is not surfaced separately on this path. The + // offset branch in supabaseQueryFn is covered directly in functions.test.ts. test.todo("pagination (page 2)", async () => { await queryResult((q) => q