Skip to content

Fix inconsistent AST formatting of an operator in the ELSE branch of viewIfPermitted - #113652

Merged
alexey-milovidov merged 8 commits into
masterfrom
fix-view-if-permitted-else-operator-formatting
Aug 10, 2026
Merged

Fix inconsistent AST formatting of an operator in the ELSE branch of viewIfPermitted#113652
alexey-milovidov merged 8 commits into
masterfrom
fix-view-if-permitted-else-operator-formatting

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Aug 6, 2026

Copy link
Copy Markdown
Member

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fix inconsistent AST formatting of viewIfPermitted: a function normally written as an operator (e.g. not) in the ELSE branch of the table function form, and the expression form viewIfPermitted(...) being wrongly formatted with ELSE.

The parser accepts only a function call after ELSE in the table function viewIfPermitted (the argument is a table function such as null('structure')), but the formatter used the generic expression path for it, so not(...) was formatted as ELSE NOT x, which cannot be parsed back. On debug builds this failed the AST formatting consistency check with a logical error ("Inconsistent AST formatting").

Found by AST fuzzer in an unrelated PR: report.

The fix formats the ELSE argument with allow_operators = false, keeping the function-call form:

SELECT * FROM viewIfPermitted(SELECT 1 ELSE not(isNull(1)))

now round-trips instead of producing ELSE NOT isNull(1).

Two follow-ups from the review:

  • The ELSE form is now printed only for the exact table function shape (a bare select query followed by a function call, no parameters). In an expression context viewIfPermitted parses as an ordinary function, and SELECT viewIfPermitted(1, 2) was formatted as ... ELSE 2, which also cannot be parsed back (the same consistency logical error on debug builds). Such shapes now take the generic function-call path.
  • ASTFunction::readJSON rejects a clickhouse_json payload where viewIfPermitted contains a bare select query argument in any shape other than the table function one, which the parser cannot produce and which would format to unparseable text.
  • Both the readJSON shape check and the ELSE formatter branch match the name case-insensitively, since the parser dispatches to the table function parser on the lowercased name: a JSON payload spelling the name e.g. VIEWIFPERMITTED obeys the same shape rules and keeps the (only parseable) ELSE form.
  • For the table function shape, readJSON also canonicalizes the name to viewIfPermitted, the same way the SQL parser does (ViewLayer dispatches on the lowercased name but always produces the canonical spelling). clickhouse_json executes the deserialized AST directly, without an SQL round trip, and execution matches the name case-sensitively, so a non-canonical spelling previously passed deserialization but failed at execution.
  • The same JSON boundary rule is applied to the sibling view table function (ViewLayer produces both): readJSON rejects a view payload containing a bare select query in any shape other than the parser-produced one (a single bare select, no parameters — the query-argument formatting path would otherwise print unparseable text or silently drop parameters) and canonicalizes a non-canonical spelling of the name for that shape, since execution matches view case-sensitively.

Related: #112921

…viewIfPermitted

The parser accepts only a function call after ELSE in `viewIfPermitted`
(the argument is a table function such as `null('structure')`), but the
formatter used the generic expression path for it, so a function that is
normally written as an operator (e.g. `not`) was formatted as `ELSE NOT x`,
which cannot be parsed back. Found by AST fuzzer: the query
`... viewIfPermitted(SELECT ... ELSE not(...)) ...` failed the AST
formatting consistency check with a logical error.

Format the ELSE argument with `allow_operators = false` so it keeps the
function-call form.

Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112921&sha=22a3a1e446758ffbb12704d86470c4c27a4e73a2&name_0=PR&name_1=AST%20fuzzer%20%28amd_debug%29
Found in: #112921

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [5e124e8]

Summary:


AI Review

Summary

This PR fixes the original viewIfPermitted formatter bug and closes the direct JSON-boundary holes that came up in the earlier review, including the sibling view table function. The remaining issue is that the new hardening only looks at direct argument children, so malformed clickhouse_json can still hide a bare SelectWithUnionQuery one level deeper and reach the same unparseable-output class the change is trying to rule out.

Findings

⚠️ Majors

  • [src/Parsers/ASTFunction.cpp:278] The new has_bare_select guard only inspects direct arguments children. A payload such as view(tuple(SELECT 1)) or viewIfPermitted(tuple(SELECT 1), null('x UInt8')) still passes readJSON, and the nested function then formats through the generic tryGetQueryArgument path as tuple(SELECT 1), which the SQL parser cannot parse back. The JSON boundary therefore still accepts parser-impossible bare-select shapes and can still emit unparseable SQL.
    Suggested fix: recurse when searching for bare SelectWithUnionQuery nodes, or tighten the generic query-argument formatter so only the exact parser-produced carriers may print bare query arguments; add a regression with a nested bare-select payload.
Final Verdict

Request changes: the direct-child checks fix the reported cases, but the malformed-input class is still open for nested bare SelectWithUnionQuery nodes.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.60% 86.60% +0.00%
Functions 91.90% 91.80% -0.10%
Branches 78.90% 78.80% -0.10%

Changed lines: Changed C/C++ lines covered: 35/35 (100.00%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 6, 2026
Comment thread src/Parsers/ASTFunction.cpp
@clickhouse-gh

clickhouse-gh Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 5e124e841 with master 9b6a2d734 (stripped binary size, per-symbol sizes and ThinLTO time; object sizes against the warmup build of 5bf49d370; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 690.65 MiB 687.63 MiB -3.02 MiB (-0.44%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Compile time of recompiled translation units

20 translation units recompiled, 35 s compile time in total, 8 of them have a recent master baseline.

Translation units without a recent master baseline:

  • contrib/hive-metastore-cmake/__/hive-metastore/ThriftHiveMetastore.cpp: 10.9 s
  • contrib/hive-metastore-cmake/__/hive-metastore/hive_metastore_types.cpp: 8.5 s

Job report

… and validate it at the JSON boundary

Address the review: the ELSE form is parseable only by the table function
parser, and the parser produces it with exactly the shape (bare select query,
function call) and no parameters. Two holes remained:

- In an expression context viewIfPermitted parses as an ordinary function
  (e.g. `viewIfPermitted(1, 2)`), but the formatter printed the ELSE form for
  it by name and argument count alone, producing text that cannot be parsed
  back (an exception in debug builds via the AST formatting consistency
  check). Now the ELSE form is printed only for the exact table function
  shape; other shapes take the generic function path, which round-trips.

- A malformed `clickhouse_json` payload could put a bare select query into
  `viewIfPermitted` in shapes the parser cannot produce. `ASTFunction::readJSON`
  now rejects, with `BAD_ARGUMENTS`, any argument list that contains a bare
  select but is not exactly the (select, function) table function shape
  without parameters. The exact shape check suggested in the review (always
  require select + function) would wrongly reject the parser-producible
  expression form, so the check is anchored on the bare select argument
  instead.

Adds `04824_view_if_permitted_ast_json_shape` for the JSON boundary and
extends `04812_view_if_permitted_else_operator_formatting` with the
expression-context round-trip.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 The two red checks on e2932f92 are unrelated to this formatting change:

alexey-milovidov and others added 2 commits August 8, 2026 02:39
`allow_experimental_json_ast_dialect` was renamed to `enable_json_ast_dialect`
on master in #113480, which made
`04824_view_if_permitted_ast_json_shape` fail in Fast test with `UNKNOWN_SETTING`:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=113652&sha=6c70546c068d076e283252a282515ee19921bdc5&name_0=PR&name_1=Fast%20test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Parsers/ASTFunction.cpp Outdated
…N guard and the `ELSE` formatter

The parser dispatches to the table function parser on the lowercased name,
so a handcrafted `clickhouse_json` payload spelling the name e.g.
`VIEWIFPERMITTED` with a bare select could bypass the `readJSON` shape check
and take the generic formatting path, producing SQL that cannot be parsed
back. Both checks now use `equalsCaseInsensitive`, and the test covers a
non-canonical spelling for both the table function shape (kept in the `ELSE`
form, parses back) and a parser-impossible shape (rejected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 The one red check on 3fb24e50 is unrelated to this formatting change: Integration tests (amd_msan, 4/8) failed in test_keeper_dynamic_log_level/test.py::test_adjust_log_level, a known flake (the test asserts a log line before the dynamically lowered log level takes effect). Fixes are in progress: #113571 and #113938. Report

Comment thread src/Parsers/ASTFunction.cpp Outdated
…ON deserialization

`clickhouse_json` executes the deserialized AST directly, without formatting it
back to SQL and reparsing, so accepting a non-canonical spelling such as
`VIEWIFPERMITTED` in `ASTFunction::readJSON` left a broken execution path:
the table function factory and `StorageView::replaceWithSubquery` match the
name case-sensitively, so a valid (select, function) payload passed
deserialization but failed at execution. Canonicalize the name for the table
function shape the same way the SQL parser does (`ViewLayer` dispatches on
the lowercased name but always produces `viewIfPermitted`).

Adds an execution-level `clickhouse_json` regression test
`04825_view_if_permitted_json_execution` and updates
`04824_view_if_permitted_ast_json_shape` for the canonicalized output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Parsers/ASTFunction.cpp Outdated
…able function

`ViewLayer` is shared by `view` and `viewIfPermitted`, so `ASTFunction::readJSON` now also
rejects parser-impossible `view` shapes that contain a bare select (extra arguments, a bare
select in the wrong position, or parameters, which the query-argument formatting path would
silently drop) and canonicalizes a non-canonical spelling of the name for the table function
shape, the same way the parser does, because execution matches the name case-sensitively
(e.g. `StorageView::replaceWithSubquery` and the table function factory).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Both remaining CI failures on d23cb2c are unrelated to this PR:

bool is_view_if_permitted = equalsCaseInsensitive(name, "viewIfPermitted");
if ((is_view || is_view_if_permitted) && arguments)
{
bool has_bare_select = std::ranges::any_of(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only inspects direct arguments children, so a nested bare SelectWithUnionQuery still slips through. For example, a handcrafted clickhouse_json payload for view(tuple(SELECT 1)) (or viewIfPermitted(tuple(SELECT 1), null('x UInt8'))) leaves has_bare_select == false here, but the nested tuple later takes the generic tryGetQueryArgument formatter and emits tuple(SELECT 1), which the SQL parser cannot read back. So the new boundary still accepts parser-impossible bare-select shapes and can still format them to unparseable SQL. This check needs to recurse through the subtree, or the generic query-argument formatter needs a stricter type guard, and the regression should cover a nested bare-select payload.

@alexey-milovidov alexey-milovidov left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok.

@alexey-milovidov alexey-milovidov self-assigned this Aug 9, 2026
@alexey-milovidov
alexey-milovidov added this pull request to the merge queue Aug 9, 2026
Merged via the queue into master with commit 5397855 Aug 10, 2026
355 of 356 checks passed
@alexey-milovidov
alexey-milovidov deleted the fix-view-if-permitted-else-operator-formatting branch August 10, 2026 00:29
@robot-ch-test-poll4 robot-ch-test-poll4 added the pr-synced-to-cloud The PR is synced to the cloud repo label Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-bugfix Pull request with bugfix, not backported by default pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants