pg_multitenant_schemas 0.1.3
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 +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +372 -0
- data/.rubocop_simple.yml +0 -0
- data/CHANGELOG.md +50 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +149 -0
- data/Rakefile +12 -0
- data/lib/pg_multitenant_schemas/configuration.rb +37 -0
- data/lib/pg_multitenant_schemas/context.rb +106 -0
- data/lib/pg_multitenant_schemas/errors.rb +11 -0
- data/lib/pg_multitenant_schemas/rails/controller_concern.rb +73 -0
- data/lib/pg_multitenant_schemas/rails/model_concern.rb +88 -0
- data/lib/pg_multitenant_schemas/rails/railtie.rb +27 -0
- data/lib/pg_multitenant_schemas/schema_switcher.rb +141 -0
- data/lib/pg_multitenant_schemas/tasks/pg_multitenant_schemas.rake +155 -0
- data/lib/pg_multitenant_schemas/tenant_resolver.rb +92 -0
- data/lib/pg_multitenant_schemas/version.rb +5 -0
- data/lib/pg_multitenant_schemas.rb +33 -0
- data/pg_multitenant_schemas.gemspec +40 -0
- data/rails_integration/app/controllers/application_controller.rb +0 -0
- data/rails_integration/app/models/tenant.rb +0 -0
- data/sig/pg_multitenant_schemas.rbs +4 -0
- metadata +126 -0
data/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# PgMultitenantSchemas
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/pg_multitenant_schemas)
|
|
4
|
+
[](https://github.com/yourusername/pg_multitenant_schemas/actions/workflows/main.yml)
|
|
5
|
+
|
|
6
|
+
A Ruby gem that provides PostgreSQL schema-based multitenancy with automatic tenant resolution, schema switching, and Rails integration. Perfect for SaaS applications that need secure tenant isolation.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- ๐ข **Schema-based multitenancy** - Complete tenant isolation using PostgreSQL schemas
|
|
11
|
+
- ๐ **Automatic schema switching** - Seamlessly switch between tenant schemas
|
|
12
|
+
- ๐ **Subdomain resolution** - Extract tenant from request subdomains
|
|
13
|
+
- ๐ก๏ธ **Rails 8 compatible** - Works with Rails 7.x and 8.x
|
|
14
|
+
- ๐ **Dual API support** - Backward compatible API design
|
|
15
|
+
- ๐งต **Thread-safe** - Safe for concurrent operations
|
|
16
|
+
- ๐ **Comprehensive logging** - Track schema operations
|
|
17
|
+
- โก **High performance** - Minimal overhead
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Add this line to your application's Gemfile:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem 'pg_multitenant_schemas'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
And then execute:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or install it yourself as:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
gem install pg_multitenant_schemas
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
Configure the gem in your Rails initializer (`config/initializers/pg_multitenant_schemas.rb`):
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
PgMultitenantSchemas.configure do |config|
|
|
45
|
+
config.connection_class = 'ApplicationRecord' # or 'ActiveRecord::Base'
|
|
46
|
+
config.tenant_model_class = 'Tenant' # your tenant model
|
|
47
|
+
config.default_schema = 'public'
|
|
48
|
+
config.excluded_subdomains = ['www', 'api', 'admin']
|
|
49
|
+
config.development_fallback = true # for development
|
|
50
|
+
config.auto_create_schemas = true # automatically create missing schemas
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
### Basic Schema Operations
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# Switch to a tenant schema
|
|
60
|
+
PgMultitenantSchemas::SchemaSwitcher.switch_schema('tenant_123')
|
|
61
|
+
|
|
62
|
+
# Create a new schema
|
|
63
|
+
PgMultitenantSchemas::SchemaSwitcher.create_schema('tenant_456')
|
|
64
|
+
|
|
65
|
+
# Check if schema exists
|
|
66
|
+
PgMultitenantSchemas::SchemaSwitcher.schema_exists?('tenant_123')
|
|
67
|
+
|
|
68
|
+
# Drop a schema
|
|
69
|
+
PgMultitenantSchemas::SchemaSwitcher.drop_schema('tenant_456')
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Tenant Resolution
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# Extract tenant from subdomain
|
|
76
|
+
subdomain = PgMultitenantSchemas.extract_subdomain('acme.myapp.com')
|
|
77
|
+
# => 'acme'
|
|
78
|
+
|
|
79
|
+
# Find tenant by subdomain
|
|
80
|
+
tenant = PgMultitenantSchemas.find_tenant_by_subdomain('acme')
|
|
81
|
+
|
|
82
|
+
# Resolve tenant from Rails request
|
|
83
|
+
tenant = PgMultitenantSchemas.resolve_tenant_from_request(request)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Context Management
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# Switch to tenant context
|
|
90
|
+
PgMultitenantSchemas.switch_to_tenant(tenant)
|
|
91
|
+
|
|
92
|
+
# Use block-based context switching
|
|
93
|
+
PgMultitenantSchemas.with_tenant(tenant) do
|
|
94
|
+
# All queries here use tenant's schema
|
|
95
|
+
User.all # Queries tenant_123.users
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check current context
|
|
99
|
+
PgMultitenantSchemas.current_tenant
|
|
100
|
+
PgMultitenantSchemas.current_schema
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Rails Integration
|
|
104
|
+
|
|
105
|
+
In your ApplicationController:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
class ApplicationController < ActionController::Base
|
|
109
|
+
include PgMultitenantSchemas::Rails::ControllerConcern
|
|
110
|
+
|
|
111
|
+
before_action :resolve_tenant
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def resolve_tenant
|
|
116
|
+
tenant = resolve_tenant_from_subdomain
|
|
117
|
+
switch_to_tenant(tenant) if tenant
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
In your models:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class Tenant < ApplicationRecord
|
|
126
|
+
include PgMultitenantSchemas::Rails::ModelConcern
|
|
127
|
+
|
|
128
|
+
after_create :create_tenant_schema
|
|
129
|
+
after_destroy :drop_tenant_schema
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
136
|
+
|
|
137
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
138
|
+
|
|
139
|
+
## Contributing
|
|
140
|
+
|
|
141
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/pg_multitenant_schemas. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/pg_multitenant_schemas/blob/main/CODE_OF_CONDUCT.md).
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
146
|
+
|
|
147
|
+
## Code of Conduct
|
|
148
|
+
|
|
149
|
+
Everyone interacting in the PgMultitenantSchemas project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/pg_multitenant_schemas/blob/main/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# PgMultitenantSchemas provides PostgreSQL schema-based multitenancy functionality.
|
|
4
|
+
module PgMultitenantSchemas
|
|
5
|
+
# Configuration class for PgMultitenantSchemas gem settings.
|
|
6
|
+
# Manages tenant resolution, schema switching behavior, and Rails integration options.
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :tenant_model_class, :default_schema, :development_fallback,
|
|
9
|
+
:excluded_subdomains, :common_tlds, :auto_create_schemas,
|
|
10
|
+
:connection_class, :logger
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@tenant_model_class = "Tenant"
|
|
14
|
+
@default_schema = "public"
|
|
15
|
+
@development_fallback = false
|
|
16
|
+
@excluded_subdomains = %w[www api admin mail ftp blog support help docs]
|
|
17
|
+
@common_tlds = %w[com org net edu gov mil int co uk ca au de fr jp cn]
|
|
18
|
+
@auto_create_schemas = true
|
|
19
|
+
@connection_class = "ActiveRecord::Base"
|
|
20
|
+
@logger = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tenant_model
|
|
24
|
+
@tenant_model ||= tenant_model_class.constantize
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def configuration
|
|
30
|
+
@configuration ||= Configuration.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def configure
|
|
34
|
+
yield(configuration) if block_given?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgMultitenantSchemas
|
|
4
|
+
# Thread-safe tenant context management
|
|
5
|
+
class Context
|
|
6
|
+
class << self
|
|
7
|
+
def current_tenant
|
|
8
|
+
Thread.current[:pg_multitenant_current_tenant]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def current_tenant=(tenant)
|
|
12
|
+
Thread.current[:pg_multitenant_current_tenant] = tenant
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def current_schema
|
|
16
|
+
Thread.current[:pg_multitenant_current_schema] || PgMultitenantSchemas.configuration.default_schema
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def current_schema=(schema_name)
|
|
20
|
+
Thread.current[:pg_multitenant_current_schema] = schema_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset!
|
|
24
|
+
Thread.current[:pg_multitenant_current_tenant] = nil
|
|
25
|
+
Thread.current[:pg_multitenant_current_schema] = nil
|
|
26
|
+
switch_to_schema(PgMultitenantSchemas.configuration.default_schema)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def switch_to_schema(schema_name)
|
|
30
|
+
schema_name = PgMultitenantSchemas.configuration.default_schema if schema_name.blank?
|
|
31
|
+
SchemaSwitcher.switch_schema(schema_name)
|
|
32
|
+
self.current_schema = schema_name
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def switch_to_tenant(tenant)
|
|
36
|
+
if tenant
|
|
37
|
+
schema_name = tenant.respond_to?(:subdomain) ? tenant.subdomain : tenant.to_s
|
|
38
|
+
switch_to_schema(schema_name)
|
|
39
|
+
self.current_tenant = tenant
|
|
40
|
+
else
|
|
41
|
+
switch_to_schema(PgMultitenantSchemas.configuration.default_schema)
|
|
42
|
+
self.current_tenant = nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Execute block within tenant context
|
|
47
|
+
def with_tenant(tenant_or_schema)
|
|
48
|
+
schema_name, tenant = extract_schema_and_tenant(tenant_or_schema)
|
|
49
|
+
previous_tenant = current_tenant
|
|
50
|
+
previous_schema = current_schema
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
switch_to_schema(schema_name)
|
|
54
|
+
self.current_tenant = tenant
|
|
55
|
+
yield if block_given?
|
|
56
|
+
ensure
|
|
57
|
+
restore_previous_context(previous_tenant, previous_schema)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Extract schema name and tenant object from input
|
|
64
|
+
def extract_schema_and_tenant(tenant_or_schema)
|
|
65
|
+
if tenant_or_schema.respond_to?(:subdomain)
|
|
66
|
+
[tenant_or_schema.subdomain, tenant_or_schema]
|
|
67
|
+
else
|
|
68
|
+
[tenant_or_schema.to_s, nil]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Restore the previous tenant context
|
|
73
|
+
def restore_previous_context(previous_tenant, previous_schema)
|
|
74
|
+
restore_schema = if previous_tenant.respond_to?(:subdomain)
|
|
75
|
+
previous_tenant.subdomain
|
|
76
|
+
else
|
|
77
|
+
previous_schema
|
|
78
|
+
end
|
|
79
|
+
switch_to_schema(restore_schema)
|
|
80
|
+
self.current_tenant = previous_tenant
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
public
|
|
84
|
+
|
|
85
|
+
# Create new tenant schema
|
|
86
|
+
def create_tenant_schema(tenant_or_schema)
|
|
87
|
+
schema_name = if tenant_or_schema.respond_to?(:subdomain)
|
|
88
|
+
tenant_or_schema.subdomain
|
|
89
|
+
else
|
|
90
|
+
tenant_or_schema.to_s
|
|
91
|
+
end
|
|
92
|
+
SchemaSwitcher.create_schema(schema_name)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Drop tenant schema
|
|
96
|
+
def drop_tenant_schema(tenant_or_schema, cascade: true)
|
|
97
|
+
schema_name = if tenant_or_schema.respond_to?(:subdomain)
|
|
98
|
+
tenant_or_schema.subdomain
|
|
99
|
+
else
|
|
100
|
+
tenant_or_schema.to_s
|
|
101
|
+
end
|
|
102
|
+
SchemaSwitcher.drop_schema(schema_name, cascade: cascade)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgMultitenantSchemas
|
|
4
|
+
# Exception classes
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class ConnectionError < Error; end
|
|
7
|
+
class SchemaExists < Error; end
|
|
8
|
+
class SchemaNotFound < Error; end
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
class TenantNotFound < Error; end
|
|
11
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgMultitenantSchemas
|
|
4
|
+
module Rails
|
|
5
|
+
# Controller concern for Rails integration
|
|
6
|
+
module ControllerConcern
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Add callback hooks that can be overridden
|
|
11
|
+
before_action :resolve_tenant_context, unless: :skip_tenant_resolution?
|
|
12
|
+
after_action :reset_tenant_context, unless: :skip_tenant_resolution?
|
|
13
|
+
|
|
14
|
+
# Make current tenant available to views
|
|
15
|
+
helper_method :current_tenant, :current_schema if respond_to?(:helper_method)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Instance methods
|
|
19
|
+
def current_tenant
|
|
20
|
+
@current_tenant || PgMultitenantSchemas::Context.current_tenant
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def current_schema
|
|
24
|
+
PgMultitenantSchemas::Context.current_schema
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def switch_to_tenant(tenant)
|
|
28
|
+
@current_tenant = tenant
|
|
29
|
+
PgMultitenantSchemas::Context.switch_to_tenant(tenant)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def resolve_tenant_context
|
|
33
|
+
tenant = PgMultitenantSchemas::TenantResolver.resolve_tenant_with_fallback(request)
|
|
34
|
+
switch_to_tenant(tenant)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
handle_tenant_resolution_error(e)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset_tenant_context
|
|
40
|
+
@current_tenant = nil
|
|
41
|
+
PgMultitenantSchemas::Context.reset!
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
Rails.logger.error "PgMultitenantSchemas: Failed to reset tenant context: #{e.message}"
|
|
44
|
+
# Don't raise - this is cleanup code
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
protected
|
|
48
|
+
|
|
49
|
+
# Override this method in controllers that shouldn't use tenant resolution
|
|
50
|
+
def skip_tenant_resolution?
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Override this method to customize error handling
|
|
55
|
+
def handle_tenant_resolution_error(error)
|
|
56
|
+
Rails.logger.error "PgMultitenantSchemas: Tenant resolution failed: #{error.message}"
|
|
57
|
+
|
|
58
|
+
raise error unless PgMultitenantSchemas.configuration.development_fallback && Rails.env.development?
|
|
59
|
+
|
|
60
|
+
switch_to_tenant(nil) # Fall back to default schema in development
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Class methods
|
|
64
|
+
class_methods do
|
|
65
|
+
# DSL to skip tenant resolution for specific actions
|
|
66
|
+
def skip_tenant_resolution(options = {})
|
|
67
|
+
skip_before_action :resolve_tenant_context, options
|
|
68
|
+
skip_after_action :reset_tenant_context, options
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgMultitenantSchemas
|
|
4
|
+
module Rails
|
|
5
|
+
# Model concern for tenant callbacks
|
|
6
|
+
module ModelConcern
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Add callbacks for schema management
|
|
11
|
+
after_create :create_tenant_schema_callback, if: :should_manage_schema?
|
|
12
|
+
before_destroy :drop_tenant_schema_callback, if: :should_manage_schema?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create_tenant_schema_callback
|
|
16
|
+
return unless valid_tenant_for_schema?
|
|
17
|
+
|
|
18
|
+
handle_schema_creation
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def drop_tenant_schema_callback
|
|
22
|
+
return unless valid_tenant_for_schema?
|
|
23
|
+
|
|
24
|
+
handle_schema_deletion
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
protected
|
|
28
|
+
|
|
29
|
+
# Override this method to control when schema management should happen
|
|
30
|
+
def should_manage_schema?
|
|
31
|
+
PgMultitenantSchemas.configuration.auto_create_schemas &&
|
|
32
|
+
respond_to?(:subdomain) &&
|
|
33
|
+
subdomain.present?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Check if tenant is valid for schema operations
|
|
39
|
+
def valid_tenant_for_schema?
|
|
40
|
+
respond_to?(:subdomain) && subdomain.present?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Handle schema creation with error handling
|
|
44
|
+
def handle_schema_creation
|
|
45
|
+
PgMultitenantSchemas::Context.create_tenant_schema(self)
|
|
46
|
+
log_schema_operation("Created schema for tenant '#{subdomain}'")
|
|
47
|
+
rescue PgMultitenantSchemas::SchemaExists
|
|
48
|
+
log_schema_operation("Schema '#{subdomain}' already exists")
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
handle_schema_error(e, "create")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Handle schema deletion with error handling
|
|
54
|
+
def handle_schema_deletion
|
|
55
|
+
PgMultitenantSchemas::Context.drop_tenant_schema(self)
|
|
56
|
+
log_schema_operation("Dropped schema for tenant '#{subdomain}'")
|
|
57
|
+
rescue PgMultitenantSchemas::SchemaNotFound
|
|
58
|
+
log_schema_operation("Schema '#{subdomain}' not found for deletion")
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
handle_schema_error(e, "drop")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Log schema operations
|
|
64
|
+
def log_schema_operation(message)
|
|
65
|
+
Rails.logger.info "PgMultitenantSchemas: #{message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Handle schema operation errors
|
|
69
|
+
def handle_schema_error(error, operation)
|
|
70
|
+
Rails.logger.error "PgMultitenantSchemas: Failed to #{operation} schema '#{subdomain}': #{error.message}"
|
|
71
|
+
raise error unless development_fallback_enabled?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if development fallback is enabled
|
|
75
|
+
def development_fallback_enabled?
|
|
76
|
+
PgMultitenantSchemas.configuration.development_fallback && Rails.env.development?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class_methods do
|
|
80
|
+
# DSL to disable automatic schema management
|
|
81
|
+
def skip_schema_management
|
|
82
|
+
skip_callback :after_create, :create_tenant_schema_callback
|
|
83
|
+
skip_callback :before_destroy, :drop_tenant_schema_callback
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgMultitenantSchemas
|
|
4
|
+
module Rails
|
|
5
|
+
# Railtie for automatic initialization and integration with Rails applications.
|
|
6
|
+
# Handles schema switcher initialization when ActiveRecord loads.
|
|
7
|
+
class Railtie < ::Rails::Railtie
|
|
8
|
+
initializer "pg_multitenant_schemas.initialize" do |_app|
|
|
9
|
+
# Initialize connection when Rails starts
|
|
10
|
+
ActiveSupport.on_load(:active_record) do
|
|
11
|
+
PgMultitenantSchemas::SchemaSwitcher.initialize_connection
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Add rake tasks
|
|
16
|
+
rake_tasks do
|
|
17
|
+
load File.expand_path("../tasks/pg_multitenant_schemas.rake", __dir__)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Add generators
|
|
21
|
+
generators do
|
|
22
|
+
require_relative "generators/install_generator"
|
|
23
|
+
require_relative "generators/tenant_migration_generator"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgMultitenantSchemas
|
|
4
|
+
# Core schema switching functionality
|
|
5
|
+
class SchemaSwitcher
|
|
6
|
+
class << self
|
|
7
|
+
# Initialize connection management
|
|
8
|
+
def initialize_connection
|
|
9
|
+
# This is called by the Railtie when Rails starts
|
|
10
|
+
# No special initialization needed as we use the configured connection class
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Get the database connection from the configured connection class
|
|
14
|
+
def connection
|
|
15
|
+
connection_class = PgMultitenantSchemas.configuration.connection_class
|
|
16
|
+
if connection_class.is_a?(String)
|
|
17
|
+
Object.const_get(connection_class).connection
|
|
18
|
+
else
|
|
19
|
+
connection_class.connection
|
|
20
|
+
end
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
raise ConnectionError, "Failed to get database connection: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Switches the search_path - supports both APIs for backward compatibility
|
|
26
|
+
def switch_schema(conn_or_schema, schema = nil)
|
|
27
|
+
if conn_or_schema.is_a?(String)
|
|
28
|
+
# New API: switch_schema('schema_name')
|
|
29
|
+
schema = conn_or_schema
|
|
30
|
+
conn = connection
|
|
31
|
+
else
|
|
32
|
+
# Old API: switch_schema(conn, 'schema_name')
|
|
33
|
+
conn = conn_or_schema
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, "Schema name cannot be empty" if schema.nil? || schema.strip.empty?
|
|
37
|
+
|
|
38
|
+
# Use simple quoting for PostgreSQL identifiers
|
|
39
|
+
quoted_schema = "\"#{schema.gsub('"', '""')}\""
|
|
40
|
+
execute_sql(conn, "SET search_path TO #{quoted_schema};")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reset to default schema - supports both APIs
|
|
44
|
+
def reset_schema(conn = nil)
|
|
45
|
+
conn ||= connection
|
|
46
|
+
execute_sql(conn, "SET search_path TO public;")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Create a new schema - supports both APIs
|
|
50
|
+
def create_schema(conn_or_schema, schema_name = nil)
|
|
51
|
+
if conn_or_schema.is_a?(String)
|
|
52
|
+
# New API: create_schema('schema_name')
|
|
53
|
+
schema_name = conn_or_schema
|
|
54
|
+
conn = connection
|
|
55
|
+
else
|
|
56
|
+
# Old API: create_schema(conn, 'schema_name')
|
|
57
|
+
conn = conn_or_schema
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "Schema name cannot be empty" if schema_name.nil? || schema_name.strip.empty?
|
|
61
|
+
|
|
62
|
+
quoted_schema = "\"#{schema_name.gsub('"', '""')}\""
|
|
63
|
+
execute_sql(conn, "CREATE SCHEMA IF NOT EXISTS #{quoted_schema};")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Drop a schema - supports both APIs
|
|
67
|
+
def drop_schema(conn_or_schema, schema_name_or_options = nil, cascade: true)
|
|
68
|
+
if conn_or_schema.is_a?(String)
|
|
69
|
+
# New API: drop_schema('schema_name', cascade: true)
|
|
70
|
+
schema_name = conn_or_schema
|
|
71
|
+
options = schema_name_or_options || {}
|
|
72
|
+
cascade = options.fetch(:cascade, cascade)
|
|
73
|
+
conn = connection
|
|
74
|
+
else
|
|
75
|
+
# Old API: drop_schema(conn, 'schema_name', cascade: true)
|
|
76
|
+
conn = conn_or_schema
|
|
77
|
+
schema_name = schema_name_or_options
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "Schema name cannot be empty" if schema_name.nil? || schema_name.strip.empty?
|
|
81
|
+
|
|
82
|
+
cascade_option = cascade ? "CASCADE" : "RESTRICT"
|
|
83
|
+
quoted_schema = "\"#{schema_name.gsub('"', '""')}\""
|
|
84
|
+
execute_sql(conn, "DROP SCHEMA IF EXISTS #{quoted_schema} #{cascade_option};")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if schema exists - supports both APIs
|
|
88
|
+
def schema_exists?(conn_or_schema, schema_name = nil)
|
|
89
|
+
if conn_or_schema.is_a?(String)
|
|
90
|
+
# New API: schema_exists?('schema_name')
|
|
91
|
+
schema_name = conn_or_schema
|
|
92
|
+
conn = connection
|
|
93
|
+
else
|
|
94
|
+
# Old API: schema_exists?(conn, 'schema_name')
|
|
95
|
+
conn = conn_or_schema
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return false if schema_name.nil? || schema_name.strip.empty?
|
|
99
|
+
|
|
100
|
+
result = execute_sql(conn, <<~SQL)
|
|
101
|
+
SELECT EXISTS(
|
|
102
|
+
SELECT 1 FROM information_schema.schemata#{" "}
|
|
103
|
+
WHERE schema_name = '#{schema_name}'
|
|
104
|
+
) AS schema_exists
|
|
105
|
+
SQL
|
|
106
|
+
get_result_value(result, 0, 0) == "t"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Get current schema - supports both APIs
|
|
110
|
+
def current_schema(conn = nil)
|
|
111
|
+
conn ||= connection
|
|
112
|
+
result = execute_sql(conn, "SELECT current_schema()")
|
|
113
|
+
get_result_value(result, 0, 0)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Execute SQL with Rails 8 compatibility
|
|
119
|
+
def execute_sql(conn, sql)
|
|
120
|
+
if conn.respond_to?(:execute)
|
|
121
|
+
# Rails connection - use execute
|
|
122
|
+
conn.execute(sql)
|
|
123
|
+
else
|
|
124
|
+
# Raw PG connection - use exec
|
|
125
|
+
conn.exec(sql)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get value from result with compatibility for different result types
|
|
130
|
+
def get_result_value(result, row, col)
|
|
131
|
+
if result.respond_to?(:getvalue)
|
|
132
|
+
# PG::Result
|
|
133
|
+
result.getvalue(row, col)
|
|
134
|
+
else
|
|
135
|
+
# ActiveRecord::Result
|
|
136
|
+
result.rows[row][col]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|