A query builder has been a request for sequelize for a long time (sequelize/sequelize#394)
This document is only going to talk about API design.
The approach taken here is different from the ones proposed in sequelize/sequelize#394. The Goal is to provide a small layer on top of Model.findAll
, Model.update
, etc…
While this API is designed to accomodate a future query builder for insert
, update
, and delete
, it currently focuses on select
as it is the biggest use case.
This Query Builder only builds a query object that Model static methods can consume.
const query = User
.select()
.alias('user')
.attributes(['name', 'id'])
.where({ email: { [Op.neq]: null } }) // using WhereOptions<User>
.andWhere('firstName', Op.iLike, 'Zoé') // using Sequelize.where(col, op, value)
.andWhere(`user.lastName <> $lastName`, { lastName }); // using Sequelize.literal(sql, bind)
const result: User[] = await query.limit(10).findAll();
// or
const result: User[] = await User.findAll(query.limit(10));
I'd also like to provide a WhereQueryBuilder
interface that only builds a WHERE
clause:
const whereBuilder = new WhereQueryBuilder()
.where({ email: { [Op.neq]: null } })
.andWhere({ firstName: { [Op.iLike]: 'Zoé' } });
// it can be used in as a value for `FindOptions.where`:
User.findAll({
where: whereBuilder,
});
// or used as a literal:
sequelize.query(`SELECT * FROM users WHERE ${whereBuilder.toSql({ alias: 'users' })}`, {
type: QueryTypes.SELECT,
bind: {
...whereBuilder.getBindParameters(),
},
})
- How does this work when used in associations? eg.
User.getProjects
Related to https://github.com/sequelize/meetings/issues/14 - How do we want to handle subqueries? Like TypeORM? Will need changes to
Model.find*
- How do we handle
distinct
&distinct on
?
Sequelize.literal(literalStr)
accepts a new parameter bind
to support escaping values in literals:
User.findAll({
where: Sequelize.literal('id = $id', { id: 5 }),
});
import { GroupOption, Order } from '../../../../../Documents/dev/sequelize/dist/index.js';
class Model<Attributes = any, CreationAttributes = any> {
static select<M extends Model>(): SelectQueryBuilder<M>;
}
abstract class QueryBuilder<M extends Model> {
/**
* Generates & Returns the SQL query without running it.
*/
abstract toSql(): string;
/**
* Resets the current value for `this.where` and sets it to
* Sequelize.literal(literalStr, bind)
*
* @example
* User.select()
* .where(`user.name = $name`, { name: 'Zoé' })
* .findAll();
*/
where(literalStr: string, bind: BindOrReplacements): this;
/**
* Resets the current value for `this.where` and sets it to `options`.
*
* @example
* User.select()
* .where({ name: 'Zoé' })
* .findAll();
*/
where(options: WhereOptions): this;
/**
* Adds a where clause using the AND operator.
* Behaves exactly like {@link #where} if this is the first added clause.
*
* Mixing with {@link #orWhere} is not allowed to avoid ambiguity.
* Use WhereBrackets.
*
* @example
* User.select()
* .where({ id: { [Op.gt]: 3 } })
* .andWhere(`user.name = $name`, { name: 'Zoé' })
* .findAll();
*/
andWhere(literal: string, bind: BindOrReplacements): this;
andWhere(options: WhereOptions): this;
/**
* Use to group `where` clauses together.
*
* @example
* User.select()
* .where({ id: { [Op.gt]: 3 } })
* .andWhere(qb => {
* qb.where({ name: 'Zoé' })
* .orWhere({ name: 'Name' })
* })
* .findAll();
*/
andWhere(options: WhereBrackets): this;
orWhere(literal: string, bind: BindOrReplacements): this;
orWhere(options: WhereOptions): this;
orWhere(options: WhereBrackets): this;
}
class SelectQueryBuilder<M extends Model<M>> extends QueryBuilder<M> {
// TODO: proper API for having
groupBy(group: GroupOption): this;
order(order: Order): this;
limit(limit: number): this;
offset(offset: number): this;
attributes(opts: FindAttributeOptions): this;
include(opts: Includeable): this;
// execution methods
findAll(options: FindOptionSubset): Promise<M[]>;
findOne(options: FindOptionSubset): Promise<M[]>;
count(): Promise<number>;
}
class WhereQueryBuilder<M extends Model<M>> extends QueryBuilder<M> {
toSql(options): string;
toFindOptions(): FindOptions<Attributes<M>>
getBindParameters(): Record<string, any>;
}
// inspired by TypeORM
type WhereBrackets = (callback: (qb: QueryBuilder) => void);
To avoid ambiguity, orWhere
and andWhere
methods cannot be used together.
// Invalid due to ambiguity. Is this
// WHERE (email IS NULL AND password IS NULL) OR id = 10
// or
// WHERE email IS NULL AND (password IS NULL OR id = 10)
await User
.select()
.where({ email: null })
.andWhere({ password: null })
.orWhere({ id: 10 })
.findAll();
You must use WhereBrackets
to avoid ambiguous queries.
// Ok! Generated as:
// WHERE email IS NULL AND (password IS NULL OR id = 10)
await User
.select()
.where({ email: null })
.andWhere(qb => {
qb.where({ password: null })
.orWhere({ id: 10 })
})
.findAll();
// Ok! Generated as:
// WHERE (email IS NULL AND password IS NULL) OR id = 10
await User
.select()
.where(qb => {
qb.where({ email: null })
.andWhere({ password: null })
})
.orWhere({ id: 10 })
.findAll();
Until sequelize introduces a way to have custom joins (sequelize/sequelize#11788), only .include
is provided:
await User
.select()
.include(User.associations.projects, includeBuilder => {
return includeBuilder
.attributes(['projectName']);
})
.findAll();
Sequelize escapes and inlines values passed to WhereOptions
.
Using Sequelize.bind
, we could tell sequelize "don't inline these, let the dbms handle it":
User.findAll({
where: { name: Sequelize.bind(name) }
});
// outputs roughtly:
// SELECT * FROM users WHERE name = ?
I think most of it looks great. You could draw some inspiration from Knex, they have done a tremendous job with theirs, its quite flexible.
Some ideas worth mentioning, at least from my experience
Attributes
I think its more concise without the brackets and gathering them up using the rest operator
Operators
Methods like
are more ergonomic compared to Sequelize's operators, the object nesting is quite unpleasant to work with.
Execution
For the execution, you can consider a thenable object, as explained here.
Knex uses this and what it allows is essentially to execute the query without the
.findAll()
at the end.You implement the
.findAll
internally, and when the user callsawait qb...
, it calls thethen
method on the Qb and from there you trigger the execution.You can then implement
findOne
orcount
with.first()
or.count()
.Subqueries
It'd be nice for the qb instances to be composable, so that they can be used in different places without having to drop down to sql. For example, some pseudo code from TypeORM example
Joins
Referring associations like in the example feels a bit verbose. I'm not sure how it would work with aliases, but perhaps something like this would be quite nice
You probably don't need my opinion, especially considering that this is private, but gonna give it anyway, because I'm very excited for this feature
Edit: I was so excited that I completely missed the part that this is an API layer on top of the current methods, so some of the propositions here may not work, but gonna leave it here anyway in case you find something useful