FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Fix LOGICAL_ERROR `Incorrect table expression` in `EXPLAIN` over table function `eval` by groeneai · Pull Request #121272 · ClickHouse/ClickHouse · GitHub

Fix LOGICAL_ERROR Incorrect table expression in EXPLAIN over table function eval - #121272

Open
groeneai wants to merge 1 commit into
ClickHouse:masterfrom
groeneai:fix-eval-table-function-explain-ast-logical-error
Open

groeneai wants to merge 1 commit into
ClickHouse:masterfrom
groeneai:fix-eval-table-function-explain-ast-logical-error

Conversation

groeneai commented Sep 21, 2026
edited by clickhouse-gh Bot
Loading

Copy link
Copy Markdown
Collaborator

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

Fixed LOGICAL_ERROR: Incorrect table expression when EXPLAIN AST optimize = 1 or EXPLAIN SYNTAX reads from the experimental table function eval.

Description

EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') failed with
LOGICAL_ERROR: Incorrect table expression: a SIGABRT in a debug or sanitizer build, an internal
error returned to the client in a release build. Reachable with
allow_experimental_eval_table_function = 1 on 26.7, 26.8 and 26.9.

TableFunctionEval::executeImpl wraps the generated query in a StorageView, as the view table
function does, so InterpreterSelectQuery calls StorageView::replaceWithSubquery. There the first
table expression has no database_and_table_name, the source being a table function, so a fake
db.table name must be synthesized from the function name. The list of view-producing names held
only view, viewIfPermitted and merge, so eval got none and the function threw.
fbb2e14d543f4 closed the same one-line gap for viewIfPermitted. The AST-based arms of
EXPLAIN AST optimize = 1 and EXPLAIN SYNTAX build that interpreter directly, which is how an
ordinary query reaches it.

Found by the AST fuzzer on master while it was running 04512_eval_table_function.sql, CIDB
test_name Logical error: Incorrect table expression (STID: 3510-55f2), first occurrence in 180
days: Stress test (arm_debug) on sha a1cfa117f551,
report
and job.
No open issue exists.

Validated in both directions with a deterministic repro whose stack matches CI's frame for frame,
100/100 runs of the new test with and without settings randomization, and an A/B over 259 existing
view / parameterized-view / EXPLAIN tests with identical failure sets.

After the rewrite EXPLAIN SYNTAX still prints FROM eval(...), as it already does for view.


Workflow [PR]
Sync PR [sync-upstream/pr/121272]

`TableFunctionEval::executeImpl` wraps the generated query in a `StorageView`, the same
way the `view` table function does. `InterpreterSelectQuery` therefore resolves a
`StorageView` and its `analyze` lambda calls `StorageView::replaceWithSubquery`. There the
first table expression has no `database_and_table_name`, because the source is a table
function, so a fake `db.table` name has to be synthesized from the table function name.
The hard-coded list of names that resolve to a view knew only `view`, `viewIfPermitted`
and `merge`, so for `eval` no name was produced and the function threw
`LOGICAL_ERROR: Incorrect table expression`. In a debug or sanitizer build
`abortOnFailedAssertion` turns that into SIGABRT; in a release build it is returned to the
client, so a valid `EXPLAIN` is rejected with an internal error.

The AST-based arms of `EXPLAIN AST optimize = 1` and `EXPLAIN SYNTAX` build that
interpreter directly through `ExplainAnalyzedSyntaxVisitor`, so
`EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x')` was enough to reach it.
Found by the AST fuzzer in `Stress test (arm_debug)` on master while it was running
04512_eval_table_function.sql.

`viewIfPermitted` was added to the same list in the same shape by fbb2e14 when that
table function was introduced. `eval` (7019b6d) did not get the same treatment.

The parser lowercases a table function name before choosing the layer and `EvalLayer`
emits the canonical `eval`, so the exact comparison already covers every spelling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
groeneai added can be tested Allows running workflows for external contributors groeneai-origin-ci-master PR origin: master/nightly CI monitoring finding labels Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author
Internal second-model review: adjudication log (click to expand)

Pre-publication review by an independent model (engine: codex; 0 findings), plus my own cold
review of the resulting code before reading the author's evidence.

# Sev Finding Verdict Evidence / action
1 💡 Independent gate: 0 findings over all three changed files, both replaceWithSubquery callers, the EXPLAIN / parser / table-function sibling paths and the existing eval/view tests n/a nothing to adjudicate
2 💡 Four PR-body and changelog nits (missing CIDB STID in the provenance, one non-ASCII separator, an implementation-internal second sentence in the changelog entry, the allow_experimental_eval_table_function precondition unstated) AGREE, fixed before publication all four in the description only; no source or test change
3 ⚠️ Should the name list be replaced with a name-agnostic default, so the next view-producing table function cannot reproduce this? DISAGREE the carrier set is closed: the std::make_shared<StorageView> sites reduce to view, viewIfPermitted and eval, nothing derives from StorageView, and InterpreterSelectQuery.cpp:821-823 gates the call on dynamic_cast<StorageView *>. A total else would also shadow the parameterized_view arm at :661-662, where the function name deliberately IS the view name
4 ⚠️ eval now matches the __view arm ahead of else if (parameterized_view), where it used to fall. Does that change any behaviour? DISAGREE unreachable by construction. StorageView.cpp:396 sets is_parameterized_view = is_parameterized_view_ || query.isParameterizedView(); the first disjunct is false because the 4-argument constructor is used, and the second requires is_ordinary_view, declared bool is_ordinary_view : 1 = false and never set by any of the three table functions
5 ⚠️ Is the exact == "eval" comparison complete, or can a non-canonical spelling still reach the throw? DISAGREE complete. getFunctionLayer dispatches on the lowercased name and EvalLayer::getResultImpl emits makeASTFunction("eval", ...), so the parser only produces the canonical spelling; the one parser-bypassing producer, AST-JSON deserialization, raises UNKNOWN_FUNCTION at storage resolution because eval is registered with the Case::Sensitive default. Test case 4 pins the premise with EVAL(...)
6 💡 Can the EXPLAIN SYNTAX reference line flip shape under settings randomization? DISAGREE no. explain_syntax_single_record defaults to true and is absent from the test runner's randomizer, and the documented way to restore the pre-26.8 one-record-per-line output is a compatibility older than 26.8, which is not randomized either

Severity: ❌ blocker / ⚠️ major / 💡 nit. DISAGREE verdicts carry recorded evidence and are
terminal per finding. Findings on hunks unchanged by the fix round are auto-dropped.

Session id: cron:clickhouse-review-slot-10:20260921-044300

clickhouse-gh Bot closed this Sep 21, 2026
clickhouse-gh Bot reopened this Sep 21, 2026

clickhouse-gh Bot commented Sep 21, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [6d36d98]


AI Review

Summary

This PR adds the missing eval branch in StorageView::replaceWithSubquery, which is the legacy rewrite path used by EXPLAIN AST optimize = 1 and EXPLAIN SYNTAX for StorageView-backed sources. I traced that path through both InterpreterSelectQuery and InterpreterExplainQuery, checked the current StorageView-producing table functions, and did not find any remaining correctness or coverage gaps in the patch. The new stateless test exercises the failing eval cases and keeps a view control case in the same path.

Final Verdict
  • Status: ✅ Approve

clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Sep 21, 2026
clickhouse-gh Bot added the comp-view Logical (non-materialized) views. label Sep 21, 2026

clickhouse-gh Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 6d36d98f2 with master d14841253 (stripped binary size, per-symbol sizes and ThinLTO time; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes

programs/clickhouse-stripped: smaller than the master baseline by the known offset between the two builds, so the difference is not shown. A delta that differs from the offset by more than 50% of it is shown, in either direction.

The official master build is compiled with -g and a pull request build is not, and XRay counts debug instructions towards its instrumentation threshold, so master instruments thousands of functions more and its binary is ~0.4% larger no matter what the pull request does.

Compile time of recompiled translation units

7 translation units recompiled, 10 s compile time in total, 7 of them have a recent master baseline.

Job report

PedroTadim self-assigned this Sep 21, 2026
SELECT
countIf(explain ILIKE '%Function eval%') AS table_function_node_left,
countIf(explain ILIKE '%Identifier x%') AS asterisk_resolved
FROM (EXPLAIN AST optimize = 1 SELECT * FROM EVAL('SELECT 5 AS x'));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

@groeneai can you confirm no other issues happen with the eval function for every other explain statement, or sql statement?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Confirmed, and measured: 80 eval shapes on two binaries built from the same tree with only this line differing, one process per shape, debug builds, so a LOGICAL_ERROR aborts and cannot be missed. Coverage is all 10 ASTExplainQuery::ExplainKind values with their option flags, both AST based arms over non-SELECT and nested tops, and 21 statements with no EXPLAIN at all (full list below).

  • On master 27 of the 80 abort, every one with the same Logical error: 'Incorrect table expression' thrown at StorageView.cpp:666 in replaceWithSubquery. With this PR: zero, no logical error in any shape.
  • Only two arms reach that code at all: EXPLAIN AST optimize = 1 (any graph), and EXPLAIN SYNTAX when the top level is not a plain SELECT (measured: INSERT and CREATE TABLE ... AS SELECT). Plain EXPLAIN SYNTAX SELECT, the other eight kinds and every statement without EXPLAIN never reach it, on either binary.
  • No second error class turned up. Every non-OK result is identical on both binaries and is a deliberate check: eval in an expression context Code: 46, eval inside eval Code: 36, eval as an argument of remote/cluster Code: 1, materialized view from a table function Code: 397, PREWHERE on a View Code: 182, EXPLAIN WHATIF wants a MergeTree table Code: 48, EXPLAIN TABLE OVERRIDE eval(...) Code: 80, the setting off Code: 344. EXPLAIN SYNTAX of CREATE MATERIALIZED VIEW or of ALTER is Code: 62 from the EXPLAIN parser, with or without eval (checked both).
  • The change is inert outside the throwing path: of the shapes that already worked, stdout is byte identical on 49 of the 50 I diffed, and the exception is EXPLAIN ANALYZE, whose output carries timings and differs the same way between two runs of the same binary.
  • The other places that hard code this name list need nothing for eval: QueryAnalyzer has such a list for view and merge as an argument of another table function, which eval can never be (Code: 1), and ASTFunction::appendColumnNameImpl lists the table functions that cannot be used as an expression, which eval reaches as an unknown function first (Code: 46).
coverage, and the 27 shapes that abort on master

EXPLAIN kinds, each with its option flags (optimize, graph, oneline, run_query_tree_passes, run_passes, json, indexes, actions, header, compact): AST, SYNTAX, QUERY TREE, PLAN, PIPELINE, ESTIMATE, ANALYZE, WHATIF, TABLE OVERRIDE, CURRENT TRANSACTION.

AST based arms over: INSERT, INSERT INTO FUNCTION, CREATE TABLE AS SELECT, CREATE VIEW AS SELECT, CREATE MATERIALIZED VIEW AS SELECT, UNION, CTE, IN, scalar subquery, subquery, JOIN with eval on either or both sides, comma join, DISTINCT with GROUP BY and ORDER BY, FINAL, SAMPLE, PREWHERE, ARRAY JOIN, eval nested in view / viewIfPermitted / remote, eval inside eval, eval((SELECT ...)), query level SETTINGS, and the fuzzer query from the CI fatal log. Controls: the same shapes with view, merge and numbers.

Without EXPLAIN: SELECT, count, ordered multi row read, JOIN, IN, CTE, subquery, INSERT SELECT, CREATE TABLE AS SELECT, CREATE VIEW then SELECT from it, CREATE MATERIALIZED VIEW, DESCRIBE, remote, cluster, eval nested in view and viewIfPermitted, expression context, FINAL, PREWHERE, and the experimental setting turned off.

Aborting on master, passing with this PR:

EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x');
EXPLAIN AST graph = 1, optimize = 1 SELECT * FROM eval('SELECT 5 AS x');
EXPLAIN AST graph = 1, optimize = 1 SELECT DISTINCT * FROM eval((SELECT toLowCardinality('SELECT 5 AS lc') LIMIT 539));
EXPLAIN AST optimize = 1 INSERT INTO t SELECT * FROM eval('SELECT 5 AS x');
EXPLAIN SYNTAX INSERT INTO t SELECT * FROM eval('SELECT 5 AS x');
EXPLAIN AST optimize = 1 CREATE TABLE t ENGINE = Memory AS SELECT * FROM eval('SELECT 5 AS x');
EXPLAIN SYNTAX CREATE TABLE t ENGINE = Memory AS SELECT * FROM eval('SELECT 5 AS x');
EXPLAIN AST optimize = 1 CREATE VIEW v AS SELECT * FROM eval('SELECT 5 AS x');
EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') UNION ALL SELECT 6 AS x;
EXPLAIN AST optimize = 1 SELECT * FROM (SELECT * FROM eval('SELECT 5 AS x'));
EXPLAIN AST optimize = 1 WITH c AS (SELECT * FROM eval('SELECT 5 AS x')) SELECT * FROM c;
EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') WHERE x IN (SELECT * FROM eval('SELECT 5 AS y'));
EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') AS a JOIN numbers(1) AS n ON 1;
EXPLAIN AST optimize = 1 SELECT * FROM numbers(1) AS n JOIN eval('SELECT 5 AS x') AS a ON 1;
EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') AS a JOIN eval('SELECT 5 AS x') AS b USING (x);
EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') AS a, eval('SELECT 6 AS y') AS b;
EXPLAIN AST optimize = 1 SELECT DISTINCT x FROM eval('SELECT 5 AS x') GROUP BY x ORDER BY x LIMIT 1;
EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') FINAL;
EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') SAMPLE 1 / 2;
EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') PREWHERE x > 1;
EXPLAIN AST optimize = 1 SELECT a FROM eval('SELECT [1, 2] AS arr') ARRAY JOIN arr AS a;
EXPLAIN AST optimize = 1 SELECT * FROM eval((SELECT 'SELECT 5 AS x'));
EXPLAIN AST optimize = 1 CREATE MATERIALIZED VIEW mv ENGINE = Memory AS SELECT * FROM eval('SELECT 5 AS x');
EXPLAIN AST optimize = 1 SELECT (SELECT count() FROM eval('SELECT 5 AS x'));
EXPLAIN AST optimize = 1 INSERT INTO FUNCTION null('x UInt8') SELECT * FROM eval('SELECT 5 AS x');
SELECT count() FROM (EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x'));
EXPLAIN AST optimize = 1 SELECT * FROM eval('SELECT 5 AS x') SETTINGS max_threads = 1;

Tell me if you want any of these pinned in the test, I kept it to five cases for a one line fix.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

@groeneai Kept it to five cases for a one line fix.

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

Labels

can be tested Allows running workflows for external contributors comp-view Logical (non-materialized) views. groeneai-origin-ci-master PR origin: master/nightly CI monitoring finding pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants


Back | FazBrowse Home | New Git URL