Merge pull request #50140 from kmcphillips/ar-protocol-adapter

Add a `ActiveRecord.protocol_adapters` configuration to map `DATABASE_URL` protocols to adapters at an application level
This commit is contained in:
Jean Boussier 2023-11-29 17:41:51 +01:00 committed by GitHub
commit 9c22f35440
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 119 additions and 2 deletions

View File

@ -1,3 +1,15 @@
* When using a `DATABASE_URL`, allow for a configuration to map the protocol in the URL to a specific database
adapter. This allows decoupling the adapter the application chooses to use from the database connection details
set in the deployment environment.
```ruby
# ENV['DATABASE_URL'] = "mysql://localhost/example_database"
config.active_record.protocol_adapters.mysql = "trilogy"
# will connect to MySQL using the trilogy adapter
```
*Jean Boussier*, *Kevin McPhillips*
* In cases where MySQL returns `warning_count` greater than zero, but returns no warnings when * In cases where MySQL returns `warning_count` greater than zero, but returns no warnings when
the `SHOW WARNINGS` query is executed, `ActiveRecord.db_warnings_action` proc will still be the `SHOW WARNINGS` query is executed, `ActiveRecord.db_warnings_action` proc will still be
called with a generic warning message rather than silently ignoring the warning(s). called with a generic warning message rather than silently ignoring the warning(s).

View File

@ -25,6 +25,7 @@
require "active_support" require "active_support"
require "active_support/rails" require "active_support/rails"
require "active_support/ordered_options"
require "active_model" require "active_model"
require "arel" require "arel"
require "yaml" require "yaml"
@ -464,6 +465,34 @@ module ActiveRecord
Marshalling.format_version = value Marshalling.format_version = value
end end
##
# :singleton-method:
# Provides a mapping between database protocols/DBMSs and the
# underlying database adapter to be used. This is used only by the
# <tt>DATABASE_URL</tt> environment variable.
#
# == Example
#
# DATABASE_URL="mysql://myuser:mypass@localhost/somedatabase"
#
# The above URL specifies that MySQL is the desired protocol/DBMS, and the
# application configuration can then decide which adapter to use. For this example
# the default mapping is from <tt>mysql</tt> to <tt>mysql2</tt>, but <tt>:trilogy</tt>
# is also supported.
#
# ActiveRecord.protocol_adapters.mysql = "mysql2"
#
# The protocols names are arbitrary, and external database adapters can be
# registered and set here.
singleton_class.attr_accessor :protocol_adapters
self.protocol_adapters = ActiveSupport::InheritableOptions.new(
{
sqlite: "sqlite3",
mysql: "mysql2",
postgres: "postgresql",
}
)
def self.eager_load! def self.eager_load!
super super
ActiveRecord::Locking.eager_load! ActiveRecord::Locking.eager_load!

View File

@ -25,8 +25,7 @@ module ActiveRecord
def initialize(url) def initialize(url)
raise "Database URL cannot be empty" if url.blank? raise "Database URL cannot be empty" if url.blank?
@uri = uri_parser.parse(url) @uri = uri_parser.parse(url)
@adapter = @uri.scheme && @uri.scheme.tr("-", "_") @adapter = resolved_adapter
@adapter = "postgresql" if @adapter == "postgres"
if @uri.opaque if @uri.opaque
@uri.opaque, @query = @uri.opaque.split("?", 2) @uri.opaque, @query = @uri.opaque.split("?", 2)
@ -80,6 +79,12 @@ module ActiveRecord
end end
end end
def resolved_adapter
adapter = uri.scheme && @uri.scheme.tr("-", "_")
adapter = ActiveRecord.protocol_adapters[adapter] || adapter
adapter
end
# Returns name of the database. # Returns name of the database.
def database_from_path def database_from_path
if @adapter == "sqlite3" if @adapter == "sqlite3"

View File

@ -10,6 +10,7 @@ module ActiveRecord
@previous_rack_env = ENV.delete("RACK_ENV") @previous_rack_env = ENV.delete("RACK_ENV")
@previous_rails_env = ENV.delete("RAILS_ENV") @previous_rails_env = ENV.delete("RAILS_ENV")
@adapters_was = ActiveRecord::ConnectionAdapters.instance_variable_get(:@adapters).dup @adapters_was = ActiveRecord::ConnectionAdapters.instance_variable_get(:@adapters).dup
@protocol_adapters = ActiveRecord.protocol_adapters.dup
end end
teardown do teardown do
@ -17,6 +18,7 @@ module ActiveRecord
ENV["RACK_ENV"] = @previous_rack_env ENV["RACK_ENV"] = @previous_rack_env
ENV["RAILS_ENV"] = @previous_rails_env ENV["RAILS_ENV"] = @previous_rails_env
ActiveRecord::ConnectionAdapters.instance_variable_set(:@adapters, @adapters_was) ActiveRecord::ConnectionAdapters.instance_variable_set(:@adapters, @adapters_was)
ActiveRecord.protocol_adapters = @protocol_adapters
end end
def resolve_config(config, env_name = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call) def resolve_config(config, env_name = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call)
@ -434,6 +436,59 @@ module ActiveRecord
adapter: "postgresql", adapter: "postgresql",
}, actual.configuration_hash) }, actual.configuration_hash)
end end
def test_protocol_adapter_mapping_is_used
ENV["DATABASE_URL"] = "mysql://localhost/exampledb"
ENV["RAILS_ENV"] = "production"
actual = resolve_db_config(:production, {})
expected = { adapter: "mysql2", database: "exampledb", host: "localhost" }
assert_equal expected, actual.configuration_hash
end
def test_protocol_adapter_mapping_falls_through_if_non_found
ENV["DATABASE_URL"] = "unknown://localhost/exampledb"
ENV["RAILS_ENV"] = "production"
actual = resolve_db_config(:production, {})
expected = { adapter: "unknown", database: "exampledb", host: "localhost" }
assert_equal expected, actual.configuration_hash
end
def test_protocol_adapter_mapping_is_used_and_can_be_updated
ActiveRecord.protocol_adapters.potato = "postgresql"
ENV["DATABASE_URL"] = "potato://localhost/exampledb"
ENV["RAILS_ENV"] = "production"
actual = resolve_db_config(:production, {})
expected = { adapter: "postgresql", database: "exampledb", host: "localhost" }
assert_equal expected, actual.configuration_hash
end
def test_protocol_adapter_mapping_translates_underscores_to_dashes
ActiveRecord.protocol_adapters.custom_protocol = "postgresql"
ENV["DATABASE_URL"] = "custom-protocol://localhost/exampledb"
ENV["RAILS_ENV"] = "production"
actual = resolve_db_config(:production, {})
expected = { adapter: "postgresql", database: "exampledb", host: "localhost" }
assert_equal expected, actual.configuration_hash
end
def test_protocol_adapter_mapping_handles_sqlite3_file_urls
ActiveRecord.protocol_adapters.custom_protocol = "sqlite3"
ENV["DATABASE_URL"] = "custom-protocol:/path/to/db.sqlite3"
ENV["RAILS_ENV"] = "production"
actual = resolve_db_config(:production, {})
expected = { adapter: "sqlite3", database: "/path/to/db.sqlite3" }
assert_equal expected, actual.configuration_hash
end
end end
end end
end end

View File

@ -1640,6 +1640,18 @@ The default value depends on the `config.load_defaults` target version:
| (original) | `true` | | (original) | `true` |
| 7.1 | `false` | | 7.1 | `false` |
#### `config.active_record.protocol_adapters`
When using a URL to configure the database connection, this option provides a mapping from the protocol to the underlying
database adapter. For example, this means the environment can specify `DATABASE_URL=mysql://localhost/database` and Rails will map
`mysql` to the `mysql2` adapter, but the application can also override these mappings:
```ruby
config.active_record.protocol_adapters.mysql = "trilogy"
```
If no mapping is found, the protocol is used as the adapter name.
### Configuring Action Controller ### Configuring Action Controller
`config.action_controller` includes a number of configuration settings: `config.action_controller` includes a number of configuration settings:
@ -2950,6 +2962,10 @@ development:
The `config/database.yml` file can contain ERB tags `<%= %>`. Anything in the tags will be evaluated as Ruby code. You can use this to pull out data from an environment variable or to perform calculations to generate the needed connection information. The `config/database.yml` file can contain ERB tags `<%= %>`. Anything in the tags will be evaluated as Ruby code. You can use this to pull out data from an environment variable or to perform calculations to generate the needed connection information.
When using a `ENV['DATABASE_URL']` or a `url` key in your `config/database.yml` file, Rails allows mapping the protocol
in the URL to a database adapter that can be configured from within the application. This allows the adapter to be configured
without modifying the URL set in the deployment environment. See: [`config.active_record.protocol_adapters`](#config-active_record-protocol-adapters).
TIP: You don't have to update the database configurations manually. If you look at the options of the application generator, you will see that one of the options is named `--database`. This option allows you to choose an adapter from a list of the most used relational databases. You can even run the generator repeatedly: `cd .. && rails new blog --database=mysql`. When you confirm the overwriting of the `config/database.yml` file, your application will be configured for MySQL instead of SQLite. Detailed examples of the common database connections are below. TIP: You don't have to update the database configurations manually. If you look at the options of the application generator, you will see that one of the options is named `--database`. This option allows you to choose an adapter from a list of the most used relational databases. You can even run the generator repeatedly: `cd .. && rails new blog --database=mysql`. When you confirm the overwriting of the `config/database.yml` file, your application will be configured for MySQL instead of SQLite. Detailed examples of the common database connections are below.