18 Commits
Author SHA1 Message Date
Hein 0146364574 chore(release): bump version to 0.0.9
CI / Test (push) Failing after 1m55s
CI / Build (push) Skipped
Release / Test (push) Failing after 1m51s
Release / Release (push) Skipped
Release / Debian packages (push) Skipped
Release / RPM package (push) Skipped
Release / Windows installer (push) Skipped
Release / AUR package (push) Skipped
Release / VSCode Extension (push) Skipped
Release / DataGrip Plugin (push) Skipped
2026-10-06 17:02:00 +02:00
Hein c540517eca fix(updatecheck): ensure response body is closed properly
CI / Test (push) Successful in 1m24s
CI / Build (push) Successful in 1m9s
2026-10-06 17:01:15 +02:00
Hein 0b26c49931 feat(lint): lint code-like dollar-quoted SQL strings 2026-10-06 16:51:04 +02:00
Hein 8dbf0f8fcb feat(lint): lint PL/pgSQL function and DO bodies, report body syntax errors 2026-10-06 16:44:08 +02:00
Hein 96ee6c659b feat(windows): rewrite NSIS installer, add pgtidy update command 2026-10-06 16:15:21 +02:00
Hein e9c0d52ae9 refactor(format): subqueries and CTE lists on Doc-IR; fix CTE commas with trailing style 2026-10-06 14:57:21 +02:00
Hein 488801b8b1 feat(lsp): hover, documentSymbol, willSaveWaitUntil, token-wide diagnostic ranges 2026-10-06 14:56:32 +02:00
Hein e6c4e8b2c3 test(format): select_wrap respects commas setting 2026-10-06 14:53:13 +02:00
Hein c51e8e2da4 feat(format): select_wrap and join_wrap with when_long via Doc-IR 2026-10-06 14:51:24 +02:00
Hein ab9fe8893b feat(format): case_collapse uses line_width via Doc-IR 2026-10-06 14:48:28 +02:00
Hein 553609e988 feat(format): Doc-IR core, line_width (120) and where_wrap when_long 2026-10-06 14:44:45 +02:00
Hein 4ff729eeeb feat(format): skip non-plpgsql bodies; keep literals and embedded code safe
- Only restyle bodies for plpgsql/sql; skip pl* (plpython3u, plperl, ...), c and internal
- Keep LANGUAGE clause on DO blocks
- Carry multi-line string and dollar-quoted literals verbatim in bodies and DML
- Format dollar-quoted literals that contain code (declare/begin/select/...), never format() templates
- Fix code joined onto -- comments, early flush after raise exception, dropped comment before BEGIN
- Regenerate test_mm_proc golden; document in README, plan, todo
2026-10-06 14:43:43 +02:00
Hein 41fdaf415c feat(format): implement subquery and CASE expression formatting
CI / Test (push) Successful in 45s
CI / Build (push) Successful in 21s
* Add support for formatting subqueries with configurable placement and spacing.
* Implement CASE expression formatting with options for wrapping and collapsing.
* Introduce tests for subquery and CASE expression scenarios to ensure correctness.
2026-09-21 17:10:49 +02:00
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
42 changed files with 7392 additions and 867 deletions
+3 -3
View File
@@ -231,10 +231,10 @@ jobs:
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-X main.version=${PKGVER}" \
-o pgtidy.exe \
-o pgtidy-windows-amd64.exe \
./cmd/pgtidy
makensis -DVERSION="${PKGVER}" -DSRC_EXE="$(pwd)/pgtidy.exe" windows/installer.nsi
makensis -DVERSION="${PKGVER}" -DEXE="$PWD/pgtidy-windows-amd64.exe" -DOUT="$PWD/pgtidy-setup-windows-amd64.exe" windows/installer.nsi
- name: Upload to release
run: |
@@ -244,7 +244,7 @@ jobs:
-H "Authorization: token ${GITHUB_TOKEN}")
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
[ -z "$UPLOAD_URL" ] && { echo "upload_url not found: $RELEASE"; exit 1; }
for f in windows/pgtidy-setup-*.exe; do
for f in pgtidy-setup-windows-amd64.exe; do
echo "Uploading $(basename "$f")..."
curl -s -X POST "${UPLOAD_URL}?name=$(basename "$f")" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-62
View File
@@ -1,62 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
- name: Format check
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Files need gofmt:"
echo "$unformatted"
exit 1
fi
build-snapshot:
name: Build snapshot
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --snapshot --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: dist-snapshot
path: dist/
retention-days: 7
-91
View File
@@ -1,91 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
vscode-package:
name: VSCode Extension
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
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
working-directory: editors/vscode
run: |
npm ci
npm run compile
npm run package
- name: Upload to release
run: gh release upload ${{ github.ref_name }} editors/vscode/*.vsix
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
datagrip-package:
name: DataGrip Plugin
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Setup Gradle
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
working-directory: editors/datagrip
run: ./gradlew buildPlugin
- name: Upload to release
run: gh release upload ${{ github.ref_name }} editors/datagrip/build/distributions/*.zip
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -165,3 +165,5 @@ dist
temp/*
dist/*
# Local build of the CLI binary
/pgtidy
+22 -7
View File
@@ -60,13 +60,28 @@ testdata/corpus/ — real-world .pgsql procedures used as the safety/idempoten
4. **Graceful degradation**: any span the parser cannot handle is passed through verbatim
rather than corrupted.
5. **Runtime safety gate**: every frontend (CLI `fmt`, LSP `textDocument/formatting` and
`rangeFormatting`) calls `format.SemanticallyEqual(src, out)` before writing or returning
formatted output. It re-lexes both sides and compares non-trivia token streams
(case-insensitive for identifiers/keywords, exact otherwise, recursing into dollar-quoted
bodies). If it ever returns false, 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
not just a test assertion (`pkg/format/format_test.go`); it is enforced at runtime so a
formatter bug can never silently drop or alter code.
`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
+6 -1
View File
@@ -2,7 +2,7 @@ APP := pgtidy
CMD := ./cmd/pgtidy
DIST := dist
.PHONY: build test lint vet fmt clean release release-version snapshot vscode-compile vscode-package
.PHONY: installer-windows build test lint vet fmt clean release release-version snapshot vscode-compile vscode-package
## build: compile binary for the current platform
build:
@@ -25,6 +25,11 @@ lint: vet
test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1)
golangci-lint run ./...
## installer-windows: build the Windows binary and NSIS installer (requires makensis)
installer-windows:
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" -o $(DIST)/pgtidy-windows-amd64.exe $(CMD)
makensis -DVERSION=$$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$$' || echo 0.0.0) -DEXE=$(CURDIR)/$(DIST)/pgtidy-windows-amd64.exe -DOUT=$(CURDIR)/$(DIST)/pgtidy-setup-windows-amd64.exe windows/installer.nsi
## clean: remove build artifacts
clean:
rm -rf $(DIST)
+5
View File
@@ -18,6 +18,9 @@ go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest
Or download a pre-built binary from [Releases](https://git.warky.dev/wdevs/pgtidy/releases).
**Windows** — run `pgtidy-setup-windows-amd64.exe` from the release. It installs to
`%ProgramFiles%\PgTidy` and adds it to the system `PATH`. Update later with `pgtidy update`.
---
## CLI
@@ -27,6 +30,7 @@ pgtidy fmt [flags] [files...] Format SQL/PL-pgSQL (stdin if no files)
pgtidy lint [flags] [files...] Lint SQL
pgtidy config Print effective configuration
pgtidy lsp Start LSP server (stdio)
pgtidy update [--check] [-y] Check for a newer release (installs it on Windows)
pgtidy version
```
@@ -76,6 +80,7 @@ pgtidy config # print resolved config
- Leading-comma lists (SELECT columns, function params)
- Function params one-per-line; `LANGUAGE`, `SECURITY`, volatility each on own line
- PL/pgSQL: `DECLARE` block vars 2-space indented; `BEGIN`/`END` at body level
- Procedural languages: bodies in `pl*` languages other than `plpgsql` (e.g. `plpython3u`, `plperl`), `c` and `internal` are left verbatim; `plpgsql` and `sql` bodies are formatted, and the function header is always formatted
- Spaces around binary operators (`=`, `<>`, `||`, `:=`); no space before `(` or around `::`, `->`, `->>`
---
+4 -4
View File
@@ -63,8 +63,8 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 2
}
out := format.File(parser.Parse(string(src)), st)
if !format.SemanticallyEqual(string(src), out) {
_, _ = fmt.Fprintln(stderr, "pgtidy: refusing to format stdin: formatter safety check failed (output would change code content)")
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 {
@@ -90,8 +90,8 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
continue
}
out := format.File(parser.Parse(string(src)), st)
if !format.SemanticallyEqual(string(src), out) {
_, _ = fmt.Fprintf(stderr, "pgtidy: refusing to format %s: formatter safety check failed (output would change code content)\n", path)
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
}
+3
View File
@@ -28,6 +28,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return cmdLsp(args[1:], stdin, stdout, stderr)
case "config":
return cmdConfig(args[1:], stdin, stdout, stderr)
case "update":
return cmdUpdate(args[1:], stdin, stdout, stderr)
case "version", "--version", "-v":
_, _ = fmt.Fprintf(stdout, "pgtidy %s\n", version)
return 0
@@ -49,6 +51,7 @@ Usage:
pgtidy lint [flags] [files...] Lint SQL (stdin if no files)
pgtidy config Print effective configuration
pgtidy lsp Start LSP server (stdio, for editors)
pgtidy update [--check] [-y] Check for a newer release (installs on Windows)
pgtidy version Print version
pgtidy help Show this help
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"git.warky.dev/wdevs/pgtidy/pkg/updatecheck"
)
// windowsInstallerAsset is the release asset built by the NSIS installer step.
const windowsInstallerAsset = "pgtidy-setup-windows-amd64.exe"
// cmdUpdate implements `pgtidy update`: check the latest release and, on
// Windows, offer to download and run the installer.
func cmdUpdate(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var checkOnly, assumeYes bool
for _, a := range args {
switch a {
case "--check":
checkOnly = true
case "-y", "--yes":
assumeYes = true
case "-h", "--help":
_, _ = fmt.Fprint(stdout, `pgtidy update — check for a newer release
Usage:
pgtidy update [--check] [-y|--yes]
--check Only report whether an update is available
-y, --yes Update without prompting
On Windows the installer is downloaded and started; elsewhere the release
page URL is shown.
`)
return 0
default:
_, _ = fmt.Fprintf(stderr, "pgtidy update: unknown flag %q\n", a)
return 2
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
err := runUpdate(ctx, updateOptions{
current: version,
apiURL: updatecheck.DefaultAPIURL,
goos: runtime.GOOS,
checkOnly: checkOnly,
assumeYes: assumeYes,
in: stdin,
out: stdout,
install: downloadAndRunInstaller,
})
if err != nil {
_, _ = fmt.Fprintf(stderr, "pgtidy update: %v\n", err)
return 1
}
return 0
}
type updateOptions struct {
current string
apiURL string
goos string
checkOnly bool
assumeYes bool
in io.Reader
out io.Writer
// install downloads and starts the installer found at url.
install func(ctx context.Context, url string, out io.Writer) error
}
func runUpdate(ctx context.Context, o updateOptions) error {
rel, err := updatecheck.Latest(ctx, nil, o.apiURL)
if err != nil {
return err
}
if !updatecheck.IsNewer(o.current, rel.Tag) {
_, _ = fmt.Fprintf(o.out, "PgTidy %s is up to date (latest release: %s)\n", o.current, rel.Tag)
return nil
}
_, _ = fmt.Fprintf(o.out, "A newer PgTidy is available: %s (installed: %s)\n", rel.Tag, o.current)
if o.checkOnly {
_, _ = fmt.Fprintf(o.out, "Release: %s\n", rel.URL)
return nil
}
installer, canInstall := rel.FindAsset(windowsInstallerAsset)
if o.goos != "windows" || !canInstall {
_, _ = fmt.Fprintf(o.out, "Download it from: %s\n", rel.URL)
return nil
}
if !o.assumeYes && !confirm(o.in, o.out, "Download and run the installer now?") {
_, _ = fmt.Fprintf(o.out, "Skipped. Release: %s\n", rel.URL)
return nil
}
return o.install(ctx, installer.URL, o.out)
}
// confirm asks a yes/no question, defaulting to no.
func confirm(in io.Reader, out io.Writer, question string) bool {
_, _ = fmt.Fprintf(out, "%s [y/N]: ", question)
line, _ := bufio.NewReader(in).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
}
return false
}
// downloadAndRunInstaller saves the installer to a temp directory and starts
// it detached so this process can exit and release pgtidy.exe.
func downloadAndRunInstaller(ctx context.Context, url string, out io.Writer) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("downloading installer: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("downloading installer: unexpected status %s", resp.Status)
}
dir, err := os.MkdirTemp("", "pgtidy-update-")
if err != nil {
return err
}
path := filepath.Join(dir, windowsInstallerAsset)
f, err := os.Create(path)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
_ = f.Close()
return fmt.Errorf("downloading installer: %w", err)
}
if err := f.Close(); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "Starting installer: %s\n", path)
return exec.Command(path).Start()
}
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func releaseServer(t *testing.T, tag string, withInstaller bool) *httptest.Server {
t.Helper()
assets := ""
if withInstaller {
assets = fmt.Sprintf(`{"name":%q,"browser_download_url":"https://x/setup.exe"}`, windowsInstallerAsset)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"html_url":"https://x/release","assets":[%s]}`, tag, assets)
}))
t.Cleanup(srv.Close)
return srv
}
func TestRunUpdate(t *testing.T) {
tests := []struct {
name string
current string
latest string
withInstaller bool
goos string
checkOnly bool
assumeYes bool
stdin string
wantOut []string
wantInstall bool
}{
{name: "up to date", current: "v1.0.5", latest: "v1.0.5", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
{name: "dev build never prompts", current: "dev", latest: "v9.0.0", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
{name: "check only", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, checkOnly: true, wantOut: []string{"v1.0.6", "https://x/release"}},
{name: "non-windows shows url", current: "v1.0.5", latest: "v1.0.6", goos: "linux", withInstaller: true, wantOut: []string{"Download it from: https://x/release"}},
{name: "windows without installer asset", current: "v1.0.5", latest: "v1.0.6", goos: "windows", wantOut: []string{"Download it from"}},
{name: "windows prompt yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "y\n", wantOut: []string{"[y/N]"}, wantInstall: true},
{name: "windows prompt no", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "n\n", wantOut: []string{"Skipped"}},
{name: "windows prompt empty", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, wantOut: []string{"Skipped"}},
{name: "windows --yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, assumeYes: true, wantInstall: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := releaseServer(t, tt.latest, tt.withInstaller)
var out bytes.Buffer
var installedURL string
err := runUpdate(context.Background(), updateOptions{
current: tt.current, apiURL: srv.URL, goos: tt.goos,
checkOnly: tt.checkOnly, assumeYes: tt.assumeYes,
in: strings.NewReader(tt.stdin), out: &out,
install: func(_ context.Context, url string, _ io.Writer) error {
installedURL = url
return nil
},
})
if err != nil {
t.Fatal(err)
}
for _, want := range tt.wantOut {
if !strings.Contains(out.String(), want) {
t.Errorf("output missing %q:\n%s", want, out.String())
}
}
if (installedURL != "") != tt.wantInstall {
t.Errorf("installer called = %v, want %v", installedURL != "", tt.wantInstall)
}
if tt.wantInstall && installedURL != "https://x/setup.exe" {
t.Errorf("installer url = %q", installedURL)
}
})
}
}
func TestRunUpdate_Errors(t *testing.T) {
bad := httptest.NewServer(http.NotFoundHandler())
defer bad.Close()
if err := runUpdate(context.Background(), updateOptions{current: "v1.0.0", apiURL: bad.URL, out: io.Discard}); err == nil {
t.Error("expected lookup error")
}
srv := releaseServer(t, "v1.0.6", true)
want := errors.New("boom")
err := runUpdate(context.Background(), updateOptions{
current: "v1.0.5", apiURL: srv.URL, goos: "windows", assumeYes: true, out: io.Discard,
install: func(context.Context, string, io.Writer) error { return want },
})
if !errors.Is(err, want) {
t.Errorf("err = %v", err)
}
}
func TestDownloadAndRunInstaller_BadStatus(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
if err := downloadAndRunInstaller(context.Background(), srv.URL, io.Discard); err == nil {
t.Error("expected error for non-200")
}
if err := downloadAndRunInstaller(context.Background(), "://bad", io.Discard); err == nil {
t.Error("expected error for bad url")
}
}
+9 -6
View File
@@ -29,7 +29,10 @@ indent_join: false # Extra indentation for JOIN … ON lines
join_indent_size: 1 # Number of extra indent levels for JOINs
# always | when_long | never
where_wrap: always # Each AND/OR condition on its own line
where_wrap: always # Each AND/OR condition on its own line (when_long: only if the clause exceeds line_width)
select_wrap: always # always | when_long | never — one SELECT/RETURNING column per line
join_wrap: never # always | when_long | never — ON (and AND/OR) on their own lines under the JOIN
line_width: 120 # Target width for when_long wrapping; 0 = unlimited
where_and_or_indent: true # AND/OR indented one level under WHERE
@@ -47,14 +50,14 @@ insert_collapse_values: true # Fold multiple VALUES rows onto fewer lines
# --- Routines (functions / procedures) ---
align_param_types: true # Pad param names so type column aligns across all params
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)
# --- PL/pgSQL body ---
plpgsql_max_blank_lines: 1 # Max consecutive blank lines in body
plpgsql_declare_align_type: false # Align type column in DECLARE block
plpgsql_declare_align_eq: false # Align := / = in DECLARE block
plpgsql_max_blank_lines: 1 # Max consecutive blank lines in body
plpgsql_declare_align_type: true # Align type column 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_loop_collapse: true # Collapse empty loop bodies to one line
@@ -64,5 +67,5 @@ binary_op_align: false # Align =, <>, || etc. vertically in WHERE/exp
space_after_comma_in_calls: false # Space after , in function calls: func(a, b)
case_when_wrap: false # Each WHEN … THEN on its own line
case_end: new_line # END placement: same_line | new_line
case_collapse: false # Collapse short CASE expressions to one line
case_collapse: false # Collapse a CASE to one line when it fits line_width
record_space_before_paren: false # Space before ( in ROW(…) / record constructors
+97
View File
@@ -0,0 +1,97 @@
# 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` | `{openClose: true, change: 1 (full), willSaveWaitUntil: true}` | `serverCaps` |
| `documentFormattingProvider` | `true` | `handle("initialize")` |
| `documentRangeFormattingProvider` | `true` | `handle("initialize")` |
| `codeActionProvider` | `true` | `handle("initialize")` |
| `hoverProvider` | `true` | `handle("initialize")` |
| `documentSymbolProvider` | `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/willSaveWaitUntil` | req/resp | Format-on-save: returns the same safety-gated edit as `formatting`. |
| `textDocument/hover` | req/resp | Markdown with the rule ID + message of the diagnostic under the cursor; `null` elsewhere. |
| `textDocument/documentSymbol` | req/resp | `CREATE FUNCTION`/`PROCEDURE` statements (name, kind Function, `function`/`procedure` detail, full range + name selection range). |
| `textDocument/publishDiagnostics` | notif | Sent on every open/change; diagnostic code = `RuleID`, source = `pgtidy`; the range covers the offending token, not one character. |
| `$/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)
Done since the first inventory: real diagnostic highlight range, `hover`, `documentSymbol`,
`willSaveWaitUntil`. Still open:
1. **No `completion`.** `textDocument/completion` is not implemented. Not urgent for a
formatter/linter.
2. **No diagnostics debounce/coalescing beyond full-sync.** Every `didChange` re-runs the full
lint engine. Fine for now; matters on large files.
3. **`initializationOptions` / workspace config.** `initialize` params are parsed nowhere — no
way to pass style overrides or a config path over the protocol.
4. **No `prepareRename`, `rename`, `references`, `foldingRange`, `documentLink`.** Low priority;
`documentSymbol` now provides the symbol info they would build on.
5. **Hover is diagnostics-only.** No keyword/type glossary for hover on plain identifiers.
## 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)
1. **`initializationOptions`**: accept a config path / style overrides in `initialize`.
2. **Debounce `didChange` diagnostics.**
3. **Hover glossary** for keywords/types.
> Do NOT: expand the LSP surface into a broad design (workspace features, incremental
parsing, custom `textDocument/*` extensions). Keep any change scoped 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`, `documentSymbol` and
`willSaveWaitUntil` are covered by tests in `pkg/lsp/server_test.go`.
+9 -3
View File
@@ -67,6 +67,9 @@ to the intended style below.
on own line; `AS` then `$$` on its own line; body; closing `$$;` on its own line.
- PL/pgSQL: `DECLARE` alone, vars 2-space indented; `--Block--` comment markers preserved;
`BEGIN`/`END` at body level; `IF/THEN/ELSIF/ELSE/END IF`, loops, `CASE` indent their bodies.
- Procedural languages: bodies in any `pl*` language other than `plpgsql` (`plpython3u`,
`plperl`, `pltcl`, …) and in `c` / `internal` are emitted verbatim; `plpgsql`, `sql` and routines with no `LANGUAGE` clause (treated as `sql`) are formatted. The
function header is always formatted.
- Spacing: spaces around binary operators (`=`,`<>`,`||`,…) and `:=`; **no** space around
`::`, `->`, `->>`, array `[...]`, or before a call's `(`.
- Dollar-quote tags preserved verbatim (`$$`, `$S$`, `$Z$`, …).
@@ -106,6 +109,9 @@ DataGrip enum conventions used below:
| `FROM_ONLY_JOIN_INDENT` | `join_indent_size` | `1` | extra indent levels for JOINs |
| `SET_ALIGN_EQUAL_SIGN` | `set_align_equal` | `false` | align `=` in UPDATE SET list |
| `WHERE_EL_WRAP` + `WHERE_EL_LINE` | `where_wrap` | `always` | always \| when_long \| never — each AND/OR condition on its own line |
| _(new)_ | `select_wrap` | `always` | always \| when_long \| never — one SELECT/RETURNING column per line |
| _(new)_ | `join_wrap` | `never` | always \| when_long \| never — `ON` and AND/OR conditions on their own lines under the JOIN |
| _(new)_ | `line_width` | `120` | target width for every `when_long` decision and `case_collapse`; 0 = unlimited |
| _(no DataGrip equivalent)_ | `where_and_or_indent` | `true` | when true, AND/OR are indented one level under WHERE, not at WHERE's column |
### Subqueries
@@ -128,7 +134,7 @@ DataGrip enum conventions used below:
| DataGrip key | PgTidy key | Default | Notes |
|---|---|---|---|
| `ROUTINE_ARG_COMMA` | uses `commas` | `leading` | same setting as query lists |
| `ROUTINE_ARG_ALIGN_TYPES` | `align_param_types` | `true` | align type column in param list |
| `ROUTINE_ARG_ALIGN_TYPES` | `align_param_types` | `false` | align type column in param list |
| `ROUTINE_AS_WRAP` | `routine_as_wrap` | `true` | newline before `AS $$` |
### PL/pgSQL body
@@ -136,8 +142,8 @@ DataGrip enum conventions used below:
| DataGrip key | PgTidy key | Default | Notes |
|---|---|---|---|
| `IMP_COMMON_KEEP_BLANK_LINES_IN_CODE` | `plpgsql_max_blank_lines` | `1` | max consecutive blank lines in body |
| `IMP_DECLARE_ALIGN_TYPE` | `plpgsql_declare_align_type` | `false` | align type column in DECLARE block |
| `IMP_DECLARE_ALIGN_EQ` | `plpgsql_declare_align_eq` | `false` | align `:=` / `=` in DECLARE block |
| `IMP_DECLARE_ALIGN_TYPE` | `plpgsql_declare_align_type` | `true` | align type column 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_LOOP_COLLAPSE` | `plpgsql_loop_collapse` | `true` | collapse empty loop bodies |
+88 -14
View File
@@ -36,9 +36,9 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
- `parser_test.go`: small round-trips, CreateFunction shape assertions, Raw fallback, and
**corpus round-trip** — reconstructs all 4 files byte-for-byte; structures all 4 functions.
- **Status:** all tests pass.
- _Still TODO (later): DML/other-DDL structuring (currently Raw) for full formatting._
- _Note: DML/other-DDL still parse to `Raw`; they are formatted at token level by `pkg/format/dml.go`, not via structured CST nodes._
### 🚧 PL/pgSQL body parser ← NEXT (the remaining V1 piece)
### ✅ PL/pgSQL body parser
#### ✅ DECLARE section — `pkg/format/body.go`
- `formatBody` splits the dollar-quote tag, calls `formatBodyInner`.
@@ -93,6 +93,14 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
- `fmt_test.go`: stdin, --check (un/formatted), -w idempotence, unknown-command.
- Safety invariants #2 (semantic equivalence), #3 (idempotence), #4 (graceful degradation)
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`.
---
@@ -108,6 +116,7 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
- NAM001/2/3 table/column/function names not snake_case (quoted identifiers only)
- `pgtidy lint [--only=ID,...] [files...]`; exits 1 on findings, 2 on error.
- Fixture SQL in `testdata/lint/`; 6 tests covering violations + clean fixtures.
- **Function bodies** (`pkg/lint/plpgsql.go`): Code-like dollar-quoted strings (SELECT/INSERT/UPDATE/DELETE/WITH, e.g. `EXECUTE $q$ … $q$` or a `LANGUAGE sql` body) are linted the same way, recursively; format() templates and bodies of other languages are skipped. `plpgsql` `CREATE FUNCTION/PROCEDURE` and `DO` bodies are parsed with the PL/pgSQL parser. Syntax errors are reported as `PLPGSQL` (located by the offending token, since libpg_query gives no position), and every embedded SQL statement is run through the normal rules with line/col mapped back to the file. Embedded findings carry no autofix. Non-plpgsql languages are skipped.
- `--fix` rewrites files in place applying autofixes; for stdin, prints fixed SQL to stdout.
- Autofixable: **MIG001** (insert `CONCURRENTLY` after `INDEX`) and **MIG003** (insert `NOT VALID` before `;`). MIG002, COR*, NAM* are intentionally not autofixable.
- `pkg/diagnostics.TextFix{Offset, End, New, Title}` — byte-range replacement attached to `Diagnostic.Fix`.
@@ -119,6 +128,7 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
- `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.
- _Range formatting: future._
- Full capability inventory, runtime-verified gaps, and ranked next steps in `docs/lsp-status.md` (issue #3).
## ✅ V4 — DataGrip
- `editors/datagrip/`: Gradle-based JetBrains plugin targeting DataGrip 2024.3+ via LSP4IJ.
@@ -158,8 +168,8 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
function call at the token level — formatted without space (known limitation).
- Note: SQL keywords inside PL/pgSQL function bodies remain lowercase (matching
the corpus golden files); casing is applied only to top-level DML.
- _Still TODO: Wadler Doc-IR printer for width-aware wrapping of long lines._
- _Still TODO: LSP range formatting._
- ✅ Doc-IR core (`pkg/format/doc.go`: Text/Line/SoftLine/Group/Indent/IfBreak + `Render`) and `line_width` (default 120, 0 = unlimited). First consumer: `where_wrap: when_long` (`dmlWhereWhenLong`). `select_wrap` (default `always`) and `join_wrap` (default `never`) also take `always|when_long|never`; `when_long` keeps the SELECT list / `JOIN … ON` on one line when it fits `line_width`, otherwise uses the existing one-column-per-line / ON-per-line layout. Items containing a subquery or wrapped CASE always use the broken layout. Subquery wrapping and multi-CTE lists are on the Doc-IR too (`HardLine`, `Lines`; CTE lists reuse `dmlCommaList`, which fixes dropped commas with `commas: trailing`). VALUES rows already share `dmlCommaList`.
- ✅ LSP range formatting (already implemented; this note was stale). LSP also has hover (diagnostic rule + message), documentSymbol (functions/procedures), willSaveWaitUntil (format-on-save) and token-wide diagnostic ranges — see `docs/lsp-status.md`.
## ✅ Config expansion — DataGrip settings parity
@@ -200,36 +210,76 @@ to route ident tokens through `AliasCase` (after AS) or `BuiltinCase` (before `(
- `space_after_comma_in_calls` applied in `dmlInline`.
- `binary_op_align` registered in config (enforcement in WHERE/expression context deferred).
### ⬜ Formatter — subquery formatting
### ✅ Formatter — subquery formatting
`subquery_opening/content/closing/space_before_paren` fields are wired in config.
Enforcement in `dml.go` is not yet implemented — subqueries use current CTE formatting
as a proxy (new_line for content, inline for single-arg subexpressions).
`pkg/format/dml.go`: `dmlIsSubqueryOpen` detects a `(` immediately followed by `SELECT`/
`WITH` (derived tables, scalar subqueries, `IN`/`EXISTS`/`ARRAY(...)` subqueries — a plain
value tuple like `IN (1, 2, 3)` is left alone). `dmlInline` splices these in via
`dmlWrapSubquery`, which recursively formats the inner tokens with `formatDML` and wraps
them per `subquery_content`/`subquery_closing`; `dmlWriteSubquerySep` handles
`subquery_opening` (same_line/new_line) and additively applies `subquery_space_before_paren`
(only adds a space where one wouldn't already be there — never removes the space `IN`/
`EXISTS`/`AS` already get). `formatCTEDef` now calls the same `dmlWrapSubquery` helper
instead of a hardcoded new_line-only layout, so CTE bodies honor the config too (the
`AS (` space itself stays unconditional — that's fixed CTE syntax, not the subquery-space
setting). Nested subqueries-in-CASE and CASE-in-subqueries recurse correctly. Not
column/Doc-IR-aligned (documented "fixed-layout, not Wadler" limitation) — wrapped content
is indented one level relative to its own local frame, which composes correctly under
JOIN/WHERE/SELECT-list embedding but isn't perfectly column-aligned for deeply nested cases.
Tests: `TestDMLSubquery*`, `TestDMLCTEUsesSubqueryConfig` in `pkg/format/dml_test.go`.
### ⬜ Formatter — INSERT VALUES collapse
### ✅ Formatter — INSERT VALUES collapse
`insert_collapse_values` field is wired in config. Enforcement in `dml.go` not yet implemented.
`dmlValuesClause` (`pkg/format/dml.go`): when `insert_collapse_values` is `true` (default)
multi-row `VALUES` stays packed on one line (matches prior behavior); when `false`, each row
gets its own line via the shared `dmlCommaList` helper (same leading/trailing-comma layout
as SELECT/SET lists). Single-row VALUES is unaffected either way.
Tests: `TestDMLInsertValues*`.
### ✅ 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.
- Golden file `testdata/corpus/test_a.pgsql` updated to reflect aligned params.
### ✅ Formatter — PL/pgSQL body settings (`pkg/format/body.go`)
- Language gate (`isPlpgsql` in `pkg/format/format.go`): bodies in a `pl*` language other than
`plpgsql` (`plpython3u`, `plperl`, …), `c` and `internal` are skipped and kept verbatim; `plpgsql`, `sql` and no
clause (treated as `sql`) are formatted. Header is always formatted. Test:
`TestNonPlpgsqlBodyVerbatim`.
- `plpgsql_max_blank_lines`: blank-line runs capped at the configured limit; default `1`.
- `plpgsql_declare_align_type` + `plpgsql_declare_align_eq`: two-pass declare formatter
measures name/type widths then pads for alignment; `writeDeclareAligned` helper.
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
preceding condition line.
- `plpgsql_loop_collapse`: `tryCollapseLoop` detects empty FOR/WHILE loop bodies and
collapses them to one line.
- CRLF normalization in trivia emission (comment text, body trivia before DECLARE).
### ⬜ Formatter — expression settings (case_when_wrap, case_end, case_collapse, record_space_before_paren)
### ✅ Formatter — expression settings (case_when_wrap, case_end, case_collapse, record_space_before_paren)
Config fields wired. Expression-level CASE/ROW formatting not yet implemented.
`pkg/format/dml.go`: `dmlInline` detects `CASE` tokens (`dmlIsCaseStart`/`dmlMatchCaseEnd`,
tracking nested-CASE depth so an inner `CASE…END`'s own `WHEN`/`THEN`/`ELSE` don't get
mistaken for the outer one's boundaries) and splices in `dmlFormatCase`. `dmlSplitCase`
breaks the body into operand/WHEN/THEN/ELSE segments (each rendered via a recursive
`dmlInline` call, so subqueries and nested CASEs inside a branch format correctly too).
Both the simple (`CASE x WHEN ...`) and searched (`CASE WHEN ...`) forms are supported.
- `case_when_wrap` (default `false`): `false` keeps everything on one line (unchanged
default behavior); `true` puts each `WHEN … THEN …` and `ELSE` on its own line, indented
one level.
- `case_end` (default `new_line`): placement of the closing `END` when wrapped —
`new_line` on its own line, `same_line` glued to the last WHEN/ELSE line.
- `case_collapse` (default `false`): when `true`, keeps the wrapped CASE on one line anyway if the
fully-inlined rendering fits in `line_width` (default 120), overriding `case_when_wrap`;
longer CASEs still wrap. Rendered through the Doc-IR (`Group(IfBreak(wrapped, inline))`).
- `record_space_before_paren` (default `false`): scoped to the `ROW` keyword specifically
(`ROW(1, 2)` vs `ROW (1, 2)`) — bare `(a, b)` record literals are indistinguishable from
grouping parens at the token level, so this setting only fires on an explicit `ROW(`.
Tests: `TestDMLCase*`, `TestDMLRecordSpaceBeforeParen`.
### ⬜ DataGrip XML import/export (optional, V4+)
@@ -241,3 +291,27 @@ not implemented.
## Open risks
- `go-pgquery` tracks PG17 (not PG18) — fine for lint; irrelevant to formatter path.
- Leading-comma + one-per-line is a first-class style option, not an afterthought.
### ✅ Literal & embedded-code safety (found via `temp/` corpus)
- Multi-line quoted strings and dollar-quoted literals inside bodies are carried verbatim
(never re-indented); only the code outside them is scanned for parens / terminators / block
depth (`formatBodyStatements`, `cut`/`tail` handling). Same for top-level DML
(`litNL` sentinel in `dml.go`).
- Dollar-quoted literals whose content starts like code (`declare`/`begin` → PL/pgSQL block;
`select`/`insert`/`update`/`delete`/`with` → DML) are formatted recursively
(`formatEmbedded`); anything else (fragments, prose, other languages) stays verbatim. Literals
containing `format()` placeholders (`%s`, `%I`, `%L`, `%1$s`) are never touched, and a result
that fails `SemanticallyEqual`/`CommentsPreserved` is discarded.
- `DO LANGUAGE x $$…$$` keeps its `LANGUAGE` clause (it was being dropped).
- Code is never joined onto a line that ends in a `--` comment.
- A line ending in a literal or non-keyword no longer counts as ending in `EXCEPTION`/`THEN`/….
- Comments between the last DECLARE variable and `BEGIN` are kept.
- Golden `testdata/corpus/test_mm_proc.pgsql` regenerated: nested dollar-quoted dynamic SQL is no
longer re-flowed.
## Local corpus (`temp/`)
`temp/` holds real-world routines (plpgsql, plpython3u, triggers) used for local testing only
(not committed). Run `pgtidy fmt` over it to find safety-gate failures. See "Language gate" above
for how non-plpgsql bodies are handled.
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=pgtidy-bin
pkgver=0.0.7
pkgver=0.0.9
pkgrel=1
pkgdesc="PostgreSQL SQL formatter and linter"
arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: pgtidy
Version: 0.0.7
Version: 0.0.9
Release: 1%{?dist}
Summary: PostgreSQL SQL formatter and linter
+32 -3
View File
@@ -69,6 +69,9 @@ type Style struct {
IndentJoin bool // extra indentation for JOIN … ON lines
JoinIndentSize int // extra indent levels for JOINs (default 1)
WhereWrap WrapMode // always|when_long|never — each AND/OR on its own line
LineWidth int // target width for when_long wrapping (0 = unlimited)
SelectWrap WrapMode // always|when_long|never — one SELECT/RETURNING column per line
JoinWrap WrapMode // always|when_long|never — break JOIN … ON onto its own lines
WhereAndOrIndent bool // AND/OR indented one level under WHERE
// --- Subqueries ---
@@ -120,6 +123,9 @@ func Default() Style {
SetAlignEqual: false,
IndentJoin: false,
JoinIndentSize: 1,
LineWidth: 120,
SelectWrap: WrapAlways,
JoinWrap: WrapNever,
WhereWrap: WrapAlways,
WhereAndOrIndent: true,
@@ -130,12 +136,12 @@ func Default() Style {
InsertCollapseValues: true,
AlignParamTypes: true,
AlignParamTypes: false,
RoutineAsWrap: true,
PlpgsqlMaxBlankLines: 1,
PlpgsqlDeclareAlignType: false,
PlpgsqlDeclareAlignEq: false,
PlpgsqlDeclareAlignType: true,
PlpgsqlDeclareAlignEq: true,
PlpgsqlIfThenNewline: true,
PlpgsqlLoopCollapse: true,
@@ -169,6 +175,9 @@ type yamlFile struct {
IndentJoin *bool `yaml:"indent_join"`
JoinIndentSize *int `yaml:"join_indent_size"`
WhereWrap *string `yaml:"where_wrap"`
LineWidth *int `yaml:"line_width"`
SelectWrap *string `yaml:"select_wrap"`
JoinWrap *string `yaml:"join_wrap"`
WhereAndOrIndent *bool `yaml:"where_and_or_indent"`
SubqueryOpening *string `yaml:"subquery_opening"`
@@ -258,6 +267,26 @@ func Load(startDir string) (Style, error) {
if yf.JoinIndentSize != nil {
st.JoinIndentSize = *yf.JoinIndentSize
}
if yf.LineWidth != nil {
if *yf.LineWidth < 0 {
return st, fmt.Errorf("pgtidy: %s: line_width: must be >= 0", path)
}
st.LineWidth = *yf.LineWidth
}
if yf.SelectWrap != nil {
wm := WrapMode(*yf.SelectWrap)
if err := validWrap(wm); err != nil {
return st, fmt.Errorf("pgtidy: %s: select_wrap: %w", path, err)
}
st.SelectWrap = wm
}
if yf.JoinWrap != nil {
wm := WrapMode(*yf.JoinWrap)
if err := validWrap(wm); err != nil {
return st, fmt.Errorf("pgtidy: %s: join_wrap: %w", path, err)
}
st.JoinWrap = wm
}
if yf.WhereWrap != nil {
wm := WrapMode(*yf.WhereWrap)
if err := validWrap(wm); err != nil {
+306 -54
View File
@@ -1,6 +1,9 @@
package format
import (
"git.warky.dev/wdevs/pgtidy/pkg/parser"
"regexp"
"sort"
"strings"
"git.warky.dev/wdevs/pgtidy/pkg/config"
@@ -109,6 +112,16 @@ func formatBodyInner(inner string, st config.Style) string {
// Format each variable declaration in the DECLARE section.
formatDeclareVars(&b, sig[declareIdx+1:beginIdx], st)
// Comments between the last declaration and BEGIN live in BEGIN's leading
// trivia; keep them, one per line, at declaration indent.
for _, tr := range sig[beginIdx].Lead {
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
b.WriteString(st.Indent)
b.WriteString(strings.TrimRight(strings.ReplaceAll(tr.Text, "\r", ""), " \t"))
b.WriteString(nl)
}
}
// Format the BEGIN…END block.
b.WriteString(formatBodyStatements(inner[sig[beginIdx].Tok.Off:], st))
@@ -177,7 +190,10 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
if nw > nameColW {
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
}
}
@@ -207,7 +223,7 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
writeDeclareAligned(b, body, st, nameColW, typeColW)
} else {
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.WriteString(caseText(t.Tok, st))
@@ -220,6 +236,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
// of a DECLARE variable declaration (without the default assignment).
// Format is: [name type [:= default]] or [name type [DEFAULT default]].
@@ -256,7 +298,7 @@ func declareNameTypeWidth(body []cst.Tok, st config.Style) (nameW, typeW int) {
}
var tb strings.Builder
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.WriteString(caseText(t.Tok, st))
@@ -315,7 +357,7 @@ func writeDeclareAligned(b *strings.Builder, body []cst.Tok, st config.Style, na
var typeStr strings.Builder
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.WriteString(caseText(t.Tok, st))
@@ -366,9 +408,60 @@ type bline struct {
// 4. Blank-line counts from the original are preserved (capped by PlpgsqlMaxBlankLines).
func formatBodyStatements(text string, st config.Style) string {
nl := st.Newline
text = formatEmbeddedLiterals(text, st)
normalised := strings.ReplaceAll(text, "\r\n", "\n")
rawLines := strings.Split(normalised, "\n")
// Mark the continuation lines of every multi-line token whose interior is
// not code: /* … */ block comments and every kind of string literal,
// including dollar-quoted ones (a dollar quote is just another way of
// quoting a string, whatever its tag, and its content may be any language).
// Those lines are token content, not statements: they must be carried
// verbatim with the token's opening line, never split off and reindented.
// On the line where such a token ends, tail holds the code that follows it
// so paren depth and statement terminators are still tracked.
inVerbatim := make([]bool, len(rawLines))
tail := make([]string, len(rawLines))
tailCol := make([]int, len(rawLines)) // column where tail[i] starts
cut := make([]int, len(rawLines)) // column where a multi-line token opens on this line, or -1
for i := range cut {
cut[i] = -1
}
lineStart := make([]int, len(rawLines))
for i, off := 0, 0; i < len(rawLines); i++ {
lineStart[i] = off
off += len(rawLines[i]) + 1
}
lineOf := func(off int) int {
return sort.Search(len(lineStart), func(i int) bool { return lineStart[i] > off }) - 1
}
for _, t := range lexer.Lex(normalised) {
switch t.Kind {
case lexer.BlockComment, lexer.String, lexer.EscapeString, lexer.BitString, lexer.DollarString:
default:
continue
}
n := strings.Count(t.Text, "\n")
if n == 0 || !literalTerminated(t) {
continue
}
start := lineOf(t.Off)
if cut[start] < 0 {
cut[start] = t.Off - lineStart[start]
}
for k := 1; k <= n && start+k < len(inVerbatim); k++ {
inVerbatim[start+k] = true
}
if start+n < len(tail) {
rest := normalised[t.Off+len(t.Text):]
if i := strings.IndexByte(rest, '\n'); i >= 0 {
rest = rest[:i]
}
tail[start+n] = rest
tailCol[start+n] = t.Off + len(t.Text) - lineStart[start+n]
}
}
maxBlanks := st.PlpgsqlMaxBlankLines
if maxBlanks < 0 {
maxBlanks = 0
@@ -429,7 +522,12 @@ func formatBodyStatements(text string, st config.Style) string {
}
case "exception":
inException = true
effectiveDepth = 0
// 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
}
}
baseIndent := strings.Repeat(st.Indent, effectiveDepth)
@@ -454,54 +552,9 @@ func formatBodyStatements(text string, st config.Style) string {
stmt = nil
}
for _, rawLine := range rawLines {
line := strings.TrimRight(rawLine, "\r")
indent := leadingWhitespace(line)
stripped := line[len(indent):]
if stripped == "" {
flush()
pendingBlanks++
continue
}
fw := lowerASCII(firstBodyKeyword(stripped))
isColZero := indent == ""
joinToPrev := isColZero && parenDepth == 0 && len(stmt) > 0 && !sqlClauseKw[fw]
// 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
// disappear into the comment text and be invisible to the lexer.
if joinToPrev {
if lowerASCII(firstBodyKeyword(stmt[len(stmt)-1].text)) == "" {
joinToPrev = false
}
}
// Pre-flush pending comment-only blines before adding a new non-comment
// non-joined bline. Without this, a comment + `end if;` end up in the
// same stmt, `fw` comes from the comment (empty string), depth is never
// decremented, and the formatter diverges on the second pass.
if !joinToPrev && fw != "" && len(stmt) > 0 {
allComments := true
for _, ll := range stmt {
if lowerASCII(firstBodyKeyword(ll.text)) != "" {
allComments = false
break
}
}
if allComments {
flush()
}
}
if joinToPrev {
last := &stmt[len(stmt)-1]
last.text = strings.TrimRight(last.text, " \t") + " " + stripped
} else {
stmt = append(stmt, bline{text: stripped, indent: indent})
}
scanLine := func(text string, openLit bool) {
var lastD0Kw string
for _, tok := range lexer.Lex(stripped) {
for _, tok := range lexer.Lex(text) {
if tok.IsTrivia() || tok.Kind == lexer.EOF {
continue
}
@@ -534,9 +587,17 @@ func formatBodyStatements(text string, st config.Style) string {
caseDepth--
}
}
} else if parenDepth == 0 {
// A non-keyword token (string, operator, …) ends the run of
// keywords, so a line like `raise exception 'x'` does not end
// in the keyword EXCEPTION.
lastD0Kw = ""
}
}
if openLit {
lastD0Kw = "" // the line ends inside a literal, not on a keyword
}
if parenDepth == 0 && len(stmt) > 0 {
switch lastD0Kw {
case "then":
@@ -566,6 +627,101 @@ func formatBodyStatements(text string, st config.Style) string {
}
}
for j, rawLine := range rawLines {
line := strings.TrimRight(rawLine, "\r")
if inVerbatim[j] {
// Verbatim continuation of a multi-line comment or literal: glue it
// to the bline holding the token's opening line.
if len(stmt) > 0 {
last := &stmt[len(stmt)-1]
last.text += "\n" + line
} else {
stmt = append(stmt, bline{text: line})
}
if tail[j] != "" {
// A second literal may open later on this same line; scan only
// the code between the two.
tt, openLit := tail[j], false
if c := cut[j] - tailCol[j]; cut[j] >= 0 && c < len(tt) {
if c < 0 {
c = 0
}
tt, openLit = tt[:c], true
}
scanLine(tt, openLit)
}
continue
}
indent := leadingWhitespace(line)
stripped := line[len(indent):]
if stripped == "" {
flush()
pendingBlanks++
continue
}
fw := lowerASCII(firstBodyKeyword(stripped))
isColZero := indent == ""
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:
// the comment has no structural keyword so `continue ;` at col-0 would
// disappear into the comment text and be invisible to the lexer.
if joinToPrev {
if lowerASCII(firstBodyKeyword(stmt[len(stmt)-1].text)) == "" {
joinToPrev = false
}
}
// Pre-flush pending comment-only blines before adding a new non-comment
// non-joined bline. Without this, a comment + `end if;` end up in the
// same stmt, `fw` comes from the comment (empty string), depth is never
// decremented, and the formatter diverges on the second pass.
if !joinToPrev && fw != "" && len(stmt) > 0 {
allComments := true
for _, ll := range stmt {
if lowerASCII(firstBodyKeyword(ll.text)) != "" {
allComments = false
break
}
}
if allComments {
flush()
}
}
// Likewise never join onto a line that ends in a -- comment: the joined
// code would become part of the comment text.
if joinToPrev && endsInLineComment(stmt[len(stmt)-1].text) {
joinToPrev = false
}
if joinToPrev {
last := &stmt[len(stmt)-1]
last.text = strings.TrimRight(last.text, " \t") + " " + stripped
} else {
stmt = append(stmt, bline{text: stripped, indent: indent})
}
// A multi-line literal that opens on this line is scanned only up to
// its opening quote; the rest is token content, not code.
scanText, openLit := stripped, false
if c := cut[j] - len(indent); cut[j] >= 0 && c < len(scanText) {
if c < 0 {
c = 0
}
scanText, openLit = scanText[:c], true
}
scanLine(scanText, openLit)
}
flush()
return result.String()
}
@@ -589,7 +745,13 @@ func formatBodyStmtLines(lines []bline, baseIndent string, st config.Style) []st
for i, ll := range lines {
text := ll.text
indent := baseIndent
if i > 0 && ll.indent != "" && !isStandaloneBodyKeyword(ll.text, "then", "else", "elsif", "elseif") {
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)
@@ -639,7 +801,9 @@ func reindentBodyDML(lines []bline, baseIndent string, st config.Style) []string
continue
}
if lineDepth == 0 && (kw == "and" || kw == "or") {
out = append(out, baseIndent+st.Indent+strings.TrimSpace(text))
// 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
@@ -764,3 +928,91 @@ func leadingWhitespace(s string) string {
}
return s[:i]
}
// endsInLineComment reports whether s ends with a -- line comment.
func endsInLineComment(s string) bool {
toks := lexer.Lex(s)
for i := len(toks) - 1; i >= 0; i-- {
switch toks[i].Kind {
case lexer.EOF, lexer.Whitespace:
continue
}
return toks[i].Kind == lexer.LineComment
}
return false
}
// literalTerminated reports whether a string or block-comment token is closed.
// Slices of nested dollar-quoted text can end mid-literal; an unterminated
// token there is a lexing artefact, not real literal content.
func literalTerminated(t lexer.Token) bool {
if t.Kind == lexer.BlockComment {
return strings.HasSuffix(t.Text, "*/") && len(t.Text) >= 4
}
if t.Kind == lexer.DollarString {
_, _, _, ok := splitDollarQuote(t.Text)
return ok
}
return len(t.Text) >= 2 && strings.HasSuffix(t.Text, "'")
}
// formatEmbeddedLiterals restyles every dollar-quoted literal in text whose
// content looks like code (see formatEmbedded). Everything else is untouched.
func formatEmbeddedLiterals(text string, st config.Style) string {
if !strings.Contains(text, "$") {
return text
}
var b strings.Builder
for _, t := range lexer.Lex(text) {
if t.Kind == lexer.DollarString {
b.WriteString(formatEmbedded(t.Text, st))
} else {
b.WriteString(t.Text)
}
}
return b.String()
}
// formatEmbedded formats a dollar-quoted literal as code when its content
// starts like code: DECLARE/BEGIN is formatted as a PL/pgSQL block and
// SELECT/INSERT/UPDATE/DELETE/WITH as DML. Any other content (SQL fragments,
// prose, other languages) is left exactly as written, as is the literal if the
// formatted result is not provably equivalent.
func formatEmbedded(lit string, st config.Style) string {
open, inner, close, ok := splitDollarQuote(lit)
if !ok {
return lit
}
// format()-style templates (%s, %I, %L, %1$s) are text, not SQL: restyling
// would split the placeholders, and the token-level safety gate can't see it.
if formatPlaceholder.MatchString(inner) {
return lit
}
var first string
for _, t := range lexer.Lex(inner) {
if t.IsTrivia() || t.Kind == lexer.EOF {
continue
}
if t.Kind == lexer.Ident {
first = lowerASCII(t.Text)
}
break
}
var res string
switch first {
case "declare", "begin":
res = formatBodyInner(inner, st)
case "select", "insert", "update", "delete", "with":
lead := inner[:len(inner)-len(strings.TrimLeft(inner, " \t\r\n"))]
trail := inner[len(strings.TrimRight(inner, " \t\r\n")):]
res = lead + strings.TrimSpace(File(parser.Parse(inner), st)) + trail
default:
return lit
}
if res == inner || !SemanticallyEqual(inner, res) || CommentsPreserved(inner, res) != nil {
return lit
}
return open + res + close
}
var formatPlaceholder = regexp.MustCompile(`%(\d+\$)?-?\d*[sILlx]`)
+507 -114
View File
@@ -182,6 +182,8 @@ func dmlSegText(seg dmlSeg, st config.Style) string {
case "set":
items := dmlSplitCommas(seg.body)
return dmlColListSet(kwText, items, st)
case "values":
return dmlValuesClause(kwText, seg.body, st)
case "where":
return dmlWhereClause(kwText, seg.body, st)
case "join", "left", "right", "inner", "full", "cross", "natural":
@@ -204,6 +206,27 @@ func dmlJoinClause(kwText string, body []cst.Tok, st config.Style) string {
if text != "" {
line += " " + text
}
if onIdx := dmlKeywordIdx(body, 0, "on"); onIdx >= 0 && st.JoinWrap != config.WrapNever {
nl := st.Newline
var b strings.Builder
b.WriteString(kwText)
if head := dmlInline(body[:onIdx], st); head != "" {
b.WriteString(" " + head)
}
for i, cond := range dmlSplitAndOr(body[onIdx+1:]) {
b.WriteString(nl + st.Indent)
if i == 0 {
b.WriteString(caseText(body[onIdx].Tok, st) + " ")
}
b.WriteString(strings.ReplaceAll(dmlInline(cond, st), nl, nl+st.Indent))
}
broken := b.String()
if st.JoinWrap == config.WrapAlways || strings.Contains(line, nl) {
line = broken
} else {
line = Render(Group(IfBreak(Text(broken), Text(line))), st.LineWidth, st.Indent, nl)
}
}
if !st.IndentJoin {
return line
}
@@ -242,25 +265,49 @@ func dmlWhereClause(kwText string, body []cst.Tok, st config.Style) string {
}
nl := st.Newline
if st.WhereWrap == config.WrapWhenLong {
return dmlWhereWhenLong(kwText, conditions, st)
}
var b strings.Builder
b.WriteString(kwText)
for i, cond := range conditions {
b.WriteString(nl)
text := dmlInline(cond, st)
prefix := ""
if st.WhereAndOrIndent {
b.WriteString(st.Indent)
prefix = st.Indent
}
if i == 0 {
// First condition: no leading AND/OR
b.WriteString(" ") // align with AND/OR token width
b.WriteString(text)
} else {
b.WriteString(text)
prefix += " " // align with AND/OR token width
}
writeListItem(&b, prefix, prefix, text, false, nl)
}
return b.String()
}
// dmlWhereWhenLong is the where_wrap: when_long layout: the whole clause stays
// on one line when it fits in line_width, otherwise it breaks exactly as
// where_wrap: always would.
func dmlWhereWhenLong(kwText string, conditions [][]cst.Tok, st config.Style) string {
prefix := ""
if st.WhereAndOrIndent {
prefix = st.Indent
}
parts := []Doc{Text(kwText)}
for i, cond := range conditions {
text := dmlInline(cond, st)
pfx := prefix
if i == 0 {
pfx += " " // align with AND/OR token width
}
// Continuation lines of a multi-line condition (subquery) keep the
// same prefix writeListItem would give them.
text = strings.ReplaceAll(text, st.Newline, st.Newline+pfx)
parts = append(parts, IfBreak(Concat(Text(st.Newline), Text(pfx)), Text(" ")), Text(text))
}
return Render(Group(Concat(parts...)), st.LineWidth, st.Indent, st.Newline)
}
// dmlSplitAndOr splits toks at depth-0 AND/OR tokens, keeping the AND/OR with
// the following condition.
func dmlSplitAndOr(toks []cst.Tok) [][]cst.Tok {
@@ -291,7 +338,6 @@ func dmlSplitAndOr(toks []cst.Tok) [][]cst.Tok {
// formatWithBody formats the body of a WITH clause by splitting CTE definitions
// at depth-0 commas and formatting the subquery inside each AS (...) block.
func formatWithBody(kwText string, body []cst.Tok, st config.Style) string {
nl := st.Newline
cteDefs := dmlSplitCommas(body)
// Filter spurious empty items.
@@ -310,37 +356,11 @@ func formatWithBody(kwText string, body []cst.Tok, st config.Style) string {
return kwText + " " + formatCTEDef(cteDefs[0], st)
default:
// Multiple CTEs: one per line with the configured comma style.
first := st.Indent + " "
cont := st.Indent + ","
contPad := strings.Repeat(" ", len(cont)) // same width as cont, no comma
var b strings.Builder
b.WriteString(kwText)
texts := make([]string, len(cteDefs))
for i, cteDef := range cteDefs {
b.WriteString(nl)
var headPfx, tailPfx string
if i == 0 || st.Commas != config.CommaLeading {
headPfx = first
tailPfx = first
} else {
headPfx = cont
tailPfx = contPad
}
cteText := formatCTEDef(cteDef, st)
cteLines := strings.Split(cteText, nl)
for j, line := range cteLines {
if j > 0 {
b.WriteString(nl)
b.WriteString(tailPfx)
} else {
b.WriteString(headPfx)
}
b.WriteString(line)
}
texts[i] = formatCTEDef(cteDef, st)
}
return b.String()
return dmlCommaList(kwText, texts, st)
}
}
@@ -384,28 +404,16 @@ func formatCTEDef(toks []cst.Tok, st config.Style) string {
// Format the header (name, optional column list, AS, optional MATERIALIZED).
header := dmlInline(toks[:parenOpen], st)
// Format the subquery as DML.
// Format and wrap the subquery per subquery_content/subquery_closing.
// The "AS (" space is standard CTE syntax and independent of
// subquery_space_before_paren; only subquery_opening's newline choice
// applies here.
subToks := toks[parenOpen+1 : parenClose]
subFormatted := strings.TrimRight(formatDML(subToks, st), nl)
if subFormatted == "" {
return header + " ()"
sep := " "
if st.SubqueryOpening == config.PlacementNewLine {
sep = nl
}
// Indent every non-empty line of the subquery by st.Indent.
indent := st.Indent
var indented strings.Builder
for i, line := range strings.Split(subFormatted, nl) {
if i > 0 {
indented.WriteString(nl)
}
if line != "" {
indented.WriteString(indent)
}
indented.WriteString(line)
}
return header + " (" + nl + indented.String() + nl + ")"
return header + sep + dmlWrapSubquery(subToks, st)
}
// dmlKeywordIdx returns the index of the first token equal to kw at paren depth 0,
@@ -450,9 +458,63 @@ func dmlMatchParen(toks []cst.Tok, open int) int {
return -1
}
// dmlIsSubqueryOpen reports whether toks[i] is a '(' immediately followed by
// SELECT or WITH — i.e. it opens a subquery (derived table, scalar subquery,
// or an IN/EXISTS/ANY/ALL/ARRAY(...) subquery), as opposed to a function-call
// argument list, a value tuple, or a grouping paren.
func dmlIsSubqueryOpen(toks []cst.Tok, i int) bool {
if toks[i].Tok.Kind != lexer.LParen {
return false
}
j := i + 1
if j >= len(toks) || toks[j].Tok.Kind != lexer.Ident {
return false
}
switch lowerASCII(toks[j].Tok.Text) {
case "select", "with":
return true
}
return false
}
// dmlWrapSubquery formats a subquery's inner tokens (excluding the enclosing
// parens) as DML and wraps them in "(" … ")" per the subquery_content and
// subquery_closing settings. The result is rendered relative to column 0;
// callers that splice it mid-line are responsible for re-indenting any
// continuation lines to the surrounding context.
func dmlWrapSubquery(inner []cst.Tok, st config.Style) string {
nl := st.Newline
sub := strings.TrimRight(formatDML(inner, st), nl)
if sub == "" {
return "()"
}
// The first line stays on the "(" line unless subquery_content says
// new_line; every later line is indented one level.
var body []Doc
for i, line := range strings.Split(sub, nl) {
if i > 0 || st.SubqueryContent == config.PlacementNewLine {
body = append(body, HardLine())
}
body = append(body, Text(line))
}
doc := []Doc{Text("("), Indent(Concat(body...))}
if st.SubqueryClosing == config.PlacementNewLine {
doc = append(doc, HardLine())
}
doc = append(doc, Text(")"))
return Render(Concat(doc...), 0, st.Indent, nl)
}
// litNL stands in for a newline inside a multi-line string literal while DML
// text is assembled; formatDML's caller restores it (see restoreLiteralNewlines).
const litNL = "\x00\x01"
func restoreLiteralNewlines(s string) string { return strings.ReplaceAll(s, litNL, "\n") }
// dmlInline renders toks on one line with keyword casing and proper spacing.
// If toks[1:] contains comment trivia the function falls back to verbatimSpan
// so no comment is lost.
// so no comment is lost. Subquery parens and CASE…END expressions embedded
// anywhere in toks are recursively formatted and spliced in.
func dmlInline(toks []cst.Tok, st config.Style) string {
if len(toks) == 0 {
return ""
@@ -460,10 +522,41 @@ func dmlInline(toks []cst.Tok, st config.Style) string {
if anyComment(toks[1:]) {
return verbatimSpan(toks)
}
nl := st.Newline
var b strings.Builder
for i, t := range toks {
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
b.WriteByte(' ')
i := 0
for i < len(toks) {
t := toks[i]
if dmlIsSubqueryOpen(toks, i) {
if closeIdx := dmlMatchParen(toks, i); closeIdx > i {
dmlWriteSubquerySep(&b, toks, i, st, nl)
b.WriteString(dmlWrapSubquery(toks[i+1:closeIdx], st))
i = closeIdx + 1
continue
}
}
if dmlIsCaseStart(t) {
if endIdx := dmlMatchCaseEnd(toks, i); endIdx > i {
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
b.WriteByte(' ')
}
b.WriteString(dmlFormatCase(toks[i:endIdx+1], st))
i = endIdx + 1
continue
}
}
if i > 0 {
space := needSpace(toks[i-1].Tok, t.Tok) && !isPctTypeBoundary(toks, i)
if !space && st.RecordSpaceBeforeParen && t.Tok.Kind == lexer.LParen &&
toks[i-1].Tok.Kind == lexer.Ident && lowerASCII(toks[i-1].Tok.Text) == "row" {
space = true
}
if space {
b.WriteByte(' ')
}
}
// Space after comma in calls: func(a, b) vs func(a,b).
if st.SpaceAfterCommaInCalls && i > 0 && toks[i-1].Tok.Kind == lexer.Comma {
@@ -476,11 +569,42 @@ func dmlInline(toks []cst.Tok, st config.Style) string {
prev = toks[i-1].Tok
}
nextIsLParen := i+1 < len(toks) && toks[i+1].Tok.Kind == lexer.LParen
b.WriteString(caseTextCtx(t.Tok, prev, nextIsLParen, st))
txt := caseTextCtx(t.Tok, prev, nextIsLParen, st)
if t.Tok.Kind == lexer.DollarString {
txt = formatEmbedded(txt, st)
}
switch t.Tok.Kind {
case lexer.String, lexer.EscapeString, lexer.BitString, lexer.DollarString:
// Line breaks inside a literal are content: hide them from the
// line-based indent helpers that post-process this text.
txt = strings.ReplaceAll(txt, "\n", litNL)
}
b.WriteString(txt)
i++
}
return b.String()
}
// dmlWriteSubquerySep writes the separator between the token preceding a
// subquery-opening '(' at toks[i] and the '(' itself, honoring
// subquery_opening (same_line|new_line) and subquery_space_before_paren.
func dmlWriteSubquerySep(b *strings.Builder, toks []cst.Tok, i int, st config.Style, nl string) {
if i == 0 {
return
}
if st.SubqueryOpening == config.PlacementNewLine {
b.WriteString(nl)
return
}
space := needSpace(toks[i-1].Tok, toks[i].Tok) && !isPctTypeBoundary(toks, i)
if !space && st.SubquerySpaceBeforeParen {
space = true
}
if space {
b.WriteByte(' ')
}
}
// dmlSplitCommas splits toks at depth-0 commas and returns the items between
// them (the comma tokens themselves are discarded).
func dmlSplitCommas(toks []cst.Tok) [][]cst.Tok {
@@ -507,22 +631,76 @@ func dmlSplitCommas(toks []cst.Tok) [][]cst.Tok {
return items
}
// dmlColListSelect formats a SELECT / RETURNING column list with optional
// align_columns and select_align_as settings.
func dmlColListSelect(kwText string, items [][]cst.Tok, st config.Style) string {
// filterEmpty drops empty token slices (spurious items from a trailing
// comma or similar).
func filterEmpty(items [][]cst.Tok) [][]cst.Tok {
var kept [][]cst.Tok
for _, item := range items {
if len(item) > 0 {
kept = append(kept, item)
}
}
items = kept
return kept
}
nl := st.Newline
switch len(items) {
// dmlCommaList renders texts as a one-item-per-line list under kwText, using
// leading or trailing commas per st.Commas. Items whose rendered text spans
// multiple lines (e.g. an embedded subquery or wrapped CASE) have their
// continuation lines re-indented to align under the item's first line.
func dmlCommaList(kwText string, texts []string, st config.Style) string {
switch len(texts) {
case 0:
return kwText
case 1:
if texts[0] == "" {
return kwText
}
return kwText + " " + texts[0]
}
nl := st.Newline
first := st.Indent + " "
cont := st.Indent + ","
contPad := strings.Repeat(" ", len(cont))
var b strings.Builder
b.WriteString(kwText)
for i, text := range texts {
b.WriteString(nl)
trailingComma := st.Commas == config.CommaTrailing && i < len(texts)-1
if i == 0 || st.Commas != config.CommaLeading {
writeListItem(&b, first, first, text, trailingComma, nl)
} else {
writeListItem(&b, cont, contPad, text, false, nl)
}
}
return b.String()
}
// writeListItem writes text prefixed with headPfx (its first line) and
// tailPfx (any continuation lines), optionally followed by a trailing comma.
func writeListItem(b *strings.Builder, headPfx, tailPfx, text string, trailingComma bool, nl string) {
for j, line := range strings.Split(text, nl) {
if j > 0 {
b.WriteString(nl)
if line != "" {
b.WriteString(tailPfx)
}
} else {
b.WriteString(headPfx)
}
b.WriteString(line)
}
if trailingComma {
b.WriteString(",")
}
}
// dmlColListSelect formats a SELECT / RETURNING column list with optional
// align_columns and select_align_as settings.
func dmlColListSelect(kwText string, items [][]cst.Tok, st config.Style) string {
items = filterEmpty(items)
if len(items) == 1 {
body := dmlInline(items[0], st)
if body == "" {
return kwText
@@ -530,52 +708,38 @@ func dmlColListSelect(kwText string, items [][]cst.Tok, st config.Style) string
return kwText + " " + body
}
// Render each item text.
texts := make([]string, len(items))
multiline := false
for i, item := range items {
texts[i] = dmlInline(item, st)
if strings.Contains(texts[i], st.Newline) {
multiline = true
}
}
flat := kwText + " " + strings.Join(texts, ", ")
// align_columns / select_align_as: pad expressions so AS and aliases align.
if (st.AlignColumns || st.SelectAlignAs) && len(texts) > 1 {
texts = alignSelectItems(texts, st)
}
first := st.Indent + " "
cont := st.Indent + ","
var b strings.Builder
b.WriteString(kwText)
for i, text := range texts {
b.WriteString(nl)
if i == 0 || st.Commas != config.CommaLeading {
b.WriteString(first)
b.WriteString(text)
if st.Commas == config.CommaTrailing && i < len(items)-1 {
b.WriteString(",")
}
} else {
b.WriteString(cont)
b.WriteString(text)
}
broken := dmlCommaList(kwText, texts, st)
if multiline {
return broken // an embedded subquery / wrapped CASE can't sit on one line
}
return b.String()
switch st.SelectWrap {
case config.WrapNever:
return flat
case config.WrapWhenLong:
return Render(Group(IfBreak(Text(broken), Text(flat))), st.LineWidth, st.Indent, st.Newline)
}
return broken
}
// dmlColListSet formats an UPDATE SET column list with optional set_align_equal.
func dmlColListSet(kwText string, items [][]cst.Tok, st config.Style) string {
var kept [][]cst.Tok
for _, item := range items {
if len(item) > 0 {
kept = append(kept, item)
}
}
items = kept
nl := st.Newline
switch len(items) {
case 0:
return kwText
case 1:
items = filterEmpty(items)
if len(items) == 1 {
body := dmlInline(items[0], st)
if body == "" {
return kwText
@@ -593,24 +757,28 @@ func dmlColListSet(kwText string, items [][]cst.Tok, st config.Style) string {
texts = alignSetItems(texts)
}
first := st.Indent + " "
cont := st.Indent + ","
var b strings.Builder
b.WriteString(kwText)
for i, text := range texts {
b.WriteString(nl)
if i == 0 || st.Commas != config.CommaLeading {
b.WriteString(first)
b.WriteString(text)
if st.Commas == config.CommaTrailing && i < len(items)-1 {
b.WriteString(",")
}
} else {
b.WriteString(cont)
b.WriteString(text)
return dmlCommaList(kwText, texts, st)
}
// dmlValuesClause formats a VALUES clause. When insert_collapse_values is
// true (the default), multiple rows stay packed onto one line, matching the
// pre-existing flat rendering. When false, each row gets its own line.
func dmlValuesClause(kwText string, body []cst.Tok, st config.Style) string {
rows := filterEmpty(dmlSplitCommas(body))
if len(rows) <= 1 || st.InsertCollapseValues {
text := dmlInline(body, st)
if text == "" {
return kwText
}
return kwText + " " + text
}
return b.String()
texts := make([]string, len(rows))
for i, row := range rows {
texts[i] = dmlInline(row, st)
}
return dmlCommaList(kwText, texts, st)
}
// alignSelectItems pads SELECT list item expressions so that AS keywords and
@@ -696,3 +864,228 @@ func alignSetItems(texts []string) []string {
}
return out
}
// dmlIsCaseStart reports whether t is a CASE keyword token.
func dmlIsCaseStart(t cst.Tok) bool {
return t.Tok.Kind == lexer.Ident && lowerASCII(t.Tok.Text) == "case"
}
// dmlMatchCaseEnd returns the index of the END token that closes the CASE
// token at toks[start], accounting for nested CASE…END and paren depth.
// Returns -1 if no matching END is found.
func dmlMatchCaseEnd(toks []cst.Tok, start int) int {
depth := 0
caseDepth := 1
for i := start + 1; i < len(toks); i++ {
switch toks[i].Tok.Kind {
case lexer.LParen, lexer.LBracket:
depth++
continue
case lexer.RParen, lexer.RBracket:
if depth > 0 {
depth--
}
continue
}
if depth != 0 || toks[i].Tok.Kind != lexer.Ident {
continue
}
switch lowerASCII(toks[i].Tok.Text) {
case "case":
caseDepth++
case "end":
caseDepth--
if caseDepth == 0 {
return i
}
}
}
return -1
}
// caseSeg is one part of a CASE expression's body: the optional leading
// operand (kw == nil), or a WHEN/THEN/ELSE-led span.
type caseSeg struct {
kw *cst.Tok
toks []cst.Tok
}
// dmlSplitCase splits a CASE expression's body (the tokens strictly between
// CASE and its matching END) into operand/when/then/else segments at
// depth-0 boundaries, skipping over any nested CASE…END.
func dmlSplitCase(body []cst.Tok) []caseSeg {
var segs []caseSeg
depth := 0
caseDepth := 0
start := 0
var curKw *cst.Tok
flush := func(end int) {
if end > start {
segs = append(segs, caseSeg{kw: curKw, toks: body[start:end]})
}
}
for i := range body {
t := body[i]
switch t.Tok.Kind {
case lexer.LParen, lexer.LBracket:
depth++
continue
case lexer.RParen, lexer.RBracket:
if depth > 0 {
depth--
}
continue
}
if depth != 0 || t.Tok.Kind != lexer.Ident {
continue
}
switch lowerASCII(t.Tok.Text) {
case "case":
caseDepth++
case "end":
if caseDepth > 0 {
caseDepth--
}
case "when", "then", "else":
if caseDepth == 0 {
flush(i)
start = i + 1
kw := body[i]
curKw = &kw
}
}
}
flush(len(body))
return segs
}
// whenThen is one rendered WHEN … THEN … branch of a CASE expression.
type whenThen struct {
whenKw, cond, thenKw, then string
}
// dmlFormatCase renders a CASE…END expression honoring case_when_wrap,
// case_end, and case_collapse. toks[0] must be CASE and toks[len(toks)-1]
// its matching END.
func dmlFormatCase(toks []cst.Tok, st config.Style) string {
body := toks[1 : len(toks)-1]
segs := dmlSplitCase(body)
operand := ""
var whens []whenThen
elseKw, elseText := "", ""
haveElse := false
pendingWhenKw, pendingCond := "", ""
for _, s := range segs {
text := dmlInline(s.toks, st)
if s.kw == nil {
operand = text
continue
}
kwText := caseText(s.kw.Tok, st)
switch lowerASCII(s.kw.Tok.Text) {
case "when":
pendingWhenKw, pendingCond = kwText, text
case "then":
whens = append(whens, whenThen{whenKw: pendingWhenKw, cond: pendingCond, thenKw: kwText, then: text})
case "else":
elseKw, elseText, haveElse = kwText, text, true
}
}
caseKw := caseText(toks[0].Tok, st)
endKw := caseText(toks[len(toks)-1].Tok, st)
inline := dmlCaseInline(caseKw, operand, whens, elseKw, elseText, haveElse, endKw)
if !st.CaseWhenWrap {
return inline
}
wrapped := dmlCaseWrapped(caseKw, operand, whens, elseKw, elseText, haveElse, endKw, st)
if st.CaseCollapse {
// Collapse when the one-line form fits in line_width.
return Render(Group(IfBreak(Text(wrapped), Text(inline))), st.LineWidth, st.Indent, st.Newline)
}
return wrapped
}
// dmlCaseInline renders a CASE expression on a single line.
func dmlCaseInline(caseKw, operand string, whens []whenThen, elseKw, elseText string, haveElse bool, endKw string) string {
var b strings.Builder
b.WriteString(caseKw)
if operand != "" {
b.WriteByte(' ')
b.WriteString(operand)
}
for _, w := range whens {
b.WriteByte(' ')
b.WriteString(w.whenKw)
if w.cond != "" {
b.WriteByte(' ')
b.WriteString(w.cond)
}
b.WriteByte(' ')
b.WriteString(w.thenKw)
if w.then != "" {
b.WriteByte(' ')
b.WriteString(w.then)
}
}
if haveElse {
b.WriteByte(' ')
b.WriteString(elseKw)
if elseText != "" {
b.WriteByte(' ')
b.WriteString(elseText)
}
}
b.WriteByte(' ')
b.WriteString(endKw)
return b.String()
}
// dmlCaseWrapped renders a CASE expression with each WHEN … THEN branch (and
// ELSE) on its own line, per case_end for the closing END's placement.
func dmlCaseWrapped(caseKw, operand string, whens []whenThen, elseKw, elseText string, haveElse bool, endKw string, st config.Style) string {
nl := st.Newline
indent := st.Indent
var b strings.Builder
b.WriteString(caseKw)
if operand != "" {
b.WriteByte(' ')
b.WriteString(operand)
}
for _, w := range whens {
b.WriteString(nl)
b.WriteString(indent)
b.WriteString(w.whenKw)
if w.cond != "" {
b.WriteByte(' ')
b.WriteString(w.cond)
}
b.WriteByte(' ')
b.WriteString(w.thenKw)
if w.then != "" {
b.WriteByte(' ')
b.WriteString(w.then)
}
}
if haveElse {
b.WriteString(nl)
b.WriteString(indent)
b.WriteString(elseKw)
if elseText != "" {
b.WriteByte(' ')
b.WriteString(elseText)
}
}
if st.CaseEnd == config.PlacementNewLine {
b.WriteString(nl)
b.WriteString(endKw)
} else {
b.WriteByte(' ')
b.WriteString(endKw)
}
return b.String()
}
+433
View File
@@ -1,6 +1,7 @@
package format
import (
"strings"
"testing"
"git.warky.dev/wdevs/pgtidy/pkg/config"
@@ -295,3 +296,435 @@ func TestCorpusUnaffectedByDML(t *testing.T) {
t.Errorf("create function: DML formatter changed semantics")
}
}
// --- Subquery formatting ---
func TestDMLSubqueryDerivedTable(t *testing.T) {
src := "select a from (select x, y from t) s where s.x = 1;"
want := "SELECT a\n" +
"FROM (\n" +
" SELECT\n" +
" x\n" +
" ,y\n" +
" FROM t\n" +
") s\n" +
"WHERE s.x = 1;\n"
got := format(src)
if got != want {
t.Errorf("derived table\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
checkDML(t, "derived table", got)
if !semanticallyEqual(src, got) {
t.Errorf("derived table: formatting changed semantics")
}
}
func TestDMLSubqueryScalarInSelect(t *testing.T) {
src := "select a, (select max(x) from t2) as m from t1;"
got := format(src)
if got == "" {
t.Error("empty output")
}
checkDML(t, "scalar subquery", got)
if !semanticallyEqual(src, got) {
t.Errorf("scalar subquery: formatting changed semantics")
}
}
func TestDMLSubqueryIn(t *testing.T) {
src := "select a from t where a in (select b from t2);"
want := "SELECT a\n" +
"FROM t\n" +
"WHERE a IN (\n" +
" SELECT b\n" +
" FROM t2\n" +
");\n"
got := format(src)
if got != want {
t.Errorf("in subquery\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
checkDML(t, "in subquery", got)
}
func TestDMLSubqueryExists(t *testing.T) {
src := "select a from t where exists (select 1 from t2 where t2.a = t.a);"
got := format(src)
if got == "" {
t.Error("empty output")
}
checkDML(t, "exists subquery", got)
if !semanticallyEqual(src, got) {
t.Errorf("exists subquery: formatting changed semantics")
}
}
func TestDMLSubqueryInValueList(t *testing.T) {
// A plain value list must not be mistaken for a subquery.
src := "select a from t where a in (1, 2, 3);"
want := "SELECT a\nFROM t\nWHERE a IN (1, 2, 3);\n"
got := format(src)
if got != want {
t.Errorf("value list in()\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
checkDML(t, "value list in()", got)
}
func TestDMLSubqueryPlacementConfig(t *testing.T) {
st := config.Default()
st.SubqueryContent = config.PlacementSameLine
st.SubqueryClosing = config.PlacementSameLine
src := "select a from t where a in (select b from t2);"
got := File(parser.Parse(src), st)
if got == "" {
t.Error("empty output")
}
twice := File(parser.Parse(got), st)
if twice != got {
t.Errorf("subquery placement config not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
}
}
func TestDMLSubquerySpaceBeforeParen(t *testing.T) {
st := config.Default()
st.SubquerySpaceBeforeParen = true
src := "select array(select x from t) from t2;"
got := File(parser.Parse(src), st)
want := "SELECT ARRAY (\n SELECT x\n FROM t\n)\nFROM t2;\n"
if got != want {
t.Errorf("subquery_space_before_paren\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
twice := File(parser.Parse(got), st)
if twice != got {
t.Errorf("subquery_space_before_paren not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
}
}
func TestDMLCTEUsesSubqueryConfig(t *testing.T) {
// CTE bodies should honor the same subquery_* settings, not a hardcoded layout.
st := config.Default()
st.SubqueryOpening = config.PlacementNewLine
src := "with cte as (select x from y) select x from cte;"
got := File(parser.Parse(src), st)
want := "WITH cte AS\n(\n SELECT x\n FROM y\n)\nSELECT x\nFROM cte;\n"
if got != want {
t.Errorf("cte subquery_opening=new_line\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
twice := File(parser.Parse(got), st)
if twice != got {
t.Errorf("cte subquery_opening not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
}
}
// --- INSERT VALUES collapse ---
func TestDMLInsertValuesCollapseDefault(t *testing.T) {
// insert_collapse_values defaults to true: multiple rows stay on one line.
src := "insert into t (a, b) values (1, 2), (3, 4), (5, 6);"
want := "INSERT INTO t(a, b)\nVALUES (1, 2), (3, 4), (5, 6);\n"
got := format(src)
if got != want {
t.Errorf("values collapse default\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
checkDML(t, "values collapse default", got)
}
func TestDMLInsertValuesNoCollapse(t *testing.T) {
st := config.Default()
st.InsertCollapseValues = false
src := "insert into t (a, b) values (1, 2), (3, 4), (5, 6);"
want := "INSERT INTO t(a, b)\n" +
"VALUES\n" +
" (1, 2)\n" +
" ,(3, 4)\n" +
" ,(5, 6);\n"
got := File(parser.Parse(src), st)
if got != want {
t.Errorf("values no collapse\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
twice := File(parser.Parse(got), st)
if twice != got {
t.Errorf("values no collapse not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
}
if !semanticallyEqual(src, got) {
t.Errorf("values no collapse: formatting changed semantics")
}
}
func TestDMLInsertValuesSingleRowUnaffected(t *testing.T) {
// A single-row VALUES is unaffected by insert_collapse_values either way.
st := config.Default()
st.InsertCollapseValues = false
src := "insert into t (a, b) values (1, 2);"
want := "INSERT INTO t(a, b)\nVALUES (1, 2);\n"
got := File(parser.Parse(src), st)
if got != want {
t.Errorf("single row values\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
// --- CASE expression formatting ---
func TestDMLCaseInlineDefault(t *testing.T) {
src := "select case when a = 1 then 'one' when a = 2 then 'two' else 'other' end as label from t;"
want := "SELECT CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' ELSE 'other' END AS label\nFROM t;\n"
got := format(src)
if got != want {
t.Errorf("case inline default\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
checkDML(t, "case inline default", got)
if !semanticallyEqual(src, got) {
t.Errorf("case inline default: formatting changed semantics")
}
}
func TestDMLCaseWhenWrap(t *testing.T) {
st := config.Default()
st.CaseWhenWrap = true
src := "select case when a = 1 then 'one' when a = 2 then 'two' else 'other' end as label from t;"
want := "SELECT CASE\n" +
" WHEN a = 1 THEN 'one'\n" +
" WHEN a = 2 THEN 'two'\n" +
" ELSE 'other'\n" +
"END AS label\n" +
"FROM t;\n"
got := File(parser.Parse(src), st)
if got != want {
t.Errorf("case when_wrap\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
twice := File(parser.Parse(got), st)
if twice != got {
t.Errorf("case when_wrap not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
}
if !semanticallyEqual(src, got) {
t.Errorf("case when_wrap: formatting changed semantics")
}
}
func TestDMLCaseEndSameLine(t *testing.T) {
st := config.Default()
st.CaseWhenWrap = true
st.CaseEnd = config.PlacementSameLine
src := "select case when a = 1 then 'one' else 'other' end as label from t;"
want := "SELECT CASE\n" +
" WHEN a = 1 THEN 'one'\n" +
" ELSE 'other' END AS label\n" +
"FROM t;\n"
got := File(parser.Parse(src), st)
if got != want {
t.Errorf("case_end same_line\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
twice := File(parser.Parse(got), st)
if twice != got {
t.Errorf("case_end same_line not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
}
}
func TestDMLCaseCollapseShort(t *testing.T) {
// case_collapse keeps a short CASE on one line even with case_when_wrap set.
st := config.Default()
st.CaseWhenWrap = true
st.CaseCollapse = true
src := "select case when a = 1 then 'x' else 'y' end from t;"
want := "SELECT CASE WHEN a = 1 THEN 'x' ELSE 'y' END\nFROM t;\n"
got := File(parser.Parse(src), st)
if got != want {
t.Errorf("case_collapse short\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
twice := File(parser.Parse(got), st)
if twice != got {
t.Errorf("case_collapse short not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
}
}
func TestDMLCaseCollapseLongStillWraps(t *testing.T) {
// case_collapse only keeps CASE inline when it is short; a long CASE still wraps.
st := config.Default()
st.CaseWhenWrap = true
st.CaseCollapse = true
src := "select case when a = 1 then 'a fairly long result value one' " +
"when a = 2 then 'a fairly long result value two' else 'a fairly long default value' end from t;"
got := File(parser.Parse(src), st)
if !strings.Contains(got, "\n WHEN a = 1") {
t.Errorf("case_collapse long: expected wrapped WHEN branches, got:\n%s", got)
}
twice := File(parser.Parse(got), st)
if twice != got {
t.Errorf("case_collapse long not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
}
}
func TestDMLCaseNestedInSubquery(t *testing.T) {
src := "select a from (select case when x = 1 then 'y' else 'n' end as c from t) s;"
got := format(src)
if got == "" {
t.Error("empty output")
}
checkDML(t, "case nested in subquery", got)
if !semanticallyEqual(src, got) {
t.Errorf("case nested in subquery: formatting changed semantics")
}
}
func TestDMLSubqueryNestedInCase(t *testing.T) {
src := "select case when exists (select 1 from t2 where t2.a = t1.a) then 'y' else 'n' end from t1;"
got := format(src)
if got == "" {
t.Error("empty output")
}
checkDML(t, "subquery nested in case", got)
if !semanticallyEqual(src, got) {
t.Errorf("subquery nested in case: formatting changed semantics")
}
}
func TestDMLCaseSimpleForm(t *testing.T) {
// Simple CASE (with an operand) must round-trip too.
src := "select case a when 1 then 'one' when 2 then 'two' else 'other' end from t;"
want := "SELECT CASE a WHEN 1 THEN 'one' WHEN 2 THEN 'two' ELSE 'other' END\nFROM t;\n"
got := format(src)
if got != want {
t.Errorf("simple case\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
checkDML(t, "simple case", got)
}
// --- record_space_before_paren ---
func TestDMLRecordSpaceBeforeParen(t *testing.T) {
src := "select row(1, 2) from t;"
got := format(src)
want := "SELECT ROW(1, 2)\nFROM t;\n"
if got != want {
t.Errorf("row() default\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
st := config.Default()
st.RecordSpaceBeforeParen = true
gotSpaced := File(parser.Parse(src), st)
wantSpaced := "SELECT ROW (1, 2)\nFROM t;\n"
if gotSpaced != wantSpaced {
t.Errorf("row() space_before_paren\n--- got ---\n%s\n--- want ---\n%s", gotSpaced, wantSpaced)
}
twice := File(parser.Parse(gotSpaced), st)
if twice != gotSpaced {
t.Errorf("row() space_before_paren not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", gotSpaced, twice)
}
}
func TestWhereWhenLong(t *testing.T) {
src := "select a from t where a = 1 and b = 2"
st := config.Default()
st.WhereWrap = config.WrapWhenLong
st.LineWidth = 80
short := File(parser.Parse(src), st)
if !strings.Contains(short, "WHERE a = 1 AND b = 2") {
t.Errorf("short clause should stay on one line:\n%s", short)
}
st.LineWidth = 12
long := File(parser.Parse(src), st)
if strings.Contains(long, "WHERE a = 1") || !strings.Contains(long, "AND b = 2") {
t.Errorf("long clause should break per condition:\n%s", long)
}
st.WhereWrap = config.WrapAlways
want := File(parser.Parse(src), st)
if long != want {
t.Errorf("broken when_long should equal always:\n%s\n---\n%s", long, want)
}
}
func TestDMLCaseCollapseUsesLineWidth(t *testing.T) {
st := config.Default()
st.CaseWhenWrap = true
st.CaseCollapse = true
src := "select case when a = 1 then 'one' when a = 2 then 'two' else 'other' end from t;"
st.LineWidth = 120
if got := File(parser.Parse(src), st); strings.Contains(got, "\n WHEN") {
t.Errorf("fits in 120, should collapse:\n%s", got)
}
st.LineWidth = 30
if got := File(parser.Parse(src), st); !strings.Contains(got, "\n WHEN") {
t.Errorf("exceeds 30, should wrap:\n%s", got)
}
}
func TestSelectWrap(t *testing.T) {
src := "select a, b, c from t"
st := config.Default()
if got := File(parser.Parse(src), st); !strings.Contains(got, "\n ,b") {
t.Errorf("default select_wrap=always should list one per line:\n%s", got)
}
st.SelectWrap = config.WrapNever
if got := File(parser.Parse(src), st); !strings.Contains(got, "SELECT a, b, c") {
t.Errorf("never:\n%s", got)
}
st.SelectWrap = config.WrapWhenLong
st.LineWidth = 120
if got := File(parser.Parse(src), st); !strings.Contains(got, "SELECT a, b, c") {
t.Errorf("when_long, short:\n%s", got)
}
st.LineWidth = 8
if got := File(parser.Parse(src), st); !strings.Contains(got, "\n ,b") {
t.Errorf("when_long, long:\n%s", got)
}
}
func TestJoinWrap(t *testing.T) {
src := "select a from t join u on t.id = u.id and t.x = u.x"
st := config.Default()
base := File(parser.Parse(src), st)
if !strings.Contains(base, "JOIN u ON t.id = u.id AND t.x = u.x") {
t.Fatalf("default join_wrap=never should stay inline:\n%s", base)
}
st.JoinWrap = config.WrapAlways
got := File(parser.Parse(src), st)
if !strings.Contains(got, "JOIN u\n ON t.id = u.id\n AND t.x = u.x") {
t.Errorf("always:\n%s", got)
}
if twice := File(parser.Parse(got), st); twice != got {
t.Errorf("not idempotent:\n%s\n---\n%s", got, twice)
}
st.JoinWrap = config.WrapWhenLong
st.LineWidth = 120
if File(parser.Parse(src), st) != base {
t.Errorf("when_long short should equal inline")
}
st.LineWidth = 20
if File(parser.Parse(src), st) != got {
t.Errorf("when_long long should equal always")
}
}
func TestSelectWrapBrokenHonoursCommaStyle(t *testing.T) {
src := "select a, b, c from t"
st := config.Default()
st.SelectWrap = config.WrapWhenLong
st.LineWidth = 8
st.Commas = config.CommaTrailing
got := File(parser.Parse(src), st)
if !strings.Contains(got, " a,\n") || strings.Contains(got, "\n ,b") {
t.Errorf("trailing commas expected when broken:\n%s", got)
}
st.Commas = config.CommaLeading
if got := File(parser.Parse(src), st); !strings.Contains(got, "\n ,b") {
t.Errorf("leading commas expected when broken:\n%s", got)
}
}
func TestMultiCTETrailingCommas(t *testing.T) {
src := "with a as (select 1), b as (select 2) select * from a, b"
st := config.Default()
st.Commas = config.CommaTrailing
got := File(parser.Parse(src), st)
if !strings.Contains(got, "a AS (") || !strings.Contains(got, "),\n") {
t.Errorf("trailing comma between CTEs expected:\n%s", got)
}
if err := VerifySafe(src, got, st); err != nil {
t.Errorf("safety gate: %v\n%s", err, got)
}
st.Commas = config.CommaLeading
got = File(parser.Parse(src), st)
if !strings.Contains(got, ",b AS (") {
t.Errorf("leading comma expected:\n%s", got)
}
}
+184
View File
@@ -0,0 +1,184 @@
package format
import "strings"
// Doc is a Wadler/Prettier-style layout document. Build one from Text, Line,
// SoftLine, Group, Indent, IfBreak and Concat, then Render it at a line width:
// a Group is printed flat (Lines become spaces, SoftLines vanish) when it fits
// in the remaining width, otherwise broken (Lines become newlines).
//
// Rendering is relative to column 0; callers that splice the result mid-line
// re-indent continuation lines themselves, so width checks ignore that offset.
type Doc interface{ isDoc() }
type (
docText string
docLine struct{ soft, hard bool }
docConcat []Doc
docIndent struct{ d Doc }
docGroup struct{ d Doc }
docBreak struct{ broken, flat Doc }
)
func (docText) isDoc() {}
func (docLine) isDoc() {}
func (docConcat) isDoc() {}
func (docIndent) isDoc() {}
func (docGroup) isDoc() {}
func (docBreak) isDoc() {}
// Text is literal text. If it contains a newline the enclosing group can never
// be printed flat.
func Text(s string) Doc { return docText(s) }
// Line is a space when flat and a newline (plus indentation) when broken.
func Line() Doc { return docLine{} }
// SoftLine is nothing when flat and a newline (plus indentation) when broken.
func SoftLine() Doc { return docLine{soft: true} }
// HardLine is always a newline (plus indentation), even inside a flat group; a
// group containing one can never be printed flat.
func HardLine() Doc { return docLine{hard: true} }
// Lines turns multi-line text into Text pieces joined by HardLines so that each
// line picks up the surrounding Indent. Empty lines carry no indentation.
func Lines(s, nl string) Doc {
parts := strings.Split(s, nl)
ds := make([]Doc, 0, 2*len(parts))
for i, p := range parts {
if i > 0 {
ds = append(ds, HardLine())
}
ds = append(ds, Text(p))
}
return Concat(ds...)
}
// Concat joins docs in order.
func Concat(ds ...Doc) Doc { return docConcat(ds) }
// Indent indents every line break inside d by one indent unit.
func Indent(d Doc) Doc { return docIndent{d} }
// Group lays d out flat if it fits on the current line, otherwise broken.
func Group(d Doc) Doc { return docGroup{d} }
// IfBreak renders broken when the enclosing group is broken, flat otherwise.
func IfBreak(broken, flat Doc) Doc { return docBreak{broken, flat} }
type docCmd struct {
indent string
flat bool
d Doc
}
// Render lays out d. width <= 0 means unlimited (every group stays flat).
func Render(d Doc, width int, unit, nl string) string {
var b strings.Builder
col := 0
pending := "" // indentation owed to the next non-empty text
stack := []docCmd{{"", false, d}}
for len(stack) > 0 {
c := stack[len(stack)-1]
stack = stack[:len(stack)-1]
switch v := c.d.(type) {
case docText:
if len(v) > 0 {
b.WriteString(pending)
pending = ""
}
b.WriteString(string(v))
if i := strings.LastIndexByte(string(v), '\n'); i >= 0 {
col = len(v) - i - 1
} else {
col += len(v)
}
case docConcat:
for i := len(v) - 1; i >= 0; i-- {
stack = append(stack, docCmd{c.indent, c.flat, v[i]})
}
case docIndent:
stack = append(stack, docCmd{c.indent + unit, c.flat, v.d})
case docBreak:
if c.flat {
stack = append(stack, docCmd{c.indent, c.flat, v.flat})
} else {
stack = append(stack, docCmd{c.indent, c.flat, v.broken})
}
case docLine:
if c.flat && !v.hard {
if !v.soft {
b.WriteByte(' ')
col++
}
break
}
b.WriteString(nl)
pending = c.indent
col = len(c.indent)
case docGroup:
flat := c.flat || width <= 0 || fitsFlat(v.d, width-col, c.indent, unit, stack)
stack = append(stack, docCmd{c.indent, flat, v.d})
}
}
return b.String()
}
// fitsFlat reports whether d, printed flat, plus whatever follows it up to the
// next possible line break, fits in rem columns.
func fitsFlat(d Doc, rem int, indent, unit string, rest []docCmd) bool {
if rem < 0 {
return false
}
type item struct {
d Doc
flat bool
}
work := []item{{d, true}}
ri := len(rest) - 1
for rem >= 0 {
if len(work) == 0 {
if ri < 0 {
return true
}
work = append(work, item{rest[ri].d, rest[ri].flat})
ri--
continue
}
it := work[len(work)-1]
work = work[:len(work)-1]
switch v := it.d.(type) {
case docText:
if strings.ContainsRune(string(v), '\n') {
return false
}
rem -= len(v)
case docConcat:
for i := len(v) - 1; i >= 0; i-- {
work = append(work, item{v[i], it.flat})
}
case docIndent:
work = append(work, item{v.d, it.flat})
case docGroup:
work = append(work, item{v.d, it.flat})
case docBreak:
if it.flat {
work = append(work, item{v.flat, it.flat})
} else {
work = append(work, item{v.broken, it.flat})
}
case docLine:
if v.hard {
return false
}
if !it.flat {
return true // a real line break ends the measured run
}
if !v.soft {
rem--
}
}
}
return false
}
+47
View File
@@ -0,0 +1,47 @@
package format
import "testing"
func TestDocGroupFlatWhenFits(t *testing.T) {
d := Group(Concat(Text("WHERE"), Indent(Concat(Line(), Text("a = 1"), Line(), Text("AND b = 2")))))
if got, want := Render(d, 80, " ", "\n"), "WHERE a = 1 AND b = 2"; got != want {
t.Errorf("flat: got %q want %q", got, want)
}
if got, want := Render(d, 10, " ", "\n"), "WHERE\n a = 1\n AND b = 2"; got != want {
t.Errorf("broken: got %q want %q", got, want)
}
if got, want := Render(d, 0, " ", "\n"), "WHERE a = 1 AND b = 2"; got != want {
t.Errorf("unlimited: got %q want %q", got, want)
}
}
func TestDocNewlineInTextForcesBreak(t *testing.T) {
d := Group(Concat(Text("x"), Line(), Text("(\n y)")))
if got, want := Render(d, 80, " ", "\n"), "x\n(\n y)"; got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestDocIfBreakAndSoftLine(t *testing.T) {
d := Group(Concat(Text("f("), Indent(Concat(SoftLine(), Text("a, b"))), SoftLine(), IfBreak(Text(" -- long"), Text(""))))
if got, want := Render(d, 80, " ", "\n"), "f(a, b"; got != want {
t.Errorf("flat: got %q want %q", got, want)
}
if got, want := Render(d, 3, " ", "\n"), "f(\n a, b\n -- long"; got != want {
t.Errorf("broken: got %q want %q", got, want)
}
}
func TestDocTrailingTextCountsTowardFit(t *testing.T) {
d := Concat(Group(Concat(Text("aaa"), Line(), Text("bbb"))), Text(";;;;"))
if got, want := Render(d, 10, " ", "\n"), "aaa\nbbb;;;;"; got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestDocHardLineForcesBreakAndSkipsBlankIndent(t *testing.T) {
d := Group(Concat(Text("("), Indent(Concat(HardLine(), Lines("a\n\nb", "\n"))), HardLine(), Text(")")))
if got, want := Render(d, 0, " ", "\n"), "(\n a\n\n b\n)"; got != want {
t.Errorf("got %q want %q", got, want)
}
}
+93 -10
View File
@@ -55,7 +55,7 @@ func (p *printer) writeItem(n cst.Node) {
case *cst.Raw:
switch {
case isDMLStart(v.Toks):
p.b.WriteString(formatDML(v.Toks, p.st))
p.b.WriteString(restoreLiteralNewlines(formatDML(v.Toks, p.st)))
case isDoBlock(v.Toks):
p.b.WriteString(formatDoBlock(v.Toks, p.st))
default:
@@ -82,8 +82,10 @@ func isDoBlock(toks []cst.Tok) bool {
// formatDoBlock formats a DO $$ ... $$ block by applying formatBody to the
// dollar-quoted string and emitting DO + newline + formatted body.
func formatDoBlock(toks []cst.Tok, st config.Style) string {
// Find the DO keyword, the dollar-string body, and the optional semicolon.
// Find the DO keyword, the dollar-string body, any other clause tokens
// (LANGUAGE x, before or after the body), and the optional semicolon.
var doTok, bodyTok *cst.Tok
var pre, post []string
hasSemi := false
for i := range toks {
t := &toks[i]
@@ -101,6 +103,21 @@ func formatDoBlock(toks []cst.Tok, st config.Style) string {
}
if t.Tok.Kind == lexer.Semicolon {
hasSemi = true
continue
}
// Clause tokens other than DO / body / ';' (e.g. LANGUAGE plpython3u).
// Any comment attached to one means we cannot safely relocate it.
if len(t.Comments()) > 0 {
return verbatimSpanFormatBody(toks, bodyTok, st)
}
txt := t.Tok.Text
if low == "language" {
txt = applyCase(txt, st.KeywordCase)
}
if bodyTok == nil {
pre = append(pre, txt)
} else {
post = append(post, txt)
}
}
if doTok == nil || bodyTok == nil {
@@ -110,8 +127,20 @@ func formatDoBlock(toks []cst.Tok, st config.Style) string {
nl := st.Newline
var b strings.Builder
b.WriteString(applyCase(doTok.Tok.Text, st.KeywordCase))
if len(pre) > 0 {
b.WriteString(" ")
b.WriteString(strings.Join(pre, " "))
}
b.WriteString(nl)
b.WriteString(formatBody(bodyTok.Tok.Text, st))
if isPlpgsql(toks) {
b.WriteString(formatBody(bodyTok.Tok.Text, st))
} else {
b.WriteString(bodyTok.Tok.Text)
}
if len(post) > 0 {
b.WriteString(nl)
b.WriteString(strings.Join(post, " "))
}
if hasSemi {
b.WriteString(";")
}
@@ -147,8 +176,9 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
paramTexts = alignParamTypes(paramTexts)
}
first := p.st.Indent + " " // align item text one column past the comma
cont := p.st.Indent
// House style: first parameter indented one level; leading-comma
// continuation lines carry the comma at column 0 followed by one space.
first := p.st.Indent
for i, text := range paramTexts {
param := cf.Params[i]
p.nl()
@@ -159,8 +189,7 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
p.b.WriteString(",")
}
} else {
p.b.WriteString(cont)
p.b.WriteString(",")
p.b.WriteString(", ")
p.b.WriteString(text)
}
// Emit trailing inline comment from the separator (e.g. --description after param).
@@ -179,6 +208,7 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
for _, clause := range cf.Options {
p.nl()
p.b.WriteString(p.st.Indent)
p.b.WriteString(p.inline(clause))
}
if cf.As != nil {
@@ -192,7 +222,11 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
}
if cf.Body != nil {
p.nl()
p.b.WriteString(formatBody(cf.Body.Tok.Text, p.st))
if isPlpgsql(cst.Tokens(cf)) {
p.b.WriteString(formatBody(cf.Body.Tok.Text, p.st))
} else {
p.b.WriteString(cf.Body.Tok.Text)
}
}
for _, clause := range cf.Tail {
p.nl()
@@ -218,7 +252,7 @@ func (p *printer) inline(toks []cst.Tok) string {
}
var b strings.Builder
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(' ')
}
var prev lexer.Token
@@ -254,6 +288,31 @@ func (p *printer) trailingComments(lead cst.Trivia) {
// tightOps are operators printed without surrounding spaces.
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
// introduce a subquery or a bracketed clause, not a function-call argument list.
var parenKws = map[string]bool{
@@ -419,7 +478,7 @@ func verbatimSpanFormatBody(toks []cst.Tok, bodyTok *cst.Tok, st config.Style) s
b.WriteString(tr.Text)
}
}
if bodyTok != nil && t.Tok.Kind == lexer.DollarString && t.Tok.Off == bodyTok.Tok.Off {
if bodyTok != nil && t.Tok.Kind == lexer.DollarString && t.Tok.Off == bodyTok.Tok.Off && isPlpgsql(toks) {
b.WriteString(formatBody(t.Tok.Text, st))
} else {
b.WriteString(t.Tok.Text)
@@ -520,3 +579,27 @@ func alignParamTypes(params []string) []string {
}
return out
}
// isPlpgsql reports whether the routine's body should be restyled. Bodies in
// any pl* language other than plpgsql (plpython3u, plperl, pltcl, ...), and in
// c or internal, are emitted verbatim; plpgsql, sql and everything else are
// formatted. A routine
// with no LANGUAGE clause is treated as sql and formatted.
func isPlpgsql(toks []cst.Tok) bool {
var sig []cst.Tok
for _, t := range toks {
if !t.Tok.IsTrivia() && t.Tok.Kind != lexer.EOF {
sig = append(sig, t)
}
}
for i := 0; i+1 < len(sig); i++ {
if sig[i].Is("language") {
name := strings.ToLower(strings.Trim(sig[i+1].Tok.Text, "'\""))
if name == "c" || name == "internal" {
return false
}
return !strings.HasPrefix(name, "pl") || name == "plpgsql"
}
}
return true
}
+97 -17
View File
@@ -23,13 +23,13 @@ func TestFormatHeaderGolden(t *testing.T) {
want := "--select * from dropall('resolvespec_login');\n" +
"CREATE OR REPLACE FUNCTION resolvespec_login(\n" +
" INOUT p_data jsonb\n" +
" ,OUT p_success boolean\n" +
" ,OUT p_error text\n" +
" INOUT p_data jsonb\n" +
", OUT p_success boolean\n" +
", OUT p_error text\n" +
")\n" +
"LANGUAGE plpgsql\n" +
"VOLATILE\n" +
"SECURITY DEFINER\n" +
" LANGUAGE plpgsql\n" +
" VOLATILE\n" +
" SECURITY DEFINER\n" +
"AS\n" +
"$$\nbegin end;\n$$;\n"
@@ -130,8 +130,8 @@ func TestFormatIssue1PLpgSQLIndenting(t *testing.T) {
want := "CREATE FUNCTION f(\n" +
")\n" +
"RETURNS void\n" +
"LANGUAGE plpgsql\n" +
" RETURNS void\n" +
" LANGUAGE plpgsql\n" +
"AS\n" +
"$$\n" +
"DECLARE\n" +
@@ -144,14 +144,14 @@ func TestFormatIssue1PLpgSQLIndenting(t *testing.T) {
" set status = 'done'\n" +
" where\n" +
" u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'done';\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" +
" and nv(u.status) <> 'open';\n" +
"\n" +
" end if;\n" +
"$$;\n"
@@ -184,21 +184,101 @@ func TestCorpusIdempotentAndSafe(t *testing.T) {
}
src := string(data)
once := format(src)
twice := format(once)
if once != twice {
t.Errorf("%s: not idempotent", e.Name())
}
if !semanticallyEqual(src, once) {
t.Errorf("%s: formatting changed semantics", e.Name())
// VerifySafe bundles every runtime gate: semantic equivalence, comment
// preservation, structural balance, and idempotence.
if err := VerifySafe(src, once, config.Default()); err != nil {
t.Errorf("%s: %v", e.Name(), err)
}
}
if seen == 0 {
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 is a test-local alias for the exported safety check.
func semanticallyEqual(a, b string) bool {
return SemanticallyEqual(a, b)
}
func TestNonPlpgsqlBodyVerbatim(t *testing.T) {
body := "$$\ndeclare x = 1\nbegin = 2\n$$"
src := "create function f() returns void as " + body + " language plpython3u;"
if out := format(src); !strings.Contains(out, body) {
t.Fatalf("plpython3u body was modified:\n%s", out)
}
}
func TestLanguageBodyGate(t *testing.T) {
body := "$$\ndeclare x int:=1;\nbegin\nnull;\nend\n$$"
for lang, verbatim := range map[string]bool{"plpgsql": false, "sql": false, "plpython3u": true, "plperl": true, "pltcl": true, "c": true, "internal": true} {
out := format("create function f() returns void as " + body + " language " + lang + ";")
if got := strings.Contains(out, body); got != verbatim {
t.Errorf("language %s: verbatim=%v, want %v\n%s", lang, got, verbatim, out)
}
}
}
func TestDoBlockLanguageGate(t *testing.T) {
body := "$$\ndeclare x int:=1;\nbegin\nnull;\nend\n$$"
for src, verbatim := range map[string]bool{
"do " + body + ";": false,
"do language plpgsql " + body + ";": false,
"do " + body + " language plpgsql;": false,
"do language plpython3u " + body + ";": true,
"do " + body + " language plperl;": true,
} {
if out := format(src); strings.Contains(out, body) != verbatim {
t.Errorf("%q: verbatim=%v, want %v\n%s", src, !verbatim, verbatim, out)
}
}
}
func TestDoBlockKeepsLanguageClause(t *testing.T) {
for _, src := range []string{
"do language plpython3u $$\nx = 1\n$$;",
"do $$\nx = 1\n$$ language plpython3u;",
} {
if out := format(src); !strings.Contains(strings.ToLower(out), "language plpython3u") {
t.Errorf("LANGUAGE clause lost:\n%s", out)
}
}
}
func TestMultiLineStringLiteralVerbatim(t *testing.T) {
src := "create function f() returns void language plpgsql as $$\nDECLARE\n x int;\nBEGIN\n if x = 1 then\n raise exception E'A client\nTo resolve this', NEW.id;\n end if;\nEND\n$$;"
if out := format(src); !strings.Contains(out, "E'A client\nTo resolve this'") {
t.Errorf("multi-line string was reindented:\n%s", out)
}
}
func TestCommentBeforeBeginKept(t *testing.T) {
src := "create function f() returns void language plpgsql as $$\nDECLARE\n x int = 1; --0=old, 1=new\nBEGIN\n null;\nEND\n$$;"
out := format(src)
if !strings.Contains(out, "--0=old, 1=new") {
t.Fatalf("comment before BEGIN lost:\n%s", out)
}
if again := format(out); again != out {
t.Fatalf("not idempotent:\n%s\n---\n%s", out, again)
}
}
func TestEmbeddedDollarLiterals(t *testing.T) {
body := func(lit string) string {
return "create function f() returns void language plpgsql as $$\nDECLARE\n x text;\nBEGIN\n x = " + lit + ";\nEND\n$$;"
}
// format() template: must be untouched.
tmpl := "format($Q$select %s, %3$s from t$Q$, a)"
if out := format(body(tmpl)); !strings.Contains(out, "$Q$select %s, %3$s from t$Q$") {
t.Errorf("placeholder template modified:\n%s", out)
}
// SQL fragment (not a statement): untouched.
frag := "$s$ and (a=1) or b=2 $s$"
if out := format(body(frag)); !strings.Contains(out, frag) {
t.Errorf("fragment modified:\n%s", out)
}
// Statement: formatted as code.
if out := format(body("$q$select a,b from t where x=1$q$")); strings.Contains(out, "$q$select a,b from t where x=1$q$") {
t.Errorf("embedded select was not formatted:\n%s", out)
}
}
+201 -3
View File
@@ -1,11 +1,52 @@
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.
@@ -14,9 +55,9 @@ import (
// 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 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.
// 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)
@@ -32,6 +73,14 @@ func SemanticallyEqual(a, b string) bool {
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)
@@ -47,6 +96,155 @@ func SemanticallyEqual(a, b string) bool {
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 {
+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
@@ -93,6 +93,13 @@ func (e *Engine) Check(sql, file string) ([]diagnostics.Diagnostic, error) {
all = append(all, found...)
}
bodies := e.checkBodies(sql, result.Stmts)
bodies = append(bodies, e.checkEmbeddedLiterals(sql, result.Stmts)...)
for i := range bodies {
bodies[i].File = file
}
all = append(all, bodies...)
sort.Slice(all, func(i, j int) bool {
if all[i].Line != all[j].Line {
return all[i].Line < all[j].Line
+51
View File
@@ -123,3 +123,54 @@ func TestCustomEngine(t *testing.T) {
}
_ = diags
}
func TestPlpgsqlBodyLint(t *testing.T) {
src := "create function f() returns void language plpgsql as $$\nbegin\n perform 1;\n select * from t;\nend;\n$$;\n" +
"create function g() returns void language plpgsql as $$\nbegin\n selec 1;\nend;\n$$;\n" +
"create function p() returns void language plpython3u as $$\nselect * from nothing\n$$;\n" +
"do $$ begin select * from t; end $$;\n"
diags, err := lint.New().Check(src, "x.sql")
if err != nil {
t.Fatal(err)
}
var cor001, pl []diagnostics.Diagnostic
for _, d := range diags {
switch d.RuleID {
case "COR001":
cor001 = append(cor001, d)
case "PLPGSQL":
pl = append(pl, d)
}
}
if len(cor001) != 2 || cor001[0].Line != 4 || cor001[0].Col != 10 || cor001[1].Line != 15 {
t.Errorf("COR001 in bodies: %+v", cor001)
}
for _, d := range cor001 {
if d.Fix != nil {
t.Errorf("embedded diagnostic must not carry fixes: %+v", d)
}
}
if len(pl) != 1 || pl[0].Line != 9 || pl[0].Col != 3 {
t.Errorf("PLPGSQL syntax error: %+v", pl)
}
}
func TestEmbeddedDollarSQLLint(t *testing.T) {
src := "create function f() returns void language plpgsql as $$\nbegin\n execute $q$\n select * from t\n $q$;\nend;\n$$;\n" +
"create function s() returns int language sql as $$ select * from u $$;\n" +
"create function p() returns void language plpython3u as $$\nselect * from nothing\n$$;\n" +
"select format($f$select %I from t$f$, 'a');\n"
diags, err := lint.New().Check(src, "x.sql")
if err != nil {
t.Fatal(err)
}
var got [][2]int
for _, d := range diags {
if d.RuleID == "COR001" {
got = append(got, [2]int{d.Line, d.Col})
}
}
if len(got) != 2 || got[0] != [2]int{4, 12} || got[1] != [2]int{8, 59} {
t.Errorf("COR001 positions: %v", got)
}
}
+332
View File
@@ -0,0 +1,332 @@
package lint
import (
"encoding/json"
"regexp"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
waspg "github.com/wasilibs/go-pgquery"
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
)
// checkBodies lints the PL/pgSQL bodies of CREATE FUNCTION/PROCEDURE and DO
// statements. The top-level parse only sees a body as an opaque string, so
// each plpgsql routine is parsed with libpg_query's PL/pgSQL parser: syntax
// errors are reported as PLPGSQL, and every embedded SQL statement is run
// through the same rules as top-level SQL, with locations mapped back to the
// original file. Autofixes are dropped for embedded statements because their
// byte offsets do not map back reliably.
func (e *Engine) checkBodies(sql string, stmts []*pg_query.RawStmt) []diagnostics.Diagnostic {
var out []diagnostics.Diagnostic
for _, raw := range stmts {
text, startOff, ok := plpgsqlRoutineText(sql, raw)
if !ok {
continue
}
startLine, _ := pgast.LocationToLineCol(sql, startOff)
js, err := waspg.ParsePlPgSqlToJSON(text)
if err != nil {
line, col := startLine, 1
// The PL/pgSQL parser gives no usable error position, so locate the
// offending token ("syntax error at or near "x"") inside the body.
if m := nearTokenRe.FindStringSubmatch(err.Error()); m != nil {
if loc := dollarOpenRe.FindStringIndex(text); loc != nil {
re := regexp.MustCompile(`(?:^|[^A-Za-z0-9_$])(` + regexp.QuoteMeta(m[1]) + `)(?:[^A-Za-z0-9_$]|$)`)
if g := re.FindStringSubmatchIndex(text[loc[1]:]); g != nil {
l, c := pgast.LocationToLineCol(text, loc[1]+g[2])
line, col = startLine+l-1, c
if l == 1 {
_, c0 := pgast.LocationToLineCol(sql, startOff)
col = c0 + c - 1
}
}
}
}
out = append(out, diagnostics.Diagnostic{
RuleID: "PLPGSQL",
Severity: diagnostics.SeverityError,
Message: err.Error(),
Line: line,
Col: col,
})
continue
}
var tree any
if json.Unmarshal([]byte(js), &tree) != nil {
continue
}
fileLines := strings.Split(sql, "\n")
for _, q := range collectBodyQueries(tree, 0, nil) {
out = append(out, e.checkEmbedded(q, startLine, fileLines)...)
}
}
return out
}
var (
nearTokenRe = regexp.MustCompile(`at or near "([^"]+)"`)
dollarOpenRe = regexp.MustCompile(`\$[A-Za-z_0-9]*\$`)
)
// plpgsqlRoutineText returns the text to hand to the PL/pgSQL parser for a
// plpgsql CREATE FUNCTION/PROCEDURE or DO statement, and the byte offset in sql
// that text starts at (so line numbers in the parse result are relative to it).
func plpgsqlRoutineText(sql string, raw *pg_query.RawStmt) (text string, off int, ok bool) {
if raw.Stmt == nil {
return "", 0, false
}
start := int(raw.StmtLocation)
end := len(sql)
if raw.StmtLen > 0 {
end = start + int(raw.StmtLen)
}
if start < 0 || end > len(sql) || start > end {
return "", 0, false
}
switch n := raw.Stmt.GetNode().(type) {
case *pg_query.Node_CreateFunctionStmt:
lang := "plpgsql"
sawLang := false
for _, o := range n.CreateFunctionStmt.Options {
d := o.GetDefElem()
if d != nil && d.Defname == "language" {
lang, sawLang = strings.ToLower(d.Arg.GetString_().GetSval()), true
}
}
if sawLang && lang != "plpgsql" {
return "", 0, false
}
for start < end && strings.ContainsRune(" \t\r\n", rune(sql[start])) {
start++
}
return sql[start:end], start, true
case *pg_query.Node_DoStmt:
lang, body := "plpgsql", ""
for _, a := range n.DoStmt.Args {
d := a.GetDefElem()
if d == nil {
continue
}
switch d.Defname {
case "language":
lang = strings.ToLower(d.Arg.GetString_().GetSval())
case "as":
body = d.Arg.GetString_().GetSval()
}
}
if lang != "plpgsql" || body == "" || strings.Contains(body, "$pgtidy$") {
return "", 0, false
}
// Locate the body text in the source so line numbers map back.
i := strings.Index(sql[start:end], body)
if i < 0 {
return "", 0, false
}
return "CREATE FUNCTION pgtidy_do() RETURNS void LANGUAGE plpgsql AS $pgtidy$" + body + "$pgtidy$", start + i, true
}
return "", 0, false
}
// bodyQuery is one embedded SQL statement found in a PL/pgSQL parse tree.
type bodyQuery struct {
query string
line int // 1-based line, relative to the routine text, of the owning statement
}
// collectBodyQueries walks the PL/pgSQL JSON tree and returns every embedded
// full SQL statement (PLpgSQL_expr with parseMode 0), tagged with the line of
// the nearest enclosing PL/pgSQL statement.
func collectBodyQueries(n any, line int, acc []bodyQuery) []bodyQuery {
switch v := n.(type) {
case map[string]any:
if ln, ok := v["lineno"].(float64); ok && ln > 0 {
line = int(ln)
}
if ex, ok := v["PLpgSQL_expr"].(map[string]any); ok {
if pm, _ := ex["parseMode"].(float64); pm == 0 {
if q, ok := ex["query"].(string); ok && strings.TrimSpace(q) != "" {
acc = append(acc, bodyQuery{query: q, line: line})
}
}
}
for _, c := range v {
acc = collectBodyQueries(c, line, acc)
}
case []any:
for _, c := range v {
acc = collectBodyQueries(c, line, acc)
}
}
return acc
}
// checkEmbedded runs the rules over one embedded statement and maps the
// findings back to file positions.
func (e *Engine) checkEmbedded(q bodyQuery, routineStartLine int, fileLines []string) []diagnostics.Diagnostic {
res, err := pgast.Parse(q.query)
if err != nil {
return nil // PL/pgSQL already accepted it; variables etc. can trip the SQL parser
}
fileLine := routineStartLine + q.line - 1
// Column of the statement's first token on its file line.
base := 0
if fileLine-1 < len(fileLines) {
first := strings.Fields(q.query)
if len(first) > 0 {
if i := strings.Index(strings.ToLower(fileLines[fileLine-1]), strings.ToLower(first[0])); i >= 0 {
base = i
}
}
}
// The query keeps the statement's own leading whitespace out; count it so
// columns on its first line line up.
lead := len(q.query) - len(strings.TrimLeft(q.query, " \t\r\n"))
var out []diagnostics.Diagnostic
for _, r := range e.rules {
for _, d := range r.Check(res.Stmts, q.query) {
d.Fix = nil
if d.Line <= 1 {
d.Col = base + d.Col - lead
if d.Col < 1 {
d.Col = 1
}
}
d.Line = fileLine + d.Line - 1
out = append(out, d)
}
}
return out
}
// checkEmbeddedLiterals lints SQL held in dollar-quoted strings: anything whose
// content starts with SELECT/INSERT/UPDATE/DELETE/WITH (the same "looks like
// code" test the formatter uses), e.g. EXECUTE $q$ select … $q$ or a
// LANGUAGE sql function body. Literals are searched recursively, including
// inside PL/pgSQL bodies. Bodies of non-sql, non-plpgsql routines are opaque
// and skipped, as are format()-style templates.
func (e *Engine) checkEmbeddedLiterals(sql string, stmts []*pg_query.RawStmt) []diagnostics.Diagnostic {
if !strings.Contains(sql, "$") {
return nil
}
var opaque [][2]int
for _, raw := range stmts {
if raw.Stmt == nil {
continue
}
lang, has := "", false
switch n := raw.Stmt.GetNode().(type) {
case *pg_query.Node_CreateFunctionStmt:
for _, o := range n.CreateFunctionStmt.Options {
if d := o.GetDefElem(); d != nil && d.Defname == "language" {
lang, has = strings.ToLower(d.Arg.GetString_().GetSval()), true
}
}
case *pg_query.Node_DoStmt:
for _, a := range n.DoStmt.Args {
if d := a.GetDefElem(); d != nil && d.Defname == "language" {
lang, has = strings.ToLower(d.Arg.GetString_().GetSval()), true
}
}
default:
continue
}
if has && lang != "sql" && lang != "plpgsql" {
s := int(raw.StmtLocation)
end := len(sql)
if raw.StmtLen > 0 {
end = s + int(raw.StmtLen)
}
opaque = append(opaque, [2]int{s, end})
}
}
var out []diagnostics.Diagnostic
var walk func(text string, base int)
walk = func(text string, base int) {
for _, t := range lexer.Lex(text) {
if t.Kind != lexer.DollarString {
continue
}
abs := base + t.Off
skip := false
for _, r := range opaque {
if abs >= r[0] && abs < r[1] {
skip = true
}
}
if skip {
continue
}
inner, innerOff, ok := dollarInner(t.Text)
if !ok {
continue
}
if looksLikeSQL(inner) && !formatPlaceholderRe.MatchString(inner) {
out = append(out, e.checkLiteral(sql, inner, abs+innerOff)...)
}
walk(inner, abs+innerOff)
}
}
walk(sql, 0)
return out
}
var formatPlaceholderRe = regexp.MustCompile(`%(\d+\$)?-?\d*[sILlx]`)
// dollarInner splits a dollar-quoted literal into its content and the content's
// offset within the literal.
func dollarInner(lit string) (inner string, off int, ok bool) {
if len(lit) < 4 || lit[0] != '$' {
return "", 0, false
}
e := strings.IndexByte(lit[1:], '$')
if e < 0 {
return "", 0, false
}
tag := lit[:e+2]
if !strings.HasSuffix(lit, tag) || len(lit) < 2*len(tag) {
return "", 0, false
}
return lit[len(tag) : len(lit)-len(tag)], len(tag), true
}
func looksLikeSQL(inner string) bool {
for _, t := range lexer.Lex(inner) {
if t.IsTrivia() || t.Kind == lexer.EOF {
continue
}
if t.Kind != lexer.Ident {
return false
}
switch strings.ToLower(t.Text) {
case "select", "insert", "update", "delete", "with":
return true
}
return false
}
return false
}
// checkLiteral lints one SQL string whose first byte sits at file offset off.
func (e *Engine) checkLiteral(sql, inner string, off int) []diagnostics.Diagnostic {
res, err := pgast.Parse(inner)
if err != nil {
return nil
}
startLine, startCol := pgast.LocationToLineCol(sql, off)
var out []diagnostics.Diagnostic
for _, r := range e.rules {
for _, d := range r.Check(res.Stmts, inner) {
d.Fix = nil
if d.Line <= 1 {
d.Col = startCol + d.Col - 1
}
d.Line = startLine + d.Line - 1
out = append(out, d)
}
}
return out
}
+165 -9
View File
@@ -2,7 +2,9 @@
//
// The server communicates over stdio using JSON-RPC 2.0 with Content-Length
// framing. It provides:
// - textDocument/formatting — full-document formatting via pkg/format
// - textDocument/formatting, rangeFormatting, willSaveWaitUntil — formatting via pkg/format
// - textDocument/hover — rule ID + message of the diagnostic under the cursor
// - textDocument/documentSymbol — CREATE FUNCTION / PROCEDURE outline
// - textDocument/publishDiagnostics — lint findings via pkg/lint, sent on
// every didOpen/didChange notification
package lsp
@@ -17,8 +19,10 @@ import (
"strings"
"git.warky.dev/wdevs/pgtidy/pkg/config"
"git.warky.dev/wdevs/pgtidy/pkg/cst"
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"git.warky.dev/wdevs/pgtidy/pkg/format"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
"git.warky.dev/wdevs/pgtidy/pkg/lint"
"git.warky.dev/wdevs/pgtidy/pkg/parser"
)
@@ -30,6 +34,7 @@ func Serve(ctx context.Context, r io.Reader, w io.Writer, startDir string) error
cfg, _ := config.Load(startDir)
srv := &server{
docs: make(map[string]string),
diags: make(map[string][]lspDiagnostic),
fixes: make(map[string][]diagnostics.Diagnostic),
cfg: cfg,
w: w,
@@ -39,6 +44,7 @@ func Serve(ctx context.Context, r io.Reader, w io.Writer, startDir string) error
type server struct {
docs map[string]string // URI → current text
diags map[string][]lspDiagnostic // URI → last published diagnostics
fixes map[string][]diagnostics.Diagnostic // URI → diagnostics that have fixes
cfg config.Style
w io.Writer
@@ -76,10 +82,12 @@ func (s *server) handle(raw []byte) bool {
case "initialize":
s.reply(req.ID, initResult{
Capabilities: serverCaps{
TextDocumentSync: 1, // full sync
TextDocumentSync: syncOptions{OpenClose: true, Change: 1, WillSaveWaitUntil: true}, // full sync
DocumentFormattingProvider: true,
DocumentRangeFormattingProvider: true,
CodeActionProvider: true,
HoverProvider: true,
DocumentSymbolProvider: true,
},
})
case "initialized": // no-op notification
@@ -111,6 +119,18 @@ func (s *server) handle(raw []byte) bool {
URI: p.TextDocument.URI,
Diagnostics: []lspDiagnostic{},
})
case "textDocument/willSaveWaitUntil":
var p formattingParams // only textDocument is used
_ = json.Unmarshal(req.Params, &p)
s.reply(req.ID, s.fullFormatEdits(p.TextDocument.URI))
case "textDocument/hover":
var p positionParams
_ = json.Unmarshal(req.Params, &p)
s.reply(req.ID, s.hover(p.TextDocument.URI, p.Position))
case "textDocument/documentSymbol":
var p formattingParams
_ = json.Unmarshal(req.Params, &p)
s.reply(req.ID, s.documentSymbols(p.TextDocument.URI))
case "textDocument/formatting":
var p formattingParams
_ = json.Unmarshal(req.Params, &p)
@@ -124,7 +144,7 @@ func (s *server) handle(raw []byte) bool {
s.reply(req.ID, []textEdit{})
return false
}
if !format.SemanticallyEqual(text, formatted) {
if err := format.VerifySafe(text, formatted, s.cfg); err != nil {
s.reply(req.ID, []textEdit{})
return false
}
@@ -167,7 +187,7 @@ func (s *server) pushDiagnostics(uri, text string) {
col--
}
lspD := lspDiagnostic{
Range: lspRange{Start: position{line, col}, End: position{line, col + 1}},
Range: lspRange{Start: position{line, col}, End: position{line, col + diagSpan(text, line, col)}},
Severity: severityCode(d.Severity),
Code: d.RuleID,
Source: "pgtidy",
@@ -179,6 +199,7 @@ func (s *server) pushDiagnostics(uri, text string) {
}
}
s.fixes[uri] = fixable
s.diags[uri] = out
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{
URI: uri,
Diagnostics: out,
@@ -228,7 +249,7 @@ func (s *server) rangeFormat(text string, r lspRange) []textEdit {
if formatted == text {
return nil
}
if !format.SemanticallyEqual(text, formatted) {
if err := format.VerifySafe(text, formatted, s.cfg); err != nil {
return nil
}
@@ -432,10 +453,18 @@ type initResult struct {
}
type serverCaps struct {
TextDocumentSync int `json:"textDocumentSync"`
DocumentFormattingProvider bool `json:"documentFormattingProvider"`
DocumentRangeFormattingProvider bool `json:"documentRangeFormattingProvider"`
CodeActionProvider bool `json:"codeActionProvider"`
TextDocumentSync syncOptions `json:"textDocumentSync"`
DocumentFormattingProvider bool `json:"documentFormattingProvider"`
DocumentRangeFormattingProvider bool `json:"documentRangeFormattingProvider"`
CodeActionProvider bool `json:"codeActionProvider"`
HoverProvider bool `json:"hoverProvider"`
DocumentSymbolProvider bool `json:"documentSymbolProvider"`
}
type syncOptions struct {
OpenClose bool `json:"openClose"`
Change int `json:"change"` // 1 = full
WillSaveWaitUntil bool `json:"willSaveWaitUntil"`
}
type textDocItem struct {
@@ -509,3 +538,130 @@ type codeAction struct {
Kind string `json:"kind,omitempty"`
Edit *workspaceEdit `json:"edit,omitempty"`
}
type positionParams struct {
TextDocument textDocID `json:"textDocument"`
Position position `json:"position"`
}
type hoverResult struct {
Contents markupContent `json:"contents"`
Range lspRange `json:"range"`
}
type markupContent struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
type documentSymbol struct {
Name string `json:"name"`
Detail string `json:"detail,omitempty"`
Kind int `json:"kind"`
Range lspRange `json:"range"`
SelectionRange lspRange `json:"selectionRange"`
}
const symbolKindFunction = 12
// fullFormatEdits returns the whole-document formatting edit for uri, or an
// empty list when the document is unknown, already formatted, or the result
// fails the safety gate.
func (s *server) fullFormatEdits(uri string) []textEdit {
text, ok := s.docs[uri]
if !ok {
return []textEdit{}
}
formatted := format.File(parser.Parse(text), s.cfg)
if formatted == text || format.VerifySafe(text, formatted, s.cfg) != nil {
return []textEdit{}
}
return []textEdit{fullReplace(text, formatted)}
}
// diagSpan returns the width in characters of the token starting at the given
// (0-based) line/col, so a diagnostic highlights the offending token rather
// than a single caret. Falls back to 1.
func diagSpan(text string, line, col uint32) uint32 {
off := positionToOffset(text, position{line, col})
for _, t := range lexer.Lex(text) {
if t.Off == off && !t.IsTrivia() && t.Kind != lexer.EOF && !strings.Contains(t.Text, "\n") && len(t.Text) > 0 {
return uint32(len(t.Text))
}
if t.Off > off {
break
}
}
return 1
}
// positionToOffset converts an LSP position to a byte offset in text.
func positionToOffset(text string, p position) int {
off := 0
for line := uint32(0); line < p.Line; line++ {
i := strings.IndexByte(text[off:], '\n')
if i < 0 {
return len(text)
}
off += i + 1
}
off += int(p.Character)
if off > len(text) {
off = len(text)
}
return off
}
// hover describes the diagnostic under the cursor, if any.
func (s *server) hover(uri string, p position) *hoverResult {
for _, d := range s.diags[uri] {
if !rangesOverlap(d.Range.Start, d.Range.End, p, p) || p == d.Range.End {
continue
}
return &hoverResult{
Contents: markupContent{Kind: "markdown", Value: "**" + d.Code + "**\n\n" + d.Message},
Range: d.Range,
}
}
return nil
}
// documentSymbols lists the CREATE FUNCTION / PROCEDURE statements in the document.
func (s *server) documentSymbols(uri string) []documentSymbol {
text, ok := s.docs[uri]
if !ok {
return []documentSymbol{}
}
out := []documentSymbol{}
for _, item := range parser.Parse(text).Items {
cf, ok := item.(*cst.CreateFunction)
if !ok || len(cf.Name) == 0 {
continue
}
all := cst.Tokens(cf)
last := all[len(all)-1].Tok
nameFirst, nameLast := cf.Name[0].Tok, cf.Name[len(cf.Name)-1].Tok
var name strings.Builder
for _, t := range cf.Name {
name.WriteString(t.Tok.Text)
}
detail := "function"
if cf.IsProcedure() {
detail = "procedure"
}
out = append(out, documentSymbol{
Name: name.String(),
Detail: detail,
Kind: symbolKindFunction,
Range: lspRange{
Start: offsetToPosition(text, all[0].Tok.Off),
End: offsetToPosition(text, last.Off+len(last.Text)),
},
SelectionRange: lspRange{
Start: offsetToPosition(text, nameFirst.Off),
End: offsetToPosition(text, nameLast.Off+len(nameLast.Text)),
},
})
}
return out
}
+87
View File
@@ -268,3 +268,90 @@ func TestDidClose_ClearsDiagnostics(t *testing.T) {
t.Errorf("expected empty diagnostics after didClose, got %d", len(lastDiags))
}
}
// request opens text at uri, sends one request, and returns its response
// (skipping the initialize response and any notifications).
func request(t *testing.T, text, method string, params map[string]interface{}) map[string]interface{} {
t.Helper()
uri := "file:///t.sql"
params["textDocument"] = map[string]interface{}{"uri": uri}
var input []byte
input = append(input, frame(1, "initialize", map[string]interface{}{})...)
input = append(input, notifFrame("initialized", nil)...)
input = append(input, notifFrame("textDocument/didOpen", map[string]interface{}{
"textDocument": map[string]interface{}{"uri": uri, "languageId": "sql", "version": 1, "text": text},
})...)
input = append(input, frame(2, method, params)...)
input = append(input, frame(3, "shutdown", nil)...)
input = append(input, notifFrame("exit", nil)...)
out := runServer(t, input)
for i := 0; i < 10; i++ {
resp := readResp(t, out)
if id, ok := resp["id"]; ok && id.(float64) == 2 {
return resp
}
}
t.Fatalf("no response to %s", method)
return nil
}
func TestDocumentSymbol(t *testing.T) {
src := "create function public.foo(a int) returns void language plpgsql as $$ begin end $$;\n\ncreate procedure bar() language plpgsql as $$ begin end $$;\n"
resp := request(t, src, "textDocument/documentSymbol", map[string]interface{}{})
syms := resp["result"].([]interface{})
if len(syms) != 2 {
t.Fatalf("want 2 symbols, got %v", syms)
}
first := syms[0].(map[string]interface{})
if first["name"] != "public.foo" || first["detail"] != "function" {
t.Errorf("first symbol: %v", first)
}
second := syms[1].(map[string]interface{})
if second["name"] != "bar" || second["detail"] != "procedure" {
t.Errorf("second symbol: %v", second)
}
line := second["range"].(map[string]interface{})["start"].(map[string]interface{})["line"].(float64)
if line != 2 {
t.Errorf("bar should start on line 2, got %v", line)
}
}
func TestHoverShowsDiagnostic(t *testing.T) {
resp := request(t, "select * from t;", "textDocument/hover", map[string]interface{}{
"position": map[string]interface{}{"line": 0, "character": 7},
})
res, ok := resp["result"].(map[string]interface{})
if !ok {
t.Fatalf("expected hover result, got %v", resp["result"])
}
val := res["contents"].(map[string]interface{})["value"].(string)
if !strings.Contains(val, "COR001") {
t.Errorf("hover should name the rule, got %q", val)
}
resp = request(t, "select * from t;", "textDocument/hover", map[string]interface{}{
"position": map[string]interface{}{"line": 0, "character": 14},
})
if resp["result"] != nil {
t.Errorf("no diagnostic at col 14, want null, got %v", resp["result"])
}
}
func TestWillSaveWaitUntilFormats(t *testing.T) {
resp := request(t, "select a from t", "textDocument/willSaveWaitUntil", map[string]interface{}{"reason": 1})
edits := resp["result"].([]interface{})
if len(edits) != 1 || !strings.Contains(edits[0].(map[string]interface{})["newText"].(string), "SELECT a") {
t.Errorf("expected a formatting edit, got %v", edits)
}
}
func TestDiagSpanCoversToken(t *testing.T) {
if got := diagSpan("select * from t;", 0, 7); got != 1 {
t.Errorf("'*' span = %d, want 1", got)
}
if got := diagSpan("select foo from t;", 0, 7); got != 3 {
t.Errorf("'foo' span = %d, want 3", got)
}
if got := diagSpan("select foo from t;", 0, 40); got != 1 {
t.Errorf("out-of-range span = %d, want 1", got)
}
}
+115
View File
@@ -0,0 +1,115 @@
// Package updatecheck looks up the latest PgTidy release on the project's
// Gitea instance and compares it against the running version.
package updatecheck
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// DefaultAPIURL is the Gitea endpoint that returns the latest release.
const DefaultAPIURL = "https://git.warky.dev/api/v1/repos/wdevs/pgtidy/releases/latest"
// Asset is a downloadable file attached to a release.
type Asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}
// Release is the subset of the Gitea release payload that is needed.
type Release struct {
Tag string `json:"tag_name"`
URL string `json:"html_url"`
Assets []Asset `json:"assets"`
}
// Latest fetches the latest release from apiURL. A nil client uses a client
// with a 10 second timeout.
func Latest(ctx context.Context, client *http.Client, apiURL string) (*Release, error) {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("checking for updates: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("checking for updates: unexpected status %s", resp.Status)
}
var rel Release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, fmt.Errorf("decoding release: %w", err)
}
if rel.Tag == "" {
return nil, fmt.Errorf("release has no tag")
}
return &rel, nil
}
// FindAsset returns the first asset with the given name.
func (r *Release) FindAsset(name string) (Asset, bool) {
for _, a := range r.Assets {
if a.Name == name {
return a, true
}
}
return Asset{}, false
}
// IsNewer reports whether latest is a higher version than current. Versions
// that are not dotted numbers (for example "dev" or a commit hash) are never
// considered outdated, so development builds are not nagged.
func IsNewer(current, latest string) bool {
cur, ok := parseVersion(current)
if !ok {
return false
}
lat, ok := parseVersion(latest)
if !ok {
return false
}
for i := range cur {
if lat[i] != cur[i] {
return lat[i] > cur[i]
}
}
return false
}
// parseVersion parses "v1.2.3" style versions into three numeric parts.
// Missing parts are zero; any pre-release or build suffix is ignored.
func parseVersion(v string) ([3]int, bool) {
var out [3]int
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
if i := strings.IndexAny(v, "-+ "); i >= 0 {
v = v[:i]
}
if v == "" {
return out, false
}
parts := strings.Split(v, ".")
if len(parts) > 3 {
return out, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return out, false
}
out[i] = n
}
return out, true
}
+94
View File
@@ -0,0 +1,94 @@
package updatecheck
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestIsNewer(t *testing.T) {
tests := []struct {
name string
current, latest string
want bool
}{
{"patch bump", "v1.0.85", "v1.0.86", true},
{"minor beats patch", "v1.0.99", "v1.1.0", true},
{"major bump", "v1.9.9", "v2.0.0", true},
{"numeric not lexical", "v1.0.9", "v1.0.10", true},
{"same", "v1.0.85", "v1.0.85", false},
{"older latest", "v1.0.86", "v1.0.85", false},
{"no v prefix", "1.0.1", "v1.0.2", true},
{"short version", "v1.0", "v1.0.1", true},
{"prerelease suffix ignored", "v1.0.1-rc1", "v1.0.2", true},
{"dev build", "dev", "v9.9.9", false},
{"commit hash", "abc1234", "v9.9.9", false},
{"empty current", "", "v1.0.0", false},
{"bad latest", "v1.0.0", "latest", false},
{"too many parts", "v1.0.0.0", "v1.0.1", false},
{"negative", "v1.-1.0", "v1.0.0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsNewer(tt.current, tt.latest); got != tt.want {
t.Errorf("IsNewer(%q,%q) = %v, want %v", tt.current, tt.latest, got, tt.want)
}
})
}
}
func TestLatest(t *testing.T) {
tests := []struct {
name string
status int
body string
wantTag string
wantErr bool
}{
{"ok", 200, `{"tag_name":"v1.2.3","html_url":"https://x/r","assets":[{"name":"a.exe","browser_download_url":"https://x/a.exe"}]}`, "v1.2.3", false},
{"not found", 404, `{}`, "", true},
{"bad json", 200, `{`, "", true},
{"missing tag", 200, `{"html_url":"u"}`, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer srv.Close()
rel, err := Latest(context.Background(), nil, srv.URL)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if err == nil {
if rel.Tag != tt.wantTag {
t.Errorf("tag = %q", rel.Tag)
}
if a, ok := rel.FindAsset("a.exe"); !ok || a.URL != "https://x/a.exe" {
t.Errorf("asset = %+v %v", a, ok)
}
if _, ok := rel.FindAsset("missing"); ok {
t.Error("unexpected asset")
}
}
})
}
}
func TestLatestUnreachable(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
url := srv.URL
srv.Close()
if _, err := Latest(context.Background(), nil, url); err == nil {
t.Error("expected error for unreachable server")
}
}
func TestLatestBadURL(t *testing.T) {
if _, err := Latest(context.Background(), nil, "://bad"); err == nil {
t.Error("expected error for bad URL")
}
}
+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'
+17 -17
View File
@@ -1,28 +1,28 @@
--select * from dropall('resolvespec_login');
CREATE OR REPLACE FUNCTION resolvespec_login(
INOUT p_data jsonb
,OUT p_success boolean
,OUT p_error text
INOUT p_data jsonb
, OUT p_success boolean
, OUT p_error text
)
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER
AS
$$
DECLARE
--Error Handling--
m_funcname text = 'resolvespec_login';
m_errmsg text;
m_errcontext text;
m_errdetail text;
m_errhint text;
m_errstate text;
m_retval integer;
m_funcname text = 'resolvespec_login';
m_errmsg text;
m_errcontext text;
m_errdetail text;
m_errhint text;
m_errstate text;
m_retval integer;
--Error Handling--
m_rid_user integer;
m_rid_hub integer;
m_pass_hashed citext[];
m_session jsonb;
m_rid_user integer;
m_rid_hub integer;
m_pass_hashed citext[];
m_session jsonb;
m_allow_hash_auth boolean;
BEGIN
m_allow_hash_auth = _try_integer( p_data->'claims'->>'rid_user',0) > 0;
+398 -255
View File
@@ -1,84 +1,84 @@
CREATE OR REPLACE FUNCTION mm_proc(
p_doctype text --File Types: html, docx, text, allfieldvalues
,p_commtype text --Merge Data Type: e.g. SMS,Email,All (Will be passed through to menu/taglist funct)
,p_template bytea --Source/Template file to be merge
,p_data_prefix text --Table prefix for data source. e.g. tcli (client) , sta (standard letters)
,p_data_rid integer --rid for given table prefix
,p_filterdata json = NULL --Advanced filter data.
,OUT p_doc bytea --Merged document output
,OUT p_docguid text --optional guid to store document guid from tempfile table. (For disk reading)
,OUT p_retval integer
,OUT p_errmsg text
p_doctype text --File Types: html, docx, text, allfieldvalues
, p_commtype text --Merge Data Type: e.g. SMS,Email,All (Will be passed through to menu/taglist funct)
, p_template bytea --Source/Template file to be merge
, p_data_prefix text --Table prefix for data source. e.g. tcli (client) , sta (standard letters)
, p_data_rid integer --rid for given table prefix
, p_filterdata json = NULL --Advanced filter data.
, OUT p_doc bytea --Merged document output
, OUT p_docguid text --optional guid to store document guid from tempfile table. (For disk reading)
, OUT p_retval integer
, OUT p_errmsg text
)
LANGUAGE plpgsql
VOLATILE
LANGUAGE plpgsql
VOLATILE
AS
$$
DECLARE
--Error Handling--
m_funcname text = 'mm_proc';
m_errmsg text;
m_errcontext text;
m_errdetail text;
m_errhint text;
m_errstate text;
m_retval integer;
m_funcname text = 'mm_proc';
m_errmsg text;
m_errcontext text;
m_errdetail text;
m_errhint text;
m_errstate text;
m_retval integer;
--Error Handling--
m_blankexec text;
m_comma text;
m_debug_exestr text;
m_exec_orderstr text;
m_execfilter text;
m_execstr text;
m_fields json;
m_guid citext;
m_json json;
m_json_full json;
m_json_full_complex jsonb;
m_result text;
m_tablefilter text;
m_temppath citext;
m_user citext;
m_exttype citext;
m_returnvalues boolean = false;
m_data_prefix citext;
m_data_rid integer;
m_rid_comm integer;
m_blankexec text;
m_comma text;
m_debug_exestr text;
m_exec_orderstr text;
m_execfilter text;
m_execstr text;
m_fields json;
m_guid citext;
m_json json;
m_json_full json;
m_json_full_complex jsonb;
m_result text;
m_tablefilter text;
m_temppath citext;
m_user citext;
m_exttype citext;
m_returnvalues boolean = false;
m_data_prefix citext;
m_data_rid integer;
m_rid_comm integer;
m_rid_commattachment integer;
m_rid_obl integer;
m_doctype citext;
m_ltime timestamp;
m_hasfilestream boolean;
m_rid_obligation integer;
g_debug boolean;
g_tag_s text;
g_tag_e text;
g_tag_split text;
g_tag_oper text;
g_mtype_root integer = 0;
g_mtype_field integer = 1;
g_mtype_tblfield integer = 2;
g_mtype_tblroot integer = 3;
g_mtype_aggfield integer = 4;
g_mtype_picture integer = 5;
g_mtype_special integer = 6;
g_mtype_filter integer = 7;
g_mtype_condfield integer = 8;
g_mtype_docreplace integer = 9;
g_mtype_html integer = 10;
g_benchmark integer;
a_tblroot integer[];
a_types citext[];
a_inner_selected citext[];
r_template record;
r_doc record;
r_lp record;
r_lp_t record;
r_lp_c record;
r_retval record;
r_tmp record;
m_rid_obl integer;
m_doctype citext;
m_ltime timestamp;
m_hasfilestream boolean;
m_rid_obligation integer;
g_debug boolean;
g_tag_s text;
g_tag_e text;
g_tag_split text;
g_tag_oper text;
g_mtype_root integer = 0;
g_mtype_field integer = 1;
g_mtype_tblfield integer = 2;
g_mtype_tblroot integer = 3;
g_mtype_aggfield integer = 4;
g_mtype_picture integer = 5;
g_mtype_special integer = 6;
g_mtype_filter integer = 7;
g_mtype_condfield integer = 8;
g_mtype_docreplace integer = 9;
g_mtype_html integer = 10;
g_benchmark integer;
a_tblroot integer[];
a_types citext[];
a_inner_selected citext[];
r_template record;
r_doc record;
r_lp record;
r_lp_t record;
r_lp_c record;
r_retval record;
r_tmp record;
--r_lp_prev record;
m_start timestamp = clock_timestamp();
m_start timestamp = clock_timestamp();
BEGIN
p_retval = 0;
p_errmsg = '';
@@ -98,17 +98,17 @@ BEGIN
m_user = f_getuser();
-- if p_doctype ilike '%plain%' and f_iscompressed(p_template) in ('none')
-- and byteatotext(p_template) not ilike '%'|| G_TAG_S || '%' || G_TAG_E || '%'
-- then
-- raise warning 'No tags to merge in plain template.';
-- p_doc = p_template;
-- return;
-- end if;
-- and byteatotext(p_template) not ilike '%'|| G_TAG_S || '%' || G_TAG_E || '%'
-- then
-- raise warning 'No tags to merge in plain template.';
-- p_doc = p_template;
-- return;
-- end if;
-- perform log_event(m_funcname,format('INIT _ type=%s commtype=%s p_data_prefix=%s p_data_rid=%s Template:%s'
-- ,p_doctype, p_commtype,p_data_prefix,p_data_rid
-- ,case when octet_length(p_template) < 100 then byteatotext(p_template) else octet_length(p_template)::text end
-- ),bt_enum('eventlog','local notice'));
-- ,p_doctype, p_commtype,p_data_prefix,p_data_rid
-- ,case when octet_length(p_template) < 100 then byteatotext(p_template) else octet_length(p_template)::text end
-- ),bt_enum('eventlog','local notice'));
if p_data_prefix::citext = 'com'
then
@@ -138,7 +138,9 @@ BEGIN
select ctl.rid_parent as rid_parent
, ctl.tableprefix as parent_prefix
from tasklist ctl --inner join t_adproclient cli on cli.rid_adproclient = ctl.rid_parent -- and ctl.tableprefix = 'tcli'
from tasklist ctl
--inner join t_adproclient cli on cli.rid_adproclient = ctl.rid_parent
-- and ctl.tableprefix = 'tcli'
where ctl.rid_tasklist = p_data_rid into m_data_rid, m_data_prefix;
elsif p_data_prefix::citext = 'ehr'
@@ -168,7 +170,7 @@ BEGIN
into r_doc;
-- perform log_event(m_funcname,format('init = p_doctype = %s p_data_prefix = %s p_data_rid = %s p_template[size] = %s'
-- ,p_doctype,p_data_prefix,p_data_rid,octet_length(p_template)),bt_enum('eventlog','local notice'));
-- ,p_doctype,p_data_prefix,p_data_rid,octet_length(p_template)),bt_enum('eventlog','local notice'));
if p_doctype = 'allfieldvalues'
then
@@ -291,7 +293,7 @@ BEGIN
from src d ;
--CREATE INDEX "idx_tmp_merge_init_src_type" ON tmp_merge_init_src USING btree (merge_type);
--CREATE INDEX "idx_tmp_merge_init_src_tag" ON tmp_merge_init_src USING btree (mergetag);
--CREATE INDEX "idx_tmp_merge_init_src_tag" ON tmp_merge_init_src USING btree (mergetag);
--HTML field types has a special meaning in the frontend. We treat them as normal fields.
update tmp_merge_init_src u
@@ -513,8 +515,8 @@ BEGIN
)
then
-- perform log_event(m_funcname,format('NO TAGS p_doctype=%s p_commtype=%s p_data_prefix=%s p_data_rid=%s'
-- ,p_doctype, p_commtype,p_data_prefix,p_data_rid),bt_enum('eventlog','local notice'));
--
-- ,p_doctype, p_commtype,p_data_prefix,p_data_rid),bt_enum('eventlog','local notice'));
--
p_doc = p_template;
return;
@@ -545,15 +547,16 @@ BEGIN
/*
---Use this code to test
select pl_writefile('/mnt/t/temp/t.docx',r.p_doc) ,r.*
from mm_proc('docx','allfieldvalues', (
select pl_writefile('/mnt/t/temp/t.docx',r.p_doc)
,r.*
from mm_proc('docx','allfieldvalues', (
select tat.maindoc
from templateattachment tat
--cross join view_blob(tat.rid_templateattachment,'templateattachment','maindoc','rid_templateattachment','utf8') v
where tat.description ilike 'Form 16 (%'
), 'TCLI',1000016) r
), 'TCLI',1000016) r
*/
/*
/*
drop table if exists debug_merge_init_src;
create table debug_merge_init_src as
@@ -1130,10 +1133,10 @@ BEGIN
)
loop
-- perform log_event(m_funcname,format(' Tables %s=%s src=%s rn=%s' ,p_data_prefix,p_data_rid
-- ,r_lp_t.source,r_lp_t.rn
-- ),bt_enum('eventlog','local notice')
-- --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text
-- );
-- ,r_lp_t.source,r_lp_t.rn
-- ),bt_enum('eventlog','local notice')
-- --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text
-- );
if r_lp_t.table_name = any(a_inner_selected) and nv(r_lp_t.table_name ) <> ''
then
@@ -1163,7 +1166,7 @@ BEGIN
m_exec_orderstr = r_lp_t.parent_order_string;
--raise notice 'Applying order % by for % %.', r_lp_t.parent_order_string, r_lp_t.parent_table_name,r_lp_t.field_name;
/*
select s.parent_order_string
select s.parent_order_string
from tmp_merge_init_fields f
inner join tmp_merge_init_src s on s.parent_rid = r_lp_t.rid
and s.merge_type = G_MTYPE_TBLROOT
@@ -1171,7 +1174,7 @@ BEGIN
where f.source = r_lp_t.source
limit 1
into m_exec_orderstr;
*/
*/
end if;
@@ -1235,62 +1238,71 @@ BEGIN
,format('%s', max(ifblnk(d.tblid,f.table_name)))::citext as subname
,($S
$(select ('{'|| $S$ || string_agg(format($SS$'"%1$s": ' || json_build_object('value', json_agg(%2$s::text),
'type',
'%3$s'
)
:
:
text
$SS$,
c
.
mergetag,
c
.
field_name,
c
.
merge_type
),
'|| '',''||'
)
||
$S$
'type',
'%3$s'
)
:
:
text
$SS$,
c
.
mergetag,
c
.
field_name,
c
.
merge_type
),
'|| '',''||'
)
||
$S$
||
'}'
)
:
:
json
from $S$ || max (
f
.
table_name
)
||
' '
||
ifblnk (
max
(
f
.
filter_string
),
'where 1=1'
)
||
')'
)
:
:
citext as qry , (
$S
$(select ('{'|| $S$ || string_agg(format($SS$'"%1$s": ' || json_build_object('value', '[]', 'type','%3$s')
:
:
text
$SS$,
from
$S$
||
max
(
f
.
table_name
)
||
' '
||
ifblnk
(
max
(
f
.
filter_string
),
'where 1=1'
)
||
')'
)
:
:
citext
as
qry
,
(
$S
$(select ('{'|| $S$ || string_agg(format($SS$'"%1$s": ' || json_build_object('value', '[]', 'type','%3$s')
:
:
text
$SS$,
c
.
mergetag,
@@ -1305,12 +1317,24 @@ BEGIN
)
||
$S$
||
'}'
)
:
:
json
$S$
||
'}'
')'
)
:
:
json $S$ || ')' ) : : citext as qryblnk , nv (
citext
as
qryblnk
,
nv
(
max
(
f
@@ -1320,96 +1344,177 @@ BEGIN
)
:
:
citext as parent_order_string
from tmp_merge_init_src f
citext
as
parent_order_string
from
tmp_merge_init_src
f
inner
join tmp_merge_init_src c
on c . rid_parent = f . rid and c . merge_type in (
join
tmp_merge_init_src
c
on
c
.
rid_parent
=
f
.
rid
and
c
.
merge_type
in
(
G_MTYPE_TBLFIELD
)
inner
join tmp_merge_init_fields d
on d . mergetag = c . mergetag and d . tblparent = r_lp_t . tblid
where f . grand_rid = r_lp_t . parent_rid and f . merge_type = G_MTYPE_TBLROOT --and f.parent_rid = any(a_tblroot) --and d.table_level > 0
join
tmp_merge_init_fields
d
on
d
.
mergetag
=
c
.
mergetag
and
d
.
tblparent
=
r_lp_t
.
tblid
where
f
.
grand_rid
=
r_lp_t
.
parent_rid
and
f
.
merge_type
=
G_MTYPE_TBLROOT
--and f.parent_rid = any(a_tblroot)
--and d.table_level > 0
group by f . rid ) loop
raise notice 'Inner Loop: %', r_lp_c . qry;
a_inner_selected = array_append(a_inner_selected, r_lp_t.table_name);
group
by
f
.
rid
)
loop
raise
notice
'Inner Loop: %',
r_lp_c
.
qry;
a_inner_selected
= array_append(a_inner_selected, r_lp_t.table_name);
m_execstr = format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::json %s)::json, 'type', '%s')::text %s$S$
m_execstr
= format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::json %s)::json, 'type', '%s')::text %s$S$
,m_execstr,',',r_lp_c.subname,r_lp_c.qry, r_lp_c.parent_order_string, r_lp_c.merge_type, E'\r\n');
m_blankexec = format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::json %s)::json, 'type', '%s')::text %s$S$
m_blankexec
= format($S$%s|| '%s"%s":' || json_build_object('value',json_agg(%s::json %s)::json, 'type', '%s')::text %s$S$
,m_blankexec,',',r_lp_c.subname,r_lp_c.qryblnk, r_lp_c.parent_order_string, r_lp_c.merge_type, E'\r\n');
end loop;
end loop;
if ifblnk(r_lp_t.parent_table_name,'') = ''
then
m_execstr = format(E'select (''{'' %s \r\n || ''}'')::json ;',m_execstr );
else
select string_agg(s.filter_string, ' ')
from tmp_merge_init_fields f
if
ifblnk(r_lp_t.parent_table_name,'') = ''
then
m_execstr = format(E'select (''{'' %s \r\n || ''}'')::json ;',m_execstr );
else
select string_agg(s.filter_string, ' ')
from tmp_merge_init_fields f
inner join tmp_merge_init_src s on s.mergetag = f.mergetag
and s.merge_type = G_MTYPE_FILTER
where f.source = r_lp_t.source into m_tablefilter ;
where f.source = r_lp_t.source into m_tablefilter
;
m_execstr = format(E'select (''{'' %s \r\n || ''}'')::json \r\nfrom %s \r\n%s;'
m_execstr
= format(E'select (''{'' %s \r\n || ''}'')::json \r\nfrom %s \r\n%s;'
,m_execstr, r_lp_t.parent_table_name,ifblnk(r_lp_t.parent_filter_string, ' where 1=1 ') || nv(m_execfilter) || nv(m_tablefilter) );
end if;
m_blankexec = format(E'select (''{'' %s \r\n || ''}'')::json \r\n;',m_blankexec );
select r.p_retval, r.p_errmsg, r.p_json - > 'str'
from exec_json(m_execstr, 'str json') r into m_retval,m_errmsg, m_json;
if m_json is null
then
select r.p_retval, r.p_errmsg, r.p_json - > 'str'
from exec_json(m_execstr, 'str json') r into m_retval,m_errmsg, m_json;
end if;
m_debug_exestr = nv(m_debug_exestr) || E'\r\n/*'|| nv(r_lp_t.parent_table_name) || ' len:' || nv(length(m_json::text)) ||E'*/ \r\n' || nv(m_execstr) || E'\r\n ';
if m_json_full_complex is null
then
m_json_full_complex = jsonb_build_object(r_lp_t.tblid::text,m_json);
end if;
if (m_json_full_complex->r_lp_t.tblid::text) is null
then
m_json_full_complex = jsonb_set(m_json_full_complex, format('{%s}',r_lp_t.tblid)::text[], m_json::jsonb,true);
else
m_json_full_complex = jsonb_set(m_json_full_complex, format('{%s}',r_lp_t.tblid)::text[], _jsonb_object_cat(m_json_full_complex->r_lp_t.tblid,m_json::jsonb),true);
end if;
-- perform log_event(m_funcname,format('Complex Tables %s=%s m_json_full_complex=%s' ,p_data_prefix,p_data_rid
-- ,m_json_full_complex::text
-- ),bt_enum('eventlog','local notice')
-- --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text
-- );
m_execfilter = '';
m_execstr = '';
m_blankexec = '';
m_exec_orderstr = '';
m_comma = '';
m_tablefilter = '';
end if;
if nv(m_comma) = '' and length(m_execstr) > 2
m_blankexec
= format(E'select (''{'' %s \r\n || ''}'')::json \r\n;',m_blankexec );
select r.p_retval, r.p_errmsg, r.p_json - > 'str'
from exec_json(m_execstr, 'str json') r into m_retval,m_errmsg, m_json;
if
m_json is null
then
m_comma = ',';
select r.p_retval, r.p_errmsg, r.p_json - > 'str'
from exec_json(m_execstr, 'str json') r into m_retval,m_errmsg, m_json;
end if;
end loop;
if G_DEBUG
then
perform pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8'));
end if;
m_debug_exestr
= nv(m_debug_exestr) || E'\r\n/*'|| nv(r_lp_t.parent_table_name) || ' len:' || nv(length(m_json::text)) ||E'*/ \r\n' || nv(m_execstr) || E'\r\n ';
EXCEPTION
if
m_json_full_complex is null
then
m_json_full_complex = jsonb_build_object(r_lp_t.tblid::text,m_json);
end if;
if
(m_json_full_complex->r_lp_t.tblid::text) is null
then
m_json_full_complex = jsonb_set(m_json_full_complex, format('{%s}',r_lp_t.tblid)::text[], m_json::jsonb,true);
else
m_json_full_complex = jsonb_set(m_json_full_complex, format('{%s}',r_lp_t.tblid)::text[], _jsonb_object_cat(m_json_full_complex->r_lp_t.tblid,m_json::jsonb),true);
end if;
-- perform log_event(m_funcname,format('Complex Tables %s=%s m_json_full_complex=%s' ,p_data_prefix,p_data_rid
-- ,m_json_full_complex::text
-- ),bt_enum('eventlog','local notice')
-- --,(select jsonb_agg(row_to_json(f)::jsonb) from tmp_merge_init_fields f)::text
-- );
m_execfilter
= '';
m_execstr
= '';
m_blankexec
= '';
m_exec_orderstr
= '';
m_comma
= '';
m_tablefilter
= '';
end if;
if
nv(m_comma) = '' and length(m_execstr) > 2
then
m_comma = ',';
end if;
end loop;
if
G_DEBUG
then
perform pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8'));
end if;
EXCEPTION
WHEN others THEN
GET STACKED DIAGNOSTICS
m_errmsg = MESSAGE_TEXT
@@ -1418,51 +1523,67 @@ EXCEPTION
,m_errhint = PG_EXCEPTION_HINT
,m_errstate = RETURNED_SQLSTATE;
m_errmsg = format(E'Merge failed to complete. Merge fields are not setup correctly. \r\nPlease check the template. \r\nThere could be table merge tags outside of a table. \r\nDetail Error: \r\n%s',m_errmsg);
m_errmsg = nv(m_errmsg) || format(E'\r\nExecString: %s ', ifblnk(m_execstr,m_debug_exestr));
m_errmsg = nv(m_errmsg) || format(E'\r\nError Detail: %s , %s, %s, %s', m_errdetail,m_errcontext,m_errhint,m_errstate);
m_errmsg
= format(E'Merge failed to complete. Merge fields are not setup correctly. \r\nPlease check the template. \r\nThere could be table merge tags outside of a table. \r\nDetail Error: \r\n%s',m_errmsg);
m_errmsg
= nv(m_errmsg) || format(E'\r\nExecString: %s ', ifblnk(m_execstr,m_debug_exestr));
m_errmsg
= nv(m_errmsg) || format(E'\r\nError Detail: %s , %s, %s, %s', m_errdetail,m_errcontext,m_errhint,m_errstate);
if G_DEBUG
if
G_DEBUG
then
m_errmsg = format(E'%s \r\nDebug file: %s',m_errmsg, r_template.debugsql_filename);
perform pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8'));
perform
pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8'));
end if;
p_retval = 1;
p_errmsg = m_errmsg;
p_retval
= 1;
p_errmsg
= m_errmsg;
m_json_full_complex = _jsonb_object_cat(m_json_full_complex, jsonb_build_object('p_retval',p_retval,'p_errmsg',p_errmsg));
m_json_full_complex
= _jsonb_object_cat(m_json_full_complex, jsonb_build_object('p_retval',p_retval,'p_errmsg',p_errmsg));
return;
--raise exception '%', m_errmsg using hint = 'in merge jsonbuild process';
END;
--------------------------------------------------------------------------------------------------------
m_json_full = json_build_object('fields',m_json_full, 'complexfields',m_json_full_complex);
m_json_full
= json_build_object('fields',m_json_full, 'complexfields',m_json_full_complex);
if G_DEBUG
if
G_DEBUG
then
perform pl_writefile(r_template.debug_filename, convert_to(m_json_full::text,'utf8'));
end if;
if G_BENCHMARK = 1
if
G_BENCHMARK = 1
then
perform log_event(m_funcname,format('Perf Complex Fields 2SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice'));
m_ltime = clock_timestamp();
m_ltime
= clock_timestamp();
end if;
if m_returnvalues
if
m_returnvalues
then
p_doc = convert_to(m_json_full::text, 'utf8');
p_docguid = 'json:see->p_doc';
p_docguid
= 'json:see->p_doc';
else
if G_BENCHMARK = 1
then
perform log_event(m_funcname,format('Perf Before pl_mailmerge SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice'));
m_ltime = clock_timestamp();
m_ltime
= clock_timestamp();
end if;
if m_hasfilestream
if
m_hasfilestream
then
--filesystem
select r.p_retval
@@ -1471,33 +1592,41 @@ select r.p_retval
from pl_mailmerge(format('merge_%s', p_doctype), r_template.filepath, r_doc.filepath, m_json_full::text,
1 /*New mode, new tags*/) r into r_retval;
if G_BENCHMARK = 1
if
G_BENCHMARK = 1
then
perform log_event(m_funcname,format('Perf After pl_mailmerge SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice'));
m_ltime = clock_timestamp();
m_ltime
= clock_timestamp();
end if;
if r_retval.p_retval = 1
if
r_retval.p_retval = 1
then
raise '%',r_retval.p_errmsg;
elseif r_retval.p_retval = 2
elseif
r_retval.p_retval = 2
then
p_retval = 2;
p_errmsg = r_retval.p_errmsg;
p_errmsg
= r_retval.p_errmsg;
end if;
select r.p_retval, r.p_errmsg
from f_tempfile_add(r_doc.filepath, p_doctype, r_doc.guid, 600, m_data_rid, m_data_prefix) r into m_retval, m_errmsg;
p_docguid = r_doc.guid;
p_docguid
= r_doc.guid;
select r.p_outfile, r.p_retval, r.p_errmsg
from pl_readfile(r_doc.filepath) r into p_doc, m_retval, m_errmsg;
if m_retval = 0
if
m_retval = 0
then
perform pl_deletefile(r_doc.filepath);
perform pl_deletefile(r_template.filepath);
perform
pl_deletefile(r_template.filepath);
end if;
else
@@ -1517,32 +1646,41 @@ select r.p_retval
from pl_mailmerge(format('merge_%s', p_doctype), null, null, m_json_full::text
, 1 /*New mode, new tags*/, r_template.blob) r into r_retval;
if G_BENCHMARK = 1
if
G_BENCHMARK = 1
then
perform log_event(m_funcname,format('Perf After pl_mailmerge Stream SinceStart: %s Duration: %s', clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice'));
m_ltime = clock_timestamp();
m_ltime
= clock_timestamp();
end if;
r_doc.blob = r_retval.p_file;
p_doc = r_retval.p_file;
r_doc.blob
= r_retval.p_file;
p_doc
= r_retval.p_file;
if r_retval.p_retval = 1
if
r_retval.p_retval = 1
then
raise '%',r_retval.p_errmsg;
elseif r_retval.p_retval = 2
elseif
r_retval.p_retval = 2
then
p_retval = 2;
p_errmsg = r_retval.p_errmsg;
p_errmsg
= r_retval.p_errmsg;
end if;
end if;
end if;
if G_BENCHMARK in (1,2)
if
G_BENCHMARK in (1,2)
then
perform log_event(m_funcname,format('Perf Merge End (%s,%s) SinceStart: %s Duration: %s',p_doctype,p_data_rid, clock_timestamp() - m_start, clock_timestamp() - m_ltime),bt_enum('eventlog','local notice'));
m_ltime = clock_timestamp();
m_ltime
= clock_timestamp();
end if;
EXCEPTION
@@ -1554,16 +1692,21 @@ WHEN others THEN
,m_errhint = PG_EXCEPTION_HINT
,m_errstate = RETURNED_SQLSTATE;
p_errmsg := get_err_msg(m_funcname, m_errmsg, m_errcontext, m_errdetail, m_errhint, m_errstate);
p_retval = 1;
p_errmsg
:= get_err_msg(m_funcname, m_errmsg, m_errcontext, m_errdetail, m_errhint, m_errstate);
p_retval
= 1;
p_errmsg = nv(p_errmsg) || nv(format(E'\r\n p_doctype:%s, p_commtype:%s, p_data_prefix:%s, p_data_rid:%s, p_filterdata:%s'
p_errmsg
= nv(p_errmsg) || nv(format(E'\r\n p_doctype:%s, p_commtype:%s, p_data_prefix:%s, p_data_rid:%s, p_filterdata:%s'
,p_doctype,p_commtype,p_data_prefix,p_data_rid, p_filterdata));
if G_DEBUG
if
G_DEBUG
then
perform pl_writefile(r_template.debugsql_filename, convert_to(m_debug_exestr,'utf8'));
perform pl_writefile(r_template.debug_filename, convert_to(m_json_full::text,'utf8'));
perform
pl_writefile(r_template.debug_filename, convert_to(m_json_full::text,'utf8'));
end if;
END;
+29
View File
@@ -0,0 +1,29 @@
# Windows installer
NSIS script: `installer.nsi`. Output: `dist/pgtidy-setup-windows-amd64.exe`.
## Build
```
make installer-windows
```
Needs `makensis` (package `nsis`). CI builds and uploads it on release.
## Installer
- Installs `pgtidy.exe` to `%ProgramFiles%\PgTidy` (admin).
- Adds the install dir to the system `PATH`; removed on uninstall.
- Registers in Add/Remove Programs (supports silent `/S` uninstall).
## Update check
```
pgtidy update # check, prompt, download + run installer (Windows)
pgtidy update --check # report only
pgtidy update --yes # no prompt
```
- Source: latest Gitea release (`pkg/updatecheck`).
- `dev` and commit-hash builds are never reported as outdated.
- Non-Windows: prints the release URL.
+84 -191
View File
@@ -1,35 +1,43 @@
; PgTidy Windows installer
; PgTidy Windows installer (NSIS)
;
; Build with (VERSION must match the release tag, without the leading "v"):
; makensis /DVERSION=1.2.3 /DSRC_EXE=path\to\pgtidy.exe windows\installer.nsi
;
; Installs pgtidy.exe into Program Files, adds the install dir to the
; machine-wide PATH, and on startup checks the git.warky.dev Gitea API for a
; newer release than the one being installed.
; Build: makensis -DVERSION=1.2.3 -DEXE=<abs path to pgtidy.exe> -DOUT=<abs path to installer> windows/installer.nsi
; Use absolute paths for EXE and OUT; relative ones resolve unpredictably.
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!ifndef SRC_EXE
!define SRC_EXE "..\dist\pgtidy.exe"
!ifndef OUT
!define OUT "pgtidy-setup-windows-amd64.exe"
!endif
!ifndef EXE
!define EXE "pgtidy-windows-amd64.exe"
!endif
!define PRODUCT_NAME "PgTidy"
!define PRODUCT_PUBLISHER "Warky Devs"
!define PRODUCT_HOMEPAGE "https://git.warky.dev/wdevs/pgtidy"
!define RELEASES_API_URL "https://git.warky.dev/api/v1/repos/wdevs/pgtidy/releases/latest"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\PgTidy"
!define ENV_KEY 'HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"'
!define APPNAME "PgTidy"
!define REGKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\PgTidy"
!define ENVKEY "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
Unicode true
Name "${APPNAME} ${VERSION}"
OutFile "${OUT}"
InstallDir "$PROGRAMFILES64\${APPNAME}"
InstallDirRegKey HKLM "Software\${APPNAME}" "InstallDir"
RequestExecutionLevel admin
SetCompressor /SOLID lzma
VIProductVersion "${VERSION}.0"
VIAddVersionKey "ProductName" "${APPNAME}"
VIAddVersionKey "FileDescription" "${APPNAME} installer"
VIAddVersionKey "FileVersion" "${VERSION}"
VIAddVersionKey "ProductVersion" "${VERSION}"
VIAddVersionKey "LegalCopyright" "Warky Devs"
!include "MUI2.nsh"
!include "LogicLib.nsh"
Name "${PRODUCT_NAME} ${VERSION}"
OutFile "pgtidy-setup-${VERSION}.exe"
InstallDir "$PROGRAMFILES64\PgTidy"
InstallDirRegKey HKLM "${UNINST_KEY}" "InstallLocation"
RequestExecutionLevel admin
Unicode true
!include "StrFunc.nsh"
!include "WinMessages.nsh"
${StrStr}
${UnStrRep}
!define MUI_ABORTWARNING
!define MUI_ICON "..\assets\logo_128.ico"
@@ -40,187 +48,72 @@ Unicode true
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
; ---------------------------------------------------------------------------
; Check the Gitea releases API for a newer version than the one we are about
; to install. Best-effort only: any failure (offline, API down, no
; PowerShell) is swallowed and the installer proceeds silently.
; ---------------------------------------------------------------------------
Function .onInit
StrCpy $1 "$TEMP\pgtidy-latest-version.txt"
Delete "$1"
DetailPrint "Checking ${PRODUCT_HOMEPAGE} for a newer release..."
nsExec::ExecToLog 'powershell -NoProfile -NonInteractive -Command "try { $$r = Invoke-RestMethod -Uri ''${RELEASES_API_URL}'' -UseBasicParsing -TimeoutSec 5; $$r.tag_name | Out-File -Encoding ascii -NoNewline ''$1'' } catch { exit 0 }"'
${IfNot} ${FileExists} "$1"
Return
${EndIf}
FileOpen $2 "$1" r
FileRead $2 $3
FileClose $2
Delete "$1"
StrCpy $4 $3
; Trim a leading "v" if the tag is e.g. "v1.2.3"
StrCpy $5 $4 1
${If} $5 == "v"
StrCpy $4 $4 "" 1
${EndIf}
${If} $4 != ""
${AndIf} $4 != "${VERSION}"
MessageBox MB_YESNO|MB_ICONINFORMATION \
"A newer version of PgTidy is available: $4 (this installer is ${VERSION}).$\n$\nOpen the releases page to download it now?$\n$\nChoosing No continues installing ${VERSION}." \
IDNO +2
ExecShell "open" "${PRODUCT_HOMEPAGE}/releases/latest"
${EndIf}
FunctionEnd
; ---------------------------------------------------------------------------
; Adds $INSTDIR to the machine PATH if it isn't already present.
; ---------------------------------------------------------------------------
Function AddToPath
ReadRegStr $0 ${ENV_KEY} "Path"
Push "$0"
Push "$INSTDIR"
Call StrContains
Pop $1
${If} $1 == ""
${If} $0 == ""
StrCpy $0 "$INSTDIR"
${Else}
StrCpy $0 "$0;$INSTDIR"
${EndIf}
WriteRegExpandStr ${ENV_KEY} "Path" "$0"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
${EndIf}
FunctionEnd
; ---------------------------------------------------------------------------
; Removes $INSTDIR from the machine PATH.
; ---------------------------------------------------------------------------
Function un.RemoveFromPath
ReadRegStr $0 ${ENV_KEY} "Path"
Push "$0;"
Push "$INSTDIR;"
Push ""
Call un.StrReplace
Pop $0
Push "$0"
Push "$INSTDIR"
Push ""
Call un.StrReplace
Pop $0
; Drop a trailing separator left behind by the replacements above.
StrCpy $1 $0 1 -1
${If} $1 == ";"
StrCpy $0 $0 -1
${EndIf}
WriteRegExpandStr ${ENV_KEY} "Path" "$0"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
FunctionEnd
; Returns the index of needle in haystack via $R0, or "" if absent.
; Push haystack, Push needle -> Pop result
Function StrContains
Exch $R1 ; needle
Exch
Exch $R2 ; haystack
Push $R3
Push $R4
Push $R5
StrLen $R3 $R1
StrCpy $R4 0
${Do}
StrCpy $R5 $R2 $R3 $R4
${If} $R5 == $R1
StrCpy $R0 $R4
${ExitDo}
${EndIf}
${If} $R5 == ""
StrCpy $R0 ""
${ExitDo}
${EndIf}
IntOp $R4 $R4 + 1
${Loop}
Pop $R5
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Push $R0
Exch
Pop $R0
FunctionEnd
; Push string, Push search, Push replace -> Pop result
Function un.StrReplace
Exch $R0 ; replace
Exch
Exch $R1 ; search
Exch 2
Exch $R2 ; string
Push $R3
Push $R4
Push $R5
Push $R6
StrLen $R3 $R1
StrCpy $R4 ""
${Do}
StrCpy $R5 $R2 $R3
${If} $R5 == $R1
StrCpy $R4 "$R4$R0"
StrCpy $R2 $R2 "" $R3
${ElseIf} $R2 == ""
${ExitDo}
${Else}
StrCpy $R6 $R2 1
StrCpy $R4 "$R4$R6"
StrCpy $R2 $R2 "" 1
${EndIf}
${Loop}
Pop $R6
Pop $R5
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Pop $R0
Push $R4
FunctionEnd
Section "PgTidy" SEC_MAIN
SectionIn RO
Section "Install"
SetRegView 64
SetOutPath "$INSTDIR"
File "${SRC_EXE}"
File "/oname=pgtidy.exe" "${EXE}"
File "..\LICENSE"
Call AddToPath
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${PRODUCT_PUBLISHER}"
WriteRegStr HKLM "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$INSTDIR\uninstall.exe /S"
WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1
WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1
WriteUninstaller "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\${APPNAME}" "InstallDir" "$INSTDIR"
WriteRegStr HKLM "${REGKEY}" "DisplayName" "${APPNAME}"
WriteRegStr HKLM "${REGKEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${REGKEY}" "Publisher" "Warky Devs"
WriteRegStr HKLM "${REGKEY}" "DisplayIcon" "$INSTDIR\pgtidy.exe"
WriteRegStr HKLM "${REGKEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "${REGKEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKLM "${REGKEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S'
WriteRegDWORD HKLM "${REGKEY}" "NoModify" 1
WriteRegDWORD HKLM "${REGKEY}" "NoRepair" 1
; Add the install directory to the system PATH unless it is already there.
ReadRegStr $0 HKLM "${ENVKEY}" "Path"
${StrStr} $1 ";$0;" ";$INSTDIR;"
${If} $1 == ""
StrLen $2 $0
${If} $2 > 900
; NSIS strings are limited; rewriting a very long PATH could truncate it.
MessageBox MB_OK|MB_ICONEXCLAMATION "PATH is too long to update automatically. Add $INSTDIR to PATH manually."
${Else}
${If} $0 == ""
WriteRegExpandStr HKLM "${ENVKEY}" "Path" "$INSTDIR"
${Else}
WriteRegExpandStr HKLM "${ENVKEY}" "Path" "$0;$INSTDIR"
${EndIf}
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
${EndIf}
${EndIf}
SectionEnd
Section "Uninstall"
Call un.RemoveFromPath
SetRegView 64
Delete "$INSTDIR\pgtidy.exe"
Delete "$INSTDIR\LICENSE"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
DeleteRegKey HKLM "${UNINST_KEY}"
; Remove the install directory from the system PATH.
ReadRegStr $0 HKLM "${ENVKEY}" "Path"
; Wrap in separators so every entry, including the first and last, matches.
StrCpy $1 ";$0;"
${UnStrRep} $1 "$1" ";$INSTDIR;" ";"
StrCpy $2 $1 1
${If} $2 == ";"
StrCpy $1 $1 "" 1
${EndIf}
StrCpy $2 $1 1 -1
${If} $2 == ";"
StrCpy $1 $1 -1
${EndIf}
${If} $1 != $0
WriteRegExpandStr HKLM "${ENVKEY}" "Path" "$1"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
${EndIf}
DeleteRegKey HKLM "${REGKEY}"
DeleteRegKey HKLM "Software\${APPNAME}"
SectionEnd