qreport 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +17 -0
- data/.rspec +1 -0
- data/ChangeLog +11 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +97 -0
- data/Rakefile +13 -0
- data/lib/qreport.rb +8 -0
- data/lib/qreport/connection.rb +378 -0
- data/lib/qreport/initialization.rb +14 -0
- data/lib/qreport/main.rb +9 -0
- data/lib/qreport/model.rb +8 -0
- data/lib/qreport/report_run.rb +191 -0
- data/lib/qreport/report_runner.rb +123 -0
- data/lib/qreport/version.rb +3 -0
- data/qreport.gemspec +25 -0
- data/spec/connection_spec.rb +118 -0
- data/spec/report_runner_spec.rb +159 -0
- data/spec/spec_helper.rb +16 -0
- metadata +137 -0
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'qreport'
|
2
|
+
|
3
|
+
module Qreport
|
4
|
+
module Initialization
|
5
|
+
def initialize opts = nil
|
6
|
+
opts ||= EMPTY_Hash
|
7
|
+
initialize_before_opts if respond_to? :initialize_before_opts
|
8
|
+
opts.each do | k, v |
|
9
|
+
send(:"#{k}=", v)
|
10
|
+
end
|
11
|
+
initialize_after_opts if respond_to? :initialize_after_opts
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/lib/qreport/main.rb
ADDED
@@ -0,0 +1,191 @@
|
|
1
|
+
require 'qreport'
|
2
|
+
require 'qreport/model'
|
3
|
+
require 'qreport/initialization'
|
4
|
+
|
5
|
+
module Qreport
|
6
|
+
class ReportRun
|
7
|
+
include Model, Initialization
|
8
|
+
|
9
|
+
attr_accessor :id
|
10
|
+
attr_accessor :name, :sql, :additional_columns
|
11
|
+
attr_accessor :description
|
12
|
+
attr_accessor :arguments
|
13
|
+
attr_accessor :report_id
|
14
|
+
attr_accessor :report_sql
|
15
|
+
attr_accessor :columns, :base_columns, :column_signature
|
16
|
+
attr_accessor :report_table
|
17
|
+
attr_accessor :nrows
|
18
|
+
attr_accessor :created_at, :started_at, :finished_at
|
19
|
+
|
20
|
+
# Construct report_table name from column names and types.
|
21
|
+
def report_table
|
22
|
+
@report_table ||=
|
23
|
+
"#{name}_#{column_signature}".
|
24
|
+
gsub(/[^a-z0-9_]/i, '_').freeze
|
25
|
+
end
|
26
|
+
|
27
|
+
def column_signature
|
28
|
+
@column_signature ||=
|
29
|
+
begin
|
30
|
+
return @column_signature = "ERROR" if base_columns.empty?
|
31
|
+
column_signature_string = columns.to_json
|
32
|
+
@column_signature_hash = Digest::MD5.hexdigest(column_signature_string)
|
33
|
+
Base64.strict_encode64(Digest::MD5.digest(column_signature_string)).
|
34
|
+
sub(/=+$/, '').
|
35
|
+
gsub(/[^a-z0-9]/i, '_').
|
36
|
+
downcase.
|
37
|
+
freeze
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def base_columns
|
42
|
+
@base_columns ||= EMPTY_Array
|
43
|
+
end
|
44
|
+
|
45
|
+
def additional_columns
|
46
|
+
@additional_columns ||= EMPTY_Array
|
47
|
+
end
|
48
|
+
|
49
|
+
def columns
|
50
|
+
@columns ||=
|
51
|
+
base_columns +
|
52
|
+
additional_columns.map{|x| x.map(&:to_s)}
|
53
|
+
end
|
54
|
+
|
55
|
+
def run! conn
|
56
|
+
runner = Qreport::ReportRunner.new
|
57
|
+
runner.connection = conn
|
58
|
+
runner.run!(self)
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
def error
|
63
|
+
self.error = @error if String === @error
|
64
|
+
@error
|
65
|
+
end
|
66
|
+
|
67
|
+
def error= x
|
68
|
+
case x
|
69
|
+
when nil, Hash
|
70
|
+
@error = x
|
71
|
+
when String
|
72
|
+
@error = JSON.parse(x)
|
73
|
+
when Exception
|
74
|
+
@error = { :error_class => x.class.name, :error_message => x.message }
|
75
|
+
else
|
76
|
+
raise TypeError
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def self.schema! conn, options = { }
|
81
|
+
result = conn.run <<"END", options.merge(:capture_error => true) # , :verbose => true
|
82
|
+
CREATE SEQUENCE qr_report_runs_pkey;
|
83
|
+
CREATE TABLE -- IF NOT EXISTS
|
84
|
+
qr_report_runs (
|
85
|
+
id INTEGER PRIMARY KEY DEFAULT nextval('qr_report_runs_pkey')
|
86
|
+
, name VARCHAR(255) NOT NULL
|
87
|
+
, sql TEXT NOT NULL
|
88
|
+
, description TEXT NOT NULL
|
89
|
+
, arguments TEXT NOT NULL
|
90
|
+
, base_columns TEXT NOT NULL
|
91
|
+
, additional_columns TEXT NOT NULL
|
92
|
+
, report_table VARCHAR(255) NOT NULL
|
93
|
+
, error TEXT
|
94
|
+
, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
95
|
+
, started_at TIMESTAMP WITH TIME ZONE
|
96
|
+
, finished_at TIMESTAMP WITH TIME ZONE
|
97
|
+
, nrows INTEGER
|
98
|
+
);
|
99
|
+
CREATE INDEX qr_report_runs__name ON qr_report_runs (name);
|
100
|
+
CREATE INDEX qr_report_runs__report_table ON qr_report_runs (report_table);
|
101
|
+
CREATE INDEX qr_report_runs__created_at ON qr_report_runs (created_at);
|
102
|
+
END
|
103
|
+
end
|
104
|
+
|
105
|
+
def insert!
|
106
|
+
values = {
|
107
|
+
:name => name,
|
108
|
+
:sql => sql,
|
109
|
+
:description => description,
|
110
|
+
:arguments => (arguments || { }),
|
111
|
+
:base_columns => base_columns,
|
112
|
+
:additional_columns => additional_columns,
|
113
|
+
:report_table => report_table,
|
114
|
+
:error => error,
|
115
|
+
:created_at => created_at,
|
116
|
+
:started_at => started_at,
|
117
|
+
:finished_at => finished_at,
|
118
|
+
:nrows => nrows,
|
119
|
+
}
|
120
|
+
|
121
|
+
result = conn.run 'INSERT INTO qr_report_runs ( :NAMES ) VALUES ( :VALUES ) RETURNING id',
|
122
|
+
:arguments => { :names_and_values => values } # , :verbose => true, :verbose_arguments => true
|
123
|
+
self.id = result.rows[0]["id"] or raise "no id"
|
124
|
+
|
125
|
+
self
|
126
|
+
end
|
127
|
+
|
128
|
+
def _options options
|
129
|
+
options ||= EMPTY_Hash
|
130
|
+
arguments = options[:arguments] || EMPTY_Hash
|
131
|
+
arguments = arguments.merge(:qr_run_id => id)
|
132
|
+
options.merge(:arguments => arguments)
|
133
|
+
end
|
134
|
+
|
135
|
+
def update! options = nil
|
136
|
+
options = _options options
|
137
|
+
values = options[:values] || EMPTY_Hash
|
138
|
+
if Array === values
|
139
|
+
h = { }
|
140
|
+
values.each { | k | h[k] = send(k) }
|
141
|
+
values = h
|
142
|
+
end
|
143
|
+
options[:arguments].update(:names_and_values => values)
|
144
|
+
# options.update :verbose => true, :verbose_result => true # , :dry_run => true
|
145
|
+
conn.run <<"END", options
|
146
|
+
UPDATE qr_report_runs
|
147
|
+
SET :SET_VALUES
|
148
|
+
WHERE id = :qr_run_id
|
149
|
+
:WHERE?
|
150
|
+
END
|
151
|
+
end
|
152
|
+
|
153
|
+
def select options = nil
|
154
|
+
options = _options options
|
155
|
+
_select({:order_by => 'ORDER BY qr_run_row'}.merge(options))
|
156
|
+
end
|
157
|
+
|
158
|
+
def _select options = nil
|
159
|
+
options = _options options
|
160
|
+
columns = options[:COLUMNS] || '*'
|
161
|
+
columns = conn.safe_sql(columns)
|
162
|
+
order_by = conn.safe_sql(options[:order_by] || '')
|
163
|
+
options[:arguments].update(
|
164
|
+
:COLUMNS => columns,
|
165
|
+
:ORDER_BY => order_by)
|
166
|
+
conn.run "SELECT :COLUMNS FROM #{report_table} WHERE qr_run_id = :qr_run_id :WHERE? :ORDER_BY?", options
|
167
|
+
end
|
168
|
+
|
169
|
+
# Deletes this report and its rows.
|
170
|
+
def delete! options = nil
|
171
|
+
truncate!
|
172
|
+
options = _options options
|
173
|
+
options.update(:capture_error => true)
|
174
|
+
conn.run "DELETE FROM qr_report_runs WHERE id = :qr_run_id", options # .merge(:verbose => true)
|
175
|
+
result =
|
176
|
+
conn.run "SELECT COUNT(*) AS \"count\" from qr_report_runs WHERE report_table = :report_table",
|
177
|
+
:arguments => { :report_table => report_table }, :capture_error => true # , :verbose => true
|
178
|
+
if result.rows[0]["count"] <= 0
|
179
|
+
conn.run "-- DROP TABLE #{report_table}", :capture_error => true # , :verbose => true
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Deletes the actual rows for this report run.
|
184
|
+
def truncate! options = nil
|
185
|
+
options = _options options
|
186
|
+
options.update(:capture_error => true)
|
187
|
+
conn.run "DELETE FROM #{report_table} WHERE qr_run_id = :qr_run_id :WHERE?", options
|
188
|
+
self
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require 'qreport/report_run'
|
2
|
+
require 'qreport/connection'
|
3
|
+
require 'digest/md5'
|
4
|
+
require 'base64'
|
5
|
+
require 'json'
|
6
|
+
require 'pp'
|
7
|
+
|
8
|
+
module Qreport
|
9
|
+
class ReportRunner
|
10
|
+
attr_accessor :connection, :verbose
|
11
|
+
|
12
|
+
def run! report_run
|
13
|
+
report_run.created_at ||=
|
14
|
+
report_run.started_at = Time.now.utc
|
15
|
+
name = report_run.name
|
16
|
+
sql = report_run.sql.strip
|
17
|
+
|
18
|
+
arguments = report_run.arguments || { }
|
19
|
+
error = error_1 = error_2 = nrows = nil
|
20
|
+
|
21
|
+
Connection.current = connection
|
22
|
+
|
23
|
+
begin
|
24
|
+
conn.transaction do
|
25
|
+
|
26
|
+
# Create a report row sequence:
|
27
|
+
run "CREATE TEMPORARY SEQUENCE qr_row_seq"
|
28
|
+
|
29
|
+
# Rewrite query to create result table rows:
|
30
|
+
arguments = arguments.merge(:qr_run_id => conn.safe_sql("nextval('qr_row_seq')"))
|
31
|
+
report_run.report_sql = report_sql(sql)
|
32
|
+
|
33
|
+
# Proof query to infer base columns:
|
34
|
+
result = run report_run.report_sql, :limit => 0, :arguments => arguments, :verbose => @verbose
|
35
|
+
report_run.base_columns = result.columns
|
36
|
+
result = nil
|
37
|
+
end # transaction
|
38
|
+
rescue ::Exception => exc
|
39
|
+
error = error_1 = exc
|
40
|
+
end
|
41
|
+
|
42
|
+
# Construct report_table name from column names and types:
|
43
|
+
report_table = report_run.report_table
|
44
|
+
|
45
|
+
conn.transaction do
|
46
|
+
# Create new ReportRun row:
|
47
|
+
report_run.insert!
|
48
|
+
report_run_id = report_run.id
|
49
|
+
arguments[:qr_run_id] = report_run_id
|
50
|
+
report_run.report_sql = report_sql(sql)
|
51
|
+
end # transaction
|
52
|
+
|
53
|
+
unless error
|
54
|
+
# Run query into report table:
|
55
|
+
begin
|
56
|
+
conn.transaction do
|
57
|
+
unless conn.table_exists? report_table
|
58
|
+
run "CREATE TABLE #{report_table} AS #{report_run.report_sql}", :arguments => arguments, :verbose => @verbose
|
59
|
+
run "CREATE INDEX #{report_table}_i1 ON #{report_table} (qr_run_id)"
|
60
|
+
run "CREATE INDEX #{report_table}_i2 ON #{report_table} (qr_run_row)"
|
61
|
+
run "CREATE UNIQUE INDEX #{report_table}_i3 ON #{report_table} (qr_run_id, qr_run_row)"
|
62
|
+
report_run.additional_columns.each do | n, t, d |
|
63
|
+
run "ALTER TABLE #{report_table} ADD COLUMN #{conn.escape_identifier(n)} #{t} DEFAULT :d", :arguments => { :d => d || nil }
|
64
|
+
end
|
65
|
+
else
|
66
|
+
result =
|
67
|
+
run "INSERT INTO #{report_table} #{report_run.report_sql}", :arguments => arguments, :verbose => @verbose
|
68
|
+
|
69
|
+
# Get the number of report run rows from cmd_status:
|
70
|
+
unless cs = result.cmd_status and cs[0] == 'INSERT' and cs[1] == 0 and nrows = cs[2]
|
71
|
+
raise Error, "cannot determine nrows"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
# Get the number of report run rows:
|
75
|
+
unless nrows || error
|
76
|
+
result = report_run._select :COLUMNS => 'COUNT(*) AS "nrows"' #, :verbose => true
|
77
|
+
nrows = result.rows[0]["nrows"] || (raise Error, "cannot determine nrows")
|
78
|
+
end
|
79
|
+
# pp result
|
80
|
+
result = nil
|
81
|
+
end # transaction
|
82
|
+
rescue ::Exception => exc
|
83
|
+
error = error_2 = exc
|
84
|
+
end # transaction
|
85
|
+
end
|
86
|
+
|
87
|
+
conn.transaction do
|
88
|
+
run "DROP SEQUENCE qr_row_seq" unless error_1
|
89
|
+
|
90
|
+
# Update stats:
|
91
|
+
report_run.finished_at = Time.now.utc
|
92
|
+
report_run.nrows = nrows.to_i
|
93
|
+
report_run.error = error
|
94
|
+
report_run.update! :values => [ :nrows, :finished_at, :error ] # , :verbose_result => true
|
95
|
+
end # transaction
|
96
|
+
|
97
|
+
report_run
|
98
|
+
end
|
99
|
+
|
100
|
+
def report_sql sql
|
101
|
+
sql = sql.sub(/\ASELECT\s+/im, <<"END"
|
102
|
+
SELECT
|
103
|
+
:qr_run_id
|
104
|
+
AS "qr_run_id"
|
105
|
+
, nextval('qr_row_seq')
|
106
|
+
AS "qr_run_row"
|
107
|
+
,
|
108
|
+
END
|
109
|
+
)
|
110
|
+
sql
|
111
|
+
end
|
112
|
+
|
113
|
+
def run *args
|
114
|
+
# conn.verbose = true
|
115
|
+
conn.run *args
|
116
|
+
end
|
117
|
+
|
118
|
+
def connection
|
119
|
+
@connection ||= Connection.new
|
120
|
+
end
|
121
|
+
alias :conn :connection
|
122
|
+
end
|
123
|
+
end
|
data/qreport.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'qreport/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "qreport"
|
8
|
+
gem.version = Qreport::VERSION
|
9
|
+
gem.authors = ["Kurt Stephens"]
|
10
|
+
gem.email = ["ks.github@kurtstephens.com"]
|
11
|
+
gem.description = %q{Automatically creates materialized report tables from a SQL query.}
|
12
|
+
gem.summary = %q{Automatically creates materialized report tables from a SQL query.}
|
13
|
+
gem.homepage = "http://github.com/kstephens/qreport"
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
17
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
18
|
+
gem.require_paths = ["lib"]
|
19
|
+
|
20
|
+
gem.add_development_dependency 'rake', '>= 0.9.0'
|
21
|
+
gem.add_development_dependency 'rspec', '~> 2.12.0'
|
22
|
+
gem.add_development_dependency 'simplecov', '~> 0.7.1'
|
23
|
+
|
24
|
+
gem.add_dependency 'pg', '~> 0.14'
|
25
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'qreport/connection'
|
3
|
+
|
4
|
+
describe Qreport::Connection do
|
5
|
+
QREPORT_TEST_CONN = [ nil ]
|
6
|
+
def conn
|
7
|
+
QREPORT_TEST_CONN[0] ||= Qreport::Connection.new
|
8
|
+
end
|
9
|
+
|
10
|
+
it "can to connect to a test database." do
|
11
|
+
conn.conn.class.should == PG::Connection
|
12
|
+
end
|
13
|
+
|
14
|
+
it "can manage transaction state." do
|
15
|
+
conn.should_receive(:_transaction_begin).once
|
16
|
+
conn.should_receive(:_transaction_commit).once
|
17
|
+
conn.should_receive(:_transaction_abort).exactly(0).times
|
18
|
+
conn.in_transaction?.should == false
|
19
|
+
conn.transaction do
|
20
|
+
conn.in_transaction?.should == true
|
21
|
+
end
|
22
|
+
conn.in_transaction?.should == false
|
23
|
+
end
|
24
|
+
|
25
|
+
it "can manage nested transactions." do
|
26
|
+
conn.should_receive(:_transaction_begin).once
|
27
|
+
conn.should_receive(:_transaction_commit).once
|
28
|
+
conn.should_receive(:_transaction_abort).exactly(0).times
|
29
|
+
conn.in_transaction?.should == false
|
30
|
+
conn.transaction do
|
31
|
+
conn.in_transaction?.should == true
|
32
|
+
conn.transaction do
|
33
|
+
conn.in_transaction?.should == true
|
34
|
+
end
|
35
|
+
conn.in_transaction?.should == true
|
36
|
+
end
|
37
|
+
conn.in_transaction?.should == false
|
38
|
+
end
|
39
|
+
|
40
|
+
it "can manage transaction state during raised exceptions" do
|
41
|
+
conn.should_receive(:_transaction_begin).once
|
42
|
+
conn.should_receive(:_transaction_commit).exactly(0).times
|
43
|
+
conn.should_receive(:_transaction_abort).once
|
44
|
+
lambda do
|
45
|
+
conn.transaction do
|
46
|
+
raise Qreport::Error, "#{__LINE__}"
|
47
|
+
end
|
48
|
+
end.should raise_error(Qreport::Error)
|
49
|
+
conn.in_transaction?.should == false
|
50
|
+
end
|
51
|
+
|
52
|
+
it "can dup to create another connection." do
|
53
|
+
conn1 = Qreport::Connection.new
|
54
|
+
conn1.fd.should == nil
|
55
|
+
conn1.conn
|
56
|
+
conn1.fd.class.should == Fixnum
|
57
|
+
conn2 = nil
|
58
|
+
conn1.transaction do
|
59
|
+
conn1.in_transaction?.should == true
|
60
|
+
conn2 = conn1.dup
|
61
|
+
conn2.in_transaction?.should == false
|
62
|
+
end
|
63
|
+
conn1.in_transaction?.should == false
|
64
|
+
conn2.fd.should == nil
|
65
|
+
conn2.conn
|
66
|
+
conn2.fd.class.should == Fixnum
|
67
|
+
conn2.fd.should_not == conn1.fd
|
68
|
+
conn2.in_transaction?.should == false
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'can set conn.' do
|
72
|
+
conn1 = conn
|
73
|
+
conn1.conn.class.should == PG::Connection
|
74
|
+
conn2 = Qreport::Connection.new(:conn => conn1.conn)
|
75
|
+
conn2.conn.object_id.should == conn1.conn.object_id
|
76
|
+
conn2.conn_owned.should be_false
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'can close conn.' do
|
80
|
+
conn.conn.class.should == PG::Connection
|
81
|
+
conn.conn_owned.should_not be_false
|
82
|
+
conn.close
|
83
|
+
conn.instance_variable_get('@conn').should == nil
|
84
|
+
conn.conn_owned.should be_false
|
85
|
+
conn.close
|
86
|
+
conn.instance_variable_get('@conn').should == nil
|
87
|
+
end
|
88
|
+
|
89
|
+
describe "#escape_value, #unescape_value" do
|
90
|
+
[
|
91
|
+
[ nil, 'NULL' ],
|
92
|
+
[ true, "'t'::boolean" ],
|
93
|
+
[ false, "'f'::boolean" ],
|
94
|
+
[ 1234, '1234' ],
|
95
|
+
[ -1234, '-1234' ],
|
96
|
+
[ 1234.45, '1234.45' ],
|
97
|
+
[ "string with \", \\, and \'", "'string with \", \\, and '''" ],
|
98
|
+
[ :a_symbol!, "'a_symbol!'", :a_symbol!.to_s ],
|
99
|
+
[ Time.parse('2011-04-27T13:23:00.000000Z'), "'2011-04-27T13:23:00.000000Z'::timestamp", Time.parse('2011-04-27T13:23:00.000000') ],
|
100
|
+
].each do | value, sql, return_value |
|
101
|
+
it "can handle encoding #{value.class.name} value #{value.inspect} as #{sql.inspect}." do
|
102
|
+
conn.escape_value(value).should == sql
|
103
|
+
end
|
104
|
+
it "can handle decoding #{value.class.name} value #{value.inspect}." do
|
105
|
+
sql_x = conn.escape_value(value)
|
106
|
+
r = conn.run "SELECT #{sql_x}"
|
107
|
+
r = r.rows.first.values.first
|
108
|
+
r.should == (return_value || value)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
it "raises TypeError for other values." do
|
112
|
+
lambda do
|
113
|
+
conn.escape_value(Object.new)
|
114
|
+
end.should raise_error(TypeError)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|