use std::collections::{BTreeMap, BTreeSet};
use arrow::datatypes::SchemaRef;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use snafu::{Backtrace, prelude::*};
use crate::metadata::{
index::IndexSpec,
logical_schema::{LogicalSchema, LogicalToArrowSchemaError},
protocol::{TABLE_PROTOCOL_VERSION, deserialize_required_features},
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TableKind {
TimeSeries(IndexSpec),
Generic,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(try_from = "RawTableMeta")]
pub struct TableMeta {
pub(crate) kind: TableKind,
pub(crate) logical_schema: Option<LogicalSchema>,
pub(crate) created_at: DateTime<Utc>,
pub(crate) protocol_version: u32,
pub(crate) required_reader_features: BTreeSet<String>,
pub(crate) required_writer_features: BTreeSet<String>,
}
#[derive(Deserialize)]
struct RawTableMeta {
kind: TableKind,
logical_schema: Option<LogicalSchema>,
created_at: DateTime<Utc>,
protocol_version: u32,
#[serde(deserialize_with = "deserialize_required_features")]
required_reader_features: BTreeSet<String>,
#[serde(deserialize_with = "deserialize_required_features")]
required_writer_features: BTreeSet<String>,
#[serde(flatten)]
extra: BTreeMap<String, serde_json::Value>,
}
impl TryFrom<RawTableMeta> for TableMeta {
type Error = String;
fn try_from(raw: RawTableMeta) -> Result<Self, Self::Error> {
if raw.extra.contains_key("format_version") {
return Err("legacy field 'format_version' is not valid protocol-v7 metadata".into());
}
Ok(Self {
kind: raw.kind,
logical_schema: raw.logical_schema,
created_at: raw.created_at,
protocol_version: raw.protocol_version,
required_reader_features: raw.required_reader_features,
required_writer_features: raw.required_writer_features,
})
}
}
#[derive(Debug, Snafu)]
#[non_exhaustive]
pub enum TableArrowSchemaError {
#[snafu(display("table has no canonical logical schema yet (logical_schema is None)"))]
MissingCanonicalSchema {
backtrace: Backtrace,
},
#[snafu(display("Failed to convert the table logical schema to Arrow: {source}"))]
Conversion {
#[snafu(source, backtrace)]
source: LogicalToArrowSchemaError,
},
}
impl TableMeta {
pub fn kind(&self) -> &TableKind {
&self.kind
}
pub fn logical_schema(&self) -> Option<&LogicalSchema> {
self.logical_schema.as_ref()
}
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub fn new_time_series(index: IndexSpec) -> Self {
TableMeta {
kind: TableKind::TimeSeries(index),
logical_schema: None,
created_at: Utc::now(),
protocol_version: TABLE_PROTOCOL_VERSION,
required_reader_features: BTreeSet::new(),
required_writer_features: BTreeSet::new(),
}
}
pub fn new_time_series_with_schema(index: IndexSpec, logical_schema: LogicalSchema) -> Self {
TableMeta {
kind: TableKind::TimeSeries(index),
logical_schema: Some(logical_schema),
created_at: Utc::now(),
protocol_version: TABLE_PROTOCOL_VERSION,
required_reader_features: BTreeSet::new(),
required_writer_features: BTreeSet::new(),
}
}
pub fn arrow_schema_ref(&self) -> Result<SchemaRef, TableArrowSchemaError> {
let logical = self
.logical_schema
.as_ref()
.ok_or_else(|| MissingCanonicalSchemaSnafu.build())?;
logical.to_arrow_schema_ref().context(ConversionSnafu)
}
}
pub type TableMetaDelta = TableMeta;
#[cfg(test)]
mod tests {
use std::error::Error as _;
use chrono::TimeZone;
use snafu::ErrorCompat;
use crate::metadata::{
index::{IndexKind, IndexSpec, TimeIndexGranularity},
logical_schema::{LogicalDataType, LogicalField},
protocol::TABLE_PROTOCOL_VERSION,
};
use super::*;
fn sample_time_index_spec() -> IndexSpec {
IndexSpec {
column: "ts".to_string(),
entity_columns: vec!["symbol".to_string()],
kind: IndexKind::Timestamp {
index_granularity: TimeIndexGranularity::Minutes(1),
timezone: None,
},
}
}
#[test]
fn table_meta_baseline_protocol_json_is_stable() {
let mut meta = TableMeta::new_time_series(sample_time_index_spec());
meta.created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap();
assert_eq!(
serde_json::to_value(meta).unwrap(),
serde_json::json!({
"kind": {
"TimeSeries": {
"column": "ts",
"entity_columns": ["symbol"],
"kind": {
"type": "timestamp",
"index_granularity": {"Minutes": 1}
}
}
},
"logical_schema": null,
"created_at": "2025-01-01T00:00:00Z",
"protocol_version": 7,
"required_reader_features": [],
"required_writer_features": []
})
);
}
#[test]
fn table_meta_protocol_fields_roundtrip_canonically() {
let mut meta = TableMeta::new_time_series(sample_time_index_spec());
meta.required_reader_features.insert("z_reader".to_string());
meta.required_reader_features.insert("a_reader".to_string());
meta.required_writer_features.insert("writer_2".to_string());
let mut json = serde_json::to_value(&meta).unwrap();
assert_eq!(json["protocol_version"], TABLE_PROTOCOL_VERSION);
assert_eq!(
json["required_reader_features"],
serde_json::json!(["a_reader", "z_reader"])
);
assert_eq!(
json["required_writer_features"],
serde_json::json!(["writer_2"])
);
assert!(json.get("format_version").is_none());
json["future_field"] = serde_json::json!({"ignored": true});
let decoded: TableMeta = serde_json::from_value(json).unwrap();
assert_eq!(decoded, meta);
}
#[test]
fn table_meta_rejects_legacy_format_version_field() {
let mut json =
serde_json::to_value(TableMeta::new_time_series(sample_time_index_spec())).unwrap();
json["format_version"] = serde_json::json!(6);
let error = serde_json::from_value::<TableMeta>(json).unwrap_err();
assert!(error.to_string().contains("legacy field 'format_version'"));
}
#[test]
fn table_meta_arrow_schema_ref_requires_logical_schema() {
let meta = TableMeta::new_time_series(sample_time_index_spec());
let err = meta.arrow_schema_ref().unwrap_err();
assert!(matches!(
&err,
TableArrowSchemaError::MissingCanonicalSchema { .. }
));
assert!(ErrorCompat::backtrace(&err).is_some());
}
#[test]
fn table_meta_arrow_schema_ref_propagates_convert_error() {
let logical = LogicalSchema::new(vec![LogicalField {
name: "legacy_ts".to_string(),
data_type: LogicalDataType::Int96,
nullable: false,
}])
.expect("valid schema structure");
let meta = TableMeta::new_time_series_with_schema(sample_time_index_spec(), logical);
let err = meta.arrow_schema_ref().unwrap_err();
let table_backtrace = ErrorCompat::backtrace(&err).expect("table schema backtrace");
let conversion = err
.source()
.and_then(|source| source.downcast_ref::<LogicalToArrowSchemaError>())
.expect("Arrow conversion source");
let conversion_backtrace =
ErrorCompat::backtrace(conversion).expect("conversion backtrace");
assert!(matches!(
conversion,
LogicalToArrowSchemaError::Int96Unsupported { column, .. }
if column == "legacy_ts"
));
assert!(std::ptr::eq(table_backtrace, conversion_backtrace));
}
}