package pgsql import ( "strings" "testing" "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/writers" ) // diffTestDB builds a database with one table per spec: name -> column name -> type. func diffTestDB(schemaName string, tables map[string]map[string]string) *models.Database { db := models.InitDatabase("testdb") schema := models.InitSchema(schemaName) for tName, cols := range tables { table := models.InitTable(tName, schemaName) seq := 1 for cName, cType := range cols { col := models.InitColumn(cName, tName, schemaName) col.Type = cType col.Sequence = uint(seq) seq++ table.Columns[cName] = col } schema.Tables = append(schema.Tables, table) } db.Schemas = append(db.Schemas, schema) return db } func diffJoin(stmts []string) string { return strings.Join(stmts, "\n") } func TestDiffStatements(t *testing.T) { tests := []struct { name string model func() *models.Database current func() *models.Database wantContain []string wantAbsent []string wantEmpty bool }{ { name: "identical schemas produce no statements", model: func() *models.Database { return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer", "email": "text"}}) }, current: func() *models.Database { return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer", "email": "text"}}) }, wantEmpty: true, }, { name: "missing table is created", model: func() *models.Database { return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}, "posts": {"id": "integer"}}) }, current: func() *models.Database { return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}}) }, wantContain: []string{"CREATE TABLE IF NOT EXISTS public.posts"}, wantAbsent: []string{"public.users"}, }, { name: "missing column is added and existing columns are skipped", model: func() *models.Database { return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer", "email": "text"}}) }, current: func() *models.Database { return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}}) }, wantContain: []string{"ADD COLUMN IF NOT EXISTS email text"}, wantAbsent: []string{"COLUMN id"}, }, { name: "changed column type is altered", model: func() *models.Database { return diffTestDB("public", map[string]map[string]string{"users": {"id": "bigint"}}) }, current: func() *models.Database { return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}}) }, wantContain: []string{"ALTER COLUMN id TYPE bigint"}, }, { name: "missing non-public schema is created before its tables", model: func() *models.Database { return diffTestDB("app", map[string]map[string]string{"users": {"id": "integer"}}) }, current: func() *models.Database { return models.InitDatabase("testdb") }, wantContain: []string{ "CREATE SCHEMA IF NOT EXISTS app", "CREATE TABLE IF NOT EXISTS app.users", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := NewWriter(&writers.WriterOptions{}) stmts, err := w.diffStatements(tt.model(), tt.current()) if err != nil { t.Fatalf("diffStatements failed: %v", err) } out := diffJoin(stmts) if tt.wantEmpty && len(stmts) != 0 { t.Fatalf("expected no statements, got:\n%s", out) } for _, want := range tt.wantContain { if !strings.Contains(out, want) { t.Errorf("missing %q in:\n%s", want, out) } } for _, absent := range tt.wantAbsent { if strings.Contains(out, absent) { t.Errorf("unexpected %q in:\n%s", absent, out) } } }) } } func TestDiffStatements_SchemaCreatedBeforeTables(t *testing.T) { w := NewWriter(&writers.WriterOptions{}) model := diffTestDB("app", map[string]map[string]string{"users": {"id": "integer"}}) stmts, err := w.diffStatements(model, models.InitDatabase("testdb")) if err != nil { t.Fatalf("diffStatements failed: %v", err) } schemaIdx, tableIdx := -1, -1 for i, s := range stmts { if strings.Contains(s, "CREATE SCHEMA") { schemaIdx = i } if strings.Contains(s, "CREATE TABLE") { tableIdx = i } } if schemaIdx < 0 || tableIdx < 0 || schemaIdx > tableIdx { t.Fatalf("expected CREATE SCHEMA before CREATE TABLE, got:\n%s", diffJoin(stmts)) } } func TestDiffStatements_NewTableCreatesPrimaryKeySequence(t *testing.T) { model := models.InitDatabase("testdb") schema := models.InitSchema("public") table := models.InitTable("users", "public") id := models.InitColumn("id", "users", "public") id.Type = "integer" id.IsPrimaryKey = true id.Default = "nextval('public.users_id_seq'::regclass)" table.Columns["id"] = id schema.Tables = append(schema.Tables, table) model.Schemas = append(model.Schemas, schema) w := NewWriter(&writers.WriterOptions{}) stmts, err := w.diffStatements(model, models.InitDatabase("testdb")) if err != nil { t.Fatalf("diffStatements failed: %v", err) } out := diffJoin(stmts) if !strings.Contains(out, "CREATE SEQUENCE IF NOT EXISTS public.users_id_seq") { t.Fatalf("expected sequence creation, got:\n%s", out) } // Existing table: sequence must not be re-emitted. current := models.InitDatabase("testdb") cs := models.InitSchema("public") cs.Tables = append(cs.Tables, table) current.Schemas = append(current.Schemas, cs) stmts, err = w.diffStatements(model, current) if err != nil { t.Fatalf("diffStatements failed: %v", err) } if strings.Contains(diffJoin(stmts), "CREATE SEQUENCE") { t.Fatalf("sequence emitted for existing table:\n%s", diffJoin(stmts)) } } func TestDiffStatements_NewIndexAndChangedIndexRecreated(t *testing.T) { build := func(cols ...string) *models.Database { db := diffTestDB("public", map[string]map[string]string{"users": {"id": "integer", "email": "text", "name": "text"}}) table := db.Schemas[0].Tables[0] table.Indexes["idx_users_lookup"] = &models.Index{Name: "idx_users_lookup", Columns: cols} return db } w := NewWriter(&writers.WriterOptions{}) // Index missing in current. noIdx := diffTestDB("public", map[string]map[string]string{"users": {"id": "integer", "email": "text", "name": "text"}}) stmts, err := w.diffStatements(build("email"), noIdx) if err != nil { t.Fatalf("diffStatements failed: %v", err) } if !strings.Contains(diffJoin(stmts), "CREATE INDEX IF NOT EXISTS idx_users_lookup") { t.Fatalf("expected index creation, got:\n%s", diffJoin(stmts)) } // Index unchanged. stmts, err = w.diffStatements(build("email"), build("email")) if err != nil { t.Fatalf("diffStatements failed: %v", err) } if len(stmts) != 0 { t.Fatalf("expected no statements for identical index, got:\n%s", diffJoin(stmts)) } // Index definition changed: dropped and recreated. stmts, err = w.diffStatements(build("name"), build("email")) if err != nil { t.Fatalf("diffStatements failed: %v", err) } out := diffJoin(stmts) dropIdx := strings.Index(out, "DROP INDEX") createIdx := strings.Index(out, "CREATE INDEX") if dropIdx < 0 || createIdx < 0 || dropIdx > createIdx { t.Fatalf("expected DROP INDEX before CREATE INDEX, got:\n%s", out) } } func TestDiffStatements_FlattenSchemaRejected(t *testing.T) { w := NewWriter(&writers.WriterOptions{FlattenSchema: true}) if _, err := w.generateLiveDiffStatements(models.InitDatabase("x"), "postgres://unused"); err == nil { t.Fatal("expected error so the caller falls back to full DDL") } } func TestDiffStatements_LiveDatabaseRepresentationsAreNotDifferences(t *testing.T) { build := func(live bool) *models.Database { db := models.InitDatabase("testdb") schema := models.InitSchema("public") parent := models.InitTable("parent", "public") parentID := models.InitColumn("id_parent", "parent", "public") parentCurrentType := "bigserial" if live { parentCurrentType = "bigint" } parentID.Type = parentCurrentType parentID.IsPrimaryKey = true parentID.NotNull = true parent.Columns["id_parent"] = parentID if live { // A live database reports the PK as a named constraint and the unique // constraint's backing index as an index too. parent.Constraints["pk_public_parent"] = &models.Constraint{ Name: "pk_public_parent", Type: models.PrimaryKeyConstraint, Columns: []string{"id_parent"}, } } guid := models.InitColumn("guid", "parent", "public") guid.Type = "uuid" parent.Columns["guid"] = guid parent.Constraints["ukey_parent_guid"] = &models.Constraint{ Name: "ukey_parent_guid", Type: models.UniqueConstraint, Columns: []string{"guid"}, } if live { parent.Indexes["ukey_parent_guid"] = &models.Index{Name: "ukey_parent_guid", Unique: true, Columns: []string{"guid"}, Type: "btree"} } // numeric(10,0) vs numeric(10); quoted default vs unquoted default with cast. amount := models.InitColumn("amount", "parent", "public") amount.Type = "numeric(10,0)" tags := models.InitColumn("tags", "parent", "public") tags.Type = "jsonb" tags.Default = "`'[]'`" if live { amount.Type = "numeric" amount.Precision = 10 tags.Default = "[]" } parent.Columns["amount"] = amount parent.Columns["tags"] = tags parent.Indexes["idx_parent_amount"] = &models.Index{Name: "idx_parent_amount", Columns: []string{"amount"}} if live { parent.Indexes["idx_parent_amount"].Type = "btree" } child := models.InitTable("child", "public") cid := models.InitColumn("id_child", "child", "public") cid.Type = "integer" child.Columns["id_child"] = cid rid := models.InitColumn("rid_parent", "child", "public") rid.Type = "bigint" child.Columns["rid_parent"] = rid action := "restrict" if live { action = "RESTRICT" } child.Constraints["fk_child_rid_parent"] = &models.Constraint{ Name: "fk_child_rid_parent", Type: models.ForeignKeyConstraint, Columns: []string{"rid_parent"}, ReferencedTable: "parent", ReferencedSchema: "public", ReferencedColumns: []string{"id_parent"}, OnDelete: action, OnUpdate: action, } parent.Description = "Parent\ntable" if live { parent.Description = "Parent\ntable\n" } schema.Tables = append(schema.Tables, parent, child) db.Schemas = append(db.Schemas, schema) return db } w := NewWriter(&writers.WriterOptions{}) stmts, err := w.diffStatements(build(false), build(true)) if err != nil { t.Fatalf("diffStatements failed: %v", err) } if len(stmts) != 0 { t.Fatalf("expected live representations to match the model, got:\n%s", diffJoin(stmts)) } } func TestDiffStatements_ChangedPrimaryKeyIsRecreated(t *testing.T) { model := diffTestDB("public", map[string]map[string]string{"users": {"id": "integer", "tenant": "integer"}}) model.Schemas[0].Tables[0].Columns["tenant"].IsPrimaryKey = true current := diffTestDB("public", map[string]map[string]string{"users": {"id": "integer", "tenant": "integer"}}) current.Schemas[0].Tables[0].Columns["id"].IsPrimaryKey = true current.Schemas[0].Tables[0].Constraints["pk_public_users"] = &models.Constraint{ Name: "pk_public_users", Type: models.PrimaryKeyConstraint, Columns: []string{"id"}, } w := NewWriter(&writers.WriterOptions{}) stmts, err := w.diffStatements(model, current) if err != nil { t.Fatalf("diffStatements failed: %v", err) } if !strings.Contains(diffJoin(stmts), "DROP CONSTRAINT IF EXISTS pk_public_users") { t.Fatalf("expected old primary key to be dropped, got:\n%s", diffJoin(stmts)) } } func TestDiffStatements_ChangedCommentEmitted(t *testing.T) { model := diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}}) model.Schemas[0].Tables[0].Description = "new" current := diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}}) current.Schemas[0].Tables[0].Description = "old" w := NewWriter(&writers.WriterOptions{}) stmts, err := w.diffStatements(model, current) if err != nil { t.Fatalf("diffStatements failed: %v", err) } if !strings.Contains(diffJoin(stmts), "COMMENT ON TABLE public.users IS 'new'") { t.Fatalf("expected changed comment, got:\n%s", diffJoin(stmts)) } } func TestDiffStatements_TruncatedConstraintNameMatches(t *testing.T) { long := "fk_individual_actor_relationship_rid_typelookup_relationship_type" if len(long) <= maxIdentifierBytes { t.Fatal("test name must exceed the identifier limit") } build := func(name string) *models.Database { db := diffTestDB("public", map[string]map[string]string{ "parent": {"id": "integer"}, "child": {"rid": "integer"}, }) for _, tbl := range db.Schemas[0].Tables { if tbl.Name == "child" { tbl.Constraints[name] = &models.Constraint{ Name: name, Type: models.ForeignKeyConstraint, Columns: []string{"rid"}, ReferencedTable: "parent", ReferencedSchema: "public", ReferencedColumns: []string{"id"}, } } } return db } w := NewWriter(&writers.WriterOptions{}) stmts, err := w.diffStatements(build(long), build(pgIdentifier(long))) if err != nil { t.Fatalf("diffStatements failed: %v", err) } if len(stmts) != 0 { t.Fatalf("expected truncated live name to match, got:\n%s", diffJoin(stmts)) } } func TestColumnDefaultsEqual_LiveRepresentations(t *testing.T) { tests := []struct { name string model, cur interface{} want bool }{ {"cast wrapped in parentheses", "gen_random_uuid()::text", "(gen_random_uuid())::text", true}, {"jsonb re-spaced array", `'["openid","profile","email"]'`, `'["openid", "profile", "email"]'::jsonb`, true}, {"backticked function", "`now()`", "now()", true}, {"quoted literal with cast", "'[]'", "'[]'::jsonb", true}, {"jsonb array already unquoted by the reader", `'["openid","profile"]'`, `["openid", "profile"]`, true}, {"text with spaces and braces", "`'{actor} #{actor_id} - {chartofaccount}'`", "{actor} #{actor_id} - {chartofaccount}", true}, {"cast on plain function stays equal", "gen_random_uuid()::text", "gen_random_uuid()::text", true}, {"different jsonb content", `'["a"]'`, `'["b"]'::jsonb`, false}, {"different function", "gen_random_uuid()::text", "(now())::text", false}, {"different literal", "'active'", "'inactive'::text", false}, {"nil vs value", nil, "now()", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := columnDefaultsEqual(tt.model, tt.cur); got != tt.want { t.Errorf("columnDefaultsEqual(%v, %v) = %v, want %v", tt.model, tt.cur, got, tt.want) } }) } }