Repository navigation
fix(pretty_print): group function calls so count(*), arrays and JSON functions break sensibly - #898
Merged
Merged
Conversation
…functions break sensibly
This was referenced Oct 24, 2026
Merged
psteinroe
changed the base branch from
fix/pretty-print-scalar-subquery
to
main
October 26, 2026 07:58
psteinroe
added a commit
that referenced
this pull request
Oct 26, 2026
Stacked on #898. `grant r1 to u1 with admin option` was rewritten to `with admin true`. Both spellings parse to the same AST since Postgres 16, but Postgres 15 and older only accept `with admin option`, so formatting broke the statement for them: ```sql -- before grant r1 to u1 with admin option; -- formatted as: grant r1 to u1 with admin true; -- Postgres 15: ERROR: syntax error at or near "true" -- after grant r1 to u1 with admin option; ``` The GRANT role option emitter now writes `admin option` when the admin option is true. `admin false`, `inherit true|false` and `set true|false` keep the boolean spelling: they only exist since Postgres 16, which also accepts it. A true `admin` mixed with them prints as `with inherit false, admin option`, which Postgres 16 accepts too. I ran the output against the local Postgres 15.19: `with admin option` and `with admin option granted by current_role` work, and `with admin true` is a syntax error there. **Snapshot changes** All reviewed; one category: `with admin true` → `with admin option` (`privileges`, both widths). Test: `grant_role_with_admin_option_keeps_the_portable_spelling` in `source_fidelity.rs` covers `admin option`, `admin true` and the Postgres 16 options.
psteinroe
added a commit
that referenced
this pull request
Oct 26, 2026
Stacked on #901. The snapshot tests render each fixture only with the options in its header, and the regression corpus test only runs the defaults. Bugs that need two options together got through both. Leading commas in fit layout (`select a , b`, fixed in #890) are one example. The release review found them by formatting everything under 288 option combinations. This PR lands a trimmed version of that harness, so the option matrix runs whenever the formatter changes. **What the test does** `tests/option_matrix.rs` formats two corpora under 32 configurations: every combination of `layout`, `clauseBodyStyle`, `logicalOperatorPlacement`, `commaStyle` and `valuesRowLayout`. Across those 32, indent (2 spaces, 4 spaces, tabs) and line width (40, 80, 120) cycle through all nine of their pairs. Upper-case keywords and constants come with width 120, `castStyle: operator` with tabs, and `isolateSemicolon` with width 40. The two corpora: - every statement of the snapshot fixtures, without their headers; - every statement Postgres 15–17 accepted in its regression suite. Each output must: - round-trip, i.e. `format_statement` doesn't refuse it as changing the AST; - format to itself again; - pass the whitespace check from #890: no space after `(` or before `)` and `,`, no double space, no trailing whitespace, no blank line; - not mix indentation: no tab when indenting with spaces, no space before a tab when indenting with tabs. Lines inside function bodies keep their source indentation and are skipped. Statements refused because a comment can't be placed (a comment after the terminator) are skipped; that refusal is by design. Line width isn't checked. At width 40 there are known statements the formatter can't fit, mostly node types listed as not covered in #898. The snapshot tests already check width at 80 and 100. **Where it runs** Both corpora take about 35 seconds each in release on 8 cores. In debug, the fixtures alone take about three minutes. Adding that to `cargo test --workspace` would slow the Test job on all three platforms. The two tests are therefore `#[ignore]`d. A new `Formatter Option Matrix` job in `pull_request.yml` runs them once, in release on Ubuntu, when `pgls_pretty_print`, its codegen, `pgls_query`, the regression fixtures, `Cargo.lock` or the workflow change. I picked a path-filtered PR job over a nightly run so a regression fails the PR that introduces it. PRs that don't touch the formatter don't pay for it. A third test, not ignored, checks that the matrix covers all 32 combinations and all nine indent/width pairs. On this stack, all 2.1 million formats pass. On `main`, the same test fails on the bugs fixed in #890–#901. **New fixtures** The review's test-gap list (P21) asked for these. VALUES with ORDER BY/LIMIT and short leading-comma lists came in #893 and #890: - `aggregate_filter.sql`: `count(*) filter`, two filtered aggregates in one expression, `within group` + `filter`, and `filter` + `over`. - `expanded_case_alignment.sql`: expanded CASE with `indentSize: 4` after `select` and after `on`, a broken WHEN condition, and a CASE nested in THEN. **Not covered** - The full 288-config matrix and the width check stay out. They are useful for a release review but too noisy and slow for CI. - The snapshot width check still excuses lines whose longest token plus 20 columns fits. Tightening it would need a pass over every fixture, so I left it for later. **Follow-ups:** The snapshot width check is tightened in #950.
psteinroe
added a commit
that referenced
this pull request
Oct 26, 2026
…thin the line width (#946) Stacked on #938. #898 put `json_value`/`json_query`/`json_exists` on the function-call layout and listed the other constructs the release review found past the line width. This PR handles those. Each had no break opportunity, or broke without an indent so that the continuation sat at the enclosing level. **Calls whose arguments the formatter writes itself** Two new helpers, `open_call_arguments` / `close_call_arguments`, give these the function-call layout: `(`, a break and one more indent when the call doesn't fit, `)` back on the call's line. The keyword parts that join the arguments (`in`, `from`, `placing`, `for`, `passing`, `as`, the commas) become break opportunities: - `json_arrayagg` / `json_objectagg`. The call has its own group when FILTER or OVER follows, so the line breaks before the clause first, as for other aggregates since #898. Their FILTER now uses the same `filter (where ...)` layout as other aggregates. - `xmlserialize`, `xmlroot`, `xmlelement`, `xmlparse`, `xmlpi`, `xmlexists` - `position(... in ...)`, `extract(... from ...)`, `overlay(...)`, `substring(...)`, `trim(...)`, `normalize(...)` ```sql -- before (lineWidth 60) select position(some_long_substring_column_name in some_other_long_string_column_name) from t; -- after select position( some_long_substring_column_name in some_other_long_string_column_name ) from t; ``` **Statement parts** - **CREATE SUBSCRIPTION:** `connection`, `publication` and `with (...)` are indented continuation clauses, as in ALTER SUBSCRIPTION and GRANT. Before, a long statement printed the header on one line and then broke inside the publication list or the options. - **`comment on ... is '...'`:** breaks before `is`, one level in. - **Event trigger `when tag in (...)`:** the tags fill indented lines inside the parentheses. - **`explain (...)`:** the options are a parenthesized list, one per line when they don't fit. - **`json_table` columns:** a column that doesn't fit puts `path`, the wrapper, quotes and the `on empty` / `on error` clauses on their own lines, one level in. **Snapshot changes** All reviewed; every change is one of the constructs above going from a too-long line, or a break without an indent, to the layout described: - `subscription`, `object_address`, `create_subscription_stmt_0`: subscription clauses - `xml`: XML functions - `sqljson`: JSON aggregates and their FILTER - `sqljson_jsontable`: json_table columns - `strings`: overlay - `timetz`, `interval`: extract - `plpgsql`, `create_table_like`, `comment_stmt_2`: comment on ... is - `event_trigger`: when tag in - `partition_prune`: explain options A few calls that were broken before now fit on one line. New `width_layout.rs` tests format each construct at width 60 and check every line fits, with exact output for the main shapes. The option matrix passes. **Not covered** `cast(x as type)` still breaks before `as` without an indent (visible inside `extract` in `timetz`). Changing that touches most snapshots, so it is left for its own PR.
psteinroe
added a commit
that referenced
this pull request
Oct 26, 2026
Stacked on #946. #912 listed this as not covered. The snapshot width check (`assert_line_lengths` in `tests.rs`) didn't catch lines past the width in two common cases: - It only counted the characters between string literals. A call like `json_value(cast('{"a": ...}' as jsonb), '$.a.b'` of 95 columns passed at width 80, because most of it was inside quotes. - It excused a line whose longest identifier plus 20 columns didn't fit. That is how the `json_value` layout fixed in #898 got through. **The new check** `common::width_problems` replaces it. It uses the Postgres lexer and measures the whole line, with tabs counted as `indentSize` columns. A line is only excused when its indentation, its longest token (string literals included) and 12 more columns don't fit. That covers a long literal, a long quoted identifier, a function body, or code nested so deeply that there is no room left. A `--` comment ending the line is written as in the source and is not counted. The snapshot tests and the option matrix from #912 both use it. The matrix checks width only at 80 and 120 with `castStyle: cast`: at 40 some statements can't fit, and a `::` cast can't break either (`select '...'::int8` with a compact select list). **Formatter changes it needed** The stricter check found these, and they are fixed here: - **Text after a multi-line literal.** A group that contained a multi-line literal or function body stayed on one line as long as the literal's first line fit. Whatever followed its last line was never measured, e.g. a chain of `#-` operators running to 206 columns in `explain`. The renderer now checks that the text after the last line of such a token fits too. - **`is [not] distinct from`, `like`, `ilike`, `similar to`.** These now wrap their right-hand side like a comparison operator does, instead of always printing a space. - **SQL-syntax calls** (`extract`, `position`, `overlay`, `substring`, `xmlexists`). Their arguments are a group inside the call's parentheses, so `second from cast(...)` stays together on the indented line when it fits there, and `from` only breaks when it doesn't. **Snapshot changes** All reviewed: - text after a multi-line literal moves to the next line, or the call around the literal breaks (`explain`, `jsonb_jsonpath`, `misc_functions`, `tsearch`); - `is [not] distinct from` / `like` wrap before the right-hand side (`xid`, `txid`, `opr_sanity`, `strings`); - `extract(field from source)` breaks after `from` only when the arguments don't fit the indented line (`timetz`, `interval`). New tests in `width_layout.rs` cover the check itself, `is not distinct from`, `similar to`, and text after a multi-line literal. All fixtures pass the stricter check, and the option matrix passes with it.
psteinroe
added a commit
that referenced
this pull request
Oct 26, 2026
Stacked on #959. With `clauseBodyStyle: compact`, a lone select item stays on the `select` line inside an indent kept for its continuation lines. #938 listed this as not covered. When the item only breaks inside its own parentheses, a function call for instance, it has no continuation lines, and the indent pushed its arguments two levels in and its `)` one level in, under nothing. This is the same pattern #938 fixed for WHERE and ON: ```sql -- clauseBodyStyle: compact -- before select json_build_object( 'identifier', users.id, 'display_name', users.name ) from users; -- after select json_build_object( 'identifier', users.id, 'display_name', users.name ) from users; ``` **What changed** The compact select list leaves out the indent for a lone item without an alias that is one of these: - a function call without FILTER, WITHIN GROUP or OVER; - `coalesce(...)`, `greatest`/`least(...)` or `xmlserialize(...)`; - `cast(...)` with `castStyle: cast`; - a subquery link, using the rule from #938. Anything else keeps the indent, because it can continue on lines of its own: an alias (`... as total` can wrap), a FILTER clause, an operator chain such as `a + b +\n c`. **The FILTER layout** The other item, `filter (where` keeping `where` on the `filter (` line with `)` attached to the condition, was already fixed later in the stack: - #898 got a follow-up commit, and its description now describes it: `filter (where cond)` on one line when it fits, otherwise `filter (`, `where cond` indented, and `)` on its own line. - #946 uses the same layout for the JSON aggregates. `count_star_stays_together_when_the_filter_breaks` in `function_call_layout.rs` asserts it, so there is no separate change here. **Snapshot changes** All reviewed; one category: a lone call or subquery in a compact select list moves its arguments one level left and closes `)` under `select` (`compact_select_list*`, `scalar_subquery_expanded`). New test `a_lone_compact_select_item_closes_under_select` in `subquery_layout.rs`, covering a call and an operator chain that keeps its indent. The option matrix passes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #896.
Three places where a call-like expression broke in the wrong place or not at all.
count(*) filter (...)keepscount(*)togetherA function call put its name and arguments in the same group as a following FILTER or WITHIN GROUP. When that clause didn't fit, the arguments broke too:
The name and arguments now have their own group whenever FILTER, WITHIN GROUP or OVER follows. OVER already had one. The line breaks before the clause first. A clause that moves to its own line is indented one level, so it reads as part of the call:
percentile_cont(0.5)\n within group (order by x).A FILTER clause that fits stays on one line
filter (wherewas followed by a soft line in the call's group, so a FILTER that fit still broke asfilter (where\n cond)once the call broke, with)attached to the condition. FILTER is now a group of its own:filter (where cond)when it fits, and otherwisefilter (,where condindented on its own lines, and)on its own line, like a policy'susing (...).Long
array[...]literals fill indented linesarray[...]had no indent and no break after[. Once it didn't fit, every element after the first went on its own line at the enclosing indent, and]stuck to the last element:Arrays now use the same layout as an
IN (...)list. On one line when they fit; otherwise a break after[, the elements filling indented lines, and]back on the line the array started on.json_value,json_queryandjson_existsrespect the line widthThese had no indent and no break after
(, so a long context item ran past the width, and the clauses that wrapped did so at the enclosing indent with)attached to the last one. #882 left this out of scope. They now use the function-call layout thatjson_tablealready uses: on one line when the call fits, otherwise the context item, the path and each clause (passing,returning, wrapper, quotes,on empty,on error) on their own lines, one level in, and)on its own line:The
passingarguments are a group of their own, sopassing 0 as x, 2 as ystays on one line. The three functions shared most of their emitter code, which is now written once.Snapshot changes
All reviewed:
fn(\n arg\n)\nfilter (where .../within group (...)→fn(arg)with the clause indented on the next line (aggregates,ordered_set_filter,tuplesort,func_call_within_group_filter_0,20230405163940_download_metrics,window)array[and](amutils,arrays,type_sanity,stats_ext,rowtypes,rangefuncs,multirangetypes,jsonpath)json_value/json_query/json_existscalls → function-call layout (sqljson_queryfuncs, most of the diff, andjson_returning)New
function_call_layout.rstests covercount(*) filter,within group, long and short arrays, and the three JSON functions, broken and flat.Not covered
json_arrayagg/json_objectagg,xmlserialize/xmlexists,position(... in ...),extract(... from ...)and a few statement headers listed in the release review can still go past the width.Follow-ups: The remaining width offenders are fixed in #946.