Skip to content

Commit

Permalink
Merge pull request #50140 from kmcphillips/ar-protocol-adapter
Browse files Browse the repository at this point in the history
Add a `ActiveRecord.protocol_adapters` configuration to map `DATABASE_URL` protocols to adapters at an application level
  • Loading branch information
byroot committed Nov 29, 2023
2 parents d22c657 + 0ccf300 commit 9c22f35
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 2 deletions.
12 changes: 12 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
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).
Expand Down
29 changes: 29 additions & 0 deletions activerecord/lib/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

require "active_support"
require "active_support/rails"
require "active_support/ordered_options"
require "active_model"
require "arel"
require "yaml"
Expand Down Expand Up @@ -464,6 +465,34 @@ def self.marshalling_format_version=(value)
Marshalling.format_version = value
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!
super
ActiveRecord::Locking.eager_load!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ class ConnectionUrlResolver # :nodoc:
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"
@adapter = resolved_adapter

if @uri.opaque
@uri.opaque, @query = @uri.opaque.split("?", 2)
Expand Down Expand Up @@ -80,6 +79,12 @@ def raw_config
end
end

def resolved_adapter
adapter = uri.scheme && @uri.scheme.tr("-", "_")
adapter = ActiveRecord.protocol_adapters[adapter] || adapter
adapter
end

# Returns name of the database.
def database_from_path
if @adapter == "sqlite3"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ def setup
@previous_rack_env = ENV.delete("RACK_ENV")
@previous_rails_env = ENV.delete("RAILS_ENV")
@adapters_was = ActiveRecord::ConnectionAdapters.instance_variable_get(:@adapters).dup
@protocol_adapters = ActiveRecord.protocol_adapters.dup
end

teardown do
ENV["DATABASE_URL"] = @previous_database_url
ENV["RACK_ENV"] = @previous_rack_env
ENV["RAILS_ENV"] = @previous_rails_env
ActiveRecord::ConnectionAdapters.instance_variable_set(:@adapters, @adapters_was)
ActiveRecord.protocol_adapters = @protocol_adapters
end

def resolve_config(config, env_name = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call)
Expand Down Expand Up @@ -434,6 +436,59 @@ def test_does_not_change_other_environments
adapter: "postgresql",
}, actual.configuration_hash)
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
16 changes: 16 additions & 0 deletions guides/source/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,18 @@ The default value depends on the `config.load_defaults` target version:
| (original) | `true` |
| 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

`config.action_controller` includes a number of configuration settings:
Expand Down Expand Up @@ -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.

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.

Expand Down

0 comments on commit 9c22f35

Please sign in to comment.