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
29 changes: 29 additions & 0 deletions docs/src/guide/migrations-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,35 @@ npx arkorm migrate:history --reset
npx arkorm migrate:history --delete
```

## Run raw SQL

`db` executes a raw SQL statement against the configured adapter and prints the
result — handy for quick inspections and one-off fixes.

Run `db` with no argument to type a statement at an interactive multi-line
prompt, or pass the SQL directly:

```sh
npx arkorm db # prompts for the SQL
npx arkorm db "select id, email from users limit 5"
npx arkorm db --file=./scripts/report.sql
npx arkorm db "select * from users where id = ?" --bindings="[1]"
npx arkorm db "select count(*) as total from users" --json
```

The command loads your `arkormx.config` (and its configured adapter) before
running, so it connects using the same driver as the rest of your app.

- SQL source resolves in this order: the positional argument, then `--file=<path>`,
then the interactive prompt.
- `--bindings`: a JSON array of positional values for `?` placeholders.
- `--json`: print rows as JSON instead of a table.

Rows are rendered as a table (with a trailing row count); statements that return
no rows report `0 rows returned`. The command requires an adapter that supports
raw queries (the Kysely/PostgreSQL adapter does; the Prisma compatibility adapter
does not).

## Foreign keys and relation aliases

Use `foreignKey` in table migrations to generate Prisma relation fields automatically:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
},
"dependencies": {
"@h3ravel/collect.js": "^5.4.0",
"@h3ravel/musket": "^2.2.1",
"@h3ravel/musket": "^2.2.2",
"@h3ravel/shared": "^2.2.1",
"@h3ravel/support": "^2.2.1",
"dotenv": "^17.3.1",
Expand Down
66 changes: 60 additions & 6 deletions pnpm-lock.yaml

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

159 changes: 159 additions & 0 deletions src/cli/commands/DbCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { DatabaseRow, DatabaseValue } from '../../types/adapter'
import { existsSync, readFileSync } from 'node:fs'
import { getRuntimeAdapter, loadArkormConfig } from '../../helpers/runtime-config'

import { CliApp } from '../CliApp'
import { Command } from '@h3ravel/musket'
import { getRuntimeCompatibilityAdapter } from '../../helpers/runtime-compatibility'
import { resolve } from 'node:path'

/**
* Executes a raw SQL statement against the configured database adapter and prints
* the result — Arkorm's equivalent of `artisan db`.
*
* @author Legacy (3m1n3nc3)
*/
export class DbCommand extends Command<CliApp> {
protected signature = `db
{sql? : Raw SQL statement to execute (prompts for it when omitted)}
{--file= : Read the SQL statement from a file instead of the argument}
{--bindings= : JSON array of positional bindings for ? placeholders}
{--json : Print result rows as JSON instead of a table}
`

protected description = 'Execute a raw SQL statement against the configured database'

async handle() {
this.app.command = this
await loadArkormConfig()

const sql = await this.resolveSql()
if (sql === null) return
if (sql.trim().length === 0) return void this.error('Error: No SQL statement provided.')

const bindings = this.resolveBindings()
if (bindings === null) return

const adapter = getRuntimeAdapter() ?? getRuntimeCompatibilityAdapter()
if (!adapter)
return void this.error(
'Error: No database driver configured. Set an adapter or client in arkormx.config.',
)

if (typeof adapter.rawQuery !== 'function')
return void this.error(
'Error: The configured adapter does not support raw queries. Use a SQL-backed adapter (e.g. the Kysely/PostgreSQL adapter).',
)

let rows: DatabaseRow[]
try {
rows = (await adapter.rawQuery({
sql,
bindings: bindings as DatabaseValue[],
})) as DatabaseRow[]
} catch (error) {
return void this.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
}

this.render(rows)
}

/**
* Resolves the SQL to run. Priority: the positional argument, then `--file`,
* then an interactive editor prompt (so a bare `arkorm db` lets you compose a
* statement). Returns `null` (after emitting an error) when a referenced file is
* missing.
*/
private async resolveSql(): Promise<string | null> {
const argument = this.argument('sql')
if (typeof argument === 'string' && argument.trim().length > 0) return argument

const filePath = this.option('file') ? String(this.option('file')) : undefined
if (filePath) {
const resolved = resolve(filePath)
if (!existsSync(resolved)) {
this.error(`Error: SQL file not found: ${this.app.formatPathForLog(resolved)}`)

return null
}

return readFileSync(resolved, 'utf-8')
}

// No SQL passed — open the user's editor to compose a statement.
return await this.multiline('Query', 'Enter the SQL to execute')
}

/**
* Parses `--bindings` as a JSON array. Returns `null` (after emitting an error)
* when the value is present but not a valid JSON array.
*/
private resolveBindings(): unknown[] | null {
const raw = this.option('bindings')
if (raw == null || raw === '') return []

try {
const parsed = JSON.parse(String(raw))
if (!Array.isArray(parsed)) {
this.error('Error: --bindings must be a JSON array, e.g. --bindings="[1, \\"active\\"]".')

return null
}

return parsed
} catch {
this.error('Error: --bindings must be valid JSON, e.g. --bindings="[1, \\"active\\"]".')

return null
}
}

private render(rows: DatabaseRow[]): void {
if (this.option('json')) {
this.line(JSON.stringify(rows, null, 2))

return
}

if (rows.length === 0) {
this.success('Statement executed successfully. (0 rows returned)')

return
}

this.renderTable(rows)
this.success(`(${rows.length} row${rows.length === 1 ? '' : 's'})`)
}

private renderTable(rows: DatabaseRow[]): void {
const columns = rows.reduce<string[]>((accumulator, row) => {
Object.keys(row).forEach((key) => {
if (!accumulator.includes(key)) accumulator.push(key)
})

return accumulator
}, [])

const cell = (value: unknown): string => {
if (value === null || value === undefined) return 'NULL'
if (value instanceof Date) return value.toISOString()
if (typeof value === 'object') return JSON.stringify(value)

return String(value)
}

const widths = columns.map((column) =>
rows.reduce((max, row) => Math.max(max, cell(row[column]).length), column.length),
)

const separator = `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`
const formatRow = (values: string[]): string =>
`| ${values.map((value, index) => value.padEnd(widths[index])).join(' | ')} |`

this.line(separator)
this.line(formatRow(columns))
this.line(separator)
rows.forEach((row) => this.line(formatRow(columns.map((column) => cell(row[column])))))
this.line(separator)
}
}
2 changes: 2 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node

import { CliApp } from './CliApp'
import { DbCommand } from './commands/DbCommand'
import { InitCommand } from './commands/InitCommand'
import { Kernel } from '@h3ravel/musket'
import { MakeFactoryCommand } from './commands/MakeFactoryCommand'
Expand All @@ -22,6 +23,7 @@ await Kernel.init(app, {
name: 'Arkormˣ CLI',
baseCommands: [
InitCommand,
DbCommand,
MakeModelCommand,
MakeFactoryCommand,
MakeSeederCommand,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './Arkorm'
export * from './Attribute'
export * from './casts'
export * from './cli/CliApp'
export * from './cli/commands/DbCommand'
export * from './cli/commands/InitCommand'
export * from './cli/commands/MakeFactoryCommand'
export * from './cli/commands/MakeMigrationCommand'
Expand Down
Loading
Loading