Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["magic_map", "magic_map_macros"]
members = ["magic_map", "magic_map_macros", "leaf_provider"]
# `compat/*` are standalone feature-configuration guards built directly in CI,
# kept out of the workspace so feature unification doesn't enable `validate` on
# them (which would defeat their purpose).
Expand Down
103 changes: 63 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,56 +267,82 @@ This is what lets mappers live in a crate that owns neither side — a services
layer between a `*_db` crate and a `*_dtos` crate, with no dependency edge
between the two.

#### Leaves need no declaration
#### Reaching your leaves

Nothing to configure — not the built-in leaves, not your `map_identity!` /
`map_display!` / `map_parse!` declarations, not a `MapFrom` impl you wrote by
hand, not a generic wrapper like a `Patch<T>` update field. Declaring a mapping
is the only way anything is ever registered.
Built-in leaves — primitives, `String`, `Uuid`, `chrono`, `Decimal`,
`serde_json::Value`, the integer widenings — are always present.

That falls out of how the tiers are built. Field resolution probes two of them
by autoref, and the fn form emits **concrete** tier-1 impls — one per declared
pair. A leaf pair therefore has no tier-1 candidate at all, so probing derefs
straight to the tier-2 blanket over `MapFrom` and finds it there.

The obvious alternative does not work, which is worth knowing if you ever touch
this code. A *blanket* tier 1 —
Your own come from the crates that own them. A crate declares its leaves once,
in its root, with `magic_map_leaves!`:

```rust
impl<S, D: LocalMapFrom<S>> ProbeLocal<D> for &mut &mut MapProbe<S, D> { .. }
// leaf-owning crate, e.g. your db crate
magic_map::magic_map_leaves! {
identity: [crate::enums::Species],
display: [crate::enums::Species],
parse: [crate::enums::Species],
// A pair whose impl you wrote by hand. The impl stays where it is; only
// the pair is registered, because a macro cannot see an impl.
custom: [crate::wire::Fahrenheit => String],
}
```

— matches every pair structurally, so the compiler commits to it and then
reports the unsatisfied bound rather than falling through:
and every consumer names the **crate**, never a type:

```text
error[E0277]: the trait bound `u16: LocalMapFrom<u8>` is not satisfied
```rust
magic_map::magic_map_scope!(from: [my_db, my_commons]);
```

Method probing selects a candidate by receiver shape and does **not** retry a
lower tier when a where-bound fails. Concrete impls are what make the fallback
real.

#### Why the trait has to be local
Add an enum to that block and it reaches every consumer with no edit on their
side. Write your own types as `crate::…` — those paths are republished as
`$crate::` so a consumer resolves them against the declaring crate; anything
else (`String`, `::chrono::DateTime<..>`) passes through verbatim.

The shortcut — one blanket forwarding every existing `MapFrom` into the local
trait — is not expressible either:
For a one-off pair whose crate has no block, `leaves:` takes it inline — a bare
type is its identity, `Src => Dest` one direction. A wrapper whose own `MapFrom`
impl is generic goes in `generic_leaves`, `;`-separated so the `where` clause's
commas stay unambiguous:

```rust
// rejected by coherence
impl<S, D: MapFrom<S>> LocalMapFrom<S> for D { .. }
magic_map::magic_map_scope! {
from: [my_db],
leaves: [Celsius, Celsius => String],
generic_leaves: {
<S, D> Patch<S> => Patch<D> where D: ::magic_map::MapFrom<S>;
},
}
```

A pair that never arrives fails at the mapping that needed it, naming both
types: ``the trait bound `String: LocalMapFrom<Celsius>` is not satisfied``.

#### Why the trait is a closed world

Two shortcuts look obvious and neither is available.

A blanket bridge forwarding every existing `MapFrom` into the local trait
overlaps the per-pair impls, and coherence cannot rule the overlap out because
either upstream crate could add the conflicting impl later:

```text
error[E0119]: conflicting implementations of trait `LocalMapFrom<Address>`
for type `AddressResponse`
= note: upstream crates may add a new impl of trait
`MapFrom<Address>` for type `AddressResponse` in future versions
```

It overlaps the per-pair impls and the compiler cannot rule that out, because
either upstream crate could add the impl later. Hence a local trait carrying
only declared pairs, and the probe reaching `MapFrom` for everything else.
Nor can a second, `MapFrom`-backed tier sit underneath to catch leaves.
Autoref tiering needs the tiers told apart by receiver *shape*, as
`MapFieldOpt`/`MapFieldVal`/`MapFieldWrap` are; a tier separated only by a
where-bound hard-errors rather than falling through. And a concrete per-pair
tier is worse than useless: with the destination type still open, probing
matches on the source alone and unifies the destination to whatever that impl
produces — so a type with both a declared mapping and a leaf (an enum with a DTO
twin *and* a `map_display!` to `String`) silently resolves to the wrong one, and
the expected type does not override it.

Hence one funnel, leaves delegated in, and `magic_map_leaves!` so that no
consumer ever transcribes them.

### `let` preludes

Expand Down Expand Up @@ -669,9 +695,9 @@ impl magic_map::MapFrom<MyWireTimestamp> for chrono::DateTime<chrono::Utc> {
}
```

These automap everywhere — impl form and fn form alike. A fn-form crate needs
[`magic_map_scope!()`](#magic_map_scope--the-fn-forms-crate-local-funnel) in
its root, but nothing about your leaves has to be repeated there.
These automap everywhere the impl form is used. To reach them from a **fn-form**
mapping too, declare them with [`magic_map_leaves!`](#reaching-your-leaves)
instead — same impls, plus the published list a consumer's scope replays.

[strum]: https://crates.io/crates/strum

Expand Down Expand Up @@ -708,14 +734,11 @@ decision for the handler that owns the batch, not for the conversion.
crate-root export — rename one or use `#[magic_map(export = "...")]`.
- Destination types must have named fields (or unit variants); tuple structs
are not supported as destinations.
- The fn form needs [`magic_map_scope!()`](#magic_map_scope--the-fn-forms-crate-local-funnel)
in the crate root — once, no arguments. Coherence allows no automatic bridge
from `MapFrom`, so the local trait cannot simply be derived; see that section
for the compiler's own reasoning.
- A declared pair nested inside a *custom generic wrapper* (`Patch<Address>` →
`Patch<AddressResponse>`, as opposed to `Patch<String>` → `Patch<String>`)
has no tier-1 candidate; give that field an explicit override. Wrappers over
leaves, and `Option`/`Vec` over declared pairs, are handled.
- The fn form needs [`magic_map_scope!`](#magic_map_scope--the-fn-forms-crate-local-funnel)
in the crate root, naming the crates whose leaves it uses. Coherence allows no
automatic bridge from `MapFrom` — see that section for the compiler's own
reasoning — so leaves are delegated in, and `magic_map_leaves!` keeps that from
becoming per-consumer bookkeeping.

## License

Expand Down
13 changes: 13 additions & 0 deletions leaf_provider/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Not published — a dev-dependency of `magic_map` so the cross-crate half of
# `magic_map_leaves!` / `leaves_from` is exercised for real. Declaring the block
# and replaying it inside one crate would prove nothing: the whole point is that
# a consumer resolves the paths, which only a crate boundary can test.
[package]
name = "leaf_provider"
version = "0.0.0"
edition = "2021"
publish = false

[dependencies]
magic_map = { path = "../magic_map", features = ["full"] }
strum = { version = "0.27", features = ["derive"] }
41 changes: 41 additions & 0 deletions leaf_provider/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! A leaf-owning crate, as a consumer of `magic_map` would write one.

// One block, in the crate root. Emits the `MapFrom` impls and publishes the
// pair list; nothing downstream restates any of it.
magic_map::magic_map_leaves! {
identity: [crate::enums::Species],
display: [crate::enums::Species],
parse: [crate::enums::Species],
custom: [crate::wire::Fahrenheit => String],
}

pub mod enums {
#[derive(
Clone, Copy, Debug, PartialEq, strum::Display, strum::EnumString, magic_map::MagicMap,
)]
pub enum Species {
Cat,
Lion,
}
}

pub mod wire {
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Fahrenheit(pub i32);

// Hand-written, so no macro can see it — the pair is registered in the
// block above instead.
impl magic_map::MapFrom<Fahrenheit> for String {
fn map_from(src: Fahrenheit) -> Result<Self, magic_map::MappingError> {
Ok(format!("{}F", src.0))
}
}
}

/// A model carrying both leaves, for a downstream mapper to convert.
#[derive(magic_map::MagicMap)]
pub struct Reading {
pub species: enums::Species,
pub species_label: enums::Species,
pub temp: wire::Fahrenheit,
}
1 change: 1 addition & 0 deletions magic_map/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ validate = ["dep:validator", "magic_map_macros/validate"]
full = ["chrono", "decimal", "json", "uuid", "validate"]

[dev-dependencies]
leaf_provider = { path = "../leaf_provider" }
# Integration tests exercise every leaf; enabling `full` on ourselves keeps
# `cargo test` meaningful without --all-features.
magic_map = { path = ".", features = ["full"] }
Expand Down
Loading