pg_multitenant_schemas 0.2.3 → 0.3.6
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 +4 -4
- data/.agent.md +54 -0
- data/.rubocop.yml +7 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +58 -0
- data/DELIVERY_SUMMARY.md +318 -0
- data/GITHUB_ACTIONS_QUICK_START.md +110 -0
- data/GITHUB_ACTIONS_SETUP.md +313 -0
- data/GITHUB_ACTIONS_SUMMARY.md +231 -0
- data/LICENSE.txt +1 -1
- data/LOCAL_TESTING_SUMMARY.md +30 -13
- data/READY_TO_RELEASE.md +308 -0
- data/RELEASE_NOTES.md +194 -0
- data/RELEASE_STEPS.md +122 -0
- data/WORKFLOW_FIXES.md +188 -0
- data/docs/index.html +929 -0
- data/lib/pg_multitenant_schemas/configuration.rb +7 -1
- data/lib/pg_multitenant_schemas/context.rb +92 -3
- data/lib/pg_multitenant_schemas/rails/controller_concern.rb +81 -6
- data/lib/pg_multitenant_schemas/rails/generators/install_generator.rb +27 -0
- data/lib/pg_multitenant_schemas/rails/generators/tenant_migration_generator.rb +30 -0
- data/lib/pg_multitenant_schemas/rails/model_concern.rb +3 -3
- data/lib/pg_multitenant_schemas/rails/railtie.rb +27 -0
- data/lib/pg_multitenant_schemas/schema_switcher.rb +73 -7
- data/lib/pg_multitenant_schemas/tenant_resolver.rb +3 -3
- data/lib/pg_multitenant_schemas/ui/app/assets/stylesheets/pg_multitenant_schemas/ui/application.css +94 -0
- data/lib/pg_multitenant_schemas/ui/app/controllers/pg_multitenant_schemas/ui/tenants_controller.rb +130 -0
- data/lib/pg_multitenant_schemas/ui/app/views/layouts/pg_multitenant_schemas/ui/application.html.erb +29 -0
- data/lib/pg_multitenant_schemas/ui/app/views/pg_multitenant_schemas/ui/tenants/index.html.erb +85 -0
- data/lib/pg_multitenant_schemas/ui/app/views/pg_multitenant_schemas/ui/tenants/migration_status.html.erb +45 -0
- data/lib/pg_multitenant_schemas/ui/app/views/pg_multitenant_schemas/ui/tenants/new.html.erb +50 -0
- data/lib/pg_multitenant_schemas/ui/config/routes.rb +20 -0
- data/lib/pg_multitenant_schemas/ui/engine.rb +49 -0
- data/lib/pg_multitenant_schemas/ui.rb +9 -0
- data/lib/pg_multitenant_schemas/version.rb +1 -1
- data/lib/pg_multitenant_schemas.rb +155 -29
- data/pg_multitenant_schemas.gemspec +15 -9
- data/pre-release-check.sh +46 -0
- metadata +39 -12
|
@@ -12,12 +12,18 @@ module PgMultitenantSchemas
|
|
|
12
12
|
def initialize
|
|
13
13
|
@tenant_model_class = "Tenant"
|
|
14
14
|
@default_schema = "public"
|
|
15
|
+
# Enable development fallback with cookie support by default in development
|
|
15
16
|
@development_fallback = false
|
|
17
|
+
begin
|
|
18
|
+
@development_fallback = ::Rails.env&.development? == true if defined?(::Rails)
|
|
19
|
+
rescue StandardError
|
|
20
|
+
# If Rails is not available, keep default
|
|
21
|
+
end
|
|
16
22
|
@excluded_subdomains = %w[www api admin mail ftp blog support help docs staging]
|
|
17
23
|
@common_tlds = %w[com org net edu gov mil int co uk ca au de fr jp cn dev test]
|
|
18
24
|
@auto_create_schemas = true
|
|
19
25
|
@connection_class = "ApplicationRecord"
|
|
20
|
-
@logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
26
|
+
@logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
def tenant_model
|
|
@@ -2,36 +2,90 @@
|
|
|
2
2
|
|
|
3
3
|
module PgMultitenantSchemas
|
|
4
4
|
# Thread-safe tenant context management
|
|
5
|
+
#
|
|
6
|
+
# The Context class manages the current tenant and schema in a thread-safe manner
|
|
7
|
+
# using Thread.current storage. This ensures proper isolation in multi-threaded
|
|
8
|
+
# environments like Rails servers.
|
|
9
|
+
#
|
|
10
|
+
# @example Get current schema
|
|
11
|
+
# PgMultitenantSchemas::Context.current_schema #=> "tenant_123"
|
|
12
|
+
#
|
|
13
|
+
# @example Switch to tenant context
|
|
14
|
+
# tenant = Tenant.find(1)
|
|
15
|
+
# PgMultitenantSchemas::Context.switch_to_tenant(tenant)
|
|
16
|
+
#
|
|
17
|
+
# @example Use block-based context
|
|
18
|
+
# PgMultitenantSchemas::Context.with_tenant(tenant) do
|
|
19
|
+
# User.all # Queries tenant's schema
|
|
20
|
+
# end
|
|
5
21
|
class Context
|
|
6
22
|
class << self
|
|
23
|
+
# Get the current tenant object
|
|
24
|
+
#
|
|
25
|
+
# @return [Object, nil] The current tenant or nil if no tenant context is set
|
|
7
26
|
def current_tenant
|
|
8
27
|
Thread.current[:pg_multitenant_current_tenant]
|
|
9
28
|
end
|
|
10
29
|
|
|
30
|
+
# Set the current tenant object
|
|
31
|
+
#
|
|
32
|
+
# @param tenant [Object] The tenant object to set as current
|
|
33
|
+
# @return [Object] The tenant object
|
|
11
34
|
def current_tenant=(tenant)
|
|
12
35
|
Thread.current[:pg_multitenant_current_tenant] = tenant
|
|
13
36
|
end
|
|
14
37
|
|
|
38
|
+
# Get the current tenant schema name
|
|
39
|
+
#
|
|
40
|
+
# @return [String] The current schema name or default schema if not set
|
|
41
|
+
# @example
|
|
42
|
+
# PgMultitenantSchemas::Context.current_schema #=> "tenant_123"
|
|
15
43
|
def current_schema
|
|
16
44
|
Thread.current[:pg_multitenant_current_schema] || PgMultitenantSchemas.configuration.default_schema
|
|
17
45
|
end
|
|
18
46
|
|
|
47
|
+
# Set the current tenant schema name
|
|
48
|
+
#
|
|
49
|
+
# @param schema_name [String] The schema name to set as current
|
|
50
|
+
# @return [String] The schema name
|
|
19
51
|
def current_schema=(schema_name)
|
|
20
52
|
Thread.current[:pg_multitenant_current_schema] = schema_name
|
|
21
53
|
end
|
|
22
54
|
|
|
55
|
+
# Reset the current tenant context to default
|
|
56
|
+
#
|
|
57
|
+
# Clears both tenant and schema context and restores the default schema
|
|
58
|
+
#
|
|
59
|
+
# @return [void]
|
|
60
|
+
# @example
|
|
61
|
+
# PgMultitenantSchemas::Context.reset!
|
|
23
62
|
def reset!
|
|
24
63
|
Thread.current[:pg_multitenant_current_tenant] = nil
|
|
25
64
|
Thread.current[:pg_multitenant_current_schema] = nil
|
|
26
65
|
switch_to_schema(PgMultitenantSchemas.configuration.default_schema)
|
|
27
66
|
end
|
|
28
67
|
|
|
68
|
+
# Switch to a specific schema
|
|
69
|
+
#
|
|
70
|
+
# @param schema_name [String] The schema name to switch to
|
|
71
|
+
# @return [void]
|
|
72
|
+
# @example
|
|
73
|
+
# PgMultitenantSchemas::Context.switch_to_schema('tenant_123')
|
|
29
74
|
def switch_to_schema(schema_name)
|
|
30
75
|
schema_name = PgMultitenantSchemas.configuration.default_schema if schema_name.blank?
|
|
31
76
|
SchemaSwitcher.switch_schema(schema_name)
|
|
32
77
|
self.current_schema = schema_name
|
|
33
78
|
end
|
|
34
79
|
|
|
80
|
+
# Switch to a specific tenant's schema
|
|
81
|
+
#
|
|
82
|
+
# @param tenant [Object, String] The tenant object (must respond to :subdomain) or schema name
|
|
83
|
+
# @return [void]
|
|
84
|
+
# @example With tenant object
|
|
85
|
+
# tenant = Tenant.find(1)
|
|
86
|
+
# PgMultitenantSchemas::Context.switch_to_tenant(tenant)
|
|
87
|
+
# @example With schema name
|
|
88
|
+
# PgMultitenantSchemas::Context.switch_to_tenant('tenant_123')
|
|
35
89
|
def switch_to_tenant(tenant)
|
|
36
90
|
if tenant
|
|
37
91
|
schema_name = tenant.respond_to?(:subdomain) ? tenant.subdomain : tenant.to_s
|
|
@@ -43,7 +97,23 @@ module PgMultitenantSchemas
|
|
|
43
97
|
end
|
|
44
98
|
end
|
|
45
99
|
|
|
46
|
-
# Execute block within tenant context
|
|
100
|
+
# Execute a block within a tenant context
|
|
101
|
+
#
|
|
102
|
+
# All database queries within the block will be executed in the tenant's schema.
|
|
103
|
+
# The previous context is restored after the block completes, even if an error occurs.
|
|
104
|
+
#
|
|
105
|
+
# @param tenant_or_schema [Object, String] The tenant object or schema name
|
|
106
|
+
# @yield Executes the given block in the tenant's schema context
|
|
107
|
+
# @return [Object] The return value of the block
|
|
108
|
+
# @example With tenant object
|
|
109
|
+
# tenant = Tenant.find(1)
|
|
110
|
+
# PgMultitenantSchemas::Context.with_tenant(tenant) do
|
|
111
|
+
# User.all # Queries tenant's schema
|
|
112
|
+
# end
|
|
113
|
+
# @example With schema name
|
|
114
|
+
# PgMultitenantSchemas::Context.with_tenant('tenant_123') do
|
|
115
|
+
# Order.create!(status: 'pending')
|
|
116
|
+
# end
|
|
47
117
|
def with_tenant(tenant_or_schema)
|
|
48
118
|
schema_name, tenant = extract_schema_and_tenant(tenant_or_schema)
|
|
49
119
|
previous_tenant = current_tenant
|
|
@@ -82,7 +152,16 @@ module PgMultitenantSchemas
|
|
|
82
152
|
|
|
83
153
|
public
|
|
84
154
|
|
|
85
|
-
# Create new
|
|
155
|
+
# Create a new PostgreSQL schema for a tenant
|
|
156
|
+
#
|
|
157
|
+
# @param tenant_or_schema [Object, String] The tenant object or schema name
|
|
158
|
+
# @return [void]
|
|
159
|
+
# @raise [ArgumentError] if schema name is blank
|
|
160
|
+
# @example With tenant object
|
|
161
|
+
# tenant = Tenant.create!(subdomain: 'acme')
|
|
162
|
+
# PgMultitenantSchemas::Context.create_tenant_schema(tenant)
|
|
163
|
+
# @example With schema name
|
|
164
|
+
# PgMultitenantSchemas::Context.create_tenant_schema('tenant_123')
|
|
86
165
|
def create_tenant_schema(tenant_or_schema)
|
|
87
166
|
schema_name = if tenant_or_schema.respond_to?(:subdomain)
|
|
88
167
|
tenant_or_schema.subdomain
|
|
@@ -92,7 +171,17 @@ module PgMultitenantSchemas
|
|
|
92
171
|
SchemaSwitcher.create_schema(schema_name)
|
|
93
172
|
end
|
|
94
173
|
|
|
95
|
-
# Drop
|
|
174
|
+
# Drop a PostgreSQL schema for a tenant
|
|
175
|
+
#
|
|
176
|
+
# @param tenant_or_schema [Object, String] The tenant object or schema name
|
|
177
|
+
# @param cascade [Boolean] Whether to drop dependent objects (default: true)
|
|
178
|
+
# @return [void]
|
|
179
|
+
# @raise [ArgumentError] if schema name is blank
|
|
180
|
+
# @example With tenant object
|
|
181
|
+
# tenant = Tenant.find(1)
|
|
182
|
+
# PgMultitenantSchemas::Context.drop_tenant_schema(tenant)
|
|
183
|
+
# @example With cascade option
|
|
184
|
+
# PgMultitenantSchemas::Context.drop_tenant_schema('old_tenant', cascade: true)
|
|
96
185
|
def drop_tenant_schema(tenant_or_schema, cascade: true)
|
|
97
186
|
schema_name = if tenant_or_schema.respond_to?(:subdomain)
|
|
98
187
|
tenant_or_schema.subdomain
|
|
@@ -30,18 +30,93 @@ module PgMultitenantSchemas
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def resolve_tenant_context
|
|
33
|
-
tenant
|
|
33
|
+
# Try to resolve tenant from cookie/session first (for development mode)
|
|
34
|
+
tenant = resolve_from_cookie_fallback(request)
|
|
35
|
+
|
|
36
|
+
# If no tenant from cookie/session, use normal resolution
|
|
37
|
+
tenant = PgMultitenantSchemas::TenantResolver.resolve_tenant_with_fallback(request) if tenant.nil?
|
|
38
|
+
|
|
34
39
|
switch_to_tenant(tenant)
|
|
35
40
|
rescue StandardError => e
|
|
36
41
|
handle_tenant_resolution_error(e)
|
|
37
42
|
end
|
|
38
43
|
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Try to resolve tenant from cookie/session-based schema (if available)
|
|
47
|
+
def resolve_from_cookie_fallback(request)
|
|
48
|
+
return nil unless should_use_cookie_fallback?
|
|
49
|
+
|
|
50
|
+
schema = extract_schema_from_request(request)
|
|
51
|
+
return nil unless schema.present? && PgMultitenantSchemas::SchemaSwitcher.schema_exists?(schema)
|
|
52
|
+
|
|
53
|
+
PgMultitenantSchemas::Context.current_schema = schema
|
|
54
|
+
@current_tenant = nil
|
|
55
|
+
VirtualTenant.new(schema)
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
::Rails.logger.debug "PgMultitenantSchemas: resolve_from_cookie_fallback failed: #{e.class} #{e.message}"
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Extract schema from session or cookies
|
|
62
|
+
def extract_schema_from_request(request)
|
|
63
|
+
# Try session first (most reliable)
|
|
64
|
+
if respond_to?(:session) && session.present? && session[:pg_multitenant_selected_schema].present?
|
|
65
|
+
return session[:pg_multitenant_selected_schema]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Try various cookie sources
|
|
69
|
+
extract_schema_from_cookies(request)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Extract schema from multiple cookie sources
|
|
73
|
+
def extract_schema_from_cookies(request)
|
|
74
|
+
if request.respond_to?(:cookie_jar)
|
|
75
|
+
request.cookie_jar[:pg_multitenant_selected_schema]
|
|
76
|
+
elsif request.respond_to?(:cookies)
|
|
77
|
+
request.cookies[:pg_multitenant_selected_schema]
|
|
78
|
+
elsif respond_to?(:cookies)
|
|
79
|
+
cookies[:pg_multitenant_selected_schema]
|
|
80
|
+
else
|
|
81
|
+
extract_schema_from_cookie_header(request)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Parse schema from raw HTTP_COOKIE header
|
|
86
|
+
def extract_schema_from_cookie_header(request)
|
|
87
|
+
cookie_header = request.env["HTTP_COOKIE"]
|
|
88
|
+
return nil unless cookie_header.present?
|
|
89
|
+
|
|
90
|
+
# Parse "key1=value1; key2=value2; ..." format
|
|
91
|
+
cookie_header.split("; ").find do |c|
|
|
92
|
+
c.start_with?("pg_multitenant_selected_schema=")
|
|
93
|
+
end&.split("=")&.last
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Virtual tenant object for cookie-based schema resolution
|
|
97
|
+
class VirtualTenant
|
|
98
|
+
attr_reader :subdomain, :schema
|
|
99
|
+
|
|
100
|
+
def initialize(schema)
|
|
101
|
+
@subdomain = schema
|
|
102
|
+
@schema = schema
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Check if we should try cookie-based resolution
|
|
107
|
+
def should_use_cookie_fallback?
|
|
108
|
+
return false unless PgMultitenantSchemas.configuration.development_fallback
|
|
109
|
+
return false unless ::Rails.env.development?
|
|
110
|
+
|
|
111
|
+
# If we reach here, we should attempt cookie-based resolution
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
39
115
|
def reset_tenant_context
|
|
40
116
|
@current_tenant = nil
|
|
41
117
|
PgMultitenantSchemas::Context.reset!
|
|
42
|
-
rescue StandardError
|
|
43
|
-
|
|
44
|
-
# Don't raise - this is cleanup code
|
|
118
|
+
rescue StandardError
|
|
119
|
+
# Silently ignore errors during context reset
|
|
45
120
|
end
|
|
46
121
|
|
|
47
122
|
protected
|
|
@@ -53,9 +128,9 @@ module PgMultitenantSchemas
|
|
|
53
128
|
|
|
54
129
|
# Override this method to customize error handling
|
|
55
130
|
def handle_tenant_resolution_error(error)
|
|
56
|
-
Rails.logger.error "PgMultitenantSchemas: Tenant resolution failed: #{error.message}"
|
|
131
|
+
::Rails.logger.error "PgMultitenantSchemas: Tenant resolution failed: #{error.message}"
|
|
57
132
|
|
|
58
|
-
raise error unless PgMultitenantSchemas.configuration.development_fallback && Rails.env.development?
|
|
133
|
+
raise error unless PgMultitenantSchemas.configuration.development_fallback && ::Rails.env.development?
|
|
59
134
|
|
|
60
135
|
switch_to_tenant(nil) # Fall back to default schema in development
|
|
61
136
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module PgMultitenantSchemas
|
|
6
|
+
module Generators
|
|
7
|
+
# Generates the initializer used to configure PgMultitenantSchemas in a Rails app.
|
|
8
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates a PgMultitenantSchemas initializer in your Rails app"
|
|
12
|
+
|
|
13
|
+
def create_initializer
|
|
14
|
+
create_file "config/initializers/pg_multitenant_schemas.rb", <<~RUBY
|
|
15
|
+
PgMultitenantSchemas.configure do |config|
|
|
16
|
+
config.connection_class = "ApplicationRecord"
|
|
17
|
+
config.tenant_model_class = "Tenant"
|
|
18
|
+
config.default_schema = "public"
|
|
19
|
+
config.excluded_subdomains = ["www", "api", "admin"]
|
|
20
|
+
config.development_fallback = true
|
|
21
|
+
config.auto_create_schemas = true
|
|
22
|
+
end
|
|
23
|
+
RUBY
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module PgMultitenantSchemas
|
|
7
|
+
module Generators
|
|
8
|
+
# Generates a migration for adding a tenant to a schema.
|
|
9
|
+
class TenantMigrationGenerator < ::Rails::Generators::Base
|
|
10
|
+
include ::Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
desc "Creates a migration to add a tenant to a schema"
|
|
15
|
+
|
|
16
|
+
argument :tenant_name, type: :string, banner: "TENANT_NAME"
|
|
17
|
+
|
|
18
|
+
def self.next_migration_number(dirname)
|
|
19
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_migration
|
|
23
|
+
migration_template(
|
|
24
|
+
"tenant_migration.rb.erb",
|
|
25
|
+
"db/migrate/create_#{tenant_name}_schema.rb"
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -62,18 +62,18 @@ module PgMultitenantSchemas
|
|
|
62
62
|
|
|
63
63
|
# Log schema operations
|
|
64
64
|
def log_schema_operation(message)
|
|
65
|
-
Rails.logger.info "PgMultitenantSchemas: #{message}"
|
|
65
|
+
::Rails.logger.info "PgMultitenantSchemas: #{message}"
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# Handle schema operation errors
|
|
69
69
|
def handle_schema_error(error, operation)
|
|
70
|
-
Rails.logger.error "PgMultitenantSchemas: Failed to #{operation} schema '#{subdomain}': #{error.message}"
|
|
70
|
+
::Rails.logger.error "PgMultitenantSchemas: Failed to #{operation} schema '#{subdomain}': #{error.message}"
|
|
71
71
|
raise error unless development_fallback_enabled?
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
# Check if development fallback is enabled
|
|
75
75
|
def development_fallback_enabled?
|
|
76
|
-
PgMultitenantSchemas.configuration.development_fallback && Rails.env.development?
|
|
76
|
+
PgMultitenantSchemas.configuration.development_fallback && ::Rails.env.development?
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
class_methods do
|
|
@@ -12,6 +12,33 @@ module PgMultitenantSchemas
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
initializer "pg_multitenant_schemas.middleware" do |app|
|
|
16
|
+
# Add cookies middleware for API-only apps in development
|
|
17
|
+
# Needed for cookie-based schema switching between dashboard and main app
|
|
18
|
+
if ::Rails.env.development?
|
|
19
|
+
# Insert cookies middleware at the beginning
|
|
20
|
+
app.config.middleware.insert_before 0, ActionDispatch::Cookies
|
|
21
|
+
# Session store for reading cookies properly
|
|
22
|
+
app.config.middleware.insert_before 1, ActionDispatch::Session::CookieStore,
|
|
23
|
+
key: "_pg_multitenant_session"
|
|
24
|
+
# Flash support for notices/alerts
|
|
25
|
+
app.config.middleware.insert_before 2, ActionDispatch::Flash
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
initializer "pg_multitenant_schemas.controller_integration" do
|
|
30
|
+
# Automatically extend ApplicationController with ControllerConcern
|
|
31
|
+
ActiveSupport.on_load(:action_controller) do
|
|
32
|
+
include PgMultitenantSchemas::Rails::ControllerConcern
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
initializer "pg_multitenant_schemas.ui.routes" do |app|
|
|
37
|
+
app.routes.append do
|
|
38
|
+
mount PgMultitenantSchemas::UI::Engine, at: "/pg_multitenant_schemas"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
15
42
|
# Add rake tasks
|
|
16
43
|
rake_tasks do
|
|
17
44
|
# Load all task files
|
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PgMultitenantSchemas
|
|
4
|
-
#
|
|
4
|
+
# Low-level PostgreSQL schema switching and management
|
|
5
|
+
#
|
|
6
|
+
# The SchemaSwitcher class provides direct access to PostgreSQL schema operations.
|
|
7
|
+
# It handles both Rails and raw PG connections transparently.
|
|
8
|
+
#
|
|
9
|
+
# @example Switching schemas
|
|
10
|
+
# PgMultitenantSchemas::SchemaSwitcher.switch_schema('tenant_123')
|
|
11
|
+
#
|
|
12
|
+
# @example Creating a schema
|
|
13
|
+
# PgMultitenantSchemas::SchemaSwitcher.create_schema('new_tenant')
|
|
14
|
+
#
|
|
15
|
+
# @example Listing all schemas
|
|
16
|
+
# PgMultitenantSchemas::SchemaSwitcher.list_schemas
|
|
17
|
+
# #=> ["public", "tenant_123", "tenant_456"]
|
|
5
18
|
class SchemaSwitcher
|
|
6
19
|
class << self
|
|
7
20
|
# Initialize connection management
|
|
21
|
+
#
|
|
22
|
+
# Called by the Railtie when Rails starts. No special initialization needed.
|
|
23
|
+
#
|
|
24
|
+
# @return [void]
|
|
8
25
|
def initialize_connection
|
|
9
26
|
# This is called by the Railtie when Rails starts
|
|
10
27
|
# No special initialization needed as we use the configured connection class
|
|
11
28
|
end
|
|
12
29
|
|
|
13
30
|
# Get the database connection from the configured connection class
|
|
31
|
+
#
|
|
32
|
+
# @return [ActiveRecord::ConnectionAdapters::AbstractAdapter, PG::Connection]
|
|
33
|
+
# @raise [PgMultitenantSchemas::ConnectionError] if connection cannot be established
|
|
14
34
|
def connection
|
|
15
35
|
connection_class = PgMultitenantSchemas.configuration.connection_class
|
|
16
36
|
if connection_class.is_a?(String)
|
|
@@ -22,7 +42,15 @@ module PgMultitenantSchemas
|
|
|
22
42
|
raise ConnectionError, "Failed to get database connection: #{e.message}"
|
|
23
43
|
end
|
|
24
44
|
|
|
25
|
-
#
|
|
45
|
+
# Switch the current PostgreSQL schema
|
|
46
|
+
#
|
|
47
|
+
# Sets the search_path to the specified schema using SET search_path.
|
|
48
|
+
#
|
|
49
|
+
# @param schema_name [String] The schema name to switch to
|
|
50
|
+
# @return [void]
|
|
51
|
+
# @raise [ArgumentError] if schema name is blank
|
|
52
|
+
# @example
|
|
53
|
+
# PgMultitenantSchemas::SchemaSwitcher.switch_schema('tenant_123')
|
|
26
54
|
def switch_schema(schema_name)
|
|
27
55
|
raise ArgumentError, "Schema name cannot be empty" if schema_name.nil? || schema_name.strip.empty?
|
|
28
56
|
|
|
@@ -31,13 +59,25 @@ module PgMultitenantSchemas
|
|
|
31
59
|
execute_sql(conn, "SET search_path TO #{quoted_schema};")
|
|
32
60
|
end
|
|
33
61
|
|
|
34
|
-
# Reset to default schema
|
|
62
|
+
# Reset to the default PostgreSQL schema
|
|
63
|
+
#
|
|
64
|
+
# Sets search_path back to 'public'.
|
|
65
|
+
#
|
|
66
|
+
# @return [void]
|
|
35
67
|
def reset_schema
|
|
36
68
|
conn = connection
|
|
37
69
|
execute_sql(conn, "SET search_path TO public;")
|
|
38
70
|
end
|
|
39
71
|
|
|
40
|
-
# Create a new schema
|
|
72
|
+
# Create a new PostgreSQL schema
|
|
73
|
+
#
|
|
74
|
+
# Uses CREATE SCHEMA IF NOT EXISTS for idempotency.
|
|
75
|
+
#
|
|
76
|
+
# @param schema_name [String] The schema name to create
|
|
77
|
+
# @return [void]
|
|
78
|
+
# @raise [ArgumentError] if schema name is blank
|
|
79
|
+
# @example
|
|
80
|
+
# PgMultitenantSchemas::SchemaSwitcher.create_schema('tenant_123')
|
|
41
81
|
def create_schema(schema_name)
|
|
42
82
|
raise ArgumentError, "Schema name cannot be empty" if schema_name.nil? || schema_name.strip.empty?
|
|
43
83
|
|
|
@@ -46,7 +86,16 @@ module PgMultitenantSchemas
|
|
|
46
86
|
execute_sql(conn, "CREATE SCHEMA IF NOT EXISTS #{quoted_schema};")
|
|
47
87
|
end
|
|
48
88
|
|
|
49
|
-
# Drop a schema
|
|
89
|
+
# Drop a PostgreSQL schema
|
|
90
|
+
#
|
|
91
|
+
# @param schema_name [String] The schema name to drop
|
|
92
|
+
# @param cascade [Boolean] Whether to drop dependent objects (default: true)
|
|
93
|
+
# @return [void]
|
|
94
|
+
# @raise [ArgumentError] if schema name is blank
|
|
95
|
+
# @example
|
|
96
|
+
# PgMultitenantSchemas::SchemaSwitcher.drop_schema('old_tenant')
|
|
97
|
+
# @example With CASCADE option
|
|
98
|
+
# PgMultitenantSchemas::SchemaSwitcher.drop_schema('old_tenant', cascade: true)
|
|
50
99
|
def drop_schema(schema_name, cascade: true)
|
|
51
100
|
raise ArgumentError, "Schema name cannot be empty" if schema_name.nil? || schema_name.strip.empty?
|
|
52
101
|
|
|
@@ -56,7 +105,14 @@ module PgMultitenantSchemas
|
|
|
56
105
|
execute_sql(conn, "DROP SCHEMA IF EXISTS #{quoted_schema} #{cascade_option};")
|
|
57
106
|
end
|
|
58
107
|
|
|
59
|
-
# Check if schema exists
|
|
108
|
+
# Check if a schema exists in the database
|
|
109
|
+
#
|
|
110
|
+
# @param schema_name [String] The schema name to check
|
|
111
|
+
# @return [Boolean] true if the schema exists, false otherwise
|
|
112
|
+
# @example
|
|
113
|
+
# if PgMultitenantSchemas::SchemaSwitcher.schema_exists?('tenant_123')
|
|
114
|
+
# # Schema exists
|
|
115
|
+
# end
|
|
60
116
|
def schema_exists?(schema_name)
|
|
61
117
|
return false if schema_name.nil? || schema_name.strip.empty?
|
|
62
118
|
|
|
@@ -73,7 +129,12 @@ module PgMultitenantSchemas
|
|
|
73
129
|
[true, "t", "true"].include?(value)
|
|
74
130
|
end
|
|
75
131
|
|
|
76
|
-
# Get current schema
|
|
132
|
+
# Get the current PostgreSQL schema
|
|
133
|
+
#
|
|
134
|
+
# @return [String] The current schema name
|
|
135
|
+
# @example
|
|
136
|
+
# PgMultitenantSchemas::SchemaSwitcher.current_schema
|
|
137
|
+
# #=> "tenant_123"
|
|
77
138
|
def current_schema
|
|
78
139
|
conn = connection
|
|
79
140
|
result = execute_sql(conn, "SELECT current_schema()")
|
|
@@ -81,6 +142,11 @@ module PgMultitenantSchemas
|
|
|
81
142
|
end
|
|
82
143
|
|
|
83
144
|
# List all schemas in the database
|
|
145
|
+
#
|
|
146
|
+
# @return [Array<String>] Array of schema names
|
|
147
|
+
# @example
|
|
148
|
+
# PgMultitenantSchemas::SchemaSwitcher.list_schemas
|
|
149
|
+
# #=> ["public", "tenant_123", "tenant_456"]
|
|
84
150
|
def list_schemas
|
|
85
151
|
conn = connection
|
|
86
152
|
result = execute_sql(conn, <<~SQL)
|
|
@@ -63,7 +63,7 @@ module PgMultitenantSchemas
|
|
|
63
63
|
tenant_model.find_by(subdomain: subdomain, status: "active")
|
|
64
64
|
end
|
|
65
65
|
rescue StandardError => e
|
|
66
|
-
Rails.logger.error "PgMultitenantSchemas: Error finding tenant '#{subdomain}': #{e.message}"
|
|
66
|
+
::Rails.logger.error "PgMultitenantSchemas: Error finding tenant '#{subdomain}': #{e.message}"
|
|
67
67
|
nil
|
|
68
68
|
end
|
|
69
69
|
|
|
@@ -80,8 +80,8 @@ module PgMultitenantSchemas
|
|
|
80
80
|
tenant = resolve_tenant_from_request(request)
|
|
81
81
|
|
|
82
82
|
# If no tenant found and development fallback is enabled
|
|
83
|
-
if tenant.nil? && PgMultitenantSchemas.configuration.development_fallback && Rails.env.development?
|
|
84
|
-
Rails.logger.info "PgMultitenantSchemas: No tenant found, using development fallback"
|
|
83
|
+
if tenant.nil? && PgMultitenantSchemas.configuration.development_fallback && ::Rails.env.development?
|
|
84
|
+
::Rails.logger.info "PgMultitenantSchemas: No tenant found, using development fallback"
|
|
85
85
|
return nil # This will cause switch to default schema
|
|
86
86
|
end
|
|
87
87
|
|
data/lib/pg_multitenant_schemas/ui/app/assets/stylesheets/pg_multitenant_schemas/ui/application.css
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2
|
+
|
|
3
|
+
body {
|
|
4
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
5
|
+
background: #f5f5f5;
|
|
6
|
+
color: #1a1a1a;
|
|
7
|
+
min-height: 100vh;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
header {
|
|
11
|
+
background: #1e293b;
|
|
12
|
+
color: #fff;
|
|
13
|
+
padding: 0 1.5rem;
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
gap: 1rem;
|
|
17
|
+
height: 56px;
|
|
18
|
+
box-shadow: 0 1px 4px rgba(0,0,0,.3);
|
|
19
|
+
}
|
|
20
|
+
header h1 { font-size: 1rem; font-weight: 600; letter-spacing: .03em; }
|
|
21
|
+
header span { font-size: .75rem; color: #94a3b8; }
|
|
22
|
+
|
|
23
|
+
main { max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
|
|
24
|
+
|
|
25
|
+
.flash { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1.25rem; font-size: .875rem; }
|
|
26
|
+
.flash.notice { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
|
|
27
|
+
.flash.alert { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
|
|
28
|
+
|
|
29
|
+
.card { background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
|
|
30
|
+
.card-header {
|
|
31
|
+
padding: 1rem 1.25rem;
|
|
32
|
+
border-bottom: 1px solid #e5e7eb;
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
}
|
|
37
|
+
.card-header h2 { font-size: 1rem; font-weight: 600; }
|
|
38
|
+
|
|
39
|
+
table { width: 100%; border-collapse: collapse; font-size: .875rem; }
|
|
40
|
+
th, td { padding: .65rem 1.25rem; text-align: left; }
|
|
41
|
+
th { font-weight: 600; color: #6b7280; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
|
|
42
|
+
tr:not(:last-child) td { border-bottom: 1px solid #f3f4f6; }
|
|
43
|
+
tr:hover td { background: #fafafa; }
|
|
44
|
+
|
|
45
|
+
.badge {
|
|
46
|
+
display: inline-flex; align-items: center; gap: .3rem;
|
|
47
|
+
padding: .2rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 600;
|
|
48
|
+
}
|
|
49
|
+
.badge-green { background: #dcfce7; color: #166534; }
|
|
50
|
+
.badge-yellow { background: #fef9c3; color: #854d0e; }
|
|
51
|
+
.badge-red { background: #fee2e2; color: #991b1b; }
|
|
52
|
+
.badge-gray { background: #f3f4f6; color: #6b7280; }
|
|
53
|
+
|
|
54
|
+
.btn {
|
|
55
|
+
display: inline-flex; align-items: center; gap: .35rem;
|
|
56
|
+
padding: .45rem .9rem; border-radius: 6px; font-size: .8rem; font-weight: 500;
|
|
57
|
+
text-decoration: none; border: none; cursor: pointer; transition: filter .15s;
|
|
58
|
+
}
|
|
59
|
+
.btn:hover { filter: brightness(.92); }
|
|
60
|
+
.btn-primary { background: #3b82f6; color: #fff; }
|
|
61
|
+
.btn-green { background: #22c55e; color: #fff; }
|
|
62
|
+
.btn-yellow { background: #f59e0b; color: #fff; }
|
|
63
|
+
.btn-red { background: #ef4444; color: #fff; }
|
|
64
|
+
.btn-ghost { background: #f3f4f6; color: #374151; }
|
|
65
|
+
.btn-sm { padding: .3rem .65rem; font-size: .75rem; }
|
|
66
|
+
|
|
67
|
+
.actions { display: flex; gap: .4rem; flex-wrap: wrap; }
|
|
68
|
+
.toolbar { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
|
|
69
|
+
|
|
70
|
+
.schema-badge {
|
|
71
|
+
font-family: monospace; font-size: .78rem;
|
|
72
|
+
background: #eff6ff; color: #1d4ed8;
|
|
73
|
+
padding: .15rem .45rem; border-radius: 4px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
form.inline { display: inline; }
|
|
77
|
+
|
|
78
|
+
/* switch bar */
|
|
79
|
+
.switch-bar {
|
|
80
|
+
margin-bottom: 1.5rem;
|
|
81
|
+
background: #fff;
|
|
82
|
+
border: 1px solid #e5e7eb;
|
|
83
|
+
border-radius: 8px;
|
|
84
|
+
padding: .75rem 1.25rem;
|
|
85
|
+
display: flex; align-items: center; gap: .75rem; font-size: .875rem; flex-wrap: wrap;
|
|
86
|
+
}
|
|
87
|
+
.switch-bar label { font-weight: 500; color: #374151; }
|
|
88
|
+
.switch-bar select {
|
|
89
|
+
flex: 1; min-width: 160px; max-width: 280px;
|
|
90
|
+
border: 1px solid #d1d5db; border-radius: 6px;
|
|
91
|
+
padding: .35rem .6rem; font-size: .875rem;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.empty { padding: 2.5rem; text-align: center; color: #9ca3af; font-size: .9rem; }
|