Skip to content

Commit 430d873

Browse files
bcordisclaude
andcommitted
fix: preflight schema check using install SQL as version reference
Parse the currently installed install.mysql.utf8.sql to determine what columns each table should have at the installed version. Compare against the live DB and drop any extra columns that are leftovers from a failed partial upgrade. This restores the DB to a clean state so Joomla's migration SQL (ALTER TABLE ADD COLUMN) won't fail on duplicates. No hardcoded column lists — the install SQL file IS the schema reference for the installed version. Works for any version-to-version upgrade path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 31ff5fd commit 430d873

File tree

1 file changed

+106
-148
lines changed

1 file changed

+106
-148
lines changed

proclaim.script.php

Lines changed: 106 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -336,173 +336,131 @@ public function preflight($type, $parent): bool
336336
}
337337

338338
/**
339-
* Ensure database schema is ready for migration SQL files.
339+
* Ensure database schema matches the currently installed version.
340340
*
341-
* MySQL's ALTER TABLE ADD COLUMN is not idempotent — it fails with
342-
* "Duplicate column" if the column already exists. This happens when:
343-
* - A previous upgrade attempt failed partway through
344-
* - A database was imported/restored from a newer version
345-
* - Joomla retries the schema update
341+
* Reads the OLD install.mysql.utf8.sql (still on disk from the
342+
* installed version) and parses the expected columns per table.
343+
* Compares against the live database and drops any columns that
344+
* exist in the DB but NOT in the install SQL — these are leftovers
345+
* from a failed partial upgrade that would cause "Duplicate column"
346+
* errors when Joomla re-runs the migration SQL files.
346347
*
347-
* This method pre-checks every column, index, and table that our
348-
* migration SQL files create, and silently adds anything missing.
349-
* After this runs, the migration SQL files will succeed whether
350-
* the columns already exist or not.
348+
* This restores the DB to the clean state of the installed version
349+
* so Joomla's migration path can run without errors.
351350
*
352351
* @return void
353352
*
354353
* @since 10.2.0
355354
*/
356355
private function ensureSchemaReady(): void
357356
{
358-
$db = $this->dbo;
357+
// Read the CURRENTLY INSTALLED install SQL (old version, not the new package)
358+
$installSqlPath = JPATH_ADMINISTRATOR . '/components/com_proclaim/sql/install.mysql.utf8.sql';
359359

360-
// Columns to ensure exist: [table => [column => definition]]
361-
// Grouped by the migration file that originally added them.
362-
$requiredColumns = [
363-
// 10.1.0-20251225: created/modified audit columns
364-
'#__bsms_message_type' => [
365-
'created' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
366-
'created_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
367-
'created_by_alias' => 'VARCHAR(255) NOT NULL DEFAULT \'\'',
368-
'modified' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
369-
'modified_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
370-
],
371-
'#__bsms_podcast' => [
372-
'subtitle' => 'TEXT NULL DEFAULT NULL',
373-
'created' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
374-
'created_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
375-
'created_by_alias' => 'VARCHAR(255) NOT NULL DEFAULT \'\'',
376-
'modified' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
377-
'modified_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
378-
'checked_out' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
379-
'checked_out_time' => 'DATETIME DEFAULT NULL',
380-
'location_id' => 'INT(3) DEFAULT NULL',
381-
'platform_links' => 'TEXT NULL DEFAULT NULL',
382-
'itunes_category' => 'VARCHAR(100) NOT NULL DEFAULT \'Religion & Spirituality\'',
383-
'itunes_subcategory' => 'VARCHAR(100) NOT NULL DEFAULT \'Christianity\'',
384-
'itunes_explicit' => 'VARCHAR(5) NOT NULL DEFAULT \'false\'',
385-
'itunes_type' => 'VARCHAR(10) NOT NULL DEFAULT \'episodic\'',
386-
],
387-
'#__bsms_series' => [
388-
'created' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
389-
'created_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
390-
'created_by_alias' => 'VARCHAR(255) NOT NULL DEFAULT \'\'',
391-
'modified' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
392-
'modified_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
393-
'checked_out' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
394-
'checked_out_time' => 'DATETIME DEFAULT NULL',
395-
'publish_up' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
396-
'publish_down' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
397-
'location_id' => 'INT(3) DEFAULT NULL',
398-
'image' => 'TEXT DEFAULT NULL',
399-
],
400-
'#__bsms_servers' => [
401-
'created' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
402-
'created_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
403-
'created_by_alias' => 'VARCHAR(255) NOT NULL DEFAULT \'\'',
404-
'modified' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
405-
'modified_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
406-
'checked_out' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
407-
'checked_out_time' => 'DATETIME DEFAULT NULL',
408-
'location_id' => 'INT(3) DEFAULT NULL',
409-
'stats_synced_at' => 'DATETIME NULL DEFAULT NULL',
410-
],
411-
'#__bsms_studies' => [
412-
'created' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
413-
'created_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
414-
'created_by_alias' => 'VARCHAR(255) NOT NULL DEFAULT \'\'',
415-
'modified' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
416-
'modified_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
417-
'bible_version' => 'VARCHAR(20) DEFAULT NULL',
418-
'bible_version2' => 'VARCHAR(20) DEFAULT NULL',
419-
'image' => 'TEXT DEFAULT NULL',
420-
],
421-
'#__bsms_teachers' => [
422-
'created' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
423-
'created_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
424-
'created_by_alias' => 'VARCHAR(255) NOT NULL DEFAULT \'\'',
425-
'modified' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
426-
'modified_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
427-
'checked_out' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
428-
'checked_out_time' => 'DATETIME DEFAULT NULL',
429-
'org_name' => 'VARCHAR(255) DEFAULT NULL',
430-
],
431-
'#__bsms_templatecode' => [
432-
'created' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
433-
'created_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
434-
'created_by_alias' => 'VARCHAR(255) NOT NULL DEFAULT \'\'',
435-
'modified' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
436-
'modified_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
437-
'checked_out' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
438-
'checked_out_time' => 'DATETIME DEFAULT NULL',
439-
'location_id' => 'INT(3) DEFAULT NULL',
440-
],
441-
'#__bsms_templates' => [
442-
'created' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
443-
'created_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
444-
'created_by_alias' => 'VARCHAR(255) NOT NULL DEFAULT \'\'',
445-
'modified' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
446-
'modified_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
447-
'checked_out' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
448-
'checked_out_time' => 'DATETIME DEFAULT NULL',
449-
'location_id' => 'INT(3) DEFAULT NULL',
450-
],
451-
'#__bsms_topics' => [
452-
'created' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
453-
'created_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
454-
'created_by_alias' => 'VARCHAR(255) NOT NULL DEFAULT \'\'',
455-
'modified' => 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\'',
456-
'modified_by' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
457-
'checked_out' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
458-
'checked_out_time' => 'DATETIME DEFAULT NULL',
459-
],
460-
'#__bsms_comments' => [
461-
'checked_out' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
462-
'checked_out_time' => 'DATETIME DEFAULT NULL',
463-
],
464-
'#__bsms_message_type' => [
465-
'checked_out' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
466-
'checked_out_time' => 'DATETIME DEFAULT NULL',
467-
],
468-
'#__bsms_bible_translations' => [
469-
'estimated_size' => 'INT(10) UNSIGNED NOT NULL DEFAULT 0',
470-
'provider_id' => 'VARCHAR(100) DEFAULT NULL',
471-
'data_size' => 'BIGINT UNSIGNED NOT NULL DEFAULT 0',
472-
'downloaded_at' => 'DATETIME NULL DEFAULT NULL',
473-
],
474-
'#__bsms_mediafiles' => [
475-
'content_origin' => 'TINYINT(1) UNSIGNED NOT NULL DEFAULT 0',
476-
],
477-
'#__bsms_analytics_events' => [
478-
'series_id' => 'INT UNSIGNED NULL DEFAULT NULL',
479-
],
480-
'#__bsms_analytics_monthly' => [
481-
'series_id' => 'INT UNSIGNED NULL DEFAULT NULL',
482-
],
483-
];
360+
if (!file_exists($installSqlPath)) {
361+
return;
362+
}
363+
364+
$sql = @file_get_contents($installSqlPath);
365+
366+
if ($sql === false || $sql === '') {
367+
return;
368+
}
369+
370+
// Parse the install SQL to get expected columns per table
371+
$expectedSchema = $this->parseInstallSql($sql);
484372

485-
foreach ($requiredColumns as $table => $columns) {
373+
if (empty($expectedSchema)) {
374+
return;
375+
}
376+
377+
$db = $this->dbo;
378+
379+
foreach ($expectedSchema as $table => $expectedColumns) {
486380
if (!$this->tableExists($table)) {
487381
continue;
488382
}
489383

490-
$existingColumns = $this->getTableColumns($table);
384+
$liveColumns = $this->getTableColumns($table);
491385

492-
foreach ($columns as $column => $definition) {
493-
if (!\in_array($column, $existingColumns, true)) {
494-
try {
495-
$db->setQuery(
496-
'ALTER TABLE ' . $db->quoteName($table) . ' ADD COLUMN '
497-
. $db->quoteName($column) . ' ' . $definition
498-
);
499-
$db->execute();
500-
} catch (\Exception $e) {
501-
// Column may have been added by a concurrent request — ignore
502-
}
386+
// Find columns in the live DB that are NOT in the install SQL.
387+
// These are leftovers from a failed partial upgrade — drop them
388+
// so the migration SQL's ADD COLUMN won't hit "Duplicate column".
389+
$extraColumns = array_diff($liveColumns, $expectedColumns);
390+
391+
foreach ($extraColumns as $column) {
392+
try {
393+
$db->setQuery(
394+
'ALTER TABLE ' . $db->quoteName($table)
395+
. ' DROP COLUMN ' . $db->quoteName($column)
396+
);
397+
$db->execute();
398+
} catch (\Exception $e) {
399+
// Ignore — column may have been dropped by concurrent request
400+
}
401+
}
402+
}
403+
}
404+
405+
/**
406+
* Parse install.mysql.utf8.sql to extract expected columns per table.
407+
*
408+
* Reads CREATE TABLE statements and extracts column names (not keys,
409+
* constraints, or other non-column definitions).
410+
*
411+
* @param string $sql The full install SQL content
412+
*
413+
* @return array<string, array<string>> Map of table name => [column names]
414+
*
415+
* @since 10.2.0
416+
*/
417+
private function parseInstallSql(string $sql): array
418+
{
419+
$schema = [];
420+
421+
// Match CREATE TABLE blocks: table name and body inside parentheses
422+
if (!preg_match_all(
423+
'/CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`([^`]+)`\s*\((.*?)\)\s*ENGINE/si',
424+
$sql,
425+
$matches,
426+
PREG_SET_ORDER
427+
)) {
428+
return [];
429+
}
430+
431+
foreach ($matches as $match) {
432+
$table = $match[1];
433+
434+
// Ensure #__ prefix for consistency
435+
if (!str_starts_with($table, '#__')) {
436+
$table = '#__' . $table;
437+
}
438+
439+
$body = $match[2];
440+
441+
// Extract column names: lines starting with `column_name` followed by a type
442+
// Skip PRIMARY KEY, KEY, INDEX, UNIQUE, CONSTRAINT lines
443+
$columns = [];
444+
445+
foreach (explode("\n", $body) as $line) {
446+
$line = trim($line);
447+
448+
if ($line === '' || str_starts_with($line, '--')) {
449+
continue;
450+
}
451+
452+
// Column definitions start with `column_name` then a space and type keyword
453+
if (preg_match('/^`([^`]+)`\s+\w/', $line)) {
454+
$columns[] = preg_replace('/^`([^`]+)`.*/', '$1', $line);
503455
}
504456
}
457+
458+
if (!empty($columns)) {
459+
$schema[$table] = $columns;
460+
}
505461
}
462+
463+
return $schema;
506464
}
507465

508466
/**

0 commit comments

Comments
 (0)