use std::{io::Cursor, sync::Arc};
use arrow::ipc::{reader::StreamReader, writer::StreamWriter};
use arrow_array::RecordBatch;
use arrow_schema::{DataType, Field, Schema};
use serde_json::{Value, json};
use crate::{IndexSpec, InfinoError, Metric};
pub(crate) const ARROW_STREAM_CONTENT_TYPE: &str = "application/vnd.apache.arrow.stream";
fn scalar_type_str(data_type: &DataType) -> Result<&'static str, InfinoError> {
Ok(match data_type {
DataType::Utf8 => "utf8",
DataType::LargeUtf8 => "large_utf8",
DataType::Boolean => "bool",
DataType::Int32 => "i32",
DataType::Int64 => "i64",
DataType::UInt32 => "u32",
DataType::UInt64 => "u64",
DataType::Float32 => "f32",
DataType::Float64 => "f64",
other => {
return Err(InfinoError::Schema(format!(
"column type not supported over the remote transport: {other:?}"
)));
}
})
}
fn field_to_json(field: &Field) -> Result<Value, InfinoError> {
let name = field.name();
let nullable = field.is_nullable();
match field.data_type() {
DataType::FixedSizeList(item, dim) if *item.data_type() == DataType::Float32 => {
Ok(json!({ "name": name, "type": "vector", "dim": dim, "nullable": nullable }))
}
DataType::List(item) => Ok(json!({
"name": name,
"type": "list",
"item": scalar_type_str(item.data_type())?,
"nullable": nullable,
})),
other => Ok(json!({ "name": name, "type": scalar_type_str(other)?, "nullable": nullable })),
}
}
pub(crate) fn schema_to_json(schema: &Schema) -> Result<Vec<Value>, InfinoError> {
schema.fields().iter().map(|f| field_to_json(f)).collect()
}
pub(crate) fn metric_str(metric: Metric) -> &'static str {
match metric {
Metric::Cosine => "cosine",
Metric::L2Sq => "l2sq",
Metric::NegDot => "negdot",
}
}
pub(crate) fn index_spec_to_json(spec: &IndexSpec) -> Value {
let mut indexes = serde_json::Map::new();
let fts = spec.fts_columns();
if !fts.is_empty() {
indexes.insert("fts".to_string(), json!(fts));
}
let vectors: Vec<Value> = spec
.vector_indexes()
.map(|(column, dim, metric)| {
json!({
"column": column,
"dim": dim,
"metric": metric_str(metric),
})
})
.collect();
if !vectors.is_empty() {
indexes.insert("vector".to_string(), Value::Array(vectors));
}
Value::Object(indexes)
}
pub(crate) fn batches_to_ipc(batches: &[RecordBatch]) -> Result<Vec<u8>, InfinoError> {
let Some(first) = batches.first() else {
return Ok(Vec::new());
};
let schema = first.schema();
let mut out = Vec::new();
let mut writer = StreamWriter::try_new(&mut out, &schema)
.map_err(|e| InfinoError::Backend(format!("arrow ipc writer: {e}")))?;
for batch in batches {
writer
.write(batch)
.map_err(|e| InfinoError::Backend(format!("arrow ipc write: {e}")))?;
}
writer
.finish()
.map_err(|e| InfinoError::Backend(format!("arrow ipc finish: {e}")))?;
Ok(out)
}
pub(crate) fn ipc_to_batches(bytes: &[u8]) -> Result<Vec<RecordBatch>, InfinoError> {
if bytes.is_empty() {
return Ok(Vec::new());
}
let reader = StreamReader::try_new(Cursor::new(bytes), None)
.map_err(|e| InfinoError::Backend(format!("arrow ipc reader: {e}")))?;
reader
.collect::<Result<Vec<_>, _>>()
.map_err(|e| InfinoError::Backend(format!("arrow ipc read: {e}")))
}
fn scalar_type_from_str(name: &str) -> Result<DataType, InfinoError> {
Ok(match name {
"utf8" | "string" => DataType::Utf8,
"large_utf8" | "large_string" => DataType::LargeUtf8,
"bool" | "boolean" => DataType::Boolean,
"i32" | "int32" => DataType::Int32,
"i64" | "int64" => DataType::Int64,
"u32" | "uint32" => DataType::UInt32,
"u64" | "uint64" => DataType::UInt64,
"f32" | "float32" => DataType::Float32,
"f64" | "float64" | "double" => DataType::Float64,
other => {
return Err(InfinoError::Schema(format!(
"unknown column type in schema response: {other}"
)));
}
})
}
fn field_from_json(value: &Value) -> Result<Field, InfinoError> {
let name = value
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| InfinoError::Schema("schema field missing `name`".to_string()))?;
let type_name = value
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| InfinoError::Schema("schema field missing `type`".to_string()))?;
let nullable = value
.get("nullable")
.and_then(Value::as_bool)
.unwrap_or(true);
let data_type = match type_name {
"vector" => {
let dim = value
.get("dim")
.and_then(Value::as_u64)
.ok_or_else(|| InfinoError::Schema("vector column missing `dim`".to_string()))?;
let item = Arc::new(Field::new("item", DataType::Float32, true));
DataType::FixedSizeList(item, dim as i32)
}
"list" => {
let item_type = value
.get("item")
.and_then(Value::as_str)
.ok_or_else(|| InfinoError::Schema("list column missing `item`".to_string()))?;
let item = Arc::new(Field::new("item", scalar_type_from_str(item_type)?, true));
DataType::List(item)
}
other => scalar_type_from_str(other)?,
};
Ok(Field::new(name, data_type, nullable))
}
pub(crate) fn json_to_schema(fields: &[Value]) -> Result<Schema, InfinoError> {
let fields = fields
.iter()
.map(field_from_json)
.collect::<Result<Vec<_>, _>>()?;
Ok(Schema::new(fields))
}
pub(crate) fn status_to_error(op: &str, code: u16, body: &str) -> InfinoError {
match code {
404 => InfinoError::NotFound(format!("{op}: {body}")),
409 => InfinoError::AlreadyExists(format!("{op}: {body}")),
401 | 403 => {
InfinoError::Backend(format!("{op}: unauthorized (check the API key): {body}"))
}
_ => InfinoError::Backend(format!("{op}: server returned {code}: {body}")),
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow_array::{Int32Array, RecordBatch};
use arrow_schema::{DataType, Field, FieldRef, Fields, Schema};
use super::*;
#[test]
fn schema_json_preserves_nullable_and_types() {
let schema = Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("body", DataType::LargeUtf8, true),
]);
let json = schema_to_json(&schema).expect("schema to json");
assert_eq!(
json[0],
json!({"name": "id", "type": "i32", "nullable": false})
);
assert_eq!(
json[1],
json!({"name": "body", "type": "large_utf8", "nullable": true})
);
}
#[test]
fn schema_json_maps_vector_column() {
let item = Arc::new(Field::new("item", DataType::Float32, true));
let schema = Schema::new(vec![Field::new(
"embedding",
DataType::FixedSizeList(item, 384),
false,
)]);
let json = schema_to_json(&schema).expect("schema to json");
assert_eq!(
json[0],
json!({"name": "embedding", "type": "vector", "dim": 384, "nullable": false})
);
}
#[test]
fn schema_json_rejects_unsupported_type() {
let schema = Schema::new(vec![Field::new("ts", DataType::Date32, true)]);
assert!(matches!(
schema_to_json(&schema),
Err(InfinoError::Schema(_))
));
}
#[test]
fn index_spec_json_shape() {
let spec = IndexSpec::new()
.fts("body")
.vector("embedding", 384, Metric::Cosine);
let json = index_spec_to_json(&spec);
assert_eq!(json["fts"], json!(["body"]));
assert_eq!(
json["vector"][0],
json!({"column": "embedding", "dim": 384, "metric": "cosine"})
);
}
#[test]
fn empty_index_spec_is_empty_object() {
assert_eq!(index_spec_to_json(&IndexSpec::new()), json!({}));
}
#[test]
fn metric_spellings() {
assert_eq!(metric_str(Metric::Cosine), "cosine");
assert_eq!(metric_str(Metric::L2Sq), "l2sq");
assert_eq!(metric_str(Metric::NegDot), "negdot");
}
fn sample_batch() -> RecordBatch {
let fields: Fields = vec![FieldRef::from(Field::new("id", DataType::Int32, false))].into();
let schema = Arc::new(Schema::new(fields));
RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))])
.expect("build batch")
}
#[test]
fn ipc_round_trips() {
let batch = sample_batch();
let bytes = batches_to_ipc(std::slice::from_ref(&batch)).expect("encode");
let back = ipc_to_batches(&bytes).expect("decode");
assert_eq!(back.len(), 1);
assert_eq!(back[0], batch);
}
#[test]
fn empty_batches_round_trip_to_empty() {
assert!(batches_to_ipc(&[]).expect("encode empty").is_empty());
assert!(ipc_to_batches(&[]).expect("decode empty").is_empty());
}
#[test]
fn status_maps_to_typed_errors() {
assert!(matches!(
status_to_error("open_table", 404, "no such table"),
InfinoError::NotFound(_)
));
assert!(matches!(
status_to_error("create_table", 409, "exists"),
InfinoError::AlreadyExists(_)
));
assert!(matches!(
status_to_error("append", 401, "bad key"),
InfinoError::Backend(_)
));
assert!(matches!(
status_to_error("query_sql", 500, "boom"),
InfinoError::Backend(_)
));
}
}