diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..cdaa92ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,21 @@ +## Steps to reproduce + +## Expected behavior + +## Actual behavior + +## System configuration + + + +* Database: (Tell us what database and its version you use.) + +* Apartment version: + +* Apartment config (in `config/initializers/apartment.rb` or so): + + * `use_schemas`: (`true` or `false`) + +* Rails (or ActiveRecord) version: + +* Ruby version: diff --git a/.gitignore b/.gitignore index 5588c1d0..01c3930e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .bundle *.lock gemfiles/*.lock +gemfiles/vendor pkg/* *.log .idea diff --git a/.ruby-gemset b/.ruby-gemset deleted file mode 100644 index 421ee4a7..00000000 --- a/.ruby-gemset +++ /dev/null @@ -1 +0,0 @@ -apartment diff --git a/.ruby-version b/.ruby-version index 81af5fe5..aedc15bb 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-2.3 +2.5.3 diff --git a/.travis.yml b/.travis.yml index f694ec4a..299033e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,23 +1,44 @@ language: ruby rvm: - - 2.0.0 - 2.1.9 - - 2.2.4 - - 2.3.1 - - jruby-9.0.5.0 + - 2.2.9 + - 2.3.6 + - 2.4.3 + - 2.5.0 + - ruby-head + - jruby-9.1.15.0 gemfile: - - gemfiles/rails_3_2.gemfile - - gemfiles/rails_4_0.gemfile - - gemfiles/rails_4_1.gemfile - gemfiles/rails_4_2.gemfile + - gemfiles/rails_5_0.gemfile + - gemfiles/rails_5_1.gemfile + - gemfiles/rails_5_2.gemfile + - gemfiles/rails_master.gemfile bundler_args: --without local before_install: - gem install bundler -v '> 1.5.0' env: RUBY_GC_MALLOC_LIMIT: 90000000 - RUBY_FREE_MIN: 200000 + RUBY_GC_HEAP_FREE_SLOTS: 200000 matrix: + allow_failures: + - rvm: ruby-head + - gemfile: gemfiles/rails_master.gemfile + - rvm: jruby-9.1.15.0 + gemfile: gemfiles/rails_5_0.gemfile exclude: - - rvm: 2.2.0 - gemfile: gemfiles/rails_3_2.gemfile + - rvm: 2.1.9 + gemfile: gemfiles/rails_5_0.gemfile + - rvm: 2.1.9 + gemfile: gemfiles/rails_5_1.gemfile + - rvm: 2.1.9 + gemfile: gemfiles/rails_5_2.gemfile + - rvm: 2.1.9 + gemfile: gemfiles/rails_master.gemfile + - rvm: jruby-9.1.15.0 + gemfile: gemfiles/rails_5_1.gemfile + - rvm: jruby-9.1.15.0 + gemfile: gemfiles/rails_5_2.gemfile + - rvm: jruby-9.1.15.0 + gemfile: gemfiles/rails_master.gemfile fast_finish: true +cache: bundler diff --git a/Appraisals b/Appraisals index f9236f80..ebe71c75 100644 --- a/Appraisals +++ b/Appraisals @@ -1,20 +1,54 @@ -appraise "rails-3-2" do - gem "rails", "~> 3.2" - gem "test-unit", "~> 3.0" +appraise "rails-4-2" do + gem "rails", "~> 4.2.0" + platforms :ruby do + gem "pg", "< 1.0.0" + gem "mysql2", "~> 0.4.0" + end + platforms :jruby do + gem 'activerecord-jdbc-adapter', '~> 1.3' + gem 'activerecord-jdbcpostgresql-adapter', '~> 1.3' + gem 'activerecord-jdbcmysql-adapter', '~> 1.3' + end end -appraise "rails-4-0" do - gem "rails", "~> 4.0" +appraise "rails-5-0" do + gem "rails", "~> 5.0.0" + platforms :ruby do + gem "pg", "< 1.0.0" + end + platforms :jruby do + gem 'activerecord-jdbc-adapter', '~> 50.0' + gem 'activerecord-jdbcpostgresql-adapter', '~> 50.0' + gem 'activerecord-jdbcmysql-adapter', '~> 50.0' + end end -appraise "rails-4-1" do - gem "rails", "~> 4.1" +appraise "rails-5-1" do + gem "rails", "~> 5.1.0" + platforms :ruby do + gem "pg", "< 1.0.0" + end + platforms :jruby do + gem 'activerecord-jdbc-adapter', '~> 51.0' + gem 'activerecord-jdbcpostgresql-adapter', '~> 51.0' + gem 'activerecord-jdbcmysql-adapter', '~> 51.0' + end end -appraise "rails-4-2" do - gem "rails", "~> 4.2" +appraise "rails-5-2" do + gem "rails", "~> 5.2.0" + platforms :jruby do + gem 'activerecord-jdbc-adapter', '~> 51.0' + gem 'activerecord-jdbcpostgresql-adapter', '~> 51.0' + gem 'activerecord-jdbcmysql-adapter', '~> 51.0' + end end -appraise "rails-5-0" do - gem "rails", "~> 5.0" +appraise "rails-master" do + gem "rails", git: 'https://github.com/rails/rails.git' + platforms :jruby do + gem 'activerecord-jdbc-adapter', '~> 51.0' + gem 'activerecord-jdbcpostgresql-adapter', '~> 51.0' + gem 'activerecord-jdbcmysql-adapter', '~> 51.0' + end end diff --git a/HISTORY.md b/HISTORY.md index 5a8907f6..162403fc 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,53 @@ +# 2.2.0 + * April 14, 2018 + +## Added + - #523: Add Rails 5.2 support [IngusSkaistkalns] + - #504: Test against Ruby 2.5.0 [ahorek] + - #528: Test against Rails 5.2 [meganemura] + +## Removed + - #504: Remove Rails 4.0/4.1 support [ahorek] + - #545: Stop supporting for JRuby + Rails 5.0 [meganemura] + +## Fixed + - #537: Fix PostgresqlSchemaFromSqlAdapter for newer PostgreSQL [shterrett] + - #532: Issue is reported by [aldrinmartoq] + - #519: Fix exception when main database doesn't exist [mayeco] + +## Changed + - #514: Fix typo [menorval] + +# 2.1.0 + * December 15, 2017 + + - Add `parallel_migration_threads` configuration option for running migrations + in parallel [ryanbrunner] + - Drop Ruby 2.0.0 support [meganemura] + - ignore_private when parsing subdomains with PublicSuffix [michiomochi] + - Ignore row_security statements in psql dumps for backward compatibility + [meganemura] + - "Host" elevator [shrmnk] + - Enhance db:drop task to act on all tenants [kuzukuzu] + +# 2.0.0 + * July 26, 2017 + + - Raise FileNotFound rather than abort when loading files [meganemura] + - Add 5.1 support with fixes for deprecations [meganemura] + - Fix tests for 5.x and a host of dev-friendly improvements [meganemura] + - Keep query cache config after switching databases [fernandomm] + - Pass constants not strings to middleware stack (Rails 5) [tzabaman] + - Remove deprecations from 1.0.0 [caironoleto] + - Replace `tld_length` configuration option with PublicSuffix gem for the + subdomain elevator [humancopy] + - Pass full config to create_database to allow :encoding/:collation/etc + [kakipo] + - Don't retain a connection during initialization [mikecmpbll] + - Fix database name escaping in drop_command [mikecmpbll] + - Skip initialization for assets:clean and assets:precompile tasks + [frank-west-iii] + # 1.2.0 * July 28, 2016 diff --git a/README.md b/README.md index 3378b0dc..9b3ced2a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Apartment -[![Code Climate](https://codeclimate.com/github/influitive/apartment.png)](https://codeclimate.com/github/influitive/apartment) -[![Build Status](https://secure.travis-ci.org/influitive/apartment.png?branch=development)](http://travis-ci.org/influitive/apartment) + +[![Gem Version](https://badge.fury.io/rb/apartment.svg)](https://badge.fury.io/rb/apartment) +[![Code Climate](https://codeclimate.com/github/influitive/apartment/badges/gpa.svg)](https://codeclimate.com/github/influitive/apartment) +[![Build Status](https://travis-ci.org/influitive/apartment.svg?branch=development)](https://travis-ci.org/influitive/apartment) *Multitenancy for Rails and ActiveRecord* @@ -29,7 +31,6 @@ this poll, we'd greatly appreciated it. gem 'rails', '4.2.1', github: 'influitive/rails', tag: 'v4.2.1.memfix' ``` - ## Installation ### Rails @@ -57,7 +58,9 @@ on a per-user basis, look under "Usage - Switching tenants per request", below. > * for Rails 3.1.x: _Rails ~> 3.1.2_, it contains a [patch](https://github.com/rails/rails/pull/3232) that makes prepared statements work with multiple schemas ## Usage + ### Video Tutorial + How to separate your application data into different accounts or companies. [GoRails #47](https://gorails.com/episodes/multitenancy-with-apartment) @@ -91,18 +94,26 @@ One can optionally use the full database creation instead if they want, though t To switch tenants using Apartment, use the following command: ```ruby -Apartment::Tenant.switch!('tenant_name') +Apartment::Tenant.switch('tenant_name') do + # ... +end ``` When switch is called, all requests coming to ActiveRecord will be routed to the tenant -you specify (with the exception of excluded models, see below). To return to the 'root' -tenant, call switch with no arguments. +you specify (with the exception of excluded models, see below). The tenant is automatically +switched back at the end of the block to what it was before. + +There is also `switch!` which doesn't take a block, but it's recommended to use `switch`. +To return to the default tenant, you can call `switch` with no arguments. ### Switching Tenants per request You can have Apartment route to the appropriate tenant by adding some Rack middleware. Apartment can support many different "Elevators" that can take care of this routing to your data. +**NOTE: when switching tenants per-request, keep in mind that the order of your Rack middleware is important.** +See the [Middleware Considerations](#middleware-considerations) section for more. + The initializer above will generate the appropriate code for the Subdomain elevator by default. You can see this in `config/initializers/apartment.rb` after running that generator. If you're *not* using the generator, you can specify your @@ -111,30 +122,22 @@ manually in your `application.rb` like so ```ruby # config/application.rb -require 'apartment/elevators/subdomain' # or 'domain' or 'generic' +require 'apartment/elevators/subdomain' # or 'domain', 'first_subdomain', 'host' ``` -**Switch on subdomain** +#### Switch on subdomain + In house, we use the subdomain elevator, which analyzes the subdomain of the request and switches to a tenant schema of the same name. It can be used like so: ```ruby # application.rb module MyApplication class Application < Rails::Application - config.middleware.use 'Apartment::Elevators::Subdomain' + config.middleware.use Apartment::Elevators::Subdomain end end ``` -By default, the subdomain elevator assumes that the parent domain consists of two segments, e.g. 'example.com'. If this isn't the case, you can adjust the `tld_length` (top level domain length) configuration variable, which defaults to 1. For example, if you are using 'localhost' in development: -```ruby -# config/initializers/apartment.rb -Apartment.configure do |config| - ... - config.tld_length = 0 if Rails.env == 'development' -end -``` - If you want to exclude a domain, for example if you don't want your application to treat www like a subdomain, in an initializer in your application, you can set the following: ```ruby @@ -144,60 +147,94 @@ Apartment::Elevators::Subdomain.excluded_subdomains = ['www'] This functions much in the same way as Apartment.excluded_models. This example will prevent switching your tenant when the subdomain is www. Handy for subdomains like: "public", "www", and "admin" :) -**Switch on first subdomain** -To switch on the first subdomain, which analyzes the chain of subdomains of the request and switches to a tenant schema of the first name in the chain (e.g. owls.birds.animals.com would switch to "owl"). It can be used like so: +#### Switch on first subdomain + +To switch on the first subdomain, which analyzes the chain of subdomains of the request and switches to a tenant schema of the first name in the chain (e.g. owls.birds.animals.com would switch to "owls"). It can be used like so: ```ruby # application.rb module MyApplication class Application < Rails::Application - config.middleware.use 'Apartment::Elevators::FirstSubdomain' + config.middleware.use Apartment::Elevators::FirstSubdomain end end ``` -If you want to exclude a domain, for example if you don't want your application to treate www like a subdomain, in an initializer in your application, you can set the following: +If you want to exclude a domain, for example if you don't want your application to treat www like a subdomain, in an initializer in your application, you can set the following: ```ruby # config/initializers/apartment/subdomain_exclusions.rb Apartment::Elevators::FirstSubdomain.excluded_subdomains = ['www'] ``` -This functions much in the same way as the Subdomain elevator. +This functions much in the same way as the Subdomain elevator. **NOTE:** in fact, at the time of this writing, the `Subdomain` and `FirstSubdomain` elevators both use the first subdomain ([#339](https://github.com/influitive/apartment/issues/339#issuecomment-235578610)). If you need to switch on larger parts of a Subdomain, consider using a Custom Elevator. -**Switch on domain** -To switch based on full domain (excluding subdomains *ie 'www'* and top level domains *ie '.com'* ) use the following: +#### Switch on domain + +To switch based on full domain (excluding the 'www' subdomains and top level domains *ie '.com'* ) use the following: ```ruby # application.rb module MyApplication class Application < Rails::Application - config.middleware.use 'Apartment::Elevators::Domain' + config.middleware.use Apartment::Elevators::Domain end end ``` -**Switch on full host using a hash** +Note that if you have several subdomains, then it will match on the first *non-www* subdomain: +- example.com => example +- www.example.com => example +- a.example.com => a + +#### Switch on full host using a hash + To switch based on full host with a hash to find corresponding tenant name use the following: ```ruby # application.rb module MyApplication class Application < Rails::Application - config.middleware.use 'Apartment::Elevators::HostHash', {'example.com' => 'example_tenant'} + config.middleware.use Apartment::Elevators::HostHash, {'example.com' => 'example_tenant'} end end ``` -**Custom Elevator** -A Generic Elevator exists that allows you to pass a `Proc` (or anything that responds to `call`) to the middleware. This Object will be passed in an `ActionDispatch::Request` object when called for you to do your magic. Apartment will use the return value of this proc to switch to the appropriate tenant. Use like so: +#### Switch on full host, ignoring given first subdomains + +To switch based on full host to find corresponding tenant name use the following: + +```ruby +# application.rb +module MyApplication + class Application < Rails::Application + config.middleware.use Apartment::Elevators::Host + end +end +``` + +If you want to exclude a first-subdomain, for example if you don't want your application to include www in the matching, in an initializer in your application, you can set the following: + +```ruby +Apartment::Elevators::Host.ignored_first_subdomains = ['www'] +``` + +With the above set, these would be the results: +- example.com => example.com +- www.example.com => example.com +- a.example.com => a.example.com +- www.a.example.com => a.example.com + +#### Custom Elevator + +A Generic Elevator exists that allows you to pass a `Proc` (or anything that responds to `call`) to the middleware. This Object will be passed in an `ActionDispatch::Request` object when called for you to do your magic. Apartment will use the return value of this proc to switch to the appropriate tenant. Use like so: ```ruby # application.rb module MyApplication class Application < Rails::Application # Obviously not a contrived example - config.middleware.use 'Apartment::Elevators::Generic', Proc.new { |request| request.host.reverse } + config.middleware.use Apartment::Elevators::Generic, Proc.new { |request| request.host.reverse } end end ``` @@ -225,6 +262,28 @@ class MyCustomElevator < Apartment::Elevators::Generic end ``` +#### Middleware Considerations + +In the examples above, we show the Apartment middleware being appended to the Rack stack with + +```ruby +Rails.application.config.middleware.use Apartment::Elevators::Subdomain +``` + +By default, the Subdomain middleware switches into a Tenant based on the subdomain at the beginning of the request, and when the request is finished, it switches back to the "public" Tenant. This happens in the [Generic](https://github.com/influitive/apartment/blob/development/lib/apartment/elevators/generic.rb#L22) elevator, so all elevators that inherit from this elevator will operate as such. + +It's also good to note that Apartment switches back to the "public" tenant any time an error is raised in your application. + +This works okay for simple applications, but it's important to consider that you may want to maintain the "selected" tenant through different parts of the Rack application stack. For example, the [Devise](https://github.com/plataformatec/devise) gem adds the `Warden::Manager` middleware at the end of the stack in the examples above, our `Apartment::Elevators::Subdomain` middleware would come after it. Trouble is, Apartment resets the selected tenant after the request is finish, so some redirects (e.g. authentication) in Devise will be run in the context of the "public" tenant. The same issue would also effect a gem such as the [better_errors](https://github.com/charliesome/better_errors) gem which inserts a middleware quite early in the Rails middleware stack. + +To resolve this issue, consider adding the Apartment middleware at a location in the Rack stack that makes sense for your needs, e.g.: + +```ruby +Rails.application.config.middleware.insert_before Warden::Manager, Apartment::Elevators::Subdomain +``` + +Now work done in the Warden middleware is wrapped in the `Apartment::Tenant.switch` context started in the Generic elevator. + ### Dropping Tenants To drop tenants using Apartment, use the following command: @@ -251,7 +310,7 @@ end ### Excluding models -If you have some models that should always access the 'public' tenant, you can specify this by configuring Apartment using `Apartment.configure`. This will yield a config object for you. You can set excluded models like so: +If you have some models that should always access the 'public' tenant, you can specify this by configuring Apartment using `Apartment.configure`. This will yield a config object for you. You can set excluded models like so: ```ruby config.excluded_models = ["User", "Company"] # these models will not be multi-tenanted, but remain in the global (public) namespace @@ -259,7 +318,7 @@ config.excluded_models = ["User", "Company"] # these models will not be m Note that a string representation of the model name is now the standard so that models are properly constantized when reloaded in development -Rails will always access the 'public' tenant when accessing these models, but note that tables will be created in all schemas. This may not be ideal, but its done this way because otherwise rails wouldn't be able to properly generate the schema.rb file. +Rails will always access the 'public' tenant when accessing these models, but note that tables will be created in all schemas. This may not be ideal, but its done this way because otherwise rails wouldn't be able to properly generate the schema.rb file. > **NOTE - Many-To-Many Excluded Models:** > Since model exclusions must come from referencing a real ActiveRecord model, `has_and_belongs_to_many` is NOT supported. In order to achieve a many-to-many relationship for excluded models, you MUST use `has_many :through`. This way you can reference the join model in the excluded models configuration. @@ -267,6 +326,7 @@ Rails will always access the 'public' tenant when accessing these models, but n ### Postgresql Schemas ## Providing a Different default_schema + By default, ActiveRecord will use `"$user", public` as the default `schema_search_path`. This can be modified if you wish to use a different default schema be setting: ```ruby @@ -276,14 +336,16 @@ config.default_schema = "some_other_schema" With that set, all excluded models will use this schema as the table name prefix instead of `public` and `reset` on `Apartment::Tenant` will return to this schema as well. ## Persistent Schemas -Apartment will normally just switch the `schema_search_path` whole hog to the one passed in. This can lead to problems if you want other schemas to always be searched as well. Enter `persistent_schemas`. You can configure a list of other schemas that will always remain in the search path, while the default gets swapped out: + +Apartment will normally just switch the `schema_search_path` whole hog to the one passed in. This can lead to problems if you want other schemas to always be searched as well. Enter `persistent_schemas`. You can configure a list of other schemas that will always remain in the search path, while the default gets swapped out: ```ruby config.persistent_schemas = ['some', 'other', 'schemas'] ``` ### Installing Extensions into Persistent Schemas -Persistent Schemas have numerous useful applications. [Hstore](http://www.postgresql.org/docs/9.1/static/hstore.html), for instance, is a popular storage engine for Postgresql. In order to use extensions such as Hstore, you have to install it to a specific schema and have that always in the `schema_search_path`. + +Persistent Schemas have numerous useful applications. [Hstore](http://www.postgresql.org/docs/9.1/static/hstore.html), for instance, is a popular storage engine for Postgresql. In order to use extensions such as Hstore, you have to install it to a specific schema and have that always in the `schema_search_path`. When using extensions, keep in mind: * Extensions can only be installed into one schema per database, so we will want to install it into a schema that is always available in the `schema_search_path` @@ -306,7 +368,6 @@ When using extensions, keep in mind: # BEFORE db:migrate. # ################################################## - namespace :db do desc 'Also create shared_extensions Schema' task :extensions => :environment do @@ -316,6 +377,8 @@ namespace :db do ActiveRecord::Base.connection.execute 'CREATE EXTENSION IF NOT EXISTS HSTORE SCHEMA shared_extensions;' # Enable UUID-OSSP ActiveRecord::Base.connection.execute 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA shared_extensions;' + # Grant usage to public + ActiveRecord::Base.connection.execute 'GRANT usage ON SCHEMA shared_extensions to public;' end end @@ -330,7 +393,7 @@ end #### 2. Ensure the schema is in Rails' default connection -Next, your `database.yml` file must mimic what you've set for your default and persistent schemas in Apartment. When you run migrations with Rails, it won't know about the extensions schema because Apartment isn't injected into the default connection, it's done on a per-request basis, therefore Rails doesn't know about `hstore` or `uuid-ossp` during migrations. To do so, add the following to your `database.yml` for all environments +Next, your `database.yml` file must mimic what you've set for your default and persistent schemas in Apartment. When you run migrations with Rails, it won't know about the extensions schema because Apartment isn't injected into the default connection, it's done on a per-request basis, therefore Rails doesn't know about `hstore` or `uuid-ossp` during migrations. To do so, add the following to your `database.yml` for all environments ```yaml # database.yml @@ -351,6 +414,7 @@ This would be for a config with `default_schema` set to `public` and `persistent To double check, login to the console of your Heroku app and see if `Apartment.connection.schema_search_path` is `public,hstore` #### 3. Ensure the schema is in the apartment config + ```ruby # config/initializers/apartment.rb ... @@ -359,6 +423,7 @@ config.persistent_schemas = ['shared_extensions'] ``` #### Alternative: Creating schema by default + Another way that we've successfully configured hstore for our applications is to add it into the postgresql template1 database so that every tenant that gets created has it by default. @@ -377,7 +442,8 @@ also contain the tenanted tables, which is an open issue with no real milestone Happy to accept PR's on the matter. #### Alternative: Creating new schemas by using raw SQL dumps -Apartment can be forced to use raw SQL dumps insted of `schema.rb` for creating new schemas. Use this when you are using some extra features in postgres that can't be respresented in `schema.rb`, like materialized views etc. + +Apartment can be forced to use raw SQL dumps insted of `schema.rb` for creating new schemas. Use this when you are using some extra features in postgres that can't be represented in `schema.rb`, like materialized views etc. This only applies while using postgres adapter and `config.use_schemas` is set to `true`. (Note: this option doesn't use `db/structure.sql`, it creates SQL dump by executing `pg_dump`) @@ -387,12 +453,11 @@ Enable this option with: config.use_sql = true ``` - ### Managing Migrations In order to migrate all of your tenants (or postgresql schemas) you need to provide a list -of dbs to Apartment. You can make this dynamic by providing a Proc object to be called on migrations. -This object should yield an array of string representing each tenant name. Example: +of dbs to Apartment. You can make this dynamic by providing a Proc object to be called on migrations. +This object should yield an array of string representing each tenant name. Example: ```ruby # Dynamically get tenant names to migrate @@ -415,11 +480,22 @@ Note that you can disable the default migrating of all tenants with `db:migrate` `Apartment.db_migrate_tenants = false` in your `Rakefile`. Note this must be done *before* the rake tasks are loaded. ie. before `YourApp::Application.load_tasks` is called +#### Parallel Migrations + +Apartment supports parallelizing migrations into multiple threads when +you have a large number of tenants. By default, parallel migrations is +turned off. You can enable this by setting `parallel_migration_threads` to +the number of threads you want to use in your initializer. + +Keep in mind that because migrations are going to access the database, +the number of threads indicated here should be less than the pool size +that Rails will use to connect to your database. + ### Handling Environments By default, when not using postgresql schemas, Apartment will prepend the environment to the tenant name -to ensure there is no conflict between your environments. This is mainly for the benefit of your development -and test environments. If you wish to turn this option off in production, you could do something like: +to ensure there is no conflict between your environments. This is mainly for the benefit of your development +and test environments. If you wish to turn this option off in production, you could do something like: ```ruby config.prepend_environment = !Rails.env.production? @@ -453,8 +529,9 @@ config.tenant_names = lambda do end ``` -## Delayed::Job -### Has been removed... See apartment-sidekiq for a better backgrounding experience +## Background workers + +See [apartment-sidekiq](https://github.com/influitive/apartment-sidekiq) or [apartment-activejob](https://github.com/influitive/apartment-activejob). ## Contributing @@ -462,13 +539,11 @@ end * Copy them into the same directory but with the name `database.yml` * Edit them to fit your own settings * Rake tasks (see the Rakefile) will help you setup your dbs necessary to run tests -* Please issue pull requests to the `development` branch. All development happens here, master is used for releases -* Ensure that your code is accompanied with tests. No code will be merged without tests +* Please issue pull requests to the `development` branch. All development happens here, master is used for releases. +* Ensure that your code is accompanied with tests. No code will be merged without tests * If you're looking to help, check out the TODO file for some upcoming changes I'd like to implement in Apartment. ## License Apartment is released under the [MIT License](http://www.opensource.org/licenses/MIT). - -[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/influitive/apartment/trend.png)](https://bitdeli.com/free "Bitdeli Badge") diff --git a/Rakefile b/Rakefile index 8bb751d0..df67edc6 100644 --- a/Rakefile +++ b/Rakefile @@ -9,7 +9,7 @@ require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec => %w{ db:copy_credentials db:test:prepare }) do |spec| spec.pattern = "spec/**/*_spec.rb" - # spec.rspec_opts = '--order rand:16996' + # spec.rspec_opts = '--order rand:47078' end namespace :spec do @@ -51,15 +51,26 @@ namespace :postgres do desc 'Build the PostgreSQL test databases' task :build_db do - %x{ createdb -E UTF8 #{pg_config['database']} -U#{pg_config['username']} } rescue "test db already exists" + params = [] + params << "-E UTF8" + params << pg_config['database'] + params << "-U#{pg_config['username']}" + params << "-h#{pg_config['host']}" if pg_config['host'] + params << "-p#{pg_config['port']}" if pg_config['port'] + %x{ createdb #{params.join(' ')} } rescue "test db already exists" ActiveRecord::Base.establish_connection pg_config - ActiveRecord::Migrator.migrate('spec/dummy/db/migrate') + migrate end desc "drop the PostgreSQL test database" task :drop_db do puts "dropping database #{pg_config['database']}" - %x{ dropdb #{pg_config['database']} -U#{pg_config['username']} } + params = [] + params << pg_config['database'] + params << "-U#{pg_config['username']}" + params << "-h#{pg_config['host']}" if pg_config['host'] + params << "-p#{pg_config['port']}" if pg_config['port'] + %x{ dropdb #{params.join(' ')} } end end @@ -70,15 +81,23 @@ namespace :mysql do desc 'Build the MySQL test databases' task :build_db do - %x{ mysqladmin -u #{my_config['username']} --password=#{my_config['password']} create #{my_config['database']} } rescue "test db already exists" + params = [] + params << "-h #{my_config['host']}" if my_config['host'] + params << "-u #{my_config['username']}" if my_config['username'] + params << "-p#{my_config['password']}" if my_config['password'] + %x{ mysqladmin #{params.join(' ')} create #{my_config['database']} } rescue "test db already exists" ActiveRecord::Base.establish_connection my_config - ActiveRecord::Migrator.migrate('spec/dummy/db/migrate') + migrate end desc "drop the MySQL test database" task :drop_db do puts "dropping database #{my_config['database']}" - %x{ mysqladmin -u #{my_config['username']} --password=#{my_config['password']} drop #{my_config['database']} --force} + params = [] + params << "-h #{my_config['host']}" if my_config['host'] + params << "-u #{my_config['username']}" if my_config['username'] + params << "-p#{my_config['password']}" if my_config['password'] + %x{ mysqladmin #{params.join(' ')} drop #{my_config['database']} --force} end end @@ -95,3 +114,15 @@ end def my_config config['mysql'] end + +def activerecord_below_5_2? + ActiveRecord.version.release < Gem::Version.new('5.2.0') +end + +def migrate + if activerecord_below_5_2? + ActiveRecord::Migrator.migrate('spec/dummy/db/migrate') + else + ActiveRecord::MigrationContext.new('spec/dummy/db/migrate').migrate + end +end diff --git a/apartment.gemspec b/apartment.gemspec index 51599313..b712e300 100644 --- a/apartment.gemspec +++ b/apartment.gemspec @@ -18,25 +18,11 @@ Gem::Specification.new do |s| s.homepage = %q{https://github.com/influitive/apartment} s.licenses = ["MIT"] - s.post_install_message = <<-MSG - ******************************** - - Apartment Deprecation Warning - - `Apartment::Tenant.process` has been deprecated in favour of `Apartment::Tenant.switch`. - You must now always pass a block to `switch`. - - To get the previous `switch` behaviour where you can switch to a tenant - without a block, use `Apartment::Tenant.switch!`. - This is to indicate that your call actually has a side affect of changing - the scope of your queries to that tenant. - - ******************************** - MSG - # must be >= 3.1.2 due to bug in prepared_statements s.add_dependency 'activerecord', '>= 3.1.2', '< 6.0' s.add_dependency 'rack', '>= 1.3.6' + s.add_dependency 'public_suffix', '>= 2' + s.add_dependency 'parallel', '>= 0.7.1' s.add_development_dependency 'appraisal' s.add_development_dependency 'rake', '~> 0.9' @@ -48,12 +34,12 @@ Gem::Specification.new do |s| s.add_development_dependency 'activerecord-jdbc-adapter' s.add_development_dependency 'activerecord-jdbcpostgresql-adapter' s.add_development_dependency 'activerecord-jdbcmysql-adapter' - s.add_development_dependency 'jdbc-postgres', '9.2.1002' + s.add_development_dependency 'jdbc-postgres' s.add_development_dependency 'jdbc-mysql' s.add_development_dependency 'jruby-openssl' else - s.add_development_dependency 'mysql2', '~> 0.3.10' - s.add_development_dependency 'pg', '>= 0.11.0' + s.add_development_dependency 'mysql2' + s.add_development_dependency 'pg' s.add_development_dependency 'sqlite3' s.add_development_dependency 'tiny_tds' s.add_development_dependency 'activerecord-sqlserver-adapter' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..23b556f0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '2' +services: + postgresql: + image: postgres:10.3 + environment: + POSTGRES_PASSWORD: "" + ports: + - "5432:5432" + mysql: + image: mysql:5.7 + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + ports: + - "3306:3306" diff --git a/gemfiles/rails_3_2.gemfile b/gemfiles/rails_3_2.gemfile deleted file mode 100644 index d18ea9dd..00000000 --- a/gemfiles/rails_3_2.gemfile +++ /dev/null @@ -1,13 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", "~> 3.2.0" -gem "test-unit", "~> 3.0" - -group :local do - gem "pry" - gem "guard-rspec", "~> 4.2" -end - -gemspec :path => "../" diff --git a/gemfiles/rails_4_0.gemfile b/gemfiles/rails_4_0.gemfile deleted file mode 100644 index a7a4f7d7..00000000 --- a/gemfiles/rails_4_0.gemfile +++ /dev/null @@ -1,12 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", "~> 4.0.0" - -group :local do - gem "pry" - gem "guard-rspec", "~> 4.2" -end - -gemspec :path => "../" diff --git a/gemfiles/rails_4_1.gemfile b/gemfiles/rails_4_1.gemfile deleted file mode 100644 index 9eb9a07a..00000000 --- a/gemfiles/rails_4_1.gemfile +++ /dev/null @@ -1,12 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", "~> 4.1.0" - -group :local do - gem "pry" - gem "guard-rspec", "~> 4.2" -end - -gemspec :path => "../" diff --git a/gemfiles/rails_4_2.gemfile b/gemfiles/rails_4_2.gemfile index 43d74179..ef31a500 100644 --- a/gemfiles/rails_4_2.gemfile +++ b/gemfiles/rails_4_2.gemfile @@ -9,4 +9,15 @@ group :local do gem "guard-rspec", "~> 4.2" end -gemspec :path => "../" +platforms :ruby do + gem "pg", "< 1.0.0" + gem "mysql2", "~> 0.4.0" +end + +platforms :jruby do + gem "activerecord-jdbc-adapter", "~> 1.3" + gem "activerecord-jdbcpostgresql-adapter", "~> 1.3" + gem "activerecord-jdbcmysql-adapter", "~> 1.3" +end + +gemspec path: "../" diff --git a/gemfiles/rails_5_0.gemfile b/gemfiles/rails_5_0.gemfile index 5467e3da..07d99a14 100644 --- a/gemfiles/rails_5_0.gemfile +++ b/gemfiles/rails_5_0.gemfile @@ -9,4 +9,14 @@ group :local do gem "guard-rspec", "~> 4.2" end -gemspec :path => "../" +platforms :ruby do + gem "pg", "< 1.0.0" +end + +platforms :jruby do + gem "activerecord-jdbc-adapter", "~> 50.0" + gem "activerecord-jdbcpostgresql-adapter", "~> 50.0" + gem "activerecord-jdbcmysql-adapter", "~> 50.0" +end + +gemspec path: "../" diff --git a/gemfiles/rails_5_1.gemfile b/gemfiles/rails_5_1.gemfile new file mode 100644 index 00000000..28353198 --- /dev/null +++ b/gemfiles/rails_5_1.gemfile @@ -0,0 +1,22 @@ +# This file was generated by Appraisal + +source "http://rubygems.org" + +gem "rails", "~> 5.1.0" + +group :local do + gem "pry" + gem "guard-rspec", "~> 4.2" +end + +platforms :ruby do + gem "pg", "< 1.0.0" +end + +platforms :jruby do + gem "activerecord-jdbc-adapter", "~> 51.0" + gem "activerecord-jdbcpostgresql-adapter", "~> 51.0" + gem "activerecord-jdbcmysql-adapter", "~> 51.0" +end + +gemspec path: "../" diff --git a/gemfiles/rails_5_2.gemfile b/gemfiles/rails_5_2.gemfile new file mode 100644 index 00000000..d6c51894 --- /dev/null +++ b/gemfiles/rails_5_2.gemfile @@ -0,0 +1,18 @@ +# This file was generated by Appraisal + +source "http://rubygems.org" + +gem "rails", "~> 5.2.0" + +group :local do + gem "pry" + gem "guard-rspec", "~> 4.2" +end + +platforms :jruby do + gem "activerecord-jdbc-adapter", "~> 51.0" + gem "activerecord-jdbcpostgresql-adapter", "~> 51.0" + gem "activerecord-jdbcmysql-adapter", "~> 51.0" +end + +gemspec path: "../" diff --git a/gemfiles/rails_master.gemfile b/gemfiles/rails_master.gemfile new file mode 100644 index 00000000..e5978830 --- /dev/null +++ b/gemfiles/rails_master.gemfile @@ -0,0 +1,18 @@ +# This file was generated by Appraisal + +source "http://rubygems.org" + +gem "rails", git: "https://github.com/rails/rails.git" + +group :local do + gem "pry" + gem "guard-rspec", "~> 4.2" +end + +platforms :jruby do + gem "activerecord-jdbc-adapter", "~> 51.0" + gem "activerecord-jdbcpostgresql-adapter", "~> 51.0" + gem "activerecord-jdbcmysql-adapter", "~> 51.0" +end + +gemspec path: "../" diff --git a/lib/apartment.rb b/lib/apartment.rb index 5b639e98..fc4a20dc 100644 --- a/lib/apartment.rb +++ b/lib/apartment.rb @@ -3,7 +3,6 @@ require 'forwardable' require 'active_record' require 'apartment/tenant' -require 'apartment/deprecation' module Apartment @@ -11,8 +10,8 @@ class << self extend Forwardable - ACCESSOR_METHODS = [:use_schemas, :use_sql, :seed_after_create, :prepend_environment, :append_environment, :with_multi_server_setup ] - WRITER_METHODS = [:tenant_names, :database_schema_file, :excluded_models, :default_schema, :persistent_schemas, :connection_class, :tld_length, :db_migrate_tenants, :seed_data_file] + ACCESSOR_METHODS = [:use_schemas, :use_sql, :seed_after_create, :prepend_environment, :append_environment, :with_multi_server_setup] + WRITER_METHODS = [:tenant_names, :database_schema_file, :excluded_models, :default_schema, :persistent_schemas, :connection_class, :tld_length, :db_migrate_tenants, :seed_data_file, :parallel_migration_threads, :pg_excluded_names] attr_accessor(*ACCESSOR_METHODS) attr_writer(*WRITER_METHODS) @@ -65,6 +64,10 @@ def excluded_models def default_schema @default_schema || "public" # TODO 'public' is postgres specific end + + def parallel_migration_threads + @parallel_migration_threads || 0 + end alias :default_tenant :default_schema alias :default_tenant= :default_schema= @@ -88,8 +91,8 @@ def seed_data_file @seed_data_file = "#{Rails.root}/db/seeds.rb" end - def tld_length - @tld_length || 1 + def pg_excluded_names + @pg_excluded_names || [] end # Reset all the config for Apartment @@ -97,26 +100,6 @@ def reset (ACCESSOR_METHODS + WRITER_METHODS).each{|method| remove_instance_variable(:"@#{method}") if instance_variable_defined?(:"@#{method}") } end - def database_names - Apartment::Deprecation.warn "[Deprecation Warning] `database_names` is now deprecated, please use `tenant_names`" - tenant_names - end - - def database_names=(names) - Apartment::Deprecation.warn "[Deprecation Warning] `database_names=` is now deprecated, please use `tenant_names=`" - self.tenant_names=(names) - end - - def use_postgres_schemas - Apartment::Deprecation.warn "[Deprecation Warning] `use_postgresql_schemas` is now deprecated, please use `use_schemas`" - use_schemas - end - - def use_postgres_schemas=(to_use_or_not_to_use) - Apartment::Deprecation.warn "[Deprecation Warning] `use_postgresql_schemas=` is now deprecated, please use `use_schemas=`" - self.use_schemas = to_use_or_not_to_use - end - def extract_tenant_config return {} unless @tenant_names values = @tenant_names.respond_to?(:call) ? @tenant_names.call : @tenant_names @@ -137,6 +120,9 @@ def extract_tenant_config # Raised when apartment cannot find the adapter specified in config/database.yml AdapterNotFound = Class.new(ApartmentError) + # Raised when apartment cannot find the file to be loaded + FileNotFound = Class.new(ApartmentError) + # Tenant specified is unknown TenantNotFound = Class.new(ApartmentError) diff --git a/lib/apartment/adapters/abstract_adapter.rb b/lib/apartment/adapters/abstract_adapter.rb index 8d2503b6..6838a9d5 100644 --- a/lib/apartment/adapters/abstract_adapter.rb +++ b/lib/apartment/adapters/abstract_adapter.rb @@ -1,5 +1,3 @@ -require 'apartment/deprecation' - module Apartment module Adapters class AbstractAdapter @@ -34,24 +32,6 @@ def create(tenant) end end - # Get the current tenant name - # - # @return {String} current tenant name - # - def current_database - Apartment::Deprecation.warn "[Deprecation Warning] `current_database` is now deprecated, please use `current`" - current - end - - # Get the current tenant name - # - # @return {String} current tenant name - # - def current_tenant - Apartment::Deprecation.warn "[Deprecation Warning] `current_tenant` is now deprecated, please use `current`" - current - end - # Note alias_method here doesn't work with inheritence apparently ?? # def current @@ -99,25 +79,14 @@ def switch!(tenant = nil) # @param {String?} tenant to connect to # def switch(tenant = nil) - if block_given? - begin - previous_tenant = current - switch!(tenant) - yield - - ensure - switch!(previous_tenant) rescue reset - end - else - Apartment::Deprecation.warn("[Deprecation Warning] `switch` now requires a block reset to the default tenant after the block. Please use `switch!` instead if you don't want this") + begin + previous_tenant = current switch!(tenant) - end - end + yield - # [Deprecated] - def process(tenant = nil, &block) - Apartment::Deprecation.warn("[Deprecation Warning] `process` is now deprecated. Please use `switch`") - switch(tenant, &block) + ensure + switch!(previous_tenant) rescue reset + end end # Iterate over all tenants, switch to tenant and yield tenant name @@ -147,7 +116,7 @@ def reset # def seed_data # Don't log the output of seeding the db - silence_warnings{ load_or_abort(Apartment.seed_data_file) } if Apartment.seed_data_file + silence_warnings{ load_or_raise(Apartment.seed_data_file) } if Apartment.seed_data_file end alias_method :seed, :seed_data @@ -159,7 +128,7 @@ def process_excluded_model(excluded_model) def drop_command(conn, tenant) # connection.drop_database note that drop_database will not throw an exception, so manually execute - conn.execute("DROP DATABASE #{environmentify(tenant)}") + conn.execute("DROP DATABASE #{conn.quote_table_name(environmentify(tenant))}") end # Create the tenant @@ -175,7 +144,7 @@ def create_tenant(tenant) end def create_tenant_command(conn, tenant) - conn.create_database(environmentify(tenant)) + conn.create_database(environmentify(tenant), @config) end def connect(tenant) @@ -195,8 +164,12 @@ def connect(tenant) # @param {String} tenant Database name # def connect_to_new(tenant) + query_cache_enabled = ActiveRecord::Base.connection.query_cache_enabled + Apartment.connection_handler.establish_connection multi_tenantify(tenant) connect_to_existing(tenant) + + Apartment.connection.enable_query_cache! if query_cache_enabled end def connect_to_existing(tenant) @@ -228,7 +201,7 @@ def environmentify(tenant) def import_database_schema ActiveRecord::Schema.verbose = false # do not log schema load output. - load_or_abort(Apartment.database_schema_file) if Apartment.database_schema_file + load_or_raise(Apartment.database_schema_file) if Apartment.database_schema_file end # Return a new config that is multi-tenanted @@ -247,15 +220,17 @@ def multi_tenantify_with_tenant_db_name(config, tenant) config[:database] = environmentify(tenant) end - # Load a file or abort if it doesn't exists + # Load a file or raise error if it doesn't exists # - def load_or_abort(file) + def load_or_raise(file) if File.exists?(file) load(file) else - abort %{#{file} doesn't exist yet} + raise FileNotFound, "#{file} doesn't exist yet" end end + # Backward compatibility + alias_method :load_or_abort, :load_or_raise # Exceptions to rescue from on db operations # diff --git a/lib/apartment/adapters/postgresql_adapter.rb b/lib/apartment/adapters/postgresql_adapter.rb index 544aa334..cac82c58 100644 --- a/lib/apartment/adapters/postgresql_adapter.rb +++ b/lib/apartment/adapters/postgresql_adapter.rb @@ -18,7 +18,7 @@ class PostgresqlAdapter < AbstractAdapter private def rescue_from - PGError + PG::Error end end @@ -63,11 +63,18 @@ def drop_command(conn, tenant) # def connect_to_new(tenant = nil) return reset if tenant.nil? - raise ActiveRecord::StatementInvalid.new("Could not find schema #{tenant}") unless Apartment.connection.schema_exists? tenant + raise ActiveRecord::StatementInvalid.new("Could not find schema #{tenant}") unless Apartment.connection.schema_exists?(tenant.to_s) @current = tenant.to_s Apartment.connection.schema_search_path = full_search_path + # When the PostgreSQL version is < 9.3, + # there is a issue for prepared statement with changing search_path. + # https://www.postgresql.org/docs/9.3/static/sql-prepare.html + if postgresql_version < 90300 + Apartment.connection.clear_cache! + end + rescue *rescuable_exceptions raise TenantNotFound, "One of the following schema(s) is invalid: \"#{tenant}\" #{full_search_path}" end @@ -87,23 +94,43 @@ def full_search_path def persistent_schemas [@current, Apartment.persistent_schemas].flatten end + + def postgresql_version + # ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#postgresql_version is + # public from Rails 5.0. + Apartment.connection.send(:postgresql_version) + end end # Another Adapter for Postgresql when using schemas and SQL class PostgresqlSchemaFromSqlAdapter < PostgresqlSchemaAdapter PSQL_DUMP_BLACKLISTED_STATEMENTS= [ - /SET search_path/i, # overridden later - /SET lock_timeout/i # new in postgresql 9.3 + /SET search_path/i, # overridden later + /SET lock_timeout/i, # new in postgresql 9.3 + /SET row_security/i, # new in postgresql 9.5 + /SET idle_in_transaction_session_timeout/i, # new in postgresql 9.6 ] def import_database_schema - clone_pg_schema - copy_schema_migrations + preserving_search_path do + clone_pg_schema + copy_schema_migrations + end end private + # Re-set search path after the schema is imported. + # Postgres now sets search path to empty before dumping the schema + # and it mut be reset + # + def preserving_search_path + search_path = Apartment.connection.execute("show search_path").first["search_path"] + yield + Apartment.connection.execute("set search_path = #{search_path}") + end + # Clone default schema into new schema named after current tenant # def clone_pg_schema @@ -140,7 +167,7 @@ def pg_dump_schema # @return {String} raw SQL contaning inserts with data from schema_migrations # def pg_dump_schema_migrations_data - with_pg_env { `pg_dump -a --inserts -t schema_migrations -t ar_internal_metadata -n #{default_tenant} #{dbname}` } + with_pg_env { `pg_dump -a --inserts -t #{default_tenant}.schema_migrations -t #{default_tenant}.ar_internal_metadata #{dbname}` } end # Temporary set Postgresql related environment variables if there are in @config @@ -165,13 +192,23 @@ def with_pg_env(&block) def patch_search_path(sql) search_path = "SET search_path = \"#{current}\", #{default_tenant};" - sql + swap_schema_qualifier(sql) .split("\n") .select {|line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty?} .prepend(search_path) .join("\n") end + def swap_schema_qualifier(sql) + sql.gsub(/#{default_tenant}\.\w*/) do |match| + if Apartment.pg_excluded_names.any? { |name| match.include? name } + match + else + match.gsub(default_tenant, %{"#{current}"}) + end + end + end + # Checks if any of regexps matches against input # def check_input_against_regexps(input, regexps) diff --git a/lib/apartment/elevators/domain.rb b/lib/apartment/elevators/domain.rb index ac877961..5b81bb8a 100644 --- a/lib/apartment/elevators/domain.rb +++ b/lib/apartment/elevators/domain.rb @@ -4,9 +4,11 @@ module Apartment module Elevators # Provides a rack based tenant switching solution based on domain # Assumes that tenant name should match domain - # Parses request host for second level domain + # Parses request host for second level domain, ignoring www # eg. example.com => example # www.example.bc.ca => example + # a.example.bc.ca => a + # # class Domain < Generic diff --git a/lib/apartment/elevators/generic.rb b/lib/apartment/elevators/generic.rb index 30367cf3..21349378 100644 --- a/lib/apartment/elevators/generic.rb +++ b/lib/apartment/elevators/generic.rb @@ -1,6 +1,5 @@ require 'rack/request' require 'apartment/tenant' -require 'apartment/deprecation' module Apartment module Elevators @@ -10,7 +9,7 @@ class Generic def initialize(app, processor = nil) @app = app - @processor = processor || parse_method + @processor = processor || method(:parse_tenant_name) end def call(env) @@ -25,27 +24,9 @@ def call(env) end end - def parse_database_name(request) - deprecation_warning - parse_tenant_name(request) - end - def parse_tenant_name(request) raise "Override" end - - def parse_method - if self.class.instance_methods(false).include? :parse_database_name - deprecation_warning - method(:parse_database_name) - else - method(:parse_tenant_name) - end - end - - def deprecation_warning - Apartment::Deprecation.warn "[DEPRECATED::Apartment] Use #parse_tenant_name instead of #parse_database_name -> #{self.class.name}" - end end end end diff --git a/lib/apartment/elevators/host.rb b/lib/apartment/elevators/host.rb new file mode 100644 index 00000000..c8cbd2f8 --- /dev/null +++ b/lib/apartment/elevators/host.rb @@ -0,0 +1,30 @@ +require 'apartment/elevators/generic' + +module Apartment + module Elevators + # Provides a rack based tenant switching solution based on the host + # Assumes that tenant name should match host + # Strips/ignores first subdomains in ignored_first_subdomains + # eg. example.com => example.com + # www.example.bc.ca => www.example.bc.ca + # if ignored_first_subdomains = ['www'] + # www.example.bc.ca => example.bc.ca + # www.a.b.c.d.com => a.b.c.d.com + # + class Host < Generic + def self.ignored_first_subdomains + @ignored_first_subdomains ||= [] + end + + def self.ignored_first_subdomains=(arg) + @ignored_first_subdomains = arg + end + + def parse_tenant_name(request) + return nil if request.host.blank? + parts = request.host.split('.') + self.class.ignored_first_subdomains.include?(parts[0]) ? parts.drop(1).join('.') : request.host + end + end + end +end \ No newline at end of file diff --git a/lib/apartment/elevators/subdomain.rb b/lib/apartment/elevators/subdomain.rb index b68aa55e..c625e29b 100644 --- a/lib/apartment/elevators/subdomain.rb +++ b/lib/apartment/elevators/subdomain.rb @@ -1,4 +1,5 @@ require 'apartment/elevators/generic' +require 'public_suffix' module Apartment module Elevators @@ -38,13 +39,23 @@ def subdomain(host) end def subdomains(host) - return [] unless named_host?(host) + host_valid?(host) ? parse_host(host) : [] + end + + def host_valid?(host) + !ip_host?(host) && domain_valid?(host) + end + + def ip_host?(host) + !/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.match(host).nil? + end - host.split('.')[0..-(Apartment.tld_length + 2)] + def domain_valid?(host) + PublicSuffix.valid?(host, ignore_private: true) end - def named_host?(host) - !(host.nil? || /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.match(host)) + def parse_host(host) + (PublicSuffix.parse(host, ignore_private: true).trd || '').split('.') end end end diff --git a/lib/apartment/migrator.rb b/lib/apartment/migrator.rb index 3d14f342..c99e9a3b 100644 --- a/lib/apartment/migrator.rb +++ b/lib/apartment/migrator.rb @@ -10,8 +10,12 @@ def migrate(database) Tenant.switch(database) do version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil - ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, version) do |migration| - ENV["SCOPE"].blank? || (ENV["SCOPE"] == migration.scope) + migration_scope_block = -> (migration) { ENV["SCOPE"].blank? || (ENV["SCOPE"] == migration.scope) } + + if activerecord_below_5_2? + ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, version, &migration_scope_block) + else + ActiveRecord::Base.connection.migration_context.migrate(version, &migration_scope_block) end end end @@ -19,15 +23,29 @@ def migrate(database) # Migrate up/down to a specific version def run(direction, database, version) Tenant.switch(database) do - ActiveRecord::Migrator.run(direction, ActiveRecord::Migrator.migrations_paths, version) + if activerecord_below_5_2? + ActiveRecord::Migrator.run(direction, ActiveRecord::Migrator.migrations_paths, version) + else + ActiveRecord::Base.connection.migration_context.run(direction, version) + end end end # rollback latest migration `step` number of times def rollback(database, step = 1) Tenant.switch(database) do - ActiveRecord::Migrator.rollback(ActiveRecord::Migrator.migrations_paths, step) + if activerecord_below_5_2? + ActiveRecord::Migrator.rollback(ActiveRecord::Migrator.migrations_paths, step) + else + ActiveRecord::Base.connection.migration_context.rollback(step) + end end end + + private + + def activerecord_below_5_2? + ActiveRecord.version.release < Gem::Version.new('5.2.0') + end end end diff --git a/lib/apartment/railtie.rb b/lib/apartment/railtie.rb index da5acf0e..aa57a55a 100644 --- a/lib/apartment/railtie.rb +++ b/lib/apartment/railtie.rb @@ -18,7 +18,6 @@ class Railtie < Rails::Railtie config.seed_after_create = false config.prepend_environment = false config.append_environment = false - config.tld_length = 1 end ActiveRecord::Migrator.migrations_paths = Rails.application.paths['db/migrate'].to_a @@ -35,7 +34,16 @@ class << ActiveRecord::Base # See the middleware/console declarations below to help with this. Hope to fix that soon. # config.to_prepare do - Apartment::Tenant.init unless ARGV.include? 'assets:precompile' + next if ARGV.any? { |arg| arg =~ /\Aassets:(?:precompile|clean)\z/ } + + begin + Apartment.connection_class.connection_pool.with_connection do + Apartment::Tenant.init + end + rescue ::ActiveRecord::NoDatabaseError + # Since `db:create` and other tasks invoke this block from Rails 5.2.0, + # we need to swallow the error to execute `db:create` properly. + end end # diff --git a/lib/apartment/tasks/enhancements.rb b/lib/apartment/tasks/enhancements.rb index f93b359e..ce93e58a 100644 --- a/lib/apartment/tasks/enhancements.rb +++ b/lib/apartment/tasks/enhancements.rb @@ -3,33 +3,54 @@ module Apartment class RakeTaskEnhancer - - TASKS = %w(db:migrate db:rollback db:migrate:up db:migrate:down db:migrate:redo db:seed) - + + module TASKS + ENHANCE_BEFORE = %w(db:drop) + ENHANCE_AFTER = %w(db:migrate db:rollback db:migrate:up db:migrate:down db:migrate:redo db:seed) + freeze + end + # This is a bit convoluted, but helps solve problems when using Apartment within an engine # See spec/integration/use_within_an_engine.rb - + class << self def enhance! - TASKS.each do |name| + return unless should_enhance? + + # insert task before + TASKS::ENHANCE_BEFORE.each do |name| task = Rake::Task[name] - task.enhance do - if should_enhance? - enhance_task(task) - end - end + enhance_before_task(task) end + + # insert task after + TASKS::ENHANCE_AFTER.each do |name| + task = Rake::Task[name] + enhance_after_task(task) + end + end - + def should_enhance? Apartment.db_migrate_tenants end - - def enhance_task(task) - Rake::Task[task.name.sub(/db:/, 'apartment:')].invoke + + def enhance_before_task(task) + task.enhance([inserted_task_name(task)]) + end + + def enhance_after_task(task) + task.enhance do + Rake::Task[inserted_task_name(task)].invoke + end end + + def inserted_task_name(task) + task.name.sub(/db:/, 'apartment:') + end + end - + end end diff --git a/lib/apartment/tenant.rb b/lib/apartment/tenant.rb index b236b9d9..7a4e33ca 100644 --- a/lib/apartment/tenant.rb +++ b/lib/apartment/tenant.rb @@ -1,5 +1,4 @@ require 'forwardable' -require 'apartment/deprecation' module Apartment # The main entry point to Apartment functions @@ -64,13 +63,4 @@ def config @config ||= Apartment.connection_config end end - - def self.const_missing(const_name) - if const_name == :Database - Apartment::Deprecation.warn "`Apartment::Database` has been deprecated. Use `Apartment::Tenant` instead." - Tenant - else - super - end - end end diff --git a/lib/apartment/version.rb b/lib/apartment/version.rb index 9b4808b2..0b599e94 100644 --- a/lib/apartment/version.rb +++ b/lib/apartment/version.rb @@ -1,3 +1,3 @@ module Apartment - VERSION = "1.2.0" + VERSION = "2.2.0" end diff --git a/lib/generators/apartment/install/templates/apartment.rb b/lib/generators/apartment/install/templates/apartment.rb index 90bf4abc..6c7c52fb 100644 --- a/lib/generators/apartment/install/templates/apartment.rb +++ b/lib/generators/apartment/install/templates/apartment.rb @@ -6,6 +6,7 @@ # require 'apartment/elevators/domain' require 'apartment/elevators/subdomain' # require 'apartment/elevators/first_subdomain' +# require 'apartment/elevators/host' # # Apartment Configuration @@ -49,16 +50,21 @@ # config.tenant_names = lambda { ToDo_Tenant_Or_User_Model.pluck :database } + # PostgreSQL: + # Specifies whether to use PostgreSQL schemas or create a new database per Tenant. + # + # MySQL: + # Specifies whether to switch databases by using `use` statement or re-establish connection. # - # ==> PostgreSQL only options - - # Specifies whether to use PostgreSQL schemas or create a new database per Tenant. # The default behaviour is true. # # config.use_schemas = true + # + # ==> PostgreSQL only options + # Apartment can be forced to use raw SQL dumps instead of schema.rb for creating new schemas. - # Use this when you are using some extra features in PostgreSQL that can't be respresented in + # Use this when you are using some extra features in PostgreSQL that can't be represented in # schema.rb, like materialized views etc. (only applies with use_schemas set to true). # (Note: this option doesn't use db/structure.sql, it creates SQL dump by executing pg_dump) # @@ -79,14 +85,25 @@ # Uncomment the line below if you want to disable this behaviour in production. # # config.prepend_environment = !Rails.env.production? + + # When using PostgreSQL schemas, the database dump will be namespaced, and + # apartment will substitute the default namespace (usually public) with the + # name of the new tenant when creating a new tenant. Some items must maintain + # a reference to the default namespace (ie public) - for instance, a default + # uuid generation. Uncomment the line below to create a list of namespaced + # items in the schema dump that should *not* have their namespace replaced by + # the new tenant + # + # config.pg_excluded_names = ["uuid_generate_v4"] end # Setup a custom Tenant switching middleware. The Proc should return the name of the Tenant that # you want to switch to. -# Rails.application.config.middleware.use 'Apartment::Elevators::Generic', lambda { |request| +# Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request| # request.host.split('.').first # } -# Rails.application.config.middleware.use 'Apartment::Elevators::Domain' -Rails.application.config.middleware.use 'Apartment::Elevators::Subdomain' -# Rails.application.config.middleware.use 'Apartment::Elevators::FirstSubdomain' +# Rails.application.config.middleware.use Apartment::Elevators::Domain +Rails.application.config.middleware.use Apartment::Elevators::Subdomain +# Rails.application.config.middleware.use Apartment::Elevators::FirstSubdomain +# Rails.application.config.middleware.use Apartment::Elevators::Host diff --git a/lib/tasks/apartment.rake b/lib/tasks/apartment.rake index 844c1550..2fd94f37 100644 --- a/lib/tasks/apartment.rake +++ b/lib/tasks/apartment.rake @@ -1,9 +1,10 @@ require 'apartment/migrator' +require 'parallel' apartment_namespace = namespace :apartment do desc "Create all tenants" - task create: 'db:migrate' do + task :create do tenants.each do |tenant| begin puts("Creating #{tenant} tenant") @@ -14,10 +15,22 @@ apartment_namespace = namespace :apartment do end end + desc "Drop all tenants" + task :drop do + tenants.each do |tenant| + begin + puts("Dropping #{tenant} tenant") + Apartment::Tenant.drop(tenant) + rescue Apartment::TenantNotFound => e + puts e.message + end + end + end + desc "Migrate all tenants" task :migrate do warn_if_tenants_empty - tenants.each do |tenant| + each_tenant do |tenant| begin puts("Migrating #{tenant} tenant") Apartment::Migrator.migrate tenant @@ -31,7 +44,7 @@ apartment_namespace = namespace :apartment do task :seed do warn_if_tenants_empty - tenants.each do |tenant| + each_tenant do |tenant| begin puts("Seeding #{tenant} tenant") Apartment::Tenant.switch(tenant) do @@ -49,7 +62,7 @@ apartment_namespace = namespace :apartment do step = ENV['STEP'] ? ENV['STEP'].to_i : 1 - tenants.each do |tenant| + each_tenant do |tenant| begin puts("Rolling back #{tenant} tenant") Apartment::Migrator.rollback tenant, step @@ -67,7 +80,7 @@ apartment_namespace = namespace :apartment do version = ENV['VERSION'] ? ENV['VERSION'].to_i : nil raise 'VERSION is required' unless version - tenants.each do |tenant| + each_tenant do |tenant| begin puts("Migrating #{tenant} tenant up") Apartment::Migrator.run :up, tenant, version @@ -84,7 +97,7 @@ apartment_namespace = namespace :apartment do version = ENV['VERSION'] ? ENV['VERSION'].to_i : nil raise 'VERSION is required' unless version - tenants.each do |tenant| + each_tenant do |tenant| begin puts("Migrating #{tenant} tenant down") Apartment::Migrator.run :down, tenant, version @@ -106,6 +119,12 @@ apartment_namespace = namespace :apartment do end end + def each_tenant(&block) + Parallel.each(tenants, in_threads: Apartment.parallel_migration_threads) do |tenant| + block.call(tenant) + end + end + def tenants ENV['DB'] ? ENV['DB'].split(',').map { |s| s.strip } : Apartment.tenant_names || [] end diff --git a/spec/adapters/mysql2_adapter_spec.rb b/spec/adapters/mysql2_adapter_spec.rb index c3037a3a..78f7b589 100644 --- a/spec/adapters/mysql2_adapter_spec.rb +++ b/spec/adapters/mysql2_adapter_spec.rb @@ -32,6 +32,14 @@ def tenant_names end end + after do + # Apartment::Tenant.init creates per model connection. + # Remove the connection after testing not to unintentionally keep the connection across tests. + Apartment.excluded_models.each do |excluded_model| + excluded_model.constantize.remove_connection + end + end + it "should process model exclusions" do Apartment::Tenant.init diff --git a/spec/apartment_spec.rb b/spec/apartment_spec.rb index c319d300..b00e0b2d 100644 --- a/spec/apartment_spec.rb +++ b/spec/apartment_spec.rb @@ -8,8 +8,4 @@ it "should be a valid app" do expect(::Rails.application).to be_a(Dummy::Application) end - - it "should deprecate Apartment::Database in favor of Apartment::Tenant" do - expect(Apartment::Database).to eq(Apartment::Tenant) - end -end \ No newline at end of file +end diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index b61781c8..e8f51ed9 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -17,7 +17,7 @@ class Application < Rails::Application require 'apartment/elevators/subdomain' require 'apartment/elevators/domain' - config.middleware.use 'Apartment::Elevators::Subdomain' + config.middleware.use Apartment::Elevators::Subdomain # Custom directories with classes and modules you want to be autoloadable. config.autoload_paths += %W(#{config.root}/lib) diff --git a/spec/dummy/config/database.yml.sample b/spec/dummy/config/database.yml.sample index 80c550c3..afc79694 100644 --- a/spec/dummy/config/database.yml.sample +++ b/spec/dummy/config/database.yml.sample @@ -25,6 +25,7 @@ development: test: adapter: postgresql database: apartment_postgresql_test + username: postgres min_messages: WARNING pool: 5 timeout: 5000 @@ -32,7 +33,8 @@ test: development: adapter: postgresql database: apartment_postgresql_development + username: postgres min_messages: WARNING pool: 5 timeout: 5000 -<% end %> \ No newline at end of file +<% end %> diff --git a/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb b/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb index ce8375f4..d1f620eb 100644 --- a/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb +++ b/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb @@ -1,4 +1,5 @@ -class CreateDummyModels < ActiveRecord::Migration +migration_class = (ActiveRecord::VERSION::MAJOR >= 5) ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration +class CreateDummyModels < migration_class def self.up create_table :companies do |t| t.boolean :dummy diff --git a/spec/dummy/db/migrate/20111202022214_create_table_books.rb b/spec/dummy/db/migrate/20111202022214_create_table_books.rb index ddaba1ad..e841e2dd 100644 --- a/spec/dummy/db/migrate/20111202022214_create_table_books.rb +++ b/spec/dummy/db/migrate/20111202022214_create_table_books.rb @@ -1,4 +1,5 @@ -class CreateTableBooks < ActiveRecord::Migration +migration_class = (ActiveRecord::VERSION::MAJOR >= 5) ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration +class CreateTableBooks < migration_class def up create_table :books do |t| t.string :name diff --git a/spec/dummy_engine/config/initializers/apartment.rb b/spec/dummy_engine/config/initializers/apartment.rb index addb1cf7..611f5322 100644 --- a/spec/dummy_engine/config/initializers/apartment.rb +++ b/spec/dummy_engine/config/initializers/apartment.rb @@ -42,10 +42,10 @@ ## # Elevator Configuration -# Rails.application.config.middleware.use 'Apartment::Elevators::Generic', lambda { |request| +# Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request| # # TODO: supply generic implementation # } -# Rails.application.config.middleware.use 'Apartment::Elevators::Domain' +# Rails.application.config.middleware.use Apartment::Elevators::Domain -Rails.application.config.middleware.use 'Apartment::Elevators::Subdomain' +Rails.application.config.middleware.use Apartment::Elevators::Subdomain diff --git a/spec/examples/connection_adapter_examples.rb b/spec/examples/connection_adapter_examples.rb index 1d2b9aa2..a7d4ac9b 100644 --- a/spec/examples/connection_adapter_examples.rb +++ b/spec/examples/connection_adapter_examples.rb @@ -6,6 +6,14 @@ let(:default_tenant){ subject.switch{ ActiveRecord::Base.connection.current_database } } describe "#init" do + after do + # Apartment::Tenant.init creates per model connection. + # Remove the connection after testing not to unintentionally keep the connection across tests. + Apartment.excluded_models.each do |excluded_model| + excluded_model.constantize.remove_connection + end + end + it "should process model exclusions" do Apartment.configure do |config| config.excluded_models = ["Company"] diff --git a/spec/examples/generic_adapter_examples.rb b/spec/examples/generic_adapter_examples.rb index f90cb28f..6c1b32ac 100644 --- a/spec/examples/generic_adapter_examples.rb +++ b/spec/examples/generic_adapter_examples.rb @@ -8,6 +8,25 @@ Apartment.append_environment = false } + describe "#init" do + it "should not retain a connection after railtie" do + # this test should work on rails >= 4, the connection pool code is + # completely different for 3.2 so we'd have to have a messy conditional.. + unless Rails::VERSION::MAJOR < 4 + ActiveRecord::Base.connection_pool.disconnect! + + Apartment::Railtie.config.to_prepare_blocks.map(&:call) + + num_available_connections = Apartment.connection_class.connection_pool + .instance_variable_get(:@available) + .instance_variable_get(:@queue) + .size + + expect(num_available_connections).to eq(1) + end + end + end + # # Creates happen already in our before_filter # @@ -39,6 +58,22 @@ subject.switch(db2){ expect(User.count).to eq(@count + 1) } end + + it "should raise error when the schema.rb is missing unless Apartment.use_sql is set to true" do + next if Apartment.use_sql + + subject.drop(db1) + begin + Dir.mktmpdir do |tmpdir| + Apartment.database_schema_file = "#{tmpdir}/schema.rb" + expect { + subject.create(db1) + }.to raise_error(Apartment::FileNotFound) + end + ensure + Apartment.remove_instance_variable(:@database_schema_file) + end + end end describe "#drop" do @@ -83,23 +118,6 @@ subject.switch(db1){ subject.drop(db2) } }.to_not raise_error end - - it "warns if no block is given, but calls switch!" do - expect(Apartment::Deprecation).to receive(:warn) - - subject.switch(db1) - expect(subject.current).to eq(db1) - end - end - - describe "#process" do - it "is deprecated" do - expect(Apartment::Deprecation).to receive(:warn) - - subject.process(db1) do - expect(subject.current).to eq(db1) - end - end end describe "#reset" do diff --git a/spec/examples/schema_adapter_examples.rb b/spec/examples/schema_adapter_examples.rb index 1800e86d..5eed5091 100644 --- a/spec/examples/schema_adapter_examples.rb +++ b/spec/examples/schema_adapter_examples.rb @@ -15,6 +15,14 @@ end end + after do + # Apartment::Tenant.init creates per model connection. + # Remove the connection after testing not to unintentionally keep the connection across tests. + Apartment.excluded_models.each do |excluded_model| + excluded_model.constantize.remove_connection + end + end + it "should process model exclusions" do Apartment::Tenant.init diff --git a/spec/integration/apartment_rake_integration_spec.rb b/spec/integration/apartment_rake_integration_spec.rb index e343b3f8..4b9b098d 100644 --- a/spec/integration/apartment_rake_integration_spec.rb +++ b/spec/integration/apartment_rake_integration_spec.rb @@ -47,19 +47,52 @@ Company.delete_all end - describe "#migrate" do - it "should migrate all databases" do - expect(ActiveRecord::Migrator).to receive(:migrate).exactly(company_count).times + context "with ActiveRecord below 5.2.0" do + before do + allow(ActiveRecord::Migrator).to receive(:migrations_paths) { %w(spec/dummy/db/migrate) } + allow(Apartment::Migrator).to receive(:activerecord_below_5_2?) { true } + end + + describe "#migrate" do + it "should migrate all databases" do + expect(ActiveRecord::Migrator).to receive(:migrate).exactly(company_count).times + + @rake['apartment:migrate'].invoke + end + end + + describe "#rollback" do + it "should rollback all dbs" do + expect(ActiveRecord::Migrator).to receive(:rollback).exactly(company_count).times - @rake['apartment:migrate'].invoke + @rake['apartment:rollback'].invoke + end end end - describe "#rollback" do - it "should rollback all dbs" do - expect(ActiveRecord::Migrator).to receive(:rollback).exactly(company_count).times + context "with ActiveRecord above or equal to 5.2.0" do + let(:migration_context_double) { double(:migration_context) } + + before do + allow(Apartment::Migrator).to receive(:activerecord_below_5_2?) { false } + end + + describe "#migrate" do + it "should migrate all databases" do + allow(ActiveRecord::Base.connection).to receive(:migration_context) { migration_context_double } + expect(migration_context_double).to receive(:migrate).exactly(company_count).times + + @rake['apartment:migrate'].invoke + end + end + + describe "#rollback" do + it "should rollback all dbs" do + allow(ActiveRecord::Base.connection).to receive(:migration_context) { migration_context_double } + expect(migration_context_double).to receive(:rollback).exactly(company_count).times - @rake['apartment:rollback'].invoke + @rake['apartment:rollback'].invoke + end end end diff --git a/spec/integration/query_caching_spec.rb b/spec/integration/query_caching_spec.rb index 8030bbf3..7bf08996 100644 --- a/spec/integration/query_caching_spec.rb +++ b/spec/integration/query_caching_spec.rb @@ -1,41 +1,81 @@ require 'spec_helper' describe 'query caching' do - let(:db_names) { [db1, db2] } + describe 'when use_schemas = true' do + let(:db_names) { [db1, db2] } - before do - Apartment.configure do |config| - config.excluded_models = ["Company"] - config.tenant_names = lambda{ Company.pluck(:database) } - config.use_schemas = true + before do + Apartment.configure do |config| + config.excluded_models = ["Company"] + config.tenant_names = lambda{ Company.pluck(:database) } + config.use_schemas = true + end + + Apartment::Tenant.reload!(config) + + db_names.each do |db_name| + Apartment::Tenant.create(db_name) + Company.create database: db_name + end + end + + after do + db_names.each{ |db| Apartment::Tenant.drop(db) } + Apartment::Tenant.reset + Company.delete_all + end + + it 'clears the ActiveRecord::QueryCache after switching databases' do + db_names.each do |db_name| + Apartment::Tenant.switch! db_name + User.create! name: db_name + end + + ActiveRecord::Base.connection.enable_query_cache! + + Apartment::Tenant.switch! db_names.first + expect(User.find_by_name(db_names.first).name).to eq(db_names.first) + + Apartment::Tenant.switch! db_names.last + expect(User.find_by_name(db_names.first)).to be_nil end + end - Apartment::Tenant.reload!(config) + describe 'when use_schemas = false' do + let(:db_name) { db1 } + + before do + Apartment.configure do |config| + config.excluded_models = ["Company"] + config.tenant_names = lambda{ Company.pluck(:database) } + config.use_schemas = false + end + + Apartment::Tenant.reload!(config) - db_names.each do |db_name| Apartment::Tenant.create(db_name) Company.create database: db_name end - end - after do - db_names.each{ |db| Apartment::Tenant.drop(db) } - Apartment::Tenant.reset - Company.delete_all - end + after do + # Avoid cannot drop the currently open database. Maybe there is a better way to handle this. + Apartment::Tenant.switch! 'template1' - it 'clears the ActiveRecord::QueryCache after switching databases' do - db_names.each do |db_name| - Apartment::Tenant.switch! db_name - User.create! name: db_name + Apartment::Tenant.drop(db_name) + Apartment::Tenant.reset + Company.delete_all end - ActiveRecord::Base.connection.enable_query_cache! + it "configuration value is kept after switching databases" do + ActiveRecord::Base.connection.enable_query_cache! - Apartment::Tenant.switch! db_names.first - expect(User.find_by_name(db_names.first).name).to eq(db_names.first) + Apartment::Tenant.switch! db_name + expect(Apartment.connection.query_cache_enabled).to be true + + ActiveRecord::Base.connection.disable_query_cache! - Apartment::Tenant.switch! db_names.last - expect(User.find_by_name(db_names.first)).to be_nil + Apartment::Tenant.switch! db_name + expect(Apartment.connection.query_cache_enabled).to be false + end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e63635cb..73cca8f4 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,6 +4,18 @@ ENV["RAILS_ENV"] = "test" require File.expand_path("../dummy/config/environment.rb", __FILE__) + +# Loading dummy applications affects table_name of each excluded models +# defined in `spec/dummy/config/initializers/apartment.rb`. +# To make them pristine, we need to execute below lines. +Apartment.excluded_models.each do |model| + klass = model.constantize + + Apartment.connection_class.remove_connection(klass) + klass.clear_all_connections! + klass.reset_table_name +end + require "rspec/rails" require 'capybara/rspec' require 'capybara/rails' diff --git a/spec/tasks/apartment_rake_spec.rb b/spec/tasks/apartment_rake_spec.rb index 45a7f708..0f86aeee 100644 --- a/spec/tasks/apartment_rake_spec.rb +++ b/spec/tasks/apartment_rake_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' require 'rake' require 'apartment/migrator' +require 'apartment/tenant' describe "apartment rake tasks" do @@ -116,5 +117,13 @@ @rake['apartment:rollback'].invoke end end + + describe "apartment:drop" do + it "should migrate public and all multi-tenant dbs" do + expect(Apartment::Tenant).to receive(:drop).exactly(tenant_count).times + @rake['apartment:drop'].invoke + end + end + end end diff --git a/spec/tenant_spec.rb b/spec/tenant_spec.rb index 68e36ab7..261185a6 100644 --- a/spec/tenant_spec.rb +++ b/spec/tenant_spec.rb @@ -59,8 +59,7 @@ describe "#adapter" do it "should load postgresql adapter" do - subject.adapter - expect(Apartment::Adapters::PostgresqlAdapter).to be_a(Class) + expect(subject.adapter).to be_a(Apartment::Adapters::PostgresqlSchemaAdapter) end it "raises exception with invalid adapter specified" do @@ -68,7 +67,7 @@ expect { Apartment::Tenant.adapter - }.to raise_error + }.to raise_error(RuntimeError) end context "threadsafety" do @@ -137,6 +136,14 @@ subject.init end + after do + # Apartment::Tenant.init creates per model connection. + # Remove the connection after testing not to unintentionally keep the connection across tests. + Apartment.excluded_models.each do |excluded_model| + excluded_model.constantize.remove_connection + end + end + it "should create excluded models in public schema" do subject.reset # ensure we're on public schema count = Company.count + x.times{ Company.create } diff --git a/spec/unit/config_spec.rb b/spec/unit/config_spec.rb index 2ccb9a60..44901952 100644 --- a/spec/unit/config_spec.rb +++ b/spec/unit/config_spec.rb @@ -50,13 +50,6 @@ def tenant_names_from_array(names) expect(Apartment.seed_after_create).to be true end - it "should set tld_length" do - Apartment.configure do |config| - config.tld_length = 2 - end - expect(Apartment.tld_length).to eq(2) - end - context "databases" do let(:users_conf_hash) { { port: 5444 } } diff --git a/spec/unit/elevators/host_spec.rb b/spec/unit/elevators/host_spec.rb new file mode 100644 index 00000000..96ea057c --- /dev/null +++ b/spec/unit/elevators/host_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' +require 'apartment/elevators/host' + +describe Apartment::Elevators::Host do + + subject(:elevator){ described_class.new(Proc.new{}) } + + describe "#parse_tenant_name" do + + it "should return nil when no host" do + request = ActionDispatch::Request.new('HTTP_HOST' => '') + expect(elevator.parse_tenant_name(request)).to be_nil + end + + context "assuming no ignored_first_subdomains" do + before { allow(described_class).to receive(:ignored_first_subdomains).and_return([]) } + + context "with 3 parts" do + it "should return the whole host" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com') + expect(elevator.parse_tenant_name(request)).to eq('foo.bar.com') + end + end + + context "with 6 parts" do + it "should return the whole host" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'one.two.three.foo.bar.com') + expect(elevator.parse_tenant_name(request)).to eq('one.two.three.foo.bar.com') + end + end + end + + context "assuming ignored_first_subdomains is set" do + before { allow(described_class).to receive(:ignored_first_subdomains).and_return(%w{www foo}) } + + context "with 3 parts" do + it "should return host without www" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'www.bar.com') + expect(elevator.parse_tenant_name(request)).to eq('bar.com') + end + + it "should return host without foo" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com') + expect(elevator.parse_tenant_name(request)).to eq('bar.com') + end + end + + context "with 6 parts" do + it "should return host without www" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'www.one.two.three.foo.bar.com') + expect(elevator.parse_tenant_name(request)).to eq('one.two.three.foo.bar.com') + end + + it "should return host without www" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.one.two.three.bar.com') + expect(elevator.parse_tenant_name(request)).to eq('one.two.three.bar.com') + end + end + end + + context "assuming localhost" do + it "should return localhost" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'localhost') + expect(elevator.parse_tenant_name(request)).to eq('localhost') + end + end + + context "assuming ip address" do + it "should return the ip address" do + request = ActionDispatch::Request.new('HTTP_HOST' => '127.0.0.1') + expect(elevator.parse_tenant_name(request)).to eq('127.0.0.1') + end + end + end + + describe "#call" do + it "switches to the proper tenant" do + allow(described_class).to receive(:ignored_first_subdomains).and_return([]) + expect(Apartment::Tenant).to receive(:switch).with('foo.bar.com') + elevator.call('HTTP_HOST' => 'foo.bar.com') + end + + it "ignores ignored_first_subdomains" do + allow(described_class).to receive(:ignored_first_subdomains).and_return(%w{foo}) + expect(Apartment::Tenant).to receive(:switch).with('bar.com') + elevator.call('HTTP_HOST' => 'foo.bar.com') + end + end +end diff --git a/spec/unit/elevators/subdomain_spec.rb b/spec/unit/elevators/subdomain_spec.rb index b8655a2b..1df11466 100644 --- a/spec/unit/elevators/subdomain_spec.rb +++ b/spec/unit/elevators/subdomain_spec.rb @@ -6,7 +6,7 @@ subject(:elevator){ described_class.new(Proc.new{}) } describe "#parse_tenant_name" do - context "assuming tld_length of 1" do + context "assuming one tld" do it "should parse subdomain" do request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com') expect(elevator.parse_tenant_name(request)).to eq('foo') @@ -18,13 +18,7 @@ end end - context "assuming tld_length of 2" do - before do - Apartment.configure do |config| - config.tld_length = 2 - end - end - + context "assuming two tlds" do it "should parse subdomain in the third level domain" do request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.co.uk') expect(elevator.parse_tenant_name(request)).to eq("foo") @@ -35,6 +29,32 @@ expect(elevator.parse_tenant_name(request)).to be_nil end end + + context "assuming two subdomains" do + it "should parse two subdomains in the two level domain" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.xyz.bar.com') + expect(elevator.parse_tenant_name(request)).to eq("foo") + end + + it "should parse two subdomains in the third level domain" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.xyz.bar.co.uk') + expect(elevator.parse_tenant_name(request)).to eq("foo") + end + end + + context "assuming localhost" do + it "should return nil for localhost" do + request = ActionDispatch::Request.new('HTTP_HOST' => 'localhost') + expect(elevator.parse_tenant_name(request)).to be_nil + end + end + + context "assuming ip address" do + it "should return nil for an ip address" do + request = ActionDispatch::Request.new('HTTP_HOST' => '127.0.0.1') + expect(elevator.parse_tenant_name(request)).to be_nil + end + end end describe "#call" do diff --git a/spec/unit/migrator_spec.rb b/spec/unit/migrator_spec.rb index d5fcaf96..242b6b97 100644 --- a/spec/unit/migrator_spec.rb +++ b/spec/unit/migrator_spec.rb @@ -8,30 +8,70 @@ # Don't need a real switch here, just testing behaviour before { allow(Apartment::Tenant.adapter).to receive(:connect_to_new) } - describe "::migrate" do - it "switches and migrates" do - expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original - expect(ActiveRecord::Migrator).to receive(:migrate) + context "with ActiveRecord below 5.2.0", skip: ActiveRecord.version >= Gem::Version.new("5.2.0") do + before do + allow(ActiveRecord::Migrator).to receive(:migrations_paths) { %w(spec/dummy/db/migrate) } + allow(Apartment::Migrator).to receive(:activerecord_below_5_2?) { true } + end + + describe "::migrate" do + it "switches and migrates" do + expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original + expect(ActiveRecord::Migrator).to receive(:migrate) - Apartment::Migrator.migrate(tenant) + Apartment::Migrator.migrate(tenant) + end end - end - describe "::run" do - it "switches and runs" do - expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original - expect(ActiveRecord::Migrator).to receive(:run).with(:up, anything, 1234) + describe "::run" do + it "switches and runs" do + expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original + expect(ActiveRecord::Migrator).to receive(:run).with(:up, anything, 1234) - Apartment::Migrator.run(:up, tenant, 1234) + Apartment::Migrator.run(:up, tenant, 1234) + end + end + + describe "::rollback" do + it "switches and rolls back" do + expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original + expect(ActiveRecord::Migrator).to receive(:rollback).with(anything, 2) + + Apartment::Migrator.rollback(tenant, 2) + end end end - describe "::rollback" do - it "switches and rolls back" do - expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original - expect(ActiveRecord::Migrator).to receive(:rollback).with(anything, 2) + context "with ActiveRecord above or equal to 5.2.0", skip: ActiveRecord.version < Gem::Version.new("5.2.0") do + before do + allow(Apartment::Migrator).to receive(:activerecord_below_5_2?) { false } + end + + describe "::migrate" do + it "switches and migrates" do + expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original + expect_any_instance_of(ActiveRecord::MigrationContext).to receive(:migrate) + + Apartment::Migrator.migrate(tenant) + end + end + + describe "::run" do + it "switches and runs" do + expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original + expect_any_instance_of(ActiveRecord::MigrationContext).to receive(:run).with(:up, 1234) + + Apartment::Migrator.run(:up, tenant, 1234) + end + end + + describe "::rollback" do + it "switches and rolls back" do + expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original + expect_any_instance_of(ActiveRecord::MigrationContext).to receive(:rollback).with(2) - Apartment::Migrator.rollback(tenant, 2) + Apartment::Migrator.rollback(tenant, 2) + end end end end