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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord # :nodoc:
4
+ # This is monkeypatching Active Record to ensure that whenever a new connection is established it
5
+ # switches to the same tenant as before the connection switching. This problem is more evident when
6
+ # using read replica in Rails 6
7
+ module ConnectionHandling
8
+ if ActiveRecord.version.release <= Gem::Version.new('6.2')
9
+ def connected_to_with_tenant(database: nil, role: nil, prevent_writes: false, &blk)
10
+ current_tenant = Apartment::Tenant.current
11
+
12
+ connected_to_without_tenant(database: database, role: role, prevent_writes: prevent_writes) do
13
+ Apartment::Tenant.switch!(current_tenant)
14
+ yield(blk)
15
+ end
16
+ end
17
+ else
18
+ def connected_to_with_tenant(role: nil, shard: nil, prevent_writes: false, &blk)
19
+ current_tenant = Apartment::Tenant.current
20
+
21
+ connected_to_without_tenant(role: role, shard: shard, prevent_writes: prevent_writes) do
22
+ Apartment::Tenant.switch!(current_tenant)
23
+ yield(blk)
24
+ end
25
+ end
26
+ end
27
+
28
+ alias connected_to_without_tenant connected_to
29
+ alias connected_to connected_to_with_tenant
30
+ end
31
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InternalMetadata < ActiveRecord::Base # :nodoc:
4
+ class << self
5
+ def table_exists?
6
+ connection.table_exists?(table_name)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This patch prevents `create_schema` from being added to db/schema.rb as schemas are managed by Apartment
4
+ # not ActiveRecord like they would be in a vanilla Rails setup.
5
+
6
+ require 'active_record/connection_adapters/abstract/schema_dumper'
7
+ require 'active_record/connection_adapters/postgresql/schema_dumper'
8
+
9
+ module ActiveRecord
10
+ module ConnectionAdapters
11
+ module PostgreSQL
12
+ class SchemaDumper
13
+ alias _original_schemas schemas
14
+ def schemas(stream)
15
+ _original_schemas(stream) unless Apartment.use_schemas
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+
5
+ # NOTE: This patch is meant to remove any schema_prefix appart from the ones for
6
+ # excluded models. The schema_prefix would be resolved by apartment's setting
7
+ # of search path
8
+ module Apartment::PostgreSqlAdapterPatch
9
+ def default_sequence_name(table, _column)
10
+ res = super
11
+
12
+ # for JDBC driver, if rescued in super_method, trim leading and trailing quotes
13
+ res.delete!('"') if defined?(JRUBY_VERSION)
14
+
15
+ schema_prefix = "#{sequence_schema(res)}."
16
+
17
+ # NOTE: Excluded models should always access the sequence from the default
18
+ # tenant schema
19
+ if excluded_model?(table)
20
+ default_tenant_prefix = "#{Apartment::Tenant.default_tenant}."
21
+
22
+ # Unless the res is already prefixed with the default_tenant_prefix
23
+ # we should delete the schema_prefix and add the default_tenant_prefix
24
+ unless res&.starts_with?(default_tenant_prefix)
25
+ res&.delete_prefix!(schema_prefix)
26
+ res = default_tenant_prefix + res
27
+ end
28
+
29
+ return res
30
+ end
31
+
32
+ # Delete the schema_prefix from the res if it is present
33
+ res&.delete_prefix!(schema_prefix)
34
+
35
+ res
36
+ end
37
+
38
+ private
39
+
40
+ def sequence_schema(sequence_name)
41
+ current = Apartment::Tenant.current
42
+ return current unless current.is_a?(Array)
43
+
44
+ current.find { |schema| sequence_name.starts_with?("#{schema}.") }
45
+ end
46
+
47
+ def excluded_model?(table)
48
+ Apartment.excluded_models.any? { |m| m.constantize.table_name == table }
49
+ end
50
+ end
51
+
52
+ require 'active_record/connection_adapters/postgresql_adapter'
53
+
54
+ # NOTE: inject this into postgresql adapters
55
+ class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
56
+ include Apartment::PostgreSqlAdapterPatch
57
+ end
58
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ class SchemaMigration # :nodoc:
5
+ class << self
6
+ def table_exists?
7
+ connection.table_exists?(table_name)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apartment
4
+ module Adapters
5
+ # Abstract adapter from which all the Apartment DB related adapters will inherit the base logic
6
+ class AbstractAdapter
7
+ include ActiveSupport::Callbacks
8
+ define_callbacks :create, :switch
9
+
10
+ attr_writer :default_tenant
11
+
12
+ # @constructor
13
+ # @param {Hash} config Database config
14
+ #
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ # Create a new tenant, import schema, seed if appropriate
20
+ #
21
+ # @param {String} tenant Tenant name
22
+ #
23
+ def create(tenant)
24
+ run_callbacks :create do
25
+ create_tenant(tenant)
26
+
27
+ switch(tenant) do
28
+ import_database_schema
29
+
30
+ # Seed data if appropriate
31
+ seed_data if Apartment.seed_after_create
32
+
33
+ yield if block_given?
34
+ end
35
+ end
36
+ end
37
+
38
+ # Initialize Apartment config options such as excluded_models
39
+ #
40
+ def init
41
+ process_excluded_models
42
+ end
43
+
44
+ # Note alias_method here doesn't work with inheritence apparently ??
45
+ #
46
+ def current
47
+ Apartment.connection.current_database
48
+ end
49
+
50
+ # Return the original public tenant
51
+ #
52
+ # @return {String} default tenant name
53
+ #
54
+ def default_tenant
55
+ @default_tenant || Apartment.default_tenant
56
+ end
57
+
58
+ # Drop the tenant
59
+ #
60
+ # @param {String} tenant name
61
+ #
62
+ def drop(tenant)
63
+ with_neutral_connection(tenant) do |conn|
64
+ drop_command(conn, tenant)
65
+ end
66
+ rescue *rescuable_exceptions => e
67
+ raise_drop_tenant_error!(tenant, e)
68
+ end
69
+
70
+ # Switch to a new tenant
71
+ #
72
+ # @param {String} tenant name
73
+ #
74
+ def switch!(tenant = nil)
75
+ run_callbacks :switch do
76
+ connect_to_new(tenant).tap do
77
+ Apartment.connection.clear_query_cache
78
+ end
79
+ end
80
+ end
81
+
82
+ # Connect to tenant, do your biz, switch back to previous tenant
83
+ #
84
+ # @param {String?} tenant to connect to
85
+ #
86
+ def switch(tenant = nil)
87
+ previous_tenant = current
88
+ switch!(tenant)
89
+ yield
90
+ ensure
91
+ begin
92
+ switch!(previous_tenant)
93
+ rescue StandardError => _e
94
+ reset
95
+ end
96
+ end
97
+
98
+ # Iterate over all tenants, switch to tenant and yield tenant name
99
+ #
100
+ def each(tenants = Apartment.tenant_names)
101
+ tenants.each do |tenant|
102
+ switch(tenant) { yield tenant }
103
+ end
104
+ end
105
+
106
+ # Establish a new connection for each specific excluded model
107
+ #
108
+ def process_excluded_models
109
+ # All other models will shared a connection (at Apartment.connection_class)
110
+ # and we can modify at will
111
+ Apartment.excluded_models.each do |excluded_model|
112
+ process_excluded_model(excluded_model)
113
+ end
114
+ end
115
+
116
+ # Reset the tenant connection to the default
117
+ #
118
+ def reset
119
+ Apartment.establish_connection @config
120
+ end
121
+
122
+ # Load the rails seed file into the db
123
+ #
124
+ def seed_data
125
+ # Don't log the output of seeding the db
126
+ silence_warnings { load_or_raise(Apartment.seed_data_file) } if Apartment.seed_data_file
127
+ end
128
+ alias seed seed_data
129
+
130
+ # Prepend the environment if configured and the environment isn't already there
131
+ #
132
+ # @param {String} tenant Database name
133
+ # @return {String} tenant name with Rails environment *optionally* prepended
134
+ #
135
+ def environmentify(tenant)
136
+ return tenant if tenant.nil? || tenant.include?(Rails.env)
137
+
138
+ if Apartment.prepend_environment
139
+ "#{Rails.env}_#{tenant}"
140
+ elsif Apartment.append_environment
141
+ "#{tenant}_#{Rails.env}"
142
+ else
143
+ tenant
144
+ end
145
+ end
146
+
147
+ protected
148
+
149
+ def process_excluded_model(excluded_model)
150
+ excluded_model.constantize.establish_connection @config
151
+ end
152
+
153
+ def drop_command(conn, tenant)
154
+ # connection.drop_database note that drop_database will not throw an exception, so manually execute
155
+ conn.execute("DROP DATABASE #{conn.quote_table_name(environmentify(tenant))}")
156
+ end
157
+
158
+ # Create the tenant
159
+ #
160
+ # @param {String} tenant Database name
161
+ #
162
+ def create_tenant(tenant)
163
+ with_neutral_connection(tenant) do |conn|
164
+ create_tenant_command(conn, tenant)
165
+ end
166
+ rescue *rescuable_exceptions => e
167
+ raise_create_tenant_error!(tenant, e)
168
+ end
169
+
170
+ def create_tenant_command(conn, tenant)
171
+ conn.create_database(environmentify(tenant), @config)
172
+ end
173
+
174
+ # Connect to new tenant
175
+ #
176
+ # @param {String} tenant Database name
177
+ #
178
+ def connect_to_new(tenant)
179
+ return reset if tenant.nil?
180
+
181
+ query_cache_enabled = ActiveRecord::Base.connection.query_cache_enabled
182
+
183
+ Apartment.establish_connection multi_tenantify(tenant)
184
+ Apartment.connection.verify! # call active? to manually check if this connection is valid
185
+
186
+ Apartment.connection.enable_query_cache! if query_cache_enabled
187
+ rescue *rescuable_exceptions => e
188
+ Apartment::Tenant.reset if reset_on_connection_exception?
189
+ raise_connect_error!(tenant, e)
190
+ end
191
+
192
+ # Import the database schema
193
+ #
194
+ def import_database_schema
195
+ ActiveRecord::Schema.verbose = false # do not log schema load output.
196
+
197
+ load_or_raise(Apartment.database_schema_file) if Apartment.database_schema_file
198
+ end
199
+
200
+ # Return a new config that is multi-tenanted
201
+ # @param {String} tenant: Database name
202
+ # @param {Boolean} with_database: if true, use the actual tenant's db name
203
+ # if false, use the default db name from the db
204
+ # rubocop:disable Style/OptionalBooleanParameter
205
+ def multi_tenantify(tenant, with_database = true)
206
+ db_connection_config(tenant).tap do |config|
207
+ multi_tenantify_with_tenant_db_name(config, tenant) if with_database
208
+ end
209
+ end
210
+ # rubocop:enable Style/OptionalBooleanParameter
211
+
212
+ def multi_tenantify_with_tenant_db_name(config, tenant)
213
+ config[:database] = environmentify(tenant)
214
+ end
215
+
216
+ # Load a file or raise error if it doesn't exists
217
+ #
218
+ def load_or_raise(file)
219
+ raise FileNotFound, "#{file} doesn't exist yet" unless File.exist?(file)
220
+
221
+ load(file)
222
+ end
223
+ # Backward compatibility
224
+ alias load_or_abort load_or_raise
225
+
226
+ # Exceptions to rescue from on db operations
227
+ #
228
+ def rescuable_exceptions
229
+ [ActiveRecord::ActiveRecordError] + Array(rescue_from)
230
+ end
231
+
232
+ # Extra exceptions to rescue from
233
+ #
234
+ def rescue_from
235
+ []
236
+ end
237
+
238
+ def db_connection_config(tenant)
239
+ Apartment.db_config_for(tenant).dup
240
+ end
241
+
242
+ def with_neutral_connection(tenant, &_block)
243
+ if Apartment.with_multi_server_setup
244
+ # neutral connection is necessary whenever you need to create/remove a database from a server.
245
+ # example: when you use postgresql, you need to connect to the default postgresql database before you create
246
+ # your own.
247
+ SeparateDbConnectionHandler.establish_connection(multi_tenantify(tenant, false))
248
+ yield(SeparateDbConnectionHandler.connection)
249
+ SeparateDbConnectionHandler.connection.close
250
+ else
251
+ yield(Apartment.connection)
252
+ end
253
+ end
254
+
255
+ def reset_on_connection_exception?
256
+ false
257
+ end
258
+
259
+ def raise_drop_tenant_error!(tenant, exception)
260
+ raise TenantNotFound, "Error while dropping tenant #{environmentify(tenant)}: #{exception.message}"
261
+ end
262
+
263
+ def raise_create_tenant_error!(tenant, exception)
264
+ raise TenantExists, "Error while creating tenant #{environmentify(tenant)}: #{exception.message}"
265
+ end
266
+
267
+ def raise_connect_error!(tenant, exception)
268
+ raise TenantNotFound, "Error while connecting to tenant #{environmentify(tenant)}: #{exception.message}"
269
+ end
270
+
271
+ class SeparateDbConnectionHandler < ::ActiveRecord::Base
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/adapters/abstract_adapter'
4
+
5
+ module Apartment
6
+ module Adapters
7
+ # JDBC Abstract adapter
8
+ class AbstractJDBCAdapter < AbstractAdapter
9
+ private
10
+
11
+ def multi_tenantify_with_tenant_db_name(config, tenant)
12
+ config[:url] = "#{config[:url].gsub(%r{(\S+)/.+$}, '\1')}/#{environmentify(tenant)}"
13
+ end
14
+
15
+ def rescue_from
16
+ ActiveRecord::JDBCError
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/adapters/abstract_jdbc_adapter'
4
+
5
+ module Apartment
6
+ module Tenant
7
+ def self.jdbc_mysql_adapter(config)
8
+ Adapters::JDBCMysqlAdapter.new config
9
+ end
10
+ end
11
+
12
+ module Adapters
13
+ class JDBCMysqlAdapter < AbstractJDBCAdapter
14
+ def reset_on_connection_exception?
15
+ true
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/adapters/postgresql_adapter'
4
+
5
+ module Apartment
6
+ # JDBC helper to decide wether to use JDBC Postgresql Adapter or JDBC Postgresql Adapter with Schemas
7
+ module Tenant
8
+ def self.jdbc_postgresql_adapter(config)
9
+ if Apartment.use_schemas
10
+ Adapters::JDBCPostgresqlSchemaAdapter.new(config)
11
+ else
12
+ Adapters::JDBCPostgresqlAdapter.new(config)
13
+ end
14
+ end
15
+ end
16
+
17
+ module Adapters
18
+ # Default adapter when not using Postgresql Schemas
19
+ class JDBCPostgresqlAdapter < PostgresqlAdapter
20
+ private
21
+
22
+ def multi_tenantify_with_tenant_db_name(config, tenant)
23
+ config[:url] = "#{config[:url].gsub(%r{(\S+)/.+$}, '\1')}/#{environmentify(tenant)}"
24
+ end
25
+
26
+ def create_tenant_command(conn, tenant)
27
+ conn.create_database(environmentify(tenant), thisisahack: '')
28
+ end
29
+
30
+ def rescue_from
31
+ ActiveRecord::JDBCError
32
+ end
33
+ end
34
+
35
+ # Separate Adapter for Postgresql when using schemas
36
+ class JDBCPostgresqlSchemaAdapter < PostgresqlSchemaAdapter
37
+ # Set schema search path to new schema
38
+ #
39
+ def connect_to_new(tenant = nil)
40
+ return reset if tenant.nil?
41
+ raise ActiveRecord::StatementInvalid, "Could not find schema #{tenant}" unless schema_exists?(tenant)
42
+
43
+ @current = tenant.is_a?(Array) ? tenant.map(&:to_s) : tenant.to_s
44
+ Apartment.connection.schema_search_path = full_search_path
45
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::JDBCError
46
+ raise TenantNotFound, "One of the following schema(s) is invalid: #{full_search_path}"
47
+ end
48
+
49
+ private
50
+
51
+ def tenant_exists?(tenant)
52
+ return true unless Apartment.tenant_presence_check
53
+
54
+ Apartment.connection.all_schemas.include? tenant
55
+ end
56
+
57
+ def rescue_from
58
+ ActiveRecord::JDBCError
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'apartment/adapters/abstract_adapter'
4
+
5
+ module Apartment
6
+ # Helper module to decide wether to use mysql2 adapter or mysql2 adapter with schemas
7
+ module Tenant
8
+ def self.mysql2_adapter(config)
9
+ if Apartment.use_schemas
10
+ Adapters::Mysql2SchemaAdapter.new(config)
11
+ else
12
+ Adapters::Mysql2Adapter.new(config)
13
+ end
14
+ end
15
+ end
16
+
17
+ module Adapters
18
+ # Mysql2 Adapter
19
+ class Mysql2Adapter < AbstractAdapter
20
+ def initialize(config)
21
+ super
22
+
23
+ @default_tenant = config[:database]
24
+ end
25
+
26
+ protected
27
+
28
+ def rescue_from
29
+ Mysql2::Error
30
+ end
31
+ end
32
+
33
+ # Mysql2 Schemas Adapter
34
+ class Mysql2SchemaAdapter < AbstractAdapter
35
+ def initialize(config)
36
+ super
37
+
38
+ @default_tenant = config[:database]
39
+ reset
40
+ end
41
+
42
+ # Reset current tenant to the default_tenant
43
+ #
44
+ def reset
45
+ return unless default_tenant
46
+
47
+ Apartment.connection.execute "use `#{default_tenant}`"
48
+ end
49
+
50
+ protected
51
+
52
+ # Connect to new tenant
53
+ #
54
+ def connect_to_new(tenant)
55
+ return reset if tenant.nil?
56
+
57
+ Apartment.connection.execute "use `#{environmentify(tenant)}`"
58
+ rescue ActiveRecord::StatementInvalid => e
59
+ Apartment::Tenant.reset
60
+ raise_connect_error!(tenant, e)
61
+ end
62
+
63
+ def process_excluded_model(model)
64
+ model.constantize.tap do |klass|
65
+ # Ensure that if a schema *was* set, we override
66
+ table_name = klass.table_name.split('.', 2).last
67
+
68
+ klass.table_name = "#{default_tenant}.#{table_name}"
69
+ end
70
+ end
71
+
72
+ def reset_on_connection_exception?
73
+ true
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # handle postgis adapter as if it were postgresql,
4
+ # only override the adapter_method used for initialization
5
+ require 'apartment/adapters/postgresql_adapter'
6
+
7
+ module Apartment
8
+ module Tenant
9
+ def self.postgis_adapter(config)
10
+ postgresql_adapter(config)
11
+ end
12
+ end
13
+ end