synerma-apartment 3.1.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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.pryrc +5 -0
  4. data/.rspec +4 -0
  5. data/.rubocop.yml +79 -0
  6. data/.ruby-version +1 -0
  7. data/Appraisals +182 -0
  8. data/CODE_OF_CONDUCT.md +71 -0
  9. data/Gemfile +20 -0
  10. data/Guardfile +11 -0
  11. data/README.md +671 -0
  12. data/Rakefile +157 -0
  13. data/legacy_CHANGELOG.md +965 -0
  14. data/lib/apartment/active_record/connection_handling.rb +31 -0
  15. data/lib/apartment/active_record/internal_metadata.rb +9 -0
  16. data/lib/apartment/active_record/postgres/schema_dumper.rb +20 -0
  17. data/lib/apartment/active_record/postgresql_adapter.rb +58 -0
  18. data/lib/apartment/active_record/schema_migration.rb +11 -0
  19. data/lib/apartment/adapters/abstract_adapter.rb +275 -0
  20. data/lib/apartment/adapters/abstract_jdbc_adapter.rb +20 -0
  21. data/lib/apartment/adapters/jdbc_mysql_adapter.rb +19 -0
  22. data/lib/apartment/adapters/jdbc_postgresql_adapter.rb +62 -0
  23. data/lib/apartment/adapters/mysql2_adapter.rb +77 -0
  24. data/lib/apartment/adapters/postgis_adapter.rb +13 -0
  25. data/lib/apartment/adapters/postgresql_adapter.rb +280 -0
  26. data/lib/apartment/adapters/sqlite3_adapter.rb +66 -0
  27. data/lib/apartment/adapters/trilogy_adapter.rb +29 -0
  28. data/lib/apartment/console.rb +24 -0
  29. data/lib/apartment/custom_console.rb +42 -0
  30. data/lib/apartment/deprecation.rb +8 -0
  31. data/lib/apartment/elevators/domain.rb +23 -0
  32. data/lib/apartment/elevators/first_subdomain.rb +18 -0
  33. data/lib/apartment/elevators/generic.rb +33 -0
  34. data/lib/apartment/elevators/host.rb +35 -0
  35. data/lib/apartment/elevators/host_hash.rb +26 -0
  36. data/lib/apartment/elevators/subdomain.rb +66 -0
  37. data/lib/apartment/log_subscriber.rb +45 -0
  38. data/lib/apartment/migrator.rb +46 -0
  39. data/lib/apartment/model.rb +29 -0
  40. data/lib/apartment/railtie.rb +68 -0
  41. data/lib/apartment/tasks/enhancements.rb +55 -0
  42. data/lib/apartment/tasks/task_helper.rb +54 -0
  43. data/lib/apartment/tenant.rb +63 -0
  44. data/lib/apartment/version.rb +5 -0
  45. data/lib/apartment.rb +155 -0
  46. data/lib/generators/apartment/install/USAGE +5 -0
  47. data/lib/generators/apartment/install/install_generator.rb +11 -0
  48. data/lib/generators/apartment/install/templates/apartment.rb +116 -0
  49. data/lib/tasks/apartment.rake +106 -0
  50. data/synerma-apartment.gemspec +40 -0
  51. metadata +198 -0
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/adapters/abstract_adapter'
4
+ require 'apartment/active_record/postgresql_adapter'
5
+
6
+ module Apartment
7
+ module Tenant
8
+ def self.postgresql_adapter(config)
9
+ adapter = Adapters::PostgresqlAdapter
10
+ adapter = Adapters::PostgresqlSchemaAdapter if Apartment.use_schemas
11
+ adapter = Adapters::PostgresqlSchemaFromSqlAdapter if Apartment.use_sql && Apartment.use_schemas
12
+ adapter.new(config)
13
+ end
14
+ end
15
+
16
+ module Adapters
17
+ # Default adapter when not using Postgresql Schemas
18
+ class PostgresqlAdapter < AbstractAdapter
19
+ private
20
+
21
+ def rescue_from
22
+ PG::Error
23
+ end
24
+ end
25
+
26
+ # Separate Adapter for Postgresql when using schemas
27
+ class PostgresqlSchemaAdapter < AbstractAdapter
28
+ def initialize(config)
29
+ super
30
+
31
+ reset
32
+ end
33
+
34
+ def default_tenant
35
+ @default_tenant = Apartment.default_tenant || 'public'
36
+ end
37
+
38
+ # Reset schema search path to the default schema_search_path
39
+ #
40
+ # @return {String} default schema search path
41
+ #
42
+ def reset
43
+ @current = default_tenant
44
+ Apartment.connection.schema_search_path = full_search_path
45
+ end
46
+
47
+ def init
48
+ super
49
+ Apartment.connection.schema_search_path = full_search_path
50
+ end
51
+
52
+ def current
53
+ @current || default_tenant
54
+ end
55
+
56
+ protected
57
+
58
+ def process_excluded_model(excluded_model)
59
+ excluded_model.constantize.tap do |klass|
60
+ # Ensure that if a schema *was* set, we override
61
+ table_name = klass.table_name.split('.', 2).last
62
+
63
+ klass.table_name = "#{default_tenant}.#{table_name}"
64
+ end
65
+ end
66
+
67
+ def drop_command(conn, tenant)
68
+ conn.execute(%(DROP SCHEMA "#{tenant}" CASCADE))
69
+ end
70
+
71
+ # Set schema search path to new schema
72
+ #
73
+ def connect_to_new(tenant = nil)
74
+ return reset if tenant.nil?
75
+ raise ActiveRecord::StatementInvalid, "Could not find schema #{tenant}" unless schema_exists?(tenant)
76
+
77
+ @current = tenant.is_a?(Array) ? tenant.map(&:to_s) : tenant.to_s
78
+ Apartment.connection.schema_search_path = full_search_path
79
+ rescue *rescuable_exceptions => e
80
+ raise_schema_connect_to_new(tenant, e)
81
+ end
82
+
83
+ private
84
+
85
+ def tenant_exists?(tenant)
86
+ return true unless Apartment.tenant_presence_check
87
+
88
+ Apartment.connection.schema_exists?(tenant)
89
+ end
90
+
91
+ def create_tenant_command(conn, tenant)
92
+ # NOTE: This was causing some tests to fail because of the database strategy for rspec
93
+ if ActiveRecord::Base.connection.open_transactions.positive?
94
+ conn.execute(%(CREATE SCHEMA "#{tenant}"))
95
+ else
96
+ schema = %(BEGIN;
97
+ CREATE SCHEMA "#{tenant}";
98
+ COMMIT;)
99
+
100
+ conn.execute(schema)
101
+ end
102
+ rescue *rescuable_exceptions => e
103
+ rollback_transaction(conn)
104
+ raise e
105
+ end
106
+
107
+ def rollback_transaction(conn)
108
+ conn.execute('ROLLBACK;')
109
+ end
110
+
111
+ # Generate the final search path to set including persistent_schemas
112
+ #
113
+ def full_search_path
114
+ persistent_schemas.map(&:inspect).join(', ')
115
+ end
116
+
117
+ def persistent_schemas
118
+ [@current, Apartment.persistent_schemas].flatten
119
+ end
120
+
121
+ def postgresql_version
122
+ # ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#postgresql_version is
123
+ # public from Rails 5.0.
124
+ Apartment.connection.send(:postgresql_version)
125
+ end
126
+
127
+ def schema_exists?(schemas)
128
+ return true unless Apartment.tenant_presence_check
129
+
130
+ Array(schemas).all? { |schema| Apartment.connection.schema_exists?(schema.to_s) }
131
+ end
132
+
133
+ def raise_schema_connect_to_new(tenant, exception)
134
+ raise TenantNotFound, <<~EXCEPTION_MESSAGE
135
+ Could not set search path to schemas, they may be invalid: "#{tenant}" #{full_search_path}.
136
+ Original error: #{exception.class}: #{exception}
137
+ EXCEPTION_MESSAGE
138
+ end
139
+ end
140
+
141
+ # Another Adapter for Postgresql when using schemas and SQL
142
+ class PostgresqlSchemaFromSqlAdapter < PostgresqlSchemaAdapter
143
+ PSQL_DUMP_BLACKLISTED_STATEMENTS = [
144
+ /SET search_path/i, # overridden later
145
+ /SET lock_timeout/i, # new in postgresql 9.3
146
+ /SET row_security/i, # new in postgresql 9.5
147
+ /SET idle_in_transaction_session_timeout/i, # new in postgresql 9.6
148
+ /SET default_table_access_method/i, # new in postgresql 12
149
+ /CREATE SCHEMA/i,
150
+ /COMMENT ON SCHEMA/i,
151
+ /SET transaction_timeout/i, # new in postgresql 17
152
+
153
+ ].freeze
154
+
155
+ def import_database_schema
156
+ preserving_search_path do
157
+ clone_pg_schema
158
+ copy_schema_migrations
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ # Re-set search path after the schema is imported.
165
+ # Postgres now sets search path to empty before dumping the schema
166
+ # and it mut be reset
167
+ #
168
+ def preserving_search_path
169
+ search_path = Apartment.connection.execute('show search_path').first['search_path']
170
+ yield
171
+ Apartment.connection.execute("set search_path = #{search_path}")
172
+ end
173
+
174
+ # Clone default schema into new schema named after current tenant
175
+ #
176
+ def clone_pg_schema
177
+ pg_schema_sql = patch_search_path(pg_dump_schema)
178
+ Apartment.connection.execute(pg_schema_sql)
179
+ end
180
+
181
+ # Copy data from schema_migrations into new schema
182
+ #
183
+ def copy_schema_migrations
184
+ pg_migrations_data = patch_search_path(pg_dump_schema_migrations_data)
185
+ Apartment.connection.execute(pg_migrations_data)
186
+ end
187
+
188
+ # Dump postgres default schema
189
+ #
190
+ # @return {String} raw SQL contaning only postgres schema dump
191
+ #
192
+ def pg_dump_schema
193
+ exclude_table =
194
+ if Apartment.pg_exclude_clone_tables
195
+ excluded_tables.map! { |t| "-T #{t}" }.join(' ')
196
+ else
197
+ ''
198
+ end
199
+ with_pg_env { `pg_dump -s -x -O -n #{default_tenant} #{dbname} #{exclude_table}` }
200
+ end
201
+
202
+ # Dump data from schema_migrations table
203
+ #
204
+ # @return {String} raw SQL contaning inserts with data from schema_migrations
205
+ #
206
+ # rubocop:disable Layout/LineLength
207
+ def pg_dump_schema_migrations_data
208
+ with_pg_env { `pg_dump -a --inserts -t #{default_tenant}.schema_migrations -t #{default_tenant}.ar_internal_metadata #{dbname}` }
209
+ end
210
+ # rubocop:enable Layout/LineLength
211
+
212
+ # Temporary set Postgresql related environment variables if there are in @config
213
+ #
214
+ def with_pg_env
215
+ pghost = ENV['PGHOST']
216
+ pgport = ENV['PGPORT']
217
+ pguser = ENV['PGUSER']
218
+ pgpassword = ENV['PGPASSWORD']
219
+
220
+ ENV['PGHOST'] = @config[:host] if @config[:host]
221
+ ENV['PGPORT'] = @config[:port].to_s if @config[:port]
222
+ ENV['PGUSER'] = @config[:username].to_s if @config[:username]
223
+ ENV['PGPASSWORD'] = @config[:password].to_s if @config[:password]
224
+
225
+ yield
226
+ ensure
227
+ ENV['PGHOST'] = pghost
228
+ ENV['PGPORT'] = pgport
229
+ ENV['PGUSER'] = pguser
230
+ ENV['PGPASSWORD'] = pgpassword
231
+ end
232
+
233
+ # Remove "SET search_path ..." line from SQL dump and prepend search_path set to current tenant
234
+ #
235
+ # @return {String} patched raw SQL dump
236
+ #
237
+ def patch_search_path(sql)
238
+ search_path = "SET search_path = \"#{current}\", #{default_tenant};"
239
+
240
+ swap_schema_qualifier(sql)
241
+ .split("\n")
242
+ .select { |line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty? }
243
+ .prepend(search_path)
244
+ .join("\n")
245
+ end
246
+
247
+ def swap_schema_qualifier(sql)
248
+ sql.gsub(/#{default_tenant}\.\w*/) do |match|
249
+ if Apartment.pg_excluded_names.any? { |name| match.include? name }
250
+ match
251
+ elsif Apartment.pg_exclude_clone_tables && excluded_tables.any?(match)
252
+ match
253
+ else
254
+ match.gsub("#{default_tenant}.", %("#{current}".))
255
+ end
256
+ end
257
+ end
258
+
259
+ # Checks if any of regexps matches against input
260
+ #
261
+ def check_input_against_regexps(input, regexps)
262
+ regexps.select { |c| input.match c }
263
+ end
264
+
265
+ # Convenience method for excluded table names
266
+ #
267
+ def excluded_tables
268
+ Apartment.excluded_models.map do |m|
269
+ m.constantize.table_name
270
+ end
271
+ end
272
+
273
+ # Convenience method for current database name
274
+ #
275
+ def dbname
276
+ Apartment.connection_config[:database]
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/adapters/abstract_adapter'
4
+
5
+ module Apartment
6
+ module Tenant
7
+ def self.sqlite3_adapter(config)
8
+ Adapters::Sqlite3Adapter.new(config)
9
+ end
10
+ end
11
+
12
+ module Adapters
13
+ class Sqlite3Adapter < AbstractAdapter
14
+ def initialize(config)
15
+ @default_dir = File.expand_path(File.dirname(config[:database]))
16
+
17
+ super
18
+ end
19
+
20
+ def drop(tenant)
21
+ unless File.exist?(database_file(tenant))
22
+ raise TenantNotFound,
23
+ "The tenant #{environmentify(tenant)} cannot be found."
24
+ end
25
+
26
+ File.delete(database_file(tenant))
27
+ end
28
+
29
+ def current
30
+ File.basename(Apartment.connection.instance_variable_get(:@config)[:database], '.sqlite3')
31
+ end
32
+
33
+ protected
34
+
35
+ def connect_to_new(tenant)
36
+ return reset if tenant.nil?
37
+
38
+ unless File.exist?(database_file(tenant))
39
+ raise TenantNotFound,
40
+ "The tenant #{environmentify(tenant)} cannot be found."
41
+ end
42
+
43
+ super database_file(tenant)
44
+ end
45
+
46
+ def create_tenant(tenant)
47
+ if File.exist?(database_file(tenant))
48
+ raise TenantExists,
49
+ "The tenant #{environmentify(tenant)} already exists."
50
+ end
51
+
52
+ begin
53
+ f = File.new(database_file(tenant), File::CREAT)
54
+ ensure
55
+ f.close
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def database_file(tenant)
62
+ "#{@default_dir}/#{environmentify(tenant)}.sqlite3"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/adapters/mysql2_adapter'
4
+
5
+ module Apartment
6
+ # Helper module to decide wether to use trilogy adapter or trilogy adapter with schemas
7
+ module Tenant
8
+ def self.trilogy_adapter(config)
9
+ if Apartment.use_schemas
10
+ Adapters::TrilogySchemaAdapter.new(config)
11
+ else
12
+ Adapters::TrilogyAdapter.new(config)
13
+ end
14
+ end
15
+ end
16
+
17
+ module Adapters
18
+ class TrilogyAdapter < Mysql2Adapter
19
+ protected
20
+
21
+ def rescue_from
22
+ Trilogy::Error
23
+ end
24
+ end
25
+
26
+ class TrilogySchemaAdapter < Mysql2SchemaAdapter
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ def st(schema_name = nil)
4
+ if schema_name.nil?
5
+ tenant_list.each { |t| puts t }
6
+
7
+ elsif tenant_list.include? schema_name
8
+ Apartment::Tenant.switch!(schema_name)
9
+ else
10
+ puts "Tenant #{schema_name} is not part of the tenant list"
11
+
12
+ end
13
+ end
14
+
15
+ def tenant_list
16
+ tenant_list = [Apartment.default_tenant]
17
+ tenant_list += Apartment.tenant_names
18
+ tenant_list.uniq
19
+ end
20
+
21
+ def tenant_info_msg
22
+ puts "Available Tenants: #{tenant_list}\n"
23
+ puts "Use `st 'tenant'` to switch tenants & `tenant_list` to see list\n"
24
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'console'
4
+
5
+ module Apartment
6
+ module CustomConsole
7
+ begin
8
+ require 'pry-rails'
9
+ rescue LoadError
10
+ # rubocop:disable Layout/LineLength
11
+ puts '[Failed to load pry-rails] If you want to use Apartment custom prompt you need to add pry-rails to your gemfile'
12
+ # rubocop:enable Layout/LineLength
13
+ end
14
+
15
+ desc = "Includes the current Rails environment and project folder name.\n" \
16
+ '[1] [project_name][Rails.env][Apartment::Tenant.current] pry(main)>'
17
+
18
+ prompt_procs = [
19
+ proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '>') },
20
+ proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '*') }
21
+ ]
22
+
23
+ if Gem::Version.new(Pry::VERSION) >= Gem::Version.new('0.13')
24
+ Pry.config.prompt = Pry::Prompt.new 'ros', desc, prompt_procs
25
+ else
26
+ Pry::Prompt.add 'ros', desc, %w[> *] do |target_self, nest_level, pry, sep|
27
+ prompt_contents(pry, target_self, nest_level, sep)
28
+ end
29
+ Pry.config.prompt = Pry::Prompt[:ros][:value]
30
+ end
31
+
32
+ Pry.config.hooks.add_hook(:when_started, 'startup message') do
33
+ tenant_info_msg
34
+ end
35
+
36
+ def self.prompt_contents(pry, target_self, nest_level, sep)
37
+ "[#{pry.input_ring.size}] [#{PryRails::Prompt.formatted_env}][#{Apartment::Tenant.current}] " \
38
+ "#{pry.config.prompt_name}(#{Pry.view_clip(target_self)})" \
39
+ "#{":#{nest_level}" unless nest_level.zero?}#{sep} "
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/deprecation'
4
+ require_relative 'version'
5
+
6
+ module Apartment
7
+ DEPRECATOR = ActiveSupport::Deprecation.new(Apartment::VERSION, 'Apartment')
8
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/elevators/generic'
4
+
5
+ module Apartment
6
+ module Elevators
7
+ # Provides a rack based tenant switching solution based on domain
8
+ # Assumes that tenant name should match domain
9
+ # Parses request host for second level domain, ignoring www
10
+ # eg. example.com => example
11
+ # www.example.bc.ca => example
12
+ # a.example.bc.ca => a
13
+ #
14
+ #
15
+ class Domain < Generic
16
+ def parse_tenant_name(request)
17
+ return nil if request.host.blank?
18
+
19
+ request.host.match(/(www\.)?(?<sld>[^.]*)/)['sld']
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/elevators/subdomain'
4
+
5
+ module Apartment
6
+ module Elevators
7
+ # Provides a rack based tenant switching solution based on the first subdomain
8
+ # of a given domain name.
9
+ # eg:
10
+ # - example1.domain.com => example1
11
+ # - example2.something.domain.com => example2
12
+ class FirstSubdomain < Subdomain
13
+ def parse_tenant_name(request)
14
+ super.split('.')[0] unless super.nil?
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/request'
4
+ require 'apartment/tenant'
5
+
6
+ module Apartment
7
+ module Elevators
8
+ # Provides a rack based tenant switching solution based on request
9
+ #
10
+ class Generic
11
+ def initialize(app, processor = nil)
12
+ @app = app
13
+ @processor = processor || method(:parse_tenant_name)
14
+ end
15
+
16
+ def call(env)
17
+ request = Rack::Request.new(env)
18
+
19
+ database = @processor.call(request)
20
+
21
+ if database
22
+ Apartment::Tenant.switch(database) { @app.call(env) }
23
+ else
24
+ @app.call(env)
25
+ end
26
+ end
27
+
28
+ def parse_tenant_name(_request)
29
+ raise 'Override'
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/elevators/generic'
4
+
5
+ module Apartment
6
+ module Elevators
7
+ # Provides a rack based tenant switching solution based on the host
8
+ # Assumes that tenant name should match host
9
+ # Strips/ignores first subdomains in ignored_first_subdomains
10
+ # eg. example.com => example.com
11
+ # www.example.bc.ca => www.example.bc.ca
12
+ # if ignored_first_subdomains = ['www']
13
+ # www.example.bc.ca => example.bc.ca
14
+ # www.a.b.c.d.com => a.b.c.d.com
15
+ #
16
+ class Host < Generic
17
+ def self.ignored_first_subdomains
18
+ @ignored_first_subdomains ||= []
19
+ end
20
+
21
+ # rubocop:disable Style/TrivialAccessors
22
+ def self.ignored_first_subdomains=(arg)
23
+ @ignored_first_subdomains = arg
24
+ end
25
+ # rubocop:enable Style/TrivialAccessors
26
+
27
+ def parse_tenant_name(request)
28
+ return nil if request.host.blank?
29
+
30
+ parts = request.host.split('.')
31
+ self.class.ignored_first_subdomains.include?(parts[0]) ? parts.drop(1).join('.') : request.host
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/elevators/generic'
4
+
5
+ module Apartment
6
+ module Elevators
7
+ # Provides a rack based tenant switching solution based on hosts
8
+ # Uses a hash to find the corresponding tenant name for the host
9
+ #
10
+ class HostHash < Generic
11
+ def initialize(app, hash = {}, processor = nil)
12
+ super app, processor
13
+ @hash = hash
14
+ end
15
+
16
+ def parse_tenant_name(request)
17
+ unless @hash.key?(request.host)
18
+ raise TenantNotFound,
19
+ "Cannot find tenant for host #{request.host}"
20
+ end
21
+
22
+ @hash[request.host]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/elevators/generic'
4
+ require 'public_suffix'
5
+
6
+ module Apartment
7
+ module Elevators
8
+ # Provides a rack based tenant switching solution based on subdomains
9
+ # Assumes that tenant name should match subdomain
10
+ #
11
+ class Subdomain < Generic
12
+ def self.excluded_subdomains
13
+ @excluded_subdomains ||= []
14
+ end
15
+
16
+ # rubocop:disable Style/TrivialAccessors
17
+ def self.excluded_subdomains=(arg)
18
+ @excluded_subdomains = arg
19
+ end
20
+ # rubocop:enable Style/TrivialAccessors
21
+
22
+ def parse_tenant_name(request)
23
+ request_subdomain = subdomain(request.host)
24
+
25
+ # If the domain acquired is set to be excluded, set the tenant to whatever is currently
26
+ # next in line in the schema search path.
27
+ tenant = if self.class.excluded_subdomains.include?(request_subdomain)
28
+ nil
29
+ else
30
+ request_subdomain
31
+ end
32
+
33
+ tenant.presence
34
+ end
35
+
36
+ protected
37
+
38
+ # *Almost* a direct ripoff of ActionDispatch::Request subdomain methods
39
+
40
+ # Only care about the first subdomain for the database name
41
+ def subdomain(host)
42
+ subdomains(host).first
43
+ end
44
+
45
+ def subdomains(host)
46
+ host_valid?(host) ? parse_host(host) : []
47
+ end
48
+
49
+ def host_valid?(host)
50
+ !ip_host?(host) && domain_valid?(host)
51
+ end
52
+
53
+ def ip_host?(host)
54
+ !/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.match(host).nil?
55
+ end
56
+
57
+ def domain_valid?(host)
58
+ PublicSuffix.valid?(host, ignore_private: true)
59
+ end
60
+
61
+ def parse_host(host)
62
+ (PublicSuffix.parse(host, ignore_private: true).trd || '').split('.')
63
+ end
64
+ end
65
+ end
66
+ end