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.
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ # A mixin for batch-loadable compound models. This is the main API of ComputedModel.
6
+ #
7
+ # See {ComputedModel::Model::ClassMethods} for methods you can use in the including classes.
8
+ #
9
+ # @example
10
+ # require 'computed_model'
11
+ #
12
+ # # Consider them external sources (ActiveRecord or resources obtained via HTTP)
13
+ # RawUser = Struct.new(:id, :name, :title)
14
+ # Preference = Struct.new(:user_id, :name_public)
15
+ #
16
+ # class User
17
+ # include ComputedModel::Model
18
+ #
19
+ # attr_reader :id
20
+ # def initialize(raw_user)
21
+ # @id = raw_user.id
22
+ # @raw_user = raw_user
23
+ # end
24
+ #
25
+ # def self.list(ids, with:)
26
+ # bulk_load_and_compute(Array(with), ids: ids)
27
+ # end
28
+ #
29
+ # define_primary_loader :raw_user do |_subfields, ids:, **|
30
+ # # In ActiveRecord:
31
+ # # raw_users = RawUser.where(id: ids).to_a
32
+ # raw_users = [
33
+ # RawUser.new(1, "Tanaka Taro", "Mr. "),
34
+ # RawUser.new(2, "Yamada Hanako", "Dr. "),
35
+ # ].filter { |u| ids.include?(u.id) }
36
+ # raw_users.map { |u| User.new(u) }
37
+ # end
38
+ #
39
+ # define_loader :preference, key: -> { id } do |user_ids, _subfields, **|
40
+ # # In ActiveRecord:
41
+ # # Preference.where(user_id: user_ids).index_by(&:user_id)
42
+ # {
43
+ # 1 => Preference.new(1, true),
44
+ # 2 => Preference.new(2, false),
45
+ # }.filter { |k, _v| user_ids.include?(k) }
46
+ # end
47
+ #
48
+ # delegate_dependency :name, to: :raw_user
49
+ # delegate_dependency :title, to: :raw_user
50
+ # delegate_dependency :name_public, to: :preference
51
+ #
52
+ # dependency :name, :name_public
53
+ # computed def public_name
54
+ # name_public ? name : "Anonymous"
55
+ # end
56
+ #
57
+ # dependency :public_name, :title
58
+ # computed def public_name_with_title
59
+ # "#{title}#{public_name}"
60
+ # end
61
+ # end
62
+ #
63
+ # # You can only access the field you requested ahead of time
64
+ # users = User.list([1, 2], with: [:public_name_with_title])
65
+ # users.map(&:public_name_with_title) # => ["Mr. Tanaka Taro", "Dr. Anonymous"]
66
+ # users.map(&:public_name) # => error (ForbiddenDependency)
67
+ #
68
+ # users = User.list([1, 2], with: [:public_name_with_title, :public_name])
69
+ # users.map(&:public_name_with_title) # => ["Mr. Tanaka Taro", "Dr. Anonymous"]
70
+ # users.map(&:public_name) # => ["Tanaka Taro", "Anonymous"]
71
+ #
72
+ # # In this case, preference will not be loaded.
73
+ # users = User.list([1, 2], with: [:title])
74
+ # users.map(&:title) # => ["Mr. ", "Dr. "]
75
+
76
+ module ComputedModel::Model
77
+ extend ActiveSupport::Concern
78
+
79
+ # A set of class methods for {ComputedModel::Model}. Automatically included to the
80
+ # singleton class when you include {ComputedModel::Model}.
81
+ #
82
+ # See {ComputedModel::Model} for examples.
83
+ module ClassMethods
84
+ # Declares the dependency of a computed field.
85
+ # Normally a call to this method will be followed by a call to {#computed} (or {#define_loader}).
86
+ #
87
+ # @param deps [Array<Symbol, Hash{Symbol=>Array, Object}>]
88
+ # Dependency list. Most simply an array of Symbols (field names).
89
+ #
90
+ # It also accepts Hashes. In this case, the keys of the hashes are field names.
91
+ # The values are called subfield selectors.
92
+ #
93
+ # Subfield selector is one of the following:
94
+ #
95
+ # - nil, true, or false (constant condition)
96
+ # - `#call`able objects accepting one argument (dynamic selector)
97
+ # - other objects (static selector)
98
+ #
99
+ # Multiple subfield selectors can be specified at once as an array.
100
+ #
101
+ # See CONCEPTS.md for the more detailed description of dependency formats.
102
+ # @return [void]
103
+ # @raise [RuntimeError] if the dependency list contains values other than Symbol or Hash
104
+ #
105
+ # @example declaring dependencies
106
+ # dependency :user, :user_external_resource
107
+ # computed def something
108
+ # # Use user and user_external_resource ...
109
+ # end
110
+ #
111
+ # @example declaring dependencies with subfield selectors
112
+ # dependency user: [:user_names, :premium], user_external_resource: [:received_stars]
113
+ # computed def something
114
+ # # Use user and user_external_resource ...
115
+ # end
116
+ #
117
+ # @example declaring dynamic dependencies
118
+ # dependency user: -> (subfields) { "..." }
119
+ # computed def something
120
+ # # Use user ...
121
+ # end
122
+ def dependency(*deps)
123
+ @__computed_model_next_dependency ||= []
124
+ @__computed_model_next_dependency.push(*deps)
125
+ end
126
+
127
+ # Declares a computed field. Normally it follows a call to {#dependency}.
128
+ #
129
+ # @param meth_name [Symbol] a method name to promote to a computed field.
130
+ # Typically used in the form of `computed def ...`.
131
+ # @return [Symbol] the first argument as-is.
132
+ #
133
+ # @example define a field which is calculated from other fields
134
+ # dependency :user, :user_external_resource
135
+ # computed def something
136
+ # # Use user and user_external_resource ...
137
+ # end
138
+ def computed(meth_name)
139
+ var_name = :"@#{meth_name}"
140
+ meth_name_orig = :"#{meth_name}_orig"
141
+ compute_meth_name = :"compute_#{meth_name}"
142
+
143
+ __computed_model_graph << ComputedModel::DepGraph::Node.new(:computed, meth_name, @__computed_model_next_dependency)
144
+ remove_instance_variable(:@__computed_model_next_dependency) if defined?(@__computed_model_next_dependency)
145
+
146
+ alias_method meth_name_orig, meth_name
147
+ define_method(meth_name) do
148
+ raise ComputedModel::NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)
149
+
150
+ __computed_model_check_availability(meth_name)
151
+ instance_variable_get(var_name)
152
+ end
153
+ define_method(compute_meth_name) do
154
+ @__computed_model_stack << @__computed_model_plan[meth_name]
155
+ begin
156
+ instance_variable_set(var_name, send(meth_name_orig))
157
+ ensure
158
+ @__computed_model_stack.pop
159
+ end
160
+ end
161
+ if public_method_defined?(meth_name_orig)
162
+ public meth_name
163
+ elsif protected_method_defined?(meth_name_orig)
164
+ protected meth_name
165
+ else # elsif private_method_defined?(meth_name_orig)
166
+ private meth_name
167
+ end
168
+
169
+ meth_name
170
+ end
171
+
172
+ # A shorthand for simple computed field.
173
+ #
174
+ # Use {#computed} for more complex definition.
175
+ #
176
+ # @param methods [Array<Symbol>] method names to delegate
177
+ # @param to [Symbol] which field to delegate the methods to.
178
+ # This parameter is used for the dependency declaration too.
179
+ # @param allow_nil [nil, Boolean] If `true`,
180
+ # nil receivers are ignored, and nil is returned instead.
181
+ # @param prefix [nil, Symbol] A prefix for the delegating method name.
182
+ # @param include_subfields [nil, Boolean] If `true`,
183
+ # it includes meth_name as a subfield selector.
184
+ # @return [void]
185
+ #
186
+ # @example delegate name from raw_user
187
+ # delegate_dependency :name, to: :raw_user
188
+ #
189
+ # @example delegate name from raw_user, but expose as user_name
190
+ # delegate_dependency :name, to: :raw_user, prefix: :user
191
+ def delegate_dependency(*methods, to:, allow_nil: nil, prefix: nil, include_subfields: nil)
192
+ method_prefix = prefix ? "#{prefix}_" : ""
193
+ methods.each do |meth_name|
194
+ pmeth_name = :"#{method_prefix}#{meth_name}"
195
+ if include_subfields
196
+ dependency to=>meth_name
197
+ else
198
+ dependency to
199
+ end
200
+ if allow_nil
201
+ define_method(pmeth_name) do
202
+ send(to)&.public_send(meth_name)
203
+ end
204
+ else
205
+ define_method(pmeth_name) do
206
+ send(to).public_send(meth_name)
207
+ end
208
+ end
209
+ computed pmeth_name
210
+ end
211
+ end
212
+
213
+ # Declares a loaded field. See {#dependency} and {#define_primary_loader} too.
214
+ #
215
+ # `define_loader :foo do ... end` generates a reader `foo` and a writer `foo=`.
216
+ # The writer only exists for historical reasons.
217
+ #
218
+ # The block passed to `define_loader` is called a loader.
219
+ # Loader should return a hash containing field values.
220
+ #
221
+ # - The keys of the hash must match `record.instance_exec(&key)`.
222
+ # - The values of the hash represents the field values.
223
+ #
224
+ # @param meth_name [Symbol] the name of the loaded field.
225
+ # @param key [Proc] The proc to collect keys. In the proc, `self` evaluates to the record instance.
226
+ # Typically `-> { id }`.
227
+ # @return [void]
228
+ # @raise [ArgumentError] if no block is given
229
+ # @yield [keys, subfields, **options]
230
+ # @yieldparam keys [Array] the array of keys.
231
+ # @yieldparam subfields [Array] subfield selectors
232
+ # @yieldparam options [Hash] the batch-loading parameters.
233
+ # The keyword arguments to {#bulk_load_and_compute} will be passed down here as-is.
234
+ # @yieldreturn [Hash] a hash containing field values.
235
+ #
236
+ # @example define a loader for ActiveRecord-based models
237
+ # define_loader :user_aux_data, key: -> { id } do |user_ids, subfields, **options|
238
+ # UserAuxData.where(user_id: user_ids).preload(subfields).group_by(&:id)
239
+ # end
240
+ def define_loader(meth_name, key:, &block)
241
+ raise ArgumentError, "No block given" unless block
242
+
243
+ var_name = :"@#{meth_name}"
244
+ loader_name = :"__computed_model_load_#{meth_name}"
245
+ writer_name = :"#{meth_name}="
246
+
247
+ __computed_model_graph << ComputedModel::DepGraph::Node.new(:loaded, meth_name, @__computed_model_next_dependency)
248
+ remove_instance_variable(:@__computed_model_next_dependency) if defined?(@__computed_model_next_dependency)
249
+ define_singleton_method(loader_name) do |objs, subfields, **options|
250
+ keys = objs.map { |o| o.instance_exec(&key) }
251
+ field_values = block.call(keys, subfields, **options)
252
+ objs.zip(keys) do |obj, key|
253
+ obj.send(writer_name, field_values[key])
254
+ end
255
+ end
256
+
257
+ define_method(meth_name) do
258
+ raise ComputedModel::NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)
259
+
260
+ __computed_model_check_availability(meth_name)
261
+ instance_variable_get(var_name)
262
+ end
263
+ # TODO: remove writer?
264
+ attr_writer meth_name
265
+ end
266
+
267
+ # Declares a primary field. See {#define_loader} and {#dependency} too.
268
+ # ComputedModel should have exactly one primary field.
269
+ #
270
+ # `define_primary_loader :foo do ... end` generates a reader `foo` and
271
+ # a writer `foo=`.
272
+ # The writer only exists for historical reasons.
273
+ #
274
+ # The block passed to `define_loader` is called a primary loader.
275
+ # The primary loader's responsibility is batch loading + enumeration (search).
276
+ # In contrast to {#define_loader}, where a hash of field values are returned,
277
+ # the primary loader should return an array of record objects.
278
+ #
279
+ # For example, if your class is `User`, the primary loader must return `Array<User>`.
280
+ #
281
+ # Additionally, the primary loader must initialize all the record objects
282
+ # so that the same instance variable `@#{meth_name}` is set.
283
+ #
284
+ # @param meth_name [Symbol] the name of the loaded field.
285
+ # @return [Array] an array of record objects.
286
+ # @raise [ArgumentError] if no block is given
287
+ # @raise [ArgumentError] if it follows a {#dependency} declaration
288
+ # @yield [subfields, **options]
289
+ # @yieldparam subfields [Array] subfield selectors
290
+ # @yieldparam options [Hash] the batch-loading parameters.
291
+ # The keyword arguments to {#bulk_load_and_compute} will be passed down here as-is.
292
+ # @yieldreturn [void]
293
+ #
294
+ # @example define a primary loader for ActiveRecord-based models
295
+ # class User
296
+ # include ComputedModel::Model
297
+ #
298
+ # def initialize(raw_user)
299
+ # # @raw_user must match the name of the primary loader
300
+ # @raw_user = raw_user
301
+ # end
302
+ #
303
+ # define_primary_loader :raw_user do |subfields, **options|
304
+ # raw_users = RawUser.where(id: user_ids).preload(subfields)
305
+ # # Create User instances
306
+ # raw_users.map { |raw_user| User.new(raw_user) }
307
+ # end
308
+ # end
309
+ def define_primary_loader(meth_name, &block)
310
+ # TODO: The current API requires the user to initialize a specific instance variable.
311
+ # TODO: this design is a bit ugly.
312
+ if defined?(@__computed_model_next_dependency)
313
+ remove_instance_variable(:@__computed_model_next_dependency)
314
+ raise ArgumentError, 'primary field cannot have a dependency'
315
+ end
316
+ raise ArgumentError, "No block given" unless block
317
+
318
+ var_name = :"@#{meth_name}"
319
+ loader_name = :"__computed_model_enumerate_#{meth_name}"
320
+
321
+ __computed_model_graph << ComputedModel::DepGraph::Node.new(:primary, meth_name, {})
322
+ define_singleton_method(loader_name) do |subfields, **options|
323
+ block.call(subfields, **options)
324
+ end
325
+
326
+ define_method(meth_name) do
327
+ raise ComputedModel::NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)
328
+
329
+ __computed_model_check_availability(meth_name)
330
+ instance_variable_get(var_name)
331
+ end
332
+ # TODO: remove writer?
333
+ attr_writer meth_name
334
+ end
335
+
336
+ # The core routine for batch-loading.
337
+ #
338
+ # Each model class is expected to provide its own wrapper of this method. See CONCEPTS.md for examples.
339
+ #
340
+ # @param deps [Array<Symbol, Hash{Symbol=>Array, Object}>] dependency list. Same format as {#dependency}.
341
+ # See {ComputedModel.normalize_dependencies} too.
342
+ # @param options [Hash] the batch-loading parameters.
343
+ # Passed down as-is to loaders ({#define_loader}) and the primary loader ({#define_primary_loader}).
344
+ # @return [Array<Object>] The array of record objects, with requested fields filled in.
345
+ # @raise [ComputedModel::CyclicDependency] if the graph has a cycle
346
+ # @raise [ArgumentError] if the graph lacks a primary field
347
+ # @raise [RuntimeError] if the graph has multiple primary fields
348
+ # @raise [RuntimeError] if the graph has a dangling dependency (reference to an undefined field)
349
+ def bulk_load_and_compute(deps, **options)
350
+ objs = nil
351
+ sorted = __computed_model_sorted_graph
352
+ plan = sorted.plan(deps)
353
+ plan.load_order.each do |node|
354
+ case sorted.original[node.name].type
355
+ when :primary
356
+ loader_name = :"__computed_model_enumerate_#{node.name}"
357
+ objs = send(loader_name, ComputedModel.filter_subfields(node.subfields), **options)
358
+ dummy_toplevel_node = ComputedModel::Plan::Node.new(nil, plan.toplevel, nil)
359
+ objs.each do |obj|
360
+ obj.instance_variable_set(:@__computed_model_plan, plan)
361
+ obj.instance_variable_set(:@__computed_model_stack, [dummy_toplevel_node])
362
+ end
363
+ when :loaded
364
+ loader_name = :"__computed_model_load_#{node.name}"
365
+ objs.each do |obj|
366
+ obj.instance_variable_get(:@__computed_model_stack) << node
367
+ end
368
+ begin
369
+ send(loader_name, objs, ComputedModel.filter_subfields(node.subfields), **options)
370
+ ensure
371
+ objs.each do |obj|
372
+ obj.instance_variable_get(:@__computed_model_stack).pop
373
+ end
374
+ end
375
+ else # when :computed
376
+ objs.each do |obj|
377
+ obj.send(:"compute_#{node.name}")
378
+ end
379
+ end
380
+ end
381
+
382
+ objs
383
+ end
384
+
385
+ # Verifies the dependency graph for errors. Useful for early error detection.
386
+ # It also prevents concurrency issues.
387
+ #
388
+ # Place it after all the relevant declarations. Otherwise a mysterious bug may occur.
389
+ #
390
+ # @return [void]
391
+ # @raise [ComputedModel::CyclicDependency] if the graph has a cycle
392
+ # @raise [ArgumentError] if the graph lacks a primary field
393
+ # @raise [RuntimeError] if the graph has multiple primary fields
394
+ # @raise [RuntimeError] if the graph has a dangling dependency (reference to an undefined field)
395
+ # @example
396
+ # class User
397
+ # computed def foo
398
+ # # ...
399
+ # end
400
+ #
401
+ # # ...
402
+ #
403
+ # verify_dependencies
404
+ # end
405
+ def verify_dependencies
406
+ __computed_model_sorted_graph
407
+ nil
408
+ end
409
+
410
+ # @return [ComputedModel::DepGraph::Sorted]
411
+ private def __computed_model_sorted_graph
412
+ @__computed_model_sorted_graph ||= __computed_model_merged_graph.tsort
413
+ end
414
+
415
+ # @return [ComputedModel::DepGraph]
416
+ private def __computed_model_merged_graph
417
+ graphs = ancestors.reverse.map { |m| m.respond_to?(:__computed_model_graph, true) ? m.send(:__computed_model_graph) : nil }.compact
418
+ ComputedModel::DepGraph.merge(graphs)
419
+ end
420
+
421
+ # @return [ComputedModel::DepGraph]
422
+ private def __computed_model_graph
423
+ @__computed_model_graph ||= ComputedModel::DepGraph.new
424
+ end
425
+ end
426
+
427
+ # Returns dependency of the currently computing field,
428
+ # or the toplevel dependency if called outside of computed fields.
429
+ # @return [Set<Symbol>]
430
+ def current_deps
431
+ @__computed_model_stack.last.deps
432
+ end
433
+
434
+ # Returns subfield selectors passed to the currently computing field,
435
+ # or nil if called outside of computed fields.
436
+ # @return [ComputedModel::NormalizableArray, nil]
437
+ def current_subfields
438
+ @__computed_model_stack.last.subfields
439
+ end
440
+
441
+ # @param name [Symbol]
442
+ private def __computed_model_check_availability(name)
443
+ return if @__computed_model_stack.last.deps.include?(name)
444
+
445
+ raise ComputedModel::ForbiddenDependency, "Not a direct dependency: #{name}"
446
+ end
447
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module ComputedModel
6
+ # A plan for batch loading. Created by {ComputedModel::DepGraph::Sorted#plan}.
7
+ #
8
+ # @api private
9
+ class Plan
10
+ # @return [Array<ComputedModel::Plan::Node>] fields in load order
11
+ attr_reader :load_order
12
+ # @return [Set<Symbol>] toplevel dependencies
13
+ attr_reader :toplevel
14
+
15
+ # @param load_order [Array<ComputedModel::Plan::Node>] fields in load order
16
+ # @param toplevel [Set<Symbol>] toplevel dependencies
17
+ def initialize(load_order, toplevel)
18
+ @load_order = load_order.freeze
19
+ @nodes = load_order.map { |node| [node.name, node] }.to_h
20
+ @toplevel = toplevel
21
+ end
22
+
23
+ # @param name [Symbol]
24
+ # @return [ComputedModel::Plan::Node, nil]
25
+ def [](name)
26
+ @nodes[name]
27
+ end
28
+
29
+ # A set of information necessary to invoke the loader or the computed def.
30
+ class Node
31
+ # @return [Symbol] field name
32
+ attr_reader :name
33
+ # @return [Set<Symbol>] set of dependency names
34
+ attr_reader :deps
35
+ # @return [ComputedModel::NormalizableArray] subfield selectors, payloads sent to the dependency
36
+ attr_reader :subfields
37
+
38
+ # @param name [Symbol] field name
39
+ # @param deps [Set<Symbol>] set of dependency names
40
+ # @param subfields [ComputedModel::NormalizableArray] subfield selectors, payloads sent to the dependency
41
+ def initialize(name, deps, subfields)
42
+ @name = name
43
+ @deps = deps
44
+ @subfields = subfields
45
+ end
46
+ end
47
+ end
48
+ end