Skip to content

Commit 90f2aaa

Browse files
NFUChenWilliam-W-Chenguillaume86claudevytautas-karpavicius
authored
Sync fork on 2026-06-03 (#7)
* refactor: refactor the release workflow's test job to use a GitHub Actions matrix strategy, running PostgreSQL 14-18 tests in parallel instead of sequentially. (pgplex#432) * refactor: streamline CI workflows for unit and integration tests with matrix strategy * fix: ensure integration tests depend on unit tests in CI workflow * refactor: consolidate unit and integration tests into a single CI job * revert: undo unnecessary trailing newline change in ci-test.yml --------- Co-authored-by: William Chen <william_w_chen@trendmicro.com> * feat: add validation for file extensions in apply command (pgplex#434) Co-authored-by: William Chen <william_w_chen@trendmicro.com> * Add support for indexes section in .pgschemaignore (pgplex#441) Adds a new [indexes] section to .pgschemaignore that lets users preserve indexes which exist in the database but are not declared in their .sql files. Without this, manually-added indexes (e.g. perf hotfixes) are flagged for drop by `pgschema plan`. Mirrors the existing per-object pattern: `Indexes []string` on `IgnoreConfig` with `ShouldIgnoreIndex(name)`, an `IndexIgnoreConfig` struct on `TomlConfig`, and a filter in `Inspector.buildIndexes` that skips ignored index names before they enter the IR. Fixes pgplex#406 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: preserve length modifier in varchar(n)[] array columns (pgplex#420) (pgplex#438) Co-authored-by: vytautas.karpavicius <vytautas.karpavicius@cloudkitchens.com> * chore: bump 1.9.0 to 1.10.0 --------- Co-authored-by: William Chen <william_w_chen@trendmicro.com> Co-authored-by: Guillaume Lecomte <guillaume86@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Vytautas <vycas0@gmail.com> Co-authored-by: vytautas.karpavicius <vytautas.karpavicius@cloudkitchens.com> Co-authored-by: Tianzhou <t@bytebase.com>
1 parent e09f460 commit 90f2aaa

184 files changed

Lines changed: 608 additions & 187 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/apply/apply.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"database/sql"
77
"fmt"
88
"os"
9+
"path/filepath"
910
"strings"
1011

1112
"github.com/pgplex/pgschema/cmd/config"
@@ -268,6 +269,24 @@ func ApplyMigration(config *ApplyConfig, provider postgres.DesiredStateProvider)
268269
return nil
269270
}
270271

272+
// validateFileExtension checks that --file and --plan flags have the expected file extensions.
273+
// Returns an actionable error if a likely flag mix-up is detected.
274+
func validateFileExtension(file, planFile string) error {
275+
if file != "" {
276+
ext := strings.ToLower(filepath.Ext(file))
277+
if ext == ".json" {
278+
return fmt.Errorf("--file expects a SQL schema file, but got a JSON file (%s). Did you mean to use --plan instead?", filepath.Base(file))
279+
}
280+
}
281+
if planFile != "" {
282+
ext := strings.ToLower(filepath.Ext(planFile))
283+
if ext == ".sql" {
284+
return fmt.Errorf("--plan expects a JSON plan file, but got a SQL file (%s). Did you mean to use --file instead?", filepath.Base(planFile))
285+
}
286+
}
287+
return nil
288+
}
289+
271290
// RunApply executes the apply command logic. Exported for testing.
272291
func RunApply(cmd *cobra.Command, args []string) error {
273292
cfg := config.Get()
@@ -281,6 +300,11 @@ func RunApply(cmd *cobra.Command, args []string) error {
281300
return fmt.Errorf("either --file or --plan must be specified")
282301
}
283302

303+
// Validate file extensions to catch flag mix-ups early
304+
if err := validateFileExtension(applyFile, applyPlan); err != nil {
305+
return err
306+
}
307+
284308
// Derive final password: use provided password or check environment variable
285309
finalPassword := applyPassword
286310
if finalPassword == "" {

cmd/apply/apply_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,166 @@ func TestApplyCommandFileError(t *testing.T) {
498498
}
499499
}
500500

501+
func TestApplyFileExtensionValidation(t *testing.T) {
502+
// Save original values
503+
origDB := applyDB
504+
origUser := applyUser
505+
origFile := applyFile
506+
origPlan := applyPlan
507+
defer func() {
508+
applyDB = origDB
509+
applyUser = origUser
510+
applyFile = origFile
511+
applyPlan = origPlan
512+
}()
513+
514+
t.Run("file with json extension suggests plan", func(t *testing.T) {
515+
applyDB = "testdb"
516+
applyUser = "testuser"
517+
applyFile = "plan.json"
518+
applyPlan = ""
519+
520+
err := RunApply(nil, []string{})
521+
if err == nil {
522+
t.Fatal("Expected error when --file has .json extension")
523+
}
524+
if !strings.Contains(err.Error(), "--file expects a SQL schema file") {
525+
t.Errorf("Expected SQL schema file error, got: %v", err)
526+
}
527+
if !strings.Contains(err.Error(), "--plan instead") {
528+
t.Errorf("Expected suggestion to use --plan, got: %v", err)
529+
}
530+
})
531+
532+
t.Run("file with JSON extension case insensitive", func(t *testing.T) {
533+
applyDB = "testdb"
534+
applyUser = "testuser"
535+
applyFile = "plan.JSON"
536+
applyPlan = ""
537+
538+
err := RunApply(nil, []string{})
539+
if err == nil {
540+
t.Fatal("Expected error when --file has .JSON extension")
541+
}
542+
if !strings.Contains(err.Error(), "--file expects a SQL schema file") {
543+
t.Errorf("Expected SQL schema file error, got: %v", err)
544+
}
545+
})
546+
547+
t.Run("plan with sql extension suggests file", func(t *testing.T) {
548+
applyDB = "testdb"
549+
applyUser = "testuser"
550+
applyFile = ""
551+
applyPlan = "schema.sql"
552+
553+
err := RunApply(nil, []string{})
554+
if err == nil {
555+
t.Fatal("Expected error when --plan has .sql extension")
556+
}
557+
if !strings.Contains(err.Error(), "--plan expects a JSON plan file") {
558+
t.Errorf("Expected JSON plan file error, got: %v", err)
559+
}
560+
if !strings.Contains(err.Error(), "--file instead") {
561+
t.Errorf("Expected suggestion to use --file, got: %v", err)
562+
}
563+
})
564+
565+
t.Run("plan with SQL extension case insensitive", func(t *testing.T) {
566+
applyDB = "testdb"
567+
applyUser = "testuser"
568+
applyFile = ""
569+
applyPlan = "schema.SQL"
570+
571+
err := RunApply(nil, []string{})
572+
if err == nil {
573+
t.Fatal("Expected error when --plan has .SQL extension")
574+
}
575+
if !strings.Contains(err.Error(), "--plan expects a JSON plan file") {
576+
t.Errorf("Expected JSON plan file error, got: %v", err)
577+
}
578+
})
579+
580+
t.Run("file with sql extension no error", func(t *testing.T) {
581+
tmpDir := t.TempDir()
582+
schemaPath := filepath.Join(tmpDir, "schema.sql")
583+
if err := os.WriteFile(schemaPath, []byte("CREATE TABLE test (id INT);"), 0644); err != nil {
584+
t.Fatalf("Failed to write schema file: %v", err)
585+
}
586+
587+
applyDB = "testdb"
588+
applyUser = "testuser"
589+
applyFile = schemaPath
590+
applyPlan = ""
591+
592+
err := RunApply(nil, []string{})
593+
// Should fail on something other than extension validation
594+
if err != nil && strings.Contains(err.Error(), "expects a") {
595+
t.Errorf("Should not get extension validation error for .sql file: %v", err)
596+
}
597+
})
598+
599+
t.Run("plan with json extension no error", func(t *testing.T) {
600+
tmpDir := t.TempDir()
601+
planPath := filepath.Join(tmpDir, "plan.json")
602+
planJSON := fmt.Sprintf(`{"version":"1.0.0","pgschema_version":"%s","created_at":"2024-01-01T00:00:00Z","transaction":true,"summary":{"total":0,"add":0,"change":0,"destroy":0,"by_type":{}},"diffs":[]}`, version.App())
603+
if err := os.WriteFile(planPath, []byte(planJSON), 0644); err != nil {
604+
t.Fatalf("Failed to write plan file: %v", err)
605+
}
606+
607+
applyDB = "testdb"
608+
applyUser = "testuser"
609+
applyFile = ""
610+
applyPlan = planPath
611+
612+
err := RunApply(nil, []string{})
613+
// Should not get extension validation error
614+
if err != nil && strings.Contains(err.Error(), "expects a") {
615+
t.Errorf("Should not get extension validation error for .json plan: %v", err)
616+
}
617+
})
618+
}
619+
620+
func TestValidateFileExtension(t *testing.T) {
621+
tests := []struct {
622+
name string
623+
file string
624+
plan string
625+
wantErr bool
626+
errMsg string
627+
}{
628+
{"file with json", "plan.json", "", true, "--file expects a SQL schema file"},
629+
{"file with JSON uppercase", "plan.JSON", "", true, "--file expects a SQL schema file"},
630+
{"file with Json mixed case", "plan.Json", "", true, "--file expects a SQL schema file"},
631+
{"plan with sql", "", "schema.sql", true, "--plan expects a JSON plan file"},
632+
{"plan with SQL uppercase", "", "schema.SQL", true, "--plan expects a JSON plan file"},
633+
{"file with sql is ok", "schema.sql", "", false, ""},
634+
{"plan with json is ok", "", "plan.json", false, ""},
635+
{"both empty is ok", "", "", false, ""},
636+
{"file with no extension is ok", "schema", "", false, ""},
637+
{"plan with no extension is ok", "", "plan", false, ""},
638+
{"file with path and json", "/tmp/plans/plan.json", "", true, "--file expects a SQL schema file"},
639+
{"plan with path and sql", "", "/tmp/schemas/schema.sql", true, "--plan expects a JSON plan file"},
640+
}
641+
642+
for _, tt := range tests {
643+
t.Run(tt.name, func(t *testing.T) {
644+
err := validateFileExtension(tt.file, tt.plan)
645+
if tt.wantErr {
646+
if err == nil {
647+
t.Fatal("Expected error but got nil")
648+
}
649+
if !strings.Contains(err.Error(), tt.errMsg) {
650+
t.Errorf("Expected error containing %q, got: %v", tt.errMsg, err)
651+
}
652+
} else {
653+
if err != nil {
654+
t.Errorf("Expected no error, got: %v", err)
655+
}
656+
}
657+
})
658+
}
659+
}
660+
501661
func TestApplyCommand_PlanDatabaseFlags(t *testing.T) {
502662
flags := ApplyCmd.Flags()
503663

cmd/dump/dump_integration_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ func TestDumpCommand_Issue191FunctionProcedureOverload(t *testing.T) {
151151
runExactMatchTest(t, "issue_191_function_procedure_overload")
152152
}
153153

154+
func TestDumpCommand_Issue420VarcharArrayLengthModifier(t *testing.T) {
155+
if testing.Short() {
156+
t.Skip("Skipping integration test in short mode")
157+
}
158+
runExactMatchTest(t, "issue_420_varchar_array_length_modifier")
159+
}
160+
154161
// Reproduces a bug where a column declared as `name` is dumped as `char[]`.
155162
// The inspector classifies any base type with pg_type.typelem <> 0 as an array,
156163
// but the `name` type has typelem = 18 (the OID of "char") despite not being an array.

cmd/ignore_integration_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,117 @@ GRANT SELECT ON users TO app_user;
13341334
}
13351335
}
13361336

1337+
// TestIgnoreIndexes tests that indexes matching .pgschemaignore [indexes] patterns
1338+
// are excluded from dump and plan output.
1339+
// Reproduces https://github.com/pgplex/pgschema/issues/406
1340+
func TestIgnoreIndexes(t *testing.T) {
1341+
if testing.Short() {
1342+
t.Skip("Skipping integration test in short mode")
1343+
}
1344+
1345+
embeddedPG := testutil.SetupPostgres(t)
1346+
defer embeddedPG.Stop()
1347+
conn, host, port, dbname, user, password := testutil.ConnectToPostgres(t, embeddedPG)
1348+
defer conn.Close()
1349+
1350+
containerInfo := &struct {
1351+
Conn *sql.DB
1352+
Host string
1353+
Port int
1354+
DBName string
1355+
User string
1356+
Password string
1357+
}{
1358+
Conn: conn,
1359+
Host: host,
1360+
Port: port,
1361+
DBName: dbname,
1362+
User: user,
1363+
Password: password,
1364+
}
1365+
1366+
// Create a table with a managed index plus a manually-added index that
1367+
// is not part of the declared schema (simulates a perf hotfix index
1368+
// added directly to a production database).
1369+
setupSQL := `
1370+
CREATE TABLE products (
1371+
id SERIAL PRIMARY KEY,
1372+
name TEXT NOT NULL,
1373+
category TEXT
1374+
);
1375+
1376+
CREATE INDEX products_name_idx ON products(name);
1377+
CREATE INDEX manual_perf_idx ON products(category);
1378+
`
1379+
_, err := conn.Exec(setupSQL)
1380+
if err != nil {
1381+
t.Fatalf("Failed to create test schema: %v", err)
1382+
}
1383+
1384+
originalWd, err := os.Getwd()
1385+
if err != nil {
1386+
t.Fatalf("Failed to get current working directory: %v", err)
1387+
}
1388+
defer func() {
1389+
if err := os.Chdir(originalWd); err != nil {
1390+
t.Fatalf("Failed to restore working directory: %v", err)
1391+
}
1392+
}()
1393+
1394+
tmpDir := t.TempDir()
1395+
if err := os.Chdir(tmpDir); err != nil {
1396+
t.Fatalf("Failed to change to temp directory: %v", err)
1397+
}
1398+
1399+
// Ignore any index whose name starts with "manual_"
1400+
ignoreContent := `[indexes]
1401+
patterns = ["manual_*"]
1402+
`
1403+
err = os.WriteFile(".pgschemaignore", []byte(ignoreContent), 0644)
1404+
if err != nil {
1405+
t.Fatalf("Failed to create .pgschemaignore: %v", err)
1406+
}
1407+
1408+
t.Run("dump", func(t *testing.T) {
1409+
output := executeIgnoreDumpCommand(t, containerInfo)
1410+
1411+
if !strings.Contains(output, "products_name_idx") {
1412+
t.Error("Dump should include products_name_idx (not ignored)")
1413+
}
1414+
1415+
if strings.Contains(output, "manual_perf_idx") {
1416+
t.Error("Dump should not include manual_perf_idx (ignored by [indexes] patterns)")
1417+
}
1418+
})
1419+
1420+
t.Run("plan", func(t *testing.T) {
1421+
// Desired schema declares products_name_idx but not manual_perf_idx.
1422+
// Without the ignore the plan would emit DROP INDEX manual_perf_idx;
1423+
// with the ignore the plan should not reference manual_perf_idx at all.
1424+
schemaSQL := `
1425+
CREATE TABLE products (
1426+
id SERIAL PRIMARY KEY,
1427+
name TEXT NOT NULL,
1428+
category TEXT
1429+
);
1430+
1431+
CREATE INDEX products_name_idx ON products(name);
1432+
`
1433+
schemaFile := "schema.sql"
1434+
err := os.WriteFile(schemaFile, []byte(schemaSQL), 0644)
1435+
if err != nil {
1436+
t.Fatalf("Failed to create schema file: %v", err)
1437+
}
1438+
defer os.Remove(schemaFile)
1439+
1440+
output := executeIgnorePlanCommand(t, containerInfo, schemaFile)
1441+
1442+
if strings.Contains(output, "manual_perf_idx") {
1443+
t.Errorf("Plan should not reference manual_perf_idx (ignored); got: %s", output)
1444+
}
1445+
})
1446+
}
1447+
13371448
// verifyPlanOutput checks that plan output excludes ignored objects
13381449
func verifyPlanOutput(t *testing.T, output string) {
13391450
// Changes that should appear in plan (regular objects)

cmd/util/ignoreloader.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type TomlConfig struct {
3434
Procedures ProcedureIgnoreConfig `toml:"procedures,omitempty"`
3535
Types TypeIgnoreConfig `toml:"types,omitempty"`
3636
Sequences SequenceIgnoreConfig `toml:"sequences,omitempty"`
37+
Indexes IndexIgnoreConfig `toml:"indexes,omitempty"`
3738
Privileges PrivilegeIgnoreConfig `toml:"privileges,omitempty"`
3839
DefaultPrivileges DefaultPrivilegeIgnoreConfig `toml:"default_privileges,omitempty"`
3940
}
@@ -68,6 +69,11 @@ type SequenceIgnoreConfig struct {
6869
Patterns []string `toml:"patterns,omitempty"`
6970
}
7071

72+
// IndexIgnoreConfig represents index-specific ignore configuration
73+
type IndexIgnoreConfig struct {
74+
Patterns []string `toml:"patterns,omitempty"`
75+
}
76+
7177
// PrivilegeIgnoreConfig represents privilege-specific ignore configuration
7278
// Patterns match on grantee role names
7379
type PrivilegeIgnoreConfig struct {
@@ -111,6 +117,7 @@ func LoadIgnoreFileWithStructureFromPath(filePath string) (*ir.IgnoreConfig, err
111117
Procedures: tomlConfig.Procedures.Patterns,
112118
Types: tomlConfig.Types.Patterns,
113119
Sequences: tomlConfig.Sequences.Patterns,
120+
Indexes: tomlConfig.Indexes.Patterns,
114121
Privileges: tomlConfig.Privileges.Patterns,
115122
DefaultPrivileges: tomlConfig.DefaultPrivileges.Patterns,
116123
}

cmd/util/ignoreloader_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ patterns = ["type_test_*"]
4141
4242
[sequences]
4343
patterns = ["seq_temp_*"]
44+
45+
[indexes]
46+
patterns = ["idx_temp_*"]
4447
`
4548

4649
err := os.WriteFile(testFile, []byte(tomlContent), 0644)
@@ -79,6 +82,10 @@ patterns = ["seq_temp_*"]
7982
if len(config.Procedures) != 1 || config.Procedures[0] != "sp_temp_*" {
8083
t.Errorf("Expected procedure patterns [\"sp_temp_*\"], got %v", config.Procedures)
8184
}
85+
86+
if len(config.Indexes) != 1 || config.Indexes[0] != "idx_temp_*" {
87+
t.Errorf("Expected indexes patterns [\"idx_temp_*\"], got %v", config.Indexes)
88+
}
8289
}
8390

8491
func TestLoadIgnoreFileWithStructure_ValidTOML(t *testing.T) {

0 commit comments

Comments
 (0)