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
21 changes: 21 additions & 0 deletions internal/postgres/desired_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
64 changes: 64 additions & 0 deletions internal/postgres/desired_state_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package postgres

import (
"errors"
"fmt"
"reflect"
"strings"
Expand Down Expand Up @@ -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())
}
})
}
4 changes: 3 additions & 1 deletion internal/postgres/embedded.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion internal/postgres/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down