Skip to content

Commit 31ff5fd

Browse files
bcordisclaude
andcommitted
fix: add preflight schema check to prevent duplicate column errors on upgrade
When upgrading from 10.0.x to 10.2.x, MySQL ALTER TABLE ADD COLUMN statements fail if columns already exist (partial upgrade, DB restore, or retry after failed install). The new ensureSchemaReady() method runs in preflight() before Joomla's schema updater and pre-adds any missing columns, making the migration SQL files safe to re-run. Covers all columns added in 10.1.0 and 10.3.0 migrations across 14 tables: audit columns, checked_out, location_id, bible_version, image, platform stats, podcast iTunes fields, and org_name. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 53aa0af commit 31ff5fd

File tree

1 file changed

+191
-11
lines changed

1 file changed

+191
-11
lines changed

proclaim.script.php

Lines changed: 191 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -328,21 +328,201 @@ public function preflight($type, $parent): bool
328328
{
329329
$this->setDboFromAdapter($parent);
330330

331-
// Check if podcast table exists using a targeted query
332-
if ($this->tableExists('#__bsms_podcast')) {
333-
// Add field if missing Subtitle to series
334-
$query = "SHOW COLUMNS FROM " . $this->dbo->quoteName('#__bsms_podcast') . " LIKE " . $this->dbo->quote('subtitle');
335-
$this->dbo->setQuery($query);
336-
$db = $this->dbo->loadResult();
331+
if ($type === 'update') {
332+
$this->ensureSchemaReady();
333+
}
337334

338-
if (empty($db)) {
339-
$alter = "ALTER TABLE #__bsms_podcast ADD subtitle TEXT";
340-
$this->dbo->setQuery($alter);
341-
$this->dbo->execute();
335+
return parent::preflight($type, $parent);
336+
}
337+
338+
/**
339+
* Ensure database schema is ready for migration SQL files.
340+
*
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
346+
*
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.
351+
*
352+
* @return void
353+
*
354+
* @since 10.2.0
355+
*/
356+
private function ensureSchemaReady(): void
357+
{
358+
$db = $this->dbo;
359+
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+
];
484+
485+
foreach ($requiredColumns as $table => $columns) {
486+
if (!$this->tableExists($table)) {
487+
continue;
488+
}
489+
490+
$existingColumns = $this->getTableColumns($table);
491+
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+
}
503+
}
342504
}
343505
}
506+
}
344507

345-
return parent::preflight($type, $parent);
508+
/**
509+
* Get column names for a table.
510+
*
511+
* @param string $table Table name with #__ prefix
512+
*
513+
* @return array List of column names
514+
*
515+
* @since 10.2.0
516+
*/
517+
private function getTableColumns(string $table): array
518+
{
519+
try {
520+
$columns = $this->dbo->getTableColumns($table);
521+
522+
return array_keys($columns);
523+
} catch (\Exception $e) {
524+
return [];
525+
}
346526
}
347527

348528
/**

0 commit comments

Comments
 (0)