99 * Non-destructive audit table sync with Symfony Console output/progress.
1010 * - Derives form tables & PKs from TestsService (no hardcoding)
1111 * - Creates audit tables if missing: audit_<formTable>
12- * - Aligns engine/collation (MyISAM , utf8mb4_0900_ai_ci)
12+ * - Aligns engine/collation (InnoDB , utf8mb4_0900_ai_ci)
1313 * - Ensures audit cols: action, revision, dt_datetime
1414 * - ADD/MODIFY/DROP columns in audit to match form (keeps data)
1515 * - Rebuilds triggers with per-key MAX(revision)+1
16+ * - Detects crashed audit tables and recreates them
1617 *
1718 * Options:
1819 * --only=form_vl,form_eid Limit to specific form_* or audit_* tables
4748final class FixAuditTablesCommand extends Command
4849{
4950 private const string CHARSET = 'utf8mb4 ' ;
50- private const string COLLATE = 'utf8mb4_0900_ai_ci ' ;
51+ private const string COLLATE_MYSQL8 = 'utf8mb4_0900_ai_ci ' ;
52+ private const string COLLATE_LEGACY = 'utf8mb4_unicode_ci ' ;
53+ private const string ENGINE = 'InnoDB ' ;
5154 private const array RESERVED_AUDIT_COLS = ['action ' , 'revision ' , 'dt_datetime ' ];
5255
5356 /** @var DatabaseService */
5457 private $ db ;
5558 /** @var \mysqli */
5659 private $ mysqli ;
5760 private string $ dbName ;
61+ private string $ collation ;
5862
5963 #[\Override]
6064 protected function configure (): void
@@ -76,16 +80,19 @@ protected function initialize(InputInterface $input, OutputInterface $output): v
7680 }
7781 $ this ->mysqli = $ this ->db ->mysqli ();
7882 $ this ->dbName = (string ) (SYSTEM_CONFIG ['database ' ]['db ' ] ?? '' );
83+ $ this ->collation = $ this ->db ->isMySQL8OrHigher ()
84+ ? self ::COLLATE_MYSQL8
85+ : self ::COLLATE_LEGACY ;
7986 }
8087
8188 #[\Override]
8289 protected function execute (InputInterface $ input , OutputInterface $ output ): int
8390 {
8491 $ io = new SymfonyStyle ($ input , $ output );
85- $ dryRun = (bool ) $ input ->getOption ('dry-run ' );
86- $ dropExtras = !$ input ->getOption ('no-drop-extras ' );
92+ $ dryRun = (bool ) $ input ->getOption ('dry-run ' );
93+ $ dropExtras = !$ input ->getOption ('no-drop-extras ' );
8794 $ rebuildTriggersOnly = (bool ) $ input ->getOption ('rebuild-triggers-only ' );
88- $ skipTriggers = (bool ) $ input ->getOption ('skip-triggers ' );
95+ $ skipTriggers = (bool ) $ input ->getOption ('skip-triggers ' );
8996
9097 $ io ->title ('Audit table sync (non-destructive) ' );
9198
@@ -113,20 +120,38 @@ protected function execute(InputInterface $input, OutputInterface $output): int
113120 $ bar ->advance ();
114121
115122 $ sqlBatch = [];
123+ $ actions = [];
116124 try {
125+ if ($ this ->tableExists ($ audit ) && $ this ->isTableCrashed ($ audit )) {
126+ $ io ->warning ("$ audit is marked as crashed; dropping and recreating. " );
127+ $ actions [] = 'recreated (crashed) ' ;
128+ $ sqlBatch = $ this ->recreateAuditTable ($ form , $ audit , $ pk );
129+ if ($ dryRun ) {
130+ $ this ->printSqlBatch ($ io , $ form , $ audit , $ sqlBatch );
131+ } else {
132+ $ this ->executeSqlBatch ($ sqlBatch );
133+ }
134+ $ sqlBatch = [];
135+ }
136+
117137 if (!$ dryRun ) {
118138 $ this ->mysqli ->begin_transaction ();
119139 }
120140
121141 if (!$ rebuildTriggersOnly ) {
122- $ sqlBatch = array_merge ($ sqlBatch , $ this ->ensureAuditTableExists ($ form , $ audit , $ pk ));
123- $ sqlBatch = array_merge ($ sqlBatch , $ this ->alignEngineAndCollation ($ audit ));
124- $ sqlBatch = array_merge ($ sqlBatch , $ this ->ensureAuditColumnsAndPK ($ audit , $ pk ));
125- $ sqlBatch = array_merge ($ sqlBatch , $ this ->syncColumnsToMatchForm ($ form , $ audit , $ dropExtras , $ pk ));
142+ $ sqlBatch = [
143+ ...$ sqlBatch ,
144+ ...$ this ->ensureAuditTableExists ($ form , $ audit , $ pk ),
145+ ...$ this ->alignEngineAndCollation ($ audit ),
146+ ...$ this ->ensureAuditColumnsAndPK ($ audit , $ pk ),
147+ ...$ this ->syncColumnsToMatchForm ($ form , $ audit , $ dropExtras , $ pk )
148+ ];
149+ $ actions [] = 'schema synced ' ;
126150 }
127151
128152 if (!$ skipTriggers ) {
129- $ sqlBatch = array_merge ($ sqlBatch , $ this ->rebuildTriggers ($ form , $ audit , $ pk ));
153+ $ sqlBatch = [...$ sqlBatch , ...$ this ->rebuildTriggers ($ form , $ audit , $ pk )];
154+ $ actions [] = 'triggers rebuilt ' ;
130155 }
131156
132157 if ($ dryRun ) {
@@ -135,6 +160,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
135160 $ this ->executeSqlBatch ($ sqlBatch );
136161 $ this ->mysqli ->commit ();
137162 }
163+
164+ $ actionsText = $ actions === [] ? 'no changes ' : implode (', ' , $ actions );
165+ $ io ->writeln (" - $ audit: $ actionsText " );
138166 } catch (\Throwable $ e ) {
139167 if (!$ dryRun ) {
140168 $ this ->mysqli ->rollback ();
@@ -158,7 +186,7 @@ private function buildTableMapFromTestsService(): array
158186 $ tmp = [];
159187 foreach ($ types as $ meta ) {
160188 $ form = $ meta ['tableName ' ] ?? null ;
161- $ pk = $ meta ['primaryKey ' ] ?? null ;
189+ $ pk = $ meta ['primaryKey ' ] ?? null ;
162190 if (!$ form || !$ pk ) {
163191 continue ;
164192 }
@@ -180,10 +208,37 @@ private function listColumns(string $table): array
180208 WHERE TABLE_SCHEMA=? AND TABLE_NAME=?
181209 ORDER BY ORDINAL_POSITION " , [$ this ->dbName , $ table ]);
182210 $ out = [];
183- foreach ($ rows as $ r ) $ out [$ r ['COLUMN_NAME ' ]] = true ;
211+ foreach ($ rows as $ r )
212+ $ out [$ r ['COLUMN_NAME ' ]] = true ;
184213 return $ out ;
185214 }
186215
216+ private function tableExists (string $ table ): bool
217+ {
218+ return (bool ) $ this ->db ->rawQueryValue (
219+ "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=? " ,
220+ [$ this ->dbName , $ table ]
221+ );
222+ }
223+
224+ private function isTableCrashed (string $ table ): bool
225+ {
226+ $ res = $ this ->mysqli ->query ("CHECK TABLE ` {$ this ->dbName }`.` $ table` " );
227+ if (!$ res ) {
228+ return false ;
229+ }
230+ while ($ row = $ res ->fetch_assoc ()) {
231+ $ msgType = strtolower ((string ) ($ row ['Msg_type ' ] ?? '' ));
232+ $ msgText = strtolower ((string ) ($ row ['Msg_text ' ] ?? '' ));
233+ if ($ msgType === 'error ' || str_contains ($ msgText , 'crash ' ) || str_contains ($ msgText , 'corrupt ' )) {
234+ $ res ->free ();
235+ return true ;
236+ }
237+ }
238+ $ res ->free ();
239+ return false ;
240+ }
241+
187242 /** Extract a single column's full DDL (backticked) from SHOW CREATE output. */
188243 private function getColumnDDL (string $ table , string $ column ): ?string
189244 {
@@ -230,27 +285,40 @@ private function parseColumnDDLs(string $createSql): array
230285 }
231286
232287 /** @return string[] */
233- private function ensureAuditTableExists (string $ form , string $ audit, string $ pk ): array
288+ private function removeAutoIncrementFromAudit (string $ audit ): array
234289 {
235290 $ sql = [];
236- $ exists = $ this ->db ->rawQueryValue (
237- "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=? " ,
238- [$ this ->dbName , $ audit ]
239- );
240- if ($ exists ) {
241- return $ sql ;
291+ $ auditCols = $ this ->parseColumnDDLs ($ this ->showCreate ($ audit ));
292+ foreach ($ auditCols as $ col => $ ddl ) {
293+ if (stripos ($ ddl , 'AUTO_INCREMENT ' ) === false ) {
294+ continue ;
295+ }
296+ $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` MODIFY COLUMN " . $ this ->stripAutoIncrementFromDDL ($ ddl );
297+ }
298+ return $ sql ;
299+ }
300+
301+ /** @return string[] */
302+ private function createAuditTableSql (
303+ string $ form ,
304+ string $ audit ,
305+ string $ pk ,
306+ bool $ dropFirst ,
307+ bool $ useAuditDdl
308+ ): array
309+ {
310+ $ sql = [];
311+ if ($ dropFirst ) {
312+ $ sql [] = "DROP TABLE IF EXISTS ` {$ this ->dbName }`.` $ audit` " ;
242313 }
243314
244315 // Create and align
245316 $ sql [] = "CREATE TABLE ` {$ this ->dbName }`.` $ audit` LIKE ` {$ this ->dbName }`.` $ form` " ;
246- $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` ENGINE=MyISAM " ;
247- $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` CONVERT TO CHARACTER SET " . self ::CHARSET . " COLLATE " . self :: COLLATE ;
317+ $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` ENGINE= " . self :: ENGINE ;
318+ $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` CONVERT TO CHARACTER SET " . self ::CHARSET . " COLLATE " . $ this -> collation ;
248319
249- // Remove AUTO_INCREMENT from copied pk BEFORE touching PKs
250- $ auditPkDDL = $ this ->getColumnDDL ($ audit , $ pk ) ?? $ this ->getColumnDDL ($ form , $ pk );
251- if ($ auditPkDDL && stripos ($ auditPkDDL , 'AUTO_INCREMENT ' ) !== false ) {
252- $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` MODIFY COLUMN " . $ this ->stripAutoIncrementFromDDL ($ auditPkDDL );
253- }
320+ // Remove AUTO_INCREMENT from any copied columns BEFORE touching PKs
321+ $ sql = [...$ sql , ...$ this ->removeAutoIncrementFromAudit ($ audit )];
254322
255323 // Add audit columns
256324 $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` ADD COLUMN `action` VARCHAR(8) NOT NULL DEFAULT 'insert' FIRST " ;
@@ -267,14 +335,32 @@ private function ensureAuditTableExists(string $form, string $audit, string $pk)
267335 return $ sql ;
268336 }
269337
338+ /** @return string[] */
339+ private function ensureAuditTableExists (string $ form , string $ audit , string $ pk ): array
340+ {
341+ $ sql = [];
342+ if ($ this ->tableExists ($ audit )) {
343+ return $ sql ;
344+ }
345+
346+ $ sql = [...$ sql , ...$ this ->createAuditTableSql ($ form , $ audit , $ pk , false , false )];
347+
348+ return $ sql ;
349+ }
350+
351+ /** @return string[] */
352+ private function recreateAuditTable (string $ form , string $ audit , string $ pk ): array
353+ {
354+ return $ this ->createAuditTableSql ($ form , $ audit , $ pk , true , false );
355+ }
270356
271357
272358 /** @return string[] */
273359 private function alignEngineAndCollation (string $ audit ): array
274360 {
275361 return [
276- "ALTER TABLE ` {$ this ->dbName }`.` $ audit` ENGINE=MyISAM " ,
277- "ALTER TABLE ` {$ this ->dbName }`.` $ audit` CONVERT TO CHARACTER SET " . self ::CHARSET . " COLLATE " . self :: COLLATE ,
362+ "ALTER TABLE ` {$ this ->dbName }`.` $ audit` ENGINE= " . self :: ENGINE ,
363+ "ALTER TABLE ` {$ this ->dbName }`.` $ audit` CONVERT TO CHARACTER SET " . self ::CHARSET . " COLLATE " . $ this -> collation ,
278364 ];
279365 }
280366
@@ -316,11 +402,8 @@ private function ensureAuditColumnsAndPK(string $audit, string $pk): array
316402 $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` ADD COLUMN `dt_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `revision` " ;
317403 }
318404
319- // Strip AUTO_INCREMENT on pk if present (safe even when PK already composite)
320- $ auditPkDDL = $ this ->getColumnDDL ($ audit , $ pk );
321- if ($ auditPkDDL && stripos ($ auditPkDDL , 'AUTO_INCREMENT ' ) !== false ) {
322- $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` MODIFY COLUMN " . $ this ->stripAutoIncrementFromDDL ($ auditPkDDL );
323- }
405+ // Strip AUTO_INCREMENT on any column in audit (audit tables should never auto-increment)
406+ $ sql = [...$ sql , ...$ this ->removeAutoIncrementFromAudit ($ audit )];
324407
325408 // Only rebuild PK if it isn't exactly (<pk>, revision)
326409 $ currentPk = $ this ->getPrimaryKeyColumns ($ audit );
@@ -338,19 +421,21 @@ private function ensureAuditColumnsAndPK(string $audit, string $pk): array
338421 private function syncColumnsToMatchForm (string $ form , string $ audit , bool $ dropExtras , string $ pk ): array
339422 {
340423 $ sql = [];
341- $ formCreate = $ this ->showCreate ($ form );
424+ $ formCreate = $ this ->showCreate ($ form );
342425 $ auditCreate = $ this ->showCreate ($ audit );
343426
344- $ formCols = $ this ->parseColumnDDLs ($ formCreate );
427+ $ formCols = $ this ->parseColumnDDLs ($ formCreate );
345428 $ auditCols = $ this ->parseColumnDDLs ($ auditCreate );
346429
347- // ADD missing (strip AI if it's the pk )
430+ // ADD missing (strip AUTO_INCREMENT if present )
348431 foreach ($ formCols as $ col => $ ddl ) {
349432 if (in_array ($ col , self ::RESERVED_AUDIT_COLS , true )) {
350433 continue ;
351434 }
352435 if (!array_key_exists ($ col , $ auditCols )) {
353- $ addDDL = ($ col === $ pk ) ? $ this ->stripAutoIncrementFromDDL ($ ddl ) : $ ddl ;
436+ $ addDDL = (stripos ($ ddl , 'AUTO_INCREMENT ' ) !== false )
437+ ? $ this ->stripAutoIncrementFromDDL ($ ddl )
438+ : $ ddl ;
354439 $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` ADD COLUMN $ addDDL " ;
355440 }
356441 }
@@ -364,8 +449,12 @@ private function syncColumnsToMatchForm(string $form, string $audit, bool $dropE
364449 continue ;
365450 }
366451
367- $ lhs = ($ col === $ pk ) ? $ this ->stripAutoIncrementFromDDL ($ ddl ) : $ ddl ; // desired
368- $ rhs = ($ col === $ pk ) ? $ this ->stripAutoIncrementFromDDL ($ auditCols [$ col ]) : $ auditCols [$ col ]; // current
452+ $ lhs = (stripos ($ ddl , 'AUTO_INCREMENT ' ) !== false )
453+ ? $ this ->stripAutoIncrementFromDDL ($ ddl )
454+ : $ ddl ; // desired
455+ $ rhs = (stripos ($ auditCols [$ col ], 'AUTO_INCREMENT ' ) !== false )
456+ ? $ this ->stripAutoIncrementFromDDL ($ auditCols [$ col ])
457+ : $ auditCols [$ col ]; // current
369458
370459 if ($ lhs !== $ rhs ) {
371460 $ sql [] = "ALTER TABLE ` {$ this ->dbName }`.` $ audit` MODIFY COLUMN $ lhs " ;
@@ -442,7 +531,7 @@ private function printSqlBatch(SymfonyStyle $io, string $form, string $audit, ar
442531 if (str_starts_with ((string ) $ sql , 'DELIMITER ' )) {
443532 $ io ->writeln ('<comment>-- trigger block --</comment> ' );
444533 } else {
445- $ io ->writeln ($ sql . ' ; ' );
534+ $ io ->writeln (" $ sql; " );
446535 }
447536 }
448537 $ io ->newLine ();
0 commit comments