use std::borrow::Cow;
use std::sync::Arc;
use crate::{DateTime, Document, DocumentError, DocumentSettings};
#[non_exhaustive]
#[derive(Clone, Debug, Default)]
pub struct DiscriminatedDocument {
document: Document,
discriminator: Option<String>,
settings: Option<Arc<dyn DocumentSettings>>,
}
impl DiscriminatedDocument {
pub fn new(document: Document) -> Self {
Self {
document,
discriminator: None,
settings: None,
}
}
pub fn with_discriminator(mut self, fqn: impl Into<String>) -> Self {
let fqn = fqn.into();
debug_assert!(
fqn.contains('#'),
"discriminator `{fqn}` must be an absolute shape id (namespace#name)"
);
self.discriminator = Some(fqn);
self
}
pub fn with_settings(mut self, settings: Arc<dyn DocumentSettings>) -> Self {
self.settings = Some(settings);
self
}
#[cfg(feature = "test-util")]
#[doc(hidden)]
pub fn set_discriminator_unchecked(&mut self, discriminator: impl Into<String>) {
self.discriminator = Some(discriminator.into());
}
pub fn discriminator(&self) -> Option<&str> {
self.discriminator.as_deref()
}
pub fn settings(&self) -> Option<&Arc<dyn DocumentSettings>> {
self.settings.as_ref()
}
pub fn document(&self) -> &Document {
&self.document
}
pub fn into_document(self) -> Document {
self.document
}
pub fn as_blob(&self) -> Result<Cow<'_, [u8]>, DocumentError> {
match &self.document {
Document::Blob(b) => Ok(Cow::Borrowed(b.as_slice())),
Document::String(s) => match &self.settings {
Some(settings) => settings.coerce_string_to_blob(s).map(Cow::Owned),
None => Err(DocumentError::unsupported(
"cannot coerce string to blob without protocol-specific document settings",
)),
},
other => Err(DocumentError::type_mismatch(format!(
"expected blob, found {}",
document_variant_name(other)
))),
}
}
pub fn as_timestamp(&self) -> Result<DateTime, DocumentError> {
match (&self.document, &self.settings) {
(Document::Timestamp(t), _) => Ok(*t),
(Document::String(s), Some(settings)) => settings.coerce_string_to_timestamp(s),
(Document::Number(n), Some(settings)) => settings.coerce_number_to_timestamp(n),
(Document::String(_), None) | (Document::Number(_), None) => {
Err(DocumentError::unsupported(
"cannot coerce string/number to timestamp without protocol-specific document \
settings",
))
}
(other, _) => Err(DocumentError::type_mismatch(format!(
"expected timestamp, found {}",
document_variant_name(other)
))),
}
}
}
impl PartialEq for DiscriminatedDocument {
fn eq(&self, other: &Self) -> bool {
self.document == other.document && self.discriminator == other.discriminator
}
}
impl From<Document> for DiscriminatedDocument {
fn from(document: Document) -> Self {
Self::new(document)
}
}
fn document_variant_name(d: &Document) -> &'static str {
match d {
Document::Null => "null",
Document::Bool(_) => "boolean",
Document::Number(_) => "number",
Document::String(_) => "string",
Document::Blob(_) => "blob",
Document::Timestamp(_) => "timestamp",
Document::BigInteger(_) => "bigInteger",
Document::BigDecimal(_) => "bigDecimal",
Document::Array(_) => "array",
Document::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Number;
#[derive(Debug)]
struct TestSettings {
protocol: String,
}
impl DocumentSettings for TestSettings {
fn protocol_id(&self) -> &str {
&self.protocol
}
fn coerce_string_to_blob(&self, s: &str) -> Result<Vec<u8>, DocumentError> {
Ok(s.as_bytes().to_vec())
}
fn coerce_string_to_timestamp(&self, _s: &str) -> Result<DateTime, DocumentError> {
Ok(DateTime::from_secs(0))
}
fn coerce_number_to_timestamp(&self, n: &Number) -> Result<DateTime, DocumentError> {
let secs = match n {
Number::PosInt(v) => *v as i64,
Number::NegInt(v) => *v,
Number::Float(v) => *v as i64,
};
Ok(DateTime::from_secs(secs))
}
}
fn test_settings() -> Arc<dyn DocumentSettings> {
Arc::new(TestSettings {
protocol: "com.example#Test".to_owned(),
})
}
#[test]
fn new_attaches_no_discriminator_or_settings() {
let d = DiscriminatedDocument::new(Document::String("hi".to_owned()));
assert_eq!(d.discriminator(), None);
assert!(d.settings().is_none());
assert_eq!(d.document().as_string(), Some("hi"));
}
#[test]
fn with_discriminator_attaches_fqn() {
let d =
DiscriminatedDocument::new(Document::Null).with_discriminator("com.example#MyShape");
assert_eq!(d.discriminator(), Some("com.example#MyShape"));
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "absolute shape id")]
fn with_discriminator_rejects_relative_id() {
let _ = DiscriminatedDocument::new(Document::Null).with_discriminator("RelativeOnly");
}
#[test]
fn with_settings_attaches_settings() {
let d = DiscriminatedDocument::new(Document::String("x".to_owned()))
.with_settings(test_settings());
assert!(d.settings().is_some());
assert_eq!(d.settings().unwrap().protocol_id(), "com.example#Test");
}
#[test]
fn into_document_unwraps_to_inner() {
let inner = Document::String("hi".to_owned());
let d = DiscriminatedDocument::new(inner.clone()).with_discriminator("com.example#X");
assert_eq!(d.into_document(), inner);
}
#[test]
fn from_document_blanket_impl_works() {
let d: DiscriminatedDocument = Document::Bool(true).into();
assert_eq!(d.document().as_bool(), Some(true));
assert_eq!(d.discriminator(), None);
}
#[test]
fn partial_eq_compares_document_and_discriminator_only() {
let a = DiscriminatedDocument::new(Document::String("x".to_owned()))
.with_discriminator("com.example#A");
let b = DiscriminatedDocument::new(Document::String("x".to_owned()))
.with_discriminator("com.example#A")
.with_settings(test_settings());
assert_eq!(a, b);
let c = DiscriminatedDocument::new(Document::String("x".to_owned()))
.with_discriminator("com.example#B");
assert_ne!(a, c);
let d = DiscriminatedDocument::new(Document::String("y".to_owned()))
.with_discriminator("com.example#A");
assert_ne!(a, d);
}
#[test]
fn as_blob_returns_borrowed_for_native_blob_variant() {
let d = DiscriminatedDocument::new(Document::Blob(b"hi".to_vec()));
match d.as_blob().unwrap() {
Cow::Borrowed(bytes) => assert_eq!(bytes, b"hi"),
Cow::Owned(_) => panic!("expected Cow::Borrowed for native Blob"),
}
}
#[test]
fn as_blob_native_works_without_settings_attached() {
let d = DiscriminatedDocument::new(Document::Blob(b"hi".to_vec()));
assert!(d.settings().is_none());
assert!(d.as_blob().is_ok());
}
#[test]
fn as_blob_coerces_string_when_settings_present() {
let d = DiscriminatedDocument::new(Document::String("hello".to_owned()))
.with_settings(test_settings());
match d.as_blob().unwrap() {
Cow::Owned(bytes) => assert_eq!(bytes, b"hello"),
Cow::Borrowed(_) => panic!("expected Cow::Owned for coerced String"),
}
}
#[test]
fn as_blob_string_without_settings_is_unsupported_operation() {
let d = DiscriminatedDocument::new(Document::String("hello".to_owned()));
let err = d.as_blob().unwrap_err();
assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
}
#[test]
fn as_blob_type_mismatch_for_non_blob_non_string_variants() {
let d = DiscriminatedDocument::new(Document::Number(Number::PosInt(42)))
.with_settings(test_settings());
let err = d.as_blob().unwrap_err();
assert!(matches!(err, DocumentError::TypeMismatch { .. }));
}
#[test]
fn as_timestamp_returns_direct_for_native_timestamp_variant() {
let ts = DateTime::from_secs(1234);
let d = DiscriminatedDocument::new(Document::Timestamp(ts));
assert_eq!(d.as_timestamp().unwrap(), ts);
}
#[test]
fn as_timestamp_native_works_without_settings() {
let ts = DateTime::from_secs(1);
let d = DiscriminatedDocument::new(Document::Timestamp(ts));
assert!(d.settings().is_none());
assert_eq!(d.as_timestamp().unwrap(), ts);
}
#[test]
fn as_timestamp_coerces_string_with_settings() {
let d = DiscriminatedDocument::new(Document::String("any string".to_owned()))
.with_settings(test_settings());
assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(0));
}
#[test]
fn as_timestamp_coerces_number_with_settings() {
let d = DiscriminatedDocument::new(Document::Number(Number::PosInt(1234)))
.with_settings(test_settings());
assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(1234));
}
#[test]
fn as_timestamp_string_without_settings_is_unsupported() {
let d = DiscriminatedDocument::new(Document::String("ignored".to_owned()));
let err = d.as_timestamp().unwrap_err();
assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
}
#[test]
fn as_timestamp_number_without_settings_is_unsupported() {
let d = DiscriminatedDocument::new(Document::Number(Number::PosInt(0)));
let err = d.as_timestamp().unwrap_err();
assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
}
#[test]
fn as_timestamp_type_mismatch_for_non_coercible_variant() {
let d = DiscriminatedDocument::new(Document::Bool(true)).with_settings(test_settings());
let err = d.as_timestamp().unwrap_err();
assert!(matches!(err, DocumentError::TypeMismatch { .. }));
}
#[test]
fn default_settings_methods_return_unsupported_operation() {
#[derive(Debug)]
struct MinimalSettings;
impl DocumentSettings for MinimalSettings {
fn protocol_id(&self) -> &str {
"com.example#Minimal"
}
}
let s = MinimalSettings;
assert!(matches!(
s.coerce_string_to_blob("anything"),
Err(DocumentError::UnsupportedOperation { .. })
));
assert!(matches!(
s.coerce_string_to_timestamp("anything"),
Err(DocumentError::UnsupportedOperation { .. })
));
assert!(matches!(
s.coerce_number_to_timestamp(&Number::PosInt(0)),
Err(DocumentError::UnsupportedOperation { .. })
));
}
}