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
252 lines
10 KiB
Go
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] = ¤tValue
|
|
currentID := *id
|
|
currentTable.Columns[key] = ¤tID
|
|
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)
|
|
}
|
|
}
|