@ballistix.digital/odata-query-builder
A typed, fluent builder for the OData v4 query strings that
@ballistix.digital/typeorm-extensions serves.
It builds a query string. It does not fetch, and it does not know about the
_embedded / _page response envelope. It has zero runtime dependencies.
Full documentation lives in docs/: a
guide for a developer who uses the package, and
maintainer pages for a developer who changes it.
Install
npm i @ballistix.digital/odata-query-builder
It needs Node 20 or newer, and TypeScript 5.9, 6.0 or 7.0.
Example
import { odataQuery } from '@ballistix.digital/odata-query-builder';
enum ProjectStatusEnum {
DRAFT = 'draft',
ACTIVE = 'active',
ARCHIVED = 'archived',
}
// ProjectDto is your own DTO type. Getting started shows its shape.
const query = odataQuery<ProjectDto>()
.filter((v, o) =>
o.and(
v.archived.isFalse(),
v.status.in([ProjectStatusEnum.DRAFT, ProjectStatusEnum.ACTIVE]),
v.children.any((a) => a.priority.ge(2)),
),
)
.select('id', 'name', 'status')
.expand.parent((p) => p.select('name').expand.owner((u) => u.select('fullName')))
.orderBy('name', 'desc')
.top(25)
.count()
.toQueryString();
// $filter=archived eq false and status in ('draft','active') and children/any(a:a/priority ge 2)&$select=id,name,status&$expand=parent($select=name;$expand=owner($select=fullName))&$orderby=name desc&$top=25&$count=true
Getting started
prints the ProjectDto shape the examples use.
What makes it typed
The scope a filter() callback receives is derived from the DTO type alone:
- an own scalar column becomes an expression typed by the column's own type, so an enum column takes enum members and a number column refuses a string;
- a
_embeddedto-one relation becomes a nested scope, emitted with/; - a
_embeddedto-many relation becomes a lambda namespace, soanyandallare the only things reachable on it — which is what the server accepts; - a primitive array column (
text[]) becomes a lambda namespace over the element.
expand.<relation> lists exactly the relations _embedded declares. There is
no code generation, no $metadata document, and no configuration: the DTO
convention is _embedded, and the library assumes it.
A property read is never a method
v.year is ambiguous at runtime: the year() function, or a column called
year. Reading a property therefore never resolves a function. Read further
from it and it was a path segment; call it and it was a function. Both roles
work in one expression:
v.year.eq(v.date.asDate().year());
// year eq year(date)
Escaping
toQueryString() is already URL-safe. Do not run encodeURIComponent over
it — that turns %25 into %2525.
The escape rules list what
each literal character becomes.
Use toParams() when the caller has its own serialiser (URLSearchParams,
ky): it hands back the raw values so that serialiser's own encoding is the only
one applied.
Documentation
- Getting started — install, the
_embeddedassumption, a first query. - Querying — every verb, the
expandnamespace, filtered expansions andodataItem. - Filtering — operators, functions, lambdas, dates,
castand the combinators. - Sending the query —
toQueryString()ortoParams(), and the escape rules. - Errors — what the builder throws and how to catch it.
- Cookbook — twelve call shapes with their strings.
Contributing
To run the tests and find your way around the source, read Conventions and Testing.