Format the SQL inside your
hasql-th quasiquotes, in place.
It parses each statement with
postgresql-syntax and
renders it again, so the layout comes from the statement's structure rather
than from whoever typed it:
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
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
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).
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:
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:
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 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.