Rails for Multi database defines reading_request? in 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, in order 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

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.

Join Our Newsletter