diff --git a/docs/src/guide/migrations-cli.md b/docs/src/guide/migrations-cli.md index f634ee9..2bb65fa 100644 --- a/docs/src/guide/migrations-cli.md +++ b/docs/src/guide/migrations-cli.md @@ -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=`, + 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: diff --git a/package.json b/package.json index 33307c2..0807b74 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", 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 new file mode 100644 index 0000000..0590683 --- /dev/null +++ b/src/cli/commands/DbCommand.ts @@ -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 { + 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 { + 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((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..ffeff06 --- /dev/null +++ b/tests/base/db-command.spec.ts @@ -0,0 +1,238 @@ +import { CliApp, configureArkormRuntime, resetArkormRuntimeForTests } 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 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 = {}, + 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.multiline = 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 = '', +) => { + // 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() + 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(() => { + process.chdir(originalCwd) + resetArkormRuntimeForTests() + 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 database driver is configured', async () => { + const io = await runDb(undefined, {}, { sql: 'select 1' }) + + expect(io.errorLines.some((line) => line.includes('No database driver configured'))).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) + }) + + 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 new file mode 100644 index 0000000..5de264c --- /dev/null +++ b/tests/postgres/db-command.spec.ts @@ -0,0 +1,81 @@ +import { Kysely, PostgresDialect } from 'kysely' +import { afterAll, afterEach, describe, expect, it } from 'vitest' +import { CliApp, configureArkormRuntime, 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) + configureArkormRuntime(undefined, { adapter }) + + const app = new CliApp() + 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), + multiline: async () => '', + }) + + 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) + }) +})