From 4b5197878c103b213c6081e45d2a67c5a0fbf9fb Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:05:41 +0530 Subject: [PATCH 1/3] feat: improve error messages for undefined extension types Inspect pgx errors for SQLSTATE 42704 (undefined_object) and provide helpful hints when extension types are not found. The error message now: - Detects 'type does not exist' errors - Extracts the type name from the error message - Suggests using schema-qualified references (e.g., extensions.ltree) - Provides a concrete example in the error output This addresses the issue where migrations work locally but fail remotely with opaque 'type does not exist' errors, making it clear to users that they should use schema-qualified type references instead of relying on search_path settings. --- pkg/migration/file.go | 26 +++++++++++++++++++++++ pkg/migration/file_test.go | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/pkg/migration/file.go b/pkg/migration/file.go index fbd4a3b7f..0635d8646 100644 --- a/pkg/migration/file.go +++ b/pkg/migration/file.go @@ -96,6 +96,20 @@ func (m *MigrationFile) ExecBatch(ctx context.Context, conn *pgx.Conn) error { if len(pgErr.Detail) > 0 { msg = append(msg, pgErr.Detail) } + // Provide helpful hint for extension type errors (SQLSTATE 42704: undefined_object) + if pgErr.Code == "42704" && strings.Contains(pgErr.Message, "type") && strings.Contains(pgErr.Message, "does not exist") { + // Extract type name from error message (e.g., 'type "ltree" does not exist') + typeName := extractTypeName(pgErr.Message) + msg = append(msg, "") + msg = append(msg, "Hint: This type may be defined in a schema that's not in your search_path.") + msg = append(msg, " Use schema-qualified type references to avoid this error:") + if typeName != "" { + msg = append(msg, fmt.Sprintf(" CREATE TABLE example (col extensions.%s);", typeName)) + } else { + msg = append(msg, " CREATE TABLE example (col extensions.);") + } + msg = append(msg, " Learn more: supabase migration new --help") + } } msg = append(msg, fmt.Sprintf("At statement: %d", i), stat) return errors.Errorf("%w\n%s", err, strings.Join(msg, "\n")) @@ -120,6 +134,18 @@ func markError(stat string, pos int) string { return strings.Join(lines, "\n") } +// extractTypeName extracts the type name from PostgreSQL error messages like: +// 'type "ltree" does not exist' -> "ltree" +func extractTypeName(errMsg string) string { + // Match pattern: type "typename" does not exist + re := regexp.MustCompile(`type "([^"]+)" does not exist`) + matches := re.FindStringSubmatch(errMsg) + if len(matches) > 1 { + return matches[1] + } + return "" +} + func (m *MigrationFile) insertVersionSQL(conn *pgx.Conn, batch *pgconn.Batch) error { value := pgtype.TextArray{} if err := value.Set(m.Statements); err != nil { diff --git a/pkg/migration/file_test.go b/pkg/migration/file_test.go index 45bee71b6..cf68f2361 100644 --- a/pkg/migration/file_test.go +++ b/pkg/migration/file_test.go @@ -77,4 +77,47 @@ func TestMigrationFile(t *testing.T) { assert.ErrorContains(t, err, "ERROR: schema \"public\" already exists (SQLSTATE 42P06)") assert.ErrorContains(t, err, "At statement: 0\ncreate schema public") }) + + t.Run("provides helpful hint for extension type errors", func(t *testing.T) { + migration := MigrationFile{ + Statements: []string{"CREATE TABLE test (path ltree NOT NULL)"}, + Version: "0", + } + // Setup mock postgres + conn := pgtest.NewConn() + defer conn.Close(t) + conn.Query(migration.Statements[0]). + ReplyError("42704", `type "ltree" does not exist`). + Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). + Reply("INSERT 0 1") + // Run test + err := migration.ExecBatch(context.Background(), conn.MockClient(t)) + // Check error + assert.ErrorContains(t, err, `type "ltree" does not exist`) + assert.ErrorContains(t, err, "Hint: This type may be defined in a schema") + assert.ErrorContains(t, err, "extensions.ltree") + assert.ErrorContains(t, err, "supabase migration new --help") + assert.ErrorContains(t, err, "At statement: 0") + }) + + t.Run("provides generic hint when type name cannot be extracted", func(t *testing.T) { + migration := MigrationFile{ + Statements: []string{"CREATE TABLE test (id custom_type)"}, + Version: "0", + } + // Setup mock postgres + conn := pgtest.NewConn() + defer conn.Close(t) + conn.Query(migration.Statements[0]). + ReplyError("42704", `type does not exist`). + Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). + Reply("INSERT 0 1") + // Run test + err := migration.ExecBatch(context.Background(), conn.MockClient(t)) + // Check error + assert.ErrorContains(t, err, "type does not exist") + assert.ErrorContains(t, err, "Hint: This type may be defined in a schema") + assert.ErrorContains(t, err, "extensions.") + assert.ErrorContains(t, err, "supabase migration new --help") + }) } From 1229547e4cecd6e81a02bebcd6a733fdbce5d7fb Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 4 Dec 2025 02:26:06 +0530 Subject: [PATCH 2/3] feat: add IsSchemaQualified method to skip hint for schema-qualified types --- pkg/migration/file.go | 24 ++++++++++++------------ pkg/migration/file_test.go | 21 +++++++++++++-------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/pkg/migration/file.go b/pkg/migration/file.go index 0635d8646..540c129e3 100644 --- a/pkg/migration/file.go +++ b/pkg/migration/file.go @@ -26,7 +26,10 @@ type MigrationFile struct { Statements []string } -var migrateFilePattern = regexp.MustCompile(`^([0-9]+)_(.*)\.sql$`) +var ( + migrateFilePattern = regexp.MustCompile(`^([0-9]+)_(.*)\.sql$`) + typeNamePattern = regexp.MustCompile(`type "([^"]+)" does not exist`) +) func NewMigrationFromFile(path string, fsys fs.FS) (*MigrationFile, error) { lines, err := parseFile(path, fsys) @@ -97,17 +100,11 @@ func (m *MigrationFile) ExecBatch(ctx context.Context, conn *pgx.Conn) error { msg = append(msg, pgErr.Detail) } // Provide helpful hint for extension type errors (SQLSTATE 42704: undefined_object) - if pgErr.Code == "42704" && strings.Contains(pgErr.Message, "type") && strings.Contains(pgErr.Message, "does not exist") { - // Extract type name from error message (e.g., 'type "ltree" does not exist') - typeName := extractTypeName(pgErr.Message) + if typeName := extractTypeName(pgErr.Message); len(typeName) > 0 && pgErr.Code == "42704" && !IsSchemaQualified(typeName) { msg = append(msg, "") msg = append(msg, "Hint: This type may be defined in a schema that's not in your search_path.") msg = append(msg, " Use schema-qualified type references to avoid this error:") - if typeName != "" { - msg = append(msg, fmt.Sprintf(" CREATE TABLE example (col extensions.%s);", typeName)) - } else { - msg = append(msg, " CREATE TABLE example (col extensions.);") - } + msg = append(msg, fmt.Sprintf(" CREATE TABLE example (col extensions.%s);", typeName)) msg = append(msg, " Learn more: supabase migration new --help") } } @@ -137,15 +134,18 @@ func markError(stat string, pos int) string { // extractTypeName extracts the type name from PostgreSQL error messages like: // 'type "ltree" does not exist' -> "ltree" func extractTypeName(errMsg string) string { - // Match pattern: type "typename" does not exist - re := regexp.MustCompile(`type "([^"]+)" does not exist`) - matches := re.FindStringSubmatch(errMsg) + matches := typeNamePattern.FindStringSubmatch(errMsg) if len(matches) > 1 { return matches[1] } return "" } +// IsSchemaQualified checks if a type name already contains a schema qualifier (e.g., "extensions.ltree") +func IsSchemaQualified(typeName string) bool { + return strings.Contains(typeName, ".") +} + func (m *MigrationFile) insertVersionSQL(conn *pgx.Conn, batch *pgconn.Batch) error { value := pgtype.TextArray{} if err := value.Set(m.Statements); err != nil { diff --git a/pkg/migration/file_test.go b/pkg/migration/file_test.go index cf68f2361..bd090353f 100644 --- a/pkg/migration/file_test.go +++ b/pkg/migration/file_test.go @@ -100,24 +100,29 @@ func TestMigrationFile(t *testing.T) { assert.ErrorContains(t, err, "At statement: 0") }) - t.Run("provides generic hint when type name cannot be extracted", func(t *testing.T) { + t.Run("skips hint for schema-qualified type errors", func(t *testing.T) { migration := MigrationFile{ - Statements: []string{"CREATE TABLE test (id custom_type)"}, + Statements: []string{"CREATE TABLE test (path extensions.ltree NOT NULL)"}, Version: "0", } // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) conn.Query(migration.Statements[0]). - ReplyError("42704", `type does not exist`). + ReplyError("42704", `type "extensions.ltree" does not exist`). Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). Reply("INSERT 0 1") // Run test err := migration.ExecBatch(context.Background(), conn.MockClient(t)) - // Check error - assert.ErrorContains(t, err, "type does not exist") - assert.ErrorContains(t, err, "Hint: This type may be defined in a schema") - assert.ErrorContains(t, err, "extensions.") - assert.ErrorContains(t, err, "supabase migration new --help") + // Check error - should NOT contain hint since type is already schema-qualified + assert.ErrorContains(t, err, `type "extensions.ltree" does not exist`) + assert.NotContains(t, err.Error(), "Hint: This type may be defined in a schema") }) } + +func TestIsSchemaQualified(t *testing.T) { + assert.True(t, IsSchemaQualified("extensions.ltree")) + assert.True(t, IsSchemaQualified("public.my_type")) + assert.False(t, IsSchemaQualified("ltree")) + assert.False(t, IsSchemaQualified("")) +} From c01e0da7dd743f25c73d2591fc9d6333ddd9fd62 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:27:47 +0530 Subject: [PATCH 3/3] test: add unit tests for extractTypeName function --- pkg/migration/file_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pkg/migration/file_test.go b/pkg/migration/file_test.go index bd090353f..703f26954 100644 --- a/pkg/migration/file_test.go +++ b/pkg/migration/file_test.go @@ -120,6 +120,38 @@ func TestMigrationFile(t *testing.T) { }) } +func TestExtractTypeName(t *testing.T) { + t.Run("extracts type name from standard error message", func(t *testing.T) { + result := extractTypeName(`type "ltree" does not exist`) + assert.Equal(t, "ltree", result) + }) + + t.Run("extracts schema-qualified type name", func(t *testing.T) { + result := extractTypeName(`type "extensions.ltree" does not exist`) + assert.Equal(t, "extensions.ltree", result) + }) + + t.Run("extracts type with underscores", func(t *testing.T) { + result := extractTypeName(`type "my_custom_type" does not exist`) + assert.Equal(t, "my_custom_type", result) + }) + + t.Run("returns empty string for non-matching message", func(t *testing.T) { + result := extractTypeName(`column "name" does not exist`) + assert.Equal(t, "", result) + }) + + t.Run("returns empty string for empty message", func(t *testing.T) { + result := extractTypeName("") + assert.Equal(t, "", result) + }) + + t.Run("handles type names with numbers", func(t *testing.T) { + result := extractTypeName(`type "type123" does not exist`) + assert.Equal(t, "type123", result) + }) +} + func TestIsSchemaQualified(t *testing.T) { assert.True(t, IsSchemaQualified("extensions.ltree")) assert.True(t, IsSchemaQualified("public.my_type"))