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
data/.gitignore
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color -f d
|
data/ChangeLog
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
2013-06-26 Kurt A. Stephens <ks.github@kurtstephens.com>
|
2
|
+
|
3
|
+
* v0.0.2: New Version: New Functionality.
|
4
|
+
* Qreport::Connection#conn= for use with existing PGConn objects.
|
5
|
+
* Fix Boolean and Float escape/unescape.
|
6
|
+
* Test Coverage.
|
7
|
+
* Depends on pg gem.
|
8
|
+
|
9
|
+
2013-01-15 Kurt A. Stephens <ks.github@kurtstephens.com>
|
10
|
+
|
11
|
+
* v0.0.1: New Version: Initial version.
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Kurt Stephens
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
# Qreport
|
2
|
+
|
3
|
+
Executes a SQL query into a report table.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'qreport'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install qreport
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
Qreport rewrites a plain SQL query so that its result set can populate a report table.
|
22
|
+
It automatically creates the report table based on a signature of the column names and types of the query result.
|
23
|
+
It can also add additional columns to the report table for other uses, for example: batch processing.
|
24
|
+
New queries, rollups and reports can be built from previous reports.
|
25
|
+
|
26
|
+
Currently supports PostgreSQL.
|
27
|
+
|
28
|
+
## Example
|
29
|
+
|
30
|
+
We have users that write articles.
|
31
|
+
Generate a report named "users_with_articles" of all users that have written an article in N days:
|
32
|
+
|
33
|
+
SELECT u.id AS "user_id"
|
34
|
+
FROM users u
|
35
|
+
WHERE
|
36
|
+
EXISTS(SELECT * FROM articles a
|
37
|
+
WHERE a.user_id = u.id AND a.created_on >= NOW() - INTERVAL '30 days')
|
38
|
+
|
39
|
+
Create a Qreport::ReportRun:
|
40
|
+
|
41
|
+
conn = Qreport::Connection.new(...)
|
42
|
+
report_run = Qreport::ReportRun.new(:name => :users_with_articles)
|
43
|
+
report_run.sql = <<"END"
|
44
|
+
SELECT u.id AS "user_id"
|
45
|
+
FROM users u
|
46
|
+
WHERE
|
47
|
+
EXISTS(SELECT * FROM articles a
|
48
|
+
WHERE a.user_id = u.id AND a.created_on >= NOW() - INTERVAL '30 days')
|
49
|
+
END
|
50
|
+
report_run.run! conn
|
51
|
+
|
52
|
+
Qreport translates the query above into:
|
53
|
+
|
54
|
+
SELECT 0 AS "qr_run_id"
|
55
|
+
, nextval('qr_row_seq') AS "qr_row_id"
|
56
|
+
, u.id AS "user_id"
|
57
|
+
FROM users u
|
58
|
+
WHERE
|
59
|
+
EXISTS(SELECT * FROM articles a
|
60
|
+
WHERE a.user_id = u.id AND a.created_on >= NOW() - INTERVAL '30 days')
|
61
|
+
|
62
|
+
Then analyzes the columns names and types of this query to produce a result signature.
|
63
|
+
The result signature is hashed, e.g.: "x2yu78i".
|
64
|
+
The result signature hash is used to create a unique report table name: e.g. "users_with_articles_x2yu78i".
|
65
|
+
The qr_report_runs table keeps track of each report run.
|
66
|
+
A record is inserted into the qr_report_runs table with a unique id.
|
67
|
+
Qreport then executes:
|
68
|
+
|
69
|
+
CREATE TABLE users_with_articles_x2yu78i AS
|
70
|
+
SELECT 123 AS "qr_run_id"
|
71
|
+
, nextval('qr_row_seq') AS "qr_row_id"
|
72
|
+
, u.id AS "user_id"
|
73
|
+
FROM users u
|
74
|
+
WHERE
|
75
|
+
EXISTS(SELECT * FROM articles a
|
76
|
+
WHERE a.user_id = u.id AND a.created_on >= NOW() - INTERVAL '30 days')
|
77
|
+
|
78
|
+
The ReportRun object state is updated:
|
79
|
+
|
80
|
+
report_run.id # => Integer
|
81
|
+
report_run.nrows # => Integer
|
82
|
+
report_run.started_at # => Time
|
83
|
+
report_run.finished_at # => Time
|
84
|
+
|
85
|
+
Subsequent queries with the same column signature will use "INSERT INTO users_with_articles_x2yu78i".
|
86
|
+
|
87
|
+
## Parameterizing Reports
|
88
|
+
|
89
|
+
## Batch Processing
|
90
|
+
|
91
|
+
## Contributing
|
92
|
+
|
93
|
+
1. Fork it
|
94
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
95
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
96
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
97
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/lib/qreport.rb
ADDED
@@ -0,0 +1,378 @@
|
|
1
|
+
require 'qreport'
|
2
|
+
require 'time' # iso8601
|
3
|
+
|
4
|
+
module Qreport
|
5
|
+
class Connection
|
6
|
+
attr_accessor :arguments, :verbose, :verbose_result, :env
|
7
|
+
attr_accessor :conn, :conn_owned
|
8
|
+
|
9
|
+
class << self
|
10
|
+
attr_accessor :current
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize args = nil
|
14
|
+
@arguments = args
|
15
|
+
initialize_copy nil
|
16
|
+
if conn = @arguments && @arguments.delete(:conn)
|
17
|
+
self.conn = conn
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize_copy src
|
22
|
+
@conn = @conn_owned = nil
|
23
|
+
@abort_transaction = @invalid = nil
|
24
|
+
@transaction_nesting = 0
|
25
|
+
end
|
26
|
+
|
27
|
+
def env
|
28
|
+
@env || ENV
|
29
|
+
end
|
30
|
+
|
31
|
+
def arguments
|
32
|
+
@arguments || {
|
33
|
+
:host => env['PGHOST'] || 'test',
|
34
|
+
:port => env['PGPORT'],
|
35
|
+
:user => env['PGUSER'] || 'test',
|
36
|
+
:password => env['PGPASSWORD'] || 'test',
|
37
|
+
:dbname => env['PGDATABASE'] || 'test',
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
# Returns the PG connection object.
|
42
|
+
# Create a new connection from #arguments.
|
43
|
+
# New connection will be closed by #close.
|
44
|
+
def conn
|
45
|
+
@conn ||=
|
46
|
+
begin
|
47
|
+
if @@require_pg
|
48
|
+
require 'pg'
|
49
|
+
@@require_pg = false
|
50
|
+
end
|
51
|
+
initialize_copy nil
|
52
|
+
c = PG.connect(arguments)
|
53
|
+
@conn_owned = true if c
|
54
|
+
c
|
55
|
+
end
|
56
|
+
end
|
57
|
+
@@require_pg = true
|
58
|
+
|
59
|
+
# Sets the PG connection object.
|
60
|
+
# Connection will not closed by #close.
|
61
|
+
def conn= x
|
62
|
+
@conn_owned = false
|
63
|
+
@conn = x
|
64
|
+
end
|
65
|
+
|
66
|
+
def fd
|
67
|
+
@conn && @conn.socket
|
68
|
+
end
|
69
|
+
|
70
|
+
def close
|
71
|
+
raise Error, "close during transaction" if in_transaction?
|
72
|
+
if @conn
|
73
|
+
conn = @conn
|
74
|
+
@conn = nil
|
75
|
+
conn.close if @conn_owned
|
76
|
+
end
|
77
|
+
ensure
|
78
|
+
@invalid = false
|
79
|
+
@transaction_nesting = 0
|
80
|
+
@conn_owned = false
|
81
|
+
end
|
82
|
+
|
83
|
+
def in_transaction?; @transaction_nesting > 0; end
|
84
|
+
|
85
|
+
def transaction
|
86
|
+
raise Error, "no block" unless block_given?
|
87
|
+
abort = false
|
88
|
+
begin
|
89
|
+
transaction_begin
|
90
|
+
yield
|
91
|
+
rescue ::Exception => exc
|
92
|
+
abort = @abort_transaction = exc
|
93
|
+
raise exc
|
94
|
+
ensure
|
95
|
+
transaction_end abort
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def transaction_begin
|
100
|
+
if @transaction_nesting == 0
|
101
|
+
_transaction_begin
|
102
|
+
end
|
103
|
+
@transaction_nesting += 1
|
104
|
+
self
|
105
|
+
end
|
106
|
+
|
107
|
+
def transaction_end abort = nil
|
108
|
+
if (@transaction_nesting -= 1) == 0
|
109
|
+
begin
|
110
|
+
if abort
|
111
|
+
_transaction_abort
|
112
|
+
else
|
113
|
+
_transaction_commit
|
114
|
+
end
|
115
|
+
ensure
|
116
|
+
if @invalid
|
117
|
+
close
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
self
|
122
|
+
end
|
123
|
+
|
124
|
+
def _transaction_begin
|
125
|
+
run "BEGIN"; self
|
126
|
+
end
|
127
|
+
|
128
|
+
def _transaction_commit
|
129
|
+
run "COMMIT"; self
|
130
|
+
end
|
131
|
+
|
132
|
+
def _transaction_abort
|
133
|
+
run "ABORT"; self
|
134
|
+
end
|
135
|
+
|
136
|
+
def table_exists? name, schemaname = 'public'
|
137
|
+
#result = run "SELECT * FROM pg_catalog.pg_tables",
|
138
|
+
#:arguments => { :tablename => name }
|
139
|
+
#pp result.rows
|
140
|
+
|
141
|
+
result =
|
142
|
+
run "SELECT EXISTS(SELECT * FROM pg_catalog.pg_tables WHERE tablename = :tablename AND schemaname = :schemaname) as \"exists\"",
|
143
|
+
:arguments => { :tablename => name, :schemaname => schemaname }
|
144
|
+
# result.rows; pp result
|
145
|
+
result.rows[0]["exists"]
|
146
|
+
end
|
147
|
+
|
148
|
+
# options:
|
149
|
+
# :arguments => { :key => value, ... }
|
150
|
+
# :limit => size
|
151
|
+
# :limit => [ size, offset ]
|
152
|
+
def run sql, options = nil
|
153
|
+
options ||= { }
|
154
|
+
conn = options[:connection] || self.conn
|
155
|
+
result = Query.new
|
156
|
+
result.sql = sql
|
157
|
+
result.options = options
|
158
|
+
result.conn = self
|
159
|
+
result.run!
|
160
|
+
dump_result result if @verbose_result || options[:verbose_result]
|
161
|
+
result
|
162
|
+
end
|
163
|
+
|
164
|
+
# Represents raw SQL.
|
165
|
+
class SQL
|
166
|
+
def self.new sql; self === sql ? sql : super; end
|
167
|
+
def initialize sql; @sql = sql.freeze; end
|
168
|
+
def to_s; @sql; end
|
169
|
+
end
|
170
|
+
|
171
|
+
def safe_sql x
|
172
|
+
SQL.new(x)
|
173
|
+
end
|
174
|
+
|
175
|
+
def escape_identifier name
|
176
|
+
conn.escape_identifier name.to_s
|
177
|
+
end
|
178
|
+
|
179
|
+
def escape_value val
|
180
|
+
case val
|
181
|
+
when SQL
|
182
|
+
val.to_s
|
183
|
+
when nil
|
184
|
+
NULL
|
185
|
+
when true
|
186
|
+
T_
|
187
|
+
when false
|
188
|
+
F_
|
189
|
+
when Integer, Float
|
190
|
+
val
|
191
|
+
when String, Symbol
|
192
|
+
"'" << conn.escape_string(val.to_s) << QUOTE
|
193
|
+
when Time
|
194
|
+
escape_value(val.iso8601(6)) << "::timestamp"
|
195
|
+
when Hash, Array
|
196
|
+
escape_value(val.to_json)
|
197
|
+
else
|
198
|
+
raise TypeError
|
199
|
+
end.to_s
|
200
|
+
end
|
201
|
+
NULL = 'NULL'.freeze
|
202
|
+
QUOTE = "'".freeze
|
203
|
+
T_ = "'t'::boolean".freeze
|
204
|
+
F_ = "'f'::boolean".freeze
|
205
|
+
T = 't'.freeze
|
206
|
+
|
207
|
+
def unescape_value val, type
|
208
|
+
case val
|
209
|
+
when String
|
210
|
+
return nil if val == NULL
|
211
|
+
case type
|
212
|
+
when "boolean"
|
213
|
+
val = val == T
|
214
|
+
when /int/
|
215
|
+
val = val.to_i
|
216
|
+
when "numeric"
|
217
|
+
val = val.to_f
|
218
|
+
when /timestamp/
|
219
|
+
val = Time.parse(val)
|
220
|
+
else
|
221
|
+
val
|
222
|
+
end
|
223
|
+
else
|
224
|
+
val
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def dump_result result
|
229
|
+
pp result if result
|
230
|
+
result
|
231
|
+
end
|
232
|
+
|
233
|
+
def type_name type, mod
|
234
|
+
@type_names ||= { }
|
235
|
+
@type_names[[type, mod]] ||=
|
236
|
+
conn.exec("SELECT format_type($1,$2)", [type, mod]).
|
237
|
+
getvalue(0, 0).to_s.dup.freeze
|
238
|
+
end
|
239
|
+
|
240
|
+
def with_limit sql, limit = nil
|
241
|
+
sql = sql.dup
|
242
|
+
case limit
|
243
|
+
when Integer
|
244
|
+
limit = "LIMIT #{limit}"
|
245
|
+
when Array
|
246
|
+
limit = "OFFSET #{limit[1].to_i}\nLIMIT #{limit[0].to_i}"
|
247
|
+
end
|
248
|
+
unless sql.sub!(/:LIMIT\b|\bLIMIT\s+\S+\s*|\Z/im, "\n#{limit}")
|
249
|
+
sql << "\n" << limit
|
250
|
+
end
|
251
|
+
sql
|
252
|
+
end
|
253
|
+
|
254
|
+
def run_query! sql, query, options = nil
|
255
|
+
options ||= EMPTY_Hash
|
256
|
+
result = nil
|
257
|
+
begin
|
258
|
+
result = conn.async_exec(sql)
|
259
|
+
rescue ::PG::Error => exc
|
260
|
+
# $stderr.puts " ERROR: #{exc.inspect}\n #{exc.backtrace * "\n "}"
|
261
|
+
query.error = exc.inspect
|
262
|
+
raise exc unless options[:capture_error]
|
263
|
+
rescue ::Exception => exc
|
264
|
+
@invalid = true
|
265
|
+
query.error = exc.inspect
|
266
|
+
raise exc unless options[:capture_error]
|
267
|
+
end
|
268
|
+
result
|
269
|
+
end
|
270
|
+
|
271
|
+
class Query
|
272
|
+
attr_accessor :conn, :sql, :options
|
273
|
+
attr_accessor :error, :cmd_status_raw, :cmd_status, :cmd_tuples
|
274
|
+
attr_accessor :nfields, :fields, :ftypes, :fmods
|
275
|
+
attr_accessor :type_names
|
276
|
+
attr_accessor :columns, :rows
|
277
|
+
|
278
|
+
def run!
|
279
|
+
@error = nil
|
280
|
+
sql = @sql_prepared = prepare_sql self.sql
|
281
|
+
if conn.verbose || options[:verbose]
|
282
|
+
$stderr.puts "\n-- =================================================================== --"
|
283
|
+
$stderr.puts sql
|
284
|
+
$stderr.puts "-- ==== --"
|
285
|
+
end
|
286
|
+
return self if options[:dry_run]
|
287
|
+
if result = conn.run_query!(sql, self, options)
|
288
|
+
extract_results! result
|
289
|
+
end
|
290
|
+
self
|
291
|
+
end
|
292
|
+
|
293
|
+
def prepare_sql sql
|
294
|
+
sql = sql.sub(/[\s\n]*;[\s\n]*\Z/, '')
|
295
|
+
if options.key?(:limit)
|
296
|
+
sql = conn.with_limit sql, options[:limit]
|
297
|
+
end
|
298
|
+
if arguments = options[:arguments]
|
299
|
+
if values = arguments[:names_and_values]
|
300
|
+
n = conn.safe_sql(values.keys * ', ')
|
301
|
+
v = conn.safe_sql(values.keys.map{|k| ":#{k}"} * ', ')
|
302
|
+
sql = sql_replace_arguments(sql,
|
303
|
+
:NAMES => n,
|
304
|
+
:VALUES => v,
|
305
|
+
:NAMES_AND_VALUES => conn.safe_sql("( #{n} ) VALUES ( #{v} )"),
|
306
|
+
:SET_VALUES => conn.safe_sql(values.keys.map{|k| "#{conn.escape_identifier(k)} = :#{k}"} * ', '))
|
307
|
+
arguments = arguments.merge(values)
|
308
|
+
end
|
309
|
+
sql = sql_replace_arguments(sql, arguments)
|
310
|
+
end
|
311
|
+
sql
|
312
|
+
end
|
313
|
+
|
314
|
+
def sql_replace_arguments sql, arguments
|
315
|
+
sql = sql.gsub(/(:(\w+)\b([?]?))/) do | m |
|
316
|
+
name = $2.to_sym
|
317
|
+
optional = ! $3.empty?
|
318
|
+
if arguments.key?(name) || optional
|
319
|
+
val = arguments[name]
|
320
|
+
unless optional && val.nil?
|
321
|
+
val = conn.escape_value(val)
|
322
|
+
end
|
323
|
+
$stderr.puts " #{name} => #{val}" if options[:verbose_arguments]
|
324
|
+
val
|
325
|
+
else
|
326
|
+
$1
|
327
|
+
end
|
328
|
+
end
|
329
|
+
sql
|
330
|
+
end
|
331
|
+
|
332
|
+
def extract_results! result
|
333
|
+
error = result.error_message
|
334
|
+
error &&= ! error.empty? && error
|
335
|
+
@error = error
|
336
|
+
@cmd_status_raw = result.cmd_status
|
337
|
+
@cmd_tuples = result.cmd_tuples
|
338
|
+
@nfields = result.nfields
|
339
|
+
@ntuples = result.ntuples
|
340
|
+
@fields = result.fields
|
341
|
+
@ftypes = (0 ... nfields).map{|i| result.ftype(i) }
|
342
|
+
@fmods = (0 ... nfields).map{|i| result.fmod(i) }
|
343
|
+
@rows = result.to_a
|
344
|
+
result.clear
|
345
|
+
self
|
346
|
+
end
|
347
|
+
|
348
|
+
def columns
|
349
|
+
@columns ||= @fields.zip(type_names)
|
350
|
+
end
|
351
|
+
|
352
|
+
def type_names
|
353
|
+
@type_names ||= (0 ... nfields).map{|i| conn.type_name(@ftypes[i], @fmods[i])}
|
354
|
+
end
|
355
|
+
|
356
|
+
def cmd_status
|
357
|
+
@cmd_status ||=
|
358
|
+
begin
|
359
|
+
x = @cmd_status_raw.split(/\s+/)
|
360
|
+
[ x[0] ] + x[1 .. -1].map(&:to_i)
|
361
|
+
end.freeze
|
362
|
+
end
|
363
|
+
|
364
|
+
def rows
|
365
|
+
return @rows if @rows_unescaped
|
366
|
+
@rows.each do | r |
|
367
|
+
columns.each do | c, t |
|
368
|
+
r[c] = conn.unescape_value(r[c], t)
|
369
|
+
end
|
370
|
+
end
|
371
|
+
@rows_unescaped = true
|
372
|
+
@rows
|
373
|
+
end
|
374
|
+
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|