fix(embed): update embedDirectives to return result directly fix(executor): clean up import order and formatting fix(syntax_test): change file permission syntax to octal fix(mysql): update file permission syntax to octal fix(pgsql): update file permission syntax to octal fix(sqlex): simplify Walk function parameter handling fix(transform): adjust identifier comparison logic fix(template): clean up function formatting and error handling
338 lines
9.3 KiB
Go
338 lines
9.3 KiB
Go
package template
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
|
)
|
|
|
|
// EntrypointMode defines how the template is executed
|
|
type EntrypointMode string
|
|
|
|
const (
|
|
// DatabaseMode executes the template once for the entire database (single output)
|
|
DatabaseMode EntrypointMode = "database"
|
|
// SchemaMode executes the template once per schema (multi-file output)
|
|
SchemaMode EntrypointMode = "schema"
|
|
// DomainMode executes the template once per domain (multi-file output)
|
|
DomainMode EntrypointMode = "domain"
|
|
// ScriptMode executes the template once per script (multi-file output)
|
|
ScriptMode EntrypointMode = "script"
|
|
// TableMode executes the template once per table (multi-file output)
|
|
TableMode EntrypointMode = "table"
|
|
)
|
|
|
|
// Writer implements the writers.Writer interface for template-based output
|
|
type Writer struct {
|
|
options *writers.WriterOptions
|
|
templatePath string
|
|
funcMap template.FuncMap
|
|
tmpl *template.Template
|
|
mode EntrypointMode
|
|
filenamePattern string
|
|
}
|
|
|
|
// NewWriter creates a new template writer with the given options
|
|
func NewWriter(options *writers.WriterOptions) (*Writer, error) {
|
|
w := &Writer{
|
|
options: options,
|
|
funcMap: BuildFuncMap(),
|
|
mode: DatabaseMode, // default mode
|
|
filenamePattern: "{{.Name}}.txt",
|
|
}
|
|
|
|
// Extract template path from metadata
|
|
if options.Metadata != nil {
|
|
if path, ok := options.Metadata["template_path"].(string); ok {
|
|
w.templatePath = path
|
|
}
|
|
|
|
if mode, ok := options.Metadata["mode"].(string); ok {
|
|
w.mode = EntrypointMode(mode)
|
|
}
|
|
|
|
if pattern, ok := options.Metadata["filename_pattern"].(string); ok {
|
|
w.filenamePattern = pattern
|
|
}
|
|
}
|
|
|
|
// Validate template path
|
|
if w.templatePath == "" {
|
|
return nil, NewTemplateLoadError("template path is required", nil)
|
|
}
|
|
|
|
// Load and parse template
|
|
if err := w.loadTemplate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return w, nil
|
|
}
|
|
|
|
// WriteDatabase writes a database using the template
|
|
func (w *Writer) WriteDatabase(db *models.Database) error {
|
|
switch w.mode {
|
|
case DatabaseMode:
|
|
return w.executeDatabaseMode(db)
|
|
case SchemaMode:
|
|
return w.executeSchemaMode(db)
|
|
case DomainMode:
|
|
return w.executeDomainMode(db)
|
|
case ScriptMode:
|
|
return w.executeScriptMode(db)
|
|
case TableMode:
|
|
return w.executeTableMode(db)
|
|
default:
|
|
return fmt.Errorf("unknown entrypoint mode: %s", w.mode)
|
|
}
|
|
}
|
|
|
|
// WriteSchema writes a schema using the template
|
|
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
|
// Create a temporary database with just this schema
|
|
db := models.InitDatabase(schema.Name)
|
|
db.Schemas = []*models.Schema{schema}
|
|
|
|
return w.WriteDatabase(db)
|
|
}
|
|
|
|
// WriteTable writes a single table using the template
|
|
func (w *Writer) WriteTable(table *models.Table) error {
|
|
// Create a temporary schema and database
|
|
schema := models.InitSchema(table.Schema)
|
|
schema.Tables = []*models.Table{table}
|
|
|
|
db := models.InitDatabase(schema.Name)
|
|
db.Schemas = []*models.Schema{schema}
|
|
|
|
return w.WriteDatabase(db)
|
|
}
|
|
|
|
// executeDatabaseMode executes the template once for the entire database
|
|
func (w *Writer) executeDatabaseMode(db *models.Database) error {
|
|
data := NewDatabaseData(db, w.options.Metadata)
|
|
output, err := w.executeTemplate(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return w.writeOutput(output, w.options.OutputPath)
|
|
}
|
|
|
|
// Multi-file generation renders and validates every artifact before touching output.
|
|
func (w *Writer) executeSchemaMode(db *models.Database) error {
|
|
var data []*TemplateData
|
|
for _, schema := range db.Schemas {
|
|
data = append(data, NewSchemaData(schema, w.options.Metadata))
|
|
}
|
|
return w.executeFiles(data)
|
|
}
|
|
|
|
func (w *Writer) executeDomainMode(db *models.Database) error {
|
|
var data []*TemplateData
|
|
for _, domain := range db.Domains {
|
|
data = append(data, NewDomainData(domain, db, w.options.Metadata))
|
|
}
|
|
return w.executeFiles(data)
|
|
}
|
|
|
|
func (w *Writer) executeScriptMode(db *models.Database) error {
|
|
var data []*TemplateData
|
|
for _, schema := range db.Schemas {
|
|
for _, script := range schema.Scripts {
|
|
data = append(data, NewScriptData(script, schema, db, w.options.Metadata))
|
|
}
|
|
}
|
|
return w.executeFiles(data)
|
|
}
|
|
|
|
func (w *Writer) executeTableMode(db *models.Database) error {
|
|
var data []*TemplateData
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
data = append(data, NewTableData(table, schema, db, w.options.Metadata))
|
|
}
|
|
}
|
|
return w.executeFiles(data)
|
|
}
|
|
|
|
type artifact struct{ name, content string }
|
|
|
|
func (w *Writer) outputRoot() string {
|
|
if w.options.OutputPath == "" {
|
|
return "."
|
|
}
|
|
return w.options.OutputPath
|
|
}
|
|
|
|
func (w *Writer) executeFiles(data []*TemplateData) error {
|
|
var files []artifact
|
|
seen := make(map[string]bool)
|
|
for _, item := range data {
|
|
name, err := w.renderFilename(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := strings.ToLower(filepath.ToSlash(name))
|
|
if seen[key] {
|
|
return fmt.Errorf("generated filename collision: %q", name)
|
|
}
|
|
seen[key] = true
|
|
content, err := w.executeTemplate(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
files = append(files, artifact{name, content})
|
|
}
|
|
// Reject file/directory conflicts before creating any output.
|
|
for name := range seen {
|
|
for parent := filepath.ToSlash(filepath.Dir(name)); parent != "."; parent = filepath.ToSlash(filepath.Dir(parent)) {
|
|
if seen[parent] {
|
|
return fmt.Errorf("generated file/directory collision: %q", name)
|
|
}
|
|
}
|
|
}
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
rootPath := w.outputRoot()
|
|
if err := safefile.CheckRoot(rootPath); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(rootPath, 0o755); err != nil {
|
|
return err
|
|
}
|
|
root, err := os.OpenRoot(rootPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer root.Close()
|
|
// Preflight every destination, including existing directory and symlink conflicts.
|
|
for _, file := range files {
|
|
if err := safefile.Check(root, file.name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, file := range files {
|
|
if err := w.writeArtifact(root, file); err != nil {
|
|
return fmt.Errorf("write generated file %q: %w", file.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *Writer) writeArtifact(root *os.Root, file artifact) error {
|
|
if err := root.MkdirAll(filepath.Dir(file.name), 0o755); err != nil {
|
|
return err
|
|
}
|
|
// Exclusive unpredictable staging avoids truncating a destination (or a link target).
|
|
var token [16]byte
|
|
if _, err := rand.Read(token[:]); err != nil {
|
|
return err
|
|
}
|
|
temporary := ".relspec-" + hex.EncodeToString(token[:]) + ".tmp"
|
|
mode := os.FileMode(0o644)
|
|
if info, err := root.Lstat(file.name); err == nil && info.Mode().IsRegular() {
|
|
mode = info.Mode().Perm()
|
|
}
|
|
staged, err := root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
// Best-effort cleanup; a successful rename already removes the staging path.
|
|
_ = root.Remove(temporary)
|
|
}()
|
|
_, writeErr := staged.WriteString(file.content)
|
|
if err := errors.Join(writeErr, staged.Close()); err != nil {
|
|
return err
|
|
}
|
|
if err := safefile.Check(root, file.name); err != nil {
|
|
return err
|
|
}
|
|
return root.Rename(temporary, file.name)
|
|
}
|
|
|
|
// loadTemplate loads and parses the template file
|
|
func (w *Writer) loadTemplate() error {
|
|
// Read template file
|
|
content, err := os.ReadFile(w.templatePath)
|
|
if err != nil {
|
|
return NewTemplateLoadError(fmt.Sprintf("failed to read template file: %s", w.templatePath), err)
|
|
}
|
|
|
|
// Parse template with function map
|
|
tmpl, err := template.New(filepath.Base(w.templatePath)).Funcs(w.funcMap).Parse(string(content))
|
|
if err != nil {
|
|
return NewTemplateParseError(fmt.Sprintf("failed to parse template: %s", w.templatePath), err)
|
|
}
|
|
|
|
w.tmpl = tmpl
|
|
return nil
|
|
}
|
|
|
|
// executeTemplate executes the template with the given data
|
|
func (w *Writer) executeTemplate(data *TemplateData) (string, error) {
|
|
var buf bytes.Buffer
|
|
if err := w.tmpl.Execute(&buf, data); err != nil {
|
|
return "", NewTemplateExecuteError("failed to execute template", err)
|
|
}
|
|
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// renderFilename returns a validated path relative to the explicit output root.
|
|
func (w *Writer) renderFilename(data *TemplateData) (string, error) {
|
|
tmpl, err := template.New("filename").Funcs(w.funcMap).Parse(w.filenamePattern)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid filename pattern: %w", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
return "", fmt.Errorf("failed to generate filename: %w", err)
|
|
}
|
|
return safefile.Relative(buf.String())
|
|
}
|
|
|
|
func (w *Writer) generateFilename(data *TemplateData) (string, error) {
|
|
name, err := w.renderFilename(data)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(w.outputRoot(), name), nil
|
|
}
|
|
|
|
// writeOutput writes the output to a file or stdout
|
|
func (w *Writer) writeOutput(content, outputPath string) error {
|
|
// If output path is empty, write to stdout
|
|
if outputPath == "" {
|
|
fmt.Print(content)
|
|
return nil
|
|
}
|
|
|
|
// Ensure directory exists
|
|
dir := filepath.Dir(outputPath)
|
|
if dir != "." && dir != "" {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
|
}
|
|
}
|
|
|
|
// Write to file
|
|
if err := os.WriteFile(outputPath, []byte(content), 0o644); err != nil {
|
|
return fmt.Errorf("failed to write file %s: %w", outputPath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|