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 +1 -1
- data/lib/mochigome_ver.rb +1 -1
- data/lib/model_extensions.rb +41 -24
- data/lib/model_graph.rb +7 -14
- data/lib/query.rb +34 -23
- data/lib/subgroup_model.rb +10 -1
- data/test/app_root/app/models/product.rb +7 -0
- data/test/unit/model_extensions_test.rb +6 -6
- data/test/unit/query_test.rb +47 -16
- metadata +4 -4
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
data/lib/model_extensions.rb
CHANGED
@@ -54,37 +54,35 @@ module Mochigome
|
|
54
54
|
write_inheritable_attribute :mochigome_aggregation_settings, settings
|
55
55
|
end
|
56
56
|
|
57
|
-
def
|
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
|
-
|
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
|
-
|
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 #{
|
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
|
-
@
|
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
|
33
|
-
update_assoc_graph(
|
34
|
-
|
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
|
44
|
-
@
|
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
|
-
@
|
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)
|
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
|
-
|
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
|
-
@
|
198
|
-
|
199
|
-
@
|
200
|
-
|
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
|
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
|
-
|
309
|
-
|
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
|
data/lib/subgroup_model.rb
CHANGED
@@ -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
|
-
|
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(
|
368
|
-
q =
|
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.
|
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.
|
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.
|
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.
|
405
|
+
Store.assoc_condition(:dinosaurs)
|
406
406
|
end
|
407
407
|
end
|
408
408
|
|
data/test/unit/query_test.rb
CHANGED
@@ -180,7 +180,52 @@ describe Mochigome::Query do
|
|
180
180
|
assert_equal 8, (data_node/1)['Sales count']
|
181
181
|
end
|
182
182
|
|
183
|
-
|
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, /\
|
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:
|
4
|
+
hash: 21
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 1
|
9
|
-
-
|
10
|
-
version: 0.1.
|
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-
|
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
|