Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6f91aa6
Allow JOIN filter pushdown when side column names do not match the jo…
ianton-ru Aug 21, 2026
af87b90
Copy left-only WHERE into IStorageCluster JOIN wraps so icebergCluste…
ianton-ru Aug 21, 2026
4e1b754
Reuse existing left-only predicate helper for IStorageCluster JOIN wraps
ianton-ru Aug 21, 2026
a54a78a
Do not copy wrap predicates onto the null-producing side of an outer …
ianton-ru Aug 24, 2026
dc2f772
Do not copy nondeterministic wrap predicates that would run twice
ianton-ru Aug 24, 2026
78a27b3
Remove unnecessary `no-parallel-replicas` tag from JOIN filter pushdo…
ianton-ru Aug 24, 2026
28b3de8
Do not copy wrap predicates onto `ASOF` right or `PASTE` JOIN sides
ianton-ru Aug 24, 2026
0e14162
Do not copy stateful wrap predicates that would run twice
ianton-ru Aug 24, 2026
707dcdc
Share JOIN prefilter side rules between wrap copy and filter pushdown
ianton-ru Aug 24, 2026
2018169
Pin JOIN filter pushdown EXPLAIN test against parallel replicas and r…
ianton-ru Aug 24, 2026
a1d4502
Drop non-function wrap predicates that depend on the other JOIN side
ianton-ru Aug 24, 2026
cdc4b28
Keep equivalent-key JOIN filter pushdown separate from side prefilter
ianton-ru Aug 24, 2026
d9ebb0b
Rebuild `IStorageCluster` listing when a later `applyFilters` predica…
ianton-ru Aug 24, 2026
cf210cb
Do not copy server-constant wrap predicates such as `hostName`
ianton-ru Aug 24, 2026
18a667b
Do not copy node-local wrap predicates such as `dictGet`
ianton-ru Aug 25, 2026
523f4a0
Merge branch 'antalya-26.6' into fix/join-filter-pushdown-through-rename
ianton-ru Aug 25, 2026
f9203a7
Fixes after review
ianton-ru Sep 2, 2026
f459b98
List `IStorageCluster` files once from the first listing filter
ianton-ru Sep 2, 2026
a5e8776
Hide object-storage credentials in `ReadFromCluster` EXPLAIN output
ianton-ru Sep 3, 2026
cc31f79
Remap JOIN-output aliases before partial filter pushdown
ianton-ru Sep 3, 2026
6a44388
AND later cluster listing filters onto the wrap predicate
ianton-ru Sep 3, 2026
9925af7
Attach cluster wrap listing filters only to `ReadFromCluster`
ianton-ru Sep 3, 2026
59af131
Merge branch 'antalya-26.6' into fix/join-filter-pushdown-through-rename
ianton-ru Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 69 additions & 10 deletions src/Analyzer/Utils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@

#include <Core/Streaming/CursorTree_fwd.h>

#include <functional>
#include <ranges>

namespace DB
Expand Down Expand Up @@ -1168,9 +1169,47 @@ bool hasUnknownColumn(const QueryTreeNodePtr & node, QueryTreeNodePtr table_expr
return false;
}

void removeExpressionsThatDoNotDependOnTableIdentifiers(
namespace
{

bool isDeterministicInScopeOfQueryTree(const QueryTreeNodePtr & node)
{
QueryTreeNodes stack = {node};
while (!stack.empty())
{
auto current = std::move(stack.back());
stack.pop_back();
if (!current)
continue;

const auto type = current->getNodeType();
if (type == QueryTreeNodeType::QUERY || type == QueryTreeNodeType::UNION)
return false;

if (const auto * function = current->as<FunctionNode>())
{
if (function->isWindowFunction() || function->isAggregateFunction())
return false;
if (function->isOrdinaryFunction())

@k-morozov k-morozov Sep 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What we should to do if function not isOrdinaryFunction ? Just ignoring?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Window and aggregate functions are checked above, so here can be only ordinary or unresolved functions.
But all functions must be resolved when this method is called.
I rewrite test to be more clean, without logic change.

{
auto function_base = function->getFunction();
if (!function_base || !function_base->isDeterministicInScopeOfQuery())
return false;
}
}

for (const auto & child : current->getChildren())
{
if (child)
stack.push_back(child);
}
}
return true;
}

void filterConjunctions(
QueryTreeNodePtr & expression,
const QueryTreeNodePtr & table_expression,
const std::function<bool(const QueryTreeNodePtr &)> & keep,
const ContextPtr & context)
{
auto * function = expression->as<FunctionNode>();
Expand All @@ -1179,13 +1218,13 @@ void removeExpressionsThatDoNotDependOnTableIdentifiers(

if (function->getFunctionName() != "and")
{
if (hasUnknownColumn(expression, table_expression))
expression = nullptr;
if (!keep(expression))
expression = {};
return;
}

QueryTreeNodesDeque conjunctions;
QueryTreeNodesDeque processing{ expression };
QueryTreeNodesDeque processing{expression};

while (!processing.empty())
{
Expand All @@ -1195,10 +1234,7 @@ void removeExpressionsThatDoNotDependOnTableIdentifiers(
if (auto * function_node = node->as<FunctionNode>())
{
if (function_node->getFunctionName() == "and")
std::ranges::copy(
function_node->getArguments(),
std::back_inserter(processing)
);
std::ranges::copy(function_node->getArguments(), std::back_inserter(processing));
else
conjunctions.push_back(node);
}
Expand All @@ -1212,7 +1248,7 @@ void removeExpressionsThatDoNotDependOnTableIdentifiers(

for (const auto & node : processing)
{
if (!hasUnknownColumn(node, table_expression))
if (keep(node))
conjunctions.push_back(node);
}

Expand All @@ -1234,6 +1270,29 @@ void removeExpressionsThatDoNotDependOnTableIdentifiers(
function->resolveAsFunction(function_impl->build(function->getArgumentColumns()));
}

}

void removeExpressionsThatDoNotDependOnTableIdentifiers(
QueryTreeNodePtr & expression,
const QueryTreeNodePtr & table_expression,
const ContextPtr & context)
{
filterConjunctions(
expression,
[&](const QueryTreeNodePtr & node) { return !hasUnknownColumn(node, table_expression); },
context);
}

void removeExpressionsThatAreNotDeterministicInScopeOfQuery(
QueryTreeNodePtr & expression,
const ContextPtr & context)
{
if (!expression)
return;

filterConjunctions(expression, isDeterministicInScopeOfQueryTree, context);
}

namespace
{

Expand Down
6 changes: 6 additions & 0 deletions src/Analyzer/Utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ void removeExpressionsThatDoNotDependOnTableIdentifiers(
const QueryTreeNodePtr & replacement_table_expression,
const ContextPtr & context);

/** Remove conjuncts that are not deterministic in the current query (`rand`, and similar).
* Nested `and` is flattened the same way as `removeExpressionsThatDoNotDependOnTableIdentifiers`.
*/
void removeExpressionsThatAreNotDeterministicInScopeOfQuery(
QueryTreeNodePtr & expression,
const ContextPtr & context);

Field getFieldFromColumnForASTLiteral(const ColumnPtr & column, size_t row, const DataTypePtr & data_type);

Expand Down
10 changes: 10 additions & 0 deletions src/Planner/Planner.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ void checkStoragesSupportTransactions(const PlannerContextPtr & planner_context)
}
}

}

namespace
{

/** Storages can rely that filters that for storage will be available for analysis before
* getQueryProcessingStage method will be called.
*
Expand Down Expand Up @@ -390,6 +395,8 @@ FiltersForTableExpressionMap collectFiltersForAnalysis(const QueryTreeNodePtr &
return res;
}

}

FiltersForTableExpressionMap collectFiltersForAnalysis(const QueryTreeNodePtr & query_tree_node, const SelectQueryOptions & select_query_options, const ActionsDAG * post_filter)
{
if (select_query_options.only_analyze)
Expand All @@ -411,6 +418,9 @@ FiltersForTableExpressionMap collectFiltersForAnalysis(const QueryTreeNodePtr &
return collectFiltersForAnalysis(query_tree_node, table_expressions_nodes, context, post_filter);
}

namespace
{

/// Extend lifetime of query context, storages, and table locks
void extendQueryContextAndStoragesLifetime(QueryPlan & query_plan, const PlannerContextPtr & planner_context)
{
Expand Down
6 changes: 6 additions & 0 deletions src/Planner/Planner.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#include <Processors/QueryPlan/QueryPlan.h>
#include <Storages/SelectQueryInfo.h>
#include <Planner/PlannerContext.h>

namespace DB
{
Expand Down Expand Up @@ -89,4 +90,9 @@ class Planner
QueryNodeToPlanStepMapping query_node_to_plan_step_mapping;
};

FiltersForTableExpressionMap collectFiltersForAnalysis(
const QueryTreeNodePtr & query_tree_node,
const SelectQueryOptions & select_query_options,
const ActionsDAG * post_filter);

}
130 changes: 127 additions & 3 deletions src/Planner/PlannerJoinTree.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
#include <Processors/QueryPlan/ReadFromTableStep.h>
#include <Processors/QueryPlan/ReadFromTableFunctionStep.h>
#include <Processors/QueryPlan/ReadNothingStep.h>
#include <Processors/QueryPlan/SourceStepWithFilter.h>
#include <Processors/QueryPlan/Optimizations/Utils.h>
#include <Processors/QueryPlan/ParallelReplicasLocalPlan.h>
#include <Processors/Sources/SourceFromSingleChunk.h>
Expand Down Expand Up @@ -215,6 +216,83 @@ void checkAccessRightsForSubquery(const QueryTreeNodePtr & subquery_node, const
}
}

/// Same outer-join sides as JOIN filter pushdown / `FunctionToSubcolumnsPass`:
/// do not copy a predicate onto the null-producing side.
bool joinTreePreservesRowsForTable(const QueryTreeNodePtr & join_tree, const QueryTreeNodePtr & table)
{
std::vector<QueryTreeNodePtr> stack = {join_tree};
while (!stack.empty())
{
auto node = std::move(stack.back());
stack.pop_back();
if (!node)
continue;

if (const auto * join = node->as<JoinNode>())
{
if (isRightOrFull(join->getKind()) && extractTableExpressionsSet(join->getLeftTableExpression()).contains(table.get()))
return false;
if (isLeftOrFull(join->getKind()) && extractTableExpressionsSet(join->getRightTableExpression()).contains(table.get()))
return false;
stack.push_back(join->getLeftTableExpression());
stack.push_back(join->getRightTableExpression());
}
else if (const auto * array_join = node->as<ArrayJoinNode>())
{
stack.push_back(array_join->getTableExpression());
}
else if (const auto * cross_join = node->as<CrossJoinNode>())
{
for (const auto & expr : cross_join->getTableExpressions())
stack.push_back(expr);
}
}
return true;
}

/// `IStorageCluster` JOINs wrap the left table in a subquery. Attach dummy-analysis
/// filters to the wrap source for listing only; do not add a FilterStep, which would
/// drop unused columns from the wrap header.
void tryAddClusterWrapFilter(QueryPlan & query_plan, const TableExpressionData & table_expression_data)
{
const auto & filter_actions = table_expression_data.getFilterActions();
if (!filter_actions || !query_plan.isInitialized())
return;

QueryPlan::Node * node = query_plan.getRootNode();
while (node && !node->children.empty())
node = node->children.front();

auto * source = node ? dynamic_cast<SourceStepWithFilter *>(node->step.get()) : nullptr;
if (!source)
return;

auto filter_dag = filter_actions->clone();
const auto filter_column_name = filter_dag.getOutputs().at(0)->result_name;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: I prefer to check size before use at if we receive vector outside.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Add an exception

const auto & header = source->getOutputHeader();
ActionsDAG rename_dag(header->getColumnsWithTypeAndName());
const auto & identifier_to_name = table_expression_data.getColumnIdentifierToColumnName();

for (const auto * input : filter_dag.getInputs())
{
if (header->has(input->result_name))
continue;

auto it = identifier_to_name.find(input->result_name);
if (it == identifier_to_name.end() || !header->has(it->second))
continue;

const auto & physical = rename_dag.findInOutputs(it->second);
rename_dag.addOrReplaceInOutputs(rename_dag.addAlias(physical, input->result_name));
}

filter_dag = ActionsDAG::merge(std::move(rename_dag), std::move(filter_dag));
source->addFilter(std::move(filter_dag), filter_column_name);
/// Wrap subquery planning already called `applyFilters` with no predicate.
/// Apply now so icebergCluster listing is recreated with the WHERE.
source->SourceStepWithFilterBase::applyFilters();
}

bool shouldIgnoreQuotaAndLimits(const TableNode & table_node)
{
const auto & storage_id = table_node.getStorageID();
Expand Down Expand Up @@ -920,8 +998,49 @@ JoinTreeQueryPlan buildQueryPlanForTableExpression(QueryTreeNodePtr table_expres

if (wrap_read_columns_in_subquery)
{
auto original_table_expression = table_expression;

/// Subqueries inherit the outer GlobalPlannerContext, whose filter map is keyed by
/// outer table nodes. Collect filters for this JOIN query so icebergCluster listing
/// still sees left-only WHERE after the wrap.
if (!table_expression_data.getFilterActions() && select_query_info.query_tree)
{
auto collected = collectFiltersForAnalysis(select_query_info.query_tree, select_query_options, nullptr);
auto it = collected.find(table_expression);
if (it != collected.end() && it->second.filter_actions)
table_expression_data.setFilterActions(it->second.filter_actions->clone());
}

auto columns = table_expression_data.getColumns();
table_expression = buildSubqueryToReadColumnsFromTableExpression(columns, table_expression, query_context);
table_expression = buildSubqueryToReadColumnsFromTableExpression(columns, original_table_expression, query_context);

/// Wrap is planned as `SELECT cols FROM icebergCluster` with no JOIN. Copy left-only
/// WHERE/PREWHERE so initiator file listing sees the same predicate as a single-table
/// `icebergCluster` read. Same helper as `IStorageCluster::updateQueryWithJoinToSendIfNeeded`.
/// Skip the null-producing side of an outer JOIN (`WHERE isNull(r.x)` on a `LEFT JOIN`).
if (const auto * parent_query = select_query_info.query_tree->as<QueryNode>();
parent_query && joinTreePreservesRowsForTable(parent_query->getJoinTree(), original_table_expression))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor ASOF and PASTE restrictions before copying predicates

The new guard excludes null-producing outer-join sides, but it still admits join shapes where prefiltering changes which rows are joined. For example, when the remote right side of an INNER ASOF JOIN has a right-only WHERE, copying it into the wrapper can discard the nearest match and select an older matching row instead; similarly, prefiltering either side of a PASTE JOIN shifts positional alignment. The normal pushdown path in filterPushDown.cpp explicitly disables right-side ASOF pushdown and all PASTE pushdown, so this wrapper path needs equivalent restrictions before copying the predicate.

Useful? React with 👍 / 👎.

{
auto copy_left_only = [&](const QueryTreeNodePtr & predicate) -> QueryTreeNodePtr
{
auto cloned = predicate->clone();
removeExpressionsThatDoNotDependOnTableIdentifiers(cloned, original_table_expression, query_context);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject bare predicates from the other join side

When an eligible IStorageCluster table participates in an inner join and the outer predicate is a bare column from the other side, such as WHERE r.flag, this call leaves the predicate unchanged: filterConjunctions returns immediately when its root is not a FunctionNode, so removeExpressionsThatDoNotDependOnTableIdentifiers never invokes its dependency check. The predicate is then attached to the cluster-only wrapper despite referencing r, which is absent from that wrapper's FROM clause, causing a valid join query to fail during planning; apply the keep predicate to non-function roots as well.

Useful? React with 👍 / 👎.

removeExpressionsThatAreNotDeterministicInScopeOfQuery(cloned, query_context);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude stateful predicates from the copied filter

This removes functions that are nondeterministic within a query, but stateful functions can still report themselves as deterministic; aiEmbed, for example, returns true from isDeterministicInScopeOfQuery while documenting that each call consumes quota and performs a potentially expensive external request. In an IStorageCluster join, such a WHERE is consequently evaluated in the wrapper and again above the join, doubling external calls and potentially exceeding ai_function_max_api_calls_per_query. The ordinary filter-pushdown path avoids this through ActionsDAG::hasStatefulFunctions, so copied predicates should apply the same exclusion.

Useful? React with 👍 / 👎.

return cloned;
};

auto & wrap_query = table_expression->as<QueryNode &>();
if (parent_query->hasWhere())
{
if (auto pred = copy_left_only(parent_query->getWhere()))
wrap_query.getWhere() = std::move(pred);
Comment on lines +1047 to +1050

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restrict copied predicates to preserved join sides

When a wrapped remote table is on the null-producing side of an outer join, copying every table-local predicate into its subquery changes join semantics. For example, with a remote right side of a LEFT JOIN, WHERE isNull(r.value) is copied below the join; rows matching a non-null r.value are then removed before the join, become null-extended unmatched rows, and incorrectly pass the original outer predicate. Check parent_join_tree and the join kind/side before copying a predicate, rather than treating every wrapped table expression as safe.

Useful? React with 👍 / 👎.

Comment on lines +1047 to +1050

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid evaluating nondeterministic predicates twice

The copied predicate is added to the wrapper while the original remains above the join, and removeExpressionsThatDoNotDependOnTableIdentifiers does not reject nondeterministic expressions. Thus an IStorageCluster join with a left-only condition such as WHERE rand() % 2 = 0 evaluates independent rand calls in the wrapper and again after the join, changing the expected cardinality from roughly one half to one quarter. Listing predicates must not become an additional execution filter unless they are proven safe to duplicate.

Useful? React with 👍 / 👎.

}
if (parent_query->hasPrewhere())
{
if (auto pred = copy_left_only(parent_query->getPrewhere()))
wrap_query.getPrewhere() = std::move(pred);
}
}
}

auto * table_node = table_expression->as<TableNode>();
Expand Down Expand Up @@ -1491,19 +1610,24 @@ JoinTreeQueryPlan buildQueryPlanForTableExpression(QueryTreeNodePtr table_expres
else
{
std::shared_ptr<GlobalPlannerContext> subquery_planner_context;
auto subquery_options = select_query_options.subquery();
if (wrap_read_columns_in_subquery)
subquery_planner_context = std::make_shared<GlobalPlannerContext>(nullptr, nullptr, nullptr, FiltersForTableExpressionMap{});
{
subquery_planner_context = std::make_shared<GlobalPlannerContext>(
nullptr, nullptr, nullptr, collectFiltersForAnalysis(table_expression, subquery_options, nullptr));
}
else
subquery_planner_context = planner_context->getGlobalPlannerContext();

auto subquery_options = select_query_options.subquery();
Planner subquery_planner(table_expression, subquery_options, subquery_planner_context);
/// Propagate storage limits to subquery
subquery_planner.addStorageLimits(*select_query_info.storage_limits);
subquery_planner.buildQueryPlanIfNeeded();
const auto & mapping = subquery_planner.getQueryNodeToPlanStepMapping();
query_node_to_plan_step_mapping.insert(mapping.begin(), mapping.end());
query_plan = std::move(subquery_planner).extractQueryPlan();
if (wrap_read_columns_in_subquery && till_stage == QueryProcessingStage::FetchColumns)
tryAddClusterWrapFilter(query_plan, table_expression_data);
}

auto & alias_column_expressions = table_expression_data.getAliasColumnExpressions();
Expand Down
Loading
Loading