How to cleanly implement "waterfall" logic in Rust

Viewed 91

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 a match:
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 the if let version, but we lose a syntactic halting guarantee, and still have the other flaws from if 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.

1 Answers

One way would be a pair of traits. The first trait determines how to get to the next version, and the second trait would complete the chain.

pub trait Migrate {
    type NextVersion;

    fn migrate(self) -> Self::NextVersion;
}

pub trait MigrateToLatest {
    type Latest;

    fn migrate_to_latest(self) -> Self::Latest;
}

/// If the next version knows how to MigrateToLatest, call their implementation 
impl<M: Migrate> MigrateToLatest for M
where
    M::NextVersion: MigrateToLatest,
{
    type Latest = <M::NextVersion as MigrateToLatest>::Latest;

    #[inline]
    fn migrate_to_latest(self) -> Self::Latest {
        self.migrate().migrate_to_latest()
    }
}

/// Start the waterfall for the LatestVersion type alias (below).
impl MigrateToLatest for LatestVersion {
    type Latest = Self;

    fn migrate_to_latest(self) -> Self::Latest {
        self
    }
}

Then we just need to implement Migrate for the previous version every time we have a new release.

impl Migrate for Version1 {
    type NextVersion = Version2;
    fn migrate(self) -> Self::NextVersion {
        Version2
    }
}

impl Migrate for Version2 {
    type NextVersion = Version3;
    fn migrate(self) -> Self::NextVersion {
        Version3
    }
}

impl Migrate for Version3 {
    type NextVersion = Version4;
    fn migrate(self) -> Self::NextVersion {
        Version4
    }
}

When a new version is version is made, the only changes that are needed are updating the LatestVersion and adding a new Migrate from the previous version.

/// Declare the latest version which ends the waterfall
type LatestVersion = Version4;

This approach means we only need to deal with the latest version and don't need to write out the entire chain ourselves.

You may also find it helpful to write your Versioned enum in a macro since you likely need to perform a couple uniform operations on every variant.

macro_rules! make_versioned {
    ($($name:ident: $type:ty),+) => {
        enum Versioned {
            /// Define the elements of the macro in the enum
            $($name($type)),+
        }
        
        impl MigrateToLatest for Versioned {
            type Latest = LatestVersion;
            
            fn migrate_to_latest(self) -> Self::Latest {
                use Versioned::*;
                match self {
                    // Take advantage of the macro to easily call on every variant
                    $($name(x) => x.migrate_to_latest()),+
                }
            }
        }
    }
}

/// Define the elements in the Versioned enum
make_versioned! {
    V1: Version1,
    V2: Version2,
    V3: Version3,
    V4: Version4
}
Related