qreport 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+