computed_model 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,166 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "computed_model/version"
4
- require 'set'
5
-
4
+ require "computed_model/plan"
5
+ require "computed_model/dep_graph"
6
+ require "computed_model/model"
7
+
8
+ # ComputedModel is a universal batch loader which comes with a dependency-resolution algorithm.
9
+ #
10
+ # - Thanks to the dependency resolution, it allows you to the following trifecta at once, without breaking abstraction.
11
+ # - Process information gathered from datasources (such as ActiveRecord) and return the derived one.
12
+ # - Prevent N+1 problem via batch loading.
13
+ # - Load only necessary data.
14
+ # - Can load data from multiple datasources.
15
+ # - Designed to be universal and datasource-independent.
16
+ # For example, you can gather data from both HTTP and ActiveRecord and return the derived one.
17
+ #
18
+ # See {ComputedModel::Model} for basic usage.
6
19
  module ComputedModel
20
+ # An error raised when you tried to read from a loaded/computed attribute,
21
+ # but that attribute isn't loaded by the batch loader.
7
22
  class NotLoaded < StandardError; end
8
23
 
9
- Plan = Struct.new(:load_order, :subdeps_hash)
10
-
11
- module ClassMethods
12
- # @param deps [Array]
13
- def dependency(*deps)
14
- @__computed_model_next_dependency ||= []
15
- @__computed_model_next_dependency.push(*deps)
16
- end
17
-
18
- # @param meth_name [Symbol]
19
- def computed(meth_name)
20
- var_name = :"@#{meth_name}"
21
- meth_name_orig = :"#{meth_name}_orig"
22
- compute_meth_name = :"compute_#{meth_name}"
23
-
24
- @__computed_model_dependencies[meth_name] = ComputedModel.normalize_dependencies(@__computed_model_next_dependency)
25
- remove_instance_variable(:@__computed_model_next_dependency)
26
-
27
- alias_method meth_name_orig, meth_name
28
- define_method(meth_name) do
29
- raise NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)
30
- instance_variable_get(var_name)
31
- end
32
- define_method(compute_meth_name) do
33
- instance_variable_set(var_name, send(meth_name_orig))
34
- end
35
- if public_method_defined?(meth_name_orig)
36
- public meth_name
37
- elsif protected_method_defined?(meth_name_orig)
38
- protected meth_name
39
- elsif private_method_defined?(meth_name_orig)
40
- private meth_name
41
- end
42
-
43
- meth_name
44
- end
45
-
46
- # @param methods [Array<Symbol>]
47
- # @param to [Symbol]
48
- # @param allow_nil [nil, Boolean]
49
- # @param prefix [nil, Symbol]
50
- # @param include_subdeps [nil, Boolean]
51
- # @return [void]
52
- def delegate_dependency(*methods, to:, allow_nil: nil, prefix: nil, include_subdeps: nil)
53
- method_prefix = prefix ? "#{prefix_}" : ""
54
- methods.each do |meth_name|
55
- pmeth_name = :"#{method_prefix}#{meth_name}"
56
- if include_subdeps
57
- dependency to=>meth_name
58
- else
59
- dependency to
60
- end
61
- if allow_nil
62
- define_method(pmeth_name) do
63
- send(to)&.public_send(meth_name)
64
- end
65
- else
66
- define_method(pmeth_name) do
67
- send(to).public_send(meth_name)
68
- end
69
- end
70
- computed pmeth_name
71
- end
72
- end
73
-
74
- # @param meth_name [Symbol]
75
- # @yieldparam objects [Array]
76
- # @yieldparam options [Hash]
77
- # @yieldreturn [void]
78
- def define_loader(meth_name, &block)
79
- raise ArgumentError, "No block given" unless block
80
-
81
- var_name = :"@#{meth_name}"
82
-
83
- @__computed_model_loaders[meth_name] = block
84
-
85
- define_method(meth_name) do
86
- raise NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)
87
- instance_variable_get(var_name)
88
- end
89
- attr_writer meth_name
90
- end
91
-
92
- # @param objs [Array]
93
- # @param deps [Array]
94
- def bulk_load_and_compute(objs, deps, **options)
95
- plan = computing_plan(deps)
96
- plan.load_order.each do |dep_name|
97
- if @__computed_model_dependencies.key?(dep_name)
98
- objs.each do |obj|
99
- obj.send(:"compute_#{dep_name}")
100
- end
101
- elsif @__computed_model_loaders.key?(dep_name)
102
- @__computed_model_loaders[dep_name].call(objs, plan.subdeps_hash[dep_name], **options)
103
- else
104
- raise "No dependency info for #{self}##{dep_name}"
105
- end
106
- end
107
- end
108
-
109
- # @param deps [Array]
110
- # @return [Plan]
111
- def computing_plan(deps)
112
- normalized = ComputedModel.normalize_dependencies(deps)
113
- load_order = []
114
- subdeps_hash = {}
115
- visiting = Set[]
116
- visited = Set[]
117
- normalized.each do |dep_name, dep_subdeps|
118
- computing_plan_dfs(dep_name, dep_subdeps, load_order, subdeps_hash, visiting, visited)
119
- end
120
-
121
- Plan.new(load_order, subdeps_hash)
122
- end
123
-
124
- # @param meth_name [Symbol]
125
- # @param meth_subdeps [Array]
126
- # @param load_order [Array<Symbol>]
127
- # @param subdeps_hash [Hash{Symbol=>Array}]
128
- # @param visiting [Set<Symbol>]
129
- # @param visited [Set<Symbol>]
130
- private def computing_plan_dfs(meth_name, meth_subdeps, load_order, subdeps_hash, visiting, visited)
131
- (subdeps_hash[meth_name] ||= []).push(*meth_subdeps)
132
- return if visited.include?(meth_name)
133
- raise "Cyclic dependency for #{self}##{meth_name}" if visiting.include?(meth_name)
134
- visiting.add(meth_name)
135
-
136
- if @__computed_model_dependencies.key?(meth_name)
137
- @__computed_model_dependencies[meth_name].each do |dep_name, dep_subdeps|
138
- computing_plan_dfs(dep_name, dep_subdeps, load_order, subdeps_hash, visiting, visited)
139
- end
140
- elsif @__computed_model_loaders.key?(meth_name)
141
- else
142
- raise "No dependency info for #{self}##{meth_name}"
143
- end
144
-
145
- load_order << meth_name
146
- visiting.delete(meth_name)
147
- visited.add(meth_name)
148
- end
149
- end
150
-
151
- # @param deps [Array<Symbol, Hash>]
152
- # @return [Hash{Symbol=>Array}]
24
+ # An error raised when you tried to read from a loaded/computed attribute,
25
+ # but that attribute isn't listed in the dependencies list.
26
+ class ForbiddenDependency < StandardError; end
27
+
28
+ # An error raised when the dependency graph contains a cycle.
29
+ class CyclicDependency < StandardError; end
30
+
31
+ # Normalizes dependency list as a hash.
32
+ #
33
+ # Normally you don't need to call it directly.
34
+ # {ComputedModel::Model::ClassMethods#dependency}, {ComputedModel::Model::ClassMethods#bulk_load_and_compute}, and
35
+ # {ComputedModel::NormalizableArray#normalized} will internally use this function.
36
+ #
37
+ # @param deps [Array<(Symbol, Hash)>, Hash, Symbol] dependency list
38
+ # @return [Hash{Symbol=>Array}] normalized dependency hash
39
+ # @raise [RuntimeError] if the dependency list contains values other than Symbol or Hash
40
+ # @example
41
+ # ComputedModel.normalize_dependencies([:foo, :bar])
42
+ # # => { foo: [true], bar: [true] }
43
+ #
44
+ # @example
45
+ # ComputedModel.normalize_dependencies([:foo, bar: :baz])
46
+ # # => { foo: [true], bar: [true, :baz] }
47
+ #
48
+ # @example
49
+ # ComputedModel.normalize_dependencies(foo: -> (subfields) { true })
50
+ # # => { foo: [#<Proc:...>] }
153
51
  def self.normalize_dependencies(deps)
154
52
  normalized = {}
155
- deps.each do |elem|
53
+ deps = [deps] if deps.is_a?(Hash)
54
+ Array(deps).each do |elem|
156
55
  case elem
157
56
  when Symbol
158
- normalized[elem] ||= []
57
+ normalized[elem] ||= [true]
159
58
  when Hash
160
59
  elem.each do |k, v|
161
60
  v = [v] if v.is_a?(Hash)
162
61
  normalized[k] ||= []
163
62
  normalized[k].push(*Array(v))
63
+ normalized[k].push(true) if v == []
164
64
  end
165
65
  else; raise "Invalid dependency: #{elem.inspect}"
166
66
  end
@@ -168,10 +68,35 @@ module ComputedModel
168
68
  normalized
169
69
  end
170
70
 
171
- def self.included(klass)
172
- super
173
- klass.extend ClassMethods
174
- klass.instance_variable_set(:@__computed_model_dependencies, {})
175
- klass.instance_variable_set(:@__computed_model_loaders, {})
71
+ # Removes `nil`, `true` and `false` from the given array.
72
+ #
73
+ # Normally you don't need to call it directly.
74
+ # {ComputedModel::Model::ClassMethods#define_loader},
75
+ # {ComputedModel::Model::ClassMethods#define_primary_loader}, and
76
+ # {ComputedModel::NormalizableArray#normalized} will internally use this function.
77
+ #
78
+ # @param subfields [Array] subfield selector list
79
+ # @return [Array] the filtered one
80
+ # @example
81
+ # ComputedModel.filter_subfields([false, {}, true, nil, { foo: :bar }])
82
+ # # => [{}, { foo: :bar }]
83
+ def self.filter_subfields(subfields)
84
+ subfields.select { |x| x && x != true }
85
+ end
86
+
87
+ # Convenience class to easily access normalized version of dependencies.
88
+ #
89
+ # You don't need to directly use it.
90
+ #
91
+ # - {ComputedModel::Model#current_subfields} returns NormalizableArray.
92
+ # - Procs passed to {ComputedModel::Model::ClassMethods#dependency} will receive NormalizeArray.
93
+ class NormalizableArray < Array
94
+ # Returns the normalized hash of the dependencies.
95
+ # @return [Hash{Symbol=>Array}] the normalized hash of the dependencies
96
+ # @raise [RuntimeError] if the list isn't valid as a dependency list.
97
+ # See {ComputedModel.normalize_dependencies} for details.
98
+ def normalized
99
+ @normalized ||= ComputedModel.normalize_dependencies(ComputedModel.filter_subfields(self))
100
+ end
176
101
  end
177
102
  end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module ComputedModel
6
+ # A dependency graph representation used within ComputedModel::Model.
7
+ # Usually you don't need to use this class directly.
8
+ #
9
+ # @api private
10
+ #
11
+ # @example
12
+ # graph = ComputedModel::DepGraph.new
13
+ # graph << ComputedModel::DepGraph::Node.new(:computed, :foo, { bar: [] })
14
+ # graph << ComputedModel::DepGraph::Node.new(:loaded, :bar, {})
15
+ # plan = graph.tsort.plan([:foo])
16
+ class DepGraph
17
+ def initialize
18
+ @nodes = {}
19
+ end
20
+
21
+ # Returns the node with the specified name.
22
+ #
23
+ # @param name [Symbol] the name of the node
24
+ # @return [Node, nil]
25
+ #
26
+ # @example
27
+ # graph = ComputedModel::DepGraph.new
28
+ # graph[:foo]
29
+ def [](name)
30
+ @nodes[name]
31
+ end
32
+
33
+ # Adds the new node.
34
+ #
35
+ # @param node [Node]
36
+ # @return [void]
37
+ # @raise [ArgumentError] when the node already exists
38
+ #
39
+ # @example
40
+ # graph = ComputedModel::DepGraph.new
41
+ # graph << ComputedModel::DepGraph::Node.new(:computed, :foo, {})
42
+ def <<(node)
43
+ raise ArgumentError, "Field already declared: #{node.name}" if @nodes.key?(node.name)
44
+
45
+ @nodes[node.name] = node
46
+ end
47
+
48
+ # @return [Array<ComputedModel::DepGraph::Node>]
49
+ def nodes
50
+ @nodes.values
51
+ end
52
+
53
+ # Preprocess the graph by topological sorting. This is a necessary step for loader planning.
54
+ #
55
+ # @return [ComputedModel::DepGraph::Sorted]
56
+ #
57
+ # @example
58
+ # graph = ComputedModel::DepGraph.new
59
+ # graph << ComputedModel::DepGraph::Node.new(:computed, :foo, { bar: [] })
60
+ # graph << ComputedModel::DepGraph::Node.new(:loaded, :bar, {})
61
+ # sorted = graph.tsort
62
+ def tsort
63
+ load_order = []
64
+ visiting = Set[]
65
+ visited = Set[]
66
+
67
+ @nodes.each_value do |node|
68
+ next unless node.type == :primary
69
+
70
+ load_order << node.name
71
+ visiting.add node.name
72
+ visited.add node.name
73
+ end
74
+
75
+ raise ArgumentError, 'No primary loader defined' if load_order.empty?
76
+ raise "Multiple primary fields: #{load_order.inspect}" if load_order.size > 1
77
+
78
+ @nodes.each_value do |node|
79
+ tsort_dfs(node.name, load_order, visiting, visited)
80
+ end
81
+
82
+ nodes_in_order = load_order.reverse.map { |name| @nodes[name] }
83
+ ComputedModel::DepGraph::Sorted.new(self, nodes_in_order)
84
+ end
85
+
86
+ private def tsort_dfs(name, load_order, visiting, visited)
87
+ return if visited.include?(name)
88
+ raise ComputedModel::CyclicDependency, "Cyclic dependency for ##{name}" if visiting.include?(name)
89
+ raise "No dependency info for ##{name}" unless @nodes.key?(name)
90
+
91
+ visiting.add(name)
92
+
93
+ @nodes[name].edges.each_value do |edge|
94
+ tsort_dfs(edge.name, load_order, visiting, visited)
95
+ end
96
+
97
+ load_order << name
98
+ visiting.delete(name)
99
+ visited.add(name)
100
+ end
101
+
102
+ # @param graphs [Array<ComputedModel::DepGraph>]
103
+ # @return [ComputedModel::DepGraph]
104
+ def self.merge(graphs)
105
+ new_graph = ComputedModel::DepGraph.new
106
+ nodes_by_name = graphs.flat_map(&:nodes).group_by(&:name)
107
+ nodes_by_name.each do |name, nodes|
108
+ type = nodes.first.type
109
+ raise ArgumentError, "Field #{name} has multiple different types" unless nodes.all? { |node| node.type == type }
110
+
111
+ new_graph << ComputedModel::DepGraph::Node.new(type, name, nodes.map { |n| n.edges.transform_values { |e| e.spec } })
112
+ end
113
+ new_graph
114
+ end
115
+
116
+ # A node in the dependency graph. That is, a field in a computed model.
117
+ #
118
+ # @example computed node with plain dependencies
119
+ # Node.new(:computed, :field1, { field2: [], field3: [] })
120
+ # @example computed node with subfield selectors
121
+ # Node.new(:computed, :field1, { field2: [:foo, bar: []], field3: [] })
122
+ # @example loaded and primary dependencies
123
+ # Node.new(:loaded, :field1, {})
124
+ # Node.new(:primary, :field1, {})
125
+ class Node
126
+ # @return [Symbol] the type of the node. One of :computed, :loaded and :primary.
127
+ attr_reader :type
128
+ # @return [Symbol] the name of the node.
129
+ attr_reader :name
130
+ # @return [Hash{Symbol => Edge}] edges indexed by its name.
131
+ attr_reader :edges
132
+
133
+ ALLOWED_TYPES = %i[computed loaded primary].freeze
134
+ private_constant :ALLOWED_TYPES
135
+
136
+ # @param type [Symbol] the type of the node. One of :computed, :loaded and :primary.
137
+ # @param name [Symbol] the name of the node.
138
+ # @param edges [Array<(Symbol, Hash)>, Hash, Symbol] list of edges.
139
+ def initialize(type, name, edges)
140
+ raise ArgumentError, "invalid type: #{type.inspect}" unless ALLOWED_TYPES.include?(type)
141
+
142
+ edges = ComputedModel.normalize_dependencies(edges)
143
+ raise ArgumentError, "primary field cannot have dependency: #{name}" if type == :primary && edges.size > 0
144
+
145
+ @type = type
146
+ @name = name
147
+ @edges = edges.map { |k, v| [k, Edge.new(k, v)] }.to_h.freeze
148
+ end
149
+ end
150
+
151
+ # An edge in the dependency graph. That is, a dependency declaration in a computed model.
152
+ class Edge
153
+ # @return [Symbol] the name of the dependency (not the dependent)
154
+ attr_reader :name
155
+ # @return [Array] an auxiliary data called subfield selectors
156
+ attr_reader :spec
157
+
158
+ # @param name [Symbol] the name of the dependency (not the dependent)
159
+ # @param spec [Array] an auxiliary data called subfield selectors
160
+ def initialize(name, spec)
161
+ @name = name
162
+ @spec = Array(spec)
163
+ end
164
+
165
+ # @param subfields [Array] incoming list of subfield selectors
166
+ # @return [Array, nil]
167
+ def evaluate(subfields)
168
+ return @spec if @spec.all? { |specelem| !specelem.respond_to?(:call) }
169
+
170
+ evaluated = []
171
+ @spec.each do |specelem|
172
+ if specelem.respond_to?(:call)
173
+ ret = specelem.call(subfields)
174
+ if ret.is_a?(Array)
175
+ evaluated.push(*ret)
176
+ else
177
+ evaluated << ret
178
+ end
179
+ else
180
+ evaluated << specelem
181
+ end
182
+ end
183
+ evaluated
184
+ end
185
+ end
186
+
187
+ # A preprocessed graph with topologically sorted order.
188
+ #
189
+ # Generated by {ComputedModel::DepGraph#tsort}.
190
+ class Sorted
191
+ # @return [ComputedModel::DepGraph]
192
+ attr_reader :original
193
+ # @return [Array<ComputedModel::DepGraph::Node>]
194
+ attr_reader :nodes_in_order
195
+
196
+ # @param original [ComputedModel::DepGraph]
197
+ # @param nodes_in_order [Array<ComputedModel::DepGraph::Node>]
198
+ def initialize(original, nodes_in_order)
199
+ @original = original
200
+ @nodes_in_order = nodes_in_order
201
+ end
202
+
203
+ # Computes the plan for the given requirements.
204
+ #
205
+ # @param deps [Array] the list of required nodes. Each dependency can optionally include subfields hashes.
206
+ # @return [ComputedModel::Plan]
207
+ #
208
+ # @example Plain dependencies
209
+ # sorted.plan([:field1, :field2])
210
+ #
211
+ # @example Dependencies with subfields
212
+ # sorted.plan([:field1, field2: { optional_field: {} }])
213
+ def plan(deps)
214
+ normalized = ComputedModel.normalize_dependencies(deps)
215
+ subfields_hash = {}
216
+ uses = Set[]
217
+ plan_nodes = []
218
+
219
+ normalized.each do |name, subfields|
220
+ raise "No dependency info for ##{name}" unless @original[name]
221
+
222
+ uses.add(name)
223
+ (subfields_hash[name] ||= []).unshift(*subfields)
224
+ end
225
+ @nodes_in_order.each do |node|
226
+ uses.add(node.name) if node.type == :primary
227
+ next unless uses.include?(node.name)
228
+
229
+ node_subfields = ComputedModel::NormalizableArray.new(subfields_hash[node.name] || [])
230
+ deps = Set[]
231
+ node.edges.each_value do |edge|
232
+ specval = edge.evaluate(node_subfields)
233
+ if specval.any?
234
+ deps.add(edge.name)
235
+ uses.add(edge.name)
236
+ (subfields_hash[edge.name] ||= []).unshift(*specval)
237
+ end
238
+ end
239
+ plan_nodes.push(ComputedModel::Plan::Node.new(node.name, deps, node_subfields))
240
+ end
241
+ ComputedModel::Plan.new(plan_nodes.reverse, normalized.keys.to_set)
242
+ end
243
+ end
244
+ end
245
+ end