qx 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/qx.rb +422 -0
- 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: []
|