From 70710b24c5320390908c054283174e4a178b13e4 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vial Date: Wed, 18 Dec 2024 19:47:43 -0300 Subject: [PATCH] Migration generator per table. --- app/composer.json | 3 +- app/generate-migrations.php | 718 ++++++++++++++++++++++++++++++++++++ 2 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 app/generate-migrations.php diff --git a/app/composer.json b/app/composer.json index 1223dfc..00f4029 100644 --- a/app/composer.json +++ b/app/composer.json @@ -17,7 +17,8 @@ "phpoffice/phpspreadsheet": "^3", "predis/predis": "^2", "robmorgan/phinx": "^0.16", - "slim/slim": "^4" + "slim/slim": "^4", + "symfony/string": "^7.2" }, "require-dev": { "fakerphp/faker": "^1", diff --git a/app/generate-migrations.php b/app/generate-migrations.php new file mode 100644 index 0000000..861e9d1 --- /dev/null +++ b/app/generate-migrations.php @@ -0,0 +1,718 @@ +tables)) { + $results = $this->connection->query("SHOW TABLES"); + $rows = $results->fetchAll(PDO::FETCH_ASSOC); + $this->tables = array_map(function($row) { + return $row["Tables_in_{$this->database}"]; + }, $rows); + } + return $this->tables; + } + public function getTableDefinition(string $table): string + { + if (!isset($this->definitions[$table])) { + $results = $this->connection->query("SHOW CREATE TABLE {$table}"); + $row = $results->fetch(PDO::FETCH_ASSOC); + $this->definitions[$table] = $row['Create Table']; + } + return $this->definitions[$table]; + } + public function parseTableDefinition(string $table, int $offset = 2): string + { + $this->extractLines($table); + $tableLine = "\$this->table('{$table}'"; + if (count($this->primary) === 1) { + $tableLine .= ", ['id' => '{$this->primary[0]}']"; + } elseif (count($this->primary) > 1) { + $primaryString = implode(', ', array_map(function($key) {return "'{$key}'";}, $this->primary)); + $tableLine .= ", ['id' = false, 'primary_key' => [{$primaryString}]]"; + } + $tableLine .= ')'; + $output = [$this->prefixLine($tableLine, $offset)]; + foreach ($this->columns as $column) { + $output []= $this->prefixLine($column, $offset + 1); + } + foreach ($this->constraints as $constraint) { + $output []= $this->prefixLine($constraint, $offset + 1); + } + $output []= $this->prefixLine('->create();', $offset + 1); + return implode(PHP_EOL, $output); + } + public function getTableIndex(string $table): int + { + return array_search($table, $this->getTables()); + } + + protected array $tables; + protected array $definitions; + protected array $primary; + protected array $columns; + protected array $constraints; + + protected function extractLines(string $table): void + { + $this->primary = []; + $this->columns = []; + $this->constraints = []; + $lines = explode(PHP_EOL, $this->definitions[$table]); + + array_shift($lines); + foreach ($lines as $line) { + if (str_starts_with(trim($line), ') ENGINE')) { + break; + } + if (str_starts_with(trim($line), '`id`') or str_starts_with(trim($line), 'KEY')) { + continue; + } + if (str_starts_with(trim($line), 'PRIMARY KEY')) { + if (!str_contains($line, '`id`')) { + $ini = strpos($line, '(') + 1; + $end = strpos($line, ')', $ini); + $this->primary []= substr($line, $ini, $end - $ini); + } + continue; + } + if (str_starts_with(trim($line), 'CONSTRAINT')) { + $this->constraints []= (new ConstraintParser($line))->parse(); + continue; + } + $this->columns []= (new ColumnParser($line))->parse(); + } + } + protected function prefixLine(string $line, int $length, string $character = "\t"): string + { + if ($length === 0) { + return $line; + } + return implode('', array_fill(0, $length, $character)) . $line; + } +} +class DataRepository +{ + public function __construct(protected Incoviba\Common\Define\Connection $connection) {} + + public int $size; + + public function getData(string $table): Generator + { + $query = $this->connection->getQueryBuilder() + ->select() + ->from($table); + $results = $this->connection->query($query); + $this->size = $results->rowCount(); + while ($row = $results->fetch(PDO::FETCH_ASSOC)) { + yield $row; + } + } +} +class ColumnParser +{ + public function __construct(protected string $line) {} + + public function parse(): string + { + return "->addColumn('{$this->getName()}', '{$this->getType()}'{$this->getOptions()})"; + } + + protected int $ini; + protected int $end; + protected array $parts; + protected array $options; + + protected function getName(): string + { + $ini = $this->getIni(); + $end = $this->getEnd(); + return substr($this->line, $ini, $end - $ini); + } + protected function getType(): string + { + $parts = $this->getParts(); + $type = array_shift($parts); + + if (str_contains($type, '(')) { + list($type, $length) = explode('(', $type); + $this->options []= "'length' => " . rtrim($length, ')'); + } + return match($type) { + 'int' => 'integer', + 'tinyint' => 'boolean', + 'varchar' => 'string', + default => $type + }; + } + protected function getOptions(): string + { + $parts = $this->getParts(); + array_shift($parts); + $validOptions = [ + 'default' => function($parts) { + $i = array_search('default', array_map(function($part) {return strtolower($part);}, $parts)); + return ['default', $parts[$i + 1]]; + }, + 'null' => function($parts) { + $value = true; + $i = array_search('null', array_map(function($part) {return strtolower($part);}, $parts)); + if (key_exists($i - 1, $parts) and strtolower($parts[$i - 1]) === 'not') { + $value = false; + } + return ['null', $value]; + }, + 'unsigned' => function($parts) { + return ['signed', false]; + }, + 'auto_increment' => function($parts) { + return ['auto_increment', true]; + } + ]; + foreach ($validOptions as $validOption => $callable) { + if (str_contains(strtolower($this->line), $validOption)) { + list($option, $value) = $callable($parts); + if (!is_bool($value) and !ctype_digit($value)) { + $value = trim($value, "'"); + $value = "'{$value}'"; + } + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + $this->options []= "'{$option}' => {$value}"; + } + } + if (count($this->options) === 0) { + return ''; + } + return ', [' . implode(', ', $this->options) . ']'; + } + protected function getIni(): int + { + if (!isset($this->ini)) { + $this->ini = strpos($this->line, '`') + 1; + } + return $this->ini; + } + protected function getEnd(): int + { + if (!isset($this->end)) { + $this->end = strpos($this->line, '`', $this->getIni()); + } + return $this->end; + } + protected function getParts(): array + { + if (!isset($this->parts)) { + $this->parts = explode(' ', trim(substr($this->line, $this->getEnd() + 1), ' ,')); + } + return $this->parts; + } +} +class ConstraintParser +{ + public function __construct(protected string $line) {} + + public function parse(): string + { + return "->addForeignKey({$this->getColumns()}, '{$this->getTable()}', {$this->getReferences()}, ['delete' => 'cascade', 'update' => 'cascade'])"; + } + + protected function getColumns(): string + { + $ini = strpos(strtolower($this->line), 'foreign key') + strlen('FOREIGN KEY '); + $ini = strpos($this->line, '(', $ini) + 1; + $end = strpos(strtolower($this->line), ' references', $ini + 1) - strlen(')'); + return $this->getNames($ini, $end); + } + protected function getTable(): string + { + $ini = strpos(strtolower($this->line), 'references') + strlen('REFERENCES ') + 1; + $end = strpos($this->line, '`', $ini + 1); + return substr($this->line, $ini, $end - $ini); + } + protected function getReferences(): string + { + $ini = strpos($this->line, '(', strpos(strtolower($this->line), 'references')) + 1; + $end = strpos($this->line, ')', $ini); + return $this->getNames($ini, $end); + } + protected function getNames($ini, $end): string + { + $names = substr($this->line, $ini, $end - $ini); + if (!str_contains($names, ',')) { + return str_replace('`', "'", $names); + } + $names = explode(', ', $names); + $columns = array_map(function($name) {return str_replace('`', "'", $name);}, $names); + return '[' . implode(', ', $columns) . ']'; + } +} +class MigrationGenerator +{ + public function __construct(protected Config $settings) + { + $this->databaseName = $settings->getEnv('DB_DATABASE'); + $this->migrationsPath = $settings->getMigrationsPath(); + $this->startDate = $settings->getStartDate(); + $this->database = new Incoviba\Common\Implement\Database\MySQL($settings->getEnv('DB_HOST'), $settings->getEnv('DB_DATABASE'), $settings->getEnv('DB_USER'), $settings->getEnv('DB_PASSWORD')); + $this->connection = new Incoviba\Common\Implement\Connection($this->database, new Incoviba\Common\Implement\Database\Query\Builder()); + $this->tableRepository = new TableRepository($this->connection, $this->databaseName); + $this->logger = $settings->getLogger(); + } + + public function run(bool $execute = true): void + { + $this->logger->output('Running generate migrations' . (($execute) ? '' : ' [dry-run]'), 250); + foreach ($this->tableRepository->getTables() as $tableName) { + if (in_array($tableName, $this->settings->getSkip())) { + continue; + } + $this->logger->output("Table: {$tableName}"); + $filename = $this->buildFilename($tableName); + if ($execute) { + $this->logger->debug("Filename: {$filename}"); + } else { + $this->logger->output("Filename: {$filename}"); + } + $content = $this->buildFile($tableName); + $this->logger->debug("Content: {$content}"); + + if ($execute) { + $status = file_put_contents($filename, $content); + $this->logger->debug("Saved: " . var_export($status, true)); + try { + $this->registerMigration($tableName); + } catch (PDOException $exception) { + $this->logger->warning($exception); + } + } + } + $this->logger->output("Total tables migrated: " . count($this->tableRepository->getTables()), 250); + } + + protected Incoviba\Common\Define\Database $database; + protected Incoviba\Common\Define\Connection $connection; + protected string $databaseName; + protected string $migrationsPath; + protected DateTimeInterface $startDate; + protected TableRepository $tableRepository; + protected Logger $logger; + + protected function buildFilename(string $table): string + { + $i = $this->tableRepository->getTableIndex($table); + $time = $this->startDate->add(new DateInterval("PT{$i}S")); + return implode(DIRECTORY_SEPARATOR, [ + $this->migrationsPath, + "{$time->format('YmdHis')}_create_{$table}.php" + ]); + } + protected function buildClassName(string $table): string + { + return 'Create' . str_replace(' ', '', ucwords(str_replace('_', ' ', $table))); + } + protected function buildHeader(): string + { + return "buildClassName($table)} extends Phinx\Migration\AbstractMigration"; + } + protected function buildFunction(string $table): string + { + $output = ["{", "\tpublic function change(): void", "\t{"]; + $output []= $this->buildInitialSetup(); + $this->tableRepository->getTableDefinition($table); + $output []= $this->tableRepository->parseTableDefinition($table); + $output []= $this->buildFinalSetup(); + $output []= "\t}"; + return implode(PHP_EOL, $output); + } + protected function buildInitialSetup(): string + { + return implode(PHP_EOL, [ + "\t\t\$this->execute('SET unique_checks=0; SET foreign_key_checks=0;');", + "\t\t\$this->execute(\"ALTER DATABASE CHARACTER SET 'utf8mb4';\");", + "\t\t\$this->execute(\"ALTER DATABASE COLLATE='utf8mb4_general_ci';\");", + '' + ]); + } + protected function buildFinalSetup(): string + { + return "\t\t\$this->execute('SET unique_checks=1; SET foreign_key_checks=1;');"; + } + protected function buildFile(string $table): string + { + return implode(PHP_EOL, [ + $this->buildHeader(), + $this->buildClass($table), + $this->buildFunction($table), + '}', + '' + ]); + } + protected function registerMigration(string $table): void + { + $i = $this->tableRepository->getTableIndex($table); + $time = $this->startDate->add(new DateInterval("PT{$i}S")); + $this->logger->output("Registering migration: {$time->format('Y-m-d H:i:s')}"); + $migrationName = $this->buildClassName($table); + + $query = $this->connection->getQueryBuilder() + ->insert() + ->into('phinxlog') + ->columns(['version', 'migration_name', 'start_time', 'end_time', 'breakpoint']) + ->values(['?', '?', '?', '?', 0]); + + $this->connection->execute($query, [ + $time->format('YmdHis'), + $migrationName, + $time->format('Y-m-d H:i:s'), + $time->format('Y-m-d H:i:s') + ]); + } +} +class SeedGenerator +{ + public function __construct(protected Config $settings) + { + $this->databaseName = $settings->getEnv('DB_DATABASE'); + $this->seedsPath = $settings->getSeedsPath(); + $this->startDate = $settings->getStartDate(); + $this->database = new Incoviba\Common\Implement\Database\MySQL($settings->getEnv('DB_HOST'), $settings->getEnv('DB_DATABASE'), $settings->getEnv('DB_USER'), $settings->getEnv('DB_PASSWORD')); + $this->connection = new Incoviba\Common\Implement\Connection($this->database, new Incoviba\Common\Implement\Database\Query\Builder()); + $this->tableRepository = new TableRepository($this->connection, $this->databaseName); + $this->dataRepository = new DataRepository($this->connection); + $this->logger = $settings->getLogger(); + } + + public function run(bool $execute = true): void + { + $this->logger->output('Running generate seeds' . (($execute) ? '' : ' [dry-run]'), 250); + $tables = $this->tableRepository->getTables(); + foreach ($tables as $table) { + if (in_array($table, $this->settings->getSkip())) { + continue; + } + $this->logger->output("Table: {$table}"); + $filename = $this->buildFilename($table); + if ($execute) { + $this->logger->debug("Filename: {$filename}"); + } else { + $this->logger->output("Filename: {$filename}"); + } + $content = $this->buildFile($table); + $this->logger->debug("Content: {$content}"); + + if ($execute) { + $status = file_put_contents($filename, $content); + $this->logger->debug("Saved: " . var_export($status, true)); + } + } + $this->logger->output("Total tables seeded: " . count($this->tableRepository->getTables()), 250); + } + + protected Incoviba\Common\Define\Database $database; + protected Incoviba\Common\Define\Connection $connection; + protected string $databaseName; + protected string $seedsPath; + protected DateTimeInterface $startDate; + protected TableRepository $tableRepository; + protected DataRepository $dataRepository; + protected Logger $logger; + + protected function buildFilename(string $table): string + { + $i = $this->tableRepository->getTableIndex($table); + $time = $this->startDate->add(new DateInterval("PT{$i}S")); + return implode(DIRECTORY_SEPARATOR, [ + $this->seedsPath, + "{$time->format('YmdHis')}_{$table}_seeder.php" + ]); + } + protected function buildFile(string $table): string + { + return implode(PHP_EOL, [ + $this->buildHeader(), + $this->buildClass($table), + "{", + $this->buildFunction($table), + '}', + '' + ]); + } + protected function buildHeader(): string + { + return "buildClassName($table)} extends AbstractSeed"; + } + protected function buildClassName(string $table): string + { + return str_replace(' ', '', ucwords(str_replace('_', ' ', $table))) . 'Seeder'; + } + protected function buildFunction(string $table): string + { + return implode(PHP_EOL, [ + "\tpublic function run(): void", + "\t{", + $this->buildData($table), + "", + $this->buildInitialSetup(), + "\t\t\$this->table('{$table}')", + "\t\t\t->insert(\$data)", + "\t\t\t->saveData();", + $this->buildFinalSetup(), + "\t}" + ]); + } + protected function buildData(string $table): string + { + $output = ["\t\t\$data = ["]; + $dataGenerator = $this->dataRepository->getData($table); + foreach ($dataGenerator as $row) { + $output []= "\t\t\t["; + foreach ($row as $key => $value) { + if (is_bool($value)) { + $value = $value ? 1 : 0; + } + if (!ctype_digit("{$value}")) { + $value = "'{$value}'"; + } + $output []= "\t\t\t\t'{$key}' => {$value},"; + } + $output []= "\t\t\t],"; + } + $output []= "\t\t];"; + $this->logger->debug("Total data: {$this->dataRepository->size}"); + return implode(PHP_EOL, $output); + } + protected function buildInitialSetup(): string + { + return "\t\t\$this->execute('SET unique_checks=0; SET foreign_key_checks=0;');"; + } + protected function buildFinalSetup(): string + { + return "\t\t\$this->execute('SET unique_checks=1; SET foreign_key_checks=1;');"; + } +} + +class Logger +{ + public function __construct(protected bool $verbose = false, protected $quiet = false) {} + + protected array $loggers; + + public function registerLog(Psr\Log\LoggerInterface $logger): Logger + { + $this->loggers['log'] = $logger; + return $this; + } + public function registerOutput(Psr\Log\LoggerInterface $logger): Logger + { + $this->loggers['output'] = $logger; + return $this; + } + public function log(string $message, int $level = 200): void + { + if ($this->canLogLevel($level)) { + $this->loggers['log']->log($level, $message); + } + } + public function output(string $message, int $level = 200): void + { + if ($this->canLogLevel($level)) { + $this->loggers['output']->log($level, $message); + $this->log($message, $level); + } + } + public function __call(string $name, array $arguments) + { + if (method_exists($this->loggers['log'], $name)) { + $levelMap = [ + 'debug' => 100, + 'info' => 200, + 'notice' => 250, + 'warning' => 300, + 'error' => 400, + 'critical' => 500, + 'alert' => 550, + 'emergency' => 600 + ]; + $level = $levelMap[strtolower($name)]; + $this->log($arguments[0], $level); + } + } + + protected function canLogLevel(int $level): bool + { + $minQuiet = 300; + $maxVerbose = 250; + + if ($this->quiet) { + return $level >= $minQuiet; + } + if ($this->verbose) { + return true; + } + return $level >= $maxVerbose; + } +} +class Config +{ + public function __construct() + { + $this->opts = getopt('d:m:s:r:vq', ['date:', 'migrations:', 'seeds:', 'run:', 'verbose', 'quiet']); + } + + public function getStartDate(): DateTimeInterface + { + $dateString = '20141101080000'; + if (isset($this->opts['d']) or isset($this->opts['date'])) { + $dateString = $this->opts['d'] ?? $this->opts['date']; + } + return new DateTimeImmutable($dateString); + } + public function getMigrationsPath(): string + { + $migrationsPath = implode(DIRECTORY_SEPARATOR, [__DIR__, 'resources', 'database', 'migrations']); + if (isset($this->opts['m']) or isset($this->opts['migrations'])) { + $migrationsPath = $this->opts['m'] ?? $this->opts['migrations']; + } + return $migrationsPath; + } + public function getSeedsPath(): string + { + $seedsPath = implode(DIRECTORY_SEPARATOR, [__DIR__, 'resources', 'database', 'seeds']); + if (isset($this->opts['s']) or isset($this->opts['seeds'])) { + $seedsPath = $this->opts['s'] ?? $this->opts['seeds']; + } + return $seedsPath; + } + public function getRun(): int + { + $option = 0; + if (isset($this->opts['r']) or isset($this->opts['run'])) { + $option = $this->opts['r'] ?? $this->opts['run']; + } + if (ctype_digit("{$option}")) { + return (int) $option; + } + return match($option) { + 'm', 'migrations' => 1, + 's', 'seeds' => 2, + 'a', 'all' => 3, + default => 0 + }; + } + public function getEnv(string $name): mixed + { + return $_ENV[$name]; + } + public function getLogger(): Logger + { + return (new Logger($this->isVerbose(), $this->isQuiet())) + ->registerLog($this->getPsrLogger()) + ->registerOutput(new Monolog\Logger('output', [ + new Monolog\Handler\FilterHandler( + (new Monolog\Handler\StreamHandler(STDOUT)) + ->setFormatter(new Monolog\Formatter\LineFormatter(null, null, false, true)), + Monolog\Level::Info, + Monolog\Level::Notice + ) + ], [ + new Monolog\Processor\ProcessIdProcessor(), + new Monolog\Processor\MemoryUsageProcessor(), + new Monolog\Processor\MemoryPeakUsageProcessor(), + new Monolog\Processor\PsrLogMessageProcessor() + ])); + } + public function getPsrLogger(): Psr\Log\LoggerInterface + { + return new Monolog\Logger('migrations', + [ + (new Monolog\Handler\RotatingFileHandler('/logs/migrations.log')) + ->setFormatter(new Monolog\Formatter\LineFormatter(null, null, false, false, true)), + ], [ + new Monolog\Processor\ProcessIdProcessor(), + new Monolog\Processor\MemoryUsageProcessor(), + new Monolog\Processor\MemoryPeakUsageProcessor(), + new Monolog\Processor\PsrLogMessageProcessor() + ] + ); + } + public function setSkip(string|array $skip): Config + { + if (is_string($skip)) { + $skip = [$skip]; + } + $this->skip = [...$this->skip ?? [], ...$skip]; + return $this; + } + public function getSkip(): array + { + return $this->skip; + } + public function isVerbose(): bool + { + return isset($this->opts['v']) or isset($this->opts['verbose']); + } + public function isQuiet(): bool + { + return isset($this->opts['q']) or isset($this->opts['quiet']); + } + + protected array $opts; + protected array $skip; +} + +$config = new Config(); +$config->setSkip([ + 'monolog', + 'phinxlog', + 'personas', + 'datos_personas', + 'proveedores', + 'datos_proveedores' +]); + +Monolog\ErrorHandler::register($config->getPsrLogger()); +try { + $generator = new MigrationGenerator($config); + $seeder = new SeedGenerator($config); + + switch ($config->getRun()) { + case 0: + $generator->run(false); + $seeder->run(false); + break; + case 3: + $generator->run(); + $seeder->run(); + break; + case 1: + $generator->run(); + break; + case 2: + $seeder->run(); + break; + } +} catch (Exception $exception) { + $config->getLogger()->warning($exception); +} catch (Error $error) { + $config->getLogger()->error($error); +}