Skip to main content

pipeline/
etl.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![allow(dead_code)]
16pub mod ctx_req;
17pub mod field;
18pub mod processor;
19pub mod transform;
20pub mod value;
21
22use std::collections::HashMap;
23
24use api::v1::Row;
25use common_time::timestamp::TimeUnit;
26use itertools::Itertools;
27use processor::{Processor, Processors};
28use snafu::{OptionExt, ResultExt, ensure};
29use transform::Transforms;
30use vrl::core::Value as VrlValue;
31use yaml_rust::{Yaml, YamlLoader};
32
33use crate::dispatcher::{Dispatcher, Rule};
34use crate::error::{
35    ArrayElementMustBeObjectSnafu, AutoTransformOneTimestampSnafu, Error,
36    IntermediateKeyIndexSnafu, InvalidVersionNumberSnafu, Result, TransformArrayElementSnafu,
37    YamlLoadSnafu, YamlParseSnafu,
38};
39use crate::etl::processor::ProcessorKind;
40use crate::etl::transform::transformer::greptime::{
41    RowWithTableSuffix, values_to_row, values_to_rows,
42};
43use crate::tablesuffix::TableSuffixTemplate;
44use crate::{
45    ContextOpt, GreptimeTransformer, IdentityTimeIndex, PipelineContext, PipelineOptions,
46    SchemaInfo, unwrap_or_continue_if_err,
47};
48
49const DESCRIPTION: &str = "description";
50const DOC_VERSION: &str = "version";
51const PROCESSORS: &str = "processors";
52const TRANSFORM: &str = "transform";
53const TRANSFORMS: &str = "transforms";
54const DISPATCHER: &str = "dispatcher";
55const TABLESUFFIX: &str = "table_suffix";
56
57pub enum Content<'a> {
58    Json(&'a str),
59    Yaml(&'a str),
60}
61
62pub fn parse(input: &Content) -> Result<Pipeline> {
63    parse_with_options(input, &PipelineOptions::default())
64}
65
66/// Parses a pipeline using the server's compilation policy.
67pub fn parse_with_options(input: &Content, options: &PipelineOptions) -> Result<Pipeline> {
68    match input {
69        Content::Yaml(str) => {
70            let docs = YamlLoader::load_from_str(str).context(YamlLoadSnafu)?;
71
72            ensure!(docs.len() == 1, YamlParseSnafu);
73
74            let doc = &docs[0];
75
76            let description = doc[DESCRIPTION].as_str().map(|s| s.to_string());
77
78            let doc_version = (&doc[DOC_VERSION]).try_into()?;
79
80            let processors = if let Some(v) = doc[PROCESSORS].as_vec() {
81                Processors::from_yaml(v, options.allow_vrl_host_access)?
82            } else {
83                Processors::default()
84            };
85
86            let transformers = if let Some(v) = doc[TRANSFORMS].as_vec().or(doc[TRANSFORM].as_vec())
87            {
88                v.try_into()?
89            } else {
90                Transforms::default()
91            };
92
93            let transformer = if transformers.is_empty() {
94                // use auto transform
95                // check processors have at least one timestamp-related processor
96                let cnt = processors
97                    .iter()
98                    .filter_map(|p| match p {
99                        ProcessorKind::Date(d) if !d.ignore_missing() => Some(
100                            d.fields
101                                .iter()
102                                .map(|f| (f.target_or_input_field(), TimeUnit::Nanosecond))
103                                .collect_vec(),
104                        ),
105                        ProcessorKind::Epoch(e) if !e.ignore_missing() => Some(
106                            e.fields
107                                .iter()
108                                .map(|f| (f.target_or_input_field(), (&e.resolution).into()))
109                                .collect_vec(),
110                        ),
111                        _ => None,
112                    })
113                    .flatten()
114                    .collect_vec();
115                ensure!(cnt.len() == 1, AutoTransformOneTimestampSnafu);
116
117                let (ts_name, timeunit) = cnt.first().unwrap();
118                TransformerMode::AutoTransform(ts_name.to_string(), *timeunit)
119            } else {
120                TransformerMode::GreptimeTransformer(GreptimeTransformer::new(
121                    transformers,
122                    &doc_version,
123                )?)
124            };
125
126            let dispatcher = if !doc[DISPATCHER].is_badvalue() {
127                Some(Dispatcher::try_from(&doc[DISPATCHER])?)
128            } else {
129                None
130            };
131
132            let tablesuffix = if !doc[TABLESUFFIX].is_badvalue() {
133                Some(TableSuffixTemplate::try_from(&doc[TABLESUFFIX])?)
134            } else {
135                None
136            };
137
138            Ok(Pipeline {
139                doc_version,
140                description,
141                processors,
142                transformer,
143                dispatcher,
144                tablesuffix,
145            })
146        }
147        Content::Json(_) => unimplemented!(),
148    }
149}
150
151#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
152pub enum PipelineDocVersion {
153    /// 1. All fields meant to be preserved have to explicitly set in the transform section.
154    /// 2. Or no transform is set, then the auto-transform will be used.
155    #[default]
156    V1,
157
158    /// A combination of transform and auto-transform.
159    /// First it goes through the transform section,
160    /// then use auto-transform to set the rest fields.
161    ///
162    /// This is useful if you only want to set the index field,
163    /// and let the normal fields be auto-inferred.
164    V2,
165}
166
167impl TryFrom<&Yaml> for PipelineDocVersion {
168    type Error = Error;
169
170    fn try_from(value: &Yaml) -> Result<Self> {
171        if value.is_badvalue() || value.is_null() {
172            return Ok(PipelineDocVersion::V1);
173        }
174
175        let version = match value {
176            Yaml::String(s) => s
177                .parse::<i64>()
178                .map_err(|_| InvalidVersionNumberSnafu { version: s.clone() }.build())?,
179            Yaml::Integer(i) => *i,
180            _ => {
181                return InvalidVersionNumberSnafu {
182                    version: value.as_str().unwrap_or_default().to_string(),
183                }
184                .fail();
185            }
186        };
187
188        match version {
189            1 => Ok(PipelineDocVersion::V1),
190            2 => Ok(PipelineDocVersion::V2),
191            _ => InvalidVersionNumberSnafu {
192                version: version.to_string(),
193            }
194            .fail(),
195        }
196    }
197}
198
199#[derive(Debug)]
200pub struct Pipeline {
201    doc_version: PipelineDocVersion,
202    description: Option<String>,
203    processors: processor::Processors,
204    dispatcher: Option<Dispatcher>,
205    transformer: TransformerMode,
206    tablesuffix: Option<TableSuffixTemplate>,
207}
208
209#[derive(Debug, Clone)]
210pub enum TransformerMode {
211    GreptimeTransformer(GreptimeTransformer),
212    AutoTransform(String, TimeUnit),
213}
214
215/// Where the pipeline executed is dispatched to, with context information
216#[derive(Debug, Hash, PartialEq, Eq, Clone, PartialOrd, Ord)]
217pub struct DispatchedTo {
218    pub table_suffix: String,
219    pub pipeline: Option<String>,
220}
221
222impl From<&Rule> for DispatchedTo {
223    fn from(value: &Rule) -> Self {
224        DispatchedTo {
225            table_suffix: value.table_suffix.clone(),
226            pipeline: value.pipeline.clone(),
227        }
228    }
229}
230
231impl DispatchedTo {
232    /// Generate destination table name from input
233    pub fn dispatched_to_table_name(&self, original: &str) -> String {
234        [original, &self.table_suffix].concat()
235    }
236}
237
238/// The result of pipeline execution
239#[derive(Debug)]
240pub enum PipelineExecOutput {
241    Transformed(TransformedOutput),
242    DispatchedTo(DispatchedTo, VrlValue),
243    Filtered,
244}
245
246/// The result after processors and dispatcher rules have run.
247#[derive(Debug)]
248pub enum PipelineProcessOutput {
249    Processed(VrlValue),
250    DispatchedTo(DispatchedTo, VrlValue),
251    Filtered,
252}
253
254/// Output from a successful pipeline transformation.
255///
256/// Rows are grouped by their ContextOpt, with each row having its own optional
257/// table_suffix for routing to different tables when using one-to-many expansion.
258/// This enables true per-row configuration options where different rows can have
259/// different database settings (TTL, merge mode, etc.).
260#[derive(Debug)]
261pub struct TransformedOutput {
262    /// Rows grouped by their ContextOpt, each with optional table suffix
263    pub rows_by_context: HashMap<ContextOpt, Vec<RowWithTableSuffix>>,
264}
265
266impl PipelineExecOutput {
267    // Note: This is a test only function, do not use it in production.
268    pub fn into_transformed(self) -> Option<Vec<RowWithTableSuffix>> {
269        if let Self::Transformed(TransformedOutput { rows_by_context }) = self {
270            // For backward compatibility, merge all rows with a default ContextOpt
271            Some(rows_by_context.into_values().flatten().collect())
272        } else {
273            None
274        }
275    }
276
277    // New method for accessing the HashMap structure directly
278    pub fn into_transformed_hashmap(self) -> Option<HashMap<ContextOpt, Vec<RowWithTableSuffix>>> {
279        if let Self::Transformed(TransformedOutput { rows_by_context }) = self {
280            Some(rows_by_context)
281        } else {
282            None
283        }
284    }
285
286    // Note: This is a test only function, do not use it in production.
287    pub fn into_dispatched(self) -> Option<DispatchedTo> {
288        if let Self::DispatchedTo(d, _) = self {
289            Some(d)
290        } else {
291            None
292        }
293    }
294}
295
296impl Pipeline {
297    fn is_v1(&self) -> bool {
298        self.doc_version == PipelineDocVersion::V1
299    }
300
301    pub fn exec_mut(
302        &self,
303        val: VrlValue,
304        pipeline_ctx: &PipelineContext<'_>,
305        schema_info: &mut SchemaInfo,
306    ) -> Result<PipelineExecOutput> {
307        match self.process_mut(val)? {
308            PipelineProcessOutput::Processed(val) => self
309                .transform_mut(val, pipeline_ctx, schema_info)
310                .map(PipelineExecOutput::Transformed),
311            PipelineProcessOutput::DispatchedTo(dispatched_to, val) => {
312                Ok(PipelineExecOutput::DispatchedTo(dispatched_to, val))
313            }
314            PipelineProcessOutput::Filtered => Ok(PipelineExecOutput::Filtered),
315        }
316    }
317
318    pub fn process_mut(&self, mut val: VrlValue) -> Result<PipelineProcessOutput> {
319        for processor in self.processors.iter() {
320            val = processor.exec_mut(val)?;
321            if val.is_null() {
322                return Ok(PipelineProcessOutput::Filtered);
323            }
324        }
325
326        if let Some(rule) = self.dispatcher.as_ref().and_then(|d| d.exec(&val)) {
327            return Ok(PipelineProcessOutput::DispatchedTo(rule.into(), val));
328        }
329
330        Ok(PipelineProcessOutput::Processed(val))
331    }
332
333    pub fn transform_mut(
334        &self,
335        val: VrlValue,
336        pipeline_ctx: &PipelineContext<'_>,
337        schema_info: &mut SchemaInfo,
338    ) -> Result<TransformedOutput> {
339        let mut val = if val.is_array() {
340            val
341        } else {
342            VrlValue::Array(vec![val])
343        };
344
345        let rows_by_context = match self.transformer() {
346            TransformerMode::GreptimeTransformer(greptime_transformer) => {
347                transform_array_elements_by_ctx(
348                    // SAFETY: by line 326, val must be an array
349                    val.as_array_mut().unwrap(),
350                    greptime_transformer,
351                    self.is_v1(),
352                    schema_info,
353                    pipeline_ctx,
354                    self.tablesuffix.as_ref(),
355                )?
356            }
357            TransformerMode::AutoTransform(ts_name, time_unit) => {
358                let def = crate::PipelineDefinition::GreptimeIdentityPipeline(Some(
359                    IdentityTimeIndex::Epoch(ts_name.clone(), *time_unit, false),
360                ));
361                let n_ctx =
362                    PipelineContext::new(&def, pipeline_ctx.pipeline_param, pipeline_ctx.channel);
363                values_to_rows(
364                    schema_info,
365                    val,
366                    &n_ctx,
367                    None,
368                    true,
369                    self.tablesuffix.as_ref(),
370                )?
371            }
372        };
373
374        Ok(TransformedOutput { rows_by_context })
375    }
376
377    pub fn processors(&self) -> &processor::Processors {
378        &self.processors
379    }
380
381    pub fn transformer(&self) -> &TransformerMode {
382        &self.transformer
383    }
384
385    pub fn resolve_table_suffix(&self, value: &VrlValue) -> Option<String> {
386        ContextOpt::resolve_table_suffix(self.tablesuffix.as_ref(), value)
387    }
388
389    // the method is for test purpose
390    pub fn schemas(&self) -> Option<&Vec<greptime_proto::v1::ColumnSchema>> {
391        match &self.transformer {
392            TransformerMode::GreptimeTransformer(t) => Some(t.schemas()),
393            TransformerMode::AutoTransform(_, _) => None,
394        }
395    }
396
397    pub fn is_variant_table_name(&self) -> bool {
398        // even if the pipeline doesn't have dispatcher or table_suffix,
399        // it can still be a variant because of VRL processor and hint
400        self.dispatcher.is_some() || self.tablesuffix.is_some()
401    }
402}
403
404/// Transforms an array of VRL values into rows grouped by their ContextOpt.
405/// Each element can have its own ContextOpt for per-row configuration.
406fn transform_array_elements_by_ctx(
407    arr: &mut [VrlValue],
408    transformer: &GreptimeTransformer,
409    is_v1: bool,
410    schema_info: &mut SchemaInfo,
411    pipeline_ctx: &PipelineContext<'_>,
412    tablesuffix_template: Option<&TableSuffixTemplate>,
413) -> Result<HashMap<ContextOpt, Vec<RowWithTableSuffix>>> {
414    let skip_error = pipeline_ctx.pipeline_param.skip_error();
415    let mut rows_by_context = HashMap::new();
416
417    for (index, element) in arr.iter_mut().enumerate() {
418        if !element.is_object() {
419            unwrap_or_continue_if_err!(
420                ArrayElementMustBeObjectSnafu {
421                    index,
422                    actual_type: element.kind_str().to_string(),
423                }
424                .fail(),
425                skip_error
426            );
427        }
428
429        let table_suffix = ContextOpt::resolve_table_suffix(tablesuffix_template, element);
430        let values = unwrap_or_continue_if_err!(
431            transformer.transform_mut_with_schema(
432                element,
433                is_v1,
434                schema_info,
435                table_suffix.as_deref(),
436            ),
437            skip_error
438        );
439        if is_v1 {
440            // v1 mode: just use transformer output directly
441            let opt = unwrap_or_continue_if_err!(
442                ContextOpt::from_pipeline_map_to_opt(element),
443                skip_error
444            );
445            rows_by_context
446                .entry(opt)
447                .or_insert_with(Vec::new)
448                .push((Row { values }, table_suffix));
449        } else {
450            // v2 mode: combine with auto-transform for remaining fields
451            let mut value = element.clone();
452            let opt = unwrap_or_continue_if_err!(
453                ContextOpt::from_pipeline_map_to_opt(&mut value),
454                skip_error
455            );
456            let row = unwrap_or_continue_if_err!(
457                values_to_row(schema_info, value, pipeline_ctx, Some(values), false,)
458                    .map_err(Box::new)
459                    .context(TransformArrayElementSnafu { index }),
460                skip_error
461            );
462            rows_by_context
463                .entry(opt)
464                .or_default()
465                .push((row, table_suffix));
466        }
467    }
468
469    Ok(rows_by_context)
470}
471
472pub(crate) fn find_key_index(intermediate_keys: &[String], key: &str, kind: &str) -> Result<usize> {
473    intermediate_keys
474        .iter()
475        .position(|k| k == key)
476        .context(IntermediateKeyIndexSnafu { kind, key })
477}
478
479/// This macro is test only, do not use it in production.
480/// The schema_info cannot be used in auto-transform ts-infer mode for lacking the ts schema.
481///
482/// Usage:
483/// ```ignore
484/// let (pipeline, schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
485/// let pipeline_ctx = PipelineContext::new(&pipeline_def, &pipeline_param, Channel::Unknown);
486/// ```
487#[macro_export]
488macro_rules! setup_pipeline {
489    ($pipeline:expr) => {{
490        use std::sync::Arc;
491
492        use $crate::{GreptimePipelineParams, Pipeline, PipelineDefinition, SchemaInfo};
493
494        let pipeline: Arc<Pipeline> = Arc::new($pipeline);
495        let schema = pipeline.schemas().unwrap();
496        let schema_info = SchemaInfo::from_schema_list(schema.clone());
497
498        let pipeline_def = PipelineDefinition::Resolved(pipeline.clone());
499        let pipeline_param = GreptimePipelineParams::default();
500
501        (pipeline, schema_info, pipeline_def, pipeline_param)
502    }};
503}
504
505#[cfg(test)]
506mod tests {
507    use std::collections::BTreeMap;
508    use std::sync::Arc;
509
510    use api::v1::Rows;
511    use datatypes::schema::{FulltextOptions, SkippingIndexOptions};
512    use greptime_proto::v1::value::ValueData;
513    use greptime_proto::v1::{self, ColumnDataType, SemanticType};
514    use vrl::prelude::Bytes;
515    use vrl::value::KeyString;
516
517    use super::*;
518
519    #[test]
520    fn test_pipeline_prepare() {
521        let input_value_str = r#"
522                    {
523                        "my_field": "1,2",
524                        "foo": "bar",
525                        "ts": "1"
526                    }
527                "#;
528        let input_value: serde_json::Value = serde_json::from_str(input_value_str).unwrap();
529
530        let pipeline_yaml = r#"description: 'Pipeline for Apache Tomcat'
531processors:
532    - csv:
533        field: my_field
534        target_fields: field1, field2
535    - epoch:
536        field: ts
537        resolution: ns
538transform:
539    - field: field1
540      type: uint32
541    - field: field2
542      type: uint32
543    - field: ts
544      type: timestamp, ns
545      index: time
546    "#;
547
548        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
549        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
550        let pipeline_ctx = PipelineContext::new(
551            &pipeline_def,
552            &pipeline_param,
553            session::context::Channel::Unknown,
554        );
555
556        let payload = input_value.into();
557        let mut result = pipeline
558            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
559            .unwrap()
560            .into_transformed()
561            .unwrap();
562
563        let (row, _table_suffix) = result.swap_remove(0);
564        assert_eq!(row.values[0].value_data, Some(ValueData::U32Value(1)));
565        assert_eq!(row.values[1].value_data, Some(ValueData::U32Value(2)));
566        match &row.values[2].value_data {
567            Some(ValueData::TimestampNanosecondValue(v)) => {
568                assert_ne!(v, &0);
569            }
570            _ => panic!("expect null value"),
571        }
572    }
573
574    #[test]
575    fn test_dissect_pipeline() {
576        let message = r#"129.37.245.88 - meln1ks [01/Aug/2024:14:22:47 +0800] "PATCH /observability/metrics/production HTTP/1.0" 501 33085"#.to_string();
577        let pipeline_str = r#"processors:
578    - dissect:
579        fields:
580          - message
581        patterns:
582          - "%{ip} %{?ignored} %{username} [%{ts}] \"%{method} %{path} %{proto}\" %{status} %{bytes}"
583    - date:
584        fields:
585          - ts
586        formats:
587          - "%d/%b/%Y:%H:%M:%S %z"
588
589transform:
590    - fields:
591        - ip
592        - username
593        - method
594        - path
595        - proto
596      type: string
597    - fields:
598        - status
599      type: uint16
600    - fields:
601        - bytes
602      type: uint32
603    - field: ts
604      type: timestamp, ns
605      index: time"#;
606        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_str)).unwrap();
607        let pipeline = Arc::new(pipeline);
608        let schema = pipeline.schemas().unwrap();
609        let mut schema_info = SchemaInfo::from_schema_list(schema.clone());
610
611        let pipeline_def = crate::PipelineDefinition::Resolved(pipeline.clone());
612        let pipeline_param = crate::GreptimePipelineParams::default();
613        let pipeline_ctx = PipelineContext::new(
614            &pipeline_def,
615            &pipeline_param,
616            session::context::Channel::Unknown,
617        );
618        let payload = VrlValue::Object(BTreeMap::from([(
619            KeyString::from("message"),
620            VrlValue::Bytes(Bytes::from(message)),
621        )]));
622
623        let result = pipeline
624            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
625            .unwrap()
626            .into_transformed()
627            .unwrap();
628
629        assert_eq!(schema_info.schema.len(), result[0].0.values.len());
630        let test = [
631            (
632                ColumnDataType::String as i32,
633                Some(ValueData::StringValue("129.37.245.88".into())),
634            ),
635            (
636                ColumnDataType::String as i32,
637                Some(ValueData::StringValue("meln1ks".into())),
638            ),
639            (
640                ColumnDataType::String as i32,
641                Some(ValueData::StringValue("PATCH".into())),
642            ),
643            (
644                ColumnDataType::String as i32,
645                Some(ValueData::StringValue(
646                    "/observability/metrics/production".into(),
647                )),
648            ),
649            (
650                ColumnDataType::String as i32,
651                Some(ValueData::StringValue("HTTP/1.0".into())),
652            ),
653            (
654                ColumnDataType::Uint16 as i32,
655                Some(ValueData::U16Value(501)),
656            ),
657            (
658                ColumnDataType::Uint32 as i32,
659                Some(ValueData::U32Value(33085)),
660            ),
661            (
662                ColumnDataType::TimestampNanosecond as i32,
663                Some(ValueData::TimestampNanosecondValue(1722493367000000000)),
664            ),
665        ];
666        // manually set schema
667        let schema = pipeline.schemas().unwrap();
668        for i in 0..schema.len() {
669            let schema = &schema[i];
670            let value = &result[0].0.values[i];
671            assert_eq!(schema.datatype, test[i].0);
672            assert_eq!(value.value_data, test[i].1);
673        }
674    }
675
676    #[test]
677    fn test_csv_pipeline() {
678        let input_value_str = r#"
679                    {
680                        "my_field": "1,2",
681                        "foo": "bar",
682                        "ts": "1"
683                    }
684                "#;
685        let input_value: serde_json::Value = serde_json::from_str(input_value_str).unwrap();
686
687        let pipeline_yaml = r#"
688    description: Pipeline for Apache Tomcat
689    processors:
690      - csv:
691          field: my_field
692          target_fields: field1, field2
693      - epoch:
694          field: ts
695          resolution: ns
696    transform:
697      - field: field1
698        type: uint32
699      - field: field2
700        type: uint32
701      - field: ts
702        type: timestamp, ns
703        index: time
704    "#;
705
706        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
707        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
708        let pipeline_ctx = PipelineContext::new(
709            &pipeline_def,
710            &pipeline_param,
711            session::context::Channel::Unknown,
712        );
713
714        let payload = input_value.into();
715        let result = pipeline
716            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
717            .unwrap()
718            .into_transformed()
719            .unwrap();
720        assert_eq!(
721            result[0].0.values[0].value_data,
722            Some(ValueData::U32Value(1))
723        );
724        assert_eq!(
725            result[0].0.values[1].value_data,
726            Some(ValueData::U32Value(2))
727        );
728        match &result[0].0.values[2].value_data {
729            Some(ValueData::TimestampNanosecondValue(v)) => {
730                assert_ne!(v, &0);
731            }
732            _ => panic!("expect null value"),
733        }
734    }
735
736    #[test]
737    fn test_date_pipeline() {
738        let input_value_str = r#"
739                {
740                    "my_field": "1,2",
741                    "foo": "bar",
742                    "test_time": "2014-5-17T04:34:56+00:00"
743                }
744            "#;
745        let input_value: serde_json::Value = serde_json::from_str(input_value_str).unwrap();
746
747        let pipeline_yaml = r#"---
748description: Pipeline for Apache Tomcat
749
750processors:
751    - date:
752        field: test_time
753
754transform:
755    - field: test_time
756      type: timestamp, ns
757      index: time
758    "#;
759
760        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
761        let pipeline = Arc::new(pipeline);
762        let schema = pipeline.schemas().unwrap();
763        let mut schema_info = SchemaInfo::from_schema_list(schema.clone());
764
765        let pipeline_def = crate::PipelineDefinition::Resolved(pipeline.clone());
766        let pipeline_param = crate::GreptimePipelineParams::default();
767        let pipeline_ctx = PipelineContext::new(
768            &pipeline_def,
769            &pipeline_param,
770            session::context::Channel::Unknown,
771        );
772        let schema = pipeline.schemas().unwrap().clone();
773        let result = input_value.into();
774
775        let rows_with_suffix = pipeline
776            .exec_mut(result, &pipeline_ctx, &mut schema_info)
777            .unwrap()
778            .into_transformed()
779            .unwrap();
780        let output = Rows {
781            schema,
782            rows: rows_with_suffix.into_iter().map(|(r, _)| r).collect(),
783        };
784        let schemas = output.schema;
785
786        assert_eq!(schemas.len(), 1);
787        let schema = schemas[0].clone();
788        assert_eq!("test_time", schema.column_name);
789        assert_eq!(ColumnDataType::TimestampNanosecond as i32, schema.datatype);
790        assert_eq!(SemanticType::Timestamp as i32, schema.semantic_type);
791
792        let row = output.rows[0].clone();
793        assert_eq!(1, row.values.len());
794        let value_data = row.values[0].clone().value_data;
795        assert_eq!(
796            Some(v1::value::ValueData::TimestampNanosecondValue(
797                1400301296000000000
798            )),
799            value_data
800        );
801    }
802
803    #[test]
804    fn test_dispatcher() {
805        let pipeline_yaml = r#"
806---
807description: Pipeline for Apache Tomcat
808
809processors:
810  - epoch:
811      field: ts
812      resolution: ns
813
814dispatcher:
815  field: typename
816  rules:
817    - value: http
818      table_suffix: http_events
819    - value: database
820      table_suffix: db_events
821      pipeline: database_pipeline
822
823transform:
824  - field: typename
825    type: string
826  - field: ts
827    type: timestamp, ns
828    index: time
829"#;
830        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
831        let dispatcher = pipeline.dispatcher.expect("expect dispatcher");
832        assert_eq!(dispatcher.field, "typename");
833
834        assert_eq!(dispatcher.rules.len(), 2);
835
836        assert_eq!(
837            dispatcher.rules[0],
838            crate::dispatcher::Rule {
839                value: VrlValue::Bytes(Bytes::from("http")),
840                table_suffix: "http_events".to_string(),
841                pipeline: None
842            }
843        );
844
845        assert_eq!(
846            dispatcher.rules[1],
847            crate::dispatcher::Rule {
848                value: VrlValue::Bytes(Bytes::from("database")),
849                table_suffix: "db_events".to_string(),
850                pipeline: Some("database_pipeline".to_string()),
851            }
852        );
853
854        let bad_yaml1 = r#"
855---
856description: Pipeline for Apache Tomcat
857
858processors:
859  - epoch:
860      field: ts
861      resolution: ns
862
863dispatcher:
864  _field: typename
865  rules:
866    - value: http
867      table_suffix: http_events
868    - value: database
869      table_suffix: db_events
870      pipeline: database_pipeline
871
872transform:
873  - field: typename
874    type: string
875  - field: ts
876    type: timestamp, ns
877    index: time
878"#;
879        let bad_yaml2 = r#"
880---
881description: Pipeline for Apache Tomcat
882
883processors:
884  - epoch:
885      field: ts
886      resolution: ns
887dispatcher:
888  field: typename
889  rules:
890    - value: http
891      _table_suffix: http_events
892    - value: database
893      _table_suffix: db_events
894      pipeline: database_pipeline
895
896transform:
897  - field: typename
898    type: string
899  - field: ts
900    type: timestamp, ns
901    index: time
902"#;
903        let bad_yaml3 = r#"
904---
905description: Pipeline for Apache Tomcat
906
907processors:
908  - epoch:
909      field: ts
910      resolution: ns
911dispatcher:
912  field: typename
913  rules:
914    - _value: http
915      table_suffix: http_events
916    - _value: database
917      table_suffix: db_events
918      pipeline: database_pipeline
919
920transform:
921  - field: typename
922    type: string
923  - field: ts
924    type: timestamp, ns
925    index: time
926"#;
927
928        let r: Result<Pipeline> = parse(&Content::Yaml(bad_yaml1));
929        assert!(r.is_err());
930        let r: Result<Pipeline> = parse(&Content::Yaml(bad_yaml2));
931        assert!(r.is_err());
932        let r: Result<Pipeline> = parse(&Content::Yaml(bad_yaml3));
933        assert!(r.is_err());
934    }
935
936    /// Test one-to-many VRL pipeline expansion.
937    /// A VRL processor can return an array, which results in multiple output rows.
938    #[test]
939    fn test_one_to_many_vrl_expansion() {
940        let pipeline_yaml = r#"
941processors:
942  - epoch:
943      field: timestamp
944      resolution: ms
945  - vrl:
946      source: |
947        events = del(.events)
948        base_host = del(.host)
949        base_ts = del(.timestamp)
950        map_values(array!(events)) -> |event| {
951            {
952                "host": base_host,
953                "event_type": event.type,
954                "event_value": event.value,
955                "timestamp": base_ts
956            }
957        }
958
959transform:
960  - field: host
961    type: string
962  - field: event_type
963    type: string
964  - field: event_value
965    type: int32
966  - field: timestamp
967    type: timestamp, ms
968    index: time
969"#;
970
971        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
972        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
973        let pipeline_ctx = PipelineContext::new(
974            &pipeline_def,
975            &pipeline_param,
976            session::context::Channel::Unknown,
977        );
978
979        // Input with 3 events
980        let input_value: serde_json::Value = serde_json::from_str(
981            r#"{
982                "host": "server1",
983                "timestamp": 1716668197217,
984                "events": [
985                    {"type": "cpu", "value": 80},
986                    {"type": "memory", "value": 60},
987                    {"type": "disk", "value": 45}
988                ]
989            }"#,
990        )
991        .unwrap();
992
993        let payload = input_value.into();
994        let result = pipeline
995            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
996            .unwrap()
997            .into_transformed()
998            .unwrap();
999
1000        // Should produce 3 rows from 1 input
1001        assert_eq!(result.len(), 3);
1002
1003        // Verify each row has correct structure
1004        for (row, _table_suffix) in &result {
1005            assert_eq!(row.values.len(), 4); // host, event_type, event_value, timestamp
1006            // First value should be "server1"
1007            assert_eq!(
1008                row.values[0].value_data,
1009                Some(ValueData::StringValue("server1".to_string()))
1010            );
1011            // Last value should be the timestamp
1012            assert_eq!(
1013                row.values[3].value_data,
1014                Some(ValueData::TimestampMillisecondValue(1716668197217))
1015            );
1016        }
1017
1018        // Verify event types
1019        let event_types: Vec<_> = result
1020            .iter()
1021            .map(|(r, _)| match &r.values[1].value_data {
1022                Some(ValueData::StringValue(s)) => s.clone(),
1023                _ => panic!("expected string"),
1024            })
1025            .collect();
1026        assert!(event_types.contains(&"cpu".to_string()));
1027        assert!(event_types.contains(&"memory".to_string()));
1028        assert!(event_types.contains(&"disk".to_string()));
1029    }
1030
1031    /// Test that single object output still works (backward compatibility)
1032    #[test]
1033    fn test_single_object_output_unchanged() {
1034        let pipeline_yaml = r#"
1035processors:
1036  - epoch:
1037      field: ts
1038      resolution: ms
1039  - vrl:
1040      source: |
1041        .processed = true
1042        .
1043
1044transform:
1045  - field: name
1046    type: string
1047  - field: processed
1048    type: boolean
1049  - field: ts
1050    type: timestamp, ms
1051    index: time
1052"#;
1053
1054        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
1055        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
1056        let pipeline_ctx = PipelineContext::new(
1057            &pipeline_def,
1058            &pipeline_param,
1059            session::context::Channel::Unknown,
1060        );
1061
1062        let input_value: serde_json::Value = serde_json::from_str(
1063            r#"{
1064                "name": "test",
1065                "ts": 1716668197217
1066            }"#,
1067        )
1068        .unwrap();
1069
1070        let payload = input_value.into();
1071        let result = pipeline
1072            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
1073            .unwrap()
1074            .into_transformed()
1075            .unwrap();
1076
1077        // Should produce exactly 1 row
1078        assert_eq!(result.len(), 1);
1079        assert_eq!(
1080            result[0].0.values[0].value_data,
1081            Some(ValueData::StringValue("test".to_string()))
1082        );
1083        assert_eq!(
1084            result[0].0.values[1].value_data,
1085            Some(ValueData::BoolValue(true))
1086        );
1087    }
1088
1089    /// Test that empty array produces zero rows
1090    #[test]
1091    fn test_empty_array_produces_zero_rows() {
1092        let pipeline_yaml = r#"
1093processors:
1094  - vrl:
1095      source: |
1096        .events
1097
1098transform:
1099  - field: value
1100    type: int32
1101  - field: greptime_timestamp
1102    type: timestamp, ns
1103    index: time
1104"#;
1105
1106        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
1107        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
1108        let pipeline_ctx = PipelineContext::new(
1109            &pipeline_def,
1110            &pipeline_param,
1111            session::context::Channel::Unknown,
1112        );
1113
1114        let input_value: serde_json::Value = serde_json::from_str(r#"{"events": []}"#).unwrap();
1115
1116        let payload = input_value.into();
1117        let result = pipeline
1118            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
1119            .unwrap()
1120            .into_transformed()
1121            .unwrap();
1122
1123        // Empty array should produce zero rows
1124        assert_eq!(result.len(), 0);
1125    }
1126
1127    /// Test that array elements must be objects
1128    #[test]
1129    fn test_array_element_must_be_object() {
1130        let pipeline_yaml = r#"
1131processors:
1132  - vrl:
1133      source: |
1134        .items
1135
1136transform:
1137  - field: value
1138    type: int32
1139  - field: greptime_timestamp
1140    type: timestamp, ns
1141    index: time
1142"#;
1143
1144        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
1145        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
1146        let pipeline_ctx = PipelineContext::new(
1147            &pipeline_def,
1148            &pipeline_param,
1149            session::context::Channel::Unknown,
1150        );
1151
1152        // Array with non-object elements should fail
1153        let input_value: serde_json::Value =
1154            serde_json::from_str(r#"{"items": [1, 2, 3]}"#).unwrap();
1155
1156        let payload = input_value.into();
1157        let result = pipeline.exec_mut(payload, &pipeline_ctx, &mut schema_info);
1158
1159        assert!(result.is_err());
1160        let err_msg = result.unwrap_err().to_string();
1161        assert!(
1162            err_msg.contains("must be an object"),
1163            "Expected error about non-object element, got: {}",
1164            err_msg
1165        );
1166    }
1167
1168    /// Test one-to-many with table suffix from VRL hint
1169    #[test]
1170    fn test_one_to_many_with_table_suffix_hint() {
1171        let pipeline_yaml = r#"
1172processors:
1173  - epoch:
1174      field: ts
1175      resolution: ms
1176  - vrl:
1177      source: |
1178        .greptime_table_suffix = "_" + string!(.category)
1179        .
1180
1181transform:
1182  - field: name
1183    type: string
1184  - field: category
1185    type: string
1186  - field: ts
1187    type: timestamp, ms
1188    index: time
1189"#;
1190
1191        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
1192        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
1193        let pipeline_ctx = PipelineContext::new(
1194            &pipeline_def,
1195            &pipeline_param,
1196            session::context::Channel::Unknown,
1197        );
1198
1199        let input_value: serde_json::Value = serde_json::from_str(
1200            r#"{
1201                "name": "test",
1202                "category": "metrics",
1203                "ts": 1716668197217
1204            }"#,
1205        )
1206        .unwrap();
1207
1208        let payload = input_value.into();
1209        let result = pipeline
1210            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
1211            .unwrap()
1212            .into_transformed()
1213            .unwrap();
1214
1215        // Should have table suffix extracted per row
1216        assert_eq!(result.len(), 1);
1217        assert_eq!(result[0].1, Some("_metrics".to_string()));
1218    }
1219
1220    /// Test one-to-many with per-row table suffix
1221    #[test]
1222    fn test_one_to_many_per_row_table_suffix() {
1223        let pipeline_yaml = r#"
1224processors:
1225  - epoch:
1226      field: timestamp
1227      resolution: ms
1228  - vrl:
1229      source: |
1230        events = del(.events)
1231        base_ts = del(.timestamp)
1232
1233        map_values(array!(events)) -> |event| {
1234            suffix = "_" + string!(event.category)
1235            {
1236                "name": event.name,
1237                "value": event.value,
1238                "timestamp": base_ts,
1239                "greptime_table_suffix": suffix
1240            }
1241        }
1242
1243transform:
1244  - field: name
1245    type: string
1246  - field: value
1247    type: int32
1248  - field: timestamp
1249    type: timestamp, ms
1250    index: time
1251"#;
1252
1253        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
1254        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
1255        let pipeline_ctx = PipelineContext::new(
1256            &pipeline_def,
1257            &pipeline_param,
1258            session::context::Channel::Unknown,
1259        );
1260
1261        // Input with events that should go to different tables
1262        let input_value: serde_json::Value = serde_json::from_str(
1263            r#"{
1264                "timestamp": 1716668197217,
1265                "events": [
1266                    {"name": "cpu_usage", "value": 80, "category": "cpu"},
1267                    {"name": "mem_usage", "value": 60, "category": "memory"},
1268                    {"name": "cpu_temp", "value": 45, "category": "cpu"}
1269                ]
1270            }"#,
1271        )
1272        .unwrap();
1273
1274        let payload = input_value.into();
1275        let result = pipeline
1276            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
1277            .unwrap()
1278            .into_transformed()
1279            .unwrap();
1280
1281        // Should produce 3 rows
1282        assert_eq!(result.len(), 3);
1283
1284        // Collect table suffixes
1285        let table_suffixes: Vec<_> = result.iter().map(|(_, suffix)| suffix.clone()).collect();
1286
1287        // Should have different table suffixes per row
1288        assert!(table_suffixes.contains(&Some("_cpu".to_string())));
1289        assert!(table_suffixes.contains(&Some("_memory".to_string())));
1290
1291        // Count rows per table suffix
1292        let cpu_count = table_suffixes
1293            .iter()
1294            .filter(|s| *s == &Some("_cpu".to_string()))
1295            .count();
1296        let memory_count = table_suffixes
1297            .iter()
1298            .filter(|s| *s == &Some("_memory".to_string()))
1299            .count();
1300        assert_eq!(cpu_count, 2);
1301        assert_eq!(memory_count, 1);
1302    }
1303
1304    /// Test that one-to-many mapping preserves per-row ContextOpt in HashMap
1305    #[test]
1306    fn test_one_to_many_hashmap_contextopt_preservation() {
1307        let pipeline_yaml = r#"
1308processors:
1309  - epoch:
1310      field: timestamp
1311      resolution: ms
1312  - vrl:
1313      source: |
1314        events = del(.events)
1315        base_ts = del(.timestamp)
1316
1317        map_values(array!(events)) -> |event| {
1318            # Set different TTL values per event type
1319            ttl = if event.type == "critical" {
1320                "1h"
1321            } else if event.type == "warning" {
1322                "24h"
1323            } else {
1324                "7d"
1325            }
1326
1327            {
1328                "host": del(.host),
1329                "event_type": event.type,
1330                "event_value": event.value,
1331                "timestamp": base_ts,
1332                "greptime_ttl": ttl
1333            }
1334        }
1335
1336transform:
1337  - field: host
1338    type: string
1339  - field: event_type
1340    type: string
1341  - field: event_value
1342    type: int32
1343  - field: timestamp
1344    type: timestamp, ms
1345    index: time
1346"#;
1347
1348        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
1349        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
1350        let pipeline_ctx = PipelineContext::new(
1351            &pipeline_def,
1352            &pipeline_param,
1353            session::context::Channel::Unknown,
1354        );
1355
1356        // Input with events that should have different ContextOpt values
1357        let input_value: serde_json::Value = serde_json::from_str(
1358            r#"{
1359                "host": "server1",
1360                "timestamp": 1716668197217,
1361                "events": [
1362                    {"type": "critical", "value": 100},
1363                    {"type": "warning", "value": 50},
1364                    {"type": "info", "value": 25}
1365                ]
1366            }"#,
1367        )
1368        .unwrap();
1369
1370        let payload = input_value.into();
1371        let result = pipeline
1372            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
1373            .unwrap();
1374
1375        // Extract the HashMap structure
1376        let rows_by_context = result.into_transformed_hashmap().unwrap();
1377
1378        // Should have 3 different ContextOpt groups due to different TTL values
1379        assert_eq!(rows_by_context.len(), 3);
1380
1381        // Verify each ContextOpt group has exactly 1 row and different configurations
1382        let mut context_opts = Vec::new();
1383        for (opt, rows) in &rows_by_context {
1384            assert_eq!(rows.len(), 1); // Each group should have exactly 1 row
1385            context_opts.push(opt.clone());
1386        }
1387
1388        // ContextOpts should be different due to different TTL values
1389        assert_ne!(context_opts[0], context_opts[1]);
1390        assert_ne!(context_opts[1], context_opts[2]);
1391        assert_ne!(context_opts[0], context_opts[2]);
1392
1393        // Verify the rows are correctly structured
1394        for rows in rows_by_context.values() {
1395            for (row, _table_suffix) in rows {
1396                assert_eq!(row.values.len(), 4); // host, event_type, event_value, timestamp
1397            }
1398        }
1399    }
1400
1401    /// Test that single object input still works with HashMap structure
1402    #[test]
1403    fn test_single_object_hashmap_compatibility() {
1404        let pipeline_yaml = r#"
1405processors:
1406  - epoch:
1407      field: ts
1408      resolution: ms
1409  - vrl:
1410      source: |
1411        .processed = true
1412        .
1413
1414transform:
1415  - field: name
1416    type: string
1417  - field: processed
1418    type: boolean
1419  - field: ts
1420    type: timestamp, ms
1421    index: time
1422"#;
1423
1424        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
1425        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
1426        let pipeline_ctx = PipelineContext::new(
1427            &pipeline_def,
1428            &pipeline_param,
1429            session::context::Channel::Unknown,
1430        );
1431
1432        let input_value: serde_json::Value = serde_json::from_str(
1433            r#"{
1434                "name": "test",
1435                "ts": 1716668197217
1436            }"#,
1437        )
1438        .unwrap();
1439
1440        let payload = input_value.into();
1441        let result = pipeline
1442            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
1443            .unwrap();
1444
1445        // Extract the HashMap structure
1446        let rows_by_context = result.into_transformed_hashmap().unwrap();
1447
1448        // Single object should produce exactly 1 ContextOpt group
1449        assert_eq!(rows_by_context.len(), 1);
1450
1451        let (_opt, rows) = rows_by_context.into_iter().next().unwrap();
1452        assert_eq!(rows.len(), 1);
1453
1454        // Verify the row structure
1455        let (row, _table_suffix) = &rows[0];
1456        assert_eq!(row.values.len(), 3); // name, processed, timestamp
1457    }
1458
1459    /// Test that empty arrays work correctly with HashMap structure
1460    #[test]
1461    fn test_empty_array_hashmap() {
1462        let pipeline_yaml = r#"
1463processors:
1464  - vrl:
1465      source: |
1466        .events
1467
1468transform:
1469  - field: value
1470    type: int32
1471  - field: greptime_timestamp
1472    type: timestamp, ns
1473    index: time
1474"#;
1475
1476        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
1477        let (pipeline, mut schema_info, pipeline_def, pipeline_param) = setup_pipeline!(pipeline);
1478        let pipeline_ctx = PipelineContext::new(
1479            &pipeline_def,
1480            &pipeline_param,
1481            session::context::Channel::Unknown,
1482        );
1483
1484        let input_value: serde_json::Value = serde_json::from_str(r#"{"events": []}"#).unwrap();
1485
1486        let payload = input_value.into();
1487        let result = pipeline
1488            .exec_mut(payload, &pipeline_ctx, &mut schema_info)
1489            .unwrap();
1490
1491        // Extract the HashMap structure
1492        let rows_by_context = result.into_transformed_hashmap().unwrap();
1493
1494        // Empty array should produce empty HashMap
1495        assert_eq!(rows_by_context.len(), 0);
1496    }
1497
1498    #[test]
1499    fn test_pipeline_detailed_index_options_roundtrip() {
1500        let pipeline_yaml = r#"
1501transform:
1502  - field: message
1503    type: string
1504    index:
1505      type: fulltext
1506      options:
1507        analyzer: Chinese
1508        case_sensitive: true
1509        backend: tantivy
1510  - field: trace_id
1511    type: int64
1512    index:
1513      type: skipping
1514      options:
1515        granularity: 2048
1516        false_positive_rate: 0.02
1517        type: BLOOM
1518  - field: ts
1519    type: timestamp, ns
1520    index: time
1521"#;
1522
1523        let pipeline: Pipeline = parse(&Content::Yaml(pipeline_yaml)).unwrap();
1524        let schema = pipeline.schemas().unwrap().clone();
1525
1526        let message = schema
1527            .iter()
1528            .find(|column| column.column_name == "message")
1529            .unwrap();
1530        let trace_id = schema
1531            .iter()
1532            .find(|column| column.column_name == "trace_id")
1533            .unwrap();
1534        let message_options = message.options.clone();
1535        let trace_id_options = trace_id.options.clone();
1536
1537        let fulltext: FulltextOptions = serde_json::from_str(
1538            message
1539                .options
1540                .as_ref()
1541                .unwrap()
1542                .options
1543                .get("fulltext")
1544                .unwrap(),
1545        )
1546        .unwrap();
1547        assert!(fulltext.enable);
1548        assert_eq!(fulltext.analyzer.to_string(), "Chinese");
1549        assert!(fulltext.case_sensitive);
1550        assert_eq!(fulltext.backend.to_string(), "tantivy");
1551
1552        let skipping: SkippingIndexOptions = serde_json::from_str(
1553            trace_id
1554                .options
1555                .as_ref()
1556                .unwrap()
1557                .options
1558                .get("skipping_index")
1559                .unwrap(),
1560        )
1561        .unwrap();
1562        assert_eq!(skipping.granularity, 2048);
1563        assert_eq!(skipping.false_positive_rate(), 0.02);
1564        assert_eq!(skipping.index_type.to_string(), "BLOOM");
1565
1566        let roundtrip_schema = SchemaInfo::from_schema_list(schema)
1567            .column_schemas()
1568            .unwrap();
1569        let roundtrip_message = roundtrip_schema
1570            .iter()
1571            .find(|column| column.column_name == "message")
1572            .unwrap();
1573        let roundtrip_trace_id = roundtrip_schema
1574            .iter()
1575            .find(|column| column.column_name == "trace_id")
1576            .unwrap();
1577
1578        assert_eq!(message_options, roundtrip_message.options);
1579        assert_eq!(trace_id_options, roundtrip_trace_id.options);
1580    }
1581}