Rails for Multi database defines reading_request? in resolver

Rails moves reading_request? method from the DatabaseSelector to the DatabaseSelector::Resolver class so that we can override the method in custom Resolver.

Rails 6 had added the support for multiple database connections using which we can configure the separate databases for reads and writes.

For example, we have two databases write_database and read_database.


/config/database.yml

default: &default
  adapter: postgresql
  encoding: unicode
  host: <%= ENV['PG_HOST'] || 'localhost' %>
  pool: 5
  username: <%= ENV['PG_USER'] || 'postgres' %>
  password: <%= ENV['PG_PASSWORD'] || 'postgres' %>

development:
  write_database:
    <<: *default
    database: <%= "write_database" %>

  read_database:
    <<: *default
    database: <%= "read_database" %>

Now to use the above-defined databases, we need to set up the Active Record model.


app/models/application_record.rb

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
  connects_to database: { writing: :write_database, reading: :read_database }
end

app/models/product.rb

class Product < ApplicationRecord
  validates :name, presence: true
  validates :description, presence: true
end

And to enable the automatic switching between the databases based on the HTTP Verb we need to add the below configuration.


config/application.rb

module MultiDBApp
  class Application < Rails::Application
    config.active_record.database_selector = { delay: 2.seconds }
    config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
    config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
  end
end

Using the above configuration Rails looks for the reading_request? method defined in the middleware ActiveRecord::Middleware::Middleware::DatabaseSelector, its default implementation is true for GET and HEAD requests, which means for POST, PUT, DELETE, or PATCH request the application will automatically write to the write_database and for read, it will use read_database.

Note:- According to the docs we are supposed to add the above configuration in initializers(/config/initializers/multi_db.rb) but this is broken in Rails latest release so as suggested here in the Rails issue, we need to add the config in application.rb

Before

Let us call a create_product(POST) API to create a Product.


POST:- http://localhost:3000/products

Body:- { name: "Detergent", description: "A mixture of surfactants with cleansing properties" }

Response:- 

{
  "id": 1,
  "name": "Detergent",
  "description": "A mixture of surfactants with cleansing properties",
  "created_at": "2022-06-14T06:25:25.877Z",
  "updated_at": "2022-06-14T06:25:25.877Z"
}

So, as expected, the product is created successfully in write_database.

If we call a get_product(GET) API, it says product not found because, the GET request will be redirected to read_database, and that does not have a Product that we created recently.


GET:- http://localhost:3000/products/1

Response:-

{
  "error": "product with id 1 not found"
}

So, this is working as expected. Now suppose, if we call a GraphQL API to read the particular product.


GraphQL API:-

{
  product(id: 1) {
    id
    name
    description
  }
}

Response:-

{
  "data": {
    "product": {
      "id": "1",
      "title": "Detergent",
      "description": "A mixture of surfactants with cleansing properties"
    }
  }
}

Whoa!!! We get the Product details back, ideally, it should respond with a not_found error because it is a read request, but GraphQL API uses the POST HTTP method and according to the default implementation POST requests get redirected to the write_database.

After

To fix this, Rails has moved reading_request? method from the ActiveRecord::Middleware::Middleware::DatabaseSelector to the ActiveRecord::Middleware::DatabaseSelector::Resolver class, so that we can override and create custom Resolver.

Let us create a custom resolver and override the reading_request? method to add the validations for a GraphQL API.


class CustomResolver < ActiveRecord::Middleware::DatabaseSelector::Resolver
  def reading_request?(request)
    graphql_read = request.post? && request.path == "/graphql" && !request.params[:query]&.include?("mutation")
    graphql_read || super
  end
end

module MultiDBApp
  class Application < Rails::Application
    config.load_defaults 7.1
  
    config.active_record.database_selector = { delay: 2.seconds }
    config.active_record.database_resolver = CustomResolver
    config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
  end
end

So, our custom reading_request? checks if the request is POST, type is GraphQL and params doesn't have mutation then consider it as a read request and redirect it to the read_database.

Now, if we call a GraphQL API again to read the particular product.


GraphQL API:-

{
  product(id: 1) {
    id
    name
    description
  }
}

Response:-

{
  "errors": [
    {
      "message": "Couldn't find Product with 'id'=1"
    }
  ]
}

It responds with a not_found message, which is expected because read_database doesn't have a Product record.

Note: The enhancement is yet to be released in the official Rails version

Check out the PR for more details.