aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord/lib/active_record/connection_adapters/connection_specification.rb
blob: 20041f0c85193d9b10a1dda418e7e981e76efccc (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# frozen_string_literal: true

require "uri"

module ActiveRecord
  module ConnectionAdapters
    class ConnectionSpecification #:nodoc:
      attr_reader :name, :config, :adapter_method

      def initialize(name, config, adapter_method)
        @name, @config, @adapter_method = name, config, adapter_method
      end

      def initialize_dup(original)
        @config = original.config.dup
      end

      def to_hash
        @config.merge(name: @name)
      end

      # Expands a connection string into a hash.
      class ConnectionUrlResolver # :nodoc:
        # == Example
        #
        #   url = "postgresql://foo:bar@localhost:9000/foo_test?pool=5&timeout=3000"
        #   ConnectionUrlResolver.new(url).to_hash
        #   # => {
        #     "adapter"  => "postgresql",
        #     "host"     => "localhost",
        #     "port"     => 9000,
        #     "database" => "foo_test",
        #     "username" => "foo",
        #     "password" => "bar",
        #     "pool"     => "5",
        #     "timeout"  => "3000"
        #   }
        def initialize(url)
          raise "Database URL cannot be empty" if url.blank?
          @uri     = uri_parser.parse(url)
          @adapter = @uri.scheme && @uri.scheme.tr("-", "_")
          @adapter = "postgresql" if @adapter == "postgres"

          if @uri.opaque
            @uri.opaque, @query = @uri.opaque.split("?", 2)
          else
            @query = @uri.query
          end
        end

        # Converts the given URL to a full connection hash.
        def to_hash
          config = raw_config.compact_blank
          config.map { |key, value| config[key] = uri_parser.unescape(value) if value.is_a? String }
          config
        end

        private
          attr_reader :uri

          def uri_parser
            @uri_parser ||= URI::Parser.new
          end

          # Converts the query parameters of the URI into a hash.
          #
          #   "localhost?pool=5&reaping_frequency=2"
          #   # => { "pool" => "5", "reaping_frequency" => "2" }
          #
          # returns empty hash if no query present.
          #
          #   "localhost"
          #   # => {}
          def query_hash
            Hash[(@query || "").split("&").map { |pair| pair.split("=") }]
          end

          def raw_config
            if uri.opaque
              query_hash.merge(
                "adapter"  => @adapter,
                "database" => uri.opaque)
            else
              query_hash.merge(
                "adapter"  => @adapter,
                "username" => uri.user,
                "password" => uri.password,
                "port"     => uri.port,
                "database" => database_from_path,
                "host"     => uri.hostname)
            end
          end

          # Returns name of the database.
          def database_from_path
            if @adapter == "sqlite3"
              # 'sqlite3:/foo' is absolute, because that makes sense. The
              # corresponding relative version, 'sqlite3:foo', is handled
              # elsewhere, as an "opaque".

              uri.path
            else
              # Only SQLite uses a filename as the "database" name; for
              # anything else, a leading slash would be silly.

              uri.path.sub(%r{^/}, "")
            end
          end
      end

      ##
      # Builds a ConnectionSpecification from user input.
      class Resolver # :nodoc:
        attr_reader :configurations

        # Accepts a list of db config objects.
        def initialize(configurations)
          @configurations = configurations
        end

        # Returns a hash with database connection information.
        #
        # == Examples
        #
        # Full hash Configuration.
        #
        #   configurations = { "production" => { "host" => "localhost", "database" => "foo", "adapter" => "sqlite3" } }
        #   Resolver.new(configurations).resolve(:production)
        #   # => { "host" => "localhost", "database" => "foo", "adapter" => "sqlite3"}
        #
        # Initialized with URL configuration strings.
        #
        #   configurations = { "production" => "postgresql://localhost/foo" }
        #   Resolver.new(configurations).resolve(:production)
        #   # => { "host" => "localhost", "database" => "foo", "adapter" => "postgresql" }
        #
        def resolve(config_or_env, pool_name = nil)
          if config_or_env
            resolve_connection config_or_env, pool_name
          else
            raise AdapterNotSpecified
          end
        end

        # Returns an instance of ConnectionSpecification for a given adapter.
        # Accepts a hash one layer deep that contains all connection information.
        #
        # == Example
        #
        #   config = { "production" => { "host" => "localhost", "database" => "foo", "adapter" => "sqlite3" } }
        #   spec = Resolver.new(config).spec(:production)
        #   spec.adapter_method
        #   # => "sqlite3_connection"
        #   spec.config
        #   # => { "host" => "localhost", "database" => "foo", "adapter" => "sqlite3" }
        #
        def spec(config)
          pool_name = config if config.is_a?(Symbol)

          spec = resolve(config, pool_name).symbolize_keys

          raise(AdapterNotSpecified, "database configuration does not specify adapter") unless spec.key?(:adapter)

          # Require the adapter itself and give useful feedback about
          #   1. Missing adapter gems and
          #   2. Adapter gems' missing dependencies.
          path_to_adapter = "active_record/connection_adapters/#{spec[:adapter]}_adapter"
          begin
            require path_to_adapter
          rescue LoadError => e
            # We couldn't require the adapter itself. Raise an exception that
            # points out config typos and missing gems.
            if e.path == path_to_adapter
              # We can assume that a non-builtin adapter was specified, so it's
              # either misspelled or missing from Gemfile.
              raise LoadError, "Could not load the '#{spec[:adapter]}' Active Record adapter. Ensure that the adapter is spelled correctly in config/database.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace

            # Bubbled up from the adapter require. Prefix the exception message
            # with some guidance about how to address it and reraise.
            else
              raise LoadError, "Error loading the '#{spec[:adapter]}' Active Record adapter. Missing a gem it depends on? #{e.message}", e.backtrace
            end
          end

          adapter_method = "#{spec[:adapter]}_connection"

          unless ActiveRecord::Base.respond_to?(adapter_method)
            raise AdapterNotFound, "database configuration specifies nonexistent #{spec.config[:adapter]} adapter"
          end

          ConnectionSpecification.new(spec.delete(:name) || "primary", spec, adapter_method)
        end

        private
          # Returns fully resolved connection, accepts hash, string or symbol.
          # Always returns a hash.
          #
          # == Examples
          #
          # Symbol representing current environment.
          #
          #   Resolver.new("production" => {}).resolve_connection(:production)
          #   # => {}
          #
          # One layer deep hash of connection values.
          #
          #   Resolver.new({}).resolve_connection("adapter" => "sqlite3")
          #   # => { "adapter" => "sqlite3" }
          #
          # Connection URL.
          #
          #   Resolver.new({}).resolve_connection("postgresql://localhost/foo")
          #   # => { "host" => "localhost", "database" => "foo", "adapter" => "postgresql" }
          #
          def resolve_connection(config_or_env, pool_name = nil)
            case config_or_env
            when Symbol
              resolve_symbol_connection config_or_env, pool_name
            when String
              resolve_url_connection config_or_env
            when Hash
              resolve_hash_connection config_or_env
            else
              resolve_connection config_or_env
            end
          end

          # Takes the environment such as +:production+ or +:development+ and a
          # pool name the corresponds to the name given by the connection pool
          # to the connection. That pool name is merged into the hash with the
          # name key.
          #
          # This requires that the @configurations was initialized with a key that
          # matches.
          #
          #   configurations = #<ActiveRecord::DatabaseConfigurations:0x00007fd9fdace3e0
          #     @configurations=[
          #       #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd9fdace250
          #         @env_name="production", @spec_name="primary", @config={"database"=>"my_db"}>
          #       ]>
          #
          #   Resolver.new(configurations).resolve_symbol_connection(:production, "primary")
          #   # => { "database" => "my_db" }
          def resolve_symbol_connection(env_name, pool_name)
            db_config = configurations.find_db_config(env_name)

            if db_config
              resolve_connection(db_config.config).merge("name" => pool_name.to_s)
            else
              raise AdapterNotSpecified, <<~MSG
                The `#{env_name}` database is not configured for the `#{ActiveRecord::ConnectionHandling::DEFAULT_ENV.call}` environment.

                Available databases configurations are:

                #{build_configuration_sentence}
              MSG
            end
          end

          def build_configuration_sentence # :nodoc:
            configs = configurations.configs_for(include_replicas: true)

            configs.group_by(&:env_name).map do |env, config|
              namespaces = config.map(&:spec_name)
              if namespaces.size > 1
                "#{env}: #{namespaces.join(", ")}"
              else
                env
              end
            end.join("\n")
          end

          # Accepts a hash. Expands the "url" key that contains a
          # URL database connection to a full connection
          # hash and merges with the rest of the hash.
          # Connection details inside of the "url" key win any merge conflicts
          def resolve_hash_connection(spec)
            if spec["url"] && !spec["url"].match?(/^jdbc:/)
              connection_hash = resolve_url_connection(spec.delete("url"))
              spec.merge!(connection_hash)
            end
            spec
          end

          # Takes a connection URL.
          #
          #   Resolver.new({}).resolve_url_connection("postgresql://localhost/foo")
          #   # => { "host" => "localhost", "database" => "foo", "adapter" => "postgresql" }
          #
          def resolve_url_connection(url)
            ConnectionUrlResolver.new(url).to_hash
          end
      end
    end
  end
end