ModelQueryBuilder (Type-Safe)
The ModelQueryBuilder
is the primary, partially type-safe query API for Hysteria ORM models. All calls to User.query()
, Post.query()
, etc., return a ModelQueryBuilder instance.
The query builder is partially type safe, triggering intellisense for developer experience but still allowing the developer to write whatever he wants.
The ModelQueryBuilder by default returns a ModelWithoutRelations<T>
type, which is a type that does not have any relations. You can add Relations or Annotations using the load
and annotate
methods.
Key Features
- Fully partially type-safe queries and results
- Decorator and relation support
- Rich API for filtering, selecting, joining, and more
- Supports advanced features like CTEs, annotations, pagination, and soft deletes
- Model columns are parsed to database columns based on the case convention of the model, this applies both on column names like
isActive
and on table.column scenarios likeusers.isActive
Example Usage
const users = await User.query().where('status', 'active').many();
Filtering
// Here if model has snake case convention, the columns will be parsed to snake case
const users = await User.query().where('age', '>', 18).andWhere('isActive', true).andWhere('users.isActive', true).many();
Raw right-hand side with rawStatement
When you need to compare a column to another column or an expression without creating a binding, use sql.rawStatement
. Identifiers inside the raw string are automatically quoted per database dialect.
import { sql } from 'hysteria-orm';
await User.query()
.where('id', sql.rawStatement('user.id'))
.many();
Selecting Columns
- In
ModelQueryBuilder
,select
now only accepts model keys or "*". Non-model or computed columns must be added usingannotate
. select
can be stacked; the resulting TypeScript type reflects only the selected model columns. If noselect
is used, the full model type (without relations) is returned as before.
// OK: model columns
const user = await User.query().select('id', 'name').first();
// OK: all model columns
const userAll = await User.query().select('*').first();
// For non-model columns use annotate (see below)
Annotations
const user = await User.query()
.annotate('count', '*', 'count') // count(*) as count
.first();
console.log(user.$annotations.count);
Annotate rules and edge cases
- Use
annotate
to project non-model columns or SQL functions. Annotated values are exposed under$annotations
on model results. - When the builder has no explicit
select
(defaultselect *
), usingannotate
alone will not mix annotation fields into the model shape. Hysteria will keep model fields empty and only include$annotations
for that query response; addselect('*')
if you also want model columns.
// Only annotation, no explicit select → model fields omitted, only $annotations present
const r1 = await User.query().annotate('count', '*', 'total').first(); // { $annotations: { total: number } }
console.log(r1.$annotations.total);
// Include full model by selecting '*'
const r2 = await User.query().select('*').annotate('count', '*', 'total').first();
console.log(r2.id, r2.$annotations.total);
Pagination
const page = await User.query().paginate(1, 10);
console.log(page.data, page.paginationMetadata);
Joins
- Joins do not use the joined table hooks, only the caller model hooks are used
- Joined model columns are cased based on the case convention of the model
const postsWithUsers = await Post.query()
.select('posts.*', 'users.name')
.join('users', 'posts.userId', 'users.id')
.many();
const postsWithUsersFromModel = await Post.query()
.select('posts.*', 'users.name')
.join(User, 'userId', 'id')
.many();
CTEs (Common Table Expressions)
const users = await User.query()
.with('users_cte', (qb) => qb.select('name').where('isActive', true))
.many();
Pluck
const names = await User.query().pluck('name'); // string[]
Increment / Decrement
await User.query().increment('age', 1);
await User.query().decrement('age', 1);
Locking
const users = await User.query().lockForUpdate().many();
const users = await User.query().forShare().many();
clearSelect
const users = await User.query().select('name').clearSelect().many();
Soft Delete
await User.query().softDelete({ column: 'deleted_at' });
Type Safety Example
// users: ModelWithoutRelations<User>
const users = await User.query().where('email', 'like', '%@example.com').many();
Comparison to QueryBuilder
For raw SQL reference (filtering, joins, pagination, streaming, chunking), see: QueryBuilder (Raw SQL)
Selected API Highlights
- Filtering:
where
,orWhere
,andWhere
,whereIn
,whereNull
,whereBetween
,whereLike
,whereExists
- Joins:
join
,leftJoin
,rightJoin
,innerJoin
- Aggregates:
getCount
,getMax
,getMin
,getAvg
,getSum
- Pagination:
paginate
,paginateWithCursor
,limit
,offset
,chunk
- Annotations:
annotate
,removeAnnotations
,clearRemoveAnnotations
- Relations:
load
,clearRelations
,havingRelated
,notHavingRelated
Refer to the tests under test/sql
for comprehensive examples aligned with the implementation.
Next: QueryBuilder (Raw SQL)