├── .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} 5 | FOOTER = %Q{
\n
\n
\n} 6 | 7 | def initialize(collection, headers = nil) 8 | @headers = headers || collection.shift 9 | @collection = collection 10 | end 11 | 12 | def each 13 | yield HEADER 14 | yield as_row(@headers) 15 | @collection.each { |record| yield as_row(record) } 16 | yield FOOTER 17 | end 18 | 19 | private 20 | 21 | def as_cell(el) 22 | %{#{el}} 23 | end 24 | 25 | def as_row(array) 26 | my_array = array.map{|a| as_cell(a)}.join("\n") 27 | "\n" + my_array + "\n\n" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/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 | [![Gem Version](https://badge.fury.io/rb/dossier.png)](https://rubygems.org/gems/dossier) 9 | [![Code Climate](https://codeclimate.com/github/adamhunter/dossier.png)](https://codeclimate.com/github/adamhunter/dossier) 10 | [![Build Status](https://travis-ci.org/adamhunter/dossier.png?branch=master)](https://travis-ci.org/adamhunter/dossier) 11 | [![Coverage Status](https://coveralls.io/repos/adamhunter/dossier/badge.png?branch=master)](https://coveralls.io/r/adamhunter/dossier?branch=master) 12 | [![Dependency Status](https://gemnasium.com/adamhunter/dossier.png)](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 | --------------------------------------------------------------------------------