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