12 Commits
Author SHA1 Message Date
Hein cf9ea5fbda chore(release): bump version to 0.0.8
CI / Build (push) Skipped
CI / Test (push) Failing after 36s
Release / Test (push) Successful in 12s
Release / Release (push) Successful in 3m20s
Release / VSCode Extension (push) Successful in 23s
Release / AUR package (push) Successful in 41s
Release / Debian packages (push) Successful in 1m27s
Release / Windows installer (push) Successful in 1m39s
Release / RPM package (push) Successful in 2m16s
Release / DataGrip Plugin (push) Successful in 3m6s
2026-09-10 15:27:41 +02:00
Hein 5cdec88299 feat(format): expand the runtime safety gate
CI / Test (push) Successful in 37s
CI / Build (push) Successful in 52s
Replace the bare SemanticallyEqual call in every frontend (CLI fmt, LSP
formatting + rangeFormatting) with format.VerifySafe, which runs four
checks before any formatted output is emitted:

  - semantic equivalence  - the code token stream is unchanged
  - comment preservation  - no -- or /* */ comment is dropped, merged,
    split, reordered, or reworded (line endings / indentation normalised
    away; recurses into dollar-quoted bodies)
  - structural balance    - the () [] and BEGIN/CASE/IF/LOOP...END nesting
    profile matches, ignoring anything inside a comment or a string
  - idempotence           - a second format pass would not change it

On failure the CLI now prints the specific reason and keeps the original.

The comment check surfaced two real formatter bugs, both fixed in
formatBodyStatements:

  - multi-line /* */ comments inside a PL/pgSQL body had their interior
    lines re-split and reindented as if they were statements; they are
    now tracked and carried verbatim with the opening line
  - a column-0 -- line was glued onto the preceding line by the
    split-line-join, which merged consecutive comment lines into one

Regenerate testdata/corpus/test_mm_proc.pgsql (was carrying the mangled
output). TestCorpusIdempotentAndSafe now runs the full VerifySafe bundle;
add safety_test.go with targeted cases.
2026-09-10 15:17:07 +02:00
Hein 83b215fd25 feat(format): default output to dist/examples house style
CI / Test (push) Successful in 1m12s
CI / Build (push) Successful in 30s
Align the formatter defaults and layout with the hand-formatted reference
procedures in dist/examples so a clean `pgtidy fmt` produces the house style.

config.Default():
- align_param_types: false (no type-column alignment in param lists)
- plpgsql_declare_align_type / plpgsql_declare_align_eq: true

Formatter:
- routine header: leading-comma params at column 0, first param at one
  indent, RETURNS/LANGUAGE/volatility/SECURITY each indented one level
- %type / %rowtype printed tight (isPctTypeBoundary)
- DECLARE = / := / DEFAULT column padded only to the widest declaration
  that carries an assignment
- WHERE continuations in body UPDATE/DELETE: AND/OR aligned with WHERE
- EXCEPTION aligned to its enclosing BEGIN; column-0 comment continuations
  kept flush-left

Safety gate:
- SemanticallyEqual tolerates CRLF vs LF inside string literals (normNL);
  the formatter re-emits all layout with st.Newline, so a \r\n inside a
  multi-line string literal is normalisation, not a code change. This was
  why action_init and event_exec_func previously refused to format.

Corpus:
- add the four CRLF reference files as idempotence/safety fixtures
- regenerate test_a and test_mm_proc goldens

FOR...LOOP body indentation keeps the existing +1 convention (LOOP aligned
with FOR); the dist/examples use +2, so loop-body regions differ by
whitespace only.
2026-09-10 14:54:20 +02:00
warkanum 94d776e3de Merge pull request 'docs(lsp): LSP capability inventory, gaps & next steps (issue #3)' (#4) from issue-3-lsp-research into main
CI / Test (push) Successful in 6m35s
CI / Build (push) Successful in 8s
Reviewed-on: #4
2026-08-23 06:59:50 +00:00
SG Command 1be745b69c docs(lsp): add capability inventory, gaps, and next steps (issue #3)
CI / Test (pull_request) Successful in 35s
CI / Build (pull_request) Successful in 20s
2026-08-22 23:19:10 +02:00
Hein 25d40ad515 chore(release): bump version to 0.0.7
CI / Build (push) Successful in 20s
Release / Release (push) Successful in 36s
Release / Debian packages (push) Successful in 33s
Release / VSCode Extension (push) Successful in 35s
Release / AUR package (push) Successful in 55s
Release / RPM package (push) Successful in 56s
Release / Windows installer (push) Successful in 59s
Release / DataGrip Plugin (push) Successful in 4m29s
CI / Test (push) Successful in 33s
Release / Test (push) Successful in 31s
2026-07-17 14:29:45 +02:00
Hein bac966c2ac feat(format): add runtime semantic-equality safety gate
CI / Build (push) Successful in 23s
CI / Test (push) Successful in 31s
2026-07-17 14:29:24 +02:00
Hein 4abc89700f chore(release): bump version to 0.0.6
Release / AUR package (push) Successful in 53s
Release / DataGrip Plugin (push) Successful in 4m17s
Release / Release (push) Successful in 1m57s
Release / VSCode Extension (push) Successful in 56s
Release / Test (push) Successful in 1m50s
CI / Test (push) Successful in 1m51s
CI / Build (push) Successful in 21s
Release / RPM package (push) Successful in 39s
Release / Debian packages (push) Successful in 47s
Release / Windows installer (push) Successful in 47s
2026-07-17 13:21:20 +02:00
Hein ad52f21cc2 feat(datagrip): migrate from LSP4IJ to native CLI integration
CI / Test (push) Successful in 26s
CI / Build (push) Successful in 22s
2026-07-17 13:20:49 +02:00
Hein 60602d1de7 chore(release): bump version to 0.0.5
CI / Test (push) Successful in 25s
CI / Build (push) Successful in 20s
Release / Test (push) Failing after 1m51s
Release / Release (push) Has been skipped
Release / Debian packages (push) Has been skipped
Release / RPM package (push) Has been skipped
Release / Windows installer (push) Has been skipped
Release / AUR package (push) Has been skipped
Release / VSCode Extension (push) Has been skipped
Release / DataGrip Plugin (push) Has been skipped
2026-07-17 09:40:51 +02:00
warkanum 8d19258aa0 Merge pull request 'Fix PL/pgSQL formatting indentation' (#2) from issue-1-formatting-indenting into main
CI / Test (push) Successful in 26s
CI / Build (push) Successful in 29s
Reviewed-on: #2
2026-07-16 19:38:55 +00:00
Hein 5cba6beeb1 fix: align PLpgSQL formatting clauses
CI / Build (pull_request) Successful in 1m2s
CI / Test (pull_request) Successful in 43s
2026-07-15 21:11:58 +02:00
38 changed files with 4720 additions and 301 deletions
+12
View File
@@ -365,6 +365,12 @@ jobs:
with: with:
version: latest version: latest
- name: Set version from tag
working-directory: editors/vscode
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
npm pkg set version="${TAG#v}"
- name: Install and package - name: Install and package
working-directory: editors/vscode working-directory: editors/vscode
run: | run: |
@@ -402,6 +408,12 @@ jobs:
distribution: temurin distribution: temurin
java-version: '21' java-version: '21'
- name: Set version from tag
working-directory: editors/datagrip
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
sed -i "s/^pluginVersion=.*/pluginVersion=${TAG#v}/" gradle.properties
- name: Build plugin - name: Build plugin
working-directory: editors/datagrip working-directory: editors/datagrip
run: ./gradlew buildPlugin run: ./gradlew buildPlugin
+8
View File
@@ -44,6 +44,10 @@ jobs:
cache: npm cache: npm
cache-dependency-path: editors/vscode/package-lock.json cache-dependency-path: editors/vscode/package-lock.json
- name: Set version from tag
working-directory: editors/vscode
run: npm pkg set version="${GITHUB_REF_NAME#v}"
- name: Install and package - name: Install and package
working-directory: editors/vscode working-directory: editors/vscode
run: | run: |
@@ -73,6 +77,10 @@ jobs:
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
- name: Set version from tag
working-directory: editors/datagrip
run: sed -i "s/^pluginVersion=.*/pluginVersion=${GITHUB_REF_NAME#v}/" gradle.properties
- name: Build plugin - name: Build plugin
working-directory: editors/datagrip working-directory: editors/datagrip
run: ./gradlew buildPlugin run: ./gradlew buildPlugin
+2
View File
@@ -165,3 +165,5 @@ dist
temp/* temp/*
dist/* dist/*
# Local build of the CLI binary
/pgtidy
+27 -3
View File
@@ -36,7 +36,7 @@ pkg/pgast/ — go-pgquery wrapper: SQL → real PG AST (lint, v2)
pkg/lint/ — rule engine + rule packs (v2) pkg/lint/ — rule engine + rule packs (v2)
pkg/lsp/ — LSP server (v3) pkg/lsp/ — LSP server (v3)
editors/vscode/ — VSCode extension (v3) editors/vscode/ — VSCode extension (v3)
editors/datagrip/ — LSP4IJ integration (v4) editors/datagrip/ — native JetBrains plugin, shells out to the CLI (v4)
testdata/corpus/ — real-world .pgsql procedures used as the safety/idempotence harness testdata/corpus/ — real-world .pgsql procedures used as the safety/idempotence harness
``` ```
@@ -59,6 +59,29 @@ testdata/corpus/ — real-world .pgsql procedures used as the safety/idempoten
3. **Idempotence**: `fmt(fmt(x)) == fmt(x)`. 3. **Idempotence**: `fmt(fmt(x)) == fmt(x)`.
4. **Graceful degradation**: any span the parser cannot handle is passed through verbatim 4. **Graceful degradation**: any span the parser cannot handle is passed through verbatim
rather than corrupted. rather than corrupted.
5. **Runtime safety gate**: every frontend (CLI `fmt`, LSP `textDocument/formatting` and
`rangeFormatting`) calls `format.VerifySafe(src, out, style)` before writing or returning
formatted output. If it returns a non-nil error the formatter has a bug — the caller must
refuse to write/emit the result and keep the original source, never guess or best-effort
it. This is enforced at runtime, not just in `pkg/format/*_test.go`, so a formatter bug can
never silently drop or alter code. `VerifySafe` runs four checks:
- **Semantic equivalence** (`SemanticallyEqual`): re-lex both sides, compare the non-trivia
token streams — case-insensitive for identifiers/keywords, exact otherwise, recursing
into dollar-quoted bodies. Comments and whitespace are trivia and are ignored here.
- **Comment preservation** (`CommentsPreserved`): every `--` and `/* */` comment in `src`
reappears in `out`, in order, with the same content (line endings and indentation are
normalised away; dropping, merging, splitting, reordering, or rewording a comment is
not). Descends into dollar-quoted bodies.
- **Structural balance** (`StructurallyBalanced`): the `( ) [ ]` and BEGIN/CASE/IF/LOOP…END
nesting profile of `out` matches `src`, counting only real code tokens (comment and
string/dollar-quote contents are skipped).
- **Idempotence**: re-formatting `out` yields `out` unchanged.
**Line endings**: the formatter re-emits all layout with `st.Newline` (default `\n`), so a
CRLF input file is normalised to LF on write. This includes `\r\n` that sits *inside* a
multi-line string literal — a line-ending change there is layout normalisation, not a change
of code content, so `SemanticallyEqual` compares string tokens modulo `\r\n` ↔ `\n`
(`normNL` in `pkg/format/safety.go`). Set `newline: "\r\n"` in `.pgtidy.yaml` to keep CRLF.
## Commands ## Commands
@@ -111,8 +134,9 @@ go fmt ./... # Format Go code
Per-platform VSIX (`win32/linux/darwin × x64/arm64`) built in CI matrix. Per-platform VSIX (`win32/linux/darwin × x64/arm64`) built in CI matrix.
### V4 — DataGrip ### V4 — DataGrip
- `editors/datagrip`: integrate via LSP4IJ (free, works across JetBrains editions incl. - `editors/datagrip`: native JetBrains plugin (no LSP4IJ dependency) that shells out to the
DataGrip). No core changes expected. `pgtidy` binary directly. Formatting and version info run `pgtidy fmt`/`version`; lint
diagnostics and quick-fixes run via a native `ExternalAnnotator` calling `pgtidy lint --json`.
## Verification ## Verification
- **Formatter:** `go test ./...` runs golden-file tests + corpus harness asserting idempotence - **Formatter:** `go test ./...` runs golden-file tests + corpus harness asserting idempotence
+9
View File
@@ -63,6 +63,10 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 2 return 2
} }
out := format.File(parser.Parse(string(src)), st) out := format.File(parser.Parse(string(src)), st)
if err := format.VerifySafe(string(src), out, st); err != nil {
_, _ = fmt.Fprintf(stderr, "pgtidy: refusing to format stdin: formatter safety check failed: %v\n", err)
return 2
}
switch { switch {
case check: case check:
if out != string(src) { if out != string(src) {
@@ -86,6 +90,11 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
continue continue
} }
out := format.File(parser.Parse(string(src)), st) out := format.File(parser.Parse(string(src)), st)
if err := format.VerifySafe(string(src), out, st); err != nil {
_, _ = fmt.Fprintf(stderr, "pgtidy: refusing to format %s: formatter safety check failed: %v\n", path, err)
exit = 2
continue
}
changed := out != string(src) changed := out != string(src)
if changed { if changed {
anyDiff = true anyDiff = true
+27 -5
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -20,6 +21,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var ( var (
only []string // --only=ID,ID rule filter only []string // --only=ID,ID rule filter
fix bool fix bool
jsonOutput bool
files []string files []string
) )
for _, a := range args { for _, a := range args {
@@ -29,6 +31,8 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0 return 0
case a == "--fix": case a == "--fix":
fix = true fix = true
case a == "--json":
jsonOutput = true
case strings.HasPrefix(a, "--only="): case strings.HasPrefix(a, "--only="):
ids := strings.Split(strings.TrimPrefix(a, "--only="), ",") ids := strings.Split(strings.TrimPrefix(a, "--only="), ",")
for _, id := range ids { for _, id := range ids {
@@ -66,7 +70,13 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return diags, nil return diags, nil
} }
printDiags := func(diags []diagnostics.Diagnostic) { var jsonDiags []diagnostics.Diagnostic
reportDiags := func(diags []diagnostics.Diagnostic) {
if jsonOutput {
jsonDiags = append(jsonDiags, diags...)
return
}
for _, d := range diags { for _, d := range diags {
loc := d.File loc := d.File
if d.Line > 0 { if d.Line > 0 {
@@ -92,14 +102,14 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
if fix { if fix && !jsonOutput {
fixed := lint.ApplyFixes(string(src), diags) fixed := lint.ApplyFixes(string(src), diags)
if fixed != string(src) { if fixed != string(src) {
_, _ = io.WriteString(stdout, fixed) _, _ = io.WriteString(stdout, fixed)
return 0 return 0
} }
} }
printDiags(diags) reportDiags(diags)
if len(diags) > 0 { if len(diags) > 0 {
found = true found = true
} }
@@ -115,7 +125,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
if fix { if fix && !jsonOutput {
fixed := lint.ApplyFixes(string(src), diags) fixed := lint.ApplyFixes(string(src), diags)
if fixed != string(src) { if fixed != string(src) {
if err := os.WriteFile(path, []byte(fixed), 0o644); err != nil { if err := os.WriteFile(path, []byte(fixed), 0o644); err != nil {
@@ -130,13 +140,24 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
} }
} }
} }
printDiags(diags) reportDiags(diags)
if len(diags) > 0 { if len(diags) > 0 {
found = true found = true
} }
} }
} }
if jsonOutput {
if jsonDiags == nil {
jsonDiags = []diagnostics.Diagnostic{}
}
enc := json.NewEncoder(stdout)
if err := enc.Encode(jsonDiags); err != nil {
_, _ = fmt.Fprintf(stderr, "pgtidy: encoding json: %v\n", err)
return 2
}
}
if found { if found {
return 1 return 1
} }
@@ -150,6 +171,7 @@ Read SQL from files (or stdin) and report lint findings.
Flags: Flags:
--fix Apply autofixes for fixable rules (MIG001, MIG003) and rewrite files --fix Apply autofixes for fixable rules (MIG001, MIG003) and rewrite files
--json Emit findings as a JSON array instead of text (ignores --fix)
--only=ID,... comma-separated rule IDs to enable (default: all rules) --only=ID,... comma-separated rule IDs to enable (default: all rules)
-h, --help show this help -h, --help show this help
+3 -3
View File
@@ -47,14 +47,14 @@ insert_collapse_values: true # Fold multiple VALUES rows onto fewer lines
# --- Routines (functions / procedures) --- # --- Routines (functions / procedures) ---
align_param_types: true # Pad param names so type column aligns across all params align_param_types: false # Pad param names so type column aligns across all params
routine_as_wrap: true # Newline before AS $$ (false = keep AS on same line) routine_as_wrap: true # Newline before AS $$ (false = keep AS on same line)
# --- PL/pgSQL body --- # --- PL/pgSQL body ---
plpgsql_max_blank_lines: 1 # Max consecutive blank lines in 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_type: true # Align type column in DECLARE block
plpgsql_declare_align_eq: false # Align := / = in DECLARE block plpgsql_declare_align_eq: true # Align := / = in DECLARE block (padded to the widest assigned declaration)
plpgsql_if_then_newline: true # THEN on its own line (false = same line as condition) 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 plpgsql_loop_collapse: true # Collapse empty loop bodies to one line
+116
View File
@@ -0,0 +1,116 @@
# PgTidy LSP Status & Roadmap
This document describes the current state of the PgTidy LSP server (`pkg/lsp`,
`cmd/pgtidy/lsp.go`), what it actually provides today, and concrete next steps.
It was written as part of issue #3 ("See what we can provide for LSP").
> Note: The V3 milestone is already implemented and shipping (`docs/todo.md` marks
> V3 done), so this is an inventory + gap analysis, not a greenfield proposal.
## What the server provides today
Verified by hand against the built binary (`pgtidy lsp`, JSON-RPC 2.0 over stdio,
`Content-Length` framing) — no external LSP library, all wire types hand-rolled.
### Advertised capabilities (`initialize` → `capabilities`)
| Capability | Value | Where |
|---|---|---|
| `textDocumentSync` | `1` (full sync) | `serverCaps` |
| `documentFormattingProvider` | `true` | `handle("initialize")` |
| `documentRangeFormattingProvider` | `true` | `handle("initialize")` |
| `codeActionProvider` | `true` | `handle("initialize")` |
### Supported methods
| Method | Direction | Behavior |
|---|---|---|
| `initialize` / `shutdown` / `exit` / `initialized` | req/resp + notif | Lifecycle. `exit`/`shutdown` acknowledged with `null` result. |
| `textDocument/didOpen` | notif | Stores document text; triggers `publishDiagnostics`. |
| `textDocument/didChange` | notif | Stores latest content version; triggers `publishDiagnostics`. |
| `textDocument/didClose` | notif | Drops text + fix cache; clears diagnostics with empty list. |
| `textDocument/formatting` | req/resp | Full-doc format via `pkg/format`; returns one `fullReplace` `TextEdit`. |
| `textDocument/rangeFormatting` | req/resp | Formats doc, returns minimal edit over the selected line range. |
| `textDocument/codeAction` | req/resp | Returns quick-fix `WorkspaceEdit`s for fixable diagnostics overlapping the range. |
| `textDocument/publishDiagnostics` | notif | Sent on every open/change; diagnostic code = `RuleID`, source = `pgtidy`. |
| `$/cancelRequest` | req | Ignored (per LSP, no response). |
| unknown | req/resp | `-32601 method not found` (when the request has an `id`). |
### Verified at runtime (e2e smoke test)
- `initialize` returns the capability block above.
- `didOpen` on `select * from t;` → `publishDiagnostics` with `COR001`
("SELECT * is fragile…", severity 4 = hint, code `COR001`).
- `textDocument/formatting` on that input → edit replacing with
`SELECT *\nFROM t;\n` (keyword casing + clause-per-line applied).
- `textDocument/hover` → `-32601 method not found` (not implemented — correct).
## What the server does NOT provide (gaps)
These are the most useful, well-scoped gaps to fill next. None are blockers for the
current shipping state.
1. **No `hover`.** `textDocument/hover` is unimplemented and returns `-32601`.
A natural first add: return the `RuleID` + a short explanation for diagnostics
on the hovered range, or a keyword/type doc for `hover` on SQL identifiers.
2. **No `documentSymbol` / `documentLink`.** No outline/symbol tree. For a formatter
that already parses `CREATE FUNCTION`/`PROCEDURE` headers into a CST, a symbol
provider listing functions/procedures would be low-cost and high-value in large
schema files.
3. **`hover`-style diagnostics shape.** Diagnostics currently use a `Range` whose
`end.character` is `start.character + 1` (a 1-char caret), not the actual
offending span. A real highlight range would improve editor UX.
4. **`textDocument/willSave` / `willSaveWaitUntil` / `didSave`.** No save hooks —
"format-on-save" must currently be driven by the client binding
`textDocument/formatting` to the editor's save event. A `willSaveWaitUntil`
handler would let the server own format-on-save.
5. **No `completion`.** `textDocument/completion` is not implemented. Not urgent for
a formatter/linter, but relevant if PL/pgSQL autocompletion (keywords, types) is
ever in scope.
6. **No diagnostics debounce/coalescing beyond full-sync.** Every `didChange`
re-runs the full lint engine. Fine for now; a debounce + incremental re-check
becomes relevant on large files.
7. **`initializationOptions` / workspace config.** `initialize` params are parsed
nowhere — no way to pass style overrides or a config path over the protocol.
8. **No `textDocument/prepareRename`, `rename`, `references`, `foldingRange`.**
Low priority; would be natural extensions once symbol info exists.
## Conventions to keep consistent
- **One core, many frontends.** The LSP reuses `pkg/diagnostics.Diagnostic` and
`pkg/lint` directly — no parallel diagnostic model. New LSP features should reuse
these, not fork them.
- **Safety gate is non-negotiable.** Both `formatting` and `rangeFormatting` call
`format.SemanticallyEqual(src, out)` before returning edits; on failure they return
an empty edit (keep original). Any new code path that formats must honor this
invariant (invariant #5 in `AGENTS.md`).
- **No new external deps.** The wire layer is intentionally dependency-free. New
protocol types should be added as local structs, not pulled in from an LSP library.
## Concrete next steps (recommended, smallest-first)
Ranked by effort/value for the smallest useful delta:
1. **Add a real diagnostic highlight range** (swap the 1-char caret for the actual
offending span) — ~1 file, no new method, immediate UX win. Reuses existing
`RuleID`/severity data.
2. **Add `textDocument/hover`** returning the rule explanation for the hovered
diagnostic, or a keyword/type glossary. Reuses `pkg/lint` rule metadata.
3. **Add `documentSymbol`** listing `CREATE FUNCTION`/`PROCEDURE` signatures.
Reuses the existing CST header parse in `pkg/format`.
4. **Add `willSaveWaitUntil`** to own format-on-save instead of relying on client
binding.
> Do NOT: expand the LSP surface into a broad design (workspace features,
incremental parsing, custom `textDocument/*` extensions) as part of this issue.
Keep any change scoped to the above and evidence-backed by a `pkg/lsp` test
(see `server_test.go` for the framed-request/response harness).
## Verification
- `go build ./cmd/pgtidy` succeeds.
- `go test ./...` passes (LSP unit tests in `pkg/lsp/server_test.go` exercise
`initialize`, formatting, range formatting, `didClose` diagnostics clearing).
- Runtime e2e smoke test (framed JSON-RPC over stdio) confirmed `initialize`
capabilities, `COR001` diagnostics, and a formatting edit; `hover` correctly
returns `-32601`.
+3 -3
View File
@@ -128,7 +128,7 @@ DataGrip enum conventions used below:
| DataGrip key | PgTidy key | Default | Notes | | DataGrip key | PgTidy key | Default | Notes |
|---|---|---|---| |---|---|---|---|
| `ROUTINE_ARG_COMMA` | uses `commas` | `leading` | same setting as query lists | | `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_ARG_ALIGN_TYPES` | `align_param_types` | `false` | align type column in param list |
| `ROUTINE_AS_WRAP` | `routine_as_wrap` | `true` | newline before `AS $$` | | `ROUTINE_AS_WRAP` | `routine_as_wrap` | `true` | newline before `AS $$` |
### PL/pgSQL body ### PL/pgSQL body
@@ -136,8 +136,8 @@ DataGrip enum conventions used below:
| DataGrip key | PgTidy key | Default | Notes | | 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_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_TYPE` | `plpgsql_declare_align_type` | `true` | align type column in DECLARE block |
| `IMP_DECLARE_ALIGN_EQ` | `plpgsql_declare_align_eq` | `false` | align `:=` / `=` in DECLARE block | | `IMP_DECLARE_ALIGN_EQ` | `plpgsql_declare_align_eq` | `true` | align `:=` / `=` in DECLARE block |
| `IMP_IF_THEN_WRAP_THEN` | `plpgsql_if_then_newline` | `true` | THEN on its own line | | `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 | | `IMP_LOOP_COLLAPSE` | `plpgsql_loop_collapse` | `true` | collapse empty loop bodies |
+13 -2
View File
@@ -93,6 +93,14 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
- `fmt_test.go`: stdin, --check (un/formatted), -w idempotence, unknown-command. - `fmt_test.go`: stdin, --check (un/formatted), -w idempotence, unknown-command.
- Safety invariants #2 (semantic equivalence), #3 (idempotence), #4 (graceful degradation) - Safety invariants #2 (semantic equivalence), #3 (idempotence), #4 (graceful degradation)
are tested in `pkg/format` over the corpus. are tested in `pkg/format` over the corpus.
- **Runtime safety gate** (`format.VerifySafe`, `pkg/format/safety.go`): every frontend runs
it before emitting. Bundles `SemanticallyEqual` (code token stream) + `CommentsPreserved`
(no comment dropped/merged/split/reworded, recursing into bodies) + `StructurallyBalanced`
(`()[]` / BEGIN·CASE·IF·LOOP…END profile, ignoring comment & string contents) + an
idempotence re-format. On failure the CLI prints the reason and keeps the original.
Caught two real bugs: multi-line `/* */` bodies were reindented as code, and col-0 `--`
lines were glued onto the previous line (merging consecutive comments) — both fixed in
`formatBodyStatements`.
--- ---
@@ -119,6 +127,7 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
- `cmd/pgtidy/lsp.go`: `pgtidy lsp` subcommand; config discovered from cwd. - `cmd/pgtidy/lsp.go`: `pgtidy lsp` subcommand; config discovered from cwd.
- `editors/vscode/`: TS extension using `vscode-languageclient`; launches `pgtidy lsp` via stdio; `.pgsql` mapped to `sql` language; `pgtidy.path` / `pgtidy.enable` settings. - `editors/vscode/`: TS extension using `vscode-languageclient`; launches `pgtidy lsp` via stdio; `.pgsql` mapped to `sql` language; `pgtidy.path` / `pgtidy.enable` settings.
- _Range formatting: future._ - _Range formatting: future._
- Full capability inventory, runtime-verified gaps, and ranked next steps in `docs/lsp-status.md` (issue #3).
## ✅ V4 — DataGrip ## ✅ V4 — DataGrip
- `editors/datagrip/`: Gradle-based JetBrains plugin targeting DataGrip 2024.3+ via LSP4IJ. - `editors/datagrip/`: Gradle-based JetBrains plugin targeting DataGrip 2024.3+ via LSP4IJ.
@@ -212,7 +221,7 @@ as a proxy (new_line for content, inline for single-arg subexpressions).
### ✅ Formatter — routine param alignment (`pkg/format/format.go`) ### ✅ Formatter — routine param alignment (`pkg/format/format.go`)
- `align_param_types`: `alignParamTypes()` pads param names so type columns align; default `true`. - `align_param_types`: `alignParamTypes()` pads param names so type columns align; default `false` (house style: no type-column alignment in param lists).
- `routine_as_wrap`: when `false`, AS stays on the same line as the last option clause. - `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. - Golden file `testdata/corpus/test_a.pgsql` updated to reflect aligned params.
@@ -220,7 +229,9 @@ as a proxy (new_line for content, inline for single-arg subexpressions).
- `plpgsql_max_blank_lines`: blank-line runs capped at the configured limit; default `1`. - `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 - `plpgsql_declare_align_type` + `plpgsql_declare_align_eq`: two-pass declare formatter
measures name/type widths then pads for alignment; `writeDeclareAligned` helper. measures name/type widths then pads for alignment; `writeDeclareAligned` helper. Both
default `true` (house style). The `=` column is padded only to the widest type among
declarations that actually carry an assignment, so a lone `x text = '…';` stays tight.
- `plpgsql_if_then_newline`: when `false`, `joinThenToCondition` merges THEN onto the - `plpgsql_if_then_newline`: when `false`, `joinThenToCondition` merges THEN onto the
preceding condition line. preceding condition line.
- `plpgsql_loop_collapse`: `tryCollapseLoop` detects empty FOR/WHILE loop bodies and - `plpgsql_loop_collapse`: `tryCollapseLoop` detects empty FOR/WHILE loop bodies and
+7 -17
View File
@@ -1,37 +1,27 @@
# PgTidy — DataGrip / JetBrains Plugin # PgTidy — DataGrip / JetBrains Plugin
Formats and lints SQL files in DataGrip (and any JetBrains IDE) via the LSP4IJ plugin. Formats SQL files in DataGrip (and any JetBrains IDE) by shelling out to the `pgtidy` binary.
## Requirements ## Requirements
- `pgtidy` binary on `PATH` — download from [releases](https://git.warky.dev/wdevs/PgTidy/releases) or build with `go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest` - `pgtidy` binary on `PATH` — download from [releases](https://git.warky.dev/wdevs/PgTidy/releases) or build with `go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest`
- [LSP4IJ](https://plugins.jetbrains.com/plugin/23257-lsp4ij) plugin installed (free, by Red Hat)
- DataGrip 2024.3+ (or any JetBrains IDE 2024.3+) - DataGrip 2024.3+ (or any JetBrains IDE 2024.3+)
## Install the plugin ## Install the plugin
### Option A — Install from disk (`.zip`)
1. Build: `./gradlew buildPlugin` (output in `build/distributions/`) 1. Build: `./gradlew buildPlugin` (output in `build/distributions/`)
2. In DataGrip: **Settings → Plugins → ⚙ → Install Plugin from Disk…** → select the `.zip` 2. In DataGrip: **Settings → Plugins → ⚙ → Install Plugin from Disk…** → select the `.zip`
3. Restart the IDE 3. Restart the IDE
### Option B — Install LSP4IJ and configure manually (no plugin build needed)
1. Install **LSP4IJ** from the marketplace (**Settings → Plugins → Marketplace → search "LSP4IJ"**)
2. Go to **Settings → Language Servers → + (Add)**
3. Fill in:
- **Name:** `PgTidy`
- **Command:** `pgtidy lsp`
4. Under **Mappings**, add file patterns: `*.sql`, `*.pgsql`
5. Click **OK** and restart the IDE
## Features ## Features
- **Formatting** — `Code → Reformat Code` (`Ctrl+Alt+L`) formats the current SQL file - **Formatting** — `Format with PgTidy` (`Ctrl+Alt+Shift+P` or right-click in the editor) formats the current SQL file via `pgtidy fmt`
- **Diagnostics** — lint findings shown as inspections (MIG001–MIG003, COR001–003, NAM001–003) - **Version check** — `Tools → Show PgTidy Version` and the status bar widget show the installed `pgtidy` version
- **Diagnostics** — lint findings (MIG001–MIG003, COR001–003, NAM001–003) are shown inline as editor annotations, via a native `ExternalAnnotator` that runs `pgtidy lint --json`
- **Quick fixes** — intention actions for MIG001 (add `CONCURRENTLY`) and MIG003 (add `NOT VALID`) - **Quick fixes** — intention actions for MIG001 (add `CONCURRENTLY`) and MIG003 (add `NOT VALID`)
## Troubleshooting ## Troubleshooting
**Server not starting:** Check **View → Tool Windows → LSP4IJ Consoles** for stderr output. Most common cause: `pgtidy` not found on `PATH` — update the command to the full path, e.g. `/usr/local/bin/pgtidy lsp`. **"Cannot start pgtidy" error:** the `pgtidy` binary isn't on `PATH` for the IDE process. Install it or restart the IDE after adding it to `PATH`.
**No diagnostics showing:** diagnostics only run on files with a `.sql` or `.pgsql` extension; check `pgtidy lint --json` runs cleanly on the file from a terminal with the same `PATH` as the IDE.
-1
View File
@@ -23,7 +23,6 @@ dependencies {
val platformType = providers.gradleProperty("platformType") val platformType = providers.gradleProperty("platformType")
create(platformType, platformVersion) create(platformType, platformVersion)
bundledPlugin("com.intellij.database") bundledPlugin("com.intellij.database")
plugin("com.redhat.devtools.lsp4ij:${providers.gradleProperty("lsp4ijVersion").get()}")
} }
} }
-1
View File
@@ -5,4 +5,3 @@ pluginSinceBuild=243
# DataGrip 2024.3 # DataGrip 2024.3
platformVersion=2024.3 platformVersion=2024.3
platformType=DB platformType=DB
lsp4ijVersion=0.20.1
@@ -0,0 +1,18 @@
package com.pgtidy.datagrip
data class PgTidyFix(
val offset: Int,
val end: Int,
val new: String,
val title: String,
)
data class PgTidyDiagnostic(
val ruleId: String,
val severity: String,
val message: String,
val file: String,
val line: Int,
val col: Int,
val fix: PgTidyFix?,
)
@@ -0,0 +1,50 @@
package com.pgtidy.datagrip
import com.intellij.lang.annotation.AnnotationHolder
import com.intellij.lang.annotation.ExternalAnnotator
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.editor.Document
import com.intellij.psi.PsiFile
class PgTidyExternalAnnotator : ExternalAnnotator<String, List<PgTidyDiagnostic>>() {
override fun collectInformation(file: PsiFile): String? {
val ext = file.virtualFile?.extension?.lowercase()
if (ext != "sql" && ext != "pgsql") return null
return file.viewProvider.document?.text
}
override fun doAnnotate(source: String): List<PgTidyDiagnostic> = PgTidyLintRunner.run(source)
override fun apply(file: PsiFile, diagnostics: List<PgTidyDiagnostic>, holder: AnnotationHolder) {
val document = file.viewProvider.document ?: return
for (d in diagnostics) {
if (d.line <= 0 || d.line > document.lineCount) continue
val range = lineTailRange(document, d.line, d.col) ?: continue
val severity = when (d.severity) {
"error" -> HighlightSeverity.ERROR
"warning" -> HighlightSeverity.WARNING
else -> HighlightSeverity.WEAK_WARNING
}
val builder = holder.newAnnotation(severity, "[${d.ruleId}] ${d.message}").range(range)
if (d.fix != null) {
builder.withFix(PgTidyQuickFix(d.fix))
}
builder.create()
}
}
/** Byte column [col] (1-based) to end-of-line, converted to a document TextRange. */
private fun lineTailRange(document: Document, line1Based: Int, col1Based: Int): com.intellij.openapi.util.TextRange? {
val lineIdx = line1Based - 1
val lineStart = document.getLineStartOffset(lineIdx)
val lineEnd = document.getLineEndOffset(lineIdx)
val lineText = document.getText(com.intellij.openapi.util.TextRange(lineStart, lineEnd))
val charCol = PgTidyOffsets.byteOffsetToCharIndex(lineText, col1Based - 1)
val start = lineStart + charCol
if (start >= lineEnd) return null
return com.intellij.openapi.util.TextRange(start, lineEnd)
}
}
@@ -0,0 +1,32 @@
package com.pgtidy.datagrip
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
object PgTidyLintRunner {
private val gson = Gson()
private val listType = TypeToken.getParameterized(List::class.java, PgTidyDiagnostic::class.java).type
/** Runs `pgtidy lint --json` over [source]. Returns an empty list if pgtidy is missing or output is unparseable. */
fun run(source: String): List<PgTidyDiagnostic> {
val proc = try {
ProcessBuilder("pgtidy", "lint", "--json")
.redirectErrorStream(false)
.start()
} catch (ex: Exception) {
return emptyList()
}
proc.outputStream.bufferedWriter().use { it.write(source) }
val output = proc.inputStream.bufferedReader().readText()
proc.errorStream.bufferedReader().readText()
proc.waitFor()
if (output.isBlank()) return emptyList()
return try {
gson.fromJson<List<PgTidyDiagnostic>>(output, listType) ?: emptyList()
} catch (ex: Exception) {
emptyList()
}
}
}
@@ -0,0 +1,28 @@
package com.pgtidy.datagrip
/**
* pgtidy reports positions as UTF-8 byte offsets (Go strings are byte slices);
* IntelliJ documents index text as UTF-16 chars. Converts one to the other.
*/
object PgTidyOffsets {
fun byteOffsetToCharIndex(text: String, byteOffset: Int): Int {
var bytes = 0
var i = 0
while (i < text.length) {
val cp = text.codePointAt(i)
val charCount = Character.charCount(cp)
val byteLen = utf8Length(cp)
if (bytes + byteLen > byteOffset) return i
bytes += byteLen
i += charCount
}
return text.length
}
private fun utf8Length(codePoint: Int): Int = when {
codePoint <= 0x7F -> 1
codePoint <= 0x7FF -> 2
codePoint <= 0xFFFF -> 3
else -> 4
}
}
@@ -0,0 +1,25 @@
package com.pgtidy.datagrip
import com.intellij.codeInsight.intention.IntentionAction
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import com.intellij.util.IncorrectOperationException
class PgTidyQuickFix(private val fix: PgTidyFix) : IntentionAction {
override fun getText(): String = fix.title
override fun getFamilyName(): String = "PgTidy"
override fun startInWriteAction(): Boolean = true
override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = editor != null
@Throws(IncorrectOperationException::class)
override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {
val document = editor?.document ?: return
val text = document.text
val start = PgTidyOffsets.byteOffsetToCharIndex(text, fix.offset)
val end = PgTidyOffsets.byteOffsetToCharIndex(text, fix.end)
document.replaceString(start, end, fix.new)
}
}
@@ -1,9 +0,0 @@
package com.pgtidy.datagrip
import com.intellij.openapi.project.Project
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
class PgTidyServerConnection(project: Project) : ProcessStreamConnectionProvider(
listOf("pgtidy", "lsp"),
project.basePath ?: System.getProperty("user.home"),
)
@@ -1,10 +0,0 @@
package com.pgtidy.datagrip
import com.intellij.openapi.project.Project
import com.redhat.devtools.lsp4ij.LanguageServerFactory
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
class PgTidyServerFactory : LanguageServerFactory {
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
PgTidyServerConnection(project)
}
@@ -5,13 +5,11 @@
<vendor url="https://git.warky.dev/wdevs/PgTidy">Warky Devs</vendor> <vendor url="https://git.warky.dev/wdevs/PgTidy">Warky Devs</vendor>
<description><![CDATA[ <description><![CDATA[
PostgreSQL formatter and linter powered by <a href="https://github.com/hein/pgtidy">pgtidy</a>.<br/> PostgreSQL formatter and linter powered by <a href="https://github.com/hein/pgtidy">pgtidy</a>.<br/>
Requires the <code>pgtidy</code> binary on PATH and the Requires the <code>pgtidy</code> binary on PATH.
<a href="https://plugins.jetbrains.com/plugin/23257-lsp4ij">LSP4IJ</a> plugin.
]]></description> ]]></description>
<depends>com.intellij.modules.platform</depends> <depends>com.intellij.modules.platform</depends>
<depends>com.intellij.database</depends> <depends>com.intellij.database</depends>
<depends>com.redhat.devtools.lsp4ij</depends>
<actions> <actions>
<action id="com.pgtidy.FormatDocument" <action id="com.pgtidy.FormatDocument"
@@ -33,17 +31,7 @@
<statusBarWidgetFactory id="com.pgtidy.StatusBarWidget" <statusBarWidgetFactory id="com.pgtidy.StatusBarWidget"
implementation="com.pgtidy.datagrip.PgTidyStatusBarWidgetFactory" implementation="com.pgtidy.datagrip.PgTidyStatusBarWidgetFactory"
order="last"/> order="last"/>
<externalAnnotator language="" implementationClass="com.pgtidy.datagrip.PgTidyExternalAnnotator"/>
</extensions> </extensions>
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
<server id="com.pgtidy.lsp"
name="PgTidy"
factoryClass="com.pgtidy.datagrip.PgTidyServerFactory">
<description>PostgreSQL formatter and linter (pgtidy lsp)</description>
</server>
<fileNamePatternMapping patterns="*.sql;*.pgsql"
serverId="com.pgtidy.lsp"
languageId="SQL"/>
</extensions>
</idea-plugin> </idea-plugin>
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=pgtidy-bin pkgname=pgtidy-bin
pkgver=0.0.4 pkgver=0.0.8
pkgrel=1 pkgrel=1
pkgdesc="PostgreSQL SQL formatter and linter" pkgdesc="PostgreSQL SQL formatter and linter"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: pgtidy Name: pgtidy
Version: 0.0.4 Version: 0.0.8
Release: 1%{?dist} Release: 1%{?dist}
Summary: PostgreSQL SQL formatter and linter Summary: PostgreSQL SQL formatter and linter
+3 -3
View File
@@ -130,12 +130,12 @@ func Default() Style {
InsertCollapseValues: true, InsertCollapseValues: true,
AlignParamTypes: true, AlignParamTypes: false,
RoutineAsWrap: true, RoutineAsWrap: true,
PlpgsqlMaxBlankLines: 1, PlpgsqlMaxBlankLines: 1,
PlpgsqlDeclareAlignType: false, PlpgsqlDeclareAlignType: true,
PlpgsqlDeclareAlignEq: false, PlpgsqlDeclareAlignEq: true,
PlpgsqlIfThenNewline: true, PlpgsqlIfThenNewline: true,
PlpgsqlLoopCollapse: true, PlpgsqlLoopCollapse: true,
+11 -11
View File
@@ -15,27 +15,27 @@ const (
// TextFix is a byte-range replacement that can be applied to the source SQL. // TextFix is a byte-range replacement that can be applied to the source SQL.
// Replace src[Offset:End] with New. An insertion has Offset == End. // Replace src[Offset:End] with New. An insertion has Offset == End.
type TextFix struct { type TextFix struct {
Offset int // byte offset in source (inclusive) Offset int `json:"offset"` // byte offset in source (inclusive)
End int // byte offset in source (exclusive) End int `json:"end"` // byte offset in source (exclusive)
New string // replacement text New string `json:"new"` // replacement text
Title string // short description shown in editor UI Title string `json:"title"` // short description shown in editor UI
} }
// Diagnostic is a single lint finding. // Diagnostic is a single lint finding.
type Diagnostic struct { type Diagnostic struct {
// RuleID is the stable identifier for the rule that produced this finding // RuleID is the stable identifier for the rule that produced this finding
// (e.g. "MIG001"). // (e.g. "MIG001").
RuleID string RuleID string `json:"ruleId"`
// Severity is the urgency level. // Severity is the urgency level.
Severity Severity Severity Severity `json:"severity"`
// Message is a human-readable description of the finding. // Message is a human-readable description of the finding.
Message string Message string `json:"message"`
// File is the path to the source file, or "" for stdin. // File is the path to the source file, or "" for stdin.
File string File string `json:"file"`
// Line is the 1-based line number of the finding. // Line is the 1-based line number of the finding.
Line int Line int `json:"line"`
// Col is the 1-based column number of the finding. // Col is the 1-based column number of the finding.
Col int Col int `json:"col"`
// Fix is non-nil when an autofix is available for this diagnostic. // Fix is non-nil when an autofix is available for this diagnostic.
Fix *TextFix Fix *TextFix `json:"fix,omitempty"`
} }
+210 -12
View File
@@ -177,7 +177,10 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
if nw > nameColW { if nw > nameColW {
nameColW = nw nameColW = nw
} }
if tw > typeColW { // typeColW drives the '='/':='/DEFAULT column (align_eq only), so
// only declarations that actually carry an assignment participate —
// a bare "name type;" must not widen it.
if tw > typeColW && declHasAssignment(body) {
typeColW = tw typeColW = tw
} }
} }
@@ -207,7 +210,7 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
writeDeclareAligned(b, body, st, nameColW, typeColW) writeDeclareAligned(b, body, st, nameColW, typeColW)
} else { } else {
for j, t := range body { for j, t := range body {
if j > 0 && needSpace(body[j-1].Tok, t.Tok) { if j > 0 && needSpace(body[j-1].Tok, t.Tok) && !isPctTypeBoundary(body, j) {
b.WriteByte(' ') b.WriteByte(' ')
} }
b.WriteString(caseText(t.Tok, st)) b.WriteString(caseText(t.Tok, st))
@@ -220,6 +223,32 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
} }
} }
// declHasAssignment reports whether a DECLARE variable body (name type … ) has
// a default assignment ( := / = / DEFAULT ) at paren depth 0.
func declHasAssignment(body []cst.Tok) bool {
depth := 0
for _, t := range body {
switch t.Tok.Kind {
case lexer.LParen, lexer.LBracket:
depth++
case lexer.RParen, lexer.RBracket:
if depth > 0 {
depth--
}
}
if depth != 0 {
continue
}
if t.Tok.Kind == lexer.Operator && (t.Tok.Text == ":=" || t.Tok.Text == "=") {
return true
}
if t.Tok.Kind == lexer.Ident && lowerASCII(t.Tok.Text) == "default" {
return true
}
}
return false
}
// declareNameTypeWidth returns the rendered width of the name and type portions // declareNameTypeWidth returns the rendered width of the name and type portions
// of a DECLARE variable declaration (without the default assignment). // of a DECLARE variable declaration (without the default assignment).
// Format is: [name type [:= default]] or [name type [DEFAULT default]]. // Format is: [name type [:= default]] or [name type [DEFAULT default]].
@@ -256,7 +285,7 @@ func declareNameTypeWidth(body []cst.Tok, st config.Style) (nameW, typeW int) {
} }
var tb strings.Builder var tb strings.Builder
for j, t := range typeTokens { for j, t := range typeTokens {
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) { if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) && !isPctTypeBoundary(typeTokens, j) {
tb.WriteByte(' ') tb.WriteByte(' ')
} }
tb.WriteString(caseText(t.Tok, st)) tb.WriteString(caseText(t.Tok, st))
@@ -315,7 +344,7 @@ func writeDeclareAligned(b *strings.Builder, body []cst.Tok, st config.Style, na
var typeStr strings.Builder var typeStr strings.Builder
for j, t := range typeTokens { for j, t := range typeTokens {
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) { if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) && !isPctTypeBoundary(typeTokens, j) {
typeStr.WriteByte(' ') typeStr.WriteByte(' ')
} }
typeStr.WriteString(caseText(t.Tok, st)) typeStr.WriteString(caseText(t.Tok, st))
@@ -369,6 +398,22 @@ func formatBodyStatements(text string, st config.Style) string {
normalised := strings.ReplaceAll(text, "\r\n", "\n") normalised := strings.ReplaceAll(text, "\r\n", "\n")
rawLines := strings.Split(normalised, "\n") rawLines := strings.Split(normalised, "\n")
// Mark the continuation lines of every multi-line /* … */ block comment.
// Those lines are comment content, not code: they must be carried verbatim
// with the comment's opening line, never split off and reindented as if
// they were statements of their own.
inBlockComment := make([]bool, len(rawLines))
for _, t := range lexer.Lex(normalised) {
if t.Kind != lexer.BlockComment {
continue
}
n := strings.Count(t.Text, "\n")
start := t.Line - 1 // lexer Line is 1-based within normalised
for k := 1; k <= n && start+k < len(inBlockComment); k++ {
inBlockComment[start+k] = true
}
}
maxBlanks := st.PlpgsqlMaxBlankLines maxBlanks := st.PlpgsqlMaxBlankLines
if maxBlanks < 0 { if maxBlanks < 0 {
maxBlanks = 0 maxBlanks = 0
@@ -429,8 +474,13 @@ func formatBodyStatements(text string, st config.Style) string {
} }
case "exception": case "exception":
inException = true inException = true
// EXCEPTION belongs to its nearest enclosing BEGIN, so align it one
// level in from the current block body (col 0 for the outermost).
effectiveDepth = blockDepth - 1
if effectiveDepth < 0 {
effectiveDepth = 0 effectiveDepth = 0
} }
}
baseIndent := strings.Repeat(st.Indent, effectiveDepth) baseIndent := strings.Repeat(st.Indent, effectiveDepth)
@@ -441,13 +491,9 @@ func formatBodyStatements(text string, st config.Style) string {
stmtLines = joinThenToCondition(stmt) stmtLines = joinThenToCondition(stmt)
} }
for i, ll := range stmtLines { formattedLines := formatBodyStmtLines(stmtLines, baseIndent, st)
if i == 0 || ll.indent == "" { for _, line := range formattedLines {
result.WriteString(baseIndent) result.WriteString(line)
} else {
result.WriteString(ll.indent)
}
result.WriteString(ll.text)
result.WriteString(nl) result.WriteString(nl)
} }
@@ -458,8 +504,21 @@ func formatBodyStatements(text string, st config.Style) string {
stmt = nil stmt = nil
} }
for _, rawLine := range rawLines { for j, rawLine := range rawLines {
line := strings.TrimRight(rawLine, "\r") line := strings.TrimRight(rawLine, "\r")
if inBlockComment[j] {
// Verbatim continuation of a multi-line block comment: glue it to the
// bline holding the comment's opening line.
if len(stmt) > 0 {
last := &stmt[len(stmt)-1]
last.text += "\n" + line
} else {
stmt = append(stmt, bline{text: line})
}
continue
}
indent := leadingWhitespace(line) indent := leadingWhitespace(line)
stripped := line[len(indent):] stripped := line[len(indent):]
@@ -472,6 +531,12 @@ func formatBodyStatements(text string, st config.Style) string {
fw := lowerASCII(firstBodyKeyword(stripped)) fw := lowerASCII(firstBodyKeyword(stripped))
isColZero := indent == "" isColZero := indent == ""
joinToPrev := isColZero && parenDepth == 0 && len(stmt) > 0 && !sqlClauseKw[fw] joinToPrev := isColZero && parenDepth == 0 && len(stmt) > 0 && !sqlClauseKw[fw]
// A col-0 comment-only line is its own thing: never glue it onto the
// previous line — doing so buries a code line's trailing text in a
// comment and collapses consecutive -- comment lines into one.
if joinToPrev && len(significantBodyTokens(stripped)) == 0 {
joinToPrev = false
}
// Don't join a col-0 continuation to a comment-only preceding bline: // Don't join a col-0 continuation to a comment-only preceding bline:
// the comment has no structural keyword so `continue ;` at col-0 would // the comment has no structural keyword so `continue ;` at col-0 would
// disappear into the comment text and be invisible to the lexer. // disappear into the comment text and be invisible to the lexer.
@@ -574,6 +639,139 @@ func formatBodyStatements(text string, st config.Style) string {
return result.String() return result.String()
} }
// formatBodyStmtLines formats one flushed PL/pgSQL statement at its contextual
// base indent. Multi-line UPDATE/DELETE statements inside PL/pgSQL get their
// top-level SET/WHERE/AND/OR clauses realigned under the statement while nested
// subqueries keep their original indentation. Non-DML statements keep
// continuation indentation, except that standalone structural keywords such as
// THEN are aligned with the block opener.
func formatBodyStmtLines(lines []bline, baseIndent string, st config.Style) []string {
if len(lines) == 0 {
return nil
}
if looksLikeMultiLineBodyDML(lines) {
return reindentBodyDML(lines, baseIndent, st)
}
out := make([]string, 0, len(lines))
for i, ll := range lines {
text := ll.text
indent := baseIndent
switch {
case i > 0 && ll.indent == "" && len(significantBodyTokens(text)) == 0:
// A column-0 comment line trailing a multi-line (commented-out)
// statement is a continuation the author left flush-left — keep it
// there rather than re-indenting it to block depth.
indent = ""
case i > 0 && ll.indent != "" && !isStandaloneBodyKeyword(ll.text, "then", "else", "elsif", "elseif"):
indent = ll.indent
}
out = append(out, indent+text)
}
return out
}
func looksLikeMultiLineBodyDML(lines []bline) bool {
if len(lines) < 2 {
return false
}
kw := lowerASCII(firstBodyKeyword(lines[0].text))
return kw == "update" || kw == "delete"
}
func reindentBodyDML(lines []bline, baseIndent string, st config.Style) []string {
out := make([]string, 0, len(lines)+1)
afterWhere := false
parenDepth := 0
for i, ll := range lines {
text := strings.TrimRight(ll.text, " ")
lineDepth := parenDepth
kw := lowerASCII(firstBodyKeyword(text))
if afterWhere && lineDepth == 0 && kw != "and" && kw != "or" {
out = append(out, baseIndent+st.Indent+st.Indent+strings.TrimSpace(text))
afterWhere = false
updateBodyParenDepth(text, &parenDepth)
continue
}
if lineDepth == 0 && (kw == "set" || kw == "where" || kw == "values" || kw == "returning") {
if kw == "where" {
whereText := strings.TrimSpace(text)
fields := strings.Fields(whereText)
nextKw := ""
if i+1 < len(lines) {
nextKw = lowerASCII(firstBodyKeyword(lines[i+1].text))
}
if len(fields) > 1 && (nextKw == "and" || nextKw == "or") {
out = append(out, baseIndent+fields[0])
out = append(out, baseIndent+st.Indent+st.Indent+strings.TrimSpace(whereText[len(fields[0]):]))
afterWhere = false
continue
}
afterWhere = len(fields) == 1
}
out = append(out, baseIndent+strings.TrimSpace(text))
continue
}
if lineDepth == 0 && (kw == "and" || kw == "or") {
// House style: AND/OR line up with the WHERE keyword; the first
// predicate is indented two levels under it.
out = append(out, baseIndent+strings.TrimSpace(text))
afterWhere = false
updateBodyParenDepth(text, &parenDepth)
continue
}
if i == 0 {
out = append(out, baseIndent+strings.TrimSpace(text))
} else if ll.indent != "" {
out = append(out, ll.indent+strings.TrimSpace(text))
} else {
out = append(out, baseIndent+strings.TrimSpace(text))
}
afterWhere = false
updateBodyParenDepth(text, &parenDepth)
}
return out
}
func updateBodyParenDepth(s string, depth *int) {
for _, tok := range lexer.Lex(s) {
switch tok.Kind {
case lexer.LParen, lexer.LBracket:
(*depth)++
case lexer.RParen, lexer.RBracket:
if *depth > 0 {
(*depth)--
}
}
}
}
func significantBodyTokens(s string) []cst.Tok {
var toks []cst.Tok
for _, tok := range lexer.Lex(s) {
if tok.IsTrivia() || tok.Kind == lexer.EOF {
continue
}
toks = append(toks, cst.Tok{Tok: tok})
}
return toks
}
func isStandaloneBodyKeyword(s string, kws ...string) bool {
toks := significantBodyTokens(s)
if len(toks) != 1 || toks[0].Tok.Kind != lexer.Ident {
return false
}
low := lowerASCII(toks[0].Tok.Text)
for _, kw := range kws {
if low == kw {
return true
}
}
return false
}
// joinThenToCondition merges a THEN line (on its own bline) into the preceding // joinThenToCondition merges a THEN line (on its own bline) into the preceding
// condition line when plpgsql_if_then_newline is false. // condition line when plpgsql_if_then_newline is false.
func joinThenToCondition(lines []bline) []bline { func joinThenToCondition(lines []bline) []bline {
+1 -1
View File
@@ -462,7 +462,7 @@ func dmlInline(toks []cst.Tok, st config.Style) string {
} }
var b strings.Builder var b strings.Builder
for i, t := range toks { for i, t := range toks {
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) { if i > 0 && needSpace(toks[i-1].Tok, t.Tok) && !isPctTypeBoundary(toks, i) {
b.WriteByte(' ') b.WriteByte(' ')
} }
// Space after comma in calls: func(a, b) vs func(a,b). // Space after comma in calls: func(a, b) vs func(a,b).
+30 -4
View File
@@ -147,8 +147,9 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
paramTexts = alignParamTypes(paramTexts) paramTexts = alignParamTypes(paramTexts)
} }
first := p.st.Indent + " " // align item text one column past the comma // House style: first parameter indented one level; leading-comma
cont := p.st.Indent // continuation lines carry the comma at column 0 followed by one space.
first := p.st.Indent
for i, text := range paramTexts { for i, text := range paramTexts {
param := cf.Params[i] param := cf.Params[i]
p.nl() p.nl()
@@ -159,7 +160,6 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
p.b.WriteString(",") p.b.WriteString(",")
} }
} else { } else {
p.b.WriteString(cont)
p.b.WriteString(", ") p.b.WriteString(", ")
p.b.WriteString(text) p.b.WriteString(text)
} }
@@ -179,6 +179,7 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
for _, clause := range cf.Options { for _, clause := range cf.Options {
p.nl() p.nl()
p.b.WriteString(p.st.Indent)
p.b.WriteString(p.inline(clause)) p.b.WriteString(p.inline(clause))
} }
if cf.As != nil { if cf.As != nil {
@@ -218,7 +219,7 @@ func (p *printer) inline(toks []cst.Tok) string {
} }
var b strings.Builder var b strings.Builder
for i, t := range toks { for i, t := range toks {
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) { if i > 0 && needSpace(toks[i-1].Tok, t.Tok) && !isPctTypeBoundary(toks, i) {
b.WriteByte(' ') b.WriteByte(' ')
} }
var prev lexer.Token var prev lexer.Token
@@ -254,6 +255,31 @@ func (p *printer) trailingComments(lead cst.Trivia) {
// tightOps are operators printed without surrounding spaces. // tightOps are operators printed without surrounding spaces.
var tightOps = map[string]bool{"::": true, ":": true, "->": true, "->>": true} var tightOps = map[string]bool{"::": true, ":": true, "->": true, "->>": true}
// isPctTypeBoundary reports whether the gap between toks[i-1] and toks[i] sits
// inside a %TYPE / %ROWTYPE modifier (e.g. core.tbl%rowtype), which is printed
// tight like "::" rather than as the modulo operator.
func isPctTypeBoundary(toks []cst.Tok, i int) bool {
if i <= 0 || i >= len(toks) {
return false
}
isPct := func(t lexer.Token) bool { return t.Kind == lexer.Operator && t.Text == "%" }
isTypeWord := func(t lexer.Token) bool {
if t.Kind != lexer.Ident {
return false
}
l := lowerASCII(t.Text)
return l == "type" || l == "rowtype"
}
prev, cur := toks[i-1].Tok, toks[i].Tok
if isPct(prev) && isTypeWord(cur) {
return true // space after %
}
if isPct(cur) && i+1 < len(toks) && isTypeWord(toks[i+1].Tok) {
return true // space before %
}
return false
}
// parenKws are keywords that always take a space before '(' because they // parenKws are keywords that always take a space before '(' because they
// introduce a subquery or a bracketed clause, not a function-call argument list. // introduce a subquery or a bracketed clause, not a function-call argument list.
var parenKws = map[string]bool{ var parenKws = map[string]bool{
+67 -51
View File
@@ -7,7 +7,6 @@ import (
"testing" "testing"
"git.warky.dev/wdevs/pgtidy/pkg/config" "git.warky.dev/wdevs/pgtidy/pkg/config"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
"git.warky.dev/wdevs/pgtidy/pkg/parser" "git.warky.dev/wdevs/pgtidy/pkg/parser"
) )
@@ -107,6 +106,66 @@ func TestFormatMmProcBroken(t *testing.T) {
} }
} }
func TestFormatIssue1PLpgSQLIndenting(t *testing.T) {
src := "CREATE FUNCTION f() RETURNS void LANGUAGE plpgsql AS $$\n" +
"DECLARE\n" +
" r_lp record;\n" +
"BEGIN\n" +
" if r_lp.total > 0\n" +
" and r_lp.totaldone >= r_lp.total\n" +
" then\n" +
" update core.process u\n" +
" set status = 'done'\n" +
" where u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'done';\n" +
" elsif r_lp.total > 0\n" +
" then\n" +
" update core.process u\n" +
" set status = 'open'\n" +
" where u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'open';\n" +
"\n" +
" end if;\n" +
"$$;\n"
want := "CREATE FUNCTION f(\n" +
")\n" +
" RETURNS void\n" +
" LANGUAGE plpgsql\n" +
"AS\n" +
"$$\n" +
"DECLARE\n" +
" r_lp record;\n" +
"BEGIN\n" +
" if r_lp.total > 0\n" +
" and r_lp.totaldone >= r_lp.total\n" +
" then\n" +
" update core.process u\n" +
" set status = 'done'\n" +
" where\n" +
" u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'done';\n" +
" elsif r_lp.total > 0\n" +
" then\n" +
" update core.process u\n" +
" set status = 'open'\n" +
" where\n" +
" u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'open';\n" +
"\n" +
" end if;\n" +
"$$;\n"
got := format(src)
if got != want {
t.Errorf("issue #1 PL/pgSQL indenting\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
checkDML(t, "issue #1 PL/pgSQL indenting", got)
if !semanticallyEqual(src, got) {
t.Errorf("issue #1 PL/pgSQL indenting changed semantics")
}
}
func TestCorpusIdempotentAndSafe(t *testing.T) { func TestCorpusIdempotentAndSafe(t *testing.T) {
dir := filepath.Join("..", "..", "testdata", "corpus") dir := filepath.Join("..", "..", "testdata", "corpus")
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
@@ -125,62 +184,19 @@ func TestCorpusIdempotentAndSafe(t *testing.T) {
} }
src := string(data) src := string(data)
once := format(src) once := format(src)
twice := format(once) // VerifySafe bundles every runtime gate: semantic equivalence, comment
if once != twice { // preservation, structural balance, and idempotence.
t.Errorf("%s: not idempotent", e.Name()) if err := VerifySafe(src, once, config.Default()); err != nil {
} t.Errorf("%s: %v", e.Name(), err)
if !semanticallyEqual(src, once) {
t.Errorf("%s: formatting changed semantics", e.Name())
} }
} }
if seen == 0 { if seen == 0 {
t.Skip("no corpus files") t.Skip("no corpus files")
} }
t.Logf("formatted %d corpus files (idempotent + semantically equal)", seen) t.Logf("verified %d corpus files (semantic + comments + structure + idempotence)", seen)
} }
// semanticallyEqual compares the non-trivia token streams of two sources, // semanticallyEqual is a test-local alias for the exported safety check.
// treating unquoted identifiers/keywords case-insensitively and everything
// else (strings, numbers, operators, punctuation) exactly. Dollar-quoted body
// tokens are compared recursively so body whitespace normalization does not
// trigger a false failure.
func semanticallyEqual(a, b string) bool { func semanticallyEqual(a, b string) bool {
ta := significant(a) return SemanticallyEqual(a, b)
tb := significant(b)
if len(ta) != len(tb) {
return false
}
for i := range ta {
if ta[i].Kind != tb[i].Kind {
return false
}
switch ta[i].Kind {
case lexer.Ident:
if !strings.EqualFold(ta[i].Text, tb[i].Text) {
return false
}
case lexer.DollarString:
_, innerA, _, okA := splitDollarQuote(ta[i].Text)
_, innerB, _, okB := splitDollarQuote(tb[i].Text)
if okA != okB || (okA && !semanticallyEqual(innerA, innerB)) {
return false
}
default:
if ta[i].Text != tb[i].Text {
return false
}
}
}
return true
}
func significant(src string) []lexer.Token {
var out []lexer.Token
for _, t := range lexer.Lex(src) {
if t.Kind == lexer.EOF || t.IsTrivia() {
continue
}
out = append(out, t)
}
return out
} }
+259
View File
@@ -0,0 +1,259 @@
package format
import (
"fmt"
"strings"
"git.warky.dev/wdevs/pgtidy/pkg/config"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
"git.warky.dev/wdevs/pgtidy/pkg/parser"
)
// VerifySafe runs every safety invariant against a formatting result before it
// is written to disk or returned to an editor. src is the original input, out
// the formatted output, and st the style out was produced with. It returns nil
// when out is safe to emit, otherwise an error naming the invariant that failed.
//
// The checks, in order of cost:
//
// 1. Semantic equivalence — the non-trivia (code) token stream is unchanged:
// identifiers/keywords compare case-insensitively, everything else exactly,
// recursing into dollar-quoted bodies. Comments and whitespace are trivia
// and are deliberately ignored here.
// 2. Comment preservation — every -- and /* */ comment in src reappears in out,
// in the same order, with the same content (ignoring only trailing
// whitespace and CRLF/LF). The formatter may move or re-indent a comment but
// must never drop, merge, split, or reword one.
// 3. Structural balance — the ( ) [ ] and BEGIN/CASE/IF/LOOP…END nesting
// profile of out matches src's, counting only real code tokens (anything
// inside a comment or a string/dollar-quoted literal is ignored).
// 4. Idempotence — formatting out again yields out unchanged.
//
// Any failure means the formatter has a bug: the caller must keep the original
// source and never emit out.
func VerifySafe(src, out string, st config.Style) error {
if !SemanticallyEqual(src, out) {
return fmt.Errorf("code token stream changed")
}
if err := CommentsPreserved(src, out); err != nil {
return err
}
if err := StructurallyBalanced(src, out); err != nil {
return err
}
if reformatted := File(parser.Parse(out), st); reformatted != out {
return fmt.Errorf("output is not idempotent (a second format pass would change it)")
}
return nil
}
// SemanticallyEqual reports whether a and b have the same non-trivia token
// stream, i.e. formatting may only ever change whitespace/comment trivia and
// layout — it must never add, remove, or alter a token of actual code.
// Unquoted identifiers/keywords compare case-insensitively (casing is a
// style choice); everything else (strings, numbers, operators, punctuation)
// must match exactly. Dollar-quoted body tokens are compared recursively so
// that independent body reformatting doesn't trigger a false failure.
//
// The CLI and LSP must call this (via VerifySafe) before ever writing or
// emitting formatted output: if it returns false, the formatter has a bug and
// the original source must be kept, never the (corrupting) formatted output.
func SemanticallyEqual(a, b string) bool {
ta := significantTokens(a)
tb := significantTokens(b)
if len(ta) != len(tb) {
return false
}
for i := range ta {
if ta[i].Kind != tb[i].Kind {
return false
}
switch ta[i].Kind {
case lexer.Ident:
if !strings.EqualFold(ta[i].Text, tb[i].Text) {
return false
}
case lexer.String, lexer.EscapeString, lexer.BitString, lexer.HexString, lexer.UnicodeString:
// A CRLF vs LF difference inside a multi-line string literal is a
// line-ending normalisation, not a change of code content — the
// formatter always re-emits layout with st.Newline. Compare the
// literal modulo \r\n ↔ \n.
if normNL(ta[i].Text) != normNL(tb[i].Text) {
return false
}
case lexer.DollarString:
_, innerA, _, okA := splitDollarQuote(ta[i].Text)
_, innerB, _, okB := splitDollarQuote(tb[i].Text)
if okA != okB || (okA && !SemanticallyEqual(innerA, innerB)) {
return false
}
default:
if ta[i].Text != tb[i].Text {
return false
}
}
}
return true
}
// CommentsPreserved reports whether every comment in a survives into b with its
// text intact. Comments are compared in document order; each is reduced to its
// sequence of non-blank text lines (line endings normalised, every line trimmed
// of surrounding whitespace, blank lines dropped) so that the formatter is free
// to move or re-indent a comment but can never drop, merge, split, reorder, or
// reword one. Comments inside dollar-quoted bodies are included (the bodies are
// lexed recursively). A non-nil error describes the first divergence.
func CommentsPreserved(a, b string) error {
ca := comments(a)
cb := comments(b)
if len(ca) != len(cb) {
return fmt.Errorf("comment count changed: input has %d, output has %d", len(ca), len(cb))
}
for i := range ca {
if ca[i] != cb[i] {
return fmt.Errorf("comment %d/%d changed:\n input: %q\n output: %q", i+1, len(ca), ca[i], cb[i])
}
}
return nil
}
// comments returns the normalised text of every -- and /* */ comment in src, in
// order, descending into dollar-quoted bodies.
func comments(src string) []string {
var out []string
for _, t := range lexer.Lex(src) {
switch t.Kind {
case lexer.LineComment, lexer.BlockComment:
out = append(out, normComment(t.Text))
case lexer.DollarString:
if _, inner, _, ok := splitDollarQuote(t.Text); ok {
out = append(out, comments(inner)...)
}
}
}
return out
}
// normComment canonicalises a comment token to its content — the ordered list of
// non-blank text lines, each stripped of surrounding whitespace, joined with LF.
// Line endings and indentation are layout, not content, so they are discarded;
// dropping or rewording an actual line of comment text still shows up.
func normComment(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
var lines []string
for _, ln := range strings.Split(s, "\n") {
if ln = strings.TrimSpace(ln); ln != "" {
lines = append(lines, ln)
}
}
return strings.Join(lines, "\n")
}
// StructurallyBalanced reports whether a and b have the same delimiter and block
// nesting profile: identical counts of ( ) [ ] and of the PL/pgSQL block
// keywords BEGIN / CASE / IF / LOOP / END (and the compound END IF / END LOOP /
// END CASE), plus an identical running paren/bracket depth trace. Only real code
// tokens are counted — anything inside a -- or /* */ comment is trivia and is
// skipped, and string / dollar-quoted literals are opaque single tokens whose
// contents never register (dollar-quoted bodies are recursed into separately).
//
// Given SemanticallyEqual, this is defence in depth: an independent re-count
// with different code that catches a structural token slipping through a bug in
// the token-stream comparison (e.g. its dollar-quote or CRLF handling), and it
// pins down *where* the structure broke.
func StructurallyBalanced(a, b string) error {
pa := structureProfile(a)
pb := structureProfile(b)
if pa.parenDepthTrace != pb.parenDepthTrace {
return fmt.Errorf("parenthesis/bracket nesting changed")
}
for _, k := range structureKeys {
if pa.counts[k] != pb.counts[k] {
return fmt.Errorf("structural token %q count changed: input %d, output %d", k, pa.counts[k], pb.counts[k])
}
}
return nil
}
var structureKeys = []string{"(", ")", "[", "]", "begin", "case", "if", "loop", "end", "end if", "end loop", "end case"}
type structProfile struct {
counts map[string]int
// parenDepthTrace is the sequence of running ( ) [ ] depths after each
// bracket token, joined with commas — a compact fingerprint of the nesting
// shape that diverges as soon as an open/close is added, dropped, or moved.
parenDepthTrace string
}
func structureProfile(src string) structProfile {
p := structProfile{counts: map[string]int{}}
var trace strings.Builder
depth := 0
toks := significantTokens(src) // trivia (comments/whitespace) already excluded
for i := 0; i < len(toks); i++ {
t := toks[i]
switch t.Kind {
case lexer.LParen:
p.counts["("]++
depth++
fmt.Fprintf(&trace, "%d,", depth)
case lexer.RParen:
p.counts[")"]++
depth--
fmt.Fprintf(&trace, "%d,", depth)
case lexer.LBracket:
p.counts["["]++
depth++
fmt.Fprintf(&trace, "%d,", depth)
case lexer.RBracket:
p.counts["]"]++
depth--
fmt.Fprintf(&trace, "%d,", depth)
case lexer.Ident:
switch lowerASCII(t.Text) {
case "begin", "case", "if", "loop":
p.counts[lowerASCII(t.Text)]++
case "end":
p.counts["end"]++
if i+1 < len(toks) && toks[i+1].Kind == lexer.Ident {
switch lowerASCII(toks[i+1].Text) {
case "if":
p.counts["end if"]++
case "loop":
p.counts["end loop"]++
case "case":
p.counts["end case"]++
}
}
}
case lexer.DollarString:
if _, inner, _, ok := splitDollarQuote(t.Text); ok {
sub := structureProfile(inner)
for _, k := range structureKeys {
p.counts[k] += sub.counts[k]
}
trace.WriteString("[" + sub.parenDepthTrace + "]")
}
}
}
p.parenDepthTrace = trace.String()
return p
}
// normNL collapses CRLF to LF so string literals compare independent of the
// source file's line-ending convention.
func normNL(s string) string { return strings.ReplaceAll(s, "\r\n", "\n") }
// significantTokens lexes src and returns its tokens excluding EOF and trivia
// (whitespace/comments).
func significantTokens(src string) []lexer.Token {
var out []lexer.Token
for _, t := range lexer.Lex(src) {
if t.Kind == lexer.EOF || t.IsTrivia() {
continue
}
out = append(out, t)
}
return out
}
+91
View File
@@ -0,0 +1,91 @@
package format
import (
"strings"
"testing"
"git.warky.dev/wdevs/pgtidy/pkg/config"
)
func TestCommentsPreserved(t *testing.T) {
cases := []struct {
name string
a, b string
wantErr bool
}{
{"identical", "select 1; -- note", "select 1;\n-- note", false},
{"reindented block comment", "/* a\n b */ select 1", " /* a\nb */\nselect 1", false},
{"crlf line comment", "-- note\r\nselect 1", "-- note\nselect 1", false},
{"dropped comment", "select 1; -- keep me\nselect 2;", "select 1;\nselect 2;", true},
{"merged comments", "-- one\n-- two\nselect 1", "-- one -- two\nselect 1", true},
{"reworded comment", "-- alpha\nselect 1", "-- beta\nselect 1", true},
{"comment inside body preserved", // -- inside a dollar-quoted body
"do $$ begin\n-- inner\nperform 1;\nend $$;",
"DO\n$$\nbegin\n -- inner\n perform 1;\nend\n$$;", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := CommentsPreserved(c.a, c.b)
if (err != nil) != c.wantErr {
t.Fatalf("CommentsPreserved(%q, %q) err = %v, wantErr %v", c.a, c.b, err, c.wantErr)
}
})
}
}
func TestCommentsPreservedIgnoresCodeText(t *testing.T) {
// A ( ; keyword etc. inside a comment must not be read as code by the check.
a := "select 1; -- ( begin case end ) ;\nselect 2;"
b := "select 1;\nselect 2;\n-- ( begin case end ) ;"
if err := CommentsPreserved(a, b); err != nil {
t.Fatalf("comment content that looks like code tripped the check: %v", err)
}
}
func TestStructurallyBalanced(t *testing.T) {
if err := StructurallyBalanced("select f((a+b)*c) from t", "select f( ( a + b ) * c )\nfrom t"); err != nil {
t.Errorf("whitespace-only reformat flagged: %v", err)
}
// Delimiters that live inside a comment or a string must not count.
if err := StructurallyBalanced("select ')(' as x -- ((((\nfrom t", "select ')(' as x\n-- ((((\nfrom t"); err != nil {
t.Errorf("comment/string delimiters counted: %v", err)
}
if err := StructurallyBalanced("select (a) from t", "select (a from t"); err == nil {
t.Errorf("dropped ')' not detected")
}
}
func TestVerifySafeCatchesNonIdempotent(t *testing.T) {
src := "create function f() returns void language sql as $$ select 1 $$;"
out := format(src)
if err := VerifySafe(src, out, config.Default()); err != nil {
t.Fatalf("clean format rejected: %v", err)
}
// A hand-mangled "output" that differs from what the formatter would produce
// must be rejected (idempotence gate).
if err := VerifySafe(src, out+"\n\n\n", config.Default()); err == nil {
t.Errorf("non-idempotent output accepted")
}
}
func TestVerifySafeBlockCommentInBody(t *testing.T) {
// Regression: a multi-line /* */ comment inside a PL/pgSQL body was being
// re-split and reindented as if its lines were statements.
src := "CREATE FUNCTION f() RETURNS void LANGUAGE plpgsql AS $$\n" +
"BEGIN\n" +
" /*\n" +
" update t u\n" +
" set x = 1\n" +
" where u.id = 2\n" +
" and u.y = 3;\n" +
" */\n" +
" perform 1;\n" +
"END $$;\n"
out := format(src)
if err := VerifySafe(src, out, config.Default()); err != nil {
t.Fatalf("block comment in body mangled: %v", err)
}
if !strings.Contains(out, "where u.id = 2") {
t.Errorf("block comment interior lost a line:\n%s", out)
}
}
+7
View File
@@ -124,6 +124,10 @@ func (s *server) handle(raw []byte) bool {
s.reply(req.ID, []textEdit{}) s.reply(req.ID, []textEdit{})
return false return false
} }
if err := format.VerifySafe(text, formatted, s.cfg); err != nil {
s.reply(req.ID, []textEdit{})
return false
}
s.reply(req.ID, []textEdit{fullReplace(text, formatted)}) s.reply(req.ID, []textEdit{fullReplace(text, formatted)})
case "textDocument/rangeFormatting": case "textDocument/rangeFormatting":
var p rangeFormattingParams var p rangeFormattingParams
@@ -224,6 +228,9 @@ func (s *server) rangeFormat(text string, r lspRange) []textEdit {
if formatted == text { if formatted == text {
return nil return nil
} }
if err := format.VerifySafe(text, formatted, s.cfg); err != nil {
return nil
}
// Split both versions into lines, keeping the trailing newline attached to // Split both versions into lines, keeping the trailing newline attached to
// each element so that joining them reconstructs the original string. // each element so that joining them reconstructs the original string.
+797
View File
@@ -0,0 +1,797 @@
--select * from dropall('action_init','core');
CREATE OR REPLACE FUNCTION core.action_init(
p_table text
, p_rid integer
, INOUT p_parms jsonb default json_build_object()
, OUT p_retval integer
, OUT p_errmsg text
)
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER
AS
$$
DECLARE
--Error Handling--
m_funcname text = 'core.action_init';
m_errmsg text;
m_errcontext text;
m_errdetail text;
m_errhint text;
m_errstate text;
m_retval integer;
--Error Handling--
--r_workflow core.workflowitem%rowtype;
r_tasklist core.tasklist%rowtype;
r_taskitem core.taskitem%rowtype;
r_taskitem_prev core.taskitem%rowtype;
r_taskitem_parent core.taskitem%rowtype;
r_mastertaskitem core.mastertaskitem%rowtype;
r_mastertaskitemevent core.mastertaskitemevent%rowtype;
r_taskitemevent core.taskitemevent%rowtype;
r_taskitemevent_parent core.taskitemevent%rowtype;
m_rid_hub_user integer;
m_rid_hub_payload integer;
m_temp_rid integer;
m_temp_rid_list integer[];
j_results jsonb;
BEGIN
p_retval = 0;
p_errmsg = '';
m_rid_hub_user = _bv(core.f_get_user_hub_rid(), (
select hub.rid_hub
from core.hub
where hub.hubtype = 'program'
limit 1
));
m_rid_hub_payload = _try_integers(p_parms ->> 'rid_hub', p_parms ->> 'rid_hub_payload');
if nv(p_rid) = 0
then
raise 'Invalid or no p_rid';
end if;
---select * from meta.table_prefix t where t.tablename = 'mastertaskitemevent'
if p_table::citext in ('taskitemevent', 'tiv')
then
raise exception 'Action Init is not allowed on task item events. Please use the mastertaskitemevent or taskitem as entry point.';
elsif p_table::citext in ('mastertaskitemevent', 'mtev')
then
select *
from core.mastertaskitemevent mtev
where mtev.rid_mastertaskitemevent = p_rid
into r_mastertaskitemevent;
if r_mastertaskitemevent.inactive > 0
then
raise exception 'The template task item event is inactive. rid_mastertaskitemevent = %',r_mastertaskitemevent.rid_mastertaskitemevent;
end if;
select ttie.*
from core.taskitemevent ttie
where ttie.rid_taskitemevent = _try_integer(p_parms ->> 'parent_rid_taskitemevent', 0)
into r_taskitemevent_parent;
-- try to get the given task item
select *
from core.taskitem ti
where
ti.rid_taskitem = _try_integer(p_parms ->> 'rid_taskitem', -1)
and ti.rid_mastertaskitem = r_mastertaskitemevent.rid_mastertaskitem
into r_taskitem;
if _try_integer(p_parms ->> 'parent_rid_taskitem', 0) > 0
then
select *
from core.taskitem ti
where ti.rid_taskitem = _try_integer(p_parms ->> 'parent_rid_taskitem', 0)
into r_taskitem_parent;
end if;
select *
from core.tasklist tl
where tl.rid_tasklist = r_taskitem.rid_tasklist
into r_tasklist;
-- perform log_event(m_funcname,format(E'Action Init via %s=%s Item:%s(%s rid_taskitem:%s[%s] < %s %s[%s]) Event:%s(%s[%s],parent_rid_taskitemevent:%s[%s])
-- p_parms:%s',p_table,p_rid
-- , r_mastertaskitem.description, r_mastertaskitem.rid_mastertaskitem,r_taskitem.rid_taskitem
-- ,r_taskitem.status, r_taskitem_parent.description
-- ,r_taskitem_parent.rid_taskitem,r_taskitem_parent.status
-- , r_mastertaskitemevent.description,r_mastertaskitemevent.status, r_mastertaskitemevent.rid_mastertaskitemevent
-- , r_taskitemevent_parent.rid_taskitemevent, r_taskitemevent_parent.status
-- , p_parms),bt_enum('eventlog','local notice'));
-- raise 'p_rid=%=% r_taskitem.status =% r_taskitem_parent.status=%,% r_mastertaskitemevent.status=% pps=%'
-- ,p_table,p_rid, r_taskitem.status,r_taskitem_parent.description,r_taskitem_parent.status, r_mastertaskitemevent.status
-- ,(select ti.status
-- from core.taskitem ti
-- where ti.rid_taskitem = r_taskitem_parent.rid_taskitem_parentaction);
-- if r_mastertaskitemevent.status = core._enumi('eventstatus','complete')
-- and r_taskitem_parent.rid_taskitem > 0
-- and exists (
-- select 1
-- from core.taskitem ti
-- where ti.rid_taskitem = r_taskitem_parent.rid_taskitem_parentaction
-- and ti.status in (core._enumi('eventstatus','complete')
-- ,core._enumi('eventstatus','error')
-- ,core._enumi('eventstatus','retry'))
-- )
-- then
-- r_taskitem = null;
--
-- end if;
--If there is no action, try getting the same open one of this type for this hub
if nv(r_taskitem.rid_taskitem) = 0
then
--Create item linked to task for this hub if not found
select r.p_retval, r.p_errmsg, r.p_a_rid_taskitem, r.p_parameters
from core.taskitem_get_or_prime(_jsonb_object_cat(
_jsonb_object_cat(r_taskitem_parent.jsonvalue
, jsonb_build_object('AOP', null, 'controls', null, 'rid_taskitem', null, 'message', null, 'subject', null,
'alerttype', null)
)
, p_parms
, jsonb_build_object(
'rid_mastertaskitem', r_mastertaskitemevent.rid_mastertaskitem
, 'rid_mastertaskitemevent', r_mastertaskitemevent.rid_mastertaskitemevent
, 'parent_rid_taskitemevent', r_taskitemevent_parent.rid_taskitemevent
, 'parent_rid_taskitem', _bv(r_taskitem_parent.rid_taskitem
, _try_integer(p_parms ->> 'parent_rid_taskitem', 0)
)
, 'rid_tasklist_prev', _try_integers(p_parms ->> 'rid_tasklist_prev', p_parms ->> 'rid_tasklist'
, r_tasklist.rid_tasklist::text, r_taskitem.rid_tasklist::text)
, 'rid_taskitem_parentaction', p_parms -> 'rid_taskitem_parentaction'
--,'rid_tasklist', r_tasklist.rid_tasklist
)
)) r
into m_retval,m_errmsg,m_temp_rid_list,j_results;
if m_retval > 0
then
raise exception '%',m_errmsg using hint = 'in taskitem_get_or_prime 2';
end if;
else
m_temp_rid_list = array [r_taskitem.rid_taskitem]::integer[];
end if;
p_parms = jsonb_build_object();
for m_temp_rid in (
select u.n
from unnest(m_temp_rid_list) u(n)
)
loop
select *
from core.taskitem ti
where ti.rid_taskitem = m_temp_rid
into r_taskitem;
select *
from core.tasklist tl
where tl.rid_tasklist = r_taskitem.rid_tasklist
into r_tasklist;
if nv(r_tasklist.rid_tasklist) = 0
then
select *
from core.tasklist tl
where
tl.rid_tasklist = r_taskitem.rid_tasklist
or nv(r_taskitem.rid_tasklist) = 0
and
(tl.rid_hub = m_rid_hub_payload
or tl.rid_hub = r_taskitem_parent.rid_hub
)
and tl.rid_mastertask in (
select mti.rid_mastertask
from core.mastertaskitem mti
where
mti.rid_mastertaskitem = r_mastertaskitemevent.rid_mastertaskitem
and coalesce(mti.inactive, 0) = 0
)
order by tl.rid_tasklist desc
limit 1
into r_tasklist;
end if;
select tie.*
from core.taskitemevent tie
where
tie.rid_taskitem = r_taskitem.rid_taskitem
and tie.rid_mastertaskitemevent = r_mastertaskitemevent.rid_mastertaskitemevent
--and tie.status = r_mastertaskitemevent.status
order by tie.rid_taskitemevent desc
limit 1
into r_taskitemevent;
perform log_event(m_funcname, format(E'__Processing task item %s(%s, status=%s) for event %s(%s) status=%s'
, r_taskitem.description, r_taskitem.rid_taskitem, r_taskitem.status
, r_taskitemevent.description, r_mastertaskitemevent.rid_mastertaskitemevent, r_taskitemevent.status
), bt_enum('eventlog', 'local notice'));
if nv(r_taskitem.status) in (4, 5, 6, 11) --11 Resolved
and r_mastertaskitemevent.status is distinct from r_taskitem.status
and existS (
select 1
from core.taskitemevent e
where
e.rid_taskitem = r_taskitem.rid_taskitem
and e.status = r_taskitem.status
)
--Or errors to errors
-- or nv(r_taskitem.status) in (10, 11)
-- and nv(r_taskitemevent.status) in (1, 2, 11, 10)
-- and not exists (
-- select 1
-- from core.taskitem ti2
-- where ti2.rid_parenttaskitem = r_taskitem.rid_taskitem
-- and ti2.status in (1,2,10,11)
-- )
-- )
-- and nv(r_mastertaskitem.jsonvalue->'AOP'->>'errorcode') = ''
or p_parms ->> 'new_task' in ('1', 'true')
then
select r.p_retval, r.p_errmsg, r.p_a_rid_taskitem, r.p_parameters
from core.taskitem_get_or_prime(_jsonb_object_cat(
_jsonb_object_cat(r_taskitem.jsonvalue, jsonb_build_object('AOP', null, 'controls', null)),
p_parms
, jsonb_build_object(
'rid_mastertaskitem', r_mastertaskitemevent.rid_mastertaskitem
, 'rid_mastertaskitemevent', r_mastertaskitemevent.rid_mastertaskitemevent
, 'parent_rid_taskitemevent', r_taskitemevent.rid_taskitemevent
, 'rid_tasklist', r_tasklist.rid_tasklist
, 'parent_rid_taskitem'
, _bv(r_taskitem.rid_taskitem
, _try_integer(p_parms ->> 'parent_rid_taskitem', 0)
, r_taskitem_parent.rid_taskitem)
, 'new_item', false
, 'status', 1
, 'rid_tasklist_prev', _try_integers(p_parms ->> 'rid_tasklist_prev', p_parms ->> 'rid_tasklist',
r_taskitem.rid_tasklist::text)
, 'rid_taskitem_parentaction', p_parms -> 'rid_taskitem_parentaction'
)
)) r
into m_retval,m_errmsg,m_temp_rid_list,j_results;
if m_retval > 0
then
raise exception '%',m_errmsg using hint = 'in taskitem_get_or_prime 2';
end if;
--raise notice 'Re-evaluated task item % for event % m_temp_rid_list=%',r_taskitem.rid_taskitem,r_mastertaskitemevent.rid_mastertaskitemevent, m_temp_rid_list;
if array_length(m_temp_rid_list, 1) > 1
and r_taskitem.rid_taskitem <> any (m_temp_rid_list)
or (p_parms ->> 'new_task' in ('1', 'true')
and r_taskitem.status in (4, 11)
)
then
perform log_event(m_funcname, format(E'*.* Fired event %s(%s) for completed action : %s New Actions: %s'
, r_mastertaskitemevent.description, r_mastertaskitemevent.rid_mastertaskitemevent, r_taskitem.description
, m_temp_rid_list), bt_enum('eventlog', 'debug'));
r_taskitem_prev = r_taskitem;
select ti.rid_taskitem
from core.taskitem ti
where
ti.rid_taskitem = any (m_temp_rid_list)
and ti.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
into r_taskitem;
select *
from core.mastertaskitem mti
where mti.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
into r_mastertaskitem;
r_taskitemevent = null;
-- else
--
-- perform log_event(m_funcname,format(E'Cannot fire event (Already Done,Cancelled,Error) (%s,%s,%s), on %s(%s) status = %s'
-- ,r_mastertaskitemevent.description
-- ,r_mastertaskitemevent.rid_mastertaskitemevent
-- ,r_mastertaskitemevent.status
-- ,r_taskitem.rid_taskitem
-- ,r_taskitem.description
-- ,r_taskitem.status
-- ),bt_enum('eventlog','debug'));
--
-- continue;
end if;
end if;
---Allow firing events again.
if nv(r_taskitemevent.rid_taskitemevent) > 0
then
-- if nv(r_taskitemevent.retval) < 2
-- then
perform log_event(m_funcname, format(E'**Updating event %s(%s) for action : %s'
, r_mastertaskitemevent.description, r_mastertaskitemevent.rid_mastertaskitemevent, r_taskitem.description
), bt_enum('eventlog', 'debug'));
--SET session_replication_role = DEFAULT;
update core.taskitemevent u
set
retval = 0
, createddatetime = now()
, jsonvalue = _jsonb_object_cat(u.jsonvalue, p_parms -> '_event_jsonvalue',
jsonb_build_object('rid_mastertaskitem_complete', case
when
_try_integer(
r_taskitemevent_parent.jsonvalue ->>
'rid_mastertaskitem_complete',
0) <>
r_mastertaskitemevent.rid_mastertaskitem
then r_taskitemevent_parent.jsonvalue ->> 'rid_mastertaskitem_complete'
else null
end
))
where u.rid_taskitemevent = r_taskitemevent.rid_taskitemevent
returning u.*
into r_taskitemevent;
select r.p_retval, r.p_errmsg
from core.event_created(r_taskitemevent.rid_taskitemevent) r
into m_retval,m_errmsg;
if m_retval > 0
then
raise exception '%',m_errmsg;
end if;
update core.taskitemevent u
set
jsonvalue = _jsonb_object_cat(u.jsonvalue, jsonb_build_object('rid_taskitemevent_retries',
_jsonb_object_cat(u.jsonvalue ->
'rid_taskitemevent_retries',
jsonb_build_array(r_taskitemevent.rid_taskitemevent))))
where u.rid_taskitemevent = r_taskitemevent_parent.rid_taskitemevent;
-- end if;
else
--SET session_replication_role = DEFAULT;
insert
into core.taskitemevent( createddatetime
, description
, guid
, rid_mastertaskitemevent
, rid_taskitem
, duedatetime
, errmsg
, escalated
, outcome
, status
, jsonvalue)
select now()
, format('%s %s', evt.description, nv((
select count(1)
from core.taskitemevent ev2
where
ev2.rid_taskitem = r_taskitem.rid_taskitem
and ev2.rid_mastertaskitemevent = r_mastertaskitemevent.rid_mastertaskitemevent
)) + 1
)
, newid()
, r_mastertaskitemevent.rid_mastertaskitemevent
, r_taskitem.rid_taskitem
, (
select eri.p_timestamp
from core.interval_fetch('mastertaskitemevent', r_mastertaskitemevent.rid_mastertaskitemevent
, r_taskitem.rid_hub, 'duedatetime',
jsonb_build_object('rid_taskitem', r_taskitem.rid_taskitem)) eri
limit 1
)
, null
, _try_integer(p_parms ->> 'escalated', 0)
, evt.outcome
, evt.status
, _jsonb_object_cat(p_parms -> '_event_jsonvalue', case
when nv(r_taskitem.rid_taskitem) = 0
then jsonb_build_object('rid_hub', m_rid_hub_payload)
else jsonb_build_object()
end
, jsonb_build_object('parent_rid_taskitem',
_try_integer(p_parms ->> 'parent_rid_taskitem', r_taskitem.rid_taskitem)
, 'parent_rid_taskitemevent', r_taskitemevent_parent.rid_taskitemevent
, 'rid_mastertaskitem_complete', case
when _try_integer(
r_taskitemevent_parent.jsonvalue ->>
'rid_mastertaskitem_complete', 0) <>
evt.rid_mastertaskitem
then r_taskitemevent_parent.jsonvalue ->> 'rid_mastertaskitem_complete'
else null
end)
)
from core.mastertaskitemevent evt
where evt.rid_mastertaskitemevent = r_mastertaskitemevent.rid_mastertaskitemevent
returning taskitemevent.*
into r_taskitemevent;
if r_taskitemevent.rid_taskitemevent > 0
then
perform log_event(m_funcname, format(E'**Created event %s(%s) for action : %s'
, r_mastertaskitemevent.description, r_mastertaskitemevent.rid_mastertaskitemevent, r_taskitem.description
), bt_enum('eventlog', 'debug'));
update core.taskitemevent u
set
jsonvalue = _jsonb_object_cat(u.jsonvalue, jsonb_build_object('rid_taskitemevent_spawned',
_jsonb_object_cat(u.jsonvalue ->
'rid_taskitemevent_spawned',
jsonb_build_array(r_taskitemevent.rid_taskitemevent))))
where u.rid_taskitemevent = r_taskitemevent_parent.rid_taskitemevent;
end if;
end if;
/*
if nv(r_taskitemevent.rid_taskitemevent) > 0
then
-- if nv(r_taskitemevent.retval) < 2
-- then
perform log_event(m_funcname,format(E'Updating event %s(%s) for action : %s'
,r_mastertaskitemevent.description,r_mastertaskitemevent.rid_mastertaskitemevent, r_taskitem.description
),bt_enum('eventlog','debug'));
perform meta.add_transaction_event(txid_current(), 'core.action_init'
,replace(replace(replace(replace(replace($CC$
DO $ICC$
DECLARE
m_retval integer;
m_errmsg text;
r_taskitemevent core.taskitemevent%rowtype;
r_taskitemevent_parent core.taskitemevent%rowtype;
r_mastertaskitemevent core.mastertaskitemevent%rowtype;
BEGIN
select *
from core.taskitemevent tie
where tie.rid_taskitemevent = [rid_taskitemevent]
into r_taskitemevent;
select *
from core.taskitemevent tie
where tie.rid_taskitemevent = [rid_taskitemevent_parent]
into r_taskitemevent_parent;
select *
from core.mastertaskitemevent t
where t.rid_mastertaskitemevent = [rid_mastertaskitemevent]
into r_mastertaskitemevent;
update core.taskitemevent u
set
retval = 0
, createddatetime = now()
, jsonvalue = _jsonb_object_cat(u.jsonvalue,$JJA$[event_json_value]$JJA$::jsonb, jsonb_build_object('rid_mastertaskitem_complete', case
when
_try_integer(
r_taskitemevent_parent.jsonvalue ->>
'rid_mastertaskitem_complete',
0) <>
r_mastertaskitemevent.rid_mastertaskitem
then r_taskitemevent_parent.jsonvalue ->> 'rid_mastertaskitem_complete'
else null
end
))
where u.rid_taskitemevent = r_taskitemevent.rid_taskitemevent
returning u.*
into r_taskitemevent;
select r.p_retval,r.p_errmsg
from core.event_created(r_taskitemevent.rid_taskitemevent) r
into m_retval,m_errmsg;
if m_retval > 0
then
raise exception '%',m_errmsg;
end if;
update core.taskitemevent u
set
jsonvalue = _jsonb_object_cat(u.jsonvalue, jsonb_build_object('rid_taskitemevent_retries',
_jsonb_object_cat(u.jsonvalue -> 'rid_taskitemevent_retries',
jsonb_build_array(r_taskitemevent.rid_taskitemevent))))
where u.rid_taskitemevent = r_taskitemevent_parent.rid_taskitemevent;
END;
$ICC$;
$CC$,'[rid_taskitemevent_parent]', nv(r_taskitemevent_parent.rid_taskitemevent)::text)
,'[rid_taskitemevent]', nv(r_taskitemevent.rid_taskitemevent)::text)
,'[rid_mastertaskitemevent]', nv(r_mastertaskitemevent.rid_mastertaskitemevent)::text)
,'[rid_taskitem]', nv(r_taskitem.rid_taskitem)::text)
,'[event_json_value]' ,coalesce((p_parms->'_event_jsonvalue')::text,'{}'))
)
,30, format('action_init_%s',r_taskitemevent.rid_taskitemevent);
SET session_replication_role = DEFAULT;
*/
-------------------------------------------__Recursive Rules__---------------------------------------------------
-- --activate rule
-- if r_taskitemevent_parent.rid_taskitemevent > 0
-- and r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_activate' is not null
-- and _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_activate',-1) > 0
-- and not exists (
-- select 1
-- from core.taskitem ti
-- where ti.rid_mastertaskitem = _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_activate',-1)
-- and ti.rid_hub = m_rid_hub_payload
-- )
-- then
-- if _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_activate',-1) <> r_taskitem.rid_mastertaskitem
-- and _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_activate',-1) <> nv(r_mastertaskitem.rid_parentmastertaskitem)
-- then
-- insert into core.taskitemevent(rid_taskitem,rid_mastertaskitemevent,description,status,createddatetime,guid,jsonvalue)
-- select r_taskitem.rid_taskitem
-- , mtev.rid_mastertaskitemevent
-- , mtev.description
-- , mtev.status
-- , now()
-- , newid()
-- , _jsonb_object_cat(mtev.jsonvalue,p_parms->'_event_jsonvalue', jsonb_build_object('rid_mastertaskitem_activate',r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_activate', 'simulated', true
-- , 'parent_rid_taskitem',_try_integer(p_parms ->> 'parent_rid_taskitem',0)
-- ))
-- from core.mastertaskitemevent mtev
-- where mtev.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
-- and coalesce(mtev.inactive,0) = 0
-- and mtev.status = 2
-- and not exists (
-- select 1
-- from core.taskitemevent tie
-- where tie.rid_taskitem = r_taskitem.rid_taskitem
-- and tie.rid_mastertaskitemevent = mtev.rid_mastertaskitemevent
-- )
-- ;
-- elseif _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_activate',-1) = r_taskitem.rid_mastertaskitem
-- then
-- --We reached the target taskitem, so we can mark it activate
-- insert into core.taskitemevent(rid_taskitem,rid_mastertaskitemevent,description,status,createddatetime,guid,jsonvalue)
-- select r_taskitem.rid_taskitem
-- , mtev.rid_mastertaskitemevent
-- , mtev.description
-- , mtev.status
-- , now()
-- , newid()
-- , _jsonb_object_cat( mtev.jsonvalue,p_parms->'_event_jsonvalue',jsonb_build_object('parent_rid_taskitem',_try_integer(p_parms ->> 'parent_rid_taskitem',0)))
-- from core.mastertaskitemevent mtev
-- where mtev.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
-- and coalesce(mtev.inactive,0) = 0
-- and mtev.status = 2
-- and not exists (
-- select 1
-- from core.taskitemevent tie
-- where tie.rid_taskitem = r_taskitem.rid_taskitem
-- and tie.rid_mastertaskitemevent = mtev.rid_mastertaskitemevent
-- )
-- ;
-- end if;
--
-- end if;
--
-- --complete rule
-- if r_taskitemevent_parent.rid_taskitemevent > 0
-- and r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_complete' is not null
-- and _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_complete',-1) > 0
-- and not exists (
-- select 1
-- from core.taskitem ti
-- where ti.rid_mastertaskitem = _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_complete',-1)
-- and ti.rid_hub = m_rid_hub_payload
-- )
-- then
-- if _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_complete',-1) <> r_taskitem.rid_mastertaskitem
-- and _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_complete',-1) <> nv(r_mastertaskitem.rid_parentmastertaskitem)
-- then
-- insert into core.taskitemevent(rid_taskitem,rid_mastertaskitemevent,description,status,createddatetime,guid,jsonvalue)
-- select r_taskitem.rid_taskitem
-- , mtev.rid_mastertaskitemevent
-- , mtev.description
-- , mtev.status
-- , now()
-- , newid()
-- , _jsonb_object_cat(mtev.jsonvalue,p_parms->'_event_jsonvalue', jsonb_build_object('rid_mastertaskitem_complete',r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_complete', 'simulated', true, 'parent_rid_taskitem',_try_integer(p_parms ->> 'parent_rid_taskitem',0)))
-- from core.mastertaskitemevent mtev
-- where mtev.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
-- and coalesce(mtev.inactive,0) = 0
-- and mtev.status = 4
-- and not exists (
-- select 1
-- from core.taskitemevent tie
-- where tie.rid_taskitem = r_taskitem.rid_taskitem
-- and tie.rid_mastertaskitemevent = mtev.rid_mastertaskitemevent
-- )
-- ;
-- elseif _try_integer(r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_complete',-1) = r_taskitem.rid_mastertaskitem
-- then
-- --We reached the target taskitem, so we can mark it activate
-- insert into core.taskitemevent(rid_taskitem,rid_mastertaskitemevent,description,status,createddatetime,guid,jsonvalue)
-- select r_taskitem.rid_taskitem
-- , mtev.rid_mastertaskitemevent
-- , mtev.description
-- , mtev.status
-- , now()
-- , newid()
-- , _jsonb_object_cat(mtev.jsonvalue,p_parms->'_event_jsonvalue',jsonb_build_object('parent_rid_taskitem',_try_integer(p_parms ->> 'parent_rid_taskitem',0)))
-- from core.mastertaskitemevent mtev
-- where mtev.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
-- and coalesce(mtev.inactive,0) = 0
-- and mtev.status = 2
-- and not exists (
-- select 1
-- from core.taskitemevent tie
-- where tie.rid_taskitem = r_taskitem.rid_taskitem
-- and tie.rid_mastertaskitemevent = mtev.rid_mastertaskitemevent
-- )
-- ;
-- end if;
-- end if;
p_parms := _jsonb_object_cat(p_parms, to_jsonb(r_taskitemevent));
end loop;
-- perform log_event(m_funcname
-- ,format('Post event insert: rid_taskitemevent=%s rid_mastertaskitem_complete=%s rid_mastertaskitem=%s'
-- ,r_taskitemevent_parent.rid_taskitemevent
-- ,r_taskitemevent_parent.jsonvalue->>'rid_mastertaskitem_complete'
-- ,r_taskitem.rid_mastertaskitem
-- ),bt_enum('eventlog','local notice'));
elsif p_table::citext in ('taskitem', 'wfl')
then
select ti.*
from core.taskitem ti
where ti.rid_taskitem = p_rid
into r_taskitem;
select ti.*
from core.mastertaskitem ti
where ti.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
into r_mastertaskitem;
select tl.*
from core.tasklist tl
where tl.rid_tasklist = r_taskitem.rid_tasklist
into r_tasklist;
--Create item linked to task for this hub if not found
perform log_event(m_funcname, format(E'Action Init via %s=%s Item:%s(%s rid_taskitem:%s)
p_parms:%s', p_table, p_rid
, r_mastertaskitem.description, r_mastertaskitem.rid_mastertaskitem, r_taskitem.rid_taskitem
, p_parms), bt_enum('eventlog', 'local notice'));
select r.p_retval, r.p_errmsg, r.p_a_rid_taskitem
from core.taskitem_get_or_prime(_jsonb_object_cat(
jsonb_build_object('rid_tasklist', r_tasklist.rid_tasklist
, 'rid_mastertaskitemevent',
r_mastertaskitemevent.rid_mastertaskitemevent
, 'rid_taskitem_parentaction', p_parms -> 'rid_taskitem_parentaction'
), to_jsonb(r_taskitem), p_parms)) r
into m_retval,m_errmsg,m_temp_rid_list;
if m_retval > 0
then
raise exception '%',m_errmsg using hint = 'in taskitem_get_or_prime 3';
end if;
if array_length(m_temp_rid_list, 1) > 1
then
select array_agg(to_jsonb(ti))
from core.taskitem ti
where ti.rid_taskitem = any (m_temp_rid_list)
into p_parms;
else
m_temp_rid = m_temp_rid_list[1];
select *
from core.taskitem ti
where ti.rid_taskitem = m_temp_rid
into r_taskitem;
p_parms = to_jsonb(r_taskitem);
end if;
elsif p_table::citext in ('mastertaskitem', 'mal')
then
select ti.*
from core.mastertaskitem ti
where ti.rid_mastertaskitem = p_rid
into r_mastertaskitem;
if r_mastertaskitem.inactive > 0
then
raise exception 'The template task item is inactive. rid_mastertaskitem = %',r_mastertaskitem.rid_mastertaskitem;
end if;
perform log_event(m_funcname, format(E'Action Init via %s=%s Item:%s(%s)
p_parms:%s', p_table, p_rid
, r_mastertaskitem.description, r_mastertaskitem.rid_mastertaskitem
, p_parms), bt_enum('eventlog', 'local notice'));
select r.p_retval, r.p_errmsg, r.p_a_rid_taskitem
from core.taskitem_get_or_prime(_jsonb_object_cat(to_jsonb(r_mastertaskitem)
, p_parms
, jsonb_build_object('rid_mastertaskitemevent', r_mastertaskitemevent.rid_mastertaskitemevent
, 'rid_taskitem_parentaction',
p_parms -> 'rid_taskitem_parentaction'
))
) r
into m_retval,m_errmsg,m_temp_rid_list;
if m_retval > 0
then
raise exception '%',m_errmsg using hint = 'in taskitem_get_or_prime 4';
end if;
if array_length(m_temp_rid_list, 1) > 1
then
select array_agg(to_jsonb(ti))
from core.taskitem ti
where ti.rid_taskitem = any (m_temp_rid_list)
into p_parms;
else
m_temp_rid = m_temp_rid_list[1];
select *
from core.taskitem ti
where ti.rid_taskitem = m_temp_rid
into r_taskitem;
p_parms = to_jsonb(r_taskitem);
end if;
end if;
if nv(r_taskitem.rid_mastertaskitem) = 0
then
raise warning 'No template for the task item. rid_mastertaskitem = %',r_taskitem.rid_mastertaskitem;
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 := format('%s table:%s rid:%s',
get_err_msg(m_funcname, m_errmsg, m_errcontext, m_errdetail, m_errhint, m_errstate), p_table,
p_rid);
p_retval = 1;
END;
$$;
+988
View File
@@ -0,0 +1,988 @@
--select * from dropall('event_exec_func','core');
CREATE OR REPLACE FUNCTION core.event_exec_func(
p_event_parameters jsonb
, p_item_parameters jsonb
, OUT p_retval integer
, OUT p_errorcode text
, OUT p_errmsg text
, OUT p_output jsonb
)
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER
AS
$$
DECLARE
--Error Handling--
m_funcname text = 'core.event_exec_func';
m_errmsg text;
m_errcode text;
m_errcontext text;
m_errdetail text;
m_errhint text;
m_errstate text;
m_retval integer;
--Error Handling--
r_taskitem core.taskitem%rowtype;
r_taskitem_parent core.taskitem%rowtype;
r_mastertaskitem core.mastertaskitem%rowtype;
r_taskitemevent core.taskitemevent%rowtype;
m_rid integer;
j_output jsonb;
j_all_parms jsonb;
j_controls jsonb;
j_tmp jsonb;
m_exec_funcname citext;
m_exec_schema citext;
m_sql citext;
r_lp record;
r_hub core.hub%rowtype;
a_async_procs citext[];
a_broker_procs citext[];
a_noerror_procs citext[];
BEGIN
j_controls = jsonb_build_array();
p_retval = 0;
p_errmsg = '';
m_exec_schema = 'public';
a_async_procs = array ['']; --hsync_upload_clienttohyphen
a_broker_procs = array ['']; --hsync_upload_clienttohyphen
a_noerror_procs = array ['ui_clientscenario_commit'];
-- if p_event_parameters -> 'AFN_RID_ACTIONFUNCTION_CODE' is null
-- then
-- p_retval = 1;
-- p_errmsg = '[ECR0001] No AFN_RID_ACTIONFUNCTION_CODE inside the AOP pack.';
--
-- select m.v[1]
-- from regexp_matches(p_errmsg, '\[([^\]]+)\]','ig') m(v)
-- where length(m.v[1]) between 2 and 10
-- order by m.v[1]
-- into p_errorcode;
--
-- return;
-- end if;
select *
from core.taskitemevent tie
where tie.rid_taskitemevent = _try_integer(p_item_parameters ->> 'rid_taskitemevent', 0)
into r_taskitemevent;
select *
from core.taskitem ti
where ti.rid_taskitem = _try_integer(p_item_parameters ->> 'rid_taskitem', r_taskitemevent.rid_taskitem)
into r_taskitem;
select *
from core.mastertaskitem mti
where
mti.rid_mastertaskitem = _try_integer(p_item_parameters ->> 'rid_mastertaskitem', 0)
or r_taskitem.rid_mastertaskitem > 0 and mti.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
order by mti.rid_mastertaskitem desc
limit 1
into r_mastertaskitem;
m_exec_funcname = r_mastertaskitem.functionname_code;
if m_exec_funcname ilike '%.%'
then
m_exec_schema = split_part(m_exec_funcname, '.', 1);
m_exec_funcname = split_part(m_exec_funcname, '.', 2);
end if;
if nv(m_exec_funcname) = ''
then
perform log_event(m_funcname, format(
E'No function to execute for MasterTaskItem %s (rid_mastertaskitem=%s) and TaskItem %s (rid_taskitem=%s)',
r_mastertaskitem.description, r_mastertaskitem.rid_mastertaskitem, r_taskitem.description,
r_taskitem.rid_taskitem), bt_enum('eventlog', 'local notice'));
return;
end if;
-- if m_exec_funcname ilike '%payment%plan%'
-- then
-- raise exception 'Payment Plan Test Error % [RTY0001]', m_exec_funcname;
-- end if;
j_all_parms = _jsonb_object_cat(r_taskitem.jsonvalue,
j_all_parms
, (
select jsonb_object_agg(k, p_event_parameters -> k)
from jsonb_object_keys(p_event_parameters) keys(k)
where jsonb_typeof(p_event_parameters -> keys.k) not in ('object', 'array', 'null')
), (
select jsonb_object_agg(k, p_item_parameters -> k)
from jsonb_object_keys(p_item_parameters) keys(k)
where jsonb_typeof(p_item_parameters -> keys.k) not in ('object', 'array', 'null')
)
);
if r_taskitem.rid_taskitem > 0 and r_taskitem.rid_taskitem_parentaction > 0
then
select ti.*
from core.taskitem ti
where ti.rid_taskitem = r_taskitem.rid_taskitem_parentaction
into r_taskitem_parent;
end if;
select af.jsonvalue -> 'controls'
from core.actionfunction af
where
(
af.functionname = m_exec_funcname
or af.functionname = r_mastertaskitem.functionname_code
)
and af.fntype = 'pgsql-function'::citext
into j_controls;
if jsonb_typeof(p_item_parameters -> 'AOP' -> 'jsonvalue' -> 'controls_code') = 'array'
then
j_controls = _jsonb_object_cat(j_controls, p_item_parameters -> 'AOP' -> 'jsonvalue' -> 'controls');
end if;
if jsonb_typeof(p_event_parameters -> 'jsonvalue' -> 'controls_code') = 'array'
then
j_controls = _jsonb_object_cat(j_controls, p_event_parameters -> 'jsonvalue' -> 'controls');
end if;
if jsonb_typeof(j_all_parms -> 'controls_code') = 'array'
then
j_controls = _jsonb_object_cat(j_controls, j_all_parms -> 'controls_code');
end if;
if jsonb_typeof(r_mastertaskitem.jsonvalue -> 'controls_code') = 'array'
then
j_controls = _jsonb_object_cat(j_controls, r_mastertaskitem.jsonvalue -> 'controls_code');
end if;
raise notice 'Controls: %', j_controls::text;
j_all_parms = _jsonb_object_cat(j_all_parms,
(
select jsonb_object_agg(ctr.j ->> 'name'
, case
when nv(j_all_parms ->> (ctr.j ->> 'name')) = ''
then ctr.j -> 'props' ->> 'value'
else j_all_parms ->> (ctr.j ->> 'name')
end
order by i
)
from jsonb_array_elements(j_controls) with ordinality ctr(j, i)
where nv(ctr.j -> 'props' ->> 'value') <> ''
)
);
if nv(j_all_parms ->> 'rid_hub_user') = ''
then
j_all_parms = _jsonb_object_cat(j_all_parms, jsonb_build_object(
'rid_hub_user', _bv(core.f_get_user_hub_rid(), (
select hub.rid_hub
from core.hub
where hub.hubtype = 'program'
limit 1
)),
'login', f_getuser()
));
end if;
if nv(j_all_parms ->> 'login') = ''
then
j_all_parms = _jsonb_object_cat(j_all_parms, jsonb_build_object(
'login', f_getuser()
));
end if;
if _try_integer(j_all_parms ->> 'rid_hub', 0) > 0
then
select *
from core.hub
where rid_hub = _try_integer(j_all_parms ->> 'rid_hub', 0)
into r_hub;
if r_hub.hubtype in ('client', 'lead')
then
j_all_parms = _jsonb_object_cat(j_all_parms, (
select jsonb_build_object(
'rid_adproclient', cli.rid_adproclient
, 'rid_client', cli.rid_adproclient
)
from t_adproclient cli
where cli.rid_hub = r_hub.rid_hub
limit 1
));
elsif r_hub.hubtype in ('trader')
then
j_all_parms = _jsonb_object_cat(j_all_parms, (
select jsonb_build_object(
'rid_trader', tdr.rid_trader
, 'rid_adprocreditor', tdr.rid_trader
)
from t_trader tdr
where tdr.rid_hub = r_hub.rid_hub
limit 1
));
end if;
end if;
j_all_parms = _jsonb_object_cat(j_all_parms, jsonb_build_object('AOP', null));
-- perform log_event(m_funcname, format('p_event_parameters=%s p_item_parameters=%s j_all_parms=%s', p_event_parameters::text,
-- p_item_parameters::text, j_all_parms::text), bt_enum('eventlog', 'local notice'));
if (
select 1
from core.actionfunction af
where
(
af.functionname = m_exec_funcname
or af.functionname = r_mastertaskitem.functionname_code
)
and af.fntype = 'pgsql-function'::citext
)
then
if m_exec_funcname = 'event_nextaction'
then
select r.p_retval, r.p_errmsg, r.p_data
from core.event_nextaction(j_all_parms) r
into m_retval,m_errmsg, j_output;
if m_retval > 0
then
p_retval = m_retval;
p_errmsg = m_errmsg;
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
return;
end if;
elsif m_exec_funcname in ('event_pre_error_check')
then
select r.p_retval, r.p_errmsg, r.p_errorcode, r.p_parms
from core.event_pre_error_check(_jsonb_object_cat(j_all_parms, jsonb_build_object(
'rid_taskitem', r_taskitem.rid_taskitem
, 'rid_tasklist', r_taskitem.rid_tasklist
, 'rid_mastertaskitem', r_mastertaskitem.rid_mastertaskitem
))) r
into p_retval,p_errmsg, p_errorcode, p_output;
return;
elsif m_exec_funcname = 'ui_maint_commitem'
then
select r.p_retval, r.p_errmsg, r.p_options
from core.ui_maint_commitem(_jsonb_object_cat(
r_taskitem.jsonvalue
, r_taskitemevent.jsonvalue
, jsonb_build_object(
'rid_taskitem', r_taskitem.rid_taskitem,
'rid_mastertaskitem', r_taskitem.rid_mastertaskitem,
'rid_hub_user', j_all_parms ->> 'rid_hub_user',
'rid_hub_payload', _try_integers(j_all_parms ->> 'rid_hub', j_all_parms ->> 'rid_hub_payload')
)
)) r
into m_retval,m_errmsg, j_output;
if m_retval > 0
then
p_retval = m_retval;
p_errmsg = m_errmsg;
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
return;
end if;
elsif m_exec_funcname = 'ui_modify_paymentplan'
then
select r.p_retval, r.p_errmsg, r.p_data
from ui_modify_paymentplan(j_all_parms) r
into m_retval,m_errmsg, j_output;
if m_retval > 0
then
p_retval = m_retval;
p_errmsg = m_errmsg;
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
return;
end if;
elsif m_exec_funcname in ('f_checkforreapprove_client')
then
select f_checkforreapprove_client(cli.rid_adproclient)
from t_adproclient cli
where cli.rid_hub = r_taskitem.rid_hub
into m_retval;
if m_retval > 0
then
p_retval = 1;
p_errmsg = 'Re-approval is required for this client and it needs to be uploaded to the PDA.';
p_errorcode = 'CLIAPRV1';
p_output = jsonb_build_object('message', p_errmsg, 'actionstatus', 'error',
'errorcode', p_errorcode
, 'alerttype', 'error'
, 'in_modal', true
);
return;
else
p_retval = 0;
p_errmsg = '';
p_errorcode = '';
p_output = jsonb_build_object('actionstatus', 'success', 'delete_after', true);
return;
end if;
elsif m_exec_funcname in ('c_checkforreapprove')
then
select c_checkforreapprove(cli.rid_adproclient)
from t_adproclient cli
where cli.rid_hub = r_taskitem.rid_hub
into m_retval;
if m_retval > 0
then
p_retval = 1;
p_errmsg = 'Re-approval is required for this client`s payment plan and it needs to be uploaded to the PDA.';
p_errorcode = 'CLIAPRV2';
p_output = jsonb_build_object('message', p_errmsg, 'actionstatus', 'error', 'errorcode'
, p_errorcode
, 'alerttype', 'error'
, 'in_modal', true);
return;
else
p_retval = 0;
p_errmsg = '';
p_errorcode = '';
p_output = jsonb_build_object('actionstatus', 'success', 'delete_after', true);
return;
end if;
elsif m_exec_funcname in ('action_init_hublinks')
then
select r.p_retval, r.p_errmsg
from core.action_init_hublinks(_jsonb_object_cat(p_item_parameters, jsonb_build_object(
'rid_taskitem', r_taskitem.rid_taskitem,
'rid_mastertaskitem', r_taskitem.rid_mastertaskitem,
'rid_hub_user', j_all_parms ->> 'rid_hub_user',
'event_status', r_taskitemevent.status,
'parent_rid_taskitemevent', r_taskitemevent.rid_taskitemevent,
'calling_function', m_funcname
))) r
into m_retval, m_errmsg;
if m_retval > 0
then
p_retval = m_retval;
p_errmsg = m_errmsg;
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
return;
end if;
elsif m_exec_funcname in ('ui_clientscenario_commit')
then
if coalesce(_try_integers(j_all_parms ->> 'rid_clientscenario', j_all_parms ->> 'p_rid_clientscenario'), 0) = 0
then
select s.rid_clientscenario
from clientscenario s
inner join t_adproclient c on c.rid_adproclient = s.rid_adproclient
and c.rid_hub = r_taskitem.rid_hub
where s.status in (1, 2)
order by s.status, s.rid_clientscenario desc
limit 1
into m_rid;
if nv(m_rid) = 0 and r_taskitem.rid_hub_link > 0
then
--Falback to link parent client when tasklink is for client.
select s.rid_clientscenario
from core.hub_link h
inner join t_adproclient c on c.rid_hub = h.rid_hub_parent
and h.parent_hubtype = 'client'
inner join clientscenario s on s.rid_adproclient = c.rid_adproclient
and s.status in (1, 2)
where h.rid_hub_link = r_taskitem.rid_hub_link
order by s.status, s.rid_clientscenario desc
limit 1
into m_rid;
end if;
j_all_parms = _jsonb_object_cat(j_all_parms, jsonb_build_object(
'rid_clientscenario', m_rid
));
end if;
select r.p_retval, r.p_errmsg
from public.ui_clientscenario_commit(_try_integers(j_all_parms ->> 'rid_clientscenario'
, j_all_parms ->> 'p_rid_clientscenario', null)
, null
, _jsonb_object_cat(p_item_parameters, jsonb_build_object(
'rid_taskitem', r_taskitem.rid_taskitem,
'rid_hub', r_taskitem.rid_hub,
'rid_mastertaskitem', r_taskitem.rid_mastertaskitem,
'rid_hub_user', j_all_parms ->> 'rid_hub_user',
'event_status', r_taskitemevent.status,
'parent_rid_taskitemevent', r_taskitemevent.rid_taskitemevent,
'calling_function', m_funcname
))) r
into m_retval, m_errmsg;
if p_event_parameters ->> 'no_error' in ('1', 'true') or p_item_parameters ->> 'no_error' in ('1', 'true')
then
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
return;
end if;
if m_retval > 0
then
p_retval = m_retval;
p_errmsg = m_errmsg;
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
return;
end if;
elsif exists (
select ns.nspname, pro.*
from pg_proc pro
inner join pg_namespace ns on ns.oid = pro.pronamespace
and ns.nspname not in ('postgres', 'pg_catalog')
and ns.nspname = m_exec_schema
where pro.proname = m_exec_funcname
)
and m_exec_funcname in
('clfrm_builderrorlist', 'upl_builduploaderrors', 'clfrm_propcalc_errlist', 'ui_clientscenarion_errorlist')
then
raise notice 'Running %',m_exec_funcname;
p_retval = 0;
p_errmsg = '';
m_errdetail = '';
SET session_replication_role = DEFAULT;
if not exists (
select cli.rid_adproclient
from t_adproclient cli
where cli.rid_hub = r_taskitem.rid_hub
)
then
p_retval = 1;
p_errmsg = 'No client found for the hub.';
return;
end if;
with
cli as (
select cli.rid_adproclient
from t_adproclient cli
where cli.rid_hub = r_taskitem.rid_hub
)
, src as (
select o.errordescription as errordescription
, o.errorcode as errorcode
, o.error_priority as priority
, o.errortype
from clfrm_builderrorlist((
select cli.rid_adproclient
from cli
limit 1
), 0, 0) o
where m_exec_funcname in ('clfrm_builderrorlist')
union
select o.errordescription as errordescription
, o.errorcode as errorcode
, o.error_priority as priority
, o.errortype
from clfrm_builderrorlist((
select cli.rid_adproclient
from cli
limit 1
), 0, 1) o
where m_exec_funcname in ('clfrm_builderrorlist')
union
select o.errordescription as errordescription, o.errorcode as errorcode, 1 as priority, 'error' as errortype
from clfrm_propcalc_errlist((
select cli.rid_adproclient
from cli
limit 1
)) o
where m_exec_funcname = 'm_exec_funcname'
union
select o.errdescription as errordescription
, o.errorcode as errorcode
, o.severity as priority
, o.errtype as errortype
from upl_builduploaderrors((
select cli.rid_adproclient
from cli
limit 1
)
, _bv(j_all_parms ->> 'p_approve_mode', j_all_parms ->> 'approve_mode', j_all_parms ->> 'mode', 'normal')) o
where
m_exec_funcname in ('upl_builduploaderrors')
union
select elm.j ->> 'errordescription' as errordescription
, elm.j ->> 'errorcode' as errorcode
, _try_integer(elm.j ->> 'priority', 1) as priority
, _bv(elm.j ->> 'errortype', 'error') as errortype
from ui_clientscenarion_errorlist((
select sc.rid_clientscenario
from cli
inner join clientscenario sc on sc.rid_adproclient = cli.rid_adproclient
and sc.status = 1
order by sc.rid_clientscenario desc
limit 1
)) o
cross join jsonb_array_elements(case
when jsonb_typeof(o.p_data) = 'object' then jsonb_build_array(o.p_data)
when jsonb_typeof(o.p_data) = 'array' then o.p_data
else jsonb_build_array()
end) elm(j)
where m_exec_funcname in ('ui_clientscenarion_errorlist')
order by priority
)
, composed as (
select
-- src.errorcode
-- , src.errordescription
-- , src.priority as errorpriority
-- , src.errortype
row_number() over (partition by src.errorcode, src.errordescription order by src.priority desc) as rn
, evt2.rid_mastertaskitemevent as rid_mastertaskitemevent
, mti.rid_mastertaskitem as rid_mastertaskitem
, ti.rid_taskitem as rid_taskitem
, core.f_get_user_hub_rid() as rid_hub_user
, jsonb_build_object(
'errordescription', src.errordescription
, 'errorcode', src.errorcode
, 'errortype', src.errortype
, 'errorpriority', src.priority
) as jsonvalue
from src
--Get the main linked event
left outer join core.mastertaskitemevent ev1 on ev1.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
and nv(ev1.errorcode) <> ''
and (
ev1.errorcode = src.errorcode
or ev1.errorcode in (
select m.interface_code
from core.error_code_map(jsonb_build_object('filter_code', src.errorcode)) m
where nv(m.interface_code) <> ''
)
)
left outer join core.mastertaskitemeventreaction er1
on er1.rid_mastertaskitemevent = ev1.rid_mastertaskitemevent
-- Fallback to the global event list
left outer join core.mastertaskitemevent evt2 on (
evt2.rid_mastertaskitemevent =
er1.rid_mastertaskitemevent_target
or evt2.status in (2)
and nv(evt2.errorcode) <> ''
and (
evt2.errorcode = src.errorcode
or evt2.errorcode in (
select m.interface_code
from core.error_code_map(jsonb_build_object('filter_code', src.errorcode)) m
where nv(m.interface_code) <> ''
)
)
)
and nv(evt2.inactive) = 0
left outer join core.mastertaskitem mti on mti.rid_mastertaskitem = evt2.rid_mastertaskitem
left outer join core.taskitem ti on ti.rid_mastertaskitem = mti.rid_mastertaskitem
and ti.rid_hub = r_taskitem.rid_hub
and nv(ti.rid_hub_link) = nv(r_taskitem.rid_hub_link)
and ti.status in (1, 2, 3)
)
select jsonb_agg(_jsonb_object_cat(to_jsonb(mti), to_jsonb(ti), jsonb_strip_nulls(to_jsonb(c))))
filter ( where c.rn = 1 )
, count(1)
from composed c
left outer join core.mastertaskitem mti on mti.rid_mastertaskitem = c.rid_mastertaskitem
left outer join core.taskitem ti on ti.rid_taskitem = c.rid_taskitem
into j_tmp, m_retval;
if m_retval > 0
then
-- update core.taskitem u
-- set jsonvalue = jsonb_build_object('errorlist', j_tmp)
-- where u.rid_taskitem = r_taskitem.rid_taskitem;
p_output =
jsonb_build_object('actionstatus', 'error', 'errorcode', 'ERRLIST', 'errorlist', j_tmp, 'delete_caller',
true);
else
p_output =
jsonb_build_object('message', null, 'actionstatus', 'success', 'errorcode', null, 'delete_after', true,
'errorlist', null);
end if;
perform log_event(m_funcname, format(E'List Function (%s) Results Code=%s Error=%s.
MasterTaskItem:%s \n p_output:%s \nm_errdetail:%s'
, m_exec_funcname
, p_errorcode
, p_errmsg
, r_mastertaskitem.description
, p_output
, m_errdetail
)
, bt_enum('eventlog', 'local error'));
return;
--m_retval =0;
elsif exists (
select ns.nspname, pro.*
from pg_proc pro
inner join pg_namespace ns on ns.oid = pro.pronamespace
and ns.nspname not in ('postgres', 'pg_catalog')
and ns.nspname = m_exec_schema
where pro.proname = m_exec_funcname
)
then
with
args as (
select ns.nspname
, pro.proname
, t.typname
, i.i
, pro.proargnames[i - 1] as argname
, pro.proargmodes[i - 1] as mode
, CASE
WHEN pro.proargdefaults IS NOT NULL
AND i.i - 1 > (pro.pronargs - pro.pronargdefaults)
THEN split_part(
pg_get_expr(pro.proargdefaults, 0),
',',
i.i - 1 - (pro.pronargs - pro.pronargdefaults)
)::text
ELSE NULL::text
END as default_value
, row_number() over (order by i.i) as number
--,pro.*
from pg_proc pro
inner join pg_namespace ns on ns.oid = pro.pronamespace
and ns.nspname not in ('postgres', 'pg_catalog')
JOIN LATERAL generate_subscripts(pro.proallargtypes, 1) AS i ON true
JOIN pg_type t ON t.oid = pro.proallargtypes[i - 1]
where
pro.proname = m_exec_funcname
and ns.nspname = m_exec_schema
--pro.proname in ('hsync_upload_clienttohyphen')
AND (pro.proargmodes IS NULL OR pro.proargmodes[i - 1] in ('i', 'b'))
)
, argval as (
select args.*
--, _bv(j_all_parms ->> args.argname,args.default_value) as argvalue
, case
when nv(j_all_parms ->> args.argname) in ('', '0', 'null')
and args.argname ilike 'p_%' and
nv(j_all_parms ->> replace(args.argname, 'p_', '')) not in ('', '0', 'null')
then j_all_parms ->> replace(args.argname, 'p_', '')
when nv(j_all_parms ->> args.argname) <> '' then j_all_parms ->> args.argname
when args.argname ilike 'p_%' and nv(j_all_parms ->> replace(args.argname, 'p_', '')) <> ''
then j_all_parms ->> replace(args.argname, 'p_', '')
when args.argname ilike 'p_login%' and j_all_parms ->> 'login' is not null
then j_all_parms ->> 'login'
when args.argname ilike 'p_user%' and j_all_parms ->> 'login' is not null
then j_all_parms ->> 'login'
when nv(args.default_value) <> '' then args.default_value
else null::text
end as argvalue
from args
)
select (
select string_agg(format('%s::%s', case
when a.typname ilike '%int%' then a.argvalue
else quote_literal(a.argvalue)
end, a.typname), ', ') as args
from argval a
)
, (
select count(1)
from argval a
where a.argvalue is null
)
, (
select format('[ECR0002] Cannot execute function %s. The following arguments are missing: %s (Defaults: %s) ',
m_exec_funcname, string_agg(a.argname, ','), string_agg(a.default_value, ','))
from argval a
where a.argvalue is null
)
into m_sql,m_retval, m_errmsg, m_errcode;
raise notice 'Function % executed with arguments: %', m_exec_funcname, m_sql;
if m_retval > 0
then
p_retval = 1;
p_errmsg = m_errmsg;
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
perform log_event(m_funcname, format(
E'Error on Function (%s) Code=%s Error=%s. MasterTaskItem:%s \n SQL:%s \n j_all_parms:%s \n Controls: %s'
, m_exec_funcname
, p_errorcode
, p_errmsg
, r_mastertaskitem.description
, m_sql
, substr((j_all_parms || jsonb_build_object('AOP', null))::text, 1, 100000)
, j_controls::text
)
, bt_enum('eventlog', 'local error'));
return;
end if;
m_sql = format('select to_jsonb(r) as val from %s.%s(%s) r ;', m_exec_schema, m_exec_funcname, m_sql);
perform log_event(m_funcname, format('Executing Function (%s) with SQL: %s', m_exec_funcname, m_sql),
bt_enum('eventlog', 'local notice'));
if m_exec_funcname::citext = any (a_broker_procs)
then
select r.p_retval, r.p_errmsg
from agent_job_add(format('exec %s', m_sql)
, get_agent_freequeue(3)
, format('%s(taskitemevent=%s)', m_exec_funcname, r_taskitemevent.rid_taskitemevent)
, 15
) r
into m_retval,m_errmsg;
elsif m_exec_funcname::citext = any (a_async_procs)
then
select _bv(j.val -> 'val', j.val)
from autoexec_query(m_sql, jsonb_build_object()) j(val)
into j_output;
else
execute m_sql into j_output;
end if;
p_output = j_output;
if p_event_parameters ->> 'no_error' in ('1', 'true') or p_item_parameters ->> 'no_error' in ('1', 'true')
then
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
perform log_event(m_funcname, format('Error(!) on Function (%s) Code=%s Error=%s. MasterTaskItem:%s Response:%s'
, m_exec_funcname
, p_errorcode
, p_errmsg
, r_mastertaskitem.description
, substr(j_output::text, 1, 10000)
)
, bt_enum('eventlog', 'local error'));
return;
end if;
if jsonb_typeof(j_output) in ('number', 'boolean', 'string')
then
perform log_event(m_funcname,
format('Function (%s) returned scalar value: %s', m_exec_funcname, j_output::text),
bt_enum('eventlog', 'debug'));
if j_all_parms -> 'p_output' is not null
and (j_all_parms ->> 'p_output')::citext is distinct from j_output::citext
then
p_retval = 1;
p_errmsg =
'Output value does not match expected value. Expected: ' || j_all_parms ->> 'p_output' || ' Actual: ' ||
j_output::text;
return;
end if;
end if;
if _try_integer(j_output ->> 'p_retval', 0) > 0
and m_exec_funcname::citext <> all (a_noerror_procs)
then
p_retval = _try_integer(j_output ->> 'p_retval', 0);
p_errmsg = format('%s', j_output ->> 'p_errmsg');
p_errorcode = _bv(j_output ->> 'p_error_code', j_output ->> 'p_errorcode', '');
perform log_event(m_funcname, format('Error on Function (%s) Code=%s Error=%s. MasterTaskItem:%s Response:%s'
, m_exec_funcname
, p_errorcode
, p_errmsg
, r_mastertaskitem.description
, substr(j_output::text, 1, 10000)
)
, bt_enum('eventlog', 'local error'));
if nv(p_errorcode) = ''
then
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
if nv(p_errorcode) = ''
then
p_errorcode = 'ECR0003';
end if;
end if;
return;
end if;
else
perform log_event(m_funcname, format('Function (%s) not defined for this event (%s).',
p_event_parameters -> 'AFN_RID_ACTIONFUNCTION_CODE' ->> 'functionname'
, r_mastertaskitem.description)
, bt_enum('eventlog', 'local error'));
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
return;
end if;
else
raise notice 'Type not supported: %', p_event_parameters -> 'AFN_RID_ACTIONFUNCTION_CODE' ->> 'fntype';
end if;
--select * from core.actionfunction
if nv(p_errorcode) = '' and nv(p_errmsg) <> ''
then
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
if nv(p_errorcode) = ''
then
p_errorcode = 'PRCERR';
end if;
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;
if nv(p_errorcode) = '' and nv(p_errmsg) <> ''
then
select m.v[1]
from regexp_matches(p_errmsg, '\[([^\]]+)\]', 'ig') m(v)
where length(m.v[1]) between 2 and 10
order by m.v[1]
into p_errorcode;
if nv(p_errorcode) = ''
then
p_errorcode = 'PRCERR';
end if;
end if;
END;
$$;
--Tests
/*
select (regexp_matches('[ECR0001] No AFN inside the AOP pack.', '\[([^\]]+)\]'))[1]
, (regexp_matches('[21] No AFN inside the AOP pack. [45345]', '\[([^\]]+)\]'))[1]
, (regexp_matches('A Test', '\[([^\]]+)\]'))[1]
*/
/*
select *
from core.taskitemevent e
where e.rid_taskitem = 26344
limit
;*/
/*
select *
from core.event_exec_func((
select _jsonb_object_cat( to_jsonb(e)
,jsonb_build_object('AFN_RID_ACTIONFUNCTION_CODE',to_jsonb(af)))
from core.taskitemevent e
inner join core.taskitem ti on ti.rid_taskitem = e.rid_taskitem
inner join core.actionoption ao on ao.guid = ti.jsonvalue -> 'AOP' ->> 'guid'
inner join core.actionfunction af on af.rid_actionfunction = ao.rid_actionfunction_code
where e.rid_taskitem = 26344
),(select _jsonb_object_cat( to_jsonb(e)
,jsonb_build_object('AFN_RID_ACTIONFUNCTION_CODE',to_jsonb(af)))
from core.taskitem e
inner join core.mastertaskitem mti
on mti.rid_mastertaskitem = e.rid_mastertaskitem
inner join core.actionoption ao on ao.guid = e.jsonvalue -> 'AOP' ->> 'guid'
inner join core.actionfunction af on af.rid_actionfunction = ao.rid_actionfunction_code
where e.rid_taskitem = 26344
))
selecT * from v_eventlog
select * from core.taskitem ti
order by ti.rid_taskitem desc
*/
File diff suppressed because it is too large Load Diff
+613
View File
@@ -0,0 +1,613 @@
--select * from dropall('ui_action_event','core');
CREATE OR REPLACE FUNCTION core.ui_action_event(
p_payload INOUT jsonb
, OUT p_retval integer
, OUT p_errmsg text
)
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER
AS
$$
DECLARE
--Error Handling--
m_funcname text = 'core.ui_action_event';
m_errmsg text;
m_errcontext text;
m_errdetail text;
m_errhint text;
m_errstate text;
m_retval integer;
--Error Handling--
m_rid_hub integer;
m_rid_hub_link integer;
m_rid_hub_user integer;
m_temp_rid integer;
m_final_rid_taskitem integer;
m_temp_rid_list integer[];
m_status integer;
m_outcome integer;
r_mastertaskitemevent core.mastertaskitemevent%rowtype;
r_taskitem core.taskitem%rowtype;
r_taskitemevent core.taskitemevent%rowtype;
r_mastertaskitem core.mastertaskitem%rowtype;
r_mastertask core.mastertask%rowtype;
r_lp record;
m_check_error boolean := false;
m_cancel boolean := false;
j_result jsonb;
j_obj jsonb;
m_tm timestamp ;
BEGIN
m_tm = clock_timestamp();
p_retval = 0;
p_errmsg = '';
j_result = jsonb_build_array();
m_rid_hub = _try_integer(p_payload ->> 'rid_hub', 0);
m_rid_hub_user = _try_integer(p_payload ->> 'rid_hub_user', core.f_get_user_hub_rid());
m_status = _try_integer(p_payload ->> 'status', 0);
m_outcome = _try_integer(p_payload ->> 'outcome', 0);
m_check_error = (p_payload ->> 'check_error')::citext in ('1', 'true');
m_cancel = (p_payload ->> 'cancel')::citext in ('1', 'true');
select *
from core.mastertaskitemevent mte
where mte.rid_mastertaskitemevent = _try_integer(p_payload ->> 'rid_mastertaskitemevent', 0)
into r_mastertaskitemevent;
select mti.*
from core.mastertaskitem mti
where
mti.rid_mastertaskitem = _try_integer(p_payload ->> 'rid_mastertaskitem', 0)
and _try_integer(p_payload ->> 'rid_mastertaskitem', 0) > 0
or mti.rid_mastertaskitem = r_mastertaskitemevent.rid_mastertaskitem
into r_mastertaskitem;
select mt.*
from core.mastertask mt
where
mt.rid_mastertask = _try_integer(p_payload ->> 'rid_mastertask', 0)
and _try_integer(p_payload ->> 'rid_mastertask', 0) > 0
or mt.rid_mastertask = r_mastertaskitem.rid_mastertask
into r_mastertask;
select *
from core.taskitem ti
where ti.rid_taskitem = _try_integer(p_payload ->> 'rid_taskitem', 0)
into r_taskitem;
if r_taskitem.rid_taskitem > 0
then
perform set_config('session.rid_taskitem', r_taskitem.rid_taskitem::text, false);
end if;
if nv(r_mastertaskitem.rid_mastertaskitem) = 0
then
if r_taskitem.rid_taskitem > 0
then
select *
from core.mastertaskitem mti
where mti.rid_mastertaskitem = r_taskitem.rid_mastertaskitem
into r_mastertaskitem;
elsif r_mastertask.rid_mastertask > 0
then
select *
from core.mastertaskitem mti
where
mti.rid_mastertask = r_mastertask.rid_mastertask
and coalesce(mti.inactive, 0) = 0
and coalesce(mti.rid_parentmastertaskitem, 0) = 0
order by mti.seqno nulls first, mti.rid_mastertaskitem
limit 1
into r_mastertaskitem;
end if;
end if;
if nv(r_mastertaskitemevent.rid_mastertaskitemevent) = 0
then
if r_mastertaskitem.rid_mastertaskitem > 0
then
select *
from core.mastertaskitemevent mte
where
mte.rid_mastertaskitem = r_mastertaskitem.rid_mastertaskitem
and coalesce(mte.inactive, 0) = 0
and mte.status in (1, 2)
order by mte.status desc, mte.seqno nulls first, mte.rid_mastertaskitemevent
limit 1
into r_mastertaskitemevent;
end if;
end if;
---Check error handler
if m_check_error
then
if nv(r_mastertaskitemevent.status) <> 13
then
select evt.*
from core.mastertaskitemevent evt
where
evt.rid_mastertaskitem = r_mastertaskitem.rid_mastertaskitem
and evt.status = 13
order by evt.seqno desc, evt.rid_mastertaskitemevent desc
into r_mastertaskitemevent;
end if;
--perform log_event(m_funcname,format('Init check error for rid_mastertaskitem:%s hub:%s ',r_mastertaskitemevent.rid_mastertaskitem,m_rid_hub),bt_enum('eventlog','local notice'));
if r_mastertaskitemevent.rid_mastertaskitemevent > 0
then
select r.p_retval, r.p_errmsg, r.p_parms
from core.action_init('mastertaskitemevent', r_mastertaskitemevent.rid_mastertaskitemevent
, _jsonb_object_cat(p_payload,
jsonb_build_object('check_error', true, 'rid_hub', m_rid_hub, 'createmode', 'existing'))
) r
into m_retval,m_errmsg, p_payload;
if m_retval > 0
then
p_retval = m_retval;
p_errmsg = m_errmsg;
return;
end if;
else
p_retval = 0;
p_errmsg = 'Error check enabled but no error event found for mastertaskitem.';
end if;
return;
end if;
---Check cancel handler
if m_cancel
then
if nv(r_mastertaskitemevent.status) <> 6
then
select evt.*
from core.mastertaskitemevent evt
where
evt.rid_mastertaskitem = r_mastertaskitem.rid_mastertaskitem
and evt.status = 6
order by evt.seqno desc, evt.rid_mastertaskitemevent desc
into r_mastertaskitemevent;
end if;
perform log_event(m_funcname,
format('Init cancel for rid_mastertaskitem:%s hub:%s ', r_mastertaskitemevent.rid_mastertaskitem,
m_rid_hub), bt_enum('eventlog', 'local notice'));
if r_mastertaskitemevent.rid_mastertaskitemevent > 0
then
select r.p_retval, r.p_errmsg, r.p_parms
from core.action_init('mastertaskitemevent', r_mastertaskitemevent.rid_mastertaskitemevent
, _jsonb_object_cat(p_payload,
jsonb_build_object('cancel', true, 'rid_hub', m_rid_hub, 'createmode', 'existing'))
) r
into m_retval,m_errmsg, p_payload;
if m_retval > 0
then
p_retval = m_retval;
p_errmsg = m_errmsg;
return;
end if;
else
p_retval = 0;
p_errmsg = 'cancel called but no cancel event found for mastertaskitem.';
end if;
return;
end if;
if nv(m_rid_hub) = 0 and r_taskitem.rid_taskitem > 0 and r_taskitem.rid_hub > 0
then
m_rid_hub = r_taskitem.rid_hub;
end if;
--perform log_event(m_funcname,format('Init %s(%s) m_rid_hub: %s p_payload: %s',r_taskitem.description,r_taskitem.rid_taskitem,m_rid_hub,p_payload::text),bt_enum('eventlog','local notice'));
if nv(m_rid_hub) = 0
then
p_errmsg := 'Missing required parameter: rid_hub';
p_retval = 1;
return;
end if;
if nv(r_mastertaskitemevent.rid_mastertaskitemevent) = 0
then
p_errmsg := 'Missing required parameter: rid_mastertaskitemevent';
p_retval = 1;
return;
end if;
perform log_event(m_funcname,
format(E'Init rid_mastertaskitemevent %s for rid_mastertaskitem:%s hub:%s \np_payload:%s',
r_mastertaskitemevent.rid_mastertaskitemevent, r_mastertaskitem.rid_mastertaskitem,
m_rid_hub, p_payload), bt_enum('eventlog', 'local notice'));
select r.p_retval, r.p_errmsg, r.p_a_rid_taskitem
from core.taskitem_get_or_prime(_jsonb_object_cat(p_payload, jsonb_build_object(
'parent_rid_taskitem', case
when r_taskitem.rid_taskitem = _try_integer(p_payload ->> 'parent_rid_taskitem', 0)
then r_taskitem.rid_taskitem_parentaction
else _try_integer(p_payload ->> 'parent_rid_taskitem',
r_taskitem.rid_taskitem_parentaction)
end
, 'rid_mastertaskitem', r_mastertaskitem.rid_mastertaskitem
, 'rid_mastertaskitemevent', r_mastertaskitemevent.rid_mastertaskitemevent
, 'status', r_mastertaskitemevent.status
, 'create_mode', p_payload ->> 'create_mode'
--,'create_mode','new'
))) r
into m_retval,m_errmsg,m_temp_rid_list;
if m_retval > 0
then
raise 'Error initializing taskitem %',m_errmsg;
end if;
for m_temp_rid in select * from unnest(m_temp_rid_list)
loop
select *
from core.taskitem ti
where ti.rid_taskitem = m_temp_rid
into r_taskitem;
if nv(r_taskitem.rid_taskitem) = 0
then
p_retval = 1;
p_errmsg = format('Task item not created or found. %s', m_temp_rid);
return;
end if;
select tie.*
from core.taskitemevent tie
where
tie.rid_mastertaskitemevent = r_mastertaskitemevent.rid_mastertaskitemevent
and tie.rid_taskitem = r_taskitem.rid_taskitem
into r_taskitemevent;
--raise exception 'Show me the error: % %',r_taskitemevent.rid_taskitemevent, r_taskitemevent.rid_taskitem;
--raise notice 'New/Update r_taskitemevent? %',r_taskitemevent;
-- if r_taskitem.rid_taskitem > 0
-- and p_payload->'jsonvalue'->>'errorcode' is not null
-- then
-- update core.taskitem u
-- set jsonvalue = _jsonb_object_cat(u.jsonvalue, jsonb_build_object(
-- 'errorcode', _bv(p_payload->'jsonvalue'->>'errorcode',u.jsonvalue->>'errorcode')
-- ,'errormessage',_bv( p_payload->'jsonvalue'->>'errormessage',u.jsonvalue->>'errormessage')
-- ,'alerttype', _bv(p_payload->'jsonvalue'->>'alerttype',u.jsonvalue->>'alerttype')
-- ))
-- where u.rid_taskitem = r_taskitem.rid_taskitem;
--
-- end if;
if r_taskitemevent.rid_taskitemevent > 0
then
update core.taskitemevent u
set
retval = 0
, errmsg = null
, jsonvalue = _jsonb_object_cat(u.jsonvalue, (
select mte.jsonvalue
from core.mastertaskitemevent mte
where mte.rid_mastertaskitemevent = u.rid_mastertaskitemevent
limit 1
), p_payload -> 'jsonvalue')
where u.rid_taskitemevent = r_taskitemevent.rid_taskitemevent;
select r.p_retval, r.p_errmsg
from core.event_created(r_taskitemevent.rid_taskitemevent
, jsonb_build_object('operation', 'UPDATE')) r
into m_retval,m_errmsg;
if m_retval > 0
then
raise '%',m_errmsg;
end if;
select e.*
from core.taskitemevent e
where e.rid_taskitemevent = r_taskitemevent.rid_taskitemevent
into r_taskitemevent;
if length(r_taskitemevent.errmsg) > 0
then
p_retval = 1;
p_errmsg = r_taskitemevent.errmsg;
end if;
else
insert
into core.taskitemevent( createddatetime, description, rid_mastertaskitemevent, rid_taskitem, status
--, duedatetime
--, escalated
, jsonvalue, outcome, rid_hub_user, retval)
select now()
, r_mastertaskitemevent.description
, r_mastertaskitemevent.rid_mastertaskitemevent
, r_taskitem.rid_taskitem
, _bv(r_mastertaskitemevent.status, m_status)
, _jsonb_object_cat((
select mte.jsonvalue
from core.mastertaskitemevent mte
where mte.rid_mastertaskitemevent = r_mastertaskitemevent.rid_mastertaskitemevent
limit 1
), p_payload -> 'jsonvalue')
, m_outcome
, m_rid_hub_user
, 0
where
not exists (
select 1
from core.taskitemevent tie
where
tie.rid_mastertaskitemevent = r_mastertaskitemevent.rid_mastertaskitemevent
and tie.rid_taskitem = r_taskitem.rid_taskitem
)
returning taskitemevent.*
into r_taskitemevent;
-- select r.p_retval,r.p_errmsg
-- from core.event_created(r_taskitemevent.rid_taskitemevent
-- ,jsonb_build_object('operation','UPDATE')) r
-- into m_retval,m_errmsg;
--
-- if m_retval > 0
-- then
-- raise '%',m_errmsg;
-- end if;
select e.*
from core.taskitemevent e
where e.rid_taskitemevent = r_taskitemevent.rid_taskitemevent
into r_taskitemevent;
if length(r_taskitemevent.errmsg) > 1
then
p_retval = 1;
p_errmsg = r_taskitemevent.errmsg;
end if;
end if;
with
recursive
tasklist_events as (
select ti.rid_taskitem
, 1 as level
, coalesce(ti.seqno, 0) as seqno
, ti.status
from core.taskitem ti
inner join core.mastertaskitem mti on mti.rid_mastertaskitem = ti.rid_mastertaskitem
where
(ti.rid_tasklist = r_taskitem.rid_tasklist
or ti.rid_tasklist in (
select tl.rid_tasklist_parent
from core.tasklistlink tl
where tl.rid_tasklist_child = r_taskitem.rid_tasklist
union
select tl.rid_tasklist_child
from core.tasklistlink tl
where tl.rid_tasklist_parent = r_taskitem.rid_tasklist
)
)
-- and mti.rid_mastertaskitem in (
-- select ev2.rid_mastertaskitem
-- from core.mastertaskitemeventreaction er
-- inner join core.mastertaskitemevent ev2 on ev2.rid_mastertaskitemevent = er.rid_mastertaskitemevent_target
-- where er.rid_mastertaskitemevent = r_taskitemevent.rid_mastertaskitemevent
-- )
union
select ti.rid_taskitem
, te.level + 1
, coalesce(ti.seqno, 0) as seqno
, ti.status
from core.taskitem ti
inner join tasklist_events te on te.rid_taskitem = ti.rid_parenttaskitem
)
select e.rid_taskitem
from tasklist_events e
where nv(e.status) in (2, 10)
order by e.level desc, e.status desc, e.seqno desc
limit 1
into m_final_rid_taskitem;
j_obj = _jsonb_object_cat(to_jsonb(r_taskitemevent), jsonb_build_object(
'jsonvalue', _jsonb_object_cat(r_taskitemevent.jsonvalue, jsonb_build_object(
'rid_tasklist', r_taskitem.rid_tasklist,
'rid_tasklist_event', (
select jsonb_agg(distinct ti.rid_tasklist)
from core.mastertaskitemeventreaction er
inner join core.mastertaskitemevent mtiet
on mtiet.rid_mastertaskitemevent = er.rid_mastertaskitemevent_target
inner join core.taskitem ti on ti.rid_mastertaskitem = mtiet.rid_mastertaskitem
and ti.rid_hub = r_taskitem.rid_hub
and ti.rid_tasklist = r_taskitem.rid_tasklist
where er.rid_mastertaskitemevent = r_taskitemevent.rid_mastertaskitemevent
)
, 'rid_tasklist_next', (
select jsonb_agg(distinct tl.rid_tasklist)
from core.taskitem ti
inner join core.mastertaskitem mti on mti.rid_mastertaskitem = ti.rid_mastertaskitem
inner join core.mastertask mtj on mti.guid = mti.jsonvalue ->> 'guid_mastertask_next'
inner join core.tasklist tl on tl.rid_mastertask = mtj.rid_mastertask
and tl.rid_hub = r_taskitem.rid_hub
where ti.rid_taskitem = r_taskitemevent.rid_taskitem
)
, 'select_taskitem_guid', (
select ti.guid
from core.taskitem ti
where
(m_final_rid_taskitem > 0
and ti.rid_taskitem = m_final_rid_taskitem
or nv(m_final_rid_taskitem) = 0
and ti.rid_tasklist = r_taskitem.rid_tasklist
)
order by
case
when ti.status in (10) then 2
when ti.status in (0, 1, 2) then 1
else 99
end
, ti.status desc, ti.seqno desc
limit 1
)
, 'openactions', (
select count(ti.rid_taskitem) filter ( where
coalesce(ti.status, 0) in (
core._enumi('eventstatus', 'todo'), core._enumi('eventstatus', 'planned')
)
)
from core.taskitem ti
where
ti.rid_tasklist in (
select r_taskitem.rid_tasklist
union
select tl.rid_tasklist_child
from core.tasklistlink tl
where tl.rid_tasklist_parent = r_taskitem.rid_tasklist
union
select tl.rid_tasklist_parent
from core.tasklistlink tl
where tl.rid_tasklist_child = r_taskitem.rid_tasklist
)
and nv(ti.subitem) = 0
and coalesce(ti.status, 0) <> coalesce(core._enumi('eventstatus', 'Canceled'), 0)
)
))
, 'WFL', to_jsonb(r_taskitem)));
j_obj = _jsonb_object_cat(j_obj
, jsonb_build_object('jsonvalue'
, _jsonb_object_cat(
j_obj -> 'jsonvalue'
, jsonb_build_object('complete'
, (j_obj -> 'jsonvalue' ->> 'rid_tasklist_event' is null
and j_obj -> 'jsonvalue' ->> 'rid_tasklist_next' is null
and _try_integer(j_obj -> 'jsonvalue' ->> 'openactions', 0) = 0
and nv(r_taskitemevent.jsonvalue ->> 'errorcode') not ilike 'E%'
)
)
)
)
);
j_result = j_result || j_obj;
if m_final_rid_taskitem > 0 and m_final_rid_taskitem is distinct from r_taskitem.rid_taskitem
then
j_obj = _jsonb_object_cat(to_jsonb(ev), jsonb_build_object(
'jsonvalue', jsonb_build_object(
'rid_tasklist', ti.rid_tasklist
, 'select_taskitem_guid', (
select ti.guid
from core.taskitem ti
where
(m_final_rid_taskitem > 0
and ti.rid_taskitem = m_final_rid_taskitem
or nv(m_final_rid_taskitem) = 0
and ti.rid_tasklist = r_taskitem.rid_tasklist
)
order by case when ti.status in (0, 1, 2) then 1 else 99 end, ti.status desc, ti.seqno desc
limit 1
)
, 'openactions', (
select count(ti.rid_taskitem) filter ( where
coalesce(ti.status, 0) in (
core._enumi('eventstatus', 'todo'), core._enumi('eventstatus', 'planned')
)
)
from core.taskitem ti
where
ti.rid_tasklist = r_taskitem.rid_tasklist
and nv(ti.subitem) = 0
and coalesce(ti.status, 0) <> coalesce(core._enumi('eventstatus', 'Canceled'), 0)
limit 1
)
)
, 'WFL', to_jsonb(ti)
)
)
from core.taskitem ti
inner join lateral (
select *
from core.taskitemevent ev
where
ev.rid_taskitem = ti.rid_taskitem
and ev.status in (2, 4)
order by ev.status desc
limit 1
) ev on ev.rid_taskitem = ti.rid_taskitem
where ti.rid_taskitem = m_final_rid_taskitem;
j_result = j_result || j_obj;
end if;
end loop;
--perform log_event(m_funcname,format('Result j_result: %s',j_result),bt_enum('eventlog','local notice'));
p_payload = j_result;
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;
perform log_event(m_funcname, format('Benchmark Err %s Items: %s', array_length(m_temp_rid_list, 1),
(clock_timestamp() - m_tm)::interval::text),
bt_enum('eventlog', 'local notice'));
END;
$$;
/*
select * from core.ui_action_event(jsonb_build_object('check_error',true
,'rid_mastertaskitem',(
select mti.rid_mastertaskitem from core.mastertaskitem mti
where mti.guid = '7a7348ec-7db6-4f13-bca7-c7840de1d22c'
),'rid_hub',20405))
;
select * from core.ui_action_event(jsonb_build_object('check_error',true
,'rid_mastertaskitem',(
select mti.rid_mastertaskitem from core.mastertaskitem mti
where mti.guid = '7a7348ec-7db6-4f13-bca7-c7840de1d22c'
),'rid_hub',20405))
;
select * from v_eventlog
selecT * from core.taskitem ti
order by ti.rid_taskitem desc
select mti.rid_mastertask,mti.rid_mastertaskitem, mti.description, mti.guid from core.mastertaskitem mti order by mti.rid_mastertaskitem desc;
*/
--select cli.rid_hub from t_adproclient cli where cli.clientid = '92092101980891'
+8 -3
View File
@@ -138,7 +138,9 @@ BEGIN
select ctl.rid_parent as rid_parent select ctl.rid_parent as rid_parent
, ctl.tableprefix as parent_prefix , ctl.tableprefix as parent_prefix
from tasklist ctl --inner join t_adproclient cli on cli.rid_adproclient = ctl.rid_parent -- and ctl.tableprefix = 'tcli' 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; where ctl.rid_tasklist = p_data_rid into m_data_rid, m_data_prefix;
elsif p_data_prefix::citext = 'ehr' elsif p_data_prefix::citext = 'ehr'
@@ -545,7 +547,8 @@ BEGIN
/* /*
---Use this code to test ---Use this code to test
select pl_writefile('/mnt/t/temp/t.docx',r.p_doc) ,r.* select pl_writefile('/mnt/t/temp/t.docx',r.p_doc)
,r.*
from mm_proc('docx','allfieldvalues', ( from mm_proc('docx','allfieldvalues', (
select tat.maindoc select tat.maindoc
from templateattachment tat from templateattachment tat
@@ -1330,7 +1333,9 @@ BEGIN
inner inner
join tmp_merge_init_fields d join tmp_merge_init_fields d
on d . mergetag = c . mergetag and d . tblparent = r_lp_t . tblid 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 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 group by f . rid ) loop
raise notice 'Inner Loop: %', r_lp_c . qry; raise notice 'Inner Loop: %', r_lp_c . qry;