Compare commits

...

3 Commits

Author SHA1 Message Date
warkanum 5edb004799 Merge pull request 'fix(bun): support extra generated model fields' (#5) from fix/bun-extra-fields-issue-4 into master
Reviewed-on: #5
Reviewed-by: Warky <warkanum@warky.dev>
2026-07-09 04:53:33 +00:00
Hermes Agent 764d00c249 fix(bun): read extra fields from file 2026-07-09 06:02:01 +02:00
Hermes Agent 47b77763cb fix(bun): support extra generated model fields 2026-07-09 05:50:33 +02:00
7 changed files with 383 additions and 2 deletions
+25 -2
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
stdjson "encoding/json"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -54,6 +55,7 @@ var (
convertFlattenSchema bool convertFlattenSchema bool
convertNullableTypes string convertNullableTypes string
convertContinueOnError bool convertContinueOnError bool
convertExtraFields string
) )
var convertCmd = &cobra.Command{ 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().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().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().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", "", "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") err := convertCmd.MarkFlagRequired("from")
if err != nil { if err != nil {
@@ -245,7 +248,7 @@ func runConvert(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, " Schema: %s\n", convertSchemaFilter) 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) return fmt.Errorf("failed to write target: %w", err)
} }
@@ -385,10 +388,30 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
return db, nil 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 var writer writers.Writer
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, continueOnError) 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")
}
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(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": string(extraFieldsJSON),
}
}
switch strings.ToLower(dbType) { switch strings.ToLower(dbType) {
case "dbml": case "dbml":
+1
View File
@@ -189,6 +189,7 @@ func runSplit(cmd *cobra.Command, args []string) error {
false, // no flatten-schema for split false, // no flatten-schema for split
splitNullableTypes, splitNullableTypes,
false, // no continue-on-error for split false, // no continue-on-error for split
"", // no extra fields for split
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to write output: %w", err) return fmt.Errorf("failed to write output: %w", err)
+61
View File
@@ -65,6 +65,12 @@ relspec convert --from pgsql --from-conn "postgres://localhost/mydb" \
# Multi-file output (one file per table) # Multi-file output (one file per table)
relspec convert --from json --from-path schema.json \ relspec convert --from json --from-path schema.json \
--to bun --to-path models/ --package models --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 extra-fields.json
``` ```
## Generated Code Examples ## Generated Code Examples
@@ -182,6 +188,61 @@ options := &writers.WriterOptions{
} }
``` ```
#### Extra fields
Use `Metadata["extra_fields"]` (or the CLI `--extra-fields <path>` 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 |
|---|---:|---|
| `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"
}
]`,
},
}
```
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 ## Notes
- Model names are derived from table names (singularized, PascalCase) - Model names are derived from table names (singularized, PascalCase)
+86
View File
@@ -1,6 +1,8 @@
package bun package bun
import ( import (
"encoding/json"
"fmt"
"sort" "sort"
"strings" "strings"
@@ -32,6 +34,90 @@ type ModelData struct {
PrimaryKeyIDType string // Helper method GetID/SetID/UpdateID type PrimaryKeyIDType string // Helper method GetID/SetID/UpdateID type
IDColumnName string // Name of the ID column in database IDColumnName string // Name of the ID column in database
Prefix string // 3-letter prefix 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 // FieldData represents a single field in a struct
+7
View File
@@ -23,6 +23,13 @@ type {{.Name}} struct {
{{- range .Fields}} {{- range .Fields}}
{{.Name}} {{.Type}} ` + "`bun:\"{{.BunTag}}\" json:\"{{.JSONTag}}\"`" + `{{if .Comment}} // {{.Comment}}{{end}} {{.Name}} {{.Type}} ` + "`bun:\"{{.BunTag}}\" json:\"{{.JSONTag}}\"`" + `{{if .Comment}} // {{.Comment}}{{end}}
{{- 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}} {{if .Config.GenerateTableName}}
// TableName returns the table name for {{.Name}} // TableName returns the table name for {{.Name}}
+43
View File
@@ -51,6 +51,43 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
return w.writeSingleFile(db) 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 // WriteSchema writes a schema as Bun models
func (w *Writer) WriteSchema(schema *models.Schema) error { func (w *Writer) WriteSchema(schema *models.Schema) error {
// Create a temporary database with just this schema // Create a temporary database with just this schema
@@ -107,6 +144,9 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
templateData.AddImport("\"fmt\"") templateData.AddImport("\"fmt\"")
} }
// Apply extra fields from generator config (issue #4)
w.addExtraFields(templateData)
// Finalize imports // Finalize imports
templateData.FinalizeImports() templateData.FinalizeImports()
@@ -200,6 +240,9 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
templateData.AddImport("\"fmt\"") templateData.AddImport("\"fmt\"")
} }
// Apply extra fields from generator config (issue #4)
w.addExtraFields(templateData)
// Finalize imports // Finalize imports
templateData.FinalizeImports() templateData.FinalizeImports()
+160
View File
@@ -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) { func TestTypeMapper_BuildBunTag_PreservesExplicitTypeModifiers(t *testing.T) {
mapper := NewTypeMapper("") mapper := NewTypeMapper("")