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
6 changes: 3 additions & 3 deletions .github/workflows/binary-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ jobs:
./dbdiff diff \
--server1-url "mysql://root:rootpass@127.0.0.1:3306/diff1" \
--server2-url "mysql://root:rootpass@127.0.0.1:3306/diff2" \
--type=schema --include=all --nocomments \
--type=schema --include=all --nocomments --allow-destructive \
--output=mysql-diff.sql
echo "--- MySQL diff output ($(wc -l < mysql-diff.sql) lines) ---"
cat mysql-diff.sql
Expand All @@ -230,7 +230,7 @@ jobs:
./dbdiff diff \
--server1-url "postgres://postgres:rootpass@127.0.0.1:5432/diff1" \
--server2-url "postgres://postgres:rootpass@127.0.0.1:5432/diff2" \
--type=schema --include=all --nocomments \
--type=schema --include=all --nocomments --allow-destructive \
--output=pgsql-diff.sql
echo "--- Postgres diff output ($(wc -l < pgsql-diff.sql) lines) ---"
cat pgsql-diff.sql
Expand Down Expand Up @@ -267,7 +267,7 @@ jobs:
node packages/@dbdiff/cli/bin/dbdiff.js diff \
--server1-url "mysql://root:rootpass@127.0.0.1:3306/diff1" \
--server2-url "mysql://root:rootpass@127.0.0.1:3306/diff2" \
--type=schema --include=all --nocomments \
--type=schema --include=all --nocomments --allow-destructive \
--output=npm-diff.sql
echo "--- npm diff output ($(wc -l < npm-diff.sql) lines) ---"
cat npm-diff.sql
Expand Down
21 changes: 19 additions & 2 deletions src/DBDiff.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
use DBDiff\SQLGen\SQLGenerator;
use DBDiff\Logger;
use DBDiff\Templater;
use DBDiff\Linter\DestructiveLinter;
use DBDiff\Exceptions\DestructiveChangeException;


class DBDiff {
Expand Down Expand Up @@ -49,8 +51,15 @@ public function run($params = null): void
* can apply any output format (Flyway, Liquibase, Laravel, native…) rather
* than being forced through the Templater.
*
* When $params->allowDestructive is false (the default) and the diff
* contains destructive schema changes (DROP TABLE, DROP COLUMN, etc.), a
* DestructiveChangeException is thrown before any SQL is generated.
* Pass --allow-destructive on the CLI (or set $params->allowDestructive = true)
* to bypass the check.
*
* @param object $params A params object (DefaultParams-shaped stdClass or subclass).
* @return array{empty: bool, up: string, down: string}
* @return array{empty: bool, up: string, down: string, lint?: \DBDiff\Linter\LintResult}
* @throws DestructiveChangeException
*/
public function getDiffResult(object $params): array
{
Expand All @@ -61,10 +70,18 @@ public function getDiffResult(object $params): array
return ['empty' => true, 'up' => '', 'down' => ''];
}

$allowDestructive = $params->allowDestructive ?? false;
$linter = new DestructiveLinter();
$lintResult = $linter->lint($diff);

if ($lintResult->hasErrors() && !$allowDestructive) {
throw new DestructiveChangeException($lintResult);
}

$sqlGenerator = new SQLGenerator($diff);
$up = ($params->include !== 'down') ? $sqlGenerator->getUp() : '';
$down = ($params->include !== 'up') ? $sqlGenerator->getDown() : '';

return ['empty' => false, 'up' => $up, 'down' => $down];
return ['empty' => false, 'up' => $up, 'down' => $down, 'lint' => $lintResult];
}
}
47 changes: 47 additions & 0 deletions src/Exceptions/DestructiveChangeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php namespace DBDiff\Exceptions;

use DBDiff\Linter\LintResult;

/**
* Thrown by DBDiff::getDiffResult() when the diff contains destructive schema
* changes and $params->allowDestructive is false (the default).
*
* The exception message is suitable for direct display to the user.
* Pass --allow-destructive on the CLI to suppress this exception.
*/
class DestructiveChangeException extends BaseException {
private LintResult $lintResult;

public function __construct(LintResult $lintResult) {
$this->lintResult = $lintResult;

$errors = $lintResult->getErrors();
$warnings = $lintResult->getWarnings();
$parts = [];
if (!empty($errors)) {
$parts[] = count($errors) . ' destructive error(s)';
}
if (!empty($warnings)) {
$parts[] = count($warnings) . ' warning(s)';
}

$lines = ['Destructive changes detected — ' . implode(', ', $parts) . ':'];
foreach ($errors as $v) {
$lines[] = " [error] {$v->type}: {$v->object}";
if ($v->suggestion) {
$lines[] = " → {$v->suggestion}";
}
}
foreach ($warnings as $v) {
$lines[] = " [warning] {$v->type}: {$v->object}";
}
$lines[] = '';
$lines[] = 'Re-run with --allow-destructive to generate the migration anyway.';

parent::__construct(implode("\n", $lines));
}

public function getLintResult(): LintResult {
return $this->lintResult;
}
}
143 changes: 143 additions & 0 deletions src/Linter/DestructiveLinter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php namespace DBDiff\Linter;

use DBDiff\Diff\DropTable;
use DBDiff\Diff\AlterTableDropColumn;
use DBDiff\Diff\AlterTableAddColumn;
use DBDiff\Diff\DropEnum;
use DBDiff\Diff\DropRoutine;
use DBDiff\Diff\DropTrigger;
use DBDiff\Diff\DropView;

/**
* Inspects a diff array and returns a LintResult describing every
* potentially destructive schema change.
*
* Violations with level='error' block migration generation unless
* $params->allowDestructive is true. Level='warning' items are
* reported but never block generation.
*/
class DestructiveLinter {
/**
* @param array{schema?: list<object>, data?: list<object>} $diff
*/
public function lint(array $diff): LintResult {

Check failure on line 23 in src/Linter/DestructiveLinter.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 27 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=DBDiff_DBDiff&issues=AZ7iI3wWutz5k-8tUx7L&open=AZ7iI3wWutz5k-8tUx7L&pullRequest=174
$schema = $diff['schema'] ?? [];
$violations = [];

// First pass: index drop-column / add-column per table for rename detection
$dropsByTable = [];
$addsByTable = [];
foreach ($schema as $item) {
if ($item instanceof AlterTableDropColumn) {
$dropsByTable[$item->table][] = $item;
}
if ($item instanceof AlterTableAddColumn) {
$addsByTable[$item->table][] = $item;
}
}

// Keys of drop-column items that look like one half of a rename
$renameKeys = [];
foreach ($dropsByTable as $table => $drops) {
$adds = $addsByTable[$table] ?? [];
foreach ($drops as $drop) {
foreach ($adds as $add) {
if ($this->likelyRename($drop, $add)) {
$renameKeys[] = $this->columnKey($table, $drop->column);
break;
}
}
}
}

foreach ($schema as $item) {
if ($item instanceof DropTable) {
$violations[] = new LintViolation(
'error',
'drop-table',
"table `{$item->table}`",
"DROP TABLE `{$item->table}`",
'Use --allow-destructive to proceed, or archive the table instead.'
);
} elseif ($item instanceof AlterTableDropColumn) {
$key = $this->columnKey($item->table, $item->column);
if (in_array($key, $renameKeys, true)) {
$violations[] = new LintViolation(
'warning',
'possible-rename',
"column `{$item->table}`.`{$item->column}`",
"ALTER TABLE `{$item->table}` DROP COLUMN `{$item->column}`",
'Detected a possible column rename. If intentional, use --allow-destructive.'
);
} else {
$violations[] = new LintViolation(
'error',
'drop-column',
"column `{$item->table}`.`{$item->column}`",
"ALTER TABLE `{$item->table}` DROP COLUMN `{$item->column}`",
'Use --allow-destructive to proceed. Back up data in this column first.'
);
}
} elseif ($item instanceof DropEnum) {
$violations[] = new LintViolation(
'warning',
'drop-enum',
"enum type `{$item->name}`",
"DROP TYPE \"{$item->name}\"",
'Ensure no columns reference this enum before dropping.'
);
} elseif ($item instanceof DropRoutine) {
$violations[] = new LintViolation(
'warning',
'drop-routine',
"routine `{$item->name}`",
"DROP FUNCTION/PROCEDURE \"{$item->name}\"",
'Ensure no code references this routine before dropping.'
);
} elseif ($item instanceof DropTrigger) {
$violations[] = new LintViolation(
'warning',
'drop-trigger',
"trigger `{$item->name}` on `{$item->table}`",
"DROP TRIGGER \"{$item->name}\"",
'Verify that removing this trigger does not break business logic.'
);
} elseif ($item instanceof DropView) {
$violations[] = new LintViolation(
'warning',
'drop-view',
"view `{$item->name}`",
"DROP VIEW \"{$item->name}\"",
'Ensure no queries or applications reference this view.'
);
}
}

return new LintResult($violations);
}

private function columnKey(string $table, string $column): string {
return "{$table}.{$column}";
}

/**
* Heuristic: same table, same data type → probably a rename, not a true drop.
* The diff sub-object comes from the DiffCalculator and may be a stdClass or array.
*/
private function likelyRename(AlterTableDropColumn $drop, AlterTableAddColumn $add): bool {
$dropType = $this->extractType($drop->diff);
$addType = $this->extractType($add->diff);
return $dropType !== null && $addType !== null && strtolower($dropType) === strtolower($addType);
}

/** Extracts the column data type from the diff metadata object. */
private function extractType($diff): ?string {
if (is_object($diff)) {
return $diff->Type ?? $diff->DATA_TYPE ?? $diff->type ?? null;
}
if (is_array($diff)) {
return $diff['Type'] ?? $diff['DATA_TYPE'] ?? $diff['type'] ?? null;
}
return null;
}
}
45 changes: 45 additions & 0 deletions src/Linter/LintResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php namespace DBDiff\Linter;

/** Aggregate of all LintViolations produced by a single linter run. */
class LintResult {
/** @var LintViolation[] */
private array $violations;

/** @param LintViolation[] $violations */
public function __construct(array $violations = []) {
$this->violations = $violations;
}

/** @return LintViolation[] */
public function getViolations(): array {
return $this->violations;
}

public function hasErrors(): bool {
foreach ($this->violations as $v) {
if ($v->level === 'error') return true;

Check failure on line 20 in src/Linter/LintResult.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add curly braces around the nested statement(s).

See more on https://sonarcloud.io/project/issues?id=DBDiff_DBDiff&issues=AZ7iI3s7utz5k-8tUx7J&open=AZ7iI3s7utz5k-8tUx7J&pullRequest=174
}
return false;
}

public function hasWarnings(): bool {
foreach ($this->violations as $v) {
if ($v->level === 'warning') return true;

Check failure on line 27 in src/Linter/LintResult.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add curly braces around the nested statement(s).

See more on https://sonarcloud.io/project/issues?id=DBDiff_DBDiff&issues=AZ7iI3s7utz5k-8tUx7K&open=AZ7iI3s7utz5k-8tUx7K&pullRequest=174
}
return false;
}

public function isEmpty(): bool {
return empty($this->violations);
}

/** @return LintViolation[] */
public function getErrors(): array {
return array_values(array_filter($this->violations, fn($v) => $v->level === 'error'));
}

/** @return LintViolation[] */
public function getWarnings(): array {
return array_values(array_filter($this->violations, fn($v) => $v->level === 'warning'));
}
}
33 changes: 33 additions & 0 deletions src/Linter/LintViolation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php namespace DBDiff\Linter;

/** Value object representing a single linting violation. */
class LintViolation {
/** @var string 'error' or 'warning' */
public string $level;

/** @var string Violation type slug, e.g. 'drop-table', 'drop-column'. */
public string $type;

/** @var string Human-readable object description, e.g. "table `orders`". */
public string $object;

/** @var string The SQL statement that triggered this violation. */
public string $sql;

/** @var string Optional remediation hint shown in the error message. */
public string $suggestion;

public function __construct(
string $level,
string $type,
string $object,
string $sql,
string $suggestion = ''
) {
$this->level = $level;
$this->type = $type;
$this->object = $object;
$this->sql = $sql;
$this->suggestion = $suggestion;
}
}
9 changes: 7 additions & 2 deletions src/Migration/Command/DiffCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ protected function configure(): void
->addOption('tables', null, InputOption::VALUE_REQUIRED,
'Comma-separated list of tables to include (supports globs: *, ?). Overrides tablesToIgnore.')
->addOption('ignore-tables', null, InputOption::VALUE_REQUIRED,
'Comma-separated list of tables to exclude (supports globs: *, ?).');
'Comma-separated list of tables to exclude (supports globs: *, ?).')
// ── Safety options ────────────────────────────────────────────────
->addOption('allow-destructive', null, InputOption::VALUE_NONE,
'Allow destructive schema changes (DROP TABLE, DROP COLUMN) to proceed without error. '
. 'Back up your data before using this flag.');
}

protected function execute(InputInterface $input, OutputInterface $output): int
Expand Down Expand Up @@ -132,7 +136,8 @@ private function buildParams(InputInterface $input): DefaultParams
$params->template = $input->getOption('template') ?? '';
$params->config = $input->getOption('config');
$params->description = $input->getOption('description') ?: '';
$params->memoryLimit = $input->getOption('memory-limit');
$params->memoryLimit = $input->getOption('memory-limit');
$params->allowDestructive = (bool) $input->getOption('allow-destructive');

if ($input->getOption('tables')) {
$params->tables = array_map('trim', explode(',', $input->getOption('tables')));
Expand Down
4 changes: 3 additions & 1 deletion src/Params/CLIGetter.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public function getParams() {
'server1::', 'server2::', 'format::',
'template::', 'type::', 'include::',
'nocomments::', 'config::', 'output::', 'debug::',
'driver::', 'supabase::'
'driver::', 'supabase::', 'allow-destructive::'
]);

$input = $getopt->get(1);
Expand Down Expand Up @@ -65,6 +65,8 @@ public function getParams() {
$params->driver = 'pgsql';
$params->sslmode = 'require';
}
// --allow-destructive bypasses the destructive-change linter
$params->allowDestructive = (bool) $getopt->get('--allow-destructive');

return $params;
}
Expand Down
7 changes: 7 additions & 0 deletions src/Params/DefaultParams.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ class DefaultParams {
*/
public $tableScope = null;

/*
When false (default), getDiffResult() throws DestructiveChangeException if the diff
contains DROP TABLE or DROP COLUMN operations, preventing accidental data-loss
migrations. Set to true (or pass --allow-destructive on the CLI) to override.
*/
public bool $allowDestructive = false;

/*
The penultimate parameter is what to compare: db1.table1:db2.table3 or​ db1:db2
This tool can compare just one table or all tables (entire db) from the database
Expand Down
Loading
Loading