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,27 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module ConnectionAdapters
|
3
|
+
# This connection adapter does the quoting the way our reporting class should generate it
|
4
|
+
#
|
5
|
+
#
|
6
|
+
class ReportingAdapter < AbstractAdapter
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
super(nil)
|
10
|
+
end
|
11
|
+
|
12
|
+
def quoted_true
|
13
|
+
"'1'"
|
14
|
+
end
|
15
|
+
|
16
|
+
def quoted_false
|
17
|
+
"'0'"
|
18
|
+
end
|
19
|
+
|
20
|
+
# Quotes a string, escaping any ' (single quote) and \ (backslash)
|
21
|
+
# characters.
|
22
|
+
def quote_string(s)
|
23
|
+
s.gsub(/\\/, '\&\&').gsub(/'/, "\\\\'") # ' (for ruby-mode)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
# This class represents a single reporting entry.
|
2
|
+
# Casting for numerical values and a '+' for the addition of different object
|
3
|
+
# is included.
|
4
|
+
class ReportingEntry
|
5
|
+
# The raw attributes hash
|
6
|
+
attr_reader :attributes # TODO remove
|
7
|
+
|
8
|
+
# These constants define fields which are casted to integers/float
|
9
|
+
# in the accessor. Also these fields are added when adding two
|
10
|
+
# SuperReportingEntries.
|
11
|
+
#
|
12
|
+
# DON'T USE THIS FOR IDs (e.g. ad_id)
|
13
|
+
SUMMABLE_INT_FIELDS_REGEXP = /OVERWRITE_THIS_IN_SUBCLASS/
|
14
|
+
SUMMABLE_FLOAT_FIELDS_REGEXP = /OVERWRITE_THIS_IN_SUBCLASS/
|
15
|
+
|
16
|
+
# Columns listed in this array are explicitely not summable
|
17
|
+
NOT_SUMMABLE_FIELDS = %w()
|
18
|
+
|
19
|
+
# Standard constructor
|
20
|
+
# takes attributes as hash (like OpenStruct)
|
21
|
+
def initialize(attributes = {})
|
22
|
+
@attributes = HashWithIndifferentAccess.new(attributes)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Offers an +OpenStruct+ like access to the values stored in +@attributes+
|
26
|
+
# Uses the information from +SUMMABLE_*_FIELDS_REGEXP+ to cast to numerical
|
27
|
+
# values.
|
28
|
+
def method_missing(method_id, *args)
|
29
|
+
return self.send("lazy_#{method_id}", *args) if self.respond_to?("lazy_#{method_id}")
|
30
|
+
if @attributes.has_key?(method_id) || method_id.to_s =~ summable_fields_regexp
|
31
|
+
return cast(method_id, @attributes[method_id])
|
32
|
+
end
|
33
|
+
super(method_id, *args)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Add another +SuperReportingEntry+ and returns the resulting entry.
|
37
|
+
# All attributes specified by +SUMMABLE_FIELD_REGEXP+ are summed up.
|
38
|
+
# Further attributes are merged (the own values has priority).
|
39
|
+
def +(other)
|
40
|
+
return self.class.composite([self, other])
|
41
|
+
end
|
42
|
+
|
43
|
+
# Returns a ReportingEntry object with all non addable values set to nil
|
44
|
+
# This is thought be used for sum(mary) rows
|
45
|
+
def to_sum_entry
|
46
|
+
subject = self
|
47
|
+
klass = Class.new(self.class) do
|
48
|
+
# overwrite explicitely not summable columns
|
49
|
+
#
|
50
|
+
subject.send(:not_summable_fields).each do |method_id|
|
51
|
+
define_method(method_id) { nil }
|
52
|
+
end
|
53
|
+
|
54
|
+
# method missing decides if the columns is displayed or not
|
55
|
+
#
|
56
|
+
define_method(:method_missing) do |method_id, *args|
|
57
|
+
(method_id.to_s =~ summable_fields_regexp) ? subject.send(method_id, *args) : nil
|
58
|
+
end
|
59
|
+
|
60
|
+
# yes, this actually is a sum entry ;-)
|
61
|
+
#
|
62
|
+
def is_sum_entry?
|
63
|
+
return true
|
64
|
+
end
|
65
|
+
end
|
66
|
+
klass.new
|
67
|
+
end
|
68
|
+
|
69
|
+
# Returns a composite element which lazily sums up the summable values of the children
|
70
|
+
def self.composite(entries)
|
71
|
+
public_methods = self.instance_methods - Object.public_methods
|
72
|
+
summable_methods = public_methods.select { |method| method.to_s =~ summable_fields_regexp }
|
73
|
+
|
74
|
+
klass = Class.new(self) do
|
75
|
+
define_method(:method_missing) do |method_id, *args|
|
76
|
+
if (method_id.to_s =~ summable_fields_regexp)
|
77
|
+
return entries.inject(0) do |sum, entry|
|
78
|
+
sum + entry.send(method_id, *args)
|
79
|
+
end
|
80
|
+
else
|
81
|
+
return entries.first.send(method_id, *args)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Delegate all summable method calls to the children
|
86
|
+
# by using the method_missing method
|
87
|
+
summable_methods.each do |method_id|
|
88
|
+
define_method(method_id) do |*args|
|
89
|
+
self.method_missing(method_id, *args)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# For better debuggability
|
94
|
+
#
|
95
|
+
define_method :inspect do
|
96
|
+
"CompositeReportingEntry [entries: #{entries.inspect} ]"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
klass.new
|
100
|
+
end
|
101
|
+
|
102
|
+
# Returns true if entry is a sum entry (like returned by to_sum_entry)
|
103
|
+
def is_sum_entry?
|
104
|
+
return false
|
105
|
+
end
|
106
|
+
|
107
|
+
protected
|
108
|
+
# Helper function to cast string values to numeric values if +key+
|
109
|
+
# matches either +SUMMABLE_INT_FIELDS_REGEXP+ or +SUMMABLE_FLOAT_FIELDS_REGEXP+
|
110
|
+
def cast(key, value)
|
111
|
+
return value.to_i if key.to_s =~ summable_int_fields_regexp
|
112
|
+
return value.to_f if key.to_s =~ summable_float_fields_regexp
|
113
|
+
value
|
114
|
+
end
|
115
|
+
|
116
|
+
# Returns the union of int and float defining regexps
|
117
|
+
def self.summable_fields_regexp
|
118
|
+
Regexp.union(self::SUMMABLE_INT_FIELDS_REGEXP, self::SUMMABLE_FLOAT_FIELDS_REGEXP)
|
119
|
+
end
|
120
|
+
|
121
|
+
# reader for the regexp defining int fields
|
122
|
+
def summable_int_fields_regexp
|
123
|
+
self.class::SUMMABLE_INT_FIELDS_REGEXP
|
124
|
+
end
|
125
|
+
|
126
|
+
# reader for the regexp defining float fields
|
127
|
+
def summable_float_fields_regexp
|
128
|
+
self.class::SUMMABLE_FLOAT_FIELDS_REGEXP
|
129
|
+
end
|
130
|
+
|
131
|
+
# convenience accessor for summable_fields_regexp
|
132
|
+
def summable_fields_regexp
|
133
|
+
self.class.summable_fields_regexp
|
134
|
+
end
|
135
|
+
|
136
|
+
# Returns an array of explicitely not summable fields
|
137
|
+
#
|
138
|
+
def not_summable_fields
|
139
|
+
self.class::NOT_SUMMABLE_FIELDS
|
140
|
+
end
|
141
|
+
|
142
|
+
# For debugging purpose
|
143
|
+
#
|
144
|
+
def inspect
|
145
|
+
"#{self.class.name}: <#{@attributes.inspect}>\n"
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,220 @@
|
|
1
|
+
# Subclass of +Reporting+ class
|
2
|
+
# Offers a variety of helpers to manage reportings that are generated via pure SQL queries
|
3
|
+
#
|
4
|
+
class SqlReporting < Reporting
|
5
|
+
attr_reader :columns_used
|
6
|
+
class_attribute :sql_tables, {:instance_reader => false, :instance_writer => false}
|
7
|
+
|
8
|
+
# Container for columns used by any (select, group by, where) statement.
|
9
|
+
# Used by the joins method to retrieve the joins needed
|
10
|
+
def columns_used
|
11
|
+
@columns_used ||= []
|
12
|
+
end
|
13
|
+
|
14
|
+
# Marks a column as used so the connected joins will be included
|
15
|
+
def mark_as_used(column)
|
16
|
+
columns_used << column.to_sym
|
17
|
+
end
|
18
|
+
|
19
|
+
# Returns the columns string for the select clause
|
20
|
+
def sql_select(additional_columns = [], mapping = {})
|
21
|
+
(map_columns(required_columns, mapping, true) << additional_columns).flatten.join(', ')
|
22
|
+
end
|
23
|
+
|
24
|
+
# Returns the columns string for the group by clause
|
25
|
+
#def sql_group_by(additional_columns = [], mapping = {})
|
26
|
+
def sql_group_by(*args)
|
27
|
+
mapping = args.extract_options! || {}
|
28
|
+
additional_columns = args.first || []
|
29
|
+
result = (map_columns(group_by, mapping) << additional_columns).flatten.join(', ')
|
30
|
+
result.empty? ? nil : result
|
31
|
+
end
|
32
|
+
|
33
|
+
def sql_order_by(mapping = {})
|
34
|
+
return nil if order_by.nil?
|
35
|
+
column = order_by[0]
|
36
|
+
direction = order_by[1]
|
37
|
+
column = mapping.has_key?(column) ? mapping[column] : sql_column_name(column)
|
38
|
+
return nil unless column
|
39
|
+
"#{column} #{direction.to_s.upcase}"
|
40
|
+
end
|
41
|
+
|
42
|
+
# TODO make protected?
|
43
|
+
def map_columns(columns, mapping = {}, with_alias = false)
|
44
|
+
mapped = []
|
45
|
+
columns.each do |column|
|
46
|
+
mapped << column if is_sql_column?(column)
|
47
|
+
end
|
48
|
+
mapped.collect do |column|
|
49
|
+
if mapping.has_key?(column)
|
50
|
+
with_alias ? "#{mapping[column]} #{column}" : mapping[column]
|
51
|
+
else
|
52
|
+
sql_column_name(column, :with_alias => with_alias)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns the join statements which are needed for the given +columns+
|
58
|
+
def sql_joins(*columns)
|
59
|
+
if columns.empty?
|
60
|
+
columns = columns_used + required_columns
|
61
|
+
columns += where.keys if self.respond_to?(:where)
|
62
|
+
end
|
63
|
+
columns.uniq!
|
64
|
+
columns.flatten!
|
65
|
+
|
66
|
+
# get all tables needed
|
67
|
+
tables = columns.inject([]) do |tables, c|
|
68
|
+
sql = datasource_columns[c.to_s][:sql] if is_sql_column?(c)
|
69
|
+
tables << sql[:table] if sql && sql.is_a?(Hash)
|
70
|
+
tables
|
71
|
+
end.compact.uniq
|
72
|
+
|
73
|
+
# explode dependencies
|
74
|
+
sql_joins = tables.collect do |table|
|
75
|
+
result = [self.class.sql_tables[table]]
|
76
|
+
while result.last.has_key?(:depends)
|
77
|
+
result << self.class.sql_tables[result.last[:depends]]
|
78
|
+
end
|
79
|
+
result.reverse
|
80
|
+
end.flatten.uniq
|
81
|
+
|
82
|
+
sql_joins.collect { |t| t[:join] }.join(' ')
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns all datasource columns that correcpond to a SQL column
|
86
|
+
def sql_columns
|
87
|
+
datasource_columns.keys.delete_if { |c| !is_sql_column?(c) }.collect(&:to_sym)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Returns +true+ if +column+ is a SQL column
|
91
|
+
def is_sql_column?(column)
|
92
|
+
datasource_columns.has_key?(column.to_s) && datasource_columns[column.to_s][:sql]
|
93
|
+
end
|
94
|
+
|
95
|
+
# Maps the column name using the +:sql+ definitions of the columns
|
96
|
+
#
|
97
|
+
# === options
|
98
|
+
# * +:with_alias+ Returns 'mapped_name name' instead of mapped_name
|
99
|
+
#
|
100
|
+
def sql_column_name(column, options = {})
|
101
|
+
column = column.to_s
|
102
|
+
return false unless is_sql_column?(column)
|
103
|
+
sql = datasource_columns[column][:sql]
|
104
|
+
return column.to_s if sql == true
|
105
|
+
|
106
|
+
parts = []
|
107
|
+
parts << sql[:table] if sql[:table] && !sql[:column].is_a?(String)
|
108
|
+
parts << (sql[:column] || column).to_s
|
109
|
+
sql_name = parts.join('.')
|
110
|
+
|
111
|
+
sql_name << " #{column}" if options[:with_alias]
|
112
|
+
sql_name
|
113
|
+
end
|
114
|
+
|
115
|
+
# Returns the SQL condition for the given column, depending on the columns type
|
116
|
+
#
|
117
|
+
def sql_condition_for(column_name, value)
|
118
|
+
column_name = column_name.to_s
|
119
|
+
# use the sql column name to be sure the table name is included if an sql column is defined
|
120
|
+
sql_name = is_sql_column?(column_name) ? sql_column_name(column_name) : column_name
|
121
|
+
bind_vars = [ ]
|
122
|
+
condition = ''
|
123
|
+
type = self.class.datasource_filters[column_name][:type].to_sym
|
124
|
+
case type
|
125
|
+
when :boolean then
|
126
|
+
bind_vars = value
|
127
|
+
condition = "(#{sql_name} = ?" + (value == false ? " OR ISNULL(#{sql_name}))" : ')')
|
128
|
+
else
|
129
|
+
bind_vars = value
|
130
|
+
# special handling for zero numbers = allow them to be null too
|
131
|
+
if [ :number, :integer ].include?(type) and not value.kind_of?(Array)
|
132
|
+
condition = "(#{sql_name} = ? #{(value.to_i.zero? ? "OR ISNULL(#{sql_name})" : "")})"
|
133
|
+
else
|
134
|
+
condition = value.kind_of?(Array) ? "(#{sql_name} IN(?))" : "(#{sql_name} = ?)"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
self.class.send(:sanitize_sql_array, [ condition, bind_vars ])
|
138
|
+
end
|
139
|
+
|
140
|
+
# Returns all key value pairs that will be bound to the query
|
141
|
+
#
|
142
|
+
def sql_bind_variables
|
143
|
+
bind_vars = {}
|
144
|
+
self.class.datasource_filters.each do |column_name, options|
|
145
|
+
# just filter sql columns and nil values
|
146
|
+
if options[:sql] and not attributes[column_name].nil?
|
147
|
+
bind_vars[column_name] = attributes[column_name]
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
bind_vars.symbolize_keys
|
152
|
+
end
|
153
|
+
|
154
|
+
# Concat all where conditions for all bind variables
|
155
|
+
#
|
156
|
+
def sql_conditions
|
157
|
+
sql_bind_variables.collect do |column_name, value|
|
158
|
+
mark_as_used(column_name)
|
159
|
+
sql_condition_for(column_name, value)
|
160
|
+
end.compact.join(' AND ')
|
161
|
+
end
|
162
|
+
|
163
|
+
# Marks all columns used in filters or in the select fields as used
|
164
|
+
#
|
165
|
+
#
|
166
|
+
def mark_used_columns
|
167
|
+
sql_bind_variables.each { |col, value| mark_as_used(col) unless value.nil? }
|
168
|
+
end
|
169
|
+
|
170
|
+
class << self
|
171
|
+
|
172
|
+
# Defines a SQL table that is not the 'main table'
|
173
|
+
#
|
174
|
+
# === Options
|
175
|
+
# * +join+ Defines the SQL JOIN statement to join this table (mandatory)
|
176
|
+
# * +depends+ Defines a table, this table depends on to join correclty (optional)
|
177
|
+
#
|
178
|
+
def table(name, options = {})
|
179
|
+
self.sql_tables ||= HashWithIndifferentAccess.new.freeze
|
180
|
+
new_entry = HashWithIndifferentAccess.new({name => options}).freeze
|
181
|
+
# frozen, to prevent modifications on class_attribute
|
182
|
+
self.sql_tables = self.sql_tables.merge(new_entry).freeze
|
183
|
+
end
|
184
|
+
|
185
|
+
def add_sql_columns_for(clazz, options = {})
|
186
|
+
except = (options[:except] || []).map(&:to_sym)
|
187
|
+
model_columns = model_columns(clazz)
|
188
|
+
column_names = (options[:columns] || model_columns.keys) # can be used to define column order
|
189
|
+
|
190
|
+
column_names.each do |col_name|
|
191
|
+
next if except.include?(col_name)
|
192
|
+
|
193
|
+
type = model_columns[col_name]
|
194
|
+
groupable = [:string, :date, :boolean].include?(type)
|
195
|
+
use_sql_sum = (type == :number) # SUM() to get correct data with grouping
|
196
|
+
send(:column, col_name.to_sym,
|
197
|
+
{
|
198
|
+
:type => type,
|
199
|
+
:sql => use_sql_sum ? {:column => "SUM(#{col_name})"} : true,
|
200
|
+
:grouping => groupable })
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def model_columns(clazz)
|
205
|
+
hsh={}
|
206
|
+
clazz.columns_hash.each do |name, col|
|
207
|
+
hsh[name.to_sym] = if col.number?
|
208
|
+
:number
|
209
|
+
elsif col.text?
|
210
|
+
:string
|
211
|
+
else
|
212
|
+
# try to map 1:1
|
213
|
+
col.type
|
214
|
+
end
|
215
|
+
end
|
216
|
+
hsh
|
217
|
+
end
|
218
|
+
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
class TestReporting < Reporting
|
2
|
+
attr_reader :aggregate_calls
|
3
|
+
|
4
|
+
filter :name,
|
5
|
+
:default => 'foobar'
|
6
|
+
|
7
|
+
|
8
|
+
filter :from_date, :type => :date
|
9
|
+
filter :to_date, :type => :date
|
10
|
+
filter :in_foo
|
11
|
+
|
12
|
+
select_default %w(name age)
|
13
|
+
group_by_default %w(name)
|
14
|
+
|
15
|
+
column :name, :type => :string
|
16
|
+
column :age, :type => :number, :grouping => true
|
17
|
+
column :address, :type => :string, :grouping => true
|
18
|
+
column :fullname, :type => :string, :requires => :name
|
19
|
+
column :fullfullname, :type => :string, :requires => :fullname
|
20
|
+
column :circle_a, :requires => :circle_b
|
21
|
+
column :circle_b, :requires => :circle_a
|
22
|
+
|
23
|
+
def initialize(*args)
|
24
|
+
#@select = %w(name age)
|
25
|
+
@aggregate_calls = 0
|
26
|
+
super(*args)
|
27
|
+
end
|
28
|
+
|
29
|
+
def aggregate
|
30
|
+
@aggregate_calls += 1
|
31
|
+
[]
|
32
|
+
end
|
33
|
+
end
|