├── .gitignore
├── .rspec
├── .travis.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Gemfile
├── MIT-LICENSE
├── README.md
├── Rakefile
├── VERSION
├── app
├── assets
│ ├── images
│ │ └── dossier
│ │ │ └── .gitkeep
│ └── stylesheets
│ │ └── dossier
│ │ └── application.css
├── controllers
│ └── dossier
│ │ ├── application_controller.rb
│ │ └── reports_controller.rb
├── helpers
│ └── dossier
│ │ └── application_helper.rb
└── views
│ └── dossier
│ ├── layouts
│ └── application.html.haml
│ └── reports
│ ├── multi.html.haml
│ └── show.html.haml
├── config
├── initializers
│ └── mime_types.rb
└── routes.rb
├── dossier.gemspec
├── lib
├── dossier.rb
├── dossier
│ ├── adapter
│ │ ├── active_record.rb
│ │ └── active_record
│ │ │ └── result.rb
│ ├── client.rb
│ ├── configuration.rb
│ ├── connection_url.rb
│ ├── engine.rb
│ ├── formatter.rb
│ ├── model.rb
│ ├── multi_report.rb
│ ├── query.rb
│ ├── renderer.rb
│ ├── report.rb
│ ├── responder.rb
│ ├── result.rb
│ ├── stream_csv.rb
│ ├── version.rb
│ ├── view_context_with_report_formatter.rb
│ └── xls.rb
├── generators
│ └── dossier
│ │ └── views
│ │ ├── templates
│ │ └── show.html.haml
│ │ └── views_generator.rb
└── tasks
│ └── dossier_tasks.rake
└── spec
├── dossier
├── adapter
│ ├── active_record
│ │ └── result_spec.rb
│ └── active_record_spec.rb
├── client_spec.rb
├── configuration_spec.rb
├── connection_url_spec.rb
├── formatter_spec.rb
├── model_spec.rb
├── multi_report_spec.rb
├── query_spec.rb
├── renderer_spec.rb
├── report_spec.rb
├── responder_spec.rb
├── result_spec.rb
├── stream_csv_spec.rb
└── version_spec.rb
├── dossier_spec.rb
├── dummy
├── Rakefile
├── app
│ ├── controllers
│ │ ├── application_controller.rb
│ │ └── site_controller.rb
│ ├── reports
│ │ ├── cats
│ │ │ └── are
│ │ │ │ └── super_fun_report.rb
│ │ ├── combination_report.rb
│ │ ├── employee_report.rb
│ │ ├── employee_with_custom_client_report.rb
│ │ ├── employee_with_custom_view_report.rb
│ │ ├── hello_my_friends_report.rb
│ │ └── test_report.rb
│ └── views
│ │ ├── dossier
│ │ └── reports
│ │ │ ├── combination
│ │ │ └── _options.html.haml
│ │ │ ├── employee_with_custom_view.html.haml
│ │ │ └── employee_with_custom_view
│ │ │ └── _options.html.haml
│ │ └── layouts
│ │ └── application.html.erb
├── bin
│ ├── rails
│ └── rake
├── config.ru
├── config
│ ├── application.rb
│ ├── database.yml
│ ├── dossier.yml
│ └── routes.rb
└── db
│ ├── .gitkeep
│ └── schema.rb
├── features
├── combination_report_spec.rb
├── employee_spec.rb
├── employee_with_custom_client_spec.rb
├── employee_with_custom_controller_spec.rb
└── namespaced_report_spec.rb
├── fixtures
├── db
│ ├── mysql2.yml.example
│ ├── mysql2.yml.travis
│ ├── postgresql.yml.example
│ ├── postgresql.yml.travis
│ ├── sqlite3.yml.example
│ └── sqlite3.yml.travis
└── reports
│ ├── employee.csv
│ └── employee.xls
├── generators
└── dossier
│ └── views
│ └── views_spec.rb
├── helpers
└── dossier
│ └── application_helper_spec.rb
├── routing
└── dossier_routes_spec.rb
├── spec_helper.rb
└── support
└── factory.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | .rvmrc
2 | .ruby-version
3 | .ruby-gemset
4 | .bundle/
5 | Gemfile.lock
6 | log/*.log
7 | pkg/
8 | coverage/*
9 | spec/dummy/db/*.sqlite3
10 | spec/dummy/log/*.log
11 | spec/dummy/tmp/
12 | spec/dummy/.sass-cache
13 | spec/fixtures/db/*.yml
14 | spec/fixtures/db/*.sqlite3
15 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | rvm:
3 | - "1.9.3"
4 | - "2.0.0"
5 | - "2.1.2"
6 | - "2.2.2"
7 | env:
8 | - "RAILS_VERSION=3.2.22"
9 | - "RAILS_VERSION=4.0.13"
10 | - "RAILS_VERSION=4.1.12"
11 | - "RAILS_VERSION=4.2.3"
12 | script:
13 | - DOSSIER_DB=sqlite3 bundle exec rspec
14 | - DOSSIER_DB=mysql2 bundle exec rspec
15 | - DOSSIER_DB=postgresql bundle exec rspec
16 | before_script:
17 | - mysql -e 'create database dossier_test;'
18 | - psql -c 'create database dossier_test;' -U postgres
19 | - cp spec/fixtures/db/mysql2.yml.travis spec/fixtures/db/mysql2.yml
20 | - cp spec/fixtures/db/sqlite3.yml.travis spec/fixtures/db/sqlite3.yml
21 | - cp spec/fixtures/db/postgresql.yml.travis spec/fixtures/db/postgresql.yml
22 | sudo: false # upgrade travis ci infrastructure http://bit.ly/1J6D4W9
23 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | Dossier does its best to use [semantic versioning](http://semver.org).
4 |
5 | ## Unreleased
6 |
7 | ## v2.13.0
8 | - Heroku `DATABASE_URL` support
9 | - CSS classes on Dossier views
10 | - `Dossier::Naming` renamed to `Dossier::Model`
11 | - Reports have a `display_column?(name)` method that can be overriden to
12 | determine if a column should be displayed.
13 | - Rails v4.2.X support
14 | - headers will now be formatted without calling `format_header` in the view, that will be called when accessing them (I'm not sure if this may cause backwards incompatible changes with custom views. I don't *think* so.
15 | - introduced `format_column(column, value)` as a default formatter that can be implemented as a fall back if a specific format method does not exist
16 | - Add license to gemspec, thanks to notice from [Benjamin Fleischer](https://github.com/bf4) - see [his blog post](http://www.benjaminfleischer.com/2013/07/12/make-the-world-a-better-place-put-a-license-in-your-gemspec/)
17 | - Output files now have a sortable date/time stamp by default. Eg, "foo-report_2014-10-02_09-12-24-EDT.csv". This can still be customized by defining a report class's `filename` method.
18 | - Add CSS classes to report `
` elements to facilitate styling.
19 |
20 | ## v2.8.0
21 | - Support namespaces for report names (`cats/are/super_fun` => `Cats::Are::SuperRunReport`
22 | - Moved controller response formats into responder class
23 | - Added renderer that contains logic for custom views, this has a pluggable engine depending on if the request is through the controller or through direct object access. If it is through the controller, the controller will be used as the rendering engine, otherwise a basic controller that only renders will be used.
24 | - Filename is configurable by overriding `self.filename` in any given report class
25 | - Options have been extracted into a partial so the entire view doesn't need to be overridden
26 | - Reports will work natively with `form_for` with no additional options (except `method: :get`)
27 | - added in `number_to_dollars` and `commafy_number` which are American only versions of `number_to_currency` and `number_with_precision` because they are suuuuuuuper slow on large datasets. (17k records profiled at 39 seconds vs 3 with the cheap ones)
28 | - added ability to use the report's formatter in view context for a custom view
29 | - allows setting template at class or instance level. Class.template = 'x' or def template; 'x'; end
30 |
31 | ## v2.7.0
32 | - Added `formatted_dossier_report_path` helper method
33 |
34 | ## v2.6.0
35 | - Support ability to combine reports into a macro report using the Dossier::MultiReport class
36 |
37 | ## v2.5.0
38 |
39 | - Made `#report_class` a public method on `Dossier::ReportsController` for easier integration with authorization gems
40 | - Moved "Download CSV" link to top of default report view
41 | - Formatting the header is now an instance method on the report class called `format_header` (thanks @rubysolo)
42 | - Rails 4 compatibility (thanks @rubysolo)
43 | - Fixed bug when using class names in SQL queries, only lowercase symbols that are a-z will be replaced with the respective method call.
44 | - Added view generator for Rails (thanks @wzcolon)
45 |
46 | ## v2.3.0
47 |
48 | Removed `view` method from report. Moved all logic for converting to and from report names from classes into Dossier module. Refactored spec support files. Fixed issue when rendering dossier template outside of `Dossier::ReportsController`.
49 |
50 | ## v2.2.0
51 |
52 | Support for XLS output, added by [michelboaventura](https://github.com/michelboaventura)
53 |
54 | ## v2.1.1
55 |
56 | Fixed bug: in production, CSV rendering should not contain a backtrace if there's an error.
57 |
58 | ## v2.1.0
59 |
60 | Formatter methods will now be passed a hash of the row values if they accept a second argument. This allows formatting certain rows specially.
61 |
62 | ## v2.0.1
63 |
64 | Switched away from `classify` in determining report name to avoid singularization.
65 |
66 | ## v2.0.0
67 |
68 | First public release (previously internal)
69 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Dossier
2 |
3 | All contributions to Dossier must come well-tested.
4 |
5 | ## Adapters
6 |
7 | Dossier currently has `Dossier::Adapter::ActiveRecord`, which allows it to get an ActiveRecord connection and use it for escaping queries, and executing them. It wraps the returned result object in a `Dossier::Adapter::ActiveRecord::Result`, which simply provides a standard way of getting headers and rows.
8 |
9 | If you'd like to add the ability to use a different ORM's connections, you'd need to add a new adapter class and a new adapter result class.
10 |
11 | You'd also need to update `Client#loaded_orms` to check for the presence of your ORM.
12 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gemspec
4 |
5 | RAILS_VERSION = ENV.fetch('RAILS_VERSION', '4.2.3')
6 | gem "activesupport", RAILS_VERSION
7 | gem "actionpack", RAILS_VERSION
8 | gem "actionmailer", RAILS_VERSION
9 | gem "railties", RAILS_VERSION
10 | gem "activerecord", RAILS_VERSION
11 |
12 | # gems used by the dummy application
13 | gem "jquery-rails"
14 | gem "mysql2"
15 | gem "pg"
16 | gem 'coveralls', require: false
17 |
18 | # test unit removed from stdlib in ruby 2.2.0
19 | gem 'test-unit' if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.2.0')
20 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2012 Adam Hunter
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.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
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, XLS, and JSON.
4 |
5 | - If you **hate** SQL, you can use whatever tool you like to generate it; for example, ActiveRecord's `to_sql`.
6 | - If you **love** SQL, you can use every feature your database supports.
7 |
8 | [](http://badge.fury.io/rb/dossier)
9 | [](https://codeclimate.com/github/tma1/dossier)
10 | [](https://travis-ci.org/tma1/dossier)
11 | [](https://coveralls.io/github/tma1/dossier?branch=master)
12 | [](https://gemnasium.com/tma1/dossier)
13 |
14 | ## Setup
15 |
16 | 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 (from your `Rails.root`: `ln -s database.yml config/dossier.yml`).
17 |
18 | ## Routing
19 |
20 | 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.
21 |
22 | ## Formats
23 |
24 | Dossier currently supports outputting to the following formats:
25 |
26 | - HTML
27 | - CSV
28 | - XLS
29 | - JSON
30 |
31 | Any of these formats can be requested by using the appropriate format extension on the end of the report's URL.
32 |
33 | ## Basic Reports
34 |
35 | 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.
36 |
37 | For example:
38 |
39 | ```ruby
40 | # app/reports/fancy_ketchup_report.rb
41 | class FancyKetchupReport < Dossier::Report
42 | def sql
43 | 'SELECT * FROM ketchups WHERE fancy = true'
44 | end
45 |
46 | # Or, if you're using ActiveRecord and hate writing SQL:
47 | def sql
48 | Ketchup.where(fancy: true).to_sql
49 | end
50 |
51 | end
52 | ```
53 |
54 | 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 lowercase symbols in the query will be replaced by calling methods of the same name in the report. Return values will be **escaped by the database connection**. Arrays will have all of their contents escaped, joined with a "," and wrapped in parentheses.
55 |
56 | ```ruby
57 | # app/reports/fancy_ketchup_report.rb
58 | class FancyKetchupReport < Dossier::Report
59 | def sql
60 | "SELECT * FROM ketchups WHERE price <= :max_price and brand IN :brands"
61 | # => "SELECT * FROM ketchups WHERE price <= 7 and brand IN ('Acme', 'Generic', 'SoylentRed')"
62 | end
63 |
64 | def max_price
65 | 7
66 | end
67 |
68 | def brands
69 | %w[Acme Generic SoylentRed]
70 | end
71 | end
72 | ```
73 |
74 | ## Header Formatting
75 |
76 | By default, headers are generated by calling `titleize` on the column name from the result set. To override this, define a `format_header` method in your report that returns what you want. For example:
77 |
78 | ```ruby
79 | class ProductMarginReport < Dossier::Report
80 | # ...
81 | def format_header(column_name)
82 | custom_headers = {
83 | margin_percentage: 'Margin %',
84 | absolute_margin: 'Margin $'
85 | }
86 | custom_headers.fetch(column_name.to_sym) { super }
87 | end
88 | end
89 | ```
90 |
91 | ## Column Formatting
92 |
93 | 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:
94 |
95 | ```ruby
96 | class EmployeeReport < Dossier::Report
97 | # ...
98 | def format_name(value)
99 | value.reverse
100 | end
101 | end
102 | ```
103 |
104 | 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:
105 |
106 | ```ruby
107 | class MoneyLaunderingReport < Dossier::Report
108 | #...
109 | def format_payment(value)
110 | formatter.number_to_currency(value)
111 | end
112 | end
113 | ```
114 |
115 | 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.
116 |
117 | ```ruby
118 | class LeastProfitableAccountsReport < Dossier::Report
119 | #...
120 | def format_account_id(value)
121 | formatter.url_formatter.link_to value, formatter.url_formatter.url_helpers.edit_accounts_path(value)
122 | end
123 | end
124 | ```
125 |
126 | The built-in `ReportsController` uses this formatting when rendering the HTML and JSON representations, but not when rendering the CSV or XLS.
127 |
128 | If your formatting method takes a second argment, it will be given a hash of the values in the row.
129 |
130 | ```ruby
131 | class MoneyLaunderingReport < Dossier::Report
132 | #...
133 | def format_payment(value, row)
134 | return "$0.00" if row[:recipient] == 'Jimmy The Squid'
135 | formatter.number_to_currency(value)
136 | end
137 | end
138 | ```
139 |
140 | ## Hidden Columns
141 |
142 | You may override `display_column?` in your report class in order to hide columns from the formatted results. For instance, you might select an employee's ID and name in order to generate a link from their name to their profile page, without actually displaying the ID value itself:
143 |
144 | ```ruby
145 | class EmployeeReport < Dossier::Report
146 | # ...
147 |
148 | def display_column?(name)
149 | name != 'id'
150 | end
151 |
152 | def format_name(value, row)
153 | url = formatter.url_formatter.url_helpers.employee_path(row['id'])
154 | formatter.url_formatter.link_to(value, url)
155 | end
156 | end
157 | ```
158 |
159 | By default, all selected columns are displayed.
160 |
161 | ## Report Options and Footers
162 |
163 | You may want to specify parameters for a report: which columns to show, a range of dates, etc. Dossier supports this via URL parameters, anything in `params[:options]` will be passed into your report's `initialize` method and made available via the `options` reader.
164 |
165 | 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:
166 |
167 | ```ruby
168 | # app/views/dossier/reports/employee.html.haml
169 |
170 | = form_for report, as: :options, url: url_for, html: {method: :get} do |f|
171 | = f.label "Salary greater than:"
172 | = f.text_field :salary_greater_than
173 | = f.label "In Division:"
174 | = f.select_tag :in_division, divisions_collection
175 | = f.button "Submit"
176 |
177 | = render template: 'dossier/reports/show', locals: {report: report}
178 | ```
179 |
180 | It's up to you to use these options in generating your SQL query.
181 |
182 | 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.
183 |
184 | ## Styling
185 |
186 | The default report views use a `` for easy CSS styling.
187 |
188 | ## Additional View Customization
189 |
190 | To further customize your results view, run the generator provided. The default will provide 'app/views/dossier/reports/show'.
191 |
192 | ```ruby
193 | rails generate dossier:views
194 | ```
195 | You may pass a filename as an argument. This example creates 'app/views/dossier/reports/account_tracker.html.haml'.
196 |
197 | ```ruby
198 | rails generate dossier:views account_tracker
199 | ```
200 |
201 | ## Callbacks
202 |
203 | 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:
204 |
205 | ```ruby
206 | set_callback :build_query, :before, :run_my_stored_procedure
207 | set_callback :execute, :after do
208 | mangle_results
209 | end
210 | ```
211 |
212 | ## Using Reports Outside of Dossier::ReportsController
213 |
214 | ### With Other Controllers
215 |
216 | 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:
217 |
218 | ```ruby
219 | class ProjectsController < ApplicationController
220 |
221 | def show
222 | @project = Project.find(params[:id])
223 | @project_status_report = ProjectStatusReport.new(project: @project)
224 | @project_revenue_report = ProjectRevenueReport.new(project: @project, grouped: 'monthly')
225 | end
226 | end
227 | ```
228 |
229 | ```haml
230 | .span6
231 | = render template: 'dossier/reports/show', locals: {report: @project_status_report.run}
232 | .span6
233 | = render template: 'dossier/reports/show', locals: {report: @project_revenue_report.run}
234 | ```
235 |
236 | ### Dossier for APIs
237 |
238 | ```ruby
239 | class Api::ProjectsController < Api::ApplicationController
240 |
241 | def snapshot
242 | render json: ProjectStatusReport.new(project: @project).results.hashes
243 | end
244 | end
245 | ```
246 |
247 | ## Advanced Usage
248 |
249 | To see a report with all the bells and whistles, check out `spec/dummy/app/reports/employee_report.rb` or other reports in `spec/dummy/app/reports`.
250 |
251 | ## Compatibility
252 |
253 | Dossier currently supports all databases supported by ActiveRecord; it comes with `Dossier::Adapter::ActiveRecord`, which uses ActiveRecord connections for escaping and executing queries. However, as the `Dossier::Adapter` namespace implies, it was written to allow for other connection adapters. See `CONTRIBUTING.md` if you'd like to add one.
254 |
255 | ## Protecting Access to Reports
256 |
257 | You probably want to provide some protection to your reports: require viewers to be logged in, possibly check whether they're allowed to access this particular report, etc.
258 |
259 | Of course, you can protect your own controllers' use of Dossier reports however you wish. To protect report access via `Dossier::Controller`, you can make use of two facts:
260 |
261 | 1. `Dossier::Controller` subclasses `ApplicationController`
262 | 2. If you use an initializer, you can call methods on `Dossier::Controller`
263 |
264 | So for a very simple, roll-your-own solution, you could do this:
265 |
266 | ```ruby
267 | # config/initializers/dossier.rb
268 | Rails.application.config.to_prepare do
269 | # Define `#my_protection_method` on your ApplicationController
270 | Dossier::ReportsController.before_filter :my_protection_method
271 | end
272 | ```
273 |
274 | For a more robust solution, you might make use of some gems. Here's a solution using [Devise](https://github.com/plataformatec/devise) for authentication and [Authority](https://github.com/nathanl/authority) for authorization:
275 |
276 | ```ruby
277 | # app/controllers/application_controller.rb
278 | class ApplicationController < ActionController::Base
279 | # Basic "you must be logged in"; will apply to all subclassing controllers,
280 | # including Dossier::Controller.
281 | before_filter :authenticate_user!
282 | end
283 |
284 | # config/initializers/dossier.rb
285 | Rails.application.config.to_prepare do
286 | # Use Authority to enforce viewing permissions for this report.
287 | # You might set the report's `authorizer_name` to 'ReportsAuthorizer', and
288 | # define that with a `readable_by?(user)` method that suits your needs
289 | Dossier::ReportsController.authorize_actions_for :report_class
290 | end
291 | ```
292 |
293 | See the referenced gems for more documentation on using them.
294 |
295 | ## Running the Tests
296 |
297 | Note: when you run the tests, Dossier will **make and/or truncate** some tables in the `dossier_test` database.
298 |
299 | - Run `bundle`
300 | - `RAILS_ENV=test rake db:create`
301 | - `cp spec/dummy/config/database.yml{.example,}` and edit it so that it can connect to the test database.
302 | - `cp spec/fixtures/db/mysql2.yml{.example,}`
303 | - `cp spec/fixtures/db/sqlite3.yml{.example,}`
304 | - `rspec spec`
305 |
306 | ## Moar Dokumentationz pleaze
307 |
308 | - How Dossier uses ORM adapters to connect to databases, currently only AR's are used.
309 | - Examples of connecting to different databases, of the same type or a different one
310 | - Document using hooks and what methods are available in them
311 | - Callbacks, eg:
312 | - Stored procedures
313 | - Reformat results
314 | - Linking
315 | - To other reports
316 | - To other formats
317 | - Extending the formatter
318 | - Show how to do "crosstab" reports (preliminary query to determine columns, then build SQL case statements?)
319 |
320 | ## Roadmap
321 |
322 | - Moar Dokumentationz pleaze
323 | - Use the [`roo`](https://github.com/hmcgowan/roo) gem to generate a variety of output formats
324 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | begin
2 | require 'bundler/setup'
3 | rescue LoadError
4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5 | end
6 |
7 | require 'rdoc/task'
8 |
9 | RDoc::Task.new(:rdoc) do |rdoc|
10 | rdoc.rdoc_dir = 'rdoc'
11 | rdoc.title = 'Dossier'
12 | rdoc.options << '--line-numbers'
13 | rdoc.rdoc_files.include('README.rdoc')
14 | rdoc.rdoc_files.include('lib/**/*.rb')
15 | end
16 |
17 | APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
18 | load 'rails/tasks/engine.rake'
19 |
20 | Bundler::GemHelper.install_tasks
21 |
--------------------------------------------------------------------------------
/VERSION:
--------------------------------------------------------------------------------
1 | 2.13.1
2 |
--------------------------------------------------------------------------------
/app/assets/images/dossier/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reasoncorp/dossier/81564d56b2d8ebe0d30073ae725982af1c6c1e0a/app/assets/images/dossier/.gitkeep
--------------------------------------------------------------------------------
/app/assets/stylesheets/dossier/application.css:
--------------------------------------------------------------------------------
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 | */
14 |
--------------------------------------------------------------------------------
/app/controllers/dossier/application_controller.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class ApplicationController < ::ApplicationController
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/controllers/dossier/reports_controller.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class ReportsController < ApplicationController
3 | include ViewContextWithReportFormatter
4 |
5 | self.responder = Dossier::Responder
6 |
7 | respond_to :html, :json, :csv, :xls
8 |
9 | def show
10 | respond_with(report)
11 | end
12 |
13 | def multi
14 | respond_with(report)
15 | end
16 |
17 | private
18 |
19 | def report_class
20 | Dossier::Model.name_to_class(params[:report])
21 | end
22 |
23 | def report
24 | @report ||= report_class.new(options_params)
25 | end
26 |
27 | def options_params
28 | params[:options].presence || {}
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/app/helpers/dossier/application_helper.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | module ApplicationHelper
3 |
4 | def formatted_dossier_report_path(format, report)
5 | dossier_report_path(format: format, options: report.options, report: report.report_name)
6 | end
7 |
8 | def render_options(report)
9 | return if report.parent
10 | render "dossier/reports/#{report.report_name}/options", report: report
11 | rescue ActionView::MissingTemplate
12 | end
13 |
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/views/dossier/layouts/application.html.haml:
--------------------------------------------------------------------------------
1 | !!!5
2 | %html
3 | %head
4 | %title #{report.formatted_title}
5 | %body
6 | = yield
7 |
--------------------------------------------------------------------------------
/app/views/dossier/reports/multi.html.haml:
--------------------------------------------------------------------------------
1 | %div{id: report.dom_id}
2 | %h1.dossier-multi-header
3 | = report.formatted_title
4 |
5 | = render_options(report)
6 |
7 | - report.reports.each do |r|
8 | = r.render layout: false
9 |
--------------------------------------------------------------------------------
/app/views/dossier/reports/show.html.haml:
--------------------------------------------------------------------------------
1 | %h2= report.formatted_title
2 |
3 | = link_to 'Download CSV', formatted_dossier_report_path('csv', report), class: 'download-csv'
4 |
5 | = render_options(report)
6 |
7 | %table.dossier.report
8 | %thead
9 | %tr
10 | - report.results.headers.each do |header|
11 | %th= header
12 | %tbody
13 | - report.results.body.each do |row|
14 | %tr
15 | - row.each do |value|
16 | %td= value
17 |
18 | - if report.results.footers.any?
19 | %tfoot
20 | - report.results.footers.each do |row|
21 | %tr
22 | - row.each do |value|
23 | %th= value
24 |
--------------------------------------------------------------------------------
/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | Mime::Type.register "application/xls", :xls
2 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 |
3 | get "reports/*report", to: 'dossier/reports#show', as: :dossier_report
4 | get "multi/reports/*report", to: 'dossier/reports#multi', as: :dossier_multi_report
5 |
6 | end
7 |
--------------------------------------------------------------------------------
/dossier.gemspec:
--------------------------------------------------------------------------------
1 | $:.push File.expand_path("../lib", __FILE__)
2 |
3 | # Maintain your gem's version:
4 | require "dossier/version"
5 |
6 | # Describe your gem and declare its dependencies:
7 | Gem::Specification.new do |s|
8 | s.name = "dossier"
9 | s.version = Dossier::VERSION
10 | s.authors = ["TMA IT"]
11 | s.email = ["developer@tma1.com"]
12 | s.summary = "SQL based report generation."
13 | s.description = "Easy SQL based report generation with the ability to accept request parameters and render multiple formats."
14 | s.homepage = "https://github.com/tma1/dossier"
15 | s.license = 'MIT'
16 |
17 | s.files = Dir["{app,config,db,lib}/**/*"] + %w[MIT-LICENSE Rakefile README.md VERSION]
18 | s.test_files = Dir["spec/**/*"] - %w[spec/dummy/config/dossier.yml]
19 |
20 | s.add_dependency "arel", ">= 3.0"
21 | s.add_dependency "activesupport", ">= 3.2"
22 | s.add_dependency "actionpack", ">= 3.2"
23 | s.add_dependency "actionmailer", ">= 3.2"
24 | s.add_dependency "railties", ">= 3.2"
25 | s.add_dependency "haml", ">= 3.1"
26 | s.add_dependency "responders", ">= 1.1"
27 |
28 | s.add_development_dependency "activerecord", ">= 3.2"
29 | s.add_development_dependency "sqlite3", ">= 1.3.6"
30 | s.add_development_dependency "pry", ">= 0.10.1"
31 | s.add_development_dependency "rspec-rails", ">= 3.3.3"
32 | s.add_development_dependency "generator_spec", "~> 0.9.3"
33 | s.add_development_dependency "capybara", "~> 2.4.4"
34 | s.add_development_dependency "simplecov", "~> 0.10.0"
35 | end
36 |
--------------------------------------------------------------------------------
/lib/dossier.rb:
--------------------------------------------------------------------------------
1 | require "dossier/engine"
2 | require "dossier/model"
3 | require "dossier/view_context_with_report_formatter"
4 | require "dossier/version"
5 |
6 | module Dossier
7 | extend self
8 |
9 | def configuration
10 | @configuration || configure
11 | end
12 |
13 | def configure
14 | @configuration = Configuration.new
15 | yield(@configuration) if block_given?
16 | @configuration
17 | end
18 |
19 | def client
20 | configuration.client
21 | end
22 |
23 | class ExecuteError < StandardError; end
24 | end
25 |
26 | require "dossier/adapter/active_record"
27 | require "dossier/adapter/active_record/result"
28 | require "dossier/client"
29 | require "dossier/connection_url"
30 | require "dossier/configuration"
31 | require "dossier/formatter"
32 | require "dossier/multi_report"
33 | require "dossier/query"
34 | require "dossier/renderer"
35 | require "dossier/report"
36 | require "dossier/responder"
37 | require "dossier/result"
38 | require "dossier/stream_csv"
39 | require "dossier/xls"
40 |
--------------------------------------------------------------------------------
/lib/dossier/adapter/active_record.rb:
--------------------------------------------------------------------------------
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 | # Ensure that SQL logs show name of report generating query
18 | Result.new(connection.exec_query(*["\n#{query}", report_name].compact))
19 | rescue => e
20 | raise Dossier::ExecuteError.new "#{e.message}\n\n#{query}"
21 | end
22 |
23 | private
24 |
25 | def active_record_connection
26 | @abstract_class = Class.new(::ActiveRecord::Base) do
27 | self.abstract_class = true
28 |
29 | # Needs a unique name for ActiveRecord's connection pool
30 | def self.name
31 | "Dossier::Adapter::ActiveRecord::Connection_#{object_id}"
32 | end
33 | end
34 | @abstract_class.establish_connection(options)
35 | @abstract_class.connection
36 | end
37 |
38 | end
39 |
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/dossier/adapter/active_record/result.rb:
--------------------------------------------------------------------------------
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
25 |
--------------------------------------------------------------------------------
/lib/dossier/client.rb:
--------------------------------------------------------------------------------
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 | end
11 |
12 | def adapter
13 | @adapter ||= dossier_adapter.new(self.options.except(:dossier_adapter))
14 | end
15 |
16 | def dossier_adapter
17 | adapter_name = options.fetch(:dossier_adapter) { determine_adapter_name }
18 | "Dossier::Adapter::#{adapter_name.classify}".constantize
19 | end
20 |
21 | private
22 |
23 | def determine_adapter_name
24 | if options.has_key?(:connection)
25 | namespace_for(options[:connection].class)
26 | else
27 | guess_adapter_name
28 | end
29 | end
30 |
31 | def namespace_for(klass)
32 | klass.name.split('::').first.underscore
33 | end
34 |
35 | def guess_adapter_name
36 | return namespace_for(loaded_orms.first) if loaded_orms.length == 1
37 |
38 | message = <<-Must_be_one_of_them_newfangled_ones.strip_heredoc
39 | You didn't specify a dossier_adapter. If you had exactly one
40 | ORM loaded that Dossier knew about, it would try to choose an
41 | appropriate adapter, but you have #{loaded_orms.length}.
42 | Must_be_one_of_them_newfangled_ones
43 | message << "Specifically, Dossier found #{loaded_orms.join(', ')}" if loaded_orms.any?
44 | raise IndeterminableAdapter.new(message)
45 | end
46 |
47 | def loaded_orms
48 | [].tap do |loaded_orms|
49 | loaded_orms << ActiveRecord::Base if defined?(ActiveRecord)
50 | end
51 | end
52 |
53 | class IndeterminableAdapter < StandardError; end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/dossier/configuration.rb:
--------------------------------------------------------------------------------
1 | require 'erb'
2 | require 'yaml'
3 |
4 | module Dossier
5 | class Configuration
6 |
7 | DB_KEY = 'DATABASE_URL'.freeze
8 |
9 | attr_accessor :config_path, :client
10 |
11 | def initialize
12 | @config_path = Rails.root.join('config', 'dossier.yml')
13 | setup_client!
14 | end
15 |
16 | def connection_options
17 | yaml_config.merge(dburl_config || {}).presence || raise_empty_conn_config
18 | end
19 |
20 | def yaml_config
21 | YAML.load(ERB.new(File.read(config_path)).result)[Rails.env].symbolize_keys
22 | rescue Errno::ENOENT
23 | {}
24 | end
25 |
26 | def dburl_config
27 | Dossier::ConnectionUrl.new.to_hash if ENV.has_key? DB_KEY
28 | end
29 |
30 | private
31 |
32 | def setup_client!
33 | @client = Dossier::Client.new(connection_options)
34 | end
35 |
36 | def raise_empty_conn_config
37 | raise ConfigurationMissingError.new(
38 | "Your connection options are blank, you are missing both #{config_path} and ENV['#{DB_KEY}']"
39 | )
40 | end
41 |
42 | end
43 |
44 | class ConfigurationMissingError < StandardError ; end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/dossier/connection_url.rb:
--------------------------------------------------------------------------------
1 | require 'uri'
2 | require 'rack/utils'
3 |
4 | module Dossier
5 | class ConnectionUrl
6 |
7 | attr_reader :uri
8 |
9 | def initialize(url = nil)
10 | @uri = URI.parse(url || ENV.fetch('DATABASE_URL'))
11 | end
12 |
13 | def to_hash
14 | {
15 | adapter: adapter,
16 | username: uri.user,
17 | password: uri.password,
18 | host: uri.host,
19 | port: uri.port,
20 | database: File.basename(uri.path)
21 | }.merge(params).reject { |k,v| v.nil? }
22 | end
23 |
24 | private
25 |
26 | def adapter
27 | uri.scheme == "postgres" ? "postgresql" : uri.scheme
28 | end
29 |
30 | def params
31 | return {} unless uri.query
32 | Rack::Utils.parse_nested_query(uri.query).symbolize_keys
33 | end
34 |
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/dossier/engine.rb:
--------------------------------------------------------------------------------
1 | require 'rails'
2 | require 'haml'
3 |
4 | module Dossier
5 | class Engine < ::Rails::Engine
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/dossier/formatter.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | module Formatter
3 | include ActiveSupport::Inflector
4 | include ActionView::Helpers::NumberHelper
5 | extend self
6 |
7 | def number_to_currency_from_cents(value)
8 | number_to_currency(value /= 100.0)
9 | end
10 |
11 | def number_to_dollars(value)
12 | commafy_number(value, 2).sub(/(\d)/, '$\1')
13 | end
14 |
15 | def commafy_number(value, precision = nil)
16 | whole, fraction = value.to_s.split('.')
17 | fraction = "%.#{precision}d" % (BigDecimal.new("0.#{fraction}").round(precision) * 10**precision).to_i if precision
18 | [whole.gsub(/(\d)(?=(\d\d\d)+(?!\d))/, "\\1,"), fraction].compact.join('.')
19 | end
20 |
21 | def url_formatter
22 | @url_formatter ||= UrlFormatter.new
23 | end
24 |
25 | def report_name(report)
26 | titleize("#{report.report_name.split('/').last} Report")
27 | end
28 |
29 | # TODO figure out how to handle this better
30 | # reports rendered with a system layout use this link_to instead of the
31 | # correct one
32 | # delegate :url_for, :link_to, :url_helpers, to: :url_formatter
33 |
34 | class UrlFormatter
35 | include ActionView::Helpers::UrlHelper
36 |
37 | include ActionDispatch::Routing::UrlFor if defined?(ActionDispatch::Routing::UrlFor) # Rails 4.1
38 | include ActionView::RoutingUrlFor if defined?(ActionView::RoutingUrlFor) # Rails 4.1
39 |
40 | def _routes
41 | Rails.application.routes
42 | end
43 |
44 | # No controller in current context, must be specified when generating routes
45 | def controller
46 | end
47 |
48 | def url_helpers
49 | _routes.url_helpers
50 | end
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/dossier/model.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | module Model
3 |
4 | # not using ActiveSupport::Concern because ClassMethods
5 | # must be extended after ActiveModel::Naming
6 | def self.included(base)
7 | base.extend ActiveModel::Naming
8 | base.extend ClassMethods
9 | end
10 |
11 | def self.class_to_name(klass)
12 | (klass.name || anonymous_report).underscore[0..-8]
13 | end
14 |
15 | def self.name_to_class(name)
16 | "#{name}_report".classify.constantize
17 | end
18 |
19 | def self.anonymous_report
20 | 'AnonymousReport'
21 | end
22 |
23 | def to_key
24 | [report_name]
25 | end
26 |
27 | def to_s
28 | report_name
29 | end
30 |
31 | def to_model
32 | self
33 | end
34 |
35 | def persisted?
36 | true
37 | end
38 |
39 | delegate :report_name, :formatted_title, to: "self.class"
40 |
41 | module ClassMethods
42 | def report_name
43 | Dossier::Model.class_to_name(self)
44 | end
45 |
46 | def formatted_title
47 | Dossier::Formatter.report_name(self)
48 | end
49 |
50 | def model_name
51 | @model_name ||= ActiveModel::Name.new(self, nil, superclass.name).tap do |name|
52 | name.instance_variable_set(:@param_key, 'options')
53 | end
54 | end
55 |
56 | end
57 |
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/dossier/multi_report.rb:
--------------------------------------------------------------------------------
1 | class Dossier::MultiReport
2 | include Dossier::Model
3 |
4 | attr_accessor :options
5 |
6 | class << self
7 | attr_accessor :reports
8 | end
9 |
10 | def self.combine(*reports)
11 | self.reports = reports
12 | end
13 |
14 | def initialize(options = {})
15 | self.options = options.dup.with_indifferent_access
16 | end
17 |
18 | def reports
19 | @reports ||= self.class.reports.map { |report|
20 | report.new(options).tap { |r|
21 | r.parent = self
22 | }
23 | }
24 | end
25 |
26 | def parent
27 | nil
28 | end
29 |
30 | def formatter
31 | Module.new
32 | end
33 |
34 | def dom_id
35 | nil
36 | end
37 |
38 | def template
39 | 'multi'
40 | end
41 |
42 | def renderer
43 | @renderer ||= Dossier::Renderer.new(self)
44 | end
45 |
46 | delegate :render, to: :renderer
47 |
48 | class UnsupportedFormatError < StandardError
49 | def initialize(format)
50 | super "Dossier::MultiReport only supports rendering in HTML format (you tried #{format})"
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/dossier/query.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class Query
3 |
4 | attr_reader :string, :report
5 |
6 | def initialize(report)
7 | @report = report
8 | @string = report.sql.dup
9 | end
10 |
11 | def to_s
12 | compile
13 | end
14 |
15 | private
16 |
17 | def compile
18 | string.gsub(/\w*(? _e
16 | render_template :default, options
17 | end
18 |
19 | def engine
20 | @engine ||= Engine.new(report)
21 | end
22 |
23 | private
24 |
25 | def render_template(template, options)
26 | template = send("#{template}_template_path")
27 | engine.render options.merge(template: template, locals: {report: report})
28 | end
29 |
30 | def template_path(template)
31 | "dossier/reports/#{template}"
32 | end
33 |
34 | def custom_template_path
35 | template_path(report.template)
36 | end
37 |
38 | def default_template_path
39 | template_path('show')
40 | end
41 |
42 | class Engine < AbstractController::Base
43 | include AbstractController::Rendering
44 | include Renderer::Layouts
45 | include ViewContextWithReportFormatter
46 |
47 | attr_reader :report
48 | config.cache_store = ActionController::Base.cache_store
49 |
50 | layout 'dossier/layouts/application'
51 |
52 | def render_to_body(options = {})
53 | renderer = ActionView::Renderer.new(lookup_context)
54 | renderer.render(view_context, options)
55 | end
56 |
57 | def self._helpers
58 | Module.new do
59 | include Rails.application.helpers
60 | include Rails.application.routes.url_helpers
61 |
62 | def default_url_options
63 | {}
64 | end
65 | end
66 | end
67 |
68 | def self._view_paths
69 | ActionController::Base.view_paths
70 | end
71 |
72 | def initialize(report)
73 | @report = report
74 | super()
75 | end
76 | end
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/lib/dossier/report.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class Report
3 | include Dossier::Model
4 | include ActiveSupport::Callbacks
5 |
6 | define_callbacks :build_query, :execute
7 |
8 | attr_reader :options
9 | attr_accessor :parent
10 |
11 | class_attribute :formatter
12 | class_attribute :template
13 |
14 | self.formatter = Dossier::Formatter
15 |
16 | delegate :formatter, :template, to: "self.class"
17 |
18 | def self.inherited(base)
19 | super
20 | base.template = base.report_name
21 | end
22 |
23 | def self.filename
24 | "#{report_name.parameterize}-report_#{Time.now.strftime('%Y-%m-%d_%H-%M-%S-%Z')}"
25 | end
26 |
27 | def initialize(options = {})
28 | @options = options.dup.with_indifferent_access
29 | end
30 |
31 | def sql
32 | raise NotImplementedError, "`sql` method must be defined by each report"
33 | end
34 |
35 | def query
36 | build_query unless defined?(@query)
37 | @query.to_s
38 | end
39 |
40 | def results
41 | execute unless query_results
42 | @results ||= Result::Formatted.new(query_results, self)
43 | end
44 |
45 | def raw_results
46 | execute unless query_results
47 | @raw_results ||= Result::Unformatted.new(query_results, self)
48 | end
49 |
50 | def run
51 | tap { execute }
52 | end
53 |
54 | def format_header(header)
55 | formatter.titleize(header.to_s)
56 | end
57 |
58 | def format_column(column, value)
59 | value
60 | end
61 |
62 | def display_column?(column)
63 | true
64 | end
65 |
66 | def dossier_client
67 | Dossier.client
68 | end
69 |
70 | def renderer
71 | @renderer ||= Renderer.new(self)
72 | end
73 |
74 | delegate :render, to: :renderer
75 |
76 | private
77 |
78 | def build_query
79 | run_callbacks(:build_query) { @query = Dossier::Query.new(self) }
80 | end
81 |
82 | def execute
83 | build_query
84 | run_callbacks :execute do
85 | self.query_results = dossier_client.execute(query, self.class.name)
86 | end
87 | end
88 |
89 | def query_results=(query_results)
90 | @query_results = query_results.freeze
91 | end
92 | attr_reader :query_results
93 |
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/lib/dossier/responder.rb:
--------------------------------------------------------------------------------
1 | require 'responders' unless defined? ::ActionController::Responder
2 |
3 | module Dossier
4 | class Responder < ::ActionController::Responder
5 | alias :report :resource
6 |
7 | def to_html
8 | report.renderer.engine = controller
9 | controller.response_body = report.render
10 | end
11 |
12 | def to_json
13 | controller.render json: report.results.hashes
14 | end
15 |
16 | def to_csv
17 | set_content_disposition!
18 | controller.response_body = StreamCSV.new(*collection_and_headers(report.raw_results.arrays))
19 | end
20 |
21 | def to_xls
22 | set_content_disposition!
23 | controller.response_body = Xls.new(*collection_and_headers(report.raw_results.arrays))
24 | end
25 |
26 | def respond
27 | multi_report_html_only!
28 | super
29 | end
30 |
31 | private
32 |
33 | def set_content_disposition!
34 | controller.headers["Content-Disposition"] = %[attachment;filename=#{filename}]
35 | end
36 |
37 | def collection_and_headers(collection)
38 | headers = collection.shift.map { |header| report.format_header(header) }
39 | [collection, headers]
40 | end
41 |
42 | def filename
43 | "#{report.class.filename}.#{format}"
44 | end
45 |
46 | def multi_report_html_only!
47 | if report.is_a?(Dossier::MultiReport) and format.to_s != 'html'
48 | raise Dossier::MultiReport::UnsupportedFormatError.new(format)
49 | end
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/dossier/result.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class Result
3 | include Enumerable
4 |
5 | attr_accessor :report, :adapter_results
6 |
7 | def initialize(adapter_results, report)
8 | self.adapter_results = adapter_results
9 | self.report = report
10 | end
11 |
12 | def raw_headers
13 | @raw_headers ||= adapter_results.headers
14 | end
15 |
16 | def headers
17 | raise NotImplementedError.new("#{self.class.name} must implement `headers', use `raw_headers' for adapter headers")
18 | end
19 |
20 | def body
21 | size = rows.length - report.options[:footer].to_i
22 | @body ||= size < 0 ? [] : rows.first(size)
23 | end
24 |
25 | def footers
26 | @footer ||= rows.last(report.options[:footer].to_i)
27 | end
28 |
29 | def rows
30 | @rows ||= to_a
31 | end
32 |
33 | def arrays
34 | @arrays ||= [headers] + rows
35 | end
36 |
37 | def hashes
38 | return @hashes if defined?(@hashes)
39 | @hashes = rows.map { |row| row_hash(row) }
40 | end
41 |
42 | # this is the method that creates the individual hash entry
43 | # hashes should always use raw headers
44 | def row_hash(row)
45 | Hash[raw_headers.zip(row)].with_indifferent_access
46 | end
47 |
48 | def each
49 | raise NotImplementedError.new("#{self.class.name} must define `each`")
50 | end
51 |
52 | class Formatted < Result
53 |
54 | def headers
55 | @formatted_headers ||= raw_headers.select { |h|
56 | report.display_column?(h)
57 | }.map { |h|
58 | report.format_header(h)
59 | }
60 | end
61 |
62 | def each
63 | adapter_results.rows.each { |row| yield format(row) }
64 | end
65 |
66 | def format(row)
67 | unless row.kind_of?(Enumerable)
68 | raise ArgumentError.new("#{row.inspect} must be a kind of Enumerable")
69 | end
70 |
71 | displayable_columns(row).map { |value, i|
72 | column = raw_headers.at(i)
73 | apply_formatter(row, column, value)
74 | }
75 | end
76 |
77 | private
78 |
79 | def displayable_columns(row)
80 | row.each_with_index.select { |value, i|
81 | column = raw_headers.at(i)
82 | report.display_column?(column)
83 | }
84 | end
85 |
86 | def apply_formatter(row, column, value)
87 | method = "format_#{column}"
88 |
89 | if report.respond_to?(method)
90 | args = [method, value]
91 | # Provide the row as context if the formatter takes two arguments
92 | args << row_hash(row) if report.method(method).arity == 2
93 | report.public_send(*args)
94 | else
95 | report.format_column(column, value)
96 | end
97 | end
98 | end
99 |
100 | class Unformatted < Result
101 | def each
102 | adapter_results.rows.each { |row| yield row }
103 | end
104 |
105 | def headers
106 | raw_headers
107 | end
108 | end
109 |
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/lib/dossier/stream_csv.rb:
--------------------------------------------------------------------------------
1 | require 'csv'
2 |
3 | module Dossier
4 | class StreamCSV
5 | attr_reader :headers, :collection
6 |
7 | def initialize(collection, headers = nil)
8 | @headers = headers || collection.shift unless false === headers
9 | @collection = collection
10 | end
11 |
12 | def each
13 | yield headers.to_csv if headers?
14 | collection.each do |record|
15 | yield record.to_csv
16 | end
17 | rescue => e
18 | if Rails.application.config.consider_all_requests_local
19 | yield e.message
20 | e.backtrace.each do |line|
21 | yield "#{line}\n"
22 | end
23 | else
24 | yield "We're sorry, but something went wrong."
25 | end
26 | end
27 |
28 | private
29 |
30 | def headers?
31 | headers.present?
32 | end
33 |
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/dossier/version.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | VERSION = File.read(File.expand_path '../../../VERSION', __FILE__).chomp
3 | end
4 |
--------------------------------------------------------------------------------
/lib/dossier/view_context_with_report_formatter.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | module ViewContextWithReportFormatter
3 | def view_context
4 | super.extend(report.formatter)
5 | end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/dossier/xls.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class Xls
3 |
4 | HEADER = %Q{\n\n\n\n\n\n}
6 |
7 | def initialize(collection, headers = nil)
8 | @headers = headers || collection.shift
9 | @collection = collection
10 | end
11 |
12 | def each
13 | yield HEADER
14 | yield as_row(@headers)
15 | @collection.each { |record| yield as_row(record) }
16 | yield FOOTER
17 | end
18 |
19 | private
20 |
21 | def as_cell(el)
22 | %{#{el} | }
23 | end
24 |
25 | def as_row(array)
26 | my_array = array.map{|a| as_cell(a)}.join("\n")
27 | "\n" + my_array + "\n
\n"
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/generators/dossier/views/templates/show.html.haml:
--------------------------------------------------------------------------------
1 | %h2= report.formatted_title
2 |
3 | = link_to 'Download CSV', formatted_dossier_report_path('csv', report), class: 'download-csv'
4 |
5 | = render_options(report)
6 |
7 | %table.dossier.report
8 | %thead
9 | %tr
10 | - report.results.headers.each do |header|
11 | %th= report.format_header(header)
12 | %tbody
13 | - report.results.body.each do |row|
14 | %tr
15 | - row.each do |value|
16 | %td= value
17 |
18 | - if report.results.footers.any?
19 | %tfoot
20 | - report.results.footers.each do |row|
21 | %tr
22 | - row.each do |value|
23 | %th= value
24 |
--------------------------------------------------------------------------------
/lib/generators/dossier/views/views_generator.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class ViewsGenerator < Rails::Generators::Base
3 | desc "This generator creates report views"
4 | source_root File.expand_path('../templates', __FILE__)
5 | argument :report_name, type: :string, default: "show"
6 |
7 | def generate_view
8 | template "show.html.haml", Rails.root.join("app/" "views/" "dossier/reports/#{report_name}.html.haml")
9 | end
10 |
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/tasks/dossier_tasks.rake:
--------------------------------------------------------------------------------
1 | # desc "Explaining what the task does"
2 | # task :dossier do
3 | # # Task goes here
4 | # end
5 |
--------------------------------------------------------------------------------
/spec/dossier/adapter/active_record/result_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Adapter::ActiveRecord::Result do
4 |
5 | let(:ar_connection_results) { double(:results, columns: %w[name age], rows: [['bob', 20], ['sue', 30]]) }
6 | let(:result) { described_class.new(ar_connection_results) }
7 |
8 | describe "headers" do
9 |
10 | let(:fake_columns) { %[foo bar] }
11 |
12 | it "calls `columns` on its connection_results" do
13 | expect(ar_connection_results).to receive(:columns)
14 | result.headers
15 | end
16 |
17 | it "returns the columns from the connection_results" do
18 | expect(result.headers).to eq(ar_connection_results.columns)
19 | end
20 |
21 | end
22 |
23 | describe "rows" do
24 |
25 | it "returns the connection_results" do
26 | expect(result.rows).to eq(ar_connection_results.rows)
27 | end
28 |
29 | end
30 | end
31 |
32 |
--------------------------------------------------------------------------------
/spec/dossier/adapter/active_record_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Adapter::ActiveRecord do
4 |
5 | let(:ar_connection) { double(:activerecord_connection) }
6 | let(:adapter) { described_class.new({connection: ar_connection}) }
7 |
8 | describe "escaping" do
9 |
10 | let(:dirty_value) { "Robert'); DROP TABLE Students;--" }
11 | let(:clean_value) { "'Robert\\'); DROP TABLE Students;--'" }
12 |
13 | it "delegates to the connection" do
14 | expect(ar_connection).to receive(:quote).with(dirty_value)
15 | adapter.escape(dirty_value)
16 | end
17 |
18 | it "returns the connection's escaped value" do
19 | allow(ar_connection).to receive(:quote).and_return(clean_value)
20 | expect(adapter.escape(dirty_value)).to eq(clean_value)
21 | end
22 |
23 | end
24 |
25 | describe "execution" do
26 |
27 | let(:query) { 'SELECT * FROM `people_who_resemble_vladimir_putin`' }
28 | let(:connection_results) { [] }
29 | let(:adapter_result_class) { Dossier::Adapter::ActiveRecord::Result}
30 |
31 | it "delegates to the connection" do
32 | expect(ar_connection).to receive(:exec_query).with("\n#{query}")
33 | adapter.execute(query)
34 | end
35 |
36 | it "builds an adapter result" do
37 | allow(ar_connection).to receive(:exec_query).and_return(connection_results)
38 | expect(adapter_result_class).to receive(:new).with(connection_results)
39 | adapter.execute(:query)
40 | end
41 |
42 | it "returns the adapter result" do
43 | allow(ar_connection).to receive(:exec_query).and_return(connection_results)
44 | expect(adapter.execute(:query)).to be_a(adapter_result_class)
45 | end
46 |
47 | it "rescues any errors and raises a Dossier::ExecuteError" do
48 | allow(ar_connection).to receive(:exec_query).and_raise(StandardError.new('wat'))
49 | expect{ adapter.execute(:query) }.to raise_error(Dossier::ExecuteError)
50 | end
51 |
52 | end
53 |
54 | end
55 |
--------------------------------------------------------------------------------
/spec/dossier/client_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Client do
4 |
5 | let(:connection) {
6 | double(:connection, class: double(:class, name: 'ActiveRecord::ConnectionAdapters::Mysql2Adapter'))
7 | }
8 |
9 | describe "initialization" do
10 |
11 | describe "finding the correct adapter" do
12 |
13 | context "when given a connection object" do
14 |
15 | let(:client) { described_class.new(connection: connection) }
16 |
17 | it "determines the adapter from the connection's class" do
18 | expect(client.adapter).to be_a(Dossier::Adapter::ActiveRecord)
19 | end
20 |
21 | end
22 |
23 | context "when given a dossier_adapter option" do
24 |
25 | before :each do
26 | Dossier::Adapter::SpecAdapter = Struct.new(:options)
27 | end
28 |
29 | after :each do
30 | Dossier::Adapter.send(:remove_const, :SpecAdapter)
31 | end
32 |
33 | it "uses an adapter by that name" do
34 | expect(Dossier::Adapter::SpecAdapter).to receive(:new).with(username: 'Timmy')
35 | described_class.new(dossier_adapter: 'spec_adapter', username: 'Timmy').adapter
36 | end
37 |
38 | end
39 |
40 | context "when not given a connection or a dossier_adapter option" do
41 |
42 | let(:loaded_orms) { raise 'implement in nested describe' }
43 | let(:client) {
44 | described_class.new(username: 'Jimmy').tap { |c|
45 | allow(c).to receive(:loaded_orms).and_return(loaded_orms)
46 | }
47 | }
48 |
49 | describe "if there is one known ORM loaded" do
50 |
51 | let(:loaded_orms) { [double(:class, name: 'ActiveRecord::Base')] }
52 |
53 | it "uses that ORM's adapter" do
54 | expect(Dossier::Adapter::ActiveRecord).to(
55 | receive(:new).with(username: 'Jimmy'))
56 | client.adapter
57 | end
58 |
59 | end
60 |
61 | context "if there are no known ORMs loaded" do
62 |
63 | let(:loaded_orms) { [] }
64 |
65 | it "raises an error" do
66 | expect{ client.adapter }.to raise_error(Dossier::Client::IndeterminableAdapter)
67 | end
68 |
69 | end
70 |
71 | describe "if there are multiple known ORMs loaded" do
72 |
73 | let(:loaded_orms) { [:orm1, :orm2] }
74 |
75 | it "raises an error" do
76 | expect{ client.adapter }.to raise_error(Dossier::Client::IndeterminableAdapter)
77 | end
78 |
79 | end
80 |
81 | end
82 |
83 | end
84 |
85 | end
86 |
87 | describe "instances" do
88 |
89 | let(:client) { described_class.new(connection: connection) }
90 | let(:adapter) { double(:adapter) }
91 |
92 | before :each do
93 | allow(client).to receive(:adapter).and_return(adapter)
94 | end
95 |
96 | it "delegates `escape` to its adapter" do
97 | expect(adapter).to receive(:escape).with('Bobby Tables')
98 | client.escape('Bobby Tables')
99 | end
100 |
101 | it "delegates `execute` to its adapter" do
102 | expect(adapter).to receive(:execute).with('SELECT * FROM `primes`') # It's OK, it's in the cloud!
103 | client.execute('SELECT * FROM `primes`')
104 | end
105 |
106 |
107 | end
108 |
109 | end
110 |
--------------------------------------------------------------------------------
/spec/dossier/configuration_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Configuration do
4 |
5 | let(:connection_options){ YAML.load(ERB.new(File.read Rails.root.join('config', 'dossier.yml')).result)[Rails.env].symbolize_keys }
6 | let(:old_database_url) { ENV.delete "DATABASE_URL"}
7 |
8 | before :each do
9 | Dossier.configure
10 | @config = Dossier.configuration
11 | end
12 |
13 | after :each do
14 | ENV.delete "DATABASE_URL" if ENV.has_key? "DATABASE_URL"
15 | end
16 |
17 | after :each do
18 | ENV["DATABASE_URL"] = old_database_url
19 | end
20 |
21 | describe "defaults" do
22 | it "uses the rails configuration directory for the config path" do
23 | expect(@config.config_path).to eq(Rails.root.join("config", "dossier.yml"))
24 | end
25 | end
26 |
27 | describe "client" do
28 |
29 | it %q{uses ENV["DATABASE_URL"] to merge with config/dossier.yml to setup the client} do
30 | ENV['DATABASE_URL'] = "mysql2://localhost/dossier_test"
31 | options = connection_options.merge Dossier::ConnectionUrl.new.to_hash
32 | expect(Dossier::Client).to receive(:new).with(options)
33 | Dossier.configure
34 | end
35 |
36 | it "uses config/dossier.yml to setup the client" do
37 | expect(Dossier::Client).to receive(:new).with(connection_options)
38 | Dossier.configure
39 | end
40 |
41 | describe "missing a dossier.yml" do
42 | let(:config_path) { Rails.root.join('config') }
43 |
44 | before :each do
45 | FileUtils.mv config_path.join('dossier.yml'),
46 | config_path.join('dossier.yml.test')
47 | end
48 |
49 | after :each do
50 | FileUtils.mv config_path.join('dossier.yml.test'),
51 | config_path.join('dossier.yml')
52 | end
53 |
54 | it "will not raise an exception if config/dossier.yml cannot be read and DATABSE_URL is set" do
55 | ENV['DATABASE_URL'] = "mysql2://localhost/dossier_test"
56 | expect { Dossier.configure }.not_to raise_error
57 | end
58 |
59 | it "will raise an error if connection options is blank" do
60 | expect { Dossier.configure }.to(
61 | raise_error(Dossier::ConfigurationMissingError))
62 | end
63 | end
64 |
65 | it "will setup the connection options" do
66 | expect(@config.connection_options).to be_a(Hash)
67 | end
68 | end
69 |
70 | end
71 |
--------------------------------------------------------------------------------
/spec/dossier/connection_url_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::ConnectionUrl do
4 |
5 | it "parses the url provided into a hash" do
6 | database_url = "mysql2://root:password@127.0.0.1/myapp_development?encoding=utf8"
7 |
8 | connection_options = described_class.new(database_url).to_hash
9 | expected_options = { adapter: "mysql2", database: "myapp_development", username:"root",
10 | password:"password", encoding:"utf8", host: "127.0.0.1"}
11 | expect(connection_options).to eq(expected_options)
12 | end
13 |
14 | it "parses DATABASE_URL into a hash if no url is provided" do
15 | old_db_url = ENV.delete "DATABASE_URL"
16 | ENV["DATABASE_URL"] = "postgres://localhost/foo"
17 | expected_options = {adapter: "postgresql",host: "localhost",database: "foo"}
18 | connection_options = described_class.new.to_hash
19 | expect(connection_options).to eq(expected_options)
20 | ENV["DATABASE_URL"] = old_db_url
21 | end
22 |
23 | it "translates postgres" do
24 | database_url = "postgres://user:secret@localhost/mydatabase"
25 | connection_options = described_class.new(database_url).to_hash
26 |
27 | expect(connection_options[:adapter]).to eq("postgresql")
28 | end
29 |
30 | it "supports additional options" do
31 | database_url = "postgresql://user:secret@remotehost.example.org:3133/mydatabase?encoding=utf8&random_key=blah"
32 | connection_options = described_class.new(database_url).to_hash
33 |
34 | expect(connection_options[:encoding]).to eq("utf8")
35 | expect(connection_options[:random_key]).to eq("blah")
36 | expect(connection_options[:port]).to eq(3133)
37 | end
38 |
39 | it "drops empty values" do
40 | database_url = "postgresql://localhost/mydatabase"
41 | connection_options = described_class.new(database_url).to_hash
42 | expect(connection_options.slice(:username, :password, :port)).to be_empty
43 | end
44 |
45 | end
46 |
--------------------------------------------------------------------------------
/spec/dossier/formatter_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Formatter do
4 |
5 | let(:formatter) { described_class }
6 |
7 | describe "methods from ActionView::Helpers::NumberHelper" do
8 |
9 | it "formats numbers with commas" do
10 | expect(formatter.number_with_delimiter(1025125)).to eq('1,025,125')
11 | end
12 |
13 | it "formats as U.S. dollars" do
14 | expect(formatter.number_to_currency(1025)).to eq('$1,025.00')
15 | end
16 |
17 | end
18 |
19 | it "formats as U.S. dollars from cents" do
20 | expect(formatter.number_to_currency_from_cents(102500)).to eq('$1,025.00')
21 | end
22 |
23 | describe "route formatting" do
24 | let(:route) { {controller: :site, action: :index} }
25 |
26 | it "allows URL generation" do
27 | expect(formatter.url_formatter.url_for(route)).to eq('/woo')
28 | end
29 |
30 | it "allows link generation" do
31 | expect(formatter.url_formatter.link_to('Woo!', route)).to eq('Woo!')
32 | end
33 |
34 | it "allows usage of url helpers" do
35 | expect(formatter.url_formatter.url_helpers.woo_path).to eq('/woo')
36 | end
37 | end
38 |
39 | describe "custom formatters" do
40 | describe "commafy_number" do
41 | {
42 | 10_000 => '10,000',
43 | 10_000.01 => '10,000.01',
44 | 1_000_000_000.001 => '1,000,000,000.001',
45 | '12345.6789' => '12,345.6789'
46 | }.each { |base, formatted|
47 | it "formats #{base} as #{formatted}" do
48 | expect(formatter.commafy_number(base)).to eq formatted
49 | end
50 | }
51 |
52 | it "will return the expected precision if too large" do
53 | expect(formatter.commafy_number(1_000.23523563, 2)).to eq '1,000.24'
54 | end
55 |
56 | it "will return the expected precision if too small" do
57 | expect(formatter.commafy_number(1_000, 5)).to eq '1,000.00000'
58 | end
59 |
60 | # h/t to @rodneyturnham for finding this edge case and providing the solution
61 | it "will properly format a number given to it" do
62 | expect(formatter.commafy_number(1342.58, 2)).to eq '1,342.58'
63 | end
64 | end
65 |
66 | describe "number_to_dollars" do
67 | {
68 | 10_000 => '$10,000.00',
69 | 10_000.00 => '$10,000.00',
70 | 1_000_000_000.000 => '$1,000,000,000.00',
71 | '12345.6788' => '$12,345.68',
72 | 0.01 => '$0.01',
73 | -0.01 => '-$0.01'
74 | }.each { |base, formatted|
75 | it "formats #{base} as #{formatted}" do
76 | expect(formatter.number_to_dollars(base)).to eq formatted
77 | end
78 | }
79 | end
80 | end
81 |
82 | end
83 |
--------------------------------------------------------------------------------
/spec/dossier/model_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Model do
4 | describe "report naming" do
5 | let(:klass) { HelloMyFriendsReport }
6 | let(:name) { 'hello_my_friends' }
7 |
8 | it "converts a report class to a report name" do
9 | expect(described_class.class_to_name(klass)).to eq(name)
10 | end
11 |
12 | it "converting a report name to a report class" do
13 | expect(described_class.name_to_class(name)).to eq(klass)
14 | end
15 |
16 | it "has a to_model" do
17 | instance = klass.new
18 | expect(instance.to_model).to eq(instance)
19 | end
20 |
21 | describe "with namespaces" do
22 | let(:klass) { Cats::Are::SuperFunReport }
23 | let(:name) { 'cats/are/super_fun' }
24 |
25 | it "converts a report class to a report name" do
26 | expect(described_class.class_to_name klass).to eq name
27 | end
28 |
29 | it "converts a report name to a report class" do
30 | expect(described_class.name_to_class name).to eq klass
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/spec/dossier/multi_report_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::MultiReport do
4 |
5 | let(:options) { {'foo' => 'bar'} }
6 | let(:combined_report) { CombinationReport }
7 | let(:report) { combined_report.new(options) }
8 |
9 | it 'knows its sub reports' do
10 | expect(combined_report.reports).to eq([EmployeeReport, EmployeeWithCustomViewReport])
11 | end
12 |
13 | it "passes options to the sub reports" do
14 | combined_report.reports.each do |report|
15 | expect(report).to receive(:new).with(options).and_call_original
16 | end
17 |
18 | report.reports
19 | end
20 |
21 | it "sets the multi property on its child reports" do
22 | expect(report.reports.first.parent).to eq(report)
23 | end
24 |
25 | it "never has a parent" do
26 | expect(report.parent).to be_nil
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/spec/dossier/query_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Query do
4 |
5 | let(:report) { TestReport.new(:foo => 'bar') }
6 | let(:query) { Dossier::Query.new(report) }
7 |
8 | before :each do
9 | allow(report).to receive(:salary).and_return(2)
10 | allow(report).to receive(:ids).and_return([1,2,3])
11 | end
12 |
13 | describe "replacing symbols by calling methods of the same name" do
14 |
15 | context "when it's a normal symbol match" do
16 |
17 | context "when the methods return single values" do
18 |
19 | before :each do
20 | allow(report).to receive(:sql).and_return("SELECT * FROM employees WHERE id = :id OR girth < :girth OR hired_on = :hired_on")
21 | allow(report).to receive(:id).and_return(92)
22 | allow(report).to receive(:girth).and_return(3.14)
23 | allow(report).to receive(:hired_on).and_return('2013-03-29')
24 | end
25 |
26 | it "escapes the values" do
27 | expect(query).to receive(:escape).with(92)
28 | expect(query).to receive(:escape).with(3.14)
29 | expect(query).to receive(:escape).with('2013-03-29')
30 | query.to_s
31 | end
32 |
33 | it "inserts the values" do
34 | expect(query.to_s).to eq("SELECT * FROM employees WHERE id = 92 OR girth < 3.14 OR hired_on = '2013-03-29'")
35 | end
36 |
37 | end
38 |
39 | context "when the methods return arrays" do
40 |
41 | before :each do
42 | allow(report).to receive(:sql).and_return("SELECT * FROM employees WHERE stuff IN :stuff")
43 | allow(report).to receive(:stuff).and_return([38, 'blue', 'mandible', 2])
44 | end
45 |
46 | it "escapes each value in the array" do
47 | expect(Dossier.client).to receive(:escape).with(38)
48 | expect(Dossier.client).to receive(:escape).with('blue')
49 | expect(Dossier.client).to receive(:escape).with('mandible')
50 | expect(Dossier.client).to receive(:escape).with(2)
51 | query.to_s
52 | end
53 |
54 | it "joins the return values with commas" do
55 | expect(query.to_s).to eq("SELECT * FROM employees WHERE stuff IN (38, 'blue', 'mandible', 2)")
56 | end
57 | end
58 | end
59 |
60 | context "when it's another string that includes :" do
61 |
62 | it "does not escape a namespaced constant" do
63 | allow(report).to receive(:sql).and_return("SELECT * FROM employees WHERE type = 'Foo::Bar'")
64 | expect(query).not_to receive(:Bar)
65 | query.to_s
66 | end
67 |
68 | it "does not escape a top-level constant" do
69 | allow(report).to receive(:sql).and_return("SELECT * FROM employees WHERE type = '::Foo'")
70 | expect(query).not_to receive(:Foo)
71 | query.to_s
72 | end
73 |
74 | end
75 |
76 | end
77 |
78 | end
79 |
--------------------------------------------------------------------------------
/spec/dossier/renderer_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Renderer do
4 |
5 | let(:report) { EmployeeReport.new }
6 | let(:renderer) { described_class.new(report) }
7 | let(:engine) { renderer.engine }
8 |
9 | describe "rendering" do
10 | let(:options) { {template: "dossier/reports/#{template}", locals: {report: report}} }
11 |
12 | describe "with custom view" do
13 | let(:report) { EmployeeWithCustomViewReport.new }
14 | let(:template) { report.report_name }
15 |
16 | it "renders the custom view" do
17 | expect(engine).to receive(:render).with(options)
18 | end
19 | end
20 |
21 | describe "without custom view" do
22 | let(:template) { 'show' }
23 |
24 | it "renders show" do
25 | expect(engine).to receive(:render).with(options.merge(template: 'dossier/reports/employee')).and_call_original
26 | expect(engine).to receive(:render).with(options)
27 | end
28 | end
29 |
30 | after(:each) { renderer.render }
31 | end
32 |
33 | describe "engine" do
34 | describe "view_context" do
35 | it "mixes in the dossier/application_helper to that view context" do
36 | expect(engine.view_context.class.ancestors).to include(Dossier::ApplicationHelper)
37 | end
38 | end
39 |
40 | describe "view path" do
41 | it "has the same view paths the application would have" do
42 | extractor = ->(vp) { vp.paths }
43 | expect(extractor.call engine.view_paths).to eq(extractor.call ActionController::Base.view_paths)
44 | end
45 | end
46 |
47 | describe "layouts" do
48 | it "uses a layout" do
49 | expect(report.render).to match('')
50 | end
51 |
52 | it "makes the report available to the layout" do
53 | expect(report.render).to match('Employee Report')
54 | end
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/spec/dossier/report_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Report do
4 |
5 | let(:report) { TestReport.new(:foo => 'bar') }
6 |
7 | it "has a report name" do
8 | expect(TestReport.report_name).to eq('test')
9 | end
10 |
11 | it "has a template name that is the report name" do
12 | expect(report.template).to eq(report.report_name)
13 | end
14 |
15 | it "allows overriding the template" do
16 | report = Class.new(described_class) { def template; 'fooo'; end }
17 | expect(report.new.template).to eq 'fooo'
18 | end
19 |
20 | describe "report instances" do
21 | let(:report_with_custom_header) do
22 | Class.new(Dossier::Report) do
23 | def format_header(header)
24 | {
25 | 'generic' => 'customized'
26 | }[header.to_s] || super
27 | end
28 | end.new
29 | end
30 |
31 | it "takes options when initializing" do
32 | expect(report.options).to eq('foo' => 'bar')
33 | end
34 |
35 | it 'generates column headers' do
36 | expect(report.format_header('Foo')).to eq 'Foo'
37 | end
38 |
39 | it 'allows for column header customization' do
40 | expect(report_with_custom_header.format_header(:generic)).to eq 'customized'
41 | end
42 |
43 | it "has a formatted title" do
44 | expect(report.formatted_title).to eq 'Test Report'
45 | end
46 | end
47 |
48 | describe "callbacks" do
49 |
50 | let(:report) do
51 | Class.new(Dossier::Report) do
52 | set_callback :build_query, :before, :before_test_for_build_query
53 | set_callback :execute, :after, :after_test_for_execute
54 |
55 | def sql; ''; end
56 | end.new
57 | end
58 |
59 | it "has callbacks for build_query" do
60 | expect(report).to receive(:before_test_for_build_query)
61 | report.query
62 | end
63 |
64 | it "has callbacks for execute" do
65 | allow(Dossier.client).to receive(:execute).and_return([])
66 | allow(report).to receive(:before_test_for_build_query)
67 | expect(report).to receive(:after_test_for_execute)
68 | report.run
69 | end
70 |
71 | end
72 |
73 | it "requires you to override the query method" do
74 | expect {report.sql}.to raise_error(NotImplementedError)
75 | end
76 |
77 | describe "DSL" do
78 |
79 | describe "run" do
80 | it "will execute the generated sql query" do
81 | report = EmployeeReport.new
82 | expect(Dossier.client).to receive(:execute).with(report.query, 'EmployeeReport').and_return([])
83 | report.run
84 | end
85 |
86 | it "will cache the results of the run in `results`" do
87 | report = EmployeeReport.new
88 | report.run
89 | expect(report.results).not_to be_nil
90 | end
91 | end
92 |
93 | end
94 |
95 | describe "rendering" do
96 | it "has a renderer" do
97 | expect(report.renderer).to be_a(Dossier::Renderer)
98 | end
99 |
100 | it "delegates render to the renderer" do
101 | expect(report.renderer).to receive(:render)
102 | report.render
103 | end
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/spec/dossier/responder_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Responder do
4 |
5 | def mock_out_report_results(report)
6 | report.tap { |r|
7 | allow(r).to receive(:results).and_return(results)
8 | allow(r).to receive(:raw_results).and_return(results)
9 | }
10 | end
11 |
12 | let(:results) { double(arrays: [%w[hi], %w[there]], hashes: [{hi: 'there'}]) }
13 | let(:report) { EmployeeReport.new }
14 | let(:reports) { [mock_out_report_results(report)] }
15 | let(:controller) {
16 | ActionController::Base.new.tap { |controller| allow(controller).to receive(:headers).and_return({}) }
17 | }
18 | let(:responder) { described_class.new(controller, reports, {}) }
19 |
20 | describe "to_html" do
21 | it "calls render on the report" do
22 | expect(report).to receive(:render)
23 | responder.to_html
24 | end
25 | end
26 |
27 | describe "to_json" do
28 | it "renders the report as json" do
29 | expect(controller).to receive(:render).with(json: results.hashes)
30 | responder.to_json
31 | end
32 | end
33 |
34 | describe "to_csv" do
35 | it "sets the content disposition" do
36 | expect(responder).to receive(:set_content_disposition!)
37 | responder.to_csv
38 | end
39 |
40 | it "sets the response body to a new csv streamer instance" do
41 | responder.to_csv
42 | expect(responder.controller.response_body).to be_a(Dossier::StreamCSV)
43 | end
44 |
45 | it "formats the headers that are passed to Dossier::StreamCSV" do
46 | expect(report).to receive(:format_header).with('hi')
47 | responder.to_csv
48 | end
49 | end
50 |
51 | describe "to_xls" do
52 | it "sets the content disposition" do
53 | expect(responder).to receive(:set_content_disposition!)
54 | responder.to_xls
55 | end
56 |
57 | it "sets the response body to a new xls instance" do
58 | responder.to_xls
59 | expect(responder.controller.response_body).to be_a(Dossier::Xls)
60 | end
61 |
62 | it "formats the headers that are passed to Dossier::Xls" do
63 | expect(report).to receive(:format_header).with('hi')
64 | responder.to_csv
65 | end
66 | end
67 |
68 | end
69 |
70 |
--------------------------------------------------------------------------------
/spec/dossier/result_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Result do
4 |
5 | module AbstractMock
6 | def each
7 | adapter_results.rows.each do |row|
8 | yield row
9 | end
10 | end
11 |
12 | def headers
13 | raw_headers
14 | end
15 | end
16 |
17 | let(:report) { TestReport.new }
18 | let(:result_row) { {'mascot' => 'platapus', 'cheese' => 'bleu'} }
19 | let(:adapter_result) { double(:adapter_result, rows: [result_row.values], headers: result_row.keys) }
20 | let(:result_class) { Class.new(described_class) { include AbstractMock } }
21 | let(:result) { result_class.new(adapter_result, report) }
22 |
23 | it "requires each to be overridden" do
24 | expect { described_class.new(adapter_result, report).each }.to raise_error(NotImplementedError, /result must define/i)
25 | end
26 |
27 | it "requires headers to be overridden" do
28 | expect { described_class.new(adapter_result, report).headers }.to raise_error(NotImplementedError, /headers/i)
29 | end
30 |
31 | describe "initialization with an adapter result object" do
32 |
33 | it "will raise if the object isn't given" do
34 | expect {Dossier::Result.new}.to raise_error(ArgumentError)
35 | end
36 |
37 | it "can extract the fields queried" do
38 | expect(adapter_result).to receive(:headers).and_return([])
39 | result.headers
40 | end
41 |
42 | it "can extract the values from the adapter results" do
43 | expect(result).to receive(:to_a)
44 | result.rows
45 | end
46 |
47 | describe "structure" do
48 |
49 | it "can return an array of hashes" do
50 | expect(result.hashes).to eq([result_row])
51 | end
52 |
53 | it "can return an array of arrays" do
54 | allow(result).to receive(:headers).and_return(%w[mascot cheese])
55 | expect(result.arrays).to eq([%w[mascot cheese], %w[platapus bleu]])
56 | end
57 |
58 | end
59 |
60 | end
61 |
62 | describe "subclasses" do
63 |
64 | describe Dossier::Result::Formatted do
65 |
66 | let(:result) { Dossier::Result::Formatted.new(adapter_result, report) }
67 |
68 | describe "headers" do
69 | it "formats the headers by calling format_header" do
70 | adapter_result.headers.each { |h| expect(result.report).to receive(:format_header).with(h) }
71 | result.headers
72 | end
73 | end
74 |
75 | describe "hashing" do
76 | it "does not format the keys of the hash" do
77 | hash = result.hashes.first
78 | expect(hash.keys).to eq %w[mascot cheese]
79 | end
80 | end
81 |
82 | describe "each" do
83 |
84 | it "calls :each on on its adapter's results" do
85 | expect(adapter_result.rows).to receive(:each)
86 | result.each { |result| }
87 | end
88 |
89 | it "formats each of the adapter's results" do
90 | expect(result).to receive(:format).with(result_row.values)
91 | result.each { |result| }
92 | end
93 |
94 | end
95 |
96 | describe "format" do
97 | let(:report) {
98 | Class.new(Dossier::Report) {
99 | def format_mascot(value); value.upcase; end
100 | }.new
101 | }
102 |
103 | let(:row) { result_row.values }
104 |
105 | it "raises unless its argument responds to :[]" do
106 | expect {result.format(Object.new)}.to raise_error(ArgumentError)
107 | end
108 |
109 | it "calls a custom formatter method if available" do
110 | expect(result.report).to receive(:format_mascot).with('platapus')
111 | result.format(row)
112 | end
113 |
114 | it "calls the default format_column method otherwise" do
115 | expect(result.report).to receive(:format_column).with('cheese', 'bleu')
116 | result.format(row)
117 | end
118 | end
119 |
120 | describe "footer" do
121 | let(:report) { TestReport.new(footer: 3) }
122 | let(:adapter_result_rows) { 7.times.map { result_row.values } }
123 |
124 | before :each do
125 | allow(adapter_result).to receive(:rows).and_return(adapter_result_rows)
126 | end
127 |
128 | it "has 4 result rows" do
129 | expect(result.body.count).to eq(4)
130 | end
131 |
132 | it "has 3 footer rows" do
133 | expect(result.footers.count).to eq(3)
134 | end
135 |
136 | describe "with empty results" do
137 | let(:adapter_result_rows) { [] }
138 |
139 | it "has an empty body" do
140 | expect(result.body.count).to be_zero
141 | end
142 | end
143 |
144 | end
145 |
146 | end
147 |
148 | describe Dossier::Result::Unformatted do
149 |
150 | let(:result) { Dossier::Result::Unformatted.new(adapter_result, report) }
151 |
152 | describe "each" do
153 |
154 | it "calls :each on on its adapter's results" do
155 | expect(adapter_result.rows).to receive(:each)
156 | result.each { |result| }
157 | end
158 |
159 | it "does not format the results" do
160 | expect(result).not_to receive(:format)
161 | result.each { |result| }
162 | end
163 |
164 | end
165 |
166 | end
167 |
168 | end
169 |
170 | end
171 |
--------------------------------------------------------------------------------
/spec/dossier/stream_csv_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::StreamCSV do
4 | let(:collection) {
5 | [
6 | %w[hello there sir how are you],
7 | %w[i am well thanks for asking]
8 | ]
9 | }
10 | let(:headers) { %w[w1 w2 w3 w4 w5 w6] }
11 | let(:streamer) { described_class.new(collection, headers) }
12 |
13 | describe "headers" do
14 | it "allows passing headers" do
15 | expect(streamer.headers).to eq headers
16 | end
17 |
18 | it "does not format the headers when streamed" do
19 | formatted = nil
20 | streamer.each { |r| formatted = r; break }
21 | expect(formatted).to eq %w[w1 w2 w3 w4 w5 w6].to_csv
22 | end
23 |
24 | describe "using the first element of the collection for headers" do
25 | let(:streamer) { described_class.new(collection) }
26 | let!(:original) { collection.dup }
27 |
28 | it "takes the first element of the collection to be the headers" do
29 | expect(streamer.headers).to eq original.first
30 | end
31 |
32 | it "*only* takes the first element off the collection" do
33 | streamer.headers
34 | expect(streamer.headers).to eq original.first
35 | end
36 | end
37 |
38 | describe "explicitly false headers" do
39 | let(:streamer) { described_class.new(collection, false) }
40 |
41 | it "will not use headers if they are explicitly false" do
42 | expect(streamer.headers).to be_nil
43 | end
44 |
45 | it "will not stream headers if they are not set" do
46 | streamer = described_class.new(collection, false)
47 | expect([].tap { |a| streamer.each { |r| a << r } }).to eq collection.map(&:to_csv)
48 | end
49 | end
50 | end
51 |
52 | it "calls to csv on each member of the collection" do
53 | collection.each { |row| expect(row).to receive(:to_csv) }
54 | streamer.each {}
55 | end
56 |
57 | describe "exceptions" do
58 | let(:output) { String.new }
59 | let(:error) { "Woooooooo cats are fluffy!" }
60 | before(:each) {
61 | allow(collection[0]).to receive(:to_csv).and_raise(error)
62 | }
63 |
64 | it "provides a backtrace if local request" do
65 | allow(Rails.application.config).to receive(:consider_all_requests_local).and_return(true)
66 | streamer.each { |line| output << line }
67 | expect(output).to include(error)
68 | end
69 |
70 | it "provides a simple error if not a local request" do
71 | allow(Rails.application.config).to receive(:consider_all_requests_local).and_return(false)
72 | streamer.each { |line| output << line }
73 | expect(output).to match(/something went wrong/)
74 | end
75 | end
76 |
77 | end
78 |
--------------------------------------------------------------------------------
/spec/dossier/version_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe Dossier::VERSION do
2 |
3 | it "is a string" do
4 | expect(subject).to be_a String
5 | end
6 |
7 | end
8 |
9 |
--------------------------------------------------------------------------------
/spec/dossier_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier do
4 | it "is a module" do
5 | expect(Dossier).to be_a(Module)
6 | end
7 |
8 | it "is configurable" do
9 | Dossier.configure
10 | expect(Dossier.configuration).to_not be_nil
11 | end
12 |
13 | it "has a configuration" do
14 | Dossier.configure
15 | expect(Dossier.configuration).to be_a(Dossier::Configuration)
16 | end
17 |
18 | it "allows configuration via a block" do
19 | some_client = Object.new
20 | Dossier.configure do |config|
21 | config.client = some_client
22 | end
23 | expect(Dossier.configuration.client).to eq(some_client)
24 | end
25 |
26 | it "exposes the configurations client via Dossier.client" do
27 | Dossier.configure
28 | expect(Dossier.configuration).to receive(:client)
29 | Dossier.client
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/spec/dummy/Rakefile:
--------------------------------------------------------------------------------
1 | require File.expand_path('../config/application', __FILE__)
2 |
3 | Dummy::Application.load_tasks
4 |
5 |
--------------------------------------------------------------------------------
/spec/dummy/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | ApplicationController = Class.new(ActionController::Base) do
2 | protect_from_forgery with: :exception
3 | end
4 |
--------------------------------------------------------------------------------
/spec/dummy/app/controllers/site_controller.rb:
--------------------------------------------------------------------------------
1 | class SiteController < ApplicationController
2 | def report
3 | report = EmployeeReport.new
4 | render template: 'dossier/reports/show', locals: {report: report.run}
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/spec/dummy/app/reports/cats/are/super_fun_report.rb:
--------------------------------------------------------------------------------
1 | module Cats
2 | module Are
3 | class SuperFunReport < Dossier::Report
4 | def sql
5 | "select #{selections.join(', ')}"
6 | end
7 |
8 | def selections
9 | columns = %w(cats are super fun)
10 | selections = columns.map { |x| "'#{x}' as #{x}" }
11 | if ENV['DOSSIER_DB'].to_s === 'postgresql'
12 | selections.map! { |x|
13 | parts = x.split(' as ')
14 | "'#{parts[0][1..-2]}'::character(7) as #{parts[1]}"
15 | }
16 | end
17 | selections
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/spec/dummy/app/reports/combination_report.rb:
--------------------------------------------------------------------------------
1 | class CombinationReport < Dossier::MultiReport
2 |
3 | combine EmployeeReport, EmployeeWithCustomViewReport
4 |
5 | def tiger_stripes
6 | options.fetch(:tiger_stripes, 0)
7 | end
8 |
9 | end
10 |
--------------------------------------------------------------------------------
/spec/dummy/app/reports/employee_report.rb:
--------------------------------------------------------------------------------
1 | class EmployeeReport < Dossier::Report
2 |
3 | set_callback :build_query, :before, :example_before_hook
4 | set_callback :execute, :after do
5 | # do some stuff
6 | end
7 |
8 | # Valid for users to choose via a multi-select
9 | def self.valid_columns
10 | %w[id name hired_on suspended]
11 | end
12 |
13 | def sql
14 | "SELECT #{columns} FROM employees WHERE 1=1".tap do |sql|
15 | sql << "\n AND division in (:divisions)" if divisions.any?
16 | sql << "\n AND salary > :salary" if salary?
17 | sql << "\n AND (#{names_like})" if names_like.present?
18 | sql << "\n ORDER BY name #{order}"
19 | end
20 | end
21 |
22 | def columns
23 | valid_columns.join(', ').presence || '*'
24 | end
25 |
26 | def valid_columns
27 | self.class.valid_columns & Array.wrap(options[:columns])
28 | end
29 |
30 | def order
31 | options[:order].to_s.upcase === 'DESC' ? 'DESC' : 'ASC'
32 | end
33 |
34 | def salary
35 | 10_000
36 | end
37 |
38 | def name
39 | "%#{names.pop}%"
40 | end
41 |
42 | def divisions
43 | @divisions ||= options.fetch(:divisions) { [] }
44 | end
45 |
46 | def salary?
47 | options[:salary].present?
48 | end
49 |
50 | def names_like
51 | names.map { |name| "name like :name" }.join(' or ')
52 | end
53 |
54 | def names
55 | @names ||= options.fetch(:names) { [] }.dup
56 | end
57 |
58 | def display_column?(name)
59 | name != 'id'
60 | end
61 |
62 | def format_salary(amount, row)
63 | return "Who's Asking?" if row[:division] == "Corporate Malfeasance"
64 | formatter.number_to_currency(amount)
65 | end
66 |
67 | def format_hired_on(date)
68 | date = Date.parse(date) if String === date
69 | date.to_s(:db)
70 | end
71 |
72 | def format_name(name)
73 | "Employee #{name}"
74 | end
75 |
76 | def format_suspended(value)
77 | value.to_s.in?(%w(1 t)) ? 'Yes' : 'No'
78 | end
79 |
80 | def example_before_hook
81 | # do some stuff
82 | end
83 |
84 | def raw_results
85 | super
86 | results = query_results.rows.map { |qr|
87 | qr.tap { |q| q[4] = format_suspended(q[4]) }
88 | }
89 | @raw_results ||= Result::Unformatted.new(results, self)
90 | end
91 |
92 | end
93 |
--------------------------------------------------------------------------------
/spec/dummy/app/reports/employee_with_custom_client_report.rb:
--------------------------------------------------------------------------------
1 | class EmployeeWithCustomClientReport < Dossier::Report
2 |
3 | def sql
4 | "SELECT * FROM `employees`"
5 | end
6 |
7 | def dossier_client
8 | Dossier::Factory.sqlite3_client
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/spec/dummy/app/reports/employee_with_custom_view_report.rb:
--------------------------------------------------------------------------------
1 | class EmployeeWithCustomViewReport < Dossier::Report
2 | # See spec/dummy/app/views
3 |
4 | def self.dragon_colors
5 | %w[blue red green black white silver brown]
6 | end
7 |
8 | def sql
9 | "SELECT * FROM employees WHERE suspended = :suspended"
10 | end
11 |
12 | def dragon_color
13 | options.fetch(:dragon_color, self.class.dragon_colors.sample)
14 | end
15 |
16 | def formatter
17 | @formatter ||= CustomFormatter
18 | end
19 |
20 | def suspended
21 | true
22 | end
23 |
24 | module CustomFormatter
25 | extend Dossier::Formatter
26 | def margery_butts(word)
27 | "Margery Butts #{word}"
28 | end
29 | end
30 |
31 | end
32 |
--------------------------------------------------------------------------------
/spec/dummy/app/reports/hello_my_friends_report.rb:
--------------------------------------------------------------------------------
1 | class HelloMyFriendsReport < Dossier::Report
2 | end
3 |
--------------------------------------------------------------------------------
/spec/dummy/app/reports/test_report.rb:
--------------------------------------------------------------------------------
1 | class TestReport < Dossier::Report
2 | end
3 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/dossier/reports/combination/_options.html.haml:
--------------------------------------------------------------------------------
1 | Some options plz!!
2 | = form_for report, method: :get do |f|
3 | = f.label :tiger_stripes
4 | = f.text_field :tiger_stripes
5 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/dossier/reports/employee_with_custom_view.html.haml:
--------------------------------------------------------------------------------
1 | %h2 Yeah. Did you get that memo?
2 | = render_options(report)
3 | %p some report goes here
4 | = debug report.results.arrays
5 | %p= margery_butts('woo')
6 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/dossier/reports/employee_with_custom_view/_options.html.haml:
--------------------------------------------------------------------------------
1 | %h3 #{report.formatted_title} Options
2 | options be here matey!
3 | = form_for report, method: :get do |f|
4 | = f.label :dragon_color
5 | = f.select :dragon_color, report.class.dragon_colors.map { |c| [c.titleize, c] }
6 |
7 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Dummy
5 | <%= stylesheet_link_tag "application", :media => "all" %>
6 | <%= javascript_include_tag "application" %>
7 | <%= csrf_meta_tags %>
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/spec/dummy/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_PATH = File.expand_path('../../config/application', __FILE__)
3 | require APP_PATH
4 | require 'rails/commands'
5 |
--------------------------------------------------------------------------------
/spec/dummy/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require_relative '../config/application'
3 | require 'rake'
4 | Rake.application.run
5 |
--------------------------------------------------------------------------------
/spec/dummy/config.ru:
--------------------------------------------------------------------------------
1 | run Dummy::Application
2 |
--------------------------------------------------------------------------------
/spec/dummy/config/application.rb:
--------------------------------------------------------------------------------
1 | ENV['BUNDLE_GEMFILE'] = File.expand_path('../../../../Gemfile', __FILE__)
2 |
3 | require 'rubygems'
4 | require 'bundler'
5 |
6 | Bundler.setup
7 |
8 | require "rails/all"
9 |
10 | Bundler.require
11 |
12 | module Dummy
13 | class Application < ::Rails::Application
14 | config.cache_classes = true
15 | config.active_support.deprecation = :stderr
16 | config.eager_load = false
17 | config.action_controller.allow_forgery_protection = false
18 |
19 | # Raise exceptions instead of rendering exception templates
20 | config.action_dispatch.show_exceptions = false
21 | # because this belongs here for some reason...??? also in spec_helper
22 | # thanks rails 5 :/
23 | config.active_support.test_order = :random
24 |
25 | config.secret_token = config.secret_key_base =
26 | 'http://s3-ec.buzzfed.com/static/enhanced/webdr03/2013/5/25/8/anigif_enhanced-buzz-11857-1369483324-0.gif'
27 | end
28 | end
29 |
30 | Dummy::Application.initialize!
31 |
--------------------------------------------------------------------------------
/spec/dummy/config/database.yml:
--------------------------------------------------------------------------------
1 | <% options = DB_CONFIG.fetch(ENV.fetch('DOSSIER_DB', :sqlite3).to_sym) %>
2 | defaults: &defaults
3 | adapter: <%= options.fetch(:adapter) %>
4 | database: <%= options.fetch(:database) %>
5 | host: <%= options.fetch(:host, 'localhost') %>
6 | username: <%= options[:username] %>
7 | password: <%= options[:password] %>
8 |
9 | development:
10 | <<: *defaults
11 | test:
12 | <<: *defaults
13 | production:
14 | <<: *defaults
15 |
--------------------------------------------------------------------------------
/spec/dummy/config/dossier.yml:
--------------------------------------------------------------------------------
1 | database.yml
--------------------------------------------------------------------------------
/spec/dummy/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 | get 'woo' => 'site#index', as: 'woo'
3 | get 'employee_report_custom_controller' => 'site#report', as: 'employee_report_custom_controller'
4 | end
5 |
--------------------------------------------------------------------------------
/spec/dummy/db/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reasoncorp/dossier/81564d56b2d8ebe0d30073ae725982af1c6c1e0a/spec/dummy/db/.gitkeep
--------------------------------------------------------------------------------
/spec/dummy/db/schema.rb:
--------------------------------------------------------------------------------
1 | # encoding: UTF-8
2 | # This file is auto-generated from the current state of the database. Instead
3 | # of editing this file, please use the migrations feature of Active Record to
4 | # incrementally modify your database, and then regenerate this schema definition.
5 | #
6 | # Note that this schema.rb definition is the authoritative source for your
7 | # database schema. If you need to create the application database on another
8 | # system, you should be using db:schema:load, not running all the migrations
9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations
10 | # you'll amass, the slower it'll run and the greater likelihood for issues).
11 | #
12 | # It's strongly recommended to check this file into your version control system.
13 |
14 | ActiveRecord::Schema.define(:version => 20130110221932) do
15 |
16 | create_table "employees", :force => true do |t|
17 | t.string "name"
18 | t.date "hired_on"
19 | t.boolean "suspended"
20 | t.string "division"
21 | t.integer "salary"
22 | end
23 |
24 | end
25 |
--------------------------------------------------------------------------------
/spec/features/combination_report_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe "combination report" do
4 | let(:path) { dossier_multi_report_path(report: 'combination') }
5 |
6 | it "displays the correct html" do
7 | visit path
8 | expect(page).to have_content('Employee Report')
9 | expect(page).to have_content('Did you get that memo?')
10 | end
11 |
12 | it "displays its options" do
13 | visit path
14 | expect(page).to have_content('Some options plz!')
15 | end
16 |
17 | it "does not display options for sub reports" do
18 | visit path
19 | expect(page).to_not have_content('options be here matey!')
20 | end
21 |
22 | it "raises an UnsupportedFormatError when trying something besides HTML" do
23 | expect { visit "#{path}.csv" }.to raise_error(Dossier::MultiReport::UnsupportedFormatError, /you tried csv/)
24 | end
25 | end
26 |
27 |
--------------------------------------------------------------------------------
/spec/features/employee_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe "employee report" do
4 |
5 | describe "rendering HTML" do
6 |
7 | context "displaying headers" do
8 | it "titleizes the headers by default" do
9 | visit '/reports/employee'
10 | expect(page).to have_content('Name')
11 | expect(page).to_not have_content('name')
12 | end
13 | end
14 |
15 | context "hiding columns" do
16 | it "does not display hidden columns" do
17 | visit '/reports/employee'
18 | expect(page).to_not have_content('Id')
19 | end
20 | end
21 |
22 | context "when a custom view exists for the report" do
23 |
24 | it "uses the custom view" do
25 | visit '/reports/employee_with_custom_view'
26 | expect(page).to have_content('Yeah. Did you get that memo?')
27 | end
28 |
29 | it "has access to the reports formatter in the view scope" do
30 | visit '/reports/employee_with_custom_view'
31 | expect(page).to have_content('Margery Butts')
32 | end
33 |
34 | end
35 |
36 | context "when no custom view exists for the report" do
37 | let(:path) { dossier_report_path(report: 'employee', options: options) }
38 | let(:options) { nil }
39 |
40 | it "creates an HTML report using its standard 'show' view" do
41 | visit path
42 | expect(page).to have_selector("table thead tr", count: 1)
43 | expect(page).to have_selector("table tbody tr", count: 3)
44 | end
45 |
46 | describe "with options for filtering" do
47 | let(:options) { {
48 | salary: true, order: 'desc',
49 | names: ['Jimmy Jackalope', 'Moustafa McMann'],
50 | divisions: ['Tedious Toiling']
51 | } }
52 |
53 | it "uses any options provided" do
54 | visit path
55 | expect(page).to have_selector("table tbody tr", count: 1)
56 | expect(page).to have_selector("td", text: %r(Employee Jimmy Jackalope, Jr.)i)
57 | end
58 | end
59 |
60 | describe "with a footer" do
61 | let(:options) { {footer: 1} }
62 |
63 | it "moves the specified number of rows into the footer" do
64 | visit path
65 | expect(page).to have_selector("table tfoot tr th", text: %r(Employee Moustafa McMann)i)
66 | end
67 | end
68 |
69 | end
70 |
71 | end
72 |
73 | describe "rendering CSV" do
74 |
75 | it "creates a standard CSV report" do
76 | visit '/reports/employee.csv'
77 | expect(page.body.downcase).to(
78 | eq(File.read('spec/fixtures/reports/employee.csv').downcase))
79 | end
80 |
81 | end
82 |
83 | describe "rendering XLS" do
84 |
85 | it "creates a standard XLS report" do
86 | visit '/reports/employee.xls'
87 | expect(page.body.downcase).to(
88 | eq(File.read('spec/fixtures/reports/employee.xls').downcase))
89 | end
90 |
91 | end
92 |
93 | end
94 |
--------------------------------------------------------------------------------
/spec/features/employee_with_custom_client_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe EmployeeWithCustomClientReport do
4 |
5 | describe "rendering HTML" do
6 |
7 | it "builds a report using the specified client's database" do
8 | visit '/reports/employee_with_custom_client'
9 | expect(page).to have_selector('table tbody tr', count: 3)
10 | expect(page).to have_selector('td', text: 'ELISE ELDERBERRY')
11 | end
12 |
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/spec/features/employee_with_custom_controller_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe "EmployeeReport with custom controller" do
4 |
5 | describe "rendering HTML" do
6 |
7 | it "builds a report using the specified client's database" do
8 | visit "/employee_report_custom_controller"
9 | expect(page).to have_selector("table thead tr", count: 1)
10 | expect(page).to have_selector("table tbody tr", count: 3)
11 | end
12 |
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/spec/features/namespaced_report_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe "namespaced report" do
4 |
5 | describe "rendering html" do
6 |
7 | it "displays the correct html" do
8 | visit '/reports/cats/are/super_fun'
9 | expect(page).to have_content('Super Fun Report')
10 | end
11 |
12 | end
13 |
14 | end
15 |
16 |
--------------------------------------------------------------------------------
/spec/fixtures/db/mysql2.yml.example:
--------------------------------------------------------------------------------
1 | adapter: mysql2
2 | database: dossier_test
3 | host: localhost
4 | username: root
5 | password:
6 |
--------------------------------------------------------------------------------
/spec/fixtures/db/mysql2.yml.travis:
--------------------------------------------------------------------------------
1 | adapter: mysql2
2 | database: dossier_test
3 | host: localhost
4 | username: root
5 |
--------------------------------------------------------------------------------
/spec/fixtures/db/postgresql.yml.example:
--------------------------------------------------------------------------------
1 | adapter: postgresql
2 | database: dossier_test
3 | host: localhost
4 | username: your_user_name
5 | password:
6 |
--------------------------------------------------------------------------------
/spec/fixtures/db/postgresql.yml.travis:
--------------------------------------------------------------------------------
1 | adapter: postgresql
2 | database: dossier_test
3 | host: localhost
4 | username: postgres
5 |
--------------------------------------------------------------------------------
/spec/fixtures/db/sqlite3.yml.example:
--------------------------------------------------------------------------------
1 | adapter: sqlite3
2 | database: db/test.sqlite3
3 |
--------------------------------------------------------------------------------
/spec/fixtures/db/sqlite3.yml.travis:
--------------------------------------------------------------------------------
1 | adapter: sqlite3
2 | database: db/test.sqlite3
3 |
--------------------------------------------------------------------------------
/spec/fixtures/reports/employee.csv:
--------------------------------------------------------------------------------
1 | Id,Name,Division,Salary,Suspended,Hired On
2 | 3,Elise Elderberry,Corporate Malfeasance,99000,no,2013-01-11
3 | 2,"Jimmy Jackalope, Jr.",Tedious Toiling,20000,yes,2013-01-11
4 | 1,Moustafa McMann,Zany Inventions,30000,no,2010-10-02
5 |
--------------------------------------------------------------------------------
/spec/fixtures/reports/employee.xls:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Id |
7 | Name |
8 | Division |
9 | Salary |
10 | Suspended |
11 | Hired On |
12 |
13 |
14 | 3 |
15 | Elise Elderberry |
16 | Corporate Malfeasance |
17 | 99000 |
18 | no |
19 | 2013-01-11 |
20 |
21 |
22 | 2 |
23 | Jimmy Jackalope, Jr. |
24 | Tedious Toiling |
25 | 20000 |
26 | yes |
27 | 2013-01-11 |
28 |
29 |
30 | 1 |
31 | Moustafa McMann |
32 | Zany Inventions |
33 | 30000 |
34 | no |
35 | 2010-10-02 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/spec/generators/dossier/views/views_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | require 'generators/dossier/views/views_generator'
4 |
5 | describe Dossier::ViewsGenerator, type: :generator do
6 | after(:each) { cleanup }
7 |
8 | let(:path) { Rails.root.join(*%w[app views dossier reports]) }
9 | let(:file) { raise 'implement in nested context/describe' }
10 | let(:file_path) { path.join(file) }
11 | let(:cleanup) { FileUtils.rm_f file_path }
12 |
13 | context "with no arguments or options" do
14 | let(:file) { 'show.html.haml' }
15 |
16 | before(:each) { run_generator }
17 |
18 | it "should generate a view file" do
19 | expect(FileTest.exists? file_path).to be true
20 | end
21 | end
22 |
23 | context "with_args: account_tracker" do
24 | let(:file) { 'account_tracker.html.haml' }
25 |
26 | before(:each) { run_generator %w[account_tracker] }
27 |
28 | it "should generate a edit_account form" do
29 | expect(FileTest.exists? file_path).to be true
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/spec/helpers/dossier/application_helper_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'open-uri'
3 |
4 | describe Dossier::ApplicationHelper do
5 | describe "#formatted_dossier_report_path" do
6 | let(:options) { {divisions: %w[Alpha Omega], salary: 125_000} }
7 | let(:report) { EmployeeReport.new(options) }
8 | let(:path) { helper.formatted_dossier_report_path('csv', report) }
9 | let(:uri) { URI.parse(path) }
10 |
11 | it "generates a path with the given format" do
12 | expect(uri.path).to match(/\.csv\z/)
13 | end
14 |
15 | it "generates a path with the given report name" do
16 | expect(uri.path).to match(/employee/)
17 | end
18 |
19 | it "generates a path with the given report options" do
20 | expect(uri.query).to eq({options: options}.to_query)
21 | end
22 | end
23 |
24 | describe "render_options" do
25 | describe "if exists" do
26 | let(:report) { EmployeeWithCustomViewReport.new }
27 | it "will render the options partial" do
28 | expect(helper.render_options report).to match('options be here matey!')
29 | end
30 | end
31 |
32 | describe "if missing" do
33 | let(:report) { EmployeeReport.new }
34 | it "will do nothing" do
35 | expect(helper.render_options report).to be_nil
36 | end
37 | end
38 |
39 | describe "if part of a multi report" do
40 | let(:multi) { CombinationReport.new }
41 | let(:report) { multi.reports.first }
42 | it "will not render options" do
43 | expect(helper.render_options report).to be_nil
44 | end
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/spec/routing/dossier_routes_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe "routing to dossier" do
4 | it "routes /dossier/reports/:report to dossier/reports#show" do
5 | expect(:get => '/reports/employee').to route_to(
6 | :controller => 'dossier/reports',
7 | :action => 'show',
8 | :report => 'employee'
9 | )
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | ENV['RAILS_ENV'] = 'test'
2 | require 'simplecov'
3 | require 'coveralls'
4 |
5 | # not sure why I need to do this now, its after I added dummy-application
6 | # ApplicationController.helper Dossier::ApplicationHelper
7 | # SiteController.helper Dossier::ApplicationHelper
8 |
9 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
10 | SimpleCov::Formatter::HTMLFormatter,
11 | Coveralls::SimpleCov::Formatter
12 | ]
13 | SimpleCov.start
14 | Coveralls.wear!('rails')
15 |
16 | require File.expand_path("../dummy/config/application.rb", __FILE__)
17 | require 'rspec/rails'
18 | require 'pry'
19 | require 'generator_spec'
20 | require 'capybara/rspec'
21 |
22 | # Load support files
23 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
24 |
25 | DB_CONFIG = [:mysql2, :sqlite3, :postgresql].reduce({}) do |config, adapter_name|
26 | config.tap do |hash|
27 | path = "spec/fixtures/db/#{adapter_name}.yml"
28 | hash[adapter_name] = YAML.load_file(path).symbolize_keys if File.exist?(path)
29 | end
30 | end.freeze
31 |
32 | RSpec.configure do |config|
33 | config.infer_spec_type_from_file_location!
34 | config.mock_with :rspec
35 |
36 | config.before :suite do
37 | DB_CONFIG.keys.each do |adapter|
38 | Dossier::Factory.send("#{adapter}_create_employees")
39 | Dossier::Factory.send("#{adapter}_seed_employees")
40 | end
41 | end
42 |
43 | config.after :each do
44 | Dossier.instance_variable_set(:@configuration, nil)
45 | end
46 |
47 | config.order = :random
48 | end
49 |
--------------------------------------------------------------------------------
/spec/support/factory.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | module Factory
3 | extend self
4 | def employees
5 | [
6 | {name: "Moustafa McMann", hired_on: '2010-10-02', suspended: false, division: 'Zany Inventions', salary: 30_000 },
7 | {name: 'Jimmy Jackalope, Jr.', hired_on: '2013-01-11', suspended: true, division: 'Tedious Toiling', salary: 20_000 },
8 | {name: 'Elise Elderberry', hired_on: '2013-01-11', suspended: false, division: 'Corporate Malfeasance', salary: 99_000 }
9 | ]
10 | end
11 |
12 | def mysql2_client
13 | @mysql2_client ||= Dossier::Client.new(DB_CONFIG.fetch(:mysql2))
14 | end
15 |
16 | def sqlite3_client
17 | @sqlite3_client ||= Dossier::Client.new(DB_CONFIG.fetch(:sqlite3))
18 | end
19 |
20 | def postgresql_client
21 | @postgres_client ||= Dossier::Client.new(DB_CONFIG.fetch(:postgresql))
22 | end
23 |
24 | def mysql2_connection
25 | mysql2_client.adapter.connection
26 | end
27 |
28 | def sqlite3_connection
29 | sqlite3_client.adapter.connection
30 | end
31 |
32 | def postgresql_connection
33 | postgresql_client.adapter.connection
34 | end
35 |
36 | def mysql2_create_employees
37 | mysql2_connection.execute('CREATE DATABASE IF NOT EXISTS `dossier_test`', 'FACTORY')
38 | mysql2_connection.execute('DROP TABLE IF EXISTS `employees`', 'FACTORY')
39 | mysql2_connection.execute(
40 | <<-SQL, 'FACTORY'
41 | CREATE TABLE `employees` (
42 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
43 | `name` varchar(255) NOT NULL,
44 | `division` varchar(255) NOT NULL,
45 | `salary` int(11) NOT NULL,
46 | `suspended` tinyint(1) NOT NULL DEFAULT 0,
47 | `hired_on` date NOT NULL,
48 | PRIMARY KEY (`id`)
49 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
50 | SQL
51 | )
52 | end
53 |
54 | def sqlite3_create_employees
55 | sqlite3_connection.execute('DROP TABLE IF EXISTS `employees`', 'FACTORY')
56 | sqlite3_connection.execute(
57 | <<-SQL, 'FACTORY'
58 | CREATE TABLE `employees` (
59 | `id` INTEGER PRIMARY KEY AUTOINCREMENT,
60 | `name` TEXT NOT NULL,
61 | `division` TEXT NOT NULL,
62 | `salary` INTEGER NOT NULL,
63 | `suspended` TINYINT NOT NULL DEFAULT 0,
64 | `hired_on` DATE NOT NULL
65 | );
66 | SQL
67 | )
68 | end
69 |
70 | def postgresql_create_employees
71 | # database must be created by hand (as far as I can tell), create
72 | # `dossier_test'
73 | postgresql_connection.execute('DROP TABLE IF EXISTS employees', 'FACTORY')
74 | postgresql_connection.execute(
75 | <<-SQL, 'FACTORY'
76 | CREATE TABLE employees (
77 | id serial PRIMARY KEY,
78 | name varchar(255) NOT NULL,
79 | division varchar(255) NOT NULL,
80 | salary integer NOT NULL,
81 | suspended boolean NOT NULL DEFAULT false,
82 | hired_on date NOT NULL
83 | )
84 | SQL
85 | )
86 | end
87 |
88 | def mysql2_seed_employees
89 | mysql2_connection.execute('TRUNCATE `employees`', 'FACTORY')
90 | employees.each do |employee|
91 | query = <<-QUERY
92 | INSERT INTO
93 | `employees` (`name`, `hired_on`, `suspended`, `division`, `salary`)
94 | VALUES ('#{employee[:name]}', '#{employee[:hired_on]}', #{employee[:suspended]}, '#{employee[:division]}', #{employee[:salary]});
95 | QUERY
96 | mysql2_connection.execute(query, 'FACTORY')
97 | end
98 | end
99 |
100 | def sqlite3_seed_employees
101 | sqlite3_connection.execute('DELETE FROM `employees`', 'FACTORY')
102 | employees.each do |employee|
103 | query = <<-QUERY
104 | INSERT INTO
105 | `employees` (`name`, `hired_on`, `suspended`, `division`, `salary`)
106 | VALUES ('#{employee[:name].upcase}', '#{employee[:hired_on]}', #{employee[:suspended] ? 1 : 0}, '#{employee[:division]}', #{employee[:salary]});
107 | QUERY
108 | sqlite3_connection.execute(query, 'FACTORY')
109 | end
110 | end
111 |
112 | def postgresql_seed_employees
113 | postgresql_connection.execute('TRUNCATE employees', 'FACTORY')
114 | employees.each do |employee|
115 | query = <<-QUERY
116 | INSERT INTO
117 | employees (name, hired_on, suspended, division, salary)
118 | VALUES ('#{employee[:name]}', '#{employee[:hired_on]}', #{employee[:suspended]}, '#{employee[:division]}', #{employee[:salary]});
119 | QUERY
120 | postgresql_connection.execute(query, 'FACTORY')
121 | end
122 | end
123 | end
124 | end
125 |
--------------------------------------------------------------------------------