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
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>
|
|
@@ -0,0 +1,50 @@
|
|
|
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:480px;">
|
|
4
|
+
<div class="card-header"><h2>New Tenant</h2></div>
|
|
5
|
+
|
|
6
|
+
<%= form_with model: @tenant,
|
|
7
|
+
url: "/pg_multitenant_schemas/tenants",
|
|
8
|
+
local: true,
|
|
9
|
+
style: "padding:1.25rem;display:flex;flex-direction:column;gap:1rem;" do |f| %>
|
|
10
|
+
|
|
11
|
+
<% if @tenant.errors.any? %>
|
|
12
|
+
<div class="flash alert">
|
|
13
|
+
<ul style="list-style:none;">
|
|
14
|
+
<% @tenant.errors.full_messages.each do |msg| %>
|
|
15
|
+
<li>⚠ <%= msg %></li>
|
|
16
|
+
<% end %>
|
|
17
|
+
</ul>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<div style="display:flex;flex-direction:column;gap:.35rem;">
|
|
22
|
+
<%= f.label :subdomain, "Subdomain / Schema name", style: "font-size:.85rem;font-weight:500;" %>
|
|
23
|
+
<%= f.text_field :subdomain,
|
|
24
|
+
placeholder: "e.g. acme_corp",
|
|
25
|
+
style: "border:1px solid #d1d5db;border-radius:6px;padding:.5rem .75rem;font-size:.9rem;",
|
|
26
|
+
required: true %>
|
|
27
|
+
<small style="color:#6b7280;font-size:.78rem;">Used as the PostgreSQL schema name. Lowercase letters, numbers, underscores only.</small>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div style="display:flex;flex-direction:column;gap:.35rem;">
|
|
31
|
+
<%= f.label :name, "Display name", style: "font-size:.85rem;font-weight:500;" %>
|
|
32
|
+
<%= f.text_field :name,
|
|
33
|
+
placeholder: "e.g. ACME Corporation",
|
|
34
|
+
style: "border:1px solid #d1d5db;border-radius:6px;padding:.5rem .75rem;font-size:.9rem;" %>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div style="display:flex;flex-direction:column;gap:.35rem;">
|
|
38
|
+
<%= f.label :status, "Status", style: "font-size:.85rem;font-weight:500;" %>
|
|
39
|
+
<%= f.select :status,
|
|
40
|
+
[["Active", "active"], ["Trial", "trial"], ["Inactive", "inactive"]],
|
|
41
|
+
{ selected: "active" },
|
|
42
|
+
style: "border:1px solid #d1d5db;border-radius:6px;padding:.5rem .75rem;font-size:.9rem;" %>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div style="display:flex;gap:.6rem;padding-top:.25rem;">
|
|
46
|
+
<%= f.submit "Create Tenant & Provision Schema", class: "btn btn-primary" %>
|
|
47
|
+
<%= link_to "Cancel", "/pg_multitenant_schemas/tenants", class: "btn btn-ghost" %>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
PgMultitenantSchemas::UI::Engine.routes.draw do
|
|
4
|
+
root to: "tenants#index"
|
|
5
|
+
|
|
6
|
+
resources :tenants, only: %i[index new create destroy] do
|
|
7
|
+
member do
|
|
8
|
+
post :migrate
|
|
9
|
+
get :migration_status
|
|
10
|
+
end
|
|
11
|
+
collection do
|
|
12
|
+
post :migrate_all
|
|
13
|
+
post :switch
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Fallback for API-only apps where Rack::MethodOverride may be absent.
|
|
18
|
+
# Keeps the same path used by RESTful DELETE forms, but accepts POST.
|
|
19
|
+
post "/tenants/:id", to: "tenants#destroy"
|
|
20
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgMultitenantSchemas
|
|
4
|
+
module UI
|
|
5
|
+
# Mountable Rails engine that exposes the tenant management UI.
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace PgMultitenantSchemas::UI
|
|
8
|
+
|
|
9
|
+
# Tell Rails where this engine's app/ directory lives
|
|
10
|
+
config.root = "#{File.expand_path("../../..", __dir__)}/lib/pg_multitenant_schemas/ui"
|
|
11
|
+
|
|
12
|
+
# Fix Zeitwerk inflection: 'ui' directory should map to 'UI' constant
|
|
13
|
+
initializer "pg_multitenant_schemas.ui.inflections", before: :load_config_initializers do
|
|
14
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
15
|
+
inflect.acronym "UI"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "pg_multitenant_schemas.ui.middleware" do |app|
|
|
20
|
+
# Ensure cookies and session middleware are available for cookie-based schema switching
|
|
21
|
+
# Only in development mode to support schema switching between dashboard and main app
|
|
22
|
+
if ::Rails.env.development?
|
|
23
|
+
# Try to add middleware, but don't fail if it's already there
|
|
24
|
+
begin
|
|
25
|
+
app.config.middleware.insert_before 0, ActionDispatch::Cookies
|
|
26
|
+
rescue StandardError
|
|
27
|
+
# Middleware may already exist, that's fine
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
app.config.middleware.insert_before 1, ActionDispatch::Session::CookieStore, key: "_pg_multitenant_session"
|
|
32
|
+
rescue StandardError
|
|
33
|
+
# Middleware may already exist, that's fine
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
app.config.middleware.insert_before 2, ActionDispatch::Flash
|
|
38
|
+
rescue StandardError
|
|
39
|
+
# Middleware may already exist, that's fine
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
initializer "pg_multitenant_schemas.ui.assets" do |app|
|
|
45
|
+
app.config.assets.precompile += %w[pg_multitenant_schemas/ui/application.css] if app.config.respond_to?(:assets)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -1,38 +1,164 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# frozen_string_li# PgMultitenantSchemas provides modern PostgreSQL schema-based multitenancy functionality.
|
|
4
|
-
# Built for Rails 8+ and Ruby 3.4+, it allows switching between different PostgreSQL schemas
|
|
5
|
-
# to achieve secure tenant isolation while maintaining high performance and developer experience.al: true
|
|
6
|
-
|
|
7
|
-
require "active_support/core_ext/module/delegation"
|
|
8
|
-
require "active_support/core_ext/object/blank"
|
|
9
|
-
require_relative "pg_multitenant_schemas/version"
|
|
10
|
-
require_relative "pg_multitenant_schemas/errors"
|
|
11
|
-
require_relative "pg_multitenant_schemas/configuration"
|
|
12
|
-
require_relative "pg_multitenant_schemas/context"
|
|
13
|
-
require_relative "pg_multitenant_schemas/schema_switcher"
|
|
14
|
-
require_relative "pg_multitenant_schemas/tenant_resolver"
|
|
15
|
-
require_relative "pg_multitenant_schemas/migrator"
|
|
16
|
-
|
|
17
|
-
# Rails integration (Rails 8+ required)
|
|
18
|
-
if defined?(Rails)
|
|
19
|
-
require_relative "pg_multitenant_schemas/rails/controller_concern"
|
|
20
|
-
require_relative "pg_multitenant_schemas/rails/model_concern"
|
|
21
|
-
require_relative "pg_multitenant_schemas/rails/railtie"
|
|
22
|
-
end
|
|
23
|
-
|
|
24
3
|
# PgMultitenantSchemas provides modern PostgreSQL schema-based multitenancy functionality.
|
|
4
|
+
#
|
|
25
5
|
# Built for Rails 8+ and Ruby 3.3+, it allows switching between different PostgreSQL schemas
|
|
26
6
|
# to achieve secure tenant isolation while maintaining high performance and developer experience.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# tenant = Tenant.find(1)
|
|
10
|
+
# PgMultitenantSchemas.switch_to_tenant(tenant)
|
|
11
|
+
# # All queries now use tenant's schema
|
|
12
|
+
#
|
|
13
|
+
# @example Block-based context
|
|
14
|
+
# PgMultitenantSchemas.with_tenant(tenant) do
|
|
15
|
+
# User.all # Queries tenant's schema
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @see PgMultitenantSchemas::Context
|
|
19
|
+
# @see PgMultitenantSchemas::SchemaSwitcher
|
|
20
|
+
# @see PgMultitenantSchemas::Migrator
|
|
27
21
|
module PgMultitenantSchemas
|
|
22
|
+
require "active_support/core_ext/module/delegation"
|
|
23
|
+
require "active_support/core_ext/object/blank"
|
|
24
|
+
require_relative "pg_multitenant_schemas/version"
|
|
25
|
+
require_relative "pg_multitenant_schemas/errors"
|
|
26
|
+
require_relative "pg_multitenant_schemas/configuration"
|
|
27
|
+
require_relative "pg_multitenant_schemas/context"
|
|
28
|
+
require_relative "pg_multitenant_schemas/schema_switcher"
|
|
29
|
+
require_relative "pg_multitenant_schemas/tenant_resolver"
|
|
30
|
+
require_relative "pg_multitenant_schemas/migrator"
|
|
31
|
+
|
|
32
|
+
# Rails integration (Rails 8+ required)
|
|
33
|
+
if defined?(Rails)
|
|
34
|
+
require_relative "pg_multitenant_schemas/rails/controller_concern"
|
|
35
|
+
require_relative "pg_multitenant_schemas/rails/model_concern"
|
|
36
|
+
require_relative "pg_multitenant_schemas/rails/railtie"
|
|
37
|
+
require_relative "pg_multitenant_schemas/ui/engine"
|
|
38
|
+
end
|
|
39
|
+
|
|
28
40
|
class << self
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
# Get the current tenant object
|
|
42
|
+
#
|
|
43
|
+
# @return [Object, nil] The current tenant or nil if not set
|
|
44
|
+
# @see Context.current_tenant
|
|
45
|
+
attr_accessor :current_tenant
|
|
46
|
+
|
|
47
|
+
# Set the current tenant object
|
|
48
|
+
#
|
|
49
|
+
# @param tenant [Object] The tenant to set as current
|
|
50
|
+
# @see Context.current_tenant=
|
|
51
|
+
|
|
52
|
+
# Get the current tenant schema name
|
|
53
|
+
#
|
|
54
|
+
# @return [String] The current schema name
|
|
55
|
+
# @see Context.current_schema
|
|
56
|
+
attr_accessor :current_schema
|
|
57
|
+
|
|
58
|
+
# Set the current tenant schema name
|
|
59
|
+
#
|
|
60
|
+
# @param schema [String] The schema name to set as current
|
|
61
|
+
# @see Context.current_schema=
|
|
62
|
+
|
|
63
|
+
# Reset the current tenant context
|
|
64
|
+
#
|
|
65
|
+
# @return [void]
|
|
66
|
+
# @see Context.reset!
|
|
67
|
+
def reset!
|
|
68
|
+
Context.reset!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Switch to a specific schema
|
|
72
|
+
#
|
|
73
|
+
# @param schema_name [String] The schema name to switch to
|
|
74
|
+
# @return [void]
|
|
75
|
+
# @see Context.switch_to_schema
|
|
76
|
+
def switch_to_schema(schema_name)
|
|
77
|
+
Context.switch_to_schema(schema_name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Switch to a specific tenant's schema
|
|
81
|
+
#
|
|
82
|
+
# @param tenant [Object, String] The tenant object or schema name
|
|
83
|
+
# @return [void]
|
|
84
|
+
# @see Context.switch_to_tenant
|
|
85
|
+
def switch_to_tenant(tenant)
|
|
86
|
+
Context.switch_to_tenant(tenant)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Execute a block within a tenant context
|
|
90
|
+
#
|
|
91
|
+
# @param tenant_or_schema [Object, String] The tenant object or schema name
|
|
92
|
+
# @yield Executes the given block in the tenant's schema context
|
|
93
|
+
# @return [Object] The return value of the block
|
|
94
|
+
# @see Context.with_tenant
|
|
95
|
+
def with_tenant(tenant_or_schema, &)
|
|
96
|
+
Context.with_tenant(tenant_or_schema, &)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Create a new tenant schema
|
|
100
|
+
#
|
|
101
|
+
# @param tenant_or_schema [Object, String] The tenant object or schema name
|
|
102
|
+
# @return [void]
|
|
103
|
+
# @see Context.create_tenant_schema
|
|
104
|
+
def create_tenant_schema(tenant_or_schema)
|
|
105
|
+
Context.create_tenant_schema(tenant_or_schema)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Drop a tenant schema
|
|
109
|
+
#
|
|
110
|
+
# @param tenant_or_schema [Object, String] The tenant object or schema name
|
|
111
|
+
# @param cascade [Boolean] Whether to drop dependent objects (default: true)
|
|
112
|
+
# @return [void]
|
|
113
|
+
# @see Context.drop_tenant_schema
|
|
114
|
+
def drop_tenant_schema(tenant_or_schema, cascade: true)
|
|
115
|
+
Context.drop_tenant_schema(tenant_or_schema, cascade: cascade)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Extract tenant subdomain from a hostname
|
|
119
|
+
#
|
|
120
|
+
# @param hostname [String] The hostname to extract from
|
|
121
|
+
# @return [String, nil] The extracted subdomain or nil
|
|
122
|
+
# @see TenantResolver.extract_subdomain
|
|
123
|
+
def extract_subdomain(hostname)
|
|
124
|
+
TenantResolver.extract_subdomain(hostname)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Find a tenant by subdomain
|
|
128
|
+
#
|
|
129
|
+
# @param subdomain [String] The subdomain to search for
|
|
130
|
+
# @return [Object, nil] The tenant object or nil if not found
|
|
131
|
+
# @see TenantResolver.find_tenant_by_subdomain
|
|
132
|
+
def find_tenant_by_subdomain(subdomain)
|
|
133
|
+
TenantResolver.find_tenant_by_subdomain(subdomain)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Resolve tenant from Rails request
|
|
137
|
+
#
|
|
138
|
+
# @param request [ActionDispatch::Request] The Rails request object
|
|
139
|
+
# @return [Object, nil] The tenant object or nil if not resolved
|
|
140
|
+
# @see TenantResolver.resolve_tenant_from_request
|
|
141
|
+
def resolve_tenant_from_request(request)
|
|
142
|
+
TenantResolver.resolve_tenant_from_request(request)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get the current configuration
|
|
147
|
+
#
|
|
148
|
+
# @return [Configuration] The gem configuration object
|
|
149
|
+
def self.configuration
|
|
150
|
+
@configuration ||= Configuration.new
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Configure the gem
|
|
154
|
+
#
|
|
155
|
+
# @yield Yields the configuration object for configuration
|
|
156
|
+
# @example
|
|
157
|
+
# PgMultitenantSchemas.configure do |config|
|
|
158
|
+
# config.tenant_model_class = 'Tenant'
|
|
159
|
+
# config.default_schema = 'public'
|
|
160
|
+
# end
|
|
161
|
+
def self.configure
|
|
162
|
+
yield(configuration) if block_given?
|
|
37
163
|
end
|
|
38
164
|
end
|
|
@@ -5,22 +5,28 @@ require_relative "lib/pg_multitenant_schemas/version"
|
|
|
5
5
|
Gem::Specification.new do |spec|
|
|
6
6
|
spec.name = "pg_multitenant_schemas"
|
|
7
7
|
spec.version = PgMultitenantSchemas::VERSION
|
|
8
|
-
spec.authors = ["
|
|
9
|
-
spec.email = ["
|
|
8
|
+
spec.authors = ["L BYTE EIRL"]
|
|
9
|
+
spec.email = ["info@lbyte.io"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "Modern PostgreSQL schema-based multitenancy for Rails
|
|
12
|
-
spec.description = "A
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
11
|
+
spec.summary = "Modern PostgreSQL schema-based multitenancy for Rails"
|
|
12
|
+
spec.description = "A production-ready Ruby gem providing PostgreSQL " \
|
|
13
|
+
"schema-based multitenancy with automatic tenant resolution, " \
|
|
14
|
+
"secure isolation, and high performance. Built for Rails 8+ and " \
|
|
15
|
+
"Ruby 4.0+, tested with latest stable versions. Features comprehensive " \
|
|
16
|
+
"test coverage (217 tests, 100% passing), complete YARD " \
|
|
17
|
+
"documentation, and interactive HTML documentation site. Perfect " \
|
|
18
|
+
"for modern SaaS applications with database-level isolation."
|
|
16
19
|
spec.homepage = "https://github.com/rubenpazch/pg_multitenant_schemas"
|
|
17
20
|
spec.license = "MIT"
|
|
18
|
-
spec.required_ruby_version = ">=
|
|
21
|
+
spec.required_ruby_version = ">= 4.0.0"
|
|
19
22
|
|
|
20
23
|
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
21
24
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
25
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
23
26
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
27
|
+
spec.metadata["documentation_uri"] = "https://rubenpazch.github.io/pg_multitenant_schemas"
|
|
28
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
29
|
+
spec.metadata["github_repo"] = "ssh://git@github.com/rubenpazch/pg_multitenant_schemas.git"
|
|
24
30
|
|
|
25
31
|
# Specify which files should be added to the gem when it is released.
|
|
26
32
|
spec.files = Dir.chdir(__dir__) do
|
|
@@ -37,5 +43,5 @@ Gem::Specification.new do |spec|
|
|
|
37
43
|
# Runtime dependencies - Rails 7+ support with Rails 8 optimizations
|
|
38
44
|
spec.add_dependency "activerecord", ">= 7.0", "< 9.0"
|
|
39
45
|
spec.add_dependency "activesupport", ">= 7.0", "< 9.0"
|
|
40
|
-
spec.add_dependency "pg", "~> 1.
|
|
46
|
+
spec.add_dependency "pg", "~> 1.6"
|
|
41
47
|
end
|