use crate::content::ContentType;
use crate::error::InvalidError;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct IndexField {
pub name: String,
pub pointer: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_type: Option<FieldType>,
}
impl IndexField {
pub fn new(name: impl Into<String>, pointer: impl Into<String>) -> Self {
Self {
name: name.into(),
pointer: pointer.into(),
field_type: None,
}
}
pub fn typed(
name: impl Into<String>,
pointer: impl Into<String>,
field_type: FieldType,
) -> Self {
Self {
name: name.into(),
pointer: pointer.into(),
field_type: Some(field_type),
}
}
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::VariantArray,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum FieldType {
Text,
Int,
Float,
Bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct IndexSchema {
pub fields: Vec<IndexField>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vector_field: Option<String>,
#[serde(default)]
pub inline_payload: bool,
}
impl IndexSchema {
pub fn builder() -> IndexSchemaBuilder {
IndexSchemaBuilder::default()
}
}
#[derive(Default)]
pub struct IndexSchemaBuilder {
schema: IndexSchema,
}
impl IndexSchemaBuilder {
pub fn field(mut self, name: impl Into<String>) -> Self {
let name = name.into();
let pointer = format!("/{name}");
self.schema.fields.push(IndexField::new(name, pointer));
self
}
pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
self.schema.fields.push(IndexField::new(name, pointer));
self
}
pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
self.schema.vector_field = Some(pointer.into());
self
}
pub fn inline_payload(mut self) -> Self {
self.schema.inline_payload = true;
self
}
pub fn build(self) -> IndexSchema {
self.schema
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProjectionId(String);
impl ProjectionId {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ProjectionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ProjectionId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for ProjectionId {
fn borrow(&self) -> &str {
&self.0
}
}
impl FromStr for ProjectionId {
type Err = InvalidError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
return Err(InvalidError::new("projection id must not be empty"));
}
Ok(Self(s.to_owned()))
}
}
impl From<&str> for ProjectionId {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl From<String> for ProjectionId {
fn from(value: String) -> Self {
Self(value)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Projection {
pub id: ProjectionId,
pub name: String,
pub version: u32,
pub content_type: ContentType,
pub extraction: IndexSchema,
#[serde(default)]
pub inline_payload_default: bool,
}
impl Projection {
pub fn builder(id: impl Into<ProjectionId>) -> ProjectionBuilder {
ProjectionBuilder {
projection: Self {
id: id.into(),
name: String::new(),
version: 1,
content_type: ContentType::Any,
extraction: IndexSchema {
fields: Vec::new(),
vector_field: None,
inline_payload: true,
},
inline_payload_default: true,
},
}
}
}
pub struct ProjectionBuilder {
projection: Projection,
}
impl ProjectionBuilder {
pub fn name(mut self, value: impl Into<String>) -> Self {
self.projection.name = value.into();
self
}
pub fn version(mut self, value: u32) -> Self {
self.projection.version = value;
self
}
pub fn content_type(mut self, value: ContentType) -> Self {
self.projection.content_type = value;
self
}
pub fn extraction(mut self, value: IndexSchema) -> Self {
self.projection.extraction = value;
self
}
pub fn field(mut self, name: impl Into<String>) -> Self {
let name = name.into();
let pointer = format!("/{name}");
self.projection
.extraction
.fields
.push(IndexField::new(name, pointer));
self
}
pub fn fields<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for name in names {
let name = name.into();
let pointer = format!("/{name}");
self.projection
.extraction
.fields
.push(IndexField::new(name, pointer));
}
self
}
pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
self.projection
.extraction
.fields
.push(IndexField::new(name, pointer));
self
}
pub fn field_typed(mut self, name: impl Into<String>, field_type: FieldType) -> Self {
let name = name.into();
let pointer = format!("/{name}");
self.projection
.extraction
.fields
.push(IndexField::typed(name, pointer, field_type));
self
}
pub fn field_at_typed(
mut self,
name: impl Into<String>,
pointer: impl Into<String>,
field_type: FieldType,
) -> Self {
self.projection
.extraction
.fields
.push(IndexField::typed(name, pointer, field_type));
self
}
pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
self.projection.extraction.vector_field = Some(pointer.into());
self
}
pub fn inline_payload(mut self) -> Self {
self.projection.inline_payload_default = true;
self.projection.extraction.inline_payload = true;
self
}
pub fn index_only(mut self) -> Self {
self.projection.inline_payload_default = false;
self.projection.extraction.inline_payload = false;
self
}
pub fn build(self) -> Projection {
self.projection
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum RetentionPolicy {
#[default]
MirrorLog,
Keep,
KeepUntilSourceDeleted,
TimeToLive { ttl_micros: u64 },
MaxRows { rows: u64 },
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProjectionBinding {
pub source: SourceSelector,
#[serde(default)]
pub allowed_projections: Vec<ProjectionId>,
#[serde(default)]
pub default_projection: Option<ProjectionId>,
pub targets: Vec<Target>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention: Option<RetentionPolicy>,
}
impl ProjectionBinding {
pub fn builder() -> ProjectionBindingBuilder {
ProjectionBindingBuilder::default()
}
}
#[derive(Default)]
pub struct ProjectionBindingBuilder {
source: Option<SourceSelector>,
allowed: Vec<ProjectionId>,
default_projection: Option<ProjectionId>,
targets: Vec<Target>,
retention: Option<RetentionPolicy>,
}
impl ProjectionBindingBuilder {
pub fn source(mut self, stream: impl Into<String>, topic: impl Into<String>) -> Self {
self.source = Some(SourceSelector::new(stream, topic));
self
}
pub fn selector(mut self, source: SourceSelector) -> Self {
self.source = Some(source);
self
}
pub fn allow(mut self, projection: impl Into<ProjectionId>) -> Self {
self.allowed.push(projection.into());
self
}
pub fn default_projection(mut self, projection: impl Into<ProjectionId>) -> Self {
self.default_projection = Some(projection.into());
self
}
pub fn add_target(mut self, target: Target) -> Self {
self.targets.push(target);
self
}
pub fn retention(mut self, retention: RetentionPolicy) -> Self {
self.retention = Some(retention);
self
}
pub fn target_table(self, table: impl Into<String>) -> Self {
self.target_on("embedded", table)
}
pub fn target_on(self, backend: impl Into<String>, table: impl Into<String>) -> Self {
self.add_target(Target {
backend: backend.into(),
table: table.into(),
role: TargetRole::ReadWrite,
delivery: Delivery::EffectivelyOnce,
required: true,
})
}
pub fn mirror_to(self, backend: impl Into<String>, table: impl Into<String>) -> Self {
self.add_target(Target {
backend: backend.into(),
table: table.into(),
role: TargetRole::WriteOnly,
delivery: Delivery::EffectivelyOnce,
required: false,
})
}
pub fn build(self) -> ProjectionBinding {
self.try_build()
.expect("ProjectionBinding requires a source - call .source(stream, topic)")
}
pub fn try_build(self) -> Result<ProjectionBinding, InvalidError> {
let source = self
.source
.ok_or_else(|| InvalidError::new("ProjectionBinding requires a source"))?;
let targets = if self.targets.is_empty() {
vec![Target {
backend: "embedded".to_owned(),
table: source.topic.clone(),
role: TargetRole::ReadWrite,
delivery: Delivery::EffectivelyOnce,
required: true,
}]
} else {
self.targets
};
Ok(ProjectionBinding {
source,
allowed_projections: self.allowed,
default_projection: self.default_projection,
targets,
retention: self.retention,
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SourceSelector {
pub stream: String,
pub topic: String,
}
impl SourceSelector {
pub fn new(stream: impl Into<String>, topic: impl Into<String>) -> Self {
Self {
stream: stream.into(),
topic: topic.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Target {
pub backend: String,
pub table: String,
#[serde(default)]
pub role: TargetRole,
#[serde(default)]
pub delivery: Delivery,
#[serde(default)]
pub required: bool,
}
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::VariantArray,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum TargetRole {
#[default]
ReadWrite,
WriteOnly,
}
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::VariantArray,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Delivery {
#[default]
EffectivelyOnce,
AtMostOnce,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SchemaDef {
pub id: u32,
pub source: SchemaSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
}
impl SchemaDef {
pub fn content_type(&self) -> ContentType {
match self.source {
SchemaSource::Avro { .. } => ContentType::Avro,
SchemaSource::Protobuf { .. } => ContentType::Protobuf,
SchemaSource::JsonSchema { .. } => ContentType::Json,
SchemaSource::Unknown => ContentType::Any,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum SchemaSource {
Avro { schema: String },
Protobuf {
#[serde(with = "crate::encoding::bin_bytes")]
descriptor_set: Vec<u8>,
message_type: String,
},
JsonSchema { schema: String },
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ControlCommand {
RegisterProjection(Projection),
DropProjection(String),
ApplyBinding(ProjectionBinding),
RemoveBinding {
source: SourceSelector,
projection_ref: Option<String>,
},
RegisterSchema(SchemaDef),
DropSchema(u32),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ControlEnvelope {
pub v: u32,
pub timestamp_micros: u64,
pub command: ControlCommand,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_an_unknown_schema_source_kind_when_decoded_then_should_degrade_not_fail() {
let source: SchemaSource =
serde_json::from_str(r#"{"kind":"parquet_descriptor","blob":[1,2,3]}"#)
.expect("an unknown schema kind decodes to Unknown, not an error");
assert_eq!(source, SchemaSource::Unknown);
let def = SchemaDef {
id: 1,
source,
name: None,
version: None,
};
assert_eq!(def.content_type(), ContentType::Any);
}
#[test]
fn given_an_unknown_retention_kind_when_decoded_then_should_degrade_not_fail() {
let policy: RetentionPolicy = serde_json::from_str(r#"{"kind":"keep_for_eras","eras":3}"#)
.expect("an unknown retention kind decodes to Unknown, not an error");
assert_eq!(policy, RetentionPolicy::Unknown);
}
#[test]
fn given_target_table_sugar_when_built_then_should_be_single_read_write_target() {
let binding = ProjectionBinding::builder()
.source("shop", "orders")
.allow("order.v1")
.target_table("orders_rows")
.build();
assert_eq!(binding.targets.len(), 1);
assert_eq!(binding.targets[0].backend, "embedded");
assert_eq!(binding.targets[0].table, "orders_rows");
assert_eq!(binding.targets[0].role, TargetRole::ReadWrite);
assert_eq!(binding.targets[0].delivery, Delivery::EffectivelyOnce);
assert!(binding.targets[0].required);
}
#[test]
fn given_target_on_named_backend_when_built_then_should_route_read_write_to_it() {
let binding = ProjectionBinding::builder()
.source("shop", "orders")
.allow("order.v1")
.target_on("warehouse", "orders_rows")
.build();
assert_eq!(binding.targets.len(), 1);
assert_eq!(binding.targets[0].backend, "warehouse");
assert_eq!(binding.targets[0].table, "orders_rows");
assert_eq!(binding.targets[0].role, TargetRole::ReadWrite);
assert!(binding.targets[0].required);
}
#[test]
fn given_target_on_and_mirror_to_when_built_then_should_fan_one_projection_to_two_backends() {
let binding = ProjectionBinding::builder()
.source("shop", "orders")
.allow("order.v1")
.target_on("embedded", "orders_rows")
.mirror_to("warehouse", "orders_warehouse")
.build();
assert_eq!(binding.targets.len(), 2);
assert_eq!(binding.targets[0].role, TargetRole::ReadWrite);
assert_eq!(binding.targets[0].backend, "embedded");
assert_eq!(binding.targets[1].role, TargetRole::WriteOnly);
assert_eq!(binding.targets[1].backend, "warehouse");
assert_eq!(binding.targets[1].table, "orders_warehouse");
assert!(!binding.targets[1].required, "a mirror is non-blocking");
}
#[test]
fn given_a_read_write_target_and_a_mirror_when_added_then_should_keep_both_in_order() {
let binding = ProjectionBinding::builder()
.source("shop", "orders")
.allow("order.v1")
.target_table("orders_rows")
.add_target(Target {
backend: "warehouse".to_owned(),
table: "orders_mirror".to_owned(),
role: TargetRole::WriteOnly,
delivery: Delivery::AtMostOnce,
required: false,
})
.build();
assert_eq!(binding.targets.len(), 2);
assert_eq!(binding.targets[0].role, TargetRole::ReadWrite);
assert_eq!(binding.targets[1].role, TargetRole::WriteOnly);
assert_eq!(binding.targets[1].backend, "warehouse");
}
#[test]
fn given_no_retention_when_built_then_should_default_to_none() {
let binding = ProjectionBinding::builder()
.source("shop", "orders")
.target_table("orders_rows")
.build();
assert_eq!(binding.retention, None);
}
#[test]
fn given_no_source_when_try_built_then_should_error() {
assert!(ProjectionBinding::builder().try_build().is_err());
}
#[test]
fn given_a_projection_built_with_the_default_when_inspected_then_should_inline_payload() {
let projection = Projection::builder("api.call.v1")
.name("api.call")
.version(1)
.fields(["endpoint", "status"])
.build();
assert!(
projection.inline_payload_default,
"Projection::builder default should inline payload"
);
assert!(
projection.extraction.inline_payload,
"Projection::builder default should mark extraction.inline_payload too"
);
}
#[test]
fn given_a_projection_with_index_only_when_inspected_then_should_skip_inlining() {
let projection = Projection::builder("api.call.v1")
.name("api.call")
.version(1)
.fields(["endpoint"])
.index_only()
.build();
assert!(
!projection.inline_payload_default,
"index_only must clear inline_payload_default"
);
assert!(
!projection.extraction.inline_payload,
"index_only must clear extraction.inline_payload"
);
}
#[test]
fn given_an_empty_projection_id_when_parsed_then_should_error() {
assert!("".parse::<ProjectionId>().is_err());
assert_eq!(
"order.v1"
.parse::<ProjectionId>()
.expect("non-empty id parses")
.as_str(),
"order.v1"
);
}
}
#[cfg(all(test, feature = "cbor"))]
mod wire_tests {
use super::*;
use crate::codes::CONTROL_OP_VERSION;
use crate::content::ContentType;
use crate::framing::{decode_named, encode_named};
#[test]
fn given_an_apply_binding_when_round_tripped_then_should_preserve_targets_and_version() {
let binding = ProjectionBinding::builder()
.source("shop", "orders")
.allow("order.v1")
.default_projection("order.v1")
.target_table("orders_rows")
.add_target(Target {
backend: "warehouse".to_owned(),
table: "orders_mirror".to_owned(),
role: TargetRole::WriteOnly,
delivery: Delivery::AtMostOnce,
required: false,
})
.build();
let envelope = ControlEnvelope {
v: CONTROL_OP_VERSION,
timestamp_micros: 42,
command: ControlCommand::ApplyBinding(binding),
};
let bytes = encode_named(&envelope).expect("envelope serializes");
let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
assert_eq!(back.v, CONTROL_OP_VERSION);
let ControlCommand::ApplyBinding(decoded) = back.command else {
panic!("expected ApplyBinding");
};
assert_eq!(decoded.targets.len(), 2);
assert_eq!(decoded.targets[0].table, "orders_rows");
assert_eq!(decoded.targets[0].role, TargetRole::ReadWrite);
assert_eq!(decoded.targets[1].table, "orders_mirror");
assert_eq!(decoded.targets[1].delivery, Delivery::AtMostOnce);
assert_eq!(decoded.retention, None);
}
#[test]
fn given_a_register_schema_when_round_tripped_then_should_preserve_source() {
let envelope = ControlEnvelope {
v: CONTROL_OP_VERSION,
timestamp_micros: 7,
command: ControlCommand::RegisterSchema(SchemaDef {
id: 11,
source: SchemaSource::Avro {
schema: r#"{"type":"record","name":"Order","fields":[]}"#.to_owned(),
},
name: None,
version: None,
}),
};
let bytes = encode_named(&envelope).expect("envelope serializes");
let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
let ControlCommand::RegisterSchema(decoded) = back.command else {
panic!("expected RegisterSchema");
};
assert_eq!(decoded.id, 11);
assert_eq!(decoded.content_type(), ContentType::Avro);
}
#[test]
fn given_a_protobuf_schema_source_when_round_tripped_then_should_preserve_bytes() {
let source = SchemaSource::Protobuf {
descriptor_set: vec![10, 20, 30],
message_type: "shop.Order".to_owned(),
};
let bytes = encode_named(&source).expect("serializes");
let back: SchemaSource = decode_named(&bytes).expect("deserializes");
assert_eq!(back, source);
}
#[test]
fn given_retention_set_when_round_tripped_then_should_preserve_policy() {
for policy in [
RetentionPolicy::MirrorLog,
RetentionPolicy::Keep,
RetentionPolicy::KeepUntilSourceDeleted,
RetentionPolicy::TimeToLive {
ttl_micros: 3_600_000_000,
},
RetentionPolicy::MaxRows { rows: 10_000 },
] {
let binding = ProjectionBinding::builder()
.source("shop", "telemetry")
.allow("telemetry.v1")
.target_table("telemetry_rows")
.retention(policy)
.build();
assert_eq!(binding.retention, Some(policy));
let bytes = encode_named(&binding).expect("binding serializes");
let back: ProjectionBinding = decode_named(&bytes).expect("binding deserializes");
assert_eq!(back.retention, Some(policy));
}
}
}
#[cfg(all(test, feature = "codecs"))]
mod schema_tests {
use super::*;
use crate::framing::{decode_named, encode_named};
#[test]
fn given_a_schema_def_with_version_when_round_tripped_then_should_preserve_it() {
let def = SchemaDef {
id: 7,
source: SchemaSource::Avro {
schema: "{}".to_owned(),
},
name: Some("orders".to_owned()),
version: Some(2),
};
let bytes = encode_named(&def).expect("serializes");
let back: SchemaDef = decode_named(&bytes).expect("deserializes");
assert_eq!(back.version, Some(2));
let unversioned = SchemaDef {
name: None,
version: None,
..def
};
let json = serde_json::to_string(&unversioned).expect("serializes");
assert!(
!json.contains("version"),
"unset version must be omitted: {json}"
);
}
}