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 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 and agg 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?
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
- else
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.size)
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.size)
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.max_by(&:size) || []
104
+ colnames += choices.flatten(1).uniq || []
107
105
  colnames
108
106
  end
109
107
 
110
- # TODO: Should handle trickier situations involving datanodes not having various columns
111
- def append_rows_to(table, pad, stack = [])
112
- row_vals = keys.reject{|k| k.to_s.start_with?("_")}.map{|k| self[k]}
113
- stack.push([@name] + row_vals)
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, pad, stack)}
125
+ @children.each {|child| child.send(:append_rows_to, table, colnames, row)}
116
126
  else
117
- row = stack.flatten(1)
118
- table << (row + Array.new(pad - row.size, nil))
127
+ table << row.dup
119
128
  end
120
- stack.pop
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
@@ -1,3 +1,3 @@
1
1
  module Mochigome
2
- VERSION = "0.1.22"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -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
- @fields = settings.options[:fields] || []
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
- self.fields.each do |field|
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[:fields] = []
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.fields with an Enumerable"
219
+ raise ModelSetupError.new "Call f.fieldset with an Enumerable"
215
220
  end
216
221
 
217
- @options[:fields] += fields.map do |f|
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(layer_types, options = {})
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
- @access_filter = options.delete(:access_filter) || lambda {|cls| {}}
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
- @ids_rel = Relation.new(@layer_types)
16
- @ids_rel.apply_access_filter_func(@access_filter)
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
- focus_model, data_model, agg_setting_name = nil, nil, nil
21
- if a.is_a?(Array) then
22
- focus_model = a.select{|e| e.is_a?(Class)}.first
23
- data_model = a.select{|e| e.is_a?(Class)}.last
24
- agg_setting_name = a.select{|e| e.is_a?(Symbol)}.first || :default
25
- else
26
- focus_model = data_model = a
27
- agg_setting_name = :default
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
- agg_rel = Relation.new(@layer_types)
31
- agg_rel.join_on_path_thru([focus_model, data_model])
32
- agg_rel.apply_access_filter_func(@access_filter)
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
- key_cols = @ids_rel.spine_layers.map{|m| m.arel_primary_key}
59
+ private
35
60
 
36
- agg_fields = data_model.
37
- mochigome_aggregation_settings(agg_setting_name).
38
- options[:fields].reject{|a| a[:in_ruby]}
39
- agg_fields.each_with_index do |a, i|
40
- d_expr = a[:value_proc].call(data_model.arel_table)
41
- d_expr = d_expr.expr if d_expr.respond_to?(:expr)
42
- agg_rel.select_expr(d_expr.as("d%03u" % i))
43
- end
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
- agg_rel_key = {
46
- :focus_model => focus_model,
47
- :data_model => data_model,
48
- :agg_setting_name => agg_setting_name
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
- @aggregate_rels[agg_rel_key] = (0..key_cols.length).map{|n|
52
- lambda {|cond|
53
- data_rel = agg_rel.clone
54
- data_rel.apply_condition(cond)
55
- data_cols = key_cols.take(n) + [data_model.arel_primary_key]
56
- inner_rel = data_rel.to_arel
57
- data_cols.each_with_index do |col, i|
58
- inner_rel.project(col.as("g%03u" % i)).group(col)
59
- end
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
- # FIXME: This subtable won't be necessary for all aggregation funcs.
62
- # When we can avoid it, we should, for performance.
63
- rel = Arel::SelectManager.new(
64
- Arel::Table.engine,
65
- Arel.sql("(#{inner_rel.to_sql}) as mochigome_data")
66
- )
67
- d_tbl = Arel::Table.new("mochigome_data")
68
- agg_fields.each_with_index do |a, i|
69
- name = "d%03u" % i
70
- rel.project(a[:agg_proc].call(d_tbl[name]).as(name))
71
- end
72
- key_cols.take(n).each_with_index do |col, i|
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 run(cond = nil)
83
- root = create_node_tree(cond)
84
- load_aggregate_data(root, cond)
85
- return root
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
- private
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 create_node_tree(cond)
100
- root = DataNode.new(:report, @name)
101
- root.comment = <<-eos
102
- Mochigome Version: #{Mochigome::VERSION}
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
- if ids_sql
116
- ids_table = connection.select_all(ids_sql).map do |row|
117
- row.each do |k,v|
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 all fields in the right order
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
@@ -27,7 +27,8 @@ module Mochigome
27
27
  end
28
28
 
29
29
  def name
30
- "#{@model}$#{@attr}" # Warning: This has to be a valid SQL field name
30
+ # This works as both a valid SQL field name and a valid XML tag name
31
+ "#{@model}__#{@attr}"
31
32
  end
32
33
 
33
34
  def human_name
@@ -43,6 +43,10 @@ class CreateTables < ActiveRecord::Migration
43
43
  t.timestamps
44
44
  t.string :a
45
45
  t.string :b
46
+ t.string :c
47
+ t.string :d
48
+ t.string :e
49
+ t.string :f
46
50
  t.integer :product_id
47
51
  t.integer :x
48
52
  end
@@ -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
- @titles = [
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 we could use another XML generator
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 so-named elems
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 @titles, table.column_names
155
- assert_equal ['acme', 400, 1, 2, 3, 'alice', 500, 9, 8, 7, "Cyborg", nil], table.data[0].to_a
156
- assert_equal ['acme', 400, 1, 2, 3, 'bob', 600, 5, 4, 8734, "Human", "lassie"], table.data[1].to_a
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 @titles, a[0]
162
- assert_equal ['acme', 400, 1, 2, 3, 'alice', 500, 9, 8, 7, "Cyborg", nil], a[1]
163
- assert_equal ['acme', 400, 1, 2, 3, 'bob', 600, 5, 4, 8734, "Human", "lassie"], a[2]
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
@@ -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: 55
4
+ hash: 23
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
- - 1
9
- - 22
10
- version: 0.1.22
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-07-17 00:00:00 Z
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