diff --git a/go.mod b/go.mod index 1348430..d39d48a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.24.0 toolchain go1.24.3 require ( + github.com/testcontainers/testcontainers-go v0.39.0 + golang.org/x/sync v0.16.0 gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 @@ -13,8 +15,28 @@ require ( ) require ( + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.3.3+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/uuid v1.6.0 // indirect @@ -24,18 +46,42 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/microsoft/go-mssqldb v1.9.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.8.0 // indirect golang.org/x/crypto v0.41.0 // indirect - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/sync v0.16.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.36.0 // indirect - gorm.io/cmd/gorm v0.1.1-0.20250825094947-30e7d4fa1f1f // indirect - gorm.io/datatypes v1.2.5 // indirect - gorm.io/hints v1.1.2 // indirect - gorm.io/plugin/dbresolver v1.6.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace gorm.io/gorm => ./gorm diff --git a/main_test.go b/main_test.go index 12ffdfe..2422155 100644 --- a/main_test.go +++ b/main_test.go @@ -1,35 +1,172 @@ package main import ( + "context" + "database/sql" + "errors" + "fmt" "testing" + "time" - "gorm.io/playground/models" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "golang.org/x/sync/errgroup" + gormpostgres "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" ) -// GORM_REPO: https://github.com/go-gorm/gorm.git -// GORM_BRANCH: master -// TEST_DRIVERS: sqlite, mysql, postgres, sqlserver +// Standalone reproduction of the behavior demonstrated in the suite test +// Test_UpdateColums_insert_deleted_associations: when using UpdateColumns on the +// parent, GORM re-inserts deleted child associations (deleted concurrently) due to +// association handling in that update path. -func TestGORM(t *testing.T) { - user := models.User{Name: "jinzhu"} +type Parent struct { + gorm.Model + Name string + Children []*Child `gorm:"foreignKey:ParentID"` +} +type Child struct { + gorm.Model + ParentID uint `gorm:"type:uuid;primaryKey"` // composite PK (ParentID, ID) +} + +func setupDB(t *testing.T) *gorm.DB { + t.Helper() + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: "postgres:16-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_PASSWORD": "test", + "POSTGRES_USER": "test", + "POSTGRES_DB": "testdb", + }, + WaitingFor: wait.ForAll( + wait.ForListeningPort("5432/tcp"), + wait.ForLog("database system is ready to accept connections"), + ).WithDeadline(5 * time.Second), + } + + pgC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) + if err != nil { + t.Fatalf("failed to start postgres container: %v", err) + } + // ensure cleanup + t.Cleanup(func() { _ = pgC.Terminate(context.Background()) }) - DB.Create(&user) + host, err := pgC.Host(ctx) + if err != nil { + t.Fatalf("container host: %v", err) + } + port, err := pgC.MappedPort(ctx, "5432/tcp") + if err != nil { + t.Fatalf("container mapped port: %v", err) + } + + dsn := fmt.Sprintf("host=%s port=%s user=test password=test dbname=testdb sslmode=disable TimeZone=UTC", host, port.Port()) + + // retry opening (container might not yet be fully accepting connections even after wait strategy) + var gormDB *gorm.DB - var result models.User - if err := DB.First(&result, user.ID).Error; err != nil { - t.Errorf("Failed, got error: %v", err) + gormDB, err = gorm.Open(postgresdriver(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Info)}) + if err == nil { + // ensure low-level ping succeeds + sqldb, derr := gormDB.DB() + if derr == nil { + pingErr := sqldb.PingContext(ctx) + err = errors.Join(err, pingErr) + } } + + if err := gormDB.AutoMigrate(&Parent{}, &Child{}); err != nil { + t.Fatalf("failed to automigrate: %v", err) + } + return gormDB } -// func TestGORMGen(t *testing.T) { -// user := models.User{Name: "jinzhu2"} -// ctx := context.Background() +// Helper to satisfy linter (explicit name to avoid import alias confusion if we later add other drivers) +func postgresdriver(dsn string) gorm.Dialector { return gormpostgres.Open(dsn) } + +func Test_UpdateColums_insert_deleted_associations(t *testing.T) { // keep original name (typo included) for clarity + DB = setupDB(t) -// gorm.G[models.User](DB).Create(ctx, &user) + parent := Parent{Name: "parent1"} + if err := DB.Create(&parent).Error; err != nil { + t.Fatalf("create parent: %v", err) + } + + child1 := Child{ParentID: parent.ID} + child2 := Child{ParentID: parent.ID} + if err := DB.Create(&child1).Error; err != nil { + t.Fatalf("create child1: %v", err) + } + if err := DB.Create(&child2).Error; err != nil { + t.Fatalf("create child2: %v", err) + } + + tx2Done := make(chan struct{}) + tx1loaded := make(chan struct{}) + start := make(chan struct{}) + var eg errgroup.Group + + // Transaction 1: load parent & children, then after deletion attempt UpdateColumns + eg.Go(func() error { + <-start + return DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Preload("Children").First(&parent, "id = ?", parent.ID).Error; err != nil { + return err + } + if len(parent.Children) != 2 { + return fmt.Errorf("expected 2 children, got %d", len(parent.Children)) + } + close(tx1loaded) + <-tx2Done // wait for delete + if err := tx.Model(&parent).UpdateColumns(map[string]interface{}{"name": "parent1-updated"}).Error; err != nil { + return err + } + return nil + }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + }) + + // Transaction 2: delete one child (unscoped) + eg.Go(func() error { + defer close(tx2Done) + <-start + <-tx1loaded + return DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Unscoped().Delete(&Child{}, "parent_id = ? AND id = ?", parent.ID, child1.ID).Error; err != nil { + return err + } + return nil + }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + }) -// if u, err := gorm.G[models.User](DB).Where(g.User.ID.Eq(user.ID)).First(ctx); err != nil { -// t.Errorf("Failed, got error: %v", err) -// } else if u.Name != user.Name { -// t.Errorf("Failed, got user name: %v", u.Name) -// } -// } + close(start) + if err := eg.Wait(); err != nil { + if testing.Verbose() { + t.Logf("errgroup wait returned error: %v", err) + } + } + + var children []Child + if err := DB.Find(&children, "parent_id = ?", parent.ID).Error; err != nil { + t.Fatalf("query children: %v", err) + } + if len(children) != 2 { + ids := make([]uint, 0, len(children)) + for _, c := range children { + ids = append(ids, c.ID) + } + // Keep concise fatal now + // Behavior changed or anomaly not reproduced + t.Fatalf("expected 2 children (resurrection) got %d; child IDs: %v", len(children), ids) + } + if err := DB.Preload("Children").First(&parent, "id = ?", parent.ID).Error; err != nil { + t.Fatalf("query parent: %v", err) + } + if parent.Name != "parent1-updated" { + t.Fatalf("expected parent name to be 'parent1-updated', got '%s'", parent.Name) + } +}