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,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color -f d
@@ -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
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in qreport.gemspec
4
+ gemspec
@@ -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.
@@ -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
@@ -0,0 +1,13 @@
1
+ require "bundler/gem_tasks"
2
+ gem 'rspec'
3
+ require 'rspec/core/rake_task'
4
+
5
+ desc "Default => :test"
6
+ task :default => :test
7
+
8
+ desc "Run all tests"
9
+ task :test => [ :spec ]
10
+
11
+ desc "Run specs"
12
+ RSpec::Core::RakeTask.new(:spec)
13
+
@@ -0,0 +1,8 @@
1
+ require "qreport/version"
2
+
3
+ module Qreport
4
+ EMPTY_Hash = {}.freeze
5
+ EMPTY_Array = [].freeze
6
+ EMPTY_String = ''.freeze
7
+ class Error < ::Exception; end
8
+ end
@@ -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
+