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