computed_model 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/CONCEPTS.md ADDED
@@ -0,0 +1,330 @@
1
+ # Basic concepts and features
2
+
3
+ [日本語版](CONCEPTS.ja.md)
4
+
5
+ ## Wrapping classes
6
+
7
+ We don't (yet) support directly including `ComputedModel::Model` into ActiveRecord classes or similar ones.
8
+ In that case, we recommend creating a wrapper class and reference the original class via the primary loader
9
+ (described later).
10
+
11
+ ## Fields
12
+
13
+ **Field** are certain attributes managed by ComputedModel. It's a unit of dependency resolution and
14
+ there are three kinds of fields:
15
+
16
+ - computed fields
17
+ - loaded fields
18
+ - primary fields
19
+
20
+ ### computed fields
21
+
22
+ A computed field is a field in which it's value is derived from other fields.
23
+ It's calculated independently per record.
24
+
25
+ ```ruby
26
+ class User
27
+ dependency :preference, :profile
28
+ computed def display_name
29
+ "#{preference.title} #{profile.name}"
30
+ end
31
+ end
32
+ ```
33
+
34
+ ### loaded fields
35
+
36
+ A loaded field is a field in which we obtain values in batches.
37
+
38
+ ```ruby
39
+ class User
40
+ define_loader :preference, key: -> { id } do |ids, _subfields, **|
41
+ Preference.where(user_id: ids).index_by(&:user_id)
42
+ end
43
+ end
44
+ ```
45
+
46
+ ### primary fields
47
+
48
+ A primary field is responsible in searching/enumerating the whole records,
49
+ in addition to the usual responsibility of loaded fields.
50
+
51
+ Consider a hypothetical `User` class for example. In this case you might want to inquiry somewhere (a data source)
52
+ whether a user with a certain id exists.
53
+
54
+ If it were a hypothetical ActiveRecord class `RawUser`, the primary field would be defined as follows:
55
+
56
+ ```ruby
57
+ class User
58
+ def initialize(raw_user)
59
+ @raw_user = raw_user
60
+ end
61
+
62
+ define_primary_loader :raw_user do |_subfields, ids:, **|
63
+ # You need to set @raw_user in User#initialize.
64
+ RawUser.where(id: ids).map { |u| User.new(u) }
65
+ end
66
+ end
67
+ ```
68
+
69
+ ## When computation is done
70
+
71
+ All necessary fields are computed eagerly when ComputedModel's `bulk_load_and_compute` is called.
72
+
73
+ It doesn't (yet) provide lazy loading functionality.
74
+
75
+ ## Dependency
76
+
77
+ You can declare dependencies on a field.
78
+ As an exception, the primary field cannot have a dependency (but it can have dependents, of course).
79
+
80
+ ```ruby
81
+ class User
82
+ dependency :preference, :profile
83
+ computed def display_name
84
+ "#{preference.title} #{profile.name}"
85
+ end
86
+ end
87
+ ```
88
+
89
+ In `computed def` or `define_loader`, among all fields, you can only read values of explicitly declared dependencies.
90
+ You cannot read other fields even if it happens to be present (such as indirect dependencies).
91
+
92
+ ## `bulk_load_and_compute`
93
+
94
+ `bulk_load_and_compute` is the very method you need to load ComputedModel records.
95
+ We recommend wrapping the method in each model class.
96
+ This is mostly because there is a lot of freedom in the format of the batch-loading parameters (described later)
97
+ and it will likely cause mistakes if used directly.
98
+
99
+ ```ruby
100
+ class User
101
+ # You can specify an array of fields like [:display_name, :title] in the `with` parameter.
102
+ def self.list(ids, with:)
103
+ bulk_load_and_compute(with, ids: ids)
104
+ end
105
+
106
+ def self.get(id, with:)
107
+ list([id], with: with).first
108
+ end
109
+
110
+ def self.get!(id, with:)
111
+ get(id, with: with) || (raise User::NotFound)
112
+ end
113
+ end
114
+ ```
115
+
116
+ There is no such method as load a single record. You can easily implement it by wrapping `bulk_load_and_compute`.
117
+ If you want a certain optimization for single-record cases, you may want to write conditionals in `define_loader` or `define_primary_loader`.
118
+
119
+ ## Subfield selectors
120
+
121
+ Subfield selectors (or subdependencies) are additional information attached to a dependency.
122
+
123
+ Implementation-wise they're just arbitrary messages sent from a field to its dependency.
124
+ Nonetheless we expect them to be used to request "subfields" as the name suggests.
125
+
126
+ ```ruby
127
+ class User
128
+ define_loader :profile, key: -> { id } do |ids, subfields, **|
129
+ Profile.preload(subfields).where(user_id: ids).index_by(&:user_id)
130
+ end
131
+
132
+ # [:contact_phones] will be passed to the loader of `profile`.
133
+ dependency profile: :contact_phones
134
+ computed def contact_phones
135
+ profile.contact_phones
136
+ end
137
+ end
138
+ ```
139
+
140
+ You can also receive subfield selectors in a computed field. See the "Dynamic dependencies" section later.
141
+
142
+ ## Batch-loading parameters
143
+
144
+ The keyword parameters given to `bulk_load_and_compute` is passed through to the blocks of `define_primary_loader` or `define_loader`.
145
+ You can use it for various purposes, some of which we present below:
146
+
147
+ ### Searching records by conditions other than id
148
+
149
+ You can pass multiple different search conditions through the keyword parameters.
150
+
151
+ ```ruby
152
+ class User
153
+ def self.list(ids, with:)
154
+ bulk_load_and_compute(with, ids: ids, emails: nil)
155
+ end
156
+
157
+ def self.list_by_emails(emails, with:)
158
+ bulk_load_and_compute(with, ids: nil, emails: emails)
159
+ end
160
+
161
+ define_primary_loader :raw_user do |_subfields, ids:, emails:, **|
162
+ s = User.all
163
+ s = s.where(id: ids) if ids
164
+ s = s.where(email: emails) if emails
165
+ s.map { |u| User.new(u) }
166
+ end
167
+ end
168
+ ```
169
+
170
+ ### Current user
171
+
172
+ Consider a situation where we want to present different information depending on whom the user is logging in as.
173
+ You can implement it by including the current user in the keyword parameters.
174
+
175
+ ```ruby
176
+ class User
177
+ def initialize(raw_user, current_user_id)
178
+ @raw_user = raw_user
179
+ @current_user_id = current_user_id
180
+ end
181
+
182
+ define_primary_loader :raw_user do |_subfields, current_user_id:, ids:, **|
183
+ # ...
184
+ end
185
+
186
+ define_loader :profile, key: -> { id } do |ids, _subfields, current_user_id:, **|
187
+ # ...
188
+ end
189
+ end
190
+ ```
191
+
192
+ ## Dynamic dependencies
193
+
194
+ You can configure dynamic dependencies by specifying Procs as subfield selectors.
195
+
196
+ ### Conditional dependencies
197
+
198
+ Dependencies which are conditionally enabled based on incoming subfield selectors:
199
+
200
+ ```ruby
201
+
202
+ class User
203
+ dependency(
204
+ :blog_articles,
205
+ # Load image_permissions only when it receives `image` subfield selector.
206
+ image_permissions: -> (subfields) { subfields.normalized[:image].any? }
207
+ )
208
+ computed def filtered_blog_articles
209
+ if current_subfields.normalized[:image].any?
210
+ # ...
211
+ end
212
+ # ...
213
+ end
214
+ end
215
+ ```
216
+
217
+ ### Subfield selectors passthrough
218
+
219
+ Passing through incoming subfield selectors to another field:
220
+
221
+ ```ruby
222
+
223
+ class User
224
+ dependency(
225
+ blog_articles: -> (subfields) { subfields }
226
+ )
227
+ computed def filtered_blog_articles
228
+ if current_subfields.normalized[:image].any?
229
+ # ...
230
+ end
231
+ # ...
232
+ end
233
+ end
234
+ ```
235
+
236
+ ### Subfield selectors mapping
237
+
238
+ Processing incoming subfield selectors and pass the result as outgoing subfield selectors to another field:
239
+
240
+ ```ruby
241
+ class User
242
+ dependency(
243
+ # Always load blog_articles, but
244
+ # if the incoming subfield selectors contain `blog_articles`, pass them down to the dependency.
245
+ blog_articles: [true, -> (subfields) { subfields.normalized[:blog_articles] }],
246
+ # Always load wiki_articles, but
247
+ # if the incoming subfield selectors contain `wiki_articles`, pass them down to the dependency.
248
+ wiki_articles: [true, -> (subfields) { subfields.normalized[:wiki_articles] }]
249
+ )
250
+ computed def articles
251
+ (blog_articles + wiki_articles).sort_by { |article| article.created_at }.reverse
252
+ end
253
+ end
254
+ ```
255
+
256
+ ### Detailed dependency format
257
+
258
+ You can pass 0 or more arguments to `dependency`.
259
+ They're pushed into an internal array and will be consumed by the next `computed def` or `define_loader`.
260
+ So they have the same meaning:
261
+
262
+ ```ruby
263
+ dependency :profile
264
+ dependency :preference
265
+ computed def display_name; ...; end
266
+ ```
267
+
268
+ ```ruby
269
+ dependency :profile, :preference
270
+ computed def display_name; ...; end
271
+ ```
272
+
273
+ The resulting array will be normalized as a hash by `ComputedModel.normalize_dependencies`. The rules are:
274
+
275
+ - If it's a Symbol, convert it to a singleton hash containing the key. (`:foo` → `{ foo: [true] }`)
276
+ - If it's a Hash, convert the values as follows:
277
+ - If the value is an empty array, replace it with `[true]`. (`{ foo: [] }` → `{ foo: [true] }`)
278
+ - If the value is not an array, convert it to the singleton array. (`{ foo: :bar }` → `{ foo: [:bar] }`)
279
+ - If the value is a non-empty array, keep it as-is.
280
+ - If it's an Array, convert each element following the rules above and merge the keys of the hashes. Hash values are always arrays and will be simply concatenated.
281
+ - `[:foo, :bar]` → `{ foo: [true], bar: [true] }`
282
+ - `[{ foo: :foo }, { foo: :bar }]` → `{ foo: [:foo, :bar] }`
283
+
284
+ We interpret the resulting hash as a dictionary from a field name (dependency names) and it's subfield selectors.
285
+
286
+ Each subfield selector is interpreted as below:
287
+
288
+ - If it contains `#call`able objects (such as Proc), call them with `subfields` (incoming subfield selectors) as their argument.
289
+ - Expand the result if it's an Array. (`{ foo: [-> { [:bar, :baz] }] }` → `{ foo: [:bar, :baz] }`)
290
+ - Otherwise push the result. (`{ foo: [-> { :bar }] }` → `{ foo: [:bar] }`)
291
+ - After Proc substitution, check if it contains any truthy value (value other than `nil` or `false`).
292
+ - If no truthy value is found, we don't use the dependency as the condition is not met.
293
+ - Otherwise (if truthy value is found), use the dependency. Subfield selectors (after substitution) are sent to the dependency as-is.
294
+
295
+ For that reason, in most cases subfield selectors contain `true`. As a special case we remove them in the following cases:
296
+
297
+ - We'll remove `nil`, `false`, `true` from the subfield selectors before passed to a `define_loader` or `define_primary_loader` block.
298
+ - In certain cases you can use `subfields.normalize` to get a hash from the subfield selectors array. This is basically `ComputedModel.normalize_dependencies` but `nil`, `false`, `true` will be removed as part of preprocessing.
299
+
300
+ ## Inheritance
301
+
302
+ You can also define partial ComputedModel class/module. You can then inherit/include it in a different class and complete the definition.
303
+
304
+ ```ruby
305
+ module UserLikeConcern
306
+ extends ActiveSupport::Concern
307
+ include ComputedModel::Model
308
+
309
+ dependency :preference, :profile
310
+ computed def display_name
311
+ "#{preference.title} #{profile.name}"
312
+ end
313
+ end
314
+
315
+ class User
316
+ include UserLikeConcern
317
+
318
+ define_loader :preference, key: -> { id } do ... end
319
+ define_loader :profile, key: -> { id } do ... end
320
+ end
321
+
322
+ class Admin
323
+ include UserLikeConcern
324
+
325
+ define_loader :preference, key: -> { id } do ... end
326
+ define_loader :profile, key: -> { id } do ... end
327
+ end
328
+ ```
329
+
330
+ Note that in certain cases overriding might work incorrectly (because `computed def` internally renames the given method)
data/Migration-0.3.md ADDED
@@ -0,0 +1,343 @@
1
+ # v0.3.0 migration guide
2
+
3
+ computed_model 0.3.0 comes with a number of breaking changes.
4
+ This guide will help you upgrade the library,
5
+ but please test your program before deploying to production.
6
+
7
+ ## Major breaking: `ComputedModel` is now `ComputedModel::Model`
8
+
9
+ https://github.com/wantedly/computed_model/pull/17
10
+
11
+ Before:
12
+
13
+ ```ruby
14
+ class User
15
+ include ComputedModel
16
+ end
17
+ ```
18
+
19
+ After:
20
+
21
+ ```ruby
22
+ class User
23
+ include ComputedModel::Model
24
+ end
25
+ ```
26
+
27
+
28
+ ## Major breaking: Indirect dependencies are now rejected
29
+
30
+ computed_model 0.3 checks if the requested field is a direct dependency.
31
+ If not, it raises `ComputedModel::ForbiddenDependency`.
32
+
33
+ https://github.com/wantedly/computed_model/pull/23
34
+
35
+ ### Case 1
36
+
37
+ This will mostly affect the following "indirect dependency" case:
38
+
39
+ Before:
40
+
41
+ ```ruby
42
+ class User
43
+ dependency :bar
44
+ computed def foo
45
+ baz # Accepted in computed_model 0.2
46
+ # ...
47
+ end
48
+
49
+ dependency :baz
50
+ computed def bar
51
+ # ...
52
+ end
53
+ end
54
+ ```
55
+
56
+ After:
57
+
58
+ ```ruby
59
+ class User
60
+ dependency :bar, :baz # Specify dependencies correctly
61
+ computed def foo
62
+ baz
63
+ # ...
64
+ end
65
+
66
+ dependency :baz
67
+ computed def bar
68
+ # ...
69
+ end
70
+ end
71
+ ```
72
+
73
+ ### Case 2
74
+
75
+ Before:
76
+
77
+ ```ruby
78
+ class User
79
+ dependency :bar
80
+ computed def foo
81
+ # ...
82
+ end
83
+ end
84
+
85
+ users = User.bulk_load_and_compute([:foo], ...)
86
+ users[0].bar # Accepted in computed_model 0.2
87
+ ```
88
+
89
+ After:
90
+
91
+ ```ruby
92
+ class User
93
+ dependency :bar
94
+ computed def foo
95
+ # ...
96
+ end
97
+ end
98
+
99
+ users = User.bulk_load_and_compute([:foo, :bar], ...) # Specify dependencies correctly
100
+ users[0].bar
101
+ ```
102
+
103
+ ### Other cases
104
+
105
+ Previously, it sometimes happens to work depending on the order in which fields are loaded.
106
+
107
+ ```ruby
108
+ class User
109
+ # No dependency between foo and bar
110
+
111
+ dependency :raw_user
112
+ computed def foo
113
+ # ...
114
+ end
115
+
116
+ dependency :raw_user
117
+ computed def bar
118
+ foo
119
+ # ...
120
+ end
121
+ end
122
+ ```
123
+
124
+ It was already fragile in computed_model 0.2.
125
+ However, in computed_model 0.3,
126
+ it always leads to `ComputedModel::ForbiddenDependency`.
127
+
128
+
129
+ ## Major breaking: `subdeps` are now called `subfields`
130
+
131
+ https://github.com/wantedly/computed_model/pull/31
132
+
133
+ Before:
134
+
135
+ ```ruby
136
+ class User
137
+ delegate_dependency :name, to: :raw_user, include_subdeps: true
138
+ end
139
+ ```
140
+
141
+ After:
142
+
143
+ ```ruby
144
+ class User
145
+ delegate_dependency :name, to: :raw_user, include_subfields: true
146
+ end
147
+ ```
148
+
149
+ We also recommend renaming block parameters named `subdeps` as `subfields`,
150
+ although not strictly necessary.
151
+
152
+
153
+ ## Minor breaking: `computed_model_error` has been removed
154
+
155
+ It was useful in computed_model 0.1 but no longer needed in computed_model 0.2.
156
+
157
+ https://github.com/wantedly/computed_model/pull/18
158
+
159
+ ```ruby
160
+ # No longer possible
161
+ self.computed_model_error = User::NotFound.new
162
+ ```
163
+
164
+ ## Minor breaking: Behavior of `dependency` not directly followed by `computed def` has been changed.
165
+
166
+ It doesn't effect you if all `dependency` declarations are followed by `computed def`.
167
+
168
+ ```ruby
169
+ # Keeps working
170
+ dependency :foo
171
+ computed def bar
172
+ end
173
+ ```
174
+
175
+ Otherwise `dependency` might be consumed by the next `define_loader` or `define_primary_loader`.
176
+
177
+ https://github.com/wantedly/computed_model/pull/20
178
+
179
+ Before:
180
+
181
+ ```ruby
182
+ dependency :foo # dependency of bar in computed_model 0.2
183
+
184
+ define_loader :quux, key: -> { id } do
185
+ # ...
186
+ end
187
+
188
+ computed def bar
189
+ # ...
190
+ end
191
+ ```
192
+
193
+ After:
194
+
195
+ ```ruby
196
+ # This would be interpreted as a dependency of quux
197
+ # dependency :foo
198
+
199
+ define_loader :quux, key: -> { id } do
200
+ # ...
201
+ end
202
+
203
+ dependency :foo # Place it here
204
+ computed def bar
205
+ # ...
206
+ end
207
+ ```
208
+
209
+ Additionally, `dependency` before `define_primary_loader` will be an error.
210
+
211
+ ## Minor breaking: Cyclic dependency is an error even if it is unused
212
+
213
+ https://github.com/wantedly/computed_model/pull/24
214
+
215
+ Before:
216
+
217
+ ```ruby
218
+ class User
219
+
220
+ # Cyclic dependency is allowed as long as it's unused
221
+
222
+ dependency :bar
223
+ computed def foo
224
+ end
225
+
226
+ dependency :foo
227
+ computed def bar
228
+ end
229
+ end
230
+
231
+ users = User.bulk_load_and_compute([], ...) # Neither :foo nor :bar is used
232
+ ```
233
+
234
+ After:
235
+
236
+ ```ruby
237
+ class User
238
+ # Remove cyclic dependency altogether
239
+ end
240
+
241
+ users = User.bulk_load_and_compute([], ...) # Neither :foo nor :bar is used
242
+ ```
243
+
244
+ ## Minor breaking: `nil`, `true`, `false` and `Proc` in subdeps are treated differently
245
+
246
+ They now have special meaning, so you should avoid using them as a normal subdependency.
247
+
248
+ https://github.com/wantedly/computed_model/pull/25
249
+
250
+ ### `nil` and `false`
251
+
252
+ They are constantly false condition in conditional dependency. Unless otherwise enabled, the dependency won't be used.
253
+
254
+ Before:
255
+
256
+ ```ruby
257
+ dependency foo: [nil, false] # foo will be used
258
+ computed def bar
259
+ end
260
+ ```
261
+
262
+ After:
263
+
264
+ ```ruby
265
+ # dependency foo: [nil, false] # foo won't be used
266
+ dependency foo: [:nil, :false] # Use other values instead
267
+ computed def bar
268
+ end
269
+ ```
270
+
271
+ ### `true`
272
+
273
+ They are constantly true condition in conditional dependency. It's filtered out before passed to a loader or a primary loader.
274
+
275
+ Before:
276
+
277
+ ```ruby
278
+ dependency foo: [true] # true is given to "define_loader :foo do ... end"
279
+ computed def bar
280
+ end
281
+ ```
282
+
283
+ After:
284
+
285
+ ```ruby
286
+ # dependency foo: [true] # true is ignored
287
+ dependency foo: [:true] # Use other values instead
288
+ computed def bar
289
+ end
290
+ ```
291
+
292
+ ### Proc
293
+
294
+ Callable objects (objects implementing `#call`), including instances of `Proc`, is interpreted as a dynamic dependency.
295
+
296
+ Before:
297
+
298
+ ```ruby
299
+ dependency foo: -> { raise "foo" } # Passed to foo as-is
300
+ computed def bar
301
+ end
302
+ ```
303
+
304
+ After:
305
+
306
+ ```ruby
307
+ # dependency foo: -> { raise "foo" } # Dynamic dependency. Called during dependency resolution.
308
+ dependency foo: { callback: -> { raise "foo" } } # Wrap it in something
309
+ computed def bar
310
+ end
311
+ ```
312
+
313
+
314
+ ## Behavioral change: The order in which fields are loaded is changed
315
+
316
+ https://github.com/wantedly/computed_model/pull/24
317
+
318
+ Independent fields may be loaded in an arbitrary order. But implementation-wise, this is to some degree predictable.
319
+
320
+ computed_model 0.3 uses different dependency resolution algorithm and may produce different orders.
321
+ As a result, if your model is accidentally order-dependent, it may break with computed_model 0.3.
322
+
323
+
324
+ ## Behavioral change: `ComputedModel::Model` now uses `ActiveSupport::Concern`
325
+
326
+ It won't affect you if you simply did `include ComputedModel::Model` (previously `include ComputedModel`) and nothing more.
327
+ Be cautious if you have a more complex inheritance/inclusion graph than that.
328
+
329
+
330
+ ## Recommendation: `#verify_dependencies`
331
+
332
+ `#verify_dependencies` allows you to check the graph in the initialization process, rather than just before loading records.
333
+ We recommend doing this at the end of the class definition.
334
+
335
+ ```ruby
336
+ class User
337
+ define_loader ...
338
+
339
+ computed def ... end
340
+
341
+ verify_dependencies # HERE
342
+ end
343
+ ```