@@ -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