acts_as_multi_tenant 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f9bec876a4f3fa00936a46c89514da147bd257c064e40752122e0270ee45137
4
- data.tar.gz: 4e37d1398746befc54de7d1a42a102f668aadf4b95e80ec853d8a960f83bfd70
3
+ metadata.gz: 839e8371c9bef7b4192ed3ed8a724358185dadefd207455495c74417a582eb46
4
+ data.tar.gz: fb6107209c01fef24a0e7ab46ca5f627916f76fcd04263e197f465a6d2962c87
5
5
  SHA512:
6
- metadata.gz: df02d6f79a2f0abe627ae2319acb6930cc8cd4043cec326b34da71ca5f704b64ab64c38a5e940bf342d6f39310463f3ca6e2eeb047481af3282052e57cdec9fe
7
- data.tar.gz: fcd53a8bcb9bff89f16a5523d9175ba5ed6dbc0bb6399dd2a16312f5c4fbdeb867d6ff4373bf95dc28e6f3ca84ce8a79d8d955cd2432939dbf5a3ad686c5651a
6
+ metadata.gz: 781cce611831503923b1a4351c5bdf7f70f26e5f280b8d99489d4296f73d36b9511add58df9348fe06c8e1526b9b90a2b1f63b81810a5c3ac23e3e3f628038a7
7
+ data.tar.gz: d6c1d709cc05cf8de12eea5eb2cea0aa1703cd5210b06c06485f0d3e272f764c56fb56462146fc3545545dd9fab613de014a4e7aaa27f18a785f5bad3a0c3967
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # acts_as_multi_tenant
2
2
 
3
- Keep multiple tenants in a single ActiveRecord database, and keep their data separate. Let's say the `Client` AR model represents your "tenants". Some Rack middleware will keep track of the "current" client in the request cycle, which will automatically filter *all ActiveRecord queries* by that client, and automatically associate new records to the client as well.
3
+ Keep multiple tenants in a single ActiveRecord database, and keep their data separate. Let's say the `Client` AR model represents your "tenants". Rack middleware will keep track of the "current" client in the request cycle which will automatically filter *all ActiveRecord queries* by that client. New records will automatically be associated to that client as well.
4
4
 
5
5
  There are 3 main components:
6
6
 
@@ -10,7 +10,7 @@ There are 3 main components:
10
10
 
11
11
  ## MultiTenant::Middleware
12
12
 
13
- Add the middleware in **config.ru**, or wherever you add middleware.
13
+ Add the middleware in **config.ru** or wherever you add middleware.
14
14
 
15
15
  ```ruby
16
16
  use MultiTenant::Middleware,
@@ -115,6 +115,27 @@ end
115
115
  License.current == Client.current.license
116
116
  ```
117
117
 
118
+ ## Multiple current tenants
119
+
120
+ Some applications may need to allow multiple current tenants at once. For example, a single user account may have access to multiple clients. `acts_as_multi_tenant` supports this with the `current: :multiple` option. When this is set, `Client.current` will be an array of clients. Queries will be filtered to ANY of those clients.
121
+
122
+ ```ruby
123
+ class Client < ActiveRecord::Base
124
+ acts_as_tenant using: :code, current: :multiple
125
+ end
126
+ ```
127
+
128
+ When you add your middleware, your `identifier` option must also return an array:
129
+
130
+ ```ruby
131
+ use MultiTenant::Middleware,
132
+ model: -> { Client.active },
133
+
134
+ identifier: ->(req) {
135
+ req.params["clients"] || []
136
+ }
137
+ ```
138
+
118
139
  ## Testing
119
140
 
120
141
  bundle install
@@ -5,3 +5,11 @@ require_relative 'multi_tenant/proxies_to_tenant'
5
5
  require_relative 'multi_tenant/belongs_to_tenant'
6
6
  require_relative 'multi_tenant/belongs_to_tenant_through'
7
7
  require_relative 'multi_tenant/middleware'
8
+
9
+ module MultiTenant
10
+ module Impl
11
+ NotImplemented = Class.new(StandardError)
12
+ autoload :SingleCurrent, 'multi_tenant/impl/single_current'
13
+ autoload :MultipleCurrent, 'multi_tenant/impl/multiple_current'
14
+ end
15
+ end
@@ -9,13 +9,22 @@ module MultiTenant
9
9
  #
10
10
  # Use this ActiveRecord model as the tenant source.
11
11
  #
12
+ # The "current" option allows you to specify whether Client.current is a single record (the default) or an array of records.
13
+ #
12
14
  # @param using [String] (optional) column that contains the unique lookup identifier. Defaults to :code.
15
+ # @param current [Symbol] :single | :multiple
13
16
  #
14
- def acts_as_tenant(using: :code)
15
- cattr_accessor :tenant_identifier, :tenant_thread_var
17
+ def acts_as_tenant(using: :code, current: :single)
18
+ cattr_accessor :tenant_identifier, :tenant_thread_var, :multi_tenant_impl
16
19
  self.tenant_identifier = using
17
20
  self.tenant_thread_var = "current_tenant_#{object_id}".freeze # allows there to be multiple tenant classes
21
+ self.multi_tenant_impl = case current
22
+ when :single then MultiTenant::Impl::SingleCurrent.new(self)
23
+ when :multiple then MultiTenant::Impl::MultipleCurrent.new(self)
24
+ else raise ArgumentError, "Unknown current option '#{current}'"
25
+ end
18
26
  self.extend MultiTenant::ActsAsTenant::ClassMethods
27
+ self.extend self.multi_tenant_impl.acts_as_tenant_class_methods
19
28
  end
20
29
 
21
30
  #
@@ -60,13 +69,7 @@ module MultiTenant
60
69
  # @param record_or_identifier the record or the identifier in the 'tenant_identifier' column.
61
70
  #
62
71
  def current=(record_or_identifier)
63
- obj = if record_or_identifier.is_a? self
64
- record_or_identifier
65
- elsif record_or_identifier
66
- where({tenant_identifier => record_or_identifier}).first
67
- else
68
- nil
69
- end
72
+ obj = resolve_tenant record_or_identifier
70
73
  Thread.current.thread_variable_set tenant_thread_var, obj
71
74
  end
72
75
 
@@ -102,7 +105,7 @@ module MultiTenant
102
105
  #
103
106
  def without_tenant
104
107
  old_current = self.current
105
- self.current = nil
108
+ self.current = resolve_tenant nil
106
109
  yield if block_given?
107
110
  ensure
108
111
  self.current = old_current
@@ -27,17 +27,10 @@ module MultiTenant
27
27
  self.tenant_foreign_key = reflection.foreign_key.to_sym
28
28
  self.tenant_primary_key = reflection.association_primary_key.to_sym
29
29
 
30
- before_validation :assign_to_tenant
31
- validates_presence_of tenant_foreign_key
32
-
33
- self.class_eval do
34
- include MultiTenant::BelongsToTenant::InstanceMethods
30
+ include tenant_class.multi_tenant_impl.belongs_to_tenant_instance_methods
31
+ default_scope(&tenant_class.multi_tenant_impl.belongs_to_tenant_default_scope(self))
35
32
 
36
- default_scope {
37
- current = tenant_class.current
38
- current ? where({tenant_foreign_key => current.send(tenant_primary_key)}) : where('1=1')
39
- }
40
- end
33
+ validates_presence_of tenant_foreign_key
41
34
  end
42
35
 
43
36
  #
@@ -48,22 +41,6 @@ module MultiTenant
48
41
  def belongs_to_tenant?
49
42
  respond_to? :tenant_class
50
43
  end
51
-
52
- #
53
- # Instance methods given to tenant-owned models.
54
- #
55
- module InstanceMethods
56
- private
57
-
58
- #
59
- # Assign this model to the current tenant (if any)
60
- #
61
- def assign_to_tenant
62
- if self.class.tenant_class.current and send(self.class.tenant_foreign_key).blank?
63
- send "#{self.class.tenant_foreign_key}=", self.class.tenant_class.current.send(self.class.tenant_primary_key)
64
- end
65
- end
66
- end
67
44
  end
68
45
  end
69
46
 
@@ -22,17 +22,8 @@ module MultiTenant
22
22
  cattr_accessor :delegate_class
23
23
  self.delegate_class = ref.klass
24
24
 
25
- self.class_eval do
26
- default_scope {
27
- tenant = delegate_class.tenant_class.current
28
- next where('1=1') if tenant.nil?
29
-
30
- # Using straight sql so we can JOIN against two columns. Otherwise one must go into "WHERE", and Arel would mistakenly apply it to UPDATEs and DELETEs.
31
- quoted_tenant_id = connection.quote tenant.send delegate_class.tenant_primary_key
32
- joins("INNER JOIN #{ref.klass.table_name} ON #{ref.klass.table_name}.#{ref.foreign_key}=#{table_name}.#{ref.association_primary_key} AND #{ref.klass.table_name}.#{ref.klass.tenant_foreign_key}=#{quoted_tenant_id}").
33
- readonly(false) # using "joins" makes records readonly, which we don't want
34
- }
35
- end
25
+ impl = self.delegate_class.tenant_class.multi_tenant_impl
26
+ default_scope(&impl.belongs_to_tenant_through_default_scope(self, ref))
36
27
  end
37
28
 
38
29
  #
@@ -0,0 +1,104 @@
1
+ module MultiTenant
2
+ module Impl
3
+ #
4
+ # An implementation where Tenant.current is an array of tenants. All queries will be scoped to ANY of these
5
+ # tenants.
6
+ #
7
+ class MultipleCurrent
8
+ attr_reader :tenant_class
9
+
10
+ def initialize(tenant_class)
11
+ @tenant_class = tenant_class
12
+ end
13
+
14
+ def acts_as_tenant_class_methods
15
+ ActsAsTenantClassMethods
16
+ end
17
+
18
+ def belongs_to_tenant_instance_methods
19
+ BelongsToTenantInstanceMethods
20
+ end
21
+
22
+ def belongs_to_tenant_default_scope(model)
23
+ -> {
24
+ current = tenant_class.current.map(&model.tenant_primary_key)
25
+ current.any? ? model.where({model.tenant_foreign_key => current}) : model.where('1=1')
26
+ }
27
+ end
28
+
29
+ def belongs_to_tenant_through_default_scope(model, ref)
30
+ -> {
31
+ tenants = model.delegate_class.tenant_class.current
32
+ next model.where('1=1') if tenants.empty?
33
+
34
+ # Using straight sql so we can JOIN against two columns. Otherwise one must go into "WHERE", and Arel would apply it to UPDATEs and DELETEs.
35
+ quoted_tenant_ids = tenants.map { |t| model.connection.quote t.send model.delegate_class.tenant_primary_key }
36
+ model.joins("INNER JOIN #{ref.klass.table_name} ON #{ref.klass.table_name}.#{ref.foreign_key}=#{model.table_name}.#{ref.association_primary_key} AND #{ref.klass.table_name}.#{ref.klass.tenant_foreign_key} IN (#{quoted_tenant_ids.join(',')})").
37
+ distinct.
38
+ readonly(false) # using "joins" makes records readonly, which we don't want
39
+ }
40
+ end
41
+
42
+ def proxies_to_tenant_class_methods(_ref)
43
+ raise MultiTenant::Impl::NotImplemented, "`proxies_to_tenant` is not currently supported for impl `:multiple`."
44
+ end
45
+
46
+ def matching_globals(records_or_identifiers, globals)
47
+ records_or_identifiers.reduce([]) { |a, rec_or_id|
48
+ id = rec_or_id.is_a?(tenant_class) ? rec_or_id.send(tenant_class.tenant_identifier) : rec_or_id
49
+ a << globals[id] if globals.has_key? id
50
+ a
51
+ }
52
+ end
53
+
54
+ #
55
+ # Class methods given to the tenant model.
56
+ #
57
+ module ActsAsTenantClassMethods
58
+ def self.extended(model)
59
+ model.current = []
60
+ end
61
+
62
+ def current?
63
+ !current.nil? && current.any?
64
+ end
65
+
66
+ def resolve_tenant(records_or_identifiers)
67
+ if records_or_identifiers.nil?
68
+ []
69
+ elsif records_or_identifiers.any? { |x| x.is_a? self }
70
+ records_or_identifiers
71
+ elsif records_or_identifiers.any?
72
+ where({tenant_identifier => records_or_identifiers}).to_a
73
+ else
74
+ []
75
+ end
76
+ end
77
+ end
78
+
79
+ #
80
+ # Instance methods given to tenant-owned models.
81
+ #
82
+ module BelongsToTenantInstanceMethods
83
+ def self.included(model)
84
+ model.class_eval do
85
+ validate :ensure_assigned_to_current_tenants
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ #
92
+ # If the tenant_id is set, make sure it's one of the current ones.
93
+ #
94
+ def ensure_assigned_to_current_tenants
95
+ _tenants = self.class.tenant_class.current.map(&:id)
96
+ _tenant_id = send self.class.tenant_foreign_key
97
+ if _tenants.any? and _tenant_id.present? and !_tenants.include?(_tenant_id.to_s)
98
+ errors.add(self.class.tenant_foreign_key, "is incorrect")
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,124 @@
1
+ module MultiTenant
2
+ module Impl
3
+ #
4
+ # An implementation where Tenant.current is set to the current tenant, or nil.
5
+ #
6
+ class SingleCurrent
7
+ attr_reader :tenant_class
8
+
9
+ def initialize(tenant_class)
10
+ @tenant_class = tenant_class
11
+ end
12
+
13
+ def acts_as_tenant_class_methods
14
+ ActsAsTenantClassMethods
15
+ end
16
+
17
+ def belongs_to_tenant_instance_methods
18
+ BelongsToTenantInstanceMethods
19
+ end
20
+
21
+ def belongs_to_tenant_default_scope(model)
22
+ -> {
23
+ current = model.tenant_class.current
24
+ current ? model.where({model.tenant_foreign_key => current.send(model.tenant_primary_key)}) : model.where('1=1')
25
+ }
26
+ end
27
+
28
+ def belongs_to_tenant_through_default_scope(model, ref)
29
+ -> {
30
+ tenant = model.delegate_class.tenant_class.current
31
+ next model.where('1=1') if tenant.nil?
32
+
33
+ # Using straight sql so we can JOIN against two columns. Otherwise one must go into "WHERE", and Arel would apply it to UPDATEs and DELETEs.
34
+ quoted_tenant_id = model.connection.quote tenant.send model.delegate_class.tenant_primary_key
35
+ model.joins("INNER JOIN #{ref.klass.table_name} ON #{ref.klass.table_name}.#{ref.foreign_key}=#{model.table_name}.#{ref.association_primary_key} AND #{ref.klass.table_name}.#{ref.klass.tenant_foreign_key}=#{quoted_tenant_id}").
36
+ readonly(false) # using "joins" makes records readonly, which we don't want
37
+ }
38
+ end
39
+
40
+ def proxies_to_tenant_class_methods(ref)
41
+ case [ref.macro, ref.inverse_of.macro]
42
+ when [:has_many, :belongs_to], [:has_one, :belongs_to], [:belongs_to, :has_one]
43
+ ProxiesToTenantSingularInverseAssociation
44
+ else
45
+ raise MultiTenant::Impl::NotImplemented, "`proxies_to_tenant` does not currently support `#{ref.macro}` associations with `#{ref.inverse_of.macro} inverses."
46
+ ProxiesToTenantPluralInverseAssociation
47
+ end
48
+ end
49
+
50
+ def matching_globals(record_or_identifier, globals)
51
+ id = record_or_identifier.is_a?(tenant_class) ? record_or_identifier.send(tenant_class.tenant_identifier) : record_or_identifier
52
+ globals.has_key?(id) ? [globals[id]] : []
53
+ end
54
+
55
+ #
56
+ # Class methods given to the tenant model.
57
+ #
58
+ module ActsAsTenantClassMethods
59
+ def current?
60
+ !current.nil?
61
+ end
62
+
63
+ def resolve_tenant(record_or_identifier)
64
+ if record_or_identifier.is_a? self
65
+ record_or_identifier
66
+ elsif record_or_identifier
67
+ where({tenant_identifier => record_or_identifier}).first
68
+ else
69
+ nil
70
+ end
71
+ end
72
+ end
73
+
74
+ #
75
+ # Instance methods given to tenant-owned models.
76
+ #
77
+ module BelongsToTenantInstanceMethods
78
+ def self.included(model)
79
+ model.class_eval do
80
+ before_validation :assign_to_tenant
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ #
87
+ # Assign this model to the current tenant (if any)
88
+ #
89
+ def assign_to_tenant
90
+ if self.class.tenant_class.current
91
+ current_tenant_id = self.class.tenant_class.current.send(self.class.tenant_primary_key)
92
+ send "#{self.class.tenant_foreign_key}=", current_tenant_id
93
+ end
94
+ end
95
+ end
96
+
97
+ #
98
+ # Class methods for tenant proxies that have a singular inverse association (i.e. belongs_to or has_one).
99
+ #
100
+ module ProxiesToTenantSingularInverseAssociation
101
+ # Returns the current record of the proxy model
102
+ def current
103
+ if (tenant = proxied_tenant_class.current)
104
+ tenant.send proxied_tenant_inverse_assoc
105
+ end
106
+ end
107
+ end
108
+
109
+ #
110
+ # Class methods for tenant proxies that have a plural inverse association (i.e. has_many).
111
+ # NOTE These are just some thoughts on *maybe* how to support this if we ever need it.
112
+ #
113
+ module ProxiesToTenantPluralInverseAssociation
114
+ # Returns the current record of the proxy model
115
+ def current
116
+ raise MultiTenant::Impl::NotImplemented, "needs confirmed"
117
+ if (tenant = proxied_tenant_class.current)
118
+ tenant.send(proxied_tenant_inverse_assoc).instance_eval(&proxied_tenant_inverse_scope).first
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -47,7 +47,8 @@ module MultiTenant
47
47
 
48
48
  # Default Proc for the not_found option
49
49
  DEFAULT_NOT_FOUND = ->(x) {
50
- [404, {'Content-Type' => 'text/html', 'Content-Length' => (33 + x.to_s.size).to_s}, ['<h1>\'%s\' is not a valid tenant</h1>' % x.to_s]]
50
+ body = "<h1>Invalid tenant: #{Array(x).map(&:to_s).join ', '}</h1>"
51
+ [404, {'Content-Type' => 'text/html', 'Content-Length' => body.size.to_s}, [body]]
51
52
  }
52
53
 
53
54
  #
@@ -73,15 +74,18 @@ module MultiTenant
73
74
  # Rack request call
74
75
  def call(env)
75
76
  tenant_class.current = nil
77
+ impl = tenant_class.multi_tenant_impl
78
+
76
79
  request = Rack::Request.new env
77
80
  tenant_identifier = identifier.(request)
78
81
 
79
- if (allowed_paths = globals[tenant_identifier])
80
- allowed = path_matches?(request, allowed_paths)
82
+ if (matching_globals = impl.matching_globals(tenant_identifier, globals)).any?
83
+ allowed = matching_globals.any? { |allowed_paths|
84
+ path_matches?(request, allowed_paths)
85
+ }
81
86
  return allowed ? @app.call(env) : not_found.(tenant_identifier)
82
87
 
83
- elsif (tenant = tenant_class.where({tenant_class.tenant_identifier => tenant_identifier}).first)
84
- tenant_class.current = tenant
88
+ elsif (tenant_class.current = tenant_identifier) and tenant_class.current?
85
89
  return @app.call env
86
90
 
87
91
  else
@@ -52,17 +52,14 @@ module MultiTenant
52
52
  raise "`proxies_to_tenant :#{association_name}`: #{reflection.klass.name} must use `acts_as_tenant`" if !reflection.klass.acts_as_tenant?
53
53
  raise "`proxies_to_tenant :#{association_name}`: the `:#{association_name}` association must use the `:inverse_of` option." if reflection.inverse_of.nil?
54
54
 
55
- case [reflection.macro, reflection.inverse_of.macro]
56
- when [:has_many, :belongs_to], [:has_one, :belongs_to], [:belongs_to, :has_one]
57
- self.extend SingularInverseAssociation
58
- else
59
- raise "`proxies_to_tenant` does not currently support `#{reflection.macro}` associations with `#{reflection.inverse_of.macro} inverses."
60
- end
61
-
62
55
  cattr_accessor :proxied_tenant_class, :proxied_tenant_inverse_assoc, :proxied_tenant_inverse_scope
63
56
  self.proxied_tenant_class = reflection.klass
64
57
  self.proxied_tenant_inverse_assoc = reflection.inverse_of.name
65
58
  self.proxied_tenant_inverse_scope = scope
59
+
60
+ impl = self.proxied_tenant_class.multi_tenant_impl
61
+ self.extend impl.proxies_to_tenant_class_methods(reflection)
62
+ self.extend ClassMethods
66
63
  end
67
64
 
68
65
  #
@@ -74,25 +71,12 @@ module MultiTenant
74
71
  respond_to? :proxied_tenant_class
75
72
  end
76
73
 
77
- private
78
-
79
- # Class methods for tenant proxies that have a singular inverse association (i.e. belongs_to or has_one).
80
- module SingularInverseAssociation
81
- # Returns the "current" record of the proxy model
82
- def current
83
- if (tenant = proxied_tenant_class.current)
84
- tenant.send proxied_tenant_inverse_assoc
85
- end
86
- end
87
- end
88
-
89
- # NOTE just some thoughts on *maybe* how to support this if we ever need it.
90
- module PluralInverseAssociation
91
- # Returns the "current" record of the proxy model
92
- def current
93
- if (tenant = proxied_tenant_class.current)
94
- tenant.send(proxied_tenant_inverse_assoc).instance_eval(&proxied_tenant_inverse_scope).first
95
- end
74
+ #
75
+ # Class methods given to proxies.
76
+ #
77
+ module ClassMethods
78
+ def multi_tenant_impl
79
+ proxied_tenant_class.multi_tenant_impl
96
80
  end
97
81
  end
98
82
  end
@@ -1,4 +1,4 @@
1
1
  module MultiTenant
2
2
  # Gem version
3
- VERSION = '1.1.0'.freeze
3
+ VERSION = '1.2.0'.freeze
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acts_as_multi_tenant
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Hollinger
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2018-06-07 00:00:00.000000000 Z
13
+ date: 2018-11-23 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activerecord
@@ -58,6 +58,8 @@ files:
58
58
  - lib/multi_tenant/acts_as_tenant.rb
59
59
  - lib/multi_tenant/belongs_to_tenant.rb
60
60
  - lib/multi_tenant/belongs_to_tenant_through.rb
61
+ - lib/multi_tenant/impl/multiple_current.rb
62
+ - lib/multi_tenant/impl/single_current.rb
61
63
  - lib/multi_tenant/middleware.rb
62
64
  - lib/multi_tenant/proxies_to_tenant.rb
63
65
  - lib/multi_tenant/version.rb