Skip to content

fix(pretty_print): keep = ( together for scalar subqueries - #896

Merged
psteinroe merged 4 commits into
mainfrom
fix/pretty-print-scalar-subquery
Oct 26, 2026
Merged

psteinroe merged 4 commits into
mainfrom
fix/pretty-print-scalar-subquery

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #894.

Follow-up to #864 for the comparison case, plus a nested-subquery case from #857.

= (subquery) stays together

#864 kept exists (, in (, = any ( and > all ( on one line, but a scalar subquery compared with an operator still broke between the operator and (. With the config reported in #857 (layout: expanded, clauseBodyStyle: compact, logicalOperatorPlacement: leading):

-- before
using (
  org_id =
  (
    select org_id
    ...
-- after
using (
  org_id = (
    select org_id
    from members
    where user_id = auth.uid()
  )
);

The default config did the same, t.owner_id = followed by (select …) on its own line. A binary operator now puts a plain space before a right-hand (subquery) or array(subquery), and the subquery breaks inside its parentheses as it does after in (. Other right-hand operands still break after the operator when they don't fit.

Subqueries with clauses expand inside a select-list-only subquery

Since #857, a subquery made of a select list alone, such as (select auth.uid()), uses fit layout in expanded mode, because there are no clauses to break between. That fit layout applied to everything inside it, so a subquery with FROM/WHERE nested in it stayed on one line in expanded mode:

-- layout: expanded, clauseBodyStyle: compact
select * from t where a = (select coalesce((select max(x) from y where y.id = t.id), 0));

The emitter now remembers the configured layout, and a nested subquery with clauses switches back to it, so the inner select max(x) / from y / where ... expands again.

Snapshot changes

All reviewed; one category: x =\n( / x <>\n( / x <\n( / <>\narray( → x = (, x <> (, x < (, <> array(. When the subquery is short enough to fit on the next line, it now sits indented inside the parentheses instead (relnamespace = (\n select oid from pg_namespace where ...\n)). Affected: create_cast, create_index, create_view, namespace, opr_sanity, rangefuncs, reloptions, tablespace, type_sanity, with.

New fixtures scalar_subquery.sql and scalar_subquery_expanded.sql (the #857 reporter's config) cover a policy, = with a multi-clause subquery, = array(subquery), the nested-coalesce case and a short = (select 1). New subquery_layout.rs tests cover both findings.

Not covered

With clauseBodyStyle: compact, a WHERE condition keeps its indent for its own continuation lines, so a subquery that opens on the where line is indented two levels and closes one level in:

where a = (
    select ...
  );

That is the same for where exists ( today and is a separate change to the compact layout.

Follow-ups: The compact WHERE indentation is fixed in #938.

@psteinroe
psteinroe changed the base branch from fix/pretty-print-case-alignment to main October 26, 2026 07:53
@psteinroe
psteinroe merged commit ec46160 into main Oct 26, 2026
11 checks passed
@psteinroe
psteinroe deleted the fix/pretty-print-scalar-subquery branch October 26, 2026 07:58
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 added a commit that referenced this pull request Oct 26, 2026
…der the keyword (#938)

Stacked on #935.

Follow-up to #896, which listed this as not covered. A condition that stays on its keyword line (WHERE and HAVING with `clauseBodyStyle: compact`, and ON in every config) was indented for its continuation lines. When the condition only breaks inside the parentheses of a subquery, it has no continuation lines of its own. The indent then put the subquery two levels in and the closing parenthesis one level in, under nothing:

```sql
-- layout: expanded, clauseBodyStyle: compact
-- before
select *
from public.permissions
where exists (
    select 1
    from public.permission_set_permissions as rp
    where rp.permission_name = permissions.name
  );
-- after
select *
from public.permissions
where exists (
  select 1
  from public.permission_set_permissions as rp
  where rp.permission_name = permissions.name
);
```

The same happened to `on exists (` in a join with the default config.

**What changed**

`emit_clause_condition` (compact) and the ON of a join leave out the continuation indent when the condition breaks only inside a subquery. That covers:

- a subquery link: `exists (...)`, `x in (...)`, `x = any (...)`, `(...)`, `array(...)`;
- `x <op> (subquery)` or `x <op> array(subquery)`, where `x` is a column, parameter or constant;
- `not` in front of either.

Everything else keeps the indent, including AND/OR chains, whose operands still continue one level in, with a subquery in them closing at that level.

**Snapshot changes**

All reviewed; one category: the body and `)` of a subquery opened on a compact `where`/`having` line, or on an `on` line, move one level left, so `)` closes under the keyword. This is in `compact_select_list*`, `scalar_subquery_expanded`, `sublink_bool_groups_expanded` and, for ON with the default config, `predicate`.

New tests in `subquery_layout.rs` cover `where exists (`, `where x = (`, a chain with an `exists` operand, and `on x = (`.

**Not covered**

A lone select item in compact layout has the same pattern: `select coalesce(` indents its arguments two levels and closes one level in. That is the select-list indent from #865 and would need the same rule there.
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