Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions configurer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <lenon@athenna.io>",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -215,6 +216,9 @@
"db:wipe": {
"path": "#src/commands/DbWipeCommand"
},
"db:query": {
"path": "#src/commands/DbQueryCommand"
},
"make:model": {
"path": "#src/commands/MakeModelCommand"
},
Expand Down
60 changes: 60 additions & 0 deletions src/commands/DbQueryCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @athenna/database
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { Is } 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 <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<void> {
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()
}
}
}
4 changes: 3 additions & 1 deletion src/database/drivers/BaseKnexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,9 @@ export class BaseKnexDriver extends Driver<Knex, Knex.QueryBuilder> {
* Calculate the average of a given column using distinct.
*/
public async countDistinct(column: string): Promise<number> {
const [{ count }] = await this.qb.clearSelect().countDistinct({ count: column })
const [{ count }] = await this.qb
.clearSelect()
.countDistinct({ count: column })

return Number(count)
}
Expand Down
90 changes: 90 additions & 0 deletions src/models/builders/ModelQueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<K extends ModelRelations<M>>(
relation: K | string,
closure?: (
query: ModelQueryBuilder<
Extract<M[K] extends BaseModel[] ? M[K][0] : M[K], BaseModel>,
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<M> | ModelRelations<M> | 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.
*/
Expand Down
55 changes: 55 additions & 0 deletions tests/fixtures/consoles/db-query-console.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @athenna/database
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { 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)
108 changes: 108 additions & 0 deletions tests/unit/commands/DbQueryCommandTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* @athenna/database
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { 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('{}')
}
}
Loading
Loading