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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ jobs:
# Gating guard: `validator` users who don't enable `magic_map/validate`
# must still compile (no `.validate()` call is generated).
- run: cargo build --manifest-path compat/no-validate-feature/Cargo.toml
- run: cargo build --manifest-path compat/scope-downstream/Cargo.toml
4 changes: 2 additions & 2 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
Expand Up @@ -7,7 +7,7 @@ members = ["magic_map", "magic_map_macros"]
exclude = ["compat"]

[workspace.package]
version = "0.2.1"
version = "0.3.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/rsansores/magic_map"
Expand Down
117 changes: 116 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,17 @@ disappears in either direction.
When **neither** type is local — `wire::Lion` comes out of prost codegen,
`db::Lion` out of the ORM, and you're mapping them in a neutral service
crate — the orphan rule forbids any trait impl. The fn form generates a plain
function instead:
function instead.

> **One-time setup:** a crate that uses the fn form must call
> [`magic_map_scope!()`](#magic_map_scope--the-fn-forms-crate-local-funnel)
> once in its crate root. Skip it and the first fn-form mapping fails with
> ``could not find `__magic_map_scope` in the crate root``.

```rust
// src/lib.rs — once per crate
magic_map::magic_map_scope!();
```

```rust
mod wire {
Expand Down Expand Up @@ -215,6 +225,99 @@ let bad = lion_to_db(wire::Lion { id: "not-a-uuid".into(), name: "?".into() });
assert!(bad.is_err());
```

### `magic_map_scope!` — the fn form's crate-local funnel

**When you need it:** any crate that declares a fn-form mapping. Call it once,
at the crate root (`lib.rs`, `main.rs`, or the top of an integration test —
integration tests are their own crate). Crates whose mappings are all impl
form never call it.

**Why it exists.** A mapping's *fields* funnel through `MapFrom` too. That is
fine for the impl form, which leaves a `MapFrom` impl behind for the next
mapping to find. The fn form leaves none — so before this existed, a nested
field whose own mapping was also foreign→foreign had nothing to resolve
against, and you hand-wrote the recursion:

```rust
// the old way — every nested pair spelled out
magic_map!(pub fn postal_code_to_dto: db::PostalCode => dtos::PostalCodeResponse {
state: state_to_dto(src.state)?,
locality: src.locality.map(locality_to_dto).transpose()?,
neighborhoods: src.neighborhoods.into_iter()
.map(locality_to_dto).collect::<Result<_, _>>()?,
});
```

`magic_map_scope!()` plants a trait in *your* crate. The orphan rule is
satisfied by a local trait just as well as by a local type, so
`impl LocalMapFrom<Address> for AddressResponse` is legal there even though
both types are foreign — and the fn form now emits one for every mapping it
declares. Nested pairs, `Option`, `Vec` and the `..Default::default()` adaptor
all compose again:

```rust
magic_map!(pub fn state_to_dto: db::State => dtos::StateResponse);
magic_map!(pub fn locality_to_dto: db::Locality => dtos::LocalityResponse);

// nested State, Option<Locality>, Vec<Locality> — no overrides needed
magic_map!(pub fn postal_code_to_dto: db::PostalCode => dtos::PostalCodeResponse);
```

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

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.

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 —

```rust
impl<S, D: LocalMapFrom<S>> ProbeLocal<D> for &mut &mut MapProbe<S, D> { .. }
```

— matches every pair structurally, so the compiler commits to it and then
reports the unsatisfied bound rather than falling through:

```text
error[E0277]: the trait bound `u16: LocalMapFrom<u8>` is not satisfied
```

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

The shortcut — one blanket forwarding every existing `MapFrom` into the local
trait — is not expressible either:

```rust
// rejected by coherence
impl<S, D: MapFrom<S>> LocalMapFrom<S> for D { .. }
```

```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.

### `let` preludes

Shared derivations that feed more than one destination field:
Expand Down Expand Up @@ -566,6 +669,10 @@ 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.

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

## prost / generated-code recipe
Expand Down Expand Up @@ -601,6 +708,14 @@ 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.

## License

Expand Down
24 changes: 24 additions & 0 deletions compat/scope-downstream/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Standalone crate (own `[workspace]` so the parent does not absorb it) that
# uses `magic_map_scope!` as a real downstream user would: it enables
# magic_map's `full` feature but declares no features of its own.
#
# This guards a cfg-leakage regression. The scope's built-in leaves must be
# emitted from macros defined under `#[cfg(feature = ...)]` *inside* magic_map.
# An emitted `#[cfg(feature = "uuid")]` would instead be evaluated against THIS
# crate's feature set — which is empty — silently dropping every Uuid, chrono,
# Decimal and json leaf, and only failing much later in a user's nested field.
# magic_map's own test crate cannot catch that: its features are magic_map's.
[workspace]

[package]
name = "scope-downstream"
version = "0.0.0"
edition = "2021"
publish = false

[dependencies]
magic_map = { path = "../../magic_map", features = ["full"] }
uuid = { version = "1", features = ["v4"] }
chrono = "0.4"
rust_decimal = "1"
serde_json = "1"
51 changes: 51 additions & 0 deletions compat/scope-downstream/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Regression guard: feature-gated scope leaves must survive the trip into a
//! crate that declares no features of its own. If this compiles, they did.

magic_map::magic_map_scope!();

use magic_map::magic_map;

pub mod db {
#[derive(magic_map::MagicMap)]
pub struct Row {
pub id: uuid::Uuid,
pub created_at: chrono::DateTime<chrono::Utc>,
pub day: chrono::NaiveDate,
pub amount: rust_decimal::Decimal,
pub payload: serde_json::Value,
pub count: i32,
}
}

pub mod dtos {
#[derive(magic_map::MagicMap)]
pub struct RowResponse {
pub id: String, // Uuid → String
pub created_at: String, // DateTime<Utc> → String
pub day: String, // NaiveDate → String
pub amount: f64, // Decimal → f64
pub payload: serde_json::Value, // identity
pub count: i64, // i32 → i64 widening
}

#[derive(magic_map::MagicMap)]
pub struct PageResponse {
pub rows: Vec<RowResponse>,
pub first: Option<RowResponse>,
}
}

pub mod db_page {
#[derive(magic_map::MagicMap)]
pub struct Page {
pub rows: Vec<super::db::Row>,
pub first: Option<super::db::Row>,
}
}

// Every field here is a built-in leaf reached through the scope.
magic_map!(pub fn row_to_dto: db::Row => dtos::RowResponse);

// And the nested foreign→foreign pair, through Vec and Option — no overrides,
// which is the whole point: `row_to_dto`'s pair is visible to this mapping.
magic_map!(pub fn page_to_dto: db_page::Page => dtos::PageResponse);
2 changes: 1 addition & 1 deletion magic_map/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ authors.workspace = true
readme = "../README.md"

[dependencies]
magic_map_macros = { version = "=0.2.1", path = "../magic_map_macros" }
magic_map_macros = { version = "=0.3.0", path = "../magic_map_macros" }
chrono = { version = "0.4", optional = true }
rust_decimal = { version = "1", optional = true }
serde_json = { version = "1", optional = true }
Expand Down
Loading