Repository: cookpad/arproxy Branch: main Commit: 93c3988736cf Files: 59 Total size: 60.2 KB Directory structure: gitextract_pt1acgj_/ ├── .github/ │ └── workflows/ │ ├── integration_test.yml │ ├── integration_tests.yml │ ├── rubocop.yml │ └── unit_tests.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Appraisals ├── ChangeLog.md ├── Dockerfile ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── UPGRADING.md ├── arproxy.gemspec ├── compose-ci.yaml ├── compose.yaml ├── db/ │ ├── mysql/ │ │ └── my.cnf │ └── sqlserver/ │ └── init.sql ├── gemfiles/ │ ├── ar_6.1.gemfile │ ├── ar_7.0.gemfile │ ├── ar_7.1.gemfile │ ├── ar_7.2.gemfile │ └── ar_8.0.gemfile ├── integration_test/ │ ├── Gemfile │ ├── docker-compose.yml │ ├── gemfiles/ │ │ ├── ar_6.1.gemfile │ │ ├── ar_7.0.gemfile │ │ └── ar_7.1.gemfile │ └── spec/ │ ├── mysql2_spec.rb │ ├── postgresql_spec.rb │ └── spec_helper.rb ├── lib/ │ ├── arproxy/ │ │ ├── base.rb │ │ ├── config.rb │ │ ├── connection_adapter_patch.rb │ │ ├── error.rb │ │ ├── plugin.rb │ │ ├── proxy.rb │ │ ├── proxy_chain.rb │ │ ├── proxy_chain_tail.rb │ │ ├── query_context.rb │ │ └── version.rb │ └── arproxy.rb └── spec/ ├── integration/ │ ├── mysql2_spec.rb │ ├── postgresql_spec.rb │ ├── shared_examples/ │ │ ├── active_record_functions.rb │ │ └── custom_proxies.rb │ ├── spec_helper.rb │ ├── sqlite3_spec.rb │ ├── sqlserver_spec.rb │ └── trilogy_spec.rb ├── lib/ │ └── arproxy/ │ └── plugin/ │ ├── legacy_plugin.rb │ ├── query_logger.rb │ └── test_plugin.rb └── unit/ ├── arproxy_spec.rb ├── config_spec.rb ├── proxy_spec.rb └── spec_helper.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/integration_test.yml ================================================ name: Integration Test on: push: branches: - master pull_request: branches: - master env: RUBY_VERSION: 3.3 jobs: mysql: continue-on-error: true runs-on: ubuntu-latest defaults: run: working-directory: ./integration_test env: BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile MYSQL_HOST: 127.0.0.1 strategy: matrix: gemfile: - ar_6.1 - ar_7.0 - ar_7.1 steps: - uses: actions/checkout@v4 - name: Start DB run: docker compose up -d mysql - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ env.RUBY_VERSION }} - name: Run bundle install run: bundle install - name: Run integration test run: bundle exec rspec spec/mysql2_spec.rb postgresql: continue-on-error: true runs-on: ubuntu-latest defaults: run: working-directory: ./integration_test env: BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile POSTGRES_HOST: 127.0.0.1 strategy: matrix: gemfile: - ar_6.1 - ar_7.0 - ar_7.1 steps: - uses: actions/checkout@v4 - name: Start DB run: docker compose up -d postgres - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ env.RUBY_VERSION }} - name: Run bundle install run: bundle install - name: Run integration test run: bundle exec rspec spec/postgresql_spec.rb ================================================ FILE: .github/workflows/integration_tests.yml ================================================ name: Integration tests on: push: branches: - main pull_request: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: integration_test: runs-on: ubuntu-latest timeout-minutes: 5 strategy: matrix: appraisal: - ar-6.1 - ar-7.0 - ar-7.1 - ar-7.2 - ar-8.0 steps: - uses: actions/checkout@v4 - name: docker compose up run: docker compose -f compose-ci.yaml up -d - name: Run integration test run: docker compose -f compose-ci.yaml exec ruby bundle exec appraisal ${{ matrix.appraisal }} rspec spec/integration/*_spec.rb ================================================ FILE: .github/workflows/rubocop.yml ================================================ name: RuboCop on: push: branches: - main pull_request: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: RUBY_VERSION: 3.3 jobs: rubocop: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ env.RUBY_VERSION }} bundler-cache: true - name: Run RuboCop run: bundle exec rubocop ================================================ FILE: .github/workflows/unit_tests.yml ================================================ name: Unit tests on: push: branches: - main pull_request: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: ubuntu-latest strategy: matrix: ruby-version: - 3.0 - 3.1 - 3.2 - 3.3 steps: - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - name: Run unit tests on ruby-${{ matrix.ruby-version }} run: bundle exec rspec spec/unit/*_spec.rb ================================================ FILE: .gitignore ================================================ *.swp *.gem Gemfile.lock *.gemfile.lock .bundle/ tmp/ db/mysql/data/* ================================================ FILE: .rspec ================================================ --color -Ispec/lib ================================================ FILE: .rubocop.yml ================================================ --- # Referenced from https://github.com/rails/rails/blob/main/.rubocop.yml require: - rubocop-md AllCops: TargetRubyVersion: 3.3 DisabledByDefault: true SuggestExtensions: false Exclude: - '**/tmp/**/*' - '**/*.gemfile' - 'vendor/**/*' # Prefer &&/|| over and/or. Style/AndOr: Enabled: true Layout/ClosingHeredocIndentation: Enabled: true Layout/ClosingParenthesisIndentation: Enabled: true # Align comments with method definitions. Layout/CommentIndentation: Enabled: true Layout/DefEndAlignment: Enabled: true Layout/ElseAlignment: Enabled: true # Align `end` with the matching keyword or starting expression except for # assignments, where it should be aligned with the LHS. Layout/EndAlignment: Enabled: true EnforcedStyleAlignWith: variable AutoCorrect: true Layout/EndOfLine: Enabled: true Layout/EmptyLineAfterMagicComment: Enabled: true Layout/EmptyLinesAroundAccessModifier: Enabled: true EnforcedStyle: around Layout/EmptyLinesAroundBlockBody: Enabled: true # In a regular class definition, no empty lines around the body. Layout/EmptyLinesAroundClassBody: Enabled: true # In a regular method definition, no empty lines around the body. Layout/EmptyLinesAroundMethodBody: Enabled: true # In a regular module definition, no empty lines around the body. Layout/EmptyLinesAroundModuleBody: Enabled: true # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. Style/HashSyntax: Enabled: true EnforcedShorthandSyntax: either # Method definitions after `private` or `protected` isolated calls need one # extra level of indentation. Layout/IndentationConsistency: Enabled: true EnforcedStyle: indented_internal_methods Exclude: - '**/*.md' # Two spaces, no tabs (for indentation). Layout/IndentationWidth: Enabled: true Layout/LeadingCommentSpace: Enabled: true Layout/SpaceAfterColon: Enabled: true Layout/SpaceAfterComma: Enabled: true Layout/SpaceAfterSemicolon: Enabled: true Layout/SpaceAroundEqualsInParameterDefault: Enabled: false Layout/SpaceAroundKeyword: Enabled: true Layout/SpaceAroundOperators: Enabled: true Layout/SpaceBeforeComma: Enabled: true Layout/SpaceBeforeComment: Enabled: true Layout/SpaceBeforeFirstArg: Enabled: true Style/DefWithParentheses: Enabled: true # Defining a method with parameters needs parentheses. Style/MethodDefParentheses: Enabled: true Style/ExplicitBlockArgument: Enabled: true Style/MapToHash: Enabled: true Style/RedundantFreeze: Enabled: true # Use `foo {}` not `foo{}`. Layout/SpaceBeforeBlockBraces: Enabled: true # Use `foo { bar }` not `foo {bar}`. Layout/SpaceInsideBlockBraces: Enabled: true EnforcedStyleForEmptyBraces: space # Use `{ a: 1 }` not `{a:1}`. Layout/SpaceInsideHashLiteralBraces: Enabled: true Layout/SpaceInsideParens: Enabled: true # Check quotes usage according to lint rule below. Style/StringLiterals: Enabled: true # Detect hard tabs, no hard tabs. Layout/IndentationStyle: Enabled: true # Empty lines should not have any spaces. Layout/TrailingEmptyLines: Enabled: true # No trailing whitespace. Layout/TrailingWhitespace: Enabled: true # Use quotes for string literals when they are enough. Style/RedundantPercentQ: Enabled: true Lint/AmbiguousOperator: Enabled: true Lint/AmbiguousRegexpLiteral: Enabled: true Lint/Debugger: Enabled: true DebuggerRequires: - debug Lint/DuplicateRequire: Enabled: true Lint/DuplicateMagicComment: Enabled: true Lint/DuplicateMethods: Enabled: true Lint/ErbNewArguments: Enabled: true Lint/EnsureReturn: Enabled: true Lint/MissingCopEnableDirective: Enabled: true # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. Lint/RequireParentheses: Enabled: true Lint/RedundantCopDisableDirective: Enabled: true Lint/RedundantCopEnableDirective: Enabled: true Lint/RedundantRequireStatement: Enabled: true Lint/RedundantStringCoercion: Enabled: true Lint/RedundantSafeNavigation: Enabled: true Lint/UriEscapeUnescape: Enabled: true Lint/UselessAssignment: Enabled: true Lint/DeprecatedClassMethods: Enabled: true Lint/InterpolationCheck: Enabled: true Lint/SafeNavigationChain: Enabled: true Style/EvalWithLocation: Enabled: false Style/ParenthesesAroundCondition: Enabled: true Style/HashTransformKeys: Enabled: true Style/HashTransformValues: Enabled: true Style/RedundantBegin: Enabled: true Style/RedundantReturn: Enabled: true AllowMultipleReturnValues: true Style/RedundantRegexpEscape: Enabled: true Style/Semicolon: Enabled: true AllowAsExpressionSeparator: true # Prefer Foo.method over Foo::method Style/ColonMethodCall: Enabled: true Style/TrivialAccessors: Enabled: true # Prefer a = b || c over a = b ? b : c Style/RedundantCondition: Enabled: true Style/RedundantDoubleSplatHashBraces: Enabled: true Style/OpenStructUse: Enabled: true Style/ArrayIntersect: Enabled: true Markdown: # Whether to run RuboCop against non-valid snippets WarnInvalid: true # Whether to lint codeblocks without code attributes Autodetect: false ================================================ FILE: Appraisals ================================================ appraise 'ar-6.1' do gem 'activerecord', '~> 6.1.0' gem 'mysql2' gem 'pg' gem 'activerecord-sqlserver-adapter' gem 'trilogy' gem 'sqlite3', '~> 1.4' # required to suppress warnings gem 'bigdecimal' gem 'base64' gem 'mutex_m' end appraise 'ar-7.0' do gem 'activerecord', '~> 7.0.0' gem 'mysql2' gem 'pg' gem 'activerecord-sqlserver-adapter' gem 'trilogy' gem 'sqlite3', '~> 1.4' # required to suppress warnings gem 'bigdecimal' gem 'base64' gem 'mutex_m' end appraise 'ar-7.1' do gem 'activerecord', '~> 7.1.0' gem 'mysql2' gem 'pg' gem 'activerecord-sqlserver-adapter' gem 'trilogy' gem 'sqlite3', '~> 2.0' end appraise 'ar-7.2' do gem 'activerecord', '~> 7.2.0' gem 'mysql2' gem 'pg' gem 'activerecord-sqlserver-adapter' gem 'trilogy' gem 'sqlite3', '~> 2.0' end appraise 'ar-8.0' do gem 'activerecord', '~> 8.0.0' gem 'mysql2' gem 'pg' gem 'activerecord-sqlserver-adapter' gem 'trilogy' gem 'sqlite3', '~> 2.1' end ================================================ FILE: ChangeLog.md ================================================ # Change Log ## 1.0.0 * Added support for ActiveRecord 7.1. * Redesigned the proxy chain to accommodate internal structure changes in ActiveRecord 7.1. * Introduced integration tests using real databases, allowing for more robust testing of functionality with MySQL, PostgreSQL, SQLite, and SQLServer. See: https://github.com/cookpad/arproxy/issues/30 ## 0.2.9 * Support ActiveRecord 7.0 (#21) Thanks to @r7kamura ## 0.2.8 * Support postgresql adapter (#19) Thanks to @jhnvz ## 0.2.7 * Support sqlserver adapter (#16) Note that it supports `AR::B.connection.execute` but not `exec_query` yet. See: https://github.com/cookpad/arproxy/pull/16 Thanks to @takanamito ## 0.2.6 * Support sqlite3 adapter (#15) Thanks to @hakatashi ## 0.2.5 * Fix against warnings around `::` in void context (#12) ## 0.2.4 * Fix against warnings around uninitialized instance variables (#12) Thanks to @amatsuda ## 0.2.3 * Set Arproxy::Config#adapter from database.yml automatically (#11) Thanks to @k0kubun ## 0.2.2 * Start supporting activerecord-5.0 and stop 3.2-4.1 ## 0.2.1 * Make ProxyChain thread-safe (#7) Thanks to @saidie ## 0.2.0 * Arproxy plugin: an easy way to make reusable proxies as gems (#6) Thanks to @winebarrel ## 0.1.3 * Silence some deprecation warnings (#1) Thanks to @amatsuda * Implement Arproxy.#enable? and Arproxy.#reenable! ## 0.1.2 * Bug fix: An error occoured when call disable! after disable! * config.adapter accepts not only String but also Class ## 0.1.1 * First Release ================================================ FILE: Dockerfile ================================================ FROM ruby:3.3 WORKDIR /app COPY lib lib COPY spec spec COPY gemfiles gemfiles COPY arproxy.gemspec arproxy.gemspec COPY Gemfile Gemfile COPY Appraisals Appraisals COPY .env .env COPY .rspec .rspec RUN apt update RUN apt install --no-install-recommends -y build-essential freetds-dev RUN bundle install RUN bundle exec appraisal install RUN mkdir -p /app/db/mysql RUN ln -s /var/lib/mysql /app/db/mysql/data # dummy command to keep the container running CMD ["sleep", "infinity"] ================================================ FILE: Gemfile ================================================ source 'https://rubygems.org' gemspec gem 'rspec' gem 'appraisal' gem 'dotenv', require: 'dotenv/load' gem 'rubocop' gem 'rubocop-md' ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2016 Issei Naruta Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [![Integration tests](https://github.com/cookpad/arproxy/actions/workflows/integration_tests.yml/badge.svg)](https://github.com/cookpad/arproxy/actions/workflows/integration_tests.yml) [![Unit tests](https://github.com/cookpad/arproxy/actions/workflows/unit_tests.yml/badge.svg)](https://github.com/cookpad/arproxy/actions/workflows/unit_tests.yml) [![Rubocop](https://github.com/cookpad/arproxy/actions/workflows/rubocop.yml/badge.svg)](https://github.com/cookpad/arproxy/actions/workflows/rubocop.yml) # 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: ```ruby 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. ```ruby # 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: ```ruby 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 ```ruby class CommentAdder < Arproxy::Proxy def execute(sql, context) sql += ' /*this_is_comment*/' super(sql, context) end end ``` ## Slow Query Logger ```ruby 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. ```ruby 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 ```ruby # 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 ``` ```ruby Arproxy.configure do |config| config.plugin :my_plugin end ``` # Upgrading guide from v0.x to v1 See [UPGRADING.md](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: * www.opensource.org/licenses/MIT ================================================ FILE: Rakefile ================================================ require 'bundler/gem_tasks' require 'rspec/core/rake_task' require 'rubocop/rake_task' RSpec::Core::RakeTask.new(:spec) RuboCop::RakeTask.new(:rubocop) task default: [:spec, :rubocop] ================================================ FILE: UPGRADING.md ================================================ # Upgrading guide from v0.x to v1 The proxy specification has changed from v0.x to v1 and is not backward compatible. The base class for proxies has changed from `Arproxy::Base` to `Arproxy::Proxy`. Also, the arguments to `#execute` have changed from `sql, name=nil, **kwargs` to `sql, context`. ```ruby # ~> v0.2.9 class MyProxy < Arproxy::Base def execute(sql, name=nil, **kwargs) super end end # >= v1.0.0 class MyProxy < Arproxy::Proxy def execute(sql, context) super end end ``` There are no other backward incompatible changes besides the above changes in proxy base class and arguments. ================================================ FILE: arproxy.gemspec ================================================ $:.push File.expand_path('../lib', __FILE__) require 'arproxy/version' Gem::Specification.new do |spec| spec.name = 'arproxy' spec.version = Arproxy::VERSION spec.summary = 'A proxy layer between ActiveRecord and database adapters' spec.description = 'Arproxy is a proxy layer that allows hooking into ActiveRecord query execution and injecting custom processing' spec.files = Dir.glob('lib/**/*.rb') spec.author = 'Issei Naruta' spec.email = 'mimitako@gmail.com' spec.homepage = 'https://github.com/cookpad/arproxy' spec.license = 'MIT' spec.require_paths = ['lib'] spec.add_dependency 'activerecord', '>= 6.1' end ================================================ FILE: compose-ci.yaml ================================================ services: ruby: build: context: . dockerfile: Dockerfile environment: MYSQL_HOST: mysql MYSQL_PORT: 3306 POSTGRES_HOST: postgres POSTGRES_PORT: 5432 MSSQL_HOST: sqlserver MSSQL_PORT: 1433 volumes: - mysql-data:/var/lib/mysql mysql: image: mysql:9.0 restart: always environment: MYSQL_ROOT_PASSWORD: rootpassword MYSQL_DATABASE: ${ARPROXY_DB_DATABASE} MYSQL_USER: ${ARPROXY_DB_USER} MYSQL_PASSWORD: ${ARPROXY_DB_PASSWORD} volumes: - ./db/mysql/my.cnf:/etc/mysql/conf.d/my.cnf - mysql-data:/var/lib/mysql ports: - "23306:3306" postgres: image: postgres:17 restart: always environment: POSTGRES_DB: ${ARPROXY_DB_DATABASE} POSTGRES_USER: ${ARPROXY_DB_USER} POSTGRES_PASSWORD: ${ARPROXY_DB_PASSWORD} ports: - "25432:5432" sqlserver: image: mcr.microsoft.com/mssql/server:2022-latest restart: always environment: ACCEPT_EULA: Y MSSQL_SA_PASSWORD: R00tPassword12! ports: - "21433:1433" healthcheck: test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P R00tPassword12! -Q 'SELECT 1' || exit 1"] interval: 5s retries: 10 start_period: 10s sqlserver-init: image: mcr.microsoft.com/mssql/server:2022-latest volumes: - ./db/sqlserver/init.sql:/init.sql command: /opt/mssql-tools18/bin/sqlcmd -C -S sqlserver -U sa -P R00tPassword12! -d master -i /init.sql depends_on: sqlserver: condition: service_healthy volumes: mysql-data: ================================================ FILE: compose.yaml ================================================ services: mysql: image: mysql:9.0 restart: always environment: MYSQL_ROOT_PASSWORD: rootpassword MYSQL_DATABASE: ${ARPROXY_DB_DATABASE} MYSQL_USER: ${ARPROXY_DB_USER} MYSQL_PASSWORD: ${ARPROXY_DB_PASSWORD} volumes: - ./db/mysql/my.cnf:/etc/mysql/conf.d/my.cnf - ./db/mysql/data:/var/lib/mysql ports: - "23306:3306" postgres: image: postgres:17 restart: always environment: POSTGRES_DB: ${ARPROXY_DB_DATABASE} POSTGRES_USER: ${ARPROXY_DB_USER} POSTGRES_PASSWORD: ${ARPROXY_DB_PASSWORD} ports: - "25432:5432" sqlserver: image: mcr.microsoft.com/mssql/server:2022-latest restart: always environment: ACCEPT_EULA: Y MSSQL_SA_PASSWORD: R00tPassword12! ports: - "21433:1433" healthcheck: test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P R00tPassword12! -Q 'SELECT 1' || exit 1"] interval: 5s retries: 10 start_period: 10s sqlserver-init: image: mcr.microsoft.com/mssql/server:2022-latest volumes: - ./db/sqlserver/init.sql:/init.sql command: /opt/mssql-tools18/bin/sqlcmd -C -S sqlserver -U sa -P R00tPassword12! -d master -i /init.sql depends_on: sqlserver: condition: service_healthy ================================================ FILE: db/mysql/my.cnf ================================================ [mysqld] tls-version=TLSv1.2,TLSv1.3 auto_generate_certs = ON ================================================ FILE: db/sqlserver/init.sql ================================================ CREATE DATABASE arproxy_test; GO USE arproxy_test; GO CREATE LOGIN arproxy WITH PASSWORD = '4rpr0*y#2024'; GO CREATE USER arproxy FOR LOGIN arproxy; GO ALTER ROLE db_owner ADD MEMBER arproxy; GO ================================================ FILE: gemfiles/ar_6.1.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rspec" gem "appraisal" gem "dotenv", require: "dotenv/load" gem "rubocop" gem "rubocop-md" gem "activerecord", "~> 6.1.0" gem "mysql2" gem "pg" gem "activerecord-sqlserver-adapter" gem "trilogy" gem "sqlite3", "~> 1.4" gem "bigdecimal" gem "base64" gem "mutex_m" gemspec path: "../" ================================================ FILE: gemfiles/ar_7.0.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rspec" gem "appraisal" gem "dotenv", require: "dotenv/load" gem "rubocop" gem "rubocop-md" gem "activerecord", "~> 7.0.0" gem "mysql2" gem "pg" gem "activerecord-sqlserver-adapter" gem "trilogy" gem "sqlite3", "~> 1.4" gem "bigdecimal" gem "base64" gem "mutex_m" gemspec path: "../" ================================================ FILE: gemfiles/ar_7.1.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rspec" gem "appraisal" gem "dotenv", require: "dotenv/load" gem "rubocop" gem "rubocop-md" gem "activerecord", "~> 7.1.0" gem "mysql2" gem "pg" gem "activerecord-sqlserver-adapter" gem "trilogy" gem "sqlite3", "~> 2.0" gemspec path: "../" ================================================ FILE: gemfiles/ar_7.2.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rspec" gem "appraisal" gem "dotenv", require: "dotenv/load" gem "rubocop" gem "rubocop-md" gem "activerecord", "~> 7.2.0" gem "mysql2" gem "pg" gem "activerecord-sqlserver-adapter" gem "trilogy" gem "sqlite3", "~> 2.0" gemspec path: "../" ================================================ FILE: gemfiles/ar_8.0.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rspec" gem "appraisal" gem "dotenv", require: "dotenv/load" gem "rubocop" gem "rubocop-md" gem "activerecord", "~> 8.0.0" gem "mysql2" gem "pg" gem "activerecord-sqlserver-adapter" gem "trilogy" gem "sqlite3", "~> 2.1" gemspec path: "../" ================================================ FILE: integration_test/Gemfile ================================================ source 'https://rubygems.org' gem 'arproxy', path: '..' gem 'rspec' gem 'appraisal' gem 'mysql2' gem 'pg' ================================================ FILE: integration_test/docker-compose.yml ================================================ version: '3' services: mysql: image: mysql:9.0 restart: always environment: MYSQL_ROOT_PASSWORD: rootpassword MYSQL_DATABASE: arproxy_test MYSQL_USER: arproxy MYSQL_PASSWORD: password ports: - "23306:3306" postgres: image: postgres:16 restart: always environment: POSTGRES_DB: arproxy_test POSTGRES_USER: arproxy POSTGRES_PASSWORD: password ports: - "25432:5432" ================================================ FILE: integration_test/gemfiles/ar_6.1.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "arproxy", path: "../.." gem "rspec" gem "appraisal" gem "mysql2" gem "pg" gem "activerecord", "~> 6.1.0" ================================================ FILE: integration_test/gemfiles/ar_7.0.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "arproxy", path: "../.." gem "rspec" gem "appraisal" gem "mysql2" gem "pg" gem "activerecord", "~> 7.0.0" ================================================ FILE: integration_test/gemfiles/ar_7.1.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "arproxy", path: "../.." gem "rspec" gem "appraisal" gem "mysql2" gem "pg" gem "activerecord", "~> 7.1.0" ================================================ FILE: integration_test/spec/mysql2_spec.rb ================================================ require_relative 'spec_helper' require 'mysql2' context 'MySQL' do before(:all) do ActiveRecord::Base.establish_connection( adapter: 'mysql2', host: ENV.fetch('MYSQL_HOST', '127.0.0.1'), port: ENV.fetch('MYSQL_PORT', '23306').to_i, database: 'arproxy_test', username: 'arproxy', password: 'password' ) Arproxy.configure do |config| config.adapter = 'mysql2' config.use HelloProxy config.use QueryLogger end Arproxy.enable! ActiveRecord::Base.connection.create_table :products, force: true do |t| t.string :name t.integer :price end Product.create(name: 'apple', price: 100) Product.create(name: 'banana', price: 200) Product.create(name: 'orange', price: 300) end after(:all) do ActiveRecord::Base.connection.drop_table :products ActiveRecord::Base.connection.close Arproxy.disable! end before(:each) do QueryLogger.reset! end it do expect(QueryLogger.log.size).to eq(0) expect(Product.count).to eq(3) expect(Product.first.name).to eq('apple') expect(QueryLogger.log.size).to eq(2) expect(QueryLogger.log[0]).to eq('SELECT COUNT(*) FROM `products` -- Hello Arproxy!') expect(QueryLogger.log[1]).to eq('SELECT `products`.* FROM `products` ORDER BY `products`.`id` ASC LIMIT 1 -- Hello Arproxy!') end end ================================================ FILE: integration_test/spec/postgresql_spec.rb ================================================ require_relative 'spec_helper' require 'pg' context 'PostgreSQL' do before(:all) do ActiveRecord::Base.establish_connection( adapter: 'postgresql', host: ENV.fetch('POSTGRES_HOST', '127.0.0.1'), port: ENV.fetch('POSTGRES_PORT', '25432').to_i, database: 'arproxy_test', username: 'arproxy', password: 'password' ) Arproxy.configure do |config| config.adapter = 'postgresql' config.use HelloProxy config.use QueryLogger end Arproxy.enable! ActiveRecord::Base.connection.create_table :products, force: true do |t| t.string :name t.integer :price end Product.create(name: 'apple', price: 100) Product.create(name: 'banana', price: 200) Product.create(name: 'orange', price: 300) end after(:all) do ActiveRecord::Base.connection.drop_table :products ActiveRecord::Base.connection.close Arproxy.disable! end before(:each) do QueryLogger.reset! end it do expect(QueryLogger.log.size).to eq(0) expect(Product.count).to eq(3) expect(Product.first.name).to eq('apple') expect(QueryLogger.log.size).to eq(2) expect(QueryLogger.log[0]).to eq('SELECT COUNT(*) FROM `products` -- Hello Arproxy!') expect(QueryLogger.log[1]).to eq('SELECT `products`.* FROM `products` ORDER BY `products`.`id` ASC LIMIT 1 -- Hello Arproxy!') end end ================================================ FILE: integration_test/spec/spec_helper.rb ================================================ require 'arproxy' require 'active_record' class Product < ActiveRecord::Base end class QueryLogger < Arproxy::Base def execute(sql, name = nil) @@log ||= [] @@log << sql puts "QueryLogger: #{sql}" super end def self.log @@log end def self.reset! @@log = [] end end class HelloProxy < Arproxy::Base def execute(sql, name = nil) super("#{sql} -- Hello Arproxy!", name) end end ================================================ FILE: lib/arproxy/base.rb ================================================ module Arproxy # This class is no longer used since Arproxy v1. class Base end end ================================================ FILE: lib/arproxy/config.rb ================================================ require 'active_record' require 'active_record/base' require 'arproxy/base' require 'arproxy/error' module Arproxy class Config attr_accessor :adapter, :logger attr_reader :proxies def initialize @proxies = [] if defined?(Rails) @adapter = Rails.application.config_for(:database)['adapter'] end end def use(proxy_class, *options) if proxy_class.is_a?(Class) && proxy_class.ancestors.include?(Arproxy::Base) raise Arproxy::Error, "Error on loading a proxy `#{proxy_class.inspect}`: the superclass `Arproxy::Base` is no longer supported since Arproxy v1. Use `Arproxy::Proxy` instead. See: https://github.com/cookpad/arproxy/blob/main/UPGRADING.md" end ::Arproxy.logger.debug("Arproxy: Mounting #{proxy_class.inspect} (#{options.inspect})") @proxies << [proxy_class, options] end def plugin(name, *options) plugin_class = Plugin.get(name) if plugin_class.is_a?(Class) && plugin_class.ancestors.include?(Arproxy::Base) raise Arproxy::Error, "Error on loading a plugin `#{plugin_class.inspect}`: the superclass `Arproxy::Base` is no longer supported since Arproxy v1. Use `Arproxy::Proxy` instead. See: https://github.com/cookpad/arproxy/blob/main/UPGRADING.md" end use(plugin_class, *options) end def adapter_class raise Arproxy::Error, 'config.adapter must be set' unless @adapter case @adapter when String, Symbol eval "::ActiveRecord::ConnectionAdapters::#{camelized_adapter_name}Adapter" when Class @adapter else raise Arproxy::Error, "unexpected config.adapter: #{@adapter}" end end private def camelized_adapter_name adapter_name = @adapter.to_s.split('_').map(&:capitalize).join case adapter_name when 'Sqlite3' 'SQLite3' when 'Sqlserver' 'SQLServer' when 'Postgresql' 'PostgreSQL' else adapter_name end end end end ================================================ FILE: lib/arproxy/connection_adapter_patch.rb ================================================ module Arproxy class ConnectionAdapterPatch attr_reader :adapter_class def initialize(adapter_class) @adapter_class = adapter_class @applied_patches = Set.new end def self.register_patches(adapter_name, patches: [], binds_patches: []) @@patches ||= {} @@patches[adapter_name] = { patches: patches, binds_patches: binds_patches } end if ActiveRecord.version >= Gem::Version.new('8.0') register_patches('Mysql2', patches: [], binds_patches: [:raw_execute]) register_patches('Trilogy', patches: [], binds_patches: [:raw_execute]) elsif ActiveRecord.version >= Gem::Version.new('7.0') register_patches('Mysql2', patches: [:raw_execute], binds_patches: []) register_patches('Trilogy', patches: [:raw_execute], binds_patches: []) else register_patches('Mysql2', patches: [:execute], binds_patches: []) register_patches('Trilogy', patches: [:raw_execute], binds_patches: []) end if ActiveRecord.version >= Gem::Version.new('8.0') register_patches('PostgreSQL', patches: [], binds_patches: [:raw_execute]) register_patches('SQLServer', patches: [], binds_patches: [:raw_execute]) register_patches('SQLite', patches: [], binds_patches: [:raw_execute]) elsif ActiveRecord.version >= Gem::Version.new('7.1') register_patches('PostgreSQL', patches: [:raw_execute], binds_patches: [:exec_no_cache, :exec_cache]) register_patches('SQLServer', patches: [:raw_execute], binds_patches: [:internal_exec_query]) register_patches('SQLite', patches: [:raw_execute], binds_patches: [:internal_exec_query]) else register_patches('PostgreSQL', patches: [:execute], binds_patches: [:exec_no_cache, :exec_cache]) register_patches('SQLServer', patches: [:execute], binds_patches: [:exec_query]) register_patches('SQLite', patches: [:execute], binds_patches: [:exec_query]) end def enable! patches = @@patches[adapter_class::ADAPTER_NAME] if patches patches[:patches]&.each do |patch| apply_patch patch end patches[:binds_patches]&.each do |binds_patch| apply_patch_binds binds_patch end else raise Arproxy::Error, "Unexpected connection adapter: patches not registered for #{adapter_class&.name}" end ::Arproxy.logger.debug("Arproxy: Enabled (#{adapter_class::ADAPTER_NAME})") end def disable! @applied_patches.dup.each do |target_method| adapter_class.class_eval do if instance_methods.include?(:"#{target_method}_with_arproxy") alias_method target_method, :"#{target_method}_without_arproxy" remove_method :"#{target_method}_with_arproxy" end end @applied_patches.delete(target_method) end ::Arproxy.logger.debug("Arproxy: Disabled (#{adapter_class::ADAPTER_NAME})") end private def apply_patch(target_method) return if @applied_patches.include?(target_method) adapter_class.class_eval do raw_execute_method_name = :"#{target_method}_without_arproxy" patched_execute_method_name = :"#{target_method}_with_arproxy" break if instance_methods.include?(patched_execute_method_name) define_method(patched_execute_method_name) do |sql, name=nil, **kwargs| context = QueryContext.new( raw_connection: self, execute_method_name: raw_execute_method_name, with_binds: false, name: name, kwargs: kwargs, ) ::Arproxy.proxy_chain.head.execute(sql, context) end alias_method raw_execute_method_name, target_method alias_method target_method, patched_execute_method_name end @applied_patches << target_method end def apply_patch_binds(target_method) return if @applied_patches.include?(target_method) adapter_class.class_eval do raw_execute_method_name = :"#{target_method}_without_arproxy" patched_execute_method_name = :"#{target_method}_with_arproxy" break if instance_methods.include?(patched_execute_method_name) define_method(patched_execute_method_name) do |sql, name=nil, binds=[], **kwargs| context = QueryContext.new( raw_connection: self, execute_method_name: raw_execute_method_name, with_binds: true, name: name, binds: binds, kwargs: kwargs, ) ::Arproxy.proxy_chain.head.execute(sql, context) end alias_method raw_execute_method_name, target_method alias_method target_method, patched_execute_method_name end @applied_patches << target_method end end end ================================================ FILE: lib/arproxy/error.rb ================================================ module Arproxy class Error < Exception end end ================================================ FILE: lib/arproxy/plugin.rb ================================================ module Arproxy module Plugin class << self def register(name, klass) name = name.to_s @plugins ||= {} if @plugins.has_key?(name) raise Arproxy::Error, "Plugin has already been registered: #{name}" end @plugins[name] = klass end def get(name) name = name.to_s require "arproxy/plugin/#{name}" plugin = @plugins[name] unless plugin raise Arproxy::Error, "Plugin is not found: #{name}" end plugin end end end end ================================================ FILE: lib/arproxy/proxy.rb ================================================ require 'arproxy/query_context' module Arproxy class Proxy attr_accessor :context, :next_proxy def execute(sql, context) unless context.instance_of?(QueryContext) raise Arproxy::Error, "`context` is expected a `Arproxy::QueryContext` but got `#{context.class}`" end next_proxy.execute(sql, context) end end end ================================================ FILE: lib/arproxy/proxy_chain.rb ================================================ require 'arproxy/proxy_chain_tail' require 'arproxy/connection_adapter_patch' module Arproxy class ProxyChain attr_reader :head, :tail, :patch def initialize(config, patch) @config = config @patch = patch setup end def setup @tail = ProxyChainTail.new @head = @config.proxies.reverse.inject(@tail) do |next_proxy, proxy_config| cls, options = proxy_config proxy = cls.new(*options) proxy.next_proxy = next_proxy proxy end end private :setup def reenable! disable! setup enable! end def enable! @patch.enable! end def disable! @patch.disable! end end end ================================================ FILE: lib/arproxy/proxy_chain_tail.rb ================================================ require 'arproxy/proxy' require 'arproxy/query_context' module Arproxy class ProxyChainTail < Proxy def execute(sql, context) unless context.instance_of?(QueryContext) raise Arproxy::Error, "`context` is expected a `Arproxy::QueryContext` but got `#{context.class}`" end if context.with_binds? context.raw_connection.send(context.execute_method_name, sql, context.name, context.binds, **context.kwargs) else context.raw_connection.send(context.execute_method_name, sql, context.name, **context.kwargs) end end end end ================================================ FILE: lib/arproxy/query_context.rb ================================================ module Arproxy class QueryContext attr_accessor :raw_connection, :execute_method_name, :with_binds, :name, :binds, :kwargs def initialize(raw_connection:, execute_method_name:, with_binds:, name: nil, binds: [], kwargs: {}) @raw_connection = raw_connection @execute_method_name = execute_method_name @with_binds = with_binds @name = name @binds = binds @kwargs = kwargs end def with_binds? !!@with_binds end end end ================================================ FILE: lib/arproxy/version.rb ================================================ module Arproxy VERSION = '1.0.0' end ================================================ FILE: lib/arproxy.rb ================================================ require 'logger' require 'arproxy/base' require 'arproxy/config' require 'arproxy/connection_adapter_patch' require 'arproxy/proxy_chain' require 'arproxy/error' require 'arproxy/plugin' module Arproxy @config = nil @enabled = nil @patch = nil module_function def clear_configuration @config = nil end def configure @config ||= Config.new yield @config end def enable! if enable? Arproxy.logger.warn 'Arproxy has already been enabled' return end unless @config raise Arproxy::Error, 'Arproxy has not been configured' end @patch = ConnectionAdapterPatch.new(@config.adapter_class) @proxy_chain = ProxyChain.new(@config, @patch) @proxy_chain.enable! @enabled = true end def disable! unless enable? Arproxy.logger.warn 'Arproxy is not enabled yet' return end if @proxy_chain @proxy_chain.disable! @proxy_chain = nil end @enabled = false end def enable? !!@enabled end def reenable! if enable? @proxy_chain.reenable! else enable! end end def logger @logger ||= @config && @config.logger || defined?(::Rails) && ::Rails.logger || ::Logger.new(STDOUT) end def proxy_chain @proxy_chain end def connection_adapter_patch @patch end end ================================================ FILE: spec/integration/mysql2_spec.rb ================================================ require_relative './spec_helper' context "MySQL (AR#{ar_version})" do before(:all) do host = ENV.fetch('MYSQL_HOST', '127.0.0.1') port = ENV.fetch('MYSQL_PORT', '23306').to_i wait_for_db(host, port) ActiveRecord::Base.establish_connection( adapter: 'mysql2', host: host, port: port, database: 'arproxy_test', username: 'arproxy', password: ENV.fetch('ARPROXY_DB_PASSWORD') ) Arproxy.configure do |config| config.adapter = 'mysql2' config.use HelloProxy config.plugin :query_logger end Arproxy.enable! end after(:all) do cleanup_activerecord Arproxy.disable! Arproxy.clear_configuration end it_behaves_like 'Arproxy does not break the original ActiveRecord functionality' it_behaves_like 'Custom proxies work expectedly' end ================================================ FILE: spec/integration/postgresql_spec.rb ================================================ require_relative './spec_helper' context "PostgreSQL (AR#{ar_version})" do before(:all) do host = ENV.fetch('POSTGRES_HOST', '127.0.0.1') port = ENV.fetch('POSTGRES_PORT', '25432').to_i wait_for_db(host, port) ActiveRecord::Base.establish_connection( adapter: 'postgresql', host: host, port: port, database: 'arproxy_test', username: 'arproxy', password: ENV.fetch('ARPROXY_DB_PASSWORD') ) Arproxy.configure do |config| config.adapter = 'postgresql' config.use HelloProxy config.plugin :query_logger end Arproxy.enable! end after(:all) do cleanup_activerecord Arproxy.disable! Arproxy.clear_configuration end it_behaves_like 'Arproxy does not break the original ActiveRecord functionality' it_behaves_like 'Custom proxies work expectedly' end ================================================ FILE: spec/integration/shared_examples/active_record_functions.rb ================================================ RSpec.shared_examples 'Arproxy does not break the original ActiveRecord functionality' do before do # CREATE ActiveRecord::Base.connection.create_table :products, force: true do |t| t.string :name t.integer :price end # INSERT Product.create!(name: 'apple', price: 100) Product.create!(name: 'banana', price: 200) Product.create!(name: 'orange', price: 300) end after(:all) do ActiveRecord::Base.connection.drop_table :products end context 'SELECT' do it { expect(Product.where(name: ['apple', 'orange']).sum(:price)).to eq(400) } end context 'UPDATE' do it do expect { Product.where(name: 'banana').update_all(price: 1000) }.to change { Product.find_by!(name: 'banana').price }.from(200).to(1000) end end context 'DELETE' do it do expect { Product.where(name: 'banana').delete_all }.to change { Product.where(name: 'banana').exists? }.from(true).to(false) end end end ================================================ FILE: spec/integration/shared_examples/custom_proxies.rb ================================================ class Product < ActiveRecord::Base end class HelloLegacyProxy < Arproxy::Base def execute(sql, name = nil) super("#{sql} -- Hello Legacy Arproxy!", name) end end class HelloProxy < Arproxy::Proxy def execute(sql, context) super("#{sql} -- Hello Arproxy!", context) end end RSpec::Matchers.define :add_query_log do |log_line_regex| supports_block_expectations match do |block| idx = QueryLogger.log.size block.call QueryLogger.log.size > idx && QueryLogger.log[idx..-1].any? { |log| log.match(log_line_regex) } end failure_message do |block| "expected to add query log matching #{log_line_regex.inspect}, but got #{QueryLogger.log.inspect}" end failure_message_when_negated do |block| "expected not to add query log matching #{log_line_regex.inspect}, but added" end def supports_block_expectations? true end end RSpec.shared_examples 'Custom proxies work expectedly' do before do ActiveRecord::Base.connection.create_table :products, force: true do |t| t.string :name t.integer :price end Product.create(name: 'apple', price: 100) Product.create(name: 'banana', price: 200) Product.create(name: 'orange', price: 300) QueryLogger.reset! end after(:all) do ActiveRecord::Base.connection.drop_table :products end around do |example| ActiveRecord::Base.uncached do example.run end end context 'CREATE TABLE' do it do expect { ActiveRecord::Base.connection.create_table :products, force: true do |t| t.string :name t.integer :price end }.to add_query_log(/^CREATE TABLE.*products.* -- Hello Arproxy!$/) end end context 'SELECT' do it do expect { Product.where(name: ['apple', 'orange']).sum(:price) }.to add_query_log(/^SELECT.*products.* -- Hello Arproxy!$/) end end context 'INSERT' do it do expect { Product.create(name: 'grape', price: 400) }.to add_query_log(/^INSERT INTO.*products.* -- Hello Arproxy!$/) end end context 'UPDATE' do it do expect { Product.where(name: 'banana').update_all(price: 1000) }.to add_query_log(/^UPDATE.*products.* -- Hello Arproxy!$/) end end context 'DELETE' do it do expect { Product.where(name: 'banana').delete_all }.to add_query_log(/^DELETE.*products.* -- Hello Arproxy!$/) end end end ================================================ FILE: spec/integration/spec_helper.rb ================================================ require 'arproxy' require 'active_record' require 'dotenv/load' require_relative './shared_examples/custom_proxies' require_relative './shared_examples/active_record_functions' Arproxy.logger.level = Logger::WARN unless ENV['DEBUG'] def ar_version "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" end def cleanup_activerecord ActiveRecord::Base.connection.close ActiveRecord::Base.connection.clear_cache! ActiveRecord::Base.descendants.each(&:reset_column_information) ActiveRecord::Base.connection.schema_cache.clear! end def wait_for_db(host, port, interval = 0.2, timeout = 10) print "\nWaiting for DB on #{host}:#{port}..." if ENV['DEBUG'] Timeout.timeout(timeout) do loop do TCPSocket.new(host, port).close puts 'ok' if ENV['DEBUG'] break rescue Errno::ECONNREFUSED print '.' if ENV['DEBUG'] sleep interval end end rescue Timeout::Error raise "Timeout waiting for DB on #{host}:#{port}" end ================================================ FILE: spec/integration/sqlite3_spec.rb ================================================ require_relative './spec_helper' context "SQLite3 (AR#{ar_version})" do before(:all) do ActiveRecord::Base.establish_connection( adapter: 'sqlite3', database: ':memory:' ) Arproxy.configure do |config| config.adapter = 'sqlite3' config.use HelloProxy config.plugin :query_logger end Arproxy.enable! end after(:all) do cleanup_activerecord Arproxy.disable! Arproxy.clear_configuration end it_behaves_like 'Arproxy does not break the original ActiveRecord functionality' it_behaves_like 'Custom proxies work expectedly' end ================================================ FILE: spec/integration/sqlserver_spec.rb ================================================ require_relative './spec_helper' context "SQLServer (AR#{ar_version})" do before(:all) do if ActiveRecord.version >= Gem::Version.new('7.2') ActiveRecord::ConnectionAdapters.register( 'sqlserver', 'ActiveRecord::ConnectionAdapters::SQLServerAdapter', 'active_record/connection_adapters/sqlserver_adapter' ) end host = ENV.fetch('MSSQL_HOST', '127.0.0.1') port = ENV.fetch('MSSQL_PORT', '21433').to_i wait_for_db(host, port) ActiveRecord::Base.establish_connection( adapter: 'sqlserver', host: host, port: port, database: 'arproxy_test', username: 'arproxy', password: ENV.fetch('ARPROXY_DB_PASSWORD') ) Arproxy.configure do |config| config.adapter = 'sqlserver' config.use HelloProxy config.plugin :query_logger end Arproxy.enable! end after(:all) do cleanup_activerecord Arproxy.disable! Arproxy.clear_configuration end it_behaves_like 'Arproxy does not break the original ActiveRecord functionality' it_behaves_like 'Custom proxies work expectedly' end ================================================ FILE: spec/integration/trilogy_spec.rb ================================================ require_relative './spec_helper' require 'trilogy' context "Trilogy (AR#{ar_version})", if: ActiveRecord.version >= '7.1' do before(:all) do host = ENV.fetch('MYSQL_HOST', '127.0.0.1') port = ENV.fetch('MYSQL_PORT', '23306').to_i wait_for_db(host, port) mysql_data_dir = File.expand_path('../../db/mysql/data', __dir__) ActiveRecord::Base.establish_connection( adapter: 'trilogy', host: host, port: port, ssl: true, ssl_mode: Trilogy::SSL_VERIFY_CA, tls_min_version: Trilogy::TLS_VERSION_12, ssl_ca: File.join(mysql_data_dir, 'ca.pem'), ssl_cert: File.join(mysql_data_dir, 'client-cert.pem'), ssl_key: File.join(mysql_data_dir, 'client-key.pem'), database: 'arproxy_test', username: 'arproxy', password: ENV.fetch('ARPROXY_DB_PASSWORD') ) Arproxy.configure do |config| config.adapter = 'trilogy' config.use HelloProxy config.plugin :query_logger end Arproxy.enable! end after(:all) do cleanup_activerecord Arproxy.disable! Arproxy.clear_configuration end it_behaves_like 'Arproxy does not break the original ActiveRecord functionality' it_behaves_like 'Custom proxies work expectedly' end ================================================ FILE: spec/lib/arproxy/plugin/legacy_plugin.rb ================================================ module Arproxy::Plugin class LegacyPlugin < Arproxy::Base Arproxy::Plugin.register(:legacy_plugin, self) def execute(sql, name) super("#{sql} /* legacy_plugin */", name) end end end ================================================ FILE: spec/lib/arproxy/plugin/query_logger.rb ================================================ require 'arproxy/plugin' class QueryLogger < Arproxy::Proxy Arproxy::Plugin.register(:query_logger, self) def execute(sql, context) @@log ||= [] @@log << sql if ENV['DEBUG'] puts "QueryLogger: [#{context.name}] #{sql}" end super end def self.log @@log end def self.reset! @@log = [] end end ================================================ FILE: spec/lib/arproxy/plugin/test_plugin.rb ================================================ module Arproxy::Plugin class TestPlugin < Arproxy::Proxy Arproxy::Plugin.register(:test_plugin, self) def initialize(*options) @options = options end def execute(sql, context) context.name = "#{context.name}_PLUGIN" super("#{sql} /* options: #{@options.inspect} */", context) end end end ================================================ FILE: spec/unit/arproxy_spec.rb ================================================ require_relative './spec_helper' describe Arproxy do before do allow(Arproxy).to receive(:logger) { Logger.new('/dev/null') } end class LegacyProxyA < Arproxy::Base def execute(sql, name) super "#{sql}_A", "#{name}_A" end end class LegacyProxyB < Arproxy::Base def initialize(opt=nil) @opt = opt end def execute(sql, name) super "#{sql}_B#{@opt}", "#{name}_B#{@opt}" end end class ProxyA < Arproxy::Proxy def execute(sql, context) context.name = "#{context.name}_A" super "#{sql}_A", context end end class ProxyB < Arproxy::Proxy def initialize(opt=nil) @opt = opt end def execute(sql, context) context.name = "#{context.name}_B#{@opt}" super "#{sql}_B#{@opt}", context end end module ::ActiveRecord module ConnectionAdapters class DummyAdapter ADAPTER_NAME = 'Dummy' def execute1(sql, name = nil, **kwargs) { sql: sql, name: name, kwargs: kwargs } end def execute2(sql, name = nil, binds = [], **kwargs) { sql: sql, name: name, binds: binds, kwargs: kwargs } end end Arproxy::ConnectionAdapterPatch.register_patches('Dummy', patches: [:execute1], binds_patches: [:execute2]) end end let(:connection) { ::ActiveRecord::ConnectionAdapters::DummyAdapter.new } after(:each) do Arproxy.disable! end context 'with a proxy' do before do Arproxy.clear_configuration Arproxy.configure do |config| config.adapter = 'dummy' config.use ProxyA end Arproxy.enable! end it { expect(connection.execute1('SQL', 'NAME')).to eq({ sql: 'SQL_A', name: 'NAME_A', kwargs: {} }) } it { expect(connection.execute1('SQL', 'NAME', a: 1, b: 2)).to eq({ sql: 'SQL_A', name: 'NAME_A', kwargs: { a: 1, b: 2 } }) } it { expect(connection.execute2('SQL', 'NAME')).to eq({ sql: 'SQL_A', name: 'NAME_A', binds: [], kwargs: {} }) } it do expect( connection.execute2('SQL', 'NAME', [:x, :y], a: 1, b: 2) ).to eq( { sql: 'SQL_A', name: 'NAME_A', binds: [:x, :y], kwargs: { a: 1, b: 2 } } ) end end context 'with 2 proxies' do before do Arproxy.clear_configuration Arproxy.configure do |config| config.adapter = 'dummy' config.use ProxyA config.use ProxyB end Arproxy.enable! end it { expect(connection.execute1('SQL', 'NAME')).to eq({ sql: 'SQL_A_B', name: 'NAME_A_B', kwargs: {} }) } end context 'with 2 proxies which have an option' do before do Arproxy.clear_configuration Arproxy.configure do |config| config.adapter = 'dummy' config.use ProxyA config.use ProxyB, 1 end Arproxy.enable! end it { expect(connection.execute1('SQL', 'NAME')).to eq({ sql: 'SQL_A_B1', name: 'NAME_A_B1', kwargs: {} }) } end context 'with a proxy that returns nil' do class ReadonlyAccess < Arproxy::Proxy def execute(sql, context) if sql =~ /^(SELECT)\b/ super sql, context else nil end end end before do Arproxy.clear_configuration Arproxy.configure do |config| config.adapter = 'dummy' config.use ReadonlyAccess end Arproxy.enable! end it { expect(connection.execute1('SELECT 1', 'NAME')).to eq({ sql: 'SELECT 1', name: 'NAME', kwargs: {} }) } it { expect(connection.execute1('UPDATE foo SET bar = 1', 'NAME')).to eq(nil) } end context 'with a legacy proxy' do class LegacyProxy < Arproxy::Base def execute(sql, name) super("#{sql} /* legacy_proxy */", name) end end before do Arproxy.clear_configuration end it 'raises an error' do expect { Arproxy.configure do |config| config.adapter = 'dummy' config.use LegacyProxy end }.to raise_error(Arproxy::Error, /Use `Arproxy::Proxy` instead/) end end context 'calls #execute with an String argument instead of `context`' do class WrongProxy < Arproxy::Proxy def execute(sql, context) super("#{sql} /* my_proxy */", "name=#{context.name}") end end before do Arproxy.clear_configuration Arproxy.configure do |config| config.adapter = 'dummy' config.use WrongProxy end Arproxy.enable! end it do expect { connection.execute1('SQL', 'NAME') }.to raise_error(Arproxy::Error, /expected a `Arproxy::QueryContext`/) end end context do before do Arproxy.clear_configuration Arproxy.configure do |config| config.adapter = 'dummy' config.use ProxyA end end context 'enable -> disable' do before do Arproxy.enable! Arproxy.disable! end it { expect(connection.execute1('SQL', 'NAME')).to eq({ sql: 'SQL', name: 'NAME', kwargs: {} }) } end context 'enable -> enable' do before do Arproxy.enable! Arproxy.enable! end it { expect(connection.execute1('SQL', 'NAME')).to eq({ sql: 'SQL_A', name: 'NAME_A', kwargs: {} }) } end context 'enable -> disable -> disable' do before do Arproxy.enable! Arproxy.disable! Arproxy.disable! end it { expect(connection.execute1('SQL', 'NAME')).to eq({ sql: 'SQL', name: 'NAME', kwargs: {} }) } end context 'clear_configuration -> enable' do before do Arproxy.clear_configuration end it do expect { Arproxy.enable! }.to raise_error(Arproxy::Error, /Arproxy has not been configured/) end end context 'enable -> disable -> enable' do before do Arproxy.enable! Arproxy.disable! Arproxy.enable! end it { expect(connection.execute1('SQL', 'NAME')).to eq({ sql: 'SQL_A', name: 'NAME_A', kwargs: {} }) } end context 're-configure' do before do Arproxy.configure do |config| config.adapter = 'dummy' config.use ProxyB end Arproxy.enable! end it { expect(connection.execute1('SQL', 'NAME')).to eq({ sql: 'SQL_A_B', name: 'NAME_A_B', kwargs: {} }) } end end context 'use a plug-in' do before do Arproxy.clear_configuration Arproxy.configure do |config| config.adapter = 'dummy' config.plugin :test_plugin, :option_a, :option_b end Arproxy.enable! end it do expect( connection.execute1('SQL', 'NAME') ).to eq( { sql: 'SQL /* options: [:option_a, :option_b] */', name: 'NAME_PLUGIN', kwargs: {} } ) end end context 'use a legacy plugin' do before do Arproxy.clear_configuration end it 'raises an error' do expect { Arproxy.configure do |config| config.adapter = 'dummy' config.plugin :legacy_plugin end }.to raise_error(Arproxy::Error, /Use `Arproxy::Proxy` instead/) end end end ================================================ FILE: spec/unit/config_spec.rb ================================================ require_relative './spec_helper' describe Arproxy::Config do describe '#adapter default value' do subject { Arproxy::Config.new.adapter } context 'when Rails is defined' do let(:rails) { Module.new } around do |example| Object.const_set('Rails', rails) example.run Object.send(:remove_const, 'Rails') end before do allow(rails).to receive_message_chain('application.config_for') { database_config } end context 'when adapter is configured in database.yml' do let(:database_config) { { 'adapter' => 'mysql2' } } it { should == 'mysql2' } end context "when adapter isn't configured in database.yml" do let(:database_config) { {} } it { should == nil } end end context "when Rails isn't defined" do it { should == nil } end end describe '#adapter_class' do subject { config.adapter_class } let(:config) { Arproxy::Config.new } before do config.adapter = adapter end context "when adapter is configured as 'mysql2'" do let(:adapter) { 'mysql2' } let(:mysql2_class) { Class.new } before do stub_const('ActiveRecord::ConnectionAdapters::Mysql2Adapter', mysql2_class) end it { should == mysql2_class } end context "when adapter is configured as 'sqlite3'" do let(:adapter) { 'sqlite3' } let(:sqlite3_class) { Class.new } before do stub_const('ActiveRecord::ConnectionAdapters::SQLite3Adapter', sqlite3_class) end it { should == sqlite3_class } end context "when adapter is configured as 'sqlserver'" do let(:adapter) { 'sqlserver' } let(:sqlserver_class) { Class.new } before do stub_const('ActiveRecord::ConnectionAdapters::SQLServerAdapter', sqlserver_class) end it { should == sqlserver_class } end context "when adapter is configured as 'postgresql'" do let(:adapter) { 'postgresql' } let(:postgresql_class) { Class.new } before do stub_const('ActiveRecord::ConnectionAdapters::PostgreSQLAdapter', postgresql_class) end it { should == postgresql_class } end end end ================================================ FILE: spec/unit/proxy_spec.rb ================================================ require_relative './spec_helper' require 'arproxy/proxy_chain_tail' require 'arproxy/proxy' require 'arproxy/query_context' describe Arproxy::Proxy do before(:all) do class DummyConnectionAdapter def execute(sql, name = nil, binds = [], **kwargs) "#{sql}" end end class Proxy1 < Arproxy::Proxy def execute(sql, context) super("#{sql} /* Proxy1 */", context) end end class Proxy2 < Arproxy::Proxy def execute(sql, context) super("#{sql} /* Proxy2 */", context) end end tail = Arproxy::ProxyChainTail.new p2 = Proxy2.new p2.next_proxy = tail p1 = Proxy1.new p1.next_proxy = p2 @head = p1 @conn = DummyConnectionAdapter.new end context 'with binds' do let(:context) { Arproxy::QueryContext.new(raw_connection: @conn, execute_method_name: 'execute', with_binds: true, name: 'test', binds: [1]) } describe '#execute' do it do expect(@head.execute('SELECT 1', context)).to eq('SELECT 1 /* Proxy1 */ /* Proxy2 */') end end end context 'without binds' do let(:context) { Arproxy::QueryContext.new(raw_connection: @conn, execute_method_name: 'execute', with_binds: false, name: 'test') } describe '#execute' do it do expect(@head.execute('SELECT 1', context)).to eq('SELECT 1 /* Proxy1 */ /* Proxy2 */') end end end end ================================================ FILE: spec/unit/spec_helper.rb ================================================ require 'arproxy' Arproxy.logger.level = Logger::WARN unless ENV['DEBUG']