mochigome 0.1.6 → 0.1.7

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/TODO CHANGED
@@ -4,5 +4,5 @@
4
4
  - Alternately, always ignore conditional associations unless they're specifically provided to Mochigome by the model
5
5
  - Named subsets of different fields and agg fields on a single model
6
6
  - Some kind of single-page preview on the edit screen would be cool. Maybe use FOP with fake data and the PNG output option?
7
- - Allow inwards-branching join patterns on layer list (i.e. Category->Store->Product)
8
7
  - Automatically set default to 0 on sum and count aggregation
8
+ - Treat through-associations as hints for preferred paths
data/lib/mochigome_ver.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mochigome
2
- VERSION = "0.1.6"
2
+ VERSION = "0.1.7"
3
3
  end
@@ -54,37 +54,35 @@ module Mochigome
54
54
  write_inheritable_attribute :mochigome_aggregation_settings, settings
55
55
  end
56
56
 
57
- def arelified_assoc(name)
57
+ def assoc_condition(name)
58
58
  # TODO: Deal with polymorphic assocs.
59
59
  model = self
60
60
  assoc = reflect_on_association(name)
61
61
  raise AssociationError.new("No such assoc #{name}") unless assoc
62
62
  table = Arel::Table.new(table_name)
63
63
  ftable = Arel::Table.new(assoc.klass.table_name)
64
- lambda do |r|
65
- # FIXME: This acts as though arel methods are non-destructive,
66
- # but they are, right? Except, I can't remove the rel
67
- # assignment from relation_over_path...
68
- cond = nil
69
- if assoc.belongs_to?
70
- cond = table[assoc.association_foreign_key].eq(
71
- ftable[assoc.klass.primary_key]
72
- )
73
- else
74
- cond = table[primary_key].eq(ftable[assoc.primary_key_name])
75
- end
76
-
77
- if assoc.options[:as]
78
- # FIXME Can we assume that this is the polymorphic type field?
79
- cond = cond.and(ftable["#{assoc.options[:as]}_type"].eq(model.name))
80
- end
81
64
 
82
- # TODO: Apply association conditions.
65
+ # FIXME: This acts as though arel methods are non-destructive,
66
+ # but they are, right? Except, I can't remove the rel
67
+ # assignment from relation_over_path...
68
+ cond = nil
69
+ if assoc.belongs_to?
70
+ cond = table[assoc.association_foreign_key].eq(
71
+ ftable[assoc.klass.primary_key]
72
+ )
73
+ else
74
+ cond = table[primary_key].eq(ftable[assoc.primary_key_name])
75
+ end
83
76
 
84
- r.join(ftable, Arel::Nodes::InnerJoin).on(cond)
77
+ if assoc.options[:as]
78
+ # FIXME Can we assume that this is the polymorphic type field?
79
+ cond = cond.and(ftable["#{assoc.options[:as]}_type"].eq(model.name))
85
80
  end
86
- end
87
81
 
82
+ # TODO: Apply association conditions.
83
+
84
+ return cond
85
+ end
88
86
  end
89
87
 
90
88
  module InstanceMethods
@@ -99,17 +97,31 @@ module Mochigome
99
97
  # in there?
100
98
 
101
99
  def self.null_unless(pred, value_func)
100
+ case_expr(
101
+ lambda {|t| pred.call(value_func.call(t))},
102
+ value_func,
103
+ Arel::Nodes::SqlLiteral.new("NULL")
104
+ )
105
+ end
106
+
107
+ def self.case_expr(table_pred, then_val, else_val)
102
108
  lambda {|t|
103
- value = value_func.call(t)
104
- val_expr = Arel::Nodes::NamedFunction.new('',[value])
105
109
  Arel::Nodes::SqlLiteral.new(
106
- "(CASE WHEN #{pred.call(value).to_sql} THEN #{val_expr.to_sql} ELSE NULL END)"
110
+ "(CASE WHEN #{arel_exprify(table_pred, t)} " +
111
+ "THEN #{arel_exprify(then_val, t)} " +
112
+ "ELSE #{arel_exprify(else_val, t)} END)"
107
113
  )
108
114
  }
109
115
  end
110
116
 
111
117
  private
112
118
 
119
+ def self.arel_exprify(e, t = nil)
120
+ e = e.call(t) if e.respond_to?(:call)
121
+ e = Arel::Nodes::NamedFunction.new('',[e]) unless e.respond_to?(:to_sql)
122
+ e.to_sql
123
+ end
124
+
113
125
  class ReportFocus
114
126
  attr_reader :type_name
115
127
  attr_reader :fields
@@ -143,6 +155,7 @@ module Mochigome
143
155
  @model = model
144
156
  @options = {}
145
157
  @options[:fields] = []
158
+ @options[:custom_subgroup_exprs] = {}
146
159
  end
147
160
 
148
161
  def type_name(n)
@@ -183,6 +196,10 @@ module Mochigome
183
196
  end
184
197
  end
185
198
  end
199
+
200
+ def custom_subgroup_expression(name, expr)
201
+ @options[:custom_subgroup_exprs][name] = expr
202
+ end
186
203
  end
187
204
 
188
205
  class AggregationSettings
data/lib/model_graph.rb CHANGED
@@ -11,7 +11,7 @@ module Mochigome
11
11
  @graphed_models = Set.new
12
12
  @table_to_model = {}
13
13
  @assoc_graph = RGL::DirectedAdjacencyGraph.new
14
- @edge_relation_funcs = {}
14
+ @edge_conditions = {}
15
15
  @shortest_paths = {}
16
16
  end
17
17
 
@@ -29,20 +29,13 @@ module Mochigome
29
29
  r
30
30
  end
31
31
 
32
- def relation_over_path(path)
33
- update_assoc_graph(path)
34
- real_path = path.map(&:to_real_model).uniq
35
- # Project ensures that we return a Rel, not a Table, even if path is empty
36
- rel = real_path.first.arel_table.project
37
- (0..(real_path.size-2)).each do |i|
38
- rel = relation_func(real_path[i], real_path[i+1]).call(rel)
39
- end
40
- rel
32
+ def relation_init(model)
33
+ update_assoc_graph([model])
34
+ model.arel_table.project # Project to convert Arel::Table to Arel::Rel
41
35
  end
42
36
 
43
- def relation_func(u, v)
44
- @edge_relation_funcs[[u,v]] or
45
- raise QueryError.new "No assoc from #{u.name} to #{v.name}"
37
+ def edge_condition(u, v)
38
+ @edge_conditions[[u,v]]
46
39
  end
47
40
 
48
41
  def path_thru(models)
@@ -119,7 +112,7 @@ module Mochigome
119
112
  edge = [model, foreign_model]
120
113
  next if @assoc_graph.has_edge?(*edge) # Ignore duplicate assocs
121
114
  @assoc_graph.add_edge(*edge)
122
- @edge_relation_funcs[edge] = model.arelified_assoc(name)
115
+ @edge_conditions[edge] = model.assoc_condition(name)
123
116
  end
124
117
  end
125
118
 
data/lib/query.rb CHANGED
@@ -22,13 +22,11 @@ module Mochigome
22
22
  else [a, a]
23
23
  end
24
24
 
25
- agg_rel = Relation.new(@layer_types) # TODO Only go as far as focus
26
- agg_rel.join_to_model(focus_model)
25
+ agg_rel = Relation.new(@layer_types)
27
26
  agg_rel.join_on_path_thru([focus_model, data_model])
28
27
  agg_rel.apply_access_filter_func(@access_filter)
29
28
 
30
- key_models = @ids_rel.spine_layers_thru(focus_model)
31
- key_cols = key_models.map{|m| m.arel_primary_key}
29
+ key_cols = @ids_rel.spine_layers.map{|m| m.arel_primary_key}
32
30
 
33
31
  agg_fields = data_model.mochigome_aggregation_settings.
34
32
  options[:fields].reject{|a| a[:in_ruby]}
@@ -83,8 +81,10 @@ module Mochigome
83
81
  Mochigome Version: #{Mochigome::VERSION}
84
82
  Report Generated: #{Time.now}
85
83
  Layers: #{@layer_types.map(&:name).join(" => ")}
86
- AR Path: #{@ids_rel.full_spine_path.map(&:name).join(" => ")}
87
84
  eos
85
+ @ids_rel.joins.each do |src, tgt|
86
+ root.comment += "Join: #{src.name} -> #{tgt.name}\n"
87
+ end
88
88
  root.comment.gsub!(/(\n|^) +/, "\\1")
89
89
 
90
90
  r = @ids_rel.clone
@@ -191,14 +191,24 @@ module Mochigome
191
191
  private
192
192
 
193
193
  class Relation
194
+ attr_reader :joins, :spine_layers
195
+
194
196
  def initialize(layers)
195
197
  @model_graph = ModelGraph.new
196
198
  @spine_layers = layers
197
- @spine = @model_graph.path_thru(layers) or
198
- raise QueryError.new("No valid path thru #{layers.inspect}") #TODO Test
199
- @models = Set.new @spine.map(&:to_real_model)
200
- @rel = @model_graph.relation_over_path(@spine)
201
-
199
+ @models = Set.new
200
+ @spine = []
201
+ @joins = []
202
+
203
+ @spine_layers.map(&:to_real_model).uniq.each do |m|
204
+ if @rel
205
+ join_to_model(m)
206
+ else
207
+ @rel = @model_graph.relation_init(m)
208
+ @models.add m
209
+ end
210
+ @spine << m
211
+ end
202
212
  @spine_layers.each{|m| select_model_id(m)}
203
213
  end
204
214
 
@@ -210,16 +220,6 @@ module Mochigome
210
220
  @rel.to_sql
211
221
  end
212
222
 
213
- def full_spine_path
214
- @spine.dup
215
- end
216
-
217
- def spine_layers_thru(model)
218
- r = @spine.take_while{|m| m != model}
219
- r << model unless r.size == @spine.size
220
- r.select{|m| @spine_layers.include? m}
221
- end
222
-
223
223
  def clone
224
224
  c = super
225
225
  c.instance_variable_set :@models, @models.clone
@@ -241,6 +241,14 @@ module Mochigome
241
241
 
242
242
  raise QueryError.new("No path to #{model}") unless best_path
243
243
  join_on_path(best_path)
244
+
245
+ # TODO: Write a test that requires the below code to work
246
+ @models.reject{|n| best_path.include?(n)}.each do |n|
247
+ cond = @model_graph.edge_condition(n, model)
248
+ if cond
249
+ @rel = @rel.where(cond)
250
+ end
251
+ end
244
252
  end
245
253
 
246
254
  def join_on_path_thru(path)
@@ -254,8 +262,9 @@ module Mochigome
254
262
 
255
263
  def join_on_path(path)
256
264
  path = path.map(&:to_real_model).uniq
265
+ join_to_model path.first
257
266
  (0..(path.size-2)).map{|i| [path[i], path[i+1]]}.each do |src, tgt|
258
- add_join_link src, tgt
267
+ add_join_link(src, tgt) unless @models.include?(tgt)
259
268
  end
260
269
  end
261
270
 
@@ -305,9 +314,11 @@ module Mochigome
305
314
  def add_join_link(src, tgt)
306
315
  raise QueryError.new("Can't join from #{src}, not available") unless
307
316
  @models.include?(src)
308
- return if @models.include?(tgt) # TODO Maybe still apply join conditions?
309
- @rel = @model_graph.relation_func(src, tgt).call(@rel)
317
+ @rel = @rel.join(tgt.arel_table, Arel::Nodes::InnerJoin).on(
318
+ @model_graph.edge_condition(src, tgt)
319
+ )
310
320
  @models.add tgt
321
+ @joins << [src, tgt]
311
322
  end
312
323
  end
313
324
  end
@@ -8,6 +8,11 @@ module Mochigome
8
8
  def initialize(model, attr)
9
9
  @model = model
10
10
  @attr = attr
11
+ if @model.mochigome_focus_settings
12
+ s = @model.mochigome_focus_settings
13
+ # @attr_expr will just be nil if there's no custom subgroup expr here
14
+ @attr_expr = s.options[:custom_subgroup_exprs][attr]
15
+ end
11
16
  @focus_settings = Mochigome::ReportFocusSettings.new(@model)
12
17
  @focus_settings.type_name "#{@model.human_name} #{@attr.to_s.humanize}"
13
18
  @focus_settings.name lambda{|r| r.send(attr)}
@@ -38,7 +43,11 @@ module Mochigome
38
43
  end
39
44
 
40
45
  def arel_primary_key
41
- arel_table[@attr]
46
+ if @attr_expr
47
+ @attr_expr
48
+ else
49
+ arel_table[@attr]
50
+ end
42
51
  end
43
52
 
44
53
  def connection
@@ -1,6 +1,13 @@
1
1
  class Product < ActiveRecord::Base
2
2
  acts_as_mochigome_focus do |f|
3
3
  f.fields [:price]
4
+ f.custom_subgroup_expression :name_ends_with_vowel,
5
+ Mochigome::case_expr(
6
+ Arel::Nodes::NamedFunction.new("SUBSTR", [Product.arel_table[:name], -1]).
7
+ in(['a','e','i','o','u','A','E','I','O','U']),
8
+ "Vowel",
9
+ "Consonant"
10
+ ).call(Product.arel_table)
4
11
  end
5
12
  has_mochigome_aggregations do |a|
6
13
  a.fields [
@@ -364,8 +364,8 @@ describe "an ActiveRecord model" do
364
364
  assert agg[:in_ruby]
365
365
  end
366
366
 
367
- def assoc_query_words_match(assoc, words)
368
- q = assoc.call(Arel::Table.new(:foo).project(Arel.star)).to_sql
367
+ def assoc_query_words_match(tbl, cond, words)
368
+ q = Arel::Table.new(:foo).project(Arel.star).join(Arel::Table.new(tbl)).on(cond).to_sql
369
369
  cur_word = words.shift
370
370
  q.split(/[ .]/).each do |s_word|
371
371
  if s_word.gsub(/["'`]+/, '').downcase == cur_word.downcase
@@ -380,7 +380,7 @@ describe "an ActiveRecord model" do
380
380
  @model_class.class_eval do
381
381
  belongs_to :store
382
382
  end
383
- assert assoc_query_words_match @model_class.arelified_assoc(:store),
383
+ assert assoc_query_words_match "stores", @model_class.assoc_condition(:store),
384
384
  %w{select * from foo join stores on fake store_id = stores id}
385
385
  end
386
386
 
@@ -388,7 +388,7 @@ describe "an ActiveRecord model" do
388
388
  @model_class.class_eval do
389
389
  has_many :stores
390
390
  end
391
- assert assoc_query_words_match @model_class.arelified_assoc(:stores),
391
+ assert assoc_query_words_match "stores", @model_class.assoc_condition(:stores),
392
392
  %w{select * from foo join stores on fake id = stores whale_id}
393
393
  end
394
394
 
@@ -396,13 +396,13 @@ describe "an ActiveRecord model" do
396
396
  @model_class.class_eval do
397
397
  has_one :store
398
398
  end
399
- assert assoc_query_words_match @model_class.arelified_assoc(:store),
399
+ assert assoc_query_words_match "stores", @model_class.assoc_condition(:store),
400
400
  %w{select * from foo join stores on fake id = stores whale_id}
401
401
  end
402
402
 
403
403
  it "raises AssociationError on attempting to arelify a non-extant assoc" do
404
404
  assert_raises Mochigome::AssociationError do
405
- Store.arelified_assoc(:dinosaurs)
405
+ Store.assoc_condition(:dinosaurs)
406
406
  end
407
407
  end
408
408
 
@@ -180,7 +180,52 @@ describe Mochigome::Query do
180
180
  assert_equal 8, (data_node/1)['Sales count']
181
181
  end
182
182
 
183
- # TODO: Test diamond patterns
183
+ it "can subgroup layers with custom expressions" do
184
+ q = Mochigome::Query.new(
185
+ [
186
+ Store,
187
+ Mochigome::SubgroupModel.new(Product, :name_ends_with_vowel),
188
+ Product
189
+ ]
190
+ )
191
+ data_node = q.run
192
+ assert_equal "Jane's Store (North)", (data_node/1).name
193
+ assert_equal "Consonant", (data_node/1/0).name
194
+ assert_equal 1, (data_node/1/0).children.size
195
+ assert_equal "Vowel", (data_node/1/1).name
196
+ assert_equal 2, (data_node/1/1).children.size
197
+ end
198
+
199
+ it "can collect aggregate data with custom expression subgroups" do
200
+ q = Mochigome::Query.new(
201
+ [
202
+ Store,
203
+ Mochigome::SubgroupModel.new(Product, :name_ends_with_vowel),
204
+ ],
205
+ :aggregate_sources => [Product]
206
+ )
207
+ data_node = q.run
208
+ assert_equal "Jane's Store (North)", (data_node/1).name
209
+ assert_equal "Consonant", (data_node/1/0).name
210
+ assert_equal @product_b.price, (data_node/1/0)['Products sum price']
211
+ assert_equal "Vowel", (data_node/1/1).name
212
+ assert_equal [@product_a, @product_e].map(&:price).sum,
213
+ (data_node/1/1)['Products sum price']
214
+ end
215
+
216
+ it "can group through a list that has no direct association path" do
217
+ q = Mochigome::Query.new(
218
+ [Category, Store, Product],
219
+ :aggregate_sources => [Sale]
220
+ )
221
+ data_node = q.run
222
+ assert_equal "Category 1", (data_node/0).name
223
+ assert_equal 15, (data_node/0)["Sales count"]
224
+ assert_equal "John's Store", (data_node/0/0).name
225
+ assert_equal 5, (data_node/0/0)["Sales count"]
226
+ assert_equal "Product A", (data_node/0/0/0).name
227
+ assert_equal 5, (data_node/0/0/0)["Sales count"]
228
+ end
184
229
 
185
230
  it "collects aggregate data by grouping on all layers" do
186
231
  q = Mochigome::Query.new(
@@ -305,20 +350,6 @@ describe Mochigome::Query do
305
350
  assert_equal 24, data_node['Sales count']
306
351
  end
307
352
 
308
- it "does not collect aggregate data for layers below focus" do
309
- q = Mochigome::Query.new(
310
- [Owner, Store, Product, Category],
311
- :aggregate_sources => [[Product, Sale]]
312
- )
313
- data_node = q.run
314
-
315
- assert_equal "Product", (data_node/0/0/0)[:internal_type]
316
- refute_nil (data_node/0/0/0)['Sales count']
317
-
318
- assert_equal "Category", (data_node/0/0/0/0)[:internal_type]
319
- assert_nil (data_node/0/0/0/0)['Sales count']
320
- end
321
-
322
353
  it "can collect aggregate data involving joins to tables not on path" do
323
354
  q = Mochigome::Query.new(
324
355
  [Owner, Store],
@@ -432,7 +463,7 @@ describe Mochigome::Query do
432
463
  assert_match c, /^Mochigome Version: #{Mochigome::VERSION}\n/
433
464
  assert_match c, /\nReport Generated: \w{3} \w{3} \d+ .+\n/
434
465
  assert_match c, /\nLayers: Owner => Store => Product\n/
435
- assert_match c, /\nAR Path: Owner => Store => StoreProduct => Product\n/
466
+ assert_match c, /\nJoin: \w+ -> \w+\n/
436
467
  end
437
468
 
438
469
  it "names the root node 'report' by default" do
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: 23
4
+ hash: 21
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
8
  - 1
9
- - 6
10
- version: 0.1.6
9
+ - 7
10
+ version: 0.1.7
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-04-28 00:00:00 Z
18
+ date: 2012-05-02 00:00:00 Z
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency
21
21
  version_requirements: &id001 !ruby/object:Gem::Requirement