@@ -147,7 +147,7 @@ func TestApplierBuildDMLEventQuery(t *testing.T) {
147147 require .Len (t , res , 1 )
148148 require .NoError (t , res [0 ].err )
149149 require .Equal (t ,
150- `replace /* gh-ost ` + "`test`.`_test_gho`" + ` */
150+ `insert /* gh-ost ` + "`test`.`_test_gho`" + ` */ ignore
151151 into
152152 ` + "`test`.`_test_gho`" + `
153153 ` + "(`id`, `item_id`)" + `
@@ -542,7 +542,9 @@ func (suite *ApplierTestSuite) TestPanicOnWarningsInApplyIterationInsertQuerySuc
542542 err = applier .ReadMigrationRangeValues ()
543543 suite .Require ().NoError (err )
544544
545+ migrationContext .SetNextIterationRangeMinValues ()
545546 hasFurtherRange , err := applier .CalculateNextIterationRangeEndValues ()
547+
546548 suite .Require ().NoError (err )
547549 suite .Require ().True (hasFurtherRange )
548550
@@ -620,6 +622,7 @@ func (suite *ApplierTestSuite) TestPanicOnWarningsInApplyIterationInsertQueryFai
620622 err = applier .AlterGhost ()
621623 suite .Require ().NoError (err )
622624
625+ migrationContext .SetNextIterationRangeMinValues ()
623626 hasFurtherRange , err := applier .CalculateNextIterationRangeEndValues ()
624627 suite .Require ().NoError (err )
625628 suite .Require ().True (hasFurtherRange )
@@ -721,6 +724,262 @@ func (suite *ApplierTestSuite) TestWriteCheckpoint() {
721724 suite .Require ().Equal (chk .IsCutover , gotChk .IsCutover )
722725}
723726
727+ func (suite * ApplierTestSuite ) TestPanicOnWarningsWithDuplicateKeyOnNonMigrationIndex () {
728+ ctx := context .Background ()
729+
730+ var err error
731+
732+ // Create table with id and email columns, where id is the primary key
733+ _ , err = suite .db .ExecContext (ctx , fmt .Sprintf ("CREATE TABLE %s (id INT PRIMARY KEY, email VARCHAR(100));" , getTestTableName ()))
734+ suite .Require ().NoError (err )
735+
736+ // Create ghost table with same schema plus a new unique index on email
737+ _ , err = suite .db .ExecContext (ctx , fmt .Sprintf ("CREATE TABLE %s (id INT PRIMARY KEY, email VARCHAR(100), UNIQUE KEY email_unique (email));" , getTestGhostTableName ()))
738+ suite .Require ().NoError (err )
739+
740+ connectionConfig , err := getTestConnectionConfig (ctx , suite .mysqlContainer )
741+ suite .Require ().NoError (err )
742+
743+ migrationContext := newTestMigrationContext ()
744+ migrationContext .ApplierConnectionConfig = connectionConfig
745+ migrationContext .SetConnectionConfig ("innodb" )
746+
747+ migrationContext .PanicOnWarnings = true
748+
749+ migrationContext .OriginalTableColumns = sql .NewColumnList ([]string {"id" , "email" })
750+ migrationContext .SharedColumns = sql .NewColumnList ([]string {"id" , "email" })
751+ migrationContext .MappedSharedColumns = sql .NewColumnList ([]string {"id" , "email" })
752+ migrationContext .UniqueKey = & sql.UniqueKey {
753+ Name : "PRIMARY" ,
754+ NameInGhostTable : "PRIMARY" ,
755+ Columns : * sql .NewColumnList ([]string {"id" }),
756+ }
757+
758+ applier := NewApplier (migrationContext )
759+ suite .Require ().NoError (applier .prepareQueries ())
760+ defer applier .Teardown ()
761+
762+ err = applier .InitDBConnections ()
763+ suite .Require ().NoError (err )
764+
765+ // Insert initial rows into ghost table (simulating bulk copy phase)
766+ _ , err = suite .db .ExecContext (ctx , fmt .Sprintf ("INSERT INTO %s (id, email) VALUES (1, 'user1@example.com'), (2, 'user2@example.com'), (3, 'user3@example.com');" , getTestGhostTableName ()))
767+ suite .Require ().NoError (err )
768+
769+ // Simulate binlog event: try to insert a row with duplicate email
770+ // This should fail with a warning because the ghost table has a unique index on email
771+ dmlEvents := []* binlog.BinlogDMLEvent {
772+ {
773+ DatabaseName : testMysqlDatabase ,
774+ TableName : testMysqlTableName ,
775+ DML : binlog .InsertDML ,
776+ NewColumnValues : sql .ToColumnValues ([]interface {}{4 , "user2@example.com" }), // duplicate email
777+ },
778+ }
779+
780+ // This should return an error when PanicOnWarnings is enabled
781+ err = applier .ApplyDMLEventQueries (dmlEvents )
782+ suite .Require ().Error (err )
783+ suite .Require ().Contains (err .Error (), "Duplicate entry" )
784+
785+ // Verify that the ghost table still has only 3 rows (no data loss)
786+ rows , err := suite .db .Query ("SELECT * FROM " + getTestGhostTableName () + " ORDER BY id" )
787+ suite .Require ().NoError (err )
788+ defer rows .Close ()
789+
790+ var count int
791+ for rows .Next () {
792+ var id int
793+ var email string
794+ err = rows .Scan (& id , & email )
795+ suite .Require ().NoError (err )
796+ count += 1
797+ }
798+ suite .Require ().NoError (rows .Err ())
799+
800+ // All 3 original rows should still be present
801+ suite .Require ().Equal (3 , count )
802+ }
803+
804+ // TestUpdateModifyingUniqueKeyWithDuplicateOnOtherIndex tests the scenario where:
805+ // 1. An UPDATE modifies the unique key (converted to DELETE+INSERT)
806+ // 2. The INSERT would create a duplicate on a NON-migration unique index
807+ // 3. Without warning detection: DELETE succeeds, INSERT IGNORE skips = DATA LOSS
808+ // 4. With PanicOnWarnings: Warning detected, transaction rolled back, no data loss
809+ // This test verifies that PanicOnWarnings correctly prevents the data loss scenario.
810+ func (suite * ApplierTestSuite ) TestUpdateModifyingUniqueKeyWithDuplicateOnOtherIndex () {
811+ ctx := context .Background ()
812+
813+ var err error
814+
815+ // Create table with id (PRIMARY) and email (NO unique constraint yet)
816+ _ , err = suite .db .ExecContext (ctx , fmt .Sprintf ("CREATE TABLE %s (id INT PRIMARY KEY, email VARCHAR(100));" , getTestTableName ()))
817+ suite .Require ().NoError (err )
818+
819+ // Create ghost table with id (PRIMARY) AND email unique index (being added)
820+ _ , err = suite .db .ExecContext (ctx , fmt .Sprintf ("CREATE TABLE %s (id INT PRIMARY KEY, email VARCHAR(100), UNIQUE KEY email_unique (email));" , getTestGhostTableName ()))
821+ suite .Require ().NoError (err )
822+
823+ connectionConfig , err := getTestConnectionConfig (ctx , suite .mysqlContainer )
824+ suite .Require ().NoError (err )
825+
826+ migrationContext := newTestMigrationContext ()
827+ migrationContext .ApplierConnectionConfig = connectionConfig
828+ migrationContext .SetConnectionConfig ("innodb" )
829+
830+ migrationContext .PanicOnWarnings = true
831+
832+ migrationContext .OriginalTableColumns = sql .NewColumnList ([]string {"id" , "email" })
833+ migrationContext .SharedColumns = sql .NewColumnList ([]string {"id" , "email" })
834+ migrationContext .MappedSharedColumns = sql .NewColumnList ([]string {"id" , "email" })
835+ migrationContext .UniqueKey = & sql.UniqueKey {
836+ Name : "PRIMARY" ,
837+ NameInGhostTable : "PRIMARY" ,
838+ Columns : * sql .NewColumnList ([]string {"id" }),
839+ }
840+
841+ applier := NewApplier (migrationContext )
842+ suite .Require ().NoError (applier .prepareQueries ())
843+ defer applier .Teardown ()
844+
845+ err = applier .InitDBConnections ()
846+ suite .Require ().NoError (err )
847+
848+ // Setup: Insert initial rows into ghost table
849+ // Row 1: id=1, email='bob@example.com'
850+ // Row 2: id=2, email='charlie@example.com'
851+ _ , err = suite .db .ExecContext (ctx , fmt .Sprintf ("INSERT INTO %s (id, email) VALUES (1, 'bob@example.com'), (2, 'charlie@example.com');" , getTestGhostTableName ()))
852+ suite .Require ().NoError (err )
853+
854+ // Simulate binlog event: UPDATE that changes BOTH PRIMARY KEY and email
855+ // From: id=2, email='charlie@example.com'
856+ // To: id=3, email='bob@example.com' (duplicate email with id=1)
857+ // This will be converted to DELETE (id=2) + INSERT (id=3, 'bob@example.com')
858+ // With INSERT IGNORE, the INSERT will skip because email='bob@example.com' already exists in id=1
859+ // Result: id=2 deleted, id=3 never inserted = DATA LOSS
860+ dmlEvents := []* binlog.BinlogDMLEvent {
861+ {
862+ DatabaseName : testMysqlDatabase ,
863+ TableName : testMysqlTableName ,
864+ DML : binlog .UpdateDML ,
865+ NewColumnValues : sql .ToColumnValues ([]interface {}{3 , "bob@example.com" }), // new: id=3, email='bob@example.com'
866+ WhereColumnValues : sql .ToColumnValues ([]interface {}{2 , "charlie@example.com" }), // old: id=2, email='charlie@example.com'
867+ },
868+ }
869+
870+ // First verify this would be converted to DELETE+INSERT
871+ buildResults := applier .buildDMLEventQuery (dmlEvents [0 ])
872+ suite .Require ().Len (buildResults , 2 , "UPDATE modifying unique key should be converted to DELETE+INSERT" )
873+
874+ // Apply the event - this should FAIL because INSERT will have duplicate email warning
875+ err = applier .ApplyDMLEventQueries (dmlEvents )
876+ suite .Require ().Error (err , "Should fail when DELETE+INSERT causes duplicate on non-migration unique key" )
877+ suite .Require ().Contains (err .Error (), "Duplicate entry" , "Error should mention duplicate entry" )
878+
879+ // Verify that BOTH rows still exist (transaction rolled back)
880+ rows , err := suite .db .Query ("SELECT id, email FROM " + getTestGhostTableName () + " ORDER BY id" )
881+ suite .Require ().NoError (err )
882+ defer rows .Close ()
883+
884+ var count int
885+ var ids []int
886+ var emails []string
887+ for rows .Next () {
888+ var id int
889+ var email string
890+ err = rows .Scan (& id , & email )
891+ suite .Require ().NoError (err )
892+ ids = append (ids , id )
893+ emails = append (emails , email )
894+ count ++
895+ }
896+ suite .Require ().NoError (rows .Err ())
897+
898+ // Transaction should have rolled back, so original 2 rows should still be there
899+ suite .Require ().Equal (2 , count , "Should still have 2 rows after failed transaction" )
900+ suite .Require ().Equal ([]int {1 , 2 }, ids , "Should have original ids" )
901+ suite .Require ().Equal ([]string {"bob@example.com" , "charlie@example.com" }, emails )
902+ }
903+
904+ // TestNormalUpdateWithPanicOnWarnings tests that normal UPDATEs (not modifying unique key) work correctly
905+ func (suite * ApplierTestSuite ) TestNormalUpdateWithPanicOnWarnings () {
906+ ctx := context .Background ()
907+
908+ var err error
909+
910+ // Create table with id (PRIMARY) and email
911+ _ , err = suite .db .ExecContext (ctx , fmt .Sprintf ("CREATE TABLE %s (id INT PRIMARY KEY, email VARCHAR(100));" , getTestTableName ()))
912+ suite .Require ().NoError (err )
913+
914+ // Create ghost table with same schema plus unique index on email
915+ _ , err = suite .db .ExecContext (ctx , fmt .Sprintf ("CREATE TABLE %s (id INT PRIMARY KEY, email VARCHAR(100), UNIQUE KEY email_unique (email));" , getTestGhostTableName ()))
916+ suite .Require ().NoError (err )
917+
918+ connectionConfig , err := getTestConnectionConfig (ctx , suite .mysqlContainer )
919+ suite .Require ().NoError (err )
920+
921+ migrationContext := newTestMigrationContext ()
922+ migrationContext .ApplierConnectionConfig = connectionConfig
923+ migrationContext .SetConnectionConfig ("innodb" )
924+
925+ migrationContext .PanicOnWarnings = true
926+
927+ migrationContext .OriginalTableColumns = sql .NewColumnList ([]string {"id" , "email" })
928+ migrationContext .SharedColumns = sql .NewColumnList ([]string {"id" , "email" })
929+ migrationContext .MappedSharedColumns = sql .NewColumnList ([]string {"id" , "email" })
930+ migrationContext .UniqueKey = & sql.UniqueKey {
931+ Name : "PRIMARY" ,
932+ NameInGhostTable : "PRIMARY" ,
933+ Columns : * sql .NewColumnList ([]string {"id" }),
934+ }
935+
936+ applier := NewApplier (migrationContext )
937+ suite .Require ().NoError (applier .prepareQueries ())
938+ defer applier .Teardown ()
939+
940+ err = applier .InitDBConnections ()
941+ suite .Require ().NoError (err )
942+
943+ // Setup: Insert initial rows into ghost table
944+ _ , err = suite .db .ExecContext (ctx , fmt .Sprintf ("INSERT INTO %s (id, email) VALUES (1, 'alice@example.com'), (2, 'bob@example.com');" , getTestGhostTableName ()))
945+ suite .Require ().NoError (err )
946+
947+ // Simulate binlog event: Normal UPDATE that only changes email (not PRIMARY KEY)
948+ // This should use UPDATE query, not DELETE+INSERT
949+ dmlEvents := []* binlog.BinlogDMLEvent {
950+ {
951+ DatabaseName : testMysqlDatabase ,
952+ TableName : testMysqlTableName ,
953+ DML : binlog .UpdateDML ,
954+ NewColumnValues : sql .ToColumnValues ([]interface {}{2 , "robert@example.com" }), // update email only
955+ WhereColumnValues : sql .ToColumnValues ([]interface {}{2 , "bob@example.com" }),
956+ },
957+ }
958+
959+ // Verify this generates a single UPDATE query (not DELETE+INSERT)
960+ buildResults := applier .buildDMLEventQuery (dmlEvents [0 ])
961+ suite .Require ().Len (buildResults , 1 , "Normal UPDATE should generate single UPDATE query" )
962+
963+ // Apply the event - should succeed
964+ err = applier .ApplyDMLEventQueries (dmlEvents )
965+ suite .Require ().NoError (err )
966+
967+ // Verify the update was applied correctly
968+ rows , err := suite .db .Query ("SELECT id, email FROM " + getTestGhostTableName () + " WHERE id = 2" )
969+ suite .Require ().NoError (err )
970+ defer rows .Close ()
971+
972+ var id int
973+ var email string
974+ suite .Require ().True (rows .Next (), "Should find updated row" )
975+ err = rows .Scan (& id , & email )
976+ suite .Require ().NoError (err )
977+ suite .Require ().Equal (2 , id )
978+ suite .Require ().Equal ("robert@example.com" , email )
979+ suite .Require ().False (rows .Next (), "Should only have one row" )
980+ suite .Require ().NoError (rows .Err ())
981+ }
982+
724983func TestApplier (t * testing.T ) {
725984 suite .Run (t , new (ApplierTestSuite ))
726985}
0 commit comments