mochigome 0.0.3 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
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