-
Notifications
You must be signed in to change notification settings - Fork 49
feat: Add PostgreSQL Extension Support #437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
60ee1be
6ed0ea4
769e98f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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))) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The IR only carries |
||
| } | ||
|
|
||
| // Note: Schema creation is out of scope for schema-level comparisons | ||
|
|
||
| // Build function lookup early - needed for both domain and table dependency checks | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extension detection only happens after the desired SQL has already been applied to the temporary plan schema. If a desired file contains |
||
| } | ||
|
|
||
| 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}) | ||
|
|
||
| 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) | ||
| } | ||
| }) | ||
| } |
| 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 &&) | ||
| ); |
| 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 &&) | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| -- Empty schema |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This records extensions that disappeared from the desired IR, but the drop generation path never reads
droppedExtensionsor emitsDROP EXTENSION. When the lastcitextorhstoreusage 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.