Files
relspecgo/pkg/writers/pgsql/quoting_test.go
T
warkanum 1d93255575 fix(assets): improve error reporting for asset failures
fix(embed): update embedDirectives to return result directly

fix(executor): clean up import order and formatting

fix(syntax_test): change file permission syntax to octal

fix(mysql): update file permission syntax to octal

fix(pgsql): update file permission syntax to octal

fix(sqlex): simplify Walk function parameter handling

fix(transform): adjust identifier comparison logic

fix(template): clean up function formatting and error handling
2026-10-12 22:04:40 +02:00

252 lines
10 KiB
Go

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"), 0o600); 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)
}
}