fix: contain generated files and assets and harden SQL rendering

This commit is contained in:
2026-10-12 20:59:46 +02:00
parent 2fa48335d3
commit dd97413b8e
68 changed files with 2328 additions and 798 deletions
+55
View File
@@ -41,6 +41,9 @@ relspec convert --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
# DBML → PostgreSQL DDL
relspec convert --from dbml --from-path schema.dbml --to pgsql --to-path schema.sql
# Lowercase model names and structured references before output
relspec convert --from dbml --from-path schema.dbml --to pgsql --to-path schema.sql --lowercase
# PostgreSQL → SQLite (auto flattens schemas)
relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path schema.sql
@@ -57,6 +60,25 @@ connections or `--dry-run`). Source files are polled every `--watch-interval`
is ignored so generating into the source tree does not loop. Conversion errors
are printed and watching continues.
#### Identifier casing and PostgreSQL quoting
PostgreSQL output always double-quotes model identifiers, including reserved
words, and preserves their case by default. For example, schema `Sales` and table
`Order` produce `"Sales"."Order"`; with `--lowercase`, they produce
`"sales"."order"`. Quoted names are case-sensitive in PostgreSQL.
`--lowercase` is available on `convert`, `batch`, `merge`, `split`, `templ`, and
`job run`. It lowercases model names and structured references on a copy and
rejects collisions such as `Users` and `users` in the same schema. SQL expressions,
defaults, comments, enum values, and data retain their case. Adjust identifier
references inside your own SQL expressions when opting into lowercasing.
Generated PostgreSQL SQL also escapes identifier quotes and literal values and
chooses dollar-quote delimiters that do not collide with function or procedural
block contents. Raw SQL expressions remain executable input. See the
[PostgreSQL writer documentation](pkg/writers/pgsql/README.md#identifier-and-value-handling)
for library options and details.
### `batch` — Convert many inputs in one run
Converts each input independently (one output per input, unlike `--from-list`
@@ -165,6 +187,7 @@ jobs:
overwrite: true
options:
flatten_schema: false
lowercase_identifiers: true # opt in; defaults to false
logfile: .relspec/log/build-schema.log
```
@@ -177,6 +200,38 @@ any job runs. Path-like fields also support `${NAME}` environment-variable
references, which are expanded and safety-checked during pre-flight. See
[docs/JOB_FILES.md](docs/JOB_FILES.md).
Use `options.lowercase_identifiers: true` per job, or
`relspec job run build-schema --lowercase` to enable lowercasing for schema outputs
throughout the selected job plan.
### `scripts` — SQL scripts and embedded assets
```bash
relspec scripts list --dir ./migrations
relspec scripts execute --dir ./migrations --conn "$DATABASE_URL"
```
Execution stops on the first error by default. `--continue-on-error` (alias
`--coe`) attempts later scripts and still exits nonzero if any script fails.
Scripts can embed nearby files using a standalone directive comment:
```sql
-- @embed: path=assets/message.txt var=:message mode=text
INSERT INTO messages (body) VALUES (:message);
```
Embed replacement preserves dollar text such as `$1`, `${2}`, and `$HOME` exactly.
Placeholders are replaced only in executable SQL; quoted strings, quoted
identifiers, comments, casts, and dollar-quoted bodies retain their contents.
Inserted content is never scanned for further placeholders. Asset-call named
parameters follow the same SQL lexical boundaries. See
[docs/SCRIPTS_COMMAND.md](docs/SCRIPTS_COMMAND.md) for file containment, limits,
binary embedding, and execution details.
MySQL DDL execution keeps each generated statement whole, preserving multiline
defaults containing semicolons or comment-like text.
### `edit` — Interactive TUI editor
```bash
+10
View File
@@ -28,6 +28,7 @@ import (
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
"git.warky.dev/wdevs/relspecgo/pkg/transform"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
wbun "git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
wdbml "git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
@@ -449,6 +450,15 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
}
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool, extraFields string) error {
if lowercaseIdentifiers {
var err error
schemaFilter = strings.ToLower(schemaFilter)
db, err = transform.LowercaseIdentifiers(db)
if err != nil {
return err
}
}
var writer writers.Writer
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, nullableArrays, continueOnError)
+17
View File
@@ -19,6 +19,7 @@ import (
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/readers"
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
"git.warky.dev/wdevs/relspecgo/pkg/transform"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
"git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec"
@@ -522,6 +523,12 @@ func runTemplJob(rj *resolvedJob, lg *jobLogger) error {
return fmt.Errorf("schema not found: %s", schema)
}
}
if lowercaseIdentifiers || rj.job.Options.LowercaseIdentifiers {
db, err = transform.LowercaseIdentifiers(db)
if err != nil {
return err
}
}
mode := rj.job.Mode
if mode == "" {
mode = "database"
@@ -837,7 +844,17 @@ func inputLabel(ri resolvedInput) string {
// writeJobOutput writes db to the job's output target (file or database).
func writeJobOutput(rj *resolvedJob, db *models.Database, lg *jobLogger) error {
if lowercaseIdentifiers || rj.job.Options.LowercaseIdentifiers {
var err error
db, err = transform.LowercaseIdentifiers(db)
if err != nil {
return err
}
}
o := rj.job.Options
if lowercaseIdentifiers || o.LowercaseIdentifiers {
o.Schema = strings.ToLower(o.Schema)
}
format := strings.ToLower(rj.job.Output.Format)
if rj.outputConn != "" {
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
)
func TestLowercaseFlagAndFileOutput(t *testing.T) {
prior := lowercaseIdentifiers
t.Cleanup(func() { lowercaseIdentifiers = prior })
for _, cmd := range []struct {
name string
flag bool
}{{"convert", convertCmd.Flags().Lookup("lowercase") != nil}, {"batch", batchCmd.Flags().Lookup("lowercase") != nil}, {"merge", mergeCmd.Flags().Lookup("lowercase") != nil}, {"templ", templCmd.Flags().Lookup("lowercase") != nil}, {"job", jobCmd.PersistentFlags().Lookup("lowercase") != nil}} {
if !cmd.flag {
t.Fatalf("missing %s --lowercase", cmd.name)
}
}
if err := convertCmd.Flags().Set("lowercase", "true"); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "schema.sql")
db := models.InitDatabase("DB")
s := models.InitSchema("App")
table := models.InitTable("Users", "App")
col := models.InitColumn("Name", "Users", "App")
col.Type = "text"
col.Default = "Keep CASE"
table.Columns[col.Name] = col
s.Tables = append(s.Tables, table)
db.Schemas = append(db.Schemas, s)
if err := writeDatabase(db, "pgsql", path, "", "", false, "", "", false, ""); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"app"."users"`) || !strings.Contains(string(data), `"name" text DEFAULT 'Keep CASE'`) {
t.Fatalf("%s", data)
}
if table.Name != "Users" {
t.Fatal("input mutated")
}
}
func TestJobLowercaseOption(t *testing.T) {
prior := lowercaseIdentifiers
lowercaseIdentifiers = false
t.Cleanup(func() { lowercaseIdentifiers = prior })
dir := t.TempDir()
writeTestJSON(t, filepath.Join(dir, "input.json"), []string{"MixedCase"})
writeFile(t, filepath.Join(dir, "relspec.yml"), `version: 1
jobs:
lower:
command: convert
inputs:
- format: json
path: input.json
output:
format: json
path: output.json
options:
lowercase_identifiers: true
`)
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
if err := executeJobPlan(set, "lower", false, false, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "output.json"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "mixedcase") || strings.Contains(string(data), "MixedCase") {
t.Fatalf("%s", data)
}
}
+9
View File
@@ -24,6 +24,7 @@ import (
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
"git.warky.dev/wdevs/relspecgo/pkg/transform"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
wbun "git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
wdbml "git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
@@ -407,6 +408,14 @@ func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.D
}
func writeDatabaseForMerge(dbType, filePath, connString string, db *models.Database, label string, flattenSchema bool) error {
if lowercaseIdentifiers {
var err error
db, err = transform.LowercaseIdentifiers(db)
if err != nil {
return err
}
}
var writer writers.Writer
switch strings.ToLower(dbType) {
+1
View File
@@ -25,6 +25,7 @@ func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullab
OutputPath: outputPath,
PackageName: packageName,
FlattenSchema: flattenSchema,
LowercaseIdentifiers: lowercaseIdentifiers,
NullableTypes: nullableTypes,
NullableArrays: nullableArrays,
TypeMappings: typeMappings,
+5
View File
@@ -18,6 +18,7 @@ var (
noVersion bool
silent bool
strictDirectives bool
lowercaseIdentifiers bool
typeMapFlags []string
typeMappings map[string]string
)
@@ -39,12 +40,16 @@ JSON, YAML, SQL, etc.).`,
}
func init() {
for _, cmd := range []*cobra.Command{convertCmd, batchCmd, mergeCmd, splitCmd, templCmd} {
cmd.Flags().BoolVar(&lowercaseIdentifiers, "lowercase", false, "Lowercase all model identifiers and structured references before output (SQL expressions and data keep their case)")
}
rootCmd.AddCommand(convertCmd)
rootCmd.AddCommand(batchCmd)
rootCmd.AddCommand(diffCmd)
rootCmd.AddCommand(inspectCmd)
rootCmd.AddCommand(scriptsCmd)
rootCmd.AddCommand(jobCmd)
jobCmd.PersistentFlags().BoolVar(&lowercaseIdentifiers, "lowercase", false, "Lowercase all model identifiers and structured references before schema output")
rootCmd.AddCommand(assetsCmd)
rootCmd.AddCommand(templCmd)
rootCmd.AddCommand(editCmd)
+8
View File
@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/transform"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
wtemplate "git.warky.dev/wdevs/relspecgo/pkg/writers/template"
)
@@ -142,6 +143,13 @@ func runTempl(cmd *cobra.Command, args []string) error {
}
}
if lowercaseIdentifiers {
db, err = transform.LowercaseIdentifiers(db)
if err != nil {
return err
}
}
// Create template writer
fmt.Fprintf(os.Stderr, "Loading template: %s\n", templTemplatePath)
fmt.Fprintf(os.Stderr, "Execution mode: %s\n", templMode)
+1
View File
@@ -191,6 +191,7 @@ jobs:
# (file output = full DDL); set options.full_ddl to force full DDL
overwrite: false # default false
options:
lowercase_identifiers: false # lowercase names/references; SQL expressions and data keep their case
flatten_schema: false
schema: public
package: models # for gorm/bun output
+30 -2
View File
@@ -96,11 +96,39 @@ INSERT INTO assets (message, payload)
VALUES (:message, decode(:payload, 'base64')::bytea);
```
- `path`: File path resolved relative to the SQL file containing the directive
- `path`: Relative file path beneath the SQL file’s directory; traversal, absolute paths, and symlinks are rejected
- `var`: Named placeholder to replace, such as `:message`
- `mode`: `text` embeds an escaped SQL string literal; `base64` embeds a base64 string literal
The directive comment is removed from the SQL, and every matching placeholder is replaced before the script is listed or executed.
A directive must be a standalone SQL line comment. Directive-like text inside
quoted strings, quoted identifiers, block comments, or dollar-quoted bodies is
ordinary SQL content. The directive is removed, and every matching placeholder in
executable SQL is replaced once before listing or execution. Replacements are
literal, preserving `$1`, `${2}`, `$HOME`, and other dollar text; inserted content
is never scanned for another placeholder. Duplicate embed variables or variables
used only inside strings/comments are errors. PostgreSQL casts (`::type`) remain
unchanged. Asset-call named parameters follow these same lexical boundaries;
repeated parameters share one positional argument.
Embed and manifest asset reads share root-scoped containment and a 64 MiB per-file
limit. Only regular files are accepted. The SQL directory reader uses its selected
script tree as an explicit asset root; embed paths remain relative to each script.
Symlinks in asset paths are rejected, and containment is enforced again at open time.
Manifest entries are checked at discovery and execution, so replacing a file with an
escaping symlink after scanning cannot expose an external file. No per-directive
external-file override is supported; copy required assets into the allowed tree.
Library callers can choose a trusted root and size limit with
`assetloader.ProcessEmbedDirectivesWithOptions(..., assetloader.EmbedOptions{
AssetRoot: root, MaxFileBytes: limit})`; zero means the 64 MiB default, and negative
or unbounded limits are rejected. Manifest items retain `AssetRoot` and
`MaxFileBytes` for execution-time validation.
For scripts loaded from a model rather than a SQL directory, the executor requires
trusted writer configuration `Metadata["asset_root"]` before expanding directives.
Model `source_path` metadata supplies resolution context only; it does not grant
local-file access. Missing or out-of-root source paths fail before reading assets.
## Commands
+10
View File
@@ -570,3 +570,13 @@ COMMIT;
- [RelSpec Documentation](../README.md)
- [Model Structure Reference](../pkg/models/)
- [Example Templates](../examples/templates/)
## Output path safety
In schema, domain, script, and table modes, `--output` always names a directory,
including a nonexistent directory without a trailing slash. An empty value uses
the current directory. Database mode uses a single output file as before.
Generated paths must stay relative to that directory. Traversal, absolute or
Windows-specific unsafe paths, symlinks, filename collisions (including case
variants), and file/directory collisions are rejected before publishing artifacts.
Nested patterns such as `{{.ParentSchema.Name}}/{{.Name}}.txt` remain supported.
+16
View File
@@ -14,6 +14,18 @@ Implementation batches (2026-10-12):
- [x] **A01, shared execution results:** PostgreSQL, SQLite, SQL Server, and raw script execution now use `writers.ExecutionReport` / `ExecutionFailure`. Stop/continue modes always fail on SQL errors; counters/results survive early failure and aggregate across script schemas. Reports distinguish applied, failed, skipped, rolled-back, and uncertain operations. Generated DDL supports optional atomic execution; raw scripts report script-managed outcomes without promising statement-level commit/rollback. Requested report failures are errors in every executor. Live PostgreSQL, SQL Server, SQLite, and dependent-job regressions pass.
- [x] **A02, conversion expressions and preflight:** column metadata `pgsql_using` configures an explicit SQL conversion expression; `pgsql_preflight` adds a row predicate. Full-DDL and diff plans evaluate every existing value before schema mutations, and live preflight failure stops even with continuation enabled. Tests cover later invalid rows, preservation of a constraint scheduled for removal, custom conversions, predicate rejection, invalid configuration, and block-delimiter collisions. Standalone schema generation also includes preflights.
- [x] **A03, contained template output:** multi-file output has an explicit directory root, portable relative filename validation, and preflight collision/symlink checks. Rendering/validation complete before writing; exclusive per-file staging and root-scoped rename prevent generated filenames from escaping the root. Multi-file atomic publication remains A16 work.
- [x] **A04, contained asset reads:** embed directives and manifest execution use shared root-scoped reads, symlink checks, regular-file validation, and a bounded 64 MiB default. Manifest paths are rechecked at execution. Imported source-path metadata cannot authorize file reads without a trusted configured asset root. Library callers can explicitly configure a root/size limit; directives cannot opt out of containment.
- [x] **A05, PostgreSQL quoting:** schema/full-DDL/diff paths and embedded templates always quote model identifiers, quote literal/catalog/regclass values, sanitize names in generated line comments, and choose collision-safe dollar delimiters for procedural and audit function bodies. NUL is rejected before output; migration comparisons preserve identifier case. Raw SQL expressions remain an explicit executable-input boundary.
- [x] **Requested lowercase option:** `--lowercase` on convert/batch/merge/split/templ/job run, job `options.lowercase_identifiers`, and PostgreSQL `WriterOptions.LowercaseIdentifiers` normalize names and structured references on a copy, rejecting collisions. SQL expressions, defaults, comments, enum values, and data keep their case; callers must adjust references inside raw SQL themselves.
- [x] **A08, SQL-aware substitution/execution:** embeds use one-pass literal substitution, preserving dollar characters and preventing replacement rescanning. Named parameters and embed directives respect SQL strings, quoted identifiers, nested comments, casts, and dollar bodies; duplicate/unused embed variables fail. MySQL executes structured generated statements without splitting multiline literals or stripping their contents.
A05–A08 verification: unit regressions and live PostgreSQL 18 checks cover unusual names, comments/defaults, rerunnable file/statement output, diff migrations, sequences, primary-key replacement, audit functions/triggers, and both standard-string modes. MySQL statement preservation is unit-tested; a live MySQL instance was not used for A08. `go test -mod=vendor -p 1 ./...` passed with `RELSPEC_TEST_PG_CONN` set to the disposable instance; `go vet -mod=vendor ./...`, executable CLI help checks, and `git diff --check` passed. SQL Server tests were not configured for this batch. The disposable instance was removed after verification.
Additional observation: a parallel live full-suite run hit a PostgreSQL reader catalog race (`could not open relation with OID`) while another package's test dropped schema objects. The writer safely fell back to full DDL and its preflight still stopped all mutations, but the existing test assumed no successful read-only preflights before failure. Running live packages serially passed. Reader resilience to concurrent catalog changes remains outside A05/A08; A10 specifically concerns cancellation/timeouts.
A03–A04 verification: `go test -mod=vendor ./...`, targeted regression tests, `go vet -mod=vendor ./...`, and `git diff --check` passed. Database-dependent tests were not configured for this filesystem-only batch. Windows test binaries were cross-compiled for the template and asset packages; Windows runtime filesystem tests remain unperformed.
Verification: `go test -mod=vendor ./...` passed with `RELSPEC_TEST_PG_CONN` and `RELSPEC_TEST_MSSQL_CONN` pointing to disposable PostgreSQL 18 and SQL Server 2022 Developer instances; SQLite regressions use temporary database files. A minimal public table was seeded for the existing PostgreSQL `ReadTable` test, which requires a nonempty default schema. `go vet -mod=vendor ./...` and `git diff --check` passed. Executable CLI checks verified `scripts execute --ignore-errors`, `--continue-on-error`, and `--coe` all attempt a later script after failure and exit 1. Raw script tests intentionally make no statement-level rollback claim. A09 remains open for concurrency, history, and migration recovery; sequence/external side effects are outside transaction rollback guarantees. Original audit evidence is retained for historical context.
## Assessment
@@ -92,6 +104,8 @@ The broad exception handler also treats unrelated failures as permission to atte
### A03 — Generated template filenames escape the output directory
**Repair status: addressed.** Multi-file roots, generated paths, collisions, symlinks, and root-scoped writes are covered by regression tests. A nonexistent output path now consistently denotes the directory itself rather than its parent.
**P1 / High · Security · Reproduced**
Evidence: `pkg/writers/template/writer.go:249–308`.
@@ -106,6 +120,8 @@ An imported model can therefore cause writes outside the intended destination, i
### A04 — SQL embed directives can read outside the script directory
**Repair status: addressed.** Shared bounded root-scoped reads protect directives and manifest assets, including revalidation after discovery. Model source-path metadata alone no longer grants local file access.
**P1 / High · Security · Reproduced**
Evidence: `pkg/assetloader/embed.go:73–74`; compare the containment checks in `pkg/assetloader/loader.go` and `pkg/jobs/jobs.go`.
+93 -18
View File
@@ -9,10 +9,22 @@ import (
"strconv"
"strings"
"unicode/utf8"
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
"git.warky.dev/wdevs/relspecgo/pkg/sqllex"
)
const ScriptSourcePathMetadataKey = "source_path"
const DefaultMaxAssetBytes int64 = 64 << 20 // 64 MiB per manifest/embedded asset
// EmbedOptions are trusted caller configuration, not script/model metadata.
// An empty AssetRoot defaults to the SQL file's directory; external reads are rejected.
type EmbedOptions struct {
AssetRoot string
MaxFileBytes int64
}
var (
embedDirectivePattern = regexp.MustCompile(`(?m)^\s*--\s*@embed:\s*(.+?)\s*$`)
embedAttrPattern = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)`)
@@ -27,7 +39,28 @@ var (
// string literal. Base64 mode embeds a quoted base64 literal suitable for
// decode(:var, 'base64').
func ProcessEmbedDirectives(sqlPath, sql string) (string, error) {
directives := embedDirectivePattern.FindAllStringSubmatch(sql, -1)
return ProcessEmbedDirectivesWithOptions(sqlPath, sql, EmbedOptions{})
}
func embedDirectives(sql string) (string, []string) {
var directives []string
// Only standalone line comments are directives, and only in SQL code.
result := sqllex.Walk(sql, nil, func(comment string) string {
if match := embedDirectivePattern.FindStringSubmatch(comment); match != nil {
directives = append(directives, match[1])
return ""
}
return comment
})
return result, directives
}
func HasEmbedDirectives(sql string) bool {
_, directives := embedDirectives(sql)
return len(directives) > 0
}
func ProcessEmbedDirectivesWithOptions(sqlPath, sql string, options EmbedOptions) (string, error) {
result, directives := embedDirectives(sql)
if len(directives) == 0 {
return sql, nil
}
@@ -36,22 +69,63 @@ func ProcessEmbedDirectives(sqlPath, sql string) (string, error) {
return "", fmt.Errorf("sql path is required for embed directives")
}
result := embedDirectivePattern.ReplaceAllString(sql, "")
for i, directive := range directives {
literal, placeholder, err := embedDirectiveLiteral(sqlPath, directive[1], i+1)
assetRoot := options.AssetRoot
if assetRoot == "" {
assetRoot = filepath.Dir(sqlPath)
}
absRoot, err := filepath.Abs(assetRoot)
if err != nil {
return "", err
}
if !embedPlaceholderPattern(placeholder).MatchString(result) {
return "", fmt.Errorf("%s embed directive %d: placeholder %s not found", sqlPath, i+1, placeholder)
absSQL, err := filepath.Abs(sqlPath)
if err != nil {
return "", err
}
scriptRel, err := filepath.Rel(absRoot, absSQL)
if err != nil {
return "", err
}
if _, err := safefile.Relative(filepath.ToSlash(scriptRel)); err != nil {
return "", fmt.Errorf("SQL source path is outside asset root: %w", err)
}
root, err := openAssetRoot(absRoot)
if err != nil {
return "", err
}
defer root.Close()
limit := options.MaxFileBytes
if limit == 0 {
limit = DefaultMaxAssetBytes
}
literals := map[string]string{}
used := map[string]bool{}
for i, directive := range directives {
literal, placeholder, err := embedDirectiveLiteral(root, filepath.Dir(scriptRel), limit, sqlPath, directive, i+1)
if err != nil {
return "", err
}
if _, exists := literals[placeholder]; exists {
return "", fmt.Errorf("duplicate embed placeholder %s", placeholder)
}
literals[placeholder] = literal
}
result = sqllex.Walk(result, func(name string) string {
if literal, ok := literals[name]; ok {
used[name] = true
return literal
}
return name
}, nil)
for name := range literals {
if !used[name] {
return "", fmt.Errorf("%s: embed placeholder %s not found in executable SQL", sqlPath, name)
}
result = replaceEmbedPlaceholder(result, placeholder, literal)
}
return result, nil
}
func embedDirectiveLiteral(sqlPath, raw string, directiveNumber int) (literal, placeholder string, err error) {
func embedDirectiveLiteral(root *os.Root, scriptDir string, maxBytes int64, sqlPath, raw string, directiveNumber int) (literal, placeholder string, err error) {
attrs, err := parseEmbedAttrs(raw)
if err != nil {
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
@@ -70,8 +144,12 @@ func embedDirectiveLiteral(sqlPath, raw string, directiveNumber int) (literal, p
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
}
resolved := filepath.Join(filepath.Dir(sqlPath), filepath.Clean(pathValue))
data, err := os.ReadFile(resolved)
relative, err := safefile.Relative(pathValue)
if err != nil {
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
}
resolved := filepath.Join(scriptDir, relative)
data, err := safefile.Read(root, resolved, maxBytes)
if err != nil {
return "", "", fmt.Errorf("%s embed directive %d: reading %s: %w", sqlPath, directiveNumber, resolved, err)
}
@@ -153,13 +231,10 @@ func sqlStringLiteral(value string) (string, error) {
if strings.ContainsRune(value, '\x00') {
return "", fmt.Errorf("embedded text contains NUL byte")
}
return "'" + strings.ReplaceAll(value, "'", "''") + "'", nil
prefix := ""
if strings.Contains(value, "\\") {
prefix = "E"
value = strings.ReplaceAll(value, "\\", "\\\\")
}
func replaceEmbedPlaceholder(sql, placeholder, literal string) string {
return embedPlaceholderPattern(placeholder).ReplaceAllString(sql, "${1}"+literal+"${2}")
}
func embedPlaceholderPattern(placeholder string) *regexp.Regexp {
return regexp.MustCompile(`(^|[^a-zA-Z0-9_:])` + regexp.QuoteMeta(placeholder) + `([^a-zA-Z0-9_]|$)`)
return prefix + "'" + strings.ReplaceAll(value, "'", "''") + "'", nil
}
+111
View File
@@ -141,3 +141,114 @@ SELECT :body, :body_extra;
t.Fatalf("placeholder boundary was not respected:\n%s", got)
}
}
func TestEmbedRejectsTraversalAbsolutePathsAndSymlinks(t *testing.T) {
root := t.TempDir()
scriptDir := filepath.Join(root, "scripts")
if err := os.Mkdir(scriptDir, 0o755); err != nil {
t.Fatal(err)
}
secret := filepath.Join(root, "secret.txt")
if err := os.WriteFile(secret, []byte("never print this secret"), 0o600); err != nil {
t.Fatal(err)
}
for _, path := range []string{"../secret.txt", secret, `..\secret.txt`, `C:\secret.txt`, `\\server\secret.txt`} {
sql := "-- @embed: path='" + path + "' var=:body mode=text\nSELECT :body;"
got, err := ProcessEmbedDirectives(filepath.Join(scriptDir, "seed.sql"), sql)
if err == nil || got != "" {
t.Fatalf("unsafe embed path accepted %q: %q %v", path, got, err)
}
if strings.Contains(err.Error(), "never print this secret") {
t.Fatal("diagnostic leaked asset bytes")
}
}
if err := os.Symlink(root, filepath.Join(scriptDir, "link")); err != nil {
t.Skip(err)
}
if _, err := ProcessEmbedDirectives(filepath.Join(scriptDir, "seed.sql"), "-- @embed: path=link/secret.txt var=:body mode=text\nSELECT :body;"); err == nil {
t.Fatal("symlink parent must be rejected")
}
if err := os.Symlink(secret, filepath.Join(scriptDir, "leaf.txt")); err != nil {
t.Fatal(err)
}
if _, err := ProcessEmbedDirectives(filepath.Join(scriptDir, "seed.sql"), "-- @embed: path=leaf.txt var=:body mode=text\nSELECT :body;"); err == nil {
t.Fatal("symlink asset must be rejected")
}
}
func TestEmbedExplicitRootAndSizeLimit(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "scripts")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("12345"), 0o600); err != nil {
t.Fatal(err)
}
sql := "-- @embed: path=body.txt var=:body mode=text\nSELECT :body;"
path := filepath.Join(dir, "seed.sql")
if _, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{AssetRoot: root, MaxFileBytes: 4}); err == nil || !strings.Contains(err.Error(), "byte limit") {
t.Fatalf("expected size limit error: %v", err)
}
if got, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{AssetRoot: root, MaxFileBytes: 5}); err != nil || !strings.Contains(got, "SELECT '12345';") {
t.Fatalf("valid bounded embed: %q %v", got, err)
}
if _, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{AssetRoot: t.TempDir()}); err == nil {
t.Fatal("source path outside explicit root must fail")
}
if _, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{MaxFileBytes: -1}); err == nil {
t.Fatal("negative limits must fail")
}
}
func TestManifestReadRechecksContainmentAndSize(t *testing.T) {
root, external := t.TempDir(), t.TempDir()
file := filepath.Join(root, "body.txt")
if err := os.WriteFile(file, []byte("valid"), 0o600); err != nil {
t.Fatal(err)
}
item := Item{AssetRoot: root, MaxFileBytes: 4, Entry: ManifestEntry{File: file}}
if _, err := readItemAsset(item); err == nil {
t.Fatal("manifest size bound must apply at execution")
}
item.MaxFileBytes = 5
if got, err := readItemAsset(item); err != nil || string(got) != "valid" {
t.Fatalf("valid manifest asset: %q %v", got, err)
}
secret := filepath.Join(external, "secret.txt")
if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Remove(file); err != nil {
t.Fatal(err)
}
if err := os.Symlink(secret, file); err != nil {
t.Skip(err)
}
if _, err := readItemAsset(item); err == nil {
t.Fatal("replacement symlink after scanning must fail")
}
item.Entry.File = secret
if _, err := readItemAsset(item); err == nil {
t.Fatal("forged asset path must fail")
}
}
func TestEmbedDefaultLimitRejectsOversizedFile(t *testing.T) {
dir := t.TempDir()
file, err := os.Create(filepath.Join(dir, "large.bin"))
if err != nil {
t.Fatal(err)
}
if err := file.Truncate(DefaultMaxAssetBytes + 1); err != nil {
file.Close()
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
_, err = ProcessEmbedDirectives(filepath.Join(dir, "seed.sql"), "-- @embed: path=large.bin var=:payload mode=base64\nSELECT :payload;")
if err == nil || !strings.Contains(err.Error(), "byte limit") {
t.Fatalf("expected default size bound: %v", err)
}
}
+4 -17
View File
@@ -3,20 +3,12 @@ package assetloader
import (
"context"
"fmt"
"os"
"git.warky.dev/wdevs/relspecgo/pkg/sqllex"
"path/filepath"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
)
// namedPlaceholder matches :identifier patterns (but not ::cast syntax).
var namedPlaceholder = regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`)
// pgCastMarker temporarily replaces :: to protect PostgreSQL cast syntax.
const pgCastMarker = "\x00PGCAST\x00"
// BuildQuery converts a SQL call that uses :name named placeholders into a
// pgx-compatible positional-parameter query ($1, $2, …) and returns the
// corresponding argument slice.
@@ -30,13 +22,11 @@ const pgCastMarker = "\x00PGCAST\x00"
// placeholder (not built-in and not in staticParams) returns an error.
// PostgreSQL cast syntax (::type) is left untouched.
func BuildQuery(call string, fileBytes []byte, filename string, staticParams map[string]string) (query string, args []any, err error) {
// Protect :: casts before running the placeholder regex.
protected := strings.ReplaceAll(call, "::", pgCastMarker)
paramIndex := map[string]int{} // name → 1-based position
var firstErr error
query = namedPlaceholder.ReplaceAllStringFunc(protected, func(match string) string {
query = sqllex.Walk(call, func(match string) string {
if firstErr != nil {
return match
}
@@ -70,15 +60,12 @@ func BuildQuery(call string, fileBytes []byte, filename string, staticParams map
paramIndex[name] = idx
args = append(args, val)
return fmt.Sprintf("$%d", idx)
})
}, nil)
if firstErr != nil {
return "", nil, firstErr
}
// Restore :: casts.
query = strings.ReplaceAll(query, pgCastMarker, "::")
return query, args, nil
}
@@ -86,7 +73,7 @@ func BuildQuery(call string, fileBytes []byte, filename string, staticParams map
// absolute path set by ScanDir) and executes the configured SQL call via conn.
// The file's raw bytes are bound as a []byte parameter — no encoding or escaping.
func ExecuteItem(ctx context.Context, conn *pgx.Conn, item Item) error {
data, err := os.ReadFile(item.Entry.File)
data, err := readItemAsset(item)
if err != nil {
return fmt.Errorf("reading asset file %s: %w", item.Entry.File, err)
}
+36
View File
@@ -0,0 +1,36 @@
package assetloader
import (
"fmt"
"os"
"path/filepath"
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
)
func openAssetRoot(path string) (*os.Root, error) {
if err := safefile.CheckRoot(path); err != nil {
return nil, err
}
return os.OpenRoot(path)
}
func readItemAsset(item Item) ([]byte, error) {
if item.AssetRoot == "" {
return nil, fmt.Errorf("asset root is required for manifest execution")
}
root, err := openAssetRoot(item.AssetRoot)
if err != nil {
return nil, err
}
defer root.Close()
relative, err := filepath.Rel(item.AssetRoot, item.Entry.File)
if err != nil {
return nil, err
}
limit := item.MaxFileBytes
if limit == 0 {
limit = DefaultMaxAssetBytes
}
return safefile.Read(root, relative, limit)
}
+29 -21
View File
@@ -18,9 +18,10 @@ import (
"regexp"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
)
// ManifestEntry describes a single file to load from an assets.yaml manifest.
@@ -37,6 +38,9 @@ type ManifestEntry struct {
// Item combines a manifest entry with its ordering metadata and the resolved
// directory where the manifest and asset file reside.
type Item struct {
// AssetRoot is the trusted scan root, retained for validation at execution time.
AssetRoot string
MaxFileBytes int64
// Priority and Sequence come from the parent directory's naming pattern.
Priority int
Sequence uint
@@ -57,7 +61,12 @@ var dirPattern = regexp.MustCompile(`^(\d+)[_-](\d+)[_-](.+)$`)
// absent or contains invalid YAML.
func LoadManifest(dir string) ([]ManifestEntry, error) {
manifestPath := filepath.Join(dir, "assets.yaml")
data, err := os.ReadFile(manifestPath)
root, err := openAssetRoot(dir)
if err != nil {
return nil, err
}
defer root.Close()
data, err := safefile.Read(root, "assets.yaml", DefaultMaxAssetBytes)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", manifestPath, err)
}
@@ -92,7 +101,7 @@ func ScanDir(baseDir string) ([]Item, error) {
if d.IsDir() {
return nil
}
if d.Name() != "assets.yaml" {
if d.Type()&os.ModeSymlink != 0 || d.Name() != "assets.yaml" {
return nil
}
@@ -119,6 +128,8 @@ func ScanDir(baseDir string) ([]Item, error) {
}
items = append(items, Item{
AssetRoot: absBase,
MaxFileBytes: DefaultMaxAssetBytes,
Priority: priority,
Sequence: sequence,
DirName: dirName,
@@ -170,31 +181,28 @@ func parseDirName(dir string) (priority int, sequence uint, name string) {
// Returns the absolute path, a skip flag (true when the entry should be silently
// dropped), and any hard error.
func resolveAssetPath(absBase, manifestDir, file string) (absPath string, skip bool, err error) {
// Clean and join before any symlink resolution so we can detect traversal.
joined := filepath.Join(manifestDir, filepath.Clean(file))
// Ensure the cleaned path is still inside absBase.
if _, err := safefile.Relative(file); err != nil {
return "", true, nil
}
joined := filepath.Join(manifestDir, file)
rel, err := filepath.Rel(absBase, joined)
if err != nil || strings.HasPrefix(rel, "..") {
// Path escapes the base directory; skip silently.
if err != nil {
return "", true, nil
}
// Walk each component to detect symlinks.
parts := strings.Split(rel, string(filepath.Separator))
current := absBase
for _, part := range parts {
current = filepath.Join(current, part)
info, statErr := os.Lstat(current)
if statErr != nil {
// File doesn't exist; skip.
root, err := openAssetRoot(absBase)
if err != nil {
return "", false, err
}
defer root.Close()
if err := safefile.Check(root, rel); err != nil {
return "", true, nil
}
if info.Mode()&os.ModeSymlink != 0 {
// Symlink in path; skip silently.
info, err := root.Stat(rel)
if err != nil || !info.Mode().IsRegular() {
return "", true, nil
}
if info.Size() > DefaultMaxAssetBytes {
return "", false, fmt.Errorf("asset %q exceeds %d byte limit", rel, DefaultMaxAssetBytes)
}
return joined, false, nil
}
+60
View File
@@ -0,0 +1,60 @@
package assetloader
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildQuerySQLContexts(t *testing.T) {
call := `SELECT ':unknown', ":unknown", $$:unknown$$, E'it\':unknown', :bytes::bytea, :filename, :bytes /* :unknown /* :unknown */ */ -- :unknown
`
got, args, err := BuildQuery(call, []byte("$1"), "File.txt", nil)
if err != nil {
t.Fatal(err)
}
if len(args) != 2 || !strings.Contains(got, "$1::bytea, $2, $1") || !strings.Contains(got, "$$:unknown$$") {
t.Fatalf("%s %#v", got, args)
}
}
func TestEmbedLiteralOnePassAndSQLContexts(t *testing.T) {
dir := t.TempDir()
data := "price $1 ${2} $HOME $$ :b 'quote' \\path"
for file, text := range map[string]string{"a.txt": data, "b.txt": "Second"} {
if err := os.WriteFile(filepath.Join(dir, file), []byte(text), 0600); err != nil {
t.Fatal(err)
}
}
sql := `-- @embed: path=a.txt var=:a mode=text
-- @embed: path=b.txt var=:b mode=text
SELECT :a,:a,:b, ':a', ":a", $$:a$$, 1::integer; -- :a
/* :a */`
got, err := ProcessEmbedDirectives(filepath.Join(dir, "script.sql"), sql)
if err != nil {
t.Fatal(err)
}
literal, err := sqlStringLiteral(data)
if err != nil {
t.Fatal(err)
}
if strings.Count(got, literal) != 2 || !strings.Contains(got, "'Second'") || !strings.Contains(got, "':a'") || !strings.Contains(got, "$$:a$$") {
t.Fatalf("%s", got)
}
for _, sql := range []string{"SELECT $body$\n-- @embed: path=missing var=:x mode=text\n$body$", "SELECT '\n-- @embed: path=missing var=:x mode=text\n'", "/*\n-- @embed: path=missing var=:x mode=text\n*/", "SELECT 1; -- @embed: path=missing var=:x mode=text"} {
if HasEmbedDirectives(sql) {
t.Fatalf("false directive: %s", sql)
}
got, err := ProcessEmbedDirectives("", sql)
if err != nil || got != sql {
t.Fatalf("%q: %v", got, err)
}
}
for _, sql := range []string{"-- @embed: path=a.txt var=:a mode=text\nSELECT ':a'", "-- @embed: path=a.txt var=:a mode=text\n-- @embed: path=b.txt var=:a mode=text\nSELECT :a"} {
if _, err := ProcessEmbedDirectives(filepath.Join(dir, "script.sql"), sql); err == nil {
t.Fatal("expected invalid directive use")
}
}
}
+1
View File
@@ -277,6 +277,7 @@ func parseHumanSize(s string) (int64, error) {
// Options carries the subset of command flags a job file may set.
type Options struct {
LowercaseIdentifiers bool `yaml:"lowercase_identifiers"`
FlattenSchema bool `yaml:"flatten_schema"`
Schema string `yaml:"schema"`
Package string `yaml:"package"`
+1 -1
View File
@@ -152,7 +152,7 @@ func (r *Reader) readScripts() ([]*models.Script, error) {
if err != nil {
return fmt.Errorf("failed to read file %s: %w", path, err)
}
sql, err := assetloader.ProcessEmbedDirectives(path, string(content))
sql, err := assetloader.ProcessEmbedDirectivesWithOptions(path, string(content), assetloader.EmbedOptions{AssetRoot: r.options.FilePath})
if err != nil {
return err
}
+136
View File
@@ -0,0 +1,136 @@
// Package safefile provides portable path validation and root-scoped file access.
package safefile
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"unicode"
)
// Relative validates artifact paths consistently on Unix and Windows. It rejects
// traversal rather than normalizing it, so aliases cannot hide filename collisions.
func Relative(name string) (string, error) {
if name == "" || !filepath.IsLocal(name) || strings.ContainsAny(name, `\:<>"|?*`) {
return "", fmt.Errorf("unsafe relative path %q", name)
}
for _, r := range name {
if unicode.IsControl(r) {
return "", fmt.Errorf("control character in path %q", name)
}
}
for _, part := range strings.Split(filepath.ToSlash(name), "/") {
if part == "" || part == "." || part == ".." || strings.HasSuffix(part, ".") || strings.HasSuffix(part, " ") {
return "", fmt.Errorf("unsafe path component in %q", name)
}
base := strings.ToUpper(strings.SplitN(part, ".", 2)[0])
device := base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || base == "CONIN$" || base == "CONOUT$"
for _, prefix := range []string{"COM", "LPT"} {
if strings.HasPrefix(base, prefix) {
suffix := []rune(strings.TrimPrefix(base, prefix))
device = device || (len(suffix) == 1 && strings.ContainsRune("123456789¹²³", suffix[0]))
}
}
if device {
return "", fmt.Errorf("reserved device path %q", name)
}
}
return filepath.FromSlash(name), nil
}
// Check rejects existing symlinks and special files; os.Root separately enforces
// containment at open/rename time, including if a symlink changes after this check.
func Check(root *os.Root, name string) error {
if _, err := Relative(filepath.ToSlash(name)); err != nil {
return err
}
parts := strings.Split(name, string(filepath.Separator))
current := ""
for i, part := range parts {
current = filepath.Join(current, part)
info, err := root.Lstat(current)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink is not allowed in path %q", name)
}
if i < len(parts)-1 && !info.IsDir() {
return fmt.Errorf("parent is not a directory in path %q", name)
}
if i == len(parts)-1 && !info.Mode().IsRegular() {
return fmt.Errorf("path is not a regular file: %q", name)
}
}
return nil
}
// Read opens through a pinned root and bounds both the declared and actual size.
func Read(root *os.Root, name string, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 || maxBytes == (1<<63)-1 {
return nil, fmt.Errorf("file size limit must be positive and bounded")
}
if err := Check(root, name); err != nil {
return nil, err
}
file, err := root.Open(name)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("asset is not a regular file: %q", name)
}
if info.Size() > maxBytes {
return nil, fmt.Errorf("asset %q exceeds %d byte limit", name, maxBytes)
}
data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("asset %q exceeds %d byte limit", name, maxBytes)
}
return data, nil
}
// CheckRoot rejects symlinks in the configured root or its existing ancestors.
// The configured root is a trusted caller-selected boundary; OpenRoot pins it for
// subsequent operations. Missing components are allowed for output root creation.
func CheckRoot(path string) error {
absolute, err := filepath.Abs(path)
if err != nil {
return err
}
volume := filepath.VolumeName(absolute)
current := volume + string(filepath.Separator)
for _, part := range strings.Split(strings.TrimPrefix(absolute, current), string(filepath.Separator)) {
if part == "" {
continue
}
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink is not allowed in root path %q", path)
}
if !info.IsDir() {
return fmt.Errorf("root parent is not a directory: %q", current)
}
}
return nil
}
+101
View File
@@ -0,0 +1,101 @@
// Package sqllex recognizes PostgreSQL lexical boundaries without parsing SQL.
package sqllex
import "strings"
func start(c byte) bool { return c >= 128 || c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
func ident(c byte) bool { return start(c) || c >= '0' && c <= '9' || c >= 128 }
// Walk visits named parameters and standalone line comments in executable SQL. Quoted
// strings/identifiers, dollar bodies, casts and nested block comments are opaque.
// Returning replacement text is literal: it is never scanned again.
func Walk(sql string, parameter func(string) string, lineComment func(string) string) string {
var out strings.Builder
for i := 0; i < len(sql); {
begin := i
switch {
case strings.HasPrefix(sql[i:], "--"):
i += 2
for i < len(sql) && sql[i] != '\n' && sql[i] != '\r' {
i++
}
text := sql[begin:i]
if lineComment != nil && strings.TrimSpace(sql[strings.LastIndexAny(sql[:begin], "\r\n")+1:begin]) == "" {
text = lineComment(text)
}
out.WriteString(text)
continue
case strings.HasPrefix(sql[i:], "/*"):
i += 2
depth := 1
for i < len(sql) && depth > 0 {
if strings.HasPrefix(sql[i:], "/*") {
depth++
i += 2
} else if strings.HasPrefix(sql[i:], "*/") {
depth--
i += 2
} else {
i++
}
}
case sql[i] == '\'' || sql[i] == '"':
q := sql[i]
escapes := q == '\'' && i > 0 && (sql[i-1] == 'E' || sql[i-1] == 'e') && (i == 1 || !ident(sql[i-2]))
i++
for i < len(sql) {
if escapes && sql[i] == '\\' {
i++
if i < len(sql) {
i++
}
continue
}
if sql[i] == q {
i++
if i < len(sql) && sql[i] == q {
i++
continue
}
break
}
i++
}
case sql[i] == '$' && (i == 0 || !ident(sql[i-1]) && sql[i-1] != '$'):
j := i + 1
if j < len(sql) && start(sql[j]) {
for j < len(sql) && ident(sql[j]) {
j++
}
}
if j < len(sql) && sql[j] == '$' {
tag := sql[i : j+1]
end := strings.Index(sql[j+1:], tag)
if end < 0 {
i = len(sql)
} else {
i = j + 1 + end + len(tag)
}
} else {
i++
}
case strings.HasPrefix(sql[i:], "::"):
i += 2
case sql[i] == ':' && i+1 < len(sql) && start(sql[i+1]) && (i == 0 || !ident(sql[i-1]) && sql[i-1] != ':'):
i += 2
for i < len(sql) && ident(sql[i]) {
i++
}
text := sql[begin:i]
if parameter != nil {
text = parameter(text)
}
out.WriteString(text)
continue
default:
i++
}
out.WriteString(sql[begin:i])
}
return out.String()
}
+28
View File
@@ -0,0 +1,28 @@
package sqllex
import (
"strings"
"testing"
)
func TestWalkSQLContexts(t *testing.T) {
sql := `SELECT :x,:x, :x::text, ':hidden', 'it'':hidden', E'it\':hidden', ":hidden", $$:hidden$$, $名$:hidden$名$;
-- :hidden
/* :hidden /* :hidden */ :hidden */ SELECT :y, value::integer, x := 1;`
var names []string
got := Walk(sql, func(name string) string { names = append(names, name); return "$1 ${2} :inserted" }, nil)
if strings.Join(names, ",") != ":x,:x,:x,:y" {
t.Fatalf("parameters: %v", names)
}
if strings.Count(got, "$1 ${2} :inserted") != 4 || !strings.Contains(got, "value::integer") {
t.Fatalf("result: %s", got)
}
}
func TestUnterminatedSQLContextsAreOpaque(t *testing.T) {
for _, sql := range []string{"SELECT ':x", `SELECT ":x`, "DO $tag$:x", "/* :x", "-- :x"} {
if got := Walk(sql, func(string) string { t.Fatal("unexpected parameter"); return "" }, nil); got != sql {
t.Fatalf("changed %q", sql)
}
}
}
+157
View File
@@ -0,0 +1,157 @@
package transform
import (
"fmt"
"reflect"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
)
// LowercaseIdentifiers copies the model and lowercases names and structured
// references. SQL expressions, defaults, enum values, comments and metadata are
// preserved. Callers must adjust identifiers inside their own SQL expressions.
// Collisions are rejected before any output is written.
func LowercaseIdentifiers(db *models.Database) (*models.Database, error) {
if db == nil {
return nil, fmt.Errorf("database is required")
}
seen := map[any]reflect.Value{}
nameFields := map[string]bool{}
for _, name := range strings.Fields("Name Schema Table Tablespace Collation TableName SchemaName FromTable FromSchema ToTable ToSchema ThroughTable ThroughSchema ForeignKey ReferencedTable ReferencedSchema OwnedByTable OwnedByColumn") {
nameFields[name] = true
}
listFields := map[string]bool{"Columns": true, "Include": true, "ReferencedColumns": true, "FromColumns": true, "ToColumns": true, "RunAfter": true}
var copyValue func(reflect.Value) (reflect.Value, error)
copyValue = func(v reflect.Value) (reflect.Value, error) {
if !v.IsValid() {
return v, nil
}
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() || v.Type().Elem().PkgPath() != reflect.TypeOf(models.Database{}).PkgPath() {
return v, nil
}
if prior, ok := seen[v.Interface()]; ok {
return prior, nil
}
out := reflect.New(v.Type().Elem())
seen[v.Interface()] = out
copied, err := copyValue(v.Elem())
if err != nil {
return reflect.Value{}, err
}
out.Elem().Set(copied)
return out, nil
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
out.Set(v)
for i := 0; i < v.NumField(); i++ {
field := v.Type().Field(i).Name
value := v.Field(i)
if nameFields[field] && value.Kind() == reflect.String {
out.Field(i).SetString(strings.ToLower(value.String()))
continue
}
if !(v.Type() == reflect.TypeOf(models.Index{}) && field == "Columns") && listFields[field] && value.Kind() == reflect.Slice && value.Type().Elem().Kind() == reflect.String {
list := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
for j := 0; j < value.Len(); j++ {
list.Index(j).SetString(strings.ToLower(value.Index(j).String()))
}
out.Field(i).Set(list)
continue
}
if field == "Metadata" || field == "Default" || field == "Properties" || field == "Permissions" {
continue
}
copied, err := copyValue(value)
if err != nil {
return reflect.Value{}, err
}
out.Field(i).Set(copied)
}
if v.Type() == reflect.TypeOf(models.Table{}) {
original := v.Interface().(models.Table)
copied := out.Addr().Interface().(*models.Table)
for originalKey, index := range original.Indexes {
if index == nil {
continue
}
target := copied.Indexes[strings.ToLower(originalKey)]
target.Columns = append([]string(nil), index.Columns...)
for i, reference := range index.Columns {
for _, column := range original.Columns {
if column != nil && (reference == column.Name || reference == `"`+strings.ReplaceAll(column.Name, `"`, `""`)+`"`) {
target.Columns[i] = strings.ToLower(column.Name)
break
}
}
}
}
}
return out, nil
case reflect.Slice:
if v.IsNil() {
return v, nil
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
names := map[string]bool{}
for i := 0; i < v.Len(); i++ {
copied, err := copyValue(v.Index(i))
if err != nil {
return reflect.Value{}, err
}
out.Index(i).Set(copied)
if copied.Kind() == reflect.Pointer && !copied.IsNil() && copied.Elem().Kind() == reflect.Struct {
name := copied.Elem().FieldByName("Name")
if name.IsValid() {
n := name.String()
if names[n] {
return reflect.Value{}, fmt.Errorf("lowercase identifier collision: %q", n)
}
names[n] = true
}
}
}
return out, nil
case reflect.Map:
if v.IsNil() || v.Type().Elem().Kind() != reflect.Pointer {
return v, nil
}
out := reflect.MakeMap(v.Type())
names := map[string]bool{}
iter := v.MapRange()
for iter.Next() {
key := iter.Key()
if key.Kind() == reflect.String {
key = reflect.ValueOf(strings.ToLower(key.String())).Convert(key.Type())
}
if out.MapIndex(key).IsValid() {
return reflect.Value{}, fmt.Errorf("lowercase identifier collision: %v", key.Interface())
}
copied, err := copyValue(iter.Value())
if err != nil {
return reflect.Value{}, err
}
if copied.Kind() == reflect.Pointer && !copied.IsNil() && copied.Elem().Kind() == reflect.Struct {
name := copied.Elem().FieldByName("Name")
if name.IsValid() {
n := name.String()
if names[n] {
return reflect.Value{}, fmt.Errorf("lowercase identifier collision: %q", n)
}
names[n] = true
}
}
out.SetMapIndex(key, copied)
}
return out, nil
}
return v, nil
}
value, err := copyValue(reflect.ValueOf(db))
if err != nil {
return nil, err
}
return value.Interface().(*models.Database), nil
}
+51
View File
@@ -0,0 +1,51 @@
package transform
import (
"git.warky.dev/wdevs/relspecgo/pkg/models"
"testing"
)
func TestLowercaseIdentifiersReferencesAndData(t *testing.T) {
db := models.InitDatabase("DB")
s := models.InitSchema("App")
tbl := models.InitTable("Users", "App")
col := models.InitColumn("ID", "Users", "App")
tbl.Tablespace = "FastStore"
col.Collation = "MyCollation"
col.Default = "Keep CASE"
col.GenerationExpression = `"ID" + 1`
tbl.Columns[col.Name] = col
tbl.Constraints["FK"] = &models.Constraint{Name: "FK", Columns: []string{"ID"}, ReferencedSchema: "Other", ReferencedTable: "Target", ReferencedColumns: []string{"Key"}}
tbl.Indexes["IDX"] = &models.Index{Name: "IDX", Columns: []string{"ID", "lower('VALUE')"}, Include: []string{"ID"}, Where: `"ID" > 0`}
tbl.RefSchema = s
s.Tables = append(s.Tables, tbl)
db.Schemas = append(db.Schemas, s)
got, err := LowercaseIdentifiers(db)
if err != nil {
t.Fatal(err)
}
table := got.Schemas[0].Tables[0]
if table.Tablespace != "faststore" || table.Columns["id"].Collation != "mycollation" || got.Name != "db" || table.Name != "users" || table.Schema != "app" || table.Columns["id"].Name != "id" || table.Constraints["fk"].ReferencedColumns[0] != "key" || table.Indexes["idx"].Include[0] != "id" || table.RefSchema != got.Schemas[0] {
t.Fatalf("bad normalized model: %+v", table)
}
if table.Columns["id"].Default != "Keep CASE" || table.Columns["id"].GenerationExpression != `"ID" + 1` || table.Indexes["idx"].Where != `"ID" > 0` || table.Indexes["idx"].Columns[1] != "lower('VALUE')" {
t.Fatal("data/expression changed")
}
if tbl.Name != "Users" || tbl.Columns["ID"] != col {
t.Fatal("input mutated")
}
}
func TestLowercaseIdentifierCollisions(t *testing.T) {
db := models.InitDatabase("db")
s := models.InitSchema("app")
db.Schemas = append(db.Schemas, s)
s.Tables = []*models.Table{models.InitTable("Users", "app"), models.InitTable("users", "app")}
if _, err := LowercaseIdentifiers(db); err == nil {
t.Fatal("expected table collision")
}
s.Tables = s.Tables[:1]
s.Tables[0].Columns = map[string]*models.Column{"ID": {Name: "ID"}, "id": {Name: "id"}}
if _, err := LowercaseIdentifiers(db); err == nil {
t.Fatal("expected column collision")
}
}
+17 -28
View File
@@ -94,6 +94,11 @@ func (w *Writer) WriteTable(t *models.Table) error {
}
func (w *Writer) writeTable(s *models.Schema, t *models.Table) error {
_, err := io.WriteString(w.writer, tableStatement(s, t))
return err
}
func tableStatement(s *models.Schema, t *models.Table) string {
name := t.Name
if s != nil {
name = fmt.Sprintf("%s.%s", quote(s.Name), quote(t.Name))
@@ -155,32 +160,27 @@ func (w *Writer) writeTable(s *models.Schema, t *models.Table) error {
defs = append(defs, fmt.Sprintf(" CONSTRAINT %s UNIQUE (%s)", quote(c.Name), quoted(c.Columns)))
}
}
sql := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n) ENGINE=InnoDB;\n\n", name, strings.Join(defs, ",\n"))
if _, err := io.WriteString(w.writer, sql); err != nil {
return err
return fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n) ENGINE=InnoDB;\n\n", name, strings.Join(defs, ",\n"))
}
return nil
func databaseStatements(db *models.Database) []string {
var statements []string
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
statements = append(statements, tableStatement(schema, table))
}
}
return statements
}
func (w *Writer) execute(dbm *models.Database, conn string) error {
var b strings.Builder
old := w.writer
w.writer = &b
if err := w.writeDatabaseDDL(dbm); err != nil {
w.writer = old
return err
}
w.writer = old
statements := databaseStatements(dbm)
db, err := sql.Open("mysql", conn)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
defer db.Close()
for _, stmt := range strings.Split(b.String(), ";\n") {
stmt = stripComments(strings.TrimSpace(stmt))
if stmt == "" {
continue
}
for _, stmt := range statements {
if _, err := db.ExecContext(context.Background(), stmt); err != nil {
return fmt.Errorf("failed to execute SQL: %w", err)
}
@@ -188,17 +188,6 @@ func (w *Writer) execute(dbm *models.Database, conn string) error {
return nil
}
func stripComments(sqlText string) string {
lines := strings.Split(sqlText, "\n")
kept := lines[:0]
for _, line := range lines {
if !strings.HasPrefix(strings.TrimSpace(line), "--") {
kept = append(kept, line)
}
}
return strings.TrimSpace(strings.Join(kept, "\n"))
}
func quote(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" }
func quoted(xs []string) string {
out := make([]string, len(xs))
+23 -7
View File
@@ -9,13 +9,6 @@ import (
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
func TestStripComments(t *testing.T) {
got := stripComments("-- header\nCREATE TABLE `users` (\n `id` INT\n);")
if strings.HasPrefix(got, "--") || !strings.HasPrefix(got, "CREATE TABLE") {
t.Fatalf("stripComments() = %q", got)
}
}
func TestWriterGeneratesMySQLDDL(t *testing.T) {
db := models.InitDatabase("app")
s := models.InitSchema("app")
@@ -42,3 +35,26 @@ func TestWriterGeneratesMySQLDDL(t *testing.T) {
}
}
}
func TestDatabaseStatementsKeepEmbeddedDelimiters(t *testing.T) {
db := models.InitDatabase("app")
s := models.InitSchema("app")
db.Schemas = append(db.Schemas, s)
for _, name := range []string{"one", "two"} {
table := models.InitTable(name, s.Name)
col := models.InitColumn("text", name, s.Name)
col.Type = "text"
col.Default = "first;\n-- data, not a comment\nlast"
table.Columns[col.Name] = col
s.Tables = append(s.Tables, table)
}
statements := databaseStatements(db)
if len(statements) != 2 {
t.Fatalf("got %d statements", len(statements))
}
for _, stmt := range statements {
if !strings.Contains(stmt, "first;\n-- data, not a comment\nlast") {
t.Fatalf("default corrupted: %s", stmt)
}
}
}
+22
View File
@@ -17,6 +17,28 @@ The PostgreSQL Writer converts RelSpec's internal database model representation
- Includes audit triggers (optional)
- Handles PostgreSQL-specific data types
## Identifier and value handling
Generated identifiers are always double-quoted, including reserved words, and
retain the model's case by default. Supply raw model names, without SQL quoting.
Identifiers containing double quotes are escaped by doubling them; NUL is rejected
before database/file output. Catalog checks, comments, audit values, and regclass
names use SQL literal quoting; generated procedural/function bodies choose dollar
delimiters absent from their rendered content. Names in generated line comments
cannot create new SQL lines.
Use CLI `--lowercase`, job option `lowercase_identifiers: true`, or PostgreSQL
`WriterOptions.LowercaseIdentifiers: true` to lowercase model names and structured
references. The transformation copies the model and rejects name collisions. Other
library writers can use `transform.LowercaseIdentifiers` before writing.
SQL expressions are executable input: types, defaults, generated expressions,
checks, index expressions/predicates, dialect directive clauses, audit user
expressions, and `pgsql_using` / `pgsql_preflight` are not sanitized or case-folded.
Adjust identifier references inside these expressions yourself when lowercasing.
Enum values, comments, and other data retain their case. Migration matching respects
identifier case; `"Users"` and `"users"` are distinct objects.
## Usage
### Basic Example
+5 -4
View File
@@ -22,8 +22,9 @@ func TestCurrentColumnHasDescription(t *testing.T) {
want bool
}{
{"nil table", nil, &models.Column{Name: "email", Description: "x"}, false},
{"match ignoring case and whitespace", table, &models.Column{Name: "email", Description: "the email"}, true},
{"different description", table, &models.Column{Name: "email", Description: "other"}, false},
{"match ignoring whitespace", table, &models.Column{Name: "Email", Description: "the email"}, true},
{"different description", table, &models.Column{Name: "Email", Description: "other"}, false},
{"different identifier case", table, &models.Column{Name: "email", Description: "the email"}, false},
{"column missing", table, &models.Column{Name: "age", Description: "x"}, false},
}
for _, tt := range tests {
@@ -41,12 +42,12 @@ func TestExecuteCommentColumn(t *testing.T) {
t.Fatal(err)
}
got, err := te.ExecuteCommentColumn(CommentColumnData{
SchemaName: "public", TableName: "users", ColumnName: "email", Comment: "it''s",
SchemaName: "public", TableName: "users", ColumnName: "email", Comment: "it's",
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "COMMENT ON COLUMN") || !strings.Contains(got, "public.users") ||
if !strings.Contains(got, "COMMENT ON COLUMN") || !strings.Contains(got, "\"public\".\"users\"") ||
!strings.Contains(got, "email") || !strings.Contains(got, "IS 'it''s';") {
t.Errorf("unexpected output: %s", got)
}
+11 -11
View File
@@ -57,8 +57,8 @@ func TestDiffStatements(t *testing.T) {
current: func() *models.Database {
return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}})
},
wantContain: []string{"CREATE TABLE IF NOT EXISTS public.posts"},
wantAbsent: []string{"public.users"},
wantContain: []string{"CREATE TABLE IF NOT EXISTS \"public\".\"posts\""},
wantAbsent: []string{"\"public\".\"users\""},
},
{
name: "missing column is added and existing columns are skipped",
@@ -68,7 +68,7 @@ func TestDiffStatements(t *testing.T) {
current: func() *models.Database {
return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}})
},
wantContain: []string{"ADD COLUMN IF NOT EXISTS email text"},
wantContain: []string{"ADD COLUMN IF NOT EXISTS \"email\" text"},
wantAbsent: []string{"COLUMN id"},
},
{
@@ -79,7 +79,7 @@ func TestDiffStatements(t *testing.T) {
current: func() *models.Database {
return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}})
},
wantContain: []string{"ALTER COLUMN id TYPE bigint"},
wantContain: []string{"ALTER COLUMN \"id\" TYPE bigint"},
},
{
name: "missing non-public schema is created before its tables",
@@ -88,8 +88,8 @@ func TestDiffStatements(t *testing.T) {
},
current: func() *models.Database { return models.InitDatabase("testdb") },
wantContain: []string{
"CREATE SCHEMA IF NOT EXISTS app",
"CREATE TABLE IF NOT EXISTS app.users",
"CREATE SCHEMA IF NOT EXISTS \"app\"",
"CREATE TABLE IF NOT EXISTS \"app\".\"users\"",
},
},
}
@@ -149,7 +149,7 @@ func TestDiffStatements_NewTableCreatesPrimaryKeySequence(t *testing.T) {
id := models.InitColumn("id", "users", "public")
id.Type = "integer"
id.IsPrimaryKey = true
id.Default = "nextval('public.users_id_seq'::regclass)"
id.Default = "nextval('\"public\".\"users_id_seq\"'::regclass)"
table.Columns["id"] = id
schema.Tables = append(schema.Tables, table)
model.Schemas = append(model.Schemas, schema)
@@ -160,7 +160,7 @@ func TestDiffStatements_NewTableCreatesPrimaryKeySequence(t *testing.T) {
t.Fatalf("diffStatements failed: %v", err)
}
out := diffJoin(stmts)
if !strings.Contains(out, "CREATE SEQUENCE IF NOT EXISTS public.users_id_seq") {
if !strings.Contains(out, "CREATE SEQUENCE IF NOT EXISTS \"public\".\"users_id_seq\"") {
t.Fatalf("expected sequence creation, got:\n%s", out)
}
@@ -193,7 +193,7 @@ func TestDiffStatements_NewIndexAndChangedIndexRecreated(t *testing.T) {
if err != nil {
t.Fatalf("diffStatements failed: %v", err)
}
if !strings.Contains(diffJoin(stmts), "CREATE INDEX IF NOT EXISTS idx_users_lookup") {
if !strings.Contains(diffJoin(stmts), "CREATE INDEX IF NOT EXISTS \"idx_users_lookup\"") {
t.Fatalf("expected index creation, got:\n%s", diffJoin(stmts))
}
@@ -329,7 +329,7 @@ func TestDiffStatements_ChangedPrimaryKeyIsRecreated(t *testing.T) {
if err != nil {
t.Fatalf("diffStatements failed: %v", err)
}
if !strings.Contains(diffJoin(stmts), "DROP CONSTRAINT IF EXISTS pk_public_users") {
if !strings.Contains(diffJoin(stmts), "DROP CONSTRAINT IF EXISTS \"pk_public_users\"") {
t.Fatalf("expected old primary key to be dropped, got:\n%s", diffJoin(stmts))
}
}
@@ -345,7 +345,7 @@ func TestDiffStatements_ChangedCommentEmitted(t *testing.T) {
if err != nil {
t.Fatalf("diffStatements failed: %v", err)
}
if !strings.Contains(diffJoin(stmts), "COMMENT ON TABLE public.users IS 'new'") {
if !strings.Contains(diffJoin(stmts), "COMMENT ON TABLE \"public\".\"users\" IS 'new'") {
t.Fatalf("expected changed comment, got:\n%s", diffJoin(stmts))
}
}
+2 -2
View File
@@ -55,7 +55,7 @@ func TestPgDirectives_WriteDatabasePath(t *testing.T) {
for _, want := range []string{
") PARTITION BY RANGE (created_at) TABLESPACE fast_data",
"GENERATED ALWAYS AS IDENTITY",
"WITH (fillfactor=90) TABLESPACE idx_space",
"WITH (fillfactor=90) TABLESPACE \"idx_space\"",
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
@@ -78,7 +78,7 @@ func TestPgDirectives_WriteSchemaPath(t *testing.T) {
if !strings.Contains(out, ") PARTITION BY RANGE (created_at) TABLESPACE fast_data;") {
t.Errorf("table suffix missing from WriteSchema path:\n%s", out)
}
if !strings.Contains(out, "WITH (fillfactor=90) TABLESPACE idx_space") {
if !strings.Contains(out, "WITH (fillfactor=90) TABLESPACE \"idx_space\"") {
t.Errorf("index clauses missing from WriteSchema path:\n%s", out)
}
}
+2 -2
View File
@@ -88,8 +88,8 @@ func TestGeneratedColumns_FullDDL(t *testing.T) {
}
out := diffJoin(stmts)
for _, s := range []string{
"full_name text GENERATED ALWAYS AS (first || ' ' || last) STORED",
"seq bigint GENERATED ALWAYS AS IDENTITY NOT NULL",
"\"full_name\" text GENERATED ALWAYS AS (first || ' ' || last) STORED",
"\"seq\" bigint GENERATED ALWAYS AS IDENTITY NOT NULL",
} {
if !strings.Contains(out, s) {
t.Errorf("missing %q:\n%s", s, out)
+116
View File
@@ -0,0 +1,116 @@
package pgsql
import (
"fmt"
"reflect"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/transform"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
// prepareModel rejects NUL before creating files or executing SQL. SQL names
// cannot contain NUL; pgx's identifier sanitizer would otherwise remove it.
func prepareModel(db *models.Database, lower bool) (*models.Database, error) {
if db == nil {
return nil, fmt.Errorf("database is required")
}
seen := map[any]bool{}
var validate func(reflect.Value) error
validate = func(v reflect.Value) error {
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() || v.Type().Elem().PkgPath() != reflect.TypeOf(models.Database{}).PkgPath() {
return nil
}
if seen[v.Interface()] {
return nil
}
seen[v.Interface()] = true
return validate(v.Elem())
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if v.Type().Field(i).Name == "Metadata" {
continue
}
if err := validate(v.Field(i)); err != nil {
return err
}
}
case reflect.String:
if strings.ContainsRune(v.String(), 0) {
return fmt.Errorf("PostgreSQL model contains a NUL byte")
}
case reflect.Slice:
for i := 0; i < v.Len(); i++ {
if err := validate(v.Index(i)); err != nil {
return err
}
}
case reflect.Map:
if v.Type().Elem().Kind() == reflect.Pointer {
iter := v.MapRange()
for iter.Next() {
if err := validate(iter.Value()); err != nil {
return err
}
}
}
}
return nil
}
if err := validate(reflect.ValueOf(db)); err != nil {
return nil, err
}
if lower {
return transform.LowercaseIdentifiers(db)
}
return db, nil
}
func (w *Writer) prepareSchema(schema *models.Schema) (*models.Schema, error) {
db, err := prepareModel(&models.Database{Schemas: []*models.Schema{schema}}, w.options.LowercaseIdentifiers)
if err != nil {
return nil, err
}
return db.Schemas[0], nil
}
// regclassLastName decodes quoted identifiers in a qualified regclass spelling.
func regclassLastName(text string) string {
var name strings.Builder
quoted := false
for i := 0; i < len(text); i++ {
c := text[i]
if c == '"' {
if quoted && i+1 < len(text) && text[i+1] == '"' {
name.WriteByte('"')
i++
} else {
quoted = !quoted
}
continue
}
if c == '.' && !quoted {
name.Reset()
continue
}
name.WriteByte(c)
}
return name.String()
}
// quoteDefaultValue keeps explicit SQL expressions intact and uses the same
// backslash-safe literal quoting as other PostgreSQL value contexts.
func quoteDefaultValue(value, sqlType string) string {
trimmed := strings.TrimSpace(value)
if len(trimmed) >= 3 && (trimmed[0] == 'E' || trimmed[0] == 'e') && trimmed[1] == '\'' && trimmed[len(trimmed)-1] == '\'' {
return trimmed
}
formatted := writers.QuoteDefaultValue(value, sqlType)
if formatted == "'"+strings.ReplaceAll(trimmed, "'", "''")+"'" {
return quote(trimmed)
}
return formatted
}
+45 -35
View File
@@ -61,6 +61,16 @@ func (w *MigrationWriter) GenerateScripts(model, current *models.Database) ([]Mi
current = models.InitDatabase(model.Name)
}
var err error
model, err = prepareModel(model, w.options.LowercaseIdentifiers)
if err != nil {
return nil, err
}
current, err = prepareModel(current, false)
if err != nil {
return nil, err
}
// Check if audit is configured in metadata
var auditConfig *AuditConfig
if w.options.Metadata != nil {
@@ -93,7 +103,7 @@ func (w *MigrationWriter) GenerateScripts(model, current *models.Database) ([]Mi
if cs == nil {
continue
}
if strings.EqualFold(cs.Name, modelSchema.Name) {
if cs.Name == modelSchema.Name {
currentSchema = cs
break
}
@@ -166,7 +176,7 @@ func (w *MigrationWriter) WriteMigration(model, current *models.Database) error
// Write header
fmt.Fprintf(w.writer, "-- PostgreSQL Migration Script\n")
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
fmt.Fprintf(w.writer, "-- Source: %s -> %s\n", current.Name, model.Name)
fmt.Fprintf(w.writer, "-- Source: %s -> %s\n", commentText(current.Name), commentText(model.Name))
if w.options.ContinueOnError {
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
}
@@ -175,7 +185,7 @@ func (w *MigrationWriter) WriteMigration(model, current *models.Database) error
// Write scripts
for _, script := range scripts {
fmt.Fprintf(w.writer, "-- Priority: %d | Type: %s | Object: %s\n",
script.Priority, script.ObjectType, script.ObjectName)
script.Priority, commentText(script.ObjectType), commentText(script.ObjectName))
fmt.Fprintf(w.writer, "%s\n\n", script.Body)
}
@@ -256,12 +266,12 @@ func (w *MigrationWriter) generateDropScripts(model, current *models.Schema) ([]
// Build map of model tables for quick lookup
modelTables := make(map[string]*models.Table)
for _, table := range model.Tables {
modelTables[strings.ToLower(table.Name)] = table
modelTables[table.Name] = table
}
// Find constraints to drop
for _, currentTable := range current.Tables {
modelTable, existsInModel := modelTables[strings.ToLower(currentTable.Name)]
modelTable, existsInModel := modelTables[currentTable.Name]
if !existsInModel {
continue
@@ -296,7 +306,7 @@ func (w *MigrationWriter) generateDropScripts(model, current *models.Schema) ([]
if refSchema == "" {
refSchema = current.Name
}
if strings.EqualFold(refTable, currentTable.Name) && strings.EqualFold(refSchema, current.Name) {
if (refTable == currentTable.Name) && (refSchema == current.Name) {
fkKey := fmt.Sprintf("%s.%s.%s", current.Name, otherTable.Name, fkName)
if !droppedFKs[fkKey] {
droppedFKs[fkKey] = true
@@ -395,13 +405,13 @@ func (w *MigrationWriter) generateTableScripts(model, current *models.Schema) ([
currentTables := make(map[string]*models.Table)
if current != nil {
for _, table := range current.Tables {
currentTables[strings.ToLower(table.Name)] = table
currentTables[table.Name] = table
}
}
// Process each model table
for _, modelTable := range model.Tables {
currentTable, exists := currentTables[strings.ToLower(modelTable.Name)]
currentTable, exists := currentTables[modelTable.Name]
if !exists {
// Table doesn't exist, create it
@@ -439,12 +449,12 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
// Build map of current columns
currentColumns := make(map[string]*models.Column)
for name, col := range currentTable.Columns {
currentColumns[strings.ToLower(name)] = col
currentColumns[name] = col
}
// Check each model column
for _, modelCol := range sortColumns(modelTable.Columns) {
currentCol, exists := currentColumns[strings.ToLower(modelCol.Name)]
currentCol, exists := currentColumns[modelCol.Name]
if !exists {
// Column doesn't exist, add it
@@ -574,13 +584,13 @@ func (w *MigrationWriter) generateIndexScripts(model, current *models.Schema) ([
currentTables := make(map[string]*models.Table)
if current != nil {
for _, table := range current.Tables {
currentTables[strings.ToLower(table.Name)] = table
currentTables[table.Name] = table
}
}
// Process each model table
for _, modelTable := range model.Tables {
currentTable := currentTables[strings.ToLower(modelTable.Name)]
currentTable := currentTables[modelTable.Name]
// Process primary keys first - check explicit constraints
foundExplicitPK := false
@@ -603,7 +613,7 @@ func (w *MigrationWriter) generateIndexScripts(model, current *models.Schema) ([
SchemaName: model.Name,
TableName: modelTable.Name,
ConstraintName: constraintName,
Columns: strings.Join(constraint.Columns, ", "),
Columns: quotedIdentifiers(constraint.Columns),
})
if err != nil {
return nil, err
@@ -627,12 +637,12 @@ func (w *MigrationWriter) generateIndexScripts(model, current *models.Schema) ([
pkColumns := []string{}
for _, col := range modelTable.Columns {
if col.IsPrimaryKey {
pkColumns = append(pkColumns, col.SQLName())
pkColumns = append(pkColumns, col.Name)
}
}
if len(pkColumns) > 0 {
sort.Strings(pkColumns)
constraintName := fmt.Sprintf("pk_%s_%s", model.SQLName(), modelTable.SQLName())
constraintName := fmt.Sprintf("pk_%s_%s", model.Name, modelTable.Name)
shouldCreate := true
if currentTable != nil {
@@ -650,7 +660,7 @@ func (w *MigrationWriter) generateIndexScripts(model, current *models.Schema) ([
SchemaName: model.Name,
TableName: modelTable.Name,
ConstraintName: constraintName,
Columns: strings.Join(pkColumns, ", "),
Columns: quotedIdentifiers(pkColumns),
})
if err != nil {
return nil, err
@@ -750,7 +760,7 @@ func buildIndexColumnExpressionsFiltered(table *models.Table, index *models.Inde
continue
}
colExpr := col.SQLName()
colExpr := quoteIdent(col.Name)
if opClass := indexOperatorClassForColumn(col, indexType, index.Comment); opClass != "" {
colExpr = fmt.Sprintf("%s %s", colExpr, opClass)
}
@@ -767,12 +777,12 @@ func (w *MigrationWriter) generateUniqueConstraintScripts(model, current *models
currentTables := make(map[string]*models.Table)
if current != nil {
for _, table := range current.Tables {
currentTables[strings.ToLower(table.Name)] = table
currentTables[table.Name] = table
}
}
for _, modelTable := range model.Tables {
currentTable := currentTables[strings.ToLower(modelTable.Name)]
currentTable := currentTables[modelTable.Name]
for _, constraint := range sortConstraints(modelTable.Constraints) {
if constraint.Type != models.UniqueConstraint {
@@ -788,7 +798,7 @@ func (w *MigrationWriter) generateUniqueConstraintScripts(model, current *models
columnExprs := make([]string, 0, len(constraint.Columns))
for _, colName := range constraint.Columns {
if col, ok := modelTable.Columns[colName]; ok {
columnExprs = append(columnExprs, col.SQLName())
columnExprs = append(columnExprs, quoteIdent(col.Name))
}
}
if len(columnExprs) == 0 {
@@ -838,7 +848,7 @@ func adoptableUniqueIndex(table *models.Table, constraint *models.Constraint) st
return ""
}
for i, col := range index.Columns {
if !strings.EqualFold(col, constraint.Columns[i]) {
if !(col == constraint.Columns[i]) {
return ""
}
}
@@ -855,13 +865,13 @@ func (w *MigrationWriter) generateForeignKeyScripts(model, current *models.Schem
currentTables := make(map[string]*models.Table)
if current != nil {
for _, table := range current.Tables {
currentTables[strings.ToLower(table.Name)] = table
currentTables[table.Name] = table
}
}
// Process each model table
for _, modelTable := range model.Tables {
currentTable := currentTables[strings.ToLower(modelTable.Name)]
currentTable := currentTables[modelTable.Name]
// Process each constraint
for _, constraint := range sortConstraints(modelTable.Constraints) {
@@ -904,10 +914,10 @@ func (w *MigrationWriter) generateForeignKeyScripts(model, current *models.Schem
SchemaName: model.Name,
TableName: modelTable.Name,
ConstraintName: constraintName,
SourceColumns: strings.Join(constraint.Columns, ", "),
SourceColumns: quotedIdentifiers(constraint.Columns),
TargetSchema: constraint.ReferencedSchema,
TargetTable: constraint.ReferencedTable,
TargetColumns: strings.Join(constraint.ReferencedColumns, ", "),
TargetColumns: quotedIdentifiers(constraint.ReferencedColumns),
OnDelete: onDelete,
OnUpdate: onUpdate,
Deferral: deferral,
@@ -939,20 +949,20 @@ func (w *MigrationWriter) generateCommentScripts(model, current *models.Schema)
currentTables := make(map[string]*models.Table)
if current != nil {
for _, table := range current.Tables {
currentTables[strings.ToLower(table.Name)] = table
currentTables[table.Name] = table
}
}
// Process each model table
for _, modelTable := range model.Tables {
currentTable := currentTables[strings.ToLower(modelTable.Name)]
currentTable := currentTables[modelTable.Name]
// Table comment (skipped when the live table already carries the same comment)
if modelTable.Description != "" && (currentTable == nil || strings.TrimSpace(currentTable.Description) != strings.TrimSpace(modelTable.Description)) {
sql, err := w.executor.ExecuteCommentTable(CommentTableData{
SchemaName: model.Name,
TableName: modelTable.Name,
Comment: escapeQuote(modelTable.Description),
Comment: modelTable.Description,
})
if err != nil {
return nil, err
@@ -976,7 +986,7 @@ func (w *MigrationWriter) generateCommentScripts(model, current *models.Schema)
SchemaName: model.Name,
TableName: modelTable.Name,
ColumnName: col.Name,
Comment: escapeQuote(col.Description),
Comment: col.Description,
})
if err != nil {
return nil, err
@@ -1229,21 +1239,21 @@ func constraintsEqual(c1, c2 *models.Constraint) bool {
return false
}
for i, col := range c1.Columns {
if !strings.EqualFold(col, c2.Columns[i]) {
if !(col == c2.Columns[i]) {
return false
}
}
// For foreign keys, also compare referenced table and columns
if c1.Type == models.ForeignKeyConstraint {
if !strings.EqualFold(c1.ReferencedTable, c2.ReferencedTable) {
if !(c1.ReferencedTable == c2.ReferencedTable) {
return false
}
if len(c1.ReferencedColumns) != len(c2.ReferencedColumns) {
return false
}
for i, col := range c1.ReferencedColumns {
if !strings.EqualFold(col, c2.ReferencedColumns[i]) {
if !(col == c2.ReferencedColumns[i]) {
return false
}
}
@@ -1273,7 +1283,7 @@ func indexesEqual(idx1, idx2 *models.Index) bool {
return false
}
for i, col := range idx1.Columns {
if !strings.EqualFold(col, idx2.Columns[i]) {
if !(col == idx2.Columns[i]) {
return false
}
}
@@ -1327,7 +1337,7 @@ func currentColumnHasDescription(currentTable *models.Table, col *models.Column)
return false
}
for _, cc := range currentTable.Columns {
if strings.EqualFold(cc.Name, col.Name) {
if cc.Name == col.Name {
return strings.TrimSpace(cc.Description) == strings.TrimSpace(col.Description)
}
}
@@ -1355,7 +1365,7 @@ func primaryKeyColumnsMatch(modelTable *models.Table, current *models.Constraint
return false
}
for i, col := range modelCols {
if !strings.EqualFold(col, current.Columns[i]) {
if !(col == current.Columns[i]) {
return false
}
}
+27 -27
View File
@@ -89,7 +89,7 @@ func TestWriteMigration_ArrayDefault(t *testing.T) {
}
output := buf.String()
if !strings.Contains(output, "tags text[] DEFAULT '{}' NOT NULL") {
if !strings.Contains(output, "\"tags\" text[] DEFAULT '{}' NOT NULL") {
t.Fatalf("expected normalized array default in migration, got:\n%s", output)
}
if strings.Contains(output, "'''{}'''") {
@@ -128,10 +128,10 @@ func TestWriteMigration_AltersColumnTypeWhenActualTypeDiffers(t *testing.T) {
}
output := buf.String()
if !strings.Contains(output, "ALTER TABLE public.learnings") || !strings.Contains(output, "ALTER COLUMN details TYPE text") {
if !strings.Contains(output, "ALTER TABLE \"public\".\"learnings\"") || !strings.Contains(output, "ALTER COLUMN \"details\" TYPE text") {
t.Fatalf("expected migration to alter mismatched column type, got:\n%s", output)
}
if !strings.Contains(output, `ALTER COLUMN details TYPE text USING details::text;`) {
if !strings.Contains(output, `ALTER COLUMN "details" TYPE text USING "details"::text;`) {
t.Fatalf("expected migration type alter to include USING cast, got:\n%s", output)
}
}
@@ -167,7 +167,7 @@ func TestWriteMigration_AltersColumnTypePreservesConversionFailure(t *testing.T)
}
output := buf.String()
if !strings.Contains(output, "ALTER COLUMN details TYPE integer USING details::integer;") {
if !strings.Contains(output, "ALTER COLUMN \"details\" TYPE integer USING \"details\"::integer;") {
t.Fatalf("expected explicit type conversion, got:\n%s", output)
}
for _, unsafe := range []string{"EXCEPTION WHEN OTHERS", "renamed_column", "RENAME COLUMN"} {
@@ -210,7 +210,7 @@ func TestWriteMigration_AltersColumnNullabilityWhenNotNullDiffers(t *testing.T)
}
output := buf.String()
if !strings.Contains(output, "ALTER COLUMN rid_service_instance_type DROP NOT NULL") {
if !strings.Contains(output, "ALTER COLUMN \"rid_service_instance_type\" DROP NOT NULL") {
t.Fatalf("expected migration to drop NOT NULL on existing column, got:\n%s", output)
}
}
@@ -331,13 +331,13 @@ func TestWriteMigration_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
}
output := buf.String()
if !strings.Contains(output, "ALTER COLUMN id TYPE bigint") {
if !strings.Contains(output, "ALTER COLUMN \"id\" TYPE bigint") {
t.Fatalf("expected serial alter to use bigint storage type, got:\n%s", output)
}
if strings.Contains(output, "ALTER COLUMN id TYPE bigserial;") {
if strings.Contains(output, "ALTER COLUMN \"id\" TYPE bigserial;") {
t.Fatalf("did not expect invalid bigserial alter statement, got:\n%s", output)
}
if !strings.Contains(output, `ALTER COLUMN id TYPE bigint USING id::bigint;`) {
if !strings.Contains(output, `ALTER COLUMN "id" TYPE bigint USING "id"::bigint;`) {
t.Fatalf("expected serial alter to include USING cast, got:\n%s", output)
}
}
@@ -373,7 +373,7 @@ func TestWriteMigration_ArrayAlterIncludesUsingCast(t *testing.T) {
}
output := buf.String()
if !strings.Contains(output, `ALTER COLUMN tags TYPE text[] USING tags::text[];`) {
if !strings.Contains(output, `ALTER COLUMN "tags" TYPE text[] USING "tags"::text[];`) {
t.Fatalf("expected array alter to include USING cast, got:\n%s", output)
}
}
@@ -410,7 +410,7 @@ func TestWriteMigration_DoesNotAlterEquivalentNormalizedColumnType(t *testing.T)
}
output := buf.String()
if strings.Contains(output, "ALTER COLUMN email TYPE") {
if strings.Contains(output, "ALTER COLUMN \"email\" TYPE") {
t.Fatalf("did not expect alter type for equivalent normalized types, got:\n%s", output)
}
}
@@ -493,7 +493,7 @@ func TestWriteMigration_GinIndexOnTextUsesTrigramOperatorClass(t *testing.T) {
if !strings.Contains(output, "CREATE EXTENSION IF NOT EXISTS pg_trgm;") {
t.Fatalf("expected trigram extension for text GIN migration index, got:\n%s", output)
}
if !strings.Contains(output, "USING gin (title gin_trgm_ops)") {
if !strings.Contains(output, "USING gin (\"title\" gin_trgm_ops)") {
t.Fatalf("expected GIN text index to include gin_trgm_ops, got:\n%s", output)
}
}
@@ -514,7 +514,7 @@ func TestWriteMigration_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *te
index := &models.Index{
Name: "idx_agent_personas_name_gin",
Type: "gin",
Columns: []string{`"name"`},
Columns: []string{"name"},
}
table.Indexes[index.Name] = index
@@ -533,7 +533,7 @@ func TestWriteMigration_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *te
}
output := buf.String()
if !strings.Contains(output, "USING gin (name gin_trgm_ops)") {
if !strings.Contains(output, "USING gin (\"name\" gin_trgm_ops)") {
t.Fatalf("expected quoted text column GIN index to include gin_trgm_ops, got:\n%s", output)
}
}
@@ -573,7 +573,7 @@ func TestWriteMigration_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *tes
}
output := buf.String()
if !strings.Contains(output, "USING gin (tags array_ops)") {
if !strings.Contains(output, "USING gin (\"tags\" array_ops)") {
t.Fatalf("expected GIN array index with array_ops, got:\n%s", output)
}
if strings.Contains(output, "gin_trgm_ops") {
@@ -616,7 +616,7 @@ func TestWriteMigration_GinIndexOnJSONBUsesJSONBOperatorClass(t *testing.T) {
}
output := buf.String()
if !strings.Contains(output, "USING gin (details jsonb_ops)") {
if !strings.Contains(output, "USING gin (\"details\" jsonb_ops)") {
t.Fatalf("expected GIN jsonb index to include jsonb_ops, got:\n%s", output)
}
if strings.Contains(output, "gin_trgm_ops") {
@@ -660,7 +660,7 @@ func TestWriteMigration_GinIndexOnJSONBIgnoresIncompatibleTrigramOperatorClass(t
}
output := buf.String()
if !strings.Contains(output, "USING gin (details jsonb_ops)") {
if !strings.Contains(output, "USING gin (\"details\" jsonb_ops)") {
t.Fatalf("expected incompatible trigram hint on jsonb to fall back to jsonb_ops, got:\n%s", output)
}
}
@@ -721,20 +721,20 @@ func TestWriteMigration_WithAudit(t *testing.T) {
t.Logf("Generated migration with audit:\n%s", output)
// Verify audit tables
if !strings.Contains(output, "CREATE TABLE IF NOT EXISTS public.atevent") {
if !strings.Contains(output, "CREATE TABLE IF NOT EXISTS \"public\".atevent") {
t.Error("Migration missing atevent table")
}
if !strings.Contains(output, "CREATE TABLE IF NOT EXISTS public.atdetail") {
if !strings.Contains(output, "CREATE TABLE IF NOT EXISTS \"public\".atdetail") {
t.Error("Migration missing atdetail table")
}
// Verify audit function
if !strings.Contains(output, "CREATE OR REPLACE FUNCTION public.tf_audit_users()") {
if !strings.Contains(output, "CREATE OR REPLACE FUNCTION \"public\".\"tf_audit_users\"()") {
t.Error("Migration missing audit function")
}
// Verify audit trigger
if !strings.Contains(output, "CREATE TRIGGER t_audit_users") {
if !strings.Contains(output, "CREATE TRIGGER \"t_audit_users\"") {
t.Error("Migration missing audit trigger")
}
@@ -766,13 +766,13 @@ func TestTemplateExecutor_CreateTable(t *testing.T) {
t.Logf("Generated SQL:\n%s", sql)
if !strings.Contains(sql, "CREATE TABLE IF NOT EXISTS public.test_table") {
if !strings.Contains(sql, "CREATE TABLE IF NOT EXISTS \"public\".\"test_table\"") {
t.Error("SQL missing CREATE TABLE statement")
}
if !strings.Contains(sql, "id integer NOT NULL") {
if !strings.Contains(sql, "\"id\" integer NOT NULL") {
t.Error("SQL missing id column definition")
}
if !strings.Contains(sql, "name text DEFAULT 'unknown'") {
if !strings.Contains(sql, "\"name\" text DEFAULT 'unknown'") {
t.Error("SQL missing name column definition")
}
}
@@ -810,7 +810,7 @@ func TestTemplateExecutor_AuditFunction(t *testing.T) {
t.Logf("Generated SQL:\n%s", sql)
if !strings.Contains(sql, "CREATE OR REPLACE FUNCTION public.tf_audit_users()") {
if !strings.Contains(sql, "CREATE OR REPLACE FUNCTION \"public\".\"tf_audit_users\"()") {
t.Error("SQL missing function definition")
}
if !strings.Contains(sql, "IF TG_OP = 'INSERT'") {
@@ -983,8 +983,8 @@ func TestWriteMigration_VectorAndPostGISIndexes(t *testing.T) {
"CREATE EXTENSION IF NOT EXISTS vector;",
"vector(1536)",
"geometry(Point,4326)",
"USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)",
"USING gist (location)",
"USING ivfflat (\"embedding\" vector_cosine_ops) WITH (lists = 100)",
"USING gist (\"location\")",
} {
if !strings.Contains(output, want) {
t.Fatalf("expected migration to contain %q, got:\n%s", want, output)
@@ -1181,7 +1181,7 @@ func TestWriteMigration_UniqueConstraintAdoptsSameNamedModelIndex(t *testing.T)
if got := strings.Contains(output, "UNIQUE USING INDEX"); got != tt.wantAdopt {
t.Errorf("USING INDEX present = %v, want %v; output:\n%s", got, tt.wantAdopt, output)
}
if got := strings.Contains(output, "UNIQUE (guid)") || strings.Contains(output, "UNIQUE (\"guid\")"); got == tt.wantAdopt {
if got := strings.Contains(output, "UNIQUE (\"guid\")") || strings.Contains(output, "UNIQUE (\"guid\")"); got == tt.wantAdopt {
t.Errorf("column-list ADD CONSTRAINT present = %v, want %v; output:\n%s", got, !tt.wantAdopt, output)
}
if got := strings.Contains(output, "DROP INDEX"); got != tt.wantDrop {
+251
View File
@@ -0,0 +1,251 @@
package pgsql
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
func TestQuotedNamesAndLowercaseOption(t *testing.T) {
db := models.InitDatabase("DB\nSELECT 'header';")
s := models.InitSchema("MySchema")
tbl := models.InitTable("Select", "MySchema")
col := models.InitColumn("Key", "Select", "MySchema")
col.Type = "text"
col.Default = "Keep CASE"
tbl.Columns[col.Name] = col
s.Tables = append(s.Tables, tbl)
db.Schemas = append(db.Schemas, s)
for _, lower := range []bool{false, true} {
w := NewWriter(&writers.WriterOptions{LowercaseIdentifiers: lower})
stmts, err := w.GenerateDatabaseStatements(db)
if err != nil {
t.Fatal(err)
}
out := strings.Join(stmts, "\n")
want := `"MySchema"."Select"`
if lower {
want = `"myschema"."select"`
}
if !strings.Contains(out, want) || !strings.Contains(out, "'Keep CASE'") || strings.Contains(out, "\nSELECT 'header';") {
t.Fatalf("%s", out)
}
}
if db.Name != "DB\nSELECT 'header';" || tbl.Name != "Select" {
t.Fatal("input changed")
}
path := filepath.Join(t.TempDir(), "out.sql")
if err := os.WriteFile(path, []byte("keep"), 0600); err != nil {
t.Fatal(err)
}
col.Name = "bad\x00name"
if err := NewWriter(&writers.WriterOptions{OutputPath: path}).WriteDatabase(db); err == nil {
t.Fatal("expected NUL rejection")
}
data, _ := os.ReadFile(path)
if string(data) != "keep" {
t.Fatal("output changed before validation")
}
}
func TestAllBlockTemplatesAvoidDelimiterCollisions(t *testing.T) {
te, err := NewTemplateExecutor(false)
if err != nil {
t.Fatal(err)
}
name := "名'\"\\\n$$ $body$ $relspec_0$ $relspec_1$"
cases := []struct {
name string
data any
}{
{"alter_column_default_with_check.tmpl", AlterColumnDefaultWithCheckData{SchemaName: name, TableName: name, ColumnName: name, SetDefault: true, DefaultValue: quote(name)}},
{"alter_column_nullability_with_check.tmpl", AlterColumnNullabilityWithCheckData{SchemaName: name, TableName: name, ColumnName: name}},
{"create_check_constraint.tmpl", CreateCheckConstraintData{SchemaName: name, TableName: name, ConstraintName: name, Expression: "true"}},
{"create_foreign_key_with_check.tmpl", CreateForeignKeyWithCheckData{SchemaName: name, TableName: name, ConstraintName: name}},
{"create_primary_key.tmpl", CreatePrimaryKeyData{SchemaName: name, TableName: name, ConstraintName: name}},
{"create_primary_key_with_autogen_check.tmpl", CreatePrimaryKeyWithAutoGenCheckData{SchemaName: name, TableName: name, ConstraintName: name}},
{"create_unique_constraint.tmpl", CreateUniqueConstraintData{SchemaName: name, TableName: name, ConstraintName: name}},
{"set_sequence_value.tmpl", SetSequenceValueData{SchemaName: name, TableName: name, SequenceName: name, ColumnName: name}},
{"audit_function.tmpl", AuditFunctionData{SchemaName: name, FunctionName: name, TableName: name, PrimaryKey: name, AuditSchema: name}},
{"audit_trigger.tmpl", AuditTriggerData{SchemaName: name, TableName: name, TriggerName: name, FunctionName: name}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := te.execute(tc.name, tc.data)
if err != nil {
t.Fatal(err)
}
if strings.Count(out, "$relspec_2$") != 2 || !strings.Contains(out, quoteIdent(name)) || !strings.Contains(out, quote(name)) {
t.Fatalf("%s", out)
}
})
}
}
func TestLive_AdversarialNamesAndAudit(t *testing.T) {
connString := liveWriterConn(t)
base, conn := liveWriterSchema(t, connString)
ctx := context.Background()
schemaName := base + "'\"\\\n名$relspec_0$"
tableName := "select"
key := "Key'\"\\\n$relspec_0$"
valueName := "名'\"\\\n$body$"
comment := "Comment ' \\ $$ $relspec_0$\nUnicode 名"
t.Cleanup(func() { _, _ = conn.Exec(ctx, "DROP SCHEMA IF EXISTS "+quoteIdent(schemaName)+" CASCADE") })
db := models.InitDatabase("test\nSELECT invalid_header;")
s := models.InitSchema(schemaName)
tbl := models.InitTable(tableName, schemaName)
id := models.InitColumn(key, tableName, schemaName)
id.Type = "integer"
id.IsPrimaryKey = true
id.NotNull = true
tbl.Columns[key] = id
value := models.InitColumn(valueName, tableName, schemaName)
value.Type = "text"
value.Default = quote(comment)
value.Description = comment
value.Comment = comment
tbl.Columns[valueName] = value
tbl.Description = comment
tbl.Comment = comment
tbl.Constraints["unique"] = &models.Constraint{Name: "unique'\"\\\n$relspec_0$", Type: models.UniqueConstraint, Columns: []string{valueName}}
tbl.Constraints["check"] = &models.Constraint{Name: "check'\"\\\n$relspec_0$", Type: models.CheckConstraint, Expression: quoteIdent(key) + ">0"}
tbl.Indexes["index"] = &models.Index{Name: "index'\"\\\n$relspec_0$", Type: "btree", Columns: []string{valueName}}
child := models.InitTable("from", schemaName)
child.Columns[key] = &models.Column{Name: key, Type: "integer"}
child.Constraints["fk"] = &models.Constraint{Name: "foreign'\"\\\n$relspec_0$", Type: models.ForeignKeyConstraint, Columns: []string{key}, ReferencedSchema: schemaName, ReferencedTable: tableName, ReferencedColumns: []string{key}}
s.Tables = []*models.Table{tbl, child}
db.Schemas = []*models.Schema{s}
w := NewWriter(&writers.WriterOptions{})
var output bytes.Buffer
w.writer = &output
if err := w.WriteDatabase(db); err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(ctx, output.String()); err != nil {
t.Fatalf("file SQL: %v\n%s", err, output.String())
}
if _, err := conn.Exec(ctx, "SET standard_conforming_strings=off"); err != nil {
t.Fatal(err)
}
statements, err := w.GenerateDatabaseStatements(db)
if err != nil {
t.Fatal(err)
}
for _, stmt := range statements {
if _, err := conn.Exec(ctx, stmt); err != nil {
t.Fatalf("statement: %v\n%s", err, stmt)
}
}
target := w.qualTable(schemaName, tableName)
if _, err := conn.Exec(ctx, "INSERT INTO "+target+" ("+quoteIdent(key)+") VALUES (1)"); err != nil {
t.Fatal(err)
}
var got string
if err := conn.QueryRow(ctx, "SELECT "+quoteIdent(valueName)+" FROM "+target).Scan(&got); err != nil || got != comment {
t.Fatalf("default: %q %v", got, err)
}
if err := conn.QueryRow(ctx, `SELECT col_description(c.oid,a.attnum) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_attribute a ON a.attrelid=c.oid WHERE n.nspname=$1 AND c.relname=$2 AND a.attname=$3`, schemaName, tableName, valueName).Scan(&got); err != nil || got != comment {
t.Fatalf("comment: %q %v", got, err)
}
// Migration templates must preserve the same identifier and literal boundaries.
current := models.InitDatabase("current")
currentSchema := models.InitSchema(schemaName)
currentTable := models.InitTable(tableName, schemaName)
currentValue := *value
currentValue.Type = "text"
currentTable.Columns[valueName] = &currentValue
currentID := *id
currentTable.Columns[key] = &currentID
currentTable.Constraints = tbl.Constraints
currentTable.Indexes = tbl.Indexes
currentSchema.Tables = []*models.Table{currentTable, child}
current.Schemas = []*models.Schema{currentSchema}
value.Type = "varchar"
value.Length = 200
migration, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatal(err)
}
var migrationOutput bytes.Buffer
migration.writer = &migrationOutput
if err := migration.WriteMigration(db, current); err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(ctx, migrationOutput.String()); err != nil {
t.Fatalf("migration: %v\n%s", err, migrationOutput.String())
}
if err := conn.QueryRow(ctx, "SELECT "+quoteIdent(valueName)+" FROM "+target+" /* after migration */").Scan(&got); err != nil || got != comment {
t.Fatalf("migration value: %q %v", got, err)
}
te := w.executor
sequenceName := "Seq'\"\\\n$relspec_0$"
sequenceSQL, err := te.ExecuteCreateSequence(CreateSequenceData{SchemaName: schemaName, SequenceName: sequenceName, Increment: 1, MinValue: 1, MaxValue: 1000, StartValue: 1, CacheSize: 1})
if err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(ctx, sequenceSQL); err != nil {
t.Fatal(err)
}
setvalSQL, err := te.ExecuteSetSequenceValue(SetSequenceValueData{SchemaName: schemaName, TableName: tableName, SequenceName: sequenceName, ColumnName: key})
if err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(ctx, setvalSQL); err != nil {
t.Fatalf("sequence: %v\n%s", err, setvalSQL)
}
var next int
if err := conn.QueryRow(ctx, "SELECT nextval($1::regclass)", w.qualTable(schemaName, sequenceName)).Scan(&next); err != nil || next != 2 {
t.Fatalf("sequence value: %d %v", next, err)
}
autoTable := w.qualTable(schemaName, "auto")
if _, err := conn.Exec(ctx, "CREATE TABLE "+autoTable+" (old integer PRIMARY KEY, new integer)"); err != nil {
t.Fatal(err)
}
pkSQL, err := te.ExecuteCreatePrimaryKeyWithAutoGenCheck(CreatePrimaryKeyWithAutoGenCheckData{SchemaName: schemaName, TableName: "auto", ConstraintName: "new'key", AutoGenNames: formatStringList([]string{"auto_pkey"}), Columns: quotedIdentifiers([]string{"new"}), ColumnNames: formatStringList([]string{"new"})})
if err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(ctx, pkSQL); err != nil {
t.Fatalf("primary key: %v\n%s", err, pkSQL)
}
auditTables, err := te.ExecuteAuditTables(AuditTablesData{AuditSchema: schemaName})
if err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(ctx, auditTables); err != nil {
t.Fatal(err)
}
config := &TableAuditConfig{AuditInsert: true, AuditUpdate: true, AuditDelete: true, TablePrefix: comment}
functionData := BuildAuditFunctionData(schemaName, tbl, id, config, schemaName, "current_user")
function, err := te.ExecuteAuditFunction(functionData)
if err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(ctx, function); err != nil {
t.Fatalf("audit function: %v\n%s", err, function)
}
trigger, err := te.ExecuteAuditTrigger(AuditTriggerData{SchemaName: schemaName, TableName: tableName, FunctionName: functionData.FunctionName, TriggerName: "trigger'\"\\\n$relspec_0$", Events: "INSERT OR UPDATE OR DELETE"})
if err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(ctx, trigger); err != nil {
t.Fatalf("audit trigger: %v\n%s", err, trigger)
}
for _, sql := range []string{"INSERT INTO " + target + " VALUES (2, 'second')", "UPDATE " + target + " SET " + quoteIdent(valueName) + "='third' WHERE " + quoteIdent(key) + "=2", "DELETE FROM " + target + " WHERE " + quoteIdent(key) + "=2"} {
if _, err := conn.Exec(ctx, sql); err != nil {
t.Fatal(err)
}
}
var count int
if err := conn.QueryRow(ctx, "SELECT count(*) FROM "+w.qualTable(schemaName, "atevent")+" WHERE tableprefix=$1", comment).Scan(&count); err != nil || count != 3 {
t.Fatalf("audit events: %d %v", count, err)
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ func TestDiffStatements_ExistingTableNewSequenceIsSetPastData(t *testing.T) {
t.Fatalf("diffStatements failed: %v", err)
}
out := diffJoin(stmts)
seqIdx := strings.Index(out, "CREATE SEQUENCE IF NOT EXISTS identity.login_event_id_login_event_seq")
seqIdx := strings.Index(out, "CREATE SEQUENCE IF NOT EXISTS \"identity\".\"login_event_id_login_event_seq\"")
setvalIdx := strings.Index(out, "setval(")
if seqIdx < 0 || setvalIdx < 0 || seqIdx > setvalIdx {
t.Fatalf("expected sequence creation before setval, got:\n%s", out)
+1 -1
View File
@@ -131,7 +131,7 @@ func TestFirstBareIdentAndHelpers(t *testing.T) {
"abc,def": "abc",
"abc;": "abc",
"\n abc\tdef": "abc",
`"a b" c`: `"a`,
`"a b" c`: `"a b"`,
" tbl (x int)": "tbl",
}
for in, want := range bare {
+11 -33
View File
@@ -24,6 +24,7 @@ func TemplateFunctions() map[string]interface{} {
"escape": escape,
"safe_identifier": safeIdentifier,
"quote_ident": quoteIdent,
"comment_text": commentText,
// Type conversion
"goTypeToSQL": goTypeToSQL,
@@ -99,6 +100,9 @@ func indent(spaces int, text string) string {
// quote quotes a string value for SQL (escapes single quotes)
func quote(s string) string {
if strings.Contains(s, "\\") {
return sqlLiteral(s)
}
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
@@ -125,41 +129,15 @@ func safeIdentifier(s string) string {
return strings.ToLower(safe)
}
// quoteIdent quotes a PostgreSQL identifier if necessary
// Identifiers need quoting if they:
// - Start with a digit
// - Contain special characters
// - Are reserved keywords
// - Contain uppercase letters (to preserve case)
func quoteIdent(s string) string {
if s == "" {
return `""`
}
// quoteIdent always quotes an identifier, preserving case and reserved words.
func quoteIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }
// Check if quoting is needed
needsQuoting := unicode.IsDigit(rune(s[0]))
// Starts with digit
// Contains uppercase letters or special characters
for _, r := range s {
if unicode.IsUpper(r) {
needsQuoting = true
break
func quotedIdentifiers(names []string) string {
out := make([]string, len(names))
for i, name := range names {
out[i] = quoteIdent(name)
}
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
needsQuoting = true
break
}
}
if needsQuoting {
// Escape double quotes by doubling them
escaped := strings.ReplaceAll(s, `"`, `""`)
return `"` + escaped + `"`
}
return s
return strings.Join(out, ", ")
}
// Type conversion functions
+3 -3
View File
@@ -106,7 +106,7 @@ func TestQuoteIdent(t *testing.T) {
input string
expected string
}{
{"valid_name", "valid_name"},
{"valid_name", `"valid_name"`},
{"ValidName", `"ValidName"`},
{"123column", `"123column"`},
{"215162_fk_constraint", `"215162_fk_constraint"`},
@@ -114,8 +114,8 @@ func TestQuoteIdent(t *testing.T) {
{"user@domain", `"user@domain"`},
{`"quoted"`, `"""quoted"""`},
{"", `""`},
{"lowercase", "lowercase"},
{"with_underscore", "with_underscore"},
{"lowercase", `"lowercase"`},
{"with_underscore", `"with_underscore"`},
}
for _, tt := range tests {
+67 -156
View File
@@ -8,7 +8,6 @@ import (
"text/template"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
//go:embed templates/*.tmpl
@@ -29,7 +28,7 @@ func NewTemplateExecutor(flattenSchema bool) (*TemplateExecutor, error) {
}
// qual_table returns a quoted, schema-qualified identifier.
// With flatten=false: "schema"."table" (or unquoted equivalents).
// With flatten=false: "schema"."table".
// With flatten=true: "schema_table".
funcMap["qual_table"] = func(schema, name string) string {
if schema == "" {
@@ -41,20 +40,20 @@ func NewTemplateExecutor(flattenSchema bool) (*TemplateExecutor, error) {
return quoteIdent(schema) + "." + quoteIdent(name)
}
// qual_table_raw is the same as qual_table but without identifier quoting.
funcMap["qual_table_raw"] = func(schema, name string) string {
if schema == "" {
return name
}
if flattenSchema {
return schema + "_" + name
}
return schema + "." + name
}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.tmpl")
funcMap["dollar_delimiter"] = func() string { return "$$" }
tmpl := template.New("").Funcs(funcMap)
entries, err := templateFS.ReadDir("templates")
if err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err)
return nil, err
}
for _, entry := range entries {
source, err := templateFS.ReadFile("templates/" + entry.Name())
if err != nil {
return nil, err
}
if _, err := tmpl.New(entry.Name()).Parse(string(source)); err != nil {
return nil, fmt.Errorf("parse %s: %w", entry.Name(), err)
}
}
return &TemplateExecutor{
@@ -62,6 +61,31 @@ func NewTemplateExecutor(flattenSchema bool) (*TemplateExecutor, error) {
}, nil
}
// execute chooses a delimiter absent from rendered data, including SQL expressions.
// Only explicit template calls emit delimiters; user text is never rewritten.
func (te *TemplateExecutor) execute(name string, data any) (string, error) {
for n := 0; ; n++ {
delimiter := fmt.Sprintf("$relspec_%d$", n)
count := 0
tmpl, err := te.templates.Clone()
if err != nil {
return "", err
}
tmpl.Funcs(template.FuncMap{"dollar_delimiter": func() string { count++; return delimiter }})
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", fmt.Errorf("execute %s: %w", name, err)
}
rendered := buf.String()
if strings.ContainsRune(rendered, 0) {
return "", fmt.Errorf("%s contains a NUL byte", name)
}
if strings.Count(rendered, delimiter) == count {
return rendered, nil
}
}
}
// Template data structures
// CreateTableData contains data for create table template
@@ -305,230 +329,117 @@ type CreatePrimaryKeyWithAutoGenCheckData struct {
// ExecuteCreateTable executes the create table template
func (te *TemplateExecutor) ExecuteCreateTable(data CreateTableData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_table.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_table template: %w", err)
}
return buf.String(), nil
return te.execute("create_table.tmpl", data)
}
// ExecuteAddColumn executes the add column template
func (te *TemplateExecutor) ExecuteAddColumn(data AddColumnData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "add_column.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute add_column template: %w", err)
}
return buf.String(), nil
return te.execute("add_column.tmpl", data)
}
// ExecuteAlterColumnTypeWithCheck executes the guarded alter column type
// template shared by the full-schema writer and the diff-based migration
// writer.
func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnTypeWithCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "alter_column_type_with_check.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute alter_column_type_with_check template: %w", err)
}
body := strings.TrimSpace(buf.String())
body = strings.TrimPrefix(body, "DO $$")
body = strings.TrimSuffix(body, "$$;")
return dollarBlock(strings.TrimSpace(body), "relspec_alter_type"), nil
return te.execute("alter_column_type_with_check.tmpl", data)
}
// ExecuteAlterColumnDefaultWithCheck executes the guarded alter column
// default template shared by the full-schema writer and the diff-based
// migration writer.
func (te *TemplateExecutor) ExecuteAlterColumnDefaultWithCheck(data AlterColumnDefaultWithCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "alter_column_default_with_check.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute alter_column_default_with_check template: %w", err)
}
return buf.String(), nil
return te.execute("alter_column_default_with_check.tmpl", data)
}
// ExecuteAlterColumnNullabilityWithCheck executes the guarded alter column
// nullability template.
func (te *TemplateExecutor) ExecuteAlterColumnNullabilityWithCheck(data AlterColumnNullabilityWithCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "alter_column_nullability_with_check.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute alter_column_nullability_with_check template: %w", err)
}
return buf.String(), nil
return te.execute("alter_column_nullability_with_check.tmpl", data)
}
// ExecuteCreatePrimaryKey executes the create primary key template
func (te *TemplateExecutor) ExecuteCreatePrimaryKey(data CreatePrimaryKeyData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_primary_key.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_primary_key template: %w", err)
}
return buf.String(), nil
return te.execute("create_primary_key.tmpl", data)
}
// ExecuteCreateIndex executes the create index template
func (te *TemplateExecutor) ExecuteCreateIndex(data CreateIndexData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_index.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_index template: %w", err)
}
return buf.String(), nil
return te.execute("create_index.tmpl", data)
}
// ExecuteCreateForeignKey executes the create foreign key template
func (te *TemplateExecutor) ExecuteCreateForeignKey(data CreateForeignKeyData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_foreign_key.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_foreign_key template: %w", err)
}
return buf.String(), nil
return te.execute("create_foreign_key.tmpl", data)
}
// ExecuteDropConstraint executes the drop constraint template
func (te *TemplateExecutor) ExecuteDropConstraint(data DropConstraintData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "drop_constraint.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute drop_constraint template: %w", err)
}
return buf.String(), nil
return te.execute("drop_constraint.tmpl", data)
}
// ExecuteDropIndex executes the drop index template
func (te *TemplateExecutor) ExecuteDropIndex(data DropIndexData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "drop_index.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute drop_index template: %w", err)
}
return buf.String(), nil
return te.execute("drop_index.tmpl", data)
}
// ExecuteCommentTable executes the table comment template
func (te *TemplateExecutor) ExecuteCommentTable(data CommentTableData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "comment_table.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute comment_table template: %w", err)
}
return buf.String(), nil
return te.execute("comment_table.tmpl", data)
}
// ExecuteCommentColumn executes the column comment template
func (te *TemplateExecutor) ExecuteCommentColumn(data CommentColumnData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "comment_column.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute comment_column template: %w", err)
}
return buf.String(), nil
return te.execute("comment_column.tmpl", data)
}
// ExecuteAuditTables executes the audit tables template
func (te *TemplateExecutor) ExecuteAuditTables(data AuditTablesData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "audit_tables.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute audit_tables template: %w", err)
}
return buf.String(), nil
return te.execute("audit_tables.tmpl", data)
}
// ExecuteAuditFunction executes the audit function template
func (te *TemplateExecutor) ExecuteAuditFunction(data AuditFunctionData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "audit_function.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute audit_function template: %w", err)
}
return buf.String(), nil
return te.execute("audit_function.tmpl", data)
}
// ExecuteAuditTrigger executes the audit trigger template
func (te *TemplateExecutor) ExecuteAuditTrigger(data AuditTriggerData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "audit_trigger.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute audit_trigger template: %w", err)
}
return buf.String(), nil
return te.execute("audit_trigger.tmpl", data)
}
// ExecuteCreateUniqueConstraint executes the create unique constraint template
func (te *TemplateExecutor) ExecuteCreateUniqueConstraint(data CreateUniqueConstraintData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_unique_constraint.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_unique_constraint template: %w", err)
}
return buf.String(), nil
return te.execute("create_unique_constraint.tmpl", data)
}
// ExecuteCreateCheckConstraint executes the create check constraint template
func (te *TemplateExecutor) ExecuteCreateCheckConstraint(data CreateCheckConstraintData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_check_constraint.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_check_constraint template: %w", err)
}
return buf.String(), nil
return te.execute("create_check_constraint.tmpl", data)
}
// ExecuteCreateForeignKeyWithCheck executes the create foreign key with check template
func (te *TemplateExecutor) ExecuteCreateForeignKeyWithCheck(data CreateForeignKeyWithCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_foreign_key_with_check.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_foreign_key_with_check template: %w", err)
}
return buf.String(), nil
return te.execute("create_foreign_key_with_check.tmpl", data)
}
// ExecuteSetSequenceValue executes the set sequence value template
func (te *TemplateExecutor) ExecuteSetSequenceValue(data SetSequenceValueData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "set_sequence_value.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute set_sequence_value template: %w", err)
}
return buf.String(), nil
return te.execute("set_sequence_value.tmpl", data)
}
// ExecuteCreateSequence executes the create sequence template
func (te *TemplateExecutor) ExecuteCreateSequence(data CreateSequenceData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_sequence.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_sequence template: %w", err)
}
return buf.String(), nil
return te.execute("create_sequence.tmpl", data)
}
// ExecuteAddColumnWithCheck executes the add column with check template
func (te *TemplateExecutor) ExecuteAddColumnWithCheck(data AddColumnWithCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "add_column_with_check.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute add_column_with_check template: %w", err)
}
return buf.String(), nil
return te.execute("add_column_with_check.tmpl", data)
}
// ExecuteCreatePrimaryKeyWithAutoGenCheck executes the create primary key with auto-generated key check template
func (te *TemplateExecutor) ExecuteCreatePrimaryKeyWithAutoGenCheck(data CreatePrimaryKeyWithAutoGenCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_primary_key_with_autogen_check.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_primary_key_with_autogen_check template: %w", err)
}
return buf.String(), nil
return te.execute("create_primary_key_with_autogen_check.tmpl", data)
}
// Helper functions to build template data from models
@@ -561,7 +472,7 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
colData.Generated, colData.Identity = columnGenerationClauses(col)
if col.Default != nil && colData.Generated == "" && colData.Identity == "" {
if value, ok := col.Default.(string); ok {
colData.Default = writers.QuoteDefaultValue(stripBackticks(value), col.Type)
colData.Default = quoteDefaultValue(stripBackticks(value), col.Type)
} else {
colData.Default = fmt.Sprintf("%v", col.Default)
}
@@ -612,7 +523,7 @@ func BuildAuditFunctionData(
updateComparisons := make([]string, 0)
for _, col := range auditedColumns {
updateComparisons = append(updateComparisons,
fmt.Sprintf("old.%s IS DISTINCT FROM new.%s", col.Name, col.Name))
fmt.Sprintf("old.%s IS DISTINCT FROM new.%s", quoteIdent(col.Name), quoteIdent(col.Name)))
}
updateCondition := strings.Join(updateComparisons, " OR ")
@@ -627,8 +538,8 @@ func BuildAuditFunctionData(
}
}
oldValue := fmt.Sprintf("old.%s::text", col.Name)
newValue := fmt.Sprintf("new.%s::text", col.Name)
oldValue := fmt.Sprintf("old.%s::text", quoteIdent(col.Name))
newValue := fmt.Sprintf("new.%s::text", quoteIdent(col.Name))
if isEncrypted {
oldValue = "'****************'"
@@ -653,7 +564,7 @@ func BuildAuditFunctionData(
}
}
oldValue := fmt.Sprintf("old.%s::text", col.Name)
oldValue := fmt.Sprintf("old.%s::text", quoteIdent(col.Name))
if isEncrypted {
oldValue = "'****************'"
}
@@ -666,7 +577,7 @@ func BuildAuditFunctionData(
tablePrefix := "NULL"
if config.TablePrefix != "" {
tablePrefix = fmt.Sprintf("'%s'", config.TablePrefix)
tablePrefix = quote(config.TablePrefix)
}
return AuditFunctionData{
@@ -1,4 +1,4 @@
DO $$
DO {{dollar_delimiter}}
DECLARE
current_default text;
BEGIN
@@ -8,9 +8,9 @@ BEGIN
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE n.nspname = '{{.SchemaName}}'
AND t.relname = '{{.TableName}}'
AND a.attname = '{{.ColumnName}}'
WHERE n.nspname = {{quote .SchemaName}}
AND t.relname = {{quote .TableName}}
AND a.attname = {{quote .ColumnName}}
AND a.attnum > 0
AND NOT a.attisdropped;
@@ -26,4 +26,4 @@ BEGIN
END IF;
{{- end }}
END;
$$;
{{dollar_delimiter}};
@@ -1,4 +1,4 @@
DO $$
DO {{dollar_delimiter}}
DECLARE
current_not_null boolean;
BEGIN
@@ -7,9 +7,9 @@ BEGIN
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = '{{.SchemaName}}'
AND t.relname = '{{.TableName}}'
AND a.attname = '{{.ColumnName}}'
WHERE n.nspname = {{quote .SchemaName}}
AND t.relname = {{quote .TableName}}
AND a.attname = {{quote .ColumnName}}
AND a.attnum > 0
AND NOT a.attisdropped;
@@ -23,4 +23,4 @@ BEGIN
{{- end }}
END IF;
END;
$$;
{{dollar_delimiter}};
@@ -1,4 +1,4 @@
DO $$
DO {{dollar_delimiter}}
DECLARE
current_type text;
BEGIN
@@ -19,4 +19,4 @@ BEGIN
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
END IF;
END;
$$;
{{dollar_delimiter}};
+16 -16
View File
@@ -1,8 +1,8 @@
CREATE OR REPLACE FUNCTION {{qual_table_raw .SchemaName .FunctionName}}()
CREATE OR REPLACE FUNCTION {{qual_table .SchemaName .FunctionName}}()
RETURNS trigger AS
$body$
{{dollar_delimiter}}
DECLARE
m_funcname text = '{{.FunctionName}}';
m_funcname text = {{quote .FunctionName}};
m_user text;
m_atevent integer;
BEGIN
@@ -23,8 +23,8 @@ BEGIN
{{- if .AuditInsert}}
IF TG_OP = 'INSERT' THEN
-- Record INSERT
INSERT INTO {{.AuditSchema}}.atevent (tablename, tableprefix, rid_parent, changeuser, changedate, changetime, actionx)
VALUES ('{{.TableName}}', {{.TablePrefix}}, new.{{.PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 1)
INSERT INTO {{quote_ident .AuditSchema}}.atevent (tablename, tableprefix, rid_parent, changeuser, changedate, changetime, actionx)
VALUES ({{quote .TableName}}, {{.TablePrefix}}, new.{{quote_ident .PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 1)
RETURNING rid_atevent INTO m_atevent;
{{- end}}
@@ -32,15 +32,15 @@ BEGIN
ELSIF TG_OP = 'UPDATE' THEN
-- Check if any audited columns changed
IF ({{.UpdateCondition}}) THEN
INSERT INTO {{.AuditSchema}}.atevent (tablename, tableprefix, rid_parent, changeuser, changedate, changetime, actionx)
VALUES ('{{.TableName}}', {{.TablePrefix}}, new.{{.PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 2)
INSERT INTO {{quote_ident .AuditSchema}}.atevent (tablename, tableprefix, rid_parent, changeuser, changedate, changetime, actionx)
VALUES ({{quote .TableName}}, {{.TablePrefix}}, new.{{quote_ident .PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 2)
RETURNING rid_atevent INTO m_atevent;
-- Record column changes
{{- range .UpdateColumns}}
IF (old.{{.Name}} IS DISTINCT FROM new.{{.Name}}) THEN
INSERT INTO {{$.AuditSchema}}.atdetail(rid_atevent, datacolumn, changedfrom, changedto)
VALUES (m_atevent, '{{.Name}}', substr({{.OldValue}}, 1, 1000), substr({{.NewValue}}, 1, 1000));
IF (old.{{quote_ident .Name}} IS DISTINCT FROM new.{{quote_ident .Name}}) THEN
INSERT INTO {{quote_ident $.AuditSchema}}.atdetail(rid_atevent, datacolumn, changedfrom, changedto)
VALUES (m_atevent, {{quote .Name}}, substr({{.OldValue}}, 1, 1000), substr({{.NewValue}}, 1, 1000));
END IF;
{{- end}}
END IF;
@@ -49,14 +49,14 @@ BEGIN
{{- if .AuditDelete}}
ELSIF TG_OP = 'DELETE' THEN
-- Record DELETE
INSERT INTO {{.AuditSchema}}.atevent (tablename, tableprefix, rid_parent, rid_deletedparent, changeuser, changedate, changetime, actionx)
VALUES ('{{.TableName}}', {{.TablePrefix}}, old.{{.PrimaryKey}}, old.{{.PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 3)
INSERT INTO {{quote_ident .AuditSchema}}.atevent (tablename, tableprefix, rid_parent, rid_deletedparent, changeuser, changedate, changetime, actionx)
VALUES ({{quote .TableName}}, {{.TablePrefix}}, old.{{quote_ident .PrimaryKey}}, old.{{quote_ident .PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 3)
RETURNING rid_atevent INTO m_atevent;
-- Record deleted column values
{{- range .DeleteColumns}}
INSERT INTO {{$.AuditSchema}}.atdetail(rid_atevent, datacolumn, changedfrom, changedto)
VALUES (m_atevent, '{{.Name}}', substr({{.OldValue}}, 1, 1000), NULL);
INSERT INTO {{quote_ident $.AuditSchema}}.atdetail(rid_atevent, datacolumn, changedfrom, changedto)
VALUES (m_atevent, {{quote .Name}}, substr({{.OldValue}}, 1, 1000), NULL);
{{- end}}
{{- end}}
END IF;
@@ -76,9 +76,9 @@ EXCEPTION
RAISE WARNING 'Audit function % failed: %', m_funcname, SQLERRM;
RETURN NULL;
END;
$body$
{{dollar_delimiter}}
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER;
COMMENT ON FUNCTION {{qual_table_raw .SchemaName .FunctionName}}() IS 'Audit trigger function for table {{qual_table_raw .SchemaName .TableName}}';
COMMENT ON FUNCTION {{qual_table .SchemaName .FunctionName}}() IS {{quote (printf "Audit trigger function for table %s" (qual_table .SchemaName .TableName))}};
+24 -24
View File
@@ -1,5 +1,5 @@
-- Audit Event Header Table
CREATE TABLE IF NOT EXISTS {{.AuditSchema}}.atevent (
CREATE TABLE IF NOT EXISTS {{quote_ident .AuditSchema}}.atevent (
rid_atevent serial PRIMARY KEY,
tablename text NOT NULL,
tableprefix text,
@@ -12,38 +12,38 @@ CREATE TABLE IF NOT EXISTS {{.AuditSchema}}.atevent (
CONSTRAINT ck_atevent_action CHECK (actionx IN (1, 2, 3))
);
CREATE INDEX IF NOT EXISTS idx_atevent_tablename ON {{.AuditSchema}}.atevent(tablename);
CREATE INDEX IF NOT EXISTS idx_atevent_rid_parent ON {{.AuditSchema}}.atevent(rid_parent);
CREATE INDEX IF NOT EXISTS idx_atevent_changedate ON {{.AuditSchema}}.atevent(changedate);
CREATE INDEX IF NOT EXISTS idx_atevent_changeuser ON {{.AuditSchema}}.atevent(changeuser);
CREATE INDEX IF NOT EXISTS idx_atevent_tablename ON {{quote_ident .AuditSchema}}.atevent(tablename);
CREATE INDEX IF NOT EXISTS idx_atevent_rid_parent ON {{quote_ident .AuditSchema}}.atevent(rid_parent);
CREATE INDEX IF NOT EXISTS idx_atevent_changedate ON {{quote_ident .AuditSchema}}.atevent(changedate);
CREATE INDEX IF NOT EXISTS idx_atevent_changeuser ON {{quote_ident .AuditSchema}}.atevent(changeuser);
COMMENT ON TABLE {{.AuditSchema}}.atevent IS 'Audit trail header table - tracks all data changes';
COMMENT ON COLUMN {{.AuditSchema}}.atevent.rid_atevent IS 'Audit event ID';
COMMENT ON COLUMN {{.AuditSchema}}.atevent.tablename IS 'Name of the table that was modified';
COMMENT ON COLUMN {{.AuditSchema}}.atevent.rid_parent IS 'Primary key value of the modified record';
COMMENT ON COLUMN {{.AuditSchema}}.atevent.rid_deletedparent IS 'Parent reference for deleted records';
COMMENT ON COLUMN {{.AuditSchema}}.atevent.changeuser IS 'User who made the change';
COMMENT ON COLUMN {{.AuditSchema}}.atevent.changedate IS 'Date of change';
COMMENT ON COLUMN {{.AuditSchema}}.atevent.changetime IS 'Time of change';
COMMENT ON COLUMN {{.AuditSchema}}.atevent.actionx IS 'Action type: 1=INSERT, 2=UPDATE, 3=DELETE';
COMMENT ON TABLE {{quote_ident .AuditSchema}}.atevent IS 'Audit trail header table - tracks all data changes';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.rid_atevent IS 'Audit event ID';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.tablename IS 'Name of the table that was modified';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.rid_parent IS 'Primary key value of the modified record';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.rid_deletedparent IS 'Parent reference for deleted records';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.changeuser IS 'User who made the change';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.changedate IS 'Date of change';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.changetime IS 'Time of change';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.actionx IS 'Action type: 1=INSERT, 2=UPDATE, 3=DELETE';
-- Audit Event Detail Table
CREATE TABLE IF NOT EXISTS {{.AuditSchema}}.atdetail (
CREATE TABLE IF NOT EXISTS {{quote_ident .AuditSchema}}.atdetail (
rid_atdetail serial PRIMARY KEY,
rid_atevent integer NOT NULL,
datacolumn text NOT NULL,
changedfrom text,
changedto text,
CONSTRAINT fk_atdetail_atevent FOREIGN KEY (rid_atevent)
REFERENCES {{.AuditSchema}}.atevent(rid_atevent) ON DELETE CASCADE
REFERENCES {{quote_ident .AuditSchema}}.atevent(rid_atevent) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_atdetail_rid_atevent ON {{.AuditSchema}}.atdetail(rid_atevent);
CREATE INDEX IF NOT EXISTS idx_atdetail_datacolumn ON {{.AuditSchema}}.atdetail(datacolumn);
CREATE INDEX IF NOT EXISTS idx_atdetail_rid_atevent ON {{quote_ident .AuditSchema}}.atdetail(rid_atevent);
CREATE INDEX IF NOT EXISTS idx_atdetail_datacolumn ON {{quote_ident .AuditSchema}}.atdetail(datacolumn);
COMMENT ON TABLE {{.AuditSchema}}.atdetail IS 'Audit trail detail table - stores individual column changes';
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.rid_atdetail IS 'Audit detail ID';
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.rid_atevent IS 'Reference to audit event';
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.datacolumn IS 'Name of the column that changed';
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.changedfrom IS 'Old value before change';
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.changedto IS 'New value after change';
COMMENT ON TABLE {{quote_ident .AuditSchema}}.atdetail IS 'Audit trail detail table - stores individual column changes';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.rid_atdetail IS 'Audit detail ID';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.rid_atevent IS 'Reference to audit event';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.datacolumn IS 'Name of the column that changed';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.changedfrom IS 'Old value before change';
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.changedto IS 'New value after change';
@@ -1,16 +1,16 @@
DO $$
DO {{dollar_delimiter}}
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_trigger
WHERE tgname = '{{.TriggerName}}'
AND tgrelid = '{{qual_table_raw .SchemaName .TableName}}'::regclass
WHERE tgname = {{quote .TriggerName}}
AND tgrelid = {{quote (qual_table .SchemaName .TableName)}}::regclass
) THEN
CREATE TRIGGER {{.TriggerName}}
CREATE TRIGGER {{quote_ident .TriggerName}}
AFTER {{.Events}}
ON {{qual_table_raw .SchemaName .TableName}}
ON {{qual_table .SchemaName .TableName}}
FOR EACH ROW
EXECUTE FUNCTION {{qual_table_raw .SchemaName .FunctionName}}();
EXECUTE FUNCTION {{qual_table .SchemaName .FunctionName}}();
END IF;
END;
$$;
{{dollar_delimiter}};
@@ -1,39 +1,39 @@
{{/* Base constraint template */}}
{{- define "constraint_base" -}}
ALTER TABLE {{qual_table_raw .SchemaName .TableName}}
ADD CONSTRAINT {{.ConstraintName}}
ALTER TABLE {{qual_table .SchemaName .TableName}}
ADD CONSTRAINT {{quote_ident .ConstraintName}}
{{block "constraint_definition" .}}{{end}};
{{- end -}}
{{/* Drop constraint with check */}}
{{- define "drop_constraint_safe" -}}
DO $$
DO {{dollar_delimiter}}
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = '{{.SchemaName}}'
AND table_name = '{{.TableName}}'
AND constraint_name = '{{.ConstraintName}}'
WHERE table_schema = {{quote .SchemaName}}
AND table_name = {{quote .TableName}}
AND constraint_name = {{quote .ConstraintName}}
) THEN
ALTER TABLE {{qual_table_raw .SchemaName .TableName}}
DROP CONSTRAINT {{.ConstraintName}};
ALTER TABLE {{qual_table .SchemaName .TableName}}
DROP CONSTRAINT {{quote_ident .ConstraintName}};
END IF;
END;
$$;
{{dollar_delimiter}};
{{- end -}}
{{/* Add constraint with existence check */}}
{{- define "add_constraint_safe" -}}
DO $$
DO {{dollar_delimiter}}
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = '{{.SchemaName}}'
AND table_name = '{{.TableName}}'
AND constraint_name = '{{.ConstraintName}}'
WHERE table_schema = {{quote .SchemaName}}
AND table_name = {{quote .TableName}}
AND constraint_name = {{quote .ConstraintName}}
) THEN
{{template "constraint_base" .}}
END IF;
END;
$$;
{{dollar_delimiter}};
{{- end -}}
+8 -8
View File
@@ -1,23 +1,23 @@
{{/* Base DDL template with common structure */}}
{{- define "ddl_header" -}}
-- DDL Operation: {{.Operation}}
-- Schema: {{.Schema}}
-- Object: {{.ObjectName}}
-- DDL Operation: {{comment_text .Operation}}
-- Schema: {{comment_text .Schema}}
-- Object: {{comment_text .ObjectName}}
{{- end -}}
{{- define "ddl_footer" -}}
-- End of {{.Operation}}
-- End of {{comment_text .Operation}}
{{- end -}}
{{/* Base ALTER TABLE structure */}}
{{- define "alter_table_base" -}}
ALTER TABLE {{qual_table_raw .SchemaName .TableName}}
ALTER TABLE {{qual_table .SchemaName .TableName}}
{{block "alter_operation" .}}{{end}};
{{- end -}}
{{/* Common existence check pattern */}}
{{- define "exists_check" -}}
DO $$
DO {{dollar_delimiter}}
BEGIN
IF NOT EXISTS (
{{block "exists_query" .}}{{end}}
@@ -25,10 +25,10 @@ BEGIN
{{block "create_statement" .}}{{end}}
END IF;
END;
$$;
{{dollar_delimiter}};
{{- end -}}
{{/* Common drop pattern */}}
{{- define "drop_if_exists" -}}
{{block "drop_type" .}}{{end}} IF EXISTS {{qual_table_raw .SchemaName .ObjectName}};
{{block "drop_type" .}}{{end}} IF EXISTS {{qual_table .SchemaName .ObjectName}};
{{- end -}}
@@ -1 +1 @@
COMMENT ON COLUMN {{qual_table .SchemaName .TableName}}.{{quote_ident .ColumnName}} IS '{{.Comment}}';
COMMENT ON COLUMN {{qual_table .SchemaName .TableName}}.{{quote_ident .ColumnName}} IS {{quote .Comment}};
@@ -1 +1 @@
COMMENT ON TABLE {{qual_table .SchemaName .TableName}} IS '{{.Comment}}';
COMMENT ON TABLE {{qual_table .SchemaName .TableName}} IS {{quote .Comment}};
@@ -1,12 +1,12 @@
DO $$
DO {{dollar_delimiter}}
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = '{{.SchemaName}}'
AND table_name = '{{.TableName}}'
AND constraint_name = '{{.ConstraintName}}'
WHERE table_schema = {{quote .SchemaName}}
AND table_name = {{quote .TableName}}
AND constraint_name = {{quote .ConstraintName}}
) THEN
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} CHECK ({{.Expression}});
END IF;
END;
$$;
{{dollar_delimiter}};
@@ -1,10 +1,10 @@
DO $$
DO {{dollar_delimiter}}
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = '{{.SchemaName}}'
AND table_name = '{{.TableName}}'
AND constraint_name = '{{.ConstraintName}}'
WHERE table_schema = {{quote .SchemaName}}
AND table_name = {{quote .TableName}}
AND constraint_name = {{quote .ConstraintName}}
) THEN
ALTER TABLE {{qual_table .SchemaName .TableName}}
ADD CONSTRAINT {{quote_ident .ConstraintName}}
@@ -15,4 +15,4 @@ BEGIN
{{.Deferral}}{{end}};
END IF;
END;
$$;
{{dollar_delimiter}};
@@ -1,13 +1,13 @@
DO $$
DO {{dollar_delimiter}}
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = '{{.SchemaName}}'
AND table_name = '{{.TableName}}'
AND constraint_name = '{{.ConstraintName}}'
WHERE table_schema = {{quote .SchemaName}}
AND table_name = {{quote .TableName}}
AND constraint_name = {{quote .ConstraintName}}
) THEN
ALTER TABLE {{qual_table .SchemaName .TableName}}
ADD CONSTRAINT {{quote_ident .ConstraintName}} PRIMARY KEY ({{.Columns}});
END IF;
END;
$$;
{{dollar_delimiter}};
@@ -1,4 +1,4 @@
DO $$
DO {{dollar_delimiter}}
DECLARE
current_pk_name text;
current_pk_matches boolean := false;
@@ -16,22 +16,22 @@ BEGIN
ON a.attrelid = t.oid
AND a.attnum = cols.attnum
WHERE c.contype = 'p'
AND n.nspname = '{{.SchemaName}}'
AND t.relname = '{{.TableName}}'
AND n.nspname = {{quote .SchemaName}}
AND t.relname = {{quote .TableName}}
ORDER BY cols.ord
),
ARRAY[]::text[]
) = ARRAY[{{.ColumnNames}}]
INTO current_pk_name, current_pk_matches
FROM information_schema.table_constraints tc
WHERE tc.table_schema = '{{.SchemaName}}'
AND tc.table_name = '{{.TableName}}'
WHERE tc.table_schema = {{quote .SchemaName}}
AND tc.table_name = {{quote .TableName}}
AND tc.constraint_type = 'PRIMARY KEY';
IF current_pk_name IS NOT NULL
AND NOT current_pk_matches
AND current_pk_name IN ({{.AutoGenNames}}) THEN
EXECUTE 'ALTER TABLE {{qual_table .SchemaName .TableName}} DROP CONSTRAINT ' || quote_ident(current_pk_name) || ' CASCADE';
EXECUTE {{quote (printf "ALTER TABLE %s DROP CONSTRAINT " (qual_table .SchemaName .TableName))}} || quote_ident(current_pk_name) || ' CASCADE';
END IF;
-- Add the desired primary key only when no matching primary key already exists.
@@ -40,4 +40,4 @@ BEGIN
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} PRIMARY KEY ({{.Columns}});
END IF;
END;
$$;
{{dollar_delimiter}};
@@ -1,12 +1,12 @@
DO $$
DO {{dollar_delimiter}}
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = '{{.SchemaName}}'
AND table_name = '{{.TableName}}'
AND constraint_name = '{{.ConstraintName}}'
WHERE table_schema = {{quote .SchemaName}}
AND table_name = {{quote .TableName}}
AND constraint_name = {{quote .ConstraintName}}
) THEN
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} UNIQUE {{if .UsingIndex}}USING INDEX {{quote_ident .UsingIndex}}{{else}}({{.Columns}}){{end}};
END IF;
END;
$$;
{{dollar_delimiter}};
+2 -2
View File
@@ -2,7 +2,7 @@
{{/* Column definition fragment */}}
{{- define "column_definition" -}}
{{.Name}} {{.Type}}
{{quote_ident .Name}} {{.Type}}
{{- if .Generated}} GENERATED ALWAYS AS ({{.Generated}}) STORED{{end}}
{{- if .Identity}} GENERATED {{.Identity}} AS IDENTITY{{end}}
{{- if and .Default (not .Generated) (not .Identity)}} DEFAULT {{.Default}}{{end}}
@@ -18,7 +18,7 @@
{{/* Qualified table name */}}
{{- define "qualified_table" -}}
{{qual_table_raw .SchemaName .TableName}}
{{qual_table .SchemaName .TableName}}
{{- end -}}
{{/* Index method clause */}}
@@ -1,4 +1,4 @@
DO $$
DO {{dollar_delimiter}}
DECLARE
m_cnt bigint;
m_next bigint;
@@ -6,8 +6,8 @@ BEGIN
IF EXISTS (
SELECT 1 FROM pg_class c
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = '{{.SequenceName}}'
AND n.nspname = '{{.SchemaName}}'
WHERE c.relname = {{quote .SequenceName}}
AND n.nspname = {{quote .SchemaName}}
AND c.relkind = 'S'
) THEN
SELECT COALESCE(MAX({{quote_ident .ColumnName}}), 0) + 1
@@ -20,8 +20,8 @@ BEGIN
-- Only move the sequence forward; never rewind one that is already past the data.
IF m_cnt > m_next THEN
PERFORM setval('{{qual_table_raw .SchemaName .SequenceName}}'::regclass, m_cnt, false);
PERFORM setval({{quote (qual_table .SchemaName .SequenceName)}}::regclass, m_cnt, false);
END IF;
END IF;
END;
$$;
{{dollar_delimiter}};
+2 -2
View File
@@ -20,7 +20,7 @@ func TestConversionMetadataValidationAndBlockDelimiters(t *testing.T) {
delete(col.Metadata, key)
}
}
col.Metadata["pgsql_using"] = "length('$relspec_alter_type_0$ $relspec_preflight_0$')"
col.Metadata["pgsql_using"] = "length('$relspec_0$ $relspec_preflight_0$')"
data, err := conversionData("public", "accounts", col)
if err != nil {
t.Fatal(err)
@@ -33,7 +33,7 @@ func TestConversionMetadataValidationAndBlockDelimiters(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(alter, "DO $relspec_alter_type_1$") {
if !strings.HasPrefix(alter, "DO $relspec_1$") {
t.Fatalf("unsafe alter delimiter: %s", alter)
}
preflight := preflightColumnType(data, false)
+186 -123
View File
@@ -37,6 +37,9 @@ type ExecutionError = writers.ExecutionError
// NewWriter creates a new PostgreSQL SQL writer
func NewWriter(options *writers.WriterOptions) *Writer {
if options == nil {
options = &writers.WriterOptions{}
}
executor, _ := NewTemplateExecutor(options.FlattenSchema)
return &Writer{
options: options,
@@ -46,11 +49,22 @@ func NewWriter(options *writers.WriterOptions) *Writer {
// qualTable returns a schema-qualified name using the writer's FlattenSchema setting.
func (w *Writer) qualTable(schema, name string) string {
return writers.QualifiedTableName(schema, name, w.options.FlattenSchema)
if schema == "" {
return quoteIdent(name)
}
if w.options.FlattenSchema {
return quoteIdent(schema + "_" + name)
}
return quoteIdent(schema) + "." + quoteIdent(name)
}
// WriteDatabase writes the entire database schema as SQL
func (w *Writer) WriteDatabase(db *models.Database) error {
var preparationErr error
db, preparationErr = prepareModel(db, w.options.LowercaseIdentifiers)
if preparationErr != nil {
return preparationErr
}
// Check if we should execute SQL directly on a database
if connString, ok := w.options.Metadata["connection_string"].(string); ok && connString != "" {
return w.executeDatabaseSQL(db, connString)
@@ -79,7 +93,7 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
// Write header comment
fmt.Fprintf(w.writer, "-- PostgreSQL Database Schema\n")
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
fmt.Fprintf(w.writer, "-- Database: %s\n", commentText(db.Name))
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
if w.options.ContinueOnError {
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
@@ -110,11 +124,16 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
// GenerateDatabaseStatements generates SQL statements as a list for the entire database
// Returns a slice of SQL statements that can be executed independently
func (w *Writer) GenerateDatabaseStatements(db *models.Database) ([]string, error) {
var preparationErr error
db, preparationErr = prepareModel(db, w.options.LowercaseIdentifiers)
if preparationErr != nil {
return nil, preparationErr
}
statements := []string{}
// Add header comment
statements = append(statements, "-- PostgreSQL Database Schema")
statements = append(statements, fmt.Sprintf("-- Database: %s", db.Name))
statements = append(statements, fmt.Sprintf("-- Database: %s", commentText(db.Name)))
statements = append(statements, "-- Generated by RelSpec")
for _, schema := range db.Schemas {
@@ -161,7 +180,7 @@ func (w *Writer) primaryKeySequenceStatement(schema *models.Schema, table *model
}
return fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s\n INCREMENT 1\n MINVALUE 1\n MAXVALUE 9223372036854775807\n START 1\n CACHE 1",
w.qualTable(schema.SQLName(), seqName))
w.qualTable(schema.Name, seqName))
}
// primaryKeySetvalStatement returns the statement that moves a table's primary key
@@ -182,6 +201,11 @@ func (w *Writer) primaryKeySetvalStatement(schema *models.Schema, table *models.
// GenerateSchemaStatements generates SQL statements as a list for a single schema
func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, error) {
var err error
schema, err = w.prepareSchema(schema)
if err != nil {
return nil, err
}
preflights, err := w.generateTypePreflights(schema)
if err != nil {
return nil, err
@@ -199,8 +223,8 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 1: Create schema (skip entirely when flattening)
if schema.Name != "public" && !w.options.FlattenSchema {
statements = append(statements, fmt.Sprintf("-- Schema: %s", schema.Name))
statements = append(statements, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema.SQLName()))
statements = append(statements, fmt.Sprintf("-- Schema: %s", commentText(schema.Name)))
statements = append(statements, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quoteIdent(schema.Name)))
}
for _, extension := range requiredExtensions(schema) {
@@ -258,14 +282,14 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
pkCols := []string{}
for _, col := range table.Columns {
if col.IsPrimaryKey {
pkCols = append(pkCols, col.SQLName())
pkCols = append(pkCols, col.Name)
}
}
if len(pkCols) > 0 {
// Sort for consistent output
sort.Strings(pkCols)
pkColumns = pkCols
pkName = fmt.Sprintf("pk_%s_%s", schema.SQLName(), table.SQLName())
pkName = fmt.Sprintf("pk_%s_%s", schema.Name, table.Name)
}
}
@@ -282,7 +306,7 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
TableName: table.Name,
ConstraintName: pkName,
AutoGenNames: formatStringList(autoGenPKNames),
Columns: strings.Join(pkColumns, ", "),
Columns: quotedIdentifiers(pkColumns),
ColumnNames: formatStringList(pkColumns),
}
@@ -322,7 +346,7 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
tablespaceClause := ""
if ts := pgIndexDirectiveTablespace(index); ts != "" {
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", ts)
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", quoteIdent(ts))
}
whereClause := ""
@@ -331,7 +355,7 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
}
stmt := fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s USING %s (%s)%s%s%s",
uniqueStr, quoteIdentifier(index.Name), w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
uniqueStr, quoteIdentifier(index.Name), w.qualTable(schema.Name, table.Name), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
statements = append(statements, stmt)
}
}
@@ -348,7 +372,7 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
SchemaName: schema.Name,
TableName: table.Name,
ConstraintName: constraint.Name,
Columns: strings.Join(constraint.Columns, ", "),
Columns: quotedIdentifiers(constraint.Columns),
UsingIndex: adoptableUniqueIndex(table, constraint),
}
@@ -410,10 +434,10 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
SchemaName: schema.Name,
TableName: table.Name,
ConstraintName: constraint.Name,
SourceColumns: strings.Join(constraint.Columns, ", "),
SourceColumns: quotedIdentifiers(constraint.Columns),
TargetSchema: refSchema,
TargetTable: constraint.ReferencedTable,
TargetColumns: strings.Join(constraint.ReferencedColumns, ", "),
TargetColumns: quotedIdentifiers(constraint.ReferencedColumns),
OnDelete: onDelete,
OnUpdate: onUpdate,
Deferral: constraint.DeferralClause(),
@@ -441,15 +465,15 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 7: Comments
for _, table := range schema.Tables {
if table.Comment != "" {
stmt := fmt.Sprintf("COMMENT ON TABLE %s IS '%s'",
w.qualTable(schema.SQLName(), table.SQLName()), escapeQuote(table.Comment))
stmt := fmt.Sprintf("COMMENT ON TABLE %s IS %s",
w.qualTable(schema.Name, table.Name), quote(table.Comment))
statements = append(statements, stmt)
}
for _, column := range sortColumns(table.Columns) {
if column.Comment != "" {
stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s IS '%s'",
w.qualTable(schema.SQLName(), table.SQLName()), column.SQLName(), escapeQuote(column.Comment))
stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s IS %s",
w.qualTable(schema.Name, table.Name), quoteIdent(column.Name), quote(column.Comment))
statements = append(statements, stmt)
}
}
@@ -461,9 +485,14 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
// GenerateAddColumnStatements generates ALTER TABLE ADD COLUMN statements for existing tables
// This is useful for schema evolution when new columns are added to existing tables
func (w *Writer) GenerateAddColumnStatements(schema *models.Schema) ([]string, error) {
var err error
schema, err = w.prepareSchema(schema)
if err != nil {
return nil, err
}
statements := []string{}
statements = append(statements, fmt.Sprintf("-- Add missing columns for schema: %s", schema.Name))
statements = append(statements, fmt.Sprintf("-- Add missing columns for schema: %s", commentText(schema.Name)))
for _, table := range schema.Tables {
// Sort columns by sequence or name for consistent output
@@ -501,9 +530,14 @@ func (w *Writer) GenerateAddColumnStatements(schema *models.Schema) ([]string, e
}
func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]string, error) {
var err error
schema, err = w.prepareSchema(schema)
if err != nil {
return nil, err
}
statements := []string{}
statements = append(statements, fmt.Sprintf("-- Alter column types for schema: %s", schema.Name))
statements = append(statements, fmt.Sprintf("-- Alter column types for schema: %s", commentText(schema.Name)))
for _, table := range schema.Tables {
columns := getSortedColumns(table.Columns)
@@ -533,9 +567,14 @@ func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]str
// statements to bring existing columns' DEFAULT clause in line with the
// model, safe to run against a database that already has the columns.
func (w *Writer) GenerateAlterColumnDefaultStatements(schema *models.Schema) ([]string, error) {
var err error
schema, err = w.prepareSchema(schema)
if err != nil {
return nil, err
}
statements := []string{}
statements = append(statements, fmt.Sprintf("-- Alter column defaults for schema: %s", schema.Name))
statements = append(statements, fmt.Sprintf("-- Alter column defaults for schema: %s", commentText(schema.Name)))
for _, table := range schema.Tables {
columns := getSortedColumns(table.Columns)
@@ -592,7 +631,7 @@ func formatColumnDefaultSQL(col *models.Column) (setDefault bool, defaultVal str
return false, ""
}
if value, ok := col.Default.(string); ok {
return true, writers.QuoteDefaultValue(stripBackticks(value), col.Type)
return true, quoteDefaultValue(stripBackticks(value), col.Type)
}
return true, fmt.Sprintf("%v", col.Default)
}
@@ -601,9 +640,14 @@ func formatColumnDefaultSQL(col *models.Column) (setDefault bool, defaultVal str
// statements to bring existing columns' NOT NULL state in line with the
// model, safe to run against a database that already has the columns.
func (w *Writer) GenerateAlterColumnNullabilityStatements(schema *models.Schema) ([]string, error) {
var err error
schema, err = w.prepareSchema(schema)
if err != nil {
return nil, err
}
statements := []string{}
statements = append(statements, fmt.Sprintf("-- Alter column nullability for schema: %s", schema.Name))
statements = append(statements, fmt.Sprintf("-- Alter column nullability for schema: %s", commentText(schema.Name)))
for _, table := range schema.Tables {
columns := getSortedColumns(table.Columns)
@@ -626,10 +670,15 @@ func (w *Writer) GenerateAlterColumnNullabilityStatements(schema *models.Schema)
// GenerateAddColumnsForDatabase generates ALTER TABLE ADD COLUMN statements for the entire database
func (w *Writer) GenerateAddColumnsForDatabase(db *models.Database) ([]string, error) {
prepared, preparationErr := prepareModel(db, w.options.LowercaseIdentifiers)
if preparationErr != nil {
return nil, preparationErr
}
db = prepared
statements := []string{}
statements = append(statements, "-- Add missing columns to existing tables")
statements = append(statements, fmt.Sprintf("-- Database: %s", db.Name))
statements = append(statements, fmt.Sprintf("-- Database: %s", commentText(db.Name)))
statements = append(statements, "-- Generated by RelSpec")
for _, schema := range db.Schemas {
@@ -666,7 +715,7 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
}
stmt := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n)%s",
w.qualTable(schema.SQLName(), table.SQLName()), strings.Join(columnDefs, ",\n"),
w.qualTable(schema.Name, table.Name), strings.Join(columnDefs, ",\n"),
pgTableDirectiveSuffix(table))
statements = append(statements, stmt)
@@ -675,7 +724,7 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
// generateColumnDefinition generates column definition
func (w *Writer) generateColumnDefinition(col *models.Column) string {
parts := []string{col.SQLName()}
parts := []string{quoteIdent(col.Name)}
parts = append(parts, effectiveColumnSQLType(col))
@@ -700,7 +749,7 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string {
if !col.Generated && !col.Identity && col.Default != nil {
switch v := col.Default.(type) {
case string:
parts = append(parts, fmt.Sprintf("DEFAULT %s", writers.QuoteDefaultValue(stripBackticks(v), col.Type)))
parts = append(parts, fmt.Sprintf("DEFAULT %s", quoteDefaultValue(stripBackticks(v), col.Type)))
case bool:
parts = append(parts, fmt.Sprintf("DEFAULT %v", v))
default:
@@ -764,13 +813,18 @@ func equivalentTypeListSQL(sqlType string) string {
variants := pgsql.EquivalentSQLTypeVariants(sqlType)
quoted := make([]string, 0, len(variants))
for _, variant := range variants {
quoted = append(quoted, fmt.Sprintf("'%s'", escapeQuote(variant)))
quoted = append(quoted, quote(variant))
}
return strings.Join(quoted, ", ")
}
// WriteSchema writes a single schema and all its tables
func (w *Writer) WriteSchema(schema *models.Schema) error {
var err error
schema, err = w.prepareSchema(schema)
if err != nil {
return err
}
if w.writer == nil {
w.writer = os.Stdout
}
@@ -885,6 +939,11 @@ func (w *Writer) WriteTable(table *models.Table) error {
// WriteAddColumnStatements writes ALTER TABLE ADD COLUMN statements for a database
// This is used for schema evolution/migration when new columns are added
func (w *Writer) WriteAddColumnStatements(db *models.Database) error {
prepared, preparationErr := prepareModel(db, w.options.LowercaseIdentifiers)
if preparationErr != nil {
return preparationErr
}
db = prepared
var writer io.Writer
var file *os.File
var err error
@@ -926,8 +985,8 @@ func (w *Writer) writeCreateSchema(schema *models.Schema) error {
return nil
}
fmt.Fprintf(w.writer, "-- Schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "CREATE SCHEMA IF NOT EXISTS %s;\n\n", schema.SQLName())
fmt.Fprintf(w.writer, "-- Schema: %s\n", commentText(schema.Name))
fmt.Fprintf(w.writer, "CREATE SCHEMA IF NOT EXISTS %s;\n\n", quoteIdent(schema.Name))
return nil
}
@@ -946,7 +1005,7 @@ func (w *Writer) writeRequiredExtensions(schema *models.Schema) error {
// writeSequences generates CREATE SEQUENCE statements for identity columns
func (w *Writer) writeSequences(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Sequences for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Sequences for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
// Only create the sequence the primary key's nextval() default uses
@@ -978,11 +1037,11 @@ func (w *Writer) writeSequences(schema *models.Schema) error {
// writeCreateTables generates CREATE TABLE statements
func (w *Writer) writeCreateTables(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Tables for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Tables for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
fmt.Fprintf(w.writer, "CREATE TABLE IF NOT EXISTS %s (\n",
w.qualTable(schema.SQLName(), table.SQLName()))
w.qualTable(schema.Name, table.Name))
// Write columns
columns := getSortedColumns(table.Columns)
@@ -1003,7 +1062,7 @@ func (w *Writer) writeCreateTables(schema *models.Schema) error {
// writeAddColumns generates ALTER TABLE ADD COLUMN statements for missing columns
func (w *Writer) writeAddColumns(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Add missing columns for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Add missing columns for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
// Sort columns by sequence or name for consistent output
@@ -1032,7 +1091,7 @@ func (w *Writer) writeAddColumns(schema *models.Schema) error {
}
func (w *Writer) writeAlterColumnTypes(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Alter column types for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Alter column types for schema: %s\n", commentText(schema.Name))
statements, err := w.GenerateAlterColumnTypeStatements(schema)
if err != nil {
@@ -1047,7 +1106,7 @@ func (w *Writer) writeAlterColumnTypes(schema *models.Schema) error {
}
func (w *Writer) writeAlterColumnDefaults(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Alter column defaults for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Alter column defaults for schema: %s\n", commentText(schema.Name))
statements, err := w.GenerateAlterColumnDefaultStatements(schema)
if err != nil {
@@ -1062,7 +1121,7 @@ func (w *Writer) writeAlterColumnDefaults(schema *models.Schema) error {
}
func (w *Writer) writeAlterColumnNullability(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Alter column nullability for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Alter column nullability for schema: %s\n", commentText(schema.Name))
statements, err := w.GenerateAlterColumnNullabilityStatements(schema)
if err != nil {
@@ -1078,7 +1137,7 @@ func (w *Writer) writeAlterColumnNullability(schema *models.Schema) error {
// writePrimaryKeys generates ALTER TABLE statements for primary keys
func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
// Find primary key constraint
@@ -1091,21 +1150,21 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
}
var columnNames []string
pkName := fmt.Sprintf("pk_%s_%s", schema.SQLName(), table.SQLName())
pkName := fmt.Sprintf("pk_%s_%s", schema.Name, table.Name)
if pkConstraint != nil {
// Build column list from explicit constraint
columnNames = make([]string, 0, len(pkConstraint.Columns))
for _, colName := range pkConstraint.Columns {
if col, ok := table.Columns[colName]; ok {
columnNames = append(columnNames, col.SQLName())
columnNames = append(columnNames, col.Name)
}
}
} else {
// No explicit PK constraint, check for columns with IsPrimaryKey = true
for _, col := range table.Columns {
if col.IsPrimaryKey {
columnNames = append(columnNames, col.SQLName())
columnNames = append(columnNames, col.Name)
}
}
// Sort for consistent output
@@ -1127,7 +1186,7 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
TableName: table.Name,
ConstraintName: pkName,
AutoGenNames: formatStringList(autoGenPKNames),
Columns: strings.Join(columnNames, ", "),
Columns: quotedIdentifiers(columnNames),
ColumnNames: formatStringList(columnNames),
}
@@ -1144,7 +1203,7 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
// writeIndexes generates CREATE INDEX statements
func (w *Writer) writeIndexes(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Indexes for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Indexes for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
// Sort indexes by name for consistent output
@@ -1170,7 +1229,7 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
indexType = "ukey"
}
columnSuffix := strings.Join(index.Columns, "_")
indexName = fmt.Sprintf("%s_%s_%s", indexType, table.SQLName(), strings.ToLower(columnSuffix))
indexName = fmt.Sprintf("%s_%s_%s", indexType, table.Name, strings.ToLower(columnSuffix))
}
indexType := index.Type
@@ -1196,7 +1255,7 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
tablespaceClause := ""
if ts := pgIndexDirectiveTablespace(index); ts != "" {
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", ts)
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", quoteIdent(ts))
}
whereClause := ""
@@ -1210,9 +1269,9 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
}
fmt.Fprintf(w.writer, "CREATE %sINDEX %sIF NOT EXISTS %s\n",
unique, concurrently, indexName)
unique, concurrently, quoteIdent(indexName))
fmt.Fprintf(w.writer, " ON %s USING %s (%s)%s%s%s;\n\n",
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
w.qualTable(schema.Name, table.Name), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
}
}
@@ -1221,7 +1280,7 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
// writeUniqueConstraints generates ALTER TABLE statements for unique constraints
func (w *Writer) writeUniqueConstraints(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Unique constraints for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Unique constraints for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
// Sort constraints by name for consistent output
@@ -1240,7 +1299,7 @@ func (w *Writer) writeUniqueConstraints(schema *models.Schema) error {
columnExprs := make([]string, 0, len(constraint.Columns))
for _, colName := range constraint.Columns {
if col, ok := table.Columns[colName]; ok {
columnExprs = append(columnExprs, col.SQLName())
columnExprs = append(columnExprs, quoteIdent(col.Name))
}
}
@@ -1268,7 +1327,7 @@ func (w *Writer) writeUniqueConstraints(schema *models.Schema) error {
// writeCheckConstraints generates ALTER TABLE statements for check constraints
func (w *Writer) writeCheckConstraints(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Check constraints for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Check constraints for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
// Sort constraints by name for consistent output
@@ -1307,7 +1366,7 @@ func (w *Writer) writeCheckConstraints(schema *models.Schema) error {
// writeForeignKeys generates ALTER TABLE statements for foreign keys
func (w *Writer) writeForeignKeys(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Foreign keys for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Foreign keys for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
// Sort relationships by name for consistent output
@@ -1327,7 +1386,7 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
fkName = name
}
if fkName == "" {
fkName = fmt.Sprintf("fk_%s_%s", table.SQLName(), rel.ToTable)
fkName = fmt.Sprintf("fk_%s_%s", table.Name, rel.ToTable)
}
// Find the foreign key constraint that matches this relationship.
@@ -1354,13 +1413,13 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
sourceColumns := make([]string, 0, len(fkConstraint.Columns))
for _, colName := range fkConstraint.Columns {
if col, ok := table.Columns[colName]; ok {
sourceColumns = append(sourceColumns, col.SQLName())
sourceColumns = append(sourceColumns, quoteIdent(col.Name))
}
}
targetColumns := make([]string, 0, len(fkConstraint.ReferencedColumns))
for _, colName := range fkConstraint.ReferencedColumns {
targetColumns = append(targetColumns, strings.ToLower(colName))
targetColumns = append(targetColumns, quoteIdent(colName))
}
if len(sourceColumns) == 0 || len(targetColumns) == 0 {
@@ -1435,15 +1494,15 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
sourceColumns := make([]string, 0, len(constraint.Columns))
for _, colName := range constraint.Columns {
if col, ok := table.Columns[colName]; ok {
sourceColumns = append(sourceColumns, col.SQLName())
sourceColumns = append(sourceColumns, quoteIdent(col.Name))
} else {
sourceColumns = append(sourceColumns, colName)
sourceColumns = append(sourceColumns, quoteIdent(colName))
}
}
targetColumns := make([]string, 0, len(constraint.ReferencedColumns))
for _, colName := range constraint.ReferencedColumns {
targetColumns = append(targetColumns, strings.ToLower(colName))
targetColumns = append(targetColumns, quoteIdent(colName))
}
if len(sourceColumns) == 0 || len(targetColumns) == 0 {
@@ -1492,7 +1551,7 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
// writeSetSequenceValues generates statements to set sequence current values
func (w *Writer) writeSetSequenceValues(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Set sequence values for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Set sequence values for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
seqName := primaryKeySequenceName(table)
@@ -1521,22 +1580,22 @@ func (w *Writer) writeSetSequenceValues(schema *models.Schema) error {
// writeComments generates COMMENT statements for tables and columns
func (w *Writer) writeComments(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Comments for schema: %s\n", schema.Name)
fmt.Fprintf(w.writer, "-- Comments for schema: %s\n", commentText(schema.Name))
for _, table := range schema.Tables {
// Table comment
if table.Description != "" {
fmt.Fprintf(w.writer, "COMMENT ON TABLE %s IS '%s';\n",
w.qualTable(schema.SQLName(), table.SQLName()),
escapeQuote(table.Description))
fmt.Fprintf(w.writer, "COMMENT ON TABLE %s IS %s;\n",
w.qualTable(schema.Name, table.Name),
quote(table.Description))
}
// Column comments
for _, col := range getSortedColumns(table.Columns) {
if col.Description != "" {
fmt.Fprintf(w.writer, "COMMENT ON COLUMN %s.%s IS '%s';\n",
w.qualTable(schema.SQLName(), table.SQLName()), col.SQLName(),
escapeQuote(col.Description))
fmt.Fprintf(w.writer, "COMMENT ON COLUMN %s.%s IS %s;\n",
w.qualTable(schema.Name, table.Name), quoteIdent(col.Name),
quote(col.Description))
}
}
@@ -1897,6 +1956,9 @@ func nativeGistBaseType(baseType string) bool {
}
func resolveIndexColumn(table *models.Table, colName string) (*models.Column, bool) {
if len(colName) >= 2 && colName[0] == '"' && colName[len(colName)-1] == '"' {
colName = strings.ReplaceAll(colName[1:len(colName)-1], `""`, `"`)
}
if table == nil {
return nil, false
}
@@ -1904,18 +1966,8 @@ func resolveIndexColumn(table *models.Table, colName string) (*models.Column, bo
return col, true
}
normalized := strings.ToLower(strings.Trim(colName, `"`))
for key, col := range table.Columns {
if col == nil {
continue
}
if strings.ToLower(strings.Trim(key, `"`)) == normalized {
return col, true
}
if strings.ToLower(strings.Trim(col.Name, `"`)) == normalized {
return col, true
}
if strings.ToLower(strings.Trim(col.SQLName(), `"`)) == normalized {
for _, col := range table.Columns {
if col != nil && col.Name == colName {
return col, true
}
}
@@ -1972,7 +2024,7 @@ func sortIndexes(indexes map[string]*models.Index) []*models.Index {
func formatStringList(items []string) string {
quoted := make([]string, len(items))
for i, item := range items {
quoted[i] = fmt.Sprintf("'%s'", escapeQuote(item))
quoted[i] = quote(item)
}
return strings.Join(quoted, ", ")
}
@@ -2049,19 +2101,20 @@ func extractSequenceName(defaultExpr string) string {
if start == -1 {
return ""
}
end := strings.Index(defaultExpr[start+1:], "'")
if end == -1 {
var name strings.Builder
for i := start + 1; i < len(defaultExpr); i++ {
if defaultExpr[i] == '\'' {
if i+1 < len(defaultExpr) && defaultExpr[i+1] == '\'' {
name.WriteByte('\'')
i++
continue
}
return regclassLastName(name.String())
}
name.WriteByte(defaultExpr[i])
}
return ""
}
fullName := defaultExpr[start+1 : start+1+end]
// Remove schema prefix if present
parts := strings.Split(fullName, ".")
if len(parts) > 1 {
return parts[len(parts)-1]
}
return fullName
}
// executeDatabaseSQL applies db to a PostgreSQL database. By default it reads the live schema
@@ -2126,7 +2179,7 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
currentSchemas := make(map[string]*models.Schema)
for _, cs := range current.Schemas {
if cs != nil {
currentSchemas[strings.ToLower(cs.Name)] = cs
currentSchemas[cs.Name] = cs
}
}
@@ -2142,25 +2195,25 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
return nil, err
}
cs := currentSchemas[strings.ToLower(schema.Name)]
cs := currentSchemas[schema.Name]
if cs == nil && schema.Name != "public" {
statements = append(statements,
fmt.Sprintf("-- Schema: %s", schema.Name),
fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema.SQLName()))
fmt.Sprintf("-- Schema: %s", commentText(schema.Name)),
fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quoteIdent(schema.Name)))
}
existing := make(map[string]*models.Table)
knownSequences := make(map[string]bool)
if cs != nil {
for _, t := range cs.Tables {
existing[strings.ToLower(t.Name)] = t
existing[t.Name] = t
}
for _, seq := range cs.Sequences {
knownSequences[strings.ToLower(seq.Name)] = true
knownSequences[seq.Name] = true
}
}
for _, table := range schema.Tables {
currentTable, ok := existing[strings.ToLower(table.Name)]
currentTable, ok := existing[table.Name]
if !ok {
// New table: no rows yet, so the sequence only needs creating.
if stmt := w.primaryKeySequenceStatement(schema, table); stmt != "" {
@@ -2173,7 +2226,7 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
// database does not have yet: create it and move it past the data,
// otherwise it restarts at 1 and collides with existing rows.
seqName := primaryKeySequenceName(table)
if seqName == "" || knownSequences[strings.ToLower(seqName)] {
if seqName == "" || knownSequences[seqName] {
continue
}
if cpk := currentTable.GetPrimaryKey(); cpk != nil && columnDefaultsEqual(table.GetPrimaryKey().Default, cpk.Default) {
@@ -2209,7 +2262,7 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
continue
}
if script.Schema != "" && script.Schema != lastSchema {
statements = append(statements, fmt.Sprintf("-- Schema: %s", script.Schema))
statements = append(statements, fmt.Sprintf("-- Schema: %s", commentText(script.Schema)))
lastSchema = script.Schema
}
statements = append(statements, body)
@@ -2311,23 +2364,8 @@ func extractTableNameFromCreate(stmt string) string {
rest = strings.TrimSpace(rest[13:])
}
// Get the table name (first token before '(' or whitespace)
tokens := strings.FieldsFunc(rest, func(r rune) bool {
return r == '(' || r == ' ' || r == '\n' || r == '\t'
})
if len(tokens) == 0 {
return ""
}
// Handle schema.table format
fullName := tokens[0]
parts := strings.Split(fullName, ".")
if len(parts) > 1 {
return parts[len(parts)-1]
}
return fullName
_, name := parseQualifiedIdent(rest)
return name
}
// truncateStatement truncates long SQL statements for error messages
@@ -2445,11 +2483,21 @@ func extractSQLStringValue(stmt, key string) string {
// parseQualifiedIdent extracts (schema, name) from the start of s, handling optional "schema"."table" quoting.
func parseQualifiedIdent(s string) (schema, name string) {
token := firstBareIdent(s)
parts := strings.SplitN(token, ".", 2)
if len(parts) == 2 {
return stripQuotes(parts[0]), stripQuotes(parts[1])
quoted := false
for i := 0; i < len(token); i++ {
if token[i] == '"' {
if quoted && i+1 < len(token) && token[i+1] == '"' {
i++
continue
}
quoted = !quoted
}
if token[i] == '.' && !quoted {
return stripQuotes(token[:i]), stripQuotes(token[i+1:])
}
}
return "", stripQuotes(token)
}
// firstBareIdent returns the first whitespace/delimiter-terminated token, stripping outer quotes.
@@ -2458,11 +2506,21 @@ func firstBareIdent(s string) string {
if s == "" {
return ""
}
end := strings.IndexAny(s, " \t\n(,;")
if end == -1 {
return s
quoted := false
for i := 0; i < len(s); i++ {
if s[i] == '"' {
if quoted && i+1 < len(s) && s[i+1] == '"' {
i++
continue
}
return s[:end]
quoted = !quoted
}
if !quoted && strings.ContainsRune(" \t\r\n(,;", rune(s[i])) {
return s[:i]
}
}
return s
}
// firstIdentAfterKeyword returns the first identifier token after keyword (matched case-insensitively via upperStmt).
@@ -2476,7 +2534,10 @@ func firstIdentAfterKeyword(stmt, upperStmt, keyword string) string {
// stripQuotes removes surrounding double-quotes from an identifier.
func stripQuotes(s string) string {
return strings.Trim(s, "\"")
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return strings.ReplaceAll(s[1:len(s)-1], `""`, `"`)
}
return s
}
// buildStmtContext assembles a display string from available identifiers.
@@ -2598,3 +2659,5 @@ func detectStatementType(stmt string) string {
func quoteIdentifier(s string) string {
return quoteIdent(s)
}
func commentText(s string) string { return strings.NewReplacer("\r", " ", "\n", " ").Replace(s) }
+39 -39
View File
@@ -22,7 +22,7 @@ func TestWriteDatabase(t *testing.T) {
idCol := models.InitColumn("id", "users", "public")
idCol.Type = "integer"
idCol.Description = "Primary key"
idCol.Default = "nextval('public.identity_users_id'::regclass)"
idCol.Default = "nextval('\"public\".\"identity_users_id\"'::regclass)"
table.Columns["id"] = idCol
nameCol := models.InitColumn("name", "users", "public")
@@ -117,7 +117,7 @@ func TestWriteDatabase_ConcurrentIndex(t *testing.T) {
output := buf.String()
if !strings.Contains(output, "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email") {
if !strings.Contains(output, "CREATE INDEX CONCURRENTLY IF NOT EXISTS \"idx_users_email\"") {
t.Errorf("Output missing CONCURRENTLY index creation:\n%s", output)
}
}
@@ -151,7 +151,7 @@ func TestWriteDatabase_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *test
}
output := buf.String()
if !strings.Contains(output, `USING gin (tags array_ops)`) {
if !strings.Contains(output, `USING gin ("tags" array_ops)`) {
t.Fatalf("expected GIN index on array column with array_ops, got:\n%s", output)
}
if strings.Contains(output, "gin_trgm_ops") {
@@ -172,7 +172,7 @@ func TestWriteDatabase_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *tes
index := &models.Index{
Name: "idx_agent_personas_name_gin",
Type: "gin",
Columns: []string{`"name"`},
Columns: []string{"name"},
}
table.Indexes[index.Name] = index
@@ -191,7 +191,7 @@ func TestWriteDatabase_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *tes
if !strings.Contains(output, `CREATE EXTENSION IF NOT EXISTS pg_trgm`) {
t.Fatalf("expected trigram extension for text GIN index, got:\n%s", output)
}
if !strings.Contains(output, `USING gin (name gin_trgm_ops)`) {
if !strings.Contains(output, `USING gin ("name" gin_trgm_ops)`) {
t.Fatalf("expected quoted text GIN index to include gin_trgm_ops, got:\n%s", output)
}
}
@@ -225,7 +225,7 @@ func TestWriteDatabase_GinIndexOnJSONBUsesJSONBOperatorClass(t *testing.T) {
}
output := buf.String()
if !strings.Contains(output, `USING gin (details jsonb_ops)`) {
if !strings.Contains(output, `USING gin ("details" jsonb_ops)`) {
t.Fatalf("expected GIN jsonb index to include jsonb_ops, got:\n%s", output)
}
if strings.Contains(output, "gin_trgm_ops") {
@@ -372,10 +372,10 @@ func TestWriteUniqueConstraints(t *testing.T) {
if !strings.Contains(output, "-- Unique constraints for schema: public") {
t.Errorf("Output missing unique constraints header")
}
if !strings.Contains(output, "ADD CONSTRAINT uq_email UNIQUE (email)") {
if !strings.Contains(output, "ADD CONSTRAINT \"uq_email\" UNIQUE (\"email\")") {
t.Errorf("Output missing uq_email unique constraint\nFull output:\n%s", output)
}
if !strings.Contains(output, "ADD CONSTRAINT uq_guid UNIQUE (guid)") {
if !strings.Contains(output, "ADD CONSTRAINT \"uq_guid\" UNIQUE (\"guid\")") {
t.Errorf("Output missing uq_guid unique constraint\nFull output:\n%s", output)
}
}
@@ -453,13 +453,13 @@ func TestWriteCheckConstraints(t *testing.T) {
if !strings.Contains(output, "-- Check constraints for schema: public") {
t.Errorf("Output missing check constraints header")
}
if !strings.Contains(output, "ADD CONSTRAINT ck_price_positive CHECK (price >= 0)") {
if !strings.Contains(output, "ADD CONSTRAINT \"ck_price_positive\" CHECK (price >= 0)") {
t.Errorf("Output missing ck_price_positive check constraint\nFull output:\n%s", output)
}
if !strings.Contains(output, "ADD CONSTRAINT ck_status_valid CHECK (status IN ('active', 'inactive', 'discontinued'))") {
if !strings.Contains(output, "ADD CONSTRAINT \"ck_status_valid\" CHECK (status IN ('active', 'inactive', 'discontinued'))") {
t.Errorf("Output missing ck_status_valid check constraint\nFull output:\n%s", output)
}
if !strings.Contains(output, "ADD CONSTRAINT ck_quantity_nonnegative CHECK (quantity >= 0)") {
if !strings.Contains(output, "ADD CONSTRAINT \"ck_quantity_nonnegative\" CHECK (quantity >= 0)") {
t.Errorf("Output missing ck_quantity_nonnegative check constraint\nFull output:\n%s", output)
}
}
@@ -573,9 +573,9 @@ func TestWriteAllConstraintTypes(t *testing.T) {
// Verify all constraint types are present
expectedConstraints := map[string]string{
"Primary Key": "PRIMARY KEY",
"Unique": "ADD CONSTRAINT uq_order_number UNIQUE (order_number)",
"Check (total)": "ADD CONSTRAINT ck_total_positive CHECK (total > 0)",
"Check (status)": "ADD CONSTRAINT ck_status_valid CHECK (status IN ('pending', 'completed', 'cancelled'))",
"Unique": "ADD CONSTRAINT \"uq_order_number\" UNIQUE (\"order_number\")",
"Check (total)": "ADD CONSTRAINT \"ck_total_positive\" CHECK (total > 0)",
"Check (status)": "ADD CONSTRAINT \"ck_status_valid\" CHECK (status IN ('pending', 'completed', 'cancelled'))",
"Foreign Key": "FOREIGN KEY",
}
@@ -640,7 +640,7 @@ func TestEscapeQuote(t *testing.T) {
input string
expected string
}{
{"simple text", "simple text"},
{"\"simple\" text", "\"simple\" text"},
{"text with 'quote'", "text with ''quote''"},
{"multiple 'quotes' here", "multiple ''quotes'' here"},
{"", ""},
@@ -1016,9 +1016,9 @@ func TestGenerateAddColumnStatements(t *testing.T) {
// Verify expected elements
expectedStrings := []string{
"ALTER TABLE public.users ADD COLUMN IF NOT EXISTS id integer NOT NULL",
"ALTER TABLE public.users ADD COLUMN IF NOT EXISTS email varchar(255) NOT NULL",
"ALTER TABLE public.users ADD COLUMN IF NOT EXISTS status text DEFAULT 'active'",
"ALTER TABLE \"public\".\"users\" ADD COLUMN IF NOT EXISTS \"id\" integer NOT NULL",
"ALTER TABLE \"public\".\"users\" ADD COLUMN IF NOT EXISTS \"email\" varchar(255) NOT NULL",
"ALTER TABLE \"public\".\"users\" ADD COLUMN IF NOT EXISTS \"status\" text DEFAULT 'active'",
}
for _, expected := range expectedStrings {
@@ -1071,10 +1071,10 @@ func TestWriteAddColumnStatements(t *testing.T) {
t.Logf("Generated output:\n%s", output)
// Verify output contains expected elements
if !strings.Contains(output, "ALTER TABLE public.products ADD COLUMN IF NOT EXISTS id integer") {
if !strings.Contains(output, "ALTER TABLE \"public\".\"products\" ADD COLUMN IF NOT EXISTS \"id\" integer") {
t.Errorf("Output missing ADD COLUMN for id\nFull output:\n%s", output)
}
if !strings.Contains(output, "ALTER TABLE public.products ADD COLUMN IF NOT EXISTS description text") {
if !strings.Contains(output, "ALTER TABLE \"public\".\"products\" ADD COLUMN IF NOT EXISTS \"description\" text") {
t.Errorf("Output missing ADD COLUMN for description\nFull output:\n%s", output)
}
if strings.Contains(output, "information_schema.columns") {
@@ -1115,16 +1115,16 @@ func TestWriteSchema_EmitsGuardedAlterColumnTypeStatements(t *testing.T) {
if !strings.Contains(output, "pg_catalog.format_type") {
t.Fatalf("expected guarded live-type check, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN name TYPE character varying(255)") {
if !strings.Contains(output, "ALTER COLUMN \"name\" TYPE character varying(255)") {
t.Fatalf("expected guarded alter for character varying(255), got:\n%s", output)
}
if !strings.Contains(output, "ARRAY['varchar(255)', 'character varying(255)']") {
t.Fatalf("expected equivalent type spellings for varchar guard, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN tags TYPE text[]") {
if !strings.Contains(output, "ALTER COLUMN \"tags\" TYPE text[]") {
t.Fatalf("expected guarded alter for array type, got:\n%s", output)
}
if !strings.Contains(output, `ALTER COLUMN tags TYPE text[] USING tags::text[];`) {
if !strings.Contains(output, `ALTER COLUMN "tags" TYPE text[] USING "tags"::text[];`) {
t.Fatalf("expected guarded alter for array type to include USING cast, got:\n%s", output)
}
}
@@ -1158,7 +1158,7 @@ func TestWriteSchema_EmitsGuardedAlterColumnDefaultStatements(t *testing.T) {
if !strings.Contains(output, "pg_get_expr(d.adbin, d.adrelid)") {
t.Fatalf("expected guarded live-default check, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN status SET DEFAULT 'active'") {
if !strings.Contains(output, "ALTER COLUMN \"status\" SET DEFAULT 'active'") {
t.Fatalf("expected guarded SET DEFAULT for status column, got:\n%s", output)
}
}
@@ -1189,7 +1189,7 @@ func TestWriteSchema_AlterColumnDefaultStripsBackticksFromFunctionExpression(t *
if strings.Contains(output, "`") {
t.Fatalf("expected no backticks in generated SQL, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN updatedat SET DEFAULT now()") {
if !strings.Contains(output, "ALTER COLUMN \"updatedat\" SET DEFAULT now()") {
t.Fatalf("expected guarded SET DEFAULT now() without backticks, got:\n%s", output)
}
}
@@ -1216,7 +1216,7 @@ func TestWriteSchema_GuardedAlterColumnTypePreservesConversionFailure(t *testing
}
output := buf.String()
if !strings.Contains(output, "ALTER COLUMN name TYPE integer USING name::integer;") {
if !strings.Contains(output, "ALTER COLUMN \"name\" TYPE integer USING \"name\"::integer;") {
t.Fatalf("expected explicit type conversion, got:\n%s", output)
}
for _, unsafe := range []string{"EXCEPTION WHEN OTHERS", "renamed_column", "RENAME COLUMN"} {
@@ -1258,7 +1258,7 @@ func TestWriteSchema_EmitsGuardedAlterColumnNullabilityStatements(t *testing.T)
if !strings.Contains(output, "current_not_null IS DISTINCT FROM false") {
t.Fatalf("expected guard comparing live nullability against desired value, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN rid_service_instance_type DROP NOT NULL") {
if !strings.Contains(output, "ALTER COLUMN \"rid_service_instance_type\" DROP NOT NULL") {
t.Fatalf("expected guarded DROP NOT NULL for nullable column, got:\n%s", output)
}
}
@@ -1284,13 +1284,13 @@ func TestWriteSchema_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
}
output := buf.String()
if !strings.Contains(output, "ALTER COLUMN id TYPE bigint") {
if !strings.Contains(output, "ALTER COLUMN \"id\" TYPE bigint") {
t.Fatalf("expected serial alter to use bigint storage type, got:\n%s", output)
}
if strings.Contains(output, "ALTER COLUMN id TYPE bigserial;") {
if strings.Contains(output, "ALTER COLUMN \"id\" TYPE bigserial;") {
t.Fatalf("did not expect invalid bigserial alter statement, got:\n%s", output)
}
if !strings.Contains(output, `ALTER COLUMN id TYPE bigint USING id::bigint;`) {
if !strings.Contains(output, `ALTER COLUMN "id" TYPE bigint USING "id"::bigint;`) {
t.Fatalf("expected serial alter to include USING cast, got:\n%s", output)
}
}
@@ -1361,7 +1361,7 @@ func TestWriteDatabase_VectorAndPostGISColumnsCreateExtensions(t *testing.T) {
func TestWriteDatabase_HNSWIndexUsesDefaultVectorOperatorClass(t *testing.T) {
output := writeDatabaseOutput(t, buildVectorSpatialSchema("hnsw", ""))
if !strings.Contains(output, "USING hnsw (embedding vector_cosine_ops)") {
if !strings.Contains(output, "USING hnsw (\"embedding\" vector_cosine_ops)") {
t.Fatalf("expected hnsw index with default vector operator class, got:\n%s", output)
}
if !strings.Contains(output, "CREATE EXTENSION IF NOT EXISTS vector;") {
@@ -1372,7 +1372,7 @@ func TestWriteDatabase_HNSWIndexUsesDefaultVectorOperatorClass(t *testing.T) {
func TestWriteDatabase_VectorIndexHonoursRequestedOperatorClassAndStorageParameters(t *testing.T) {
output := writeDatabaseOutput(t, buildVectorSpatialSchema("ivfflat", "opclass=vector_l2_ops; with (lists=100)"))
if !strings.Contains(output, "USING ivfflat (embedding vector_l2_ops) WITH (lists = 100)") {
if !strings.Contains(output, "USING ivfflat (\"embedding\" vector_l2_ops) WITH (lists = 100)") {
t.Fatalf("expected ivfflat index with requested opclass and storage parameters, got:\n%s", output)
}
}
@@ -1380,7 +1380,7 @@ func TestWriteDatabase_VectorIndexHonoursRequestedOperatorClassAndStorageParamet
func TestWriteDatabase_VectorIndexIgnoresIncompatibleOperatorClass(t *testing.T) {
output := writeDatabaseOutput(t, buildVectorSpatialSchema("hnsw", "opclass=halfvec_l2_ops"))
if !strings.Contains(output, "USING hnsw (embedding vector_cosine_ops)") {
if !strings.Contains(output, "USING hnsw (\"embedding\" vector_cosine_ops)") {
t.Fatalf("expected halfvec operator class to be rejected for a vector column, got:\n%s", output)
}
}
@@ -1416,7 +1416,7 @@ func TestWriteDatabase_GistIndexOnGeometryUsesDefaultOperatorClass(t *testing.T)
output := writeDatabaseOutput(t, db)
if !strings.Contains(output, "USING gist (geom)") {
if !strings.Contains(output, "USING gist (\"geom\")") {
t.Fatalf("expected gist index to rely on the PostGIS default operator class, got:\n%s", output)
}
}
@@ -1442,7 +1442,7 @@ func TestWriteDatabase_GistIndexHonoursRequestedSpatialOperatorClass(t *testing.
output := writeDatabaseOutput(t, db)
if !strings.Contains(output, "USING gist (geom gist_geometry_ops_nd)") {
if !strings.Contains(output, "USING gist (\"geom\" gist_geometry_ops_nd)") {
t.Fatalf("expected requested spatial operator class, got:\n%s", output)
}
}
@@ -1460,7 +1460,7 @@ func TestGenerateDatabaseStatements_VectorIndexIncludesOperatorClassAndParameter
for _, want := range []string{
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS postgis",
"USING hnsw (embedding vector_ip_ops) WITH (m = 16)",
"USING hnsw (\"embedding\" vector_ip_ops) WITH (m = 16)",
} {
if !strings.Contains(joined, want) {
t.Fatalf("expected statements to contain %q, got:\n%s", want, joined)
@@ -1509,7 +1509,7 @@ func TestGenerateColumnDefinition_GeneratedColumnEmitsGeneratedClauseNotDefault(
got := w.generateColumnDefinition(col)
want := "geom text GENERATED ALWAYS AS (st_makepoint(lon, lat)) STORED NOT NULL"
want := "\"geom\" text GENERATED ALWAYS AS (st_makepoint(lon, lat)) STORED NOT NULL"
if got != want {
t.Fatalf("generateColumnDefinition() = %q, want %q", got, want)
}
@@ -1528,7 +1528,7 @@ func TestGenerateColumnDefinition_IdentityColumnEmitsIdentityClauseNotDefault(t
got := w.generateColumnDefinition(col)
want := "id bigint GENERATED ALWAYS AS IDENTITY NOT NULL"
want := "\"id\" bigint GENERATED ALWAYS AS IDENTITY NOT NULL"
if got != want {
t.Fatalf("generateColumnDefinition() = %q, want %q", got, want)
}
@@ -1575,7 +1575,7 @@ func TestWriteDatabase_MultipleForeignKeysToSameTable(t *testing.T) {
}
output := buf.String()
for _, name := range []string{"rid_department", "rid_manager"} {
want := "ADD CONSTRAINT fk_employee_" + name + "\n FOREIGN KEY (" + name + ")"
want := "ADD CONSTRAINT " + quoteIdent("fk_employee_"+name) + "\n FOREIGN KEY (" + quoteIdent(name) + ")"
if !strings.Contains(output, want) {
t.Fatalf("run %d: missing %q in output:\n%s", i, want, output)
}
+8
View File
@@ -264,3 +264,11 @@ docker rm postgres-test
- **SQL Directory Reader**: `pkg/readers/sqldir/` - Read scripts from filesystem
- **Script Model**: `pkg/models/models.go` - Script structure definition
- **pgx Documentation**: https://github.com/jackc/pgx - PostgreSQL driver docs
## Model embed access
SQL directory readers expand embed directives within their trusted input root.
When executing unexpanded directives from a model, explicitly configure
`WriterOptions.Metadata["asset_root"]` with the allowed directory. Script
`source_path` metadata alone never authorizes file reads. Paths, symlinks, regular
file types, and the 64 MiB asset-size limit are checked before reading.
+12 -5
View File
@@ -120,7 +120,8 @@ func (w *Writer) runScripts(ctx context.Context, conn *pgx.Conn, scripts []*mode
backend := writers.ExecutionBackend{ScriptManaged: true, Exec: func(ctx context.Context, i int, _ string) error {
script := scripts[i]
fmt.Printf("Executing script: %s (Priority=%d, Sequence=%d)\n", script.Name, script.Priority, script.Sequence)
sql, err := processEmbedDirectives(script)
assetRoot, _ := w.options.Metadata["asset_root"].(string)
sql, err := processEmbedDirectives(script, assetRoot)
if err != nil {
return fmt.Errorf("preparing script %s: %w", script.Name, err)
}
@@ -141,10 +142,16 @@ func scriptOperations(scripts []*models.Script) []writers.ExecutionOperation {
return operations
}
func processEmbedDirectives(script *models.Script) (string, error) {
sqlPath, _ := script.Metadata[assetloader.ScriptSourcePathMetadataKey].(string)
if sqlPath == "" {
func processEmbedDirectives(script *models.Script, assetRoot string) (string, error) {
if !assetloader.HasEmbedDirectives(script.SQL) {
return script.SQL, nil
}
return assetloader.ProcessEmbedDirectives(sqlPath, script.SQL)
if assetRoot == "" {
return "", fmt.Errorf("asset_root is required to resolve embed directives from a model; source_path metadata does not authorize file access")
}
sqlPath, _ := script.Metadata[assetloader.ScriptSourcePathMetadataKey].(string)
if sqlPath == "" {
return "", fmt.Errorf("source_path is required for model embed directives")
}
return assetloader.ProcessEmbedDirectivesWithOptions(sqlPath, script.SQL, assetloader.EmbedOptions{AssetRoot: assetRoot})
}
+20 -7
View File
@@ -232,7 +232,7 @@ func TestProcessEmbedDirectives_UsesScriptSourcePath(t *testing.T) {
script.SQL = "-- @embed: path=body.txt var=:body mode=text\nSELECT :body;"
script.Metadata[assetloader.ScriptSourcePathMetadataKey] = sqlPath
got, err := processEmbedDirectives(script)
got, err := processEmbedDirectives(script, dir)
if err != nil {
t.Fatalf("processEmbedDirectives failed: %v", err)
}
@@ -241,15 +241,28 @@ func TestProcessEmbedDirectives_UsesScriptSourcePath(t *testing.T) {
}
}
func TestProcessEmbedDirectives_NoSourcePathLeavesSQLUnchanged(t *testing.T) {
func TestProcessEmbedDirectives_NoSourcePathCannotAuthorizeRead(t *testing.T) {
script := models.InitScript("seed")
script.SQL = "-- @embed: path=missing.txt var=:body mode=text\nSELECT :body;"
got, err := processEmbedDirectives(script)
if err != nil {
t.Fatalf("processEmbedDirectives failed: %v", err)
got, err := processEmbedDirectives(script, t.TempDir())
if err == nil || got != "" {
t.Fatalf("expected missing source_path failure, got %q %v", got, err)
}
if got != script.SQL {
t.Fatalf("expected unchanged SQL without source path, got:\n%s", got)
}
func TestProcessEmbedDirectivesModelSourcePathIsNotAuthorization(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
script := models.InitScript("untrusted")
script.SQL = "-- @embed: path=secret.txt var=:body mode=text\nSELECT :body;"
script.Metadata[assetloader.ScriptSourcePathMetadataKey] = filepath.Join(dir, "fake.sql")
if _, err := processEmbedDirectives(script, ""); err == nil || !strings.Contains(err.Error(), "asset_root is required") {
t.Fatalf("source metadata must not authorize file read: %v", err)
}
if _, err := processEmbedDirectives(script, t.TempDir()); err == nil {
t.Fatal("source path outside trusted root must fail")
}
}
+14 -7
View File
@@ -168,15 +168,22 @@ relspec templ --from json --from-path schema.json --template test.tmpl
## Multi-file Output
For multi-file modes, the writer:
For multi-file modes, `OutputPath` is always the output directory, including when
it does not yet exist or has no trailing slash. An empty path uses the current
directory. Database mode continues to treat `OutputPath` as one explicit file.
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
The writer renders and validates all filenames and contents before writing. Names
must be relative, may contain safe subdirectories, and cannot contain traversal,
absolute/drive/UNC paths, backslashes, Windows device names, alternate streams,
control characters, or trailing dots/spaces. Duplicate filenames (including case
variants) and file/directory collisions fail before any artifact is replaced.
Output directory is created automatically if it doesn't exist.
Output directories are created automatically. Existing symlink roots, parents, and
files are rejected. Writes and renames use `os.Root` to retain containment if paths
change during generation. Each file is staged exclusively, closed successfully,
and renamed into place; existing permissions are preserved on replacement.
Multi-file publication is per file, so an I/O error partway through finalization can
still leave a mixed generation (the broader audit A16 staging/manifest work remains).
## Filename Pattern Execution
+114 -92
View File
@@ -2,6 +2,9 @@ package template
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
@@ -9,6 +12,7 @@ import (
"text/template"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
@@ -125,97 +129,133 @@ func (w *Writer) executeDatabaseMode(db *models.Database) error {
return w.writeOutput(output, w.options.OutputPath)
}
// executeSchemaMode executes the template once per schema
// Multi-file generation renders and validates every artifact before touching output.
func (w *Writer) executeSchemaMode(db *models.Database) error {
var data []*TemplateData
for _, schema := range db.Schemas {
data := 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)
data = append(data, NewSchemaData(schema, w.options.Metadata))
}
filename, err := w.generateFilename(data)
if err != nil {
return fmt.Errorf("failed to generate filename for schema %s: %w", schema.Name, err)
return w.executeFiles(data)
}
if err := w.writeOutput(output, filename); err != nil {
return fmt.Errorf("failed to write output for schema %s: %w", schema.Name, err)
}
}
return nil
}
// executeDomainMode executes the template once per domain
func (w *Writer) executeDomainMode(db *models.Database) error {
var data []*TemplateData
for _, domain := range db.Domains {
data := NewDomainData(domain, db, w.options.Metadata)
output, err := w.executeTemplate(data)
if err != nil {
return fmt.Errorf("failed to execute template for domain %s: %w", domain.Name, err)
data = append(data, NewDomainData(domain, db, w.options.Metadata))
}
filename, err := w.generateFilename(data)
if err != nil {
return fmt.Errorf("failed to generate filename for domain %s: %w", domain.Name, err)
return w.executeFiles(data)
}
if err := w.writeOutput(output, filename); err != nil {
return fmt.Errorf("failed to write output for domain %s: %w", domain.Name, err)
}
}
return nil
}
// executeScriptMode executes the template once per script
func (w *Writer) executeScriptMode(db *models.Database) error {
var data []*TemplateData
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)
data = append(data, NewScriptData(script, schema, db, w.options.Metadata))
}
}
return w.executeFiles(data)
}
return nil
}
// executeTableMode executes the template once per table
func (w *Writer) executeTableMode(db *models.Database) error {
var data []*TemplateData
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
data := NewTableData(table, schema, db, w.options.Metadata)
output, err := w.executeTemplate(data)
data = append(data, NewTableData(table, schema, db, w.options.Metadata))
}
}
return w.executeFiles(data)
}
type artifact struct{ name, content string }
func (w *Writer) outputRoot() string {
if w.options.OutputPath == "" {
return "."
}
return w.options.OutputPath
}
func (w *Writer) executeFiles(data []*TemplateData) error {
var files []artifact
seen := make(map[string]bool)
for _, item := range data {
name, err := w.renderFilename(item)
if err != nil {
return fmt.Errorf("failed to execute template for table %s.%s: %w", schema.Name, table.Name, err)
return err
}
filename, err := w.generateFilename(data)
key := strings.ToLower(filepath.ToSlash(name))
if seen[key] {
return fmt.Errorf("generated filename collision: %q", name)
}
seen[key] = true
content, err := w.executeTemplate(item)
if err != nil {
return fmt.Errorf("failed to generate filename for table %s.%s: %w", schema.Name, table.Name, err)
return 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)
files = append(files, artifact{name, content})
}
// Reject file/directory conflicts before creating any output.
for name := range seen {
for parent := filepath.ToSlash(filepath.Dir(name)); parent != "."; parent = filepath.ToSlash(filepath.Dir(parent)) {
if seen[parent] {
return fmt.Errorf("generated file/directory collision: %q", name)
}
}
}
if len(files) == 0 {
return nil
}
rootPath := w.outputRoot()
if err := safefile.CheckRoot(rootPath); err != nil {
return err
}
if err := os.MkdirAll(rootPath, 0o755); err != nil {
return err
}
root, err := os.OpenRoot(rootPath)
if err != nil {
return err
}
defer root.Close()
// Preflight every destination, including existing directory and symlink conflicts.
for _, file := range files {
if err := safefile.Check(root, file.name); err != nil {
return err
}
}
for _, file := range files {
if err := w.writeArtifact(root, file); err != nil {
return fmt.Errorf("write generated file %q: %w", file.name, err)
}
}
return nil
}
func (w *Writer) writeArtifact(root *os.Root, file artifact) error {
if err := root.MkdirAll(filepath.Dir(file.name), 0o755); err != nil {
return err
}
// Exclusive unpredictable staging avoids truncating a destination (or a link target).
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return err
}
temporary := ".relspec-" + hex.EncodeToString(token[:]) + ".tmp"
mode := os.FileMode(0o644)
if info, err := root.Lstat(file.name); err == nil && info.Mode().IsRegular() {
mode = info.Mode().Perm()
}
staged, err := root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
defer root.Remove(temporary)
_, writeErr := staged.WriteString(file.content)
if err := errors.Join(writeErr, staged.Close()); err != nil {
return err
}
if err := safefile.Check(root, file.name); err != nil {
return err
}
return root.Rename(temporary, file.name)
}
// loadTemplate loads and parses the template file
func (w *Writer) loadTemplate() error {
@@ -245,43 +285,25 @@ func (w *Writer) executeTemplate(data *TemplateData) (string, error) {
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
// renderFilename returns a validated path relative to the explicit output root.
func (w *Writer) renderFilename(data *TemplateData) (string, error) {
tmpl, err := template.New("filename").Funcs(w.funcMap).Parse(w.filenamePattern)
if err != nil {
return "", fmt.Errorf("invalid filename pattern: %w", err)
}
// 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 safefile.Relative(buf.String())
}
return filename, nil
func (w *Writer) generateFilename(data *TemplateData) (string, error) {
name, err := w.renderFilename(data)
if err != nil {
return "", err
}
return filepath.Join(w.outputRoot(), name), nil
}
// writeOutput writes the output to a file or stdout
+122 -2
View File
@@ -180,8 +180,8 @@ func TestWriterGenerateFilenameOutputPathForms(t *testing.T) {
{"no output path", "", "users.txt"},
{"existing dir", dir, filepath.Join(dir, "users.txt")},
{"trailing separator", filepath.Join(dir, "new") + string(filepath.Separator), filepath.Join(dir, "new", "users.txt")},
{"file path uses its dir", filepath.Join(dir, "x.out"), filepath.Join(dir, "users.txt")},
{"bare file name", "x.out", "users.txt"},
{"nonexistent path is root", filepath.Join(dir, "x.out"), filepath.Join(dir, "x.out", "users.txt")},
{"bare path is root", "x.out", filepath.Join("x.out", "users.txt")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -217,3 +217,123 @@ func TestWriterWriteSchemaAndTable(t *testing.T) {
t.Errorf("WriteTable: %q", data)
}
}
func TestMultiFileRejectsUnsafeFilenamesBeforeWriting(t *testing.T) {
for _, name := range []string{"../escape", "/absolute", `..\escape`, `C:\escape`, `\\server\share\file`, "a/../escape", "a//escape", "CON", "nul.txt", "COM¹.txt", "file:stream", "trailing.", "trailing ", "line\nbreak"} {
t.Run(name, func(t *testing.T) {
out := filepath.Join(t.TempDir(), "new-output")
w, err := newTestWriter(t, "content", "schema", "{{.Name}}", out)
if err != nil {
t.Fatal(err)
}
db := models.InitDatabase("unsafe")
db.Schemas = []*models.Schema{models.InitSchema("valid"), models.InitSchema(name)}
if err := w.WriteDatabase(db); err == nil {
t.Fatal("expected rejected filename")
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatalf("validation failure must not create output: %v", err)
}
})
}
}
func TestMultiFileCollisionsDoNotOverwrite(t *testing.T) {
for _, names := range [][]string{{"same.txt", "same.txt"}, {"SAME.txt", "same.txt"}, {"file", "file/child.txt"}} {
out := t.TempDir()
seed := filepath.Join(out, "same.txt")
if err := os.WriteFile(seed, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
w, err := newTestWriter(t, "replacement", "schema", "{{.Name}}", out)
if err != nil {
t.Fatal(err)
}
db := models.InitDatabase("collision")
for _, name := range names {
db.Schemas = append(db.Schemas, models.InitSchema(name))
}
if err := w.WriteDatabase(db); err == nil || !strings.Contains(err.Error(), "collision") {
t.Fatalf("expected collision error: %v", err)
}
got, err := os.ReadFile(seed)
if err != nil || string(got) != "original" {
t.Fatalf("existing output changed: %q %v", got, err)
}
}
}
func TestMultiFileRejectsSymlinksBeforeAnyWrites(t *testing.T) {
for _, kind := range []string{"parent", "leaf", "root"} {
t.Run(kind, func(t *testing.T) {
out, external := t.TempDir(), t.TempDir()
secret := filepath.Join(external, "secret.txt")
if err := os.WriteFile(secret, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
link, target := filepath.Join(out, "link"), external
pattern := "{{.Name}}/secret.txt"
if kind == "leaf" {
target = secret
pattern = "{{.Name}}"
}
if kind == "root" {
link = filepath.Join(out, "root")
out = link
pattern = "{{.Name}}"
}
if err := os.Symlink(target, link); err != nil {
t.Skip(err)
}
w, err := newTestWriter(t, "replacement", "schema", pattern, out)
if err != nil {
t.Fatal(err)
}
db := models.InitDatabase("links")
db.Schemas = []*models.Schema{models.InitSchema("first"), models.InitSchema("link")}
if err := w.WriteDatabase(db); err == nil {
t.Fatal("expected symlink rejection")
}
got, err := os.ReadFile(secret)
if err != nil || string(got) != "original" {
t.Fatalf("symlink target changed: %q %v", got, err)
}
if _, err := os.Stat(filepath.Join(out, "first")); !os.IsNotExist(err) {
t.Fatalf("preflight must precede writes: %v", err)
}
})
}
}
func TestMultiFileNewDirectoryAndNestedFiles(t *testing.T) {
out := filepath.Join(t.TempDir(), "new-output")
w, err := newTestWriter(t, "{{.Table.Name}}", "table", "{{.ParentSchema.Name}}/{{.Name}}.txt", out)
if err != nil {
t.Fatal(err)
}
if err := w.WriteDatabase(modeDB()); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(out, "a", "t1.txt"))
if err != nil || string(got) != "t1" {
t.Fatalf("nested output: %q %v", got, err)
}
}
func TestMultiFileRootAncestorSymlinkRejected(t *testing.T) {
parent, external := t.TempDir(), t.TempDir()
link := filepath.Join(parent, "link")
if err := os.Symlink(external, link); err != nil {
t.Skip(err)
}
w, err := newTestWriter(t, "content", "schema", "{{.Name}}.txt", filepath.Join(link, "new"))
if err != nil {
t.Fatal(err)
}
if err := w.WriteDatabase(modeDB()); err == nil {
t.Fatal("root ancestor symlink must fail")
}
if _, err := os.Stat(filepath.Join(external, "new")); !os.IsNotExist(err) {
t.Fatalf("must not create external directories: %v", err)
}
}
+3
View File
@@ -67,6 +67,9 @@ type WriterOptions struct {
// Useful for databases like SQLite that do not support schemas.
FlattenSchema bool
// LowercaseIdentifiers lowercases PostgreSQL names and structured references before generation.
LowercaseIdentifiers bool
// NullableTypes selects the Go type package used for nullable columns in
// code-generation writers (bun, gorm). Accepted values:
// "sqltypes" — git.warky.dev/wdevs/relspecgo/pkg/sqltypes