Skip to main content

lance_index/scalar/
json.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use lance_core::utils::row_addr_remap::RowAddrRemap;
5use std::{
6    ops::Bound,
7    sync::{Arc, Mutex},
8};
9
10use arrow_array::{Array, LargeBinaryArray, RecordBatch, StructArray, UInt8Array};
11use arrow_schema::{DataType, Field, Field as ArrowField, Schema, SortOptions};
12use async_trait::async_trait;
13use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
14use datafusion::{
15    execution::SendableRecordBatchStream,
16    physical_plan::{ExecutionPlan, projection::ProjectionExec, sorts::sort::SortExec},
17};
18use datafusion_common::{ScalarValue, config::ConfigOptions};
19use datafusion_expr::{Expr, Operator, ScalarUDF};
20use datafusion_physical_expr::{
21    PhysicalExpr, PhysicalSortExpr, ScalarFunctionExpr,
22    expressions::{Column, Literal},
23};
24use futures::StreamExt;
25use lance_core::deepsize::DeepSizeOf;
26use lance_datafusion::exec::{
27    LanceExecutionOptions, OneShotExec, execute_plan, get_session_context,
28};
29use lance_datafusion::udf::json::JsonbType;
30use prost::Message;
31use roaring::RoaringBitmap;
32use serde::{Deserialize, Serialize};
33
34use lance_core::{Error, ROW_ID, Result, cache::LanceCache, error::LanceOptionExt};
35
36use crate::{
37    Index, IndexType,
38    metrics::MetricsCollector,
39    registry::IndexPluginRegistry,
40    scalar::{
41        AnyQuery, CreatedIndex, IndexStore, RowIdRemapper, ScalarIndex, SearchResult,
42        UpdateCriteria,
43        expression::{IndexedExpression, ScalarIndexExpr, ScalarIndexSearch, ScalarQueryParser},
44        registry::{
45            BasicTrainer, ScalarIndexPlugin, TrainingCriteria, TrainingOrdering, TrainingRequest,
46            VALUE_COLUMN_NAME,
47        },
48    },
49};
50
51const JSON_INDEX_VERSION: u32 = 0;
52
53/// A JSON index that indexes a field in a JSON column
54///
55/// The underlying index can be any other type of scalar index
56#[derive(Debug)]
57pub struct JsonIndex {
58    target_index: Arc<dyn ScalarIndex>,
59    path: String,
60}
61
62impl JsonIndex {
63    pub fn new(target_index: Arc<dyn ScalarIndex>, path: String) -> Self {
64        Self { target_index, path }
65    }
66}
67
68impl DeepSizeOf for JsonIndex {
69    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
70        self.target_index.deep_size_of_children(context) + self.path.deep_size_of_children(context)
71    }
72}
73
74#[async_trait]
75impl Index for JsonIndex {
76    fn as_any(&self) -> &dyn std::any::Any {
77        self
78    }
79
80    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
81        self
82    }
83
84    fn index_type(&self) -> IndexType {
85        // TODO: This causes the index to appear as btree in list_indices call.  Need better logic
86        // in list_indices to use details instead of index_type.
87        IndexType::Scalar
88    }
89
90    async fn prewarm(&self) -> Result<()> {
91        self.target_index.prewarm().await
92    }
93
94    fn statistics(&self) -> Result<serde_json::Value> {
95        todo!()
96    }
97
98    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
99        self.target_index.calculate_included_frags().await
100    }
101}
102
103#[async_trait]
104impl ScalarIndex for JsonIndex {
105    async fn search(
106        &self,
107        query: &dyn AnyQuery,
108        metrics: &dyn MetricsCollector,
109    ) -> Result<SearchResult> {
110        let query = query.as_any().downcast_ref::<JsonQuery>().unwrap();
111        self.target_index
112            .search(query.target_query.as_ref(), metrics)
113            .await
114    }
115
116    fn can_remap(&self) -> bool {
117        self.target_index.can_remap()
118    }
119
120    async fn remap(
121        &self,
122        mapping: &RowAddrRemap,
123        dest_store: &dyn IndexStore,
124    ) -> Result<CreatedIndex> {
125        let target_created = self.target_index.remap(mapping, dest_store).await?;
126        let json_details = crate::pb::JsonIndexDetails {
127            path: self.path.clone(),
128            target_details: Some(target_created.index_details),
129        };
130        Ok(CreatedIndex {
131            index_details: prost_types::Any::from_msg(&json_details)?,
132            // TODO: We should store the target index version in the details
133            index_version: JSON_INDEX_VERSION,
134            files: target_created.files,
135        })
136    }
137
138    async fn update(
139        &self,
140        new_data: SendableRecordBatchStream,
141        dest_store: &dyn IndexStore,
142        old_data_filter: Option<super::OldIndexDataFilter>,
143    ) -> Result<CreatedIndex> {
144        let target_created = self
145            .target_index
146            .update(new_data, dest_store, old_data_filter)
147            .await?;
148        let json_details = crate::pb::JsonIndexDetails {
149            path: self.path.clone(),
150            target_details: Some(target_created.index_details),
151        };
152        Ok(CreatedIndex {
153            index_details: prost_types::Any::from_msg(&json_details)?,
154            // TODO: We should store the target index version in the details
155            index_version: JSON_INDEX_VERSION,
156            files: target_created.files,
157        })
158    }
159
160    fn update_criteria(&self) -> UpdateCriteria {
161        self.target_index.update_criteria()
162    }
163
164    fn derive_index_params(&self) -> Result<super::ScalarIndexParams> {
165        self.target_index.derive_index_params()
166    }
167}
168
169/// Parameters for a [`JsonIndex`]
170#[derive(Debug, Serialize, Deserialize)]
171pub struct JsonIndexParameters {
172    target_index_type: String,
173    target_index_parameters: Option<String>,
174    path: String,
175}
176
177// TODO: Do we really need to wrap the query or could we just return the target query directly?
178//
179// I think the only thing we really gain is a different format impl (e.g. it shows up as a json query
180// in the explain plan) but I don't know if that helps the user much.
181#[derive(Debug, Clone)]
182pub struct JsonQuery {
183    target_query: Arc<dyn AnyQuery>,
184    path: String,
185}
186
187impl JsonQuery {
188    pub fn new(target_query: Arc<dyn AnyQuery>, path: String) -> Self {
189        Self { target_query, path }
190    }
191}
192
193impl PartialEq for JsonQuery {
194    fn eq(&self, other: &Self) -> bool {
195        self.target_query.dyn_eq(other.target_query.as_ref()) && self.path == other.path
196    }
197}
198
199impl AnyQuery for JsonQuery {
200    fn as_any(&self) -> &dyn std::any::Any {
201        self
202    }
203
204    fn format(&self, col: &str) -> String {
205        format!("Json({}->{})", self.target_query.format(col), self.path)
206    }
207
208    fn to_expr(&self, _col: String) -> Expr {
209        todo!()
210    }
211
212    fn dyn_eq(&self, other: &dyn AnyQuery) -> bool {
213        match other.as_any().downcast_ref::<Self>() {
214            Some(o) => self == o,
215            None => false,
216        }
217    }
218}
219
220#[derive(Debug)]
221pub struct JsonQueryParser {
222    path: String,
223    target_parser: Box<dyn ScalarQueryParser>,
224}
225
226impl JsonQueryParser {
227    pub fn new(path: String, target_parser: Box<dyn ScalarQueryParser>) -> Self {
228        Self {
229            path,
230            target_parser,
231        }
232    }
233
234    fn wrap_search(&self, target_expr: IndexedExpression) -> IndexedExpression {
235        if let Some(scalar_query) = target_expr.scalar_query {
236            let scalar_query = match scalar_query {
237                ScalarIndexExpr::Query(ScalarIndexSearch {
238                    column,
239                    index_name,
240                    index_type,
241                    query,
242                    needs_recheck,
243                    fragment_bitmap,
244                }) => ScalarIndexExpr::Query(ScalarIndexSearch {
245                    column,
246                    index_name,
247                    index_type,
248                    query: Arc::new(JsonQuery::new(query, self.path.clone())),
249                    needs_recheck,
250                    fragment_bitmap,
251                }),
252                // This code path should only be hit on leaf expr
253                _ => unreachable!(),
254            };
255            IndexedExpression {
256                scalar_query: Some(scalar_query),
257                refine_expr: target_expr.refine_expr,
258            }
259        } else {
260            target_expr
261        }
262    }
263}
264
265impl ScalarQueryParser for JsonQueryParser {
266    fn visit_between(
267        &self,
268        column: &str,
269        low: &Bound<ScalarValue>,
270        high: &Bound<ScalarValue>,
271    ) -> Option<IndexedExpression> {
272        self.target_parser
273            .visit_between(column, low, high)
274            .map(|target_expr| self.wrap_search(target_expr))
275    }
276    fn visit_in_list(&self, column: &str, in_list: &[ScalarValue]) -> Option<IndexedExpression> {
277        self.target_parser
278            .visit_in_list(column, in_list)
279            .map(|target_expr| self.wrap_search(target_expr))
280    }
281    fn visit_is_bool(&self, column: &str, value: bool) -> Option<IndexedExpression> {
282        self.target_parser
283            .visit_is_bool(column, value)
284            .map(|target_expr| self.wrap_search(target_expr))
285    }
286    fn visit_is_null(&self, column: &str) -> Option<IndexedExpression> {
287        self.target_parser
288            .visit_is_null(column)
289            .map(|target_expr| self.wrap_search(target_expr))
290    }
291    fn visit_comparison(
292        &self,
293        column: &str,
294        value: &ScalarValue,
295        op: &Operator,
296    ) -> Option<IndexedExpression> {
297        self.target_parser
298            .visit_comparison(column, value, op)
299            .map(|target_expr| self.wrap_search(target_expr))
300    }
301    fn visit_scalar_function(
302        &self,
303        column: &str,
304        data_type: &DataType,
305        func: &ScalarUDF,
306        args: &[Expr],
307    ) -> Option<IndexedExpression> {
308        self.target_parser
309            .visit_scalar_function(column, data_type, func, args)
310            .map(|target_expr| self.wrap_search(target_expr))
311    }
312
313    // TODO: maybe we should address it by https://github.com/lance-format/lance/issues/4624
314    fn is_valid_reference(&self, func: &Expr, _data_type: &DataType) -> Option<DataType> {
315        match func {
316            Expr::ScalarFunction(udf) => {
317                // Support multiple JSON extraction functions
318                let json_functions = [
319                    "json_extract",
320                    "json_get",
321                    "json_get_int",
322                    "json_get_float",
323                    "json_get_bool",
324                    "json_get_string",
325                ];
326                if !json_functions.contains(&udf.name()) {
327                    return None;
328                }
329                if udf.args.len() != 2 {
330                    return None;
331                }
332                // We already know index 0 is a column reference to the column so we just need to
333                // ensure that index 1 matches our path
334                match &udf.args[1] {
335                    Expr::Literal(ScalarValue::Utf8(Some(path)), _) => {
336                        if path == &self.path {
337                            // Return the appropriate type based on the function
338                            match udf.name() {
339                                "json_get_int" => Some(DataType::Int64),
340                                "json_get_float" => Some(DataType::Float64),
341                                "json_get_bool" => Some(DataType::Boolean),
342                                "json_get_string" | "json_extract" => Some(DataType::Utf8),
343                                _ => None,
344                            }
345                        } else {
346                            None
347                        }
348                    }
349                    _ => None,
350                }
351            }
352            _ => None,
353        }
354    }
355}
356
357pub struct JsonTrainingRequest {
358    parameters: JsonIndexParameters,
359    target_request: Box<dyn TrainingRequest>,
360    criteria: TrainingCriteria,
361}
362
363impl JsonTrainingRequest {
364    pub fn new(parameters: JsonIndexParameters, target_request: Box<dyn TrainingRequest>) -> Self {
365        let target_criteria = target_request.criteria();
366        // The scanner can only sort its output by the raw JSON column, not by the value
367        // at `path` that this plugin extracts from it, so a `Values`-ordered scan here
368        // would sort by the wrong key and still need re-sorting after extraction. Ask
369        // for unordered input instead and let `train_index` sort the extracted value
370        // stream itself, once, right before handing it to the target trainer.
371        //
372        // This is safe for `Addresses` too: `scan_training_data` only special-cases
373        // `Values` (it calls `order_by` only then); an `Addresses` or `None` criteria
374        // both fall through to the same unordered-scan behavior, since the scan already
375        // returns rows in row-address order by default.
376        let mut criteria = TrainingCriteria::new(TrainingOrdering::None);
377        criteria.needs_row_ids = target_criteria.needs_row_ids;
378        criteria.needs_row_addrs = target_criteria.needs_row_addrs;
379        Self {
380            parameters,
381            target_request,
382            criteria,
383        }
384    }
385}
386
387impl TrainingRequest for JsonTrainingRequest {
388    fn as_any(&self) -> &dyn std::any::Any {
389        self
390    }
391
392    fn criteria(&self) -> &TrainingCriteria {
393        &self.criteria
394    }
395}
396
397/// Plugin implementation for a [`JsonIndex`]
398#[derive(Default)]
399pub struct JsonIndexPlugin {
400    registry: Mutex<Option<Arc<IndexPluginRegistry>>>,
401}
402
403impl std::fmt::Debug for JsonIndexPlugin {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        write!(f, "JsonIndexPlugin")
406    }
407}
408
409impl JsonIndexPlugin {
410    fn registry(&self) -> Result<Arc<IndexPluginRegistry>> {
411        Ok(self.registry.lock().unwrap().as_ref().expect_ok()?.clone())
412    }
413
414    /// Extract JSON with type information using the new UDF
415    async fn extract_json_with_type_info(
416        data: SendableRecordBatchStream,
417        path: String,
418    ) -> Result<(SendableRecordBatchStream, DataType)> {
419        let input = Arc::new(OneShotExec::new(data));
420        let input_schema = input.schema();
421        let value_column_idx = input_schema
422            .column_with_name(VALUE_COLUMN_NAME)
423            .expect_ok()?
424            .0;
425        let row_id_column_idx = input_schema.column_with_name(ROW_ID).expect_ok()?.0;
426
427        // Call json_extract_with_type UDF
428        let exprs = vec![
429            (
430                Arc::new(ScalarFunctionExpr::try_new(
431                    Arc::new(lance_datafusion::udf::json::json_extract_with_type_udf()),
432                    vec![
433                        Arc::new(Column::new(VALUE_COLUMN_NAME, value_column_idx)),
434                        Arc::new(Literal::new(ScalarValue::Utf8(Some(path)))),
435                    ],
436                    &input_schema,
437                    Arc::new(ConfigOptions::default()),
438                )?) as Arc<dyn PhysicalExpr>,
439                "json_result".to_string(),
440            ),
441            (
442                Arc::new(Column::new(ROW_ID, row_id_column_idx)) as Arc<dyn PhysicalExpr>,
443                ROW_ID.to_string(),
444            ),
445        ];
446
447        let project = ProjectionExec::try_new(exprs, input)?;
448        let ctx = get_session_context(&LanceExecutionOptions::default());
449        let mut stream = project.execute(0, ctx.task_ctx())?;
450
451        // Collect batches and determine type from first non-null value
452        let mut all_batches = Vec::new();
453        let mut inferred_type: Option<DataType> = None;
454
455        while let Some(batch_result) = stream.next().await {
456            let batch = batch_result?;
457
458            // Determine type from first non-null value if not yet set
459            if inferred_type.is_none()
460                && let Some(json_result_column) = batch.column_by_name("json_result")
461                && let Some(struct_array) =
462                    json_result_column.as_any().downcast_ref::<StructArray>()
463                && let Some(type_array) = struct_array.column_by_name("type_tag")
464                && let Some(uint8_array) = type_array.as_any().downcast_ref::<UInt8Array>()
465            {
466                // Find first non-null value to determine type
467                for i in 0..uint8_array.len() {
468                    if !uint8_array.is_null(i) {
469                        let type_tag = uint8_array.value(i);
470                        let jsonb_type = JsonbType::from_u8(type_tag).ok_or_else(|| {
471                            Error::invalid_input_source(
472                                format!("Invalid type tag: {}", type_tag).into(),
473                            )
474                        })?;
475
476                        // Map JsonbType to Arrow DataType
477                        inferred_type = Some(match jsonb_type {
478                            JsonbType::Null => continue, // Skip null values
479                            JsonbType::Boolean => DataType::Boolean,
480                            JsonbType::Int64 => DataType::Int64,
481                            JsonbType::Float64 => DataType::Float64,
482                            JsonbType::String => DataType::Utf8,
483                            JsonbType::Array => DataType::LargeBinary,
484                            JsonbType::Object => DataType::LargeBinary,
485                        });
486                        break;
487                    }
488                }
489            }
490
491            all_batches.push(batch);
492        }
493
494        // If no type was inferred (all nulls), default to String
495        let inferred_type = inferred_type.unwrap_or(DataType::Utf8);
496
497        // Recreate stream from collected batches
498        let schema = all_batches
499            .first()
500            .map(|b| b.schema())
501            .ok_or_else(|| Error::invalid_input_source("No batches in stream".into()))?;
502
503        let recreated_stream = Box::pin(RecordBatchStreamAdapter::new(
504            schema,
505            futures::stream::iter(all_batches.into_iter().map(Ok)),
506        )) as SendableRecordBatchStream;
507
508        Ok((recreated_stream, inferred_type))
509    }
510
511    /// Convert the stream with JSONB values and type tags to properly typed values
512    async fn convert_stream_by_type(
513        data: SendableRecordBatchStream,
514        target_type: DataType,
515    ) -> Result<SendableRecordBatchStream> {
516        let input = Arc::new(OneShotExec::new(data));
517        let _input_schema = input.schema();
518        let ctx = get_session_context(&LanceExecutionOptions::default());
519        let mut stream = input.execute(0, ctx.task_ctx())?;
520
521        let mut converted_batches = Vec::new();
522
523        while let Some(batch_result) = stream.next().await {
524            let batch = batch_result?;
525
526            // Extract the struct column containing value and type_tag
527            let json_result_column = batch
528                .column_by_name("json_result")
529                .ok_or_else(|| Error::invalid_input_source("Missing json_result column".into()))?;
530
531            let struct_array = json_result_column
532                .as_any()
533                .downcast_ref::<StructArray>()
534                .ok_or_else(|| Error::invalid_input_source("json_result is not a struct".into()))?;
535
536            let value_array = struct_array.column_by_name("value").ok_or_else(|| {
537                Error::invalid_input_source("Missing value column in struct".into())
538            })?;
539
540            let binary_array = value_array
541                .as_any()
542                .downcast_ref::<LargeBinaryArray>()
543                .ok_or_else(|| Error::invalid_input_source("value is not LargeBinary".into()))?;
544
545            // Convert based on target type using serde deserialization
546            let converted_array: Arc<dyn Array> =
547                match target_type {
548                    DataType::Boolean => {
549                        let mut builder =
550                            arrow_array::builder::BooleanBuilder::with_capacity(binary_array.len());
551                        for i in 0..binary_array.len() {
552                            if binary_array.is_null(i) {
553                                builder.append_null();
554                            } else if let Some(bytes) = binary_array.value(i).into() {
555                                let raw_jsonb = jsonb::RawJsonb::new(bytes);
556                                // Try to deserialize directly to bool
557                                match jsonb::from_raw_jsonb::<bool>(&raw_jsonb) {
558                                    Ok(bool_val) => builder.append_value(bool_val),
559                                    Err(e) => {
560                                        return Err(Error::invalid_input_source(format!(
561                                        "Failed to deserialize JSONB to bool at index {}: {}",
562                                        i, e
563                                    )
564                                    .into()));
565                                    }
566                                }
567                            } else {
568                                builder.append_null();
569                            }
570                        }
571                        Arc::new(builder.finish())
572                    }
573                    DataType::Int64 => {
574                        let mut builder =
575                            arrow_array::builder::Int64Builder::with_capacity(binary_array.len());
576                        for i in 0..binary_array.len() {
577                            if binary_array.is_null(i) {
578                                builder.append_null();
579                            } else if let Some(bytes) = binary_array.value(i).into() {
580                                let raw_jsonb = jsonb::RawJsonb::new(bytes);
581                                // Try to deserialize directly to i64
582                                match jsonb::from_raw_jsonb::<i64>(&raw_jsonb) {
583                                    Ok(int_val) => builder.append_value(int_val),
584                                    Err(e) => {
585                                        return Err(Error::invalid_input_source(format!(
586                                        "Failed to deserialize JSONB to i64 at index {}: {}",
587                                        i, e
588                                    )
589                                    .into()));
590                                    }
591                                }
592                            } else {
593                                builder.append_null();
594                            }
595                        }
596                        Arc::new(builder.finish())
597                    }
598                    DataType::Float64 => {
599                        let mut builder =
600                            arrow_array::builder::Float64Builder::with_capacity(binary_array.len());
601                        for i in 0..binary_array.len() {
602                            if binary_array.is_null(i) {
603                                builder.append_null();
604                            } else if let Some(bytes) = binary_array.value(i).into() {
605                                let raw_jsonb = jsonb::RawJsonb::new(bytes);
606                                // Try to deserialize directly to f64 (serde handles int->float conversion)
607                                match jsonb::from_raw_jsonb::<f64>(&raw_jsonb) {
608                                    Ok(float_val) => builder.append_value(float_val),
609                                    Err(e) => {
610                                        return Err(Error::invalid_input_source(format!(
611                                        "Failed to deserialize JSONB to f64 at index {}: {}",
612                                        i, e
613                                    )
614                                    .into()));
615                                    }
616                                }
617                            } else {
618                                builder.append_null();
619                            }
620                        }
621                        Arc::new(builder.finish())
622                    }
623                    DataType::Utf8 => {
624                        let mut builder = arrow_array::builder::StringBuilder::with_capacity(
625                            binary_array.len(),
626                            1024,
627                        );
628                        for i in 0..binary_array.len() {
629                            if binary_array.is_null(i) {
630                                builder.append_null();
631                            } else if let Some(bytes) = binary_array.value(i).into() {
632                                let raw_jsonb = jsonb::RawJsonb::new(bytes);
633                                // Try to deserialize to String, or use to_string() for any type
634                                match jsonb::from_raw_jsonb::<String>(&raw_jsonb) {
635                                    Ok(str_val) => builder.append_value(&str_val),
636                                    Err(_) => {
637                                        // For non-string types, convert to string representation
638                                        builder.append_value(raw_jsonb.to_string());
639                                    }
640                                }
641                            } else {
642                                builder.append_null();
643                            }
644                        }
645                        Arc::new(builder.finish())
646                    }
647                    DataType::LargeBinary => {
648                        // Keep as binary for array/object types
649                        value_array.clone()
650                    }
651                    _ => {
652                        return Err(Error::invalid_input_source(
653                            format!("Unsupported target type: {:?}", target_type).into(),
654                        ));
655                    }
656                };
657
658            // Get row_id column
659            let row_id_column = batch
660                .column_by_name(ROW_ID)
661                .ok_or_else(|| Error::invalid_input_source("Missing row_id column".into()))?
662                .clone();
663
664            // Create new batch with converted values
665            let new_schema = Arc::new(Schema::new(vec![
666                ArrowField::new(VALUE_COLUMN_NAME, target_type.clone(), true),
667                ArrowField::new(ROW_ID, DataType::UInt64, false),
668            ]));
669
670            let new_batch =
671                RecordBatch::try_new(new_schema.clone(), vec![converted_array, row_id_column])?;
672
673            converted_batches.push(new_batch);
674        }
675
676        // Create stream from converted batches
677        let schema = converted_batches
678            .first()
679            .map(|b| b.schema())
680            .ok_or_else(|| Error::invalid_input_source("No batches to convert".into()))?;
681
682        Ok(Box::pin(RecordBatchStreamAdapter::new(
683            schema,
684            futures::stream::iter(converted_batches.into_iter().map(Ok)),
685        )))
686    }
687
688    /// Sort a `(value, row_id)` stream ascending by value.
689    ///
690    /// Target index types that require `TrainingOrdering::Values` (e.g. btree, whose
691    /// per-page min/max stats are taken from the first/last row of each page) need this
692    /// as the only sort in the JSON training path: `JsonTrainingRequest` requests
693    /// unordered input from the scanner, since the scanner can only sort on the raw
694    /// JSON column, not on the value at `path`.
695    async fn sort_stream_by_value(
696        data: SendableRecordBatchStream,
697    ) -> Result<SendableRecordBatchStream> {
698        let input = Arc::new(OneShotExec::new(data));
699        let value_idx = input.schema().index_of(VALUE_COLUMN_NAME)?;
700        let sort_expr = PhysicalSortExpr {
701            expr: Arc::new(Column::new(VALUE_COLUMN_NAME, value_idx)),
702            options: SortOptions {
703                descending: false,
704                nulls_first: true,
705            },
706        };
707        let plan = Arc::new(SortExec::new([sort_expr].into(), input));
708        execute_plan(
709            plan,
710            LanceExecutionOptions {
711                use_spilling: true,
712                ..Default::default()
713            },
714        )
715    }
716}
717
718#[async_trait]
719impl BasicTrainer for JsonIndexPlugin {
720    fn new_training_request(
721        &self,
722        params: &str,
723        field: &Field,
724    ) -> Result<Box<dyn TrainingRequest>> {
725        if !matches!(field.data_type(), DataType::Binary | DataType::LargeBinary) {
726            return Err(Error::invalid_input_source(
727                "A JSON index can only be created on a Binary or LargeBinary field.".into(),
728            ));
729        }
730
731        // Initially use Utf8, will be refined during training with type inference
732        let target_type = DataType::Utf8;
733
734        let params = serde_json::from_str::<JsonIndexParameters>(params)?;
735        let registry = self.registry()?;
736        let target_plugin = registry.get_plugin_by_name(&params.target_index_type)?;
737        let target_trainer = target_plugin.basic_trainer().ok_or_else(|| {
738            Error::invalid_input_source(
739                format!("The '{}' index type does not support basic training, please refer to the index's documentation for more details on how to create this index.", params.target_index_type).into(),
740            )
741        })?;
742        let target_request = target_trainer.new_training_request(
743            params.target_index_parameters.as_deref().unwrap_or("{}"),
744            &Field::new("", target_type, true),
745        )?;
746
747        Ok(Box::new(JsonTrainingRequest::new(params, target_request)))
748    }
749
750    async fn train_index(
751        &self,
752        data: SendableRecordBatchStream,
753        index_store: &dyn IndexStore,
754        request: Box<dyn TrainingRequest>,
755        fragment_ids: Option<Vec<u32>>,
756        progress: Arc<dyn crate::progress::IndexBuildProgress>,
757    ) -> Result<CreatedIndex> {
758        let request = (request as Box<dyn std::any::Any>)
759            .downcast::<JsonTrainingRequest>()
760            .unwrap();
761        let path = request.parameters.path.clone();
762
763        // Extract JSON with type information
764        let (data_stream, inferred_type) =
765            Self::extract_json_with_type_info(data, path.clone()).await?;
766
767        // Convert the stream to properly typed values based on inferred type
768        let converted_stream =
769            Self::convert_stream_by_type(data_stream, inferred_type.clone()).await?;
770
771        // `JsonTrainingRequest::criteria()` asked the scanner for unordered input (see
772        // its constructor), since the scanner can only sort on the raw JSON column, not
773        // on the value at `path`. If the target index needs value-ordered input, this is
774        // the one place that sort happens: on the extracted value, after extraction.
775        //
776        // Deliberately `request.target_request.criteria()` here, not `request.criteria()`:
777        // the latter is `JsonTrainingRequest`'s own criteria, which is always `None` (that's
778        // what asked the scanner for unordered input above) and would never take this branch.
779        let converted_stream =
780            if request.target_request.criteria().ordering == TrainingOrdering::Values {
781                Self::sort_stream_by_value(converted_stream).await?
782            } else {
783                converted_stream
784            };
785
786        // Update the target request with inferred type
787        let registry = self.registry()?;
788        let target_plugin = registry.get_plugin_by_name(&request.parameters.target_index_type)?;
789
790        // Create a new training request with the inferred type
791        let target_trainer = target_plugin.basic_trainer().ok_or_else(|| {
792            Error::invalid_input_source(
793                format!("The '{}' index type does not support basic training, please refer to the index's documentation for more details on how to create this index.", request.parameters.target_index_type).into(),
794            )
795        })?;
796        let target_request = target_trainer.new_training_request(
797            request
798                .parameters
799                .target_index_parameters
800                .as_deref()
801                .unwrap_or("{}"),
802            &Field::new("", inferred_type, true),
803        )?;
804
805        let target_index = target_trainer
806            .train_index(
807                converted_stream,
808                index_store,
809                target_request,
810                fragment_ids,
811                progress,
812            )
813            .await?;
814
815        let index_details = crate::pb::JsonIndexDetails {
816            path,
817            target_details: Some(target_index.index_details),
818        };
819        Ok(CreatedIndex {
820            index_details: prost_types::Any::from_msg(&index_details)?,
821            index_version: JSON_INDEX_VERSION,
822            files: target_index.files,
823        })
824    }
825}
826
827#[async_trait]
828impl ScalarIndexPlugin for JsonIndexPlugin {
829    fn basic_trainer(&self) -> Option<&dyn BasicTrainer> {
830        Some(self)
831    }
832
833    fn name(&self) -> &str {
834        "Json"
835    }
836
837    fn provides_exact_answer(&self) -> bool {
838        // TODO: Need to lookup target plugin via details to figure this out correctly
839        true
840    }
841
842    fn attach_registry(&self, registry: Arc<IndexPluginRegistry>) {
843        let mut reg_ref = self.registry.lock().unwrap();
844        *reg_ref = Some(registry);
845    }
846
847    fn version(&self) -> u32 {
848        JSON_INDEX_VERSION
849    }
850
851    fn new_query_parser(
852        &self,
853        index_name: String,
854        index_details: &prost_types::Any,
855    ) -> Option<Box<dyn ScalarQueryParser>> {
856        // TODO: Allow return Result here
857        let registry = self.registry().unwrap();
858        let json_details =
859            crate::pb::JsonIndexDetails::decode(index_details.value.as_slice()).unwrap();
860        let target_details = json_details.target_details.as_ref().expect_ok().unwrap();
861        let target_plugin = registry.get_plugin_by_details(target_details).unwrap();
862        // TODO: Use something like ${index_name}_${path} for the index name?  Don't have access to path here tho
863        let target_parser = target_plugin.new_query_parser(index_name, index_details)?;
864        Some(Box::new(JsonQueryParser::new(
865            json_details.path.clone(),
866            target_parser,
867        )) as Box<dyn ScalarQueryParser>)
868    }
869
870    async fn load_index(
871        &self,
872        index_store: Arc<dyn IndexStore>,
873        index_details: &prost_types::Any,
874        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
875        cache: &LanceCache,
876    ) -> Result<Arc<dyn ScalarIndex>> {
877        let registry = self.registry().unwrap();
878        let json_details = crate::pb::JsonIndexDetails::decode(index_details.value.as_slice())?;
879        let target_details = json_details.target_details.as_ref().expect_ok()?;
880        let target_plugin = registry.get_plugin_by_details(target_details).unwrap();
881        let target_index = target_plugin
882            .load_index(index_store, target_details, frag_reuse_index, cache)
883            .await?;
884        Ok(Arc::new(JsonIndex::new(target_index, json_details.path)))
885    }
886
887    fn details_as_json(&self, details: &prost_types::Any) -> Result<serde_json::Value> {
888        let registry = self.registry().unwrap();
889        let json_details = crate::pb::JsonIndexDetails::decode(details.value.as_slice())?;
890        let target_details = json_details.target_details.as_ref().expect_ok()?;
891        let target_plugin = registry.get_plugin_by_details(target_details).unwrap();
892        let target_details_json = target_plugin.details_as_json(target_details)?;
893        Ok(serde_json::json!({
894            "path": json_details.path,
895            "target_details": target_details_json,
896        }))
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903    use crate::scalar::{SargableQuery, TextQuery};
904    use arrow_array::{ArrayRef, RecordBatch};
905    use arrow_schema::{DataType, Field, Schema};
906    use rstest::rstest;
907    use std::ops::Bound;
908    use std::sync::Arc;
909
910    // Note: The old test_detect_json_value_type test has been removed as we now use
911    // JSONB's inherent type information instead of string-based type detection
912
913    #[tokio::test]
914    async fn test_json_extract_with_type_info() {
915        use arrow_array::{LargeBinaryArray, UInt64Array};
916        use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
917        use futures::stream;
918
919        // Create test JSONB data
920        let json_data = vec![
921            r#"{"name": "Alice", "age": 30, "active": true}"#,
922            r#"{"name": "Bob", "age": 25, "active": false}"#,
923            r#"{"name": "Charlie", "age": 35, "active": true}"#,
924        ];
925
926        // Convert JSON strings to JSONB binary format
927        let mut jsonb_values = Vec::new();
928        for json_str in &json_data {
929            let owned_jsonb: jsonb::OwnedJsonb = json_str.parse().unwrap();
930            jsonb_values.push(Some(owned_jsonb.to_vec()));
931        }
932
933        // Create test batch with JSONB data
934        let schema = Arc::new(Schema::new(vec![
935            Field::new(VALUE_COLUMN_NAME, DataType::LargeBinary, true),
936            Field::new(ROW_ID, DataType::UInt64, false),
937        ]));
938
939        let jsonb_array = LargeBinaryArray::from(
940            jsonb_values
941                .iter()
942                .map(|v| v.as_deref())
943                .collect::<Vec<_>>(),
944        );
945        let row_ids = UInt64Array::from(vec![1, 2, 3]);
946
947        let batch = RecordBatch::try_new(
948            schema.clone(),
949            vec![
950                Arc::new(jsonb_array) as ArrayRef,
951                Arc::new(row_ids) as ArrayRef,
952            ],
953        )
954        .unwrap();
955
956        let stream = Box::pin(RecordBatchStreamAdapter::new(
957            schema.clone(),
958            stream::iter(vec![Ok(batch)]),
959        )) as SendableRecordBatchStream;
960
961        // Test type inference for integer field
962        let (_result_stream, inferred_type) =
963            JsonIndexPlugin::extract_json_with_type_info(stream, "$.age".to_string())
964                .await
965                .unwrap();
966
967        assert_eq!(inferred_type, DataType::Int64);
968
969        // Create new test stream for boolean field
970        let batch2 = RecordBatch::try_new(
971            schema.clone(),
972            vec![
973                Arc::new(LargeBinaryArray::from(vec![
974                    json_data[0]
975                        .parse::<jsonb::OwnedJsonb>()
976                        .ok()
977                        .map(|j| j.to_vec())
978                        .as_deref(),
979                    json_data[1]
980                        .parse::<jsonb::OwnedJsonb>()
981                        .ok()
982                        .map(|j| j.to_vec())
983                        .as_deref(),
984                    json_data[2]
985                        .parse::<jsonb::OwnedJsonb>()
986                        .ok()
987                        .map(|j| j.to_vec())
988                        .as_deref(),
989                ])) as ArrayRef,
990                Arc::new(UInt64Array::from(vec![1, 2, 3])) as ArrayRef,
991            ],
992        )
993        .unwrap();
994
995        let stream2 = Box::pin(RecordBatchStreamAdapter::new(
996            schema.clone(),
997            stream::iter(vec![Ok(batch2)]),
998        )) as SendableRecordBatchStream;
999
1000        // Test type inference for boolean field
1001        let (_, inferred_type) =
1002            JsonIndexPlugin::extract_json_with_type_info(stream2, "$.active".to_string())
1003                .await
1004                .unwrap();
1005
1006        assert_eq!(inferred_type, DataType::Boolean);
1007
1008        // Create test stream for string field
1009        let batch3 = RecordBatch::try_new(
1010            schema.clone(),
1011            vec![
1012                Arc::new(LargeBinaryArray::from(vec![
1013                    json_data[0]
1014                        .parse::<jsonb::OwnedJsonb>()
1015                        .ok()
1016                        .map(|j| j.to_vec())
1017                        .as_deref(),
1018                    json_data[1]
1019                        .parse::<jsonb::OwnedJsonb>()
1020                        .ok()
1021                        .map(|j| j.to_vec())
1022                        .as_deref(),
1023                    json_data[2]
1024                        .parse::<jsonb::OwnedJsonb>()
1025                        .ok()
1026                        .map(|j| j.to_vec())
1027                        .as_deref(),
1028                ])) as ArrayRef,
1029                Arc::new(UInt64Array::from(vec![1, 2, 3])) as ArrayRef,
1030            ],
1031        )
1032        .unwrap();
1033
1034        let stream3 = Box::pin(RecordBatchStreamAdapter::new(
1035            schema,
1036            stream::iter(vec![Ok(batch3)]),
1037        )) as SendableRecordBatchStream;
1038
1039        // Test type inference for string field
1040        let (_, inferred_type) =
1041            JsonIndexPlugin::extract_json_with_type_info(stream3, "$.name".to_string())
1042                .await
1043                .unwrap();
1044
1045        assert_eq!(inferred_type, DataType::Utf8);
1046    }
1047
1048    /// Trains a JSON-path index of `target_index_type` over `json_docs` (fed to the
1049    /// trainer in exactly the given order, with row ids `0..json_docs.len()`) and
1050    /// returns the loaded index. `store` is a caller-owned `LanceIndexStore` so the
1051    /// caller controls how long the backing `TempObjDir` stays alive.
1052    async fn train_and_load_json_index(
1053        store: Arc<dyn IndexStore>,
1054        target_index_type: &str,
1055        path: &str,
1056        json_docs: &[&str],
1057    ) -> Arc<dyn ScalarIndex> {
1058        use crate::progress::noop_progress;
1059        use arrow_array::{LargeBinaryArray, UInt64Array};
1060        use futures::stream;
1061
1062        let jsonb: Vec<Vec<u8>> = json_docs
1063            .iter()
1064            .map(|s| s.parse::<jsonb::OwnedJsonb>().unwrap().to_vec())
1065            .collect();
1066
1067        let schema = Arc::new(Schema::new(vec![
1068            Field::new(VALUE_COLUMN_NAME, DataType::LargeBinary, true),
1069            Field::new(ROW_ID, DataType::UInt64, false),
1070        ]));
1071        let batch = RecordBatch::try_new(
1072            schema.clone(),
1073            vec![
1074                Arc::new(LargeBinaryArray::from(
1075                    jsonb.iter().map(|v| Some(v.as_slice())).collect::<Vec<_>>(),
1076                )) as ArrayRef,
1077                Arc::new(UInt64Array::from_iter_values(0..json_docs.len() as u64)) as ArrayRef,
1078            ],
1079        )
1080        .unwrap();
1081        let data = Box::pin(RecordBatchStreamAdapter::new(
1082            schema,
1083            stream::iter(vec![Ok(batch)]),
1084        )) as SendableRecordBatchStream;
1085
1086        let registry = IndexPluginRegistry::with_default_plugins();
1087        let plugin = registry.get_plugin_by_name("json").unwrap();
1088        let trainer = plugin.basic_trainer().unwrap();
1089        let params = format!(r#"{{"target_index_type":"{target_index_type}","path":"{path}"}}"#);
1090        let request = trainer
1091            .new_training_request(
1092                &params,
1093                &Field::new(VALUE_COLUMN_NAME, DataType::LargeBinary, true),
1094            )
1095            .unwrap();
1096
1097        // The scanner must be asked for unordered input: only this plugin knows the
1098        // order of the extracted value, so sorting on the raw JSON column would be
1099        // wasted work that either goes unused (non-`Values` targets) or still leaves
1100        // the extracted stream unsorted (`Values` targets, see below).
1101        assert_eq!(request.criteria().ordering, TrainingOrdering::None);
1102
1103        let created = trainer
1104            .train_index(data, store.as_ref(), request, None, noop_progress())
1105            .await
1106            .unwrap();
1107
1108        plugin
1109            .load_index(store, &created.index_details, None, &LanceCache::no_cache())
1110            .await
1111            .unwrap()
1112    }
1113
1114    fn local_json_index_store() -> (Arc<dyn IndexStore>, lance_core::utils::tempfile::TempObjDir) {
1115        use crate::scalar::lance_format::LanceIndexStore;
1116        use lance_core::utils::tempfile::TempObjDir;
1117        use lance_io::object_store::ObjectStore;
1118
1119        let tmpdir = TempObjDir::default();
1120        let store = Arc::new(LanceIndexStore::new(
1121            Arc::new(ObjectStore::local()),
1122            tmpdir.clone(),
1123            Arc::new(LanceCache::no_cache()),
1124        )) as Arc<dyn IndexStore>;
1125        (store, tmpdir)
1126    }
1127
1128    /// Regression test for https://github.com/lance-format/lance/issues/7485.
1129    ///
1130    /// A JSON-path btree index over float values returned wrong results because the
1131    /// btree trainer assumes its input arrives sorted by value (page min/max come from
1132    /// the first/last row of each page), but the value at `path` is extracted by this
1133    /// plugin *after* the scanner has already produced its rows, so a scan sorted on
1134    /// the raw JSON column does not sort the extracted value. This exercises the fix
1135    /// end to end: `JsonTrainingRequest::criteria()` must ask for unordered input (so
1136    /// the scanner does not waste time sorting on the wrong key), and `train_index` must
1137    /// sort the extracted value stream itself before training the target btree.
1138    ///
1139    /// Rows are fed in raw storage order (not sorted by value) to simulate what an
1140    /// unordered scan would produce.
1141    ///
1142    /// Each case below runs a spilling `SortExec` that reserves a non-spillable merge
1143    /// buffer from the process-wide cached DataFusion memory pool (see
1144    /// `get_session_context`); running the cases concurrently contends for that shared
1145    /// pool and can spuriously exhaust it, so this guard serializes them.
1146    static FLOAT_INDEX_CASE_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1147
1148    #[rstest]
1149    #[case::range_gt_zero(
1150        SargableQuery::Range(Bound::Excluded(ScalarValue::Float64(Some(0.0))), Bound::Unbounded),
1151        vec![0, 1]
1152    )]
1153    #[case::range_gte_page_min(
1154        SargableQuery::Range(Bound::Included(ScalarValue::Float64(Some(10.5))), Bound::Unbounded),
1155        vec![0, 1]
1156    )]
1157    #[case::equals_non_exact_float(
1158        SargableQuery::Equals(ScalarValue::Float64(Some(40.1))),
1159        vec![1]
1160    )]
1161    #[case::equals_exact_float(SargableQuery::Equals(ScalarValue::Float64(Some(10.5))), vec![0])]
1162    #[case::range_covers_all(
1163        SargableQuery::Range(Bound::Unbounded, Bound::Excluded(ScalarValue::Float64(Some(100.0)))),
1164        vec![0, 1, 2]
1165    )]
1166    #[tokio::test]
1167    async fn test_json_float_btree_index_unsorted_input(
1168        #[case] query: SargableQuery,
1169        #[case] expected: Vec<u64>,
1170    ) {
1171        let _guard = FLOAT_INDEX_CASE_GUARD.lock().await;
1172        use crate::metrics::NoOpMetricsCollector;
1173        use lance_select::RowAddrTreeMap;
1174
1175        // row0=10.5, row1=40.1, row2=-3.2: storage order does not match ascending value
1176        // order (-3.2, 10.5, 40.1), so a btree trained on this order without an explicit
1177        // value sort would record a corrupted page max of -3.2.
1178        let (store, _tmpdir) = local_json_index_store();
1179        let index = train_and_load_json_index(
1180            store,
1181            "btree",
1182            "latitude",
1183            &[
1184                r#"{"latitude": 10.5}"#,
1185                r#"{"latitude": 40.1}"#,
1186                r#"{"latitude": -3.2}"#,
1187            ],
1188        )
1189        .await;
1190
1191        let json_query = JsonQuery::new(Arc::new(query.clone()), "latitude".to_string());
1192        let result = index
1193            .search(&json_query, &NoOpMetricsCollector)
1194            .await
1195            .unwrap();
1196        assert_eq!(
1197            result,
1198            SearchResult::exact(RowAddrTreeMap::from_iter(expected.iter().copied())),
1199            "query {query:?}"
1200        );
1201    }
1202
1203    /// Regression test for a null value at `path` surviving `sort_stream_by_value`.
1204    ///
1205    /// `sort_stream_by_value` sorts the extracted `(value, row_id)` stream with
1206    /// `nulls_first: true`. This checks that a null row's row_id stays paired with its
1207    /// (null) value through that sort -- if the sort ever reordered values without their
1208    /// row_ids, a null-valued row could be attributed to the wrong id -- and that the
1209    /// resulting btree still answers `IsNull` and non-null range/equality queries
1210    /// correctly with nulls mixed in and fed out of value order.
1211    ///
1212    /// Row 1's `path` is missing entirely, which is what actually produces a null in the
1213    /// extracted value column (`extract_json_path_with_type` returns `None`, which
1214    /// `json_extract_with_type_impl` turns into an arrow-null). An explicit JSON `null`
1215    /// literal at `path` (e.g. `{"v": null}`) is a different, pre-existing case that
1216    /// `convert_stream_by_type` does not yet handle (it tries to deserialize the JSONB
1217    /// `null` bytes as the inferred type and errors) -- unrelated to this fix, so it's
1218    /// out of scope here.
1219    #[tokio::test]
1220    async fn test_json_btree_index_null_at_path() {
1221        use crate::metrics::NoOpMetricsCollector;
1222        use lance_select::RowAddrTreeMap;
1223
1224        let _guard = FLOAT_INDEX_CASE_GUARD.lock().await;
1225        let (store, _tmpdir) = local_json_index_store();
1226        let index = train_and_load_json_index(
1227            store,
1228            "btree",
1229            "v",
1230            &[
1231                r#"{"v": 40.1}"#,  // row 0
1232                r#"{"other": 1}"#, // row 1: path missing -> null
1233                r#"{"v": -3.2}"#,  // row 2
1234                r#"{"v": 10.5}"#,  // row 3
1235            ],
1236        )
1237        .await;
1238
1239        let search = |query: SargableQuery| {
1240            let index = index.clone();
1241            let json_query = JsonQuery::new(Arc::new(query), "v".to_string());
1242            async move {
1243                index
1244                    .search(&json_query, &NoOpMetricsCollector)
1245                    .await
1246                    .unwrap()
1247            }
1248        };
1249
1250        // Range/equality queries carry row 1 in `nulls` (three-valued logic: `NULL > 0`
1251        // is unknown, not false -- see `NullableRowAddrSet`), which is pre-existing
1252        // btree/framework behavior. Asserting it exactly here is exactly the property
1253        // this test targets: row 1's row_id must stay paired with its null value
1254        // through `sort_stream_by_value`, not just be excluded from `selected`.
1255        assert_eq!(
1256            search(SargableQuery::IsNull()).await,
1257            SearchResult::exact(RowAddrTreeMap::from_iter([1u64])),
1258            "IsNull"
1259        );
1260        assert_eq!(
1261            search(SargableQuery::Range(
1262                Bound::Excluded(ScalarValue::Float64(Some(0.0))),
1263                Bound::Unbounded,
1264            ))
1265            .await,
1266            SearchResult::exact(RowAddrTreeMap::from_iter([0u64, 3]))
1267                .with_nulls(RowAddrTreeMap::from_iter([1u64])),
1268            "> 0"
1269        );
1270        assert_eq!(
1271            search(SargableQuery::Equals(ScalarValue::Float64(Some(40.1)))).await,
1272            SearchResult::exact(RowAddrTreeMap::from_iter([0u64]))
1273                .with_nulls(RowAddrTreeMap::from_iter([1u64])),
1274            "= 40.1"
1275        );
1276        assert_eq!(
1277            search(SargableQuery::Range(
1278                Bound::Unbounded,
1279                Bound::Excluded(ScalarValue::Float64(Some(100.0))),
1280            ))
1281            .await,
1282            SearchResult::exact(RowAddrTreeMap::from_iter([0u64, 2, 3]))
1283                .with_nulls(RowAddrTreeMap::from_iter([1u64])),
1284            "< 100 (null is neither < 100 nor >= 100)"
1285        );
1286    }
1287
1288    /// Regression coverage for the non-`Values`-ordering branch in `train_index`: a
1289    /// JSON-path index over a target that does not need value-ordered input (ngram
1290    /// requires `TrainingOrdering::None`) must skip `sort_stream_by_value` entirely and
1291    /// still produce correct results from rows fed out of value order.
1292    #[tokio::test]
1293    async fn test_json_ngram_index_skips_value_sort() {
1294        use crate::metrics::NoOpMetricsCollector;
1295        use lance_select::RowAddrTreeMap;
1296
1297        let (store, _tmpdir) = local_json_index_store();
1298        let index = train_and_load_json_index(
1299            store,
1300            "ngram",
1301            "tag",
1302            &[
1303                r#"{"tag": "unique-charlie"}"#,
1304                r#"{"tag": "unique-alpha"}"#,
1305                r#"{"tag": "unique-bravo"}"#,
1306            ],
1307        )
1308        .await;
1309
1310        let json_query = JsonQuery::new(
1311            Arc::new(TextQuery::StringContains("unique-bravo".to_string())),
1312            "tag".to_string(),
1313        );
1314        let result = index
1315            .search(&json_query, &NoOpMetricsCollector)
1316            .await
1317            .unwrap();
1318        assert_eq!(
1319            result,
1320            SearchResult::at_most(RowAddrTreeMap::from_iter([2u64])),
1321        );
1322    }
1323}