From 3d57c947cd10e05c9f7dc9ea8a987dc1e2fd95d5 Mon Sep 17 00:00:00 2001 From: SG Command Date: Tue, 8 Sep 2026 06:17:45 +0200 Subject: [PATCH] feat(job): support templ command --- cmd/relspec/job.go | 62 ++++++++++++++++++++++++++++++- cmd/relspec/job_test.go | 27 ++++++++++++++ docs/JOB_FILES.md | 11 +++++- pkg/jobs/jobs.go | 82 ++++++++++++++++++++++++++++++++++++----- 4 files changed, 170 insertions(+), 12 deletions(-) diff --git a/cmd/relspec/job.go b/cmd/relspec/job.go index 2e1af8d..0be6426 100644 --- a/cmd/relspec/job.go +++ b/cmd/relspec/job.go @@ -16,7 +16,9 @@ import ( "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/readers" "git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir" + "git.warky.dev/wdevs/relspecgo/pkg/writers" wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql" + wtemplate "git.warky.dev/wdevs/relspecgo/pkg/writers/template" ) var ( @@ -54,7 +56,7 @@ inputs, output and options: logfile: .relspec/log/build-schema.log Rules and guarantees: - - command is a closed allow-list (convert, merge, scripts-list). Arbitrary + - command is a closed allow-list (convert, merge, scripts-list, templ). Arbitrary shell strings are never executed. - Every path is relative to the directory holding the job file and may not escape it. Absolute and home-relative paths are rejected. @@ -215,6 +217,7 @@ type resolvedJob struct { outputConn string // resolved connection string (secret) outputConnEnv string logPath string + templatePath string secrets []string // resolved secret values to redact from logs } @@ -236,6 +239,17 @@ func preflightJob(j *jobs.Job) (*resolvedJob, error) { } rj.logPath = p } + if j.Template != "" { + p, err := jobs.SafeJoin(root, j.Template) + if err != nil { + return nil, fmt.Errorf("template: %w", err) + } + info, err := os.Stat(p) + if err != nil || info.IsDir() { + return nil, fmt.Errorf("template %q: not found or is a directory", j.Template) + } + rj.templatePath = p + } for i, in := range j.Inputs { ri := resolvedInput{format: strings.ToLower(in.Format)} @@ -348,6 +362,8 @@ func executeResolvedJob(rj *resolvedJob) (err error) { err = runMergeJob(rj, lg) case jobs.CommandScriptsList: err = runScriptsListJob(rj, lg) + case jobs.CommandTempl: + err = runTemplJob(rj, lg) default: err = fmt.Errorf("unsupported command %q", rj.job.Command) } @@ -359,6 +375,50 @@ func executeResolvedJob(rj *resolvedJob) (err error) { return err } +func runTemplJob(rj *resolvedJob, lg *jobLogger) error { + db, err := readJobInputs(rj, lg) + if err != nil { + return err + } + if schema := rj.job.Options.Schema; schema != "" { + found := false + for _, s := range db.Schemas { + if s.Name == schema { + db.Schemas = []*models.Schema{s} + found = true + break + } + } + if !found { + return fmt.Errorf("schema not found: %s", schema) + } + } + mode := rj.job.Mode + if mode == "" { + mode = "database" + } + pattern := rj.job.FilenamePattern + if pattern == "" { + pattern = "{{.Name}}.txt" + } + writer, err := wtemplate.NewWriter(&writers.WriterOptions{ + OutputPath: rj.outputPath, + Metadata: map[string]interface{}{ + "template_path": rj.templatePath, + "mode": mode, + "filename_pattern": pattern, + }, + }) + if err != nil { + return fmt.Errorf("create template writer: %w", err) + } + lg.logf("applying template: %s (mode %s)", rj.templatePath, mode) + if err := writer.WriteDatabase(db); err != nil { + return fmt.Errorf("execute template: %w", err) + } + return nil +} + func runConvertJob(rj *resolvedJob, lg *jobLogger) error { db, err := readJobInputs(rj, lg) if err != nil { diff --git a/cmd/relspec/job_test.go b/cmd/relspec/job_test.go index 2743847..a0ca9ae 100644 --- a/cmd/relspec/job_test.go +++ b/cmd/relspec/job_test.go @@ -375,3 +375,30 @@ jobs: t.Fatal("job list output not deterministic") } } + +func TestJobRun_TemplDatabaseMode(t *testing.T) { + dir := jobFixture(t, `version: 1 +jobs: + docs: + command: templ + inputs: + - path: schema/core.dbml + format: dbml + template: templates/schema.tmpl + output: + path: build/schema.txt + overwrite: true +`) + writeFile(t, filepath.Join(dir, "templates", "schema.tmpl"), "{{range .Database.Schemas}}{{range .Tables}}{{.Name}} {{end}}{{end}}") + set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) + if err := executeJobPlan(set, "docs", false, false, &bytes.Buffer{}); err != nil { + t.Fatalf("execute templ job: %v", err) + } + out, err := os.ReadFile(filepath.Join(dir, "build", "schema.txt")) + if err != nil { + t.Fatalf("read templ output: %v", err) + } + if !strings.Contains(string(out), "users") { + t.Fatalf("templ output missing users table: %s", out) + } +} diff --git a/docs/JOB_FILES.md b/docs/JOB_FILES.md index e8aca67..e3cc9f6 100644 --- a/docs/JOB_FILES.md +++ b/docs/JOB_FILES.md @@ -25,9 +25,10 @@ means adding a vetted adapter in the RelSpec source. | `convert` | read one or more input schemas, additively merge them, write one output | | `merge` | like `convert` but requires ≥2 inputs and exposes `skip_*` merge options | | `scripts-list` | deterministically list SQL scripts across one or more directories | +| `templ` | apply a custom Go text template to one or more input schemas | Deferred (documented, not implemented here): `scripts` execution against a live -database, `split`, `inspect`, `diff`, `templ`, job-to-job output wiring, +database, `split`, `inspect`, `diff`, job-to-job output wiring, log rotation/retention. Live SQL execution already exists as `relspec scripts execute`; wiring it into the job runner is a follow-up because it needs live database credentials and cannot be covered by offline tests. @@ -123,6 +124,9 @@ jobs: script_dirs: # scripts-list (≥1) - migrations/core - migrations/tenant + template: templates/schema.tmpl # templ (required) + mode: table # templ: database/schema/script/table + filename_pattern: "{{.Name}}.go" # templ multi-output modes output: # convert / merge (required) format: pgsql path: build/schema.sql # file output, OR: @@ -141,6 +145,11 @@ jobs: logfile: .relspec/log/.log # optional; appended to ``` +For `templ`, `inputs` use the same file or `pgsql`/`conn_env` source forms as +schema conversion. `output` is optional (empty means stdout); when present it +contains only `path` and `overwrite`, because templates do not select a schema +writer format. + ### Supported input formats `dbml`, `dctx`, `drawdb`, `graphql`, `json`, `yaml`, `gorm`, `bun`, `drizzle`, diff --git a/pkg/jobs/jobs.go b/pkg/jobs/jobs.go index 6e29dab..ea41aaf 100644 --- a/pkg/jobs/jobs.go +++ b/pkg/jobs/jobs.go @@ -33,10 +33,11 @@ const ( CommandConvert = "convert" // read one or more schema files, optionally merge, write one output CommandMerge = "merge" // additive merge of two or more schema files into one output CommandScriptsList = "scripts-list" // deterministically list SQL scripts across one or more directories + CommandTempl = "templ" // apply a custom Go text template to one or more schemas ) // SupportedCommands lists every accepted command, in help order. -var SupportedCommands = []string{CommandConvert, CommandMerge, CommandScriptsList} +var SupportedCommands = []string{CommandConvert, CommandMerge, CommandScriptsList, CommandTempl} // readerFormats are the file-based input formats a job may declare (path). var readerFormats = map[string]bool{ @@ -72,14 +73,17 @@ type Job struct { Name string `yaml:"-"` SourceFile string `yaml:"-"` - Command string `yaml:"command"` - Description string `yaml:"description"` - DependsOn []string `yaml:"depends_on"` - Inputs []Input `yaml:"inputs"` - ScriptDirs []string `yaml:"script_dirs"` - Output *Output `yaml:"output"` - Options Options `yaml:"options"` - Logfile string `yaml:"logfile"` + Command string `yaml:"command"` + Description string `yaml:"description"` + DependsOn []string `yaml:"depends_on"` + Inputs []Input `yaml:"inputs"` + ScriptDirs []string `yaml:"script_dirs"` + Template string `yaml:"template"` + Mode string `yaml:"mode"` + FilenamePattern string `yaml:"filename_pattern"` + Output *Output `yaml:"output"` + Options Options `yaml:"options"` + Logfile string `yaml:"logfile"` } // Input is one declared input schema. @@ -258,7 +262,7 @@ func (j *Job) validate() []string { var e []string switch j.Command { - case CommandConvert, CommandMerge, CommandScriptsList: + case CommandConvert, CommandMerge, CommandScriptsList, CommandTempl: case "": e = append(e, "missing command") return e @@ -277,6 +281,7 @@ func (j *Job) validate() []string { } } checkPath("logfile", j.Logfile) + checkPath("template", j.Template) for _, in := range j.Inputs { checkPath("input path", in.Path) } @@ -317,6 +322,63 @@ func (j *Job) validate() []string { if j.Output != nil { e = append(e, "output is not valid for command \"scripts-list\"") } + case CommandTempl: + if len(j.Inputs) < 1 { + e = append(e, "command \"templ\" requires at least 1 input") + } + for i, in := range j.Inputs { + e = append(e, validateTemplInput(i, in)...) + } + if j.Template == "" { + e = append(e, "command \"templ\" requires template") + } + mode := strings.ToLower(j.Mode) + if mode == "" { + mode = "database" + } + switch mode { + case "database", "schema", "script", "table": + default: + e = append(e, fmt.Sprintf("command \"templ\" has unsupported mode %q (supported: database, schema, script, table)", j.Mode)) + } + if len(j.ScriptDirs) > 0 { + e = append(e, "script_dirs is not valid for command \"templ\"") + } + if j.Output != nil && j.Output.ConnEnv != "" { + e = append(e, "command \"templ\" does not support database output") + } + if j.Output != nil && j.Output.Format != "" { + e = append(e, "output.format is not valid for command \"templ\"") + } + } + return e +} + +func validateTemplInput(i int, in Input) []string { + var e []string + if in.Format == "" { + return []string{fmt.Sprintf("input[%d]: missing format", i)} + } + f := strings.ToLower(in.Format) + if f == "pgsql" { + if in.ConnEnv == "" { + e = append(e, fmt.Sprintf("input[%d]: format %q requires conn_env (an environment variable name)", i, in.Format)) + } + if in.Path != "" { + e = append(e, fmt.Sprintf("input[%d]: format %q takes conn_env, not path", i, in.Format)) + } + } else if readerFormats[f] { + if in.Path == "" { + e = append(e, fmt.Sprintf("input[%d]: missing path", i)) + } + if in.ConnEnv != "" { + e = append(e, fmt.Sprintf("input[%d]: format %q does not use conn_env", i, in.Format)) + } + } else { + e = append(e, fmt.Sprintf("input[%d]: unsupported templ input format %q", i, in.Format)) + } + if looksLikeSecret(in.ConnEnv) { + e = append(e, fmt.Sprintf("input[%d]: conn_env must be an environment variable name, not a connection string", i)) } return e }