dossier 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/MIT-LICENSE +20 -0
- data/README.markdown +181 -0
- data/Rakefile +27 -0
- data/app/assets/javascripts/dossier/application.js +15 -0
- data/app/assets/stylesheets/dossier/application.css +13 -0
- data/app/controllers/dossier/application_controller.rb +4 -0
- data/app/controllers/dossier/reports_controller.rb +34 -0
- data/app/helpers/dossier/application_helper.rb +4 -0
- data/app/views/dossier/reports/show.html.haml +21 -0
- data/config/routes.rb +5 -0
- data/lib/dossier.rb +31 -0
- data/lib/dossier/adapter/active_record.rb +40 -0
- data/lib/dossier/adapter/active_record/result.rb +24 -0
- data/lib/dossier/client.rb +52 -0
- data/lib/dossier/configuration.rb +28 -0
- data/lib/dossier/engine.rb +7 -0
- data/lib/dossier/formatter.rb +33 -0
- data/lib/dossier/query.rb +30 -0
- data/lib/dossier/report.rb +69 -0
- data/lib/dossier/result.rb +67 -0
- data/lib/dossier/stream_csv.rb +24 -0
- data/lib/dossier/version.rb +3 -0
- data/lib/tasks/dossier_tasks.rake +4 -0
- data/spec/dossier/adapter/active_record/result_spec.rb +31 -0
- data/spec/dossier/adapter/active_record_spec.rb +54 -0
- data/spec/dossier/client_spec.rb +109 -0
- data/spec/dossier/configuration_spec.rb +35 -0
- data/spec/dossier/formatter_spec.rb +39 -0
- data/spec/dossier/query_spec.rb +59 -0
- data/spec/dossier/report_spec.rb +67 -0
- data/spec/dossier/result_spec.rb +119 -0
- data/spec/dossier_spec.rb +31 -0
- data/spec/dummy/README.rdoc +261 -0
- data/spec/dummy/Rakefile +7 -0
- data/spec/dummy/app/assets/javascripts/application.js +15 -0
- data/spec/dummy/app/assets/stylesheets/application.css +13 -0
- data/spec/dummy/app/controllers/application_controller.rb +3 -0
- data/spec/dummy/app/controllers/site_controller.rb +5 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/views/dossier/reports/suspended_employee.html.haml +1 -0
- data/spec/dummy/app/views/dossier/reports/total.html.haml +11 -0
- data/spec/dummy/app/views/layouts/application.html.erb +14 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/config/application.rb +56 -0
- data/spec/dummy/config/boot.rb +10 -0
- data/spec/dummy/config/database.yml.example +13 -0
- data/spec/dummy/config/environment.rb +9 -0
- data/spec/dummy/config/environments/development.rb +37 -0
- data/spec/dummy/config/environments/production.rb +67 -0
- data/spec/dummy/config/environments/test.rb +37 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/inflections.rb +15 -0
- data/spec/dummy/config/initializers/mime_types.rb +5 -0
- data/spec/dummy/config/initializers/secret_token.rb +7 -0
- data/spec/dummy/config/initializers/session_store.rb +8 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/spec/dummy/config/locales/en.yml +5 -0
- data/spec/dummy/config/routes.rb +5 -0
- data/spec/dummy/config/setup_load_paths.rb +15 -0
- data/spec/dummy/db/schema.rb +24 -0
- data/spec/dummy/dossier_test +0 -0
- data/spec/dummy/public/404.html +26 -0
- data/spec/dummy/public/422.html +26 -0
- data/spec/dummy/public/500.html +25 -0
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/dummy/script/rails +6 -0
- data/spec/fixtures/customized_employee_report.html +38 -0
- data/spec/fixtures/db/mysql2.yml.example +4 -0
- data/spec/fixtures/db/sqlite3.yml.example +2 -0
- data/spec/fixtures/employee_report.csv +4 -0
- data/spec/fixtures/employee_report.html +54 -0
- data/spec/fixtures/employee_report_with_footer.html +56 -0
- data/spec/fixtures/employee_with_custom_client.html +54 -0
- data/spec/requests/employee_report_spec.rb +52 -0
- data/spec/requests/employee_with_custom_client_spec.rb +13 -0
- data/spec/routing/dossier_routes_spec.rb +11 -0
- data/spec/spec_helper.rb +36 -0
- data/spec/support/factory.rb +86 -0
- data/spec/support/reports/employee_report.rb +78 -0
- data/spec/support/reports/employee_with_custom_client.rb +10 -0
- data/spec/support/reports/sqlite_employee_report.rb +15 -0
- data/spec/support/reports/supended_employee_report.rb +7 -0
- data/spec/support/reports/test_report.rb +4 -0
- data/spec/support/reports/total_report.rb +35 -0
- metadata +361 -0
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Dossier
|
4
|
+
class Configuration
|
5
|
+
|
6
|
+
attr_accessor :config_path, :connection_options, :client
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@config_path = Rails.root.join('config', 'dossier.yml')
|
10
|
+
setup_client!
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def setup_client!
|
16
|
+
@connection_options = YAML.load_file(@config_path)[Rails.env].symbolize_keys
|
17
|
+
@client = Dossier::Client.new(@connection_options)
|
18
|
+
|
19
|
+
rescue Errno::ENOENT => e
|
20
|
+
raise ConfigurationMissingError.new(
|
21
|
+
"#{e.message}. #{@config_path} must exist for Dossier to connect to the database."
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
|
27
|
+
class ConfigurationMissingError < StandardError ; end
|
28
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Dossier
|
2
|
+
module Formatter
|
3
|
+
extend self
|
4
|
+
extend ActiveSupport::Inflector
|
5
|
+
extend ActionView::Helpers::NumberHelper
|
6
|
+
|
7
|
+
def number_to_currency_from_cents(value)
|
8
|
+
number_to_currency(value /= 100.0)
|
9
|
+
end
|
10
|
+
|
11
|
+
def url_formatter
|
12
|
+
@url_formatter ||= UrlFormatter.new
|
13
|
+
end
|
14
|
+
|
15
|
+
delegate :url_for, :link_to, :url_helpers, to: :url_formatter
|
16
|
+
|
17
|
+
class UrlFormatter
|
18
|
+
include ActionView::Helpers::UrlHelper
|
19
|
+
|
20
|
+
def _routes
|
21
|
+
Rails.application.routes
|
22
|
+
end
|
23
|
+
|
24
|
+
# No controller in current context, must be specified when generating routes
|
25
|
+
def controller
|
26
|
+
end
|
27
|
+
|
28
|
+
def url_helpers
|
29
|
+
_routes.url_helpers
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,30 @@
|
|
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*\:\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
|
@@ -0,0 +1,69 @@
|
|
1
|
+
module Dossier
|
2
|
+
class Report
|
3
|
+
include ActiveSupport::Callbacks
|
4
|
+
extend ActiveModel::Naming
|
5
|
+
|
6
|
+
define_callbacks :build_query, :execute
|
7
|
+
|
8
|
+
attr_reader :options
|
9
|
+
|
10
|
+
def initialize(options = {})
|
11
|
+
@options = options.dup.with_indifferent_access
|
12
|
+
end
|
13
|
+
|
14
|
+
def sql
|
15
|
+
raise NotImplementedError, "`sql` method must be defined by each report"
|
16
|
+
end
|
17
|
+
|
18
|
+
def query
|
19
|
+
build_query unless defined?(@query)
|
20
|
+
@query.to_s
|
21
|
+
end
|
22
|
+
|
23
|
+
def results
|
24
|
+
execute unless defined?(@results)
|
25
|
+
@results
|
26
|
+
end
|
27
|
+
|
28
|
+
def raw_results
|
29
|
+
execute unless defined?(@raw_results)
|
30
|
+
@raw_results
|
31
|
+
end
|
32
|
+
|
33
|
+
def run
|
34
|
+
tap { execute }
|
35
|
+
end
|
36
|
+
|
37
|
+
def view
|
38
|
+
self.class.name.sub(/Report\Z/, '').underscore
|
39
|
+
end
|
40
|
+
|
41
|
+
def formatter
|
42
|
+
Dossier::Formatter
|
43
|
+
end
|
44
|
+
|
45
|
+
def dossier_client
|
46
|
+
Dossier.client
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def build_query
|
52
|
+
run_callbacks(:build_query) { @query = Dossier::Query.new(self) }
|
53
|
+
end
|
54
|
+
|
55
|
+
def execute
|
56
|
+
build_query
|
57
|
+
run_callbacks :execute do
|
58
|
+
self.results = dossier_client.execute(query, self.class.name)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def results=(results)
|
63
|
+
results.freeze
|
64
|
+
@raw_results = Result::Unformatted.new(results, self)
|
65
|
+
@results = Result::Formatted.new(results, self)
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,67 @@
|
|
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 headers
|
13
|
+
adapter_results.headers
|
14
|
+
end
|
15
|
+
|
16
|
+
def body
|
17
|
+
rows.first(rows.length - report.options[:footer].to_i)
|
18
|
+
end
|
19
|
+
|
20
|
+
def footers
|
21
|
+
rows.last(report.options[:footer].to_i)
|
22
|
+
end
|
23
|
+
|
24
|
+
def rows
|
25
|
+
@rows ||= to_a
|
26
|
+
end
|
27
|
+
|
28
|
+
def arrays
|
29
|
+
@arrays ||= [headers] + rows
|
30
|
+
end
|
31
|
+
|
32
|
+
def hashes
|
33
|
+
return @hashes if defined?(@hashes)
|
34
|
+
@hashes = rows.map { |row| Hash[headers.zip(row)] }
|
35
|
+
end
|
36
|
+
|
37
|
+
def each
|
38
|
+
raise NotImplementedError, "Every result class must define `each`"
|
39
|
+
end
|
40
|
+
|
41
|
+
class Formatted < Result
|
42
|
+
def each
|
43
|
+
adapter_results.rows.each do |row|
|
44
|
+
yield format(row)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def format(result_row)
|
49
|
+
unless result_row.kind_of?(Enumerable)
|
50
|
+
raise ArgumentError.new("#{result_row.inspect} must be a kind of Enumerable")
|
51
|
+
end
|
52
|
+
|
53
|
+
result_row.each_with_index.map do |field, i|
|
54
|
+
method = "format_#{headers[i]}"
|
55
|
+
report.respond_to?(method) ? report.public_send(method, field) : field
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
class Unformatted < Result
|
61
|
+
def each
|
62
|
+
adapter_results.rows.each { |row| yield row }
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'csv'
|
2
|
+
|
3
|
+
module Dossier
|
4
|
+
class StreamCSV
|
5
|
+
|
6
|
+
def initialize(collection, headers = nil)
|
7
|
+
@headers = headers || collection.shift
|
8
|
+
@collection = collection
|
9
|
+
end
|
10
|
+
|
11
|
+
def each
|
12
|
+
yield @headers.map { |header| Dossier::Formatter.titleize(header) }.to_csv
|
13
|
+
@collection.each do |record|
|
14
|
+
yield record.to_csv
|
15
|
+
end
|
16
|
+
rescue => e
|
17
|
+
yield e.message
|
18
|
+
e.backtrace.each do |line|
|
19
|
+
yield "#{line}\n"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,31 @@
|
|
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
|
+
ar_connection_results.should_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
|
+
|
@@ -0,0 +1,54 @@
|
|
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
|
+
ar_connection.should_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
|
+
ar_connection.should_receive(:exec_query).with(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
|
+
adapter_result_class.should_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
|
@@ -0,0 +1,109 @@
|
|
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
|
+
Dossier::Adapter::SpecAdapter.should_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
|
+
Dossier::Adapter::ActiveRecord.should_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
|
+
adapter.should_receive(:escape).with('Bobby Tables')
|
98
|
+
client.escape('Bobby Tables')
|
99
|
+
end
|
100
|
+
|
101
|
+
it "delegates `execute` to its adapter" do
|
102
|
+
adapter.should_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
|