mochigome 0.1.22 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/TODO +9 -2
- data/lib/data_node.rb +26 -16
- data/lib/mochigome_ver.rb +1 -1
- data/lib/model_extensions.rb +13 -7
- data/lib/query.rb +204 -130
- data/lib/subgroup_model.rb +2 -1
- data/test/app_root/db/migrate/20110817163830_create_tables.rb +4 -0
- data/test/unit/data_node_test.rb +14 -9
- data/test/unit/model_extensions_test.rb +19 -2
- data/test/unit/query_test.rb +43 -15
- metadata +5 -5
data/TODO
CHANGED
@@ -2,8 +2,8 @@
|
|
2
2
|
- Replace all the TODOs and FIXMEs with whatever they're asking for (additional checks, use of ordered indifferent hashes instead of arrays of hashes, etc.)
|
3
3
|
- If there is more than one association from model A to model B and they're both focusable, pick the one with no conditions. If all the associations have conditions, complain and require that the correct association be manually specified (where "correct" might mean none of them should be valid)
|
4
4
|
- Alternately, always ignore conditional associations unless they're specifically provided to Mochigome by the model
|
5
|
-
- Named subsets of different fields
|
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?
|
5
|
+
- Named subsets of different fields on a single model
|
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? Slow, tho...
|
7
7
|
- Automatically set default to 0 on sum and count aggregation
|
8
8
|
- Better handling of nil in subgroup fields
|
9
9
|
- Handle has_and_belongs_to_many correctly
|
@@ -12,3 +12,10 @@
|
|
12
12
|
- Allow row counts that skip layers (i.e. a school count report on School$region, SchoolDetail$comp_shelter should have a top-level box that summarizes the # of schools that do and do not have competency shelters)
|
13
13
|
- Some kind of on-write caching feature for records that would be expensive to fully re-aggregate every time (i.e. AttendanceRecord)
|
14
14
|
- Some cancan joins must be allowed to double-back over already-joined tables (i.e. the troublesome SchoolStudent ability)
|
15
|
+
- Don't take aggregations deeper than focus model layer unless appropriate
|
16
|
+
- How to determine "appropriate"? Maybe if the path from data to lower layer adds
|
17
|
+
additional restrictions? For example, on Student->Class, it makes sense
|
18
|
+
to aggregate Student::AttendanceRecord on the Class layer
|
19
|
+
but it wouldn't make sense to aggregate Student::AttendanceRecord all the
|
20
|
+
way down on Student->Assignment because Assignment results would
|
21
|
+
just be a copy of the Student layer results.
|
data/lib/data_node.rb
CHANGED
@@ -30,10 +30,11 @@ module Mochigome
|
|
30
30
|
def <<(item)
|
31
31
|
if item.is_a?(Array)
|
32
32
|
item.map {|i| self << i}
|
33
|
-
|
34
|
-
raise DataNodeError.new("New child #{item} is not a DataNode") unless item.is_a?(DataNode)
|
33
|
+
elsif item.is_a?(DataNode)
|
35
34
|
@children << item
|
36
35
|
@children.last
|
36
|
+
else
|
37
|
+
raise DataNodeError.new("Can't adopt #{item.inspect}, it's not a DataNode")
|
37
38
|
end
|
38
39
|
end
|
39
40
|
|
@@ -47,18 +48,16 @@ module Mochigome
|
|
47
48
|
twin
|
48
49
|
end
|
49
50
|
|
50
|
-
# TODO: Only define xml-related methods if nokogiri loaded
|
51
51
|
def to_xml
|
52
52
|
doc = Nokogiri::XML::Document.new
|
53
53
|
append_xml_to(doc)
|
54
54
|
doc
|
55
55
|
end
|
56
56
|
|
57
|
-
# TODO: Only define ruport-related methods if ruport is loaded
|
58
57
|
def to_flat_ruport_table
|
59
58
|
col_names = flat_column_names
|
60
59
|
table = Ruport::Data::Table.new(:column_names => col_names)
|
61
|
-
append_rows_to(table, col_names
|
60
|
+
append_rows_to(table, col_names)
|
62
61
|
table
|
63
62
|
end
|
64
63
|
|
@@ -66,7 +65,7 @@ module Mochigome
|
|
66
65
|
table = []
|
67
66
|
col_names = flat_column_names
|
68
67
|
table << col_names
|
69
|
-
append_rows_to(table, col_names
|
68
|
+
append_rows_to(table, col_names)
|
70
69
|
table
|
71
70
|
end
|
72
71
|
|
@@ -97,27 +96,38 @@ module Mochigome
|
|
97
96
|
x.add_child(node)
|
98
97
|
end
|
99
98
|
|
100
|
-
# TODO: Should handle trickier situations involving datanodes not having various columns
|
101
99
|
def flat_column_names
|
102
100
|
colnames = (["name"] + keys).
|
103
101
|
reject{|key| key.to_s.start_with?("_")}.
|
104
102
|
map{|key| "#{@type_name}::#{key}"}
|
105
103
|
choices = @children.map(&:flat_column_names)
|
106
|
-
colnames += choices.
|
104
|
+
colnames += choices.flatten(1).uniq || []
|
107
105
|
colnames
|
108
106
|
end
|
109
107
|
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
108
|
+
def append_rows_to(table, colnames, row = nil)
|
109
|
+
row = colnames.map{nil} if row.nil?
|
110
|
+
|
111
|
+
added_cell_indices = []
|
112
|
+
colnames.each_with_index do |k, i|
|
113
|
+
if k =~ /^#{@type_name}::(.+)$/
|
114
|
+
attr_name = $1
|
115
|
+
if attr_name.to_sym == :name
|
116
|
+
row[i] = name
|
117
|
+
else
|
118
|
+
row[i] = self[attr_name.to_sym]
|
119
|
+
end
|
120
|
+
added_cell_indices << i
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
114
124
|
if @children.size > 0
|
115
|
-
@children.each {|child| child.send(:append_rows_to, table,
|
125
|
+
@children.each {|child| child.send(:append_rows_to, table, colnames, row)}
|
116
126
|
else
|
117
|
-
|
118
|
-
table << (row + Array.new(pad - row.size, nil))
|
127
|
+
table << row.dup
|
119
128
|
end
|
120
|
-
|
129
|
+
|
130
|
+
added_cell_indices.each{|i| row[i] = nil}
|
121
131
|
end
|
122
132
|
end
|
123
133
|
end
|
data/lib/mochigome_ver.rb
CHANGED
data/lib/model_extensions.rb
CHANGED
@@ -154,22 +154,23 @@ module Mochigome
|
|
154
154
|
|
155
155
|
class ReportFocus
|
156
156
|
attr_reader :type_name
|
157
|
-
attr_reader :fields
|
158
157
|
|
159
158
|
def initialize(owner, settings)
|
160
159
|
@owner = owner
|
161
160
|
@name_proc = settings.options[:name] || lambda{|obj| obj.name}
|
162
161
|
@type_name = settings.options[:type_name] || owner.class.human_name
|
163
|
-
@
|
162
|
+
@fieldsets = settings.options[:fieldsets] || {}
|
164
163
|
end
|
165
164
|
|
166
165
|
def name
|
167
166
|
@name_proc.call(@owner)
|
168
167
|
end
|
169
168
|
|
170
|
-
def field_data
|
169
|
+
def field_data(fieldset_names = nil)
|
170
|
+
fieldset_names ||= [:default]
|
171
171
|
h = ActiveSupport::OrderedHash.new
|
172
|
-
|
172
|
+
field_descs = fieldset_names.map{|n|@fieldsets[n]}.compact.flatten(1).uniq
|
173
|
+
field_descs.each do |field|
|
173
174
|
h[field[:name]] = field[:value_func].call(@owner)
|
174
175
|
end
|
175
176
|
h
|
@@ -184,7 +185,7 @@ module Mochigome
|
|
184
185
|
def initialize(model)
|
185
186
|
@model = model
|
186
187
|
@options = {}
|
187
|
-
@options[:
|
188
|
+
@options[:fieldsets] = {}
|
188
189
|
@options[:custom_subgroup_exprs] = ActiveSupport::OrderedHash.new
|
189
190
|
@options[:custom_assocs] = ActiveSupport::OrderedHash.new
|
190
191
|
@options[:ignore_assocs] = Set.new
|
@@ -210,11 +211,15 @@ module Mochigome
|
|
210
211
|
end
|
211
212
|
|
212
213
|
def fields(fields)
|
214
|
+
fieldset(:default, fields)
|
215
|
+
end
|
216
|
+
|
217
|
+
def fieldset(name, fields)
|
213
218
|
unless fields.respond_to?(:each)
|
214
|
-
raise ModelSetupError.new "Call f.
|
219
|
+
raise ModelSetupError.new "Call f.fieldset with an Enumerable"
|
215
220
|
end
|
216
221
|
|
217
|
-
|
222
|
+
field_descs = fields.map do |f|
|
218
223
|
case f
|
219
224
|
when String, Symbol then {
|
220
225
|
:name => Mochigome::complain_if_reserved_name(f.to_s.strip),
|
@@ -227,6 +232,7 @@ module Mochigome
|
|
227
232
|
else raise ModelSetupError.new "Invalid field: #{f.inspect}"
|
228
233
|
end
|
229
234
|
end
|
235
|
+
(@options[:fieldsets][name.to_sym] ||= []).concat field_descs
|
230
236
|
end
|
231
237
|
|
232
238
|
def custom_subgroup_expression(name, expr)
|
data/lib/query.rb
CHANGED
@@ -1,91 +1,216 @@
|
|
1
1
|
module Mochigome
|
2
2
|
class Query
|
3
|
-
def initialize(
|
4
|
-
# TODO: Validate layer types: not empty, AR, act_as_mochigome_focus
|
5
|
-
@layer_types = layer_types
|
6
|
-
|
3
|
+
def initialize(layers, options = {})
|
7
4
|
@name = options.delete(:root_name).try(:to_s) || "report"
|
8
|
-
|
9
|
-
# TODO: Validate that aggregate_sources is in the correct format
|
5
|
+
access_filter = options.delete(:access_filter) || lambda {|cls| {}}
|
10
6
|
aggregate_sources = options.delete(:aggregate_sources) || []
|
11
7
|
unless options.empty?
|
12
8
|
raise QueryError.new("Unknown options: #{options.keys.inspect}")
|
13
9
|
end
|
14
10
|
|
15
|
-
|
16
|
-
|
11
|
+
if layers.is_a? Array
|
12
|
+
layer_paths = [layers]
|
13
|
+
else
|
14
|
+
unless layers.is_a?(Hash) &&
|
15
|
+
layers.size == 1 &&
|
16
|
+
layers.keys.first == :report
|
17
|
+
raise QueryError.new("Invalid layer tree")
|
18
|
+
end
|
19
|
+
layer_paths = Query.tree_root_to_leaf_paths(layers.values.first)
|
20
|
+
end
|
21
|
+
layer_paths = [[]] if layer_paths.empty? # Create at least one QueryLine
|
22
|
+
@lines = layer_paths.map{ |path| QueryLine.new(path, access_filter) }
|
17
23
|
|
18
|
-
@aggregate_rels = ActiveSupport::OrderedHash.new
|
19
24
|
aggregate_sources.each do |a|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
25
|
+
@lines.each{|line| line.add_aggregate_source(a)}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def run(cond = nil)
|
30
|
+
model_ids = {}
|
31
|
+
parental_seqs = {}
|
32
|
+
@lines.each do |line|
|
33
|
+
tbl = line.build_id_table(cond)
|
34
|
+
parent_models = []
|
35
|
+
line.layer_types.each do |model|
|
36
|
+
tbl.each do |ids_row|
|
37
|
+
i = ids_row["#{model.name}_id"]
|
38
|
+
if i
|
39
|
+
(model_ids[model] ||= Set.new).add(i)
|
40
|
+
parental_seq_key = parent_models.zip(
|
41
|
+
parent_models.map{|pm| ids_row["#{pm}_id"]}
|
42
|
+
)
|
43
|
+
(parental_seqs[parental_seq_key] ||= Set.new).add([model.name, i])
|
44
|
+
end
|
45
|
+
end
|
46
|
+
parent_models << model.name
|
28
47
|
end
|
48
|
+
end
|
29
49
|
|
30
|
-
|
31
|
-
|
32
|
-
|
50
|
+
model_datanodes = generate_datanodes(model_ids)
|
51
|
+
root = create_root_node
|
52
|
+
add_datanode_children([], root, model_datanodes, parental_seqs)
|
53
|
+
@lines.each do |line|
|
54
|
+
line.load_aggregate_data(root, cond)
|
55
|
+
end
|
56
|
+
return root
|
57
|
+
end
|
33
58
|
|
34
|
-
|
59
|
+
private
|
35
60
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
61
|
+
def self.tree_root_to_leaf_paths(t)
|
62
|
+
if t.is_a?(Hash)
|
63
|
+
t.map{|k, v|
|
64
|
+
tree_root_to_leaf_paths(v).map{|p| [k] + p}
|
65
|
+
}.flatten(1)
|
66
|
+
elsif t.is_a?(Array)
|
67
|
+
t.map{|v| tree_root_to_leaf_paths(v)}.flatten(1)
|
68
|
+
else
|
69
|
+
[[t]]
|
70
|
+
end
|
71
|
+
end
|
44
72
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
73
|
+
def generate_datanodes(model_ids)
|
74
|
+
model_datanodes = {}
|
75
|
+
model_ids.keys.each do |model|
|
76
|
+
# TODO: Find a way to do this without loading all recs at one time
|
77
|
+
model.all(
|
78
|
+
:conditions => {model.primary_key => model_ids[model].to_a},
|
79
|
+
:order => model.mochigome_focus_settings.get_ordering
|
80
|
+
).each_with_index do |rec, seq_idx|
|
81
|
+
f = rec.mochigome_focus
|
82
|
+
dn = DataNode.new(f.type_name, f.name)
|
83
|
+
dn.merge!(f.field_data)
|
84
|
+
dn[:id] = rec.id
|
85
|
+
dn[:internal_type] = model.name
|
86
|
+
(model_datanodes[model.name] ||= {})[rec.id] = [dn, seq_idx]
|
87
|
+
end
|
88
|
+
end
|
89
|
+
return model_datanodes
|
90
|
+
end
|
50
91
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
92
|
+
def add_datanode_children(path, node, model_datanodes, parental_seqs)
|
93
|
+
path_children = parental_seqs[path]
|
94
|
+
return unless path_children
|
95
|
+
ordered_children = {}
|
96
|
+
path_children.each do |model, i|
|
97
|
+
src_dn, seq_idx = model_datanodes[model][i]
|
98
|
+
dn = src_dn.clone
|
99
|
+
full_path = path + [[model, i]]
|
100
|
+
dn[:_report_path] = full_path.map(&:first).join("___")
|
101
|
+
add_datanode_children(full_path, dn, model_datanodes, parental_seqs)
|
60
102
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
name = "g%03u" % i
|
74
|
-
rel.project(d_tbl[name].as(name)).group(name)
|
75
|
-
end
|
76
|
-
rel
|
77
|
-
}
|
78
|
-
}
|
103
|
+
# Sorting by left-to-right class order in Query layer tree, then
|
104
|
+
# by the order of the records themselves.
|
105
|
+
# TODO: This way of getting model_idx could create problems
|
106
|
+
# if a class appears more than once in the tree.
|
107
|
+
model_idx = @lines.index{|line| line.layer_types.any?{|m| m.name == model}}
|
108
|
+
(ordered_children[model_idx] ||= {})[seq_idx] = dn
|
109
|
+
end
|
110
|
+
ordered_children.keys.sort.each do |k|
|
111
|
+
subhash = ordered_children[k]
|
112
|
+
subhash.keys.sort.each do |seqkey|
|
113
|
+
node.children << subhash[seqkey]
|
114
|
+
end
|
79
115
|
end
|
80
116
|
end
|
81
117
|
|
82
|
-
def
|
83
|
-
root =
|
84
|
-
|
85
|
-
|
118
|
+
def create_root_node
|
119
|
+
root = DataNode.new(:report, @name)
|
120
|
+
root.comment = <<-eos
|
121
|
+
Mochigome Version: #{Mochigome::VERSION}
|
122
|
+
Report Generated: #{Time.now}
|
123
|
+
eos
|
124
|
+
# FIXME Show layers and joins for all lines individually
|
125
|
+
#Layers: #{@layer_types.map(&:name).join(" => ")}
|
126
|
+
#eos
|
127
|
+
#@ids_rel.joins.each do |src, tgt|
|
128
|
+
# root.comment += "Join: #{src.name} -> #{tgt.name}\n"
|
129
|
+
#end
|
130
|
+
root.comment.gsub!(/(\n|^) +/, "\\1")
|
131
|
+
return root
|
86
132
|
end
|
133
|
+
end
|
87
134
|
|
88
|
-
|
135
|
+
private
|
136
|
+
|
137
|
+
class QueryLine
|
138
|
+
attr_accessor :layer_types
|
139
|
+
attr_accessor :ids_rel
|
140
|
+
|
141
|
+
def initialize(layer_types, access_filter)
|
142
|
+
# TODO: Validate layer types: not empty, AR, act_as_mochigome_focus
|
143
|
+
@layer_types = layer_types
|
144
|
+
@access_filter = access_filter
|
145
|
+
|
146
|
+
@ids_rel = Relation.new(@layer_types)
|
147
|
+
@ids_rel.apply_access_filter_func(@access_filter)
|
148
|
+
|
149
|
+
@aggregate_rels = ActiveSupport::OrderedHash.new
|
150
|
+
end
|
151
|
+
|
152
|
+
def add_aggregate_source(a)
|
153
|
+
focus_model, data_model, agg_setting_name = nil, nil, nil
|
154
|
+
if a.is_a?(Array) then
|
155
|
+
focus_model = a.select{|e| e.is_a?(Class)}.first
|
156
|
+
data_model = a.select{|e| e.is_a?(Class)}.last
|
157
|
+
agg_setting_name = a.select{|e| e.is_a?(Symbol)}.first || :default
|
158
|
+
else
|
159
|
+
focus_model = data_model = a
|
160
|
+
agg_setting_name = :default
|
161
|
+
end
|
162
|
+
# FIXME Raise exception if a isn't in a correct format
|
163
|
+
|
164
|
+
agg_rel = Relation.new(@layer_types)
|
165
|
+
agg_rel.join_on_path_thru([focus_model, data_model])
|
166
|
+
agg_rel.apply_access_filter_func(@access_filter)
|
167
|
+
|
168
|
+
key_cols = @ids_rel.spine_layers.map{|m| m.arel_primary_key}
|
169
|
+
|
170
|
+
agg_fields = data_model.
|
171
|
+
mochigome_aggregation_settings(agg_setting_name).
|
172
|
+
options[:fields].reject{|a| a[:in_ruby]}
|
173
|
+
agg_fields.each_with_index do |a, i|
|
174
|
+
d_expr = a[:value_proc].call(data_model.arel_table)
|
175
|
+
d_expr = d_expr.expr if d_expr.respond_to?(:expr)
|
176
|
+
agg_rel.select_expr(d_expr.as("d%03u" % i))
|
177
|
+
end
|
178
|
+
|
179
|
+
agg_rel_key = {
|
180
|
+
:focus_model => focus_model,
|
181
|
+
:data_model => data_model,
|
182
|
+
:agg_setting_name => agg_setting_name
|
183
|
+
}
|
184
|
+
|
185
|
+
@aggregate_rels[agg_rel_key] = (0..key_cols.length).map{|n|
|
186
|
+
lambda {|cond|
|
187
|
+
data_rel = agg_rel.clone
|
188
|
+
data_rel.apply_condition(cond)
|
189
|
+
data_cols = key_cols.take(n) + [data_model.arel_primary_key]
|
190
|
+
inner_rel = data_rel.to_arel
|
191
|
+
data_cols.each_with_index do |col, i|
|
192
|
+
inner_rel.project(col.as("g%03u" % i)).group(col)
|
193
|
+
end
|
194
|
+
|
195
|
+
# FIXME: This subtable won't be necessary for all aggregation funcs.
|
196
|
+
# When we can avoid it, we should, for performance.
|
197
|
+
rel = Arel::SelectManager.new(
|
198
|
+
Arel::Table.engine,
|
199
|
+
Arel.sql("(#{inner_rel.to_sql}) as mochigome_data")
|
200
|
+
)
|
201
|
+
d_tbl = Arel::Table.new("mochigome_data")
|
202
|
+
agg_fields.each_with_index do |a, i|
|
203
|
+
name = "d%03u" % i
|
204
|
+
rel.project(a[:agg_proc].call(d_tbl[name]).as(name))
|
205
|
+
end
|
206
|
+
key_cols.take(n).each_with_index do |col, i|
|
207
|
+
name = "g%03u" % i
|
208
|
+
rel.project(d_tbl[name].as(name)).group(name)
|
209
|
+
end
|
210
|
+
rel
|
211
|
+
}
|
212
|
+
}
|
213
|
+
end
|
89
214
|
|
90
215
|
def connection
|
91
216
|
ActiveRecord::Base.connection
|
@@ -96,71 +221,19 @@ module Mochigome
|
|
96
221
|
(v.nil? || v.to_s.strip.empty?) ? "(None)" : v
|
97
222
|
end
|
98
223
|
|
99
|
-
def
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
Report Generated: #{Time.now}
|
104
|
-
Layers: #{@layer_types.map(&:name).join(" => ")}
|
105
|
-
eos
|
106
|
-
@ids_rel.joins.each do |src, tgt|
|
107
|
-
root.comment += "Join: #{src.name} -> #{tgt.name}\n"
|
108
|
-
end
|
109
|
-
root.comment.gsub!(/(\n|^) +/, "\\1")
|
110
|
-
|
111
|
-
unless @layer_types.empty?
|
224
|
+
def build_id_table(cond)
|
225
|
+
if @layer_types.empty?
|
226
|
+
return []
|
227
|
+
else
|
112
228
|
r = @ids_rel.clone
|
113
229
|
r.apply_condition(cond)
|
114
230
|
ids_sql = r.to_sql
|
115
|
-
|
116
|
-
|
117
|
-
row
|
118
|
-
row[k] = denilify(v)
|
119
|
-
end
|
231
|
+
return connection.select_all(ids_sql).map do |row|
|
232
|
+
row.each do |k,v|
|
233
|
+
row[k] = denilify(v)
|
120
234
|
end
|
121
|
-
fill_layers(ids_table, {[] => root}, @layer_types)
|
122
235
|
end
|
123
236
|
end
|
124
|
-
|
125
|
-
root
|
126
|
-
end
|
127
|
-
|
128
|
-
def fill_layers(ids_table, parents, types, type_idx = 0)
|
129
|
-
return if type_idx >= types.size
|
130
|
-
|
131
|
-
model = types[type_idx]
|
132
|
-
layer_ids = Set.new
|
133
|
-
cur_to_parent = {}
|
134
|
-
|
135
|
-
parent_types = types.take(type_idx)
|
136
|
-
ids_table.each do |row|
|
137
|
-
cur_id = row["#{model.name}_id"]
|
138
|
-
layer_ids.add cur_id
|
139
|
-
cur_to_parent[cur_id] ||= Set.new
|
140
|
-
cur_to_parent[cur_id].add parent_types.map{|m| row["#{m.name}_id"]}
|
141
|
-
end
|
142
|
-
|
143
|
-
layer = {}
|
144
|
-
model.all( # TODO: Find a way to do this with data streaming
|
145
|
-
:conditions => {model.primary_key => layer_ids.to_a},
|
146
|
-
:order => model.mochigome_focus_settings.get_ordering
|
147
|
-
).each do |obj|
|
148
|
-
f = obj.mochigome_focus
|
149
|
-
dn = DataNode.new(f.type_name, f.name)
|
150
|
-
dn.merge!(f.field_data)
|
151
|
-
|
152
|
-
# TODO: Maybe make special fields below part of ModelExtensions?
|
153
|
-
dn[:id] = obj.id
|
154
|
-
dn[:internal_type] = model.name
|
155
|
-
|
156
|
-
cur_to_parent.fetch(obj.id).each do |parent_ids_seq|
|
157
|
-
cloned = dn.clone
|
158
|
-
parents.fetch(parent_ids_seq) << cloned
|
159
|
-
layer[parent_ids_seq + [obj.id]] = cloned
|
160
|
-
end
|
161
|
-
end
|
162
|
-
|
163
|
-
fill_layers(ids_table, layer, types, type_idx + 1)
|
164
237
|
end
|
165
238
|
|
166
239
|
def load_aggregate_data(node, cond)
|
@@ -185,15 +258,16 @@ module Mochigome
|
|
185
258
|
c[group_values.last] = data_values
|
186
259
|
end
|
187
260
|
end
|
188
|
-
insert_aggregate_data_fields(node, data_tree, agg_settings)
|
261
|
+
insert_aggregate_data_fields(node, data_tree, agg_settings, 0)
|
189
262
|
end
|
190
263
|
end
|
191
264
|
end
|
192
265
|
|
193
|
-
def insert_aggregate_data_fields(node, table, agg_settings)
|
266
|
+
def insert_aggregate_data_fields(node, table, agg_settings, depth)
|
267
|
+
return unless depth == 0 || node[:internal_type] == @layer_types[depth-1].name
|
194
268
|
if table.is_a? Array
|
195
269
|
fields = agg_settings.options[:fields]
|
196
|
-
# Pre-fill the node with
|
270
|
+
# Pre-fill the node with default values in the right order
|
197
271
|
fields.each{|fld| node[fld[:name]] = fld[:default] unless fld[:hidden] }
|
198
272
|
agg_row = {} # Hold regular results here to be used in ruby-based fields
|
199
273
|
fields.reject{|fld| fld[:in_ruby]}.zip(table).each do |fld, v|
|
@@ -205,12 +279,12 @@ module Mochigome
|
|
205
279
|
node[fld[:name]] = fld[:ruby_proc].call(agg_row)
|
206
280
|
end
|
207
281
|
node.children.each do |c|
|
208
|
-
insert_aggregate_data_fields(c, [], agg_settings)
|
282
|
+
insert_aggregate_data_fields(c, [], agg_settings, depth+1)
|
209
283
|
end
|
210
284
|
else
|
211
285
|
node.children.each do |c|
|
212
286
|
subtable = table[c[:id]] || []
|
213
|
-
insert_aggregate_data_fields(c, subtable, agg_settings)
|
287
|
+
insert_aggregate_data_fields(c, subtable, agg_settings, depth+1)
|
214
288
|
end
|
215
289
|
end
|
216
290
|
end
|
data/lib/subgroup_model.rb
CHANGED
data/test/unit/data_node_test.rb
CHANGED
@@ -102,11 +102,12 @@ describe Mochigome::DataNode do
|
|
102
102
|
@datanode.merge! [{:id => 400}, {:apples => 1}, {:box_cutters => 2}, {:can_openers => 3}]
|
103
103
|
emp1 = @datanode << Mochigome::DataNode.new(:employee, :alice)
|
104
104
|
emp1.merge! [{:id => 500}, {:x => 9}, {:y => 8}, {:z => 7}, {:internal_type => "Cyborg"}, {:_foo => "bar"}]
|
105
|
+
emp1 << Mochigome::DataNode.new(:phone, :android)
|
105
106
|
emp2 = @datanode << Mochigome::DataNode.new(:employee, :bob)
|
106
107
|
emp2.merge! [{:id => 600}, {:x => 5}, {:y => 4}, {:z => 8734}, {:internal_type => "Human"}]
|
107
108
|
emp2 << Mochigome::DataNode.new(:pet, :lassie)
|
108
109
|
|
109
|
-
@
|
110
|
+
@expected_titles = [
|
110
111
|
"corporation::name",
|
111
112
|
"corporation::id",
|
112
113
|
"corporation::apples",
|
@@ -118,12 +119,15 @@ describe Mochigome::DataNode do
|
|
118
119
|
"employee::y",
|
119
120
|
"employee::z",
|
120
121
|
"employee::internal_type",
|
122
|
+
"phone::name",
|
121
123
|
"pet::name"
|
122
124
|
]
|
125
|
+
@expected_row_1 = ['acme', 400, 1, 2, 3, 'alice', 500, 9, 8, 7, "Cyborg", "android", nil]
|
126
|
+
@expected_row_2 = ['acme', 400, 1, 2, 3, 'bob', 600, 5, 4, 8734, "Human", nil, "lassie"]
|
123
127
|
end
|
124
128
|
|
125
129
|
it "can convert to an XML document with correct attributes and elements" do
|
126
|
-
# Why stringify and reparse? So that
|
130
|
+
# Why stringify and reparse? So that implementation could use another XML generator
|
127
131
|
doc = Nokogiri::XML(@datanode.to_xml.to_s)
|
128
132
|
|
129
133
|
comment = doc.xpath('/node[@type="Corporation"]/comment()').first
|
@@ -140,9 +144,10 @@ describe Mochigome::DataNode do
|
|
140
144
|
assert_equal "bob", emp_nodes.last['name']
|
141
145
|
assert_equal "Cyborg", emp_nodes.first['internal_type']
|
142
146
|
assert_equal "4", emp_nodes.last.xpath('datum[@name="Y"]').first.content
|
147
|
+
assert_equal "android", emp_nodes.first.xpath('node').first['name']
|
143
148
|
assert_equal "lassie", emp_nodes.last.xpath('node').first['name']
|
144
149
|
|
145
|
-
# Keys that start with an underscore are to be turned into
|
150
|
+
# Keys that start with an underscore are to be turned into special elems
|
146
151
|
assert_empty emp_nodes.first.xpath('datum').select{|datum|
|
147
152
|
datum['name'] =~ /foo/i
|
148
153
|
}
|
@@ -151,16 +156,16 @@ describe Mochigome::DataNode do
|
|
151
156
|
|
152
157
|
it "can convert to a flattened Ruport table" do
|
153
158
|
table = @datanode.to_flat_ruport_table
|
154
|
-
assert_equal @
|
155
|
-
assert_equal
|
156
|
-
assert_equal
|
159
|
+
assert_equal @expected_titles, table.column_names
|
160
|
+
assert_equal @expected_row_1, table.data[0].to_a
|
161
|
+
assert_equal @expected_row_2, table.data[1].to_a
|
157
162
|
end
|
158
163
|
|
159
164
|
it "can convert to a flat array of arrays" do
|
160
165
|
a = @datanode.to_flat_arrays
|
161
|
-
assert_equal @
|
162
|
-
assert_equal
|
163
|
-
assert_equal
|
166
|
+
assert_equal @expected_titles, a[0]
|
167
|
+
assert_equal @expected_row_1, a[1]
|
168
|
+
assert_equal @expected_row_2, a[2]
|
164
169
|
end
|
165
170
|
end
|
166
171
|
end
|
@@ -142,17 +142,34 @@ describe "an ActiveRecord model" do
|
|
142
142
|
@model_class.mochigome_focus_settings.get_ordering
|
143
143
|
end
|
144
144
|
|
145
|
-
it "can specify fields" do
|
145
|
+
it "can specify fields without name that act as fieldset named 'default'" do
|
146
146
|
@model_class.class_eval do
|
147
147
|
acts_as_mochigome_focus do |f|
|
148
148
|
f.fields ["a", "b"]
|
149
149
|
end
|
150
150
|
end
|
151
|
-
i = @model_class.new(:a => "abc", :b => "xyz")
|
151
|
+
i = @model_class.new(:a => "abc", :b => "xyz", :c => "123")
|
152
152
|
expected = ActiveSupport::OrderedHash.new
|
153
153
|
expected["a"] = "abc"
|
154
154
|
expected["b"] = "xyz"
|
155
155
|
assert_equal expected, i.mochigome_focus.field_data
|
156
|
+
assert_equal expected, i.mochigome_focus.field_data([:default])
|
157
|
+
end
|
158
|
+
|
159
|
+
it "can specify and request fieldsets with custom names" do
|
160
|
+
@model_class.class_eval do
|
161
|
+
acts_as_mochigome_focus do |f|
|
162
|
+
f.fieldset :foo, ["c", "d"]
|
163
|
+
f.fieldset :bar, ["e"]
|
164
|
+
f.fieldset :zap, ["f"]
|
165
|
+
end
|
166
|
+
end
|
167
|
+
i = @model_class.new(:c => "cat", :d => "dog", :e => "elephant", :f => "ferret")
|
168
|
+
expected = ActiveSupport::OrderedHash.new
|
169
|
+
expected["f"] = "ferret"
|
170
|
+
expected["c"] = "cat"
|
171
|
+
expected["d"] = "dog"
|
172
|
+
assert_equal expected, i.mochigome_focus.field_data([:zap, :foo])
|
156
173
|
end
|
157
174
|
|
158
175
|
it "has no report focus data if no fields are specified" do
|
data/test/unit/query_test.rb
CHANGED
@@ -148,6 +148,14 @@ describe Mochigome::Query do
|
|
148
148
|
assert_no_children data_node/0/0
|
149
149
|
end
|
150
150
|
|
151
|
+
it "can build a two-layer tree from a simple list-equivalent layer tree" do
|
152
|
+
q = Mochigome::Query.new({:report => {Category => Product}})
|
153
|
+
data_node = q.run(@category1)
|
154
|
+
assert_equal_children [@category1], data_node
|
155
|
+
assert_equal_children [@product_a, @product_b], data_node/0
|
156
|
+
assert_no_children data_node/0/0
|
157
|
+
end
|
158
|
+
|
151
159
|
it "cannot build a Query through disconnected layers" do
|
152
160
|
assert_raises Mochigome::QueryError do
|
153
161
|
q = Mochigome::Query.new([Category, BoringDatum])
|
@@ -170,6 +178,16 @@ describe Mochigome::Query do
|
|
170
178
|
end
|
171
179
|
end
|
172
180
|
|
181
|
+
it "can build a three-layer tree from a simple list-equivalent layer tree" do
|
182
|
+
q = Mochigome::Query.new({:report => {Owner => {Store => Product}}})
|
183
|
+
data_node = q.run([@john, @jane])
|
184
|
+
assert_equal_children [@john, @jane], data_node
|
185
|
+
assert_equal_children [@store_x], data_node/0
|
186
|
+
assert_equal_children [@store_y, @store_z], data_node/1
|
187
|
+
assert_equal_children [@product_a, @product_c], data_node/0/0
|
188
|
+
assert_equal_children [@product_c, @product_d], data_node/1/1
|
189
|
+
end
|
190
|
+
|
173
191
|
it "can subgroup layers by attributes" do
|
174
192
|
q = Mochigome::Query.new(
|
175
193
|
[Mochigome::SubgroupModel.new(Owner, :last_name), Owner, Store, Product]
|
@@ -517,9 +535,6 @@ describe Mochigome::Query do
|
|
517
535
|
data_node = q.run([@store_x, @store_y, @store_z])
|
518
536
|
c = data_node.comment
|
519
537
|
assert_match c, /^Mochigome Version: #{Mochigome::VERSION}\n/
|
520
|
-
assert_match c, /\nReport Generated: \w{3} \w{3} \d+ .+\n/
|
521
|
-
assert_match c, /\nLayers: Owner => Store => Product\n/
|
522
|
-
assert_match c, /\nJoin: \w+ -> \w+\n/
|
523
538
|
end
|
524
539
|
|
525
540
|
it "names the root node 'report' by default" do
|
@@ -570,18 +585,6 @@ describe Mochigome::Query do
|
|
570
585
|
refute dn.children.any?{|c| c.name == "Product E"}
|
571
586
|
end
|
572
587
|
|
573
|
-
it "access filter joins will not duplicate joins already in the query" do
|
574
|
-
af = proc do |cls|
|
575
|
-
return {} unless cls == Product
|
576
|
-
return {
|
577
|
-
:join_paths => [[Product, StoreProduct, Store]],
|
578
|
-
:condition => Store.arel_table[:name].matches("Jo%")
|
579
|
-
}
|
580
|
-
end
|
581
|
-
q = Mochigome::Query.new([Product, Store], :access_filter => af)
|
582
|
-
assert_equal 1, q.instance_variable_get(:@ids_rel).to_sql.scan(/join .stores./i).size
|
583
|
-
end
|
584
|
-
|
585
588
|
it "automatically joins if run given a condition on a new table" do
|
586
589
|
q = Mochigome::Query.new([Product, Store])
|
587
590
|
dn = q.run(Category.arel_table[:name].eq(@category1.name))
|
@@ -633,4 +636,29 @@ describe Mochigome::Query do
|
|
633
636
|
assert_equal "Widget 6", (dn/5).name
|
634
637
|
assert_equal 3, (dn/5).children.size
|
635
638
|
end
|
639
|
+
|
640
|
+
it "can create cross-group reports when given a layer tree" do
|
641
|
+
q = Mochigome::Query.new({:report => {Owner => [Store, Category]}})
|
642
|
+
dn = q.run
|
643
|
+
assert_equal "John Smith", (dn/0).name
|
644
|
+
assert_equal 3, (dn/0).children.size
|
645
|
+
assert_equal "John's Store", (dn/0/0).name
|
646
|
+
assert_equal "Store", (dn/0/0)[:internal_type]
|
647
|
+
assert_equal "Category 1", (dn/0/1).name
|
648
|
+
assert_equal "Category", (dn/0/1)[:internal_type]
|
649
|
+
end
|
650
|
+
|
651
|
+
it "can run aggregations on cross-group reports" do
|
652
|
+
q = Mochigome::Query.new(
|
653
|
+
{:report => {Owner => [Store, Category]}},
|
654
|
+
:aggregate_sources => [Sale]
|
655
|
+
)
|
656
|
+
dn = q.run
|
657
|
+
assert_equal "John Smith", (dn/0).name
|
658
|
+
assert_equal 8, (dn/0)['Sales count']
|
659
|
+
assert_equal "John's Store", (dn/0/0).name
|
660
|
+
assert_equal 8, (dn/0/0)['Sales count']
|
661
|
+
assert_equal "Category 1", (dn/0/1).name
|
662
|
+
assert_equal 5, (dn/0/1)['Sales count']
|
663
|
+
end
|
636
664
|
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:
|
4
|
+
hash: 23
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
|
-
-
|
9
|
-
-
|
10
|
-
version: 0.
|
8
|
+
- 2
|
9
|
+
- 0
|
10
|
+
version: 0.2.0
|
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-09-24 00:00:00 Z
|
19
19
|
dependencies:
|
20
20
|
- !ruby/object:Gem::Dependency
|
21
21
|
version_requirements: &id001 !ruby/object:Gem::Requirement
|