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
16 changes: 11 additions & 5 deletions internal/diff/column.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch
var statements []string
qualifiedTableName := getTableNameWithSchema(tableSchema, tableName, targetSchema)

// Handle data type changes
if cd.Old.DataType != cd.New.DataType {
// Handle data type changes - normalize types by stripping target schema prefix
oldType := stripSchemaPrefix(cd.Old.DataType, targetSchema)
newType := stripSchemaPrefix(cd.New.DataType, targetSchema)
if oldType != newType {
sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s TYPE %s;",
qualifiedTableName, cd.New.Name, cd.New.DataType)
qualifiedTableName, cd.New.Name, newType)
statements = append(statements, sql)
}

Expand Down Expand Up @@ -57,11 +59,15 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch
}

// columnsEqual compares two columns for equality
func columnsEqual(old, new *ir.Column) bool {
// targetSchema is used to normalize type names before comparison
func columnsEqual(old, new *ir.Column, targetSchema string) bool {
if old.Name != new.Name {
return false
}
if old.DataType != new.DataType {
// Normalize types by stripping target schema prefix before comparison
oldType := stripSchemaPrefix(old.DataType, targetSchema)
newType := stripSchemaPrefix(new.DataType, targetSchema)
if oldType != newType {
return false
}
if old.IsNullable != new.IsNullable {
Expand Down
2 changes: 1 addition & 1 deletion internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff {
diff.modifiedTables = append(diff.modifiedTables, tableDiff)
}
} else {
if tableDiff := diffTables(oldTable, newTable); tableDiff != nil {
if tableDiff := diffTables(oldTable, newTable, targetSchema); tableDiff != nil {
diff.modifiedTables = append(diff.modifiedTables, tableDiff)
}
}
Expand Down
19 changes: 8 additions & 11 deletions internal/diff/diff_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package diff

import (
"context"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -153,19 +152,16 @@ func runFileBasedDiffTest(t *testing.T, oldFile, newFile, diffFile, testName str
testutil.ShouldSkipTest(t, testName, majorVersion)

// Read optional setup.sql (for cross-schema setup, extension types, etc.)
// This will be passed to ParseSQLToIRWithSetup to run AFTER schema recreation
// so that extensions in public schema aren't dropped (issue #197)
var setupSQL string
setupFile := filepath.Join(filepath.Dir(oldFile), "setup.sql")
if _, err := os.Stat(setupFile); err == nil {
setupContent, err := os.ReadFile(setupFile)
if err != nil {
t.Fatalf("Failed to read setup.sql: %v", err)
}

// Execute setup.sql once before parsing old/new SQL
// This creates shared infrastructure (e.g., custom types in utils schema)
// that will be available to both old.sql and new.sql
if _, err := conn.ExecContext(context.Background(), string(setupContent)); err != nil {
t.Fatalf("Failed to execute setup.sql: %v", err)
}
setupSQL = string(setupContent)
}

// Read old DDL
Expand All @@ -186,9 +182,10 @@ func runFileBasedDiffTest(t *testing.T, oldFile, newFile, diffFile, testName str
t.Fatalf("Failed to read plan.sql: %v", err)
}

// Parse DDL to IR (setup.sql already applied, so just parse old/new SQL directly)
oldIR := testutil.ParseSQLToIR(t, sharedTestPostgres, string(oldDDL), "public")
newIR := testutil.ParseSQLToIR(t, sharedTestPostgres, string(newDDL), "public")
// Parse DDL to IR with optional setup SQL
// setup.sql runs after schema recreation so extensions in public schema aren't dropped
oldIR := testutil.ParseSQLToIRWithSetup(t, sharedTestPostgres, string(oldDDL), "public", setupSQL)
newIR := testutil.ParseSQLToIRWithSetup(t, sharedTestPostgres, string(newDDL), "public", setupSQL)

// Run diff
diffs := GenerateMigration(oldIR, newIR, "public")
Expand Down
8 changes: 5 additions & 3 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ func diffTriggers(oldTable, newTable *ir.Table, diff *tableDiff) {
}

// diffTables compares two tables and returns the differences
func diffTables(oldTable, newTable *ir.Table) *tableDiff {
// targetSchema is used to normalize type names before comparison
func diffTables(oldTable, newTable *ir.Table, targetSchema string) *tableDiff {
diff := &tableDiff{
Table: newTable,
AddedColumns: []*ir.Column{},
Expand Down Expand Up @@ -128,7 +129,7 @@ func diffTables(oldTable, newTable *ir.Table) *tableDiff {
// Find modified columns
for name, newColumn := range newColumns {
if oldColumn, exists := oldColumns[name]; exists {
if !columnsEqual(oldColumn, newColumn) {
if !columnsEqual(oldColumn, newColumn, targetSchema) {
diff.ModifiedColumns = append(diff.ModifiedColumns, &ColumnDiff{
Old: oldColumn,
New: newColumn,
Expand Down Expand Up @@ -649,8 +650,9 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
}
}

// Build column type
// Build column type and strip schema prefix if it matches target schema
columnType := formatColumnDataType(column)
columnType = stripSchemaPrefix(columnType, targetSchema)
tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema)

// Build and append all column clauses
Expand Down
5 changes: 3 additions & 2 deletions internal/postgres/embedded.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,9 @@ func (ep *EmbeddedPostgres) ApplySchema(ctx context.Context, schema string, sql
return fmt.Errorf("failed to create temporary schema %s: %w", ep.tempSchema, err)
}

// Set search_path to the temporary schema
setSearchPathSQL := fmt.Sprintf("SET search_path TO \"%s\"", ep.tempSchema)
// Set search_path to the temporary schema, with public as fallback
// for resolving extension types installed in public schema (issue #197)
setSearchPathSQL := fmt.Sprintf("SET search_path TO \"%s\", public", ep.tempSchema)
if _, err := util.ExecContextWithLogging(ctx, ep.db, setSearchPathSQL, "set search_path for desired state"); err != nil {
return fmt.Errorf("failed to set search_path: %w", err)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/postgres/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ func (ed *ExternalDatabase) ApplySchema(ctx context.Context, schema string, sql
return fmt.Errorf("failed to create temporary schema %s: %w", ed.tempSchema, err)
}

// Set search_path to the temporary schema
setSearchPathSQL := fmt.Sprintf("SET search_path TO \"%s\"", ed.tempSchema)
// Set search_path to the temporary schema, with public as fallback
// for resolving extension types installed in public schema (issue #197)
setSearchPathSQL := fmt.Sprintf("SET search_path TO \"%s\", public", ed.tempSchema)
if _, err := util.ExecContextWithLogging(ctx, ed.db, setSearchPathSQL, "set search_path for desired state"); err != nil {
return fmt.Errorf("failed to set search_path: %w", err)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
-- Create utils schema with a custom type for cross-schema testing
CREATE SCHEMA IF NOT EXISTS utils;
-- Drop and recreate for idempotency (setup runs for both old.sql and new.sql)
DROP SCHEMA IF EXISTS utils CASCADE;
CREATE SCHEMA utils;

CREATE TYPE utils.priority_level AS ENUM ('low', 'medium', 'high', 'critical');
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ALTER TABLE users ADD COLUMN fqdn citext NOT NULL;
ALTER TABLE users ADD COLUMN metadata utils.hstore;
ALTER TABLE users ADD COLUMN fqdn utils.citext NOT NULL;
ALTER TABLE users ADD COLUMN description utils.custom_text;
ALTER TABLE users ADD COLUMN status utils.custom_enum DEFAULT 'active';
ALTER TABLE users ADD COLUMN status utils.custom_enum DEFAULT 'active';
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
-- New state: Add columns using extension types, custom domain, and enum
-- Types are created via setup.sql
-- This tests GitHub #144 fix and PR #145 functionality:
-- - Extension types in external schemas (hstore) should be qualified
-- - Custom domains and enums in external schemas should be qualified
--
-- This tests GitHub #197 and #144/#145:
--
-- PUBLIC schema type (citext):
-- Written unqualified because both table and type are in public schema.
-- This is natural usage - pgschema must handle this by including public
-- in search_path when applying to temp schema.
--
-- CUSTOM schema types (hstore, custom_text, custom_enum):
-- Written with schema qualifier because they're in utils schema.

CREATE TABLE public.users (
id bigint PRIMARY KEY,
username text NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
-- Extension type from utils schema: must be qualified
-- Extension type from public schema: unqualified (natural usage)
-- Reproduces #197 - pgschema must include public in search_path
fqdn citext NOT NULL,
-- Extension type from utils schema: must be schema-qualified
metadata utils.hstore,
fqdn utils.citext NOT NULL,
-- Custom domain from utils schema: must be qualified
-- Custom domain from utils schema: must be schema-qualified
description utils.custom_text,
-- Enum type from utils schema: must be qualified
-- Enum type from utils schema: must be schema-qualified
status utils.custom_enum DEFAULT 'active'
);
);
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@
{
"steps": [
{
"sql": "ALTER TABLE users ADD COLUMN metadata utils.hstore;",
"sql": "ALTER TABLE users ADD COLUMN fqdn citext NOT NULL;",
"type": "table.column",
"operation": "create",
"path": "public.users.metadata"
"path": "public.users.fqdn"
},
{
"sql": "ALTER TABLE users ADD COLUMN fqdn utils.citext NOT NULL;",
"sql": "ALTER TABLE users ADD COLUMN metadata utils.hstore;",
"type": "table.column",
"operation": "create",
"path": "public.users.fqdn"
"path": "public.users.metadata"
},
{
"sql": "ALTER TABLE users ADD COLUMN description utils.custom_text;",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
ALTER TABLE users ADD COLUMN metadata utils.hstore;
ALTER TABLE users ADD COLUMN fqdn citext NOT NULL;

ALTER TABLE users ADD COLUMN fqdn utils.citext NOT NULL;
ALTER TABLE users ADD COLUMN metadata utils.hstore;

ALTER TABLE users ADD COLUMN description utils.custom_text;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ Tables:
DDL to be executed:
--------------------------------------------------

ALTER TABLE users ADD COLUMN metadata utils.hstore;
ALTER TABLE users ADD COLUMN fqdn citext NOT NULL;

ALTER TABLE users ADD COLUMN fqdn utils.citext NOT NULL;
ALTER TABLE users ADD COLUMN metadata utils.hstore;

ALTER TABLE users ADD COLUMN description utils.custom_text;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
-- Setup: Create extension type, custom domain, and enum to test type qualification
-- This reproduces GitHub #144 and validates PR #145 fixes
-- All types from external schemas (not the target schema) should be schema-qualified
-- This includes:
-- - Extension types (hstore)
-- - Custom domains and enums
-- Setup: Test type qualification for types in different schemas
-- This reproduces GitHub #197 and validates fixes for:
--
-- PUBLIC schema types (citext):
-- Users naturally write unqualified types when both table and type are in public.
-- pgschema must include public in search_path when applying to temp schema,
-- otherwise type resolution fails with "type citext does not exist".
--
-- CUSTOM schema types (hstore, custom_text, custom_enum):
-- Types in non-public schemas must always be schema-qualified in the output.

CREATE SCHEMA IF NOT EXISTS utils;
-- Drop and recreate utils schema for idempotency (setup runs for both old.sql and new.sql)
DROP SCHEMA IF EXISTS utils CASCADE;
CREATE SCHEMA utils;

-- Domain and enum in utils schema - must be schema-qualified
CREATE DOMAIN utils.custom_text AS text;

CREATE TYPE utils.custom_enum AS ENUM ('active', 'inactive', 'pending');

-- hstore type stays in utils schema
-- hstore in utils schema - tests cross-schema type qualification
CREATE EXTENSION IF NOT EXISTS hstore SCHEMA utils;
CREATE EXTENSION IF NOT EXISTS citext SCHEMA utils;

-- citext in public schema - reproduces #197
-- Users write unqualified "citext" when in public schema (natural usage)
CREATE EXTENSION IF NOT EXISTS citext SCHEMA public;
21 changes: 19 additions & 2 deletions testutil/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ func SetupPostgres(t testing.TB) *postgres.EmbeddedPostgres {
// The schema will be reset (dropped and recreated) to ensure clean state between test calls.
// This ensures tests use the same code path as production (database inspection) rather than parsing.
func ParseSQLToIR(t *testing.T, embeddedPG *postgres.EmbeddedPostgres, sqlContent string, schema string) *ir.IR {
return ParseSQLToIRWithSetup(t, embeddedPG, sqlContent, schema, "")
}

// ParseSQLToIRWithSetup is like ParseSQLToIR but accepts optional setup SQL.
// The setupSQL is executed AFTER schema recreation but BEFORE the main SQL.
// This is useful for tests that need to install extensions in the target schema
// (e.g., citext in public schema) which would otherwise be dropped during schema reset.
func ParseSQLToIRWithSetup(t *testing.T, embeddedPG *postgres.EmbeddedPostgres, sqlContent string, schema string, setupSQL string) *ir.IR {
t.Helper()

ctx := context.Background()
Expand Down Expand Up @@ -81,12 +89,21 @@ func ParseSQLToIR(t *testing.T, embeddedPG *postgres.EmbeddedPostgres, sqlConten
t.Fatalf("Failed to create schema: %v", err)
}

// Set search_path to target schema
setSearchPathSQL := fmt.Sprintf("SET search_path TO \"%s\"", schema)
// Set search_path to target schema, with public as fallback
// for resolving extension types installed in public schema (issue #197)
setSearchPathSQL := fmt.Sprintf("SET search_path TO \"%s\", public", schema)
if _, err := conn.ExecContext(ctx, setSearchPathSQL); err != nil {
t.Fatalf("Failed to set search_path: %v", err)
}

// Execute optional setup SQL (e.g., CREATE EXTENSION for types in public schema)
// This runs after schema recreation so extensions won't be dropped
if setupSQL != "" {
if _, err := conn.ExecContext(ctx, setupSQL); err != nil {
t.Fatalf("Failed to apply setup SQL to embedded PostgreSQL: %v", err)
}
}

// Execute the SQL
if _, err := conn.ExecContext(ctx, sqlContent); err != nil {
t.Fatalf("Failed to apply SQL to embedded PostgreSQL: %v", err)
Expand Down