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) } }