diff --git a/docs/config/default.pgtidy.yaml b/docs/config/default.pgtidy.yaml index a853f33..545c425 100644 --- a/docs/config/default.pgtidy.yaml +++ b/docs/config/default.pgtidy.yaml @@ -1,26 +1,68 @@ -# PgTidy house style — all fields shown with their default values. +# PgTidy house style — all fields with their default values. # Place as .pgtidy.yaml in your project root (or any parent directory). # Any field you omit keeps its default. -# Indentation string for one level (two spaces). -indent: " " +# --- Core --- -# Line terminator written by the formatter. -newline: "\n" +indent: " " # One indentation level (two spaces). +newline: "\n" # Line terminator emitted by the formatter. -# Casing for SQL keywords (SELECT, FROM, WHERE, …). +# --- Casing --- # upper | lower | preserve -keyword_case: upper -# Casing for unquoted identifiers (column names, variable names, …). -# upper | lower | preserve -ident_case: lower +keyword_case: upper # SQL keywords (SELECT, FROM, WHERE, …) +ident_case: lower # Unquoted identifiers (column/variable names) +type_case: lower # Built-in type names (text, integer, boolean, …) +alias_case: lower # Token immediately following AS in SELECT / FROM +builtin_case: lower # Built-in function names (COALESCE, MAX, NOW, …) +custom_type_case: lower # User-defined / domain types not in the built-in set -# Casing for built-in type names (text, integer, boolean, …). -# upper | lower | preserve -type_case: lower +# --- Query layout --- -# Comma placement in multi-line parameter / column lists. -# leading → comma at the start of the continuation line (,col) -# trailing → comma at the end of the preceding line (col,) -commas: leading +commas: leading # leading → ,col | trailing → col, + +align_columns: false # Pad SELECT list items so values align vertically +align_line_comments: false # Align trailing -- comments within a block +select_align_as: false # Pad between expression and AS keyword in SELECT list +set_align_equal: false # Align = in UPDATE SET list +indent_join: false # Extra indentation for JOIN … ON lines +join_indent_size: 1 # Number of extra indent levels for JOINs + +# always | when_long | never +where_wrap: always # Each AND/OR condition on its own line + +where_and_or_indent: true # AND/OR indented one level under WHERE + +# --- Subqueries --- +# same_line | new_line + +subquery_opening: same_line # Opening ( placement +subquery_content: new_line # Content indentation inside parens +subquery_closing: new_line # Closing ) placement +subquery_space_before_paren: false # Space before ( in subqueries + +# --- INSERT --- + +insert_collapse_values: true # Fold multiple VALUES rows onto fewer lines + +# --- Routines (functions / procedures) --- + +align_param_types: true # Pad param names so type column aligns across all params +routine_as_wrap: true # Newline before AS $$ (false = keep AS on same line) + +# --- PL/pgSQL body --- + +plpgsql_max_blank_lines: 1 # Max consecutive blank lines in body +plpgsql_declare_align_type: false # Align type column in DECLARE block +plpgsql_declare_align_eq: false # Align := / = in DECLARE block +plpgsql_if_then_newline: true # THEN on its own line (false = same line as condition) +plpgsql_loop_collapse: true # Collapse empty loop bodies to one line + +# --- Expressions --- + +binary_op_align: false # Align =, <>, || etc. vertically in WHERE/expression lists +space_after_comma_in_calls: false # Space after , in function calls: func(a, b) +case_when_wrap: false # Each WHEN … THEN on its own line +case_end: new_line # END placement: same_line | new_line +case_collapse: false # Collapse short CASE expressions to one line +record_space_before_paren: false # Space before ( in ROW(…) / record constructors diff --git a/docs/plan.md b/docs/plan.md index b0407f2..cdc175c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -71,6 +71,89 @@ to the intended style below. `::`, `->`, `->>`, array `[...]`, or before a call's `(`. - Dollar-quote tags preserved verbatim (`$$`, `$S$`, `$Z$`, …). +## DataGrip settings mapping + +`PostgresCodeStyleSettings` (DataGrip / JetBrains) is the reference for all configurable +style options. The table below maps every relevant DataGrip key to its `.pgtidy.yaml` +counterpart so a user can reproduce their DataGrip style exactly in PgTidy. + +DataGrip enum conventions used below: +- **Case**: 0=preserve, 1=upper, 2=lower +- **Comma**: 1=leading (`,col`), 2=trailing (`col,`) +- **Placement**: 1=same_line, 2=new_line +- **Wrap**: 0=never, 1=when_long, 2=always + +### Casing + +| DataGrip key | PgTidy key | Default | Notes | +|---|---|---|---| +| `KEYWORD_CASE` | `keyword_case` | `upper` | SELECT, FROM, WHERE, … | +| `IDENTIFIER_CASE` | `ident_case` | `lower` | unquoted column/variable names | +| `TYPE_CASE` | `type_case` | `lower` | built-in type names (text, integer, …) | +| `CUSTOM_TYPE_CASE` | `custom_type_case` | `lower` | user-defined / domain types | +| `ALIAS_CASE` | `alias_case` | `lower` | column and table aliases | +| `BUILT_IN_CASE` | `builtin_case` | `lower` | built-in functions (COALESCE, MAX, …) | + +### Query layout + +| DataGrip key | PgTidy key | Default | Notes | +|---|---|---|---| +| `QUERY_EL_COMMA` | `commas` | `leading` | applies to all clause element lists | +| `QUERY_ALIGN_ELEMENTS` | `align_columns` | `false` | align SELECT list items to same column | +| `QUERY_ALIGN_LINE_COMMENTS` | `align_line_comments` | `false` | align `--` inline comments in a block | +| `SELECT_ALIGN_AS` | `select_align_as` | `false` | align `AS` keyword across SELECT list | +| `FROM_INDENT_JOIN` | `indent_join` | `false` | indent JOIN relative to FROM | +| `FROM_ONLY_JOIN_INDENT` | `join_indent_size` | `1` | extra indent levels for JOINs | +| `SET_ALIGN_EQUAL_SIGN` | `set_align_equal` | `false` | align `=` in UPDATE SET list | +| `WHERE_EL_WRAP` + `WHERE_EL_LINE` | `where_wrap` | `always` | always \| when_long \| never — each AND/OR condition on its own line | +| _(no DataGrip equivalent)_ | `where_and_or_indent` | `true` | when true, AND/OR are indented one level under WHERE, not at WHERE's column | + +### Subqueries + +| DataGrip key | PgTidy key | Default | Notes | +|---|---|---|---| +| `SUBQUERY_OPENING` | `subquery_opening` | `same_line` | opening `(` placement | +| `SUBQUERY_CONTENT` | `subquery_content` | `new_line` | content indentation inside paren | +| `SUBQUERY_CLOSING` | `subquery_closing` | `new_line` | closing `)` placement | +| `SUBQUERY_PAR_SPACE_BEFORE` | `subquery_space_before_paren` | `false` | space before `(` | + +### INSERT + +| DataGrip key | PgTidy key | Default | Notes | +|---|---|---|---| +| `INSERT_COLLAPSE_MULTI_ROW_VALUES` | `insert_collapse_values` | `true` | fold VALUES rows into fewer lines | + +### Routine (function / procedure) + +| DataGrip key | PgTidy key | Default | Notes | +|---|---|---|---| +| `ROUTINE_ARG_COMMA` | uses `commas` | `leading` | same setting as query lists | +| `ROUTINE_ARG_ALIGN_TYPES` | `align_param_types` | `true` | align type column in param list | +| `ROUTINE_AS_WRAP` | `routine_as_wrap` | `true` | newline before `AS $$` | + +### PL/pgSQL body + +| DataGrip key | PgTidy key | Default | Notes | +|---|---|---|---| +| `IMP_COMMON_KEEP_BLANK_LINES_IN_CODE` | `plpgsql_max_blank_lines` | `1` | max consecutive blank lines in body | +| `IMP_DECLARE_ALIGN_TYPE` | `plpgsql_declare_align_type` | `false` | align type column in DECLARE block | +| `IMP_DECLARE_ALIGN_EQ` | `plpgsql_declare_align_eq` | `false` | align `:=` / `=` in DECLARE block | +| `IMP_IF_THEN_WRAP_THEN` | `plpgsql_if_then_newline` | `true` | THEN on its own line | +| `IMP_LOOP_COLLAPSE` | `plpgsql_loop_collapse` | `true` | collapse empty loop bodies | + +### Expressions + +| DataGrip key | PgTidy key | Default | Notes | +|---|---|---|---| +| `EXPR_BINARY_OP_ALIGN` | `binary_op_align` | `false` | align `=`, `<>`, `||`, … vertically in WHERE/expression lists; default false — must not be hardcoded | +| `EXPR_CALL_SPACE_AFTER_COMMA` | `space_after_comma_in_calls` | `false` | space after `,` in function calls | +| `EXPR_CASE_WHEN_WRAP` | `case_when_wrap` | `false` | each WHEN on its own line | +| `EXPR_CASE_END` | `case_end` | `new_line` | same_line \| new_line | +| `EXPR_CASE_COLLAPSE` | `case_collapse` | `false` | collapse short CASE to one line | +| `CORTEGE_SPACE_BEFORE_L_PAREN` | `record_space_before_paren` | `false` | space before `(` in ROW/record constructors | + +--- + ## Milestones ### V1 — Formatter + CLI (priority) @@ -88,6 +171,8 @@ to the intended style below. 5. **CLI** (`cmd/pgtidy fmt`): `--check`, `--write`/`-w`, stdin→stdout, `--diff`; config discovery walking up to `.pgtidy.yaml`; CI-friendly exit codes. 6. **Config** (`pkg/config`): load/merge style config; defaults = house style above. + Full field set defined in the DataGrip settings mapping section above — covers casing + (6 keys), query layout, subqueries, INSERT, routines, PL/pgSQL body, and expressions. **Safety guarantees (tested):** semantic equivalence (re-lex output, compare non-trivia token stream to input), and idempotence (`fmt(fmt(x)) == fmt(x)`). The corpus is the diff --git a/docs/todo.md b/docs/todo.md index d3108ca..ba12ba4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -161,6 +161,83 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started - _Still TODO: Wadler Doc-IR printer for width-aware wrapping of long lines._ - _Still TODO: LSP range formatting._ +## ✅ Config expansion — DataGrip settings parity + +Reference: `PostgresCodeStyleSettings` mapping in `docs/plan.md`. + +### ✅ Extended `pkg/config` fields + +Added to `Style` struct, `yamlFile`, and `Load()` in `pkg/config/config.go`: + +- New types: `WrapMode` (`always`|`when_long`|`never`), `Placement` (`same_line`|`new_line`) +- **Casing**: `AliasCase`, `BuiltinCase`, `CustomTypeCase` — all default `lower` +- **Query layout**: `AlignColumns`, `AlignLineComments`, `SelectAlignAs`, `SetAlignEqual`, + `IndentJoin`, `JoinIndentSize`, `WhereWrap`, `WhereAndOrIndent` +- **Subqueries**: `SubqueryOpening`, `SubqueryContent`, `SubqueryClosing`, `SubquerySpaceBeforeParen` +- **INSERT**: `InsertCollapseValues` +- **Routines**: `AlignParamTypes`, `RoutineAsWrap` +- **PL/pgSQL**: `PlpgsqlMaxBlankLines`, `PlpgsqlDeclareAlignType`, `PlpgsqlDeclareAlignEq`, + `PlpgsqlIfThenNewline`, `PlpgsqlLoopCollapse` +- **Expressions**: `BinaryOpAlign`, `SpaceAfterCommaInCalls`, `CaseWhenWrap`, `CaseEnd`, + `CaseCollapse`, `RecordSpaceBeforeParen` +- `docs/config/default.pgtidy.yaml` updated with all new keys and comments. + +### ✅ Casing engine — alias and built-in classification + +`pkg/format/keywords.go`: added `builtinFunctions` set (COALESCE, MAX, MIN, NOW, …). +`pkg/format/format.go`: `caseTextCtx` uses context — `prev` token and `nextIsLParen` flag +to route ident tokens through `AliasCase` (after AS) or `BuiltinCase` (before `(`). +`inline()` and `dmlInline()` pass context to `caseTextCtx`. + +### ✅ Formatter — query layout settings (`pkg/format/dml.go`) + +- `indent_join` + `join_indent_size`: JOIN clause indented by `JoinIndentSize × Indent`. +- `where_wrap` + `where_and_or_indent`: `dmlWhereClause` splits AND/OR conditions; `always` + puts each condition on its own line indented under WHERE; `never` keeps inline. +- `set_align_equal`: `dmlColListSet` pads LHS of SET items so `=` signs align. +- `align_columns` + `select_align_as`: `dmlColListSelect` + `alignSelectItems` pads + SELECT expressions so AS keywords and aliases align vertically. +- `space_after_comma_in_calls` applied in `dmlInline`. +- `binary_op_align` registered in config (enforcement in WHERE/expression context deferred). + +### ⬜ Formatter — subquery formatting + +`subquery_opening/content/closing/space_before_paren` fields are wired in config. +Enforcement in `dml.go` is not yet implemented — subqueries use current CTE formatting +as a proxy (new_line for content, inline for single-arg subexpressions). + +### ⬜ Formatter — INSERT VALUES collapse + +`insert_collapse_values` field is wired in config. Enforcement in `dml.go` not yet implemented. + +### ✅ Formatter — routine param alignment (`pkg/format/format.go`) + +- `align_param_types`: `alignParamTypes()` pads param names so type columns align; default `true`. +- `routine_as_wrap`: when `false`, AS stays on the same line as the last option clause. +- Golden file `testdata/corpus/test_a.pgsql` updated to reflect aligned params. + +### ✅ Formatter — PL/pgSQL body settings (`pkg/format/body.go`) + +- `plpgsql_max_blank_lines`: blank-line runs capped at the configured limit; default `1`. +- `plpgsql_declare_align_type` + `plpgsql_declare_align_eq`: two-pass declare formatter + measures name/type widths then pads for alignment; `writeDeclareAligned` helper. +- `plpgsql_if_then_newline`: when `false`, `joinThenToCondition` merges THEN onto the + preceding condition line. +- `plpgsql_loop_collapse`: `tryCollapseLoop` detects empty FOR/WHILE loop bodies and + collapses them to one line. +- CRLF normalization in trivia emission (comment text, body trivia before DECLARE). + +### ⬜ Formatter — expression settings (case_when_wrap, case_end, case_collapse, record_space_before_paren) + +Config fields wired. Expression-level CASE/ROW formatting not yet implemented. + +### ⬜ DataGrip XML import/export (optional, V4+) + +`pgtidy config import --datagrip ` / `pgtidy config export --datagrip` +not implemented. + +--- + ## Open risks - `go-pgquery` tracks PG17 (not PG18) — fine for lint; irrelevant to formatter path. - Leading-comma + one-per-line is a first-class style option, not an afterthought. diff --git a/pkg/config/config.go b/pkg/config/config.go index 88ee7cd..7a7b59a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,9 +16,8 @@ import ( type Case string const ( - CaseUpper Case = "upper" - CaseLower Case = "lower" - // CasePreserve leaves the token text unchanged. + CaseUpper Case = "upper" + CaseLower Case = "lower" CasePreserve Case = "preserve" ) @@ -26,51 +25,174 @@ const ( type CommaStyle string const ( - // CommaLeading puts the comma at the start of the continuation line - // (",col"), the house style. - CommaLeading CommaStyle = "leading" - // CommaTrailing puts the comma at the end of the preceding line ("col,"). + CommaLeading CommaStyle = "leading" CommaTrailing CommaStyle = "trailing" ) +// WrapMode controls whether a construct wraps to multiple lines. +type WrapMode string + +const ( + WrapAlways WrapMode = "always" + WrapWhenLong WrapMode = "when_long" + WrapNever WrapMode = "never" +) + +// Placement controls where a token or block is placed relative to surrounding content. +type Placement string + +const ( + PlacementSameLine Placement = "same_line" + PlacementNewLine Placement = "new_line" +) + // Style is the formatter configuration. type Style struct { - // Indent is one indentation level (default two spaces). - Indent string - // Newline is the line terminator emitted by the formatter. + // --- Core --- + Indent string Newline string - // KeywordCase controls SQL keyword casing (types excluded — see TypeCase). - KeywordCase Case - // IdentCase controls unquoted identifier casing (quoted identifiers are - // never touched). - IdentCase Case - // TypeCase controls built-in type-name casing. - TypeCase Case - // Commas controls list separator placement. - Commas CommaStyle + + // --- Casing --- + KeywordCase Case + IdentCase Case + TypeCase Case + AliasCase Case // token immediately following AS in SELECT/FROM + BuiltinCase Case // built-in function names (COALESCE, MAX, NOW, …) + CustomTypeCase Case // user-defined / domain types not in the built-in set + + // --- Query layout --- + Commas CommaStyle + AlignColumns bool // pad SELECT list so values align + AlignLineComments bool // align trailing -- comments in a block + SelectAlignAs bool // pad between expression and AS in SELECT list + SetAlignEqual bool // align = in UPDATE SET list + IndentJoin bool // extra indentation for JOIN … ON lines + JoinIndentSize int // extra indent levels for JOINs (default 1) + WhereWrap WrapMode // always|when_long|never — each AND/OR on its own line + WhereAndOrIndent bool // AND/OR indented one level under WHERE + + // --- Subqueries --- + SubqueryOpening Placement // opening ( placement: same_line|new_line + SubqueryContent Placement // content indentation: same_line|new_line + SubqueryClosing Placement // closing ) placement: same_line|new_line + SubquerySpaceBeforeParen bool // space before ( in subqueries + + // --- INSERT --- + InsertCollapseValues bool // fold multiple VALUES rows onto fewer lines + + // --- Routines --- + AlignParamTypes bool // pad param names so type column aligns + RoutineAsWrap bool // newline before AS $$ + + // --- PL/pgSQL body --- + PlpgsqlMaxBlankLines int // max consecutive blank lines in body + PlpgsqlDeclareAlignType bool // align type column in DECLARE block + PlpgsqlDeclareAlignEq bool // align := / = in DECLARE block + PlpgsqlIfThenNewline bool // THEN on its own line + PlpgsqlLoopCollapse bool // collapse empty loop bodies to one line + + // --- Expressions --- + BinaryOpAlign bool // align =, <>, || etc. vertically in WHERE/expr lists + SpaceAfterCommaInCalls bool // space after , in function calls: func(a, b) + CaseWhenWrap bool // each WHEN … THEN on its own line + CaseEnd Placement // END placement: same_line|new_line + CaseCollapse bool // collapse short CASE to one line + RecordSpaceBeforeParen bool // space before ( in ROW(…) / record constructors } // Default returns the house-style configuration. func Default() Style { return Style{ - Indent: " ", - Newline: "\n", - KeywordCase: CaseUpper, - IdentCase: CaseLower, - TypeCase: CaseLower, - Commas: CommaLeading, + Indent: " ", + Newline: "\n", + + KeywordCase: CaseUpper, + IdentCase: CaseLower, + TypeCase: CaseLower, + AliasCase: CaseLower, + BuiltinCase: CaseLower, + CustomTypeCase: CaseLower, + + Commas: CommaLeading, + AlignColumns: false, + AlignLineComments: false, + SelectAlignAs: false, + SetAlignEqual: false, + IndentJoin: false, + JoinIndentSize: 1, + WhereWrap: WrapAlways, + WhereAndOrIndent: true, + + SubqueryOpening: PlacementSameLine, + SubqueryContent: PlacementNewLine, + SubqueryClosing: PlacementNewLine, + SubquerySpaceBeforeParen: false, + + InsertCollapseValues: true, + + AlignParamTypes: true, + RoutineAsWrap: true, + + PlpgsqlMaxBlankLines: 1, + PlpgsqlDeclareAlignType: false, + PlpgsqlDeclareAlignEq: false, + PlpgsqlIfThenNewline: true, + PlpgsqlLoopCollapse: true, + + BinaryOpAlign: false, + SpaceAfterCommaInCalls: false, + CaseWhenWrap: false, + CaseEnd: PlacementNewLine, + CaseCollapse: false, + RecordSpaceBeforeParen: false, } } // yamlFile is the on-disk representation of .pgtidy.yaml. // All fields are pointers so we can distinguish "not set" from "set to zero value". type yamlFile struct { - Indent *string `yaml:"indent"` - Newline *string `yaml:"newline"` - KeywordCase *string `yaml:"keyword_case"` - IdentCase *string `yaml:"ident_case"` - TypeCase *string `yaml:"type_case"` - Commas *string `yaml:"commas"` + Indent *string `yaml:"indent"` + Newline *string `yaml:"newline"` + + KeywordCase *string `yaml:"keyword_case"` + IdentCase *string `yaml:"ident_case"` + TypeCase *string `yaml:"type_case"` + AliasCase *string `yaml:"alias_case"` + BuiltinCase *string `yaml:"builtin_case"` + CustomTypeCase *string `yaml:"custom_type_case"` + + Commas *string `yaml:"commas"` + AlignColumns *bool `yaml:"align_columns"` + AlignLineComments *bool `yaml:"align_line_comments"` + SelectAlignAs *bool `yaml:"select_align_as"` + SetAlignEqual *bool `yaml:"set_align_equal"` + IndentJoin *bool `yaml:"indent_join"` + JoinIndentSize *int `yaml:"join_indent_size"` + WhereWrap *string `yaml:"where_wrap"` + WhereAndOrIndent *bool `yaml:"where_and_or_indent"` + + SubqueryOpening *string `yaml:"subquery_opening"` + SubqueryContent *string `yaml:"subquery_content"` + SubqueryClosing *string `yaml:"subquery_closing"` + SubquerySpaceBeforeParen *bool `yaml:"subquery_space_before_paren"` + + InsertCollapseValues *bool `yaml:"insert_collapse_values"` + + AlignParamTypes *bool `yaml:"align_param_types"` + RoutineAsWrap *bool `yaml:"routine_as_wrap"` + + PlpgsqlMaxBlankLines *int `yaml:"plpgsql_max_blank_lines"` + PlpgsqlDeclareAlignType *bool `yaml:"plpgsql_declare_align_type"` + PlpgsqlDeclareAlignEq *bool `yaml:"plpgsql_declare_align_eq"` + PlpgsqlIfThenNewline *bool `yaml:"plpgsql_if_then_newline"` + PlpgsqlLoopCollapse *bool `yaml:"plpgsql_loop_collapse"` + + BinaryOpAlign *bool `yaml:"binary_op_align"` + SpaceAfterCommaInCalls *bool `yaml:"space_after_comma_in_calls"` + CaseWhenWrap *bool `yaml:"case_when_wrap"` + CaseEnd *string `yaml:"case_end"` + CaseCollapse *bool `yaml:"case_collapse"` + RecordSpaceBeforeParen *bool `yaml:"record_space_before_paren"` } // Load discovers and parses the nearest .pgtidy.yaml by walking up from @@ -100,27 +222,26 @@ func Load(startDir string) (Style, error) { if yf.Newline != nil { st.Newline = *yf.Newline } - if yf.KeywordCase != nil { - c := Case(*yf.KeywordCase) - if err := validCase(c); err != nil { - return st, fmt.Errorf("pgtidy: %s: keyword_case: %w", path, err) - } - st.KeywordCase = c + + if err := loadCase(yf.KeywordCase, &st.KeywordCase, path, "keyword_case"); err != nil { + return st, err } - if yf.IdentCase != nil { - c := Case(*yf.IdentCase) - if err := validCase(c); err != nil { - return st, fmt.Errorf("pgtidy: %s: ident_case: %w", path, err) - } - st.IdentCase = c + if err := loadCase(yf.IdentCase, &st.IdentCase, path, "ident_case"); err != nil { + return st, err } - if yf.TypeCase != nil { - c := Case(*yf.TypeCase) - if err := validCase(c); err != nil { - return st, fmt.Errorf("pgtidy: %s: type_case: %w", path, err) - } - st.TypeCase = c + if err := loadCase(yf.TypeCase, &st.TypeCase, path, "type_case"); err != nil { + return st, err } + if err := loadCase(yf.AliasCase, &st.AliasCase, path, "alias_case"); err != nil { + return st, err + } + if err := loadCase(yf.BuiltinCase, &st.BuiltinCase, path, "builtin_case"); err != nil { + return st, err + } + if err := loadCase(yf.CustomTypeCase, &st.CustomTypeCase, path, "custom_type_case"); err != nil { + return st, err + } + if yf.Commas != nil { cs := CommaStyle(*yf.Commas) if cs != CommaLeading && cs != CommaTrailing { @@ -129,9 +250,93 @@ func Load(startDir string) (Style, error) { st.Commas = cs } + loadBool(yf.AlignColumns, &st.AlignColumns) + loadBool(yf.AlignLineComments, &st.AlignLineComments) + loadBool(yf.SelectAlignAs, &st.SelectAlignAs) + loadBool(yf.SetAlignEqual, &st.SetAlignEqual) + loadBool(yf.IndentJoin, &st.IndentJoin) + if yf.JoinIndentSize != nil { + st.JoinIndentSize = *yf.JoinIndentSize + } + if yf.WhereWrap != nil { + wm := WrapMode(*yf.WhereWrap) + if err := validWrap(wm); err != nil { + return st, fmt.Errorf("pgtidy: %s: where_wrap: %w", path, err) + } + st.WhereWrap = wm + } + loadBool(yf.WhereAndOrIndent, &st.WhereAndOrIndent) + + if yf.SubqueryOpening != nil { + pl := Placement(*yf.SubqueryOpening) + if err := validPlacement(pl); err != nil { + return st, fmt.Errorf("pgtidy: %s: subquery_opening: %w", path, err) + } + st.SubqueryOpening = pl + } + if yf.SubqueryContent != nil { + pl := Placement(*yf.SubqueryContent) + if err := validPlacement(pl); err != nil { + return st, fmt.Errorf("pgtidy: %s: subquery_content: %w", path, err) + } + st.SubqueryContent = pl + } + if yf.SubqueryClosing != nil { + pl := Placement(*yf.SubqueryClosing) + if err := validPlacement(pl); err != nil { + return st, fmt.Errorf("pgtidy: %s: subquery_closing: %w", path, err) + } + st.SubqueryClosing = pl + } + loadBool(yf.SubquerySpaceBeforeParen, &st.SubquerySpaceBeforeParen) + + loadBool(yf.InsertCollapseValues, &st.InsertCollapseValues) + + loadBool(yf.AlignParamTypes, &st.AlignParamTypes) + loadBool(yf.RoutineAsWrap, &st.RoutineAsWrap) + + if yf.PlpgsqlMaxBlankLines != nil { + st.PlpgsqlMaxBlankLines = *yf.PlpgsqlMaxBlankLines + } + loadBool(yf.PlpgsqlDeclareAlignType, &st.PlpgsqlDeclareAlignType) + loadBool(yf.PlpgsqlDeclareAlignEq, &st.PlpgsqlDeclareAlignEq) + loadBool(yf.PlpgsqlIfThenNewline, &st.PlpgsqlIfThenNewline) + loadBool(yf.PlpgsqlLoopCollapse, &st.PlpgsqlLoopCollapse) + + loadBool(yf.BinaryOpAlign, &st.BinaryOpAlign) + loadBool(yf.SpaceAfterCommaInCalls, &st.SpaceAfterCommaInCalls) + loadBool(yf.CaseWhenWrap, &st.CaseWhenWrap) + if yf.CaseEnd != nil { + pl := Placement(*yf.CaseEnd) + if err := validPlacement(pl); err != nil { + return st, fmt.Errorf("pgtidy: %s: case_end: %w", path, err) + } + st.CaseEnd = pl + } + loadBool(yf.CaseCollapse, &st.CaseCollapse) + loadBool(yf.RecordSpaceBeforeParen, &st.RecordSpaceBeforeParen) + return st, nil } +func loadCase(src *string, dst *Case, path, key string) error { + if src == nil { + return nil + } + c := Case(*src) + if err := validCase(c); err != nil { + return fmt.Errorf("pgtidy: %s: %s: %w", path, key, err) + } + *dst = c + return nil +} + +func loadBool(src *bool, dst *bool) { + if src != nil { + *dst = *src + } +} + // findConfig walks parent directories from startDir looking for .pgtidy.yaml. // Returns ("", nil) when no file is found before reaching the filesystem root. func findConfig(startDir string) (string, error) { @@ -159,3 +364,19 @@ func validCase(c Case) error { } return fmt.Errorf("must be \"upper\", \"lower\", or \"preserve\"") } + +func validWrap(w WrapMode) error { + switch w { + case WrapAlways, WrapWhenLong, WrapNever: + return nil + } + return fmt.Errorf("must be \"always\", \"when_long\", or \"never\"") +} + +func validPlacement(p Placement) error { + switch p { + case PlacementSameLine, PlacementNewLine: + return nil + } + return fmt.Errorf("must be \"same_line\" or \"new_line\"") +} diff --git a/pkg/format/body.go b/pkg/format/body.go index 5512b6a..286cdba 100644 --- a/pkg/format/body.go +++ b/pkg/format/body.go @@ -92,10 +92,11 @@ func formatBodyInner(inner string, st config.Style) string { var b strings.Builder // Emit verbatim up to and including DECLARE (keyword-cased). + // Normalize CRLF in trivia so the output always uses st.Newline. for i := 0; i <= declareIdx; i++ { t := sig[i] for _, tr := range t.Lead { - b.WriteString(tr.Text) + b.WriteString(strings.ReplaceAll(tr.Text, "\r\n", nl)) } if i == declareIdx { b.WriteString(applyCase(t.Tok.Text, st.KeywordCase)) @@ -122,57 +123,30 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) { nl := st.Newline indent := st.Indent depth := 0 + var decls [][]cst.Tok var cur []cst.Tok - var preComments []string + var preCommentSets [][]string + var curPreComments []string - emit := func() { + collect := func() { if len(cur) == 0 { return } - for _, c := range preComments { - b.WriteString(indent) - b.WriteString(c) - b.WriteString(nl) - } - preComments = nil - - // Graceful degradation: mid-declaration comments stay verbatim. - if anyComment(cur[1:]) { - b.WriteString(indent) - b.WriteString(verbatimSpan(cur)) - b.WriteString(nl) - cur = nil - return - } - - body := cur - hasSemi := len(body) > 0 && body[len(body)-1].Tok.Kind == lexer.Semicolon - if hasSemi { - body = body[:len(body)-1] - } - b.WriteString(indent) - for i, t := range body { - if i > 0 && needSpace(body[i-1].Tok, t.Tok) { - b.WriteByte(' ') - } - b.WriteString(caseText(t.Tok, st)) - } - if hasSemi { - b.WriteString(";") - } - b.WriteString(nl) + decls = append(decls, cur) + preCommentSets = append(preCommentSets, curPreComments) cur = nil + curPreComments = nil } for _, t := range toks { if len(cur) == 0 { for _, tr := range t.Lead { if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment { - preComments = append(preComments, strings.TrimRight(tr.Text, " \t")) + text := strings.TrimRight(strings.ReplaceAll(tr.Text, "\r", ""), " \t") + curPreComments = append(curPreComments, text) } } } - switch t.Tok.Kind { case lexer.LParen, lexer.LBracket: depth++ @@ -181,14 +155,195 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) { depth-- } } - cur = append(cur, t) - if t.Tok.Kind == lexer.Semicolon && depth == 0 { - emit() + collect() + } + } + collect() + + // Compute alignment widths when requested. + var nameColW, typeColW int + if st.PlpgsqlDeclareAlignType || st.PlpgsqlDeclareAlignEq { + for _, decl := range decls { + if anyComment(decl[1:]) { + continue + } + body := decl + if len(body) > 0 && body[len(body)-1].Tok.Kind == lexer.Semicolon { + body = body[:len(body)-1] + } + nw, tw := declareNameTypeWidth(body, st) + if nw > nameColW { + nameColW = nw + } + if tw > typeColW { + typeColW = tw + } + } + } + + for i, cur := range decls { + for _, c := range preCommentSets[i] { + b.WriteString(indent) + b.WriteString(c) + b.WriteString(nl) + } + // Graceful degradation: mid-declaration comments stay verbatim. + if anyComment(cur[1:]) { + b.WriteString(indent) + b.WriteString(verbatimSpan(cur)) + b.WriteString(nl) + continue + } + + body := cur + hasSemi := len(body) > 0 && body[len(body)-1].Tok.Kind == lexer.Semicolon + if hasSemi { + body = body[:len(body)-1] + } + b.WriteString(indent) + if (st.PlpgsqlDeclareAlignType || st.PlpgsqlDeclareAlignEq) && nameColW > 0 { + writeDeclareAligned(b, body, st, nameColW, typeColW) + } else { + for j, t := range body { + if j > 0 && needSpace(body[j-1].Tok, t.Tok) { + b.WriteByte(' ') + } + b.WriteString(caseText(t.Tok, st)) + } + } + if hasSemi { + b.WriteString(";") + } + b.WriteString(nl) + } +} + +// declareNameTypeWidth returns the rendered width of the name and type portions +// of a DECLARE variable declaration (without the default assignment). +// Format is: [name type [:= default]] or [name type [DEFAULT default]]. +func declareNameTypeWidth(body []cst.Tok, st config.Style) (nameW, typeW int) { + if len(body) < 2 { + return 0, 0 + } + // name is always the first token. + name := caseText(body[0].Tok, st) + nameW = len(name) + + // type runs from body[1] until we hit := / DEFAULT / = at depth 0. + var typeTokens []cst.Tok + depth := 0 + for _, t := range body[1:] { + switch t.Tok.Kind { + case lexer.LParen, lexer.LBracket: + depth++ + case lexer.RParen, lexer.RBracket: + if depth > 0 { + depth-- + } + } + if depth == 0 { + low := lowerASCII(t.Tok.Text) + if t.Tok.Kind == lexer.Operator && (t.Tok.Text == ":=" || t.Tok.Text == "=") { + break + } + if t.Tok.Kind == lexer.Ident && low == "default" { + break + } + } + typeTokens = append(typeTokens, t) + } + var tb strings.Builder + for j, t := range typeTokens { + if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) { + tb.WriteByte(' ') + } + tb.WriteString(caseText(t.Tok, st)) + } + typeW = len(tb.String()) + return nameW, typeW +} + +// writeDeclareAligned writes a single DECLARE variable with aligned columns. +func writeDeclareAligned(b *strings.Builder, body []cst.Tok, st config.Style, nameColW, typeColW int) { + if len(body) == 0 { + return + } + name := caseText(body[0].Tok, st) + b.WriteString(name) + if len(body) == 1 { + return + } + + // Pad name to nameColW if align_type is requested. + if st.PlpgsqlDeclareAlignType { + pad := nameColW - len(name) + for k := 0; k < pad; k++ { + b.WriteByte(' ') + } + } + + // Collect type tokens. + var typeTokens, restTokens []cst.Tok + depth := 0 + pastType := false + for _, t := range body[1:] { + switch t.Tok.Kind { + case lexer.LParen, lexer.LBracket: + depth++ + case lexer.RParen, lexer.RBracket: + if depth > 0 { + depth-- + } + } + if !pastType && depth == 0 { + low := lowerASCII(t.Tok.Text) + if (t.Tok.Kind == lexer.Operator && (t.Tok.Text == ":=" || t.Tok.Text == "=")) || + (t.Tok.Kind == lexer.Ident && low == "default") { + pastType = true + restTokens = append(restTokens, t) + continue + } + } + if pastType { + restTokens = append(restTokens, t) + } else { + typeTokens = append(typeTokens, t) + } + } + + var typeStr strings.Builder + for j, t := range typeTokens { + if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) { + typeStr.WriteByte(' ') + } + typeStr.WriteString(caseText(t.Tok, st)) + } + typeRendered := typeStr.String() + + b.WriteByte(' ') + b.WriteString(typeRendered) + + if len(restTokens) > 0 { + // Pad type to typeColW if align_eq is requested. + if st.PlpgsqlDeclareAlignEq { + pad := typeColW - len(typeRendered) + for k := 0; k < pad; k++ { + b.WriteByte(' ') + } + } + for j, t := range restTokens { + prev := restTokens[0].Tok + if j > 0 { + prev = restTokens[j-1].Tok + } + if j == 0 || needSpace(prev, t.Tok) { + b.WriteByte(' ') + } + b.WriteString(caseText(t.Tok, st)) } } - emit() } // bline is one logical line within an accumulated statement. @@ -208,12 +363,17 @@ type bline struct { // 3. After EXCEPTION the formatter switches to verbatim-indent mode (original // leading whitespace is preserved) to avoid conflicts between styles that // put WHEN at col-0 vs indented. -// 4. Blank-line counts from the original are preserved. +// 4. Blank-line counts from the original are preserved (capped by PlpgsqlMaxBlankLines). func formatBodyStatements(text string, st config.Style) string { nl := st.Newline normalised := strings.ReplaceAll(text, "\r\n", "\n") rawLines := strings.Split(normalised, "\n") + maxBlanks := st.PlpgsqlMaxBlankLines + if maxBlanks < 0 { + maxBlanks = 0 + } + var ( result strings.Builder stmt []bline @@ -228,7 +388,11 @@ func formatBodyStatements(text string, st config.Style) string { if len(stmt) == 0 { return } - for i := 0; i < pendingBlanks; i++ { + blanks := pendingBlanks + if blanks > maxBlanks { + blanks = maxBlanks + } + for i := 0; i < blanks; i++ { result.WriteString(nl) } pendingBlanks = 0 @@ -259,8 +423,6 @@ func formatBodyStatements(text string, st config.Style) string { } effectiveDepth = blockDepth case "else", "elsif", "elseif": - // Emit at one level up; blockDepth unchanged so the body continues - // at the same depth (THEN will re-apply depthInc for elsif). if blockDepth > 0 { effectiveDepth = blockDepth - 1 } @@ -271,7 +433,14 @@ func formatBodyStatements(text string, st config.Style) string { baseIndent := strings.Repeat(st.Indent, effectiveDepth) - for i, ll := range stmt { + // plpgsql_if_then_newline: when false, THEN stays on the same line as + // the condition. When true (default) it's already on its own logical line. + stmtLines := stmt + if !st.PlpgsqlIfThenNewline && fw == "if" { + stmtLines = joinThenToCondition(stmt) + } + + for i, ll := range stmtLines { if i == 0 || ll.indent == "" { result.WriteString(baseIndent) } else { @@ -301,8 +470,6 @@ func formatBodyStatements(text string, st config.Style) string { fw := lowerASCII(firstBodyKeyword(stripped)) isColZero := indent == "" - // Only join when we are at paren-depth 0; content inside parens (e.g. - // inside a CTE subquery) should not be merged across lines. joinToPrev := isColZero && parenDepth == 0 && len(stmt) > 0 && !sqlClauseKw[fw] if joinToPrev { @@ -312,7 +479,6 @@ func formatBodyStatements(text string, st config.Style) string { stmt = append(stmt, bline{text: stripped, indent: indent}) } - // Scan tokens to track paren depth and detect flush triggers. var lastD0Kw string for _, tok := range lexer.Lex(stripped) { if tok.IsTrivia() || tok.Kind == lexer.EOF { @@ -327,6 +493,13 @@ func formatBodyStatements(text string, st config.Style) string { } case lexer.Semicolon: if parenDepth == 0 { + // plpgsql_loop_collapse: fold empty FOR … LOOP END LOOP; to one line. + if st.PlpgsqlLoopCollapse && len(stmt) > 0 { + collapsed, ok := tryCollapseLoop(stmt, st) + if ok { + stmt = []bline{{text: collapsed, indent: ""}} + } + } flush() } } @@ -335,13 +508,9 @@ func formatBodyStatements(text string, st config.Style) string { } } - // Structural keywords at the end of a line (paren depth 0) trigger a - // flush and possibly a block-depth change. if parenDepth == 0 && len(stmt) > 0 { switch lastD0Kw { case "then", "loop", "begin": - // ELSIF/ELSEIF headers end with THEN but must NOT increment depth - // (blockDepth is already at the right level for the body). fw0 := lowerASCII(firstBodyKeyword(stmt[0].text)) if fw0 != "elsif" && fw0 != "elseif" { depthInc = true @@ -357,6 +526,52 @@ func formatBodyStatements(text string, st config.Style) string { return result.String() } +// joinThenToCondition merges a THEN line (on its own bline) into the preceding +// condition line when plpgsql_if_then_newline is false. +func joinThenToCondition(lines []bline) []bline { + out := make([]bline, 0, len(lines)) + for i, ll := range lines { + if i > 0 && strings.EqualFold(strings.TrimSpace(ll.text), "then") { + out[len(out)-1].text = strings.TrimRight(out[len(out)-1].text, " \t") + " THEN" + } else { + out = append(out, ll) + } + } + return out +} + +// tryCollapseLoop tries to collapse an empty loop body to one line. +// Detects: FOR … LOOP\n (empty or only blanks)\nEND LOOP; +// Returns the collapsed line and true on success. +func tryCollapseLoop(lines []bline, st config.Style) (string, bool) { + if len(lines) < 2 { + return "", false + } + first := strings.TrimSpace(lines[0].text) + last := strings.TrimSpace(lines[len(lines)-1].text) + firstLow := lowerASCII(first) + lastLow := lowerASCII(last) + + // Check last line is END LOOP; or LOOP (for WHILE/FOR empty bodies that end with LOOP). + if !strings.HasPrefix(lastLow, "end loop") && lastLow != "end loop;" { + return "", false + } + // Check middle lines are all empty. + for _, mid := range lines[1 : len(lines)-1] { + if strings.TrimSpace(mid.text) != "" { + return "", false + } + } + // Check first line ends with LOOP. + if !strings.HasSuffix(firstLow, "loop") { + return "", false + } + _ = st + _ = firstLow + // Collapse to:
END LOOP; + return strings.TrimRight(first, " \t") + " " + strings.ToUpper(last), true +} + // firstBodyKeyword returns the text of the first identifier token in s // (lowercased), or "" if the first significant token is not an identifier. func firstBodyKeyword(s string) string { diff --git a/pkg/format/dml.go b/pkg/format/dml.go index 4dfb5ef..f19d85c 100644 --- a/pkg/format/dml.go +++ b/pkg/format/dml.go @@ -179,9 +179,16 @@ func dmlSegText(seg dmlSeg, st config.Style) string { } switch kw { - case "select", "set", "returning": + case "select", "returning": items := dmlSplitCommas(seg.body) - return dmlColList(kwText, items, st) + return dmlColListSelect(kwText, items, st) + case "set": + items := dmlSplitCommas(seg.body) + return dmlColListSet(kwText, items, st) + case "where": + return dmlWhereClause(kwText, seg.body, st) + case "join", "left", "right", "inner", "full", "cross", "natural": + return dmlJoinClause(kwText, seg.body, st) case "with": return formatWithBody(kwText, seg.body, st) default: @@ -193,6 +200,97 @@ func dmlSegText(seg dmlSeg, st config.Style) string { } } +// dmlJoinClause formats a JOIN clause, applying indent_join when configured. +func dmlJoinClause(kwText string, body []cst.Tok, st config.Style) string { + text := dmlInline(body, st) + line := kwText + if text != "" { + line += " " + text + } + if !st.IndentJoin { + return line + } + indent := strings.Repeat(st.Indent, st.JoinIndentSize) + nl := st.Newline + var b strings.Builder + for i, part := range strings.Split(line, nl) { + if i > 0 { + b.WriteString(nl) + } + b.WriteString(indent) + b.WriteString(part) + } + return b.String() +} + +// dmlWhereClause formats a WHERE clause, splitting AND/OR conditions per +// the where_wrap and where_and_or_indent settings. +func dmlWhereClause(kwText string, body []cst.Tok, st config.Style) string { + if st.WhereWrap == config.WrapNever { + text := dmlInline(body, st) + if text == "" { + return kwText + } + return kwText + " " + text + } + + // Split at depth-0 AND/OR. + conditions := dmlSplitAndOr(body) + if len(conditions) <= 1 { + text := dmlInline(body, st) + if text == "" { + return kwText + } + return kwText + " " + text + } + + nl := st.Newline + var b strings.Builder + b.WriteString(kwText) + for i, cond := range conditions { + b.WriteString(nl) + text := dmlInline(cond, st) + if st.WhereAndOrIndent { + b.WriteString(st.Indent) + } + if i == 0 { + // First condition: no leading AND/OR + b.WriteString(" ") // align with AND/OR token width + b.WriteString(text) + } else { + b.WriteString(text) + } + } + return b.String() +} + +// dmlSplitAndOr splits toks at depth-0 AND/OR tokens, keeping the AND/OR with +// the following condition. +func dmlSplitAndOr(toks []cst.Tok) [][]cst.Tok { + var result [][]cst.Tok + depth := 0 + start := 0 + for i, t := range toks { + switch t.Tok.Kind { + case lexer.LParen, lexer.LBracket: + depth++ + case lexer.RParen, lexer.RBracket: + if depth > 0 { + depth-- + } + } + if depth == 0 && t.Tok.Kind == lexer.Ident { + low := lowerASCII(t.Tok.Text) + if (low == "and" || low == "or") && i > start { + result = append(result, toks[start:i]) + start = i + } + } + } + result = append(result, toks[start:]) + return result +} + // formatWithBody formats the body of a WITH clause by splitting CTE definitions // at depth-0 commas and formatting the subquery inside each AS (...) block. func formatWithBody(kwText string, body []cst.Tok, st config.Style) string { @@ -370,7 +468,18 @@ func dmlInline(toks []cst.Tok, st config.Style) string { if i > 0 && needSpace(toks[i-1].Tok, t.Tok) { b.WriteByte(' ') } - b.WriteString(caseText(t.Tok, st)) + // Space after comma in calls: func(a, b) vs func(a,b). + if st.SpaceAfterCommaInCalls && i > 0 && toks[i-1].Tok.Kind == lexer.Comma { + // Only inside parens (caller manages this at depth > 0, but we add space + // when the comma is not a clause-level comma — heuristic: always add). + b.WriteByte(' ') + } + var prev lexer.Token + if i > 0 { + prev = toks[i-1].Tok + } + nextIsLParen := i+1 < len(toks) && toks[i+1].Tok.Kind == lexer.LParen + b.WriteString(caseTextCtx(t.Tok, prev, nextIsLParen, st)) } return b.String() } @@ -405,7 +514,12 @@ func dmlSplitCommas(toks []cst.Tok) [][]cst.Tok { // One item: kept on the same line as the keyword. // Multiple items: each on its own line with the configured comma style. func dmlColList(kwText string, items [][]cst.Tok, st config.Style) string { - // Filter out spurious empty items (e.g. trailing comma in source). + return dmlColListSelect(kwText, items, st) +} + +// dmlColListSelect formats a SELECT / RETURNING column list with optional +// align_columns and select_align_as settings. +func dmlColListSelect(kwText string, items [][]cst.Tok, st config.Style) string { var kept [][]cst.Tok for _, item := range items { if len(item) > 0 { @@ -426,14 +540,23 @@ func dmlColList(kwText string, items [][]cst.Tok, st config.Style) string { return kwText + " " + body } - // Multiple items: one per line. - first := st.Indent + " " // aligns item text one column past the comma + // Render each item text. + texts := make([]string, len(items)) + for i, item := range items { + texts[i] = dmlInline(item, st) + } + + // align_columns / select_align_as: pad expressions so AS and aliases align. + if (st.AlignColumns || st.SelectAlignAs) && len(texts) > 1 { + texts = alignSelectItems(texts, st) + } + + first := st.Indent + " " cont := st.Indent + "," var b strings.Builder b.WriteString(kwText) - for i, item := range items { + for i, text := range texts { b.WriteString(nl) - text := dmlInline(item, st) if i == 0 || st.Commas != config.CommaLeading { b.WriteString(first) b.WriteString(text) @@ -447,3 +570,136 @@ func dmlColList(kwText string, items [][]cst.Tok, st config.Style) string { } return b.String() } + +// dmlColListSet formats an UPDATE SET column list with optional set_align_equal. +func dmlColListSet(kwText string, items [][]cst.Tok, st config.Style) string { + var kept [][]cst.Tok + for _, item := range items { + if len(item) > 0 { + kept = append(kept, item) + } + } + items = kept + + nl := st.Newline + switch len(items) { + case 0: + return kwText + case 1: + body := dmlInline(items[0], st) + if body == "" { + return kwText + } + return kwText + " " + body + } + + texts := make([]string, len(items)) + for i, item := range items { + texts[i] = dmlInline(item, st) + } + + // set_align_equal: pad lhs so = signs align. + if st.SetAlignEqual && len(texts) > 1 { + texts = alignSetItems(texts) + } + + first := st.Indent + " " + cont := st.Indent + "," + var b strings.Builder + b.WriteString(kwText) + for i, text := range texts { + b.WriteString(nl) + if i == 0 || st.Commas != config.CommaLeading { + b.WriteString(first) + b.WriteString(text) + if st.Commas == config.CommaTrailing && i < len(items)-1 { + b.WriteString(",") + } + } else { + b.WriteString(cont) + b.WriteString(text) + } + } + return b.String() +} + +// alignSelectItems pads SELECT list item expressions so that AS keywords and +// alias names align vertically. +func alignSelectItems(texts []string, st config.Style) []string { + // Split each text into (expr, " AS ", alias) or keep as-is. + type part struct{ expr, alias string; hasAs bool } + parts := make([]part, len(texts)) + maxExpr := 0 + for i, t := range texts { + // Find " AS " or " as " (case-insensitive). + if idx := findAsIndex(t); idx >= 0 { + parts[i] = part{expr: t[:idx], alias: t[idx:], hasAs: true} + if l := len(t[:idx]); l > maxExpr { + maxExpr = l + } + } else { + parts[i] = part{expr: t} + if st.AlignColumns { + if l := len(t); l > maxExpr { + maxExpr = l + } + } + } + } + + out := make([]string, len(texts)) + for i, p := range parts { + if !p.hasAs || maxExpr == 0 { + out[i] = texts[i] + continue + } + pad := strings.Repeat(" ", maxExpr-len(p.expr)) + out[i] = p.expr + pad + p.alias + } + return out +} + +// findAsIndex returns the byte index of " AS " (case-insensitive) in s, +// or -1 if not present at depth 0. +func findAsIndex(s string) int { + low := lowerASCII(s) + // Look for " as " boundary. + for i := 0; i < len(low)-3; i++ { + if low[i] == ' ' && low[i+1] == 'a' && low[i+2] == 's' && low[i+3] == ' ' { + return i + 1 // index of 'a' + } + } + return -1 +} + +// alignSetItems pads SET assignment lhs values so that = signs align. +func alignSetItems(texts []string) []string { + maxLhs := 0 + lhsWidths := make([]int, len(texts)) + for i, t := range texts { + idx := strings.Index(t, " = ") + if idx < 0 { + idx = strings.Index(t, "=") + } + if idx >= 0 { + lhsWidths[i] = idx + if idx > maxLhs { + maxLhs = idx + } + } + } + if maxLhs == 0 { + return texts + } + out := make([]string, len(texts)) + for i, t := range texts { + if lhsWidths[i] == 0 || lhsWidths[i] == maxLhs { + out[i] = t + continue + } + idx := lhsWidths[i] + pad := strings.Repeat(" ", maxLhs-idx) + out[i] = t[:idx] + pad + t[idx:] + } + return out +} diff --git a/pkg/format/dml_test.go b/pkg/format/dml_test.go index c928498..447f76a 100644 --- a/pkg/format/dml_test.go +++ b/pkg/format/dml_test.go @@ -226,6 +226,62 @@ func TestDMLIdempotent(t *testing.T) { } } +func TestDMLWhereAndOr(t *testing.T) { + // where_wrap=always should split AND/OR conditions onto separate lines. + src := "select a from t where x = 1 and y = 2 or z = 3;" + got := format(src) + want := "SELECT a\nFROM t\nWHERE\n x = 1\n AND y = 2\n OR z = 3;\n" + if got != want { + t.Errorf("where and/or\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + checkDML(t, "where and/or", got) +} + +func TestDMLIndentJoin(t *testing.T) { + st := config.Default() + st.IndentJoin = true + src := "select a from t join s on t.id = s.id;" + got := File(parser.Parse(src), st) + want := "SELECT a\nFROM t\n JOIN s ON t.id = s.id;\n" + if got != want { + t.Errorf("indent join\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + // Idempotence with same config. + twice := File(parser.Parse(got), st) + if twice != got { + t.Errorf("indent join not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice) + } +} + +func TestDMLSetAlignEqual(t *testing.T) { + st := config.Default() + st.SetAlignEqual = true + src := "update t set a = 1, bb = 2, ccc = 3 where id = 1;" + got := File(parser.Parse(src), st) + // All = signs should align. + if got == "" { + t.Error("empty output") + } + // Idempotence. + twice := File(parser.Parse(got), st) + if twice != got { + t.Errorf("set_align_equal not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice) + } +} + +func TestDMLAlignParamTypes(t *testing.T) { + src := "create function f(in p_name text, in p_long_name integer, out p_result boolean) returns void language sql as $$ select 1 $$;" + got := format(src) + // p_name and p_long_name should have aligned types. + if got == "" { + t.Error("empty output") + } + twice := format(got) + if twice != got { + t.Errorf("align_param_types not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice) + } +} + func TestCorpusUnaffectedByDML(t *testing.T) { // Verify the corpus (which contains only CREATE FUNCTION) is not affected // by the new DML formatting path. diff --git a/pkg/format/format.go b/pkg/format/format.go index b0915f8..da1de3b 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -53,9 +53,12 @@ func (p *printer) writeItem(n cst.Node) { case *cst.CreateFunction: p.writeCreateFunction(v) case *cst.Raw: - if isDMLStart(v.Toks) { + switch { + case isDMLStart(v.Toks): p.b.WriteString(formatDML(v.Toks, p.st)) - } else { + case isDoBlock(v.Toks): + p.b.WriteString(formatDoBlock(v.Toks, p.st)) + default: p.b.WriteString(verbatimSpan(v.Toks)) } default: @@ -63,11 +66,64 @@ func (p *printer) writeItem(n cst.Node) { } } +// isDoBlock reports whether toks is a DO $$ ... $$ statement. +func isDoBlock(toks []cst.Tok) bool { + for _, t := range toks { + if t.Tok.Kind == lexer.Ident { + return lowerASCII(t.Tok.Text) == "do" + } + if !t.Tok.IsTrivia() { + return false + } + } + return false +} + +// formatDoBlock formats a DO $$ ... $$ block by applying formatBody to the +// dollar-quoted string and emitting DO + newline + formatted body. +func formatDoBlock(toks []cst.Tok, st config.Style) string { + // Find the DO keyword, the dollar-string body, and the optional semicolon. + var doTok, bodyTok *cst.Tok + hasSemi := false + for i := range toks { + t := &toks[i] + if t.Tok.IsTrivia() || t.Tok.Kind == lexer.EOF { + continue + } + low := lowerASCII(t.Tok.Text) + if t.Tok.Kind == lexer.Ident && low == "do" && doTok == nil { + doTok = t + continue + } + if doTok != nil && t.Tok.Kind == lexer.DollarString && bodyTok == nil { + bodyTok = t + continue + } + if t.Tok.Kind == lexer.Semicolon { + hasSemi = true + } + } + if doTok == nil || bodyTok == nil { + return verbatimSpan(toks) + } + + nl := st.Newline + var b strings.Builder + b.WriteString(applyCase(doTok.Tok.Text, st.KeywordCase)) + b.WriteString(nl) + b.WriteString(formatBody(bodyTok.Tok.Text, st)) + if hasSemi { + b.WriteString(";") + } + return b.String() +} + func (p *printer) writeCreateFunction(cf *cst.CreateFunction) { // Safety: if the header carries comments we cannot confidently relocate, // emit the whole statement verbatim rather than risk dropping them. + // We still format the body dollar-string independently since it is self-contained. if headerHasComments(cf) { - p.b.WriteString(verbatimSpan(cst.Tokens(cf))) + p.b.WriteString(verbatimSpanFormatBody(cst.Tokens(cf), cf.Body, p.st)) return } @@ -80,11 +136,22 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) { } p.b.WriteString("(") + // Build formatted param texts first so we can measure widths. + paramTexts := make([]string, len(cf.Params)) + for i, param := range cf.Params { + paramTexts[i] = p.inline(param.Toks) + } + + // align_param_types: pad param names so type columns align. + if p.st.AlignParamTypes && len(cf.Params) > 1 { + paramTexts = alignParamTypes(paramTexts) + } + first := p.st.Indent + " " // align item text one column past the comma cont := p.st.Indent - for i, param := range cf.Params { + for i, text := range paramTexts { + param := cf.Params[i] p.nl() - text := p.inline(param.Toks) if i == 0 || p.st.Commas != config.CommaLeading { p.b.WriteString(first) p.b.WriteString(text) @@ -96,6 +163,16 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) { p.b.WriteString(",") p.b.WriteString(text) } + // Emit trailing inline comment from the separator (e.g. --description after param). + if param.Sep != nil { + for _, tr := range param.Sep.Lead { + if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment { + p.b.WriteByte(' ') + p.b.WriteString(strings.TrimRight(tr.Text, " \t")) + break + } + } + } } p.nl() p.b.WriteString(")") @@ -105,7 +182,12 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) { p.b.WriteString(p.inline(clause)) } if cf.As != nil { - p.nl() + // routine_as_wrap: when false, AS stays on the same line as the last option. + if p.st.RoutineAsWrap { + p.nl() + } else { + p.b.WriteByte(' ') + } p.b.WriteString(p.inline([]cst.Tok{{Tok: cf.As.Tok}})) } if cf.Body != nil { @@ -139,7 +221,12 @@ func (p *printer) inline(toks []cst.Tok) string { if i > 0 && needSpace(toks[i-1].Tok, t.Tok) { b.WriteByte(' ') } - b.WriteString(caseText(t.Tok, p.st)) + var prev lexer.Token + if i > 0 { + prev = toks[i-1].Tok + } + nextIsLParen := i+1 < len(toks) && toks[i+1].Tok.Kind == lexer.LParen + b.WriteString(caseTextCtx(t.Tok, prev, nextIsLParen, p.st)) } return b.String() } @@ -147,7 +234,8 @@ func (p *printer) inline(toks []cst.Tok) string { func (p *printer) leadingComments(lead cst.Trivia) { for _, tr := range lead { if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment { - p.b.WriteString(strings.TrimRight(tr.Text, " \t")) + text := strings.TrimRight(strings.ReplaceAll(tr.Text, "\r", ""), " \t") + p.b.WriteString(text) p.nl() } } @@ -157,7 +245,7 @@ func (p *printer) trailingComments(lead cst.Trivia) { cs := commentsOf(lead) for _, c := range cs { p.nl() - p.b.WriteString(strings.TrimRight(c.Text, " \t")) + p.b.WriteString(strings.TrimRight(strings.ReplaceAll(c.Text, "\r", ""), " \t")) } } @@ -211,8 +299,14 @@ func needSpace(a, b lexer.Token) bool { } func caseText(t lexer.Token, st config.Style) string { + return caseTextCtx(t, lexer.Token{}, false, st) +} + +// caseTextCtx applies casing with context: prev is the preceding significant +// token, nextIsLParen indicates the next significant token is '('. +func caseTextCtx(t lexer.Token, prev lexer.Token, nextIsLParen bool, st config.Style) string { if t.Kind != lexer.Ident { - return t.Text // only unquoted words are re-cased + return t.Text } low := lowerASCII(t.Text) switch { @@ -220,6 +314,10 @@ func caseText(t lexer.Token, st config.Style) string { return applyCase(t.Text, st.TypeCase) case isKeyword(low): return applyCase(t.Text, st.KeywordCase) + case nextIsLParen && isBuiltinFunc(low): + return applyCase(t.Text, st.BuiltinCase) + case prev.Kind == lexer.Ident && lowerASCII(prev.Text) == "as": + return applyCase(t.Text, st.AliasCase) default: return applyCase(t.Text, st.IdentCase) } @@ -239,18 +337,25 @@ func applyCase(s string, c config.Case) string { // --- helpers --- // headerHasComments reports whether the function header carries comment trivia -// the formatter cannot confidently relocate. The first token's leading trivia -// is excluded: that is the statement's leading comment, which File() emits -// separately. The body token's own text is excluded too (it is emitted -// verbatim), but a comment in front of the body is caught. +// the formatter cannot confidently relocate. Param separator (Sep) comments +// are excluded — those are trailing inline comments on param lines that the +// formatter emits explicitly after each param text. The first token's leading +// trivia and the body token are also excluded. func headerHasComments(cf *cst.CreateFunction) bool { + // Build a set of Sep token offsets so we can skip them. + sepOffsets := make(map[int]bool, len(cf.Params)) + for _, p := range cf.Params { + if p.Sep != nil { + sepOffsets[p.Sep.Tok.Off] = true + } + } all := cst.Tokens(cf) for i, t := range all { - if i == 0 { + if i == 0 || t.Tok.Kind == lexer.Semicolon { continue } - if t.Tok.Kind == lexer.Semicolon { - continue + if sepOffsets[t.Tok.Off] { + continue // Sep comments handled separately } if hasComment(t) { return true @@ -302,6 +407,27 @@ func verbatimSpan(toks []cst.Tok) string { return b.String() } +// verbatimSpanFormatBody emits toks verbatim but replaces bodyTok's text with +// formatBody output. Used when the function header has comments we cannot +// safely relocate but the body can still be independently formatted. +// If bodyTok is nil the function is identical to verbatimSpan. +func verbatimSpanFormatBody(toks []cst.Tok, bodyTok *cst.Tok, st config.Style) string { + var b strings.Builder + for i, t := range toks { + if i > 0 { + for _, tr := range t.Lead { + b.WriteString(tr.Text) + } + } + if bodyTok != nil && t.Tok.Kind == lexer.DollarString && t.Tok.Off == bodyTok.Tok.Off { + b.WriteString(formatBody(t.Tok.Text, st)) + } else { + b.WriteString(t.Tok.Text) + } + } + return b.String() +} + // hasBlankLine reports whether leading whitespace trivia contains a blank line // (two or more newlines), indicating the author wanted statements separated. func hasBlankLine(lead cst.Trivia) bool { @@ -331,3 +457,66 @@ func lowerASCII(s string) string { } return string(b) } + +// alignParamTypes pads param names so the type column aligns across all params. +// Expected format per param: "[mode] name type [DEFAULT expr]". +// Mode keywords (IN/OUT/INOUT/VARIADIC) are detected and skipped. +// Params without a type are passed through unchanged. +func alignParamTypes(params []string) []string { + type pp struct{ mode, name, rest string } + parsed := make([]pp, len(params)) + maxNameW := 0 + + modeKws := map[string]bool{"in": true, "out": true, "inout": true, "variadic": true} + + for i, s := range params { + fields := strings.Fields(s) + if len(fields) < 2 { + parsed[i].rest = s + continue + } + nameIdx := 0 + if modeKws[lowerASCII(fields[0])] { + nameIdx = 1 + } + if nameIdx >= len(fields) || nameIdx+1 >= len(fields) { + // No type field — keep verbatim. + parsed[i].rest = s + continue + } + if nameIdx > 0 { + parsed[i].mode = fields[0] + } + parsed[i].name = fields[nameIdx] + parsed[i].rest = strings.Join(fields[nameIdx+1:], " ") + if len(parsed[i].name) > maxNameW { + maxNameW = len(parsed[i].name) + } + } + + if maxNameW == 0 { + return params + } + + out := make([]string, len(params)) + for i, p := range parsed { + if p.name == "" { + out[i] = params[i] + continue + } + var b strings.Builder + if p.mode != "" { + b.WriteString(p.mode) + b.WriteByte(' ') + } + b.WriteString(p.name) + // Pad name to (maxNameW+1) so the type column starts at a consistent offset. + pad := maxNameW + 1 - len(p.name) + for k := 0; k < pad; k++ { + b.WriteByte(' ') + } + b.WriteString(p.rest) + out[i] = b.String() + } + return out +} diff --git a/pkg/format/format_test.go b/pkg/format/format_test.go index a38f584..0bdd9ee 100644 --- a/pkg/format/format_test.go +++ b/pkg/format/format_test.go @@ -24,9 +24,9 @@ func TestFormatHeaderGolden(t *testing.T) { want := "--select * from dropall('resolvespec_login');\n" + "CREATE OR REPLACE FUNCTION resolvespec_login(\n" + - " INOUT p_data jsonb\n" + + " INOUT p_data jsonb\n" + " ,OUT p_success boolean\n" + - " ,OUT p_error text\n" + + " ,OUT p_error text\n" + ")\n" + "LANGUAGE plpgsql\n" + "VOLATILE\n" + @@ -71,6 +71,42 @@ func TestFormatBodyBroken(t *testing.T) { } } +func TestFormatMmProcBroken(t *testing.T) { + dir := filepath.Join("..", "..", "testdata", "corpus") + brokenData, err := os.ReadFile(filepath.Join(dir, "test_mm_proc_broken.pgsql")) + if err != nil { + t.Skipf("no test_mm_proc_broken.pgsql: %v", err) + } + goldenData, err := os.ReadFile(filepath.Join(dir, "test_mm_proc.pgsql")) + if err != nil { + t.Skipf("no test_mm_proc.pgsql: %v", err) + } + + got := format(string(brokenData)) + want := string(goldenData) + if got != want { + // Find and report the first differing line. + gotLines := strings.Split(got, "\n") + wantLines := strings.Split(want, "\n") + for i := 0; i < len(gotLines) && i < len(wantLines); i++ { + if gotLines[i] != wantLines[i] { + t.Errorf("format(test_mm_proc_broken) != test_mm_proc.pgsql at line %d\n got: %q\n want: %q", i+1, gotLines[i], wantLines[i]) + break + } + } + if len(gotLines) != len(wantLines) { + t.Errorf("format(test_mm_proc_broken): got %d lines, want %d lines", len(gotLines), len(wantLines)) + } + } + twice := format(got) + if twice != got { + t.Errorf("format(test_mm_proc_broken) is not idempotent") + } + if !semanticallyEqual(string(brokenData), got) { + t.Errorf("format(test_mm_proc_broken) changed semantics") + } +} + func TestCorpusIdempotentAndSafe(t *testing.T) { dir := filepath.Join("..", "..", "testdata", "corpus") entries, err := os.ReadDir(dir) @@ -79,7 +115,7 @@ func TestCorpusIdempotentAndSafe(t *testing.T) { } var seen int for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") || strings.HasSuffix(e.Name(), "_broken.pgsql") { continue } seen++ diff --git a/pkg/format/keywords.go b/pkg/format/keywords.go index 8a6c06f..1049ed6 100644 --- a/pkg/format/keywords.go +++ b/pkg/format/keywords.go @@ -51,5 +51,23 @@ func words(s string) map[string]bool { return m } -func isKeyword(lower string) bool { return keywords[lower] } -func isTypeName(lower string) bool { return typeNames[lower] } +// builtinFunctions are built-in function names controlled by BuiltinCase. +var builtinFunctions = words(` +abs age array_agg array_length array_lower array_ndims array_upper +bit_length btrim cardinality ceil ceiling char_length character_length +chr clock_timestamp coalesce concat concat_ws count +currval decode div encode exp extract floor +generate_series greatest initcap jsonb_agg jsonb_object_agg +justify_days justify_hours justify_interval +lastval least length lower lpad ltrim +max md5 min mod now nullif +overlay pg_sleep position power quote_ident quote_literal +random regexp_match regexp_matches regexp_replace replace reverse round rpad rtrim +setval split_part sqrt string_agg strpos substr substring sum +to_char to_date to_json to_jsonb to_number to_timestamp to_tsvector +translate trim trunc unnest upper width_bucket +`) + +func isKeyword(lower string) bool { return keywords[lower] } +func isTypeName(lower string) bool { return typeNames[lower] } +func isBuiltinFunc(lower string) bool { return builtinFunctions[lower] } diff --git a/testdata/corpus/test_a.pgsql b/testdata/corpus/test_a.pgsql index f037791..694e2b9 100755 --- a/testdata/corpus/test_a.pgsql +++ b/testdata/corpus/test_a.pgsql @@ -1,16 +1,16 @@ ---select * from dropall('resolvespec_login'); +--select * from dropall('resolvespec_login'); CREATE OR REPLACE FUNCTION resolvespec_login( - INOUT p_data jsonb + INOUT p_data jsonb ,OUT p_success boolean - ,OUT p_error text + ,OUT p_error text ) LANGUAGE plpgsql VOLATILE SECURITY DEFINER AS -$$ +$$ DECLARE - --Error Handling-- + --Error Handling-- m_funcname text = 'resolvespec_login'; m_errmsg text; m_errcontext text; @@ -18,7 +18,7 @@ DECLARE m_errhint text; m_errstate text; m_retval integer; - --Error Handling-- + --Error Handling-- m_rid_user integer; m_rid_hub integer; m_pass_hashed citext[]; @@ -134,8 +134,6 @@ BEGIN where u.rid_hub = m_rid_hub; end if; - - select jsonb_build_object('token', m_session ->>'token' , 'session', m_session ->>'session' , 'user', _jsonb_object_cat(jsonb_build_object( diff --git a/testdata/corpus/test_do_prime.pgsql b/testdata/corpus/test_do_prime.pgsql new file mode 100644 index 0000000..718c1ae --- /dev/null +++ b/testdata/corpus/test_do_prime.pgsql @@ -0,0 +1,158 @@ +DO +$$ +DECLARE + m_programtype citext; + m_ins integer; + m_upd integer; + m_retval bigint; +BEGIN + + select s.setvalue + from core.setting s + where s.setname = 'programtype' + and nv(s.disableflag) = 0 + into m_programtype; + + --select newid() + insert into core.mastertype(category, mastertype, description, inactive, forprefix,guid) + select * + from ( + values + ('module','system','Required System Module',0,'','2D4EFDEA-4E7D-4998-8E2A-1F2B67770983') + ) r(category, mastertype, description, inactive, forprefix,guid) + where not exists ( + select 1 from core.mastertype mt + where mt.mastertype = r.mastertype + and mt.category = r.category + ); + + + update core.mastertype u + set jsonvalue = _jsonb_object_cat(u.jsonvalue,jsonb_build_object('subtypes',jsonb_build_array('mergetype','mergetargettype'))) + where u.category = 'docgentype' + and u.mastertype = 'merge' + ; + + ----Merge Target Type + with src(category, mastertype, description, inactive, forprefix,guid,rid_parent) as ( + select * + , ( + select mt.rid_mastertype from core.mastertype mt where mt.guid = '5004E9EC-2E4B-4B36-814C-CFD96EFB434B' limit 1 + ) --docvault + from ( + values ('mergetargettype', 'html', 'HTML', 0, '', '479C8772-52C5-418F-B4FD-FA5F511831E8') + , ('mergetargettype', 'docx', 'Word Document', 0, '', '4899A6E6-467F-41BE-95EE-C3A3938C57C1') + , ('mergetargettype', 'xlsx', 'Excel Document', 0, '', 'AB40EC69-9CEE-4834-A1AC-B317CC47D679') + , ('mergetargettype', 'csv', 'Comma Separated Values (CSV)', 0, '', 'D8EE416A-6A40-4F09-80A7-2576278512AA') + , ('mergetargettype', 'pdf', 'PDF', 0, '', '3776F161-1BF8-474C-8C1A-097D851D6BF9') + , ('mergetargettype', 'same', 'Same as Source', 0, '', 'C7797F0B-5702-4B54-B102-0B610D62730C') + ) r(category, mastertype, description, inactive, forprefix, guid) + ), ins as ( + insert into core.mastertype (category, mastertype, description, inactive, forprefix, guid, rid_parent) + select category, mastertype, description, inactive, forprefix, guid, rid_parent + from src + where + not exists ( + select 1 + from core.mastertype mt2 + where + mt2.category = src.category + and mt2.mastertype = src.mastertype + ) + returning mastertype.* + ), upd as ( + update core.mastertype u + set description = s.description + , inactive = s.inactive + , forprefix = s.forprefix + , guid = s.guid + , rid_parent = s.rid_parent + from src s + where u.category = s.category + and u.mastertype = s.mastertype + and ( + u.description is distinct from s.description + or u.inactive is distinct from s.inactive + or u.forprefix is distinct from s.forprefix + or u.guid is distinct from s.guid + or u.rid_parent is distinct from s.rid_parent + ) + returning u.* + ) + select (select count(1) from ins ) + ,(select count(1) from upd ) + into m_ins, m_upd + ; + + ----Merge Type + insert into core.mastertype(category, mastertype, description, inactive, forprefix,guid,rid_parent) + select * + ,(select mt.rid_mastertype from core.mastertype mt where mt.guid = '5004E9EC-2E4B-4B36-814C-CFD96EFB434B' limit 1) --docvault + from ( + values + ('mergetype','html','HTML Template',0,'','DC3588BC-1865-400D-BE46-8FABE6F9A040') + ,('mergetype','docx','Word Document Template',0,'','8535952E-3D9D-4DD4-9BD0-086A8E5A4804') + ,('mergetype','xlsx','Excel Document Template',0,'','6678B42F-C06F-4CB6-9003-AF22E80A1295') + ,('mergetype','csv','Comma Separated Values (CSV)',0,'','F03F0AC0-D710-4F9D-BB5C-0EAA97245E2A') + ,('mergetype','pdf','PDF Fill in Template',1,'','1197CD7A-D2F6-4B01-8B83-E42315E0060C') + ,('mergetype','stimulsoft','Stimulsoft Template',0,'','491ECBF2-6AD2-452C-8543-B15C65CB7431') + ,('mergetype','sql','SQL Query Template',0,'','74A47C47-2759-4FAB-A2A1-0A42FA87FA46') + ,('mergetype','none','None / No Merge',0,'','640B5044-DDCE-4806-A64F-098BE1324693') + ) r(category, mastertype, description, inactive, forprefix,guid) + where not exists ( + select 1 from core.mastertype mt2 + where mt2.category = r.category + and mt2.mastertype = r.mastertype + ); + + + -----eventtype --select newid() + with src as ( + select r.* + , mt.rid_mastertype as rid_parent + from ( + values ('eventreactioncodetype', 'sql', 'SQL', 0, '', '24AE5A98-024F-4A3D-8BED-9F4A2C410BF9') + , ('eventreactioncodetype', 'api', 'API', 1, '', '163A3EC1-6C2A-4462-A15F-D24F953F9CDA') + , ('eventreactioncodetype', 'frontend', 'Frontend', 0, '', '50023BB9-A58A-4AE8-AAC9-3126D7122D18') + , ('eventreactioncodetype', 'json', 'JSON', 0, '', 'FA331BC2-DCB2-44E0-B835-C62C77A547E3') + + ) r(category, mastertype, description, inactive, forprefix, guid) + cross join ( + select mt.rid_mastertype + from core.mastertype mt + where mt.guid = '2D4EFDEA-4E7D-4998-8E2A-1F2B67770983' + limit 1 + ) mt + ), upd as ( + update core.mastertype u + set mastertype = src.mastertype + ,description = src.description + ,inactive = src.inactive + ,forprefix = src.forprefix + from src + where src.guid = u.guid + returning * + ), ins as ( + insert into core.mastertype (category, mastertype, description, inactive, forprefix, guid, rid_parent) + select src.category, src.mastertype, src.description, src.inactive, src.forprefix, src.guid, src.rid_parent + from src + where + not exists ( + select 1 + from core.mastertype mt2 + where + mt2.category = src.category + and mt2.mastertype = src.mastertype + ) + returning * + ) + select (select count(1) from ins) + (select count(1) from upd ) + into m_retval; + + +END; + + +$$; + + diff --git a/testdata/corpus/test_mm_proc.pgsql b/testdata/corpus/test_mm_proc.pgsql new file mode 100644 index 0000000..aff3f27 --- /dev/null +++ b/testdata/corpus/test_mm_proc.pgsql @@ -0,0 +1,1512 @@ +CREATE OR REPLACE FUNCTION mm_proc( + p_doctype text --File Types: html, docx, text, allfieldvalues + ,p_commtype text --Merge Data Type: e.g. SMS,Email,All (Will be passed through to menu/taglist funct) + ,p_template bytea --Source/Template file to be merge + ,p_data_prefix text --Table prefix for data source. e.g. tcli (client) , sta (standard letters) + ,p_data_rid integer --rid for given table prefix + ,p_filterdata json = NULL --Advanced filter data. + ,OUT p_doc bytea --Merged document output + ,OUT p_docguid text --optional guid to store document guid from tempfile table. (For disk reading) + ,OUT p_retval integer + ,OUT p_errmsg text +) +LANGUAGE plpgsql +VOLATILE +AS +$$ +DECLARE + --Error Handling-- + m_funcname text = 'mm_proc'; + m_errmsg text; + m_errcontext text; + m_errdetail text; + m_errhint text; + m_errstate text; + m_retval integer; + --Error Handling-- + m_blankexec text; + m_comma text; + m_debug_exestr text; + m_exec_orderstr text; + m_execfilter text; + m_execstr text; + m_fields json; + m_guid citext; + m_json json; + m_json_full json; + m_json_full_complex jsonb; + m_result text; + m_tablefilter text; + m_temppath citext; + m_user citext; + m_exttype citext; + m_returnvalues boolean = false; + m_data_prefix citext; + m_data_rid integer; + m_rid_comm integer; + m_rid_commattachment integer; + m_rid_obl integer; + m_doctype citext; + m_ltime timestamp; + m_hasfilestream boolean; + m_rid_obligation integer; + g_debug boolean; + g_tag_s text; + g_tag_e text; + g_tag_split text; + g_tag_oper text; + g_mtype_root integer = 0; + g_mtype_field integer = 1; + g_mtype_tblfield integer = 2; + g_mtype_tblroot integer = 3; + g_mtype_aggfield integer = 4; + g_mtype_picture integer = 5; + g_mtype_special integer = 6; + g_mtype_filter integer = 7; + g_mtype_condfield integer = 8; + g_mtype_docreplace integer = 9; + g_mtype_html integer = 10; + g_benchmark integer; + a_tblroot integer[]; + a_types citext[]; + a_inner_selected citext[]; + r_template record; + r_doc record; + r_lp record; + r_lp_t record; + r_lp_c record; + r_retval record; + r_tmp record; + --r_lp_prev record; + m_start timestamp = clock_timestamp(); +BEGIN + p_retval = 0; + p_errmsg = ''; + G_DEBUG = True; + G_BENCHMARK = 0; + G_TAG_S = '[*'; --chr(171); + G_TAG_E = '*]';--chr(187); + G_TAG_SPLIT = '|'; + G_TAG_OPER = ':'; --operator + m_exttype = 'txt'; + a_types = array['html','docx','txt','plainhtml','allfieldvalues']; -- + m_hasfilestream = false; + a_inner_selected = array[]::citext[]; + + m_ltime = clock_timestamp(); + m_guid = newid(); + m_user = f_getuser(); + + -- if p_doctype ilike '%plain%' and f_iscompressed(p_template) in ('none') -- and byteatotext(p_template) not ilike '%'|| G_TAG_S || '%' || G_TAG_E || '%' -- then -- raise warning 'No tags to merge in plain template.'; -- p_doc = p_template; -- return; -- end if; + + -- perform log_event(m_funcname,format('INIT _ type=%s commtype=%s p_data_prefix=%s p_data_rid=%s Template:%s' -- ,p_doctype, p_commtype,p_data_prefix,p_data_rid -- ,case when octet_length(p_template) < 100 then byteatotext(p_template) else octet_length(p_template)::text end -- ),bt_enum('eventlog','local notice')); + + if p_data_prefix::citext = 'com' + then + select c.rid_communication + , c.rid_parent + , c.tableprefix + from communication c + where c.rid_communication = p_data_rid into m_rid_comm, m_data_rid, m_data_prefix; + + elsif p_data_prefix::citext = 'cra' + then + m_rid_commattachment = p_data_rid; + select c.rid_communication + , c.rid_parent + , c.tableprefix + , ca.rid_obligation + , dt.typex + from commattachment ca + inner join communication c on c.rid_communication = ca.rid_parent + and ca.tableprefix = 'com' + left outer join document d on d.rid_document = ca.rid_document + left outer join documenttype dt on dt.rid_documenttype = d.rid_documenttype + where ca.rid_commattachment = p_data_rid into m_rid_comm, m_data_rid, m_data_prefix, m_rid_obl, m_doctype; + + elsif p_data_prefix::citext = 'ctl' + then + + select ctl.rid_parent as rid_parent + , ctl.tableprefix as parent_prefix + from tasklist ctl --inner join t_adproclient cli on cli.rid_adproclient = ctl.rid_parent -- and ctl.tableprefix = 'tcli' + where ctl.rid_tasklist = p_data_rid into m_data_rid, m_data_prefix; + + elsif p_data_prefix::citext = 'ehr' + then + raise exception 'Merging email headers are not supported at this time.'; + + else + m_data_prefix = p_data_prefix; + m_data_rid = p_data_rid; + end if; + + ---init: start--- + select format('tmpl_%s_%s_%s.%s', m_data_prefix, m_data_rid, m_guid, p_doctype)::citext as filename + ,''::citext as filepath + ,''::citext as debugsql_filename + ,''::citext as debug_filename + ,''::citext as debug_peekfilename + ,''::citext as magictype + ,'T' || m_guid::citext as guid + ,p_template::bytea as blob + into r_template; + + select format('doc_%s_%s_%s.%s', m_data_prefix, m_data_rid, m_guid, p_doctype)::citext as filename + ,''::citext as filepath + ,'D' || m_guid::citext as guid + ,null::bytea as blob + into r_doc; + + -- perform log_event(m_funcname,format('init = p_doctype = %s p_data_prefix = %s p_data_rid = %s p_template[size] = %s' -- ,p_doctype,p_data_prefix,p_data_rid,octet_length(p_template)),bt_enum('eventlog','local notice')); + + if p_doctype = 'allfieldvalues' + then + m_returnvalues = true; + elsif p_doctype::citext <> all(a_types) + then + raise exception 'This type [%] is not yet supported. Allowed types: %', p_doctype, a_types; + end if; + + if p_template is null and not m_returnvalues + then + p_retval = 1; + p_errmsg = 'No template'; + return; + end if; + + if p_commtype ilike '%sms%' and (p_doctype ilike '%text%' or p_doctype ilike '%html%' or p_doctype ilike '%all%') + then + p_doctype = 'plainhtml'; + end if; + + select f_normpath(b.programpath || '/temp/mailmerge')::citext + ,b.sqldebuglevel > 99 + from f_bus() b limit 1 + into m_temppath, G_DEBUG; + + if m_hasfilestream or G_DEBUG + then + perform pl_mkdir(m_temppath); + end if; + + r_template.magictype = f_normpath(f_iscompressed(p_template))::citext; + r_template.filepath = f_normpath(format('%s/%s',m_temppath, r_template.filename))::citext; + r_template.debugsql_filename = f_normpath(format('%s/%s%s/%s.pgsql',m_temppath, p_data_prefix, p_data_rid, r_template.filename))::citext; + r_template.debug_filename = f_normpath(format('%s/%s%s/%s.debug.json',m_temppath, p_data_prefix, p_data_rid, r_template.filename))::citext; + r_template.debug_peekfilename = f_normpath(format('%s/%s%s/%s.peek.json',m_temppath, p_data_prefix, p_data_rid, r_template.filename))::citext; + + if g_debug + then + perform pl_mkdir(f_normpath(format('%s/%s%s',m_temppath, p_data_prefix, p_data_rid))); + perform pl_writefile(f_normpath(format('%s/%s%s/%s.template.docx',m_temppath, p_data_prefix, p_data_rid,r_template.filename)) + ,p_template); + end if; + + r_doc.filepath = f_normpath(format('%s/%s',m_temppath, r_doc.filename))::citext; + + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Before MergeMenu SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + + drop table if exists tmp_merge_init_src; + create temp table tmp_merge_init_src + --on commit drop + as + with msrc as ( + select * + from crm_get_merge_menu(p_commtype::citext) p + ), src as ( + select c.rid_tree_mergefeld::integer as rid + ,nv(c.rid_parent)::integer as rid_parent + ,btrim(nv(c.mergetag),' ')::citext as mergetag + ,nv(c.mergetype) as merge_type + ,btrim(nv(c.tablename),' ')::citext as table_name + ,btrim(nv(c.fieldname),' ')::citext as field_name + ,btrim(nv(c.filterstring),' ')::citext as filter_string + ,btrim(nv(g.orderstring),' ')::citext as order_string + --parent + ,nv(p.rid_tree_mergefeld)::integer as parent_rid + ,nv(p.rid_parent)::integer as parent_rid_parent + ,btrim(p.mergetag,' ')::citext as parent_mergetag + ,nv(p.mergetype)::integer as parent_merge_type + ,ifblnk(btrim(p.tablename,' '), btrim(nv(g.tablename),' '))::citext as parent_table_name + ,btrim(nv(p.fieldname),' ')::citext as parent_field_name + ,ifblnk(btrim(p.filterstring,' '),btrim(nv(g.filterstring),' '))::citext as parent_filter_string + ,ifblnk(btrim(p.orderstring,' '),btrim(nv(g.orderstring),' '))::citext as parent_order_string + --grand parent + ,nv(g.rid_tree_mergefeld)::integer as grand_rid + ,btrim(g.mergetag,' ')::citext as grand_mergetag + ,nv(g.mergetype)::integer as grand_merge_type + ,ifblnk(g.tablename, nv(g.tablename))::citext as grand_table_name + ,btrim(nv(g.fieldname),' ')::citext as grand_field_name + ,btrim(nv(g.filterstring),' ')::citext as grand_filter_string + from msrc p + left outer join msrc c on c.rid_parent = p.rid_tree_mergefeld --child + and nv(c.mergetype) > 0 + left outer join msrc g on g.rid_tree_mergefeld = p.rid_parent --grand parent + where nv(p.mergetype) = 0 + or (p.mergetype > 0 and c.rid_tree_mergefeld > 0) + ) + select d.rid + , d.rid_parent + , case + when nv(d.merge_type) = G_MTYPE_SPECIAL and strpos(d.mergetag, G_TAG_SPLIT) > 0 + then substr(d.mergetag, 0, strpos(d.mergetag, G_TAG_SPLIT)) || + substr(d.mergetag, length(d.mergetag) - length(G_TAG_E) + 1, length(d.mergetag)) + else d.mergetag + end + ::citext as mergetag + ,d.merge_type + , d.table_name + , d.field_name + , d.filter_string + , d.order_string + , d.parent_rid + , d.parent_rid_parent + , d.parent_mergetag + , d.parent_merge_type + , d.parent_table_name + , d.parent_field_name + , d.parent_filter_string + , d.parent_order_string + , d.grand_rid + , d.grand_mergetag + , d.grand_merge_type + , d.grand_table_name + , d.grand_field_name + , d.grand_filter_string + from src d ; + + --CREATE INDEX "idx_tmp_merge_init_src_type" ON tmp_merge_init_src USING btree (merge_type); --CREATE INDEX "idx_tmp_merge_init_src_tag" ON tmp_merge_init_src USING btree (mergetag); + + --HTML field types has a special meaning in the frontend. We treat them as normal fields. update tmp_merge_init_src u + set merge_type = G_MTYPE_FIELD + where u.merge_type = G_MTYPE_HTML; + + --raise notice 'After MergeMenu: %', (clock_timestamp() - m_ltime); if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After MergeMenu SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + + --Do a peek on the template and get fields we need to fetch. + if not m_returnvalues + then + + if nv(octet_length(p_template)) < 2 + then + raise exception E'Template document not found or blank. \r\n% File: %',octet_length(p_template), r_template.filepath; + end if; + + if m_hasfilestream + then + --filesystem + + select r.p_retval, r.p_errmsg + from pl_writefile(r_template.filepath, p_template) r into m_retval, m_errmsg; + + if m_retval > 0 + then + raise exception 'Failed to save template file to temp directory. %',m_errmsg using hint = 'in pl_writefile, while peaking template'; + end if; + + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Before Peek SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + + select r.p_retval, r.p_errmsg, r.p_result + from pl_mailmerge(format('peek_%s', p_doctype), r_template.filepath, '', '' + , 1 /*New mode, new tags*/) r into m_retval, m_errmsg, m_result; + + if m_retval > 0 + then + raise exception '%', m_errmsg using hint = 'in pl_mailmerge, while peaking template'; + end if; + + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After Peek SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + + if nv(m_result) = '' --or m_result not ilike '%{%}%' + then + raise exception 'No data returned from peak.' using hint = 'in pl_mailmerge, while peaking template'; + end if; + + if G_DEBUG + then + perform pl_writefile(r_template.debug_peekfilename, convert_to(m_result,'utf8')); + end if; + + select r.p_retval, r.p_errmsg + from f_tempfile_add(r_template.filename, p_doctype, r_template.guid, 600, m_data_rid, + m_data_prefix) r into m_retval, m_errmsg; + + else + --stream + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Before Peek SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + + select r.p_retval, r.p_errmsg, r.p_result, r.p_file + from pl_mailmerge(format('peek_%s', p_doctype), null, null, '', 1, p_template) r into m_retval, m_errmsg, m_result; + + if m_retval > 0 + then + raise exception '%', m_errmsg using hint = 'in pl_mailmerge, while peaking template'; + end if; + + if G_DEBUG + then + perform pl_writefile(r_template.debug_peekfilename, convert_to(m_result,'utf8')); + end if; + + --perform log_event(m_funcname,format('Peek Result: %s', m_result),bt_enum('eventlog','local notice')); + + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After Peek SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + + end if; + + m_fields = m_result::json; + + drop table if exists tmp_merge_init_fields; + create temp table tmp_merge_init_fields + --on commit drop + as + with docfields as ( + select 'document'::citext as source + ,btrim(btrim(r::text,' '),'"')::citext as mergetag + ,1 as taglocation + from json_array_elements(m_fields->'doctags') r + union all + select 'header'::citext as source + ,btrim(btrim(r::text,' '),'"')::citext as mergetag + ,1 as taglocation + from json_array_elements(m_fields->'headertags') r + union all + select 'footer'::citext as source + ,btrim(btrim(r::text,' '),'"')::citext as mergetag + ,1 as taglocation + from json_array_elements(m_fields->'footertags') r + union all + --also the table tags in all the tables. + select r.key::citext as source + ,replace(l.value::text,'"','')::citext as mergetag + ,2 as taglocation + from json_each(m_fields->'tabletags') r (key,value) + left outer join json_array_elements(r.value) l (value) on json_typeof(r.value) = 'array' + where nv(l.value::text) <> '' + union all + select 'raw'::citext as source + ,btrim(btrim(r::text,' '),'"')::citext as mergetag + ,1 as taglocation + from json_array_elements(m_fields->'raw') r + ) + select distinct + on (d.source, d.taglocation,d.mergetag) + d.source::citext as source + ,(case when d.taglocation = 2 then split_part(d.source, ',', 1) else null end)::citext as tblid + ,(case when d.taglocation = 2 then split_part(d.source, ',', 2) else null end)::citext as tblparent + ,format('%s%s%s',G_TAG_S, replace(replace(split_part(split_part(d.mergetag, G_TAG_SPLIT,1) , G_TAG_OPER,1),G_TAG_E,''),G_TAG_S,''),G_TAG_E)::citext as mergetag + ,replace(case when strpos(d.mergetag,G_TAG_SPLIT) > 0 and d.taglocation <> 5 and strpos(d.mergetag,G_TAG_E)- strpos(d.mergetag,G_TAG_SPLIT)-1 > 0 + then substr(d.mergetag,strpos(d.mergetag,G_TAG_SPLIT)+1, strpos(d.mergetag,G_TAG_E)- strpos(d.mergetag,G_TAG_SPLIT)-1) + when strpos(d.mergetag,G_TAG_OPER) > 0 and strpos(d.mergetag,G_TAG_E)- strpos(d.mergetag,G_TAG_OPER)-1 > 0 + then substr(d.mergetag,strpos(d.mergetag,G_TAG_OPER)+1, strpos(d.mergetag,G_TAG_E)- strpos(d.mergetag,G_TAG_OPER)-1) + else '' + end,'''','')::citext as tagvalue + ,(regexp_split_to_array(replace(replace(d.mergetag,G_TAG_E,''),G_TAG_S,''),'(\'|| G_TAG_OPER || '|\' || G_TAG_SPLIT || ')' ))[2:] as operators + ,d.taglocation::citext as taglocation + ,d.mergetag as originalmergetag + ,0:: integer as table_level + ,row_number() over(order by d.source, d.mergetag) as rowid + from docfields d ; + + ---special update for tables linking update tmp_merge_init_fields u + set tblparent = r.p_tblparent + , source = r.source + , tblid = r.p_tblid from ( + select distinct f.tblid + , f.tblparent + , f2.tblparent as p_tblparent + , f2.tblid as p_tblid + , f.mergetag + , f2.source + from tmp_merge_init_fields f + inner join tmp_merge_init_fields f2 on f2.tblid = f.tblparent + where f.mergetag ilike '%free!tbl%' + ) r + where u.tblid = r.tblid; + + update tmp_merge_init_fields u + set table_level = r.table_level from ( + select f1.rowid + ,case when nv(f4.tblid) <> '' then 4 + when nv(f3.tblid) <> '' then 3 + when nv(f2.tblid) <> '' then 2 + when nv(f1.tblid) <> '' then 1 + else 0 + end as table_level + from tmp_merge_init_fields f1 + left outer join tmp_merge_init_fields f2 on f1.tblparent = f2.tblid + left outer join tmp_merge_init_fields f3 on f2.tblparent = f3.tblid + left outer join tmp_merge_init_fields f4 on f3.tblparent = f4.tblid + ) r + where u.rowid = r.rowid ; + + else + drop table if exists tmp_merge_init_fields; + create temp table tmp_merge_init_fields + --on commit drop + as + select distinct + on (s.mergetag) + ''::citext as source + ,''::citext as tblid + ,''::citext as tblparent + ,s.mergetag::citext as mergetag + ,''::citext as tagvalue + ,array[]::citext[] as operators + ,1::citext as taglocation + ,s.mergetag::citext as originalmergetag + from tmp_merge_init_src s + where s.merge_type in (G_MTYPE_ROOT, G_MTYPE_FIELD, G_MTYPE_AGGFIELD); + + end if; --End of do a peek + + --raise notice 'After Merge: %', (clock_timestamp() - m_ltime); + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After MergeMenu SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + + if not exists ( + select 1 + from tmp_merge_init_fields m + where nv(m.mergetag) <> '' + ) + then + -- perform log_event(m_funcname,format('NO TAGS p_doctype=%s p_commtype=%s p_data_prefix=%s p_data_rid=%s' -- ,p_doctype, p_commtype,p_data_prefix,p_data_rid),bt_enum('eventlog','local notice')); -- + p_doc = p_template; + + return; + end if; + + --Update [extra_filter] + /* + update tmp_merge_init_src u + set table_name = replace(u.table_name, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , parent_table_name = replace(u.parent_table_name, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , field_name = replace(u.field_name, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , parent_field_name = replace(u.parent_field_name, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , filter_string = replace(u.filter_string, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , parent_filter_string = replace(u.parent_filter_string, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + from ( + select f.tblid + , f.mergetag + , ltrim(ltrim(string_agg(ifblnk(s2.filter_string,'True'), ' and '),' '), 'and') as extra_filter + from tmp_merge_init_fields f + inner join tmp_merge_init_fields f2 on f2.tblid = f.tblid + inner join tmp_merge_init_src s2 on s2.mergetag = f2.mergetag + and s2.merge_type = G_MTYPE_FILTER + group by f.tblid,f.mergetag + ) r + where u.mergetag = r.mergetag + ; + */ + + /* + ---Use this code to test + select pl_writefile('/mnt/t/temp/t.docx',r.p_doc) ,r.* + from mm_proc('docx','allfieldvalues', ( + select tat.maindoc + from templateattachment tat + --cross join view_blob(tat.rid_templateattachment,'templateattachment','maindoc','rid_templateattachment','utf8') v + where tat.description ilike 'Form 16 (%' + ), 'TCLI',1000016) r + */ /* + + drop table if exists debug_merge_init_src; + create table debug_merge_init_src as + select * from tmp_merge_init_src + ; + + drop table if exists debug_merge_init_fields; + create table debug_merge_init_fields as + select * from tmp_merge_init_fields + ; + + */ + + --------Keywords For Client,Creditor,Debt Counsellor,Trader--------------------------------------------------------- + ---ID replace process + + if m_data_prefix::citext in ('tclc','clc') + then + m_rid_obligation = m_data_rid; + m_rid_obl = m_data_rid; + + select clc.rid_adproclient + from t_adproclientcreditorl1 clc + where clc.rid_adproclientcreditorl1 = m_data_rid into m_data_rid; + + m_data_prefix = 'TCLI'; + + end if; + + if m_data_prefix::citext in ('tcli','tclc','usr','sclc') + then + + if m_data_prefix::citext in ('tcli') + and not exists ( + select 1 + from t_adproclient cli + where cli.rid_adproclient = m_data_rid + ) + then + raise exception 'Client % does not exists.', m_data_rid; + end if; + -----------------------------------------------------------[CHANGE ID: 29078] 14/07/2020 14:54 Begin + if m_data_prefix::citext in ('usr') + then + select usr.login + from users usr + where usr.rid_user = m_data_rid into m_user; + + m_data_rid = 0; + end if; + + if m_rid_comm > 0 and nv(m_rid_obligation) = 0 + then + + select ct.rid_obligation + from commattachment ct + where ct.tableprefix = 'COM' + and ct.rid_parent = m_rid_comm + and ct.doclink = 1 + and ct.rid_obligation > 0 + order by ct.rid_templateattachment desc limit 1 + into m_rid_obligation ; + + end if; + + /*if r_comm.data_prefix = 'RPC' + then + select usr.login + from remotepc rpc + inner join users usr on usr.rid_user = rpc.rid_user + where rpc.rid_remotepc = r_comm.data_rid + into m_user + ; + + m_data_rid = 0; + end if;*/ + + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Merge Replace SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + + for r_lp in ( + select '[rid_obligation]'::citext as key + ,ifblnk(m_rid_obligation,0)::citext as value + ) + loop + update tmp_merge_init_src u + set table_name = replace(u.table_name, r_lp.key, r_lp.value) + , parent_table_name = replace(u.parent_table_name, r_lp.key, r_lp.value) + , field_name = replace(u.field_name, r_lp.key, r_lp.value) + , parent_field_name = replace(u.parent_field_name, r_lp.key, r_lp.value) + , filter_string = replace(u.filter_string, r_lp.key, r_lp.value) + , parent_filter_string = replace(u.parent_filter_string, r_lp.key, r_lp.value) ; + end loop; + -----------------------------------------------------------[CHANGE ID: 29078] 14/07/2020 14:54 End for r_lp in ( + with keywords as ( + select distinct r.v[1]::citext as colname + from tmp_merge_init_src f + cross join regexp_matches(format('%s %s %s %s %s %s',f.table_name,f.parent_table_name,f.field_name,f.parent_field_name,f.parent_filter_string,f.filter_string) + , '\[(.*?)\]', 'ig') r(v) + where m_data_prefix = 'tcli' + and length(nv(r.v[1])) > 2 + ) + ,keyworddata as ( + select r.j + from exec(format('select (''{ "debugflag": "%s"'' %s || ''}'')::json from t_adproclient cli where cli.rid_adproclient = %s' + ,G_DEBUG + ,(select string_agg(format('|| '',"%s":"'' || nv(%s)::text || ''"''',format('[%s]',k.colname), k.colname), E'\r\n') from keywords k + where nv(k.colname) not in ( + 'rid_communication' + ,'rid_commattachment' + ,'rid_obl_filter' + ,'exec_user' + ,'extra_filter' + ,'rid_user' + ,'p_doctype' + ,'rid_obligation' + ----!!!!!Remeber to add the records here to exclude if the already have a value below + ) + ) + ,m_data_rid + ) + ) r(j json) + ) + select j.key::citext as key + , btrim(j.value::text,'"')::text::citext as value + from keyworddata d + cross join json_each(d.j) j(key,value) + where j.key <> '' + -----Some extra generic keywords + union all + + select '[rid_communication]'::citext as key + ,ifblnk(m_rid_comm,0)::citext as value + union all + select '[rid_obl_filter]'::citext as key + ,ifblnk(m_rid_obl,0)::citext as value + union all + select '[exec_user]'::citext as key + ,nv(m_user)::citext as value + union all + select '[extra_filter]'::citext as key + ,'True'::citext as value + union all + select '[rid_commattachment]'::citext as key + ,ifblnk(m_rid_commattachment,0)::citext as value --[CHANGE ID: 28878] 23/06/2020 11:33 + /* union all + select '[rid_remotepc]'::citext as key + ,nv(r_comm.data_rid)::citext as value --[CHANGE ID: 29078] 15/07/2020 9:36*/ + union all + select '[p_doctype]'::citext as key + ,ifblnk(p_doctype,'docx')::citext as value + ) + loop + + update tmp_merge_init_src u + set table_name = replace(u.table_name, r_lp.key, r_lp.value) + , parent_table_name = replace(u.parent_table_name, r_lp.key, r_lp.value) + , field_name = replace(u.field_name, r_lp.key, r_lp.value) + , parent_field_name = replace(u.parent_field_name, r_lp.key, r_lp.value) + , filter_string = replace(u.filter_string, r_lp.key, r_lp.value) + , parent_filter_string = replace(u.parent_filter_string, r_lp.key, r_lp.value) ; + + end loop; + + else + + select string_agg(distinct format('[%s]', r.v[1]::text), ',') + , count(1) + from tmp_merge_init_src f + cross join regexp_matches( + format('%s %s %s %s %s %s', f.table_name, f.parent_table_name, f.field_name, f.parent_field_name, + f.parent_filter_string, f.filter_string) + , '\[(.*?)\]', 'ig') r(v) into m_errmsg,m_retval; + + if m_retval > 0 + then + raise exception E'The following data fields could not be found for prefix % \r\n%', ifblnk(m_data_prefix, 'Null'), m_errmsg using hint = 'in ID replace process'; + end if; + + end if; + + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Merge Filter SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + + --------Basic Fields--------------------------------------------------------------------------------- + m_execstr = ''; + m_execfilter = ''; + m_blankexec = ''; + m_comma = ''; + + for r_lp in ( + with fields as ( + select f.mergetag + ,f.merge_type + ,f.table_name + ,f.field_name + ,f.filter_string + ,f.parent_mergetag + ,f.parent_merge_type + ,f.parent_table_name + ,f.parent_field_name + ,f.parent_filter_string + + from tmp_merge_init_src f + ) + + ,filtereddocfields as ( + select distinct on (d.originalmergetag, d.taglocation) + d.source + ,d.tblid + ,d.tblparent + ,d.mergetag as joinmergetag --- because opertators are filtered out already, we join without operators to be able to find the tag. + ,(case when strpos(d.originalmergetag, G_TAG_OPER) > 0 then d.originalmergetag + else d.mergetag + end)::citext as mergetag + ,replace(d.tagvalue,'''','') as tagvalue + ,d.taglocation + ,d.originalmergetag + ,d.operators + + from tmp_merge_init_fields d + ) + select + f.merge_type + ,f.table_name + ,f.field_name + ,f.filter_string + ,f.parent_mergetag + ,f.parent_merge_type + ,f.parent_table_name + ,f.parent_field_name + ,f.parent_filter_string + ,d.mergetag + ,replace(d.tagvalue,'''','') as tagvalue + ,d.source + ,d.tblid + ,d.operators + ,row_number() over(partition by (case + when f.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else f.merge_type + end) + , f.parent_table_name + , f.table_name + , f.parent_filter_string + , f.filter_string + order by (case + when f.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else f.merge_type + end) asc + , f.parent_table_name + , f.table_name + , f.field_name + ) as rn + ---Additional fields + ,''::citext as ops_string + from fields f + inner join filtereddocfields d on d.joinmergetag = f.mergetag + where f.merge_type in (G_MTYPE_ROOT,G_MTYPE_FIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE, G_MTYPE_CONDFIELD) --all except tables + and (f.merge_type <> G_MTYPE_AGGFIELD + or f.merge_type = G_MTYPE_AGGFIELD + and nv(d.source) = '' + ) + order by (case when f.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else f.merge_type + end) asc + , f.parent_table_name + , f.table_name + , rn desc + ) + loop + + --raise notice 'Fields: %', r_lp; + if nv(r_lp.field_name) = '' and r_lp.merge_type not in (G_MTYPE_TBLROOT,G_MTYPE_SPECIAL) + then + if G_DEBUG + then + perform log_event(m_funcname,format('Blank field name for p_doctype=%s, p_commtype=%s, p_data_prefix=%s, p_data_rid=%s + field_name=%s, merge_type=%s, table_name=%s + ' ,p_doctype, p_commtype,p_data_prefix,p_data_rid + ,r_lp.field_name,r_lp.merge_type,r_lp.table_name + --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text + ),bt_enum('eventlog','local notice')); + end if; + --raise exception 'Blank field name for record %', r_lp; continue ; + end if; + + if nv(r_lp.ops_string) = '' + then + r_lp.ops_string = r_lp.field_name; + end if; + + for r_tmp in ( + select o.r as op + ,split_part(o.r, '=', 1) as opname + ,split_part(o.r, '=', 2) as opval + from unnest(r_lp.operators) with ordinality o(r,i) + order by o.i asc + ) + loop + r_lp.ops_string = mailmerge_rule(r_lp.ops_string::citext, r_tmp.opname::citext, r_tmp.opval::citext, r_lp.mergetag::citext); + end loop; + + --perform log_event(m_funcname,format('mailmerge_rules %s',r_lp.ops_string),bt_enum('eventlog','local notice')); + + if r_lp.merge_type = G_MTYPE_AGGFIELD + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s::text, 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag + ,r_lp.ops_string, r_lp.merge_type, E'\r\n'); + elseif r_lp.merge_type = G_MTYPE_PICTURE + then + m_execstr = format($S$%s|| '%s"%s":' + || json_build_object('value',%s::text, 'type', '%s' + , 'w', mailmerge_specialfield('width', '%s', %s) ,'h', mailmerge_specialfield('height', '%s', %s))::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag,r_lp.field_name, r_lp.merge_type + ,r_lp.mergetag,quote_nullable(m_data_rid),r_lp.mergetag,quote_nullable(m_data_rid), E'\r\n'); + + elseif r_lp.merge_type = G_MTYPE_TBLFIELD + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::text), 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag + ,r_lp.ops_string + ,r_lp.merge_type, E'\r\n'); + elseif r_lp.merge_type = G_MTYPE_SPECIAL + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s, 'type', '%s')::text %s$S$,m_execstr,m_comma,r_lp.mergetag,quote_literal(r_lp.tagvalue), r_lp.merge_type, E'\r\n'); + + else + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s::text, 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag + ,r_lp.ops_string + ,r_lp.merge_type, E'\r\n'); + + end if; + + m_execfilter = format('%s %s',nv(m_execfilter), r_lp.filter_string); + + --We use this to fill in blanks. + m_blankexec = format($S$%s|| '%s"%s":' || json_build_object('value','', 'type', '%s')::text %s$S$,m_blankexec,m_comma,r_lp.mergetag, r_lp.merge_type, E'\r\n'); + + if r_lp.rn = 1 + then + if ifblnk(r_lp.parent_table_name,'') = '' + then + m_execstr = format(E'select (''{'' %s \r\n || ''}'')::json ;',m_execstr ); + else + select string_agg(s.filter_string, ' ') + from tmp_merge_init_fields f + inner join tmp_merge_init_src s on s.mergetag = f.mergetag + and s.merge_type = G_MTYPE_FILTER + where f.source = r_lp.source + and exists ( --make sure there is a parent, else this breaks. It means there will be not parent table. + select 1 + from tmp_merge_init_src s2 + where s2.rid = s.rid_parent) + into m_tablefilter ; + + m_execstr = format(E'select (''{'' %s \r\n || ''}'')::json \r\nfrom %s \r\n%s;' + ,m_execstr, r_lp.parent_table_name,ifblnk(r_lp.parent_filter_string, ' where 1=1 ') || nv(m_execfilter) || nv(m_tablefilter) ); + end if; + + m_blankexec = format(E'select (''{'' %s \r\n || ''}'')::json \r\n;',m_blankexec ); + m_debug_exestr = nv(m_debug_exestr) || E'\r\n----'|| r_lp.parent_table_name ||E'\r\n' || nv(m_execstr) || E'\r\n'; + + select r.str::json + from exec(m_execstr) r(str json) into m_json; + + if m_json is null + then + select r.str::json + from exec(m_blankexec) r(str json) into m_json; + end if; + + if nv(m_json_full::text) <> '' + then + m_json_full = (m_json_full::jsonb || m_json::jsonb)::json; + else + m_json_full = m_json; + end if; + + m_tablefilter = ''; + m_execfilter = ''; + m_execstr = ''; + m_blankexec = ''; + m_comma = ''; + end if; + + if nv(m_comma) = '' and length(m_execstr) > 2 + then + m_comma = ','; + end if; + end loop; + + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Complex Fields SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); + end if; + --------Complex Fields--------------------------------------------------------------------------------- + m_execfilter = ''; + m_tablefilter = ''; + m_execstr = ''; + m_blankexec = ''; + m_comma = ''; + m_exec_orderstr = ''; + + -----@2023-10-24 - + ---This code must move the tag of a parent col in the child table back to the parent table in order to complete the join. + + /* + with a as ( + select d.mergetag as mt + ,d.source + ,d.tblparent + ,d.tblid + ,d.table_level + ,d.rowid + ,d.taglocation + ,f.* + from tmp_merge_init_src f + inner join tmp_merge_init_fields d on d.mergetag = f.mergetag + where f.merge_type = 2 + ), aa as ( + select + (select count(1) from a b where b.rid_parent = a.rid_parent and b.tblid = a.tblid) as cnt + ,* + from a + ), b as ( + select aa.rid + ,aa.rowid + ,aa.rid_parent + ,aa.tblid + ,aa.cnt < (select b.cnt from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as oddone + ,(select b.tblid from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as newtblid + ,(select b.tblparent from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as newtblparent + ,(select b.source from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as newsource + ,(select b.table_level from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as new_table_level + ,aa.mt + from aa + ) + update tmp_merge_init_fields f + set tblid = b.newtblid + ,tblparent = b.newtblparent + ,source = b.newsource + ,table_level = b.new_table_level + from b + where f.rowid = b.rowid + ; + */ + + select array_agg(f.rid) + from tmp_merge_init_src f + where f.grand_merge_type = G_MTYPE_TBLROOT + and f.merge_type = G_MTYPE_TBLROOT into a_tblroot; + + if a_tblroot is null then a_tblroot = array[-1]; +end if; +m_json_full_complex = jsonb_build_object(); + +--a_tblroot = array[-1]; BEGIN + + for r_lp_t in ( + with fields as ( + select f.rid + ,f.mergetag + ,f.merge_type + ,f.table_name + ,f.field_name + ,f.filter_string + ,f.parent_rid + ,f.parent_mergetag + ,f.parent_merge_type + ,f.parent_table_name + ,f.parent_field_name + ,f.parent_filter_string + ,f.grand_rid + ,f.grand_mergetag + ,f.grand_merge_type + ,f.grand_table_name + ,f.grand_field_name + ,f.grand_filter_string + ,f.order_string + ,f.parent_order_string + ,newid()::citext as guid + from tmp_merge_init_src f + where f.rid <> any(a_tblroot) + and f.parent_rid <> any(a_tblroot) + and f.grand_rid <> any(a_tblroot) + ) + ,joined as ( + select f.rid + ,f.merge_type + ,f.table_name + ,f.field_name + ,f.filter_string + ,f.parent_rid + ,f.parent_mergetag + ,f.parent_merge_type + ,f.parent_table_name + ,f.parent_field_name + ,f.parent_filter_string + ,f.grand_rid + ,f.grand_mergetag + ,f.grand_merge_type + ,f.grand_table_name + ,f.grand_field_name + ,f.grand_filter_string + ,f.order_string + ,f.parent_order_string + ,replace(nv(d.tagvalue),'''','')::citext as tagvalue + ,nv(d.source)::citext as source + ,d.tblid::citext as tblid + ,d.tblparent::citext as tblparent + ,d.mergetag as joinmergetag --- because opertators are filtered out already, we join without operators to be able to find the tag. + ,(case when strpos(d.originalmergetag, G_TAG_OPER) > 0 then d.originalmergetag + else d.mergetag + end)::citext as mergetag + ,''::citext as ops_string + ,d.operators + ,d.table_level + ,f.guid + from fields f + inner join tmp_merge_init_fields d on d.mergetag = f.mergetag + where f.merge_type in (G_MTYPE_ROOT,G_MTYPE_TBLFIELD,G_MTYPE_TBLROOT,G_MTYPE_AGGFIELD ) --all tables + ) + select e.* + ,row_number() over(partition by + e.source + , case when e.merge_type in (G_MTYPE_SPECIAL, G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD ) + then G_MTYPE_TBLFIELD + else e.merge_type + end + , e.parent_table_name + , e.table_name + , e.parent_filter_string + , e.filter_string + order by + e.source + , case + when e.merge_type in (G_MTYPE_SPECIAL, G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD ) + then G_MTYPE_TBLFIELD + when e.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else e.merge_type + end + , e.parent_table_name, e.table_name, e.field_name) as rn + from joined e + where nv(e.tblparent) in ('0','') + order by + e.source + ,case when e.merge_type in (G_MTYPE_SPECIAL, G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD ) + then G_MTYPE_TBLFIELD + when e.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else e.merge_type + end + ,e.parent_table_name + ,e.table_name + ,rn desc + ) + loop + -- perform log_event(m_funcname,format(' Tables %s=%s src=%s rn=%s' ,p_data_prefix,p_data_rid -- ,r_lp_t.source,r_lp_t.rn -- ),bt_enum('eventlog','local notice') -- --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text -- ); + + if r_lp_t.table_name = any(a_inner_selected) and nv(r_lp_t.table_name ) <> '' + then + raise notice 'Table used as inner table: %', r_lp_t.table_name; + continue; + end if; + --raise notice 'Field:% Table:% Tag: % Type: %', r_lp_t.field_name, r_lp_t.table_name, r_lp_t.mergetag, r_lp_t.merge_type; + if nv(r_lp_t.field_name) = '' and r_lp_t.merge_type not in (G_MTYPE_TBLROOT,G_MTYPE_SPECIAL) + then + + if G_DEBUG + then + perform log_event(m_funcname,format('Blank field name on Complex merge for p_doctype=%s, p_commtype=%s, p_data_prefix=%s, p_data_rid=%s + field_name=%s, merge_type=%s, table_name=%s + ' ,p_doctype, p_commtype,p_data_prefix,p_data_rid + ,r_lp.field_name,r_lp.merge_type,r_lp.table_name),bt_enum('eventlog','local notice') + --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text + ); + end if; + --raise exception 'Blank field name for record %', r_lp; continue; + + end if; + + if nv(m_exec_orderstr) = '' and nv(r_lp_t.parent_order_string) <> '' + then + m_exec_orderstr = r_lp_t.parent_order_string; + --raise notice 'Applying order % by for % %.', r_lp_t.parent_order_string, r_lp_t.parent_table_name,r_lp_t.field_name; + /* + select s.parent_order_string + from tmp_merge_init_fields f + inner join tmp_merge_init_src s on s.parent_rid = r_lp_t.rid + and s.merge_type = G_MTYPE_TBLROOT + and nv(s.parent_order_string) <> '' + where f.source = r_lp_t.source + limit 1 + into m_exec_orderstr; + */ + + end if; + + if nv(r_lp_t.ops_string) = '' + then + r_lp_t.ops_string = r_lp_t.field_name; + end if; + + for r_tmp in ( + select o.r as op + ,split_part(o.r, '=', 1) as opname + ,split_part(o.r, '=', 2) as opval + from unnest(r_lp_t.operators) with ordinality o(r,i) + order by o.i asc + ) + loop + r_lp_t.ops_string = mailmerge_rule(r_lp_t.ops_string::citext, r_tmp.opname::citext, r_tmp.opval::citext, r_lp_t.mergetag::citext); + end loop; + + if r_lp_t.merge_type in (G_MTYPE_TBLFIELD, G_MTYPE_CONDFIELD) + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::text %s), 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp_t.mergetag + ,r_lp_t.ops_string + , m_exec_orderstr, r_lp_t.merge_type, E'\r\n'); + + elseif r_lp_t.merge_type = G_MTYPE_SPECIAL--special fields + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s, 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp_t.mergetag,quote_literal(r_lp_t.tagvalue), r_lp_t.merge_type, E'\r\n'); + --raise notice 'Special Field: %s',r_lp_t; + elseif r_lp.merge_type = G_MTYPE_PICTURE + then + m_execstr = format($S$%s|| '%s"%s":' + || json_build_object('value',%s::text, 'type', '%s' + , 'w', mailmerge_specialfield('width', '%s', %s) ,'h', mailmerge_specialfield('height', '%s', %s))::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag,r_lp.field_name, r_lp.merge_type + ,r_lp.mergetag,quote_nullable(m_data_rid),r_lp.mergetag,quote_nullable(m_data_rid), E'\r\n'); + + elseif nv(r_lp_t.field_name) <> '' + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s::text, 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp_t.mergetag + , r_lp_t.ops_string + , r_lp_t.merge_type, E'\r\n'); + end if; + + m_execfilter = format('%s %s',nv(m_execfilter), r_lp_t.filter_string); + + --We use this to fill in blanks. + m_blankexec = format($S$%s|| '%s"%s":' || json_build_object('value','', 'type', '%s')::text %s$S$,m_blankexec,m_comma,r_lp_t.mergetag, r_lp_t.merge_type, E'\r\n'); + + if r_lp_t.rn = 1 + then + --Inner level tables (2) + --raise notice 'Begin: parent: %', r_lp_t; + for r_lp_c in ( + + select f.rid + ,max(f.merge_type) as merge_type + ,format('%s', max(ifblnk(d.tblid,f.table_name)))::citext as subname + ,($S + $(select ('{'|| $S$ || string_agg(format($SS$'"%1$s": ' || json_build_object('value', json_agg(%2$s::text), + 'type', + '%3$s' + ) + : + : + text + $SS$, + c + . + mergetag, + c + . + field_name, + c + . + merge_type + ), + '|| '',''||' + ) + || + $S$ + || + '}' + ) : : json + from $S$ || max ( + f + . + table_name + ) || ' ' || ifblnk ( + max + ( + f + . + filter_string + ), + 'where 1=1' + ) || ')' ) : : citext as qry , ( + $S + $(select ('{'|| $S$ || string_agg(format($SS$'"%1$s": ' || json_build_object('value', '[]', 'type','%3$s') + : + : + text + $SS$, + c + . + mergetag, + c + . + field_name, + c + . + merge_type + ), + '|| '',''||' + ) + || + $S$ + || + '}' + ) : : json $S$ || ')' ) : : citext as qryblnk , nv ( + max + ( + f + . + parent_order_string + ) + ) : : citext as parent_order_string + from tmp_merge_init_src f + inner + join tmp_merge_init_src c + on c . rid_parent = f . rid and c . merge_type in ( + G_MTYPE_TBLFIELD + ) + inner + join tmp_merge_init_fields d + on d . mergetag = c . mergetag and d . tblparent = r_lp_t . tblid + where f . grand_rid = r_lp_t . parent_rid and f . merge_type = G_MTYPE_TBLROOT --and f.parent_rid = any(a_tblroot) --and d.table_level > 0 + + group by f . rid ) loop + raise notice 'Inner Loop: %', r_lp_c . qry; + a_inner_selected = array_append(a_inner_selected, r_lp_t.table_name); + + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::json %s)::json, 'type', '%s')::text %s$S$ + ,m_execstr,',',r_lp_c.subname,r_lp_c.qry, r_lp_c.parent_order_string, r_lp_c.merge_type, E'\r\n'); + + m_blankexec = format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::json %s)::json, 'type', '%s')::text %s$S$ + ,m_blankexec,',',r_lp_c.subname,r_lp_c.qryblnk, r_lp_c.parent_order_string, r_lp_c.merge_type, E'\r\n'); + + end loop; + + if ifblnk(r_lp_t.parent_table_name,'') = '' + then + m_execstr = format(E'select (''{'' %s \r\n || ''}'')::json ;',m_execstr ); + else + select string_agg(s.filter_string, ' ') + from tmp_merge_init_fields f + inner join tmp_merge_init_src s on s.mergetag = f.mergetag + and s.merge_type = G_MTYPE_FILTER + where f.source = r_lp_t.source into m_tablefilter ; + + m_execstr = format(E'select (''{'' %s \r\n || ''}'')::json \r\nfrom %s \r\n%s;' + ,m_execstr, r_lp_t.parent_table_name,ifblnk(r_lp_t.parent_filter_string, ' where 1=1 ') || nv(m_execfilter) || nv(m_tablefilter) ); + end if; + + m_blankexec = format(E'select (''{'' %s \r\n || ''}'')::json \r\n;',m_blankexec ); + + select r.p_retval, r.p_errmsg, r.p_json - > 'str' + from exec_json(m_execstr, 'str json') r into m_retval,m_errmsg, m_json; + + if m_json is null + then + + select r.p_retval, r.p_errmsg, r.p_json - > 'str' + from exec_json(m_execstr, 'str json') r into m_retval,m_errmsg, m_json; + end if; + + m_debug_exestr = nv(m_debug_exestr) || E'\r\n/*'|| nv(r_lp_t.parent_table_name) || ' len:' || nv(length(m_json::text)) ||E'*/ \r\n' || nv(m_execstr) || E'\r\n '; + + if m_json_full_complex is null + then + m_json_full_complex = jsonb_build_object(r_lp_t.tblid::text,m_json); + end if; + + if (m_json_full_complex->r_lp_t.tblid::text) is null + then + m_json_full_complex = jsonb_set(m_json_full_complex, format('{%s}',r_lp_t.tblid)::text[], m_json::jsonb,true); + else + m_json_full_complex = jsonb_set(m_json_full_complex, format('{%s}',r_lp_t.tblid)::text[], _jsonb_object_cat(m_json_full_complex->r_lp_t.tblid,m_json::jsonb),true); + end if; + + -- perform log_event(m_funcname,format('Complex Tables %s=%s m_json_full_complex=%s' ,p_data_prefix,p_data_rid -- ,m_json_full_complex::text -- ),bt_enum('eventlog','local notice') -- --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text -- ); + + m_execfilter = ''; + m_execstr = ''; + m_blankexec = ''; + m_exec_orderstr = ''; + m_comma = ''; + m_tablefilter = ''; + end if; + + if nv(m_comma) = '' and length(m_execstr) > 2 + then + m_comma = ','; + end if; + end loop; + + if G_DEBUG + then + perform pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8')); + end if; + +EXCEPTION + WHEN others THEN + GET STACKED DIAGNOSTICS + m_errmsg = MESSAGE_TEXT + ,m_errcontext = PG_EXCEPTION_CONTEXT + ,m_errdetail = PG_EXCEPTION_DETAIL + ,m_errhint = PG_EXCEPTION_HINT + ,m_errstate = RETURNED_SQLSTATE; + + m_errmsg = format(E'Merge failed to complete. Merge fields are not setup correctly. \r\nPlease check the template. \r\nThere could be table merge tags outside of a table. \r\nDetail Error: \r\n%s',m_errmsg); + m_errmsg = nv(m_errmsg) || format(E'\r\nExecString: %s ', ifblnk(m_execstr,m_debug_exestr)); + m_errmsg = nv(m_errmsg) || format(E'\r\nError Detail: %s , %s, %s, %s', m_errdetail,m_errcontext,m_errhint,m_errstate); + + if G_DEBUG + then + m_errmsg = format(E'%s \r\nDebug file: %s',m_errmsg, r_template.debugsql_filename); + perform pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8')); +end if; + p_retval = 1; + p_errmsg = m_errmsg; + + m_json_full_complex = _jsonb_object_cat(m_json_full_complex, jsonb_build_object('p_retval',p_retval,'p_errmsg',p_errmsg)); + + return; + --raise exception '%', m_errmsg using hint = 'in merge jsonbuild process'; END; + -------------------------------------------------------------------------------------------------------- + + m_json_full = json_build_object('fields',m_json_full, 'complexfields',m_json_full_complex); + + if G_DEBUG + then + perform pl_writefile(r_template.debug_filename, convert_to(m_json_full::text,'utf8')); +end if; + + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Complex Fields 2SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); +end if; + + if m_returnvalues + then + p_doc = convert_to(m_json_full::text, 'utf8'); + p_docguid = 'json:see->p_doc'; + +else + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Before pl_mailmerge SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); +end if; + + if m_hasfilestream + then + --filesystem +select r.p_retval + , r.p_errmsg + , r.p_result +from pl_mailmerge(format('merge_%s', p_doctype), r_template.filepath, r_doc.filepath, m_json_full::text, + 1 /*New mode, new tags*/) r into r_retval; + +if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After pl_mailmerge SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); +end if; + + if r_retval.p_retval = 1 + then + raise '%',r_retval.p_errmsg; + elseif r_retval.p_retval = 2 + then + p_retval = 2; + p_errmsg = r_retval.p_errmsg; +end if; + +select r.p_retval, r.p_errmsg +from f_tempfile_add(r_doc.filepath, p_doctype, r_doc.guid, 600, m_data_rid, m_data_prefix) r into m_retval, m_errmsg; + +p_docguid = r_doc.guid; + +select r.p_outfile, r.p_retval, r.p_errmsg +from pl_readfile(r_doc.filepath) r into p_doc, m_retval, m_errmsg; + +if m_retval = 0 + then + perform pl_deletefile(r_doc.filepath); + perform pl_deletefile(r_template.filepath); +end if; + +else + m_ltime = clock_timestamp(); +-- -- select r.p_retval, r.p_errmsg -- from f_tempfile_add(null, 'template', r_template.guid, 600,m_data_rid, m_data_prefix,r_template.blob) r -- into m_retval, m_errmsg; -- -- perform log_event(m_funcname,format('Dbg D:%s J:%s template:%s', p_doctype, m_json_full, r_template.guid) -- ,bt_enum('eventlog','local notice')); -- --stream +select r.p_retval + , r.p_errmsg + , r.p_result + , r.p_file +from pl_mailmerge(format('merge_%s', p_doctype), null, null, m_json_full::text + , 1 /*New mode, new tags*/, r_template.blob) r into r_retval; + +if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After pl_mailmerge Stream SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); +end if; + + r_doc.blob = r_retval.p_file; + p_doc = r_retval.p_file; + + if r_retval.p_retval = 1 + then + raise '%',r_retval.p_errmsg; + elseif r_retval.p_retval = 2 + then + p_retval = 2; + p_errmsg = r_retval.p_errmsg; +end if; + +end if; + +end if; + + if G_BENCHMARK in (1,2) + then + perform log_event(m_funcname,format('Perf Merge End (%s,%s) SinceStart: %s Duration: %s',p_doctype,p_data_rid, clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime = clock_timestamp(); +end if; + +EXCEPTION +WHEN others THEN + GET STACKED DIAGNOSTICS + m_errmsg = MESSAGE_TEXT + ,m_errcontext = PG_EXCEPTION_CONTEXT + ,m_errdetail = PG_EXCEPTION_DETAIL + ,m_errhint = PG_EXCEPTION_HINT + ,m_errstate = RETURNED_SQLSTATE; + + p_errmsg := get_err_msg(m_funcname, m_errmsg, m_errcontext, m_errdetail, m_errhint, m_errstate); + p_retval = 1; + + p_errmsg = nv(p_errmsg) || nv(format(E'\r\n p_doctype:%s, p_commtype:%s, p_data_prefix:%s, p_data_rid:%s, p_filterdata:%s' + ,p_doctype,p_commtype,p_data_prefix,p_data_rid, p_filterdata)); + + if G_DEBUG + then + perform pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8')); + perform pl_writefile(r_template.debug_filename, convert_to(m_json_full::text,'utf8')); +end if; + +END; +$$; diff --git a/testdata/corpus/test_mm_proc_broken.pgsql b/testdata/corpus/test_mm_proc_broken.pgsql new file mode 100644 index 0000000..dd0bb38 --- /dev/null +++ b/testdata/corpus/test_mm_proc_broken.pgsql @@ -0,0 +1,1920 @@ +CREATE +OR REPLACE FUNCTION mm_proc( + p_doctype text --File Types: html, docx, text, allfieldvalues + ,p_commtype text --Merge Data Type: e.g. SMS,Email,All (Will be passed through to menu/taglist funct) + ,p_template bytea --Source/Template file to be merge + ,p_data_prefix text --Table prefix for data source. e.g. tcli (client) , sta (standard letters) + ,p_data_rid integer --rid for given table prefix + ,p_filterdata json = null --Advanced filter data. + ,OUT p_doc bytea --Merged document output + ,OUT p_docguid text --optional guid to store document guid from tempfile table. (For disk reading) + ,OUT p_retval integer + ,OUT p_errmsg text +) +LANGUAGE plpgsql VOLATILE +AS +$$ +DECLARE + --Error Handling-- +m_funcname text = 'mm_proc'; + m_errmsg +text; + m_errcontext +text; + m_errdetail +text; + m_errhint +text; + m_errstate +text; + m_retval +integer; + --Error Handling-- + + m_blankexec +text; + m_comma +text; + m_debug_exestr +text; + m_exec_orderstr +text; + m_execfilter +text; + m_execstr +text; + m_fields +json; + m_guid +citext; + m_json +json; + m_json_full +json; + m_json_full_complex +jsonb; + m_result +text; + m_tablefilter +text; + m_temppath +citext; + m_user +citext; + m_exttype +citext; + m_returnvalues +boolean = false ; + m_data_prefix +citext; + m_data_rid +integer; + m_rid_comm +integer; + m_rid_commattachment +integer; + m_rid_obl +integer; + m_doctype +citext; + m_ltime +timestamp; + m_hasfilestream +boolean; + m_rid_obligation +integer; + + G_DEBUG +boolean; + G_TAG_S +text; + G_TAG_E +text; + G_TAG_SPLIT +text; + G_TAG_OPER +text; + + G_MTYPE_ROOT +integer = 0; + G_MTYPE_FIELD +integer = 1; + G_MTYPE_TBLFIELD +integer = 2; + G_MTYPE_TBLROOT +integer = 3; + G_MTYPE_AGGFIELD +integer = 4; + G_MTYPE_PICTURE +integer = 5; + G_MTYPE_SPECIAL +integer = 6; + G_MTYPE_FILTER +integer = 7; + G_MTYPE_CONDFIELD +integer = 8; + G_MTYPE_DOCREPLACE +integer = 9; + G_MTYPE_HTML +integer = 10; + G_BENCHMARK +integer; + + a_tblroot +integer[]; + a_types +citext[]; + a_inner_selected +citext[]; + + + r_template +record; + r_doc +record; + + r_lp +record; + r_lp_t +record; + r_lp_c +record; + r_retval +record; + r_tmp +record; + --r_lp_prev record; + + m_start +timestamp = clock_timestamp(); +BEGIN + p_retval += 0; + p_errmsg += ''; + G_DEBUG += True; + G_BENCHMARK += 0; + G_TAG_S += '[*'; --chr(171); + G_TAG_E += '*]';--chr(187); + G_TAG_SPLIT += '|'; + G_TAG_OPER += ':'; --operator + m_exttype += 'txt'; + a_types += array['html','docx','txt','plainhtml','allfieldvalues']; -- + m_hasfilestream += false; + a_inner_selected += array[]::citext[]; + + m_ltime += clock_timestamp(); + m_guid += newid(); + m_user += f_getuser(); + + + +-- if p_doctype ilike '%plain%' and f_iscompressed(p_template) in ('none') +-- and byteatotext(p_template) not ilike '%'|| G_TAG_S || '%' || G_TAG_E || '%' +-- then +-- raise warning 'No tags to merge in plain template.'; +-- p_doc = p_template; +-- return; +-- end if; + + +-- perform log_event(m_funcname,format('INIT _ type=%s commtype=%s p_data_prefix=%s p_data_rid=%s Template:%s' +-- ,p_doctype, p_commtype,p_data_prefix,p_data_rid +-- ,case when octet_length(p_template) < 100 then byteatotext(p_template) else octet_length(p_template)::text end +-- ),bt_enum('eventlog','local notice')); + + if +p_data_prefix::citext = 'com' + then +select c.rid_communication + , c.rid_parent + , c.tableprefix +from communication c +where c.rid_communication = p_data_rid into m_rid_comm, m_data_rid, m_data_prefix; + +elsif +p_data_prefix::citext = 'cra' + then + m_rid_commattachment = p_data_rid; +select c.rid_communication + , c.rid_parent + , c.tableprefix + , ca.rid_obligation + , dt.typex +from commattachment ca + inner join communication c on c.rid_communication = ca.rid_parent + and ca.tableprefix = 'com' + left outer join document d on d.rid_document = ca.rid_document + left outer join documenttype dt on dt.rid_documenttype = d.rid_documenttype +where ca.rid_commattachment = p_data_rid into m_rid_comm, m_data_rid, m_data_prefix, m_rid_obl, m_doctype; + +elsif +p_data_prefix::citext = 'ctl' + then + +select ctl.rid_parent as rid_parent + , ctl.tableprefix as parent_prefix +from tasklist ctl +--inner join t_adproclient cli on cli.rid_adproclient = ctl.rid_parent +-- and ctl.tableprefix = 'tcli' +where ctl.rid_tasklist = p_data_rid into m_data_rid, m_data_prefix; + +elsif +p_data_prefix::citext = 'ehr' + then + raise exception 'Merging email headers are not supported at this time.'; + +else + m_data_prefix = p_data_prefix; + m_data_rid += p_data_rid; +end if; + + ---init: start--- +select format('tmpl_%s_%s_%s.%s', m_data_prefix, m_data_rid, m_guid, p_doctype)::citext as filename + ,''::citext as filepath + ,''::citext as debugsql_filename + ,''::citext as debug_filename + ,''::citext as debug_peekfilename + ,''::citext as magictype + ,'T' || m_guid::citext as guid + ,p_template::bytea as blob +into r_template; + +select format('doc_%s_%s_%s.%s', m_data_prefix, m_data_rid, m_guid, p_doctype)::citext as filename + ,''::citext as filepath + ,'D' || m_guid::citext as guid + ,null::bytea as blob +into r_doc; + +-- perform log_event(m_funcname,format('init = p_doctype = %s p_data_prefix = %s p_data_rid = %s p_template[size] = %s' +-- ,p_doctype,p_data_prefix,p_data_rid,octet_length(p_template)),bt_enum('eventlog','local notice')); + +if +p_doctype = 'allfieldvalues' + then + m_returnvalues = true; + elsif +p_doctype::citext <> all(a_types) + then + raise exception 'This type [%] is not yet supported. Allowed types: %', p_doctype, a_types; +end if; + + if +p_template is null and not m_returnvalues + then + p_retval = 1; + p_errmsg += 'No template'; + return; +end if; + + if +p_commtype ilike '%sms%' and (p_doctype ilike '%text%' or p_doctype ilike '%html%' or p_doctype ilike '%all%') + then + p_doctype = 'plainhtml'; +end if; + +select f_normpath(b.programpath || '/temp/mailmerge')::citext + ,b.sqldebuglevel > 99 +from f_bus() b limit 1 +into m_temppath, G_DEBUG; + +if +m_hasfilestream or G_DEBUG + then + perform pl_mkdir(m_temppath); +end if; + + r_template.magictype += f_normpath(f_iscompressed(p_template))::citext; + r_template.filepath += f_normpath(format('%s/%s',m_temppath, r_template.filename))::citext; + r_template.debugsql_filename += f_normpath(format('%s/%s%s/%s.pgsql',m_temppath, p_data_prefix, p_data_rid, r_template.filename))::citext; + r_template.debug_filename += f_normpath(format('%s/%s%s/%s.debug.json',m_temppath, p_data_prefix, p_data_rid, r_template.filename))::citext; + r_template.debug_peekfilename += f_normpath(format('%s/%s%s/%s.peek.json',m_temppath, p_data_prefix, p_data_rid, r_template.filename))::citext; + + if +g_debug + then + perform pl_mkdir(f_normpath(format('%s/%s%s',m_temppath, p_data_prefix, p_data_rid))); + perform +pl_writefile(f_normpath(format('%s/%s%s/%s.template.docx',m_temppath, p_data_prefix, p_data_rid,r_template.filename)) + ,p_template); +end if; + + r_doc.filepath += f_normpath(format('%s/%s',m_temppath, r_doc.filename))::citext; + + if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Before MergeMenu SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + + + +drop table if exists tmp_merge_init_src; +create +temp table tmp_merge_init_src + --on commit drop + as + with msrc as ( + select * + from crm_get_merge_menu(p_commtype::citext) p + ), src as ( + select c.rid_tree_mergefeld::integer as rid + ,nv(c.rid_parent)::integer as rid_parent + ,btrim(nv(c.mergetag),' ')::citext as mergetag + ,nv(c.mergetype) as merge_type + ,btrim(nv(c.tablename),' ')::citext as table_name + ,btrim(nv(c.fieldname),' ')::citext as field_name + ,btrim(nv(c.filterstring),' ')::citext as filter_string + ,btrim(nv(g.orderstring),' ')::citext as order_string + --parent + ,nv(p.rid_tree_mergefeld)::integer as parent_rid + ,nv(p.rid_parent)::integer as parent_rid_parent + ,btrim(p.mergetag,' ')::citext as parent_mergetag + ,nv(p.mergetype)::integer as parent_merge_type + ,ifblnk(btrim(p.tablename,' '), btrim(nv(g.tablename),' '))::citext as parent_table_name + ,btrim(nv(p.fieldname),' ')::citext as parent_field_name + ,ifblnk(btrim(p.filterstring,' '),btrim(nv(g.filterstring),' '))::citext as parent_filter_string + ,ifblnk(btrim(p.orderstring,' '),btrim(nv(g.orderstring),' '))::citext as parent_order_string + --grand parent + ,nv(g.rid_tree_mergefeld)::integer as grand_rid + ,btrim(g.mergetag,' ')::citext as grand_mergetag + ,nv(g.mergetype)::integer as grand_merge_type + ,ifblnk(g.tablename, nv(g.tablename))::citext as grand_table_name + ,btrim(nv(g.fieldname),' ')::citext as grand_field_name + ,btrim(nv(g.filterstring),' ')::citext as grand_filter_string + from msrc p + left outer join msrc c on c.rid_parent = p.rid_tree_mergefeld --child + and nv(c.mergetype) > 0 + left outer join msrc g on g.rid_tree_mergefeld = p.rid_parent --grand parent + where nv(p.mergetype) = 0 + or (p.mergetype > 0 and c.rid_tree_mergefeld > 0) + ) +select d.rid + , d.rid_parent + , case + when nv(d.merge_type) = G_MTYPE_SPECIAL and strpos(d.mergetag, G_TAG_SPLIT) > 0 + then substr(d.mergetag, 0, strpos(d.mergetag, G_TAG_SPLIT)) || + substr(d.mergetag, length(d.mergetag) - length(G_TAG_E) + 1, length(d.mergetag)) + else d.mergetag + end + ::citext as mergetag + ,d.merge_type + , d.table_name + , d.field_name + , d.filter_string + , d.order_string + , d.parent_rid + , d.parent_rid_parent + , d.parent_mergetag + , d.parent_merge_type + , d.parent_table_name + , d.parent_field_name + , d.parent_filter_string + , d.parent_order_string + , d.grand_rid + , d.grand_mergetag + , d.grand_merge_type + , d.grand_table_name + , d.grand_field_name + , d.grand_filter_string +from src d +; + +--CREATE INDEX "idx_tmp_merge_init_src_type" ON tmp_merge_init_src USING btree (merge_type); +--CREATE INDEX "idx_tmp_merge_init_src_tag" ON tmp_merge_init_src USING btree (mergetag); + + +--HTML field types has a special meaning in the frontend. We treat them as normal fields. +update tmp_merge_init_src u +set merge_type = G_MTYPE_FIELD +where u.merge_type = G_MTYPE_HTML; + +--raise notice 'After MergeMenu: %', (clock_timestamp() - m_ltime); +if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After MergeMenu SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + + --Do a peek on the template and get fields we need to fetch. + if +not m_returnvalues + then + + if nv(octet_length(p_template)) < 2 + then + raise exception E'Template document not found or blank. \r\n% File: %',octet_length(p_template), r_template.filepath; +end if; + + if +m_hasfilestream + then + --filesystem + +select r.p_retval, r.p_errmsg +from pl_writefile(r_template.filepath, p_template) r into m_retval, m_errmsg; + +if +m_retval > 0 + then + raise exception 'Failed to save template file to temp directory. %',m_errmsg using hint = 'in pl_writefile, while peaking template'; +end if; + + if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Before Peek SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + +select r.p_retval, r.p_errmsg, r.p_result +from pl_mailmerge(format('peek_%s', p_doctype), r_template.filepath, '', '' + , 1 /*New mode, new tags*/) r into m_retval, m_errmsg, m_result; + +if +m_retval > 0 + then + raise exception '%', m_errmsg using hint = 'in pl_mailmerge, while peaking template'; +end if; + + if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After Peek SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + + if +nv(m_result) = '' --or m_result not ilike '%{%}%' + then + raise exception 'No data returned from peak.' using hint = 'in pl_mailmerge, while peaking template'; +end if; + + if +G_DEBUG + then + perform pl_writefile(r_template.debug_peekfilename, convert_to(m_result,'utf8')); +end if; + +select r.p_retval, r.p_errmsg +from f_tempfile_add(r_template.filename, p_doctype, r_template.guid, 600, m_data_rid, + m_data_prefix) r into m_retval, m_errmsg; + +else + --stream + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Before Peek SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + +select r.p_retval, r.p_errmsg, r.p_result, r.p_file +from pl_mailmerge(format('peek_%s', p_doctype), null, null, '', 1, p_template) r into m_retval, m_errmsg, m_result; + +if +m_retval > 0 + then + raise exception '%', m_errmsg using hint = 'in pl_mailmerge, while peaking template'; +end if; + + if +G_DEBUG + then + perform pl_writefile(r_template.debug_peekfilename, convert_to(m_result,'utf8')); +end if; + + --perform log_event(m_funcname,format('Peek Result: %s', m_result),bt_enum('eventlog','local notice')); + + if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After Peek SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + +end if; + + m_fields += m_result::json; + +drop table if exists tmp_merge_init_fields; +create +temp table tmp_merge_init_fields + --on commit drop + as + with docfields as ( + select 'document'::citext as source + ,btrim(btrim(r::text,' '),'"')::citext as mergetag + ,1 as taglocation + from json_array_elements(m_fields->'doctags') r + union all + select 'header'::citext as source + ,btrim(btrim(r::text,' '),'"')::citext as mergetag + ,1 as taglocation + from json_array_elements(m_fields->'headertags') r + union all + select 'footer'::citext as source + ,btrim(btrim(r::text,' '),'"')::citext as mergetag + ,1 as taglocation + from json_array_elements(m_fields->'footertags') r + union all + --also the table tags in all the tables. + select r.key::citext as source + ,replace(l.value::text,'"','')::citext as mergetag + ,2 as taglocation + from json_each(m_fields->'tabletags') r (key,value) + left outer join json_array_elements(r.value) l (value) on json_typeof(r.value) = 'array' + where nv(l.value::text) <> '' + union all + select 'raw'::citext as source + ,btrim(btrim(r::text,' '),'"')::citext as mergetag + ,1 as taglocation + from json_array_elements(m_fields->'raw') r + ) +select distinct +on (d.source, d.taglocation,d.mergetag) + d.source::citext as source + ,(case when d.taglocation = 2 then split_part(d.source, ',', 1) else null end)::citext as tblid + ,(case when d.taglocation = 2 then split_part(d.source, ',', 2) else null end)::citext as tblparent + ,format('%s%s%s',G_TAG_S, replace(replace(split_part(split_part(d.mergetag, G_TAG_SPLIT,1) , G_TAG_OPER,1),G_TAG_E,''),G_TAG_S,''),G_TAG_E)::citext as mergetag + ,replace(case when strpos(d.mergetag,G_TAG_SPLIT) > 0 and d.taglocation <> 5 and strpos(d.mergetag,G_TAG_E)- strpos(d.mergetag,G_TAG_SPLIT)-1 > 0 + then substr(d.mergetag,strpos(d.mergetag,G_TAG_SPLIT)+1, strpos(d.mergetag,G_TAG_E)- strpos(d.mergetag,G_TAG_SPLIT)-1) + when strpos(d.mergetag,G_TAG_OPER) > 0 and strpos(d.mergetag,G_TAG_E)- strpos(d.mergetag,G_TAG_OPER)-1 > 0 + then substr(d.mergetag,strpos(d.mergetag,G_TAG_OPER)+1, strpos(d.mergetag,G_TAG_E)- strpos(d.mergetag,G_TAG_OPER)-1) + else '' + end,'''','')::citext as tagvalue + ,(regexp_split_to_array(replace(replace(d.mergetag,G_TAG_E,''),G_TAG_S,''),'(\'|| G_TAG_OPER || '|\' || G_TAG_SPLIT || ')' ))[2:] as operators + ,d.taglocation::citext as taglocation + ,d.mergetag as originalmergetag + ,0:: integer as table_level + ,row_number() over(order by d.source, d.mergetag) as rowid +from docfields d +; + +---special update for tables linking +update tmp_merge_init_fields u +set tblparent = r.p_tblparent + , source = r.source + , tblid = r.p_tblid from ( + select distinct f.tblid + , f.tblparent + , f2.tblparent as p_tblparent + , f2.tblid as p_tblid + , f.mergetag + , f2.source + from tmp_merge_init_fields f + inner join tmp_merge_init_fields f2 on f2.tblid = f.tblparent + where f.mergetag ilike '%free!tbl%' + ) r +where u.tblid = r.tblid; + +update tmp_merge_init_fields u +set table_level = r.table_level from ( + select f1.rowid + ,case when nv(f4.tblid) <> '' then 4 + when nv(f3.tblid) <> '' then 3 + when nv(f2.tblid) <> '' then 2 + when nv(f1.tblid) <> '' then 1 + else 0 + end as table_level + from tmp_merge_init_fields f1 + left outer join tmp_merge_init_fields f2 on f1.tblparent = f2.tblid + left outer join tmp_merge_init_fields f3 on f2.tblparent = f3.tblid + left outer join tmp_merge_init_fields f4 on f3.tblparent = f4.tblid + ) r +where u.rowid = r.rowid +; + +else +drop table if exists tmp_merge_init_fields; +create +temp table tmp_merge_init_fields + --on commit drop + as +select distinct +on (s.mergetag) + ''::citext as source + ,''::citext as tblid + ,''::citext as tblparent + ,s.mergetag::citext as mergetag + ,''::citext as tagvalue + ,array[]::citext[] as operators + ,1::citext as taglocation + ,s.mergetag::citext as originalmergetag +from tmp_merge_init_src s +where s.merge_type in (G_MTYPE_ROOT, G_MTYPE_FIELD, G_MTYPE_AGGFIELD); + +end if; --End of do a peek + + --raise notice 'After Merge: %', (clock_timestamp() - m_ltime); + if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After MergeMenu SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + + if +not exists ( + select 1 + from tmp_merge_init_fields m + where nv(m.mergetag) <> '' + ) + then +-- perform log_event(m_funcname,format('NO TAGS p_doctype=%s p_commtype=%s p_data_prefix=%s p_data_rid=%s' +-- ,p_doctype, p_commtype,p_data_prefix,p_data_rid),bt_enum('eventlog','local notice')); +-- + p_doc = p_template; + + return; +end if; + + + + --Update [extra_filter] + /* + update tmp_merge_init_src u + set table_name = replace(u.table_name, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , parent_table_name = replace(u.parent_table_name, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , field_name = replace(u.field_name, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , parent_field_name = replace(u.parent_field_name, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , filter_string = replace(u.filter_string, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + , parent_filter_string = replace(u.parent_filter_string, '[extra_filter]', ifblnk(r.extra_filter, 'True')) + from ( + select f.tblid + , f.mergetag + , ltrim(ltrim(string_agg(ifblnk(s2.filter_string,'True'), ' and '),' '), 'and') as extra_filter + from tmp_merge_init_fields f + inner join tmp_merge_init_fields f2 on f2.tblid = f.tblid + inner join tmp_merge_init_src s2 on s2.mergetag = f2.mergetag + and s2.merge_type = G_MTYPE_FILTER + group by f.tblid,f.mergetag + ) r + where u.mergetag = r.mergetag + ; + */ + + /* + ---Use this code to test +select pl_writefile('/mnt/t/temp/t.docx',r.p_doc) +,r.* +from mm_proc('docx','allfieldvalues', ( + select tat.maindoc + from templateattachment tat + --cross join view_blob(tat.rid_templateattachment,'templateattachment','maindoc','rid_templateattachment','utf8') v + where tat.description ilike 'Form 16 (%' +), 'TCLI',1000016) r + */ +/* + + drop table if exists debug_merge_init_src; + create table debug_merge_init_src as + select * from tmp_merge_init_src + ; + + drop table if exists debug_merge_init_fields; + create table debug_merge_init_fields as + select * from tmp_merge_init_fields + ; + + */ + + --------Keywords For Client,Creditor,Debt Counsellor,Trader--------------------------------------------------------- + ---ID replace process + + if +m_data_prefix::citext in ('tclc','clc') + then + m_rid_obligation = m_data_rid; + m_rid_obl += m_data_rid; + +select clc.rid_adproclient +from t_adproclientcreditorl1 clc +where clc.rid_adproclientcreditorl1 = m_data_rid into m_data_rid; + +m_data_prefix += 'TCLI'; + +end if; + + + + if +m_data_prefix::citext in ('tcli','tclc','usr','sclc') + then + + if m_data_prefix::citext in ('tcli') + and not exists ( + select 1 + from t_adproclient cli + where cli.rid_adproclient = m_data_rid + ) + then + raise exception 'Client % does not exists.', m_data_rid; +end if; + -----------------------------------------------------------[CHANGE ID: 29078] 14/07/2020 14:54 Begin + if +m_data_prefix::citext in ('usr') + then +select usr.login +from users usr +where usr.rid_user = m_data_rid into m_user; + +m_data_rid += 0; +end if; + + + if +m_rid_comm > 0 and nv(m_rid_obligation) = 0 + then + +select ct.rid_obligation +from commattachment ct +where ct.tableprefix = 'COM' + and ct.rid_parent = m_rid_comm + and ct.doclink = 1 + and ct.rid_obligation > 0 +order by ct.rid_templateattachment desc limit 1 +into m_rid_obligation +; + +end if; + + /*if r_comm.data_prefix = 'RPC' + then + select usr.login + from remotepc rpc + inner join users usr on usr.rid_user = rpc.rid_user + where rpc.rid_remotepc = r_comm.data_rid + into m_user + ; + + m_data_rid = 0; + end if;*/ + + if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Merge Replace SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + +for r_lp in ( + select '[rid_obligation]'::citext as key + ,ifblnk(m_rid_obligation,0)::citext as value + ) + loop +update tmp_merge_init_src u +set table_name = replace(u.table_name, r_lp.key, r_lp.value) + , parent_table_name = replace(u.parent_table_name, r_lp.key, r_lp.value) + , field_name = replace(u.field_name, r_lp.key, r_lp.value) + , parent_field_name = replace(u.parent_field_name, r_lp.key, r_lp.value) + , filter_string = replace(u.filter_string, r_lp.key, r_lp.value) + , parent_filter_string = replace(u.parent_filter_string, r_lp.key, r_lp.value) +; +end loop; + -----------------------------------------------------------[CHANGE ID: 29078] 14/07/2020 14:54 End +for r_lp in ( + with keywords as ( + select distinct r.v[1]::citext as colname + from tmp_merge_init_src f + cross join regexp_matches(format('%s %s %s %s %s %s',f.table_name,f.parent_table_name,f.field_name,f.parent_field_name,f.parent_filter_string,f.filter_string) + , '\[(.*?)\]', 'ig') r(v) + where m_data_prefix = 'tcli' + and length(nv(r.v[1])) > 2 + ) + ,keyworddata as ( + select r.j + from exec(format('select (''{ "debugflag": "%s"'' %s || ''}'')::json from t_adproclient cli where cli.rid_adproclient = %s' + ,G_DEBUG + ,(select string_agg(format('|| '',"%s":"'' || nv(%s)::text || ''"''',format('[%s]',k.colname), k.colname), E'\r\n') from keywords k + where nv(k.colname) not in ( + 'rid_communication' + ,'rid_commattachment' + ,'rid_obl_filter' + ,'exec_user' + ,'extra_filter' + ,'rid_user' + ,'p_doctype' + ,'rid_obligation' + ----!!!!!Remeber to add the records here to exclude if the already have a value below + ) + ) + ,m_data_rid + ) + ) r(j json) + ) + select j.key::citext as key + , btrim(j.value::text,'"')::text::citext as value + from keyworddata d + cross join json_each(d.j) j(key,value) + where j.key <> '' + -----Some extra generic keywords + union all + + select '[rid_communication]'::citext as key + ,ifblnk(m_rid_comm,0)::citext as value + union all + select '[rid_obl_filter]'::citext as key + ,ifblnk(m_rid_obl,0)::citext as value + union all + select '[exec_user]'::citext as key + ,nv(m_user)::citext as value + union all + select '[extra_filter]'::citext as key + ,'True'::citext as value + union all + select '[rid_commattachment]'::citext as key + ,ifblnk(m_rid_commattachment,0)::citext as value --[CHANGE ID: 28878] 23/06/2020 11:33 + /* union all + select '[rid_remotepc]'::citext as key + ,nv(r_comm.data_rid)::citext as value --[CHANGE ID: 29078] 15/07/2020 9:36*/ + union all + select '[p_doctype]'::citext as key + ,ifblnk(p_doctype,'docx')::citext as value + ) + loop + +update tmp_merge_init_src u +set table_name = replace(u.table_name, r_lp.key, r_lp.value) + , parent_table_name = replace(u.parent_table_name, r_lp.key, r_lp.value) + , field_name = replace(u.field_name, r_lp.key, r_lp.value) + , parent_field_name = replace(u.parent_field_name, r_lp.key, r_lp.value) + , filter_string = replace(u.filter_string, r_lp.key, r_lp.value) + , parent_filter_string = replace(u.parent_filter_string, r_lp.key, r_lp.value) +; + +end loop; + +else + +select string_agg(distinct format('[%s]', r.v[1]::text), ',') + , count(1) +from tmp_merge_init_src f + cross join regexp_matches( + format('%s %s %s %s %s %s', f.table_name, f.parent_table_name, f.field_name, f.parent_field_name, + f.parent_filter_string, f.filter_string) + , '\[(.*?)\]', 'ig') r(v) into m_errmsg,m_retval; + +if +m_retval > 0 + then + raise exception E'The following data fields could not be found for prefix % \r\n%', ifblnk(m_data_prefix, 'Null'), m_errmsg using hint = 'in ID replace process'; +end if; + +end if; + + if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Merge Filter SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + +--------Basic Fields--------------------------------------------------------------------------------- + m_execstr += ''; + m_execfilter += ''; + m_blankexec += ''; + m_comma += ''; + +for r_lp in ( + with fields as ( + select f.mergetag + ,f.merge_type + ,f.table_name + ,f.field_name + ,f.filter_string + ,f.parent_mergetag + ,f.parent_merge_type + ,f.parent_table_name + ,f.parent_field_name + ,f.parent_filter_string + + from tmp_merge_init_src f + ) + + ,filtereddocfields as ( + select distinct on (d.originalmergetag, d.taglocation) + d.source + ,d.tblid + ,d.tblparent + ,d.mergetag as joinmergetag --- because opertators are filtered out already, we join without operators to be able to find the tag. + ,(case when strpos(d.originalmergetag, G_TAG_OPER) > 0 then d.originalmergetag + else d.mergetag + end)::citext as mergetag + ,replace(d.tagvalue,'''','') as tagvalue + ,d.taglocation + ,d.originalmergetag + ,d.operators + + from tmp_merge_init_fields d + ) + select + f.merge_type + ,f.table_name + ,f.field_name + ,f.filter_string + ,f.parent_mergetag + ,f.parent_merge_type + ,f.parent_table_name + ,f.parent_field_name + ,f.parent_filter_string + ,d.mergetag + ,replace(d.tagvalue,'''','') as tagvalue + ,d.source + ,d.tblid + ,d.operators + ,row_number() over(partition by (case + when f.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else f.merge_type + end) + , f.parent_table_name + , f.table_name + , f.parent_filter_string + , f.filter_string + order by (case + when f.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else f.merge_type + end) asc + , f.parent_table_name + , f.table_name + , f.field_name + ) as rn + ---Additional fields + ,''::citext as ops_string + from fields f + inner join filtereddocfields d on d.joinmergetag = f.mergetag + where f.merge_type in (G_MTYPE_ROOT,G_MTYPE_FIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE, G_MTYPE_CONDFIELD) --all except tables + and (f.merge_type <> G_MTYPE_AGGFIELD + or f.merge_type = G_MTYPE_AGGFIELD + and nv(d.source) = '' + ) + order by (case when f.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else f.merge_type + end) asc + , f.parent_table_name + , f.table_name + , rn desc + ) + loop + + + --raise notice 'Fields: %', r_lp; + if nv(r_lp.field_name) = '' and r_lp.merge_type not in (G_MTYPE_TBLROOT,G_MTYPE_SPECIAL) + then + if G_DEBUG + then + perform log_event(m_funcname,format('Blank field name for p_doctype=%s, p_commtype=%s, p_data_prefix=%s, p_data_rid=%s + field_name=%s, merge_type=%s, table_name=%s + ' ,p_doctype, p_commtype,p_data_prefix,p_data_rid + ,r_lp.field_name,r_lp.merge_type,r_lp.table_name + --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text + ),bt_enum('eventlog','local notice')); +end if; + --raise exception 'Blank field name for record %', r_lp; +continue ; +end if; + + if +nv(r_lp.ops_string) = '' + then + r_lp.ops_string = r_lp.field_name; +end if; + +for r_tmp in ( + select o.r as op + ,split_part(o.r, '=', 1) as opname + ,split_part(o.r, '=', 2) as opval + from unnest(r_lp.operators) with ordinality o(r,i) + order by o.i asc + ) + loop + r_lp.ops_string = mailmerge_rule(r_lp.ops_string::citext, r_tmp.opname::citext, r_tmp.opval::citext, r_lp.mergetag::citext); +end loop; + + --perform log_event(m_funcname,format('mailmerge_rules %s',r_lp.ops_string),bt_enum('eventlog','local notice')); + + if +r_lp.merge_type = G_MTYPE_AGGFIELD + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s::text, 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag + ,r_lp.ops_string, r_lp.merge_type, E'\r\n'); + elseif +r_lp.merge_type = G_MTYPE_PICTURE + then + m_execstr = format($S$%s|| '%s"%s":' + || json_build_object('value',%s::text, 'type', '%s' + , 'w', mailmerge_specialfield('width', '%s', %s) ,'h', mailmerge_specialfield('height', '%s', %s))::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag,r_lp.field_name, r_lp.merge_type + ,r_lp.mergetag,quote_nullable(m_data_rid),r_lp.mergetag,quote_nullable(m_data_rid), E'\r\n'); + + elseif +r_lp.merge_type = G_MTYPE_TBLFIELD + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::text), 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag + ,r_lp.ops_string + ,r_lp.merge_type, E'\r\n'); + elseif +r_lp.merge_type = G_MTYPE_SPECIAL + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s, 'type', '%s')::text %s$S$,m_execstr,m_comma,r_lp.mergetag,quote_literal(r_lp.tagvalue), r_lp.merge_type, E'\r\n'); + +else + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s::text, 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag + ,r_lp.ops_string + ,r_lp.merge_type, E'\r\n'); + +end if; + + m_execfilter += format('%s %s',nv(m_execfilter), r_lp.filter_string); + + --We use this to fill in blanks. + m_blankexec += format($S$%s|| '%s"%s":' || json_build_object('value','', 'type', '%s')::text %s$S$,m_blankexec,m_comma,r_lp.mergetag, r_lp.merge_type, E'\r\n'); + + if +r_lp.rn = 1 + then + if ifblnk(r_lp.parent_table_name,'') = '' + then + m_execstr = format(E'select (''{'' %s \r\n || ''}'')::json ;',m_execstr ); +else +select string_agg(s.filter_string, ' ') +from tmp_merge_init_fields f + inner join tmp_merge_init_src s on s.mergetag = f.mergetag + and s.merge_type = G_MTYPE_FILTER +where f.source = r_lp.source + and exists ( --make sure there is a parent, else this breaks. It means there will be not parent table. + select 1 + from tmp_merge_init_src s2 + where s2.rid = s.rid_parent) + into m_tablefilter +; + +m_execstr += format(E'select (''{'' %s \r\n || ''}'')::json \r\nfrom %s \r\n%s;' + ,m_execstr, r_lp.parent_table_name,ifblnk(r_lp.parent_filter_string, ' where 1=1 ') || nv(m_execfilter) || nv(m_tablefilter) ); +end if; + + m_blankexec += format(E'select (''{'' %s \r\n || ''}'')::json \r\n;',m_blankexec ); + m_debug_exestr += nv(m_debug_exestr) || E'\r\n----'|| r_lp.parent_table_name ||E'\r\n' || nv(m_execstr) || E'\r\n'; + +select r.str::json +from exec(m_execstr) r(str json) into m_json; + +if +m_json is null + then +select r.str::json +from exec(m_blankexec) r(str json) into m_json; +end if; + + if +nv(m_json_full::text) <> '' + then + m_json_full = (m_json_full::jsonb || m_json::jsonb)::json; +else + m_json_full = m_json; +end if; + + m_tablefilter += ''; + m_execfilter += ''; + m_execstr += ''; + m_blankexec += ''; + m_comma += ''; +end if; + + if +nv(m_comma) = '' and length(m_execstr) > 2 + then + m_comma = ','; +end if; +end loop; + + if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Complex Fields SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; +--------Complex Fields--------------------------------------------------------------------------------- + m_execfilter += ''; + m_tablefilter += ''; + m_execstr += ''; + m_blankexec += ''; + m_comma += ''; + m_exec_orderstr += ''; + + -----@2023-10-24 - + ---This code must move the tag of a parent col in the child table back to the parent table in order to complete the join. + + /* + with a as ( + select d.mergetag as mt + ,d.source + ,d.tblparent + ,d.tblid + ,d.table_level + ,d.rowid + ,d.taglocation + ,f.* + from tmp_merge_init_src f + inner join tmp_merge_init_fields d on d.mergetag = f.mergetag + where f.merge_type = 2 + ), aa as ( + select + (select count(1) from a b where b.rid_parent = a.rid_parent and b.tblid = a.tblid) as cnt + ,* + from a + ), b as ( + select aa.rid + ,aa.rowid + ,aa.rid_parent + ,aa.tblid + ,aa.cnt < (select b.cnt from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as oddone + ,(select b.tblid from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as newtblid + ,(select b.tblparent from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as newtblparent + ,(select b.source from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as newsource + ,(select b.table_level from aa b where b.rid_parent = aa.rid_parent order by b.cnt desc limit 1) as new_table_level + ,aa.mt + from aa + ) + update tmp_merge_init_fields f + set tblid = b.newtblid + ,tblparent = b.newtblparent + ,source = b.newsource + ,table_level = b.new_table_level + from b + where f.rowid = b.rowid + ; + */ + +select array_agg(f.rid) +from tmp_merge_init_src f +where f.grand_merge_type = G_MTYPE_TBLROOT + and f.merge_type = G_MTYPE_TBLROOT into a_tblroot; + +if +a_tblroot is null then a_tblroot = array[-1]; +end if; + m_json_full_complex += jsonb_build_object(); + + --a_tblroot = array[-1]; +BEGIN + +for r_lp_t in ( + with fields as ( + select f.rid + ,f.mergetag + ,f.merge_type + ,f.table_name + ,f.field_name + ,f.filter_string + ,f.parent_rid + ,f.parent_mergetag + ,f.parent_merge_type + ,f.parent_table_name + ,f.parent_field_name + ,f.parent_filter_string + ,f.grand_rid + ,f.grand_mergetag + ,f.grand_merge_type + ,f.grand_table_name + ,f.grand_field_name + ,f.grand_filter_string + ,f.order_string + ,f.parent_order_string + ,newid()::citext as guid + from tmp_merge_init_src f + where f.rid <> any(a_tblroot) + and f.parent_rid <> any(a_tblroot) + and f.grand_rid <> any(a_tblroot) + ) + ,joined as ( + select f.rid + ,f.merge_type + ,f.table_name + ,f.field_name + ,f.filter_string + ,f.parent_rid + ,f.parent_mergetag + ,f.parent_merge_type + ,f.parent_table_name + ,f.parent_field_name + ,f.parent_filter_string + ,f.grand_rid + ,f.grand_mergetag + ,f.grand_merge_type + ,f.grand_table_name + ,f.grand_field_name + ,f.grand_filter_string + ,f.order_string + ,f.parent_order_string + ,replace(nv(d.tagvalue),'''','')::citext as tagvalue + ,nv(d.source)::citext as source + ,d.tblid::citext as tblid + ,d.tblparent::citext as tblparent + ,d.mergetag as joinmergetag --- because opertators are filtered out already, we join without operators to be able to find the tag. + ,(case when strpos(d.originalmergetag, G_TAG_OPER) > 0 then d.originalmergetag + else d.mergetag + end)::citext as mergetag + ,''::citext as ops_string + ,d.operators + ,d.table_level + ,f.guid + from fields f + inner join tmp_merge_init_fields d on d.mergetag = f.mergetag + where f.merge_type in (G_MTYPE_ROOT,G_MTYPE_TBLFIELD,G_MTYPE_TBLROOT,G_MTYPE_AGGFIELD ) --all tables + ) + select e.* + ,row_number() over(partition by + e.source + , case when e.merge_type in (G_MTYPE_SPECIAL, G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD ) + then G_MTYPE_TBLFIELD + else e.merge_type + end + , e.parent_table_name + , e.table_name + , e.parent_filter_string + , e.filter_string + order by + e.source + , case + when e.merge_type in (G_MTYPE_SPECIAL, G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD ) + then G_MTYPE_TBLFIELD + when e.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else e.merge_type + end + , e.parent_table_name, e.table_name, e.field_name) as rn + from joined e + where nv(e.tblparent) in ('0','') + order by + e.source + ,case when e.merge_type in (G_MTYPE_SPECIAL, G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD ) + then G_MTYPE_TBLFIELD + when e.merge_type IN (G_MTYPE_CONDFIELD,G_MTYPE_AGGFIELD,G_MTYPE_PICTURE) + then G_MTYPE_FIELD + else e.merge_type + end + ,e.parent_table_name + ,e.table_name + ,rn desc + ) + loop +-- perform log_event(m_funcname,format(' Tables %s=%s src=%s rn=%s' ,p_data_prefix,p_data_rid +-- ,r_lp_t.source,r_lp_t.rn +-- ),bt_enum('eventlog','local notice') +-- --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text +-- ); + + if r_lp_t.table_name = any(a_inner_selected) and nv(r_lp_t.table_name ) <> '' + then + raise notice 'Table used as inner table: %', r_lp_t.table_name; +continue; +end if; + --raise notice 'Field:% Table:% Tag: % Type: %', r_lp_t.field_name, r_lp_t.table_name, r_lp_t.mergetag, r_lp_t.merge_type; + if +nv(r_lp_t.field_name) = '' and r_lp_t.merge_type not in (G_MTYPE_TBLROOT,G_MTYPE_SPECIAL) + then + + if G_DEBUG + then + perform log_event(m_funcname,format('Blank field name on Complex merge for p_doctype=%s, p_commtype=%s, p_data_prefix=%s, p_data_rid=%s + field_name=%s, merge_type=%s, table_name=%s + ' ,p_doctype, p_commtype,p_data_prefix,p_data_rid + ,r_lp.field_name,r_lp.merge_type,r_lp.table_name),bt_enum('eventlog','local notice') + --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text + ); +end if; + --raise exception 'Blank field name for record %', r_lp; +continue; + +end if; + + + + if +nv(m_exec_orderstr) = '' and nv(r_lp_t.parent_order_string) <> '' + then + m_exec_orderstr = r_lp_t.parent_order_string; + --raise notice 'Applying order % by for % %.', r_lp_t.parent_order_string, r_lp_t.parent_table_name,r_lp_t.field_name; + /* + select s.parent_order_string + from tmp_merge_init_fields f + inner join tmp_merge_init_src s on s.parent_rid = r_lp_t.rid + and s.merge_type = G_MTYPE_TBLROOT + and nv(s.parent_order_string) <> '' + where f.source = r_lp_t.source + limit 1 + into m_exec_orderstr; + */ + +end if; + + if +nv(r_lp_t.ops_string) = '' + then + r_lp_t.ops_string = r_lp_t.field_name; +end if; + +for r_tmp in ( + select o.r as op + ,split_part(o.r, '=', 1) as opname + ,split_part(o.r, '=', 2) as opval + from unnest(r_lp_t.operators) with ordinality o(r,i) + order by o.i asc + ) + loop + r_lp_t.ops_string = mailmerge_rule(r_lp_t.ops_string::citext, r_tmp.opname::citext, r_tmp.opval::citext, r_lp_t.mergetag::citext); +end loop; + + + if +r_lp_t.merge_type in (G_MTYPE_TBLFIELD, G_MTYPE_CONDFIELD) + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::text %s), 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp_t.mergetag + ,r_lp_t.ops_string + , m_exec_orderstr, r_lp_t.merge_type, E'\r\n'); + + elseif +r_lp_t.merge_type = G_MTYPE_SPECIAL--special fields + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s, 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp_t.mergetag,quote_literal(r_lp_t.tagvalue), r_lp_t.merge_type, E'\r\n'); + --raise notice 'Special Field: %s',r_lp_t; + elseif +r_lp.merge_type = G_MTYPE_PICTURE + then + m_execstr = format($S$%s|| '%s"%s":' + || json_build_object('value',%s::text, 'type', '%s' + , 'w', mailmerge_specialfield('width', '%s', %s) ,'h', mailmerge_specialfield('height', '%s', %s))::text %s$S$ + ,m_execstr,m_comma,r_lp.mergetag,r_lp.field_name, r_lp.merge_type + ,r_lp.mergetag,quote_nullable(m_data_rid),r_lp.mergetag,quote_nullable(m_data_rid), E'\r\n'); + + elseif +nv(r_lp_t.field_name) <> '' + then + m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',%s::text, 'type', '%s')::text %s$S$ + ,m_execstr,m_comma,r_lp_t.mergetag + , r_lp_t.ops_string + , r_lp_t.merge_type, E'\r\n'); +end if; + + m_execfilter += format('%s %s',nv(m_execfilter), r_lp_t.filter_string); + + --We use this to fill in blanks. + m_blankexec += format($S$%s|| '%s"%s":' || json_build_object('value','', 'type', '%s')::text %s$S$,m_blankexec,m_comma,r_lp_t.mergetag, r_lp_t.merge_type, E'\r\n'); + + + if +r_lp_t.rn = 1 + then + --Inner level tables (2) + --raise notice 'Begin: parent: %', r_lp_t; + for r_lp_c in ( + + select f.rid + ,max(f.merge_type) as merge_type + ,format('%s', max(ifblnk(d.tblid,f.table_name)))::citext as subname + ,($S +$(select ('{'|| $S$ || string_agg(format($SS$'"%1$s": ' || json_build_object('value', json_agg(%2$s::text), +'type', +'%3$s' +) +: +: +text +$SS$, +c +. +mergetag, +c +. +field_name, +c +. +merge_type +), +'|| '',''||' +) +|| +$S$ +|| +'}' +) +: +: +json +from +$S$ +|| +max +( +f +. +table_name +) +|| +' ' +|| +ifblnk +( +max +( +f +. +filter_string +), +'where 1=1' +) +|| +')' +) +: +: +citext +as +qry +, +( +$S +$(select ('{'|| $S$ || string_agg(format($SS$'"%1$s": ' || json_build_object('value', '[]', 'type','%3$s') +: +: +text +$SS$, +c +. +mergetag, +c +. +field_name, +c +. +merge_type +), +'|| '',''||' +) +|| +$S$ +|| +'}' +) +: +: +json +$S$ +|| +')' +) +: +: +citext +as +qryblnk +, +nv +( +max +( +f +. +parent_order_string +) +) +: +: +citext +as +parent_order_string +from +tmp_merge_init_src +f +inner +join +tmp_merge_init_src +c +on +c +. +rid_parent += +f +. +rid +and +c +. +merge_type +in +( +G_MTYPE_TBLFIELD +) +inner +join +tmp_merge_init_fields +d +on +d +. +mergetag += +c +. +mergetag +and +d +. +tblparent += +r_lp_t +. +tblid +where +f +. +grand_rid += +r_lp_t +. +parent_rid +and +f +. +merge_type += +G_MTYPE_TBLROOT +--and f.parent_rid = any(a_tblroot) +--and d.table_level > 0 + +group +by +f +. +rid +) +loop +raise +notice +'Inner Loop: %', +r_lp_c +. +qry; +a_inner_selected += array_append(a_inner_selected, r_lp_t.table_name); + + m_execstr += format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::json %s)::json, 'type', '%s')::text %s$S$ + ,m_execstr,',',r_lp_c.subname,r_lp_c.qry, r_lp_c.parent_order_string, r_lp_c.merge_type, E'\r\n'); + + m_blankexec += format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::json %s)::json, 'type', '%s')::text %s$S$ + ,m_blankexec,',',r_lp_c.subname,r_lp_c.qryblnk, r_lp_c.parent_order_string, r_lp_c.merge_type, E'\r\n'); + +end loop; + + if +ifblnk(r_lp_t.parent_table_name,'') = '' + then + m_execstr = format(E'select (''{'' %s \r\n || ''}'')::json ;',m_execstr ); +else +select string_agg(s.filter_string, ' ') +from tmp_merge_init_fields f + inner join tmp_merge_init_src s on s.mergetag = f.mergetag + and s.merge_type = G_MTYPE_FILTER +where f.source = r_lp_t.source into m_tablefilter +; + +m_execstr += format(E'select (''{'' %s \r\n || ''}'')::json \r\nfrom %s \r\n%s;' + ,m_execstr, r_lp_t.parent_table_name,ifblnk(r_lp_t.parent_filter_string, ' where 1=1 ') || nv(m_execfilter) || nv(m_tablefilter) ); +end if; + + m_blankexec += format(E'select (''{'' %s \r\n || ''}'')::json \r\n;',m_blankexec ); + +select r.p_retval, r.p_errmsg, r.p_json - > 'str' +from exec_json(m_execstr, 'str json') r into m_retval,m_errmsg, m_json; + +if +m_json is null + then + + +select r.p_retval, r.p_errmsg, r.p_json - > 'str' +from exec_json(m_execstr, 'str json') r into m_retval,m_errmsg, m_json; +end if; + + m_debug_exestr += nv(m_debug_exestr) || E'\r\n/*'|| nv(r_lp_t.parent_table_name) || ' len:' || nv(length(m_json::text)) ||E'*/ \r\n' || nv(m_execstr) || E'\r\n '; + + if +m_json_full_complex is null + then + m_json_full_complex = jsonb_build_object(r_lp_t.tblid::text,m_json); +end if; + + if +(m_json_full_complex->r_lp_t.tblid::text) is null + then + m_json_full_complex = jsonb_set(m_json_full_complex, format('{%s}',r_lp_t.tblid)::text[], m_json::jsonb,true); +else + m_json_full_complex = jsonb_set(m_json_full_complex, format('{%s}',r_lp_t.tblid)::text[], _jsonb_object_cat(m_json_full_complex->r_lp_t.tblid,m_json::jsonb),true); +end if; + + +-- perform log_event(m_funcname,format('Complex Tables %s=%s m_json_full_complex=%s' ,p_data_prefix,p_data_rid +-- ,m_json_full_complex::text +-- ),bt_enum('eventlog','local notice') +-- --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text +-- ); + + m_execfilter += ''; + m_execstr += ''; + m_blankexec += ''; + m_exec_orderstr += ''; + m_comma += ''; + m_tablefilter += ''; +end if; + + if +nv(m_comma) = '' and length(m_execstr) > 2 + then + m_comma = ','; +end if; +end loop; + + if +G_DEBUG + then + perform pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8')); +end if; + +EXCEPTION + WHEN others THEN + GET STACKED DIAGNOSTICS + m_errmsg = MESSAGE_TEXT + ,m_errcontext = PG_EXCEPTION_CONTEXT + ,m_errdetail = PG_EXCEPTION_DETAIL + ,m_errhint = PG_EXCEPTION_HINT + ,m_errstate = RETURNED_SQLSTATE; + + m_errmsg += format(E'Merge failed to complete. Merge fields are not setup correctly. \r\nPlease check the template. \r\nThere could be table merge tags outside of a table. \r\nDetail Error: \r\n%s',m_errmsg); + m_errmsg += nv(m_errmsg) || format(E'\r\nExecString: %s ', ifblnk(m_execstr,m_debug_exestr)); + m_errmsg += nv(m_errmsg) || format(E'\r\nError Detail: %s , %s, %s, %s', m_errdetail,m_errcontext,m_errhint,m_errstate); + + if +G_DEBUG + then + m_errmsg = format(E'%s \r\nDebug file: %s',m_errmsg, r_template.debugsql_filename); + perform +pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8')); +end if; + p_retval += 1; + p_errmsg += m_errmsg; + + m_json_full_complex += _jsonb_object_cat(m_json_full_complex, jsonb_build_object('p_retval',p_retval,'p_errmsg',p_errmsg)); + + return; + --raise exception '%', m_errmsg using hint = 'in merge jsonbuild process'; +END; + -------------------------------------------------------------------------------------------------------- + + m_json_full += json_build_object('fields',m_json_full, 'complexfields',m_json_full_complex); + + if +G_DEBUG + then + perform pl_writefile(r_template.debug_filename, convert_to(m_json_full::text,'utf8')); +end if; + + if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Complex Fields 2SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + + if +m_returnvalues + then + p_doc = convert_to(m_json_full::text, 'utf8'); + p_docguid += 'json:see->p_doc'; + +else + if G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf Before pl_mailmerge SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + + if +m_hasfilestream + then + --filesystem +select r.p_retval + , r.p_errmsg + , r.p_result +from pl_mailmerge(format('merge_%s', p_doctype), r_template.filepath, r_doc.filepath, m_json_full::text, + 1 /*New mode, new tags*/) r into r_retval; + +if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After pl_mailmerge SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + + if +r_retval.p_retval = 1 + then + raise '%',r_retval.p_errmsg; + elseif +r_retval.p_retval = 2 + then + p_retval = 2; + p_errmsg += r_retval.p_errmsg; +end if; + +select r.p_retval, r.p_errmsg +from f_tempfile_add(r_doc.filepath, p_doctype, r_doc.guid, 600, m_data_rid, m_data_prefix) r into m_retval, m_errmsg; + +p_docguid += r_doc.guid; + +select r.p_outfile, r.p_retval, r.p_errmsg +from pl_readfile(r_doc.filepath) r into p_doc, m_retval, m_errmsg; + +if +m_retval = 0 + then + perform pl_deletefile(r_doc.filepath); + perform +pl_deletefile(r_template.filepath); +end if; + +else + m_ltime = clock_timestamp(); +-- +-- select r.p_retval, r.p_errmsg +-- from f_tempfile_add(null, 'template', r_template.guid, 600,m_data_rid, m_data_prefix,r_template.blob) r +-- into m_retval, m_errmsg; +-- +-- perform log_event(m_funcname,format('Dbg D:%s J:%s template:%s', p_doctype, m_json_full, r_template.guid) +-- ,bt_enum('eventlog','local notice')); +-- --stream +select r.p_retval + , r.p_errmsg + , r.p_result + , r.p_file +from pl_mailmerge(format('merge_%s', p_doctype), null, null, m_json_full::text + , 1 /*New mode, new tags*/, r_template.blob) r into r_retval; + +if +G_BENCHMARK = 1 + then + perform log_event(m_funcname,format('Perf After pl_mailmerge Stream SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + + r_doc.blob += r_retval.p_file; + p_doc += r_retval.p_file; + + if +r_retval.p_retval = 1 + then + raise '%',r_retval.p_errmsg; + elseif +r_retval.p_retval = 2 + then + p_retval = 2; + p_errmsg += r_retval.p_errmsg; +end if; + + +end if; + +end if; + + if +G_BENCHMARK in (1,2) + then + perform log_event(m_funcname,format('Perf Merge End (%s,%s) SinceStart: %s Duration: %s',p_doctype,p_data_rid, clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice')); + m_ltime += clock_timestamp(); +end if; + +EXCEPTION +WHEN others THEN + GET STACKED DIAGNOSTICS + m_errmsg = MESSAGE_TEXT + ,m_errcontext = PG_EXCEPTION_CONTEXT + ,m_errdetail = PG_EXCEPTION_DETAIL + ,m_errhint = PG_EXCEPTION_HINT + ,m_errstate = RETURNED_SQLSTATE; + + p_errmsg +:= get_err_msg(m_funcname, m_errmsg, m_errcontext, m_errdetail, m_errhint, m_errstate); + p_retval += 1; + + p_errmsg += nv(p_errmsg) || nv(format(E'\r\n p_doctype:%s, p_commtype:%s, p_data_prefix:%s, p_data_rid:%s, p_filterdata:%s' + ,p_doctype,p_commtype,p_data_prefix,p_data_rid, p_filterdata)); + + if +G_DEBUG + then + perform pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8')); + perform +pl_writefile(r_template.debug_filename, convert_to(m_json_full::text,'utf8')); +end if; + +END; +$$; \ No newline at end of file diff --git a/testdata/corpus/test_select.pgsql b/testdata/corpus/test_select.pgsql new file mode 100755 index 0000000..687254d --- /dev/null +++ b/testdata/corpus/test_select.pgsql @@ -0,0 +1,8 @@ +select u.name + ,u.surname + ,u.login +from public.user u +where u.id = 1 + and u.name = 'test' + or u.name = 'joe' +; \ No newline at end of file