Skip to content

Commit 89b9187

Browse files
committed
Merge pull request #8 from spiceai/mach/compound-art-scan
ART Index: Support compound key scans Squashed commit of the following: commit fec3602 Author: David Stancu <david@spice.ai> Date: Mon Nov 3 14:26:06 2025 -0500 tryscanindex: fix direct match lookup, range check vec access commit 2714c3d Author: David Stancu <david@spice.ai> Date: Mon Nov 3 13:55:13 2025 -0500 tryscanindex: do column matching first, to use possibly rebound matches in both sanity check and index expr rebinding add test for this scenario commit 36ffa5b Author: David Stancu <david@spice.ai> Date: Mon Nov 3 12:30:28 2025 -0500 tryscanindex sanity check: indexed_columns / art column ids may not need remapping if the scan is not a view scan commit 525f9c7 Author: David Stancu <david@spice.ai> Date: Thu Oct 30 10:42:17 2025 -0400 do not do index scan if there are other non index filters in the predicate (fix shutdown_create_index.test) commit b0a6e2d Author: David Stancu <david@spice.ai> Date: Thu Oct 30 10:04:54 2025 -0400 add test, bail out for eg composite query with IN () list commit a22a430 Author: David Stancu <david@spice.ai> Date: Wed Oct 29 16:37:30 2025 -0400 simplify filter expression storage index bindings (just reuse the ones we made earlier), fix single-ref-per-expr predicate to correctly walk expr tree and yank refs (allowing nesting in fns, etc) commit 9c8c1ed Author: David Stancu <david@spice.ai> Date: Wed Oct 29 15:11:23 2025 -0400 copy index expressions before rewriting column refs commit aff2c98 Author: David Stancu <david@spice.ai> Date: Wed Oct 29 14:36:33 2025 -0400 table scan: rebind projected columns in ALL index exprs do not bail out early if more than one index expr hook up composite key scan commit bfc6f02 Author: David Stancu <david@spice.ai> Date: Wed Oct 29 14:35:09 2025 -0400 make specialized compound key scan state for eq compares, specialized scan using ARTKey::Concat
1 parent b390a7c commit 89b9187

5 files changed

Lines changed: 247 additions & 50 deletions

File tree

src/execution/index/art/art.cpp

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#include "duckdb/execution/index/art/art.hpp"
22

3+
#include "duckdb/common/assert.hpp"
4+
#include "duckdb/common/helper.hpp"
5+
#include "duckdb/common/typedefs.hpp"
36
#include "duckdb/common/types/conflict_manager.hpp"
47
#include "duckdb/common/unordered_map.hpp"
58
#include "duckdb/common/vector_operations/vector_operations.hpp"
@@ -39,6 +42,17 @@ struct ARTIndexScanState : public IndexScanState {
3942
set<row_t> row_ids;
4043
};
4144

45+
struct ARTIndexCompoundKeyScanState : public IndexScanState {
46+
//! The predicates to scan.
47+
//! A single predicate for each constituent key in a compound index.
48+
vector<Value> values;
49+
//! The expressions over the scan predicates.
50+
vector<ExpressionType> expressions;
51+
bool checked = false;
52+
//! All scanned row IDs.
53+
set<row_t> row_ids;
54+
};
55+
4256
//===--------------------------------------------------------------------===//
4357
// ART
4458
//===--------------------------------------------------------------------===//
@@ -142,6 +156,34 @@ static unique_ptr<IndexScanState> InitializeScanTwoPredicates(const Value &low_v
142156
return std::move(result);
143157
}
144158

159+
// Build compound scan state by building individual index scans and collecting their exprs/values
160+
unique_ptr<IndexScanState> ART::TryInitializeCompoundKeyScan(const vector<unique_ptr<Expression>> &index_exprs,
161+
vector<vector<unique_ptr<Expression>>> &exprs) {
162+
auto compound_scan_state = make_uniq<ARTIndexCompoundKeyScanState>();
163+
164+
for (idx_t i = 0; i < index_exprs.size(); ++i) {
165+
auto index_expr = &index_exprs[i];
166+
auto filter_exprs = &exprs[i];
167+
168+
for (const auto &filter_expr : *filter_exprs) {
169+
auto single_scan = ART::TryInitializeScan(**index_expr, *filter_expr);
170+
if (!single_scan) {
171+
return nullptr;
172+
}
173+
174+
auto single_scan_concrete = single_scan->Cast<ARTIndexScanState>();
175+
if (single_scan_concrete.expressions[0] != ExpressionType::COMPARE_EQUAL) {
176+
return nullptr;
177+
}
178+
179+
compound_scan_state->values.push_back(single_scan_concrete.values[0]);
180+
compound_scan_state->expressions.push_back(single_scan_concrete.expressions[0]);
181+
}
182+
}
183+
184+
return compound_scan_state;
185+
}
186+
145187
unique_ptr<IndexScanState> ART::TryInitializeScan(const Expression &expr, const Expression &filter_expr) {
146188
Value low_value, high_value, equal_value;
147189
ExpressionType low_comparison_type = ExpressionType::INVALID, high_comparison_type = ExpressionType::INVALID;
@@ -675,6 +717,30 @@ bool ART::SearchCloseRange(ARTKey &lower_bound, ARTKey &upper_bound, bool left_e
675717
return it.Scan(upper_bound, max_count, row_ids, right_equal);
676718
}
677719

720+
bool ART::CompoundKeyScan(IndexScanState &state, const idx_t max_count, set<row_t> &row_ids) {
721+
auto &scan_state = state.Cast<ARTIndexCompoundKeyScanState>();
722+
723+
if (scan_state.values.size() != types.size()) {
724+
return false;
725+
}
726+
727+
for (idx_t i = 0; i < scan_state.values.size(); ++i) {
728+
D_ASSERT(scan_state.values[i].type().InternalType() == types[i]);
729+
}
730+
731+
ArenaAllocator arena_allocator(Allocator::Get(db));
732+
733+
// Make a compound key from the collected state values
734+
auto compound_key = ARTKey::CreateKey(arena_allocator, types[0], scan_state.values[0]);
735+
for (idx_t i = 1; i < scan_state.values.size(); ++i) {
736+
auto part_key = ARTKey::CreateKey(arena_allocator, types[i], scan_state.values[i]);
737+
compound_key.Concat(arena_allocator, part_key);
738+
}
739+
740+
lock_guard<mutex> l(lock);
741+
return SearchEqual(compound_key, max_count, row_ids);
742+
}
743+
678744
bool ART::Scan(IndexScanState &state, const idx_t max_count, set<row_t> &row_ids) {
679745
auto &scan_state = state.Cast<ARTIndexScanState>();
680746
D_ASSERT(scan_state.values[0].type().InternalType() == types[0]);

src/function/table/table_scan.cpp

Lines changed: 124 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
#include "duckdb/planner/filter/conjunction_filter.hpp"
3131
#include "duckdb/common/types/value_map.hpp"
3232
#include "duckdb/main/settings.hpp"
33+
#include <limits>
3334
#include <list>
35+
#include <utility>
3436

3537
namespace duckdb {
3638

@@ -486,86 +488,158 @@ vector<unique_ptr<Expression>> ExtractFilterExpressions(const ColumnDefinition &
486488

487489
bool TryScanIndex(ART &art, const ColumnList &column_list, TableFunctionInitInput &input, TableFilterSet &filter_set,
488490
idx_t max_count, set<row_t> &row_ids) {
489-
// FIXME: No support for index scans on compound ARTs.
490-
// See note above on multi-filter support.
491-
if (art.unbound_expressions.size() > 1) {
492-
return false;
491+
vector<unique_ptr<Expression>> index_exprs;
492+
for (const auto &expr : art.unbound_expressions) {
493+
index_exprs.push_back(expr->Copy());
493494
}
494495

495-
auto index_expr = art.unbound_expressions[0]->Copy();
496+
// If this is a view, the column IDs are (may be?) relative to the view projection
496497
auto &indexed_columns = art.GetColumnIds();
497498

498-
// NOTE: We do not push down multi-column filters, e.g., 42 = a + b.
499-
if (indexed_columns.size() != 1) {
499+
// Allow composite ART scans
500+
if (indexed_columns.size() != index_exprs.size()) {
500501
return false;
501502
}
502503

503504
// Resolve bound column references in the index_expr against the current input projection
504-
column_t updated_index_column;
505-
bool found_index_column_in_input = false;
505+
bool rewrite_index_exprs = false;
506+
vector<column_t> index_column_to_input_pos;
507+
index_column_to_input_pos.resize(indexed_columns.size(), std::numeric_limits<idx_t>::max());
508+
509+
// Associate indexed columns to input columns
510+
for (idx_t i = 0; i < indexed_columns.size(); ++i) {
511+
for (idx_t j = 0; j < input.column_ids.size(); ++j) {
512+
if (indexed_columns[i] == input.column_ids[j]) {
513+
rewrite_index_exprs = i != j;
514+
index_column_to_input_pos.at(i) = j;
515+
break;
516+
}
517+
}
518+
}
506519

507-
// Find the indexed column amongst the input columns
508-
for (idx_t i = 0; i < input.column_ids.size(); ++i) {
509-
if (input.column_ids[i] == indexed_columns[0]) {
510-
updated_index_column = i;
511-
found_index_column_in_input = true;
512-
break;
520+
// Make sure that all indexed_columns were bound, or bail out
521+
for (auto col : index_column_to_input_pos) {
522+
if (col == std::numeric_limits<idx_t>::max()) {
523+
return false;
513524
}
514525
}
515526

516-
// If found, update the bound column ref within index_expr
517-
if (found_index_column_in_input) {
518-
ExpressionIterator::EnumerateExpression(index_expr, [&](Expression &expr) {
519-
if (expr.GetExpressionClass() != ExpressionClass::BOUND_COLUMN_REF) {
520-
return;
527+
// Allow scan only if index expressions reference ONE column each, and that column
528+
// is associated with an indexed_column
529+
// NOTE: We do not push down multi-column filters, e.g., 42 = a + b.
530+
for (idx_t i = 0; i < index_exprs.size(); ++i) {
531+
unordered_set<column_t> referenced_columns;
532+
auto expr = &index_exprs[i];
533+
534+
// Walk the expr in case of nesting (e.g. function)
535+
ExpressionIterator::EnumerateExpression(*expr, [&](Expression &child_expr) {
536+
if (child_expr.GetExpressionClass() == ExpressionClass::BOUND_COLUMN_REF) {
537+
auto &col_ref = child_expr.Cast<BoundColumnRefExpression>();
538+
referenced_columns.insert(col_ref.binding.column_index);
521539
}
540+
});
522541

523-
auto &bound_column_ref_expr = expr.Cast<BoundColumnRefExpression>();
542+
if (referenced_columns.size() != 1) {
543+
return false;
544+
}
524545

525-
// If the bound column references the index column, use updated_index_column
526-
if (bound_column_ref_expr.binding.column_index == indexed_columns[0]) {
527-
bound_column_ref_expr.binding.column_index = updated_index_column;
528-
}
529-
});
530-
}
546+
// Make sure the column reference can be looked up
547+
auto ref_col_idx = *referenced_columns.begin();
548+
if (ref_col_idx >= index_column_to_input_pos.size() || ref_col_idx >= input.column_ids.size()) {
549+
return false;
550+
}
531551

532-
// Get ART column.
533-
auto &col = column_list.GetColumn(LogicalIndex(indexed_columns[0]));
552+
// The column for this position matches the indexed_column ID for this position directly
553+
auto direct_match = input.column_ids[ref_col_idx] == indexed_columns[i];
534554

535-
// The indexes of the filters match input.column_indexes, which are: i -> column_index.
536-
// Try to find a filter on the ART column.
537-
optional_idx storage_index;
538-
for (idx_t i = 0; i < input.column_indexes.size(); i++) {
539-
if (input.column_indexes[i].ToLogical() == col.Logical()) {
540-
storage_index = i;
541-
break;
555+
// We should know if there is a different mapping for this reference.
556+
// If there is not, it won't match, so it is not worth trying.
557+
if (!direct_match && !rewrite_index_exprs) {
558+
return false;
559+
}
560+
561+
auto remapped_cid_position = index_column_to_input_pos[ref_col_idx];
562+
auto remapped_match = remapped_cid_position < input.column_ids.size() &&
563+
input.column_ids[remapped_cid_position] == indexed_columns[i];
564+
565+
if (!(direct_match || remapped_match)) {
566+
return false;
542567
}
543568
}
544569

545-
// No filter matches the ART column.
546-
if (!storage_index.IsValid()) {
547-
return false;
570+
// If the position of the indexed_columns differs from the order of the input, remap the index expressions
571+
if (rewrite_index_exprs) {
572+
for (auto &index_expr : index_exprs) {
573+
ExpressionIterator::EnumerateExpression(index_expr, [&](Expression &expr) {
574+
if (expr.GetExpressionClass() != ExpressionClass::BOUND_COLUMN_REF) {
575+
return;
576+
}
577+
578+
auto &bound_column_ref_expr = expr.Cast<BoundColumnRefExpression>();
579+
580+
// If the bound column references an indexed column, update it
581+
for (idx_t i = 0; i < indexed_columns.size(); ++i) {
582+
auto remapped_index = index_column_to_input_pos[bound_column_ref_expr.binding.column_index];
583+
if (input.column_ids[remapped_index] == indexed_columns[i]) {
584+
bound_column_ref_expr.binding.column_index = index_column_to_input_pos[i];
585+
break;
586+
}
587+
}
588+
});
589+
}
590+
}
591+
592+
// The indexes of the filters match input.column_indexes, which are: i -> column_index.
593+
// Reuse the index <-> projection mappings from index expr rebinding (which are canonical even if not rewriting)
594+
vector<vector<unique_ptr<Expression>>> index_filters;
595+
596+
for (idx_t i = 0; i < index_column_to_input_pos.size(); ++i) {
597+
auto column_def = &column_list.GetColumn(LogicalIndex(indexed_columns[i]));
598+
auto maybe_filter = filter_set.filters.find(index_column_to_input_pos[i]);
599+
if (maybe_filter != filter_set.filters.end()) {
600+
auto filter = &maybe_filter->second;
601+
auto filter_expressions = ExtractFilterExpressions(*column_def, *filter, index_column_to_input_pos[i]);
602+
603+
index_filters.push_back(std::move(filter_expressions));
604+
}
548605
}
549606

550-
// Try to find a matching filter for the column.
551-
auto filter = filter_set.filters.find(storage_index.GetIndex());
552-
if (filter == filter_set.filters.end()) {
607+
// Index filters must:
608+
// - Match ART column count 1:1
609+
// - Match filter expression set 1:1 (there may be filters on non-indexed columns, bail out if so)
610+
if (index_filters.size() != indexed_columns.size() || filter_set.filters.size() != index_filters.size() ||
611+
index_filters.empty()) {
553612
return false;
554613
}
555614

556-
auto expressions = ExtractFilterExpressions(col, filter->second, storage_index.GetIndex());
557-
for (const auto &filter_expr : expressions) {
558-
auto scan_state = art.TryInitializeScan(*index_expr, *filter_expr);
615+
// Do a compound scan if we have filter exprs bound for several columns
616+
if (index_filters.size() > 1) {
617+
auto scan_state = art.TryInitializeCompoundKeyScan(index_exprs, index_filters);
559618
if (!scan_state) {
560619
return false;
561620
}
562621

563-
// Check if we can use an index scan, and already retrieve the matching row ids.
564-
if (!art.Scan(*scan_state, max_count, row_ids)) {
622+
if (!art.CompoundKeyScan(*scan_state, max_count, row_ids)) {
565623
row_ids.clear();
566624
return false;
567625
}
568626
}
627+
// Original single column index scan
628+
else {
629+
for (const auto &filter_expr : index_filters[0]) {
630+
auto scan_state = art.TryInitializeScan(*index_exprs[0], *filter_expr);
631+
if (!scan_state) {
632+
return false;
633+
}
634+
635+
// Check if we can use an index scan, and already retrieve the matching row ids.
636+
if (!art.Scan(*scan_state, max_count, row_ids)) {
637+
row_ids.clear();
638+
return false;
639+
}
640+
}
641+
}
642+
569643
return true;
570644
}
571645

@@ -588,9 +662,9 @@ unique_ptr<GlobalTableFunctionState> TableScanInitGlobal(ClientContext &context,
588662
// 1.2. Find + scan one ART for b = 24.
589663
// 1.3. Return the intersecting row IDs.
590664
// 2. (Reorder and) scan a single ART with a compound key of (a, b).
591-
if (filter_set.filters.size() != 1) {
592-
return DuckTableScanInitGlobal(context, input, storage, bind_data);
593-
}
665+
// if (filter_set.filters.size() != 1) {
666+
// return DuckTableScanInitGlobal(context, input, storage, bind_data);
667+
// }
594668

595669
// The checkpoint lock ensures that we do not checkpoint while scanning this table.
596670
auto &transaction = DuckTransaction::Get(context, storage.db);

src/include/duckdb/execution/index/art/art.hpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include "duckdb/execution/index/bound_index.hpp"
1212
#include "duckdb/execution/index/art/node.hpp"
1313
#include "duckdb/common/array.hpp"
14+
#include "duckdb/planner/expression.hpp"
1415

1516
namespace duckdb {
1617

@@ -24,6 +25,7 @@ class ARTKeySection;
2425
class FixedSizeAllocator;
2526

2627
struct ARTIndexScanState;
28+
struct ARTIndexCompoundKeyScanState;
2729

2830
class ART : public BoundIndex {
2931
public:
@@ -70,10 +72,20 @@ class ART : public BoundIndex {
7072
public:
7173
//! Try to initialize a scan on the ART with the given expression and filter.
7274
unique_ptr<IndexScanState> TryInitializeScan(const Expression &expr, const Expression &filter_expr);
75+
76+
//! Try to initialize a compound key scan on the ART, using the given index expr -> filter expr mappings.
77+
//! Supports equality comparisons only.
78+
unique_ptr<IndexScanState> TryInitializeCompoundKeyScan(const vector<unique_ptr<Expression>> &index_exprs,
79+
vector<vector<unique_ptr<Expression>>> &exprs);
80+
7381
//! Perform a lookup on the ART, fetching up to max_count row IDs.
7482
//! If all row IDs were fetched, it return true, else false.
7583
bool Scan(IndexScanState &state, idx_t max_count, set<row_t> &row_ids);
7684

85+
//! Like `ART::Scan`, but uses `ARTIndexCompoundKeyScanState` to concatenate multiple
86+
//! values for equality comparisons only.
87+
bool CompoundKeyScan(IndexScanState &state, idx_t max_count, set<row_t> &row_ids);
88+
7789
//! Appends data to the locked index.
7890
ErrorData Append(IndexLock &l, DataChunk &chunk, Vector &row_ids) override;
7991
//! Appends data to the locked index and verifies constraint violations.

test/sql/index/art/issues/test_art_view_col_binding.test

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,23 @@ select z, y, x from test_view where upper(x) = '526';
4646

4747
statement ok
4848
drop index test_upper_x;
49+
50+
statement ok
51+
create or replace table other_test_table (
52+
name VARCHAR NOT NULL,
53+
id BIGINT NOT NULL,
54+
age INTEGER,
55+
status VARCHAR NOT NULL
56+
);
57+
58+
# test simple permutation of initial table projection
59+
statement ok
60+
CREATE INDEX index_id_other_test_table ON other_test_table (id);
61+
62+
statement ok
63+
CREATE OR REPLACE VIEW ott_view AS SELECT * FROM other_test_table;
64+
65+
query II
66+
explain analyze select * from ott_view where id = 1;
67+
----
68+
analyzed_plan <REGEX>:.*Index Scan.*

0 commit comments

Comments
 (0)