Add unknown ranges and open object values

This commit is contained in:
2026-08-14 08:00:22 +09:00
parent 8c50dd202d
commit 848c7f169f
53 changed files with 9047 additions and 6572 deletions
+106 -5
View File
@@ -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")
);
}
}
+119 -1
View File
@@ -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)));
}