diff --git a/postgres.go b/postgres.go index 8a60c36..53d2865 100644 --- a/postgres.go +++ b/postgres.go @@ -12,12 +12,33 @@ package main import ( "database/sql" + "errors" "fmt" "io" "os" "path/filepath" + + "github.com/jackc/pgx/v5/pgconn" ) +// isPGUnavailableError reports whether err indicates that the queried object +// is not installed/available (missing extension, table, function, or schema). +// These are treated as skips rather than failures. +func isPGUnavailableError(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return false + } + switch pgErr.Code { + case "42P01", // undefined_table + "42704", // undefined_object + "42883", // undefined_function + "3F000": // invalid_schema_name + return true + } + return false +} + // postgresConfigFileTasks defines tasks for collecting PostgreSQL configuration files (sorted alphabetically by name) var postgresConfigFileTasks = []SimpleConfigFileTask{ { @@ -154,6 +175,9 @@ func execPGQueryOnDB(dbname string, cfg *Config, query string, w io.Writer) erro rows, err := db.Query(query) if err != nil { + if isPGUnavailableError(err) { + return NewSkipError(err.Error()) + } return fmt.Errorf("query failed: %w", err) } defer closeErrCheck(rows, "query rows") diff --git a/postgres_test.go b/postgres_test.go index f1a6b0e..98182c8 100644 --- a/postgres_test.go +++ b/postgres_test.go @@ -11,8 +11,13 @@ package main import ( + "bytes" + "errors" "strings" "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jackc/pgx/v5/pgconn" ) // TestPostgreSQLCollectors verifies all expected PostgreSQL collectors are registered @@ -151,3 +156,42 @@ func TestPostgreSQLTasksStructure(t *testing.T) { } } } + +// TestPGQueryCollectorUnavailableAsSkip verifies that PG errors for missing +// extensions (undefined_table/function/object, invalid_schema) are returned +// as SkipError, while real errors (permission denied) are returned as-is. +func TestPGQueryCollectorUnavailableAsSkip(t *testing.T) { + tests := []struct { + name string + pgErr *pgconn.PgError + wantSkip bool + }{ + {"undefined_table is skip", &pgconn.PgError{Code: "42P01", Message: "relation \"pg_stat_statements\" does not exist"}, true}, + {"undefined_function is skip", &pgconn.PgError{Code: "42883", Message: "function does not exist"}, true}, + {"undefined_object is skip", &pgconn.PgError{Code: "42704", Message: "type does not exist"}, true}, + {"invalid_schema is skip", &pgconn.PgError{Code: "3F000", Message: "schema \"pgstatviz\" does not exist"}, true}, + {"permission_denied is real error", &pgconn.PgError{Code: "42501", Message: "permission denied"}, false}, + {"syntax_error is real error", &pgconn.PgError{Code: "42601", Message: "syntax error"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock: %v", err) + } + mock.ExpectQuery("SELECT").WillReturnError(tt.pgErr) + + collector := pgQueryCollector(db, "SELECT 1") + err = collector(&Config{}, &bytes.Buffer{}) + + if err == nil { + t.Fatal("expected error, got nil") + } + var skipErr SkipError + isSkip := errors.As(err, &skipErr) + if isSkip != tt.wantSkip { + t.Errorf("errors.As SkipError = %v, want %v (err: %v)", isSkip, tt.wantSkip, err) + } + }) + } +} diff --git a/radar.go b/radar.go index bce5e0a..dbeaf98 100644 --- a/radar.go +++ b/radar.go @@ -614,6 +614,9 @@ func pgQueryCollector(db *sql.DB, query string) func(*Config, io.Writer) error { } rows, err := db.Query(query) if err != nil { + if isPGUnavailableError(err) { + return NewSkipError(err.Error()) + } return err } defer closeErrCheck(rows, "query rows")