rsql 0.2.7 → 0.2.8
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.
- data/LICENSE +19 -0
- data/example.rsqlrc +291 -0
- data/extra/mysql-client-5.1.59-1.tgz +0 -0
- data/lib/rsql.rb +10 -0
- data/lib/rsql/commands.rb +243 -0
- data/lib/rsql/eval_context.rb +737 -0
- data/lib/rsql/mysql_results.rb +470 -0
- data/test/test_commands.rb +85 -0
- data/test/test_eval_context.rb +179 -0
- data/test/test_mysql_results.rb +192 -0
- metadata +17 -10
@@ -0,0 +1,470 @@
|
|
1
|
+
#--
|
2
|
+
# Copyright (C) 2011-2012 by Brad Robel-Forrest <brad+rsql@gigglewax.com>
|
3
|
+
#
|
4
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
5
|
+
# of this software and associated documentation files (the "Software"), to deal
|
6
|
+
# in the Software without restriction, including without limitation the rights
|
7
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
8
|
+
# copies of the Software, and to permit persons to whom the Software is
|
9
|
+
# furnished to do so, subject to the following conditions:
|
10
|
+
#
|
11
|
+
# The above copyright notice and this permission notice shall be included in
|
12
|
+
# all copies or substantial portions of the Software.
|
13
|
+
#
|
14
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
15
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
16
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
17
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
18
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
19
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
20
|
+
# THE SOFTWARE.
|
21
|
+
|
22
|
+
# The standard MySQL hooks block Ruby threads, this interface provides an
|
23
|
+
# asynchronous query.
|
24
|
+
#
|
25
|
+
require 'mysqlplus'
|
26
|
+
|
27
|
+
class Mysql
|
28
|
+
alias :query :async_query
|
29
|
+
end
|
30
|
+
|
31
|
+
module RSQL
|
32
|
+
|
33
|
+
########################################
|
34
|
+
# A wrapper to make it easier to work with MySQL results (and prettier).
|
35
|
+
#
|
36
|
+
class MySQLResults
|
37
|
+
|
38
|
+
HEX_RANGE = [
|
39
|
+
Mysql::Field::TYPE_BLOB,
|
40
|
+
Mysql::Field::TYPE_STRING,
|
41
|
+
]
|
42
|
+
|
43
|
+
@@conn = nil
|
44
|
+
@@field_separator = ' '
|
45
|
+
@@max_rows = 1000
|
46
|
+
@@database_name = nil
|
47
|
+
@@name_cache = {}
|
48
|
+
@@history = []
|
49
|
+
@@max_history = 10
|
50
|
+
|
51
|
+
class MaxRowsException < RangeError
|
52
|
+
def initialize(rows, max)
|
53
|
+
@rows = rows
|
54
|
+
@max = max
|
55
|
+
end
|
56
|
+
attr_reader :rows, :max
|
57
|
+
end
|
58
|
+
|
59
|
+
class << self
|
60
|
+
|
61
|
+
# Get the underlying MySQL connection object in use.
|
62
|
+
#
|
63
|
+
def conn; @@conn; end
|
64
|
+
|
65
|
+
# Set the underlying MySQL connection object to use which
|
66
|
+
# implicitly resets the name cache.
|
67
|
+
#
|
68
|
+
def conn=(conn)
|
69
|
+
@@conn = conn
|
70
|
+
reset_cache
|
71
|
+
end
|
72
|
+
|
73
|
+
# Get the field separator to use when writing rows in
|
74
|
+
# columns.
|
75
|
+
#
|
76
|
+
def field_separator; @@field_separator; end
|
77
|
+
|
78
|
+
# Set the field separator to use when writing rows in
|
79
|
+
# columns.
|
80
|
+
#
|
81
|
+
def field_separator=(sep); @@field_separator = sep; end
|
82
|
+
|
83
|
+
# Get the maximum number of rows to process before
|
84
|
+
# throwing a MaxRowsException.
|
85
|
+
#
|
86
|
+
def max_rows; @@max_rows; end
|
87
|
+
|
88
|
+
# Set the maximum number of rows to process before
|
89
|
+
# throwing a MaxRowsException.
|
90
|
+
#
|
91
|
+
def max_rows=(cnt); @@max_rows = cnt; end
|
92
|
+
|
93
|
+
# Get the name of the current database in use.
|
94
|
+
#
|
95
|
+
def database_name; @@database_name; end
|
96
|
+
|
97
|
+
# Set the name of the current database in use.
|
98
|
+
#
|
99
|
+
def database_name=(database); @@database_name = database; end
|
100
|
+
|
101
|
+
# Get a list of the most recent query strings.
|
102
|
+
#
|
103
|
+
def history(cnt=:all)
|
104
|
+
if Integer === cnt && cnt < @@history.size
|
105
|
+
@@history[-cnt,cnt]
|
106
|
+
else
|
107
|
+
@@history
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Reset the history to an empty list.
|
112
|
+
#
|
113
|
+
def reset_history
|
114
|
+
@@history = []
|
115
|
+
end
|
116
|
+
|
117
|
+
# Get the maximum number of historical entries to retain.
|
118
|
+
#
|
119
|
+
def get_max_history; @@max_history; end
|
120
|
+
|
121
|
+
# Set the maximum number of historical entries to retain.
|
122
|
+
#
|
123
|
+
def set_max_history=(count); @@max_history = count; end
|
124
|
+
|
125
|
+
# Get the list of databases available.
|
126
|
+
#
|
127
|
+
def databases
|
128
|
+
@@name_cache.keys.sort
|
129
|
+
end
|
130
|
+
|
131
|
+
# Get the list of tables available for the current
|
132
|
+
# database or a specific one.
|
133
|
+
#
|
134
|
+
def tables(database=@@database_name)
|
135
|
+
@@name_cache[database] || []
|
136
|
+
end
|
137
|
+
|
138
|
+
# Force the database and table names cache to be (re)loaded.
|
139
|
+
#
|
140
|
+
def reset_cache
|
141
|
+
@@name_cache = {}
|
142
|
+
begin
|
143
|
+
if @@conn
|
144
|
+
@@conn.list_dbs.each do |db_name|
|
145
|
+
@@conn.select_db(db_name)
|
146
|
+
@@name_cache[db_name] = @@conn.list_tables.sort
|
147
|
+
end
|
148
|
+
end
|
149
|
+
rescue Mysql::Error => ex
|
150
|
+
ensure
|
151
|
+
if @@conn && @@database_name
|
152
|
+
@@conn.select_db(@@database_name)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Provide a list of tab completions given the prompted
|
158
|
+
# case-insensitive value.
|
159
|
+
#
|
160
|
+
def complete(str)
|
161
|
+
return [] unless @@conn
|
162
|
+
|
163
|
+
ret = []
|
164
|
+
|
165
|
+
# offer table names from a specific database
|
166
|
+
if str =~ /^([^.]+)\.(.*)$/
|
167
|
+
db = $1
|
168
|
+
tb = $2
|
169
|
+
@@name_cache.each do |db_name, tnames|
|
170
|
+
if db.casecmp(db_name) == 0
|
171
|
+
tnames.each do |n|
|
172
|
+
if m = n.match(/^(#{tb})/i)
|
173
|
+
ret << "#{db_name}.#{n}"
|
174
|
+
end
|
175
|
+
end
|
176
|
+
break
|
177
|
+
end
|
178
|
+
end
|
179
|
+
else
|
180
|
+
@@name_cache.each do |db_name, tnames|
|
181
|
+
if db_name == @@database_name
|
182
|
+
tnames.each do |n|
|
183
|
+
if m = n.match(/^(#{str})/i)
|
184
|
+
ret << n
|
185
|
+
end
|
186
|
+
end
|
187
|
+
elsif m = db_name.match(/^(#{str})/i)
|
188
|
+
ret << db_name
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
return ret.sort
|
194
|
+
end
|
195
|
+
|
196
|
+
# Get results from a query.
|
197
|
+
#
|
198
|
+
def query(sql, eval_context, raw=false, max_rows=@@max_rows)
|
199
|
+
@@history.shift if @@max_history <= @@history.size
|
200
|
+
@@history << sql
|
201
|
+
|
202
|
+
start = Time.now.to_f
|
203
|
+
results = @@conn.query(sql)
|
204
|
+
elapsed = Time.now.to_f - start.to_f
|
205
|
+
|
206
|
+
affected_rows = @@conn.affected_rows
|
207
|
+
unless results && 0 < results.num_rows
|
208
|
+
return new(sql, elapsed, affected_rows)
|
209
|
+
end
|
210
|
+
|
211
|
+
if max_rows < results.num_rows
|
212
|
+
raise MaxRowsException.new(results.num_rows, max_rows)
|
213
|
+
end
|
214
|
+
|
215
|
+
# extract mysql results into our own table so we can predetermine the
|
216
|
+
# lengths of columns and give users a chance to reformat column data
|
217
|
+
# before it's displayed (via the bang maps)
|
218
|
+
|
219
|
+
fields = results.fetch_fields
|
220
|
+
fields.collect! do |field|
|
221
|
+
def field.longest_length=(len); @longest_length = len; end
|
222
|
+
def field.longest_length; @longest_length; end
|
223
|
+
field.longest_length = field.name.length
|
224
|
+
field
|
225
|
+
end
|
226
|
+
|
227
|
+
results_table = []
|
228
|
+
while vals = results.fetch_row
|
229
|
+
row = []
|
230
|
+
fields.each_with_index do |field, i|
|
231
|
+
if raw
|
232
|
+
val = vals[i]
|
233
|
+
else
|
234
|
+
val = eval_context.bang_eval(field.name, vals[i])
|
235
|
+
if val.nil?
|
236
|
+
val = 'NULL'
|
237
|
+
elsif HEX_RANGE.include?(field.type) && val =~ /[^[:print:]\s]/
|
238
|
+
val = eval_context.to_hexstr(val)
|
239
|
+
end
|
240
|
+
end
|
241
|
+
vlen = val.respond_to?(:length) ? val.length : 0
|
242
|
+
if field.longest_length < vlen
|
243
|
+
if String === val
|
244
|
+
# consider only the longest line length since some
|
245
|
+
# output contains multiple lines like "show create table"
|
246
|
+
longest_line = val.split(/\r?\n/).collect{|l|l.length}.max
|
247
|
+
if field.longest_length < longest_line
|
248
|
+
field.longest_length = longest_line
|
249
|
+
end
|
250
|
+
else
|
251
|
+
field.longest_length = val.length
|
252
|
+
end
|
253
|
+
end
|
254
|
+
row << val
|
255
|
+
end
|
256
|
+
results_table << row
|
257
|
+
end
|
258
|
+
|
259
|
+
return new(sql, elapsed, affected_rows, fields, results_table)
|
260
|
+
end
|
261
|
+
|
262
|
+
end # class << self
|
263
|
+
|
264
|
+
########################################
|
265
|
+
|
266
|
+
def initialize(sql, elapsed, affected_rows,
|
267
|
+
fields=nil, table=nil, field_separator=@@field_separator)
|
268
|
+
@sql = sql;
|
269
|
+
@elapsed = elapsed;
|
270
|
+
@affected_rows = affected_rows;
|
271
|
+
@fields = fields
|
272
|
+
@table = table
|
273
|
+
@field_separator = field_separator
|
274
|
+
|
275
|
+
# we set this here so that (a) it occurs _after_ we are
|
276
|
+
# successful and so we can show an appropriate messge in a
|
277
|
+
# displayer
|
278
|
+
if @sql.match(/use\s+(\S+)/i)
|
279
|
+
@database_changed = true
|
280
|
+
@@database_name = $1
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
# Get the query associated with these results.
|
285
|
+
#
|
286
|
+
attr_reader :sql
|
287
|
+
|
288
|
+
# Get the number of rows that were affected by the query.
|
289
|
+
#
|
290
|
+
attr_reader :affected_rows
|
291
|
+
|
292
|
+
# Get the amount of elapsed time taken by the query.
|
293
|
+
#
|
294
|
+
attr_reader :elapsed
|
295
|
+
|
296
|
+
# Determine if there are any results.
|
297
|
+
#
|
298
|
+
def any?
|
299
|
+
!@table.nil?
|
300
|
+
end
|
301
|
+
|
302
|
+
# Determine if there are no results.
|
303
|
+
#
|
304
|
+
def empty?
|
305
|
+
@table.nil?
|
306
|
+
end
|
307
|
+
|
308
|
+
# Get the number of rows available in the results.
|
309
|
+
#
|
310
|
+
def num_rows
|
311
|
+
@table ? @table.size : 0
|
312
|
+
end
|
313
|
+
|
314
|
+
# Get a row from the table hashed with the field names.
|
315
|
+
#
|
316
|
+
def [](index)
|
317
|
+
if !@fields || !@table
|
318
|
+
return nil
|
319
|
+
end
|
320
|
+
if row = @table[index]
|
321
|
+
hash = {}
|
322
|
+
@fields.each_with_index {|f,i| hash[f.name] = row[i]}
|
323
|
+
return hash
|
324
|
+
else
|
325
|
+
return nil
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
# Iterate through each row of the table hashed with the field names.
|
330
|
+
#
|
331
|
+
def each_hash(&block)
|
332
|
+
if @table
|
333
|
+
@table.each do |row|
|
334
|
+
hash = {}
|
335
|
+
@fields.each_with_index {|f,i| hash[f.name] = row[i]}
|
336
|
+
yield(hash)
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
# Remove all rows that do NOT match the expression. Returns true if any
|
342
|
+
# matches were found.
|
343
|
+
#
|
344
|
+
# Options:
|
345
|
+
# :fixed => indicates that the string should be escaped of any special characters
|
346
|
+
# :nocolor => will not add color escape codes to indicate the match
|
347
|
+
# :inverse => reverses the regular expression match
|
348
|
+
#
|
349
|
+
def grep(pattern, *gopts)
|
350
|
+
if @table
|
351
|
+
nocolor = gopts.include?(:nocolor)
|
352
|
+
|
353
|
+
if inverted = gopts.include?(:inverse)
|
354
|
+
# there's no point in coloring matches we are removing
|
355
|
+
nocolor = true
|
356
|
+
end
|
357
|
+
|
358
|
+
if gopts.include?(:fixed)
|
359
|
+
regexp = Regexp.new(/#{Regexp.escape(pattern.to_str)}/)
|
360
|
+
elsif Regexp === pattern
|
361
|
+
regexp = pattern
|
362
|
+
else
|
363
|
+
regexp = Regexp.new(/#{pattern.to_str}/)
|
364
|
+
end
|
365
|
+
|
366
|
+
rval = inverted
|
367
|
+
|
368
|
+
@table.delete_if do |row|
|
369
|
+
matched = false
|
370
|
+
row.each do |val|
|
371
|
+
val = val.to_str unless String === val
|
372
|
+
if nocolor
|
373
|
+
if matched = !val.match(regexp).nil?
|
374
|
+
rval = inverted ? false : true
|
375
|
+
break
|
376
|
+
end
|
377
|
+
else
|
378
|
+
# in the color case, we want to colorize all hits in
|
379
|
+
# all columns, so we can't early terminate our
|
380
|
+
# search
|
381
|
+
if val.gsub!(regexp){|m| "\e[31;1m#{m}\e[0m"}
|
382
|
+
matched = true
|
383
|
+
rval = inverted ? false : true
|
384
|
+
end
|
385
|
+
end
|
386
|
+
end
|
387
|
+
inverted ? matched : !matched
|
388
|
+
end
|
389
|
+
|
390
|
+
return rval
|
391
|
+
else
|
392
|
+
return false
|
393
|
+
end
|
394
|
+
end
|
395
|
+
|
396
|
+
# Show a set of results in a decent fashion.
|
397
|
+
#
|
398
|
+
def display_by_column(io=$stdout)
|
399
|
+
if @fields && @table
|
400
|
+
fmts = []
|
401
|
+
names = []
|
402
|
+
len = 0
|
403
|
+
@fields.each do |field|
|
404
|
+
fmts << "%-#{field.longest_length}s"
|
405
|
+
names << field.name
|
406
|
+
len += field.longest_length
|
407
|
+
end
|
408
|
+
|
409
|
+
fmt = fmts.join(@field_separator)
|
410
|
+
sep = '-' * (len + fmts.length)
|
411
|
+
io.puts(fmt % names, sep)
|
412
|
+
@table.each{|row| io.puts(fmt % row)}
|
413
|
+
display_stats(io, sep)
|
414
|
+
else
|
415
|
+
display_stats(io)
|
416
|
+
end
|
417
|
+
end
|
418
|
+
|
419
|
+
# Show a set of results with a single character separation.
|
420
|
+
#
|
421
|
+
def display_by_batch(io=$stdout)
|
422
|
+
if @fields && @table
|
423
|
+
fmt = (['%s'] * @fields.size).join(@field_separator)
|
424
|
+
@table.each{|row| io.puts(fmt % row)}
|
425
|
+
end
|
426
|
+
end
|
427
|
+
|
428
|
+
# Show a set of results line separated.
|
429
|
+
#
|
430
|
+
def display_by_line(io=$stdout)
|
431
|
+
if @fields && @table
|
432
|
+
namelen = 0
|
433
|
+
@fields.each do |field|
|
434
|
+
namelen = field.name.length if namelen < field.name.length
|
435
|
+
end
|
436
|
+
namelen += 1
|
437
|
+
|
438
|
+
@table.each_with_index do |row, i|
|
439
|
+
io.puts("#{'*'*30} #{i+1}. row #{'*'*30}")
|
440
|
+
row.each_with_index do |val, vi|
|
441
|
+
io.printf("%#{namelen}s #{val}#{$/}", @fields[vi].name + ':')
|
442
|
+
end
|
443
|
+
end
|
444
|
+
end
|
445
|
+
display_stats(io)
|
446
|
+
end
|
447
|
+
|
448
|
+
# Show a summary line of the results.
|
449
|
+
#
|
450
|
+
def display_stats(io=$stdout, hdr='')
|
451
|
+
if @table
|
452
|
+
if @database_changed
|
453
|
+
io.puts(hdr, "Database changed");
|
454
|
+
hdr = ''
|
455
|
+
end
|
456
|
+
s = 1 == @table.size ? 'row' : 'rows'
|
457
|
+
io.puts(hdr, "#{@table.size} #{s} in set (#{'%0.2f'%@elapsed} sec)")
|
458
|
+
else
|
459
|
+
if @database_changed
|
460
|
+
io.puts(hdr, "Database changed");
|
461
|
+
else
|
462
|
+
s = 1 == @affected_rows ? 'row' : 'rows'
|
463
|
+
io.puts(hdr, "Query OK, #{@affected_rows} #{s} affected (#{'%0.2f'%@elapsed} sec)")
|
464
|
+
end
|
465
|
+
end
|
466
|
+
end
|
467
|
+
|
468
|
+
end # class MySQLResults
|
469
|
+
|
470
|
+
end # module RSQL
|