'INTEGER', '/\binteger\b/' => 'INTEGER', '/\bsmallint\b/' => 'INTEGER', '/\bboolean\b/' => 'INTEGER', '/character varying\(\d+\)/' => 'TEXT', '/timestamp\(\d+\) without time zone/' => 'TEXT', '/timestamp\(\d+\) with time zone/' => 'TEXT', '/\bjsonb\b/' => 'TEXT', '/\bjson\b/' => 'TEXT', '/\buuid\b/' => 'TEXT', '/double precision/' => 'REAL', '/numeric\(\d+,\d+\)/' => 'REAL', '/\bdate\b/' => 'TEXT', ]; private array $castRemovals = [ '::character varying', '::text', '::integer', '::boolean', '::timestamp without time zone', '::timestamp with time zone', '::numeric', ]; public function handle(): int { $connection = $this->option('connection'); if (DB::connection($connection)->getDriverName() !== 'pgsql') { $this->error("Connection '{$connection}' is not PostgreSQL."); return self::FAILURE; } $this->info('Reading schema from PostgreSQL...'); $tables = $this->getTables($connection); $lastMigration = DB::connection($connection) ->table('migrations') ->orderByDesc('id') ->value('migration'); $output = []; $output[] = '-- Generated by: php artisan schema:generate-testing'; $output[] = '-- Date: '.now()->format('Y-m-d H:i:s'); $output[] = '-- Last migration: '.($lastMigration ?? 'none'); $output[] = ''; foreach ($tables as $table) { $columns = $this->getColumns($connection, $table); $output[] = $this->generateCreateTable($table, $columns); } $indexes = $this->getIndexes($connection, $tables); foreach ($indexes as $index) { $output[] = $index; } $output[] = ''; $output[] = '-- Migration records'; $migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get(); foreach ($migrations as $m) { $migration = str_replace("'", "''", $m->migration); $output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});"; } $path = database_path('schema/testing-schema.sql'); if (! is_dir(dirname($path))) { mkdir(dirname($path), 0755, true); } file_put_contents($path, implode("\n", $output)."\n"); $this->info("Schema written to {$path}"); $this->info(count($tables).' tables, '.count($migrations).' migration records.'); return self::SUCCESS; } private function getTables(string $connection): array { return collect(DB::connection($connection)->select( "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename" ))->pluck('tablename')->toArray(); } private function getColumns(string $connection, string $table): array { return DB::connection($connection)->select( "SELECT column_name, data_type, character_maximum_length, column_default, is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? ORDER BY ordinal_position", [$table] ); } private function generateCreateTable(string $table, array $columns): string { $lines = []; foreach ($columns as $col) { $lines[] = ' '.$this->generateColumnDef($table, $col); } return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n"; } private function generateColumnDef(string $table, object $col): string { $name = $col->column_name; $sqliteType = $this->convertType($col); // Auto-increment primary key for id columns if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) { return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL"; } $parts = ["\"{$name}\"", $sqliteType]; // Default value $default = $col->column_default; if ($default !== null && ! str_contains($default, 'nextval')) { $default = $this->cleanDefault($default); $parts[] = "DEFAULT {$default}"; } // NOT NULL if ($col->is_nullable === 'NO') { $parts[] = 'NOT NULL'; } return implode(' ', $parts); } private function convertType(object $col): string { $pgType = $col->data_type; return match (true) { in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER', $pgType === 'boolean' => 'INTEGER', in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT', str_contains($pgType, 'timestamp') => 'TEXT', in_array($pgType, ['json', 'jsonb']) => 'TEXT', $pgType === 'uuid' => 'TEXT', $pgType === 'double precision' => 'REAL', $pgType === 'numeric' => 'REAL', $pgType === 'date' => 'TEXT', default => 'TEXT', }; } private function cleanDefault(string $default): string { foreach ($this->castRemovals as $cast) { $default = str_replace($cast, '', $default); } // Remove array type casts like ::text[] $default = preg_replace('/::[\w\s]+(\[\])?/', '', $default); return $default; } private function getIndexes(string $connection, array $tables): array { $results = []; $indexes = DB::connection($connection)->select( "SELECT indexname, tablename, indexdef FROM pg_indexes WHERE schemaname = 'public' ORDER BY tablename, indexname" ); foreach ($indexes as $idx) { $def = $idx->indexdef; // Skip primary key indexes if (str_contains($def, '_pkey')) { continue; } // Skip PG-specific indexes (GIN, GIST, expression indexes) if (preg_match('/USING (gin|gist)/i', $def)) { continue; } if (str_contains($def, '->>') || str_contains($def, '::')) { continue; } // Convert to SQLite-compatible CREATE INDEX $unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : ''; // Extract columns from the index definition if (preg_match('/\((.+)\)$/', $def, $m)) { $cols = $m[1]; $results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});"; } } return $results; } }