use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const DATA_CAPSULE_FORMAT: &str = "autumn-data-capsule";
pub const DATA_CAPSULE_FORMAT_VERSION: u32 = 1;
pub type Record = serde_json::Map<String, serde_json::Value>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Relationship {
pub column: String,
pub target: String,
pub target_column: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CapsuleModel {
pub table: String,
pub primary_key: String,
pub subject_column: String,
pub relationships: Vec<Relationship>,
pub blob_columns: Vec<String>,
pub excluded: Vec<String>,
}
impl CapsuleModel {
#[must_use]
pub fn new(table: impl Into<String>, subject_column: impl Into<String>) -> Self {
Self {
table: table.into(),
primary_key: "id".to_owned(),
subject_column: subject_column.into(),
relationships: Vec::new(),
blob_columns: Vec::new(),
excluded: Vec::new(),
}
}
#[must_use]
pub fn primary_key(mut self, column: impl Into<String>) -> Self {
self.primary_key = column.into();
self
}
#[must_use]
pub fn belongs_to(self, column: impl Into<String>, target: impl Into<String>) -> Self {
self.references(column, target, "id")
}
#[must_use]
pub fn references(
mut self,
column: impl Into<String>,
target: impl Into<String>,
target_column: impl Into<String>,
) -> Self {
self.relationships.push(Relationship {
column: column.into(),
target: target.into(),
target_column: target_column.into(),
});
self
}
#[must_use]
pub fn blob(mut self, column: impl Into<String>) -> Self {
self.blob_columns.push(column.into());
self
}
#[must_use]
pub fn exclude(mut self, column: impl Into<String>) -> Self {
self.excluded.push(column.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FieldSpec {
pub name: String,
pub data_type: String,
#[serde(default)]
pub nullable: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub generated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_type: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub has_default: bool,
}
impl FieldSpec {
#[must_use]
pub fn new(name: impl Into<String>, data_type: impl Into<String>) -> Self {
Self {
name: name.into(),
data_type: data_type.into(),
nullable: false,
generated: false,
base_type: None,
has_default: false,
}
}
#[must_use]
pub const fn nullable(mut self) -> Self {
self.nullable = true;
self
}
#[must_use]
pub const fn generated(mut self) -> Self {
self.generated = true;
self
}
#[must_use]
pub const fn with_default(mut self) -> Self {
self.has_default = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ModelManifest {
pub table: String,
pub primary_key: String,
pub subject_column: String,
pub fields: Vec<FieldSpec>,
pub relationships: Vec<Relationship>,
#[serde(default)]
pub blob_columns: Vec<String>,
pub record_count: u64,
pub file: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BlobEntry {
pub key: String,
pub sha256: String,
pub content_type: String,
pub byte_size: u64,
}
impl BlobEntry {
#[must_use]
pub fn file(&self) -> String {
format!("blobs/{}", self.sha256)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CapsuleManifest {
pub format: String,
pub format_version: u32,
pub subject: String,
pub generated_at: String,
pub framework_version: String,
pub models: Vec<ModelManifest>,
#[serde(default)]
pub blobs: Vec<BlobEntry>,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
impl CapsuleManifest {
pub(super) fn new(subject: &str) -> Self {
Self {
format: DATA_CAPSULE_FORMAT.to_owned(),
format_version: DATA_CAPSULE_FORMAT_VERSION,
subject: subject.to_owned(),
generated_at: crate::time::ambient_now().to_rfc3339(),
framework_version: env!("CARGO_PKG_VERSION").to_owned(),
models: Vec::new(),
blobs: Vec::new(),
files: BTreeMap::new(),
}
}
#[must_use]
pub fn model(&self, table: &str) -> Option<&ModelManifest> {
self.models.iter().find(|m| m.table == table)
}
#[must_use]
pub fn blob(&self, key: &str) -> Option<&BlobEntry> {
self.blobs.iter().find(|b| b.key == key)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DataCapsuleError {
#[error("capsule I/O error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("capsule file {file} is not valid: {message}")]
Json {
file: String,
message: String,
},
#[error("name is not safe: {0:?}")]
InvalidName(String),
#[error("input is not valid: {0}")]
InvalidInput(String),
#[error("capsule integrity check failed: {0}")]
Integrity(String),
#[error("unsupported capsule format: {0}")]
UnsupportedFormat(String),
#[error("no signing secret: set AUTUMN_SECURITY__SIGNING_SECRET")]
MissingSigningSecret,
#[error("table is not registered for capsules: {0}")]
UnknownTable(String),
#[error("relationship cycle between tables: {0}")]
RelationshipCycle(String),
#[error("record conflict: {0}")]
Conflict(String),
#[error("directory is not empty: {}", .0.display())]
NotEmpty(PathBuf),
#[error("capsule store error: {0}")]
Store(String),
#[error("capsule blob error: {0}")]
Blob(String),
#[error("data capsules are not configured: {0}")]
NotConfigured(String),
}
impl DataCapsuleError {
pub(super) fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
Self::Io {
path: path.into(),
source,
}
}
}
#[must_use]
pub(super) fn is_safe_ident(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
name.len() <= 63
&& (first.is_ascii_alphabetic() || first == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub(super) fn check_ident(name: &str) -> Result<(), DataCapsuleError> {
if is_safe_ident(name) {
Ok(())
} else {
Err(DataCapsuleError::InvalidName(name.to_owned()))
}
}
pub(super) fn check_model_names(
table: &str,
primary_key: &str,
subject_column: &str,
relationships: &[Relationship],
blob_columns: &[String],
) -> Result<(), DataCapsuleError> {
check_ident(table)?;
check_ident(primary_key)?;
check_ident(subject_column)?;
for rel in relationships {
check_ident(&rel.column)?;
check_ident(&rel.target)?;
check_ident(&rel.target_column)?;
}
blob_columns.iter().try_for_each(|c| check_ident(c))
}
pub(super) fn check_field_names(table: &str, fields: &[FieldSpec]) -> Result<(), DataCapsuleError> {
let mut names = BTreeSet::new();
for field in fields {
check_ident(&field.name)?;
if !names.insert(field.name.as_str()) {
return Err(DataCapsuleError::InvalidInput(format!(
"{table}.{} is described twice in the capsule",
field.name
)));
}
}
Ok(())
}
pub(super) fn record_file(table: &str) -> String {
format!("records/{table}.json")
}
pub(super) fn value_key(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_ident_accepts_plain_names() {
for name in ["users", "_t", "Post2", "a_b_c"] {
assert!(is_safe_ident(name), "{name}");
}
}
#[test]
fn safe_ident_rejects_unsafe_names() {
let long = "a".repeat(64);
for name in [
"",
"1a",
"a-b",
"a b",
"a.b",
"a\"b",
"../x",
"é",
long.as_str(),
] {
assert!(!is_safe_ident(name), "{name}");
}
}
#[test]
fn value_key_reads_scalars_only() {
assert_eq!(value_key(&serde_json::json!(7)).as_deref(), Some("7"));
assert_eq!(value_key(&serde_json::json!("a")).as_deref(), Some("a"));
assert_eq!(value_key(&serde_json::json!(true)).as_deref(), Some("true"));
assert_eq!(value_key(&serde_json::json!(null)), None);
assert_eq!(value_key(&serde_json::json!({"a": 1})), None);
}
#[test]
fn model_builder_sets_defaults() {
let m = CapsuleModel::new("posts", "author_id")
.belongs_to("author_id", "users")
.references("org", "orgs", "slug")
.blob("cover")
.primary_key("post_id");
assert_eq!(m.primary_key, "post_id");
assert_eq!(m.relationships[0].target_column, "id");
assert_eq!(m.relationships[1].target_column, "slug");
assert_eq!(m.blob_columns, ["cover"]);
}
#[test]
fn base_type_is_written_only_when_set() {
let json = serde_json::to_string(&FieldSpec::new("a", "money")).unwrap();
assert!(!json.contains("base_type"), "{json}");
let mut field = FieldSpec::new("a", "cash");
field.base_type = Some("money".to_owned());
let json = serde_json::to_string(&field).unwrap();
assert!(json.contains("\"base_type\":\"money\""), "{json}");
let back: FieldSpec = serde_json::from_str(&json).unwrap();
assert_eq!(back, field);
}
#[test]
fn generated_flag_is_not_written_when_false() {
let json = serde_json::to_string(&FieldSpec::new("a", "text")).unwrap();
assert!(!json.contains("generated"), "{json}");
let json = serde_json::to_string(&FieldSpec::new("a", "text").generated()).unwrap();
assert!(json.contains("\"generated\":true"), "{json}");
}
}