automodel-sqlserver 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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +81 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +5 -0
  7. data/.yardopts +1 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +82 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +225 -0
  12. data/Rakefile +6 -0
  13. data/automodel-sqlserver.gemspec +39 -0
  14. data/bin/console +14 -0
  15. data/bin/setup +8 -0
  16. data/docs/Automodel.html +161 -0
  17. data/docs/Automodel/AdapterAlreadyRegistered.html +144 -0
  18. data/docs/Automodel/AdapterAlreadyRegisteredError.html +144 -0
  19. data/docs/Automodel/CannotFindOnCompoundPrimaryKey.html +144 -0
  20. data/docs/Automodel/Connectors.html +117 -0
  21. data/docs/Automodel/Error.html +140 -0
  22. data/docs/Automodel/FindOnCompoundPrimaryKeyError.html +144 -0
  23. data/docs/Automodel/Helpers.html +722 -0
  24. data/docs/Automodel/NameCollisionError.html +143 -0
  25. data/docs/Automodel/SchemaInspector.html +1046 -0
  26. data/docs/Automodel/UnregisteredAdapter.html +144 -0
  27. data/docs/_index.html +206 -0
  28. data/docs/class_list.html +51 -0
  29. data/docs/css/common.css +1 -0
  30. data/docs/css/full_list.css +58 -0
  31. data/docs/css/style.css +496 -0
  32. data/docs/file.README.html +333 -0
  33. data/docs/file_list.html +56 -0
  34. data/docs/frames.html +17 -0
  35. data/docs/index.html +333 -0
  36. data/docs/js/app.js +292 -0
  37. data/docs/js/full_list.js +216 -0
  38. data/docs/js/jquery.js +4 -0
  39. data/docs/method_list.html +155 -0
  40. data/docs/top-level-namespace.html +478 -0
  41. data/lib/automodel.rb +132 -0
  42. data/lib/automodel/automodel.rb +4 -0
  43. data/lib/automodel/connectors.rb +8 -0
  44. data/lib/automodel/errors.rb +24 -0
  45. data/lib/automodel/helpers.rb +141 -0
  46. data/lib/automodel/schema_inspector.rb +218 -0
  47. data/lib/automodel/version.rb +10 -0
  48. data/samples/database.yml +38 -0
  49. metadata +259 -0
@@ -0,0 +1,132 @@
1
+ require 'active_record'
2
+ require 'active_support/all'
3
+ require 'securerandom'
4
+
5
+ require 'automodel/automodel'
6
+ require 'automodel/connectors'
7
+ require 'automodel/helpers'
8
+ require 'automodel/version'
9
+
10
+ ## The main (really *only*) entrypoint for the **automodel-sqlserver** gem. This is the method the
11
+ ## end-user calls to trigger a database scrape and model generation.
12
+ ##
13
+ ##
14
+ ## @param spec [Symbol, String, Hash]
15
+ ## The Symbol/String/Hash to pass through to the ActiveRecord connection resolver, as detailed in
16
+ ## [ActiveRecord::ConnectionHandling#establish_connection](http://bit.ly/2JQdA8c). Whether the
17
+ ## given "spec" value is a Hash or is a Symbol/String to run through the ActiveRecord resolver,
18
+ ## the resulting Hash may include the following options (in addition to the actual connection
19
+ ## parameters).
20
+ ##
21
+ ## @option spec [String] :subschema
22
+ ## The name of an additional namespace with which tables in the target database are prefixed.
23
+ ## Intended for use with SQL Server, where a table's fully-qualified name may have an additional
24
+ ## level of namespacing between the database name and the base table name (e.g.
25
+ ## `database.dbo.table`, in which case the subschema would be `"dbo"`).
26
+ ##
27
+ ## @option spec [String] :namespace
28
+ ## A String representing the desired namespace for the generated model classes (e.g. `"NewDB"` or
29
+ ## `"WeirdDB::Models"`). If not given, the generated models will fall under `Kernel` so they are
30
+ ## always available without namespacing, like standard user-defined model classes.
31
+ ##
32
+ ##
33
+ ## @raise [Automodel::ModelNameCollisionError]
34
+ ##
35
+ ## @return [ActiveRecord::Base]
36
+ ## The returned value is an instance of an ActiveRecord::Base subclass. This is the class that
37
+ ## serves as superclass to all of the generated model classes, so that a list of all models can be
38
+ ## easily compiled by calling `#subclasses` on this value.
39
+ ##
40
+ def automodel(spec)
41
+ ## Build out a connection spec Hash from the given value.
42
+ ##
43
+ resolver = ActiveRecord::ConnectionAdapters::ConnectionSpecification::Resolver
44
+ connection_spec = resolver.new(ActiveRecord::Base.configurations).resolve(spec).symbolize_keys
45
+
46
+ ## We need a base class for all of the models we're about to create, but don't want to pollute
47
+ ## ActiveRecord::Base's own connection pool, so we'll need a subclass. This will serve as both
48
+ ## our base class for new models and as the connection pool handler. We're defining it with names
49
+ ## that reflect both uses just to keep the code more legible.
50
+ ##
51
+ connection_handler_name = "CH_#{SecureRandom.uuid.delete('-')}"
52
+ base_class_for_new_models = connection_handler = Class.new(ActiveRecord::Base)
53
+ Automodel::Helpers.register_class(connection_handler, as: connection_handler_name,
54
+ within: :'Automodel::Connectors')
55
+
56
+ ## Establish a connection with the given params.
57
+ ##
58
+ connection_handler.establish_connection(connection_spec)
59
+
60
+ ## Map out the table structures.
61
+ ##
62
+ tables = Automodel::Helpers.map_tables(connection_handler, subschema: connection_spec[:subschema])
63
+
64
+ ## Safeguard against class name collisions.
65
+ ##
66
+ defined_names = Array((connection_spec[:namespace] || :Kernel).to_s.safe_constantize&.constants)
67
+ potential_names = tables.map { |table| table[:model_name].to_sym }
68
+ name_collisions = defined_names & potential_names
69
+ if name_collisions.present?
70
+ connection_handler.connection_pool.disconnect!
71
+ Automodel::Connectors.send(:remove_const, connection_handler_name)
72
+ raise Automodel::NameCollisionError, name_collisions
73
+ end
74
+
75
+ ## Define the table models.
76
+ ##
77
+ tables.each do |table|
78
+ table[:model] = Class.new(base_class_for_new_models) do
79
+ ## We can't assume table properties confom to any standard.
80
+ ##
81
+ self.table_name = table[:name]
82
+ self.primary_key = table[:primary_key]
83
+
84
+ ## Don't allow `#find` for tables with a composite primary key.
85
+ ##
86
+ def find(*args)
87
+ raise Automodel::FindOnCompoundPrimaryKeyError if table[:composite_primary_key]
88
+ super
89
+ end
90
+
91
+ ## Create railsy column name aliases whenever possible.
92
+ ##
93
+ table[:columns].each do |column|
94
+ railsy_name = Automodel::Helpers.railsy_column_name(column)
95
+ unless table[:column_aliases].key? railsy_name
96
+ table[:column_aliases][railsy_name] = column
97
+ alias_attribute(railsy_name, column.name)
98
+ end
99
+ end
100
+ end
101
+
102
+ ## Register the model class.
103
+ ##
104
+ Automodel::Helpers.register_class(table[:model], as: table[:model_name],
105
+ within: connection_spec[:namespace])
106
+ end
107
+
108
+ ## With all models registered, we can safely declare relationships.
109
+ ##
110
+ tables.map { |table| table[:foreign_keys] }.flatten.each do |fk|
111
+ from_table = tables.find { |table| table[:base_name] == fk.from_table.delete('"') }
112
+ next unless from_table.present?
113
+
114
+ to_table = tables.find { |table| table[:base_name] == fk.to_table.delete('"') }
115
+ next unless to_table.present?
116
+
117
+ association_setup = <<~END_OF_HEREDOC
118
+ belongs_to #{to_table[:base_name].to_sym.inspect},
119
+ class_name: #{to_table[:model].to_s.inspect},
120
+ primary_key: #{fk.options[:primary_key].to_sym.inspect},
121
+ foreign_key: #{fk.options[:column].to_sym.inspect}
122
+
123
+ alias #{to_table[:model_name].underscore.to_sym.inspect} #{to_table[:base_name].to_sym.inspect}
124
+ END_OF_HEREDOC
125
+ from_table[:model].class_eval(association_setup, __FILE__, __LINE__)
126
+ end
127
+
128
+ ## There's no obvious value we can return that would be of any use, except maybe the base class,
129
+ ## in case the end user wants to procure a list of all the models (via `#subclasses`).
130
+ ##
131
+ base_class_for_new_models
132
+ end
@@ -0,0 +1,4 @@
1
+ ## The root module for the **automodel-sqlserver** gem.
2
+ ##
3
+ module Automodel
4
+ end
@@ -0,0 +1,8 @@
1
+ require 'automodel/automodel'
2
+
3
+ module Automodel
4
+ ## The module within which all {::automodel}-generated connection handlers are registered.
5
+ ##
6
+ module Connectors
7
+ end
8
+ end
@@ -0,0 +1,24 @@
1
+ require 'automodel/automodel'
2
+
3
+ module Automodel
4
+ ## The base Error class for all **automodel-sqlserver** gem issues.
5
+ ##
6
+ class Error < ::StandardError
7
+ end
8
+
9
+ ## An error resulting from an attempt to register the same adapter name with
10
+ ## {Automodel::SchemaInspector.register_adapter} multiple times.
11
+ ##
12
+ class AdapterAlreadyRegisteredError < Error
13
+ end
14
+
15
+ ## An error resulting from an aborted Automodel due to a class name collision.
16
+ ##
17
+ class NameCollisionError < Error
18
+ end
19
+
20
+ ## An error resulting from calling `#find` on a table with a compound primary key.
21
+ ##
22
+ class FindOnCompoundPrimaryKeyError < Error
23
+ end
24
+ end
@@ -0,0 +1,141 @@
1
+ require 'automodel/automodel'
2
+ require 'automodel/schema_inspector'
3
+
4
+ module Automodel
5
+ ## Houses some helper methods used directly by {::automodel}.
6
+ ##
7
+ module Helpers
8
+ class << self
9
+ ## Takes a connection handler (an object that implements ActiveRecord::ConnectionHandling),
10
+ ## scrapes the target database, and returns a list of the tables' metadata.
11
+ ##
12
+ ##
13
+ ## @param connection_handler [ActiveRecord::ConnectionHandling]
14
+ ## The connection pool/handler to inspect and map out.
15
+ ##
16
+ ## @param subschema [String]
17
+ ## The name of an additional namespace with which tables in the target database are
18
+ ## prefixed, as eplained in {::automodel}.
19
+ ##
20
+ ##
21
+ ## @return [Array<Hash>]
22
+ ## An Array where each value is a Hash representing a table in the target database. Each
23
+ ## such Hash will define the following keys:
24
+ ##
25
+ ## - `:name` (String) -- The table name, prefixed with the subschema name (if one is given).
26
+ ## - `:columns` (Array<ActiveRecord::ConnectionAdapters::Column>)
27
+ ## - `:primary_key` (String, Array<String>)
28
+ ## - `:foreign_keys` (Array<ActiveRecord::ConnectionAdapters::ForeignKeyDefinition>)
29
+ ## - `:base_name` (String) -- The table name, with no subschema.
30
+ ## - `:model_name` (String) -- A Railsy class name for the corresponding model.
31
+ ## - `:composite_primary_key` (true, false)
32
+ ## - `:column_aliases` (Hash<String, ActiveRecord::ConnectionAdapters::Column>)
33
+ ##
34
+ def map_tables(connection_handler, subschema: '')
35
+ ## Normalize the "subschema" name.
36
+ ##
37
+ subschema = "#{subschema}.".sub(%r{\.+$}, '.').sub(%r{^\.}, '')
38
+
39
+ ## Prep the Automodel::SchemaInspector we'll be using.
40
+ ##
41
+ schema_inspector = Automodel::SchemaInspector.new(connection_handler)
42
+
43
+ ## Get as much metadata as possible out of the Automodel::SchemaInspector.
44
+ ##
45
+ schema_inspector.tables.map do |table_name|
46
+ table = {}
47
+
48
+ table[:name] = "#{subschema}#{table_name}"
49
+ table[:columns] = schema_inspector.columns(table[:name])
50
+ table[:primary_key] = schema_inspector.primary_key(table[:name])
51
+ table[:foreign_keys] = schema_inspector.foreign_keys(table[:name])
52
+
53
+ table[:base_name] = table[:name].split('.').last
54
+ table[:model_name] = table[:base_name].underscore.classify
55
+ table[:composite_primary_key] = table[:primary_key].is_a? Array
56
+ table[:column_aliases] = table[:columns].map { |column| [column.name, column] }.to_h
57
+
58
+ table
59
+ end
60
+ end
61
+
62
+ ## Returns a Railsy name for the given column.
63
+ ##
64
+ ##
65
+ ## @param column [ActiveRecord::ConnectionAdapters::Column]
66
+ ## The column for which we want to generate a Railsy name.
67
+ ##
68
+ ##
69
+ ## @return [String]
70
+ ## The given column's name, in Railsy form.
71
+ ##
72
+ ## Note Date/Datetime columns are not suffixed with "_on" or "_at" per Rails norm, as this
73
+ ## can work against you sometimes ("BirthDate" turns into "birth_on"). A future release will
74
+ ## address this by building out a comprehensive list of such names and their correct Railsy
75
+ ## representation, but that is not currently the case.
76
+ ##
77
+ def railsy_column_name(column)
78
+ name = railsy_name(column.name)
79
+ name = name.sub(%r{^is_}, '') if column.type == :boolean
80
+
81
+ name
82
+ end
83
+
84
+ ## Returns the given name in Railsy form.
85
+ ##
86
+ ##
87
+ ## @param name [String, Symbol]
88
+ ## The column name for which we want to generate a Railsy name.
89
+ ##
90
+ ##
91
+ ## @return [String]
92
+ ## The given name, in Railsy form.
93
+ ##
94
+ def railsy_name(name)
95
+ name.to_s.gsub(%r{[^a-z0-9]+}i, '_').underscore
96
+ end
97
+
98
+ ## Registers the given class **as** the given name and **within** the given namespace (if any).
99
+ ##
100
+ ##
101
+ ## @param class_object [Class]
102
+ ## The class to register.
103
+ ##
104
+ ## @param as [String]
105
+ ## The name with which to register the class. Note this should be a base name (no "::").
106
+ ##
107
+ ## @param within [String, Symbol, Module, Class]
108
+ ## The module/class under which the given class should be registered. If the named module or
109
+ ## class does not exist, as many nested modules as needed are declared so the class can be
110
+ ## registered as requested.
111
+ ##
112
+ ## e.g. Calling this method with an "as" value of `"Sample"` and a "within" value of
113
+ ## `"Many::Levels::Deep"` will: check for module/class "Many" and create one as a
114
+ ## Module if it doesn't already exist; then check for a module/class "Many::Levels" and
115
+ ## create a "Levels" Module within `Many` if it doesn't already exist; then check for a
116
+ ## module/class "Many::Levels::Deep" and create a "Deep" Module within `Many::Levels`
117
+ ## if it doesn't already exist; and finally register the given class as "Sample" within
118
+ ## `Many::Levels::Deep`.
119
+ ##
120
+ ##
121
+ ## @return [Class]
122
+ ## The newly-registered class (the same value as the originally-submitted "class_object").
123
+ ##
124
+ def register_class(class_object, as:, within: nil)
125
+ components = within.to_s.split('::').compact.map(&:to_sym)
126
+ components.unshift(:Kernel) unless components.first.to_s.safe_constantize.present?
127
+
128
+ namespace = components.shift.to_s.constantize
129
+ components.each do |component|
130
+ namespace = if component.in? namespace.constants
131
+ namespace.const_get(component)
132
+ else
133
+ namespace.const_set(component, Module.new)
134
+ end
135
+ end
136
+
137
+ namespace.const_set(as, class_object)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,218 @@
1
+ require 'active_support/all'
2
+ require 'securerandom'
3
+
4
+ require 'automodel/automodel'
5
+ require 'automodel/errors'
6
+
7
+ module Automodel
8
+ ## A utility object that issues the actual database inspection commands and returns the table,
9
+ ## column, primary-key, and foreign-key data.
10
+ ##
11
+ class SchemaInspector
12
+ ## rubocop:disable all
13
+
14
+ ## Class-Instance variable: `known_adapters` is a Hash of adapters registered via
15
+ ## {Automodel::SchemaInspector.register_adapter}.
16
+ ##
17
+ @known_adapters = {}
18
+ def self.known_adapters; @known_adapters; end
19
+ def known_adapters; self.class.known_adapters; end
20
+ ## rubocop:enable all
21
+
22
+ ## "Registers" an adapter with the Automodel::SchemaInspector. This allows for alternate
23
+ ## mechanisms of procuring lists of tables, columns, primary keys, and/or foreign keys from an
24
+ ## adapter that may not itself support `#tables`/`#columns`/`#primary_key`/`#foreign_keys`.
25
+ ##
26
+ ##
27
+ ## @param adapter [String, Symbol]
28
+ ## The "adapter" value used to match that given in the connection spec. It is with this value
29
+ ## that the adapter being registered is matched to an existing database pool/connection.
30
+ ##
31
+ ## @param tables [Proc]
32
+ ## The Proc to `#call` to request a list of table names. The Proc will be called with one
33
+ ## parameter: a database connection.
34
+ ##
35
+ ## @param columns [Proc]
36
+ ## The Proc to `#call` to request a list of columns for a specific table. The Proc will be
37
+ ## called with two parameters: a database connection and a table name.
38
+ ##
39
+ ## @param primary_key [Proc]
40
+ ## The Proc to `#call` to request the primary key for a specific table. The Proc will be
41
+ ## called with two parameters: a database connection and a table name.
42
+ ##
43
+ ## @param foreign_keys [Proc]
44
+ ## The Proc to `#call` to request a list of foreign keys for a specific table. The Proc will
45
+ ## be called with two parameters: a database connection and a table name.
46
+ ##
47
+ ##
48
+ ## @raise [Automodel::AdapterAlreadyRegisteredError]
49
+ ##
50
+ def self.register_adapter(adapter:, tables:, columns:, primary_key:, foreign_keys: nil)
51
+ adapter = adapter.to_sym.downcase
52
+ raise Automodel::AdapterAlreadyRegisteredError, adapter if known_adapters.key? adapter
53
+
54
+ known_adapters[adapter] = { tables: tables,
55
+ columns: columns,
56
+ primary_key: primary_key,
57
+ foreign_keys: foreign_keys }
58
+ end
59
+
60
+ ## @param connection_handler [ActiveRecord::ConnectionHandling]
61
+ ## The connection pool/handler (an object that implements ActiveRecord::ConnectionHandling) to
62
+ ## inspect and map out.
63
+ ##
64
+ def initialize(connection_handler)
65
+ @connection = connection_handler.connection
66
+ adapter = connection_handler.connection_pool.spec.config[:adapter]
67
+
68
+ @registration = known_adapters[adapter.to_sym] || {}
69
+ end
70
+
71
+ ## Returns a list of table names in the target database.
72
+ ##
73
+ ## If a matching Automodel::SchemaInspector registration is found for the connection's adapter,
74
+ ## and that registration specified a `:tables` Proc, the Proc is called. Otherwise, the standard
75
+ ## connection `#tables` is returned.
76
+ ##
77
+ ##
78
+ ## @return [Array<String>]
79
+ ##
80
+ def tables
81
+ @tables ||= if @registration[:tables].present?
82
+ @registration[:tables].call(@connection)
83
+ else
84
+ @connection.tables
85
+ end
86
+ end
87
+
88
+ ## Returns a list of columns for the given table.
89
+ ##
90
+ ## If a matching Automodel::SchemaInspector registration is found for the connection's adapter,
91
+ ## and that registration specified a `:columns` Proc, the Proc is called. Otherwise, the
92
+ ## standard connection `#columns` is returned.
93
+ ##
94
+ ##
95
+ ## @param table_name [String]
96
+ ## The table whose columns should be fetched.
97
+ ##
98
+ ##
99
+ ## @return [Array<ActiveRecord::ConnectionAdapters::Column>]
100
+ ##
101
+ def columns(table_name)
102
+ table_name = table_name.to_s
103
+
104
+ @columns ||= {}
105
+ @columns[table_name] ||= if @registration[:columns].present?
106
+ @registration[:columns].call(@connection, table_name)
107
+ else
108
+ @connection.columns(table_name)
109
+ end
110
+ end
111
+
112
+ ## Returns the primary key for the given table.
113
+ ##
114
+ ## If a matching Automodel::SchemaInspector registration is found for the connection's adapter,
115
+ ## and that registration specified a `:primary_key` Proc, the Proc is called. Otherwise, the
116
+ ## standard connection `#primary_key` is returned.
117
+ ##
118
+ ##
119
+ ## @param table_name [String]
120
+ ## The table whose primary key should be fetched.
121
+ ##
122
+ ##
123
+ ## @return [String, Array<String>]
124
+ ##
125
+ def primary_key(table_name)
126
+ table_name = table_name.to_s
127
+
128
+ @primary_keys ||= {}
129
+ @primary_keys[table_name] ||= if @registration[:primary_key].present?
130
+ @registration[:primary_key].call(@connection, table_name)
131
+ else
132
+ @connection.primary_key(table_name)
133
+ end
134
+ end
135
+
136
+ ## Returns a list of foreign keys for the given table.
137
+ ##
138
+ ## If a matching Automodel::SchemaInspector registration is found for the connection's adapter,
139
+ ## and that registration specified a `:foreign_keys` Proc, the Proc is called. Otherwise, the
140
+ ## standard connection `#foreign_keys` is attempted. If that call to ``#foreign_keys` raises a
141
+ ## ::NoMethodError or ::NotImplementedError, a best-effort attempt is made to build a list of
142
+ ## foreign keys based on table and column names.
143
+ ##
144
+ ##
145
+ ## @param table_name [String]
146
+ ## The table whose foreign keys should be fetched.
147
+ ##
148
+ ##
149
+ ## @return [Array<ActiveRecord::ConnectionAdapters::ForeignKeyDefinition>]
150
+ ##
151
+ def foreign_keys(table_name)
152
+ table_name = table_name.to_s
153
+
154
+ @foreign_keys ||= {}
155
+ @foreign_keys[table_name] ||= begin
156
+ if @registration[:foreign_keys].present?
157
+ @registration[:foreign_keys].call(@connection, table_name)
158
+ else
159
+ begin
160
+ @connection.foreign_keys(table_name)
161
+ rescue ::NoMethodError, ::NotImplementedError
162
+ ## Not all ActiveRecord adapters support `#foreign_keys`. When this happens, we'll make
163
+ ## a best-effort attempt to intuit relationships from the table and column names.
164
+ ##
165
+ columns(table_name).map do |column|
166
+ id_pattern = %r{(?:_id|Id)$}
167
+ next unless column.name =~ id_pattern
168
+
169
+ target_table = column.name.sub(id_pattern, '')
170
+ next unless target_table.in? tables
171
+
172
+ target_column = primary_key(qualified_name(target_table, context: table_name))
173
+ next unless target_column.in? ['id', 'Id', 'ID', column.name]
174
+
175
+ ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
176
+ table_name.split('.').last,
177
+ target_table,
178
+ name: "FK_#{SecureRandom.uuid.delete('-')}",
179
+ column: column.name,
180
+ primary_key: target_column,
181
+ on_update: nil,
182
+ on_delete: nil
183
+ )
184
+ end.compact
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ ## Returns a qualified table name.
193
+ ##
194
+ ##
195
+ ## @param table_name [String]
196
+ ## The name to qualify.
197
+ ##
198
+ ## @param context [String]
199
+ ## The name of an existing table from whose namespace we want to be able to reach the first
200
+ ## table.
201
+ ##
202
+ ##
203
+ ## @return [String]
204
+ ##
205
+ def qualified(table_name, context:)
206
+ return table_name if table_name['.'].present?
207
+ return table_name if context['.'].blank?
208
+
209
+ "#{context.sub(%r{[^.]*$}, '')}#{table_name}"
210
+ end
211
+
212
+ ## Returns an unqualified table name.
213
+ ##
214
+ def unqualified(table_name)
215
+ table_name.split('.').last
216
+ end
217
+ end
218
+ end