Skip to main content

uni_store/storage/
arrow_convert.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Arrow type conversion utilities for reducing cognitive complexity.
5//!
6//! This module provides shared helper functions and macros for converting
7//! between Arrow arrays and JSON Values, reducing code duplication across
8//! vertex.rs, delta.rs, and executor.rs.
9
10use anyhow::{Result, anyhow};
11use arrow_array::builder::{
12    BinaryBuilder, BooleanBufferBuilder, BooleanBuilder, Date32Builder, DurationMicrosecondBuilder,
13    FixedSizeBinaryBuilder, FixedSizeListBuilder, Float32Builder, Float64Builder, Int32Builder,
14    Int64Builder, IntervalMonthDayNanoBuilder, LargeBinaryBuilder, ListBuilder, PrimitiveBuilder,
15    StringBuilder, StructBuilder, Time64MicrosecondBuilder, Time64NanosecondBuilder,
16    TimestampNanosecondBuilder, UInt8Builder, UInt32Builder,
17};
18use arrow_array::types::{
19    ArrowPrimitiveType, Float32Type, Float64Type, Int32Type, Int64Type, UInt64Type,
20};
21use arrow_array::{
22    Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, FixedSizeBinaryArray,
23    FixedSizeListArray, Float32Array, Float64Array, Int32Array, Int64Array,
24    IntervalMonthDayNanoArray, LargeBinaryArray, LargeStringArray, ListArray, StringArray,
25    StructArray, Time64NanosecondArray, TimestampNanosecondArray, UInt8Array, UInt32Array,
26    UInt64Array,
27};
28use arrow_schema::{DataType as ArrowDataType, Field};
29use std::collections::HashMap;
30use std::sync::Arc;
31use uni_common::DataType;
32use uni_common::Value;
33use uni_common::core::id::{Eid, Vid};
34use uni_common::core::schema;
35use uni_common::core::schema::PointType;
36use uni_crdt::Crdt;
37
38/// Build a timestamp column from a map of ID -> timestamp (nanoseconds).
39///
40/// Shared utility for building `_created_at` and `_updated_at` columns
41/// in vertex and edge tables. Works with any hashable ID type (Vid, Eid, etc.).
42fn build_timestamp_column_from_id_map<K, I>(
43    ids: I,
44    timestamps: Option<&HashMap<K, i64>>,
45) -> ArrayRef
46where
47    K: Eq + std::hash::Hash,
48    I: IntoIterator<Item = K>,
49{
50    let mut builder = TimestampNanosecondBuilder::new().with_timezone("UTC");
51    for id in ids {
52        match timestamps.and_then(|m| m.get(&id)) {
53            Some(&ts) => builder.append_value(ts),
54            None => builder.append_null(),
55        }
56    }
57    Arc::new(builder.finish())
58}
59
60pub fn build_timestamp_column_from_vid_map<I>(
61    ids: I,
62    timestamps: Option<&HashMap<Vid, i64>>,
63) -> ArrayRef
64where
65    I: IntoIterator<Item = Vid>,
66{
67    build_timestamp_column_from_id_map(ids, timestamps)
68}
69
70pub fn build_timestamp_column_from_eid_map<I>(
71    ids: I,
72    timestamps: Option<&HashMap<Eid, i64>>,
73) -> ArrayRef
74where
75    I: IntoIterator<Item = Eid>,
76{
77    build_timestamp_column_from_id_map(ids, timestamps)
78}
79
80/// Build a timestamp column from an iterator of optional timestamps.
81///
82/// This is useful for building timestamp columns directly from entry structs.
83pub fn build_timestamp_column<I>(timestamps: I) -> ArrayRef
84where
85    I: IntoIterator<Item = Option<i64>>,
86{
87    let mut builder = TimestampNanosecondBuilder::new().with_timezone("UTC");
88    for ts in timestamps {
89        builder.append_option(ts);
90    }
91    Arc::new(builder.finish())
92}
93
94/// Extract a `Vec<String>` from a single row of a `List<Utf8>` column.
95///
96/// Returns an empty vec when the row is null, the inner array is not a
97/// `StringArray`, or the list is empty.  Null entries inside the list are
98/// silently skipped.
99pub fn labels_from_list_array(list_arr: &ListArray, row: usize) -> Vec<String> {
100    if list_arr.is_null(row) {
101        return Vec::new();
102    }
103    let values = list_arr.value(row);
104    let Some(str_arr) = values.as_any().downcast_ref::<StringArray>() else {
105        return Vec::new();
106    };
107    (0..str_arr.len())
108        .filter(|&j| !str_arr.is_null(j))
109        .map(|j| str_arr.value(j).to_string())
110        .collect()
111}
112
113/// Parse a datetime string into nanoseconds since Unix epoch.
114///
115/// Tries RFC3339, "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M%:z",
116/// and "%Y-%m-%dT%H:%MZ" formats.
117fn parse_datetime_to_nanos(s: &str) -> Option<i64> {
118    chrono::DateTime::parse_from_rfc3339(s)
119        .map(|dt| {
120            dt.with_timezone(&chrono::Utc)
121                .timestamp_nanos_opt()
122                .unwrap_or(0)
123        })
124        .or_else(|_| {
125            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
126                .map(|ndt| ndt.and_utc().timestamp_nanos_opt().unwrap_or(0))
127        })
128        .or_else(|_| {
129            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ")
130                .map(|ndt| ndt.and_utc().timestamp_nanos_opt().unwrap_or(0))
131        })
132        .or_else(|_| {
133            chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M%:z").map(|dt| {
134                dt.with_timezone(&chrono::Utc)
135                    .timestamp_nanos_opt()
136                    .unwrap_or(0)
137            })
138        })
139        .ok()
140        .or_else(|| {
141            s.strip_suffix('Z')
142                .and_then(|base| chrono::NaiveDateTime::parse_from_str(base, "%Y-%m-%dT%H:%M").ok())
143                .map(|ndt| ndt.and_utc().timestamp_nanos_opt().unwrap_or(0))
144        })
145}
146
147/// Detect the Arrow Map-as-List(Struct(key, value)) pattern and reconstruct a map.
148///
149/// Arrow represents Map columns as `List(Struct { key, value })`. This helper
150/// checks whether the given array matches that layout and, if so, converts the
151/// key/value pairs back into a `HashMap<String, Value>`.
152pub(crate) fn try_reconstruct_map(arr: &ArrayRef) -> Option<HashMap<String, Value>> {
153    let structs = arr.as_any().downcast_ref::<StructArray>()?;
154    let fields = structs.fields();
155    if fields.len() != 2 || fields[0].name() != "key" || fields[1].name() != "value" {
156        return None;
157    }
158    // A typed `Map(_, Bytes)` value child carries the `uni_raw_bytes` marker; decode
159    // each value verbatim. CV-encoded map values carry no marker → codec path.
160    let value_hint = raw_bytes_hint(fields[1].metadata());
161    let key_col = structs.column(0);
162    let val_col = structs.column(1);
163    let mut map = HashMap::new();
164    for i in 0..structs.len() {
165        if let Value::String(k) = arrow_to_value(key_col.as_ref(), i, None) {
166            map.insert(k, arrow_to_value(val_col.as_ref(), i, value_hint));
167        }
168    }
169    Some(map)
170}
171
172/// Convert all elements of an Arrow array into a `Vec<Value>`.
173///
174/// `elem_type` is the schema hint for each element — `Some(DataType::Bytes)` when the
175/// list child field is marked `uni_raw_bytes`, so raw `Bytes` elements decode verbatim.
176fn array_to_value_list(arr: &ArrayRef, elem_type: Option<&DataType>) -> Vec<Value> {
177    (0..arr.len())
178        .map(|i| arrow_to_value(arr.as_ref(), i, elem_type))
179        .collect()
180}
181
182/// Returns `Some(&DataType::Bytes)` when Arrow field metadata marks the field as a
183/// raw `Bytes` value (`uni_raw_bytes=true`), else `None`. Used to discriminate raw
184/// `Bytes` container children from CV-encoded `LargeBinary` without array sniffing.
185fn raw_bytes_hint(metadata: &HashMap<String, String>) -> Option<&'static DataType> {
186    if metadata.get("uni_raw_bytes").is_some_and(|v| v == "true") {
187        Some(&DataType::Bytes)
188    } else {
189        None
190    }
191}
192
193/// Extracts the raw-`Bytes` element hint from a list-like Arrow type's child field.
194fn list_child_bytes_hint(dt: &ArrowDataType) -> Option<&'static DataType> {
195    match dt {
196        ArrowDataType::List(f)
197        | ArrowDataType::LargeList(f)
198        | ArrowDataType::FixedSizeList(f, _) => raw_bytes_hint(f.metadata()),
199        _ => None,
200    }
201}
202
203/// Convert an Arrow array value at a given row index to a Uni Value.
204///
205/// Handles all common Arrow types and recursively processes nested structures
206/// like Lists and Structs. The optional `data_type` parameter provides schema
207/// context for decoding DateTime and Time struct arrays; when provided, it
208/// takes precedence over runtime type detection.
209pub fn arrow_to_value(col: &dyn Array, row: usize, data_type: Option<&DataType>) -> Value {
210    if col.is_null(row) {
211        return Value::Null;
212    }
213
214    // Schema-driven decode for DateTime and Time structs
215    if let Some(dt) = data_type {
216        match dt {
217            DataType::DateTime => {
218                // Expect StructArray with three fields
219                if let Some(struct_arr) = col.as_any().downcast_ref::<StructArray>()
220                    && let (Some(nanos_col), Some(offset_col), Some(tz_col)) = (
221                        struct_arr.column_by_name("nanos_since_epoch"),
222                        struct_arr.column_by_name("offset_seconds"),
223                        struct_arr.column_by_name("timezone_name"),
224                    )
225                    && let (Some(nanos_arr), Some(offset_arr), Some(tz_arr)) = (
226                        nanos_col
227                            .as_any()
228                            .downcast_ref::<TimestampNanosecondArray>(),
229                        offset_col.as_any().downcast_ref::<Int32Array>(),
230                        tz_col.as_any().downcast_ref::<StringArray>(),
231                    )
232                {
233                    if nanos_arr.is_null(row) {
234                        return Value::Null;
235                    }
236                    let nanos = nanos_arr.value(row);
237                    if offset_arr.is_null(row) {
238                        // No offset → LocalDateTime
239                        return Value::Temporal(uni_common::TemporalValue::LocalDateTime {
240                            nanos_since_epoch: nanos,
241                        });
242                    }
243                    let offset = offset_arr.value(row);
244                    let tz_name = (!tz_arr.is_null(row)).then(|| tz_arr.value(row).to_string());
245                    return Value::Temporal(uni_common::TemporalValue::DateTime {
246                        nanos_since_epoch: nanos,
247                        offset_seconds: offset,
248                        timezone_name: tz_name,
249                    });
250                }
251                // Fall back to old schema migration: TimestampNanosecond → DateTime with offset=0
252                if let Some(ts) = col.as_any().downcast_ref::<TimestampNanosecondArray>() {
253                    let nanos = ts.value(row);
254                    let tz_name = ts.timezone().map(|s| s.to_string());
255                    return Value::Temporal(uni_common::TemporalValue::DateTime {
256                        nanos_since_epoch: nanos,
257                        offset_seconds: 0,
258                        timezone_name: tz_name,
259                    });
260                }
261            }
262            DataType::Time => {
263                // Expect StructArray with two fields
264                if let Some(struct_arr) = col.as_any().downcast_ref::<StructArray>()
265                    && let (Some(nanos_col), Some(offset_col)) = (
266                        struct_arr.column_by_name("nanos_since_midnight"),
267                        struct_arr.column_by_name("offset_seconds"),
268                    )
269                    && let (Some(nanos_arr), Some(offset_arr)) = (
270                        nanos_col.as_any().downcast_ref::<Time64NanosecondArray>(),
271                        offset_col.as_any().downcast_ref::<Int32Array>(),
272                    )
273                {
274                    // Check field-level nulls before calling .value()
275                    if nanos_arr.is_null(row) || offset_arr.is_null(row) {
276                        return Value::Null;
277                    }
278                    let nanos = nanos_arr.value(row);
279                    let offset = offset_arr.value(row);
280                    return Value::Temporal(uni_common::TemporalValue::Time {
281                        nanos_since_midnight: nanos,
282                        offset_seconds: offset,
283                    });
284                }
285                // Fall back to old schema: Time64Nanosecond → Time with offset=0
286                if let Some(t) = col.as_any().downcast_ref::<Time64NanosecondArray>() {
287                    let nanos = t.value(row);
288                    return Value::Temporal(uni_common::TemporalValue::Time {
289                        nanos_since_midnight: nanos,
290                        offset_seconds: 0,
291                    });
292                }
293            }
294            DataType::Point(pt) => {
295                // Reconstruct the `Value::Map` shape produced by `point(...)`
296                // (`spatial.rs`) from the declared struct layout. A Point-typed
297                // column can only hold points, so anything not reconstructable
298                // (non-struct, missing fields) is `Null` — never a bare scalar and
299                // never a panic. Returns unconditionally rather than falling
300                // through to the generic handler.
301                if let Some(struct_arr) = col.as_any().downcast_ref::<StructArray>() {
302                    let f64_at = |name: &str| -> Option<f64> {
303                        struct_arr
304                            .column_by_name(name)
305                            .and_then(|c| c.as_any().downcast_ref::<Float64Array>())
306                            .filter(|a| !a.is_null(row))
307                            .map(|a| a.value(row))
308                    };
309                    let crs = struct_arr
310                        .column_by_name("crs")
311                        .and_then(|c| c.as_any().downcast_ref::<StringArray>())
312                        .filter(|a| !a.is_null(row))
313                        .map(|a| a.value(row).to_string());
314
315                    match pt {
316                        PointType::Geographic => {
317                            if let (Some(lat), Some(lon)) =
318                                (f64_at("latitude"), f64_at("longitude"))
319                            {
320                                return Value::Map(HashMap::from([
321                                    ("type".to_string(), Value::String("Point".into())),
322                                    (
323                                        "crs".to_string(),
324                                        Value::String(crs.unwrap_or_else(|| "WGS84".into())),
325                                    ),
326                                    ("latitude".to_string(), Value::Float(lat)),
327                                    ("longitude".to_string(), Value::Float(lon)),
328                                ]));
329                            }
330                        }
331                        PointType::Cartesian2D => {
332                            if let (Some(x), Some(y)) = (f64_at("x"), f64_at("y")) {
333                                return Value::Map(HashMap::from([
334                                    ("type".to_string(), Value::String("Point".into())),
335                                    (
336                                        "crs".to_string(),
337                                        Value::String(crs.unwrap_or_else(|| "cartesian".into())),
338                                    ),
339                                    ("x".to_string(), Value::Float(x)),
340                                    ("y".to_string(), Value::Float(y)),
341                                ]));
342                            }
343                        }
344                        PointType::Cartesian3D => {
345                            if let (Some(x), Some(y), Some(z)) =
346                                (f64_at("x"), f64_at("y"), f64_at("z"))
347                            {
348                                return Value::Map(HashMap::from([
349                                    ("type".to_string(), Value::String("Point".into())),
350                                    (
351                                        "crs".to_string(),
352                                        Value::String(crs.unwrap_or_else(|| "cartesian-3d".into())),
353                                    ),
354                                    ("x".to_string(), Value::Float(x)),
355                                    ("y".to_string(), Value::Float(y)),
356                                    ("z".to_string(), Value::Float(z)),
357                                ]));
358                            }
359                        }
360                    }
361                }
362                // Point-typed but not a reconstructable point → Null.
363                return Value::Null;
364            }
365            DataType::Bytes => {
366                let Some(arr) = col.as_any().downcast_ref::<LargeBinaryArray>() else {
367                    log::warn!("Bytes column is not LargeBinaryArray");
368                    return Value::Null;
369                };
370                if arr.is_null(row) {
371                    return Value::Null;
372                }
373                return Value::Bytes(arr.value(row).to_vec());
374            }
375            DataType::Btic => {
376                let Some(fsb) = col.as_any().downcast_ref::<FixedSizeBinaryArray>() else {
377                    log::warn!("BTIC column is not FixedSizeBinaryArray");
378                    return Value::Null;
379                };
380                let bytes = fsb.value(row);
381                return match uni_btic::encode::decode_slice(bytes) {
382                    Ok(btic) => Value::Temporal(uni_common::TemporalValue::Btic {
383                        lo: btic.lo(),
384                        hi: btic.hi(),
385                        meta: btic.meta(),
386                    }),
387                    Err(e) => {
388                        log::warn!("BTIC decode error: {}", e);
389                        Value::Null
390                    }
391                };
392            }
393            DataType::SparseVector { .. } => {
394                let Some(struct_arr) = col.as_any().downcast_ref::<StructArray>() else {
395                    log::warn!("SparseVector column is not StructArray");
396                    return Value::Null;
397                };
398                if struct_arr.is_null(row) {
399                    return Value::Null;
400                }
401                let (Some(indices_list), Some(values_list)) = (
402                    struct_arr
403                        .column_by_name("indices")
404                        .and_then(|c| c.as_any().downcast_ref::<ListArray>()),
405                    struct_arr
406                        .column_by_name("values")
407                        .and_then(|c| c.as_any().downcast_ref::<ListArray>()),
408                ) else {
409                    log::warn!("SparseVector struct missing indices/values list columns");
410                    return Value::Null;
411                };
412                let idx_vals = indices_list.value(row);
413                let Some(idx_arr) = idx_vals.as_any().downcast_ref::<UInt32Array>() else {
414                    log::warn!("SparseVector 'indices' inner not UInt32");
415                    return Value::Null;
416                };
417                let w_vals = values_list.value(row);
418                let Some(w_arr) = w_vals.as_any().downcast_ref::<Float32Array>() else {
419                    log::warn!("SparseVector 'values' inner not Float32");
420                    return Value::Null;
421                };
422                let indices: Vec<u32> = (0..idx_arr.len()).map(|i| idx_arr.value(i)).collect();
423                let values: Vec<f32> = (0..w_arr.len()).map(|i| w_arr.value(i)).collect();
424                return Value::SparseVector { indices, values };
425            }
426            _ => {}
427        }
428    }
429
430    // String types
431    if let Some(s) = col.as_any().downcast_ref::<StringArray>() {
432        return Value::String(s.value(row).to_string());
433    }
434    // `LargeUtf8`. Distinct from `Utf8` at the Arrow level, so it needs its own
435    // downcast — a `StringArray` downcast does not match a `LargeStringArray`.
436    // Locy reaches this constantly: `infer_expr_type` types string literals and
437    // every string-returning function (`toUpper`, `substring`, `toString`, ...)
438    // as `LargeUtf8`, and `plan_locy_project` casts the column to match. Without
439    // this arm all of those fell through to the `Value::Null` fallback below,
440    // so `derived` reported NULL while `QUERY` (which evaluates natively, never
441    // touching Arrow) reported the real value.
442    if let Some(s) = col.as_any().downcast_ref::<LargeStringArray>() {
443        return Value::String(s.value(row).to_string());
444    }
445
446    // Integer types
447    if let Some(u) = col.as_any().downcast_ref::<UInt64Array>() {
448        return Value::Int(u.value(row) as i64);
449    }
450    if let Some(i) = col.as_any().downcast_ref::<Int64Array>() {
451        return Value::Int(i.value(row));
452    }
453    if let Some(i) = col.as_any().downcast_ref::<Int32Array>() {
454        return Value::Int(i.value(row) as i64);
455    }
456
457    // Float types
458    if let Some(f) = col.as_any().downcast_ref::<Float64Array>() {
459        return Value::Float(f.value(row));
460    }
461    if let Some(f) = col.as_any().downcast_ref::<Float32Array>() {
462        return Value::Float(f.value(row) as f64);
463    }
464
465    // Boolean type
466    if let Some(b) = col.as_any().downcast_ref::<BooleanArray>() {
467        return Value::Bool(b.value(row));
468    }
469
470    // Fixed-size list: a `Float32` child is a dense vector and round-trips as
471    // `Value::Vector`, preserving type identity (parity with `SparseVector` /
472    // `Btic`); any other element type decodes as a generic list. The recursive
473    // inner-element decode for multivector tokens (via `array_to_value_list`)
474    // reaches this same arm, so a `List<FixedSizeList<Float32>>` multivector
475    // decodes as a `Value::List` of `Value::Vector` tokens.
476    if let Some(list) = col.as_any().downcast_ref::<FixedSizeListArray>() {
477        let inner = list.value(row);
478        if let Some(floats) = inner.as_any().downcast_ref::<Float32Array>() {
479            return Value::Vector((0..floats.len()).map(|i| floats.value(i)).collect());
480        }
481        // A `UInt8` child is a binary vector — round-trip as `Value::BinaryVector`
482        // to preserve type identity (parity with the `Float32` dense-vector arm).
483        if let Some(bytes) = inner.as_any().downcast_ref::<UInt8Array>() {
484            return Value::BinaryVector((0..bytes.len()).map(|i| bytes.value(i)).collect());
485        }
486        let elem_hint = list_child_bytes_hint(list.data_type());
487        return Value::List(array_to_value_list(&inner, elem_hint));
488    }
489
490    // Variable-size list
491    if let Some(list) = col.as_any().downcast_ref::<ListArray>() {
492        let arr = list.value(row);
493
494        // Map types are stored as List(Struct(key, value)); reconstruct as map
495        if let Some(obj) = try_reconstruct_map(&arr) {
496            return Value::Map(obj);
497        }
498
499        let elem_hint = list_child_bytes_hint(list.data_type());
500        return Value::List(array_to_value_list(&arr, elem_hint));
501    }
502
503    // Large list (variable-size list with i64 offsets)
504    if let Some(list) = col.as_any().downcast_ref::<arrow_array::LargeListArray>() {
505        let elem_hint = list_child_bytes_hint(list.data_type());
506        return Value::List(array_to_value_list(&list.value(row), elem_hint));
507    }
508
509    // Struct type — detect temporal structs by field names before generic handler
510    if let Some(s) = col.as_any().downcast_ref::<StructArray>() {
511        // Sparse-vector struct: the result-row path calls with `data_type = None`,
512        // so detect it by Arrow shape here (otherwise the generic struct→Map
513        // handler below reads the `List<UInt32>` indices child as null).
514        if schema::is_sparse_vector_struct(col.data_type()) {
515            if s.is_null(row) {
516                return Value::Null;
517            }
518            if let (Some(idx_list), Some(val_list)) = (
519                s.column_by_name("indices")
520                    .and_then(|c| c.as_any().downcast_ref::<ListArray>()),
521                s.column_by_name("values")
522                    .and_then(|c| c.as_any().downcast_ref::<ListArray>()),
523            ) {
524                let idx_vals = idx_list.value(row);
525                let val_vals = val_list.value(row);
526                if let (Some(ia), Some(va)) = (
527                    idx_vals.as_any().downcast_ref::<UInt32Array>(),
528                    val_vals.as_any().downcast_ref::<Float32Array>(),
529                ) {
530                    let indices = (0..ia.len()).map(|i| ia.value(i)).collect();
531                    let values = (0..va.len()).map(|i| va.value(i)).collect();
532                    return Value::SparseVector { indices, values };
533                }
534            }
535        }
536
537        let field_names: Vec<&str> = s.fields().iter().map(|f| f.name().as_str()).collect();
538
539        // DateTime struct: {nanos_since_epoch, offset_seconds, timezone_name}
540        if field_names.contains(&"nanos_since_epoch")
541            && field_names.contains(&"offset_seconds")
542            && field_names.contains(&"timezone_name")
543            && let (Some(nanos_col), Some(offset_col), Some(tz_col)) = (
544                s.column_by_name("nanos_since_epoch"),
545                s.column_by_name("offset_seconds"),
546                s.column_by_name("timezone_name"),
547            )
548        {
549            // Try TimestampNanosecond first (standard schema), then Int64 fallback
550            let nanos_opt = nanos_col
551                .as_any()
552                .downcast_ref::<TimestampNanosecondArray>()
553                .map(|a| {
554                    if a.is_null(row) {
555                        None
556                    } else {
557                        Some(a.value(row))
558                    }
559                })
560                .or_else(|| {
561                    nanos_col.as_any().downcast_ref::<Int64Array>().map(|a| {
562                        if a.is_null(row) {
563                            None
564                        } else {
565                            Some(a.value(row))
566                        }
567                    })
568                });
569            let offset_opt = offset_col.as_any().downcast_ref::<Int32Array>().map(|a| {
570                if a.is_null(row) {
571                    None
572                } else {
573                    Some(a.value(row))
574                }
575            });
576
577            if let Some(Some(nanos)) = nanos_opt {
578                match offset_opt {
579                    Some(Some(offset)) => {
580                        let tz_name = tz_col.as_any().downcast_ref::<StringArray>().and_then(|a| {
581                            if a.is_null(row) {
582                                None
583                            } else {
584                                Some(a.value(row).to_string())
585                            }
586                        });
587                        return Value::Temporal(uni_common::TemporalValue::DateTime {
588                            nanos_since_epoch: nanos,
589                            offset_seconds: offset,
590                            timezone_name: tz_name,
591                        });
592                    }
593                    _ => {
594                        // No offset → LocalDateTime
595                        return Value::Temporal(uni_common::TemporalValue::LocalDateTime {
596                            nanos_since_epoch: nanos,
597                        });
598                    }
599                }
600            }
601        }
602
603        // Time struct: {nanos_since_midnight, offset_seconds}
604        if field_names.contains(&"nanos_since_midnight")
605            && field_names.contains(&"offset_seconds")
606            && let (Some(nanos_col), Some(offset_col)) = (
607                s.column_by_name("nanos_since_midnight"),
608                s.column_by_name("offset_seconds"),
609            )
610        {
611            // Try Time64Nanosecond first (standard schema), then Int64 fallback
612            let nanos_opt = nanos_col
613                .as_any()
614                .downcast_ref::<Time64NanosecondArray>()
615                .map(|a| {
616                    if a.is_null(row) {
617                        None
618                    } else {
619                        Some(a.value(row))
620                    }
621                })
622                .or_else(|| {
623                    nanos_col.as_any().downcast_ref::<Int64Array>().map(|a| {
624                        if a.is_null(row) {
625                            None
626                        } else {
627                            Some(a.value(row))
628                        }
629                    })
630                });
631            let offset_opt = offset_col.as_any().downcast_ref::<Int32Array>().map(|a| {
632                if a.is_null(row) {
633                    None
634                } else {
635                    Some(a.value(row))
636                }
637            });
638
639            if let (Some(Some(nanos)), Some(Some(offset))) = (nanos_opt, offset_opt) {
640                return Value::Temporal(uni_common::TemporalValue::Time {
641                    nanos_since_midnight: nanos,
642                    offset_seconds: offset,
643                });
644            }
645        }
646
647        // Generic struct → Map
648        let mut map = HashMap::new();
649        for (field, child) in s.fields().iter().zip(s.columns()) {
650            map.insert(
651                field.name().clone(),
652                arrow_to_value(child.as_ref(), row, None),
653            );
654        }
655        return Value::Map(map);
656    }
657
658    // Date32 type (days since epoch) - return as Value::Temporal
659    if let Some(d) = col.as_any().downcast_ref::<Date32Array>() {
660        let days = d.value(row);
661        return Value::Temporal(uni_common::TemporalValue::Date {
662            days_since_epoch: days,
663        });
664    }
665
666    // Timestamp (nanoseconds since epoch) - timezone presence determines DateTime vs LocalDateTime
667    if let Some(ts) = col.as_any().downcast_ref::<TimestampNanosecondArray>() {
668        let nanos = ts.value(row);
669        return match ts.timezone() {
670            Some(tz) => Value::Temporal(uni_common::TemporalValue::DateTime {
671                nanos_since_epoch: nanos,
672                offset_seconds: 0,
673                timezone_name: Some(tz.to_string()),
674            }),
675            None => Value::Temporal(uni_common::TemporalValue::LocalDateTime {
676                nanos_since_epoch: nanos,
677            }),
678        };
679    }
680
681    // Time64 (nanoseconds since midnight) - return as Value::Temporal
682    if let Some(t) = col.as_any().downcast_ref::<Time64NanosecondArray>() {
683        let nanos = t.value(row);
684        return Value::Temporal(uni_common::TemporalValue::LocalTime {
685            nanos_since_midnight: nanos,
686        });
687    }
688
689    // Time64 (microseconds since midnight) - convert to nanoseconds
690    if let Some(t) = col
691        .as_any()
692        .downcast_ref::<arrow_array::Time64MicrosecondArray>()
693    {
694        let micros = t.value(row);
695        return Value::Temporal(uni_common::TemporalValue::LocalTime {
696            nanos_since_midnight: micros * 1000,
697        });
698    }
699
700    // DurationMicrosecond - convert to Duration with nanoseconds
701    if let Some(d) = col
702        .as_any()
703        .downcast_ref::<arrow_array::DurationMicrosecondArray>()
704    {
705        let micros = d.value(row);
706        let total_nanos = micros * 1000;
707        let seconds = total_nanos / 1_000_000_000;
708        let remaining_nanos = total_nanos % 1_000_000_000;
709        return Value::Temporal(uni_common::TemporalValue::Duration {
710            months: 0,
711            days: 0,
712            nanos: seconds * 1_000_000_000 + remaining_nanos,
713        });
714    }
715
716    // IntervalMonthDayNano - return as Value::Temporal(Duration)
717    if let Some(interval) = col.as_any().downcast_ref::<IntervalMonthDayNanoArray>() {
718        let val = interval.value(row);
719        return Value::Temporal(uni_common::TemporalValue::Duration {
720            months: val.months as i64,
721            days: val.days as i64,
722            nanos: val.nanoseconds,
723        });
724    }
725
726    // LargeBinary (CypherValue MessagePack-tagged encoding)
727    if let Some(b) = col.as_any().downcast_ref::<LargeBinaryArray>() {
728        let bytes = b.value(row);
729        if bytes.is_empty() {
730            return Value::Null;
731        }
732        return uni_common::cypher_value_codec::decode(bytes).unwrap_or_else(|e| {
733            eprintln!("CypherValue decode error: {}", e);
734            Value::Null
735        });
736    }
737
738    // FixedSizeBinary(24) — BTIC temporal interval
739    if let Some(fsb) = col.as_any().downcast_ref::<FixedSizeBinaryArray>()
740        && fsb.value_length() == 24
741    {
742        let bytes = fsb.value(row);
743        return match uni_btic::encode::decode_slice(bytes) {
744            Ok(btic) => Value::Temporal(uni_common::TemporalValue::Btic {
745                lo: btic.lo(),
746                hi: btic.hi(),
747                meta: btic.meta(),
748            }),
749            Err(e) => {
750                log::warn!("BTIC decode error: {}", e);
751                Value::Null
752            }
753        };
754    }
755
756    // Binary (CRDT MessagePack) - decode to Value via serde_json boundary
757    if let Some(b) = col.as_any().downcast_ref::<BinaryArray>() {
758        let bytes = b.value(row);
759        return Crdt::from_msgpack(bytes)
760            .ok()
761            .and_then(|crdt| serde_json::to_value(&crdt).ok())
762            .map(Value::from)
763            .unwrap_or(Value::Null);
764    }
765
766    // Arrow's Null type carries no values by construction, so `Value::Null` is
767    // the correct decode, not a gap. Listed explicitly so it does not reach the
768    // diagnostic below.
769    if *col.data_type() == ArrowDataType::Null {
770        return Value::Null;
771    }
772
773    // `_uid` — the 32-byte content hash on vertex/index datasets
774    // (`vertex.rs`, `main_vertex.rs`, `index.rs`). Internal, never user-facing,
775    // and by far the most common visitor here (~750 calls in one integration
776    // run), which is why it is matched explicitly rather than left to warn on
777    // every cell. The `FixedSizeBinary(24)` arm above claims BTIC; every other
778    // width is this.
779    if matches!(col.data_type(), ArrowDataType::FixedSizeBinary(_)) {
780        return Value::Null;
781    }
782
783    // Fallback: an Arrow type no arm above handles. Decoding to `Null` silently
784    // is how the missing `LargeUtf8` arm survived long enough to be written down
785    // as a Locy language constraint — every string literal and string-returning
786    // function in a `YIELD` came back NULL with no diagnostic anywhere. Warn so
787    // the next gap is visible.
788    //
789    // Deliberately not an error: this decoder is shared with the Cypher read
790    // path and not every caller has been audited for types that legitimately
791    // land here. The two high-volume benign cases are handled above, so
792    // reaching this point is genuinely unexpected and worth a line in the log.
793    log::warn!(
794        "arrow_to_value: no decoder for Arrow type {:?}; returning Null. \
795         This is a silent wrong answer if the column holds real data — \
796         add a downcast arm for it.",
797        col.data_type()
798    );
799    Value::Null
800}
801
802/// Shared body of the primitive `values_to_*_array` helpers: build a nullable
803/// Arrow primitive array, appending a null wherever `extract` yields `None`.
804fn values_to_primitive<T: ArrowPrimitiveType>(
805    values: &[Value],
806    extract: impl Fn(&Value) -> Option<T::Native>,
807) -> ArrayRef {
808    let mut builder = PrimitiveBuilder::<T>::with_capacity(values.len());
809    for v in values {
810        match extract(v) {
811            Some(n) => builder.append_value(n),
812            None => builder.append_null(),
813        }
814    }
815    Arc::new(builder.finish())
816}
817
818fn values_to_uint64_array(values: &[Value]) -> ArrayRef {
819    values_to_primitive::<UInt64Type>(values, Value::as_u64)
820}
821
822fn values_to_int64_array(values: &[Value]) -> ArrayRef {
823    values_to_primitive::<Int64Type>(values, Value::as_i64)
824}
825
826fn values_to_int32_array(values: &[Value]) -> ArrayRef {
827    // Wrapping `as i32` is this path's long-standing narrowing behavior; do not
828    // swap it for `i32::try_from` (which would null out-of-range values).
829    values_to_primitive::<Int32Type>(values, |v| v.as_i64().map(|n| n as i32))
830}
831
832fn values_to_string_array(values: &[Value]) -> ArrayRef {
833    let mut builder = StringBuilder::with_capacity(values.len(), values.len() * 10);
834    for v in values {
835        if let Some(s) = v.as_str() {
836            builder.append_value(s);
837        } else if v.is_null() {
838            builder.append_null();
839        } else {
840            builder.append_value(v.to_string());
841        }
842    }
843    Arc::new(builder.finish())
844}
845
846fn values_to_bool_array(values: &[Value]) -> ArrayRef {
847    let mut builder = BooleanBuilder::with_capacity(values.len());
848    for v in values {
849        if let Some(b) = v.as_bool() {
850            builder.append_value(b);
851        } else {
852            builder.append_null();
853        }
854    }
855    Arc::new(builder.finish())
856}
857
858fn values_to_float32_array(values: &[Value]) -> ArrayRef {
859    values_to_primitive::<Float32Type>(values, |v| v.as_f64().map(|n| n as f32))
860}
861
862fn values_to_float64_array(values: &[Value]) -> ArrayRef {
863    values_to_primitive::<Float64Type>(values, Value::as_f64)
864}
865
866fn values_to_fixed_size_binary_array(values: &[Value], size: i32) -> Result<ArrayRef> {
867    let mut builder = FixedSizeBinaryBuilder::with_capacity(values.len(), size);
868    for v in values {
869        match v {
870            Value::Temporal(uni_common::TemporalValue::Btic { lo, hi, meta }) if size == 24 => {
871                let btic = uni_btic::Btic::new(*lo, *hi, *meta)
872                    .map_err(|e| anyhow!("invalid BTIC value: {}", e))?;
873                builder.append_value(uni_btic::encode::encode(&btic))?;
874            }
875            Value::String(s) if size == 24 => match uni_btic::parse::parse_btic_literal(s) {
876                Ok(b) => builder.append_value(uni_btic::encode::encode(&b))?,
877                Err(_) => builder.append_null(),
878            },
879            Value::List(bytes) => {
880                let b: Vec<u8> = bytes
881                    .iter()
882                    .map(|bv| bv.as_u64().unwrap_or(0) as u8)
883                    .collect();
884                if b.len() as i32 == size {
885                    builder.append_value(&b)?;
886                } else {
887                    builder.append_null();
888                }
889            }
890            _ => builder.append_null(),
891        }
892    }
893    Ok(Arc::new(builder.finish()))
894}
895
896/// Extract f32 vector values from a Value, ensuring correct Arrow FixedSizeList invariants.
897///
898/// Always returns exactly `dimensions` f32 values (zeros for null/invalid), plus a validity flag.
899/// This guarantees `child_array.len() == parent_array.len() × dimensions`.
900///
901/// # Arguments
902/// - `val`: Optional property value to extract from
903/// - `is_deleted`: Whether the containing entity is deleted (affects validity)
904/// - `dimensions`: Expected vector dimensions
905///
906/// # Returns
907/// - Tuple of (vector values, validity flag)
908///   - Vector always has exactly `dimensions` elements
909///   - Validity is `true` for valid vectors or deleted entries, `false` for null/invalid
910pub fn extract_vector_f32_values(
911    val: Option<&Value>,
912    is_deleted: bool,
913    dimensions: usize,
914) -> (Vec<f32>, bool) {
915    let zeros = || vec![0.0_f32; dimensions];
916
917    // Deleted entries always return zeros with valid=true
918    if is_deleted {
919        return (zeros(), true);
920    }
921
922    match val {
923        // Native f32 vector (Value::Vector)
924        Some(Value::Vector(v)) if v.len() == dimensions => (v.clone(), true),
925        Some(Value::Vector(_)) => (zeros(), false), // Wrong dimensions
926        // List of values (Value::List) - convert to f32
927        Some(Value::List(arr)) if arr.len() == dimensions => {
928            let values: Vec<f32> = arr
929                .iter()
930                .map(|v| v.as_f64().unwrap_or(0.0) as f32)
931                .collect();
932            (values, true)
933        }
934        Some(Value::List(_)) => (zeros(), false), // Wrong dimensions
935        _ => (zeros(), false),                    // Missing or unsupported value
936    }
937}
938
939/// Fail-closed sibling of [`extract_vector_f32_values`] for declared-schema columns.
940///
941/// The lenient extractor silently nulls a wrong-dimension or wrong-shape value —
942/// which is how issue #137's corruption reached storage. On the declared-schema
943/// flush path (`PropertyExtractor::build_vector_column` and the multi-vector list
944/// builder) that is always a bug or a guard bypass, so this variant errors instead.
945/// Deleted rows and genuine nulls (`None` / `Value::Null`) keep the lenient
946/// behavior — they are legal null rows, not data loss.
947///
948/// # Errors
949/// Returns the underlying [`VectorDimError`](uni_common::core::schema::VectorDimError)
950/// when a present, non-null value has the wrong dimensions, a non-numeric element,
951/// or is not a vector at all.
952pub fn extract_vector_f32_values_strict(
953    val: Option<&Value>,
954    is_deleted: bool,
955    dimensions: usize,
956) -> Result<(Vec<f32>, bool)> {
957    if !is_deleted && let Some(v) = val {
958        uni_common::core::schema::check_dense_vector_value(v, dimensions)?;
959    }
960    Ok(extract_vector_f32_values(val, is_deleted, dimensions))
961}
962
963/// Extract `u8` lane values from a Value for a `BinaryVector(dimensions)` column.
964///
965/// The binary-vector analogue of [`extract_vector_f32_values`]: always returns
966/// exactly `dimensions` bytes (zeros for null/invalid), plus a validity flag, so
967/// the Arrow `FixedSizeList<UInt8>` stride invariant holds. Accepts a
968/// [`Value::BinaryVector`] or a [`Value::List`] of byte-valued integers.
969///
970/// # Returns
971/// `(lane bytes, validity)` — validity is `true` for a valid vector or a deleted
972/// entry, `false` for a null/wrong-shape value.
973pub fn extract_binary_vector_values(
974    val: Option<&Value>,
975    is_deleted: bool,
976    dimensions: usize,
977) -> (Vec<u8>, bool) {
978    let zeros = || vec![0_u8; dimensions];
979
980    if is_deleted {
981        return (zeros(), true);
982    }
983
984    match val {
985        Some(Value::BinaryVector(b)) if b.len() == dimensions => (b.clone(), true),
986        Some(Value::BinaryVector(_)) => (zeros(), false), // Wrong lane count
987        Some(Value::List(arr)) if arr.len() == dimensions => {
988            let mut bytes = Vec::with_capacity(dimensions);
989            for v in arr {
990                match v.as_i64() {
991                    Some(n @ 0..=255) => bytes.push(n as u8),
992                    _ => return (zeros(), false), // Non-byte element
993                }
994            }
995            (bytes, true)
996        }
997        Some(Value::List(_)) => (zeros(), false), // Wrong lane count
998        _ => (zeros(), false),                    // Missing or unsupported value
999    }
1000}
1001
1002/// Fail-closed sibling of [`extract_binary_vector_values`] for declared-schema
1003/// columns, mirroring [`extract_vector_f32_values_strict`].
1004///
1005/// # Errors
1006/// Returns the underlying [`VectorDimError`](uni_common::core::schema::VectorDimError)
1007/// when a present, non-null value has the wrong lane count, a non-byte element,
1008/// or is not a binary vector at all.
1009pub fn extract_binary_vector_values_strict(
1010    val: Option<&Value>,
1011    is_deleted: bool,
1012    dimensions: usize,
1013) -> Result<(Vec<u8>, bool)> {
1014    if !is_deleted && let Some(v) = val {
1015        uni_common::core::schema::check_binary_vector_value(v, dimensions)?;
1016    }
1017    Ok(extract_binary_vector_values(val, is_deleted, dimensions))
1018}
1019
1020fn values_to_fixed_size_list_f32_array(values: &[Value], size: i32) -> ArrayRef {
1021    let mut builder = FixedSizeListBuilder::new(Float32Builder::new(), size);
1022    for v in values {
1023        let (vals, valid) = extract_vector_f32_values(Some(v), false, size as usize);
1024        for val in vals {
1025            builder.values().append_value(val);
1026        }
1027        builder.append(valid);
1028    }
1029    Arc::new(builder.finish())
1030}
1031
1032fn values_to_timestamp_array(values: &[Value], tz: Option<&Arc<str>>) -> ArrayRef {
1033    let mut builder = TimestampNanosecondBuilder::with_capacity(values.len());
1034    for v in values {
1035        if v.is_null() {
1036            builder.append_null();
1037        } else if let Value::Temporal(tv) = v {
1038            match tv {
1039                uni_common::TemporalValue::DateTime {
1040                    nanos_since_epoch, ..
1041                }
1042                | uni_common::TemporalValue::LocalDateTime {
1043                    nanos_since_epoch, ..
1044                } => builder.append_value(*nanos_since_epoch),
1045                _ => builder.append_null(),
1046            }
1047        } else if let Some(n) = v.as_i64() {
1048            builder.append_value(n);
1049        } else if let Some(s) = v.as_str() {
1050            match parse_datetime_to_nanos(s) {
1051                Some(nanos) => builder.append_value(nanos),
1052                None => builder.append_null(),
1053            }
1054        } else {
1055            builder.append_null();
1056        }
1057    }
1058
1059    let arr = builder.finish();
1060    if let Some(tz) = tz {
1061        Arc::new(arr.with_timezone(tz.as_ref()))
1062    } else {
1063        Arc::new(arr)
1064    }
1065}
1066
1067/// Build a DateTime struct array from values.
1068///
1069/// Encodes DateTime as a 3-field struct: (nanos_since_epoch, offset_seconds, timezone_name).
1070/// This preserves timezone offset information that was previously lost with TimestampNanosecond encoding.
1071fn values_to_datetime_struct_array(values: &[Value]) -> ArrayRef {
1072    let mut nanos_builder = TimestampNanosecondBuilder::with_capacity(values.len());
1073    let mut offset_builder = Int32Builder::with_capacity(values.len());
1074    let mut tz_builder = StringBuilder::with_capacity(values.len(), values.len() * 20);
1075    let mut null_buffer = BooleanBufferBuilder::new(values.len());
1076
1077    for v in values {
1078        match v {
1079            Value::Temporal(uni_common::TemporalValue::DateTime {
1080                nanos_since_epoch,
1081                offset_seconds,
1082                timezone_name,
1083            }) => {
1084                nanos_builder.append_value(*nanos_since_epoch);
1085                offset_builder.append_value(*offset_seconds);
1086                tz_builder.append_option(timezone_name.as_deref());
1087                null_buffer.append(true);
1088            }
1089            Value::Temporal(uni_common::TemporalValue::LocalDateTime { nanos_since_epoch }) => {
1090                nanos_builder.append_value(*nanos_since_epoch);
1091                offset_builder.append_null();
1092                tz_builder.append_null();
1093                null_buffer.append(true);
1094            }
1095            _ => {
1096                nanos_builder.append_null();
1097                offset_builder.append_null();
1098                tz_builder.append_null();
1099                null_buffer.append(false);
1100            }
1101        }
1102    }
1103
1104    let struct_arr = StructArray::new(
1105        schema::datetime_struct_fields(),
1106        vec![
1107            Arc::new(nanos_builder.finish()) as ArrayRef,
1108            Arc::new(offset_builder.finish()) as ArrayRef,
1109            Arc::new(tz_builder.finish()) as ArrayRef,
1110        ],
1111        Some(null_buffer.finish().into()),
1112    );
1113    Arc::new(struct_arr)
1114}
1115
1116/// Build a Time struct array from values.
1117///
1118/// Encodes Time as a 2-field struct: (nanos_since_midnight, offset_seconds).
1119/// This preserves timezone offset information that was previously lost with Time64Nanosecond encoding.
1120fn values_to_time_struct_array(values: &[Value]) -> ArrayRef {
1121    let mut nanos_builder = Time64NanosecondBuilder::with_capacity(values.len());
1122    let mut offset_builder = Int32Builder::with_capacity(values.len());
1123    let mut null_buffer = BooleanBufferBuilder::new(values.len());
1124
1125    for v in values {
1126        match v {
1127            Value::Temporal(uni_common::TemporalValue::Time {
1128                nanos_since_midnight,
1129                offset_seconds,
1130            }) => {
1131                nanos_builder.append_value(*nanos_since_midnight);
1132                offset_builder.append_value(*offset_seconds);
1133                null_buffer.append(true);
1134            }
1135            Value::Temporal(uni_common::TemporalValue::LocalTime {
1136                nanos_since_midnight,
1137            }) => {
1138                nanos_builder.append_value(*nanos_since_midnight);
1139                offset_builder.append_null();
1140                null_buffer.append(true);
1141            }
1142            _ => {
1143                nanos_builder.append_null();
1144                offset_builder.append_null();
1145                null_buffer.append(false);
1146            }
1147        }
1148    }
1149
1150    let struct_arr = StructArray::new(
1151        schema::time_struct_fields(),
1152        vec![
1153            Arc::new(nanos_builder.finish()) as ArrayRef,
1154            Arc::new(offset_builder.finish()) as ArrayRef,
1155        ],
1156        Some(null_buffer.finish().into()),
1157    );
1158    Arc::new(struct_arr)
1159}
1160
1161/// Build a Point struct array from `Value::Map` points.
1162///
1163/// The Arrow layout is selected by the column's declared [`PointType`], matching
1164/// [`schema::DataType::to_arrow`]: Geographic → `{latitude, longitude, crs}`,
1165/// Cartesian2D → `{x, y, crs}`, Cartesian3D → `{x, y, z, crs}`. All child fields
1166/// are non-nullable, so a missing/mismatched row marks the struct slot null while
1167/// writing placeholder `0.0`/`""` into the children (a null struct slot's child
1168/// values are never observed).
1169pub(crate) fn values_to_point_struct_array(values: &[Value], point_type: PointType) -> ArrayRef {
1170    use arrow_array::builder::Float64Builder;
1171
1172    let n = values.len();
1173    let fields = match schema::DataType::Point(point_type).to_arrow() {
1174        ArrowDataType::Struct(f) => f,
1175        // `DataType::Point` always maps to a struct; anything else is a bug.
1176        _ => unreachable!("Point maps to an Arrow struct"),
1177    };
1178
1179    // Read an f64 coordinate from a point map, or None if absent/non-numeric.
1180    let coord = |v: &Value, key: &str| -> Option<f64> {
1181        v.as_object()
1182            .and_then(|m| m.get(key))
1183            .and_then(Value::as_f64)
1184    };
1185    let crs_of = |v: &Value, default: &str| -> String {
1186        v.as_object()
1187            .and_then(|m| m.get("crs"))
1188            .and_then(|c| c.as_str())
1189            .unwrap_or(default)
1190            .to_string()
1191    };
1192
1193    let mut null_buffer = BooleanBufferBuilder::new(n);
1194    let mut crs_b = StringBuilder::with_capacity(n, n * 8);
1195
1196    // Ordered coordinate builders + the key/default-crs metadata for this layout.
1197    let (keys, default_crs): (&[&str], &str) = match point_type {
1198        PointType::Geographic => (&["latitude", "longitude"], "WGS84"),
1199        PointType::Cartesian2D => (&["x", "y"], "cartesian"),
1200        PointType::Cartesian3D => (&["x", "y", "z"], "cartesian-3d"),
1201    };
1202    let mut coord_builders: Vec<Float64Builder> = keys
1203        .iter()
1204        .map(|_| Float64Builder::with_capacity(n))
1205        .collect();
1206
1207    for v in values {
1208        let coords: Option<Vec<f64>> = keys.iter().map(|k| coord(v, k)).collect();
1209        match coords {
1210            Some(cs) => {
1211                for (b, c) in coord_builders.iter_mut().zip(cs) {
1212                    b.append_value(c);
1213                }
1214                crs_b.append_value(crs_of(v, default_crs));
1215                null_buffer.append(true);
1216            }
1217            None => {
1218                for b in &mut coord_builders {
1219                    b.append_value(0.0);
1220                }
1221                crs_b.append_value(default_crs);
1222                null_buffer.append(false);
1223            }
1224        }
1225    }
1226
1227    let mut columns: Vec<ArrayRef> = coord_builders
1228        .into_iter()
1229        .map(|mut b| Arc::new(b.finish()) as ArrayRef)
1230        .collect();
1231    columns.push(Arc::new(crs_b.finish()) as ArrayRef);
1232
1233    Arc::new(StructArray::new(
1234        fields,
1235        columns,
1236        Some(null_buffer.finish().into()),
1237    ))
1238}
1239
1240fn values_to_large_binary_array(values: &[Value]) -> ArrayRef {
1241    let mut builder =
1242        arrow_array::builder::LargeBinaryBuilder::with_capacity(values.len(), values.len() * 64);
1243    for v in values {
1244        if v.is_null() {
1245            builder.append_null();
1246        } else {
1247            // Encode as CypherValue (MessagePack-tagged)
1248            let cv_bytes = uni_common::cypher_value_codec::encode(v);
1249            builder.append_value(&cv_bytes);
1250        }
1251    }
1252    Arc::new(builder.finish())
1253}
1254
1255/// Convert a slice of JSON Values to an Arrow array based on the target Arrow DataType.
1256///
1257/// Note: there is deliberately no `LargeUtf8` arm, unlike the read direction in
1258/// [`arrow_to_value`]. Checked when the missing `LargeUtf8` *read* arm was
1259/// fixed: no Uni `DataType` maps to `LargeUtf8` (`DataType::String` →
1260/// `Utf8`), and no UDF declares it as a return type, so nothing reaches here
1261/// with it. Should that change, the fallthrough is an `Err` — loud, and the
1262/// right direction — not a silent null.
1263pub fn values_to_array(values: &[Value], dt: &ArrowDataType) -> Result<ArrayRef> {
1264    match dt {
1265        ArrowDataType::UInt64 => Ok(values_to_uint64_array(values)),
1266        ArrowDataType::Int64 => Ok(values_to_int64_array(values)),
1267        ArrowDataType::Int32 => Ok(values_to_int32_array(values)),
1268        ArrowDataType::Utf8 => Ok(values_to_string_array(values)),
1269        ArrowDataType::Boolean => Ok(values_to_bool_array(values)),
1270        ArrowDataType::Float32 => Ok(values_to_float32_array(values)),
1271        ArrowDataType::Float64 => Ok(values_to_float64_array(values)),
1272        ArrowDataType::FixedSizeBinary(size) => values_to_fixed_size_binary_array(values, *size),
1273        ArrowDataType::FixedSizeList(inner, size) => {
1274            if inner.data_type() == &ArrowDataType::Float32 {
1275                Ok(values_to_fixed_size_list_f32_array(values, *size))
1276            } else {
1277                Err(anyhow!("Unsupported FixedSizeList inner type"))
1278            }
1279        }
1280        ArrowDataType::Timestamp(arrow_schema::TimeUnit::Nanosecond, tz) => {
1281            Ok(values_to_timestamp_array(values, tz.as_ref()))
1282        }
1283        ArrowDataType::Timestamp(arrow_schema::TimeUnit::Microsecond, tz) => {
1284            Ok(values_to_timestamp_array(values, tz.as_ref()))
1285        }
1286        ArrowDataType::Date32 => {
1287            let mut builder = Date32Builder::with_capacity(values.len());
1288            for v in values {
1289                if v.is_null() {
1290                    builder.append_null();
1291                } else if let Value::Temporal(uni_common::TemporalValue::Date {
1292                    days_since_epoch,
1293                }) = v
1294                {
1295                    builder.append_value(*days_since_epoch);
1296                } else if let Some(n) = v.as_i64() {
1297                    builder.append_value(n as i32);
1298                } else {
1299                    builder.append_null();
1300                }
1301            }
1302            Ok(Arc::new(builder.finish()))
1303        }
1304        ArrowDataType::Time64(arrow_schema::TimeUnit::Nanosecond) => {
1305            let mut builder = Time64NanosecondBuilder::with_capacity(values.len());
1306            for v in values {
1307                if v.is_null() {
1308                    builder.append_null();
1309                } else if let Value::Temporal(tv) = v {
1310                    match tv {
1311                        uni_common::TemporalValue::LocalTime {
1312                            nanos_since_midnight,
1313                        }
1314                        | uni_common::TemporalValue::Time {
1315                            nanos_since_midnight,
1316                            ..
1317                        } => builder.append_value(*nanos_since_midnight),
1318                        _ => builder.append_null(),
1319                    }
1320                } else if let Some(n) = v.as_i64() {
1321                    builder.append_value(n);
1322                } else {
1323                    builder.append_null();
1324                }
1325            }
1326            Ok(Arc::new(builder.finish()))
1327        }
1328        ArrowDataType::Time64(arrow_schema::TimeUnit::Microsecond) => {
1329            let mut builder = Time64MicrosecondBuilder::with_capacity(values.len());
1330            for v in values {
1331                if v.is_null() {
1332                    builder.append_null();
1333                } else if let Value::Temporal(tv) = v {
1334                    match tv {
1335                        uni_common::TemporalValue::LocalTime {
1336                            nanos_since_midnight,
1337                        }
1338                        | uni_common::TemporalValue::Time {
1339                            nanos_since_midnight,
1340                            ..
1341                        } => builder.append_value(*nanos_since_midnight / 1_000), // nanos→micros for legacy
1342                        _ => builder.append_null(),
1343                    }
1344                } else if let Some(n) = v.as_i64() {
1345                    builder.append_value(n);
1346                } else {
1347                    builder.append_null();
1348                }
1349            }
1350            Ok(Arc::new(builder.finish()))
1351        }
1352        ArrowDataType::Interval(arrow_schema::IntervalUnit::MonthDayNano) => {
1353            let mut builder = IntervalMonthDayNanoBuilder::with_capacity(values.len());
1354            for v in values {
1355                if v.is_null() {
1356                    builder.append_null();
1357                } else if let Value::Temporal(uni_common::TemporalValue::Duration {
1358                    months,
1359                    days,
1360                    nanos,
1361                }) = v
1362                {
1363                    builder.append_value(arrow::datatypes::IntervalMonthDayNano {
1364                        months: *months as i32,
1365                        days: *days as i32,
1366                        nanoseconds: *nanos,
1367                    });
1368                } else {
1369                    builder.append_null();
1370                }
1371            }
1372            Ok(Arc::new(builder.finish()))
1373        }
1374        ArrowDataType::Duration(arrow_schema::TimeUnit::Microsecond) => {
1375            let mut builder = DurationMicrosecondBuilder::with_capacity(values.len());
1376            for v in values {
1377                if v.is_null() {
1378                    builder.append_null();
1379                } else if let Value::Temporal(uni_common::TemporalValue::Duration {
1380                    months,
1381                    days,
1382                    nanos,
1383                }) = v
1384                {
1385                    let total_micros =
1386                        months * 30 * 86_400_000_000i64 + days * 86_400_000_000i64 + nanos / 1_000;
1387                    builder.append_value(total_micros);
1388                } else if let Some(n) = v.as_i64() {
1389                    builder.append_value(n);
1390                } else {
1391                    builder.append_null();
1392                }
1393            }
1394            Ok(Arc::new(builder.finish()))
1395        }
1396        ArrowDataType::LargeBinary => Ok(values_to_large_binary_array(values)),
1397        ArrowDataType::List(field) => {
1398            if field.data_type() == &ArrowDataType::Utf8 {
1399                let mut builder = ListBuilder::new(StringBuilder::new());
1400                for v in values {
1401                    if let Value::List(arr) = v {
1402                        for item in arr {
1403                            if let Some(s) = item.as_str() {
1404                                builder.values().append_value(s);
1405                            } else {
1406                                builder.values().append_null();
1407                            }
1408                        }
1409                        builder.append(true);
1410                    } else {
1411                        builder.append_null();
1412                    }
1413                }
1414                Ok(Arc::new(builder.finish()))
1415            } else {
1416                Err(anyhow!(
1417                    "Unsupported List inner type: {:?}",
1418                    field.data_type()
1419                ))
1420            }
1421        }
1422        ArrowDataType::Struct(_) if schema::is_datetime_struct(dt) => {
1423            Ok(values_to_datetime_struct_array(values))
1424        }
1425        ArrowDataType::Struct(_) if schema::is_time_struct(dt) => {
1426            Ok(values_to_time_struct_array(values))
1427        }
1428        _ => Err(anyhow!("Unsupported type for conversion: {:?}", dt)),
1429    }
1430}
1431
1432/// Property value extractor for building Arrow columns from entity properties.
1433pub struct PropertyExtractor<'a> {
1434    /// Property name, carried for actionable flush-error messages (issue #137).
1435    name: &'a str,
1436    data_type: &'a DataType,
1437}
1438
1439/// Extract sparse `(indices, values)` from a property value.
1440///
1441/// Accepts the native [`Value::SparseVector`] **and** the degraded
1442/// `Value::Map { "indices": [..], "values": [..] }` form that a `SparseVector`
1443/// collapses into when round-tripped through `#[serde(untagged)]` persistence —
1444/// notably the WAL, which serializes mutations via `serde_json`. This mirrors
1445/// how the dense `Vector` column tolerates a `Value::List` from the same hazard.
1446/// Returns `None` for any other value (→ a null struct row).
1447fn sparse_pair_from_value(v: &Value) -> Option<(Vec<u32>, Vec<f32>)> {
1448    match v {
1449        Value::SparseVector { indices, values } => {
1450            // Guard the native arm the same way the `Value::Map` arm below does: a
1451            // length-mismatched value would otherwise emit an Arrow struct whose
1452            // `indices`/`values` child lists desync, which the reader silently
1453            // truncates via `.zip()` — pairing weights with the wrong term ids
1454            // (issue #95). Reject it to a null struct row instead.
1455            if indices.len() != values.len() {
1456                return None;
1457            }
1458            Some((indices.clone(), values.clone()))
1459        }
1460        Value::Map(m) => {
1461            let idx = match m.get("indices") {
1462                Some(Value::List(l)) => l,
1463                _ => return None,
1464            };
1465            let vals = match m.get("values") {
1466                Some(Value::List(l)) => l,
1467                _ => return None,
1468            };
1469            let indices: Vec<u32> = idx
1470                .iter()
1471                .map(|x| x.as_u64().map(|n| n as u32))
1472                .collect::<Option<_>>()?;
1473            let values: Vec<f32> = vals
1474                .iter()
1475                .map(|x| x.as_f64().map(|n| n as f32))
1476                .collect::<Option<_>>()?;
1477            if indices.len() != values.len() {
1478                return None;
1479            }
1480            Some((indices, values))
1481        }
1482        _ => None,
1483    }
1484}
1485
1486/// Build a sparse-vector `Struct { indices: List<UInt32>, values: List<Float32> }`
1487/// Arrow column from a sequence of property values. Used by the query result
1488/// projection path (`RETURN d.sparse_col`). A `None` / non-sparse / map-degraded
1489/// value that can't be extracted becomes a null struct row. Mirrors
1490/// `PropertyExtractor::build_sparse_vector_column` but operates on owned values.
1491pub fn build_sparse_vector_array(values: &[Option<Value>]) -> ArrayRef {
1492    let mut indices_builder = ListBuilder::new(UInt32Builder::new());
1493    let mut values_builder = ListBuilder::new(Float32Builder::new());
1494    let mut null_buffer = BooleanBufferBuilder::new(values.len());
1495    for v in values {
1496        match v.as_ref().and_then(sparse_pair_from_value) {
1497            Some((indices, vals)) => {
1498                for ix in indices {
1499                    indices_builder.values().append_value(ix);
1500                }
1501                indices_builder.append(true);
1502                for w in vals {
1503                    values_builder.values().append_value(w);
1504                }
1505                values_builder.append(true);
1506                null_buffer.append(true);
1507            }
1508            None => {
1509                indices_builder.append(true);
1510                values_builder.append(true);
1511                null_buffer.append(false);
1512            }
1513        }
1514    }
1515    let struct_arr = StructArray::new(
1516        schema::sparse_vector_struct_fields(),
1517        vec![
1518            Arc::new(indices_builder.finish()) as ArrayRef,
1519            Arc::new(values_builder.finish()) as ArrayRef,
1520        ],
1521        Some(null_buffer.finish().into()),
1522    );
1523    Arc::new(struct_arr)
1524}
1525
1526/// Build a multi-vector `List<FixedSizeList<Float32, dimensions>>` Arrow column.
1527///
1528/// Used by the query result projection path (`RETURN d.multivector_col`) on the
1529/// L0 (unflushed) read path. Mirrors the `DataType::Vector { dimensions }` arm of
1530/// `PropertyExtractor::build_list_column` but operates on owned values: a `None`
1531/// or non-list value becomes a null row, and each token is validated through
1532/// `extract_vector_f32_values` (a wrong-dimension or non-numeric token becomes a
1533/// null inner vector), so failure modes match the write path.
1534///
1535/// # Examples
1536/// ```ignore
1537/// let col = build_multivector_array(&[Some(Value::List(vec![Value::Vector(vec![1.0, 2.0])]))], 2);
1538/// ```
1539pub fn build_multivector_array(values: &[Option<Value>], dimensions: usize) -> ArrayRef {
1540    let dim = dimensions as i32;
1541    let mut builder = ListBuilder::new(FixedSizeListBuilder::new(Float32Builder::new(), dim));
1542    for v in values {
1543        match v.as_ref().and_then(|v| v.as_array()) {
1544            Some(arr) => {
1545                // Variable token count per row: one fixed-size inner vector per
1546                // token. `extract_vector_f32_values` always yields exactly
1547                // `dimensions` values, satisfying the FixedSizeList stride.
1548                for tok in arr {
1549                    let (vals, valid) = extract_vector_f32_values(Some(tok), false, dimensions);
1550                    for f in vals {
1551                        builder.values().values().append_value(f);
1552                    }
1553                    builder.values().append(valid);
1554                }
1555                builder.append(true);
1556            }
1557            None => builder.append_null(),
1558        }
1559    }
1560    Arc::new(builder.finish())
1561}
1562
1563/// Collect one nullable scalar per row for a `PropertyExtractor` column build.
1564///
1565/// A row whose property is absent yields `None`, except on a *deleted* row,
1566/// where `deleted_placeholder` stands in — deleted rows carry a type-specific
1567/// zero so the column stays non-null for tombstones.
1568fn collect_scalar<T: Copy>(
1569    len: usize,
1570    deleted: &[bool],
1571    extract: impl Fn(usize) -> Option<T>,
1572    deleted_placeholder: T,
1573) -> Vec<Option<T>> {
1574    let mut values = Vec::with_capacity(len);
1575    for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
1576        values.push(extract(i).or(is_deleted.then_some(deleted_placeholder)));
1577    }
1578    values
1579}
1580
1581impl<'a> PropertyExtractor<'a> {
1582    pub fn new(name: &'a str, data_type: &'a DataType) -> Self {
1583        Self { name, data_type }
1584    }
1585
1586    /// Build an Arrow column from a slice of property maps.
1587    /// The `deleted` slice indicates which entries are deleted (use default values).
1588    pub fn build_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
1589    where
1590        F: Fn(usize) -> Option<&'a Value>,
1591    {
1592        match self.data_type {
1593            DataType::String => self.build_string_column(len, deleted, get_props),
1594            DataType::Int32 => self.build_int32_column(len, deleted, get_props),
1595            DataType::Int64 => self.build_int64_column(len, deleted, get_props),
1596            DataType::Float32 => self.build_float32_column(len, deleted, get_props),
1597            DataType::Float64 => self.build_float64_column(len, deleted, get_props),
1598            DataType::Bool => self.build_bool_column(len, deleted, get_props),
1599            DataType::Vector { dimensions } => {
1600                self.build_vector_column(len, deleted, get_props, *dimensions)
1601            }
1602            DataType::SparseVector { .. } => {
1603                self.build_sparse_vector_column(len, deleted, get_props)
1604            }
1605            DataType::BinaryVector { dimensions } => {
1606                self.build_binary_vector_column(len, deleted, get_props, *dimensions)
1607            }
1608            DataType::CypherValue => self.build_json_column(len, deleted, get_props),
1609            DataType::Bytes => self.build_bytes_column(len, deleted, get_props),
1610            DataType::List(inner) => self.build_list_column(len, deleted, get_props, inner),
1611            DataType::Map(key, value) => self.build_map_column(len, deleted, get_props, key, value),
1612            DataType::Crdt(_) => self.build_crdt_column(len, deleted, get_props),
1613            DataType::DateTime => self.build_datetime_struct_column(len, deleted, get_props),
1614            DataType::Timestamp => self.build_timestamp_column(len, deleted, get_props),
1615            DataType::Date => self.build_date32_column(len, deleted, get_props),
1616            DataType::Time => self.build_time_struct_column(len, deleted, get_props),
1617            DataType::Point(pt) => self.build_point_struct_column(len, deleted, get_props, *pt),
1618            DataType::Duration => self.build_duration_column(len, deleted, get_props),
1619            DataType::Btic => self.build_btic_column(len, deleted, get_props),
1620            _ => Err(anyhow!(
1621                "Unsupported data type for arrow conversion: {:?}",
1622                self.data_type
1623            )),
1624        }
1625    }
1626
1627    fn build_string_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
1628    where
1629        F: Fn(usize) -> Option<&'a Value>,
1630    {
1631        let mut builder = arrow_array::builder::StringBuilder::with_capacity(len, len * 32);
1632        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
1633            let prop = get_props(i);
1634            if let Some(s) = prop.and_then(|v| v.as_str()) {
1635                builder.append_value(s);
1636            } else if let Some(Value::Temporal(tv)) = prop {
1637                builder.append_value(tv.to_string());
1638            } else if is_deleted {
1639                builder.append_value("");
1640            } else {
1641                builder.append_null();
1642            }
1643        }
1644        Ok(Arc::new(builder.finish()))
1645    }
1646
1647    fn build_int32_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
1648    where
1649        F: Fn(usize) -> Option<&'a Value>,
1650    {
1651        let values = collect_scalar(
1652            len,
1653            deleted,
1654            // i64 -> i32 via try_from: an out-of-range value becomes NULL rather
1655            // than silently wrapping to a different number. (review H13)
1656            |i| {
1657                get_props(i)
1658                    .and_then(|v| v.as_i64())
1659                    .and_then(|v| i32::try_from(v).ok())
1660            },
1661            0,
1662        );
1663        Ok(Arc::new(Int32Array::from(values)))
1664    }
1665
1666    fn build_int64_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
1667    where
1668        F: Fn(usize) -> Option<&'a Value>,
1669    {
1670        let values = collect_scalar(len, deleted, |i| get_props(i).and_then(|v| v.as_i64()), 0);
1671        Ok(Arc::new(Int64Array::from(values)))
1672    }
1673
1674    fn build_timestamp_column<F>(
1675        &self,
1676        len: usize,
1677        deleted: &[bool],
1678        get_props: F,
1679    ) -> Result<ArrayRef>
1680    where
1681        F: Fn(usize) -> Option<&'a Value>,
1682    {
1683        let mut values = Vec::with_capacity(len);
1684        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
1685            let val = get_props(i);
1686            // A missing property on a live row must be NULL, not epoch 0 — every
1687            // sibling builder (int/string/datetime-struct) appends null here.
1688            // Storing Some(0) rendered absent timestamps as 1970-01-01 and broke
1689            // `IS NULL`. Only deleted rows get the 0 placeholder (below).
1690            let ts = if let Some(Value::Temporal(tv)) = val {
1691                match tv {
1692                    uni_common::TemporalValue::DateTime {
1693                        nanos_since_epoch, ..
1694                    }
1695                    | uni_common::TemporalValue::LocalDateTime {
1696                        nanos_since_epoch, ..
1697                    } => Some(*nanos_since_epoch),
1698                    _ => None,
1699                }
1700            } else if let Some(v) = val.and_then(|v| v.as_i64()) {
1701                Some(v)
1702            } else if let Some(s) = val.and_then(|v| v.as_str()) {
1703                parse_datetime_to_nanos(s)
1704            } else {
1705                None
1706            };
1707
1708            if is_deleted {
1709                values.push(Some(0));
1710            } else {
1711                values.push(ts);
1712            }
1713        }
1714        let arr = TimestampNanosecondArray::from(values).with_timezone("UTC");
1715        Ok(Arc::new(arr))
1716    }
1717
1718    fn build_datetime_struct_column<F>(
1719        &self,
1720        len: usize,
1721        deleted: &[bool],
1722        get_props: F,
1723    ) -> Result<ArrayRef>
1724    where
1725        F: Fn(usize) -> Option<&'a Value>,
1726    {
1727        let values = self.collect_values_or_null(len, deleted, &get_props);
1728        Ok(values_to_datetime_struct_array(&values))
1729    }
1730
1731    fn build_time_struct_column<F>(
1732        &self,
1733        len: usize,
1734        deleted: &[bool],
1735        get_props: F,
1736    ) -> Result<ArrayRef>
1737    where
1738        F: Fn(usize) -> Option<&'a Value>,
1739    {
1740        let values = self.collect_values_or_null(len, deleted, &get_props);
1741        Ok(values_to_time_struct_array(&values))
1742    }
1743
1744    fn build_point_struct_column<F>(
1745        &self,
1746        len: usize,
1747        deleted: &[bool],
1748        get_props: F,
1749        point_type: PointType,
1750    ) -> Result<ArrayRef>
1751    where
1752        F: Fn(usize) -> Option<&'a Value>,
1753    {
1754        let values = self.collect_values_or_null(len, deleted, &get_props);
1755        Ok(values_to_point_struct_array(&values, point_type))
1756    }
1757
1758    /// Collect property values into a Vec, substituting `Value::Null` for deleted or missing entries.
1759    fn collect_values_or_null<F>(&self, len: usize, deleted: &[bool], get_props: &F) -> Vec<Value>
1760    where
1761        F: Fn(usize) -> Option<&'a Value>,
1762    {
1763        deleted
1764            .iter()
1765            .enumerate()
1766            .take(len)
1767            .map(|(i, &is_deleted)| {
1768                if is_deleted {
1769                    Value::Null
1770                } else {
1771                    get_props(i).cloned().unwrap_or(Value::Null)
1772                }
1773            })
1774            .collect()
1775    }
1776
1777    fn build_date32_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
1778    where
1779        F: Fn(usize) -> Option<&'a Value>,
1780    {
1781        let mut builder = Date32Builder::with_capacity(len);
1782        let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
1783
1784        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
1785            let val = get_props(i);
1786            // Missing property on a live row -> NULL (append_null below), not
1787            // 1970-01-01. Only deleted rows get the 0 placeholder.
1788            let days = if let Some(Value::Temporal(uni_common::TemporalValue::Date {
1789                days_since_epoch,
1790            })) = val
1791            {
1792                Some(*days_since_epoch)
1793            } else if let Some(v) = val.and_then(|v| v.as_i64()) {
1794                // i64 day count -> i32: an out-of-range value becomes NULL
1795                // rather than silently wrapping to a different date. (review H13)
1796                i32::try_from(v).ok()
1797            } else if let Some(s) = val.and_then(|v| v.as_str()) {
1798                match chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
1799                    Ok(date) => Some(date.signed_duration_since(epoch).num_days() as i32),
1800                    Err(_) => None,
1801                }
1802            } else {
1803                None
1804            };
1805
1806            if is_deleted {
1807                builder.append_value(0);
1808            } else if let Some(v) = days {
1809                builder.append_value(v);
1810            } else {
1811                builder.append_null();
1812            }
1813        }
1814        Ok(Arc::new(builder.finish()))
1815    }
1816
1817    fn build_duration_column<F>(
1818        &self,
1819        len: usize,
1820        deleted: &[bool],
1821        get_props: F,
1822    ) -> Result<ArrayRef>
1823    where
1824        F: Fn(usize) -> Option<&'a Value>,
1825    {
1826        // Duration stored as LargeBinary via CypherValue codec (Lance doesn't support Interval(MonthDayNano))
1827        let mut builder = LargeBinaryBuilder::with_capacity(len, len * 32);
1828        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
1829            let raw_val = get_props(i);
1830            if let Some(val @ Value::Temporal(uni_common::TemporalValue::Duration { .. })) = raw_val
1831            {
1832                let encoded = uni_common::cypher_value_codec::encode(val);
1833                builder.append_value(&encoded);
1834            } else if is_deleted {
1835                let zero = Value::Temporal(uni_common::TemporalValue::Duration {
1836                    months: 0,
1837                    days: 0,
1838                    nanos: 0,
1839                });
1840                let encoded = uni_common::cypher_value_codec::encode(&zero);
1841                builder.append_value(&encoded);
1842            } else {
1843                builder.append_null();
1844            }
1845        }
1846        Ok(Arc::new(builder.finish()))
1847    }
1848
1849    fn build_btic_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
1850    where
1851        F: Fn(usize) -> Option<&'a Value>,
1852    {
1853        const ENCODED_LEN: i32 = 24;
1854        let mut builder = FixedSizeBinaryBuilder::with_capacity(len, ENCODED_LEN);
1855        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
1856            let raw_val = get_props(i);
1857            let btic = match raw_val {
1858                Some(Value::Temporal(uni_common::TemporalValue::Btic { lo, hi, meta })) => Some(
1859                    uni_btic::Btic::new(*lo, *hi, *meta)
1860                        .map_err(|e| anyhow!("invalid BTIC value: {}", e))?,
1861                ),
1862                Some(Value::String(s)) => Some(
1863                    uni_btic::parse::parse_btic_literal(s)
1864                        .map_err(|e| anyhow!("BTIC parse error for '{}': {}", s, e))?,
1865                ),
1866                _ => None,
1867            };
1868
1869            if let Some(b) = btic {
1870                builder.append_value(uni_btic::encode::encode(&b))?;
1871            } else if is_deleted {
1872                builder.append_value([0u8; ENCODED_LEN as usize])?;
1873            } else {
1874                builder.append_null();
1875            }
1876        }
1877        Ok(Arc::new(builder.finish()))
1878    }
1879
1880    fn build_float32_column<F>(
1881        &self,
1882        len: usize,
1883        deleted: &[bool],
1884        get_props: F,
1885    ) -> Result<ArrayRef>
1886    where
1887        F: Fn(usize) -> Option<&'a Value>,
1888    {
1889        let values = collect_scalar(
1890            len,
1891            deleted,
1892            |i| get_props(i).and_then(|v| v.as_f64()).map(|v| v as f32),
1893            0.0,
1894        );
1895        Ok(Arc::new(Float32Array::from(values)))
1896    }
1897
1898    fn build_float64_column<F>(
1899        &self,
1900        len: usize,
1901        deleted: &[bool],
1902        get_props: F,
1903    ) -> Result<ArrayRef>
1904    where
1905        F: Fn(usize) -> Option<&'a Value>,
1906    {
1907        let values = collect_scalar(len, deleted, |i| get_props(i).and_then(|v| v.as_f64()), 0.0);
1908        Ok(Arc::new(Float64Array::from(values)))
1909    }
1910
1911    fn build_bool_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
1912    where
1913        F: Fn(usize) -> Option<&'a Value>,
1914    {
1915        let values = collect_scalar(
1916            len,
1917            deleted,
1918            |i| get_props(i).and_then(|v| v.as_bool()),
1919            false,
1920        );
1921        Ok(Arc::new(BooleanArray::from(values)))
1922    }
1923
1924    fn build_vector_column<F>(
1925        &self,
1926        len: usize,
1927        deleted: &[bool],
1928        get_props: F,
1929        dimensions: usize,
1930    ) -> Result<ArrayRef>
1931    where
1932        F: Fn(usize) -> Option<&'a Value>,
1933    {
1934        let mut builder = FixedSizeListBuilder::new(Float32Builder::new(), dimensions as i32);
1935
1936        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
1937            let val = get_props(i);
1938            // Fail closed on a wrong-dimension value instead of nulling it: the write
1939            // paths reject these (issue #137), so reaching one here means an older
1940            // WAL/version wrote it or a write path bypassed validation.
1941            let (values, valid) = extract_vector_f32_values_strict(val, is_deleted, dimensions)
1942                .map_err(|e| {
1943                    anyhow!(
1944                        "flush: property '{}' row {}: {}; wrong-dimension vectors are rejected \
1945                         at write time as of issue #137 — this value was likely written by an \
1946                         older version",
1947                        self.name,
1948                        i,
1949                        e
1950                    )
1951                })?;
1952            for v in values {
1953                builder.values().append_value(v);
1954            }
1955            builder.append(valid);
1956        }
1957        Ok(Arc::new(builder.finish()))
1958    }
1959
1960    /// Build a binary-vector column as `FixedSizeList<UInt8, dimensions>`.
1961    ///
1962    /// The `u8`-lane analogue of [`Self::build_vector_column`]: each row yields
1963    /// exactly `dimensions` bytes (via [`extract_binary_vector_values_strict`]),
1964    /// preserving the FixedSizeList stride. Fails closed on a present, wrong-shape
1965    /// value — the write path validates lane counts (`check_vector_dims`), so
1966    /// reaching one here means a bypass or an older-version WAL value.
1967    fn build_binary_vector_column<F>(
1968        &self,
1969        len: usize,
1970        deleted: &[bool],
1971        get_props: F,
1972        dimensions: usize,
1973    ) -> Result<ArrayRef>
1974    where
1975        F: Fn(usize) -> Option<&'a Value>,
1976    {
1977        let mut builder = FixedSizeListBuilder::new(UInt8Builder::new(), dimensions as i32);
1978
1979        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
1980            let val = get_props(i);
1981            let (values, valid) = extract_binary_vector_values_strict(val, is_deleted, dimensions)
1982                .map_err(|e| {
1983                    anyhow!(
1984                        "flush: property '{}' row {}: {}; wrong-shape binary vectors are \
1985                         rejected at write time — this value was likely written by an older \
1986                         version",
1987                        self.name,
1988                        i,
1989                        e
1990                    )
1991                })?;
1992            for v in values {
1993                builder.values().append_value(v);
1994            }
1995            builder.append(valid);
1996        }
1997        Ok(Arc::new(builder.finish()))
1998    }
1999
2000    /// Build a sparse-vector column as `Struct { indices: List<UInt32>,
2001    /// values: List<Float32> }` (see `schema::sparse_vector_struct_fields`).
2002    /// Deleted, missing, or non-sparse rows become null struct rows carrying
2003    /// empty child lists — mirroring how `build_vector_column` nulls deleted
2004    /// rows. An empty sparse vector is stored as two empty (non-null) lists.
2005    fn build_sparse_vector_column<F>(
2006        &self,
2007        len: usize,
2008        deleted: &[bool],
2009        get_props: F,
2010    ) -> Result<ArrayRef>
2011    where
2012        F: Fn(usize) -> Option<&'a Value>,
2013    {
2014        let mut indices_builder = ListBuilder::new(UInt32Builder::new());
2015        let mut values_builder = ListBuilder::new(Float32Builder::new());
2016        let mut null_buffer = BooleanBufferBuilder::new(len);
2017
2018        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
2019            let pair = if is_deleted {
2020                None
2021            } else {
2022                get_props(i).and_then(sparse_pair_from_value)
2023            };
2024            match pair {
2025                Some((indices, values)) => {
2026                    for ix in indices {
2027                        indices_builder.values().append_value(ix);
2028                    }
2029                    indices_builder.append(true);
2030                    for w in values {
2031                        values_builder.values().append_value(w);
2032                    }
2033                    values_builder.append(true);
2034                    null_buffer.append(true);
2035                }
2036                None => {
2037                    indices_builder.append(true);
2038                    values_builder.append(true);
2039                    null_buffer.append(false);
2040                }
2041            }
2042        }
2043
2044        let struct_arr = StructArray::new(
2045            schema::sparse_vector_struct_fields(),
2046            vec![
2047                Arc::new(indices_builder.finish()) as ArrayRef,
2048                Arc::new(values_builder.finish()) as ArrayRef,
2049            ],
2050            Some(null_buffer.finish().into()),
2051        );
2052        Ok(Arc::new(struct_arr))
2053    }
2054
2055    fn build_json_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
2056    where
2057        F: Fn(usize) -> Option<&'a Value>,
2058    {
2059        let null_val = Value::Null;
2060        let mut builder = arrow_array::builder::LargeBinaryBuilder::with_capacity(len, len * 64);
2061        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
2062            let val = get_props(i);
2063            let uni_val = if val.is_none() && is_deleted {
2064                &null_val
2065            } else {
2066                val.unwrap_or(&null_val)
2067            };
2068            // Encode to CypherValue (MessagePack-tagged)
2069            let cv_bytes = uni_common::cypher_value_codec::encode(uni_val);
2070            builder.append_value(&cv_bytes);
2071        }
2072        Ok(Arc::new(builder.finish()))
2073    }
2074
2075    fn build_bytes_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
2076    where
2077        F: Fn(usize) -> Option<&'a Value>,
2078    {
2079        let mut builder = LargeBinaryBuilder::with_capacity(len, len * 64);
2080        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
2081            let val = get_props(i);
2082            if let Some(Value::Bytes(b)) = val {
2083                builder.append_value(b);
2084            } else if is_deleted {
2085                builder.append_value(&[][..]);
2086            } else {
2087                builder.append_null();
2088            }
2089        }
2090        Ok(Arc::new(builder.finish()))
2091    }
2092
2093    fn build_list_column<F>(
2094        &self,
2095        len: usize,
2096        deleted: &[bool],
2097        get_props: F,
2098        inner: &DataType,
2099    ) -> Result<ArrayRef>
2100    where
2101        F: Fn(usize) -> Option<&'a Value>,
2102    {
2103        match inner {
2104            DataType::String => {
2105                self.build_typed_list(len, deleted, &get_props, StringBuilder::new(), |v, b| {
2106                    if let Some(s) = v.as_str() {
2107                        b.append_value(s);
2108                    } else {
2109                        b.append_null();
2110                    }
2111                })
2112            }
2113            DataType::Int64 => {
2114                self.build_typed_list(len, deleted, &get_props, Int64Builder::new(), |v, b| {
2115                    if let Some(n) = v.as_i64() {
2116                        b.append_value(n);
2117                    } else {
2118                        b.append_null();
2119                    }
2120                })
2121            }
2122            DataType::Float64 => {
2123                self.build_typed_list(len, deleted, &get_props, Float64Builder::new(), |v, b| {
2124                    if let Some(f) = v.as_f64() {
2125                        b.append_value(f);
2126                    } else {
2127                        b.append_null();
2128                    }
2129                })
2130            }
2131            DataType::Bytes => {
2132                // Raw `Bytes` elements: store each buffer verbatim in a `LargeBinary`
2133                // child and mark the child field `uni_raw_bytes` so the read path
2134                // decodes it verbatim instead of through the tagged codec.
2135                let item_field = Arc::new(
2136                    Field::new("item", ArrowDataType::LargeBinary, true)
2137                        .with_metadata(schema::raw_bytes_field_metadata()),
2138                );
2139                let mut builder =
2140                    ListBuilder::new(LargeBinaryBuilder::new()).with_field(item_field);
2141                for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
2142                    let val_array = get_props(i).and_then(|v| v.as_array());
2143                    if val_array.is_none() && is_deleted {
2144                        builder.append_null();
2145                    } else if let Some(arr) = val_array {
2146                        for v in arr {
2147                            if let Value::Bytes(b) = v {
2148                                builder.values().append_value(b);
2149                            } else {
2150                                builder.values().append_null();
2151                            }
2152                        }
2153                        builder.append(true);
2154                    } else {
2155                        builder.append_null();
2156                    }
2157                }
2158                Ok(Arc::new(builder.finish()))
2159            }
2160            DataType::Vector { dimensions } => {
2161                // Multi-vector / late-interaction (ColBERT) property: a per-row
2162                // variable-count set of fixed-`dimensions` token vectors, stored as
2163                // `List<FixedSizeList<Float32, dimensions>>`. Each inner token is
2164                // validated through the same `extract_vector_f32_values` path used for
2165                // single dense vectors, so dimension/type failure modes match.
2166                //
2167                // NOTE: only the declared-schema write path (this builder) supports
2168                // multi-vector today. The schemaless, Arrow-type-driven `values_to_array`
2169                // path does not yet handle `List<FixedSizeList<Float32>>`; schemaless
2170                // multi-vector writes are a deferred follow-up (issue #96, Phase 1.5).
2171                let dim = *dimensions as i32;
2172                let mut builder =
2173                    ListBuilder::new(FixedSizeListBuilder::new(Float32Builder::new(), dim));
2174                for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
2175                    let raw = get_props(i);
2176                    // Fail closed on a wrong-dimension / wrong-shape token set instead
2177                    // of nulling it: the write paths reject these (issue #137), so
2178                    // reaching one here means an older WAL/version wrote it or a write
2179                    // path bypassed validation. `check_vector_dims` reports the
2180                    // offending token index; nulls stay legal null rows.
2181                    if !is_deleted && let Some(v) = raw {
2182                        self.data_type.check_vector_dims(v).map_err(|e| {
2183                            anyhow!(
2184                                "flush: property '{}' row {}: {}; wrong-dimension vectors \
2185                                 are rejected at write time as of issue #137 — this value \
2186                                 was likely written by an older version",
2187                                self.name,
2188                                i,
2189                                e
2190                            )
2191                        })?;
2192                    }
2193                    let val_array = raw.and_then(|v| v.as_array());
2194                    if val_array.is_none() && is_deleted {
2195                        builder.append_null();
2196                    } else if let Some(arr) = val_array {
2197                        // Variable token count per row: append one fixed-size inner
2198                        // vector per token. `extract_vector_f32_values` always yields
2199                        // exactly `dimensions` values, satisfying the FixedSizeList stride
2200                        // (tokens are pre-validated above, so nothing is silently nulled).
2201                        for tok in arr {
2202                            let (vals, valid) =
2203                                extract_vector_f32_values(Some(tok), false, *dimensions);
2204                            for v in vals {
2205                                builder.values().values().append_value(v);
2206                            }
2207                            builder.values().append(valid);
2208                        }
2209                        builder.append(true);
2210                    } else {
2211                        builder.append_null();
2212                    }
2213                }
2214                Ok(Arc::new(builder.finish()))
2215            }
2216            _ => Err(anyhow!("Unsupported inner type for List: {:?}", inner)),
2217        }
2218    }
2219
2220    /// Generic helper to build a list column with any inner builder type.
2221    fn build_typed_list<F, B, A>(
2222        &self,
2223        len: usize,
2224        deleted: &[bool],
2225        get_props: &F,
2226        inner_builder: B,
2227        mut append_value: A,
2228    ) -> Result<ArrayRef>
2229    where
2230        F: Fn(usize) -> Option<&'a Value>,
2231        B: arrow_array::builder::ArrayBuilder,
2232        A: FnMut(&Value, &mut B),
2233    {
2234        let mut builder = ListBuilder::new(inner_builder);
2235        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
2236            let val_array = get_props(i).and_then(|v| v.as_array());
2237            if val_array.is_none() && is_deleted {
2238                builder.append_null();
2239            } else if let Some(arr) = val_array {
2240                for v in arr {
2241                    append_value(v, builder.values());
2242                }
2243                builder.append(true);
2244            } else {
2245                builder.append_null();
2246            }
2247        }
2248        Ok(Arc::new(builder.finish()))
2249    }
2250
2251    fn build_map_column<F>(
2252        &self,
2253        len: usize,
2254        deleted: &[bool],
2255        get_props: F,
2256        key: &DataType,
2257        value: &DataType,
2258    ) -> Result<ArrayRef>
2259    where
2260        F: Fn(usize) -> Option<&'a Value>,
2261    {
2262        if !matches!(key, DataType::String) {
2263            return Err(anyhow!("Map keys must be String (JSON limitation)"));
2264        }
2265
2266        match value {
2267            DataType::String => self.build_typed_map(
2268                len,
2269                deleted,
2270                &get_props,
2271                StringBuilder::new(),
2272                arrow_schema::DataType::Utf8,
2273                None,
2274                |v, b: &mut StringBuilder| {
2275                    if let Some(s) = v.as_str() {
2276                        b.append_value(s);
2277                    } else {
2278                        b.append_null();
2279                    }
2280                },
2281            ),
2282            DataType::Int64 => self.build_typed_map(
2283                len,
2284                deleted,
2285                &get_props,
2286                Int64Builder::new(),
2287                arrow_schema::DataType::Int64,
2288                None,
2289                |v, b: &mut Int64Builder| {
2290                    if let Some(n) = v.as_i64() {
2291                        b.append_value(n);
2292                    } else {
2293                        b.append_null();
2294                    }
2295                },
2296            ),
2297            DataType::Int32 => self.build_typed_map(
2298                len,
2299                deleted,
2300                &get_props,
2301                Int32Builder::new(),
2302                arrow_schema::DataType::Int32,
2303                None,
2304                |v, b: &mut Int32Builder| match v.as_i64().and_then(|n| i32::try_from(n).ok()) {
2305                    Some(n) => b.append_value(n),
2306                    None => b.append_null(),
2307                },
2308            ),
2309            DataType::Float64 => self.build_typed_map(
2310                len,
2311                deleted,
2312                &get_props,
2313                Float64Builder::new(),
2314                arrow_schema::DataType::Float64,
2315                None,
2316                |v, b: &mut Float64Builder| match v.as_f64() {
2317                    Some(f) => b.append_value(f),
2318                    None => b.append_null(),
2319                },
2320            ),
2321            DataType::Float32 => self.build_typed_map(
2322                len,
2323                deleted,
2324                &get_props,
2325                Float32Builder::new(),
2326                arrow_schema::DataType::Float32,
2327                None,
2328                |v, b: &mut Float32Builder| match v.as_f64() {
2329                    Some(f) => b.append_value(f as f32),
2330                    None => b.append_null(),
2331                },
2332            ),
2333            DataType::Bool => self.build_typed_map(
2334                len,
2335                deleted,
2336                &get_props,
2337                BooleanBuilder::new(),
2338                arrow_schema::DataType::Boolean,
2339                None,
2340                |v, b: &mut BooleanBuilder| match v.as_bool() {
2341                    Some(x) => b.append_value(x),
2342                    None => b.append_null(),
2343                },
2344            ),
2345            DataType::Bytes => self.build_typed_map(
2346                len,
2347                deleted,
2348                &get_props,
2349                LargeBinaryBuilder::new(),
2350                arrow_schema::DataType::LargeBinary,
2351                // Mark the value child `uni_raw_bytes` so the read path decodes each
2352                // raw `Bytes` value verbatim rather than through the tagged codec.
2353                Some(schema::raw_bytes_field_metadata()),
2354                |v, b: &mut LargeBinaryBuilder| {
2355                    if let Value::Bytes(bytes) = v {
2356                        b.append_value(bytes);
2357                    } else {
2358                        b.append_null();
2359                    }
2360                },
2361            ),
2362            // Nested / non-scalar value types (Vector, List, Map, temporal, …): no typed
2363            // Arrow builder — CypherValue-encode each value into an UNMARKED LargeBinary
2364            // child (no `uni_raw_bytes`), so the read path (`try_reconstruct_map` →
2365            // `arrow_to_value` LargeBinary arm) decodes it through the tagged codec. This
2366            // mirrors how schemaless/overflow maps are stored and handles arbitrary nesting.
2367            _ => self.build_typed_map(
2368                len,
2369                deleted,
2370                &get_props,
2371                LargeBinaryBuilder::new(),
2372                arrow_schema::DataType::LargeBinary,
2373                None,
2374                |v, b: &mut LargeBinaryBuilder| {
2375                    if v.is_null() {
2376                        b.append_null();
2377                    } else {
2378                        b.append_value(uni_common::cypher_value_codec::encode(v));
2379                    }
2380                },
2381            ),
2382        }
2383    }
2384
2385    /// Generic helper to build a map column with any value builder type.
2386    #[expect(
2387        clippy::too_many_arguments,
2388        reason = "builder plumbing: value type + optional child metadata are distinct knobs"
2389    )]
2390    fn build_typed_map<F, B, A>(
2391        &self,
2392        len: usize,
2393        deleted: &[bool],
2394        get_props: &F,
2395        value_builder: B,
2396        value_arrow_type: arrow_schema::DataType,
2397        value_metadata: Option<HashMap<String, String>>,
2398        mut append_value: A,
2399    ) -> Result<ArrayRef>
2400    where
2401        F: Fn(usize) -> Option<&'a Value>,
2402        B: arrow_array::builder::ArrayBuilder,
2403        A: FnMut(&Value, &mut B),
2404    {
2405        let key_builder = Box::new(StringBuilder::new());
2406        let value_builder = Box::new(value_builder);
2407        let value_field = match value_metadata {
2408            Some(meta) => Field::new("value", value_arrow_type, true).with_metadata(meta),
2409            None => Field::new("value", value_arrow_type, true),
2410        };
2411        let struct_builder = StructBuilder::new(
2412            vec![
2413                Field::new("key", arrow_schema::DataType::Utf8, false),
2414                value_field,
2415            ],
2416            vec![key_builder, value_builder],
2417        );
2418        let mut builder = ListBuilder::new(struct_builder);
2419
2420        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
2421            self.append_map_entry(&mut builder, get_props(i), is_deleted, &mut append_value);
2422        }
2423        Ok(Arc::new(builder.finish()))
2424    }
2425
2426    /// Append a single map entry to the list builder.
2427    fn append_map_entry<B, A>(
2428        &self,
2429        builder: &mut ListBuilder<StructBuilder>,
2430        val: Option<&'a Value>,
2431        is_deleted: bool,
2432        append_value: &mut A,
2433    ) where
2434        B: arrow_array::builder::ArrayBuilder,
2435        A: FnMut(&Value, &mut B),
2436    {
2437        let val_obj = val.and_then(|v| v.as_object());
2438        if val_obj.is_none() && is_deleted {
2439            builder.append(false);
2440        } else if let Some(obj) = val_obj {
2441            let struct_b = builder.values();
2442            for (k, v) in obj {
2443                struct_b
2444                    .field_builder::<StringBuilder>(0)
2445                    .unwrap()
2446                    .append_value(k);
2447                // Safety: We know the value builder type matches B
2448                let value_b = struct_b.field_builder::<B>(1).unwrap();
2449                append_value(v, value_b);
2450                struct_b.append(true);
2451            }
2452            builder.append(true);
2453        } else {
2454            builder.append(false);
2455        }
2456    }
2457
2458    fn build_crdt_column<F>(&self, len: usize, deleted: &[bool], get_props: F) -> Result<ArrayRef>
2459    where
2460        F: Fn(usize) -> Option<&'a Value>,
2461    {
2462        let mut builder = BinaryBuilder::new();
2463        for (i, &is_deleted) in deleted.iter().enumerate().take(len) {
2464            if is_deleted {
2465                builder.append_null();
2466                continue;
2467            }
2468            if let Some(val) = get_props(i) {
2469                // Try to parse CRDT from the value
2470                // If it's a string, first parse it as JSON, then as CRDT
2471                let crdt_result = if let Some(s) = val.as_str() {
2472                    serde_json::from_str::<Crdt>(s)
2473                } else {
2474                    // Convert uni_common::Value to serde_json::Value at the CRDT boundary
2475                    let json_val: serde_json::Value = val.clone().into();
2476                    serde_json::from_value::<Crdt>(json_val)
2477                };
2478
2479                if let Ok(crdt) = crdt_result {
2480                    if let Ok(bytes) = crdt.to_msgpack() {
2481                        builder.append_value(&bytes);
2482                    } else {
2483                        builder.append_null();
2484                    }
2485                } else {
2486                    builder.append_null();
2487                }
2488            } else {
2489                builder.append_null();
2490            }
2491        }
2492        Ok(Arc::new(builder.finish()))
2493    }
2494}
2495
2496/// Build a column for edge entries (no deleted flag handling needed).
2497pub fn build_edge_column<'a>(
2498    name: &'a str,
2499    data_type: &'a DataType,
2500    len: usize,
2501    get_props: impl Fn(usize) -> Option<&'a Value>,
2502) -> Result<ArrayRef> {
2503    // For edges, use empty deleted array
2504    let deleted = vec![false; len];
2505    let extractor = PropertyExtractor::new(name, data_type);
2506    extractor.build_column(len, &deleted, get_props)
2507}
2508
2509#[cfg(test)]
2510mod tests {
2511    use super::*;
2512    use arrow_array::{
2513        Array, DurationMicrosecondArray,
2514        builder::{BinaryBuilder, Time64MicrosecondBuilder, TimestampNanosecondBuilder},
2515    };
2516    use std::collections::HashMap;
2517    use uni_common::TemporalValue;
2518    use uni_crdt::{Crdt, GCounter};
2519
2520    #[test]
2521    fn test_sparse_vector_columnar_roundtrip_and_no_silent_null() {
2522        use crate::storage::value_codec::{CrdtDecodeMode, decode_column_value, value_from_column};
2523
2524        let dt = DataType::SparseVector { dimensions: 100 };
2525        let v0 = Value::SparseVector {
2526            indices: vec![1, 5, 9],
2527            values: vec![0.5, -1.0, 2.0],
2528        };
2529        // An empty sparse vector must round-trip as empty lists, not null.
2530        let v1 = Value::SparseVector {
2531            indices: vec![],
2532            values: vec![],
2533        };
2534        // row 2 is a tombstone → decodes back to Null.
2535        let props = [Some(v0.clone()), Some(v1.clone()), None];
2536        let deleted = [false, false, true];
2537
2538        let extractor = PropertyExtractor::new("emb", &dt);
2539        let col = extractor
2540            .build_column(3, &deleted, |i| props[i].as_ref())
2541            .unwrap();
2542
2543        // Full-fidelity decode → uni_common::Value::SparseVector.
2544        assert_eq!(
2545            decode_column_value(&col, &dt, 0, CrdtDecodeMode::Strict).unwrap(),
2546            v0
2547        );
2548        assert_eq!(
2549            decode_column_value(&col, &dt, 1, CrdtDecodeMode::Strict).unwrap(),
2550            v1
2551        );
2552        assert_eq!(
2553            decode_column_value(&col, &dt, 2, CrdtDecodeMode::Strict).unwrap(),
2554            Value::Null
2555        );
2556
2557        // Regression for the `_ => Ok(Value::Null)` fallback: the serde_json
2558        // read path must surface the data, not silently null it.
2559        let json0 = value_from_column(&col, &dt, 0, CrdtDecodeMode::Strict).unwrap();
2560        assert!(
2561            json0.is_object(),
2562            "sparse column was silently nulled by value_from_column: {json0:?}"
2563        );
2564        assert_eq!(json0["indices"], serde_json::json!([1u32, 5u32, 9u32]));
2565        assert_eq!(
2566            json0["values"],
2567            serde_json::json!([0.5f32, -1.0f32, 2.0f32])
2568        );
2569    }
2570
2571    #[test]
2572    fn test_arrow_to_value_string() {
2573        let arr = StringArray::from(vec![Some("hello"), None, Some("world")]);
2574        assert_eq!(
2575            arrow_to_value(&arr, 0, None),
2576            Value::String("hello".to_string())
2577        );
2578        assert_eq!(arrow_to_value(&arr, 1, None), Value::Null);
2579        assert_eq!(
2580            arrow_to_value(&arr, 2, None),
2581            Value::String("world".to_string())
2582        );
2583    }
2584
2585    /// `LargeUtf8` is a distinct Arrow type from `Utf8` and needs its own
2586    /// downcast — this decoder had a `StringArray` arm but no
2587    /// `LargeStringArray` one, so every `LargeUtf8` column fell through to the
2588    /// trailing `Value::Null` fallback.
2589    ///
2590    /// Locy hit this on every string literal and every string-returning
2591    /// function in a `YIELD` (all typed `LargeUtf8` by `infer_expr_type`),
2592    /// reporting NULL from `derived` while `QUERY` — which never touches Arrow
2593    /// — reported the real value.
2594    #[test]
2595    fn test_arrow_to_value_large_string() {
2596        let arr = LargeStringArray::from(vec![Some("hello"), None, Some("world")]);
2597        assert_eq!(
2598            arrow_to_value(&arr, 0, None),
2599            Value::String("hello".to_string())
2600        );
2601        assert_eq!(arrow_to_value(&arr, 1, None), Value::Null);
2602        assert_eq!(
2603            arrow_to_value(&arr, 2, None),
2604            Value::String("world".to_string())
2605        );
2606    }
2607
2608    #[test]
2609    fn test_arrow_to_value_int64() {
2610        let arr = Int64Array::from(vec![Some(42), None, Some(-10)]);
2611        assert_eq!(arrow_to_value(&arr, 0, None), Value::Int(42));
2612        assert_eq!(arrow_to_value(&arr, 1, None), Value::Null);
2613        assert_eq!(arrow_to_value(&arr, 2, None), Value::Int(-10));
2614    }
2615
2616    #[test]
2617    #[allow(clippy::approx_constant)]
2618    fn test_arrow_to_value_float64() {
2619        let arr = Float64Array::from(vec![Some(3.14), None]);
2620        assert_eq!(arrow_to_value(&arr, 0, None), Value::Float(3.14));
2621        assert_eq!(arrow_to_value(&arr, 1, None), Value::Null);
2622    }
2623
2624    #[test]
2625    fn test_arrow_to_value_bool() {
2626        let arr = BooleanArray::from(vec![Some(true), Some(false), None]);
2627        assert_eq!(arrow_to_value(&arr, 0, None), Value::Bool(true));
2628        assert_eq!(arrow_to_value(&arr, 1, None), Value::Bool(false));
2629        assert_eq!(arrow_to_value(&arr, 2, None), Value::Null);
2630    }
2631
2632    #[test]
2633    fn test_values_to_array_int64() {
2634        let values = vec![Value::Int(1), Value::Int(2), Value::Null, Value::Int(4)];
2635        let arr = values_to_array(&values, &ArrowDataType::Int64).unwrap();
2636        assert_eq!(arr.len(), 4);
2637
2638        let int_arr = arr.as_any().downcast_ref::<Int64Array>().unwrap();
2639        assert_eq!(int_arr.value(0), 1);
2640        assert_eq!(int_arr.value(1), 2);
2641        assert!(int_arr.is_null(2));
2642        assert_eq!(int_arr.value(3), 4);
2643    }
2644
2645    #[test]
2646    fn test_values_to_array_string() {
2647        let values = vec![
2648            Value::String("a".to_string()),
2649            Value::String("b".to_string()),
2650            Value::Null,
2651        ];
2652        let arr = values_to_array(&values, &ArrowDataType::Utf8).unwrap();
2653        assert_eq!(arr.len(), 3);
2654
2655        let str_arr = arr.as_any().downcast_ref::<StringArray>().unwrap();
2656        assert_eq!(str_arr.value(0), "a");
2657        assert_eq!(str_arr.value(1), "b");
2658        assert!(str_arr.is_null(2));
2659    }
2660
2661    #[test]
2662    fn test_property_extractor_string() {
2663        let props: Vec<HashMap<String, Value>> = vec![
2664            [("name".to_string(), Value::String("Alice".to_string()))]
2665                .into_iter()
2666                .collect(),
2667            [("name".to_string(), Value::String("Bob".to_string()))]
2668                .into_iter()
2669                .collect(),
2670            HashMap::new(),
2671        ];
2672        let deleted = vec![false, false, true];
2673
2674        let extractor = PropertyExtractor::new("name", &DataType::String);
2675        let arr = extractor
2676            .build_column(3, &deleted, |i| props[i].get("name"))
2677            .unwrap();
2678
2679        let str_arr = arr.as_any().downcast_ref::<StringArray>().unwrap();
2680        assert_eq!(str_arr.value(0), "Alice");
2681        assert_eq!(str_arr.value(1), "Bob");
2682        assert_eq!(str_arr.value(2), ""); // Deleted entries get default
2683    }
2684
2685    #[test]
2686    fn test_property_extractor_int64() {
2687        let props: Vec<HashMap<String, Value>> = vec![
2688            [("age".to_string(), Value::Int(25))].into_iter().collect(),
2689            [("age".to_string(), Value::Int(30))].into_iter().collect(),
2690            HashMap::new(),
2691        ];
2692        let deleted = vec![false, false, true];
2693
2694        let extractor = PropertyExtractor::new("age", &DataType::Int64);
2695        let arr = extractor
2696            .build_column(3, &deleted, |i| props[i].get("age"))
2697            .unwrap();
2698
2699        let int_arr = arr.as_any().downcast_ref::<Int64Array>().unwrap();
2700        assert_eq!(int_arr.value(0), 25);
2701        assert_eq!(int_arr.value(1), 30);
2702        assert_eq!(int_arr.value(2), 0); // Deleted entries get default
2703    }
2704
2705    #[test]
2706    fn test_property_extractor_bytes_roundtrip() {
2707        let blob = vec![0u8, 1, 2, 255];
2708        let props: Vec<HashMap<String, Value>> = vec![
2709            [("blob".to_string(), Value::Bytes(blob.clone()))]
2710                .into_iter()
2711                .collect(),
2712            [("blob".to_string(), Value::Bytes(Vec::new()))]
2713                .into_iter()
2714                .collect(),
2715            HashMap::new(),
2716        ];
2717        let deleted = vec![false, false, false];
2718
2719        let extractor = PropertyExtractor::new("blob", &DataType::Bytes);
2720        let arr = extractor
2721            .build_column(3, &deleted, |i| props[i].get("blob"))
2722            .unwrap();
2723
2724        // Decode each row through arrow_to_value with Bytes hint.
2725        assert_eq!(
2726            arrow_to_value(arr.as_ref(), 0, Some(&DataType::Bytes)),
2727            Value::Bytes(blob)
2728        );
2729        assert_eq!(
2730            arrow_to_value(arr.as_ref(), 1, Some(&DataType::Bytes)),
2731            Value::Bytes(Vec::new())
2732        );
2733        // Missing property → null in the Arrow array.
2734        assert_eq!(
2735            arrow_to_value(arr.as_ref(), 2, Some(&DataType::Bytes)),
2736            Value::Null
2737        );
2738    }
2739
2740    #[test]
2741    fn test_bytes_vs_cypher_value_disambiguation() {
2742        // A LargeBinary column tagged as DataType::Bytes must NOT be decoded
2743        // through the CypherValue MessagePack codec, even though both share
2744        // the same Arrow physical type.
2745        let raw = vec![0xDEu8, 0xAD, 0xBE, 0xEF];
2746        let props: Vec<HashMap<String, Value>> = vec![
2747            [("blob".to_string(), Value::Bytes(raw.clone()))]
2748                .into_iter()
2749                .collect(),
2750        ];
2751        let extractor = PropertyExtractor::new("blob", &DataType::Bytes);
2752        let arr = extractor
2753            .build_column(1, &[false], |i| props[i].get("blob"))
2754            .unwrap();
2755        // With schema hint: raw bytes returned.
2756        assert_eq!(
2757            arrow_to_value(arr.as_ref(), 0, Some(&DataType::Bytes)),
2758            Value::Bytes(raw)
2759        );
2760    }
2761
2762    #[test]
2763    fn test_data_type_bytes_to_arrow() {
2764        assert_eq!(DataType::Bytes.to_arrow(), ArrowDataType::LargeBinary);
2765    }
2766
2767    #[test]
2768    fn test_arrow_to_value_time64() {
2769        // Test Time64MicrosecondArray legacy fallback (micros→nanos conversion)
2770        let mut builder = Time64MicrosecondBuilder::new();
2771        // 10:30:45 = 10*3600 + 30*60 + 45 = 37845 seconds = 37845000000 microseconds
2772        builder.append_value(37_845_000_000);
2773        // 00:00:00 = 0 microseconds
2774        builder.append_value(0);
2775        // 23:59:59.123456 = 86399.123456 seconds
2776        builder.append_value(86_399_123_456);
2777        builder.append_null();
2778
2779        let arr = builder.finish();
2780        // Arrow→Value returns Value::Temporal(LocalTime) with nanos (micros * 1000)
2781        assert_eq!(arrow_to_value(&arr, 0, None).to_string(), "10:30:45");
2782        assert_eq!(arrow_to_value(&arr, 1, None).to_string(), "00:00");
2783        assert_eq!(arrow_to_value(&arr, 2, None).to_string(), "23:59:59.123456");
2784        assert_eq!(arrow_to_value(&arr, 3, None), Value::Null);
2785    }
2786
2787    #[test]
2788    fn test_arrow_to_value_duration() {
2789        // Test DurationMicrosecondArray conversion
2790        // Arrow→Value now returns Value::Temporal(Duration)
2791        let arr = DurationMicrosecondArray::from(vec![
2792            Some(1_000_000),      // 1 second in microseconds
2793            Some(3_600_000_000),  // 1 hour
2794            Some(86_400_000_000), // 1 day
2795            None,
2796        ]);
2797
2798        assert_eq!(arrow_to_value(&arr, 0, None).to_string(), "PT1S");
2799        assert_eq!(arrow_to_value(&arr, 1, None).to_string(), "PT1H");
2800        assert_eq!(arrow_to_value(&arr, 2, None).to_string(), "PT24H");
2801        assert_eq!(arrow_to_value(&arr, 3, None), Value::Null);
2802    }
2803
2804    #[test]
2805    fn test_arrow_to_value_binary_crdt() {
2806        // Test BinaryArray (CRDT) conversion - round-trip test
2807        let mut builder = BinaryBuilder::new();
2808
2809        // Create a GCounter CRDT and serialize it
2810        let mut counter = GCounter::new();
2811        counter.increment("actor1", 5);
2812        let crdt = Crdt::GCounter(counter);
2813        let bytes = crdt.to_msgpack().unwrap();
2814        builder.append_value(&bytes);
2815
2816        // Add a null value
2817        builder.append_null();
2818
2819        let arr = builder.finish();
2820
2821        // The first value should deserialize back to a map
2822        let result = arrow_to_value(&arr, 0, None);
2823        assert!(result.as_object().is_some());
2824        let obj = result.as_object().unwrap();
2825        // GCounter serializes with tag "t": "gc"
2826        assert_eq!(obj.get("t"), Some(&Value::String("gc".to_string())));
2827
2828        // Null value should return null
2829        assert_eq!(arrow_to_value(&arr, 1, None), Value::Null);
2830    }
2831
2832    #[test]
2833    fn test_datetime_struct_encode_decode_roundtrip() {
2834        // Test DateTime struct encoding with offset and timezone preservation
2835        let values = vec![
2836            Value::Temporal(TemporalValue::DateTime {
2837                nanos_since_epoch: 441763200000000000, // 1984-01-01T00:00:00Z
2838                offset_seconds: 3600,                  // +01:00
2839                timezone_name: Some("Europe/Paris".to_string()),
2840            }),
2841            Value::Temporal(TemporalValue::DateTime {
2842                nanos_since_epoch: 1704067200000000000, // 2024-01-01T00:00:00Z
2843                offset_seconds: -18000,                 // -05:00
2844                timezone_name: None,
2845            }),
2846            Value::Temporal(TemporalValue::DateTime {
2847                nanos_since_epoch: 0, // Unix epoch
2848                offset_seconds: 0,
2849                timezone_name: Some("UTC".to_string()),
2850            }),
2851        ];
2852
2853        // Encode to Arrow struct
2854        let arr_ref = values_to_datetime_struct_array(&values);
2855        let arr = arr_ref.as_any().downcast_ref::<StructArray>().unwrap();
2856        assert_eq!(arr.len(), 3);
2857
2858        // Decode back to Value
2859        let decoded_0 = arrow_to_value(arr_ref.as_ref(), 0, Some(&DataType::DateTime));
2860        let decoded_1 = arrow_to_value(arr_ref.as_ref(), 1, Some(&DataType::DateTime));
2861        let decoded_2 = arrow_to_value(arr_ref.as_ref(), 2, Some(&DataType::DateTime));
2862
2863        // Verify round-trip preserves all fields
2864        assert_eq!(decoded_0, values[0]);
2865        assert_eq!(decoded_1, values[1]);
2866        assert_eq!(decoded_2, values[2]);
2867
2868        // Verify struct field extraction
2869        if let Value::Temporal(TemporalValue::DateTime {
2870            nanos_since_epoch,
2871            offset_seconds,
2872            timezone_name,
2873        }) = decoded_0
2874        {
2875            assert_eq!(nanos_since_epoch, 441763200000000000);
2876            assert_eq!(offset_seconds, 3600);
2877            assert_eq!(timezone_name, Some("Europe/Paris".to_string()));
2878        } else {
2879            panic!("Expected DateTime value");
2880        }
2881    }
2882
2883    #[test]
2884    fn test_datetime_struct_null_handling() {
2885        // Test DateTime struct with null values
2886        let values = vec![
2887            Value::Temporal(TemporalValue::DateTime {
2888                nanos_since_epoch: 441763200000000000,
2889                offset_seconds: 3600,
2890                timezone_name: Some("Europe/Paris".to_string()),
2891            }),
2892            Value::Null,
2893            Value::Temporal(TemporalValue::DateTime {
2894                nanos_since_epoch: 0,
2895                offset_seconds: 0,
2896                timezone_name: None,
2897            }),
2898        ];
2899
2900        let arr_ref = values_to_datetime_struct_array(&values);
2901        let arr = arr_ref.as_any().downcast_ref::<StructArray>().unwrap();
2902        assert_eq!(arr.len(), 3);
2903
2904        // Check first value is valid
2905        let decoded_0 = arrow_to_value(arr_ref.as_ref(), 0, Some(&DataType::DateTime));
2906        assert_eq!(decoded_0, values[0]);
2907
2908        // Check second value is null
2909        assert!(arr.is_null(1));
2910        let decoded_1 = arrow_to_value(arr_ref.as_ref(), 1, Some(&DataType::DateTime));
2911        assert_eq!(decoded_1, Value::Null);
2912
2913        // Check third value is valid
2914        let decoded_2 = arrow_to_value(arr_ref.as_ref(), 2, Some(&DataType::DateTime));
2915        assert_eq!(decoded_2, values[2]);
2916    }
2917
2918    #[test]
2919    fn test_datetime_struct_boundary_values() {
2920        // Test boundary values: offset=0, large positive/negative offsets
2921        let values = vec![
2922            Value::Temporal(TemporalValue::DateTime {
2923                nanos_since_epoch: 441763200000000000,
2924                offset_seconds: 0, // UTC
2925                timezone_name: None,
2926            }),
2927            Value::Temporal(TemporalValue::DateTime {
2928                nanos_since_epoch: 441763200000000000,
2929                offset_seconds: 43200, // +12:00 (max typical offset)
2930                timezone_name: None,
2931            }),
2932            Value::Temporal(TemporalValue::DateTime {
2933                nanos_since_epoch: 441763200000000000,
2934                offset_seconds: -43200, // -12:00 (min typical offset)
2935                timezone_name: None,
2936            }),
2937        ];
2938
2939        let arr_ref = values_to_datetime_struct_array(&values);
2940        let arr = arr_ref.as_any().downcast_ref::<StructArray>().unwrap();
2941        assert_eq!(arr.len(), 3);
2942
2943        // Verify round-trip for all boundary values
2944        for (i, expected) in values.iter().enumerate() {
2945            let decoded = arrow_to_value(arr_ref.as_ref(), i, Some(&DataType::DateTime));
2946            assert_eq!(&decoded, expected);
2947        }
2948    }
2949
2950    #[test]
2951    fn test_datetime_old_schema_migration() {
2952        // Test backward compatibility: TimestampNanosecondArray → DateTime with offset=0
2953        let mut builder = TimestampNanosecondBuilder::new().with_timezone("UTC");
2954        builder.append_value(441763200000000000); // 1984-01-01T00:00:00Z
2955        builder.append_value(1704067200000000000); // 2024-01-01T00:00:00Z
2956        builder.append_null();
2957
2958        let arr = builder.finish();
2959
2960        // Decode with DataType::DateTime hint should migrate old schema
2961        let decoded_0 = arrow_to_value(&arr, 0, Some(&DataType::DateTime));
2962        let _decoded_1 = arrow_to_value(&arr, 1, Some(&DataType::DateTime));
2963        let decoded_2 = arrow_to_value(&arr, 2, Some(&DataType::DateTime));
2964
2965        // Old schema should default to offset=0, preserve timezone
2966        if let Value::Temporal(TemporalValue::DateTime {
2967            nanos_since_epoch,
2968            offset_seconds,
2969            timezone_name,
2970        }) = decoded_0
2971        {
2972            assert_eq!(nanos_since_epoch, 441763200000000000);
2973            assert_eq!(offset_seconds, 0);
2974            assert_eq!(timezone_name, Some("UTC".to_string()));
2975        } else {
2976            panic!("Expected DateTime value");
2977        }
2978
2979        // Verify null handling
2980        assert_eq!(decoded_2, Value::Null);
2981    }
2982
2983    #[test]
2984    fn test_time_struct_encode_decode_roundtrip() {
2985        // Test Time struct encoding with offset preservation
2986        let values = vec![
2987            Value::Temporal(TemporalValue::Time {
2988                nanos_since_midnight: 37845000000000, // 10:30:45
2989                offset_seconds: 3600,                 // +01:00
2990            }),
2991            Value::Temporal(TemporalValue::Time {
2992                nanos_since_midnight: 0, // 00:00:00
2993                offset_seconds: 0,
2994            }),
2995            Value::Temporal(TemporalValue::Time {
2996                nanos_since_midnight: 86399999999999, // 23:59:59.999999999
2997                offset_seconds: -18000,               // -05:00
2998            }),
2999        ];
3000
3001        // Encode to Arrow struct
3002        let arr_ref = values_to_time_struct_array(&values);
3003        let arr = arr_ref.as_any().downcast_ref::<StructArray>().unwrap();
3004        assert_eq!(arr.len(), 3);
3005
3006        // Decode back to Value
3007        let decoded_0 = arrow_to_value(arr_ref.as_ref(), 0, Some(&DataType::Time));
3008        let decoded_1 = arrow_to_value(arr_ref.as_ref(), 1, Some(&DataType::Time));
3009        let decoded_2 = arrow_to_value(arr_ref.as_ref(), 2, Some(&DataType::Time));
3010
3011        // Verify round-trip preserves all fields
3012        assert_eq!(decoded_0, values[0]);
3013        assert_eq!(decoded_1, values[1]);
3014        assert_eq!(decoded_2, values[2]);
3015
3016        // Verify struct field extraction
3017        if let Value::Temporal(TemporalValue::Time {
3018            nanos_since_midnight,
3019            offset_seconds,
3020        }) = decoded_0
3021        {
3022            assert_eq!(nanos_since_midnight, 37845000000000);
3023            assert_eq!(offset_seconds, 3600);
3024        } else {
3025            panic!("Expected Time value");
3026        }
3027    }
3028
3029    #[test]
3030    fn test_time_struct_null_handling() {
3031        // Test Time struct with null values
3032        let values = vec![
3033            Value::Temporal(TemporalValue::Time {
3034                nanos_since_midnight: 37845000000000,
3035                offset_seconds: 3600,
3036            }),
3037            Value::Null,
3038            Value::Temporal(TemporalValue::Time {
3039                nanos_since_midnight: 0,
3040                offset_seconds: 0,
3041            }),
3042        ];
3043
3044        let arr_ref = values_to_time_struct_array(&values);
3045        let arr = arr_ref.as_any().downcast_ref::<StructArray>().unwrap();
3046        assert_eq!(arr.len(), 3);
3047
3048        // Check first value is valid
3049        let decoded_0 = arrow_to_value(arr_ref.as_ref(), 0, Some(&DataType::Time));
3050        assert_eq!(decoded_0, values[0]);
3051
3052        // Check second value is null
3053        assert!(arr.is_null(1));
3054        let decoded_1 = arrow_to_value(arr_ref.as_ref(), 1, Some(&DataType::Time));
3055        assert_eq!(decoded_1, Value::Null);
3056
3057        // Check third value is valid
3058        let decoded_2 = arrow_to_value(arr_ref.as_ref(), 2, Some(&DataType::Time));
3059        assert_eq!(decoded_2, values[2]);
3060    }
3061
3062    // Tests for extract_vector_f32_values
3063
3064    #[test]
3065    fn test_extract_vector_f32_values_valid_vector() {
3066        let v = vec![1.0, 2.0, 3.0];
3067        let val = Value::Vector(v.clone());
3068        let (result, valid) = extract_vector_f32_values(Some(&val), false, 3);
3069        assert_eq!(result, v);
3070        assert!(valid);
3071    }
3072
3073    #[test]
3074    fn test_extract_vector_f32_values_vector_wrong_dims() {
3075        // Pins the LENIENT read-path helper: wrong dims null out. The declared-schema
3076        // flush path uses `extract_vector_f32_values_strict`, which errors instead
3077        // (issue #137).
3078        let v = vec![1.0, 2.0];
3079        let val = Value::Vector(v);
3080        let (result, valid) = extract_vector_f32_values(Some(&val), false, 3);
3081        assert_eq!(result, vec![0.0, 0.0, 0.0]);
3082        assert!(!valid);
3083    }
3084
3085    #[test]
3086    fn test_extract_vector_f32_values_valid_list() {
3087        let v = vec![Value::Float(1.0), Value::Float(2.0), Value::Float(3.0)];
3088        let val = Value::List(v);
3089        let (result, valid) = extract_vector_f32_values(Some(&val), false, 3);
3090        assert_eq!(result, vec![1.0, 2.0, 3.0]);
3091        assert!(valid);
3092    }
3093
3094    #[test]
3095    fn test_extract_vector_f32_values_list_wrong_dims() {
3096        // Pins the LENIENT read-path helper (see the strict variant for the flush path).
3097        let v = vec![Value::Float(1.0), Value::Float(2.0)];
3098        let val = Value::List(v);
3099        let (result, valid) = extract_vector_f32_values(Some(&val), false, 3);
3100        assert_eq!(result, vec![0.0, 0.0, 0.0]);
3101        assert!(!valid);
3102    }
3103
3104    #[test]
3105    fn test_extract_vector_f32_values_list_int_coercion() {
3106        let v = vec![Value::Int(1), Value::Int(2), Value::Int(3)];
3107        let val = Value::List(v);
3108        let (result, valid) = extract_vector_f32_values(Some(&val), false, 3);
3109        assert_eq!(result, vec![1.0, 2.0, 3.0]);
3110        assert!(valid);
3111    }
3112
3113    #[test]
3114    fn test_extract_vector_f32_values_none() {
3115        let (result, valid) = extract_vector_f32_values(None, false, 3);
3116        assert_eq!(result, vec![0.0, 0.0, 0.0]);
3117        assert!(!valid);
3118    }
3119
3120    #[test]
3121    fn test_extract_vector_f32_values_null() {
3122        let val = Value::Null;
3123        let (result, valid) = extract_vector_f32_values(Some(&val), false, 3);
3124        assert_eq!(result, vec![0.0, 0.0, 0.0]);
3125        assert!(!valid);
3126    }
3127
3128    #[test]
3129    fn test_extract_vector_f32_values_unsupported_type() {
3130        let val = Value::String("not a vector".to_string());
3131        let (result, valid) = extract_vector_f32_values(Some(&val), false, 3);
3132        assert_eq!(result, vec![0.0, 0.0, 0.0]);
3133        assert!(!valid);
3134    }
3135
3136    #[test]
3137    fn test_extract_vector_f32_values_deleted_with_none() {
3138        let (result, valid) = extract_vector_f32_values(None, true, 3);
3139        assert_eq!(result, vec![0.0, 0.0, 0.0]);
3140        assert!(valid); // Deleted entries are marked as valid with zeros
3141    }
3142
3143    #[test]
3144    fn test_extract_vector_f32_values_deleted_with_null() {
3145        let val = Value::Null;
3146        let (result, valid) = extract_vector_f32_values(Some(&val), true, 3);
3147        assert_eq!(result, vec![0.0, 0.0, 0.0]);
3148        assert!(valid); // Deleted entries are marked as valid with zeros
3149    }
3150
3151    // Tests for values_to_array with FixedSizeList
3152
3153    #[test]
3154    fn test_values_to_fixed_size_list_vector_with_nulls() {
3155        let values = vec![
3156            Value::Vector(vec![1.0, 2.0]),
3157            Value::Null,
3158            Value::Vector(vec![3.0, 4.0]),
3159            Value::String("invalid".to_string()),
3160        ];
3161        let arr_ref = values_to_array(
3162            &values,
3163            &ArrowDataType::FixedSizeList(
3164                Arc::new(Field::new("item", ArrowDataType::Float32, false)),
3165                2,
3166            ),
3167        )
3168        .unwrap();
3169
3170        let arr = arr_ref
3171            .as_any()
3172            .downcast_ref::<FixedSizeListArray>()
3173            .unwrap();
3174
3175        assert_eq!(arr.len(), 4);
3176        assert!(arr.is_valid(0));
3177        assert!(!arr.is_valid(1)); // Null value
3178        assert!(arr.is_valid(2));
3179        assert!(!arr.is_valid(3)); // Invalid type
3180    }
3181
3182    #[test]
3183    fn test_values_to_fixed_size_list_from_list() {
3184        let values = vec![
3185            Value::List(vec![Value::Float(1.0), Value::Float(2.0)]),
3186            Value::List(vec![Value::Int(3), Value::Int(4)]),
3187        ];
3188        let arr_ref = values_to_array(
3189            &values,
3190            &ArrowDataType::FixedSizeList(
3191                Arc::new(Field::new("item", ArrowDataType::Float32, false)),
3192                2,
3193            ),
3194        )
3195        .unwrap();
3196
3197        let arr = arr_ref
3198            .as_any()
3199            .downcast_ref::<FixedSizeListArray>()
3200            .unwrap();
3201
3202        assert_eq!(arr.len(), 2);
3203        assert!(arr.is_valid(0));
3204        assert!(arr.is_valid(1));
3205
3206        // Check values
3207        let child = arr
3208            .values()
3209            .as_any()
3210            .downcast_ref::<Float32Array>()
3211            .unwrap();
3212        assert_eq!(child.value(0), 1.0);
3213        assert_eq!(child.value(1), 2.0);
3214        assert_eq!(child.value(2), 3.0);
3215        assert_eq!(child.value(3), 4.0);
3216    }
3217
3218    #[test]
3219    fn test_values_to_fixed_size_list_wrong_dimensions() {
3220        let values = vec![
3221            Value::Vector(vec![1.0, 2.0, 3.0]),   // 3 dims, expecting 2
3222            Value::List(vec![Value::Float(4.0)]), // 1 dim, expecting 2
3223        ];
3224        let arr_ref = values_to_array(
3225            &values,
3226            &ArrowDataType::FixedSizeList(
3227                Arc::new(Field::new("item", ArrowDataType::Float32, false)),
3228                2,
3229            ),
3230        )
3231        .unwrap();
3232
3233        let arr = arr_ref
3234            .as_any()
3235            .downcast_ref::<FixedSizeListArray>()
3236            .unwrap();
3237
3238        assert_eq!(arr.len(), 2);
3239        assert!(!arr.is_valid(0)); // Wrong dimensions
3240        assert!(!arr.is_valid(1)); // Wrong dimensions
3241
3242        // Check that child array has zeros for invalid entries
3243        let child = arr
3244            .values()
3245            .as_any()
3246            .downcast_ref::<Float32Array>()
3247            .unwrap();
3248        assert_eq!(child.value(0), 0.0);
3249        assert_eq!(child.value(1), 0.0);
3250        assert_eq!(child.value(2), 0.0);
3251        assert_eq!(child.value(3), 0.0);
3252    }
3253
3254    #[test]
3255    fn test_values_to_fixed_size_list_all_nulls() {
3256        let values = vec![Value::Null, Value::Null, Value::Null];
3257        let arr_ref = values_to_array(
3258            &values,
3259            &ArrowDataType::FixedSizeList(
3260                Arc::new(Field::new("item", ArrowDataType::Float32, false)),
3261                3,
3262            ),
3263        )
3264        .unwrap();
3265
3266        let arr = arr_ref
3267            .as_any()
3268            .downcast_ref::<FixedSizeListArray>()
3269            .unwrap();
3270
3271        assert_eq!(arr.len(), 3);
3272        assert!(!arr.is_valid(0));
3273        assert!(!arr.is_valid(1));
3274        assert!(!arr.is_valid(2));
3275
3276        // Verify child array length is correct (3 rows × 3 dims = 9)
3277        let child = arr
3278            .values()
3279            .as_any()
3280            .downcast_ref::<Float32Array>()
3281            .unwrap();
3282        assert_eq!(child.len(), 9);
3283    }
3284
3285    #[test]
3286    fn test_values_to_fixed_size_list_mixed_types() {
3287        let values = vec![
3288            Value::Vector(vec![1.0, 2.0]),
3289            Value::List(vec![Value::Float(3.0), Value::Float(4.0)]),
3290            Value::Null,
3291            Value::String("invalid".to_string()),
3292        ];
3293        let arr_ref = values_to_array(
3294            &values,
3295            &ArrowDataType::FixedSizeList(
3296                Arc::new(Field::new("item", ArrowDataType::Float32, false)),
3297                2,
3298            ),
3299        )
3300        .unwrap();
3301
3302        let arr = arr_ref
3303            .as_any()
3304            .downcast_ref::<FixedSizeListArray>()
3305            .unwrap();
3306
3307        assert_eq!(arr.len(), 4);
3308        assert!(arr.is_valid(0)); // Value::Vector
3309        assert!(arr.is_valid(1)); // Value::List
3310        assert!(!arr.is_valid(2)); // Value::Null
3311        assert!(!arr.is_valid(3)); // Value::String
3312
3313        // Check values for valid entries
3314        let child = arr
3315            .values()
3316            .as_any()
3317            .downcast_ref::<Float32Array>()
3318            .unwrap();
3319        assert_eq!(child.value(0), 1.0);
3320        assert_eq!(child.value(1), 2.0);
3321        assert_eq!(child.value(2), 3.0);
3322        assert_eq!(child.value(3), 4.0);
3323    }
3324
3325    // Tests for PropertyExtractor::build_vector_column
3326
3327    #[test]
3328    fn test_build_vector_column_with_nulls_and_deleted() {
3329        let data_type = DataType::Vector { dimensions: 3 };
3330        let extractor = PropertyExtractor::new("test_vec", &data_type);
3331
3332        let props = [
3333            Some(Value::Vector(vec![1.0, 2.0, 3.0])),
3334            None,              // Missing property
3335            Some(Value::Null), // Null value
3336            Some(Value::Vector(vec![4.0, 5.0, 6.0])),
3337        ];
3338        let deleted = [false, false, false, true]; // Last one is deleted
3339
3340        let arr_ref = extractor
3341            .build_vector_column(4, &deleted, |i| props[i].as_ref(), 3)
3342            .unwrap();
3343
3344        let arr = arr_ref
3345            .as_any()
3346            .downcast_ref::<FixedSizeListArray>()
3347            .unwrap();
3348
3349        assert_eq!(arr.len(), 4);
3350        assert!(arr.is_valid(0)); // Valid vector
3351        assert!(!arr.is_valid(1)); // Missing property
3352        assert!(!arr.is_valid(2)); // Null value
3353        assert!(arr.is_valid(3)); // Deleted entry (valid with zeros)
3354
3355        // Check values
3356        let child = arr
3357            .values()
3358            .as_any()
3359            .downcast_ref::<Float32Array>()
3360            .unwrap();
3361        assert_eq!(child.value(0), 1.0);
3362        assert_eq!(child.value(1), 2.0);
3363        assert_eq!(child.value(2), 3.0);
3364        // Indices 3-5: zeros for missing
3365        // Indices 6-8: zeros for null
3366        // Indices 9-11: zeros for deleted (but marked as valid)
3367        assert_eq!(child.value(9), 0.0);
3368        assert_eq!(child.value(10), 0.0);
3369        assert_eq!(child.value(11), 0.0);
3370    }
3371
3372    #[test]
3373    fn test_build_vector_column_with_list_input() {
3374        let data_type = DataType::Vector { dimensions: 2 };
3375        let extractor = PropertyExtractor::new("test_vec", &data_type);
3376
3377        let props = [
3378            Some(Value::List(vec![Value::Float(1.0), Value::Float(2.0)])),
3379            Some(Value::List(vec![Value::Int(3), Value::Int(4)])),
3380            Some(Value::Vector(vec![5.0, 6.0])),
3381        ];
3382        let deleted = [false, false, false];
3383
3384        let arr_ref = extractor
3385            .build_vector_column(3, &deleted, |i| props[i].as_ref(), 2)
3386            .unwrap();
3387
3388        let arr = arr_ref
3389            .as_any()
3390            .downcast_ref::<FixedSizeListArray>()
3391            .unwrap();
3392
3393        assert_eq!(arr.len(), 3);
3394        assert!(arr.is_valid(0));
3395        assert!(arr.is_valid(1));
3396        assert!(arr.is_valid(2));
3397
3398        // Check values
3399        let child = arr
3400            .values()
3401            .as_any()
3402            .downcast_ref::<Float32Array>()
3403            .unwrap();
3404        assert_eq!(child.value(0), 1.0);
3405        assert_eq!(child.value(1), 2.0);
3406        assert_eq!(child.value(2), 3.0);
3407        assert_eq!(child.value(3), 4.0);
3408        assert_eq!(child.value(4), 5.0);
3409        assert_eq!(child.value(5), 6.0);
3410    }
3411
3412    #[test]
3413    fn test_build_binary_vector_column_roundtrip() {
3414        // A `BinaryVector(dim)` column is `FixedSizeList<UInt8, dim>`. Accepts a
3415        // native `BinaryVector` or a `List` of byte-ints, decodes back with type
3416        // fidelity as `Value::BinaryVector` (not a generic int list).
3417        let data_type = DataType::BinaryVector { dimensions: 3 };
3418        let extractor = PropertyExtractor::new("bits", &data_type);
3419
3420        let props = [
3421            Some(Value::BinaryVector(vec![0x00, 0xFF, 0xA5])),
3422            Some(Value::List(vec![
3423                Value::Int(1),
3424                Value::Int(2),
3425                Value::Int(255),
3426            ])),
3427            None, // deleted
3428            None, // missing/null
3429        ];
3430        let deleted = [false, false, true, false];
3431
3432        let arr_ref = extractor
3433            .build_binary_vector_column(4, &deleted, |i| props[i].as_ref(), 3)
3434            .unwrap();
3435
3436        let arr = arr_ref
3437            .as_any()
3438            .downcast_ref::<FixedSizeListArray>()
3439            .unwrap();
3440        assert_eq!(arr.len(), 4);
3441        assert!(arr.is_valid(0));
3442        assert!(arr.is_valid(1));
3443        assert!(arr.is_valid(2)); // deleted rows are valid zeros
3444        assert!(!arr.is_valid(3)); // null row
3445
3446        let child = arr.values().as_any().downcast_ref::<UInt8Array>().unwrap();
3447        assert_eq!(child.value(0), 0x00);
3448        assert_eq!(child.value(1), 0xFF);
3449        assert_eq!(child.value(2), 0xA5);
3450        assert_eq!(child.value(3), 1);
3451        assert_eq!(child.value(5), 255);
3452
3453        // Read-back preserves type identity.
3454        assert_eq!(
3455            arrow_to_value(&arr_ref, 0, Some(&data_type)),
3456            Value::BinaryVector(vec![0x00, 0xFF, 0xA5])
3457        );
3458        assert_eq!(
3459            arrow_to_value(&arr_ref, 1, Some(&data_type)),
3460            Value::BinaryVector(vec![1, 2, 255])
3461        );
3462        assert_eq!(arrow_to_value(&arr_ref, 3, Some(&data_type)), Value::Null);
3463    }
3464
3465    #[test]
3466    fn test_build_binary_vector_column_wrong_length_fails_closed() {
3467        // A present, wrong-lane-count value must error at flush, not silently null.
3468        let data_type = DataType::BinaryVector { dimensions: 3 };
3469        let extractor = PropertyExtractor::new("bits", &data_type);
3470        let props = [Some(Value::BinaryVector(vec![1, 2]))]; // only 2 lanes
3471        let deleted = [false];
3472        let err = extractor
3473            .build_binary_vector_column(1, &deleted, |i| props[i].as_ref(), 3)
3474            .unwrap_err();
3475        assert!(err.to_string().contains("bits"), "got: {err}");
3476    }
3477
3478    // Tests for multi-vector (ColBERT) `List<Vector>` columns (issue #96)
3479
3480    #[test]
3481    fn test_build_multivector_list_column_roundtrip() {
3482        // A multi-vector property is `List<FixedSizeList<Float32, dim>>` with a
3483        // VARIABLE token count per row. Each token is a dense vector, so it reads
3484        // back with type fidelity as a `Value::List` of `Value::Vector` tokens
3485        // (parity with `SparseVector`/`Btic` — `FixedSizeList<Float32>` decodes to
3486        // `Value::Vector`, not a generic float list).
3487        let data_type = DataType::List(Box::new(DataType::Vector { dimensions: 3 }));
3488        let extractor = PropertyExtractor::new("tokens", &data_type);
3489
3490        let props = [
3491            // row 0: two tokens
3492            Some(Value::List(vec![
3493                Value::Vector(vec![1.0, 2.0, 3.0]),
3494                Value::Vector(vec![4.0, 5.0, 6.0]),
3495            ])),
3496            // row 1: three tokens (different count -> exercises variable length)
3497            Some(Value::List(vec![
3498                Value::Vector(vec![7.0, 8.0, 9.0]),
3499                Value::Vector(vec![10.0, 11.0, 12.0]),
3500                Value::Vector(vec![13.0, 14.0, 15.0]),
3501            ])),
3502            // row 2: empty token set (present but zero tokens)
3503            Some(Value::List(vec![])),
3504            // row 3: deleted, missing property
3505            None,
3506        ];
3507        let deleted = [false, false, false, true];
3508
3509        let arr_ref = extractor
3510            .build_column(4, &deleted, |i| props[i].as_ref())
3511            .unwrap();
3512
3513        // Outer column is a variable-length list of fixed-size vectors.
3514        let outer = arr_ref.as_any().downcast_ref::<ListArray>().unwrap();
3515        assert_eq!(outer.len(), 4);
3516        assert!(outer.is_valid(0));
3517        assert!(outer.is_valid(1));
3518        assert!(outer.is_valid(2)); // empty-but-present row
3519        assert!(!outer.is_valid(3)); // deleted/missing -> null
3520
3521        // Variable token counts survive.
3522        assert_eq!(outer.value(0).len(), 2);
3523        assert_eq!(outer.value(1).len(), 3);
3524        assert_eq!(outer.value(2).len(), 0);
3525
3526        // Read back through the generic decoder; each token round-trips with type
3527        // fidelity as a `Value::Vector`.
3528        let row0 = arrow_to_value(arr_ref.as_ref(), 0, Some(&data_type));
3529        assert_eq!(
3530            row0,
3531            Value::List(vec![
3532                Value::Vector(vec![1.0, 2.0, 3.0]),
3533                Value::Vector(vec![4.0, 5.0, 6.0]),
3534            ])
3535        );
3536
3537        let row1 = arrow_to_value(arr_ref.as_ref(), 1, Some(&data_type));
3538        let Value::List(tokens) = row1 else {
3539            panic!("row1 should decode to a list of tokens");
3540        };
3541        assert_eq!(tokens.len(), 3);
3542        assert_eq!(tokens[2], Value::Vector(vec![13.0, 14.0, 15.0]));
3543    }
3544
3545    #[test]
3546    fn test_build_multivector_invalid_inner_tokens() {
3547        // A row with a wrong-dimension token fails the build (fail-closed, issue
3548        // #137) instead of silently nulling the token, and the error names the
3549        // property and the offending token index.
3550        let data_type = DataType::List(Box::new(DataType::Vector { dimensions: 2 }));
3551        let extractor = PropertyExtractor::new("tokens", &data_type);
3552
3553        let props = [Some(Value::List(vec![
3554            Value::Vector(vec![1.0, 2.0]),      // valid
3555            Value::Vector(vec![9.0, 9.0, 9.0]), // wrong dim -> error
3556        ]))];
3557        let deleted = [false];
3558
3559        let err = extractor
3560            .build_column(1, &deleted, |i| props[i].as_ref())
3561            .unwrap_err()
3562            .to_string();
3563        assert!(err.contains("'tokens'"), "message: {err}");
3564        assert!(err.contains("token 1"), "message: {err}");
3565
3566        // A non-vector token fails too.
3567        let props = [Some(Value::List(vec![Value::String("nope".to_string())]))];
3568        let err = extractor
3569            .build_column(1, &deleted, |i| props[i].as_ref())
3570            .unwrap_err()
3571            .to_string();
3572        assert!(err.contains("token 0"), "message: {err}");
3573    }
3574
3575    #[test]
3576    fn test_build_multivector_valid_tokens_and_null_rows() {
3577        // Happy path: valid tokens (both Vector and numeric-List form) build, a
3578        // missing/null value stays a legal null row, and an empty token list is a
3579        // legal empty multi-vector.
3580        let data_type = DataType::List(Box::new(DataType::Vector { dimensions: 2 }));
3581        let extractor = PropertyExtractor::new("tokens", &data_type);
3582
3583        let props = [
3584            Some(Value::List(vec![
3585                Value::Vector(vec![1.0, 2.0]),
3586                Value::List(vec![Value::Float(3.0), Value::Float(4.0)]),
3587            ])),
3588            None,
3589            Some(Value::Null),
3590            Some(Value::List(vec![])),
3591        ];
3592        let deleted = [false; 4];
3593
3594        let arr_ref = extractor
3595            .build_column(4, &deleted, |i| props[i].as_ref())
3596            .unwrap();
3597        let outer = arr_ref.as_any().downcast_ref::<ListArray>().unwrap();
3598        assert!(outer.is_valid(0));
3599        assert!(!outer.is_valid(1)); // absent -> null row
3600        assert!(!outer.is_valid(2)); // explicit Null -> null row
3601        assert!(outer.is_valid(3)); // empty multi-vector row
3602        let inner_row = outer.value(0);
3603        let inner = inner_row
3604            .as_any()
3605            .downcast_ref::<FixedSizeListArray>()
3606            .unwrap();
3607        assert_eq!(inner.len(), 2);
3608        assert!(inner.is_valid(0));
3609        assert!(inner.is_valid(1));
3610        assert_eq!(outer.value(3).len(), 0);
3611    }
3612
3613    #[test]
3614    fn test_build_vector_column_wrong_dims_is_error() {
3615        // Fail-closed flush for single dense vectors (issue #137): a wrong-dimension
3616        // value errors with the property name and row instead of becoming NULL.
3617        let data_type = DataType::Vector { dimensions: 3 };
3618        let extractor = PropertyExtractor::new("embedding", &data_type);
3619
3620        let props = [
3621            Some(Value::Vector(vec![1.0, 2.0, 3.0])),
3622            Some(Value::Vector(vec![1.0, 2.0])),
3623        ];
3624        let deleted = [false; 2];
3625
3626        let err = extractor
3627            .build_column(2, &deleted, |i| props[i].as_ref())
3628            .unwrap_err()
3629            .to_string();
3630        assert!(err.contains("'embedding'"), "message: {err}");
3631        assert!(err.contains("row 1"), "message: {err}");
3632
3633        // Null rows and deleted rows still build fine.
3634        let props = [
3635            Some(Value::Vector(vec![1.0, 2.0, 3.0])),
3636            None,
3637            Some(Value::Null),
3638        ];
3639        let deleted = [false, false, true];
3640        let arr_ref = extractor
3641            .build_column(3, &deleted, |i| props[i].as_ref())
3642            .unwrap();
3643        let arr = arr_ref
3644            .as_any()
3645            .downcast_ref::<FixedSizeListArray>()
3646            .unwrap();
3647        assert!(arr.is_valid(0));
3648        assert!(!arr.is_valid(1));
3649    }
3650
3651    #[test]
3652    fn test_extract_vector_f32_values_strict() {
3653        // Wrong dim -> Err; null/none -> Ok null row; deleted -> Ok valid zeros.
3654        assert!(
3655            extract_vector_f32_values_strict(Some(&Value::Vector(vec![1.0, 2.0])), false, 3)
3656                .is_err()
3657        );
3658        assert!(
3659            extract_vector_f32_values_strict(Some(&Value::String("x".into())), false, 3).is_err()
3660        );
3661        let (vals, valid) = extract_vector_f32_values_strict(None, false, 3).unwrap();
3662        assert_eq!((vals, valid), (vec![0.0, 0.0, 0.0], false));
3663        let (vals, valid) = extract_vector_f32_values_strict(Some(&Value::Null), false, 3).unwrap();
3664        assert_eq!((vals, valid), (vec![0.0, 0.0, 0.0], false));
3665        let (vals, valid) =
3666            extract_vector_f32_values_strict(Some(&Value::Vector(vec![1.0, 2.0])), true, 3)
3667                .unwrap();
3668        assert_eq!((vals, valid), (vec![0.0, 0.0, 0.0], true));
3669        let (vals, valid) =
3670            extract_vector_f32_values_strict(Some(&Value::Vector(vec![1.0, 2.0, 3.0])), false, 3)
3671                .unwrap();
3672        assert_eq!((vals, valid), (vec![1.0, 2.0, 3.0], true));
3673    }
3674
3675    #[test]
3676    fn test_values_to_array_multivector_schemaless_deferred() {
3677        // Schemaless (Arrow-type-driven) multi-vector writes are intentionally NOT
3678        // supported yet (issue #96, Phase 1.5). Declared-schema writes go through
3679        // `build_list_column` instead. This test pins the deferral contract so a
3680        // future change that enables it updates this expectation deliberately.
3681        let values = vec![Value::List(vec![Value::Vector(vec![1.0, 2.0])])];
3682        let dt = ArrowDataType::List(Arc::new(Field::new(
3683            "item",
3684            ArrowDataType::FixedSizeList(
3685                Arc::new(Field::new("item", ArrowDataType::Float32, true)),
3686                2,
3687            ),
3688            true,
3689        )));
3690        assert!(values_to_array(&values, &dt).is_err());
3691    }
3692
3693    /// H13: out-of-range i64 values must become NULL in i32/date32 columns
3694    /// instead of silently wrapping to a different number/date.
3695    #[test]
3696    fn test_int32_and_date32_columns_null_out_of_range() {
3697        let dt = DataType::Int64;
3698        let extractor = PropertyExtractor::new("x", &dt);
3699
3700        let over = Value::Int(i64::from(i32::MAX) + 1);
3701        let ok = Value::Int(42);
3702        let vals = [over, ok];
3703        let deleted = [false, false];
3704
3705        let arr = extractor
3706            .build_int32_column(2, &deleted, |i| Some(&vals[i]))
3707            .unwrap();
3708        let arr = arr.as_any().downcast_ref::<Int32Array>().unwrap();
3709        assert!(
3710            arr.is_null(0),
3711            "out-of-range i64 must be NULL in an int32 column, not wrapped"
3712        );
3713        assert_eq!(arr.value(1), 42);
3714
3715        let arr = extractor
3716            .build_date32_column(2, &deleted, |i| Some(&vals[i]))
3717            .unwrap();
3718        let arr = arr.as_any().downcast_ref::<Date32Array>().unwrap();
3719        assert!(
3720            arr.is_null(0),
3721            "out-of-range day count must be NULL in a date32 column, not wrapped"
3722        );
3723        assert_eq!(arr.value(1), 42);
3724    }
3725}