Skip to content
Guide/Building a query

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().

Everything starts here. generateUri() throws without it.

qb.setResource('articles'); // → /articles
qb.setBaseUrl('https://api.dev'); // → https://api.dev/articles

A 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 /articles still gives https://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.
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(); // 2
qb.hasNextPage(); // true
qb.hasPreviousPage(); // true
qb.isFirstPage(); // false
qb.totalPages(); // 6 — throws until synced
import { SortEnum } from '@qubeejs/core';
qb.addSort('createdAt', SortEnum.DESC).addSort('title', SortEnum.ASC);
qb.deleteSorts('title');

Two different things, and drivers support them independently:

qb.addSelect('id', 'title'); // flat — capabilities.select
qb.addFields('articles', ['id']); // typed — capabilities.fields

addSelect() 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]=name
qb.addIncludes('author', 'comments'); // capabilities.includes
qb.addEmbedded('author', 'name', 'email'); // capabilities.embedded — PostgREST
qb.setSearch('typescript'); // capabilities.search

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,user
qb.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

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.

const uri = qb
.setResource('articles')
.addFilter('status', 'published')
.addSort('createdAt', SortEnum.DESC)
.setLimit(25)
.generateUri();
qb.reset(); // back to a pristine state

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.