Paginating a query that joins one-to-many relationships is deceptively difficult. A naiveDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/fajarnugraha37/drizzle-castor/llms.txt
Use this file to discover all available pages before exploring further.
SELECT … JOIN … LIMIT 10 does not return 10 parent rows — it returns at most 10 join result rows, which may represent as few as one parent record if it has many children. This is called Cartesian fan-out, and it breaks LIMIT/OFFSET pagination silently. Drizzle Castor solves this with a CTE split query strategy that issues two coordinated queries: one to determine the exact set of paginated IDs, and one to hydrate those IDs with their full relation graph.
The Cartesian fan-out problem
Consider users who each have multiple posts. A simple paginated join might look like:The CTE split query solution
Drizzle Castor’sbuildSearchQueries function solves this with a two-query strategy wrapped in a Common Table Expression:
- Query 1 — the CTE: selects the exact paginated base IDs by applying
GROUP BY,ORDER BY,LIMIT, andOFFSETon the parent table only. No one-to-many joins are applied here, so each row in the result represents exactly one parent record. - Query 2 — the outer query: takes the IDs returned by the CTE and
INNER JOINs them against the full relation graph. The join to one-to-many children happens only after the ID set is locked.
INNER JOIN "sq" on the outer query constrains it to precisely the 10 parent rows selected in the CTE, regardless of how many child rows each parent has.
The searchPage method
searchPage(query, profile) is the public API for paginated results. It accepts a SearchQuery with page and pageSize parameters alongside the standard filter, order, and projection fields.
Return shape
searchPage always returns an object with two keys: data and meta.
When
totalItems is 0, searchPage returns immediately without executing the main query. This short-circuit prevents unnecessary database round-trips for empty result sets.How count and data queries are issued
TheexecuteSearchPage function in src/queries/search.ts issues two database round-trips:
COUNT(*) and no LIMIT. The main query uses the full CTE split structure described above. After the raw rows are returned, hydrateResults collapses the flat tabular output into the nested JSON shape.
Pagination parameters
Bothpage and pageSize are optional fields on SearchQuery. Defaults are applied in the executor when they are absent.
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | One-indexed page number to fetch. |
pageSize | number | 10 | Number of parent records per page. |
Choosing searchMany vs searchPage
searchMany
Returns all matching records with no
LIMIT. Use when you need every result and pagination is not relevant — for example, populating a dropdown, running a batch export, or seeding data.searchPage
Returns a single page of results with exact
meta counts. Use for any user-facing list view, infinite scroll, or API endpoint that a client will paginate through.Interaction with soft deletes and RBAC
The CTE split query strategy operates downstream of both the soft-delete filter injection and the RBAC middleware. BeforebuildSearchQueries is called, injectSoftDeleteFilter has already appended the active-record condition to the filter object, and the RBAC engine has already trimmed any unpermitted fields from the projection and filter. The CTE receives the fully pre-processed query.
Related pages
Multi-dialect support
How the CTE strategy is rendered for PostgreSQL, MySQL, and SQLite.
SearchQuery reference
Full type reference for the
SearchQuery object including all filter, order, and projection options.Read methods reference
Complete API reference for
searchOne, searchMany, searchPage, and the soft-deleted variants.Native soft deletes
How soft-delete filters are automatically injected before the CTE query is built.