I would like to relaunch the topic with a new approach I found to implement out-of-the-box serialization/deserialization.
Compared to my previous approach and the stdlib:
- no monkey-patching for serialization, maybe for deserialization
- out of the box serialization/deserialization for any type.
require "json"
module Crystalizer::JSON
extend self
def serialize(object)
String.build do |str|
serialize str, object
end
end
def serialize(io : IO, object : O) forall O
::JSON.build(io) do |builder|
serialize builder, object
end
end
def serialize(builder : ::JSON::Builder, object : Int32)
object.to_json builder
end
def serialize(builder : ::JSON::Builder, object : String)
object.to_json builder
end
def serialize(builder : ::JSON::Builder, object : O) forall O
builder.object do
{% for ivar in O.instance_vars %}
builder.field {{ivar.stringify}} do
serialize builder, object.@{{ivar}}
end
{% end %}
end
end
def deserialize(data : String | IO, *, to type : O.class) forall O
deserialize ::JSON::PullParser.new(data), type
end
def deserialize(pull : ::JSON::PullParser, type : O.class) forall O
{% begin %}
{% properties = {} of Nil => Nil %}
{% for ivar in O.instance_vars %}
{% ann = ivar.annotation(::Serialization) %}
{% unless ann && ann[:ignore] %}
{%
properties[ivar.id] = {
type: ivar.type,
key: ((ann && ann[:key]) || ivar).id.stringify,
has_default: ivar.has_default_value?,
default: ivar.default_value,
nilable: ivar.type.nilable?,
root: ann && ann[:root],
converter: ann && ann[:converter],
presence: ann && ann[:presence],
}
%}
{% end %}
{% end %}
{% for name, value in properties %}
%var{name} = nil
%found{name} = false
{% end %}
pull.read_begin_object
while !pull.kind.end_object?
key = pull.read_object_key
case key
{% for name, value in properties %}
when {{value[:key]}}
raise "duplicated key: #{key}" if %found{name}
%found{name} = true
%var{name} = deserialize pull, {{value[:type]}}
{% end %}
else raise "#{key} not found"
end
end
O.new(
{% for name, value in properties %}
{{name}}: %var{name}.as({{value[:type]}}),
{% end %}
)
{% end %}
end
def deserialize(pull : ::JSON::PullParser, type : String.class)
pull.read_string
end
def deserialize(pull : ::JSON::PullParser, type : Int32.class)
v = pull.int_value.to_i32
pull.read_next
v
end
end
annotation Serialization
end
struct Point
getter x : Int32
@[Serialization(key: "YYY")]
getter y : String
def initialize(@x, @y)
end
end
struct MainPoint
getter p : Point
def initialize(@p)
end
end
data = %({"p": {"x": 1, "YYY": "abc"}})
point = Crystalizer::JSON.deserialize data, to: MainPoint
puts point # => MainPoint(@p=Point(@x=1, @y="abc"))
#{Crystalizer::YAML,
{Crystalizer::JSON}.each do |type|
puts type.serialize point
# ---
# p:
# x: 1
# y: abc
# {"p":{"x":1,"y":"abc"}}
end
This POC is of course the very base; it is not correct as is.
I would like to have others opinions on this. It be a shard at first.
One point I have not a golden solution: how to create an instance from an object T
for deserialization?
T#new
won’t necessarily take all ivars, and defining any custom method will required to monkey-patch either T
or Object
.
Another point, annotations: we should be able to tell how to serialize/deserialize without monkey-patching it with annotations. I can be done with named arguments, or an object passed as argument defining how to (de)serialize.