mochigome 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Gemfile CHANGED
@@ -1,6 +1,7 @@
1
1
  source :rubygems
2
2
 
3
3
  gem 'rails', '2.3.12'
4
+ gem 'arel', '~> 2.1'
4
5
  gem 'nokogiri'
5
6
  gem 'ruport'
6
7
  gem 'rgl'
@@ -8,6 +9,7 @@ gem 'rgl'
8
9
  gem 'sqlite3'
9
10
  gem 'mysql'
10
11
  gem 'factory_girl', '2.0.4'
12
+ gem 'rdoc'
11
13
  gem 'rcov'
12
14
  gem 'ruby-prof'
13
15
  gem 'minitest'
data/Gemfile.lock CHANGED
@@ -11,6 +11,7 @@ GEM
11
11
  activeresource (2.3.12)
12
12
  activesupport (= 2.3.12)
13
13
  activesupport (2.3.12)
14
+ arel (2.1.4)
14
15
  autowatchr (0.1.5)
15
16
  watchr
16
17
  color (1.4.1)
@@ -37,6 +38,7 @@ GEM
37
38
  rake (>= 0.8.3)
38
39
  rake (0.9.2)
39
40
  rcov (0.9.10)
41
+ rdoc (3.9.4)
40
42
  rev (0.3.2)
41
43
  iobuffer (>= 0.1.3)
42
44
  rgl (0.4.0)
@@ -57,6 +59,7 @@ PLATFORMS
57
59
  ruby
58
60
 
59
61
  DEPENDENCIES
62
+ arel (~> 2.1)
60
63
  autowatchr
61
64
  factory_girl (= 2.0.4)
62
65
  minitest
@@ -65,6 +68,7 @@ DEPENDENCIES
65
68
  nokogiri
66
69
  rails (= 2.3.12)
67
70
  rcov
71
+ rdoc
68
72
  rev
69
73
  rgl
70
74
  ruby-prof
data/Rakefile CHANGED
@@ -59,12 +59,13 @@ gemspec = Gem::Specification.new do |s|
59
59
  s.email = "david.mike.simon@gmail.com"
60
60
  s.homepage = "http://github.com/DavidMikeSimon/mochigome"
61
61
  s.summary = "User-customizable report generator"
62
- s.description = "Mochigome builds sophisticated report datasets from your ActiveRecord models"
62
+ s.description = "Report generator that uses your ActiveRecord associations"
63
63
  s.files = `git ls-files .`.split("\n") - [".gitignore"]
64
64
  s.platform = Gem::Platform::RUBY
65
65
  s.require_path = 'lib'
66
66
  s.rubyforge_project = '[none]'
67
67
 
68
+ s.add_dependency('arel', '~> 2.1')
68
69
  s.add_dependency('ruport')
69
70
  s.add_dependency('nokogiri')
70
71
  s.add_dependency('rgl')
@@ -0,0 +1,49 @@
1
+ unless ActiveRecord::ConnectionAdapters::ConnectionPool.methods.include?("table_exists?")
2
+ module Mochigome
3
+ class ColumnsHashProxy
4
+ def initialize(pool)
5
+ @pool = pool
6
+ @cache = HashWithIndifferentAccess.new
7
+ end
8
+
9
+ def [](table_name)
10
+ cached(table_name)
11
+ end
12
+
13
+ private
14
+
15
+ def cached(table_name)
16
+ return @cache[table_name] if @cache.has_key?(:table_name)
17
+
18
+ @cache[table_name] = h = HashWithIndifferentAccess.new
19
+ @pool.with_connection do |c|
20
+ c.columns(table_name).each do |col|
21
+ h[col.name] = col
22
+ end
23
+ end
24
+ return h
25
+ end
26
+ end
27
+ end
28
+
29
+ class ActiveRecord::ConnectionAdapters::ConnectionPool
30
+ def table_exists?(name)
31
+ ActiveRecord::Base.connection_pool.with_connection do |c|
32
+ c.table_exists?(name)
33
+ end
34
+ end
35
+
36
+ @@columns_hash_proxy = nil
37
+ def columns_hash
38
+ @@columns_hash_proxy ||= Mochigome::ColumnsHashProxy.new(self)
39
+ end
40
+ end
41
+
42
+ class ActiveRecord::ConnectionAdapters::SQLiteAdapter
43
+ def select_rows(sql, name = nil)
44
+ execute(sql, name).map do |row|
45
+ row.keys.select{|key| key.is_a? Integer}.map{|key| row[key]}
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/data_node.rb CHANGED
@@ -37,6 +37,10 @@ module Mochigome
37
37
  end
38
38
  end
39
39
 
40
+ def /(idx)
41
+ @children[idx]
42
+ end
43
+
40
44
  # TODO: Only define xml-related methods if nokogiri loaded
41
45
  def to_xml
42
46
  doc = Nokogiri::XML::Document.new
data/lib/exceptions.rb CHANGED
@@ -3,4 +3,5 @@ module Mochigome
3
3
  class InvalidLayerError < StandardError; end
4
4
  class QueryError < StandardError; end
5
5
  class DataNodeError < StandardError; end
6
+ class AssociationError < StandardError; end
6
7
  end
data/lib/mochigome.rb CHANGED
@@ -3,5 +3,6 @@ require 'exceptions'
3
3
  require 'data_node'
4
4
  require 'query'
5
5
  require 'model_extensions'
6
+ require 'arel_rails2_hacks'
6
7
 
7
8
  ActiveRecord::Base.send(:include, Mochigome::ModelExtensions)
data/lib/mochigome_ver.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mochigome
2
- VERSION = "0.0.3"
2
+ VERSION = "0.0.4"
3
3
  end
@@ -1,9 +1,4 @@
1
1
  module Mochigome
2
- @reportFocusModels = []
3
- def self.reportFocusModels
4
- @reportFocusModels
5
- end
6
-
7
2
  module ModelExtensions
8
3
  def self.included(base)
9
4
  base.extend(ClassMethods)
@@ -11,75 +6,68 @@ module Mochigome
11
6
  base.write_inheritable_attribute :mochigome_focus_settings, nil
12
7
  base.class_inheritable_reader :mochigome_focus_settings
13
8
 
14
- base.write_inheritable_attribute :mochigome_aggregations, []
15
- base.class_inheritable_reader :mochigome_aggregations
9
+ # TODO: Use an ordered hash for this
10
+ base.write_inheritable_attribute :mochigome_aggregation_settings, nil
11
+ base.class_inheritable_reader :mochigome_aggregation_settings
16
12
  end
17
13
 
18
14
  module ClassMethods
19
15
  def acts_as_mochigome_focus
20
- if self.try(:mochigome_focus_settings).try(:orig_class) == self
16
+ if self.try(:mochigome_focus_settings).try(:model) == self
21
17
  raise Mochigome::ModelSetupError.new("Already acts_as_mochigome_focus for #{self.name}")
22
18
  end
23
19
  settings = ReportFocusSettings.new(self)
24
20
  yield settings if block_given?
25
21
  write_inheritable_attribute :mochigome_focus_settings, settings
26
22
  send(:include, InstanceMethods)
27
- Mochigome::reportFocusModels << self
28
23
  end
29
24
 
30
25
  def acts_as_mochigome_focus?
31
26
  !!mochigome_focus_settings
32
27
  end
33
28
 
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"
29
+ # TODO: Split out aggregation stuff into its own module
30
+
31
+ def has_mochigome_aggregations
32
+ if self.try(:mochigome_aggregation_settings).try(:model) == self
33
+ raise Mochigome::ModelSetupError.new("Already aggregation settings for #{self.name}")
49
34
  end
35
+ settings = AggregationSettings.new(self)
36
+ yield settings if block_given?
37
+ write_inheritable_attribute :mochigome_aggregation_settings, settings
38
+ end
50
39
 
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]))
40
+ def arelified_assoc(name)
41
+ # TODO: Deal with polymorphic assocs.
42
+ model = self
43
+ assoc = reflect_on_association(name)
44
+ raise AssociationError.new("No such assoc #{name}") unless assoc
45
+ table = Arel::Table.new(table_name)
46
+ ftable = Arel::Table.new(assoc.klass.table_name)
47
+ lambda do |r|
48
+ # FIXME: This acts as though arel methods are non-destructive,
49
+ # but they are, right? Except, I can't remove the rel
50
+ # assignment from relation_over_path...
51
+ cond = nil
52
+ if assoc.belongs_to?
53
+ cond = table[assoc.association_foreign_key].eq(
54
+ ftable[assoc.klass.primary_key]
55
+ )
56
+ else
57
+ cond = table[primary_key].eq(ftable[assoc.primary_key_name])
68
58
  end
69
- raise ModelSetupError.new "Invalid aggregation expr: #{obj.inspect}"
70
- end
71
59
 
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}"
60
+ if assoc.options[:as]
61
+ # FIXME Can we assume that this is the polymorphic type field?
62
+ cond = cond.and(ftable["#{assoc.options[:as]}_type"].eq(model.name))
79
63
  end
64
+
65
+ # TODO: Apply association conditions.
66
+
67
+ r.join(ftable, Arel::Nodes::OuterJoin).on(cond)
80
68
  end
81
- mochigome_aggregations.concat(additions)
82
69
  end
70
+
83
71
  end
84
72
 
85
73
  module InstanceMethods
@@ -89,6 +77,20 @@ module Mochigome
89
77
  end
90
78
  end
91
79
 
80
+ # FIXME This probably doesn't belong here. Maybe I should have a module
81
+ # for this kind of stuff and also put the standard aggregation functions
82
+ # in there?
83
+
84
+ def self.null_unless(pred, value_func)
85
+ lambda {|t|
86
+ value = value_func.call(t)
87
+ val_expr = Arel::Nodes::NamedFunction.new('',[value])
88
+ Arel::Nodes::SqlLiteral.new(
89
+ "(CASE WHEN #{pred.call(value).to_sql} THEN #{val_expr.to_sql} ELSE NULL END)"
90
+ )
91
+ }
92
+ end
93
+
92
94
  private
93
95
 
94
96
  class ReportFocus
@@ -98,7 +100,7 @@ module Mochigome
98
100
  def initialize(owner, settings)
99
101
  @owner = owner
100
102
  @name_proc = settings.options[:name] || lambda{|obj| obj.name}
101
- @type_name = settings.options[:type_name] || owner.class.name
103
+ @type_name = settings.options[:type_name] || owner.class.human_name
102
104
  @fields = settings.options[:fields] || []
103
105
  end
104
106
 
@@ -106,10 +108,6 @@ module Mochigome
106
108
  @name_proc.call(@owner)
107
109
  end
108
110
 
109
- def data(options = {})
110
- field_data.merge(aggregate_data(:all, options))
111
- end
112
-
113
111
  def field_data
114
112
  h = ActiveSupport::OrderedHash.new
115
113
  self.fields.each do |field|
@@ -117,66 +115,15 @@ module Mochigome
117
115
  end
118
116
  h
119
117
  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]] = self.class.auto_numerify(row.x)
155
- end
156
- end
157
- h
158
- end
159
-
160
- def self.auto_numerify(data)
161
- # It's already some more specific type, leave it alone
162
- return data unless data.is_a?(String)
163
-
164
- # Try to turn data into an integer or float if appropriate
165
- data = data.strip
166
- if data =~ /\A[+-]?\d+(\.\d+)?\Z/
167
- return ($1 and !$1.blank?) ? data.to_f : data.to_i
168
- else
169
- return data
170
- end
171
- end
172
118
  end
173
119
 
174
120
  class ReportFocusSettings
175
121
  attr_reader :options
176
- attr_reader :orig_class
122
+ attr_reader :model
123
+ attr_reader :ordering
177
124
 
178
- def initialize(orig_class)
179
- @orig_class = orig_class
125
+ def initialize(model)
126
+ @model = model
180
127
  @options = {}
181
128
  @options[:fields] = []
182
129
  end
@@ -192,16 +139,15 @@ module Mochigome
192
139
  @options[:name] = n.to_proc
193
140
  end
194
141
 
195
- def fields(fields)
196
- def complain_if_reserved(s)
197
- ['name', 'id', 'type', 'internal_type'].each do |reserved|
198
- if s.gsub(/ +/, "_").underscore == reserved
199
- raise ModelSetupError.new "Field name \"#{s}\" conflicts with reserved term \"#{reserved}\""
200
- end
201
- end
202
- s
203
- end
142
+ def ordering(n)
143
+ @options[:ordering] = n.to_s
144
+ end
204
145
 
146
+ def get_ordering
147
+ @options[:ordering] || @model.primary_key.to_s
148
+ end
149
+
150
+ def fields(fields)
205
151
  unless fields.respond_to?(:each)
206
152
  raise ModelSetupError.new "Call f.fields with an Enumerable"
207
153
  end
@@ -209,11 +155,11 @@ module Mochigome
209
155
  @options[:fields] += fields.map do |f|
210
156
  case f
211
157
  when String, Symbol then {
212
- :name => complain_if_reserved(f.to_s.strip),
158
+ :name => Mochigome::complain_if_reserved_name(f.to_s.strip),
213
159
  :value_func => lambda{|obj| obj.send(f.to_sym)}
214
160
  }
215
161
  when Hash then {
216
- :name => complain_if_reserved(f.keys.first.to_s.strip),
162
+ :name => Mochigome::complain_if_reserved_name(f.keys.first.to_s.strip),
217
163
  :value_func => f.values.first.to_proc
218
164
  }
219
165
  else raise ModelSetupError.new "Invalid field: #{f.inspect}"
@@ -221,4 +167,138 @@ module Mochigome
221
167
  end
222
168
  end
223
169
  end
170
+
171
+ class AggregationSettings
172
+ attr_reader :options
173
+ attr_reader :model
174
+
175
+ def initialize(model)
176
+ @model = model
177
+ @options = {}
178
+ @options[:fields] = []
179
+ end
180
+
181
+ def fields(aggs)
182
+ unless aggs.is_a?(Enumerable)
183
+ raise ModelSetupError.new "Call a.fields with an Enumerable"
184
+ end
185
+
186
+ @options[:fields].concat(aggs.map {|f|
187
+ case f
188
+ when String, Symbol then
189
+ {
190
+ :name => "%s %s" % [@model.name.pluralize, f.to_s.sub("_", " ")]
191
+ }.merge(Mochigome::split_out_aggregation_procs(f))
192
+ when Hash then
193
+ if f.size == 1
194
+ {
195
+ :name => f.keys.first.to_s
196
+ }.merge(Mochigome::split_out_aggregation_procs(f.values.first))
197
+ else
198
+ {
199
+ :name => f[:name],
200
+ :value_proc => Mochigome::value_proc(f[:value]),
201
+ :agg_proc => Mochigome::aggregation_proc(f[:aggregation])
202
+ }
203
+ end
204
+ else
205
+ raise ModelSetupError.new "Invalid aggregation: #{f.inspect}"
206
+ end
207
+ })
208
+ end
209
+
210
+ def hidden_fields(aggs)
211
+ orig_keys = Set.new @options[:fields].map{|a| a[:name]}
212
+ fields(aggs)
213
+ @options[:fields].each do |h|
214
+ next if orig_keys.include? h[:name]
215
+ h[:hidden] = true
216
+ end
217
+ end
218
+
219
+ def fields_in_ruby(aggs)
220
+ @options[:fields].concat(aggs.map {|f|
221
+ raise ModelSetupError.new "Invalid ruby agg #{f.inspect}" unless f.is_a?(Hash)
222
+ {
223
+ :name => f.keys.first.to_s,
224
+ :ruby_proc => f.values.first,
225
+ :in_ruby => true
226
+ }
227
+ })
228
+ end
229
+ end
230
+
231
+ def self.complain_if_reserved_name(s)
232
+ test_s = s.gsub(/ +/, "_").underscore
233
+ ['name', 'id', 'type', 'internal_type'].each do |reserved|
234
+ if test_s == reserved
235
+ raise ModelSetupError.new "Field name \"#{s}\" conflicts with reserved term \"#{reserved}\""
236
+ end
237
+ end
238
+ s
239
+ end
240
+
241
+ AGGREGATION_FUNCS = {
242
+ :count => lambda{|a| a.count},
243
+ :distinct => lambda{|a| a.count(true)},
244
+ :average => lambda{|a| a.average},
245
+ :avg => :average,
246
+ :minimum => lambda{|a| a.minimum},
247
+ :min => :minimum,
248
+ :maximum => lambda{|a| a.maximum},
249
+ :max => :maximum,
250
+ :sum => lambda{|a| a.sum}
251
+ }
252
+
253
+ # Given an object, tries to coerce it into a proc that takes a node
254
+ # and returns an expression node to collect some aggregate data from it.
255
+ def self.aggregation_proc(obj)
256
+ if obj.is_a?(Symbol)
257
+ orig_name = obj
258
+ 2.times do
259
+ # Lookup twice to allow for indirect aliases in AGGREGATION_FUNCS
260
+ obj = AGGREGATION_FUNCS[obj] if obj.is_a?(Symbol)
261
+ end
262
+ raise ModelSetupError.new "Can't find aggregation function #{orig_name}" unless obj
263
+ obj
264
+ elsif obj.is_a?(Proc)
265
+ obj
266
+ else
267
+ raise ModelSetupError.new "Invalid aggregation function #{obj.inspect}"
268
+ end
269
+ end
270
+
271
+ # Given an object, tries to coerce it into a proc that takes a relation
272
+ # and returns a node for some value in it to be aggregated over
273
+ def self.value_proc(obj)
274
+ if obj.is_a?(Symbol)
275
+ lambda {|t| t[obj]}
276
+ elsif obj.is_a?(Proc)
277
+ obj
278
+ else
279
+ raise ModelSetupError.new "Invalid value function #{obj.inspect}"
280
+ end
281
+ end
282
+
283
+ def self.split_out_aggregation_procs(obj)
284
+ case obj
285
+ when Symbol, String
286
+ vals = obj.to_s.split(/[ _]/).map(&:downcase).map(&:to_sym)
287
+ when Array
288
+ vals = obj.dup
289
+ else
290
+ raise ModelSetupError.new "Invalid aggregation type: #{obj.inspect}"
291
+ end
292
+
293
+ if vals.size == 1
294
+ vals << :id # TODO : Use real primary key
295
+ elsif vals.size != 2
296
+ raise ModelSetupError.new "Wrong # of components for agg: #{obj.inspect}"
297
+ end
298
+
299
+ {
300
+ :agg_proc => aggregation_proc(vals.first),
301
+ :value_proc => value_proc(vals.last)
302
+ }
303
+ end
224
304
  end