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 +28/−0
- README.md +234/−0
- app/Audit.hs +147/−0
- app/Hasql/ThFormat.hs +103/−0
- app/Hasql/ThFormat/SqlFormat.hs +1950/−0
- app/Main.hs +103/−0
- hasql-th-format.cabal +160/−0
- test/Test.hs +1343/−0
+ 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]