├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.TXT ├── README.md ├── Rakefile ├── gemfiles ├── Gemfile.mongoid-6 ├── Gemfile.mongoid-7 ├── Gemfile.mongoid-8 └── Gemfile.mongoid-9 ├── lib ├── mongoid-multitenancy.rb └── mongoid │ ├── multitenancy.rb │ └── multitenancy │ ├── document.rb │ ├── validators │ ├── tenancy.rb │ └── tenant_uniqueness.rb │ └── version.rb ├── mongoid-multitenancy.gemspec └── spec ├── conditional_uniqueness_spec.rb ├── immutable_spec.rb ├── indexable_spec.rb ├── inheritance_spec.rb ├── mandatory_spec.rb ├── models ├── account.rb ├── conditional_uniqueness.rb ├── immutable.rb ├── indexable.rb ├── mandatory.rb ├── mutable.rb ├── mutable_child.rb ├── no_scopable.rb ├── optional.rb └── optional_exclude.rb ├── mongoid-multitenancy_spec.rb ├── mutable_child_spec.rb ├── mutable_spec.rb ├── optional_exclude_spec.rb ├── optional_spec.rb ├── scopable_spec.rb ├── spec_helper.rb └── support ├── mongoid_matchers.rb └── shared_examples.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | coverage 6 | InstalledFiles 7 | lib/bundler/man 8 | pkg 9 | rdoc 10 | spec/reports 11 | test/tmp 12 | test/version_tmp 13 | tmp 14 | 15 | # YARD artifacts 16 | .yardoc 17 | _yardoc 18 | doc/ 19 | Gemfile*.lock 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --order random -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.2 4 | - 2.3.0 5 | - 2.4.1 6 | gemfile: 7 | - gemfiles/Gemfile.mongoid-6 8 | - gemfiles/Gemfile.mongoid-7 9 | - gemfiles/Gemfile.mongoid-8 10 | - gemfiles/Gemfile.mongoid-9 11 | - Gemfile 12 | services: 13 | - mongodb 14 | sudo: false -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [2.0.4] - 2024-06-17 8 | ### Fixed 9 | 10 | * Full Support of mongoid 8 & 9 11 | 12 | ## [2.0.3] - 2020-06-25 13 | ### Fixed 14 | 15 | * Full Support of mongoid 7 16 | 17 | ## [2.0.2] - 2018-08-23 18 | ### Added 19 | 20 | * Support of mongoid 7 21 | 22 | ## [2.0.1] - 2017-12-14 23 | ### Changed 24 | 25 | * Add ensure block in method with_tenant 26 | 27 | ## [2.0] - 2017-07-21 28 | ### New Features 29 | 30 | * Add support for mongoid 6 31 | * Remove support for mongoid 4 & 5 32 | 33 | ## 1.2 34 | 35 | ### New Features 36 | 37 | * Add *exclude_shared* option for the TenantUniquenessValidator 38 | 39 | ## 1.1 40 | 41 | ### New Features 42 | 43 | * Add scopes *shared* and *unshared* (1b5c420) 44 | 45 | ### Fixes 46 | 47 | * When a tenant is optional, do not override the tenant during persisted document initialization (81a9b45) 48 | 49 | ## 1.0.0 50 | 51 | ### New Features 52 | 53 | * Add support for mongoid 5 54 | 55 | ### Major Changes (Backwards Incompatible) 56 | 57 | * Drops support for mongoid 3 58 | 59 | * An optional tenant is now automatically set if a current tenant is defined. 60 | 61 | * A unique constraint with an optional tenant now uses the client scoping. An item cannot be shared if another client item has the same value. -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rake' 4 | 5 | group :test do 6 | gem 'database_cleaner-mongoid' 7 | gem 'coveralls', require: false 8 | gem 'rspec', '~> 3.1' 9 | gem 'yard' 10 | gem 'mongoid-rspec', git: 'https://github.com/mongoid-rspec/mongoid-rspec.git' 11 | gem 'rubocop', require: false 12 | end 13 | 14 | # Specify your gem's dependencies in mongoid-multitenancy.gemspec 15 | gemspec 16 | -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Perfect Memory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mongoid-multitenancy [![Build Status](https://api.travis-ci.org/PerfectMemory/mongoid-multitenancy.png?branch=master)](https://travis-ci.org/PerfectMemory/mongoid-multitenancy) [![Coverage Status](https://coveralls.io/repos/github/PerfectMemory/mongoid-multitenancy/badge.svg?branch=master)](https://coveralls.io/github/PerfectMemory/mongoid-multitenancy?branch=master) [![Code Climate](https://codeclimate.com/github/PerfectMemory/mongoid-multitenancy.png)](https://codeclimate.com/github/PerfectMemory/mongoid-multitenancy) 2 | 3 | mongoid-multitenancy adds the ability to scope [Mongoid](https://github.com/mongoid/mongoid) models to a tenant in a **shared database strategy**. Tenants are represented by a tenant model, such as `Client`. mongoid-multitenancy will help you set the current tenant on each request and ensures that all 'tenant models' are always properly scoped to the current tenant: when viewing, searching and creating. 4 | 5 | It is directly inspired by the [acts_as_tenant gem](https://github.com/ErwinM/acts_as_tenant) for Active Record. 6 | 7 | In addition, mongoid-multitenancy: 8 | 9 | * allows you to set the current tenant 10 | * allows shared items between the tenants 11 | * allows you to define an immutable tenant field once it is persisted 12 | * is thread safe 13 | * redefines some mongoid functions like `index`, `validates_with` and `delete_all` to take in account the multitenancy. 14 | 15 | Compatibility 16 | =============== 17 | 18 | mongoid-multitenancy 2 is compatible with mongoid 6/7/8/9. For mongoid 4/5 compatiblity, use mongoid-multitenancy 1.2. 19 | 20 | Installation 21 | =============== 22 | 23 | Add this line to your application's Gemfile: 24 | 25 | gem 'mongoid-multitenancy', '~> 2.0' 26 | 27 | And then execute: 28 | 29 | $ bundle 30 | 31 | Or install it yourself as: 32 | 33 | $ gem install mongoid-multitenancy 34 | 35 | Usage 36 | =============== 37 | 38 | There are two steps to add multi-tenancy to your app with mongoid-multitenancy: 39 | 40 | 1. setting the current tenant and 41 | 2. scoping your models. 42 | 43 | Setting the current tenant 44 | -------------------------- 45 | There are two ways to set the current tenant: (1) by setting the current tenant manually, or (2) by setting the current tenant for a block. 46 | 47 | **Setting the current tenant in a controller, manually** 48 | 49 | ```ruby 50 | Mongoid::Multitenancy.current_tenant = client_instance 51 | ``` 52 | 53 | Setting the current_tenant yourself requires you to use a before_filter to set the Mongoid::Multitenancy.current_tenant variable. 54 | 55 | **Setting the current tenant for a block** 56 | 57 | ```ruby 58 | Mongoid::Multitenancy.with_tenant(client_instance) do 59 | # Current tenant is set for all code in this block 60 | end 61 | ``` 62 | 63 | This approach is useful when running background processes for a specified tenant. For example, by putting this in your worker's run method, 64 | any code in this block will be scoped to the current tenant. All methods that set the current tenant are thread safe. 65 | 66 | **Note:** If the current tenant is not set by one of these methods, mongoid-multitenancy will apply a global scope to your models, not related to any tenant. So make sure you use one of the two methods to tell mongoid-multitenancy about the current tenant. 67 | 68 | Scoping your models 69 | ------------------- 70 | ```ruby 71 | class Client 72 | include Mongoid::Document 73 | 74 | field :name, :type => String 75 | validates_uniqueness_of :name 76 | end 77 | 78 | class Article 79 | include Mongoid::Document 80 | include Mongoid::Multitenancy::Document 81 | 82 | tenant(:tenant) 83 | 84 | field :title, :type => String 85 | end 86 | ``` 87 | 88 | Adding `tenant` to your model declaration will scope that model to the current tenant **BUT ONLY if a current tenant has been set**. 89 | The association passed to the `tenant` function must be valid. 90 | 91 | `tenant` accepts several options: 92 | 93 | | Option | Default | Description | 94 | | ------------- | ------------- | ------------- | 95 | | :optional | false | set to true when the tenant is optional | 96 | | :immutable | true | set to true when the tenant field is immutable | 97 | | :full_indexes | true | set to true to add the tenant field automatically to all the indexes | 98 | | :index | false | set to true to define an index for the tenant field | 99 | | :scopes | true | set to true to define scopes :shared and :unshared | 100 | | :class_name, etc. | | all the other options will be passed to the mongoid relation (belongs_to) | 101 | 102 | Some examples to illustrate this behavior: 103 | 104 | ```ruby 105 | # This manually sets the current tenant for testing purposes. In your app this is handled by the gem. 106 | Mongoid::Multitenancy.current_tenant = Client.find_by(:name => 'Perfect Memory') # => <#Client _id:50ca04b86c82bfc125000025, :name: "Perfect Memory"> 107 | 108 | # All searches are scoped by the tenant, the following searches will only return objects belonging to the current client. 109 | Article.all # => all articles where tenant_id => 50ca04b86c82bfc125000025 110 | 111 | # New objects are scoped to the current tenant 112 | article = Article.new(:title => 'New blog') 113 | article.save # => <#Article _id: 50ca04b86c82bfc125000044, title: 'New blog', tenant_id: 50ca04b86c82bfc125000025> 114 | 115 | # It can make the tenant field immutable once it is persisted to avoid inconsistency 116 | article.persisted? # => true 117 | article.tenant = another_client 118 | article.valid? # => false 119 | ``` 120 | 121 | **Optional tenant** 122 | 123 | When setting an optional tenant, for example to allow shared instances between all the tenants, the default scope will return both the tenant and the free-tenant items. That means that using `Article.delete_all` or `Article.destroy_all` will **remove the shared items too**. 124 | 125 | Note: if a current tenant is set and you want to mark the item shared, you must explicitly set the tenant relation to nil after the initialization. 126 | 127 | ```ruby 128 | class Article 129 | include Mongoid::Document 130 | include Mongoid::Multitenancy::Document 131 | 132 | tenant(:tenant, optional: true) 133 | 134 | field :title, :type => String 135 | end 136 | 137 | Mongoid::Multitenancy.with_tenant(client_instance) do 138 | Article.all # => all articles where tenant_id.in [50ca04b86c82bfc125000025, nil] 139 | article = Article.new(:title => 'New article') 140 | article.save # => <#Article _id: 50ca04b86c82bfc125000044, title: 'New blog', tenant_id: 50ca04b86c82bfc125000025> 141 | 142 | # tenant needs to be set manually to nil 143 | article = Article.new(:title => 'New article', :tenant => nil) 144 | article.save # => <#Article _id: 50ca04b86c82bfc125000044, title: 'New blog', tenant_id: 50ca04b86c82bfc125000025> 145 | article.tenant = nil 146 | article.save => <#Article _id: 50ca04b86c82bfc125000044, title: 'New blog', tenant_id: nil> 147 | end 148 | ``` 149 | 150 | Rails 151 | ------------------- 152 | 153 | If you are using Rails, you may want to set the current tenant at each request. 154 | 155 | **Manually set the current tenant in ApplicationController using the host request** 156 | 157 | ```ruby 158 | class ApplicationController < ActionController::Base 159 | before_filter :set_current_client 160 | 161 | def set_current_client 162 | current_client = Client.find_by_host(request.host) 163 | Mongoid::Multitenancy.current_tenant = current_client 164 | end 165 | end 166 | ``` 167 | 168 | Setting the current_tenant yourself requires you to use a before_filter to set the Mongoid::Multitenancy.current_tenant variable. 169 | 170 | Mongoid Uniqueness validators 171 | ------------------- 172 | 173 | mongoid-multitenancy brings a TenantUniqueness validator that will, depending on the tenant options, check that your uniqueness 174 | constraints are respected: 175 | 176 | * When used with a *mandatory* tenant, the uniqueness constraint is scoped to the current client. 177 | 178 | In the following case, 2 articles can have the same slug if they belongs to 2 different clients. 179 | 180 | ```ruby 181 | class Article 182 | include Mongoid::Document 183 | include Mongoid::Multitenancy::Document 184 | 185 | tenant :tenant 186 | 187 | field :slug 188 | 189 | validates_tenant_uniqueness_of :slug 190 | end 191 | ``` 192 | 193 | * When used with an *optional* tenant, the uniqueness constraint by default is not scoped if the item is shared, but is 194 | scoped to the client new item otherwise. Note that by default in that case a private item cannot have a value if a shared item 195 | already uses it. You can change that behaviour by setting the option `exclude_shared` to `true`. 196 | 197 | In the following case, 2 private articles can have the same slug if they belongs to 2 different clients. But if a shared 198 | article has the slug "slugA", no client will be able to use that slug again, like a standard `validates_uniqueness_of` does. 199 | 200 | ```ruby 201 | class Article 202 | include Mongoid::Document 203 | include Mongoid::Multitenancy::Document 204 | 205 | tenant :tenant, optional: true 206 | 207 | field :slug 208 | 209 | validates_tenant_uniqueness_of :slug 210 | end 211 | ``` 212 | 213 | In the following case, 2 private articles can have the same slug if they belongs to 2 different clients even if a shared 214 | article already uses that same slug, like a `validates_uniqueness_of scope: :tenant` does. 215 | 216 | ```ruby 217 | class Article 218 | include Mongoid::Document 219 | include Mongoid::Multitenancy::Document 220 | 221 | tenant :tenant, optional: true 222 | 223 | field :slug 224 | 225 | validates_tenant_uniqueness_of :slug, exclude_shared: true 226 | end 227 | ``` 228 | 229 | TenantUniqueness validator also allow to specify additional `conditions` to limit the uniqueness of the constraint. 230 | 231 | ```ruby 232 | class Article 233 | include Mongoid::Document 234 | include Mongoid::Multitenancy::Document 235 | 236 | tenant :tenant, optional: true 237 | 238 | field :title 239 | field :slug 240 | 241 | validates_tenant_uniqueness_of :slug, exclude_shared: true, conditions: -> { ne(title: nil) } 242 | end 243 | ``` 244 | 245 | Mongoid indexes 246 | ------------------- 247 | 248 | mongoid-multitenancy automatically adds the tenant foreign key in all your mongoid indexes to avoid to redefine all your validators. If you prefer to define the indexes manually, you can use the option `full_indexes: false` on the tenant or `full_index: true/false` on the indexes. 249 | 250 | To create a single index on the tenant field, you can use the option `index: true` like any `belongs_to` declaration (false by default) 251 | 252 | On the example below, only one index will be created: 253 | 254 | * { 'tenant_id' => 1, 'title' => 1 } 255 | 256 | ```ruby 257 | class Article 258 | include Mongoid::Document 259 | include Mongoid::Multitenancy::Document 260 | 261 | tenant :tenant, full_indexes: true 262 | 263 | field :title 264 | 265 | index({ :title => 1 }) 266 | end 267 | ``` 268 | 269 | On the example below, 3 indexes will be created: 270 | 271 | * { 'tenant_id' => 1 } 272 | * { 'tenant_id' => 1, 'title' => 1 } 273 | * { 'name' => 1 } 274 | 275 | ```ruby 276 | class Article 277 | include Mongoid::Document 278 | include Mongoid::Multitenancy::Document 279 | 280 | tenant :tenant, index: true 281 | 282 | field :title 283 | field :name 284 | 285 | index({ :title => 1 }) 286 | index({ :name => 1 }, { full_index: false }) 287 | end 288 | ``` 289 | 290 | Author & Credits 291 | ---------------- 292 | mongoid-multitenancy is written by [Aymeric Brisse](https://github.com/abrisse/), from [Perfect Memory](http://www.perfect-memory.com). 293 | 294 | ## Contributing 295 | 296 | 1. Fork it 297 | 2. Create your feature branch (`git checkout -b my-new-feature`) 298 | 3. Commit your changes (`git commit -am 'Added some feature'`) 299 | 4. Push to the branch (`git push origin my-new-feature`) 300 | 5. Create new Pull Request 301 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | require 'yard' 4 | 5 | YARD::Rake::YardocTask.new 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.mongoid-6: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'mongoid', '~> 6.0' 4 | 5 | gem 'rake' 6 | 7 | group :test do 8 | gem 'database_cleaner-mongoid' 9 | gem 'coveralls', require: false 10 | gem 'rspec', '~> 3.1' 11 | gem 'yard' 12 | gem 'mongoid-rspec', git: 'https://github.com/mongoid-rspec/mongoid-rspec.git' 13 | gem 'rubocop', require: false 14 | end 15 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.mongoid-7: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'mongoid', '~> 7.0' 4 | 5 | gem 'rake' 6 | 7 | group :test do 8 | gem 'database_cleaner-mongoid' 9 | gem 'coveralls', require: false 10 | gem 'rspec', '~> 3.1' 11 | gem 'yard' 12 | gem 'mongoid-rspec', git: 'https://github.com/mongoid-rspec/mongoid-rspec.git' 13 | gem 'rubocop', require: false 14 | end 15 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.mongoid-8: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'mongoid', '~> 8.0' 4 | 5 | gem 'rake' 6 | 7 | group :test do 8 | gem 'database_cleaner-mongoid' 9 | gem 'coveralls', require: false 10 | gem 'rspec', '~> 3.1' 11 | gem 'yard' 12 | gem 'mongoid-rspec', git: 'https://github.com/mongoid-rspec/mongoid-rspec.git' 13 | gem 'rubocop', require: false 14 | end 15 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.mongoid-9: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'mongoid', '~> 9.0' 4 | 5 | gem 'rake' 6 | 7 | group :test do 8 | gem 'database_cleaner-mongoid' 9 | gem 'coveralls', require: false 10 | gem 'rspec', '~> 3.1' 11 | gem 'yard' 12 | gem 'mongoid-rspec', git: 'https://github.com/mongoid-rspec/mongoid-rspec.git' 13 | gem 'rubocop', require: false 14 | end 15 | -------------------------------------------------------------------------------- /lib/mongoid-multitenancy.rb: -------------------------------------------------------------------------------- 1 | require 'mongoid/multitenancy' 2 | -------------------------------------------------------------------------------- /lib/mongoid/multitenancy.rb: -------------------------------------------------------------------------------- 1 | require 'mongoid' 2 | require 'mongoid/multitenancy/document' 3 | require 'mongoid/multitenancy/version' 4 | require 'mongoid/multitenancy/validators/tenancy' 5 | require 'mongoid/multitenancy/validators/tenant_uniqueness' 6 | 7 | module Mongoid 8 | module Multitenancy 9 | class << self 10 | # Set the current tenant. Make it Thread aware 11 | def current_tenant=(tenant) 12 | Thread.current[:current_tenant] = tenant 13 | end 14 | 15 | # Returns the current tenant 16 | def current_tenant 17 | Thread.current[:current_tenant] 18 | end 19 | 20 | # Affects a tenant temporary for a block execution 21 | def with_tenant(tenant, &block) 22 | raise ArgumentError, 'block required' if block.nil? 23 | 24 | begin 25 | old_tenant = current_tenant 26 | self.current_tenant = tenant 27 | yield 28 | ensure 29 | self.current_tenant = old_tenant 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/mongoid/multitenancy/document.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Multitenancy 3 | module Document 4 | extend ActiveSupport::Concern 5 | 6 | module ClassMethods 7 | attr_accessor :tenant_field, :tenant_options 8 | 9 | # List of authorized options 10 | MULTITENANCY_OPTIONS = [:optional, :immutable, :full_indexes, :index, :scopes].freeze 11 | 12 | # Defines the tenant field for the document. 13 | # 14 | # @example Define a tenant. 15 | # tenant :client, optional: false, immutable: true, full_indexes: true 16 | # 17 | # @param [ Symbol ] name The name of the relation. 18 | # @param [ Hash ] options The relation options. 19 | # All the belongs_to options are allowed plus the following ones: 20 | # 21 | # @option options [ Boolean ] :full_indexes If true the tenant field 22 | # will be added for each index. 23 | # @option options [ Boolean ] :immutable If true changing the tenant 24 | # wil raise an Exception. 25 | # @option options [ Boolean ] :optional If true allow the document 26 | # to be shared among all the tenants. 27 | # @option options [ Boolean ] :index If true build an index for 28 | # the tenant field itself. 29 | # @option options [ Boolean ] :scopes If true create scopes :shared 30 | # and :unshared. 31 | # @return [ Field ] The generated field 32 | def tenant(association = :account, options = {}) 33 | options = { full_indexes: true, immutable: true, scopes: true }.merge!(options) 34 | assoc_options, multitenant_options = build_options(options) 35 | 36 | # Setup the association between the class and the tenant class 37 | belongs_to association, assoc_options 38 | 39 | # Get the tenant model and its foreign key 40 | self.tenant_field = reflect_on_association(association).foreign_key.to_sym 41 | self.tenant_options = multitenant_options 42 | 43 | # Validates the tenant field 44 | validates_tenancy_of tenant_field, multitenant_options 45 | 46 | define_scopes if multitenant_options[:scopes] 47 | define_initializer association 48 | define_inherited association, options 49 | define_index if multitenant_options[:index] 50 | end 51 | 52 | # Validates whether or not a field is unique against the documents in the 53 | # database. 54 | # 55 | # @example 56 | # 57 | # class Person 58 | # include Mongoid::Document 59 | # include Mongoid::Multitenancy::Document 60 | # field :title 61 | # 62 | # validates_tenant_uniqueness_of :title 63 | # end 64 | # 65 | # @param [ Array ] *args The arguments to pass to the validator. 66 | def validates_tenant_uniqueness_of(*args) 67 | validates_with(TenantUniquenessValidator, _merge_attributes(args)) 68 | end 69 | 70 | # Validates whether or not a tenant field is correct. 71 | # 72 | # @example Define the tenant validator 73 | # 74 | # class Person 75 | # include Mongoid::Document 76 | # include Mongoid::Multitenancy::Document 77 | # field :title 78 | # tenant :client 79 | # 80 | # validates_tenant_of :client 81 | # end 82 | # 83 | # @param [ Array ] *args The arguments to pass to the validator. 84 | def validates_tenancy_of(*args) 85 | validates_with(TenancyValidator, _merge_attributes(args)) 86 | end 87 | 88 | # Redefine 'index' to include the tenant field in first position 89 | def index(spec, options = nil) 90 | super_options = (options || {}).dup 91 | full_index = super_options.delete(:full_index) 92 | if full_index.nil? ? tenant_options[:full_indexes] : full_index 93 | spec = { tenant_field => 1 }.merge(spec) 94 | end 95 | 96 | super(spec, super_options) 97 | end 98 | 99 | # Redefine 'delete_all' to take in account the default scope 100 | def delete_all(conditions = {}) 101 | scoped.where(conditions).delete 102 | end 103 | 104 | private 105 | 106 | # @private 107 | def build_options(options) 108 | assoc_options = {} 109 | multitenant_options = {} 110 | 111 | options.each do |k, v| 112 | if MULTITENANCY_OPTIONS.include?(k) 113 | multitenant_options[k] = v 114 | assoc_options[k] = v if k == :optional 115 | else 116 | assoc_options[k] = v 117 | end 118 | end 119 | 120 | [assoc_options, multitenant_options] 121 | end 122 | 123 | # @private 124 | # 125 | # Define the after_initialize 126 | def define_initializer(association) 127 | # Apply the default value when the default scope is complex (optional tenant) 128 | after_initialize lambda { 129 | if Multitenancy.current_tenant && new_record? 130 | send "#{association}=".to_sym, Multitenancy.current_tenant 131 | end 132 | } 133 | end 134 | 135 | # @private 136 | # 137 | # Define the inherited method 138 | def define_inherited(association, options) 139 | define_singleton_method(:inherited) do |child| 140 | child.tenant association, options.merge(scopes: false) 141 | super(child) 142 | end 143 | end 144 | 145 | # @private 146 | # 147 | # Define the scopes 148 | def define_scopes 149 | # Set the default_scope to scope to current tenant 150 | default_scope lambda { 151 | if Multitenancy.current_tenant 152 | tenant_id = Multitenancy.current_tenant.id 153 | if tenant_options[:optional] 154 | where(tenant_field.in => [tenant_id, nil]) 155 | else 156 | where(tenant_field => tenant_id) 157 | end 158 | else 159 | all 160 | end 161 | } 162 | 163 | scope :shared, -> { where(tenant_field => nil) } 164 | scope :unshared, -> { where(tenant_field => Multitenancy.current_tenant.id) } 165 | end 166 | 167 | # @private 168 | # 169 | # Create the index 170 | def define_index 171 | index({ tenant_field => 1 }, background: true) 172 | end 173 | end 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/mongoid/multitenancy/validators/tenancy.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Multitenancy 3 | # Validates whether or not a tenant field is correct. 4 | # 5 | # @example Define the tenant validator 6 | # 7 | # class Person 8 | # include Mongoid::Document 9 | # include Mongoid::Multitenancy::Document 10 | # field :title 11 | # tenant :client 12 | # 13 | # validates_tenancy_of :client 14 | # end 15 | class TenancyValidator < ActiveModel::EachValidator 16 | def validate_each(object, attribute, value) 17 | # Immutable Check 18 | if options[:immutable] 19 | if object.send(:attribute_changed?, attribute) && object.send(:attribute_was, attribute) 20 | object.errors.add(attribute, 'is immutable and cannot be updated') 21 | end 22 | end 23 | 24 | # Ownership check 25 | if value && Mongoid::Multitenancy.current_tenant && value != Mongoid::Multitenancy.current_tenant.id 26 | object.errors.add(attribute, 'not authorized') 27 | end 28 | 29 | # Optional Check 30 | if !options[:optional] && value.nil? 31 | object.errors.add(attribute, 'is mandatory') 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/mongoid/multitenancy/validators/tenant_uniqueness.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Multitenancy 3 | # Validates whether or not a field is unique against the documents in the 4 | # database. 5 | # 6 | # @example Define the tenant uniqueness validator. 7 | # 8 | # class Person 9 | # include Mongoid::Document 10 | # include Mongoid::Multitenancy::Document 11 | # field :title 12 | # tenant :client 13 | # 14 | # validates_tenant_uniqueness_of :title 15 | # end 16 | # 17 | # It is also possible to limit the uniqueness constraint to a set of 18 | # records matching certain conditions: 19 | # class Person 20 | # include Mongoid::Document 21 | # include Mongoid::Multitenancy::Document 22 | # field :title 23 | # field :active, type: Boolean 24 | # tenant :client 25 | # 26 | # validates_tenant_uniqueness_of :title, conditions: -> {where(active: true)} 27 | # end 28 | class TenantUniquenessValidator < Mongoid::Validatable::UniquenessValidator 29 | # Validate a tenant root document. 30 | def validate_root(document, attribute, value) 31 | klass = document.class 32 | 33 | while klass.superclass.respond_to?(:validators) && klass.superclass.validators.include?(self) 34 | klass = klass.superclass 35 | end 36 | criteria = create_criteria(klass, document, attribute, value) 37 | 38 | # <> 39 | criteria = with_tenant_criterion(criteria, klass, document) 40 | # Add additional conditions 41 | if options[:conditions] 42 | conditions = klass.unscoped { options[:conditions].call } 43 | criteria = criteria.merge(conditions) 44 | end 45 | 46 | if criteria.read(mode: :primary).exists? 47 | add_error(document, attribute, value) 48 | end 49 | end 50 | 51 | # Add the scope criteria for a tenant model criteria. 52 | # 53 | # @api private 54 | def with_tenant_criterion(criteria, base, document) 55 | item = base.tenant_field.to_sym 56 | name = document.database_field_name(item) 57 | tenant_value = document.attributes[name] 58 | 59 | if document.class.tenant_options[:optional] && !options[:exclude_shared] 60 | if tenant_value 61 | criteria = criteria.where(:"#{item}".in => [tenant_value, nil]) 62 | end 63 | else 64 | criteria = criteria.where(item => tenant_value) 65 | end 66 | 67 | criteria 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/mongoid/multitenancy/version.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Multitenancy 3 | # Version 4 | VERSION = '2.0.4'.freeze 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /mongoid-multitenancy.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/mongoid/multitenancy/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ['Aymeric Brisse'] 6 | gem.email = ['aymeric.brisse@mperfect-memory.com'] 7 | gem.description = 'MultiTenancy with Mongoid' 8 | gem.summary = 'Support of a multi-tenant database with Mongoid' 9 | gem.homepage = 'https://github.com/PerfectMemory/mongoid-multitenancy' 10 | gem.license = 'MIT' 11 | gem.files = `git ls-files`.split($OUTPUT_RECORD_SEPARATOR) 12 | gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) } 13 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 14 | gem.name = 'mongoid-multitenancy' 15 | gem.require_paths = ['lib'] 16 | gem.version = Mongoid::Multitenancy::VERSION 17 | 18 | gem.add_dependency('mongoid', '>= 6', '< 10') 19 | end 20 | -------------------------------------------------------------------------------- /spec/conditional_uniqueness_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ConditionalUniqueness do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | let(:another_client) do 9 | Account.create!(name: 'another client') 10 | end 11 | 12 | let(:item) do 13 | ConditionalUniqueness.new(approved: true, slug: 'page-x') 14 | end 15 | 16 | it_behaves_like 'a tenantable model' 17 | 18 | describe '#valid?' do 19 | context 'with a tenant' do 20 | before do 21 | Mongoid::Multitenancy.current_tenant = client 22 | end 23 | 24 | it 'is valid' do 25 | expect(item).to be_valid 26 | end 27 | 28 | context 'with a duplicate on the constraint' do 29 | let(:duplicate) do 30 | ConditionalUniqueness.new(approved: true, slug: 'page-x') 31 | end 32 | 33 | before do 34 | item.save! 35 | end 36 | 37 | it 'is not valid' do 38 | expect(duplicate).not_to be_valid 39 | end 40 | 41 | context 'with a duplicate outside the conditions' do 42 | before do 43 | item.update(approved: false) 44 | end 45 | 46 | it 'is valid' do 47 | expect(duplicate).to be_valid 48 | end 49 | end 50 | 51 | context 'with a different tenant' do 52 | it 'is valid' do 53 | Mongoid::Multitenancy.with_tenant(another_client) do 54 | expect(duplicate).to be_valid 55 | end 56 | end 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/immutable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Immutable do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | let(:another_client) do 9 | Account.create!(name: 'another client') 10 | end 11 | 12 | let(:item) do 13 | Immutable.new(title: 'title X', slug: 'page-x') 14 | end 15 | 16 | it_behaves_like 'a tenantable model' 17 | 18 | describe '#valid?' do 19 | before do 20 | Mongoid::Multitenancy.current_tenant = client 21 | end 22 | 23 | context 'when the tenant has not changed' do 24 | before do 25 | item.save! 26 | end 27 | 28 | it 'is valid' do 29 | item.title = 'title X (2)' 30 | expect(item).to be_valid 31 | end 32 | end 33 | 34 | context 'when the tenant has changed' do 35 | before do 36 | item.save! 37 | Mongoid::Multitenancy.current_tenant = another_client 38 | end 39 | 40 | it 'is not valid' do 41 | item.tenant = another_client 42 | expect(item).not_to be_valid 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/indexable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'tenant' do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | before do 9 | Mongoid::Multitenancy.current_tenant = client 10 | end 11 | 12 | describe 'tenant full_indexes option' do 13 | context 'without index option' do 14 | it 'does not create an index' do 15 | expect(IndexableWithoutIndex).not_to have_index_for(tenant_id: 1) 16 | end 17 | end 18 | 19 | context 'with index: false' do 20 | it 'does not create an index' do 21 | expect(IndexableWithIndexFalse).not_to have_index_for(tenant_id: 1) 22 | end 23 | end 24 | 25 | context 'with index: true' do 26 | it 'creates an index' do 27 | expect(IndexableWithIndexTrue).to have_index_for(tenant_id: 1) 28 | end 29 | end 30 | end 31 | 32 | describe 'index full_index option' do 33 | context 'without tenant full_indexes option specified' do 34 | it 'adds the tenant field on each index' do 35 | expect(IndexableWithoutFullIndexes).to have_index_for(tenant_id: 1, title: 1) 36 | end 37 | 38 | it 'adds the tenant field on the index with full_index: true' do 39 | expect(IndexableWithoutFullIndexes).to have_index_for(tenant_id: 1, name: 1) 40 | end 41 | 42 | it 'does not add the tenant field on the index with full_index: false' do 43 | expect(IndexableWithoutFullIndexes).not_to have_index_for(tenant_id: 1, slug: 1) 44 | expect(IndexableWithoutFullIndexes).to have_index_for(slug: 1) 45 | end 46 | end 47 | 48 | context 'with full_indexes: true' do 49 | it 'adds the tenant field on each index' do 50 | expect(IndexableWithFullIndexesTrue).to have_index_for(tenant_id: 1, title: 1) 51 | end 52 | 53 | it 'does not add the tenant field on the index with full_index: false' do 54 | expect(IndexableWithFullIndexesTrue).not_to have_index_for(tenant_id: 1, name: 1) 55 | expect(IndexableWithFullIndexesTrue).to have_index_for(name: 1) 56 | end 57 | end 58 | 59 | context 'with full_indexes: false' do 60 | it 'does not add the tenant field on each index' do 61 | expect(IndexableWithFullIndexesFalse).not_to have_index_for(tenant_id: 1, title: 1) 62 | expect(IndexableWithFullIndexesFalse).to have_index_for(title: 1) 63 | end 64 | 65 | it 'does add the tenant field on the index with full_index: true' do 66 | expect(IndexableWithFullIndexesFalse).to have_index_for(tenant_id: 1, name: 1) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Inheritance' do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | before do 9 | Mongoid::Multitenancy.current_tenant = client 10 | end 11 | 12 | describe 'class' do 13 | it 'uses inheritance pattern' do 14 | MutableChild.create!(title: 'title X', slug: 'page-x') 15 | expect(Mutable.last).to be_a MutableChild 16 | end 17 | 18 | it 'keeps options' do 19 | expect(AnotherMutableChild.new(title: 'title X', slug: 'page-x')).to be_valid 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/mandatory_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mandatory do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | let(:another_client) do 9 | Account.create!(name: 'another client') 10 | end 11 | 12 | let(:item) do 13 | Mandatory.new(title: 'title X', slug: 'page-x') 14 | end 15 | 16 | it_behaves_like 'a tenantable model' 17 | it { is_expected.to validate_tenant_uniqueness_of(:slug) } 18 | 19 | describe '.shared' do 20 | it 'is defined' do 21 | expect(Mandatory).to respond_to(:shared) 22 | end 23 | end 24 | 25 | describe '.unshared' do 26 | it 'is defined' do 27 | expect(Mandatory).to respond_to(:unshared) 28 | end 29 | end 30 | 31 | describe '.default_scope' do 32 | before do 33 | Mongoid::Multitenancy.with_tenant(client) { @itemX = Mandatory.create!(title: 'title X', slug: 'article-x') } 34 | Mongoid::Multitenancy.with_tenant(another_client) { @itemY = Mandatory.create!(title: 'title Y', slug: 'article-y') } 35 | end 36 | 37 | context 'with a current tenant' do 38 | before do 39 | Mongoid::Multitenancy.current_tenant = another_client 40 | end 41 | 42 | it 'filters on the current tenant' do 43 | expect(Mandatory.all.to_a).to match_array [@itemY] 44 | end 45 | end 46 | 47 | context 'without a current tenant' do 48 | before do 49 | Mongoid::Multitenancy.current_tenant = nil 50 | end 51 | 52 | it 'does not filter on any tenant' do 53 | expect(Mandatory.all.to_a).to match_array [@itemX, @itemY] 54 | end 55 | end 56 | end 57 | 58 | describe '#delete_all' do 59 | before do 60 | Mongoid::Multitenancy.with_tenant(client) { @itemX = Mandatory.create!(title: 'title X', slug: 'article-x') } 61 | Mongoid::Multitenancy.with_tenant(another_client) { @itemY = Mandatory.create!(title: 'title Y', slug: 'article-y') } 62 | end 63 | 64 | context 'with a current tenant' do 65 | it 'only deletes the current tenant' do 66 | Mongoid::Multitenancy.with_tenant(another_client) { Mandatory.delete_all } 67 | expect(Mandatory.all.to_a).to match_array [@itemX] 68 | end 69 | end 70 | 71 | context 'without a current tenant' do 72 | it 'deletes all the items' do 73 | Mandatory.delete_all 74 | expect(Mandatory.all.to_a).to be_empty 75 | end 76 | end 77 | end 78 | 79 | describe '#valid?' do 80 | context 'with a tenant' do 81 | before do 82 | Mongoid::Multitenancy.current_tenant = client 83 | end 84 | 85 | it 'is valid' do 86 | expect(item).to be_valid 87 | end 88 | 89 | context 'with a uniqueness constraint' do 90 | let(:duplicate) do 91 | Mandatory.new(title: 'title Y', slug: 'page-x') 92 | end 93 | 94 | before do 95 | item.save! 96 | end 97 | 98 | it 'does not allow duplicates on the same tenant' do 99 | expect(duplicate).not_to be_valid 100 | end 101 | 102 | it 'allow duplicates on a different same tenant' do 103 | Mongoid::Multitenancy.with_tenant(another_client) do 104 | expect(duplicate).to be_valid 105 | end 106 | end 107 | end 108 | end 109 | 110 | context 'without a tenant' do 111 | it 'is not valid' do 112 | expect(item).not_to be_valid 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/models/account.rb: -------------------------------------------------------------------------------- 1 | class Account 2 | include Mongoid::Document 3 | 4 | field :name, type: String 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/conditional_uniqueness.rb: -------------------------------------------------------------------------------- 1 | class ConditionalUniqueness 2 | include Mongoid::Document 3 | include Mongoid::Multitenancy::Document 4 | 5 | tenant(:tenant, class_name: 'Account', optional: true) 6 | 7 | field :slug, type: String 8 | field :approved, type: Boolean, default: false 9 | 10 | validates_tenant_uniqueness_of :slug, conditions: -> { where(approved: true) } 11 | validates_presence_of :slug 12 | 13 | index(title: 1) 14 | end 15 | -------------------------------------------------------------------------------- /spec/models/immutable.rb: -------------------------------------------------------------------------------- 1 | class Immutable 2 | include Mongoid::Document 3 | include Mongoid::Multitenancy::Document 4 | 5 | tenant(:tenant, class_name: 'Account', immutable: true) 6 | 7 | field :slug, type: String 8 | field :title, type: String 9 | 10 | validates_tenant_uniqueness_of :slug 11 | validates_presence_of :slug 12 | validates_presence_of :title 13 | 14 | index(title: 1) 15 | end 16 | -------------------------------------------------------------------------------- /spec/models/indexable.rb: -------------------------------------------------------------------------------- 1 | class IndexableWithoutIndex 2 | include Mongoid::Document 3 | include Mongoid::Multitenancy::Document 4 | 5 | tenant :tenant, class_name: 'Account' 6 | end 7 | 8 | class IndexableWithIndexTrue 9 | include Mongoid::Document 10 | include Mongoid::Multitenancy::Document 11 | 12 | tenant :tenant, class_name: 'Account', index: true 13 | end 14 | 15 | class IndexableWithIndexFalse 16 | include Mongoid::Document 17 | include Mongoid::Multitenancy::Document 18 | 19 | tenant :tenant, class_name: 'Account', index: false 20 | end 21 | 22 | class IndexableWithoutFullIndexes 23 | include Mongoid::Document 24 | include Mongoid::Multitenancy::Document 25 | 26 | field :title, type: String 27 | field :name, type: String 28 | field :slug, type: String 29 | 30 | tenant :tenant, class_name: 'Account' 31 | 32 | index(title: 1) 33 | index({ name: 1 }, { full_index: true }) 34 | index({ slug: 1 }, { full_index: false }) 35 | end 36 | 37 | class IndexableWithFullIndexesFalse 38 | include Mongoid::Document 39 | include Mongoid::Multitenancy::Document 40 | 41 | field :title, type: String 42 | field :name, type: String 43 | 44 | tenant :tenant, class_name: 'Account', full_indexes: false 45 | 46 | index(title: 1) 47 | index({ name: 1 }, { full_index: true }) 48 | end 49 | 50 | class IndexableWithFullIndexesTrue 51 | include Mongoid::Document 52 | include Mongoid::Multitenancy::Document 53 | 54 | field :title, type: String 55 | field :name, type: String 56 | 57 | tenant :tenant, class_name: 'Account', full_indexes: true 58 | 59 | index(title: 1) 60 | index({ name: 1 }, { full_index: false }) 61 | end 62 | -------------------------------------------------------------------------------- /spec/models/mandatory.rb: -------------------------------------------------------------------------------- 1 | class Mandatory 2 | include Mongoid::Document 3 | include Mongoid::Multitenancy::Document 4 | 5 | tenant(:tenant, class_name: 'Account') 6 | 7 | field :slug, type: String 8 | field :title, type: String 9 | 10 | validates_tenant_uniqueness_of :slug 11 | validates_presence_of :slug 12 | validates_presence_of :title 13 | 14 | index(title: 1) 15 | end 16 | -------------------------------------------------------------------------------- /spec/models/mutable.rb: -------------------------------------------------------------------------------- 1 | class Mutable 2 | include Mongoid::Document 3 | include Mongoid::Multitenancy::Document 4 | 5 | tenant :tenant, class_name: 'Account', immutable: false, optional: false 6 | 7 | field :slug, type: String 8 | field :title, type: String 9 | 10 | validates_tenant_uniqueness_of :slug 11 | validates_presence_of :slug 12 | validates_presence_of :title 13 | 14 | index(title: 1) 15 | end 16 | -------------------------------------------------------------------------------- /spec/models/mutable_child.rb: -------------------------------------------------------------------------------- 1 | require 'models/mutable' 2 | 3 | class MutableChild < Mutable 4 | field :random, type: String 5 | end 6 | 7 | class AnotherMutableChild < Mutable 8 | field :random, type: String 9 | end 10 | -------------------------------------------------------------------------------- /spec/models/no_scopable.rb: -------------------------------------------------------------------------------- 1 | class NoScopable 2 | include Mongoid::Document 3 | include Mongoid::Multitenancy::Document 4 | 5 | tenant :tenant, class_name: 'Account', scopes: false 6 | 7 | field :slug, type: String 8 | field :title, type: String 9 | 10 | index(title: 1) 11 | end 12 | -------------------------------------------------------------------------------- /spec/models/optional.rb: -------------------------------------------------------------------------------- 1 | class Optional 2 | include Mongoid::Document 3 | include Mongoid::Multitenancy::Document 4 | 5 | tenant(:tenant, class_name: 'Account', optional: true) 6 | 7 | field :slug, type: String 8 | field :title, type: String 9 | 10 | validates_tenant_uniqueness_of :slug 11 | validates_presence_of :slug 12 | validates_presence_of :title 13 | 14 | index(title: 1) 15 | end 16 | -------------------------------------------------------------------------------- /spec/models/optional_exclude.rb: -------------------------------------------------------------------------------- 1 | class OptionalExclude 2 | include Mongoid::Document 3 | include Mongoid::Multitenancy::Document 4 | 5 | tenant(:tenant, class_name: 'Account', optional: true) 6 | 7 | field :slug, type: String 8 | field :title, type: String 9 | 10 | validates_tenant_uniqueness_of :slug, exclude_shared: true, conditions: -> { ne(title: nil) } 11 | validates_presence_of :slug 12 | validates_presence_of :title 13 | 14 | index(title: 1) 15 | end 16 | -------------------------------------------------------------------------------- /spec/mongoid-multitenancy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Multitenancy do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | let(:another_client) do 9 | Account.create!(name: 'another client') 10 | end 11 | 12 | before do 13 | Mongoid::Multitenancy.current_tenant = client 14 | end 15 | 16 | describe '.with_tenant' do 17 | it 'changes temporary the current tenant within the block' do 18 | Mongoid::Multitenancy.with_tenant(another_client) do 19 | expect(Mongoid::Multitenancy.current_tenant).to eq another_client 20 | end 21 | end 22 | 23 | it 'restores the current tenant after the block' do 24 | Mongoid::Multitenancy.with_tenant(another_client) { ; } 25 | expect(Mongoid::Multitenancy.current_tenant).to eq client 26 | end 27 | 28 | context 'when the block fails' do 29 | it 'restores the current tenant' do 30 | begin 31 | Mongoid::Multitenancy.with_tenant(another_client) { raise StandardError } 32 | rescue StandardError; end 33 | expect(Mongoid::Multitenancy.current_tenant).to eq client 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/mutable_child_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MutableChild do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | let(:another_client) do 9 | Account.create!(name: 'another client') 10 | end 11 | 12 | let(:item) do 13 | MutableChild.new(title: 'title X', slug: 'page-x') 14 | end 15 | 16 | it_behaves_like 'a tenantable model' 17 | 18 | describe '#valid?' do 19 | before do 20 | Mongoid::Multitenancy.current_tenant = client 21 | end 22 | 23 | context 'when the tenant has not changed' do 24 | before do 25 | item.save! 26 | end 27 | 28 | it 'is valid' do 29 | item.title = 'title X (2)' 30 | expect(item).to be_valid 31 | end 32 | end 33 | 34 | context 'when the tenant has changed' do 35 | before do 36 | item.save! 37 | Mongoid::Multitenancy.current_tenant = another_client 38 | end 39 | 40 | it 'is valid' do 41 | item.tenant = another_client 42 | expect(item).to be_valid 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/mutable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mutable do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | let(:another_client) do 9 | Account.create!(name: 'another client') 10 | end 11 | 12 | let(:item) do 13 | Mutable.new(title: 'title X', slug: 'page-x') 14 | end 15 | 16 | it_behaves_like 'a tenantable model' 17 | 18 | describe '#valid?' do 19 | before do 20 | Mongoid::Multitenancy.current_tenant = client 21 | end 22 | 23 | after do 24 | Mongoid::Multitenancy.current_tenant = nil 25 | end 26 | 27 | context 'when the tenant has not changed' do 28 | before do 29 | item.save! 30 | end 31 | 32 | it 'is valid' do 33 | item.title = 'title X (2)' 34 | expect(item).to be_valid 35 | end 36 | end 37 | 38 | context 'when the tenant has changed' do 39 | before do 40 | item.save! 41 | Mongoid::Multitenancy.current_tenant = another_client 42 | end 43 | 44 | it 'is valid' do 45 | item.tenant = another_client 46 | expect(item).to be_valid 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/optional_exclude_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe OptionalExclude do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | let(:another_client) do 9 | Account.create!(name: 'another client') 10 | end 11 | 12 | let(:item) do 13 | OptionalExclude.new(title: 'title X', slug: 'page-x') 14 | end 15 | 16 | it_behaves_like 'a tenantable model' 17 | 18 | describe '#valid?' do 19 | context 'with a tenant' do 20 | before do 21 | Mongoid::Multitenancy.current_tenant = client 22 | end 23 | 24 | it 'is valid' do 25 | expect(item).to be_valid 26 | end 27 | 28 | context 'with a uniqueness constraint' do 29 | let(:duplicate) do 30 | OptionalExclude.new(title: 'title Y', slug: 'page-x') 31 | end 32 | 33 | before do 34 | item.save! 35 | end 36 | 37 | it 'does not allow duplicates on the same tenant' do 38 | expect(duplicate).not_to be_valid 39 | end 40 | 41 | it 'allow duplicates on a different same tenant' do 42 | Mongoid::Multitenancy.with_tenant(another_client) do 43 | expect(duplicate).to be_valid 44 | end 45 | end 46 | end 47 | end 48 | 49 | context 'without a tenant' do 50 | it 'is valid' do 51 | expect(item).to be_valid 52 | end 53 | 54 | context 'with a uniqueness constraint' do 55 | let(:duplicate) do 56 | OptionalExclude.new(title: 'title Y', slug: 'page-x') 57 | end 58 | 59 | before do 60 | item.save! 61 | end 62 | 63 | it 'allow duplicates on any client' do 64 | Mongoid::Multitenancy.with_tenant(client) do 65 | expect(duplicate).to be_valid 66 | end 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/optional_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Optional do 4 | let(:client) do 5 | Account.create!(name: 'client') 6 | end 7 | 8 | let(:another_client) do 9 | Account.create!(name: 'another client') 10 | end 11 | 12 | let(:item) do 13 | Optional.new(title: 'title X', slug: 'page-x') 14 | end 15 | 16 | it_behaves_like 'a tenantable model' 17 | it { is_expected.to validate_tenant_uniqueness_of(:slug) } 18 | 19 | describe '#initialize' do 20 | context 'within a client context' do 21 | before do 22 | Mongoid::Multitenancy.current_tenant = client 23 | end 24 | 25 | context 'when persisted' do 26 | before do 27 | item.tenant = nil 28 | item.save! 29 | end 30 | 31 | it 'does not override the client' do 32 | item.reload 33 | expect(Optional.last.tenant).to be_nil 34 | end 35 | end 36 | end 37 | end 38 | 39 | describe '.default_scope' do 40 | let!(:item_a) do 41 | Mongoid::Multitenancy.with_tenant(client) do 42 | Optional.create!(title: 'title A', slug: 'article-a') 43 | end 44 | end 45 | 46 | let!(:item_b) do 47 | Mongoid::Multitenancy.with_tenant(another_client) do 48 | Optional.create!(title: 'title B', slug: 'article-b') 49 | end 50 | end 51 | 52 | let!(:shared_item) do 53 | Optional.create!(title: 'title C', slug: 'article-c') 54 | end 55 | 56 | context 'with a current tenant' do 57 | it 'filters on the current tenant / free-tenant items' do 58 | Mongoid::Multitenancy.with_tenant(another_client) do 59 | expect(Optional.all.to_a).to match_array [shared_item, item_b] 60 | end 61 | end 62 | end 63 | 64 | context 'without a current tenant' do 65 | it 'does not filter on any tenant' do 66 | expect(Optional.all.to_a).to match_array [item_a, item_b, shared_item] 67 | end 68 | end 69 | end 70 | 71 | describe '.shared' do 72 | let!(:item_a) do 73 | Mongoid::Multitenancy.with_tenant(client) do 74 | Optional.create!(title: 'title A', slug: 'article-a') 75 | end 76 | end 77 | 78 | let!(:item_b) do 79 | Mongoid::Multitenancy.with_tenant(another_client) do 80 | Optional.create!(title: 'title B', slug: 'article-b') 81 | end 82 | end 83 | 84 | let!(:shared_item) do 85 | Optional.create!(title: 'title C', slug: 'article-c') 86 | end 87 | 88 | it 'returns only the shared items' do 89 | Mongoid::Multitenancy.with_tenant(another_client) do 90 | expect(Optional.shared.to_a).to match_array [shared_item] 91 | end 92 | end 93 | end 94 | 95 | describe '.unshared' do 96 | let!(:item_a) do 97 | Mongoid::Multitenancy.with_tenant(client) do 98 | Optional.create!(title: 'title A', slug: 'article-a') 99 | end 100 | end 101 | 102 | let!(:item_b) do 103 | Mongoid::Multitenancy.with_tenant(another_client) do 104 | Optional.create!(title: 'title B', slug: 'article-b') 105 | end 106 | end 107 | 108 | let!(:shared_item) do 109 | Optional.create!(title: 'title C', slug: 'article-c') 110 | end 111 | 112 | it 'returns only the shared items' do 113 | Mongoid::Multitenancy.with_tenant(another_client) do 114 | expect(Optional.unshared.to_a).to match_array [item_b] 115 | end 116 | end 117 | end 118 | 119 | describe '#delete_all' do 120 | let!(:item_a) do 121 | Mongoid::Multitenancy.with_tenant(client) do 122 | Optional.create!(title: 'title A', slug: 'article-a') 123 | end 124 | end 125 | 126 | let!(:item_b) do 127 | Mongoid::Multitenancy.with_tenant(another_client) do 128 | Optional.create!(title: 'title B', slug: 'article-b') 129 | end 130 | end 131 | 132 | let!(:shared_item) do 133 | Optional.create!(title: 'title C', slug: 'article-c') 134 | end 135 | 136 | context 'with a current tenant' do 137 | it 'only deletes the current tenant / free-tenant items' do 138 | Mongoid::Multitenancy.with_tenant(another_client) do 139 | Optional.delete_all 140 | end 141 | 142 | expect(Optional.all.to_a).to match_array [item_a] 143 | end 144 | end 145 | 146 | context 'without a current tenant' do 147 | it 'deletes all the pages' do 148 | Optional.delete_all 149 | expect(Optional.all.to_a).to be_empty 150 | end 151 | end 152 | end 153 | 154 | describe '#valid?' do 155 | context 'with a tenant' do 156 | before do 157 | Mongoid::Multitenancy.current_tenant = client 158 | end 159 | 160 | it 'is valid' do 161 | expect(item).to be_valid 162 | end 163 | 164 | context 'with a uniqueness constraint' do 165 | let(:duplicate) do 166 | Optional.new(title: 'title Y', slug: 'page-x') 167 | end 168 | 169 | before do 170 | item.save! 171 | end 172 | 173 | it 'does not allow duplicates on the same tenant' do 174 | expect(duplicate).not_to be_valid 175 | end 176 | 177 | it 'allow duplicates on a different same tenant' do 178 | Mongoid::Multitenancy.with_tenant(another_client) do 179 | expect(duplicate).to be_valid 180 | end 181 | end 182 | end 183 | end 184 | 185 | context 'without a tenant' do 186 | it 'is valid' do 187 | expect(item).to be_valid 188 | end 189 | 190 | context 'with a uniqueness constraint' do 191 | let(:duplicate) do 192 | Optional.new(title: 'title Y', slug: 'page-x') 193 | end 194 | 195 | before do 196 | item.save! 197 | end 198 | 199 | it 'does not allow duplicates on any client' do 200 | Mongoid::Multitenancy.with_tenant(client) do 201 | expect(duplicate).not_to be_valid 202 | end 203 | end 204 | end 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /spec/scopable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NoScopable do 4 | it_behaves_like 'a tenantable model' do 5 | let(:client) do 6 | Account.create!(name: 'client') 7 | end 8 | 9 | let(:another_client) do 10 | Account.create!(name: 'another client') 11 | end 12 | 13 | let(:item) do 14 | NoScopable.new(title: 'title X', slug: 'page-x') 15 | end 16 | end 17 | 18 | describe '.shared' do 19 | it 'is not defined' do 20 | expect(NoScopable).not_to respond_to(:shared) 21 | end 22 | end 23 | 24 | describe '.unshared' do 25 | it 'is not defined' do 26 | expect(NoScopable).not_to respond_to(:unshared) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | MODELS = File.join(File.dirname(__FILE__), 'models') 2 | 3 | require 'simplecov' 4 | require 'coveralls' 5 | require 'database_cleaner-mongoid' 6 | 7 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[ 8 | SimpleCov::Formatter::HTMLFormatter, 9 | Coveralls::SimpleCov::Formatter 10 | ] 11 | SimpleCov.start 12 | 13 | require 'rspec' 14 | require 'mongoid' 15 | require 'mongoid-multitenancy' 16 | require 'mongoid-rspec' 17 | 18 | require_relative 'support/shared_examples' 19 | require_relative 'support/mongoid_matchers' 20 | 21 | Dir["#{MODELS}/*.rb"].each { |f| require f } 22 | 23 | Mongoid.configure do |config| 24 | config.connect_to 'mongoid_multitenancy' 25 | end 26 | 27 | Mongoid.logger.level = Logger::INFO 28 | Mongo::Logger.logger.level = Logger::INFO 29 | 30 | RSpec.configure do |config| 31 | config.include Mongoid::Matchers 32 | 33 | config.expect_with :rspec do |c| 34 | c.syntax = :expect 35 | end 36 | 37 | config.before(:each) do 38 | DatabaseCleaner.clean 39 | end 40 | 41 | config.before(:each) do 42 | Mongoid::Multitenancy.current_tenant = nil 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/support/mongoid_matchers.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | class ValidateTenantUniquenessOfMatcher < ValidateUniquenessOfMatcher 5 | def initialize(field) 6 | @field = field.to_s 7 | @type = 'tenant_uniqueness' 8 | @options = {} 9 | end 10 | end 11 | 12 | def validate_tenant_uniqueness_of(field) 13 | ValidateTenantUniquenessOfMatcher.new(field) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/shared_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'a tenantable model' do 2 | it { is_expected.to belong_to(:tenant) } 3 | it { is_expected.to have_index_for(tenant_id: 1, title: 1) } 4 | 5 | describe '#initialize' do 6 | context 'within a client context' do 7 | before do 8 | Mongoid::Multitenancy.current_tenant = client 9 | end 10 | 11 | it 'set the client' do 12 | expect(item.tenant).to eq client 13 | end 14 | end 15 | 16 | context 'without a client context' do 17 | before do 18 | Mongoid::Multitenancy.current_tenant = nil 19 | end 20 | 21 | it 'does not set any client' do 22 | expect(item.tenant).to be_nil 23 | end 24 | end 25 | end 26 | 27 | describe '#valid?' do 28 | context 'within a client context' do 29 | before do 30 | Mongoid::Multitenancy.current_tenant = client 31 | end 32 | 33 | context 'with the client id' do 34 | before do 35 | item.tenant = client 36 | end 37 | 38 | it 'is valid' do 39 | expect(item).to be_valid 40 | end 41 | end 42 | 43 | context 'with another client id' do 44 | before do 45 | item.tenant = another_client 46 | end 47 | 48 | it 'is not valid' do 49 | expect(item).not_to be_valid 50 | end 51 | end 52 | end 53 | 54 | context 'without a client context' do 55 | before do 56 | Mongoid::Multitenancy.current_tenant = nil 57 | end 58 | 59 | context 'with the client id' do 60 | before do 61 | item.tenant = client 62 | end 63 | 64 | it 'is valid' do 65 | expect(item).to be_valid 66 | end 67 | end 68 | 69 | context 'with another client id' do 70 | before do 71 | item.tenant = another_client 72 | end 73 | 74 | it 'is valid' do 75 | expect(item).to be_valid 76 | end 77 | end 78 | end 79 | end 80 | end 81 | --------------------------------------------------------------------------------