UNPKG

@athenna/database

Version:

The Athenna database handler for SQL/NoSQL.

975 lines (974 loc) 32.2 kB
/** * @athenna/database * * (c) João Lenon <lenon@athenna.io> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import { Is, Options, Collection } from '@athenna/common'; import { QueryBuilder } from '#src/database/builders/QueryBuilder'; import { ModelGenerator } from '#src/models/factories/ModelGenerator'; import { UniqueValueException } from '#src/exceptions/UniqueValueException'; import { HasOneRelation } from '#src/models/relations/HasOne/HasOneRelation'; import { NotFoundDataException } from '#src/exceptions/NotFoundDataException'; import { HasManyRelation } from '#src/models/relations/HasMany/HasManyRelation'; import { NullableValueException } from '#src/exceptions/NullableValueException'; import { BelongsToRelation } from '#src/models/relations/BelongsTo/BelongsToRelation'; import { BelongsToManyRelation } from '#src/models/relations/BelongsToMany/BelongsToManyRelation'; import { HasOneThroughRelation } from '#src/models/relations/HasOneThrough/HasOneThroughRelation'; import { HasManyThroughRelation } from '#src/models/relations/HasManyThrough/HasManyThroughRelation'; export class ModelQueryBuilder extends QueryBuilder { constructor(model, driver) { super(driver, model.table()); this.isToSetAttributes = true; this.isToValidateUnique = true; this.isToValidateNullable = true; this.selectColumns = []; this.DELETED_AT_PROP = null; this.DELETED_AT_NAME = null; this.isSoftDelete = false; this.hasCustomSelect = false; this.Model = model; this.schema = model.schema(); this.generator = new ModelGenerator(this.Model, this.schema); this.primaryKeyName = this.schema.getMainPrimaryKeyName(); this.primaryKeyProperty = this.schema.getMainPrimaryKeyProperty(); const deletedAtColumn = this.schema.getDeletedAtColumn(); if (deletedAtColumn) { this.isSoftDelete = true; this.DELETED_AT_NAME = deletedAtColumn.name; this.DELETED_AT_PROP = deletedAtColumn.property; } this.selectColumns = this.schema.getAllColumnNames(); this.setPrimaryKey(this.primaryKeyName); } /** * Define a transaction to be used by the model query builder. */ setTransaction(trx) { return this.setDriver(trx.driver, this.Model.table()); } /** * Set a different driver to the model query builder. */ setDriver(driver, tableName) { super.setDriver(driver, tableName); return this; } /** * Calculate the average of a given column. */ async avg(column) { this.setInternalQueries(); const name = this.schema.getColumnNameByProperty(column); return super.avg(name); } /** * Calculate the average of a given column. */ async avgDistinct(column) { this.setInternalQueries(); const name = this.schema.getColumnNameByProperty(column); return super.avgDistinct(name); } /** * Get the max number of a given column. */ async max(column) { this.setInternalQueries(); const name = this.schema.getColumnNameByProperty(column); return super.max(name); } /** * Get the min number of a given column. */ async min(column) { this.setInternalQueries(); const name = this.schema.getColumnNameByProperty(column); return super.min(name); } /** * Sum all numbers of a given column. */ async sum(column) { this.setInternalQueries(); const name = this.schema.getColumnNameByProperty(column); return super.sum(name); } /** * Sum all numbers of a given column. */ async sumDistinct(column) { this.setInternalQueries(); const name = this.schema.getColumnNameByProperty(column); return super.sumDistinct(name); } /** * Increment a value of a given column. */ async increment(column) { this.setInternalQueries(); const name = this.schema.getColumnNameByProperty(column); return super.increment(name); } /** * Decrement a value of a given column. */ async decrement(column) { this.setInternalQueries(); const name = this.schema.getColumnNameByProperty(column); await super.decrement(name); } /** * Calculate the average of a given column using distinct. */ async count(column) { this.setInternalQueries(); if (!column) { return super.count(); } const name = this.schema.getColumnNameByProperty(column); return super.count(name); } /** * Calculate the average of a given column using distinct. */ async countDistinct(column) { this.setInternalQueries(); const name = this.schema.getColumnNameByProperty(column); return super.countDistinct(name); } async pluck(column) { this.setInternalQueries(); const columnName = this.schema.getColumnNameByProperty(column); return super.pluck(columnName); } async pluckMany(column) { this.setInternalQueries(); const columnName = this.schema.getColumnNameByProperty(column); return super.pluckMany(columnName); } /** * Find a value in database. */ async find() { this.setInternalQueries(); const data = await super.find(); this.resetCustomSelect(); if (this.hasCustomSelect) { return data; } return this.generator.generateOne(data); } /** * Find a value in database or throw exception if undefined. */ async findOrFail() { const data = await this.find(); if (!data) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore throw new NotFoundDataException(this.Model.connection()); } return data; } /** * Find a value in database or create a new one if it doesn't exist. */ async findOrCreate(data = {}) { const hasValue = await this.find(); if (hasValue) { return hasValue; } return this.create(data); } /** * Return a single data or, if no results are found, * execute the given closure. */ async findOr(closure) { const data = (await this.find()); if (!data) { return closure(); } return data; } /** * Find a value in database and return as boolean. */ async exists() { this.setInternalQueries(); return super.exists(); } /** * Find many values in database. */ async findMany() { this.setInternalQueries(); const data = await super.findMany(); this.resetCustomSelect(); if (this.hasCustomSelect) { return data; } return this.generator.generateMany(data); } /** * Find many values in database and return paginated. */ async paginate(page = { page: 0, limit: 10, resourceUrl: '/' }, limit = 10, resourceUrl = '/') { this.setInternalQueries(); const data = await super.paginate(page, limit, resourceUrl); this.resetCustomSelect(); if (this.hasCustomSelect) { return data; } data.data = await this.generator.generateMany(data.data); return data; } /** * Find many values in database and return * as a collection instance. */ async collection() { const models = await this.findMany(); return new Collection(models); } /** * Create a value in database. */ async create(data = {}, cleanPersist = true) { const created = await this.createMany([data], cleanPersist); return created[0]; } /** * Create many values in database. */ async createMany(data, cleanPersist = true) { data = await Promise.all(data.map(async (d) => { const date = new Date(); const createdAt = this.schema.getCreatedAtColumn(); const updatedAt = this.schema.getUpdatedAtColumn(); const deletedAt = this.schema.getDeletedAtColumn(); const attributes = this.isToSetAttributes ? this.Model.attributes() : {}; const parsed = this.schema.propertiesToColumnNames(d, { attributes, cleanPersist }); if (createdAt && parsed[createdAt.name] === undefined) { parsed[createdAt.name] = date; } if (updatedAt && parsed[updatedAt.name] === undefined) { parsed[updatedAt.name] = date; } if (deletedAt && parsed[deletedAt.name] === undefined) { parsed[deletedAt.name] = null; } this.validateNullable(parsed); await this.validateUnique(parsed); return parsed; })); const created = await super.createMany(data); return this.generator.generateMany(created); } /** * Create or update a value in database. */ async createOrUpdate(data, cleanPersist = true) { const hasValue = await this.find(); if (hasValue) { const pk = this.primaryKeyProperty; return this.where(pk, hasValue[pk]).update(data, cleanPersist); } return this.create(data, cleanPersist); } /** * Update a value in database. */ async update(data, cleanPersist = true) { this.setInternalQueries(); const date = new Date(); const updatedAt = this.schema.getUpdatedAtColumn(); const attributes = this.isToSetAttributes ? this.Model.attributes() : {}; const parsed = this.schema.propertiesToColumnNames(data, { attributes, cleanPersist }); if (updatedAt && parsed[updatedAt.name] === undefined) { parsed[updatedAt.name] = date; } await this.validateUnique(parsed, true); const updated = await super.update(parsed); if (Is.Array(updated)) { return this.generator.generateMany(updated); } return this.generator.generateOne(updated); } /** * Delete or soft delete a value in database. */ async delete(force = false) { this.setInternalQueries({ addSelect: false }); if (!this.DELETED_AT_NAME || force) { await super.delete(); return; } await this.update({ [this.DELETED_AT_PROP]: new Date() }); } /** * Restore one or multiple soft deleted models. */ async restore(data) { this.setInternalQueries({ addSoftDelete: false }); if (!this.DELETED_AT_PROP) { return; } const date = new Date(); const updatedAt = this.schema.getUpdatedAtColumn(); const attributes = this.isToSetAttributes ? this.Model.attributes() : {}; const parsed = this.schema.propertiesToColumnNames({ ...data, [this.DELETED_AT_PROP]: null }, { attributes }); if (updatedAt && parsed[updatedAt.name] === undefined) { parsed[updatedAt.name] = date; } const updated = await super.update(parsed); if (Is.Array(updated)) { return this.generator.generateMany(updated); } return this.generator.generateOne(updated); } /** * Retrieve only the values that are soft deleted in * database. */ onlyTrashed() { this.isSoftDelete = false; if (!this.DELETED_AT_PROP) { return this; } return this.whereNotNull(this.DELETED_AT_PROP); } /** * Retrieve active and soft deleted values from database. */ withTrashed() { this.isSoftDelete = false; return this; } /** * Enable/disable setting the default attributes properties * when creating/updating models. */ setAttributes(value) { this.isToSetAttributes = value; return this; } /** * Enable/disable the `isUnique` property validation of * models columns. */ uniqueValidation(value) { this.isToValidateUnique = value; return this; } /** * Enable/disable the `isNullable` property validation of * models columns. */ nullableValidation(value) { this.isToValidateNullable = value; return this; } /** * Eager load a relation in your query. */ with(relation, closure) { this.schema.includeRelation(relation, closure); return this; } /** * Only returns the data if the closure returns a result. */ whereHas(relation, closure) { const options = this.schema.includeWhereHasRelation(relation, closure); /** * Snapshot the full options object immediately at call time, before any * subsequent `with(sameRelation)` call can mutate the shared `options` * object (e.g. overwriting `closure` or `withClosure`). Because this * spread happens here — outside the Knex callback — the snapshot is * frozen regardless of what happens to `options` afterwards. */ const snapshot = { ...options }; super.whereExists(query => { switch (snapshot.type) { case 'hasOne': return HasOneRelation.whereHas(this.Model, query, snapshot); case 'hasMany': return HasManyRelation.whereHas(this.Model, query, snapshot); case 'hasOneThrough': return HasOneThroughRelation.whereHas(this.Model, query, snapshot); case 'hasManyThrough': return HasManyThroughRelation.whereHas(this.Model, query, snapshot); case 'belongsTo': return BelongsToRelation.whereHas(this.Model, query, snapshot); case 'belongsToMany': return BelongsToManyRelation.whereHas(this.Model, query, snapshot); } }); return this; } /** * Same as {@link ModelQueryBuilder.whereHas}, but joins the resulting * `EXISTS (...)` clause to the surrounding WHERE with `OR` instead of `AND`. * * Useful inside a grouped `where(qb => ...)` closure to build expressions * like `(directCol ILIKE x OR relation.col ILIKE x)` without resorting to * raw SQL. */ orWhereHas(relation, closure) { const options = this.schema.includeWhereHasRelation(relation, closure); /** * Snapshot the full options object immediately at call time, before any * subsequent `with(sameRelation)` call can mutate the shared `options` * object (e.g. overwriting `closure` or `withClosure`). Because this * spread happens here — outside the Knex callback — the snapshot is * frozen regardless of what happens to `options` afterwards. */ const snapshot = { ...options }; super.orWhereExists(query => { switch (snapshot.type) { case 'hasOne': return HasOneRelation.whereHas(this.Model, query, snapshot); case 'hasMany': return HasManyRelation.whereHas(this.Model, query, snapshot); case 'hasOneThrough': return HasOneThroughRelation.whereHas(this.Model, query, snapshot); case 'hasManyThrough': return HasManyThroughRelation.whereHas(this.Model, query, snapshot); case 'belongsTo': return BelongsToRelation.whereHas(this.Model, query, snapshot); case 'belongsToMany': return BelongsToManyRelation.whereHas(this.Model, query, snapshot); } }); return this; } /** * Build a grouped OR search across any mix of direct columns and * relation columns in a single `WHERE (...)` clause. * * Each entry in `fields` is either a direct column property (e.g. `name`) * or a `relation.column` path (e.g. `profile.bio`). The resulting SQL is a * single parenthesized group joined exclusively by `OR`. Passing a falsy * `term` short-circuits and the query is left untouched. * * @example * ```ts * User.query().search(['name', 'email', 'profile.bio'], 'john') * ``` */ search(fields, term) { if (!term) { return this; } const value = `%${term}%`; this.where(qb => { fields.forEach((field, i) => { const isRelation = field.includes('.'); if (isRelation) { const [relation, column] = field.split('.'); const relOp = i === 0 ? 'whereHas' : 'orWhereHas'; qb[relOp](relation, (q) => q.whereILike(column, value)); return; } const op = i === 0 ? 'whereILike' : 'orWhereILike'; qb[op](field, value); }); }); return this; } /** * Executes the given closure when the first argument is true. */ when(criteria, closure) { if (criteria) { closure(this, criteria); return this; } return this; } /** * Set the columns that should be selected on query. */ select(...columns) { const selectColumns = this.schema.getColumnNamesByProperties(columns); super.select(...selectColumns); this.hasCustomSelect = true; return this; } /** * Set the columns that should be selected on query raw. */ selectRaw(sql, bindings) { super.selectRaw(sql, bindings); this.hasCustomSelect = true; return this; } /** * Set a group by statement in your query. */ groupBy(...columns) { super.groupBy(...this.schema.getColumnNamesByProperties(columns)); return this; } /** * Set a having statement in your query. */ having(column, operation, value) { const name = this.schema.getColumnNameByProperty(column); super.having(name, operation, value); return this; } /** * Set a having in statement in your query. */ havingIn(column, values) { const name = this.schema.getColumnNameByProperty(column); super.havingIn(name, values); return this; } /** * Set a having not in statement in your query. */ havingNotIn(column, values) { const name = this.schema.getColumnNameByProperty(column); super.havingNotIn(name, values); return this; } /** * Set a having between statement in your query. */ havingBetween(column, values) { const name = this.schema.getColumnNameByProperty(column); super.havingBetween(name, values); return this; } /** * Set a having not between statement in your query. */ havingNotBetween(column, values) { const name = this.schema.getColumnNameByProperty(column); super.havingNotBetween(name, values); return this; } /** * Set a having null statement in your query. */ havingNull(column) { const name = this.schema.getColumnNameByProperty(column); super.havingNull(name); return this; } /** * Set a having not null statement in your query. */ havingNotNull(column) { const name = this.schema.getColumnNameByProperty(column); super.havingNotNull(name); return this; } /** * Set a orHaving statement in your query. */ orHaving(column, operation, value) { const name = this.schema.getColumnNameByProperty(column); super.orHaving(name, operation, value); return this; } /** * Set a orHaving not in statement in your query. */ orHavingNotIn(column, values) { const name = this.schema.getColumnNameByProperty(column); super.orHavingNotIn(name, values); return this; } /** * Set a orHaving between statement in your query. */ orHavingBetween(column, values) { const name = this.schema.getColumnNameByProperty(column); super.orHavingBetween(name, values); return this; } /** * Set a orHaving not between statement in your query. */ orHavingNotBetween(column, values) { const name = this.schema.getColumnNameByProperty(column); super.orHavingNotBetween(name, values); return this; } /** * Set a orHaving null statement in your query. */ orHavingNull(column) { const name = this.schema.getColumnNameByProperty(column); super.orHavingNull(name); return this; } /** * Set a orHaving not null statement in your query. */ orHavingNotNull(column) { const name = this.schema.getColumnNameByProperty(column); super.orHavingNotNull(name); return this; } /** * Set a where statement in your query. */ where(statement, operation, value) { if (Is.Function(statement)) { super.where(query => { statement(new ModelQueryBuilder(this.Model, query)); }); return this; } if (!Is.String(statement) && Is.Undefined(operation)) { const parsed = this.schema.propertiesToColumnNames(statement); super.where(parsed); return this; } const name = this.schema.getColumnNameByProperty(statement); super.where(name, operation, value); return this; } /** * Set a where not statement in your query. */ whereNot(statement, value) { if (Is.Function(statement)) { super.whereNot(query => { statement(new ModelQueryBuilder(this.Model, query)); }); return this; } if (!Is.String(statement) && Is.Undefined(value)) { const parsed = this.schema.propertiesToColumnNames(statement); super.whereNot(parsed); return this; } const name = this.schema.getColumnNameByProperty(statement); super.whereNot(name, value); return this; } /** * Set a where like statement in your query. */ whereLike(column, value) { const name = this.schema.getColumnNameByProperty(column); super.whereLike(name, value); return this; } /** * Set a where ILike statement in your query. */ whereILike(column, value) { const name = this.schema.getColumnNameByProperty(column); super.whereILike(name, value); return this; } /** * Set a where in statement in your query. */ whereIn(column, values) { const name = this.schema.getColumnNameByProperty(column); super.whereIn(name, values); return this; } /** * Set a where not in statement in your query. */ whereNotIn(column, values) { const name = this.schema.getColumnNameByProperty(column); super.whereNotIn(name, values); return this; } /** * Set a where between statement in your query. */ whereBetween(column, values) { const name = this.schema.getColumnNameByProperty(column); super.whereBetween(name, values); return this; } /** * Set a where not between statement in your query. */ whereNotBetween(column, values) { const name = this.schema.getColumnNameByProperty(column); super.whereNotBetween(name, values); return this; } /** * Set a where null statement in your query. */ whereNull(column) { const name = this.schema.getColumnNameByProperty(column); super.whereNull(name); return this; } /** * Set a where not null statement in your query. */ whereNotNull(column) { const name = this.schema.getColumnNameByProperty(column); super.whereNotNull(name); return this; } /** * Set a where json statement in your query. */ whereJson(column, operation, value) { const name = this.schema.getColumnNameByProperty(column); super.whereJson(name, operation, value); return this; } /** * Set a orWhere statement in your query. */ orWhere(statement, operation, value) { if (Is.Function(statement)) { super.orWhere(query => { statement(new ModelQueryBuilder(this.Model, query)); }); return this; } if (!Is.String(statement) && Is.Undefined(operation)) { const parsed = this.schema.propertiesToColumnNames(statement); super.orWhere(parsed); return this; } const name = this.schema.getColumnNameByProperty(statement); super.orWhere(name, operation, value); return this; } /** * Set a orWhere not statement in your query. */ orWhereNot(statement, value) { if (Is.Function(statement)) { super.orWhereNot(query => { statement(new ModelQueryBuilder(this.Model, query)); }); return this; } if (!Is.String(statement) && Is.Undefined(value)) { const parsed = this.schema.propertiesToColumnNames(statement); super.orWhereNot(parsed); return this; } const name = this.schema.getColumnNameByProperty(statement); super.orWhereNot(name, value); return this; } /** * Set a orWhere like statement in your query. */ orWhereLike(statement, value) { if (!Is.String(statement) && Is.Undefined(value)) { const parsed = this.schema.propertiesToColumnNames(statement); super.orWhereLike(parsed); return this; } const name = this.schema.getColumnNameByProperty(statement); super.orWhereLike(name, value); return this; } /** * Set a orWhere ILike statement in your query. */ orWhereILike(statement, value) { if (!Is.String(statement) && Is.Undefined(value)) { const parsed = this.schema.propertiesToColumnNames(statement); super.orWhereILike(parsed); return this; } const name = this.schema.getColumnNameByProperty(statement); super.orWhereILike(name, value); return this; } /** * Set a orWhere in statement in your query. */ orWhereIn(column, values) { const name = this.schema.getColumnNameByProperty(column); super.orWhereIn(name, values); return this; } /** * Set a orWhere not in statement in your query. */ orWhereNotIn(column, values) { const name = this.schema.getColumnNameByProperty(column); super.orWhereNotIn(name, values); return this; } /** * Set a orWhere between statement in your query. */ orWhereBetween(column, values) { const name = this.schema.getColumnNameByProperty(column); super.orWhereBetween(name, values); return this; } /** * Set a orWhere not between statement in your query. */ orWhereNotBetween(column, values) { const name = this.schema.getColumnNameByProperty(column); super.orWhereNotBetween(name, values); return this; } /** * Set a orWhere null statement in your query. */ orWhereNull(column) { const name = this.schema.getColumnNameByProperty(column); super.orWhereNull(name); return this; } /** * Set a orWhere not null statement in your query. */ orWhereNotNull(column) { const name = this.schema.getColumnNameByProperty(column); super.orWhereNotNull(name); return this; } /** * Set an orWhereJson statement in your query. */ orWhereJson(column, operation, value) { const name = this.schema.getColumnNameByProperty(column); super.orWhereJson(name, operation, value); return this; } /** * Set an order by statement in your query. */ orderBy(column, direction = 'ASC') { const name = this.schema.getColumnNameByProperty(column); super.orderBy(name, direction); return this; } /** * Order the results easily by the latest date. By default, the result will * be ordered by the table's "createdAt" column. */ latest(column) { if (!column) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore column = 'createdAt'; } const name = this.schema.getColumnNameByProperty(column); super.latest(name); return this; } /** * Order the results easily by the oldest date. By default, the result will * be ordered by the table's "createdAt" column. */ oldest(column) { if (!column) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore column = 'createdAt'; } const name = this.schema.getColumnNameByProperty(column); super.oldest(name); return this; } /** * Set the internal selected properties and soft delete * queries. */ setInternalQueries(options) { options = Options.create(options, { addSelect: true, addSoftDelete: true }); if (options.addSelect && !this.hasCustomSelect) { super.select(...this.selectColumns); } if (options.addSoftDelete) { super.when(this.isSoftDelete, query => query.whereNull(this.DELETED_AT_NAME)); } } /** * Reset select state after terminal custom select queries. */ resetCustomSelect() { if (!this.hasCustomSelect) { return; } this.hasCustomSelect = false; this.selectColumns = this.schema.getAllColumnNames(); } /** * Verify that columns with `isNullable` property * can be created in database. */ validateNullable(data) { if (!this.isToValidateNullable) { return; } const records = []; for (const column of this.schema.getAllNotNullableColumns()) { const value = data[column.name]; if (value === undefined || value === null) { records.push(column.property); } } if (!Is.Empty(records)) { throw new NullableValueException(records); } } /** * Verify that columns with isUnique property * can be created in database. */ async validateUnique(data, isUpdate = false) { if (!this.isToValidateUnique) { return; } const records = {}; for (const column of this.schema.getAllUniqueColumns()) { const value = data[column.name]; if (value === undefined) { continue; } if (isUpdate) { const data = await this.Model.query() .where(column.name, value) .findMany(); if (data.length > 1) { records[column.property] = value; continue; } } const isDuplicated = await this.Model.query() .where(column.name, value) .exists(); if (isDuplicated) { records[column.property] = value; } } if (!Is.Empty(records)) { throw new UniqueValueException(records); } } }