Add unknown ranges and open object values
This commit is contained in:
@@ -35,12 +35,38 @@ fn expand_decodal(input: DeriveInput) -> Result<TokenStream2> {
|
||||
|
||||
let mut schema_fields = Vec::new();
|
||||
let mut decode_fields = Vec::new();
|
||||
let mut known_field_roots = Vec::new();
|
||||
let mut rest_field = None;
|
||||
|
||||
for field in fields {
|
||||
let ident = field.ident.expect("named field");
|
||||
let ty = field.ty;
|
||||
let attrs = FieldAttrs::from_attrs(&field.attrs, &ident.to_string())?;
|
||||
|
||||
if attrs.rest {
|
||||
if attrs.rename_explicit || attrs.default.is_some() || !attrs.constraints.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
"`rest` cannot be combined with rename, default, or field constraints",
|
||||
));
|
||||
}
|
||||
if rest_field.is_some() {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
"Decodal derive supports only one `rest` field",
|
||||
));
|
||||
}
|
||||
rest_field = Some((ident, ty));
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = attrs.rename.clone();
|
||||
known_field_roots.push(
|
||||
path.split('.')
|
||||
.next()
|
||||
.expect("field paths are non-empty")
|
||||
.to_owned(),
|
||||
);
|
||||
let schema_value = schema_expr(&ty, &attrs)?;
|
||||
let decode_value = decode_expr(&ty, &path, &attrs);
|
||||
|
||||
@@ -52,14 +78,40 @@ fn expand_decodal(input: DeriveInput) -> Result<TokenStream2> {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some((ident, ty)) = &rest_field {
|
||||
decode_fields.push(quote! {
|
||||
#ident: <#ty as ::decodal::DecodalRest>::decodal_decode_rest(
|
||||
data,
|
||||
&[#(#known_field_roots),*],
|
||||
)?
|
||||
});
|
||||
}
|
||||
|
||||
let schema_entries = if schema_fields.is_empty() {
|
||||
quote! { ::core::iter::empty::<(&'static str, ::decodal::Value)>() }
|
||||
} else {
|
||||
quote! { [#(#schema_fields),*] }
|
||||
};
|
||||
|
||||
let build_schema = if let Some((_, ty)) = &rest_field {
|
||||
quote! {
|
||||
::decodal::Value::object_from_paths_with_rest(
|
||||
#schema_entries,
|
||||
<#ty as ::decodal::DecodalRest>::decodal_rest_schema(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
::decodal::Value::object_from_paths(#schema_entries)
|
||||
}
|
||||
};
|
||||
|
||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||
|
||||
Ok(quote! {
|
||||
impl #impl_generics ::decodal::DecodalSchema for #name #ty_generics #where_clause {
|
||||
fn decodal_schema() -> ::decodal::HostValue {
|
||||
::decodal::HostValue::object_from_paths([
|
||||
#(#schema_fields),*
|
||||
])
|
||||
fn decodal_schema() -> ::decodal::Value {
|
||||
#build_schema
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +128,8 @@ fn expand_decodal(input: DeriveInput) -> Result<TokenStream2> {
|
||||
#[derive(Default)]
|
||||
struct FieldAttrs {
|
||||
rename: String,
|
||||
rename_explicit: bool,
|
||||
rest: bool,
|
||||
default: Option<DefaultAttr>,
|
||||
constraints: Vec<ConstraintAttr>,
|
||||
}
|
||||
@@ -95,6 +149,14 @@ impl FieldAttrs {
|
||||
if meta.path.is_ident("rename") {
|
||||
let value: LitStr = meta.value()?.parse()?;
|
||||
output.rename = value.value();
|
||||
output.rename_explicit = true;
|
||||
return Ok(());
|
||||
}
|
||||
if meta.path.is_ident("rest") {
|
||||
if meta.input.peek(syn::Token![=]) || meta.input.peek(syn::token::Paren) {
|
||||
return Err(meta.error("`rest` does not accept a value"));
|
||||
}
|
||||
output.rest = true;
|
||||
return Ok(());
|
||||
}
|
||||
if meta.path.is_ident("default") {
|
||||
@@ -182,7 +244,7 @@ fn schema_expr(ty: &Type, attrs: &FieldAttrs) -> Result<TokenStream2> {
|
||||
};
|
||||
tokens.extend(quote! {
|
||||
schema = schema
|
||||
.default(::decodal::IntoHostValue::into_host_value(#default_expr))
|
||||
.default(::decodal::IntoValue::into_value(#default_expr))
|
||||
.expect("Decodal derive generated a valid default");
|
||||
});
|
||||
}
|
||||
@@ -210,3 +272,42 @@ fn decode_expr(ty: &Type, path: &str, attrs: &FieldAttrs) -> TokenStream2 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_multiple_rest_fields() {
|
||||
let input: DeriveInput = syn::parse_quote! {
|
||||
struct Invalid {
|
||||
#[decodal(rest)]
|
||||
first: std::collections::BTreeMap<String, String>,
|
||||
#[decodal(rest)]
|
||||
second: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
expand_decodal(input)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("only one `rest` field")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_rest_field_modifiers() {
|
||||
let input: DeriveInput = syn::parse_quote! {
|
||||
struct Invalid {
|
||||
#[decodal(rest, default)]
|
||||
extra: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
expand_decodal(input)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("cannot be combined")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use decodal::{Data, DecodalDecode, DecodalSchema, EmptyLoader, Engine};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use decodal::{Constraint, Data, DecodalDecode, DecodalSchema, EmptyLoader, Engine, Value};
|
||||
use decodal_derive::Decodal;
|
||||
|
||||
#[derive(Debug, PartialEq, Decodal)]
|
||||
@@ -24,6 +26,28 @@ struct Fleet {
|
||||
workers: Vec<Worker>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Decodal)]
|
||||
struct OpenConfig {
|
||||
name: String,
|
||||
#[decodal(default = true, rename = "feature.enabled")]
|
||||
enabled: bool,
|
||||
#[decodal(rest)]
|
||||
extra: BTreeMap<String, Data>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Decodal)]
|
||||
struct StringLabels {
|
||||
name: String,
|
||||
#[decodal(rest)]
|
||||
labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Decodal)]
|
||||
struct OpenBag {
|
||||
#[decodal(rest)]
|
||||
values: BTreeMap<String, Data>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_schema_and_decode() {
|
||||
let mut engine = Engine::new(EmptyLoader);
|
||||
@@ -148,3 +172,97 @@ fn vec_schema_applies_nested_struct_defaults() {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_map_opens_the_derived_object_and_collects_additional_fields() {
|
||||
let schema = OpenConfig::decodal_schema();
|
||||
let Value::Object {
|
||||
fields,
|
||||
rest: Some(rest),
|
||||
} = schema
|
||||
else {
|
||||
panic!("derived schema should have an object rest range")
|
||||
};
|
||||
assert_eq!(fields.len(), 2);
|
||||
assert!(matches!(
|
||||
*rest,
|
||||
Value::Range { ref constraints, .. }
|
||||
if constraints == &[Constraint::Unknown]
|
||||
));
|
||||
|
||||
let mut engine = Engine::new(EmptyLoader);
|
||||
engine
|
||||
.bind_global("OpenConfig", OpenConfig::decodal_schema())
|
||||
.unwrap();
|
||||
let module = engine
|
||||
.add_root_source(
|
||||
"test",
|
||||
"test",
|
||||
r#"
|
||||
{
|
||||
name = "api";
|
||||
plugin = { name = "cache"; };
|
||||
retries = 3;
|
||||
} as OpenConfig
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let value = engine.eval_module(module).unwrap();
|
||||
let data = engine.materialize(&value).unwrap();
|
||||
let config = OpenConfig::decodal_decode(&data).unwrap();
|
||||
|
||||
assert_eq!(config.name, "api");
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.extra.get("retries"), Some(&Data::Int(3)));
|
||||
assert!(matches!(config.extra.get("plugin"), Some(Data::Object(_))));
|
||||
assert!(!config.extra.contains_key("name"));
|
||||
assert!(!config.extra.contains_key("feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_rest_map_constrains_each_additional_field() {
|
||||
let mut engine = Engine::new(EmptyLoader);
|
||||
engine
|
||||
.bind_global("StringLabels", StringLabels::decodal_schema())
|
||||
.unwrap();
|
||||
let module = engine
|
||||
.add_root_source(
|
||||
"test",
|
||||
"test",
|
||||
r#"{ name = "service"; region = "ap-northeast-1"; tier = "edge"; } as StringLabels"#,
|
||||
)
|
||||
.unwrap();
|
||||
let value = engine.eval_module(module).unwrap();
|
||||
let data = engine.materialize(&value).unwrap();
|
||||
let labels = StringLabels::decodal_decode(&data).unwrap();
|
||||
assert_eq!(labels.labels.get("region"), Some(&"ap-northeast-1".into()));
|
||||
assert_eq!(labels.labels.get("tier"), Some(&"edge".into()));
|
||||
|
||||
let module = engine
|
||||
.add_root_source(
|
||||
"invalid",
|
||||
"invalid",
|
||||
r#"{ name = "service"; priority = 1; } as StringLabels"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(engine.eval_module(module).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_only_struct_collects_every_field() {
|
||||
let schema = OpenBag::decodal_schema();
|
||||
assert!(matches!(
|
||||
schema,
|
||||
Value::Object {
|
||||
ref fields,
|
||||
rest: Some(_),
|
||||
} if fields.is_empty()
|
||||
));
|
||||
|
||||
let data = Data::Object(vec![decodal::runtime::DataField {
|
||||
name: "answer".into(),
|
||||
value: Data::Int(42),
|
||||
}]);
|
||||
let bag = OpenBag::decodal_decode(&data).unwrap();
|
||||
assert_eq!(bag.values.get("answer"), Some(&Data::Int(42)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user