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.
@@ -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