mochigome 0.1.6 → 0.1.7

Sign up to get free protection for your applications and to get access to all the features.
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