rubyinterfacesorbet

Using sorbet interface abstraction on singleton methods


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.


Solution

  • 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