mochigome 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/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