From 47b77763cbff8a55c27cdedfc224ef1a983c01d5 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 9 Jul 2026 05:50:33 +0200 Subject: [PATCH 1/2] fix(bun): support extra generated model fields --- cmd/relspec/convert.go | 22 ++++- cmd/relspec/split.go | 1 + pkg/writers/bun/README.md | 42 ++++++++ pkg/writers/bun/template_data.go | 86 +++++++++++++++++ pkg/writers/bun/templates.go | 7 ++ pkg/writers/bun/writer.go | 43 +++++++++ pkg/writers/bun/writer_test.go | 160 +++++++++++++++++++++++++++++++ 7 files changed, 359 insertions(+), 2 deletions(-) diff --git a/cmd/relspec/convert.go b/cmd/relspec/convert.go index 6977f48..b0e4e5a 100644 --- a/cmd/relspec/convert.go +++ b/cmd/relspec/convert.go @@ -1,6 +1,7 @@ package main import ( + stdjson "encoding/json" "fmt" "os" "strings" @@ -54,6 +55,7 @@ var ( convertFlattenSchema bool convertNullableTypes string convertContinueOnError bool + convertExtraFields string ) var convertCmd = &cobra.Command{ @@ -179,6 +181,7 @@ func init() { convertCmd.Flags().BoolVar(&convertFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table (useful for databases like SQLite that do not support schemas)") convertCmd.Flags().StringVar(&convertNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'") convertCmd.Flags().BoolVar(&convertContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL so psql continues past errors (pgsql output only)") + convertCmd.Flags().StringVar(&convertExtraFields, "extra-fields", "", "JSON array of extra Bun model fields to inject (bun output only); fields support target_table, name, type, bun_tag, json_tag, comment") err := convertCmd.MarkFlagRequired("from") if err != nil { @@ -245,7 +248,7 @@ func runConvert(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, " Schema: %s\n", convertSchemaFilter) } - if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema, convertNullableTypes, convertContinueOnError); err != nil { + if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema, convertNullableTypes, convertContinueOnError, convertExtraFields); err != nil { return fmt.Errorf("failed to write target: %w", err) } @@ -385,10 +388,25 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa return db, nil } -func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes string, continueOnError bool) error { +func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes string, continueOnError bool, extraFields string) error { var writer writers.Writer writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, continueOnError) + if extraFields != "" { + if strings.ToLower(dbType) != "bun" { + return fmt.Errorf("--extra-fields is only supported for Bun output") + } + var parsed []wbun.ExtraFieldConfig + if err := stdjson.Unmarshal([]byte(extraFields), &parsed); err != nil { + return fmt.Errorf("invalid --extra-fields JSON: %w", err) + } + if len(parsed) == 0 { + return fmt.Errorf("--extra-fields must contain at least one field") + } + writerOpts.Metadata = map[string]interface{}{ + "extra_fields": extraFields, + } + } switch strings.ToLower(dbType) { case "dbml": diff --git a/cmd/relspec/split.go b/cmd/relspec/split.go index 81fcc97..38af369 100644 --- a/cmd/relspec/split.go +++ b/cmd/relspec/split.go @@ -189,6 +189,7 @@ func runSplit(cmd *cobra.Command, args []string) error { false, // no flatten-schema for split splitNullableTypes, false, // no continue-on-error for split + "", // no extra fields for split ) if err != nil { return fmt.Errorf("failed to write output: %w", err) diff --git a/pkg/writers/bun/README.md b/pkg/writers/bun/README.md index 35e9cce..e427362 100644 --- a/pkg/writers/bun/README.md +++ b/pkg/writers/bun/README.md @@ -65,6 +65,11 @@ relspec convert --from pgsql --from-conn "postgres://localhost/mydb" \ # Multi-file output (one file per table) relspec convert --from json --from-path schema.json \ --to bun --to-path models/ --package models + +# Inject computed/scan-only fields that are not present in the database schema +relspec convert --from dbml --from-path schema.dbml \ + --to bun --to-path models.go --package models \ + --extra-fields '[{"target_table":"projects","name":"ThoughtCount","type":"sql_types.SqlInt64","bun_tag":"thought_count,scanonly","json_tag":"thought_count"}]' ``` ## Generated Code Examples @@ -182,6 +187,43 @@ options := &writers.WriterOptions{ } ``` +#### Extra fields + +Use `Metadata["extra_fields"]` (or the CLI `--extra-fields` flag) to inject +custom fields into generated Bun models without editing generated files. This is +intended for computed columns and scan-only query fields that Bun must scan into +but that are not real database table columns. + +Each entry supports: + +| Field | Required | Description | +|---|---:|---| +| `target_table` | No | Optional model scope. Accepts table name (`projects`), qualified table (`public.projects`), or model name (`ModelPublicProjects`). If omitted, the field is added to every generated model. | +| `name` | Yes | Go struct field name. | +| `type` | Yes | Go type to emit. | +| `bun_tag` | No | Raw Bun tag contents, e.g. `thought_count,scanonly`. | +| `json_tag` | No | Raw JSON tag contents. | +| `comment` | No | Optional line comment. | + +```go +options := &writers.WriterOptions{ + OutputPath: "models.go", + PackageName: "models", + Metadata: map[string]any{ + "extra_fields": `[ + { + "target_table": "projects", + "name": "ThoughtCount", + "type": "sql_types.SqlInt64", + "bun_tag": "thought_count,scanonly", + "json_tag": "thought_count", + "comment": "Computed by ResolveSpec queries" + } + ]`, + }, +} +``` + ## Notes - Model names are derived from table names (singularized, PascalCase) diff --git a/pkg/writers/bun/template_data.go b/pkg/writers/bun/template_data.go index 7cb4335..767adc0 100644 --- a/pkg/writers/bun/template_data.go +++ b/pkg/writers/bun/template_data.go @@ -1,6 +1,8 @@ package bun import ( + "encoding/json" + "fmt" "sort" "strings" @@ -32,6 +34,90 @@ type ModelData struct { PrimaryKeyIDType string // Helper method GetID/SetID/UpdateID type IDColumnName string // Name of the ID column in database Prefix string // 3-letter prefix + + // ExtraFields are user-defined fields added via generator config (issue #4) + ExtraFields []*FieldData `json:"extra_fields,omitempty"` +} + +// ExtraFieldConfig represents a custom field to be added to generated models +type ExtraFieldConfig struct { + Name string `json:"name"` + Type string `json:"type"` + BunTag string `json:"bun_tag,omitempty"` + JSONTag string `json:"json_tag,omitempty"` + Comment string `json:"comment,omitempty"` + TargetTable string `json:"target_table,omitempty"` // optional: if set, field only applies to this table +} + +// LoadExtraFieldsFromMetadata loads custom field configurations from metadata map. +// Accepts either a JSON-encoded array of fields (for CLI use) or a structured list +// (for programmatic/sidecar file use). Each entry must have "name" and "type"; +// an optional "target_table" scopes the field to one table only. +func LoadExtraFieldsFromMetadata(metadata map[string]interface{}) []ExtraFieldConfig { + if metadata == nil { + return nil + } + + extraFieldsRaw, ok := metadata["extra_fields"] + if !ok || extraFieldsRaw == nil { + return nil + } + + // Try to parse as JSON string first (from CLI flag) + if strVal, ok := extraFieldsRaw.(string); ok { + var fields []ExtraFieldConfig + if err := json.Unmarshal([]byte(strVal), &fields); err == nil && len(fields) > 0 { + return filterValidFields(fields) + } + } + + // Try to parse as structured data (from sidecar file or programmatic API) + extraFieldsList, ok := extraFieldsRaw.([]interface{}) + if !ok || len(extraFieldsList) == 0 { + return nil + } + + fields := make([]ExtraFieldConfig, 0, len(extraFieldsList)) + for _, item := range extraFieldsList { + if fieldMap, ok := item.(map[string]interface{}); ok { + field := ExtraFieldConfig{ + Name: toString(fieldMap["name"]), + Type: toString(fieldMap["type"]), + BunTag: toString(fieldMap["bun_tag"]), + JSONTag: toString(fieldMap["json_tag"]), + Comment: toString(fieldMap["comment"]), + TargetTable: toString(fieldMap["target_table"]), + } + if field.Name != "" && field.Type != "" { + fields = append(fields, field) + } + } + } + + return filterValidFields(fields) +} + +// filterValidFields removes entries that are missing required fields. +func filterValidFields(fields []ExtraFieldConfig) []ExtraFieldConfig { + var valid []ExtraFieldConfig + for _, f := range fields { + if f.Name != "" && f.Type != "" { + valid = append(valid, f) + } + } + return valid +} + +func toString(v interface{}) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + default: + return fmt.Sprintf("%v", val) + } } // FieldData represents a single field in a struct diff --git a/pkg/writers/bun/templates.go b/pkg/writers/bun/templates.go index 5c0fa19..dbea7f6 100644 --- a/pkg/writers/bun/templates.go +++ b/pkg/writers/bun/templates.go @@ -23,6 +23,13 @@ type {{.Name}} struct { {{- range .Fields}} {{.Name}} {{.Type}} ` + "`bun:\"{{.BunTag}}\" json:\"{{.JSONTag}}\"`" + `{{if .Comment}} // {{.Comment}}{{end}} {{- end}} +{{- if .ExtraFields}} + + // --- Custom fields (managed by relspecgo generator config, issue #4) --- +{{- range .ExtraFields}} + {{.Name}} {{.Type}} ` + "`bun:\"{{.BunTag}}\" json:\"{{.JSONTag}}\"`" + `{{if .Comment}} // {{.Comment}}{{end}} +{{- end}} +{{- end}} } {{if .Config.GenerateTableName}} // TableName returns the table name for {{.Name}} diff --git a/pkg/writers/bun/writer.go b/pkg/writers/bun/writer.go index 001777d..964eb44 100644 --- a/pkg/writers/bun/writer.go +++ b/pkg/writers/bun/writer.go @@ -51,6 +51,43 @@ func (w *Writer) WriteDatabase(db *models.Database) error { return w.writeSingleFile(db) } +// addExtraFields appends user-defined extra fields to matching models. +// Fields may be scoped by target_table (table name, schema.table name, or model name). +// Unscoped fields apply to every generated model. +func (w *Writer) addExtraFields(templateData *TemplateData) { + extraFields := LoadExtraFieldsFromMetadata(w.options.Metadata) + if len(extraFields) == 0 { + return + } + + for _, model := range templateData.Models { + for _, ef := range extraFields { + if !extraFieldMatchesModel(ef, model) { + continue + } + + fieldData := &FieldData{ + Name: resolveFieldNameCollision(ef.Name), + Type: ef.Type, + BunTag: ef.BunTag, + JSONTag: ef.JSONTag, + Comment: ef.Comment, + } + model.ExtraFields = append(model.ExtraFields, fieldData) + } + } +} + +func extraFieldMatchesModel(field ExtraFieldConfig, model *ModelData) bool { + if field.TargetTable == "" { + return true + } + + return field.TargetTable == model.TableNameOnly || + field.TargetTable == model.TableName || + field.TargetTable == model.Name +} + // WriteSchema writes a schema as Bun models func (w *Writer) WriteSchema(schema *models.Schema) error { // Create a temporary database with just this schema @@ -107,6 +144,9 @@ func (w *Writer) writeSingleFile(db *models.Database) error { templateData.AddImport("\"fmt\"") } + // Apply extra fields from generator config (issue #4) + w.addExtraFields(templateData) + // Finalize imports templateData.FinalizeImports() @@ -200,6 +240,9 @@ func (w *Writer) writeMultiFile(db *models.Database) error { templateData.AddImport("\"fmt\"") } + // Apply extra fields from generator config (issue #4) + w.addExtraFields(templateData) + // Finalize imports templateData.FinalizeImports() diff --git a/pkg/writers/bun/writer_test.go b/pkg/writers/bun/writer_test.go index 33018ce..7ef67ae 100644 --- a/pkg/writers/bun/writer_test.go +++ b/pkg/writers/bun/writer_test.go @@ -855,6 +855,166 @@ func TestTypeMapper_BuildBunTag_StdlibArrayHasArrayTag(t *testing.T) { } } +func TestExtraFields_LoadFromMetadata(t *testing.T) { + tests := []struct { + name string + metadata map[string]interface{} + expected int + }{ + { + name: "nil metadata", + metadata: nil, + expected: 0, + }, + { + name: "no extra_fields key", + metadata: map[string]interface{}{ + "generate_table_name": true, + }, + expected: 0, + }, + { + name: "empty array", + metadata: map[string]interface{}{ + "extra_fields": []interface{}{}, + }, + expected: 0, + }, + { + name: "JSON string format", + metadata: map[string]interface{}{ + "extra_fields": `[{"name":"ThoughtCount","type":"int64","bun_tag":"thought_count,scanonly","json_tag":"thought_count"}]`, + }, + expected: 1, + }, + { + name: "structured format", + metadata: map[string]interface{}{ + "extra_fields": []interface{}{ + map[string]interface{}{ + "name": "ThoughtCount", + "type": "int64", + "bun_tag": "thought_count,scanonly", + "json_tag": "thought_count", + }, + }, + }, + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := LoadExtraFieldsFromMetadata(tt.metadata) + if len(fields) != tt.expected { + t.Errorf("expected %d fields, got %d", tt.expected, len(fields)) + } + }) + } +} + +func TestExtraFields_InSingleFile(t *testing.T) { + table := models.InitTable("projects", "public") + table.Columns["id"] = &models.Column{ + Name: "id", + Type: "bigint", + NotNull: true, + IsPrimaryKey: true, + } + table.Columns["name"] = &models.Column{ + Name: "name", + Type: "varchar", + Length: 255, + Sequence: 1, + } + + opts := &writers.WriterOptions{ + PackageName: "models", + OutputPath: t.TempDir() + "/test.go", + Metadata: map[string]interface{}{ + "extra_fields": `[{"name":"ThoughtCount","type":"int64","bun_tag":"thought_count,scanonly","json_tag":"thought_count"}]`, + }, + } + + writer := NewWriter(opts) + err := writer.WriteTable(table) + if err != nil { + t.Fatalf("WriteTable failed: %v", err) + } + + content, _ := os.ReadFile(opts.OutputPath) + generated := string(content) + + // Verify extra field is present with separator comment + expectations := []string{ + "// --- Custom fields (managed by relspecgo generator config, issue #4) ---", + "ThoughtCount int64 `bun:\"thought_count,scanonly\" json:\"thought_count\"`", + } + + for _, expected := range expectations { + if !strings.Contains(generated, expected) { + t.Errorf("Generated code missing: %q\nGenerated:\n%s", expected, generated) + } + } +} + +func TestExtraFields_InMultiFile(t *testing.T) { + db := models.InitDatabase("testdb") + schema := models.InitSchema("public") + + users := models.InitTable("users", "public") + users.Columns["id"] = &models.Column{ + Name: "id", + Type: "bigint", + NotNull: true, + IsPrimaryKey: true, + } + schema.Tables = append(schema.Tables, users) + + posts := models.InitTable("posts", "public") + posts.Columns["id"] = &models.Column{ + Name: "id", + Type: "bigint", + NotNull: true, + IsPrimaryKey: true, + } + schema.Tables = append(schema.Tables, posts) + + db.Schemas = append(db.Schemas, schema) + + tmpDir := t.TempDir() + opts := &writers.WriterOptions{ + PackageName: "models", + OutputPath: tmpDir, + Metadata: map[string]interface{}{ + "multi_file": true, + "extra_fields": `[{"target_table":"users","name":"PostCount","type":"int64","bun_tag":"post_count,scanonly","json_tag":"post_count"}]`, + }, + } + + writer := NewWriter(opts) + err := writer.WriteDatabase(db) + if err != nil { + t.Fatalf("WriteDatabase failed: %v", err) + } + + // Check users file has the extra field (matched by table name "users") + usersContent, _ := os.ReadFile(tmpDir + "/sql_public_users.go") + usersStr := string(usersContent) + + if !strings.Contains(usersStr, "PostCount int64 `bun:\"post_count,scanonly\"") { + t.Errorf("Extra field not found in users file:\n%s", usersStr) + } + + // Posts file should NOT have the extra field (name mismatch) + postsContent, _ := os.ReadFile(tmpDir + "/sql_public_posts.go") + postsStr := string(postsContent) + + if strings.Contains(postsStr, "PostCount") { + t.Errorf("Extra field should not be in posts file:\n%s", postsStr) + } +} + func TestTypeMapper_BuildBunTag_PreservesExplicitTypeModifiers(t *testing.T) { mapper := NewTypeMapper("") From 764d00c24997bed87c6a7e221bcb99e1822ef1f3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 9 Jul 2026 06:02:01 +0200 Subject: [PATCH 2/2] fix(bun): read extra fields from file --- cmd/relspec/convert.go | 13 +++++++++---- pkg/writers/bun/README.md | 23 +++++++++++++++++++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/cmd/relspec/convert.go b/cmd/relspec/convert.go index b0e4e5a..08c798a 100644 --- a/cmd/relspec/convert.go +++ b/cmd/relspec/convert.go @@ -181,7 +181,7 @@ func init() { convertCmd.Flags().BoolVar(&convertFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table (useful for databases like SQLite that do not support schemas)") convertCmd.Flags().StringVar(&convertNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'") convertCmd.Flags().BoolVar(&convertContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL so psql continues past errors (pgsql output only)") - convertCmd.Flags().StringVar(&convertExtraFields, "extra-fields", "", "JSON array of extra Bun model fields to inject (bun output only); fields support target_table, name, type, bun_tag, json_tag, comment") + convertCmd.Flags().StringVar(&convertExtraFields, "extra-fields", "", "Path to JSON file containing extra Bun model fields to inject (bun output only); fields support target_table, name, type, bun_tag, json_tag, comment") err := convertCmd.MarkFlagRequired("from") if err != nil { @@ -396,15 +396,20 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF if strings.ToLower(dbType) != "bun" { return fmt.Errorf("--extra-fields is only supported for Bun output") } + extraFieldsJSON, err := os.ReadFile(extraFields) + if err != nil { + return fmt.Errorf("failed to read --extra-fields file %q: %w", extraFields, err) + } + var parsed []wbun.ExtraFieldConfig - if err := stdjson.Unmarshal([]byte(extraFields), &parsed); err != nil { - return fmt.Errorf("invalid --extra-fields JSON: %w", err) + if err := stdjson.Unmarshal(extraFieldsJSON, &parsed); err != nil { + return fmt.Errorf("invalid --extra-fields JSON in %q: %w", extraFields, err) } if len(parsed) == 0 { return fmt.Errorf("--extra-fields must contain at least one field") } writerOpts.Metadata = map[string]interface{}{ - "extra_fields": extraFields, + "extra_fields": string(extraFieldsJSON), } } diff --git a/pkg/writers/bun/README.md b/pkg/writers/bun/README.md index e427362..e580865 100644 --- a/pkg/writers/bun/README.md +++ b/pkg/writers/bun/README.md @@ -67,9 +67,10 @@ relspec convert --from json --from-path schema.json \ --to bun --to-path models/ --package models # Inject computed/scan-only fields that are not present in the database schema +# (extra-fields.json contains the JSON array shown below) relspec convert --from dbml --from-path schema.dbml \ --to bun --to-path models.go --package models \ - --extra-fields '[{"target_table":"projects","name":"ThoughtCount","type":"sql_types.SqlInt64","bun_tag":"thought_count,scanonly","json_tag":"thought_count"}]' + --extra-fields extra-fields.json ``` ## Generated Code Examples @@ -189,11 +190,14 @@ options := &writers.WriterOptions{ #### Extra fields -Use `Metadata["extra_fields"]` (or the CLI `--extra-fields` flag) to inject +Use `Metadata["extra_fields"]` (or the CLI `--extra-fields ` flag) to inject custom fields into generated Bun models without editing generated files. This is intended for computed columns and scan-only query fields that Bun must scan into but that are not real database table columns. +The CLI flag expects a path to a JSON file, not inline JSON. This avoids shell +quoting problems with struct tags and complex type names. + Each entry supports: | Field | Required | Description | @@ -224,6 +228,21 @@ options := &writers.WriterOptions{ } ``` +Example `extra-fields.json`: + +```json +[ + { + "target_table": "projects", + "name": "ThoughtCount", + "type": "sql_types.SqlInt64", + "bun_tag": "thought_count,scanonly", + "json_tag": "thought_count", + "comment": "Computed by ResolveSpec queries" + } +] +``` + ## Notes - Model names are derived from table names (singularized, PascalCase)