mochigome 0.0.3 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
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