Files
relspecgo/pkg/writers/template/writer.go
T

332 lines
9.2 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 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
}