Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions cmd/dump/extension_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package dump

import (
"context"
"fmt"
"testing"

"github.com/pgplex/pgschema/ir"
"github.com/pgplex/pgschema/testutil"
"github.com/stretchr/testify/require"
)

// Issue #595: pg_stat_statements' view was dumped while its required function
// was omitted. Neither definition belongs in an application schema dump.
func TestDumpExtensionMembers(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
pg := testutil.SetupPostgres(t)
defer pg.Stop()
db, host, port, name, user, password := testutil.ConnectToPostgres(t, pg)
defer db.Close()
ctx := context.Background()
// Catalog inspection and view creation do not execute pg_stat_statements,
// so the bundled extension needs no shared_preload_libraries modification.
_, err := db.ExecContext(ctx, `
CREATE EXTENSION pg_stat_statements;
CREATE TABLE app_requests (id integer PRIMARY KEY);
CREATE VIEW app_stats AS SELECT queryid FROM pg_stat_statements;
GRANT SELECT ON app_requests TO PUBLIC;
GRANT SELECT (queryid) ON pg_stat_statements TO PUBLIC;
`)
require.NoError(t, err)
config := &DumpConfig{
Host: host, Port: port, DB: name, User: user, Password: password,
Schema: "public", NoComments: true, ConfigDir: t.TempDir(),
}
dumped, err := ExecuteDump(config)
require.NoError(t, err)
require.NotContains(t, dumped, "VIEW pg_stat_statements")
require.NotContains(t, dumped, "CREATE OR REPLACE FUNCTION pg_stat_statements")
require.NotContains(t, dumped, "ON TABLE pg_stat_statements")
require.Contains(t, dumped, "CREATE OR REPLACE VIEW app_stats")
require.Contains(t, dumped, "FROM pg_stat_statements")
require.Contains(t, dumped, "GRANT SELECT ON TABLE app_requests TO PUBLIC")
// Reload the unmodified native dump while the extension is still installed.
_, err = db.ExecContext(ctx, `DROP VIEW app_stats; DROP TABLE app_requests;`)
require.NoError(t, err)
_, err = db.ExecContext(ctx, dumped)
require.NoError(t, err)
roundtrip, err := ExecuteDump(config)
require.NoError(t, err)
require.Equal(t, dumped, roundtrip)
}

// An application partition remains managed even when its parent is an
// extension member. Replaying its dump must preserve its own column rules.
func TestDumpExtensionPartitionOverrides(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
pg := testutil.SetupPostgres(t)
defer pg.Stop()
db, host, port, name, user, password := testutil.ConnectToPostgres(t, pg)
defer db.Close()
ctx := context.Background()
_, err := db.ExecContext(ctx, `CREATE SCHEMA extension595; CREATE EXTENSION hstore SCHEMA extension595;`)
require.NoError(t, err)
for _, tc := range []struct{ parentSchema, childSchema string }{
{"public", "public"},
{"Extension Space", "App Space"},
} {
t.Run(tc.childSchema, func(t *testing.T) {
parent := ir.QuoteIdentifier(tc.parentSchema) + `."Member Parent"`
child := ir.QuoteIdentifier(tc.childSchema) + `."App Child"`
_, err := db.ExecContext(ctx, fmt.Sprintf(`
CREATE SCHEMA IF NOT EXISTS %s;
CREATE SCHEMA IF NOT EXISTS %s;
CREATE TABLE %s (
id integer NOT NULL,
priority integer DEFAULT 0,
notes text,
inherited integer DEFAULT 42 NOT NULL,
calculated integer GENERATED ALWAYS AS (id * 2) STORED
) PARTITION BY RANGE (id);
ALTER EXTENSION hstore ADD TABLE %s;
CREATE TABLE %s PARTITION OF %s (
priority DEFAULT 10, notes NOT NULL
) FOR VALUES FROM (0) TO (100);
`, ir.QuoteIdentifier(tc.parentSchema), ir.QuoteIdentifier(tc.childSchema), parent, parent, child, parent))
require.NoError(t, err)
config := &DumpConfig{Host: host, Port: port, DB: name, User: user, Password: password,
Schema: tc.childSchema, NoComments: true, QualifySchema: true, ConfigDir: t.TempDir()}
dumped, err := ExecuteDump(config)
require.NoError(t, err)
require.NotContains(t, dumped, `CREATE TABLE IF NOT EXISTS `+parent+` (`)
_, err = db.ExecContext(ctx, `DROP TABLE `+child)
require.NoError(t, err)
_, err = db.ExecContext(ctx, dumped)
require.NoError(t, err, dumped)
var priority, inherited, calculated int
err = db.QueryRowContext(ctx, `INSERT INTO `+child+` (id, notes) VALUES (1, 'kept') RETURNING priority, inherited, calculated`).Scan(&priority, &inherited, &calculated)
require.NoError(t, err)
require.Equal(t, 10, priority, "partition default must survive dump replay")
require.Equal(t, 42, inherited)
require.Equal(t, 2, calculated)
_, err = db.ExecContext(ctx, `INSERT INTO `+child+` (id) VALUES (2)`)
require.ErrorContains(t, err, "23502", "partition NOT NULL must survive dump replay")
roundtrip, err := ExecuteDump(config)
require.NoError(t, err)
require.Equal(t, dumped, roundtrip)
_, err = db.ExecContext(ctx, `DROP TABLE `+child+`; ALTER EXTENSION hstore DROP TABLE `+parent+`; DROP TABLE `+parent)
require.NoError(t, err)
})
}
}
7 changes: 7 additions & 0 deletions cmd/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,13 @@ func normalizeSchemaNames(irData *ir.IR, fromSchema, toSchema string) {
*column.GeneratedExpr = stripQualifiers(replaceString(*column.GeneratedExpr))
}
}
// Unmanaged parent defaults must use the same schema context as
// child defaults, otherwise inherited expressions look like overrides.
for _, column := range table.PartitionParentColumns {
if column.DefaultValue != nil {
*column.DefaultValue = stripQualifiers(replaceString(*column.DefaultValue))
}
}

// Normalize schema names in indexes
for _, index := range table.Indexes {
Expand Down
28 changes: 28 additions & 0 deletions cmd/plan/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/pgplex/pgschema/internal/diff"
"github.com/pgplex/pgschema/ir"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -237,6 +239,32 @@ func TestNormalizeSchemaNames_StripsSameSchemaQualifiersFromViewDefinitions(t *t
}
}

func TestNormalizeSchemaNames_PreservesInheritedPartitionDefault(t *testing.T) {
const tempSchema = "pgschema_tmp_partition595"
childDefault, parentDefault := "public.member_default()", "public.member_default()"
child := &ir.Table{
Schema: tempSchema, Name: "app_child", PartitionOf: "member_parent",
PartitionOfSchema: "public", PartitionBound: "FOR VALUES FROM (0) TO (10)",
Columns: []*ir.Column{{Name: "value", DefaultValue: &childDefault}},
PartitionParentColumns: []*ir.Column{{Name: "value", DefaultValue: &parentDefault}},
}
desired := &ir.IR{Schemas: map[string]*ir.Schema{
tempSchema: {Name: tempSchema, Tables: map[string]*ir.Table{"app_child": child}},
}}
normalizeSchemaNames(desired, tempSchema, "public")
changes := diff.GenerateMigration(ir.NewIR(), desired, "public")
var statements []string
for _, change := range changes {
for _, statement := range change.Statements {
statements = append(statements, statement.SQL)
}
}
sql := strings.Join(statements, "\n")
if !strings.Contains(sql, "PARTITION OF member_parent") || strings.Contains(sql, "DEFAULT") {
t.Fatalf("expected inherited default without a child override, got %s", sql)
}
}

func TestNormalizeSchemaNames_PreservesViewTableAliasesMatchingTargetSchema(t *testing.T) {
irData := &ir.IR{
Schemas: map[string]*ir.Schema{
Expand Down
13 changes: 13 additions & 0 deletions docs/cli/plan-db.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ pgschema apply \

pgschema does not manage extensions, so `dump` never emits `CREATE EXTENSION` and you should not add it to your schema files. Instead, the plan database must already have the extensions your schema uses, in the same schema as on the target.

Extension-owned objects are also unmanaged. `dump` omits their definitions and
privileges, and `plan` leaves them alone. This includes privileges changed after
installation, such as a custom grant on `spatial_ref_sys` or a revoked `PUBLIC`
grant on an extension function. Manage those privileges separately; do not put
extension-member `GRANT`/`REVOKE` statements in the desired schema. In an external
plan database those statements can affect the preinstalled extension itself,
outside the temporary schema, without representing a managed change.

Application objects that use extension types or functions remain managed,
including their privileges. Membership is determined from PostgreSQL's dependency
catalog, not object names or the schema containing the extension. Unlike
`pg_dump`, pgschema does not export changes from an extension's initial privileges.

The default embedded plan database handles this for you: it installs every extension found on the target database before applying your schema. This covers all extensions bundled with PostgreSQL (`hstore`, `pg_trgm`, `citext`, `uuid-ossp`, `btree_gist`, ...). Third-party extensions such as `postgis` or `pgvector` are not bundled, so for those you need an external plan database.

An external plan database is not mirrored automatically: install every extension your schema uses yourself, bundled ones included, in the same schema as on the target:
Expand Down
5 changes: 5 additions & 0 deletions docs/syntax/grant_revoke.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ pgschema understands the following `GRANT`/`REVOKE` features:
- **REVOKE GRANT OPTION FOR**: Revoke only the grant option while keeping the privilege
- **PUBLIC**: Special grantee representing all roles

These features apply to application-owned objects. Extension-member privileges,
including custom grants and revoked defaults, are excluded from dump and plan
along with the extension-owned definitions. Manage them separately from desired
schema SQL; see [Using PostgreSQL Extensions](/cli/plan-db#using-postgresql-extensions).

## Examples

### Grant table privileges
Expand Down
8 changes: 6 additions & 2 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -937,9 +937,13 @@ func generateTableSQL(table *ir.Table, targetSchema string, qualifySchema bool,

// Detect per-child column overrides (DEFAULT, NOT NULL) by comparing against the parent.
parentKey := parentSchema + "." + table.PartitionOf
parentColumns := table.PartitionParentColumns
if parentTable, ok := allTables[parentKey]; ok {
parentCols := make(map[string]*ir.Column, len(parentTable.Columns))
for _, col := range parentTable.Columns {
parentColumns = parentTable.Columns
}
if len(parentColumns) > 0 {
parentCols := make(map[string]*ir.Column, len(parentColumns))
for _, col := range parentColumns {
parentCols[col.Name] = col
}
for _, col := range table.Columns {
Expand Down
40 changes: 40 additions & 0 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ func (i *Inspector) BuildIR(ctx context.Context, targetSchema string) (*IR, erro
return nil, err
}

if err := i.buildPartitionParentColumns(ctx, schema, targetSchema); err != nil {
return nil, fmt.Errorf("failed to build partition parent columns: %w", err)
}

// Load rows of data-managed tables now that columns, constraints, and
// partitions are known.
if err := i.buildRows(ctx, schema, targetSchema); err != nil {
Expand Down Expand Up @@ -760,6 +764,42 @@ func (i *Inspector) buildPartitionMapping(ctx context.Context, schema *IR, targe
return partitionMapping
}

// buildPartitionParentColumns keeps unmanaged parents available for comparing
// inherited column properties, without adding their definitions to the IR.
func (i *Inspector) buildPartitionParentColumns(ctx context.Context, schema *IR, targetSchema string) error {
children := make(map[string]*Table)
for name, table := range schema.Schemas[targetSchema].Tables {
if table.PartitionOf == "" {
continue
}
parentSchema := table.PartitionOfSchema
if parentSchema == "" {
parentSchema = targetSchema
}
if s := schema.Schemas[parentSchema]; s != nil && s.Tables[table.PartitionOf] != nil {
continue
}
children[name] = table
}
if len(children) == 0 {
return nil
}
columns, err := i.queries.GetPartitionParentColumnsForSchema(ctx, targetSchema)
if err != nil {
return err
}
for _, col := range columns {
if table := children[col.ChildTable]; table != nil {
column := &Column{Name: col.ColumnName, IsNullable: col.IsNullable.Bool}
if col.ColumnDefault.Valid {
column.DefaultValue = &col.ColumnDefault.String
}
table.PartitionParentColumns = append(table.PartitionParentColumns, column)
}
}
return nil
}

// sortPrimaryKeyColumnsForPartitionedTable sorts primary key constraint columns
// to ensure partition key columns come first
func (i *Inspector) sortPrimaryKeyColumnsForPartitionedTable(constraint *Constraint, partitionKey string) {
Expand Down
5 changes: 5 additions & 0 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ type Table struct {
// schema definition, so it is excluded from serialization (and thereby
// from fingerprints and plan JSON).
AllConstraintNames map[string]bool `json:"-"`
// PartitionParentColumns holds comparison metadata when a partition's
// parent is outside the managed IR, e.g. an extension member. It preserves
// child DEFAULT/NOT NULL overrides without managing or fingerprinting the
// parent itself.
PartitionParentColumns []*Column `json:"-"`
// DataManaged is true when the table matches [data] in pgschema.toml and
// its rows are part of the desired state. Rows holds those rows, in
// DataColumns() order. Both are excluded from serialization, and therefore
Expand Down
6 changes: 6 additions & 0 deletions ir/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ func normalizeTable(table *Table) {
for _, column := range table.Columns {
normalizeColumn(column, table.Schema)
}
// Compare parent expressions in the child's schema context, just like its
// own defaults. The parent can live in a different (unmanaged) schema.
for _, column := range table.PartitionParentColumns {
normalizeColumn(column, table.Schema)
}

// Normalize policies
for _, policy := range table.Policies {
Expand Down Expand Up @@ -1598,6 +1603,7 @@ func IsTextLikeType(typeName string) bool {
// to avoid a perpetual spurious diff (issue #473):
// - array-level cast: "col::text = ANY ((ARRAY['a'::varchar])::text[])" (wrapping paren + array cast)
// - element-level: "col::text = ANY (ARRAY[('a'::varchar)::text])" (cast on each element)
//
// Both collapse to "col::text IN ('a'::varchar)".
func convertAnyArrayToIn(expr string) string {
const anyMarker = " = ANY ("
Expand Down
Loading