-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Summary
FieldFilter and Form are currently hardwired to HashMap<String, Value> / FormData for hydration and validation. Field names are runtime strings, values are the dynamically-typed Value enum, and type correctness is deferred entirely to runtime TypeMismatch violations. In a statically-typed language like Rust, users should get compiler errors when wiring incompatible rules to fields — not runtime failures.
This issue proposes investigating a trait-based design that lets users validate concrete structs directly, restoring compile-time guarantees while preserving the existing HashMap<String, Value> path for dynamic/config-driven and WASM use cases.
Problem
Current FieldFilter (hardwired to HashMap<String, Value>)
FieldFilter::validate (field_filter.rs) accepts &HashMap<String, Value>:
pub fn validate(&self, data: &HashMap<String, Value>) -> Result<(), FormViolations> { ... }- Field names are runtime strings — a typo in a field name silently passes.
- Field values are the dynamically-typed
Valueenum — aRule::<Value>::MinLengthapplied to aValue::I64produces a runtimeTypeMismatchviolation instead of a compile error. - Rule/value type compatibility is never checked at compile time.
Current Form (hardwired to FormData)
Form::validate (form.rs) delegates to FieldFilter or per-element validation, both operating on FormData (a HashMap<String, Value> newtype):
pub fn validate(&self, data: &FormData) -> Result<(), FormViolations> { ... }
pub fn bind_data(&mut self, data: FormData) -> &mut Self { ... }The same runtime-only type safety issues apply.
What users expect in Rust
Users should be able to write:
struct UserAddress {
street: String,
zip: String,
}…attach validation rules per field with compile-time type checking, and call a single validate(&user_address) method — with the compiler rejecting rule/type mismatches (e.g., Rule::<i64>::Min on a String field).
Proposed Direction
1. Filterable trait
Introduce a trait in walrs_inputfilter that concrete structs implement:
pub trait Filterable: Sized {
/// Validate all fields and cross-field rules.
fn validate(&self) -> Result<(), FormViolations>;
/// Apply per-field filters, consuming and returning Self.
fn filter(self) -> Self;
/// Filter then validate (provided default).
fn process(self) -> Result<Self, FormViolations> {
let filtered = self.filter();
filtered.validate()?;
Ok(filtered)
}
}validate(&self)— validates every field viaRule<T>::validate_ref()/Rule<T>::validate()with the correct concreteTper field, then runs cross-field rules. Collects errors intoFormViolationskeyed by field name.filter(self)— takes ownership, applies per-fieldFilter<T>::apply(), returnsSelf. Ownership avoidsmem::takehacks on non-Defaultfields.process(self)— provided default: filter then validate.
Both manual trait implementation and derive-macro-generated implementations must be supported.
2. Derive macro (#[derive(Filterable)])
A proc-macro crate (walrs_inputfilter_derive) provides #[derive(Filterable)] with field-level attribute annotations:
#[derive(Filterable)]
struct UserAddress {
#[validate(required, min_length = 3)]
#[filter(trim)]
street: String,
#[validate(required, pattern = r"^\d{5}$")]
zip: String,
}Generated code:
validate(&self)constructsRule::Required.and(Rule::MinLength(3))forstreet, callsrule.validate_ref(self.street.as_str()), collects violations keyed by"street".filter(self)constructsFilter::<String>::Trim, applies it toself.street, returnsSelf { street: filtered, ..self }.- Multiple
#[validate(...)]attributes combine viaRule::and(). - Multiple
#[filter(...)]attributes produce aFilter::Chain(...).
Supported annotations:
|#[validate(...)]| Maps to |
|---|---|
|required|Rule::Required|
|min_length = N|Rule::MinLength(N)|
|max_length = N|Rule::MaxLength(N)|
|email|Rule::Email|
|url|Rule::Url|
|pattern = "regex"|Rule::Pattern("regex".into())|
|min = N|Rule::Min(N)|
|max = N|Rule::Max(N)|
|custom = "path::to::fn"|Rule::Custom(Arc::new(path::to::fn))|
|#[filter(...)]| Maps to |
|---|---|
|trim|Filter::Trim|
|lowercase|Filter::Lowercase|
|uppercase|Filter::Uppercase|
|strip_tags|Filter::StripTags|
|slug|Filter::Slug { max_length: None }|
|custom = "path::to::fn"|Filter::Custom(Arc::new(path::to::fn))|
A builder/config-block API for dynamic rule composition should also be supported if feasible alongside attribute annotations. If supporting both in the initial design is too complex, prioritize attribute annotations only.
3. Cross-field validation on concrete types
CrossFieldRuleType currently accesses fields via HashMap::get with string keys. On concrete structs, cross-field rules should be typed closures over &Self:
Manual override approach (MVP):
impl Filterable for Registration {
fn validate(&self) -> Result<(), FormViolations> {
// Call generated per-field checks
let mut v = self.__filterable_validate_fields()?;
if self.password != self.confirm {
v.add_form_violation(
Violation::new(ViolationType::NotEqual, "Passwords must match")
);
}
if v.is_empty() { Ok(()) } else { Err(v) }
}
// filter() uses generated default
}Struct-level attribute approach (stretch goal):
#[derive(Filterable)]
#[cross_validate(passwords_match)]
struct Registration {
password: String,
confirm: String,
}
fn passwords_match(r: &Registration) -> RuleResult {
if r.password == r.confirm { Ok(()) }
else { Err(Violation::new(ViolationType::NotEqual, "Passwords must match")) }
}4. Per-field filtering strategy
Filter<T> enum's apply method takes values by-move (fn apply(&self, value: T) -> T). The generated filter(self) impl applies per-field filters via ownership transfer:
fn filter(self) -> Self {
let street = Filter::<String>::Trim.apply(self.street);
Self { street, ..self }
}This avoids mem::take / Default requirements. Users who need the original struct can clone before calling filter.
5. Form integration with concrete structs
Form::validate and Form::bind_data currently accept only FormData (a HashMap<String, Value> wrapper). The design should investigate:
Form::validate_struct<T: Filterable>(&self, data: &T)— a generic validation path with compile-time checked rules.Form::bind_struct<T: Into<FormData>>(&mut self, data: T)— or a trait-based bridge for hydrating form elements from a concrete struct (the struct providesInto<FormData>).- The derive macro can optionally generate
impl From<MyStruct> for FormDataby iterating fields and callingValue::from(self.field)for each. - The
FormData/HashMap<String, Value>path remains for JSON payloads, WASMweb_sys::FormData, and config-driven forms.
6. Backward compatibility
The HashMap<String, Value> path is preserved for:
- Config-driven/JSON-deserialized forms
- WASM boundaries (
web_sys::FormData) - Dynamic form generation
The struct-basedFilterablepath supplements it. A futureimpl Filterable for HashMap<String, Value>could unify both, but is out of scope for initial design.
Scope
Investigation and design only. Implementation is a follow-up.
Deliverables:
- Design document in
md/plans/with trait signatures, derive macro spec, and code sketches. - Decision on open questions listed below.
Open Questions
- Nested structs — Should
#[derive(Filterable)]support nested struct fields (e.g.,address: AddresswhereAddressis alsoFilterable)? This maps naturally toFormData's dot-notation path resolution but adds design complexity. (Recommendation: support this (if too complex, in a separate github ticket)) FieldFiltergeneric extension — ShouldFieldFilteritself become generic overT: Filterable, or doesFilterablefully replace the need forFieldFilteron concrete types? (Recommendation:FilterablereplacesFieldFilterfor typed use cases;FieldFilterstays as the dynamic path.)- WASM boundary bridge —
Filterablestructs likely needInto<FormData>/TryFrom<FormData>. Should the derive macro generate these automatically? (Recommendation: yes, as opt-in via#[derive(Filterable)]attribute flag.) - Proc-macro crate naming —
walrs_inputfilter_derivevs.walrs_derive(if it will serve multiple crates later). - Usewalrs_inputfilter_derive.
References
- Prior art:
SchemaValidator/#[derive(Validate)]concept (~line 850) - Current
Valueenum:validation/src/value.rs - Current
FieldFilter:inputfilter/src/field_filter.rs - Current
Form:form/src/form.rs - Current
FormData:form/src/form_data.rs Filtertrait:filter/src/traits.rs