google_data_source 0.7.6
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +7 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +25 -0
- data/Rakefile +31 -0
- data/google_data_source.gemspec +32 -0
- data/lib/assets/images/google_data_source/chart_bar_add.png +0 -0
- data/lib/assets/images/google_data_source/chart_bar_delete.png +0 -0
- data/lib/assets/images/google_data_source/loader.gif +0 -0
- data/lib/assets/javascripts/google_data_source/data_source_init.js +3 -0
- data/lib/assets/javascripts/google_data_source/extended_data_table.js +76 -0
- data/lib/assets/javascripts/google_data_source/filter_form.js +180 -0
- data/lib/assets/javascripts/google_data_source/google_visualization/combo_table.js.erb +113 -0
- data/lib/assets/javascripts/google_data_source/google_visualization/table.js +116 -0
- data/lib/assets/javascripts/google_data_source/google_visualization/timeline.js +13 -0
- data/lib/assets/javascripts/google_data_source/google_visualization/visualization.js.erb +141 -0
- data/lib/assets/javascripts/google_data_source/index.js +7 -0
- data/lib/dummy_engine.rb +5 -0
- data/lib/google_data_source.rb +33 -0
- data/lib/google_data_source/base.rb +281 -0
- data/lib/google_data_source/column.rb +31 -0
- data/lib/google_data_source/csv_data.rb +23 -0
- data/lib/google_data_source/data_date.rb +17 -0
- data/lib/google_data_source/data_date_time.rb +17 -0
- data/lib/google_data_source/helper.rb +69 -0
- data/lib/google_data_source/html_data.rb +6 -0
- data/lib/google_data_source/invalid_data.rb +14 -0
- data/lib/google_data_source/json_data.rb +78 -0
- data/lib/google_data_source/railtie.rb +36 -0
- data/lib/google_data_source/sql/models.rb +266 -0
- data/lib/google_data_source/sql/parser.rb +239 -0
- data/lib/google_data_source/sql_parser.rb +82 -0
- data/lib/google_data_source/template_handler.rb +31 -0
- data/lib/google_data_source/test_helper.rb +26 -0
- data/lib/google_data_source/version.rb +3 -0
- data/lib/google_data_source/xml_data.rb +25 -0
- data/lib/locale/de.yml +5 -0
- data/lib/reporting/action_controller_extension.rb +19 -0
- data/lib/reporting/grouped_set.rb +58 -0
- data/lib/reporting/helper.rb +110 -0
- data/lib/reporting/reporting.rb +352 -0
- data/lib/reporting/reporting_adapter.rb +27 -0
- data/lib/reporting/reporting_entry.rb +147 -0
- data/lib/reporting/sql_reporting.rb +220 -0
- data/test/lib/empty_reporting.rb +2 -0
- data/test/lib/test_reporting.rb +33 -0
- data/test/lib/test_reporting_b.rb +9 -0
- data/test/lib/test_reporting_c.rb +3 -0
- data/test/locales/en.models.yml +6 -0
- data/test/locales/en.reportings.yml +5 -0
- data/test/rails/reporting_renderer_test.rb +47 -0
- data/test/test_helper.rb +50 -0
- data/test/units/base_test.rb +340 -0
- data/test/units/csv_data_test.rb +36 -0
- data/test/units/grouped_set_test.rb +60 -0
- data/test/units/json_data_test.rb +68 -0
- data/test/units/reporting_adapter_test.rb +20 -0
- data/test/units/reporting_entry_test.rb +149 -0
- data/test/units/reporting_test.rb +374 -0
- data/test/units/sql_parser_test.rb +111 -0
- data/test/units/sql_reporting_test.rb +307 -0
- data/test/units/xml_data_test.rb +32 -0
- metadata +286 -0
@@ -0,0 +1,110 @@
|
|
1
|
+
module GoogleDataSource
|
2
|
+
module Reporting
|
3
|
+
module Helper
|
4
|
+
# Shows a reporting consisting of a visualization and a form for configuration
|
5
|
+
# +type+ can be either
|
6
|
+
# * 'Table'
|
7
|
+
# * 'TimeLine'
|
8
|
+
# +reporting+ is the +Reporting+ object to be displayed
|
9
|
+
# Have a look at +google_visualization+ helper for +url+ and +options+ parameter
|
10
|
+
def google_reporting(type, reporting, url = nil, options = {})
|
11
|
+
# no form
|
12
|
+
return google_visualization(type, url, options) unless options.delete(:form)
|
13
|
+
|
14
|
+
# form
|
15
|
+
# default options
|
16
|
+
options = {
|
17
|
+
:form => reporting_form_id(reporting),
|
18
|
+
:autosubmit => true,
|
19
|
+
:form_position => :bottom
|
20
|
+
}.update(options)
|
21
|
+
|
22
|
+
visualization_html = google_visualization(type, url, options)
|
23
|
+
|
24
|
+
form_html = content_tag("form", :id => reporting_form_id(reporting), :class => 'formtastic') do
|
25
|
+
render options[:form_partial] || reporting_form_partial(reporting)
|
26
|
+
end
|
27
|
+
|
28
|
+
options[:form_position] == :bottom ? visualization_html << form_html : form_html << visualization_html
|
29
|
+
end
|
30
|
+
|
31
|
+
# Shows a timeline reporting
|
32
|
+
def google_reporting_timeline(reporting, url = nil, options = {})
|
33
|
+
google_reporting('TimeLine', reporting, url, options)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Shows a table reporting
|
37
|
+
def google_reporting_table(reporting, url = nil, options = {})
|
38
|
+
google_reporting('Table', reporting, url, options)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Shows a combo table reporting
|
42
|
+
def google_reporting_combo_table(reporting, url = nil, options = {})
|
43
|
+
google_reporting('ComboTable', reporting, url, options)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns callback JS code that sends the rendered form partial
|
47
|
+
# So validation errors can be displayed
|
48
|
+
def form_render_callback(reporting, options = {})
|
49
|
+
out = "$('##{reporting_form_id(reporting)}').html(#{render(options[:form_partial] || reporting_form_partial(reporting), :reporting => reporting).to_json});"
|
50
|
+
out << '$(document).trigger("formRendered");'
|
51
|
+
out
|
52
|
+
end
|
53
|
+
|
54
|
+
# Shows a select tag for grouping selection on a given reporting
|
55
|
+
# TODO more docu
|
56
|
+
# TODO really take namespace from classname?
|
57
|
+
def reporting_group_by_select(reporting, select_options, i = 1, options = {})
|
58
|
+
select_options = reporting_options_for_select(reporting, select_options, options)
|
59
|
+
|
60
|
+
tag_name = "#{reporting.class.name.underscore}[groupby(#{i}i)]"
|
61
|
+
current_option = (reporting.group_by.size < i) ? nil : reporting.group_by[i-1]
|
62
|
+
option_tags = options_for_select(select_options, current_option)
|
63
|
+
select_tag(tag_name, option_tags, options)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Shows a Multiselect box for the columns to 'select'
|
67
|
+
def reporting_select_select(reporting, select_options, options = {})
|
68
|
+
select_options = reporting_options_for_select(reporting, select_options, options)
|
69
|
+
|
70
|
+
tag_name = "#{reporting.class.name.underscore}[select]"
|
71
|
+
option_tags = options_for_select(select_options, reporting.select)
|
72
|
+
select_tag(tag_name, option_tags, :multiple => true)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Adds labels to the select options when columns are passed in
|
76
|
+
def reporting_options_for_select(reporting, select_options, options = {})
|
77
|
+
if (select_options.is_a?(Array))
|
78
|
+
select_options = select_options.collect { |column| [reporting.column_label(column), column] }
|
79
|
+
select_options.unshift('') if options.delete(:include_blank)
|
80
|
+
end
|
81
|
+
select_options
|
82
|
+
end
|
83
|
+
|
84
|
+
# Registers form subit hooks
|
85
|
+
# This way the standard form serialization can be overwritten
|
86
|
+
def reporting_form_hooks(reporting)
|
87
|
+
hooks = OpenStruct.new
|
88
|
+
yield(hooks)
|
89
|
+
|
90
|
+
json = []
|
91
|
+
%w(select).each do |hook|
|
92
|
+
next if hooks.send(hook).nil?
|
93
|
+
json << "#{hook}: function(){#{hooks.send hook}}"
|
94
|
+
end
|
95
|
+
js = "DataSource.FilterForm.setHooks(#{reporting_form_id(reporting).to_json}, {#{json.join(', ')}});"
|
96
|
+
javascript_tag(js)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Returns the standard DOM id for reporting forms
|
100
|
+
def reporting_form_id(reporting)
|
101
|
+
"#{reporting.id}_form"
|
102
|
+
end
|
103
|
+
|
104
|
+
# Returns the standard partial for reporting forms
|
105
|
+
def reporting_form_partial(reporting)
|
106
|
+
"#{reporting.id}_form.html"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,352 @@
|
|
1
|
+
class CircularDependencyException < Exception; end
|
2
|
+
|
3
|
+
# Super class for reportings to be rendered with Google visualizations
|
4
|
+
# Encapsulates the aggregation of the reporting data based on a configuration.
|
5
|
+
#
|
6
|
+
# The configuration is implemented by inherting from +ActiveRecord+ and switching off
|
7
|
+
# the database stuff. Thus the regular +ActiveRecord+ validators and parameter assignments
|
8
|
+
# including type casting cab be used
|
9
|
+
#
|
10
|
+
# The ActiveRecord extension is copied from the ActiveForm plugin (http://github.com/remvee/active_form)
|
11
|
+
class Reporting < ActiveRecord::Base
|
12
|
+
attr_accessor :query, :group_by, :select, :order_by, :limit, :offset
|
13
|
+
attr_reader :virtual_columns #, :required_columns
|
14
|
+
attr_writer :id
|
15
|
+
|
16
|
+
class_attribute :datasource_filters,
|
17
|
+
:datasource_columns,
|
18
|
+
:datasource_defaults,
|
19
|
+
{:instance_reader => false, :instance_writer => false}
|
20
|
+
|
21
|
+
def initialize(*args)
|
22
|
+
@required_columns = []
|
23
|
+
@virtual_columns = {}
|
24
|
+
super(*args)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Returns an instance of our own connection adapter
|
28
|
+
#
|
29
|
+
def self.connection
|
30
|
+
@adapter ||= ActiveRecord::ConnectionAdapters::ReportingAdapter.new
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns an ID which is used by frontend components to generate unique dom ids
|
34
|
+
# Defaults to the underscoreized classname
|
35
|
+
def id
|
36
|
+
@id || self.class.name.underscore.split('/').last #gsub('/', '_')
|
37
|
+
end
|
38
|
+
|
39
|
+
# helper method used by required_columns
|
40
|
+
# Returns all columns required by a certain column resolving the dependencies recursively
|
41
|
+
def required_columns_for(column, start = nil)
|
42
|
+
return [] unless self.datasource_columns.has_key?(column)
|
43
|
+
raise CircularDependencyException.new("Column #{start} has a circular dependency") if column.to_sym == start
|
44
|
+
columns = [ self.datasource_columns[column][:requires] ].flatten.compact.collect(&:to_s)
|
45
|
+
columns.collect { |c| [c, required_columns_for(c, start || column.to_sym)] }.flatten
|
46
|
+
end
|
47
|
+
|
48
|
+
# Returns the columns that have to be selected
|
49
|
+
def required_columns
|
50
|
+
(select + group_by + @required_columns).inject([]) do |columns, column|
|
51
|
+
columns << required_columns_for(column)
|
52
|
+
columns << column
|
53
|
+
end.flatten.map(&:to_s).uniq
|
54
|
+
end
|
55
|
+
|
56
|
+
# Adds required columns
|
57
|
+
def add_required_columns(*columns)
|
58
|
+
@required_columns = (@required_columns + columns.flatten.collect(&:to_s)).uniq
|
59
|
+
end
|
60
|
+
|
61
|
+
# 'Abstract' method that has to be overridden by subclasses
|
62
|
+
# Returns an array of rows written to #rows and accessible in DataSource compatible format in #rows_as_datasource
|
63
|
+
#
|
64
|
+
def aggregate
|
65
|
+
[]
|
66
|
+
end
|
67
|
+
|
68
|
+
# Returns the data rows
|
69
|
+
# Calls the aggregate method first if rows do not exist
|
70
|
+
#
|
71
|
+
def data(options = {})
|
72
|
+
add_required_columns(options[:required_columns])
|
73
|
+
@rows ||= aggregate
|
74
|
+
end
|
75
|
+
|
76
|
+
# Lazy getter for the columns object
|
77
|
+
def columns
|
78
|
+
select.inject([]) do |columns, column|
|
79
|
+
columns << {
|
80
|
+
:type => all_columns[column][:type]
|
81
|
+
}.merge({
|
82
|
+
:id => column.to_s,
|
83
|
+
:label => column_label(column)
|
84
|
+
}) if all_columns.key?(column)
|
85
|
+
|
86
|
+
columns
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Retrieves the I18n translation of the column label
|
91
|
+
def column_label(column, default = nil)
|
92
|
+
return '' if column.blank?
|
93
|
+
defaults = ['reportings.{{model}}.{{column}}', 'models.attributes.{{model}}.{{column}}'].collect do |scope|
|
94
|
+
scope.gsub!('{{model}}', self.class.name.underscore.gsub('/', '.'))
|
95
|
+
scope.gsub('{{column}}', column.to_s)
|
96
|
+
end.collect(&:to_sym)
|
97
|
+
defaults << column.to_s.humanize
|
98
|
+
I18n.t(defaults.shift, :default => defaults)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Returns the select columns as array
|
102
|
+
def select
|
103
|
+
(@select ||= (defaults[:select] || [])).collect { |c| c == '*' ? all_columns.keys : c }.flatten
|
104
|
+
end
|
105
|
+
|
106
|
+
# Returns the grouping columns as array
|
107
|
+
def group_by
|
108
|
+
@group_by ||= (defaults[:group_by] || [])
|
109
|
+
end
|
110
|
+
|
111
|
+
# add a virtual column
|
112
|
+
def add_virtual_column(name, type = :string)
|
113
|
+
virtual_columns[name.to_s] = {
|
114
|
+
:type => type
|
115
|
+
}
|
116
|
+
end
|
117
|
+
|
118
|
+
# Returns a list of all columns (real and virtual)
|
119
|
+
def all_columns
|
120
|
+
datasource_columns.merge(virtual_columns)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Returns an array of columns which are allowed for grouping
|
124
|
+
#
|
125
|
+
def groupable_columns
|
126
|
+
datasource_columns.collect do |key, options|
|
127
|
+
key.to_sym if options[:grouping]
|
128
|
+
end.compact
|
129
|
+
end
|
130
|
+
|
131
|
+
# Returns the +defaults+ Hash
|
132
|
+
# Convenience wrapper for instance access
|
133
|
+
def defaults
|
134
|
+
self.class.defaults #.merge(@defaults || {})
|
135
|
+
end
|
136
|
+
|
137
|
+
# Attribute reader for datasource_columns
|
138
|
+
def datasource_columns
|
139
|
+
self.class.datasource_columns || { }.freeze
|
140
|
+
end
|
141
|
+
|
142
|
+
# Returns a serialized representation of the reporting
|
143
|
+
def serialize
|
144
|
+
to_param.to_json
|
145
|
+
end
|
146
|
+
|
147
|
+
def to_param # :nodoc:
|
148
|
+
attributes.merge({
|
149
|
+
:select => select,
|
150
|
+
:group_by => group_by,
|
151
|
+
:order_by => order_by,
|
152
|
+
:limit => limit,
|
153
|
+
:offset => offset
|
154
|
+
})
|
155
|
+
end
|
156
|
+
|
157
|
+
def limit
|
158
|
+
(@limit || 1000).to_i
|
159
|
+
end
|
160
|
+
|
161
|
+
# Returns the serialized Reporting in a Hash that can be used for links
|
162
|
+
# and which is deserialized by from_params
|
163
|
+
def to_params(key = self.class.name.underscore.gsub('/', '_'))
|
164
|
+
HashWithIndifferentAccess.new( key => to_param )
|
165
|
+
end
|
166
|
+
|
167
|
+
# Returns true if the given column is available for filtering
|
168
|
+
#
|
169
|
+
def filterable_by?(column_name)
|
170
|
+
self.class.datasource_filters.key?(column_name.to_s)
|
171
|
+
end
|
172
|
+
|
173
|
+
class << self
|
174
|
+
# Defines a displayable column of the datasource
|
175
|
+
# Type defaults to string
|
176
|
+
def column(name, options = {})
|
177
|
+
self.datasource_columns ||= Hash.new.freeze
|
178
|
+
self.datasource_columns = column_or_filter(name, options, self.datasource_columns)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Marks a column as filterable / adds it in the background
|
182
|
+
#
|
183
|
+
#
|
184
|
+
def filter(name, options = {})
|
185
|
+
self.datasource_filters ||= Hash.new.freeze
|
186
|
+
|
187
|
+
# update the column options
|
188
|
+
self.datasource_filters = column_or_filter(name, options, self.datasource_filters)
|
189
|
+
end
|
190
|
+
|
191
|
+
# Returns the defaults class variable
|
192
|
+
def defaults
|
193
|
+
self.datasource_defaults ||= Hash.new.freeze
|
194
|
+
end
|
195
|
+
|
196
|
+
# Sets the default value for select
|
197
|
+
def select_default(select)
|
198
|
+
self.datasource_defaults = defaults.merge({:select => select})
|
199
|
+
end
|
200
|
+
|
201
|
+
# Sets the default value for group_by
|
202
|
+
def group_by_default(group_by)
|
203
|
+
self.datasource_defaults = defaults.merge({:group_by => group_by})
|
204
|
+
end
|
205
|
+
|
206
|
+
# Returns a reporting from a serialized representation
|
207
|
+
def deserialize(value)
|
208
|
+
self.new(JSON.parse(value))
|
209
|
+
end
|
210
|
+
|
211
|
+
# Uses the +simple_parse+ method of the SqlParser to setup a reporting
|
212
|
+
# from a query. The where clause is intepreted as reporting configuration (activerecord attributes)
|
213
|
+
def from_params(params, key = self.name.underscore.gsub('/', '_'))
|
214
|
+
return self.deserialize(params[key]) if params.has_key?(key) && params[key].is_a?(String)
|
215
|
+
|
216
|
+
reporting = self.new(params[key])
|
217
|
+
reporting = from_query_params(params["query"]) if params.has_key?("query")
|
218
|
+
return reporting unless params.has_key?(:tq)
|
219
|
+
|
220
|
+
query = GoogleDataSource::DataSource::SqlParser.simple_parse(params[:tq])
|
221
|
+
attributes = Hash.new
|
222
|
+
query.conditions.each do |k, v|
|
223
|
+
if v.is_a?(Array)
|
224
|
+
v.each do |condition|
|
225
|
+
case condition.op
|
226
|
+
when '<='
|
227
|
+
attributes["to_#{k}"] = condition.value
|
228
|
+
when '>='
|
229
|
+
attributes["from_#{k}"] = condition.value
|
230
|
+
when 'in'
|
231
|
+
attributes["in_#{k}"] = condition.value
|
232
|
+
else
|
233
|
+
# raise exception for unsupported operator?
|
234
|
+
end
|
235
|
+
end
|
236
|
+
else
|
237
|
+
attributes[k] = v
|
238
|
+
end
|
239
|
+
end
|
240
|
+
attributes[:group_by] = query.groupby
|
241
|
+
attributes[:select] = query.select
|
242
|
+
attributes[:order_by] = query.orderby
|
243
|
+
attributes[:limit] = query.limit
|
244
|
+
attributes[:offset] = query.offset
|
245
|
+
attributes.merge!(params[key]) if params.has_key?(key)
|
246
|
+
#reporting.update_attributes(attributes)
|
247
|
+
reporting.attributes = attributes
|
248
|
+
reporting.query = params[:tq]
|
249
|
+
reporting
|
250
|
+
end
|
251
|
+
|
252
|
+
############################
|
253
|
+
# ActiveRecord overrides
|
254
|
+
############################
|
255
|
+
# Needed to build column accessors
|
256
|
+
#
|
257
|
+
def columns_hash
|
258
|
+
@columns_hash ||= begin
|
259
|
+
ret = { }
|
260
|
+
data = { }
|
261
|
+
data.update(self.datasource_columns) if self.datasource_columns
|
262
|
+
data.update(self.datasource_filters) if self.datasource_filters
|
263
|
+
|
264
|
+
data.each do |k, v|
|
265
|
+
ret[k.to_s] = ActiveRecord::ConnectionAdapters::Column.new(k.to_s,
|
266
|
+
v[:default],
|
267
|
+
v[:type].to_s,
|
268
|
+
v.key?(:null) ? v[:null] : true)
|
269
|
+
|
270
|
+
# define a humanize method which returns the human name
|
271
|
+
if v.key?(:human_name)
|
272
|
+
ret[k.to_s].define_singleton_method(:humanize) do
|
273
|
+
v[:human_name]
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
ret
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
# Returns a hash with column name as key and default value as value
|
283
|
+
#
|
284
|
+
def column_defaults(*args)
|
285
|
+
columns_hash.keys.inject({}) do |mem, var|
|
286
|
+
mem[var] = columns_hash[var].default
|
287
|
+
mem
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
# TODO: merge with columns hash
|
292
|
+
def columns
|
293
|
+
columns_hash.values
|
294
|
+
end
|
295
|
+
|
296
|
+
def primary_key
|
297
|
+
'id'
|
298
|
+
end
|
299
|
+
|
300
|
+
def abstract_class # :nodoc:
|
301
|
+
true
|
302
|
+
end
|
303
|
+
|
304
|
+
protected
|
305
|
+
# Used to decorate a filter or column
|
306
|
+
# used by #column and #filter internally
|
307
|
+
#
|
308
|
+
def column_or_filter(name, options, registry)
|
309
|
+
name = name.to_s
|
310
|
+
|
311
|
+
# whatever this is.
|
312
|
+
# options.each { |k,v| options[k] = v.to_s if Symbol === v }
|
313
|
+
|
314
|
+
default_options = { :type => :string }
|
315
|
+
|
316
|
+
new_entry = { name => (registry[name] || {}) }
|
317
|
+
new_entry[name].reverse_merge!(default_options.merge(options))
|
318
|
+
new_entry.freeze
|
319
|
+
|
320
|
+
# frozen, to prevent modifications on class_attribute
|
321
|
+
registry.merge(new_entry).freeze
|
322
|
+
end
|
323
|
+
|
324
|
+
def from_query_params(query)
|
325
|
+
page = query.delete('page')
|
326
|
+
reporting = self.new(query)
|
327
|
+
reporting.limit = query['limit']
|
328
|
+
if page
|
329
|
+
reporting.offset = reporting.limit * (page.to_i - 1)
|
330
|
+
else
|
331
|
+
reporting.offset = query['offset'].to_i
|
332
|
+
end
|
333
|
+
|
334
|
+
reporting
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
############################
|
339
|
+
# ActiveRecord overrides
|
340
|
+
############################
|
341
|
+
|
342
|
+
def save # :nodoc:
|
343
|
+
if result = valid?
|
344
|
+
end
|
345
|
+
|
346
|
+
result
|
347
|
+
end
|
348
|
+
|
349
|
+
def save! # :nodoc:
|
350
|
+
save or raise ActiveRecord::RecordInvalid.new(self)
|
351
|
+
end
|
352
|
+
end
|