oraora 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ea908b533f803251dad4a3c654c32da376f4026b
4
+ data.tar.gz: 708a8b06e602feaf36619098b5f585d6bed3505a
5
+ SHA512:
6
+ metadata.gz: d9d0bad778bd145d9244ce38bf32bfba433d6c259bca2669e8666657e2818cd953ca5f5ffae409c446b57da319b6cc8892739a4c2c2d3daefbd5fdc0c8dc64fc
7
+ data.tar.gz: 59cc924133dcafbbf68d78b70606a6fb3a3d63122cec05fed7c6a4c29794ab700f61ac1437a2240a02c92681e8d52db658e0e020a7f1b56751e533a14899b2cf
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ *.gem
2
+ Gemfile.lock
3
+ .ruby-version
4
+ .ruby-gemset
5
+ .rvmrc
6
+
7
+ # RubyMine project
8
+ /.idea
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014 Kombajn Zbożowy
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # Oraora
2
+
3
+ Oraora is a command-line utility for interacting with Oracle database.
4
+
5
+ ## Features
6
+
7
+ * Command line history
8
+ * Input TAB-completion
9
+ * Password file support
10
+ * Metadata querying
11
+ * Context-aware SQL
12
+ * Readable colored output
13
+ * su/sudo as SYS
14
+
15
+ ## Installation
16
+
17
+ Oraora comes bundled as a Ruby gem. To install just run:
18
+ ```
19
+ $ gem install oraora
20
+ ```
21
+
22
+ If you don't have Ruby, check one-click installer for Windows or rvm for Linux.
23
+
24
+ ## Usage
25
+
26
+ Start oraora passing connection string as argument, just like you would connect to SQL*Plus:
27
+ ```
28
+ $ oraora user/password@DB
29
+ ```
30
+
31
+ OS authentication is supported (pass `/`).
32
+
33
+ Roles are supported (append `as SYSDBA` / `as SYSOPER`).
34
+
35
+ ### Passfile
36
+
37
+ Oraora attempts to read file `.orapass` in your home directory if it exists. It should contain connection strings in
38
+ `user/password@DB` format. Then it's enough to provide `user@DB` when connecting and oraora will automatically fill
39
+ the password.
40
+ ```
41
+ $ oraora --log-level=debug user@DB
42
+ [DEBUG] Connecting: user/password@DB
43
+ ```
44
+
45
+ ### Context
46
+
47
+ Use `c` command to navigate through database like directory structure.
48
+ ```
49
+ ~ $ c some_table # Starting at home schema, navigate into table some_table
50
+ ~.SOME_TABLE $ c col1 # Navigate into column col1
51
+ ~.SOME_TABLE.COL1 $ c - # Navigate up
52
+ ~.SOME_TABLE $ c -- # Navigate two levels up - to database level. You could also use 'cd .'
53
+ . $ c HR.EMPLOYEES # Navigate to schema HR, table EMPLOYEES
54
+ HR.EMPLOYEES $ c -/DEPARTMENTS # Navigate up, then to table DEPARTMENTS
55
+ HR.DEPARTMENTS $ c . # Navigate to root (database level)
56
+ / $ c # Navigate to your home schema
57
+ ```
58
+
59
+ Note: `c` is aliased as `cd` for unix addicts.
60
+
61
+ ### Listing and describing objects
62
+
63
+ Use `d` command to describe object currently in context.
64
+ ```
65
+ HR.EMPLOYEES $ d
66
+ Schema: HR
67
+ Name: EMPLOYEES
68
+ Partitioned: NO
69
+ ```
70
+
71
+ Use `l` command to list for object currently in context. In database schemas are listed. In schema - objects. In table,
72
+ view or materialized view - columns, etc.
73
+ ```
74
+ HR.EMPLOYEES $ l
75
+ EMPLOYEE_ID PHONE_NUMBER COMMISSION_PCT
76
+ FIRST_NAME HIRE_DATE MANAGER_ID
77
+ LAST_NAME JOB_ID DEPARTMENT_ID
78
+ EMAIL SALARY
79
+ ```
80
+
81
+ You can also provide context path as additional parameter for list and describe:
82
+ ```
83
+ HR.EMPLOYEES $ l .SYS.DUAL
84
+ DUMMY
85
+ ```
86
+
87
+ For list provide filter as last segment of the path:
88
+ ```
89
+ ~ $ l .HR.EMPLOYEES.*NAME
90
+ FIRST_NAME LAST_NAME
91
+ ```
92
+
93
+ Note: `l` is aliased as `ls`. `d` is aliased as `desc` and `describe`.
94
+
95
+ ### SQL
96
+
97
+ Any input starting with SQL keyword like `SELECT`, `INSERT`, `CREATE`, ... is recognized as SQL and passed to database
98
+ for execution.
99
+
100
+ ### Context-aware SQL
101
+
102
+ When in specific context, you can omit some obvious parts of SQL statements. For example, in context of a table following
103
+ statements will work:
104
+ ```
105
+ ~.SOME_TABLE $ SELECT; # implicit '... FROM SOME_TABLE'
106
+ ~.SOME_TABLE $ WHERE col = 1; # implicit 'SELECT * FROM SOME_TABLE ...'
107
+ ~.SOME_TABLE $ SET col = 2; # implicit 'UPDATE SOME_TABLE ...'
108
+ ~.SOME_TABLE $ ADD x INTEGER; # implicit 'ALTER TABLE SOME_TABLE ...'
109
+ ```
110
+
111
+ Some other examples:
112
+ ```
113
+ ~ $ IDENTIFIED BY oraora; # implicit 'ALTER USER xxx ...'
114
+ ~.SOME_TABLE.COL $ WHERE x = 1; # implicit 'SELECT COL FROM SOME_TABLE ...'
115
+ ~.SOME_TABLE.COL $ RENAME TO kol; # implicit 'ALTER TABLE SOME_TABLE RENAME COLUMN col TO kol'
116
+ ```
117
+
118
+ ### Su / sudo
119
+
120
+ `su` and `sudo` allow to switch to SYS session temporarily or execute a single statement as SYS, similarly to their
121
+ unix counterparts. If you don't have SYS password for current connection in orafile, you will be prompted for it.
122
+ ```
123
+ $ oraora foo@DB
124
+ ~ $ SELECT * FROM boo.test;
125
+ ERROR: Insufficient privileges
126
+ ~ $ sudo GRANT SELECT ON boo.test TO foo;
127
+ Grant succeeded.
128
+ ~ $ SELECT * FROM boo.test;
129
+ text
130
+ ------------
131
+ Hello world!
132
+ ```
133
+
134
+ ## Limitations
135
+
136
+ This is an early alpha version. Things may crash and bugs are hiding out there.
137
+
138
+ PL/SQL blocks are not supported (yet).
139
+
140
+ Oraora does not implement SQL*Plus-specific commands. `rem`, `set`, `show`, `desc`, `exec`, etc. are not
141
+ supported.
data/bin/oraora ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'oraora'
4
+ require 'optparse'
5
+ require 'logger'
6
+
7
+ begin
8
+ # Options
9
+ options = { log_level: Logger::INFO }
10
+
11
+ OptionParser.new do |opts|
12
+ opts.banner = "Usage: oraora [options] connection"
13
+
14
+ opts.on("-l LEVEL", "--log-level=LEVEL", [:debug, :info, :warn, :error], "Set message verbosity (debug, info, warn, error)") do |l|
15
+ options[:log_level] = Logger.const_get(l.upcase)
16
+ end
17
+
18
+ opts.on("-h", "--help", "Show this message") { |h| puts opts; exit }
19
+ end.parse!
20
+
21
+ # Logger
22
+ logger = Oraora::Logger.new(STDOUT, options[:log_level])
23
+
24
+ # Read passfile
25
+ if File.file?(passfile = ENV['HOME'] + '/.orapass')
26
+ ok = Oraora::Credentials.read_passfile(passfile)
27
+ logger.warn "There were invalid entries in orapass file, which were ignored" if !ok
28
+ end
29
+
30
+ # Command line arguments
31
+ credentials = Oraora::Credentials.parse(ARGV[0])
32
+ credentials.fill_password_from_vault
33
+ role = ARGV[2] if ARGV[1] == 'as'
34
+
35
+ # Run application
36
+ app = Oraora::App.new(credentials, role, logger)
37
+ app.run
38
+
39
+ rescue OptionParser::ParseError => e
40
+ puts "Options error: " + e.message
41
+
42
+ rescue Oraora::Credentials::ParseError => e
43
+ puts "Invalid connection string: " + e.message
44
+
45
+ rescue OCIError => e
46
+ logger.error "#{e.message}"
47
+
48
+ end
data/lib/oraora.rb ADDED
@@ -0,0 +1,9 @@
1
+ require 'readline'
2
+ require 'highline'
3
+ require 'highline/import'
4
+ require 'indentation'
5
+ require 'bigdecimal'
6
+ require 'colorize'
7
+ require 'oci8'
8
+
9
+ Dir[File.dirname(__FILE__) + '/oraora/**/*.rb'].each { |file| require file }
data/lib/oraora/app.rb ADDED
@@ -0,0 +1,298 @@
1
+ module Oraora
2
+ class App
3
+ class InvalidCommand < StandardError; end
4
+
5
+ SQL_INITIAL_KEYWORDS = %w(
6
+ SELECT COUNT FROM PARTITION WHERE CONNECT GROUP MODEL UNION INTERSECT MINUS ORDER
7
+ INSERT UPDATE SET DELETE MERGE
8
+ TRUNCATE ADD DROP CREATE RENAME ALTER PURGE GRANT REVOKE
9
+ COMPILE ANALYZE COMMIT ROLLBACK
10
+ IDENTIFIED PROFILE ACCOUNT QUOTA DEFAULT TEMPORARY
11
+ )
12
+ SQL_KEYWORDS = SQL_INITIAL_KEYWORDS + %w(
13
+ TABLE VIEW MATERIALIZED COLUMN PROCEDURE FUNCTION PACKAGE TYPE BODY
14
+ USER SESSION SCHEMA SYSTEM DATABASE
15
+ REPLACE AND OR
16
+ )
17
+ ORAORA_KEYWORDS = %w(c cd l ls d desc describe x exit su sudo - -- --- . ! /)
18
+
19
+ attr_reader :meta, :context
20
+
21
+ def initialize(credentials, role, logger, context = nil)
22
+ @credentials = credentials
23
+ @user, @database, @role = (credentials.user ? credentials.user.upcase : nil), credentials.database, (role ? role.upcase.to_sym : nil)
24
+ @logger = logger
25
+ @context = context
26
+ end
27
+
28
+ # Run the application with given credentials
29
+ def run(command = nil)
30
+ last_interrupt = Time.now - 2
31
+
32
+ # Connect to Oracle
33
+ @logger.debug "Connecting: #{@credentials}" + (@role ? " as #{@role}" : '')
34
+ logon
35
+ @user ||= @oci.username
36
+ @context ||= Context.new(@user, schema: @user)
37
+
38
+ # Readline tab completion
39
+ Readline.completion_append_character = ''
40
+ Readline.completion_proc = Completion.new(self).comp_proc
41
+
42
+ if command
43
+ process(command)
44
+ else
45
+ # Main loop
46
+ buffer = ''
47
+ prompt = @context.prompt + ' ' + (@role== :SYSDBA ? '#' : '$') + ' '
48
+
49
+ while !@terminate do
50
+ begin
51
+ line = Readline.readline(prompt.green.bold)
52
+ break if !line
53
+
54
+ line.strip!
55
+ Readline::HISTORY << line if line != '' # Manually add to history to avoid empty lines
56
+ buffer += (buffer == '' ? '' : "\n") + line
57
+
58
+ # Process buffer on one of these conditions:
59
+ # * This is first line of the buffer and is empty
60
+ # * This is first line of the buffer and is a Oraora command
61
+ # * Entire buffer is a comment
62
+ # * Line is '/' or ends with ';'
63
+ if (buffer == line && (line =~ /^(#{ORAORA_KEYWORDS.collect { |k| Regexp.escape(k) }.join('|')})($|\s+)/i || line =~ /^\s*$/)) || line == '/' || line =~ /;$/ || buffer =~ /\A\s*--/ || buffer =~ /\A\s*\/\*.*\*\/\s*\Z/m
64
+ process(buffer)
65
+ buffer = ''
66
+ end
67
+
68
+ if buffer == ''
69
+ prompt = @context.prompt + ' ' + (@role == :SYSDBA ? '#' : '$') + ' '
70
+ else
71
+ prompt = @context.prompt.gsub(/./, ' ') + ' % '
72
+ end
73
+
74
+ rescue Interrupt
75
+ if Time.now - last_interrupt < 2
76
+ @logger.warn "Exit on CTRL+C, "
77
+ terminate
78
+ else
79
+ @logger.warn "CTRL+C, hit again within 2 seconds to quit"
80
+ buffer = ''
81
+ prompt = @context.prompt + ' ' + (@role == :SYSDBA ? '#' : '$') + ' '
82
+ last_interrupt = Time.now
83
+ end
84
+ end
85
+ end
86
+
87
+ end
88
+
89
+ if !@terminate
90
+ @logger.debug "Exiting on end of input"
91
+ terminate
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # Logon to the server
98
+ def logon
99
+ begin
100
+ @oci = OCI.new(@user, @credentials.password, @database, @role)
101
+ @meta = Meta.new(@oci)
102
+ rescue Interrupt
103
+ @logger.warn "CTRL+C, aborting logon"
104
+ exit!
105
+ end
106
+ end
107
+
108
+ # Log off the server and terminate
109
+ def terminate
110
+ if @oci
111
+ @logger.debug "Logging off"
112
+ @oci.logoff
113
+ end
114
+ @terminate = true
115
+ rescue Interrupt
116
+ @logger.warn "Interrupt on logoff, force exit"
117
+ exit!
118
+ end
119
+
120
+ # Parse command options from arguments
121
+ # Returns options hash and the remaining argument untouched
122
+ def options_for(args)
123
+ options = {}
124
+ while (args =~ /^-[[:alnum:]]/) do
125
+ opts, args = args.split(/\s+/, 2)
126
+ @logger.debug "Raw options: #{opts}"
127
+ opts.gsub(/^-/, '').split('').each do |o|
128
+ options[o.downcase] = true
129
+ end
130
+ end
131
+ @logger.debug "Options: #{options}"
132
+ [options, args]
133
+ end
134
+
135
+ # Process the command buffer
136
+ def process(text)
137
+ @logger.debug "Processing buffer: #{text}"
138
+
139
+ # shortcuts for '.' and '-'
140
+ text = 'c ' + text if text =~ /^\s*(\.|\-+)\s*$/
141
+
142
+ # Determine first non-comment word of a command
143
+ text =~ /\A(?:\/\*.*?\*\/\s*|--.*?(?:\n|\Z))*\s*([^[:space:]\*\(\/;]+)?\s*(.*?)(?:[[:space:];]*)\Z/mi
144
+ # <------------- 1 ---------------> <--------- 2 -------->- < 3 >
145
+ # 1) strip '/* ... */' or '--' style comments from the beginning
146
+ # 2) first word (any characters not being a space, '(', ';' or '*'), captured into $1
147
+ # 3) remaining portion of a command, captured into $2
148
+
149
+ case first_word = $1 && $1.upcase
150
+ # Nothing, gibberish or just comments
151
+ when nil
152
+ if $2 && $2 != ''
153
+ raise InvalidCommand, "Invalid command: #{$2}"
154
+ end
155
+
156
+ when 'C', 'CD'
157
+ @logger.debug "Switch context"
158
+ old_schema = @context.schema || @context.user
159
+ if $2 && $2 != ''
160
+ @context = context_for(@context, $2[/^\S+/])
161
+ else
162
+ @context.set(schema: @user)
163
+ end
164
+ @logger.debug "New context is #{@context.send(:key_hash)}"
165
+ if old_schema != (@context.schema || @context.user)
166
+ @logger.debug "Implicit ALTER SESSION SET CURRENT_SCHEMA = " + (@context.schema || @context.user)
167
+ @oci.exec("ALTER SESSION SET CURRENT_SCHEMA = " + (@context.schema || @context.user))
168
+ end
169
+
170
+ when 'L', 'LS'
171
+ @logger.debug "List"
172
+ options, path = options_for($2)
173
+ filter = path ? path[/[^\.\/]*(\*|\?)[^\.\/]*$/] : nil
174
+ path.chomp!(filter) if filter
175
+ path = path.chomp('.').chomp('/')[/^\S+/] unless path.nil? || path == '.' || path == '/'
176
+ filter.upcase! if filter
177
+ @logger.debug "Path: #{path}, Filter: #{filter}"
178
+ work_context = context_for(@context, path)
179
+ @logger.debug "List for #{work_context.level || 'database'}"
180
+ Terminal.puts_grid(@meta.find(work_context).list(options, filter))
181
+
182
+ when 'D', 'DESC', 'DESCRIBE'
183
+ @logger.debug "Describe"
184
+ options, args = options_for($2)
185
+ path = args.split(/\s+/).first rescue nil
186
+ work_context = context_for(@context, path)
187
+ @logger.debug "Describe for #{work_context.level || 'database'}"
188
+ puts(@meta.find(work_context).describe(options))
189
+
190
+ # TODO: For refactoring
191
+ if work_context.level == :column && options['p']
192
+ prof = @oci.exec <<-SQL
193
+ SELECT value, cnt, rank
194
+ FROM (SELECT value, cnt, row_number() over (order by cnt desc) AS rank
195
+ FROM (SELECT #{work_context.column} AS value, count(*) AS cnt
196
+ FROM #{work_context.object}
197
+ GROUP BY #{work_context.column}
198
+ )
199
+ )
200
+ WHERE rank <= 20 OR value IS NULL
201
+ ORDER BY rank
202
+ SQL
203
+ puts ""
204
+ Terminal.puts_cursor(prof)
205
+ end
206
+
207
+ # Exit
208
+ when 'X', 'EXIT'
209
+ @logger.debug "Exiting on exit command"
210
+ terminate
211
+
212
+ # SQL
213
+ when *SQL_INITIAL_KEYWORDS
214
+ raw_sql = text.gsub(/[;\/]\Z/, '')
215
+ @logger.debug "SQL: #{raw_sql}"
216
+ context_aware_sql = Awareness.enrich(raw_sql, @context)
217
+ @logger.debug "SQL (context-aware): #{context_aware_sql}" if context_aware_sql != raw_sql
218
+ res = @oci.exec(context_aware_sql)
219
+
220
+ if res.is_a? OCI8::Cursor
221
+ Terminal.puts_cursor(res)
222
+ @logger.info "#{res.row_count} row(s) selected"
223
+ else
224
+ @logger.info "#{res} row(s) affected"
225
+ end
226
+
227
+ when 'SU'
228
+ @logger.debug "Command type: su"
229
+ su
230
+
231
+ when 'SUDO'
232
+ @logger.debug "Command type: sudo (#{$2})"
233
+ raise InvalidCommand, "Command required for sudo" if $2.strip == ''
234
+ su($2)
235
+
236
+ when '!'
237
+ @logger.debug "Command type: metadata refresh"
238
+ @meta.purge_cache
239
+
240
+ # Unknown
241
+ else
242
+ raise InvalidCommand, "Invalid command: #{$1}"
243
+ end
244
+
245
+ rescue InvalidCommand, Meta::NotApplicable => e
246
+ @logger.error e.message
247
+ rescue Context::InvalidKey, Meta::NotExists => e
248
+ @logger.error "Invalid path"
249
+ rescue OCIError => e
250
+ @logger.error e.parse_error_offset ? "#{e.message} at #{e.parse_error_offset}" : e.message
251
+ rescue Interrupt
252
+ @logger.warn "Interrupted by user"
253
+ rescue StandardError => e
254
+ @logger.error "Internal error"
255
+ @logger.debug e.backtrace
256
+ end
257
+
258
+ # Returns new context relative to current one, traversing given path
259
+ def context_for(context, path)
260
+ return context.dup if !path || path == ''
261
+ new_context = context.dup
262
+ nodes = path.split(/[\.\/]/).collect(&:upcase) rescue []
263
+ return new_context.root if nodes.empty?
264
+ level = nodes[0] == '' ? nil : new_context.level
265
+
266
+ nodes.each_with_index do |node, i|
267
+ case
268
+ when i.zero? && node == '' then new_context.root
269
+ when i.zero? && node == '~' then new_context.set(schema: @user)
270
+ when node == '-' then new_context.up
271
+ when node == '--' then new_context.up.up
272
+ when node =~ /^-+$/ then new_context.up.up.up
273
+ else
274
+ raise Context::InvalidKey if node !~ /^[a-zA-Z0-9_\$]{,30}$/
275
+ case new_context.level
276
+ when nil
277
+ @meta.find(new_context.traverse(schema: node))
278
+ when :schema
279
+ o = @meta.find_object(new_context.schema, node)
280
+ new_context.traverse(object: node, object_type: o.type)
281
+ when :object
282
+ @meta.find(new_context.traverse(column: node))
283
+ #TODO: Subprograms
284
+ else raise Context::InvalidKey
285
+ end
286
+ end
287
+ end
288
+ new_context
289
+ end
290
+
291
+ # Gets SYS password either from orapass file or user input, then spawns subshell
292
+ def su(command = nil)
293
+ su_credentials = Credentials.new('sys', nil, @database).fill_password_from_vault
294
+ su_credentials.password = ask("SYS password: ") { |q| q.echo = '' } if !su_credentials.password
295
+ App.new(su_credentials, :SYSDBA, @logger, @context.su('SYS')).run(command)
296
+ end
297
+ end
298
+ end