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.
@@ -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
@@ -0,0 +1,9 @@
1
+ require 'qreport/report_run'
2
+ require 'qreport/report_runner'
3
+ require 'pp'
4
+
5
+ module Qreport
6
+ class Main
7
+ end
8
+ end
9
+
@@ -0,0 +1,8 @@
1
+ module Qreport
2
+ module Model
3
+ attr_accessor :conn
4
+ def conn
5
+ @conn || Qreport::Connection.current
6
+ end
7
+ end
8
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module Qreport
2
+ VERSION = "0.0.2"
3
+ end
@@ -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