Building a query
QueryBuilder is a fluent facade over a QubeeStore. Every mutator returns this, so a whole
query is one chain. Nothing is sent anywhere until you call generateUri().
The resource
Section titled “The resource”Everything starts here. generateUri() throws without it.
qb.setResource('articles'); // → /articlesqb.setBaseUrl('https://api.dev'); // → https://api.dev/articlesA resource can be a path, for APIs that nest one collection under another:
qb.setResource(`projects/${encodeURIComponent(projectId)}/tasks`); // → /projects/42/tasks- One slash joins the two. A trailing slash on the base URL and a leading slash on the resource
are dropped, so
https://api.dev/with/articlesstill giveshttps://api.dev/articles, and a URI never starts with//, which a browser would read as another host. - A trailing slash on the resource is kept, for backends whose routes end in one, as Django REST
Framework’s do:
articles/gives/articles/?…. - The resource is not encoded. Encode every path segment that comes from data, as above;
otherwise a
/or?in it changes the URI.
Pagination
Section titled “Pagination”qb.setPage(2).setLimit(25);Navigation helpers move relative to the current page and clamp rather than throw:
| Method | Behaviour |
|---|---|
firstPage() |
Jump to page 1. Idempotent |
nextPage() |
Advance one page. No-op on the last page, once known |
previousPage() |
Back one page. No-op on page 1 |
lastPage() |
Jump to the final page. Throws until a response is synced |
goToPage(n) |
Jump to n. Throws InvalidPageNumberError if out of range |
Read-only helpers answer where you are:
qb.currentPage(); // 2qb.hasNextPage(); // trueqb.hasPreviousPage(); // trueqb.isFirstPage(); // falseqb.totalPages(); // 6 — throws until syncedSorting
Section titled “Sorting”import { SortEnum } from '@qubeejs/core';
qb.addSort('createdAt', SortEnum.DESC).addSort('title', SortEnum.ASC);qb.deleteSorts('title');Field selection
Section titled “Field selection”Two different things, and drivers support them independently:
qb.addSelect('id', 'title'); // flat — capabilities.selectqb.addFields('articles', ['id']); // typed — capabilities.fieldsaddSelect() names columns on the primary resource. addFields() names them per model, the
JSON:API-style sparse fieldset.
On JSON:API and Spatie, every model passed to addFields() must be the resource’s own or one of
its includes, and the resource’s own must be among them. For a nested resource, the path cannot tell
the model: users/42/followers returns users, and users/me ends in me. The fields then go out
exactly as you name them, and the server checks them:
qb.setResource('users/42/followers').addFields('users', ['name']); // → fields[users]=nameRelations
Section titled “Relations”qb.addIncludes('author', 'comments'); // capabilities.includesqb.addEmbedded('author', 'name', 'email'); // capabilities.embedded — PostgRESTSearch
Section titled “Search”qb.setSearch('typescript'); // capabilities.searchParameters no driver models
Section titled “Parameters no driver models”Backends accept more than any driver models — a top-level status, an include on an endpoint
whose driver has no includes. setParam() adds one and deleteParams() removes it:
qb.setResource('jobs').setParam('status', 'failed');// NestJS → /jobs?limit=15&page=1&status=failed
qb.setParam('include', 'queue', 'user'); // → include=queue,userqb.deleteParams('include');generateUri() appends them after the driver’s own parameters, in the order their keys were first
set — except that integer-like keys such as '2024' come first, as in any JavaScript object. That happens once the request strategy has returned, so every driver emits them — a custom
strategy that implements IRequestStrategy directly included — and no capability gates them.
| Behaviour | |
|---|---|
| Key | Emitted verbatim, so meta[tag] works — never build one from end-user input |
| Values | Each percent-encoded like a filter value, then joined with a literal ,: setParam('ids', 'a,b', 'c') gives ids=a%2Cb,c |
| Setting a key again | Replaces its values |
| No values | setParam('status') does nothing; remove a key with deleteParams() |
| Repeated keys | Not supported — a key is emitted once |
| Page | Both methods reset it to 1, as filters do |
reset() |
Clears them |
Collisions
Section titled “Collisions”A param must not shadow one the driver emits. When its key equals a key in the generated URI — or
one is the other followed by [, as page and JSON:API’s page[number] are — generateUri()
throws rather than send a duplicate the backend would resolve however it likes:
jsonApi.setResource('jobs').setParam('page', 2).generateUri();// ParamCollisionError: The param 'page' collides with 'page[number]', which the// 'json-api' driver emits. Use the builder method that controls it, or// remove the param with deleteParams('page').The check reads the URI the driver actually produced, so a key is free until the driver needs it:
on JSON:API, setParam('sort', …) works until addSort() makes the driver emit sort itself.
Siblings are fine — page[cursor] beside JSON:API’s page[number] does not collide, though beside
a bare page it would.
Pagination keys are the exception: the next page’s URI is checked too. PostgREST and OData leave
offset and $skip out of page 1, and a param named after one still collides there, rather than
only once the user navigates.
Chaining and resetting
Section titled “Chaining and resetting”const uri = qb .setResource('articles') .addFilter('status', 'published') .addSort('createdAt', SortEnum.DESC) .setLimit(25) .generateUri();
qb.reset(); // back to a pristine stateCapability checks happen immediately
Section titled “Capability checks happen immediately”Every mutator asks the driver first. Calling something the backend cannot express throws at the call site rather than emitting a URI the server silently ignores:
const qb = new QueryBuilder(store, LARAVEL_DRIVER.createRequestStrategy('query'), undefined, 'laravel');
qb.addFilter('status', 'published');// UnsupportedFilterError: The 'laravel' driver does not support filters.See the capability matrix for what each driver supports.
