From 289e840bd9f48e5520cb72b478c9db6bdfd3ce04 Mon Sep 17 00:00:00 2001 From: Eduardo Zaniboni Date: Tue, 10 Mar 2026 10:11:58 -0300 Subject: [PATCH] fix(restore): strip database-redirect directives from plain SQL before restore (v0.9.1) pg_dump -C, pgAdmin and mysqldump --databases embed directives (\connect, CREATE DATABASE, USE) that redirect the client to the source database, overriding the --db target provided by the user. The result was that the data was restored to the source database instead of the intended one. Fix: read the plain SQL file into memory, strip the offending directives via filterSqlDirectives(), then pipe the sanitised content via stdin. This works for both internal herdux backups (which never include these directives) and for external SQL files. Affects: PostgreSQL plain restore (psql), MySQL restore. New helper: src/infra/engines/sql-filter.ts (10 unit tests). --- CHANGELOG.md | 8 ++ README.md | 2 +- README.pt-BR.md | 2 +- package.json | 2 +- src/infra/command-runner.ts | 3 + src/infra/engines/mysql/mysql.engine.ts | 11 ++- src/infra/engines/postgres/postgres.engine.ts | 18 ++-- src/infra/engines/sql-filter.ts | 42 +++++++++ tests/unit/infra/sql-filter.test.ts | 89 +++++++++++++++++++ 9 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 src/infra/engines/sql-filter.ts create mode 100644 tests/unit/infra/sql-filter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e0186..702b3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and --- +## [0.9.1] - 2026-03-10 + +### Fixed + +- `restore` (PostgreSQL plain, MySQL): SQL files produced by `pg_dump -C`, pgAdmin, or `mysqldump --databases` embed `\connect`, `CREATE DATABASE`, and `USE` directives that redirected the client to the source database, ignoring `--db`. The plain SQL content is now filtered to strip these directives before being piped to the database client, so the restore always targets the database specified via `--db`. + +--- + ## [0.9.0] - 2026-03-04 ### Added diff --git a/README.md b/README.md index 1d55e8f..0ab5131 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A fast, interactive CLI that removes friction from daily local database workflows, especially when juggling multiple instances and large datasets. -![Version](https://img.shields.io/badge/version-0.9.0-blue.svg) +![Version](https://img.shields.io/badge/version-0.9.1-blue.svg) ![License](https://img.shields.io/badge/license-MIT-green.svg) ![Node](https://img.shields.io/badge/node-18%2B-43853d.svg) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=flat&logo=postgresql&logoColor=white) diff --git a/README.pt-BR.md b/README.pt-BR.md index ca64fe6..ef0196c 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -12,7 +12,7 @@ Uma CLI rápida e interativa que remove a fricção dos workflows diários com bancos de dados locais, especialmente ao lidar com múltiplas instâncias e grandes datasets. -![Version](https://img.shields.io/badge/version-0.9.0-blue.svg) +![Version](https://img.shields.io/badge/version-0.9.1-blue.svg) ![License](https://img.shields.io/badge/license-MIT-green.svg) ![Node](https://img.shields.io/badge/node-18%2B-43853d.svg) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=flat&logo=postgresql&logoColor=white) diff --git a/package.json b/package.json index 05555a4..3b3645e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "herdux-cli", - "version": "0.9.0", + "version": "0.9.1", "description": "Modern Database workflow CLI focused on developer experience, safety, and automation.", "repository": { "type": "git", diff --git a/src/infra/command-runner.ts b/src/infra/command-runner.ts index bcb3a4d..8c47a61 100644 --- a/src/infra/command-runner.ts +++ b/src/infra/command-runner.ts @@ -12,6 +12,8 @@ export interface RunOptions { timeout?: number; /** Path to a file whose contents will be piped into stdin */ stdin?: string; + /** Raw string content to pipe into stdin directly */ + stdinContent?: string; } export async function runCommand( @@ -25,6 +27,7 @@ export async function runCommand( ...(options.cwd && { cwd: options.cwd }), ...(options.env && { env: { ...process.env, ...options.env } }), ...(options.stdin && { inputFile: options.stdin }), + ...(options.stdinContent !== undefined && { input: options.stdinContent }), }; const result = await execa(command, args, execaOptions); diff --git a/src/infra/engines/mysql/mysql.engine.ts b/src/infra/engines/mysql/mysql.engine.ts index 4027cc9..68b791f 100644 --- a/src/infra/engines/mysql/mysql.engine.ts +++ b/src/infra/engines/mysql/mysql.engine.ts @@ -6,8 +6,9 @@ import type { DatabaseInfo, HealthCheck, } from "../../../core/interfaces/database-engine.interface.js"; -import { existsSync, mkdirSync } from "fs"; +import { existsSync, mkdirSync, readFileSync } from "fs"; import { join, resolve } from "path"; +import { filterSqlDirectives } from "../sql-filter.js"; import { execa } from "execa"; import { checkMysqlClient, checkMysqlDump } from "./mysql-env.js"; @@ -385,12 +386,18 @@ export class MysqlEngine implements IDatabaseEngine { // Database might already exist — that's fine } + // Filter USE / CREATE DATABASE directives so mysql cannot redirect to a + // different database (common in mysqldump --databases exports). + const filteredSql = filterSqlDirectives( + readFileSync(resolvedPath, "utf-8"), + ); + const args = [...buildConnectionArgs(opts), dbName]; const result = await runCommand("mysql", args, { env: buildEnv(opts), timeout: 0, - stdin: resolvedPath, + stdinContent: filteredSql, }); if (result.exitCode !== 0) { diff --git a/src/infra/engines/postgres/postgres.engine.ts b/src/infra/engines/postgres/postgres.engine.ts index 2cca160..5bbd46b 100644 --- a/src/infra/engines/postgres/postgres.engine.ts +++ b/src/infra/engines/postgres/postgres.engine.ts @@ -6,8 +6,9 @@ import type { DatabaseInstance, DatabaseInfo, } from "../../../core/interfaces/database-engine.interface.js"; -import { existsSync, mkdirSync } from "fs"; +import { existsSync, mkdirSync, readFileSync } from "fs"; import { join, resolve } from "path"; +import { filterSqlDirectives } from "../sql-filter.js"; import { execa } from "execa"; import { checkPostgresClient, checkPgDump } from "./postgres-env.js"; @@ -425,17 +426,18 @@ export class PostgresEngine implements IDatabaseEngine { : resolvedPath.toLowerCase().endsWith(".sql"); if (isPlainFormat) { - const args = [ - ...buildConnectionArgs(opts), - "-d", - dbName, - "-f", - resolvedPath, - ]; + // Filter \connect / CREATE DATABASE directives so psql cannot redirect to + // a different database (common in pg_dump -C and pgAdmin exports). + const filteredSql = filterSqlDirectives( + readFileSync(resolvedPath, "utf-8"), + ); + + const args = [...buildConnectionArgs(opts), "-d", dbName]; const result = await runCommand("psql", args, { env: buildEnv(opts), timeout: 0, + stdinContent: filteredSql, }); if (result.exitCode !== 0) { diff --git a/src/infra/engines/sql-filter.ts b/src/infra/engines/sql-filter.ts new file mode 100644 index 0000000..6522f34 --- /dev/null +++ b/src/infra/engines/sql-filter.ts @@ -0,0 +1,42 @@ +/** + * Strips database-redirect directives from a plain SQL file before restore. + * + * Some dump tools (pg_dump -C, pgAdmin, mysqldump --databases) embed statements + * that redirect the connection to the source database, overriding the --db target + * specified by the user. This function removes those directives so the restore + * always targets the correct database. + * + * Stripped patterns: + * PostgreSQL: \connect / \c — psql meta-commands + * MySQL: USE ; — MySQL USE statement + * Both: CREATE DATABASE ...; — database creation block + * DROP DATABASE ...; — database drop block + */ +export function filterSqlDirectives(content: string): string { + const lines: string[] = []; + let insideBlock = false; + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + + // PostgreSQL: \connect and \c meta-commands redirect psql to another DB + if (/^\\(connect|c)\b/i.test(trimmed)) continue; + + // MySQL: USE statement redirects mysql client to another DB + if (/^USE\s+/i.test(trimmed)) continue; + + // CREATE DATABASE / DROP DATABASE may span multiple lines (WITH clause, etc.) + if (/^(CREATE|DROP)\s+DATABASE\b/i.test(trimmed)) { + insideBlock = true; + } + + if (insideBlock) { + if (trimmed.endsWith(";")) insideBlock = false; + continue; + } + + lines.push(line); + } + + return lines.join("\n"); +} diff --git a/tests/unit/infra/sql-filter.test.ts b/tests/unit/infra/sql-filter.test.ts new file mode 100644 index 0000000..e50a72f --- /dev/null +++ b/tests/unit/infra/sql-filter.test.ts @@ -0,0 +1,89 @@ +import { filterSqlDirectives } from "../../../src/infra/engines/sql-filter.js"; + +describe("filterSqlDirectives()", () => { + // --- PostgreSQL directives --- + + it("removes \\connect meta-commands", () => { + const input = + "SET lock_timeout = 0;\n\\connect sateus\nCREATE TABLE t (id int);"; + const result = filterSqlDirectives(input); + expect(result).not.toContain("\\connect"); + expect(result).toContain("SET lock_timeout = 0;"); + expect(result).toContain("CREATE TABLE t"); + }); + + it("removes \\c shorthand meta-commands", () => { + const input = "\\c sateus\nSELECT 1;"; + const result = filterSqlDirectives(input); + expect(result).not.toContain("\\c sateus"); + expect(result).toContain("SELECT 1;"); + }); + + it("removes single-line CREATE DATABASE statement", () => { + const input = "CREATE DATABASE sateus;\nCREATE TABLE t (id int);"; + const result = filterSqlDirectives(input); + expect(result).not.toContain("CREATE DATABASE"); + expect(result).toContain("CREATE TABLE t"); + }); + + it("removes multi-line CREATE DATABASE block", () => { + const input = [ + "CREATE DATABASE sateus", + " WITH TEMPLATE = template0", + " ENCODING = 'UTF8';", + "CREATE TABLE t (id int);", + ].join("\n"); + const result = filterSqlDirectives(input); + expect(result).not.toContain("CREATE DATABASE"); + expect(result).not.toContain("WITH TEMPLATE"); + expect(result).toContain("CREATE TABLE t"); + }); + + it("removes DROP DATABASE statement", () => { + const input = "DROP DATABASE IF EXISTS sateus;\nCREATE TABLE t (id int);"; + const result = filterSqlDirectives(input); + expect(result).not.toContain("DROP DATABASE"); + expect(result).toContain("CREATE TABLE t"); + }); + + // --- MySQL directives --- + + it("removes USE statement", () => { + const input = "USE `sateus`;\nCREATE TABLE t (id int);"; + const result = filterSqlDirectives(input); + expect(result).not.toContain("USE `sateus`"); + expect(result).toContain("CREATE TABLE t"); + }); + + it("removes USE statement without backticks", () => { + const input = "USE sateus;\nINSERT INTO t VALUES (1);"; + const result = filterSqlDirectives(input); + expect(result).not.toContain("USE sateus"); + expect(result).toContain("INSERT INTO t"); + }); + + // --- Case insensitivity --- + + it("is case-insensitive for directives", () => { + const input = "create database foo;\nuse bar;\n\\Connect baz\nSELECT 1;"; + const result = filterSqlDirectives(input); + expect(result).not.toContain("create database"); + expect(result).not.toContain("use bar"); + expect(result).not.toContain("\\Connect"); + expect(result).toContain("SELECT 1;"); + }); + + // --- Passthrough --- + + it("does not alter SQL that has no redirect directives", () => { + const input = + "CREATE TABLE users (id serial, name text);\nINSERT INTO users VALUES (1, 'Alice');"; + expect(filterSqlDirectives(input)).toBe(input); + }); + + it("preserves CREATE TABLE that starts with CREATE keyword", () => { + const input = "CREATE TABLE database_config (key text, val text);"; + const result = filterSqlDirectives(input); + expect(result).toContain("CREATE TABLE database_config"); + }); +});