tabloid 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,5 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ .idea/*
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in tabloid.gemspec
4
+ gemspec
5
+
6
+ group :test do
7
+ gem "ruby-debug"
8
+ gem "ZenTest"
9
+ end
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2011 Inductive Applications
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,137 @@
1
+ Tabloid allows the creation of cacheable report data using a straightforward DSL and output to HTML, CSV, and more to come.
2
+
3
+ This gem comes out of an Austin.rb meeting about our favorite gems where I sketched out what my ideal reporting DSL would look like. This gem is inspired by some of the features of Ruport, but I found its API to be a bit top-heavy for rapidly producing reports (though at the time of this writing, Ruport is far more flexible than Tabloid) and its up to you to take care of items like data caching. If your reporting needs are fairly straightforward, Tabloid should work pretty well for your needs. That said, this gem is early in development and has a lot of rough edges (see TODO list below), but it is being used in production and makes me happy there.
4
+
5
+ Features:
6
+ * easy to use DSL for specifying report definitions
7
+ * built-in caching of compiled data to Memcached and Redis
8
+ * parameterized reports
9
+ * can be used with your choice of ORM (used in production with ActiveRecord, should be usable with any other ORM)
10
+ * grouping of data with group and report summaries (totals only at the moment)
11
+ * unicorns
12
+
13
+ How to use it
14
+
15
+ Simple report
16
+ class UnpaidInvoicesReport
17
+ include Tabloid::Report
18
+
19
+ element :invoice_number, "Invoice Number"
20
+ element :invoice_date, "Invoice Date"
21
+ element :customer_name, "Name"
22
+ element :invoice_amount, "Amount"
23
+ element :balance, "Balance"
24
+
25
+ rows do
26
+ Invoice.select(:invoice_number, :invoice_date, :customer_name, :invoice_amount, :balance).where("balance > 0")
27
+ end
28
+ end
29
+
30
+ #create the report
31
+ report = UnpaidInvoicesReport.new
32
+
33
+ #collect the data
34
+ report.prepare
35
+
36
+ #output formats supported now
37
+ csv = report.to_csv
38
+ html = report.to_html
39
+
40
+
41
+ Walking through the above:
42
+
43
+ include Tabloid::Report
44
+
45
+ makes this class into a Tabloid report.
46
+
47
+ element :invoice_number, "Invoice Number"
48
+
49
+ #element creates a report column. In this case the report data will either use the first element of an array or whatever responds to the symbol :invoice_number on the data coming back from #rows. More on that in a sec...
50
+
51
+ rows do
52
+ Invoice.select(:invoice_number, :invoice_date, :customer_name, :invoice_amount, :balance).where("balance > 0")
53
+ end
54
+
55
+ #rows is the workhorse of a report. This is where you collect your data for reporting. It should return an array of arrays or an array of objects that respond to the keys dictated by your use of #element. (Support for an array of hashes is on the TODO list.) If you use nested arrays, the elements are order dependent—use the order you specified when adding element columns.
56
+
57
+ #to_csv and #to_html do pretty much what you'd think; they return a string containing the report in the respective formats. The HTML returned by #to_html is a table with one column per visible column; each cell will have the element symbol as a class name to allow for styling of columns.
58
+
59
+ Bells and whistles
60
+ Tabloid also supports groups with summaries and a report summary. Only totals are supported at the moment, but more flexibility is coming soon.
61
+
62
+ class UnpaidInvoicesReport < ActiveRecord::Base
63
+ include Tabloid::Report
64
+ handle_asynchronously :prepare
65
+
66
+ cache_key { "unpaid_invoices_report-#{id}"}
67
+
68
+ #parameterized report, supply parameters to report.prepare(...)
69
+ parameter :start_date
70
+ parameter :end_date
71
+
72
+ grouping :customer_name, :total => true
73
+
74
+ element :invoice_number, "Invoice Number"
75
+ element :invoice_date, "Invoice Date"
76
+ element :customer_name, "Name", :hidden => true
77
+ element :invoice_amount, "Amount", :total => true
78
+ element :balance, "Balance"
79
+
80
+ summary :balance => :sum
81
+
82
+ rows do
83
+ Invoice.select(:invoice_number, :invoice_date, :customer_name, :invoice_amount, :balance).where("balance > 0 AND invoice_date BETWEEN ? AND ?", parameter(:start_date), parameter(:end_date))
84
+ end
85
+ end
86
+
87
+ There's several things different about this one. We use #grouping to tell Tabloid to group the data by customer name, and indicate that we want totals to be calculated for each group. We indicate which columns we want totalled by passing :total => true on the elements requiring totals. We tell Tabloid to hide the :customer_name column because it will show a group header that contains this element for us. Finally, we tell Tabloid to summarize the report by summing balances. (:sum is the only accepted value for now, but support is coming for arbitrary blocks and a wider range of built-in functions).
88
+
89
+ Background support
90
+ Notice the parent class on this one? This report is backed by ActiveRecord. The main reason you'd want to do that is to allow for generation of report data in the background. In the report above, we've enabled that by making #prepare (which invokes the #rows block) run in the background using DelayedJob's #handle_asynchronously method. To use it under these circumstances, you'll create and save the report, then call prepare explicitly:
91
+
92
+ report = UnpaidInvoicesReport.create
93
+ report.prepare(:start_date => 30.days.ago, :end_date => Date.today)
94
+
95
+ Caching support
96
+ Background generation of data wouldn't make sense to do, however, unless you were also caching the data somewhere. Tabloid has explicit support for caching using Memcached and Redis. Redis is preferred under most circumstances, as it doesn't have the 1MB record limit of memcached. To enable caching, you have to provide the #cache_key block above (see TODO for changes that are coming there) and set the caching parameters of Tabloid in an initializer:
97
+
98
+ #config/initializers/tabloid.rb
99
+ config = YAML.load(IO.read(Rails.root.join("config/tabloid.yml")))[Rails.env]
100
+ if config
101
+ Tabloid.cache_engine = config['cache_engine'].to_sym
102
+ Tabloid.cache_connection_options = {
103
+ :server => config['server'],
104
+ :port => config['port']
105
+ }
106
+ end
107
+
108
+ #config/tabloid.yml
109
+ production:
110
+ cache_engine: redis
111
+ server: 172.16.0.10
112
+ port: 6379
113
+ development:
114
+ cache_engine: memcached
115
+ server: localhost
116
+ port: 11211
117
+ test:
118
+ cache_engine: memcached
119
+ server: localhost
120
+ port: 11211
121
+
122
+
123
+ Caveat Emptor
124
+ This gem is being used in production without any real problems. There are definitely some rough edges to it; it was born out of the needs of a particular application, so while I've designed it to be something that isn't single purpose, its also geared towards basic reports that have totals in various configurations. So its a work in progress. That said, if you need to whip up a report quickly, give it a try. It has very few dependencies, as its being used with a Rails 2.3 application, so it doesn't pull in anything like ActiveSupport that would make it unsuitable for other environments.
125
+
126
+ Patches are welcome!
127
+
128
+ TODO:
129
+ * Add more options for summary rows, like average and arbitrary blocks
130
+ * clean up the test suite a bit
131
+ * documentation!
132
+ * more caching mechanisms
133
+ * better callbacks
134
+ * PDF output format support with PDFkit (optional)
135
+ * extend the summary method to support more complex summary formats
136
+ * Add support for a preamble section (e.g. detailing report parameters)
137
+
@@ -0,0 +1,7 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+ require 'rspec/core/rake_task'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task :default => :spec
@@ -0,0 +1,18 @@
1
+ require "builder"
2
+ require 'yaml'
3
+
4
+ module Tabloid
5
+ # Your code goes here...
6
+ end
7
+ require 'tabloid/configuration'
8
+ require 'tabloid/header_row'
9
+ require 'tabloid/missing_parameter_error'
10
+ require 'tabloid/missing_element_error'
11
+ require 'tabloid/parameter'
12
+ require 'tabloid/report'
13
+ require 'tabloid/column_extensions'
14
+ require 'tabloid/report_column'
15
+ require 'tabloid/row'
16
+ require 'tabloid/group'
17
+ require 'tabloid/data'
18
+
@@ -0,0 +1,11 @@
1
+ module Tabloid
2
+ module ColumnExtensions
3
+ def [](val)
4
+ if val.is_a?(String) || val.is_a?(Symbol)
5
+ self.detect { |c| c.key == val }
6
+ else
7
+ super
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ module Tabloid
2
+ def self.cache_engine=(engine)
3
+ @engine = engine
4
+ end
5
+
6
+ def self.cache_engine
7
+ @engine
8
+ end
9
+
10
+ def self.cache_enabled?
11
+ !@engine.nil?
12
+ end
13
+
14
+ def self.cache_connection_options=(options)
15
+ @cache_connection_options = options
16
+ end
17
+ def self.cache_connection_options
18
+ @cache_connection_options || {}
19
+ end
20
+ end
@@ -0,0 +1,106 @@
1
+ module Tabloid
2
+ class Data
3
+ attr_accessor :report_columns
4
+ attr_reader :rows
5
+
6
+ def initialize(options = {})
7
+ raise ArgumentError.new("Must supply row data") unless options[:rows]
8
+ raise ArgumentError.new("Must supply column data") unless options[:report_columns]
9
+
10
+ @report_columns = options[:report_columns]
11
+ @grouping_key = options[:grouping_key]
12
+ @grouping_options = options[:grouping_options] || {:total => true}
13
+ @summary_options = options[:summary] || {}
14
+
15
+ @rows = convert_rows(options[:rows])
16
+
17
+ end
18
+
19
+ def to_csv
20
+ header_csv + rows.map(&:to_csv).join + summary_csv
21
+ end
22
+
23
+ def to_html
24
+ header_html + rows.map(&:to_html).join + summary_html
25
+ end
26
+
27
+ private
28
+ def convert_rows(rows)
29
+ rows.map! do |row|
30
+ Tabloid::Row.new(:columns => @report_columns, :data => row)
31
+ end
32
+
33
+ if @grouping_key
34
+ rows = rows.group_by { |r| r[@grouping_key] }
35
+ else
36
+ rows = {:default => rows}
37
+ end
38
+
39
+ rows.keys.sort.map do |key|
40
+ data_rows = rows[key]
41
+
42
+ label = (key == :default ? false : key)
43
+ Tabloid::Group.new :columns => @report_columns, :rows => data_rows, :label => label, :total => @grouping_options[:total]
44
+ end
45
+ end
46
+
47
+ def header_csv
48
+ FasterCSV.generate do |csv|
49
+ csv << @report_columns.map(&:to_header)
50
+ end
51
+ end
52
+
53
+ def header_html
54
+ headers = Builder::XmlMarkup.new
55
+ headers.tr do |tr|
56
+ @report_columns.each do |col|
57
+ tr.th(col.to_header, "class" => col.key) unless col.hidden?
58
+ end
59
+ end
60
+ end
61
+
62
+ def summary_html
63
+ summary_rows.map { |row| row.to_html(:class => "summary") }.join
64
+ end
65
+
66
+ def summary_csv
67
+ summary_rows.map(&:to_csv).join
68
+ end
69
+
70
+ #perform the supplied block on all rows in the data structure
71
+ def summarize(key, block)
72
+ summaries = rows.map { |r| r.summarize(key, &block) }
73
+ if summaries.any?
74
+ summaries[1..-1].inject(summaries[0]) do |summary, val|
75
+ block.call(summary, val)
76
+ end
77
+ else
78
+ nil
79
+ end
80
+ end
81
+
82
+ def summary_rows
83
+ data_summary = report_columns.map do |col|
84
+ if summarizer = @summary_options[col.key]
85
+ summarize(col.key, self.send(summarizer)) unless col.hidden?
86
+ end
87
+
88
+ end
89
+ [
90
+ Tabloid::HeaderRow.new("Totals", :column_count => visible_column_count),
91
+ Tabloid::Row.new(:columns => @report_columns,
92
+ :data => data_summary)
93
+
94
+ ]
95
+
96
+ end
97
+
98
+ def visible_column_count
99
+ @visible_col_count ||= @report_columns.select { |col| !col.hidden? }.count
100
+ end
101
+
102
+ def sum
103
+ proc(&:+)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,69 @@
1
+ class Tabloid::Group
2
+
3
+ attr_reader :rows
4
+ attr_reader :columns
5
+ attr_reader :label
6
+
7
+ def initialize(options)
8
+ @rows = options[:rows]
9
+ @columns = options[:columns]
10
+ @visible_column_count = @columns.select { |col| !col.hidden? }.count
11
+ @total_required = options[:total]
12
+ @label = options[:label]
13
+ raise ArgumentError.new("Must supply row data to a Group") unless @rows
14
+ end
15
+
16
+ def total_required?
17
+ !(@total_required.nil? || @total_required == false)
18
+ end
19
+
20
+ def rows
21
+ if total_required?
22
+ summed_data = columns.map { |col| col.total? ? sum_rows(col.key) : nil }
23
+ @rows + [Tabloid::Row.new(:data => summed_data, :columns => self.columns)]
24
+ else
25
+ @rows
26
+ end
27
+ end
28
+
29
+ def summarize(key, &block)
30
+ @rows[1..-1].inject(@rows[0].send(key)){|summary, row| block.call(summary, row.send(key)) }
31
+ end
32
+
33
+ def to_csv
34
+ header_row_csv + rows.map(&:to_csv).join
35
+ end
36
+
37
+ def to_html
38
+ header_row_html + rows.map(&:to_html).join
39
+ end
40
+
41
+ private
42
+ def sum_rows(key)
43
+ #use the initial value from the same set of addends to prevent type conflict
44
+ #like 0:Fixnum + 0:Money => Exception
45
+ return nil unless @rows && @rows.any?
46
+ @rows[1..-1].inject(@rows[0][key]) { |sum, row| sum + row[key] }
47
+ end
48
+
49
+ def header_row_csv
50
+ if @label
51
+ cols = [label]
52
+ (@visible_column_count-1).times{ cols << nil}
53
+ FasterCSV.generate{|csv| csv<<cols}
54
+ else
55
+ ""
56
+ end
57
+ end
58
+
59
+ def header_row_html
60
+ if @label
61
+ html = Builder::XmlMarkup.new
62
+ html.tr(:class => "group_header") do |tr|
63
+ tr.td(label, {"colspan" => @visible_column_count})
64
+ end
65
+ else
66
+ ""
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,31 @@
1
+ class Tabloid::HeaderRow
2
+ def initialize(text, options={})
3
+ @text = text
4
+ @options = options
5
+ end
6
+
7
+ def to_csv
8
+ FasterCSV.generate{|csv| csv << to_a}
9
+ end
10
+
11
+ def to_html(options={})
12
+ html = Builder::XmlMarkup.new
13
+ html.tr("class" => (options[:class] || "header")) do |tr|
14
+ tr.td(@text, "colspan" => column_count)
15
+ end
16
+ end
17
+
18
+ def to_a
19
+ [@text].fill(nil, 1, column_count-1)
20
+ end
21
+
22
+ def summarize
23
+ nil
24
+ end
25
+
26
+ private
27
+ def column_count
28
+ (@options[:column_count] || 1)
29
+ end
30
+
31
+ end
@@ -0,0 +1,3 @@
1
+ class Tabloid::MissingElementError < Exception
2
+ # To change this template use File | Settings | File Templates.
3
+ end
@@ -0,0 +1,5 @@
1
+ module Tabloid
2
+ class MissingParameterError < Exception
3
+
4
+ end
5
+ end
@@ -0,0 +1,15 @@
1
+ module Tabloid
2
+ class Parameter
3
+ attr_accessor :key, :label
4
+ def initialize(key, label = nil )
5
+ self.key = key
6
+ self.label = label || humanize(key.to_s)
7
+ end
8
+
9
+ private
10
+ def humanize(string)
11
+ "#{string.first.upcase}#{string[1..-1]}".gsub("_", " ")
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,228 @@
1
+ module Tabloid::Report
2
+
3
+ def self.included(base)
4
+ base.class_eval do
5
+ @report_parameters = []
6
+ @report_columns = []
7
+ @report_columns.extend Tabloid::ColumnExtensions
8
+ extend Tabloid::Report::ClassMethods
9
+ include Tabloid::Report::InstanceMethods
10
+ end
11
+ end
12
+
13
+ module ClassMethods
14
+ def parameter(*args)
15
+ @report_parameters << Tabloid::Parameter.new(*args)
16
+ end
17
+
18
+ def store_parameters(attribute)
19
+
20
+ end
21
+
22
+ def parameters
23
+ @report_parameters
24
+ end
25
+
26
+ def summary(summary_options = {})
27
+ @summary_options = summary_options
28
+ end
29
+
30
+ def report_columns
31
+ @report_columns
32
+ end
33
+
34
+ def cache_key(&block)
35
+ if block
36
+ @cache_block = block
37
+ end
38
+ end
39
+
40
+ def cache_key_block
41
+ @cache_block
42
+ end
43
+
44
+ def rows_block
45
+ @rows_block
46
+ end
47
+
48
+ def rows(*args, &block)
49
+ @rows_block = block
50
+ end
51
+
52
+ def element(key, label = "", options={})
53
+ @report_columns << Tabloid::ReportColumn.new(key, label, options)
54
+ end
55
+
56
+ def grouping(key, options = {})
57
+ @grouping_key = key
58
+ @grouping_options = options
59
+ end
60
+
61
+ def grouping_key
62
+ @grouping_key
63
+ end
64
+
65
+ def grouping_options
66
+ @grouping_options
67
+ end
68
+
69
+ def summary_options
70
+ @summary_options
71
+ end
72
+ end
73
+
74
+ module InstanceMethods
75
+
76
+ def prepare(options={})
77
+ before_prepare if self.respond_to?(:before_prepare)
78
+ @report_parameters = {}
79
+ parameters.each do |param|
80
+ value = options.delete param.key
81
+ raise Tabloid::MissingParameterError.new("Must supply :#{param.key} when creating the report") unless value
82
+ @report_parameters[param.key] = value
83
+ end
84
+ data
85
+ after_prepare if self.respond_to?(:after_prepare)
86
+
87
+ self
88
+ end
89
+
90
+ def report_columns
91
+ self.class.report_columns
92
+ end
93
+
94
+ def parameters
95
+ self.class.parameters
96
+ end
97
+
98
+ def parameter(key)
99
+ load_from_cache if Tabloid.cache_enabled?
100
+ @report_parameters[key] if @report_parameters
101
+ end
102
+
103
+ def data
104
+ load_from_cache if Tabloid.cache_enabled?
105
+ build_and_cache_data
106
+ @data
107
+ end
108
+
109
+ def to_html
110
+ "<table>#{data.to_html}</table>"
111
+ end
112
+
113
+ def to_csv
114
+ data.to_csv
115
+ end
116
+
117
+ def cache_key
118
+ @key ||= begin
119
+ if self.class.cache_key_block
120
+ self.instance_exec &self.class.cache_key_block
121
+ else
122
+ nil
123
+ end
124
+ end
125
+ end
126
+
127
+
128
+ private
129
+ def cache_data(data)
130
+ if Tabloid.cache_enabled?
131
+ raise Tabloid::MissingParameterError.new("Must supply a cache_key block when caching is enabled") unless self.class.cache_key_block
132
+
133
+ report_data = {
134
+ :parameters => @report_parameters,
135
+ :data => data
136
+ }
137
+
138
+ raise "Unable to cache data" unless cache_client.set(cache_key, YAML.dump(report_data))
139
+
140
+ end
141
+ data
142
+ end
143
+
144
+ def load_from_cache
145
+ if Tabloid.cache_enabled? && !@cached_data
146
+ @cached_data = read_from_cache
147
+ if @cached_data
148
+ @cached_data = YAML.load(@cached_data)
149
+ @data = @cached_data[:data]
150
+ @report_parameters = @cached_data[:parameters]
151
+ end
152
+ end
153
+ end
154
+
155
+
156
+ def cache_client
157
+ if Tabloid.cache_enabled?
158
+ server = Tabloid.cache_connection_options[:server] || 'localhost'
159
+ if Tabloid.cache_engine == :memcached
160
+ port = Tabloid.cache_connection_options[:port] || '11211'
161
+ @cache_client ||= Dalli::Client.new("#{server}:#{port}")
162
+ elsif Tabloid.cache_engine == :redis
163
+ port = Tabloid.cache_connection_options[:port] || '6379'
164
+ @cache_client ||= Redis.new(
165
+ :host => server,
166
+ :port => port)
167
+ end
168
+ end
169
+ end
170
+
171
+ def build_and_cache_data
172
+
173
+ @data ||= begin
174
+ report_data = Tabloid::Data.new(
175
+ :report_columns => self.report_columns,
176
+ :rows => prepare_data,
177
+ :grouping_key => grouping_key,
178
+ :grouping_options => grouping_options,
179
+ :summary => summary_options
180
+ )
181
+ cache_data(report_data)
182
+ report_data
183
+ end
184
+ end
185
+
186
+
187
+ def prepare_data
188
+ row_data = instance_exec(&self.class.rows_block)
189
+ #unless row_data.first.is_a? Array
190
+ # row_data.map! do |row|
191
+ # report_columns.map do |col|
192
+ # row.send(col.key).to_s
193
+ # end
194
+ # end
195
+ #end
196
+ row_data
197
+ end
198
+
199
+ def read_from_cache
200
+ cache_client.get(cache_key) if cache_client && cache_key
201
+ end
202
+
203
+ def grouping_options
204
+ self.class.grouping_options
205
+ end
206
+
207
+ def grouping_key
208
+ self.class.grouping_key
209
+ end
210
+
211
+ def summary_options
212
+ self.class.summary_options
213
+ end
214
+
215
+ def parameter_info_html
216
+ html = Builder::XmlMarkup.new
217
+ html = html.p("id" => "parameters") do |p|
218
+ parameters.each do |param|
219
+ p.div do |div|
220
+ div.span(param.label, "class" => "parameter_label")
221
+ div.span(parameter(param.key), "class" => "parameter_value")
222
+ end
223
+ end
224
+ end
225
+ html.to_s
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,31 @@
1
+ module Tabloid
2
+ class ReportColumn
3
+ attr_accessor :key
4
+ attr_accessor :label
5
+ attr_accessor :hidden
6
+
7
+ def initialize(key, label = "", options={})
8
+ self.key = key
9
+ self.label = label
10
+ @hidden = options[:hidden]
11
+ @total = options[:total]
12
+ end
13
+
14
+ def to_s
15
+ @key.to_s
16
+ end
17
+
18
+ def total?
19
+ @total
20
+ end
21
+
22
+ def hidden?
23
+ hidden
24
+ end
25
+
26
+ def to_header
27
+ return self.label if label
28
+ return self.key
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,65 @@
1
+ class Tabloid::Row
2
+ def initialize(*args)
3
+ options = args.pop
4
+ if args.first
5
+ @data = args.first
6
+ else
7
+ @data = options[:data]
8
+ end
9
+ raise "Must supply data to .new when creating a new Row" unless @data
10
+
11
+ @columns = options[:columns]
12
+ raise "Must supply column information when creating a new Row" unless @columns
13
+ end
14
+
15
+
16
+ def to_csv
17
+ FasterCSV.generate do |csv|
18
+ csv_array = []
19
+ @columns.each_with_index do |col, index|
20
+ next if col.hidden?
21
+ val = self[col.key]
22
+ csv_array << val
23
+ end
24
+ csv << csv_array
25
+ end
26
+ end
27
+
28
+ def to_html(options = {})
29
+ html = Builder::XmlMarkup.new
30
+ html.tr("class" => (options[:class] || "data")) do |tr|
31
+ @columns.each_with_index do |col, index|
32
+ tr.td(self[col.key], "class" => col.key) unless col.hidden?
33
+ end
34
+ end
35
+ end
36
+
37
+ def summarize(key, &block)
38
+ self[key]
39
+ end
40
+
41
+ def [](key)
42
+ if @data.is_a? Array
43
+ if key.is_a? Numeric
44
+ @data[key]
45
+ else
46
+ index = @columns.index{|col| col.key == key}
47
+ @data[index]
48
+ end
49
+ else
50
+ if key.is_a? Numeric
51
+ key = @columns[key].key
52
+ end
53
+ @data.send(key)
54
+ end
55
+ end
56
+
57
+ def method_missing(method, *args)
58
+ if @columns.detect{|col| col.key == method}
59
+ self[method]
60
+ else
61
+ super
62
+ end
63
+ end
64
+
65
+ end
@@ -0,0 +1,3 @@
1
+ module Tabloid
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,42 @@
1
+ require "spec_helper"
2
+
3
+ describe Tabloid::Data do
4
+ let(:columns) do
5
+ [
6
+ Tabloid::ReportColumn.new(:col1, "Column 1"),
7
+ Tabloid::ReportColumn.new(:col2, "Column 2")
8
+ ]
9
+ end
10
+ let(:rows) { [[1, 2], [3, 4]] }
11
+ describe "creation" do
12
+ it "works when rows and columns are provided" do
13
+ lambda { Tabloid::Data.new(:report_columns => columns, :rows => rows) }.should_not raise_error(ArgumentError)
14
+
15
+ end
16
+ it "requires row data" do
17
+ lambda { Tabloid::Data.new(:report_columns => columns) }.should raise_error(ArgumentError, "Must supply row data")
18
+ end
19
+ it "requires column data" do
20
+ lambda { Tabloid::Data.new(:rows => rows) }.should raise_error(ArgumentError, "Must supply column data")
21
+ end
22
+
23
+ it "puts rows into groups" do
24
+ data = Tabloid::Data.new(:report_columns => columns, :rows => rows, :grouping_key => :col1)
25
+ data.rows.first.should be_a(Tabloid::Group)
26
+ end
27
+
28
+ describe "summary" do
29
+ let(:data){ Tabloid::Data.new(:report_columns => columns, :rows => rows, :summary => { :col2 => :sum } )}
30
+ it "adds a totals row to the csv output" do
31
+ csv_rows = FasterCSV.parse(data.to_csv)
32
+ csv_rows.should include(["Totals", nil])
33
+ csv_rows.should include([nil, "6"])
34
+ end
35
+ it "adds a totals row to the html output" do
36
+ doc = Nokogiri::HTML(data.to_html)
37
+ (doc/"tr.summary").should_not be_nil
38
+ (doc/"tr.summary td.col2").text.should == "6"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,72 @@
1
+ require "spec_helper"
2
+
3
+ describe Tabloid::Group do
4
+ let(:columns) do
5
+ [
6
+ Tabloid::ReportColumn.new(:col1, "Column 1"),
7
+ Tabloid::ReportColumn.new(:col2, "Column 2")
8
+ ]
9
+ end
10
+ let(:row1) { Tabloid::Row.new(:columns => columns, :data => [1, 2]) }
11
+ let(:row2) { Tabloid::Row.new(:columns => columns, :data => [3, 4]) }
12
+ let(:group) { Tabloid::Group.new(:rows =>[row1, row2], :columns => columns, :label => "foobar") }
13
+ let(:anon_group) { Tabloid::Group.new(:rows =>[row1, row2], :columns => columns, :label => false) }
14
+
15
+ it "has a label" do
16
+ group.label.should == "foobar"
17
+ end
18
+
19
+ describe "producing output" do
20
+ describe "as CSV" do
21
+ let(:rows) { FasterCSV.parse(group.to_csv) }
22
+ it "includes all rows for the group" do
23
+ rows.should include(["1", "2"])
24
+ rows.should include(["3", "4"])
25
+ end
26
+ it "includes a group label row" do
27
+ rows.should include(["foobar", nil])
28
+ end
29
+ it "doesn't include a label row when label is falsey" do
30
+ rows = FasterCSV.parse(anon_group.to_csv)
31
+ rows.should_not include(["foobar", nil])
32
+ end
33
+ end
34
+ describe "as html" do
35
+ let(:doc) { doc = Nokogiri::HTML(group.to_html)
36
+ }
37
+ it "creates a table row for each data row" do
38
+ (doc/"tr[class='data']").count.should == 2
39
+ end
40
+
41
+ it "includes a group label row" do
42
+ (doc/"tr[class = 'group_header']")[0].text.should == "foobar"
43
+ end
44
+ it "doesn't include a label row when label is false" do
45
+ doc = Nokogiri::HTML(anon_group.to_html)
46
+ (doc/"tr[class='group_header']").count.should == 0
47
+ end
48
+ end
49
+
50
+ describe "#summarize" do
51
+ it "performs the supplied operation on the indicated column" do
52
+ group.summarize(:col1, &:+).should == 4
53
+ group.summarize(:col2, &:+).should == 6
54
+ end
55
+ end
56
+ context "with totals enabled" do
57
+ describe "#rows" do
58
+ it "includes a total row" do
59
+ columns = [
60
+ Tabloid::ReportColumn.new(:col1, "Column 1", :total => true),
61
+ Tabloid::ReportColumn.new(:col2, "Column 2", :total => true)
62
+ ]
63
+ total_group = Tabloid::Group.new(:rows =>[row1, row2], :columns => columns, :total => true)
64
+ rows = total_group.rows
65
+ rows.count.should == 3
66
+ rows.last[:col1].should == 4
67
+ rows.last[:col2].should == 6
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,8 @@
1
+ require 'spec_helper'
2
+
3
+ describe Tabloid::Row do
4
+ let(:header){Tabloid::HeaderRow.new("testtext", :column_count => 3)}
5
+ it "has the correct number of columns" do
6
+ header.to_a.should == ["testtext", nil, nil]
7
+ end
8
+ end
@@ -0,0 +1,157 @@
1
+ require "spec_helper"
2
+
3
+ describe Tabloid::Report do
4
+
5
+ context "producing output" do
6
+ class CsvReport
7
+ DATA=[
8
+ [1, 2],
9
+ [3, 4]
10
+ ]
11
+ include Tabloid::Report
12
+
13
+ parameter :param1, "TestParameter"
14
+
15
+ element :col1, 'Col1'
16
+ element :col2, 'Col2'
17
+
18
+ cache_key{'report'}
19
+
20
+ rows do
21
+ CsvReport::DATA
22
+ end
23
+ end
24
+
25
+ before do
26
+ @report = CsvReport.new
27
+ @report.prepare(:param1 => "foobar")
28
+ end
29
+
30
+ context "with memcached caching" do
31
+ before do
32
+ Tabloid.cache_engine = :memcached
33
+ Tabloid.cache_connection_options = {
34
+ :server => "localhost",
35
+ :port => "11211"
36
+ }
37
+ end
38
+ after do
39
+ dc = Dalli::Client.new
40
+ dc.set("report", nil)
41
+ Tabloid.cache_engine = nil
42
+ end
43
+
44
+ describe "#data" do
45
+ it "should cache after collecting the data" do
46
+ Dalli::Client.any_instance.stub(:get).and_return(nil)
47
+ Dalli::Client.any_instance.should_receive(:set).with('report', anything).and_return(true)
48
+ @report.data
49
+ end
50
+
51
+ it "should return the cached data if it exists" do
52
+ Dalli::Client.any_instance.stub(:get).with('report').and_return(YAML.dump(@report.data))
53
+ Dalli::Client.any_instance.stub(:set).and_return(true)
54
+
55
+ @report.data.rows.should_not be_nil
56
+ end
57
+ end
58
+ end
59
+
60
+ describe "#to_csv" do
61
+ it "includes headers by default" do
62
+ csv_output = FasterCSV.parse(@report.to_csv)
63
+ headers = csv_output.first
64
+ headers.first.should match(/Col1/)
65
+ headers.last.should match(/Col2/)
66
+ end
67
+
68
+ it "includes the data from the report" do
69
+ csv_output = FasterCSV.parse(@report.to_csv)
70
+ csv_output.should include( ['1', '2'])
71
+ csv_output.should include( ['3', '4'])
72
+ end
73
+ end
74
+
75
+ describe "#to_html" do
76
+ let(:doc){Nokogiri::HTML(@report.to_html)}
77
+ it "creates a table" do
78
+ (doc/"table").count.should == 1
79
+ end
80
+
81
+ it "includes parameter information" do
82
+ pending("Need to put parameter block in the report")
83
+ (doc/".parameter_label").text.should include("TestParameter")
84
+ (doc/".parameter_value").text.should include("foobar")
85
+ end
86
+ end
87
+ end
88
+
89
+
90
+ describe "#element" do
91
+ class ElementTestReport
92
+ include Tabloid::Report
93
+ element :col1
94
+ rows do
95
+ [[1,2]]
96
+ end
97
+ end
98
+
99
+ before do
100
+ @report = ElementTestReport.new
101
+ end
102
+
103
+ it "adds a column to the report data" do
104
+ @report.data.report_columns[:col1].should_not be_nil
105
+ @report.data.report_columns[0].key.to_s.should == "col1"
106
+ end
107
+
108
+ end
109
+
110
+ describe "grouping" do
111
+ class GroupingTest
112
+ include Tabloid::Report
113
+ element :col1, "Col 1"
114
+ element :col2, "Col 2"
115
+ grouping :col1
116
+
117
+ rows do
118
+ [
119
+ [1,2,3],
120
+ [1,4,5]
121
+ ]
122
+ end
123
+ end
124
+
125
+ it "groups data by column specified" do
126
+ report = GroupingTest.new
127
+ data = FasterCSV.parse(report.to_csv)
128
+ data.should include(['1',nil])
129
+ end
130
+ end
131
+
132
+ describe "#parameter" do
133
+ class ParameterTestReport
134
+ attr_accessor :parameter_stash
135
+
136
+ include Tabloid::Report
137
+ parameter :test_param
138
+ cache_key {"key"}
139
+
140
+ element :col1, "Column 1"
141
+ rows do
142
+ [[parameter(:test_param)]]
143
+ end
144
+ end
145
+
146
+ it "requires a parameter in the initializer" do
147
+ expect{ ParameterTestReport.new.prepare}.should raise_error(Tabloid::MissingParameterError, "Must supply :test_param when creating the report")
148
+ end
149
+
150
+ it "makes the parameter available in the report" do
151
+ report = ParameterTestReport.new.prepare(:test_param => "supercalifragilisticexpialidocious")
152
+ report.to_html.should match(/supercalifragilisticexpialidocious/)
153
+ end
154
+ end
155
+
156
+ end
157
+
@@ -0,0 +1,67 @@
1
+ require "spec_helper"
2
+
3
+ describe Tabloid::Row do
4
+ let(:columns) { [
5
+ Tabloid::ReportColumn.new(:col1, "Column 1", :hidden => true),
6
+ Tabloid::ReportColumn.new(:col2, "Column 2")
7
+ ] }
8
+ let(:data) { [1, 2] }
9
+ before do
10
+ @row = Tabloid::Row.new(
11
+ :columns => columns,
12
+ :data => [1, 2]
13
+ )
14
+ end
15
+ context "producing output" do
16
+ describe "#to_csv" do
17
+ it "includes visible columns" do
18
+ rows = FasterCSV.parse(@row.to_csv)
19
+ rows.first.should include("2")
20
+ end
21
+ it "does not include hidden columns" do
22
+ rows = FasterCSV.parse(@row.to_csv)
23
+ rows.first.should_not include("1")
24
+ end
25
+ end
26
+
27
+ describe "#to_html" do
28
+ before do
29
+ @doc = Nokogiri::HTML(@row.to_html)
30
+ end
31
+ it "should have a single row" do
32
+ (@doc / "tr").count.should == 1
33
+ end
34
+ it "should have classes on the columns" do
35
+ (@doc / "td[class='col2']").count.should == 1
36
+ end
37
+ it "should not include hidden columns" do
38
+ (@doc / "td[class='col1']").count.should == 0
39
+
40
+ end
41
+ end
42
+
43
+
44
+ end
45
+ context "with array data" do
46
+ describe "accessing contents with []" do
47
+ it "allows numeric access" do
48
+ @row[0].should == 1
49
+ end
50
+ it "allows access by element key" do
51
+ @row[:col1].should == 1
52
+ end
53
+ end
54
+ end
55
+ context "with object data" do
56
+ let(:data){OpenStruct.new({:col1 => 1, :col2 => 2})}
57
+ let(:row) { Tabloid::Row.new(:columns => columns, :data => data) }
58
+ describe "accessing contents with []" do
59
+ it "allows numeric access" do
60
+ row[0].should == 1
61
+ end
62
+ it "allows access by element key" do
63
+ row[:col1].should == 1
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,6 @@
1
+ require 'tabloid'
2
+ require 'dalli'
3
+
4
+ require 'ostruct'
5
+ require 'nokogiri'
6
+ require 'fastercsv'
@@ -0,0 +1,26 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "tabloid/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "tabloid"
7
+ s.version = Tabloid::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Keith Gaddis"]
10
+ s.email = ["keith.gaddis@gmail.com"]
11
+ s.homepage = "http://github.com/Inductive/tabloid"
12
+ s.summary = %q{ Tabloid allows the creation of cacheable report data using a straightforward DSL and output to HTML, CSV, and more to come.}
13
+ s.description = %q{ Tabloid allows the creation of cacheable report data using a straightforward DSL and output to HTML, CSV, and more to come.}
14
+
15
+ s.rubyforge_project = "tabloid"
16
+
17
+ s.files = `git ls-files`.split("\n")
18
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20
+ s.require_paths = ["lib"]
21
+ s.add_development_dependency "rspec"
22
+ s.add_development_dependency "nokogiri"
23
+ s.add_development_dependency "dalli"
24
+ s.add_runtime_dependency "fastercsv"
25
+ s.add_runtime_dependency "builder"
26
+ end
metadata ADDED
@@ -0,0 +1,164 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tabloid
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Keith Gaddis
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-10-03 00:00:00 Z
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: rspec
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ none: false
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ hash: 3
29
+ segments:
30
+ - 0
31
+ version: "0"
32
+ type: :development
33
+ version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: nokogiri
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ none: false
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ hash: 3
43
+ segments:
44
+ - 0
45
+ version: "0"
46
+ type: :development
47
+ version_requirements: *id002
48
+ - !ruby/object:Gem::Dependency
49
+ name: dalli
50
+ prerelease: false
51
+ requirement: &id003 !ruby/object:Gem::Requirement
52
+ none: false
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ hash: 3
57
+ segments:
58
+ - 0
59
+ version: "0"
60
+ type: :development
61
+ version_requirements: *id003
62
+ - !ruby/object:Gem::Dependency
63
+ name: fastercsv
64
+ prerelease: false
65
+ requirement: &id004 !ruby/object:Gem::Requirement
66
+ none: false
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ hash: 3
71
+ segments:
72
+ - 0
73
+ version: "0"
74
+ type: :runtime
75
+ version_requirements: *id004
76
+ - !ruby/object:Gem::Dependency
77
+ name: builder
78
+ prerelease: false
79
+ requirement: &id005 !ruby/object:Gem::Requirement
80
+ none: false
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ hash: 3
85
+ segments:
86
+ - 0
87
+ version: "0"
88
+ type: :runtime
89
+ version_requirements: *id005
90
+ description: " Tabloid allows the creation of cacheable report data using a straightforward DSL and output to HTML, CSV, and more to come."
91
+ email:
92
+ - keith.gaddis@gmail.com
93
+ executables: []
94
+
95
+ extensions: []
96
+
97
+ extra_rdoc_files: []
98
+
99
+ files:
100
+ - .gitignore
101
+ - Gemfile
102
+ - LICENSE.txt
103
+ - README.txt
104
+ - Rakefile
105
+ - lib/tabloid.rb
106
+ - lib/tabloid/column_extensions.rb
107
+ - lib/tabloid/configuration.rb
108
+ - lib/tabloid/data.rb
109
+ - lib/tabloid/group.rb
110
+ - lib/tabloid/header_row.rb
111
+ - lib/tabloid/missing_element_error.rb
112
+ - lib/tabloid/missing_parameter_error.rb
113
+ - lib/tabloid/parameter.rb
114
+ - lib/tabloid/report.rb
115
+ - lib/tabloid/report_column.rb
116
+ - lib/tabloid/row.rb
117
+ - lib/tabloid/version.rb
118
+ - spec/lib/tabloid/data_spec.rb
119
+ - spec/lib/tabloid/group_spec.rb
120
+ - spec/lib/tabloid/header_row_spec.rb
121
+ - spec/lib/tabloid/report_spec.rb
122
+ - spec/lib/tabloid/row_spec.rb
123
+ - spec/spec_helper.rb
124
+ - tabloid.gemspec
125
+ homepage: http://github.com/Inductive/tabloid
126
+ licenses: []
127
+
128
+ post_install_message:
129
+ rdoc_options: []
130
+
131
+ require_paths:
132
+ - lib
133
+ required_ruby_version: !ruby/object:Gem::Requirement
134
+ none: false
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ hash: 3
139
+ segments:
140
+ - 0
141
+ version: "0"
142
+ required_rubygems_version: !ruby/object:Gem::Requirement
143
+ none: false
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ hash: 3
148
+ segments:
149
+ - 0
150
+ version: "0"
151
+ requirements: []
152
+
153
+ rubyforge_project: tabloid
154
+ rubygems_version: 1.8.6
155
+ signing_key:
156
+ specification_version: 3
157
+ summary: Tabloid allows the creation of cacheable report data using a straightforward DSL and output to HTML, CSV, and more to come.
158
+ test_files:
159
+ - spec/lib/tabloid/data_spec.rb
160
+ - spec/lib/tabloid/group_spec.rb
161
+ - spec/lib/tabloid/header_row_spec.rb
162
+ - spec/lib/tabloid/report_spec.rb
163
+ - spec/lib/tabloid/row_spec.rb
164
+ - spec/spec_helper.rb