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 +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
|