Home > Blockchain >  Using sorbet interface abstraction on singleton methods
Using sorbet interface abstraction on singleton methods

Time:10-28

I love the sorbet interface feature!

And in the sorbet documentation there is a paragraph of making singleton methods abstract. This seems like a great feature for deserialization and migrations (upcasting).

My idea would be to store a serialized version of a Typed Struct in a database. Because the struct could evolve over time I also want to provide some functionality to convert old serialized version of the struct into the current version.

The way to achieve this would to save the name of the class, the data and a version into the database. Assume this struct

class MyStruct < T::Struct
  const :v1_field, String
  const :v2_field, String

  def self.version
    2
  end
end

An old serialized version in a data store could look like this:

class data version
MyStruct {"v1_field": "v1 value"} 1

I can't just deserialize the data because it's missing the mandatory field v2_field. So my idea was to provide singleton methods for a migration.

module VersionedStruct
  module ClassMethods
    abstract!

    sig { abstract.returns(Integer) }
    def version; end

    sig { abstract.params(payload: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
    def migrate(payload); end
  end

  mixes_in_class_methods(ClassMethods)
end

class MyStruct < T::Struct
  include VersionedStruct

  const :v1_field, String
  const :v2_field, String

  sig { override.returns(Integer) }
  def self.version
    2
  end

  sig { override.params(payload: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
  def self.migrate(data)
    return if data[:v2_field]

    data.merge(v2_field: "default value")
  end
end

NOTE: I realize there is a default option for struct fields but there are migrations that can't be modeled with this (like renaming field names). Unfortunately these singleton method interface don't behave in the same way I would expect interfaces to work:

class DataDeserializer

  sig { params(data_class: String, data_version: Integer, data: T::Hash[Symbol, T.untyped]).returns(T.any(MyStruct, MyOtherStruct, ...)) }
  def load(data_class, data_version, data)
    struct_class = Object.const_get(data_class)

    migrated_data = if struct_class.include?(VersionedStruct) # This seems to be the only check that actually returns true for all classes that include the interface
      migrate(data_version, T.cast(struct_class, VersionedStruct), data)
    else
      data # fallback if the persistent data model never changed
    end

    struct_class.new(migrated_data)
  end

  private

  sig { params(data_version: Integer, struct: VersionedStruct, data: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
  def migrate(data_version, struct, data)
    return data if data_version == struct.version # serialized data is up to date

    struct.migrate(data)
  end
end

This code (or variations of this) won't work, because sorbet will raise an error saying:

Method `version` does not exist on `VersionedStruct`
Method `migrate` does not exist on `VersionedStruct`

Changing the signature to T.class_of(VersionedStruct) will raise the same error:

Method `version` does not exist on `T.class_of(VersionedStruct)`
Method `migrate` does not exist on `T.class_of(VersionedStruct)`

Even though the methods are defined on a class level. The main reason why I'm not including the methods on an instance level is because I can't instantiate the struct without having the correct data.

CodePudding user response:

I think you want to extend VersionedStruct instead of trying to do the magic mixes in class methods trick. That works really well:

# typed: true

module VersionedStruct
  extend T::Sig
  extend T::Helpers
  abstract!

  sig { abstract.returns(Integer) }
  def version; end

  sig { abstract.params(payload: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
  def migrate(payload); end
end

class MyStruct < T::Struct
  extend T::Sig
  extend VersionedStruct

  const :v1_field, String
  const :v2_field, String

  sig { override.returns(Integer) }
  def self.version
    2
  end

  sig { override.params(data: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
  def self.migrate(data)
    return {} if data[:v2_field]

    data.merge(v2_field: "default value")
  end
end
  • Related