Skip to main content

laminar_connectors/schema/json/
decoder.rs

1//! JSON format decoder implementing [`FormatDecoder`].
2//!
3//! Converts raw JSON byte payloads into Arrow `RecordBatch`es.
4//! Constructed once at `CREATE SOURCE` time with a frozen Arrow schema;
5//! the decoder is stateless after construction so the Ring 1 hot path
6//! has zero schema lookups.
7#![allow(clippy::disallowed_types)] // cold path: schema management
8
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::Arc;
12
13use arrow_array::builder::{
14    BooleanBuilder, Float32Builder, Float64Builder, Int16Builder, Int32Builder, Int64Builder,
15    Int8Builder, LargeBinaryBuilder, LargeStringBuilder, ListBuilder, StringBuilder,
16    TimestampMicrosecondBuilder, TimestampMillisecondBuilder, TimestampNanosecondBuilder,
17    TimestampSecondBuilder, UInt16Builder, UInt32Builder, UInt64Builder, UInt8Builder,
18};
19use arrow_array::{ArrayRef, RecordBatch};
20use arrow_schema::{DataType, SchemaRef, TimeUnit};
21
22use crate::schema::error::{SchemaError, SchemaResult};
23use crate::schema::json::jsonb::JsonbEncoder;
24use crate::schema::traits::FormatDecoder;
25use crate::schema::types::RawRecord;
26
27/// Strategy for JSON fields not in the Arrow schema.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum UnknownFieldStrategy {
30    /// Silently ignore unknown fields (default).
31    Ignore,
32    /// Collect unknown fields into an `_extra` `LargeBinary` (JSONB) column.
33    CollectExtra,
34    /// Return a decode error if any unknown field is encountered.
35    Reject,
36}
37
38/// Strategy for JSON values that don't match the expected Arrow type.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TypeMismatchStrategy {
41    /// Insert null and increment the mismatch counter (default).
42    Null,
43    /// Attempt coercion (e.g., `"123"` → `123` for Int64 columns).
44    Coerce,
45    /// Return a decode error on the first mismatch.
46    Reject,
47}
48
49impl TypeMismatchStrategy {
50    /// Parse from a `WITH` option value (`schema.enforcement`).
51    #[must_use]
52    pub fn from_enforcement_str(s: &str) -> Option<Self> {
53        match s.to_lowercase().as_str() {
54            "coerce" => Some(Self::Coerce),
55            "strict" => Some(Self::Reject),
56            "permissive" => Some(Self::Null),
57            _ => None,
58        }
59    }
60}
61
62/// Epoch unit for *numeric* JSON timestamps feeding a `Timestamp` column
63/// (strings still go through `timestamp_formats`).
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum EpochUnit {
66    /// Epoch seconds.
67    Seconds,
68    /// Epoch milliseconds (default).
69    #[default]
70    Millis,
71    /// Epoch microseconds.
72    Micros,
73    /// Epoch nanoseconds.
74    Nanos,
75}
76
77impl EpochUnit {
78    /// Parse a `json.column.<col>.epoch_unit` value.
79    #[must_use]
80    pub fn from_str_opt(s: &str) -> Option<Self> {
81        match s.trim().to_lowercase().as_str() {
82            "seconds" => Some(Self::Seconds),
83            "millis" => Some(Self::Millis),
84            "micros" => Some(Self::Micros),
85            "nanos" => Some(Self::Nanos),
86            _ => None,
87        }
88    }
89
90    /// Nanoseconds per one unit (used for lossless scaling).
91    const fn nanos_per(self) -> i64 {
92        match self {
93            Self::Seconds => 1_000_000_000,
94            Self::Millis => 1_000_000,
95            Self::Micros => 1_000,
96            Self::Nanos => 1,
97        }
98    }
99}
100
101/// Per-column extraction strategy, pre-computed at Ring 2.
102#[derive(Debug, Clone)]
103enum ColumnExtraction {
104    /// Extract field from the default path target (json.path or root).
105    DefaultPath,
106    /// Extract field from a custom absolute path from root.
107    CustomPath { segments: Vec<String> },
108}
109
110/// JSON decoder configuration.
111#[derive(Debug, Clone)]
112pub struct JsonDecoderConfig {
113    /// How to handle fields present in JSON but absent from the schema.
114    pub unknown_fields: UnknownFieldStrategy,
115
116    /// How to handle type mismatches.
117    pub type_mismatch: TypeMismatchStrategy,
118
119    /// Timestamp format patterns to try when parsing string values
120    /// into Timestamp columns. Tried in order; first match wins.
121    /// Use `"iso8601"` for RFC 3339 / ISO 8601 auto-detection.
122    pub timestamp_formats: Vec<String>,
123
124    /// Whether to encode nested objects as JSONB binary format
125    /// instead of JSON-serialized Utf8. When true, nested objects
126    /// become `LargeBinary` columns with JSONB encoding.
127    pub nested_as_jsonb: bool,
128
129    /// Dot-separated path to navigate before field extraction.
130    /// e.g. `"data"` → navigate into `{"data": {...}}` before extraction.
131    /// e.g. `"data.trade"` → navigate into `{"data":{"trade":{...}}}`.
132    pub json_path: Option<Vec<String>>,
133
134    /// Per-column absolute path overrides: `column_name` → path segments.
135    /// Parsed from `json.column.<name> = 'path.to.field'` options.
136    /// These paths are absolute from the root, ignoring `json_path`.
137    pub json_column_paths: HashMap<String, Vec<String>>,
138
139    /// Per-column numeric-timestamp epoch unit, from
140    /// `json.column.<name>.epoch_unit`. Absent ⇒ [`EpochUnit::Millis`].
141    pub numeric_timestamp_units: HashMap<String, EpochUnit>,
142
143    /// Column names for array-to-rows expansion.
144    /// When set, the target (after `json_path`) must be an array.
145    /// Each element becomes a row; positional names map array elements to columns.
146    pub json_explode: Option<Vec<String>>,
147}
148
149impl Default for JsonDecoderConfig {
150    fn default() -> Self {
151        Self {
152            unknown_fields: UnknownFieldStrategy::Ignore,
153            type_mismatch: TypeMismatchStrategy::Coerce,
154            timestamp_formats: vec![
155                "iso8601".into(),
156                "%Y-%m-%dT%H:%M:%S%.fZ".into(),
157                "%Y-%m-%dT%H:%M:%S%.f%:z".into(),
158                "%Y-%m-%d %H:%M:%S%.f".into(),
159                "%Y-%m-%d %H:%M:%S".into(),
160            ],
161            nested_as_jsonb: false,
162            json_path: None,
163            json_column_paths: HashMap::new(),
164            numeric_timestamp_units: HashMap::new(),
165            json_explode: None,
166        }
167    }
168}
169
170impl JsonDecoderConfig {
171    /// Build config from a [`ConnectorConfig`](crate::config::ConnectorConfig).
172    ///
173    /// Parses `json.path`, `json.column.*`, `json.explode`,
174    /// `schema.enforcement`, and `nested.as.jsonb` properties.
175    /// Epoch-unit options are validated against the frozen output schema.
176    /// Called once at Ring 2 (`CREATE SOURCE` time).
177    ///
178    /// # Errors
179    ///
180    /// Returns an invalid-configuration error when an epoch unit is unknown or
181    /// targets a missing or non-timestamp column.
182    pub fn from_connector_config(
183        config: &crate::config::ConnectorConfig,
184        schema: &SchemaRef,
185    ) -> SchemaResult<Self> {
186        let mut cfg = Self::default();
187
188        if let Some(path) = config.get("json.path") {
189            cfg.json_path = Some(parse_path_option("json.path", path)?);
190        }
191
192        let mut col_props: Vec<_> = config
193            .properties_with_prefix("json.column.")
194            .into_iter()
195            .collect();
196        col_props.sort_unstable_by(|left, right| left.0.cmp(&right.0));
197        for (col_name, val) in col_props {
198            // `json.column.<col>.epoch_unit = '<unit>'` configures numeric
199            // timestamp scaling for <col>; everything else is a path.
200            // (Column names are SQL identifiers — no dots — so the suffix
201            // split is unambiguous against dotted path *values*.)
202            if let Some(col) = col_name.strip_suffix(".epoch_unit") {
203                let unit =
204                    EpochUnit::from_str_opt(&val).ok_or_else(|| SchemaError::InvalidConfig {
205                        key: format!("json.column.{col}.epoch_unit"),
206                        message: format!(
207                            "invalid epoch unit '{val}'; expected seconds, millis, micros, or nanos"
208                        ),
209                    })?;
210                let field =
211                    schema
212                        .field_with_name(col)
213                        .map_err(|_| SchemaError::InvalidConfig {
214                            key: format!("json.column.{col}.epoch_unit"),
215                            message: format!("column '{col}' is not present in the source schema"),
216                        })?;
217                if !matches!(field.data_type(), DataType::Timestamp(_, _)) {
218                    return Err(SchemaError::InvalidConfig {
219                        key: format!("json.column.{col}.epoch_unit"),
220                        message: format!(
221                            "column '{col}' must be a Timestamp, found {:?}",
222                            field.data_type()
223                        ),
224                    });
225                }
226                cfg.numeric_timestamp_units.insert(col.to_string(), unit);
227                continue;
228            }
229            schema
230                .field_with_name(&col_name)
231                .map_err(|_| SchemaError::InvalidConfig {
232                    key: format!("json.column.{col_name}"),
233                    message: format!("column '{col_name}' is not present in the source schema"),
234                })?;
235            cfg.json_column_paths.insert(
236                col_name.clone(),
237                parse_path_option(&format!("json.column.{col_name}"), &val)?,
238            );
239        }
240
241        if let Some(explode) = config.get("json.explode") {
242            let columns: Vec<String> = explode
243                .split(',')
244                .map(str::trim)
245                .map(ToString::to_string)
246                .collect();
247            if columns.is_empty() || columns.iter().any(String::is_empty) {
248                return Err(SchemaError::InvalidConfig {
249                    key: "json.explode".into(),
250                    message: "expected one or more comma-separated schema columns".into(),
251                });
252            }
253            let mut seen = std::collections::HashSet::with_capacity(columns.len());
254            for column in &columns {
255                if !seen.insert(column) {
256                    return Err(SchemaError::InvalidConfig {
257                        key: "json.explode".into(),
258                        message: format!("column '{column}' is listed more than once"),
259                    });
260                }
261                schema
262                    .field_with_name(column)
263                    .map_err(|_| SchemaError::InvalidConfig {
264                        key: "json.explode".into(),
265                        message: format!("column '{column}' is not present in the source schema"),
266                    })?;
267            }
268            cfg.json_explode = Some(columns);
269        }
270
271        if let Some(enforcement) = config.get("schema.enforcement") {
272            cfg.type_mismatch = TypeMismatchStrategy::from_enforcement_str(enforcement)
273                .ok_or_else(|| SchemaError::InvalidConfig {
274                    key: "schema.enforcement".into(),
275                    message: format!(
276                        "invalid value '{enforcement}'; expected lenient, coerce, or strict"
277                    ),
278                })?;
279        }
280        if let Some(v) = config.get("nested.as.jsonb") {
281            cfg.nested_as_jsonb = match v.to_ascii_lowercase().as_str() {
282                "true" => true,
283                "false" => false,
284                _ => {
285                    return Err(SchemaError::InvalidConfig {
286                        key: "nested.as.jsonb".into(),
287                        message: format!("invalid boolean '{v}'; expected true or false"),
288                    });
289                }
290            };
291        }
292        Ok(cfg)
293    }
294}
295
296fn parse_path_option(key: &str, value: &str) -> Result<Vec<String>, SchemaError> {
297    let segments: Vec<String> = value
298        .split('.')
299        .map(str::trim)
300        .map(ToString::to_string)
301        .collect();
302    if segments.is_empty() || segments.iter().any(String::is_empty) {
303        return Err(SchemaError::InvalidConfig {
304            key: key.into(),
305            message: "path must contain non-empty dot-separated segments".into(),
306        });
307    }
308    Ok(segments)
309}
310
311/// Decodes JSON byte payloads into Arrow `RecordBatch`es.
312///
313/// # Ring Placement
314///
315/// - **Ring 1**: `decode_batch()` — parse JSON, build columnar Arrow output
316/// - **Ring 2**: Construction (`new` / `with_config`) — one-time setup
317pub struct JsonDecoder {
318    /// Frozen output schema.
319    schema: SchemaRef,
320    /// Decoder configuration.
321    config: JsonDecoderConfig,
322    /// Pre-computed field index map: field name → column index.
323    field_indices: Vec<(String, usize)>,
324    /// Cumulative type mismatch count (diagnostics).
325    mismatch_count: AtomicU64,
326    /// Ring 2 pre-computed: extraction strategy per schema column.
327    column_extractions: Vec<ColumnExtraction>,
328    /// Ring 2 pre-computed: numeric epoch unit per schema column
329    /// (aligned to `schema.fields()`; default [`EpochUnit::Millis`]).
330    column_epoch_units: Vec<EpochUnit>,
331    /// Ring 2 pre-computed: explode position → schema column index.
332    /// `Some` when `json.explode` is configured; each entry maps an
333    /// explode position to the schema column index (or `None` if
334    /// the explode name doesn't match any schema column).
335    explode_col_indices: Option<Vec<Option<usize>>>,
336}
337
338#[allow(clippy::missing_fields_in_debug)]
339impl std::fmt::Debug for JsonDecoder {
340    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341        f.debug_struct("JsonDecoder")
342            .field("schema", &self.schema)
343            .field("config", &self.config)
344            .field(
345                "mismatch_count",
346                &self.mismatch_count.load(Ordering::Relaxed),
347            )
348            .finish()
349    }
350}
351
352impl JsonDecoder {
353    /// Creates a new JSON decoder for the given Arrow schema.
354    #[must_use]
355    pub fn new(schema: SchemaRef) -> Self {
356        Self::with_config(schema, JsonDecoderConfig::default())
357    }
358
359    /// Creates a new JSON decoder with custom configuration.
360    #[must_use]
361    pub fn with_config(schema: SchemaRef, config: JsonDecoderConfig) -> Self {
362        let field_indices: Vec<(String, usize)> = schema
363            .fields()
364            .iter()
365            .enumerate()
366            .map(|(i, f)| (f.name().clone(), i))
367            .collect();
368
369        let column_extractions: Vec<ColumnExtraction> = schema
370            .fields()
371            .iter()
372            .map(|f| {
373                let col_name = f.name();
374                if let Some(path_segments) = config.json_column_paths.get(col_name.as_str()) {
375                    ColumnExtraction::CustomPath {
376                        segments: path_segments.clone(),
377                    }
378                } else {
379                    ColumnExtraction::DefaultPath
380                }
381            })
382            .collect();
383
384        let column_epoch_units: Vec<EpochUnit> = schema
385            .fields()
386            .iter()
387            .map(|f| {
388                config
389                    .numeric_timestamp_units
390                    .get(f.name().as_str())
391                    .copied()
392                    .unwrap_or_default()
393            })
394            .collect();
395
396        let explode_col_indices = config.json_explode.as_ref().map(|names| {
397            names
398                .iter()
399                .map(|name| {
400                    field_indices
401                        .iter()
402                        .find(|(n, _)| n == name)
403                        .map(|(_, idx)| *idx)
404                })
405                .collect()
406        });
407
408        Self {
409            schema,
410            config,
411            field_indices,
412            mismatch_count: AtomicU64::new(0),
413            column_extractions,
414            column_epoch_units,
415            explode_col_indices,
416        }
417    }
418
419    /// Returns the cumulative type mismatch count.
420    pub fn mismatch_count(&self) -> u64 {
421        self.mismatch_count.load(Ordering::Relaxed)
422    }
423}
424
425impl FormatDecoder for JsonDecoder {
426    fn output_schema(&self) -> SchemaRef {
427        self.schema.clone()
428    }
429
430    fn decode_batch(&self, records: &[RawRecord]) -> SchemaResult<RecordBatch> {
431        let values: Vec<&[u8]> = records.iter().map(|r| r.value.as_slice()).collect();
432        self.decode_slices(&values)
433    }
434
435    fn format_name(&self) -> &'static str {
436        "json"
437    }
438}
439
440impl JsonDecoder {
441    /// Decode borrowed JSON payload slices into a `RecordBatch` — avoids the owned
442    /// copy a `RawRecord` requires. Used by the streaming deserializer.
443    ///
444    /// # Errors
445    /// Returns `SchemaError::DecodeError` if a payload is not valid JSON or the
446    /// decoded values cannot be assembled into the output schema.
447    #[allow(clippy::too_many_lines)]
448    pub fn decode_slices(&self, values: &[&[u8]]) -> SchemaResult<RecordBatch> {
449        self.decode_slices_bounded(values, usize::MAX)
450    }
451
452    /// Decode borrowed JSON payloads while rejecting output expansion beyond
453    /// `max_rows` before values are appended to Arrow builders.
454    ///
455    /// # Errors
456    /// Returns `SchemaError::DecodeError` when parsing, coercion, or
457    /// `json.explode` would exceed the row bound.
458    pub fn decode_slices_bounded(
459        &self,
460        values: &[&[u8]],
461        max_rows: usize,
462    ) -> SchemaResult<RecordBatch> {
463        if values.is_empty() {
464            return Ok(RecordBatch::new_empty(self.schema.clone()));
465        }
466
467        let num_fields = self.schema.fields().len();
468        let capacity = values.len();
469
470        // Initialise one builder per schema column.
471        let mut builders = create_builders(&self.schema, capacity);
472
473        // Optional _extra JSONB column for CollectExtra strategy.
474        let collect_extra = matches!(
475            self.config.unknown_fields,
476            UnknownFieldStrategy::CollectExtra
477        );
478        let mut extra_builder = if collect_extra {
479            Some(LargeBinaryBuilder::with_capacity(capacity, capacity * 64))
480        } else {
481            None
482        };
483
484        let mut jsonb_encoder = if self.config.nested_as_jsonb {
485            Some(JsonbEncoder::new())
486        } else {
487            None
488        };
489
490        // Reusable populated-tracking buffer — avoids heap alloc per record.
491        let mut populated = vec![false; num_fields];
492        let mut output_rows = 0usize;
493
494        for &bytes in values {
495            let value: serde_json::Value = serde_json::from_slice(bytes)
496                .map_err(|e| SchemaError::DecodeError(format!("JSON parse error: {e}")))?;
497
498            // Navigate json.path → default target (borrowed, ZERO alloc).
499            // Records lacking the configured path (subscribe acks, server
500            // status frames) are skipped silently. Erroring the batch would
501            // poison the source buffer since failures don't clear consumed
502            // messages.
503            let default_target: &serde_json::Value =
504                match navigate_path_opt(&value, self.config.json_path.as_deref()) {
505                    Some(v) => v,
506                    None => continue,
507                };
508
509            if let Some(ref col_indices) = self.explode_col_indices {
510                // === EXPLODE MODE ===
511                let arr = default_target.as_array().ok_or_else(|| {
512                    SchemaError::DecodeError("json.explode target must be an array".into())
513                })?;
514                output_rows = output_rows
515                    .checked_add(arr.len())
516                    .filter(|rows| *rows <= max_rows)
517                    .ok_or_else(|| {
518                        SchemaError::DecodeError(format!(
519                            "JSON output exceeds the {max_rows}-row batch limit"
520                        ))
521                    })?;
522                for element in arr {
523                    populated.fill(false);
524                    match element {
525                        serde_json::Value::Array(items) => {
526                            for (pos, col_idx_opt) in col_indices.iter().enumerate() {
527                                if let Some(col_idx) = col_idx_opt {
528                                    let val = items.get(pos).unwrap_or(&serde_json::Value::Null);
529                                    let field = &self.schema.fields()[*col_idx];
530                                    append_value(
531                                        &mut builders[*col_idx],
532                                        field.data_type(),
533                                        val,
534                                        &self.config,
535                                        &self.mismatch_count,
536                                        jsonb_encoder.as_mut(),
537                                        self.column_epoch_units[*col_idx],
538                                    )?;
539                                    populated[*col_idx] = true;
540                                }
541                            }
542                        }
543                        serde_json::Value::Object(obj) => {
544                            for (col_idx, (col_name, _)) in self.field_indices.iter().enumerate() {
545                                if let Some(val) = obj.get(col_name.as_str()) {
546                                    let field = &self.schema.fields()[col_idx];
547                                    append_value(
548                                        &mut builders[col_idx],
549                                        field.data_type(),
550                                        val,
551                                        &self.config,
552                                        &self.mismatch_count,
553                                        jsonb_encoder.as_mut(),
554                                        self.column_epoch_units[col_idx],
555                                    )?;
556                                    populated[col_idx] = true;
557                                }
558                            }
559                        }
560                        _ => {
561                            return Err(SchemaError::DecodeError(
562                                "json.explode array elements must be arrays or objects".into(),
563                            ));
564                        }
565                    }
566                    // Append nulls for missing fields.
567                    for (col_idx, was_populated) in populated.iter().enumerate() {
568                        if !was_populated {
569                            append_null(&mut builders[col_idx]);
570                        }
571                    }
572                    // _extra column: append null for each exploded row.
573                    if let Some(ref mut eb) = extra_builder {
574                        eb.append_null();
575                    }
576                }
577            } else {
578                // === NORMAL OBJECT MODE (with per-column path support) ===
579                output_rows = output_rows
580                    .checked_add(1)
581                    .filter(|rows| *rows <= max_rows)
582                    .ok_or_else(|| {
583                        SchemaError::DecodeError(format!(
584                            "JSON output exceeds the {max_rows}-row batch limit"
585                        ))
586                    })?;
587                let default_obj = default_target.as_object().ok_or_else(|| {
588                    SchemaError::DecodeError("JSON value must be an object".into())
589                })?;
590
591                populated.fill(false);
592
593                // Collect unknown fields for CollectExtra.
594                let mut extra_fields: Option<serde_json::Map<String, serde_json::Value>> =
595                    if collect_extra {
596                        Some(serde_json::Map::new())
597                    } else {
598                        None
599                    };
600
601                // Per-column extraction using pre-computed ColumnExtraction.
602                for (col_idx, (col_name, _)) in self.field_indices.iter().enumerate() {
603                    match &self.column_extractions[col_idx] {
604                        ColumnExtraction::DefaultPath => {
605                            if let Some(val) = default_obj.get(col_name.as_str()) {
606                                populated[col_idx] = true;
607                                let field = &self.schema.fields()[col_idx];
608                                append_value(
609                                    &mut builders[col_idx],
610                                    field.data_type(),
611                                    val,
612                                    &self.config,
613                                    &self.mismatch_count,
614                                    jsonb_encoder.as_mut(),
615                                    self.column_epoch_units[col_idx],
616                                )?;
617                            }
618                        }
619                        ColumnExtraction::CustomPath { segments } => {
620                            // Navigate custom path from root (ZERO alloc).
621                            let extracted = navigate_path(&value, segments);
622                            if let Some(val) = extracted {
623                                populated[col_idx] = true;
624                                let field = &self.schema.fields()[col_idx];
625                                append_value(
626                                    &mut builders[col_idx],
627                                    field.data_type(),
628                                    val,
629                                    &self.config,
630                                    &self.mismatch_count,
631                                    jsonb_encoder.as_mut(),
632                                    self.column_epoch_units[col_idx],
633                                )?;
634                            }
635                        }
636                    }
637                }
638
639                // Collect unknown fields (fields in default_obj not in schema).
640                if collect_extra {
641                    for (key, val) in default_obj {
642                        if self.field_index(key).is_none() {
643                            match self.config.unknown_fields {
644                                UnknownFieldStrategy::CollectExtra => {
645                                    if let Some(ref mut extra) = extra_fields {
646                                        extra.insert(key.clone(), val.clone());
647                                    }
648                                }
649                                UnknownFieldStrategy::Reject => {
650                                    return Err(SchemaError::DecodeError(format!(
651                                        "unknown field '{key}' not in schema"
652                                    )));
653                                }
654                                UnknownFieldStrategy::Ignore => {}
655                            }
656                        }
657                    }
658                } else if matches!(self.config.unknown_fields, UnknownFieldStrategy::Reject) {
659                    for key in default_obj.keys() {
660                        if self.field_index(key).is_none() {
661                            return Err(SchemaError::DecodeError(format!(
662                                "unknown field '{key}' not in schema"
663                            )));
664                        }
665                    }
666                }
667
668                // Append nulls for missing fields.
669                for (col_idx, was_populated) in populated.iter().enumerate() {
670                    if !was_populated {
671                        append_null(&mut builders[col_idx]);
672                    }
673                }
674
675                // Append _extra column.
676                if let Some(ref mut eb) = extra_builder {
677                    if let Some(ref extra) = extra_fields {
678                        if extra.is_empty() {
679                            eb.append_null();
680                        } else {
681                            let mut enc = jsonb_encoder
682                                .as_mut()
683                                .map_or_else(JsonbEncoder::new, |_| JsonbEncoder::new());
684                            let bytes = enc.encode(&serde_json::Value::Object(extra.clone()));
685                            eb.append_value(&bytes);
686                        }
687                    } else {
688                        eb.append_null();
689                    }
690                }
691            }
692        }
693
694        // Finish all builders into arrays.
695        let mut columns: Vec<ArrayRef> = builders.into_iter().map(|mut b| b.finish()).collect();
696
697        // Append _extra column if present.
698        let final_schema = if let Some(mut eb) = extra_builder {
699            columns.push(Arc::new(eb.finish()));
700            let mut fields = self.schema.fields().to_vec();
701            fields.push(Arc::new(arrow_schema::Field::new(
702                "_extra",
703                DataType::LargeBinary,
704                true,
705            )));
706            Arc::new(arrow_schema::Schema::new(fields))
707        } else {
708            self.schema.clone()
709        };
710
711        RecordBatch::try_new(final_schema, columns)
712            .map_err(|e| SchemaError::DecodeError(format!("RecordBatch construction: {e}")))
713    }
714
715    /// O(n) field lookup. For schemas with many fields, consider switching
716    /// to a `HashMap`; for typical schemas (<50 fields) linear scan is faster.
717    fn field_index(&self, name: &str) -> Option<usize> {
718        self.field_indices
719            .iter()
720            .find(|(n, _)| n == name)
721            .map(|(_, idx)| *idx)
722    }
723}
724
725/// Navigate a dot-path through a JSON value tree. Returns `None` if any
726/// segment is missing. Zero allocations — pure pointer-chasing.
727fn navigate_path<'a>(
728    root: &'a serde_json::Value,
729    segments: &[String],
730) -> Option<&'a serde_json::Value> {
731    let mut current = root;
732    for segment in segments {
733        current = current.get(segment.as_str())?;
734    }
735    Some(current)
736}
737
738/// Like `navigate_path`, but `None` segments mean "no path configured —
739/// return the root unchanged."
740fn navigate_path_opt<'a>(
741    root: &'a serde_json::Value,
742    segments: Option<&[String]>,
743) -> Option<&'a serde_json::Value> {
744    match segments {
745        Some(s) => navigate_path(root, s),
746        None => Some(root),
747    }
748}
749
750// ── Builder helpers ────────────────────────────────────────────────
751
752/// Trait-object wrapper so we can store heterogeneous builders in a `Vec`.
753trait ColumnBuilder: Send {
754    fn finish(&mut self) -> ArrayRef;
755    fn append_null_value(&mut self);
756    fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
757}
758
759macro_rules! impl_column_builder {
760    ($builder:ty, $array:ty) => {
761        impl ColumnBuilder for $builder {
762            fn finish(&mut self) -> ArrayRef {
763                Arc::new(<$builder>::finish(self))
764            }
765            fn append_null_value(&mut self) {
766                self.append_null();
767            }
768            fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
769                self
770            }
771        }
772    };
773}
774
775impl_column_builder!(BooleanBuilder, arrow_array::BooleanArray);
776impl_column_builder!(Int8Builder, arrow_array::Int8Array);
777impl_column_builder!(Int16Builder, arrow_array::Int16Array);
778impl_column_builder!(Int32Builder, arrow_array::Int32Array);
779impl_column_builder!(Int64Builder, arrow_array::Int64Array);
780impl_column_builder!(UInt8Builder, arrow_array::UInt8Array);
781impl_column_builder!(UInt16Builder, arrow_array::UInt16Array);
782impl_column_builder!(UInt32Builder, arrow_array::UInt32Array);
783impl_column_builder!(UInt64Builder, arrow_array::UInt64Array);
784impl_column_builder!(Float32Builder, arrow_array::Float32Array);
785impl_column_builder!(Float64Builder, arrow_array::Float64Array);
786impl_column_builder!(StringBuilder, arrow_array::StringArray);
787impl_column_builder!(ListBuilder<StringBuilder>, arrow_array::ListArray);
788impl_column_builder!(LargeStringBuilder, arrow_array::LargeStringArray);
789impl_column_builder!(LargeBinaryBuilder, arrow_array::LargeBinaryArray);
790impl_column_builder!(TimestampSecondBuilder, arrow_array::TimestampSecondArray);
791impl_column_builder!(
792    TimestampMillisecondBuilder,
793    arrow_array::TimestampMillisecondArray
794);
795impl_column_builder!(
796    TimestampMicrosecondBuilder,
797    arrow_array::TimestampMicrosecondArray
798);
799impl_column_builder!(
800    TimestampNanosecondBuilder,
801    arrow_array::TimestampNanosecondArray
802);
803
804fn create_builders(schema: &SchemaRef, capacity: usize) -> Vec<Box<dyn ColumnBuilder>> {
805    schema
806        .fields()
807        .iter()
808        .map(|f| create_builder(f.data_type(), capacity))
809        .collect()
810}
811
812fn create_builder(data_type: &DataType, capacity: usize) -> Box<dyn ColumnBuilder> {
813    match data_type {
814        DataType::Boolean => Box::new(BooleanBuilder::with_capacity(capacity)),
815        DataType::Int8 => Box::new(Int8Builder::with_capacity(capacity)),
816        DataType::Int16 => Box::new(Int16Builder::with_capacity(capacity)),
817        DataType::Int32 => Box::new(Int32Builder::with_capacity(capacity)),
818        DataType::Int64 => Box::new(Int64Builder::with_capacity(capacity)),
819        DataType::UInt8 => Box::new(UInt8Builder::with_capacity(capacity)),
820        DataType::UInt16 => Box::new(UInt16Builder::with_capacity(capacity)),
821        DataType::UInt32 => Box::new(UInt32Builder::with_capacity(capacity)),
822        DataType::UInt64 => Box::new(UInt64Builder::with_capacity(capacity)),
823        DataType::Float32 => Box::new(Float32Builder::with_capacity(capacity)),
824        DataType::Float64 => Box::new(Float64Builder::with_capacity(capacity)),
825        DataType::LargeUtf8 => Box::new(LargeStringBuilder::with_capacity(capacity, capacity * 32)),
826        DataType::LargeBinary => {
827            Box::new(LargeBinaryBuilder::with_capacity(capacity, capacity * 64))
828        }
829        DataType::Timestamp(TimeUnit::Second, tz) => {
830            let builder =
831                TimestampSecondBuilder::with_capacity(capacity).with_timezone_opt(tz.clone());
832            Box::new(builder)
833        }
834        DataType::Timestamp(TimeUnit::Millisecond, tz) => {
835            let builder =
836                TimestampMillisecondBuilder::with_capacity(capacity).with_timezone_opt(tz.clone());
837            Box::new(builder)
838        }
839        DataType::Timestamp(TimeUnit::Microsecond, tz) => {
840            let builder =
841                TimestampMicrosecondBuilder::with_capacity(capacity).with_timezone_opt(tz.clone());
842            Box::new(builder)
843        }
844        DataType::Timestamp(TimeUnit::Nanosecond, tz) => {
845            let builder =
846                TimestampNanosecondBuilder::with_capacity(capacity).with_timezone_opt(tz.clone());
847            Box::new(builder)
848        }
849        DataType::List(field) if matches!(field.data_type(), DataType::Utf8) => {
850            Box::new(ListBuilder::new(StringBuilder::new()))
851        }
852        // Fallback: serialize as JSON string.
853        _ => Box::new(StringBuilder::with_capacity(capacity, capacity * 32)),
854    }
855}
856
857fn append_null(builder: &mut Box<dyn ColumnBuilder>) {
858    builder.append_null_value();
859}
860
861/// Append a JSON value to the appropriate builder column.
862#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
863fn append_value(
864    builder: &mut Box<dyn ColumnBuilder>,
865    target_type: &DataType,
866    value: &serde_json::Value,
867    config: &JsonDecoderConfig,
868    mismatch_count: &AtomicU64,
869    jsonb_encoder: Option<&mut JsonbEncoder>,
870    numeric_ts_unit: EpochUnit,
871) -> SchemaResult<()> {
872    if value.is_null() {
873        builder.append_null_value();
874        return Ok(());
875    }
876
877    match target_type {
878        DataType::Boolean => {
879            let b = builder
880                .as_any_mut()
881                .downcast_mut::<BooleanBuilder>()
882                .unwrap();
883            match extract_bool(value, config) {
884                Ok(v) => b.append_value(v),
885                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
886            }
887        }
888        DataType::Int8 => {
889            let b = builder.as_any_mut().downcast_mut::<Int8Builder>().unwrap();
890            match extract_i8(value, config) {
891                Ok(v) => b.append_value(v),
892                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
893            }
894        }
895        DataType::Int16 => {
896            let b = builder.as_any_mut().downcast_mut::<Int16Builder>().unwrap();
897            match extract_i16(value, config) {
898                Ok(v) => b.append_value(v),
899                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
900            }
901        }
902        DataType::Int32 => {
903            let b = builder.as_any_mut().downcast_mut::<Int32Builder>().unwrap();
904            match extract_i32(value, config) {
905                Ok(v) => b.append_value(v),
906                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
907            }
908        }
909        DataType::Int64 => {
910            let b = builder.as_any_mut().downcast_mut::<Int64Builder>().unwrap();
911            match extract_i64(value, config) {
912                Ok(v) => b.append_value(v),
913                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
914            }
915        }
916        DataType::UInt8 => {
917            let b = builder.as_any_mut().downcast_mut::<UInt8Builder>().unwrap();
918            match extract_u8(value, config) {
919                Ok(v) => b.append_value(v),
920                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
921            }
922        }
923        DataType::UInt16 => {
924            let b = builder
925                .as_any_mut()
926                .downcast_mut::<UInt16Builder>()
927                .unwrap();
928            match extract_u16(value, config) {
929                Ok(v) => b.append_value(v),
930                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
931            }
932        }
933        DataType::UInt32 => {
934            let b = builder
935                .as_any_mut()
936                .downcast_mut::<UInt32Builder>()
937                .unwrap();
938            match extract_u32(value, config) {
939                Ok(v) => b.append_value(v),
940                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
941            }
942        }
943        DataType::UInt64 => {
944            let b = builder
945                .as_any_mut()
946                .downcast_mut::<UInt64Builder>()
947                .unwrap();
948            match extract_u64(value, config) {
949                Ok(v) => b.append_value(v),
950                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
951            }
952        }
953        DataType::Float32 => {
954            let b = builder
955                .as_any_mut()
956                .downcast_mut::<Float32Builder>()
957                .unwrap();
958            match extract_f32(value, config) {
959                Ok(v) => b.append_value(v),
960                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
961            }
962        }
963        DataType::Float64 => {
964            let b = builder
965                .as_any_mut()
966                .downcast_mut::<Float64Builder>()
967                .unwrap();
968            match extract_f64(value, config) {
969                Ok(v) => b.append_value(v),
970                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
971            }
972        }
973        DataType::LargeUtf8 => {
974            let b = builder
975                .as_any_mut()
976                .downcast_mut::<LargeStringBuilder>()
977                .unwrap();
978            let s = value_to_string(value);
979            b.append_value(&s);
980        }
981        DataType::LargeBinary => {
982            let b = builder
983                .as_any_mut()
984                .downcast_mut::<LargeBinaryBuilder>()
985                .unwrap();
986            if let Some(enc) = jsonb_encoder {
987                let bytes = enc.encode(value);
988                b.append_value(&bytes);
989            } else {
990                // Fallback: serialize as JSON bytes.
991                let bytes = serde_json::to_vec(value).unwrap_or_default();
992                b.append_value(&bytes);
993            }
994        }
995        DataType::Timestamp(unit, _) => {
996            match extract_timestamp(value, config, *unit, numeric_ts_unit) {
997                Ok(ts) => append_timestamp(builder, *unit, ts),
998                Err(e) => handle_mismatch(builder, config, mismatch_count, &e)?,
999            }
1000        }
1001        DataType::List(field) if matches!(field.data_type(), DataType::Utf8) => {
1002            if let Some(items) = value.as_array() {
1003                let b = builder
1004                    .as_any_mut()
1005                    .downcast_mut::<ListBuilder<StringBuilder>>()
1006                    .unwrap();
1007                for item in items {
1008                    if let Some(s) = item.as_str() {
1009                        b.values().append_value(s);
1010                    } else if item.is_null() {
1011                        b.values().append_null();
1012                    } else {
1013                        b.values().append_value(value_to_string(item));
1014                    }
1015                }
1016                b.append(true);
1017            } else {
1018                // A non-array value for a list column is a type mismatch; honor
1019                // the Null/Coerce/Reject policy like the scalar arms above.
1020                handle_mismatch(
1021                    builder,
1022                    config,
1023                    mismatch_count,
1024                    &format!("expected array, got {}", json_type_name(value)),
1025                )?;
1026            }
1027        }
1028        // Unsupported types: serialize as JSON string.
1029        _ => {
1030            let b = builder
1031                .as_any_mut()
1032                .downcast_mut::<StringBuilder>()
1033                .unwrap();
1034            let s = value_to_string(value);
1035            b.append_value(&s);
1036        }
1037    }
1038
1039    Ok(())
1040}
1041
1042fn handle_mismatch(
1043    builder: &mut Box<dyn ColumnBuilder>,
1044    config: &JsonDecoderConfig,
1045    mismatch_count: &AtomicU64,
1046    error_msg: &str,
1047) -> SchemaResult<()> {
1048    match config.type_mismatch {
1049        TypeMismatchStrategy::Null => {
1050            mismatch_count.fetch_add(1, Ordering::Relaxed);
1051            builder.append_null_value();
1052            Ok(())
1053        }
1054        TypeMismatchStrategy::Coerce => {
1055            // Coercion already failed in the extractor — this is a real error.
1056            Err(SchemaError::DecodeError(format!(
1057                "type coercion failed: {error_msg}"
1058            )))
1059        }
1060        TypeMismatchStrategy::Reject => Err(SchemaError::DecodeError(format!(
1061            "type mismatch: {error_msg}"
1062        ))),
1063    }
1064}
1065
1066// ── Value extractors ───────────────────────────────────────────────
1067
1068fn extract_bool(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<bool, String> {
1069    if let Some(b) = value.as_bool() {
1070        return Ok(b);
1071    }
1072    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1073        if let Some(s) = value.as_str() {
1074            match s.to_ascii_lowercase().as_str() {
1075                "true" | "1" | "yes" => return Ok(true),
1076                "false" | "0" | "no" => return Ok(false),
1077                _ => {}
1078            }
1079        }
1080        if let Some(n) = value.as_i64() {
1081            return Ok(n != 0);
1082        }
1083    }
1084    Err(format!("expected boolean, got {}", json_type_name(value)))
1085}
1086
1087fn extract_i8(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<i8, String> {
1088    if let Some(n) = value.as_i64() {
1089        if let Ok(v) = i8::try_from(n) {
1090            return Ok(v);
1091        }
1092        return Err(format!("integer {n} out of i8 range"));
1093    }
1094    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1095        if let Some(s) = value.as_str() {
1096            if let Ok(v) = s.parse::<i8>() {
1097                return Ok(v);
1098            }
1099        }
1100        if let Some(f) = value.as_f64() {
1101            #[allow(clippy::cast_possible_truncation)]
1102            let v = f as i8;
1103            return Ok(v);
1104        }
1105    }
1106    Err(format!("expected i8, got {}", json_type_name(value)))
1107}
1108
1109fn extract_i16(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<i16, String> {
1110    if let Some(n) = value.as_i64() {
1111        if let Ok(v) = i16::try_from(n) {
1112            return Ok(v);
1113        }
1114        return Err(format!("integer {n} out of i16 range"));
1115    }
1116    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1117        if let Some(s) = value.as_str() {
1118            if let Ok(v) = s.parse::<i16>() {
1119                return Ok(v);
1120            }
1121        }
1122        if let Some(f) = value.as_f64() {
1123            #[allow(clippy::cast_possible_truncation)]
1124            let v = f as i16;
1125            return Ok(v);
1126        }
1127    }
1128    Err(format!("expected i16, got {}", json_type_name(value)))
1129}
1130
1131fn extract_i32(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<i32, String> {
1132    if let Some(n) = value.as_i64() {
1133        if let Ok(v) = i32::try_from(n) {
1134            return Ok(v);
1135        }
1136        return Err(format!("integer {n} out of i32 range"));
1137    }
1138    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1139        if let Some(s) = value.as_str() {
1140            if let Ok(v) = s.parse::<i32>() {
1141                return Ok(v);
1142            }
1143        }
1144        if let Some(f) = value.as_f64() {
1145            #[allow(clippy::cast_possible_truncation)]
1146            return Ok(f as i32);
1147        }
1148    }
1149    Err(format!("expected i32, got {}", json_type_name(value)))
1150}
1151
1152fn extract_i64(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<i64, String> {
1153    if let Some(n) = value.as_i64() {
1154        return Ok(n);
1155    }
1156    if let Some(n) = value.as_u64() {
1157        if let Ok(v) = i64::try_from(n) {
1158            return Ok(v);
1159        }
1160        return Err(format!("u64 {n} out of i64 range"));
1161    }
1162    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1163        if let Some(s) = value.as_str() {
1164            if let Ok(v) = s.parse::<i64>() {
1165                return Ok(v);
1166            }
1167        }
1168        if let Some(f) = value.as_f64() {
1169            #[allow(clippy::cast_possible_truncation)]
1170            return Ok(f as i64);
1171        }
1172    }
1173    Err(format!("expected i64, got {}", json_type_name(value)))
1174}
1175
1176fn extract_f32(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<f32, String> {
1177    if let Some(f) = value.as_f64() {
1178        #[allow(clippy::cast_possible_truncation)]
1179        return Ok(f as f32);
1180    }
1181    if let Some(n) = value.as_i64() {
1182        #[allow(clippy::cast_precision_loss)]
1183        return Ok(n as f32);
1184    }
1185    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1186        if let Some(s) = value.as_str() {
1187            if let Ok(v) = s.parse::<f32>() {
1188                return Ok(v);
1189            }
1190        }
1191    }
1192    Err(format!("expected f32, got {}", json_type_name(value)))
1193}
1194
1195fn extract_f64(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<f64, String> {
1196    if let Some(f) = value.as_f64() {
1197        return Ok(f);
1198    }
1199    if let Some(n) = value.as_i64() {
1200        #[allow(clippy::cast_precision_loss)]
1201        return Ok(n as f64);
1202    }
1203    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1204        if let Some(s) = value.as_str() {
1205            if let Ok(v) = s.parse::<f64>() {
1206                return Ok(v);
1207            }
1208        }
1209    }
1210    Err(format!("expected f64, got {}", json_type_name(value)))
1211}
1212
1213fn extract_u8(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<u8, String> {
1214    if let Some(n) = value.as_u64() {
1215        if let Ok(v) = u8::try_from(n) {
1216            return Ok(v);
1217        }
1218        return Err(format!("integer {n} out of u8 range"));
1219    }
1220    if let Some(n) = value.as_i64() {
1221        if let Ok(v) = u8::try_from(n) {
1222            return Ok(v);
1223        }
1224        return Err(format!("integer {n} out of u8 range"));
1225    }
1226    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1227        if let Some(s) = value.as_str() {
1228            if let Ok(v) = s.parse::<u8>() {
1229                return Ok(v);
1230            }
1231        }
1232    }
1233    Err(format!("expected u8, got {}", json_type_name(value)))
1234}
1235
1236fn extract_u16(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<u16, String> {
1237    if let Some(n) = value.as_u64() {
1238        if let Ok(v) = u16::try_from(n) {
1239            return Ok(v);
1240        }
1241        return Err(format!("integer {n} out of u16 range"));
1242    }
1243    if let Some(n) = value.as_i64() {
1244        if let Ok(v) = u16::try_from(n) {
1245            return Ok(v);
1246        }
1247        return Err(format!("integer {n} out of u16 range"));
1248    }
1249    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1250        if let Some(s) = value.as_str() {
1251            if let Ok(v) = s.parse::<u16>() {
1252                return Ok(v);
1253            }
1254        }
1255    }
1256    Err(format!("expected u16, got {}", json_type_name(value)))
1257}
1258
1259fn extract_u32(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<u32, String> {
1260    if let Some(n) = value.as_u64() {
1261        if let Ok(v) = u32::try_from(n) {
1262            return Ok(v);
1263        }
1264        return Err(format!("integer {n} out of u32 range"));
1265    }
1266    if let Some(n) = value.as_i64() {
1267        if let Ok(v) = u32::try_from(n) {
1268            return Ok(v);
1269        }
1270        return Err(format!("integer {n} out of u32 range"));
1271    }
1272    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1273        if let Some(s) = value.as_str() {
1274            if let Ok(v) = s.parse::<u32>() {
1275                return Ok(v);
1276            }
1277        }
1278    }
1279    Err(format!("expected u32, got {}", json_type_name(value)))
1280}
1281
1282fn extract_u64(value: &serde_json::Value, config: &JsonDecoderConfig) -> Result<u64, String> {
1283    if let Some(n) = value.as_u64() {
1284        return Ok(n);
1285    }
1286    if let Some(n) = value.as_i64() {
1287        if let Ok(v) = u64::try_from(n) {
1288            return Ok(v);
1289        }
1290        return Err(format!("integer {n} out of u64 range"));
1291    }
1292    if matches!(config.type_mismatch, TypeMismatchStrategy::Coerce) {
1293        if let Some(s) = value.as_str() {
1294            if let Ok(v) = s.parse::<u64>() {
1295                return Ok(v);
1296            }
1297        }
1298    }
1299    Err(format!("expected u64, got {}", json_type_name(value)))
1300}
1301
1302/// Extracts a timestamp value as an i64 in the specified [`TimeUnit`].
1303///
1304/// For numeric JSON values, scales from `from` (the column's configured
1305/// [`EpochUnit`], default millis) to the target Arrow `TimeUnit`. For
1306/// string values, tries the configured timestamp format patterns (the
1307/// `from` unit does not apply — strings carry their own resolution).
1308fn extract_timestamp(
1309    value: &serde_json::Value,
1310    config: &JsonDecoderConfig,
1311    unit: TimeUnit,
1312    from: EpochUnit,
1313) -> Result<i64, String> {
1314    if let Some(n) = value.as_i64() {
1315        return checked_epoch_to_unit(n, from, unit);
1316    }
1317    if let Some(f) = value.as_f64() {
1318        // JSON numbers written with a decimal point or exponent reach this
1319        // branch. Never round a fractional epoch: that can move a record
1320        // across a watermark boundary. Integral f64 values are accepted only
1321        // inside the IEEE-754 exact-integer range so the source text cannot
1322        // already have lost integer precision during JSON parsing.
1323        const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
1324        if !f.is_finite() || !(-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&f) {
1325            return Err(format!(
1326                "timestamp {f} is not exactly representable as an integer"
1327            ));
1328        }
1329        if f.fract() != 0.0 {
1330            return Err(format!(
1331                "fractional timestamp {f} is not supported; provide an integer in {from:?} units"
1332            ));
1333        }
1334        let v = format!("{f:.0}")
1335            .parse::<i64>()
1336            .map_err(|error| format!("invalid integral timestamp {f}: {error}"))?;
1337        return checked_epoch_to_unit(v, from, unit);
1338    }
1339
1340    if let Some(s) = value.as_str() {
1341        for fmt in &config.timestamp_formats {
1342            if fmt == "iso8601" {
1343                if let Ok(nanos) = arrow_cast::parse::string_to_timestamp_nanos(s) {
1344                    return Ok(nanos_to_unit(nanos, unit));
1345                }
1346                continue;
1347            }
1348            if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
1349                let nanos = ndt.and_utc().timestamp_nanos_opt().unwrap_or(0);
1350                return Ok(nanos_to_unit(nanos, unit));
1351            }
1352        }
1353        return Err(format!("cannot parse timestamp from string: {s}"));
1354    }
1355
1356    Err(format!("expected timestamp, got {}", json_type_name(value)))
1357}
1358
1359/// Scales an integer epoch `value` from `from` units to the target Arrow
1360/// `TimeUnit`. Up-scaling (e.g. seconds→nanos) is checked and errors
1361/// rather than wrapping on i64 overflow; down-scaling (e.g. nanos→millis)
1362/// truncates toward zero, the conventional and expected precision loss.
1363fn checked_epoch_to_unit(value: i64, from: EpochUnit, to: TimeUnit) -> Result<i64, String> {
1364    let from_ns = from.nanos_per();
1365    let to_ns = match to {
1366        TimeUnit::Second => 1_000_000_000,
1367        TimeUnit::Millisecond => 1_000_000,
1368        TimeUnit::Microsecond => 1_000,
1369        TimeUnit::Nanosecond => 1,
1370    };
1371    if from_ns >= to_ns {
1372        // Exact integer factor (both are powers-of-ten multiples of 1ns).
1373        let factor = from_ns / to_ns;
1374        value
1375            .checked_mul(factor)
1376            .ok_or_else(|| format!("timestamp {value} ({from:?}) out of i64 {to:?} range"))
1377    } else {
1378        let factor = to_ns / from_ns;
1379        Ok(value / factor)
1380    }
1381}
1382
1383/// Converts nanoseconds to the target time unit.
1384fn nanos_to_unit(nanos: i64, unit: TimeUnit) -> i64 {
1385    match unit {
1386        TimeUnit::Second => nanos / 1_000_000_000,
1387        TimeUnit::Millisecond => nanos / 1_000_000,
1388        TimeUnit::Microsecond => nanos / 1_000,
1389        TimeUnit::Nanosecond => nanos,
1390    }
1391}
1392
1393/// Appends a timestamp value to the appropriate builder based on [`TimeUnit`].
1394fn append_timestamp(builder: &mut Box<dyn ColumnBuilder>, unit: TimeUnit, value: i64) {
1395    match unit {
1396        TimeUnit::Second => {
1397            builder
1398                .as_any_mut()
1399                .downcast_mut::<TimestampSecondBuilder>()
1400                .unwrap()
1401                .append_value(value);
1402        }
1403        TimeUnit::Millisecond => {
1404            builder
1405                .as_any_mut()
1406                .downcast_mut::<TimestampMillisecondBuilder>()
1407                .unwrap()
1408                .append_value(value);
1409        }
1410        TimeUnit::Microsecond => {
1411            builder
1412                .as_any_mut()
1413                .downcast_mut::<TimestampMicrosecondBuilder>()
1414                .unwrap()
1415                .append_value(value);
1416        }
1417        TimeUnit::Nanosecond => {
1418            builder
1419                .as_any_mut()
1420                .downcast_mut::<TimestampNanosecondBuilder>()
1421                .unwrap()
1422                .append_value(value);
1423        }
1424    }
1425}
1426
1427fn value_to_string(value: &serde_json::Value) -> String {
1428    match value {
1429        serde_json::Value::String(s) => s.clone(),
1430        other => other.to_string(),
1431    }
1432}
1433
1434fn json_type_name(value: &serde_json::Value) -> &'static str {
1435    match value {
1436        serde_json::Value::Null => "null",
1437        serde_json::Value::Bool(_) => "boolean",
1438        serde_json::Value::Number(_) => "number",
1439        serde_json::Value::String(_) => "string",
1440        serde_json::Value::Array(_) => "array",
1441        serde_json::Value::Object(_) => "object",
1442    }
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447    use super::*;
1448    use arrow_array::cast::AsArray;
1449    use arrow_schema::{Field, Schema};
1450
1451    fn make_schema(fields: Vec<(&str, DataType, bool)>) -> SchemaRef {
1452        Arc::new(Schema::new(
1453            fields
1454                .into_iter()
1455                .map(|(name, dt, nullable)| Field::new(name, dt, nullable))
1456                .collect::<Vec<_>>(),
1457        ))
1458    }
1459
1460    fn json_record(json: &str) -> RawRecord {
1461        RawRecord::new(json.as_bytes().to_vec())
1462    }
1463
1464    // ── Basic decode tests ────────────────────────────────────
1465
1466    #[test]
1467    fn test_decode_empty_batch() {
1468        let schema = make_schema(vec![("id", DataType::Int64, false)]);
1469        let decoder = JsonDecoder::new(schema.clone());
1470        let batch = decoder.decode_batch(&[]).unwrap();
1471        assert_eq!(batch.num_rows(), 0);
1472        assert_eq!(batch.schema(), schema);
1473    }
1474
1475    #[test]
1476    fn test_decode_single_record() {
1477        let schema = make_schema(vec![
1478            ("id", DataType::Int64, false),
1479            ("name", DataType::Utf8, true),
1480        ]);
1481        let decoder = JsonDecoder::new(schema);
1482        let records = vec![json_record(r#"{"id": 42, "name": "Alice"}"#)];
1483        let batch = decoder.decode_batch(&records).unwrap();
1484
1485        assert_eq!(batch.num_rows(), 1);
1486        assert_eq!(
1487            batch
1488                .column(0)
1489                .as_primitive::<arrow_array::types::Int64Type>()
1490                .value(0),
1491            42
1492        );
1493        assert_eq!(batch.column(1).as_string::<i32>().value(0), "Alice");
1494    }
1495
1496    #[test]
1497    fn test_decode_multiple_records() {
1498        let schema = make_schema(vec![
1499            ("x", DataType::Int64, false),
1500            ("y", DataType::Float64, false),
1501        ]);
1502        let decoder = JsonDecoder::new(schema);
1503        let records = vec![
1504            json_record(r#"{"x": 1, "y": 1.5}"#),
1505            json_record(r#"{"x": 2, "y": 2.5}"#),
1506            json_record(r#"{"x": 3, "y": 3.5}"#),
1507        ];
1508        let batch = decoder.decode_batch(&records).unwrap();
1509
1510        assert_eq!(batch.num_rows(), 3);
1511        let x_col = batch
1512            .column(0)
1513            .as_primitive::<arrow_array::types::Int64Type>();
1514        assert_eq!(x_col.value(0), 1);
1515        assert_eq!(x_col.value(1), 2);
1516        assert_eq!(x_col.value(2), 3);
1517    }
1518
1519    #[test]
1520    fn test_decode_all_types() {
1521        let schema = make_schema(vec![
1522            ("bool_col", DataType::Boolean, false),
1523            ("int_col", DataType::Int64, false),
1524            ("float_col", DataType::Float64, false),
1525            ("str_col", DataType::Utf8, false),
1526        ]);
1527        let decoder = JsonDecoder::new(schema);
1528        let records = vec![json_record(
1529            r#"{"bool_col": true, "int_col": 42, "float_col": 3.14, "str_col": "hello"}"#,
1530        )];
1531        let batch = decoder.decode_batch(&records).unwrap();
1532
1533        assert_eq!(batch.num_rows(), 1);
1534        assert!(batch.column(0).as_boolean().value(0));
1535        assert_eq!(
1536            batch
1537                .column(1)
1538                .as_primitive::<arrow_array::types::Int64Type>()
1539                .value(0),
1540            42
1541        );
1542        let f = batch
1543            .column(2)
1544            .as_primitive::<arrow_array::types::Float64Type>()
1545            .value(0);
1546        assert!((f - 3.14).abs() < f64::EPSILON);
1547        assert_eq!(batch.column(3).as_string::<i32>().value(0), "hello");
1548    }
1549
1550    // ── Null handling ─────────────────────────────────────────
1551
1552    #[test]
1553    fn test_decode_null_values() {
1554        let schema = make_schema(vec![
1555            ("a", DataType::Int64, true),
1556            ("b", DataType::Utf8, true),
1557        ]);
1558        let decoder = JsonDecoder::new(schema);
1559        let records = vec![json_record(r#"{"a": null, "b": null}"#)];
1560        let batch = decoder.decode_batch(&records).unwrap();
1561
1562        assert!(batch.column(0).is_null(0));
1563        assert!(batch.column(1).is_null(0));
1564    }
1565
1566    #[test]
1567    fn test_decode_missing_field_becomes_null() {
1568        let schema = make_schema(vec![
1569            ("a", DataType::Int64, true),
1570            ("b", DataType::Utf8, true),
1571        ]);
1572        let decoder = JsonDecoder::new(schema);
1573        let records = vec![json_record(r#"{"a": 1}"#)]; // "b" missing
1574        let batch = decoder.decode_batch(&records).unwrap();
1575
1576        assert_eq!(
1577            batch
1578                .column(0)
1579                .as_primitive::<arrow_array::types::Int64Type>()
1580                .value(0),
1581            1
1582        );
1583        assert!(batch.column(1).is_null(0));
1584    }
1585
1586    // ── Type mismatch strategies ──────────────────────────────
1587
1588    #[test]
1589    fn test_mismatch_null_strategy() {
1590        let schema = make_schema(vec![("x", DataType::Int64, true)]);
1591        let config = JsonDecoderConfig {
1592            type_mismatch: TypeMismatchStrategy::Null,
1593            ..Default::default()
1594        };
1595        let decoder = JsonDecoder::with_config(schema, config);
1596        let records = vec![json_record(r#"{"x": "not_a_number"}"#)];
1597        let batch = decoder.decode_batch(&records).unwrap();
1598
1599        assert!(batch.column(0).is_null(0));
1600        assert_eq!(decoder.mismatch_count(), 1);
1601    }
1602
1603    #[test]
1604    fn test_mismatch_coerce_strategy() {
1605        let schema = make_schema(vec![("x", DataType::Int64, true)]);
1606        let config = JsonDecoderConfig {
1607            type_mismatch: TypeMismatchStrategy::Coerce,
1608            ..Default::default()
1609        };
1610        let decoder = JsonDecoder::with_config(schema, config);
1611        let records = vec![json_record(r#"{"x": "123"}"#)];
1612        let batch = decoder.decode_batch(&records).unwrap();
1613
1614        assert_eq!(
1615            batch
1616                .column(0)
1617                .as_primitive::<arrow_array::types::Int64Type>()
1618                .value(0),
1619            123
1620        );
1621    }
1622
1623    #[test]
1624    fn test_mismatch_reject_strategy() {
1625        let schema = make_schema(vec![("x", DataType::Int64, false)]);
1626        let config = JsonDecoderConfig {
1627            type_mismatch: TypeMismatchStrategy::Reject,
1628            ..Default::default()
1629        };
1630        let decoder = JsonDecoder::with_config(schema, config);
1631        let records = vec![json_record(r#"{"x": "not_a_number"}"#)];
1632        let result = decoder.decode_batch(&records);
1633
1634        assert!(result.is_err());
1635        assert!(result.unwrap_err().to_string().contains("type mismatch"));
1636    }
1637
1638    // ── Unknown field strategies ──────────────────────────────
1639
1640    #[test]
1641    fn test_unknown_fields_ignore() {
1642        let schema = make_schema(vec![("a", DataType::Int64, false)]);
1643        let decoder = JsonDecoder::new(schema);
1644        let records = vec![json_record(r#"{"a": 1, "unknown": "value"}"#)];
1645        let batch = decoder.decode_batch(&records).unwrap();
1646
1647        assert_eq!(batch.num_columns(), 1);
1648        assert_eq!(batch.num_rows(), 1);
1649    }
1650
1651    #[test]
1652    fn test_unknown_fields_reject() {
1653        let schema = make_schema(vec![("a", DataType::Int64, false)]);
1654        let config = JsonDecoderConfig {
1655            unknown_fields: UnknownFieldStrategy::Reject,
1656            ..Default::default()
1657        };
1658        let decoder = JsonDecoder::with_config(schema, config);
1659        let records = vec![json_record(r#"{"a": 1, "unknown": "value"}"#)];
1660        let result = decoder.decode_batch(&records);
1661
1662        assert!(result.is_err());
1663        assert!(result.unwrap_err().to_string().contains("unknown field"));
1664    }
1665
1666    #[test]
1667    fn test_unknown_fields_collect_extra() {
1668        let schema = make_schema(vec![("a", DataType::Int64, false)]);
1669        let config = JsonDecoderConfig {
1670            unknown_fields: UnknownFieldStrategy::CollectExtra,
1671            ..Default::default()
1672        };
1673        let decoder = JsonDecoder::with_config(schema, config);
1674        let records = vec![json_record(r#"{"a": 1, "extra1": "v1", "extra2": 42}"#)];
1675        let batch = decoder.decode_batch(&records).unwrap();
1676
1677        // Schema should have an extra `_extra` column.
1678        assert_eq!(batch.num_columns(), 2);
1679        assert_eq!(batch.schema().field(1).name(), "_extra");
1680        assert!(!batch.column(1).is_null(0));
1681    }
1682
1683    // ── Timestamp parsing ─────────────────────────────────────
1684
1685    #[test]
1686    fn test_decode_timestamp_iso8601() {
1687        let schema = make_schema(vec![(
1688            "ts",
1689            DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
1690            false,
1691        )]);
1692        let decoder = JsonDecoder::new(schema);
1693        let records = vec![json_record(r#"{"ts": "2025-01-15T10:30:00Z"}"#)];
1694        let batch = decoder.decode_batch(&records).unwrap();
1695
1696        assert!(!batch.column(0).is_null(0));
1697    }
1698
1699    #[test]
1700    fn test_decode_timestamp_epoch_millis() {
1701        let schema = make_schema(vec![(
1702            "ts",
1703            DataType::Timestamp(TimeUnit::Nanosecond, None),
1704            false,
1705        )]);
1706        let decoder = JsonDecoder::new(schema);
1707        let records = vec![json_record(r#"{"ts": 1705312200000}"#)];
1708        let batch = decoder.decode_batch(&records).unwrap();
1709
1710        let ts_col = batch
1711            .column(0)
1712            .as_primitive::<arrow_array::types::TimestampNanosecondType>();
1713        // 1705312200000 ms * 1_000_000 = nanos
1714        assert_eq!(ts_col.value(0), 1_705_312_200_000_000_000);
1715    }
1716
1717    #[test]
1718    fn test_decode_out_of_range_float_timestamp_is_rejected() {
1719        // A garbage float epoch-ms must not silently saturate to i64::MAX
1720        // and poison event-time/watermarks — it routes through the
1721        // configured type-mismatch path instead.
1722        let schema = make_schema(vec![(
1723            "ts",
1724            DataType::Timestamp(TimeUnit::Millisecond, None),
1725            false,
1726        )]);
1727        let decoder = JsonDecoder::new(schema);
1728        let records = vec![json_record(r#"{"ts": 1e30}"#)];
1729        let result = decoder.decode_batch(&records);
1730
1731        assert!(result.is_err());
1732        assert!(result
1733            .unwrap_err()
1734            .to_string()
1735            .contains("not exactly representable"));
1736    }
1737
1738    #[test]
1739    fn test_decode_timestamp_overflow_on_nanosecond_scaling_is_rejected() {
1740        // A ms value that fits i64 but overflows when scaled to nanoseconds
1741        // must error, not wrap into a bogus (watermark-poisoning) timestamp.
1742        let schema = make_schema(vec![(
1743            "ts",
1744            DataType::Timestamp(TimeUnit::Nanosecond, None),
1745            false,
1746        )]);
1747        let decoder = JsonDecoder::new(schema);
1748        let records = vec![json_record(r#"{"ts": 9999999999999999}"#)];
1749        let result = decoder.decode_batch(&records);
1750
1751        assert!(result.is_err());
1752        assert!(result
1753            .unwrap_err()
1754            .to_string()
1755            .contains("out of i64 Nanosecond range"));
1756    }
1757
1758    // ── Nested objects as LargeBinary ─────────────────────────
1759
1760    #[test]
1761    fn test_decode_nested_object_as_json_string() {
1762        let schema = make_schema(vec![("data", DataType::LargeBinary, true)]);
1763        let decoder = JsonDecoder::new(schema);
1764        let records = vec![json_record(r#"{"data": {"nested": true}}"#)];
1765        let batch = decoder.decode_batch(&records).unwrap();
1766
1767        assert!(!batch.column(0).is_null(0));
1768    }
1769
1770    // ── Error cases ───────────────────────────────────────────
1771
1772    #[test]
1773    fn test_decode_invalid_json() {
1774        let schema = make_schema(vec![("a", DataType::Int64, false)]);
1775        let decoder = JsonDecoder::new(schema);
1776        let records = vec![RawRecord::new(b"not json".to_vec())];
1777        let result = decoder.decode_batch(&records);
1778
1779        assert!(result.is_err());
1780        assert!(result.unwrap_err().to_string().contains("JSON parse error"));
1781    }
1782
1783    #[test]
1784    fn test_decode_non_object_json() {
1785        let schema = make_schema(vec![("a", DataType::Int64, false)]);
1786        let decoder = JsonDecoder::new(schema);
1787        let records = vec![json_record("[1, 2, 3]")];
1788        let result = decoder.decode_batch(&records);
1789
1790        assert!(result.is_err());
1791        assert!(result
1792            .unwrap_err()
1793            .to_string()
1794            .contains("must be an object"));
1795    }
1796
1797    // ── FormatDecoder trait ───────────────────────────────────
1798
1799    #[test]
1800    fn test_format_name() {
1801        let schema = make_schema(vec![("a", DataType::Int64, false)]);
1802        let decoder = JsonDecoder::new(schema);
1803        assert_eq!(decoder.format_name(), "json");
1804    }
1805
1806    #[test]
1807    fn test_output_schema() {
1808        let schema = make_schema(vec![
1809            ("a", DataType::Int64, false),
1810            ("b", DataType::Utf8, true),
1811        ]);
1812        let decoder = JsonDecoder::new(schema.clone());
1813        assert_eq!(decoder.output_schema(), schema);
1814    }
1815
1816    #[test]
1817    fn test_decode_one() {
1818        let schema = make_schema(vec![("x", DataType::Int64, false)]);
1819        let decoder = JsonDecoder::new(schema);
1820        let record = json_record(r#"{"x": 99}"#);
1821        let batch = decoder.decode_one(&record).unwrap();
1822        assert_eq!(batch.num_rows(), 1);
1823        assert_eq!(
1824            batch
1825                .column(0)
1826                .as_primitive::<arrow_array::types::Int64Type>()
1827                .value(0),
1828            99
1829        );
1830    }
1831
1832    // ── Int/Float numeric coercion ────────────────────────────
1833
1834    #[test]
1835    fn test_decode_int_from_float_json() {
1836        // JSON number 42.0 is parsed as f64 by serde_json. With the default
1837        // Coerce strategy, it is coerced to Int64 = 42.
1838        let schema = make_schema(vec![("x", DataType::Int64, true)]);
1839        let decoder = JsonDecoder::new(schema);
1840        let records = vec![json_record(r#"{"x": 42.0}"#)];
1841        let batch = decoder.decode_batch(&records).unwrap();
1842        assert_eq!(
1843            batch
1844                .column(0)
1845                .as_primitive::<arrow_array::types::Int64Type>()
1846                .value(0),
1847            42
1848        );
1849    }
1850
1851    #[test]
1852    fn test_decode_float_from_int_json() {
1853        // JSON integer 42 should decode as Float64 = 42.0.
1854        let schema = make_schema(vec![("x", DataType::Float64, false)]);
1855        let decoder = JsonDecoder::new(schema);
1856        let records = vec![json_record(r#"{"x": 42}"#)];
1857        let batch = decoder.decode_batch(&records).unwrap();
1858        let val = batch
1859            .column(0)
1860            .as_primitive::<arrow_array::types::Float64Type>()
1861            .value(0);
1862        assert!((val - 42.0).abs() < f64::EPSILON);
1863    }
1864
1865    // ── Coercion tests (string→numeric, int→float, etc.) ────
1866
1867    #[test]
1868    fn test_decode_string_number_to_float64() {
1869        let schema = make_schema(vec![("price", DataType::Float64, false)]);
1870        let decoder = JsonDecoder::new(schema);
1871        let records = vec![json_record(r#"{"price": "187.52"}"#)];
1872        let batch = decoder.decode_batch(&records).unwrap();
1873        let val = batch
1874            .column(0)
1875            .as_primitive::<arrow_array::types::Float64Type>()
1876            .value(0);
1877        assert!((val - 187.52).abs() < f64::EPSILON);
1878    }
1879
1880    #[test]
1881    fn test_decode_string_to_int() {
1882        let schema = make_schema(vec![("qty", DataType::Int32, false)]);
1883        let decoder = JsonDecoder::new(schema);
1884        let records = vec![json_record(r#"{"qty": "100"}"#)];
1885        let batch = decoder.decode_batch(&records).unwrap();
1886        assert_eq!(
1887            batch
1888                .column(0)
1889                .as_primitive::<arrow_array::types::Int32Type>()
1890                .value(0),
1891            100
1892        );
1893    }
1894
1895    #[test]
1896    fn test_decode_epoch_millis_to_timestamp_millis() {
1897        let schema = make_schema(vec![(
1898            "ts",
1899            DataType::Timestamp(TimeUnit::Millisecond, None),
1900            false,
1901        )]);
1902        let decoder = JsonDecoder::new(schema);
1903        let records = vec![json_record(r#"{"ts": 1705312200000}"#)];
1904        let batch = decoder.decode_batch(&records).unwrap();
1905        let ts_col = batch
1906            .column(0)
1907            .as_primitive::<arrow_array::types::TimestampMillisecondType>();
1908        assert_eq!(ts_col.value(0), 1_705_312_200_000);
1909    }
1910
1911    #[test]
1912    fn test_decode_int_to_float_promotion() {
1913        let schema = make_schema(vec![("val", DataType::Float64, false)]);
1914        let decoder = JsonDecoder::new(schema);
1915        let records = vec![json_record(r#"{"val": 100}"#)];
1916        let batch = decoder.decode_batch(&records).unwrap();
1917        let val = batch
1918            .column(0)
1919            .as_primitive::<arrow_array::types::Float64Type>()
1920            .value(0);
1921        assert!((val - 100.0).abs() < f64::EPSILON);
1922    }
1923
1924    #[test]
1925    fn test_decode_string_boolean() {
1926        let schema = make_schema(vec![("active", DataType::Boolean, false)]);
1927        let decoder = JsonDecoder::new(schema);
1928        let records = vec![json_record(r#"{"active": "true"}"#)];
1929        let batch = decoder.decode_batch(&records).unwrap();
1930        assert!(batch.column(0).as_boolean().value(0));
1931    }
1932
1933    #[test]
1934    fn test_coerce_fails_on_unconvertible() {
1935        // With default Coerce, a string that can't be parsed as Int64 should error.
1936        let schema = make_schema(vec![("x", DataType::Int64, true)]);
1937        let decoder = JsonDecoder::new(schema);
1938        let records = vec![json_record(r#"{"x": "not_a_number"}"#)];
1939        let result = decoder.decode_batch(&records);
1940        assert!(result.is_err());
1941        assert!(result
1942            .unwrap_err()
1943            .to_string()
1944            .contains("type coercion failed"));
1945    }
1946
1947    #[test]
1948    fn test_enforcement_str_parsing() {
1949        assert_eq!(
1950            TypeMismatchStrategy::from_enforcement_str("coerce"),
1951            Some(TypeMismatchStrategy::Coerce)
1952        );
1953        assert_eq!(
1954            TypeMismatchStrategy::from_enforcement_str("STRICT"),
1955            Some(TypeMismatchStrategy::Reject)
1956        );
1957        assert_eq!(
1958            TypeMismatchStrategy::from_enforcement_str("Permissive"),
1959            Some(TypeMismatchStrategy::Null)
1960        );
1961        assert_eq!(TypeMismatchStrategy::from_enforcement_str("unknown"), None);
1962    }
1963
1964    // ── Small integer types ──────────────────────────────────
1965
1966    #[test]
1967    fn test_decode_i8_and_u8() {
1968        let schema = make_schema(vec![
1969            ("signed", DataType::Int8, false),
1970            ("unsigned", DataType::UInt8, false),
1971        ]);
1972        let decoder = JsonDecoder::new(schema);
1973        let records = vec![json_record(r#"{"signed": -5, "unsigned": 200}"#)];
1974        let batch = decoder.decode_batch(&records).unwrap();
1975        assert_eq!(
1976            batch
1977                .column(0)
1978                .as_primitive::<arrow_array::types::Int8Type>()
1979                .value(0),
1980            -5
1981        );
1982        assert_eq!(
1983            batch
1984                .column(1)
1985                .as_primitive::<arrow_array::types::UInt8Type>()
1986                .value(0),
1987            200
1988        );
1989    }
1990
1991    // ── json.path tests ─────────────────────────────────────────
1992
1993    #[test]
1994    fn test_json_path_single() {
1995        let schema = make_schema(vec![("id", DataType::Int64, false)]);
1996        let config = JsonDecoderConfig {
1997            json_path: Some(vec!["data".into()]),
1998            ..Default::default()
1999        };
2000        let decoder = JsonDecoder::with_config(schema, config);
2001        let records = vec![json_record(r#"{"data":{"id":1}}"#)];
2002        let batch = decoder.decode_batch(&records).unwrap();
2003        assert_eq!(batch.num_rows(), 1);
2004        assert_eq!(
2005            batch
2006                .column(0)
2007                .as_primitive::<arrow_array::types::Int64Type>()
2008                .value(0),
2009            1
2010        );
2011    }
2012
2013    #[test]
2014    fn test_json_path_multi() {
2015        let schema = make_schema(vec![("id", DataType::Int64, false)]);
2016        let config = JsonDecoderConfig {
2017            json_path: Some(vec!["a".into(), "b".into()]),
2018            ..Default::default()
2019        };
2020        let decoder = JsonDecoder::with_config(schema, config);
2021        let records = vec![json_record(r#"{"a":{"b":{"id":1}}}"#)];
2022        let batch = decoder.decode_batch(&records).unwrap();
2023        assert_eq!(
2024            batch
2025                .column(0)
2026                .as_primitive::<arrow_array::types::Int64Type>()
2027                .value(0),
2028            1
2029        );
2030    }
2031
2032    /// Records that don't carry the configured json.path are skipped rather
2033    /// than poisoning a batch containing mixed control and data envelopes.
2034    #[test]
2035    fn test_json_path_missing_skips_record() {
2036        let schema = make_schema(vec![("id", DataType::Int64, false)]);
2037        let config = JsonDecoderConfig {
2038            json_path: Some(vec!["data".into()]),
2039            ..Default::default()
2040        };
2041        let decoder = JsonDecoder::with_config(schema, config);
2042        let records = vec![
2043            json_record(r#"{"success":true,"op":"subscribe"}"#), // ack — no `data`
2044            json_record(r#"{"data":{"id":42}}"#),
2045        ];
2046        let batch = decoder.decode_batch(&records).unwrap();
2047        assert_eq!(batch.num_rows(), 1);
2048        assert_eq!(
2049            batch
2050                .column(0)
2051                .as_primitive::<arrow_array::types::Int64Type>()
2052                .value(0),
2053            42
2054        );
2055    }
2056
2057    // ── json.column.* tests ─────────────────────────────────────
2058
2059    #[test]
2060    fn test_json_column_custom_path() {
2061        let schema = make_schema(vec![
2062            ("p", DataType::Float64, false),
2063            ("stream_name", DataType::Utf8, true),
2064        ]);
2065        let mut col_paths = HashMap::new();
2066        col_paths.insert("stream_name".into(), vec!["stream".into()]);
2067        let config = JsonDecoderConfig {
2068            json_path: Some(vec!["data".into()]),
2069            json_column_paths: col_paths,
2070            ..Default::default()
2071        };
2072        let decoder = JsonDecoder::with_config(schema, config);
2073        let records = vec![json_record(
2074            r#"{"stream":"btcusdt@trade","data":{"p":67523.0}}"#,
2075        )];
2076        let batch = decoder.decode_batch(&records).unwrap();
2077        assert_eq!(batch.num_rows(), 1);
2078        let price = batch
2079            .column(0)
2080            .as_primitive::<arrow_array::types::Float64Type>()
2081            .value(0);
2082        assert!((price - 67523.0).abs() < f64::EPSILON);
2083        assert_eq!(batch.column(1).as_string::<i32>().value(0), "btcusdt@trade");
2084    }
2085
2086    #[test]
2087    fn test_json_column_deep_path() {
2088        let schema = make_schema(vec![
2089            ("p", DataType::Float64, false),
2090            ("ts", DataType::Int64, true),
2091        ]);
2092        let mut col_paths = HashMap::new();
2093        col_paths.insert("ts".into(), vec!["meta".into(), "timestamp".into()]);
2094        let config = JsonDecoderConfig {
2095            json_path: Some(vec!["data".into()]),
2096            json_column_paths: col_paths,
2097            ..Default::default()
2098        };
2099        let decoder = JsonDecoder::with_config(schema, config);
2100        let records = vec![json_record(
2101            r#"{"meta":{"timestamp":123456},"data":{"p":99.5}}"#,
2102        )];
2103        let batch = decoder.decode_batch(&records).unwrap();
2104        assert_eq!(batch.num_rows(), 1);
2105        assert_eq!(
2106            batch
2107                .column(1)
2108                .as_primitive::<arrow_array::types::Int64Type>()
2109                .value(0),
2110            123456
2111        );
2112    }
2113
2114    #[test]
2115    fn test_json_column_missing_returns_null() {
2116        let schema = make_schema(vec![
2117            ("id", DataType::Int64, false),
2118            ("missing_col", DataType::Utf8, true),
2119        ]);
2120        let mut col_paths = HashMap::new();
2121        col_paths.insert("missing_col".into(), vec!["nowhere".into(), "gone".into()]);
2122        let config = JsonDecoderConfig {
2123            json_column_paths: col_paths,
2124            ..Default::default()
2125        };
2126        let decoder = JsonDecoder::with_config(schema, config);
2127        let records = vec![json_record(r#"{"id":42}"#)];
2128        let batch = decoder.decode_batch(&records).unwrap();
2129        assert_eq!(
2130            batch
2131                .column(0)
2132                .as_primitive::<arrow_array::types::Int64Type>()
2133                .value(0),
2134            42
2135        );
2136        assert!(batch.column(1).is_null(0));
2137    }
2138
2139    #[test]
2140    fn test_json_column_path_to_nested_string_array() {
2141        use arrow_array::Array;
2142        use arrow_schema::Field;
2143        let schema = make_schema(vec![(
2144            "tags",
2145            DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
2146            true,
2147        )]);
2148        let mut col_paths = HashMap::new();
2149        col_paths.insert("tags".into(), vec!["record".into(), "tags".into()]);
2150        let config = JsonDecoderConfig {
2151            json_column_paths: col_paths,
2152            ..Default::default()
2153        };
2154        let decoder = JsonDecoder::with_config(schema, config);
2155        let records = vec![json_record(r#"{"record":{"tags":["en","es"]}}"#)];
2156        let batch = decoder.decode_batch(&records).unwrap();
2157
2158        let list = batch
2159            .column(0)
2160            .as_any()
2161            .downcast_ref::<arrow_array::ListArray>()
2162            .expect("List column");
2163        let vals = list.value(0);
2164        let strs = vals.as_string::<i32>();
2165        assert_eq!(strs.len(), 2);
2166        assert_eq!(strs.value(0), "en");
2167        assert_eq!(strs.value(1), "es");
2168    }
2169
2170    #[test]
2171    fn test_list_column_non_array_honors_reject_strategy() {
2172        use arrow_schema::Field;
2173        let schema = make_schema(vec![(
2174            "tags",
2175            DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
2176            true,
2177        )]);
2178        let config = JsonDecoderConfig {
2179            type_mismatch: TypeMismatchStrategy::Reject,
2180            ..Default::default()
2181        };
2182        let decoder = JsonDecoder::with_config(schema, config);
2183        // A scalar where the list column expects an array must be rejected, not
2184        // silently coerced to NULL.
2185        let records = vec![json_record(r#"{"tags": "en"}"#)];
2186        let result = decoder.decode_batch(&records);
2187
2188        assert!(result.is_err());
2189        assert!(result.unwrap_err().to_string().contains("type mismatch"));
2190    }
2191
2192    // ── json.explode tests ──────────────────────────────────────
2193
2194    #[test]
2195    fn test_json_explode_arrays() {
2196        let schema = make_schema(vec![
2197            ("price", DataType::Utf8, true),
2198            ("qty", DataType::Utf8, true),
2199        ]);
2200        let config = JsonDecoderConfig {
2201            json_explode: Some(vec!["price".into(), "qty".into()]),
2202            ..Default::default()
2203        };
2204        let decoder = JsonDecoder::with_config(schema, config);
2205        let records = vec![json_record(r#"[["67523","1.5"],["67522","0.8"]]"#)];
2206        let batch = decoder.decode_batch(&records).unwrap();
2207        assert_eq!(batch.num_rows(), 2);
2208        assert_eq!(batch.column(0).as_string::<i32>().value(0), "67523");
2209        assert_eq!(batch.column(0).as_string::<i32>().value(1), "67522");
2210        assert_eq!(batch.column(1).as_string::<i32>().value(0), "1.5");
2211        assert_eq!(batch.column(1).as_string::<i32>().value(1), "0.8");
2212    }
2213
2214    #[test]
2215    fn test_json_explode_objects() {
2216        let schema = make_schema(vec![
2217            ("id", DataType::Int64, true),
2218            ("name", DataType::Utf8, true),
2219        ]);
2220        let config = JsonDecoderConfig {
2221            json_explode: Some(vec!["id".into(), "name".into()]),
2222            ..Default::default()
2223        };
2224        let decoder = JsonDecoder::with_config(schema, config);
2225        let records = vec![json_record(
2226            r#"[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]"#,
2227        )];
2228        let batch = decoder.decode_batch(&records).unwrap();
2229        assert_eq!(batch.num_rows(), 2);
2230        assert_eq!(
2231            batch
2232                .column(0)
2233                .as_primitive::<arrow_array::types::Int64Type>()
2234                .value(0),
2235            1
2236        );
2237        assert_eq!(batch.column(1).as_string::<i32>().value(1), "Bob");
2238    }
2239
2240    // ── Combined json.path + json.explode ───────────────────────
2241
2242    #[test]
2243    fn test_json_path_plus_explode() {
2244        let schema = make_schema(vec![
2245            ("price", DataType::Utf8, true),
2246            ("qty", DataType::Utf8, true),
2247        ]);
2248        let config = JsonDecoderConfig {
2249            json_path: Some(vec!["bids".into()]),
2250            json_explode: Some(vec!["price".into(), "qty".into()]),
2251            ..Default::default()
2252        };
2253        let decoder = JsonDecoder::with_config(schema, config);
2254        let records = vec![json_record(r#"{"bids":[["67523","1.5"],["67522","0.8"]]}"#)];
2255        let batch = decoder.decode_batch(&records).unwrap();
2256        assert_eq!(batch.num_rows(), 2);
2257        assert_eq!(batch.column(0).as_string::<i32>().value(0), "67523");
2258        assert_eq!(batch.column(1).as_string::<i32>().value(1), "0.8");
2259    }
2260
2261    // ── from_connector_config tests ─────────────────────────────
2262
2263    #[test]
2264    fn test_from_connector_config() {
2265        let mut config = crate::config::ConnectorConfig::new("json");
2266        config.set("json.path", "data.trade");
2267        config.set("json.column.stream_name", "stream");
2268        config.set("json.column.ts", "meta.timestamp");
2269        config.set("json.explode", "price, qty");
2270        config.set("schema.enforcement", "strict");
2271        config.set("nested.as.jsonb", "true");
2272
2273        let schema = make_schema(vec![
2274            ("stream_name", DataType::Utf8, true),
2275            ("ts", DataType::Timestamp(TimeUnit::Microsecond, None), true),
2276            ("price", DataType::Utf8, true),
2277            ("qty", DataType::Utf8, true),
2278        ]);
2279        let cfg = JsonDecoderConfig::from_connector_config(&config, &schema).unwrap();
2280        assert_eq!(cfg.json_path, Some(vec!["data".into(), "trade".into()]));
2281        assert_eq!(
2282            cfg.json_column_paths.get("stream_name"),
2283            Some(&vec!["stream".into()])
2284        );
2285        assert_eq!(
2286            cfg.json_column_paths.get("ts"),
2287            Some(&vec!["meta".into(), "timestamp".into()])
2288        );
2289        assert_eq!(cfg.json_explode, Some(vec!["price".into(), "qty".into()]));
2290        assert_eq!(cfg.type_mismatch, TypeMismatchStrategy::Reject);
2291        assert!(cfg.nested_as_jsonb);
2292    }
2293
2294    #[test]
2295    fn connector_config_rejects_invalid_projection_options() {
2296        let schema = make_schema(vec![
2297            ("id", DataType::Int64, false),
2298            ("ts", DataType::Timestamp(TimeUnit::Microsecond, None), true),
2299        ]);
2300        for (key, value) in [
2301            ("json.path", "data..event"),
2302            ("json.column.missing", "payload.value"),
2303            ("json.column.id", "payload..value"),
2304            ("json.explode", "id,missing"),
2305            ("json.explode", "id,id"),
2306            ("schema.enforcement", "strcit"),
2307            ("nested.as.jsonb", "yes"),
2308        ] {
2309            let mut config = crate::config::ConnectorConfig::new("json");
2310            config.set(key, value);
2311            let error = JsonDecoderConfig::from_connector_config(&config, &schema)
2312                .unwrap_err()
2313                .to_string();
2314            assert!(error.contains(key), "{key}={value}: {error}");
2315        }
2316    }
2317
2318    #[test]
2319    fn test_default_config_unchanged() {
2320        let schema = make_schema(vec![
2321            ("a", DataType::Int64, false),
2322            ("b", DataType::Utf8, true),
2323        ]);
2324        let decoder = JsonDecoder::new(schema);
2325        let records = vec![
2326            json_record(r#"{"a": 1, "b": "hello"}"#),
2327            json_record(r#"{"a": 2, "b": "world"}"#),
2328        ];
2329        let batch = decoder.decode_batch(&records).unwrap();
2330        assert_eq!(batch.num_rows(), 2);
2331        assert_eq!(
2332            batch
2333                .column(0)
2334                .as_primitive::<arrow_array::types::Int64Type>()
2335                .value(0),
2336            1
2337        );
2338        assert_eq!(batch.column(1).as_string::<i32>().value(1), "world");
2339    }
2340
2341    #[test]
2342    fn test_unknown_fields_with_path() {
2343        let schema = make_schema(vec![("a", DataType::Int64, false)]);
2344        let config = JsonDecoderConfig {
2345            json_path: Some(vec!["data".into()]),
2346            unknown_fields: UnknownFieldStrategy::CollectExtra,
2347            ..Default::default()
2348        };
2349        let decoder = JsonDecoder::with_config(schema, config);
2350        let records = vec![json_record(r#"{"data":{"a":1,"extra":"value"}}"#)];
2351        let batch = decoder.decode_batch(&records).unwrap();
2352        assert_eq!(batch.num_columns(), 2); // a + _extra
2353        assert_eq!(batch.schema().field(1).name(), "_extra");
2354        assert!(!batch.column(1).is_null(0));
2355    }
2356
2357    // ── Per-column numeric epoch unit ─────────────────────────
2358
2359    #[test]
2360    fn from_connector_config_parses_epoch_unit_without_polluting_paths() {
2361        use crate::config::ConnectorConfig;
2362        let mut props = std::collections::HashMap::new();
2363        props.insert("json.column.evt".to_string(), "time_us".to_string());
2364        props.insert(
2365            "json.column.evt.epoch_unit".to_string(),
2366            "micros".to_string(),
2367        );
2368        let cc = ConnectorConfig::with_properties("json", props);
2369        let schema = make_schema(vec![(
2370            "evt",
2371            DataType::Timestamp(TimeUnit::Microsecond, None),
2372            false,
2373        )]);
2374        let cfg = JsonDecoderConfig::from_connector_config(&cc, &schema).unwrap();
2375        assert_eq!(
2376            cfg.numeric_timestamp_units.get("evt"),
2377            Some(&EpochUnit::Micros)
2378        );
2379        // The `.epoch_unit` key must NOT become a phantom path column.
2380        assert!(cfg.json_column_paths.contains_key("evt"));
2381        assert!(!cfg.json_column_paths.contains_key("evt.epoch_unit"));
2382    }
2383
2384    #[test]
2385    fn from_connector_config_rejects_noncanonical_epoch_units() {
2386        for value in ["epoch_micros", "minutes"] {
2387            let mut config = crate::config::ConnectorConfig::new("source");
2388            config.set("json.column.evt.epoch_unit", value);
2389            let schema = make_schema(vec![(
2390                "evt",
2391                DataType::Timestamp(TimeUnit::Microsecond, None),
2392                false,
2393            )]);
2394            let error = JsonDecoderConfig::from_connector_config(&config, &schema)
2395                .unwrap_err()
2396                .to_string();
2397            assert!(error.contains("json.column.evt.epoch_unit"), "{error}");
2398            assert!(error.contains(value), "{error}");
2399        }
2400    }
2401
2402    #[test]
2403    fn from_connector_config_rejects_unknown_or_non_timestamp_epoch_columns() {
2404        let schema = make_schema(vec![
2405            (
2406                "ts",
2407                DataType::Timestamp(TimeUnit::Microsecond, None),
2408                false,
2409            ),
2410            ("id", DataType::Int64, false),
2411        ]);
2412
2413        for (column, expected) in [
2414            ("missing", "not present in the source schema"),
2415            ("id", "must be a Timestamp"),
2416        ] {
2417            let mut config = crate::config::ConnectorConfig::new("source");
2418            config.set(format!("json.column.{column}.epoch_unit"), "micros");
2419            let error = JsonDecoderConfig::from_connector_config(&config, &schema)
2420                .unwrap_err()
2421                .to_string();
2422            assert!(error.contains(column), "{error}");
2423            assert!(error.contains(expected), "{error}");
2424        }
2425    }
2426
2427    #[test]
2428    fn integer_epoch_conversion_covers_every_source_and_arrow_unit() {
2429        let source_values = [
2430            (EpochUnit::Seconds, 2),
2431            (EpochUnit::Millis, 2_000),
2432            (EpochUnit::Micros, 2_000_000),
2433            (EpochUnit::Nanos, 2_000_000_000),
2434        ];
2435        let target_values = [
2436            (TimeUnit::Second, 2),
2437            (TimeUnit::Millisecond, 2_000),
2438            (TimeUnit::Microsecond, 2_000_000),
2439            (TimeUnit::Nanosecond, 2_000_000_000),
2440        ];
2441
2442        for (from, value) in source_values {
2443            for (to, expected) in &target_values {
2444                assert_eq!(
2445                    checked_epoch_to_unit(value, from, *to).unwrap(),
2446                    *expected,
2447                    "from={from:?}, to={to:?}"
2448                );
2449            }
2450        }
2451    }
2452
2453    #[test]
2454    fn integer_epoch_downscale_matches_arrow_for_negative_values() {
2455        assert_eq!(
2456            checked_epoch_to_unit(-1_500, EpochUnit::Nanos, TimeUnit::Microsecond).unwrap(),
2457            -1
2458        );
2459        assert_eq!(
2460            checked_epoch_to_unit(-1_500, EpochUnit::Micros, TimeUnit::Millisecond).unwrap(),
2461            -1
2462        );
2463        assert_eq!(
2464            checked_epoch_to_unit(-2, EpochUnit::Seconds, TimeUnit::Nanosecond).unwrap(),
2465            -2_000_000_000
2466        );
2467    }
2468
2469    #[test]
2470    fn integer_epoch_upscale_rejects_positive_and_negative_overflow() {
2471        assert!(checked_epoch_to_unit(i64::MAX, EpochUnit::Seconds, TimeUnit::Nanosecond).is_err());
2472        assert!(checked_epoch_to_unit(i64::MIN, EpochUnit::Millis, TimeUnit::Microsecond).is_err());
2473    }
2474
2475    #[test]
2476    fn fractional_numeric_epoch_is_rejected_instead_of_rounded() {
2477        let schema = make_schema(vec![(
2478            "ts",
2479            DataType::Timestamp(TimeUnit::Millisecond, None),
2480            false,
2481        )]);
2482        let decoder = JsonDecoder::new(schema);
2483        let error = decoder
2484            .decode_batch(&[json_record(r#"{"ts": 1.5}"#)])
2485            .unwrap_err()
2486            .to_string();
2487        assert!(error.contains("fractional timestamp"), "{error}");
2488    }
2489
2490    #[test]
2491    fn integral_float_epoch_requires_exact_integer_range() {
2492        let schema = make_schema(vec![(
2493            "ts",
2494            DataType::Timestamp(TimeUnit::Millisecond, None),
2495            false,
2496        )]);
2497        let decoder = JsonDecoder::new(schema);
2498
2499        let batch = decoder
2500            .decode_batch(&[json_record(r#"{"ts": 1000.0}"#)])
2501            .unwrap();
2502        let timestamps = batch
2503            .column(0)
2504            .as_primitive::<arrow_array::types::TimestampMillisecondType>();
2505        assert_eq!(timestamps.value(0), 1_000);
2506
2507        let error = decoder
2508            .decode_batch(&[json_record(r#"{"ts": 9.007199254740992e15}"#)])
2509            .unwrap_err()
2510            .to_string();
2511        assert!(error.contains("not exactly representable"), "{error}");
2512    }
2513
2514    #[test]
2515    fn numeric_micros_decodes_into_millis_timestamp_column() {
2516        // End-to-end: a numeric microsecond field (Jetstream time_us shape)
2517        // mapped to a Timestamp(ms) column lands at a wall-clock-plausible
2518        // millisecond value instead of being misread as epoch-millis.
2519        let schema = make_schema(vec![(
2520            "ts",
2521            DataType::Timestamp(TimeUnit::Millisecond, None),
2522            false,
2523        )]);
2524        let mut units = HashMap::new();
2525        units.insert("ts".to_string(), EpochUnit::Micros);
2526        let config = JsonDecoderConfig {
2527            numeric_timestamp_units: units,
2528            ..Default::default()
2529        };
2530        let decoder = JsonDecoder::with_config(schema, config);
2531        // 1_779_019_200_123_456 µs == 1_779_019_200_123 ms (~2026-05-17).
2532        let records = vec![json_record(r#"{"ts": 1779019200123456}"#)];
2533        let batch = decoder.decode_batch(&records).unwrap();
2534        let col = batch
2535            .column(0)
2536            .as_primitive::<arrow_array::types::TimestampMillisecondType>();
2537        assert_eq!(col.value(0), 1_779_019_200_123);
2538
2539        // Without an override, numeric epochs use milliseconds.
2540        let schema2 = make_schema(vec![(
2541            "ts",
2542            DataType::Timestamp(TimeUnit::Millisecond, None),
2543            false,
2544        )]);
2545        let d2 = JsonDecoder::new(schema2);
2546        let b2 = d2.decode_batch(&records).unwrap();
2547        let c2 = b2
2548            .column(0)
2549            .as_primitive::<arrow_array::types::TimestampMillisecondType>();
2550        assert_eq!(c2.value(0), 1_779_019_200_123_456);
2551    }
2552}