qx 0.0.1

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.
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: []