mochigome 0.0.8 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile CHANGED
@@ -7,7 +7,7 @@ gem 'ruport'
7
7
  gem 'rgl'
8
8
 
9
9
  gem 'sqlite3'
10
- gem 'mysql'
10
+ gem 'mysql2', '~> 0.2.0'
11
11
  gem 'factory_girl', '2.0.4'
12
12
  gem 'rdoc'
13
13
  gem 'rcov'
data/Gemfile.lock CHANGED
@@ -23,7 +23,7 @@ GEM
23
23
  minitest (2.5.0)
24
24
  mynyml-redgreen (0.7.1)
25
25
  term-ansicolor (>= 1.0.4)
26
- mysql (2.8.1)
26
+ mysql2 (0.2.18)
27
27
  nokogiri (1.5.0)
28
28
  pdf-writer (1.1.8)
29
29
  color (>= 1.4.0)
@@ -64,7 +64,7 @@ DEPENDENCIES
64
64
  factory_girl (= 2.0.4)
65
65
  minitest
66
66
  mynyml-redgreen
67
- mysql
67
+ mysql2 (~> 0.2.0)
68
68
  nokogiri
69
69
  rails (= 2.3.12)
70
70
  rcov
@@ -42,7 +42,7 @@ unless ActiveRecord::ConnectionAdapters::ConnectionPool.methods.include?("table_
42
42
  class ActiveRecord::ConnectionAdapters::SQLiteAdapter
43
43
  def select_rows(sql, name = nil)
44
44
  execute(sql, name).map do |row|
45
- row.keys.select{|key| key.is_a? Integer}.map{|key| row[key]}
45
+ row.keys.select{|key| key.is_a? Integer}.sort.map{|key| row[key]}
46
46
  end
47
47
  end
48
48
  end
data/lib/data_node.rb CHANGED
@@ -41,6 +41,12 @@ module Mochigome
41
41
  @children[idx]
42
42
  end
43
43
 
44
+ def dup
45
+ twin = super
46
+ twin.instance_variable_set(:@children, @children.map{|c| c.dup})
47
+ twin
48
+ end
49
+
44
50
  # TODO: Only define xml-related methods if nokogiri loaded
45
51
  def to_xml
46
52
  doc = Nokogiri::XML::Document.new
data/lib/mochigome.rb CHANGED
@@ -4,6 +4,7 @@ require 'data_node'
4
4
  require 'query'
5
5
  require 'model_extensions'
6
6
  require 'formatting'
7
+ require 'subgroup_model'
7
8
  require 'arel_rails2_hacks'
8
9
 
9
10
  ActiveRecord::Base.send(:include, Mochigome::ModelExtensions)
data/lib/mochigome_ver.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mochigome
2
- VERSION = "0.0.8"
2
+ VERSION = "0.0.9"
3
3
  end
@@ -12,6 +12,19 @@ module Mochigome
12
12
  end
13
13
 
14
14
  module ClassMethods
15
+ def real_model?
16
+ true
17
+ end
18
+
19
+ # TODO: Use this instead of calling Table.new all over the place
20
+ def arel_table
21
+ Arel::Table.new(table_name)
22
+ end
23
+
24
+ def arel_primary_key
25
+ arel_table[primary_key]
26
+ end
27
+
15
28
  def acts_as_mochigome_focus
16
29
  if self.try(:mochigome_focus_settings).try(:model) == self
17
30
  raise Mochigome::ModelSetupError.new("Already acts_as_mochigome_focus for #{self.name}")
data/lib/query.rb CHANGED
@@ -4,9 +4,10 @@ require 'rgl/traversal'
4
4
  module Mochigome
5
5
  class Query
6
6
  def initialize(layer_types, options = {})
7
- # TODO: Validate layer types: not empty, AR, act_as_mochigome_focus, graph correctly, no repeats
7
+ # TODO: Validate layer types: not empty, AR, act_as_mochigome_focus
8
8
  @layer_types = layer_types
9
- @layers_path = self.class.path_thru(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
10
11
 
11
12
  @name = options.delete(:root_name).try(:to_s) || "report"
12
13
  @access_filter = options.delete(:access_filter) || lambda {|cls| {}}
@@ -16,14 +17,17 @@ module Mochigome
16
17
  end
17
18
 
18
19
  @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
- })
20
+ project(@layers_path.map{|m| m.arel_primary_key})
22
21
  @ids_rel = access_filtered_relation(@ids_rel, @layers_path)
23
22
 
24
23
  # TODO: Validate that aggregate_sources is in the correct format
25
24
  aggs_by_model = {}
26
- aggregate_sources.each do |focus_cls, data_cls|
25
+ 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
30
+ end
27
31
  aggs_by_model[focus_cls] ||= []
28
32
  aggs_by_model[focus_cls] << data_cls
29
33
  end
@@ -39,7 +43,12 @@ module Mochigome
39
43
 
40
44
  @aggregate_rels[focus_model] = {}
41
45
  data_models.each do |data_model|
42
- f2d_path = self.class.path_thru([focus_model, data_model]) #TODO: Handle nil here
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
43
52
  agg_path = nil
44
53
  key_path = nil
45
54
  f2d_path.each do |link_model|
@@ -63,15 +72,12 @@ module Mochigome
63
72
  end
64
73
  end
65
74
 
66
- key_cols = key_path.map{|m|
67
- Arel::Table.new(m.table_name)[m.primary_key]
68
- }
75
+ key_cols = key_path.map{|m| m.arel_primary_key }
69
76
 
70
77
  agg_data_rel = self.class.relation_over_path(agg_path, focus_rel.dup)
71
78
  agg_data_rel = access_filtered_relation(agg_data_rel, @layers_path + agg_path)
72
79
  data_tbl = Arel::Table.new(data_model.table_name)
73
80
  agg_fields = data_model.mochigome_aggregation_settings.options[:fields].reject{|a| a[:in_ruby]}
74
- agg_data_rel.project # FIXME ??? What is this for?
75
81
  agg_fields.each_with_index do |a, i|
76
82
  agg_data_rel.project(a[:value_proc].call(data_tbl).as("d#{i}"))
77
83
  end
@@ -115,22 +121,29 @@ module Mochigome
115
121
  end
116
122
  if cond.is_a?(Array)
117
123
  return root if cond.empty?
118
- unless cond.all?{|obj| obj.class == cond.first.class}
119
- raise QueryError.new("Query target objects must all be the same type")
124
+ cond = cond.inject(nil) do |expr, obj|
125
+ cls = obj.class
126
+ tbl = Arel::Table.new(cls.table_name)
127
+ subexpr = tbl[cls.primary_key].eq(obj.id)
128
+ expr ? expr.or(subexpr) : subexpr
120
129
  end
121
- unless @layer_types.any?{|layer| cond.first.is_a?(layer)}
122
- raise QueryError.new("Query target's class must be in layer list")
130
+ end
131
+ if cond
132
+ self.class.expr_tables(cond).each do |t|
133
+ raise QueryError.new("Condition table #{t} not in layer list") unless
134
+ @layers_path.any?{|m| m.table_name == t}
123
135
  end
124
- cls = cond.first.class
125
- cond = Arel::Table.new(cls.table_name)[cls.primary_key].in(cond.map(&:id))
126
136
  end
127
137
 
128
138
  q = @ids_rel.dup
129
139
  q.where(cond) if cond
130
140
  ids_table = @layer_types.first.connection.select_rows(q.to_sql)
131
- ids_table = ids_table.map{|row| row.map{|cell| cell.to_i}}
141
+ ids_table = ids_table.map do |row|
142
+ # FIXME: Should do this conversion based on type of column
143
+ row.map{|cell| cell =~ /^\d+$/ ? cell.to_i : cell}
144
+ end
132
145
 
133
- fill_layers(ids_table, {:root => root}, @layer_types)
146
+ fill_layers(ids_table, {[] => root}, @layer_types)
134
147
 
135
148
  @aggregate_rels.each do |focus_model, data_model_rels|
136
149
  super_types = @layer_types.take_while{|m| m != focus_model}
@@ -150,10 +163,10 @@ module Mochigome
150
163
  c = data_tree
151
164
  super_cols.each_with_index do |sc_num, sc_idx|
152
165
  break if aggs_count+sc_idx >= row.size-1
153
- col_num = aggs_count + super_cols[sc_num]
154
- c = (c[row[col_num].to_i] ||= {})
166
+ col_num = aggs_count + sc_num
167
+ c = (c[row[col_num]] ||= {})
155
168
  end
156
- c[row.last.to_i] = row.take(aggs_count)
169
+ c[row.last] = row.take(aggs_count)
157
170
  end
158
171
  end
159
172
  insert_aggregate_data_fields(root, data_tree, data_model)
@@ -196,7 +209,7 @@ module Mochigome
196
209
  r
197
210
  end
198
211
 
199
- def fill_layers(ids_table, parents, types, parent_col_num = nil)
212
+ def fill_layers(ids_table, parents, types, parent_col_nums = [])
200
213
  return if types.size == 0
201
214
 
202
215
  model = types.first
@@ -207,10 +220,8 @@ module Mochigome
207
220
  ids_table.each do |row|
208
221
  cur_id = row[col_num]
209
222
  layer_ids.add cur_id
210
- if parent_col_num
211
- cur_to_parent[cur_id] ||= Set.new
212
- cur_to_parent[cur_id].add row[parent_col_num]
213
- end
223
+ cur_to_parent[cur_id] ||= Set.new
224
+ cur_to_parent[cur_id].add parent_col_nums.map{|i| row[i]}
214
225
  end
215
226
 
216
227
  layer = {}
@@ -221,23 +232,19 @@ module Mochigome
221
232
  f = obj.mochigome_focus
222
233
  dn = DataNode.new(f.type_name, f.name)
223
234
  dn.merge!(f.field_data)
235
+
224
236
  # TODO: Maybe make special fields below part of ModelExtensions?
225
237
  dn[:id] = obj.id
226
- dn[:internal_type] = obj.class.name
238
+ dn[:internal_type] = model.name
227
239
 
228
- if parent_col_num
229
- duping = false
230
- cur_to_parent.fetch(obj.id).each do |parent_id|
231
- parents.fetch(parent_id) << (duping ? dn.dup : dn)
232
- duping = true
233
- end
234
- else
235
- parents[:root] << dn
240
+ cur_to_parent.fetch(obj.id).each do |parent_ids_seq|
241
+ duped = dn.dup
242
+ parents.fetch(parent_ids_seq) << duped
243
+ layer[parent_ids_seq + [obj.id]] = duped
236
244
  end
237
- layer[obj.id] = dn
238
245
  end
239
246
 
240
- fill_layers(ids_table, layer, types.drop(1), col_num)
247
+ fill_layers(ids_table, layer, types.drop(1), parent_col_nums + [col_num])
241
248
  end
242
249
 
243
250
  def insert_aggregate_data_fields(node, table, data_model)
@@ -263,15 +270,28 @@ module Mochigome
263
270
 
264
271
  # TODO: Move the stuff below into its own module
265
272
 
273
+ def self.expr_tables(e)
274
+ # TODO: This is kind of hacky, Arel probably has a better way
275
+ # to do this with its API.
276
+ r = Set.new
277
+ [:expr, :left, :right].each do |m|
278
+ r += expr_tables(e.send(m)) if e.respond_to?(m)
279
+ end
280
+ r.add e.relation.name if e.respond_to?(:relation)
281
+ r
282
+ end
283
+
266
284
  @@graphed_models = Set.new
267
285
  @@assoc_graph = RGL::DirectedAdjacencyGraph.new
268
286
  @@edge_relation_funcs = {}
269
287
  @@shortest_paths = {}
270
288
 
271
289
  def self.relation_over_path(path, rel = nil)
272
- rel ||= Arel::Table.new(path.first.table_name)
273
- (0..(path.size-2)).each do |i|
274
- rel = relation_func(path[i], path[i+1]).call(rel)
290
+ # Project ensures that we don't return a Table even if path is empty
291
+ real_path = path.map{|e| (e.real_model? ? e : e.model)}.uniq
292
+ rel ||= Arel::Table.new(real_path.first.table_name).project
293
+ (0..(real_path.size-2)).each do |i|
294
+ rel = relation_func(real_path[i], real_path[i+1]).call(rel)
275
295
  end
276
296
  rel
277
297
  end
@@ -283,14 +303,21 @@ module Mochigome
283
303
 
284
304
  def self.path_thru(models)
285
305
  update_assoc_graph(models)
286
- path = [models.first]
287
- (0..(models.size-2)).each do |i|
288
- u = models[i]
289
- v = models[i+1]
290
- next if u == v
291
- seg = @@shortest_paths[[u,v]]
292
- raise QueryError.new("Can't travel from #{u.name} to #{v.name}") unless seg
293
- seg.drop(1).each{|step| path << step}
306
+ model_queue = models.dup
307
+ path = [model_queue.shift]
308
+ until model_queue.empty?
309
+ src = path.last
310
+ tgt = model_queue.shift
311
+ real_src = src.real_model? ? src : src.model
312
+ real_tgt = tgt.real_model? ? tgt : tgt.model
313
+ unless real_src == real_tgt
314
+ seg = @@shortest_paths[[real_src,real_tgt]]
315
+ unless seg
316
+ raise QueryError.new("No path: #{real_src.name} to #{real_tgt.name}")
317
+ end
318
+ path.concat seg.take(seg.size-1).drop(1)
319
+ end
320
+ path << tgt
294
321
  end
295
322
  unless path.uniq.size == path.size
296
323
  raise QueryError.new(
@@ -306,6 +333,7 @@ module Mochigome
306
333
  added_models = []
307
334
  until model_queue.empty?
308
335
  model = model_queue.shift
336
+ next if model.is_a?(SubgroupModel)
309
337
  next if @@graphed_models.include? model
310
338
  @@graphed_models.add model
311
339
  added_models << model
@@ -0,0 +1,102 @@
1
+ module Mochigome
2
+ # An instance of SubgroupModel acts like a class that derives from
3
+ # AR::Base, but is used to do subgrouping in Query and does not
4
+ # interact with the database itself.
5
+ class SubgroupModel
6
+ attr_reader :model, :attr
7
+
8
+ def initialize(model, attr)
9
+ @model = model
10
+ @attr = attr
11
+ @focus_settings = Mochigome::ReportFocusSettings.new(@model)
12
+ @focus_settings.type_name "#{@model.human_name} #{@attr.to_s.humanize}"
13
+ @focus_settings.name lambda{|r| r.send(attr)}
14
+ end
15
+
16
+ def name
17
+ "#{@model}%#{@attr}"
18
+ end
19
+
20
+ def human_name
21
+ "#{@model.human_name} #{@attr.to_s.humanize}"
22
+ end
23
+
24
+ def real_model?
25
+ false
26
+ end
27
+
28
+ def arel_table
29
+ @model.arel_table
30
+ end
31
+
32
+ def primary_key
33
+ @attr
34
+ end
35
+
36
+ def arel_primary_key
37
+ arel_table[@attr]
38
+ end
39
+
40
+ def connection
41
+ @model.connection
42
+ end
43
+
44
+ def mochigome_focus_settings
45
+ @focus_settings
46
+ end
47
+
48
+ def acts_as_mochigome_focus?
49
+ true
50
+ end
51
+
52
+ def all(options = {})
53
+ c = options[:conditions]
54
+ unless c.is_a?(Hash) && c.size == 1 && c[@attr].is_a?(Array)
55
+ raise QueryError.new("Invalid conditions given to SubgroupModel#all")
56
+ end
57
+ recs = c[@attr].map do |val|
58
+ SubgroupPseudoRecord.new(self, val)
59
+ end
60
+ # TODO: Support some kind of custom ordering
61
+ recs.sort!{|a,b| a.value <=> b.value}
62
+ recs
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ class SubgroupPseudoRecord
69
+ attr_reader :subgroup_model, :value
70
+
71
+ def initialize(subgroup_model, value)
72
+ @subgroup_model = subgroup_model
73
+ @value = value
74
+ end
75
+
76
+ def id
77
+ @value
78
+ end
79
+
80
+ def mochigome_focus
81
+ SubgroupPseudoRecordReportFocus.new(self)
82
+ end
83
+ end
84
+
85
+ class SubgroupPseudoRecordReportFocus
86
+ def initialize(rec)
87
+ @rec = rec
88
+ end
89
+
90
+ def type_name
91
+ @rec.subgroup_model.human_name
92
+ end
93
+
94
+ def name
95
+ @rec.value
96
+ end
97
+
98
+ def field_data
99
+ {}
100
+ end
101
+ end
102
+ end
@@ -1,4 +1,4 @@
1
1
  test:
2
- adapter: mysql
3
- database: "mochigome_testing"
2
+ adapter: mysql2
3
+ database: "mochi_test"
4
4
  username: root
@@ -16,6 +16,15 @@ describe Mochigome::DataNode do
16
16
  assert_equal "bar", datanode.name
17
17
  end
18
18
 
19
+ it "can be duplicated deeply" do
20
+ datanode = Mochigome::DataNode.new(:foo, :bar)
21
+ twin = datanode.dup
22
+ datanode << Mochigome::DataNode.new(:xyzzy, :froboz)
23
+ assert_empty twin.children
24
+ datanode.name = "Mike"
25
+ assert_equal "bar", twin.name
26
+ end
27
+
19
28
  describe "when created empty" do
20
29
  before do
21
30
  @datanode = Mochigome::DataNode.new(:data, :john_doe)
@@ -167,6 +167,29 @@ describe Mochigome::Query do
167
167
  end
168
168
  end
169
169
 
170
+ it "can subgroup layers by attributes" do
171
+ q = Mochigome::Query.new(
172
+ [Mochigome::SubgroupModel.new(Owner, :last_name), Owner, Store, Product]
173
+ )
174
+ data_node = q.run
175
+ assert_equal "Smith", (data_node/1).name
176
+ assert_equal "John Smith", (data_node/1/0).name
177
+ assert_equal "John's Store", (data_node/1/0/0).name
178
+ assert_equal "Product A", (data_node/1/0/0/0).name
179
+ end
180
+
181
+ it "can subgroup layers by attributes without including layer model" do
182
+ q = Mochigome::Query.new(
183
+ [Mochigome::SubgroupModel.new(Owner, :last_name), Store, Product]
184
+ )
185
+ data_node = q.run
186
+ assert_equal "Smith", (data_node/1).name
187
+ assert_equal "John's Store", (data_node/1/0).name
188
+ assert_equal "Product A", (data_node/1/0/0).name
189
+ end
190
+
191
+ # TODO: Test diamond patterns
192
+
170
193
  it "collects aggregate data by grouping on all layers" do
171
194
  q = Mochigome::Query.new(
172
195
  [Owner, Store, Product],
@@ -190,6 +213,55 @@ describe Mochigome::Query do
190
213
  assert_equal 2, (data_node/1/0/0)['Sales count']
191
214
  end
192
215
 
216
+ it "collects aggregate data in subgroups" do
217
+ q = Mochigome::Query.new(
218
+ [Mochigome::SubgroupModel.new(Owner, :last_name), Owner, Store, Product],
219
+ :aggregate_sources => [[Product, Sale]]
220
+ )
221
+ data_node = q.run
222
+
223
+ assert_equal "Smith", (data_node/1).name
224
+ assert_equal 8, (data_node/1)['Sales count']
225
+ assert_equal "John Smith", (data_node/1/0).name
226
+ assert_equal 8, (data_node/1/0)['Sales count']
227
+ assert_equal "John's Store", (data_node/1/0/0).name
228
+ assert_equal 8, (data_node/1/0/0)['Sales count']
229
+ assert_equal "Product A", (data_node/1/0/0/0).name
230
+ assert_equal 5, (data_node/1/0/0/0)['Sales count']
231
+ end
232
+
233
+ it "collects aggregate data in subgroups going farther than layer list" do
234
+ q = Mochigome::Query.new(
235
+ [Mochigome::SubgroupModel.new(Owner, :last_name), Owner, Store],
236
+ :aggregate_sources => [[Product, Sale]]
237
+ )
238
+ data_node = q.run
239
+
240
+ assert_equal "Smith", (data_node/1).name
241
+ assert_equal 8, (data_node/1)['Sales count']
242
+ assert_equal "John Smith", (data_node/1/0).name
243
+ assert_equal 8, (data_node/1/0)['Sales count']
244
+ assert_equal "John's Store", (data_node/1/0/0).name
245
+ assert_equal 8, (data_node/1/0/0)['Sales count']
246
+ end
247
+
248
+ it "collects aggregate data using data model as focus if focus not supplied" do
249
+ q = Mochigome::Query.new(
250
+ [Owner, Store, Product],
251
+ :aggregate_sources => [Sale]
252
+ )
253
+
254
+ data_node = q.run
255
+
256
+ assert_equal "Jane's Store (North)", (data_node/1/0).name
257
+ assert_equal 11, (data_node/1/0)['Sales count']
258
+
259
+ assert_equal "Jane Doe", (data_node/1).name
260
+ assert_equal 16, (data_node/1)['Sales count']
261
+
262
+ assert_equal 24, data_node['Sales count']
263
+ end
264
+
193
265
  it "collects aggregate data on layers above the focus" do
194
266
  q = Mochigome::Query.new(
195
267
  [Owner, Store, Product],
@@ -360,13 +432,6 @@ describe Mochigome::Query do
360
432
  end
361
433
  end
362
434
 
363
- it "will not allow a query on targets of different types" do
364
- q = Mochigome::Query.new([Owner, Store, Product])
365
- assert_raises Mochigome::QueryError do
366
- q.run([@store_x, @john])
367
- end
368
- end
369
-
370
435
  it "will not allow a query on targets not in the layer list" do
371
436
  q = Mochigome::Query.new([Product])
372
437
  assert_raises Mochigome::QueryError do
@@ -413,5 +478,12 @@ describe Mochigome::Query do
413
478
  assert_equal 1, q.instance_variable_get(:@ids_rel).to_sql.scan(/join .stores./i).size
414
479
  end
415
480
 
481
+ it "complains if run given a condition on an unused table" do
482
+ q = Mochigome::Query.new([Product, Store])
483
+ assert_raises Mochigome::QueryError do
484
+ q.run(Arel::Table.new(Category.table_name)[:id].eq(41))
485
+ end
486
+ end
487
+
416
488
  # TODO: Test that access filter join paths are followed, rather than closest path
417
489
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mochigome
3
3
  version: !ruby/object:Gem::Version
4
- hash: 15
4
+ hash: 13
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
8
  - 0
9
- - 8
10
- version: 0.0.8
9
+ - 9
10
+ version: 0.0.9
11
11
  platform: ruby
12
12
  authors:
13
13
  - David Mike Simon
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2012-03-12 00:00:00 Z
18
+ date: 2012-03-16 00:00:00 Z
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency
21
21
  version_requirements: &id001 !ruby/object:Gem::Requirement
@@ -98,6 +98,7 @@ files:
98
98
  - lib/mochigome_ver.rb
99
99
  - lib/model_extensions.rb
100
100
  - lib/query.rb
101
+ - lib/subgroup_model.rb
101
102
  - test/app_root/app/controllers/application_controller.rb
102
103
  - test/app_root/app/controllers/owners_controller.rb
103
104
  - test/app_root/app/models/boring_datum.rb