Skip to content

Conversation

@andrewmarkle
Copy link
Contributor

@andrewmarkle andrewmarkle commented Nov 7, 2025

Add MySQL Support

This PR adds full MySQL support enabling the gem to work with both mysql2 and trilogy adapters alongside the existing SQLite support.

This is a bit of a big PR. If you prefer I would happily split this up into smaller chunks:

  • Get test infrastructure ready to support multiple database adapters
  • Add devcontainers that can run mysql
  • Add mysql adapter / tests

What's New

MySQL Database Adapter

  • Implemented ActiveRecord::Tenanted::DatabaseAdapters::MySQL with full feature parity to the SQLite adapter
  • Registered both mysql2 and trilogy adapters in the adapter registry

Devcontainer support

  • To make installing a mysql db and running the tests easy. No need to mess about with homebrew.

Testing Infrastructure

  • Added comprehensive MySQL test scenarios covering:
    • Primary database with tenanted models
    • Primary named database configuration
    • Secondary database configuration
    • All migration and schema testing scenarios
  • Updated integration test script (bin/test-integration) to:
    • Support MySQL-specific environment variables (MYSQL_UNIQUE_PREFIX, MYSQL_HOST)
    • Generate unique database names per test run to avoid conflicts
    • Handle database dropping in MySQL
  • Added MySQL 8.0 service to CI workflow for both unit and integration tests

Dependencies

  • Added mysql2 gem to development/test dependencies in Gemfile

Technical Details

The MySQL adapter implements the same interface as the SQLite adapter but with MySQL-specific behavior:

  • Tenant Discovery: Uses SQL pattern matching (SHOW DATABASES LIKE 'pattern') instead of file globbing
  • Database Names: Validates against MySQL's naming restrictions (max 64 characters, alphanumeric + underscore/$/hyphen only)
  • Connection Management: Uses temporary connections without database (which is very similar to how Rails does this)

Testing

All existing tests should pass with both SQLite and MySQL adapters. The integration test suite now runs scenarios against both database types, ensuring feature parity.

To test locally I've been using the devcontainer.

devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . bundle

# then you should be able to run the tests
devcontainer exec --workspace-folder . bin/test-unit
devcontainer exec --workspace-folder . bin/test-integration

Known Limitations

There are some features still missing to fully support MySQL. The main one is supporting the use-case for using a DATABASE_URL to set everything up. In theory this should just work but I haven't tested yet and want to fully test this in the gem. I'll open a PR for this later!

Breaking Changes

None. This is purely additive functionality.

@andrewmarkle andrewmarkle marked this pull request as draft November 7, 2025 15:08
@andrewmarkle andrewmarkle changed the title Mysql adapter MySQL adapter Nov 7, 2025
@andrewmarkle andrewmarkle marked this pull request as ready for review November 7, 2025 16:58
@flavorjones flavorjones force-pushed the mysql-adapter-with-polish branch from 11fde4d to d238e6b Compare November 9, 2025 20:02
@flavorjones
Copy link
Member

I rebased onto current main. Reviewing now!

Copy link
Member

@flavorjones flavorjones left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh my gosh, this PR is great, thank you for the huge contribution!

I have two primary reservations:

  1. the devcontainer setup seems very particular for your needs (v8.0 and not v8.4 or v9.0, and mounting /var/lib/mysql) and I'd like to move that to a separate PR or discussion if that's OK.
  2. parallel testing doesn't work for me -- does it work for you?

On (2), in particular, I tend to run the unit test suite highly parallelized (~20 workers). But even running with NCPU=2 for two workers raises exceptions:

ActiveRecord::Tenanted::Tenant::#cache_key::scenario::mysql/primary_named_db::secondary_record::created in tenanted context#test_includes_the_tenant_name:
ActiveRecord::StatementInvalid: Mysql2::Error: Table 'schema_migrations' already exists

Just curious if this is something that doesn't happen to you, in which case I'll dig in.

I've made quite a few comments in here, but many of them are "to-dos" and don't necessarily need to be fixed in this PR.

I'm going to spend some time in the morning appending small changes to this branch in response to some of my comments. Maybe we can collaborate a bit on getting this merged?

Thank you again!!! ❤️❤️🙏

@@ -0,0 +1,15 @@
FROM ghcr.io/rails/devcontainer/images/ruby:3.3.5
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is devcontainer introduced only to run the mysql server? This container is pretty big and has other utilities and libraries being installed that I don't think the test suite needs.

Why not just use the mysql docker image? I can imagine having a bin/setup script that runs something like:

docker run -d \
  --restart unless-stopped \
  -p "127.0.0.1:3306:3306" \
  --name=mysql84 \
  -e MYSQL_ROOT_PASSWORD= -e MYSQL_ALLOW_EMPTY_PASSWORD=true \
  mysql:8.4

Copy link
Contributor Author

@andrewmarkle andrewmarkle Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My goal was to just make setting up mysql easy and be able to run all the tests with this. To do that you also need chrome and maybe these other things...? (I'll have to verify!)

That said though we could definitely do a simpler thing though. I'll take a look at this!

runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth setting up a matrix to test against 8 and 9? And maybe even patch releases like 8.4?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could! I'm not that worried that we're doing anything too novel that would break b/w versions of mysql though. Probably testing against the latest version is good enough 🤷‍♂️. But I'm very open to doing that if you think it's a good idea.

We're really only doing "SHOW DATABASES LIKE '#{database_path}' and DROP DATABASE IF EXISTS which will work across all versions. The rest of the stuff we're just calling the built-in Rails methods which are already well tested. Just not too worried about it I guess!

puts "Creating integration app for #{COLOR_FG_BLUE}#{scenario}#{COLOR_RESET} at #{app_path}"

# Generate a unique database prefix for this scenario to avoid conflicts
db_prefix = "test_#{scenario[:database]}_#{scenario[:models]}_#{Process.pid}".gsub(/[^a-zA-Z0-9_]/, "_")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious what conflicts you're avoiding with this that DatabaseConfiguration#test_worker_id= isn't sufficient to prevent. Can you explain a bit more?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was having a bunch of contention issues before this when running integration tests in parallel.

In SQLite each test run uses a unique temporary directory which means that multiple test runs never touched the same database file.

But with MySQL it's different b/c we have the same shared server. What this means is that all parallel test runs originally used the same database name which caused collisions and contention. Different tests were trying to drop, create, or migrate using the same schema.

There are different ways we could set this up but I just went with an env variable that gets evaluated at runtime (<%= ENV.fetch('MYSQL_DB_PREFIX', 'primary') %>) from the database.yml. This happens when we db:drop, db:prepare, etc. and just ensures that each test database is unique from any others.

I actually didn't realize you could run the unit tests in parallel and I didn't implement something similar for them. But the problem you're seeing running the unit tests is the same problem that this was supposed to address.

Comment on lines -93 to -98
# create a fake tenant database to validate that it is deleted in the test suite
dev_db = Dir.glob("storage/development/*/development-tenant/main.sqlite3").first
test_db = dev_db.gsub("/development/", "/test/").gsub("development-tenant", "delete-me")
FileUtils.mkdir_p(File.dirname(test_db))
FileUtils.touch(test_db)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this being removed? This test database needs to be here to make this test meaningful:

https://github.com/basecamp/activerecord-tenanted/blob/main/test/integration/test/testcase_test.rb#L12-L15

And I think the mysql implementation also needs to delete existing tenant databases at the start of the test suite, though you may need to change this setup to something that will work for both databases, maybe:

run_cmd(scenario, "bin/rails db:migrate", env: { ARTENANT: "delete-me" })

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may have misunderstood what this does. I just added in a db:drop at the start of the test run which I thought was a bit simpler / handles all database types. But the test you mentioned would be redundant then: https://github.com/basecamp/activerecord-tenanted/blob/main/test/integration/test/testcase_test.rb#L12-L15.

We can keep this in if you want! But we still need to db:drop with mysql just to ensure that we're starting from a clean slate every time.


# set up the database and schema files
run_cmd(scenario, "bin/rails db:prepare")
adapter_config = {"RAILS_ENV" => "test","ARTENANT_SCHEMA_DUMP" => "1"}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On main, the integration test suite runs rails db:prepare in the development environment, and then the tests get run in the test environment (implicitly setting up the databases).

Can you say a bit more about this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this in b/c I kept getting this error when trying to run any database tasks.

bin/rails db:drop (1.89s)
bin/rails aborted!
ActiveRecord::EnvironmentMismatchError: You are attempting to modify a database that was last run in `test` environment. (ActiveRecord::EnvironmentMismatchError)
You are running in `development` environment. If you are sure you want to continue, first set the environment using:

        bin/rails db:environment:set RAILS_ENV=development


Tasks: TOP => db:drop => db:check_protected_environments
(See full trace by running task with --trace)
ERROR: Command failed

But I could easily be doing something wrong!

Comment on lines +94 to +96
rescue ActiveRecord::NoDatabaseError
database_already_initialized = false
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't feel like we should be rescuing this here. If you know this is a condition that will often happen, where table_exists? should return false, I'd prefer if we either fixed the method upstream and/or patched the method.

Looking at table_exists? I'm not sure if this error is raised by ConnectionPool#with_connection or ConnectionAdapter#data_source_exists?, do you know?

# dump the schema and schema cache
if Rails.env.development? || ENV["ARTENANT_SCHEMA_DUMP"].present?
if migrated
if migrated || !database_already_initialized
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to add the check for !database_already_initialized, unless I'm misunderstanding the intent. The tests all pass without it.

Comment on lines +33 to +34
rescue Capybara::ElementNotFound
sleep 0.1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this method rescuing and sleeping? Seems like it should raise an exception if the assert_selector failed.

end
end

describe "new_tenant_config" do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, this made me realize how little test coverage the gem has for this method. 😬

Comment on lines +383 to +396
test "only returns tenants from tenanted databases, not shared databases" do
assert_empty(base_config.tenants)

TenantedApplicationRecord.create_tenant("foo")

assert_equal([ "foo" ], base_config.tenants)

all_databases = ActiveRecord::Base.configurations.configs_for(env_name: base_config.env_name)

untenanted_config = all_databases.reject { |c| c.configuration_hash[:tenanted] }
untenanted_config.each do |shared_config|
assert_not_includes(base_config.tenants, shared_config.database)
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 This test implicitly depends on the naming in the mysql scenarios' database.yml files, so it's not testing anything under sqlite.

Instead, I'd like to rewrite this test to explicitly modify ActiveRecord::Base.configurations so there is an additional untenanted config that is named like a tenant.

@andrewmarkle
Copy link
Contributor Author

Thanks Mike! Feel free to make any changes as you see fit. I won’t have time to go through the feedback today but I will circle back this week. 👌

@flavorjones
Copy link
Member

@andrewmarkle I'm dealing with a fire drill at work, so I may not have much time this week. I'm very keen to land this so it's at the top of my "OSS to-do list". 🙌

@andrewmarkle andrewmarkle marked this pull request as draft November 14, 2025 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants