I’m going to preface this with another reminder that ORMs are complicated af. This will be a long road if you choose to write your own.
This is what I meant about the instance variables not being available outside of the method.
@type.instance_vars
is empty outside of a method for reasons that are a bit complex but boil down to: at the point in time where the macro is evaluated at the class level, it doesn’t yet have all the type information. Method bodies are evaluated later in the compilation process (if at all) but class/struct/module bodies, including their macros, are evaluated early enough that even the order in which they’re defined matters, similar to class bodies in Ruby.
I mean I think the implementation can’t live inside the class body. At its simplest, I think it will need to live inside a method that is invoked inside the class body:
module Model
macro included
define_columns!
{% verbatim do %}
def self.define_columns!
{% begin %}
{% for ivar in @type.instance_vars %}
Db.exec "ALTER TABLE ADD COLUMN {{ivar.name}} ..."
{% end %}
{% end %}
end
{% end %}
end
end
The more the code evolves, the more likely you’ll want to extract it to something else.
It looks like you’re trying to automatically provision the tables when the app starts. I’d be careful with this, because it can cause some weird issues in a production environment where you might start multiple instances of the app simultaneously. To be clear, the end result should still be what you’re looking for but there’s the potential for errors on startup (resulting in app crashes) in getting to that point if, for example, multiple instances of the app start the ALTER TABLE
query at the same time.
It also blocks the app from listening for connections until those columns are added, which may be a nontrivial amount of time in a production environment and its impact on your system depends entirely on your deployment architecture. For example, if you’re deploying to Kubernetes without startup/readiness/liveness probes configured or to Heroku without preboot enabled you may get 502/503 errors.
In general, I’d recommend a separate migration step. It doesn’t eliminate the possibility of these collisions, but it does reduce the probability by multiple orders of magnitude.
There is, but you’ve gotta figure out what your source of truth is. For example, IIRC Avram
uses the DB schema as it existed when the app was compiled, which seems to align with Sequel and ActiveRecord’s approach — they do it on DB connection/app boot, but Crystal can’t define methods at runtime. Interro
uses methods you define on your query objects, pushing the responsibility of type safety to the app developer.
If you want your source of truth to be the model’s instance variables, you need to guarantee the schema matches them any time you interact with data. It seems that’s what you were trying to achieve by running DDL queries on startup, but again, be careful there.
A couple different ways I can think of to do this. One is to update them in-place like you’re doing:
def update(**kwargs)
sql = ...
args = ...
Db.exec sql, args: args, as: self.class
kwargs.each do |key, value|
# We need to `begin` the macro here so the parser doesn't think
# the macro code is just a single `when` clause, because that
# isn't a valid Crystal expression on its own. We have to tell
# it to encompass the full `case` expression.
# See: https://crystal-lang.org/reference/1.2/syntax_and_semantics/macros/index.html#pitfalls
{% begin %}
case key
{% for ivar in @type.instance_vars %}
when :{{ivar.name}}
@{{ivar.name}} = value
{% end %}
end
{% end %}
end
end
The other is to return the updated row from the query and assign all its ivars:
def update(**kwargs)
sql = ...
args = ...
updated = Db.query_one sql, args: args, as: self.class
{% for ivar in @type.instance_vars %}
@{{ivar.name}} = updated.{{ivar.name}}
{% end %}
end
Both of them are really doing the same thing but the second is arguably more flexible.