use serde::{Deserialize, Serialize};
use crate::error::{Result, SynaError};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub enum FeatureType {
Float64,
Int64,
String,
Bool,
Vector(u16),
Timestamp,
Categorical(u32),
}
impl FeatureType {
pub fn type_name(&self) -> &'static str {
match self {
FeatureType::Float64 => "Float64",
FeatureType::Int64 => "Int64",
FeatureType::String => "String",
FeatureType::Bool => "Bool",
FeatureType::Vector(_) => "Vector",
FeatureType::Timestamp => "Timestamp",
FeatureType::Categorical(_) => "Categorical",
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub enum FeatureValue {
Null,
Float64(f64),
Int64(i64),
String(String),
Bool(bool),
Vector(Vec<f32>),
Timestamp(u64),
Categorical(u32),
}
impl FeatureValue {
pub fn feature_type(&self) -> Option<FeatureType> {
match self {
FeatureValue::Null => None,
FeatureValue::Float64(_) => Some(FeatureType::Float64),
FeatureValue::Int64(_) => Some(FeatureType::Int64),
FeatureValue::String(_) => Some(FeatureType::String),
FeatureValue::Bool(_) => Some(FeatureType::Bool),
FeatureValue::Vector(v) => Some(FeatureType::Vector(v.len() as u16)),
FeatureValue::Timestamp(_) => Some(FeatureType::Timestamp),
FeatureValue::Categorical(_) => Some(FeatureType::Categorical(0)),
}
}
pub fn is_null(&self) -> bool {
matches!(self, FeatureValue::Null)
}
pub fn as_f64(&self) -> Option<f64> {
match self {
FeatureValue::Float64(v) => Some(*v),
_ => None,
}
}
pub fn as_i64(&self) -> Option<i64> {
match self {
FeatureValue::Int64(v) => Some(*v),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct StoredFeatureValue {
pub value: FeatureValue,
pub event_timestamp: u64,
pub ingestion_timestamp: u64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct ColumnConstraints {
pub not_null: bool,
pub min: Option<f64>,
pub max: Option<f64>,
pub regex: Option<String>,
pub allowed_values: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct ColumnDef {
pub name: String,
pub dtype: FeatureType,
pub default: Option<FeatureValue>,
pub constraints: Option<ColumnConstraints>,
pub ttl_seconds: Option<u64>,
pub is_entity_key: bool,
pub is_event_timestamp: bool,
pub deprecated: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct FeatureSchema {
pub name: String,
pub columns: Vec<ColumnDef>,
pub version: u32,
pub description: Option<String>,
pub tags: Vec<String>,
pub created_at: u64,
pub created_by: Option<String>,
}
impl FeatureSchema {
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
return Err(SynaError::InvalidInput(
"Feature schema name cannot be empty".to_string(),
));
}
if self.columns.is_empty() {
return Err(SynaError::InvalidInput(
"Feature schema must have at least one column".to_string(),
));
}
let entity_key_count = self.columns.iter().filter(|c| c.is_entity_key).count();
if entity_key_count != 1 {
return Err(SynaError::InvalidInput(format!(
"Feature schema must have exactly one entity key column, found {}",
entity_key_count
)));
}
let event_ts_count = self.columns.iter().filter(|c| c.is_event_timestamp).count();
if event_ts_count != 1 {
return Err(SynaError::InvalidInput(format!(
"Feature schema must have exactly one event timestamp column, found {}",
event_ts_count
)));
}
let mut seen = std::collections::HashSet::new();
for col in &self.columns {
if col.name.is_empty() {
return Err(SynaError::InvalidInput(
"Column name cannot be empty".to_string(),
));
}
if !seen.insert(&col.name) {
return Err(SynaError::InvalidInput(format!(
"Duplicate column name: '{}'",
col.name
)));
}
}
Ok(())
}
pub fn validate_row(&self, values: &[(&str, FeatureValue)]) -> Result<()> {
for (name, value) in values {
let col = self.columns.iter().find(|c| c.name == *name);
let col = match col {
Some(c) => c,
None => {
return Err(SynaError::InvalidInput(format!(
"Unknown column '{}' in schema '{}'",
name, self.name
)));
}
};
if col.is_entity_key || col.is_event_timestamp {
continue;
}
if value.is_null() {
if let Some(ref constraints) = col.constraints {
if constraints.not_null {
return Err(SynaError::InvalidInput(format!(
"Column '{}': null value not allowed (not_null constraint)",
name
)));
}
}
continue;
}
if !is_type_compatible(&col.dtype, value) {
return Err(SynaError::InvalidInput(format!(
"Column '{}': expected type {}, got {:?}",
name,
col.dtype.type_name(),
value
)));
}
if let Some(ref constraints) = col.constraints {
validate_constraints(name, value, constraints)?;
}
}
for col in &self.columns {
if col.is_entity_key || col.is_event_timestamp {
continue;
}
if let Some(ref constraints) = col.constraints {
if constraints.not_null {
let provided = values.iter().any(|(n, _)| *n == col.name);
if !provided && col.default.is_none() {
return Err(SynaError::InvalidInput(format!(
"Column '{}': required (not_null) but not provided and no default",
col.name
)));
}
}
}
}
Ok(())
}
pub fn entity_key_column(&self) -> &str {
self.columns
.iter()
.find(|c| c.is_entity_key)
.map(|c| c.name.as_str())
.unwrap_or("")
}
pub fn event_timestamp_column(&self) -> &str {
self.columns
.iter()
.find(|c| c.is_event_timestamp)
.map(|c| c.name.as_str())
.unwrap_or("")
}
pub fn feature_columns(&self) -> Vec<&ColumnDef> {
self.columns
.iter()
.filter(|c| !c.is_entity_key && !c.is_event_timestamp)
.collect()
}
}
fn is_type_compatible(dtype: &FeatureType, value: &FeatureValue) -> bool {
match (dtype, value) {
(FeatureType::Float64, FeatureValue::Float64(_)) => true,
(FeatureType::Int64, FeatureValue::Int64(_)) => true,
(FeatureType::String, FeatureValue::String(_)) => true,
(FeatureType::Bool, FeatureValue::Bool(_)) => true,
(FeatureType::Vector(expected_dims), FeatureValue::Vector(v)) => {
v.len() == *expected_dims as usize
}
(FeatureType::Timestamp, FeatureValue::Timestamp(_)) => true,
(FeatureType::Categorical(max_card), FeatureValue::Categorical(v)) => *v < *max_card,
_ => false,
}
}
fn validate_constraints(
name: &str,
value: &FeatureValue,
constraints: &ColumnConstraints,
) -> Result<()> {
if let Some(min) = constraints.min {
let numeric_val = match value {
FeatureValue::Float64(v) => Some(*v),
FeatureValue::Int64(v) => Some(*v as f64),
_ => None,
};
if let Some(v) = numeric_val {
if v < min {
return Err(SynaError::InvalidInput(format!(
"Column '{}': value {} is below minimum {}",
name, v, min
)));
}
}
}
if let Some(max) = constraints.max {
let numeric_val = match value {
FeatureValue::Float64(v) => Some(*v),
FeatureValue::Int64(v) => Some(*v as f64),
_ => None,
};
if let Some(v) = numeric_val {
if v > max {
return Err(SynaError::InvalidInput(format!(
"Column '{}': value {} is above maximum {}",
name, v, max
)));
}
}
}
if let Some(ref pattern) = constraints.regex {
if let FeatureValue::String(s) = value {
let re = regex::Regex::new(pattern).map_err(|e| {
SynaError::InvalidInput(format!(
"Column '{}': invalid regex '{}': {}",
name, pattern, e
))
})?;
if !re.is_match(s) {
return Err(SynaError::InvalidInput(format!(
"Column '{}': value '{}' does not match regex '{}'",
name, s, pattern
)));
}
}
}
if let Some(ref allowed) = constraints.allowed_values {
let str_val = match value {
FeatureValue::String(s) => Some(s.as_str()),
_ => None,
};
if let Some(s) = str_val {
if !allowed.iter().any(|a| a == s) {
return Err(SynaError::InvalidInput(format!(
"Column '{}': value '{}' not in allowed values {:?}",
name, s, allowed
)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_schema() -> FeatureSchema {
FeatureSchema {
name: "test".to_string(),
columns: vec![
ColumnDef {
name: "id".to_string(),
dtype: FeatureType::String,
default: None,
constraints: None,
ttl_seconds: None,
is_entity_key: true,
is_event_timestamp: false,
deprecated: false,
},
ColumnDef {
name: "ts".to_string(),
dtype: FeatureType::Timestamp,
default: None,
constraints: None,
ttl_seconds: None,
is_entity_key: false,
is_event_timestamp: true,
deprecated: false,
},
],
version: 1,
description: None,
tags: vec![],
created_at: 0,
created_by: None,
}
}
#[test]
fn test_valid_schema() {
let schema = minimal_schema();
assert!(schema.validate().is_ok());
}
#[test]
fn test_no_entity_key() {
let mut schema = minimal_schema();
schema.columns[0].is_entity_key = false;
assert!(schema.validate().is_err());
}
#[test]
fn test_no_event_timestamp() {
let mut schema = minimal_schema();
schema.columns[1].is_event_timestamp = false;
assert!(schema.validate().is_err());
}
#[test]
fn test_duplicate_column_names() {
let mut schema = minimal_schema();
schema.columns[1].name = "id".to_string();
assert!(schema.validate().is_err());
}
#[test]
fn test_validate_row_type_mismatch() {
let mut schema = minimal_schema();
schema.columns.push(ColumnDef {
name: "score".to_string(),
dtype: FeatureType::Float64,
default: None,
constraints: None,
ttl_seconds: None,
is_entity_key: false,
is_event_timestamp: false,
deprecated: false,
});
let result = schema.validate_row(&[("score", FeatureValue::String("bad".to_string()))]);
assert!(result.is_err());
}
#[test]
fn test_validate_row_not_null() {
let mut schema = minimal_schema();
schema.columns.push(ColumnDef {
name: "score".to_string(),
dtype: FeatureType::Float64,
default: None,
constraints: Some(ColumnConstraints {
not_null: true,
..Default::default()
}),
ttl_seconds: None,
is_entity_key: false,
is_event_timestamp: false,
deprecated: false,
});
let result = schema.validate_row(&[("score", FeatureValue::Null)]);
assert!(result.is_err());
}
#[test]
fn test_validate_row_min_max() {
let mut schema = minimal_schema();
schema.columns.push(ColumnDef {
name: "score".to_string(),
dtype: FeatureType::Float64,
default: None,
constraints: Some(ColumnConstraints {
min: Some(0.0),
max: Some(100.0),
..Default::default()
}),
ttl_seconds: None,
is_entity_key: false,
is_event_timestamp: false,
deprecated: false,
});
assert!(schema
.validate_row(&[("score", FeatureValue::Float64(50.0))])
.is_ok());
assert!(schema
.validate_row(&[("score", FeatureValue::Float64(-1.0))])
.is_err());
assert!(schema
.validate_row(&[("score", FeatureValue::Float64(101.0))])
.is_err());
}
#[test]
fn test_entity_key_column() {
let schema = minimal_schema();
assert_eq!(schema.entity_key_column(), "id");
}
#[test]
fn test_event_timestamp_column() {
let schema = minimal_schema();
assert_eq!(schema.event_timestamp_column(), "ts");
}
}