diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 7870cba6..df45788a 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -42,6 +42,7 @@ const ( DiffTypePrivilege DiffTypeRevokedDefaultPrivilege DiffTypeColumnPrivilege + DiffTypeExtension ) // String returns the string representation of DiffType @@ -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" } @@ -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) } @@ -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 @@ -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) + } + } + // Compare schemas first in deterministic order schemaNames := sortedKeys(newIR.Schemas) for _, name := range schemaNames { @@ -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))) + } + // Note: Schema creation is out of scope for schema-level comparisons // Build function lookup early - needed for both domain and table dependency checks diff --git a/internal/dump/formatter.go b/internal/dump/formatter.go index 6d1524cf..618ad909 100644 --- a/internal/dump/formatter.go +++ b/internal/dump/formatter.go @@ -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 { @@ -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": diff --git a/internal/plan/plan.go b/internal/plan/plan.go index e5f81df2..f67cdd48 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -87,6 +87,7 @@ type TypeSummary struct { type Type string const ( + TypeExtension Type = "extensions" TypeSchema Type = "schemas" TypeType Type = "types" TypeFunction Type = "functions" @@ -119,6 +120,7 @@ const ( // getObjectOrder returns the dependency order for database objects func getObjectOrder() []Type { return []Type{ + TypeExtension, TypeSchema, TypeDefaultPrivilege, TypeType, diff --git a/ir/inspector.go b/ir/inspector.go index 23a0a5c4..fafb00c5 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -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) + } + if err := i.buildTables(ctx, schema, targetSchema); err != nil { return nil, fmt.Errorf("failed to build tables: %w", err) } @@ -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}) diff --git a/ir/inspector_extension_test.go b/ir/inspector_extension_test.go new file mode 100644 index 00000000..d5980ce6 --- /dev/null +++ b/ir/inspector_extension_test.go @@ -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) + } + }) +} diff --git a/ir/ir.go b/ir/ir.go index fb0ff718..b9515869 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -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 @@ -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 } + diff --git a/testdata/diff/create_table/add_column_cross_schema_custom_type/diff.sql b/testdata/diff/create_table/add_column_cross_schema_custom_type/diff.sql index f20f3c5c..f2e67926 100644 --- a/testdata/diff/create_table/add_column_cross_schema_custom_type/diff.sql +++ b/testdata/diff/create_table/add_column_cross_schema_custom_type/diff.sql @@ -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; diff --git a/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.json b/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.json index 215bca72..5a4f5c81 100644 --- a/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.json +++ b/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.json @@ -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", diff --git a/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.sql b/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.sql index 3c30525f..33d488e7 100644 --- a/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.sql +++ b/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.sql @@ -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; diff --git a/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.txt b/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.txt index dc808eb3..cf93b881 100644 --- a/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.txt +++ b/testdata/diff/create_table/add_column_cross_schema_custom_type/plan.txt @@ -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) @@ -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; diff --git a/testdata/diff/create_table/add_pk/diff.sql b/testdata/diff/create_table/add_pk/diff.sql index 21ddbe38..53ce033c 100644 --- a/testdata/diff/create_table/add_pk/diff.sql +++ b/testdata/diff/create_table/add_pk/diff.sql @@ -1,3 +1,4 @@ +CREATE EXTENSION IF NOT EXISTS btree_gist; ALTER TABLE categories ADD COLUMN code text CONSTRAINT categories_pkey PRIMARY KEY; diff --git a/testdata/diff/create_table/add_pk/plan.json b/testdata/diff/create_table/add_pk/plan.json index 14a20357..e27246de 100644 --- a/testdata/diff/create_table/add_pk/plan.json +++ b/testdata/diff/create_table/add_pk/plan.json @@ -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", diff --git a/testdata/diff/create_table/add_pk/plan.sql b/testdata/diff/create_table/add_pk/plan.sql index 21ddbe38..35dcb23c 100644 --- a/testdata/diff/create_table/add_pk/plan.sql +++ b/testdata/diff/create_table/add_pk/plan.sql @@ -1,3 +1,5 @@ +CREATE EXTENSION IF NOT EXISTS btree_gist; + ALTER TABLE categories ADD COLUMN code text CONSTRAINT categories_pkey PRIMARY KEY; diff --git a/testdata/diff/create_table/add_pk/plan.txt b/testdata/diff/create_table/add_pk/plan.txt index cf392421..1b4b1e1c 100644 --- a/testdata/diff/create_table/add_pk/plan.txt +++ b/testdata/diff/create_table/add_pk/plan.txt @@ -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) @@ -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; diff --git a/testdata/diff/dependency/extension_btree_gist/diff.sql b/testdata/diff/dependency/extension_btree_gist/diff.sql new file mode 100644 index 00000000..da280aee --- /dev/null +++ b/testdata/diff/dependency/extension_btree_gist/diff.sql @@ -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 &&) +); diff --git a/testdata/diff/dependency/extension_btree_gist/new.sql b/testdata/diff/dependency/extension_btree_gist/new.sql new file mode 100644 index 00000000..3af65758 --- /dev/null +++ b/testdata/diff/dependency/extension_btree_gist/new.sql @@ -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 &&) +); diff --git a/testdata/diff/dependency/extension_btree_gist/old.sql b/testdata/diff/dependency/extension_btree_gist/old.sql new file mode 100644 index 00000000..03d7e9ae --- /dev/null +++ b/testdata/diff/dependency/extension_btree_gist/old.sql @@ -0,0 +1 @@ +-- Empty schema diff --git a/testdata/diff/dependency/extension_btree_gist/plan.json b/testdata/diff/dependency/extension_btree_gist/plan.json new file mode 100644 index 00000000..9ba7591e --- /dev/null +++ b/testdata/diff/dependency/extension_btree_gist/plan.json @@ -0,0 +1,26 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.9.0", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "965b1131737c955e24c7f827c55bd78e4cb49a75adfd04229e0ba297376f5085" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE EXTENSION IF NOT EXISTS btree_gist;", + "type": "extension", + "operation": "create", + "path": "btree_gist" + }, + { + "sql": "CREATE TABLE IF NOT EXISTS reservations (\n id uuid,\n resource_id uuid NOT NULL,\n start_date date NOT NULL,\n end_date date NOT NULL,\n CONSTRAINT reservations_pkey PRIMARY KEY (id),\n CONSTRAINT valid_period CHECK (end_date >= start_date),\n CONSTRAINT no_overlap EXCLUDE USING gist (resource_id WITH =, daterange(start_date, end_date, '[]'::text) WITH &&)\n);", + "type": "table", + "operation": "create", + "path": "public.reservations" + } + ] + } + ] +} diff --git a/testdata/diff/dependency/extension_btree_gist/plan.sql b/testdata/diff/dependency/extension_btree_gist/plan.sql new file mode 100644 index 00000000..abcdc1c9 --- /dev/null +++ b/testdata/diff/dependency/extension_btree_gist/plan.sql @@ -0,0 +1,11 @@ +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 &&) +); diff --git a/testdata/diff/dependency/extension_btree_gist/plan.txt b/testdata/diff/dependency/extension_btree_gist/plan.txt new file mode 100644 index 00000000..817ee637 --- /dev/null +++ b/testdata/diff/dependency/extension_btree_gist/plan.txt @@ -0,0 +1,26 @@ +Plan: 2 to add. + +Summary by type: + extensions: 1 to add + tables: 1 to add + +Extensions: + + btree_gist + +Tables: + + reservations + +DDL to be executed: +-------------------------------------------------- + +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 &&) +); diff --git a/testdata/diff/dependency/extension_btree_gist/setup.sql b/testdata/diff/dependency/extension_btree_gist/setup.sql new file mode 100644 index 00000000..c30efd12 --- /dev/null +++ b/testdata/diff/dependency/extension_btree_gist/setup.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS btree_gist;