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

@@ -20,4 +20,5 @@ func init() {
rootCmd.AddCommand(diffCmd)
rootCmd.AddCommand(inspectCmd)
rootCmd.AddCommand(scriptsCmd)
rootCmd.AddCommand(templCmd)
}

167
cmd/relspec/templ.go Normal file
View File

@@ -0,0 +1,167 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
wtemplate "git.warky.dev/wdevs/relspecgo/pkg/writers/template"
)
var (
templSourceType string
templSourcePath string
templSourceConn string
templTemplatePath string
templOutputPath string
templSchemaFilter string
templMode string
templFilenamePattern string
)
var templCmd = &cobra.Command{
Use: "templ",
Short: "Apply custom templates to database schemas",
Long: `Apply custom Go text templates to database schemas with flexible execution modes.
The templ command allows you to transform database schemas using custom Go text
templates. It supports multiple execution modes for different use cases:
Execution Modes:
database Execute template once for entire database (single output file)
schema Execute template once per schema (one file per schema)
script Execute template once per script (one file per script)
table Execute template once per table (one file per table)
Supported Input Formats:
dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql
Template Functions:
String utilities: toUpper, toLower, toCamelCase, toPascalCase, toSnakeCase, toKebabCase,
pluralize, singularize, title, trim, split, join, replace
Type conversion: sqlToGo, sqlToTypeScript, sqlToJava, sqlToPython, sqlToRust,
sqlToCSharp, sqlToPhp
Filtering: filterTables, filterColumns, filterPrimaryKeys, filterForeignKeys,
filterNullable, filterNotNull, filterColumnsByType
Formatting: toJSON, toJSONPretty, toYAML, indent, escape, comment
Loop helpers: enumerate, batch, reverse, first, last, skip, take, concat,
unique, sortBy, groupBy
Safe access: get, getOr, getPath, has, keys, values, merge, pick, omit,
sliceContains, indexOf, pluck
Examples:
# Generate documentation from PostgreSQL database
relspec templ --from pgsql --from-conn "postgres://user:pass@localhost/db" \
--template docs.tmpl --output schema-docs.md
# Generate one TypeScript model file per table
relspec templ --from dbml --from-path schema.dbml \
--template ts-model.tmpl --mode table \
--output ./models/ \
--filename-pattern "{{.Name | toCamelCase}}.ts"
# Generate schema documentation files
relspec templ --from json --from-path db.json \
--template schema.tmpl --mode schema \
--output ./docs/ \
--filename-pattern "{{.Name}}_schema.md"`,
RunE: runTempl,
}
func init() {
templCmd.Flags().StringVar(&templSourceType, "from", "", "Source format (dbml, pgsql, json, etc.)")
templCmd.Flags().StringVar(&templSourcePath, "from-path", "", "Source file path (for file-based sources)")
templCmd.Flags().StringVar(&templSourceConn, "from-conn", "", "Source connection string (for database sources)")
templCmd.Flags().StringVar(&templTemplatePath, "template", "", "Template file path (required)")
templCmd.Flags().StringVar(&templOutputPath, "output", "", "Output path (file or directory, empty for stdout)")
templCmd.Flags().StringVar(&templSchemaFilter, "schema", "", "Filter to specific schema")
templCmd.Flags().StringVar(&templMode, "mode", "database", "Execution mode: database, schema, script, or table")
templCmd.Flags().StringVar(&templFilenamePattern, "filename-pattern", "{{.Name}}.txt", "Filename pattern for multi-output modes")
_ = templCmd.MarkFlagRequired("from")
_ = templCmd.MarkFlagRequired("template")
}
func runTempl(cmd *cobra.Command, args []string) error {
// Print header
fmt.Fprintf(os.Stderr, "=== RelSpec Template Execution ===\n")
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
// Read database using the same function as convert
fmt.Fprintf(os.Stderr, "Reading from %s...\n", templSourceType)
db, err := readDatabaseForConvert(templSourceType, templSourcePath, templSourceConn)
if err != nil {
return fmt.Errorf("failed to read source: %w", err)
}
// Print database stats
schemaCount := len(db.Schemas)
tableCount := 0
for _, schema := range db.Schemas {
tableCount += len(schema.Tables)
}
fmt.Fprintf(os.Stderr, "✓ Successfully read database: %s\n", db.Name)
fmt.Fprintf(os.Stderr, " Schemas: %d\n", schemaCount)
fmt.Fprintf(os.Stderr, " Tables: %d\n\n", tableCount)
// Apply schema filter if specified
if templSchemaFilter != "" {
fmt.Fprintf(os.Stderr, "Filtering to schema: %s\n", templSchemaFilter)
found := false
for _, schema := range db.Schemas {
if schema.Name == templSchemaFilter {
db.Schemas = []*models.Schema{schema}
found = true
break
}
}
if !found {
return fmt.Errorf("schema not found: %s", templSchemaFilter)
}
}
// Create template writer
fmt.Fprintf(os.Stderr, "Loading template: %s\n", templTemplatePath)
fmt.Fprintf(os.Stderr, "Execution mode: %s\n", templMode)
metadata := map[string]interface{}{
"template_path": templTemplatePath,
"mode": templMode,
"filename_pattern": templFilenamePattern,
}
writerOpts := &writers.WriterOptions{
OutputPath: templOutputPath,
Metadata: metadata,
}
writer, err := wtemplate.NewWriter(writerOpts)
if err != nil {
return fmt.Errorf("failed to create template writer: %w", err)
}
// Execute template
fmt.Fprintf(os.Stderr, "\nExecuting template...\n")
if err := writer.WriteDatabase(db); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
// Print success message
fmt.Fprintf(os.Stderr, "\n✓ Template executed successfully\n")
if templOutputPath != "" {
fmt.Fprintf(os.Stderr, "Output written to: %s\n", templOutputPath)
} else {
fmt.Fprintf(os.Stderr, "Output written to stdout\n")
}
fmt.Fprintf(os.Stderr, "Completed at: %s\n", getCurrentTimestamp())
return nil
}

552
docs/TEMPLATE_MODE.md Normal file
View File

@@ -0,0 +1,552 @@
# RelSpec Template Mode
The `templ` command allows you to transform database schemas using custom Go text templates. It provides powerful template functions and flexible execution modes for generating any type of output from your database schema.
## Table of Contents
- [Quick Start](#quick-start)
- [Execution Modes](#execution-modes)
- [Template Functions](#template-functions)
- [String Utilities](#string-utilities)
- [Type Conversion](#type-conversion)
- [Filtering](#filtering)
- [Formatting](#formatting)
- [Loop Helpers](#loop-helpers)
- [Sorting Helpers](#sorting-helpers)
- [Safe Access](#safe-access)
- [Utility Functions](#utility-functions)
- [Data Model](#data-model)
- [Examples](#examples)
## Quick Start
```bash
# Generate documentation from a database
relspec templ --from pgsql --from-conn "postgres://user:pass@localhost/db" \
--template docs.tmpl --output schema-docs.md
# Generate TypeScript models (one file per table)
relspec templ --from dbml --from-path schema.dbml \
--template model.tmpl --mode table \
--output ./models/ \
--filename-pattern "{{.Name | toCamelCase}}.ts"
# Output to stdout
relspec templ --from json --from-path schema.json \
--template report.tmpl
```
## Execution Modes
The `--mode` flag controls how the template is executed:
| Mode | Description | Output | When to Use |
|------|-------------|--------|-------------|
| `database` | Execute once for entire database | Single file | Documentation, reports, overview files |
| `schema` | Execute once per schema | One file per schema | Schema-specific documentation |
| `script` | Execute once per script | One file per script | Script processing |
| `table` | Execute once per table | One file per table | Model generation, table docs |
### Filename Patterns
For multi-file modes (`schema`, `script`, `table`), use `--filename-pattern` to control output filenames:
```bash
# Default pattern
--filename-pattern "{{.Name}}.txt"
# With transformations
--filename-pattern "{{.Name | toCamelCase}}.ts"
# Nested directories
--filename-pattern "{{.Schema}}/{{.Name}}.md"
# Complex patterns
--filename-pattern "{{.ParentSchema.Name}}/models/{{.Name | toPascalCase}}Model.java"
```
## Template Functions
### String Utilities
Transform and manipulate strings in your templates.
| Function | Description | Example | Output |
|----------|-------------|---------|--------|
| `toUpper` | Convert to uppercase | `{{ "hello" \| toUpper }}` | `HELLO` |
| `toLower` | Convert to lowercase | `{{ "HELLO" \| toLower }}` | `hello` |
| `toCamelCase` | Convert to camelCase | `{{ "user_name" \| toCamelCase }}` | `userName` |
| `toPascalCase` | Convert to PascalCase | `{{ "user_name" \| toPascalCase }}` | `UserName` |
| `toSnakeCase` | Convert to snake_case | `{{ "UserName" \| toSnakeCase }}` | `user_name` |
| `toKebabCase` | Convert to kebab-case | `{{ "UserName" \| toKebabCase }}` | `user-name` |
| `pluralize` | Convert to plural | `{{ "user" \| pluralize }}` | `users` |
| `singularize` | Convert to singular | `{{ "users" \| singularize }}` | `user` |
| `title` | Capitalize first letter | `{{ "hello world" \| title }}` | `Hello World` |
| `trim` | Trim whitespace | `{{ " hello " \| trim }}` | `hello` |
| `trimPrefix` | Remove prefix | `{{ trimPrefix "tbl_users" "tbl_" }}` | `users` |
| `trimSuffix` | Remove suffix | `{{ trimSuffix "users_old" "_old" }}` | `users` |
| `replace` | Replace occurrences | `{{ replace "hello" "l" "L" -1 }}` | `heLLo` |
| `stringContains` | Check if contains substring | `{{ stringContains "hello" "ell" }}` | `true` |
| `hasPrefix` | Check if starts with | `{{ hasPrefix "hello" "hel" }}` | `true` |
| `hasSuffix` | Check if ends with | `{{ hasSuffix "hello" "llo" }}` | `true` |
| `split` | Split by separator | `{{ split "a,b,c" "," }}` | `[a b c]` |
| `join` | Join with separator | `{{ join (list "a" "b") "," }}` | `a,b` |
### Type Conversion
Convert SQL types to various programming language types.
| Function | Parameters | Description | Example |
|----------|------------|-------------|---------|
| `sqlToGo` | `sqlType`, `nullable` | SQL to Go | `{{ sqlToGo "varchar" true }}``string` |
| `sqlToTypeScript` | `sqlType`, `nullable` | SQL to TypeScript | `{{ sqlToTypeScript "integer" false }}``number \| null` |
| `sqlToJava` | `sqlType`, `nullable` | SQL to Java | `{{ sqlToJava "varchar" true }}``String` |
| `sqlToPython` | `sqlType` | SQL to Python | `{{ sqlToPython "integer" }}``int` |
| `sqlToRust` | `sqlType`, `nullable` | SQL to Rust | `{{ sqlToRust "varchar" false }}``Option<String>` |
| `sqlToCSharp` | `sqlType`, `nullable` | SQL to C# | `{{ sqlToCSharp "integer" false }}``int?` |
| `sqlToPhp` | `sqlType`, `nullable` | SQL to PHP | `{{ sqlToPhp "varchar" false }}``?string` |
**Supported SQL Types:**
- Integer: `integer`, `int`, `smallint`, `bigint`, `serial`, `bigserial`
- String: `text`, `varchar`, `char`, `character`, `citext`
- Boolean: `boolean`, `bool`
- Float: `real`, `float`, `double precision`, `numeric`, `decimal`
- Date/Time: `timestamp`, `date`, `time`, `timestamptz`
- Binary: `bytea`
- Special: `uuid`, `json`, `jsonb`, `array`
### Filtering
Filter and select specific database objects.
| Function | Description | Example |
|----------|-------------|---------|
| `filterTables` | Filter tables by pattern | `{{ filterTables .Schema.Tables "user_*" }}` |
| `filterTablesByPattern` | Alias for filterTables | `{{ filterTablesByPattern .Schema.Tables "temp_*" }}` |
| `filterColumns` | Filter columns by pattern | `{{ filterColumns .Table.Columns "*_id" }}` |
| `filterColumnsByType` | Filter by SQL type | `{{ filterColumnsByType .Table.Columns "varchar" }}` |
| `filterPrimaryKeys` | Get primary key columns | `{{ filterPrimaryKeys .Table.Columns }}` |
| `filterForeignKeys` | Get foreign key constraints | `{{ filterForeignKeys .Table.Constraints }}` |
| `filterUniqueConstraints` | Get unique constraints | `{{ filterUniqueConstraints .Table.Constraints }}` |
| `filterCheckConstraints` | Get check constraints | `{{ filterCheckConstraints .Table.Constraints }}` |
| `filterNullable` | Get nullable columns | `{{ filterNullable .Table.Columns }}` |
| `filterNotNull` | Get non-nullable columns | `{{ filterNotNull .Table.Columns }}` |
**Pattern Matching:**
- `*` - Match any characters
- `?` - Match single character
- Example: `user_*` matches `user_profile`, `user_settings`
### Formatting
Format output and add structure to generated code.
| Function | Description | Example |
|----------|-------------|---------|
| `toJSON` | Convert to JSON | `{{ .Database \| toJSON }}` |
| `toJSONPretty` | Pretty-print JSON | `{{ toJSONPretty .Table " " }}` |
| `toYAML` | Convert to YAML | `{{ .Schema \| toYAML }}` |
| `indent` | Indent by spaces | `{{ indent .Column.Description 4 }}` |
| `indentWith` | Indent with prefix | `{{ indentWith .Comment " " }}` |
| `escape` | Escape special chars | `{{ escape .Column.Default }}` |
| `escapeQuotes` | Escape quotes only | `{{ escapeQuotes .String }}` |
| `comment` | Add comment prefix | `{{ comment .Description "//" }}` |
| `quoteString` | Add quotes | `{{ quoteString "value" }}``"value"` |
| `unquoteString` | Remove quotes | `{{ unquoteString "\"value\"" }}``value` |
**Comment Styles:**
- `//` - C/Go/JavaScript style
- `#` - Python/Shell style
- `--` - SQL style
- `/* */` - Block comment style
### Loop Helpers
Iterate and manipulate collections.
| Function | Description | Example |
|----------|-------------|---------|
| `enumerate` | Add index to items | `{{ range enumerate .Tables }}{{ .Index }}: {{ .Value.Name }}{{ end }}` |
| `batch` | Split into chunks | `{{ range batch .Columns 3 }}...{{ end }}` |
| `chunk` | Alias for batch | `{{ range chunk .Columns 5 }}...{{ end }}` |
| `reverse` | Reverse order | `{{ range reverse .Tables }}...{{ end }}` |
| `first` | Get first N items | `{{ range first .Tables 5 }}...{{ end }}` |
| `last` | Get last N items | `{{ range last .Tables 3 }}...{{ end }}` |
| `skip` | Skip first N items | `{{ range skip .Tables 2 }}...{{ end }}` |
| `take` | Take first N (alias) | `{{ range take .Tables 10 }}...{{ end }}` |
| `concat` | Concatenate slices | `{{ $all := concat .Schema1.Tables .Schema2.Tables }}` |
| `unique` | Remove duplicates | `{{ $unique := unique .Items }}` |
| `sortBy` | Sort by field | `{{ $sorted := sortBy .Tables "Name" }}` |
| `groupBy` | Group by field | `{{ $grouped := groupBy .Tables "Schema" }}` |
### Sorting Helpers
Sort database objects by name or sequence number. All sort functions modify the slice in-place.
**Schema Sorting:**
| Function | Description | Example |
|----------|-------------|---------|
| `sortSchemasByName` | Sort schemas by name | `{{ sortSchemasByName .Database.Schemas false }}` |
| `sortSchemasBySequence` | Sort schemas by sequence | `{{ sortSchemasBySequence .Database.Schemas false }}` |
**Table Sorting:**
| Function | Description | Example |
|----------|-------------|---------|
| `sortTablesByName` | Sort tables by name | `{{ sortTablesByName .Schema.Tables false }}` |
| `sortTablesBySequence` | Sort tables by sequence | `{{ sortTablesBySequence .Schema.Tables true }}` |
**Column Sorting:**
| Function | Description | Example |
|----------|-------------|---------|
| `sortColumnsMapByName` | Convert column map to sorted slice by name | `{{ $cols := sortColumnsMapByName .Table.Columns false }}` |
| `sortColumnsMapBySequence` | Convert column map to sorted slice by sequence | `{{ $cols := sortColumnsMapBySequence .Table.Columns false }}` |
| `sortColumnsByName` | Sort column slice by name | `{{ sortColumnsByName $columns false }}` |
| `sortColumnsBySequence` | Sort column slice by sequence | `{{ sortColumnsBySequence $columns true }}` |
**Other Object Sorting:**
| Function | Description | Example |
|----------|-------------|---------|
| `sortViewsByName` | Sort views by name | `{{ sortViewsByName .Schema.Views false }}` |
| `sortViewsBySequence` | Sort views by sequence | `{{ sortViewsBySequence .Schema.Views false }}` |
| `sortSequencesByName` | Sort sequences by name | `{{ sortSequencesByName .Schema.Sequences false }}` |
| `sortSequencesBySequence` | Sort sequences by sequence | `{{ sortSequencesBySequence .Schema.Sequences false }}` |
| `sortIndexesMapByName` | Convert index map to sorted slice by name | `{{ $idx := sortIndexesMapByName .Table.Indexes false }}` |
| `sortIndexesMapBySequence` | Convert index map to sorted slice by sequence | `{{ $idx := sortIndexesMapBySequence .Table.Indexes false }}` |
| `sortIndexesByName` | Sort index slice by name | `{{ sortIndexesByName $indexes false }}` |
| `sortIndexesBySequence` | Sort index slice by sequence | `{{ sortIndexesBySequence $indexes false }}` |
| `sortConstraintsMapByName` | Convert constraint map to sorted slice by name | `{{ $cons := sortConstraintsMapByName .Table.Constraints false }}` |
| `sortConstraintsByName` | Sort constraint slice by name | `{{ sortConstraintsByName $constraints false }}` |
| `sortRelationshipsMapByName` | Convert relationship map to sorted slice by name | `{{ $rels := sortRelationshipsMapByName .Table.Relationships false }}` |
| `sortRelationshipsByName` | Sort relationship slice by name | `{{ sortRelationshipsByName $relationships false }}` |
| `sortScriptsByName` | Sort scripts by name | `{{ sortScriptsByName .Schema.Scripts false }}` |
| `sortEnumsByName` | Sort enums by name | `{{ sortEnumsByName .Schema.Enums false }}` |
**Sort Parameters:**
- Second parameter: `false` = ascending, `true` = descending
- Example: `{{ sortTablesByName .Schema.Tables true }}` sorts descending (Z-A)
### Safe Access
Safely access nested data without panicking.
| Function | Description | Example |
|----------|-------------|---------|
| `get` | Get map value | `{{ get .Metadata "key" }}` |
| `getOr` | Get with default | `{{ getOr .Metadata "key" "default" }}` |
| `getPath` | Nested access | `{{ getPath .Config "database.host" }}` |
| `getPathOr` | Nested with default | `{{ getPathOr .Config "db.port" 5432 }}` |
| `safeIndex` | Safe array access | `{{ safeIndex .Tables 0 }}` |
| `safeIndexOr` | Safe with default | `{{ safeIndexOr .Tables 0 nil }}` |
| `has` | Check key exists | `{{ if has .Metadata "key" }}...{{ end }}` |
| `hasPath` | Check nested path | `{{ if hasPath .Config "db.host" }}...{{ end }}` |
| `keys` | Get map keys | `{{ range keys .Metadata }}...{{ end }}` |
| `values` | Get map values | `{{ range values .Table.Columns }}...{{ end }}` |
| `merge` | Merge maps | `{{ $merged := merge .Map1 .Map2 }}` |
| `pick` | Select keys | `{{ $subset := pick .Metadata "name" "desc" }}` |
| `omit` | Exclude keys | `{{ $filtered := omit .Metadata "internal" }}` |
| `sliceContains` | Check contains | `{{ if sliceContains .Names "admin" }}...{{ end }}` |
| `indexOf` | Find index | `{{ $idx := indexOf .Names "admin" }}` |
| `pluck` | Extract field | `{{ $names := pluck .Tables "Name" }}` |
### Utility Functions
General-purpose template helpers.
| Function | Description | Example |
|----------|-------------|---------|
| `add` | Add numbers | `{{ add 5 3 }}``8` |
| `sub` | Subtract | `{{ sub 10 3 }}``7` |
| `mul` | Multiply | `{{ mul 4 5 }}``20` |
| `div` | Divide | `{{ div 10 2 }}``5` |
| `mod` | Modulo | `{{ mod 10 3 }}``1` |
| `default` | Default value | `{{ default "unknown" .Name }}` |
| `dict` | Create map | `{{ $m := dict "key1" "val1" "key2" "val2" }}` |
| `list` | Create list | `{{ $l := list "a" "b" "c" }}` |
| `seq` | Number sequence | `{{ range seq 1 5 }}{{ . }}{{ end }}``12345` |
## Data Model
The data available in templates depends on the execution mode:
### Database Mode
```go
.Database // *models.Database - Full database
.ParentDatabase // *models.Database - Same as .Database
.FlatColumns // []*models.FlatColumn - All columns flattened
.FlatTables // []*models.FlatTable - All tables flattened
.FlatConstraints // []*models.FlatConstraint - All constraints
.FlatRelationships // []*models.FlatRelationship - All relationships
.Summary // *models.DatabaseSummary - Statistics
.Metadata // map[string]interface{} - User metadata
```
### Schema Mode
```go
.Schema // *models.Schema - Current schema
.ParentDatabase // *models.Database - Parent database context
.FlatColumns // []*models.FlatColumn - Schema's columns flattened
.FlatTables // []*models.FlatTable - Schema's tables flattened
.FlatConstraints // []*models.FlatConstraint - Schema's constraints
.FlatRelationships // []*models.FlatRelationship - Schema's relationships
.Summary // *models.DatabaseSummary - Statistics
.Metadata // map[string]interface{} - User metadata
```
### Table Mode
```go
.Table // *models.Table - Current table
.ParentSchema // *models.Schema - Parent schema
.ParentDatabase // *models.Database - Parent database context
.Metadata // map[string]interface{} - User metadata
```
### Script Mode
```go
.Script // *models.Script - Current script
.ParentSchema // *models.Schema - Parent schema
.ParentDatabase // *models.Database - Parent database context
.Metadata // map[string]interface{} - User metadata
```
### Model Structures
**Database:**
- `.Name` - Database name
- `.Schemas` - List of schemas
- `.Description`, `.Comment` - Documentation
**Schema:**
- `.Name` - Schema name
- `.Tables` - List of tables
- `.Views`, `.Sequences`, `.Scripts` - Other objects
- `.Enums` - Enum types
**Table:**
- `.Name` - Table name
- `.Schema` - Schema name
- `.Columns` - Map of columns (use `values` function to iterate)
- `.Constraints` - Map of constraints
- `.Indexes` - Map of indexes
- `.Relationships` - Map of relationships
- `.Description`, `.Comment` - Documentation
**Column:**
- `.Name` - Column name
- `.Type` - SQL type
- `.NotNull` - Is NOT NULL
- `.IsPrimaryKey` - Is primary key
- `.Default` - Default value
- `.Description`, `.Comment` - Documentation
## Examples
### Example 1: TypeScript Interfaces (Table Mode)
**Template:** `typescript-interface.tmpl`
```typescript
// Generated from {{ .ParentDatabase.Name }}.{{ .ParentSchema.Name }}.{{ .Table.Name }}
export interface {{ .Table.Name | toPascalCase }} {
{{- range .Table.Columns | values }}
{{ .Name | toCamelCase }}: {{ sqlToTypeScript .Type .NotNull }};
{{- end }}
}
{{- $fks := filterForeignKeys .Table.Constraints }}
{{- if $fks }}
// Foreign Keys:
{{- range $fks }}
// - {{ .Name }}: references {{ .ReferencedTable }}
{{- end }}
{{- end }}
```
**Command:**
```bash
relspec templ --from pgsql --from-conn "..." \
--template typescript-interface.tmpl \
--mode table \
--output ./src/types/ \
--filename-pattern "{{.Name | toCamelCase}}.ts"
```
### Example 2: Markdown Documentation (Database Mode)
**Template:** `database-docs.tmpl`
```markdown
# Database: {{ .Database.Name }}
{{ if .Database.Description }}{{ .Database.Description }}{{ end }}
**Statistics:**
- Schemas: {{ len .Database.Schemas }}
- Tables: {{ .Summary.TotalTables }}
- Columns: {{ .Summary.TotalColumns }}
{{ range .Database.Schemas }}
## Schema: {{ .Name }}
{{ range .Tables }}
### {{ .Name }}
{{ if .Description }}{{ .Description }}{{ end }}
**Columns:**
| Column | Type | Nullable | PK | Description |
|--------|------|----------|----|----|
{{- range .Columns | values }}
| {{ .Name }} | `{{ .Type }}` | {{ if .NotNull }}No{{ else }}Yes{{ end }} | {{ if .IsPrimaryKey }}✓{{ end }} | {{ .Description }} |
{{- end }}
{{- $fks := filterForeignKeys .Constraints }}
{{- if $fks }}
**Foreign Keys:**
{{ range $fks }}
- `{{ .Name }}`: {{ join .Columns ", " }} → {{ .ReferencedTable }}({{ join .ReferencedColumns ", " }})
{{- end }}
{{- end }}
{{ end }}
{{ end }}
```
### Example 3: Python SQLAlchemy Models (Table Mode)
**Template:** `python-model.tmpl`
```python
"""{{ .Table.Name | toPascalCase }} model for {{ .ParentDatabase.Name }}.{{ .ParentSchema.Name }}"""
from sqlalchemy import Column
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class {{ .Table.Name | toPascalCase }}(Base):
"""{{ if .Table.Description }}{{ .Table.Description }}{{ else }}{{ .Table.Name }} table{{ end }}"""
__tablename__ = "{{ .Table.Name }}"
__table_args__ = {"schema": "{{ .ParentSchema.Name }}"}
{{- range .Table.Columns | values }}
{{ .Name }} = Column({{ sqlToPython .Type }}{{ if .IsPrimaryKey }}, primary_key=True{{ end }}{{ if .NotNull }}, nullable=False{{ end }})
{{- end }}
```
### Example 4: GraphQL Schema (Schema Mode)
**Template:** `graphql-schema.tmpl`
```graphql
"""{{ .Schema.Name }} schema"""
{{ range .Schema.Tables }}
type {{ .Name | toPascalCase }} {
{{- range .Columns | values }}
{{ .Name | toCamelCase }}: {{ sqlToTypeScript .Type .NotNull | replace " | null" "" }}{{ if not .NotNull }}{{ end }}
{{- end }}
}
input {{ .Name | toPascalCase }}Input {
{{- $cols := filterNotNull .Columns | filterPrimaryKeys }}
{{- range $cols }}
{{ .Name | toCamelCase }}: {{ sqlToTypeScript .Type true | replace " | null" "" }}!
{{- end }}
}
{{ end }}
```
### Example 5: SQL Migration (Database Mode)
**Template:** `migration.tmpl`
```sql
-- Migration for {{ .Database.Name }}
-- Generated: {{ .Metadata.timestamp }}
BEGIN;
{{ range .Database.Schemas }}
-- Schema: {{ .Name }}
CREATE SCHEMA IF NOT EXISTS {{ .Name }};
{{ range .Tables }}
CREATE TABLE {{ $.Database.Name }}.{{ .Schema }}.{{ .Name }} (
{{- range $i, $col := .Columns | values }}
{{- if $i }},{{ end }}
{{ $col.Name }} {{ $col.Type }}{{ if $col.NotNull }} NOT NULL{{ end }}{{ if $col.Default }} DEFAULT {{ $col.Default }}{{ end }}
{{- end }}
);
{{- $pks := filterPrimaryKeys .Columns }}
{{- if $pks }}
ALTER TABLE {{ $.Database.Name }}.{{ .Schema }}.{{ .Name }}
ADD PRIMARY KEY ({{ range $i, $pk := $pks }}{{ if $i }}, {{ end }}{{ $pk.Name }}{{ end }});
{{- end }}
{{ end }}
{{ end }}
COMMIT;
```
## Best Practices
1. **Use Hyphen for Whitespace Control:**
```
{{- removes whitespace before
-}} removes whitespace after
```
2. **Store Intermediate Results:**
```
{{ $pks := filterPrimaryKeys .Table.Columns }}
{{ if $pks }}...{{ end }}
```
3. **Check Before Accessing:**
```
{{ if .Table.Description }}{{ .Table.Description }}{{ end }}
```
4. **Use Safe Access for Maps:**
```
{{ getOr .Metadata "key" "default-value" }}
```
5. **Iterate Map Values:**
```
{{ range .Table.Columns | values }}...{{ end }}
```
## Troubleshooting
**Error: "wrong type for value"**
- Check function parameter order (e.g., `sqlToGo .Type .NotNull` not `.NotNull .Type`)
**Error: "can't evaluate field"**
- Field doesn't exist on the object
- Use `{{ if .Field }}` to check before accessing
**Empty Output:**
- Check your mode matches your template expectations
- Verify data exists (use `{{ .Database | toJSON }}` to inspect)
**Whitespace Issues:**
- Use `{{-` and `-}}` to control whitespace
- Run output through a formatter if needed
## Additional Resources
- [Go Template Documentation](https://pkg.go.dev/text/template)
- [RelSpec Documentation](../README.md)
- [Model Structure Reference](../pkg/models/)
- [Example Templates](../examples/templates/)

74
pkg/commontypes/csharp.go Normal file
View File

@@ -0,0 +1,74 @@
package commontypes
import "strings"
// CSharpTypeMap maps PostgreSQL types to C# types
var CSharpTypeMap = map[string]string{
// Integer types
"integer": "int",
"int": "int",
"int4": "int",
"smallint": "short",
"int2": "short",
"bigint": "long",
"int8": "long",
"serial": "int",
"bigserial": "long",
"smallserial": "short",
// String types
"text": "string",
"varchar": "string",
"char": "string",
"character": "string",
"citext": "string",
"bpchar": "string",
"uuid": "Guid",
// Boolean
"boolean": "bool",
"bool": "bool",
// Float types
"real": "float",
"float4": "float",
"double precision": "double",
"float8": "double",
"numeric": "decimal",
"decimal": "decimal",
// Date/Time types
"timestamp": "DateTime",
"timestamp without time zone": "DateTime",
"timestamp with time zone": "DateTimeOffset",
"timestamptz": "DateTimeOffset",
"date": "DateTime",
"time": "TimeSpan",
"time without time zone": "TimeSpan",
"time with time zone": "DateTimeOffset",
"timetz": "DateTimeOffset",
// Binary
"bytea": "byte[]",
// JSON
"json": "string",
"jsonb": "string",
}
// SQLToCSharp converts SQL types to C# types
func SQLToCSharp(sqlType string, nullable bool) string {
baseType := ExtractBaseType(sqlType)
csType, ok := CSharpTypeMap[baseType]
if !ok {
csType = "object"
}
// Handle nullable value types (reference types are already nullable)
if !nullable && csType != "string" && !strings.HasSuffix(csType, "[]") && csType != "object" {
return csType + "?"
}
return csType
}

89
pkg/commontypes/golang.go Normal file
View File

@@ -0,0 +1,89 @@
package commontypes
import "strings"
// GoTypeMap maps PostgreSQL types to Go types
var GoTypeMap = map[string]string{
// Integer types
"integer": "int32",
"int": "int32",
"int4": "int32",
"smallint": "int16",
"int2": "int16",
"bigint": "int64",
"int8": "int64",
"serial": "int32",
"bigserial": "int64",
"smallserial": "int16",
// String types
"text": "string",
"varchar": "string",
"char": "string",
"character": "string",
"citext": "string",
"bpchar": "string",
// Boolean
"boolean": "bool",
"bool": "bool",
// Float types
"real": "float32",
"float4": "float32",
"double precision": "float64",
"float8": "float64",
"numeric": "float64",
"decimal": "float64",
// Date/Time types
"timestamp": "time.Time",
"timestamp without time zone": "time.Time",
"timestamp with time zone": "time.Time",
"timestamptz": "time.Time",
"date": "time.Time",
"time": "time.Time",
"time without time zone": "time.Time",
"time with time zone": "time.Time",
"timetz": "time.Time",
// Binary
"bytea": "[]byte",
// UUID
"uuid": "string",
// JSON
"json": "string",
"jsonb": "string",
// Array
"array": "[]string",
}
// SQLToGo converts SQL types to Go types
func SQLToGo(sqlType string, nullable bool) string {
baseType := ExtractBaseType(sqlType)
goType, ok := GoTypeMap[baseType]
if !ok {
goType = "interface{}"
}
// Handle nullable types
if nullable {
return goType
}
// For nullable, use pointer types (except for slices and interfaces)
if !strings.HasPrefix(goType, "[]") && goType != "interface{}" {
return "*" + goType
}
return goType
}
// NeedsTimeImport checks if a Go type requires the time package
func NeedsTimeImport(goType string) bool {
return strings.Contains(goType, "time.Time")
}

68
pkg/commontypes/java.go Normal file
View File

@@ -0,0 +1,68 @@
package commontypes
// JavaTypeMap maps PostgreSQL types to Java types
var JavaTypeMap = map[string]string{
// Integer types
"integer": "Integer",
"int": "Integer",
"int4": "Integer",
"smallint": "Short",
"int2": "Short",
"bigint": "Long",
"int8": "Long",
"serial": "Integer",
"bigserial": "Long",
"smallserial": "Short",
// String types
"text": "String",
"varchar": "String",
"char": "String",
"character": "String",
"citext": "String",
"bpchar": "String",
"uuid": "UUID",
// Boolean
"boolean": "Boolean",
"bool": "Boolean",
// Float types
"real": "Float",
"float4": "Float",
"double precision": "Double",
"float8": "Double",
"numeric": "BigDecimal",
"decimal": "BigDecimal",
// Date/Time types
"timestamp": "Timestamp",
"timestamp without time zone": "Timestamp",
"timestamp with time zone": "Timestamp",
"timestamptz": "Timestamp",
"date": "Date",
"time": "Time",
"time without time zone": "Time",
"time with time zone": "Time",
"timetz": "Time",
// Binary
"bytea": "byte[]",
// JSON
"json": "String",
"jsonb": "String",
}
// SQLToJava converts SQL types to Java types
func SQLToJava(sqlType string, nullable bool) string {
baseType := ExtractBaseType(sqlType)
javaType, ok := JavaTypeMap[baseType]
if !ok {
javaType = "Object"
}
// Java uses wrapper classes for nullable types by default
return javaType
}

72
pkg/commontypes/php.go Normal file
View File

@@ -0,0 +1,72 @@
package commontypes
// PHPTypeMap maps PostgreSQL types to PHP types
var PHPTypeMap = map[string]string{
// Integer types
"integer": "int",
"int": "int",
"int4": "int",
"smallint": "int",
"int2": "int",
"bigint": "int",
"int8": "int",
"serial": "int",
"bigserial": "int",
"smallserial": "int",
// String types
"text": "string",
"varchar": "string",
"char": "string",
"character": "string",
"citext": "string",
"bpchar": "string",
"uuid": "string",
// Boolean
"boolean": "bool",
"bool": "bool",
// Float types
"real": "float",
"float4": "float",
"double precision": "float",
"float8": "float",
"numeric": "float",
"decimal": "float",
// Date/Time types
"timestamp": "\\DateTime",
"timestamp without time zone": "\\DateTime",
"timestamp with time zone": "\\DateTime",
"timestamptz": "\\DateTime",
"date": "\\DateTime",
"time": "\\DateTime",
"time without time zone": "\\DateTime",
"time with time zone": "\\DateTime",
"timetz": "\\DateTime",
// Binary
"bytea": "string",
// JSON
"json": "array",
"jsonb": "array",
}
// SQLToPhp converts SQL types to PHP types
func SQLToPhp(sqlType string, nullable bool) string {
baseType := ExtractBaseType(sqlType)
phpType, ok := PHPTypeMap[baseType]
if !ok {
phpType = "mixed"
}
// PHP 7.1+ supports nullable types with ?Type syntax
if !nullable && phpType != "mixed" {
return "?" + phpType
}
return phpType
}

71
pkg/commontypes/python.go Normal file
View File

@@ -0,0 +1,71 @@
package commontypes
// PythonTypeMap maps PostgreSQL types to Python types
var PythonTypeMap = map[string]string{
// Integer types
"integer": "int",
"int": "int",
"int4": "int",
"smallint": "int",
"int2": "int",
"bigint": "int",
"int8": "int",
"serial": "int",
"bigserial": "int",
"smallserial": "int",
// String types
"text": "str",
"varchar": "str",
"char": "str",
"character": "str",
"citext": "str",
"bpchar": "str",
"uuid": "UUID",
// Boolean
"boolean": "bool",
"bool": "bool",
// Float types
"real": "float",
"float4": "float",
"double precision": "float",
"float8": "float",
"numeric": "Decimal",
"decimal": "Decimal",
// Date/Time types
"timestamp": "datetime",
"timestamp without time zone": "datetime",
"timestamp with time zone": "datetime",
"timestamptz": "datetime",
"date": "date",
"time": "time",
"time without time zone": "time",
"time with time zone": "time",
"timetz": "time",
// Binary
"bytea": "bytes",
// JSON
"json": "dict",
"jsonb": "dict",
// Array
"array": "list",
}
// SQLToPython converts SQL types to Python types
func SQLToPython(sqlType string) string {
baseType := ExtractBaseType(sqlType)
pyType, ok := PythonTypeMap[baseType]
if !ok {
pyType = "Any"
}
// Python uses Optional[Type] for nullable, but we return the base type
return pyType
}

72
pkg/commontypes/rust.go Normal file
View File

@@ -0,0 +1,72 @@
package commontypes
// RustTypeMap maps PostgreSQL types to Rust types
var RustTypeMap = map[string]string{
// Integer types
"integer": "i32",
"int": "i32",
"int4": "i32",
"smallint": "i16",
"int2": "i16",
"bigint": "i64",
"int8": "i64",
"serial": "i32",
"bigserial": "i64",
"smallserial": "i16",
// String types
"text": "String",
"varchar": "String",
"char": "String",
"character": "String",
"citext": "String",
"bpchar": "String",
"uuid": "String",
// Boolean
"boolean": "bool",
"bool": "bool",
// Float types
"real": "f32",
"float4": "f32",
"double precision": "f64",
"float8": "f64",
"numeric": "f64",
"decimal": "f64",
// Date/Time types (using chrono crate)
"timestamp": "NaiveDateTime",
"timestamp without time zone": "NaiveDateTime",
"timestamp with time zone": "DateTime<Utc>",
"timestamptz": "DateTime<Utc>",
"date": "NaiveDate",
"time": "NaiveTime",
"time without time zone": "NaiveTime",
"time with time zone": "DateTime<Utc>",
"timetz": "DateTime<Utc>",
// Binary
"bytea": "Vec<u8>",
// JSON
"json": "serde_json::Value",
"jsonb": "serde_json::Value",
}
// SQLToRust converts SQL types to Rust types
func SQLToRust(sqlType string, nullable bool) string {
baseType := ExtractBaseType(sqlType)
rustType, ok := RustTypeMap[baseType]
if !ok {
rustType = "String"
}
// Handle nullable types with Option<T>
if nullable {
return rustType
}
return "Option<" + rustType + ">"
}

22
pkg/commontypes/sql.go Normal file
View File

@@ -0,0 +1,22 @@
package commontypes
import "strings"
// ExtractBaseType extracts the base type from a SQL type string
// Examples: varchar(100) → varchar, numeric(10,2) → numeric
func ExtractBaseType(sqlType string) string {
sqlType = strings.ToLower(strings.TrimSpace(sqlType))
// Remove everything after '('
if idx := strings.Index(sqlType, "("); idx > 0 {
sqlType = sqlType[:idx]
}
return sqlType
}
// NormalizeType normalizes a SQL type to its base form
// Alias for ExtractBaseType for backwards compatibility
func NormalizeType(sqlType string) string {
return ExtractBaseType(sqlType)
}

View File

@@ -0,0 +1,75 @@
package commontypes
// TypeScriptTypeMap maps PostgreSQL types to TypeScript types
var TypeScriptTypeMap = map[string]string{
// Integer types
"integer": "number",
"int": "number",
"int4": "number",
"smallint": "number",
"int2": "number",
"bigint": "number",
"int8": "number",
"serial": "number",
"bigserial": "number",
"smallserial": "number",
// String types
"text": "string",
"varchar": "string",
"char": "string",
"character": "string",
"citext": "string",
"bpchar": "string",
"uuid": "string",
// Boolean
"boolean": "boolean",
"bool": "boolean",
// Float types
"real": "number",
"float4": "number",
"double precision": "number",
"float8": "number",
"numeric": "number",
"decimal": "number",
// Date/Time types
"timestamp": "Date",
"timestamp without time zone": "Date",
"timestamp with time zone": "Date",
"timestamptz": "Date",
"date": "Date",
"time": "Date",
"time without time zone": "Date",
"time with time zone": "Date",
"timetz": "Date",
// Binary
"bytea": "Buffer",
// JSON
"json": "any",
"jsonb": "any",
// Array
"array": "any[]",
}
// SQLToTypeScript converts SQL types to TypeScript types
func SQLToTypeScript(sqlType string, nullable bool) string {
baseType := ExtractBaseType(sqlType)
tsType, ok := TypeScriptTypeMap[baseType]
if !ok {
tsType = "any"
}
// Handle nullable types
if nullable {
return tsType
}
return tsType + " | null"
}

266
pkg/models/sorting.go Normal file
View File

@@ -0,0 +1,266 @@
package models
import (
"sort"
"strings"
)
// SortOrder represents the sort direction
type SortOrder bool
const (
// Ascending sort order
Ascending SortOrder = false
// Descending sort order
Descending SortOrder = true
)
// Schema Sorting
// SortSchemasByName sorts schemas by name
func SortSchemasByName(schemas []*Schema, desc bool) {
sort.SliceStable(schemas, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(schemas[i].Name), strings.ToLower(schemas[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}
// SortSchemasBySequence sorts schemas by sequence number
func SortSchemasBySequence(schemas []*Schema, desc bool) {
sort.SliceStable(schemas, func(i, j int) bool {
if desc {
return schemas[i].Sequence > schemas[j].Sequence
}
return schemas[i].Sequence < schemas[j].Sequence
})
}
// Table Sorting
// SortTablesByName sorts tables by name
func SortTablesByName(tables []*Table, desc bool) {
sort.SliceStable(tables, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(tables[i].Name), strings.ToLower(tables[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}
// SortTablesBySequence sorts tables by sequence number
func SortTablesBySequence(tables []*Table, desc bool) {
sort.SliceStable(tables, func(i, j int) bool {
if desc {
return tables[i].Sequence > tables[j].Sequence
}
return tables[i].Sequence < tables[j].Sequence
})
}
// Column Sorting
// SortColumnsMapByName converts column map to sorted slice by name
func SortColumnsMapByName(columns map[string]*Column, desc bool) []*Column {
result := make([]*Column, 0, len(columns))
for _, col := range columns {
result = append(result, col)
}
SortColumnsByName(result, desc)
return result
}
// SortColumnsMapBySequence converts column map to sorted slice by sequence
func SortColumnsMapBySequence(columns map[string]*Column, desc bool) []*Column {
result := make([]*Column, 0, len(columns))
for _, col := range columns {
result = append(result, col)
}
SortColumnsBySequence(result, desc)
return result
}
// SortColumnsByName sorts columns by name
func SortColumnsByName(columns []*Column, desc bool) {
sort.SliceStable(columns, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(columns[i].Name), strings.ToLower(columns[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}
// SortColumnsBySequence sorts columns by sequence number
func SortColumnsBySequence(columns []*Column, desc bool) {
sort.SliceStable(columns, func(i, j int) bool {
if desc {
return columns[i].Sequence > columns[j].Sequence
}
return columns[i].Sequence < columns[j].Sequence
})
}
// View Sorting
// SortViewsByName sorts views by name
func SortViewsByName(views []*View, desc bool) {
sort.SliceStable(views, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(views[i].Name), strings.ToLower(views[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}
// SortViewsBySequence sorts views by sequence number
func SortViewsBySequence(views []*View, desc bool) {
sort.SliceStable(views, func(i, j int) bool {
if desc {
return views[i].Sequence > views[j].Sequence
}
return views[i].Sequence < views[j].Sequence
})
}
// Sequence Sorting
// SortSequencesByName sorts sequences by name
func SortSequencesByName(sequences []*Sequence, desc bool) {
sort.SliceStable(sequences, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(sequences[i].Name), strings.ToLower(sequences[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}
// SortSequencesBySequence sorts sequences by sequence number
func SortSequencesBySequence(sequences []*Sequence, desc bool) {
sort.SliceStable(sequences, func(i, j int) bool {
if desc {
return sequences[i].Sequence > sequences[j].Sequence
}
return sequences[i].Sequence < sequences[j].Sequence
})
}
// Index Sorting
// SortIndexesMapByName converts index map to sorted slice by name
func SortIndexesMapByName(indexes map[string]*Index, desc bool) []*Index {
result := make([]*Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
SortIndexesByName(result, desc)
return result
}
// SortIndexesMapBySequence converts index map to sorted slice by sequence
func SortIndexesMapBySequence(indexes map[string]*Index, desc bool) []*Index {
result := make([]*Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
SortIndexesBySequence(result, desc)
return result
}
// SortIndexesByName sorts indexes by name
func SortIndexesByName(indexes []*Index, desc bool) {
sort.SliceStable(indexes, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(indexes[i].Name), strings.ToLower(indexes[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}
// SortIndexesBySequence sorts indexes by sequence number
func SortIndexesBySequence(indexes []*Index, desc bool) {
sort.SliceStable(indexes, func(i, j int) bool {
if desc {
return indexes[i].Sequence > indexes[j].Sequence
}
return indexes[i].Sequence < indexes[j].Sequence
})
}
// Constraint Sorting
// SortConstraintsMapByName converts constraint map to sorted slice by name
func SortConstraintsMapByName(constraints map[string]*Constraint, desc bool) []*Constraint {
result := make([]*Constraint, 0, len(constraints))
for _, c := range constraints {
result = append(result, c)
}
SortConstraintsByName(result, desc)
return result
}
// SortConstraintsByName sorts constraints by name
func SortConstraintsByName(constraints []*Constraint, desc bool) {
sort.SliceStable(constraints, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(constraints[i].Name), strings.ToLower(constraints[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}
// Relationship Sorting
// SortRelationshipsMapByName converts relationship map to sorted slice by name
func SortRelationshipsMapByName(relationships map[string]*Relationship, desc bool) []*Relationship {
result := make([]*Relationship, 0, len(relationships))
for _, r := range relationships {
result = append(result, r)
}
SortRelationshipsByName(result, desc)
return result
}
// SortRelationshipsByName sorts relationships by name
func SortRelationshipsByName(relationships []*Relationship, desc bool) {
sort.SliceStable(relationships, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(relationships[i].Name), strings.ToLower(relationships[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}
// Script Sorting
// SortScriptsByName sorts scripts by name
func SortScriptsByName(scripts []*Script, desc bool) {
sort.SliceStable(scripts, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(scripts[i].Name), strings.ToLower(scripts[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}
// Enum Sorting
// SortEnumsByName sorts enums by name
func SortEnumsByName(enums []*Enum, desc bool) {
sort.SliceStable(enums, func(i, j int) bool {
cmp := strings.Compare(strings.ToLower(enums[i].Name), strings.ToLower(enums[j].Name))
if desc {
return cmp > 0
}
return cmp < 0
})
}

326
pkg/reflectutil/helpers.go Normal file
View File

@@ -0,0 +1,326 @@
package reflectutil
import (
"reflect"
"strings"
)
// Deref dereferences pointers until it reaches a non-pointer value
// Returns the dereferenced value and true if successful, or the original value and false if nil
func Deref(v reflect.Value) (reflect.Value, bool) {
for v.Kind() == reflect.Ptr {
if v.IsNil() {
return v, false
}
v = v.Elem()
}
return v, true
}
// DerefInterface dereferences an interface{} until it reaches a non-pointer value
func DerefInterface(i interface{}) reflect.Value {
v := reflect.ValueOf(i)
v, _ = Deref(v)
return v
}
// GetFieldValue extracts a field value from a struct, map, or pointer
// Returns nil if the field doesn't exist or can't be accessed
func GetFieldValue(item interface{}, field string) interface{} {
v := reflect.ValueOf(item)
v, ok := Deref(v)
if !ok {
return nil
}
switch v.Kind() {
case reflect.Struct:
fieldVal := v.FieldByName(field)
if fieldVal.IsValid() {
return fieldVal.Interface()
}
return nil
case reflect.Map:
keyVal := reflect.ValueOf(field)
mapVal := v.MapIndex(keyVal)
if mapVal.IsValid() {
return mapVal.Interface()
}
return nil
default:
return nil
}
}
// IsSliceOrArray checks if an interface{} is a slice or array
func IsSliceOrArray(i interface{}) bool {
v := reflect.ValueOf(i)
v, ok := Deref(v)
if !ok {
return false
}
k := v.Kind()
return k == reflect.Slice || k == reflect.Array
}
// IsMap checks if an interface{} is a map
func IsMap(i interface{}) bool {
v := reflect.ValueOf(i)
v, ok := Deref(v)
if !ok {
return false
}
return v.Kind() == reflect.Map
}
// SliceLen returns the length of a slice/array, or 0 if not a slice/array
func SliceLen(i interface{}) int {
v := reflect.ValueOf(i)
v, ok := Deref(v)
if !ok {
return 0
}
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return 0
}
return v.Len()
}
// MapLen returns the length of a map, or 0 if not a map
func MapLen(i interface{}) int {
v := reflect.ValueOf(i)
v, ok := Deref(v)
if !ok {
return 0
}
if v.Kind() != reflect.Map {
return 0
}
return v.Len()
}
// SliceToInterfaces converts a slice/array to []interface{}
// Returns empty slice if not a slice/array
func SliceToInterfaces(i interface{}) []interface{} {
v := reflect.ValueOf(i)
v, ok := Deref(v)
if !ok {
return []interface{}{}
}
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return []interface{}{}
}
result := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
result[i] = v.Index(i).Interface()
}
return result
}
// MapKeys returns all keys from a map as []interface{}
// Returns empty slice if not a map
func MapKeys(i interface{}) []interface{} {
v := reflect.ValueOf(i)
v, ok := Deref(v)
if !ok {
return []interface{}{}
}
if v.Kind() != reflect.Map {
return []interface{}{}
}
keys := v.MapKeys()
result := make([]interface{}, len(keys))
for i, key := range keys {
result[i] = key.Interface()
}
return result
}
// MapValues returns all values from a map as []interface{}
// Returns empty slice if not a map
func MapValues(i interface{}) []interface{} {
v := reflect.ValueOf(i)
v, ok := Deref(v)
if !ok {
return []interface{}{}
}
if v.Kind() != reflect.Map {
return []interface{}{}
}
result := make([]interface{}, 0, v.Len())
iter := v.MapRange()
for iter.Next() {
result = append(result, iter.Value().Interface())
}
return result
}
// MapGet safely gets a value from a map by key
// Returns nil if key doesn't exist or not a map
func MapGet(m interface{}, key interface{}) interface{} {
v := reflect.ValueOf(m)
v, ok := Deref(v)
if !ok {
return nil
}
if v.Kind() != reflect.Map {
return nil
}
keyVal := reflect.ValueOf(key)
mapVal := v.MapIndex(keyVal)
if mapVal.IsValid() {
return mapVal.Interface()
}
return nil
}
// SliceIndex safely gets an element from a slice/array by index
// Returns nil if index out of bounds or not a slice/array
func SliceIndex(slice interface{}, index int) interface{} {
v := reflect.ValueOf(slice)
v, ok := Deref(v)
if !ok {
return nil
}
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return nil
}
if index < 0 || index >= v.Len() {
return nil
}
return v.Index(index).Interface()
}
// CompareValues compares two values for sorting
// Returns -1 if a < b, 0 if a == b, 1 if a > b
func CompareValues(a, b interface{}) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return -1
}
if b == nil {
return 1
}
va := reflect.ValueOf(a)
vb := reflect.ValueOf(b)
// Handle different types
switch va.Kind() {
case reflect.String:
if vb.Kind() == reflect.String {
as := va.String()
bs := vb.String()
if as < bs {
return -1
} else if as > bs {
return 1
}
return 0
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if vb.Kind() >= reflect.Int && vb.Kind() <= reflect.Int64 {
ai := va.Int()
bi := vb.Int()
if ai < bi {
return -1
} else if ai > bi {
return 1
}
return 0
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if vb.Kind() >= reflect.Uint && vb.Kind() <= reflect.Uint64 {
au := va.Uint()
bu := vb.Uint()
if au < bu {
return -1
} else if au > bu {
return 1
}
return 0
}
case reflect.Float32, reflect.Float64:
if vb.Kind() == reflect.Float32 || vb.Kind() == reflect.Float64 {
af := va.Float()
bf := vb.Float()
if af < bf {
return -1
} else if af > bf {
return 1
}
return 0
}
}
return 0
}
// GetNestedValue gets a nested value using dot notation path
// Example: GetNestedValue(obj, "database.schema.table")
func GetNestedValue(m interface{}, path string) interface{} {
if path == "" {
return m
}
parts := strings.Split(path, ".")
current := m
for _, part := range parts {
if current == nil {
return nil
}
v := reflect.ValueOf(current)
v, ok := Deref(v)
if !ok {
return nil
}
switch v.Kind() {
case reflect.Map:
keyVal := reflect.ValueOf(part)
mapVal := v.MapIndex(keyVal)
if !mapVal.IsValid() {
return nil
}
current = mapVal.Interface()
case reflect.Struct:
fieldVal := v.FieldByName(part)
if !fieldVal.IsValid() {
return nil
}
current = fieldVal.Interface()
default:
return nil
}
}
return current
}
// DeepEqual performs a deep equality check between two values
func DeepEqual(a, b interface{}) bool {
return reflect.DeepEqual(a, b)
}

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
}