Skip to content
Open
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
39 changes: 39 additions & 0 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const (
DiffTypePrivilege
DiffTypeRevokedDefaultPrivilege
DiffTypeColumnPrivilege
DiffTypeExtension
)

// String returns the string representation of DiffType
Expand Down Expand Up @@ -103,6 +104,8 @@ func (d DiffType) String() string {
return "revoked_default_privilege"
case DiffTypeColumnPrivilege:
return "column_privilege"
case DiffTypeExtension:
return "extension"
default:
return "unknown"
}
Expand Down Expand Up @@ -177,6 +180,8 @@ func (d *DiffType) UnmarshalJSON(data []byte) error {
*d = DiffTypeRevokedDefaultPrivilege
case "column_privilege":
*d = DiffTypeColumnPrivilege
case "extension":
*d = DiffTypeExtension
default:
return fmt.Errorf("unknown diff type: %s", s)
}
Expand Down Expand Up @@ -296,6 +301,9 @@ type ddlDiff struct {
addedColumnPrivileges []*ir.ColumnPrivilege
droppedColumnPrivileges []*ir.ColumnPrivilege
modifiedColumnPrivileges []*columnPrivilegeDiff
// Extensions
addedExtensions []string
droppedExtensions []string
}

// schemaDiff represents changes to a schema
Expand Down Expand Up @@ -462,6 +470,26 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff {
modifiedColumnPrivileges: []*columnPrivilegeDiff{},
}

// Compare extensions
oldExtSet := make(map[string]bool)
for _, ext := range oldIR.Extensions {
oldExtSet[ext] = true
}
for _, ext := range newIR.Extensions {
if !oldExtSet[ext] {
diff.addedExtensions = append(diff.addedExtensions, ext)
}
}
newExtSet := make(map[string]bool)
for _, ext := range newIR.Extensions {
newExtSet[ext] = true
}
for _, ext := range oldIR.Extensions {
if !newExtSet[ext] {
diff.droppedExtensions = append(diff.droppedExtensions, ext)
Comment on lines +487 to +489
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dropped extensions ignored

This records extensions that disappeared from the desired IR, but the drop generation path never reads droppedExtensions or emits DROP EXTENSION. When the last citext or hstore usage is removed, the migration can remove the dependent schema objects while leaving the extension installed, so the database does not converge to the desired state.

}
}

// Compare schemas first in deterministic order
schemaNames := sortedKeys(newIR.Schemas)
for _, name := range schemaNames {
Expand Down Expand Up @@ -1497,6 +1525,17 @@ func (d *ddlDiff) generatePreDropMaterializedViewsSQL(targetSchema string, colle

// generateCreateSQL generates CREATE statements in dependency order
func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollector) {
for _, ext := range d.addedExtensions {
context := &diffContext{
Type: DiffTypeExtension,
Operation: DiffOperationCreate,
Path: ext,
Source: &ir.Extension{Name: ext},
CanRunInTransaction: true,
}
collector.collect(context, fmt.Sprintf("CREATE EXTENSION IF NOT EXISTS %s;", ir.QuoteIdentifier(ext)))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Extension schema lost

The IR only carries extname, so this DDL always creates the extension in the default extension schema instead of the schema where the inspected extension objects live. The updated add_column_cross_schema_custom_type fixture installs hstore with CREATE EXTENSION ... SCHEMA utils, but the generated migration emits CREATE EXTENSION IF NOT EXISTS hstore; and then adds a column of type utils.hstore. On a target database without hstore, this creates public.hstore and the later utils.hstore reference still fails.

}

// Note: Schema creation is out of scope for schema-level comparisons

// Build function lookup early - needed for both domain and table dependency checks
Expand Down
4 changes: 3 additions & 1 deletion internal/dump/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func (f *DumpFormatter) FormatMultiFile(diffs []diff.Diff, outputPath string) er
}

// Create files in dependency order
orderedDirs := []string{"types", "domains", "sequences", "functions", "procedures", "tables", "views", "materialized_views", "default_privileges", "privileges"}
orderedDirs := []string{"extensions", "types", "domains", "sequences", "functions", "procedures", "tables", "views", "materialized_views", "default_privileges", "privileges"}

for _, dir := range orderedDirs {
if objects, exists := filesByType[dir]; exists {
Expand Down Expand Up @@ -235,6 +235,8 @@ func (f *DumpFormatter) writeObjectFile(filePath string, diffs []diff.Diff) erro
// getObjectDirectory returns the directory name for an object type
func (f *DumpFormatter) getObjectDirectory(objectType string) string {
switch objectType {
case "extension":
return "extensions"
case "type":
return "types"
case "domain":
Expand Down
2 changes: 2 additions & 0 deletions internal/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type TypeSummary struct {
type Type string

const (
TypeExtension Type = "extensions"
TypeSchema Type = "schemas"
TypeType Type = "types"
TypeFunction Type = "functions"
Expand Down Expand Up @@ -119,6 +120,7 @@ const (
// getObjectOrder returns the dependency order for database objects
func getObjectOrder() []Type {
return []Type{
TypeExtension,
TypeSchema,
TypeDefaultPrivilege,
TypeType,
Expand Down
62 changes: 62 additions & 0 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ func (i *Inspector) BuildIR(ctx context.Context, targetSchema string) (*IR, erro
return nil, fmt.Errorf("failed to build schemas: %w", err)
}

if err := i.buildExtensions(ctx, schema, targetSchema); err != nil {
return nil, fmt.Errorf("failed to build extensions: %w", err)
Comment on lines +68 to +69
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Detection runs too late

Extension detection only happens after the desired SQL has already been applied to the temporary plan schema. If a desired file contains CREATE TABLE users (email citext); or an EXCLUDE constraint that needs btree_gist, ApplySchema fails with the missing type/operator before BuildIR can inspect dependencies and add the CREATE EXTENSION step. This means the new auto-detection path only works when the planning database already has the needed extension installed or the desired SQL creates it manually.

}

if err := i.buildTables(ctx, schema, targetSchema); err != nil {
return nil, fmt.Errorf("failed to build tables: %w", err)
}
Expand Down Expand Up @@ -207,6 +211,64 @@ func (i *Inspector) buildMetadata(ctx context.Context, schema *IR) error {
return nil
}

func (i *Inspector) buildExtensions(ctx context.Context, schema *IR, targetSchema string) error {
query := `
SELECT DISTINCT e.extname
FROM pg_catalog.pg_depend d
JOIN pg_catalog.pg_depend ed ON ed.objid = d.refobjid
AND ed.deptype = 'e'
AND ed.refclassid = 'pg_catalog.pg_extension'::regclass
JOIN pg_catalog.pg_extension e ON e.oid = ed.refobjid
WHERE d.deptype = 'n'
AND e.extname NOT IN ('plpgsql')
AND NOT EXISTS (
SELECT 1 FROM pg_catalog.pg_depend x
WHERE x.objid = d.objid AND x.deptype = 'e'
)
AND (
(d.classid = 'pg_catalog.pg_constraint'::regclass AND EXISTS (
SELECT 1 FROM pg_catalog.pg_constraint c
JOIN pg_catalog.pg_namespace n ON c.connamespace = n.oid
WHERE c.oid = d.objid AND n.nspname = $1
))
OR
(d.classid = 'pg_catalog.pg_class'::regclass AND EXISTS (
SELECT 1 FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid
WHERE c.oid = d.objid AND n.nspname = $1
))
OR
(d.classid = 'pg_catalog.pg_type'::regclass AND EXISTS (
SELECT 1 FROM pg_catalog.pg_type t
JOIN pg_catalog.pg_namespace n ON t.typnamespace = n.oid
WHERE t.oid = d.objid AND n.nspname = $1
))
OR
(d.classid = 'pg_catalog.pg_proc'::regclass AND EXISTS (
SELECT 1 FROM pg_catalog.pg_proc p
JOIN pg_catalog.pg_namespace n ON p.pronamespace = n.oid
WHERE p.oid = d.objid AND n.nspname = $1
))
)
ORDER BY e.extname`

rows, err := i.db.QueryContext(ctx, query, targetSchema)
if err != nil {
return fmt.Errorf("failed to query extensions: %w", err)
}
defer rows.Close()

for rows.Next() {
var extname string
if err := rows.Scan(&extname); err != nil {
return fmt.Errorf("failed to scan extension: %w", err)
}
schema.Extensions = append(schema.Extensions, extname)
}

return rows.Err()
}

func (i *Inspector) buildSchemas(ctx context.Context, schema *IR, targetSchema string) error {
// Use the schema-specific query to prefilter at the database level
schemaName, err := i.queries.GetSchema(ctx, sql.NullString{String: targetSchema, Valid: true})
Expand Down
39 changes: 39 additions & 0 deletions ir/inspector_extension_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package ir_test

import (
"testing"

"github.com/pgplex/pgschema/testutil"

_ "github.com/jackc/pgx/v5/stdlib"
)

func TestExtensionDetection(t *testing.T) {
embeddedPG := testutil.SetupPostgres(t)
defer embeddedPG.Stop()

setupSQL := "CREATE EXTENSION IF NOT EXISTS btree_gist;"

t.Run("empty schema does not detect extensions", func(t *testing.T) {
ir := testutil.ParseSQLToIRWithSetup(t, embeddedPG, "-- empty", "public", setupSQL)
if len(ir.Extensions) != 0 {
t.Errorf("Expected no extensions for empty schema, got %v", ir.Extensions)
}
})

t.Run("schema with EXCLUDE constraint detects btree_gist", func(t *testing.T) {
sql := `
CREATE TABLE reservations (
id uuid,
resource_id uuid NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
CONSTRAINT reservations_pkey PRIMARY KEY (id),
CONSTRAINT no_overlap EXCLUDE USING gist (resource_id WITH =, daterange(start_date, end_date, '[]'::text) WITH &&)
);`
ir := testutil.ParseSQLToIRWithSetup(t, embeddedPG, sql, "public", setupSQL)
if len(ir.Extensions) != 1 || ir.Extensions[0] != "btree_gist" {
t.Errorf("Expected [btree_gist], got %v", ir.Extensions)
}
})
}
14 changes: 11 additions & 3 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import (

// IR represents the complete database schema intermediate representation
type IR struct {
Metadata Metadata `json:"metadata"`
Schemas map[string]*Schema `json:"schemas"` // schema_name -> Schema
mu sync.RWMutex // Protects concurrent access to Schemas
Metadata Metadata `json:"metadata"`
Schemas map[string]*Schema `json:"schemas"` // schema_name -> Schema
Extensions []string `json:"extensions,omitempty"` // Required extensions (e.g., btree_gist)
mu sync.RWMutex // Protects concurrent access to Schemas
}

// Metadata contains information about the schema dump
Expand Down Expand Up @@ -710,3 +711,10 @@ func (v *View) GetObjectName() string { return v.Name }
func (s *Sequence) GetObjectName() string { return s.Name }
func (t *Type) GetObjectName() string { return t.Name }

// Extension represents a required PostgreSQL extension
type Extension struct {
Name string `json:"name"`
}

func (e *Extension) GetObjectName() string { return e.Name }

Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS hstore;
ALTER TABLE users ADD COLUMN fqdn citext NOT NULL;
ALTER TABLE users ADD COLUMN metadata utils.hstore;
ALTER TABLE users ADD COLUMN description utils.custom_text;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@
"groups": [
{
"steps": [
{
"sql": "CREATE EXTENSION IF NOT EXISTS citext;",
"type": "extension",
"operation": "create",
"path": "citext"
},
{
"sql": "CREATE EXTENSION IF NOT EXISTS hstore;",
"type": "extension",
"operation": "create",
"path": "hstore"
},
{
"sql": "ALTER TABLE users ADD COLUMN fqdn citext NOT NULL;",
"type": "table.column",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
CREATE EXTENSION IF NOT EXISTS citext;

CREATE EXTENSION IF NOT EXISTS hstore;

ALTER TABLE users ADD COLUMN fqdn citext NOT NULL;

ALTER TABLE users ADD COLUMN metadata utils.hstore;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
Plan: 1 to modify.
Plan: 2 to add, 1 to modify.

Summary by type:
extensions: 2 to add
tables: 1 to modify

Extensions:
+ citext
+ hstore

Tables:
~ users
+ description (column)
Expand All @@ -13,6 +18,10 @@ Tables:
DDL to be executed:
--------------------------------------------------

CREATE EXTENSION IF NOT EXISTS citext;

CREATE EXTENSION IF NOT EXISTS hstore;

ALTER TABLE users ADD COLUMN fqdn citext NOT NULL;

ALTER TABLE users ADD COLUMN metadata utils.hstore;
Expand Down
1 change: 1 addition & 0 deletions testdata/diff/create_table/add_pk/diff.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE categories
ADD COLUMN code text CONSTRAINT categories_pkey PRIMARY KEY;

Expand Down
6 changes: 6 additions & 0 deletions testdata/diff/create_table/add_pk/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
"groups": [
{
"steps": [
{
"sql": "CREATE EXTENSION IF NOT EXISTS btree_gist;",
"type": "extension",
"operation": "create",
"path": "btree_gist"
},
{
"sql": "ALTER TABLE categories\nADD COLUMN code text CONSTRAINT categories_pkey PRIMARY KEY;",
"type": "table.column",
Expand Down
2 changes: 2 additions & 0 deletions testdata/diff/create_table/add_pk/plan.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE categories
ADD COLUMN code text CONSTRAINT categories_pkey PRIMARY KEY;

Expand Down
8 changes: 7 additions & 1 deletion testdata/diff/create_table/add_pk/plan.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
Plan: 7 to modify.
Plan: 1 to add, 7 to modify.

Summary by type:
extensions: 1 to add
tables: 7 to modify

Extensions:
+ btree_gist

Tables:
~ categories
+ code (column)
Expand All @@ -22,6 +26,8 @@ Tables:
DDL to be executed:
--------------------------------------------------

CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE categories
ADD COLUMN code text CONSTRAINT categories_pkey PRIMARY KEY;

Expand Down
10 changes: 10 additions & 0 deletions testdata/diff/dependency/extension_btree_gist/diff.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE IF NOT EXISTS reservations (
id uuid,
resource_id uuid NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
CONSTRAINT reservations_pkey PRIMARY KEY (id),
CONSTRAINT valid_period CHECK (end_date >= start_date),
CONSTRAINT no_overlap EXCLUDE USING gist (resource_id WITH =, daterange(start_date, end_date, '[]'::text) WITH &&)
);
9 changes: 9 additions & 0 deletions testdata/diff/dependency/extension_btree_gist/new.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE public.reservations (
id uuid,
resource_id uuid NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
CONSTRAINT reservations_pkey PRIMARY KEY (id),
CONSTRAINT valid_period CHECK (end_date >= start_date),
CONSTRAINT no_overlap EXCLUDE USING gist (resource_id WITH =, daterange(start_date, end_date, '[]'::text) WITH &&)
);
1 change: 1 addition & 0 deletions testdata/diff/dependency/extension_btree_gist/old.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Empty schema
Loading