Files
relspecgo/pkg/writers/mysql/live_roundtrip_test.go
T
warkanum 1d93255575 fix(assets): improve error reporting for asset failures
fix(embed): update embedDirectives to return result directly

fix(executor): clean up import order and formatting

fix(syntax_test): change file permission syntax to octal

fix(mysql): update file permission syntax to octal

fix(pgsql): update file permission syntax to octal

fix(sqlex): simplify Walk function parameter handling

fix(transform): adjust identifier comparison logic

fix(template): clean up function formatting and error handling
2026-10-12 22:04:40 +02:00

245 lines
8.5 KiB
Go

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)
}
}