Skip to content

Commit 8af6f1f

Browse files
committed
feat: initial implementation for psql powered by postgres.js
1 parent ac739c0 commit 8af6f1f

File tree

16 files changed

+934
-5
lines changed

16 files changed

+934
-5
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"minimatch": "^10.1.1",
102102
"modern-tar": "^0.7.3",
103103
"papaparse": "^5.5.3",
104+
"postgres": "^3.4.5",
104105
"pyodide": "^0.27.0",
105106
"re2js": "^1.2.1",
106107
"smol-toml": "^1.6.0",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Bash.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ import {
4242
import { type ExecutionLimits, resolveLimits } from "./limits.js";
4343
import {
4444
createSecureFetch,
45+
createSecurePostgresConnect,
4546
type NetworkConfig,
4647
type SecureFetch,
48+
type SecurePostgresConnect,
4749
} from "./network/index.js";
4850
import { LexerError } from "./parser/lexer.js";
4951
import { type ParseException, parse } from "./parser/parser.js";
@@ -212,6 +214,7 @@ export class Bash {
212214
private useDefaultLayout: boolean = false;
213215
private limits: Required<ExecutionLimits>;
214216
private secureFetch?: SecureFetch;
217+
private securePostgresConnect?: SecurePostgresConnect;
215218
private sleepFn?: (ms: number) => Promise<void>;
216219
private traceFn?: TraceCallback;
217220
private logger?: BashLogger;
@@ -261,6 +264,16 @@ export class Bash {
261264
// Create secure fetch if network is configured
262265
if (options.network) {
263266
this.secureFetch = createSecureFetch(options.network);
267+
268+
// Create secure PostgreSQL connect if PostgreSQL hosts are configured
269+
if (
270+
options.network.allowedPostgresHosts ||
271+
options.network.dangerouslyAllowFullInternetAccess
272+
) {
273+
this.securePostgresConnect = createSecurePostgresConnect(
274+
options.network,
275+
);
276+
}
264277
}
265278

266279
// Store sleep function if provided (for mock clocks in testing)
@@ -540,6 +553,7 @@ export class Bash {
540553
limits: this.limits,
541554
exec: this.exec.bind(this),
542555
fetch: this.secureFetch,
556+
connectPostgres: this.securePostgresConnect,
543557
sleep: this.sleepFn,
544558
trace: this.traceFn,
545559
coverage: this.coverageWriter,

src/commands/psql/connection.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Connection string parsing and option resolution for psql
3+
*/
4+
5+
import type { SecurePostgresOptions } from "../../network/index.js";
6+
import type { PsqlOptions } from "./parser.js";
7+
8+
/**
9+
* Build SecurePostgresOptions from parsed CLI options
10+
*/
11+
export function buildConnectionOptions(
12+
options: PsqlOptions,
13+
): SecurePostgresOptions | null {
14+
// Host is required
15+
if (!options.host) {
16+
return null;
17+
}
18+
19+
return {
20+
host: options.host,
21+
port: options.port,
22+
database: options.database,
23+
username: options.username,
24+
password: undefined, // Password via CLI is not supported for security
25+
ssl: "prefer", // Default to prefer SSL
26+
};
27+
}
28+
29+
/**
30+
* Get SQL to execute from options
31+
*/
32+
export function getSqlToExecute(options: PsqlOptions, stdin: string): string {
33+
if (options.command) {
34+
return options.command;
35+
}
36+
37+
if (stdin.trim()) {
38+
return stdin.trim();
39+
}
40+
41+
return "";
42+
}

src/commands/psql/formatters.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Output formatters for psql command
3+
*/
4+
5+
import type { PsqlOptions } from "./parser.js";
6+
7+
/**
8+
* Format query results based on output options
9+
*/
10+
export function formatResults(
11+
columns: string[],
12+
rows: unknown[][],
13+
options: PsqlOptions,
14+
): string {
15+
if (rows.length === 0 && options.tuplesOnly) {
16+
return "";
17+
}
18+
19+
switch (options.outputFormat) {
20+
case "aligned":
21+
return formatAligned(columns, rows, options);
22+
case "unaligned":
23+
return formatUnaligned(columns, rows, options);
24+
case "csv":
25+
return formatCsv(columns, rows, options);
26+
case "json":
27+
return formatJson(columns, rows);
28+
case "html":
29+
return formatHtml(columns, rows, options);
30+
default:
31+
return formatAligned(columns, rows, options);
32+
}
33+
}
34+
35+
/**
36+
* Format as aligned table (default psql output)
37+
*/
38+
function formatAligned(
39+
columns: string[],
40+
rows: unknown[][],
41+
options: PsqlOptions,
42+
): string {
43+
if (columns.length === 0) return "";
44+
45+
const widths = columns.map((col, i) => {
46+
const maxDataWidth = Math.max(
47+
...rows.map((row) => String(row[i] ?? "").length),
48+
);
49+
return Math.max(col.length, maxDataWidth);
50+
});
51+
52+
let output = "";
53+
54+
// Header
55+
if (!options.tuplesOnly) {
56+
output +=
57+
columns.map((col, i) => col.padEnd(widths[i])).join(" | ") +
58+
options.recordSeparator;
59+
60+
// Separator line
61+
output +=
62+
widths.map((w) => "-".repeat(w)).join("-+-") + options.recordSeparator;
63+
}
64+
65+
// Rows
66+
for (const row of rows) {
67+
output +=
68+
row.map((val, i) => String(val ?? "").padEnd(widths[i])).join(" | ") +
69+
options.recordSeparator;
70+
}
71+
72+
// Footer with row count
73+
if (!options.tuplesOnly && !options.quiet) {
74+
const rowText = rows.length === 1 ? "row" : "rows";
75+
output += `(${rows.length} ${rowText})${options.recordSeparator}`;
76+
}
77+
78+
return output;
79+
}
80+
81+
/**
82+
* Format as unaligned output (field separator delimited)
83+
*/
84+
function formatUnaligned(
85+
columns: string[],
86+
rows: unknown[][],
87+
options: PsqlOptions,
88+
): string {
89+
let output = "";
90+
91+
// Header
92+
if (!options.tuplesOnly) {
93+
output += columns.join(options.fieldSeparator) + options.recordSeparator;
94+
}
95+
96+
// Rows
97+
for (const row of rows) {
98+
output +=
99+
row.map((val) => String(val ?? "")).join(options.fieldSeparator) +
100+
options.recordSeparator;
101+
}
102+
103+
return output;
104+
}
105+
106+
/**
107+
* Format as CSV
108+
*/
109+
function formatCsv(
110+
columns: string[],
111+
rows: unknown[][],
112+
options: PsqlOptions,
113+
): string {
114+
let output = "";
115+
116+
// Header
117+
if (!options.tuplesOnly) {
118+
output += columns.map(escapeCsv).join(",") + options.recordSeparator;
119+
}
120+
121+
// Rows
122+
for (const row of rows) {
123+
output +=
124+
row.map((val) => escapeCsv(String(val ?? ""))).join(",") +
125+
options.recordSeparator;
126+
}
127+
128+
return output;
129+
}
130+
131+
/**
132+
* Escape CSV field (add quotes if needed)
133+
*/
134+
function escapeCsv(field: string): string {
135+
if (
136+
field.includes(",") ||
137+
field.includes('"') ||
138+
field.includes("\n") ||
139+
field.includes("\r")
140+
) {
141+
return `"${field.replace(/"/g, '""')}"`;
142+
}
143+
return field;
144+
}
145+
146+
/**
147+
* Format as JSON array of objects
148+
*/
149+
function formatJson(columns: string[], rows: unknown[][]): string {
150+
const objects = rows.map((row) => {
151+
const obj: Record<string, unknown> = {};
152+
for (let i = 0; i < columns.length; i++) {
153+
obj[columns[i]] = row[i];
154+
}
155+
return obj;
156+
});
157+
158+
return `${JSON.stringify(objects, null, 2)}\n`;
159+
}
160+
161+
/**
162+
* Format as HTML table
163+
*/
164+
function formatHtml(
165+
columns: string[],
166+
rows: unknown[][],
167+
options: PsqlOptions,
168+
): string {
169+
let output = "<table>\n";
170+
171+
// Header
172+
if (!options.tuplesOnly) {
173+
output += " <thead>\n <tr>\n";
174+
for (const col of columns) {
175+
output += ` <th>${escapeHtml(col)}</th>\n`;
176+
}
177+
output += " </tr>\n </thead>\n";
178+
}
179+
180+
// Body
181+
output += " <tbody>\n";
182+
for (const row of rows) {
183+
output += " <tr>\n";
184+
for (const val of row) {
185+
output += ` <td>${escapeHtml(String(val ?? ""))}</td>\n`;
186+
}
187+
output += " </tr>\n";
188+
}
189+
output += " </tbody>\n";
190+
191+
output += "</table>\n";
192+
return output;
193+
}
194+
195+
/**
196+
* Escape HTML entities
197+
*/
198+
function escapeHtml(text: string): string {
199+
return text
200+
.replace(/&/g, "&amp;")
201+
.replace(/</g, "&lt;")
202+
.replace(/>/g, "&gt;")
203+
.replace(/"/g, "&quot;")
204+
.replace(/'/g, "&#39;");
205+
}

0 commit comments

Comments
 (0)