pg_multitenant_schemas 0.2.2 → 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 -2
- 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/README.md +4 -4
- 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/docs/testing_rails_tasks.md +128 -0
- data/lib/pg_multitenant_schemas/configuration.rb +7 -1
- data/lib/pg_multitenant_schemas/context.rb +92 -3
- data/lib/pg_multitenant_schemas/migration_schema_operations.rb +99 -5
- data/lib/pg_multitenant_schemas/migrator.rb +66 -9
- 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 +31 -1
- data/lib/pg_multitenant_schemas/schema_switcher.rb +107 -7
- data/lib/pg_multitenant_schemas/tasks/tenant_tasks.rake +0 -3
- 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 +18 -12
- data/pre-release-check.sh +46 -0
- metadata +44 -17
- data/lib/pg_multitenant_schemas/tasks/pg_multitenant_schemas.rake +0 -65
|
@@ -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,9 +12,39 @@ 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
|
|
45
|
+
Dir[File.expand_path("../tasks/*.rake", __dir__)].each do |task_file|
|
|
46
|
+
load task_file
|
|
47
|
+
end
|
|
18
48
|
end
|
|
19
49
|
|
|
20
50
|
# Add generators
|
|
@@ -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,15 +129,59 @@ 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()")
|
|
80
141
|
get_result_value(result, 0, 0)
|
|
81
142
|
end
|
|
82
143
|
|
|
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"]
|
|
150
|
+
def list_schemas
|
|
151
|
+
conn = connection
|
|
152
|
+
result = execute_sql(conn, <<~SQL)
|
|
153
|
+
SELECT schema_name FROM information_schema.schemata#{" "}
|
|
154
|
+
ORDER BY schema_name
|
|
155
|
+
SQL
|
|
156
|
+
|
|
157
|
+
extract_schemas_from_result(result)
|
|
158
|
+
end
|
|
159
|
+
|
|
83
160
|
private
|
|
84
161
|
|
|
162
|
+
def extract_schemas_from_result(result)
|
|
163
|
+
schemas = []
|
|
164
|
+
|
|
165
|
+
if result.respond_to?(:rows)
|
|
166
|
+
# Rails ActiveRecord::Result
|
|
167
|
+
result.rows.each { |row| schemas << row[0] }
|
|
168
|
+
elsif result.respond_to?(:each)
|
|
169
|
+
# Raw PG::Result
|
|
170
|
+
result.each { |row| schemas << row["schema_name"] }
|
|
171
|
+
else
|
|
172
|
+
# Fallback for other result types
|
|
173
|
+
extract_schemas_fallback(result, schemas)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
schemas
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def extract_schemas_fallback(result, schemas)
|
|
180
|
+
(0...result.ntuples).each do |i|
|
|
181
|
+
schemas << get_result_value(result, i, 0)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
85
185
|
# Execute SQL - handles both Rails connections and raw PG connections
|
|
86
186
|
def execute_sql(conn, sql)
|
|
87
187
|
if conn.respond_to?(:execute)
|
|
@@ -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; }
|
data/lib/pg_multitenant_schemas/ui/app/controllers/pg_multitenant_schemas/ui/tenants_controller.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgMultitenantSchemas
|
|
4
|
+
module UI
|
|
5
|
+
# Controller backing the UI engine for listing and managing tenants and schemas.
|
|
6
|
+
class TenantsController < ActionController::Base
|
|
7
|
+
layout "pg_multitenant_schemas/ui/application"
|
|
8
|
+
skip_forgery_protection only: :destroy
|
|
9
|
+
|
|
10
|
+
before_action :load_tenant_model
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
@tenants = tenant_model.all.order(:subdomain)
|
|
14
|
+
@schemas = load_available_schemas
|
|
15
|
+
@current_schema = resolve_selected_schema
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def new
|
|
19
|
+
@tenant = tenant_model.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create
|
|
23
|
+
@tenant = tenant_model.new(tenant_params)
|
|
24
|
+
|
|
25
|
+
if @tenant.save
|
|
26
|
+
PgMultitenantSchemas::Migrator.setup_tenant(@tenant.subdomain, verbose: false)
|
|
27
|
+
redirect_to "/pg_multitenant_schemas/tenants",
|
|
28
|
+
notice: "Tenant '#{@tenant.subdomain}' created and schema provisioned."
|
|
29
|
+
else
|
|
30
|
+
render :new, status: :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
@tenant&.destroy if @tenant&.persisted?
|
|
34
|
+
redirect_to "/pg_multitenant_schemas/tenants",
|
|
35
|
+
alert: "Failed to provision schema: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def destroy
|
|
39
|
+
@tenant = tenant_model.find(params[:id])
|
|
40
|
+
schema = @tenant.subdomain
|
|
41
|
+
|
|
42
|
+
@tenant.destroy
|
|
43
|
+
PgMultitenantSchemas::SchemaSwitcher.drop_schema(schema)
|
|
44
|
+
redirect_to "/pg_multitenant_schemas/tenants",
|
|
45
|
+
notice: "Tenant '#{schema}' and its schema deleted."
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
redirect_to "/pg_multitenant_schemas/tenants",
|
|
48
|
+
alert: "Error deleting tenant: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def migrate
|
|
52
|
+
@tenant = tenant_model.find(params[:id])
|
|
53
|
+
result = PgMultitenantSchemas::Migrator.migrate_tenant(@tenant.subdomain, verbose: false)
|
|
54
|
+
status = result[:status] == :success ? "notice" : "alert"
|
|
55
|
+
msg = result[:message] || "Migration completed."
|
|
56
|
+
redirect_to "/pg_multitenant_schemas/tenants", status => msg
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
redirect_to "/pg_multitenant_schemas/tenants", alert: "Migration failed: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def migrate_all
|
|
62
|
+
results = PgMultitenantSchemas::Migrator.migrate_all(verbose: false)
|
|
63
|
+
failed = results.count { |r| r[:status] == :error }
|
|
64
|
+
if failed.zero?
|
|
65
|
+
redirect_to "/pg_multitenant_schemas/tenants",
|
|
66
|
+
notice: "All #{results.size} tenant schemas migrated successfully."
|
|
67
|
+
else
|
|
68
|
+
redirect_to "/pg_multitenant_schemas/tenants",
|
|
69
|
+
alert: "#{failed} of #{results.size} migrations failed."
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def migration_status
|
|
74
|
+
@tenant = tenant_model.find(params[:id])
|
|
75
|
+
statuses = PgMultitenantSchemas::Migrator.migration_status(verbose: false)
|
|
76
|
+
@status = statuses.find { |s| s[:schema] == @tenant.subdomain } || {}
|
|
77
|
+
render :migration_status
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def switch
|
|
81
|
+
schema = params[:schema].to_s.strip
|
|
82
|
+
return redirect_invalid_schema(schema) unless PgMultitenantSchemas::SchemaSwitcher.schema_exists?(schema)
|
|
83
|
+
|
|
84
|
+
PgMultitenantSchemas::Context.current_schema = schema
|
|
85
|
+
store_schema_in_session_and_cookies(schema)
|
|
86
|
+
redirect_to "/pg_multitenant_schemas/tenants",
|
|
87
|
+
notice: "Switched active context to schema '#{schema}'."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def load_available_schemas
|
|
93
|
+
PgMultitenantSchemas::SchemaSwitcher.list_schemas
|
|
94
|
+
rescue StandardError
|
|
95
|
+
[]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def resolve_selected_schema
|
|
99
|
+
selected = (session[:pg_multitenant_selected_schema] || cookies[:pg_multitenant_selected_schema]).to_s.strip
|
|
100
|
+
return selected if selected.present? && load_available_schemas.include?(selected)
|
|
101
|
+
|
|
102
|
+
PgMultitenantSchemas::Context.current_schema
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def store_schema_in_session_and_cookies(schema)
|
|
106
|
+
session[:pg_multitenant_selected_schema] = schema
|
|
107
|
+
cookies[:pg_multitenant_selected_schema] = {
|
|
108
|
+
value: schema,
|
|
109
|
+
expires: 24.hours.from_now,
|
|
110
|
+
path: "/",
|
|
111
|
+
same_site: :lax,
|
|
112
|
+
http_only: false
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def redirect_invalid_schema(schema)
|
|
117
|
+
redirect_to "/pg_multitenant_schemas/tenants",
|
|
118
|
+
alert: "Schema '#{schema}' does not exist."
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def load_tenant_model
|
|
122
|
+
@tenant_model_name = PgMultitenantSchemas.configuration.tenant_model_class
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def tenant_model = @tenant_model_name.constantize
|
|
126
|
+
|
|
127
|
+
def tenant_params = params.require(:tenant).permit(:subdomain, :name, :status)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/pg_multitenant_schemas/ui/app/views/layouts/pg_multitenant_schemas/ui/application.html.erb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Tenant Manager · PgMultitenantSchemas</title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<style>
|
|
9
|
+
<%= File.read(File.expand_path("../../../../assets/stylesheets/pg_multitenant_schemas/ui/application.css", __dir__)).html_safe %>
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<header>
|
|
14
|
+
<h1>🏢 Tenant Manager</h1>
|
|
15
|
+
<span>pg_multitenant_schemas</span>
|
|
16
|
+
</header>
|
|
17
|
+
|
|
18
|
+
<main>
|
|
19
|
+
<% if flash[:notice] %>
|
|
20
|
+
<div class="flash notice"><%= flash[:notice] %></div>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% if flash[:alert] %>
|
|
23
|
+
<div class="flash alert"><%= flash[:alert] %></div>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<%= yield %>
|
|
27
|
+
</main>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<%# Switch active schema context %>
|
|
2
|
+
<div class="switch-bar">
|
|
3
|
+
<label>Active schema:</label>
|
|
4
|
+
<span class="schema-badge" id="current-schema-badge"><%= @current_schema %></span>
|
|
5
|
+
|
|
6
|
+
<%= form_with url: "/pg_multitenant_schemas/tenants/switch", method: :post, local: true do |f| %>
|
|
7
|
+
<%= f.select :schema,
|
|
8
|
+
options_for_select(@schemas, @current_schema),
|
|
9
|
+
{ include_blank: false },
|
|
10
|
+
class: "",
|
|
11
|
+
id: "schema-select" %>
|
|
12
|
+
<%= f.submit "Switch →", class: "btn btn-ghost btn-sm" %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<script>
|
|
17
|
+
// Update badge when selection changes
|
|
18
|
+
document.getElementById('schema-select')?.addEventListener('change', function(e) {
|
|
19
|
+
document.getElementById('current-schema-badge').textContent = e.target.value;
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<%# Toolbar %>
|
|
24
|
+
<div class="toolbar" style="margin-bottom:1rem;">
|
|
25
|
+
<%= link_to "+ New Tenant", "/pg_multitenant_schemas/tenants/new", class: "btn btn-primary" %>
|
|
26
|
+
|
|
27
|
+
<%= form_with url: "/pg_multitenant_schemas/tenants/migrate_all", method: :post, local: true do |f| %>
|
|
28
|
+
<%= f.submit "⚡ Migrate All", class: "btn btn-yellow",
|
|
29
|
+
data: { confirm: "Run pending migrations on all tenant schemas?" } %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<%# Tenant table %>
|
|
34
|
+
<div class="card">
|
|
35
|
+
<div class="card-header">
|
|
36
|
+
<h2>Tenants <span style="font-weight:400;color:#9ca3af;">(<%= @tenants.count %>)</span></h2>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<% if @tenants.empty? %>
|
|
40
|
+
<div class="empty">No tenants yet. Create your first one above.</div>
|
|
41
|
+
<% else %>
|
|
42
|
+
<table>
|
|
43
|
+
<thead>
|
|
44
|
+
<tr>
|
|
45
|
+
<th>Subdomain</th>
|
|
46
|
+
<th>Name</th>
|
|
47
|
+
<th>Status</th>
|
|
48
|
+
<th>Actions</th>
|
|
49
|
+
</tr>
|
|
50
|
+
</thead>
|
|
51
|
+
<tbody>
|
|
52
|
+
<% @tenants.each do |tenant| %>
|
|
53
|
+
<tr>
|
|
54
|
+
<td><span class="schema-badge"><%= tenant.subdomain %></span></td>
|
|
55
|
+
<td><%= tenant.try(:name) || "—" %></td>
|
|
56
|
+
<td>
|
|
57
|
+
<% status = tenant.try(:status).to_s %>
|
|
58
|
+
<% badge_class = { "active" => "badge-green", "trial" => "badge-yellow",
|
|
59
|
+
"inactive" => "badge-gray", "suspended" => "badge-red" }[status] || "badge-gray" %>
|
|
60
|
+
<span class="badge <%= badge_class %>"><%= status.presence || "—" %></span>
|
|
61
|
+
</td>
|
|
62
|
+
<td>
|
|
63
|
+
<div class="actions">
|
|
64
|
+
<%= link_to "📊 Status",
|
|
65
|
+
"/pg_multitenant_schemas/tenants/#{tenant.id}/migration_status",
|
|
66
|
+
class: "btn btn-ghost btn-sm" %>
|
|
67
|
+
|
|
68
|
+
<%= form_with url: "/pg_multitenant_schemas/tenants/#{tenant.id}/migrate",
|
|
69
|
+
method: :post, local: true, style: "display:inline" do |f| %>
|
|
70
|
+
<%= f.submit "▶ Migrate", class: "btn btn-green btn-sm" %>
|
|
71
|
+
<% end %>
|
|
72
|
+
|
|
73
|
+
<%= form_with url: "/pg_multitenant_schemas/tenants/#{tenant.id}",
|
|
74
|
+
method: :delete, local: true, style: "display:inline" do |f| %>
|
|
75
|
+
<%= f.submit "✕ Delete", class: "btn btn-red btn-sm",
|
|
76
|
+
data: { confirm: "Delete tenant '#{tenant.subdomain}' and drop its schema? This is irreversible." } %>
|
|
77
|
+
<% end %>
|
|
78
|
+
</div>
|
|
79
|
+
</td>
|
|
80
|
+
</tr>
|
|
81
|
+
<% end %>
|
|
82
|
+
</tbody>
|
|
83
|
+
</table>
|
|
84
|
+
<% end %>
|
|
85
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%= link_to "← Back to Tenants", "/pg_multitenant_schemas/tenants", class: "btn btn-ghost btn-sm", style: "margin-bottom:1rem;display:inline-flex;" %>
|
|
2
|
+
|
|
3
|
+
<div class="card" style="max-width:560px;">
|
|
4
|
+
<div class="card-header">
|
|
5
|
+
<h2>Migration Status — <span class="schema-badge"><%= @tenant.subdomain %></span></h2>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div style="padding:1.25rem;display:flex;flex-direction:column;gap:1rem;">
|
|
9
|
+
<% if @status[:error] %>
|
|
10
|
+
<div class="flash alert">Error: <%= @status[:error] %></div>
|
|
11
|
+
<% else %>
|
|
12
|
+
<table>
|
|
13
|
+
<tbody>
|
|
14
|
+
<tr>
|
|
15
|
+
<td style="color:#6b7280;font-size:.8rem;font-weight:600;text-transform:uppercase;">Status</td>
|
|
16
|
+
<td>
|
|
17
|
+
<% if @status[:status] == :up_to_date %>
|
|
18
|
+
<span class="badge badge-green">✓ Up to date</span>
|
|
19
|
+
<% elsif @status[:status] == :pending %>
|
|
20
|
+
<span class="badge badge-yellow">⚠ Pending</span>
|
|
21
|
+
<% else %>
|
|
22
|
+
<span class="badge badge-gray"><%= @status[:status] %></span>
|
|
23
|
+
<% end %>
|
|
24
|
+
</td>
|
|
25
|
+
</tr>
|
|
26
|
+
<tr>
|
|
27
|
+
<td style="color:#6b7280;font-size:.8rem;font-weight:600;text-transform:uppercase;">Applied</td>
|
|
28
|
+
<td><strong><%= @status[:applied_count] || 0 %></strong> migrations</td>
|
|
29
|
+
</tr>
|
|
30
|
+
<tr>
|
|
31
|
+
<td style="color:#6b7280;font-size:.8rem;font-weight:600;text-transform:uppercase;">Pending</td>
|
|
32
|
+
<td><strong><%= @status[:pending_count] || 0 %></strong> migrations</td>
|
|
33
|
+
</tr>
|
|
34
|
+
</tbody>
|
|
35
|
+
</table>
|
|
36
|
+
|
|
37
|
+
<% if @status[:status] == :pending %>
|
|
38
|
+
<%= form_with url: "/pg_multitenant_schemas/tenants/#{@tenant.id}/migrate",
|
|
39
|
+
method: :post, local: true do |f| %>
|
|
40
|
+
<%= f.submit "▶ Run Migrations Now", class: "btn btn-green" %>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% end %>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|