mochigome 0.0.3 → 0.0.4

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/lib/query.rb CHANGED
@@ -1,199 +1,346 @@
1
1
  require 'rgl/adjacency'
2
+ require 'rgl/traversal'
2
3
 
3
4
  module Mochigome
4
5
  class Query
5
- def initialize(layer_types, name = "report")
6
+ def initialize(layer_types, options = {})
6
7
  # TODO: Validate layer types: not empty, AR, act_as_mochigome_focus, graph correctly, no repeats
7
8
  @layer_types = layer_types
8
- @name = name
9
- end
10
-
11
- def run(objs)
12
- objs = [objs] unless objs.is_a?(Enumerable)
13
- return DataNode.new(:report, @name) if objs.size == 0 # Empty DataNode for empty input
9
+ @layers_path = self.class.path_thru(layer_types)
14
10
 
15
- unless objs.all?{|obj| obj.class == objs.first.class}
16
- raise QueryError.new("Query target objects must all be the same type")
11
+ @name = options.delete(:root_name).try(:to_s) || "report"
12
+ @access_filter = options.delete(:access_filter) || lambda {|cls| {}}
13
+ aggregate_sources = options.delete(:aggregate_sources) || []
14
+ unless options.empty?
15
+ raise QueryError.new("Unknown options: #{options.keys.inspect}")
17
16
  end
18
17
 
19
- unless @layer_types.any?{|layer| objs.first.is_a?(layer)}
20
- raise QueryError.new("Query target object type must be in the query layer list")
18
+ @ids_rel = self.class.relation_over_path(@layers_path).
19
+ project(@layers_path.map{|m|
20
+ Arel::Table.new(m.table_name)[m.primary_key]
21
+ })
22
+ @ids_rel = access_filtered_relation(@ids_rel, @layers_path)
23
+
24
+ # TODO: Validate that aggregate_sources is in the correct format
25
+ aggs_by_model = {}
26
+ aggregate_sources.each do |focus_cls, data_cls|
27
+ aggs_by_model[focus_cls] ||= []
28
+ aggs_by_model[focus_cls] << data_cls
21
29
  end
22
30
 
23
- # Used to provide debugging information in the root DataNode comment
24
- assoc_path = ["== #{objs.first.class.name} =="]
31
+ @aggregate_rels = {}
32
+ aggs_by_model.each do |focus_model, data_models|
33
+ # Need to do a relation over the entire path in case query runs
34
+ # on something other than the focus model layer.
35
+ # TODO: Would be better to only do this if necesssitated by the
36
+ # conditions supplied to the query when it is ran, and/or
37
+ # the access filter.
38
+ focus_rel = self.class.relation_over_path(@layers_path)
25
39
 
26
- # Start at the layer for objs, and descend downwards through layers after that
27
- #TODO: It would be really fantastic if I could just use AR eager loading for this
28
- downwards_layers = @layer_types.drop_while{|cls| !objs.first.is_a?(cls)}
29
- root = DataNode.new(:report, @name)
30
- root << objs.map{|obj| DataNode.new(
31
- obj.mochigome_focus.type_name,
32
- obj.mochigome_focus.name,
33
- [{:obj => obj}]
34
- )}
35
- cur_layer = root.children
36
- downwards_layers.drop(1).each do |cls|
37
- new_layer = []
38
- assoc = Query.edge_assoc(cur_layer.first[:obj].class, cls)
39
-
40
- assoc_str = "-> #{cls.name} via #{cur_layer.first[:obj].class.name}##{assoc.name}"
41
- if assoc.through_reflection
42
- assoc_str << " (thru #{assoc.through_reflection.name})"
43
- end
44
- assoc_path.push assoc_str
45
-
46
- cur_layer.each do |datanode|
47
- # FIXME: Don't assume that downwards means plural association
48
- # TODO: Are there other ways context could matter besides :through assocs?
49
- # i.e. If C belongs_to A and also belongs_to B, and layer_types = [A,B,C]
50
- # TODO: What if a through reflection goes through _another_ through reflection?
51
- if assoc.through_reflection
52
- datanode[:obj].send(assoc.through_reflection.name).each do |through_obj|
53
- # TODO: Don't assume that through means singular!
54
- obj = through_obj.send(assoc.source_reflection.name)
55
- subnode = datanode << DataNode.new(
56
- obj.mochigome_focus.type_name,
57
- obj.mochigome_focus.name,
58
- {:obj => obj, :through_obj => through_obj}
59
- )
60
- new_layer << subnode
61
- end
62
- else
63
- #FIXME: Not DRY
64
- datanode[:obj].send(assoc.name).each do |obj|
65
- subnode = datanode << DataNode.new(
66
- obj.mochigome_focus.type_name,
67
- obj.mochigome_focus.name,
68
- [{:obj => obj}]
69
- )
70
- new_layer << subnode
40
+ # TODO: Properly handle focus model that is not in layer types list
41
+ key_path = @layers_path.take_while{|m| m != focus_model} + [focus_model]
42
+ key_cols = key_path.map{|m|
43
+ Arel::Table.new(m.table_name)[m.primary_key]
44
+ }
45
+
46
+ @aggregate_rels[focus_model] = {}
47
+ data_models.each do |data_model|
48
+ f2d_path = self.class.path_thru([focus_model, data_model]) #TODO: Handle nil here
49
+ agg_path = nil
50
+ f2d_path.reverse.each do |link_model|
51
+ if @layers_path.include?(link_model)
52
+ agg_path = f2d_path.drop_while{|m| m != link_model}
53
+ break
71
54
  end
72
55
  end
56
+ # TODO: Properly handle focus model that is not in layer types list
57
+ fail unless agg_path
58
+
59
+ agg_data_rel = self.class.relation_over_path(agg_path, focus_rel.dup)
60
+ agg_data_rel = access_filtered_relation(agg_data_rel, @layers_path + agg_path)
61
+ data_tbl = Arel::Table.new(data_model.table_name)
62
+ agg_fields = data_model.mochigome_aggregation_settings.options[:fields].reject{|a| a[:in_ruby]}
63
+ agg_data_rel.project
64
+ agg_fields.each_with_index do |a, i|
65
+ agg_data_rel.project(a[:value_proc].call(data_tbl).as("d#{i}"))
66
+ end
67
+
68
+ @aggregate_rels[focus_model][data_model] = (0..key_cols.length).map{|n|
69
+ lambda {|cond|
70
+ d_rel = agg_data_rel.dup
71
+ d_cols = key_cols.take(n) + [Arel::Table.new(data_model.table_name)[data_model.primary_key]]
72
+ d_cols.each_with_index do |col, i|
73
+ d_rel.project(col.as("g#{i}")).group(col)
74
+ end
75
+ d_rel.where(cond)
76
+
77
+ a_rel = Arel::SelectManager.new(
78
+ Arel::Table.engine,
79
+ Arel.sql("(#{d_rel.to_sql}) as mochigome_data")
80
+ )
81
+ d_tbl = Arel::Table.new("mochigome_data")
82
+ agg_fields.each_with_index do |a, i|
83
+ a_rel.project(a[:agg_proc].call(d_tbl["d#{i}"]))
84
+ end
85
+ key_cols.take(n).each_with_index do |col, i|
86
+ outer_name = "og#{i}"
87
+ a_rel.project(d_tbl["g#{i}"].as(outer_name)).group(outer_name)
88
+ end
89
+ a_rel
90
+ }
91
+ }
73
92
  end
74
- cur_layer = new_layer
93
+
75
94
  end
95
+ end
76
96
 
77
- # Take our tree so far and include it in parent trees, going up to the first layer
78
- upwards_layers = @layer_types.take_while{|cls| !objs.first.is_a?(cls)}.reverse
79
- upwards_layers.each do |cls|
80
- assoc = Query.edge_assoc(root.children.first[:obj].class, cls)
97
+ def run(cond = nil)
98
+ root = DataNode.new(:report, @name)
81
99
 
82
- assoc_str = "<- #{cls.name} via #{root.children.first[:obj].class.name}##{assoc.name}"
83
- if assoc.through_reflection
84
- assoc_str << " (thru #{assoc.through_reflection.name})"
100
+ if cond.is_a?(ActiveRecord::Base)
101
+ cond = [cond]
102
+ end
103
+ if cond.is_a?(Array)
104
+ return root if cond.empty?
105
+ unless cond.all?{|obj| obj.class == cond.first.class}
106
+ raise QueryError.new("Query target objects must all be the same type")
85
107
  end
86
- assoc_path.unshift assoc_str
87
-
88
- parent_children_map = ActiveSupport::OrderedHash.new
89
- root.children.each do |child|
90
- if assoc.through_reflection
91
- through_objs = child[:obj].send(assoc.through_reflection.name)
92
- through_objs = [through_objs] unless through_objs.is_a?(Enumerable)
93
- through_objs.each do |through_obj|
94
- # TODO: Don't assume that through means singular!
95
- parent = through_obj.send(assoc.source_reflection.name)
96
- unless parent_children_map.has_key?(parent.id)
97
- attrs = {:obj => parent, :through_obj => through_obj}
98
- parent_children_map[parent.id] = DataNode.new(
99
- parent.mochigome_focus.type_name,
100
- parent.mochigome_focus.name,
101
- attrs
102
- )
103
- end
104
- parent_children_map[parent.id] << child.dup
105
- end
106
- else
107
- #FIXME: Not DRY
108
- parents = child[:obj].send(assoc.name)
109
- parents = [parents] unless parents.is_a?(Enumerable)
110
- parents.each do |parent|
111
- unless parent_children_map.has_key?(parent.id)
112
- attrs = {:obj => parent}
113
- parent_children_map[parent.id] = DataNode.new(
114
- parent.mochigome_focus.name,
115
- parent.mochigome_focus.type_name,
116
- attrs
117
- )
108
+ unless @layer_types.any?{|layer| cond.first.is_a?(layer)}
109
+ raise QueryError.new("Query target's class must be in layer list")
110
+ end
111
+ cls = cond.first.class
112
+ cond = Arel::Table.new(cls.table_name)[cls.primary_key].in(cond.map(&:id))
113
+ end
114
+
115
+ q = @ids_rel.dup
116
+ q.where(cond) if cond
117
+ ids_table = @layer_types.first.connection.select_rows(q.to_sql)
118
+ ids_table = ids_table.map{|row| row.map{|cell| cell.to_i}}
119
+
120
+ fill_layers(ids_table, {:root => root}, @layer_types)
121
+
122
+ @aggregate_rels.each do |focus_model, data_model_rels|
123
+ super_types = @layer_types.take_while{|m| m != focus_model}
124
+ super_cols = super_types.map{|m| @layers_path.find_index(m)}
125
+ data_model_rels.each do |data_model, rel_funcs|
126
+ aggs = data_model.mochigome_aggregation_settings.options[:fields]
127
+ aggs_count = aggs.reject{|a| a[:in_ruby]}.size
128
+ rel_funcs.each do |rel_func|
129
+ q = rel_func.call(cond)
130
+ data_tree = {}
131
+ # Each row has aggs_count data fields, followed by the id fields
132
+ # from least specific to most.
133
+ @layer_types.first.connection.select_rows(q.to_sql).each do |row|
134
+ if row.size == aggs_count
135
+ data_tree = row.take(aggs_count)
136
+ else
137
+ c = data_tree
138
+ super_cols.each_with_index do |sc_num, sc_idx|
139
+ break if aggs_count+sc_idx >= row.size-1
140
+ col_num = aggs_count + super_cols[sc_num]
141
+ c = (c[row[col_num].to_i] ||= {})
142
+ end
143
+ c[row.last.to_i] = row.take(aggs_count)
118
144
  end
119
- parent_children_map[parent.id] << child.dup
120
145
  end
146
+ insert_aggregate_data_fields(root, data_tree, data_model)
121
147
  end
122
148
  end
123
-
124
- root = DataNode.new(:report, @name)
125
- root << parent_children_map.values
126
149
  end
127
150
 
128
151
  root.comment = <<-eos
129
152
  Mochigome Version: #{Mochigome::VERSION}
130
- Time: #{Time.now}
153
+ Report Generated: #{Time.now}
131
154
  Layers: #{@layer_types.map(&:name).join(" => ")}
132
- AR Association Path:
133
- #{assoc_path.map{|s| "* #{s}"}.join("\n")}
155
+ AR Path: #{@layers_path.map(&:name).join(" => ")}
134
156
  eos
135
157
  root.comment.gsub!(/\n +/, "\n")
136
158
  root.comment.lstrip!
137
159
 
138
- focus_data_node_objs(root)
139
160
  return root
140
161
  end
141
162
 
142
163
  private
143
164
 
144
- def focus_data_node_objs(node, obj_stack=[], commenting=true)
145
- pushed = 0
146
- if node.has_key?(:obj)
147
- obj = node.delete(:obj)
148
- if node.has_key?(:through_obj)
149
- obj_stack.push(node.delete(:through_obj)); pushed += 1
165
+ def access_filtered_relation(r, models)
166
+ joined = Set.new
167
+ models.uniq.each do |model|
168
+ h = @access_filter.call(model)
169
+ h.delete(:join_paths).try :each do |path|
170
+ (0..(path.size-2)).each do |i|
171
+ next if models.include?(path[i+1]) or joined.include?(path[i+1])
172
+ r = self.class.relation_func(path[i], path[i+1]).call(r)
173
+ joined.add path[i+1]
174
+ end
150
175
  end
151
- obj_stack.push(obj); pushed += 1
152
- if commenting
153
- node.comment = <<-eos
154
- Context:
155
- #{obj_stack.map{|o| "#{o.class.name}:#{o.id}"}.join("\n")}
156
- eos
157
- node.comment.gsub!(/\n +/, "\n")
158
- node.comment.lstrip!
176
+ if h[:condition]
177
+ r = r.where(h.delete(:condition))
178
+ end
179
+ unless h.empty?
180
+ raise QueryError.new("Unknown assoc filter keys #{h.keys.inspect}")
159
181
  end
160
- node.merge!(obj.mochigome_focus.data(:context => obj_stack))
161
- node[:internal_type] = obj.class.name
162
- end
163
- node.children.each_index do |i|
164
- focus_data_node_objs(node.children[i], obj_stack, i == 0 && commenting)
165
182
  end
166
- pushed.times{ obj_stack.pop }
183
+ r
167
184
  end
168
185
 
169
- @@assoc_graph = nil
170
- @@edge_assocs = {}
171
-
172
- def self.assoc_graph
173
- return @@assoc_graph if @assoc_graph
174
-
175
- # Build a directed graph of the associations between focusable models
176
- @@assoc_graph = RGL::DirectedAdjacencyGraph.new
177
- @@assoc_graph.add_vertices(*Mochigome::reportFocusModels)
178
- Mochigome::reportFocusModels.each do |cls|
179
- # Add any associations that lead to other reportFocusModels
180
- cls.reflections.each do |name, assoc|
181
- if Mochigome::reportFocusModels.include?(assoc.klass)
182
- @@assoc_graph.add_edge(cls, assoc.klass)
183
- @@edge_assocs[[cls, assoc.klass]] = assoc
186
+ def fill_layers(ids_table, parents, types, parent_col_num = nil)
187
+ return if types.size == 0
188
+
189
+ model = types.first
190
+ col_num = @layers_path.find_index(model)
191
+ layer_ids = Set.new
192
+ cur_to_parent = {}
193
+
194
+ ids_table.each do |row|
195
+ cur_id = row[col_num]
196
+ layer_ids.add cur_id
197
+ if parent_col_num
198
+ cur_to_parent[cur_id] ||= Set.new
199
+ cur_to_parent[cur_id].add row[parent_col_num]
200
+ end
201
+ end
202
+
203
+ layer = {}
204
+ model.all( # TODO: Find a way to do this with data streaming
205
+ :conditions => {model.primary_key => layer_ids.to_a},
206
+ :order => model.mochigome_focus_settings.get_ordering
207
+ ).each do |obj|
208
+ f = obj.mochigome_focus
209
+ dn = DataNode.new(f.type_name, f.name)
210
+ dn.merge!(f.field_data)
211
+ # TODO: Maybe make special fields below part of ModelExtensions?
212
+ dn[:id] = obj.id
213
+ dn[:internal_type] = obj.class.name
214
+
215
+ if parent_col_num
216
+ duping = false
217
+ cur_to_parent.fetch(obj.id).each do |parent_id|
218
+ parents.fetch(parent_id) << (duping ? dn.dup : dn)
219
+ duping = true
184
220
  end
221
+ else
222
+ parents[:root] << dn
185
223
  end
224
+ layer[obj.id] = dn
225
+ end
226
+
227
+ fill_layers(ids_table, layer, types.drop(1), col_num)
228
+ end
229
+
230
+ def insert_aggregate_data_fields(node, table, data_model)
231
+ if table.is_a? Array
232
+ fields = data_model.mochigome_aggregation_settings.options[:fields]
233
+ # Pre-fill the node with all fields in the right order
234
+ fields.each{|agg| node[agg[:name]] = nil unless agg[:hidden] }
235
+ agg_row = {} # Hold regular aggs here to be used in ruby-based aggs
236
+ fields.reject{|agg| agg[:in_ruby]}.zip(table).each do |agg, v|
237
+ agg_row[agg[:name]] = v
238
+ node[agg[:name]] = v unless agg[:hidden]
239
+ end
240
+ fields.select{|agg| agg[:in_ruby]}.each do |agg|
241
+ node[agg[:name]] = agg[:ruby_proc].call(agg_row)
242
+ end
243
+ else
244
+ node.children.each do |c|
245
+ subtable = table[c[:id]] or next
246
+ insert_aggregate_data_fields(c, subtable, data_model)
247
+ end
248
+ end
249
+ end
250
+
251
+ # TODO: Move the stuff below into its own module
252
+
253
+ @@graphed_models = Set.new
254
+ @@assoc_graph = RGL::DirectedAdjacencyGraph.new
255
+ @@edge_relation_funcs = {}
256
+ @@shortest_paths = {}
257
+
258
+ def self.relation_over_path(path, rel = nil)
259
+ rel ||= Arel::Table.new(path.first.table_name)
260
+ (0..(path.size-2)).each do |i|
261
+ rel = relation_func(path[i], path[i+1]).call(rel)
186
262
  end
187
- return @@assoc_graph
263
+ rel
188
264
  end
189
265
 
190
- def self.edge_assoc(u, v)
191
- assoc_graph # Make sure @@edge_assocs has been populated
192
- assoc = @@edge_assocs[[u,v]]
193
- unless assoc
194
- raise QueryError.new("No association between #{u} and #{v}")
266
+ def self.relation_func(u, v)
267
+ @@edge_relation_funcs[[u,v]] or
268
+ raise QueryError.new "No assoc from #{u.name} to #{v.name}"
269
+ end
270
+
271
+ def self.path_thru(models)
272
+ update_assoc_graph(models)
273
+ path = [models.first]
274
+ (0..(models.size-2)).each do |i|
275
+ u = models[i]
276
+ v = models[i+1]
277
+ next if u == v
278
+ seg = @@shortest_paths[[u,v]]
279
+ raise QueryError.new("Can't travel from #{u.name} to #{v.name}") unless seg
280
+ seg.drop(1).each{|step| path << step}
281
+ end
282
+ unless path.uniq.size == path.size
283
+ raise QueryError.new(
284
+ "Path thru #{models.map(&:name).join('-')} doubles back: " +
285
+ path.map(&:name).join('-')
286
+ )
287
+ end
288
+ path
289
+ end
290
+
291
+ def self.update_assoc_graph(models)
292
+ model_queue = models.dup
293
+ added_models = []
294
+ until model_queue.empty?
295
+ model = model_queue.shift
296
+ next if @@graphed_models.include? model
297
+ @@graphed_models.add model
298
+ added_models << model
299
+
300
+ model.reflections.
301
+ reject{|name, assoc| assoc.through_reflection}.
302
+ each do |name, assoc|
303
+ # TODO: What about self associations?
304
+ # TODO: What about associations to the same model on different keys?
305
+ next if assoc.options[:polymorphic] # TODO How to deal with these? Check for matching has_X assoc?
306
+ foreign_model = assoc.klass
307
+ edge = [model, foreign_model]
308
+ next if @@assoc_graph.has_edge?(*edge) # Ignore duplicate assocs
309
+ @@assoc_graph.add_edge(*edge)
310
+ @@edge_relation_funcs[edge] = model.arelified_assoc(name)
311
+ unless @@graphed_models.include?(foreign_model)
312
+ model_queue.push(foreign_model)
313
+ end
314
+ end
315
+ end
316
+
317
+ added_models.each do |model|
318
+ next unless @@assoc_graph.has_vertex?(model)
319
+ path_tree = @@assoc_graph.bfs_search_tree_from(model).reverse
320
+ path_tree.depth_first_search do |tgt_model|
321
+ next if tgt_model == model
322
+ path = [tgt_model]
323
+ while (parent = path_tree.adjacent_vertices(path.first).first)
324
+ path.unshift parent
325
+ end
326
+ @@shortest_paths[[model,tgt_model]] = path
327
+ end
328
+
329
+ # Use through reflections as a hint for preferred indirect paths
330
+ model.reflections.
331
+ select{|name, assoc| assoc.through_reflection}.
332
+ each do |name, assoc|
333
+ begin
334
+ foreign_model = assoc.klass
335
+ join_model = assoc.through_reflection.klass
336
+ rescue NameError
337
+ # FIXME Can't handle polymorphic through reflection
338
+ end
339
+ edge = [model,foreign_model]
340
+ next if @@shortest_paths[edge].try(:size).try(:<, 3)
341
+ @@shortest_paths[edge] = [model, join_model, foreign_model]
342
+ end
195
343
  end
196
- return assoc
197
344
  end
198
345
  end
199
346
  end
@@ -1,7 +1,9 @@
1
1
  class Category < ActiveRecord::Base
2
- acts_as_mochigome_focus
2
+ acts_as_mochigome_focus do |f|
3
+ f.ordering :name
4
+ end
3
5
 
4
- has_many :products, :conditions => {:categorized => true}
6
+ has_many :products
5
7
 
6
8
  validates_presence_of :name
7
9
  end
@@ -2,11 +2,20 @@ class Product < ActiveRecord::Base
2
2
  acts_as_mochigome_focus do |f|
3
3
  f.fields [:price]
4
4
  end
5
- has_mochigome_aggregations [
6
- :average_price,
7
- {"Power level" => "9000+1"},
8
- {"Expensive products" => [:count, "price > 40"]}
9
- ]
5
+ has_mochigome_aggregations do |a|
6
+ a.fields [
7
+ :sum_price,
8
+ {"Expensive products" => [
9
+ :count,
10
+ Mochigome::null_unless(
11
+ lambda{|v| v.gt(10.00)},
12
+ lambda{|t| t[:price]}
13
+ )
14
+ ]}
15
+ ]
16
+ a.hidden_fields [ {"Secret count" => :count} ]
17
+ a.fields_in_ruby [ {"Count squared" => lambda{|row| row["Secret count"]**2}} ]
18
+ end
10
19
 
11
20
  belongs_to :category
12
21
  has_many :store_products
@@ -1,5 +1,7 @@
1
1
  class Sale < ActiveRecord::Base
2
- has_mochigome_aggregations [:count]
2
+ has_mochigome_aggregations do |a|
3
+ a.fields [:count]
4
+ end
3
5
 
4
6
  belongs_to :store_product
5
7
  has_one :store, :through => :store_product
@@ -0,0 +1,2 @@
1
+ require 'arel'
2
+ Arel::Table.engine = Arel::Sql::Engine.new(ActiveRecord::Base)
@@ -4,7 +4,6 @@ class CreateTables < ActiveRecord::Migration
4
4
  t.string :name
5
5
  t.decimal :price
6
6
  t.integer :category_id
7
- t.boolean :categorized
8
7
  t.timestamps
9
8
  end
10
9
 
data/test/console.sh ADDED
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ SCRIPT=`readlink -f $0`
3
+ SCRIPTPATH=`dirname $SCRIPT`
4
+ cd $SCRIPTPATH
5
+ export NO_MINITEST=true
6
+ irb -r test_helper
data/test/factories.rb CHANGED
@@ -4,7 +4,6 @@ FactoryGirl.define do
4
4
  factory :product do
5
5
  name 'LG Optimus V'
6
6
  price 19.95
7
- categorized true
8
7
  end
9
8
 
10
9
  factory :category do
@@ -23,7 +22,7 @@ FactoryGirl.define do
23
22
  phone_number "800-555-1212"
24
23
  email_address { "#{first_name}.#{last_name}@example.com".downcase }
25
24
  end
26
-
25
+
27
26
  factory :store_product do
28
27
  store
29
28
  product