Skip to content
Open
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
69 changes: 43 additions & 26 deletions src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,43 @@ import {
} from "@tanstack/db"
import type { QueryClient, QueryMeta } from "@tanstack/query-core"

type GenericPostgrestFilterBuilder = PostgrestFilterBuilder<any, any, any, any>

// 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<any, any, any, any>,
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 = (
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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("*")

Expand All @@ -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) => {
Expand All @@ -146,7 +163,7 @@ export const supabaseQueryFn = async (

if (parsed.filters) {
;[...parsed.filters, ...cursorFilters].forEach((filter) => {
buildQuery(baseQuery, filter)
baseQuery = buildQuery(baseQuery, filter)
})
}

Expand Down
88 changes: 88 additions & 0 deletions tests/functions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof usersFrom>) => unknown
): IR.BasicExpression<boolean> {
const built = build(usersFrom())
const where = (built as unknown as BaseQueryBuilder)._getQuery().where?.[0] as
| IR.BasicExpression<boolean>
| 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<typeof createMockFetch>
let supabase: SupabaseClient

beforeEach(() => {
mockFetch = createMockFetch()
supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
global: { fetch: mockFetch },
})
})

function run(loadSubsetOptions: Record<string, unknown>) {
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")
})
})
3 changes: 3 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down