diff --git a/pkg/reflectutil/helpers.go b/pkg/reflectutil/helpers.go index 6043f36..a43caf4 100644 --- a/pkg/reflectutil/helpers.go +++ b/pkg/reflectutil/helpers.go @@ -1,7 +1,9 @@ package reflectutil import ( + "fmt" "reflect" + "sort" "strings" ) @@ -134,7 +136,7 @@ func MapKeys(i interface{}) []interface{} { return []interface{}{} } - keys := v.MapKeys() + keys := sortedMapKeys(v) result := make([]interface{}, len(keys)) for i, key := range keys { result[i] = key.Interface() @@ -155,14 +157,39 @@ func MapValues(i interface{}) []interface{} { return []interface{}{} } - result := make([]interface{}, 0, v.Len()) - iter := v.MapRange() - for iter.Next() { - result = append(result, iter.Value().Interface()) + keys := sortedMapKeys(v) + result := make([]interface{}, 0, len(keys)) + for _, key := range keys { + result = append(result, v.MapIndex(key).Interface()) } return result } +func sortedMapKeys(v reflect.Value) []reflect.Value { + keys := v.MapKeys() + sort.SliceStable(keys, func(i, j int) bool { + return mapKeyLess(keys[i], keys[j]) + }) + return keys +} + +func mapKeyLess(a, b reflect.Value) bool { + switch a.Kind() { + case reflect.String: + return a.String() < b.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return a.Int() < b.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return a.Uint() < b.Uint() + case reflect.Float32, reflect.Float64: + return a.Float() < b.Float() + case reflect.Bool: + return !a.Bool() && b.Bool() + default: + return fmt.Sprint(a.Interface()) < fmt.Sprint(b.Interface()) + } +} + // MapGet safely gets a value from a map by key // Returns nil if key doesn't exist or not a map func MapGet(m interface{}, key interface{}) interface{} { diff --git a/pkg/writers/template/writer_test.go b/pkg/writers/template/writer_test.go new file mode 100644 index 0000000..7e0cdcf --- /dev/null +++ b/pkg/writers/template/writer_test.go @@ -0,0 +1,87 @@ +package template + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.warky.dev/wdevs/relspecgo/pkg/models" + "git.warky.dev/wdevs/relspecgo/pkg/writers" +) + +func TestWriterTableIndexValuesDeterministic(t *testing.T) { + dir := t.TempDir() + templatePath := filepath.Join(dir, "indexes.tmpl") + outputDir := filepath.Join(dir, "out") + outputPath := filepath.Join(outputDir, "accounts.txt") + templateBody := "{{range values .Table.Indexes}}{{.Name}}:{{join .Columns \",\"}}\n{{end}}" + + if err := os.MkdirAll(outputDir, 0755); err != nil { + t.Fatalf("create output dir: %v", err) + } + if err := os.WriteFile(templatePath, []byte(templateBody), 0644); err != nil { + t.Fatalf("write template: %v", err) + } + + db := databaseWithMultipleIndexes() + var first []byte + const runs = 100 + for i := 0; i < runs; i++ { + writer, err := NewWriter(&writers.WriterOptions{ + OutputPath: outputDir, + Metadata: map[string]interface{}{ + "template_path": templatePath, + "mode": string(TableMode), + }, + }) + if err != nil { + t.Fatalf("new writer: %v", err) + } + + if err := writer.WriteDatabase(db); err != nil { + t.Fatalf("write database run %d: %v", i, err) + } + + got, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output run %d: %v", i, err) + } + + if i == 0 { + first = got + continue + } + if string(got) != string(first) { + t.Fatalf("run %d output differed from first run\nfirst:\n%s\nrun %d:\n%s", i, first, i, got) + } + } + + want := strings.Join([]string{ + "idx_accounts_email:email", + "idx_accounts_last_login:last_login", + "idx_accounts_name:name", + "idx_accounts_status:status", + "idx_accounts_tenant:tenant_id", + "", + }, "\n") + if string(first) != want { + t.Fatalf("unexpected index order\nwant:\n%s\ngot:\n%s", want, first) + } +} + +func databaseWithMultipleIndexes() *models.Database { + db := models.InitDatabase("test") + schema := models.InitSchema("public") + table := models.InitTable("accounts", "public") + + table.Indexes["idx_accounts_status"] = &models.Index{Name: "idx_accounts_status", Table: table.Name, Schema: schema.Name, Columns: []string{"status"}} + table.Indexes["idx_accounts_email"] = &models.Index{Name: "idx_accounts_email", Table: table.Name, Schema: schema.Name, Columns: []string{"email"}} + table.Indexes["idx_accounts_tenant"] = &models.Index{Name: "idx_accounts_tenant", Table: table.Name, Schema: schema.Name, Columns: []string{"tenant_id"}} + table.Indexes["idx_accounts_name"] = &models.Index{Name: "idx_accounts_name", Table: table.Name, Schema: schema.Name, Columns: []string{"name"}} + table.Indexes["idx_accounts_last_login"] = &models.Index{Name: "idx_accounts_last_login", Table: table.Name, Schema: schema.Name, Columns: []string{"last_login"}} + + schema.Tables = append(schema.Tables, table) + db.Schemas = append(db.Schemas, schema) + return db +}