-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathnormalize.go
More file actions
1465 lines (1259 loc) · 48 KB
/
normalize.go
File metadata and controls
1465 lines (1259 loc) · 48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package ir
import (
"fmt"
"regexp"
"sort"
"strings"
"unicode"
)
// normalizeIR normalizes the IR representation from the inspector.
//
// Since both desired state (from embedded postgres) and current state (from target database)
// now come from the same PostgreSQL version via database inspection, most normalizations
// are no longer needed. The remaining normalizations handle:
//
// - Type name mappings (internal PostgreSQL types → standard SQL types, e.g., int4 → integer)
// - PostgreSQL internal representations (e.g., "~~ " → "LIKE", "= ANY (ARRAY[...])" → "IN (...)")
// - Minor formatting differences in default values, policies, triggers, etc.
func normalizeIR(ir *IR) {
if ir == nil {
return
}
for _, schema := range ir.Schemas {
normalizeSchema(schema)
}
}
// normalizeSchema normalizes all objects within a schema
func normalizeSchema(schema *Schema) {
if schema == nil {
return
}
// Normalize tables
for _, table := range schema.Tables {
normalizeTable(table)
}
// Normalize views
for _, view := range schema.Views {
normalizeView(view)
}
// Normalize functions
for _, function := range schema.Functions {
normalizeFunction(function)
}
// Normalize procedures
for _, procedure := range schema.Procedures {
normalizeProcedure(procedure)
}
// Normalize types (including domains)
for _, typeObj := range schema.Types {
normalizeType(typeObj)
}
// Strip same-schema qualifiers from function/procedure privilege signatures
for _, priv := range schema.Privileges {
if priv.ObjectType == PrivilegeObjectTypeFunction || priv.ObjectType == PrivilegeObjectTypeProcedure {
priv.ObjectName = stripSchemaFromFunctionSignature(priv.ObjectName, schema.Name)
}
}
for _, priv := range schema.RevokedDefaultPrivileges {
if priv.ObjectType == PrivilegeObjectTypeFunction || priv.ObjectType == PrivilegeObjectTypeProcedure {
priv.ObjectName = stripSchemaFromFunctionSignature(priv.ObjectName, schema.Name)
}
}
}
// normalizeTable normalizes table-related objects
func normalizeTable(table *Table) {
if table == nil {
return
}
// Normalize columns (pass table schema for context)
for _, column := range table.Columns {
normalizeColumn(column, table.Schema)
}
// Normalize policies (pass table schema for context - Issue #220)
for _, policy := range table.Policies {
normalizePolicy(policy, table.Schema)
}
// Normalize triggers
for _, trigger := range table.Triggers {
normalizeTrigger(trigger)
}
// Normalize indexes
for _, index := range table.Indexes {
normalizeIndex(index)
}
// Normalize constraints
for _, constraint := range table.Constraints {
normalizeConstraint(constraint)
}
}
// normalizeColumn normalizes column default values
// tableSchema is used to strip same-schema qualifiers from function calls
func normalizeColumn(column *Column, tableSchema string) {
if column == nil || column.DefaultValue == nil {
return
}
normalized := normalizeDefaultValue(*column.DefaultValue, tableSchema)
column.DefaultValue = &normalized
}
// normalizeDefaultValue normalizes default values for semantic comparison
// tableSchema is used to strip same-schema qualifiers from function calls
func normalizeDefaultValue(value string, tableSchema string) string {
// Remove unnecessary whitespace
value = strings.TrimSpace(value)
// Handle nextval sequence references - remove schema qualification
if strings.Contains(value, "nextval(") {
// Pattern: nextval('schema_name.seq_name'::regclass) -> nextval('seq_name'::regclass)
re := regexp.MustCompile(`nextval\('([^.]+)\.([^']+)'::regclass\)`)
if re.MatchString(value) {
// Replace with unqualified sequence name
value = re.ReplaceAllString(value, "nextval('$2'::regclass)")
}
// Early return for nextval - don't apply type casting normalization
return value
}
// Normalize function calls - remove schema qualifiers for functions in the same schema
// This matches PostgreSQL's pg_get_expr() behavior which strips same-schema qualifiers
// Example: public.get_status() -> get_status() (when tableSchema is "public")
// other_schema.get_status() -> other_schema.get_status() (preserved)
if tableSchema != "" && strings.Contains(value, tableSchema+".") {
// Pattern: schema.function_name(
// Replace "tableSchema." with "" when followed by identifier and (
prefix := tableSchema + "."
pattern := regexp.MustCompile(regexp.QuoteMeta(prefix) + `([a-zA-Z_][a-zA-Z0-9_]*)\(`)
value = pattern.ReplaceAllString(value, `${1}(`)
}
// Handle type casting - remove explicit type casts that are semantically equivalent
if strings.Contains(value, "::") {
// Strip temporary embedded postgres schema prefixes (pgschema_tmp_*)
// These are used internally during plan generation and should be normalized away
// Pattern: ::pgschema_tmp_YYYYMMDD_HHMMSS_XXXXXXXX.typename -> ::typename
if strings.Contains(value, "::pgschema_tmp_") {
re := regexp.MustCompile(`::pgschema_tmp_[^.]+\.`)
value = re.ReplaceAllString(value, "::")
}
// Also strip same-schema type qualifiers for consistent comparison during plan/diff
// This ensures that '::public.typename' from current state matches '::typename' from
// desired state (after pgschema_tmp_* is stripped). Both are semantically equivalent
// within the same schema context. (Issue #218)
if tableSchema != "" && strings.Contains(value, "::"+tableSchema+".") {
re := regexp.MustCompile(`::\Q` + tableSchema + `\E\.`)
value = re.ReplaceAllString(value, "::")
}
// Handle NULL::type -> NULL
// Example: NULL::text -> NULL
re := regexp.MustCompile(`\bNULL::(?:[a-zA-Z_][\w\s.]*)(?:\[\])?`)
value = re.ReplaceAllString(value, "NULL")
// Handle numeric literals with type casts
// Example: '-1'::integer -> -1
// Example: '100'::bigint -> 100
// Note: PostgreSQL sometimes casts numeric literals to different types, e.g., -1::integer stored as numeric
re = regexp.MustCompile(`'(-?\d+(?:\.\d+)?)'::(?:integer|bigint|smallint|numeric|decimal|real|double precision|int2|int4|int8|float4|float8)`)
value = re.ReplaceAllString(value, "$1")
// Handle string literals with ONLY truly redundant type casts
// Only remove casts where the literal is inherently the target type:
// Example: 'text'::text -> 'text' (string literal IS text)
// Example: 'O''Brien'::character varying -> 'O''Brien'
// Example: '{}'::text[] -> '{}' (empty array literal with text array cast)
// Example: '{}'::jsonb -> '{}' (JSON object literal - column type provides context)
//
// IMPORTANT: Do NOT remove semantically significant casts like:
// - '1 year'::interval (interval literals REQUIRE the cast)
// - 'value'::my_enum (custom type casts)
// - '2024-01-01'::date (date literals need the cast in expressions)
//
// Pattern matches redundant text/varchar/char/json casts (including arrays)
// For column defaults, these casts are redundant because the column type provides context
// Note: jsonb must come before json to avoid partial match
// Note: (?:\[\])* handles multi-dimensional arrays like text[][]
re = regexp.MustCompile(`('(?:[^']|'')*')::(text|character varying|character|bpchar|varchar|jsonb|json)(?:\[\])*`)
value = re.ReplaceAllString(value, "$1")
// Handle parenthesized expressions with type casts - remove outer parentheses
// Example: (100)::bigint -> 100::bigint
// Pattern captures the number and the type cast separately
re = regexp.MustCompile(`\((\d+)\)(::(?:bigint|integer|smallint|numeric|decimal))`)
value = re.ReplaceAllString(value, "$1$2")
}
return value
}
// normalizePolicy normalizes RLS policy representation
// tableSchema is used to strip same-schema qualifiers from function calls (Issue #220)
func normalizePolicy(policy *RLSPolicy, tableSchema string) {
if policy == nil {
return
}
// Normalize roles - ensure consistent ordering and case
policy.Roles = normalizePolicyRoles(policy.Roles)
// Normalize expressions by removing extra whitespace
// For policy expressions, we want to preserve parentheses as they are part of the expected format
policy.Using = normalizePolicyExpression(policy.Using, tableSchema)
policy.WithCheck = normalizePolicyExpression(policy.WithCheck, tableSchema)
}
// normalizePolicyRoles normalizes policy roles for consistent comparison
func normalizePolicyRoles(roles []string) []string {
if len(roles) == 0 {
return roles
}
// Normalize role names with special handling for PUBLIC
normalized := make([]string, len(roles))
for i, role := range roles {
// Keep PUBLIC in uppercase, normalize others to lowercase
if strings.ToUpper(role) == "PUBLIC" {
normalized[i] = "PUBLIC"
} else {
normalized[i] = strings.ToLower(role)
}
}
// Sort to ensure consistent ordering
sort.Strings(normalized)
return normalized
}
// normalizePolicyExpression normalizes policy expressions (USING/WITH CHECK clauses)
// It preserves parentheses as they are part of the expected format for policies
// tableSchema is used to strip same-schema qualifiers from function calls and table references (Issue #220, #224)
func normalizePolicyExpression(expr string, tableSchema string) string {
if expr == "" {
return expr
}
// Remove extra whitespace and normalize
expr = strings.TrimSpace(expr)
expr = regexp.MustCompile(`\s+`).ReplaceAllString(expr, " ")
// Strip same-schema qualifiers from function calls (Issue #220)
// This matches PostgreSQL's behavior where same-schema qualifiers are stripped
// Example: tenant1.auth_uid() -> auth_uid() (when tableSchema is "tenant1")
// util.get_status() -> util.get_status() (preserved, different schema)
if tableSchema != "" && strings.Contains(expr, tableSchema+".") {
prefix := tableSchema + "."
pattern := regexp.MustCompile(regexp.QuoteMeta(prefix) + `([a-zA-Z_][a-zA-Z0-9_]*)\(`)
expr = pattern.ReplaceAllString(expr, `${1}(`)
// Strip same-schema qualifiers from table references (Issue #224)
// Matches schema.identifier followed by whitespace, comma, closing paren, or end of string
// Example: public.users -> users (when tableSchema is "public")
tablePattern := regexp.MustCompile(regexp.QuoteMeta(prefix) + `([a-zA-Z_][a-zA-Z0-9_]*)(\s|,|\)|$)`)
expr = tablePattern.ReplaceAllString(expr, `${1}${2}`)
}
// Handle all parentheses normalization (adding required ones, removing unnecessary ones)
expr = normalizeExpressionParentheses(expr)
// Normalize PostgreSQL internal type names to standard SQL types
expr = normalizePostgreSQLType(expr)
return expr
}
// normalizeView normalizes view definition.
//
// While both desired state (from embedded postgres) and current state (from target database)
// come from pg_get_viewdef(), they may differ in schema qualification of functions and tables.
// This happens when extension functions (e.g., ltree's nlevel()) or search_path differences
// cause one side to produce "public.func()" and the other "func()".
// Stripping same-schema qualifiers ensures the definitions compare as equal. (Issue #314)
func normalizeView(view *View) {
if view == nil {
return
}
// Strip same-schema qualifiers from view definition for consistent comparison.
// This uses the same logic as function/procedure body normalization.
view.Definition = StripSchemaPrefixFromBody(view.Definition, view.Schema)
// Normalize triggers on the view (e.g., INSTEAD OF triggers)
for _, trigger := range view.Triggers {
normalizeTrigger(trigger)
}
}
// normalizeFunction normalizes function signature and definition
func normalizeFunction(function *Function) {
if function == nil {
return
}
// lowercase LANGUAGE plpgsql is more common in modern usage
function.Language = strings.ToLower(function.Language)
// Normalize return type to handle PostgreSQL-specific formats
function.ReturnType = normalizeFunctionReturnType(function.ReturnType)
// Strip current schema qualifier from return type for consistent comparison.
// pg_get_function_result may or may not qualify types in the current schema
// depending on search_path (e.g., "SETOF public.actor" vs "SETOF actor").
function.ReturnType = stripSchemaFromReturnType(function.ReturnType, function.Schema)
// Normalize parameter types, modes, and default values
for _, param := range function.Parameters {
if param != nil {
param.DataType = normalizePostgreSQLType(param.DataType)
// Normalize mode: empty string → "IN" for functions (PostgreSQL default)
// Functions: IN is default, only OUT/INOUT/VARIADIC need explicit mode
// But for consistent comparison, normalize empty to "IN"
if param.Mode == "" {
param.Mode = "IN"
}
// Normalize default values (pass function schema for context)
if param.DefaultValue != nil {
normalized := normalizeDefaultValue(*param.DefaultValue, function.Schema)
param.DefaultValue = &normalized
}
}
}
// Normalize function body to handle whitespace differences
function.Definition = normalizeFunctionDefinition(function.Definition)
// Note: We intentionally do NOT strip schema qualifiers from function bodies here.
// Functions may have SET search_path that excludes their own schema, making
// qualified references (e.g., public.test) necessary. Stripping is done at
// comparison time in the diff package instead. (Issue #354)
}
// normalizeFunctionDefinition normalizes function body whitespace
// PostgreSQL stores function bodies with specific whitespace that may differ from source
func normalizeFunctionDefinition(def string) string {
if def == "" {
return def
}
// Only trim trailing whitespace from each line, preserving the line structure
// This ensures leading/trailing blank lines are preserved (matching PostgreSQL storage)
lines := strings.Split(def, "\n")
var normalized []string
for _, line := range lines {
// Trim all trailing whitespace (spaces, tabs, CR) but preserve leading whitespace for indentation
normalized = append(normalized, strings.TrimRightFunc(line, unicode.IsSpace))
}
return strings.Join(normalized, "\n")
}
// StripSchemaPrefixFromBody removes the current schema qualifier from identifiers
// in a function or procedure body. For example, "public.users" becomes "users".
// It skips single-quoted string literals to avoid modifying string constants.
func StripSchemaPrefixFromBody(body, schema string) string {
if body == "" || schema == "" {
return body
}
prefix := schema + "."
prefixLen := len(prefix)
// Fast path: if the prefix doesn't appear at all, return as-is
if !strings.Contains(body, prefix) {
return body
}
var result strings.Builder
result.Grow(len(body))
inString := false
for i := 0; i < len(body); i++ {
ch := body[i]
// Track single-quoted string literals, handling '' escapes
if ch == '\'' {
if inString {
if i+1 < len(body) && body[i+1] == '\'' {
// Escaped quote inside string: write both and skip
result.WriteString("''")
i++
continue
}
inString = false
} else {
inString = true
}
result.WriteByte(ch)
continue
}
// Only attempt replacement outside string literals
if !inString && i+prefixLen <= len(body) && body[i:i+prefixLen] == prefix {
// Ensure this is a schema qualifier, not part of a longer identifier
// (e.g., "not_public.users" should not match)
if i == 0 || !isIdentChar(body[i-1]) {
// After stripping the schema prefix, check if the remaining identifier
// is a reserved keyword that needs quoting.
// e.g., public.user → "user", public.order → "order"
afterPrefix := i + prefixLen
identEnd := afterPrefix
for identEnd < len(body) && isIdentChar(body[identEnd]) {
identEnd++
}
ident := body[afterPrefix:identEnd]
if needsQuoting(ident) {
result.WriteString(QuoteIdentifier(ident))
i = identEnd - 1
continue
}
// Skip the schema prefix, keep everything after it
i += prefixLen - 1
continue
}
}
result.WriteByte(ch)
}
return result.String()
}
// isIdentChar returns true if the byte is a valid SQL identifier character.
func isIdentChar(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
// normalizeProcedure normalizes procedure representation
func normalizeProcedure(procedure *Procedure) {
if procedure == nil {
return
}
// Normalize language to lowercase (PLPGSQL → plpgsql)
procedure.Language = strings.ToLower(procedure.Language)
// Normalize parameter types, modes, and default values
for _, param := range procedure.Parameters {
if param != nil {
param.DataType = normalizePostgreSQLType(param.DataType)
// Normalize mode: empty string → "IN" for procedures (PostgreSQL default)
if param.Mode == "" {
param.Mode = "IN"
}
// Normalize default values (pass procedure schema for context)
if param.DefaultValue != nil {
normalized := normalizeDefaultValue(*param.DefaultValue, procedure.Schema)
param.DefaultValue = &normalized
}
}
}
// Note: We intentionally do NOT strip schema qualifiers from procedure bodies here.
// Same rationale as functions — see normalizeFunction. (Issue #354)
}
// splitTableColumns splits a TABLE column list by top-level commas,
// respecting nested parentheses (e.g., numeric(10, 2)).
func splitTableColumns(inner string) []string {
var parts []string
depth := 0
inQuotes := false
start := 0
for i := 0; i < len(inner); i++ {
ch := inner[i]
if inQuotes {
if ch == '"' {
if i+1 < len(inner) && inner[i+1] == '"' {
i++ // skip escaped ""
} else {
inQuotes = false
}
}
continue
}
switch ch {
case '"':
inQuotes = true
case '(':
depth++
case ')':
depth--
case ',':
if depth == 0 {
parts = append(parts, inner[start:i])
start = i + 1
}
}
}
parts = append(parts, inner[start:])
return parts
}
// splitColumnNameAndType splits a TABLE column definition like `"full name" public.mytype`
// into the column name and the type, respecting double-quoted identifiers.
func splitColumnNameAndType(colDef string) (name, typePart string) {
colDef = strings.TrimSpace(colDef)
if colDef == "" {
return "", ""
}
var nameEnd int
if colDef[0] == '"' {
// Quoted identifier: find the closing double-quote
// PostgreSQL escapes embedded quotes as ""
i := 1
for i < len(colDef) {
if colDef[i] == '"' {
if i+1 < len(colDef) && colDef[i+1] == '"' {
i += 2 // skip escaped ""
continue
}
nameEnd = i + 1
break
}
i++
}
if nameEnd == 0 {
// Unterminated quote — treat whole thing as name
return colDef, ""
}
} else {
// Unquoted identifier: ends at first whitespace
nameEnd = strings.IndexFunc(colDef, func(r rune) bool {
return r == ' ' || r == '\t'
})
if nameEnd == -1 {
return colDef, ""
}
}
name = colDef[:nameEnd]
rest := strings.TrimSpace(colDef[nameEnd:])
return name, rest
}
// normalizeFunctionReturnType normalizes function return types, especially TABLE types
func normalizeFunctionReturnType(returnType string) string {
if returnType == "" {
return returnType
}
// Handle TABLE return types
if strings.HasPrefix(returnType, "TABLE(") && strings.HasSuffix(returnType, ")") {
// Extract the contents inside TABLE(...)
inner := returnType[6 : len(returnType)-1] // Remove "TABLE(" and ")"
// Split by top-level commas (respecting nested parentheses like numeric(10,2))
parts := splitTableColumns(inner)
var normalizedParts []string
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Split column definition into name and type, respecting quoted identifiers
name, typePart := splitColumnNameAndType(part)
if typePart != "" {
normalizedType := normalizePostgreSQLType(typePart)
normalizedParts = append(normalizedParts, name+" "+normalizedType)
} else {
// Just a type, normalize it
normalizedParts = append(normalizedParts, normalizePostgreSQLType(part))
}
}
return "TABLE(" + strings.Join(normalizedParts, ", ") + ")"
}
// For non-TABLE return types, apply regular type normalization
return normalizePostgreSQLType(returnType)
}
// stripSchemaFromReturnType strips the current schema qualifier from a function return type.
// This handles SETOF and array types, e.g., "SETOF public.actor" → "SETOF actor"
// when the function is in the public schema.
func stripSchemaFromReturnType(returnType, schema string) string {
if returnType == "" || schema == "" {
return returnType
}
prefix := schema + "."
// Handle SETOF prefix
if len(returnType) > 6 && strings.EqualFold(returnType[:6], "SETOF ") {
rest := strings.TrimSpace(returnType[6:])
stripped := stripSchemaPrefix(rest, prefix)
if stripped != rest {
return returnType[:6] + stripped
}
return returnType
}
// Handle TABLE(...) return types - strip schema from individual column types
if strings.HasPrefix(returnType, "TABLE(") && strings.HasSuffix(returnType, ")") {
inner := returnType[6 : len(returnType)-1] // Remove "TABLE(" and ")"
// Split by top-level commas (respecting nested parentheses like numeric(10,2))
parts := splitTableColumns(inner)
var newParts []string
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Split column definition into name and type, respecting quoted identifiers
name, typePart := splitColumnNameAndType(part)
if typePart != "" {
strippedType := stripSchemaPrefix(typePart, prefix)
newParts = append(newParts, name+" "+strippedType)
} else {
newParts = append(newParts, part)
}
}
return "TABLE(" + strings.Join(newParts, ", ") + ")"
}
// Direct type name
return stripSchemaPrefix(returnType, prefix)
}
// stripSchemaPrefix removes a schema prefix from a type name, preserving array notation.
func stripSchemaPrefix(typeName, prefix string) string {
// Separate base type from array suffix (e.g., "public.mytype[]" → "public.mytype" + "[]")
base := typeName
arrayStart := strings.Index(base, "[]")
arraySuffix := ""
if arrayStart >= 0 {
arraySuffix = base[arrayStart:]
base = base[:arrayStart]
}
if strings.HasPrefix(base, prefix) {
return base[len(prefix):] + arraySuffix
}
return typeName
}
// stripSchemaFromFunctionSignature strips same-schema qualifiers from type references
// within a function signature like "func_name(p_name text, p_kind myschema.mytype)".
func stripSchemaFromFunctionSignature(signature, schema string) string {
if schema == "" {
return signature
}
// Replace both quoted and unquoted schema prefixes within the signature
signature = strings.ReplaceAll(signature, `"`+schema+`".`, "")
signature = strings.ReplaceAll(signature, schema+".", "")
return signature
}
// normalizeTrigger normalizes trigger representation
func normalizeTrigger(trigger *Trigger) {
if trigger == nil {
return
}
// Normalize trigger function call with the trigger's schema context
trigger.Function = normalizeTriggerFunctionCall(trigger.Function, trigger.Schema)
// Normalize trigger events to standard order: INSERT, UPDATE, DELETE, TRUNCATE
trigger.Events = normalizeTriggerEvents(trigger.Events)
// Normalize trigger condition (WHEN clause) for consistent comparison
trigger.Condition = normalizeTriggerCondition(trigger.Condition)
}
// normalizeTriggerFunctionCall normalizes trigger function call syntax and removes same-schema qualifiers
func normalizeTriggerFunctionCall(functionCall string, triggerSchema string) string {
if functionCall == "" {
return functionCall
}
// Remove extra whitespace
functionCall = strings.TrimSpace(functionCall)
functionCall = regexp.MustCompile(`\s+`).ReplaceAllString(functionCall, " ")
// Normalize function call formatting
functionCall = regexp.MustCompile(`\(\s*`).ReplaceAllString(functionCall, "(")
functionCall = regexp.MustCompile(`\s*\)`).ReplaceAllString(functionCall, ")")
functionCall = regexp.MustCompile(`\s*,\s*`).ReplaceAllString(functionCall, ", ")
// Strip schema qualifier if it matches the trigger's schema
if triggerSchema != "" {
schemaPrefix := triggerSchema + "."
functionCall = strings.TrimPrefix(functionCall, schemaPrefix)
}
return functionCall
}
// normalizeTriggerEvents normalizes trigger events to standard order
func normalizeTriggerEvents(events []TriggerEvent) []TriggerEvent {
if len(events) == 0 {
return events
}
// Define standard order: INSERT, UPDATE, DELETE, TRUNCATE
standardOrder := []TriggerEvent{
TriggerEventInsert,
TriggerEventUpdate,
TriggerEventDelete,
TriggerEventTruncate,
}
// Create a set of events for quick lookup
eventSet := make(map[TriggerEvent]bool)
for _, event := range events {
eventSet[event] = true
}
// Build normalized events in standard order
var normalized []TriggerEvent
for _, event := range standardOrder {
if eventSet[event] {
normalized = append(normalized, event)
}
}
return normalized
}
// normalizeTriggerCondition normalizes trigger WHEN conditions for consistent comparison
func normalizeTriggerCondition(condition string) string {
if condition == "" {
return condition
}
// Normalize whitespace
condition = strings.TrimSpace(condition)
condition = regexp.MustCompile(`\s+`).ReplaceAllString(condition, " ")
// Normalize NEW and OLD identifiers to uppercase
condition = regexp.MustCompile(`\bnew\b`).ReplaceAllStringFunc(condition, func(match string) string {
return strings.ToUpper(match)
})
condition = regexp.MustCompile(`\bold\b`).ReplaceAllStringFunc(condition, func(match string) string {
return strings.ToUpper(match)
})
// PostgreSQL stores "IS NOT DISTINCT FROM" as "NOT (... IS DISTINCT FROM ...)"
// Convert the internal form to the SQL standard form for consistency
// Pattern: NOT (expr IS DISTINCT FROM expr) -> expr IS NOT DISTINCT FROM expr
re := regexp.MustCompile(`NOT \((.+?)\s+IS\s+DISTINCT\s+FROM\s+(.+?)\)`)
condition = re.ReplaceAllString(condition, "$1 IS NOT DISTINCT FROM $2")
return condition
}
// normalizeIndex normalizes index WHERE clauses and other properties
func normalizeIndex(index *Index) {
if index == nil {
return
}
// Normalize WHERE clause for partial indexes
if index.IsPartial && index.Where != "" {
index.Where = normalizeIndexWhereClause(index.Where)
}
}
// normalizeIndexWhereClause normalizes WHERE clauses in partial indexes
// It handles proper parentheses for different expression types
func normalizeIndexWhereClause(where string) string {
if where == "" {
return where
}
// Remove any existing outer parentheses to normalize the input
if strings.HasPrefix(where, "(") && strings.HasSuffix(where, ")") {
// Check if the parentheses wrap the entire expression
inner := where[1 : len(where)-1]
if isBalancedParentheses(inner) {
where = inner
}
}
// Convert PostgreSQL's "= ANY (ARRAY[...])" format to "IN (...)" format
where = convertAnyArrayToIn(where)
// Determine if this expression needs outer parentheses based on its structure
needsParentheses := shouldAddParenthesesForWhereClause(where)
if needsParentheses {
return fmt.Sprintf("(%s)", where)
}
return where
}
// shouldAddParenthesesForWhereClause determines if a WHERE clause needs outer parentheses
// Based on PostgreSQL's formatting expectations for pg_get_expr
func shouldAddParenthesesForWhereClause(expr string) bool {
if expr == "" {
return false
}
// Don't add parentheses for well-formed expressions that are self-contained:
// 1. IN expressions: "column IN (value1, value2, value3)"
if strings.Contains(expr, " IN (") {
return false
}
// 2. Function calls: "function_name(args)"
if matches, _ := regexp.MatchString(`^[a-zA-Z_][a-zA-Z0-9_]*\s*\(.*\)$`, expr); matches {
return false
}
// 3. Simple comparisons with parenthesized right side: "column = (value)"
if matches, _ := regexp.MatchString(`^[a-zA-Z_][a-zA-Z0-9_]*\s*[=<>!]+\s*\(.*\)$`, expr); matches {
return false
}
// 4. Already fully parenthesized complex expressions
if strings.HasPrefix(expr, "(") && strings.HasSuffix(expr, ")") {
return false
}
// For other expressions (simple comparisons, AND/OR combinations, etc.), add parentheses
return true
}
// normalizeExpressionParentheses handles parentheses normalization for policy expressions
// It ensures required parentheses for PostgreSQL DDL while removing unnecessary ones
func normalizeExpressionParentheses(expr string) string {
if expr == "" {
return expr
}
// Step 1: Ensure WITH CHECK/USING expressions are properly parenthesized
// PostgreSQL requires parentheses around all policy expressions in DDL
if !strings.HasPrefix(expr, "(") || !strings.HasSuffix(expr, ")") {
expr = fmt.Sprintf("(%s)", expr)
}
// Step 2: Remove unnecessary parentheses around function calls within the expression
// Specifically targets patterns like (function_name(...)) -> function_name(...)
// This pattern looks for:
// \( - opening parenthesis
// ([a-zA-Z_][a-zA-Z0-9_]*) - function name (captured)
// \( - opening parenthesis for function call
// ([^)]*) - function arguments (captured, non-greedy to avoid matching nested parens)
// \) - closing parenthesis for function call
// \) - closing parenthesis around the whole function
functionParensRegex := regexp.MustCompile(`\(([a-zA-Z_][a-zA-Z0-9_]*\([^)]*\))\)`)
// Replace (function(...)) with function(...)
// Keep applying until no more matches to handle nested cases
for {
original := expr
expr = functionParensRegex.ReplaceAllString(expr, "$1")
if expr == original {
break
}
}
// Step 3: Normalize redundant type casts in function arguments
// Pattern: 'text'::text -> 'text' (removing redundant text cast from literals)
// IMPORTANT: Do NOT match when followed by [] (array cast is semantically significant)
// e.g., '{nested,key}'::text[] must be preserved as-is
// Since Go regex doesn't support lookahead, we use [^[\w] which excludes both '['
// and word characters (letters/digits/_), correctly preventing matches like ::text[] or ::textual
redundantTextCastRegex := regexp.MustCompile(`'([^']+)'::text([^[\w]|$)`)
expr = redundantTextCastRegex.ReplaceAllString(expr, "'$1'$2")
return expr
}
// isBalancedParentheses checks if parentheses are properly balanced in the expression
func isBalancedParentheses(expr string) bool {
count := 0
inQuotes := false
var quoteChar rune
for _, r := range expr {
if !inQuotes {
switch r {
case '\'', '"':
inQuotes = true
quoteChar = r
case '(':
count++
case ')':
count--
if count < 0 {
return false
}
}
} else {
if r == quoteChar {
inQuotes = false
}
}
}
return count == 0
}
// normalizeType normalizes type-related objects, including domain constraints
func normalizeType(typeObj *Type) {
if typeObj == nil || typeObj.Kind != TypeKindDomain {
return
}
// Normalize domain default value
if typeObj.Default != "" {
typeObj.Default = normalizeDomainDefault(typeObj.Default)
}
// Normalize domain constraints (pass schema for stripping same-schema qualifiers)
for _, constraint := range typeObj.Constraints {
normalizeDomainConstraint(constraint, typeObj.Schema)
}
}
// normalizeDomainDefault normalizes domain default values
func normalizeDomainDefault(defaultValue string) string {
if defaultValue == "" {
return defaultValue
}
// Remove redundant type casts from string literals
// e.g., 'example@acme.com'::text -> 'example@acme.com'
defaultValue = regexp.MustCompile(`'([^']+)'::text\b`).ReplaceAllString(defaultValue, "'$1'")
return defaultValue
}
// normalizeDomainConstraint normalizes domain constraint definitions
// domainSchema is used to strip same-schema qualifiers from function calls
func normalizeDomainConstraint(constraint *DomainConstraint, domainSchema string) {
if constraint == nil || constraint.Definition == "" {
return
}
def := constraint.Definition
// Normalize VALUE keyword to uppercase in domain constraints
// Use word boundaries to ensure we only match the identifier, not parts of other words
def = regexp.MustCompile(`\bvalue\b`).ReplaceAllStringFunc(def, func(match string) string {
return strings.ToUpper(match)
})
// Handle CHECK constraints
if strings.HasPrefix(def, "CHECK ") {
// Extract the expression inside CHECK (...)
checkMatch := regexp.MustCompile(`^CHECK\s*\((.*)\)$`).FindStringSubmatch(def)
if len(checkMatch) > 1 {
expr := checkMatch[1]
// Remove outer parentheses if they wrap the entire expression
expr = strings.TrimSpace(expr)
if strings.HasPrefix(expr, "(") && strings.HasSuffix(expr, ")") {
inner := expr[1 : len(expr)-1]
if isBalancedParentheses(inner) {
expr = inner
}
}
// Strip same-schema qualifiers from function calls (similar to normalizePolicyExpression)
// This matches PostgreSQL's behavior where pg_get_constraintdef includes schema qualifiers
// but the source SQL may not include them
// Example: public.validate_custom_id(VALUE) -> validate_custom_id(VALUE) (when domainSchema is "public")
if domainSchema != "" && strings.Contains(expr, domainSchema+".") {
prefix := domainSchema + "."
pattern := regexp.MustCompile(regexp.QuoteMeta(prefix) + `([a-zA-Z_][a-zA-Z0-9_]*)\(`)
expr = pattern.ReplaceAllString(expr, `${1}(`)
}
// Remove redundant type casts
// e.g., '...'::text -> '...'
expr = regexp.MustCompile(`'([^']+)'::text\b`).ReplaceAllString(expr, "'$1'")
// Reconstruct the CHECK constraint
def = fmt.Sprintf("CHECK (%s)", expr)
}
}
constraint.Definition = def
}
// postgresTypeNormalization maps PostgreSQL internal type names to standard SQL types.
// This map is used by normalizePostgreSQLType to normalize type representations.
var postgresTypeNormalization = map[string]string{
// Numeric types
"int2": "smallint",
"int4": "integer",
"int8": "bigint",
"float4": "real",
"float8": "double precision",