├── .rspec
├── spec
├── dummy
│ ├── db
│ │ ├── .gitkeep
│ │ └── schema.rb
│ ├── config
│ │ ├── dossier.yml
│ │ ├── database.yml.travis
│ │ ├── routes.rb
│ │ └── database.yml.example
│ ├── config.ru
│ └── app
│ │ ├── reports
│ │ ├── test_report.rb
│ │ ├── hello_my_friends_report.rb
│ │ ├── cats
│ │ │ └── are
│ │ │ │ └── super_fun_report.rb
│ │ ├── combination_report.rb
│ │ ├── employee_with_custom_client_report.rb
│ │ ├── employee_with_custom_view_report.rb
│ │ └── employee_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
│ │ └── controllers
│ │ └── site_controller.rb
├── fixtures
│ ├── db
│ │ ├── sqlite3.yml.example
│ │ ├── sqlite3.yml.travis
│ │ ├── mysql2.yml.travis
│ │ └── mysql2.yml.example
│ └── reports
│ │ ├── employee.csv
│ │ └── employee.xls
├── features
│ ├── namespaced_report_spec.rb
│ ├── employee_with_custom_client_spec.rb
│ ├── employee_with_custom_controller_spec.rb
│ ├── combination_report_spec.rb
│ └── employee_spec.rb
├── routing
│ └── dossier_routes_spec.rb
├── generators
│ └── dossier
│ │ └── views
│ │ └── views_spec.rb
├── dossier_spec.rb
├── dossier
│ ├── multi_report_spec.rb
│ ├── adapter
│ │ ├── active_record
│ │ │ └── result_spec.rb
│ │ └── active_record_spec.rb
│ ├── naming_spec.rb
│ ├── configuration_spec.rb
│ ├── renderer_spec.rb
│ ├── connection_url_spec.rb
│ ├── responder_spec.rb
│ ├── stream_csv_spec.rb
│ ├── query_spec.rb
│ ├── formatter_spec.rb
│ ├── report_spec.rb
│ ├── client_spec.rb
│ └── result_spec.rb
├── spec_helper.rb
├── helpers
│ └── dossier
│ │ └── application_helper_spec.rb
└── support
│ └── factory.rb
├── app
├── assets
│ ├── images
│ │ └── dossier
│ │ │ └── .gitkeep
│ └── stylesheets
│ │ └── dossier
│ │ └── application.css
├── controllers
│ └── dossier
│ │ ├── application_controller.rb
│ │ └── reports_controller.rb
├── views
│ └── dossier
│ │ ├── layouts
│ │ └── application.html.haml
│ │ └── reports
│ │ ├── multi.html.haml
│ │ └── show.html.haml
└── helpers
│ └── dossier
│ └── application_helper.rb
├── lib
├── dossier
│ ├── version.rb
│ ├── engine.rb
│ ├── view_context_with_report_formatter.rb
│ ├── adapter
│ │ ├── active_record
│ │ │ └── result.rb
│ │ └── active_record.rb
│ ├── query.rb
│ ├── connection_url.rb
│ ├── stream_csv.rb
│ ├── configuration.rb
│ ├── xls.rb
│ ├── multi_report.rb
│ ├── naming.rb
│ ├── responder.rb
│ ├── client.rb
│ ├── formatter.rb
│ ├── renderer.rb
│ ├── report.rb
│ └── result.rb
├── tasks
│ └── dossier_tasks.rake
├── generators
│ └── dossier
│ │ └── views
│ │ ├── views_generator.rb
│ │ └── templates
│ │ └── show.html.haml
└── dossier.rb
├── config
├── initializers
│ └── mime_types.rb
└── routes.rb
├── Rakefile
├── .gitignore
├── script
└── rails
├── Gemfile
├── .travis.yml
├── CONTRIBUTING.md
├── MIT-LICENSE
├── dossier.gemspec
├── CHANGELOG.md
└── README.md
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 |
--------------------------------------------------------------------------------
/spec/dummy/db/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/assets/images/dossier/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/config/dossier.yml:
--------------------------------------------------------------------------------
1 | database.yml
--------------------------------------------------------------------------------
/spec/dummy/config.ru:
--------------------------------------------------------------------------------
1 | run Dummy::Application
2 |
--------------------------------------------------------------------------------
/lib/dossier/version.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | VERSION = "2.12.2"
3 | end
4 |
--------------------------------------------------------------------------------
/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | Mime::Type.register "application/xls", :xls
2 |
--------------------------------------------------------------------------------
/spec/dummy/app/reports/test_report.rb:
--------------------------------------------------------------------------------
1 | class TestReport < Dossier::Report
2 | end
3 |
--------------------------------------------------------------------------------
/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/dummy/app/reports/hello_my_friends_report.rb:
--------------------------------------------------------------------------------
1 | class HelloMyFriendsReport < Dossier::Report
2 | end
3 |
--------------------------------------------------------------------------------
/spec/fixtures/db/mysql2.yml.travis:
--------------------------------------------------------------------------------
1 | adapter: mysql2
2 | database: dossier_test
3 | host: localhost
4 | username: root
5 |
--------------------------------------------------------------------------------
/lib/tasks/dossier_tasks.rake:
--------------------------------------------------------------------------------
1 | # desc "Explaining what the task does"
2 | # task :dossier do
3 | # # Task goes here
4 | # end
5 |
--------------------------------------------------------------------------------
/spec/fixtures/db/mysql2.yml.example:
--------------------------------------------------------------------------------
1 | adapter: mysql2
2 | database: dossier_test
3 | host: localhost
4 | username: root
5 | password:
6 |
--------------------------------------------------------------------------------
/lib/dossier/engine.rb:
--------------------------------------------------------------------------------
1 | require 'rails'
2 | require 'haml'
3 |
4 | module Dossier
5 | class Engine < ::Rails::Engine
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/spec/dummy/config/database.yml.travis:
--------------------------------------------------------------------------------
1 | test:
2 | adapter: mysql2
3 | database: dossier_test
4 | username: root
5 | encoding: utf8
6 |
--------------------------------------------------------------------------------
/app/controllers/dossier/application_controller.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class ApplicationController < ::ApplicationController
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/views/dossier/layouts/application.html.haml:
--------------------------------------------------------------------------------
1 | !!!5
2 | %html
3 | %head
4 | %title #{report.formatted_title}
5 | %body
6 | = yield
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 'cats', 'are', 'super', 'fun'"
6 | end
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/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/fixtures/reports/employee.csv:
--------------------------------------------------------------------------------
1 | Id,Name,Division,Salary,Suspended,Hired On
2 | 3,Elise Elderberry,Corporate Malfeasance,99000,0,2013-01-11
3 | 2,"Jimmy Jackalope, Jr.",Tedious Toiling,20000,1,2013-01-11
4 | 1,Moustafa McMann,Zany Inventions,30000,0,2010-10-02
5 |
--------------------------------------------------------------------------------
/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/config/database.yml.example:
--------------------------------------------------------------------------------
1 | defaults: &defaults
2 | adapter: mysql2
3 | database: dossier_test
4 | host: localhost
5 | username: root
6 | password:
7 |
8 | development:
9 | <<: *defaults
10 | test:
11 | <<: *defaults
12 | production:
13 | <<: *defaults
14 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env rake
2 |
3 | begin
4 | require 'bundler/setup'
5 | rescue LoadError
6 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
7 | end
8 |
9 | Bundler::GemHelper.install_tasks
10 |
11 | require 'rails/all'
12 | require 'dummy/application/tasks'
13 |
14 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .rvmrc
2 | .ruby-version
3 | .ruby-gemset
4 | .bundle/
5 | Gemfile.lock
6 | log/*.log
7 | pkg/
8 | coverage/*
9 | spec/dummy/config/database.yml
10 | spec/dummy/db/*.sqlite3
11 | spec/dummy/log/*.log
12 | spec/dummy/tmp/
13 | spec/dummy/.sass-cache
14 | spec/fixtures/db/*.yml
15 | spec/fixtures/db/*.sqlite3
16 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/script/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application.
3 |
4 | ENGINE_ROOT = File.expand_path('../..', __FILE__)
5 | ENGINE_PATH = File.expand_path('../../lib/dossier/engine', __FILE__)
6 |
7 | require 'rails/all'
8 | require 'rails/engine/commands'
9 |
--------------------------------------------------------------------------------
/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/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 | {:get => '/reports/employee'}.should route_to(
6 | :controller => 'dossier/reports',
7 | :action => 'show',
8 | :report => 'employee'
9 | )
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gemspec
4 |
5 | RAILS_VERSION = ENV.fetch('RAILS_VERSION', '4.1.1')
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 'coveralls', require: false
16 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | rvm:
3 | - "1.9.3"
4 | - "2.0.0"
5 | - "2.1.2"
6 | env:
7 | - "RAILS_VERSION=3.2.17"
8 | - "RAILS_VERSION=4.0.4"
9 | - "RAILS_VERSION=4.1.0"
10 | script: bundle exec rspec spec
11 | before_script:
12 | - mysql -e 'create database dossier_test;'
13 | - cp spec/dummy/config/database.yml.travis spec/dummy/config/database.yml
14 | - cp spec/fixtures/db/mysql2.yml.travis spec/fixtures/db/mysql2.yml
15 | - cp spec/fixtures/db/sqlite3.yml.travis spec/fixtures/db/sqlite3.yml
16 |
--------------------------------------------------------------------------------
/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/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
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 |
--------------------------------------------------------------------------------
/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*\:[a-z]{1}\w*/) { |match| escape(report.public_send(match[1..-1])) }
19 | end
20 |
21 | def escape(value)
22 | if value.respond_to?(:map)
23 | "(#{value.map { |v| escape(v) }.join(', ')})"
24 | else
25 | report.dossier_client.escape(value)
26 | end
27 | end
28 |
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
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 |
--------------------------------------------------------------------------------
/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 = true"
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 | module CustomFormatter
21 | extend Dossier::Formatter
22 | def margery_butts(word)
23 | "Margery Butts #{word}"
24 | end
25 | end
26 |
27 | end
28 |
--------------------------------------------------------------------------------
/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::Naming.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 |
--------------------------------------------------------------------------------
/spec/generators/dossier/views/views_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe 'dossier:views' do
4 | context "with no arguments or options" do
5 |
6 | before :each do
7 | FileUtils.rm_rf("spec/dummy/app/views/dossier/reports/show.html.haml")
8 | end
9 |
10 | it "should generate a view file" do
11 | FileTest.exists?(Rails.root.join("app" "views" "dossier" "show.html.haml"))
12 | end
13 | end
14 |
15 | with_args "account_tracker" do
16 |
17 | before :each do
18 | FileUtils.rm_rf("spec/dummy/app/views/dossier/reports/account_tracker.html.haml")
19 | end
20 |
21 | it "should generate a edit_account form" do
22 | FileTest.exists?(Rails.root.join("app" "views" "dossier" "account_tracker.html.haml"))
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/spec/dossier_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier do
4 | it "is a module" do
5 | Dossier.should be_a(Module)
6 | end
7 |
8 | it "is configurable" do
9 | Dossier.configure
10 | Dossier.configuration.should_not be_nil
11 | end
12 |
13 | it "has a configuration" do
14 | Dossier.configure
15 | Dossier.configuration.should 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 | Dossier.configuration.client.should 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/naming_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Naming 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 | describe "with namespaces" do
17 | let(:klass) { Cats::Are::SuperFunReport }
18 | let(:name) { 'cats/are/super_fun' }
19 |
20 | it "converts a report class to a report name" do
21 | expect(described_class.class_to_name klass).to eq name
22 | end
23 |
24 | it "converts a report name to a report class" do
25 | expect(described_class.name_to_class name).to eq klass
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/dossier.rb:
--------------------------------------------------------------------------------
1 | require "dossier/engine"
2 | require "dossier/naming"
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/configuration.rb:
--------------------------------------------------------------------------------
1 | require 'erb'
2 | require 'yaml'
3 |
4 | module Dossier
5 | class Configuration
6 |
7 | attr_accessor :config_path, :client
8 |
9 | def initialize
10 | @config_path = Rails.root.join('config', 'dossier.yml')
11 | setup_client!
12 | end
13 |
14 | def connection_options
15 | yaml_config.merge(dburl_config || {})
16 | end
17 |
18 | def yaml_config
19 | YAML.load(ERB.new(File.read(@config_path)).result)[Rails.env].symbolize_keys
20 | end
21 |
22 | def dburl_config
23 | Dossier::ConnectionUrl.new.to_hash if ENV.has_key? "DATABASE_URL"
24 | end
25 |
26 | private
27 |
28 | def setup_client!
29 | @client = Dossier::Client.new(connection_options)
30 |
31 | rescue Errno::ENOENT => e
32 | raise ConfigurationMissingError.new(
33 | "#{e.message}. #{@config_path} must exist for Dossier to connect to the database."
34 | )
35 | end
36 |
37 | end
38 |
39 | class ConfigurationMissingError < StandardError ; end
40 | end
41 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/dossier/xls.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class Xls
3 |
4 | HEADER = %Q{\n\n\n\n\n\n}
6 |
7 | def initialize(collection, headers = nil)
8 | @headers = headers || collection.shift
9 | @collection = collection
10 | end
11 |
12 | def each
13 | yield HEADER
14 | yield as_row(@headers)
15 | @collection.each { |record| yield as_row(record) }
16 | yield FOOTER
17 | end
18 |
19 | private
20 |
21 | def as_cell(el)
22 | %{#{el} | }
23 | end
24 |
25 | def as_row(array)
26 | my_array = array.map{|a| as_cell(a)}.join("\n")
27 | "\n" + my_array + "\n
\n"
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/dossier/multi_report.rb:
--------------------------------------------------------------------------------
1 | class Dossier::MultiReport
2 | include Dossier::Naming
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/dossier/naming.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | module Naming
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 | delegate :report_name, :formatted_title, to: "self.class"
32 |
33 | module ClassMethods
34 | def report_name
35 | Dossier::Naming.class_to_name(self)
36 | end
37 |
38 | def formatted_title
39 | Dossier::Formatter.report_name(self)
40 | end
41 |
42 | def model_name
43 | @model_name ||= ActiveModel::Name.new(self, nil, superclass.name).tap do |name|
44 | name.instance_variable_set(:@param_key, 'options')
45 | end
46 | end
47 |
48 | end
49 |
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/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/responder.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class Responder < ::ActionController::Responder
3 | alias :report :resource
4 |
5 | def to_html
6 | report.renderer.engine = controller
7 | controller.response_body = report.render
8 | end
9 |
10 | def to_json
11 | controller.render json: report.results.hashes
12 | end
13 |
14 | def to_csv
15 | set_content_disposition!
16 | controller.response_body = StreamCSV.new(*collection_and_headers(report.raw_results.arrays))
17 | end
18 |
19 | def to_xls
20 | set_content_disposition!
21 | controller.response_body = Xls.new(*collection_and_headers(report.raw_results.arrays))
22 | end
23 |
24 | def respond
25 | multi_report_html_only!
26 | super
27 | end
28 |
29 | private
30 |
31 | def set_content_disposition!
32 | controller.headers["Content-Disposition"] = %[attachment;filename=#{filename}]
33 | end
34 |
35 | def collection_and_headers(collection)
36 | headers = collection.shift.map { |header| report.format_header(header) }
37 | [collection, headers]
38 | end
39 |
40 | def filename
41 | "#{report.class.filename}.#{format}"
42 | end
43 |
44 | def multi_report_html_only!
45 | if report.is_a?(Dossier::MultiReport) and format.to_s != 'html'
46 | raise Dossier::MultiReport::UnsupportedFormatError.new(format)
47 | end
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require 'rails/all'
2 | require 'dummy/application'
3 | require 'active_model'
4 | require 'simplecov'
5 | require 'coveralls'
6 |
7 | # not sure why I need to do this now, its after I added dummy-application
8 | ApplicationController.helper Dossier::ApplicationHelper
9 | SiteController.helper Dossier::ApplicationHelper
10 |
11 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
12 | SimpleCov::Formatter::HTMLFormatter,
13 | Coveralls::SimpleCov::Formatter
14 | ]
15 | SimpleCov.start
16 | Coveralls.wear!('rails')
17 |
18 | require "rails/test_help"
19 | require 'rspec/rails'
20 | require 'pry'
21 | require 'genspec'
22 | require 'capybara/rspec'
23 |
24 | Rails.backtrace_cleaner.remove_silencers!
25 |
26 | # Load support files
27 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
28 |
29 | DB_CONFIG = [:mysql2, :sqlite3].reduce({}) do |config, adapter_name|
30 | config.tap do |hash|
31 | path = "spec/fixtures/db/#{adapter_name}.yml"
32 | hash[adapter_name] = YAML.load_file(path).symbolize_keys if File.exist?(path)
33 | end
34 | end.freeze
35 |
36 | RSpec.configure do |config|
37 | config.mock_with :rspec
38 |
39 | config.before :suite do
40 | DB_CONFIG.keys.each do |adapter|
41 | Dossier::Factory.send("#{adapter}_create_employees")
42 | Dossier::Factory.send("#{adapter}_seed_employees")
43 | end
44 | end
45 |
46 | config.after :each do
47 | Dossier.instance_variable_set(:@configuration, nil)
48 | end
49 |
50 | config.order = :random
51 | end
52 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 = ["Adam Hunter", "Nathan Long", "Rodney Turnham"]
11 | s.email = ["adamhunter@me.com", "nathanmlong@gmail.com", "rodney.turnham@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/adamhunter/dossier"
15 | s.license = 'MIT'
16 |
17 | s.files = Dir["{app,config,db,lib}/**/*"] + %w[MIT-LICENSE Rakefile README.md]
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 |
27 | s.add_development_dependency "activerecord", ">= 3.2"
28 | s.add_development_dependency "sqlite3", ">= 1.3.6"
29 | s.add_development_dependency "pry", ">= 0.9.10"
30 | s.add_development_dependency "rspec-rails", ">= 2.14.1"
31 | s.add_development_dependency "genspec", "~> 0.2.7"
32 | s.add_development_dependency "capybara", "~> 2.1.0"
33 | s.add_development_dependency "simplecov", "~> 0.7.1"
34 | s.add_development_dependency "dummy-application"
35 | end
36 |
--------------------------------------------------------------------------------
/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 | self.adapter = dossier_adapter.new(self.options.except(:dossier_adapter))
11 | end
12 |
13 | def dossier_adapter
14 | adapter_name = options.fetch(:dossier_adapter) { determine_adapter_name }
15 | "Dossier::Adapter::#{adapter_name.classify}".constantize
16 | end
17 |
18 | private
19 |
20 | def determine_adapter_name
21 | if options.has_key?(:connection)
22 | namespace_for(options[:connection].class)
23 | else
24 | guess_adapter_name
25 | end
26 | end
27 |
28 | def namespace_for(klass)
29 | klass.name.split('::').first.underscore
30 | end
31 |
32 | def guess_adapter_name
33 | return namespace_for(loaded_orms.first) if loaded_orms.length == 1
34 |
35 | message = <<-Must_be_one_of_them_newfangled_ones.strip_heredoc
36 | You didn't specify a dossier_adapter. If you had exactly one
37 | ORM loaded that Dossier knew about, it would try to choose an
38 | appropriate adapter, but you have #{loaded_orms.length}.
39 | Must_be_one_of_them_newfangled_ones
40 | message << "Specifically, Dossier found #{loaded_orms.join(', ')}" if loaded_orms.any?
41 | raise IndeterminableAdapter.new(message)
42 | end
43 |
44 | def loaded_orms
45 | [].tap do |loaded_orms|
46 | loaded_orms << ActiveRecord::Base if defined?(ActiveRecord)
47 | end
48 | end
49 |
50 | class IndeterminableAdapter < StandardError; end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 0 |
19 | 2013-01-11 |
20 |
21 |
22 | | 2 |
23 | Jimmy Jackalope, Jr. |
24 | Tedious Toiling |
25 | 20000 |
26 | 1 |
27 | 2013-01-11 |
28 |
29 |
30 | | 1 |
31 | Moustafa McMann |
32 | Zany Inventions |
33 | 30000 |
34 | 0 |
35 | 2010-10-02 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/spec/dossier/configuration_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Configuration do
4 |
5 | let(:connection_options){ YAML.load_file(Rails.root.join('config', 'dossier.yml'))[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["DATABASE_URL"] = old_database_url
15 | end
16 |
17 | describe "defaults" do
18 | it "uses the rails configuration directory for the config path" do
19 | @config.config_path.should eq(Rails.root.join("config", "dossier.yml"))
20 | end
21 | end
22 |
23 | describe "client" do
24 |
25 | it %q{uses ENV["DATABASE_URL"] to merge with config/dossier.yml to setup the client} do
26 | ENV['DATABASE_URL'] = "mysql2://localhost/dossier_test"
27 | options = connection_options.merge Dossier::ConnectionUrl.new.to_hash
28 | expect(Dossier::Client).to receive(:new).with(options)
29 | Dossier.configure
30 | end
31 |
32 | it "uses config/dossier.yml to setup the client" do
33 | ENV.delete "DATABASE_URL" if ENV.has_key? "DATABASE_URL"
34 | expect(Dossier::Client).to receive(:new).with(connection_options)
35 | Dossier.configure
36 | end
37 |
38 | it "will raise an exception if config/dossier.yml cannot be read" do
39 | config_path = Rails.root.join('config')
40 | FileUtils.mv config_path.join('dossier.yml'), config_path.join('dossier.yml.test')
41 | expect { Dossier.configure }.to raise_error(Dossier::ConfigurationMissingError)
42 | FileUtils.mv config_path.join('dossier.yml.test'), config_path.join('dossier.yml')
43 | end
44 |
45 | it "will setup the connection options" do
46 | @config.connection_options.should be_a(Hash)
47 | end
48 | end
49 |
50 | end
51 |
--------------------------------------------------------------------------------
/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/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 format_salary(amount, row)
59 | return "Who's Asking?" if row[:division] == "Corporate Malfeasance"
60 | formatter.number_to_currency(amount)
61 | end
62 |
63 | def format_hired_on(date)
64 | date.to_s(:db)
65 | end
66 |
67 | def format_name(name)
68 | "Employee #{name}"
69 | end
70 |
71 | def format_suspended(value)
72 | value.to_i == 1 ? 'Yes' : 'No'
73 | end
74 |
75 | def example_before_hook
76 | # do some stuff
77 | end
78 |
79 | end
80 |
--------------------------------------------------------------------------------
/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/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 | ar_connection.stub(: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 | ar_connection.stub(: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 | ar_connection.stub(: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 | ar_connection.stub(: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 |
--------------------------------------------------------------------------------
/lib/dossier/renderer.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class Renderer
3 | attr_reader :report
4 | attr_writer :engine
5 |
6 | # Conditional for Rails 4.1 or < 4.1 Layout module
7 | Layouts = defined?(ActionView::Layouts) ? ActionView::Layouts : AbstractController::Layouts
8 |
9 | def initialize(report)
10 | @report = report
11 | end
12 |
13 | def render(options = {})
14 | render_template :custom, options
15 | rescue ActionView::MissingTemplate => _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 |
--------------------------------------------------------------------------------
/spec/dossier/responder_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Responder do
4 |
5 | def stub_out_report_results(report)
6 | report.tap { |r|
7 | r.stub(:results).and_return(results)
8 | r.stub(: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) { [stub_out_report_results(report)] }
15 | let(:controller) {
16 | ActionController::Base.new.tap { |controller| controller.stub(: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 |
--------------------------------------------------------------------------------
/lib/dossier/report.rb:
--------------------------------------------------------------------------------
1 | module Dossier
2 | class Report
3 | include Dossier::Naming
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 defined?(@results)
42 | @results
43 | end
44 |
45 | def raw_results
46 | execute unless defined?(@raw_results)
47 | @raw_results
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 dossier_client
63 | Dossier.client
64 | end
65 |
66 | def renderer
67 | @renderer ||= Renderer.new(self)
68 | end
69 |
70 | delegate :render, to: :renderer
71 |
72 | private
73 |
74 | def build_query
75 | run_callbacks(:build_query) { @query = Dossier::Query.new(self) }
76 | end
77 |
78 | def execute
79 | build_query
80 | run_callbacks :execute do
81 | self.results = dossier_client.execute(query, self.class.name)
82 | end
83 | end
84 |
85 | def results=(results)
86 | results.freeze
87 | @raw_results = Result::Unformatted.new(results, self)
88 | @results = Result::Formatted.new(results, self)
89 | end
90 |
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/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.map { |h| report.format_header(h) }
56 | end
57 |
58 | def each
59 | adapter_results.rows.each { |row| yield format(row) }
60 | end
61 |
62 | def format(row)
63 | unless row.kind_of?(Enumerable)
64 | raise ArgumentError.new("#{row.inspect} must be a kind of Enumerable")
65 | end
66 |
67 | row.each_with_index.map do |value, i|
68 | column = raw_headers.at(i)
69 | method = "format_#{column}"
70 |
71 | if report.respond_to?(method)
72 | args = [method, value]
73 | # Provide the row as context if the formatter takes two arguments
74 | args << row_hash(row) if report.method(method).arity == 2
75 | report.public_send(*args)
76 | else
77 | report.format_column(column, value)
78 | end
79 | end
80 | end
81 | end
82 |
83 | class Unformatted < Result
84 | def each
85 | adapter_results.rows.each { |row| yield row }
86 | end
87 |
88 | def headers
89 | raw_headers
90 | end
91 | end
92 |
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/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) { collection[0].stub(:to_csv).and_return { raise error } }
61 |
62 | it "provides a backtrace if local request" do
63 | Rails.application.config.stub(:consider_all_requests_local).and_return(true)
64 | streamer.each { |line| output << line }
65 | expect(output).to include(error)
66 | end
67 |
68 | it "provides a simple error if not a local request" do
69 | Rails.application.config.stub(:consider_all_requests_local).and_return(false)
70 | streamer.each { |line| output << line }
71 | expect(output).to match(/something went wrong/)
72 | end
73 | end
74 |
75 | end
76 |
--------------------------------------------------------------------------------
/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 "when a custom view exists for the report" do
16 |
17 | it "uses the custom view" do
18 | visit '/reports/employee_with_custom_view'
19 | expect(page).to have_content('Yeah. Did you get that memo?')
20 | end
21 |
22 | it "has access to the reports formatter in the view scope" do
23 | visit '/reports/employee_with_custom_view'
24 | expect(page).to have_content('Margery Butts')
25 | end
26 |
27 | end
28 |
29 | context "when no custom view exists for the report" do
30 | let(:path) { dossier_report_path(report: 'employee', options: options) }
31 | let(:options) { nil }
32 |
33 | it "creates an HTML report using its standard 'show' view" do
34 | visit path
35 | expect(page).to have_selector("table thead tr", count: 1)
36 | expect(page).to have_selector("table tbody tr", count: 3)
37 | end
38 |
39 | describe "with options for filtering" do
40 | let(:options) { {
41 | salary: true, order: 'desc',
42 | names: ['Jimmy Jackalope', 'Moustafa McMann'],
43 | divisions: ['Tedious Toiling']
44 | } }
45 |
46 | it "uses any options provided" do
47 | visit path
48 | expect(page).to have_selector("table tbody tr", count: 1)
49 | expect(page).to have_selector("td", text: 'Employee Jimmy Jackalope, Jr.')
50 | end
51 | end
52 |
53 | describe "with a footer" do
54 | let(:options) { {footer: 1} }
55 |
56 | it "moves the specified number of rows into the footer" do
57 | visit path
58 | expect(page).to have_selector("table tfoot tr th", text: 'Employee Moustafa McMann')
59 | end
60 | end
61 |
62 | end
63 |
64 | end
65 |
66 | describe "rendering CSV" do
67 |
68 | it "creates a standard CSV report" do
69 | visit '/reports/employee.csv'
70 | expect(page.body).to eq(File.read('spec/fixtures/reports/employee.csv'))
71 | end
72 |
73 | end
74 |
75 | describe "rendering XLS" do
76 |
77 | it "creates a standard XLS report" do
78 | visit '/reports/employee.xls'
79 | expect(page.body).to eq(File.read('spec/fixtures/reports/employee.xls'))
80 | end
81 |
82 | end
83 |
84 | end
85 |
--------------------------------------------------------------------------------
/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 | report.stub(:salary).and_return(2)
10 | report.stub(: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 | report.stub(:sql).and_return("SELECT * FROM employees WHERE id = :id OR girth < :girth OR hired_on = :hired_on")
21 | report.stub(:id).and_return(92)
22 | report.stub(:girth).and_return(3.14)
23 | report.stub(: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 | report.stub(:sql).and_return("SELECT * FROM employees WHERE stuff IN :stuff")
43 | report.stub(: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 | report.stub(:sql).and_return("SELECT * FROM employees WHERE type = 'Foo::Bar'")
64 | query.should_not_receive(:Bar)
65 | query.to_s
66 | end
67 |
68 | it "does not escape a top-level constant" do
69 | report.stub(:sql).and_return("SELECT * FROM employees WHERE type = '::Foo'")
70 | query.should_not_receive(:Foo)
71 | query.to_s
72 | end
73 |
74 | end
75 |
76 | end
77 |
78 | end
79 |
--------------------------------------------------------------------------------
/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/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 | TestReport.report_name.should 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 | report.options.should eq('foo' => 'bar')
33 | end
34 |
35 | it 'generates column headers' do
36 | report.format_header('Foo').should eq 'Foo'
37 | end
38 |
39 | it 'allows for column header customization' do
40 | report_with_custom_header.format_header(:generic).should 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 | Dossier.client.stub(:execute).and_return([])
66 | report.stub(: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 | report.results.should_not 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/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 mysql2_connection
21 | mysql2_client.adapter.connection
22 | end
23 |
24 | def sqlite3_connection
25 | sqlite3_client.adapter.connection
26 | end
27 |
28 | def mysql2_create_employees
29 | mysql2_connection.execute('CREATE DATABASE IF NOT EXISTS `dossier_test`', 'FACTORY')
30 | mysql2_connection.execute('DROP TABLE IF EXISTS `employees`', 'FACTORY')
31 | mysql2_connection.execute(
32 | <<-SQL, 'FACTORY'
33 | CREATE TABLE `employees` (
34 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
35 | `name` varchar(255) NOT NULL,
36 | `division` varchar(255) NOT NULL,
37 | `salary` int(11) NOT NULL,
38 | `suspended` tinyint(1) NOT NULL DEFAULT 0,
39 | `hired_on` date NOT NULL,
40 | PRIMARY KEY (`id`)
41 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
42 | SQL
43 | )
44 | end
45 |
46 | def sqlite3_create_employees
47 | sqlite3_connection.execute('DROP TABLE IF EXISTS `employees`', 'FACTORY')
48 | sqlite3_connection.execute(
49 | <<-SQL, 'FACTORY'
50 | CREATE TABLE `employees` (
51 | `id` INTEGER PRIMARY KEY AUTOINCREMENT,
52 | `name` TEXT NOT NULL,
53 | `division` TEXT NOT NULL,
54 | `salary` INTEGER NOT NULL,
55 | `suspended` TINYINT NOT NULL DEFAULT 0,
56 | `hired_on` DATE NOT NULL
57 | );
58 | SQL
59 | )
60 | end
61 |
62 | def mysql2_seed_employees
63 | mysql2_connection.execute('TRUNCATE `employees`', 'FACTORY')
64 | employees.each do |employee|
65 | query = <<-QUERY
66 | INSERT INTO
67 | `employees` (`name`, `hired_on`, `suspended`, `division`, `salary`)
68 | VALUES ('#{employee[:name]}', '#{employee[:hired_on]}', #{employee[:suspended]}, '#{employee[:division]}', #{employee[:salary]});
69 | QUERY
70 | mysql2_connection.execute(query, 'FACTORY')
71 | end
72 | end
73 |
74 | def sqlite3_seed_employees
75 | sqlite3_connection.execute('DELETE FROM `employees`', 'FACTORY')
76 | employees.each do |employee|
77 | query = <<-QUERY
78 | INSERT INTO
79 | `employees` (`name`, `hired_on`, `suspended`, `division`, `salary`)
80 | VALUES ('#{employee[:name].upcase}', '#{employee[:hired_on]}', #{employee[:suspended] ? 1 : 0}, '#{employee[:division]}', #{employee[:salary]});
81 | QUERY
82 | sqlite3_connection.execute(query, 'FACTORY')
83 | end
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/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')
36 | end
37 |
38 | end
39 |
40 | context "when not given a connection or a dossier_adapter option" do
41 |
42 | let(:client) { described_class.new(username: 'Jimmy') }
43 |
44 | describe "if there is one known ORM loaded" do
45 |
46 | before :each do
47 | described_class.any_instance.stub(:loaded_orms).and_return([double(:class, name: 'ActiveRecord::Base')])
48 | end
49 |
50 | it "uses that ORM's adapter" do
51 | expect(Dossier::Adapter::ActiveRecord).to receive(:new).with(username: 'Jimmy')
52 | described_class.new(username: 'Jimmy')
53 | end
54 |
55 | end
56 |
57 | context "if there are no known ORMs loaded" do
58 |
59 | before :each do
60 | described_class.any_instance.stub(:loaded_orms).and_return([])
61 | end
62 |
63 | it "raises an error" do
64 | expect{described_class.new(username: 'Jimmy')}.to raise_error(Dossier::Client::IndeterminableAdapter)
65 | end
66 |
67 | end
68 |
69 | describe "if there are multiple known ORMs loaded" do
70 |
71 | before :each do
72 | described_class.any_instance.stub(:loaded_orms).and_return([:orm1, :orm2])
73 | end
74 |
75 | it "raises an error" do
76 | expect{described_class.new(username: 'Jimmy')}.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 | client.stub(: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 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | Dossier does its best to use [semantic versioning](http://semver.org).
4 |
5 | ## Unreleased
6 | - 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.
7 | - 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
8 | - 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/)
9 | - 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.
10 |
11 | ## v2.8.0
12 | - Support namespaces for report names (`cats/are/super_fun` => `Cats::Are::SuperRunReport`
13 | - Moved controller response formats into responder class
14 | - 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.
15 | - Filename is configurable by overriding `self.filename` in any given report class
16 | - Options have been extracted into a partial so the entire view doesn't need to be overridden
17 | - Reports will work natively with `form_for` with no additional options (except `method: :get`)
18 | - 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)
19 | - added ability to use the report's formatter in view context for a custom view
20 | - allows setting template at class or instance level. Class.template = 'x' or def template; 'x'; end
21 |
22 | ## v2.7.0
23 | - Added `formatted_dossier_report_path` helper method
24 |
25 | ## v2.6.0
26 | - Support ability to combine reports into a macro report using the Dossier::MultiReport class
27 |
28 | ## v2.5.0
29 |
30 | - Made `#report_class` a public method on `Dossier::ReportsController` for easier integration with authorization gems
31 | - Moved "Download CSV" link to top of default report view
32 | - Formatting the header is now an instance method on the report class called `format_header` (thanks @rubysolo)
33 | - Rails 4 compatibility (thanks @rubysolo)
34 | - Fixed bug when using class names in SQL queries, only lowercase symbols that are a-z will be replaced with the respective method call.
35 | - Added view generator for Rails (thanks @wzcolon)
36 |
37 | ## v2.3.0
38 |
39 | 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`.
40 |
41 | ## v2.2.0
42 |
43 | Support for XLS output, added by [michelboaventura](https://github.com/michelboaventura)
44 |
45 | ## v2.1.1
46 |
47 | Fixed bug: in production, CSV rendering should not contain a backtrace if there's an error.
48 |
49 | ## v2.1.0
50 |
51 | Formatter methods will now be passed a hash of the row values if they accept a second argument. This allows formatting certain rows specially.
52 |
53 | ## v2.0.1
54 |
55 | Switched away from `classify` in determining report name to avoid singularization.
56 |
57 | ## v2.0.0
58 |
59 | First public release (previously internal)
60 |
--------------------------------------------------------------------------------
/spec/dossier/result_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Dossier::Result do
4 |
5 | module AbstractStub
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 AbstractStub } }
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 | result.stub(: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 | adapter_result.stub(: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 | result.should_not_receive(:format)
161 | result.each { |result| }
162 | end
163 |
164 | end
165 |
166 | end
167 |
168 | end
169 |
170 | end
171 |
--------------------------------------------------------------------------------
/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 | [](https://rubygems.org/gems/dossier)
9 | [](https://codeclimate.com/github/adamhunter/dossier)
10 | [](https://travis-ci.org/adamhunter/dossier)
11 | [](https://coveralls.io/r/adamhunter/dossier?branch=master)
12 | [](https://gemnasium.com/adamhunter/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 | ## Report Options and Footers
141 |
142 | 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.
143 |
144 | 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:
145 |
146 | ```ruby
147 | # app/views/dossier/reports/employee.html.haml
148 |
149 | = form_for report, as: :options, url: url_for, html: {method: :get} do |f|
150 | = f.label "Salary greater than:"
151 | = f.text_field :salary_greater_than
152 | = f.label "In Division:"
153 | = f.select_tag :in_division, divisions_collection
154 | = f.button "Submit"
155 |
156 | = render template: 'dossier/reports/show', locals: {report: report}
157 | ```
158 |
159 | It's up to you to use these options in generating your SQL query.
160 |
161 | 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.
162 |
163 | ## Additional View Customization
164 |
165 | To further customize your results view, run the the generator provided. The default will provide 'app/views/dossier/reports/show'.
166 |
167 | ```ruby
168 | rails generate dossier:views
169 | ```
170 | You may pass a filename as an argument. This example creates 'app/views/dossier/reports/account_tracker.html.haml'.
171 |
172 | ```ruby
173 | rails generate dossier:views account_tracker
174 | ```
175 |
176 | ## Callbacks
177 |
178 | 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:
179 |
180 | ```ruby
181 | set_callback :build_query, :before, :run_my_stored_procedure
182 | set_callback :execute, :after do
183 | mangle_results
184 | end
185 | ```
186 |
187 | ## Using Reports Outside of Dossier::ReportsController
188 |
189 | ### With Other Controllers
190 |
191 | 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:
192 |
193 | ```ruby
194 | class ProjectsController < ApplicationController
195 |
196 | def show
197 | @project = Project.find(params[:id])
198 | @project_status_report = ProjectStatusReport.new(project: @project)
199 | @project_revenue_report = ProjectRevenueReport.new(project: @project, grouped: 'monthly')
200 | end
201 | end
202 | ```
203 |
204 | ```haml
205 | .span6
206 | = render template: 'dossier/reports/show', locals: {report: @project_status_report.run}
207 | .span6
208 | = render template: 'dossier/reports/show', locals: {report: @project_revenue_report.run}
209 | ```
210 |
211 | ### Dossier for APIs
212 |
213 | ```ruby
214 | class Api::ProjectsController < Api::ApplicationController
215 |
216 | def snapshot
217 | render json: ProjectStatusReport.new(project: @project).results.hashes
218 | end
219 | end
220 | ```
221 |
222 | ## Advanced Usage
223 |
224 | 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`.
225 |
226 | ## Compatibility
227 |
228 | 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.
229 |
230 | ## Protecting Access to Reports
231 |
232 | 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.
233 |
234 | 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:
235 |
236 | 1. `Dossier::Controller` subclasses `ApplicationController`
237 | 2. If you use an initializer, you can call methods on `Dossier::Controller`
238 |
239 | So for a very simple, roll-your-own solution, you could do this:
240 |
241 | ```ruby
242 | # config/initializers/dossier.rb
243 | Rails.application.config.to_prepare do
244 | # Define `#my_protection_method` on your ApplicationController
245 | Dossier::ReportsController.before_filter :my_protection_method
246 | end
247 | ```
248 |
249 | 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:
250 |
251 | ```ruby
252 | # app/controllers/application_controller.rb
253 | class ApplicationController < ActionController::Base
254 | # Basic "you must be logged in"; will apply to all subclassing controllers,
255 | # including Dossier::Controller.
256 | before_filter :authenticate_user!
257 | end
258 |
259 | # config/initializers/dossier.rb
260 | Rails.application.config.to_prepare do
261 | # Use Authority to enforce viewing permissions for this report.
262 | # You might set the report's `authorizer_name` to 'ReportsAuthorizer', and
263 | # define that with a `readable_by?(user)` method that suits your needs
264 | Dossier::ReportsController.authorize_actions_for :report_class
265 | end
266 | ```
267 |
268 | See the referenced gems for more documentation on using them.
269 |
270 | ## Running the Tests
271 |
272 | Note: when you run the tests, Dossier will **make and/or truncate** some tables in the `dossier_test` database.
273 |
274 | - Run `bundle`
275 | - `RAILS_ENV=test rake db:create`
276 | - `cp spec/dummy/config/database.yml{.example,}` and edit it so that it can connect to the test database.
277 | - `cp spec/fixtures/db/mysql2.yml{.example,}`
278 | - `cp spec/fixtures/db/sqlite3.yml{.example,}`
279 | - `rspec spec`
280 |
281 | ## Moar Dokumentationz pleaze
282 |
283 | - How Dossier uses ORM adapters to connect to databases, currently only AR's are used.
284 | - Examples of connecting to different databases, of the same type or a different one
285 | - Document using hooks and what methods are available in them
286 | - Callbacks, eg:
287 | - Stored procedures
288 | - Reformat results
289 | - Linking
290 | - To other reports
291 | - To other formats
292 | - Extending the formatter
293 | - Show how to do "crosstab" reports (preliminary query to determine columns, then build SQL case statements?)
294 |
295 | ## Roadmap
296 |
297 | - Moar Dokumentationz pleaze
298 | - Use the [`roo`](https://github.com/hmcgowan/roo) gem to generate a variety of output formats
299 |
--------------------------------------------------------------------------------