Files
SkinbaseNova/app/Console/Commands/AuditMigrationSchemaCommand.php
Gregor Klevze 979e011257 Refactor dashboard and upload flows
Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
2026-03-21 11:02:22 +01:00

520 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Finder\Finder;
class AuditMigrationSchemaCommand extends Command
{
protected $signature = 'schema:audit-migrations
{--all-files : Audit all discovered migration files, not only migrations marked as ran}
{--json : Output the report as JSON}
{--base-path=* : Additional base paths to scan for migrations, relative to project root}';
protected $description = 'Compare the live database schema against executed migration files and report missing tables or columns';
private const NO_ARG_COLUMN_METHODS = [
'id' => ['id'],
'timestamps' => ['created_at', 'updated_at'],
'timestampsTz' => ['created_at', 'updated_at'],
'softDeletes' => ['deleted_at'],
'softDeletesTz' => ['deleted_at'],
'rememberToken' => ['remember_token'],
];
private const NON_COLUMN_METHODS = [
'index',
'unique',
'primary',
'foreign',
'foreignIdFor',
'dropColumn',
'dropColumns',
'dropIndex',
'dropUnique',
'dropPrimary',
'dropForeign',
'dropConstrainedForeignId',
'renameColumn',
'renameIndex',
'constrained',
'cascadeOnDelete',
'restrictOnDelete',
'nullOnDelete',
'cascadeOnUpdate',
'restrictOnUpdate',
'nullOnUpdate',
'after',
'nullable',
'default',
'useCurrent',
'useCurrentOnUpdate',
'comment',
'charset',
'collation',
'storedAs',
'virtualAs',
'generatedAs',
'always',
'invisible',
'first',
];
public function handle(): int
{
$migrationFiles = $this->discoverMigrationFiles();
$ranMigrations = collect(DB::table('migrations')->pluck('migration')->all())
->mapWithKeys(fn (string $migration): array => [$migration => true])
->all();
$expected = [];
$parsedFiles = 0;
foreach ($migrationFiles as $migrationName => $path) {
if (! $this->option('all-files') && ! isset($ranMigrations[$migrationName])) {
continue;
}
$parsedFiles++;
$operations = $this->parseMigrationFile($path);
foreach ($operations as $operation) {
$table = $operation['table'];
if ($operation['type'] === 'create-table' && isset($expected[$table])) {
$expected[$table]['sources'][$migrationName] = true;
if (Schema::hasTable($table)) {
$actualColumns = array_fill_keys(
array_map('strtolower', Schema::getColumnListing($table)),
true
);
$existingColumns = array_fill_keys(array_keys($expected[$table]['columns']), true);
$replacementColumns = [];
foreach ($operation['add'] as $column) {
if (! isset($existingColumns[$column]) && isset($actualColumns[$column])) {
$replacementColumns[$column] = true;
}
}
if ($replacementColumns !== []) {
foreach ($replacementColumns as $column => $_) {
$expected[$table]['columns'][$column] = true;
}
foreach (array_keys($expected[$table]['columns']) as $column) {
if (! isset($actualColumns[$column]) && ! isset($replacementColumns[$column])) {
unset($expected[$table]['columns'][$column]);
}
}
}
}
continue;
}
if ($operation['type'] === 'alter-table' && ! isset($expected[$table]) && ! Schema::hasTable($table)) {
continue;
}
$expected[$table] ??= [
'columns' => [],
'sources' => [],
];
$expected[$table]['sources'][$migrationName] = true;
if ($operation['type'] === 'drop-table') {
unset($expected[$table]);
continue;
}
foreach ($operation['add'] as $column) {
$expected[$table]['columns'][$column] = true;
}
foreach ($operation['drop'] as $column) {
unset($expected[$table]['columns'][$column]);
}
}
}
ksort($expected);
$report = [
'parsed_files' => $parsedFiles,
'expected_tables' => count($expected),
'missing_tables' => [],
'missing_columns' => [],
];
foreach ($expected as $table => $spec) {
$sources = array_keys($spec['sources']);
sort($sources);
if (! Schema::hasTable($table)) {
$report['missing_tables'][] = [
'table' => $table,
'sources' => $sources,
];
continue;
}
$actualColumns = array_map('strtolower', Schema::getColumnListing($table));
$expectedColumns = array_keys($spec['columns']);
sort($expectedColumns);
$missing = array_values(array_diff($expectedColumns, $actualColumns));
if ($missing !== []) {
$report['missing_columns'][] = [
'table' => $table,
'columns' => $missing,
'sources' => $sources,
];
}
}
if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->renderReport($report);
}
return ($report['missing_tables'] === [] && $report['missing_columns'] === [])
? self::SUCCESS
: self::FAILURE;
}
/**
* @return array<string, string>
*/
private function discoverMigrationFiles(): array
{
$paths = [
database_path('migrations'),
base_path('packages/klevze'),
];
foreach ((array) $this->option('base-path') as $relativePath) {
$resolved = base_path((string) $relativePath);
if (is_dir($resolved)) {
$paths[] = $resolved;
}
}
$finder = new Finder();
$finder->files()->name('*.php');
foreach ($paths as $path) {
if (is_dir($path)) {
$finder->in($path);
}
}
$files = [];
foreach ($finder as $file) {
$realPath = $file->getRealPath();
if (! $realPath) {
continue;
}
$normalized = str_replace('\\', '/', $realPath);
if (! str_contains($normalized, '/database/migrations/') && ! str_contains($normalized, '/Migrations/')) {
continue;
}
$files[pathinfo($realPath, PATHINFO_FILENAME)] = $realPath;
}
ksort($files);
return $files;
}
/**
* @return array<int, array{type:string, table:string, add:array<int,string>, drop:array<int,string>}>
*/
private function parseMigrationFile(string $path): array
{
$content = File::get($path);
$upBody = $this->extractMethodBody($content, 'up');
if ($upBody === null) {
return [];
}
$operations = [];
foreach ($this->extractSchemaClosures($upBody) as $closure) {
$operations[] = [
'type' => $closure['operation'],
'table' => $closure['table'],
'add' => $this->extractAddedColumns($closure['body']),
'drop' => $this->extractDroppedColumns($closure['body']),
];
}
if (preg_match_all("/Schema::drop(?:IfExists)?\(\s*['\"]([^'\"]+)['\"]\s*\)/", $upBody, $matches)) {
foreach ($matches[1] as $table) {
$operations[] = [
'type' => 'drop-table',
'table' => strtolower((string) $table),
'add' => [],
'drop' => [],
];
}
}
foreach ($this->extractRawAlterTableChanges($upBody) as $change) {
$operations[] = [
'type' => 'alter-table',
'table' => $change['table'],
'add' => [$change['new_column']],
'drop' => [$change['old_column']],
];
}
return $operations;
}
/**
* @return array<int, array{table:string, old_column:string, new_column:string}>
*/
private function extractRawAlterTableChanges(string $upBody): array
{
$changes = [];
if (preg_match_all(
'/ALTER\s+TABLE\s+[`"]?([^`"\s]+)[`"]?\s+CHANGE(?:\s+COLUMN)?\s+[`"]?([^`"\s]+)[`"]?\s+[`"]?([^`"\s]+)[`"]?/i',
$upBody,
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$oldColumn = strtolower((string) $match[2]);
$newColumn = strtolower((string) $match[3]);
if ($oldColumn === $newColumn) {
continue;
}
$changes[] = [
'table' => strtolower((string) $match[1]),
'old_column' => $oldColumn,
'new_column' => $newColumn,
];
}
}
return $changes;
}
private function extractMethodBody(string $content, string $method): ?string
{
if (! preg_match('/function\s+' . preg_quote($method, '/') . '\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/m', $content, $match, PREG_OFFSET_CAPTURE)) {
return null;
}
$start = $match[0][1] + strlen($match[0][0]) - 1;
$end = $this->findMatchingBrace($content, $start);
if ($end === null) {
return null;
}
return substr($content, $start + 1, $end - $start - 1);
}
private function findMatchingBrace(string $content, int $openingBracePos): ?int
{
$length = strlen($content);
$depth = 0;
$inSingle = false;
$inDouble = false;
for ($index = $openingBracePos; $index < $length; $index++) {
$char = $content[$index];
$prev = $index > 0 ? $content[$index - 1] : '';
if ($char === "'" && ! $inDouble && $prev !== '\\') {
$inSingle = ! $inSingle;
continue;
}
if ($char === '"' && ! $inSingle && $prev !== '\\') {
$inDouble = ! $inDouble;
continue;
}
if ($inSingle || $inDouble) {
continue;
}
if ($char === '{') {
$depth++;
continue;
}
if ($char === '}') {
$depth--;
if ($depth === 0) {
return $index;
}
}
}
return null;
}
/**
* @return array<int, array{operation:string, table:string, body:string}>
*/
private function extractSchemaClosures(string $upBody): array
{
preg_match_all('/Schema::(create|table)\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*function/s', $upBody, $matches, PREG_OFFSET_CAPTURE);
$closures = [];
foreach ($matches[0] as $index => $fullMatch) {
$offset = (int) $fullMatch[1];
$operation = strtolower((string) $matches[1][$index][0]) === 'create' ? 'create-table' : 'alter-table';
$table = strtolower((string) $matches[2][$index][0]);
$bracePos = strpos($upBody, '{', $offset);
if ($bracePos === false) {
continue;
}
$closing = $this->findMatchingBrace($upBody, $bracePos);
if ($closing === null) {
continue;
}
$closures[] = [
'operation' => $operation,
'table' => $table,
'body' => substr($upBody, $bracePos + 1, $closing - $bracePos - 1),
];
}
return $closures;
}
/**
* @return array<int, string>
*/
private function extractAddedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
$column = strtolower((string) $match[2]);
if (in_array($method, self::NON_COLUMN_METHODS, true)) {
continue;
}
$columns[$column] = true;
}
}
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*\)(?:[^;]*)?;/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
foreach (self::NO_ARG_COLUMN_METHODS[$method] ?? [] as $column) {
$columns[$column] = true;
}
}
}
if (preg_match_all('/\$table->(nullableMorphs|morphs|uuidMorphs|nullableUuidMorphs|ulidMorphs|nullableUlidMorphs)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$prefix = strtolower((string) $match[2]);
$columns[$prefix . '_type'] = true;
$columns[$prefix . '_id'] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @return array<int, string>
*/
private function extractDroppedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->dropColumn\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches)) {
foreach ($matches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
if (preg_match_all('/\$table->dropColumn\(\s*\[(.*?)\]\s*\);/s', $body, $matches)) {
foreach ($matches[1] as $arrayBody) {
if (preg_match_all('/[\'\"]([^\'\"]+)[\'\"]/', $arrayBody, $columnMatches)) {
foreach ($columnMatches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
}
}
if (preg_match_all('/\$table->renameColumn\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$columns[strtolower((string) $match[1])] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @param array{parsed_files:int, expected_tables:int, missing_tables:array<int,array{table:string,sources:array<int,string>}>, missing_columns:array<int,array{table:string,columns:array<int,string>,sources:array<int,string>}>} $report
*/
private function renderReport(array $report): void
{
$this->info(sprintf(
'Parsed %d migration file(s). Expected schema covers %d table(s).',
$report['parsed_files'],
$report['expected_tables']
));
if ($report['missing_tables'] === [] && $report['missing_columns'] === []) {
$this->info('Schema audit passed. No missing tables or columns detected.');
return;
}
if ($report['missing_tables'] !== []) {
$this->newLine();
$this->error('Missing tables:');
foreach ($report['missing_tables'] as $item) {
$this->line(sprintf(' - %s', $item['table']));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
if ($report['missing_columns'] !== []) {
$this->newLine();
$this->error('Missing columns:');
foreach ($report['missing_columns'] as $item) {
$this->line(sprintf(' - %s: %s', $item['table'], implode(', ', $item['columns'])));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
}
}