From 438845d700542b4f66c580876471097cdcfd9efa Mon Sep 17 00:00:00 2001 From: zth Date: Fri, 29 May 2026 12:12:13 +0200 Subject: [PATCH] Add query diagnostics CLI --- CHANGELOG.md | 5 + package-lock.json | 8 + packages/cli/package.json | 3 + packages/cli/src/diagnostics.rescript.test.ts | 122 ++++ packages/cli/src/diagnostics.ts | 575 ++++++++++++++++++ packages/cli/src/index.ts | 160 ++++- 6 files changed, 854 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/diagnostics.rescript.test.ts create mode 100644 packages/cli/src/diagnostics.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dd12f76e..d4b98400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased + +- Add `pgtyped-rescript diagnose` for inspecting named SQL queries with `describe`, `explain`, and `explain analyze`, including support for parameter JSON, parameter files, processed SQL output, query listing, JSON output, and statement timeouts. +- Reduce default CLI generation output to only recompiled files, errors, and concise unchanged-run summaries; pass `--verbose` to restore detailed per-file processing and skipped-file output. + # 3.0.1 - Gracefully skip optional AST-based query analysis when `pgsql-ast-parser` cannot parse valid PostgreSQL syntax, so generation can continue with database-derived types. diff --git a/package-lock.json b/package-lock.json index efef8b31..aff58678 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2825,6 +2825,7 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.2.tgz", "integrity": "sha512-MKFs9P6nJ+LAeHLU3V0cODEOgyThJ3OAnmOlsZsxux6sfQs3HRXR5bBn7xG5DjckEFhTAxsXi7k7cd0pCMxpJw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9584,6 +9585,7 @@ "version": "8.11.2", "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.2.tgz", "integrity": "sha512-l4rmVeV8qTIrrPrIR3kZQqBgSN93331s9i6wiUiLOSk0Q7PmUxZD/m1rQI622l3NfqBby9Ar5PABfS/SulfieQ==", + "license": "MIT", "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -12463,7 +12465,9 @@ "io-ts-reporters": "^2.0.1", "nunjucks": "3.2.4", "pascal-case": "^3.1.1", + "pg": "^8.11.2", "pgtyped-rescript-query": "^3.0.1", + "pgtyped-rescript-runtime": "^3.0.0", "piscina": "^4.0.0", "tinypool": "^0.7.0", "ts-parse-database-url": "^1.0.3", @@ -12476,6 +12480,7 @@ "@types/debug": "4.1.8", "@types/fs-extra": "11.0.1", "@types/nunjucks": "^3.1.3", + "@types/pg": "^8.10.2", "@types/yargs": "17.0.24", "rescript": "12.2.0" }, @@ -19961,6 +19966,7 @@ "@types/debug": "4.1.8", "@types/fs-extra": "11.0.1", "@types/nunjucks": "^3.1.3", + "@types/pg": "^8.10.2", "@types/yargs": "17.0.24", "camel-case": "^4.1.1", "chalk": "^4.0.0", @@ -19972,7 +19978,9 @@ "io-ts-reporters": "^2.0.1", "nunjucks": "3.2.4", "pascal-case": "^3.1.1", + "pg": "^8.11.2", "pgtyped-rescript-query": "^3.0.1", + "pgtyped-rescript-runtime": "^3.0.0", "piscina": "^4.0.0", "rescript": "12.2.0", "tinypool": "^0.7.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index eb3bf787..bb9d3258 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,7 +48,9 @@ "io-ts-reporters": "^2.0.1", "nunjucks": "3.2.4", "pascal-case": "^3.1.1", + "pg": "^8.11.2", "pgtyped-rescript-query": "^3.0.1", + "pgtyped-rescript-runtime": "^3.0.0", "piscina": "^4.0.0", "tinypool": "^0.7.0", "ts-parse-database-url": "^1.0.3", @@ -58,6 +60,7 @@ "@types/debug": "4.1.8", "@types/fs-extra": "11.0.1", "@types/nunjucks": "^3.1.3", + "@types/pg": "^8.10.2", "@types/yargs": "17.0.24", "rescript": "12.2.0" }, diff --git a/packages/cli/src/diagnostics.rescript.test.ts b/packages/cli/src/diagnostics.rescript.test.ts new file mode 100644 index 00000000..bf42df6d --- /dev/null +++ b/packages/cli/src/diagnostics.rescript.test.ts @@ -0,0 +1,122 @@ +import { jest } from '@jest/globals'; +import { parseSQLFile, queryASTToIR } from '@pgtyped/parser'; +import fs from 'fs'; +import { + getParameterLabels, + parseErrorFields, + runDiagnostics, +} from './diagnostics.js'; + +const sqlFile = '../example/src/books/books.sql'; + +describe('diagnostics', () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + test('lists query names without config', async () => { + await runDiagnostics(undefined, { + file: sqlFile, + list: true, + mode: 'explain', + format: 'text', + }); + + expect(logSpy).toHaveBeenCalledWith('FindBookById'); + expect(logSpy).toHaveBeenCalledWith('InsertBooks'); + }); + + test('prints processed SQL without config', async () => { + await runDiagnostics(undefined, { + file: sqlFile, + queryName: 'FindBookById', + sql: true, + mode: 'explain', + format: 'text', + }); + + expect(logSpy).toHaveBeenCalledWith('SELECT * FROM books WHERE id = $1'); + }); + + test('keeps scalar parameter labels when params are supplied', () => { + const query = parseSQLFile(` + /* @name FindBookById */ + SELECT * FROM books WHERE id = :id; + `).queries[0]; + + const labels = getParameterLabels(queryASTToIR(query, null), { id: 1 }); + + expect(labels.get(1)).toBe('id'); + }); + + test('does not require params for unused parameter declarations', async () => { + const file = '/tmp/pgtyped-unused-params.sql'; + await fs.promises.writeFile( + file, + ` + /* + @name StaticQuery + @param unused -> (id!) + */ + SELECT 1; + `, + ); + + await runDiagnostics(undefined, { + file, + queryName: 'StaticQuery', + sql: true, + mode: 'explain', + format: 'text', + }); + + expect(logSpy).toHaveBeenCalledWith('SELECT 1'); + }); + + test('uses SQLSTATE as parse error code', () => { + expect( + parseErrorFields({ + C: '42601', + M: 'syntax error at or near "FROM"', + R: 'scanner_yyerror', + }), + ).toMatchObject({ + errorCode: '42601', + message: 'syntax error at or near "FROM"', + }); + }); + + test('keeps spread-pick labels when params are supplied', () => { + const query = parseSQLFile(` + /* + @name InsertBooks + @param books -> ((rank!, name!)...) + */ + INSERT INTO books (rank, name) + VALUES :books; + `).queries[0]; + + const labels = getParameterLabels(queryASTToIR(query, null), { + books: [ + { rank: 1, name: 'one' }, + { rank: 2, name: 'two' }, + ], + }); + + expect([...labels.values()]).toEqual([ + 'books.rank', + 'books.name', + 'books.rank', + 'books.name', + ]); + }); +}); diff --git a/packages/cli/src/diagnostics.ts b/packages/cli/src/diagnostics.ts new file mode 100644 index 00000000..552f43ed --- /dev/null +++ b/packages/cli/src/diagnostics.ts @@ -0,0 +1,575 @@ +import { + parseSQLFile, + queryASTToIR, + SQLQueryIR, + TransformType, +} from '@pgtyped/parser'; +import { AsyncQueue, messages, PreparedObjectType } from '@pgtyped/wire'; +import crypto from 'crypto'; +import fs from 'fs-extra'; +import pg from 'pg'; +import { processSQLQueryIR, QueryParameters } from 'pgtyped-rescript-runtime'; +import { startup } from 'pgtyped-rescript-query'; +import { ParsedConfig } from './config.js'; + +// tslint:disable:no-console + +const { Client } = pg; + +export type DiagnosticsMode = 'describe' | 'explain' | 'analyze'; +export type DiagnosticsFormat = 'text' | 'table' | 'json'; + +export interface DiagnosticsOptions { + file: string; + queryName?: string; + params?: string; + paramsFile?: string; + mode: DiagnosticsMode; + format: DiagnosticsFormat; + list?: boolean; + sql?: boolean; + timeout?: string; +} + +interface FieldData { + name: string; + tableOID: number; + columnAttrNumber: number; + typeOID: number; + typeSize: number; + typeModifier: number; + formatCode: number; +} + +interface DescribeData { + params: Array<{ oid: number }>; + fields: FieldData[]; +} + +interface ParseError { + errorCode: string; + hint?: string; + message: string; + position?: string; +} + +type TypeData = DescribeData | ParseError; + +interface ErrorFields { + C?: string; + H?: string; + M: string; + P?: string; + R: string; +} + +interface TypeMetadata { + oid: number; + name: string; +} + +interface ColumnMetadata { + tableOID: number; + columnAttrNumber: number; + tableName: string; + columnName: string; +} + +function parseParams( + params: string | undefined, + paramsFile: string | undefined, +): QueryParameters | undefined { + if (params && paramsFile) { + throw new Error('--params and --params-file are mutually exclusive'); + } + + if (!params && !paramsFile) { + return undefined; + } + + const json = params ?? fs.readFileSync(paramsFile as string, 'utf-8'); + const parsed = JSON.parse(json); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Parameters must be a JSON object'); + } + + return parsed as QueryParameters; +} + +function isParseError(value: unknown): value is ParseError { + return ( + value != null && + typeof value === 'object' && + 'message' in value && + 'errorCode' in value + ); +} + +export function parseErrorFields(errorFields: ErrorFields): ParseError { + return { + errorCode: errorFields.C ?? errorFields.R, + hint: errorFields.H, + message: errorFields.M, + position: errorFields.P, + }; +} + +async function getTypeData( + query: string, + queue: AsyncQueue, +): Promise { + const uniqueName = crypto.createHash('md5').update(query).digest('hex'); + + await queue.send(messages.parse, { + name: uniqueName, + query, + dataTypes: [], + }); + await queue.send(messages.describe, { + name: uniqueName, + type: PreparedObjectType.Statement, + }); + await queue.send(messages.close, { + target: PreparedObjectType.Statement, + targetName: uniqueName, + }); + await queue.send(messages.flush, {}); + + const parseResult = await queue.reply( + messages.errorResponse, + messages.parseComplete, + ); + + await queue.send(messages.sync, {}); + + if ('fields' in parseResult) { + return parseErrorFields(parseResult.fields); + } + + const paramsResult = await queue.reply( + messages.parameterDescription, + messages.noData, + ); + const params = 'params' in paramsResult ? paramsResult.params : []; + const fieldsResult = await queue.reply( + messages.rowDescription, + messages.noData, + ); + const fields = 'fields' in fieldsResult ? fieldsResult.fields : []; + await queue.reply(messages.closeComplete); + return { params, fields }; +} + +async function describeQuery(query: string, config: ParsedConfig) { + const queue = new AsyncQueue(); + try { + await startup(config.db, queue); + return await getTypeData(query, queue); + } finally { + queue.socket.end(); + } +} + +function createPgClient(config: ParsedConfig) { + return new Client({ + host: config.db.host, + port: config.db.port, + user: config.db.user, + password: config.db.password, + database: config.db.dbName, + ssl: config.db.ssl, + }); +} + +function parseTimeout(timeout: string | undefined): number | undefined { + if (!timeout) { + return undefined; + } + + const match = /^(\d+)(ms|s|min)?$/.exec(timeout); + if (!match) { + throw new Error('--timeout must look like 500ms, 5s, or 1min'); + } + + const value = Number(match[1]); + const unit = match[2] ?? 'ms'; + switch (unit) { + case 'ms': + return value; + case 's': + return value * 1000; + case 'min': + return value * 60 * 1000; + default: + return value; + } +} + +function makeExplainQuery( + query: string, + mode: DiagnosticsMode, + format: DiagnosticsFormat, +) { + const options = [ + mode === 'analyze' ? 'ANALYZE' : undefined, + mode === 'analyze' ? 'BUFFERS' : undefined, + format === 'json' ? 'FORMAT JSON' : undefined, + ].filter(Boolean); + + return options.length > 0 + ? `EXPLAIN (${options.join(', ')}) ${query}` + : `EXPLAIN ${query}`; +} + +async function runExplain( + query: string, + bindings: unknown[], + config: ParsedConfig, + mode: DiagnosticsMode, + format: DiagnosticsFormat, + timeout: string | undefined, +) { + const client = createPgClient(config); + + await client.connect(); + try { + const timeoutMs = parseTimeout(timeout); + if (timeoutMs != null) { + await client.query(`SET statement_timeout = ${timeoutMs}`); + } + + return await client.query({ + text: makeExplainQuery(query, mode, format), + values: bindings, + }); + } finally { + await client.end(); + } +} + +async function fetchTypeMetadata( + oids: number[], + config: ParsedConfig, +): Promise> { + if (oids.length === 0) { + return new Map(); + } + + const client = createPgClient(config); + await client.connect(); + try { + const result = await client.query({ + text: ` + SELECT oid, format_type(oid, NULL) AS name + FROM pg_type + WHERE oid = ANY($1::oid[]) + `, + values: [oids], + }); + return new Map(result.rows.map((row) => [Number(row.oid), row.name])); + } finally { + await client.end(); + } +} + +async function fetchColumnMetadata( + fields: FieldData[], + config: ParsedConfig, +): Promise> { + const refs = fields + .filter((field) => field.tableOID > 0 && field.columnAttrNumber > 0) + .map((field) => ({ + tableOID: field.tableOID, + columnAttrNumber: field.columnAttrNumber, + })); + + if (refs.length === 0) { + return new Map(); + } + + const client = createPgClient(config); + await client.connect(); + try { + const result = await client.query({ + text: ` + SELECT + c.oid AS "tableOID", + a.attnum AS "columnAttrNumber", + c.relname AS "tableName", + a.attname AS "columnName" + FROM pg_class c + JOIN pg_attribute a ON a.attrelid = c.oid + WHERE (c.oid::int, a.attnum::int) IN ( + SELECT + (value->>'tableOID')::int, + (value->>'columnAttrNumber')::int + FROM jsonb_array_elements($1::jsonb) + ) + `, + values: [JSON.stringify(refs)], + }); + return new Map( + result.rows.map((row) => [ + `${row.tableOID}:${row.columnAttrNumber}`, + row, + ]), + ); + } finally { + await client.end(); + } +} + +export function getParameterLabels( + queryIR: SQLQueryIR, + params: QueryParameters | undefined, +): Map { + const labels = new Map(); + const usedParams = getUsedParams(queryIR); + let index = 1; + + for (const param of usedParams) { + switch (param.transform.type) { + case TransformType.Scalar: + labels.set(index++, param.name); + break; + + case TransformType.ArraySpread: { + const value = params?.[param.name]; + if (Array.isArray(value)) { + value.forEach(() => labels.set(index++, param.name)); + } else { + labels.set(index++, param.name); + } + break; + } + + case TransformType.PickTuple: + for (const key of param.transform.keys) { + labels.set(index++, `${param.name}.${key.name}`); + } + break; + + case TransformType.PickArraySpread: { + const value = params?.[param.name]; + if (Array.isArray(value)) { + value.forEach(() => { + if (param.transform.type === TransformType.PickArraySpread) { + param.transform.keys.forEach((key) => + labels.set(index++, `${param.name}.${key.name}`), + ); + } + }); + } else { + param.transform.keys.forEach((key) => + labels.set(index++, `${param.name}.${key.name}`), + ); + } + break; + } + } + } + + return labels; +} + +function getUsedParams(queryIR: SQLQueryIR) { + return queryIR.params.filter((param) => param.name in queryIR.usedParamSet); +} + +function pad(value: string, width: number) { + return value.padEnd(width, ' '); +} + +function printRows(headers: string[], rows: string[][]) { + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => row[index].length)), + ); + + console.log( + headers.map((header, index) => pad(header, widths[index])).join(' '), + ); + console.log(widths.map((width) => '-'.repeat(width)).join(' ')); + + for (const row of rows) { + console.log( + row.map((value, index) => pad(value, widths[index])).join(' '), + ); + } +} + +async function printDescribeResult( + result: Awaited>, + config: ParsedConfig, + format: DiagnosticsFormat, + parameterLabels: Map, +) { + if (isParseError(result)) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + const describeData = result as DescribeData; + const typeOids = [ + ...describeData.params.map((param) => param.oid), + ...describeData.fields.map((field) => field.typeOID), + ]; + const [typeNames, columnMetadata] = await Promise.all([ + fetchTypeMetadata(typeOids, config), + fetchColumnMetadata(describeData.fields, config), + ]); + + const enriched = { + parameters: describeData.params.map((param, index) => ({ + position: index + 1, + name: parameterLabels.get(index + 1), + type: typeNames.get(param.oid) ?? String(param.oid), + oid: param.oid, + })), + fields: describeData.fields.map((field) => { + const column = columnMetadata.get( + `${field.tableOID}:${field.columnAttrNumber}`, + ); + return { + name: field.name, + type: typeNames.get(field.typeOID) ?? String(field.typeOID), + oid: field.typeOID, + source: column ? `${column.tableName}.${column.columnName}` : undefined, + }; + }), + }; + + if (format === 'json') { + console.log(JSON.stringify(enriched, null, 2)); + return; + } + + if (enriched.parameters.length > 0) { + console.log('Parameters'); + printRows( + ['Position', 'Name', 'Type', 'OID'], + enriched.parameters.map((param) => [ + `$${param.position}`, + param.name ?? '', + param.type, + String(param.oid), + ]), + ); + } else { + console.log('Parameters: none'); + } + + console.log(''); + if (enriched.fields.length > 0) { + console.log('Result columns'); + printRows( + ['Name', 'Type', 'Source', 'OID'], + enriched.fields.map((field) => [ + field.name, + field.type, + field.source ?? '', + String(field.oid), + ]), + ); + return; + } + + console.log('Result columns: none'); +} + +function printExplainResult( + result: Awaited>, + format: DiagnosticsFormat, +) { + if (format === 'json') { + console.log(JSON.stringify(result.rows[0]['QUERY PLAN'][0], null, 2)); + return; + } + + for (const row of result.rows) { + console.log(row['QUERY PLAN']); + } +} + +export async function runDiagnostics( + config: ParsedConfig | undefined, + options: DiagnosticsOptions, +) { + const fileContents = (await fs.readFile(options.file, 'utf-8')).replace( + /\r\n/g, + '\n', + ); + const parsed = parseSQLFile(fileContents); + + if (parsed.events.length > 0) { + console.error(JSON.stringify(parsed.events, null, 2)); + } + + if (parsed.queries.length === 0) { + throw new Error(`No queries found in ${options.file}`); + } + + if (options.list) { + parsed.queries.forEach((parsedQuery) => console.log(parsedQuery.name)); + return; + } + + const query = + options.queryName == null && parsed.queries.length === 1 + ? parsed.queries[0] + : parsed.queries.find((q) => q.name === options.queryName); + + if (!query) { + const names = parsed.queries.map((q) => q.name).join(', '); + throw new Error(`Query not found. Available queries: ${names}`); + } + + const params = parseParams(options.params, options.paramsFile); + const queryIR = queryASTToIR(query, null); + + if ( + !options.sql && + options.mode !== 'describe' && + getUsedParams(queryIR).length > 0 && + !params + ) { + throw new Error( + 'EXPLAIN diagnostics for parameterized queries require --params', + ); + } + + const processed = processSQLQueryIR(queryIR, params); + + if (options.sql) { + console.log(processed.query); + return; + } + + if (options.mode === 'describe') { + if (!config) { + throw new Error('Config file required for describe diagnostics'); + } + + const describeResult = await describeQuery(processed.query, config); + await printDescribeResult( + describeResult, + config, + options.format, + getParameterLabels(queryIR, params), + ); + return; + } + + if (!config) { + throw new Error('Config file required for explain diagnostics'); + } + + const explainResult = await runExplain( + processed.query, + processed.bindings, + config, + options.mode, + options.format, + options.timeout, + ); + printExplainResult(explainResult, options.format); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4cfd2d69..8ba5d55d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,6 +10,11 @@ import { debug } from './util.js'; import { parseConfig, ParsedConfig, TransformConfig } from './config.js'; import { getMatchedFiles } from './glob.js'; import path from 'path'; +import { + DiagnosticsFormat, + DiagnosticsMode, + runDiagnostics, +} from './diagnostics.js'; import WorkerPool from 'piscina'; @@ -25,35 +30,64 @@ interface TransformJob { class FileProcessor { private readonly pool: WorkerPool; public readonly workQueue: Promise[] = []; + private processedCount = 0; + private skippedCount = 0; + private recompiledCount = 0; + private errorCount = 0; - constructor(private readonly config: ParsedConfig) { + constructor( + private readonly config: ParsedConfig, + private readonly verboseOutput: boolean, + ) { this.pool = new WorkerPool({ filename: new URL('./worker.js', import.meta.url).href, maxThreads: 8, workerData: config, }); - console.log(`Using a pool of ${this.pool.threads.length} threads.`); + if (this.verboseOutput) { + console.log(`Using a pool of ${this.pool.threads.length} threads.`); + } } public async shutdown() { await this.pool.destroy(); } + public printSummary() { + if (this.verboseOutput) { + console.log( + `Summary: ${this.recompiledCount} recompiled, ${this.skippedCount} unchanged, ${this.errorCount} errors, ${this.processedCount} processed.`, + ); + return; + } + + if (this.recompiledCount === 0 && this.errorCount === 0) { + console.log('No files recompiled.'); + } + } + public push(job: TransformJob) { this.workQueue.push( ...job.files.map(async (fileName) => { try { fileName = path.relative(process.cwd(), fileName); - console.log(`Processing ${fileName}`); + this.processedCount += 1; + if (this.verboseOutput) { + console.log(`Processing ${fileName}`); + } const result = await this.pool.run({ fileName, transform: job.transform, }); if (result.skipped) { - console.log( - `Skipped ${fileName}: no changes or no queries detected`, - ); + this.skippedCount += 1; + if (this.verboseOutput) { + console.log( + `Skipped ${fileName}: no changes or no queries detected`, + ); + } } else { + this.recompiledCount += 1; console.log( `Saved ${result.typeDecsLength} query types from ${fileName} to ${result.relativePath}`, ); @@ -66,11 +100,13 @@ class FileProcessor { return; } - console.log( + this.errorCount += 1; + console.error( `Error processing file: ${err.stack || JSON.stringify(err)}`, ); } else { - console.log(`Error processing file: ${JSON.stringify(err)}`); + this.errorCount += 1; + console.error(`Error processing file: ${JSON.stringify(err)}`); } if (this.config.failOnError) { await this.pool.destroy(); @@ -88,6 +124,7 @@ async function main( isWatchMode: boolean, // tslint:disable-next-line:no-shadowed-variable fileOverride?: string, + verboseOutput = false, ) { const config = await cfg; const connection = new AsyncQueue(); @@ -96,7 +133,7 @@ async function main( debug('connected to database %o', config.db.dbName); - const fileProcessor = new FileProcessor(config); + const fileProcessor = new FileProcessor(config, verboseOutput); let fileOverrideUsed = false; for (const transform of config.transforms) { const pattern = `${config.srcDir}/**/${transform.include}`; @@ -138,12 +175,64 @@ async function main( } if (!isWatchMode) { await Promise.all(fileProcessor.workQueue); + fileProcessor.printSummary(); await fileProcessor.shutdown(); process.exit(0); } } const args = yargs(hideBin(process.argv)) + .scriptName('pgtyped-rescript') + .usage('$0 -c config.json [options]') + .command( + 'diagnose ', + 'Run diagnostics for a named SQL query', + (cmd) => + cmd + .positional('sqlFile', { + type: 'string', + description: 'SQL file containing the query', + demandOption: true, + }) + .option('query', { + alias: 'q', + type: 'string', + description: 'Named query to diagnose', + }) + .option('mode', { + alias: 'm', + choices: ['describe', 'explain', 'analyze'] as const, + default: 'explain' as const, + description: 'Diagnostic mode to run', + }) + .option('params', { + alias: 'p', + type: 'string', + description: 'JSON object with query parameters', + }) + .option('params-file', { + type: 'string', + description: 'Path to a JSON file with query parameters', + }) + .option('format', { + choices: ['text', 'table', 'json'] as const, + default: 'text' as const, + description: 'Output format for diagnostics', + }) + .option('list', { + type: 'boolean', + description: 'List query names in the SQL file', + }) + .option('sql', { + type: 'boolean', + description: 'Print the processed SQL without running diagnostics', + }) + .option('timeout', { + type: 'string', + description: + 'Statement timeout for explain analyze, such as 500ms, 5s, or 1min', + }), + ) .version() .env() .options({ @@ -151,7 +240,6 @@ const args = yargs(hideBin(process.argv)) alias: 'c', type: 'string', description: 'Config file path', - demandOption: true, }, watch: { alias: 'w', @@ -168,6 +256,11 @@ const args = yargs(hideBin(process.argv)) conflicts: 'watch', description: 'File path (process single file, incompatible with --watch)', }, + verbose: { + alias: 'v', + type: 'boolean', + description: 'Show detailed processing output', + }, }) .epilogue('For more information, find our manual at https://pgtyped.dev/') .parseSync(); @@ -177,9 +270,13 @@ const { file: fileOverride, config: configPath, uri: connectionUri, + verbose, } = args; +const command = args._[0]; +const needsConfig = + command !== 'diagnose' || (!(args.list as boolean) && !(args.sql as boolean)); -if (typeof configPath !== 'string') { +if (needsConfig && typeof configPath !== 'string') { console.log('Config file required. See help -h for details.\nExiting.'); process.exit(0); } @@ -190,14 +287,39 @@ if (isWatchMode && fileOverride) { } try { - chokidar.watch(configPath).on('change', () => { - console.log('Config file changed. Exiting.'); - process.exit(); - }); - const config = parseConfig(configPath, connectionUri); - main(config, isWatchMode || false, fileOverride).catch((e) => - debug('error in main: %o', e.message), - ); + const config = + typeof configPath === 'string' + ? parseConfig(configPath, connectionUri) + : undefined; + if (command === 'diagnose') { + runDiagnostics(config, { + file: args.sqlFile as string, + queryName: args.query as string | undefined, + params: args.params as string | undefined, + paramsFile: args.paramsFile as string | undefined, + mode: args.mode as DiagnosticsMode, + format: args.format as DiagnosticsFormat, + list: args.list as boolean | undefined, + sql: args.sql as boolean | undefined, + timeout: args.timeout as string | undefined, + }).catch((e) => { + console.error((e as Error).message); + process.exitCode = 1; + }); + } else { + if (!config || typeof configPath !== 'string') { + console.log('Config file required. See help -h for details.\nExiting.'); + process.exit(0); + } + + chokidar.watch(configPath).on('change', () => { + console.log('Config file changed. Exiting.'); + process.exit(); + }); + main(config, isWatchMode || false, fileOverride, verbose || false).catch( + (e) => debug('error in main: %o', e.message), + ); + } } catch (e) { console.error('Failed to parse config file:'); console.error((e as any).message);