Hi! I wanted to used a macro to streamline the initialization of a struct based on the values of another class with which it shares many instance variable names. For instance:
class User
property id = 1
property name = "Name"
end
struct SerializedUser
getter id : Int64
getter name : String
def initialize(user : User)
@id = user.id
@name = user.name
end
end
In this case all the variable names are short, but for longer ones it’s repetitive. I would like to use a macro to do something like this in SerializedUser:
def initialize(user : User)
set :id
set :name
end
This is the macro I’m using:
macro set(var)
@{{var.id}} = {{@def.args.first.name}}.{{var.id}}
end
The strange thing is that it seems to work, i.e., the macro expands correctly and the variable is initialized, but the compiler complains with “Error: instance variable ‘@id’ of SerializedUser was not initialized directly in all of the ‘initialize’ methods, rendering it nilable”.
For instance, given a user with id = 1, in the following method the macro works and @id is correctly initialized, but if I don’t put a explicit initialization, e.g. (@id = 0), or similar it won’t even compile.
def initialize(user : User)
set :id
puts @id # => 1
@id = 0
@name = name.id
end
Perhaps I’m missing something or my approach is not possible (nor advisable) for #initialize?
I think it has something to do with how/when the macros are expanded. I think you could do better tho. I’m also a fan of using dedicated DTO types to avoid coupling core entities with serialization related things and have thought about this a bit in the past and came up with:
class User
property id = 1
property name = "Name"
property active = true
end
module DTO(T)
macro included
macro finished
{% verbatim do %}
{% verbatim do %}
def initialize(obj : T)
{% for ivar in @type.instance_vars %}
@{{ivar.name.id}} = obj.{{ivar.name.id}}
{% end %}
end
{% end %}
{% end %}
end
end
end
record SerializedUser, id : Int32, name : String do
include DTO(User)
end
pp SerializedUser.new User.new # => SerializedUser(@id=1, @name="Name")
Where you can use the record macro to control what properties are included, then just include the DTO module with the type it wraps, and it’ll generate the entire constructor itself. Could probably get more advanced by using annotations to control mapping of properties, error handling to ensure T actually has properties you’re defining via the record, etc. But I think this works pretty well nonetheless for something fairly simple.
That was quick! I adapted your solution by adding a DTO::Ignore annotation and an optional hook to initialize instance variables not copied from obj. Thanks!
Hi, following code get same result. (no finished macro required)
class User
property id = 1
property name = "Name"
property active = true
end
module DTO(T)
macro included
{% verbatim do %}
def initialize(obj : T)
{% for ivar in @type.instance_vars %}
@{{ivar.name.id}} = obj.{{ivar.name.id}}
{% end %}
end
{% end %}
end
end
record SerializedUser, id : Int32, name : String do
include DTO(User)
end
pp SerializedUser.new User.new # => SerializedUser(@id=1, @name="Name")
So, what is the different for current use case use the included or not?