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.

Need help on your Ruby on Rails or React project?

Join Our Newsletter