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
165 changes: 165 additions & 0 deletions internal/diff/topological.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,168 @@ func nextInOrder(order []string, processed map[string]bool) string {
}
return ""
}

// topologicallySortTypes sorts types across all schemas in dependency order
// Types that are referenced by composite types will come before the types that reference them
func topologicallySortTypes(types []*ir.Type) []*ir.Type {
if len(types) <= 1 {
return types
}

// Build maps for efficient lookup
typeMap := make(map[string]*ir.Type)
var insertionOrder []string
for _, t := range types {
key := t.Schema + "." + t.Name
typeMap[key] = t
insertionOrder = append(insertionOrder, key)
}

// Build dependency graph
inDegree := make(map[string]int)
adjList := make(map[string][]string)

// Initialize
for key := range typeMap {
inDegree[key] = 0
adjList[key] = []string{}
}

// Build edges: if typeA references typeB (composite type column uses typeB), add edge typeB -> typeA
for keyA, typeA := range typeMap {
if typeA.Kind == ir.TypeKindComposite {
for _, col := range typeA.Columns {
// Extract type name from DataType (may include schema prefix or array notation)
referencedType := extractTypeName(col.DataType, typeA.Schema)
if referencedType != "" {
// Check if the referenced type exists in our set
if _, exists := typeMap[referencedType]; exists && keyA != referencedType {
adjList[referencedType] = append(adjList[referencedType], keyA)
inDegree[keyA]++
}
}
}
} else if typeA.Kind == ir.TypeKindDomain {
// Domain types may reference other types as their base type
referencedType := extractTypeName(typeA.BaseType, typeA.Schema)
if referencedType != "" {
if _, exists := typeMap[referencedType]; exists && keyA != referencedType {
adjList[referencedType] = append(adjList[referencedType], keyA)
inDegree[keyA]++
}
}
}
}

// Kahn's algorithm with deterministic cycle breaking
var queue []string
var result []string
processed := make(map[string]bool, len(typeMap))

// Seed queue with nodes that have no incoming edges
for key, degree := range inDegree {
if degree == 0 {
queue = append(queue, key)
}
}
sort.Strings(queue)

for len(result) < len(typeMap) {
if len(queue) == 0 {
// Cycle detected: pick the next unprocessed type using original insertion order
//
// CYCLE BREAKING STRATEGY FOR TYPES:
// Setting inDegree[next] = 0 effectively declares "this type has no remaining dependencies"
// for the purpose of breaking the cycle. This is safe because:
//
// 1. The 'processed' map prevents any type from being added to the result twice, even if
// its inDegree becomes zero or negative multiple times (see line 344 check).
//
// 2. For circular type dependencies in PostgreSQL, the dependency cycle can only occur
// through composite types referencing each other. Unlike table foreign keys, type
// dependencies cannot be added after creation - the entire type definition must be
// complete at CREATE TYPE time.
//
// 3. PostgreSQL itself prohibits creating types with true circular dependencies
// (composite type A containing type B, which contains type A) because it would
// result in infinite size. The only cycles that can occur in practice involve
// array types or indirection (e.g., A contains B[], B contains A[]), which
// PostgreSQL allows because arrays don't expand the size infinitely.
//
// 4. Using insertion order (alphabetical by schema.name) ensures deterministic output
// when multiple valid orderings exist.
//
// For types with unavoidable circular references (via arrays), the order doesn't
// affect correctness since PostgreSQL's type system handles these internally.
next := nextInOrder(insertionOrder, processed)
if next == "" {
break
}
queue = append(queue, next)
inDegree[next] = 0
}

current := queue[0]
queue = queue[1:]
if processed[current] {
continue
}
processed[current] = true
result = append(result, current)

neighbors := append([]string(nil), adjList[current]...)
sort.Strings(neighbors)

for _, neighbor := range neighbors {
inDegree[neighbor]--
// Add neighbor to queue if all its dependencies are satisfied.
// The '!processed[neighbor]' check is critical: it prevents re-adding types
// that have already been processed, even if their inDegree becomes <= 0 again
// due to cycle breaking (where we artificially set inDegree to 0).
if inDegree[neighbor] <= 0 && !processed[neighbor] {
queue = append(queue, neighbor)
sort.Strings(queue)
}
}
}

// Convert result back to type slice
sortedTypes := make([]*ir.Type, 0, len(result))
for _, key := range result {
sortedTypes = append(sortedTypes, typeMap[key])
}

return sortedTypes
}

// extractTypeName extracts a fully qualified type name from a data type string
// It handles array notation (e.g., "status_type[]") and schema prefixes
func extractTypeName(dataType, defaultSchema string) string {
if dataType == "" {
return ""
}

// Remove array notation
typeName := dataType
for len(typeName) > 2 && typeName[len(typeName)-2:] == "[]" {
typeName = typeName[:len(typeName)-2]
}

// Check if it's a schema-qualified name
if idx := findLastDot(typeName); idx != -1 {
return typeName // Already fully qualified
}

// Not qualified - use default schema
return defaultSchema + "." + typeName
}

// findLastDot finds the last dot in a string, returning -1 if not found
func findLastDot(s string) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '.' {
return i
}
}
return -1
}
163 changes: 163 additions & 0 deletions internal/diff/topological_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,166 @@ func newTestTable(name string, deps ...string) *ir.Table {
Constraints: constraints,
}
}

func TestTopologicallySortTypesHandlesCycles(t *testing.T) {
types := []*ir.Type{
// Simple chain: a <- b <- c
newTestEnumType("a"),
newTestCompositeType("b", "a"),
newTestCompositeType("c", "b"),
// Cycle: x <-> y (theoretically impossible in PostgreSQL but test handles it)
newTestCompositeType("x", "y"),
newTestCompositeType("y", "x"),
// Type depending on the cycle
newTestCompositeType("z", "y"),
}

sorted := topologicallySortTypes(types)
if len(sorted) != len(types) {
t.Fatalf("expected %d types, got %d", len(types), len(sorted))
}

order := make(map[string]int, len(sorted))
for idx, typ := range sorted {
order[typ.Name] = idx
}

assertBefore := func(first, second string) {
if order[first] >= order[second] {
t.Fatalf("expected %s to appear before %s in %v", first, second, order)
}
}

// Verify simple chain ordering
assertBefore("a", "b")
assertBefore("b", "c")
// Dependent types should still come after cycle members
assertBefore("y", "z")

// Cycle members should have a deterministic order (insertion order)
if order["x"] >= order["y"] {
t.Fatalf("expected x to be ordered before y for deterministic output, got %v", order)
}
}

func TestTopologicallySortTypesMultipleNoDependencies(t *testing.T) {
types := []*ir.Type{
newTestEnumType("z"),
newTestEnumType("a"),
newTestEnumType("m"),
newTestEnumType("b"),
}

sorted := topologicallySortTypes(types)
if len(sorted) != len(types) {
t.Fatalf("expected %d types, got %d", len(types), len(sorted))
}

// With no dependencies, should maintain deterministic alphabetical order
order := make(map[string]int, len(sorted))
for idx, typ := range sorted {
order[typ.Name] = idx
}

// Verify deterministic ordering: a < b < m < z
if order["a"] >= order["b"] || order["b"] >= order["m"] || order["m"] >= order["z"] {
t.Fatalf("expected alphabetical order for types with no dependencies, got %v", order)
}
}

func TestTopologicallySortTypesDomainReferencingCustomType(t *testing.T) {
types := []*ir.Type{
newTestEnumType("status_type"),
newTestDomainType("status_domain", "status_type"),
newTestCompositeType("person", "status_domain"),
}

sorted := topologicallySortTypes(types)
if len(sorted) != len(types) {
t.Fatalf("expected %d types, got %d", len(types), len(sorted))
}

order := make(map[string]int, len(sorted))
for idx, typ := range sorted {
order[typ.Name] = idx
}

assertBefore := func(first, second string) {
if order[first] >= order[second] {
t.Fatalf("expected %s to appear before %s in %v", first, second, order)
}
}

// Verify correct dependency chain
assertBefore("status_type", "status_domain")
assertBefore("status_domain", "person")
}

func TestTopologicallySortTypesCompositeWithMultipleDependencies(t *testing.T) {
types := []*ir.Type{
newTestEnumType("status"),
newTestEnumType("priority"),
newTestEnumType("category"),
newTestCompositeType("task", "status", "priority", "category"),
newTestCompositeType("project", "task"),
}

sorted := topologicallySortTypes(types)
if len(sorted) != len(types) {
t.Fatalf("expected %d types, got %d", len(types), len(sorted))
}

order := make(map[string]int, len(sorted))
for idx, typ := range sorted {
order[typ.Name] = idx
}

assertBefore := func(first, second string) {
if order[first] >= order[second] {
t.Fatalf("expected %s to appear before %s in %v", first, second, order)
}
}

// All dependencies should come before task
assertBefore("status", "task")
assertBefore("priority", "task")
assertBefore("category", "task")
// And task should come before project
assertBefore("task", "project")
}

func newTestEnumType(name string) *ir.Type {
return &ir.Type{
Schema: "public",
Name: name,
Kind: ir.TypeKindEnum,
EnumValues: []string{"value1", "value2"},
}
}

func newTestCompositeType(name string, deps ...string) *ir.Type {
columns := make([]*ir.TypeColumn, len(deps))
for idx, dep := range deps {
columns[idx] = &ir.TypeColumn{
Name: fmt.Sprintf("col_%d", idx),
DataType: dep, // References the type
Position: idx + 1,
}
}

return &ir.Type{
Schema: "public",
Name: name,
Kind: ir.TypeKindComposite,
Columns: columns,
}
}

func newTestDomainType(name, baseType string) *ir.Type {
return &ir.Type{
Schema: "public",
Name: name,
Kind: ir.TypeKindDomain,
BaseType: baseType,
}
}
20 changes: 2 additions & 18 deletions internal/diff/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,8 @@ import (

// generateCreateTypesSQL generates CREATE TYPE statements
func generateCreateTypesSQL(types []*ir.Type, targetSchema string, collector *diffCollector) {
// Sort types: CREATE TYPE statements first, then CREATE DOMAIN statements
sortedTypes := make([]*ir.Type, len(types))
copy(sortedTypes, types)
sort.Slice(sortedTypes, func(i, j int) bool {
typeI := sortedTypes[i]
typeJ := sortedTypes[j]

// Domain types should come after non-domain types
if typeI.Kind == ir.TypeKindDomain && typeJ.Kind != ir.TypeKindDomain {
return false
}
if typeI.Kind != ir.TypeKindDomain && typeJ.Kind == ir.TypeKindDomain {
return true
}

// Within the same category, sort alphabetically by name
return typeI.Name < typeJ.Name
})
// Sort types topologically to handle dependencies (e.g., composite types referencing enum types)
sortedTypes := topologicallySortTypes(types)

for _, typeObj := range sortedTypes {
sql := generateTypeSQL(typeObj, targetSchema)
Expand Down
6 changes: 6 additions & 0 deletions testdata/diff/dependency/type_to_type/diff.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TYPE status_type AS ENUM (
'active',
'inactive'
);

CREATE TYPE record_type AS (id integer, status status_type);
11 changes: 11 additions & 0 deletions testdata/diff/dependency/type_to_type/new.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Enum type (dependency)
CREATE TYPE public.status_type AS ENUM (
'active',
'inactive'
);

-- Composite type that references the enum type
CREATE TYPE public.record_type AS (
id integer,
status status_type
);
1 change: 1 addition & 0 deletions testdata/diff/dependency/type_to_type/old.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Empty schema (no types)
Loading