diff --git a/ir/formatter.go b/ir/formatter.go deleted file mode 100644 index 88084aea..00000000 --- a/ir/formatter.go +++ /dev/null @@ -1,908 +0,0 @@ -package ir - -import ( - "strconv" - "strings" - - pg_query "github.com/pganalyze/pg_query_go/v6" -) - -// postgreSQLFormatter implements PostgreSQL's pg_get_viewdef pretty-print algorithm -type postgreSQLFormatter struct { - buffer *strings.Builder - indentLevel int - viewSchema string // Schema of the view being formatted (for proper schema qualification) -} - -// newPostgreSQLFormatter creates a new PostgreSQL formatter -func newPostgreSQLFormatter(viewSchema string) *postgreSQLFormatter { - return &postgreSQLFormatter{ - buffer: &strings.Builder{}, - indentLevel: 0, - viewSchema: viewSchema, - } -} - -// formatQueryNode formats a query AST using PostgreSQL's formatting rules -func (f *postgreSQLFormatter) formatQueryNode(queryNode *pg_query.Node) string { - if selectStmt := queryNode.GetSelectStmt(); selectStmt != nil { - f.formatSelectStmt(selectStmt) - } else { - // Fallback to deparse if not a SelectStmt - stmt := &pg_query.RawStmt{Stmt: queryNode} - parseResult := &pg_query.ParseResult{Stmts: []*pg_query.RawStmt{stmt}} - if deparseResult, err := pg_query.Deparse(parseResult); err == nil { - return strings.TrimSpace(deparseResult) - } - return "" - } - - return f.buffer.String() -} - -// formatSelectStmt formats a SELECT statement according to PostgreSQL's rules -func (f *postgreSQLFormatter) formatSelectStmt(stmt *pg_query.SelectStmt) { - // Add leading space and SELECT keyword (PostgreSQL adds a leading space) - f.buffer.WriteString(" SELECT") - - // Format target list (columns) - if len(stmt.TargetList) > 0 { - f.formatTargetList(stmt.TargetList) - } - - // Format FROM clause - if len(stmt.FromClause) > 0 { - f.buffer.WriteString("\n FROM ") - f.formatFromClause(stmt.FromClause) - } - - // Format WHERE clause - if stmt.WhereClause != nil { - f.buffer.WriteString("\n WHERE ") - f.formatExpression(stmt.WhereClause) - } - - // Format GROUP BY clause - if len(stmt.GroupClause) > 0 { - f.buffer.WriteString("\n GROUP BY ") - f.formatGroupByClause(stmt.GroupClause) - } - - // Format HAVING clause - if stmt.HavingClause != nil { - f.buffer.WriteString("\n HAVING ") - f.formatExpression(stmt.HavingClause) - } - - // Format ORDER BY clause - if len(stmt.SortClause) > 0 { - f.buffer.WriteString("\n ORDER BY ") - f.formatOrderByClause(stmt.SortClause) - } -} - -// formatTargetList formats the SELECT column list -func (f *postgreSQLFormatter) formatTargetList(targets []*pg_query.Node) { - for i, target := range targets { - if i == 0 { - f.buffer.WriteString(" ") // First column on same line as SELECT - } else { - f.buffer.WriteString(",\n ") // Subsequent columns indented - } - - if resTarget := target.GetResTarget(); resTarget != nil { - f.formatResTarget(resTarget) - } - } -} - -// formatResTarget formats a single SELECT target (column/expression) -func (f *postgreSQLFormatter) formatResTarget(target *pg_query.ResTarget) { - // Format the expression - if target.Val != nil { - f.formatExpression(target.Val) - } - - // Add alias if present - if target.Name != "" { - f.buffer.WriteString(" AS ") - f.buffer.WriteString(target.Name) - } -} - -// formatFromClause formats the FROM clause -func (f *postgreSQLFormatter) formatFromClause(fromList []*pg_query.Node) { - for i, fromItem := range fromList { - if i > 0 { - f.buffer.WriteString(", ") - } - f.formatFromItem(fromItem) - } -} - -// formatFromItem formats a single FROM item (table, join, subquery) -func (f *postgreSQLFormatter) formatFromItem(item *pg_query.Node) { - switch { - case item.GetRangeVar() != nil: - f.formatRangeVar(item.GetRangeVar()) - case item.GetJoinExpr() != nil: - f.formatJoinExpr(item.GetJoinExpr()) - case item.GetRangeSubselect() != nil: - f.formatRangeSubselect(item.GetRangeSubselect()) - default: - // Fallback to deparse for unknown node types - if deparseResult, err := f.deparseNode(item); err == nil { - f.buffer.WriteString(deparseResult) - } - } -} - -// formatRangeVar formats a table reference -func (f *postgreSQLFormatter) formatRangeVar(rangeVar *pg_query.RangeVar) { - // Apply schema qualification rules: - // - If table schema == view schema: omit schema qualifier (same schema) - // - If table schema != view schema: include schema qualifier (cross-schema reference) - // - If no schema name in RangeVar: no qualifier (unqualified reference) - tableSchema := rangeVar.Schemaname - - // Only include schema qualifier for cross-schema references - if tableSchema != "" && tableSchema != f.viewSchema { - f.buffer.WriteString(tableSchema) - f.buffer.WriteString(".") - } - f.buffer.WriteString(rangeVar.Relname) - - if rangeVar.Alias != nil && rangeVar.Alias.Aliasname != "" { - f.buffer.WriteString(" ") - f.buffer.WriteString(rangeVar.Alias.Aliasname) - } -} - -// formatJoinExpr formats a JOIN expression -func (f *postgreSQLFormatter) formatJoinExpr(join *pg_query.JoinExpr) { - // Format left side - if join.Larg != nil { - f.formatFromItem(join.Larg) - } - - // Determine JOIN type keyword - var joinKeyword string - switch join.Jointype { - case pg_query.JoinType_JOIN_LEFT: - joinKeyword = "LEFT JOIN" - case pg_query.JoinType_JOIN_RIGHT: - joinKeyword = "RIGHT JOIN" - case pg_query.JoinType_JOIN_FULL: - joinKeyword = "FULL JOIN" - case pg_query.JoinType_JOIN_INNER: - // CROSS JOIN is represented as INNER JOIN with no quals (no ON condition) - if join.Quals == nil { - joinKeyword = "CROSS JOIN" - } else { - joinKeyword = "JOIN" - } - default: - joinKeyword = "JOIN" - } - - // Add JOIN keyword with proper indentation - f.buffer.WriteString("\n " + joinKeyword + " ") - - // Format right side - if join.Rarg != nil { - f.formatFromItem(join.Rarg) - } - - // Add ON condition (only if present, CROSS JOIN has no ON condition) - if join.Quals != nil { - f.buffer.WriteString(" ON ") - f.formatExpression(join.Quals) - } -} - -// formatRangeSubselect formats a subquery in FROM clause -func (f *postgreSQLFormatter) formatRangeSubselect(subselect *pg_query.RangeSubselect) { - // Save the current buffer state - savedBuffer := f.buffer.String() - tempBuffer := &strings.Builder{} - f.buffer = tempBuffer - - // Format the subquery - if selectStmt := subselect.Subquery.GetSelectStmt(); selectStmt != nil { - f.formatSelectStmt(selectStmt) - } - - // Get the formatted subquery and trim leading space - subqueryContent := strings.TrimPrefix(tempBuffer.String(), " ") - - // Restore original buffer and append formatted content - f.buffer = &strings.Builder{} - f.buffer.WriteString(savedBuffer) - f.buffer.WriteString("(") - f.buffer.WriteString(subqueryContent) - f.buffer.WriteString(")") - - if subselect.Alias != nil && subselect.Alias.Aliasname != "" { - f.buffer.WriteString(" ") - f.buffer.WriteString(subselect.Alias.Aliasname) - } -} - -// formatExpression formats a general expression -// -// NOTE: Two important expression types for array operations: -// 1. A_Expr: Appears when parsing SQL files directly (e.g., "value = ANY(ARRAY[...])") -// 2. ScalarArrayOpExpr: Appears when fetching view definitions from PostgreSQL via pg_get_viewdef() -// -// PostgreSQL internally converts "IN (...)" to "= ANY(ARRAY[...])" when storing views. -// When we fetch the view definition back via pg_get_viewdef(), it returns ScalarArrayOpExpr nodes. -// Both formatAExpr and formatScalarArrayOpExpr convert "= ANY" back to the cleaner "IN" syntax, -// while preserving other operators (>, <, <>) with ANY/ALL syntax. -func (f *postgreSQLFormatter) formatExpression(expr *pg_query.Node) { - switch { - case expr.GetColumnRef() != nil: - f.formatColumnRef(expr.GetColumnRef()) - case expr.GetAConst() != nil: - f.formatAConst(expr.GetAConst()) - case expr.GetAExpr() != nil: - f.formatAExpr(expr.GetAExpr()) - case expr.GetFuncCall() != nil: - f.formatFuncCall(expr.GetFuncCall()) - case expr.GetBoolExpr() != nil: - f.formatBoolExpr(expr.GetBoolExpr()) - case expr.GetTypeCast() != nil: - f.formatTypeCast(expr.GetTypeCast()) - case expr.GetCaseExpr() != nil: - f.formatCaseExpr(expr.GetCaseExpr()) - case expr.GetSubLink() != nil: - f.formatSubLink(expr.GetSubLink()) - case expr.GetCoalesceExpr() != nil: - f.formatCoalesceExpr(expr.GetCoalesceExpr()) - case expr.GetNullTest() != nil: - f.formatNullTest(expr.GetNullTest()) - case expr.GetScalarArrayOpExpr() != nil: - f.formatScalarArrayOpExpr(expr.GetScalarArrayOpExpr()) - case expr.GetAArrayExpr() != nil: - f.formatAArrayExpr(expr.GetAArrayExpr()) - default: - // Fallback to deparse for complex expressions - if deparseResult, err := f.deparseNode(expr); err == nil { - f.buffer.WriteString(deparseResult) - } - } -} - -// formatColumnRef formats a column reference -func (f *postgreSQLFormatter) formatColumnRef(col *pg_query.ColumnRef) { - for i, field := range col.Fields { - if i > 0 { - f.buffer.WriteString(".") - } - if str := field.GetString_(); str != nil { - f.buffer.WriteString(str.Sval) - } - } -} - -// formatAConst formats a constant value -func (f *postgreSQLFormatter) formatAConst(constant *pg_query.A_Const) { - // Check for NULL first - if constant.Isnull { - f.buffer.WriteString("NULL") - return - } - - switch val := constant.Val.(type) { - case *pg_query.A_Const_Sval: - f.buffer.WriteString("'") - f.buffer.WriteString(val.Sval.Sval) - f.buffer.WriteString("'") - case *pg_query.A_Const_Ival: - f.buffer.WriteString(strconv.FormatInt(int64(val.Ival.Ival), 10)) - case *pg_query.A_Const_Fval: - f.buffer.WriteString(val.Fval.Fval) - case *pg_query.A_Const_Boolval: - if val.Boolval.Boolval { - f.buffer.WriteString("true") - } else { - f.buffer.WriteString("false") - } - case *pg_query.A_Const_Bsval: - f.buffer.WriteString(val.Bsval.Bsval) - default: - // Fallback to deparse - if deparseResult, err := f.deparseNode(&pg_query.Node{Node: &pg_query.Node_AConst{AConst: constant}}); err == nil { - f.buffer.WriteString(deparseResult) - } - } -} - -// formatAExpr formats an A_Expr (binary/unary expressions) -func (f *postgreSQLFormatter) formatAExpr(expr *pg_query.A_Expr) { - // Handle AEXPR_OP_ANY and AEXPR_OP_ALL (e.g., "value > ANY(ARRAY[...])") - if expr.Kind == pg_query.A_Expr_Kind_AEXPR_OP_ANY || expr.Kind == pg_query.A_Expr_Kind_AEXPR_OP_ALL { - // Check if this is "= ANY" which can be converted to IN - isEqualityAny := expr.Kind == pg_query.A_Expr_Kind_AEXPR_OP_ANY && - len(expr.Name) == 1 && - expr.Name[0].GetString_() != nil && - expr.Name[0].GetString_().Sval == "=" - - if isEqualityAny && expr.Rexpr != nil { - if aArrayExpr := expr.Rexpr.GetAArrayExpr(); aArrayExpr != nil { - // Convert "column = ANY(ARRAY[...])" to "column IN (...)" - f.formatArrayAsIN(expr.Lexpr, aArrayExpr.Elements) - return - } - } - - // Format other ANY/ALL operations (>, <, <>, etc.) - // Format: () - if expr.Lexpr != nil { - f.formatExpression(expr.Lexpr) - } - - // Format operator - if len(expr.Name) > 0 { - f.buffer.WriteString(" ") - for i, nameNode := range expr.Name { - if i > 0 { - f.buffer.WriteString(".") - } - if str := nameNode.GetString_(); str != nil { - f.buffer.WriteString(str.Sval) - } - } - f.buffer.WriteString(" ") - } - - // Format ANY or ALL - if expr.Kind == pg_query.A_Expr_Kind_AEXPR_OP_ANY { - f.buffer.WriteString("ANY (") - } else { - f.buffer.WriteString("ALL (") - } - - // Format right operand - if expr.Rexpr != nil { - f.formatExpression(expr.Rexpr) - } - - f.buffer.WriteString(")") - return - } - - // Special case: Detect "column = ARRAY[...]" pattern and convert to "column IN (...)" - // This pattern appears when parsing view definitions from pg_get_viewdef() - if len(expr.Name) == 1 && expr.Rexpr != nil { - if str := expr.Name[0].GetString_(); str != nil && str.Sval == "=" { - if aArrayExpr := expr.Rexpr.GetAArrayExpr(); aArrayExpr != nil { - // Direct array comparison: column = ARRAY[...] → column IN (...) - f.formatArrayAsIN(expr.Lexpr, aArrayExpr.Elements) - return - } - } - } - - // Default formatting for other A_Expr cases - // Format left operand - if expr.Lexpr != nil { - f.formatExpression(expr.Lexpr) - } - - // Format operator - if len(expr.Name) > 0 { - f.buffer.WriteString(" ") - for i, nameNode := range expr.Name { - if i > 0 { - f.buffer.WriteString(".") - } - if str := nameNode.GetString_(); str != nil { - f.buffer.WriteString(str.Sval) - } - } - f.buffer.WriteString(" ") - } - - // Format right operand - if expr.Rexpr != nil { - f.formatExpression(expr.Rexpr) - } -} - -// formatFuncCall formats a function call -func (f *postgreSQLFormatter) formatFuncCall(funcCall *pg_query.FuncCall) { - // Format function name - for i, nameNode := range funcCall.Funcname { - if i > 0 { - f.buffer.WriteString(".") - } - if str := nameNode.GetString_(); str != nil { - f.buffer.WriteString(str.Sval) - } - } - - // Format arguments - f.buffer.WriteString("(") - - // Handle DISTINCT for aggregate functions - if funcCall.AggDistinct { - f.buffer.WriteString("DISTINCT ") - } - - // Handle aggregate functions with star (like COUNT(*)) - if funcCall.AggStar { - f.buffer.WriteString("*") - } else { - // Regular arguments - for i, arg := range funcCall.Args { - if i > 0 { - f.buffer.WriteString(", ") - } - f.formatExpression(arg) - } - } - f.buffer.WriteString(")") - - // Handle window functions (OVER clause) - if funcCall.Over != nil { - f.buffer.WriteString(" OVER (") - f.formatWindowDef(funcCall.Over) - f.buffer.WriteString(")") - } -} - -// formatBoolExpr formats boolean expressions (AND, OR, NOT) -func (f *postgreSQLFormatter) formatBoolExpr(boolExpr *pg_query.BoolExpr) { - switch boolExpr.Boolop { - case pg_query.BoolExprType_AND_EXPR: - for i, arg := range boolExpr.Args { - if i > 0 { - f.buffer.WriteString(" AND ") - } - f.formatExpression(arg) - } - case pg_query.BoolExprType_OR_EXPR: - for i, arg := range boolExpr.Args { - if i > 0 { - f.buffer.WriteString(" OR ") - } - f.formatExpression(arg) - } - case pg_query.BoolExprType_NOT_EXPR: - f.buffer.WriteString("NOT ") - if len(boolExpr.Args) > 0 { - f.formatExpression(boolExpr.Args[0]) - } - } -} - -// formatTypeCast formats a type cast expression -func (f *postgreSQLFormatter) formatTypeCast(typeCast *pg_query.TypeCast) { - // Check if this is a redundant cast that should be stripped for cleaner output - if f.isRedundantTypeCast(typeCast) { - // Just format the argument without the cast - if typeCast.Arg != nil { - f.formatExpression(typeCast.Arg) - } - return - } - - // Special handling for INTERVAL type casts - if typeCast.TypeName != nil && len(typeCast.TypeName.Names) > 0 { - // Get the type name (last element in the names array) - typeName := "" - if str := typeCast.TypeName.Names[len(typeCast.TypeName.Names)-1].GetString_(); str != nil { - typeName = str.Sval - } - - // Check if this is an interval cast with a string constant - if typeName == "interval" && typeCast.Arg != nil { - if aConst := typeCast.Arg.GetAConst(); aConst != nil { - if sval := aConst.GetSval(); sval != nil { - // Format as INTERVAL 'value' instead of 'value'::interval - f.buffer.WriteString("INTERVAL '") - f.buffer.WriteString(sval.Sval) - f.buffer.WriteString("'") - return - } - } - } - } - - // Default formatting for other type casts - if typeCast.Arg != nil { - f.formatExpression(typeCast.Arg) - } - - f.buffer.WriteString("::") - - if typeCast.TypeName != nil { - f.formatTypeName(typeCast.TypeName) - } -} - -// isRedundantTypeCast checks if a type cast is redundant and can be safely removed -// for cleaner view output. This includes: -// - Casts on string literals (e.g., 'value'::text, 'value'::varchar) -// - Casts on NULL (e.g., NULL::numeric) -// - Casts with pg_catalog schema qualifiers on basic types -// - Nested casts (e.g., 'value'::varchar::text) -func (f *postgreSQLFormatter) isRedundantTypeCast(typeCast *pg_query.TypeCast) bool { - if typeCast.Arg == nil || typeCast.TypeName == nil { - return false - } - - // Helper to check if we can find a constant at the bottom of nested casts - var findBaseConstant func(*pg_query.Node) *pg_query.A_Const - findBaseConstant = func(node *pg_query.Node) *pg_query.A_Const { - if aConst := node.GetAConst(); aConst != nil { - return aConst - } - // Check if this is a nested type cast - if nestedCast := node.GetTypeCast(); nestedCast != nil && nestedCast.Arg != nil { - return findBaseConstant(nestedCast.Arg) - } - return nil - } - - // Check if the argument is a constant value (possibly nested in casts) - if aConst := findBaseConstant(typeCast.Arg); aConst != nil { - // Get the type name to check if this is a text-like cast - typeName := "" - if len(typeCast.TypeName.Names) > 0 { - if len(typeCast.TypeName.Names) == 2 { - // Handle pg_catalog.typename - if schema := typeCast.TypeName.Names[0].GetString_(); schema != nil && schema.Sval == "pg_catalog" { - if typ := typeCast.TypeName.Names[1].GetString_(); typ != nil { - typeName = typ.Sval - } - } - } else if len(typeCast.TypeName.Names) == 1 { - if typ := typeCast.TypeName.Names[0].GetString_(); typ != nil { - typeName = typ.Sval - } - } - } - - // String literal casts to text-like types are redundant (e.g., 'text'::text, 'value'::varchar) - // But date/timestamp casts are NOT redundant (e.g., '2020-01-01'::date) - if aConst.GetSval() != nil { - // Only strip casts to text-like types - textLikeTypes := []string{"text", "varchar", "character varying", "char", "character", "bpchar"} - for _, t := range textLikeTypes { - if typeName == t { - return true - } - } - // Keep all other casts (date, timestamp, numeric, etc.) - return false - } - - // NULL casts are redundant (e.g., NULL::numeric → NULL) - if aConst.Isnull { - return true - } - } - - // Check if this is a redundant column cast (e.g., column::text where column is already text) - // For view formatting, we strip casts on column references to basic types - if typeCast.Arg.GetColumnRef() != nil && typeCast.TypeName != nil { - // Get the type name - if len(typeCast.TypeName.Names) > 0 { - typeName := "" - // Check if it's a pg_catalog qualified type - if len(typeCast.TypeName.Names) == 2 { - if schema := typeCast.TypeName.Names[0].GetString_(); schema != nil && schema.Sval == "pg_catalog" { - if typ := typeCast.TypeName.Names[1].GetString_(); typ != nil { - typeName = typ.Sval - } - } - } else if len(typeCast.TypeName.Names) == 1 { - if typ := typeCast.TypeName.Names[0].GetString_(); typ != nil { - typeName = typ.Sval - } - } - - // Common text-like types that are often redundantly cast - textLikeTypes := []string{"text", "varchar", "character varying", "char", "character", "bpchar"} - for _, t := range textLikeTypes { - if typeName == t { - return true - } - } - } - } - - return false -} - -// formatTypeName formats a type name -func (f *postgreSQLFormatter) formatTypeName(typeName *pg_query.TypeName) { - for i, nameNode := range typeName.Names { - if i > 0 { - f.buffer.WriteString(".") - } - if str := nameNode.GetString_(); str != nil { - f.buffer.WriteString(str.Sval) - } - } -} - -// formatGroupByClause formats GROUP BY clause -func (f *postgreSQLFormatter) formatGroupByClause(groupBy []*pg_query.Node) { - for i, item := range groupBy { - if i > 0 { - f.buffer.WriteString(", ") - } - f.formatExpression(item) - } -} - -// formatOrderByClause formats ORDER BY clause -func (f *postgreSQLFormatter) formatOrderByClause(orderBy []*pg_query.Node) { - for i, item := range orderBy { - if i > 0 { - f.buffer.WriteString(", ") - } - if sortBy := item.GetSortBy(); sortBy != nil { - f.formatExpression(sortBy.Node) - if sortBy.SortbyDir == pg_query.SortByDir_SORTBY_DESC { - f.buffer.WriteString(" DESC") - } - } - } -} - -// deparseNode is a helper to deparse individual nodes as fallback -func (f *postgreSQLFormatter) deparseNode(node *pg_query.Node) (string, error) { - stmt := &pg_query.RawStmt{Stmt: node} - parseResult := &pg_query.ParseResult{Stmts: []*pg_query.RawStmt{stmt}} - return pg_query.Deparse(parseResult) -} - -// formatCaseExpr formats CASE expressions -func (f *postgreSQLFormatter) formatCaseExpr(caseExpr *pg_query.CaseExpr) { - f.buffer.WriteString("CASE") - - // CASE with an argument (CASE expr WHEN ...) - if caseExpr.Arg != nil { - f.buffer.WriteString(" ") - f.formatExpression(caseExpr.Arg) - } - - // Format WHEN clauses - for _, whenClause := range caseExpr.Args { - if when := whenClause.GetCaseWhen(); when != nil { - f.buffer.WriteString(" WHEN ") - f.formatExpression(when.Expr) - f.buffer.WriteString(" THEN ") - // Format result expression - redundant type casts will be stripped by formatTypeCast - f.formatExpression(when.Result) - } - } - - // Format ELSE clause - redundant type casts will be stripped by formatTypeCast - if caseExpr.Defresult != nil { - f.buffer.WriteString(" ELSE ") - f.formatExpression(caseExpr.Defresult) - } - - f.buffer.WriteString(" END") -} - -// formatCoalesceExpr formats COALESCE expressions -func (f *postgreSQLFormatter) formatCoalesceExpr(coalesceExpr *pg_query.CoalesceExpr) { - f.buffer.WriteString("COALESCE(") - - // Format arguments - for i, arg := range coalesceExpr.Args { - if i > 0 { - f.buffer.WriteString(", ") - } - f.formatExpression(arg) - } - - f.buffer.WriteString(")") -} - -// formatWindowDef formats window definition (OVER clause) -func (f *postgreSQLFormatter) formatWindowDef(windowDef *pg_query.WindowDef) { - needsSpace := false - - // PARTITION BY clause - if len(windowDef.PartitionClause) > 0 { - f.buffer.WriteString("PARTITION BY ") - for i, partExpr := range windowDef.PartitionClause { - if i > 0 { - f.buffer.WriteString(", ") - } - f.formatExpression(partExpr) - } - needsSpace = true - } - - // ORDER BY clause - if len(windowDef.OrderClause) > 0 { - if needsSpace { - f.buffer.WriteString(" ") - } - f.buffer.WriteString("ORDER BY ") - for i, sortExpr := range windowDef.OrderClause { - if i > 0 { - f.buffer.WriteString(", ") - } - if sortBy := sortExpr.GetSortBy(); sortBy != nil { - f.formatExpression(sortBy.Node) - if sortBy.SortbyDir == pg_query.SortByDir_SORTBY_DESC { - f.buffer.WriteString(" DESC") - } - } - } - } -} - -// formatSubLink formats subquery expressions (IN, EXISTS, etc.) -func (f *postgreSQLFormatter) formatSubLink(subLink *pg_query.SubLink) { - // For now, use deparse as fallback - // This handles complex subquery expressions that need special formatting - if deparseResult, err := f.deparseNode(&pg_query.Node{Node: &pg_query.Node_SubLink{SubLink: subLink}}); err == nil { - f.buffer.WriteString(deparseResult) - } -} - -// formatNullTest formats NULL test expressions (IS NULL, IS NOT NULL) -func (f *postgreSQLFormatter) formatNullTest(nullTest *pg_query.NullTest) { - // Format the argument expression - if nullTest.Arg != nil { - f.formatExpression(nullTest.Arg) - } - - // Add the appropriate NULL test operator - switch nullTest.Nulltesttype { - case pg_query.NullTestType_IS_NULL: - f.buffer.WriteString(" IS NULL") - case pg_query.NullTestType_IS_NOT_NULL: - f.buffer.WriteString(" IS NOT NULL") - } -} - -// formatAArrayExpr formats array expressions (ARRAY[...]) -func (f *postgreSQLFormatter) formatAArrayExpr(arrayExpr *pg_query.A_ArrayExpr) { - f.buffer.WriteString("ARRAY[") - for i, elem := range arrayExpr.Elements { - if i > 0 { - f.buffer.WriteString(", ") - } - f.formatExpression(elem) - } - f.buffer.WriteString("]") -} - -// formatArrayAsIN is a helper to format "column IN (values)" syntax -// Used by both formatAExpr and formatScalarArrayOpExpr to convert "= ANY(ARRAY[...])" to "IN (...)" -func (f *postgreSQLFormatter) formatArrayAsIN(leftExpr *pg_query.Node, arrayElements []*pg_query.Node) { - // Format left side (the column/expression) - preserves type casts for canonical representation - f.formatExpression(leftExpr) - - f.buffer.WriteString(" IN (") - - // Format array elements as comma-separated list - preserves type casts for canonical representation - for i, elem := range arrayElements { - if i > 0 { - f.buffer.WriteString(", ") - } - f.formatExpression(elem) - } - - f.buffer.WriteString(")") -} - -// formatScalarArrayOpExpr formats ScalarArrayOpExpr nodes (PostgreSQL's internal array operation representation). -// -// CONTEXT: This function handles a narrow case - formatting view definitions fetched from PostgreSQL -// via pg_get_viewdef(). When PostgreSQL stores views, it converts "IN (...)" to "= ANY(ARRAY[...])" -// internally. When we fetch views back, we get ScalarArrayOpExpr nodes instead of the original A_Expr. -// -// This function converts "= ANY" back to the cleaner "IN (...)" syntax, while preserving -// other operators (>, <, <>, etc.) with their original ANY/ALL syntax. -// -// Example transformations: -// - "value = ANY (ARRAY[1, 2, 3])" → "value IN (1, 2, 3)" (converted) -// - "value > ANY (ARRAY[1, 2, 3])" → "value > ANY (ARRAY[1, 2, 3])" (preserved) -// - "value = ALL (ARRAY[1, 2, 3])" → "value = ALL (ARRAY[1, 2, 3])" (preserved) -func (f *postgreSQLFormatter) formatScalarArrayOpExpr(arrayOp *pg_query.ScalarArrayOpExpr) { - // Validate Args structure - if len(arrayOp.Args) != 2 { - // Malformed expression, use deparse fallback - if deparseResult, err := f.deparseNode(&pg_query.Node{Node: &pg_query.Node_ScalarArrayOpExpr{ScalarArrayOpExpr: arrayOp}}); err == nil { - f.buffer.WriteString(deparseResult) - } - return - } - - // Deparse once to extract the operator name - // We need to deparse because ScalarArrayOpExpr doesn't directly expose the operator name - deparsed, err := f.deparseNode(&pg_query.Node{Node: &pg_query.Node_ScalarArrayOpExpr{ScalarArrayOpExpr: arrayOp}}) - if err != nil { - // If deparse fails, silently return (shouldn't happen in practice) - return - } - - // Extract operator from deparsed string (e.g., "value > ANY (...)" → ">") - opName := extractOperator(deparsed) - - // Check if this is "= ANY" which can be converted to cleaner "IN" syntax - // - UseOr == true means ANY (disjunction/OR semantics) - // - UseOr == false means ALL (conjunction/AND semantics) - // - Only convert equality with ANY, not other operators or ALL - if arrayOp.UseOr && opName == "=" { - // Convert "column = ANY (ARRAY[...])" → "column IN (...)" - if arrayExpr := arrayOp.Args[1].GetArrayExpr(); arrayExpr != nil { - // Use the shared helper to format as IN syntax - f.formatArrayAsIN(arrayOp.Args[0], arrayExpr.Elements) - return - } - } - - // For all other operations (<> ANY, > ANY, < ANY, = ALL, etc.), preserve original syntax - // Format: () - - // Format left side (the column/expression) - f.formatExpression(arrayOp.Args[0]) - - // Format operator - if opName != "" { - f.buffer.WriteString(" ") - f.buffer.WriteString(opName) - f.buffer.WriteString(" ") - } else { - // Shouldn't happen, but provide fallback - f.buffer.WriteString(" ") - } - - // Format ANY or ALL keyword - if arrayOp.UseOr { - f.buffer.WriteString("ANY (") - } else { - f.buffer.WriteString("ALL (") - } - - // Format right side (the array expression) - f.formatExpression(arrayOp.Args[1]) - - f.buffer.WriteString(")") -} - -// extractOperator extracts the operator from a deparsed ScalarArrayOpExpr string -// e.g., "value > ANY (ARRAY[...])" -> ">" -func extractOperator(deparsed string) string { - // Look for pattern: ANY/ALL - anyIdx := strings.Index(deparsed, " ANY") - allIdx := strings.Index(deparsed, " ALL") - - var cutoff int - if anyIdx >= 0 && (allIdx < 0 || anyIdx < allIdx) { - cutoff = anyIdx - } else if allIdx >= 0 { - cutoff = allIdx - } else { - return "" - } - - // Work backwards from cutoff to find the operator - // Operators can be: =, <>, !=, <, >, <=, >=, etc. - substr := deparsed[:cutoff] - - // Common operators in reverse order of length (to match longest first) - operators := []string{"<>", "!=", "<=", ">=", "=", "<", ">", "~", "!~", "~~", "!~~"} - - for _, op := range operators { - // Look for " " pattern - searchPattern := " " + op + " " - if idx := strings.LastIndex(substr, searchPattern); idx >= 0 { - return op - } - } - - return "" -} diff --git a/ir/normalize.go b/ir/normalize.go index b54e2255..98ab062e 100644 --- a/ir/normalize.go +++ b/ir/normalize.go @@ -6,24 +6,17 @@ import ( "sort" "strings" "unicode" - - pg_query "github.com/pganalyze/pg_query_go/v6" ) -// normalizeIR normalizes the IR representation from the inspector -// -// Historical note: This normalization was originally needed to reconcile differences -// between parsed SQL (from parser.go) and database-inspected schema (from inspector.go). -// Since the parser was removed in favor of the embedded-postgres approach (both desired -// and current states now come from database inspection), much of this normalization is -// no longer necessary and can be simplified in a future refactor. +// normalizeIR normalizes the IR representation from the inspector. // -// Current normalization still handles: -// - PostgreSQL version differences (PG 14 vs 17 format variations) -// - Type name mappings (internal PostgreSQL types → standard SQL types) -// - View definition formatting across different versions +// 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: // -// TODO: Simplify this file to remove parser-specific normalizations +// - 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 @@ -222,44 +215,18 @@ func normalizePolicyExpression(expr string) string { return expr } -// normalizeView normalizes view definition +// normalizeView normalizes view definition. +// +// Since both desired state (from embedded postgres) and current state (from target database) +// now come from the same PostgreSQL version via pg_get_viewdef(), they produce identical +// output and no normalization is needed. func normalizeView(view *View) { if view == nil { return } - view.Definition = normalizeViewDefinition(view.Definition, view.Schema) -} - -// normalizeViewDefinition normalizes view SQL definition for consistent comparison -// across different PostgreSQL versions. -// -// PostgreSQL versions produce different pg_get_viewdef() output: -// - PostgreSQL 15: Includes table qualifiers → "dept_emp.emp_no, max(dept_emp.from_date)" -// - PostgreSQL 16+: Omits unnecessary qualifiers → "emp_no, max(from_date)" -// -// This function removes unnecessary table qualifiers from column references when unambiguous -// to ensure consistent comparison between Inspector (database) and Parser (SQL files). -func normalizeViewDefinition(definition string, viewSchema string) string { - if definition == "" { - return definition - } - - // Parse the view definition to get AST and remove unnecessary table qualifiers - normalized, err := removeUnnecessaryTableQualifiers(definition) - if err != nil { - // If parsing fails, use the original definition - normalized = definition - } - - // Apply all AST-based normalizations in one pass to avoid re-parsing - // This includes: - // 1. Converting PostgreSQL's "= ANY (ARRAY[...])" to "IN (...)" - // 2. Normalizing ORDER BY clauses to use aliases - // 3. Applying proper schema qualification rules for table references - normalized = normalizeViewWithAST(normalized, viewSchema) - - return normalized + // No normalization needed - both IR forms come from database inspection + // at the same PostgreSQL version, so pg_get_viewdef() output is identical } // normalizeFunction normalizes function signature and definition @@ -829,8 +796,11 @@ func normalizeConstraint(constraint *Constraint) { } } -// normalizeCheckClause converts PostgreSQL's normalized CHECK expressions to parser format -// Uses pg_query to parse and deparse for consistent normalization +// normalizeCheckClause normalizes CHECK constraint expressions. +// +// Since both desired state (from embedded postgres) and current state (from target database) +// now come from the same PostgreSQL version via pg_get_constraintdef(), they produce identical +// output. We only need basic cleanup for PostgreSQL internal representations. func normalizeCheckClause(checkClause string) string { // Strip " NOT VALID" suffix if present (mimicking pg_dump behavior) // PostgreSQL's pg_get_constraintdef may include NOT VALID at the end, @@ -855,71 +825,13 @@ func normalizeCheckClause(checkClause string) string { } } - // Apply legacy normalizations for PostgreSQL-specific patterns + // Apply basic normalizations for PostgreSQL internal representations + // (e.g., "~~ " to "LIKE", "= ANY (ARRAY[...])" to "IN (...)") normalizedClause := applyLegacyCheckNormalizations(clause) - // Try to normalize using pg_query parse/deparse for consistent formatting - pgNormalizedClause := normalizeExpressionWithPgQuery(normalizedClause) - if pgNormalizedClause != "" { - return fmt.Sprintf("CHECK (%s)", pgNormalizedClause) - } - - // Fallback to legacy normalization result if pg_query fails return fmt.Sprintf("CHECK (%s)", normalizedClause) } -// normalizeExpressionWithPgQuery normalizes an expression using PostgreSQL's parser -func normalizeExpressionWithPgQuery(expr string) string { - // Create a dummy SELECT statement with the expression to parse it - dummySQL := fmt.Sprintf("SELECT %s", expr) - - parseResult, err := pg_query.Parse(dummySQL) - if err != nil { - // If parsing fails, return empty string to trigger fallback - return "" - } - - // Deparse to get normalized form - deparsed, err := pg_query.Deparse(parseResult) - if err != nil { - return "" - } - - // Extract the expression from "SELECT expr" format - if after, found := strings.CutPrefix(deparsed, "SELECT "); found { - normalized := strings.TrimSpace(after) - // Remove redundant numeric type casts from literals - normalized = removeRedundantNumericCasts(normalized) - return normalized - } - - return "" -} - -// removeRedundantNumericCasts removes type casts from numeric literals -// e.g., "0::numeric" -> "0", "123::integer" -> "123" -func removeRedundantNumericCasts(expr string) string { - // Pattern: number::numeric_type -> number - // This handles: 0::numeric, 123::integer, 45.67::numeric, etc. - patterns := []string{ - `(\d+(?:\.\d+)?)::numeric\b`, - `(\d+)::integer\b`, - `(\d+)::bigint\b`, - `(\d+)::smallint\b`, - `(\d+(?:\.\d+)?)::decimal\b`, - `(\d+(?:\.\d+)?)::real\b`, - `(\d+(?:\.\d+)?)::double\s+precision\b`, - } - - result := expr - for _, pattern := range patterns { - re := regexp.MustCompile(pattern) - result = re.ReplaceAllString(result, "$1") - } - - return result -} - // applyLegacyCheckNormalizations applies the existing normalization patterns func applyLegacyCheckNormalizations(clause string) string { // Convert PostgreSQL's "= ANY (ARRAY[...])" format to "IN (...)" format @@ -944,66 +856,6 @@ func applyLegacyCheckNormalizations(clause string) string { return clause } -// removeUnnecessaryTableQualifiers removes table qualifiers from column references -// when they are unambiguous (i.e., when there's only one table in the FROM clause) -func removeUnnecessaryTableQualifiers(definition string) (string, error) { - // Parse the SQL definition to validate and extract table information - parseResult, err := pg_query.Parse(definition) - if err != nil { - return definition, err - } - - if len(parseResult.Stmts) == 0 { - return definition, fmt.Errorf("no statements found") - } - - // Get the first statement (should be a SELECT) - stmt := parseResult.Stmts[0] - selectStmt := stmt.Stmt.GetSelectStmt() - if selectStmt == nil { - return definition, fmt.Errorf("not a SELECT statement") - } - - // Extract table names from FROM clause - tables := extractTablesFromFromClause(selectStmt.FromClause) - - // If there's more than one table, keep qualifiers as they might be necessary - if len(tables) != 1 { - return definition, fmt.Errorf("multiple tables found, keeping original") - } - - tableName := tables[0] - - // Use regex-based replacement to preserve formatting while removing qualifiers - // This approach maintains the original PostgreSQL pretty-printing format - qualifierRegex := regexp.MustCompile(`\b` + regexp.QuoteMeta(tableName) + `\.([a-zA-Z_][a-zA-Z0-9_]*)\b`) - normalized := qualifierRegex.ReplaceAllString(definition, "$1") - - return normalized, nil -} - -// extractTablesFromFromClause extracts table names or aliases from the FROM clause -func extractTablesFromFromClause(fromClause []*pg_query.Node) []string { - var tables []string - - for _, fromItem := range fromClause { - if rangeVar := fromItem.GetRangeVar(); rangeVar != nil { - if rangeVar.Relname != "" { - // Use alias if present, otherwise use the table name - if rangeVar.Alias != nil && rangeVar.Alias.Aliasname != "" { - tables = append(tables, rangeVar.Alias.Aliasname) - } else { - tables = append(tables, rangeVar.Relname) - } - } - } - // TODO: Handle other FROM clause types like JOINs, subqueries, etc. - // For now, we only handle simple table references - } - - return tables -} - // convertAnyArrayToIn converts PostgreSQL's "column = ANY (ARRAY[...])" format // to the more readable "column IN (...)" format func convertAnyArrayToIn(expr string) string { @@ -1042,141 +894,3 @@ func convertAnyArrayToIn(expr string) string { return fmt.Sprintf("%s IN (%s)", columnName, strings.Join(cleanValues, ", ")) } -// normalizeViewWithAST applies all AST-based normalizations in a single pass -// This includes converting "= ANY (ARRAY[...])" to "IN (...)" and normalizing ORDER BY -func normalizeViewWithAST(definition string, viewSchema string) string { - if definition == "" { - return definition - } - - // Parse the view definition - parseResult, err := pg_query.Parse(definition) - if err != nil { - return definition - } - - if len(parseResult.Stmts) == 0 { - return definition - } - - stmt := parseResult.Stmts[0] - selectStmt := stmt.Stmt.GetSelectStmt() - if selectStmt == nil { - return definition - } - - // Step 1: Normalize ORDER BY clauses (modify AST if needed) - if len(selectStmt.SortClause) > 0 { - // Build reverse alias map (expression -> alias) from target list - exprToAliasMap := buildExpressionToAliasMap(selectStmt.TargetList) - - // Transform ORDER BY clauses: replace complex expressions with aliases when possible - for _, sortItem := range selectStmt.SortClause { - if sortBy := sortItem.GetSortBy(); sortBy != nil { - normalizeOrderByExpressionToAlias(sortBy, exprToAliasMap) - } - } - } - - // Step 2: Check if we need to use custom formatter for normalization - // Use custom formatter only if the view definition contains "= ANY" (needs conversion to IN) - // For other cases, preserve the original definition to avoid breaking complex expressions - if strings.Contains(definition, "= ANY") { - // Use custom formatter to normalize the query - // The formatter will handle: - // - Converting "= ANY (ARRAY[...])" to "IN (...)" - // - Proper formatting of all expressions - // - Applying proper schema qualification rules - formatter := newPostgreSQLFormatter(viewSchema) - formatted := formatter.formatQueryNode(stmt.Stmt) - if formatted != "" { - return formatted - } - } - - return definition -} - -// buildExpressionToAliasMap creates a map from expression fingerprints to their aliases -// This helps convert ORDER BY expressions back to column aliases -func buildExpressionToAliasMap(targetList []*pg_query.Node) map[string]string { - exprToAlias := make(map[string]string) - - for _, target := range targetList { - if resTarget := target.GetResTarget(); resTarget != nil && resTarget.Name != "" && resTarget.Val != nil { - // Create a fingerprint of the expression by deparsing it - if fingerprint := getExpressionFingerprint(resTarget.Val); fingerprint != "" { - exprToAlias[fingerprint] = resTarget.Name - } - } - } - - return exprToAlias -} - -// normalizeOrderByExpressionToAlias converts ORDER BY expressions back to aliases when possible -// Returns true if the expression was modified -func normalizeOrderByExpressionToAlias(sortBy *pg_query.SortBy, exprToAliasMap map[string]string) bool { - if sortBy.Node == nil { - return false - } - - // Get the fingerprint of the current ORDER BY expression - fingerprint := getExpressionFingerprint(sortBy.Node) - if fingerprint == "" { - return false - } - - // Check if this expression matches one of our aliased expressions - if alias, exists := exprToAliasMap[fingerprint]; exists { - // Replace the complex expression with a simple ColumnRef to the alias - sortBy.Node = &pg_query.Node{ - Node: &pg_query.Node_ColumnRef{ - ColumnRef: &pg_query.ColumnRef{ - Fields: []*pg_query.Node{{ - Node: &pg_query.Node_String_{ - String_: &pg_query.String{Sval: alias}, - }, - }}, - }, - }, - } - return true - } - - return false -} - -// getExpressionFingerprint creates a normalized fingerprint of an expression -// This is used to match expressions between SELECT list and ORDER BY -func getExpressionFingerprint(expr *pg_query.Node) string { - if expr == nil { - return "" - } - - // Create a temporary SELECT statement with just this expression to deparse it - tempSelect := &pg_query.SelectStmt{ - TargetList: []*pg_query.Node{{ - Node: &pg_query.Node_ResTarget{ - ResTarget: &pg_query.ResTarget{Val: expr}, - }, - }}, - } - tempResult := &pg_query.ParseResult{ - Stmts: []*pg_query.RawStmt{{ - Stmt: &pg_query.Node{ - Node: &pg_query.Node_SelectStmt{SelectStmt: tempSelect}, - }, - }}, - } - - if deparsed, err := pg_query.Deparse(tempResult); err == nil { - // Extract just the expression part from "SELECT expression" - if expr, found := strings.CutPrefix(deparsed, "SELECT "); found { - // Normalize the fingerprint by removing extra whitespace and lowercasing - return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(expr), " ", "")) - } - } - - return "" -} diff --git a/testdata/diff/create_table/add_table_no_online_rewrite/diff.sql b/testdata/diff/create_table/add_table_no_online_rewrite/diff.sql index 39a990ad..26c0c0f9 100644 --- a/testdata/diff/create_table/add_table_no_online_rewrite/diff.sql +++ b/testdata/diff/create_table/add_table_no_online_rewrite/diff.sql @@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS departments ( created_at timestamp DEFAULT now(), CONSTRAINT departments_pkey PRIMARY KEY (id), CONSTRAINT departments_company_id_fkey FOREIGN KEY (company_id) REFERENCES companies (id), - CONSTRAINT departments_budget_check CHECK (budget > 0) + CONSTRAINT departments_budget_check CHECK (budget > 0::numeric) ); CREATE INDEX IF NOT EXISTS idx_departments_name ON departments (name); diff --git a/testdata/diff/create_table/add_table_no_online_rewrite/plan.json b/testdata/diff/create_table/add_table_no_online_rewrite/plan.json index 13a09aee..d02f5e6a 100644 --- a/testdata/diff/create_table/add_table_no_online_rewrite/plan.json +++ b/testdata/diff/create_table/add_table_no_online_rewrite/plan.json @@ -15,7 +15,7 @@ "path": "public.companies" }, { - "sql": "CREATE TABLE IF NOT EXISTS departments (\n id integer,\n name text NOT NULL,\n company_id integer NOT NULL,\n budget numeric(10,2),\n created_at timestamp DEFAULT now(),\n CONSTRAINT departments_pkey PRIMARY KEY (id),\n CONSTRAINT departments_company_id_fkey FOREIGN KEY (company_id) REFERENCES companies (id),\n CONSTRAINT departments_budget_check CHECK (budget > 0)\n);", + "sql": "CREATE TABLE IF NOT EXISTS departments (\n id integer,\n name text NOT NULL,\n company_id integer NOT NULL,\n budget numeric(10,2),\n created_at timestamp DEFAULT now(),\n CONSTRAINT departments_pkey PRIMARY KEY (id),\n CONSTRAINT departments_company_id_fkey FOREIGN KEY (company_id) REFERENCES companies (id),\n CONSTRAINT departments_budget_check CHECK (budget > 0::numeric)\n);", "type": "table", "operation": "create", "path": "public.departments" diff --git a/testdata/diff/create_table/add_table_no_online_rewrite/plan.sql b/testdata/diff/create_table/add_table_no_online_rewrite/plan.sql index 39a990ad..26c0c0f9 100644 --- a/testdata/diff/create_table/add_table_no_online_rewrite/plan.sql +++ b/testdata/diff/create_table/add_table_no_online_rewrite/plan.sql @@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS departments ( created_at timestamp DEFAULT now(), CONSTRAINT departments_pkey PRIMARY KEY (id), CONSTRAINT departments_company_id_fkey FOREIGN KEY (company_id) REFERENCES companies (id), - CONSTRAINT departments_budget_check CHECK (budget > 0) + CONSTRAINT departments_budget_check CHECK (budget > 0::numeric) ); CREATE INDEX IF NOT EXISTS idx_departments_name ON departments (name); diff --git a/testdata/diff/create_table/add_table_no_online_rewrite/plan.txt b/testdata/diff/create_table/add_table_no_online_rewrite/plan.txt index 3ecece24..ed24cff1 100644 --- a/testdata/diff/create_table/add_table_no_online_rewrite/plan.txt +++ b/testdata/diff/create_table/add_table_no_online_rewrite/plan.txt @@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS departments ( created_at timestamp DEFAULT now(), CONSTRAINT departments_pkey PRIMARY KEY (id), CONSTRAINT departments_company_id_fkey FOREIGN KEY (company_id) REFERENCES companies (id), - CONSTRAINT departments_budget_check CHECK (budget > 0) + CONSTRAINT departments_budget_check CHECK (budget > 0::numeric) ); CREATE INDEX IF NOT EXISTS idx_departments_name ON departments (name); diff --git a/testdata/diff/create_view/add_view/diff.sql b/testdata/diff/create_view/add_view/diff.sql index d207f857..f0287391 100644 --- a/testdata/diff/create_view/add_view/diff.sql +++ b/testdata/diff/create_view/add_view/diff.sql @@ -1,12 +1,23 @@ CREATE OR REPLACE VIEW array_operators_view AS SELECT id, priority, - CASE WHEN priority IN (10, 20, 30) THEN 'matched' ELSE 'not_matched' END AS equal_any_test, - CASE WHEN priority > ANY (ARRAY[10, 20, 30]) THEN 'high' ELSE 'low' END AS greater_any_test, - CASE WHEN priority < ANY (ARRAY[5, 15, 25]) THEN 'found_lower' ELSE 'all_higher' END AS less_any_test, - CASE WHEN priority <> ANY (ARRAY[1, 2, 3]) THEN 'different' ELSE 'same' END AS not_equal_any_test + CASE + WHEN priority = ANY (ARRAY[10, 20, 30]) THEN 'matched'::text + ELSE 'not_matched'::text + END AS equal_any_test, + CASE + WHEN priority > ANY (ARRAY[10, 20, 30]) THEN 'high'::text + ELSE 'low'::text + END AS greater_any_test, + CASE + WHEN priority < ANY (ARRAY[5, 15, 25]) THEN 'found_lower'::text + ELSE 'all_higher'::text + END AS less_any_test, + CASE + WHEN priority <> ANY (ARRAY[1, 2, 3]) THEN 'different'::text + ELSE 'same'::text + END AS not_equal_any_test FROM employees; - CREATE OR REPLACE VIEW cte_with_case_view AS WITH monthly_stats AS ( SELECT date_trunc('month'::text, CURRENT_DATE - ((n.n || ' months'::text)::interval)) AS month_start, @@ -39,7 +50,6 @@ CREATE OR REPLACE VIEW cte_with_case_view AS CROSS JOIN departments d LEFT JOIN employee_summary es ON d.id = es.department_id ORDER BY ms.month_start DESC, d.name; - CREATE OR REPLACE VIEW nullif_functions_view AS SELECT e.id, e.name AS employee_name, @@ -57,7 +67,6 @@ CREATE OR REPLACE VIEW nullif_functions_view AS FROM employees e JOIN departments d USING (id) WHERE e.priority > 0; - CREATE OR REPLACE VIEW text_search_view AS SELECT id, COALESCE((first_name::text || ' '::text) || last_name::text, 'Anonymous'::text) AS display_name, @@ -66,7 +75,6 @@ CREATE OR REPLACE VIEW text_search_view AS to_tsvector('english'::regconfig, (((COALESCE(first_name, ''::character varying)::text || ' '::text) || COALESCE(last_name, ''::character varying)::text) || ' '::text) || COALESCE(bio, ''::text)) AS search_vector FROM employees WHERE status::text = 'active'::text; - CREATE OR REPLACE VIEW union_subquery_view AS SELECT id, name, diff --git a/testdata/diff/create_view/add_view/plan.json b/testdata/diff/create_view/add_view/plan.json index a32c9210..acdb33c3 100644 --- a/testdata/diff/create_view/add_view/plan.json +++ b/testdata/diff/create_view/add_view/plan.json @@ -9,7 +9,7 @@ { "steps": [ { - "sql": "CREATE OR REPLACE VIEW array_operators_view AS\n SELECT id,\n priority,\n CASE WHEN priority IN (10, 20, 30) THEN 'matched' ELSE 'not_matched' END AS equal_any_test,\n CASE WHEN priority > ANY (ARRAY[10, 20, 30]) THEN 'high' ELSE 'low' END AS greater_any_test,\n CASE WHEN priority < ANY (ARRAY[5, 15, 25]) THEN 'found_lower' ELSE 'all_higher' END AS less_any_test,\n CASE WHEN priority <> ANY (ARRAY[1, 2, 3]) THEN 'different' ELSE 'same' END AS not_equal_any_test\n FROM employees;", + "sql": "CREATE OR REPLACE VIEW array_operators_view AS\n SELECT id,\n priority,\n CASE\n WHEN priority = ANY (ARRAY[10, 20, 30]) THEN 'matched'::text\n ELSE 'not_matched'::text\n END AS equal_any_test,\n CASE\n WHEN priority > ANY (ARRAY[10, 20, 30]) THEN 'high'::text\n ELSE 'low'::text\n END AS greater_any_test,\n CASE\n WHEN priority < ANY (ARRAY[5, 15, 25]) THEN 'found_lower'::text\n ELSE 'all_higher'::text\n END AS less_any_test,\n CASE\n WHEN priority <> ANY (ARRAY[1, 2, 3]) THEN 'different'::text\n ELSE 'same'::text\n END AS not_equal_any_test\n FROM employees;", "type": "view", "operation": "create", "path": "public.array_operators_view" diff --git a/testdata/diff/create_view/add_view/plan.sql b/testdata/diff/create_view/add_view/plan.sql index d207f857..f11a9ba3 100644 --- a/testdata/diff/create_view/add_view/plan.sql +++ b/testdata/diff/create_view/add_view/plan.sql @@ -1,10 +1,22 @@ CREATE OR REPLACE VIEW array_operators_view AS SELECT id, priority, - CASE WHEN priority IN (10, 20, 30) THEN 'matched' ELSE 'not_matched' END AS equal_any_test, - CASE WHEN priority > ANY (ARRAY[10, 20, 30]) THEN 'high' ELSE 'low' END AS greater_any_test, - CASE WHEN priority < ANY (ARRAY[5, 15, 25]) THEN 'found_lower' ELSE 'all_higher' END AS less_any_test, - CASE WHEN priority <> ANY (ARRAY[1, 2, 3]) THEN 'different' ELSE 'same' END AS not_equal_any_test + CASE + WHEN priority = ANY (ARRAY[10, 20, 30]) THEN 'matched'::text + ELSE 'not_matched'::text + END AS equal_any_test, + CASE + WHEN priority > ANY (ARRAY[10, 20, 30]) THEN 'high'::text + ELSE 'low'::text + END AS greater_any_test, + CASE + WHEN priority < ANY (ARRAY[5, 15, 25]) THEN 'found_lower'::text + ELSE 'all_higher'::text + END AS less_any_test, + CASE + WHEN priority <> ANY (ARRAY[1, 2, 3]) THEN 'different'::text + ELSE 'same'::text + END AS not_equal_any_test FROM employees; CREATE OR REPLACE VIEW cte_with_case_view AS diff --git a/testdata/diff/create_view/add_view/plan.txt b/testdata/diff/create_view/add_view/plan.txt index fe2b1cca..d34a2aae 100644 --- a/testdata/diff/create_view/add_view/plan.txt +++ b/testdata/diff/create_view/add_view/plan.txt @@ -16,10 +16,22 @@ DDL to be executed: CREATE OR REPLACE VIEW array_operators_view AS SELECT id, priority, - CASE WHEN priority IN (10, 20, 30) THEN 'matched' ELSE 'not_matched' END AS equal_any_test, - CASE WHEN priority > ANY (ARRAY[10, 20, 30]) THEN 'high' ELSE 'low' END AS greater_any_test, - CASE WHEN priority < ANY (ARRAY[5, 15, 25]) THEN 'found_lower' ELSE 'all_higher' END AS less_any_test, - CASE WHEN priority <> ANY (ARRAY[1, 2, 3]) THEN 'different' ELSE 'same' END AS not_equal_any_test + CASE + WHEN priority = ANY (ARRAY[10, 20, 30]) THEN 'matched'::text + ELSE 'not_matched'::text + END AS equal_any_test, + CASE + WHEN priority > ANY (ARRAY[10, 20, 30]) THEN 'high'::text + ELSE 'low'::text + END AS greater_any_test, + CASE + WHEN priority < ANY (ARRAY[5, 15, 25]) THEN 'found_lower'::text + ELSE 'all_higher'::text + END AS less_any_test, + CASE + WHEN priority <> ANY (ARRAY[1, 2, 3]) THEN 'different'::text + ELSE 'same'::text + END AS not_equal_any_test FROM employees; CREATE OR REPLACE VIEW cte_with_case_view AS diff --git a/testdata/diff/online/add_constraint/diff.sql b/testdata/diff/online/add_constraint/diff.sql index e566ccd4..bf8fed43 100644 --- a/testdata/diff/online/add_constraint/diff.sql +++ b/testdata/diff/online/add_constraint/diff.sql @@ -1,2 +1,2 @@ ALTER TABLE orders -ADD CONSTRAINT check_amount_positive CHECK (amount > 0); +ADD CONSTRAINT check_amount_positive CHECK (amount > 0::numeric); diff --git a/testdata/diff/online/add_constraint/plan.json b/testdata/diff/online/add_constraint/plan.json index 786db511..d564f6a8 100644 --- a/testdata/diff/online/add_constraint/plan.json +++ b/testdata/diff/online/add_constraint/plan.json @@ -9,7 +9,7 @@ { "steps": [ { - "sql": "ALTER TABLE orders\nADD CONSTRAINT check_amount_positive CHECK (amount > 0) NOT VALID;", + "sql": "ALTER TABLE orders\nADD CONSTRAINT check_amount_positive CHECK (amount > 0::numeric) NOT VALID;", "type": "table.constraint", "operation": "create", "path": "public.orders.check_amount_positive" diff --git a/testdata/diff/online/add_constraint/plan.sql b/testdata/diff/online/add_constraint/plan.sql index 9be7ebc0..1be25342 100644 --- a/testdata/diff/online/add_constraint/plan.sql +++ b/testdata/diff/online/add_constraint/plan.sql @@ -1,4 +1,4 @@ ALTER TABLE orders -ADD CONSTRAINT check_amount_positive CHECK (amount > 0) NOT VALID; +ADD CONSTRAINT check_amount_positive CHECK (amount > 0::numeric) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT check_amount_positive; diff --git a/testdata/diff/online/add_constraint/plan.txt b/testdata/diff/online/add_constraint/plan.txt index 046f561a..d31693c7 100644 --- a/testdata/diff/online/add_constraint/plan.txt +++ b/testdata/diff/online/add_constraint/plan.txt @@ -11,6 +11,6 @@ DDL to be executed: -------------------------------------------------- ALTER TABLE orders -ADD CONSTRAINT check_amount_positive CHECK (amount > 0) NOT VALID; +ADD CONSTRAINT check_amount_positive CHECK (amount > 0::numeric) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT check_amount_positive; diff --git a/testdata/diff/online/alter_constraint/diff.sql b/testdata/diff/online/alter_constraint/diff.sql index abd6ca92..63e39935 100644 --- a/testdata/diff/online/alter_constraint/diff.sql +++ b/testdata/diff/online/alter_constraint/diff.sql @@ -1,4 +1,4 @@ ALTER TABLE orders DROP CONSTRAINT check_amount_positive; ALTER TABLE orders -ADD CONSTRAINT check_amount_positive CHECK (amount > 0); +ADD CONSTRAINT check_amount_positive CHECK (amount > 0::numeric); diff --git a/testdata/diff/online/alter_constraint/plan.json b/testdata/diff/online/alter_constraint/plan.json index 1d88f188..9e21655a 100644 --- a/testdata/diff/online/alter_constraint/plan.json +++ b/testdata/diff/online/alter_constraint/plan.json @@ -3,7 +3,7 @@ "pgschema_version": "1.4.0", "created_at": "1970-01-01T00:00:00Z", "source_fingerprint": { - "hash": "e1f0d3765739657457ab00ee30ac1f25770cd024ee6f31585f8474d5c22df40d" + "hash": "6f2182d55e7944a7676fbb25a9384e27fc6a87a63c6902eee9567c77f54f956a" }, "groups": [ { @@ -15,7 +15,7 @@ "path": "public.orders.check_amount_positive" }, { - "sql": "ALTER TABLE orders\nADD CONSTRAINT check_amount_positive CHECK (amount > 0) NOT VALID;", + "sql": "ALTER TABLE orders\nADD CONSTRAINT check_amount_positive CHECK (amount > 0::numeric) NOT VALID;", "type": "table.constraint", "operation": "create", "path": "public.orders.check_amount_positive" diff --git a/testdata/diff/online/alter_constraint/plan.sql b/testdata/diff/online/alter_constraint/plan.sql index 5ac8fa50..b1f0e314 100644 --- a/testdata/diff/online/alter_constraint/plan.sql +++ b/testdata/diff/online/alter_constraint/plan.sql @@ -1,6 +1,6 @@ ALTER TABLE orders DROP CONSTRAINT check_amount_positive; ALTER TABLE orders -ADD CONSTRAINT check_amount_positive CHECK (amount > 0) NOT VALID; +ADD CONSTRAINT check_amount_positive CHECK (amount > 0::numeric) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT check_amount_positive; diff --git a/testdata/diff/online/alter_constraint/plan.txt b/testdata/diff/online/alter_constraint/plan.txt index cf6fc031..076e9647 100644 --- a/testdata/diff/online/alter_constraint/plan.txt +++ b/testdata/diff/online/alter_constraint/plan.txt @@ -14,6 +14,6 @@ DDL to be executed: ALTER TABLE orders DROP CONSTRAINT check_amount_positive; ALTER TABLE orders -ADD CONSTRAINT check_amount_positive CHECK (amount > 0) NOT VALID; +ADD CONSTRAINT check_amount_positive CHECK (amount > 0::numeric) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT check_amount_positive; diff --git a/testdata/dump/issue_78_constraint_not_valid/pgschema.sql b/testdata/dump/issue_78_constraint_not_valid/pgschema.sql index 5775a3c0..45c696d4 100644 --- a/testdata/dump/issue_78_constraint_not_valid/pgschema.sql +++ b/testdata/dump/issue_78_constraint_not_valid/pgschema.sql @@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS products ( id uuid DEFAULT gen_random_uuid() NOT NULL, name text NOT NULL, price numeric(10,2) NOT NULL, - CONSTRAINT products_price_positive CHECK (price > 0) + CONSTRAINT products_price_positive CHECK (price > 0::numeric) ); -- diff --git a/testdata/dump/issue_82_view_logic_expr/pgschema.sql b/testdata/dump/issue_82_view_logic_expr/pgschema.sql index 71b00219..203477dd 100644 --- a/testdata/dump/issue_82_view_logic_expr/pgschema.sql +++ b/testdata/dump/issue_82_view_logic_expr/pgschema.sql @@ -24,7 +24,10 @@ CREATE TABLE IF NOT EXISTS orders ( CREATE OR REPLACE VIEW paid_orders AS SELECT id AS order_id, status, - CASE WHEN status IN ('paid', 'completed') THEN amount ELSE NULL END AS paid_amount + CASE + WHEN status::text = ANY (ARRAY['paid'::character varying::text, 'completed'::character varying::text]) THEN amount + ELSE NULL::numeric + END AS paid_amount FROM orders - ORDER BY order_id, status; + ORDER BY id, status;