dossier 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|