use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use arrow::datatypes::{DataType, Field, Fields, Schema, SchemaRef, TimeUnit};
use graphforge_ontology::ontology::{PropertyDef, PropertyValueType};
pub(crate) fn uuid_field(name: &str) -> Field {
Field::new(name, DataType::FixedSizeBinary(16), false)
}
pub(crate) fn id_field(name: &str) -> Field {
Field::new(name, DataType::UInt64, false)
}
#[must_use]
pub fn duration_struct_fields() -> Fields {
Fields::from(vec![
Field::new("months", DataType::Int64, true),
Field::new("days", DataType::Int64, true),
Field::new("seconds", DataType::Int64, true),
Field::new("nanos", DataType::Int64, true),
])
}
#[must_use]
pub fn date_struct_fields() -> Fields {
Fields::from(vec![Field::new("epoch_day", DataType::Int64, true)])
}
#[must_use]
pub fn localdatetime_struct_fields() -> Fields {
Fields::from(vec![
Field::new("date", DataType::Int64, true),
Field::new("time", DataType::Time64(TimeUnit::Nanosecond), true),
])
}
#[must_use]
pub fn time_struct_fields() -> Fields {
Fields::from(vec![
Field::new("time", DataType::Time64(TimeUnit::Nanosecond), true),
Field::new("offset", DataType::Int32, true),
])
}
#[must_use]
pub fn datetime_struct_fields() -> Fields {
Fields::from(vec![
Field::new("date", DataType::Int64, true),
Field::new("time", DataType::Time64(TimeUnit::Nanosecond), true),
Field::new("offset", DataType::Int32, true),
Field::new("zone", DataType::Utf8, true),
])
}
pub(crate) fn ts_field(name: &str) -> Field {
Field::new(
name,
DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
false,
)
}
pub static TOPOLOGY_NODES_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
Arc::new(Schema::new(vec![
uuid_field("node_uuid"),
id_field("node_id"),
Field::new("type_id", DataType::UInt32, false),
Field::new(
"type_ids",
DataType::List(Arc::new(Field::new("item", DataType::UInt32, false))),
false,
),
ts_field("created_at"),
ts_field("updated_at"),
]))
});
pub static TYPED_EDGE_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
Arc::new(Schema::new(vec![
uuid_field("edge_uuid"),
uuid_field("src_uuid"),
uuid_field("dst_uuid"),
id_field("edge_id"),
id_field("src_id"),
id_field("dst_id"),
ts_field("created_at"),
]))
});
pub static EXPLORATORY_EDGE_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
let mut fields: Vec<Field> = TYPED_EDGE_SCHEMA
.fields()
.iter()
.map(|f| f.as_ref().clone())
.collect();
fields.push(Field::new("rel_type_name", DataType::Utf8, false));
Arc::new(Schema::new(fields))
});
pub static PROPERTY_BASE_SCHEMA: LazyLock<SchemaRef> =
LazyLock::new(|| Arc::new(Schema::new(vec![uuid_field("node_uuid")])));
pub static EDGE_PROPERTY_BASE_SCHEMA: LazyLock<SchemaRef> =
LazyLock::new(|| Arc::new(Schema::new(vec![uuid_field("edge_uuid")])));
pub(crate) fn adjacency_entry_fields() -> Fields {
Fields::from(vec![id_field("edge_id"), id_field("neighbor_id")])
}
pub static ADJACENCY_CSR_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
Arc::new(Schema::new(vec![Field::new(
"adjacency",
DataType::LargeList(Arc::new(Field::new(
"item",
DataType::Struct(adjacency_entry_fields()),
false,
))),
false,
)]))
});
pub static ADJACENCY_MANIFEST_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
Arc::new(Schema::new(vec![
Field::new("relation_type", DataType::Utf8, false),
Field::new("direction", DataType::Utf8, false),
Field::new("topology_generation", DataType::UInt64, false),
ts_field("built_at"),
Field::new("node_count", DataType::UInt64, false),
Field::new("edge_count", DataType::UInt64, false),
]))
});
pub static ADJACENCY_DELTA_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
Arc::new(Schema::new(vec![
Field::new("rel_type_name", DataType::Utf8, false),
Field::new("edge_id", DataType::UInt64, false),
Field::new("src_id", DataType::UInt64, false),
Field::new("dst_id", DataType::UInt64, false),
]))
});
#[must_use]
pub fn property_type_to_arrow(vt: &PropertyValueType) -> DataType {
match vt {
PropertyValueType::Utf8 => DataType::Utf8,
PropertyValueType::Int64 => DataType::Int64,
PropertyValueType::Float64 => DataType::Float64,
PropertyValueType::Bool => DataType::Boolean,
PropertyValueType::Duration => DataType::Struct(duration_struct_fields()),
PropertyValueType::DateTime => {
DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into()))
}
PropertyValueType::List | PropertyValueType::Map => DataType::LargeUtf8,
}
}
#[must_use]
pub fn property_schema(entity_type: &str, property_defs: &[PropertyDef]) -> Schema {
let mut fields = vec![uuid_field("node_uuid")];
for def in property_defs {
fields.push(Field::new(
&def.name,
property_type_to_arrow(&def.value_type),
def.nullable,
));
}
let meta: HashMap<String, String> =
[("graphforge.entity_type".to_owned(), entity_type.to_owned())]
.into_iter()
.collect();
Schema::new(fields).with_metadata(meta)
}
#[must_use]
pub fn result_schema(
fields: Vec<Field>,
query_id: &str,
ontology_ver: &str,
ir_ver: &str,
) -> Schema {
debug_assert!(
fields.iter().all(|f| !f.name().ends_with("_id")),
"result_schema: surrogate '*_id' columns must not appear in public API results \
(offending fields: {:?})",
fields
.iter()
.filter(|f| f.name().ends_with("_id"))
.map(arrow::datatypes::Field::name)
.collect::<Vec<_>>()
);
let meta: HashMap<String, String> = [
("graphforge.query_id".to_owned(), query_id.to_owned()),
(
"graphforge.ontology_version".to_owned(),
ontology_ver.to_owned(),
),
("graphforge.ir_version".to_owned(), ir_ver.to_owned()),
]
.into_iter()
.collect();
Schema::new(fields).with_metadata(meta)
}
#[cfg(test)]
mod tests {
use super::*;
use graphforge_ontology::ontology::PropertyValueType;
#[test]
fn topology_nodes_schema_field_names() {
let s = &*TOPOLOGY_NODES_SCHEMA;
assert_eq!(s.fields().len(), 6);
let names: Vec<&str> = s.fields().iter().map(|f| f.name().as_str()).collect();
assert_eq!(
names,
[
"node_uuid",
"node_id",
"type_id",
"type_ids",
"created_at",
"updated_at"
]
);
}
#[test]
fn typed_edge_schema_field_types() {
let s = &*TYPED_EDGE_SCHEMA;
assert_eq!(s.fields().len(), 7);
assert_eq!(
s.fields()
.iter()
.map(|field| field.name().as_str())
.collect::<Vec<_>>(),
[
"edge_uuid",
"src_uuid",
"dst_uuid",
"edge_id",
"src_id",
"dst_id",
"created_at",
]
);
for name in ["edge_uuid", "src_uuid", "dst_uuid"] {
let f = s.field_with_name(name).unwrap();
assert_eq!(
f.data_type(),
&DataType::FixedSizeBinary(16),
"{name} should be FixedSizeBinary(16)"
);
}
for name in ["edge_id", "src_id", "dst_id"] {
let f = s.field_with_name(name).unwrap();
assert_eq!(f.data_type(), &DataType::UInt64, "{name} should be UInt64");
}
}
#[test]
fn exploratory_edge_schema_has_rel_type_name() {
let s = &*EXPLORATORY_EDGE_SCHEMA;
assert_eq!(s.fields().len(), TYPED_EDGE_SCHEMA.fields().len() + 1);
let last = s.fields().last().unwrap();
assert_eq!(last.name(), "rel_type_name");
assert_eq!(last.data_type(), &DataType::Utf8);
assert!(!last.is_nullable());
}
#[test]
fn exploratory_edge_schema_extends_typed_edge_schema() {
let typed = &*TYPED_EDGE_SCHEMA;
let exploratory = &*EXPLORATORY_EDGE_SCHEMA;
for (i, typed_field) in typed.fields().iter().enumerate() {
assert_eq!(
exploratory.field(i).as_ref(),
typed_field.as_ref(),
"field {i} mismatch between TYPED and EXPLORATORY schemas"
);
}
}
#[test]
fn property_type_to_arrow_all_variants() {
assert_eq!(
property_type_to_arrow(&PropertyValueType::Utf8),
DataType::Utf8
);
assert_eq!(
property_type_to_arrow(&PropertyValueType::Int64),
DataType::Int64
);
assert_eq!(
property_type_to_arrow(&PropertyValueType::Float64),
DataType::Float64
);
assert_eq!(
property_type_to_arrow(&PropertyValueType::Bool),
DataType::Boolean
);
assert_eq!(
property_type_to_arrow(&PropertyValueType::Duration),
DataType::Struct(duration_struct_fields())
);
assert_eq!(
property_type_to_arrow(&PropertyValueType::DateTime),
DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into()))
);
assert_eq!(
property_type_to_arrow(&PropertyValueType::List),
DataType::LargeUtf8
);
assert_eq!(
property_type_to_arrow(&PropertyValueType::Map),
DataType::LargeUtf8
);
}
#[test]
fn property_schema_roundtrip() {
use graphforge_ontology::ontology::PropertyDef;
let defs = vec![
PropertyDef {
owner: "Person".into(),
name: "name".into(),
value_type: PropertyValueType::Utf8,
nullable: false,
multivalued: false,
default_json: None,
},
PropertyDef {
owner: "Person".into(),
name: "age".into(),
value_type: PropertyValueType::Int64,
nullable: true,
multivalued: false,
default_json: None,
},
];
let schema = property_schema("Person", &defs);
assert_eq!(schema.fields().len(), 3);
assert_eq!(schema.field(0).name(), "node_uuid");
assert_eq!(schema.field(1).name(), "name");
assert_eq!(schema.field(1).data_type(), &DataType::Utf8);
assert!(!schema.field(1).is_nullable());
assert_eq!(schema.field(2).name(), "age");
assert_eq!(schema.field(2).data_type(), &DataType::Int64);
assert!(schema.field(2).is_nullable());
assert_eq!(
schema.metadata().get("graphforge.entity_type"),
Some(&"Person".to_owned())
);
}
#[test]
fn result_schema_metadata() {
let schema = result_schema(
vec![Field::new("n_name", DataType::Utf8, true)],
"qid-123",
"sha256:abc",
"0.1.0",
);
let meta = schema.metadata();
assert_eq!(meta.get("graphforge.query_id"), Some(&"qid-123".to_owned()));
assert_eq!(
meta.get("graphforge.ontology_version"),
Some(&"sha256:abc".to_owned())
);
assert_eq!(meta.get("graphforge.ir_version"), Some(&"0.1.0".to_owned()));
assert_eq!(schema.fields().len(), 1);
}
#[test]
fn adjacency_csr_schema_shape() {
let s = &*ADJACENCY_CSR_SCHEMA;
assert_eq!(s.fields().len(), 1);
let f = s.field(0);
assert_eq!(f.name(), "adjacency");
assert!(!f.is_nullable());
let DataType::LargeList(item) = f.data_type() else {
panic!("adjacency should be a LargeList, got {:?}", f.data_type());
};
assert!(!item.is_nullable());
let DataType::Struct(entry) = item.data_type() else {
panic!("list item should be a Struct, got {:?}", item.data_type());
};
let names: Vec<&str> = entry.iter().map(|f| f.name().as_str()).collect();
assert_eq!(names, ["edge_id", "neighbor_id"]);
for f in entry {
assert_eq!(f.data_type(), &DataType::UInt64);
assert!(!f.is_nullable());
}
}
#[test]
fn adjacency_manifest_schema_field_names_and_types() {
let s = &*ADJACENCY_MANIFEST_SCHEMA;
let names: Vec<&str> = s.fields().iter().map(|f| f.name().as_str()).collect();
assert_eq!(
names,
[
"relation_type",
"direction",
"topology_generation",
"built_at",
"node_count",
"edge_count"
]
);
for name in ["relation_type", "direction"] {
let f = s.field_with_name(name).unwrap();
assert_eq!(f.data_type(), &DataType::Utf8);
}
for name in ["topology_generation", "node_count", "edge_count"] {
let f = s.field_with_name(name).unwrap();
assert_eq!(f.data_type(), &DataType::UInt64);
}
assert_eq!(
s.field_with_name("built_at").unwrap().data_type(),
&DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into()))
);
assert!(s.fields().iter().all(|f| !f.is_nullable()));
}
#[test]
fn result_schema_allows_uuid_fields() {
let schema = result_schema(
vec![
Field::new("node_uuid", DataType::FixedSizeBinary(16), false),
Field::new("name", DataType::Utf8, true),
],
"q1",
"v1",
"0.1.0",
);
assert_eq!(schema.fields().len(), 2);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "surrogate '*_id' columns must not appear")]
fn result_schema_rejects_surrogate_id_field() {
let _ = result_schema(
vec![Field::new("node_id", DataType::UInt64, false)],
"q1",
"v1",
"0.1.0",
);
}
}