@adonisjs/lucid
Version:
SQL ORM built on top of Active Record pattern
701 lines (700 loc) • 22.1 kB
JavaScript
/*
* @adonisjs/lucid
*
* (c) Harminder Virk <virk@adonisjs.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { Exception } from '@poppinss/utils';
import { isObject } from '../../utils/index.js';
import { Preloader } from '../preloader/index.js';
import { ModelPaginator } from '../paginator/index.js';
import { QueryRunner } from '../../query_runner/index.js';
import { Chainable } from '../../database/query_builder/chainable.js';
import { SimplePaginator } from '../../database/paginator/simple_paginator.js';
import * as errors from '../../errors.js';
/**
* A wrapper to invoke scope methods on the query builder
* underlying model
*/
class ModelScopes {
builder;
constructor(builder) {
this.builder = builder;
return new Proxy(this, {
get(target, key) {
if (typeof target.builder.model[key] === 'function') {
return (...args) => {
return target.builder.model[key](target.builder, ...args);
};
}
/**
* Unknown keys are not allowed
*/
throw new Error(`"${String(key)}" is not defined as a query scope on "${target.builder.model.name}" model`);
},
});
}
}
/**
* Database query builder exposes the API to construct and run queries for selecting,
* updating and deleting records.
*/
export class ModelQueryBuilder extends Chainable {
model;
client;
/**
* A copy of defined preloads on the model instance
*/
preloader;
/**
* A custom callback to transform each model row
*/
rowTransformerCallback;
/**
* Required by macroable
*/
static macros = {};
static getters = {};
/**
* A references to model scopes wrapper. It is lazily initialized
* only when the `apply` method is invoked
*/
scopesWrapper = undefined;
/**
* Control whether to wrap adapter result to model
* instances or not
*/
wrapResultsToModelInstances = true;
/**
* Custom data someone want to send to the profiler and the
* query event
*/
customReporterData;
/**
* Control whether to debug the query or not. The initial
* value is inherited from the query client
*/
debugQueries;
/**
* Self join counter, increments with every "withCount"
* "has" and "whereHas" queries.
*/
joinCounter = 0;
/**
* Options that must be passed to all new model instances
*/
clientOptions;
/**
* Whether query is a sub-query for `.where` callback
*/
isChildQuery = false;
/**
* Side-loaded attributes that will be passed to the model instances
*/
sideloaded = {};
constructor(builder, model, client, customFn = (userFn) => {
return ($builder) => {
const subQuery = new ModelQueryBuilder($builder, this.model, this.client);
subQuery.isChildQuery = true;
userFn(subQuery);
subQuery.applyWhere();
};
}) {
super(builder, customFn, model.$keys.attributesToColumns.resolve.bind(model.$keys.attributesToColumns));
this.model = model;
this.client = client;
this.preloader = new Preloader(this.model);
this.debugQueries = this.client.debug;
this.clientOptions = {
client: this.client,
connection: this.client.connectionName,
};
/**
* Assign table when not already assigned
*/
if (!builder['_single'] || !builder['_single'].table) {
builder.table(model.table);
}
}
/**
* Executes the current query
*/
async execQuery() {
this.applyWhere();
const isWriteQuery = ['update', 'del', 'insert'].includes(this.knexQuery['_method']);
const queryData = Object.assign(this.getQueryData(), this.customReporterData);
const rows = await new QueryRunner(this.client, this.debugQueries, queryData).run(this.knexQuery);
/**
* Return the rows as it is when query is a write query
*/
if (isWriteQuery || !this.wrapResultsToModelInstances) {
return Array.isArray(rows) ? rows : [rows];
}
/**
* Convert fetched results to an array of model instances
*/
const modelInstances = rows.reduce((models, row) => {
if (isObject(row)) {
const modelInstance = this.model.$createFromAdapterResult(row, this.sideloaded, this.clientOptions);
/**
* Transform row when row transformer is defined
*/
if (this.rowTransformerCallback) {
this.rowTransformerCallback(modelInstance);
}
models.push(modelInstance);
}
return models;
}, []);
/**
* Preload for model instances
*/
await this.preloader
.sideload(this.sideloaded)
.debug(this.debugQueries)
.processAllForMany(modelInstances, this.client);
return modelInstances;
}
/**
* Ensures that we are not executing `update` or `del` when using read only
* client
*/
ensureCanPerformWrites() {
if (this.client && this.client.mode === 'read') {
throw new Exception('Updates and deletes cannot be performed in read mode');
}
}
/**
* Defines sub query for checking the existence of a relationship
*/
addWhereHas(relationName, boolean, operator, value, callback) {
let rawMethod = 'whereRaw';
let existsMethod = 'whereExists';
switch (boolean) {
case 'or':
rawMethod = 'orWhereRaw';
existsMethod = 'orWhereExists';
break;
case 'not':
existsMethod = 'whereNotExists';
break;
case 'orNot':
rawMethod = 'orWhereRaw';
existsMethod = 'orWhereNotExists';
break;
}
const subQuery = this.getRelationship(relationName).subQuery(this.client);
subQuery.selfJoinCounter = this.joinCounter;
/**
* Invoke callback when defined
*/
if (typeof callback === 'function') {
callback(subQuery);
}
/**
* Count all when value and operator are defined.
*/
if (value !== undefined && operator !== undefined) {
/**
* If user callback has not defined any aggregates, then we should
* add a count
*/
if (!subQuery.hasAggregates) {
subQuery.count('*');
}
/**
* Pull sql and bindings from the query
*/
const { sql, bindings } = subQuery.prepare().toSQL();
/**
* Define where raw clause. Query builder doesn't have any "whereNotRaw" method
* and hence we need to prepend the `NOT` keyword manually
*/
boolean === 'orNot' || boolean === 'not'
? this[rawMethod](`not (${sql}) ${operator} (?)`, bindings.concat([value]))
: this[rawMethod](`(${sql}) ${operator} (?)`, bindings.concat([value]));
return this;
}
/**
* Use where exists when no operator and value is defined
*/
;
this[existsMethod](subQuery.prepare());
return this;
}
/**
* Returns the profiler action. Protected, since the class is extended
* by relationships
*/
getQueryData() {
return {
connection: this.client.connectionName,
inTransaction: this.client.isTransaction,
model: this.model.name,
};
}
/**
* Returns the relationship instance from the model. An exception is
* raised when relationship is missing
*/
getRelationship(name) {
const relation = this.model.$getRelation(name);
/**
* Ensure relationship exists
*/
if (!relation) {
throw new errors.E_UNDEFINED_RELATIONSHIP([name, this.model.name]);
}
relation.boot();
return relation;
}
/**
* Define custom reporter data. It will be merged with
* the existing data
*/
reporterData(data) {
this.customReporterData = data;
return this;
}
/**
* Define a custom callback to transform rows
*/
rowTransformer(callback) {
this.rowTransformerCallback = callback;
return this;
}
/**
* Clone the current query builder
*/
clone() {
const clonedQuery = new ModelQueryBuilder(this.knexQuery.clone(), this.model, this.client);
this.applyQueryFlags(clonedQuery);
clonedQuery.usePreloader(this.preloader.clone());
clonedQuery.sideloaded = Object.assign({}, this.sideloaded);
clonedQuery.debug(this.debugQueries);
clonedQuery.reporterData(this.customReporterData);
this.rowTransformerCallback && this.rowTransformer(this.rowTransformerCallback);
return clonedQuery;
}
/**
* Define returning columns
*/
returning(columns) {
if (this.client.dialect.supportsReturningStatement) {
columns = Array.isArray(columns)
? columns.map((column) => this.resolveKey(column))
: this.resolveKey(columns);
this.knexQuery.returning(columns);
}
return this;
}
/**
* Define a query to constraint to be defined when condition is truthy
*/
ifDialect(dialects, matchCallback, noMatchCallback) {
dialects = Array.isArray(dialects) ? dialects : [dialects];
if (dialects.includes(this.client.dialect.name)) {
matchCallback(this);
}
else if (noMatchCallback) {
noMatchCallback(this);
}
return this;
}
/**
* Define a query to constraint to be defined when condition is falsy
*/
unlessDialect(dialects, matchCallback, noMatchCallback) {
dialects = Array.isArray(dialects) ? dialects : [dialects];
if (!dialects.includes(this.client.dialect.name)) {
matchCallback(this);
}
else if (noMatchCallback) {
noMatchCallback(this);
}
return this;
}
/**
* Applies the query scopes on the current query builder
* instance
*/
withScopes(callback) {
this.scopesWrapper = this.scopesWrapper || new ModelScopes(this);
callback(this.scopesWrapper);
return this;
}
/**
* Applies the query scopes on the current query builder
* instance
*/
apply(callback) {
return this.withScopes(callback);
}
/**
* Define a custom preloader instance for preloading relationships
*/
usePreloader(preloader) {
this.preloader = preloader;
return this;
}
/**
* Set side-loaded properties to be passed to the model instance
*/
sideload(value, merge = false) {
if (merge) {
Object.assign(this.sideloaded, value);
}
else {
this.sideloaded = value;
}
return this;
}
/**
* Fetch and return first results from the results set. This method
* will implicitly set a `limit` on the query
*/
async first() {
const isFetchCall = this.wrapResultsToModelInstances && this.knexQuery['_method'] === 'select';
if (isFetchCall) {
await this.model.$hooks.runner('before:find').run(this);
}
const result = await this.limit(1).execQuery();
if (result[0] && isFetchCall) {
await this.model.$hooks.runner('after:find').run(result[0]);
}
return result[0] || null;
}
/**
* Fetch and return first results from the results set. This method
* will implicitly set a `limit` on the query
*/
async firstOrFail() {
const row = await this.first();
if (!row) {
throw new errors.E_ROW_NOT_FOUND(this.model);
}
return row;
}
/**
* Load aggregate value as a sub-query for a relationship
*/
withAggregate(relationName, userCallback) {
const subQuery = this.getRelationship(relationName).subQuery(this.client);
subQuery.selfJoinCounter = this.joinCounter;
/**
* Invoke user callback
*/
userCallback(subQuery);
/**
* Raise exception if the callback has not defined an aggregate
*/
if (!subQuery.hasAggregates) {
throw new Exception('"withAggregate" callback must use an aggregate function');
}
/**
* Select "*" when no custom selects are defined
*/
if (!this.columns.length) {
this.select(`${this.model.table}.*`);
}
/**
* Throw exception when no alias
*/
if (!subQuery.subQueryAlias) {
throw new Exception('"withAggregate" callback must define the alias for the aggregate query');
}
/**
* Count sub-query selection
*/
this.select(subQuery.prepare());
/**
* Bump the counter
*/
this.joinCounter++;
return this;
}
/**
* Get count of a relationship alongside the main query results
*/
withCount(relationName, userCallback) {
this.withAggregate(relationName, (subQuery) => {
if (typeof userCallback === 'function') {
userCallback(subQuery);
}
/**
* Count "*"
*/
if (!subQuery.hasAggregates) {
subQuery.count('*');
}
/**
* Define alias for the sub-query
*/
if (!subQuery.subQueryAlias) {
subQuery.as(`${relationName}_count`);
}
});
return this;
}
/**
* Add where constraint using the relationship
*/
whereHas(relationName, callback, operator, value) {
return this.addWhereHas(relationName, 'and', operator, value, callback);
}
/**
* Add or where constraint using the relationship
*/
orWhereHas(relationName, callback, operator, value) {
return this.addWhereHas(relationName, 'or', operator, value, callback);
}
/**
* Alias of [[whereHas]]
*/
andWhereHas(relationName, callback, operator, value) {
return this.addWhereHas(relationName, 'and', operator, value, callback);
}
/**
* Add where not constraint using the relationship
*/
whereDoesntHave(relationName, callback, operator, value) {
return this.addWhereHas(relationName, 'not', operator, value, callback);
}
/**
* Add or where not constraint using the relationship
*/
orWhereDoesntHave(relationName, callback, operator, value) {
return this.addWhereHas(relationName, 'orNot', operator, value, callback);
}
/**
* Alias of [[whereDoesntHave]]
*/
andWhereDoesntHave(relationName, callback, operator, value) {
return this.addWhereHas(relationName, 'not', operator, value, callback);
}
/**
* Add where constraint using the relationship
*/
has(relationName, operator, value) {
return this.addWhereHas(relationName, 'and', operator, value);
}
/**
* Add or where constraint using the relationship
*/
orHas(relationName, operator, value) {
return this.addWhereHas(relationName, 'or', operator, value);
}
/**
* Alias of [[has]]
*/
andHas(relationName, operator, value) {
return this.addWhereHas(relationName, 'and', operator, value);
}
/**
* Add where not constraint using the relationship
*/
doesntHave(relationName, operator, value) {
return this.addWhereHas(relationName, 'not', operator, value);
}
/**
* Add or where not constraint using the relationship
*/
orDoesntHave(relationName, operator, value) {
return this.addWhereHas(relationName, 'orNot', operator, value);
}
/**
* Alias of [[doesntHave]]
*/
andDoesntHave(relationName, operator, value) {
return this.addWhereHas(relationName, 'not', operator, value);
}
/**
* Define a relationship to be preloaded
*/
preload(relationName, userCallback) {
this.preloader.load(relationName, userCallback);
return this;
}
/**
* Define a relationship to preload, but only if they are not
* already preloaded
*/
preloadOnce(relationName) {
this.preloader.preloadOnce(relationName);
return this;
}
/**
* Perform update by incrementing value for a given column. Increments
* can be clubbed with `update` as well
*/
increment(column, counter) {
this.ensureCanPerformWrites();
this.knexQuery.increment(this.resolveKey(column, true), counter);
return this;
}
/**
* Perform update by decrementing value for a given column. Decrements
* can be clubbed with `update` as well
*/
decrement(column, counter) {
this.ensureCanPerformWrites();
this.knexQuery.decrement(this.resolveKey(column, true), counter);
return this;
}
update(column, value, returning) {
this.ensureCanPerformWrites();
if (value === undefined && returning === undefined) {
this.knexQuery.update(this.resolveKey(column, true));
}
else if (returning === undefined) {
this.knexQuery.update(this.resolveKey(column), value);
}
else {
this.knexQuery.update(this.resolveKey(column), value, returning);
}
return this;
}
/**
* Delete rows under the current query
*/
del() {
this.ensureCanPerformWrites();
this.knexQuery.del();
return this;
}
/**
* Alias for [[del]]
*/
delete() {
return this.del();
}
/**
* Turn on/off debugging for this query
*/
debug(debug) {
this.debugQueries = debug;
return this;
}
/**
* Define query timeout
*/
timeout(time, options) {
this.knexQuery['timeout'](time, options);
return this;
}
/**
* Returns SQL query as a string
*/
toQuery() {
this.applyWhere();
return this.knexQuery.toQuery();
}
/**
* @deprecated
* Do not use this method. Instead create a query with options.client
*
* ```ts
* Model.query({ client: trx })
* ```
*/
useTransaction(transaction) {
this.knexQuery.transacting(transaction.knexClient);
return this;
}
/**
* Executes the query
*/
async exec() {
const isFetchCall = this.wrapResultsToModelInstances && this.knexQuery['_method'] === 'select';
if (isFetchCall) {
await this.model.$hooks.runner('before:fetch').run(this);
}
const result = await this.execQuery();
if (isFetchCall) {
await this.model.$hooks.runner('after:fetch').run(result);
}
return result;
}
/**
* Paginate through rows inside a given table
*/
async paginate(page, perPage = 20) {
const isFetchCall = this.wrapResultsToModelInstances && this.knexQuery['_method'] === 'select';
/**
* Cast to number
*/
page = Number(page);
perPage = Number(perPage);
const countQuery = this.clone()
.clearOrder()
.clearLimit()
.clearOffset()
.clearSelect()
.count('* as total')
.pojo();
/**
* We pass both the counts query and the main query to the
* paginate hook
*/
if (isFetchCall) {
await this.model.$hooks.runner('before:paginate').run([countQuery, this]);
await this.model.$hooks.runner('before:fetch').run(this);
}
const aggregateResult = await countQuery.exec();
const total = this.hasGroupBy ? aggregateResult.length : aggregateResult[0].total;
const results = total > 0 ? await this.forPage(page, perPage).execQuery() : [];
/**
* Choose paginator
*/
const paginator = this.wrapResultsToModelInstances
? new ModelPaginator(total, perPage, page, ...results)
: new SimplePaginator(total, perPage, page, ...results);
paginator.namingStrategy = this.model.namingStrategy;
if (isFetchCall) {
await this.model.$hooks.runner('after:paginate').run(paginator);
await this.model.$hooks.runner('after:fetch').run(results);
}
return paginator;
}
/**
* Get sql representation of the query
*/
toSQL() {
this.applyWhere();
return this.knexQuery.toSQL();
}
/**
* Get rows back as a plain javascript object and not an array
* of model instances
*/
pojo() {
this.wrapResultsToModelInstances = false;
return this;
}
/**
* Implementation of `then` for the promise API
*/
then(resolve, reject) {
return this.exec().then(resolve, reject);
}
/**
* Implementation of `catch` for the promise API
*/
catch(reject) {
return this.exec().catch(reject);
}
/**
* Implementation of `finally` for the promise API
*/
finally(fulfilled) {
return this.exec().finally(fulfilled);
}
/**
* Required when Promises are extended
*/
get [Symbol.toStringTag]() {
return this.constructor.name;
}
}