qreport 0.0.2
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/.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
|