I have a collection of types, representing various older and newer versions of my data schema:
struct Version1;
struct Version2;
struct Version3;
struct Version4;
These types can be migrated between each other, one at a time:
impl Version1 { fn migrate_to_v2(self) -> Version2 { Version2 } }
impl Version2 { fn migrate_to_v3(self) -> Version3 { Version3 } }
impl Version3 { fn migrate_to_v4(self) -> Version4 { Version4 } }
I have an enum containing all of these versions, which is the data format I read from the disk:
enum Versioned {
V1(Version1),
V2(Version2),
V3(Version3),
V4(Version4),
}
I'd like to write a function that performs a full migration of a Versioned object to Version4. There are various ways to do it, but all of them have obvious flaws; my question is, are there any other solutions I've overlooked?
- Call the methods directly. The problem with this is exponential blowup; as I add more versions over the lifespan of this product, the size of this code will blow up quadratically:
fn migrate_versioned(versioned: Versioned) -> Version4 {
match versioned {
V1(data) => data.migrate_to_v2().migrate_to_v3().migrate_to_v4(),
V2(data) => data.migrate_to_v3().migrate_to_v4(),
V3(data) => data.migrate_to_v4(),
V4(data) => data,
}
}
- Use a series of chained
if lets. This requires round-tripping through the enum type, and we lose the exhaustiveness checking of amatch:
fn migrate_versioned(mut versioned: Versioned) -> Version4 {
use Versioned::*;
if let V1(data) = versioned {
versioned = V2(data.migrate_to_v2());
}
if let V2(data) = versioned {
versioned = V3(data.migrate_to_v3());
}
if let V3(data) = versioned {
versioned = V4(data.migrate_to_v4());
}
if let V4(data) = versioned {
return data;
}
unreachable!();
}
- Use a
loop. This restores the exhaustiveness checking that was lost in theif letversion, but we lose a syntactic halting guarantee, and still have the other flaws fromif let:
fn migrate_versioned(mut versioned: Versioned) -> Version4 {
loop {
versioned = match versioned {
V1(data) => V2(data.migrate_to_v2()),
V2(data) => V3(data.migrate_to_v3()),
V3(data) => V4(data.migrate_to_v4()),
V4(data) => break data,
};
}
}
- Some chained traits. This is better than the others but is very heavy on boilerplate:
trait ToV2: Sized {
fn migrate_to_v2(self) -> Version2;
}
impl ToV2 for Version1 { ... }
trait ToV3: Sized {
fn migrate_to_v3(self) -> Version3;
}
impl ToV3 for Version2 { ... }
impl<T: ToV2> ToV3 for T {
fn migrate_to_v3(self) -> Version3 { self.migrate_to_v2().migrate_to_v3() }{
}
trait ToV4: Sized {
fn migrate_to_v4(self) -> Version4;
}
impl ToV4 for Version3 { ... }
impl<T: ToV3> ToV4 for T {
fn migrate_to_v4(self) -> Version3 { self.migrate_to_v3().migrate_to_v4() }{
}
fn migrate_versioned(versioned: Versioned) -> Version4 {
match versioned {
V1(data) => data.migrate_to_v4(),
V2(data) => data.migrate_to_v4(),
V3(data) => data.migrate_to_v4(),
V4(data) => data,
}
}
- Various macro-based solutions. Generally these use a macro to produce one of the noisy solutions (such as the trait version or the direct call version). Because of trickiness related to creating chaining calls using macro repetition rules (which requires a complex recursive macro, as far as I can tell), these tend to be extremely noisy, to the point where it can't meaningfully be said to be a complexity saver: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=c8b8bed94afe654bbee7cfabfe24c784.
Are there any solutions that I've missed here? Conceptually it seems straightforward to want to do something like this:
// V1 starts here
let v2 = v1.migrate_to_v2();
// V2 starts here
let v3 = v2.migrate_to_v3();
// V3 starts here
let v4 = v3.migrate_to_v4();
// v4 starts here
But it's not at all clear to me how (if at all) it's possible to express the control flow for a solution that looks as simple as that.