From b771de90f8e1f47bb30e3823d100521b5c64584f Mon Sep 17 00:00:00 2001 From: jlenon7 Date: Sat, 16 May 2026 13:57:44 -0300 Subject: [PATCH 1/2] feat: add new search and orWhereHas methods --- package-lock.json | 4 +- package.json | 2 +- src/database/drivers/BaseKnexDriver.ts | 4 +- src/models/builders/ModelQueryBuilder.ts | 90 ++++++++++ .../models/builders/ModelQueryBuilderTest.ts | 164 ++++++++++++++++++ 5 files changed, 260 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a283e3b..d0c044a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/database", - "version": "5.52.0", + "version": "5.53.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/database", - "version": "5.52.0", + "version": "5.53.0", "license": "MIT", "dependencies": { "@faker-js/faker": "^8.4.1" diff --git a/package.json b/package.json index 3998ae6..079a535 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/database", - "version": "5.52.0", + "version": "5.53.0", "description": "The Athenna database handler for SQL/NoSQL.", "license": "MIT", "author": "João Lenon ", diff --git a/src/database/drivers/BaseKnexDriver.ts b/src/database/drivers/BaseKnexDriver.ts index 7bbadc5..246ae36 100644 --- a/src/database/drivers/BaseKnexDriver.ts +++ b/src/database/drivers/BaseKnexDriver.ts @@ -380,7 +380,9 @@ export class BaseKnexDriver extends Driver { * Calculate the average of a given column using distinct. */ public async countDistinct(column: string): Promise { - const [{ count }] = await this.qb.clearSelect().countDistinct({ count: column }) + const [{ count }] = await this.qb + .clearSelect() + .countDistinct({ count: column }) return Number(count) } diff --git a/src/models/builders/ModelQueryBuilder.ts b/src/models/builders/ModelQueryBuilder.ts index 06e049b..9a44e09 100644 --- a/src/models/builders/ModelQueryBuilder.ts +++ b/src/models/builders/ModelQueryBuilder.ts @@ -625,6 +625,96 @@ export class ModelQueryBuilder< return this } + /** + * Same as {@link ModelQueryBuilder.whereHas}, but joins the resulting + * `EXISTS (...)` clause to the surrounding WHERE with `OR` instead of `AND`. + * + * Useful inside a grouped `where(qb => ...)` closure to build expressions + * like `(directCol ILIKE x OR relation.col ILIKE x)` without resorting to + * raw SQL. + */ + public orWhereHas>( + relation: K | string, + closure?: ( + query: ModelQueryBuilder< + Extract, + Driver + > + ) => any + ) { + const options = this.schema.includeWhereHasRelation(relation, closure) + + /** + * Snapshot the full options object immediately at call time, before any + * subsequent `with(sameRelation)` call can mutate the shared `options` + * object (e.g. overwriting `closure` or `withClosure`). Because this + * spread happens here — outside the Knex callback — the snapshot is + * frozen regardless of what happens to `options` afterwards. + */ + const snapshot = { ...options } + + super.orWhereExists(query => { + switch (snapshot.type) { + case 'hasOne': + return HasOneRelation.whereHas(this.Model, query, snapshot) + case 'hasMany': + return HasManyRelation.whereHas(this.Model, query, snapshot) + case 'belongsTo': + return BelongsToRelation.whereHas(this.Model, query, snapshot) + case 'belongsToMany': + return BelongsToManyRelation.whereHas(this.Model, query, snapshot) + } + }) + + return this + } + + /** + * Build a grouped OR search across any mix of direct columns and + * relation columns in a single `WHERE (...)` clause. + * + * Each entry in `fields` is either a direct column property (e.g. `name`) + * or a `relation.column` path (e.g. `profile.bio`). The resulting SQL is a + * single parenthesized group joined exclusively by `OR`. Passing a falsy + * `term` short-circuits and the query is left untouched. + * + * @example + * ```ts + * User.query().search(['name', 'email', 'profile.bio'], 'john') + * ``` + */ + public search( + fields: (ModelColumns | ModelRelations | string)[], + term: string + ) { + if (!term) { + return this + } + + const value = `%${term}%` + + this.where(qb => { + fields.forEach((field, i) => { + const isRelation = (field as string).includes('.') + + if (isRelation) { + const [relation, column] = (field as string).split('.') + const relOp = i === 0 ? 'whereHas' : 'orWhereHas' + + ;(qb as any)[relOp](relation, (q: any) => q.whereILike(column, value)) + + return + } + + const op = i === 0 ? 'whereILike' : 'orWhereILike' + + ;(qb as any)[op](field, value) + }) + }) + + return this + } + /** * Executes the given closure when the first argument is true. */ diff --git a/tests/unit/models/builders/ModelQueryBuilderTest.ts b/tests/unit/models/builders/ModelQueryBuilderTest.ts index 4e3469f..187b0db 100644 --- a/tests/unit/models/builders/ModelQueryBuilderTest.ts +++ b/tests/unit/models/builders/ModelQueryBuilderTest.ts @@ -2465,4 +2465,168 @@ export default class ModelQueryBuilderTest { assert.isTrue(whereExistsCalled) } + + @Test() + public async orWhereHasShouldRegisterAnOrWhereExistsClause({ assert }: Context) { + let orWhereExistsCalled = false + + Mock.stub(Database.driver, 'findMany').callsFake(async () => []) + Mock.stub(Database.driver, 'orWhereExists').callsFake(() => { + orWhereExistsCalled = true + return Database.driver + }) + + await User.query() + .where('name', 'John') + .orWhereHas('products', qb => qb.where('id', 'p1')) + .findMany() + + assert.isTrue(orWhereExistsCalled) + } + + @Test() + public async orWhereHasShouldBeChainable({ assert }: Context) { + Mock.when(Database.driver, 'orWhereExists').return(Database.driver) + + const builder = User.query().orWhereHas('products', qb => qb.where('id', 'p1')) + + assert.equal(typeof (builder as any).findMany, 'function') + } + + @Test() + public async orWhereHasShouldNotEagerLoadRelations({ assert }: Context) { + let findManyCalls = 0 + + Mock.stub(Database.driver, 'findMany').callsFake(async () => { + findManyCalls++ + + return [{ id: '1', name: 'John Doe' }] + }) + + await User.query() + .where('name', 'John') + .orWhereHas('products', qb => qb.where('id', 'p1')) + .findMany() + + assert.equal(findManyCalls, 1) + } + + @Test() + public async orWhereHasClosureShouldNotLeakIntoEagerLoad({ assert }: Context) { + const whereCalls: string[] = [] + + Mock.stub(Database.driver, 'findMany').callsFake(async () => { + return [{ id: '1', name: 'John Doe' }] + }) + + Mock.stub(Database.driver, 'where').callsFake((...args: any[]) => { + whereCalls.push(args[0]) + return Database.driver + }) + + await User.query() + .with('products') + .orWhereHas('products', qb => qb.where('orWhereHasFilter', 'value')) + .findMany() + + assert.isFalse(whereCalls.includes('orWhereHasFilter')) + } + + @Test() + public async searchShouldBeANoOpWhenTermIsEmpty({ assert }: Context) { + let whereCalled = false + + Mock.stub(Database.driver, 'where').callsFake(() => { + whereCalled = true + return Database.driver + }) + + const builder = User.query().search(['name', 'email'], '') + + assert.isFalse(whereCalled) + assert.equal(typeof (builder as any).findMany, 'function') + } + + @Test() + public async searchShouldBeANoOpWhenTermIsUndefined({ assert }: Context) { + let whereCalled = false + + Mock.stub(Database.driver, 'where').callsFake(() => { + whereCalled = true + return Database.driver + }) + + User.query().search(['name', 'email'], undefined as any) + + assert.isFalse(whereCalled) + } + + @Test() + public async searchShouldOpenASingleGroupedWhereClause({ assert }: Context) { + let whereClosureCalls = 0 + + Mock.stub(Database.driver, 'where').callsFake((arg: any) => { + if (typeof arg === 'function') { + whereClosureCalls++ + } + + return Database.driver + }) + + User.query().search(['name', 'email'], 'john') + + assert.equal(whereClosureCalls, 1) + } + + @Test() + public async searchShouldOpenASingleGroupedWhereClauseForRelationOnly({ assert }: Context) { + let whereClosureCalls = 0 + + Mock.stub(Database.driver, 'where').callsFake((arg: any) => { + if (typeof arg === 'function') { + whereClosureCalls++ + } + + return Database.driver + }) + + User.query().search(['products.id'], 'p1') + + assert.equal(whereClosureCalls, 1) + } + + @Test() + public async searchShouldOpenASingleGroupedWhereClauseForMixedFields({ assert }: Context) { + let whereClosureCalls = 0 + + Mock.stub(Database.driver, 'where').callsFake((arg: any) => { + if (typeof arg === 'function') { + whereClosureCalls++ + } + + return Database.driver + }) + + User.query().search(['name', 'products.id'], 'john') + + assert.equal(whereClosureCalls, 1) + } + + @Test() + public async searchShouldBeChainable({ assert }: Context) { + Mock.when(Database.driver, 'where').return(Database.driver) + + const builder = User.query().search(['name'], 'john') + + assert.equal(typeof (builder as any).findMany, 'function') + } + + @Test() + public async modelQueryBuilderShouldExposeOrWhereHasOnTheInstance({ assert }: Context) { + const builder = User.query() + + assert.equal(typeof (builder as any).whereHas, 'function') + assert.equal(typeof (builder as any).orWhereHas, 'function') + assert.equal(typeof (builder as any).search, 'function') + } } From 0b0e435a74290e06ffd3644657fafdbeda6970de Mon Sep 17 00:00:00 2001 From: jlenon7 Date: Sat, 16 May 2026 14:16:52 -0300 Subject: [PATCH 2/2] feat: add new db:query command --- configurer/index.ts | 4 + package.json | 4 + src/commands/DbQueryCommand.ts | 60 +++++++++++ tests/fixtures/consoles/db-query-console.ts | 55 ++++++++++ tests/unit/commands/DbQueryCommandTest.ts | 108 ++++++++++++++++++++ 5 files changed, 231 insertions(+) create mode 100644 src/commands/DbQueryCommand.ts create mode 100644 tests/fixtures/consoles/db-query-console.ts create mode 100644 tests/unit/commands/DbQueryCommandTest.ts diff --git a/configurer/index.ts b/configurer/index.ts index 6cb7a9f..f9ad2c0 100644 --- a/configurer/index.ts +++ b/configurer/index.ts @@ -59,6 +59,10 @@ export default class DatabaseConfigurer extends BaseConfigurer { path: '@athenna/database/commands/DbWipeCommand', loadApp: true }) + .setTo('commands', 'db:query', { + path: '@athenna/database/commands/DbQueryCommand', + loadApp: true + }) .setTo('commands', 'migration:run', { path: '@athenna/database/commands/MigrationRunCommand', loadApp: true diff --git a/package.json b/package.json index 079a535..5b3b5c1 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "./package.json": "./package.json", "./testing/plugins": "./src/testing/plugins/index.js", "./commands/DbFreshCommand": "./src/commands/DbFreshCommand.js", + "./commands/DbQueryCommand": "./src/commands/DbQueryCommand.js", "./commands/DbSeedCommand": "./src/commands/DbSeedCommand.js", "./commands/DbWipeCommand": "./src/commands/DbWipeCommand.js", "./commands/MakeCrudCommand": "./src/commands/MakeCrudCommand.js", @@ -215,6 +216,9 @@ "db:wipe": { "path": "#src/commands/DbWipeCommand" }, + "db:query": { + "path": "#src/commands/DbQueryCommand" + }, "make:model": { "path": "#src/commands/MakeModelCommand" }, diff --git a/src/commands/DbQueryCommand.ts b/src/commands/DbQueryCommand.ts new file mode 100644 index 0000000..7cc04e8 --- /dev/null +++ b/src/commands/DbQueryCommand.ts @@ -0,0 +1,60 @@ +/** + * @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 { Is } from '@athenna/common' +import { Database } from '#src/facades/Database' +import { BaseCommand, Argument, Option } from '@athenna/artisan' + +export class DbQueryCommand extends BaseCommand { + @Argument({ + signature: 'query...', + description: 'The raw SQL query to execute.' + }) + public query: string[] + + @Option({ + default: 'default', + signature: '-c, --connection ', + description: 'Set the the database connection.' + }) + public connection: string + + public static signature(): string { + return 'db:query' + } + + public static description(): string { + return 'Run a raw SQL query against the database.' + } + + public async handle(): Promise { + this.logger.simple('({bold,green} [ RUNNING QUERY ])\n') + + const sql = this.query.join(' ') + const DB = Database.connection(this.connection) + + try { + const result = await DB.raw(sql) + + if (result === null || result === undefined) { + return + } + + if (Is.Object(result) || Is.Array(result)) { + this.logger.simple(JSON.stringify(result)) + + return + } + + this.logger.simple(String(result)) + } finally { + await DB.close() + } + } +} diff --git a/tests/fixtures/consoles/db-query-console.ts b/tests/fixtures/consoles/db-query-console.ts new file mode 100644 index 0000000..aa976de --- /dev/null +++ b/tests/fixtures/consoles/db-query-console.ts @@ -0,0 +1,55 @@ +/** + * @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 { Path } from '@athenna/common' +import { ViewProvider } from '@athenna/view' +import { Rc, Config } from '@athenna/config' +import { FakeDriver } from '#src/database/drivers/FakeDriver' +import { DatabaseProvider } from '#src/providers/DatabaseProvider' +import { Artisan, ConsoleKernel, ArtisanProvider } from '@athenna/artisan' + +new ViewProvider().register() +new ArtisanProvider().register() +new DatabaseProvider().register() + +await Config.loadAll(Path.fixtures('config')) + +Rc.setFile(Path.pwd('package.json')) + +Path.mergeDirs({ + seeders: 'tests/fixtures/database/seeders', + migrations: 'tests/fixtures/database/migrations' +}) + +switch (process.env.MOCK_RAW_TYPE) { + case 'array': + FakeDriver.raw = () => [{ id: 1, name: 'Lenon' }] as any + break + case 'number': + FakeDriver.raw = () => 42 as any + break + case 'string': + FakeDriver.raw = () => 'hello' as any + break + case 'boolean': + FakeDriver.raw = () => true as any + break + case 'undefined': + FakeDriver.raw = () => undefined as any + break + case 'throw': + FakeDriver.raw = () => { + throw new Error('Syntax error near token "FROOM"') + } + break +} + +await new ConsoleKernel().registerCommands() + +await Artisan.parse(process.argv) diff --git a/tests/unit/commands/DbQueryCommandTest.ts b/tests/unit/commands/DbQueryCommandTest.ts new file mode 100644 index 0000000..8a1b6ee --- /dev/null +++ b/tests/unit/commands/DbQueryCommandTest.ts @@ -0,0 +1,108 @@ +/** + * @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 { Path } from '@athenna/common' +import { AfterEach, Test, type Context } from '@athenna/test' +import { BaseCommandTest } from '#tests/helpers/BaseCommandTest' + +export default class DbQueryCommandTest extends BaseCommandTest { + @AfterEach() + public async afterEachQueryTest() { + delete process.env.MOCK_RAW_TYPE + } + + @Test() + public async shouldBeAbleToRunARawQueryAndPrintObjectResultAsJson({ command }: Context) { + const output = await command.run('db:query SELECT * from users --connection=fake', { + path: Path.fixtures('consoles/db-query-console.ts') + }) + + output.assertSucceeded() + output.assertLogged('[ RUNNING QUERY ]') + output.assertLogged('{}') + } + + @Test() + public async shouldPrintArrayResultAsJson({ command }: Context) { + process.env.MOCK_RAW_TYPE = 'array' + + const output = await command.run('db:query SELECT * from users --connection=fake', { + path: Path.fixtures('consoles/db-query-console.ts') + }) + + output.assertSucceeded() + output.assertLogged('[ RUNNING QUERY ]') + output.assertLogged('[{"id":1,"name":"Lenon"}]') + } + + @Test() + public async shouldPrintNumberResultAsString({ command }: Context) { + process.env.MOCK_RAW_TYPE = 'number' + + const output = await command.run('db:query SELECT COUNT(*) from users --connection=fake', { + path: Path.fixtures('consoles/db-query-console.ts') + }) + + output.assertSucceeded() + output.assertLogged('[ RUNNING QUERY ]') + output.assertLogged('42') + output.assertNotLogged('{') + } + + @Test() + public async shouldPrintStringResultAsString({ command }: Context) { + process.env.MOCK_RAW_TYPE = 'string' + + const output = await command.run('db:query SELECT version --connection=fake', { + path: Path.fixtures('consoles/db-query-console.ts') + }) + + output.assertSucceeded() + output.assertLogged('[ RUNNING QUERY ]') + output.assertLogged('hello') + } + + @Test() + public async shouldPrintBooleanResultAsString({ command }: Context) { + process.env.MOCK_RAW_TYPE = 'boolean' + + const output = await command.run('db:query SELECT 1 --connection=fake', { + path: Path.fixtures('consoles/db-query-console.ts') + }) + + output.assertSucceeded() + output.assertLogged('[ RUNNING QUERY ]') + output.assertLogged('true') + } + + @Test() + public async shouldNotPrintAnyResultWhenQueryReturnsUndefined({ command }: Context) { + process.env.MOCK_RAW_TYPE = 'undefined' + + const output = await command.run('db:query INSERT INTO users VALUES (1) --connection=fake', { + path: Path.fixtures('consoles/db-query-console.ts') + }) + + output.assertSucceeded() + output.assertLogged('[ RUNNING QUERY ]') + output.assertNotLogged('undefined') + output.assertNotLogged('null') + } + + @Test() + public async shouldJoinMultipleTokensIntoTheRawQueryString({ command }: Context) { + const output = await command.run('db:query SELECT id, name FROM users WHERE id = 1 --connection=fake', { + path: Path.fixtures('consoles/db-query-console.ts') + }) + + output.assertSucceeded() + output.assertLogged('[ RUNNING QUERY ]') + output.assertLogged('{}') + } +}