feat(templ): added templ to command line that reads go template and outputs code

This commit is contained in:
2026-01-03 20:28:56 +02:00
parent 64aeac972a
commit fca7c99d74
24 changed files with 3955 additions and 0 deletions

View File

@@ -0,0 +1,276 @@
# Template Writer
Custom template-based writer for RelSpec that allows users to generate any output format using Go text templates.
## Overview
The template writer provides a powerful and flexible way to transform database schemas into any desired format. It supports multiple execution modes and provides 80+ template functions for data transformation.
**For complete user documentation, see:** [/docs/TEMPLATE_MODE.md](../../../docs/TEMPLATE_MODE.md)
## Architecture
### Package Structure
```
pkg/writers/template/
├── README.md # This file
├── writer.go # Core writer with entrypoint mode logic
├── template_data.go # Data structures passed to templates
├── funcmap.go # Template function registry
├── string_helpers.go # String manipulation functions
├── type_mappers.go # SQL type conversion (delegates to commontypes)
├── filters.go # Database object filtering
├── formatters.go # JSON/YAML formatting utilities
├── loop_helpers.go # Iteration and collection utilities
├── safe_access.go # Safe map/array access functions
└── errors.go # Custom error types
```
### Dependencies
- **`pkg/commontypes`** - Centralized type mappings for Go, TypeScript, Java, Python, Rust, C#, PHP
- **`pkg/reflectutil`** - Reflection utilities for safe type manipulation
- **`pkg/models`** - Database schema models
## Writer Interface Implementation
Implements the standard `writers.Writer` interface:
```go
type Writer interface {
WriteDatabase(db *models.Database) error
WriteSchema(schema *models.Schema) error
WriteTable(table *models.Table) error
}
```
## Execution Modes
The writer supports four execution modes via `WriterOptions.Metadata["mode"]`:
| Mode | Data Passed | Output | Use Case |
|------|-------------|--------|----------|
| `database` | Full database | Single file | Reports, documentation |
| `schema` | One schema at a time | File per schema | Schema-specific docs |
| `script` | One script at a time | File per script | Script processing |
| `table` | One table at a time | File per table | Model generation |
## Configuration
Writer is configured via `WriterOptions.Metadata`:
```go
metadata := map[string]interface{}{
"template_path": "/path/to/template.tmpl", // Required
"mode": "table", // Default: "database"
"filename_pattern": "{{.Name}}.ts", // Default: "{{.Name}}.txt"
}
```
## Template Data Structure
Templates receive a `TemplateData` struct:
```go
type TemplateData struct {
// Primary data (one populated based on mode)
Database *models.Database
Schema *models.Schema
Script *models.Script
Table *models.Table
// Parent context
ParentSchema *models.Schema
ParentDatabase *models.Database
// Pre-computed views
FlatColumns []*models.FlatColumn
FlatTables []*models.FlatTable
FlatConstraints []*models.FlatConstraint
FlatRelationships []*models.FlatRelationship
Summary *models.DatabaseSummary
// User metadata
Metadata map[string]interface{}
}
```
## Function Categories
### String Utilities (string_helpers.go)
Case conversion, pluralization, trimming, splitting, joining
### Type Mappers (type_mappers.go)
SQL type conversion to 7+ programming languages (delegates to `pkg/commontypes`)
### Filters (filters.go)
Database object filtering by pattern, type, constraints
### Formatters (formatters.go)
JSON/YAML serialization, indentation, escaping, commenting
### Loop Helpers (loop_helpers.go)
Enumeration, batching, reversing, sorting, grouping (uses `pkg/reflectutil`)
### Safe Access (safe_access.go)
Safe map/array access without panics (uses `pkg/reflectutil`)
## Adding New Functions
To add a new template function:
1. **Implement the function** in the appropriate file:
```go
// string_helpers.go
func ToScreamingSnakeCase(s string) string {
return strings.ToUpper(ToSnakeCase(s))
}
```
2. **Register in funcmap.go:**
```go
func BuildFuncMap() template.FuncMap {
return template.FuncMap{
// ... existing functions
"toScreamingSnakeCase": ToScreamingSnakeCase,
}
}
```
3. **Document in /docs/TEMPLATE_MODE.md**
## Error Handling
Custom error types in `errors.go`:
- `TemplateLoadError` - Template file not found or unreadable
- `TemplateParseError` - Invalid template syntax
- `TemplateExecuteError` - Error during template execution
All errors wrap the underlying error for context.
## Testing
```bash
# Run tests
go test ./pkg/writers/template/...
# Test with example data
cat > test.tmpl << 'EOF'
{{ range .Database.Schemas }}
Schema: {{ .Name }} ({{ len .Tables }} tables)
{{ end }}
EOF
relspec templ --from json --from-path schema.json --template test.tmpl
```
## Multi-file Output
For multi-file modes, the writer:
1. Iterates through items (schemas/scripts/tables)
2. Creates `TemplateData` for each item
3. Executes template with item data
4. Generates filename using `filename_pattern` template
5. Writes output to generated filename
Output directory is created automatically if it doesn't exist.
## Filename Pattern Execution
The filename pattern is itself a template:
```go
// Pattern: "{{.Schema}}/{{.Name | toCamelCase}}.ts"
// For table "user_profile" in schema "public"
// Generates: "public/userProfile.ts"
```
Available in pattern template:
- `.Name` - Item name (schema/script/table)
- `.Schema` - Schema name (for scripts/tables)
- All template functions
## Example Usage
### As a Library
```go
import (
"git.warky.dev/wdevs/relspecgo/pkg/writers"
"git.warky.dev/wdevs/relspecgo/pkg/writers/template"
)
// Create writer
metadata := map[string]interface{}{
"template_path": "model.tmpl",
"mode": "table",
"filename_pattern": "{{.Name}}.go",
}
opts := &writers.WriterOptions{
OutputPath: "./models/",
PackageName: "models",
Metadata: metadata,
}
writer, err := template.NewWriter(opts)
if err != nil {
// Handle error
}
// Write database
err = writer.WriteDatabase(db)
```
### Via CLI
```bash
relspec templ \
--from pgsql \
--from-conn "postgres://localhost/mydb" \
--template model.tmpl \
--mode table \
--output ./models/ \
--filename-pattern "{{.Name | toPascalCase}}.go"
```
## Performance Considerations
1. **Template Parsing** - Template is parsed once in `NewWriter()`, not per execution
2. **Reflection** - Loop and safe access helpers use reflection; cached where possible
3. **Pre-computed Views** - `FlatColumns`, `FlatTables`, etc. computed once per data item
4. **File I/O** - Multi-file mode creates directories as needed
## Future Enhancements
Potential improvements:
- [ ] Template caching for filename patterns
- [ ] Parallel template execution for multi-file mode
- [ ] Template function plugins
- [ ] Custom function injection via metadata
- [ ] Template includes/partials support
- [ ] Dry-run mode to preview filenames
- [ ] Progress reporting for large schemas
## Contributing
When adding new features:
1. Follow existing patterns (see similar functions)
2. Add to appropriate category file
3. Register in `funcmap.go`
4. Update `/docs/TEMPLATE_MODE.md`
5. Add tests
6. Consider edge cases (nil, empty, invalid input)
## See Also
- [User Documentation](/docs/TEMPLATE_MODE.md) - Complete template function reference
- [Common Types Package](../../commontypes/) - Centralized type mappings
- [Reflect Utilities](../../reflectutil/) - Reflection helpers
- [Models Package](../../models/) - Database schema models
- [Go Template Docs](https://pkg.go.dev/text/template) - Official Go template documentation

View File

@@ -0,0 +1,50 @@
package template
import "fmt"
// TemplateError represents an error that occurred during template operations
type TemplateError struct {
Phase string // "load", "parse", "execute"
Message string
Err error
}
// Error implements the error interface
func (e *TemplateError) Error() string {
if e.Err != nil {
return fmt.Sprintf("template %s error: %s: %v", e.Phase, e.Message, e.Err)
}
return fmt.Sprintf("template %s error: %s", e.Phase, e.Message)
}
// Unwrap returns the wrapped error
func (e *TemplateError) Unwrap() error {
return e.Err
}
// NewTemplateLoadError creates a new template load error
func NewTemplateLoadError(msg string, err error) *TemplateError {
return &TemplateError{
Phase: "load",
Message: msg,
Err: err,
}
}
// NewTemplateParseError creates a new template parse error
func NewTemplateParseError(msg string, err error) *TemplateError {
return &TemplateError{
Phase: "parse",
Message: msg,
Err: err,
}
}
// NewTemplateExecuteError creates a new template execution error
func NewTemplateExecuteError(msg string, err error) *TemplateError {
return &TemplateError{
Phase: "execute",
Message: msg,
Err: err,
}
}

View File

@@ -0,0 +1,144 @@
package template
import (
"path/filepath"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/commontypes"
"git.warky.dev/wdevs/relspecgo/pkg/models"
)
// FilterTables filters tables using a predicate function
// Usage: {{ $filtered := filterTables .Schema.Tables (func $t) { return hasPrefix $t.Name "user_" } }}
// Note: Template functions can't pass Go funcs, so this is primarily for internal use
func FilterTables(tables []*models.Table, pattern string) []*models.Table {
if pattern == "" {
return tables
}
result := make([]*models.Table, 0)
for _, table := range tables {
if matchPattern(table.Name, pattern) {
result = append(result, table)
}
}
return result
}
// FilterTablesByPattern filters tables by name pattern (glob-style)
// Usage: {{ $userTables := filterTablesByPattern .Schema.Tables "user_*" }}
func FilterTablesByPattern(tables []*models.Table, pattern string) []*models.Table {
return FilterTables(tables, pattern)
}
// FilterColumns filters columns from a map using a pattern
// Usage: {{ $filtered := filterColumns .Table.Columns "*_id" }}
func FilterColumns(columns map[string]*models.Column, pattern string) []*models.Column {
result := make([]*models.Column, 0)
for _, col := range columns {
if pattern == "" || matchPattern(col.Name, pattern) {
result = append(result, col)
}
}
return result
}
// FilterColumnsByType filters columns by SQL type
// Usage: {{ $stringCols := filterColumnsByType .Table.Columns "varchar" }}
func FilterColumnsByType(columns map[string]*models.Column, sqlType string) []*models.Column {
result := make([]*models.Column, 0)
baseType := commontypes.ExtractBaseType(sqlType)
for _, col := range columns {
colBaseType := commontypes.ExtractBaseType(col.Type)
if colBaseType == baseType {
result = append(result, col)
}
}
return result
}
// FilterPrimaryKeys returns only columns that are primary keys
// Usage: {{ $pks := filterPrimaryKeys .Table.Columns }}
func FilterPrimaryKeys(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0)
for _, col := range columns {
if col.IsPrimaryKey {
result = append(result, col)
}
}
return result
}
// FilterForeignKeys returns only foreign key constraints
// Usage: {{ $fks := filterForeignKeys .Table.Constraints }}
func FilterForeignKeys(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0)
for _, constraint := range constraints {
if constraint.Type == models.ForeignKeyConstraint {
result = append(result, constraint)
}
}
return result
}
// FilterUniqueConstraints returns only unique constraints
// Usage: {{ $uniques := filterUniqueConstraints .Table.Constraints }}
func FilterUniqueConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0)
for _, constraint := range constraints {
if constraint.Type == models.UniqueConstraint {
result = append(result, constraint)
}
}
return result
}
// FilterCheckConstraints returns only check constraints
// Usage: {{ $checks := filterCheckConstraints .Table.Constraints }}
func FilterCheckConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0)
for _, constraint := range constraints {
if constraint.Type == models.CheckConstraint {
result = append(result, constraint)
}
}
return result
}
// FilterNullable returns only nullable columns
// Usage: {{ $nullables := filterNullable .Table.Columns }}
func FilterNullable(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0)
for _, col := range columns {
if !col.NotNull {
result = append(result, col)
}
}
return result
}
// FilterNotNull returns only non-nullable columns
// Usage: {{ $required := filterNotNull .Table.Columns }}
func FilterNotNull(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0)
for _, col := range columns {
if col.NotNull {
result = append(result, col)
}
}
return result
}
// matchPattern performs simple glob-style pattern matching
// Supports: * (any characters), ? (single character)
// Examples: "user_*" matches "user_profile", "user_settings"
func matchPattern(s, pattern string) bool {
// Use filepath.Match for glob-style pattern matching
matched, err := filepath.Match(pattern, s)
if err != nil {
// If pattern is invalid, do exact match
return strings.EqualFold(s, pattern)
}
return matched
}

View File

@@ -0,0 +1,157 @@
package template
import (
"encoding/json"
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
// ToJSON converts a value to JSON string
// Usage: {{ .Database | toJSON }}
func ToJSON(v interface{}) string {
data, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("{\"error\": \"failed to marshal: %v\"}", err)
}
return string(data)
}
// ToJSONPretty converts a value to pretty-printed JSON string
// Usage: {{ .Database | toJSONPretty " " }}
func ToJSONPretty(v interface{}, indent string) string {
data, err := json.MarshalIndent(v, "", indent)
if err != nil {
return fmt.Sprintf("{\"error\": \"failed to marshal: %v\"}", err)
}
return string(data)
}
// ToYAML converts a value to YAML string
// Usage: {{ .Database | toYAML }}
func ToYAML(v interface{}) string {
data, err := yaml.Marshal(v)
if err != nil {
return fmt.Sprintf("error: failed to marshal: %v", err)
}
return string(data)
}
// Indent indents each line of a string by the specified number of spaces
// Usage: {{ .Column.Description | indent 4 }}
func Indent(s string, spaces int) string {
if s == "" {
return ""
}
prefix := strings.Repeat(" ", spaces)
lines := strings.Split(s, "\n")
for i, line := range lines {
if line != "" {
lines[i] = prefix + line
}
}
return strings.Join(lines, "\n")
}
// IndentWith indents each line of a string with a custom prefix
// Usage: {{ .Column.Description | indentWith " " }}
func IndentWith(s string, prefix string) string {
if s == "" {
return ""
}
lines := strings.Split(s, "\n")
for i, line := range lines {
if line != "" {
lines[i] = prefix + line
}
}
return strings.Join(lines, "\n")
}
// Escape escapes special characters in a string for use in code
// Usage: {{ .Column.Default | escape }}
func Escape(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "\\r")
s = strings.ReplaceAll(s, "\t", "\\t")
return s
}
// EscapeQuotes escapes only quote characters
// Usage: {{ .Column.Comment | escapeQuotes }}
func EscapeQuotes(s string) string {
s = strings.ReplaceAll(s, "\"", "\\\"")
s = strings.ReplaceAll(s, "'", "\\'")
return s
}
// Comment adds comment prefix to a string
// Supports: "//" (Go, C++, etc.), "#" (Python, shell), "--" (SQL), "/* */" (block)
// Usage: {{ .Table.Description | comment "//" }}
func Comment(s string, style string) string {
if s == "" {
return ""
}
lines := strings.Split(s, "\n")
switch style {
case "//":
for i, line := range lines {
lines[i] = "// " + line
}
return strings.Join(lines, "\n")
case "#":
for i, line := range lines {
lines[i] = "# " + line
}
return strings.Join(lines, "\n")
case "--":
for i, line := range lines {
lines[i] = "-- " + line
}
return strings.Join(lines, "\n")
case "/* */", "/**/":
if len(lines) == 1 {
return "/* " + lines[0] + " */"
}
result := "/*\n"
for _, line := range lines {
result += " * " + line + "\n"
}
result += " */"
return result
default:
// Default to // style
for i, line := range lines {
lines[i] = "// " + line
}
return strings.Join(lines, "\n")
}
}
// QuoteString adds quotes around a string
// Usage: {{ .Column.Default | quoteString }}
func QuoteString(s string) string {
return "\"" + s + "\""
}
// UnquoteString removes quotes from a string
// Usage: {{ .Value | unquoteString }}
func UnquoteString(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}

View File

@@ -0,0 +1,171 @@
package template
import (
"text/template"
"git.warky.dev/wdevs/relspecgo/pkg/models"
)
// BuildFuncMap creates a template.FuncMap with all available helper functions
func BuildFuncMap() template.FuncMap {
return template.FuncMap{
// String manipulation functions
"toUpper": ToUpper,
"toLower": ToLower,
"toCamelCase": ToCamelCase,
"toPascalCase": ToPascalCase,
"toSnakeCase": ToSnakeCase,
"toKebabCase": ToKebabCase,
"pluralize": Pluralize,
"singularize": Singularize,
"title": Title,
"trim": Trim,
"trimPrefix": TrimPrefix,
"trimSuffix": TrimSuffix,
"replace": Replace,
"stringContains": StringContains, // Avoid conflict with slice contains
"hasPrefix": HasPrefix,
"hasSuffix": HasSuffix,
"split": Split,
"join": Join,
// Type conversion functions
"sqlToGo": SQLToGo,
"sqlToTypeScript": SQLToTypeScript,
"sqlToJava": SQLToJava,
"sqlToPython": SQLToPython,
"sqlToRust": SQLToRust,
"sqlToCSharp": SQLToCSharp,
"sqlToPhp": SQLToPhp,
// Filtering functions
"filterTables": FilterTables,
"filterTablesByPattern": FilterTablesByPattern,
"filterColumns": FilterColumns,
"filterColumnsByType": FilterColumnsByType,
"filterPrimaryKeys": FilterPrimaryKeys,
"filterForeignKeys": FilterForeignKeys,
"filterUniqueConstraints": FilterUniqueConstraints,
"filterCheckConstraints": FilterCheckConstraints,
"filterNullable": FilterNullable,
"filterNotNull": FilterNotNull,
// Formatting functions
"toJSON": ToJSON,
"toJSONPretty": ToJSONPretty,
"toYAML": ToYAML,
"indent": Indent,
"indentWith": IndentWith,
"escape": Escape,
"escapeQuotes": EscapeQuotes,
"comment": Comment,
"quoteString": QuoteString,
"unquoteString": UnquoteString,
// Loop/iteration helper functions
"enumerate": Enumerate,
"batch": Batch,
"chunk": Chunk,
"reverse": Reverse,
"first": First,
"last": Last,
"skip": Skip,
"take": Take,
"concat": Concat,
"unique": Unique,
"sortBy": SortBy,
"groupBy": GroupBy,
// Safe access functions
"get": Get,
"getOr": GetOr,
"getPath": GetPath,
"getPathOr": GetPathOr,
"safeIndex": SafeIndex,
"safeIndexOr": SafeIndexOr,
"has": Has,
"hasPath": HasPath,
"keys": Keys,
"values": Values,
"merge": Merge,
"pick": Pick,
"omit": Omit,
"sliceContains": SliceContains,
"indexOf": IndexOf,
"pluck": Pluck,
// Sorting functions
"sortSchemasByName": models.SortSchemasByName,
"sortSchemasBySequence": models.SortSchemasBySequence,
"sortTablesByName": models.SortTablesByName,
"sortTablesBySequence": models.SortTablesBySequence,
"sortColumnsByName": models.SortColumnsByName,
"sortColumnsBySequence": models.SortColumnsBySequence,
"sortColumnsMapByName": models.SortColumnsMapByName,
"sortColumnsMapBySequence": models.SortColumnsMapBySequence,
"sortViewsByName": models.SortViewsByName,
"sortViewsBySequence": models.SortViewsBySequence,
"sortSequencesByName": models.SortSequencesByName,
"sortSequencesBySequence": models.SortSequencesBySequence,
"sortIndexesByName": models.SortIndexesByName,
"sortIndexesBySequence": models.SortIndexesBySequence,
"sortIndexesMapByName": models.SortIndexesMapByName,
"sortIndexesMapBySequence": models.SortIndexesMapBySequence,
"sortConstraintsByName": models.SortConstraintsByName,
"sortConstraintsMapByName": models.SortConstraintsMapByName,
"sortRelationshipsByName": models.SortRelationshipsByName,
"sortRelationshipsMapByName": models.SortRelationshipsMapByName,
"sortScriptsByName": models.SortScriptsByName,
"sortEnumsByName": models.SortEnumsByName,
// Utility functions (built-in Go template helpers + custom)
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"div": func(a, b int) int {
if b == 0 {
return 0
}
return a / b
},
"mod": func(a, b int) int {
if b == 0 {
return 0
}
return a % b
},
"default": func(defaultVal, val interface{}) interface{} {
if val == nil {
return defaultVal
}
return val
},
"dict": func(values ...interface{}) map[string]interface{} {
if len(values)%2 != 0 {
return nil
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil
}
dict[key] = values[i+1]
}
return dict
},
"list": func(values ...interface{}) []interface{} {
return values
},
"seq": func(start, end int) []int {
if start > end {
return []int{}
}
result := make([]int, end-start+1)
for i := range result {
result[i] = start + i
}
return result
},
}
}

View File

@@ -0,0 +1,282 @@
package template
import (
"reflect"
"sort"
"git.warky.dev/wdevs/relspecgo/pkg/reflectutil"
)
// EnumeratedItem represents an item with its index
type EnumeratedItem struct {
Index int
Value interface{}
}
// Enumerate returns a slice with index-value pairs
// Usage: {{ range enumerate .Tables }}{{ .Index }}: {{ .Value.Name }}{{ end }}
func Enumerate(slice interface{}) []EnumeratedItem {
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return []EnumeratedItem{}
}
result := make([]EnumeratedItem, v.Len())
for i := 0; i < v.Len(); i++ {
result[i] = EnumeratedItem{
Index: i,
Value: v.Index(i).Interface(),
}
}
return result
}
// Batch splits a slice into chunks of specified size
// Usage: {{ range batch .Columns 3 }}...{{ end }}
func Batch(slice interface{}, size int) [][]interface{} {
if size <= 0 {
return [][]interface{}{}
}
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return [][]interface{}{}
}
length := v.Len()
if length == 0 {
return [][]interface{}{}
}
numBatches := (length + size - 1) / size
result := make([][]interface{}, numBatches)
for i := 0; i < numBatches; i++ {
start := i * size
end := start + size
if end > length {
end = length
}
batch := make([]interface{}, end-start)
for j := start; j < end; j++ {
batch[j-start] = v.Index(j).Interface()
}
result[i] = batch
}
return result
}
// Chunk is an alias for Batch
func Chunk(slice interface{}, size int) [][]interface{} {
return Batch(slice, size)
}
// Reverse reverses a slice
// Usage: {{ range reverse .Tables }}...{{ end }}
func Reverse(slice interface{}) []interface{} {
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return []interface{}{}
}
length := v.Len()
result := make([]interface{}, length)
for i := 0; i < length; i++ {
result[length-1-i] = v.Index(i).Interface()
}
return result
}
// First returns the first N items from a slice
// Usage: {{ range first .Tables 5 }}...{{ end }}
func First(slice interface{}, n int) []interface{} {
if n <= 0 {
return []interface{}{}
}
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return []interface{}{}
}
length := v.Len()
if n > length {
n = length
}
result := make([]interface{}, n)
for i := 0; i < n; i++ {
result[i] = v.Index(i).Interface()
}
return result
}
// Last returns the last N items from a slice
// Usage: {{ range last .Tables 5 }}...{{ end }}
func Last(slice interface{}, n int) []interface{} {
if n <= 0 {
return []interface{}{}
}
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return []interface{}{}
}
length := v.Len()
if n > length {
n = length
}
result := make([]interface{}, n)
start := length - n
for i := 0; i < n; i++ {
result[i] = v.Index(start + i).Interface()
}
return result
}
// Skip skips the first N items and returns the rest
// Usage: {{ range skip .Tables 2 }}...{{ end }}
func Skip(slice interface{}, n int) []interface{} {
if n < 0 {
n = 0
}
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return []interface{}{}
}
length := v.Len()
if n >= length {
return []interface{}{}
}
result := make([]interface{}, length-n)
for i := n; i < length; i++ {
result[i-n] = v.Index(i).Interface()
}
return result
}
// Take returns the first N items (alias for First)
// Usage: {{ range take .Tables 5 }}...{{ end }}
func Take(slice interface{}, n int) []interface{} {
return First(slice, n)
}
// Concat concatenates multiple slices
// Usage: {{ $all := concat .Schema1.Tables .Schema2.Tables }}
func Concat(slices ...interface{}) []interface{} {
result := make([]interface{}, 0)
for _, slice := range slices {
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
continue
}
for i := 0; i < v.Len(); i++ {
result = append(result, v.Index(i).Interface())
}
}
return result
}
// Unique removes duplicates from a slice (compares by string representation)
// Usage: {{ $unique := unique .Items }}
func Unique(slice interface{}) []interface{} {
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return []interface{}{}
}
seen := make(map[interface{}]bool)
result := make([]interface{}, 0)
for i := 0; i < v.Len(); i++ {
item := v.Index(i).Interface()
if !seen[item] {
seen[item] = true
result = append(result, item)
}
}
return result
}
// SortBy sorts a slice by a field name (for structs) or key (for maps)
// Usage: {{ $sorted := sortBy .Tables "Name" }}
// Note: This is a basic implementation that works for simple cases
func SortBy(slice interface{}, field string) []interface{} {
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return []interface{}{}
}
// Convert to interface slice
result := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
result[i] = v.Index(i).Interface()
}
// Sort by field
sort.Slice(result, func(i, j int) bool {
vi := getFieldValue(result[i], field)
vj := getFieldValue(result[j], field)
return compareValues(vi, vj) < 0
})
return result
}
// GroupBy groups a slice by a field value
// Usage: {{ $grouped := groupBy .Tables "Schema" }}
func GroupBy(slice interface{}, field string) map[interface{}][]interface{} {
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return map[interface{}][]interface{}{}
}
result := make(map[interface{}][]interface{})
for i := 0; i < v.Len(); i++ {
item := v.Index(i).Interface()
key := getFieldValue(item, field)
result[key] = append(result[key], item)
}
return result
}
// CountIf counts items matching a condition
// Note: Since templates can't pass functions, this is limited
// Usage in code (not directly in templates)
func CountIf(slice interface{}, matches func(interface{}) bool) int {
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return 0
}
count := 0
for i := 0; i < v.Len(); i++ {
if matches(v.Index(i).Interface()) {
count++
}
}
return count
}
// getFieldValue extracts a field value from a struct, map, or pointer
func getFieldValue(item interface{}, field string) interface{} {
return reflectutil.GetFieldValue(item, field)
}
// compareValues compares two values for sorting
func compareValues(a, b interface{}) int {
return reflectutil.CompareValues(a, b)
}

View File

@@ -0,0 +1,288 @@
package template
import (
"reflect"
"git.warky.dev/wdevs/relspecgo/pkg/reflectutil"
)
// Get safely gets a value from a map by key
// Usage: {{ get .Metadata "key" }}
func Get(m interface{}, key interface{}) interface{} {
return reflectutil.MapGet(m, key)
}
// GetOr safely gets a value from a map with a default fallback
// Usage: {{ getOr .Metadata "key" "default" }}
func GetOr(m interface{}, key interface{}, defaultValue interface{}) interface{} {
result := Get(m, key)
if result == nil {
return defaultValue
}
return result
}
// GetPath safely gets a nested value using dot notation
// Usage: {{ getPath .Config "database.connection.host" }}
func GetPath(m interface{}, path string) interface{} {
return reflectutil.GetNestedValue(m, path)
}
// GetPathOr safely gets a nested value with a default fallback
// Usage: {{ getPathOr .Config "database.connection.host" "localhost" }}
func GetPathOr(m interface{}, path string, defaultValue interface{}) interface{} {
result := GetPath(m, path)
if result == nil {
return defaultValue
}
return result
}
// SafeIndex safely gets an element from a slice by index
// Usage: {{ safeIndex .Tables 0 }}
func SafeIndex(slice interface{}, index int) interface{} {
return reflectutil.SliceIndex(slice, index)
}
// SafeIndexOr safely gets an element from a slice with a default fallback
// Usage: {{ safeIndexOr .Tables 0 "default" }}
func SafeIndexOr(slice interface{}, index int, defaultValue interface{}) interface{} {
result := SafeIndex(slice, index)
if result == nil {
return defaultValue
}
return result
}
// Has checks if a key exists in a map
// Usage: {{ if has .Metadata "key" }}...{{ end }}
func Has(m interface{}, key interface{}) bool {
v := reflect.ValueOf(m)
// Dereference pointers
for v.Kind() == reflect.Ptr {
if v.IsNil() {
return false
}
v = v.Elem()
}
if v.Kind() != reflect.Map {
return false
}
keyVal := reflect.ValueOf(key)
return v.MapIndex(keyVal).IsValid()
}
// HasPath checks if a nested path exists
// Usage: {{ if hasPath .Config "database.connection.host" }}...{{ end }}
func HasPath(m interface{}, path string) bool {
return GetPath(m, path) != nil
}
// Keys returns all keys from a map
// Usage: {{ range keys .Metadata }}...{{ end }}
func Keys(m interface{}) []interface{} {
return reflectutil.MapKeys(m)
}
// Values returns all values from a map
// Usage: {{ range values .Table.Columns }}...{{ end }}
func Values(m interface{}) []interface{} {
return reflectutil.MapValues(m)
}
// Merge merges multiple maps into a new map
// Usage: {{ $merged := merge .Map1 .Map2 }}
func Merge(maps ...interface{}) map[interface{}]interface{} {
result := make(map[interface{}]interface{})
for _, m := range maps {
v := reflect.ValueOf(m)
// Dereference pointers
for v.Kind() == reflect.Ptr {
if v.IsNil() {
continue
}
v = v.Elem()
}
if v.Kind() != reflect.Map {
continue
}
iter := v.MapRange()
for iter.Next() {
result[iter.Key().Interface()] = iter.Value().Interface()
}
}
return result
}
// Pick returns a new map with only the specified keys
// Usage: {{ $subset := pick .Metadata "name" "description" }}
func Pick(m interface{}, keys ...interface{}) map[interface{}]interface{} {
result := make(map[interface{}]interface{})
v := reflect.ValueOf(m)
// Dereference pointers
for v.Kind() == reflect.Ptr {
if v.IsNil() {
return result
}
v = v.Elem()
}
if v.Kind() != reflect.Map {
return result
}
for _, key := range keys {
keyVal := reflect.ValueOf(key)
mapVal := v.MapIndex(keyVal)
if mapVal.IsValid() {
result[key] = mapVal.Interface()
}
}
return result
}
// Omit returns a new map without the specified keys
// Usage: {{ $filtered := omit .Metadata "internal" "private" }}
func Omit(m interface{}, keys ...interface{}) map[interface{}]interface{} {
result := make(map[interface{}]interface{})
v := reflect.ValueOf(m)
// Dereference pointers
for v.Kind() == reflect.Ptr {
if v.IsNil() {
return result
}
v = v.Elem()
}
if v.Kind() != reflect.Map {
return result
}
// Create a set of keys to omit
omitSet := make(map[interface{}]bool)
for _, key := range keys {
omitSet[key] = true
}
// Add all keys that are not in the omit set
iter := v.MapRange()
for iter.Next() {
key := iter.Key().Interface()
if !omitSet[key] {
result[key] = iter.Value().Interface()
}
}
return result
}
// SliceContains checks if a slice contains a value
// Usage: {{ if sliceContains .Names "admin" }}...{{ end }}
func SliceContains(slice interface{}, value interface{}) bool {
v := reflect.ValueOf(slice)
v, ok := reflectutil.Deref(v)
if !ok {
return false
}
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return false
}
for i := 0; i < v.Len(); i++ {
if reflectutil.DeepEqual(v.Index(i).Interface(), value) {
return true
}
}
return false
}
// IndexOf returns the index of a value in a slice, or -1 if not found
// Usage: {{ $idx := indexOf .Names "admin" }}
func IndexOf(slice interface{}, value interface{}) int {
v := reflect.ValueOf(slice)
v, ok := reflectutil.Deref(v)
if !ok {
return -1
}
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return -1
}
for i := 0; i < v.Len(); i++ {
if reflectutil.DeepEqual(v.Index(i).Interface(), value) {
return i
}
}
return -1
}
// Pluck extracts a field from each element in a slice
// Usage: {{ $names := pluck .Tables "Name" }}
func Pluck(slice interface{}, field string) []interface{} {
v := reflect.ValueOf(slice)
// Dereference pointers
for v.Kind() == reflect.Ptr {
if v.IsNil() {
return []interface{}{}
}
v = v.Elem()
}
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return []interface{}{}
}
result := make([]interface{}, 0, v.Len())
for i := 0; i < v.Len(); i++ {
item := v.Index(i)
// Dereference item pointers
for item.Kind() == reflect.Ptr {
if item.IsNil() {
result = append(result, nil)
continue
}
item = item.Elem()
}
switch item.Kind() {
case reflect.Struct:
fieldVal := item.FieldByName(field)
if fieldVal.IsValid() {
result = append(result, fieldVal.Interface())
} else {
result = append(result, nil)
}
case reflect.Map:
keyVal := reflect.ValueOf(field)
mapVal := item.MapIndex(keyVal)
if mapVal.IsValid() {
result = append(result, mapVal.Interface())
} else {
result = append(result, nil)
}
default:
result = append(result, nil)
}
}
return result
}

View File

@@ -0,0 +1,316 @@
package template
import (
"strings"
"unicode"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// ToUpper converts a string to uppercase
func ToUpper(s string) string {
return strings.ToUpper(s)
}
// ToLower converts a string to lowercase
func ToLower(s string) string {
return strings.ToLower(s)
}
// ToCamelCase converts snake_case to camelCase
// Examples: user_name → userName, http_request → httpRequest
func ToCamelCase(s string) string {
if s == "" {
return ""
}
parts := strings.Split(s, "_")
for i, part := range parts {
if i == 0 {
parts[i] = strings.ToLower(part)
} else {
parts[i] = capitalize(part)
}
}
return strings.Join(parts, "")
}
// ToPascalCase converts snake_case to PascalCase
// Examples: user_name → UserName, http_request → HTTPRequest
func ToPascalCase(s string) string {
if s == "" {
return ""
}
parts := strings.Split(s, "_")
for i, part := range parts {
parts[i] = capitalize(part)
}
return strings.Join(parts, "")
}
// ToSnakeCase converts PascalCase/camelCase to snake_case
// Examples: UserName → user_name, HTTPRequest → http_request
func ToSnakeCase(s string) string {
if s == "" {
return ""
}
var result strings.Builder
var prevUpper bool
var nextUpper bool
runes := []rune(s)
for i, r := range runes {
isUpper := unicode.IsUpper(r)
if i+1 < len(runes) {
nextUpper = unicode.IsUpper(runes[i+1])
} else {
nextUpper = false
}
if i > 0 && isUpper {
// Add underscore before uppercase letter if:
// 1. Previous char was lowercase, OR
// 2. Next char is lowercase (end of acronym)
if !prevUpper || (!nextUpper && i+1 < len(runes)) {
result.WriteRune('_')
}
}
result.WriteRune(unicode.ToLower(r))
prevUpper = isUpper
}
return result.String()
}
// ToKebabCase converts snake_case or PascalCase/camelCase to kebab-case
// Examples: user_name → user-name, UserName → user-name
func ToKebabCase(s string) string {
// First convert to snake_case, then replace underscores with hyphens
snakeCase := ToSnakeCase(s)
return strings.ReplaceAll(snakeCase, "_", "-")
}
// Title capitalizes the first letter of each word
func Title(s string) string {
caser := cases.Title(language.English)
src := []byte(s)
dest := []byte(s)
_, _, _ = caser.Transform(dest, src, true)
return string(dest)
}
// Pluralize converts a singular word to plural
// Basic implementation with common English rules
func Pluralize(s string) string {
if s == "" {
return ""
}
// Special cases
irregular := map[string]string{
"person": "people",
"child": "children",
"tooth": "teeth",
"foot": "feet",
"man": "men",
"woman": "women",
"mouse": "mice",
"goose": "geese",
"ox": "oxen",
"datum": "data",
"medium": "media",
"analysis": "analyses",
"crisis": "crises",
"status": "statuses",
}
if plural, ok := irregular[strings.ToLower(s)]; ok {
return plural
}
// Already plural (ends in 's' but not 'ss' or 'us')
if strings.HasSuffix(s, "s") && !strings.HasSuffix(s, "ss") && !strings.HasSuffix(s, "us") {
return s
}
// Words ending in s, x, z, ch, sh
if strings.HasSuffix(s, "s") || strings.HasSuffix(s, "x") ||
strings.HasSuffix(s, "z") || strings.HasSuffix(s, "ch") ||
strings.HasSuffix(s, "sh") {
return s + "es"
}
// Words ending in consonant + y
if len(s) >= 2 && strings.HasSuffix(s, "y") {
prevChar := s[len(s)-2]
if !isVowel(prevChar) {
return s[:len(s)-1] + "ies"
}
}
// Words ending in f or fe
if strings.HasSuffix(s, "f") {
return s[:len(s)-1] + "ves"
}
if strings.HasSuffix(s, "fe") {
return s[:len(s)-2] + "ves"
}
// Words ending in consonant + o
if len(s) >= 2 && strings.HasSuffix(s, "o") {
prevChar := s[len(s)-2]
if !isVowel(prevChar) {
return s + "es"
}
}
// Default: add 's'
return s + "s"
}
// Singularize converts a plural word to singular
// Basic implementation with common English rules
func Singularize(s string) string {
if s == "" {
return ""
}
// Special cases
irregular := map[string]string{
"people": "person",
"children": "child",
"teeth": "tooth",
"feet": "foot",
"men": "man",
"women": "woman",
"mice": "mouse",
"geese": "goose",
"oxen": "ox",
"data": "datum",
"media": "medium",
"analyses": "analysis",
"crises": "crisis",
"statuses": "status",
}
if singular, ok := irregular[strings.ToLower(s)]; ok {
return singular
}
// Words ending in ies
if strings.HasSuffix(s, "ies") && len(s) > 3 {
return s[:len(s)-3] + "y"
}
// Words ending in ves
if strings.HasSuffix(s, "ves") {
return s[:len(s)-3] + "f"
}
// Words ending in ses, xes, zes, ches, shes
if strings.HasSuffix(s, "ses") || strings.HasSuffix(s, "xes") ||
strings.HasSuffix(s, "zes") || strings.HasSuffix(s, "ches") ||
strings.HasSuffix(s, "shes") {
return s[:len(s)-2]
}
// Words ending in s (not ss)
if strings.HasSuffix(s, "s") && !strings.HasSuffix(s, "ss") {
return s[:len(s)-1]
}
// Already singular
return s
}
// Trim trims whitespace from both ends
func Trim(s string) string {
return strings.TrimSpace(s)
}
// TrimPrefix removes the prefix from the string if present
func TrimPrefix(s, prefix string) string {
return strings.TrimPrefix(s, prefix)
}
// TrimSuffix removes the suffix from the string if present
func TrimSuffix(s, suffix string) string {
return strings.TrimSuffix(s, suffix)
}
// Replace replaces occurrences of old with new (n times, or all if n < 0)
func Replace(s, old, newstr string, n int) string {
return strings.Replace(s, old, newstr, n)
}
// StringContains checks if substr is within s
func StringContains(s, substr string) bool {
return strings.Contains(s, substr)
}
// HasPrefix checks if string starts with prefix
func HasPrefix(s, prefix string) bool {
return strings.HasPrefix(s, prefix)
}
// HasSuffix checks if string ends with suffix
func HasSuffix(s, suffix string) bool {
return strings.HasSuffix(s, suffix)
}
// Split splits string by separator
func Split(s, sep string) []string {
return strings.Split(s, sep)
}
// Join joins string slice with separator
func Join(parts []string, sep string) string {
return strings.Join(parts, sep)
}
// capitalize capitalizes the first letter and handles common acronyms
func capitalize(s string) string {
if s == "" {
return ""
}
upper := strings.ToUpper(s)
// Handle common acronyms
acronyms := map[string]bool{
"ID": true,
"UUID": true,
"GUID": true,
"URL": true,
"URI": true,
"HTTP": true,
"HTTPS": true,
"API": true,
"JSON": true,
"XML": true,
"SQL": true,
"HTML": true,
"CSS": true,
"RID": true,
}
if acronyms[upper] {
return upper
}
// Capitalize first letter
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
// isVowel checks if a byte is a vowel
func isVowel(c byte) bool {
c = byte(unicode.ToLower(rune(c)))
return c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u'
}

View File

@@ -0,0 +1,95 @@
package template
import "git.warky.dev/wdevs/relspecgo/pkg/models"
// TemplateData wraps the model data with additional context for template execution
type TemplateData struct {
// One of these will be populated based on execution mode
Database *models.Database
Schema *models.Schema
Script *models.Script
Table *models.Table
// Context information (parent references)
ParentSchema *models.Schema // Set for table/script modes
ParentDatabase *models.Database // Always set for full database context
// Pre-computed views for convenience
FlatColumns []*models.FlatColumn
FlatTables []*models.FlatTable
FlatConstraints []*models.FlatConstraint
FlatRelationships []*models.FlatRelationship
Summary *models.DatabaseSummary
// User metadata from WriterOptions
Metadata map[string]interface{}
}
// NewDatabaseData creates template data for database mode
func NewDatabaseData(db *models.Database, metadata map[string]interface{}) *TemplateData {
return &TemplateData{
Database: db,
ParentDatabase: db,
FlatColumns: db.ToFlatColumns(),
FlatTables: db.ToFlatTables(),
FlatConstraints: db.ToFlatConstraints(),
FlatRelationships: db.ToFlatRelationships(),
Summary: db.ToSummary(),
Metadata: metadata,
}
}
// NewSchemaData creates template data for schema mode
func NewSchemaData(schema *models.Schema, metadata map[string]interface{}) *TemplateData {
// Create a temporary database with just this schema for context
db := models.InitDatabase(schema.Name)
db.Schemas = []*models.Schema{schema}
return &TemplateData{
Schema: schema,
ParentDatabase: db,
FlatColumns: db.ToFlatColumns(),
FlatTables: db.ToFlatTables(),
FlatConstraints: db.ToFlatConstraints(),
FlatRelationships: db.ToFlatRelationships(),
Summary: db.ToSummary(),
Metadata: metadata,
}
}
// NewScriptData creates template data for script mode
func NewScriptData(script *models.Script, schema *models.Schema, db *models.Database, metadata map[string]interface{}) *TemplateData {
return &TemplateData{
Script: script,
ParentSchema: schema,
ParentDatabase: db,
Metadata: metadata,
}
}
// NewTableData creates template data for table mode
func NewTableData(table *models.Table, schema *models.Schema, db *models.Database, metadata map[string]interface{}) *TemplateData {
return &TemplateData{
Table: table,
ParentSchema: schema,
ParentDatabase: db,
Metadata: metadata,
}
}
// Name returns the primary name for the current template data (used for filename generation)
func (td *TemplateData) Name() string {
if td.Database != nil {
return td.Database.Name
}
if td.Schema != nil {
return td.Schema.Name
}
if td.Script != nil {
return td.Script.Name
}
if td.Table != nil {
return td.Table.Name
}
return "output"
}

View File

@@ -0,0 +1,38 @@
package template
import "git.warky.dev/wdevs/relspecgo/pkg/commontypes"
// SQLToGo converts SQL types to Go types
func SQLToGo(sqlType string, nullable bool) string {
return commontypes.SQLToGo(sqlType, nullable)
}
// SQLToTypeScript converts SQL types to TypeScript types
func SQLToTypeScript(sqlType string, nullable bool) string {
return commontypes.SQLToTypeScript(sqlType, nullable)
}
// SQLToJava converts SQL types to Java types
func SQLToJava(sqlType string, nullable bool) string {
return commontypes.SQLToJava(sqlType, nullable)
}
// SQLToPython converts SQL types to Python types
func SQLToPython(sqlType string) string {
return commontypes.SQLToPython(sqlType)
}
// SQLToRust converts SQL types to Rust types
func SQLToRust(sqlType string, nullable bool) string {
return commontypes.SQLToRust(sqlType, nullable)
}
// SQLToCSharp converts SQL types to C# types
func SQLToCSharp(sqlType string, nullable bool) string {
return commontypes.SQLToCSharp(sqlType, nullable)
}
// SQLToPhp converts SQL types to PHP types
func SQLToPhp(sqlType string, nullable bool) string {
return commontypes.SQLToPhp(sqlType, nullable)
}

View File

@@ -0,0 +1,283 @@
package template
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"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"
// 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 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)
}
// executeSchemaMode executes the template once per schema
func (w *Writer) executeSchemaMode(db *models.Database) error {
for _, schema := range db.Schemas {
data := NewSchemaData(schema, w.options.Metadata)
output, err := w.executeTemplate(data)
if err != nil {
return fmt.Errorf("failed to execute template for schema %s: %w", schema.Name, err)
}
filename, err := w.generateFilename(data)
if err != nil {
return fmt.Errorf("failed to generate filename for schema %s: %w", schema.Name, err)
}
if err := w.writeOutput(output, filename); err != nil {
return fmt.Errorf("failed to write output for schema %s: %w", schema.Name, err)
}
}
return nil
}
// executeScriptMode executes the template once per script
func (w *Writer) executeScriptMode(db *models.Database) error {
for _, schema := range db.Schemas {
for _, script := range schema.Scripts {
data := NewScriptData(script, schema, db, w.options.Metadata)
output, err := w.executeTemplate(data)
if err != nil {
return fmt.Errorf("failed to execute template for script %s: %w", script.Name, err)
}
filename, err := w.generateFilename(data)
if err != nil {
return fmt.Errorf("failed to generate filename for script %s: %w", script.Name, err)
}
if err := w.writeOutput(output, filename); err != nil {
return fmt.Errorf("failed to write output for script %s: %w", script.Name, err)
}
}
}
return nil
}
// executeTableMode executes the template once per table
func (w *Writer) executeTableMode(db *models.Database) error {
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
data := NewTableData(table, schema, db, w.options.Metadata)
output, err := w.executeTemplate(data)
if err != nil {
return fmt.Errorf("failed to execute template for table %s.%s: %w", schema.Name, table.Name, err)
}
filename, err := w.generateFilename(data)
if err != nil {
return fmt.Errorf("failed to generate filename for table %s.%s: %w", schema.Name, table.Name, err)
}
if err := w.writeOutput(output, filename); err != nil {
return fmt.Errorf("failed to write output for table %s.%s: %w", schema.Name, table.Name, err)
}
}
}
return nil
}
// 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
}
// generateFilename generates a filename from the filename pattern
func (w *Writer) generateFilename(data *TemplateData) (string, error) {
// Parse filename pattern as a template
tmpl, err := template.New("filename").Funcs(w.funcMap).Parse(w.filenamePattern)
if err != nil {
return "", fmt.Errorf("invalid filename pattern: %w", err)
}
// Execute filename template
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to generate filename: %w", err)
}
filename := buf.String()
// If output path is a directory, join with generated filename
if w.options.OutputPath != "" {
// Check if output path is a directory
info, err := os.Stat(w.options.OutputPath)
if err == nil && info.IsDir() {
filename = filepath.Join(w.options.OutputPath, filename)
} else {
// If it doesn't exist, check if it looks like a directory (ends with /)
if strings.HasSuffix(w.options.OutputPath, string(filepath.Separator)) {
filename = filepath.Join(w.options.OutputPath, filename)
} else {
// Use output path as base directory
dir := filepath.Dir(w.options.OutputPath)
if dir != "." {
filename = filepath.Join(dir, filename)
}
}
}
}
return filename, nil
}
// writeOutput writes the output to a file or stdout
func (w *Writer) writeOutput(content string, 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, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
// Write to file
if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write file %s: %w", outputPath, err)
}
return nil
}