From dcb41657c5295acda5658d52d99788273989ac33 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sun, 5 Jul 2026 05:25:19 +0100 Subject: [PATCH 1/3] feat(cli): add db command for executing raw SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arkorm's equivalent of `artisan db`. Executes a raw SQL statement against the configured adapter and prints the result as a table (or JSON). - SQL source: positional argument, --file=, or an interactive editor (this.editor) when run with no argument, so a bare `arkorm db` lets you compose a statement. - --bindings= supplies positional ? bindings; --json prints rows as JSON. Results render as an aligned table with a row count; empty results report '0 rows returned'. - Requires an adapter that supports raw queries (Kysely/PostgreSQL); reports a clear error otherwise. Tests: base (fake rawQuery adapter — table/JSON/bindings/file/editor/errors) and postgres e2e against the Kysely adapter. Docs: 'Run raw SQL' section. --- docs/src/guide/migrations-cli.md | 26 ++++ src/cli/commands/DbCommand.ts | 155 ++++++++++++++++++++++ src/cli/index.ts | 2 + src/index.ts | 1 + tests/base/db-command.spec.ts | 207 ++++++++++++++++++++++++++++++ tests/postgres/db-command.spec.ts | 68 ++++++++++ 6 files changed, 459 insertions(+) create mode 100644 src/cli/commands/DbCommand.ts create mode 100644 tests/base/db-command.spec.ts create mode 100644 tests/postgres/db-command.spec.ts diff --git a/docs/src/guide/migrations-cli.md b/docs/src/guide/migrations-cli.md index f634ee9..2d7105f 100644 --- a/docs/src/guide/migrations-cli.md +++ b/docs/src/guide/migrations-cli.md @@ -509,6 +509,32 @@ 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 compose a statement in your `$EDITOR`, or pass the +SQL directly: + +```sh +npx arkorm db # opens your editor +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 +``` + +- SQL source resolves in this order: the positional argument, then `--file=`, + then the interactive editor. +- `--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: diff --git a/src/cli/commands/DbCommand.ts b/src/cli/commands/DbCommand.ts new file mode 100644 index 0000000..5470de6 --- /dev/null +++ b/src/cli/commands/DbCommand.ts @@ -0,0 +1,155 @@ +import { existsSync, readFileSync } from 'node:fs' + +import { CliApp } from '../CliApp' +import { Command } from '@h3ravel/musket' +import type { DatabaseRow, DatabaseValue } from '../../types/adapter' +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 { + protected signature = `db + {sql? : Raw SQL statement to execute (opens an editor 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 + + 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 = this.app.getConfig('adapter') + if (!adapter) + return void this.error('Error: Raw queries require a configured database adapter.') + + if (typeof adapter.rawQuery !== 'function') + return void this.error( + 'Error: The configured adapter does not support raw queries (rawQuery).', + ) + + 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 { + 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.editor('Enter the SQL to execute', '.sql', '') + } + + /** + * 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((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) + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 09e6ecf..9ddae9e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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' @@ -22,6 +23,7 @@ await Kernel.init(app, { name: 'Arkormˣ CLI', baseCommands: [ InitCommand, + DbCommand, MakeModelCommand, MakeFactoryCommand, MakeSeederCommand, diff --git a/src/index.ts b/src/index.ts index c702211..baa8d6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/tests/base/db-command.spec.ts b/tests/base/db-command.spec.ts new file mode 100644 index 0000000..f782344 --- /dev/null +++ b/tests/base/db-command.spec.ts @@ -0,0 +1,207 @@ +import { CliApp } from '../../src' +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' + +import { DbCommand } from '../../src/cli/commands/DbCommand' +import { Kernel } from '@h3ravel/musket' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const tempDirs: string[] = [] + +const attachIo = ( + command: any, + options: Record = {}, + args: Record = {}, + editorReturn = '', +) => { + const successLines: string[] = [] + const errorLines: string[] = [] + const lines: string[] = [] + let editorOpened = false + + command.option = (name: string, fallback?: unknown) => options[name] ?? fallback + command.options = () => options + command.argument = (name: string) => args[name] + command.success = (line: string) => successLines.push(line) + command.error = (line: string) => errorLines.push(line) + command.line = (line: string) => lines.push(line) + command.editor = async () => { + editorOpened = true + + return editorReturn + } + + return { successLines, errorLines, lines, editorOpened: () => editorOpened } +} + +const makeAdapter = ( + rows: Array>, + behavior: { throw?: string } = {}, +) => { + const calls: Array<{ sql: string; bindings?: unknown[] }> = [] + + const adapter = { + rawQuery: async (spec: { sql: string; bindings?: unknown[] }) => { + calls.push(spec) + if (behavior.throw) throw new Error(behavior.throw) + + return rows + }, + } + + return { adapter, calls } +} + +const runDb = async ( + adapter: unknown, + options: Record, + args: Record = {}, + editorReturn = '', +) => { + const app = new CliApp() + // Stub config resolution so the command sees exactly the adapter under test. + ;(app as unknown as { getConfig: (key: string) => unknown }).getConfig = (key: string) => + key === 'adapter' ? adapter : undefined + const command = new DbCommand(app, new Kernel(app)) + ;(command as unknown as { app: CliApp }).app = app + const io = attachIo(command as unknown as any, options, args, editorReturn) + + await command.handle() + + return io +} + +afterEach(() => { + tempDirs.splice(0).forEach((directory) => rmSync(directory, { recursive: true, force: true })) +}) + +describe('DbCommand (raw SQL)', () => { + it('executes SQL from the argument and renders a table', async () => { + const { adapter, calls } = makeAdapter([ + { id: 1, name: 'Jane' }, + { id: 2, name: 'John' }, + ]) + + const io = await runDb(adapter, {}, { sql: 'select id, name from users' }) + + expect(io.errorLines).toHaveLength(0) + expect(calls).toEqual([{ sql: 'select id, name from users', bindings: [] }]) + + const table = io.lines.join('\n') + expect(table).toContain('id') + expect(table).toContain('name') + expect(table).toContain('Jane') + expect(table).toContain('John') + expect(io.successLines.some((line) => line.includes('(2 rows)'))).toBe(true) + }) + + it('renders NULL for null cells and singular row count', async () => { + const { adapter } = makeAdapter([{ id: 1, name: null }]) + + const io = await runDb(adapter, {}, { sql: 'select 1' }) + + expect(io.lines.join('\n')).toContain('NULL') + expect(io.successLines.some((line) => line.includes('(1 row)'))).toBe(true) + }) + + it('outputs JSON with --json', async () => { + const rows = [{ id: 1, active: true }] + const { adapter } = makeAdapter(rows) + + const io = await runDb(adapter, { json: true }, { sql: 'select 1' }) + + expect(JSON.parse(io.lines.join('\n'))).toEqual(rows) + }) + + it('passes parsed --bindings to the adapter', async () => { + const { adapter, calls } = makeAdapter([]) + + await runDb( + adapter, + { bindings: '[1, "active"]' }, + { sql: 'select * from users where id = ? and status = ?' }, + ) + + expect(calls[0]?.bindings).toEqual([1, 'active']) + }) + + it('reads SQL from --file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'arkormx-db-cmd-')) + tempDirs.push(dir) + const file = join(dir, 'query.sql') + writeFileSync(file, 'select 42 as answer') + + const { adapter, calls } = makeAdapter([{ answer: 42 }]) + + const io = await runDb(adapter, { file }, {}) + + expect(io.errorLines).toHaveLength(0) + expect(calls[0]?.sql).toBe('select 42 as answer') + }) + + it('reports a friendly message when no rows are returned', async () => { + const { adapter } = makeAdapter([]) + + const io = await runDb(adapter, {}, { sql: 'update users set active = true' }) + + expect(io.successLines.some((line) => line.includes('0 rows returned'))).toBe(true) + expect(io.lines).toHaveLength(0) + }) + + it('opens the editor when no SQL is given and runs the entered statement', async () => { + const { adapter, calls } = makeAdapter([{ now: '2026-07-05' }]) + + const io = await runDb(adapter, {}, {}, 'select now()') + + expect(io.editorOpened()).toBe(true) + expect(calls[0]?.sql).toBe('select now()') + expect(io.errorLines).toHaveLength(0) + }) + + it('errors when the editor is closed without any SQL', async () => { + const { adapter } = makeAdapter([]) + + const io = await runDb(adapter, {}, {}, ' ') + + expect(io.editorOpened()).toBe(true) + expect(io.errorLines.some((line) => line.includes('No SQL statement provided'))).toBe(true) + }) + + it('prefers the argument over the editor', async () => { + const { adapter, calls } = makeAdapter([]) + + const io = await runDb(adapter, {}, { sql: 'select 1' }, 'select 2') + + expect(io.editorOpened()).toBe(false) + expect(calls[0]?.sql).toBe('select 1') + }) + + it('errors on an invalid --bindings value', async () => { + const { adapter } = makeAdapter([]) + + const io = await runDb(adapter, { bindings: 'not json' }, { sql: 'select 1' }) + + expect(io.errorLines.some((line) => line.includes('--bindings must be valid JSON'))).toBe(true) + }) + + it('errors when no adapter is configured', async () => { + const io = await runDb(undefined, {}, { sql: 'select 1' }) + + expect(io.errorLines.some((line) => line.includes('configured database adapter'))).toBe(true) + }) + + it('errors when the adapter does not support rawQuery', async () => { + const io = await runDb({ select: async () => [] }, {}, { sql: 'select 1' }) + + expect(io.errorLines.some((line) => line.includes('does not support raw queries'))).toBe(true) + }) + + it('surfaces adapter execution errors', async () => { + const { adapter } = makeAdapter([], { throw: 'syntax error at or near "slect"' }) + + const io = await runDb(adapter, {}, { sql: 'slect 1' }) + + expect(io.errorLines.some((line) => line.includes('syntax error'))).toBe(true) + }) +}) diff --git a/tests/postgres/db-command.spec.ts b/tests/postgres/db-command.spec.ts new file mode 100644 index 0000000..c1aa2d9 --- /dev/null +++ b/tests/postgres/db-command.spec.ts @@ -0,0 +1,68 @@ +import { Kysely, PostgresDialect } from 'kysely' +import { afterAll, describe, expect, it } from 'vitest' +import { CliApp } from '../../src' +import { DbCommand } from '../../src/cli/commands/DbCommand' +import { Kernel } from '@h3ravel/musket' +import { Pool } from 'pg' +import { createKyselyAdapter } from '../../src' + +describe('DbCommand against the Kysely adapter', () => { + const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + const db = new Kysely>({ dialect: new PostgresDialect({ pool }) }) + const adapter = createKyselyAdapter(db) + + afterAll(async () => { + await db.destroy() + }) + + const runDb = async (options: Record, args: Record = {}) => { + const app = new CliApp() + ;(app as unknown as { getConfig: (key: string) => unknown }).getConfig = (key: string) => + key === 'adapter' ? adapter : undefined + const command = new DbCommand(app, new Kernel(app)) + ;(command as unknown as { app: CliApp }).app = app + + const lines: string[] = [] + const successLines: string[] = [] + const errorLines: string[] = [] + Object.assign(command as unknown as Record, { + option: (name: string, fallback?: unknown) => options[name] ?? fallback, + options: () => options, + argument: (name: string) => args[name], + line: (line: string) => lines.push(line), + success: (line: string) => successLines.push(line), + error: (line: string) => errorLines.push(line), + }) + + await command.handle() + + return { lines, successLines, errorLines } + } + + it('executes a real SELECT and renders the result', async () => { + const io = await runDb({}, { sql: "select 1 as one, 'hi' as greeting" }) + + expect(io.errorLines).toHaveLength(0) + const table = io.lines.join('\n') + expect(table).toContain('one') + expect(table).toContain('greeting') + expect(table).toContain('hi') + expect(io.successLines.some((line) => line.includes('(1 row)'))).toBe(true) + }) + + it('applies positional bindings', async () => { + const io = await runDb( + { json: true, bindings: '[7]' }, + { sql: 'select ? as answer' }, + ) + + expect(io.errorLines).toHaveLength(0) + expect(JSON.parse(io.lines.join('\n'))).toEqual([{ answer: 7 }]) + }) + + it('surfaces SQL errors from the adapter', async () => { + const io = await runDb({}, { sql: 'select * from a_table_that_does_not_exist_xyz' }) + + expect(io.errorLines.length).toBeGreaterThan(0) + }) +}) From 84a73f17983d58ff26c6cdc35d7216f79696818f Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sun, 5 Jul 2026 06:49:57 +0100 Subject: [PATCH 2/3] fix(cli): load arkormx.config so db resolves the configured adapter The db command read the adapter from a freshly constructed CliApp that had not applied the user config yet, so it always reported 'Raw queries require a configured database adapter'. It now calls loadArkormConfig() first (as the migrate commands do), connecting through the project's configured driver. Adds a regression test that runs db against an adapter declared only in a temporary arkormx.config (no pre-set adapter), plus test isolation so config loading runs in an empty working directory. --- docs/src/guide/migrations-cli.md | 11 ++++-- package.json | 4 +- pnpm-lock.yaml | 66 ++++++++++++++++++++++++++++--- src/cli/commands/DbCommand.ts | 9 +++-- tests/base/db-command.spec.ts | 39 +++++++++++++++++- tests/postgres/db-command.spec.ts | 21 +++++++++- 6 files changed, 131 insertions(+), 19 deletions(-) diff --git a/docs/src/guide/migrations-cli.md b/docs/src/guide/migrations-cli.md index 2d7105f..2bb65fa 100644 --- a/docs/src/guide/migrations-cli.md +++ b/docs/src/guide/migrations-cli.md @@ -514,19 +514,22 @@ npx arkorm migrate:history --delete `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 compose a statement in your `$EDITOR`, or pass the -SQL directly: +Run `db` with no argument to type a statement at an interactive multi-line +prompt, or pass the SQL directly: ```sh -npx arkorm db # opens your editor +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=`, - then the interactive editor. + then the interactive prompt. - `--bindings`: a JSON array of positional values for `?` placeholders. - `--json`: print rows as JSON instead of a table. diff --git a/package.json b/package.json index 33307c2..851d936 100644 --- a/package.json +++ b/package.json @@ -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", @@ -114,4 +114,4 @@ "pg": "^8.19.0" }, "packageManager": "pnpm@10.0.0" -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 166fa25..5af2d17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^5.4.0 version: 5.4.0 '@h3ravel/musket': - specifier: ^2.2.1 - version: 2.2.1(@types/node@25.6.2) + specifier: ^2.2.2 + version: 2.2.2(@types/node@25.6.2) '@h3ravel/shared': specifier: ^2.2.1 version: 2.2.1(@types/node@25.6.2) @@ -172,6 +172,10 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@cli-prompts/multiline@1.0.0': + resolution: {integrity: sha512-Jl9c1dra6M1I+yRDHC5FB2LtF/gWN9FmYX9/IKKtA6nQI2cuRde52/0E6f0D6CZffLExLvhvuKbfYrPbnBPBug==} + engines: {node: '>=18'} + '@docsearch/css@4.6.0': resolution: {integrity: sha512-YlcAimkXclvqta47g47efzCM5CFxDwv2ClkDfEs/fC/Ak0OxPH2b3czwa4o8O1TRBf+ujFF2RiUwszz2fPVNJQ==} @@ -413,13 +417,16 @@ packages: '@h3ravel/contracts@2.2.1': resolution: {integrity: sha512-l1v++lRkxq1L10TEfLEDrECPDUcTZAb4J5d0ZAncgprKDssmXw+1D9GUQVg9NbS+6gNWaUtGVVkjrCo87wFNjw==} - '@h3ravel/musket@2.2.1': - resolution: {integrity: sha512-zLw+ATzc2gEc9BwIpsGdrSD9qTsZE0HdqrRVVNzBPPnrFdlGOI6mkmDBitdt+nJITVrMyxhl+FtPIzxoa2n1XA==} + '@h3ravel/musket@2.2.2': + resolution: {integrity: sha512-waXWZeJduuZcDvIsLaTMTEDnpuQKFpb8l8CYCh8HVJ5IctaW6UMlkUFXt8UgkyjHzjWzwqiLjvCtOmAnMCfx5A==} engines: {node: ^20.19.0 || >=22.12.0} '@h3ravel/shared@2.2.1': resolution: {integrity: sha512-0hTbVo4LoIUtxEWULGmpfiVaCNNn9XChXhIs8zrTSDYl5RTbpHyyL4MBlX2W9tdRPSSiBfN3GvEQ9ts7pM1bkg==} + '@h3ravel/shared@2.2.4': + resolution: {integrity: sha512-h8DrvKK54ar69DH2Np7J5y1mLVBPHktFgwqLH8VMv6OIlSfRYjApCd5YUkmfNdm55aEqdZ03Q4UItMRw/QS6iQ==} + '@h3ravel/support@2.2.1': resolution: {integrity: sha512-+7TCcas+cESqslX4hljjoR/+1BdTjQXaqNXwDQ0U6fER4XdEaxfUA0wRehRnyAns2uk2aSO7MYe+InKYz+++BA==} @@ -1480,6 +1487,10 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1700,6 +1711,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console-table-printer@2.16.1: + resolution: {integrity: sha512-Sc9FRJ4O9xKGNrvulNdPfK5SyBcZ6lcaRnDE4AQ/uw6IDtjHhsqyzzqcnMikjyGaiOOF2tNOKoBhbVjRvFy9Lw==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -1848,6 +1862,10 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -3098,6 +3116,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-wcswidth@1.1.2: + resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} + slugify@1.6.6: resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} engines: {node: '>=8.0.0'} @@ -3564,6 +3585,10 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@cli-prompts/multiline@1.0.0': + dependencies: + ansi-escapes: 7.3.0 + '@docsearch/css@4.6.0': {} '@docsearch/js@4.6.0': {} @@ -3737,9 +3762,9 @@ snapshots: transitivePeerDependencies: - crossws - '@h3ravel/musket@2.2.1(@types/node@25.6.2)': + '@h3ravel/musket@2.2.2(@types/node@25.6.2)': dependencies: - '@h3ravel/shared': 2.2.1(@types/node@25.6.2) + '@h3ravel/shared': 2.2.4(@types/node@25.6.2) chalk: 5.6.2 commander: 14.0.3 dayjs: 1.11.19 @@ -3768,6 +3793,23 @@ snapshots: - '@types/node' - crossws + '@h3ravel/shared@2.2.4(@types/node@25.6.2)': + dependencies: + '@cli-prompts/multiline': 1.0.0 + '@inquirer/prompts': 7.10.1(@types/node@25.6.2) + chalk: 5.6.2 + console-table-printer: 2.16.1 + edge.js: 6.5.0 + escalade: 3.2.0 + h3: 2.0.1-rc.22 + inquirer-autocomplete-standalone: 0.8.1 + jiti: 2.7.0 + ora: 9.3.0 + preferred-pm: 4.1.1 + transitivePeerDependencies: + - '@types/node' + - crossws + '@h3ravel/support@2.2.1(@types/node@25.6.2)': dependencies: '@h3ravel/collect.js': 5.4.0 @@ -4799,6 +4841,10 @@ snapshots: dependencies: type-fest: 0.21.3 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -4973,6 +5019,10 @@ snapshots: consola@3.4.2: {} + console-table-printer@2.16.1: + dependencies: + simple-wcswidth: 1.1.2 + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -5088,6 +5138,8 @@ snapshots: env-paths@3.0.0: {} + environment@1.1.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -6582,6 +6634,8 @@ snapshots: signal-exit@4.1.0: {} + simple-wcswidth@1.1.2: {} + slugify@1.6.6: {} source-map-js@1.2.1: {} diff --git a/src/cli/commands/DbCommand.ts b/src/cli/commands/DbCommand.ts index 5470de6..e086903 100644 --- a/src/cli/commands/DbCommand.ts +++ b/src/cli/commands/DbCommand.ts @@ -1,8 +1,9 @@ +import type { DatabaseRow, DatabaseValue } from '../../types/adapter' import { existsSync, readFileSync } from 'node:fs' import { CliApp } from '../CliApp' import { Command } from '@h3ravel/musket' -import type { DatabaseRow, DatabaseValue } from '../../types/adapter' +import { loadArkormConfig } from '../../helpers/runtime-config' import { resolve } from 'node:path' /** @@ -13,7 +14,7 @@ import { resolve } from 'node:path' */ export class DbCommand extends Command { protected signature = `db - {sql? : Raw SQL statement to execute (opens an editor when omitted)} + {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} @@ -23,6 +24,8 @@ export class DbCommand extends Command { async handle() { this.app.command = this + await loadArkormConfig() + console.log(this.app.getConfig()) const sql = await this.resolveSql() if (sql === null) return @@ -77,7 +80,7 @@ export class DbCommand extends Command { } // No SQL passed — open the user's editor to compose a statement. - return await this.editor('Enter the SQL to execute', '.sql', '') + return await this.multiline('Query', 'Enter the SQL to execute') } /** diff --git a/tests/base/db-command.spec.ts b/tests/base/db-command.spec.ts index f782344..f83aec9 100644 --- a/tests/base/db-command.spec.ts +++ b/tests/base/db-command.spec.ts @@ -1,4 +1,4 @@ -import { CliApp } from '../../src' +import { CliApp, resetArkormRuntimeForTests } from '../../src' import { afterEach, describe, expect, it } from 'vitest' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -7,8 +7,16 @@ import { Kernel } from '@h3ravel/musket' import { join } from 'node:path' import { tmpdir } from 'node:os' +const originalCwd = process.cwd() const tempDirs: string[] = [] +const makeTempDir = (): string => { + const directory = mkdtempSync(join(tmpdir(), 'arkormx-db-cmd-')) + tempDirs.push(directory) + + return directory +} + const attachIo = ( command: any, options: Record = {}, @@ -26,7 +34,7 @@ const attachIo = ( command.success = (line: string) => successLines.push(line) command.error = (line: string) => errorLines.push(line) command.line = (line: string) => lines.push(line) - command.editor = async () => { + command.multiline = async () => { editorOpened = true return editorReturn @@ -59,6 +67,9 @@ const runDb = async ( args: Record = {}, editorReturn = '', ) => { + // Run from an empty directory so loadArkormConfig() finds no project config. + process.chdir(makeTempDir()) + const app = new CliApp() // Stub config resolution so the command sees exactly the adapter under test. ;(app as unknown as { getConfig: (key: string) => unknown }).getConfig = (key: string) => @@ -73,6 +84,8 @@ const runDb = async ( } afterEach(() => { + process.chdir(originalCwd) + resetArkormRuntimeForTests() tempDirs.splice(0).forEach((directory) => rmSync(directory, { recursive: true, force: true })) }) @@ -204,4 +217,26 @@ describe('DbCommand (raw SQL)', () => { expect(io.errorLines.some((line) => line.includes('syntax error'))).toBe(true) }) + + it('loads the adapter from arkormx.config when none is pre-set (regression)', async () => { + // No getConfig stub: the command must load the project config itself and + // resolve the adapter from it — the original bug returned "no adapter". + const dir = makeTempDir() + writeFileSync( + join(dir, 'arkormx.config.cjs'), + 'module.exports = { adapter: { rawQuery: async () => [{ loaded_from: "config" }] } }\n', + ) + process.chdir(dir) + + const app = new CliApp() + const command = new DbCommand(app, new Kernel(app)) + ;(command as unknown as { app: CliApp }).app = app + const io = attachIo(command as unknown as any, {}, { sql: 'select 1' }) + + await command.handle() + + expect(io.errorLines).toHaveLength(0) + expect(io.lines.join('\n')).toContain('loaded_from') + expect(io.lines.join('\n')).toContain('config') + }) }) diff --git a/tests/postgres/db-command.spec.ts b/tests/postgres/db-command.spec.ts index c1aa2d9..e0e9522 100644 --- a/tests/postgres/db-command.spec.ts +++ b/tests/postgres/db-command.spec.ts @@ -1,21 +1,37 @@ import { Kysely, PostgresDialect } from 'kysely' -import { afterAll, describe, expect, it } from 'vitest' -import { CliApp } from '../../src' +import { afterAll, afterEach, describe, expect, it } from 'vitest' +import { CliApp, resetArkormRuntimeForTests } from '../../src' +import { mkdtempSync, rmSync } from 'node:fs' import { DbCommand } from '../../src/cli/commands/DbCommand' import { Kernel } from '@h3ravel/musket' import { Pool } from 'pg' import { createKyselyAdapter } from '../../src' +import { join } from 'node:path' +import { tmpdir } from 'node:os' describe('DbCommand against the Kysely adapter', () => { const pool = new Pool({ connectionString: process.env.DATABASE_URL }) const db = new Kysely>({ dialect: new PostgresDialect({ pool }) }) const adapter = createKyselyAdapter(db) + const originalCwd = process.cwd() + const tempDirs: string[] = [] + + afterEach(() => { + process.chdir(originalCwd) + resetArkormRuntimeForTests() + tempDirs.splice(0).forEach((directory) => rmSync(directory, { recursive: true, force: true })) + }) afterAll(async () => { await db.destroy() }) const runDb = async (options: Record, args: Record = {}) => { + // Run from an empty directory so loadArkormConfig() finds no project config. + const directory = mkdtempSync(join(tmpdir(), 'arkormx-db-cmd-pg-')) + tempDirs.push(directory) + process.chdir(directory) + const app = new CliApp() ;(app as unknown as { getConfig: (key: string) => unknown }).getConfig = (key: string) => key === 'adapter' ? adapter : undefined @@ -32,6 +48,7 @@ describe('DbCommand against the Kysely adapter', () => { line: (line: string) => lines.push(line), success: (line: string) => successLines.push(line), error: (line: string) => errorLines.push(line), + multiline: async () => '', }) await command.handle() From 59538c5feed9af721aa5f75db424af407b29f0c4 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sun, 5 Jul 2026 07:12:04 +0100 Subject: [PATCH 3/3] fix(cli): resolve db adapter via the runtime resolver; drop debug log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the effective adapter the same way models do — getRuntimeAdapter() with a getRuntimeCompatibilityAdapter() fallback — so db works whether the project configures an explicit adapter or a client, and give an accurate message when the resolved adapter cannot run raw SQL. Also removes a stray debug console.log and switches the tests to configure the runtime adapter so they exercise the real resolution path. --- package.json | 2 +- src/cli/commands/DbCommand.ts | 15 ++++++++------- tests/base/db-command.spec.ts | 16 ++++++---------- tests/postgres/db-command.spec.ts | 10 +++------- 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 851d936..0807b74 100644 --- a/package.json +++ b/package.json @@ -114,4 +114,4 @@ "pg": "^8.19.0" }, "packageManager": "pnpm@10.0.0" -} \ No newline at end of file +} diff --git a/src/cli/commands/DbCommand.ts b/src/cli/commands/DbCommand.ts index e086903..0590683 100644 --- a/src/cli/commands/DbCommand.ts +++ b/src/cli/commands/DbCommand.ts @@ -1,9 +1,10 @@ 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 { loadArkormConfig } from '../../helpers/runtime-config' +import { getRuntimeCompatibilityAdapter } from '../../helpers/runtime-compatibility' import { resolve } from 'node:path' /** @@ -25,23 +26,23 @@ export class DbCommand extends Command { async handle() { this.app.command = this await loadArkormConfig() - console.log(this.app.getConfig()) const sql = await this.resolveSql() if (sql === null) return - if (sql.trim().length === 0) - return void this.error('Error: No SQL statement provided.') + if (sql.trim().length === 0) return void this.error('Error: No SQL statement provided.') const bindings = this.resolveBindings() if (bindings === null) return - const adapter = this.app.getConfig('adapter') + const adapter = getRuntimeAdapter() ?? getRuntimeCompatibilityAdapter() if (!adapter) - return void this.error('Error: Raw queries require a configured database 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 (rawQuery).', + 'Error: The configured adapter does not support raw queries. Use a SQL-backed adapter (e.g. the Kysely/PostgreSQL adapter).', ) let rows: DatabaseRow[] diff --git a/tests/base/db-command.spec.ts b/tests/base/db-command.spec.ts index f83aec9..ffeff06 100644 --- a/tests/base/db-command.spec.ts +++ b/tests/base/db-command.spec.ts @@ -1,4 +1,4 @@ -import { CliApp, resetArkormRuntimeForTests } from '../../src' +import { CliApp, configureArkormRuntime, resetArkormRuntimeForTests } from '../../src' import { afterEach, describe, expect, it } from 'vitest' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -43,10 +43,7 @@ const attachIo = ( return { successLines, errorLines, lines, editorOpened: () => editorOpened } } -const makeAdapter = ( - rows: Array>, - behavior: { throw?: string } = {}, -) => { +const makeAdapter = (rows: Array>, behavior: { throw?: string } = {}) => { const calls: Array<{ sql: string; bindings?: unknown[] }> = [] const adapter = { @@ -69,11 +66,10 @@ const runDb = async ( ) => { // Run from an empty directory so loadArkormConfig() finds no project config. process.chdir(makeTempDir()) + // Configure the runtime adapter so the command resolves it the real way. + if (adapter !== undefined) configureArkormRuntime(undefined, { adapter: adapter as never }) const app = new CliApp() - // Stub config resolution so the command sees exactly the adapter under test. - ;(app as unknown as { getConfig: (key: string) => unknown }).getConfig = (key: string) => - key === 'adapter' ? adapter : undefined const command = new DbCommand(app, new Kernel(app)) ;(command as unknown as { app: CliApp }).app = app const io = attachIo(command as unknown as any, options, args, editorReturn) @@ -198,10 +194,10 @@ describe('DbCommand (raw SQL)', () => { expect(io.errorLines.some((line) => line.includes('--bindings must be valid JSON'))).toBe(true) }) - it('errors when no adapter is configured', async () => { + it('errors when no database driver is configured', async () => { const io = await runDb(undefined, {}, { sql: 'select 1' }) - expect(io.errorLines.some((line) => line.includes('configured database adapter'))).toBe(true) + expect(io.errorLines.some((line) => line.includes('No database driver configured'))).toBe(true) }) it('errors when the adapter does not support rawQuery', async () => { diff --git a/tests/postgres/db-command.spec.ts b/tests/postgres/db-command.spec.ts index e0e9522..5de264c 100644 --- a/tests/postgres/db-command.spec.ts +++ b/tests/postgres/db-command.spec.ts @@ -1,6 +1,6 @@ import { Kysely, PostgresDialect } from 'kysely' import { afterAll, afterEach, describe, expect, it } from 'vitest' -import { CliApp, resetArkormRuntimeForTests } from '../../src' +import { CliApp, configureArkormRuntime, resetArkormRuntimeForTests } from '../../src' import { mkdtempSync, rmSync } from 'node:fs' import { DbCommand } from '../../src/cli/commands/DbCommand' import { Kernel } from '@h3ravel/musket' @@ -31,10 +31,9 @@ describe('DbCommand against the Kysely adapter', () => { const directory = mkdtempSync(join(tmpdir(), 'arkormx-db-cmd-pg-')) tempDirs.push(directory) process.chdir(directory) + configureArkormRuntime(undefined, { adapter }) const app = new CliApp() - ;(app as unknown as { getConfig: (key: string) => unknown }).getConfig = (key: string) => - key === 'adapter' ? adapter : undefined const command = new DbCommand(app, new Kernel(app)) ;(command as unknown as { app: CliApp }).app = app @@ -68,10 +67,7 @@ describe('DbCommand against the Kysely adapter', () => { }) it('applies positional bindings', async () => { - const io = await runDb( - { json: true, bindings: '[7]' }, - { sql: 'select ? as answer' }, - ) + const io = await runDb({ json: true, bindings: '[7]' }, { sql: 'select ? as answer' }) expect(io.errorLines).toHaveLength(0) expect(JSON.parse(io.lines.join('\n'))).toEqual([{ answer: 7 }])