From c5b5041a82c98684b32036baaae1b05130a4a2e9 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Wed, 10 Jun 2026 02:03:28 -0700 Subject: [PATCH] feat: hint at extension dependency when plan fails with missing object errors (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the desired state depends on a PostgreSQL extension (e.g. btree_gist, citext, pgvector) that is absent from the plan database, applying the schema fails with an opaque error like: ERROR: data type uuid has no default operator class for access method "gist" pgschema intentionally does not manage extension lifecycle (database-level object), so detect the common SQLSTATEs for this failure mode — 42704 undefined_object and 42883 undefined_function — and append a hint pointing to the external plan database (https://www.pgschema.com/cli/plan-db): - embedded path: suggest --plan-host, since embedded postgres cannot provide extensions at all - external path: suggest installing the extension in the plan database Co-Authored-By: Claude Fable 5 --- internal/postgres/desired_state.go | 21 ++++++++ internal/postgres/desired_state_test.go | 64 +++++++++++++++++++++++++ internal/postgres/embedded.go | 4 +- internal/postgres/external.go | 4 +- 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/internal/postgres/desired_state.go b/internal/postgres/desired_state.go index dd2c008a..271d41d4 100644 --- a/internal/postgres/desired_state.go +++ b/internal/postgres/desired_state.go @@ -491,3 +491,24 @@ func enhanceApplyError(err error, sql string) error { return fmt.Errorf("%w\n\nError location (line %d, column %d):\n%s", err, line, col, snippet.String()) } + +// hintExtensionDependency appends hint to errors whose SQLSTATE indicates a +// missing type, function, operator, or operator class — the typical failure +// when the desired state depends on a PostgreSQL extension (e.g. btree_gist, +// citext, pgvector) that is not available in the plan database (issue #436). +// pgschema does not manage extension lifecycle, so the hint guides the user +// toward a plan database that has the extension installed. +func hintExtensionDependency(err error, hint string) error { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return err + } + switch pgErr.Code { + // 42704 undefined_object: "type X does not exist", "data type X has no + // default operator class for access method gist" + // 42883 undefined_function: "function X does not exist", "operator does not exist" + case "42704", "42883": + return fmt.Errorf("%w\nHint: %s", err, hint) + } + return err +} diff --git a/internal/postgres/desired_state_test.go b/internal/postgres/desired_state_test.go index 812977c3..ec1a7790 100644 --- a/internal/postgres/desired_state_test.go +++ b/internal/postgres/desired_state_test.go @@ -1,6 +1,7 @@ package postgres import ( + "errors" "fmt" "reflect" "strings" @@ -362,3 +363,66 @@ func TestEnhanceApplyError(t *testing.T) { } }) } + +func TestHintExtensionDependency(t *testing.T) { + const hint = "this schema may depend on a PostgreSQL extension" + + t.Run("undefined_object gets hint", func(t *testing.T) { + pgErr := &pgconn.PgError{ + Message: `data type uuid has no default operator class for access method "gist"`, + Code: "42704", + } + result := hintExtensionDependency(pgErr, hint) + if !strings.Contains(result.Error(), "Hint: "+hint) { + t.Errorf("expected hint to be appended, got: %s", result.Error()) + } + // Original error must remain unwrappable + var unwrapped *pgconn.PgError + if !errors.As(result, &unwrapped) { + t.Error("expected wrapped error to preserve PgError") + } + }) + + t.Run("undefined_function gets hint", func(t *testing.T) { + pgErr := &pgconn.PgError{ + Message: "function gen_random_uuid() does not exist", + Code: "42883", + } + result := hintExtensionDependency(pgErr, hint) + if !strings.Contains(result.Error(), "Hint: "+hint) { + t.Errorf("expected hint to be appended, got: %s", result.Error()) + } + }) + + t.Run("hint applies after enhanceApplyError wrapping", func(t *testing.T) { + sql := "CREATE TABLE foo (v citext);" + pgErr := &pgconn.PgError{ + Message: `type "citext" does not exist`, + Code: "42704", + Position: int32(strings.Index(sql, "citext") + 1), + } + result := hintExtensionDependency(enhanceApplyError(pgErr, sql), hint) + if !strings.Contains(result.Error(), "Hint: "+hint) { + t.Errorf("expected hint on enhanced error, got: %s", result.Error()) + } + }) + + t.Run("other SQLSTATE passes through", func(t *testing.T) { + pgErr := &pgconn.PgError{ + Message: "syntax error", + Code: "42601", + } + result := hintExtensionDependency(pgErr, hint) + if result != error(pgErr) { + t.Errorf("expected same error instance, got: %s", result.Error()) + } + }) + + t.Run("non-pg error passes through", func(t *testing.T) { + origErr := fmt.Errorf("some other error") + result := hintExtensionDependency(origErr, hint) + if result != origErr { + t.Errorf("expected same error instance, got: %s", result.Error()) + } + }) +} diff --git a/internal/postgres/embedded.go b/internal/postgres/embedded.go index 8220dc29..11e5e585 100644 --- a/internal/postgres/embedded.go +++ b/internal/postgres/embedded.go @@ -254,7 +254,9 @@ func (ep *EmbeddedPostgres) ApplySchema(ctx context.Context, schema string, sql // Note: Desired state SQL should never contain operations like CREATE INDEX CONCURRENTLY // that cannot run in transactions. Those are migration details, not state declarations. if _, err := util.ExecContextWithLogging(ctx, conn, schemaAgnosticSQL, "apply desired state SQL to temporary schema"); err != nil { - return fmt.Errorf("failed to apply schema SQL to temporary schema %s: %w", ep.tempSchema, enhanceApplyError(err, schemaAgnosticSQL)) + enhanced := enhanceApplyError(err, schemaAgnosticSQL) + enhanced = hintExtensionDependency(enhanced, "this schema may depend on a PostgreSQL extension, which the embedded plan database cannot provide. Use an external plan database with the extension installed (--plan-host), see https://www.pgschema.com/cli/plan-db") + return fmt.Errorf("failed to apply schema SQL to temporary schema %s: %w", ep.tempSchema, enhanced) } return nil diff --git a/internal/postgres/external.go b/internal/postgres/external.go index 62f34a18..4208711c 100644 --- a/internal/postgres/external.go +++ b/internal/postgres/external.go @@ -158,7 +158,9 @@ func (ed *ExternalDatabase) ApplySchema(ctx context.Context, schema string, sql // Note: Desired state SQL should never contain operations like CREATE INDEX CONCURRENTLY // that cannot run in transactions. Those are migration details, not state declarations. if _, err := util.ExecContextWithLogging(ctx, conn, schemaAgnosticSQL, "apply desired state SQL to temporary schema"); err != nil { - return fmt.Errorf("failed to apply schema SQL to temporary schema %s: %w", ed.tempSchema, enhanceApplyError(err, schemaAgnosticSQL)) + enhanced := enhanceApplyError(err, schemaAgnosticSQL) + enhanced = hintExtensionDependency(enhanced, "this schema may depend on a PostgreSQL extension that is not installed in the plan database. Install the extension in the plan database (CREATE EXTENSION) and re-run, see https://www.pgschema.com/cli/plan-db") + return fmt.Errorf("failed to apply schema SQL to temporary schema %s: %w", ed.tempSchema, enhanced) } return nil