Skip to content

Commit 270db99

Browse files
committed
Improvements in fix-audit-tables.php script
1 parent f05cfc7 commit 270db99

File tree

6 files changed

+420
-205
lines changed

6 files changed

+420
-205
lines changed

bin/setup/fix-audit-tables.php

Lines changed: 129 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
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
@@ -47,14 +48,17 @@
4748
final 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();

composer.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,33 +30,33 @@
3030
"amitdugar/db-tools": "^2.0",
3131
"brick/phonenumber": "^0.6.0",
3232
"crunzphp/crunz": "^3.8",
33-
"ezyang/htmlpurifier": "^4.17",
33+
"ezyang/htmlpurifier": "^4.19",
3434
"gettext/gettext": "^5.7",
3535
"gregwar/captcha": "^1.3",
3636
"guzzlehttp/guzzle": "^7.10",
3737
"hackzilla/password-generator": "^1.7",
3838
"halaxa/json-machine": "^1.2",
3939
"league/csv": "^9.27",
40-
"maennchen/zipstream-php": "^3.1",
41-
"monolog/monolog": "^3.9",
42-
"nesbot/carbon": "^3.10",
40+
"maennchen/zipstream-php": "^3.2",
41+
"monolog/monolog": "^3.10",
42+
"nesbot/carbon": "^3.11",
4343
"nikic/iter": "^2.4",
44-
"openspout/openspout": "^4.28",
44+
"openspout/openspout": "^5.2",
4545
"php-di/php-di": "^7.1",
4646
"phpmailer/phpmailer": "^7.0",
4747
"phpmyadmin/sql-parser": "^5.11",
48-
"phpoffice/phpspreadsheet": "^5.2",
48+
"phpoffice/phpspreadsheet": "^5.4",
4949
"psr/log": "^3.0",
5050
"setasign/fpdi": "^2.6",
51-
"slim/psr7": "^1.7",
51+
"slim/psr7": "^1.8",
5252
"slim/slim": "^4.15",
5353
"sqids/sqids": "^0.4.1",
5454
"symfony/cache": "^7.3",
5555
"symfony/console": "^7.2",
56-
"symfony/filesystem": "^7.3",
57-
"symfony/process": "^7.3",
56+
"symfony/filesystem": "^7.4",
57+
"symfony/process": "^7.4",
5858
"symfony/string": "^6.4",
59-
"symfony/uid": "^7.3",
59+
"symfony/uid": "^8.0",
6060
"tecnickcom/tcpdf": "^6.10",
6161
"thingengineer/mysqli-database-class": "dev-master"
6262
},
@@ -168,4 +168,4 @@
168168
"lock-samples": "Lock sample records that have been completed",
169169
"cleanup": "Cleanup old temporary files and logs"
170170
}
171-
}
171+
}

0 commit comments

Comments
 (0)