mochigome 0.0.10 → 0.1

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