qx 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/qx.rb +422 -0
  3. metadata +44 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a558a9c173beb2047e5c83c5959c2ebb0c00952e
4
+ data.tar.gz: 1419ef67b882bcb1e831284fc13903daab8cc948
5
+ SHA512:
6
+ metadata.gz: 371152ed4d23950f658964dd79613e2a2434cdc7066579030a559ac376d283cc833fb9b6f86dbdbcf83ca235ef77b4aa5071517613b71573d7dc6a68eaf10bd4
7
+ data.tar.gz: 2aa4f770de70ffda4da9e8473616c7b035cf6a6b7744bf9083a03d984f006db9e9fa824a9361ae4acc678057c73f0b622fa0326ae19c412c3db8c8ac8c412f64
data/lib/qx.rb ADDED
@@ -0,0 +1,422 @@
1
+ require 'uri'
2
+ require 'active_record'
3
+ require 'colorize' # for pretty printing/debugging
4
+
5
+
6
+ class Qx
7
+
8
+ ##
9
+ # Initialize the database connection using a database url
10
+ # Running this is required for #execute to work
11
+ # Pass in a hash. For now, it only takes on key called :database_url
12
+ # Include the full url including userpass and database name
13
+ # For example:
14
+ # Qx.config(database_url: 'postgres://admin:password@localhost/database_name')
15
+ def self.config(h)
16
+ @@type_map = h[:type_map]
17
+ end
18
+
19
+ # Qx.new, only used internally
20
+ def initialize(tree)
21
+ @tree = tree
22
+ return self
23
+ end
24
+
25
+ def self.transaction(&block)
26
+ ActiveRecord::Base.transaction do
27
+ yield block
28
+ end
29
+ end
30
+
31
+ def self.parse_select(expr)
32
+ str = 'SELECT'
33
+ str += " DISTINCT ON (#{expr[:DISTINCT_ON].map(&:to_s).join(', ')})" if expr[:DISTINCT_ON]
34
+ str += ' ' + expr[:SELECT].join(", ")
35
+ throw ArgumentError.new("FROM clause is missing for SELECT") unless expr[:FROM]
36
+ str += ' FROM ' + expr[:FROM]
37
+ str += expr[:JOIN].map{|from, cond| " JOIN #{from} ON #{cond}"}.join if expr[:JOIN]
38
+ str += expr[:LEFT_JOIN].map{|from, cond| " LEFT JOIN #{from} ON #{cond}"}.join if expr[:LEFT_JOIN]
39
+ str += ' WHERE ' + expr[:WHERE].map{|w| "(#{w})"}.join(" AND ") if expr[:WHERE]
40
+ str += ' GROUP BY ' + expr[:GROUP_BY].join(", ") if expr[:GROUP_BY]
41
+ str += ' HAVING ' + expr[:HAVING].map{|h| "(#{h})"}.join(" AND ") if expr[:HAVING]
42
+ str += ' ORDER BY ' + expr[:ORDER_BY].map{|col, order| col + (order ? ' ' + order : '')}.join(", ") if expr[:ORDER_BY]
43
+ str += ' LIMIT ' + expr[:LIMIT] if expr[:LIMIT]
44
+ str += ' OFFSET ' + expr[:OFFSET] if expr[:OFFSET]
45
+ str = "(#{str}) AS #{expr[:AS]}" if expr[:AS]
46
+ str = "EXPLAIN #{str}" if expr[:EXPLAIN]
47
+ return str
48
+ end
49
+
50
+ # Parse a Qx expression tree into a single query string that can be executed
51
+ # http://www.postgresql.org/docs/9.0/static/sql-commands.html
52
+ def self.parse(expr)
53
+ if expr.is_a?(String)
54
+ return expr # already parsed
55
+ elsif expr.is_a?(Array)
56
+ return expr.join(",")
57
+ elsif expr[:INSERT_INTO]
58
+ str = "INSERT INTO #{expr[:INSERT_INTO]} (#{expr[:INSERT_COLUMNS].join(", ")})"
59
+ throw ArgumentError.new("VALUES (or SELECT) clause is missing for INSERT INTO") unless expr[:VALUES] || expr[:SELECT]
60
+ if expr[:SELECT]
61
+ str += ' ' + parse_select(expr)
62
+ else
63
+ str += " VALUES #{expr[:VALUES].map{|vals| "(#{vals.join(", ")})"}.join(", ")}"
64
+ end
65
+ str += " RETURNING " + expr[:RETURNING].join(", ") if expr[:RETURNING]
66
+ elsif expr[:SELECT]
67
+ str = parse_select(expr)
68
+ elsif expr[:DELETE_FROM]
69
+ str = 'DELETE FROM ' + expr[:DELETE_FROM]
70
+ str += ' WHERE ' + expr[:WHERE].map{|w| "(#{w})"}.join(" AND ") if expr[:WHERE]
71
+ str += " RETURNING " + expr[:RETURNING].join(", ") if expr[:RETURNING]
72
+ elsif expr[:UPDATE]
73
+ str = 'UPDATE ' + expr[:UPDATE]
74
+ throw ArgumentError.new("SET clause is missing for UPDATE") unless expr[:SET]
75
+ str += ' SET ' + expr[:SET]
76
+ str += ' FROM ' + expr[:FROM] if expr[:FROM]
77
+ str += ' WHERE ' + expr[:WHERE].map{|w| "(#{w})"}.join(" AND ") if expr[:WHERE]
78
+ str += " RETURNING " + expr[:RETURNING].join(", ") if expr[:RETURNING]
79
+ end
80
+ return str
81
+ end
82
+ # An instance method version of the above
83
+ def parse; Qx.parse(@tree); end
84
+
85
+ # Qx.select("id").from("supporters").execute
86
+ def execute(options={})
87
+ expr = Qx.parse(@tree).to_s.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
88
+ return Qx.execute_raw(expr, options)
89
+ end
90
+ alias_method :ex, :execute
91
+
92
+ # Can pass in an expression string or another Qx object
93
+ # Qx.execute("SELECT id FROM table_name", {format: 'csv'})
94
+ # Qx.execute(Qx.select("id").from("table_name"))
95
+ def self.execute(expr, data={}, options={})
96
+ return expr.execute(data) if expr.is_a?(Qx)
97
+ interpolated = Qx.interpolate_expr(expr, data)
98
+ return self.execute_raw(interpolated, options)
99
+ end
100
+
101
+ # options
102
+ # verbose: print the query
103
+ # format: 'csv' | 'hash' give data csv style with Arrays -- good for exports or for saving memory
104
+ def self.execute_raw(expr, options={})
105
+ puts expr if options[:verbose]
106
+ result = ActiveRecord::Base.connection.execute(expr)
107
+ result.map_types!(@@type_map) if @@type_map
108
+ if options[:format] == 'csv'
109
+ data = result.map{|h| h.values}
110
+ data.unshift((result.first || {}).keys)
111
+ else
112
+ data = result.map{|h| h}
113
+ end
114
+ result.clear
115
+ return data
116
+ end
117
+
118
+ # -- Top-level clauses
119
+
120
+ def self.select(*cols)
121
+ self.new(SELECT: cols)
122
+ end
123
+ def select(*cols)
124
+ @tree[:SELECT] = cols
125
+ self
126
+ end
127
+ def self.insert_into(table_name, cols=[])
128
+ self.new(INSERT_INTO: Qx.quote_ident(table_name), INSERT_COLUMNS: cols.map{|c| Qx.quote_ident(c)})
129
+ end
130
+ def insert_into(table_name, cols=[])
131
+ @tree[:INSERT_INTO] = Qx.quote_ident(table_name)
132
+ @tree[:INSERT_COLUMNS] = cols.map{|c| Qx.quote_ident(c)}
133
+ self
134
+ end
135
+ def self.delete_from(table_name)
136
+ self.new(DELETE_FROM: Qx.quote_ident(table_name))
137
+ end
138
+ def delete_from(table_name)
139
+ @tree[:DELETE_FROM] = Qx.quote_ident(table_name)
140
+ self
141
+ end
142
+ def self.update(table_name)
143
+ self.new(UPDATE: Qx.quote_ident(table_name))
144
+ end
145
+ def update(table_name)
146
+ @tree[:UPDATE] = Qx.quote_ident(table_name)
147
+ self
148
+ end
149
+
150
+ # -- Sub-clauses
151
+
152
+ # - SELECT sub-clauses
153
+
154
+ def distinct_on(*cols)
155
+ @tree[:DISTINCT_ON] = cols
156
+ self
157
+ end
158
+
159
+ def from(expr)
160
+ @tree[:FROM] = expr.is_a?(Qx) ? expr.parse : expr.to_s
161
+ self
162
+ end
163
+ def as(table_name)
164
+ @tree[:AS] = Qx.quote_ident(table_name)
165
+ self
166
+ end
167
+
168
+ # Clauses are pairs of expression and data
169
+ def where(*clauses)
170
+ ws = Qx.get_where_params(clauses)
171
+ @tree[:WHERE] = Qx.parse_wheres(ws)
172
+ self
173
+ end
174
+ def and_where(*clauses)
175
+ ws = Qx.get_where_params(clauses)
176
+ @tree[:WHERE] ||= []
177
+ @tree[:WHERE].concat(Qx.parse_wheres(ws))
178
+ self
179
+ end
180
+
181
+ def group_by(*cols)
182
+ @tree[:GROUP_BY] = cols.map{|c| c.to_s}
183
+ self
184
+ end
185
+
186
+ def order_by(*cols)
187
+ orders = /(asc)|(desc)( nulls (first)|(last))?/i
188
+ # Sanitize out invalid order keywords
189
+ @tree[:ORDER_BY] = cols.map{|col, order| [col.to_s, order.to_s.downcase.strip.match(order.to_s.downcase) ? order.to_s.upcase : nil]}
190
+ self
191
+ end
192
+
193
+ def having(expr, data={})
194
+ @tree[:HAVING] = [Qx.interpolate_expr(expr, data)]
195
+ self
196
+ end
197
+ def and_having(expr, data={})
198
+ @tree[:HAVING].push(Qx.interpolate_expr(expr, data))
199
+ self
200
+ end
201
+
202
+ def limit(n)
203
+ @tree[:LIMIT] = n.to_i.to_s
204
+ self
205
+ end
206
+
207
+ def offset(n)
208
+ @tree[:OFFSET] = n.to_i.to_s
209
+ self
210
+ end
211
+
212
+ def join(*joins)
213
+ js = Qx.get_join_param(joins)
214
+ @tree[:JOIN] = Qx.parse_joins(js)
215
+ self
216
+ end
217
+ def add_join(*joins)
218
+ js = Qx.get_join_param(joins)
219
+ @tree[:JOIN] ||= []
220
+ @tree[:JOIN].concat(Qx.parse_joins(js))
221
+ self
222
+ end
223
+ def left_join(*joins)
224
+ js = Qx.get_join_param(joins)
225
+ @tree[:LEFT_JOIN] = Qx.parse_joins(js)
226
+ self
227
+ end
228
+ def add_left_join(*joins)
229
+ js = Qx.get_join_param(joins)
230
+ @tree[:LEFT_JOIN] ||= []
231
+ @tree[:LEFT_JOIN].concat(Qx.parse_joins(js))
232
+ self
233
+ end
234
+
235
+ # - INSERT INTO / UPDATE
236
+
237
+ # Allows three formats:
238
+ # insert.values([[col1, col2], [val1, val2], [val3, val3]], options)
239
+ # insert.values([{col1: val1, col2: val2}, {col1: val3, co2: val4}], options)
240
+ # insert.values({col1: val1, col2: val2}, options) <- only for single inserts
241
+ def values(vals)
242
+ if vals.is_a?(Array) && vals.first.is_a?(Array)
243
+ cols = vals.first
244
+ data = vals[1..-1]
245
+ elsif vals.is_a?(Array) && vals.first.is_a?(Hash)
246
+ hashes = vals.map{|h| h.sort.to_h} # Make sure hash keys line up with all row data
247
+ cols = hashes.first.keys
248
+ data = hashes.map{|h| h.values}
249
+ elsif vals.is_a?(Hash)
250
+ cols = vals.keys
251
+ data = [vals.values]
252
+ end
253
+ @tree[:VALUES] = data.map{|vals| vals.map{|d| Qx.quote(d)}}
254
+ @tree[:INSERT_COLUMNS] = cols.map{|c| Qx.quote_ident(c)}
255
+ self
256
+ end
257
+
258
+ # A convenience function for setting the same values across all inserted rows
259
+ def common_values(h)
260
+ cols = h.keys.map{|col| Qx.quote_ident(col)}
261
+ data = h.values.map{|val| Qx.quote(val)}
262
+ @tree[:VALUES] = @tree[:VALUES].map{|row| row.concat(data)}
263
+ @tree[:INSERT_COLUMNS] = @tree[:INSERT_COLUMNS].concat(cols)
264
+ self
265
+ end
266
+
267
+ # add timestamps to an insert or update
268
+ def ts
269
+ now = "'#{Time.now.utc}'"
270
+ if @tree[:VALUES]
271
+ @tree[:INSERT_COLUMNS].concat ['created_at', 'updated_at']
272
+ @tree[:VALUES] = @tree[:VALUES].map{|arr| arr.concat [now, now]}
273
+ elsif @tree[:SET]
274
+ @tree[:SET] += ", updated_at = #{now}"
275
+ end
276
+ self
277
+ end
278
+ alias_method :timestamps, :ts
279
+
280
+ def returning(*cols)
281
+ @tree[:RETURNING] = cols.map{|c| Qx.quote_ident(c)}
282
+ self
283
+ end
284
+
285
+ # Vals can be a raw SQL string or a hash of data
286
+ def set(vals)
287
+ if vals.is_a? Hash
288
+ vals = vals.map{|key, val| "#{Qx.quote_ident(key)} = #{Qx.quote(val)}"}.join(", ")
289
+ end
290
+ @tree[:SET] = vals.to_s
291
+ self
292
+ end
293
+
294
+ def explain
295
+ @tree[:EXPLAIN] = true
296
+ self
297
+ end
298
+
299
+ # -- Helpers!
300
+
301
+ def self.fetch(table_name, data, options={})
302
+ expr = Qx.select('*').from(table_name)
303
+ if data.is_a?(Hash)
304
+ expr = data.reduce(expr){|acc, pair| acc.and_where("#{pair.first} IN ($vals)", vals: Array(pair.last))}
305
+ else
306
+ expr = expr.where("id IN ($ids)", ids: Array(data))
307
+ end
308
+ result = expr.execute(options)
309
+ return result
310
+ end
311
+
312
+ # Given a Qx expression, add a LIMIT and OFFSET for pagination
313
+ def paginate(current_page, page_length)
314
+ current_page = 1 if current_page.nil? || current_page < 1
315
+ self.limit(page_length).offset((current_page - 1) * page_length)
316
+ end
317
+
318
+ def pp
319
+ str = self.parse
320
+ # Colorize some tokens
321
+ # TODO indent by paren levels
322
+ str = str
323
+ .gsub(/(FROM|WHERE|VALUES|SET|SELECT|UPDATE|INSERT INTO|DELETE FROM)/){"#{$1}".blue.bold}
324
+ .gsub(/(\(|\))/){"#{$1}".cyan}
325
+ .gsub("$Q$", "'")
326
+ return str
327
+ end
328
+
329
+ # -- utils
330
+
331
+ def tree; @tree; end
332
+
333
+ # Safely interpolate some data into a SQL expression
334
+ def self.interpolate_expr(expr, data={})
335
+ expr.to_s.gsub(/\$\w+/) do |match|
336
+ val = data[match.gsub(/[ \$]*/, '').to_sym]
337
+ vals = val.is_a?(Array) ? val : [val]
338
+ vals.map{|x| Qx.quote(x)}.join(", ")
339
+ end
340
+ end
341
+
342
+ # Quote a string for use in sql to prevent injection or weird errors
343
+ # Always use this for all values!
344
+ # Just uses double-dollar quoting universally. Should be generally safe and easy.
345
+ # Will return an unquoted value it it's a Fixnum
346
+ def self.quote(val)
347
+ if val.is_a?(Qx)
348
+ val.parse
349
+ elsif val.is_a?(Fixnum)
350
+ val.to_s
351
+ elsif val.is_a?(Time)
352
+ "'" + val.to_s + "'" # single-quoted times for a little better readability
353
+ elsif val == nil
354
+ "NULL"
355
+ elsif !!val == val # is a boolean
356
+ val ? "'t'" : "'f'"
357
+ else
358
+ return "$Q$" + val.to_s + "$Q$"
359
+ end
360
+ end
361
+
362
+ # Double-quote sql identifiers (or parse Qx trees for subqueries)
363
+ def self.quote_ident(expr)
364
+ if expr.is_a?(Qx)
365
+ Qx.parse(expr.tree)
366
+ else
367
+ expr.to_s.split('.').map{|s| s == '*' ? s : "\"#{s}\""}.join('.')
368
+ end
369
+ end
370
+
371
+ private # Internal utils
372
+
373
+ # Turn join params into something that .parse can use
374
+ def self.parse_joins(js)
375
+ js.map{|table, cond, data| [table.is_a?(Qx) ? table.parse : table, Qx.interpolate_expr(cond, data)]}
376
+ end
377
+
378
+ # Given an array, determine if it has the form
379
+ # [[join_table, join_on, data], ...]
380
+ # or
381
+ # [join_table, join_on, data]
382
+ # Always return the former format
383
+ def self.get_join_param(js)
384
+ js.first.is_a?(Array) ? js : [[js.first, js[1], js[2]]]
385
+ end
386
+
387
+ # given either a single hash or a string expr + data, parse it into a single string expression
388
+ def self.parse_wheres(clauses)
389
+ clauses.map do |expr, data|
390
+ if expr.is_a?(Hash)
391
+ expr.map{|key, val| "#{Qx.quote_ident(key)} IN (#{Qx.quote(val)})"}.join(" AND ")
392
+ else
393
+ Qx.interpolate_expr(expr, data)
394
+ end
395
+ end
396
+ end
397
+
398
+ # Similar to get_joins_params, except each where clause is a pair, not a triplet
399
+ def self.get_where_params(ws)
400
+ ws.first.is_a?(Array) ? ws : [[ws.first, ws[1]]]
401
+ end
402
+
403
+ # given either a single, hash, array of hashes, or csv style, turn it all into csv style
404
+ # util for INSERT INTO x (y) VALUES z
405
+ def self.parse_val_params(vals)
406
+ if vals.is_a?(Array) && vals.first.is_a?(Array)
407
+ cols = vals.first
408
+ data = vals[1..-1]
409
+ elsif vals.is_a?(Array) && vals.first.is_a?(Hash)
410
+ hashes = vals.map{|h| h.sort.to_h}
411
+ cols = hashes.first.keys
412
+ data = hashes.map{|h| h.values}
413
+ elsif vals.is_a?(Hash)
414
+ cols = vals.keys
415
+ data = [vals.values]
416
+ end
417
+ return [cols, data]
418
+ end
419
+
420
+
421
+ end
422
+
metadata ADDED
@@ -0,0 +1,44 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: qx
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Jay R Bolton
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-05-18 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A expression builder for SQL expressions with Postgresql support
14
+ email: jayrbolton@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - lib/qx.rb
20
+ homepage: https://github.com/jayrbolton/qx
21
+ licenses:
22
+ - MIT
23
+ metadata: {}
24
+ post_install_message:
25
+ rdoc_options: []
26
+ require_paths:
27
+ - lib
28
+ required_ruby_version: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ required_rubygems_version: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ requirements: []
39
+ rubyforge_project:
40
+ rubygems_version: 2.6.6
41
+ signing_key:
42
+ specification_version: 4
43
+ summary: SQL expression builder
44
+ test_files: []