dossier 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (85) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.markdown +181 -0
  3. data/Rakefile +27 -0
  4. data/app/assets/javascripts/dossier/application.js +15 -0
  5. data/app/assets/stylesheets/dossier/application.css +13 -0
  6. data/app/controllers/dossier/application_controller.rb +4 -0
  7. data/app/controllers/dossier/reports_controller.rb +34 -0
  8. data/app/helpers/dossier/application_helper.rb +4 -0
  9. data/app/views/dossier/reports/show.html.haml +21 -0
  10. data/config/routes.rb +5 -0
  11. data/lib/dossier.rb +31 -0
  12. data/lib/dossier/adapter/active_record.rb +40 -0
  13. data/lib/dossier/adapter/active_record/result.rb +24 -0
  14. data/lib/dossier/client.rb +52 -0
  15. data/lib/dossier/configuration.rb +28 -0
  16. data/lib/dossier/engine.rb +7 -0
  17. data/lib/dossier/formatter.rb +33 -0
  18. data/lib/dossier/query.rb +30 -0
  19. data/lib/dossier/report.rb +69 -0
  20. data/lib/dossier/result.rb +67 -0
  21. data/lib/dossier/stream_csv.rb +24 -0
  22. data/lib/dossier/version.rb +3 -0
  23. data/lib/tasks/dossier_tasks.rake +4 -0
  24. data/spec/dossier/adapter/active_record/result_spec.rb +31 -0
  25. data/spec/dossier/adapter/active_record_spec.rb +54 -0
  26. data/spec/dossier/client_spec.rb +109 -0
  27. data/spec/dossier/configuration_spec.rb +35 -0
  28. data/spec/dossier/formatter_spec.rb +39 -0
  29. data/spec/dossier/query_spec.rb +59 -0
  30. data/spec/dossier/report_spec.rb +67 -0
  31. data/spec/dossier/result_spec.rb +119 -0
  32. data/spec/dossier_spec.rb +31 -0
  33. data/spec/dummy/README.rdoc +261 -0
  34. data/spec/dummy/Rakefile +7 -0
  35. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  36. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  37. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  38. data/spec/dummy/app/controllers/site_controller.rb +5 -0
  39. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  40. data/spec/dummy/app/views/dossier/reports/suspended_employee.html.haml +1 -0
  41. data/spec/dummy/app/views/dossier/reports/total.html.haml +11 -0
  42. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  43. data/spec/dummy/config.ru +4 -0
  44. data/spec/dummy/config/application.rb +56 -0
  45. data/spec/dummy/config/boot.rb +10 -0
  46. data/spec/dummy/config/database.yml.example +13 -0
  47. data/spec/dummy/config/environment.rb +9 -0
  48. data/spec/dummy/config/environments/development.rb +37 -0
  49. data/spec/dummy/config/environments/production.rb +67 -0
  50. data/spec/dummy/config/environments/test.rb +37 -0
  51. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  52. data/spec/dummy/config/initializers/inflections.rb +15 -0
  53. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  54. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  55. data/spec/dummy/config/initializers/session_store.rb +8 -0
  56. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  57. data/spec/dummy/config/locales/en.yml +5 -0
  58. data/spec/dummy/config/routes.rb +5 -0
  59. data/spec/dummy/config/setup_load_paths.rb +15 -0
  60. data/spec/dummy/db/schema.rb +24 -0
  61. data/spec/dummy/dossier_test +0 -0
  62. data/spec/dummy/public/404.html +26 -0
  63. data/spec/dummy/public/422.html +26 -0
  64. data/spec/dummy/public/500.html +25 -0
  65. data/spec/dummy/public/favicon.ico +0 -0
  66. data/spec/dummy/script/rails +6 -0
  67. data/spec/fixtures/customized_employee_report.html +38 -0
  68. data/spec/fixtures/db/mysql2.yml.example +4 -0
  69. data/spec/fixtures/db/sqlite3.yml.example +2 -0
  70. data/spec/fixtures/employee_report.csv +4 -0
  71. data/spec/fixtures/employee_report.html +54 -0
  72. data/spec/fixtures/employee_report_with_footer.html +56 -0
  73. data/spec/fixtures/employee_with_custom_client.html +54 -0
  74. data/spec/requests/employee_report_spec.rb +52 -0
  75. data/spec/requests/employee_with_custom_client_spec.rb +13 -0
  76. data/spec/routing/dossier_routes_spec.rb +11 -0
  77. data/spec/spec_helper.rb +36 -0
  78. data/spec/support/factory.rb +86 -0
  79. data/spec/support/reports/employee_report.rb +78 -0
  80. data/spec/support/reports/employee_with_custom_client.rb +10 -0
  81. data/spec/support/reports/sqlite_employee_report.rb +15 -0
  82. data/spec/support/reports/supended_employee_report.rb +7 -0
  83. data/spec/support/reports/test_report.rb +4 -0
  84. data/spec/support/reports/total_report.rb +35 -0
  85. metadata +361 -0
@@ -0,0 +1,20 @@
1
+ Copyright 2012 YOURNAME
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,181 @@
1
+ # Dossier
2
+
3
+ Dossier is a Rails engine that turns SQL into reports. Reports can be easily rendered in various formats, like HTML, CSV, and JSON. The SQL can be written by hand or generated by another tool, like ActiveRecord.
4
+
5
+ ## Setup
6
+
7
+ Install the Dossier gem and create `config/dossier.yml`. This has the same format as Rails' `database.yml`, and can actually just be a symlink: `ln -s config/{database,dossier}.yml`.
8
+
9
+ ## Routing
10
+
11
+ Dossier will add a route to your app so that `reports/fancy_ketchup` will instantiate and run a `FancyKetchupReport`. It will respond with whatever format was requested; for example `reports/fancy_ketchup.csv` will render the results as CSV.
12
+
13
+ ## Basic Reports
14
+
15
+ In your app, create report classes under `app/reports`, with `Report` as the end of the class name. Define a `sql` method that returns the sql string to be sent to the database.
16
+
17
+ For example:
18
+
19
+ ```ruby
20
+ # app/reports/fancy_ketchup_report.rb
21
+ class FancyKetchupReport < Dossier::Report
22
+ def sql
23
+ 'SELECT * FROM ketchups WHERE fancy = true'
24
+ end
25
+
26
+ # Or, if you're using ActiveRecord and hate writing SQL:
27
+ def sql
28
+ Ketchup.where(fancy: true).to_sql
29
+ end
30
+
31
+ end
32
+ ```
33
+
34
+ If you need dynamic values that may be influenced by the user, [do not interpolate them directly](http://xkcd.com/327/). Dossier provides a safer way to add them: any symbols in the query will be replaced by calling methods of the same name in the report. Return values other than numerics will be coerced to strings and **escaped by the database**.
35
+
36
+ ```ruby
37
+ # app/reports/fancy_ketchup_report.rb
38
+ class FancyKetchupReport < Dossier::Report
39
+ def sql
40
+ 'SELECT * FROM ketchups WHERE brand = :brand'
41
+ end
42
+
43
+ def brand
44
+ 'Acme'
45
+ end
46
+ end
47
+ ```
48
+
49
+ ## Column Formatting
50
+
51
+ You can format any values in your results by defining a `format_` method for that column on your report class. For instance, to reverse the names of your employees:
52
+
53
+ ```ruby
54
+ class EmployeeReport < Dossier::Report
55
+ # ...
56
+ def format_name(value)
57
+ value.reverse
58
+ end
59
+ end
60
+ ```
61
+
62
+ Dossier also provides a `formatter` with access to all the standard Rails formatters. So to format all values in the `payment` column as currency, you could do:
63
+
64
+ ```ruby
65
+ class MoneyLaunderingReport < Dossier::Report
66
+ #...
67
+ def format_payment(value)
68
+ formatter.number_to_currency
69
+ end
70
+ end
71
+ ```
72
+
73
+ In addition, the formatter provides Rails' URL helpers for use in your reports. For example, in a report of your least profitable accounts, you might want to add a link to change the salesperson assigned to that account.
74
+
75
+ ```ruby
76
+ formatter.url_helpers.edit_accounts_path(3)
77
+ ```
78
+
79
+ The built-in `ReportsController` uses this formatting when rendering the HTML and JSON representations, but not when rendering the CSV.
80
+
81
+ ## Report Options and Footers
82
+
83
+ You may want to specify parameters for a report: which columns to show, a range of dates, etc. Dossier supports this via URL parameters, which will be passed into your report's `initialize` method and made available via the `options` reader.
84
+
85
+ You can pass these options by hardcoding them into a link, or you can allow users to customize a report with a form. For example:
86
+
87
+ ```ruby
88
+ # app/views/dossier/reports/employee.html.haml
89
+
90
+ = form_for report, as: :options, url: url_for, html: {method: :get} do |f|
91
+
92
+ = f.label "Salary greater than:"
93
+ = f.text_field :salary_greater_than
94
+ = f.label "In Division:"
95
+ = f.select_tag :in_division, divisions_collection
96
+ = f.button "Submit"
97
+
98
+ = render template: 'dossier/dossier/reports/show', locals: {report: report}
99
+ ```
100
+
101
+ It's up to you to use these options in generating your SQL query.
102
+
103
+ However, Dossier does support one URL parameter natively: if you supply a `footer` parameter with an integer value, the last N rows will be accesible via `report.results.footers` instead of `report.results.body`. The built-in `show` view renders those rows inside an HTML footer. This is an easy way to display a totals row or something similar.
104
+
105
+ ## Additional View Customization
106
+
107
+ To further customize your results view, provide your own `app/views/dossier/reports/show`.
108
+
109
+ ## Callbacks
110
+
111
+ To produce report results, Dossier builds your query and executes it in separate steps. It uses [ActiveSupport::Callbacks](http://api.rubyonrails.org/classes/ActiveSupport/Callbacks.html) to define callbacks for `build_query` and `execute`. Therefore, you may provide callbacks similar to these:
112
+
113
+ ```ruby
114
+ set_callback :build_query, :before, :run_my_stored_procedure
115
+ set_callback :execute, :after do
116
+ mangle_results
117
+ end
118
+ ```
119
+
120
+ ## Using Reports Outside of Dossier::ReportsController
121
+
122
+ ### With Other Controllers
123
+
124
+ You can use Dossier reports in your own controllers and views. For example, if you wanted to render two reports on a page with other information, you might do this in a controller:
125
+
126
+ ```ruby
127
+ class ProjectsController < ApplicationController
128
+
129
+ def show
130
+ @project = Project.find(params[:id])
131
+ @project_status_report = ProjectStatusReport.new(project: @project)
132
+ @project_revenue_report = ProjectRevenueReport.new(project: @project, grouped: 'monthly')
133
+ end
134
+ end
135
+ ```
136
+
137
+ ```haml
138
+ .span6
139
+ = render template: 'dossier/reports/show', locals: {report: @project_status_report.run}
140
+ .span6
141
+ = render template: 'dossier/reports/show', locals: {report: @project_revenue_report.run}
142
+ ```
143
+
144
+ ### Dossier for APIs
145
+
146
+ ```ruby
147
+ class API::ProjectsController < Api::ApplicationController
148
+
149
+ def snapshot
150
+ render json: ProjectStatusReport.new(project: @project).results.hashes
151
+ end
152
+ end
153
+ ```
154
+
155
+ ## Advanced Usage
156
+
157
+ To see a report with all the bells and whistles, check out `spec/support/reports/employee_report.rb`.
158
+
159
+ ## Running the Tests
160
+
161
+ Note: when you run the tests, Dossier will **make and/or truncate** some tables in the `dossier_test` database.
162
+
163
+ - Run `bundle`
164
+ - `cp spec/dummy/config/database.yml{.example,}` and edit it so that it can connect to the test database.
165
+ - `rspec spec`
166
+
167
+ ## TODO
168
+
169
+ ### Features
170
+
171
+ - Support more databases
172
+
173
+ ### Moar Dokumentationz pleaze
174
+
175
+ - Document using hooks and what methods are available in them
176
+ - callbacks
177
+ - stored procedures
178
+ - reformat results
179
+ - linking to reports
180
+ - linking to formats
181
+ - Extending the formatter
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+ begin
8
+ require 'rdoc/task'
9
+ rescue LoadError
10
+ require 'rdoc/rdoc'
11
+ require 'rake/rdoctask'
12
+ RDoc::Task = Rake::RDocTask
13
+ end
14
+
15
+ RDoc::Task.new(:rdoc) do |rdoc|
16
+ rdoc.rdoc_dir = 'rdoc'
17
+ rdoc.title = 'Dossier'
18
+ rdoc.options << '--line-numbers'
19
+ rdoc.rdoc_files.include('README.rdoc')
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+ APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
24
+ load 'rails/tasks/engine.rake'
25
+
26
+ Bundler::GemHelper.install_tasks
27
+
@@ -0,0 +1,15 @@
1
+ // This is a manifest file that'll be compiled into application.js, which will include all the files
2
+ // listed below.
3
+ //
4
+ // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5
+ // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path.
6
+ //
7
+ // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8
+ // the compiled file.
9
+ //
10
+ // WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD
11
+ // GO AFTER THE REQUIRES BELOW.
12
+ //
13
+ //= require jquery
14
+ //= require jquery_ujs
15
+ //= require_tree .
@@ -0,0 +1,13 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the top of the
9
+ * compiled file, but it's generally better to create a new file per style scope.
10
+ *
11
+ *= require_self
12
+ *= require_tree .
13
+ */
@@ -0,0 +1,4 @@
1
+ module Dossier
2
+ class ApplicationController < ::ApplicationController
3
+ end
4
+ end
@@ -0,0 +1,34 @@
1
+ module Dossier
2
+ class ReportsController < ApplicationController
3
+ def show
4
+ report = report_class.new(params[:options] || {})
5
+ report.run
6
+
7
+ respond_to do |format|
8
+ format.html do
9
+ begin
10
+ render template: "dossier/reports/#{report.view}", locals: {report: report}
11
+ rescue ActionView::MissingTemplate => e
12
+ render template: 'dossier/reports/show', locals: {report: report}
13
+ end
14
+ end
15
+
16
+ format.json do
17
+ render :json => report.results.hashes
18
+ end
19
+
20
+ format.csv do
21
+ headers["Content-Disposition"] = %[attachment;filename=#{params[:report]}-report_#{Time.now.strftime('%m-%d-%Y-%H%M%S')}.csv]
22
+ self.response_body = StreamCSV.new(report.raw_results.arrays)
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def report_class
30
+ "#{params[:report].classify}Report".constantize
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,4 @@
1
+ module Dossier
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,21 @@
1
+ %h1= report.class.name.titleize
2
+
3
+ %table
4
+ %thead
5
+ %tr
6
+ - report.results.headers.each do |header|
7
+ %th= Dossier::Formatter.titleize(header)
8
+ %tbody
9
+ - report.results.body.each do |row|
10
+ %tr
11
+ - row.each do |value|
12
+ %td= value
13
+
14
+ - if report.results.footers.any?
15
+ %tfoot
16
+ - report.results.footers.each do |row|
17
+ %tr
18
+ - row.each do |value|
19
+ %th= value
20
+
21
+ = link_to 'Download CSV', dossier_report_path(:format => 'csv', :options => params[:options]), :class => 'download-csv'
@@ -0,0 +1,5 @@
1
+ Rails.application.routes.draw do
2
+
3
+ match "reports/:report", :to => 'dossier/reports#show', :as => :dossier_report
4
+
5
+ end
@@ -0,0 +1,31 @@
1
+ require "dossier/engine"
2
+ require "dossier/version"
3
+
4
+ module Dossier
5
+
6
+ def self.configuration
7
+ @configuration || configure
8
+ end
9
+
10
+ def self.configure
11
+ @configuration = Configuration.new
12
+ yield(@configuration) if block_given?
13
+ @configuration
14
+ end
15
+
16
+ def self.client
17
+ configuration.client
18
+ end
19
+
20
+ class ExecuteError < StandardError; end
21
+ end
22
+
23
+ require "dossier/adapter/active_record"
24
+ require "dossier/adapter/active_record/result"
25
+ require "dossier/client"
26
+ require "dossier/configuration"
27
+ require "dossier/formatter"
28
+ require "dossier/query"
29
+ require "dossier/report"
30
+ require "dossier/result"
31
+ require "dossier/stream_csv"
@@ -0,0 +1,40 @@
1
+ module Dossier
2
+ module Adapter
3
+ class ActiveRecord
4
+
5
+ attr_accessor :options, :connection
6
+
7
+ def initialize(options = {})
8
+ self.options = options
9
+ self.connection = options.delete(:connection) || active_record_connection
10
+ end
11
+
12
+ def escape(value)
13
+ connection.quote(value)
14
+ end
15
+
16
+ def execute(query, report_name = nil)
17
+ Result.new(connection.exec_query(*[query, report_name].compact))
18
+ rescue => e
19
+ raise Dossier::ExecuteError.new "#{e.message}\n\n#{query}"
20
+ end
21
+
22
+ private
23
+
24
+ def active_record_connection
25
+ @abstract_class = Class.new(::ActiveRecord::Base) do
26
+ self.abstract_class = true
27
+
28
+ # Needs a unique name for ActiveRecord's connection pool
29
+ def self.name
30
+ "Dossier::Adapter::ActiveRecord::Connection_#{object_id}"
31
+ end
32
+ end
33
+ @abstract_class.establish_connection(options)
34
+ @abstract_class.connection
35
+ end
36
+
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,24 @@
1
+ module Dossier
2
+ module Adapter
3
+ class ActiveRecord
4
+ class Result
5
+
6
+ attr_accessor :result
7
+
8
+ def initialize(activerecord_result)
9
+ self.result = activerecord_result
10
+ end
11
+
12
+ def headers
13
+ result.columns
14
+ end
15
+
16
+ def rows
17
+ result.rows
18
+ end
19
+
20
+ end
21
+ end
22
+
23
+ end
24
+ end
@@ -0,0 +1,52 @@
1
+ module Dossier
2
+ class Client
3
+
4
+ attr_accessor :adapter, :options
5
+
6
+ delegate :escape, :execute, to: :adapter
7
+
8
+ def initialize(options)
9
+ self.options = options.symbolize_keys
10
+ self.adapter = dossier_adapter.new(self.options.except(:dossier_adapter))
11
+ end
12
+
13
+ def dossier_adapter
14
+ adapter_name = options.fetch(:dossier_adapter) { determine_adapter_name }
15
+ "Dossier::Adapter::#{adapter_name.classify}".constantize
16
+ end
17
+
18
+ private
19
+
20
+ def determine_adapter_name
21
+ if options.has_key?(:connection)
22
+ namespace_for(options[:connection].class)
23
+ else
24
+ guess_adapter_name
25
+ end
26
+ end
27
+
28
+ def namespace_for(klass)
29
+ klass.name.split('::').first.underscore
30
+ end
31
+
32
+ def guess_adapter_name
33
+ return namespace_for(loaded_orms.first) if loaded_orms.length == 1
34
+
35
+ message = <<-Must_be_one_of_them_newfangled_ones.strip_heredoc
36
+ You didn't specify a dossier_adapter. If you had exactly one
37
+ ORM loaded that Dossier knew about, it would try to choose an
38
+ appropriate adapter, but you have #{loaded_orms.length}.
39
+ Must_be_one_of_them_newfangled_ones
40
+ message << "Specifically, Dossier found #{loaded_orms.join(', ')}" if loaded_orms.any?
41
+ raise IndeterminableAdapter.new(message)
42
+ end
43
+
44
+ def loaded_orms
45
+ [].tap do |loaded_orms|
46
+ loaded_orms << ActiveRecord::Base if defined?(ActiveRecord)
47
+ end
48
+ end
49
+
50
+ class IndeterminableAdapter < StandardError; end
51
+ end
52
+ end