rsql 0.1.3

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 ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (C) 2011 by brad+rsql@gigglewax.com
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.txt ADDED
@@ -0,0 +1,119 @@
1
+ = rsql
2
+
3
+ https://github.com/bradrf/rsql
4
+
5
+ == DESCRIPTION
6
+
7
+ This is an application to make working with a SQL command line more
8
+ convenient by allowing interaction with Ruby in addition to embedding
9
+ the common operation of using a SSH connection to an intermediary host
10
+ for access to the SQL server.
11
+
12
+ == SYNOPSIS
13
+
14
+ See rsql -help for usage.
15
+
16
+ Aside from the standard MySQL command syntax, the following
17
+ functionality allows for a little more expressive processing.
18
+
19
+ Multiple commands can be issued in one set by separation with
20
+ semicolons.
21
+
22
+ Generating SQL
23
+ --------------
24
+
25
+ Ruby code may be called to generate the SQL that is to be executed.
26
+ This is done by starting any command string with a period. If the
27
+ final result of evaluating the command string is another string, it is
28
+ executed as SQL. Any semicolons meant to be processed by Ruby must be
29
+ escaped. Example:
30
+
31
+ rsql> . puts 'hello world!' \\; 'select * from Account'
32
+
33
+ Utilizing Canned Methods
34
+ ------------------------
35
+
36
+ Commands can be stored in the .rsqlrc file in your HOME directory to
37
+ expose methods that may be invoked to generate SQL with variable
38
+ interpolation. Use of the 'register' helper is recommended for this
39
+ approach. These can then be called in the same way as above. Example:
40
+
41
+ In the .rsqlrc file...
42
+
43
+ register :users_by_email, :email %q{
44
+ SELECT * FROM Users WHERE email = '\#\{email\}'
45
+ }
46
+
47
+ ...then from the prompt:
48
+
49
+ rsql> . users_by_email 'brad@gigglewax.com'
50
+
51
+ If a block is provided to the registration, it will be called as a
52
+ method. Example:
53
+
54
+ In the .sqlrc file...
55
+
56
+ register :dumby, :hello do |*args|
57
+ p args
58
+ end
59
+
60
+ rsql> . dumby :world
61
+
62
+ All registered methods can be listed using the built-in 'list'
63
+ command.
64
+
65
+ Changes to a sourced file can be reloaded using the built-in 'reload'
66
+ command.
67
+
68
+ Processing Column Data
69
+ ----------------------
70
+
71
+ Ruby can be called to process any data on a per-column basis before a
72
+ displayer is used to render the output. In this way, one can write
73
+ Ruby to act like MySQL functions on all the data for a given column,
74
+ converting it into a more readable value. A bang indicator (exlamation
75
+ point: !) is used to demarcate a mapping of column names to Ruby
76
+ methods that should be invoked to processes content. Example:
77
+
78
+ rsql> select IpAddress from Devices ; ! IpAddress => bin_to_str
79
+
80
+ This will call 'bin_to_str' for each 'IpAddress' returned from the
81
+ query. Mulitple mappings are separated by a comma. These mappings can
82
+ also be utilized in a canned method. Example:
83
+
84
+ register :all_ips, 'select IpAddress from Devices', 'IpAddress' => :bin_to_str
85
+
86
+ Redirection
87
+ -----------
88
+
89
+ Output from one or more queries may be post-processed dynamically. If
90
+ any set of commands is follwed by a greater-than symbol, all results
91
+ will be stored in a global $results array (with field information
92
+ stored in $fields) and the final Ruby code will be evaluated with
93
+ access to them. Any result of the evaluation that is a string is then
94
+ executed as SQL. Example:
95
+
96
+ rsql> select * from Account; select * from Users; > $results.each {|r| p r}
97
+
98
+ == LICENSE
99
+
100
+ Copyright (C) 2011 by Brad Robel-Forrest <brad+rsql@gigglewax.com>
101
+
102
+ Permission is hereby granted, free of charge, to any person obtaining
103
+ a copy of this software and associated documentation files (the
104
+ "Software"), to deal in the Software without restriction, including
105
+ without limitation the rights to use, copy, modify, merge, publish,
106
+ distribute, sublicense, and/or sell copies of the Software, and to
107
+ permit persons to whom the Software is furnished to do so, subject to
108
+ the following conditions:
109
+
110
+ The above copyright notice and this permission notice shall be
111
+ included in all copies or substantial portions of the Software.
112
+
113
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
114
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
115
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
116
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
117
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
118
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
119
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/TODO ADDED
@@ -0,0 +1,30 @@
1
+ * Support separation of semicolon content from registered sql.
2
+
3
+ * Fix multiline dump to be columnar sensitive (i.e. all but the first
4
+ line will need a prefix of previous column length whitespace.
5
+
6
+ * Add option to allow registration to include an automatic displayer.
7
+
8
+ * Add ability to save passwords in local OS password storage.
9
+
10
+ * Add ability to highlight lines (e.g. to indicate warning lines about
11
+ some data like LastHeartbeatTime not recent).
12
+
13
+ * Add way to prevent this and just allow the raw string to be printed:
14
+ elsif HEX_RANGE.include?(field.type) && val =~ /[^[:print:]\s]/
15
+ val = @@eval_context.to_hexstr(val)
16
+ end
17
+
18
+ * Fix overlap of functionality between getting input in rsql and
19
+ parsing input in commands.rb (e.g. they both duplicate the concepts
20
+ of looking for exit/quit as well as what the end character(s) should
21
+ be)
22
+
23
+ * Wrap calling of mysql query in a thread to make it interruptable
24
+ (might need to re-establish a connection if it's stopped but
25
+ expected a reply...look at the source).
26
+
27
+ * Consider using mysql's ping to determine if we need to reconnect.
28
+
29
+ * Move last_command logic into mysql_results and remember last five
30
+ (or so).
data/bin/rsql ADDED
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Copyright (C) 2011 by Brad Robel-Forrest <brad+rsql@gigglewax.com>
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+
23
+ begin
24
+ # this isn't required unless that's how mysql and net/ssh have
25
+ # been installed
26
+ require 'rubygems'
27
+ rescue LoadError
28
+ end
29
+
30
+ require 'thread'
31
+ require 'timeout'
32
+ require 'readline'
33
+ require 'yaml'
34
+ require 'net/ssh'
35
+
36
+ # allow ourselves to be run from within a source tree
37
+ if File.symlink?(__FILE__)
38
+ fn = File.readlink(__FILE__)
39
+ else
40
+ fn = __FILE__
41
+ end
42
+ libdir = File.expand_path(File.join(File.dirname(fn),'..','lib'))
43
+ $: << libdir if File.directory?(libdir)
44
+
45
+ require 'rsql'
46
+ include RSQL
47
+
48
+ bn = File.basename($0, '.rb')
49
+
50
+ eval_context = EvalContext.new
51
+
52
+ if i = ARGV.index('-rc')
53
+ ARGV.delete_at(i)
54
+ rc_fn = ARGV.delete_at(i)
55
+ else
56
+ rc_fn = File.join(ENV['HOME'], ".#{bn}rc")
57
+ end
58
+
59
+ eval_context.load(rc_fn) if File.exists?(rc_fn)
60
+
61
+ def get_password(prompt)
62
+ iswin = nil != (RUBY_PLATFORM =~ /(win|w)32$/)
63
+ STDOUT.print(prompt)
64
+ STDOUT.flush
65
+ `stty -echo` unless iswin
66
+ password = STDIN.gets
67
+ password.chomp!
68
+ ensure
69
+ `stty echo` unless iswin
70
+ STDOUT.puts
71
+ return password
72
+ end
73
+
74
+ # safely separate login credentials while preserving "emtpy" values--
75
+ # anything of the form [<username>[:<password]@]<host>
76
+ #
77
+ def split_login(str)
78
+ login = []
79
+ if i = str.rindex(?@)
80
+ login << str[i+1..-1]
81
+ if 0 < i
82
+ str = str[0..i-1]
83
+ i = str.index(?:)
84
+ if 0 == i
85
+ login << '' << str[i+1..-1]
86
+ elsif i
87
+ login << str[0..i-1] << str[i+1..-1]
88
+ else
89
+ login << str
90
+ end
91
+ else
92
+ login << ''
93
+ end
94
+ else
95
+ login << str
96
+ end
97
+ end
98
+
99
+ if ARGV.delete('-help')
100
+ eval_context.help
101
+ exit
102
+ end
103
+
104
+ if ARGV.delete('-version')
105
+ puts "#{bn} v#{RSQL::VERSION}"
106
+ exit
107
+ end
108
+
109
+ if i = ARGV.index('-maxrows')
110
+ ARGV.delete_at(i)
111
+ MySQLResults.max_rows = ARGV.delete_at(i).to_i
112
+ end
113
+
114
+ if i = ARGV.index('-batch')
115
+ ARGV.delete_at(i)
116
+ MySQLResults.field_separator = ARGV.delete_at(i)
117
+ MySQLResults.field_separator = "\t" if MySQLResults.field_separator == '\t'
118
+ batch_output = true
119
+ end
120
+
121
+ if i = ARGV.index('-ssh')
122
+ ARGV.delete_at(i)
123
+ (ssh_host, ssh_user, ssh_password) = split_login(ARGV.delete_at(i))
124
+ end
125
+
126
+ if i = ARGV.index('-e')
127
+ ARGV.delete_at(i)
128
+ batch_input = ''
129
+ ARGV.delete_if do |arg|
130
+ arg_i = ARGV.index(arg)
131
+ if i <= arg_i
132
+ batch_input << ' ' << arg
133
+ end
134
+ end
135
+ batch_input.strip!
136
+ end
137
+
138
+ if ARGV.size < 1
139
+ prefix = ' ' << ' ' * bn.size
140
+ $stderr.puts <<USAGE
141
+
142
+ usage: #{bn} [-version] [-help]
143
+ #{prefix}[-rc <rcfile>] [-maxrows <max>] [-batch <field_separator>]
144
+ #{prefix}[-ssh [<ssh_user>[:<ssh_password>]@]<ssh_host>]
145
+ #{prefix}[<mysql_user>[:<mysql_password>]@]<mysql_host>
146
+ #{prefix}[<database>] [-e <remaining_args_as_input>]
147
+
148
+ If -ssh is used, a SSH tunnel is established before trying to
149
+ connect to the MySQL server.
150
+
151
+ Commands may either be passed in non-interactively via the -e option
152
+ or by piping into the process' standard input (stdin). If using -e,
153
+ it _must_ be the last option following all other arguments.
154
+
155
+ USAGE
156
+ exit 1
157
+ end
158
+
159
+ (mysql_host, mysql_user, mysql_password) = split_login(ARGV.shift)
160
+ mysql_password = get_password('mysql password? ') unless mysql_password
161
+ real_mysql_host = mysql_host
162
+
163
+ if ssh_host
164
+ # randomly pick a tcp port above 1024
165
+ mysql_port = rand(0xffff-1025) + 1025
166
+ else
167
+ mysql_port = 3306
168
+ end
169
+
170
+ db_name = ARGV.shift
171
+
172
+ unless $stdin.tty?
173
+ batch_input = $stdin.read.gsub(/\r?\n/,';')
174
+ end
175
+
176
+ # make sure we remove any duplicates when we add to the history to
177
+ # keep it clean
178
+ #
179
+ def add_to_history(item)
180
+ found = nil
181
+ Readline::HISTORY.each_with_index do |h,i|
182
+ if h == item
183
+ found = i
184
+ break
185
+ end
186
+ end
187
+ Readline::HISTORY.delete_at(found) if found
188
+ Readline::HISTORY.push(item)
189
+ end
190
+
191
+ # try closing but wrapped with a timer so we don't hang forever
192
+ #
193
+ def safe_timeout(conn, meth, name)
194
+ Timeout.timeout(5) { conn.send(meth) }
195
+ true
196
+ rescue Timeout::Error
197
+ $stderr.puts "Timed out waiting to close #{name} connection"
198
+ false
199
+ rescue Exception => ex
200
+ $stderr.puts(ex)
201
+ false
202
+ end
203
+
204
+ MySQLResults.max_rows ||= batch_output ? 5000 : 1000
205
+
206
+ ssh_enabled = false
207
+
208
+ if ssh_host
209
+
210
+ # might need to open an idle channel here so server doesn't close on
211
+ # us...or just loop reconnection here in the thread...
212
+
213
+ port_opened = false
214
+
215
+ puts "SSH #{ssh_user}#{ssh_user ? '@' : ''}#{ssh_host}..." unless batch_input
216
+ ssh = nil
217
+ ssh_thread = Thread.new do
218
+ opts = {:timeout => 15}
219
+ opts[:password] = ssh_password if ssh_password
220
+ ssh = Net::SSH.start(ssh_host, ssh_user, opts)
221
+ ssh_enabled = true
222
+ ssh.forward.local(mysql_port, mysql_host, 3306)
223
+ port_opened = true
224
+ ssh.loop(1) { ssh_enabled }
225
+ end
226
+
227
+ 15.times do
228
+ break if ssh_enabled && port_opened
229
+ sleep(1)
230
+ end
231
+
232
+ unless ssh_enabled
233
+ $stderr.puts "failed to connect to #{ssh_host} ssh host in 15 seconds"
234
+ exit 1
235
+ end
236
+
237
+ unless port_opened
238
+ $stderr.puts("failed to forward #{mysql_port}:#{mysql_host}:3306 via #{ssh_host} " \
239
+ "ssh host in 15 seconds")
240
+ exit 1
241
+ end
242
+
243
+ # now have our mysql connection use our port forward...
244
+ mysql_host = '127.0.0.1'
245
+ end
246
+
247
+ puts "MySQL #{mysql_user}@#{real_mysql_host}..." unless batch_input
248
+ begin
249
+ MySQLResults.conn = Mysql.new(mysql_host, mysql_user, mysql_password, db_name, mysql_port)
250
+ rescue Mysql::Error => ex
251
+ if ex.message.include?('Client does not support authentication')
252
+ $stderr.puts "failed to connect to #{mysql_host} mysql server: unknown credentials?"
253
+ else
254
+ $stderr.puts "failed to connect to #{mysql_host} mysql server: #{ex.message}"
255
+ end
256
+ exit 1
257
+ end
258
+
259
+ eval_context.call_init_registrations(MySQLResults.conn)
260
+
261
+ history_fn = File.join(ENV['HOME'], ".#{bn}_history")
262
+ if File.exists?(history_fn) && 0 < File.size(history_fn)
263
+ YAML.load_file(history_fn).each {|i| Readline::HISTORY.push(i)}
264
+ end
265
+
266
+ Readline.completion_proc = eval_context.method(:complete)
267
+
268
+ cmd_thread = Thread.new do
269
+ me = Thread.current
270
+ me[:shutdown] = false
271
+ until me[:shutdown] do
272
+ default_displayer = :display_by_column
273
+ if batch_input
274
+ default_displayer = :display_by_batch if batch_output
275
+ me[:shutdown] = true # only run once
276
+ input = batch_input
277
+ else
278
+ db_name = (MySQLResults.database_name || db_name)
279
+ puts '',"[#{mysql_user}@#{ssh_host||mysql_host}:#{db_name}]"
280
+ input = ''
281
+ prompt = bn + '> '
282
+ loop do
283
+ str = Readline.readline(prompt)
284
+ if str.nil?
285
+ input = nil if input.empty?
286
+ break
287
+ end
288
+ if str =~ /^\s*(exit|quit)\s*$/
289
+ me[:shutdown] = true
290
+ break
291
+ end
292
+ input << str
293
+ break if input =~ /([^\\];|\\G)\s*$/
294
+ # make sure we separate the lines with some whitespace if
295
+ # they didn't
296
+ input << ' ' unless str =~ /\s$/
297
+ prompt = ''
298
+ end
299
+ if input.nil? || me[:shutdown]
300
+ puts
301
+ break
302
+ end
303
+ input.strip!
304
+ next if input.empty?
305
+ end
306
+
307
+ add_to_history(input)
308
+ cmds = Commands.new(input, default_displayer)
309
+ if cmds.empty?
310
+ Readline::HISTORY.pop
311
+ next
312
+ end
313
+
314
+ me[:running] = true
315
+ break if cmds.run!(eval_context) == :done
316
+ me[:running] = false
317
+ end
318
+ end
319
+
320
+ Signal.trap('INT') do
321
+ if cmd_thread[:running] && MySQLResults.conn
322
+ $stderr.puts 'Interrupting MySQL query...'
323
+ safe_timeout(MySQLResults.conn, :close, 'MySQL')
324
+ MySQLResults.conn = nil
325
+ end
326
+ $stderr.puts 'Shutting down...'
327
+ cmd_thread[:shutdown] = true
328
+ sleep(0.3)
329
+ cmd_thread.kill
330
+ end
331
+
332
+ begin
333
+ cmd_thread.join
334
+ rescue Exception => ex
335
+ $stderr.puts ex.message, ex.backtrace
336
+ end
337
+
338
+ unless MySQLResults.conn.nil?
339
+ safe_timeout(MySQLResults.conn, :close, 'MySQL')
340
+ MySQLResults.conn = nil
341
+ end
342
+
343
+ sleep(0.3)
344
+ ssh_enabled = false
345
+
346
+ if Readline::HISTORY.any?
347
+ if 100 < Readline::HISTORY.size
348
+ (Readline::HISTORY.size - 100).times do |i|
349
+ Readline::HISTORY.delete_at(i)
350
+ end
351
+ end
352
+ File.open(history_fn, 'w') {|f| YAML.dump(Readline::HISTORY.to_a, f)}
353
+ end
354
+
355
+ if ssh_thread
356
+ safe_timeout(ssh_thread, :join, 'SSH')
357
+ end