diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfb2e46 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.gitignore +.env* +Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d81a40 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: push + +jobs: + test: + strategy: + matrix: + rails_version: + - main + ruby_version: + - '3.0' + fail-fast: false + runs-on: ubuntu-latest + name: Test on Rails ${{ matrix.rails_version }} + env: + RAILS_VERSION: ${{ matrix.rails_version }} + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + steps: + - uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846 + - name: Cache .gem files + uses: actions/cache@v3 + with: + key: gems-${{ matrix.rails_version }}-${{ hashFiles('Gemfile', '*.gemspec') }} + path: vendor/cache + - name: Start mysql + run: docker run -d --rm --name=mysql -e MYSQL_ALLOW_EMPTY_PASSWORD=1 -p 3306:3306 --health-interval=1s --health-timeout=5s --health-retries=5 --volume="$(pwd)"/script/setup.sql:/docker-entrypoint-initdb.d/setup.sql --health-cmd='mysql trilogy_test -e "select * from posts"' mysql:5.7 --sql_mode=NO_ENGINE_SUBSTITUTION --log-bin --server-id=1 --gtid-mode=ON --enforce-gtid-consistency=ON + - uses: ruby/setup-ruby@bd94d6a504586da892a5753afdd1480096ed30df + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + - name: Wait for mysql + run: timeout -v '1m' bash -c 'until [ "`docker inspect -f {{.State.Health.Status}} mysql `" == "healthy" ]; do sleep 0.5; done' + - name: Run tests + run: bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9711119 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.bundle +.rubocop-http* +pkg/* +Gemfile.lock +tmp +log/* +.byebug_history +vendor/cache/byebug-*.gem +vendor/cache/coderay-*.gem +vendor/cache/concurrent-ruby-*.gem +vendor/cache/connection_pool-*.gem +vendor/cache/i18n-*.gem +vendor/cache/method_source-*.gem +vendor/cache/minitest-*.gem +vendor/cache/minitest-focus-*.gem +vendor/cache/pry-*.gem +vendor/cache/pry-*.gem +vendor/cache/rake-*.gem +vendor/cache/toxiproxy-*.gem +vendor/cache/tzinfo-*.gem +vendor/cache/zeitwerk-*.gem diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..b0f2dcb --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.0.4 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..19bf9f1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +## 2.0.0 + +### Added + +- Initial release of the adapter. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..6773120 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @github/ruby-architecture diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..659f0bd --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,73 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when +an individual is representing the project or its community in public spaces. +Examples of representing a project or community include using an official +project e-mail address, posting via an official social media account, or acting +as an appointed representative at an online or offline event. Representation of +a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at opensource+trilogy@github.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7efcb4c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +## Contributing + +[fork]: https://github.com/github/activerecord-trilogy-adapter/fork +[pr]: https://github.com/github/activerecord-trilogy-adapter/compare +[style]: https://github.com/styleguide/ruby +[code-of-conduct]: CODE_OF_CONDUCT.md + +Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. + +Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.md). + +Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. + +## Setup + +For local development, run: + + bin/setup + +To test, run: + + bundle exec rake + +## Submitting a pull request + +0. [Fork][fork] and clone the repository +0. Configure and install the dependencies: `bin/setup` +0. Make sure the tests pass on your machine: `bundle exec rake` +0. Create a new branch: `git checkout -b my-branch-name` +0. Make your change, add tests, and make sure the tests still pass +0. Push to your fork and [submit a pull request][pr] +0. Pat your self on the back and wait for your pull request to be reviewed and merged. + +Here are a few things you can do that will increase the likelihood of your pull request being accepted: + +- Follow the [style guide][style]. +- Write tests. +- Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. +- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). + +## Resources + +- [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) +- [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) +- [GitHub Help](https://help.github.com) + diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..69aa3e9 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,9 @@ +FROM ruby:2.6.3 +LABEL maintainer="github@github.com" + +RUN apt-get update -qq && apt-get install -y default-mysql-client + +WORKDIR /app +COPY . . + +CMD ["script/test"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e1b163c --- /dev/null +++ b/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +if !ENV["RAILS_VERSION"] || ENV["RAILS_VERSION"] == "main" + gem "activerecord", git: "https://github.com/rails/rails", branch: "main" +else + gem "activerecord", ENV["RAILS_VERSION"] +end + +gemspec diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1867c4f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +Copyright (c) 2018-2022 GitHub, Inc. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d359550 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Trilogy Adapter + +Active Record database adapter for [Trilogy](https://github.com/github/trilogy) + +## Requirements + +- [Ruby](https://www.ruby-lang.org) 2.7 or higher +- [Active Record](https://github.com/rails/rails) 7.1 or higher +- [Trilogy](https://github.com/github/trilogy) 2.1.1 or higher + +## Setup + +* Add the following to your Gemfile: + + ```rb + gem "activerecord-trilogy-adapter" + ``` + +* Update your database configuration (e.g. `config/database.yml`) to use + `trilogy` as the adapter. + +## Versioning + +Read [Semantic Versioning](https://semver.org) for details. Briefly, it means: + +- Major (X.y.z) - Incremented for any backwards incompatible public API changes. +- Minor (x.Y.z) - Incremented for new, backwards compatible, public API enhancements/fixes. +- Patch (x.y.Z) - Incremented for small, backwards compatible, bug fixes. + +## Code of Conduct + +Please note that this project is released with a [CODE OF CONDUCT](CODE_OF_CONDUCT.md). By +participating in this project you agree to abide by its terms. + +## Contributions + +Read [CONTRIBUTING](CONTRIBUTING.md) for details. + +## License + +Released under the [MIT License](LICENSE.md). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..51d72a6 --- /dev/null +++ b/Rakefile @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +begin + require "bundler/gem_tasks" + + require "rake/testtask" + + Rake::TestTask.new do |task| + task.libs << "test" + task.pattern = "test/**/*_test.rb" + end +rescue LoadError => error + puts error.message +end + +task default: %i[test] diff --git a/activerecord-trilogy-adapter.gemspec b/activerecord-trilogy-adapter.gemspec new file mode 100644 index 0000000..24fbe9d --- /dev/null +++ b/activerecord-trilogy-adapter.gemspec @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "lib/trilogy_adapter/version" + +Gem::Specification.new do |spec| + spec.name = "activerecord-trilogy-adapter" + spec.version = TrilogyAdapter::VERSION + spec.platform = Gem::Platform::RUBY + spec.authors = ["GitHub Engineering"] + spec.email = ["opensource+trilogy@github.com"] + spec.homepage = "https://github.com/github/activerecord-trilogy-adapter" + spec.summary = "Active Record adapter for https://github.com/github/trilogy." + spec.license = "MIT" + + spec.metadata = { + "source_code_uri" => "https://github.com/github/activerecord-trilogy-adapter", + "changelog_uri" => "https://github.com/github/activerecord-trilogy-adapter/blob/master/CHANGELOG.md", + "bug_tracker_uri" => "https://github.com/github/activerecord-trilogy-adapter/issues" + } + + spec.add_dependency "trilogy", ">= 2.1.1" + spec.add_dependency "activerecord", ">= 7.1.0.alpha" + spec.add_development_dependency "minitest", "~> 5.11" + spec.add_development_dependency "minitest-focus", "~> 1.1" + spec.add_development_dependency "pry", "~> 0.10" + spec.add_development_dependency "rake", "~> 12.3" + + spec.files = Dir["lib/**/*"] + spec.extra_rdoc_files = Dir["README*", "LICENSE*"] + spec.require_paths = ["lib"] +end diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..46043f4 --- /dev/null +++ b/bin/setup @@ -0,0 +1,9 @@ +#! /usr/bin/env bash + +set -o nounset # Exit, with error message, when attempting to use an undefined variable. +set -o errexit # Abort script at first error, when a command exits with non-zero status. +set -o pipefail # Return exit status of the last command in the pipe that returned a non-zero return value. +IFS=$'\n\t' # Defines newlines and tabs as delimiters for splitting words and iterating arrays. + +bundle install +$(brew --prefix)/opt/mysql@5.7/bin/mysql --user=root --password= < "script/setup.sql" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..af8e2b8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.5" +services: + db: + image: mysql:5.7 + command: --sql_mode="NO_ENGINE_SUBSTITUTION" + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: 1 + volumes: + - "db-data:/var/lib/mysql" + app: + image: app + build: + context: . + dockerfile: Dockerfile.test + environment: + DB_HOST: db + depends_on: + - db + +volumes: + db-data: diff --git a/lib/active_record/connection_adapters/trilogy_adapter.rb b/lib/active_record/connection_adapters/trilogy_adapter.rb new file mode 100644 index 0000000..c92f59e --- /dev/null +++ b/lib/active_record/connection_adapters/trilogy_adapter.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "trilogy" +require "active_record/connection_adapters/abstract_mysql_adapter" + +require "active_record/tasks/trilogy_database_tasks" +require "trilogy_adapter/lost_connection_exception_translator" + +module ActiveRecord + module ConnectionAdapters + class TrilogyAdapter < ::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter + module DatabaseStatements + READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp( + :desc, :describe, :set, :show, :use + ) # :nodoc: + private_constant :READ_QUERY + + def write_query?(sql) # :nodoc: + !READ_QUERY.match?(sql) + rescue ArgumentError # Invalid encoding + !READ_QUERY.match?(sql.b) + end + + def explain(arel, binds = []) + sql = "EXPLAIN #{to_sql(arel, binds)}" + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = exec_query(sql, "EXPLAIN", binds) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + + MySQL::ExplainPrettyPrinter.new.pp(result, elapsed) + end + + def execute(sql, name = nil, async: false) + sql = transform_query(sql) + check_if_write_query(sql) + + raw_execute(sql, name, async: async) + end + + def exec_query(sql, name = "SQL", binds = [], prepare: false, async: false) + result = execute(sql, name, async: async) + ActiveRecord::Result.new(result.fields, result.to_a) + end + + alias exec_without_stmt exec_query + + def exec_insert(sql, name, binds, pk = nil, sequence_name = nil) + execute(to_sql(sql, binds), name) + end + + def exec_delete(sql, name = nil, binds = []) + result = execute(to_sql(sql, binds), name) + result.affected_rows + end + + alias :exec_update :exec_delete + + private + def last_inserted_id(result) + result.last_insert_id + end + end + + ER_BAD_DB_ERROR = 1049 + ER_ACCESS_DENIED_ERROR = 1045 + ER_CONN_HOST_ERROR = 2003 + ER_UNKNOWN_HOST_ERROR = 2005 + + ADAPTER_NAME = "Trilogy" + + include DatabaseStatements + + class << self + def new_client(config) + ::Trilogy.new(config) + rescue Trilogy::DatabaseError => error + raise translate_connect_error(config, error) + end + + def translate_connect_error(config, error) + case error.error_code + when ER_BAD_DB_ERROR + ActiveRecord::NoDatabaseError.db_error(config[:database]) + when ER_ACCESS_DENIED_ERROR + ActiveRecord::DatabaseConnectionError.username_error(config[:username]) + when ER_CONN_HOST_ERROR, ER_UNKNOWN_HOST_ERROR + ActiveRecord::DatabaseConnectionError.hostname_error(config[:host]) + else + ActiveRecord::ConnectionNotEstablished.new(error.message) + end + end + end + + def supports_json? + !mariadb? && database_version >= "5.7.8" + end + + def supports_comments? + true + end + + def supports_comments_in_create? + true + end + + def supports_savepoints? + true + end + + def savepoint_errors_invalidate_transactions? + true + end + + def supports_lazy_transactions? + true + end + + def quote_string(string) + any_raw_connection.escape string + end + + def active? + connection&.ping || false + rescue ::Trilogy::Error + false + end + + alias reset! reconnect! + + def disconnect! + unless connection.nil? + connection.close + self.connection = nil + end + end + + def discard! + self.connection = nil + end + + def raw_execute(sql, name, async: false, allow_retry: false, uses_transaction: true) + mark_transaction_written_if_write(sql) + + log(sql, name, async: async) do + with_raw_connection(allow_retry: allow_retry, uses_transaction: uses_transaction) do |conn| + # Sync any changes since connection last established. + if default_timezone == :local + conn.query_flags |= ::Trilogy::QUERY_FLAGS_LOCAL_TIMEZONE + else + conn.query_flags &= ~::Trilogy::QUERY_FLAGS_LOCAL_TIMEZONE + end + + conn.query(sql) + end + end + end + + def each_hash(result) + return to_enum(:each_hash, result) unless block_given? + + keys = result.fields.map(&:to_sym) + result.rows.each do |row| + hash = {} + idx = 0 + row.each do |value| + hash[keys[idx]] = value + idx += 1 + end + yield hash + end + + nil + end + + def error_number(exception) + exception.error_code if exception.respond_to?(:error_code) + end + + private + def connection + @raw_connection + end + + def connection=(conn) + @raw_connection = conn + end + + def connect + self.connection = self.class.new_client(@config) + end + + def reconnect + connection&.close + connect + end + + def full_version + schema_cache.database_version.full_version_string + end + + def get_full_version + any_raw_connection.server_info[:version] + end + + def translate_exception(exception, message:, sql:, binds:) + error_code = exception.error_code if exception.respond_to?(:error_code) + + ::TrilogyAdapter::LostConnectionExceptionTranslator. + new(exception, message, error_code).translate || super + end + end + end +end diff --git a/lib/active_record/tasks/trilogy_database_tasks.rb b/lib/active_record/tasks/trilogy_database_tasks.rb new file mode 100644 index 0000000..2b4f47f --- /dev/null +++ b/lib/active_record/tasks/trilogy_database_tasks.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Use MySQLDatabaseTasks for Trilogy +ActiveRecord::Tasks::DatabaseTasks.register_task( + "trilogy", + "ActiveRecord::Tasks::MySQLDatabaseTasks" +) diff --git a/lib/activerecord-trilogy-adapter.rb b/lib/activerecord-trilogy-adapter.rb new file mode 100644 index 0000000..d7a2359 --- /dev/null +++ b/lib/activerecord-trilogy-adapter.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "active_record" +require "trilogy_adapter/errors" +require "trilogy_adapter/railtie" diff --git a/lib/trilogy_adapter/connection.rb b/lib/trilogy_adapter/connection.rb new file mode 100644 index 0000000..306ef93 --- /dev/null +++ b/lib/trilogy_adapter/connection.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "trilogy" +require "active_record/connection_adapters/trilogy_adapter" + +module TrilogyAdapter + # Necessary for enhancing ActiveRecord to recognize the Trilogy adapter. Example: + # + # ActiveRecord::Base.public_send :extend, TrilogyAdapter::Connection + # + # This will allow downstream applications to use the Trilogy adapter. Example: + # + # ActiveRecord::Base.establish_connection adapter: "trilogy", + # host: "localhost", + # database: "demo_development" + module Connection + def trilogy_connection(config) + configuration = config.dup + + # Set FOUND_ROWS capability on the connection so UPDATE queries returns number of rows + # matched rather than number of rows updated. + configuration[:found_rows] = true + + options = [ + configuration[:host], + configuration[:port], + configuration[:database], + configuration[:username], + configuration[:password], + configuration[:socket], + 0 + ] + + ActiveRecord::ConnectionAdapters::TrilogyAdapter.new nil, logger, options, configuration + end + end +end diff --git a/lib/trilogy_adapter/errors.rb b/lib/trilogy_adapter/errors.rb new file mode 100644 index 0000000..edbda46 --- /dev/null +++ b/lib/trilogy_adapter/errors.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module TrilogyAdapter + module Errors + # ServerShutdown will be raised when the database server was shutdown. + class ServerShutdown < ActiveRecord::ConnectionFailed + end + + # ServerLost will be raised when the database connection was lost. + class ServerLost < ActiveRecord::ConnectionFailed + end + + # ServerGone will be raised when the database connection is gone. + class ServerGone < ActiveRecord::ConnectionFailed + end + + # BrokenPipe will be raised when a system process connection fails. + class BrokenPipe < ActiveRecord::ConnectionFailed + end + + # SocketError will be raised when Ruby encounters a network error. + class SocketError < ActiveRecord::ConnectionFailed + end + + # ConnectionResetByPeer will be raised when a network connection is closed + # outside the sytstem process. + class ConnectionResetByPeer < ActiveRecord::ConnectionFailed + end + + # ClosedConnection will be raised when the Trilogy encounters a closed + # connection. + class ClosedConnection < ActiveRecord::ConnectionFailed + end + + # InvalidSequenceId will be raised when Trilogy ecounters an invalid sequence + # id. + class InvalidSequenceId < ActiveRecord::ConnectionFailed + end + + # UnexpectedPacket will be raised when Trilogy ecounters an unexpected + # response packet. + class UnexpectedPacket < ActiveRecord::ConnectionFailed + end + end +end diff --git a/lib/trilogy_adapter/lost_connection_exception_translator.rb b/lib/trilogy_adapter/lost_connection_exception_translator.rb new file mode 100644 index 0000000..8995355 --- /dev/null +++ b/lib/trilogy_adapter/lost_connection_exception_translator.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module TrilogyAdapter + class LostConnectionExceptionTranslator + attr_reader :exception, :message, :error_number + + def initialize(exception, message, error_number) + @exception = exception + @message = message + @error_number = error_number + end + + def translate + translate_database_exception || translate_ruby_exception || translate_trilogy_exception + end + + private + ER_SERVER_SHUTDOWN = 1053 + CR_SERVER_LOST = 2013 + CR_SERVER_LOST_EXTENDED = 2055 + CR_SERVER_GONE_ERROR = 2006 + + def translate_database_exception + case error_number + when ER_SERVER_SHUTDOWN + Errors::ServerShutdown.new(message) + when CR_SERVER_LOST, CR_SERVER_LOST_EXTENDED + Errors::ServerLost.new(message) + when CR_SERVER_GONE_ERROR + Errors::ServerGone.new(message) + end + end + + def translate_ruby_exception + case exception + when Errno::EPIPE + Errors::BrokenPipe.new(message) + when SocketError + Errors::SocketError.new(message) + when Errno::ECONNRESET + Errors::ConnectionResetByPeer.new(message) + end + end + + def translate_trilogy_exception + return unless exception.is_a?(Trilogy::Error) + + case message + when /TRILOGY_CLOSED_CONNECTION/ + Errors::ClosedConnection.new(message) + when /TRILOGY_INVALID_SEQUENCE_ID/ + Errors::InvalidSequenceId.new(message) + when /TRILOGY_UNEXPECTED_PACKET/ + Errors::UnexpectedPacket.new(message) + end + end + end +end diff --git a/lib/trilogy_adapter/rails/dbconsole.rb b/lib/trilogy_adapter/rails/dbconsole.rb new file mode 100644 index 0000000..8671ed6 --- /dev/null +++ b/lib/trilogy_adapter/rails/dbconsole.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module TrilogyAdapter + module Rails + module DBConsole + class AdapterAdapter < SimpleDelegator + def adapter + "mysql" + end + end + + def db_config + if super.adapter == "trilogy" + AdapterAdapter.new(super) + else + super + end + end + end + end +end diff --git a/lib/trilogy_adapter/railtie.rb b/lib/trilogy_adapter/railtie.rb new file mode 100644 index 0000000..a87585a --- /dev/null +++ b/lib/trilogy_adapter/railtie.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +if defined?(Rails) + require "rails/railtie" + + module TrilogyAdapter + class Railtie < ::Rails::Railtie + ActiveSupport.on_load(:active_record) do + require "trilogy_adapter/connection" + ActiveRecord::Base.public_send :extend, TrilogyAdapter::Connection + end + end + end +end + +if defined?(Rails::DBConsole) + require "trilogy_adapter/rails/dbconsole" + Rails::DBConsole.prepend(TrilogyAdapter::Rails::DBConsole) +end diff --git a/lib/trilogy_adapter/version.rb b/lib/trilogy_adapter/version.rb new file mode 100644 index 0000000..94871b9 --- /dev/null +++ b/lib/trilogy_adapter/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module TrilogyAdapter + VERSION = "2.0.0" +end diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..0c2d47b --- /dev/null +++ b/script/cibuild @@ -0,0 +1,45 @@ +#!/bin/bash + +output_fold() { + # Exit early if no label provided + if [ -z "$1" ]; then + echo "output_fold(): requires a label argument." + return + fi + + exit_value=0 # exit_value is used to record exit status of the given command + label=$1 # human-readable label describing what's being folded up + shift 1 # having retrieved the output_fold()-specific arguments, strip them off $@ + + # Only echo the tags when in CI_MODE + if [ "$CI_MODE" ]; then + echo "%%%FOLD {$label}%%%" + fi + + # run the remaining arguments. If the command exits non-0, the `||` will + # prevent the `-e` flag from seeing the failure exit code, and we'll see + # the second echo execute + "$@" || exit_value=$? + + # Only echo the tags when in CI_MODE + if [ "$CI_MODE" ]; then + echo "%%%END FOLD%%%" + fi + + # preserve the exit code from the subcommand. + return $exit_value +} + +function cleanup() { + echo + echo "%%%FOLD {Shutting down services...}%%%" + docker-compose down + echo "%%%END FOLD%%%" +} + +trap cleanup EXIT + +export CI_MODE=true + +output_fold "Bootstrapping container..." docker-compose build +output_fold "Running tests..." docker-compose run --rm app diff --git a/script/setup.sql b/script/setup.sql new file mode 100644 index 0000000..3959e0e --- /dev/null +++ b/script/setup.sql @@ -0,0 +1,30 @@ +-- Constructs a database for testing purposes (useful for both local testing and CI builds). +DROP DATABASE IF EXISTS `trilogy_test`; +CREATE DATABASE `trilogy_test` CHARACTER SET utf8; +USE `trilogy_test`; + +CREATE TABLE `ar_internal_metadata` ( + `key` VARCHAR(255) NOT NULL PRIMARY KEY, + `value` VARCHAR(255) NOT NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL +); + +CREATE TABLE `users` ( + `id` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(255) NOT NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL +); + +CREATE TABLE `posts` ( + `id` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY, + `author_id` INT(11), + `title` VARCHAR(255) NOT NULL, + `body` VARCHAR(255) NOT NULL, + `kind` VARCHAR(255) NOT NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + KEY `index_posts_on_author_id` (`author_id`), + UNIQUE KEY `index_posts_on_kind` (`kind`) +); diff --git a/script/test b/script/test new file mode 100755 index 0000000..7effb6f --- /dev/null +++ b/script/test @@ -0,0 +1,11 @@ +#!/bin/sh +set -ex + +bundle install + +host=${DB_HOST:-localhost} +port=${DB_PORT:-3306} + +mysql --protocol=tcp --host $host --port $port --user=root < "script/setup.sql" + +bundle exec rake diff --git a/test/lib/active_record/connection_adapters/trilogy_adapter_test.rb b/test/lib/active_record/connection_adapters/trilogy_adapter_test.rb new file mode 100644 index 0000000..49c1b48 --- /dev/null +++ b/test/lib/active_record/connection_adapters/trilogy_adapter_test.rb @@ -0,0 +1,854 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActiveRecord::ConnectionAdapters::TrilogyAdapterTest < TestCase + setup do + @schema_cache_fixture_path = @fixtures_path.join("schema.dump").to_s + + @host = ENV["DB_HOST"] + @configuration = { + adapter: "trilogy", + username: "root", + host: @host, + port: Integer(ENV["DB_PORT"]), + database: DATABASE + } + + @adapter = trilogy_adapter + @adapter.execute("TRUNCATE posts") + + db_config = ActiveRecord::DatabaseConfigurations.new({}).resolve(@configuration) + pool_config = ActiveRecord::ConnectionAdapters::PoolConfig.new(ActiveRecord::Base, db_config, :writing, :default) + @pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new(pool_config) + end + + teardown do + @adapter.disconnect! + end + + test "#explain for one query" do + explain = @adapter.explain("select * from posts") + assert_match %(possible_keys), explain + end + + test "#adapter_name answers name" do + assert_equal "Trilogy", @adapter.adapter_name + end + + test "#supports_json answers true without Maria DB and greater version" do + assert @adapter.supports_json? + end + + test "#supports_json answers false without Maria DB and lesser version" do + database_version = @adapter.class::Version.new("5.0.0", nil) + + @adapter.stub(:database_version, database_version) do + assert_equal false, @adapter.supports_json? + end + end + + test "#supports_json answers false with Maria DB" do + @adapter.stub(:mariadb?, true) do + assert_equal false, @adapter.supports_json? + end + end + + test "#supports_comments? answers true" do + assert @adapter.supports_comments? + end + + test "#supports_comments_in_create? answers true" do + assert @adapter.supports_comments_in_create? + end + + test "#supports_savepoints? answers true" do + assert @adapter.supports_savepoints? + end + + test "#requires_reloading? answers false" do + assert_equal false, @adapter.requires_reloading? + end + + test "#native_database_types answers known types" do + assert_equal ActiveRecord::ConnectionAdapters::TrilogyAdapter::NATIVE_DATABASE_TYPES, @adapter.native_database_types + end + + test "#quote_column_name answers quoted string when not quoted" do + assert_equal "`test`", @adapter.quote_column_name("test") + end + + test "#quote_column_name answers triple quoted string when quoted" do + assert_equal "```test```", @adapter.quote_column_name("`test`") + end + + test "#quote_column_name answers quoted string for integer" do + assert_equal "`1`", @adapter.quote_column_name(1) + end + + test "#quote_table_name delgates to #quote_column_name" do + @adapter.stub(:quote_column_name, "stubbed_method_check") do + assert_equal "stubbed_method_check", @adapter.quote_table_name("test") + end + end + + test "#quote_string answers string with connection" do + assert_equal "\\\"test\\\"", @adapter.quote_string(%("test")) + end + + test "#quoted_true answers TRUE" do + assert_equal "TRUE", @adapter.quoted_true + end + + test "#quoted_false answers FALSE" do + assert_equal "FALSE", @adapter.quoted_false + end + + test "#active? answers true with connection" do + assert @adapter.active? + end + + test "#active? answers false with connection and exception" do + @adapter.send(:connection).stub(:ping, -> { raise Trilogy::Error.new }) do + assert_equal false, @adapter.active? + end + end + + test "#active? answers false without connection" do + adapter = trilogy_adapter + assert_equal false, adapter.active? + end + + test "#reconnect closes connection with connection" do + connection = Minitest::Mock.new Trilogy.new(@configuration) + connection.expect :close, true + adapter = trilogy_adapter_with_connection(connection) + adapter.reconnect! + + assert connection.verify + end + + test "#reconnect answers new connection with existing connection" do + old_connection = @adapter.send(:connection) + @adapter.reconnect! + connection = @adapter.send(:connection) + + assert_instance_of Trilogy, connection + assert_not_equal old_connection, connection + end + + test "#reconnect answers new connection without existing connection" do + adapter = trilogy_adapter + adapter.reconnect! + assert_instance_of Trilogy, adapter.send(:connection) + end + + test "#reset closes connection with existing connection" do + connection = Minitest::Mock.new Trilogy.new(@configuration) + connection.expect :close, true + adapter = trilogy_adapter_with_connection(connection) + adapter.reset! + + assert connection.verify + end + + test "#reset answers new connection with existing connection" do + old_connection = @adapter.send(:connection) + @adapter.reset! + connection = @adapter.send(:connection) + + assert_instance_of Trilogy, connection + assert_not_equal old_connection, connection + end + + test "#reset answers new connection without existing connection" do + adapter = trilogy_adapter + adapter.reset! + assert_instance_of Trilogy, adapter.send(:connection) + end + + test "#disconnect closes connection with existing connection" do + connection = Minitest::Mock.new Trilogy.new(@configuration) + connection.expect :close, true + adapter = trilogy_adapter_with_connection(connection) + adapter.disconnect! + + assert connection.verify + end + + test "#disconnect makes adapter inactive with connection" do + @adapter.disconnect! + assert_equal false, @adapter.active? + end + + test "#disconnect answers nil with connection" do + assert_nil @adapter.disconnect! + end + + test "#disconnect answers nil without connection" do + adapter = trilogy_adapter + assert_nil adapter.disconnect! + end + + test "#disconnect leaves adapter inactive without connection" do + adapter = trilogy_adapter + adapter.disconnect! + + assert_equal false, adapter.active? + end + + test "#discard answers nil with connection" do + assert_nil @adapter.discard! + end + + test "#discard makes adapter inactive with connection" do + @adapter.discard! + assert_equal false, @adapter.active? + end + + test "#discard answers nil without connection" do + adapter = trilogy_adapter + assert_nil adapter.discard! + end + + test "#exec_query answers result with valid query" do + result = @adapter.exec_query "SELECT * FROM posts;" + + assert_equal %w[id author_id title body kind created_at updated_at], result.columns + assert_equal [], result.rows + end + + test "#exec_query fails with invalid query" do + assert_raises_with_message ActiveRecord::StatementInvalid, /'trilogy_test.bogus' doesn't exist/ do + @adapter.exec_query "SELECT * FROM bogus;" + end + end + + test "#exec_insert inserts new row" do + @adapter.exec_insert "INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('Test', 'example', 'content', '2019-05-31 12:52:00', '2019-05-31 12:52:00');", nil, nil + result = @adapter.execute "SELECT * FROM posts;" + + assert_equal [[1, nil, "Test", "content", "example", Time.utc(2019, 5, 31, 12, 52), Time.utc(2019, 5, 31, 12, 52)]], result.rows + end + + test "#exec_delete deletes existing row" do + @adapter.execute "INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('Test', 'example', 'content', NOW(), NOW());" + @adapter.exec_delete "DELETE FROM posts WHERE title = 'Test';", nil, nil + result = @adapter.execute "SELECT * FROM posts;" + + assert_equal [], result.rows + end + + test "#exec_update updates existing row" do + @adapter.execute "INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('Test', 'example', 'content', '2019-05-31 12:52:00', '2019-05-31 12:52:00');" + @adapter.exec_update "UPDATE posts SET title = 'Test II' where kind = 'example';", nil, nil + result = @adapter.execute "SELECT * FROM posts;" + + assert_equal [[1, nil, "Test II", "content", "example", Time.utc(2019, 5, 31, 12, 52), Time.utc(2019, 5, 31, 12, 52)]], result.rows + end + + test "default query flags set timezone to UTC" do + if ActiveRecord.respond_to?(:default_timezone) + assert_equal :utc, ActiveRecord.default_timezone + else + assert_equal :utc, ActiveRecord::Base.default_timezone + end + ruby_time = Time.utc(2019, 5, 31, 12, 52) + time = '2019-05-31 12:52:00' + + @adapter.execute("INSERT into posts (title, body, kind, created_at, updated_at) VALUES ('title', 'body', 'a kind of post', '#{time}', '#{time}');") + result = @adapter.execute("select * from posts limit 1;") + + result.each_hash do |hsh| + assert_equal ruby_time, hsh["created_at"] + assert_equal ruby_time, hsh["updated_at"] + end + + assert_equal 1, @adapter.send(:connection).query_flags + end + + test "query flags for timezone can be set to local" do + if ActiveRecord.respond_to?(:default_timezone) + old_timezone, ActiveRecord.default_timezone = ActiveRecord.default_timezone, :local + assert_equal :local, ActiveRecord.default_timezone + else + old_timezone, ActiveRecord::Base.default_timezone = ActiveRecord::Base.default_timezone, :local + assert_equal :local, ActiveRecord::Base.default_timezone + end + ruby_time = Time.local(2019, 5, 31, 12, 52) + time = '2019-05-31 12:52:00' + + @adapter.execute("INSERT into posts (title, body, kind, created_at, updated_at) VALUES ('title', 'body', 'a kind of post', '#{time}', '#{time}');") + result = @adapter.execute("select * from posts limit 1;") + + result.each_hash do |hsh| + assert_equal ruby_time, hsh["created_at"] + assert_equal ruby_time, hsh["updated_at"] + end + + assert_equal 5, @adapter.send(:connection).query_flags + ensure + if ActiveRecord.respond_to?(:default_timezone) + ActiveRecord.default_timezone = old_timezone + else + ActiveRecord::Base.default_timezone = old_timezone + end + end + + test "query flags for timezone can be set to local and reset to utc" do + if ActiveRecord.respond_to?(:default_timezone) + old_timezone, ActiveRecord.default_timezone = ActiveRecord.default_timezone, :local + assert_equal :local, ActiveRecord.default_timezone + else + old_timezone, ActiveRecord::Base.default_timezone = ActiveRecord::Base.default_timezone, :local + assert_equal :local, ActiveRecord::Base.default_timezone + end + ruby_time = Time.local(2019, 5, 31, 12, 52) + time = '2019-05-31 12:52:00' + + @adapter.execute("INSERT into posts (title, body, kind, created_at, updated_at) VALUES ('title', 'body', 'a kind of post', '#{time}', '#{time}');") + result = @adapter.execute("select * from posts limit 1;") + + result.each_hash do |hsh| + assert_equal ruby_time, hsh["created_at"] + assert_equal ruby_time, hsh["updated_at"] + end + + assert_equal 5, @adapter.send(:connection).query_flags + + if ActiveRecord.respond_to?(:default_timezone) + ActiveRecord.default_timezone = :utc + else + ActiveRecord::Base.default_timezone = :utc + end + + ruby_utc_time = Time.utc(2019, 5, 31, 12, 52) + utc_result = @adapter.execute("select * from posts limit 1;") + + utc_result.each_hash do |hsh| + assert_equal ruby_utc_time, hsh["created_at"] + assert_equal ruby_utc_time, hsh["updated_at"] + end + + assert_equal 1, @adapter.send(:connection).query_flags + ensure + if ActiveRecord.respond_to?(:default_timezone) + ActiveRecord.default_timezone = old_timezone + else + ActiveRecord::Base.default_timezone = old_timezone + end + end + + test "#execute answers results for valid query" do + result = @adapter.execute "SELECT * FROM posts;" + assert_equal %w[id author_id title body kind created_at updated_at], result.fields + end + + test "#execute answers results for valid query after reconnect" do + mock_connection = Minitest::Mock.new Trilogy.new(@configuration) + adapter = trilogy_adapter_with_connection(mock_connection) + + # Cause an ER_SERVER_SHUTDOWN error (code 1053) after the session is + # set. On reconnect, the adapter will get a real, working connection. + server_shutdown_error = Trilogy::DatabaseError.new + server_shutdown_error.instance_variable_set(:@error_code, 1053) + mock_connection.expect(:query, nil) { raise server_shutdown_error } + + assert_raises(TrilogyAdapter::Errors::ServerShutdown) do + adapter.execute "SELECT * FROM posts;" + end + + adapter.reconnect! + result = adapter.execute "SELECT * FROM posts;" + + assert_equal %w[id author_id title body kind created_at updated_at], result.fields + assert mock_connection.verify + mock_connection.close + end + + test "#execute fails with invalid query" do + assert_raises_with_message ActiveRecord::StatementInvalid, /Table 'trilogy_test.bogus' doesn't exist/ do + @adapter.execute "SELECT * FROM bogus;" + end + end + + test "#execute fails with invalid SQL" do + assert_raises(ActiveRecord::StatementInvalid) do + @adapter.execute "SELECT bogus FROM posts;" + end + end + + test "#execute answers results for valid query after losing connection unexpectedly" do + connection = Trilogy.new(@configuration.merge(read_timeout: 1)) + + adapter = trilogy_adapter_with_connection(connection) + assert adapter.active? + + # Make connection lost for future queries by exceeding the read timeout + assert_raises(Errno::ETIMEDOUT) do + connection.query "SELECT sleep(2);" + end + assert_not adapter.active? + + # The adapter believes the connection is verified, so it will run the + # following query immediately. It will fail, and as the query's not + # retryable, the adapter will raise an error. + + # The next query fails because the connection is lost + assert_raises(TrilogyAdapter::Errors::ClosedConnection) do + adapter.execute "SELECT COUNT(*) FROM posts;" + end + assert_not adapter.active? + + # The adapter now knows the connection is lost, so it will re-verify (and + # ultimately reconnect) before running another query. + + # This query triggers a reconnect + result = adapter.execute "SELECT COUNT(*) FROM posts;" + assert_equal [[0]], result.rows + assert adapter.active? + end + + test "#execute answers results for valid query after losing connection" do + connection = Trilogy.new(@configuration.merge(read_timeout: 1)) + + adapter = trilogy_adapter_with_connection(connection) + assert adapter.active? + + # Make connection lost for future queries by exceeding the read timeout + assert_raises(ActiveRecord::StatementInvalid) do + adapter.execute "SELECT sleep(2);" + end + assert_not adapter.active? + + # The above failure has not yet caused a reconnect, but the adapter has + # lost confidence in the connection, so it will re-verify before running + # the next query -- which means it will succeed. + + # This query triggers a reconnect + result = adapter.execute "SELECT COUNT(*) FROM posts;" + assert_equal [[0]], result.rows + assert adapter.active? + end + + test "#execute fails if the connection is closed" do + connection = Trilogy.new(@configuration.merge(read_timeout: 1)) + + adapter = trilogy_adapter_with_connection(connection) + adapter.pool = @pool + + assert_raises TrilogyAdapter::Errors::ClosedConnection do + adapter.transaction do + # Make connection lost for future queries by exceeding the read timeout + assert_raises(ActiveRecord::StatementInvalid) do + adapter.execute "SELECT sleep(2);" + end + assert_not adapter.active? + + adapter.execute "SELECT COUNT(*) FROM posts;" + end + end + + assert_not adapter.active? + + # This query triggers a reconnect + result = adapter.execute "SELECT COUNT(*) FROM posts;" + assert_equal [[0]], result.rows + end + + test "can reconnect after failing to rollback" do + connection = Trilogy.new(@configuration.merge(read_timeout: 1)) + + adapter = trilogy_adapter_with_connection(connection) + adapter.pool = @pool + + adapter.transaction do + adapter.execute("SELECT 1") + + # Cause the client to disconnect without the adapter's awareness + assert_raises Errno::ETIMEDOUT do + adapter.send(:connection).query("SELECT sleep(2)") + end + + raise ActiveRecord::Rollback + end + + result = adapter.execute("SELECT 1") + assert_equal [[1]], result.rows + end + + test "can reconnect after failing to commit" do + connection = Trilogy.new(@configuration.merge(read_timeout: 1)) + + adapter = trilogy_adapter_with_connection(connection) + adapter.pool = @pool + + assert_raises TrilogyAdapter::Errors::ClosedConnection do + adapter.transaction do + adapter.execute("SELECT 1") + + # Cause the client to disconnect without the adapter's awareness + assert_raises Errno::ETIMEDOUT do + adapter.send(:connection).query("SELECT sleep(2)") + end + end + end + + result = adapter.execute("SELECT 1") + assert_equal [[1]], result.rows + end + + test "#execute fails with deadlock error" do + adapter = trilogy_adapter + + new_connection = Trilogy.new(@configuration) + + deadlocking_adapter = trilogy_adapter_with_connection(new_connection) + + # Add seed data + adapter.insert("INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES('Setup', 'Example', 'Content', NOW(), NOW())") + + adapter.transaction do + adapter.execute( + "UPDATE posts SET title = 'Connection 1' WHERE title != 'Connection 1';" + ) + + # Decrease the lock wait timeout in this session + deadlocking_adapter.execute("SET innodb_lock_wait_timeout = 1") + + assert_raises(ActiveRecord::LockWaitTimeout) do + deadlocking_adapter.execute( + "UPDATE posts SET title = 'Connection 2' WHERE title != 'Connection 2';" + ) + end + end + end + + test "#execute fails with unknown error" do + assert_raises_with_message(ActiveRecord::StatementInvalid, /A random error/) do + connection = Minitest::Mock.new Trilogy.new(@configuration) + connection.expect(:query, nil) { raise Trilogy::DatabaseError, "A random error." } + adapter = trilogy_adapter_with_connection(connection) + + adapter.execute "SELECT * FROM posts;" + end + end + + test "#select_all when query cache is enabled fires the same notification payload for uncached and cached queries" do + @adapter.cache do + event_fired = false + subscription = ->(name, start, finish, id, payload) { + event_fired = true + + # First, we test keys that are defined by default by the AbstractAdapter + assert_includes payload, :sql + assert_equal "SELECT * FROM posts", payload[:sql] + + assert_includes payload, :name + assert_equal "uncached query", payload[:name] + + assert_includes payload, :connection + assert_equal @adapter, payload[:connection] + + assert_includes payload, :binds + assert_equal [], payload[:binds] + + assert_includes payload, :type_casted_binds + assert_equal [], payload[:type_casted_binds] + + # :stament_name is always nil and never set 🤷‍♂️ + assert_includes payload, :statement_name + assert_nil payload[:statement_name] + + refute_includes payload, :cached + } + ActiveSupport::Notifications.subscribed(subscription, "sql.active_record") do + @adapter.select_all "SELECT * FROM posts", "uncached query" + end + assert event_fired + + event_fired = false + subscription = ->(name, start, finish, id, payload) { + event_fired = true + + # First, we test keys that are defined by default by the AbstractAdapter + assert_includes payload, :sql + assert_equal "SELECT * FROM posts", payload[:sql] + + assert_includes payload, :name + assert_equal "cached query", payload[:name] + + assert_includes payload, :connection + assert_equal @adapter, payload[:connection] + + assert_includes payload, :binds + assert_equal [], payload[:binds] + + assert_includes payload, :type_casted_binds + assert_equal [], payload[:type_casted_binds].is_a?(Proc) ? payload[:type_casted_binds].call : payload[:type_casted_binds] + + # Rails does not include :stament_name for cached queries 🤷‍♂️ + refute_includes payload, :statement_name + + assert_includes payload, :cached + assert_equal true, payload[:cached] + } + ActiveSupport::Notifications.subscribed(subscription, "sql.active_record") do + @adapter.select_all "SELECT * FROM posts", "cached query" + end + assert event_fired + end + end + + test "#execute answers result with valid SQL" do + result = @adapter.execute "SELECT * FROM posts;" + + assert_equal %w[id author_id title body kind created_at updated_at], result.fields + assert_equal [], result.rows + end + + test "#execute emits a query notification" do + assert_notification("sql.active_record") do + @adapter.execute "SELECT * FROM posts;" + end + end + + test "#indexes answers indexes with existing indexes" do + proof = [{ + table: "posts", + name: "index_posts_on_kind", + unique: true, + columns: ["kind"], + lengths: {}, + orders: {}, + opclasses: {}, + where: nil, + type: nil, + using: :btree, + comment: nil + }, + { + table: "posts", + name: "index_posts_on_author_id", + unique: false, + columns: ["author_id"], + lengths: {}, + orders: {}, + opclasses: {}, + where: nil, + type: nil, + using: :btree, + comment: nil + }] + + indexes = @adapter.indexes("posts").map do |index| + { + table: index.table, + name: index.name, + unique: index.unique, + columns: index.columns, + lengths: index.lengths, + orders: index.orders, + opclasses: index.opclasses, + where: index.where, + type: index.type, + using: index.using, + comment: index.comment + } + end + + assert_equal proof, indexes + end + + test "#indexes answers empty array with no indexes" do + assert_equal [], @adapter.indexes("users") + end + + test "#begin_db_transaction answers empty result" do + result = @adapter.begin_db_transaction + assert_equal [], result.rows + + # rollback transaction so it doesn't bleed into other tests + @adapter.rollback_db_transaction + end + + test "#begin_db_transaction raises error" do + error = Class.new(Exception) + assert_raises error do + @adapter.stub(:raw_execute, -> (*) { raise error }) do + @adapter.begin_db_transaction + end + end + + # rollback transaction so it doesn't bleed into other tests + @adapter.rollback_db_transaction + end + + test "#commit_db_transaction answers empty result" do + result = @adapter.commit_db_transaction + assert_equal [], result.rows + end + + test "#commit_db_transaction raises error" do + error = Class.new(Exception) + assert_raises error do + @adapter.stub(:raw_execute, -> (*) { raise error }) do + @adapter.commit_db_transaction + end + end + end + + test "#rollback_db_transaction raises error" do + error = Class.new(Exception) + assert_raises error do + @adapter.stub(:raw_execute, -> (*) { raise error }) do + @adapter.rollback_db_transaction + end + end + end + + test "#insert answers ID with ID" do + assert_equal 5, @adapter.insert("INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('test', 'example', 'content', NOW(), NOW());", "test", nil, 5) + end + + test "#insert answers last ID without ID" do + assert_equal 1, @adapter.insert("INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('test', 'example', 'content', NOW(), NOW());", "test") + end + + test "#insert answers incremented last ID without ID" do + @adapter.insert("INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('test', 'one', 'content', NOW(), NOW());", "test") + assert_equal 2, @adapter.insert("INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('test', 'two', 'content', NOW(), NOW());", "test") + end + + test "#update answers affected row count when updatable" do + @adapter.insert("INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('test', 'example', 'content', NOW(), NOW());") + assert_equal 1, @adapter.update("UPDATE posts SET title = 'Test' WHERE id = 1;") + end + + test "#update answers zero affected rows when not updatable" do + assert_equal 0, @adapter.update("UPDATE posts SET title = 'Test' WHERE id = 1;") + end + + test "strict mode can be disabled" do + adapter = trilogy_adapter(strict: false) + + adapter.execute "INSERT INTO posts (title) VALUES ('test');" + result = adapter.execute "SELECT * FROM posts;" + assert_equal [[1, nil, "test", "", "", nil, nil]], result.rows + end + + test "#select_value returns a single value" do + assert_equal 123, @adapter.select_value("SELECT 123") + end + + test "#each_hash yields symbolized result rows" do + @adapter.execute "INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('test', 'example', 'content', NOW(), NOW());" + result = @adapter.execute "SELECT * FROM posts;" + + @adapter.each_hash(result) do |row| + assert_equal "test", row[:title] + end + end + + test "#each_hash returns an enumarator of symbolized result rows when no block is given" do + @adapter.execute "INSERT INTO posts (title, kind, body, created_at, updated_at) VALUES ('test', 'example', 'content', NOW(), NOW());" + result = @adapter.execute "SELECT * FROM posts;" + rows_enum = @adapter.each_hash result + + assert_equal "test", rows_enum.next[:title] + end + + test "#each_hash returns empty array when results is empty" do + result = @adapter.execute "SELECT * FROM posts;" + rows = @adapter.each_hash result + + assert_empty rows.to_a + end + + test "#error_number answers number for exception" do + exception = Minitest::Mock.new + exception.expect :error_code, 123 + + assert_equal 123, @adapter.error_number(exception) + end + + test "schema cache works without querying DB" do + adapter = trilogy_adapter + adapter.schema_cache = adapter.schema_cache.class.load_from(@schema_cache_fixture_path) + + flunk_cb = ->(name, started, finished, unique_id, payload) { puts caller; flunk "expected no queries, but got: #{payload[:sql]}" } + ActiveSupport::Notifications.subscribed(flunk_cb, "sql.active_record") do + adapter.schema_cache.data_source_exists?("users") + + # Should still be disconnected + assert_nil adapter.send(:connection) + end + end + + test "async queries can be run" do + return skip unless ActiveRecord::Base.respond_to?(:asynchronous_queries_tracker) + + @adapter.pool = @pool + + ActiveRecord::Base.asynchronous_queries_tracker.start_session + + payloads = [] + callback = lambda {|name, started, finished, unique_id, payload| + payloads << payload if payload[:name] != "SCHEMA" + } + + result = @adapter.select_all("SELECT 123", async: true) + assert result.pending? + 200.times do + break unless result.pending? + sleep 0.001 + end + refute result.pending? + + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + # Data is already loaded, but notifications are buffered + result.to_a + end + + assert_kind_of ActiveRecord::FutureResult, result + assert_equal [{"123" => 123}], result.to_a + + assert_equal 1, payloads.size + assert payloads[0][:async] + + ActiveRecord::Base.asynchronous_queries_tracker.finalize_session + end + + test "execute uses AbstractAdapter#transform_query when available" do + # We only want to test if QueryLogs functionality is available + skip unless ActiveRecord.respond_to?(:query_transformers) + + # Add custom query transformer + old_query_transformers = ActiveRecord.query_transformers + ActiveRecord.query_transformers = [-> (sql) { sql + " /* it works */" }] + + sql = "SELECT * FROM posts;" + + mock_connection = Minitest::Mock.new Trilogy.new(@configuration) + adapter = trilogy_adapter_with_connection(mock_connection) + mock_connection.expect :query, nil, [sql + " /* it works */"] + + adapter.execute sql + + assert mock_connection.verify + ensure + # Teardown custom query transformers + ActiveRecord.query_transformers = old_query_transformers if ActiveRecord.respond_to?(:query_transformers) + end + + def trilogy_adapter_with_connection(connection, **config_overrides) + ActiveRecord::ConnectionAdapters::TrilogyAdapter + .new(connection, nil, {}, @configuration.merge(config_overrides)) + .tap { |conn| conn.execute("SELECT 1") } + end + + def trilogy_adapter(**config_overrides) + ActiveRecord::ConnectionAdapters::TrilogyAdapter + .new(@configuration.merge(config_overrides)) + end +end diff --git a/test/lib/trilogy_adapter/lost_connection_exception_translator_test.rb b/test/lib/trilogy_adapter/lost_connection_exception_translator_test.rb new file mode 100644 index 0000000..cac2dff --- /dev/null +++ b/test/lib/trilogy_adapter/lost_connection_exception_translator_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "test_helper" + +class TrilogyAdapter::LostConnectionExceptionTranslatorTest < TestCase + test "#translate returns appropriate TrilogyAdapter error for Trilogy exceptions" do + translator = TrilogyAdapter::LostConnectionExceptionTranslator.new( + Trilogy::DatabaseError.new, + "ER_SERVER_SHUTDOWN 1053", + 1053 + ) + + assert_kind_of(TrilogyAdapter::Errors::ServerShutdown, translator.translate) + end + + test "#translate returns nil for Trilogy exceptions when the error code is not given" do + translator = TrilogyAdapter::LostConnectionExceptionTranslator.new( + Trilogy::DatabaseError.new, + "ER_SERVER_SHUTDOWN 1053", + nil + ) + + assert_nil translator.translate + end + + test "#translate returns appropriate TrilogyAdapter error for Ruby exceptions" do + translator = TrilogyAdapter::LostConnectionExceptionTranslator.new( + SocketError.new, + "Failed to open TCP connection", + nil + ) + + assert_kind_of(TrilogyAdapter::Errors::SocketError, translator.translate) + end + + test "#translate returns appropriate TrilogyAdapter error for lost connection Trilogy exceptions" do + translator = TrilogyAdapter::LostConnectionExceptionTranslator.new( + Trilogy::Error.new, + "TRILOGY_UNEXPECTED_PACKET", + nil + ) + + assert_kind_of(TrilogyAdapter::Errors::UnexpectedPacket, translator.translate) + end + + test "#translate returns nil for non-lost connection exceptions" do + translator = TrilogyAdapter::LostConnectionExceptionTranslator.new( + Trilogy::Error.new, + "Something bad happened but it wasn't a lost connection so...", + nil + ) + + assert_nil translator.translate + end +end diff --git a/test/support/fixtures/schema.dump b/test/support/fixtures/schema.dump new file mode 100644 index 0000000..5023c94 Binary files /dev/null and b/test/support/fixtures/schema.dump differ diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..7f3a3db --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "pry" +require "minitest/autorun" +require "minitest/focus" + +require "activerecord-trilogy-adapter" +require "trilogy_adapter/connection" +ActiveRecord::Base.public_send :extend, TrilogyAdapter::Connection + +if ActiveRecord.respond_to?(:async_query_executor) + ActiveRecord.async_query_executor = :multi_thread_pool +elsif ActiveRecord::Base.respond_to?(:async_query_executor) + ActiveRecord::Base.async_query_executor = :multi_thread_pool +end + +ENV["DB_HOST"] ||= "localhost" +ENV["DB_PORT"] ||= "3306" + +class TestCase < ActiveSupport::TestCase + DATABASE = "trilogy_test" + + setup do + @fixtures_path = Bundler.root.join "test", "support", "fixtures" + end + + def assert_raises_with_message(exception, message, &block) + block.call + rescue exception => error + assert_match message, error.message + else + fail %(Expected #{exception} with message "#{message}" but nothing failed.) + end + + # Create a temporary subscription to verify notification is sent. + # Optionally verify the notification payload includes expected types. + def assert_notification(notification, expected_payload = {}, &block) + notification_sent = false + + subscription = lambda do |*args| + notification_sent = true + event = ActiveSupport::Notifications::Event.new(*args) + + expected_payload.each do |key, value| + assert( + value === event.payload[key], + "Expected notification payload[:#{key}] to match #{value.inspect}, but got #{event.payload[key].inspect}." + ) + end + end + + ActiveSupport::Notifications.subscribed(subscription, notification) do + block.call if block_given? + end + + assert notification_sent, "#{notification} notification was not sent" + end + + # Create a temporary subscription to verify notification was not sent. + def assert_no_notification(notification, &block) + notification_sent = false + + subscription = lambda do |*args| + notification_sent = true + end + + ActiveSupport::Notifications.subscribed(subscription, notification) do + block.call if block_given? + end + + assert_not notification_sent, "#{notification} notification was sent" + end +end