├── .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 | [![Gem Version](https://badge.fury.io/rb/dossier.svg)](http://badge.fury.io/rb/dossier) 9 | [![Code Climate](https://codeclimate.com/github/tma1/dossier/badges/gpa.svg)](https://codeclimate.com/github/tma1/dossier) 10 | [![Build Status](https://travis-ci.org/tma1/dossier.svg?branch=master)](https://travis-ci.org/tma1/dossier) 11 | [![Coverage Status](https://coveralls.io/repos/tma1/dossier/badge.svg?branch=master&service=github)](https://coveralls.io/github/tma1/dossier?branch=master) 12 | [![Dependency Status](https://gemnasium.com/tma1/dossier.svg)](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} 5 | FOOTER = %Q{
\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 | --------------------------------------------------------------------------------