package mysql_test import ( "database/sql" "encoding/json" "fmt" "os" "reflect" "sort" "strings" "testing" "time" "gopkg.in/yaml.v3" driver "github.com/go-sql-driver/mysql" "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/readers" rmysql "git.warky.dev/wdevs/relspecgo/pkg/readers/mysql" "git.warky.dev/wdevs/relspecgo/pkg/writers" wmysql "git.warky.dev/wdevs/relspecgo/pkg/writers/mysql" ) func TestLiveSchemaRoundTrip(t *testing.T) { conn := os.Getenv("RELSPEC_TEST_MYSQL_CONN") if conn == "" { t.Skip("RELSPEC_TEST_MYSQL_CONN is not set") } db, err := sql.Open("mysql", conn) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) prefix := fmt.Sprintf("rt_%d", time.Now().UnixNano()) parent, child := prefix+"_zparent", prefix+"_achild" q := func(n string) string { return "`" + strings.ReplaceAll(n, "`", "``") + "`" } exec := func(stmt string) { t.Helper() if _, err := db.Exec(stmt); err != nil { t.Fatalf("%v\n%s", err, stmt) } } t.Cleanup(func() { for _, name := range []string{child + "_copy", child, parent + "_copy", parent} { if _, err := db.Exec("DROP TABLE IF EXISTS " + q(name)); err != nil { t.Errorf("cleanup %s: %v", name, err) } } }) exec("CREATE TABLE " + q(parent) + ` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code CHAR(3) NOT NULL, email VARCHAR(100) NOT NULL, label VARCHAR(64) DEFAULT 'don''t (change)' COMMENT 'column''s comment', amount DECIMAL(12,4) NOT NULL DEFAULT 3.2500, medium MEDIUMINT UNSIGNED, flags BIT(8) DEFAULT b'00001010', status ENUM('one','two','don''t, split') DEFAULT 'don''t, split', choices SET('a','b') DEFAULT 'a,b', stamp TIMESTAMP(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), duration TIME(4), bytes VARBINARY(16), binary_def VARBINARY(8) DEFAULT X'00275CFF', slash VARCHAR(64) DEFAULT 'C:\\temp\\file', uuid_value CHAR(36) DEFAULT (uuid()), hidden_col INT INVISIBLE, dt DATETIME(6) DEFAULT '2020-01-02 03:04:05.123456', payload MEDIUMBLOB, doc TEXT DEFAULT ('first; -- data, not a comment last'), generated_amount DECIMAL(13,4) GENERATED ALWAYS AS (amount * 2) STORED, virtual_label VARCHAR(80) GENERATED ALWAYS AS (concat('prefix:',lower(label))) VIRTUAL, PRIMARY KEY (id,code), UNIQUE KEY email_unique (email), UNIQUE KEY pair_unique (label,email), UNIQUE KEY unique_expression ((concat(lower(code),'-x')),email), KEY prefix_desc (label(10) DESC), KEY expression_key ((lower(email))), KEY invisible_key (medium) INVISIBLE COMMENT 'index''s comment', FULLTEXT KEY text_search (doc), CONSTRAINT ` + q(prefix+"_amount_check") + ` CHECK (amount >= 0), CONSTRAINT ` + q(prefix+"_optional_check") + ` CHECK (medium > 0 AND status <> 'forbidden;-- value') NOT ENFORCED ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='table''s comment'`) exec("CREATE TABLE " + q(child) + ` ( id INT PRIMARY KEY, parent_id BIGINT UNSIGNED, parent_code CHAR(3), parent_email VARCHAR(100), CONSTRAINT ` + q(prefix+"_composite_fk") + ` FOREIGN KEY (parent_id,parent_code) REFERENCES ` + q(parent) + ` (id,code) ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT ` + q(prefix+"_email_fk") + ` FOREIGN KEY (parent_email) REFERENCES ` + q(parent) + ` (email) ON DELETE CASCADE ON UPDATE RESTRICT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`) readerConfig, err := driver.ParseDSN(conn) if err != nil { t.Fatal(err) } if readerConfig.Params == nil { readerConfig.Params = map[string]string{} } readerConfig.Params["sql_quote_show_create"] = "0" // reader must ensure quoted native metadata read := func() *models.Database { t.Helper() model, err := rmysql.NewReader(&readers.ReaderOptions{ConnectionString: readerConfig.FormatDSN()}).ReadDatabase() if err != nil { t.Fatal(err) } return model } model := read() var selected []*models.Table for _, table := range model.Schemas[0].Tables { if table.Name == parent || table.Name == child { selected = append(selected, table) } } if len(selected) != 2 { t.Fatalf("read %d fixture tables", len(selected)) } model.Schemas[0].Tables = selected model.Metadata = nil model.Schemas[0].Metadata = nil // unrelated pre-existing test objects are outside this fixture for _, table := range selected { if table.Name == parent { if table.Columns["id"].Type != "uint64" { t.Fatal("unsigned type lost") } if table.Columns["amount"].Precision != 12 || table.Columns["amount"].Scale != 4 { t.Fatal("decimal dimensions lost") } if table.Indexes["expression_key"].KeyParts[0].Expression == "" { t.Fatal("functional index expression lost") } } else if len(table.Relationships) != 2 { t.Fatalf("foreign key relationships collapsed: %d", len(table.Relationships)) } } // Exercise the serialized model boundary as well as the native reader objects. encoded, err := json.Marshal(model) if err != nil { t.Fatal(err) } var target models.Database if err := json.Unmarshal(encoded, &target); err != nil { t.Fatal(err) } yamlModel, err := yaml.Marshal(&target) if err != nil { t.Fatal(err) } target = models.Database{} if err := yaml.Unmarshal(yamlModel, &target); err != nil { t.Fatal(err) } for _, table := range target.Schemas[0].Tables { table.Name += "_copy" for _, col := range table.Columns { col.Table = table.Name } for _, index := range table.Indexes { index.Table = table.Name } constraints := map[string]*models.Constraint{} for _, c := range table.Constraints { c.Table = table.Name if c.Type == models.ForeignKeyConstraint { c.ReferencedTable += "_copy" } if c.Type == models.ForeignKeyConstraint || c.Type == models.CheckConstraint { c.Name += "_copy" } constraints[c.Name] = c } table.Constraints = constraints for _, r := range table.Relationships { r.FromTable += "_copy" r.ToTable += "_copy" r.ForeignKey += "_copy" } } opts := &writers.WriterOptions{StrictConversion: true, Metadata: map[string]any{"connection_string": conn}} if err := wmysql.NewWriter(opts).WriteDatabase(&target); err != nil { t.Fatal(err) } show := func(table string) string { t.Helper() var name, ddl string if err := db.QueryRow("SHOW CREATE TABLE "+q(table)).Scan(&name, &ddl); err != nil { t.Fatal(err) } ddl = strings.ReplaceAll(ddl, "_copy", "") // Index creation order is not semantic. Keep column and constraint order intact. lines := strings.Split(ddl, "\n") var indexes []string for _, line := range lines { if strings.HasPrefix(strings.TrimSpace(line), "KEY ") { indexes = append(indexes, line) } } sort.Strings(indexes) n := 0 for i, line := range lines { if strings.HasPrefix(strings.TrimSpace(line), "KEY ") { lines[i] = indexes[n] n++ } } return strings.Join(lines, "\n") } for _, name := range []string{parent, child} { a, b := show(name), show(name+"_copy") if a != b { t.Errorf("schema mismatch\nsource:\n%s\ntarget:\n%s", a, b) } } again := read() for _, table := range again.Schemas[0].Tables { if table.Name == parent+"_copy" && !reflect.DeepEqual(table.Columns["amount"].Metadata, selected[1].Columns["amount"].Metadata) { t.Error("type metadata changed on second read") } } // Verify constraints and defaults as behavior, after comparing untouched schemas. exec("INSERT INTO " + q(parent+"_copy") + " (id,code,email) VALUES (18446744073709551614,'abc','one@example.com')") exec("INSERT INTO " + q(child+"_copy") + " (id,parent_id,parent_code,parent_email) VALUES (1,18446744073709551614,'abc','one@example.com')") if _, err := db.Exec("INSERT INTO " + q(child+"_copy") + " (id,parent_id,parent_code) VALUES (2,123,'abc')"); err == nil { t.Error("foreign key is not enforced") } if _, err := db.Exec("UPDATE " + q(parent+"_copy") + " SET amount=-1"); err == nil { t.Error("check is not enforced") } var label, doc, binaryHex, slash string var flags int if err := db.QueryRow("SELECT label,doc,flags+0,HEX(binary_def),slash FROM "+q(parent+"_copy")).Scan(&label, &doc, &flags, &binaryHex, &slash); err != nil { t.Fatal(err) } for _, table := range read().Schemas[0].Tables { if table.Name == parent+"_copy" && table.Metadata["mysql_auto_increment"] != "18446744073709551615" { t.Errorf("large auto-increment counter lost: %v", table.Metadata["mysql_auto_increment"]) } } if label != "don't (change)" || doc != "first;\n-- data, not a comment\nlast" || flags != 10 || binaryHex != "00275CFF" || slash != `C:\temp\file` { t.Errorf("defaults corrupted: %q %q %d %s %q", label, doc, flags, binaryHex, slash) } }