acts_as_multi_tenant 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
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