Skip to content

Commit 6742b4d

Browse files
1 parent 42bac16 commit 6742b4d

File tree

5 files changed

+317
-8
lines changed

5 files changed

+317
-8
lines changed

src/commands/database/connect.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import readline from 'readline'
2+
3+
import { log, logJson } from '../../utils/command-helpers.js'
4+
import BaseCommand from '../base-command.js'
5+
import { connectRawClient } from './db-connection.js'
6+
import { executeMetaCommand } from './meta-commands.js'
7+
import { formatQueryResult } from './psql-formatter.js'
8+
9+
export interface ConnectOptions {
10+
query?: string
11+
json?: boolean
12+
}
13+
14+
export const connect = async (options: ConnectOptions, command: BaseCommand): Promise<void> => {
15+
const buildDir = command.netlify.site.root ?? command.project.root ?? command.project.baseDirectory
16+
if (!buildDir) {
17+
throw new Error('Could not determine the project root directory.')
18+
}
19+
20+
const { client, connectionString, cleanup } = await connectRawClient(buildDir)
21+
22+
// --json without --query: print connection details
23+
if (options.json && !options.query) {
24+
logJson({ connection_string: connectionString, context: 'dev' })
25+
await cleanup()
26+
return
27+
}
28+
29+
log(`Connected to ${connectionString}`)
30+
31+
// --query: one-shot mode
32+
if (options.query) {
33+
try {
34+
const result = await client.query<Record<string, unknown>>(options.query)
35+
if (options.json) {
36+
logJson(result.rows)
37+
} else {
38+
log(formatQueryResult(result.fields, result.rows, result.rowCount, result.command))
39+
}
40+
} finally {
41+
await cleanup()
42+
}
43+
return
44+
}
45+
46+
// Interactive REPL
47+
const rl = readline.createInterface({
48+
input: process.stdin,
49+
output: process.stdout,
50+
prompt: 'netlifydb=> ',
51+
})
52+
53+
let buffer = ''
54+
55+
const handleCleanup = async () => {
56+
rl.close()
57+
await cleanup()
58+
}
59+
60+
process.on('SIGINT', () => {
61+
if (buffer) {
62+
// Cancel current multi-line input
63+
buffer = ''
64+
process.stdout.write('\n')
65+
rl.setPrompt('netlifydb=> ')
66+
rl.prompt()
67+
} else {
68+
log('')
69+
void handleCleanup()
70+
}
71+
})
72+
73+
rl.on('close', () => {
74+
void handleCleanup()
75+
})
76+
77+
rl.on('line', (line: string) => {
78+
// Meta-commands are only recognized at the start of input (not mid-statement)
79+
if (buffer === '' && line.trimStart().startsWith('\\')) {
80+
rl.pause()
81+
void (async () => {
82+
try {
83+
const result = await executeMetaCommand(line, client)
84+
switch (result.type) {
85+
case 'quit':
86+
await handleCleanup()
87+
return
88+
case 'help':
89+
log(result.text)
90+
break
91+
case 'unknown':
92+
log(`Invalid command ${result.command}. Try \\? for help.`)
93+
break
94+
case 'query':
95+
log(formatQueryResult(result.fields, result.rows, result.rowCount, result.command))
96+
break
97+
}
98+
} catch (err) {
99+
const message = err instanceof Error ? err.message : String(err)
100+
log(`ERROR: ${message}`)
101+
}
102+
rl.resume()
103+
rl.prompt()
104+
})()
105+
return
106+
}
107+
108+
buffer += (buffer ? '\n' : '') + line
109+
110+
if (buffer.trimEnd().endsWith(';')) {
111+
const sql = buffer
112+
buffer = ''
113+
rl.setPrompt('netlifydb=> ')
114+
rl.pause()
115+
void (async () => {
116+
try {
117+
const result = await client.query<Record<string, unknown>>(sql)
118+
log(formatQueryResult(result.fields, result.rows, result.rowCount, result.command))
119+
} catch (err) {
120+
const message = err instanceof Error ? err.message : String(err)
121+
log(`ERROR: ${message}`)
122+
}
123+
rl.resume()
124+
rl.prompt()
125+
})()
126+
} else {
127+
rl.setPrompt('netlifydb-> ')
128+
rl.prompt()
129+
}
130+
})
131+
132+
rl.prompt()
133+
}

src/commands/database/database.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,25 @@ export const createDatabaseCommand = (program: BaseCommand) => {
9090
})
9191

9292
if (process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1') {
93+
dbCommand
94+
.command('connect')
95+
.description('Connect to the database')
96+
.option('-q, --query <sql>', 'Execute a single query and exit')
97+
.option(
98+
'--json',
99+
'Output query results as JSON. When used without --query, prints the connection details as JSON instead.',
100+
)
101+
.action(async (options: { query?: string; json?: boolean }, command: BaseCommand) => {
102+
const { connect } = await import('./connect.js')
103+
await connect(options, command)
104+
})
105+
.addExamples([
106+
'netlify db connect',
107+
'netlify db connect --query "SELECT * FROM users"',
108+
'netlify db connect --json --query "SELECT * FROM users"',
109+
'netlify db connect --json',
110+
])
111+
93112
dbCommand
94113
.command('migrate')
95114
.description('Apply database migrations to the local development database')

src/commands/database/db-connection.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,29 @@ interface DBConnection {
1111
}
1212

1313
export async function connectToDatabase(buildDir: string): Promise<DBConnection> {
14+
const { client, cleanup } = await connectRawClient(buildDir)
15+
return {
16+
executor: new PgClientExecutor(client),
17+
cleanup,
18+
}
19+
}
20+
21+
interface RawDBConnection {
22+
client: Client
23+
connectionString: string
24+
cleanup: () => Promise<void>
25+
}
26+
27+
export async function connectRawClient(buildDir: string): Promise<RawDBConnection> {
1428
const state = new LocalState(buildDir)
15-
const connectionString = state.get('dbConnectionString')
29+
const storedConnectionString = state.get('dbConnectionString')
1630

17-
if (connectionString) {
18-
const client = new Client({ connectionString })
31+
if (storedConnectionString) {
32+
const client = new Client({ connectionString: storedConnectionString })
1933
await client.connect()
2034
return {
21-
executor: new PgClientExecutor(client),
35+
client,
36+
connectionString: storedConnectionString,
2237
cleanup: () => client.end(),
2338
}
2439
}
@@ -40,14 +55,21 @@ export async function connectToDatabase(buildDir: string): Promise<DBConnection>
4055

4156
await netlifyDev.start()
4257

43-
const { db } = netlifyDev
44-
if (!db) {
58+
const connectionString = state.get('dbConnectionString')
59+
if (!connectionString) {
4560
await netlifyDev.stop()
4661
throw new Error('Local database failed to start. Set EXPERIMENTAL_NETLIFY_DB_ENABLED=1 to enable.')
4762
}
4863

64+
const client = new Client({ connectionString })
65+
await client.connect()
66+
4967
return {
50-
executor: db,
51-
cleanup: () => netlifyDev.stop(),
68+
client,
69+
connectionString,
70+
cleanup: async () => {
71+
await client.end()
72+
await netlifyDev.stop()
73+
},
5274
}
5375
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Client, FieldDef } from 'pg'
2+
3+
export type MetaCommandResult =
4+
| { type: 'query'; fields: FieldDef[]; rows: Record<string, unknown>[]; rowCount: number | null; command: string }
5+
| { type: 'quit' }
6+
| { type: 'help'; text: string }
7+
| { type: 'unknown'; command: string }
8+
9+
const HELP_TEXT = `Netlify DB interactive client. Supports a subset of psql commands.
10+
11+
General
12+
\\q quit
13+
14+
Informational
15+
\\d list tables
16+
\\dt list tables
17+
\\d NAME describe table
18+
\\l list databases
19+
\\? show this help`
20+
21+
export const executeMetaCommand = async (input: string, client: Client): Promise<MetaCommandResult> => {
22+
const trimmed = input.trim()
23+
const [cmd, ...args] = trimmed.split(/\s+/)
24+
25+
if (cmd === '\\q') {
26+
return { type: 'quit' }
27+
}
28+
29+
if (cmd === '\\?') {
30+
return { type: 'help', text: HELP_TEXT }
31+
}
32+
33+
if (cmd === '\\dt' || (cmd === '\\d' && args.length === 0)) {
34+
const result = await client.query<Record<string, unknown>>(
35+
`SELECT schemaname AS "Schema", tablename AS "Name", tableowner AS "Owner"
36+
FROM pg_tables
37+
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
38+
ORDER BY schemaname, tablename`,
39+
)
40+
return { type: 'query', fields: result.fields, rows: result.rows, rowCount: result.rowCount, command: 'SELECT' }
41+
}
42+
43+
if (cmd === '\\d' && args.length > 0) {
44+
const tableName = args[0]
45+
const result = await client.query<Record<string, unknown>>(
46+
`SELECT column_name AS "Column", data_type AS "Type",
47+
CASE WHEN is_nullable = 'YES' THEN 'yes' ELSE 'no' END AS "Nullable",
48+
column_default AS "Default"
49+
FROM information_schema.columns
50+
WHERE table_schema = 'public' AND table_name = $1
51+
ORDER BY ordinal_position`,
52+
[tableName],
53+
)
54+
if (result.rowCount === 0) {
55+
return { type: 'query', fields: result.fields, rows: result.rows, rowCount: 0, command: 'SELECT' }
56+
}
57+
return { type: 'query', fields: result.fields, rows: result.rows, rowCount: result.rowCount, command: 'SELECT' }
58+
}
59+
60+
if (cmd === '\\l') {
61+
const result = await client.query<Record<string, unknown>>(
62+
`SELECT datname AS "Name",
63+
pg_catalog.pg_get_userbyid(datdba) AS "Owner",
64+
pg_catalog.pg_encoding_to_char(encoding) AS "Encoding"
65+
FROM pg_catalog.pg_database
66+
ORDER BY 1`,
67+
)
68+
return { type: 'query', fields: result.fields, rows: result.rows, rowCount: result.rowCount, command: 'SELECT' }
69+
}
70+
71+
return { type: 'unknown', command: cmd }
72+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { FieldDef } from 'pg'
2+
3+
const formatValue = (value: unknown): string => {
4+
if (value === null || value === undefined) {
5+
return ''
6+
}
7+
if (value instanceof Date) {
8+
return value.toISOString()
9+
}
10+
if (typeof value === 'object') {
11+
return JSON.stringify(value)
12+
}
13+
if (typeof value === 'string') {
14+
return value
15+
}
16+
return String(value as number | boolean | bigint)
17+
}
18+
19+
export const formatQueryResult = (
20+
fields: FieldDef[],
21+
rows: Record<string, unknown>[],
22+
rowCount: number | null,
23+
command: string,
24+
): string => {
25+
if (fields.length === 0) {
26+
// DDL or DML without returning clause
27+
if (command === 'INSERT') {
28+
return `INSERT 0 ${String(rowCount ?? 0)}`
29+
}
30+
if (command === 'UPDATE' || command === 'DELETE') {
31+
return `${command} ${String(rowCount ?? 0)}`
32+
}
33+
return command
34+
}
35+
36+
const headers = fields.map((f) => f.name)
37+
38+
const stringRows = rows.map((row) => headers.map((h) => formatValue(row[h])))
39+
40+
const widths = headers.map((header, i) => {
41+
const maxDataWidth = stringRows.reduce((max, row) => Math.max(max, row[i].length), 0)
42+
return Math.max(header.length, maxDataWidth)
43+
})
44+
45+
const lines: string[] = []
46+
47+
// Header
48+
lines.push(headers.map((h, i) => ` ${h.padEnd(widths[i])} `).join('|'))
49+
50+
// Separator
51+
lines.push(widths.map((w) => '-'.repeat(w + 2)).join('+'))
52+
53+
// Rows
54+
for (const row of stringRows) {
55+
lines.push(row.map((val, i) => ` ${val.padEnd(widths[i])} `).join('|'))
56+
}
57+
58+
// Footer
59+
const count = rowCount ?? rows.length
60+
lines.push(`(${String(count)} ${count === 1 ? 'row' : 'rows'})`)
61+
62+
return lines.join('\n')
63+
}

0 commit comments

Comments
 (0)