-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathmodel.cr
More file actions
261 lines (234 loc) · 7.52 KB
/
model.cr
File metadata and controls
261 lines (234 loc) · 7.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
require "./model/*"
require "./serializable"
require "./converters"
# Model (also *record*) is a business unit.
# Models usually have fields and relations with other models.
# The `Model` module allows to represent an SQL model as a plain Crystal object.
#
# ```sql
# CREATE TABLE users (
# id SERIAL PRIMARY KEY,
# username TEXT NOT NULL
# );
#
# CREATE TABLE posts (
# id SERIAL PRIMARY KEY,
# content TEXT NOT NULL,
# cover TEXT,
# author_id INT NOT NULL REFERENCES users (id),
# );
# ```
#
# ```
# class User
# include Onyx::SQL::Model
#
# schema users do
# pkey id : Int32
# type username : String, not_null: true
# type authored_posts : Array(Post), foreign_key: "author_id"
# end
# end
#
# class Post
# include Onyx::SQL::Model
#
# schema posts do
# pkey id : Int32
# type content : String, not_null: true
# type cover : String
# type author : User, key: "author_id", not_null: true
# end
# end
# ```
#
# In this example, `User` and `Post` are models. `User` has primary key `id`, field `username` and
# foreign enumerable reference `authored_posts`. `Post` also has primary key `id`,
# non-nilable field `content` and nilable field `cover`, and direct reference `author`.
# It's pretty simple and straightforward mapping.
# Read more about references in `Serializable` docs.
#
# ## Serialization
#
# `Model` module includes `Serializable`, which enables deserializing models from a `DB::ResultSet`,
# effectively allowing this:
#
# ```
# db = DB.open(ENV["DATABASE_URL"])
# users = User.from_rs(db.query("SELECT * FROM users"))
# ```
#
# But it's more convenient to use `Repository` to interact with the database:
#
# ```
# repo = Onyx::SQL::Repository.new(db)
# users = repo.query(User, "SELECT * FROM users")
# ```
#
# That's not much less code, but the repo, for example, handles query arguments
# (`?` -> `$1` for PostrgreSQL queries) and also logs the requests.
# The real power of repository is handling `Query` arguments:
#
# ```
# user = repo.query(User.where(id: 42)).first
# ```
#
# ## Schema
#
# Onyx::SQL is based on Crystal annotations to keep composition and simplify the underlying code.
# But since annotations are quite low-level, they are masked under the convenient `.schema` DSL.
# It's a good idea to understand what the `.schema` macro generates, but it's not mandatory
# for most of developers.
module Onyx::SQL::Model
include Converters
# Return this model database table.
# It must be defined with `Options` annotation:
#
# ```
# @[Onyx::SQL::Model::Options(table: "users")]
# class User
# end
#
# pp User.table # => "users"
# ```
#
# This method is defined upon the module inclusion.
def self.table
end
macro included
include Onyx::SQL::Serializable
include Onyx::SQL::Model::Mappable(self)
extend Onyx::SQL::Model::ClassQueryShortcuts(self)
macro finished
def self.table
# When using `schema` macro, the annotation is placed on the
# second macro run only, therefore need to wait until `finished`
{% verbatim do %}
{% raise "A model must have table defined with `Onyx::SQL::Model::Options` annotation" unless (ann = @type.annotation(Onyx::SQL::Model::Options)) && ann[:table] %}
{{@type.annotation(Onyx::SQL::Model::Options)[:table].id.stringify}}
{% end %}
end
end
end
# Compare `self` against *other* model of the same type by their primary keys.
# Returns `false` if the `self` primary key is `nil`.
def ==(other : self)
{% begin %}
{%
options = @type.annotation(Onyx::SQL::Model::Options)
raise "Onyx::SQL::Model::Options annotation must be defined for #{@type}" unless options
pk = options[:primary_key]
raise "#{@type} must have Onyx::SQL::Model::Options annotation with :primary_key option" unless pk
pk_rivar = @type.instance_vars.find { |riv| "@#{riv.name}".id == pk.id }
raise "Cannot find primary key field #{pk} in #{@type}" unless pk_rivar
%}
unless primary_key.nil?
primary_key == other.{{pk_rivar.name}}
end
{% end %}
end
# Initialize an instance of `self`. It accepts an arbitrary amount of arguments,
# but they must match the variable names, raising in compile-time instead:
#
# ```
# User.new(id: 42, username: "John") # => <User @id=42 @username="John">
# User.new(foo: "bar") # Compilation-time error
# ```
def initialize(**values : **T) : self forall T
{% for ivar in @type.instance_vars %}
{%
a = 42 # BUG: Dummy assignment, otherwise the compiler crashes
unless ivar.type.nilable?
raise "#{@type}@#{ivar.name} must be nilable, as it's an Onyx::SQL::Model variable"
end
unless ivar.type.union_types.size == 2
raise "Only T | Nil unions can be an Onyx::SQL::Model's variables (got #{ivar.type} type for #{@type}@#{ivar.name})"
end
type = ivar.type.union_types.find { |t| t != Nil }
if type <= Enumerable
if (type.type_vars.size != 1 || type.type_vars.first.union?)
raise "If an Onyx::SQL::Model variable is a Enumerable, it must have a single non-union type var (got #{type} type for #{@type}@#{ivar.name})"
end
end
%}
{% end %}
values.each do |key, value|
{% begin %}
case key
{% for key, value in T %}
{% found = false %}
{% for ivar in @type.instance_vars %}
{% if ivar.name == key %}
{% raise "Invalid type #{value} for #{@type}@#{ivar.name} (expected #{ivar.type})" unless value <= ivar.type %}
when {{ivar.name.symbolize}}
@{{ivar.name}} = value.as({{value}})
{% found = true %}
{% end %}
{% end %}
{% raise "Cannot find instance variable by key #{key} in #{@type}" unless found %}
{% end %}
else
raise "BUG: Runtime key mismatch"
end
{% end %}
end
self
end
# This annotation specifies options for a `Model`. It has two mandatory options itself:
#
# * `:table` -- the table name in the DB, e.g. "users"
# * `:primary_key` -- the primary key **variable**, for example:
#
# ```
# @[Onyx::SQL::Options(table: "users", primary_key: @id)]
# class User
# include Onyx::SQL::Model
# @id : Int32?
# end
# ```
#
# The `Model.schema` macro defines the `Options` annotation for you:
#
# ```
# class User
# include Onyx::SQL::Model
#
# schema users do # "users" is going to be the :table option
# pkey id : Int32 # @id is the :primary_key
# end
# end
# ```
#
# TODO: Handle different `:primary_key` variants:
#
# ```
# @[Options(primary_key: {@a, @b})] # Composite
#
# @[Options(primary_key: {x: @a, y: @b})] # With different getters (and composite)
# class User
# def x
# @a
# end
# end
# ```
annotation Options
end
def_hash primary_key
protected def primary_key
{% begin %}
{%
options = @type.annotation(Onyx::SQL::Model::Options)
raise "Onyx::SQL::Model::Options annotation must be defined for #{@type}" unless options
pk = options[:primary_key]
raise "#{@type} must have Onyx::SQL::Model::Options annotation with :primary_key option" unless pk
pk_rivar = @type.instance_vars.find { |riv| "@#{riv.name}".id == pk.id }
raise "Cannot find primary key field #{pk} in #{@type}" unless pk_rivar
%}
@{{pk_rivar}}
{% end %}
end
def before_insert
end
def before_update
end
end