diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c38be3d..e065c86 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -8,6 +8,9 @@ on: jobs: build: runs-on: ubuntu-latest + permissions: + contents: write + id-token: write steps: - name: Checkout uses: actions/checkout@v2 @@ -32,8 +35,7 @@ jobs: id: release - name: Publish to NPM Registry - run: cd build && npm publish --access public + run: cd build && npm publish --provenance --access public if: steps.release.outputs.released == 'true' env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} name: Deploy diff --git a/package-lock.json b/package-lock.json index 1ac5ba5..d0fe35a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/database", - "version": "5.38.0", + "version": "5.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/database", - "version": "5.38.0", + "version": "5.39.0", "license": "MIT", "dependencies": { "@faker-js/faker": "^8.4.1" diff --git a/package.json b/package.json index 0dddb8d..993a096 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/database", - "version": "5.38.0", + "version": "5.39.0", "description": "The Athenna database handler for SQL/NoSQL.", "license": "MIT", "author": "João Lenon ", diff --git a/src/database/builders/QueryBuilder.ts b/src/database/builders/QueryBuilder.ts index e66a5ce..d592295 100644 --- a/src/database/builders/QueryBuilder.ts +++ b/src/database/builders/QueryBuilder.ts @@ -150,7 +150,7 @@ export class QueryBuilder< /** * Calculate the average of a given column using distinct. */ - public async count(column: string | ModelColumns = '*'): Promise { + public async count(column: string | ModelColumns = '*'): Promise { return this.driver.count(column as string) } @@ -159,7 +159,7 @@ export class QueryBuilder< */ public async countDistinct( column: string | ModelColumns - ): Promise { + ): Promise { return this.driver.countDistinct(column as string) } @@ -583,24 +583,6 @@ export class QueryBuilder< return this } - /** - * Set a having exists statement in your query. - */ - public havingExists(closure: (query: Driver) => void) { - this.driver.havingExists(closure) - - return this - } - - /** - * Set a having not exists statement in your query. - */ - public havingNotExists(closure: (query: Driver) => void) { - this.driver.havingNotExists(closure) - - return this - } - /** * Set a having in statement in your query. */ @@ -680,33 +662,6 @@ export class QueryBuilder< return this } - /** - * Set an or having exists statement in your query. - */ - public orHavingExists(closure: (query: Driver) => void) { - this.driver.orHavingExists(closure) - - return this - } - - /** - * Set an or having not exists statement in your query. - */ - public orHavingNotExists(closure: (query: Driver) => void) { - this.driver.orHavingNotExists(closure) - - return this - } - - /** - * Set an or having in statement in your query. - */ - public orHavingIn(column: string | ModelColumns, values: any[]) { - this.driver.orHavingIn(column as string, values) - - return this - } - /** * Set an or having not in statement in your query. */ @@ -887,6 +842,27 @@ export class QueryBuilder< return this } + public whereJson( + column: string | ModelColumns, + operation: any, + value?: any + ): this + + public whereJson(column: string | ModelColumns, value: any): this + + /** + * Set a where json statement in your query. + */ + public whereJson( + column: string | ModelColumns, + operation: any, + value?: any + ) { + this.driver.whereJson(column as string, operation, value) + + return this + } + public orWhere(statement: (query: this) => void): this public orWhere(statement: Partial): this public orWhere(statement: Record): this @@ -1030,6 +1006,27 @@ export class QueryBuilder< return this } + public orWhereJson( + column: string | ModelColumns, + operation: Operations, + value?: any + ): this + + public orWhereJson(column: string | ModelColumns, value: any): this + + /** + * Set an orWhereJson statement in your query. + */ + public orWhereJson( + column: string | ModelColumns, + operation: Operations, + value?: any + ) { + this.driver.orWhereJson(column as string, operation, value) + + return this + } + /** * Set an order by statement in your query. */ diff --git a/src/database/drivers/BaseKnexDriver.ts b/src/database/drivers/BaseKnexDriver.ts new file mode 100644 index 0000000..64cbe77 --- /dev/null +++ b/src/database/drivers/BaseKnexDriver.ts @@ -0,0 +1,1710 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { + Exec, + Is, + Json, + Options, + type PaginatedResponse, + type PaginationOptions +} from '@athenna/common' + +import type { Knex } from 'knex' +import { debug } from '#src/debug' +import { Log } from '@athenna/logger' +import { Driver } from '#src/database/drivers/Driver' +import { ConnectionFactory } from '#src/factories/ConnectionFactory' +import { Transaction } from '#src/database/transactions/Transaction' +import type { ConnectionOptions, Direction, Operations } from '#src/types' +import { MigrationSource } from '#src/database/migrations/MigrationSource' +import { EmptyValueException } from '#src/exceptions/EmptyValueException' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' +import { WrongMethodException } from '#src/exceptions/WrongMethodException' +import { PROTECTED_QUERY_METHODS } from '#src/constants/ProtectedQueryMethods' +import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException' + +export class BaseKnexDriver extends Driver { + /** + * Connect to database. + */ + public connect(options: ConnectionOptions = {}): void { + options = Options.create(options, { + force: false, + saveOnFactory: true, + connect: true + }) + + if (!options.connect) { + return + } + + if (this.isConnected && !options.force) { + return + } + + const knex = this.getKnex() + const configs = Config.get(`database.connections.${this.connection}`, {}) + const knexOpts = { + migrations: { + tableName: 'migrations' + }, + pool: { + min: 2, + max: 20, + acquireTimeoutMillis: 60 * 1000 + }, + debug: false, + useNullAsDefault: false, + ...Json.omit(configs, ['driver', 'validations']) + } + + debug('creating new connection using Knex. options defined: %o', knexOpts) + + if (Config.is('rc.bootLogs', true)) { + Log.channelOrVanilla('application').success( + `Successfully connected to ({yellow} ${this.connection}) database connection` + ) + } + + this.client = knex.default(knexOpts) + + this.isConnected = true + this.isSavedOnFactory = options.saveOnFactory + + if (this.isSavedOnFactory) { + ConnectionFactory.setClient(this.connection, this.client) + } + + this.qb = this.query() + } + + /** + * Close the connection with database in this instance. + */ + public async close(): Promise { + if (!this.isConnected) { + return + } + + await this.client.destroy() + + this.qb = null + this.tableName = null + this.client = null + this.isConnected = false + + ConnectionFactory.setClient(this.connection, null) + } + + /** + * Creates a new instance of query builder. + */ + public query(): Knex.QueryBuilder { + if (!this.isConnected) { + throw new NotConnectedDatabaseException() + } + + const query = this.useSetQB + ? this.qb.table(this.tableName) + : this.client.queryBuilder().table(this.tableName) + + const handler = { + get: (target: Knex.QueryBuilder, propertyKey: string) => { + if (PROTECTED_QUERY_METHODS.includes(propertyKey)) { + this.qb = this.query() + } + + return target[propertyKey] + } + } + + return new Proxy(query, handler) + } + + /** + * Sync a model schema with database. + */ + public async sync(): Promise { + debug( + `database sync with ${this.constructor.name} is not available yet, use migration instead.` + ) + } + + /** + * Create a new transaction. + */ + + public async startTransaction(): Promise< + Transaction + > { + const trx = await this.client.transaction() + + return new Transaction(this.clone().setClient(trx)) + } + + /** + * Commit the transaction. + */ + public async commitTransaction(): Promise { + const client = this.client as Knex.Transaction + + await client.commit() + + this.tableName = null + this.client = null + this.isConnected = false + } + + /** + * Rollback the transaction. + */ + public async rollbackTransaction(): Promise { + const client = this.client as Knex.Transaction + + await client.rollback() + + this.tableName = null + this.client = null + this.isConnected = false + } + + /** + * Run database migrations. + */ + public async runMigrations(): Promise { + await this.client.migrate.latest({ + migrationSource: new MigrationSource(this.connection) + }) + } + + /** + * Revert database migrations. + */ + public async revertMigrations(): Promise { + await this.client.migrate.rollback({ + migrationSource: new MigrationSource(this.connection) + }) + } + + /** + * List all databases available. + */ + public async getDatabases(): Promise { + const [databases] = await this.raw('SHOW DATABASES') + + return databases.map(database => database.Database) + } + + /** + * Get the current database name. + */ + public async getCurrentDatabase(): Promise { + return this.client.client.database() + } + + /** + * Verify if database exists. + */ + public async hasDatabase(database: string): Promise { + const databases = await this.getDatabases() + + return databases.includes(database) + } + + /** + * Create a new database. + */ + public async createDatabase(database: string): Promise { + await this.raw('CREATE DATABASE IF NOT EXISTS ??', database) + } + + /** + * Drop some database. + */ + public async dropDatabase(database: string): Promise { + await this.raw('DROP DATABASE IF EXISTS ??', database) + } + + /** + * List all tables available. + */ + public async getTables(): Promise { + const [tables] = await this.raw( + 'SELECT table_name FROM information_schema.tables WHERE table_schema = ?', + await this.getCurrentDatabase() + ) + + return tables.map(table => table.TABLE_NAME) + } + + /** + * Verify if table exists. + */ + public async hasTable(table: string): Promise { + return this.client.schema.hasTable(table) + } + + /** + * Create a new table in database. + */ + public async createTable( + table: string, + closure: (builder: Knex.TableBuilder) => void | Promise + ): Promise { + await this.client.schema.createTable(table, closure) + } + + /** + * Alter a table in database. + */ + public async alterTable( + table: string, + closure: (builder: Knex.TableBuilder) => void | Promise + ): Promise { + await this.client.schema.alterTable(table, closure) + } + + /** + * Drop a table in database. + */ + public async dropTable(table: string): Promise { + await this.client.schema.dropTableIfExists(table) + } + + /** + * Remove all data inside some database table + * and restart the identity of the table. + */ + public async truncate(table: string): Promise { + try { + await this.raw('SET FOREIGN_KEY_CHECKS = 0') + await this.raw('TRUNCATE TABLE ??', table) + } finally { + await this.raw('SET FOREIGN_KEY_CHECKS = 1') + } + } + + /** + * Make a raw query in database. + */ + public raw(sql: string, bindings?: any): T { + return this.client.raw(sql, bindings) as any + } + + /** + * Calculate the average of a given column. + */ + public async avg(column: string): Promise { + const [{ avg }] = await this.qb.avg({ avg: column }) + + return avg + } + + /** + * Calculate the average of a given column using distinct. + */ + public async avgDistinct(column: string): Promise { + const [{ avg }] = await this.qb.avgDistinct({ avg: column }) + + return avg + } + + /** + * Get the max number of a given column. + */ + public async max(column: string): Promise { + const [{ max }] = await this.qb.max({ max: column }) + + return max + } + + /** + * Get the min number of a given column. + */ + public async min(column: string): Promise { + const [{ min }] = await this.qb.min({ min: column }) + + return min + } + + /** + * Sum all numbers of a given column. + */ + public async sum(column: string): Promise { + const [{ sum }] = await this.qb.sum({ sum: column }) + + return sum + } + + /** + * Sum all numbers of a given column in distinct mode. + */ + public async sumDistinct(column: string): Promise { + const [{ sum }] = await this.qb.sumDistinct({ sum: column }) + + return sum + } + + /** + * Increment a value of a given column. + */ + public async increment(column: string): Promise { + await this.qb.increment(column) + } + + /** + * Decrement a value of a given column. + */ + public async decrement(column: string): Promise { + await this.qb.decrement(column) + } + + /** + * Calculate the average of a given column using distinct. + */ + public async count(column: string = '*'): Promise { + const [{ count }] = await this.qb.count({ count: column }) + + return Number(count) + } + + /** + * Calculate the average of a given column using distinct. + */ + public async countDistinct(column: string): Promise { + const [{ count }] = await this.qb.countDistinct({ count: column }) + + return Number(count) + } + + /** + * Find a value in database. + */ + public async find(): Promise { + return this.qb.first() + } + + /** + * Find many values in database. + */ + public async findMany(): Promise { + const data = await this.qb + + this.qb = this.query() + + return data + } + + /** + * Find many values in database and return as paginated response. + */ + public async paginate( + page: PaginationOptions | number = { page: 0, limit: 10, resourceUrl: '/' }, + limit = 10, + resourceUrl = '/' + ): Promise> { + if (Is.Number(page)) { + page = { page, limit, resourceUrl } + } + + const [{ count }] = await this.qb + .clone() + .clearOrder() + .clearSelect() + .count({ count: '*' }) + + const data = await this.offset(page.page * page.limit) + .limit(page.limit) + .findMany() + + return Exec.pagination(data, Number(count), page) + } + + /** + * Create a value in database. + */ + public async create(data: Partial = {}): Promise { + if (Is.Array(data)) { + throw new WrongMethodException('create', 'createMany') + } + + const created = await this.createMany([data]) + + return created[0] + } + + /** + * Create many values in database. + */ + public async createMany(data: Partial[] = []): Promise { + if (!Is.Array(data)) { + throw new WrongMethodException('createMany', 'create') + } + + const preparedData = data.map(data => this.prepareInsert(data)) + const ids = [] + + const promises = preparedData.map((prepared, index) => { + return this.qb + .clone() + .insert(prepared) + .then(([id]) => ids.push(data[index][this.primaryKey] || id)) + }) + + await Promise.all(promises) + + return this.whereIn(this.primaryKey, ids).findMany() + } + + /** + * Create data or update if already exists. + */ + public async createOrUpdate(data: Partial): Promise { + const query = this.qb.clone() + const hasValue = await query.first() + const preparedData = this.prepareInsert(data) + + if (hasValue) { + await this.qb + .where(this.primaryKey, hasValue[this.primaryKey]) + .limit(1) + .update(preparedData) + + return this.where(this.primaryKey, hasValue[this.primaryKey]).find() + } + + return this.create(data) + } + + /** + * Update a value in database. + */ + public async update(data: Partial): Promise { + const preparedData = this.prepareInsert(data) + + await this.qb.clone().update(preparedData) + + const result = await this.findMany() + + if (result.length === 1) { + return result[0] + } + + return result + } + + /** + * Stringify object-like values before persisting with Knex. + */ + protected prepareInsert(data: Partial) { + return Object.entries(data).reduce((prepared, [key, value]) => { + if (!this.shouldStringifyJsonValue(value)) { + prepared[key] = value + + return prepared + } + + prepared[key] = JSON.stringify(value) + + return prepared + }, {} as Partial) + } + + /** + * Verify if a value should be serialized before persisting. + */ + private shouldStringifyJsonValue(value: any) { + return !!value && (Is.Array(value) || Is.Object(value)) + } + + /** + * Delete one value in database. + */ + public async delete(): Promise { + await this.qb.delete() + } + + /** + * Set the table that this query will be executed. + */ + public table(table: string) { + if (!this.isConnected) { + throw new NotConnectedDatabaseException() + } + + if (!Is.String(table)) { + throw new Error('Table must be a string value') + } + + this.tableName = table + this.qb = this.query() + + return this + } + + /** + * Log in console the actual query built. + */ + public dump() { + process.stdout.write(`${JSON.stringify(this.qb.toSQL().toNative())}\n`) + + return this + } + + /** + * Set the columns that should be selected on query. + */ + public select(...columns: string[]) { + if (!Is.Array(columns)) { + throw new EmptyValueException('select') + } + + this.qb.select(...columns) + + return this + } + + /** + * Set the columns that should be selected on query raw. + */ + public selectRaw(sql: string, bindings?: any) { + if (Is.Undefined(sql)) { + throw new EmptyValueException('selectRaw') + } + + return this.select(this.raw(sql, bindings) as any) + } + + /** + * Set the table that should be used on query. + * Different from `table()` method, this method + * doesn't change the driver table. + */ + public from(table: string) { + if (Is.Undefined(table)) { + throw new Error('Table must be a string value') + } + + this.qb.from(table) + + return this + } + + /** + * Set the table that should be used on query raw. + * Different from `table()` method, this method + * doesn't change the driver table. + */ + public fromRaw(sql: string, bindings?: any) { + if (Is.Undefined(sql)) { + throw new EmptyValueException('fromRaw') + } + + return this.from(this.raw(sql, bindings) as any) + } + + /** + * Set a join statement in your query. + */ + public join( + table: any, + column1?: any, + operation?: any | Operations, + column2?: any + ) { + return this.joinByType('join', table, column1, operation, column2) + } + + /** + * Set a left join statement in your query. + */ + public leftJoin( + table: any, + column1?: any, + operation?: any | Operations, + column2?: any + ) { + return this.joinByType('leftJoin', table, column1, operation, column2) + } + + /** + * Set a right join statement in your query. + */ + public rightJoin( + table: any, + column1?: any, + operation?: any | Operations, + column2?: any + ) { + return this.joinByType('rightJoin', table, column1, operation, column2) + } + + /** + * Set a cross join statement in your query. + */ + public crossJoin( + table: any, + column1?: any, + operation?: any | Operations, + column2?: any + ) { + return this.joinByType('crossJoin', table, column1, operation, column2) + } + + /** + * Set a full outer join statement in your query. + */ + public fullOuterJoin( + table: any, + column1?: any, + operation?: any | Operations, + column2?: any + ) { + // TODO https://github.com/knex/knex/issues/3949 + return this.joinByType('leftJoin', table, column1, operation, column2) + } + + /** + * Set a left outer join statement in your query. + */ + public leftOuterJoin( + table: any, + column1?: any, + operation?: any | Operations, + column2?: any + ) { + return this.joinByType('leftOuterJoin', table, column1, operation, column2) + } + + /** + * Set a right outer join statement in your query. + */ + public rightOuterJoin( + table: any, + column1?: any, + operation?: any | Operations, + column2?: any + ) { + return this.joinByType('rightOuterJoin', table, column1, operation, column2) + } + + /** + * Set a join raw statement in your query. + */ + public joinRaw(sql: string, bindings?: any) { + if (Is.Undefined(sql)) { + throw new EmptyValueException('joinRaw') + } + + this.qb.joinRaw(sql, bindings) + + return this + } + + /** + * Set a group by statement in your query. + */ + public groupBy(...columns: string[]) { + if (Is.Undefined(columns)) { + throw new EmptyColumnException('groupBy') + } + + this.qb.groupBy(...columns) + + return this + } + + /** + * Set a group by raw statement in your query. + */ + public groupByRaw(sql: string, bindings?: any) { + if (Is.Undefined(sql)) { + throw new EmptyValueException('groupByRaw') + } + + this.qb.groupByRaw(sql, bindings) + + return this + } + + public having(column: string): this + public having(column: string, value: any): this + public having(column: string, operation: Operations, value: any): this + + /** + * Set a having statement in your query. + */ + public having(column: any, operation?: Operations, value?: any) { + if (Is.Undefined(operation)) { + if (Is.Undefined(column)) { + throw new EmptyColumnException('having') + } + + this.qb.having(column) + + return this + } + + if (Is.Undefined(value)) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('having') + } + + if (Is.Undefined(operation)) { + throw new EmptyValueException('having') + } + + this.qb.having(column, '=', operation) + + return this + } + + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('having') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('having') + } + + this.qb.having(column, operation, value) + + return this + } + + /** + * Set a having raw statement in your query. + */ + public havingRaw(sql: string, bindings?: any) { + if (Is.Undefined(sql)) { + throw new EmptyValueException('havingRaw') + } + + this.qb.havingRaw(sql, bindings) + + return this + } + + /** + * Set a having in statement in your query. + */ + public havingIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('havingIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('havingIn') + } + + this.qb.havingIn(column, values) + + return this + } + + /** + * Set a having not in statement in your query. + */ + public havingNotIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('havingNotIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('havingNotIn') + } + + this.qb.havingNotIn(column, values) + + return this + } + + /** + * Set a having between statement in your query. + */ + public havingBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('havingBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('havingBetween') + } + + this.qb.havingBetween(column, values) + + return this + } + + /** + * Set a having not between statement in your query. + */ + public havingNotBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('havingNotBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('havingNotBetween') + } + + this.qb.havingNotBetween(column, values) + + return this + } + + /** + * Set a having null statement in your query. + */ + public havingNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('havingNull') + } + + this.qb.havingNull(column) + + return this + } + + /** + * Set a having not null statement in your query. + */ + public havingNotNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('havingNotNull') + } + + this.qb.havingNotNull(column) + + return this + } + + public orHaving(column: string): this + public orHaving(column: string, value: any): this + public orHaving(column: string, operation: Operations, value: any): this + + /** + * Set an or having statement in your query. + */ + public orHaving(column: any, operation?: Operations, value?: any) { + if (Is.Undefined(operation)) { + if (Is.Undefined(column)) { + throw new EmptyColumnException('orHaving') + } + + this.qb.orHaving(column) + + return this + } + + if (Is.Undefined(value)) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orHaving') + } + + if (Is.Undefined(operation)) { + throw new EmptyValueException('orHaving') + } + + this.qb.orHaving(column, '=', operation) + + return this + } + + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orHaving') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('orHaving') + } + + this.qb.orHaving(column, operation, value) + + return this + } + + /** + * Set an or having raw statement in your query. + */ + public orHavingRaw(sql: string, bindings?: any) { + if (Is.Undefined(sql) || !Is.String(sql)) { + throw new EmptyValueException('orHavingRaw') + } + + this.qb.orHavingRaw(sql, bindings) + + return this + } + + /** + * Set an or having not in statement in your query. + */ + public orHavingNotIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orHavingNotIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('orHavingNotIn') + } + + this.qb.orHavingNotIn(column, values) + + return this + } + + /** + * Set an or having between statement in your query. + */ + public orHavingBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orHavingBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('orHavingBetween') + } + + this.qb.orHavingBetween(column, values) + + return this + } + + /** + * Set an or having not between statement in your query. + */ + public orHavingNotBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orHavingNotBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('orHavingNotBetween') + } + + this.qb.orHavingNotBetween(column, values) + + return this + } + + /** + * Set an or having null statement in your query. + */ + public orHavingNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orHavingNull') + } + + this.qb.orHavingNull(column) + + return this + } + + /** + * Set an or having not null statement in your query. + */ + public orHavingNotNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orHavingNotNull') + } + + this.qb.orHavingNotNull(column) + + return this + } + + public where(statement: Record): this + public where(key: string, value: any): this + public where(key: string, operation: Operations, value: any): this + + /** + * Set a where statement in your query. + */ + public where(statement: any, operation?: Operations, value?: any) { + if (Is.Function(statement)) { + const driver = this.clone() + + this.qb.where(function () { + statement(driver.setQueryBuilder(this, { useSetQB: true })) + }) + + return this + } + + if (Is.Undefined(operation)) { + if (Is.Undefined(statement) || !Is.Object(statement)) { + throw new EmptyValueException('where') + } + + this.qb.where(statement) + + return this + } + + if (Is.Undefined(value)) { + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyValueException('where') + } + + if (this.isUsingJsonSelector(statement)) { + this.whereJson(statement, operation) + + return this + } + + this.qb.where(statement, operation) + + return this + } + + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyColumnException('where') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('where') + } + + if (this.isUsingJsonSelector(statement)) { + this.whereJson(statement, operation, value) + + return this + } + + this.qb.where(statement, operation, value) + + return this + } + + public whereNot(statement: Record): this + public whereNot(key: string, value: any): this + + /** + * Set a where not statement in your query. + */ + public whereNot(statement: any, value?: any) { + if (Is.Function(statement)) { + const driver = this.clone() + + this.qb.whereNot(function () { + statement(driver.setQueryBuilder(this, { useSetQB: true })) + }) + + return this + } + + if (Is.Undefined(value)) { + if (Is.Undefined(statement) || !Is.Object(statement)) { + throw new EmptyValueException('whereNot') + } + + this.qb.whereNot(statement) + + return this + } + + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyColumnException('whereNot') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('whereNot') + } + + this.qb.whereNot(statement, value) + + return this + } + + /** + * Set a where raw statement in your query. + */ + public whereRaw(sql: string, bindings?: any) { + if (Is.Undefined(sql) || !Is.String(sql)) { + throw new EmptyValueException('whereRaw') + } + + this.qb.whereRaw(sql, bindings) + + return this + } + + /** + * Set a where exists statement in your query. + */ + public whereExists(closure: (query: BaseKnexDriver) => void) { + const driver = this.clone() as BaseKnexDriver + + this.qb.whereExists(function () { + closure(driver.setQueryBuilder(this, { useSetQB: true })) + }) + + return this + } + + /** + * Set a where not exists statement in your query. + */ + public whereNotExists(closure: (query: BaseKnexDriver) => void) { + const driver = this.clone() as BaseKnexDriver + + this.qb.whereNotExists(function () { + closure(driver.setQueryBuilder(this, { useSetQB: true })) + }) + + return this + } + + /** + * Set a where like statement in your query. + */ + public whereLike(column: string, value: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereLike') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('whereLike') + } + + this.qb.whereLike(column, value) + + return this + } + + /** + * Set a where ILike statement in your query. + */ + public whereILike(column: string, value: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereILike') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('whereILike') + } + + this.qb.whereILike(column, value) + + return this + } + + /** + * Set a where in statement in your query. + */ + public whereIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('whereIn') + } + + this.qb.whereIn(column, values) + + return this + } + + /** + * Set a where not in statement in your query. + */ + public whereNotIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereNotIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('whereNotIn') + } + + this.qb.whereNotIn(column, values) + + return this + } + + /** + * Set a where between statement in your query. + */ + public whereBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('whereBetween') + } + + this.qb.whereBetween(column, values) + + return this + } + + /** + * Set a where not between statement in your query. + */ + public whereNotBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereNotBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('whereNotBetween') + } + + this.qb.whereNotBetween(column, values) + + return this + } + + /** + * Set a where null statement in your query. + */ + public whereNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereNull') + } + + this.qb.whereNull(column) + + return this + } + + /** + * Set a where not null statement in your query. + */ + public whereNotNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereNotNull') + } + + this.qb.whereNotNull(column) + + return this + } + + public whereJson(column: string, value: any): this + public whereJson(column: string, operation: Operations, value: any): this + + /** + * Set a where json statement in your query. + */ + public whereJson(column: string, operator: any, value?: any) { + const parsed = this.parseJsonSelector(column) + + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) + } + + const path = this.parseJsonSelectorToPath(parsed.path) + + if (Is.Undefined(value)) { + this.qb.whereJsonPath(parsed.column, path, '=', operator) + + return this + } + + this.qb.whereJsonPath(parsed.column, path, operator, value) + + return this + } + + public orWhere(statement: Record): this + public orWhere(key: string, value: any): this + public orWhere(key: string, operation: Operations, value: any): this + + /** + * Set a or where statement in your query. + */ + public orWhere(statement: any, operation?: Operations, value?: any) { + if (Is.Function(statement)) { + const driver = this.clone() + + this.qb.orWhere(function () { + statement(driver.setQueryBuilder(this, { useSetQB: true })) + }) + + return this + } + + if (Is.Undefined(operation)) { + if (Is.Undefined(statement) || !Is.Object(statement)) { + throw new EmptyValueException('orWhere') + } + + this.qb.orWhere(statement) + + return this + } + + if (Is.Undefined(value)) { + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyColumnException('orWhere') + } + + this.qb.orWhere(statement, operation) + + return this + } + + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyColumnException('orWhere') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('orWhere') + } + + this.qb.orWhere(statement, operation, value) + + return this + } + + public orWhereNot(statement: Record): this + public orWhereNot(key: string, value: any): this + + /** + * Set an or where not statement in your query. + */ + public orWhereNot(statement: any, value?: any) { + if (Is.Function(statement)) { + const driver = this.clone() + + this.qb.orWhereNot(function () { + statement(driver.setQueryBuilder(this, { useSetQB: true })) + }) + + return this + } + + if (Is.Undefined(value)) { + if (Is.Undefined(statement) || !Is.Object(statement)) { + throw new EmptyValueException('orWhereNot') + } + + this.qb.orWhereNot(statement) + + return this + } + + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyColumnException('orWhereNot') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('orWhereNot') + } + + this.qb.orWhereNot(statement, value) + + return this + } + + /** + * Set a or where raw statement in your query. + */ + public orWhereRaw(sql: string, bindings?: any) { + if (Is.Undefined(sql) || !Is.String(sql)) { + throw new EmptyValueException('orWhereRaw') + } + + this.qb.orWhereRaw(sql, bindings) + + return this + } + + /** + * Set an or where exists statement in your query. + */ + public orWhereExists(closure: (query: BaseKnexDriver) => void) { + const driver = this.clone() as BaseKnexDriver + + this.qb.orWhereExists(function () { + closure(driver.setQueryBuilder(this, { useSetQB: true })) + }) + + return this + } + + /** + * Set an or where not exists statement in your query. + */ + public orWhereNotExists(closure: (query: BaseKnexDriver) => void) { + const driver = this.clone() as BaseKnexDriver + + this.qb.orWhereNotExists(function () { + closure(driver.setQueryBuilder(this, { useSetQB: true })) + }) + + return this + } + + /** + * Set an or where like statement in your query. + */ + public orWhereLike(column: string, value: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereLike') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('orWhereLike') + } + + this.qb.orWhereLike(column, value) + + return this + } + + /** + * Set an or where ILike statement in your query. + */ + public orWhereILike(column: string, value: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereILike') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('orWhereILike') + } + + this.qb.orWhereILike(column, value) + + return this + } + + /** + * Set an or where in statement in your query. + */ + public orWhereIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('orWhereIn') + } + + this.qb.orWhereIn(column, values) + + return this + } + + /** + * Set an or where not in statement in your query. + */ + public orWhereNotIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereNotIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('orWhereNotIn') + } + + this.qb.orWhereNotIn(column, values) + + return this + } + + /** + * Set an or where between statement in your query. + */ + public orWhereBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('orWhereBetween') + } + + this.qb.orWhereBetween(column, values) + + return this + } + + /** + * Set an or where not between statement in your query. + */ + public orWhereNotBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereNotBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('orWhereNotBetween') + } + + this.qb.orWhereNotBetween(column, values) + + return this + } + + /** + * Set an or where null statement in your query. + */ + public orWhereNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereNull') + } + + this.qb.orWhereNull(column) + + return this + } + + /** + * Set an or where not null statement in your query. + */ + public orWhereNotNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereNotNull') + } + + this.qb.orWhereNotNull(column) + + return this + } + + public orWhereJson(column: string, value: any): this + public orWhereJson(column: string, operation: Operations, value: any): this + + /** + * Set an or where json statement in your query. + */ + public orWhereJson(column: string, operator: any, value?: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereJson') + } + + const parsed = this.parseJsonSelector(column) + + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) + } + + const path = this.parseJsonSelectorToPath(parsed.path) + + if (Is.Undefined(value)) { + this.qb.orWhereJsonPath(parsed.column, path, '=', operator) + + return this + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('orWhereJson') + } + + this.qb.orWhereJsonPath(parsed.column, path, operator, value) + + return this + } + + /** + * Convert a json selector path to a valid json path. + */ + private parseJsonSelectorToPath(path: string) { + const parts = path + .split('->') + .map(part => part.trim()) + .filter(Boolean) + + return parts.reduce((jsonPath, part) => { + if (part === '*') { + return `${jsonPath}[*]` + } + + if (/^\d+$/.test(part)) { + return `${jsonPath}[${part}]` + } + + return `${jsonPath}.${part}` + }, '$') + } + + /** + * Set an order by statement in your query. + */ + public orderBy(column: string, direction: Direction = 'ASC') { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orderBy') + } + + this.qb.orderBy(column, direction.toUpperCase()) + + return this + } + + /** + * Set an order by raw statement in your query. + */ + public orderByRaw(sql: string, bindings?: any) { + if (Is.Undefined(sql) || !Is.String(sql)) { + throw new EmptyValueException('orderByRaw') + } + + this.qb.orderByRaw(sql, bindings) + + return this + } + + /** + * Order the results easily by the latest date. By default, the result will + * be ordered by the table's "createdAt" column. + */ + public latest(column: string = 'createdAt') { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('latest') + } + + return this.orderBy(column, 'DESC') + } + + /** + * Order the results easily by the oldest date. By default, the result will + * be ordered by the table's "createdAt" column. + */ + public oldest(column: string = 'createdAt') { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('oldest') + } + + return this.orderBy(column, 'ASC') + } + + /** + * Set the skip number in your query. + */ + public offset(number: number) { + if (Is.Undefined(number) || !Is.Number(number)) { + throw new EmptyValueException('offset') + } + + this.qb.offset(number) + + return this + } + + /** + * Set the limit number in your query. + */ + public limit(number: number) { + if (Is.Undefined(number) || !Is.Number(number)) { + throw new EmptyValueException('limit') + } + + this.qb.limit(number) + + return this + } +} diff --git a/src/database/drivers/Driver.ts b/src/database/drivers/Driver.ts index 83e819c..edc9991 100644 --- a/src/database/drivers/Driver.ts +++ b/src/database/drivers/Driver.ts @@ -8,16 +8,20 @@ */ import { + Is, Module, Options, Collection, type PaginatedResponse, type PaginationOptions } from '@athenna/common' + import type { Knex, TableBuilder } from 'knex' import type { ModelSchema } from '#src/models/schemas/ModelSchema' import type { Transaction } from '#src/database/transactions/Transaction' +import { EmptyValueException } from '#src/exceptions/EmptyValueException' import type { Direction, ConnectionOptions, Operations } from '#src/types' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' import { NotFoundDataException } from '#src/exceptions/NotFoundDataException' export abstract class Driver { @@ -152,6 +156,10 @@ export abstract class Driver { * Set the primary key of the driver. */ public setPrimaryKey(primaryKey: string) { + if (!Is.String(primaryKey)) { + throw new Error('Primary key must be a string value') + } + this.primaryKey = primaryKey return this @@ -167,6 +175,10 @@ export abstract class Driver { operation?: string | Operations, column2?: string ) { + if (Is.Undefined(table)) { + throw new Error('Table is required for join methods') + } + if (!column1) { this.qb[joinType](table) @@ -174,22 +186,70 @@ export abstract class Driver { } if (!operation) { + if (Is.Undefined(column1)) { + throw new EmptyColumnException(joinType) + } + this.qb[joinType](table, column1) return this } if (!column2) { + if (Is.Undefined(column1) || !Is.String(column1)) { + throw new EmptyColumnException(joinType) + } + + if (Is.Undefined(operation) || !Is.String(operation)) { + throw new EmptyValueException(joinType) + } + this.qb[joinType](table, column1, operation) return this } + if (Is.Undefined(column1) || !Is.String(column1)) { + throw new EmptyColumnException(joinType) + } + + if (Is.Undefined(column2) || !Is.String(column2)) { + throw new EmptyColumnException(joinType) + } + this.qb[joinType](table, column1, operation, column2) return this } + /** + * Verify if a statement is using the json selector syntax. + */ + public isUsingJsonSelector(statement: string) { + return Is.String(statement) && statement.includes('->') + } + + /** + * Parse a statement using the json selector syntax. + */ + public parseJsonSelector(statement: string) { + if (!this.isUsingJsonSelector(statement)) { + return null + } + + const [column, ...pathParts] = statement.split('->') + const path = pathParts.join('->').trim() + + if (!column?.trim() || !path) { + return null + } + + return { + column: column.trim(), + path + } + } + /** * Connect to database. */ @@ -345,12 +405,12 @@ export abstract class Driver { /** * Calculate the average of a given column using distinct. */ - public abstract count(column?: string): Promise + public abstract count(column?: string): Promise /** * Calculate the average of a given column using distinct. */ - public abstract countDistinct(column?: string): Promise + public abstract countDistinct(column?: string): Promise /** * Find a value in database and return as boolean. @@ -464,7 +524,7 @@ export abstract class Driver { /** * Create data or update if already exists. */ - public abstract createOrUpdate(data?: Partial): Promise + public abstract createOrUpdate(data?: Partial): Promise /** * Update a value in database. @@ -625,20 +685,6 @@ export abstract class Driver { */ public abstract havingRaw(sql: string, bindings?: any): this - /** - * Set a having exists statement in your query. - */ - public abstract havingExists( - closure: (query: Driver) => void - ): this - - /** - * Set a having not exists statement in your query. - */ - public abstract havingNotExists( - closure: (query: Driver) => void - ): this - /** * Set a having in statement in your query. */ @@ -683,25 +729,6 @@ export abstract class Driver { */ public abstract orHavingRaw(sql: string, bindings?: any): this - /** - * Set an or having exists statement in your query. - */ - public abstract orHavingExists( - closure: (query: Driver) => void - ): this - - /** - * Set an or having not exists statement in your query. - */ - public abstract orHavingNotExists( - closure: (query: Driver) => void - ): this - - /** - * Set an or having in statement in your query. - */ - public abstract orHavingIn(column: string, values: any[]): this - /** * Set an or having not in statement in your query. */ @@ -800,6 +827,22 @@ export abstract class Driver { */ public abstract whereNotNull(column: string): this + public abstract whereJson(column: string, value: any): this + public abstract whereJson( + column: string, + operation: Operations, + value?: any + ): this + + /** + * Set a where json statement in your query. + */ + public abstract whereJson( + column: string, + operation: Operations, + value?: any + ): this + /** * Set a or where statement in your query. */ @@ -880,6 +923,22 @@ export abstract class Driver { */ public abstract orWhereNotNull(column: string): this + public abstract orWhereJson(column: string, value: any): this + public abstract orWhereJson( + column: string, + operation: Operations, + value?: any + ): this + + /** + * Set an orWhereJson statement in your query. + */ + public abstract orWhereJson( + column: string, + operation: Operations, + value?: any + ): this + /** * Set an order by statement in your query. */ diff --git a/src/database/drivers/FakeDriver.ts b/src/database/drivers/FakeDriver.ts index 93e6ec4..9fdb54c 100644 --- a/src/database/drivers/FakeDriver.ts +++ b/src/database/drivers/FakeDriver.ts @@ -317,15 +317,15 @@ export class FakeDriver { /** * Calculate the average of a given column using distinct. */ - public static async count(): Promise { - return '1' + public static async count(): Promise { + return 1 } /** * Calculate the average of a given column using distinct. */ - public static async countDistinct(): Promise { - return '1' + public static async countDistinct(): Promise { + return 1 } /** @@ -471,7 +471,7 @@ export class FakeDriver { */ public static async createOrUpdate( data: Partial = {} - ): Promise { + ): Promise { return data as T } @@ -635,20 +635,6 @@ export class FakeDriver { return this } - /** - * Set a having exists statement in your query. - */ - public static havingExists() { - return this - } - - /** - * Set a having not exists statement in your query. - */ - public static havingNotExists() { - return this - } - /** * Set a having in statement in your query. */ @@ -713,27 +699,6 @@ export class FakeDriver { return this } - /** - * Set an or having exists statement in your query. - */ - public static orHavingExists() { - return this - } - - /** - * Set an or having not exists statement in your query. - */ - public static orHavingNotExists() { - return this - } - - /** - * Set an or having in statement in your query. - */ - public static orHavingIn() { - return this - } - /** * Set an or having not in statement in your query. */ @@ -871,6 +836,20 @@ export class FakeDriver { return this } + public static whereJson(column: string, value: any): typeof FakeDriver + public static whereJson( + column: string, + operation: Operations, + value: any + ): typeof FakeDriver + + /** + * Set a where json statement in your query. + */ + public static whereJson() { + return this + } + public static orWhere(statement: Record): typeof FakeDriver public static orWhere(key: string, value: any): typeof FakeDriver public static orWhere( @@ -972,6 +951,20 @@ export class FakeDriver { return this } + public static orWhereJson(column: string, value: any): typeof FakeDriver + public static orWhereJson( + column: string, + operation: Operations, + value: any + ): typeof FakeDriver + + /** + * Set an or where json statement in your query. + */ + public static orWhereJson() { + return this + } + /** * Set an order by statement in your query. */ @@ -1015,4 +1008,16 @@ export class FakeDriver { public static limit() { return this } + + public static isUsingJsonSelector() { + return false + } + + public static parseJsonSelector() { + return null + } + + public static parseJsonSelectorToPath(path: string) { + return path + } } diff --git a/src/database/drivers/MongoDriver.ts b/src/database/drivers/MongoDriver.ts index e6eb40e..cbb5d85 100644 --- a/src/database/drivers/MongoDriver.ts +++ b/src/database/drivers/MongoDriver.ts @@ -24,7 +24,9 @@ import { ModelSchema } from '#src/models/schemas/ModelSchema' import { Transaction } from '#src/database/transactions/Transaction' import { ConnectionFactory } from '#src/factories/ConnectionFactory' import type { Connection, Collection, ClientSession } from 'mongoose' +import { EmptyValueException } from '#src/exceptions/EmptyValueException' import type { ConnectionOptions, Direction, Operations } from '#src/types' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' import { WrongMethodException } from '#src/exceptions/WrongMethodException' import { MONGO_OPERATIONS_DICTIONARY } from '#src/constants/MongoOperationsDictionary' import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException' @@ -549,7 +551,7 @@ export class MongoDriver extends Driver { /** * Calculate the average of a given column using distinct. */ - public async count(column: string = '*'): Promise { + public async count(column: string = '*'): Promise { await this.client.asPromise() const pipeline = this.createPipeline() @@ -565,13 +567,13 @@ export class MongoDriver extends Driver { .aggregate(pipeline, { session: this.session }) .toArray() - return `${result[0]?.count || 0}` + return Number(result[0]?.count || 0) } /** * Calculate the average of a given column using distinct. */ - public async countDistinct(column: string): Promise { + public async countDistinct(column: string): Promise { await this.client.asPromise() const pipeline = this.createPipeline() @@ -591,7 +593,7 @@ export class MongoDriver extends Driver { .aggregate(pipeline, { session: this.session }) .toArray() - return `${count}` + return Number(count) } /** @@ -699,9 +701,7 @@ export class MongoDriver extends Driver { /** * Create data or update if already exists. */ - public async createOrUpdate( - data: Partial = {} - ): Promise { + public async createOrUpdate(data: Partial = {}): Promise { await this.client.asPromise() const pipeline = this.createPipeline() @@ -711,7 +711,9 @@ export class MongoDriver extends Driver { )[0] if (hasValue) { - return this.where(this.primaryKey, hasValue[this.primaryKey]).update(data) + return this.where(this.primaryKey, hasValue[this.primaryKey]).update( + data + ) as Promise } return this.create(data) @@ -760,6 +762,10 @@ export class MongoDriver extends Driver { throw new NotConnectedDatabaseException() } + if (!Is.String(table)) { + throw new Error('Table must be a string value') + } + this.tableName = table this.qb = this.query() @@ -770,11 +776,13 @@ export class MongoDriver extends Driver { * Log in console the actual query built. */ public dump() { - console.log({ - where: this._where, - orWhere: this._orWhere, - pipeline: this.pipeline - }) + process.stdout.write( + `${JSON.stringify({ + where: this._where, + orWhere: this._orWhere, + pipeline: this.pipeline + })}\n` + ) return this } @@ -783,6 +791,10 @@ export class MongoDriver extends Driver { * Set the columns that should be selected on query. */ public select(...columns: string[]) { + if (!Is.Array(columns)) { + throw new EmptyValueException('select') + } + if (columns.includes('*')) { return this } @@ -971,6 +983,10 @@ export class MongoDriver extends Driver { * Set a group by statement in your query. */ public groupBy(...columns: string[]) { + if (Is.Undefined(columns) || !Is.Array(columns)) { + throw new EmptyColumnException('groupBy') + } + const $group = { [this.primaryKey]: {} } columns.forEach(column => ($group[this.primaryKey][column] = `$${column}`)) @@ -1006,20 +1022,6 @@ export class MongoDriver extends Driver { throw new NotImplementedMethodException(this.havingRaw.name, 'mongo') } - /** - * Set a having exists statement in your query. - */ - public havingExists(): this { - throw new NotImplementedMethodException(this.havingExists.name, 'mongo') - } - - /** - * Set a having not exists statement in your query. - */ - public havingNotExists(): this { - throw new NotImplementedMethodException(this.havingNotExists.name, 'mongo') - } - /** * Set a having in statement in your query. */ @@ -1080,30 +1082,6 @@ export class MongoDriver extends Driver { throw new NotImplementedMethodException(this.orHavingRaw.name, 'mongo') } - /** - * Set an or having exists statement in your query. - */ - public orHavingExists(): this { - throw new NotImplementedMethodException(this.orHavingExists.name, 'mongo') - } - - /** - * Set an or having not exists statement in your query. - */ - public orHavingNotExists(): this { - throw new NotImplementedMethodException( - this.orHavingNotExists.name, - 'mongo' - ) - } - - /** - * Set an or having in statement in your query. - */ - public orHavingIn(column: string, values: any[]) { - return this.orWhereIn(column, values) - } - /** * Set an or having not in statement in your query. */ @@ -1153,13 +1131,25 @@ export class MongoDriver extends Driver { return this } - if (operation === undefined) { + if (Is.Undefined(operation)) { + if (Is.Undefined(statement) || !Is.Object(statement)) { + throw new EmptyValueException('where') + } + this._where.push(statement) return this } - if (value === undefined) { + if (Is.Undefined(value)) { + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyValueException('where') + } + + if (this.isUsingJsonSelector(statement)) { + return this.whereJson(statement, operation) + } + this._where.push({ [statement]: this.setOperator(operation, '=') }) @@ -1167,6 +1157,18 @@ export class MongoDriver extends Driver { return this } + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyColumnException('where') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('where') + } + + if (this.isUsingJsonSelector(statement)) { + return this.whereJson(statement, operation, value) + } + this._where.push({ [statement]: this.setOperator(value, operation) }) return this @@ -1221,6 +1223,14 @@ export class MongoDriver extends Driver { * Set a where in statement in your query. */ public whereIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('whereIn') + } + values = values.flatMap(value => { if (ObjectId.isValidStringOrObject(value)) { return [String(value), new ObjectId(value)] @@ -1238,6 +1248,14 @@ export class MongoDriver extends Driver { * Set a where not in statement in your query. */ public whereNotIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereNotIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('whereNotIn') + } + values = values.flatMap(value => { if (ObjectId.isValidStringOrObject(value)) { return [String(value), new ObjectId(value)] @@ -1255,6 +1273,14 @@ export class MongoDriver extends Driver { * Set a where between statement in your query. */ public whereBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('whereBetween') + } + this._where.push({ [column]: { $gte: values[0], $lte: values[1] } }) return this @@ -1264,6 +1290,14 @@ export class MongoDriver extends Driver { * Set a where not between statement in your query. */ public whereNotBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereNotBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('whereNotBetween') + } + this._where.push({ [column]: { $not: { $gte: values[0], $lte: values[1] } } }) @@ -1275,6 +1309,10 @@ export class MongoDriver extends Driver { * Set a where null statement in your query. */ public whereNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereNull') + } + this._where.push({ [column]: null }) return this @@ -1284,11 +1322,49 @@ export class MongoDriver extends Driver { * Set a where not null statement in your query. */ public whereNotNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereNotNull') + } + this._where.push({ [column]: { $ne: null } }) return this } + public whereJson(column: string, value: any): this + public whereJson(column: string, operation: Operations, value: any): this + + /** + * Set a where json statement in your query. + */ + public whereJson(column: string, operation: any, value?: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereJson') + } + + const parsed = this.parseJsonSelector(column) + + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) + } + + const path = this.jsonSelectorToDotPath(parsed.path) + + if (value === undefined) { + this._where.push({ + [`${parsed.column}.${path}`]: this.setOperator(operation, '=') + }) + + return this + } + + this._where.push({ + [`${parsed.column}.${path}`]: this.setOperator(value, operation) + }) + + return this + } + public orWhere(statement: Record): this public orWhere(key: string, value: any): this public orWhere(key: string, operation: Operations, value: any): this @@ -1303,18 +1379,42 @@ export class MongoDriver extends Driver { return this } - if (operation === undefined) { + if (Is.Undefined(operation)) { + if (Is.Undefined(statement) || !Is.Object(statement)) { + throw new EmptyValueException('orWhere') + } + this._orWhere.push(statement) return this } - if (value === undefined) { + if (Is.Undefined(value)) { + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyColumnException('orWhere') + } + + if (this.isUsingJsonSelector(statement)) { + return this.orWhereJson(statement, operation) + } + this._orWhere.push({ [statement]: this.setOperator(operation, '=') }) return this } + if (Is.Undefined(statement) || !Is.String(statement)) { + throw new EmptyColumnException('orWhere') + } + + if (Is.Undefined(value)) { + throw new EmptyValueException('orWhere') + } + + if (this.isUsingJsonSelector(statement)) { + return this.orWhereJson(statement, operation, value) + } + this._orWhere.push({ [statement]: this.setOperator(value, operation) }) return this @@ -1330,6 +1430,40 @@ export class MongoDriver extends Driver { return this.orWhere(statement, '<>', value) } + public orWhereJson(column: string, value: any): this + public orWhereJson(column: string, operation: Operations, value: any): this + + /** + * Set an or where json statement in your query. + */ + public orWhereJson(column: string, operation: any, value?: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereJson') + } + + const parsed = this.parseJsonSelector(column) + + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) + } + + const path = this.jsonSelectorToDotPath(parsed.path) + + if (value === undefined) { + this._orWhere.push({ + [`${parsed.column}.${path}`]: this.setOperator(operation, '=') + }) + + return this + } + + this._orWhere.push({ + [`${parsed.column}.${path}`]: this.setOperator(value, operation) + }) + + return this + } + /** * Set a or where raw statement in your query. */ @@ -1369,6 +1503,14 @@ export class MongoDriver extends Driver { * Set an or where in statement in your query. */ public orWhereIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('orWhereIn') + } + values = values.flatMap(value => { if (ObjectId.isValidStringOrObject(value)) { return [String(value), new ObjectId(value)] @@ -1386,6 +1528,14 @@ export class MongoDriver extends Driver { * Set an or where not in statement in your query. */ public orWhereNotIn(column: string, values: any[]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereNotIn') + } + + if (Is.Undefined(values)) { + throw new EmptyValueException('orWhereNotIn') + } + values = values.flatMap(value => { if (ObjectId.isValidStringOrObject(value)) { return [String(value), new ObjectId(value)] @@ -1403,6 +1553,14 @@ export class MongoDriver extends Driver { * Set an or where between statement in your query. */ public orWhereBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('orWhereBetween') + } + this._orWhere.push({ [column]: { $gte: values[0], $lte: values[1] } }) return this @@ -1412,6 +1570,14 @@ export class MongoDriver extends Driver { * Set an or where not between statement in your query. */ public orWhereNotBetween(column: string, values: [any, any]) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereNotBetween') + } + + if (Is.Undefined(values?.[0]) || Is.Undefined(values?.[1])) { + throw new EmptyValueException('orWhereNotBetween') + } + this._orWhere.push({ [column]: { $not: { $gte: values[0], $lte: values[1] } } }) @@ -1423,6 +1589,10 @@ export class MongoDriver extends Driver { * Set an or where null statement in your query. */ public orWhereNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereNull') + } + this._orWhere.push({ [column]: null }) return this @@ -1432,6 +1602,10 @@ export class MongoDriver extends Driver { * Set an or where not null statement in your query. */ public orWhereNotNull(column: string) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereNotNull') + } + this._orWhere.push({ [column]: { $ne: null } }) return this @@ -1441,6 +1615,10 @@ export class MongoDriver extends Driver { * Set an order by statement in your query. */ public orderBy(column: string, direction: Direction = 'ASC') { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orderBy') + } + this.pipeline.push({ $sort: { [column]: direction.toLowerCase() === 'asc' ? 1 : -1 } }) @@ -1460,6 +1638,10 @@ export class MongoDriver extends Driver { * be ordered by the table's "createdAt" column. */ public latest(column: string = 'createdAt') { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('latest') + } + return this.orderBy(column, 'DESC') } @@ -1468,6 +1650,10 @@ export class MongoDriver extends Driver { * be ordered by the table's "createdAt" column. */ public oldest(column: string = 'createdAt') { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('oldest') + } + return this.orderBy(column, 'ASC') } @@ -1475,6 +1661,10 @@ export class MongoDriver extends Driver { * Set the skip number in your query. */ public offset(number: number) { + if (Is.Undefined(number) || !Is.Number(number)) { + throw new EmptyValueException('offset') + } + this.pipeline.push({ $skip: number }) return this @@ -1484,6 +1674,10 @@ export class MongoDriver extends Driver { * Set the limit number in your query. */ public limit(number: number) { + if (Is.Undefined(number) || !Is.Number(number)) { + throw new EmptyValueException('limit') + } + this.pipeline.push({ $limit: number }) return this @@ -1520,6 +1714,18 @@ export class MongoDriver extends Driver { return object } + /** + * Convert a json selector path to mongo dot notation. + */ + private jsonSelectorToDotPath(path: string) { + return path + .split('->') + .map(part => part.trim()) + .filter(Boolean) + .filter(part => part !== '*') + .join('.') + } + /** * Creates the where clause with where and orWhere. */ diff --git a/src/database/drivers/MySqlDriver.ts b/src/database/drivers/MySqlDriver.ts index eaf6faf..7de1111 100644 --- a/src/database/drivers/MySqlDriver.ts +++ b/src/database/drivers/MySqlDriver.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ /** * @athenna/database * @@ -8,28 +7,17 @@ * file that was distributed with this source code. */ -import { - Exec, - Is, - Json, - Options, - type PaginatedResponse, - type PaginationOptions -} from '@athenna/common' - -import type { Knex } from 'knex' import { debug } from '#src/debug' import { Log } from '@athenna/logger' -import { Driver } from '#src/database/drivers/Driver' +import type { Operations } from '#src/types' +import { Is, Json, Options } from '@athenna/common' import { ConnectionFactory } from '#src/factories/ConnectionFactory' -import { Transaction } from '#src/database/transactions/Transaction' -import type { ConnectionOptions, Direction, Operations } from '#src/types' -import { MigrationSource } from '#src/database/migrations/MigrationSource' +import type { ConnectionOptions } from '#src/types/ConnectionOptions' +import { BaseKnexDriver } from '#src/database/drivers/BaseKnexDriver' import { WrongMethodException } from '#src/exceptions/WrongMethodException' -import { PROTECTED_QUERY_METHODS } from '#src/constants/ProtectedQueryMethods' -import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' -export class MySqlDriver extends Driver { +export class MySqlDriver extends BaseKnexDriver { /** * Connect to database. */ @@ -85,114 +73,6 @@ export class MySqlDriver extends Driver { this.qb = this.query() } - /** - * Close the connection with database in this instance. - */ - public async close(): Promise { - if (!this.isConnected) { - return - } - - await this.client.destroy() - - this.qb = null - this.tableName = null - this.client = null - this.isConnected = false - - ConnectionFactory.setClient(this.connection, null) - } - - /** - * Creates a new instance of query builder. - */ - public query(): Knex.QueryBuilder { - if (!this.isConnected) { - throw new NotConnectedDatabaseException() - } - - const query = this.useSetQB - ? this.qb.table(this.tableName) - : this.client.queryBuilder().table(this.tableName) - - const handler = { - get: (target: Knex.QueryBuilder, propertyKey: string) => { - if (PROTECTED_QUERY_METHODS.includes(propertyKey)) { - this.qb = this.query() - } - - return target[propertyKey] - } - } - - return new Proxy(query, handler) - } - - /** - * Sync a model schema with database. - */ - public async sync(): Promise { - debug( - `database sync with ${MySqlDriver.name} is not available yet, use migration instead.` - ) - } - - /** - * Create a new transaction. - */ - - public async startTransaction(): Promise< - Transaction - > { - const trx = await this.client.transaction() - - return new Transaction(this.clone().setClient(trx)) - } - - /** - * Commit the transaction. - */ - public async commitTransaction(): Promise { - const client = this.client as Knex.Transaction - - await client.commit() - - this.tableName = null - this.client = null - this.isConnected = false - } - - /** - * Rollback the transaction. - */ - public async rollbackTransaction(): Promise { - const client = this.client as Knex.Transaction - - await client.rollback() - - this.tableName = null - this.client = null - this.isConnected = false - } - - /** - * Run database migrations. - */ - public async runMigrations(): Promise { - await this.client.migrate.latest({ - migrationSource: new MigrationSource(this.connection) - }) - } - - /** - * Revert database migrations. - */ - public async revertMigrations(): Promise { - await this.client.migrate.rollback({ - migrationSource: new MigrationSource(this.connection) - }) - } - /** * List all databases available. */ @@ -202,22 +82,6 @@ export class MySqlDriver extends Driver { return databases.map(database => database.Database) } - /** - * Get the current database name. - */ - public async getCurrentDatabase(): Promise { - return this.client.client.database() - } - - /** - * Verify if database exists. - */ - public async hasDatabase(database: string): Promise { - const databases = await this.getDatabases() - - return databases.includes(database) - } - /** * Create a new database. */ @@ -244,40 +108,6 @@ export class MySqlDriver extends Driver { return tables.map(table => table.TABLE_NAME) } - /** - * Verify if table exists. - */ - public async hasTable(table: string): Promise { - return this.client.schema.hasTable(table) - } - - /** - * Create a new table in database. - */ - public async createTable( - table: string, - closure: (builder: Knex.TableBuilder) => void | Promise - ): Promise { - await this.client.schema.createTable(table, closure) - } - - /** - * Alter a table in database. - */ - public async alterTable( - table: string, - closure: (builder: Knex.TableBuilder) => void | Promise - ): Promise { - await this.client.schema.alterTable(table, closure) - } - - /** - * Drop a table in database. - */ - public async dropTable(table: string): Promise { - await this.client.schema.dropTableIfExists(table) - } - /** * Remove all data inside some database table * and restart the identity of the table. @@ -291,155 +121,6 @@ export class MySqlDriver extends Driver { } } - /** - * Make a raw query in database. - */ - public raw(sql: string, bindings?: any): T { - return this.client.raw(sql, bindings) as any - } - - /** - * Calculate the average of a given column. - */ - public async avg(column: string): Promise { - const [{ avg }] = await this.qb.avg({ avg: column }) - - return avg - } - - /** - * Calculate the average of a given column using distinct. - */ - public async avgDistinct(column: string): Promise { - const [{ avg }] = await this.qb.avgDistinct({ avg: column }) - - return avg - } - - /** - * Get the max number of a given column. - */ - public async max(column: string): Promise { - const [{ max }] = await this.qb.max({ max: column }) - - return max - } - - /** - * Get the min number of a given column. - */ - public async min(column: string): Promise { - const [{ min }] = await this.qb.min({ min: column }) - - return min - } - - /** - * Sum all numbers of a given column. - */ - public async sum(column: string): Promise { - const [{ sum }] = await this.qb.sum({ sum: column }) - - return sum - } - - /** - * Sum all numbers of a given column in distinct mode. - */ - public async sumDistinct(column: string): Promise { - const [{ sum }] = await this.qb.sumDistinct({ sum: column }) - - return sum - } - - /** - * Increment a value of a given column. - */ - public async increment(column: string): Promise { - await this.qb.increment(column) - } - - /** - * Decrement a value of a given column. - */ - public async decrement(column: string): Promise { - await this.qb.decrement(column) - } - - /** - * Calculate the average of a given column using distinct. - */ - public async count(column: string = '*'): Promise { - const [{ count }] = await this.qb.count({ count: column }) - - return `${count}` - } - - /** - * Calculate the average of a given column using distinct. - */ - public async countDistinct(column: string): Promise { - const [{ count }] = await this.qb.countDistinct({ count: column }) - - return `${count}` - } - - /** - * Find a value in database. - */ - public async find(): Promise { - return this.qb.first() - } - - /** - * Find many values in database. - */ - public async findMany(): Promise { - const data = await this.qb - - this.qb = this.query() - - return data - } - - /** - * Find many values in database and return as paginated response. - */ - public async paginate( - page: PaginationOptions | number = { page: 0, limit: 10, resourceUrl: '/' }, - limit = 10, - resourceUrl = '/' - ): Promise> { - if (Is.Number(page)) { - page = { page, limit, resourceUrl } - } - - const [{ count }] = await this.qb - .clone() - .clearOrder() - .clearSelect() - .count({ count: '*' }) - - const data = await this.offset(page.page * page.limit) - .limit(page.limit) - .findMany() - - return Exec.pagination(data, parseInt(count), page) - } - - /** - * Create a value in database. - */ - public async create(data: Partial = {}): Promise { - if (Is.Array(data)) { - throw new WrongMethodException('create', 'createMany') - } - - const created = await this.createMany([data]) - - return created[0] - } - /** * Create many values in database. */ @@ -448,13 +129,14 @@ export class MySqlDriver extends Driver { throw new WrongMethodException('createMany', 'create') } + const preparedData = data.map(data => this.prepareInsert(data)) const ids = [] - const promises = data.map(data => { + const promises = preparedData.map((prepared, index) => { return this.qb .clone() - .insert(data) - .then(([id]) => ids.push(data[this.primaryKey] || id)) + .insert(prepared) + .then(([id]) => ids.push(data[index][this.primaryKey] || id)) }) await Promise.all(promises) @@ -462,853 +144,150 @@ export class MySqlDriver extends Driver { return this.whereIn(this.primaryKey, ids).findMany() } - /** - * Create data or update if already exists. - */ - public async createOrUpdate( - data: Partial = {} - ): Promise { - const query = this.qb.clone() - const hasValue = await query.first() - - if (hasValue) { - await this.qb - .where(this.primaryKey, hasValue[this.primaryKey]) - .update(data) - - return this.where(this.primaryKey, hasValue[this.primaryKey]).find() - } - - return this.create(data) - } + public whereJson(column: string, value: any): this + public whereJson(column: string, operation: Operations, value: any): this /** - * Update a value in database. + * Set a where json statement in your query. */ - public async update(data: Partial): Promise { - await this.qb.clone().update(data) - - const result = await this.findMany() - - if (result.length === 1) { - return result[0] + public whereJson(column: string, operator: any, value?: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereJson') } - return result - } - - /** - * Delete one value in database. - */ - public async delete(): Promise { - await this.qb.delete() - } + const parsed = this.parseJsonSelector(column) - /** - * Set the table that this query will be executed. - */ - public table(table: string) { - if (!this.isConnected) { - throw new NotConnectedDatabaseException() + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) } - this.tableName = table - this.qb = this.query() - - return this - } - - /** - * Log in console the actual query built. - */ - public dump() { - console.log(this.qb.toSQL().toNative()) - - return this - } + const normalized = this.normalizeJsonOperation(operator, value) - /** - * Set the columns that should be selected on query. - */ - public select(...columns: string[]) { - this.qb.select(...columns) + if (!parsed.path.includes('*')) { + this.qb.whereRaw( + 'JSON_UNQUOTE(JSON_EXTRACT(??, ?)) ' + normalized.operator + ' ?', + [ + parsed.column, + this.parseJsonSelectorToMySqlPath(parsed.path), + normalized.value + ] + ) - return this - } + return this + } - /** - * Set the columns that should be selected on query raw. - */ - public selectRaw(sql: string, bindings?: any) { - return this.select(this.raw(sql, bindings) as any) - } + const wildcard = this.parseJsonSelectorToWildcardParts(parsed.path) - /** - * Set the table that should be used on query. - * Different from `table()` method, this method - * doesn't change the driver table. - */ - public from(table: string) { - this.qb.from(table) + this.qb.whereRaw( + "exists (select 1 from json_table(json_extract(??, ?), '$[*]' columns (value json path ?)) as jt where JSON_UNQUOTE(jt.value) " + + normalized.operator + + ' ?)', + [parsed.column, wildcard.arrayPath, wildcard.valuePath, normalized.value] + ) return this } - /** - * Set the table that should be used on query raw. - * Different from `table()` method, this method - * doesn't change the driver table. - */ - public fromRaw(sql: string, bindings?: any) { - return this.from(this.raw(sql, bindings) as any) - } - - /** - * Set a join statement in your query. - */ - public join( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('join', table, column1, operation, column2) - } - - /** - * Set a left join statement in your query. - */ - public leftJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('leftJoin', table, column1, operation, column2) - } + public orWhereJson(column: string, value: any): this + public orWhereJson(column: string, operation: Operations, value: any): this /** - * Set a right join statement in your query. + * Set an or where json statement in your query. */ - public rightJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('rightJoin', table, column1, operation, column2) - } + public orWhereJson(column: string, operator: any, value?: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereJson') + } - /** - * Set a cross join statement in your query. - */ - public crossJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('crossJoin', table, column1, operation, column2) - } + const parsed = this.parseJsonSelector(column) - /** - * Set a full outer join statement in your query. - */ - public fullOuterJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - // TODO https://github.com/knex/knex/issues/3949 - return this.joinByType('leftJoin', table, column1, operation, column2) - } + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) + } - /** - * Set a left outer join statement in your query. - */ - public leftOuterJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('leftOuterJoin', table, column1, operation, column2) - } + const normalized = this.normalizeJsonOperation(operator, value) - /** - * Set a right outer join statement in your query. - */ - public rightOuterJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('rightOuterJoin', table, column1, operation, column2) - } + if (!parsed.path.includes('*')) { + this.qb.orWhereRaw( + 'JSON_UNQUOTE(JSON_EXTRACT(??, ?)) ' + normalized.operator + ' ?', + [ + parsed.column, + this.parseJsonSelectorToMySqlPath(parsed.path), + normalized.value + ] + ) - /** - * Set a join raw statement in your query. - */ - public joinRaw(sql: string, bindings?: any) { - this.qb.joinRaw(sql, bindings) + return this + } - return this - } + const wildcard = this.parseJsonSelectorToWildcardParts(parsed.path) - /** - * Set a group by statement in your query. - */ - public groupBy(...columns: string[]) { - this.qb.groupBy(...columns) + this.qb.orWhereRaw( + "exists (select 1 from json_table(json_extract(??, ?), '$[*]' columns (value json path ?)) as jt where JSON_UNQUOTE(jt.value) " + + normalized.operator + + ' ?)', + [parsed.column, wildcard.arrayPath, wildcard.valuePath, normalized.value] + ) return this } /** - * Set a group by raw statement in your query. + * Convert a json selector path to mysql json path. */ - public groupByRaw(sql: string, bindings?: any) { - this.qb.groupByRaw(sql, bindings) + private parseJsonSelectorToMySqlPath(path: string) { + const parts = path + .split('->') + .map(part => part.trim()) + .filter(Boolean) - return this + return this.toJsonPath(parts) } - public having(column: string): this - public having(column: string, value: any): this - public having(column: string, operation: Operations, value: any): this - /** - * Set a having statement in your query. + * Split a json selector around the wildcard. */ - public having(column: any, operation?: Operations, value?: any) { - if (operation === undefined) { - this.qb.having(column) - - return this - } + private parseJsonSelectorToWildcardParts(path: string) { + const parts = path + .split('->') + .map(part => part.trim()) + .filter(Boolean) - if (value === undefined) { - this.qb.having(column, '=', operation) + const wildcardIndex = parts.indexOf('*') - return this + return { + arrayPath: this.toJsonPath(parts.slice(0, wildcardIndex)), + valuePath: this.toJsonPath(parts.slice(wildcardIndex + 1)) } - - this.qb.having(column, operation, value) - - return this - } - - /** - * Set a having raw statement in your query. - */ - public havingRaw(sql: string, bindings?: any) { - this.qb.havingRaw(sql, bindings) - - return this } /** - * Set a having exists statement in your query. + * Convert path parts to a valid json path. */ - public havingExists(closure: (query: MySqlDriver) => void) { - const driver = this.clone() as MySqlDriver - - // @ts-ignore - this.qb.havingExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) + private toJsonPath(parts: string[]) { + return parts.reduce((jsonPath, part) => { + if (/^\d+$/.test(part)) { + return `${jsonPath}[${part}]` + } - return this + return `${jsonPath}.${part}` + }, '$') } /** - * Set a having not exists statement in your query. + * Normalize operator/value pairs from the whereJson overloads. */ - public havingNotExists(closure: (query: MySqlDriver) => void) { - const driver = this.clone() as MySqlDriver - - // @ts-ignore - this.qb.havingNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) + private normalizeJsonOperation(operator: any, value?: any) { + if (Is.Undefined(value)) { + return { + operator: '=', + value: operator + } + } - return this - } - - /** - * Set a having in statement in your query. - */ - public havingIn(column: string, values: any[]) { - this.qb.havingIn(column, values) - - return this - } - - /** - * Set a having not in statement in your query. - */ - public havingNotIn(column: string, values: any[]) { - this.qb.havingNotIn(column, values) - - return this - } - - /** - * Set a having between statement in your query. - */ - public havingBetween(column: string, values: [any, any]) { - this.qb.havingBetween(column, values) - - return this - } - - /** - * Set a having not between statement in your query. - */ - public havingNotBetween(column: string, values: [any, any]) { - this.qb.havingNotBetween(column, values) - - return this - } - - /** - * Set a having null statement in your query. - */ - public havingNull(column: string) { - this.qb.havingNull(column) - - return this - } - - /** - * Set a having not null statement in your query. - */ - public havingNotNull(column: string) { - this.qb.havingNotNull(column) - - return this - } - - public orHaving(column: string): this - public orHaving(column: string, value: any): this - public orHaving(column: string, operation: Operations, value: any): this - - /** - * Set an or having statement in your query. - */ - public orHaving(column: any, operation?: Operations, value?: any) { - if (operation === undefined) { - this.qb.orHaving(column) - - return this - } - - if (value === undefined) { - this.qb.orHaving(column, '=', operation) - - return this - } - - this.qb.orHaving(column, operation, value) - - return this - } - - /** - * Set an or having raw statement in your query. - */ - public orHavingRaw(sql: string, bindings?: any) { - this.qb.orHavingRaw(sql, bindings) - - return this - } - - /** - * Set an or having exists statement in your query. - */ - public orHavingExists(closure: (query: MySqlDriver) => void) { - const driver = this.clone() as MySqlDriver - - // @ts-ignore - this.qb.orHavingExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or having not exists statement in your query. - */ - public orHavingNotExists(closure: (query: MySqlDriver) => void) { - const driver = this.clone() as MySqlDriver - - // @ts-ignore - this.qb.orHavingNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or having in statement in your query. - */ - public orHavingIn(column: string, values: any[]) { - // @ts-ignore - this.qb.orHavingIn(column, values) - - return this - } - - /** - * Set an or having not in statement in your query. - */ - public orHavingNotIn(column: string, values: any[]) { - this.qb.orHavingNotIn(column, values) - - return this - } - - /** - * Set an or having between statement in your query. - */ - public orHavingBetween(column: string, values: [any, any]) { - this.qb.orHavingBetween(column, values) - - return this - } - - /** - * Set an or having not between statement in your query. - */ - public orHavingNotBetween(column: string, values: [any, any]) { - this.qb.orHavingNotBetween(column, values) - - return this - } - - /** - * Set an or having null statement in your query. - */ - public orHavingNull(column: string) { - // @ts-ignore - this.qb.orHavingNull(column) - - return this - } - - /** - * Set an or having not null statement in your query. - */ - public orHavingNotNull(column: string) { - // @ts-ignore - this.qb.orHavingNotNull(column) - - return this - } - - public where(statement: Record): this - public where(key: string, value: any): this - public where(key: string, operation: Operations, value: any): this - - /** - * Set a where statement in your query. - */ - public where(statement: any, operation?: Operations, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.where(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (operation === undefined) { - if (Is.Array(statement)) { - throw new Error('Arrays as statement are not supported.') - } - - if (Is.String(statement)) { - throw new Error( - `The value for the "${statement}" column is undefined and where will not work.` - ) - } - - this.qb.where(statement) - - return this - } - - if (value === undefined) { - this.qb.where(statement, operation) - - return this - } - - this.qb.where(statement, operation, value) - - return this - } - - public whereNot(statement: Record): this - public whereNot(key: string, value: any): this - - /** - * Set a where not statement in your query. - */ - public whereNot(statement: any, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.whereNot(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (value === undefined) { - this.qb.whereNot(statement) - - return this - } - - this.qb.whereNot(statement, value) - - return this - } - - /** - * Set a where raw statement in your query. - */ - public whereRaw(sql: string, bindings?: any) { - this.qb.whereRaw(sql, bindings) - - return this - } - - /** - * Set a where exists statement in your query. - */ - public whereExists(closure: (query: MySqlDriver) => void) { - const driver = this.clone() as MySqlDriver - - this.qb.whereExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set a where not exists statement in your query. - */ - public whereNotExists(closure: (query: MySqlDriver) => void) { - const driver = this.clone() as MySqlDriver - - this.qb.whereNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set a where like statement in your query. - */ - public whereLike(column: string, value: any) { - this.qb.whereLike(column, value) - - return this - } - - /** - * Set a where ILike statement in your query. - */ - public whereILike(column: string, value: any) { - this.qb.whereILike(column, value) - - return this - } - - /** - * Set a where in statement in your query. - */ - public whereIn(column: string, values: any[]) { - this.qb.whereIn(column, values) - - return this - } - - /** - * Set a where not in statement in your query. - */ - public whereNotIn(column: string, values: any[]) { - this.qb.whereNotIn(column, values) - - return this - } - - /** - * Set a where between statement in your query. - */ - public whereBetween(column: string, values: [any, any]) { - this.qb.whereBetween(column, values) - - return this - } - - /** - * Set a where not between statement in your query. - */ - public whereNotBetween(column: string, values: [any, any]) { - this.qb.whereNotBetween(column, values) - - return this - } - - /** - * Set a where null statement in your query. - */ - public whereNull(column: string) { - this.qb.whereNull(column) - - return this - } - - /** - * Set a where not null statement in your query. - */ - public whereNotNull(column: string) { - this.qb.whereNotNull(column) - - return this - } - - public orWhere(statement: Record): this - public orWhere(key: string, value: any): this - public orWhere(key: string, operation: Operations, value: any): this - - /** - * Set a or where statement in your query. - */ - public orWhere(statement: any, operation?: Operations, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.orWhere(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (operation === undefined) { - this.qb.orWhere(statement) - - return this - } - - if (value === undefined) { - this.qb.orWhere(statement, operation) - - return this - } - - this.qb.orWhere(statement, operation, value) - - return this - } - - public orWhereNot(statement: Record): this - public orWhereNot(key: string, value: any): this - - /** - * Set an or where not statement in your query. - */ - public orWhereNot(statement: any, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.orWhereNot(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (value === undefined) { - this.qb.orWhereNot(statement) - - return this + return { + operator, + value } - - this.qb.orWhereNot(statement, value) - - return this - } - - /** - * Set a or where raw statement in your query. - */ - public orWhereRaw(sql: string, bindings?: any) { - this.qb.orWhereRaw(sql, bindings) - - return this - } - - /** - * Set an or where exists statement in your query. - */ - public orWhereExists(closure: (query: MySqlDriver) => void) { - const driver = this.clone() as MySqlDriver - - this.qb.orWhereExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or where not exists statement in your query. - */ - public orWhereNotExists(closure: (query: MySqlDriver) => void) { - const driver = this.clone() as MySqlDriver - - this.qb.orWhereNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or where like statement in your query. - */ - public orWhereLike(column: string, value: any) { - this.qb.orWhereLike(column, value) - - return this - } - - /** - * Set an or where ILike statement in your query. - */ - public orWhereILike(column: string, value: any) { - this.qb.orWhereILike(column, value) - - return this - } - - /** - * Set an or where in statement in your query. - */ - public orWhereIn(column: string, values: any[]) { - this.qb.orWhereIn(column, values) - - return this - } - - /** - * Set an or where not in statement in your query. - */ - public orWhereNotIn(column: string, values: any[]) { - this.qb.orWhereNotIn(column, values) - - return this - } - - /** - * Set an or where between statement in your query. - */ - public orWhereBetween(column: string, values: [any, any]) { - this.qb.orWhereBetween(column, values) - - return this - } - - /** - * Set an or where not between statement in your query. - */ - public orWhereNotBetween(column: string, values: [any, any]) { - this.qb.orWhereNotBetween(column, values) - - return this - } - - /** - * Set an or where null statement in your query. - */ - public orWhereNull(column: string) { - this.qb.orWhereNull(column) - - return this - } - - /** - * Set an or where not null statement in your query. - */ - public orWhereNotNull(column: string) { - this.qb.orWhereNotNull(column) - - return this - } - - /** - * Set an order by statement in your query. - */ - public orderBy(column: string, direction: Direction = 'ASC') { - this.qb.orderBy(column, direction.toUpperCase()) - - return this - } - - /** - * Set an order by raw statement in your query. - */ - public orderByRaw(sql: string, bindings?: any) { - this.qb.orderByRaw(sql, bindings) - - return this - } - - /** - * Order the results easily by the latest date. By default, the result will - * be ordered by the table's "createdAt" column. - */ - public latest(column: string = 'createdAt') { - return this.orderBy(column, 'DESC') - } - - /** - * Order the results easily by the oldest date. By default, the result will - * be ordered by the table's "createdAt" column. - */ - public oldest(column: string = 'createdAt') { - return this.orderBy(column, 'ASC') - } - - /** - * Set the skip number in your query. - */ - public offset(number: number) { - this.qb.offset(number) - - return this - } - - /** - * Set the limit number in your query. - */ - public limit(number: number) { - this.qb.limit(number) - - return this } } diff --git a/src/database/drivers/PostgresDriver.ts b/src/database/drivers/PostgresDriver.ts index 66dccdd..ad114aa 100644 --- a/src/database/drivers/PostgresDriver.ts +++ b/src/database/drivers/PostgresDriver.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ /** * @athenna/database * @@ -8,28 +7,17 @@ * file that was distributed with this source code. */ -import { - Exec, - Is, - Json, - Options, - type PaginatedResponse, - type PaginationOptions -} from '@athenna/common' - -import type { Knex } from 'knex' import { debug } from '#src/debug' import { Log } from '@athenna/logger' -import { Driver } from '#src/database/drivers/Driver' +import type { Operations } from '#src/types' +import { Is, Json, Options } from '@athenna/common' import { ConnectionFactory } from '#src/factories/ConnectionFactory' -import { Transaction } from '#src/database/transactions/Transaction' -import type { ConnectionOptions, Direction, Operations } from '#src/types' -import { MigrationSource } from '#src/database/migrations/MigrationSource' +import { BaseKnexDriver } from '#src/database/drivers/BaseKnexDriver' +import type { ConnectionOptions } from '#src/types/ConnectionOptions' import { WrongMethodException } from '#src/exceptions/WrongMethodException' -import { PROTECTED_QUERY_METHODS } from '#src/constants/ProtectedQueryMethods' -import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' -export class PostgresDriver extends Driver { +export class PostgresDriver extends BaseKnexDriver { /** * Connect to database. */ @@ -103,95 +91,6 @@ export class PostgresDriver extends Driver { ConnectionFactory.setClient(this.connection, null) } - /** - * Creates a new instance of query builder. - */ - public query(): Knex.QueryBuilder { - if (!this.isConnected) { - throw new NotConnectedDatabaseException() - } - - const query = this.useSetQB - ? this.qb.table(this.tableName) - : this.client.queryBuilder().table(this.tableName) - - const handler = { - get: (target: Knex.QueryBuilder, propertyKey: string) => { - if (PROTECTED_QUERY_METHODS.includes(propertyKey)) { - this.qb = this.query() - } - - return target[propertyKey] - } - } - - return new Proxy(query, handler) - } - - /** - * Sync a model schema with database. - */ - public async sync(): Promise { - debug( - `database sync with ${PostgresDriver.name} is not available yet, use migration instead.` - ) - } - - /** - * Create a new transaction. - */ - public async startTransaction(): Promise< - Transaction - > { - const trx = await this.client.transaction() - - return new Transaction(this.clone().setClient(trx)) - } - - /** - * Commit the transaction. - */ - public async commitTransaction(): Promise { - const client = this.client as Knex.Transaction - - await client.commit() - - this.tableName = null - this.client = null - this.isConnected = false - } - - /** - * Rollback the transaction. - */ - public async rollbackTransaction(): Promise { - const client = this.client as Knex.Transaction - - await client.rollback() - - this.tableName = null - this.client = null - this.isConnected = false - } - - /** - * Run database migrations. - */ - public async runMigrations(): Promise { - await this.client.migrate.latest({ - migrationSource: new MigrationSource(this.connection) - }) - } - - /** - * Revert database migrations. - */ - public async revertMigrations(): Promise { - await this.client.migrate.rollback({ - migrationSource: new MigrationSource(this.connection) - }) - } - /** * List all databases available. */ @@ -203,22 +102,6 @@ export class PostgresDriver extends Driver { return databases.map(database => database.datname) } - /** - * Get the current database name. - */ - public async getCurrentDatabase(): Promise { - return this.client.client.database() - } - - /** - * Verify if database exists. - */ - public async hasDatabase(database: string): Promise { - const databases = await this.getDatabases() - - return databases.includes(database) - } - /** * Create a new database. */ @@ -255,40 +138,6 @@ export class PostgresDriver extends Driver { return tables.map(table => table.table_name) } - /** - * Verify if table exists. - */ - public async hasTable(table: string): Promise { - return this.client.schema.hasTable(table) - } - - /** - * Create a new table in database. - */ - public async createTable( - table: string, - closure: (builder: Knex.TableBuilder) => void | Promise - ): Promise { - await this.client.schema.createTable(table, closure) - } - - /** - * Alter a table in database. - */ - public async alterTable( - table: string, - closure: (builder: Knex.TableBuilder) => void | Promise - ): Promise { - await this.client.schema.alterTable(table, closure) - } - - /** - * Drop a table in database. - */ - public async dropTable(table: string): Promise { - await this.client.schema.dropTableIfExists(table) - } - /** * Remove all data inside some database table * and restart the identity of the table. @@ -297,155 +146,6 @@ export class PostgresDriver extends Driver { await this.raw('TRUNCATE TABLE ?? CASCADE', table) } - /** - * Make a raw query in database. - */ - public raw(sql: string, bindings?: any): T { - return this.client.raw(sql, bindings) as any - } - - /** - * Calculate the average of a given column. - */ - public async avg(column: string): Promise { - const [{ avg }] = await this.qb.avg({ avg: column }) - - return avg - } - - /** - * Calculate the average of a given column using distinct. - */ - public async avgDistinct(column: string): Promise { - const [{ avg }] = await this.qb.avgDistinct({ avg: column }) - - return avg - } - - /** - * Get the max number of a given column. - */ - public async max(column: string): Promise { - const [{ max }] = await this.qb.max({ max: column }) - - return max - } - - /** - * Get the min number of a given column. - */ - public async min(column: string): Promise { - const [{ min }] = await this.qb.min({ min: column }) - - return min - } - - /** - * Sum all numbers of a given column. - */ - public async sum(column: string): Promise { - const [{ sum }] = await this.qb.sum({ sum: column }) - - return sum - } - - /** - * Sum all numbers of a given column in distinct mode. - */ - public async sumDistinct(column: string): Promise { - const [{ sum }] = await this.qb.sumDistinct({ sum: column }) - - return sum - } - - /** - * Increment a value of a given column. - */ - public async increment(column: string): Promise { - await this.qb.increment(column) - } - - /** - * Decrement a value of a given column. - */ - public async decrement(column: string): Promise { - await this.qb.decrement(column) - } - - /** - * Calculate the average of a given column using distinct. - */ - public async count(column: string = '*'): Promise { - const [{ count }] = await this.qb.count({ count: column }) - - return `${count}` - } - - /** - * Calculate the average of a given column using distinct. - */ - public async countDistinct(column: string): Promise { - const [{ count }] = await this.qb.countDistinct({ count: column }) - - return `${count}` - } - - /** - * Find a value in database. - */ - public async find(): Promise { - return this.qb.first() - } - - /** - * Find many values in database. - */ - public async findMany(): Promise { - const data = await this.qb - - this.qb = this.query() - - return data - } - - /** - * Find many values in database and return as paginated response. - */ - public async paginate( - page: PaginationOptions | number = { page: 0, limit: 10, resourceUrl: '/' }, - limit = 10, - resourceUrl = '/' - ): Promise> { - if (Is.Number(page)) { - page = { page, limit, resourceUrl } - } - - const [{ count }] = await this.qb - .clone() - .clearOrder() - .clearSelect() - .count({ count: '*' }) - - const data = await this.offset(page.page * page.limit) - .limit(page.limit) - .findMany() - - return Exec.pagination(data, parseInt(count), page) - } - - /** - * Create a value in database. - */ - public async create(data: Partial = {}): Promise { - if (Is.Array(data)) { - throw new WrongMethodException('create', 'createMany') - } - - const created = await this.createMany([data]) - - return created[0] - } - /** * Create many values in database. */ @@ -454,855 +154,131 @@ export class PostgresDriver extends Driver { throw new WrongMethodException('createMany', 'create') } - return this.qb.insert(data, '*') - } - - /** - * Create data or update if already exists. - */ - public async createOrUpdate( - data: Partial = {} - ): Promise { - const query = this.qb.clone() - const hasValue = await query.first() - - if (hasValue) { - await this.qb - .where(this.primaryKey, hasValue[this.primaryKey]) - .update(data) - - return this.where(this.primaryKey, hasValue[this.primaryKey]).find() - } - - return this.create(data) - } - - /** - * Update a value in database. - */ - public async update(data: Partial): Promise { - await this.qb.clone().update(data) - - const result = await this.findMany() - - if (result.length === 1) { - return result[0] - } + const preparedData = data.map(data => this.prepareInsert(data)) - return result + return this.qb.insert(preparedData, '*') } - /** - * Delete one value in database. - */ - public async delete(): Promise { - await this.qb.delete() - } + public whereJson(column: string, value: any): this + public whereJson(column: string, operation: Operations, value: any): this /** - * Set the table that this query will be executed. + * Set a where json statement in your query. */ - public table(table: string) { - if (!this.isConnected) { - throw new NotConnectedDatabaseException() + public whereJson(column: string, operator: any, value?: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereJson') } - this.tableName = table - this.qb = this.query() - - return this - } - - /** - * Log in console the actual query built. - */ - public dump() { - console.log(this.qb.toSQL().toNative()) - - return this - } + const parsed = this.parseJsonSelector(column) - /** - * Set the columns that should be selected on query. - */ - public select(...columns: string[]) { - this.qb.select(...columns) + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) + } - return this - } + if (!parsed.path.includes('*')) { + return super.whereJson(column, operator, value) + } - /** - * Set the columns that should be selected on query raw. - */ - public selectRaw(sql: string, bindings?: any) { - return this.select(this.raw(sql, bindings) as any) - } + const path = this.parseJsonSelectorToWildcardPath(parsed.path) + const normalized = this.normalizeJsonOperation(operator, value) - /** - * Set the table that should be used on query. - * Different from `table()` method, this method - * doesn't change the driver table. - */ - public from(table: string) { - this.qb.from(table) + this.qb.whereRaw('jsonb_path_exists(??, ?::jsonpath, ?::jsonb)', [ + parsed.column, + `${path} ? (@ ${normalized.operator} $value)`, + JSON.stringify({ value: normalized.value }) + ]) return this } - /** - * Set the table that should be used on query raw. - * Different from `table()` method, this method - * doesn't change the driver table. - */ - public fromRaw(sql: string, bindings?: any) { - return this.from(this.raw(sql, bindings) as any) - } - - /** - * Set a join statement in your query. - */ - public join( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('join', table, column1, operation, column2) - } - - /** - * Set a left join statement in your query. - */ - public leftJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('leftJoin', table, column1, operation, column2) - } + public orWhereJson(column: string, value: any): this + public orWhereJson(column: string, operation: Operations, value: any): this /** - * Set a right join statement in your query. + * Set an or where json statement in your query. */ - public rightJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('rightJoin', table, column1, operation, column2) - } + public orWhereJson(column: string, operator: any, value?: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereJson') + } - /** - * Set a cross join statement in your query. - */ - public crossJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('crossJoin', table, column1, operation, column2) - } + const parsed = this.parseJsonSelector(column) - /** - * Set a full outer join statement in your query. - */ - public fullOuterJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('fullOuterJoin', table, column1, operation, column2) - } + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) + } - /** - * Set a left outer join statement in your query. - */ - public leftOuterJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('leftOuterJoin', table, column1, operation, column2) - } + if (!parsed.path.includes('*')) { + return super.orWhereJson(column, operator, value) + } - /** - * Set a right outer join statement in your query. - */ - public rightOuterJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('rightOuterJoin', table, column1, operation, column2) - } + const path = this.parseJsonSelectorToWildcardPath(parsed.path) + const normalized = this.normalizeJsonOperation(operator, value) - /** - * Set a join raw statement in your query. - */ - public joinRaw(sql: string, bindings?: any) { - this.qb.joinRaw(sql, bindings) + this.qb.orWhereRaw('jsonb_path_exists(??, ?::jsonpath, ?::jsonb)', [ + parsed.column, + `${path} ? (@ ${normalized.operator} $value)`, + JSON.stringify({ value: normalized.value }) + ]) return this } /** - * Set a group by statement in your query. + * Convert a json selector path to a valid postgres json path. */ - public groupBy(...columns: string[]) { - this.qb.groupBy(...columns) + private parseJsonSelectorToWildcardPath(path: string) { + const parts = path + .split('->') + .map(part => part.trim()) + .filter(Boolean) - return this - } + return parts.reduce((jsonPath, part) => { + if (part === '*') { + return `${jsonPath}[*]` + } - /** - * Set a group by raw statement in your query. - */ - public groupByRaw(sql: string, bindings?: any) { - this.qb.groupByRaw(sql, bindings) + if (/^\d+$/.test(part)) { + return `${jsonPath}[${part}]` + } - return this + return `${jsonPath}.${part}` + }, '$') } - public having(column: string): this - public having(column: string, value: any): this - public having(column: string, operation: Operations, value: any): this - /** - * Set a having statement in your query. + * Normalize operator/value pair for postgres json path comparisons. */ - public having(column: any, operation?: Operations, value?: any) { - if (operation === undefined) { - this.qb.having(column) - - return this + private normalizeJsonOperation(operator: any, value?: any) { + if (Is.Undefined(value)) { + return { + operator: '==', + value: operator + } } - if (value === undefined) { - this.qb.having(column, '=', operation) - - return this + return { + operator: this.getJsonPathOperator(operator), + value } - - this.qb.having(column, operation, value) - - return this - } - - /** - * Set a having raw statement in your query. - */ - public havingRaw(sql: string, bindings?: any) { - this.qb.havingRaw(sql, bindings) - - return this - } - - /** - * Set a having exists statement in your query. - */ - public havingExists(closure: (query: PostgresDriver) => void) { - const driver = this.clone() as PostgresDriver - - // @ts-ignore - this.qb.havingExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this } /** - * Set a having not exists statement in your query. + * Convert query operators to postgres json path operators. */ - public havingNotExists(closure: (query: PostgresDriver) => void) { - const driver = this.clone() as PostgresDriver - - // @ts-ignore - this.qb.havingNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) + private getJsonPathOperator(operator: string) { + const operators = { + '=': '==', + '==': '==', + '!=': '!=', + '<>': '!=', + '>': '>', + '>=': '>=', + '<': '<', + '<=': '<=' + } - return this - } - - /** - * Set a having in statement in your query. - */ - public havingIn(column: string, values: any[]) { - this.qb.havingIn(column, values) - - return this - } - - /** - * Set a having not in statement in your query. - */ - public havingNotIn(column: string, values: any[]) { - this.qb.havingNotIn(column, values) - - return this - } - - /** - * Set a having between statement in your query. - */ - public havingBetween(column: string, values: [any, any]) { - this.qb.havingBetween(column, values) - - return this - } - - /** - * Set a having not between statement in your query. - */ - public havingNotBetween(column: string, values: [any, any]) { - this.qb.havingNotBetween(column, values) - - return this - } - - /** - * Set a having null statement in your query. - */ - public havingNull(column: string) { - this.qb.havingNull(column) - - return this - } - - /** - * Set a having not null statement in your query. - */ - public havingNotNull(column: string) { - this.qb.havingNotNull(column) - - return this - } - - public orHaving(column: string): this - public orHaving(column: string, value: any): this - public orHaving(column: string, operation: Operations, value: any): this - - /** - * Set an or having statement in your query. - */ - public orHaving(column: any, operation?: Operations, value?: any) { - if (operation === undefined) { - this.qb.orHaving(column) - - return this - } - - if (value === undefined) { - this.qb.orHaving(column, '=', operation) - - return this - } - - this.qb.orHaving(column, operation, value) - - return this - } - - /** - * Set an or having raw statement in your query. - */ - public orHavingRaw(sql: string, bindings?: any) { - this.qb.orHavingRaw(sql, bindings) - - return this - } - - /** - * Set an or having exists statement in your query. - */ - public orHavingExists(closure: (query: PostgresDriver) => void) { - const driver = this.clone() as PostgresDriver - - // @ts-ignore - this.qb.orHavingExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or having not exists statement in your query. - */ - public orHavingNotExists(closure: (query: PostgresDriver) => void) { - const driver = this.clone() as PostgresDriver - - // @ts-ignore - this.qb.orHavingNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or having in statement in your query. - */ - public orHavingIn(column: string, values: any[]) { - // @ts-ignore - this.qb.orHavingIn(column, values) - - return this - } - - /** - * Set an or having not in statement in your query. - */ - public orHavingNotIn(column: string, values: any[]) { - this.qb.orHavingNotIn(column, values) - - return this - } - - /** - * Set an or having between statement in your query. - */ - public orHavingBetween(column: string, values: [any, any]) { - this.qb.orHavingBetween(column, values) - - return this - } - - /** - * Set an or having not between statement in your query. - */ - public orHavingNotBetween(column: string, values: [any, any]) { - this.qb.orHavingNotBetween(column, values) - - return this - } - - /** - * Set an or having null statement in your query. - */ - public orHavingNull(column: string) { - // @ts-ignore - this.qb.orHavingNull(column) - - return this - } - - /** - * Set an or having not null statement in your query. - */ - public orHavingNotNull(column: string) { - // @ts-ignore - this.qb.orHavingNotNull(column) - - return this - } - - public where(statement: Record): this - public where(key: string, value: any): this - public where(key: string, operation: Operations, value: any): this - - /** - * Set a where statement in your query. - */ - public where(statement: any, operation?: Operations, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.where(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (operation === undefined) { - if (Is.Array(statement)) { - throw new Error('Arrays as statement are not supported.') - } - - if (Is.String(statement)) { - throw new Error( - `The value for the "${statement}" column is undefined and where will not work.` - ) - } - - this.qb.where(statement) - - return this - } - - if (value === undefined) { - this.qb.where(statement, operation) - - return this - } - - this.qb.where(statement, operation, value) - - return this - } - - public whereNot(statement: Record): this - public whereNot(key: string, value: any): this - - /** - * Set a where not statement in your query. - */ - public whereNot(statement: any, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.whereNot(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (value === undefined) { - this.qb.whereNot(statement) - - return this - } - - this.qb.whereNot(statement, value) - - return this - } - - /** - * Set a where raw statement in your query. - */ - public whereRaw(sql: string, bindings?: any) { - this.qb.whereRaw(sql, bindings) - - return this - } - - /** - * Set a where exists statement in your query. - */ - public whereExists(closure: (query: PostgresDriver) => void) { - const driver = this.clone() as PostgresDriver - - this.qb.whereExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set a where not exists statement in your query. - */ - public whereNotExists(closure: (query: PostgresDriver) => void) { - const driver = this.clone() as PostgresDriver - - this.qb.whereNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set a where like statement in your query. - */ - public whereLike(column: string, value: any) { - this.qb.whereLike(column, value) - - return this - } - - /** - * Set a where ILike statement in your query. - */ - public whereILike(column: string, value: any) { - this.qb.whereILike(column, value) - - return this - } - - /** - * Set a where in statement in your query. - */ - public whereIn(column: string, values: any[]) { - this.qb.whereIn(column, values) - - return this - } - - /** - * Set a where not in statement in your query. - */ - public whereNotIn(column: string, values: any[]) { - this.qb.whereNotIn(column, values) - - return this - } - - /** - * Set a where between statement in your query. - */ - public whereBetween(column: string, values: [any, any]) { - this.qb.whereBetween(column, values) - - return this - } - - /** - * Set a where not between statement in your query. - */ - public whereNotBetween(column: string, values: [any, any]) { - this.qb.whereNotBetween(column, values) - - return this - } - - /** - * Set a where null statement in your query. - */ - public whereNull(column: string) { - this.qb.whereNull(column) - - return this - } - - /** - * Set a where not null statement in your query. - */ - public whereNotNull(column: string) { - this.qb.whereNotNull(column) - - return this - } - - public orWhere(statement: Record): this - public orWhere(key: string, value: any): this - public orWhere(key: string, operation: Operations, value: any): this - - /** - * Set a or where statement in your query. - */ - public orWhere(statement: any, operation?: Operations, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.orWhere(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (operation === undefined) { - this.qb.orWhere(statement) - - return this - } - - if (value === undefined) { - this.qb.orWhere(statement, operation) - - return this - } - - this.qb.orWhere(statement, operation, value) - - return this - } - - public orWhereNot(statement: Record): this - public orWhereNot(key: string, value: any): this - - /** - * Set an or where not statement in your query. - */ - public orWhereNot(statement: any, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.orWhereNot(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (value === undefined) { - this.qb.orWhereNot(statement) - - return this - } - - this.qb.orWhereNot(statement, value) - - return this - } - - /** - * Set a or where raw statement in your query. - */ - public orWhereRaw(sql: string, bindings?: any) { - this.qb.orWhereRaw(sql, bindings) - - return this - } - - /** - * Set an or where exists statement in your query. - */ - public orWhereExists(closure: (query: PostgresDriver) => void) { - const driver = this.clone() as PostgresDriver - - this.qb.orWhereExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or where not exists statement in your query. - */ - public orWhereNotExists(closure: (query: PostgresDriver) => void) { - const driver = this.clone() as PostgresDriver - - this.qb.orWhereNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or where like statement in your query. - */ - public orWhereLike(column: string, value: any) { - this.qb.orWhereLike(column, value) - - return this - } - - /** - * Set an or where ILike statement in your query. - */ - public orWhereILike(column: string, value: any) { - this.qb.orWhereILike(column, value) - - return this - } - - /** - * Set an or where in statement in your query. - */ - public orWhereIn(column: string, values: any[]) { - this.qb.orWhereIn(column, values) - - return this - } - - /** - * Set an or where not in statement in your query. - */ - public orWhereNotIn(column: string, values: any[]) { - this.qb.orWhereNotIn(column, values) - - return this - } - - /** - * Set an or where between statement in your query. - */ - public orWhereBetween(column: string, values: [any, any]) { - this.qb.orWhereBetween(column, values) - - return this - } - - /** - * Set an or where not between statement in your query. - */ - public orWhereNotBetween(column: string, values: [any, any]) { - this.qb.orWhereNotBetween(column, values) - - return this - } - - /** - * Set an or where null statement in your query. - */ - public orWhereNull(column: string) { - this.qb.orWhereNull(column) - - return this - } - - /** - * Set an or where not null statement in your query. - */ - public orWhereNotNull(column: string) { - this.qb.orWhereNotNull(column) - - return this - } - - /** - * Set an order by statement in your query. - */ - public orderBy(column: string, direction: Direction = 'ASC') { - this.qb.orderBy(column, direction.toUpperCase()) - - return this - } - - /** - * Set an order by raw statement in your query. - */ - public orderByRaw(sql: string, bindings?: any) { - this.qb.orderByRaw(sql, bindings) - - return this - } - - /** - * Order the results easily by the latest date. By default, the result will - * be ordered by the table's "createdAt" column. - */ - public latest(column: string = 'createdAt') { - return this.orderBy(column, 'DESC') - } - - /** - * Order the results easily by the oldest date. By default, the result will - * be ordered by the table's "createdAt" column. - */ - public oldest(column: string = 'createdAt') { - return this.orderBy(column, 'ASC') - } - - /** - * Set the skip number in your query. - */ - public offset(number: number) { - this.qb.offset(number) - - return this - } - - /** - * Set the limit number in your query. - */ - public limit(number: number) { - this.qb.limit(number) - - return this + return operators[operator] || operator } } diff --git a/src/database/drivers/SqliteDriver.ts b/src/database/drivers/SqliteDriver.ts index 4919c54..0e8e761 100644 --- a/src/database/drivers/SqliteDriver.ts +++ b/src/database/drivers/SqliteDriver.ts @@ -7,30 +7,17 @@ * file that was distributed with this source code. */ -/* eslint-disable @typescript-eslint/ban-ts-comment */ - -import { - Exec, - Is, - Json, - Options, - type PaginatedResponse, - type PaginationOptions -} from '@athenna/common' - -import type { Knex } from 'knex' import { debug } from '#src/debug' import { Log } from '@athenna/logger' -import { Driver } from '#src/database/drivers/Driver' +import type { Operations } from '#src/types' +import { Is, Json, Options } from '@athenna/common' import { ConnectionFactory } from '#src/factories/ConnectionFactory' -import { Transaction } from '#src/database/transactions/Transaction' -import type { ConnectionOptions, Direction, Operations } from '#src/types' -import { MigrationSource } from '#src/database/migrations/MigrationSource' +import { BaseKnexDriver } from '#src/database/drivers/BaseKnexDriver' +import type { ConnectionOptions } from '#src/types/ConnectionOptions' import { WrongMethodException } from '#src/exceptions/WrongMethodException' -import { PROTECTED_QUERY_METHODS } from '#src/constants/ProtectedQueryMethods' -import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' -export class SqliteDriver extends Driver { +export class SqliteDriver extends BaseKnexDriver { /** * Connect to database. */ @@ -86,113 +73,6 @@ export class SqliteDriver extends Driver { this.qb = this.query() } - /** - * Close the connection with database in this instance. - */ - public async close(): Promise { - if (!this.isConnected) { - return - } - - await this.client.destroy() - - this.qb = null - this.tableName = null - this.client = null - this.isConnected = false - - ConnectionFactory.setClient(this.connection, null) - } - - /** - * Creates a new instance of query builder. - */ - public query(): Knex.QueryBuilder { - if (!this.isConnected) { - throw new NotConnectedDatabaseException() - } - - const query = this.useSetQB - ? this.qb.table(this.tableName) - : this.client.queryBuilder().table(this.tableName) - - const handler = { - get: (target: Knex.QueryBuilder, propertyKey: string) => { - if (PROTECTED_QUERY_METHODS.includes(propertyKey)) { - this.qb = this.query() - } - - return target[propertyKey] - } - } - - return new Proxy(query, handler) - } - - /** - * Sync a model schema with database. - */ - public async sync(): Promise { - debug( - `database sync with ${SqliteDriver.name} is not available yet, use migration instead.` - ) - } - - /** - * Create a new transaction. - */ - public async startTransaction(): Promise< - Transaction - > { - const trx = await this.client.transaction() - - return new Transaction(this.clone().setClient(trx)) - } - - /** - * Commit the transaction. - */ - public async commitTransaction(): Promise { - const client = this.client as Knex.Transaction - - await client.commit() - - this.tableName = null - this.client = null - this.isConnected = false - } - - /** - * Rollback the transaction. - */ - public async rollbackTransaction(): Promise { - const client = this.client as Knex.Transaction - - await client.rollback() - - this.tableName = null - this.client = null - this.isConnected = false - } - - /** - * Run database migrations. - */ - public async runMigrations(): Promise { - await this.client.migrate.latest({ - migrationSource: new MigrationSource(this.connection) - }) - } - - /** - * Revert database migrations. - */ - public async revertMigrations(): Promise { - await this.client.migrate.rollback({ - migrationSource: new MigrationSource(this.connection) - }) - } - /** * List all databases available. */ @@ -202,22 +82,6 @@ export class SqliteDriver extends Driver { return databases.map(database => database.name) } - /** - * Get the current database name. - */ - public async getCurrentDatabase(): Promise { - return this.client.client.database() - } - - /** - * Verify if database exists. - */ - public async hasDatabase(database: string): Promise { - const databases = await this.getDatabases() - - return databases.includes(database) - } - /** * Create a new database. */ @@ -254,40 +118,6 @@ export class SqliteDriver extends Driver { return tables.map(table => table.name) } - /** - * Verify if table exists. - */ - public async hasTable(table: string): Promise { - return this.client.schema.hasTable(table) - } - - /** - * Create a new table in database. - */ - public async createTable( - table: string, - closure: (builder: Knex.TableBuilder) => void | Promise - ): Promise { - await this.client.schema.createTable(table, closure) - } - - /** - * Alter a table in database. - */ - public async alterTable( - table: string, - closure: (builder: Knex.TableBuilder) => void | Promise - ): Promise { - await this.client.schema.alterTable(table, closure) - } - - /** - * Drop a table in database. - */ - public async dropTable(table: string): Promise { - await this.client.schema.dropTableIfExists(table) - } - /** * Remove all data inside some database table * and restart the identity of the table. @@ -297,1011 +127,231 @@ export class SqliteDriver extends Driver { } /** - * Make a raw query in database. - */ - public raw(sql: string, bindings?: any): T { - return this.client.raw(sql, bindings) as any - } - - /** - * Calculate the average of a given column. - */ - public async avg(column: string): Promise { - const [{ avg }] = await this.qb.avg({ avg: column }) - - return avg - } - - /** - * Calculate the average of a given column using distinct. - */ - public async avgDistinct(column: string): Promise { - const [{ avg }] = await this.qb.avgDistinct({ avg: column }) - - return avg - } - - /** - * Get the max number of a given column. - */ - public async max(column: string): Promise { - const [{ max }] = await this.qb.max({ max: column }) - - return max - } - - /** - * Get the min number of a given column. - */ - public async min(column: string): Promise { - const [{ min }] = await this.qb.min({ min: column }) - - return min - } - - /** - * Sum all numbers of a given column. - */ - public async sum(column: string): Promise { - const [{ sum }] = await this.qb.sum({ sum: column }) - - return sum - } - - /** - * Sum all numbers of a given column in distinct mode. - */ - public async sumDistinct(column: string): Promise { - const [{ sum }] = await this.qb.sumDistinct({ sum: column }) - - return sum - } - - /** - * Increment a value of a given column. - */ - public async increment(column: string): Promise { - await this.qb.increment(column) - } - - /** - * Decrement a value of a given column. - */ - public async decrement(column: string): Promise { - await this.qb.decrement(column) - } - - /** - * Calculate the average of a given column using distinct. + * Create many values in database. */ - public async count(column: string = '*'): Promise { - const [{ count }] = await this.qb.count({ count: column }) - - return `${count}` - } + public async createMany(data: Partial[] = []): Promise { + if (!Is.Array(data)) { + throw new WrongMethodException('createMany', 'create') + } - /** - * Calculate the average of a given column using distinct. - */ - public async countDistinct(column: string): Promise { - const [{ count }] = await this.qb.countDistinct({ count: column }) + const preparedData = data.map(data => this.prepareInsert(data)) - return `${count}` + return this.qb.insert(preparedData, '*') } /** * Find a value in database. */ public async find(): Promise { - return this.qb.first() + const data = await super.find() + + return this.normalizeRow(data) } /** * Find many values in database. */ public async findMany(): Promise { - const data = await this.qb - - this.qb = this.query() - - return data - } - - /** - * Find many values in database and return as paginated response. - */ - public async paginate( - page: PaginationOptions | number = { page: 0, limit: 10, resourceUrl: '/' }, - limit = 10, - resourceUrl = '/' - ): Promise> { - if (Is.Number(page)) { - page = { page, limit, resourceUrl } - } - - const [{ count }] = await this.qb - .clone() - .clearOrder() - .clearSelect() - .count({ count: '*' }) - - const data = await this.offset(page.page * page.limit) - .limit(page.limit) - .findMany() - - return Exec.pagination(data, parseInt(count), page) - } - - /** - * Create a value in database. - */ - public async create(data: Partial = {}): Promise { - if (Is.Array(data)) { - throw new WrongMethodException('create', 'createMany') - } - - const created = await this.createMany([data]) + const data = await super.findMany() - return created[0] + return data.map(row => this.normalizeRow(row)) } - /** - * Create many values in database. - */ - public async createMany(data: Partial[] = []): Promise { - if (!Is.Array(data)) { - throw new WrongMethodException('createMany', 'create') - } - - return this.qb.insert(data, '*') - } + public whereJson(column: string, value: any): this + public whereJson(column: string, operation: Operations, value: any): this /** - * Create data or update if already exists. + * Set a where json statement in your query. */ - public async createOrUpdate( - data: Partial = {} - ): Promise { - const query = this.qb.clone() - const hasValue = await query.first() - - if (hasValue) { - await this.qb - .where(this.primaryKey, hasValue[this.primaryKey]) - .update(data) - - return this.where(this.primaryKey, hasValue[this.primaryKey]).find() + public whereJson(column: string, operator: any, value?: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('whereJson') } - return this.create(data) - } - - /** - * Update a value in database. - */ - public async update(data: Partial): Promise { - await this.qb.clone().update(data) - - const result = await this.findMany() + const parsed = this.parseJsonSelector(column) - if (result.length === 1) { - return result[0] + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) } - return result - } + const normalized = this.normalizeJsonOperation(operator, value) - /** - * Delete one value in database. - */ - public async delete(): Promise { - await this.qb.delete() - } + if (!parsed.path.includes('*')) { + this.qb.whereRaw('json_extract(??, ?) ' + normalized.operator + ' ?', [ + parsed.column, + this.parseJsonSelectorToSqlitePath(parsed.path), + normalized.value + ]) - /** - * Set the table that this query will be executed. - */ - public table(table: string) { - if (!this.isConnected) { - throw new NotConnectedDatabaseException() + return this } - this.tableName = table - this.qb = this.query() - - return this - } + const wildcard = this.parseJsonSelectorToWildcardParts(parsed.path) - /** - * Log in console the actual query built. - */ - public dump() { - console.log(this.qb.toSQL().toNative()) + this.qb.whereRaw( + 'exists (select 1 from json_each(??, ?) where json_extract(json_each.value, ?) ' + + normalized.operator + + ' ?)', + [parsed.column, wildcard.arrayPath, wildcard.valuePath, normalized.value] + ) return this } - /** - * Set the columns that should be selected on query. - */ - public select(...columns: string[]) { - this.qb.select(...columns) - - return this - } + public orWhereJson(column: string, value: any): this + public orWhereJson(column: string, operation: Operations, value: any): this /** - * Set the columns that should be selected on query raw. + * Set an or where json statement in your query. */ - public selectRaw(sql: string, bindings?: any) { - return this.select(this.raw(sql, bindings) as any) - } + public orWhereJson(column: string, operator: any, value?: any) { + if (Is.Undefined(column) || !Is.String(column)) { + throw new EmptyColumnException('orWhereJson') + } - /** - * Set the table that should be used on query. - * Different from `table()` method, this method - * doesn't change the driver table. - */ - public from(table: string) { - this.qb.from(table) + const parsed = this.parseJsonSelector(column) - return this - } + if (!parsed) { + throw new Error(`Invalid JSON selector: ${column}`) + } - /** - * Set the table that should be used on query raw. - * Different from `table()` method, this method - * doesn't change the driver table. - */ - public fromRaw(sql: string, bindings?: any) { - return this.from(this.raw(sql, bindings) as any) - } + const normalized = this.normalizeJsonOperation(operator, value) - /** - * Set a join statement in your query. - */ - public join( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('join', table, column1, operation, column2) - } + if (!parsed.path.includes('*')) { + this.qb.orWhereRaw('json_extract(??, ?) ' + normalized.operator + ' ?', [ + parsed.column, + this.parseJsonSelectorToSqlitePath(parsed.path), + normalized.value + ]) - /** - * Set a left join statement in your query. - */ - public leftJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('leftJoin', table, column1, operation, column2) - } + return this + } - /** - * Set a right join statement in your query. - */ - public rightJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('rightJoin', table, column1, operation, column2) - } + const wildcard = this.parseJsonSelectorToWildcardParts(parsed.path) - /** - * Set a cross join statement in your query. - */ - public crossJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('crossJoin', table, column1, operation, column2) - } + this.qb.orWhereRaw( + 'exists (select 1 from json_each(??, ?) where json_extract(json_each.value, ?) ' + + normalized.operator + + ' ?)', + [parsed.column, wildcard.arrayPath, wildcard.valuePath, normalized.value] + ) - /** - * Set a full outer join statement in your query. - */ - public fullOuterJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('fullOuterJoin', table, column1, operation, column2) + return this } /** - * Set a left outer join statement in your query. + * Convert a json selector path to sqlite json path. */ - public leftOuterJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('leftOuterJoin', table, column1, operation, column2) - } + private parseJsonSelectorToSqlitePath(path: string) { + const parts = path + .split('->') + .map(part => part.trim()) + .filter(Boolean) - /** - * Set a right outer join statement in your query. - */ - public rightOuterJoin( - table: any, - column1?: any, - operation?: any | Operations, - column2?: any - ) { - return this.joinByType('rightOuterJoin', table, column1, operation, column2) + return this.toJsonPath(parts) } /** - * Set a join raw statement in your query. + * Split a json selector around the wildcard. */ - public joinRaw(sql: string, bindings?: any) { - this.qb.joinRaw(sql, bindings) - - return this - } + private parseJsonSelectorToWildcardParts(path: string) { + const parts = path + .split('->') + .map(part => part.trim()) + .filter(Boolean) - /** - * Set a group by statement in your query. - */ - public groupBy(...columns: string[]) { - this.qb.groupBy(...columns) + const wildcardIndex = parts.indexOf('*') - return this + return { + arrayPath: this.toJsonPath(parts.slice(0, wildcardIndex)), + valuePath: this.toJsonPath(parts.slice(wildcardIndex + 1)) + } } /** - * Set a group by raw statement in your query. + * Convert path parts to a valid json path. */ - public groupByRaw(sql: string, bindings?: any) { - this.qb.groupByRaw(sql, bindings) + private toJsonPath(parts: string[]) { + return parts.reduce((jsonPath, part) => { + if (/^\d+$/.test(part)) { + return `${jsonPath}[${part}]` + } - return this + return `${jsonPath}.${part}` + }, '$') } - public having(column: string): this - public having(column: string, value: any): this - public having(column: string, operation: Operations, value: any): this - /** - * Set a having statement in your query. + * Normalize operator/value pairs from the whereJson overloads. */ - public having(column: any, operation?: Operations, value?: any) { - if (operation === undefined) { - this.qb.having(column) - - return this + private normalizeJsonOperation(operator: any, value?: any) { + if (Is.Undefined(value)) { + return { + operator: '=', + value: operator + } } - if (value === undefined) { - this.qb.having(column, '=', operation) - - return this + return { + operator, + value } - - this.qb.having(column, operation, value) - - return this - } - - /** - * Set a having raw statement in your query. - */ - public havingRaw(sql: string, bindings?: any) { - this.qb.havingRaw(sql, bindings) - - return this } /** - * Set a having exists statement in your query. + * Normalize json strings returned by sqlite into arrays/objects. */ - public havingExists(closure: (query: SqliteDriver) => void) { - const driver = this.clone() as SqliteDriver + private normalizeRow(row: T): T { + if (!row || !Is.Object(row)) { + return row + } - // @ts-ignore - this.qb.havingExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) + return Object.entries(row).reduce((normalized, [key, value]) => { + normalized[key] = this.normalizeJsonValue(value) - return this + return normalized + }, {} as T) } /** - * Set a having not exists statement in your query. + * Parse stringified json objects/arrays returned by sqlite. */ - public havingNotExists(closure: (query: SqliteDriver) => void) { - const driver = this.clone() as SqliteDriver - - // @ts-ignore - this.qb.havingNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) + private normalizeJsonValue(value: any) { + if (!Is.String(value)) { + return value + } - return this - } + const trimmed = value.trim() - /** - * Set a having in statement in your query. - */ - public havingIn(column: string, values: any[]) { - this.qb.havingIn(column, values) + if ( + !(trimmed.startsWith('{') && trimmed.endsWith('}')) && + !(trimmed.startsWith('[') && trimmed.endsWith(']')) + ) { + return value + } - return this + try { + return JSON.parse(trimmed) + } catch { + return value + } } /** - * Set a having not in statement in your query. + * Set a where ILike statement in your query. */ - public havingNotIn(column: string, values: any[]) { - this.qb.havingNotIn(column, values) + public whereILike(column: string, value: any) { + this.qb.whereLike(column, value) return this } /** - * Set a having between statement in your query. - */ - public havingBetween(column: string, values: [any, any]) { - this.qb.havingBetween(column, values) - - return this - } - - /** - * Set a having not between statement in your query. - */ - public havingNotBetween(column: string, values: [any, any]) { - this.qb.havingNotBetween(column, values) - - return this - } - - /** - * Set a having null statement in your query. - */ - public havingNull(column: string) { - this.qb.havingNull(column) - - return this - } - - /** - * Set a having not null statement in your query. - */ - public havingNotNull(column: string) { - this.qb.havingNotNull(column) - - return this - } - - public orHaving(column: string): this - public orHaving(column: string, value: any): this - public orHaving(column: string, operation: Operations, value: any): this - - /** - * Set an or having statement in your query. - */ - public orHaving(column: any, operation?: Operations, value?: any) { - if (operation === undefined) { - this.qb.orHaving(column) - - return this - } - - if (value === undefined) { - this.qb.orHaving(column, '=', operation) - - return this - } - - this.qb.orHaving(column, operation, value) - - return this - } - - /** - * Set an or having raw statement in your query. - */ - public orHavingRaw(sql: string, bindings?: any) { - this.qb.orHavingRaw(sql, bindings) - - return this - } - - /** - * Set an or having exists statement in your query. - */ - public orHavingExists(closure: (query: SqliteDriver) => void) { - const driver = this.clone() as SqliteDriver - - // @ts-ignore - this.qb.orHavingExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or having not exists statement in your query. - */ - public orHavingNotExists(closure: (query: SqliteDriver) => void) { - const driver = this.clone() as SqliteDriver - - // @ts-ignore - this.qb.orHavingNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or having in statement in your query. - */ - public orHavingIn(column: string, values: any[]) { - // @ts-ignore - this.qb.orHavingIn(column, values) - - return this - } - - /** - * Set an or having not in statement in your query. - */ - public orHavingNotIn(column: string, values: any[]) { - this.qb.orHavingNotIn(column, values) - - return this - } - - /** - * Set an or having between statement in your query. - */ - public orHavingBetween(column: string, values: [any, any]) { - this.qb.orHavingBetween(column, values) - - return this - } - - /** - * Set an or having not between statement in your query. - */ - public orHavingNotBetween(column: string, values: [any, any]) { - this.qb.orHavingNotBetween(column, values) - - return this - } - - /** - * Set an or having null statement in your query. - */ - public orHavingNull(column: string) { - // @ts-ignore - this.qb.orHavingNull(column) - - return this - } - - /** - * Set an or having not null statement in your query. - */ - public orHavingNotNull(column: string) { - // @ts-ignore - this.qb.orHavingNotNull(column) - - return this - } - - public where(statement: Record): this - public where(key: string, value: any): this - public where(key: string, operation: Operations, value: any): this - - /** - * Set a where statement in your query. - */ - public where(statement: any, operation?: Operations, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.where(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (operation === undefined) { - if (Is.Array(statement)) { - throw new Error('Arrays as statement are not supported.') - } - - if (Is.String(statement)) { - throw new Error( - `The value for the "${statement}" column is undefined and where will not work.` - ) - } - - this.qb.where(statement) - - return this - } - - if (value === undefined) { - this.qb.where(statement, operation) - - return this - } - - this.qb.where(statement, operation, value) - - return this - } - - public whereNot(statement: Record): this - public whereNot(key: string, value: any): this - - /** - * Set a where not statement in your query. - */ - public whereNot(statement: any, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.whereNot(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (value === undefined) { - this.qb.whereNot(statement) - - return this - } - - this.qb.whereNot(statement, value) - - return this - } - - /** - * Set a where raw statement in your query. - */ - public whereRaw(sql: string, bindings?: any) { - this.qb.whereRaw(sql, bindings) - - return this - } - - /** - * Set a where exists statement in your query. - */ - public whereExists(closure: (query: SqliteDriver) => void) { - const driver = this.clone() as SqliteDriver - - this.qb.whereExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set a where not exists statement in your query. - */ - public whereNotExists(closure: (query: SqliteDriver) => void) { - const driver = this.clone() as SqliteDriver - - this.qb.whereNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set a where like statement in your query. - */ - public whereLike(column: string, value: any) { - this.qb.whereLike(column, value) - - return this - } - - /** - * Set a where ILike statement in your query. - */ - public whereILike(column: string, value: any) { - this.qb.whereLike(column, value) - - return this - } - - /** - * Set a where in statement in your query. - */ - public whereIn(column: string, values: any[]) { - this.qb.whereIn(column, values) - - return this - } - - /** - * Set a where not in statement in your query. - */ - public whereNotIn(column: string, values: any[]) { - this.qb.whereNotIn(column, values) - - return this - } - - /** - * Set a where between statement in your query. - */ - public whereBetween(column: string, values: [any, any]) { - this.qb.whereBetween(column, values) - - return this - } - - /** - * Set a where not between statement in your query. - */ - public whereNotBetween(column: string, values: [any, any]) { - this.qb.whereNotBetween(column, values) - - return this - } - - /** - * Set a where null statement in your query. - */ - public whereNull(column: string) { - this.qb.whereNull(column) - - return this - } - - /** - * Set a where not null statement in your query. - */ - public whereNotNull(column: string) { - this.qb.whereNotNull(column) - - return this - } - - public orWhere(statement: Record): this - public orWhere(key: string, value: any): this - public orWhere(key: string, operation: Operations, value: any): this - - /** - * Set a or where statement in your query. - */ - public orWhere(statement: any, operation?: Operations, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.orWhere(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (operation === undefined) { - this.qb.orWhere(statement) - - return this - } - - if (value === undefined) { - this.qb.orWhere(statement, operation) - - return this - } - - this.qb.orWhere(statement, operation, value) - - return this - } - - public orWhereNot(statement: Record): this - public orWhereNot(key: string, value: any): this - - /** - * Set an or where not statement in your query. - */ - public orWhereNot(statement: any, value?: any) { - if (Is.Function(statement)) { - const driver = this.clone() - - this.qb.orWhereNot(function () { - statement(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - if (value === undefined) { - this.qb.orWhereNot(statement) - - return this - } - - this.qb.orWhereNot(statement, value) - - return this - } - - /** - * Set a or where raw statement in your query. - */ - public orWhereRaw(sql: string, bindings?: any) { - this.qb.orWhereRaw(sql, bindings) - - return this - } - - /** - * Set an or where exists statement in your query. - */ - public orWhereExists(closure: (query: SqliteDriver) => void) { - const driver = this.clone() as SqliteDriver - - this.qb.orWhereExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or where not exists statement in your query. - */ - public orWhereNotExists(closure: (query: SqliteDriver) => void) { - const driver = this.clone() as SqliteDriver - - this.qb.orWhereNotExists(function () { - closure(driver.setQueryBuilder(this, { useSetQB: true })) - }) - - return this - } - - /** - * Set an or where like statement in your query. - */ - public orWhereLike(column: string, value: any) { - this.qb.orWhereLike(column, value) - - return this - } - - /** - * Set an or where ILike statement in your query. + * Set a where ILike statement in your query. */ public orWhereILike(column: string, value: any) { this.qb.orWhereLike(column, value) return this } - - /** - * Set an or where in statement in your query. - */ - public orWhereIn(column: string, values: any[]) { - this.qb.orWhereIn(column, values) - - return this - } - - /** - * Set an or where not in statement in your query. - */ - public orWhereNotIn(column: string, values: any[]) { - this.qb.orWhereNotIn(column, values) - - return this - } - - /** - * Set an or where between statement in your query. - */ - public orWhereBetween(column: string, values: [any, any]) { - this.qb.orWhereBetween(column, values) - - return this - } - - /** - * Set an or where not between statement in your query. - */ - public orWhereNotBetween(column: string, values: [any, any]) { - this.qb.orWhereNotBetween(column, values) - - return this - } - - /** - * Set an or where null statement in your query. - */ - public orWhereNull(column: string) { - this.qb.orWhereNull(column) - - return this - } - - /** - * Set an or where not null statement in your query. - */ - public orWhereNotNull(column: string) { - this.qb.orWhereNotNull(column) - - return this - } - - /** - * Set an order by statement in your query. - */ - public orderBy(column: string, direction: Direction = 'ASC') { - this.qb.orderBy(column, direction.toUpperCase()) - - return this - } - - /** - * Set an order by raw statement in your query. - */ - public orderByRaw(sql: string, bindings?: any) { - this.qb.orderByRaw(sql, bindings) - - return this - } - - /** - * Order the results easily by the latest date. By default, the result will - * be ordered by the table's "createdAt" column. - */ - public latest(column: string = 'createdAt') { - return this.orderBy(column, 'DESC') - } - - /** - * Order the results easily by the oldest date. By default, the result will - * be ordered by the table's "createdAt" column. - */ - public oldest(column: string = 'createdAt') { - return this.orderBy(column, 'ASC') - } - - /** - * Set the skip number in your query. - */ - public offset(number: number) { - this.qb.offset(number) - - return this - } - - /** - * Set the limit number in your query. - */ - public limit(number: number) { - this.qb.limit(number) - - return this - } } diff --git a/src/exceptions/EmptyColumnException.ts b/src/exceptions/EmptyColumnException.ts new file mode 100644 index 0000000..e85a46d --- /dev/null +++ b/src/exceptions/EmptyColumnException.ts @@ -0,0 +1,22 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Exception } from '@athenna/common' + +export class EmptyColumnException extends Exception { + public constructor(method: string) { + const message = `The column set in your query builder ${method} method is empty or with wrong type.` + + super({ + message, + code: 'E_EMPTY_COLUMN_ERROR', + help: `You must set a column value to perform the ${method}() operation. Accepted values are only strings and objects.` + }) + } +} diff --git a/src/exceptions/EmptyValueException.ts b/src/exceptions/EmptyValueException.ts new file mode 100644 index 0000000..87319e8 --- /dev/null +++ b/src/exceptions/EmptyValueException.ts @@ -0,0 +1,22 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Exception } from '@athenna/common' + +export class EmptyValueException extends Exception { + public constructor(method: string) { + const message = `The value set in your query builder ${method}() operation is undefined.` + + super({ + message, + code: 'E_EMPTY_VALUE_ERROR', + help: `You must set a value to perform the ${method}() operation, undefined is not supported.` + }) + } +} diff --git a/src/models/BaseModel.ts b/src/models/BaseModel.ts index db90275..e64a3ef 100644 --- a/src/models/BaseModel.ts +++ b/src/models/BaseModel.ts @@ -18,9 +18,9 @@ import { } from '@athenna/common' import { Database } from '#src/facades/Database' -import type { ModelRelations } from '#src/types' import { faker, type Faker } from '@faker-js/faker' import { ModelSchema } from '#src/models/schemas/ModelSchema' +import type { ModelColumns, ModelRelations } from '#src/types' import { ORIGINAL_SYMBOL } from '#src/constants/OriginalSymbol' import { ModelFactory } from '#src/models/factories/ModelFactory' import { ModelGenerator } from '#src/models/factories/ModelGenerator' @@ -203,12 +203,24 @@ export class BaseModel { */ public static async pluck< T extends typeof BaseModel, - K extends keyof InstanceType = keyof InstanceType + K extends Extract>, keyof InstanceType> >( this: T, key: K, where?: Partial> - ): Promise[K]> { + ): Promise[K]> + + public static async pluck( + this: T, + key: ModelColumns>, + where?: Partial> + ): Promise + + public static async pluck( + this: T, + key: any, + where?: Partial> + ): Promise { const query = this.query() if (where) { @@ -224,12 +236,24 @@ export class BaseModel { */ public static async pluckMany< T extends typeof BaseModel, - K extends keyof InstanceType = keyof InstanceType + K extends Extract>, keyof InstanceType> >( this: T, key: K, where?: Partial> - ): Promise[K][]> { + ): Promise[K][]> + + public static async pluckMany( + this: T, + key: ModelColumns>, + where?: Partial> + ): Promise + + public static async pluckMany( + this: T, + key: any, + where?: Partial> + ): Promise { const query = this.query() if (where) { @@ -461,8 +485,9 @@ export class BaseModel { */ public setOriginal() { this[ORIGINAL_SYMBOL] = {} + const copied = Json.copy(this) - Object.keys(Json.copy(this)).forEach(key => { + Object.keys(copied).forEach(key => { const value = this[key] if (Is.Array(value) && value[0] && ORIGINAL_SYMBOL in value[0]) { @@ -473,7 +498,7 @@ export class BaseModel { return } - this[ORIGINAL_SYMBOL][key] = value + this[ORIGINAL_SYMBOL][key] = copied[key] }) return this diff --git a/src/models/builders/ModelQueryBuilder.ts b/src/models/builders/ModelQueryBuilder.ts index 31d9e9f..f58d456 100644 --- a/src/models/builders/ModelQueryBuilder.ts +++ b/src/models/builders/ModelQueryBuilder.ts @@ -8,9 +8,9 @@ */ import { - Collection, Is, Options, + Collection, type PaginationOptions } from '@athenna/common' @@ -181,7 +181,7 @@ export class ModelQueryBuilder< /** * Calculate the average of a given column using distinct. */ - public async count(column?: ModelColumns): Promise { + public async count(column?: ModelColumns): Promise { this.setInternalQueries() if (!column) { @@ -196,7 +196,7 @@ export class ModelQueryBuilder< /** * Calculate the average of a given column using distinct. */ - public async countDistinct(column: ModelColumns): Promise { + public async countDistinct(column: ModelColumns): Promise { this.setInternalQueries() const name = this.schema.getColumnNameByProperty(column) @@ -208,9 +208,13 @@ export class ModelQueryBuilder< * Find value in database but returns only the value of * selected column directly. */ - public async pluck>( + public async pluck, keyof M>>( column: K - ): Promise { + ): Promise + + public async pluck(column: ModelColumns): Promise + + public async pluck(column: any): Promise { this.setInternalQueries() const columnName: any = this.schema.getColumnNameByProperty(column as any) @@ -222,9 +226,13 @@ export class ModelQueryBuilder< * Find many values in database but returns only the * values of selected column directly. */ - public async pluckMany>( + public async pluckMany, keyof M>>( column: K - ): Promise { + ): Promise + + public async pluckMany(column: ModelColumns): Promise + + public async pluckMany(column: any): Promise { this.setInternalQueries() const columnName: any = this.schema.getColumnNameByProperty(column as any) @@ -741,17 +749,6 @@ export class ModelQueryBuilder< return this } - /** - * Set a orHaving in statement in your query. - */ - public orHavingIn(column: ModelColumns, values: any[]) { - const name = this.schema.getColumnNameByProperty(column) - - super.orHavingIn(name, values) - - return this - } - /** * Set a orHaving not in statement in your query. */ @@ -974,6 +971,24 @@ export class ModelQueryBuilder< return this } + public whereJson(column: ModelColumns, value: any): this + public whereJson( + column: ModelColumns, + operation: Operations, + value: any + ): this + + /** + * Set a where json statement in your query. + */ + public whereJson(column: ModelColumns, operation: any, value?: any) { + const name = this.schema.getColumnNameByProperty(column) + + super.whereJson(name, operation, value) + + return this + } + public orWhere(statement: (query: this) => void): this public orWhere(statement: Partial): this public orWhere(statement: Record): this @@ -1165,6 +1180,28 @@ export class ModelQueryBuilder< return this } + public orWhereJson(column: ModelColumns, value: any): this + public orWhereJson( + column: ModelColumns, + operation: Operations, + value: any + ): this + + /** + * Set an orWhereJson statement in your query. + */ + public orWhereJson( + column: ModelColumns, + operation: Operations, + value?: any + ) { + const name = this.schema.getColumnNameByProperty(column) + + super.orWhereJson(name, operation, value) + + return this + } + /** * Set an order by statement in your query. */ diff --git a/src/types/columns/ModelColumns.ts b/src/types/columns/ModelColumns.ts index 922f5f7..4ec89d2 100644 --- a/src/types/columns/ModelColumns.ts +++ b/src/types/columns/ModelColumns.ts @@ -9,6 +9,8 @@ import type { BaseModel } from '#src/models/BaseModel' +type UnsafeColumnSelector = `${string}.${string}` | `${string}->${string}` + export type ColumnKeys = { [K in keyof T]: T[K] extends BaseModel | BaseModel[] ? never : K }[keyof Omit< @@ -27,4 +29,6 @@ export type ColumnKeys = { | 'toJSON' >] -export type ModelColumns = Extract, string> +export type ModelColumns = + | Extract, string> + | UnsafeColumnSelector diff --git a/tests/fixtures/models/User.ts b/tests/fixtures/models/User.ts index 18ac3c7..25b9467 100644 --- a/tests/fixtures/models/User.ts +++ b/tests/fixtures/models/User.ts @@ -48,6 +48,9 @@ export class User extends BaseModel { @Column({ isHidden: true }) public metadata3: string + @Column({ persist: false }) + public metadata4: { name: string; age: number; address: { city: string; state: string } } + @Column({ name: 'rate_number' }) public rate: number diff --git a/tests/unit/database/builders/QueryBuilderTest.ts b/tests/unit/database/builders/QueryBuilderTest.ts index 4db31d2..a649e2c 100644 --- a/tests/unit/database/builders/QueryBuilderTest.ts +++ b/tests/unit/database/builders/QueryBuilderTest.ts @@ -599,32 +599,6 @@ export default class QueryBuilderTest { assert.calledOnceWith(FakeDriver.havingRaw, sql) } - @Test() - public async shouldAddAHavingExistsClauseToTheQuery({ assert }: Context) { - const closure = query => { - query.table('profiles').select('*').whereRaw('users.account_id = accounts.id') - } - Mock.when(FakeDriver, 'havingExists').resolve(undefined) - - const queryBuilder = new QueryBuilder(FakeDriver, 'users') - queryBuilder.havingExists(closure) - - assert.calledOnce(FakeDriver.havingExists) - } - - @Test() - public async shouldAddAHavingNotExistsClauseToTheQuery({ assert }: Context) { - const closure = query => { - query.table('profiles').select('*').whereRaw('users.account_id = accounts.id') - } - Mock.when(FakeDriver, 'havingNotExists').resolve(undefined) - - const queryBuilder = new QueryBuilder(FakeDriver, 'users') - queryBuilder.havingNotExists(closure) - - assert.calledOnce(FakeDriver.havingNotExists) - } - @Test() public async shouldAddAHavingInClauseToTheQuery({ assert }: Context) { const column = 'id' @@ -716,56 +690,6 @@ export default class QueryBuilderTest { assert.calledOnce(FakeDriver.orHavingRaw) } - @Test() - public async shouldAddAnOrHavingExistsClauseToTheQuery({ assert }: Context) { - const closure = query => { - query.table('profiles').select('*').whereRaw('users.account_id = accounts.id') - } - Mock.when(FakeDriver, 'orHavingExists').resolve(undefined) - - const queryBuilder = new QueryBuilder(FakeDriver, 'users') - queryBuilder.orHavingExists(closure) - - assert.calledOnce(FakeDriver.orHavingExists) - } - - @Test() - public async shouldAddAnOrHavingNotExistsClauseToTheQuery({ assert }: Context) { - const closure = query => { - query.table('profiles').select('*').whereRaw('users.account_id = accounts.id') - } - Mock.when(FakeDriver, 'orHavingNotExists').resolve(undefined) - - const queryBuilder = new QueryBuilder(FakeDriver, 'users') - queryBuilder.orHavingNotExists(closure) - - assert.calledOnce(FakeDriver.orHavingNotExists) - } - - @Test() - public async shouldAddAnOrHavingInClauseToTheQuery({ assert }: Context) { - const column = 'id' - const values = [1, 2, 3] - Mock.when(FakeDriver, 'orHavingIn').resolve(undefined) - - const queryBuilder = new QueryBuilder(FakeDriver, 'users') - queryBuilder.orHavingIn(column, values) - - assert.calledOnce(FakeDriver.orHavingIn) - } - - @Test() - public async shouldAddAnOrHavingNotInClauseToTheQuery({ assert }: Context) { - const column = 'id' - const values = [1, 2, 3] - Mock.when(FakeDriver, 'orHavingNotIn').resolve(undefined) - - const queryBuilder = new QueryBuilder(FakeDriver, 'users') - queryBuilder.orHavingNotIn(column, values) - - assert.calledOnce(FakeDriver.orHavingNotIn) - } - @Test() public async shouldAddAnOrHavingBetweenClauseToTheQuery({ assert }: Context) { const column = 'id' diff --git a/tests/unit/drivers/MongoDriverTest.ts b/tests/unit/drivers/MongoDriverTest.ts index 4440dd3..97189f1 100644 --- a/tests/unit/drivers/MongoDriverTest.ts +++ b/tests/unit/drivers/MongoDriverTest.ts @@ -11,7 +11,9 @@ import { Config } from '@athenna/config' import { Path, Sleep, Collection } from '@athenna/common' import { MongoDriver } from '#src/database/drivers/MongoDriver' import { ConnectionFactory } from '#src/factories/ConnectionFactory' +import { EmptyValueException } from '#src/exceptions/EmptyValueException' import { WrongMethodException } from '#src/exceptions/WrongMethodException' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' import { NotFoundDataException } from '#src/exceptions/NotFoundDataException' import { Test, Mock, AfterEach, BeforeEach, type Context } from '@athenna/test' import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException' @@ -46,6 +48,7 @@ export default class MongoDriverTest { await this.driver.dropTable('products') await this.driver.dropTable('users') await this.driver.dropTable('orders') + await this.driver.dropTable('events') await this.driver.dropTable('migrations') await this.driver.dropTable('migrations_lock') @@ -555,7 +558,7 @@ export default class MongoDriverTest { const result = await this.driver.table('products').countDistinct('quantity') - assert.equal(result, '1') + assert.equal(result, 1) } @Test() @@ -857,7 +860,7 @@ export default class MongoDriverTest { await this.driver.table('users').create(data) - assert.deepEqual(await this.driver.table('users').count(), '1') + assert.deepEqual(await this.driver.table('users').count(), 1) } @Test() @@ -867,13 +870,18 @@ export default class MongoDriverTest { await assert.rejects(() => this.driver.table('users'), NotConnectedDatabaseException) } + @Test() + public async shouldThrowWhenTryingToSetInvalidTableName({ assert }: Context) { + assert.throws(() => this.driver.table(undefined as any), Error) + } + @Test() public async shouldBeAbleToDumpTheSQLQuery({ assert }: Context) { - Mock.when(console, 'log').return(undefined) + Mock.when(process.stdout, 'write').return(undefined) this.driver.table('users').select('*').dump() - assert.calledWith(console.log, { where: [], orWhere: [], pipeline: [] }) + assert.calledWith(process.stdout.write, `${JSON.stringify({ where: [], orWhere: [], pipeline: [] })}\n`) } @Test() @@ -1200,18 +1208,44 @@ export default class MongoDriverTest { } @Test() - public async shouldThrowNotImplementedExceptionWhenTryingToRunHavingRaw({ assert }: Context) { - await assert.rejects(() => this.driver.havingRaw(), NotImplementedMethodException) + public async shouldBeAbleToFilterJsonByIndexUsingWhereJson({ assert }: Context) { + await this.driver.table('events').createMany([ + { _id: '1', metadata: { name: 'admin' } }, + { _id: '2', metadata: { name: 'member' } } + ]) + + const data = await this.driver.table('events').whereJson('metadata->name', 'admin').findMany() + + assert.deepEqual(data, [{ _id: '1', metadata: { name: 'admin' } }]) + } + + @Test() + public async shouldBeAbleToFilterJsonArrayByIndexUsingWhereJson({ assert }: Context) { + await this.driver.table('events').createMany([ + { _id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { _id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('events').whereJson('metadata->1->name', 'editor').findMany() + + assert.deepEqual(data, [{ _id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }]) } @Test() - public async shouldThrowNotImplementedExceptionWhenTryingToRunHavingExistsMethod({ assert }: Context) { - await assert.rejects(() => this.driver.havingExists(), NotImplementedMethodException) + public async shouldBeAbleToFilterJsonArrayByWildcardUsingWhereSelector({ assert }: Context) { + await this.driver.table('events').createMany([ + { _id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { _id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('events').where('metadata->*->name', 'member').findMany() + + assert.deepEqual(data, [{ _id: '2', metadata: [{ name: 'user' }, { name: 'member' }] }]) } @Test() - public async shouldThrowNotImplementedExceptionWhenTryingToRunHavingNotExistsMethod({ assert }: Context) { - await assert.rejects(() => this.driver.havingNotExists(), NotImplementedMethodException) + public async shouldThrowNotImplementedExceptionWhenTryingToRunHavingRaw({ assert }: Context) { + await assert.rejects(() => this.driver.havingRaw(), NotImplementedMethodException) } @Test() @@ -1423,42 +1457,6 @@ export default class MongoDriverTest { await assert.rejects(() => this.driver.orHavingRaw(), NotImplementedMethodException) } - @Test() - public async shouldThrowNotImplementedExceptionWhenTryingToRunOrHavingExistsMethod({ assert }: Context) { - await assert.rejects(() => this.driver.orHavingExists(), NotImplementedMethodException) - } - - @Test() - public async shouldThrowNotImplementedExceptionWhenTryingToRunOrHavingNotExistsMethod({ assert }: Context) { - await assert.rejects(() => this.driver.orHavingNotExists(), NotImplementedMethodException) - } - - @Test() - public async shouldBeAbleToAddAOrHavingInClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { _id: '1', name: 'Robert Kiyosaki' }, - { _id: '2', name: 'Warren Buffet' }, - { _id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { _id: '1', user_id: '1' }, - { _id: '2', user_id: '1' }, - { _id: '3', user_id: '1' }, - { _id: '4', user_id: '1' }, - { _id: '5', user_id: '2' }, - { _id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('_id', 'name') - .groupBy('_id', 'name') - .orHavingIn('name', ['Alan Turing']) - .findMany() - - assert.deepEqual(data, [{ _id: '3', name: 'Alan Turing' }]) - } - @Test() public async shouldBeAbleToAddAOrHavingNotInClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -1634,6 +1632,54 @@ export default class MongoDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldBeAbleToAddAWhereJsonClauseToTheQueryUsingDriver({ assert }: Context) { + await this.driver.table('users').createMany([ + { _id: '1', metadata: { roles: ['admin', 'editor'] } }, + { _id: '2', metadata: { roles: ['member'] } } + ]) + + const data = await this.driver.table('users').whereJson('metadata->roles->0', 'admin').findMany() + + assert.deepEqual(data, [{ _id: '1', metadata: { roles: ['admin', 'editor'] } }]) + } + + @Test() + public async shouldBeAbleToUseJsonSelectorDirectlyInsideWhereUsingDriver({ assert }: Context) { + await this.driver.table('users').createMany([ + { _id: '1', metadata: { roles: ['admin', 'editor'] } }, + { _id: '2', metadata: { roles: ['member'] } } + ]) + + const data = await this.driver.table('users').where('metadata->roles->0', 'admin').findMany() + + assert.deepEqual(data, [{ _id: '1', metadata: { roles: ['admin', 'editor'] } }]) + } + + @Test() + public async shouldBeAbleToUseArrayIndexInJsonSelectorInsideWhereUsingDriver({ assert }: Context) { + await this.driver.table('users').createMany([ + { _id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { _id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('users').where('metadata->1->name', 'editor').findMany() + + assert.deepEqual(data, [{ _id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }]) + } + + @Test() + public async shouldBeAbleToUseArrayWildcardInJsonSelectorInsideWhereUsingDriver({ assert }: Context) { + await this.driver.table('users').createMany([ + { _id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { _id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('users').where('metadata->*->name', 'member').findMany() + + assert.deepEqual(data, [{ _id: '2', metadata: [{ name: 'user' }, { name: 'member' }] }]) + } + @Test() public async shouldBeAbleToAddAWhereClauseAsClosureToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -1661,6 +1707,26 @@ export default class MongoDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidStatementObject({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any), EmptyValueException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidStatementKey({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any, 'value'), EmptyValueException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidColumnAndValue({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any, '=', 'value'), EmptyColumnException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereJsonWithInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.whereJson(undefined as any, 'value'), EmptyColumnException) + } + @Test() public async shouldThrowNotImplementedExceptionWhenTryingToRunWhereRaw({ assert }: Context) { await assert.rejects(() => this.driver.whereRaw(), NotImplementedMethodException) @@ -1975,6 +2041,16 @@ export default class MongoDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldThrowWhenTryingToAddOrWhereWithInvalidStatementKey({ assert }: Context) { + assert.throws(() => this.driver.orWhere(undefined as any, 'value'), EmptyColumnException) + } + + @Test() + public async shouldThrowWhenTryingToAddOrWhereJsonWithInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.orWhereJson(undefined as any, 'value'), EmptyColumnException) + } + @Test() public async shouldThrowNotImplementedExceptionWhenTryingToRunOrWhereRaw({ assert }: Context) { await assert.rejects(() => this.driver.orWhereRaw(), NotImplementedMethodException) @@ -2245,6 +2321,11 @@ export default class MongoDriverTest { await assert.rejects(() => this.driver.orderByRaw(), NotImplementedMethodException) } + @Test() + public async shouldThrowWhenTryingToOrderByInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.orderBy(undefined as any), EmptyColumnException) + } + @Test() public async shouldBeAbleToAutomaticallyOrderTheDataByDatesUsingLatest({ assert }: Context) { await this.driver.table('users').create({ _id: '1', name: 'Robert Kiyosaki', created_at: new Date() }) @@ -2280,6 +2361,11 @@ export default class MongoDriverTest { assert.deepEqual(data, [{ name: 'Warren Buffet' }, { name: 'Alan Turing' }]) } + @Test() + public async shouldThrowWhenTryingToOffsetByInvalidValue({ assert }: Context) { + assert.throws(() => this.driver.offset(undefined as any), EmptyValueException) + } + @Test() public async shouldLimitTheResultsByGivenValue({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2292,4 +2378,9 @@ export default class MongoDriverTest { assert.deepEqual(data, [{ name: 'Robert Kiyosaki' }]) } + + @Test() + public async shouldThrowWhenTryingToLimitByInvalidValue({ assert }: Context) { + assert.throws(() => this.driver.limit(undefined as any), EmptyValueException) + } } diff --git a/tests/unit/drivers/MySqlDriverTest.ts b/tests/unit/drivers/MySqlDriverTest.ts index 636008a..088f079 100644 --- a/tests/unit/drivers/MySqlDriverTest.ts +++ b/tests/unit/drivers/MySqlDriverTest.ts @@ -12,6 +12,8 @@ import { Path, Sleep, Collection } from '@athenna/common' import { MySqlDriver } from '#src/database/drivers/MySqlDriver' import { ConnectionFactory } from '#src/factories/ConnectionFactory' import { WrongMethodException } from '#src/exceptions/WrongMethodException' +import { EmptyValueException } from '#src/exceptions/EmptyValueException' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' import { NotFoundDataException } from '#src/exceptions/NotFoundDataException' import { Test, Mock, AfterEach, BeforeEach, type Context, Skip } from '@athenna/test' import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException' @@ -43,6 +45,11 @@ export default class MySqlDriverTest { builder.string('id').primary() builder.string('user_id').references('id').inTable('users') }) + + await this.driver.createTable('events', builder => { + builder.string('id').primary() + builder.jsonb('metadata') + }) } @AfterEach() @@ -57,6 +64,7 @@ export default class MySqlDriverTest { await this.driver.dropDatabase('trx') await this.driver.dropTable('trx') await this.driver.dropTable('rents') + await this.driver.dropTable('events') await this.driver.dropTable('students_courses') await this.driver.dropTable('students') await this.driver.dropTable('courses') @@ -604,7 +612,7 @@ export default class MySqlDriverTest { const result = await this.driver.table('products').countDistinct('quantity') - assert.equal(result, '1') + assert.equal(result, 1) } @Test() @@ -906,7 +914,7 @@ export default class MySqlDriverTest { await this.driver.table('users').create(data) - assert.deepEqual(await this.driver.table('users').count(), '1') + assert.deepEqual(await this.driver.table('users').count(), 1) } @Test() @@ -916,13 +924,18 @@ export default class MySqlDriverTest { await assert.rejects(() => this.driver.table('users'), NotConnectedDatabaseException) } + @Test() + public async shouldThrowWhenTryingToSetInvalidTableName({ assert }: Context) { + assert.throws(() => this.driver.table(undefined as any), Error) + } + @Test() public async shouldBeAbleToDumpTheSQLQuery({ assert }: Context) { - Mock.when(console, 'log').return(undefined) + Mock.when(process.stdout, 'write').return(undefined) this.driver.table('users').select('*').dump() - assert.calledWith(console.log, { bindings: [], sql: 'select * from `users`' }) + assert.calledWith(process.stdout.write, `${JSON.stringify({ sql: 'select * from `users`', bindings: [] })}\n`) } @Test() @@ -1375,68 +1388,6 @@ export default class MySqlDriverTest { assert.deepEqual(data, [{ user_id: '1' }, { user_id: '2' }]) } - @Test() - public async shouldBeAbleToAddAHavingExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('rents') - .groupBy('id', 'user_id') - .havingExists(query => { - query.select(query.raw('1')).from('users').whereRaw('users.id = rents.user_id') - }) - .orderBy('id') - .findMany() - - assert.deepEqual(data, [ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - } - - @Test() - public async shouldBeAbleToAddAHavingNotExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' }, - { id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('id', 'name') - .groupBy('id', 'name') - .havingNotExists(query => { - query.select(query.raw('1')).from('rents').whereRaw('users.id = rents.user_id') - }) - .findMany() - - assert.deepEqual(data, [{ id: '3', name: 'Alan Turing' }]) - } - @Test() public async shouldBeAbleToAddAHavingInClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -1700,94 +1651,6 @@ export default class MySqlDriverTest { assert.deepEqual(data, [{ user_id: '1' }, { user_id: '2' }]) } - @Test() - public async shouldBeAbleToAddAOrHavingExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('rents') - .groupBy('id', 'user_id') - .orHavingExists(query => { - query.select(query.raw('1')).from('users').whereRaw('users.id = rents.user_id') - }) - .orderBy('id') - .findMany() - - assert.deepEqual(data, [ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - } - - @Test() - public async shouldBeAbleToAddAOrHavingNotExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' }, - { id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('id', 'name') - .groupBy('id', 'name') - .orHavingNotExists(query => { - query.select(query.raw('1')).from('rents').whereRaw('users.id = rents.user_id') - }) - .findMany() - - assert.deepEqual(data, [{ id: '3', name: 'Alan Turing' }]) - } - - @Test() - public async shouldBeAbleToAddAOrHavingInClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' }, - { id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('id', 'name') - .groupBy('id', 'name') - .orHavingIn('name', ['Alan Turing']) - .findMany() - - assert.deepEqual(data, [{ id: '3', name: 'Alan Turing' }]) - } - @Test() public async shouldBeAbleToAddAOrHavingNotInClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -1967,6 +1830,42 @@ export default class MySqlDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldBeAbleToFilterJsonByIndexUsingWhereJson({ assert }: Context) { + await this.driver.table('events').createMany([ + { id: '1', metadata: { name: 'admin' } }, + { id: '2', metadata: { name: 'member' } } + ]) + + const data = await this.driver.table('events').whereJson('metadata->name', 'admin').findMany() + + assert.deepEqual(data, [{ id: '1', metadata: { name: 'admin' } }]) + } + + @Test() + public async shouldBeAbleToFilterJsonArrayByIndexUsingWhereJson({ assert }: Context) { + await this.driver.table('events').createMany([ + { id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('events').whereJson('metadata->1->name', 'editor').findMany() + + assert.deepEqual(data, [{ id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }]) + } + + @Test() + public async shouldBeAbleToFilterJsonArrayByWildcardUsingWhereSelector({ assert }: Context) { + await this.driver.table('events').createMany([ + { id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('events').where('metadata->*->name', 'member').findMany() + + assert.deepEqual(data, [{ id: '2', metadata: [{ name: 'user' }, { name: 'member' }] }]) + } + @Test() public async shouldBeAbleToAddAWhereClauseAsRawToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2019,6 +1918,26 @@ export default class MySqlDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidStatementObject({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any), EmptyValueException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidStatementKey({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any, 'value'), EmptyValueException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidColumnAndValue({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any, '=', 'value'), EmptyColumnException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereJsonWithInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.whereJson(undefined as any, 'value'), Error) + } + @Test() public async shouldBeAbleToAddAWhereRawClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2454,6 +2373,16 @@ export default class MySqlDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldThrowWhenTryingToAddOrWhereWithInvalidStatementKey({ assert }: Context) { + assert.throws(() => this.driver.orWhere(undefined as any, 'value'), EmptyColumnException) + } + + @Test() + public async shouldThrowWhenTryingToAddOrWhereJsonWithInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.orWhereJson(undefined as any, 'value'), EmptyColumnException) + } + @Test() public async shouldBeAbleToAddAOrWhereRawClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2862,6 +2791,11 @@ export default class MySqlDriverTest { ]) } + @Test() + public async shouldThrowWhenTryingToOrderByInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.orderBy(undefined as any), EmptyColumnException) + } + @Test() public async shouldBeAbleToAutomaticallyOrderTheDataByDatesUsingLatest({ assert }: Context) { await this.driver.table('users').create({ id: '1', name: 'Robert Kiyosaki' }) @@ -2896,6 +2830,11 @@ export default class MySqlDriverTest { assert.deepEqual(data, [{ name: 'Warren Buffet' }, { name: 'Alan Turing' }]) } + @Test() + public async shouldThrowWhenTryingToOffsetByInvalidValue({ assert }: Context) { + assert.throws(() => this.driver.offset(undefined as any), EmptyValueException) + } + @Test() public async shouldLimitTheResultsByGivenValue({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2908,4 +2847,9 @@ export default class MySqlDriverTest { assert.deepEqual(data, [{ name: 'Robert Kiyosaki' }]) } + + @Test() + public async shouldThrowWhenTryingToLimitByInvalidValue({ assert }: Context) { + assert.throws(() => this.driver.limit(undefined as any), EmptyValueException) + } } diff --git a/tests/unit/drivers/PostgresDriverTest.ts b/tests/unit/drivers/PostgresDriverTest.ts index c1b076f..49b3b90 100644 --- a/tests/unit/drivers/PostgresDriverTest.ts +++ b/tests/unit/drivers/PostgresDriverTest.ts @@ -12,6 +12,8 @@ import { Path, Collection } from '@athenna/common' import { ConnectionFactory } from '#src/factories/ConnectionFactory' import { PostgresDriver } from '#src/database/drivers/PostgresDriver' import { WrongMethodException } from '#src/exceptions/WrongMethodException' +import { EmptyValueException } from '#src/exceptions/EmptyValueException' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' import { NotFoundDataException } from '#src/exceptions/NotFoundDataException' import { Test, Mock, AfterEach, BeforeEach, type Context } from '@athenna/test' import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException' @@ -43,6 +45,11 @@ export default class PostgresDriverTest { builder.string('id').primary() builder.string('user_id').references('id').inTable('users') }) + + await this.driver.createTable('events', builder => { + builder.string('id').primary() + builder.jsonb('metadata') + }) } @AfterEach() @@ -57,6 +64,7 @@ export default class PostgresDriverTest { await this.driver.dropDatabase('trx') await this.driver.dropTable('trx') await this.driver.dropTable('rents') + await this.driver.dropTable('events') await this.driver.dropTable('students_courses') await this.driver.dropTable('students') await this.driver.dropTable('courses') @@ -603,7 +611,7 @@ export default class PostgresDriverTest { const result = await this.driver.table('products').countDistinct('quantity') - assert.equal(result, '1') + assert.equal(result, 1) } @Test() @@ -905,7 +913,7 @@ export default class PostgresDriverTest { await this.driver.table('users').create(data) - assert.deepEqual(await this.driver.table('users').count(), '1') + assert.deepEqual(await this.driver.table('users').count(), 1) } @Test() @@ -915,13 +923,18 @@ export default class PostgresDriverTest { await assert.rejects(() => this.driver.table('users'), NotConnectedDatabaseException) } + @Test() + public async shouldThrowWhenTryingToSetInvalidTableName({ assert }: Context) { + assert.throws(() => this.driver.table(undefined as any), Error) + } + @Test() public async shouldBeAbleToDumpTheSQLQuery({ assert }: Context) { - Mock.when(console, 'log').return(undefined) + Mock.when(process.stdout, 'write').return(undefined) this.driver.table('users').select('*').dump() - assert.calledWith(console.log, { bindings: [], sql: 'select * from "users"' }) + assert.calledWith(process.stdout.write, `${JSON.stringify({ sql: 'select * from "users"', bindings: [] })}\n`) } @Test() @@ -1374,67 +1387,6 @@ export default class PostgresDriverTest { assert.deepEqual(data, [{ user_id: '1' }, { user_id: '2' }]) } - @Test() - public async shouldBeAbleToAddAHavingExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('rents') - .groupBy('id', 'user_id') - .havingExists(query => { - query.select(query.raw('1')).from('users').whereRaw('users.id = rents.user_id') - }) - .findMany() - - assert.deepEqual(data, [ - { id: '6', user_id: '2' }, - { id: '2', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '1', user_id: '1' }, - { id: '3', user_id: '1' } - ]) - } - - @Test() - public async shouldBeAbleToAddAHavingNotExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' }, - { id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('id', 'name') - .groupBy('id', 'name') - .havingNotExists(query => { - query.select(query.raw('1')).from('rents').whereRaw('users.id = rents.user_id') - }) - .findMany() - - assert.deepEqual(data, [{ id: '3', name: 'Alan Turing' }]) - } - @Test() public async shouldBeAbleToAddAHavingInClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -1698,93 +1650,6 @@ export default class PostgresDriverTest { assert.deepEqual(data, [{ user_id: '1' }, { user_id: '2' }]) } - @Test() - public async shouldBeAbleToAddAOrHavingExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('rents') - .groupBy('id', 'user_id') - .orHavingExists(query => { - query.select(query.raw('1')).from('users').whereRaw('users.id = rents.user_id') - }) - .findMany() - - assert.deepEqual(data, [ - { id: '6', user_id: '2' }, - { id: '2', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '1', user_id: '1' }, - { id: '3', user_id: '1' } - ]) - } - - @Test() - public async shouldBeAbleToAddAOrHavingNotExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' }, - { id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('id', 'name') - .groupBy('id', 'name') - .orHavingNotExists(query => { - query.select(query.raw('1')).from('rents').whereRaw('users.id = rents.user_id') - }) - .findMany() - - assert.deepEqual(data, [{ id: '3', name: 'Alan Turing' }]) - } - - @Test() - public async shouldBeAbleToAddAOrHavingInClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' }, - { id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('id', 'name') - .groupBy('id', 'name') - .orHavingIn('name', ['Alan Turing']) - .findMany() - - assert.deepEqual(data, [{ id: '3', name: 'Alan Turing' }]) - } - @Test() public async shouldBeAbleToAddAOrHavingNotInClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -1964,6 +1829,30 @@ export default class PostgresDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldBeAbleToFilterJsonArrayByIndexUsingWhereJson({ assert }: Context) { + await this.driver.table('events').createMany([ + { id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('events').whereJson('metadata->1->name', 'editor').findMany() + + assert.deepEqual(data, [{ id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }]) + } + + @Test() + public async shouldBeAbleToFilterJsonArrayByWildcardUsingWhereSelector({ assert }: Context) { + await this.driver.table('events').createMany([ + { id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('events').where('metadata->*->name', 'member').findMany() + + assert.deepEqual(data, [{ id: '2', metadata: [{ name: 'user' }, { name: 'member' }] }]) + } + @Test() public async shouldBeAbleToAddAWhereClauseAsRawToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2016,6 +1905,26 @@ export default class PostgresDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidStatementObject({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any), EmptyValueException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidStatementKey({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any, 'value'), EmptyValueException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidColumnAndValue({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any, '=', 'value'), EmptyColumnException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereJsonWithInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.whereJson(undefined as any, 'value'), Error) + } + @Test() public async shouldBeAbleToAddAWhereRawClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2450,6 +2359,16 @@ export default class PostgresDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldThrowWhenTryingToAddOrWhereWithInvalidStatementKey({ assert }: Context) { + assert.throws(() => this.driver.orWhere(undefined as any, 'value'), EmptyColumnException) + } + + @Test() + public async shouldThrowWhenTryingToAddOrWhereJsonWithInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.orWhereJson(undefined as any, 'value'), EmptyColumnException) + } + @Test() public async shouldBeAbleToAddAOrWhereRawClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2857,6 +2776,11 @@ export default class PostgresDriverTest { ]) } + @Test() + public async shouldThrowWhenTryingToOrderByInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.orderBy(undefined as any), EmptyColumnException) + } + @Test() public async shouldBeAbleToAutomaticallyOrderTheDataByDatesUsingLatest({ assert }: Context) { await this.driver.table('users').create({ id: '1', name: 'Robert Kiyosaki' }) @@ -2890,6 +2814,11 @@ export default class PostgresDriverTest { assert.deepEqual(data, [{ name: 'Warren Buffet' }, { name: 'Alan Turing' }]) } + @Test() + public async shouldThrowWhenTryingToOffsetByInvalidValue({ assert }: Context) { + assert.throws(() => this.driver.offset(undefined as any), EmptyValueException) + } + @Test() public async shouldLimitTheResultsByGivenValue({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2902,4 +2831,9 @@ export default class PostgresDriverTest { assert.deepEqual(data, [{ name: 'Robert Kiyosaki' }]) } + + @Test() + public async shouldThrowWhenTryingToLimitByInvalidValue({ assert }: Context) { + assert.throws(() => this.driver.limit(undefined as any), EmptyValueException) + } } diff --git a/tests/unit/drivers/SqliteDriverTest.ts b/tests/unit/drivers/SqliteDriverTest.ts index d0569b1..25232fb 100644 --- a/tests/unit/drivers/SqliteDriverTest.ts +++ b/tests/unit/drivers/SqliteDriverTest.ts @@ -12,6 +12,8 @@ import { Path, Sleep, Collection } from '@athenna/common' import { SqliteDriver } from '#src/database/drivers/SqliteDriver' import { ConnectionFactory } from '#src/factories/ConnectionFactory' import { WrongMethodException } from '#src/exceptions/WrongMethodException' +import { EmptyValueException } from '#src/exceptions/EmptyValueException' +import { EmptyColumnException } from '#src/exceptions/EmptyColumnException' import { NotFoundDataException } from '#src/exceptions/NotFoundDataException' import { Test, Mock, AfterEach, BeforeEach, type Context, Skip } from '@athenna/test' import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException' @@ -43,6 +45,11 @@ export default class SqliteDriverTest { builder.string('id').primary() builder.string('user_id').references('id').inTable('users') }) + + await this.driver.createTable('events', builder => { + builder.string('id').primary() + builder.jsonb('metadata') + }) } @AfterEach() @@ -57,6 +64,7 @@ export default class SqliteDriverTest { await this.driver.dropDatabase('trx') await this.driver.dropTable('trx') await this.driver.dropTable('rents') + await this.driver.dropTable('events') await this.driver.dropTable('students_courses') await this.driver.dropTable('students') await this.driver.dropTable('courses') @@ -605,7 +613,7 @@ export default class SqliteDriverTest { const result = await this.driver.table('products').countDistinct('quantity') - assert.equal(result, '1') + assert.equal(result, 1) } @Test() @@ -907,7 +915,7 @@ export default class SqliteDriverTest { await this.driver.table('users').create(data) - assert.deepEqual(await this.driver.table('users').count(), '1') + assert.deepEqual(await this.driver.table('users').count(), 1) } @Test() @@ -917,13 +925,18 @@ export default class SqliteDriverTest { await assert.rejects(() => this.driver.table('users'), NotConnectedDatabaseException) } + @Test() + public async shouldThrowWhenTryingToSetInvalidTableName({ assert }: Context) { + assert.throws(() => this.driver.table(undefined as any), Error) + } + @Test() public async shouldBeAbleToDumpTheSQLQuery({ assert }: Context) { - Mock.when(console, 'log').return(undefined) + Mock.when(process.stdout, 'write').return(undefined) this.driver.table('users').select('*').dump() - assert.calledWith(console.log, { bindings: [], sql: 'select * from `users`' }) + assert.calledWith(process.stdout.write, `${JSON.stringify({ sql: 'select * from `users`', bindings: [] })}\n`) } @Test() @@ -1376,68 +1389,6 @@ export default class SqliteDriverTest { assert.deepEqual(data, [{ user_id: '1' }, { user_id: '2' }]) } - @Test() - public async shouldBeAbleToAddAHavingExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('rents') - .groupBy('id', 'user_id') - .havingExists(query => { - query.select(query.raw('1')).from('users').whereRaw('users.id = rents.user_id') - }) - .orderBy('id') - .findMany() - - assert.deepEqual(data, [ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - } - - @Test() - public async shouldBeAbleToAddAHavingNotExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' }, - { id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('id', 'name') - .groupBy('id', 'name') - .havingNotExists(query => { - query.select(query.raw('1')).from('rents').whereRaw('users.id = rents.user_id') - }) - .findMany() - - assert.deepEqual(data, [{ id: '3', name: 'Alan Turing' }]) - } - @Test() public async shouldBeAbleToAddAHavingInClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -1693,94 +1644,6 @@ export default class SqliteDriverTest { assert.deepEqual(data, [{ user_id: '1' }, { user_id: '2' }]) } - @Test() - public async shouldBeAbleToAddAOrHavingExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('rents') - .groupBy('id', 'user_id') - .orHavingExists(query => { - query.select(query.raw('1')).from('users').whereRaw('users.id = rents.user_id') - }) - .orderBy('id') - .findMany() - - assert.deepEqual(data, [ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - } - - @Test() - public async shouldBeAbleToAddAOrHavingNotExistsClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' }, - { id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('id', 'name') - .groupBy('id', 'name') - .orHavingNotExists(query => { - query.select(query.raw('1')).from('rents').whereRaw('users.id = rents.user_id') - }) - .findMany() - - assert.deepEqual(data, [{ id: '3', name: 'Alan Turing' }]) - } - - @Test() - public async shouldBeAbleToAddAOrHavingInClauseToTheQueryUsingDriver({ assert }: Context) { - await this.driver.table('users').createMany([ - { id: '1', name: 'Robert Kiyosaki' }, - { id: '2', name: 'Warren Buffet' }, - { id: '3', name: 'Alan Turing' } - ]) - await this.driver.table('rents').createMany([ - { id: '1', user_id: '1' }, - { id: '2', user_id: '1' }, - { id: '3', user_id: '1' }, - { id: '4', user_id: '1' }, - { id: '5', user_id: '2' }, - { id: '6', user_id: '2' } - ]) - - const data = await this.driver - .table('users') - .select('id', 'name') - .groupBy('id', 'name') - .orHavingIn('name', ['Alan Turing']) - .findMany() - - assert.deepEqual(data, [{ id: '3', name: 'Alan Turing' }]) - } - @Test() public async shouldBeAbleToAddAOrHavingNotInClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -1952,6 +1815,42 @@ export default class SqliteDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldBeAbleToFilterJsonByIndexUsingWhereJson({ assert }: Context) { + await this.driver.table('events').createMany([ + { id: '1', metadata: { name: 'admin' } }, + { id: '2', metadata: { name: 'member' } } + ]) + + const data = await this.driver.table('events').whereJson('metadata->name', 'admin').findMany() + + assert.deepEqual(data, [{ id: '1', metadata: { name: 'admin' } }]) + } + + @Test() + public async shouldBeAbleToFilterJsonArrayByIndexUsingWhereJson({ assert }: Context) { + await this.driver.table('events').createMany([ + { id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('events').whereJson('metadata->1->name', 'editor').findMany() + + assert.deepEqual(data, [{ id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }]) + } + + @Test() + public async shouldBeAbleToFilterJsonArrayByWildcardUsingWhereSelector({ assert }: Context) { + await this.driver.table('events').createMany([ + { id: '1', metadata: [{ name: 'admin' }, { name: 'editor' }] }, + { id: '2', metadata: [{ name: 'user' }, { name: 'member' }] } + ]) + + const data = await this.driver.table('events').where('metadata->*->name', 'member').findMany() + + assert.deepEqual(data, [{ id: '2', metadata: [{ name: 'user' }, { name: 'member' }] }]) + } + @Test() public async shouldBeAbleToAddAWhereClauseAsRawToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2004,6 +1903,26 @@ export default class SqliteDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidStatementObject({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any), EmptyValueException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidStatementKey({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any, 'value'), EmptyValueException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereWithInvalidColumnAndValue({ assert }: Context) { + assert.throws(() => this.driver.where(undefined as any, '=', 'value'), EmptyColumnException) + } + + @Test() + public async shouldThrowWhenTryingToAddWhereJsonWithInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.whereJson(undefined as any, 'value'), Error) + } + @Test() public async shouldBeAbleToAddAWhereRawClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2432,6 +2351,16 @@ export default class SqliteDriverTest { assert.deepEqual(data, [{ user_id: '2' }, { user_id: '2' }]) } + @Test() + public async shouldThrowWhenTryingToAddOrWhereWithInvalidStatementKey({ assert }: Context) { + assert.throws(() => this.driver.orWhere(undefined as any, 'value'), EmptyColumnException) + } + + @Test() + public async shouldThrowWhenTryingToAddOrWhereJsonWithInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.orWhereJson(undefined as any, 'value'), EmptyColumnException) + } + @Test() public async shouldBeAbleToAddAOrWhereRawClauseToTheQueryUsingDriver({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2833,6 +2762,11 @@ export default class SqliteDriverTest { ]) } + @Test() + public async shouldThrowWhenTryingToOrderByInvalidColumn({ assert }: Context) { + assert.throws(() => this.driver.orderBy(undefined as any), EmptyColumnException) + } + @Test() public async shouldBeAbleToAutomaticallyOrderTheDataByDatesUsingLatest({ assert }: Context) { await this.driver.table('users').create({ id: '1', name: 'Robert Kiyosaki' }) @@ -2867,6 +2801,11 @@ export default class SqliteDriverTest { assert.deepEqual(data, [{ name: 'Warren Buffet' }, { name: 'Alan Turing' }]) } + @Test() + public async shouldThrowWhenTryingToOffsetByInvalidValue({ assert }: Context) { + assert.throws(() => this.driver.offset(undefined as any), EmptyValueException) + } + @Test() public async shouldLimitTheResultsByGivenValue({ assert }: Context) { await this.driver.table('users').createMany([ @@ -2879,4 +2818,9 @@ export default class SqliteDriverTest { assert.deepEqual(data, [{ name: 'Robert Kiyosaki' }]) } + + @Test() + public async shouldThrowWhenTryingToLimitByInvalidValue({ assert }: Context) { + assert.throws(() => this.driver.limit(undefined as any), EmptyValueException) + } } diff --git a/tests/unit/models/BaseModelTest.ts b/tests/unit/models/BaseModelTest.ts index 33fc70e..89bac82 100644 --- a/tests/unit/models/BaseModelTest.ts +++ b/tests/unit/models/BaseModelTest.ts @@ -473,7 +473,7 @@ export default class BaseModelTest { } @Test() - public async shouldBeAbleToGetModelAsJsonHiddingIsHiddenFieldsUsingToJSONMethod({ assert }: Context) { + public async shouldBeAbleToGetModelAsJsonHidingIsHiddenFieldsUsingToJSONMethod({ assert }: Context) { const user = new User() user.id = '1' @@ -487,7 +487,7 @@ export default class BaseModelTest { } @Test() - public async shouldBeAbleToGetModelAsJsonWithoutHiddingIsHiddenFieldsUsingToJSONMethod({ assert }: Context) { + public async shouldBeAbleToGetModelAsJsonWithoutHidingIsHiddenFieldsUsingToJSONMethod({ assert }: Context) { const user = new User() user.id = '1' @@ -560,6 +560,36 @@ export default class BaseModelTest { assert.deepEqual(user.dirty(), { id: '1', name: 'lenon' }) } + @Test() + public async shouldBeAbleToGetOnlyDirtyJsonValuesOfModel({ assert }: Context) { + const user = new User() + + user.id = '1' + user.name = 'lenon' + user.createdAt = new Date() + user.deletedAt = null + user.score = 5 + user.metadata4 = { + name: 'admin', + age: 20, + address: { + city: 'New York', + state: 'NY' + } + } + + user.setOriginal() + + user.metadata4.name = 'editor' + user.metadata4.age = 21 + user.metadata4.address.city = 'Los Angeles' + user.metadata4.address.state = 'CA' + + assert.deepEqual(user.dirty(), { + metadata4: { name: 'editor', age: 21, address: { city: 'Los Angeles', state: 'CA' } } + }) + } + @Test() public async shouldBeAbleToCreateModelFromScratchUsingSaveMethod({ assert }: Context) { Mock.when(Database.driver, 'find').resolve(undefined) diff --git a/tests/unit/models/builders/ModelQueryBuilderTest.ts b/tests/unit/models/builders/ModelQueryBuilderTest.ts index a044de2..6fec211 100644 --- a/tests/unit/models/builders/ModelQueryBuilderTest.ts +++ b/tests/unit/models/builders/ModelQueryBuilderTest.ts @@ -1362,30 +1362,6 @@ export default class ModelQueryBuilderTest { assert.calledOnceWith(Database.driver.havingRaw, sql) } - @Test() - public async shouldAddAHavingExistsClauseToTheQuery({ assert }: Context) { - const closure = query => { - query.table('profiles').select('*').whereRaw('users.account_id = accounts.id') - } - Mock.when(Database.driver, 'havingExists').resolve(undefined) - - User.query().havingExists(closure) - - assert.calledOnce(Database.driver.havingExists) - } - - @Test() - public async shouldAddAHavingNotExistsClauseToTheQuery({ assert }: Context) { - const closure = query => { - query.table('profiles').select('*').whereRaw('users.account_id = accounts.id') - } - Mock.when(Database.driver, 'havingNotExists').resolve(undefined) - - User.query().havingNotExists(closure) - - assert.calledOnce(Database.driver.havingNotExists) - } - @Test() public async shouldAddAHavingInClauseToTheQuery({ assert }: Context) { const column = 'id' @@ -1545,52 +1521,6 @@ export default class ModelQueryBuilderTest { assert.calledOnceWith(Database.driver.orHavingRaw, sql) } - @Test() - public async shouldAddAnOrHavingExistsClauseToTheQuery({ assert }: Context) { - const closure = query => { - query.table('profiles').select('*').whereRaw('users.account_id = accounts.id') - } - Mock.when(Database.driver, 'orHavingExists').resolve(undefined) - - User.query().orHavingExists(closure) - - assert.calledOnce(Database.driver.orHavingExists) - } - - @Test() - public async shouldAddAnOrHavingNotExistsClauseToTheQuery({ assert }: Context) { - const closure = query => { - query.table('profiles').select('*').whereRaw('users.account_id = accounts.id') - } - Mock.when(Database.driver, 'orHavingNotExists').resolve(undefined) - - User.query().orHavingNotExists(closure) - - assert.calledOnce(Database.driver.orHavingNotExists) - } - - @Test() - public async shouldAddAnOrHavingInClauseToTheQuery({ assert }: Context) { - const column = 'id' - const values = [1, 2, 3] - Mock.when(Database.driver, 'orHavingIn').resolve(undefined) - - User.query().orHavingIn(column, values) - - assert.calledOnce(Database.driver.orHavingIn) - } - - @Test() - public async shouldAddAnOrHavingInClauseToTheQueryParsingColumnNames({ assert }: Context) { - const column = 'rate' - const values = [1, 2, 3] - Mock.when(Database.driver, 'orHavingIn').resolve(undefined) - - User.query().orHavingIn(column, values) - - assert.calledOnceWith(Database.driver.orHavingIn, 'rate_number', [1, 2, 3]) - } - @Test() public async shouldAddAnOrHavingNotInClauseToTheQuery({ assert }: Context) { const column = 'id'