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
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
|
+
|