Skip to content

cookpad/arproxy

Repository files navigation

Integration tests Unit tests Rubocop

Arproxy

Arproxy is a library that can intercept SQL queries executed by ActiveRecord to log them or modify the queries themselves.

Getting Started

Create your custom proxy and add its configuration in your Rails' config/initializers/ directory:

class QueryTracer < Arproxy::Proxy
  def execute(sql, context)
    Rails.logger.debug sql
    Rails.logger.debug caller(1).join("\n")
    super(sql, context)
  end
end

Arproxy.configure do |config|
  config.adapter = 'mysql2' # A DB Adapter name which is used in your database.yml
  config.use QueryTracer
end
Arproxy.enable!

Then you can see the backtrace of SQLs in the Rails' log.

# In your Rails code
MyTable.where(id: id).limit(1) # => The SQL and the backtrace appear in the log

What the context argument is

context is an instance of Arproxy::QueryContext and contains values that are passed from Arproxy to the Database Adapter. context is a set of values used when calling Database Adapter methods, and you don't need to use the context values directly. However, you must always pass context to super like super(sql, context).

For example, let's look at the Mysql2Adapter implementation. When executing a query in Mysql2Adapter, the Mysql2Adapter#internal_exec_query method is called internally.

# https://github.com/rails/rails/blob/v7.1.0/activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rb#L21
def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: false) # :nodoc:
  # ...
end

In Arproxy, this method is called at the end of the Arproxy::Proxy#execute method chain, and at this time context contains the arguments to be passed to #internal_exec_query:

member example value
context.name "SQL"
context.binds []
context.kwargs { prepare: false, async: false }

You can modify the values of context in the proxy, but do so after understanding the implementation of the Database Adapter.

context.name

In the Rails' log you may see queries like this:

User Load (22.6ms)  SELECT `users`.* FROM `users` WHERE `users`.`name` = 'Issei Naruta'

Then "User Load" is the context.name.

Architecture

Without Arproxy:

+-------------------------+        +------------------+
| ActiveRecord::Base#find |--SQL-->| Database Adapter |
+-------------------------+        +------------------+

With Arproxy:

Arproxy.configure do |config|
  config.adapter = 'mysql2'
  config.use MyProxy1
  config.use MyProxy2
end
+-------------------------+        +----------+   +----------+   +------------------+
| ActiveRecord::Base#find |--SQL-->| MyProxy1 |-->| MyProxy2 |-->| Database Adapter |
+-------------------------+        +----------+   +----------+   +------------------+

Supported Environments

Arproxy supports the following databases and adapters:

  • MySQL
    • mysql2, trilogy
  • PostgreSQL
    • pg
  • SQLite
    • sqlite3
  • SQLServer
    • activerecord-sqlserver-adapter

We have tested with the following versions of Ruby, ActiveRecord, and databases:

  • Ruby
    • 3.0, 3.1, 3.2, 3.3
  • ActiveRecord
    • 6.1, 7.0, 7.1, 7.2, 8.0
  • MySQL
    • 9.0
  • PostgreSQL
    • 17
  • SQLite
    • 3.x (not specified)
  • SQLServer
    • 2022

Examples

Adding Comments to SQLs

class CommentAdder < Arproxy::Proxy
  def execute(sql, context)
    sql += ' /*this_is_comment*/'
    super(sql, context)
  end
end

Slow Query Logger

class SlowQueryLogger < Arproxy::Proxy
  def initialize(slow_ms)
    @slow_ms = slow_ms
  end

  def execute(sql, context)
    result = nil
    ms = Benchmark.ms { result = super(sql, context) }
    if ms >= @slow_ms
      Rails.logger.info "Slow(#{ms.to_i}ms): #{sql}"
    end
    result
  end
end

Arproxy.configure do |config|
  config.use SlowQueryLogger, 1000
end

Readonly Access

If you don't call super in the proxy, you can block the query execution.

class Readonly < Arproxy::Proxy
  def execute(sql, context)
    if sql =~ /^(SELECT|SET|SHOW|DESCRIBE)\b/
      super(sql, context)
    else
      Rails.logger.warn "#{context.name} (BLOCKED) #{sql}"
      nil
    end
  end
end

Use plug-in

# any_gem/lib/arproxy/plugin/my_plugin
module Arproxy::Plugin
  class MyPlugin < Arproxy::Proxy
    Arproxy::Plugin.register(:my_plugin, self)

    def execute(sql, context)
      # Any processing
      # ...
      super(sql, context)
    end
  end
end
Arproxy.configure do |config|
  config.plugin :my_plugin
end

Upgrading guide from v0.x to v1

See UPGRADING.md

Development

Setup

$ git clone https://github.com/cookpad/arproxy.git
$ cd arproxy
$ bundle install
$ bundle exec appraisal install

Run test

To run all tests with all supported versions of ActiveRecord:

$ docker compose up -d
$ bundle exec appraisal rspec

To run tests for a specific version of ActiveRecord:

$ bundle exec appraisal ar_7.1 rspec
or
$ BUNDLE_GEMFILE=gemfiles/ar_7.1.gemfile bundle exec rspec

License

Arproxy is released under the MIT license: