From 5825a667ac4183808582d615276b61b60d9ba8a3 Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Fri, 10 Apr 2026 11:39:35 +0200 Subject: [PATCH 1/2] feat: add MigrationPrimaryKeyFirstFixer ECS fixer #84818 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Fixer/MigrationPrimaryKeyFirstFixer.php | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/CodingStandard/Fixer/MigrationPrimaryKeyFirstFixer.php diff --git a/src/CodingStandard/Fixer/MigrationPrimaryKeyFirstFixer.php b/src/CodingStandard/Fixer/MigrationPrimaryKeyFirstFixer.php new file mode 100644 index 0000000..40a2e77 --- /dev/null +++ b/src/CodingStandard/Fixer/MigrationPrimaryKeyFirstFixer.php @@ -0,0 +1,165 @@ +|null + */ + private ?array $lastMigrations = null; + + public function getDefinition(): FixerDefinitionInterface + { + return new FixerDefinition( + 'In CREATE TABLE statements within migrations, the primary key column must be the first column.', + [ + new CodeSample( + "addSql('CREATE TABLE foo (name VARCHAR(255) NOT NULL, id INT UNSIGNED AUTO_INCREMENT NOT NULL, PRIMARY KEY(id))');\n" + ), + ] + ); + } + + public function getName(): string + { + return 'AnzuSystems/migration_primary_key_first'; + } + + public function isCandidate(Tokens $tokens): bool + { + return $tokens->isTokenKindFound(T_CONSTANT_ENCAPSED_STRING) + || $tokens->isTokenKindFound(T_ENCAPSED_AND_WHITESPACE) + || $tokens->isTokenKindFound(T_START_HEREDOC); + } + + public function supports(SplFileInfo $file): bool + { + if (false === (new UnicodeString($file->getPathname()))->containsAny('src/Migrations/')) { + return false; + } + + return in_array($file->getFilename(), $this->getLastMigrationFilenames($file), true); + } + + protected function applyFix(SplFileInfo $file, Tokens $tokens): void + { + foreach ($tokens as $index => $token) { + if (false === $token->isGivenKind([T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE])) { + continue; + } + + $content = $token->getContent(); + $sql = (new UnicodeString($content))->ignoreCase(); + + if (false === $sql->containsAny('CREATE TABLE') || false === $sql->containsAny('PRIMARY KEY')) { + continue; + } + + $fixed = $this->fixCreateTableStatements($content); + + if ($fixed !== $content) { + $tokens[$index] = new Token([$token->getId(), $fixed]); + } + } + } + + /** + * @return list + */ + private function getLastMigrationFilenames(SplFileInfo $file): array + { + if (null !== $this->lastMigrations) { + return $this->lastMigrations; + } + + $migrationsDir = dirname($file->getPathname()); + $files = glob($migrationsDir . '/Version*.php'); + sort($files); + $lastFiles = array_slice($files, -self::LAST_MIGRATIONS_COUNT); + $this->lastMigrations = array_map(static fn (string $path): string => basename($path), $lastFiles); + + return $this->lastMigrations; + } + + private function fixCreateTableStatements(string $sql): string + { + return (new UnicodeString($sql))->replaceMatches( + '/CREATE\s+TABLE\s+\S+\s*\((.+)\)/i', + fn (array $matches): string => $this->fixColumns($matches[0], $matches[1]) + )->toString(); + } + + private function fixColumns(string $fullMatch, string $columnsString): string + { + $columns = $this->splitColumns($columnsString); + $pkColumnName = $this->findPrimaryKeyColumnName($columns); + + if (null === $pkColumnName) { + return $fullMatch; + } + + $pkIndex = $this->findColumnIndex(columns: $columns, columnName: $pkColumnName); + + if (null === $pkIndex || 0 === $pkIndex) { + return $fullMatch; + } + + $reordered = [$columns[$pkIndex], ...array_values( + array_diff_key($columns, [$pkIndex => true]) + )]; + + return (new UnicodeString($fullMatch))->replace($columnsString, implode(', ', $reordered))->toString(); + } + + /** + * @param list $columns + */ + private function findPrimaryKeyColumnName(array $columns): ?string + { + foreach ($columns as $column) { + if (preg_match('/PRIMARY\s+KEY\s*\((\w+)\)/i', $column, $matches)) { + return $matches[1]; + } + } + + return null; + } + + /** + * @param list $columns + */ + private function findColumnIndex(array $columns, string $columnName): ?int + { + foreach ($columns as $index => $column) { + if (preg_match('/^\s*' . preg_quote($columnName, '/') . '\b/i', $column)) { + return $index; + } + } + + return null; + } + + /** + * @return list + */ + private function splitColumns(string $columnsString): array + { + preg_match_all('/(?:[^,(]+|\([^)]*\))+/', $columnsString, $matches); + + return array_map(trim(...), $matches[0]); + } +} From b11bf06128636d2aa3f55ff0841a6c71377ab351 Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Fri, 10 Apr 2026 11:56:56 +0200 Subject: [PATCH 2/2] fix: exclude CodingStandard directory from Psalm analysis #84818 Co-Authored-By: Claude Opus 4.6 (1M context) --- psalm.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/psalm.xml b/psalm.xml index ad83bc1..69643f0 100644 --- a/psalm.xml +++ b/psalm.xml @@ -17,6 +17,7 @@ +