Skip to main content

GEN.L1.DOWN_DROPS_WHAT_UP_FOUND — A rollback that drops what up() may have found already

  • Category: safety
  • Level: 1
  • Confidence: heuristic
  • Downtime class: online
  • Stability: preview
  • Suites: lint
  • Applies to: PostgreSQL 18 and MySQL 8.4

A guard such as Schema::hasTable() lets up() run on a database that already holds the table, and nothing carries that answer over to down(). On an empty database the pair is symmetric. On one an earlier install already filled, up() builds nothing, and the rollback deletes rows the migration never wrote.

Flagged​

// up() creates the table only where it is missing:
if (! Schema::hasTable('ledger_markers')) {
Schema::create('ledger_markers', fn (Blueprint $table) => $table->id());
}
// down(), in the same migration, drops it whatever up() found:
Schema::dropIfExists('ledger_markers');

Preferred​

// In a migration annotated #[SqlensAllowDestructive]. up() as before; down() refuses while the table holds rows:
if (Schema::hasTable('ledger_markers') && DB::table('ledger_markers')->exists()) {
throw new RuntimeException('ledger_markers holds rows an earlier install wrote; export them and drop the table by hand.');
}
Schema::dropIfExists('ledger_markers');

The refusal names what it would delete, so the person running the rollback learns why it stopped, and an empty table still goes.

What it compares​

The rule reads two things the migration file says and puts them beside the rollback:

  • the tables and columns up() asks about in a guard: Schema::hasTable(), Schema::hasColumn() and Schema::hasColumns(), called on the facade or on a schema builder, where the answer decides a branch, also through a variable or the return of a helper method;
  • whether down(), or a method it calls, can throw.
The rollback…is reported when up()…
drops or truncates a tableasks whether the table exists and creates it
drops a columnasks whether the column exists

A down() that can throw is not reported: a rollback that refuses while there is data is the remedy.

A table up() asks about but never creates is GEN.L1.DOWN_MORE_DESTRUCTIVE's finding, and this rule says nothing about it.

Why the captured statements cannot show it​

Under pretend mode the guard is answered with nothing, so the CREATE inside it is captured as if it always ran, and a rollback that drops what up() created is the ordinary one. The difference is only in the file, which is why the pre-scan reads it there.

In pretend mode a migration with such a guard is reported by CAP.PRESCAN.INTROSPECTION_GUARD and not captured, so this rule judges it in shadow mode, where the guard gets a real answer.

What it deliberately does NOT see​

  • A throw that guards something else. Every throw in down() counts as a refusal, because the parse cannot tell one from another. That is why the confidence is heuristic.
  • A name the parse cannot read. Schema::hasTable($this->table) asks about a table whose name is known only at run time, and a guessed name would point the finding at the wrong one.
  • Several columns in one drop. Laravel's $table->dropColumn(['a', 'b']) emits one statement, and its classification names the first column.

When nothing can be lost​

A column derived from the others holds nothing that cannot be rebuilt, so dropping it loses nothing. Say so on the migration, with the rule id and the reason:

return new #[SqlensIgnore(
rules: ['GEN.L1.DOWN_DROPS_WHAT_UP_FOUND'],
reason: 'active_identity is rebuilt from the other columns.',
)] class extends Migration
{
// ...
};

Sources​

The fix material this rule carries​

A finding from this rule carries a payload whose strategy is none: this rule has looked, and there is no safe standard sequence. That is a conclusion rather than an omission — a finding with no payload at all says only that nobody wrote one.

The payload is material for you or an agent to apply. SQLens writes no migration and runs no DDL. See the remediation payload for every field, the placeholder semantics, and the version rules a consumer has to follow.