mochigome 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.autotest +14 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +71 -0
- data/LICENSE +674 -0
- data/README.rdoc +11 -0
- data/Rakefile +74 -0
- data/TODO +6 -0
- data/lib/data_node.rb +106 -0
- data/lib/exceptions.rb +6 -0
- data/lib/mochigome.rb +7 -0
- data/lib/mochigome_ver.rb +3 -0
- data/lib/model_extensions.rb +211 -0
- data/lib/query.rb +199 -0
- data/test/app_root/app/controllers/application_controller.rb +2 -0
- data/test/app_root/app/controllers/owners_controller.rb +2 -0
- data/test/app_root/app/models/boring_datum.rb +3 -0
- data/test/app_root/app/models/category.rb +7 -0
- data/test/app_root/app/models/owner.rb +17 -0
- data/test/app_root/app/models/product.rb +21 -0
- data/test/app_root/app/models/sale.rb +9 -0
- data/test/app_root/app/models/store.rb +13 -0
- data/test/app_root/app/models/store_product.rb +11 -0
- data/test/app_root/config/boot.rb +130 -0
- data/test/app_root/config/database-pg.yml +8 -0
- data/test/app_root/config/database.yml +6 -0
- data/test/app_root/config/environment.rb +14 -0
- data/test/app_root/config/environments/test.rb +20 -0
- data/test/app_root/config/offroad.yml +6 -0
- data/test/app_root/config/preinitializer.rb +20 -0
- data/test/app_root/config/routes.rb +4 -0
- data/test/app_root/db/migrate/20110817163830_create_tables.rb +66 -0
- data/test/app_root/vendor/plugins/mochigome/init.rb +2 -0
- data/test/factories.rb +39 -0
- data/test/test.watchr +6 -0
- data/test/test_helper.rb +66 -0
- data/test/unit/data_node_test.rb +144 -0
- data/test/unit/model_extensions_test.rb +367 -0
- data/test/unit/query_test.rb +202 -0
- 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
data/lib/mochigome.rb
ADDED
@@ -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,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
|