This is an automated email from the ASF dual-hosted git repository.

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 7395ce49f fix(migrationhelper): keep the primary key while changing 
its column type (#9215)
7395ce49f is described below

commit 7395ce49fe8da689729135756f942a1d95e8517b
Author: tpam28 <[email protected]>
AuthorDate: Wed Oct 14 11:54:46 2026 +0500

    fix(migrationhelper): keep the primary key while changing its column type 
(#9215)
    
    ChangePrimaryKeyColumnsType dropped the primary key before copying data
    into the new column. MySQL Group Replication rejects DML on tables without
    a primary key, so the copy failed with error 3098 and left the table
    without one. Keep the key on the renamed column during the copy and move
    it to the new column with a single ALTER TABLE. Restore a missing key
    before copying data.
    
    Closes #9194
---
 backend/helpers/migrationhelper/migrationhelper.go |  87 +++++--------
 .../e2e/migrationhelper/change_primary_key_test.go | 141 +++++++++++++++++++++
 2 files changed, 175 insertions(+), 53 deletions(-)

diff --git a/backend/helpers/migrationhelper/migrationhelper.go 
b/backend/helpers/migrationhelper/migrationhelper.go
index 9e98c9f9a..acdc80070 100644
--- a/backend/helpers/migrationhelper/migrationhelper.go
+++ b/backend/helpers/migrationhelper/migrationhelper.go
@@ -52,6 +52,19 @@ func ChangeColumnsType[D any](
        tableName string,
        columns []string,
        update func(tmpColumnParams []interface{}) errors.Error,
+) (err errors.Error) {
+       return changeColumnsTypeWithHook[D](basicRes, script, tableName, 
columns, update, nil)
+}
+
+// changeColumnsTypeWithHook is ChangeColumnsType with an optional afterUpdate 
hook that
+// runs once the data is copied, while the old columns still exist
+func changeColumnsTypeWithHook[D any](
+       basicRes context.BasicRes,
+       script plugin.MigrationScript,
+       tableName string,
+       columns []string,
+       update func(tmpColumnParams []interface{}) errors.Error,
+       afterUpdate func() errors.Error,
 ) (err errors.Error) {
        db := basicRes.GetDal()
        tmpColumnsNames := make([]string, len(columns))
@@ -110,6 +123,13 @@ func ChangeColumnsType[D any](
                return err
        }
 
+       if afterUpdate != nil {
+               err = afterUpdate()
+               if err != nil {
+                       return err
+               }
+       }
+
        err = db.DropColumns(tableName, tmpColumnsNames...)
        if err != nil {
                return err
@@ -118,7 +138,7 @@ func ChangeColumnsType[D any](
        return nil
 }
 
-// ChangeColumnsType change the type of specified columns for the table
+// ChangePrimaryKeyColumnsType change the type of the primary key columns for 
the table
 func ChangePrimaryKeyColumnsType[D any](
        basicRes context.BasicRes,
        script plugin.MigrationScript,
@@ -135,68 +155,29 @@ func ChangePrimaryKeyColumnsType[D any](
        if errParse != nil {
                return errors.Convert(errParse)
        }
-       // Delete the primary key
+       dropPrimaryKey := fmt.Sprintf("DROP CONSTRAINT %s_pkey", tableName)
        if u.Scheme == "mysql" {
-               sql := fmt.Sprintf("ALTER TABLE %s DROP PRIMARY KEY", tableName)
-               if err := db.Exec(sql); err != nil {
-                       return err
-               }
-       } else {
-               sql := fmt.Sprintf("ALTER TABLE %s DROP CONSTRAINT %s_pkey", 
tableName, tableName)
-               if err := db.Exec(sql); err != nil {
-                       return err
-               }
-       }
-       // Change the type of the primary key
-       err = ChangeColumnsType[D](basicRes, script, tableName, 
TargetPriColumns, update)
-       if err != nil {
-               return err
+               dropPrimaryKey = "DROP PRIMARY KEY"
        }
+       addPrimaryKey := fmt.Sprintf("ADD PRIMARY KEY (%s)", 
strings.Join(TargetPriColumns, ","))
 
-       // Some GORM dialects recreate the primary key while AutoMigrate 
changes the
-       // column type, so adding it unconditionally would fail with a 
duplicate key.
+       // Earlier versions of this helper dropped the primary key first and 
could
+       // leave the table without one on failure, restore it before copying 
data
        primaryKeyColumns, err := dal.GetPrimarykeyColumns(db, 
dal.DefaultTabler{Name: tableName})
        if err != nil {
                return errors.Default.Wrap(err, fmt.Sprintf("failed to check 
primary key on table [%s]", tableName))
        }
-       if len(primaryKeyColumns) == len(TargetPriColumns) {
-               existingPrimaryKeyColumns := make(map[string]struct{}, 
len(primaryKeyColumns))
-               for _, column := range primaryKeyColumns {
-                       existingPrimaryKeyColumns[column.Name()] = struct{}{}
-               }
-               allColumnsPresent := true
-               for _, column := range TargetPriColumns {
-                       if _, ok := existingPrimaryKeyColumns[column]; !ok {
-                               allColumnsPresent = false
-                               break
-                       }
-               }
-               if allColumnsPresent {
-                       return nil
-               }
-       }
-
-       if len(primaryKeyColumns) > 0 {
-               if u.Scheme == "mysql" {
-                       sql := fmt.Sprintf("ALTER TABLE %s DROP PRIMARY KEY", 
tableName)
-                       if err := db.Exec(sql); err != nil {
-                               return err
-                       }
-               } else {
-                       sql := fmt.Sprintf("ALTER TABLE %s DROP CONSTRAINT 
%s_pkey", tableName, tableName)
-                       if err := db.Exec(sql); err != nil {
-                               return err
-                       }
+       if len(primaryKeyColumns) == 0 {
+               if err := db.Exec(fmt.Sprintf("ALTER TABLE %s %s", tableName, 
addPrimaryKey)); err != nil {
+                       return err
                }
        }
 
-       // Add the primary key
-       sql := fmt.Sprintf("ALTER TABLE %s ADD PRIMARY KEY (%s)", tableName, 
strings.Join(TargetPriColumns, ","))
-       if err := db.Exec(sql); err != nil {
-               return err
-       }
-
-       return nil
+       return changeColumnsTypeWithHook[D](basicRes, script, tableName, 
TargetPriColumns, update, func() errors.Error {
+               // Move the primary key from the renamed columns to the new 
ones in a
+               // single statement, so the table is never left without one
+               return db.Exec(fmt.Sprintf("ALTER TABLE %s %s, %s", tableName, 
dropPrimaryKey, addPrimaryKey))
+       })
 }
 
 // TransformColumns change the type of specified columns for the table and 
transform data one by one
diff --git a/backend/test/e2e/migrationhelper/change_primary_key_test.go 
b/backend/test/e2e/migrationhelper/change_primary_key_test.go
new file mode 100644
index 000000000..5601e4685
--- /dev/null
+++ b/backend/test/e2e/migrationhelper/change_primary_key_test.go
@@ -0,0 +1,141 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationhelper
+
+import (
+       "fmt"
+       "testing"
+
+       "github.com/apache/devlake/core/config"
+       "github.com/apache/devlake/core/context"
+       "github.com/apache/devlake/core/dal"
+       "github.com/apache/devlake/core/errors"
+       "github.com/apache/devlake/core/plugin"
+       "github.com/apache/devlake/core/runner"
+       "github.com/apache/devlake/helpers/e2ehelper"
+       "github.com/apache/devlake/helpers/migrationhelper"
+       "github.com/apache/devlake/impls/logruslog"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+const changePkTable = "change_pk_test"
+
+type changePkBefore struct {
+       Id   string `gorm:"primaryKey;type:varchar(255)"`
+       Name string `gorm:"type:varchar(255)"`
+}
+
+func (changePkBefore) TableName() string {
+       return changePkTable
+}
+
+type changePkAfter struct {
+       Id   string `gorm:"primaryKey;type:varchar(500)"`
+       Name string `gorm:"type:varchar(255)"`
+}
+
+func (changePkAfter) TableName() string {
+       return changePkTable
+}
+
+type changePkScript struct{}
+
+func (*changePkScript) Up(context.BasicRes) errors.Error { return nil }
+func (*changePkScript) Version() uint64                  { return 
20261006000000 }
+func (*changePkScript) Name() string                     { return "change 
primary key test" }
+
+var _ plugin.MigrationScript = (*changePkScript)(nil)
+
+// TestChangePrimaryKeyColumnsType checks that the table never loses its 
primary
+// key: MySQL Group Replication rejects DML on tables without one
+// (https://github.com/apache/devlake/issues/9194). On MySQL the check is done 
by
+// the database itself through sql_require_primary_key, which rejects every
+// ALTER TABLE that would leave the table without a primary key.
+//
+// Requires E2E_DB_URL (runs under `make e2e-test`).
+func TestChangePrimaryKeyColumnsType(t *testing.T) {
+       for _, dropPrimaryKeyFirst := range []bool{false, true} {
+               name := "with primary key"
+               if dropPrimaryKeyFirst {
+                       // the state left behind by earlier versions of the 
helper
+                       name = "without primary key"
+               }
+               t.Run(name, func(t *testing.T) {
+                       db := e2ehelper.NewIsolatedMigrationDb(t, "change_pk")
+                       basicRes := runner.CreateBasicRes(config.GetConfig(), 
logruslog.Global, db)
+                       dalInstance := basicRes.GetDal()
+
+                       require.NoError(t, 
dalInstance.AutoMigrate(&changePkBefore{}))
+                       require.NoError(t, 
dalInstance.CreateOrUpdate([]*changePkBefore{
+                               {Id: "a", Name: "first"},
+                               {Id: "b", Name: "second"},
+                       }))
+                       if dropPrimaryKeyFirst {
+                               dropPrimaryKey := fmt.Sprintf("ALTER TABLE %s 
DROP CONSTRAINT %s_pkey", changePkTable, changePkTable)
+                               if db.Dialector.Name() == "mysql" {
+                                       dropPrimaryKey = fmt.Sprintf("ALTER 
TABLE %s DROP PRIMARY KEY", changePkTable)
+                               }
+                               require.NoError(t, 
dalInstance.Exec(dropPrimaryKey))
+                       }
+                       if db.Dialector.Name() == "mysql" {
+                               // sql_require_primary_key is a session 
variable, keep a single
+                               // connection so it applies to every statement 
of the helper
+                               sqlDb, err := db.DB()
+                               require.NoError(t, err)
+                               sqlDb.SetMaxOpenConns(1)
+                               require.NoError(t, dalInstance.Exec("SET 
SESSION sql_require_primary_key = ON"))
+                       }
+
+                       err := 
migrationhelper.ChangePrimaryKeyColumnsType[changePkAfter](
+                               basicRes,
+                               &changePkScript{},
+                               changePkTable,
+                               []string{"id"},
+                               func(tmpColumnParams []interface{}) 
errors.Error {
+                                       primaryKeyColumns, err := 
dal.GetPrimarykeyColumns(dalInstance, dal.DefaultTabler{Name: changePkTable})
+                                       require.NoError(t, err)
+                                       assert.NotEmpty(t, primaryKeyColumns, 
"table must keep a primary key while data is copied")
+                                       return dalInstance.UpdateColumn(
+                                               &changePkAfter{},
+                                               "id",
+                                               dal.DalClause{Expr: " ? ", 
Params: tmpColumnParams},
+                                               dal.Where("? IS NOT NULL", 
tmpColumnParams...),
+                                       )
+                               },
+                       )
+                       require.NoError(t, err)
+
+                       primaryKeyColumns, err := 
dal.GetPrimarykeyColumns(dalInstance, dal.DefaultTabler{Name: changePkTable})
+                       require.NoError(t, err)
+                       require.Len(t, primaryKeyColumns, 1)
+                       assert.Equal(t, "id", primaryKeyColumns[0].Name())
+                       length, ok := primaryKeyColumns[0].Length()
+                       assert.True(t, ok)
+                       assert.Equal(t, int64(500), length)
+
+                       columnNames, err := dal.GetColumnNames(dalInstance, 
dal.DefaultTabler{Name: changePkTable}, func(dal.ColumnMeta) bool { return true 
})
+                       require.NoError(t, err)
+                       assert.ElementsMatch(t, []string{"id", "name"}, 
columnNames)
+
+                       var rows []changePkAfter
+                       require.NoError(t, dalInstance.All(&rows, 
dal.Orderby("id")))
+                       assert.Equal(t, []changePkAfter{{Id: "a", Name: 
"first"}, {Id: "b", Name: "second"}}, rows)
+               })
+       }
+}

Reply via email to