packages feed

hasql-th-format (empty) → 0.1.0.0

raw patch · 8 files changed

+4068/−0 lines, 8 filesdep +basedep +bytestringdep +hasql-check

Dependencies added: base, bytestring, hasql-check, postgresql-syntax, prettyprinter, string-interpolate, text, text-builder

Files

+ LICENSE view
@@ -0,0 +1,28 @@+BSD 3-Clause License++Copyright (c) 2021-2026, Kronor++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its+   contributors may be used to endorse or promote products derived from+   this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,234 @@+# hasql-th-format++Format the SQL inside your+[hasql-th](https://hackage.haskell.org/package/hasql-th) quasiquotes, in place.++It parses each statement with+[postgresql-syntax](https://hackage.haskell.org/package/postgresql-syntax) and+renders it again, so the layout comes from the statement's structure rather+than from whoever typed it:++```haskell+recent =+    [vectorStatement|+        select o.id::int8, c.email::text, o.total::numeric from orders o join customer c on c.id = o.customer_id where o.placed_at > $1::timestamptz and o.status = $2::text order by o.placed_at desc limit 50+    |]+```++```+$ hasql-th-format src/Shop/Orders.hs+Reformatted: src/Shop/Orders.hs+```++```haskell+recent =+    [vectorStatement|+        select+            o.id::int8+          , c.email::text+          , o.total::numeric+        from orders o+        join customer c+          on c.id = o.customer_id+        where o.placed_at > $1::timestamptz+          and o.status = $2::text+        order by o.placed_at desc+        limit 50+    |]+```++## Contents++- [Installing](#installing)+- [Formatting](#formatting)+- [It will not change what a statement means](#it-will-not-change-what-a-statement-means)+- [Checking a refactor did not either: `hasql-th-audit`](#checking-a-refactor-did-not-either-hasql-th-audit)+- [The `kronor-syntax` flag](#the-kronor-syntax-flag)+- [Reference](#reference)+- [Limitations](#limitations)+- [Development](#development)++## Installing++```+git clone https://github.com/kronor-io/hasql-check+cd hasql-check/format+cabal install exe:hasql-th-format exe:hasql-th-audit+```++It reads quotes through `hasql-check`'s lexer, which links against libpq, so+install your platform's PostgreSQL client library first (`libpq-dev` on Debian+and Ubuntu).++## Formatting++Give it files. It rewrites every supported quasiquote in place and says which+files it touched:++```+$ hasql-th-format src/Shop/Orders.hs src/Shop/Customers.hs+Reformatted: src/Shop/Orders.hs+```++`--check` changes nothing and exits 1 if any file would be rewritten, which is+what you want in CI or a pre-commit hook:++```+$ hasql-th-format --check src/Shop/Orders.hs+Would reformat: src/Shop/Orders.hs+```++`--stdin` reads one file's worth of Haskell on standard input and writes the+formatted result to stdout, which is the quickest way to see what the formatter+does with a quote. It is Haskell it reads, not bare SQL: a statement on its own+has no quasiquote around it, and comes back untouched.++```+$ printf 'q =\n    [vectorStatement|\n        select c.id::int8, c.email::text from customer c where c.active\n    |]\n' \+    | hasql-th-format --stdin+q =+    [vectorStatement|+        select+            c.id::int8+          , c.email::text+        from customer c+        where c.active+    |]+```++These quoters are recognised: `singletonStatement`, `maybeStatement`,+`vectorStatement`, `foldStatement`, `resultlessStatement`,+`rowsAffectedStatement`.++## It will not change what a statement means++After rendering, the formatter **re-parses its own output and compares the two+ASTs**. If they differ it writes nothing, leaves the file byte for byte as it+was, and fails:++```+$ hasql-th-format src/Report/Ledger.hs+Error processing src/Report/Ledger.hs: line 164: AST mismatch: formatter rendered semantically-different SQL (renderer bug)+```++That is a bug in this tool, not in your SQL — please report it. An earlier+version fell back silently to the unformatted text, and a renderer bug that+dropped a `DISTINCT ON` clause reached production that way.++There is no false-positive risk: `postgresql-syntax` parses+semantically-equivalent text to identical ASTs — whitespace, keyword case and+`::` spacing all normalise — so a mismatch is always real.++The other failure is `AST mismatch: formatter output did not re-parse`, meaning+the renderer emitted SQL that is not valid. It should be unreachable, and+reports the underlying parse error if it happens.++## Checking a refactor did not either: `hasql-th-audit`++The check above runs per quote, at format time, against the input the formatter+was just handed. It cannot see a hand-edit, a tree-wide rename, or any change+`hasql-th-format` was never run on — and if the formatter itself crashes, it+does not run at all.++`hasql-th-audit` takes two versions of a file and compares their quotes by AST,+using only `postgresql-syntax`:++```+$ hasql-th-audit old/Orders.hs new/Orders.hs+new/Orders.hs:6 (qq #1): AST DIFFERS+  --- old (line 6) ---+  ... order by o.placed_at desc+      limit 50+  --- new (line 6) ---+  ... order by o.placed_at desc+      limit 20+```++Exit codes: `0` every quote is equivalent, `1` one or more differ (or the+counts differ), `2` usage error or lexer failure.++Across a branch:++```bash+BIN=$(cabal list-bin hasql-th-audit)+base=$(git merge-base origin/main HEAD)++while read -r f; do+    old=$(mktemp); new=$(mktemp)+    git show "$base:$f" > "$old" 2>/dev/null || { rm -f "$old"; continue; }+    git show "HEAD:$f"  > "$new" 2>/dev/null || { rm -f "$old" "$new"; continue; }+    "$BIN" "$old" "$new" || echo "REGRESSION: $f"+    rm -f "$old" "$new"+done < <(git diff --name-only "$base" HEAD -- '*.hs')+```++## The `kronor-syntax` flag++Off by default, the formatter handles what upstream `postgresql-syntax` accepts+plus hasql-th's `::type?` nullability markers.++Kronor's fork of hasql-th adds two more forms, and `+kronor-syntax` parses+*and* renders both:++```sql+select $Just (name :: text), $Row { id = o.id :: int8 }+from orders o+where o.customer_id = $1.$customerId :: int8+```++The flag needs Kronor's fork of `postgresql-syntax`, where those constructors+live, so a build against Hackage must leave it off. It is the same flag,+meaning the same thing, as `hasql-check`'s.++## Reference++### `hasql-th-format`++| | |+|---|---|+| `hasql-th-format FILES...` | Rewrite every quote in each file, in place |+| `--check` | Change nothing; exit 1 if any file would change |+| `--stdin` | Format one statement from stdin onto stdout |++Exit codes: `0` success, `1` a file would change (`--check`) or a quote failed+to parse or round-trip, `2` usage error.++### `hasql-th-audit`++| | |+|---|---|+| `hasql-th-audit OLD NEW` | Compare the quotes of two versions of one file |++## Limitations++- **Long lines it cannot break.** The renderer wraps at a set of known+  constructs. An expression built from others — a deeply nested `case` inside a+  function argument, say — can still exceed the line budget.+- **`(select ...)` in a `join`** renders with both the subquery's parens and+  the join's, so you get `((select ...))`. It parses the same; it just looks+  odd.+- **Some `AExpr` / `CExpr` constructors have no multi-line form** and fall+  through to the inline renderer whatever their length.++## Development++```+cabal build+cabal test          # 64 golden cases, each also asserting AST equivalence+```++The test suite in `test/Test.hs` is the right home for a minimised reproducer+once you have narrowed a layout bug down. Each case is an input and the exact+expected output, so a change in layout shows up as a diff rather than as a+judgement call.++Two render paths run over the same AST: an inline one used inside subqueries+and short targets, and a multi-line one. Most layout bugs are a construct+handled by one and not the other. Wrapping is driven by three thresholds in+`SqlFormat.hs` — `inlineLengthThreshold` (60), `subqueryInlineThreshold` (100)+and `selectTargetInlineThreshold` (150) — and by the `isWrapEligible*`+predicates, which decide whether a construct has a multi-line form at all.++`cabal test` rebuilds the test suite but **not** the executables. Run+`cabal build` before running the binary over a codebase, or you will be running+the previous one.
+ app/Audit.hs view
@@ -0,0 +1,147 @@+-- | Per-quasiquote AST diff between two versions of a Haskell file.+--+-- Usage: hasql-th-audit <old-file> <new-file>+--+-- Extracts every supported hasql-th quasiquote from both files (using the+-- same lexer/parser the formatter uses), parses each SQL block with+-- postgresql-syntax, and reports any pair whose AST differs. Designed to+-- audit a large reformat for accidental semantic regressions like the+-- `DISTINCT ON (...)` clause that a renderer bug once dropped silently.+--+-- Exit codes:+--   0 — all QQs in the file have semantically equivalent ASTs+--   1 — one or more QQs differ+--   2 — usage error or lexer error in one of the files+{-# LANGUAGE CPP #-}++{-# LANGUAGE CPP #-}++module Main where++import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as BSL+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Hasql.Check.Lexer (AlexPosn (..), runAlex)+import Hasql.Check.Parser (QuasiQuote (..), parse)+import Hasql.Check.Quoter (quotersSupported)+import PostgresqlSyntax qualified as Ast+import System.Environment (getArgs)+import System.Exit (ExitCode (..), exitWith)+import System.IO (hPutStrLn, stderr)+import Prelude+++-- | One per QQ pair: either an unauditable pair (input parser rejected one+-- side) or a real AST mismatch. Used to split reporting so parse-skips don't+-- count as failures.+data AuditEntry+    = ParseSkip String+    | AstDiff String+++main :: IO ()+main = do+    args <- getArgs+    case args of+        [oldPath, newPath] -> do+            oldBytes <- BS.readFile oldPath+            newBytes <- BS.readFile newPath+            case (,) <$> extractQqs oldBytes <*> extractQqs newBytes of+                Left err -> do+                    putStrLn ("Lex error: " <> err)+                    exitWith (ExitFailure 2)+                Right (oldQqs, newQqs)+                    | length oldQqs /= length newQqs -> do+                        putStrLn $+                            "QQ count mismatch in "+                                <> newPath+                                <> ": old="+                                <> show (length oldQqs)+                                <> " new="+                                <> show (length newQqs)+                        exitWith (ExitFailure 1)+                    | otherwise -> do+                        let entries = classifyQqs newPath (zip oldQqs newQqs)+                            diffs = [m | AstDiff m <- entries]+                            skips = [m | ParseSkip m <- entries]+                        -- Parse-skips are informational (the formatter+                        -- itself tolerates unparseable input); emit them+                        -- on stderr so they don't conflate with regressions.+                        mapM_ (hPutStrLn stderr) skips+                        mapM_ putStrLn diffs+                        if null diffs+                            then pure ()+                            else exitWith (ExitFailure 1)+        _ -> do+            putStrLn "Usage: hasql-th-audit <old-file> <new-file>"+            exitWith (ExitFailure 2)+++-- | Extract supported hasql-th QQs from a Haskell source ByteString.+extractQqs :: BS.ByteString -> Either String [QuasiQuote]+extractQqs bs =+    case runAlex (BSL.fromStrict bs) parse of+        Left err -> Left err+        Right qqs -> Right (filter isSupportedQuoter qqs)+++isSupportedQuoter :: QuasiQuote -> Bool+isSupportedQuoter (QuasiQuote (_, quoter) _) = quoter `elem` quotersSupported+++-- | For each (old, new) QQ pair, parse the SQL content and classify the+-- outcome: parse-skip (one side unparseable — informational, never a+-- regression flag) or AST diff (real semantic divergence). QQ pairs where+-- both sides parse and the ASTs match are dropped (no entry).+classifyQqs :: FilePath -> [(QuasiQuote, QuasiQuote)] -> [AuditEntry]+classifyQqs filePath = mapMaybe go . zip [(1 :: Int) ..]+  where+    go (idx, (QuasiQuote (AlexPn _ oldLine _, _) (_, oldSql), QuasiQuote (AlexPn _ newLine _, _) (_, newSql))) =+        case (parseSql oldSql, parseSql newSql) of+            (Left _, _) ->+                -- Old side didn't parse; can't audit semantic equivalence.+                -- 'formatSql' itself tolerates unparseable input by leaving+                -- it untouched, so we mirror that behaviour rather than+                -- failing the build on it.+                Just (ParseSkip (filePath <> ":" <> show oldLine <> " (qq #" <> show idx <> "): old SQL did not parse, skipping"))+            (_, Left _) ->+                Just (ParseSkip (filePath <> ":" <> show newLine <> " (qq #" <> show idx <> "): new SQL did not parse, skipping"))+            (Right a, Right b)+                | a == b -> Nothing+                | otherwise ->+                    Just $+                        AstDiff $+                            filePath+                                <> ":"+                                <> show newLine+                                <> " (qq #"+                                <> show idx+                                <> "): AST DIFFERS"+                                <> "\n  --- old (line "+                                <> show oldLine+                                <> ") ---\n"+                                <> T.unpack (T.strip oldSql)+                                <> "\n  --- new (line "+                                <> show newLine+                                <> ") ---\n"+                                <> T.unpack (T.strip newSql)+++-- | Parse with the Kronor extensions enabled, exactly as the quasiquoters+-- (and the formatter) do, so a quasiquote using @$f (..)@ or @$1.$field@ is+-- audited rather than skipped.+settings :: Ast.Settings+settings =+    mconcat+        [ Ast.nullabilityMarkers True+#ifdef KRONOR_SYNTAX+        , Ast.haskellTargets True+        , Ast.haskellParamFields True+#endif+        ]+++parseSql :: Text -> Either Text Ast.PreparableStmt+parseSql = Ast.parse settings . T.strip
+ app/Hasql/ThFormat.hs view
@@ -0,0 +1,103 @@+module Hasql.ThFormat (+    processFile,+    processBytes,+    ProcessResult (..),+)+where++import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as BSL+import Data.Either (lefts, rights)+import Data.List (intercalate, sortBy)+import Data.Ord (Down (..), comparing)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Hasql.Check.Lexer (AlexPosn (..), runAlex)+import Hasql.Check.Parser (QuasiQuote (..), parse)+import Hasql.Check.Quoter (quotersSupported)+import Hasql.ThFormat.SqlFormat (formatSql)+import Prelude+++data ProcessResult+    = Unchanged+    | Changed+    | Error String+    deriving (Show, Eq)+++-- | Format a ByteString in memory, returning the result. The Alex lexer+-- requires a lazy ByteString so we convert at this single boundary; the rest+-- of the pipeline works on strict bytes. Returns 'Left' with a+-- newline-separated list of per-QQ errors if the formatter ever emitted+-- AST-divergent SQL; the bytes are left untouched in that case.+processBytes :: ByteString -> Either String ByteString+processBytes original =+    case runAlex (BSL.fromStrict original) parse of+        Left err -> Left err+        Right qqs ->+            let supported = reverse (filter isSupportedQuoter qqs)+                results = map makeReplacement supported+                errors = lefts results+                replacements = rights results+             in if null errors+                    then Right (applyReplacements original (sortReplacements replacements))+                    else Left (intercalate "\n" errors)+++processFile :: Bool -> FilePath -> IO ProcessResult+processFile checkOnly filePath = do+    original <- BS.readFile filePath+    case processBytes original of+        Left err -> pure (Error err)+        Right modified+            | original == modified -> pure Unchanged+            | checkOnly -> pure Changed+            | otherwise -> do+                BS.writeFile filePath modified+                pure Changed+++isSupportedQuoter :: QuasiQuote -> Bool+isSupportedQuoter (QuasiQuote (_, quoter) _) = quoter `elem` quotersSupported+++data Replacement = Replacement+    { rStartOffset :: Int+    , rEndOffset :: Int+    , rFormatted :: Text+    }+    deriving (Show)+++makeReplacement :: QuasiQuote -> Either String Replacement+makeReplacement (QuasiQuote (AlexPn quoterOffset quoterLine quoterCol, quoterName) (AlexPn endOffset _ _, sqlContent)) =+    let startOffset = quoterOffset + 1 + T.length quoterName + 1+        -- quoterCol is 1-based column of '[', so the base indent for SQL is (quoterCol - 1) spaces+        qqIndent = quoterCol - 1+     in case formatSql qqIndent sqlContent of+            Right formatted ->+                Right+                    Replacement+                        { rStartOffset = startOffset+                        , rEndOffset = endOffset+                        , rFormatted = formatted+                        }+            Left reason -> Left ("line " <> show quoterLine <> ": " <> T.unpack reason)+++sortReplacements :: [Replacement] -> [Replacement]+sortReplacements = sortBy (comparing (Down . rStartOffset))+++applyReplacements :: ByteString -> [Replacement] -> ByteString+applyReplacements = foldl applyOne+  where+    applyOne :: ByteString -> Replacement -> ByteString+    applyOne bs Replacement{rStartOffset, rEndOffset, rFormatted} =+        let before = BS.take rStartOffset bs+            after = BS.drop rEndOffset bs+            formattedBS = TE.encodeUtf8 rFormatted+         in before <> formattedBS <> after
+ app/Hasql/ThFormat/SqlFormat.hs view
@@ -0,0 +1,1950 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE FunctionalDependencies #-}++module Hasql.ThFormat.SqlFormat (+    formatSql,+)+where++import Data.Char (toLower)+import Data.List.NonEmpty qualified as NE+import Data.Maybe (catMaybes)+import Data.Text (Text)+import Data.Text qualified as T+import PostgresqlSyntax qualified as Ast+import Prettyprinter (Doc, LayoutOptions (..), PageWidth (..), align, hardline, hcat, indent, layoutPretty, nest, pretty, punctuate, vsep, (<+>))+import Prettyprinter.Render.Text (renderStrict)+import TextBuilder (TextBuilder)+import TextBuilder qualified+import Prelude+++-- | postgresql-syntax 0.5 wraps every comma-separated production in its own+-- newtype where 0.4 used a bare @NonEmpty@. Rather than unwrap at each of the+-- forty-odd call sites, 'toL' names "give me the elements" once and covers+-- both shapes.+class ToList a b | a -> b where+    toL :: a -> [b]+++instance ToList (NE.NonEmpty a) a where+    toL = NE.toList+++instance ToList Ast.ArrayExprList Ast.ArrayExpr where toL (Ast.ArrayExprList xs) = NE.toList xs+instance ToList Ast.Attrs Ast.Ident where toL (Ast.Attrs xs) = NE.toList xs+instance ToList Ast.ExprList Ast.AExpr where toL (Ast.ExprList xs) = NE.toList xs+instance ToList Ast.FromList Ast.TableRef where toL (Ast.FromList xs) = NE.toList xs+instance ToList Ast.FromClause Ast.TableRef where toL (Ast.FromClause xs) = toL xs+instance ToList Ast.GroupClause Ast.GroupByItem where toL (Ast.GroupClause xs) = NE.toList xs+instance ToList Ast.Indirection Ast.IndirectionEl where toL (Ast.Indirection xs) = NE.toList xs+#ifdef KRONOR_SYNTAX+instance ToList Ast.HsTargetList Ast.HsTargetEl where toL (Ast.HsTargetList xs) = NE.toList xs+#endif+instance ToList Ast.SetClauseList Ast.SetClause where toL (Ast.SetClauseList xs) = NE.toList xs+instance ToList Ast.SortClause Ast.SortBy where toL (Ast.SortClause xs) = NE.toList xs+instance ToList Ast.RowsfromList Ast.RowsfromItem where toL (Ast.RowsfromList xs) = NE.toList xs+instance ToList Ast.NameList Ast.Ident where toL (Ast.NameList xs) = NE.toList xs+instance ToList Ast.IndexParams Ast.IndexElem where toL (Ast.IndexParams xs) = NE.toList xs+instance ToList Ast.InsertColumnList Ast.InsertColumnItem where toL (Ast.InsertColumnList xs) = NE.toList xs+instance ToList Ast.TableFuncElementList Ast.TableFuncElement where toL (Ast.TableFuncElementList xs) = NE.toList xs+instance ToList Ast.TargetList Ast.TargetEl where toL (Ast.TargetList xs) = NE.toList xs+instance ToList Ast.ValuesClause Ast.ExprList where toL (Ast.ValuesClause xs) = NE.toList xs+instance ToList Ast.SetTargetList Ast.SetTarget where toL (Ast.SetTargetList xs) = NE.toList xs+instance ToList Ast.TypeList Ast.Typename where toL (Ast.TypeList xs) = NE.toList xs+instance ToList Ast.WhenClauseList Ast.WhenClause where toL (Ast.WhenClauseList xs) = NE.toList xs+instance ToList Ast.WindowClause Ast.WindowDefinition where toL (Ast.WindowClause xs) = NE.toList xs+++-- | Indentation size: 4 spaces per level.+indentSize :: Int+indentSize = 4+++-- | Layout options: unbounded width so explicit `line`/`hardline` are the only+-- source of line breaks; the layout never tries to flatten or reflow.+layoutOpts :: LayoutOptions+layoutOpts = LayoutOptions Unbounded+++-- | Format SQL content. qqIndent is the 0-based column of the quasiquote+-- opening bracket. The formatted output starts with a leading newline (so the+-- first SQL line is on its own line under the bracket) and ends with a newline+-- + qqIndent spaces (so the closing `|]` lines up under the opening bracket).+--+-- Returns @Right formatted@ on success, @Left reason@ if the formatter+-- emitted an AST-divergent rewrite. Callers should surface @Left@ as a+-- hard failure (the file is left untouched; callers exit non-zero rather+-- than silently committing a renderer bug). Inputs the parser rejects are+-- not considered an error — they round-trip as @Right input@ since the+-- formatter never tried to rewrite them.+formatSql :: Int -> Text -> Either Text Text+formatSql qqIndent sql+    | T.null (T.strip sql) = Right sql+    -- Single-line queries (no newlines) are left untouched.+    | not (T.isInfixOf "\n" sql) = Right sql+    | otherwise =+        case parseStmt (T.strip sql) of+            Left _ -> Right sql+            Right ast ->+                let doc :: Doc ()+                    doc = renderPreparableStmt ast+                    body = renderStrict (layoutPretty layoutOpts (indent (qqIndent + indentSize) doc))+                    qqIndentText = T.replicate qqIndent " "+                    formatted = "\n" <> body <> "\n" <> qqIndentText+                 in -- Round-trip safety: compare the re-parsed AST to the original.+                    -- Parse-only equivalence isn't enough — the renderer could drop+                    -- or alter a clause and still produce valid SQL (see the+                    -- `DistinctTargeting` regression that motivated this check).+                    case parseStmt (T.strip formatted) of+                        Right ast' | ast' == ast -> Right formatted+                        Right _ ->+                            Left "AST mismatch: formatter rendered semantically-different SQL (renderer bug)"+                        Left err ->+                            Left ("AST mismatch: formatter output did not re-parse (" <> err <> ")")+++-- | The formatter reads and writes the SQL that lives inside a hasql-th+-- quasiquote, so every Kronor extension has to survive the round trip:+-- @$1 :: int8?@ nullability markers, @$f (..)@ \/ @$R {..}@ Haskell targets+-- and @$1.$field@ Haskell param fields. Both the parser and the renderer run+-- with all three enabled, unlike the quasiquoter itself, which lowers and+-- erases them on the way to Postgres.+settings :: Ast.Settings+settings =+    mconcat+        [ Ast.nullabilityMarkers True+#ifdef KRONOR_SYNTAX+        , Ast.haskellTargets True+        , Ast.haskellParamFields True+#endif+        ]+++-- | Parse a statement with the extension syntax enabled.+parseStmt :: Text -> Either Text Ast.PreparableStmt+parseStmt = Ast.parse settings+++-- | Render any AST node with postgresql-syntax's own renderer, extensions on.+render :: Ast.IsAst a => a -> TextBuilder+render = Ast.toTextBuilder settings+++-- | 'Ast.VerbalExprBinOp' renders without its @NOT@, which lives in the+-- enclosing 'Ast.VerbalExprBinOpAExpr'. Mirrors postgresql-syntax's own+-- rendering of that node.+verbalExprBinOp :: Bool -> Ast.VerbalExprBinOp -> TextBuilder+verbalExprBinOp negated op = (if negated then "NOT " else mempty) <> render op+++-- | Same story for 'Ast.AExprReversableOp': the negation is external to the+-- type, and each alternative places it differently (@IS NOT NULL@ but+-- @NOT BETWEEN@). Mirrors postgresql-syntax's @renderAExprReversableOp@.+aExprReversableOp :: Bool -> Ast.AExprReversableOp -> TextBuilder+aExprReversableOp negated = \case+    Ast.NullAExprReversableOp -> isNot <> "NULL"+    Ast.TrueAExprReversableOp -> isNot <> "TRUE"+    Ast.FalseAExprReversableOp -> isNot <> "FALSE"+    Ast.UnknownAExprReversableOp -> isNot <> "UNKNOWN"+    Ast.DistinctFromAExprReversableOp a -> isNot <> "DISTINCT FROM " <> render a+    Ast.OfAExprReversableOp a -> isNot <> "OF (" <> render a <> ")"+    Ast.BetweenAExprReversableOp asymmetric a b ->+        notPfx <> (if asymmetric then "BETWEEN ASYMMETRIC " else "BETWEEN ") <> render a <> " AND " <> render b+    Ast.BetweenSymmetricAExprReversableOp a b ->+        notPfx <> "BETWEEN SYMMETRIC " <> render a <> " AND " <> render b+    Ast.InAExprReversableOp a -> notPfx <> "IN " <> render a+    Ast.DocumentAExprReversableOp -> isNot <> "DOCUMENT"+  where+    isNot = if negated then "IS NOT " else "IS "+    notPfx = if negated then "NOT " else mempty+++-- | 0.5 models @ROW ()@ as its own 'Ast.ExplicitRow' type where 0.4 used a+-- @Maybe@ of the expression list. The formatter still branches on the @Maybe@.+explicitRowExprs :: Ast.ExplicitRow -> Maybe Ast.ExprList+explicitRowExprs = \case+    Ast.EmptyExplicitRow -> Nothing+    Ast.ExprListExplicitRow xs -> Just xs+++-- | JSON/JSONB access operators rendered without surrounding spaces, since+-- they act like field accessors.+jsonOps :: [Text]+jsonOps = ["->", "->>", "#>", "#>>"]+++-- | Render via postgresql-syntax's renderer as a 'Doc'. Output is lowercased+-- and @:: @ typecast spacing is tightened to @::@, while content inside+-- single-quoted string literals (@'...'@) and double-quoted identifiers+-- (@"..."@) is preserved verbatim.+inline :: (a -> TextBuilder) -> a -> Doc ann+inline f = pretty . inlineText f+++-- | Same as 'inline' but stays at the Text layer. Useful when we need to do+-- a string comparison on the rendered output (e.g. detecting a JSON access+-- operator) before lifting it into a 'Doc'.+inlineText :: (a -> TextBuilder) -> a -> Text+inlineText f = inlineNormalize . TextBuilder.toText . f+++-- | Lowercase keywords and tighten @ :: @ to @::@ in a single pass, while+-- preserving the contents of single-quoted string literals and double-quoted+-- identifiers verbatim. Standard SQL doubled-quote escapes (@''@ and @""@)+-- are recognised so the scanner stays inside the literal until the real+-- closing quote.+inlineNormalize :: Text -> Text+inlineNormalize = T.pack . goCode . T.unpack+  where+    goCode :: String -> String+    goCode [] = []+    goCode ('\'' : rest) = '\'' : goString '\'' rest+    goCode ('"' : rest) = '"' : goString '"' rest+    goCode (' ' : ':' : ':' : ' ' : rest) = "::" ++ goCode rest+    goCode (c : rest) = toLower c : goCode rest++    goString :: Char -> String -> String+    goString _ [] = []+    -- Doubled-quote escape: stay inside the literal.+    goString q (c1 : c2 : rest) | c1 == q && c2 == q = c1 : c2 : goString q rest+    goString q (c : rest)+        | c == q = c : goCode rest+        | otherwise = c : goString q rest+++----------------------------------------------------------------------+-- Expression / leaf renderers (return 'Doc ann' — inline single-line by+-- construction; structural renderers below use the same type for+-- multi-line layouts).+----------------------------------------------------------------------++renderExpr :: Ast.AExpr -> Doc ann+renderExpr = \case+    Ast.TypecastAExpr expr tn ->+        renderExpr expr <> "::" <> renderTypename tn+    Ast.AndAExpr left right ->+        renderExpr left <+> "and" <+> renderExpr right+    Ast.OrAExpr left right ->+        renderExpr left <+> "or" <+> renderExpr right+    Ast.NotAExpr expr ->+        "not" <+> renderExpr expr+    Ast.CExprAExpr cexpr -> renderCExpr cexpr+    Ast.SymbolicBinOpAExpr left op right ->+        let opText = inlineText render op+            opDoc = pretty opText+         in if opText `elem` jsonOps+                then renderExpr left <> opDoc <> renderExpr right+                else renderExpr left <+> opDoc <+> renderExpr right+    Ast.PlusAExpr expr -> "+" <> renderExpr expr+    Ast.MinusAExpr expr -> "-" <> renderExpr expr+    Ast.ReversableOpAExpr expr negated op ->+        renderExpr expr <+> renderReversableOp negated op+    Ast.IsnullAExpr expr -> renderExpr expr <+> "isnull"+    Ast.NotnullAExpr expr -> renderExpr expr <+> "notnull"+    Ast.CollateAExpr expr name -> renderExpr expr <+> "collate" <+> inline render name+    Ast.AtTimeZoneAExpr expr tz -> renderExpr expr <+> "at time zone" <+> renderExpr tz+    Ast.PrefixQualOpAExpr op expr -> inline render op <+> renderExpr expr+    Ast.VerbalExprBinOpAExpr left negated op right mEscape ->+        renderExpr left+            <+> inline (verbalExprBinOp negated) op+            <+> renderExpr right+            <> maybe mempty (\e -> " escape" <+> renderExpr e) mEscape+    Ast.SubqueryAExpr expr op subType subq ->+        renderExpr expr+            <+> inline render op+            <+> inline render subType+            <> either+                ((" " <>) . renderSelectWithParensMaybeWrap)+                (\e -> "(" <> renderExprMaybeWrap e <> ")")+                subq+    Ast.DefaultAExpr -> "default"+    Ast.UniqueAExpr swp -> "unique" <+> renderSelectWithParensMaybeWrap swp+    Ast.OverlapsAExpr r1 r2 -> inline render r1 <+> "overlaps" <+> inline render r2+++renderCExpr :: Ast.CExpr -> Doc ann+renderCExpr = \case+    Ast.ParamCExpr n mInd ->+        "$" <> pretty n <> maybe mempty renderIndirection mInd+    Ast.InParensCExpr expr mInd ->+        "(" <> renderExpr expr <> ")" <> maybe mempty renderIndirection mInd+    Ast.FuncCExpr fe -> renderFuncExpr fe+    Ast.ColumnrefCExpr cr -> inline render cr+    Ast.AexprConstCExpr ac -> inline render ac+    Ast.SelectWithParensCExpr swp mInd ->+        renderSelectWithParensMaybeWrap swp <> maybe mempty renderIndirection mInd+    Ast.ExistsCExpr swp -> "exists" <+> renderSelectWithParensMaybeWrap swp+    Ast.CaseCExpr caseExpr -> renderCaseExpr caseExpr+    Ast.ArrayCExpr arr -> "array" <> either renderSelectWithParensMaybeWrap renderArrayExpr arr+    Ast.ExplicitRowCExpr row ->+        "row(" <> maybe mempty (commaJoin . map renderExpr . toL) (explicitRowExprs row) <> ")"+    Ast.ImplicitRowCExpr (Ast.ImplicitRow exprs expr) ->+        "(" <> commaJoin (map renderExpr (toL exprs) ++ [renderExpr expr]) <> ")"+    other -> inline render other+++-- | Comma-and-space-separated concatenation of inline 'Doc's. Used wherever+-- the previous Text-based renderer used 'T.intercalate ", "'.+commaJoin :: [Doc ann] -> Doc ann+commaJoin = hcat . punctuate ", "+++renderArrayExpr :: Ast.ArrayExpr -> Doc ann+renderArrayExpr = \case+    Ast.ExprListArrayExpr exprs ->+        "[" <> commaJoin (map renderExpr (toL exprs)) <> "]"+    Ast.ArrayExprListArrayExpr nested ->+        "[" <> commaJoin (map renderArrayExpr (toL nested)) <> "]"+    Ast.EmptyArrayExpr -> "[]"+++renderCaseExpr :: Ast.CaseExpr -> Doc ann+renderCaseExpr (Ast.CaseExpr mArg whenClauses mDefault) =+    "case"+        <> maybe mempty (\arg -> " " <> renderExpr arg) mArg+        <> mconcat [" " <> renderWhenClause w | w <- toL whenClauses]+        <> maybe mempty (\d -> " else" <+> renderExpr d) mDefault+        <+> "end"+++renderWhenClause :: Ast.WhenClause -> Doc ann+renderWhenClause (Ast.WhenClause cond result) =+    "when" <+> renderExpr cond <+> "then" <+> renderExpr result+++renderIndirection :: Ast.Indirection -> Doc ann+renderIndirection = mconcat . map renderIndirectionEl . toL+++renderIndirectionEl :: Ast.IndirectionEl -> Doc ann+renderIndirectionEl = \case+    Ast.AttrNameIndirectionEl ident -> "." <> renderIdent ident+    Ast.AllIndirectionEl -> ".*"+    Ast.ExprIndirectionEl expr -> "[" <> renderExpr expr <> "]"+    Ast.SliceIndirectionEl mFrom mTo ->+        "[" <> maybe mempty renderExpr mFrom <> ":" <> maybe mempty renderExpr mTo <> "]"+#ifdef KRONOR_SYNTAX+    -- Kronor extension: a Haskell field selector on a @$n@ placeholder.+    Ast.HsAttrNameIndirectionEl t -> ".$" <> pretty t+#endif+++renderIdent :: Ast.Ident -> Doc ann+renderIdent = \case+    Ast.QuotedIdent t -> "\"" <> pretty t <> "\""+    Ast.UnquotedIdent t -> pretty t+++renderFuncExpr :: Ast.FuncExpr -> Doc ann+renderFuncExpr = \case+    Ast.ApplicationFuncExpr app mWithin mFilter mOver ->+        renderFuncApplication app+            <> maybe mempty (\w -> " " <> inline render w) mWithin+            <> maybe mempty (\f -> " filter (where" <+> renderExpr f <> ")") mFilter+            <> maybe mempty (\o -> " " <> inline render o) mOver+    Ast.SubexprFuncExpr sub -> renderFuncExprCommonSubexpr sub+++renderFuncApplication :: Ast.FuncApplication -> Doc ann+renderFuncApplication (Ast.FuncApplication name mParams) =+    inline render name <> "(" <> maybe mempty renderFuncAppParams mParams <> ")"+++-- | Threshold (number of positional arguments) at which a function call's+-- argument list is broken onto its own line per argument. A call with+-- fewer args than this threshold renders inline; one with this many or more+-- wraps. Currently set to 2, so single-arg calls (e.g. @count(*)@,+-- @md5(x)@, @unnest(arr)@) stay inline.+funcWrapThreshold :: Int+funcWrapThreshold = 2+++-- | Number of positional arguments in a function application (variadic args+-- count as one each, star/none count as zero).+funcAppArgCount :: Ast.FuncApplication -> Int+funcAppArgCount (Ast.FuncApplication _ mParams) = case mParams of+    Nothing -> 0+    Just (Ast.NormalFuncApplicationParams _ args _) -> length (toL args)+    Just (Ast.VariadicFuncApplicationParams mArgs _ _) -> maybe 0 (length . toL) mArgs + 1+    Just Ast.StarFuncApplicationParams -> 0+++-- | Multi-line rendering of a function application using the leading-comma+-- block-list layout that's standard elsewhere in this formatter:+--+-- @+-- fn(+--       arg1+--     , arg2+--     , arg3+-- )+-- @+--+-- The args sit at @outer + indentSize + 2@ (first) and @outer + indentSize@+-- (`, ` prefix on subsequent), where @outer@ is the surrounding nesting+-- level. The closing paren returns to @outer@; this can put it visually+-- left of the function name when the function call is itself indented+-- (e.g. as a leading-comma item), but it keeps the layout predictable.+renderFuncApplicationWrapped :: Ast.FuncApplication -> Doc ann+renderFuncApplicationWrapped (Ast.FuncApplication name mParams) =+    inline render name+        <> "("+        <> nest indentSize (maybe mempty wrappedBody mParams)+        <> hardline+        <> ")"+  where+    wrappedBody params = case params of+        Ast.NormalFuncApplicationParams mDistinct args mSort ->+            let argList = toL args+                argDocs = map renderFuncArgExprMaybeWrap argList+                prefix = maybe mempty (\b -> if b then "distinct " else "all ") mDistinct+                rows =+                    if looksLikeKeyValuePairs argList+                        then renderArgPairRows argDocs+                        else renderArgRows argDocs+                sortDoc = maybe mempty (\sc -> hardline <> inline render sc) mSort+             in prefix <> rows <> sortDoc+        Ast.VariadicFuncApplicationParams mArgs varArg mSort ->+            let regular = maybe [] (map renderFuncArgExprMaybeWrap . toL) mArgs+                variadic = "variadic " <> renderFuncArgExprMaybeWrap varArg+                rows = renderArgRows (regular ++ [variadic])+                sortDoc = maybe mempty (\sc -> hardline <> inline render sc) mSort+             in rows <> sortDoc+        Ast.StarFuncApplicationParams -> hardline <> "*"+++-- | First arg: hardline, then 2 extra spaces (so arg sits at col + 6), then+-- the arg. Subsequent args: hardline, then @, @ (so the comma sits at col ++-- 4 and the arg content also at col + 6). Used by every wrapped renderer+-- that emits a leading-comma block-list.+renderArgRows :: [Doc ann] -> Doc ann+renderArgRows = \case+    [] -> mempty+    (x : xs) ->+        (hardline <> "  " <> x)+            <> mconcat [hardline <> ", " <> r | r <- xs]+++-- | Pair layout for key/value-shaped arg lists. Each pair lives on its own+-- line (key + inline @, @ + value). The first pair uses the same "  " row+-- prefix as 'renderArgRows'; subsequent pairs use the leading @, @ row+-- separator. Caller guarantees an even-length input.+renderArgPairRows :: [Doc ann] -> Doc ann+renderArgPairRows = goFirst . pairs+  where+    pairs (a : b : rest) = (a, b) : pairs rest+    pairs _ = []++    goFirst [] = mempty+    goFirst ((k, v) : rest) =+        (hardline <> "  " <> k <> ", " <> v)+            <> mconcat [hardline <> ", " <> k' <> ", " <> v' | (k', v') <- rest]+++-- | If an expression is a top-level function call (optionally wrapped in any+-- number of chained typecasts), return the function application along with a+-- function that re-attaches the typecasts to a Doc, preserving their order.+-- Used to detect "wrap-eligible" expressions in select target lists.+topLevelFuncApp :: Ast.AExpr -> Maybe (Ast.FuncApplication, Doc ann -> Doc ann)+topLevelFuncApp = \case+    Ast.TypecastAExpr inner tn+        | Just (app, attach) <- topLevelFuncApp inner ->+            Just (app, \d -> attach d <> "::" <> renderTypename tn)+    Ast.CExprAExpr (Ast.FuncCExpr (Ast.ApplicationFuncExpr app Nothing Nothing Nothing)) ->+        Just (app, id)+    _ -> Nothing+++-- | Heuristic: an arg list "looks like" alternating key/value pairs when it+-- has at least 4 args, an even count, and every even-indexed argument+-- (positions 0, 2, 4, ...) is a string-literal expression. This catches+-- @jsonb_build_object('k1', v1, 'k2', v2, ...)@ and friends without naming+-- specific functions, so the layout adapts to anything with the same shape.+looksLikeKeyValuePairs :: [Ast.FuncArgExpr] -> Bool+looksLikeKeyValuePairs args =+    let n = length args+     in even n && n >= 4 && all isLiteralKey (zip [0 :: Int ..] args)+  where+    isLiteralKey (i, arg)+        | even i = case arg of+            Ast.ExprFuncArgExpr e -> isStringLiteral e+            _ -> False+        | otherwise = True++    isStringLiteral :: Ast.AExpr -> Bool+    isStringLiteral = \case+        Ast.CExprAExpr (Ast.AexprConstCExpr (Ast.SAexprConst _)) -> True+        _ -> False+++-- | An expression is "wrap-eligible" when it has a structural shape the+-- multi-line renderers know how to lay out: a function call (optionally+-- under chained typecasts), an @array[...]@ literal whose elements+-- themselves contain wrap-eligible content, a @row(...)@ / @(a, b, c)@+-- tuple, a parenthesized expression, a @case@ expression, or one of the+-- SQL keyword-form pseudo-functions (@coalesce@, @cast@, @nullif@,+-- @greatest@, @least@, @position@, @substring@, @trim@, @overlay@,+-- @treat@). The decision to actually wrap (vs render inline) is made by+-- 'renderFuncArgExprMaybeWrap' / 'renderExprMaybeWrap' based on the+-- inline rendered length.+isWrapEligibleExpr :: Ast.AExpr -> Bool+isWrapEligibleExpr = \case+    Ast.TypecastAExpr inner _ -> isWrapEligibleExpr inner+    Ast.CExprAExpr cexpr -> isWrapEligibleCExpr cexpr+    _ -> False+++isWrapEligibleCExpr :: Ast.CExpr -> Bool+isWrapEligibleCExpr = \case+    Ast.FuncCExpr (Ast.ApplicationFuncExpr _ Nothing Nothing Nothing) -> True+    Ast.FuncCExpr (Ast.SubexprFuncExpr sub) -> isWrapEligibleSubexpr sub+    Ast.ArrayCExpr (Right arr) -> isWrapEligibleArray arr+    Ast.InParensCExpr inner _ -> isWrapEligibleExpr inner+    Ast.ExplicitRowCExpr row ->+        any isWrapEligibleExpr (maybe [] toL (explicitRowExprs row))+    Ast.ImplicitRowCExpr (Ast.ImplicitRow exprs expr) ->+        any isWrapEligibleExpr (toL exprs) || isWrapEligibleExpr expr+    -- @case ... when ... end@ is always wrap-eligible: even when the+    -- WHEN/THEN bodies are simple, the multi-line layout reads much+    -- better once the inline form gets long (e.g. multi-clause SET RHS+    -- assignments). The length gate decides when to actually wrap.+    Ast.CaseCExpr _ -> True+    _ -> False+++-- | An array literal is wrap-eligible when any of its elements is itself+-- wrap-eligible (so a long @array[fn(...)]@ expands to multi-line by+-- propagating the wrap into the contained function call).+isWrapEligibleArray :: Ast.ArrayExpr -> Bool+isWrapEligibleArray = \case+    Ast.ExprListArrayExpr exprs -> any isWrapEligibleExpr (toL exprs)+    Ast.ArrayExprListArrayExpr nested -> any isWrapEligibleArray (toL nested)+    Ast.EmptyArrayExpr -> False+++-- | The SQL keyword-form pseudo-functions (@coalesce@, @cast@, @nullif@,+-- @greatest@, @least@, @position@, @substring@, @trim@, @overlay@,+-- @treat@, @extract@) are always wrap-eligible — when their inline form+-- gets too long, the wrapped layout puts each argument on its own line+-- using 'renderArgRows'.+isWrapEligibleSubexpr :: Ast.FuncExprCommonSubexpr -> Bool+isWrapEligibleSubexpr = \case+    Ast.CoalesceFuncExprCommonSubexpr _ -> True+    Ast.CastFuncExprCommonSubexpr _ _ -> True+    Ast.NullIfFuncExprCommonSubexpr _ _ -> True+    Ast.GreatestFuncExprCommonSubexpr _ -> True+    Ast.LeastFuncExprCommonSubexpr _ -> True+    Ast.PositionFuncExprCommonSubexpr _ -> True+    Ast.SubstringFuncExprCommonSubexpr _ -> True+    Ast.TrimFuncExprCommonSubexpr _ _ -> True+    Ast.OverlayFuncExprCommonSubexpr _ -> True+    Ast.TreatFuncExprCommonSubexpr _ _ -> True+    Ast.ExtractFuncExprCommonSubexpr _ -> True+    _ -> False+++-- | Inline-length threshold above which a wrap-eligible sub-expression+-- switches from inline to multi-line rendering inside an already-wrapped+-- function call. Short calls like @jsonb_build_object('a', $1)@ stay on+-- one line; long ones like+-- @array_agg(mk_payload(caller => '...', payload => ...))::jsonb[]@ break+-- onto multiple lines, recursing into their own arguments.+inlineLengthThreshold :: Int+inlineLengthThreshold = 60+++-- | Wrap-aware variant of 'renderFuncArgExpr'. Used inside+-- 'renderFuncApplicationWrapped' so that nested function-call arguments+-- expand to multi-line when their inline form exceeds+-- 'inlineLengthThreshold', and stay inline otherwise. Pair-shaped+-- sub-calls (e.g. nested @jsonb_build_object(...)@) always wrap when+-- they appear inside an already-wrapped parent — keeping the visual+-- consistency of the @(key, value)@ pairs even when the inner call's+-- inline form would fit under the threshold.+renderFuncArgExprMaybeWrap :: Ast.FuncArgExpr -> Doc ann+renderFuncArgExprMaybeWrap arg =+    let inlineDoc = renderFuncArgExpr arg+        inlineLen = T.length (renderToText inlineDoc)+        forceWrap = isPairShapedArg arg+     in if (inlineLen > inlineLengthThreshold || forceWrap) && argIsWrapEligible arg+            then renderFuncArgExprWrapped arg+            else inlineDoc+  where+    argIsWrapEligible :: Ast.FuncArgExpr -> Bool+    argIsWrapEligible = \case+        Ast.ExprFuncArgExpr e -> isWrapEligibleExpr e+        Ast.ColonEqualsFuncArgExpr _ e -> isWrapEligibleExpr e+        Ast.EqualsGreaterFuncArgExpr _ e -> isWrapEligibleExpr e++    isPairShapedArg :: Ast.FuncArgExpr -> Bool+    isPairShapedArg = \case+        Ast.ExprFuncArgExpr e -> hasPairShapedCall e+        Ast.ColonEqualsFuncArgExpr _ e -> hasPairShapedCall e+        Ast.EqualsGreaterFuncArgExpr _ e -> hasPairShapedCall e+++-- | True when the expression is a function call (optionally under chained+-- typecasts) whose argument list satisfies 'looksLikeKeyValuePairs'. Used+-- to force-wrap nested @jsonb_build_object@-shaped values so a wrapped+-- pair-grouped parent doesn't have inline pair-grouped children+-- (visually inconsistent).+hasPairShapedCall :: Ast.AExpr -> Bool+hasPairShapedCall = \case+    Ast.TypecastAExpr inner _ -> hasPairShapedCall inner+    Ast.CExprAExpr (Ast.FuncCExpr (Ast.ApplicationFuncExpr (Ast.FuncApplication _ (Just (Ast.NormalFuncApplicationParams _ args _))) Nothing Nothing Nothing)) ->+        looksLikeKeyValuePairs (toL args)+    _ -> False+++-- | Render a wrap-eligible 'FuncArgExpr' multi-line. The named-arg prefix+-- (@name := @ / @name => @) stays on the same line as the function name and+-- @(@; only the function body wraps.+renderFuncArgExprWrapped :: Ast.FuncArgExpr -> Doc ann+renderFuncArgExprWrapped = \case+    Ast.ExprFuncArgExpr e -> renderExprWrapped e+    Ast.ColonEqualsFuncArgExpr ident e -> inline render ident <> " := " <> renderExprWrapped e+    Ast.EqualsGreaterFuncArgExpr ident e -> inline render ident <> " => " <> renderExprWrapped e+++-- | Render a wrap-eligible expression multi-line: peel chained typecasts,+-- then dispatch the underlying function application through+-- 'renderFuncApplicationWrapped' (or @array[...]@ literals through+-- 'renderArrayExprWrapped'). Recursion into deeper levels happens via+-- those wrappers calling 'renderFuncArgExprMaybeWrap' /+-- 'renderExprMaybeWrap' on their own elements.+renderExprWrapped :: Ast.AExpr -> Doc ann+renderExprWrapped = \case+    Ast.TypecastAExpr inner tn ->+        renderExprWrapped inner <> "::" <> renderTypename tn+    Ast.CExprAExpr (Ast.FuncCExpr (Ast.ApplicationFuncExpr app Nothing Nothing Nothing)) ->+        renderFuncApplicationWrapped app+    Ast.CExprAExpr (Ast.FuncCExpr (Ast.SubexprFuncExpr sub)) ->+        renderFuncExprCommonSubexprWrapped sub+    Ast.CExprAExpr (Ast.ArrayCExpr (Right arr)) ->+        "array" <> renderArrayExprWrapped arr+    Ast.CExprAExpr (Ast.InParensCExpr inner mInd) ->+        renderInParensWrapped inner mInd+    Ast.CExprAExpr (Ast.ExplicitRowCExpr row) ->+        renderExplicitRowWrapped (explicitRowExprs row)+    Ast.CExprAExpr (Ast.ImplicitRowCExpr row) ->+        renderImplicitRowWrapped row+    Ast.CExprAExpr (Ast.CaseCExpr caseExpr) ->+        renderCaseExprWrapped caseExpr+    other -> renderExpr other+++-- | Multi-line @(expr)@: open paren on the current line, inner expression+-- nested one level deeper on its own block, closing paren back at the+-- caller's column. The optional indirection (@.field@, @[index]@) appends+-- after the closing paren on the same line.+--+-- When the inner expression is a @case@ (with optional surrounding parens+-- / typecasts) we force-wrap it via 'renderExprWrapped' regardless of its+-- inline length, matching the top-level @case@-always-wraps rule used in+-- 'renderExprDoc' — otherwise a short CASE inside a wrapped parens would+-- collapse onto a single line while its enclosing parens stay multi-line.+--+-- When the inner is a direct @CaseCExpr@ (with no indirection) we emit+-- the compact form @(case ... end)@ — the @case@ keyword on the same+-- line as @(@, and @end)@ together at the close. The @case@/@end@+-- keywords already give the block clear visual structure; opening/closing+-- parens on their own lines are redundant ceremony.+renderInParensWrapped :: Ast.AExpr -> Maybe Ast.Indirection -> Doc ann+renderInParensWrapped expr mInd =+    case (mInd, expr) of+        (Nothing, Ast.CExprAExpr (Ast.CaseCExpr c)) ->+            -- Compact form: `(case ... end)`. We use 'align' to capture+            -- the actual column at @(@ so the case body indents relative+            -- to that column regardless of how the caller positioned us+            -- mid-line (e.g. via explicit @firstPad@ in a SELECT block+            -- list, or via the @"  "@ prefix from 'renderArgRows' inside+            -- a wrapped function call). Body lands at @(-col ++            -- indentSize@; @end@ lines up with @(@; @)@ follows @end@ on+            -- the same line.+            align ("(" <> renderCaseExprWrapped c <> ")")+        _ ->+            let body =+                    if isTopLevelCase expr+                        then renderExprWrapped expr+                        else renderExprMaybeWrap expr+             in "("+                    <> nest indentSize (hardline <> body)+                    <> hardline+                    <> ")"+                    <> maybe mempty renderIndirection mInd+++-- | Multi-line @row(...)@ / @(a, b, c)@: same leading-comma block-list+-- as a wrapped function call.+renderExplicitRowWrapped :: Maybe Ast.ExprList -> Doc ann+renderExplicitRowWrapped mExprs =+    let argDocs = maybe [] (map renderExprMaybeWrap . toL) mExprs+     in "row(" <> nest indentSize (renderArgRows argDocs) <> hardline <> ")"+++renderImplicitRowWrapped :: Ast.ImplicitRow -> Doc ann+renderImplicitRowWrapped (Ast.ImplicitRow exprs expr) =+    let argDocs = map renderExprMaybeWrap (toL exprs) ++ [renderExprMaybeWrap expr]+     in "(" <> nest indentSize (renderArgRows argDocs) <> hardline <> ")"+++-- | Multi-line @case@ expression: each @when ... then ...@ on its own line,+-- @else@ and @end@ on their own lines too. Matches the layout convention+-- used by hand-written SQL.+--+-- Inside a wrapped CASE, any @(select ...)@ subquery in a THEN or ELSE+-- body force-wraps as a multi-line SELECT block (regardless of length)+-- via 'renderExprForceSubqueryWrap'. The convention is that once we've+-- decided the CASE itself reads better multi-line, an inline+-- @(select x from t)@ inside one of its arms looks visually inconsistent+-- next to the multi-line WHEN/ELSE structure — so we expand it too.+renderCaseExprWrapped :: Ast.CaseExpr -> Doc ann+renderCaseExprWrapped (Ast.CaseExpr mArg whenClauses mDefault) =+    "case"+        <> maybe mempty (\arg -> " " <> renderExpr arg) mArg+        <> nest+            indentSize+            ( mconcat [hardline <> renderWhenClauseWrapped w | w <- toL whenClauses]+                <> maybe mempty (\d -> hardline <> "else" <+> renderExprForceSubqueryWrap d) mDefault+            )+        <> hardline+        <> "end"+++renderWhenClauseWrapped :: Ast.WhenClause -> Doc ann+renderWhenClauseWrapped (Ast.WhenClause cond result) =+    "when" <+> renderExprMaybeWrap cond <+> "then" <+> renderExprForceSubqueryWrap result+++-- | Like 'renderExprMaybeWrap' but force-wraps any directly-applied+-- @(select ...)@ subquery (with optional chained typecast) regardless of+-- inline length. Used inside wrapped CASE arms; falls through to+-- 'renderExprMaybeWrap' for everything that isn't a parenthesized SELECT.+renderExprForceSubqueryWrap :: Ast.AExpr -> Doc ann+renderExprForceSubqueryWrap = \case+    Ast.CExprAExpr (Ast.SelectWithParensCExpr swp mInd) ->+        renderSelectWithParensWrapped swp <> maybe mempty renderIndirection mInd+    Ast.TypecastAExpr inner tn ->+        renderExprForceSubqueryWrap inner <> "::" <> renderTypename tn+    e -> renderExprMaybeWrap e+++-- | Multi-line keyword-form pseudo-function. Each constructor knows the+-- syntactic separator between its operands (commas for @coalesce@,+-- @greatest@, @least@; @ as @ for @cast@/@treat@; @ from @ for @extract@,+-- @substring@, @trim@; @ in @ for @position@; @ placing @ /@ from @/@ for @+-- for @overlay@), and uses 'renderArgRows' for the comma-separated cases.+renderFuncExprCommonSubexprWrapped :: Ast.FuncExprCommonSubexpr -> Doc ann+renderFuncExprCommonSubexprWrapped = \case+    Ast.CoalesceFuncExprCommonSubexpr exprs ->+        let argDocs = map renderExprMaybeWrap (toL exprs)+         in "coalesce(" <> nest indentSize (renderArgRows argDocs) <> hardline <> ")"+    Ast.GreatestFuncExprCommonSubexpr exprs ->+        let argDocs = map renderExprMaybeWrap (toL exprs)+         in "greatest(" <> nest indentSize (renderArgRows argDocs) <> hardline <> ")"+    Ast.LeastFuncExprCommonSubexpr exprs ->+        let argDocs = map renderExprMaybeWrap (toL exprs)+         in "least(" <> nest indentSize (renderArgRows argDocs) <> hardline <> ")"+    Ast.NullIfFuncExprCommonSubexpr e1 e2 ->+        let argDocs = [renderExprMaybeWrap e1, renderExprMaybeWrap e2]+         in "nullif(" <> nest indentSize (renderArgRows argDocs) <> hardline <> ")"+    Ast.CastFuncExprCommonSubexpr expr tn ->+        "cast("+            <> nest indentSize (hardline <> renderExprMaybeWrap expr <+> "as" <+> renderTypename tn)+            <> hardline+            <> ")"+    Ast.TreatFuncExprCommonSubexpr expr tn ->+        "treat("+            <> nest indentSize (hardline <> renderExprMaybeWrap expr <+> "as" <+> renderTypename tn)+            <> hardline+            <> ")"+    -- Keyword-form functions whose argument structure isn't a simple+    -- comma-separated list. We render them inline; if length is the+    -- problem we fall back to the inline renderer (which is short+    -- compared to other cases).+    other -> renderFuncExprCommonSubexpr other+++-- | Wrap-aware variant for an 'AExpr'. Renders inline first; if the inline+-- form exceeds 'inlineLengthThreshold' AND the expression is structurally+-- wrap-eligible, switches to 'renderExprWrapped'. Pair-shaped sub-calls+-- (nested @jsonb_build_object@-style) force-wrap regardless of length so a+-- wrapped pair-grouped parent doesn't have inline pair-grouped children.+renderExprMaybeWrap :: Ast.AExpr -> Doc ann+renderExprMaybeWrap e =+    let inlineDoc = renderExpr e+        inlineLen = T.length (renderToText inlineDoc)+        forceWrap = hasPairShapedCall e+     in if (inlineLen > inlineLengthThreshold || forceWrap) && isWrapEligibleExpr e+            then renderExprWrapped e+            else inlineDoc+++-- | Multi-line render of an @array[...]@ literal: open bracket on the+-- current line, elements laid out with the same leading-comma block-list+-- as 'renderFuncApplicationWrapped', closing bracket back at the caller's+-- column. Each element is rendered through 'renderExprMaybeWrap', so a+-- long inner function call wraps recursively.+renderArrayExprWrapped :: Ast.ArrayExpr -> Doc ann+renderArrayExprWrapped = \case+    Ast.ExprListArrayExpr exprs ->+        let argDocs = map renderExprMaybeWrap (toL exprs)+         in "[" <> nest indentSize (renderArgRows argDocs) <> hardline <> "]"+    Ast.ArrayExprListArrayExpr nested ->+        let argDocs = map (\a -> "array" <> renderArrayExprWrapped a) (toL nested)+         in "[" <> nest indentSize (renderArgRows argDocs) <> hardline <> "]"+    Ast.EmptyArrayExpr -> "[]"+++renderFuncAppParams :: Ast.FuncApplicationParams -> Doc ann+renderFuncAppParams = \case+    Ast.NormalFuncApplicationParams mDistinct args mSort ->+        maybe mempty (\b -> if b then "distinct " else "all ") mDistinct+            <> commaJoin (map renderFuncArgExpr (toL args))+            <> maybe mempty (\sc -> " " <> inline render sc) mSort+    Ast.VariadicFuncApplicationParams mArgs varArg mSort ->+        maybe mempty (\args -> commaJoin (map renderFuncArgExpr (toL args)) <> ", ") mArgs+            <> "variadic "+            <> renderFuncArgExpr varArg+            <> maybe mempty (\sc -> " " <> inline render sc) mSort+    Ast.StarFuncApplicationParams -> "*"+++renderFuncArgExpr :: Ast.FuncArgExpr -> Doc ann+renderFuncArgExpr = \case+    Ast.ExprFuncArgExpr e -> renderExpr e+    Ast.ColonEqualsFuncArgExpr ident e -> inline render ident <> " := " <> renderExpr e+    Ast.EqualsGreaterFuncArgExpr ident e -> inline render ident <> " => " <> renderExpr e+++renderFuncExprCommonSubexpr :: Ast.FuncExprCommonSubexpr -> Doc ann+renderFuncExprCommonSubexpr = \case+    Ast.CoalesceFuncExprCommonSubexpr exprs ->+        "coalesce(" <> commaJoin (map renderExpr (toL exprs)) <> ")"+    Ast.CastFuncExprCommonSubexpr expr tn ->+        "cast(" <> renderExpr expr <+> "as" <+> renderTypename tn <> ")"+    Ast.NullIfFuncExprCommonSubexpr e1 e2 ->+        "nullif(" <> renderExpr e1 <> ", " <> renderExpr e2 <> ")"+    Ast.GreatestFuncExprCommonSubexpr exprs ->+        "greatest(" <> commaJoin (map renderExpr (toL exprs)) <> ")"+    Ast.LeastFuncExprCommonSubexpr exprs ->+        "least(" <> commaJoin (map renderExpr (toL exprs)) <> ")"+    Ast.ExtractFuncExprCommonSubexpr mList ->+        "extract(" <> maybe mempty renderExtractList mList <> ")"+    Ast.TreatFuncExprCommonSubexpr expr tn ->+        "treat(" <> renderExpr expr <+> "as" <+> renderTypename tn <> ")"+    Ast.PositionFuncExprCommonSubexpr mList ->+        "position(" <> maybe mempty renderPositionList mList <> ")"+    Ast.SubstringFuncExprCommonSubexpr mList ->+        "substring(" <> maybe mempty renderSubstrList mList <> ")"+    Ast.TrimFuncExprCommonSubexpr mModifier list ->+        "trim(" <> renderTrimModifier mModifier <> renderTrimList list <> ")"+    Ast.OverlayFuncExprCommonSubexpr list ->+        "overlay(" <> renderOverlayList list <> ")"+    other -> inline render other+++renderPositionList :: Ast.PositionList -> Doc ann+renderPositionList (Ast.PositionList needle haystack) =+    inline render needle <+> "in" <+> inline render haystack+++renderSubstrList :: Ast.SubstrList -> Doc ann+renderSubstrList = \case+    Ast.ExprListSubstrList exprs -> commaJoin (map renderExpr (toL exprs))+    Ast.ExprSubstrList expr fromFor -> renderExpr expr <+> renderSubstrListFromFor fromFor+++renderSubstrListFromFor :: Ast.SubstrListFromFor -> Doc ann+renderSubstrListFromFor = \case+    Ast.FromForSubstrListFromFor e1 e2 ->+        "from" <+> renderExpr e1 <+> "for" <+> renderExpr e2+    Ast.ForFromSubstrListFromFor e1 e2 ->+        "for" <+> renderExpr e1 <+> "from" <+> renderExpr e2+    Ast.FromSubstrListFromFor e -> "from" <+> renderExpr e+    Ast.ForSubstrListFromFor e -> "for" <+> renderExpr e+++renderTrimModifier :: Maybe Ast.TrimModifier -> Doc ann+renderTrimModifier = \case+    Nothing -> mempty+    Just Ast.BothTrimModifier -> "both "+    Just Ast.LeadingTrimModifier -> "leading "+    Just Ast.TrailingTrimModifier -> "trailing "+++renderTrimList :: Ast.TrimList -> Doc ann+renderTrimList = \case+    Ast.ExprFromExprListTrimList chars from ->+        renderExpr chars <+> "from" <+> commaJoin (map renderExpr (toL from))+    Ast.FromExprListTrimList from ->+        "from" <+> commaJoin (map renderExpr (toL from))+    Ast.ExprListTrimList exprs ->+        commaJoin (map renderExpr (toL exprs))+++renderOverlayList :: Ast.OverlayList -> Doc ann+renderOverlayList (Ast.OverlayList target placing fromE mForE) =+    renderExpr target+        <+> "placing"+        <+> renderExpr placing+        <+> "from"+        <+> renderExpr fromE+        <> maybe mempty (\forE -> " for" <+> renderExpr forE) mForE+++renderExtractList :: Ast.ExtractList -> Doc ann+renderExtractList (Ast.ExtractList arg expr) =+    renderExtractArg arg <+> "from" <+> renderExpr expr+++renderExtractArg :: Ast.ExtractArg -> Doc ann+renderExtractArg = \case+    Ast.IdentExtractArg ident -> renderIdent ident+    Ast.YearExtractArg -> "year"+    Ast.MonthExtractArg -> "month"+    Ast.DayExtractArg -> "day"+    Ast.HourExtractArg -> "hour"+    Ast.MinuteExtractArg -> "minute"+    Ast.SecondExtractArg -> "second"+    Ast.SconstExtractArg (Ast.Sconst s) -> "'" <> pretty s <> "'"+++renderBExpr :: Ast.BExpr -> Doc ann+renderBExpr = \case+    Ast.CExprBExpr ce -> renderCExpr ce+    Ast.TypecastBExpr be tn -> renderBExpr be <> "::" <> renderTypename tn+    Ast.PlusBExpr be -> "+" <> renderBExpr be+    Ast.MinusBExpr be -> "-" <> renderBExpr be+    Ast.SymbolicBinOpBExpr left op right ->+        renderBExpr left <+> inline render op <+> renderBExpr right+    other -> inline render other+++renderReversableOp :: Bool -> Ast.AExprReversableOp -> Doc ann+renderReversableOp negated = \case+    Ast.BetweenAExprReversableOp symmetric low high ->+        (if negated then "not " else mempty)+            <> (if symmetric then "between symmetric " else "between ")+            <> renderBExpr low+            <+> "and"+            <+> renderExpr high+    Ast.InAExprReversableOp inExpr ->+        (if negated then "not in " else "in ") <> inline render inExpr+    Ast.DistinctFromAExprReversableOp expr ->+        "is " <> (if negated then "not " else mempty) <> "distinct from" <+> renderExpr expr+    other -> inline (aExprReversableOp negated) other+++renderTypename :: Ast.Typename -> Doc ann+renderTypename (Ast.Typename _setof stn questionMark mArrayDims) =+    inline render stn+        <> (if questionMark then "?" else mempty)+        <> maybe mempty (\(ad, q) -> inline render ad <> if q then "?" else mempty) mArrayDims+++renderTargetEl :: Ast.TargetEl -> Doc ann+renderTargetEl = \case+    Ast.AsteriskTargetEl -> "*"+    Ast.ExprTargetEl e -> renderExpr e+    Ast.AliasedExprTargetEl e alias -> renderExpr e <+> "as" <+> inline render alias+    Ast.ImplicitlyAliasedExprTargetEl e alias -> renderExpr e <+> inline render alias+++-- | Wrap-aware target renderer that prefers a multi-line layout when the+-- expression is a top-level function call (optionally with a trailing+-- typecast) and the call has enough arguments to trigger wrapping.+renderTargetElDoc :: Ast.TargetEl -> Doc ann+renderTargetElDoc = \case+    Ast.ExprTargetEl e -> renderExprDoc e+    Ast.AliasedExprTargetEl e alias -> renderExprDoc e <+> "as" <+> inline render alias+    Ast.ImplicitlyAliasedExprTargetEl e alias -> renderExprDoc e <+> inline render alias+    other -> renderTargetEl other+++-- | Inline-length threshold used at the top-level select-target / SET-RHS+-- position. Higher than 'inlineLengthThreshold' (which gates wrapping+-- inside a function-arg context) because a select target is allowed to be+-- somewhat longer before wrapping pays off — e.g. @select enqueue(array+-- [mk_payload(...)])@ at ~110 chars reads fine inline, but @select unnest+-- (array[format(...), format(...)])@ at 500+ chars does not.+selectTargetInlineThreshold :: Int+selectTargetInlineThreshold = 150+++-- | Wrap-aware expression renderer for SELECT targets and similar+-- top-level positions. Wraps a top-level function application (or a+-- chained-typecast over one) whenever it has enough arguments to trigger+-- the existing arg-count rule. A top-level @case ... end@ (with optional+-- surrounding parens / typecast) always wraps regardless of length —+-- inline @case@ in a SELECT target reads poorly even at moderate widths,+-- and the wrapped form matches the convention of hand-written SQL.+-- Additionally, when the expression's inline form exceeds+-- 'selectTargetInlineThreshold' and the expression is structurally+-- wrap-eligible, it expands multi-line — covers single-arg calls with+-- deeply nested content (e.g. @unnest(array[fn(...), fn(...)])::text@)+-- and other wrap-eligible shapes (parenthesized expressions,+-- @row(...)@, @coalesce@/@cast@, etc.).+renderExprDoc :: Ast.AExpr -> Doc ann+renderExprDoc e = case topLevelFuncApp e of+    Just (app, wrapTypecast)+        | funcAppArgCount app >= funcWrapThreshold ->+            wrapTypecast (renderFuncApplicationWrapped app)+    _+        | isTopLevelCase e ->+            -- 'align' captures the actual column where this expression+            -- starts (which is mid-line after the block-list's+            -- @firstPad@ / @, @ prefix). Subsequent nest-driven indents+            -- inside 'renderExprWrapped' / 'renderCaseExprWrapped' add+            -- to that column, so body @when@ / @else@ land at+            -- @case-col + indentSize@ and @end@ aligns under @case@.+            -- Without this, a bare @case ... end@ has its body indent+            -- driven by the outer nest level, which makes @when@ land+            -- at the same column as @case@ (visually confusing).+            align (renderExprWrapped e)+        | otherwise ->+            let inlineDoc = renderExpr e+                inlineLen = T.length (renderToText inlineDoc)+             in if inlineLen > selectTargetInlineThreshold && isWrapEligibleExpr e+                    then renderExprWrapped e+                    else inlineDoc+++-- | True when the expression is a @case ... end@ (with optional surrounding+-- parens and/or chained typecasts). Used in 'renderExprDoc' to force CASE+-- expressions in SELECT-target position to always wrap multi-line.+isTopLevelCase :: Ast.AExpr -> Bool+isTopLevelCase = \case+    Ast.TypecastAExpr inner _ -> isTopLevelCase inner+    Ast.CExprAExpr (Ast.InParensCExpr inner _) -> isTopLevelCase inner+    Ast.CExprAExpr (Ast.CaseCExpr _) -> True+    _ -> False+++-- | Wrap-aware table reference renderer used for JOIN right-hand sides.+-- Wraps the call when the operand is a 'FuncTableRef' with a wrap-eligible+-- function call; any other table ref falls back to the inline renderer.+renderTableRefDoc :: Ast.TableRef -> Doc ann+renderTableRefDoc = \case+    Ast.FuncTableRef lp ft mFuncAlias+        | Just (app, ordinality) <- wrapEligibleFuncTable ft ->+            (if lp then "lateral " else mempty)+                <> renderFuncApplicationWrapped app+                <> (if ordinality then " with ordinality" else mempty)+                <> maybe mempty (\a -> " " <> renderFuncAliasClauseDoc a) mFuncAlias+    Ast.SelectTableRef lp swp mAlias ->+        (if lp then "lateral " else mempty)+            <> renderSelectWithParensMaybeWrap swp+            <> maybe mempty (\a -> " " <> inline render a) mAlias+    other -> renderTableRefInline other+++-- | Render a function-alias clause (the @as alias_name (col1, col2, ...)@+-- after a FROM-list / JOIN function call). Short column lists stay inline;+-- lists of more than 3 columns wrap onto their own bracket-list block+-- below the alias name.+renderFuncAliasClauseDoc :: Ast.FuncAliasClause -> Doc ann+renderFuncAliasClauseDoc = \case+    Ast.AliasFuncAliasClause (Ast.AliasClause hasAs name (Just cols))+        | length (toL cols) > 3 ->+            let prefix = if hasAs then "as " else mempty+                colDocs = map (inline render) (toL cols)+             in prefix+                    <> inline render name+                    <> hardline+                    <> indent indentSize (renderBracketList colDocs)+    other -> inline render other+++-- | If the FuncTable wraps a single ApplicationFuncExprWindowless with enough+-- arguments to trigger wrapping, return the application and its WITH ORDINALITY+-- flag; otherwise Nothing.+wrapEligibleFuncTable :: Ast.FuncTable -> Maybe (Ast.FuncApplication, Bool)+wrapEligibleFuncTable = \case+    Ast.FuncExprFuncTable (Ast.ApplicationFuncExprWindowless app) (Ast.OptOrdinality ordinality)+        | funcAppArgCount app >= funcWrapThreshold+        , T.length (renderToText (renderFuncApplication app)) > inlineLengthThreshold ->+            Just (app, ordinality)+    _ -> Nothing+++renderTargetListInline :: Ast.TargetList -> Doc ann+renderTargetListInline = commaJoin . map renderTargetEl . toL+++renderSetClause :: Ast.SetClause -> Doc ann+renderSetClause = \case+    Ast.TargetSetClause target expr ->+        inline render target+            <+> "="+            <+> nest indentSize (renderExprMaybeWrap expr)+    Ast.TargetListSetClause targets expr ->+        "("+            <> commaJoin (map (inline render) (toL targets))+            <> ") ="+            <+> nest indentSize (renderExprMaybeWrap expr)+++renderInsertTarget :: Ast.InsertTarget -> Doc ann+renderInsertTarget (Ast.InsertTarget name mAlias) =+    inline render name <> maybe mempty (\a -> " as " <> inline render a) mAlias+++-- Inline renderers for table refs / function tables — used inside FROM lists+-- that fit on a single line, and inside joined-table renderings where the+-- right-hand side is always inline.++renderTableRefInline :: Ast.TableRef -> Doc ann+renderTableRefInline = \case+    Ast.RelationExprTableRef re mAlias mSample ->+        let base = inline render re+            aliased = maybe base (\a -> base <+> inline render a) mAlias+         in maybe aliased (\s -> aliased <+> inline render s) mSample+    Ast.FuncTableRef lp ft mFuncAlias ->+        (if lp then "lateral " else mempty) <> renderFuncTable ft <> maybe mempty (\a -> " " <> renderFuncAliasClauseDoc a) mFuncAlias+    Ast.SelectTableRef lp swp mAlias ->+        (if lp then "lateral " else mempty) <> renderSelectWithParensInline swp <> maybe mempty (\a -> " " <> inline render a) mAlias+    Ast.JoinTableRef jt mAlias ->+        inline render jt <> maybe mempty (\a -> " " <> inline render a) mAlias+++renderFuncTable :: Ast.FuncTable -> Doc ann+renderFuncTable = \case+    Ast.FuncExprFuncTable few (Ast.OptOrdinality ordinality) ->+        renderFuncExprWindowless few <> if ordinality then " with ordinality" else mempty+    Ast.RowsFromFuncTable rowsList (Ast.OptOrdinality ordinality) ->+        "rows from (" <> commaJoin (map renderRowsfromItem (toL rowsList)) <> ")" <> if ordinality then " with ordinality" else mempty+++renderFuncExprWindowless :: Ast.FuncExprWindowless -> Doc ann+renderFuncExprWindowless = \case+    Ast.ApplicationFuncExprWindowless app -> renderFuncApplication app+    Ast.CommonSubexprFuncExprWindowless sub -> renderFuncExprCommonSubexpr sub+++renderRowsfromItem :: Ast.RowsfromItem -> Doc ann+renderRowsfromItem (Ast.RowsfromItem few mColDef) =+    renderFuncExprWindowless few <> maybe mempty (\cd -> " as (" <> inline render cd <> ")") mColDef+++-- Inline select renderers for subqueries that appear on a single line+-- (e.g. inside `EXISTS (...)`, `= (select ...)`, etc.).++renderSelectWithParensInline :: Ast.SelectWithParens -> Doc ann+renderSelectWithParensInline = \case+    Ast.NoParensSelectWithParens snp -> "(" <> renderSelectNoParensInline snp <> ")"+    Ast.WithParensSelectWithParens swp -> "(" <> renderSelectWithParensInline swp <> ")"+++-- | Inline-length threshold for in-expression parenthesized SELECTs. Higher+-- than 'inlineLengthThreshold' (which gates function-argument wrapping)+-- because subqueries are naturally wider than a function arg, so the+-- threshold needs to admit shapes like @(select 1::int from gen(...))@+-- without forcing them onto multiple lines.+subqueryInlineThreshold :: Int+subqueryInlineThreshold = 100+++-- | Render an in-expression parenthesized SELECT (used for @EXISTS (...)@,+-- @ARRAY (...)@, @UNIQUE (...)@, scalar subselects, and @ANY/ALL (subquery)@)+-- inline when its inline form fits within 'subqueryInlineThreshold',+-- otherwise expand it onto multiple lines so the inner query reads as a+-- normal block (FROM split off, WHERE on its own line with right-aligned+-- AND continuations, leading-comma SELECT lists, etc. — the same layout+-- principles a top-level SELECT uses).+renderSelectWithParensMaybeWrap :: Ast.SelectWithParens -> Doc ann+renderSelectWithParensMaybeWrap swp =+    let inlineDoc = renderSelectWithParensInline swp+        inlineLen = T.length (renderToText inlineDoc)+     in if inlineLen > subqueryInlineThreshold+            then renderSelectWithParensWrapped swp+            else inlineDoc+++-- | Multi-line render of a parenthesized SELECT: open paren on the current+-- line, inner statement on its own block one indent deeper, closing paren+-- back at the caller's column. Mirrors the layout used by+-- 'renderCte' / @WITH ... AS (...)@.+renderSelectWithParensWrapped :: Ast.SelectWithParens -> Doc ann+renderSelectWithParensWrapped = \case+    Ast.NoParensSelectWithParens snp ->+        "(" <> nest indentSize (hardline <> renderSelectNoParens snp) <> hardline <> ")"+    Ast.WithParensSelectWithParens inner ->+        "(" <> nest indentSize (hardline <> renderSelectWithParensWrapped inner) <> hardline <> ")"+++renderSelectNoParensInline :: Ast.SelectNoParens -> Doc ann+renderSelectNoParensInline (Ast.SelectNoParens mWith selectClause mSort mLimit mLocking) =+    spaceJoin $+        catMaybes+            [ inline render <$> mWith+            , Just (renderSelectClauseInline selectClause)+            , inline render <$> mSort+            , inline render <$> mLimit+            , inline render <$> mLocking+            ]+++-- | Concatenate inline 'Doc's with a single space between each. Used wherever+-- the previous Text-based renderer used 'T.unwords'.+spaceJoin :: [Doc ann] -> Doc ann+spaceJoin = hcat . punctuate " "+++renderSelectClauseInline :: Ast.SelectClause -> Doc ann+renderSelectClauseInline = \case+    Ast.SimpleSelectSelectClause ss -> renderSimpleSelectInline ss+    Ast.WithParensSelectClause swp -> renderSelectWithParensInline swp+++renderSimpleSelectInline :: Ast.SimpleSelect -> Doc ann+renderSimpleSelectInline = \case+    Ast.NormalSimpleSelect mTargeting mInto mFrom mWhere mGroup mHaving mWindow ->+        spaceJoin $+            catMaybes+                [ Just (renderTargetingInline mTargeting)+                , inline render <$> mInto+                , renderFromClauseInline <$> mFrom+                , (\(Ast.WhereClause w) -> "where" <+> renderExpr w) <$> mWhere+                , inline render <$> mGroup+                , (\(Ast.HavingClause h) -> "having" <+> renderExpr h) <$> mHaving+                , inline render <$> mWindow+                ]+    Ast.BinSimpleSelect op left mAll right ->+        renderSelectClauseInline left+            <+> inline render op+            <> maybe mempty (\distinct -> if distinct then " distinct" else " all") mAll+            <+> renderSelectClauseInline right+    other -> inline render other+++renderFromClauseInline :: Ast.FromClause -> Doc ann+renderFromClauseInline fromList =+    "from " <> commaJoin (map renderTableRefInline (toL fromList))+++renderTargetingInline :: Maybe Ast.Targeting -> Doc ann+renderTargetingInline = \case+    Nothing -> "select"+#ifdef KRONOR_SYNTAX+    Just (Ast.NormalTargeting tl) -> "select " <> commaJoin (map renderHsTargetElInline (toL tl))+#else+    Just (Ast.NormalTargeting tl) -> "select " <> commaJoin (map renderTargetEl (toL tl))+#endif+    Just (Ast.AllTargeting Nothing) -> "select all"+    Just (Ast.AllTargeting (Just tl)) -> "select all " <> commaJoin (map renderTargetEl (toL tl))+    Just (Ast.DistinctTargeting mExprs tl) ->+        let prefix = case mExprs of+                Nothing -> "select distinct"+                Just exprs -> "select distinct on (" <> inline render exprs <> ")"+         in prefix <> " " <> commaJoin (map renderTargetEl (toL tl))+++#ifdef KRONOR_SYNTAX++-- | Render a hasql-th target element on a single line. Used in inline subquery+-- contexts. The multi-line `$Name{...}` layout is handled separately by+-- 'renderHsTargetEl'.+renderHsTargetElInline :: Ast.HsTargetEl -> Doc ann+renderHsTargetElInline = \case+    Ast.HsFuncTargetEl name args ->+        "$" <> pretty name <> " (" <> commaJoin (map renderHsTargetElInline args) <> ")"+    Ast.HsRecTargetEl name fields ->+        "$"+            <> pretty name+            <> "{ "+            <> commaJoin (map renderHsFieldContentInline (toL fields))+            <> " }"+    Ast.SqlTargetEl te -> renderTargetEl te+++renderHsFieldContentInline :: Ast.HsFieldTargetEl -> Doc ann+renderHsFieldContentInline (Ast.HsFieldEl name target) =+    pretty name <> " = " <> renderHsTargetElInline target++#endif+++----------------------------------------------------------------------+-- Multi-line statement / clause renderers (return Doc).+----------------------------------------------------------------------++renderPreparableStmt :: Ast.PreparableStmt -> Doc ann+renderPreparableStmt = \case+    Ast.SelectPreparableStmt s -> renderSelectStmt s+    Ast.InsertPreparableStmt s -> renderInsertStmt s+    Ast.UpdatePreparableStmt s -> renderUpdateStmt s+    Ast.DeletePreparableStmt s -> renderDeleteStmt s+    Ast.CallPreparableStmt s -> inline render s+++renderSelectStmt :: Ast.SelectStmt -> Doc ann+renderSelectStmt = \case+    Ast.NoParensSelectStmt snp -> renderSelectNoParens snp+    Ast.WithParensSelectStmt swp -> renderSelectWithParens swp+++renderSelectWithParens :: Ast.SelectWithParens -> Doc ann+renderSelectWithParens = \case+    Ast.NoParensSelectWithParens snp -> "(" <> renderSelectNoParens snp <> ")"+    Ast.WithParensSelectWithParens swp -> "(" <> renderSelectWithParens swp <> ")"+++renderSelectNoParens :: Ast.SelectNoParens -> Doc ann+renderSelectNoParens (Ast.SelectNoParens mWith selectClause mSort mLimit mLocking) =+    vsep $+        catMaybes+            [ renderWithClause <$> mWith+            , Just (renderSelectClause selectClause)+            , inline render <$> mSort+            , renderSelectLimit <$> mLimit+            , inline render <$> mLocking+            ]+++renderSelectClause :: Ast.SelectClause -> Doc ann+renderSelectClause = \case+    Ast.SimpleSelectSelectClause ss -> renderSimpleSelect ss+    Ast.WithParensSelectClause swp -> renderSelectWithParens swp+++renderSimpleSelect :: Ast.SimpleSelect -> Doc ann+renderSimpleSelect = \case+    Ast.NormalSimpleSelect mTargeting mInto mFrom mWhere mGroup mHaving mWindow ->+        vsep $+            catMaybes+                [ Just (renderTargeting mTargeting)+                , inline render <$> mInto+                , renderFromClause <$> mFrom+                , renderWhereClause <$> mWhere+                , inline render <$> mGroup+                , (\(Ast.HavingClause h) -> renderCondWithAndAligned (T.length "having") "having" h) <$> mHaving+                , inline render <$> mWindow+                ]+    Ast.ValuesSimpleSelect vc ->+        renderValuesClause vc+    Ast.TableSimpleSelect re ->+        "table" <+> inline render re+    Ast.BinSimpleSelect op left mAll right ->+        vsep+            [ renderSelectClause left+            , inline render op <> maybe mempty (\d -> if d then " distinct" else " all") mAll+            , renderSelectClause right+            ]+++-- | Render the SELECT targeting (column list).+renderTargeting :: Maybe Ast.Targeting -> Doc ann+renderTargeting = \case+    Nothing -> "select"+#ifdef KRONOR_SYNTAX+    Just (Ast.NormalTargeting tl) -> renderHsTargetList "select" tl+#else+    Just (Ast.NormalTargeting tl) -> renderSqlTargetList "select" tl+#endif+    Just (Ast.AllTargeting Nothing) -> "select all"+    Just (Ast.AllTargeting (Just tl)) -> renderSqlTargetList "select all" tl+    Just (Ast.DistinctTargeting mExprs tl) ->+        let prefix = case mExprs of+                Nothing -> "select distinct"+                Just exprs -> "select distinct on (" <> inline render exprs <> ")"+         in renderSqlTargetList prefix tl+++#ifdef KRONOR_SYNTAX++-- | Block-list layout for hasql-th target lists. First item lands at one+-- indent past the SELECT keyword; subsequent items are emitted as `, item` at+-- (indent - 2), so every item's content column lines up vertically.+--+-- A single target that's a top-level @case ... end@ (with optional+-- surrounding parens / typecasts) forces the block-list layout too, so+-- @case@ lands at the item-content column rather than mid-line after+-- @select @. Otherwise the @align@ inside 'renderExprDoc' would capture+-- the @case@ keyword's mid-line column and push the body / @end@ far to+-- the right.+renderHsTargetList :: Doc ann -> Ast.HsTargetList -> Doc ann+renderHsTargetList prefix tl =+    let items = toL tl+        hasRecord = any isRecTarget items+     in case items of+            [single]+                | not hasRecord && not (singleHsItemForcesMultiLine single) ->+                    prefix <+> renderHsTargetEl single+            _ -> renderBlockListWith prefix renderHsTargetEl items+  where+    isRecTarget (Ast.HsRecTargetEl _ _) = True+    isRecTarget _ = False++#endif+++renderSqlTargetList :: Doc ann -> Ast.TargetList -> Doc ann+renderSqlTargetList prefix tl =+    let items = toL tl+     in case items of+            [single]+                | not (singleSqlItemForcesMultiLine single) ->+                    prefix <+> renderTargetElDoc single+            _ -> renderBlockListWith prefix renderTargetElDoc items+++-- | A single SELECT target item forces block-list layout when its+-- expression would render multi-line via 'renderExprDoc'. Currently this+-- is just the top-level CASE rule (peeling typecasts and parens) — keeping+-- the predicate narrow avoids unintended layout changes for short single+-- targets.+#ifdef KRONOR_SYNTAX+singleHsItemForcesMultiLine :: Ast.HsTargetEl -> Bool+singleHsItemForcesMultiLine = \case+    Ast.SqlTargetEl te -> singleSqlItemForcesMultiLine te+    _ -> False+#endif+++singleSqlItemForcesMultiLine :: Ast.TargetEl -> Bool+singleSqlItemForcesMultiLine = \case+    Ast.ExprTargetEl e -> isTopLevelCase e+    Ast.AliasedExprTargetEl e _ -> isTopLevelCase e+    Ast.ImplicitlyAliasedExprTargetEl e _ -> isTopLevelCase e+    _ -> False+++-- | Build the block-list layout used by SELECT target lists and the+-- multi-assignment UPDATE SET. Pattern:+--+-- @+-- prefix+--     first+--   , second+--   , third+-- @+--+-- The body sits at indent 4; commas at indent 2.+--+-- Note: padding is applied as explicit spaces in the Doc rather than via+-- 'indent'/'nest'. This is deliberate — we want the *first* item one indent+-- deeper than the comma rows, so that the comma column lines up under the+-- prefix while item content aligns. 'indent' would push every row to the same+-- column.+renderBlockListWith :: Doc ann -> (a -> Doc ann) -> [a] -> Doc ann+renderBlockListWith prefix renderItem items =+    prefix+        <> hardline+        <> vsep (zipWith renderRow [(0 :: Int) ..] items)+  where+    firstPad = pretty (T.replicate indentSize " ")+    commaPad = pretty (T.replicate (max 0 (indentSize - 2)) " ")+    renderRow 0 item = firstPad <> renderItem item+    renderRow _ item = commaPad <> ", " <> renderItem item+++#ifdef KRONOR_SYNTAX++-- | Multi-line `$Name{...}` record layout:+--+-- @++-- $Name+--     { field1 = ...+--     , field2 = ...+--     }+-- @+--+-- Wrapped in 'align' so any hard line break within the record body picks up+-- the column where `$Name` was placed (rather than the outer block's column);+-- 'indent' then takes us one level deeper for the brace/fields/closing brace.+++renderHsTargetEl :: Ast.HsTargetEl -> Doc ann+renderHsTargetEl = \case+    Ast.HsFuncTargetEl name args ->+        let inlineDoc = "$" <> pretty name <> " (" <> commaJoin (map renderHsTargetElInline args) <> ")"+            inlineLen = T.length (renderToText inlineDoc)+         in if not (null args) && inlineLen > selectTargetInlineThreshold+                then renderHsFuncTargetWrapped name args+                else inlineDoc+    Ast.HsRecTargetEl name fields ->+        align $+            "$"+                <> pretty name+                <> hardline+                <> indent indentSize (renderRecordFields fields)+    Ast.SqlTargetEl te -> renderTargetElDoc te+++-- | Multi-line layout for hasql-th @$Name (...)@: each arg on its own+-- line in a leading-comma block, same shape as+-- 'renderFuncApplicationWrapped'. Each arg is rendered wrap-aware so a+-- long inner SQL expression (CASE, function call, parenthesized cast,+-- etc.) wraps the same way it would at the top-level SELECT-target+-- position. Used both for top-level @$Foo (...)@ targets (gated by+-- 'selectTargetInlineThreshold') and for nested @$Foo (...)@ values+-- inside record fields (gated by 'inlineLengthThreshold' via+-- 'renderHsTargetElMaybeWrap').+renderHsFuncTargetWrapped :: Text -> [Ast.HsTargetEl] -> Doc ann+renderHsFuncTargetWrapped name args =+    "$"+        <> pretty name+        <> " ("+        <> nest indentSize (renderArgRows (map renderHsFuncTargetArg args))+        <> hardline+        <> ")"+++-- | Render one arg of a wrapped @$Name (...)@. SQL args go through+-- 'renderTargetElDoc' (the wrap-aware SELECT-target renderer); nested+-- @$Foo (...)@ / @$Foo{...}@ args recurse via 'renderHsTargetElMaybeWrap'+-- so a deeply nested splice picks up the same inline-vs-wrap rule it+-- would in a record field.+renderHsFuncTargetArg :: Ast.HsTargetEl -> Doc ann+renderHsFuncTargetArg = \case+    Ast.SqlTargetEl te -> renderTargetElDoc te+    other -> renderHsTargetElMaybeWrap other+++-- | Same as 'renderHsTargetEl' but without the outer 'align' — for use+-- when this target is a nested record value (rendered inside+-- 'renderRecordFields'). We rely on the surrounding @indent indentSize@+-- to give us the right base column, and propagate it via plain+-- @hardline + indent indentSize@ so each nesting level adds exactly+-- @indentSize@ of indent rather than capturing the field's mid-line+-- column (which would cascade rightward).+renderHsTargetElWrappedInner :: Ast.HsTargetEl -> Doc ann+renderHsTargetElWrappedInner = \case+    Ast.HsRecTargetEl name fields ->+        "$"+            <> pretty name+            <> hardline+            <> indent indentSize (renderRecordFields fields)+    Ast.HsFuncTargetEl name args ->+        renderHsFuncTargetWrapped name args+    other -> renderHsTargetEl other+++renderRecordFields :: NE.NonEmpty Ast.HsFieldTargetEl -> Doc ann+renderRecordFields fields =+    let firstField = NE.head fields+        restFields = NE.tail fields+        renderField (Ast.HsFieldEl name target) =+            pretty name <> " = " <> renderHsTargetElMaybeWrap target+        firstLine = "{ " <> renderField firstField+        restLines = map (\f -> ", " <> renderField f) restFields+     in vsep $ firstLine : restLines ++ ["}"]+++-- | Wrap-aware renderer for hasql-th @$Foo@ / @$Foo{...}@ target+-- expressions used inside record fields. Renders inline first; if the+-- inline form exceeds 'inlineLengthThreshold' AND the target is a+-- record-shaped @$Foo{...}@ (or a function-shaped @$f(...)@ that+-- contains a wrap-eligible record), expands to multi-line via+-- 'renderHsTargetElWrappedInner' so each nested level adds exactly+-- @indentSize@ of indent rather than cascading rightward.+renderHsTargetElMaybeWrap :: Ast.HsTargetEl -> Doc ann+renderHsTargetElMaybeWrap el =+    let inlineDoc = renderHsTargetElInline el+        inlineLen = T.length (renderToText inlineDoc)+     in if inlineLen > inlineLengthThreshold && isHsTargetWrapEligible el+            then renderHsTargetElWrappedInner el+            else inlineDoc+++isHsTargetWrapEligible :: Ast.HsTargetEl -> Bool+isHsTargetWrapEligible = \case+    Ast.HsRecTargetEl _ _ -> True+    Ast.HsFuncTargetEl _ args -> not (null args)+    Ast.SqlTargetEl _ -> False++#endif+++-- | Render the FROM clause. A list with no joins stays on a single line+-- (`from t1, t2`). Any join forces multi-line layout so each join lands on its+-- own line with its ON/USING qualification below.+renderFromClause :: Ast.FromClause -> Doc ann+renderFromClause fromList =+    let items = toL fromList+        lastIdx = length items - 1+     in if any forcesMultiLine items+            then+                vsep $+                    zipWith+                        ( \i item ->+                            renderTableRef (i == (0 :: Int)) item+                                <> if i < lastIdx then "," else mempty+                        )+                        [0 ..]+                        items+            else "from" <+> commaJoin (map renderTableRefInline items)+  where+    -- A FROM item forces multi-line layout when it's a JOIN (each arm on+    -- its own line), a wrap-eligible function call (e.g.+    -- @from job_queue.enqueue_payload(array[...]::jsonb[], priority_ :=+    -- 5000)@), or a parenthesized SELECT whose inline form exceeds+    -- 'subqueryInlineThreshold'.+    forcesMultiLine (Ast.JoinTableRef _ _) = True+    forcesMultiLine (Ast.FuncTableRef _ ft _) =+        case wrapEligibleFuncTable ft of+            Just _ -> True+            Nothing -> False+    forcesMultiLine (Ast.SelectTableRef _ swp _) =+        T.length (renderToText (renderSelectWithParensInline swp)) > subqueryInlineThreshold+    forcesMultiLine _ = False+++renderTableRef :: Bool -> Ast.TableRef -> Doc ann+renderTableRef isFirst = \case+    Ast.RelationExprTableRef re mAlias mSample ->+        let base = inline render re+            aliased = maybe base (\a -> base <> " " <> inline render a) mAlias+            sampled = maybe aliased (\s -> aliased <> " " <> inline render s) mSample+         in pfx <> sampled+    Ast.FuncTableRef lp ft mFuncAlias+        | Just (app, ordinality) <- wrapEligibleFuncTable ft ->+            pfx+                <> (if lp then "lateral " else mempty)+                <> renderFuncApplicationWrapped app+                <> (if ordinality then " with ordinality" else mempty)+                <> maybe mempty (\a -> " " <> renderFuncAliasClauseDoc a) mFuncAlias+    Ast.FuncTableRef lp ft mFuncAlias ->+        pfx+            <> ( (if lp then "lateral " else mempty)+                    <> renderFuncTable ft+                    <> maybe mempty (\a -> " " <> renderFuncAliasClauseDoc a) mFuncAlias+               )+    Ast.SelectTableRef lp swp mAlias ->+        pfx+            <> (if lp then "lateral " else mempty)+            <> renderSelectWithParensMaybeWrap swp+            <> maybe mempty (\a -> " " <> inline render a) mAlias+    Ast.JoinTableRef jt mAlias ->+        pfx <> renderJoinedTable jt <> maybe mempty (\a -> " " <> inline render a) mAlias+  where+    pfx = if isFirst then "from " else mempty+++-- | Render a joined table. The @from @ prefix is the caller's responsibility+-- (added by 'renderTableRef' when the surrounding 'JoinTableRef' is the first+-- FROM item) so that parenthesized or non-first joins don't end up with the+-- @from@ keyword inside the parens or duplicated mid-list.+renderJoinedTable :: Ast.JoinedTable -> Doc ann+renderJoinedTable = \case+    Ast.InParensJoinedTable jt -> "(" <> renderJoinedTable jt <> ")"+    Ast.CrossJoinedTable left right ->+        renderTableRef False left <> hardline <> ("cross join" <+> renderTableRefDoc right)+    Ast.NaturalJoinedTable left mType right ->+        let prefix = "natural " <> maybe "" (\t -> renderJoinType t <> " ") mType <> "join"+         in renderTableRef False left <> hardline <> (pretty prefix <+> renderTableRefDoc right)+    Ast.QualJoinedTable left mType right qual ->+        -- Right-align the ON keyword (and any AND continuations) under the+        -- first word of the JOIN-block (`join` itself for a plain join, or+        -- `left` / `right` / `inner` / `full` for a prefixed one). This keeps+        -- the qualifier visually attached to the join opener regardless of+        -- how long the full keyword sequence is.+        let mTypePrefix = maybe "" (\t -> renderJoinType t <> " ") mType+            keyword = mTypePrefix <> "join"+            anchor = firstWordLen keyword+            joinHeader = pretty keyword <+> renderTableRefDoc right+         in renderTableRef False left <> hardline <> joinHeader <> hardline <> renderJoinQual anchor qual+++-- | Length of the first whitespace-delimited word in @t@ (or 0 for empty).+firstWordLen :: Text -> Int+firstWordLen = T.length . T.takeWhile (/= ' ')+++renderJoinType :: Ast.JoinType -> Text+renderJoinType = \case+    Ast.FullJoinType outer -> "full" <> if outer then " outer" else ""+    Ast.LeftJoinType outer -> "left" <> if outer then " outer" else ""+    Ast.RightJoinType outer -> "right" <> if outer then " outer" else ""+    Ast.InnerJoinType -> "inner"+++renderJoinQual :: Int -> Ast.JoinQual -> Doc ann+renderJoinQual anchor = \case+    Ast.OnJoinQual expr -> renderCondWithAndAligned anchor "on" expr+    Ast.UsingJoinQual cols ->+        "using (" <> commaJoin (map (inline render) (toL cols)) <> ")"+++-- | Render WHERE clause; AND continuations right-align with `where`'s end.+renderWhereClause :: Ast.WhereClause -> Doc ann+renderWhereClause (Ast.WhereClause expr) = renderCondWithAndAligned (T.length "where") "where" expr+++-- | Render a condition expression. AND continuations are emitted with explicit+-- left-padding so their last letter lines up with the @anchor@-wide opening+-- keyword (e.g. @anchor = 5@ for `where`, @anchor = 4@ for `join`-block ON).+--+-- AND splitting works on the rendered Text rather than the AST because+-- @postgresql-syntax@ parses @a = 1 AND b = 2@ as+-- @SymbolicBinOpAExpr a (=) (AndAExpr 1 (SymbolicBinOpAExpr b (=) ...))@ —+-- the AND ends up nested inside the comparison rather than at the top level.+renderCondWithAndAligned :: Int -> Text -> Ast.AExpr -> Doc ann+renderCondWithAndAligned anchor keyword expr =+    let renderedText = renderToText (renderExpr expr)+        parts = splitOnAnd renderedText+        kwPadLen = max 0 (anchor - T.length keyword)+        andPadLen = max 0 (anchor - T.length "and")+        kwPad = pretty (T.replicate kwPadLen " ")+        andPad = pretty (T.replicate andPadLen " ")+     in case parts of+            -- Single condition (no top-level AND): if its inline form is+            -- long and the expression is wrap-eligible, hand it off to+            -- 'renderExprMaybeWrap' so the inner shape (e.g. a long+            -- function call or @case@) can expand multi-line.+            [single]+                | T.length single > inlineLengthThreshold && isWrapEligibleExpr expr ->+                    kwPad <> pretty keyword <+> renderExprMaybeWrap expr+                | otherwise ->+                    kwPad <> pretty keyword <+> pretty single+            (first : rest) ->+                vsep $+                    (kwPad <> pretty keyword <+> pretty first)+                        : map (\p -> andPad <> "and" <+> pretty p) rest+            [] -> mempty+++-- | Render an inline 'Doc' down to 'Text'. Used in the few places we need+-- to do text-level operations (e.g. AND splitting) on the rendered output+-- before re-wrapping into a 'Doc'.+renderToText :: Doc ann -> Text+renderToText = renderStrict . layoutPretty layoutOpts+++-- | Split rendered SQL text at top-level @ and @ boundaries. Skips+-- occurrences inside parens, under a @between@, and inside single-quoted+-- string literals or double-quoted identifiers (@'@ / @"@, with standard+-- doubled-quote escapes).+splitOnAnd :: Text -> [Text]+splitOnAnd = go 0 False Nothing "" . T.unpack+  where+    go :: Int -> Bool -> Maybe Char -> String -> String -> [Text]+    go _ _ _ acc [] = [T.strip (T.pack (reverse acc))]+    -- Inside a string/quoted-identifier: doubled-quote escape stays inside;+    -- otherwise the real closing quote exits.+    go d btwn (Just q) acc (c1 : c2 : rest)+        | c1 == q && c2 == q = go d btwn (Just q) (c2 : c1 : acc) rest+    go d btwn (Just q) acc (c : rest)+        | c == q = go d btwn Nothing (c : acc) rest+        | otherwise = go d btwn (Just q) (c : acc) rest+    -- Outside strings:+    go d btwn Nothing acc ('\'' : rest) = go d btwn (Just '\'') ('\'' : acc) rest+    go d btwn Nothing acc ('"' : rest) = go d btwn (Just '"') ('"' : acc) rest+    go d btwn Nothing acc ('(' : rest) = go (d + 1) btwn Nothing ('(' : acc) rest+    go d btwn Nothing acc (')' : rest) = go (max 0 (d - 1)) btwn Nothing (')' : acc) rest+    go 0 True Nothing acc (' ' : 'a' : 'n' : 'd' : ' ' : rest) =+        go 0 False Nothing (reverse " and " ++ acc) rest+    go 0 _ Nothing acc (' ' : 'a' : 'n' : 'd' : ' ' : rest) =+        T.strip (T.pack (reverse acc)) : go 0 False Nothing "" rest+    go d _ Nothing acc ('b' : 'e' : 't' : 'w' : 'e' : 'e' : 'n' : ' ' : rest) =+        go d True Nothing (reverse "between " ++ acc) rest+    go d btwn st acc (c : rest) = go d btwn st (c : acc) rest+++renderSelectLimit :: Ast.SelectLimit -> Doc ann+renderSelectLimit = \case+    Ast.LimitOffsetSelectLimit lc oc ->+        inline render lc <> hardline <> inline render oc+    Ast.OffsetLimitSelectLimit oc lc ->+        inline render oc <> hardline <> inline render lc+    Ast.LimitSelectLimit lc -> inline render lc+    Ast.OffsetSelectLimit oc -> inline render oc+++renderWithClause :: Ast.WithClause -> Doc ann+renderWithClause (Ast.WithClause recursive ctes) =+    let keyword :: Text+        keyword = if recursive then "with recursive" else "with"+        cteList = toL ctes+        lastIdx = length cteList - 1+     in vsep $+            zipWith+                ( \i cte ->+                    let isLast = i == lastIdx+                        prefix = if i == (0 :: Int) then pretty keyword <> " " else mempty+                     in prefix <> renderCte isLast cte+                )+                [0 :: Int ..]+                cteList+++renderCte :: Bool -> Ast.CommonTableExpr -> Doc ann+renderCte isLast (Ast.CommonTableExpr name mCols mMaterialized stmt) =+    let suffix = if isLast then ")" else "),"+        materializedKeyword = case mMaterialized of+            Nothing -> ""+            Just True -> "materialized "+            Just False -> "not materialized "+        header =+            inline render name+                <> maybe+                    mempty+                    (\cols -> " (" <> commaJoin (map (inline render) (toL cols)) <> ")")+                    mCols+                <> " as "+                <> materializedKeyword+                <> "("+     in header+            <> hardline+            <> indent indentSize (renderPreparableStmt stmt)+            <> hardline+            <> suffix+++-- | Render INSERT statement.+renderInsertStmt :: Ast.InsertStmt -> Doc ann+renderInsertStmt (Ast.InsertStmt mWith target rest mConflict mReturning) =+    vsep $+        catMaybes+            [ renderWithClause <$> mWith+            , Just ("insert into" <+> renderInsertTarget target)+            , Just (renderInsertRest rest)+            , renderOnConflict <$> mConflict+            , (\(Ast.ReturningClause rc) -> "returning" <+> renderTargetListInline rc) <$> mReturning+            ]+++renderInsertRest :: Ast.InsertRest -> Doc ann+renderInsertRest = \case+    Ast.SelectInsertRest mCols mOverride selectStmt ->+        let colsDoc = maybe mempty renderInsertColumnList mCols+            overrideDoc = case mOverride of+                Nothing -> mempty+                Just o -> "overriding " <> inline render o <> " value" <> hardline+            selectDoc = overrideDoc <> renderSelectStmt selectStmt+         in case mCols of+                Nothing -> selectDoc+                Just _ -> colsDoc <> hardline <> selectDoc+    Ast.DefaultValuesInsertRest -> "default values"+++-- | Render the bracketed INSERT column list. Short lists (≤ 3 cols) stay on+-- one line wrapped in `(...)`; longer lists use the bracket-list block-comma+-- layout.+renderInsertColumnList :: Ast.InsertColumnList -> Doc ann+renderInsertColumnList cols =+    let items = toL cols+        rendered = map (inline render) items+     in if length items <= 3+            then indent indentSize ("(" <> commaJoin rendered <> ")")+            else indent indentSize (renderBracketList rendered)+++-- | Build the bracket-list layout used for long INSERT column lists and+-- VALUES tuples:+--+-- @+-- ( first+-- , second+-- , third+-- )+-- @+renderBracketList :: [Doc ann] -> Doc ann+renderBracketList = \case+    [] -> "()"+    (x : xs) ->+        vsep $+            ("( " <> x)+                : map (", " <>) xs+                ++ [")"]+++-- | Render VALUES clause. The keyword `values` lands on its own line with the+-- tuples emitted as bracket-lists below it.+renderValuesClause :: Ast.ValuesClause -> Doc ann+renderValuesClause vc =+    let rows = toL vc+        lastIdx = length rows - 1+        renderRow i row =+            let bracketed = renderValuesRow row+             in if i < lastIdx then bracketed <> "," else bracketed+     in "values"+            <> hardline+            <> indent indentSize (vsep (zipWith renderRow [(0 :: Int) ..] rows))+++renderValuesRow :: Ast.ExprList -> Doc ann+renderValuesRow exprs =+    let items = toL exprs+        rendered = map renderExpr items+     in case items of+            [_] -> "(" <> commaJoin rendered <> ")"+            _ -> renderBracketList rendered+++renderOnConflict :: Ast.OnConflict -> Doc ann+renderOnConflict (Ast.OnConflict mConfExpr onDo) =+    let header = "on conflict" <> maybe mempty (\ce -> " " <> inline render ce) mConfExpr+     in header <> hardline <> renderOnConflictDo onDo+++renderOnConflictDo :: Ast.OnConflictDo -> Doc ann+renderOnConflictDo = \case+    Ast.NothingOnConflictDo -> "do nothing"+    Ast.UpdateOnConflictDo setClauses mWhere ->+        let items = toL setClauses+            base = case items of+                [single] ->+                    "do update set"+                        <> hardline+                        <> indent indentSize (renderSetClause single)+                _ ->+                    let renderItem i clause =+                            let d = renderSetClause clause+                             in if i < length items - 1+                                    then d <> ","+                                    else d+                     in "do update set"+                            <> hardline+                            <> indent indentSize (vsep (zipWith renderItem [(0 :: Int) ..] items))+         in case mWhere of+                Nothing -> base+                Just w -> base <> hardline <> renderWhereClause (Ast.WhereClause w)+++-- | Render UPDATE statement.+renderUpdateStmt :: Ast.UpdateStmt -> Doc ann+renderUpdateStmt (Ast.UpdateStmt mWith rel setClauses mFrom mWhere mReturning) =+    vsep $+        catMaybes+            [ renderWithClause <$> mWith+            , Just ("update" <+> inline render rel)+            , Just (renderSetClauseLines setClauses)+            , renderFromClause <$> mFrom+            , renderWhereOrCurrent <$> mWhere+            , (\(Ast.ReturningClause rc) -> "returning" <+> renderTargetListInline rc) <$> mReturning+            ]+++-- | Render DELETE statement.+renderDeleteStmt :: Ast.DeleteStmt -> Doc ann+renderDeleteStmt (Ast.DeleteStmt mWith rel mUsing mWhere mReturning) =+    vsep $+        catMaybes+            [ renderWithClause <$> mWith+            , Just ("delete from" <+> inline render rel)+            , (\(Ast.UsingClause u) -> "using" <+> inline render u) <$> mUsing+            , renderWhereOrCurrent <$> mWhere+            , (\(Ast.ReturningClause rc) -> "returning" <+> renderTargetListInline rc) <$> mReturning+            ]+++renderWhereOrCurrent :: Ast.WhereOrCurrentClause -> Doc ann+renderWhereOrCurrent = \case+    Ast.ExprWhereOrCurrentClause expr -> renderWhereClause (Ast.WhereClause expr)+    Ast.CursorWhereOrCurrentClause name -> "where current of" <+> inline render name+++-- | Multi-assignment SETs put `set` on its own line and use the same+-- block-list layout as multi-target SELECTs. Single-assignment SETs stay+-- inline (`set col = val`).+renderSetClauseLines :: Ast.SetClauseList -> Doc ann+renderSetClauseLines setClauses =+    let items = toL setClauses+     in case items of+            [single] -> "set" <+> renderSetClause single+            _ -> renderBlockListWith "set" renderSetClause items
+ app/Main.hs view
@@ -0,0 +1,103 @@+module Main where++import Control.Monad (when)+import Data.ByteString qualified as BS+import Hasql.ThFormat (ProcessResult (..), processBytes, processFile)+import System.Environment (getArgs, getProgName)+import System.Exit (ExitCode (..), exitWith)+import Prelude+++data Mode+    = -- | checkOnly, files+      ModeFiles Bool [FilePath]+    | ModeStdin+++main :: IO ()+main = do+    args <- getArgs+    progName <- getProgName+    case parseArgs args of+        Left err -> do+            putStrLn err+            putStrLn (usage progName)+            exitWith (ExitFailure 2)+        Right ModeStdin -> do+            input <- BS.getContents+            case processBytes input of+                Left err -> do+                    putStrLn ("Error: " <> err)+                    exitWith (ExitFailure 1)+                Right output ->+                    BS.putStr output+        Right (ModeFiles checkOnly files)+            | null files -> do+                putStrLn (usage progName)+                exitWith (ExitFailure 2)+            | otherwise -> do+                results <- mapM (processOneFile checkOnly) files+                let hasErrors = any isError results+                    hasChanges = any isChanged results+                when hasErrors (exitWith (ExitFailure 1))+                when (checkOnly && hasChanges) (exitWith (ExitFailure 1))+++processOneFile :: Bool -> FilePath -> IO ProcessResult+processOneFile checkOnly filePath = do+    result <- processFile checkOnly filePath+    case result of+        Error err -> do+            putStrLn ("Error processing " <> filePath <> ": " <> err)+            pure result+        Changed+            | checkOnly -> do+                putStrLn ("Would reformat: " <> filePath)+                pure result+            | otherwise -> do+                putStrLn ("Reformatted: " <> filePath)+                pure result+        Unchanged -> pure result+++isError :: ProcessResult -> Bool+isError (Error _) = True+isError _ = False+++isChanged :: ProcessResult -> Bool+isChanged Changed = True+isChanged _ = False+++parseArgs :: [String] -> Either String Mode+parseArgs = go Nothing False []+  where+    go mStdin checkOnly files [] =+        case mStdin of+            Just True+                | not (null files) -> Left "Cannot combine --stdin with file arguments."+                | checkOnly -> Left "Cannot combine --stdin with --check."+                | otherwise -> Right ModeStdin+            _ -> Right (ModeFiles checkOnly (reverse files))+    go mStdin _ files ("--check" : rest) =+        go mStdin True files rest+    go _ checkOnly files ("--stdin" : rest) =+        go (Just True) checkOnly files rest+    go _ _ _ (('-' : '-' : opt) : _) =+        Left ("Unknown option: --" <> opt)+    go mStdin checkOnly files (f : rest) =+        go mStdin checkOnly (f : files) rest+++usage :: String -> String+usage progName =+    "Usage: "+        <> progName+        <> " [--check] files...\n"+        <> "       "+        <> progName+        <> " --stdin\n\n"+        <> "Options:\n"+        <> "  --check       Don't modify files, exit with code 1 if any file would change\n"+        <> "  --stdin       Read from stdin, write formatted output to stdout\n"
+ hasql-th-format.cabal view
@@ -0,0 +1,160 @@+cabal-version:   3.0+name:            hasql-th-format+version:         0.1.0.0+synopsis:        Format the SQL inside hasql-th quasiquotes+description:+  Rewrites the SQL in every hasql-th quasiquote of a Haskell source file into+  one house layout, in place. It parses each statement, renders it again, and+  re-parses the result, so a formatting pass that would change what the+  statement means fails instead of being written out.+  .+  Ships two executables: @hasql-th-format@, the formatter itself, and+  @hasql-th-audit@, which diffs the statements of two revisions of a file+  quasiquote by quasiquote, so a reformatting commit can be shown to have+  changed no SQL.++category:        Database, Development+homepage:        https://github.com/kronor-io/hasql-check+bug-reports:     https://github.com/kronor-io/hasql-check/issues+license:         BSD-3-Clause+license-file:    LICENSE+author:          Kronor+maintainer:      lorenzo@kronor.io+copyright:       2026 Kronor+build-type:      Simple+tested-with:     GHC ==9.10.3 || ==9.12.4 || ==9.14.1+extra-doc-files: README.md++source-repository head+  type:     git+  location: https://github.com/kronor-io/hasql-check++flag lint+  description: lint+  manual:      True+  default:     False++flag kronor-syntax+  description:+    Accept the hasql-th extensions of Kronor's forks (Haskell targets and+    Haskell param fields) and format them. Needs Kronor's fork of+    postgresql-syntax.++  manual:      True+  default:     False++executable hasql-th-format+  main-is:            Main.hs++  -- cabal-fmt: expand app -Main -Audit+  other-modules:+    Hasql.ThFormat+    Hasql.ThFormat.SqlFormat++  hs-source-dirs:     app+  default-language:   GHC2021+  default-extensions:+    ApplicativeDo+    BangPatterns+    BlockArguments+    ConstraintKinds+    DataKinds+    DeriveAnyClass+    DeriveFunctor+    DeriveGeneric+    DerivingStrategies+    DerivingVia+    DuplicateRecordFields+    FlexibleContexts+    FlexibleInstances+    GADTs+    GeneralizedNewtypeDeriving+    ImportQualifiedPost+    LambdaCase+    MultiParamTypeClasses+    NamedFieldPuns+    NoImplicitPrelude+    OverloadedRecordDot+    OverloadedStrings+    PatternSynonyms+    RecordWildCards+    ScopedTypeVariables+    StrictData+    TupleSections+    TypeApplications+    TypeFamilies+    ViewPatterns++  ghc-options:+    -Wall -Wcompat -Widentities -Wincomplete-record-updates+    -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints+    -freverse-errors -fhide-source-paths++  if flag(lint)+    ghc-options: -Werror++  build-depends:+    , base               >=4.20 && <4.23+    , bytestring         >=0.11 && <0.13+    , hasql-check        >=0.5  && <0.6+    , postgresql-syntax  >=0.5  && <0.6+    , prettyprinter      >=1.7  && <1.8+    , text               >=2.0  && <2.2+    , text-builder       >=1.0  && <1.1++  if flag(kronor-syntax)+    cpp-options: -DKRONOR_SYNTAX++executable hasql-th-audit+  main-is:            Audit.hs+  hs-source-dirs:     app+  default-language:   GHC2021+  default-extensions:+    ImportQualifiedPost+    LambdaCase+    NamedFieldPuns+    NoImplicitPrelude+    OverloadedStrings++  ghc-options:        -Wall++  if flag(lint)+    ghc-options: -Werror+  build-depends:+    , base               >=4.20 && <4.23+    , bytestring         >=0.11 && <0.13+    , hasql-check        >=0.5  && <0.6+    , postgresql-syntax  >=0.5  && <0.6+    , text               >=2.0  && <2.2++  if flag(kronor-syntax)+    cpp-options: -DKRONOR_SYNTAX++test-suite hasql-th-format-test+  type:               exitcode-stdio-1.0+  main-is:            Test.hs+  other-modules:      Hasql.ThFormat.SqlFormat+  hs-source-dirs:     test app+  default-language:   GHC2021+  default-extensions:+    ImportQualifiedPost+    LambdaCase+    NoImplicitPrelude+    OverloadedStrings+    QuasiQuotes++  ghc-options:        -Wall++  if flag(lint)+    ghc-options: -Werror+  build-depends:+    , base                >=4.20 && <4.23+    , hasql-check         >=0.5  && <0.6+    , postgresql-syntax   >=0.5  && <0.6+    , prettyprinter       >=1.7  && <1.8+    , string-interpolate  >=0.3  && <0.4+    , text                >=2.0  && <2.2+    , text-builder        >=1.0  && <1.1++  if flag(kronor-syntax)+    cpp-options: -DKRONOR_SYNTAX
+ test/Test.hs view
@@ -0,0 +1,1343 @@+{-# LANGUAGE CPP #-}++module Main where++import Data.String.Interpolate (__i)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Hasql.ThFormat.SqlFormat (formatSql)+import PostgresqlSyntax qualified as Ast+import System.Exit (ExitCode (..), exitWith)+import Prelude+++-- | Parse a SQL snippet, ignoring surrounding whitespace. Returns 'Nothing' if+-- the parser rejects the input. Used to compare the AST of input/expected/actual+-- as a semantic-equivalence guard on top of string equality.+parseAst :: Text -> Maybe Ast.PreparableStmt+parseAst t = either (const Nothing) Just (Ast.parse settings (T.strip t))+  where+    settings =+        mconcat+            [ Ast.nullabilityMarkers True+#ifdef KRONOR_SYNTAX+            , Ast.haskellTargets True+            , Ast.haskellParamFields True+#endif+            ]+++data TestCase = TestCase+    { name :: Text+    , qqCol :: Int+    , input :: Text+    , expected :: Text+    }+++-- | Prepend @n@ spaces to each non-blank line.+indent :: Int -> Text -> Text+indent n = T.intercalate "\n" . map addPrefix . T.lines+  where+    prefix = T.replicate n " "+    addPrefix line+        | T.null (T.strip line) = line+        | otherwise = prefix <> line+++-- | Build a test case. @rawInput@ and @rawExpected@ are dedented SQL snippets;+-- the input is indented to column @qqCol@ (simulating the real quasiquote+-- context), and the expected output is indented to @qqCol + 4@ (the formatter+-- indents content one level deeper than the opening bracket). Both are given a+-- leading newline, matching what a quasiquote block contains.+mkCase :: Text -> Int -> Text -> Text -> TestCase+mkCase nm col rawInput rawExpected =+    TestCase+        { name = nm+        , qqCol = col+        , input = "\n" <> indent col rawInput+        , expected = "\n" <> indent (col + 4) rawExpected+        }+++tests :: [TestCase]+tests =+    [ TestCase+        { name = "[01] single-line query (no newlines) is left untouched"+        , qqCol = 4+        , input = " update releaser.config set is_frozen = $1::bool where id = 42 "+        , expected = " update releaser.config set is_frozen = $1::bool where id = 42 "+        }+    , mkCase+        "[02] SELECT ... WHERE: inlined FROM is split out, trailing LIMIT moves to its own line, ANDs are indented under WHERE"+        4+        [__i|+            select response_body::jsonb from history.idempotency+            where customer_id = $1::bigint+                and namespace = $2::text+                and idempotency_key = $3::text+                and request_params = $4::jsonb limit 1+        |]+        [__i|+            select response_body::jsonb+            from history.idempotency+            where customer_id = $1::bigint+              and namespace = $2::text+              and idempotency_key = $3::text+              and request_params = $4::jsonb+            limit 1+        |]+    , mkCase+        "[03] INSERT with 4+ columns and a VALUES tuple: both are split into leading-comma lists"+        4+        [__i|+            insert into history.idempotency (customer_id, namespace, idempotency_key, request_params)+            values ($1::bigint, $2::text, $3::text, $4::jsonb)+        |]+        [__i|+            insert into history.idempotency+                ( customer_id+                , namespace+                , idempotency_key+                , request_params+                )+            values+                ( $1::bigint+                , $2::text+                , $3::text+                , $4::jsonb+                )+        |]+    , mkCase+        "[04] UPDATE ... WHERE normalizes inconsistent AND indentation (2 spaces -> 4) under the WHERE keyword"+        4+        [__i|+            update history.idempotency+            set response_body = $4::jsonb+            where customer_id = $1::bigint+              and namespace = $2::text+              and idempotency_key = $3::text+        |]+        [__i|+            update history.idempotency+            set response_body = $4::jsonb+            where customer_id = $1::bigint+              and namespace = $2::text+              and idempotency_key = $3::text+        |]+    , mkCase+        "[05] JOIN with multi-condition ON: the AND continuation sits at the same indent as ON, not nested further"+        4+        [__i|+            select payment.id::bigint,+                payment.vendor_handle::text?+            from storefront_core.transaction_archive+            join config.vendor_setting+                on vendor_setting.customer_id = transaction_archive.customer_id+            join storefront_core.payment+                on payment.id = transaction_archive.payment_id+                and payment.customer_id = transaction_archive.customer_id+            where transaction_archive.customer_id = $1::bigint+                and transaction_archive.id = $2::bigint+        |]+        [__i|+            select+                payment.id::bigint+              , payment.vendor_handle::text?+            from storefront_core.transaction_archive+            join config.vendor_setting+              on vendor_setting.customer_id = transaction_archive.customer_id+            join storefront_core.payment+              on payment.id = transaction_archive.payment_id+             and payment.customer_id = transaction_archive.customer_id+            where transaction_archive.customer_id = $1::bigint+              and transaction_archive.id = $2::bigint+        |]+#ifdef KRONOR_SYNTAX+    , mkCase+        "[06] hasql-th $CanBeValidated(...) splice is preserved verbatim in a select list"+        4+        [__i|+            select+                $CanBeValidated (api_key::text, 'config.t'::text, 'api_key'::text),+                $CanBeValidated (private_key::text, 'config.t'::text, 'private_key'::text)+            from config.northwind_provider_setting+            where customer_id = $1::bigint+              and id = $2::bigint+        |]+        [__i|+            select+                $CanBeValidated (api_key::text, 'config.t'::text, 'api_key'::text)+              , $CanBeValidated (private_key::text, 'config.t'::text, 'private_key'::text)+            from config.northwind_provider_setting+            where customer_id = $1::bigint+              and id = $2::bigint+        |]+#endif+#ifdef KRONOR_SYNTAX+    , mkCase+        "[07] hasql-th $Name{...} record syntax: inlined braces are reformatted to Haskell-style leading-comma record"+        8+        [__i|+            select $AuthRowData{ isCardSame = x::bool?,+                rowServer = tse.row_server::text,+                country = ae.country::text }+            from app.row_details+            where id = $1::uuid+        |]+        [__i|+            select+                $AuthRowData+                    { isCardSame = x::bool?+                    , rowServer = tse.row_server::text+                    , country = ae.country::text+                    }+            from app.row_details+            where id = $1::uuid+        |]+#endif+#ifdef KRONOR_SYNTAX+    , mkCase+        "[08] hasql-th $1.$field record access survives inside BETWEEN x AND y and coalesce() args"+        8+        [__i|+            select 1+            from t ext+            where ext.org = $1.$orgId::bigint+                and ext.d between $1.$start::date and $1.$end::date+                and ext.acq = coalesce($1.$acq::text?, ext.acq)+        |]+        [__i|+            select 1+            from t ext+            where ext.org = $1.$orgId::bigint+              and ext.d between $1.$start::date and $1.$end::date+              and ext.acq = coalesce($1.$acq::text?, ext.acq)+        |]+#endif+    , mkCase+        "[09] INSERT ... ON CONFLICT DO UPDATE SET puts the updated column assignment on its own indented line"+        4+        [__i|+            insert into t (org_id, batch_id, data)+            select $1::bigint, $2::text, unnest($3::jsonb[])+            on conflict (batch_id, md5(data::text))+            do update set data = excluded.data+        |]+        [__i|+            insert into t+                (org_id, batch_id, data)+            select+                $1::bigint+              , $2::text+              , unnest($3::jsonb[])+            on conflict (batch_id, md5(data::text))+            do update set+                data = excluded.data+        |]+    , mkCase+        "[10] WITH CTE + LEFT JOIN LATERAL fn(...): CTE body is multi-lined, last CTE closes with ')' not '),', ::text? survives inside the lateral function"+        8+        [__i|+            with details as (+                select * from app.row_details+                where id = $1::uuid )+            select details.id::uuid+            from details+            left join lateral fsm.notify ( shard := details.mid, event := $2::text, data := jsonb_build_object( 'error', $3::text? ) )+                on true+        |]+        [__i|+            with details as (+                select *+                from app.row_details+                where id = $1::uuid+            )+            select details.id::uuid+            from details+            left join lateral fsm.notify(+                  shard := details.mid+                , event := $2::text+                , data := jsonb_build_object('error', $3::text?)+            )+              on true+        |]+    , mkCase+        "[11] content inside single-quoted string literals ('U', 'Y', 'FM000') is not lowercased or otherwise touched"+        4+        [__i|+            select (case when x is null then 'U' when x then 'Y' else 'N' end)::text,+                to_char(n, 'FM000')::text+            from t+            where id = $1::uuid+        |]+        [__i|+            select+                (case+                    when x is null then 'U'+                    when x then 'Y'+                    else 'N'+                end)::text+              , to_char(+                  n+                , 'FM000'+            )::text+            from t+            where id = $1::uuid+        |]+    , mkCase+        "[12] chained typecasts like ::text?::uuid? preserve both nullability markers (not just the last)"+        4+        [__i|+            select (x->>'a')::text?,+                (x->>'b')::text?::uuid?,+                (x->>'c')::text?+            from t+            where id = $1::uuid+        |]+        [__i|+            select+                (x->>'a')::text?+              , (x->>'b')::text?::uuid?+              , (x->>'c')::text?+            from t+            where id = $1::uuid+        |]+    , mkCase+        "[13] UPDATE ... SET: assigned expressions keep nullability chains like ::text?::uuid on the right-hand side"+        4+        [__i|+            update app.row_details+            set auth_response = $2::jsonb,+                ds_trans_id = $3::text?::uuid+            where id = $1::uuid+        |]+        [__i|+            update app.row_details+            set+                auth_response = $2::jsonb+              , ds_trans_id = $3::text?::uuid+            where id = $1::uuid+        |]+    , mkCase+        "[14] UNION ALL: both arms are formatted independently with their own multi-line select layout"+        4+        [__i|+            select id::bigint, name::text from table_a where active = true+            union all+            select id::bigint, name::text from table_b where active = true+        |]+        [__i|+            select+                id::bigint+              , name::text+            from table_a+            where active = true+            union all+            select+                id::bigint+              , name::text+            from table_b+            where active = true+        |]+    , mkCase+        "[15] GROUP BY / ORDER BY / LIMIT each land on their own line at the base clause indent after WHERE"+        4+        [__i|+            select provider::text, count(*)::bigint+            from classification.ext+            where org_id = $1::bigint+            group by provider+            order by count(*) desc+            limit 10+        |]+        [__i|+            select+                provider::text+              , count(*)::bigint+            from classification.ext+            where org_id = $1::bigint+            group by provider+            order by count(*) desc+            limit 10+        |]+    , mkCase+        "[16] DELETE FROM with a multi-predicate WHERE: ANDs are split onto their own indented lines"+        4+        [__i|+            delete from history.some_table+            where customer_id = $1::bigint+                and id = $2::bigint+        |]+        [__i|+            delete from history.some_table+            where customer_id = $1::bigint+              and id = $2::bigint+        |]+    , mkCase+        "[17] FROM can be a set-returning function call (not just a table name) without losing the FROM keyword"+        4+        [__i|+            select count(*)::bigint+            from app.set_opts_and_config($1::text, $2::jsonb)+        |]+        [__i|+            select count(*)::bigint+            from app.set_opts_and_config($1::text, $2::jsonb)+        |]+    , mkCase+        "[18] regression: ORDER BY inside an inline subquery is not emitted as 'order by order by' (R.sortClause already includes the keyword)"+        4+        [__i|+            select id::bigint+            from t+            where id = (select id from u order by created_at desc limit 1)+        |]+        [__i|+            select id::bigint+            from t+            where id = (select id from u order by created_at desc limit 1)+        |]+    , mkCase+        "[19] regression: GROUP BY inside an inline subquery is not emitted as 'group by group by' (R.groupClause already includes the keyword)"+        4+        [__i|+            select id::bigint+            from t+            where id = (select max(id) from u group by org_id)+        |]+        [__i|+            select id::bigint+            from t+            where id = (select max(id) from u group by org_id)+        |]+    , mkCase+        "[20] regression: ORDER BY inside an aggregate's argument list (string_agg(x, ',' order by y)) is not doubled"+        4+        [__i|+            select string_agg(name, ', ' order by id)::text+            from t+            where org_id = $1::bigint+        |]+        [__i|+            select string_agg(+                  name+                , ', '+                order by id+            )::text+            from t+            where org_id = $1::bigint+        |]+    , mkCase+        "[21] x = ANY(array_expr) keeps the parens around the array expression (Postgres requires them when the operand isn't a subquery)"+        4+        [__i|+            select id::bigint+            from t+            where customer_id = ANY($1::bigint[])+        |]+        [__i|+            select id::bigint+            from t+            where customer_id = any($1::bigint[])+        |]+    , mkCase+        "[22] CASE WHEN ... THEN ... ELSE ... END arms preserve hasql-th's ::text? nullable marker (don't fall through to the generic renderer)"+        4+        [__i|+            update t+            set x = case when $2::text? = 'Y' then $1::jsonb else x end+            where id = $3::uuid+        |]+        [__i|+            update t+            set x = case when $2::text? = 'Y' then $1::jsonb else x end+            where id = $3::uuid+        |]+    , mkCase+        "[23] FROM with multiple tables and no joins stays on a single line (no 'from t1\\nfrom t2' duplication)"+        4+        [__i|+            select t1.id::bigint+            from t1, t2+            where t1.id = t2.id+        |]+        [__i|+            select t1.id::bigint+            from t1, t2+            where t1.id = t2.id+        |]+    , mkCase+        "[24] nullable array typecast ::text[]? keeps the trailing ? (the second Bool in the array-dims tuple must be rendered)"+        4+        [__i|+            update t+            set statuses = $1::text[]?+            where id = $2::uuid+        |]+        [__i|+            update t+            set statuses = $1::text[]?+            where id = $2::uuid+        |]+#ifdef KRONOR_SYNTAX+    , mkCase+        "[25] array[...] literal preserves hasql-th $1.$field access inside its element expressions"+        4+        [__i|+            select enqueue(array[mk_payload(organization_id => $1.$organizationId::bigint, frequency => $2::text)])::text+        |]+        [__i|+            select enqueue(array[mk_payload(organization_id => $1.$organizationId::bigint, frequency => $2::text)])::text+        |]+#endif+#ifdef KRONOR_SYNTAX+    , mkCase+        "[26] ROW(...) constructor preserves $1.$field access and ::text? nullability on its elements"+        4+        [__i|+            select create_payment(capture_input := ROW($2::bigint, $3::bigint::currency_unit, $4::text?)::capture_input, created_by := $5::text?)+        |]+        [__i|+            select create_payment(+                  capture_input := row($2::bigint, $3::bigint::currency_unit, $4::text?)::capture_input+                , created_by := $5::text?+            )+        |]+#endif+#ifdef KRONOR_SYNTAX+    , mkCase+        "[27] inline subquery's FROM function-call (inside EXISTS (...)) preserves $1.$field in its argument list"+        4+        [__i|+            select exists (select 1::int from gen($1.$orgId::bigint, $1.$cursor::text?))::bool+        |]+        [__i|+            select exists (select 1::int from gen($1.$orgId::bigint, $1.$cursor::text?))::bool+        |]+#endif+#ifdef KRONOR_SYNTAX+    , mkCase+        "[28] UNION ALL inside EXISTS (...) wraps when the inline form gets long; each arm preserves $1.$field"+        4+        [__i|+            select exists (select 1::int from gen1($1.$orgId::bigint, $1.$cursor::text?) union all select 1::int from gen2($1.$orgId::bigint, $1.$cursor::text?))::bool+        |]+        [__i|+            select exists (+                select 1::int+                from gen1($1.$orgId::bigint, $1.$cursor::text?)+                union all+                select 1::int+                from gen2($1.$orgId::bigint, $1.$cursor::text?)+            )::bool+        |]+#endif+#ifdef KRONOR_SYNTAX+    , mkCase+        "[29] EXTRACT(field FROM $1.$field::type) preserves the hasql-th field access inside the extracted expression"+        4+        [__i|+            select extract(epoch from ($2.$expiresAt::timestamptz - now()))+        |]+        [__i|+            select extract(epoch from ($2.$expiresAt::timestamptz - now()))+        |]+#endif+    , mkCase+        "[30] multi-row VALUES emits the 'values' keyword once with comma-separated row tuples (not 'values (...) values (...)')"+        4+        [__i|+            insert into t (a, b, c, d)+            values+                ($1::bigint, 'A', null, 'x'),+                ($1::bigint, 'B', null, 'y')+        |]+        [__i|+            insert into t+                ( a+                , b+                , c+                , d+                )+            values+                ( $1::bigint+                , 'A'+                , null+                , 'x'+                ),+                ( $1::bigint+                , 'B'+                , null+                , 'y'+                )+        |]+    , mkCase+        "[31] LEFT JOIN with multi-condition ON: AND continuations under the ON clause"+        4+        [__i|+            select t1.id::bigint+            from t1+            left join t2 on t1.id = t2.t1_id and t1.kind = t2.kind and t1.org = t2.org+            where t1.id = $1::bigint+        |]+        [__i|+            select t1.id::bigint+            from t1+            left join t2+              on t1.id = t2.t1_id+             and t1.kind = t2.kind+             and t1.org = t2.org+            where t1.id = $1::bigint+        |]+    , mkCase+        "[32] WITH chains multiple CTEs separated by '),' and ends the last with ')'"+        4+        [__i|+            with parsed (parsed_cursor) as (+                select coalesce($1::jsonb, '{}'::jsonb)+            ),+            data as (select 1::int from parsed)+            select 1::int from data+        |]+        [__i|+            with parsed (parsed_cursor) as (+                select coalesce($1::jsonb, '{}'::jsonb)+            ),+            data as (+                select 1::int+                from parsed+            )+            select 1::int+            from data+        |]+    , mkCase+        "[33] CTE with explicit column list: 'with name (col1, col2) as (...)' preserves the column list parens"+        4+        [__i|+            with rolled (a, b) as (+                select 1::int, 2::int+            )+            select rolled.a::int from rolled+        |]+        [__i|+            with rolled (a, b) as (+                select+                    1::int+                  , 2::int+            )+            select rolled.a::int+            from rolled+        |]+    , mkCase+        "[34] CROSS JOIN renders on its own line with no ON/USING qualifier"+        4+        [__i|+            select t1.id::bigint+            from t1+            cross join t2+            where t1.id = $1::bigint+        |]+        [__i|+            select t1.id::bigint+            from t1+            cross join t2+            where t1.id = $1::bigint+        |]+    , mkCase+        "[35] INSERT ... ON CONFLICT DO NOTHING: 'on conflict' and 'do nothing' each go on their own line"+        4+        [__i|+            insert into t (a, b, c)+            values ($1::bigint, $2::text, $3::text)+            on conflict+            do nothing+        |]+        [__i|+            insert into t+                (a, b, c)+            values+                ( $1::bigint+                , $2::text+                , $3::text+                )+            on conflict+            do nothing+        |]+    , mkCase+        "[36] UPDATE ... FROM: the FROM clause goes on its own line between SET and WHERE"+        4+        [__i|+            update t1 set x = t2.y from t2 where t1.id = t2.id and t1.id = $1::bigint+        |]+        [__i|+            update t1+            set x = t2.y+            from t2+            where t1.id = t2.id+              and t1.id = $1::bigint+        |]+    , mkCase+        "[37] DELETE ... USING: the USING clause goes on its own line between DELETE and WHERE"+        4+        [__i|+            delete from t1 using t2 where t1.id = t2.id and t1.id = $1::bigint+        |]+        [__i|+            delete from t1+            using t2+            where t1.id = t2.id+              and t1.id = $1::bigint+        |]+    , mkCase+        "[38] Function call with multiple arguments wraps each argument onto its own line, with closing paren back at the function's column"+        4+        [__i|+            select app.save_records_pre_commit($2::text, $1::jsonb, $3::jsonb, $4::bytea, $5::jsonb)::uuid+        |]+        [__i|+            select app.save_records_pre_commit(+                  $2::text+                , $1::jsonb+                , $3::jsonb+                , $4::bytea+                , $5::jsonb+            )::uuid+        |]+    , mkCase+        "[39] LEFT JOIN LATERAL with a function call that has multiple named args wraps the call onto multiple lines"+        4+        [__i|+            select 1::int+            from t+            left join lateral fsm.notify_state_machine(shard := $1::bigint, machine := $2::bigint, event := 'foo', data := jsonb_build_object('a', $3::text)) on true+        |]+        [__i|+            select 1::int+            from t+            left join lateral fsm.notify_state_machine(+                  shard := $1::bigint+                , machine := $2::bigint+                , event := 'foo'+                , data := jsonb_build_object('a', $3::text)+            )+              on true+        |]+    , mkCase+        "[40] Single-argument function call stays inline (no wrap when args < threshold)"+        4+        [__i|+            select md5($1::text)::text+        |]+        [__i|+            select md5($1::text)::text+        |]+    , mkCase+        "[41] Two-argument function call wraps onto multiple lines (threshold is 2)"+        4+        [__i|+            select to_char(n, 'FM000')::text from t+        |]+        [__i|+            select to_char(+                  n+                , 'FM000'+            )::text+            from t+        |]+    , mkCase+        "[42] string literal containing ' and ' is not split as a boolean AND"+        4+        [__i|+            select id::bigint from t+            where label = 'apple and orange'+              and other = $1::text+        |]+        [__i|+            select id::bigint+            from t+            where label = 'apple and orange'+              and other = $1::text+        |]+    , mkCase+        "[43] string literal containing ' :: ' is preserved verbatim"+        4+        [__i|+            select id::bigint from t+            where note = 'see x :: y for details'+        |]+        [__i|+            select id::bigint+            from t+            where note = 'see x :: y for details'+        |]+    , mkCase+        "[44] FOR UPDATE inside an inline subquery is preserved"+        4+        [__i|+            select id::bigint from t+            where exists (select 1 from u where u.t_id = t.id for update)+        |]+        [__i|+            select id::bigint+            from t+            where exists (select 1 from u where u.t_id = t.id for update)+        |]+    , mkCase+        "[45] Chained typecasts on a function-call select target keep every cast"+        4+        [__i|+            select jsonb_build_object($1::text, $2::text)::text::bytea from t+        |]+        [__i|+            select jsonb_build_object(+                  $1::text+                , $2::text+            )::text::bytea+            from t+        |]+    , mkCase+        "[46] Top-level WINDOW clause is preserved on its own line"+        4+        [__i|+            select row_number() over w::bigint from t window w as (partition by x order by y)+        |]+        [__i|+            select row_number() over w::bigint+            from t+            window w as (partition by x order by y)+        |]+    , mkCase+        "[47] CTE materialization hint (MATERIALIZED) is preserved"+        4+        [__i|+            with cte as materialized (select 1::bigint as x)+            select x::bigint from cte+        |]+        [__i|+            with cte as materialized (+                select 1::bigint as x+            )+            select x::bigint+            from cte+        |]+    , mkCase+        "[48] INSERT ... OVERRIDING SYSTEM VALUE is preserved on its own line"+        4+        [__i|+            insert into t (id, name, kind, source) overriding system value+            select x.id::bigint, x.name::text, x.kind::text, x.source::text from src x+        |]+        [__i|+            insert into t+                ( id+                , name+                , kind+                , source+                )+            overriding system value+            select+                x.id::bigint+              , x.name::text+              , x.kind::text+              , x.source::text+            from src x+        |]+    , mkCase+        "[49] Nested function calls wrap recursively; jsonb_build_object groups (key, value) pairs onto one line when wrapped; trailing args after a wrapped sub-call line up with the leading-comma column"+        4+        [__i|+            select job_queue.enqueue_payload(+                array_agg(+                    job_queue.mk_payload(+                        caller => 'Sample.Vantage.FetchSettlements',+                        provider => 'Vantage',+                        payload => jsonb_build_object(+                            'tag', 'FetchVantageBatchesTag',+                            'contents', jsonb_build_object(+                                'organizationId', inserted.organization_id,+                                'vantageSettlementId', inserted.id,+                                'stateMachineId', $4::bigint+                            )+                        ),+                        run_at => now(),+                        timeout => '1 day'::interval+                    )+                )::jsonb[],+                priority_ := 100+            )::text+            from inserted+        |]+        [__i|+            select job_queue.enqueue_payload(+                  array_agg(+                      job_queue.mk_payload(+                          caller => 'Sample.Vantage.FetchSettlements'+                        , provider => 'Vantage'+                        , payload => jsonb_build_object(+                              'tag', 'FetchVantageBatchesTag'+                            , 'contents', jsonb_build_object(+                                  'organizationId', inserted.organization_id+                                , 'vantageSettlementId', inserted.id+                                , 'stateMachineId', $4::bigint+                            )+                        )+                        , run_at => now()+                        , timeout => '1 day'::interval+                    )+                )::jsonb[]+                , priority_ := 100+            )::text+            from inserted+        |]+    , mkCase+        "[50] EXISTS subquery in a CTE wraps multi-line when the inline form gets too long"+        4+        [__i|+            with customer_note as (+                select exists (select * from storefront_core.customer_note where customer_id = current_setting('app.customer_id')::bigint and customer_reference = current_setting('app.customer_reference')::text and fingerprint = $2::uuid) card_exists+            )+            select (save_card_details and not customer_note.card_exists)::boolean+            from app.row_details, customer_note+            where row_details.customer_id = current_setting('app.customer_id')::bigint+              and row_details.id = $1::uuid+        |]+        [__i|+            with customer_note as (+                select exists (+                    select *+                    from storefront_core.customer_note+                    where customer_id = current_setting('app.customer_id')::bigint+                      and customer_reference = current_setting('app.customer_reference')::text+                      and fingerprint = $2::uuid+                ) card_exists+            )+            select (save_card_details and not customer_note.card_exists)::boolean+            from app.row_details, customer_note+            where row_details.customer_id = current_setting('app.customer_id')::bigint+              and row_details.id = $1::uuid+        |]+    , mkCase+        "[51] SQL keyword-form functions (position / substring / trim / overlay) emit no space before '('"+        4+        [__i|+            select position(' ' in name)::int+                 , substring(s from 1 for 5)::text+                 , substring(s, 1, 5)::text+                 , trim(both ' ' from name)::text+                 , overlay(name placing 'x' from 1 for 1)::text+            from t+            where id = $1::bigint+        |]+        [__i|+            select+                position(' ' in name)::int+              , substring(s from 1 for 5)::text+              , substring(s, 1, 5)::text+              , trim(both ' ' from name)::text+              , overlay(name placing 'x' from 1 for 1)::text+            from t+            where id = $1::bigint+        |]+    , mkCase+        "[52] array[fn(...)] literal whose element is a wrap-eligible call expands the inner call across multiple lines"+        4+        [__i|+            select job_queue.enqueue_payload(+                array[job_queue.mk_payload(caller => 'X', provider => 'PayWallet', payload => jsonb_build_object('tag', 'FetchTag', 'contents', jsonb_build_object('organizationId', $1::bigint, 'ledgerId', $2::bigint, 'cursor', $3::text)))]+                , priority_ := 5000+            )::text+        |]+        [__i|+            select job_queue.enqueue_payload(+                  array[+                      job_queue.mk_payload(+                          caller => 'X'+                        , provider => 'PayWallet'+                        , payload => jsonb_build_object(+                              'tag', 'FetchTag'+                            , 'contents', jsonb_build_object(+                                  'organizationId', $1::bigint+                                , 'ledgerId', $2::bigint+                                , 'cursor', $3::text+                            )+                        )+                    )+                ]+                , priority_ := 5000+            )::text+        |]+    , mkCase+        "[53] FROM-list `from fn(args)` wraps when the function call has multiple args"+        4+        [__i|+            select count(*)::bigint+            from job_queue.enqueue_payload(array[job_queue.mk_payload(customer_id => $1::bigint, caller => 'Foo', payload => jsonb_build_object('tag', 'X', 'contents', jsonb_build_object('orgId', $2::bigint, 'cursor', $3::text)))]::jsonb[], priority_ := 5000)+        |]+        [__i|+            select count(*)::bigint+            from job_queue.enqueue_payload(+                  array[+                      job_queue.mk_payload(+                          customer_id => $1::bigint+                        , caller => 'Foo'+                        , payload => jsonb_build_object(+                              'tag', 'X'+                            , 'contents', jsonb_build_object(+                                  'orgId', $2::bigint+                                , 'cursor', $3::text+                            )+                        )+                    )+                ]::jsonb[]+                , priority_ := 5000+            )+        |]+    , mkCase+        "[54] Single-arg outer call (e.g. unnest) with a long array arg wraps via the select-target length gate"+        4+        [__i|+            select unnest(array[format('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $1::text), format('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $2::text)])::text+            from t+        |]+        [__i|+            select unnest(+                  array[+                      format(+                          'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'+                        , $1::text+                    )+                    , format(+                          'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'+                        , $2::text+                    )+                ]+            )::text+            from t+        |]+    , mkCase+        "[55] (expr)::type — parenthesized wrap-eligible expression with a typecast wraps when too long"+        4+        [__i|+            select (json_build_object('https://myapp.dev/jwt/claims', $2::jsonb?, 'iat', extract(epoch from now()), 'exp', extract(epoch from now() + interval '1 hour'), 'uid', $4::text?, 'utype', $3::text?, 'uname', null, 'registered_email', $1::text?)::jsonb)::json+        |]+        [__i|+            select (+                json_build_object(+                      'https://myapp.dev/jwt/claims', $2::jsonb?+                    , 'iat', extract(epoch from now())+                    , 'exp', extract(epoch from now() + interval '1 hour')+                    , 'uid', $4::text?+                    , 'utype', $3::text?+                    , 'uname', null+                    , 'registered_email', $1::text?+                )::jsonb+            )::json+        |]+    , mkCase+        "[56] FROM unnest(...) AS alias (col1, col2, ..., colN) wraps the column list when it has more than 3 columns"+        4+        [__i|+            select count(*)::bigint from unnest($1::int[]) as txn_row (entry_reference, external_status, currency, amount, transaction_type, name, booking_date, value_date)+        |]+        [__i|+            select count(*)::bigint+            from unnest($1::int[]) as txn_row+                ( entry_reference+                , external_status+                , currency+                , amount+                , transaction_type+                , name+                , booking_date+                , value_date+                )+        |]+#ifdef KRONOR_SYNTAX+    , mkCase+        "[57] hasql-th nested $Record{...} fields expand multi-line when an inner record's inline form is long"+        8+        [__i|+            select $PaymentSource{ wallet = $Wallet{ experienceContext = $ExperienceContext{ brandName = customer.name::text, returnUrl = wallet_payment.return_url::text, cancelUrl = wallet_payment.return_url::text } } }+            from t+            where id = $1::uuid+        |]+        [__i|+            select+                $PaymentSource+                    { wallet = $Wallet+                        { experienceContext = $ExperienceContext+                            { brandName = customer.name::text+                            , returnUrl = wallet_payment.return_url::text+                            , cancelUrl = wallet_payment.return_url::text+                            }+                        }+                    }+            from t+            where id = $1::uuid+        |]+#endif+    , mkCase+        "[58] CROSS JOIN (VALUES ...) with many tuples wraps multi-line; each tuple > 3 cols also expands to bracket-list"+        4+        [__i|+            select m.id::bigint, payment_method.column1::text, payment_method.column2::int+            from config.customer m+            cross join (values ('SUCCESSFUL_WALLET_PAYMENT', 71, 400, 'SEK', 'Wallet payment charge'), ('SUCCESSFUL_DIRECT_DEBIT_PAYMENTS', 72, 300, 'SEK', 'Direct Debit (Open Banking)'), ('SUCCESSFUL_CREDIT_CARD_PAYMENTS', 83, 45, 'SEK', 'Credit card fee')) as payment_method+        |]+        [__i|+            select+                m.id::bigint+              , payment_method.column1::text+              , payment_method.column2::int+            from config.customer m+            cross join (+                values+                    ( 'SUCCESSFUL_WALLET_PAYMENT'+                    , 71+                    , 400+                    , 'SEK'+                    , 'Wallet payment charge'+                    ),+                    ( 'SUCCESSFUL_DIRECT_DEBIT_PAYMENTS'+                    , 72+                    , 300+                    , 'SEK'+                    , 'Direct Debit (Open Banking)'+                    ),+                    ( 'SUCCESSFUL_CREDIT_CARD_PAYMENTS'+                    , 83+                    , 45+                    , 'SEK'+                    , 'Credit card fee'+                    )+            ) as payment_method+        |]+    , mkCase+        "[59] CASE expressions in SET RHS expand multi-line when the inline form gets long; body aligns under the CASE keyword"+        4+        [__i|+            update app.row_details+            set+                challenge_response = case when $3::text? = 'Y' then $2::jsonb else challenge_response end+              , post_auth_response = case when $3::text? = 'Y' then $2::jsonb else post_auth_response end+              , error_response = case when $3::text? != 'Y' or $3::text? is null then $2::jsonb else error_response end+            where id = $1::uuid+        |]+        [__i|+            update app.row_details+            set+                challenge_response = case+                    when $3::text? = 'Y' then $2::jsonb+                    else challenge_response+                end+              , post_auth_response = case+                    when $3::text? = 'Y' then $2::jsonb+                    else post_auth_response+                end+              , error_response = case+                    when $3::text? != 'Y' or $3::text? is null then $2::jsonb+                    else error_response+                end+            where id = $1::uuid+        |]+#ifdef KRONOR_SYNTAX+    , mkCase+        "[60] hasql-th $Foo (...) splice wraps multi-line when inline form is long; SQL inside its args (CASE, parens, casts) wraps the same way it would at the top-level SELECT-target position"+        4+        [__i|+            select+                mobile_charge.id::uuid+              , $CanBeValidated ((case mobile_charge.payment_category when 'StandAlone' then (select client_credentials_public from customer_as_target) else (select client_credentials_public from vendor_as_target) end)::text, 'config.wallet_config'::text, 'client_credentials_public'::text)+            from history.mobile_charge+        |]+        [__i|+            select+                mobile_charge.id::uuid+              , $CanBeValidated (+                  (case mobile_charge.payment_category+                      when 'StandAlone' then (+                          select client_credentials_public+                          from customer_as_target+                      )+                      else (+                          select client_credentials_public+                          from vendor_as_target+                      )+                  end)::text+                , 'config.wallet_config'::text+                , 'client_credentials_public'::text+            )+            from history.mobile_charge+        |]+#endif+    , mkCase+        "[61] CASE expression in a top-level SELECT target always wraps multi-line, even when its inline form would fit under the length threshold"+        4+        [__i|+            select+                pma.country::text+              , (case when row_details.row_method_invoked then 'Y' else 'N' end)::text+              , row_details.row_trans_id::uuid+            from app.row_details+            join config.payment_method_provider pma on pma.id = row_details.pma_id+            where row_details.id = $1::uuid+        |]+        [__i|+            select+                pma.country::text+              , (case+                    when row_details.row_method_invoked then 'Y'+                    else 'N'+                end)::text+              , row_details.row_trans_id::uuid+            from app.row_details+            join config.payment_method_provider pma+              on pma.id = row_details.pma_id+            where row_details.id = $1::uuid+        |]+    , mkCase+        "[62] bare CASE in SELECT target (no surrounding parens, optional trailing typecast on @end@) wraps with body indented relative to the @case@ keyword's column, not the outer block's nest level"+        4+        [__i|+            select+                id::text+              , currency::text+              , case transaction_type when 'CAPTURE' then 'Purchase' when 'REFUND' then 'Refund' end::text+              , amount::int+            from storefront_core.ledger+            where customer_id = $1::bigint+        |]+        [__i|+            select+                id::text+              , currency::text+              , case transaction_type+                    when 'CAPTURE' then 'Purchase'+                    when 'REFUND' then 'Refund'+                end::text+              , amount::int+            from storefront_core.ledger+            where customer_id = $1::bigint+        |]+    , mkCase+        "[63] single-target SELECT whose target is a top-level CASE forces block-list layout (`select` ends the line, `case` starts at item-content column) so @end@ lines up under @case@ — instead of @case@ landing mid-line after `select ` and the body / @end@ being pushed far right by 'align'"+        4+        [__i|+            select case when status = 'won' then fsm.notify_state_machine(shard := organization_id, machine := state_machine_id, event := 'dispute.won') when status = 'lost' then fsm.notify_state_machine(shard := organization_id, machine := state_machine_id, event := 'dispute.lost') end::text+            from classification.provider_dispute dispute+            where organization_id = $1::bigint+              and dispute.id = $2::uuid+        |]+        [__i|+            select+                case+                    when status = 'won' then fsm.notify_state_machine(+                          shard := organization_id+                        , machine := state_machine_id+                        , event := 'dispute.won'+                    )+                    when status = 'lost' then fsm.notify_state_machine(+                          shard := organization_id+                        , machine := state_machine_id+                        , event := 'dispute.lost'+                    )+                end::text+            from classification.provider_dispute dispute+            where organization_id = $1::bigint+              and dispute.id = $2::uuid+        |]+    , mkCase+        "[64] inline subquery `SELECT DISTINCT ON (...)` keeps the `ON (...)` clause — regression guard"+        4+        [__i|+            insert into reference.code_bins (start_num, end_num, num_length)+            select (row_data->>'start_num')::numeric, (row_data->>'end_num')::numeric, (row_data->>'num_length')::bigint+            from (select distinct on (row_data->>'start_num', row_data->>'end_num') row_data from jsonb_array_elements($1::jsonb) as row_data) s+            on conflict (start_num, end_num) do nothing+        |]+        [__i|+            insert into reference.code_bins+                (start_num, end_num, num_length)+            select+                (row_data->>'start_num')::numeric+              , (row_data->>'end_num')::numeric+              , (row_data->>'num_length')::bigint+            from (+                select distinct on (row_data ->> 'start_num', row_data ->> 'end_num') row_data+                from jsonb_array_elements($1::jsonb) as row_data+            ) s+            on conflict (start_num, end_num)+            do nothing+        |]+    ]+++main :: IO ()+main = do+    let results = map runTest tests+        failures = filter (not . fst) results+    mapM_+        ( \(_, msg) -> do+            TIO.putStrLn "---"+            TIO.putStrLn msg+        )+        failures+    if null failures+        then do+            putStrLn $ "All " <> show (length tests) <> " tests passed."+        else do+            putStrLn $ "\n" <> show (length failures) <> " of " <> show (length tests) <> " tests FAILED."+            exitWith (ExitFailure 1)+++runTest :: TestCase -> (Bool, Text)+runTest TestCase{name, qqCol, input, expected} =+    -- 'formatSql' now returns 'Left' on AST mismatch; treating that as a+    -- failure is the whole point of this test suite — if the formatter ever+    -- emits semantically-different SQL, the test reports it as such.+    let actual = either ("ERROR: " <>) id (formatSql qqCol (T.stripEnd input))+        stringEq = T.stripEnd actual == T.stripEnd expected+        -- Semantic guard on the test fixture itself: the hand-written+        -- `expected` must parse to the same AST as `input`. Catches a+        -- fixture-authoring mistake even before the formatter runs.+        astFixtureEq = case (parseAst input, parseAst expected) of+            (Just a, Just b) -> a == b+            _ -> True -- can't parse one side; defer to string check+            -- Semantic guard on the formatter output: parsing `actual` must+            -- yield the same AST as `input`. Catches a renderer that drops+            -- or alters a clause but still emits valid SQL (the+            -- `DISTINCT ON (...)` regression that motivated this guard).+        astFormatterEq = case (parseAst input, parseAst actual) of+            (Just a, Just b) -> a == b+            _ -> True+        pass = stringEq && astFixtureEq && astFormatterEq+        reason+            | not stringEq = "string mismatch"+            | not astFixtureEq = "AST mismatch: expected fixture is not semantically equivalent to input"+            | not astFormatterEq = "AST mismatch: formatter output is not semantically equivalent to input"+            | otherwise = ""+        msg =+            if pass+                then "PASS: " <> name+                else+                    "FAIL ("+                        <> reason+                        <> "): "+                        <> name+                        <> "\n=== INPUT ===\n"+                        <> showWhitespace input+                        <> "\n=== EXPECTED ===\n"+                        <> showWhitespace expected+                        <> "\n=== ACTUAL ===\n"+                        <> showWhitespace actual+     in (pass, msg)+++-- | Show whitespace characters for debugging+showWhitespace :: Text -> Text+showWhitespace = T.pack . concatMap showC . T.unpack+  where+    showC '\n' = "\\n\n"+    showC c = [c]