Skip to main content

graphar_flight/
convert.rs

1//! FalkorDB GRAPH.QUERY response (Redis RESP) → Arrow RecordBatch.
2//!
3//! Response shape (non-compact mode):
4//!   Array[
5//!     Array[col_name, ...]          ← headers  (index 0)
6//!     Array[Array[val, ...], ...]   ← data rows (index 1)
7//!     Array[stat_string, ...]       ← stats     (index 2, ignored)
8//!   ]
9
10use std::sync::Arc;
11
12use arrow_array::{
13    ArrayRef, RecordBatch,
14    builder::{BooleanBuilder, Float64Builder, Int64Builder, StringBuilder},
15};
16use arrow_schema::{DataType, Field, SchemaRef};
17use redis::Value;
18
19use crate::error::{FlightSqlError, Result};
20
21/// Convert a raw `GRAPH.QUERY` Redis response to a `RecordBatch` using a
22/// **pre-registered** schema to guide type coercion.
23///
24/// Schema fields must be listed in the same order as the `RETURN` clause.
25pub fn response_to_batch(response: Value, schema: &SchemaRef) -> Result<RecordBatch> {
26    let mut outer = match response {
27        Value::Array(v) => v,
28        other => {
29            return Err(FlightSqlError::MalformedResponse(format!(
30                "expected Array at top level, got {other:?}"
31            )));
32        }
33    };
34
35    if outer.len() < 2 {
36        return Ok(RecordBatch::new_empty(schema.clone()));
37    }
38
39    let rows_val = outer.remove(1);
40    let rows = match rows_val {
41        Value::Array(r) => r,
42        Value::Nil => vec![],
43        other => {
44            return Err(FlightSqlError::MalformedResponse(format!(
45                "expected Array for data rows, got {other:?}"
46            )));
47        }
48    };
49
50    let ncols = schema.fields().len();
51    let nrows = rows.len();
52
53    let mut builders: Vec<ColumnBuilder> = schema
54        .fields()
55        .iter()
56        .map(|f| ColumnBuilder::new(f.data_type(), nrows))
57        .collect();
58
59    for (row_idx, row_val) in rows.into_iter().enumerate() {
60        let cells = match row_val {
61            Value::Array(c) => c,
62            other => {
63                return Err(FlightSqlError::MalformedResponse(format!(
64                    "row {row_idx}: expected Array, got {other:?}"
65                )));
66            }
67        };
68
69        if cells.len() != ncols {
70            return Err(FlightSqlError::ColumnCountMismatch {
71                schema: ncols,
72                row: cells.len(),
73            });
74        }
75
76        for (col_idx, cell) in cells.iter().enumerate() {
77            builders[col_idx].append(cell, row_idx)?;
78        }
79    }
80
81    let columns: Vec<ArrayRef> = builders.into_iter().map(|b| b.finish()).collect();
82    Ok(RecordBatch::try_new(schema.clone(), columns)?)
83}
84
85/// Convert a raw `GRAPH.QUERY` response to a `RecordBatch` **without a
86/// pre-registered schema**.
87///
88/// Column names are taken from the FalkorDB response header row. The Arrow
89/// type for each column is inferred by inspecting the first non-null Redis
90/// value in that column:
91///
92/// | Redis value | Arrow type |
93/// |---|---|
94/// | `Int` | `Int64` |
95/// | `Double` | `Float64` |
96/// | `Boolean` | `Boolean` |
97/// | `BulkString` / `SimpleString` / other | `Utf8` |
98/// | all-null column | `Utf8` (fallback) |
99///
100/// This means integer IDs like `_gar_id`, `src`, and `dst` are returned as
101/// `Int64` by default — no registration needed.
102pub fn response_to_batch_auto(response: Value) -> Result<RecordBatch> {
103    let outer = match response {
104        Value::Array(v) => v,
105        other => {
106            return Err(FlightSqlError::MalformedResponse(format!(
107                "expected Array at top level, got {other:?}"
108            )));
109        }
110    };
111
112    if outer.len() < 2 {
113        return Ok(RecordBatch::new_empty(Arc::new(
114            arrow_schema::Schema::empty(),
115        )));
116    }
117
118    let col_names = extract_string_vec(&outer[0])?;
119    let rows: Vec<&Value> = match &outer[1] {
120        Value::Array(r) => r.iter().collect(),
121        Value::Nil => vec![],
122        other => {
123            return Err(FlightSqlError::MalformedResponse(format!(
124                "expected Array for data rows, got {other:?}"
125            )));
126        }
127    };
128
129    let ncols = col_names.len();
130    let nrows = rows.len();
131
132    if ncols == 0 {
133        return Ok(RecordBatch::new_empty(Arc::new(
134            arrow_schema::Schema::empty(),
135        )));
136    }
137
138    // Pass 1 — sniff the Arrow type for each column in a single row-major scan
139    // that short-circuits once every column's type is known (M4).
140    let col_types = sniff_column_types(&rows, ncols);
141
142    let schema = Arc::new(arrow_schema::Schema::new(
143        col_names
144            .iter()
145            .zip(&col_types)
146            .map(|(n, dt)| Field::new(n.as_str(), dt.clone(), true))
147            .collect::<Vec<_>>(),
148    ));
149
150    // Pass 2 — fill typed builders.
151    let mut builders: Vec<ColumnBuilder> = col_types
152        .iter()
153        .map(|dt| ColumnBuilder::new(dt, nrows))
154        .collect();
155
156    for (row_idx, row_val) in rows.iter().enumerate() {
157        let cells = match row_val {
158            Value::Array(c) => c,
159            other => {
160                return Err(FlightSqlError::MalformedResponse(format!(
161                    "row {row_idx}: expected Array, got {other:?}"
162                )));
163            }
164        };
165        // Validate the row width before filling builders. Without this a ragged
166        // row (fewer cells than columns) silently under-fills the trailing
167        // builders, and the mismatch only surfaces later as an opaque
168        // `RecordBatch::try_new` length error. Mirror the schema-driven
169        // `response_to_batch`, which already returns `ColumnCountMismatch`.
170        if cells.len() != ncols {
171            return Err(FlightSqlError::ColumnCountMismatch {
172                schema: ncols,
173                row: cells.len(),
174            });
175        }
176
177        for (col_idx, cell) in cells.iter().enumerate() {
178            builders[col_idx].append(cell, row_idx)?;
179        }
180    }
181
182    let columns: Vec<ArrayRef> = builders.into_iter().map(|b| b.finish()).collect();
183    Ok(RecordBatch::try_new(schema, columns)?)
184}
185
186/// Map a single Redis cell to the Arrow type it fixes for its column, or `None`
187/// for a `Nil` cell (which leaves the column undetermined).
188///
189/// This is the *one* place the per-cell type rule lives; both the original
190/// per-column sniff and the single-pass sniff agree because they call it.
191#[inline]
192fn cell_type(cell: &Value) -> Option<DataType> {
193    match cell {
194        Value::Nil => None, // null: keep the column undetermined
195        Value::Int(_) => Some(DataType::Int64),
196        Value::Double(_) => Some(DataType::Float64),
197        Value::Boolean(_) => Some(DataType::Boolean),
198        _ => Some(DataType::Utf8),
199    }
200}
201
202/// Infer the Arrow type of every column in a **single row-major pass** that
203/// stops as soon as all columns are determined (M4 — single-pass type sniff).
204///
205/// ## Equivalence to the previous per-column scan
206///
207/// The previous code called `sniff_column_type(rows, ci)` once per column,
208/// each call walking `rows` top-to-bottom and returning the type of the
209/// **first non-null cell** in that column (falling back to `Utf8` for an
210/// all-null/empty column). Crucially the rule is *first-non-null wins* — there
211/// is **no type promotion**: a column's type never changes once a non-null cell
212/// is seen, regardless of what later rows contain (e.g. an `Int` first row
213/// followed by a `Double` row still yields `Int64`; the builder then coerces
214/// the later cells, see `redis_to_i64`). Because the type is a pure function of
215/// the *first non-null cell per column* and is independent across columns, the
216/// order in which we visit cells does not affect the result, so we can fuse the
217/// `ncols` independent passes into one row-major pass:
218///
219/// * `types[ci]` is written exactly once — on the first non-null cell of column
220///   `ci` — and never overwritten, matching "first non-null wins".
221/// * columns whose every cell is `Nil` (or that have no cell in any row) are
222///   never written and keep the `Utf8` default, matching the all-null fallback.
223/// * once `remaining == 0` every column is fixed and no later cell could change
224///   any answer, so we stop early — saving the wasted tail scan M4 targets.
225///
226/// Rows shorter than `ncols` (a malformed/ragged response) are handled exactly
227/// as before: `cells.get(ci)` simply yields `None` for the missing trailing
228/// columns, leaving them undetermined just like the per-column scan's
229/// `cells.get(col_idx)` miss did.
230fn sniff_column_types(rows: &[&Value], ncols: usize) -> Vec<DataType> {
231    // Default for all-null / empty columns is Utf8 (the original fallback).
232    let mut types = vec![DataType::Utf8; ncols];
233    // `determined[ci]` flips true the moment column `ci`'s type is fixed.
234    let mut determined = vec![false; ncols];
235    let mut remaining = ncols;
236
237    for row in rows {
238        if remaining == 0 {
239            break; // every column resolved — the rest of the scan is wasted work
240        }
241        let Value::Array(cells) = row else { continue };
242        for ci in 0..ncols {
243            if determined[ci] {
244                continue; // already fixed by an earlier row — skip
245            }
246            if let Some(cell) = cells.get(ci)
247                && let Some(dt) = cell_type(cell)
248            {
249                types[ci] = dt;
250                determined[ci] = true;
251                remaining -= 1;
252            }
253        }
254    }
255    types
256}
257
258// ── Per-column builder ────────────────────────────────────────────────────────
259
260enum ColumnBuilder {
261    Int64(Int64Builder),
262    Float64(Float64Builder),
263    Bool(BooleanBuilder),
264    Utf8(StringBuilder),
265}
266
267impl ColumnBuilder {
268    fn new(dt: &DataType, capacity: usize) -> Self {
269        match dt {
270            DataType::Int8
271            | DataType::Int16
272            | DataType::Int32
273            | DataType::Int64
274            | DataType::UInt8
275            | DataType::UInt16
276            | DataType::UInt32
277            | DataType::UInt64 => Self::Int64(Int64Builder::with_capacity(capacity)),
278            DataType::Float16 | DataType::Float32 | DataType::Float64 => {
279                Self::Float64(Float64Builder::with_capacity(capacity))
280            }
281            DataType::Boolean => Self::Bool(BooleanBuilder::with_capacity(capacity)),
282            _ => Self::Utf8(StringBuilder::with_capacity(capacity, capacity * 16)),
283        }
284    }
285
286    fn append(&mut self, val: &Value, row: usize) -> Result<()> {
287        match self {
288            Self::Int64(b) => b.append_option(redis_to_i64(val, row)?),
289            Self::Float64(b) => b.append_option(redis_to_f64(val, row)?),
290            Self::Bool(b) => b.append_option(redis_to_bool(val, row)?),
291            // Append directly into the builder; for the common valid-UTF-8
292            // BulkString this borrows (Cow::Borrowed) with no owned-String copy.
293            Self::Utf8(b) => append_redis_str(b, val),
294        }
295        Ok(())
296    }
297
298    fn finish(self) -> ArrayRef {
299        match self {
300            Self::Int64(mut b) => Arc::new(b.finish()),
301            Self::Float64(mut b) => Arc::new(b.finish()),
302            Self::Bool(mut b) => Arc::new(b.finish()),
303            Self::Utf8(mut b) => Arc::new(b.finish()),
304        }
305    }
306}
307
308// ── Redis Value → scalar coercions ───────────────────────────────────────────
309
310fn redis_to_i64(v: &Value, row: usize) -> Result<Option<i64>> {
311    match v {
312        Value::Nil => Ok(None),
313        Value::Int(i) => Ok(Some(*i)),
314        Value::Double(d) => Ok(Some(*d as i64)),
315        Value::BulkString(b) => {
316            let s = std::str::from_utf8(b).unwrap_or("");
317            s.parse::<i64>()
318                .map(Some)
319                .map_err(|_| malformed(row, "expected i64", s))
320        }
321        Value::SimpleString(s) => s
322            .parse::<i64>()
323            .map(Some)
324            .map_err(|_| malformed(row, "expected i64", s)),
325        other => Err(malformed(
326            row,
327            "expected Int for Int64",
328            &format!("{other:?}"),
329        )),
330    }
331}
332
333fn redis_to_f64(v: &Value, row: usize) -> Result<Option<f64>> {
334    match v {
335        Value::Nil => Ok(None),
336        Value::Double(d) => Ok(Some(*d)),
337        Value::Int(i) => Ok(Some(*i as f64)),
338        Value::BulkString(b) => {
339            let s = std::str::from_utf8(b).unwrap_or("");
340            s.parse::<f64>()
341                .map(Some)
342                .map_err(|_| malformed(row, "expected f64", s))
343        }
344        Value::SimpleString(s) => s
345            .parse::<f64>()
346            .map(Some)
347            .map_err(|_| malformed(row, "expected f64", s)),
348        other => Err(malformed(
349            row,
350            "expected Double for Float64",
351            &format!("{other:?}"),
352        )),
353    }
354}
355
356fn redis_to_bool(v: &Value, row: usize) -> Result<Option<bool>> {
357    match v {
358        Value::Nil => Ok(None),
359        Value::Boolean(b) => Ok(Some(*b)),
360        Value::Int(i) => Ok(Some(*i != 0)),
361        Value::BulkString(b) => match b.as_slice() {
362            b"true" | b"1" => Ok(Some(true)),
363            b"false" | b"0" => Ok(Some(false)),
364            other => Err(malformed(
365                row,
366                "expected bool",
367                std::str::from_utf8(other).unwrap_or("?"),
368            )),
369        },
370        other => Err(malformed(row, "expected Boolean", &format!("{other:?}"))),
371    }
372}
373
374/// Append a redis value to a `StringBuilder` without an intermediate owned
375/// `String` for the common cases (`from_utf8_lossy` is `Cow::Borrowed` when the
376/// bytes are valid UTF-8, and the builder copies into its own buffer anyway).
377fn append_redis_str(b: &mut StringBuilder, v: &Value) {
378    match v {
379        Value::Nil => b.append_null(),
380        Value::BulkString(bytes) => b.append_value(String::from_utf8_lossy(bytes)),
381        Value::SimpleString(s) => b.append_value(s),
382        Value::Int(i) => b.append_value(i.to_string()),
383        Value::Double(d) => b.append_value(d.to_string()),
384        Value::Boolean(x) => b.append_value(if *x { "true" } else { "false" }),
385        other => b.append_value(format!("{other:?}")),
386    }
387}
388
389fn extract_string_vec(v: &Value) -> Result<Vec<String>> {
390    match v {
391        Value::Array(items) => items
392            .iter()
393            .map(|item| match item {
394                Value::BulkString(b) => Ok(String::from_utf8_lossy(b).into_owned()),
395                Value::SimpleString(s) => Ok(s.clone()),
396                other => Ok(format!("{other:?}")),
397            })
398            .collect(),
399        other => Err(FlightSqlError::MalformedResponse(format!(
400            "expected Array for column names, got {other:?}"
401        ))),
402    }
403}
404
405fn malformed(row: usize, expected: &str, got: &str) -> FlightSqlError {
406    FlightSqlError::MalformedResponse(format!("row {row}: {expected}, got '{got}'"))
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn header(names: &[&str]) -> Value {
414        Value::Array(names.iter().map(|n| Value::BulkString(n.as_bytes().to_vec())).collect())
415    }
416
417    fn row(cells: Vec<Value>) -> Value {
418        Value::Array(cells)
419    }
420
421    /// A well-formed 2-column response round-trips through the schema-less path.
422    #[test]
423    fn auto_well_formed_rows_build_batch() {
424        let resp = Value::Array(vec![
425            header(&["id", "name"]),
426            Value::Array(vec![
427                row(vec![Value::Int(1), Value::BulkString(b"a".to_vec())]),
428                row(vec![Value::Int(2), Value::BulkString(b"b".to_vec())]),
429            ]),
430        ]);
431        let batch = response_to_batch_auto(resp).unwrap();
432        assert_eq!(batch.num_rows(), 2);
433        assert_eq!(batch.num_columns(), 2);
434    }
435
436    /// A ragged row (fewer cells than columns) must be rejected with a precise
437    /// `ColumnCountMismatch`, not silently under-fill the trailing builder and
438    /// surface later as an opaque `RecordBatch::try_new` length error.
439    #[test]
440    fn auto_short_row_is_column_count_mismatch() {
441        let resp = Value::Array(vec![
442            header(&["id", "name"]),
443            Value::Array(vec![
444                row(vec![Value::Int(1), Value::BulkString(b"a".to_vec())]),
445                row(vec![Value::Int(2)]), // ragged: only 1 cell for 2 columns
446            ]),
447        ]);
448        let err = response_to_batch_auto(resp).unwrap_err();
449        match err {
450            FlightSqlError::ColumnCountMismatch { schema, row } => {
451                assert_eq!(schema, 2);
452                assert_eq!(row, 1);
453            }
454            other => panic!("expected ColumnCountMismatch, got {other:?}"),
455        }
456    }
457
458    /// An over-wide row (more cells than columns) is likewise a mismatch, where
459    /// previously the extra cell was silently dropped.
460    #[test]
461    fn auto_long_row_is_column_count_mismatch() {
462        let resp = Value::Array(vec![
463            header(&["id"]),
464            Value::Array(vec![row(vec![Value::Int(1), Value::Int(99)])]),
465        ]);
466        let err = response_to_batch_auto(resp).unwrap_err();
467        assert!(matches!(
468            err,
469            FlightSqlError::ColumnCountMismatch { schema: 1, row: 2 }
470        ));
471    }
472
473    /// A nested Cypher value (a `map`/`node`/`list` — anything Redis returns as a
474    /// non-scalar `Value`) has no scalar Arrow type, so [`cell_type`] falls back
475    /// to `Utf8` and the cell is rendered via its `Debug` form. This pins that
476    /// documented fallback: `RETURN {name:'alice', age:30}` (a map) collapses to
477    /// a `Utf8` column whose value is the map's debug string, rather than
478    /// erroring or silently dropping the column.
479    #[test]
480    fn auto_nested_cypher_value_falls_back_to_utf8() {
481        // A Cypher map value, as FalkorDB returns it over RESP.
482        let cypher_map = Value::Map(vec![
483            (
484                Value::BulkString(b"name".to_vec()),
485                Value::BulkString(b"alice".to_vec()),
486            ),
487            (Value::BulkString(b"age".to_vec()), Value::Int(30)),
488        ]);
489        // A Cypher list value (nested array).
490        let cypher_list = Value::Array(vec![Value::Int(1), Value::Int(2), Value::Int(3)]);
491
492        let resp = Value::Array(vec![
493            header(&["id", "props", "tags"]),
494            Value::Array(vec![row(vec![
495                Value::Int(7),
496                cypher_map.clone(),
497                cypher_list.clone(),
498            ])]),
499        ]);
500        let batch = response_to_batch_auto(resp).unwrap();
501        assert_eq!(batch.num_rows(), 1);
502        assert_eq!(batch.num_columns(), 3);
503
504        // The nested columns sniff to Utf8 (the fallback), the scalar id stays Int64.
505        assert_eq!(batch.schema().field(0).data_type(), &DataType::Int64);
506        assert_eq!(batch.schema().field(1).data_type(), &DataType::Utf8);
507        assert_eq!(batch.schema().field(2).data_type(), &DataType::Utf8);
508
509        // The cell is the value's Debug rendering (what `append_redis_str` emits
510        // for a non-scalar), so no data is lost — it is stringified, not dropped.
511        let props = batch
512            .column(1)
513            .as_any()
514            .downcast_ref::<arrow_array::StringArray>()
515            .unwrap();
516        assert_eq!(props.value(0), format!("{cypher_map:?}"));
517        let tags = batch
518            .column(2)
519            .as_any()
520            .downcast_ref::<arrow_array::StringArray>()
521            .unwrap();
522        assert_eq!(tags.value(0), format!("{cypher_list:?}"));
523    }
524
525    /// The same Utf8 fallback drives cell-type sniffing directly: a non-scalar
526    /// `Value` fixes a column to `Utf8`, while `Nil` leaves it undetermined.
527    #[test]
528    fn cell_type_maps_nested_values_to_utf8() {
529        assert_eq!(cell_type(&Value::Int(1)), Some(DataType::Int64));
530        assert_eq!(cell_type(&Value::Double(1.0)), Some(DataType::Float64));
531        assert_eq!(cell_type(&Value::Boolean(true)), Some(DataType::Boolean));
532        assert_eq!(cell_type(&Value::Nil), None);
533        // Nested Cypher shapes → Utf8.
534        assert_eq!(cell_type(&Value::Map(vec![])), Some(DataType::Utf8));
535        assert_eq!(cell_type(&Value::Array(vec![])), Some(DataType::Utf8));
536        assert_eq!(
537            cell_type(&Value::BulkString(b"x".to_vec())),
538            Some(DataType::Utf8)
539        );
540    }
541}