mochigome 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. data/.autotest +14 -0
  2. data/Gemfile +16 -0
  3. data/Gemfile.lock +71 -0
  4. data/LICENSE +674 -0
  5. data/README.rdoc +11 -0
  6. data/Rakefile +74 -0
  7. data/TODO +6 -0
  8. data/lib/data_node.rb +106 -0
  9. data/lib/exceptions.rb +6 -0
  10. data/lib/mochigome.rb +7 -0
  11. data/lib/mochigome_ver.rb +3 -0
  12. data/lib/model_extensions.rb +211 -0
  13. data/lib/query.rb +199 -0
  14. data/test/app_root/app/controllers/application_controller.rb +2 -0
  15. data/test/app_root/app/controllers/owners_controller.rb +2 -0
  16. data/test/app_root/app/models/boring_datum.rb +3 -0
  17. data/test/app_root/app/models/category.rb +7 -0
  18. data/test/app_root/app/models/owner.rb +17 -0
  19. data/test/app_root/app/models/product.rb +21 -0
  20. data/test/app_root/app/models/sale.rb +9 -0
  21. data/test/app_root/app/models/store.rb +13 -0
  22. data/test/app_root/app/models/store_product.rb +11 -0
  23. data/test/app_root/config/boot.rb +130 -0
  24. data/test/app_root/config/database-pg.yml +8 -0
  25. data/test/app_root/config/database.yml +6 -0
  26. data/test/app_root/config/environment.rb +14 -0
  27. data/test/app_root/config/environments/test.rb +20 -0
  28. data/test/app_root/config/offroad.yml +6 -0
  29. data/test/app_root/config/preinitializer.rb +20 -0
  30. data/test/app_root/config/routes.rb +4 -0
  31. data/test/app_root/db/migrate/20110817163830_create_tables.rb +66 -0
  32. data/test/app_root/vendor/plugins/mochigome/init.rb +2 -0
  33. data/test/factories.rb +39 -0
  34. data/test/test.watchr +6 -0
  35. data/test/test_helper.rb +66 -0
  36. data/test/unit/data_node_test.rb +144 -0
  37. data/test/unit/model_extensions_test.rb +367 -0
  38. data/test/unit/query_test.rb +202 -0
  39. metadata +143 -0
data/README.rdoc ADDED
@@ -0,0 +1,11 @@
1
+ *NOTE*: This gem is a work in progress.
2
+
3
+ *NOTE*: Currently the gem is only compatible with Rails 2.
4
+
5
+ == Overview
6
+
7
+ Mochigome builds sophisticated report datasets from your ActiveRecord models.
8
+
9
+ == Why "Mochigome"?
10
+
11
+ It means "sticky rice", a kind of rice where the grains tend to clump together. The Mochigome gem makes a bunch of ActiveRecord models clump together to form a single comprehensive report.
data/Rakefile ADDED
@@ -0,0 +1,74 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/testtask'
4
+ require 'rdoc/task'
5
+ require 'rubygems/package_task'
6
+ require 'rcov/rcovtask'
7
+ require 'ruby-prof/task'
8
+
9
+ require 'bundler/setup'
10
+ Bundler.require(:default)
11
+
12
+ def common_test_settings(t)
13
+ t.libs << 'lib'
14
+ t.libs << 'test'
15
+ t.pattern = 'test/**/*_test.rb'
16
+ t.verbose = true
17
+ end
18
+
19
+ desc 'Default: run unit and functional tests.'
20
+ task :default => :test
21
+
22
+ desc 'Test Mochigome'
23
+ Rake::TestTask.new(:test) do |t|
24
+ common_test_settings(t)
25
+ end
26
+
27
+ desc 'Run tests automatically as files change'
28
+ task :watchr do |t|
29
+ exec 'watchr test/test.watchr'
30
+ end
31
+
32
+ desc 'Generate documentation for Mochigome.'
33
+ RDoc::Task.new(:rdoc) do |rdoc|
34
+ rdoc.rdoc_dir = 'rdoc'
35
+ rdoc.title = 'Mochigome'
36
+ rdoc.options << '--line-numbers' << '--inline-source'
37
+ rdoc.rdoc_files.include('README')
38
+ rdoc.rdoc_files.include('lib/**/*.rb')
39
+ end
40
+
41
+ Rcov::RcovTask.new(:rcov) do |t|
42
+ common_test_settings(t)
43
+ t.pattern = 'test/unit/*_test.rb' # Don't care about coverage added by functional tests
44
+ t.rcov_opts << '-o coverage -x "/ruby/,/gems/,/test/,/migrate/"'
45
+ end
46
+
47
+ RubyProf::ProfileTask.new(:profile) do |t|
48
+ common_test_settings(t)
49
+ t.output_dir = "#{File.dirname(__FILE__)}/profile"
50
+ t.printer = :call_tree
51
+ t.min_percent = 10
52
+ end
53
+
54
+ require 'lib/mochigome_ver'
55
+ gemspec = Gem::Specification.new do |s|
56
+ s.name = "mochigome"
57
+ s.version = Mochigome::VERSION
58
+ s.authors = ["David Mike Simon"]
59
+ s.email = "david.mike.simon@gmail.com"
60
+ s.homepage = "http://github.com/DavidMikeSimon/mochigome"
61
+ s.summary = "User-customizable report generator"
62
+ s.description = "Mochigome builds sophisticated report datasets from your ActiveRecord models"
63
+ s.files = `git ls-files .`.split("\n") - [".gitignore"]
64
+ s.platform = Gem::Platform::RUBY
65
+ s.require_path = 'lib'
66
+ s.rubyforge_project = '[none]'
67
+
68
+ s.add_dependency('ruport')
69
+ s.add_dependency('nokogiri')
70
+ s.add_dependency('rgl')
71
+ end
72
+
73
+ Gem::PackageTask.new(gemspec) do |pkg|
74
+ end
data/TODO ADDED
@@ -0,0 +1,6 @@
1
+ - Still need some way to filter queries by weird stuff (i.e. EffectivelyDatedAssociation, or to filter AttendanceRecords by the dates on the Sessions that they belong_to)
2
+ - Maybe can do this by adjusting the EffectivelyDated module so that when included it calls a hook on Mochigome that describes the possible filter on the effectively dated object (i.e. SectionRoster) as an SQL snippet referencing "SectionRoster" typed entries of the effective dates table.
3
+ - Do as few queries as possible to accomplish stuff; this also encourages app to rely on SQL's 'IN' syntax and magical grouping/joining abilities to make aggregation easier
4
+ - 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.)
5
+ - 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)
6
+ - Alternately, always ignore conditional associations unless they're specifically provided to Mochigome by the model
data/lib/data_node.rb ADDED
@@ -0,0 +1,106 @@
1
+ require 'active_support'
2
+
3
+ module Mochigome
4
+ class DataNode < ActiveSupport::OrderedHash
5
+ attr_accessor :type_name
6
+ attr_accessor :name
7
+ attr_accessor :comment
8
+ attr_reader :children
9
+
10
+ def initialize(type_name, name, content = [])
11
+ # Convert content keys to symbols
12
+ super()
13
+ self.merge!(content)
14
+ @type_name = type_name.to_s
15
+ @name = name.to_s
16
+ @comment = nil
17
+ @children = []
18
+ end
19
+
20
+ def merge!(a)
21
+ if a.is_a?(Array)
22
+ a.each do |h|
23
+ self[h.keys.first.to_sym] = h.values.first
24
+ end
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ def <<(item)
31
+ if item.is_a?(Array)
32
+ item.map {|i| self << i}
33
+ else
34
+ raise DataNodeError.new("New child #{item} is not a DataNode") unless item.is_a?(DataNode)
35
+ @children << item
36
+ @children.last
37
+ end
38
+ end
39
+
40
+ # TODO: Only define xml-related methods if nokogiri loaded
41
+ def to_xml
42
+ doc = Nokogiri::XML::Document.new
43
+ append_xml_to(doc)
44
+ doc
45
+ end
46
+
47
+ # TODO: Only define ruport-related methods if ruport is loaded
48
+ def to_flat_ruport_table
49
+ col_names = flat_column_names
50
+ table = Ruport::Data::Table.new(:column_names => col_names)
51
+ append_rows_to(table, col_names.size)
52
+ table
53
+ end
54
+
55
+ def to_flat_arrays
56
+ table = []
57
+ col_names = flat_column_names
58
+ table << col_names
59
+ append_rows_to(table, col_names.size)
60
+ table
61
+ end
62
+
63
+ private
64
+
65
+ def append_xml_to(x)
66
+ doc = x.document
67
+ node = Nokogiri::XML::Node.new("node", doc)
68
+ node["type"] = @type_name.titleize
69
+ node["name"] = @name
70
+ [:id, :internal_type].each do |attr|
71
+ node[attr.to_s] = delete(attr).to_s if has_key?(attr)
72
+ end
73
+ node.add_child(Nokogiri::XML::Comment.new(doc, @comment)) if @comment
74
+ each do |key, value|
75
+ sub_node = Nokogiri::XML::Node.new("datum", doc)
76
+ sub_node["name"] = key.to_s.titleize
77
+ sub_node.content = value
78
+ node.add_child(sub_node)
79
+ end
80
+ @children.each do |child|
81
+ child.send(:append_xml_to, node)
82
+ end
83
+ x.add_child(node)
84
+ end
85
+
86
+ # TODO: Should handle trickier situations involving datanodes not having various columns
87
+ def flat_column_names
88
+ colnames = (["name"] + keys).map {|key| "#{@type_name}::#{key}"}
89
+ choices = @children.map(&:flat_column_names)
90
+ colnames += choices.max_by(&:size) || []
91
+ colnames
92
+ end
93
+
94
+ # TODO: Should handle trickier situations involving datanodes not having various columns
95
+ def append_rows_to(table, pad, stack = [])
96
+ stack.push([@name] + values)
97
+ if @children.size > 0
98
+ @children.each {|child| child.send(:append_rows_to, table, pad, stack)}
99
+ else
100
+ row = stack.flatten(1)
101
+ table << (row + Array.new(pad - row.size, nil))
102
+ end
103
+ stack.pop
104
+ end
105
+ end
106
+ end
data/lib/exceptions.rb ADDED
@@ -0,0 +1,6 @@
1
+ module Mochigome
2
+ class ModelSetupError < StandardError; end
3
+ class InvalidLayerError < StandardError; end
4
+ class QueryError < StandardError; end
5
+ class DataNodeError < StandardError; end
6
+ end
data/lib/mochigome.rb ADDED
@@ -0,0 +1,7 @@
1
+ require 'mochigome_ver'
2
+ require 'exceptions'
3
+ require 'data_node'
4
+ require 'query'
5
+ require 'model_extensions'
6
+
7
+ ActiveRecord::Base.send(:include, Mochigome::ModelExtensions)
@@ -0,0 +1,3 @@
1
+ module Mochigome
2
+ VERSION = "0.0.2"
3
+ end
@@ -0,0 +1,211 @@
1
+ module Mochigome
2
+ @reportFocusModels = []
3
+ def self.reportFocusModels
4
+ @reportFocusModels
5
+ end
6
+
7
+ module ModelExtensions
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+
11
+ base.write_inheritable_attribute :mochigome_focus_settings, nil
12
+ base.class_inheritable_reader :mochigome_focus_settings
13
+
14
+ base.write_inheritable_attribute :mochigome_aggregations, []
15
+ base.class_inheritable_reader :mochigome_aggregations
16
+ end
17
+
18
+ module ClassMethods
19
+ def acts_as_mochigome_focus
20
+ if self.try(:mochigome_focus_settings).try(:orig_class) == self
21
+ raise Mochigome::ModelSetupError.new("Already acts_as_mochigome_focus for #{self.name}")
22
+ end
23
+ settings = ReportFocusSettings.new(self)
24
+ yield settings if block_given?
25
+ write_inheritable_attribute :mochigome_focus_settings, settings
26
+ send(:include, InstanceMethods)
27
+ Mochigome::reportFocusModels << self
28
+ end
29
+
30
+ def acts_as_mochigome_focus?
31
+ !!mochigome_focus_settings
32
+ end
33
+
34
+ AGGREGATION_FUNCS = {
35
+ 'count' => 'count()',
36
+ 'distinct' => 'count(distinct %s)',
37
+ 'average' => 'avg(%s)',
38
+ 'avg' => 'avg(%s)',
39
+ 'minimum' => 'min(%s)',
40
+ 'min' => 'min(%s)',
41
+ 'maximum' => 'max(%s)',
42
+ 'max' => 'max(%s)',
43
+ 'sum' => 'sum(%s)'
44
+ }
45
+
46
+ def has_mochigome_aggregations(aggregations)
47
+ unless aggregations.respond_to?(:each)
48
+ raise ModelSetupError.new "Call has_mochigome_aggregations with an Enumerable"
49
+ end
50
+
51
+ def aggregation_opts(obj)
52
+ if obj.is_a?(String) or obj.is_a?(Symbol)
53
+ obj = obj.to_s
54
+ AGGREGATION_FUNCS.each do |func, expr_pat|
55
+ if expr_pat.include?('%s')
56
+ if obj =~ /^#{func}[-_ ](.+)/i
57
+ return {:expr => (expr_pat % $1)}
58
+ end
59
+ else
60
+ if obj.downcase == func
61
+ return {:expr => expr_pat}
62
+ end
63
+ end
64
+ end
65
+ return {:expr => obj} # Assume the string is just a plain SQL expression
66
+ elsif obj.is_a?(Array) and obj.size == 2
67
+ return {:conditions => obj[1]}.merge(aggregation_opts(obj[0]))
68
+ end
69
+ raise ModelSetupError.new "Invalid aggregation expr: #{obj.inspect}"
70
+ end
71
+
72
+ additions = aggregations.map do |f|
73
+ case f
74
+ when String, Symbol then
75
+ {:name => "%s %s" % [name.pluralize, f.to_s.sub("_", " ")]}.merge(aggregation_opts(f))
76
+ when Hash then
77
+ {:name => f.keys.first.to_s}.merge(aggregation_opts(f.values.first))
78
+ else raise ModelSetupError.new "Invalid aggregation: #{f.inspect}"
79
+ end
80
+ end
81
+ mochigome_aggregations.concat(additions)
82
+ end
83
+ end
84
+
85
+ module InstanceMethods
86
+ def mochigome_focus
87
+ ReportFocus.new(self, self.class.mochigome_focus_settings)
88
+ end
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ class ReportFocus
95
+ attr_reader :type_name
96
+ attr_reader :fields
97
+
98
+ def initialize(owner, settings)
99
+ @owner = owner
100
+ @name_proc = settings.options[:name] || lambda{|obj| obj.name}
101
+ @type_name = settings.options[:type_name] || owner.class.name
102
+ @fields = settings.options[:fields] || []
103
+ end
104
+
105
+ def name
106
+ @name_proc.call(@owner)
107
+ end
108
+
109
+ def data(options = {})
110
+ field_data.merge(aggregate_data(:all, options))
111
+ end
112
+
113
+ def field_data
114
+ h = ActiveSupport::OrderedHash.new
115
+ self.fields.each do |field|
116
+ h[field[:name]] = field[:value_func].call(@owner)
117
+ end
118
+ h
119
+ end
120
+
121
+ def aggregate_data(assoc_name, options = {})
122
+ h = ActiveSupport::OrderedHash.new
123
+ assoc_name = assoc_name.to_sym
124
+ if assoc_name == :all
125
+ @owner.class.reflections.each do |name, assoc|
126
+ h.merge! aggregate_data(name, options)
127
+ end
128
+ else
129
+ assoc = @owner.class.reflections[assoc_name]
130
+ assoc_object = @owner
131
+ # TODO: Are there other ways context could matter besides :through assocs?
132
+ # TODO: What if a through reflection goes through _another_ through reflection?
133
+ if options.has_key?(:context) && assoc.through_reflection
134
+ # FIXME: This seems like it's repeating Query work
135
+ join_objs = assoc_object.send(assoc.through_reflection.name)
136
+ options[:context].each do |obj|
137
+ next unless join_objs.include?(obj)
138
+ assoc = assoc.source_reflection
139
+ assoc_object = obj
140
+ break
141
+ end
142
+ end
143
+ assoc.klass.mochigome_aggregations.each do |agg|
144
+ # TODO: There *must* be a better way to do this query
145
+ # It's ugly, involves an ActiveRecord creation, and causes lots of DB hits
146
+ sel_expr = "(#{agg[:expr]}) AS x"
147
+ cond_expr = agg[:conditions] ? agg[:conditions] : "1=1"
148
+ if assoc.belongs_to? # FIXME: or has_one
149
+ obj = assoc_object.send(assoc.name)
150
+ row = obj.class.find(obj.id, :select => sel_expr, :conditions => cond_expr)
151
+ else
152
+ row = assoc_object.send(assoc.name).first(:select => sel_expr, :conditions => cond_expr)
153
+ end
154
+ h[agg[:name]] = row.x
155
+ end
156
+ end
157
+ h
158
+ end
159
+ end
160
+
161
+ class ReportFocusSettings
162
+ attr_reader :options
163
+ attr_reader :orig_class
164
+
165
+ def initialize(orig_class)
166
+ @orig_class = orig_class
167
+ @options = {}
168
+ @options[:fields] = []
169
+ end
170
+
171
+ def type_name(n)
172
+ unless n.is_a?(String)
173
+ raise ModelSetupError.new "Call f.type_name with a String"
174
+ end
175
+ @options[:type_name] = n
176
+ end
177
+
178
+ def name(n)
179
+ @options[:name] = n.to_proc
180
+ end
181
+
182
+ def fields(fields)
183
+ def complain_if_reserved(s)
184
+ ['name', 'id', 'type', 'internal_type'].each do |reserved|
185
+ if s.gsub(/ +/, "_").underscore == reserved
186
+ raise ModelSetupError.new "Field name \"#{s}\" conflicts with reserved term \"#{reserved}\""
187
+ end
188
+ end
189
+ s
190
+ end
191
+
192
+ unless fields.respond_to?(:each)
193
+ raise ModelSetupError.new "Call f.fields with an Enumerable"
194
+ end
195
+
196
+ @options[:fields] += fields.map do |f|
197
+ case f
198
+ when String, Symbol then {
199
+ :name => complain_if_reserved(f.to_s.strip),
200
+ :value_func => lambda{|obj| obj.send(f.to_sym)}
201
+ }
202
+ when Hash then {
203
+ :name => complain_if_reserved(f.keys.first.to_s.strip),
204
+ :value_func => f.values.first.to_proc
205
+ }
206
+ else raise ModelSetupError.new "Invalid field: #{f.inspect}"
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
data/lib/query.rb ADDED
@@ -0,0 +1,199 @@
1
+ require 'rgl/adjacency'
2
+
3
+ module Mochigome
4
+ class Query
5
+ def initialize(layer_types, name = "report")
6
+ # TODO: Validate layer types: not empty, AR, act_as_mochigome_focus, graph correctly, no repeats
7
+ @layer_types = layer_types
8
+ @name = name
9
+ end
10
+
11
+ def run(objs)
12
+ objs = [objs] unless objs.is_a?(Enumerable)
13
+ return DataNode.new(:report, @name) if objs.size == 0 # Empty DataNode for empty input
14
+
15
+ unless objs.all?{|obj| obj.class == objs.first.class}
16
+ raise QueryError.new("Query target objects must all be the same type")
17
+ end
18
+
19
+ unless @layer_types.any?{|layer| objs.first.is_a?(layer)}
20
+ raise QueryError.new("Query target object type must be in the query layer list")
21
+ end
22
+
23
+ # Used to provide debugging information in the root DataNode comment
24
+ assoc_path = ["== #{objs.first.class.name} =="]
25
+
26
+ # Start at the layer for objs, and descend downwards through layers after that
27
+ #TODO: It would be really fantastic if I could just use AR eager loading for this
28
+ downwards_layers = @layer_types.drop_while{|cls| !objs.first.is_a?(cls)}
29
+ root = DataNode.new(:report, @name)
30
+ root << objs.map{|obj| DataNode.new(
31
+ obj.mochigome_focus.type_name,
32
+ obj.mochigome_focus.name,
33
+ [{:obj => obj}]
34
+ )}
35
+ cur_layer = root.children
36
+ downwards_layers.drop(1).each do |cls|
37
+ new_layer = []
38
+ assoc = Query.edge_assoc(cur_layer.first[:obj].class, cls)
39
+
40
+ assoc_str = "-> #{cls.name} via #{cur_layer.first[:obj].class.name}##{assoc.name}"
41
+ if assoc.through_reflection
42
+ assoc_str << " (thru #{assoc.through_reflection.name})"
43
+ end
44
+ assoc_path.push assoc_str
45
+
46
+ cur_layer.each do |datanode|
47
+ # FIXME: Don't assume that downwards means plural association
48
+ # TODO: Are there other ways context could matter besides :through assocs?
49
+ # i.e. If C belongs_to A and also belongs_to B, and layer_types = [A,B,C]
50
+ # TODO: What if a through reflection goes through _another_ through reflection?
51
+ if assoc.through_reflection
52
+ datanode[:obj].send(assoc.through_reflection.name).each do |through_obj|
53
+ # TODO: Don't assume that through means singular!
54
+ obj = through_obj.send(assoc.source_reflection.name)
55
+ subnode = datanode << DataNode.new(
56
+ obj.mochigome_focus.type_name,
57
+ obj.mochigome_focus.name,
58
+ {:obj => obj, :through_obj => through_obj}
59
+ )
60
+ new_layer << subnode
61
+ end
62
+ else
63
+ #FIXME: Not DRY
64
+ datanode[:obj].send(assoc.name).each do |obj|
65
+ subnode = datanode << DataNode.new(
66
+ obj.mochigome_focus.type_name,
67
+ obj.mochigome_focus.name,
68
+ [{:obj => obj}]
69
+ )
70
+ new_layer << subnode
71
+ end
72
+ end
73
+ end
74
+ cur_layer = new_layer
75
+ end
76
+
77
+ # Take our tree so far and include it in parent trees, going up to the first layer
78
+ upwards_layers = @layer_types.take_while{|cls| !objs.first.is_a?(cls)}.reverse
79
+ upwards_layers.each do |cls|
80
+ assoc = Query.edge_assoc(root.children.first[:obj].class, cls)
81
+
82
+ assoc_str = "<- #{cls.name} via #{root.children.first[:obj].class.name}##{assoc.name}"
83
+ if assoc.through_reflection
84
+ assoc_str << " (thru #{assoc.through_reflection.name})"
85
+ end
86
+ assoc_path.unshift assoc_str
87
+
88
+ parent_children_map = ActiveSupport::OrderedHash.new
89
+ root.children.each do |child|
90
+ if assoc.through_reflection
91
+ through_objs = child[:obj].send(assoc.through_reflection.name)
92
+ through_objs = [through_objs] unless through_objs.is_a?(Enumerable)
93
+ through_objs.each do |through_obj|
94
+ # TODO: Don't assume that through means singular!
95
+ parent = through_obj.send(assoc.source_reflection.name)
96
+ unless parent_children_map.has_key?(parent.id)
97
+ attrs = {:obj => parent, :through_obj => through_obj}
98
+ parent_children_map[parent.id] = DataNode.new(
99
+ parent.mochigome_focus.type_name,
100
+ parent.mochigome_focus.name,
101
+ attrs
102
+ )
103
+ end
104
+ parent_children_map[parent.id] << child.dup
105
+ end
106
+ else
107
+ #FIXME: Not DRY
108
+ parents = child[:obj].send(assoc.name)
109
+ parents = [parents] unless parents.is_a?(Enumerable)
110
+ parents.each do |parent|
111
+ unless parent_children_map.has_key?(parent.id)
112
+ attrs = {:obj => parent}
113
+ parent_children_map[parent.id] = DataNode.new(
114
+ parent.mochigome_focus.name,
115
+ parent.mochigome_focus.type_name,
116
+ attrs
117
+ )
118
+ end
119
+ parent_children_map[parent.id] << child.dup
120
+ end
121
+ end
122
+ end
123
+
124
+ root = DataNode.new(:report, @name)
125
+ root << parent_children_map.values
126
+ end
127
+
128
+ root.comment = <<-eos
129
+ Mochigome Version: #{Mochigome::VERSION}
130
+ Time: #{Time.now}
131
+ Layers: #{@layer_types.map(&:name).join(" => ")}
132
+ AR Association Path:
133
+ #{assoc_path.map{|s| "* #{s}"}.join("\n")}
134
+ eos
135
+ root.comment.gsub!(/\n +/, "\n")
136
+ root.comment.lstrip!
137
+
138
+ focus_data_node_objs(root)
139
+ return root
140
+ end
141
+
142
+ private
143
+
144
+ def focus_data_node_objs(node, obj_stack=[], commenting=true)
145
+ pushed = 0
146
+ if node.has_key?(:obj)
147
+ obj = node.delete(:obj)
148
+ if node.has_key?(:through_obj)
149
+ obj_stack.push(node.delete(:through_obj)); pushed += 1
150
+ end
151
+ obj_stack.push(obj); pushed += 1
152
+ if commenting
153
+ node.comment = <<-eos
154
+ Context:
155
+ #{obj_stack.map{|o| "#{o.class.name}:#{o.id}"}.join("\n")}
156
+ eos
157
+ node.comment.gsub!(/\n +/, "\n")
158
+ node.comment.lstrip!
159
+ end
160
+ node.merge!(obj.mochigome_focus.data(:context => obj_stack))
161
+ node[:internal_type] = obj.class.name
162
+ end
163
+ node.children.each_index do |i|
164
+ focus_data_node_objs(node.children[i], obj_stack, i == 0 && commenting)
165
+ end
166
+ pushed.times{ obj_stack.pop }
167
+ end
168
+
169
+ @@assoc_graph = nil
170
+ @@edge_assocs = {}
171
+
172
+ def self.assoc_graph
173
+ return @@assoc_graph if @assoc_graph
174
+
175
+ # Build a directed graph of the associations between focusable models
176
+ @@assoc_graph = RGL::DirectedAdjacencyGraph.new
177
+ @@assoc_graph.add_vertices(*Mochigome::reportFocusModels)
178
+ Mochigome::reportFocusModels.each do |cls|
179
+ # Add any associations that lead to other reportFocusModels
180
+ cls.reflections.each do |name, assoc|
181
+ if Mochigome::reportFocusModels.include?(assoc.klass)
182
+ @@assoc_graph.add_edge(cls, assoc.klass)
183
+ @@edge_assocs[[cls, assoc.klass]] = assoc
184
+ end
185
+ end
186
+ end
187
+ return @@assoc_graph
188
+ end
189
+
190
+ def self.edge_assoc(u, v)
191
+ assoc_graph # Make sure @@edge_assocs has been populated
192
+ assoc = @@edge_assocs[[u,v]]
193
+ unless assoc
194
+ raise QueryError.new("No association between #{u} and #{v}")
195
+ end
196
+ return assoc
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,2 @@
1
+ class ApplicationController < ActionController::Base
2
+ end
@@ -0,0 +1,2 @@
1
+ class OwnersController < ApplicationController
2
+ end
@@ -0,0 +1,3 @@
1
+ class BoringDatum < ActiveRecord::Base
2
+ # Does not act as report focus
3
+ end
@@ -0,0 +1,7 @@
1
+ class Category < ActiveRecord::Base
2
+ acts_as_mochigome_focus
3
+
4
+ has_many :products, :conditions => {:categorized => true}
5
+
6
+ validates_presence_of :name
7
+ end
@@ -0,0 +1,17 @@
1
+ class Owner < ActiveRecord::Base
2
+ acts_as_mochigome_focus
3
+
4
+ has_many :stores
5
+
6
+ def name(reverse = false)
7
+ if reverse
8
+ "#{last_name}, #{first_name}"
9
+ else
10
+ "#{first_name} #{last_name}"
11
+ end
12
+ end
13
+
14
+ def age
15
+ ((Date.today - birth_date)/365.25).floor
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ class Product < ActiveRecord::Base
2
+ acts_as_mochigome_focus do |f|
3
+ f.fields [:price]
4
+ end
5
+ has_mochigome_aggregations [
6
+ :average_price,
7
+ {"Power level" => "9000+1"},
8
+ {"Expensive products" => [:count, "price > 40"]}
9
+ ]
10
+
11
+ belongs_to :category
12
+ has_many :store_products
13
+ has_many :stores, :through => :store_products
14
+ has_many :sales, :through => :store_products
15
+
16
+ validates_presence_of :name
17
+ validates_presence_of :price
18
+ validates_numericality_of :price, :greater_than_or_equal_to => 0
19
+ # Note: Does NOT validate presence of category!
20
+ # We want to be able to find category-less products with a report.
21
+ end