package sqlite import ( "database/sql" "os" "path/filepath" "strings" "testing" "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/readers" rdbml "git.warky.dev/wdevs/relspecgo/pkg/readers/dbml" "git.warky.dev/wdevs/relspecgo/pkg/writers" ) func shopDB() *models.Database { s := models.InitSchema("public") users := models.InitTable("users", "public") id := models.InitColumn("id", "users", "public") id.Type, id.IsPrimaryKey, id.NotNull, id.AutoIncrement, id.Sequence = "integer", true, true, true, 1 email := models.InitColumn("email", "users", "public") email.Type, email.NotNull, email.Sequence = "text", true, 2 age := models.InitColumn("age", "users", "public") age.Type, age.Sequence, age.Default = "integer", 3, 18 users.Columns["id"], users.Columns["email"], users.Columns["age"] = id, email, age uq := models.InitConstraint("uq_users_email", models.UniqueConstraint) uq.Columns = []string{"email"} ck := models.InitConstraint("ck_age", models.CheckConstraint) ck.Expression = "age >= 0" users.Constraints["uq_users_email"], users.Constraints["ck_age"] = uq, ck ix := models.InitIndex("idx_users_age", "users", "public") ix.Columns = []string{"age"} uix := models.InitIndex("uidx_users_nick", "users", "public") uix.Columns, uix.Unique = []string{"age", "email"}, true pkIx := models.InitIndex("users_pkey", "users", "public") pkIx.Columns = []string{"id"} users.Indexes["idx_users_age"], users.Indexes["uidx_users_nick"], users.Indexes["users_pkey"] = ix, uix, pkIx orders := models.InitTable("orders", "public") oid := models.InitColumn("id", "orders", "public") oid.Type, oid.IsPrimaryKey, oid.NotNull = "integer", true, true uid := models.InitColumn("user_id", "orders", "public") uid.Type, uid.NotNull = "integer", true orders.Columns["id"], orders.Columns["user_id"] = oid, uid fk := models.InitConstraint("fk_orders_users", models.ForeignKeyConstraint) fk.Columns, fk.ReferencedTable, fk.ReferencedColumns = []string{"user_id"}, "users", []string{"id"} orders.Constraints["fk_orders_users"] = fk s.Tables = append(s.Tables, users, orders) db := models.InitDatabase("shop") db.Schemas = append(db.Schemas, s) return db } func scriptFor(t *testing.T, db *models.Database) string { t.Helper() out := filepath.Join(t.TempDir(), "out.sql") if err := NewWriter(&writers.WriterOptions{OutputPath: out}).WriteDatabase(db); err != nil { t.Fatal(err) } b, err := os.ReadFile(out) if err != nil { t.Fatal(err) } return string(b) } func TestWriteDatabase_Script(t *testing.T) { got := scriptFor(t, shopDB()) for _, want := range []string{ "-- SQLite Database Schema", "-- Database: shop", "PRAGMA foreign_keys", "CREATE TABLE", "users", "orders", "CREATE INDEX", "idx_users_age", "CREATE UNIQUE INDEX", } { if !strings.Contains(got, want) { t.Errorf("missing %q\n%s", want, got) } } if strings.Contains(got, "users_pkey") { t.Errorf("pkey index must be skipped:\n%s", got) } if strings.Contains(got, "-- Schema: public") { t.Errorf("default schema must not be announced:\n%s", got) } } func TestWriteDatabase_Deterministic(t *testing.T) { first := scriptFor(t, shopDB()) for i := 0; i < 15; i++ { if got := scriptFor(t, shopDB()); got != first { t.Fatalf("output differs on run %d", i) } } } func TestWriter_ReusableAfterFileOutput(t *testing.T) { out := filepath.Join(t.TempDir(), "o.sql") w := NewWriter(&writers.WriterOptions{OutputPath: out}) for i := 0; i < 2; i++ { if err := w.WriteDatabase(shopDB()); err != nil { t.Fatalf("write %d: %v", i, err) } } } func TestWriteSchemaAndTable_UseOutputPath(t *testing.T) { db := shopDB() dir := t.TempDir() sOut := filepath.Join(dir, "s.sql") if err := NewWriter(&writers.WriterOptions{OutputPath: sOut}).WriteSchema(db.Schemas[0]); err != nil { t.Fatal(err) } if b, _ := os.ReadFile(sOut); !strings.Contains(string(b), "CREATE TABLE") { t.Errorf("schema output:\n%s", b) } tOut := filepath.Join(dir, "t.sql") if err := NewWriter(&writers.WriterOptions{OutputPath: tOut}).WriteTable(db.Schemas[0].Tables[0]); err != nil { t.Fatal(err) } if b, _ := os.ReadFile(tOut); !strings.Contains(string(b), "CREATE TABLE") { t.Errorf("table output:\n%s", b) } } func TestWriteDatabase_BadOutputPath(t *testing.T) { bad := filepath.Join(t.TempDir(), "missing", "x.sql") if err := NewWriter(&writers.WriterOptions{OutputPath: bad}).WriteDatabase(shopDB()); err == nil || !strings.Contains(err.Error(), "failed to create output file") { t.Errorf("got %v", err) } } func TestExecuteAgainstSQLiteFile(t *testing.T) { path := filepath.Join(t.TempDir(), "shop.db") opts := &writers.WriterOptions{Metadata: map[string]any{"connection_string": path}} if err := NewWriter(opts).WriteDatabase(shopDB()); err != nil { t.Fatal(err) } if opts.Metadata["execution_failed"] != 0 || opts.Metadata["execution_success"].(int) == 0 { t.Errorf("metadata: %+v", opts.Metadata) } conn, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } defer conn.Close() for _, tbl := range []string{"users", "orders"} { var n string if err := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, tbl).Scan(&n); err != nil { t.Errorf("table %s not created: %v", tbl, err) } } var idx int if err := conn.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='index' AND name IN ('idx_users_age','uidx_users_nick','uq_users_email')`).Scan(&idx); err != nil || idx != 3 { t.Errorf("indexes created: %d (%v)", idx, err) } if _, err := conn.Exec(`INSERT INTO users(email) VALUES('a@x')`); err != nil { t.Errorf("insert: %v", err) } if _, err := conn.Exec(`INSERT INTO users(email) VALUES('a@x')`); err == nil { t.Error("unique constraint on email must be enforced") } } func TestExecute_StopsOnErrorUnlessIgnored(t *testing.T) { // Pre-create "users" so the first CREATE TABLE fails. prepare := func(t *testing.T) string { path := filepath.Join(t.TempDir(), "pre.db") conn, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } defer conn.Close() if _, err := conn.Exec(`CREATE TABLE users (x int)`); err != nil { t.Fatal(err) } return path } path := prepare(t) opts := &writers.WriterOptions{Metadata: map[string]any{"connection_string": path}} err := NewWriter(opts).WriteDatabase(shopDB()) if err == nil || !strings.Contains(err.Error(), "failed to execute") { t.Fatalf("expected failure, got %v", err) } if opts.Metadata["execution_failed"] != 1 { t.Errorf("must stop at first failure: %+v", opts.Metadata) } path = prepare(t) opts = &writers.WriterOptions{Metadata: map[string]any{"connection_string": path, "ignore_errors": true}} err = NewWriter(opts).WriteDatabase(shopDB()) if err == nil { t.Fatal("errors are still reported when ignored") } if opts.Metadata["execution_success"].(int) == 0 || opts.Metadata["execution_failed"].(int) == 0 { t.Errorf("ignore_errors must continue past failures: %+v", opts.Metadata) } conn, _ := sql.Open("sqlite", path) defer conn.Close() var n string if err := conn.QueryRow(`SELECT name FROM sqlite_master WHERE name='orders'`).Scan(&n); err != nil { t.Errorf("orders must still be created: %v", err) } } func TestTruncateStatement(t *testing.T) { if got := truncateStatement("CREATE TABLE\n x"); got != "CREATE TABLE x" { t.Errorf("collapse: %q", got) } long := strings.Repeat("a", 200) if got := truncateStatement(long); len(got) != 83 || !strings.HasSuffix(got, "...") { t.Errorf("truncate: %q", got) } } func TestTableSchemaName(t *testing.T) { for in, want := range map[string]string{"public": "", "PUBLIC": "", "main": "", "auth": "auth", "": ""} { if got := tableSchemaName(in); got != want { t.Errorf("tableSchemaName(%q) = %q, want %q", in, got, want) } } } func TestCheckConstraintsWrittenAsComments(t *testing.T) { w := NewWriter(&writers.WriterOptions{}) var sb strings.Builder w.writer = &sb if err := w.writeCheckConstraints("", shopDB().Schemas[0].Tables[0]); err != nil { t.Fatal(err) } if got := sb.String(); !strings.Contains(got, "ck_age") || !strings.Contains(got, "age >= 0") { t.Errorf("check output: %q", got) } } func TestDBMLFixtureExecutes(t *testing.T) { db, err := rdbml.NewReader(&readers.ReaderOptions{FilePath: "../../../tests/assets/dbml/complex.dbml"}).ReadDatabase() if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "complex.db") opts := &writers.WriterOptions{Metadata: map[string]any{"connection_string": path, "ignore_errors": true}} _ = NewWriter(opts).WriteDatabase(db) if opts.Metadata["execution_success"].(int) == 0 { t.Errorf("nothing executed: %+v", opts.Metadata) } }