Rails and SQL Server Database Without Schema Modifications
Edu Depetris
- Jul 09, 2024- Github Actions
- Multiple Databases
- Sql Server
- Ruby On Rails
One of our clients asked us to extend their legacy software to track order shipping from warehouses. Their current software processes orders but doesn’t track shipping status and requires additional functionality.
This off-the-shelf software has been in use for many years, and now they need to track orders between the warehouse and their system, allowing warehouse interaction.
We face some constraints: the software uses a SQL Server database that we can’t modify but access, we don’t have the source code, and the exposed code is in a language we don’t prefer.
As Ruby enthusiasts, we decided to use Ruby on Rails and try out the multiple databases feature. We planned to have our own database to keep meta information and connect to the SQL Server to read and eventually write some columns.
To connect to the SQL Server (external) we used the Active Record SQL Server adapter, which worked smoothly:
We face some constraints: the software uses a SQL Server database that we can’t modify but access, we don’t have the source code, and the exposed code is in a language we don’t prefer.
As Ruby enthusiasts, we decided to use Ruby on Rails and try out the multiple databases feature. We planned to have our own database to keep meta information and connect to the SQL Server to read and eventually write some columns.
To connect to the SQL Server (external) we used the Active Record SQL Server adapter, which worked smoothly:
development: primary: <<: *default database: storage/development.sqlite3 external: adapter: sqlserver database_tasks: false azure: true database: "..." username: "..." password: "..." host: "..." test: primary: <<: *default database: storage/test.sqlite3 external: adapter: sqlserver database_tasks: false database: "test_db" username: "sa" password: "YourStrong!Passw0rd" host: "localhost"
Most of the configuration values under external are standard, except for database_tasks. This config value allows us to connect to the external database without any database management tasks such as schema management, migrations, seeds, etc. [Rails documentation, and pull request].
This is great because we’re not able to touch the schema or run operations on it.
Our external models look like this:
# app/models/external_record.rb class ExternalRecord < ApplicationRecord self.abstract_class = true connects_to database: { writing: :external, reading: :external } end # app/models/external/order_reference.rb module External class OrderReference < ExternalRecord self.table_name = "Extension.OrderReferences" end end
🎉 Great! We are able to use Active Record with our external database!
This solves half of the problem. Now, we want to have tests and a CI running with this configuration, but we don’t want to connect to an external host in these environments.
For our tests, we’re using RSpec and FactoryBot, and for CI, we use GitHub Actions.
Initially, our tests crashed because we didn’t have any database or table to match our models. Our solution was to provide a SQL Server database loaded with some tables to Rails so the app can connect and use it.
I’m not using a Windows machine and have never installed SQL Server, so I decided to use Docker to get the SQL Server running.
Here’s my Docker Compose configuration:
# We are using the official Microsoft SQL Server Docker image. version: '3.8' services: mssql: image: mcr.microsoft.com/mssql/server:2019-latest ports: - 1433:1433 environment: # The ACCEPT_EULA variable must be set to "Y" to accept the license agreement. ACCEPT_EULA: "Y" # The SA_PASSWORD variable sets the password for the SQL Server system administrator (SA) user. SA_PASSWORD: "YourStrong!Passw0rd" healthcheck: # Health check to ensure the SQL Server service is ready before running the tests. test: ["CMD-SHELL", "echo 'SELECT 1' | /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd"] interval: 10s timeout: 5s retries: 10
Then:
docker-compose up -d
Sweet, we have our server running. Now let’s create the database and some tables:
I exported some tables from the external database using TablePlus and saved it into db/external_schemas.sql.
# Create the database $ docker-compose exec mssql /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'YourStrong!Passw0rd' -Q "CREATE DATABASE [test_db];" # Copy the schema file to the instance $ docker cp db/external_schemas.sql your_app-mssql-1:/external_schemas.sql # Create the schema $ docker-compose exec mssql /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'YourStrong!Passw0rd' -d test_db -i /external_schemas.sql
✅ Now tests are working and we’re able to use our factories:
# spec/factories/external/order_references.rb FactoryBot.define do factory :external_order_reference, class: "External::OrderReference" do OrderID { Faker::Number.non_zero_digit } OrderEventID { SecureRandom.uuid } RowGuid { SecureRandom.uuid } # ... end end # spec/models/external/order_reference_spec.rb require "rails_helper" RSpec.describe External::OrderReference, type: :model do it "has the correct table name" do expect(described_class.table_name).to eq("Extension.OrderReferences") end it "has a valid factory" do expect(build(:external_order_reference)).to be_valid end end
Our final step is to configure the GitHub Action CI with a similar setup for SQL Server
Here’s a snippet showing that:
rspec: runs-on: ubuntu-latest services: # Others # omitted # External mssql: image: mcr.microsoft.com/mssql/server:2019-latest ports: - 1433:1433 env: ACCEPT_EULA: Y SA_PASSWORD: "YourStrong!Passw0rd" options: >- --health-cmd "echo 'SELECT 1' | /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P YourStrong!Passw0rd" --health-interval 10s --health-timeout 5s --health-retries 10 env: RAILS_ENV: test steps: # Install some packages here # omitted # activerecord-sqlserver-adapter needs FreeTDS - name: Install FreeTDS and dependencies run: | sudo apt-get install -y freetds-dev freetds-bin build-essential - name: Set FreeTDS config for gem installation run: | echo "FREETDS_DIR=/usr" >> $GITHUB_ENV - name: Checkout code uses: actions/checkout@v4 - name: Install Ruby and gems uses: ruby/setup-ruby@v1 with: ruby-version: .ruby-version bundler-cache: true - name: Wait for SQL Server to be ready run: | echo "Waiting for SQL Server to start..." until /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -Q 'SELECT 1' &> /dev/null; do echo -n '.' sleep 1 done - name: Create test database run: | /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -Q 'CREATE DATABASE [test_db];' - name: Create schema from SQL script run: | /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -d test_db -i db/external_schemas.sql - name: Prepare Primary database run: bin/rails db:test:prepare - name: Run tests run: bundle exec rspec
The final ✅: Tests are running in our CI too.
Future Improvements:
There’s room for improving the developer experience. I would like to remove the database_tasks from the test database and rely on db:test:prepare. The future flow will be:
- Get an external database dump via Rails $ bin/rails db:schema:dump:external
- Remove the Docker commands to create the database and load the schema, and rely on $ bin/rails db:test:prepare.
Currently, my external database has multiple schemas, and the dump only works with dbo, but I saw an open PR that might address that here.
Also, my database has views, and they fail to load. There seems to be a difference between the dump generation and the load. This issue will probably be fixed. I think this GitHub pull request contains the approach, and I’ll keep an eye on the comments.
Happy Coding!