Skip to content

Commit b393582

Browse files
mach-kernelsgrebnov
authored andcommitted
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 8a58519 commit b393582

5 files changed

Lines changed: 253 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"
@@ -40,6 +43,17 @@ struct ARTIndexScanState : public IndexScanState {
4043
set<row_t> row_ids;
4144
};
4245

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

166+
// Build compound scan state by building individual index scans and collecting their exprs/values
167+
unique_ptr<IndexScanState> ART::TryInitializeCompoundKeyScan(const vector<unique_ptr<Expression>> &index_exprs,
168+
vector<vector<unique_ptr<Expression>>> &exprs) {
169+
auto compound_scan_state = make_uniq<ARTIndexCompoundKeyScanState>();
170+
171+
for (idx_t i = 0; i < index_exprs.size(); ++i) {
172+
auto index_expr = &index_exprs[i];
173+
auto filter_exprs = &exprs[i];
174+
175+
for (const auto &filter_expr : *filter_exprs) {
176+
auto single_scan = ART::TryInitializeScan(**index_expr, *filter_expr);
177+
if (!single_scan) {
178+
return nullptr;
179+
}
180+
181+
auto single_scan_concrete = single_scan->Cast<ARTIndexScanState>();
182+
if (single_scan_concrete.expressions[0] != ExpressionType::COMPARE_EQUAL) {
183+
return nullptr;
184+
}
185+
186+
compound_scan_state->values.push_back(single_scan_concrete.values[0]);
187+
compound_scan_state->expressions.push_back(single_scan_concrete.expressions[0]);
188+
}
189+
}
190+
191+
return compound_scan_state;
192+
}
193+
152194
unique_ptr<IndexScanState> ART::TryInitializeScan(const Expression &expr, const Expression &filter_expr) {
153195
Value low_value, high_value, equal_value;
154196
ExpressionType low_comparison_type = ExpressionType::INVALID, high_comparison_type = ExpressionType::INVALID;
@@ -760,6 +802,30 @@ bool ART::SearchCloseRange(ARTKey &lower_bound, ARTKey &upper_bound, bool left_e
760802
return it.Scan(upper_bound, output, right_equal) == ARTScanResult::COMPLETED;
761803
}
762804

805+
bool ART::CompoundKeyScan(IndexScanState &state, const idx_t max_count, set<row_t> &row_ids) {
806+
auto &scan_state = state.Cast<ARTIndexCompoundKeyScanState>();
807+
808+
if (scan_state.values.size() != types.size()) {
809+
return false;
810+
}
811+
812+
for (idx_t i = 0; i < scan_state.values.size(); ++i) {
813+
D_ASSERT(scan_state.values[i].type().InternalType() == types[i]);
814+
}
815+
816+
ArenaAllocator arena_allocator(Allocator::Get(db));
817+
818+
// Make a compound key from the collected state values
819+
auto compound_key = ARTKey::CreateKey(arena_allocator, scan_state.values[0], storage_version);
820+
for (idx_t i = 1; i < scan_state.values.size(); ++i) {
821+
auto part_key = ARTKey::CreateKey(arena_allocator, scan_state.values[i], storage_version);
822+
compound_key.Concat(arena_allocator, part_key);
823+
}
824+
825+
lock_guard<mutex> l(lock);
826+
return SearchEqual(compound_key, max_count, row_ids);
827+
}
828+
763829
bool ART::Scan(IndexScanState &state, const idx_t max_count, set<row_t> &row_ids) {
764830
auto &scan_state = state.Cast<ARTIndexScanState>();
765831
if (scan_state.values[0].IsNull()) {

src/function/table/table_scan.cpp

Lines changed: 128 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
#include "duckdb/common/types/value_map.hpp"
3232
#include "duckdb/main/settings.hpp"
3333
#include "duckdb/transaction/duck_transaction_manager.hpp"
34+
#include <limits>
35+
#include <list>
36+
#include <utility>
3437

3538
namespace duckdb {
3639

@@ -558,70 +561,127 @@ vector<unique_ptr<Expression>> ExtractFilterExpressions(const ColumnDefinition &
558561

559562
bool TryScanIndex(ART &art, IndexEntry &entry, const ColumnList &column_list, TableFunctionInitInput &input,
560563
TableFilterSet &filter_set, idx_t max_count, set<row_t> &row_ids) {
561-
// FIXME: No support for index scans on compound ARTs.
562-
// See note above on multi-filter support.
563-
if (art.unbound_expressions.size() > 1) {
564-
return false;
564+
vector<unique_ptr<Expression>> index_exprs;
565+
for (const auto &expr : art.unbound_expressions) {
566+
index_exprs.push_back(expr->Copy());
565567
}
566568

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

570-
// NOTE: We do not push down multi-column filters, e.g., 42 = a + b.
571-
if (indexed_columns.size() != 1) {
572+
// Allow composite ART scans
573+
if (indexed_columns.size() != index_exprs.size()) {
572574
return false;
573575
}
574576

575577
// Resolve bound column references in the index_expr against the current input projection
576-
column_t updated_index_column;
577-
bool found_index_column_in_input = false;
578-
579-
// Find the indexed column amongst the input columns
580-
for (idx_t i = 0; i < input.column_ids.size(); ++i) {
581-
if (input.column_ids[i] == indexed_columns[0]) {
582-
updated_index_column = i;
583-
found_index_column_in_input = true;
584-
break;
578+
bool rewrite_index_exprs = false;
579+
vector<column_t> index_column_to_input_pos;
580+
index_column_to_input_pos.resize(indexed_columns.size(), std::numeric_limits<idx_t>::max());
581+
582+
// Associate indexed columns to input columns
583+
for (idx_t i = 0; i < indexed_columns.size(); ++i) {
584+
for (idx_t j = 0; j < input.column_ids.size(); ++j) {
585+
if (indexed_columns[i] == input.column_ids[j]) {
586+
rewrite_index_exprs = i != j;
587+
index_column_to_input_pos.at(i) = j;
588+
break;
589+
}
585590
}
586591
}
587592

588-
// If found, update the bound column ref within index_expr
589-
if (found_index_column_in_input) {
590-
ExpressionIterator::EnumerateExpression(index_expr, [&](Expression &expr) {
591-
if (expr.GetExpressionClass() != ExpressionClass::BOUND_COLUMN_REF) {
592-
return;
593-
}
594-
595-
auto &bound_column_ref_expr = expr.Cast<BoundColumnRefExpression>();
593+
// Make sure that all indexed_columns were bound, or bail out
594+
for (auto col : index_column_to_input_pos) {
595+
if (col == std::numeric_limits<idx_t>::max()) {
596+
return false;
597+
}
598+
}
596599

597-
// If the bound column references the index column, use updated_index_column
598-
if (bound_column_ref_expr.binding.column_index == indexed_columns[0]) {
599-
bound_column_ref_expr.binding.column_index = updated_index_column;
600+
// Allow scan only if index expressions reference ONE column each, and that column
601+
// is associated with an indexed_column
602+
// NOTE: We do not push down multi-column filters, e.g., 42 = a + b.
603+
for (idx_t i = 0; i < index_exprs.size(); ++i) {
604+
unordered_set<column_t> referenced_columns;
605+
auto expr = &index_exprs[i];
606+
607+
// Walk the expr in case of nesting (e.g. function)
608+
ExpressionIterator::EnumerateExpression(*expr, [&](Expression &child_expr) {
609+
if (child_expr.GetExpressionClass() == ExpressionClass::BOUND_COLUMN_REF) {
610+
auto &col_ref = child_expr.Cast<BoundColumnRefExpression>();
611+
referenced_columns.insert(col_ref.binding.column_index);
600612
}
601613
});
614+
615+
if (referenced_columns.size() != 1) {
616+
return false;
617+
}
618+
619+
// Make sure the column reference can be looked up
620+
auto ref_col_idx = *referenced_columns.begin();
621+
if (ref_col_idx >= index_column_to_input_pos.size() || ref_col_idx >= input.column_ids.size()) {
622+
return false;
623+
}
624+
625+
// The column for this position matches the indexed_column ID for this position directly
626+
auto direct_match = input.column_ids[ref_col_idx] == indexed_columns[i];
627+
628+
// We should know if there is a different mapping for this reference.
629+
// If there is not, it won't match, so it is not worth trying.
630+
if (!direct_match && !rewrite_index_exprs) {
631+
return false;
632+
}
633+
634+
auto remapped_cid_position = index_column_to_input_pos[ref_col_idx];
635+
auto remapped_match = remapped_cid_position < input.column_ids.size() &&
636+
input.column_ids[remapped_cid_position] == indexed_columns[i];
637+
638+
if (!(direct_match || remapped_match)) {
639+
return false;
640+
}
602641
}
603642

604-
// Get ART column.
605-
auto &col = column_list.GetColumn(LogicalIndex(indexed_columns[0]));
643+
// If the position of the indexed_columns differs from the order of the input, remap the index expressions
644+
if (rewrite_index_exprs) {
645+
for (auto &index_expr : index_exprs) {
646+
ExpressionIterator::EnumerateExpression(index_expr, [&](Expression &expr) {
647+
if (expr.GetExpressionClass() != ExpressionClass::BOUND_COLUMN_REF) {
648+
return;
649+
}
606650

607-
// The indexes of the filters match input.column_indexes, which are: i -> column_index.
608-
// Try to find a filter on the ART column.
609-
optional_idx storage_index;
610-
for (idx_t i = 0; i < input.column_indexes.size(); i++) {
611-
if (input.column_indexes[i].ToLogical() == col.Logical()) {
612-
storage_index = i;
613-
break;
651+
auto &bound_column_ref_expr = expr.Cast<BoundColumnRefExpression>();
652+
653+
// If the bound column references an indexed column, update it
654+
for (idx_t i = 0; i < indexed_columns.size(); ++i) {
655+
auto remapped_index = index_column_to_input_pos[bound_column_ref_expr.binding.column_index];
656+
if (input.column_ids[remapped_index] == indexed_columns[i]) {
657+
bound_column_ref_expr.binding.column_index = index_column_to_input_pos[i];
658+
break;
659+
}
660+
}
661+
});
614662
}
615663
}
616664

617-
// No filter matches the ART column.
618-
if (!storage_index.IsValid()) {
619-
return false;
665+
// The indexes of the filters match input.column_indexes, which are: i -> column_index.
666+
// Reuse the index <-> projection mappings from index expr rebinding (which are canonical even if not rewriting)
667+
vector<vector<unique_ptr<Expression>>> index_filters;
668+
669+
for (idx_t i = 0; i < index_column_to_input_pos.size(); ++i) {
670+
auto column_def = &column_list.GetColumn(LogicalIndex(indexed_columns[i]));
671+
auto maybe_filter = filter_set.filters.find(index_column_to_input_pos[i]);
672+
if (maybe_filter != filter_set.filters.end()) {
673+
auto filter = &maybe_filter->second;
674+
auto filter_expressions = ExtractFilterExpressions(*column_def, *filter, index_column_to_input_pos[i]);
675+
676+
index_filters.push_back(std::move(filter_expressions));
677+
}
620678
}
621679

622-
// Try to find a matching filter for the column.
623-
auto filter = filter_set.filters.find(storage_index.GetIndex());
624-
if (filter == filter_set.filters.end()) {
680+
// Index filters must:
681+
// - Match ART column count 1:1
682+
// - Match filter expression set 1:1 (there may be filters on non-indexed columns, bail out if so)
683+
if (index_filters.size() != indexed_columns.size() || filter_set.filters.size() != index_filters.size() ||
684+
index_filters.empty()) {
625685
return false;
626686
}
627687

@@ -641,22 +701,40 @@ bool TryScanIndex(ART &art, IndexEntry &entry, const ColumnList &column_list, Ta
641701
arts_to_scan.push_back(entry.added_data_during_checkpoint->Cast<ART>());
642702
}
643703

644-
auto expressions = ExtractFilterExpressions(col, filter->second, storage_index.GetIndex());
645-
for (const auto &filter_expr : expressions) {
704+
// Do a compound scan if we have filter exprs bound for several columns
705+
if (index_filters.size() > 1) {
646706
for (auto &art_ref : arts_to_scan) {
647707
auto &art_to_scan = art_ref.get();
648-
auto scan_state = art_to_scan.TryInitializeScan(*index_expr, *filter_expr);
708+
auto scan_state = art_to_scan.TryInitializeCompoundKeyScan(index_exprs, index_filters);
649709
if (!scan_state) {
650710
return false;
651711
}
652712

653-
// Check if we can use an index scan, and already retrieve the matching row ids.
654-
if (!art_to_scan.Scan(*scan_state, max_count, row_ids)) {
713+
if (!art_to_scan.CompoundKeyScan(*scan_state, max_count, row_ids)) {
655714
row_ids.clear();
656715
return false;
657716
}
658717
}
659718
}
719+
// Original single column index scan
720+
else {
721+
for (const auto &filter_expr : index_filters[0]) {
722+
for (auto &art_ref : arts_to_scan) {
723+
auto &art_to_scan = art_ref.get();
724+
auto scan_state = art_to_scan.TryInitializeScan(*index_exprs[0], *filter_expr);
725+
if (!scan_state) {
726+
return false;
727+
}
728+
729+
// Check if we can use an index scan, and already retrieve the matching row ids.
730+
if (!art_to_scan.Scan(*scan_state, max_count, row_ids)) {
731+
row_ids.clear();
732+
return false;
733+
}
734+
}
735+
}
736+
}
737+
660738
return true;
661739
}
662740

@@ -679,9 +757,9 @@ unique_ptr<GlobalTableFunctionState> TableScanInitGlobal(ClientContext &context,
679757
// 1.2. Find + scan one ART for b = 24.
680758
// 1.3. Return the intersecting row IDs.
681759
// 2. (Reorder and) scan a single ART with a compound key of (a, b).
682-
if (filter_set.filters.size() != 1) {
683-
return DuckTableScanInitGlobal(context, input, storage, bind_data);
684-
}
760+
// if (filter_set.filters.size() != 1) {
761+
// return DuckTableScanInitGlobal(context, input, storage, bind_data);
762+
// }
685763

686764
auto &info = storage.GetDataTableInfo();
687765
auto &indexes = info->GetIndexes();

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

Lines changed: 11 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
struct DeleteIndexInfo {
2931
DeleteIndexInfo() : delete_indexes(nullptr) {
@@ -75,6 +77,11 @@ class ART : public BoundIndex {
7577
//! Try to initialize a scan on the ART with the given expression and filter.
7678
unique_ptr<IndexScanState> TryInitializeScan(const Expression &expr, const Expression &filter_expr);
7779
unique_ptr<IndexScanState> InitializeFullScan();
80+
81+
//! Try to initialize a compound key scan on the ART, using the given index expr -> filter expr mappings.
82+
//! Supports equality comparisons only.
83+
unique_ptr<IndexScanState> TryInitializeCompoundKeyScan(const vector<unique_ptr<Expression>> &index_exprs,
84+
vector<vector<unique_ptr<Expression>>> &exprs);
7885
//! Perform a lookup on the ART, fetching up to max_count row IDs.
7986
//! If all row IDs were fetched, it return true, else false.
8087
bool Scan(IndexScanState &state, idx_t max_count, set<row_t> &row_ids);
@@ -93,6 +100,10 @@ class ART : public BoundIndex {
93100
//! Obtains a lock and calls InsertMerge while holding that lock.
94101
ErrorData InsertMerge(BoundIndex &source_index, IndexAppendMode append_mode);
95102

103+
//! Like `ART::Scan`, but uses `ARTIndexCompoundKeyScanState` to concatenate multiple
104+
//! values for equality comparisons only.
105+
bool CompoundKeyScan(IndexScanState &state, idx_t max_count, set<row_t> &row_ids);
106+
96107
//! Appends data to the locked index.
97108
ErrorData Append(IndexLock &l, DataChunk &chunk, Vector &row_ids) override;
98109
//! Appends data to the locked index and verifies constraint violations.

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,26 @@ 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+
statement ok
59+
INSERT INTO other_test_table VALUES ('alice', 1, 30, 'active'), ('bob', 2, 25, 'inactive');
60+
61+
# test simple permutation of initial table projection
62+
statement ok
63+
CREATE INDEX index_id_other_test_table ON other_test_table (id);
64+
65+
statement ok
66+
CREATE OR REPLACE VIEW ott_view AS SELECT * FROM other_test_table;
67+
68+
query II
69+
explain analyze select * from ott_view where id = 1;
70+
----
71+
analyzed_plan <REGEX>:.*Index Scan.*

0 commit comments

Comments
 (0)