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 +7 -0
- data/.gitignore +8 -0
- data/LICENSE +21 -0
- data/README.md +141 -0
- data/bin/oraora +48 -0
- data/lib/oraora.rb +9 -0
- data/lib/oraora/app.rb +298 -0
- data/lib/oraora/awareness.rb +78 -0
- data/lib/oraora/completion.rb +56 -0
- data/lib/oraora/context.rb +90 -0
- data/lib/oraora/credentials.rb +64 -0
- data/lib/oraora/logger.rb +19 -0
- data/lib/oraora/meta.rb +39 -0
- data/lib/oraora/meta/column.rb +45 -0
- data/lib/oraora/meta/database.rb +27 -0
- data/lib/oraora/meta/materialized_view.rb +48 -0
- data/lib/oraora/meta/object.rb +46 -0
- data/lib/oraora/meta/schema.rb +40 -0
- data/lib/oraora/meta/sequence.rb +33 -0
- data/lib/oraora/meta/subprogram.rb +36 -0
- data/lib/oraora/meta/table.rb +42 -0
- data/lib/oraora/meta/view.rb +41 -0
- data/lib/oraora/oci.rb +56 -0
- data/lib/oraora/terminal.rb +55 -0
- data/oraora.gemspec +22 -0
- data/spec/context_spec.rb +132 -0
- data/spec/credentials_spec.rb +83 -0
- metadata +154 -0
@@ -0,0 +1,78 @@
|
|
1
|
+
module Oraora
|
2
|
+
module Awareness
|
3
|
+
Entry = Struct.new(:key, :object_types, :map, :sql)
|
4
|
+
|
5
|
+
ENRICH_MAP = [
|
6
|
+
# Column
|
7
|
+
Entry.new(:column, nil, %w(SELECT ;), 'SELECT $column FROM $object'),
|
8
|
+
*%w(PARTITION WHERE CONNECT GROUP MODEL UNION INTERSECT MINUS ORDER).collect do |keyword|
|
9
|
+
Entry.new(:column, nil, [keyword], "SELECT $column FROM $object #{keyword}")
|
10
|
+
end,
|
11
|
+
Entry.new(:column, nil, %w(SET), 'UPDATE $object SET $column ='),
|
12
|
+
Entry.new(:column, 'TABLE', %w(DROP), 'ALTER TABLE $object DROP COLUMN $column'),
|
13
|
+
Entry.new(:column, 'TABLE', %w(RENAME), 'ALTER TABLE $table RENAME COLUMN $column TO'),
|
14
|
+
|
15
|
+
# Table
|
16
|
+
Entry.new(:object, 'TABLE', %w(DROP ;), 'DROP TABLE $object'),
|
17
|
+
Entry.new(:object, 'TABLE', %w(DROP CASCADE), 'DROP TABLE $object CASCADE'),
|
18
|
+
Entry.new(:object, 'TABLE', %w(DROP PURGE), 'DROP TABLE $object PURGE'),
|
19
|
+
Entry.new(:object, 'TABLE', %w(TRUNCATE), 'TRUNCATE TABLE $object'),
|
20
|
+
Entry.new(:object, 'TABLE', %w(DROP PARTITION), 'ALTER TABLE $object DROP PARTITION'),
|
21
|
+
*%w(ADD MODIFY RENAME PARALLEL NOPARALLEL ENABLE DISABLE CACHE NOCACHE READ REKEY PCTFREE PCTUSED INITRANS COMPRESS NOCOMPRESS SHRINK MERGE SPLIT).collect do |keyword|
|
22
|
+
Entry.new(:object, 'TABLE', [keyword], "ALTER TABLE $object #{keyword}")
|
23
|
+
end,
|
24
|
+
|
25
|
+
# View
|
26
|
+
Entry.new(:object, 'VIEW', %w(DROP), 'DROP VIEW $object'),
|
27
|
+
Entry.new(:object, 'VIEW', %w(RENAME), 'RENAME $object'),
|
28
|
+
Entry.new(:object, 'VIEW', %w(COMPILE), 'ALTER VIEW $object COMPILE'),
|
29
|
+
|
30
|
+
# Mview
|
31
|
+
Entry.new(:object, 'MATERIALIZED VIEW', %w(DROP), 'DROP MATERIALIZED VIEW $object'),
|
32
|
+
|
33
|
+
# Relation
|
34
|
+
Entry.new(:object, ['TABLE', 'VIEW', 'MATERIALIZED VIEW'], %w(SELECT ;), 'SELECT * FROM $object'),
|
35
|
+
Entry.new(:object, ['TABLE', 'VIEW', 'MATERIALIZED VIEW'], %w(COUNT ), 'SELECT count(*) FROM $object'),
|
36
|
+
*%w(PARTITION WHERE CONNECT GROUP MODEL UNION INTERSECT MINUS ORDER).collect do |keyword|
|
37
|
+
Entry.new(:object, ['TABLE', 'VIEW', 'MATERIALIZED VIEW'], [keyword], "SELECT * FROM $object #{keyword}")
|
38
|
+
end,
|
39
|
+
Entry.new(:object, ['TABLE', 'VIEW', 'MATERIALIZED VIEW'], %w(SET), 'UPDATE $object SET'),
|
40
|
+
Entry.new(:object, ['TABLE', 'VIEW', 'MATERIALIZED VIEW'], %w(DELETE ;), 'DELETE FROM $object'),
|
41
|
+
Entry.new(:object, ['TABLE', 'VIEW', 'MATERIALIZED VIEW'], %w(DELETE WHERE), 'DELETE FROM $object WHERE'),
|
42
|
+
Entry.new(:object, ['TABLE', 'VIEW', 'MATERIALIZED VIEW'], %w(INSERT VALUES), 'INSERT INTO $object VALUES'),
|
43
|
+
Entry.new(:object, ['TABLE', 'VIEW', 'MATERIALIZED VIEW'], %w(INSERT SELECT), 'INSERT INTO $object SELECT'),
|
44
|
+
|
45
|
+
# Schema
|
46
|
+
*%w(IDENTIFIED PROFILE ACCOUNT QUOTA DEFAULT TEMPORARY).collect do |keyword|
|
47
|
+
Entry.new(:schema, nil, [keyword], "ALTER USER $schema #{keyword}")
|
48
|
+
end
|
49
|
+
]
|
50
|
+
|
51
|
+
def self.enrich(sql, context)
|
52
|
+
tokens = []
|
53
|
+
map = []
|
54
|
+
(sql + ';').scan(/(?:\w+|\/\*.*?\*\/|--.*?\n|;|\s+)/mi) do |token|
|
55
|
+
tokens << token
|
56
|
+
if token =~ /^(\/\*.*?\*\/|--.*?\n|\s+)$/mi
|
57
|
+
next
|
58
|
+
end
|
59
|
+
map << token.upcase
|
60
|
+
#puts "[AWARENESS] #{token}, map: #{map}"
|
61
|
+
match = ENRICH_MAP.detect do |entry|
|
62
|
+
context.send(entry.key) && (!entry.object_types || [*entry.object_types].include?(context.object_type)) && map == entry.map
|
63
|
+
end
|
64
|
+
|
65
|
+
if match
|
66
|
+
#puts "[AWARENESS] Map match: #{match.sql}"
|
67
|
+
sql =~ /^#{tokens.join.chomp(';')}(.*)$/mi
|
68
|
+
last_part = $1
|
69
|
+
first_part = eval '"' + match.sql.gsub(/\$(\w+)+/, '#{context.send(:\1)}') + '"'
|
70
|
+
return first_part + last_part
|
71
|
+
end
|
72
|
+
|
73
|
+
break if tokens.length >= 2
|
74
|
+
end
|
75
|
+
sql
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Oraora
|
2
|
+
class Completion
|
3
|
+
TEMPLATES = {
|
4
|
+
's' => 'SELECT ',
|
5
|
+
's*' => 'SELECT * FROM ',
|
6
|
+
'c*' => 'SELECT COUNT(*) FROM ',
|
7
|
+
'i' => 'INSERT ',
|
8
|
+
'u' => 'UPDATE ',
|
9
|
+
'd' => 'DELETE ',
|
10
|
+
'a' => 'ALTER ',
|
11
|
+
'c' => 'CREATE ',
|
12
|
+
'cr' => 'CREATE OR REPLACE '
|
13
|
+
}
|
14
|
+
|
15
|
+
def initialize(app)
|
16
|
+
@app = app
|
17
|
+
end
|
18
|
+
|
19
|
+
def comp_proc
|
20
|
+
Proc.new do |s|
|
21
|
+
# Complete with template alone if matched
|
22
|
+
if TEMPLATES[Readline.line_buffer.downcase]
|
23
|
+
TEMPLATES[Readline.line_buffer.downcase]
|
24
|
+
|
25
|
+
else
|
26
|
+
# Complete for SQL keywords
|
27
|
+
comp = App::SQL_KEYWORDS
|
28
|
+
|
29
|
+
# Complete for current context
|
30
|
+
if s !~ /[\.\/]/
|
31
|
+
comp += @app.meta.find(@app.context).list rescue []
|
32
|
+
context = @app.context.dup
|
33
|
+
comp += @app.meta.find(context.up).list while context.level
|
34
|
+
|
35
|
+
# Complete for input
|
36
|
+
else
|
37
|
+
context = @app.context.dup
|
38
|
+
path = s.split(/(?<=[\.\/])/, -1)
|
39
|
+
last = path.pop
|
40
|
+
loop do
|
41
|
+
comp_context = @app.context_for(context, path.join) rescue nil
|
42
|
+
if comp_context
|
43
|
+
comp += @app.meta.find(comp_context).list.collect { |n| path.join + n } rescue []
|
44
|
+
end
|
45
|
+
break if context.level == nil
|
46
|
+
context.up
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
comp.sort.uniq.grep(/^#{Regexp.escape(s)}/i)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
module Oraora
|
2
|
+
class Context
|
3
|
+
class InvalidKey < StandardError; end
|
4
|
+
|
5
|
+
HIERARCHY = {
|
6
|
+
nil => [:schema],
|
7
|
+
schema: [:object],
|
8
|
+
object: [:column, :subprogram],
|
9
|
+
column: [],
|
10
|
+
subprogram: [],
|
11
|
+
}
|
12
|
+
KEYS = HIERARCHY.keys.compact + [:object_type, :subprogram_type]
|
13
|
+
RELATION_OBJECT_TYPES = ['TABLE', 'VIEW', 'MATERIALIZED VIEW']
|
14
|
+
|
15
|
+
attr_reader :level, :user, *KEYS
|
16
|
+
|
17
|
+
def initialize(user = nil, hash = {})
|
18
|
+
@user = user
|
19
|
+
set(hash)
|
20
|
+
end
|
21
|
+
|
22
|
+
def su(user)
|
23
|
+
self.class.new(user, key_hash)
|
24
|
+
end
|
25
|
+
|
26
|
+
def dup
|
27
|
+
su(@user)
|
28
|
+
end
|
29
|
+
|
30
|
+
def set(hash = {})
|
31
|
+
KEYS.each { |key| instance_variable_set("@#{key}", nil) }
|
32
|
+
@level = nil
|
33
|
+
traverse(hash)
|
34
|
+
end
|
35
|
+
|
36
|
+
def traverse(hash)
|
37
|
+
while(!hash.empty?) do
|
38
|
+
key = HIERARCHY[@level].detect { |k| hash[k] } or raise InvalidKey
|
39
|
+
case key
|
40
|
+
when :column then raise InvalidKey unless RELATION_OBJECT_TYPES.include?(@object_type)
|
41
|
+
when :object then raise InvalidKey unless @object_type = hash.delete(:object_type)
|
42
|
+
when :subprogram then raise InvalidKey unless @object_type == :package && @subprogram_type = hash.delete(:subprogram_type)
|
43
|
+
end
|
44
|
+
@level = key
|
45
|
+
instance_variable_set("@#{key}", hash.delete(key))
|
46
|
+
end
|
47
|
+
self
|
48
|
+
end
|
49
|
+
|
50
|
+
def root
|
51
|
+
set
|
52
|
+
end
|
53
|
+
|
54
|
+
def up
|
55
|
+
case @level
|
56
|
+
when nil then return self
|
57
|
+
when :subprogram then @subprogram_type = nil
|
58
|
+
when :object then @object_type = nil
|
59
|
+
end
|
60
|
+
instance_variable_set("@#{level}", nil)
|
61
|
+
@level = HIERARCHY.invert.detect { |k, v| k.include? @level }.last
|
62
|
+
self
|
63
|
+
end
|
64
|
+
|
65
|
+
def prompt
|
66
|
+
if @schema
|
67
|
+
p = @user == @schema ? '~' : @schema
|
68
|
+
level_2 = @object
|
69
|
+
p += ".#{level_2}" if level_2
|
70
|
+
level_3 = @column || @subprogram
|
71
|
+
p += ".#{level_3}" if level_3
|
72
|
+
else
|
73
|
+
p = '/'
|
74
|
+
end
|
75
|
+
p
|
76
|
+
end
|
77
|
+
|
78
|
+
def key_hash
|
79
|
+
Hash[ KEYS.collect { |key| [key, instance_variable_get("@#{key}")] } ].delete_if { |k, v| v.nil? }
|
80
|
+
end
|
81
|
+
|
82
|
+
def hash
|
83
|
+
key_hash.hash
|
84
|
+
end
|
85
|
+
|
86
|
+
def eql?(other)
|
87
|
+
key_hash == other.key_hash
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Oraora
|
2
|
+
class Credentials
|
3
|
+
class ParseError < StandardError; end
|
4
|
+
|
5
|
+
attr_accessor :user, :password, :database
|
6
|
+
@@vault = []
|
7
|
+
|
8
|
+
def initialize(user = nil, password = nil, database = nil)
|
9
|
+
@user = user
|
10
|
+
@password = password
|
11
|
+
@database = database
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.read_passfile(filename)
|
15
|
+
@@vault = []
|
16
|
+
ok = true
|
17
|
+
File.open(filename, "r") do |infile|
|
18
|
+
while (line = infile.gets)
|
19
|
+
begin
|
20
|
+
@@vault << parse(line.chomp)
|
21
|
+
rescue ParseError
|
22
|
+
ok = false
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
ok
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.parse(str)
|
30
|
+
if str
|
31
|
+
match = str.match /^([^\/@]+)?\/?([^\/@]+)?@?([^\/@]+)?$/
|
32
|
+
raise ParseError, "invalid format (use login/pass@DB)" if !match
|
33
|
+
user, password, database = match[1..3]
|
34
|
+
raise ParseError, "user can only contain alphanumeric characters" if user && !user.match(/^\w+$/)
|
35
|
+
raise ParseError, "database name can only contain alphanumeric characters" if database && !database.match(/^\w+$/)
|
36
|
+
return new(user, password, database)
|
37
|
+
else
|
38
|
+
return new
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def fill_password_from_vault
|
43
|
+
entry = @@vault.detect { |e| match?(e) }
|
44
|
+
@password = entry.password if entry && !@password
|
45
|
+
self
|
46
|
+
end
|
47
|
+
|
48
|
+
def to_s
|
49
|
+
s = @user || ''
|
50
|
+
s += '/' + @password if @password
|
51
|
+
s = '/' if s == ''
|
52
|
+
s += '@' + @database if @database
|
53
|
+
s
|
54
|
+
end
|
55
|
+
|
56
|
+
def eql?(c)
|
57
|
+
user == c.user && password == c.password && database == c.database
|
58
|
+
end
|
59
|
+
|
60
|
+
def match?(c)
|
61
|
+
user == c.user && database == c.database
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'colorize'
|
3
|
+
|
4
|
+
module Oraora
|
5
|
+
class Logger < ::Logger
|
6
|
+
SEVERITY_COLORS = {
|
7
|
+
'WARN' => :yellow,
|
8
|
+
'ERROR' => :red,
|
9
|
+
'INFO' => :light_black,
|
10
|
+
'DEBUG' => :light_black
|
11
|
+
}
|
12
|
+
|
13
|
+
def initialize(name, log_level = ::Logger::WARN)
|
14
|
+
super
|
15
|
+
self.level = log_level
|
16
|
+
self.formatter = proc { |severity, datetime, progname, msg| "[#{severity}] #{msg}\n".send(SEVERITY_COLORS[severity]) }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
data/lib/oraora/meta.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
module Oraora
|
2
|
+
# Helper class wrapping OCI methods for querying metadata
|
3
|
+
class Meta
|
4
|
+
class NotExists < StandardError; end
|
5
|
+
class NotApplicable < StandardError; end
|
6
|
+
|
7
|
+
# Initializes with OCI
|
8
|
+
def initialize(oci)
|
9
|
+
@oci = oci
|
10
|
+
@cache = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
# Returns a node identified by context
|
14
|
+
def find(context)
|
15
|
+
node = case context.level
|
16
|
+
when nil
|
17
|
+
@cache[context] || Meta::Database.from_oci(@oci)
|
18
|
+
when :schema
|
19
|
+
@cache[context] || Meta::Schema.from_oci(@oci, context.schema)
|
20
|
+
when :object
|
21
|
+
@cache[context] || Meta::Object.from_oci(@oci, context.schema, context.object, context.object_type)
|
22
|
+
when :column
|
23
|
+
find(context.dup.up).columns(context.column)
|
24
|
+
end
|
25
|
+
@cache[context] = node if node && context.level != :column
|
26
|
+
node
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns an object node identified by name
|
30
|
+
def find_object(schema, name)
|
31
|
+
Meta::Object.from_oci(@oci, schema, name)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Removes all cached metadata
|
35
|
+
def purge_cache
|
36
|
+
@cache = {}
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Oraora
|
2
|
+
class Meta
|
3
|
+
class Column
|
4
|
+
CHAR_USED_MAP = { 'B' => 'BYTE', 'C' => 'CHAR' }
|
5
|
+
attr_reader :name, :id
|
6
|
+
|
7
|
+
def initialize(schema, relation, name, attributes = {})
|
8
|
+
@schema = schema
|
9
|
+
@relation = relation
|
10
|
+
@name = name
|
11
|
+
attributes.each { |k, v| instance_variable_set("@#{k}".to_sym, v) }
|
12
|
+
end
|
13
|
+
|
14
|
+
def describe(options = {})
|
15
|
+
<<-HERE.reset_indentation
|
16
|
+
Column #{@schema}.#{@relation}.#{@name}
|
17
|
+
Id: #{@id}
|
18
|
+
Type: #{display_type}
|
19
|
+
HERE
|
20
|
+
end
|
21
|
+
|
22
|
+
def list(options = {}, filter = nil)
|
23
|
+
raise NotApplicable, "Nothing to list for column"
|
24
|
+
end
|
25
|
+
|
26
|
+
def display_type
|
27
|
+
case @type
|
28
|
+
when 'NUMBER'
|
29
|
+
case
|
30
|
+
when !@precision && !@scale then "NUMBER"
|
31
|
+
when !@precision && @scale == 0 then "INTEGER"
|
32
|
+
when @scale == 0 then "NUMBER(#{@precision})"
|
33
|
+
else "NUMBER(#{@precision},#{@scale})"
|
34
|
+
end
|
35
|
+
when 'CHAR', 'NCHAR'
|
36
|
+
@char_length == 1 ? 'CHAR' : "CHAR(#{@char_length} #{@char_used})"
|
37
|
+
when 'VARCHAR', 'VARCHAR2', 'NVARCHAR2'
|
38
|
+
"#{@type}(#{@char_length} #{CHAR_USED_MAP[@char_used]})"
|
39
|
+
else
|
40
|
+
@type
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Oraora
|
2
|
+
class Meta
|
3
|
+
class Database
|
4
|
+
def load_from_oci(oci)
|
5
|
+
@name, @created = oci.select_one("SELECT name, created FROM v$database")
|
6
|
+
@schemas = oci.pluck_one("SELECT username FROM all_users ORDER BY username")
|
7
|
+
self
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.from_oci(oci)
|
11
|
+
new.load_from_oci(oci)
|
12
|
+
end
|
13
|
+
|
14
|
+
def describe(options = {})
|
15
|
+
<<-HERE.reset_indentation
|
16
|
+
Database #{@name}
|
17
|
+
Created: #{@created}
|
18
|
+
HERE
|
19
|
+
end
|
20
|
+
|
21
|
+
def list(options = {}, filter = nil)
|
22
|
+
schemas = @schemas.select! { |o| o =~ /^#{Regexp.escape(filter).gsub('\*', '.*').gsub('\?', '.')}$/ } if filter
|
23
|
+
schemas || @schemas
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require_relative './object.rb'
|
2
|
+
|
3
|
+
module Oraora
|
4
|
+
class Meta
|
5
|
+
class MaterializedView < Object
|
6
|
+
def type
|
7
|
+
'MATERIALIZED VIEW'
|
8
|
+
end
|
9
|
+
|
10
|
+
def load_from_oci(oci)
|
11
|
+
@updatable, @refresh_mode, @fast_refreshable, @staleness =
|
12
|
+
oci.select_one("SELECT updatable, refresh_mode, fast_refreshable, staleness
|
13
|
+
FROM all_mviews
|
14
|
+
WHERE owner = :schema AND mview_name = :name", @schema, @name)
|
15
|
+
raise NotExists if !@updatable
|
16
|
+
@columns = oci.pluck("SELECT column_name, column_id, data_type, data_length, data_precision, data_scale, char_used, char_length " +
|
17
|
+
"FROM all_tab_columns WHERE owner = :schema AND table_name = :name ORDER BY column_id", @schema, @name).collect do |col|
|
18
|
+
Column.new(@schema, @name, col[0], id: col[1].to_i, type: col[2], length: col[3] && col[3].to_i,
|
19
|
+
precision: col[4] && col[4].to_i, scale: col[5] && col[5].to_i, char_used: col[6],
|
20
|
+
char_length: col[7] && col[7].to_i)
|
21
|
+
end
|
22
|
+
@columns_hash = Hash[@columns.collect { |col| [col.name, col] }]
|
23
|
+
self
|
24
|
+
end
|
25
|
+
|
26
|
+
def describe(options = {})
|
27
|
+
<<-HERE.reset_indentation
|
28
|
+
Materialized view #{@schema}.#{@name}
|
29
|
+
Updatable: #{@updatable}
|
30
|
+
Refresh mode: #{@refresh_mode}
|
31
|
+
Fast refreshable: #{@fast_refreshable}
|
32
|
+
Staleness: #{@staleness}
|
33
|
+
HERE
|
34
|
+
end
|
35
|
+
|
36
|
+
def list(options = {}, filter = nil)
|
37
|
+
columns = @columns_hash.keys
|
38
|
+
columns.select! { |c| c =~ /^#{Regexp.escape(filter).gsub('\*', '.*').gsub('\?', '.')}$/ } if filter
|
39
|
+
columns
|
40
|
+
end
|
41
|
+
|
42
|
+
def columns(column)
|
43
|
+
raise NotExists if !@columns_hash[column]
|
44
|
+
@columns_hash[column]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|