Skip to content

Commit 1b3b933

Browse files
authored
Merge pull request #234 from AthennaIO/develop
feat: add new search and orWhereHas methods
2 parents 56fe9ca + 0b0e435 commit 1b3b933

9 files changed

Lines changed: 491 additions & 4 deletions

File tree

configurer/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export default class DatabaseConfigurer extends BaseConfigurer {
5959
path: '@athenna/database/commands/DbWipeCommand',
6060
loadApp: true
6161
})
62+
.setTo('commands', 'db:query', {
63+
path: '@athenna/database/commands/DbQueryCommand',
64+
loadApp: true
65+
})
6266
.setTo('commands', 'migration:run', {
6367
path: '@athenna/database/commands/MigrationRunCommand',
6468
loadApp: true

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@athenna/database",
3-
"version": "5.52.0",
3+
"version": "5.53.0",
44
"description": "The Athenna database handler for SQL/NoSQL.",
55
"license": "MIT",
66
"author": "João Lenon <lenon@athenna.io>",
@@ -51,6 +51,7 @@
5151
"./package.json": "./package.json",
5252
"./testing/plugins": "./src/testing/plugins/index.js",
5353
"./commands/DbFreshCommand": "./src/commands/DbFreshCommand.js",
54+
"./commands/DbQueryCommand": "./src/commands/DbQueryCommand.js",
5455
"./commands/DbSeedCommand": "./src/commands/DbSeedCommand.js",
5556
"./commands/DbWipeCommand": "./src/commands/DbWipeCommand.js",
5657
"./commands/MakeCrudCommand": "./src/commands/MakeCrudCommand.js",
@@ -215,6 +216,9 @@
215216
"db:wipe": {
216217
"path": "#src/commands/DbWipeCommand"
217218
},
219+
"db:query": {
220+
"path": "#src/commands/DbQueryCommand"
221+
},
218222
"make:model": {
219223
"path": "#src/commands/MakeModelCommand"
220224
},

src/commands/DbQueryCommand.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @athenna/database
3+
*
4+
* (c) João Lenon <lenon@athenna.io>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { Is } from '@athenna/common'
11+
import { Database } from '#src/facades/Database'
12+
import { BaseCommand, Argument, Option } from '@athenna/artisan'
13+
14+
export class DbQueryCommand extends BaseCommand {
15+
@Argument({
16+
signature: 'query...',
17+
description: 'The raw SQL query to execute.'
18+
})
19+
public query: string[]
20+
21+
@Option({
22+
default: 'default',
23+
signature: '-c, --connection <connection>',
24+
description: 'Set the the database connection.'
25+
})
26+
public connection: string
27+
28+
public static signature(): string {
29+
return 'db:query'
30+
}
31+
32+
public static description(): string {
33+
return 'Run a raw SQL query against the database.'
34+
}
35+
36+
public async handle(): Promise<void> {
37+
this.logger.simple('({bold,green} [ RUNNING QUERY ])\n')
38+
39+
const sql = this.query.join(' ')
40+
const DB = Database.connection(this.connection)
41+
42+
try {
43+
const result = await DB.raw(sql)
44+
45+
if (result === null || result === undefined) {
46+
return
47+
}
48+
49+
if (Is.Object(result) || Is.Array(result)) {
50+
this.logger.simple(JSON.stringify(result))
51+
52+
return
53+
}
54+
55+
this.logger.simple(String(result))
56+
} finally {
57+
await DB.close()
58+
}
59+
}
60+
}

src/database/drivers/BaseKnexDriver.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,9 @@ export class BaseKnexDriver extends Driver<Knex, Knex.QueryBuilder> {
380380
* Calculate the average of a given column using distinct.
381381
*/
382382
public async countDistinct(column: string): Promise<number> {
383-
const [{ count }] = await this.qb.clearSelect().countDistinct({ count: column })
383+
const [{ count }] = await this.qb
384+
.clearSelect()
385+
.countDistinct({ count: column })
384386

385387
return Number(count)
386388
}

src/models/builders/ModelQueryBuilder.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,96 @@ export class ModelQueryBuilder<
625625
return this
626626
}
627627

628+
/**
629+
* Same as {@link ModelQueryBuilder.whereHas}, but joins the resulting
630+
* `EXISTS (...)` clause to the surrounding WHERE with `OR` instead of `AND`.
631+
*
632+
* Useful inside a grouped `where(qb => ...)` closure to build expressions
633+
* like `(directCol ILIKE x OR relation.col ILIKE x)` without resorting to
634+
* raw SQL.
635+
*/
636+
public orWhereHas<K extends ModelRelations<M>>(
637+
relation: K | string,
638+
closure?: (
639+
query: ModelQueryBuilder<
640+
Extract<M[K] extends BaseModel[] ? M[K][0] : M[K], BaseModel>,
641+
Driver
642+
>
643+
) => any
644+
) {
645+
const options = this.schema.includeWhereHasRelation(relation, closure)
646+
647+
/**
648+
* Snapshot the full options object immediately at call time, before any
649+
* subsequent `with(sameRelation)` call can mutate the shared `options`
650+
* object (e.g. overwriting `closure` or `withClosure`). Because this
651+
* spread happens here — outside the Knex callback — the snapshot is
652+
* frozen regardless of what happens to `options` afterwards.
653+
*/
654+
const snapshot = { ...options }
655+
656+
super.orWhereExists(query => {
657+
switch (snapshot.type) {
658+
case 'hasOne':
659+
return HasOneRelation.whereHas(this.Model, query, snapshot)
660+
case 'hasMany':
661+
return HasManyRelation.whereHas(this.Model, query, snapshot)
662+
case 'belongsTo':
663+
return BelongsToRelation.whereHas(this.Model, query, snapshot)
664+
case 'belongsToMany':
665+
return BelongsToManyRelation.whereHas(this.Model, query, snapshot)
666+
}
667+
})
668+
669+
return this
670+
}
671+
672+
/**
673+
* Build a grouped OR search across any mix of direct columns and
674+
* relation columns in a single `WHERE (...)` clause.
675+
*
676+
* Each entry in `fields` is either a direct column property (e.g. `name`)
677+
* or a `relation.column` path (e.g. `profile.bio`). The resulting SQL is a
678+
* single parenthesized group joined exclusively by `OR`. Passing a falsy
679+
* `term` short-circuits and the query is left untouched.
680+
*
681+
* @example
682+
* ```ts
683+
* User.query().search(['name', 'email', 'profile.bio'], 'john')
684+
* ```
685+
*/
686+
public search(
687+
fields: (ModelColumns<M> | ModelRelations<M> | string)[],
688+
term: string
689+
) {
690+
if (!term) {
691+
return this
692+
}
693+
694+
const value = `%${term}%`
695+
696+
this.where(qb => {
697+
fields.forEach((field, i) => {
698+
const isRelation = (field as string).includes('.')
699+
700+
if (isRelation) {
701+
const [relation, column] = (field as string).split('.')
702+
const relOp = i === 0 ? 'whereHas' : 'orWhereHas'
703+
704+
;(qb as any)[relOp](relation, (q: any) => q.whereILike(column, value))
705+
706+
return
707+
}
708+
709+
const op = i === 0 ? 'whereILike' : 'orWhereILike'
710+
711+
;(qb as any)[op](field, value)
712+
})
713+
})
714+
715+
return this
716+
}
717+
628718
/**
629719
* Executes the given closure when the first argument is true.
630720
*/
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @athenna/database
3+
*
4+
* (c) João Lenon <lenon@athenna.io>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { Path } from '@athenna/common'
11+
import { ViewProvider } from '@athenna/view'
12+
import { Rc, Config } from '@athenna/config'
13+
import { FakeDriver } from '#src/database/drivers/FakeDriver'
14+
import { DatabaseProvider } from '#src/providers/DatabaseProvider'
15+
import { Artisan, ConsoleKernel, ArtisanProvider } from '@athenna/artisan'
16+
17+
new ViewProvider().register()
18+
new ArtisanProvider().register()
19+
new DatabaseProvider().register()
20+
21+
await Config.loadAll(Path.fixtures('config'))
22+
23+
Rc.setFile(Path.pwd('package.json'))
24+
25+
Path.mergeDirs({
26+
seeders: 'tests/fixtures/database/seeders',
27+
migrations: 'tests/fixtures/database/migrations'
28+
})
29+
30+
switch (process.env.MOCK_RAW_TYPE) {
31+
case 'array':
32+
FakeDriver.raw = () => [{ id: 1, name: 'Lenon' }] as any
33+
break
34+
case 'number':
35+
FakeDriver.raw = () => 42 as any
36+
break
37+
case 'string':
38+
FakeDriver.raw = () => 'hello' as any
39+
break
40+
case 'boolean':
41+
FakeDriver.raw = () => true as any
42+
break
43+
case 'undefined':
44+
FakeDriver.raw = () => undefined as any
45+
break
46+
case 'throw':
47+
FakeDriver.raw = () => {
48+
throw new Error('Syntax error near token "FROOM"')
49+
}
50+
break
51+
}
52+
53+
await new ConsoleKernel().registerCommands()
54+
55+
await Artisan.parse(process.argv)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @athenna/database
3+
*
4+
* (c) João Lenon <lenon@athenna.io>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { Path } from '@athenna/common'
11+
import { AfterEach, Test, type Context } from '@athenna/test'
12+
import { BaseCommandTest } from '#tests/helpers/BaseCommandTest'
13+
14+
export default class DbQueryCommandTest extends BaseCommandTest {
15+
@AfterEach()
16+
public async afterEachQueryTest() {
17+
delete process.env.MOCK_RAW_TYPE
18+
}
19+
20+
@Test()
21+
public async shouldBeAbleToRunARawQueryAndPrintObjectResultAsJson({ command }: Context) {
22+
const output = await command.run('db:query SELECT * from users --connection=fake', {
23+
path: Path.fixtures('consoles/db-query-console.ts')
24+
})
25+
26+
output.assertSucceeded()
27+
output.assertLogged('[ RUNNING QUERY ]')
28+
output.assertLogged('{}')
29+
}
30+
31+
@Test()
32+
public async shouldPrintArrayResultAsJson({ command }: Context) {
33+
process.env.MOCK_RAW_TYPE = 'array'
34+
35+
const output = await command.run('db:query SELECT * from users --connection=fake', {
36+
path: Path.fixtures('consoles/db-query-console.ts')
37+
})
38+
39+
output.assertSucceeded()
40+
output.assertLogged('[ RUNNING QUERY ]')
41+
output.assertLogged('[{"id":1,"name":"Lenon"}]')
42+
}
43+
44+
@Test()
45+
public async shouldPrintNumberResultAsString({ command }: Context) {
46+
process.env.MOCK_RAW_TYPE = 'number'
47+
48+
const output = await command.run('db:query SELECT COUNT(*) from users --connection=fake', {
49+
path: Path.fixtures('consoles/db-query-console.ts')
50+
})
51+
52+
output.assertSucceeded()
53+
output.assertLogged('[ RUNNING QUERY ]')
54+
output.assertLogged('42')
55+
output.assertNotLogged('{')
56+
}
57+
58+
@Test()
59+
public async shouldPrintStringResultAsString({ command }: Context) {
60+
process.env.MOCK_RAW_TYPE = 'string'
61+
62+
const output = await command.run('db:query SELECT version --connection=fake', {
63+
path: Path.fixtures('consoles/db-query-console.ts')
64+
})
65+
66+
output.assertSucceeded()
67+
output.assertLogged('[ RUNNING QUERY ]')
68+
output.assertLogged('hello')
69+
}
70+
71+
@Test()
72+
public async shouldPrintBooleanResultAsString({ command }: Context) {
73+
process.env.MOCK_RAW_TYPE = 'boolean'
74+
75+
const output = await command.run('db:query SELECT 1 --connection=fake', {
76+
path: Path.fixtures('consoles/db-query-console.ts')
77+
})
78+
79+
output.assertSucceeded()
80+
output.assertLogged('[ RUNNING QUERY ]')
81+
output.assertLogged('true')
82+
}
83+
84+
@Test()
85+
public async shouldNotPrintAnyResultWhenQueryReturnsUndefined({ command }: Context) {
86+
process.env.MOCK_RAW_TYPE = 'undefined'
87+
88+
const output = await command.run('db:query INSERT INTO users VALUES (1) --connection=fake', {
89+
path: Path.fixtures('consoles/db-query-console.ts')
90+
})
91+
92+
output.assertSucceeded()
93+
output.assertLogged('[ RUNNING QUERY ]')
94+
output.assertNotLogged('undefined')
95+
output.assertNotLogged('null')
96+
}
97+
98+
@Test()
99+
public async shouldJoinMultipleTokensIntoTheRawQueryString({ command }: Context) {
100+
const output = await command.run('db:query SELECT id, name FROM users WHERE id = 1 --connection=fake', {
101+
path: Path.fixtures('consoles/db-query-console.ts')
102+
})
103+
104+
output.assertSucceeded()
105+
output.assertLogged('[ RUNNING QUERY ]')
106+
output.assertLogged('{}')
107+
}
108+
}

0 commit comments

Comments
 (0)