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