Skip to content

fix(pretty_print): break the remaining calls and statement headers within the line width - #946

Merged
psteinroe merged 17 commits into
mainfrom
fix/pretty-print-width-offenders
Oct 26, 2026
Merged

psteinroe merged 17 commits into
mainfrom
fix/pretty-print-width-offenders

Conversation

@psteinroe

Copy link
Copy Markdown
Collaborator

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 fix(pretty_print): group function calls so count(*), arrays and JSON functions break sensibly #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(...)
-- 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
…functions break sensibly (#898)

Stacked on #896.

Three places where a call-like expression broke in the wrong place or not at all.

**`count(*) filter (...)` keeps `count(*)` together**

A 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:

```sql
-- before
count(
  dl.id
)
filter (where
  dl.created_at > now() - cast('180 days' as interval))
-- after
count(dl.id)
  filter (where
    dl.created_at > now() - cast('180 days' as interval))
```

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 (where` was followed by a soft line in the call's group, so a FILTER that fit still broke as `filter (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 otherwise `filter (`, `where cond` indented on its own lines, and `)` on its own line, like a policy's `using (...)`.

**Long `array[...]` literals fill indented lines**

`array[...]` 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:

```sql
-- before
oid <> all (cast(array['regproc',
'regprocedure',
'regoper',
...
'regcollation']
as regtype[]))
-- after
oid <> all (cast(array[
  'regproc', 'regprocedure', 'regoper', 'regoperator', 'regconfig',
  'regdictionary', 'regnamespace', 'regcollation'
]
as regtype[]))
```

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_query` and `json_exists` respect the line width**

These 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 that `json_table` already 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:

```sql
-- before (lineWidth 60)
select
  json_value(jsonb_column_with_long_name, '$.some.very.long.path.expression.here'
  returning int
  default 0 on empty
  error on error)
from
  t;
-- after
select
  json_value(
    jsonb_column_with_long_name,
    '$.some.very.long.path.expression.here'
    returning int
    default 0 on empty
    error on error
  )
from
  t;
```

The `passing` arguments are a group of their own, so `passing 0 as x, 2 as y` stays 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 elements one per line at the enclosing indent → filled, indented lines between `array[` and `]` (`amutils`, `arrays`, `type_sanity`, `stats_ext`, `rowtypes`, `rangefuncs`, `multirangetypes`, `jsonpath`)
- broken `json_value`/`json_query`/`json_exists` calls → function-call layout (`sqljson_queryfuncs`, most of the diff, and `json_returning`)

New `function_call_layout.rs` tests cover `count(*) 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.
@psteinroe
psteinroe changed the base branch from fix/pretty-print-compact-subquery-indent to main October 26, 2026 11:38
@psteinroe
psteinroe merged commit acd06ad into main Oct 26, 2026
12 checks passed
@psteinroe
psteinroe deleted the fix/pretty-print-width-offenders branch October 26, 2026 12:00
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 #950.

`cast(x as type)` broke before `as` with no indent, so the type sat at the column of the enclosing expression, and `)` stuck to it. #946 listed this as not covered:

```sql
-- before
select
  cast('4 millenniums 5 centuries 4 decades 1 year 4 months 4 days 17 minutes 31 seconds'
  as interval);
-- after
select
  cast(
    '4 millenniums 5 centuries 4 decades 1 year 4 months 4 days 17 minutes 31 seconds'
    as interval
  );
```

**What changed**

CAST now uses the function-call layout from #946: on one line when it fits; otherwise `(`, then `expr as type` one level in, and `)` back on the cast's line. `expr as type` is a group of its own, so it stays together on the indented line when it fits there, and breaks before `as` only when it doesn't:

```sql
-- before
  -cast('-2147483647 months -2147483647 days -9223372036854775807 us'
  as interval);
-- after
  -cast(
    '-2147483647 months -2147483647 days -9223372036854775807 us' as interval
  );
```

`castStyle: operator` (`x::type`) is unchanged.

**Snapshot changes**

51 snapshot files change, all reviewed. The categories:

- **A cast that doesn't fit:** `cast(x\nas type)` becomes `cast(\n  x as type\n)` when `x as type` fits on the indented line, or `cast(\n  x\n  as type\n)` when it doesn't. This is most of the diff, mostly long literals: `jsonb`, `json`, `interval`, `tstypes`, `horology`, `xml`, `sqljson_queryfuncs`, `int8`, `md5`, `name`, `timestamptz`, `xid`, `txid`.
- **A cast around a call that breaks:** `cast(format(` / `cast(row(` used to hug the call, with `as type)` on the last line at the enclosing indent. Now the call sits one level inside `cast(`, followed by `as type` and `)` on their own lines (`brin`, `brin_bloom`, `brin_multi`, `amutils`, `type_sanity`, `stats_import`, `fast_default`, `jsonb`).
- **Deep nesting:** a broken cast around a deep chain of calls moves the whole chain one level right. The deep `replace(...)` chain in `complex_select_0` / `complex_select_part_2` shows this, and it is the most visible change.
- A few casts inside `extract(`, `tuplesort`, `window`, `arrays` and `plpgsql` move the same way.

New `cast_breaks_like_a_function_call` test in `width_layout.rs`. All `pgls_pretty_print` tests and the option matrix (fixtures and regression suite) pass, and clippy is clean.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant