rsql 0.2.7 → 0.2.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,737 @@
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
+ module RSQL
23
+
24
+ require 'ostruct'
25
+ require 'time'
26
+
27
+ # todo: add a simple way to interpolate directly within a sql query about to
28
+ # be exec'd (so we can save stuff from other queries in variables that can
29
+ # then be ref'd in a new query all on the cmd line)
30
+
31
+ ################################################################################
32
+ # This class wraps all dynamic evaluation and serves as the reflection class
33
+ # for adding methods dynamically.
34
+ #
35
+ class EvalContext
36
+
37
+ Registration = Struct.new(:name, :args, :bangs, :block, :usage,
38
+ :desc, :source, :source_fn)
39
+
40
+ HEXSTR_LIMIT = 32
41
+
42
+ def initialize(options=OpenStruct.new)
43
+ @opts = options
44
+ @prompt = nil
45
+ @verbose = @opts.verbose
46
+ @hexstr_limit = HEXSTR_LIMIT
47
+ @results = nil
48
+
49
+ @loaded_fns = []
50
+ @loaded_fns_state = {}
51
+ @init_registrations = []
52
+ @bangs = {}
53
+ @global_bangs = {}
54
+
55
+ @registrations = {
56
+ :version => Registration.new('version', [], {},
57
+ method(:version),
58
+ 'version',
59
+ 'Version information about RSQL, the client, and the server.'),
60
+ :help => Registration.new('help', [], {},
61
+ method(:help),
62
+ 'help',
63
+ 'Show short syntax help.'),
64
+ :grep => Registration.new('grep', [], {},
65
+ method(:grep),
66
+ 'grep',
67
+ 'Show results when regular expression matches any part of the content.'),
68
+ :reload => Registration.new('reload', [], {},
69
+ method(:reload),
70
+ 'reload',
71
+ 'Reload the rsqlrc file.'),
72
+ :desc => Registration.new('desc', [], {},
73
+ method(:desc),
74
+ 'desc',
75
+ 'Describe the content of a recipe.'),
76
+ :history => Registration.new('history', [], {},
77
+ method(:history),
78
+ 'history(cnt=1)',
79
+ 'Print recent queries made (request a count or use :all for entire list).'),
80
+ :set_max_rows => Registration.new('set_max_rows', [], {},
81
+ Proc.new{|r| MySQLResults.max_rows = r},
82
+ 'set_max_rows',
83
+ 'Set the maximum number of rows to process.'),
84
+ :max_rows => Registration.new('max_rows', [], {},
85
+ Proc.new{MySQLResults.max_rows},
86
+ 'max_rows',
87
+ 'Get the maximum number of rows to process.'),
88
+ }
89
+ end
90
+
91
+ attr_reader :prompt
92
+ attr_accessor :bangs, :verbose
93
+
94
+ def call_init_registrations
95
+ @init_registrations.each do |sym|
96
+ reg = @registrations[sym]
97
+ sql = reg.block.call(*reg.args)
98
+ query(sql) if String === sql
99
+ end
100
+ end
101
+
102
+ def load(fn, opt=nil)
103
+ @loaded_fns << fn unless @loaded_fns_state.key?(fn)
104
+ @loaded_fns_state[fn] = :loading
105
+
106
+ # this should only be done after we have established a
107
+ # mysql connection, so this option allows rsql to load the
108
+ # init file immediately and then later make the init
109
+ # registration calls--we set this as an instance variable
110
+ # to allow for loaded files to call load again and yet
111
+ # still maintain the skip logic
112
+ if opt == :skip_init_registrations
113
+ reset_skipping = @skipping_init_registrations = true
114
+ end
115
+
116
+ ret = Thread.new {
117
+ begin
118
+ eval(File.read(fn), binding, fn)
119
+ nil
120
+ rescue Exception => ex
121
+ ex
122
+ end
123
+ }.value
124
+
125
+ if Exception === ret
126
+ @loaded_fns_state[fn] = :failed
127
+ if @verbose
128
+ $stderr.puts("#{ex.class}: #{ex.message}", ex.backtrace)
129
+ else
130
+ bt = ret.backtrace.collect{|line| line.start_with?(fn) ? line : nil}.compact
131
+ $stderr.puts("#{ret.class}: #{ret.message}", bt, '')
132
+ end
133
+ ret = false
134
+ else
135
+ @loaded_fns_state[fn] = :loaded
136
+ call_init_registrations unless @skipping_init_registrations
137
+ ret = true
138
+ end
139
+
140
+ @skipping_init_registrations = false if reset_skipping
141
+
142
+ return ret
143
+ end
144
+
145
+ def reload
146
+ # some files may be loaded by other files, if so, we don't want to
147
+ # reload them again here
148
+ @loaded_fns.each{|fn| @loaded_fns_state[fn] = nil}
149
+ @loaded_fns.each{|fn| self.load(fn, :skip_init_registrations) if @loaded_fns_state[fn] == nil}
150
+
151
+ # load up the inits after all the normal registrations are ready
152
+ call_init_registrations
153
+
154
+ # report all the successfully loaded ones
155
+ loaded = []
156
+ @loaded_fns.each{|fn,state| loaded << fn if @loaded_fns_state[fn] == :loaded}
157
+ puts "loaded: #{loaded.inspect}"
158
+ end
159
+
160
+ def bang_eval(field, val)
161
+ # allow individual bangs to override global ones, even if they're nil
162
+ if @bangs.key?(field)
163
+ bang = @bangs[field]
164
+ else
165
+ # todo: this will run on *every* value--this should be optimized
166
+ # so that it's only run once on each query's result column
167
+ # fields and then we'd know if any bangs are usable and pased in
168
+ # for each result value
169
+ @global_bangs.each do |m,b|
170
+ if (String === m && m == field.to_s) ||
171
+ (Regexp === m && m.match(field.to_s))
172
+ bang = b
173
+ break
174
+ end
175
+ end
176
+ end
177
+
178
+ if bang
179
+ begin
180
+ val = Thread.new{ eval("#{bang}(val)") }.value
181
+ rescue Exception => ex
182
+ if @verbose
183
+ $stderr.puts("#{ex.class}: #{ex.message}", ex.backtrace)
184
+ else
185
+ $stderr.puts(ex.message, ex.backtrace.first)
186
+ end
187
+ end
188
+ end
189
+
190
+ return val
191
+ end
192
+
193
+ # Safely evaluate Ruby content within our context.
194
+ #
195
+ def safe_eval(content, results, stdout)
196
+ @results = results
197
+
198
+ # allow a simple reload to be called directly as it requires a
199
+ # little looser safety valve...
200
+ if 'reload' == content
201
+ reload
202
+ return
203
+ end
204
+
205
+ # same relaxed call to load too
206
+ if m = content.match(/^\s*load\s+'(.+)'\s*$/)
207
+ self.load(m[1])
208
+ return
209
+ end
210
+
211
+ # help out the poor user and fix up any describes
212
+ # requested so they don't need to remember that it needs
213
+ # to be a symbol passed in
214
+ if m = content.match(/^\s*desc\s+([^:]\S+)\s*$/)
215
+ content = "desc :#{m[1]}"
216
+ end
217
+
218
+ if stdout
219
+ # capture stdout
220
+ orig_stdout = $stdout
221
+ $stdout = stdout
222
+ end
223
+
224
+ begin
225
+ # in order to print out errors in a loaded script so
226
+ # that we have file/line info, we need to rescue their
227
+ # exceptions inside the evaluation
228
+ th = Thread.new do
229
+ eval('begin;' << content << %q{
230
+ rescue Exception => ex
231
+ if @verbose
232
+ $stderr.puts("#{ex.class}: #{ex.message}", ex.backtrace)
233
+ else
234
+ bt = []
235
+ ex.backtrace.each do |t|
236
+ break if t.include?('bin/rsql')
237
+ bt << t unless t.include?('lib/rsql/') || t.include?('(eval)')
238
+ end
239
+ $stderr.puts(ex.message.gsub(/\(eval\):\d+:/,''),bt)
240
+ end
241
+ end
242
+ })
243
+ end
244
+ value = th.value
245
+ rescue Exception => ex
246
+ $stderr.puts(ex.message.gsub(/\(eval\):\d+:/,''))
247
+ ensure
248
+ $stdout = orig_stdout if stdout
249
+ end
250
+
251
+ return value
252
+ end
253
+
254
+ # Provide a list of tab completions given the prompted value.
255
+ #
256
+ def complete(str)
257
+ if str[0] == ?.
258
+ str.slice!(0)
259
+ prefix = '.'
260
+ else
261
+ prefix = ''
262
+ end
263
+
264
+ ret = MySQLResults.complete(str)
265
+
266
+ ret += @registrations.keys.sort_by{|sym|sym.to_s}.collect do |sym|
267
+ name = sym.to_s
268
+ if name.start_with?(str)
269
+ prefix + name
270
+ else
271
+ nil
272
+ end
273
+ end
274
+
275
+ ret.compact!
276
+ ret
277
+ end
278
+
279
+ # Reset the hexstr limit back to the default value.
280
+ #
281
+ def reset_hexstr_limit
282
+ @hexstr_limit = HEXSTR_LIMIT
283
+ end
284
+
285
+ # Convert a binary string value into a hexadecimal string.
286
+ #
287
+ def to_hexstr(bin, limit=@hexstr_limit, prefix='0x')
288
+ return bin if bin.nil?
289
+
290
+ cnt = 0
291
+ str = prefix << bin.gsub(/./m) do |ch|
292
+ if limit
293
+ if limit < 1
294
+ cnt += 1
295
+ next
296
+ end
297
+ limit -= 1
298
+ end
299
+ '%02x' % ch.bytes.first
300
+ end
301
+
302
+ if limit && limit < 1 && 0 < cnt
303
+ str << "... (#{cnt} bytes hidden)"
304
+ end
305
+
306
+ return str
307
+ end
308
+
309
+ ########################################
310
+ private
311
+
312
+ # Display a listing of all registered helpers.
313
+ #
314
+ def list # :doc:
315
+ usagelen = 0
316
+ desclen = 0
317
+
318
+ sorted = @registrations.values.sort_by do |reg|
319
+ usagelen = reg.usage.length if usagelen < reg.usage.length
320
+ longest_line = reg.desc.split(/\r?\n/).collect{|l|l.length}.max
321
+ desclen = longest_line if longest_line && desclen < longest_line
322
+ reg.usage
323
+ end
324
+
325
+ fmt = "%-#{usagelen}s %s#{$/}"
326
+
327
+ printf(fmt, 'usage', 'description')
328
+ puts '-'*(usagelen+2+desclen)
329
+
330
+ sorted.each do |reg|
331
+ printf(fmt, reg.usage, reg.desc)
332
+ end
333
+
334
+ return nil
335
+ end
336
+
337
+ # Attempt to locate the parameters of a given block by
338
+ # searching its source.
339
+ #
340
+ def params(name, block)
341
+ params = ''
342
+
343
+ if block.arity != 0 && block.arity != -1 &&
344
+ block.inspect.match(/@(.+):(\d+)>$/)
345
+ fn = $1
346
+ lineno = $2.to_i
347
+
348
+ if fn == '(eval)'
349
+ $stderr.puts "refusing to search an eval block for :#{name}"
350
+ return params
351
+ end
352
+
353
+ File.open(fn) do |f|
354
+ i = 0
355
+ found = false
356
+ while line = f.gets
357
+ i += 1
358
+ next if i < lineno
359
+
360
+ unless found
361
+ # give up if no start found within 20
362
+ # lines
363
+ break if lineno + 20 < i
364
+ if m = line.match(/(\{|do\b)(.*)$/)
365
+ # adjust line to be the remainder
366
+ # after the start
367
+ line = m[2]
368
+ found = true
369
+ else
370
+ next
371
+ end
372
+ end
373
+
374
+ if m = line.match(/^\s*\|([^\|]*)\|/)
375
+ params = "(#{m[1]})"
376
+ break
377
+ end
378
+
379
+ # if the params aren't here then we'd
380
+ # better only have whitespace otherwise
381
+ # this block doesn't have params...even
382
+ # though arity says it should
383
+ next if line.match(/^\s*$/)
384
+ $stderr.puts "unable to locate params for :#{name}"
385
+ break
386
+ end
387
+ end
388
+ end
389
+
390
+ return params
391
+ end
392
+
393
+ # Similiar to the MySQL "desc" command, show the content
394
+ # of nearly any registered recipe including where it was
395
+ # sourced (e.g. what file:line it came from).
396
+ #
397
+ def desc(sym)
398
+ unless Symbol === sym
399
+ $stderr.puts("must provide a Symbol--try prefixing it with a colon (:)")
400
+ return
401
+ end
402
+
403
+ unless reg = @registrations[sym]
404
+ $stderr.puts "nothing registered as #{sym}"
405
+ return
406
+ end
407
+
408
+ if Method === reg.block
409
+ $stderr.puts "refusing to describe the #{sym} method"
410
+ return
411
+ end
412
+
413
+ if !reg.source && reg.block.inspect.match(/@(.+):(\d+)>$/)
414
+ fn = $1
415
+ lineno = $2.to_i
416
+
417
+ if fn == __FILE__
418
+ $stderr.puts "refusing to describe EvalContext##{sym}"
419
+ return
420
+ end
421
+
422
+ if fn == '(eval)'
423
+ $stderr.puts 'unable to describe body for an eval block'
424
+ return
425
+ end
426
+
427
+ reg.source_fn = "#{fn}:#{lineno}"
428
+
429
+ File.open(fn) do |f|
430
+ source = ''
431
+ i = 0
432
+ ending = nil
433
+ found = false
434
+
435
+ while line = f.gets
436
+ i += 1
437
+ next unless ending || i == lineno
438
+ source << line
439
+ unless ending
440
+ unless m = line.match(/\{|do\b/)
441
+ $stderr.puts "unable to locate block beginning at #{fn}:#{lineno}"
442
+ return
443
+ end
444
+ ending = m[0] == '{' ? '\}' : 'end'
445
+ next
446
+ end
447
+
448
+ if m = line.match(/^#{ending}/)
449
+ found = true
450
+ break
451
+ end
452
+ end
453
+
454
+ if found
455
+ reg.source = source
456
+ else
457
+ reg.source = ''
458
+ end
459
+ end
460
+ end
461
+
462
+ if reg.source && !reg.source.empty?
463
+ puts '', "[#{reg.source_fn}]", '', reg.source
464
+ else
465
+ $stderr.puts "unable to locate body for #{sym}"
466
+ end
467
+ end
468
+
469
+ # Show all the pertinent version data we have about our
470
+ # software and the mysql connection.
471
+ #
472
+ def version # :doc:
473
+ puts "rsql:v#{RSQL::VERSION} client:v#{MySQLResults.conn.client_info} " \
474
+ "server:v#{MySQLResults.conn.server_info}"
475
+ end
476
+
477
+ # Show a short amount of information about acceptable syntax.
478
+ #
479
+ def help # :doc:
480
+ puts <<EOF
481
+
482
+ Converting values on the fly:
483
+
484
+ rsql> select name, value from rsql_example ! value => humanize_bytes;
485
+
486
+ Inspect MySQL connection:
487
+
488
+ rsql> . p [host_info, proto_info];
489
+
490
+ Escape strings:
491
+
492
+ rsql> . p escape_string('drop table "here"');
493
+
494
+ Show only rows containing a string:
495
+
496
+ rsql> select * from rsql_example | grep 'mystuff';
497
+
498
+ Show only rows containing a regular expression with case insensitive search:
499
+
500
+ rsql> select * from rsql_example | grep /mystuff/i;
501
+
502
+ EOF
503
+ end
504
+
505
+ # Provide a helper utility in the event a registered method would
506
+ # like to make its own queries.
507
+ #
508
+ def query(content, *args) # :doc:
509
+ MySQLResults.query(content, self, *args)
510
+ end
511
+
512
+ # Show the most recent queries made to the MySQL server in this
513
+ # session. Default is to show the last one.
514
+ #
515
+ def history(cnt=1)
516
+ if h = MySQLResults.history(cnt)
517
+ h.each{|q| puts '', q}
518
+ end
519
+ nil
520
+ end
521
+
522
+ # Call MySQLResults' grep to remove (or show) only those lines that
523
+ # have content matching the patterrn.
524
+ #
525
+ def grep(*args)
526
+ if @results.grep(*args)
527
+ @results
528
+ else
529
+ $stderr.puts 'No matches found'
530
+ end
531
+ end
532
+
533
+ # Register bangs to evaluate on all displayers as long as a column
534
+ # match is located. Bang keys may be either exact string matches or
535
+ # regular expressions.
536
+ #
537
+ def register_global_bangs(bangs)
538
+ @global_bangs.merge!(bangs)
539
+ end
540
+
541
+ # Exactly like register below except in addition to registering as
542
+ # a usable call for later, we will also use these as soon as we
543
+ # have a connection to MySQL.
544
+ #
545
+ def register_init(sym, *args, &block) # :doc:
546
+ register(sym, *args, &block)
547
+ @init_registrations << sym unless @init_registrations.include?(sym)
548
+ end
549
+
550
+ # If given a block, allow the block to be called later, otherwise,
551
+ # create a method whose sole purpose is to dynmaically generate
552
+ # sql with variable interpolation.
553
+ #
554
+ def register(sym, *args, &block) # :doc:
555
+ if m = caller.first.match(/^([^:]+:\d+)/)
556
+ source_fn = m[1]
557
+ end
558
+
559
+ name = usage = sym.to_s
560
+
561
+ if Hash === args.last
562
+ bangs = args.pop
563
+ desc = bangs.delete(:desc)
564
+ else
565
+ bangs = {}
566
+ end
567
+
568
+ desc = '' unless desc
569
+
570
+ if block.nil?
571
+ source = args.pop.strip
572
+ sql = squeeze!(source.dup)
573
+
574
+ argstr = args.join(',')
575
+ usage << "(#{argstr})" unless argstr.empty?
576
+
577
+ blockstr = %{lambda{|#{argstr}|%{#{sql}} % [#{argstr}]}}
578
+ block = Thread.new{ eval(blockstr) }.value
579
+ args = []
580
+ else
581
+ source = nil
582
+ usage << params(name, block)
583
+ end
584
+
585
+ @registrations[sym] = Registration.new(name, args, bangs, block, usage,
586
+ desc, source, source_fn)
587
+ end
588
+
589
+ # Convert a list of values into a comma-delimited string,
590
+ # optionally with each value in single quotes.
591
+ #
592
+ def to_list(vals, quoted=false) # :doc:
593
+ vals.collect{|v| quoted ? "'#{v}'" : v.to_s}.join(',')
594
+ end
595
+
596
+ # Convert a collection of values into hexadecimal strings.
597
+ #
598
+ def hexify(*ids) # :doc:
599
+ ids.collect do |id|
600
+ case id
601
+ when String
602
+ if id.start_with?('0x')
603
+ id
604
+ else
605
+ '0x' << id
606
+ end
607
+ when Integer
608
+ '0x' << id.to_s(16)
609
+ else
610
+ raise "invalid id: #{id.class}"
611
+ end
612
+ end.join(',')
613
+ end
614
+
615
+ # Convert a number of bytes into a human readable string.
616
+ #
617
+ def humanize_bytes(bytes) # :doc:
618
+ abbrev = ['B ','KB','MB','GB','TB','PB','EB','ZB','YB']
619
+ bytes = bytes.to_i
620
+ fmt = '%7.2f'
621
+
622
+ abbrev.each_with_index do |a,i|
623
+ if bytes < (1024**(i+1))
624
+ if i == 0
625
+ return "#{fmt % bytes} B"
626
+ else
627
+ b = bytes / (1024.0**i)
628
+ return "#{fmt % b} #{a}"
629
+ end
630
+ end
631
+ end
632
+
633
+ return bytes.to_s
634
+ end
635
+
636
+ # Convert a human readable string of bytes into an integer.
637
+ #
638
+ def dehumanize_bytes(str) # :doc:
639
+ abbrev = ['B','KB','MB','GB','TB','PB','EB','ZB','YB']
640
+
641
+ if str =~ /(\d+(\.\d+)?)\s*(\w+)?/
642
+ b = $1.to_f
643
+ if $3
644
+ i = abbrev.index($3.upcase)
645
+ return (b * (1024**i)).round
646
+ else
647
+ return b.round
648
+ end
649
+ end
650
+
651
+ raise "unable to parse '#{str}'"
652
+ end
653
+
654
+ # Show a nice percent value of a decimal string.
655
+ #
656
+ def humanize_percentage(decimal, precision=1) # :doc:
657
+ if decimal.nil? || decimal == 'NULL'
658
+ 'NA'
659
+ else
660
+ "%5.#{precision}f%%" % (decimal.to_f * 100)
661
+ end
662
+ end
663
+
664
+ # Convert a time into a relative string from now.
665
+ #
666
+ def relative_time(dt) # :doc:
667
+ return dt unless String === dt
668
+
669
+ now = Time.now.utc
670
+ theirs = Time.parse(dt + ' UTC')
671
+ if theirs < now
672
+ diff = now - theirs
673
+ postfix = 'ago'
674
+ else
675
+ diff = theirs - now
676
+ postfix = 'from now'
677
+ end
678
+
679
+ fmt = '%3.0f'
680
+
681
+ [
682
+ [31556926.0, 'years'],
683
+ [2629743.83, 'months'],
684
+ [86400.0, 'days'],
685
+ [3600.0, 'hours'],
686
+ [60.0, 'minutes']
687
+ ].each do |(limit, label)|
688
+ if (limit * 1.5) < diff
689
+ return "#{fmt % (diff / limit)} #{label} #{postfix}"
690
+ end
691
+ end
692
+
693
+ return "#{fmt % diff} seconds #{postfix}"
694
+ end
695
+
696
+ # Squeeze out any spaces.
697
+ #
698
+ def squeeze!(sql) # :doc:
699
+ sql.gsub!(/\s+/,' ')
700
+ sql.strip!
701
+ sql << ';' unless sql[-1] == ?;
702
+ sql
703
+ end
704
+
705
+ # Safely store an object into a file keeping at most one
706
+ # backup if the file already exists.
707
+ #
708
+ def safe_save(obj, name) # :doc:
709
+ name += '.yml' unless File.extname(name) == '.yml'
710
+ tn = "#{name}.tmp"
711
+ File.open(tn, 'w'){|f| YAML.dump(obj, f)}
712
+ if File.exist?(name)
713
+ bn = "#{name}~"
714
+ File.unlink(bn) if File.exist?(bn)
715
+ File.rename(name, bn)
716
+ end
717
+ File.rename(tn, name)
718
+ puts "Saved: #{name}"
719
+ end
720
+
721
+ def method_missing(sym, *args, &block)
722
+ if reg = @registrations[sym]
723
+ @bangs.merge!(reg.bangs)
724
+ final_args = reg.args + args
725
+ reg.block.call(*final_args)
726
+ elsif MySQLResults.respond_to?(sym)
727
+ MySQLResults.send(sym, *args)
728
+ elsif MySQLResults.conn.respond_to?(sym)
729
+ MySQLResults.conn.send(sym, *args)
730
+ else
731
+ super.method_missing(sym, *args, &block)
732
+ end
733
+ end
734
+
735
+ end # class EvalContext
736
+
737
+ end # module RSQL