Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5edb004799 | |||
| 764d00c249 | |||
| 47b77763cb |
+25
-2
@@ -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", "", "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 {
|
||||
@@ -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,30 @@ 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")
|
||||
}
|
||||
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) {
|
||||
case "dbml":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -65,6 +65,12 @@ 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
|
||||
# (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
|
||||
@@ -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
|
||||
|
||||
- Model names are derived from table names (singularized, PascalCase)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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("")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user