mochigome 0.0.10 → 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -39,6 +39,7 @@ unless ActiveRecord::ConnectionAdapters::ConnectionPool.methods.include?("table_
39
39
  end
40
40
  end
41
41
 
42
+ # FIXME: Shouldn't use select_rows anymore
42
43
  class ActiveRecord::ConnectionAdapters::SQLiteAdapter
43
44
  def select_rows(sql, name = nil)
44
45
  execute(sql, name).map do |row|
data/lib/mochigome.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  require 'mochigome_ver'
2
2
  require 'exceptions'
3
+ require 'model_extensions'
4
+ require 'model_graph'
3
5
  require 'data_node'
4
6
  require 'query'
5
- require 'model_extensions'
6
7
  require 'formatting'
7
8
  require 'subgroup_model'
8
9
  require 'arel_rails2_hacks'
data/lib/mochigome_ver.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mochigome
2
- VERSION = "0.0.10"
2
+ VERSION = "0.1"
3
3
  end
@@ -0,0 +1,132 @@
1
+ require 'rgl/adjacency'
2
+ require 'rgl/traversal'
3
+
4
+ module Mochigome
5
+ private
6
+
7
+ module ModelGraph
8
+ @@graphed_models = Set.new
9
+ @@table_to_model = {}
10
+ @@assoc_graph = RGL::DirectedAdjacencyGraph.new
11
+ @@edge_relation_funcs = {}
12
+ @@shortest_paths = {}
13
+
14
+ # Take an expression and return a Set of all models it references
15
+ def self.expr_models(e)
16
+ r = Set.new
17
+ [:expr, :left, :right].each do |m|
18
+ r += expr_models(e.send(m)) if e.respond_to?(m)
19
+ end
20
+ if e.respond_to?(:relation)
21
+ model = @@table_to_model[e.relation.name]
22
+ raise ModelSetupError.new("Table->model lookup error") unless model
23
+ r.add model
24
+ end
25
+ r
26
+ end
27
+
28
+ def self.relation_over_path(path, rel = nil)
29
+ real_path = path.map{|e| (e.real_model? ? e : e.model)}.uniq
30
+ # Project ensures that we return a Rel, not a Table, even if path is empty
31
+ rel ||= real_path.first.arel_table.project
32
+ (0..(real_path.size-2)).each do |i|
33
+ rel = relation_func(real_path[i], real_path[i+1]).call(rel)
34
+ end
35
+ rel
36
+ end
37
+
38
+ def self.relation_func(u, v)
39
+ @@edge_relation_funcs[[u,v]] or
40
+ raise QueryError.new "No assoc from #{u.name} to #{v.name}"
41
+ end
42
+
43
+ def self.path_thru(models)
44
+ update_assoc_graph(models)
45
+ model_queue = models.dup
46
+ path = [model_queue.shift]
47
+ until model_queue.empty?
48
+ src = path.last
49
+ tgt = model_queue.shift
50
+ next if src == tgt
51
+ real_src = src.real_model? ? src : src.model
52
+ real_tgt = tgt.real_model? ? tgt : tgt.model
53
+ unless real_src == real_tgt
54
+ seg = @@shortest_paths[[real_src,real_tgt]]
55
+ unless seg
56
+ raise QueryError.new("No path: #{real_src.name} to #{real_tgt.name}")
57
+ end
58
+ path.concat seg.take(seg.size-1).drop(1)
59
+ end
60
+ path << tgt
61
+ end
62
+ unless path.uniq.size == path.size
63
+ raise QueryError.new(
64
+ "Path thru #{models.map(&:name).join('-')} doubles back: " +
65
+ path.map(&:name).join('-')
66
+ )
67
+ end
68
+ path
69
+ end
70
+
71
+ def self.update_assoc_graph(models)
72
+ model_queue = models.dup
73
+ added_models = []
74
+ until model_queue.empty?
75
+ model = model_queue.shift
76
+ next if model.is_a?(SubgroupModel)
77
+ next if @@graphed_models.include? model
78
+ @@graphed_models.add model
79
+ added_models << model
80
+
81
+ if @@table_to_model.has_key?(model.table_name)
82
+ raise ModelError.new("Table #{model.table_name} used twice")
83
+ end
84
+ @@table_to_model[model.table_name] = model
85
+
86
+ model.reflections.
87
+ reject{|name, assoc| assoc.through_reflection}.
88
+ each do |name, assoc|
89
+ # TODO: What about self associations?
90
+ # TODO: What about associations to the same model on different keys?
91
+ next if assoc.options[:polymorphic] # TODO How to deal with these? Check for matching has_X assoc?
92
+ foreign_model = assoc.klass
93
+ edge = [model, foreign_model]
94
+ next if @@assoc_graph.has_edge?(*edge) # Ignore duplicate assocs
95
+ @@assoc_graph.add_edge(*edge)
96
+ @@edge_relation_funcs[edge] = model.arelified_assoc(name)
97
+ unless @@graphed_models.include?(foreign_model)
98
+ model_queue.push(foreign_model)
99
+ end
100
+ end
101
+ end
102
+
103
+ added_models.each do |model|
104
+ next unless @@assoc_graph.has_vertex?(model)
105
+ path_tree = @@assoc_graph.bfs_search_tree_from(model).reverse
106
+ path_tree.depth_first_search do |tgt_model|
107
+ next if tgt_model == model
108
+ path = [tgt_model]
109
+ while (parent = path_tree.adjacent_vertices(path.first).first)
110
+ path.unshift parent
111
+ end
112
+ @@shortest_paths[[model,tgt_model]] = path
113
+ end
114
+
115
+ # Use through reflections as a hint for preferred indirect paths
116
+ model.reflections.
117
+ select{|name, assoc| assoc.through_reflection}.
118
+ each do |name, assoc|
119
+ begin
120
+ foreign_model = assoc.klass
121
+ join_model = assoc.through_reflection.klass
122
+ rescue NameError
123
+ # FIXME Can't handle polymorphic through reflection
124
+ end
125
+ edge = [model,foreign_model]
126
+ next if @@shortest_paths[edge].try(:size).try(:<, 3)
127
+ @@shortest_paths[edge] = [model, join_model, foreign_model]
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
data/lib/query.rb CHANGED
@@ -1,243 +1,116 @@
1
- require 'rgl/adjacency'
2
- require 'rgl/traversal'
3
-
4
1
  module Mochigome
5
2
  class Query
6
3
  def initialize(layer_types, options = {})
7
4
  # TODO: Validate layer types: not empty, AR, act_as_mochigome_focus
8
5
  @layer_types = layer_types
9
- @layers_path = self.class.path_thru(@layer_types)
10
- @layers_path or raise QueryError.new("No valid path thru layer list") #TODO Test
6
+ @layers_path = ModelGraph.path_thru(@layer_types)
11
7
 
12
8
  @name = options.delete(:root_name).try(:to_s) || "report"
13
9
  @access_filter = options.delete(:access_filter) || lambda {|cls| {}}
10
+ # TODO: Validate that aggregate_sources is in the correct format
14
11
  aggregate_sources = options.delete(:aggregate_sources) || []
15
12
  unless options.empty?
16
13
  raise QueryError.new("Unknown options: #{options.keys.inspect}")
17
14
  end
18
15
 
19
- @ids_rel = self.class.relation_over_path(@layers_path).
20
- project(@layers_path.map{|m| m.arel_primary_key})
21
- @ids_rel = access_filtered_relation(@ids_rel, @layers_path)
16
+ @ids_rel = Relation.new(@layer_types)
17
+ @ids_rel.apply_access_filter_func(@access_filter)
22
18
 
23
- # TODO: Validate that aggregate_sources is in the correct format
24
- aggs_by_model = {}
19
+ @aggregate_rels = {}
25
20
  aggregate_sources.each do |a|
26
- if a.instance_of?(Array)
27
- focus_cls, data_cls = a.first, a.second
28
- else
29
- focus_cls, data_cls = a, a
21
+ focus_model, data_model = case a
22
+ when Array then [a.first, a.second]
23
+ else [a, a]
30
24
  end
31
- aggs_by_model[focus_cls] ||= []
32
- aggs_by_model[focus_cls] << data_cls
33
- end
34
25
 
35
- @aggregate_rels = {}
36
- aggs_by_model.each do |focus_model, data_models|
37
- # Need to do a relation over the entire path in case query has
38
- # a condition on something other than the focus model layer.
39
- # TODO: Would be better to only do this if necesssitated by the
40
- # conditions supplied to the query when it is ran, and/or
41
- # the access filter.
42
- focus_rel = self.class.relation_over_path(@layers_path)
43
-
44
- @aggregate_rels[focus_model] = {}
45
- data_models.each do |data_model|
46
- if focus_model == data_model
47
- f2d_path = [focus_model]
48
- else
49
- #TODO: Handle nil here
50
- f2d_path = self.class.path_thru([focus_model, data_model])
51
- end
52
- agg_path = nil
53
- key_path = nil
54
- f2d_path.each do |link_model|
55
- remainder = f2d_path.drop_while{|m| m != link_model}
56
- next if (remainder.drop(1) & @layers_path).size > 0
57
- if @layers_path.include?(link_model)
58
- agg_path = remainder
59
- key_path = @layers_path.take(@layers_path.index(focus_model)+1)
60
- break
61
- else
62
- # Route it from the closest layer model
63
- @layers_path.reverse.each do |layer|
64
- p = self.class.path_thru([layer, link_model]) + remainder.drop(1) # TODO: Handle path_thru returning nil
65
- next if (p.drop(1) & @layers_path).size > 0
66
- next if p.uniq.size != p.size
67
- if agg_path.nil? || p.size < agg_path.size
68
- agg_path = p
69
- key_path = @layers_path
70
- end
71
- end
72
- end
73
- end
26
+ agg_rel = Relation.new(@layer_types) # TODO Only go as far as focus
27
+ agg_rel.join_to_model(focus_model)
28
+ agg_rel.join_on_path_thru([focus_model, data_model])
29
+ agg_rel.apply_access_filter_func(@access_filter)
30
+
31
+ focus_idx = @layers_path.index(focus_model)
32
+ key_path = focus_idx ? @layers_path.take(focus_idx+1) : @layers_path
33
+ key_path = key_path.select{|m| @layer_types.include?(m)}
34
+ key_cols = key_path.map{|m| m.arel_primary_key}
35
+
36
+ agg_fields = data_model.mochigome_aggregation_settings.
37
+ options[:fields].reject{|a| a[:in_ruby]}
38
+ agg_fields.each_with_index do |a, i|
39
+ d_expr = a[:value_proc].call(data_model.arel_table)
40
+ agg_rel.select_expr(d_expr.as("d%03u" % i))
41
+ end
74
42
 
75
- key_cols = key_path.map{|m| m.arel_primary_key }
76
-
77
- agg_data_rel = self.class.relation_over_path(agg_path, focus_rel.dup)
78
- agg_data_rel = access_filtered_relation(agg_data_rel, @layers_path + agg_path)
79
- agg_fields = data_model.mochigome_aggregation_settings.options[:fields].reject{|a| a[:in_ruby]}
80
- agg_joined_models = @layers_path + agg_path
81
- agg_fields.each_with_index do |a, i|
82
- (a[:joins] || []).each do |m|
83
- unless agg_joined_models.include?(m)
84
- cand = nil
85
- agg_joined_models.each do |agg_join_src_m|
86
- p = self.class.path_thru([agg_join_src_m, m])
87
- if p && (cand.nil? || p.size < cand.size)
88
- cand = p
89
- end
90
- end
91
- if cand
92
- agg_data_rel = self.class.relation_over_path(cand, agg_data_rel)
93
- agg_joined_models += cand
94
- else
95
- raise QueryError.new("Can't join from query to agg join model #{m.name}") # TODO: Test this
96
- end
97
- end
43
+ @aggregate_rels[focus_model] ||= {}
44
+ @aggregate_rels[focus_model][data_model] = (0..key_cols.length).map{|n|
45
+ lambda {|cond|
46
+ data_rel = agg_rel.clone
47
+ data_rel.apply_condition(cond)
48
+ data_cols = key_cols.take(n) + [data_model.arel_primary_key]
49
+ inner_rel = data_rel.to_arel
50
+ data_cols.each_with_index do |col, i|
51
+ inner_rel.project(col.as("g%03u" % i)).group(col)
98
52
  end
99
- d_expr = a[:value_proc].call(data_model.arel_table)
100
- agg_data_rel.project(d_expr.as("d#{i}"))
101
- end
102
53
 
103
- @aggregate_rels[focus_model][data_model] = (0..key_cols.length).map{|n|
104
- lambda {|cond|
105
- d_rel = agg_data_rel.dup
106
- d_cols = key_cols.take(n) + [data_model.arel_primary_key]
107
- d_cols.each_with_index do |col, i|
108
- d_rel.project(col.as("g#{i}")).group(col)
109
- end
110
- d_rel.where(cond) if cond
111
-
112
- # FIXME: This subtable won't be necessary for all aggregation funcs.
113
- # When we can avoid it, we should, for performance.
114
- a_rel = Arel::SelectManager.new(
115
- Arel::Table.engine,
116
- Arel.sql("(#{d_rel.to_sql}) as mochigome_data")
117
- )
118
- d_tbl = Arel::Table.new("mochigome_data")
119
- agg_fields.each_with_index do |a, i|
120
- a_rel.project(a[:agg_proc].call(d_tbl["d#{i}"]))
121
- end
122
- key_cols.take(n).each_with_index do |col, i|
123
- outer_name = "og#{i}"
124
- a_rel.project(d_tbl["g#{i}"].as(outer_name)).group(outer_name)
125
- end
126
- a_rel
127
- }
54
+ # FIXME: This subtable won't be necessary for all aggregation funcs.
55
+ # When we can avoid it, we should, for performance.
56
+ rel = Arel::SelectManager.new(
57
+ Arel::Table.engine,
58
+ Arel.sql("(#{inner_rel.to_sql}) as mochigome_data")
59
+ )
60
+ d_tbl = Arel::Table.new("mochigome_data")
61
+ agg_fields.each_with_index do |a, i|
62
+ name = "d%03u" % i
63
+ rel.project(a[:agg_proc].call(d_tbl[name]).as(name))
64
+ end
65
+ key_cols.take(n).each_with_index do |col, i|
66
+ name = "g%03u" % i
67
+ rel.project(d_tbl[name].as(name)).group(name)
68
+ end
69
+ rel
128
70
  }
129
- end
130
-
71
+ }
131
72
  end
132
73
  end
133
74
 
134
75
  def run(cond = nil)
135
- root = DataNode.new(:report, @name)
136
-
137
- if cond.is_a?(ActiveRecord::Base)
138
- cond = [cond]
139
- end
140
- if cond.is_a?(Array)
141
- return root if cond.empty?
142
- cond = cond.inject(nil) do |expr, obj|
143
- subexpr = obj.class.arel_primary_key.eq(obj.id)
144
- expr ? expr.or(subexpr) : subexpr
145
- end
146
- end
147
- if cond
148
- self.class.expr_tables(cond).each do |t|
149
- raise QueryError.new("Condition table #{t} not in layer list") unless
150
- @layers_path.any?{|m| m.real_model? && m.table_name == t}
151
- end
152
- end
153
-
154
- q = @ids_rel.dup
155
- q.where(cond) if cond
156
- ids_table = @layer_types.first.connection.select_rows(q.to_sql)
157
- ids_table = ids_table.map do |row|
158
- # FIXME: Should do this conversion based on type of column
159
- row.map{|cell| cell =~ /^\d+$/ ? cell.to_i : cell}
160
- end
161
-
162
- fill_layers(ids_table, {[] => root}, @layer_types)
76
+ root = create_node_tree(cond)
77
+ load_aggregate_data(root, cond)
78
+ return root
79
+ end
163
80
 
164
- @aggregate_rels.each do |focus_model, data_model_rels|
165
- super_types = @layer_types.take_while{|m| m != focus_model}
166
- super_cols = super_types.map{|m| @layers_path.find_index(m)}
167
- data_model_rels.each do |data_model, rel_funcs|
168
- aggs = data_model.mochigome_aggregation_settings.options[:fields]
169
- aggs_count = aggs.reject{|a| a[:in_ruby]}.size
170
- rel_funcs.each do |rel_func|
171
- q = rel_func.call(cond)
172
- data_tree = {}
173
- # Each row has aggs_count data fields, followed by the id fields
174
- # from least specific to most.
175
- @layer_types.first.connection.select_rows(q.to_sql).each do |row|
176
- if row.size == aggs_count
177
- data_tree = row.take(aggs_count)
178
- else
179
- c = data_tree
180
- super_cols.each_with_index do |sc_num, sc_idx|
181
- break if aggs_count+sc_idx >= row.size-1
182
- col_num = aggs_count + sc_num
183
- c = (c[row[col_num]] ||= {})
184
- end
185
- c[row.last] = row.take(aggs_count)
186
- end
187
- end
188
- insert_aggregate_data_fields(root, data_tree, data_model)
189
- end
190
- end
191
- end
81
+ private
192
82
 
83
+ def create_node_tree(cond)
84
+ root = DataNode.new(:report, @name)
193
85
  root.comment = <<-eos
194
86
  Mochigome Version: #{Mochigome::VERSION}
195
87
  Report Generated: #{Time.now}
196
88
  Layers: #{@layer_types.map(&:name).join(" => ")}
197
89
  AR Path: #{@layers_path.map(&:name).join(" => ")}
198
90
  eos
199
- root.comment.gsub!(/\n +/, "\n")
200
- root.comment.lstrip!
201
-
202
- return root
203
- end
91
+ root.comment.gsub!(/(\n|^) +/, "\\1")
204
92
 
205
- private
93
+ r = @ids_rel.dup
94
+ r.apply_condition(cond)
95
+ ids_table = @layer_types.first.connection.select_all(r.to_sql)
96
+ fill_layers(ids_table, {[] => root}, @layer_types)
206
97
 
207
- def access_filtered_relation(r, models)
208
- joined = Set.new
209
- models.uniq.each do |model|
210
- h = @access_filter.call(model)
211
- h.delete(:join_paths).try :each do |path|
212
- (0..(path.size-2)).each do |i|
213
- next if models.include?(path[i+1]) or joined.include?(path[i+1])
214
- r = self.class.relation_func(path[i], path[i+1]).call(r)
215
- joined.add path[i+1]
216
- end
217
- end
218
- if h[:condition]
219
- r = r.where(h.delete(:condition))
220
- end
221
- unless h.empty?
222
- raise QueryError.new("Unknown assoc filter keys #{h.keys.inspect}")
223
- end
224
- end
225
- r
98
+ root
226
99
  end
227
100
 
228
- def fill_layers(ids_table, parents, types, parent_col_nums = [])
229
- return if types.size == 0
101
+ def fill_layers(ids_table, parents, types, type_idx = 0)
102
+ return if type_idx >= types.size
230
103
 
231
- model = types.first
232
- col_num = @layers_path.find_index(model)
104
+ model = types[type_idx]
233
105
  layer_ids = Set.new
234
106
  cur_to_parent = {}
235
107
 
108
+ parent_types = types.take(type_idx)
236
109
  ids_table.each do |row|
237
- cur_id = row[col_num]
110
+ cur_id = row["#{model.name}_id"]
238
111
  layer_ids.add cur_id
239
112
  cur_to_parent[cur_id] ||= Set.new
240
- cur_to_parent[cur_id].add parent_col_nums.map{|i| row[i]}
113
+ cur_to_parent[cur_id].add parent_types.map{|m| row["#{m.name}_id"]}
241
114
  end
242
115
 
243
116
  layer = {}
@@ -260,7 +133,36 @@ module Mochigome
260
133
  end
261
134
  end
262
135
 
263
- fill_layers(ids_table, layer, types.drop(1), parent_col_nums + [col_num])
136
+ fill_layers(ids_table, layer, types, type_idx + 1)
137
+ end
138
+
139
+ def load_aggregate_data(node, cond)
140
+ @aggregate_rels.each do |focus_model, data_model_rels|
141
+ # TODO Actually get the key types found in init for this aggregation
142
+ super_types = @layer_types.take_while{|m| m != focus_model}
143
+ data_model_rels.each do |data_model, rel_funcs|
144
+ aggs = data_model.mochigome_aggregation_settings.options[:fields]
145
+ aggs_count = aggs.reject{|a| a[:in_ruby]}.size
146
+ rel_funcs.each do |rel_func|
147
+ q = rel_func.call(cond)
148
+ data_tree = {}
149
+ @layer_types.first.connection.select_all(q.to_sql).each do |row|
150
+ group_values = row.keys.select{|k| k.start_with?("g")}.sort.map{|k| row[k]}
151
+ data_values = row.keys.select{|k| k.start_with?("d")}.sort.map{|k| row[k]}
152
+ if group_values.empty?
153
+ data_tree = data_values
154
+ else
155
+ c = data_tree
156
+ group_values.take(group_values.size-1).each do |group_id|
157
+ c = (c[group_id] ||= {})
158
+ end
159
+ c[group_values.last] = data_values
160
+ end
161
+ end
162
+ insert_aggregate_data_fields(node, data_tree, data_model)
163
+ end
164
+ end
165
+ end
264
166
  end
265
167
 
266
168
  def insert_aggregate_data_fields(node, table, data_model)
@@ -283,122 +185,112 @@ module Mochigome
283
185
  end
284
186
  end
285
187
  end
188
+ end
286
189
 
287
- # TODO: Move the stuff below into its own module
190
+ private
288
191
 
289
- # Take an expression and return a Set of all tables it references
290
- def self.expr_tables(e)
291
- # TODO: This is kind of hacky, Arel probably has a better way
292
- # to do this with its API.
293
- r = Set.new
294
- [:expr, :left, :right].each do |m|
295
- r += expr_tables(e.send(m)) if e.respond_to?(m)
296
- end
297
- r.add e.relation.name if e.respond_to?(:relation)
298
- r
192
+ class Relation
193
+ def initialize(layers)
194
+ @spine_layers = layers
195
+ @spine = ModelGraph.path_thru(layers) or
196
+ raise QueryError.new("No valid path thru #{layers.inspect}") #TODO Test
197
+ @models = Set.new @spine
198
+ @rel = ModelGraph.relation_over_path(@spine)
199
+
200
+ @spine_layers.each{|m| select_model_id(m)}
299
201
  end
300
202
 
301
- @@graphed_models = Set.new
302
- @@assoc_graph = RGL::DirectedAdjacencyGraph.new
303
- @@edge_relation_funcs = {}
304
- @@shortest_paths = {}
305
-
306
- def self.relation_over_path(path, rel = nil)
307
- real_path = path.map{|e| (e.real_model? ? e : e.model)}.uniq
308
- # Project ensures that we return a Rel, not a Table, even if path is empty
309
- rel ||= real_path.first.arel_table.project
310
- (0..(real_path.size-2)).each do |i|
311
- rel = relation_func(real_path[i], real_path[i+1]).call(rel)
312
- end
313
- rel
203
+ def to_arel
204
+ @rel.clone
314
205
  end
315
206
 
316
- def self.relation_func(u, v)
317
- @@edge_relation_funcs[[u,v]] or
318
- raise QueryError.new "No assoc from #{u.name} to #{v.name}"
207
+ def to_sql
208
+ @rel.to_sql
319
209
  end
320
210
 
321
- def self.path_thru(models)
322
- update_assoc_graph(models)
323
- model_queue = models.dup
324
- path = [model_queue.shift]
325
- until model_queue.empty?
326
- src = path.last
327
- tgt = model_queue.shift
328
- real_src = src.real_model? ? src : src.model
329
- real_tgt = tgt.real_model? ? tgt : tgt.model
330
- unless real_src == real_tgt
331
- seg = @@shortest_paths[[real_src,real_tgt]]
332
- unless seg
333
- raise QueryError.new("No path: #{real_src.name} to #{real_tgt.name}")
334
- end
335
- path.concat seg.take(seg.size-1).drop(1)
211
+ def clone
212
+ c = super
213
+ c.instance_variable_set :@spine, @spine.dup
214
+ c.instance_variable_set :@models, @models.dup
215
+ c.instance_variable_set :@rel, @rel.project
216
+ c
217
+ end
218
+
219
+ def join_to_model(model)
220
+ return if @models.include?(model)
221
+
222
+ # Route to it in as few steps as possible, closer to spine end if tie.
223
+ best_path = nil
224
+ (@spine.reverse + (@models.to_a - @spine)).each do |link_model|
225
+ path = ModelGraph.path_thru([link_model, model])
226
+ if path && (best_path.nil? || path.size < best_path.size)
227
+ best_path = path
336
228
  end
337
- path << tgt
338
229
  end
339
- unless path.uniq.size == path.size
340
- raise QueryError.new(
341
- "Path thru #{models.map(&:name).join('-')} doubles back: " +
342
- path.map(&:name).join('-')
343
- )
230
+
231
+ raise QueryError.new("No path to #{model}") unless best_path
232
+ join_on_path(best_path)
233
+ end
234
+
235
+ def join_on_path_thru(path)
236
+ join_on_path(ModelGraph.path_thru(path).uniq)
237
+ end
238
+
239
+ def join_on_path(path)
240
+ path = path.map{|e| (e.real_model? ? e : e.model)}.uniq
241
+ (0..(path.size-2)).map{|i| [path[i], path[i+1]]}.each do |src, tgt|
242
+ add_join_link src, tgt
344
243
  end
345
- path
346
244
  end
347
245
 
348
- def self.update_assoc_graph(models)
349
- model_queue = models.dup
350
- added_models = []
351
- until model_queue.empty?
352
- model = model_queue.shift
353
- next if model.is_a?(SubgroupModel)
354
- next if @@graphed_models.include? model
355
- @@graphed_models.add model
356
- added_models << model
357
-
358
- model.reflections.
359
- reject{|name, assoc| assoc.through_reflection}.
360
- each do |name, assoc|
361
- # TODO: What about self associations?
362
- # TODO: What about associations to the same model on different keys?
363
- next if assoc.options[:polymorphic] # TODO How to deal with these? Check for matching has_X assoc?
364
- foreign_model = assoc.klass
365
- edge = [model, foreign_model]
366
- next if @@assoc_graph.has_edge?(*edge) # Ignore duplicate assocs
367
- @@assoc_graph.add_edge(*edge)
368
- @@edge_relation_funcs[edge] = model.arelified_assoc(name)
369
- unless @@graphed_models.include?(foreign_model)
370
- model_queue.push(foreign_model)
371
- end
246
+ def select_model_id(m)
247
+ @rel = @rel.project(m.arel_primary_key.as("#{m.name}_id"))
248
+ end
249
+
250
+ def select_expr(e)
251
+ ModelGraph.expr_models(e).each{|m| join_to_model(m)}
252
+ @rel = @rel.project(e)
253
+ end
254
+
255
+ def apply_condition(cond)
256
+ return unless cond
257
+ if cond.is_a?(ActiveRecord::Base)
258
+ cond = [cond]
259
+ end
260
+ if cond.is_a?(Array)
261
+ cond = cond.inject(nil) do |expr, obj|
262
+ subexpr = obj.class.arel_primary_key.eq(obj.id)
263
+ expr ? expr.or(subexpr) : subexpr
372
264
  end
373
265
  end
374
266
 
375
- added_models.each do |model|
376
- next unless @@assoc_graph.has_vertex?(model)
377
- path_tree = @@assoc_graph.bfs_search_tree_from(model).reverse
378
- path_tree.depth_first_search do |tgt_model|
379
- next if tgt_model == model
380
- path = [tgt_model]
381
- while (parent = path_tree.adjacent_vertices(path.first).first)
382
- path.unshift parent
383
- end
384
- @@shortest_paths[[model,tgt_model]] = path
385
- end
267
+ ModelGraph.expr_models(cond).each{|m| join_to_model(m)}
268
+ @rel = @rel.where(cond)
269
+ end
386
270
 
387
- # Use through reflections as a hint for preferred indirect paths
388
- model.reflections.
389
- select{|name, assoc| assoc.through_reflection}.
390
- each do |name, assoc|
391
- begin
392
- foreign_model = assoc.klass
393
- join_model = assoc.through_reflection.klass
394
- rescue NameError
395
- # FIXME Can't handle polymorphic through reflection
396
- end
397
- edge = [model,foreign_model]
398
- next if @@shortest_paths[edge].try(:size).try(:<, 3)
399
- @@shortest_paths[edge] = [model, join_model, foreign_model]
271
+ def apply_access_filter_func(func)
272
+ @models.each do |m|
273
+ h = func.call(m)
274
+ h.delete(:join_paths).try :each do |path|
275
+ join_on_path path
276
+ end
277
+ if h[:condition]
278
+ apply_condition h.delete(:condition)
279
+ end
280
+ unless h.empty?
281
+ raise QueryError.new("Unknown assoc filter keys #{h.keys.inspect}")
400
282
  end
401
283
  end
402
284
  end
285
+
286
+ private
287
+
288
+ def add_join_link(src, tgt)
289
+ raise QueryError.new("Can't join from #{src}, not available") unless
290
+ @models.include?(src)
291
+ return if @models.include?(tgt) # TODO Maybe still apply join conditions?
292
+ @rel = ModelGraph.relation_func(src, tgt).call(@rel)
293
+ @models.add tgt
294
+ end
403
295
  end
404
296
  end
@@ -14,7 +14,7 @@ module Mochigome
14
14
  end
15
15
 
16
16
  def name
17
- "#{@model}%#{@attr}"
17
+ "#{@model}$#{@attr}" # Warning: This has to be a valid SQL field name
18
18
  end
19
19
 
20
20
  def human_name
@@ -3,7 +3,7 @@ class Sale < ActiveRecord::Base
3
3
  a.fields [:count, {
4
4
  "Gross" => [:sum, lambda {|t|
5
5
  Product.arel_table[:price]
6
- }, {:joins => [Product]}]
6
+ }]
7
7
  }]
8
8
  end
9
9
 
@@ -65,13 +65,6 @@ describe Mochigome::Query do
65
65
  assert_empty obj.children
66
66
  end
67
67
 
68
- it "returns an empty DataNode if given an empty array" do
69
- q = Mochigome::Query.new([Category, Product])
70
- data_node = q.run([])
71
- assert_empty data_node
72
- assert_no_children data_node
73
- end
74
-
75
68
  it "returns all possible results if no conditions given" do
76
69
  q = Mochigome::Query.new([Category, Product])
77
70
  data_node = q.run()
@@ -88,8 +81,7 @@ describe Mochigome::Query do
88
81
 
89
82
  it "can build a one-layer DataNode when given an arbitrary Arel condition" do
90
83
  q = Mochigome::Query.new([Product])
91
- tbl = Arel::Table.new(Product.table_name)
92
- data_node = q.run(tbl[:name].eq(@product_a.name))
84
+ data_node = q.run(Product.arel_table[:name].eq(@product_a.name))
93
85
  assert_equal_children [@product_a], data_node
94
86
  assert_no_children data_node/0
95
87
  end
@@ -447,18 +439,11 @@ describe Mochigome::Query do
447
439
  end
448
440
  end
449
441
 
450
- it "will not allow a query on targets not in the layer list" do
451
- q = Mochigome::Query.new([Product])
452
- assert_raises Mochigome::QueryError do
453
- q.run(@category1)
454
- end
455
- end
456
-
457
442
  it "can use a provided access filter function to limit query results" do
458
443
  af = proc do |cls|
459
444
  return {} unless cls == Product
460
445
  return {
461
- :condition => Arel::Table.new(Product.table_name)[:category_id].gt(0)
446
+ :condition => Product.arel_table[:category_id].gt(0)
462
447
  }
463
448
  end
464
449
  q = Mochigome::Query.new([Product], :access_filter => af)
@@ -472,7 +457,7 @@ describe Mochigome::Query do
472
457
  return {} unless cls == Product
473
458
  return {
474
459
  :join_paths => [[Product, StoreProduct, Store]],
475
- :condition => Arel::Table.new(Store.table_name)[:name].matches("Jo%")
460
+ :condition => Store.arel_table[:name].matches("Jo%")
476
461
  }
477
462
  end
478
463
  q = Mochigome::Query.new([Product], :access_filter => af)
@@ -486,18 +471,17 @@ describe Mochigome::Query do
486
471
  return {} unless cls == Product
487
472
  return {
488
473
  :join_paths => [[Product, StoreProduct, Store]],
489
- :condition => Arel::Table.new(Store.table_name)[:name].matches("Jo%")
474
+ :condition => Store.arel_table[:name].matches("Jo%")
490
475
  }
491
476
  end
492
477
  q = Mochigome::Query.new([Product, Store], :access_filter => af)
493
478
  assert_equal 1, q.instance_variable_get(:@ids_rel).to_sql.scan(/join .stores./i).size
494
479
  end
495
480
 
496
- it "complains if run given a condition on an unused table" do
481
+ it "automatically joins if run given a condition on a new table" do
497
482
  q = Mochigome::Query.new([Product, Store])
498
- assert_raises Mochigome::QueryError do
499
- q.run(Arel::Table.new(Category.table_name)[:id].eq(41))
500
- end
483
+ dn = q.run(Category.arel_table[:name].eq(@category1.name))
484
+ assert_equal 2, dn.children.size
501
485
  end
502
486
 
503
487
  # TODO: Test that access filter join paths are followed, rather than closest path
metadata CHANGED
@@ -1,13 +1,12 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mochigome
3
3
  version: !ruby/object:Gem::Version
4
- hash: 11
4
+ hash: 9
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
- - 0
9
- - 10
10
- version: 0.0.10
8
+ - 1
9
+ version: "0.1"
11
10
  platform: ruby
12
11
  authors:
13
12
  - David Mike Simon
@@ -15,7 +14,7 @@ autorequire:
15
14
  bindir: bin
16
15
  cert_chain: []
17
16
 
18
- date: 2012-04-01 00:00:00 Z
17
+ date: 2012-04-09 00:00:00 Z
19
18
  dependencies:
20
19
  - !ruby/object:Gem::Dependency
21
20
  version_requirements: &id001 !ruby/object:Gem::Requirement
@@ -97,6 +96,7 @@ files:
97
96
  - lib/mochigome.rb
98
97
  - lib/mochigome_ver.rb
99
98
  - lib/model_extensions.rb
99
+ - lib/model_graph.rb
100
100
  - lib/query.rb
101
101
  - lib/subgroup_model.rb
102
102
  - test/app_root/app/controllers/application_controller.rb