Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ jobs:
unit-tests:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432

steps:
- uses: actions/checkout@v4

Expand Down
17 changes: 17 additions & 0 deletions docker-compose.psql-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
version: '3.8'

services:
postgres:
image: postgres:16-alpine
container_name: just-bash-psql-test
environment:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
interval: 2s
timeout: 5s
retries: 10
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"minimatch": "^10.1.1",
"modern-tar": "^0.7.3",
"papaparse": "^5.5.3",
"postgres": "^3.4.5",
"pyodide": "^0.27.0",
"re2js": "^1.2.1",
"smol-toml": "^1.6.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions src/Bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ import {
import { type ExecutionLimits, resolveLimits } from "./limits.js";
import {
createSecureFetch,
createSecurePostgresConnect,
type NetworkConfig,
type SecureFetch,
type SecurePostgresConnect,
} from "./network/index.js";
import { LexerError } from "./parser/lexer.js";
import { type ParseException, parse } from "./parser/parser.js";
Expand Down Expand Up @@ -212,6 +214,7 @@ export class Bash {
private useDefaultLayout: boolean = false;
private limits: Required<ExecutionLimits>;
private secureFetch?: SecureFetch;
private securePostgresConnect?: SecurePostgresConnect;
private sleepFn?: (ms: number) => Promise<void>;
private traceFn?: TraceCallback;
private logger?: BashLogger;
Expand Down Expand Up @@ -261,6 +264,16 @@ export class Bash {
// Create secure fetch if network is configured
if (options.network) {
this.secureFetch = createSecureFetch(options.network);

// Create secure PostgreSQL connect if PostgreSQL hosts are configured
if (
options.network.allowedPostgresHosts ||
options.network.dangerouslyAllowFullInternetAccess
) {
this.securePostgresConnect = createSecurePostgresConnect(
options.network,
);
}
}

// Store sleep function if provided (for mock clocks in testing)
Expand Down Expand Up @@ -540,6 +553,7 @@ export class Bash {
limits: this.limits,
exec: this.exec.bind(this),
fetch: this.secureFetch,
connectPostgres: this.securePostgresConnect,
sleep: this.sleepFn,
trace: this.traceFn,
coverage: this.coverageWriter,
Expand Down
50 changes: 50 additions & 0 deletions src/commands/psql/connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Connection string parsing and option resolution for psql
*/

import type { SecurePostgresOptions } from "../../network/index.js";
import type { PsqlOptions } from "./parser.js";

/**
* Build SecurePostgresOptions from parsed CLI options
*/
export function buildConnectionOptions(
options: PsqlOptions,
): SecurePostgresOptions | null {
// Host is required
if (!options.host) {
return null;
}

return {
host: options.host,
port: options.port,
database: options.database,
username: options.username,
password: undefined, // Password via CLI is not supported for security
ssl: "prefer", // Default to prefer SSL
};
}

/**
* Get SQL to execute from options
* Returns empty string only if no SQL source is available (no -c, no -f, no stdin)
*/
export function getSqlToExecute(options: PsqlOptions, stdin: string): string {
// -c takes precedence
if (options.command) {
return options.command;
}

// -f will be read later, return placeholder
if (options.file) {
return "FILE"; // Non-empty placeholder to pass validation
}

// Check stdin
if (stdin.trim()) {
return stdin.trim();
}

return "";
}
205 changes: 205 additions & 0 deletions src/commands/psql/formatters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* Output formatters for psql command
*/

import type { PsqlOptions } from "./parser.js";

/**
* Format query results based on output options
*/
export function formatResults(
columns: string[],
rows: unknown[][],
options: PsqlOptions,
): string {
if (rows.length === 0 && options.tuplesOnly) {
return "";
}

switch (options.outputFormat) {
case "aligned":
return formatAligned(columns, rows, options);
case "unaligned":
return formatUnaligned(columns, rows, options);
case "csv":
return formatCsv(columns, rows, options);
case "json":
return formatJson(columns, rows);
case "html":
return formatHtml(columns, rows, options);
default:
return formatAligned(columns, rows, options);
}
}

/**
* Format as aligned table (default psql output)
*/
function formatAligned(
columns: string[],
rows: unknown[][],
options: PsqlOptions,
): string {
if (columns.length === 0) return "";

const widths = columns.map((col, i) => {
const maxDataWidth = Math.max(
...rows.map((row) => String(row[i] ?? "").length),
);
return Math.max(col.length, maxDataWidth);
});

let output = "";

// Header
if (!options.tuplesOnly) {
output +=
columns.map((col, i) => col.padEnd(widths[i])).join(" | ") +
options.recordSeparator;

// Separator line
output +=
widths.map((w) => "-".repeat(w)).join("-+-") + options.recordSeparator;
}

// Rows
for (const row of rows) {
output +=
row.map((val, i) => String(val ?? "").padEnd(widths[i])).join(" | ") +
options.recordSeparator;
}

// Footer with row count
if (!options.tuplesOnly && !options.quiet) {
const rowText = rows.length === 1 ? "row" : "rows";
output += `(${rows.length} ${rowText})${options.recordSeparator}`;
}

return output;
}

/**
* Format as unaligned output (field separator delimited)
*/
function formatUnaligned(
columns: string[],
rows: unknown[][],
options: PsqlOptions,
): string {
let output = "";

// Header
if (!options.tuplesOnly) {
output += columns.join(options.fieldSeparator) + options.recordSeparator;
}

// Rows
for (const row of rows) {
output +=
row.map((val) => String(val ?? "")).join(options.fieldSeparator) +
options.recordSeparator;
}

return output;
}

/**
* Format as CSV
*/
function formatCsv(
columns: string[],
rows: unknown[][],
options: PsqlOptions,
): string {
let output = "";

// Header
if (!options.tuplesOnly) {
output += columns.map(escapeCsv).join(",") + options.recordSeparator;
}

// Rows
for (const row of rows) {
output +=
row.map((val) => escapeCsv(String(val ?? ""))).join(",") +
options.recordSeparator;
}

return output;
}

/**
* Escape CSV field (add quotes if needed)
*/
function escapeCsv(field: string): string {
if (
field.includes(",") ||
field.includes('"') ||
field.includes("\n") ||
field.includes("\r")
) {
return `"${field.replace(/"/g, '""')}"`;
}
return field;
}

/**
* Format as JSON array of objects
*/
function formatJson(columns: string[], rows: unknown[][]): string {
const objects = rows.map((row) => {
const obj: Record<string, unknown> = {};
for (let i = 0; i < columns.length; i++) {
obj[columns[i]] = row[i];
}
return obj;
});

return `${JSON.stringify(objects, null, 2)}\n`;
}

/**
* Format as HTML table
*/
function formatHtml(
columns: string[],
rows: unknown[][],
options: PsqlOptions,
): string {
let output = "<table>\n";

// Header
if (!options.tuplesOnly) {
output += " <thead>\n <tr>\n";
for (const col of columns) {
output += ` <th>${escapeHtml(col)}</th>\n`;
}
output += " </tr>\n </thead>\n";
}

// Body
output += " <tbody>\n";
for (const row of rows) {
output += " <tr>\n";
for (const val of row) {
output += ` <td>${escapeHtml(String(val ?? ""))}</td>\n`;
}
output += " </tr>\n";
}
output += " </tbody>\n";

output += "</table>\n";
return output;
}

/**
* Escape HTML entities
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
Loading