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
245 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|