feat: impl StructArray -- support ARRAY_CONTAINS* (#47172)

issue: https://github.com/milvus-io/milvus/issues/42148
design doc:
https://github.com/milvus-io/milvus-design-docs/blob/main/design_docs/20260306-struct.md

---------

Signed-off-by: SpadeA <tangchenjie1210@gmail.com>
This commit is contained in:
Spade A
2026-03-06 14:35:21 +08:00
committed by GitHub
parent fd50c35167
commit ce9ea20342
21 changed files with 1638 additions and 1104 deletions
+50
View File
@@ -152,6 +152,30 @@ ArrayOffsetsSealed::RowOffsetsToElementOffsets(
return element_offsets;
}
TargetBitmap
ArrayOffsetsSealed::ForEachRowElementRange(
const ElementRangePredicate& predicate,
int64_t row_start,
int64_t row_count) const {
AssertInfo(row_start >= 0 && row_start + row_count <= GetRowCount(),
"row range out of bounds: row_start={}, row_count={}, "
"total_rows={}",
row_start,
row_count,
GetRowCount());
TargetBitmap result(row_count);
for (int64_t i = 0; i < row_count; ++i) {
int64_t row_id = row_start + i;
int32_t elem_start = row_to_element_start_[row_id];
int32_t elem_end = row_to_element_start_[row_id + 1];
result[i] = predicate(elem_start, elem_end);
}
return result;
}
std::shared_ptr<ArrayOffsetsSealed>
ArrayOffsetsSealed::BuildFromSegment(const void* segment,
const FieldMeta& field_meta) {
@@ -378,6 +402,32 @@ ArrayOffsetsGrowing::RowOffsetsToElementOffsets(
return element_offsets;
}
TargetBitmap
ArrayOffsetsGrowing::ForEachRowElementRange(
const ElementRangePredicate& predicate,
int64_t row_start,
int64_t row_count) const {
std::shared_lock lock(mutex_);
AssertInfo(row_start >= 0 && row_start + row_count <= committed_row_count_,
"row range out of bounds: row_start={}, row_count={}, "
"committed_rows={}",
row_start,
row_count,
committed_row_count_);
TargetBitmap result(row_count);
for (int64_t i = 0; i < row_count; ++i) {
int64_t row_id = row_start + i;
int32_t elem_start = row_to_element_start_[row_id];
int32_t elem_end = row_to_element_start_[row_id + 1];
result[i] = predicate(elem_start, elem_end);
}
return result;
}
void
ArrayOffsetsGrowing::Insert(int64_t row_id_start,
const int32_t* array_lengths,
+23
View File
@@ -71,6 +71,19 @@ class IArrayOffsets {
virtual FixedVector<int32_t>
RowOffsetsToElementOffsets(
const FixedVector<int32_t>& row_offsets) const = 0;
// Iterate over rows and apply predicate with element range
// predicate: function(element_start, element_end) -> bool
// element_start: first element ID of the row (inclusive)
// element_end: last element ID of the row (exclusive)
// Returns: bitmap where bit[i] = predicate result for row (row_start + i)
using ElementRangePredicate =
std::function<bool(int32_t elem_start, int32_t elem_end)>;
virtual TargetBitmap
ForEachRowElementRange(const ElementRangePredicate& predicate,
int64_t row_start,
int64_t row_count) const = 0;
};
class ArrayOffsetsSealed : public IArrayOffsets {
@@ -122,6 +135,11 @@ class ArrayOffsetsSealed : public IArrayOffsets {
RowOffsetsToElementOffsets(
const FixedVector<int32_t>& row_offsets) const override;
TargetBitmap
ForEachRowElementRange(const ElementRangePredicate& predicate,
int64_t row_start,
int64_t row_count) const override;
static std::shared_ptr<ArrayOffsetsSealed>
BuildFromSegment(const void* segment, const FieldMeta& field_meta);
@@ -169,6 +187,11 @@ class ArrayOffsetsGrowing : public IArrayOffsets {
RowOffsetsToElementOffsets(
const FixedVector<int32_t>& row_offsets) const override;
TargetBitmap
ForEachRowElementRange(const ElementRangePredicate& predicate,
int64_t row_start,
int64_t row_count) const override;
private:
struct PendingRow {
int64_t row_id;
+13
View File
@@ -375,6 +375,16 @@ class QueryContext : public Context {
return element_level_bitset_.has_value();
}
void
set_bitset_is_element_level(bool is_element_level) {
bitset_is_element_level_ = is_element_level;
}
bool
bitset_is_element_level() const {
return bitset_is_element_level_;
}
private:
folly::Executor* executor_;
//folly::Executor::KeepAlive<> executor_keepalive_;
@@ -412,6 +422,9 @@ class QueryContext : public Context {
std::shared_ptr<const IArrayOffsets> array_offsets_{nullptr};
int64_t active_element_count_{0}; // Total elements in active documents
std::optional<TargetBitmap> element_level_bitset_;
// Whether the current bitset has been converted to element-level
// Set by ElementFilterBitsNode after conversion, checked by VectorSearchNode
bool bitset_is_element_level_{false};
};
// Represent the state of one thread of query execution.
+35 -5
View File
@@ -1523,9 +1523,27 @@ class SegmentExpr : public Expr {
return processed_size;
}
// ProcessIndexChunks: execute index query and batch results
template <typename T, typename FUNC, typename... ValTypes>
VectorPtr
ProcessIndexChunks(FUNC func, const ValTypes&... values) {
return ProcessIndexChunksImpl<T>(func, false, values...);
}
// ProcessIndexChunks with func_returns_row_level flag
// func_returns_row_level: if true, func returns row-level bitset even for nested index
// (used when func already handles element-to-row conversion internally)
template <typename T, typename FUNC, typename... ValTypes>
VectorPtr
ProcessIndexChunksWithRowLevel(FUNC func, const ValTypes&... values) {
return ProcessIndexChunksImpl<T>(func, true, values...);
}
template <typename T, typename FUNC, typename... ValTypes>
VectorPtr
ProcessIndexChunksImpl(FUNC func,
bool func_returns_row_level,
const ValTypes&... values) {
typedef std::
conditional_t<std::is_same_v<T, std::string_view>, std::string, T>
IndexInnerType;
@@ -1568,17 +1586,29 @@ class SegmentExpr : public Expr {
cached_index_chunk_res_ = std::make_shared<TargetBitmap>(
std::move(func(index_ptr, values...)));
cached_index_chunk_valid_res_ =
std::make_shared<TargetBitmap>(index_ptr->IsNotNull());
cached_index_chunk_id_ = 0;
cached_is_nested_index_ = index_ptr->IsNestedIndex();
if (cached_is_nested_index_ && func_returns_row_level) {
// TODO(SpadeA): now, nested index is only supported for Struct which
// does not support null now.
cached_index_chunk_valid_res_ =
std::make_shared<TargetBitmap>(active_count_, true);
} else {
cached_index_chunk_valid_res_ =
std::make_shared<TargetBitmap>(index_ptr->IsNotNull());
}
}
TargetBitmap result;
TargetBitmap valid_result;
if (cached_is_nested_index_) {
// Nested index: batch by rows, return corresponding elements
// If func already returns row-level bitset, skip element-to-row conversion
bool need_element_slicing =
cached_is_nested_index_ && !func_returns_row_level;
if (need_element_slicing) {
// Nested index with element-level result: batch by rows, slice elements
auto array_offsets = segment_->GetArrayOffsets(field_id_);
auto data_pos = current_index_chunk_pos_;
@@ -1596,7 +1626,7 @@ class SegmentExpr : public Expr {
current_index_chunk_pos_ = data_pos + batch_rows;
} else {
// Normal index: batch by rows
// Normal index or row-level result: batch by rows directly
auto data_pos = current_index_chunk_pos_;
auto size =
std::min(std::min(size_per_chunk_ - data_pos, batch_size_),
@@ -14,6 +14,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
#include <boost/container/vector.hpp>
#include <folly/FBVector.h>
#include <gtest/gtest.h>
#include <algorithm>
@@ -52,6 +53,8 @@
#include "segcore/SegmentGrowingImpl.h"
#include "test_utils/DataGen.h"
#include "test_utils/GenExprProto.h"
#include "test_utils/storage_test_utils.h"
#include "test_utils/cachinglayer_test_utils.h"
using namespace milvus;
using namespace milvus::query;
@@ -1888,3 +1891,259 @@ TEST(Expr, TestTermInArray) {
}
}
}
TEST(Expr, TestArrayContainsForStruct) {
// Step 1: Prepare schema with array field
int dim = 4;
auto schema = std::make_shared<Schema>();
auto vec_fid = schema->AddDebugVectorArrayField("structA[array_float_vec]",
DataType::VECTOR_FLOAT,
dim,
knowhere::metric::L2);
auto int_array_fid = schema->AddDebugArrayField(
"structA[price_array]", DataType::INT32, false);
auto int64_fid = schema->AddDebugField("id", DataType::INT64);
schema->set_primary_field_id(int64_fid);
size_t N = 500;
int array_len = 3;
// Step 2: Generate test data
auto raw_data = DataGen(schema, N, 42, 0, 1, array_len);
// Use random values between 1-20 for array elements
std::mt19937 rng(42); // Fixed seed for reproducibility
std::uniform_int_distribution<int> dist(1, 20);
// Track which rows contain value 5 for verification
std::set<int> rows_containing_5;
for (int i = 0; i < raw_data.raw_->fields_data_size(); i++) {
auto* field_data = raw_data.raw_->mutable_fields_data(i);
if (field_data->field_id() == int_array_fid.get()) {
field_data->mutable_scalars()
->mutable_array_data()
->mutable_data()
->Clear();
for (int row = 0; row < N; row++) {
auto* array_data = field_data->mutable_scalars()
->mutable_array_data()
->mutable_data()
->Add();
for (int elem = 0; elem < array_len; elem++) {
int value = dist(rng); // Random value between 1-20
array_data->mutable_int_data()->mutable_data()->Add(value);
if (value == 5) {
rows_containing_5.insert(row);
}
}
}
break;
}
}
// Step 3: Create sealed segment with field data
auto segment = CreateSealedWithFieldDataLoaded(schema, raw_data);
// Step 4: Load vector index for element-level search
auto array_vec_values = raw_data.get_col<VectorFieldProto>(vec_fid);
// DataGen generates VECTOR_ARRAY with data in float_vector (flattened),
// not in vector_array (nested structure)
std::vector<float> vector_data(dim * N * array_len);
for (int i = 0; i < N; i++) {
const auto& float_vec = array_vec_values[i].float_vector().data();
// float_vec contains array_len * dim floats
for (int j = 0; j < array_len * dim; j++) {
vector_data[i * array_len * dim + j] = float_vec[j];
}
}
// For element-level search, index all elements (N * array_len vectors)
auto indexing = GenVecIndexing(N * array_len,
dim,
vector_data.data(),
knowhere::IndexEnum::INDEX_HNSW);
LoadIndexInfo load_index_info;
load_index_info.field_id = vec_fid.get();
load_index_info.index_params = GenIndexParams(indexing.get());
load_index_info.cache_index =
CreateTestCacheIndex("test", std::move(indexing));
load_index_info.index_params["metric_type"] = knowhere::metric::L2;
load_index_info.field_type = DataType::VECTOR_ARRAY;
load_index_info.element_type = DataType::VECTOR_FLOAT;
segment->LoadIndex(load_index_info);
int topK = 5;
ScopedSchemaHandle schema_handle(*schema);
// Step 5a: Test with array_contains_any filter
{
std::string expr = "array_contains_any(structA[price_array], [5])";
auto plan_bytes = schema_handle.ParseSearch(
expr, "structA[array_float_vec]", topK, "L2", R"({"ef": 50})", 3);
auto plan = CreateSearchPlanByExpr(
schema, plan_bytes.data(), plan_bytes.size());
ASSERT_NE(plan, nullptr);
auto num_queries = 1;
auto seed = 1024;
auto ph_group_raw =
CreatePlaceholderGroup(num_queries, dim, seed, true);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto search_result =
segment->Search(plan.get(), ph_group.get(), 1L << 63);
// Verify results
ASSERT_NE(search_result, nullptr);
// Array contains is a regular filter, not element-level search
ASSERT_FALSE(search_result->seg_offsets_.empty());
// Should have topK results per query
ASSERT_LE(search_result->seg_offsets_.size(),
static_cast<size_t>(topK * num_queries));
for (size_t i = 0; i < search_result->seg_offsets_.size(); i++) {
int64_t doc_id = search_result->seg_offsets_[i];
float distance = search_result->distances_[i];
// Verify the doc's array contains value 5 using our tracked set
ASSERT_TRUE(rows_containing_5.count(doc_id) > 0)
<< "Result doc_id " << doc_id << " should contain value 5";
}
// Verify distances are sorted (ascending for L2)
for (size_t i = 1; i < search_result->distances_.size(); ++i) {
ASSERT_LE(search_result->distances_[i - 1],
search_result->distances_[i])
<< "Distances should be sorted in ascending order";
}
}
// Step 5b: Test with array_contains_all filter
// contains_all requires the array to contain ALL given elements
{
std::string expr = "array_contains_all(structA[price_array], [5, 10])";
auto plan_bytes = schema_handle.ParseSearch(
expr, "structA[array_float_vec]", topK, "L2", R"({"ef": 50})", 3);
auto plan = CreateSearchPlanByExpr(
schema, plan_bytes.data(), plan_bytes.size());
ASSERT_NE(plan, nullptr);
auto num_queries = 1;
auto seed = 1024;
auto ph_group_raw =
CreatePlaceholderGroup(num_queries, dim, seed, true);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto search_result =
segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(search_result, nullptr);
// Verify each result's array contains BOTH 5 and 10
auto array_col =
raw_data.get_col(int_array_fid)->scalars().array_data().data();
for (size_t i = 0; i < search_result->seg_offsets_.size(); i++) {
int64_t doc_id = search_result->seg_offsets_[i];
float distance = search_result->distances_[i];
auto& arr = array_col[doc_id].int_data().data();
bool has_5 = std::find(arr.begin(), arr.end(), 5) != arr.end();
bool has_10 = std::find(arr.begin(), arr.end(), 10) != arr.end();
ASSERT_TRUE(has_5 && has_10) << "Result doc_id " << doc_id
<< " should contain both 5 and 10";
}
for (size_t i = 1; i < search_result->distances_.size(); ++i) {
ASSERT_LE(search_result->distances_[i - 1],
search_result->distances_[i])
<< "Distances should be sorted in ascending order";
}
}
// Step 6: Test with scalar index on price_array field
{
// Get array data from raw_data and convert to boost::container::vector format
// (required by InvertedIndexTantivy::BuildWithRawDataForUT)
auto array_col =
raw_data.get_col(int_array_fid)->scalars().array_data().data();
std::vector<boost::container::vector<int32_t>> vec_of_array;
vec_of_array.reserve(N);
for (size_t i = 0; i < N; i++) {
boost::container::vector<int32_t> arr;
for (size_t j = 0; j < array_col[i].int_data().data_size(); j++) {
arr.push_back(array_col[i].int_data().data(j));
}
vec_of_array.push_back(arr);
}
// Build inverted index using simplified API
auto arr_index =
std::make_unique<index::InvertedIndexTantivy<int32_t>>();
Config cfg;
cfg["is_array"] = true;
cfg["is_nested_index"] = true;
arr_index->BuildWithRawDataForUT(N, vec_of_array.data(), cfg);
// Load index into segment
LoadIndexInfo arr_index_info;
arr_index_info.field_id = int_array_fid.get();
arr_index_info.index_params = GenIndexParams(arr_index.get());
arr_index_info.cache_index =
CreateTestCacheIndex("test_array", std::move(arr_index));
segment->LoadIndex(arr_index_info);
// Now search with index
std::string expr = "array_contains_any(structA[price_array], [5])";
auto plan_bytes = schema_handle.ParseSearch(
expr, "structA[array_float_vec]", topK, "L2", R"({"ef": 50})", 3);
auto plan = CreateSearchPlanByExpr(
schema, plan_bytes.data(), plan_bytes.size());
ASSERT_NE(plan, nullptr);
auto num_queries = 1;
auto seed = 1024;
auto ph_group_raw =
CreatePlaceholderGroup(num_queries, dim, seed, true);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto search_result =
segment->Search(plan.get(), ph_group.get(), 1L << 63);
// Verify results with index
ASSERT_NE(search_result, nullptr);
ASSERT_FALSE(search_result->seg_offsets_.empty());
ASSERT_LE(search_result->seg_offsets_.size(),
static_cast<size_t>(topK * num_queries));
for (size_t i = 0; i < search_result->seg_offsets_.size(); i++) {
int64_t doc_id = search_result->seg_offsets_[i];
float distance = search_result->distances_[i];
// Verify the doc's array contains value 5
ASSERT_TRUE(rows_containing_5.count(doc_id) > 0)
<< "Result doc_id " << doc_id
<< " should contain value 5 (with index)";
}
// Verify distances are sorted (ascending for L2)
for (size_t i = 1; i < search_result->distances_.size(); ++i) {
ASSERT_LE(search_result->distances_[i - 1],
search_result->distances_[i])
<< "Distances should be sorted in ascending order (with index)";
}
}
}
@@ -2120,31 +2120,60 @@ PhyJsonContainsFilterExpr::ExecArrayContainsForIndexSegmentImpl() {
elements.insert(GetValueWithCastNumber<GetType>(element));
}
boost::container::vector<GetType> elems(elements.begin(), elements.end());
// Get array offsets for nested index (needed for element-to-row conversion)
auto array_offsets = segment_->GetArrayOffsets(expr_->column_.field_id_);
auto execute_sub_batch =
[this](Index* index_ptr,
const boost::container::vector<GetType>& vals) {
switch (expr_->op_) {
case proto::plan::JSONContainsExpr_JSONOp_Contains:
case proto::plan::JSONContainsExpr_JSONOp_ContainsAny: {
return index_ptr->In(vals.size(), vals.data());
}
case proto::plan::JSONContainsExpr_JSONOp_ContainsAll: {
TargetBitmap result(index_ptr->Count());
result.set();
for (size_t i = 0; i < vals.size(); i++) {
auto sub = index_ptr->In(1, &vals[i]);
result &= sub;
}
return result;
}
default:
ThrowInfo(
ExprInvalid,
"unsupported array contains type {}",
proto::plan::JSONContainsExpr_JSONOp_Name(expr_->op_));
[this, &array_offsets](
Index* index_ptr,
const boost::container::vector<GetType>& vals) -> TargetBitmap {
// Query helper: for nested index, convert element-level to row-level
auto query_in = [&](size_t n, const GetType* data) -> TargetBitmap {
auto element_bitset = index_ptr->In(n, data);
if (!index_ptr->IsNestedIndex()) {
return element_bitset;
}
AssertInfo(array_offsets != nullptr,
"array offsets not found for field {}",
expr_->column_.field_id_.get());
return array_offsets->ForEachRowElementRange(
[&element_bitset](int32_t elem_start, int32_t elem_end) {
for (int32_t i = elem_start; i < elem_end; ++i) {
if (element_bitset[i]) {
return true;
}
}
return false;
},
0,
active_count_);
};
auto res = ProcessIndexChunks<GetType>(execute_sub_batch, elems);
switch (expr_->op_) {
case proto::plan::JSONContainsExpr_JSONOp_Contains:
case proto::plan::JSONContainsExpr_JSONOp_ContainsAny:
return query_in(vals.size(), vals.data());
case proto::plan::JSONContainsExpr_JSONOp_ContainsAll: {
TargetBitmap result(active_count_);
result.set();
for (size_t i = 0; i < vals.size(); i++) {
result &= query_in(1, &vals[i]);
}
return result;
}
default:
ThrowInfo(
ExprInvalid,
"unsupported array contains type {}",
proto::plan::JSONContainsExpr_JSONOp_Name(expr_->op_));
}
};
// Use WithRowLevel version since func handles element-to-row conversion for nested index
auto res =
ProcessIndexChunksWithRowLevel<GetType>(execute_sub_batch, elems);
AssertInfo(res->size() == real_batch_size,
"internal error: expr processed rows {} not equal "
"expect batch size {}",
@@ -120,6 +120,8 @@ PhyElementFilterBitsNode::GetOutput() {
// Step 4: Set query context
query_context_->set_element_level_query(true);
query_context_->set_struct_name(struct_name_);
// Mark that bitset has been converted to element-level
query_context_->set_bitset_is_element_level(true);
std::chrono::high_resolution_clock::time_point end_time =
std::chrono::high_resolution_clock::now();
@@ -118,6 +118,7 @@ PhyFilterBitsNode::GetOutput() {
}
}
bitset.flip();
AssertInfo(bitset.size() == need_process_rows_,
"bitset size: {}, need_process_rows_: {}",
bitset.size(),
@@ -115,8 +115,10 @@ PhyVectorSearchNode::GetOutput() {
//
// When embedding search embedding on embedding list is used, which means element_level_ is true, we need to transform doc-level
// bitset to element-level bitset. In pre-filter path, ElementFilterBitsNode already transforms the bitset. We need to transform it
// in iterative filter path.
if (milvus::exec::UseVectorIterator(search_info_) && ph.element_level_) {
// in iterative filter path or when ElementFilterBitsNode is not present (e.g., only doc-level filter without element-level filter).
//
// Check if bitset needs conversion: element_level search is requested but bitset hasn't been converted yet
if (ph.element_level_ && !query_context_->bitset_is_element_level()) {
auto col_input = GetColumnVector(input_);
TargetBitmapView view(col_input->GetRawData(), col_input->size());
TargetBitmapView valid_view(col_input->GetValidRawData(),
@@ -339,6 +339,13 @@ InvertedIndexTantivy<T>::IsNull() {
int64_t count = Count();
TargetBitmap bitset(count);
// For nested index, null rows don't have elements in the index,
// so all elements in the index are valid (none are null).
// null_offset_ stores row offsets, not element offsets.
if (is_nested_index_) {
return bitset; // All false - no element is null
}
auto fill_bitset = [this, count, &bitset]() {
auto end =
std::lower_bound(null_offset_.begin(), null_offset_.end(), count);
@@ -365,6 +372,13 @@ InvertedIndexTantivy<T>::IsNotNull() {
int64_t count = Count();
TargetBitmap bitset(count, true);
// For nested index, null rows don't have elements in the index,
// so all elements in the index are valid.
// null_offset_ stores row offsets, not element offsets.
if (is_nested_index_) {
return bitset; // All true - all elements are valid
}
auto fill_bitset = [this, count, &bitset]() {
auto end =
std::lower_bound(null_offset_.begin(), null_offset_.end(), count);
+2
View File
@@ -9,6 +9,7 @@ expr:
| StringLiteral # String
| (Identifier|Meta) # Identifier
| JSONIdentifier # JSONIdentifier
| StructFieldIdentifier # StructField
| StructSubFieldIdentifier # StructSubField
| LBRACE Identifier RBRACE # TemplateVariable
| '(' expr ')' # Parens
@@ -139,6 +140,7 @@ Meta: '$meta';
StringLiteral: EncodingPrefix? ('"' DoubleSCharSequence? '"' | '\'' SingleSCharSequence? '\'');
JSONIdentifier: (Identifier | Meta)('[' (StringLiteral | DecimalConstant) ']')+;
StructFieldIdentifier: Identifier '[' Identifier ']';
StructSubFieldIdentifier: '$[' Identifier ']';
fragment EncodingPrefix: 'u8' | 'u' | 'U' | 'L';
File diff suppressed because one or more lines are too long
@@ -69,9 +69,10 @@ Identifier=68
Meta=69
StringLiteral=70
JSONIdentifier=71
StructSubFieldIdentifier=72
Whitespace=73
Newline=74
StructFieldIdentifier=72
StructSubFieldIdentifier=73
Whitespace=74
Newline=75
'('=1
')'=2
'['=3
File diff suppressed because one or more lines are too long
@@ -69,9 +69,10 @@ Identifier=68
Meta=69
StringLiteral=70
JSONIdentifier=71
StructSubFieldIdentifier=72
Whitespace=73
Newline=74
StructFieldIdentifier=72
StructSubFieldIdentifier=73
Whitespace=74
Newline=75
'('=1
')'=2
'['=3
@@ -51,6 +51,10 @@ func (v *BasePlanVisitor) VisitIdentifier(ctx *IdentifierContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BasePlanVisitor) VisitStructField(ctx *StructFieldContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BasePlanVisitor) VisitLike(ctx *LikeContext) interface{} {
return v.VisitChildren(ctx)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -40,6 +40,9 @@ type PlanVisitor interface {
// Visit a parse tree produced by PlanParser#Identifier.
VisitIdentifier(ctx *IdentifierContext) interface{}
// Visit a parse tree produced by PlanParser#StructField.
VisitStructField(ctx *StructFieldContext) interface{}
// Visit a parse tree produced by PlanParser#Like.
VisitLike(ctx *LikeContext) interface{}
@@ -1400,6 +1400,34 @@ func (v *ParserVisitor) VisitJSONIdentifier(ctx *parser.JSONIdentifierContext) i
}
}
// VisitStructField handles struct_array[sub_field] syntax for struct sub-field access.
func (v *ParserVisitor) VisitStructField(ctx *parser.StructFieldContext) interface{} {
// Get the full identifier text, e.g., "struct_array[sub_int]"
identifier := ctx.StructFieldIdentifier().GetText()
// Look up the field directly by its full name
field, err := v.schema.GetFieldFromName(identifier)
if err != nil {
return fmt.Errorf("struct field not found: %s, error: %s", identifier, err)
}
return &ExprWithType{
expr: &planpb.Expr{
Expr: &planpb.Expr_ColumnExpr{
ColumnExpr: &planpb.ColumnExpr{
Info: &planpb.ColumnInfo{
FieldId: field.FieldID,
DataType: field.DataType,
ElementType: field.GetElementType(),
},
},
},
},
dataType: field.DataType,
nodeDependent: true,
}
}
func (v *ParserVisitor) VisitExists(ctx *parser.ExistsContext) interface{} {
child := ctx.Expr().Accept(v)
if err := getError(child); err != nil {
@@ -2717,6 +2717,22 @@ func TestExpr_Match(t *testing.T) {
}
}
func TestExpr_ArrayContains(t *testing.T) {
schema := newTestSchema(true)
helper, err := typeutil.CreateSchemaHelper(schema)
assert.NoError(t, err)
// Valid ArrayContains expressions
validExprs := []string{
`array_contains(struct_array[sub_int], 1)`,
`array_contains(struct_array[sub_int], 1) && array_contains(struct_array[sub_int], 2)`,
}
for _, expr := range validExprs {
assertValidExpr(t, helper, expr)
}
}
// ============================================================================
// Timestamptz Expression Tests
// These tests cover VisitTimestamptzCompareForward and VisitTimestamptzCompareReverse