google_data_source 0.7.6
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/.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
|