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
data/docs/index.html
ADDED
|
@@ -0,0 +1,929 @@
|
|
|
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.0">
|
|
6
|
+
<title>PgMultitenantSchemas - PostgreSQL Schema-Based Multitenancy for Rails</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
:root {
|
|
15
|
+
--primary: #2563eb;
|
|
16
|
+
--primary-dark: #1e40af;
|
|
17
|
+
--secondary: #7c3aed;
|
|
18
|
+
--success: #16a34a;
|
|
19
|
+
--warning: #ea580c;
|
|
20
|
+
--danger: #dc2626;
|
|
21
|
+
--light: #f8fafc;
|
|
22
|
+
--dark: #0f172a;
|
|
23
|
+
--gray: #64748b;
|
|
24
|
+
--gray-light: #e2e8f0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
29
|
+
line-height: 1.6;
|
|
30
|
+
color: var(--dark);
|
|
31
|
+
background: var(--light);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.container {
|
|
35
|
+
max-width: 1200px;
|
|
36
|
+
margin: 0 auto;
|
|
37
|
+
padding: 0 20px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Navigation */
|
|
41
|
+
nav {
|
|
42
|
+
background: white;
|
|
43
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
44
|
+
position: sticky;
|
|
45
|
+
top: 0;
|
|
46
|
+
z-index: 100;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
nav .container {
|
|
50
|
+
display: flex;
|
|
51
|
+
justify-content: space-between;
|
|
52
|
+
align-items: center;
|
|
53
|
+
padding: 1rem 20px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
nav h1 {
|
|
57
|
+
font-size: 1.5rem;
|
|
58
|
+
color: var(--primary);
|
|
59
|
+
font-weight: 700;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
nav ul {
|
|
63
|
+
display: flex;
|
|
64
|
+
list-style: none;
|
|
65
|
+
gap: 2rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
nav a {
|
|
69
|
+
color: var(--dark);
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
font-weight: 500;
|
|
72
|
+
transition: color 0.3s;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
nav a:hover {
|
|
76
|
+
color: var(--primary);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Hero Section */
|
|
80
|
+
.hero {
|
|
81
|
+
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
|
82
|
+
color: white;
|
|
83
|
+
padding: 4rem 0;
|
|
84
|
+
text-align: center;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.hero h2 {
|
|
88
|
+
font-size: 2.5rem;
|
|
89
|
+
margin-bottom: 1rem;
|
|
90
|
+
font-weight: 700;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.hero p {
|
|
94
|
+
font-size: 1.25rem;
|
|
95
|
+
opacity: 0.95;
|
|
96
|
+
margin-bottom: 2rem;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.buttons {
|
|
100
|
+
display: flex;
|
|
101
|
+
gap: 1rem;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
flex-wrap: wrap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.btn {
|
|
107
|
+
padding: 0.75rem 1.5rem;
|
|
108
|
+
border-radius: 0.5rem;
|
|
109
|
+
text-decoration: none;
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
transition: all 0.3s;
|
|
112
|
+
border: none;
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
display: inline-block;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.btn-primary {
|
|
118
|
+
background: white;
|
|
119
|
+
color: var(--primary);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.btn-primary:hover {
|
|
123
|
+
transform: translateY(-2px);
|
|
124
|
+
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.btn-secondary {
|
|
128
|
+
background: rgba(255,255,255,0.2);
|
|
129
|
+
color: white;
|
|
130
|
+
border: 2px solid white;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.btn-secondary:hover {
|
|
134
|
+
background: rgba(255,255,255,0.3);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Features Section */
|
|
138
|
+
.features {
|
|
139
|
+
padding: 4rem 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.features h3 {
|
|
143
|
+
text-align: center;
|
|
144
|
+
font-size: 2rem;
|
|
145
|
+
margin-bottom: 3rem;
|
|
146
|
+
color: var(--dark);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.feature-grid {
|
|
150
|
+
display: grid;
|
|
151
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
152
|
+
gap: 2rem;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.feature-card {
|
|
156
|
+
background: white;
|
|
157
|
+
padding: 2rem;
|
|
158
|
+
border-radius: 0.75rem;
|
|
159
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
160
|
+
transition: all 0.3s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.feature-card:hover {
|
|
164
|
+
transform: translateY(-5px);
|
|
165
|
+
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.feature-icon {
|
|
169
|
+
font-size: 2.5rem;
|
|
170
|
+
margin-bottom: 1rem;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.feature-card h4 {
|
|
174
|
+
font-size: 1.25rem;
|
|
175
|
+
margin-bottom: 0.5rem;
|
|
176
|
+
color: var(--primary);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.feature-card p {
|
|
180
|
+
color: var(--gray);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Sections */
|
|
184
|
+
section {
|
|
185
|
+
background: white;
|
|
186
|
+
padding: 3rem 0;
|
|
187
|
+
border-bottom: 1px solid var(--gray-light);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
section h2 {
|
|
191
|
+
font-size: 2rem;
|
|
192
|
+
margin-bottom: 1.5rem;
|
|
193
|
+
color: var(--dark);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
section h3 {
|
|
197
|
+
font-size: 1.5rem;
|
|
198
|
+
margin: 2rem 0 1rem 0;
|
|
199
|
+
color: var(--primary);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
section h4 {
|
|
203
|
+
font-size: 1.1rem;
|
|
204
|
+
margin: 1.5rem 0 0.75rem 0;
|
|
205
|
+
color: var(--dark);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
section p {
|
|
209
|
+
margin-bottom: 1rem;
|
|
210
|
+
color: var(--gray);
|
|
211
|
+
line-height: 1.8;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* Code Blocks */
|
|
215
|
+
code {
|
|
216
|
+
background: var(--light);
|
|
217
|
+
padding: 0.2rem 0.4rem;
|
|
218
|
+
border-radius: 0.25rem;
|
|
219
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
220
|
+
font-size: 0.9em;
|
|
221
|
+
color: var(--danger);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
pre {
|
|
225
|
+
background: var(--dark);
|
|
226
|
+
color: #e2e8f0;
|
|
227
|
+
padding: 1.5rem;
|
|
228
|
+
border-radius: 0.5rem;
|
|
229
|
+
overflow-x: auto;
|
|
230
|
+
margin: 1rem 0;
|
|
231
|
+
font-size: 0.9rem;
|
|
232
|
+
line-height: 1.5;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
pre code {
|
|
236
|
+
background: none;
|
|
237
|
+
padding: 0;
|
|
238
|
+
color: inherit;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* API Reference */
|
|
242
|
+
.api-item {
|
|
243
|
+
background: var(--light);
|
|
244
|
+
padding: 1.5rem;
|
|
245
|
+
border-radius: 0.5rem;
|
|
246
|
+
margin-bottom: 1.5rem;
|
|
247
|
+
border-left: 4px solid var(--primary);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.api-item .signature {
|
|
251
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
252
|
+
background: white;
|
|
253
|
+
padding: 1rem;
|
|
254
|
+
border-radius: 0.25rem;
|
|
255
|
+
overflow-x: auto;
|
|
256
|
+
margin-bottom: 1rem;
|
|
257
|
+
font-size: 0.85rem;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.api-item h4 {
|
|
261
|
+
color: var(--primary);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.params {
|
|
265
|
+
margin: 1rem 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.params h5 {
|
|
269
|
+
font-size: 0.95rem;
|
|
270
|
+
color: var(--dark);
|
|
271
|
+
margin: 0.75rem 0 0.5rem 0;
|
|
272
|
+
font-weight: 600;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.param {
|
|
276
|
+
margin-left: 1.5rem;
|
|
277
|
+
padding: 0.5rem 0;
|
|
278
|
+
border-bottom: 1px solid var(--gray-light);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.param .name {
|
|
282
|
+
font-weight: 600;
|
|
283
|
+
color: var(--dark);
|
|
284
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.param .type {
|
|
288
|
+
color: var(--secondary);
|
|
289
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
290
|
+
font-size: 0.9em;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.param .description {
|
|
294
|
+
color: var(--gray);
|
|
295
|
+
margin-top: 0.25rem;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* Tables */
|
|
299
|
+
table {
|
|
300
|
+
width: 100%;
|
|
301
|
+
border-collapse: collapse;
|
|
302
|
+
margin: 1.5rem 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
th, td {
|
|
306
|
+
padding: 1rem;
|
|
307
|
+
text-align: left;
|
|
308
|
+
border-bottom: 1px solid var(--gray-light);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
th {
|
|
312
|
+
background: var(--light);
|
|
313
|
+
font-weight: 600;
|
|
314
|
+
color: var(--dark);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
tr:hover {
|
|
318
|
+
background: var(--light);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* Links */
|
|
322
|
+
a {
|
|
323
|
+
color: var(--primary);
|
|
324
|
+
text-decoration: none;
|
|
325
|
+
font-weight: 500;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
a:hover {
|
|
329
|
+
text-decoration: underline;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* Footer */
|
|
333
|
+
footer {
|
|
334
|
+
background: var(--dark);
|
|
335
|
+
color: white;
|
|
336
|
+
padding: 2rem 0;
|
|
337
|
+
text-align: center;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
footer a {
|
|
341
|
+
color: #60a5fa;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* Tabs */
|
|
345
|
+
.tabs {
|
|
346
|
+
display: flex;
|
|
347
|
+
border-bottom: 2px solid var(--gray-light);
|
|
348
|
+
margin-bottom: 1.5rem;
|
|
349
|
+
gap: 1rem;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.tab-btn {
|
|
353
|
+
padding: 0.75rem 1.5rem;
|
|
354
|
+
background: none;
|
|
355
|
+
border: none;
|
|
356
|
+
cursor: pointer;
|
|
357
|
+
font-weight: 600;
|
|
358
|
+
color: var(--gray);
|
|
359
|
+
border-bottom: 2px solid transparent;
|
|
360
|
+
transition: all 0.3s;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.tab-btn.active {
|
|
364
|
+
color: var(--primary);
|
|
365
|
+
border-bottom-color: var(--primary);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.tab-btn:hover {
|
|
369
|
+
color: var(--primary);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.tab-content {
|
|
373
|
+
display: none;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.tab-content.active {
|
|
377
|
+
display: block;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* Badges */
|
|
381
|
+
.badge {
|
|
382
|
+
display: inline-block;
|
|
383
|
+
padding: 0.25rem 0.75rem;
|
|
384
|
+
border-radius: 0.25rem;
|
|
385
|
+
font-size: 0.85rem;
|
|
386
|
+
font-weight: 600;
|
|
387
|
+
margin-right: 0.5rem;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.badge-primary {
|
|
391
|
+
background: #dbeafe;
|
|
392
|
+
color: var(--primary);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.badge-success {
|
|
396
|
+
background: #dcfce7;
|
|
397
|
+
color: var(--success);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.badge-warning {
|
|
401
|
+
background: #fed7aa;
|
|
402
|
+
color: var(--warning);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* Responsive */
|
|
406
|
+
@media (max-width: 768px) {
|
|
407
|
+
nav ul {
|
|
408
|
+
gap: 1rem;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.hero h2 {
|
|
412
|
+
font-size: 1.75rem;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.hero p {
|
|
416
|
+
font-size: 1rem;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.buttons {
|
|
420
|
+
flex-direction: column;
|
|
421
|
+
align-items: center;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
section h2 {
|
|
425
|
+
font-size: 1.5rem;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
</style>
|
|
429
|
+
</head>
|
|
430
|
+
<body>
|
|
431
|
+
<!-- Navigation -->
|
|
432
|
+
<nav>
|
|
433
|
+
<div class="container" style="display: flex; justify-content: space-between; align-items: center;">
|
|
434
|
+
<h1>📦 PgMultitenantSchemas</h1>
|
|
435
|
+
<ul style="display: flex; gap: 2rem; list-style: none;">
|
|
436
|
+
<li><a href="#features">Features</a></li>
|
|
437
|
+
<li><a href="#installation">Install</a></li>
|
|
438
|
+
<li><a href="#api">API</a></li>
|
|
439
|
+
<li><a href="#examples">Examples</a></li>
|
|
440
|
+
<li><a href="#docs">Docs</a></li>
|
|
441
|
+
</ul>
|
|
442
|
+
</div>
|
|
443
|
+
</nav>
|
|
444
|
+
|
|
445
|
+
<!-- Hero Section -->
|
|
446
|
+
<section class="hero">
|
|
447
|
+
<div class="container">
|
|
448
|
+
<h2>PostgreSQL Schema-Based Multitenancy</h2>
|
|
449
|
+
<p>Modern Rails gem for secure, isolated, high-performance multi-tenant applications</p>
|
|
450
|
+
<div class="buttons">
|
|
451
|
+
<a href="#installation" class="btn btn-primary">Get Started</a>
|
|
452
|
+
<a href="https://github.com/rubenpazch/pg_multitenant_schemas" class="btn btn-secondary">View on GitHub</a>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
</section>
|
|
456
|
+
|
|
457
|
+
<!-- Features Section -->
|
|
458
|
+
<section id="features">
|
|
459
|
+
<div class="container">
|
|
460
|
+
<h3>Powerful Features</h3>
|
|
461
|
+
<div class="feature-grid">
|
|
462
|
+
<div class="feature-card">
|
|
463
|
+
<div class="feature-icon">🏢</div>
|
|
464
|
+
<h4>Schema Isolation</h4>
|
|
465
|
+
<p>Each tenant gets their own PostgreSQL schema. Complete data isolation with shared application code.</p>
|
|
466
|
+
</div>
|
|
467
|
+
<div class="feature-card">
|
|
468
|
+
<div class="feature-icon">🔄</div>
|
|
469
|
+
<h4>Automatic Switching</h4>
|
|
470
|
+
<p>Seamlessly switch between tenant schemas. Context is maintained throughout the request lifecycle.</p>
|
|
471
|
+
</div>
|
|
472
|
+
<div class="feature-card">
|
|
473
|
+
<div class="feature-icon">🌐</div>
|
|
474
|
+
<h4>Subdomain Resolution</h4>
|
|
475
|
+
<p>Extract tenant from request subdomains. Built-in tenant identification strategies.</p>
|
|
476
|
+
</div>
|
|
477
|
+
<div class="feature-card">
|
|
478
|
+
<div class="feature-icon">🧵</div>
|
|
479
|
+
<h4>Thread-Safe</h4>
|
|
480
|
+
<p>Safe for concurrent operations. Proper context isolation for multi-threaded environments.</p>
|
|
481
|
+
</div>
|
|
482
|
+
<div class="feature-card">
|
|
483
|
+
<div class="feature-icon">⚡</div>
|
|
484
|
+
<h4>High Performance</h4>
|
|
485
|
+
<p>Minimal overhead. Optimized for Rails 8+ with efficient schema switching.</p>
|
|
486
|
+
</div>
|
|
487
|
+
<div class="feature-card">
|
|
488
|
+
<div class="feature-icon">🔒</div>
|
|
489
|
+
<h4>Security-First</h4>
|
|
490
|
+
<p>Database-level tenant isolation. No risk of accidental cross-tenant queries.</p>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
</section>
|
|
495
|
+
|
|
496
|
+
<!-- Installation Section -->
|
|
497
|
+
<section id="installation">
|
|
498
|
+
<div class="container">
|
|
499
|
+
<h2>Installation</h2>
|
|
500
|
+
|
|
501
|
+
<h3>Requirements</h3>
|
|
502
|
+
<ul style="margin: 1rem 0 1rem 2rem; color: var(--gray);">
|
|
503
|
+
<li>Ruby 3.0+</li>
|
|
504
|
+
<li>Rails 7.0+ (optimized for Rails 8)</li>
|
|
505
|
+
<li>PostgreSQL 12+</li>
|
|
506
|
+
</ul>
|
|
507
|
+
|
|
508
|
+
<h3>Setup</h3>
|
|
509
|
+
<p>Add to your Gemfile:</p>
|
|
510
|
+
<pre><code>gem 'pg_multitenant_schemas'
|
|
511
|
+
bundle install</code></pre>
|
|
512
|
+
|
|
513
|
+
<h3>Configuration</h3>
|
|
514
|
+
<p>Create <code>config/initializers/pg_multitenant_schemas.rb</code>:</p>
|
|
515
|
+
<pre><code>PgMultitenantSchemas.configure do |config|
|
|
516
|
+
config.connection_class = 'ApplicationRecord'
|
|
517
|
+
config.tenant_model_class = 'Tenant'
|
|
518
|
+
config.default_schema = 'public'
|
|
519
|
+
config.excluded_subdomains = ['www', 'api', 'admin']
|
|
520
|
+
config.development_fallback = true
|
|
521
|
+
config.auto_create_schemas = true
|
|
522
|
+
end</code></pre>
|
|
523
|
+
</div>
|
|
524
|
+
</section>
|
|
525
|
+
|
|
526
|
+
<!-- Quick Start -->
|
|
527
|
+
<section>
|
|
528
|
+
<div class="container">
|
|
529
|
+
<h2>Quick Start</h2>
|
|
530
|
+
|
|
531
|
+
<h3>Migrate All Tenants</h3>
|
|
532
|
+
<pre><code># Migrate all tenant schemas at once
|
|
533
|
+
rails tenants:migrate
|
|
534
|
+
|
|
535
|
+
# Migrate a specific tenant
|
|
536
|
+
rails tenants:migrate_tenant[acme_corp]
|
|
537
|
+
|
|
538
|
+
# Check migration status
|
|
539
|
+
rails tenants:status</code></pre>
|
|
540
|
+
|
|
541
|
+
<h3>Create New Tenant</h3>
|
|
542
|
+
<pre><code># Create tenant with setup (schema + migrations)
|
|
543
|
+
rails tenants:create[new_tenant]
|
|
544
|
+
|
|
545
|
+
# Create with attributes
|
|
546
|
+
rails tenants:new['{"subdomain":"acme","name":"ACME Corp"}']</code></pre>
|
|
547
|
+
|
|
548
|
+
<h3>Context Management</h3>
|
|
549
|
+
<pre><code>// Switch to tenant context
|
|
550
|
+
PgMultitenantSchemas.switch_to_tenant(tenant)
|
|
551
|
+
|
|
552
|
+
# Use with block
|
|
553
|
+
PgMultitenantSchemas.with_tenant(tenant) do
|
|
554
|
+
User.all # Queries tenant's schema
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Get current context
|
|
558
|
+
PgMultitenantSchemas.current_schema #=> "tenant_123"</code></pre>
|
|
559
|
+
|
|
560
|
+
<h3>Rails Integration</h3>
|
|
561
|
+
<pre><code>class ApplicationController < ActionController::Base
|
|
562
|
+
include PgMultitenantSchemas::Rails::ControllerConcern
|
|
563
|
+
|
|
564
|
+
before_action :resolve_tenant
|
|
565
|
+
|
|
566
|
+
private
|
|
567
|
+
|
|
568
|
+
def resolve_tenant
|
|
569
|
+
tenant = resolve_tenant_from_subdomain
|
|
570
|
+
switch_to_tenant(tenant) if tenant
|
|
571
|
+
end
|
|
572
|
+
end</code></pre>
|
|
573
|
+
</div>
|
|
574
|
+
</section>
|
|
575
|
+
|
|
576
|
+
<!-- API Reference Section -->
|
|
577
|
+
<section id="api">
|
|
578
|
+
<div class="container">
|
|
579
|
+
<h2>API Reference</h2>
|
|
580
|
+
|
|
581
|
+
<h3>Context Class</h3>
|
|
582
|
+
|
|
583
|
+
<div class="api-item">
|
|
584
|
+
<h4>current_schema</h4>
|
|
585
|
+
<div class="signature">PgMultitenantSchemas::Context.current_schema → String</div>
|
|
586
|
+
<p>Get the current tenant schema name. Returns default schema if not set.</p>
|
|
587
|
+
<div class="params">
|
|
588
|
+
<h5>Returns</h5>
|
|
589
|
+
<div class="param">
|
|
590
|
+
<span class="type">String</span>
|
|
591
|
+
<span class="description">The current schema name</span>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
<pre><code>PgMultitenantSchemas::Context.current_schema
|
|
595
|
+
#=> "tenant_123"</code></pre>
|
|
596
|
+
</div>
|
|
597
|
+
|
|
598
|
+
<div class="api-item">
|
|
599
|
+
<h4>current_tenant</h4>
|
|
600
|
+
<div class="signature">PgMultitenantSchemas::Context.current_tenant → Object</div>
|
|
601
|
+
<p>Get the current tenant object.</p>
|
|
602
|
+
<pre><code>PgMultitenantSchemas::Context.current_tenant
|
|
603
|
+
#=> #<Tenant id=1, subdomain="acme"></code></pre>
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
<div class="api-item">
|
|
607
|
+
<h4>switch_to_tenant(tenant)</h4>
|
|
608
|
+
<div class="signature">PgMultitenantSchemas::Context.switch_to_tenant(tenant) → void</div>
|
|
609
|
+
<p>Switch to the given tenant's schema.</p>
|
|
610
|
+
<div class="params">
|
|
611
|
+
<h5>Parameters</h5>
|
|
612
|
+
<div class="param">
|
|
613
|
+
<span class="name">tenant</span>
|
|
614
|
+
<span class="type">Tenant | String</span>
|
|
615
|
+
<span class="description">Tenant object or schema name</span>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
<pre><code>PgMultitenantSchemas::Context.switch_to_tenant(tenant)
|
|
619
|
+
# Now all queries use tenant's schema</code></pre>
|
|
620
|
+
</div>
|
|
621
|
+
|
|
622
|
+
<div class="api-item">
|
|
623
|
+
<h4>with_tenant(tenant) { block }</h4>
|
|
624
|
+
<div class="signature">PgMultitenantSchemas::Context.with_tenant(tenant) { |block| } → Object</div>
|
|
625
|
+
<p>Execute block within tenant context. Restores previous context after block.</p>
|
|
626
|
+
<div class="params">
|
|
627
|
+
<h5>Parameters</h5>
|
|
628
|
+
<div class="param">
|
|
629
|
+
<span class="name">tenant</span>
|
|
630
|
+
<span class="type">Tenant | String</span>
|
|
631
|
+
<span class="description">Tenant object or schema name</span>
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
<pre><code>PgMultitenantSchemas::Context.with_tenant(tenant) do
|
|
635
|
+
User.all # Queries tenant's schema
|
|
636
|
+
Order.create!(...)
|
|
637
|
+
end
|
|
638
|
+
# Context restored here</code></pre>
|
|
639
|
+
</div>
|
|
640
|
+
|
|
641
|
+
<h3>SchemaSwitcher Class</h3>
|
|
642
|
+
|
|
643
|
+
<div class="api-item">
|
|
644
|
+
<h4>create_schema(schema_name)</h4>
|
|
645
|
+
<div class="signature">PgMultitenantSchemas::SchemaSwitcher.create_schema(schema_name) → void</div>
|
|
646
|
+
<p>Create a new PostgreSQL schema.</p>
|
|
647
|
+
<div class="params">
|
|
648
|
+
<h5>Parameters</h5>
|
|
649
|
+
<div class="param">
|
|
650
|
+
<span class="name">schema_name</span>
|
|
651
|
+
<span class="type">String</span>
|
|
652
|
+
<span class="description">Name of the schema to create</span>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
|
|
657
|
+
<div class="api-item">
|
|
658
|
+
<h4>drop_schema(schema_name, cascade: true)</h4>
|
|
659
|
+
<div class="signature">PgMultitenantSchemas::SchemaSwitcher.drop_schema(schema_name, cascade: true) → void</div>
|
|
660
|
+
<p>Drop a PostgreSQL schema.</p>
|
|
661
|
+
<div class="params">
|
|
662
|
+
<h5>Parameters</h5>
|
|
663
|
+
<div class="param">
|
|
664
|
+
<span class="name">schema_name</span>
|
|
665
|
+
<span class="type">String</span>
|
|
666
|
+
<span class="description">Name of the schema to drop</span>
|
|
667
|
+
</div>
|
|
668
|
+
<div class="param">
|
|
669
|
+
<span class="name">cascade</span>
|
|
670
|
+
<span class="type">Boolean</span>
|
|
671
|
+
<span class="description">Drop dependent objects (default: true)</span>
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
|
|
676
|
+
<div class="api-item">
|
|
677
|
+
<h4>schema_exists?(schema_name)</h4>
|
|
678
|
+
<div class="signature">PgMultitenantSchemas::SchemaSwitcher.schema_exists?(schema_name) → Boolean</div>
|
|
679
|
+
<p>Check if a schema exists in the database.</p>
|
|
680
|
+
<div class="params">
|
|
681
|
+
<h5>Parameters</h5>
|
|
682
|
+
<div class="param">
|
|
683
|
+
<span class="name">schema_name</span>
|
|
684
|
+
<span class="type">String</span>
|
|
685
|
+
<span class="description">Name of the schema to check</span>
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
<pre><code>if PgMultitenantSchemas::SchemaSwitcher.schema_exists?('tenant_123')
|
|
689
|
+
# Schema exists
|
|
690
|
+
end</code></pre>
|
|
691
|
+
</div>
|
|
692
|
+
|
|
693
|
+
<div class="api-item">
|
|
694
|
+
<h4>list_schemas</h4>
|
|
695
|
+
<div class="signature">PgMultitenantSchemas::SchemaSwitcher.list_schemas → Array[String]</div>
|
|
696
|
+
<p>List all schemas in the database.</p>
|
|
697
|
+
<pre><code>PgMultitenantSchemas::SchemaSwitcher.list_schemas
|
|
698
|
+
#=> ["public", "tenant_123", "tenant_456"]</code></pre>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
<h3>Migrator Class</h3>
|
|
702
|
+
|
|
703
|
+
<div class="api-item">
|
|
704
|
+
<h4>migrate_all</h4>
|
|
705
|
+
<div class="signature">PgMultitenantSchemas::Migrator.migrate_all → Hash</div>
|
|
706
|
+
<p>Run migrations across all tenant schemas.</p>
|
|
707
|
+
<pre><code>results = PgMultitenantSchemas::Migrator.migrate_all
|
|
708
|
+
#=> {
|
|
709
|
+
# success: ["tenant_1", "tenant_2"],
|
|
710
|
+
# failed: [],
|
|
711
|
+
# total_migrations: 5
|
|
712
|
+
# }</code></pre>
|
|
713
|
+
</div>
|
|
714
|
+
|
|
715
|
+
<div class="api-item">
|
|
716
|
+
<h4>setup_tenant(schema_name)</h4>
|
|
717
|
+
<div class="signature">PgMultitenantSchemas::Migrator.setup_tenant(schema_name) → Boolean</div>
|
|
718
|
+
<p>Create schema and run migrations for a new tenant.</p>
|
|
719
|
+
<div class="params">
|
|
720
|
+
<h5>Parameters</h5>
|
|
721
|
+
<div class="param">
|
|
722
|
+
<span class="name">schema_name</span>
|
|
723
|
+
<span class="type">String</span>
|
|
724
|
+
<span class="description">Name of the tenant schema</span>
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
</div>
|
|
728
|
+
|
|
729
|
+
<div class="api-item">
|
|
730
|
+
<h4>migration_status</h4>
|
|
731
|
+
<div class="signature">PgMultitenantSchemas::Migrator.migration_status → Hash</div>
|
|
732
|
+
<p>Get migration status for all tenants.</p>
|
|
733
|
+
<pre><code>PgMultitenantSchemas::Migrator.migration_status
|
|
734
|
+
#=> {
|
|
735
|
+
# "tenant_1" => { status: :up_to_date, pending: 0 },
|
|
736
|
+
# "tenant_2" => { status: :pending, pending: 2 }
|
|
737
|
+
# }</code></pre>
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
</section>
|
|
741
|
+
|
|
742
|
+
<!-- Examples Section -->
|
|
743
|
+
<section id="examples">
|
|
744
|
+
<div class="container">
|
|
745
|
+
<h2>Common Patterns</h2>
|
|
746
|
+
|
|
747
|
+
<h3>Subdomain-Based Routing</h3>
|
|
748
|
+
<pre><code>class ApplicationController < ActionController::Base
|
|
749
|
+
include PgMultitenantSchemas::Rails::ControllerConcern
|
|
750
|
+
|
|
751
|
+
before_action :authenticate_user!
|
|
752
|
+
before_action :resolve_tenant_from_subdomain
|
|
753
|
+
before_action :ensure_user_in_tenant
|
|
754
|
+
|
|
755
|
+
private
|
|
756
|
+
|
|
757
|
+
def resolve_tenant_from_subdomain
|
|
758
|
+
@tenant = resolve_tenant_from_request(request)
|
|
759
|
+
switch_to_tenant(@tenant) if @tenant
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def ensure_user_in_tenant
|
|
763
|
+
redirect_unless current_user.tenant == @tenant
|
|
764
|
+
end
|
|
765
|
+
end</code></pre>
|
|
766
|
+
|
|
767
|
+
<h3>Background Jobs with Tenant Context</h3>
|
|
768
|
+
<pre><code>class ProcessOrderJob
|
|
769
|
+
include Sidekiq::Worker
|
|
770
|
+
|
|
771
|
+
def perform(order_id, tenant_id)
|
|
772
|
+
tenant = Tenant.find(tenant_id)
|
|
773
|
+
|
|
774
|
+
PgMultitenantSchemas.with_tenant(tenant) do
|
|
775
|
+
order = Order.find(order_id)
|
|
776
|
+
# Process order in tenant's schema
|
|
777
|
+
order.process!
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Enqueue with tenant context
|
|
783
|
+
tenant = Tenant.first
|
|
784
|
+
ProcessOrderJob.perform_async(order.id, tenant.id)</code></pre>
|
|
785
|
+
|
|
786
|
+
<h3>Cross-Tenant Admin Operations</h3>
|
|
787
|
+
<pre><code>class AdminController < ApplicationController
|
|
788
|
+
def platform_stats
|
|
789
|
+
stats = {}
|
|
790
|
+
|
|
791
|
+
Tenant.find_each do |tenant|
|
|
792
|
+
PgMultitenantSchemas.with_tenant(tenant) do
|
|
793
|
+
stats[tenant.subdomain] = {
|
|
794
|
+
users_count: User.count,
|
|
795
|
+
orders_count: Order.count,
|
|
796
|
+
revenue: Order.sum(:amount)
|
|
797
|
+
}
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
render json: stats
|
|
802
|
+
end
|
|
803
|
+
end</code></pre>
|
|
804
|
+
|
|
805
|
+
<h3>Testing with Tenants</h3>
|
|
806
|
+
<pre><code>RSpec.describe User, type: :model do
|
|
807
|
+
let(:tenant) { create(:tenant) }
|
|
808
|
+
|
|
809
|
+
before { PgMultitenantSchemas.switch_to_tenant(tenant) }
|
|
810
|
+
after { PgMultitenantSchemas.reset! }
|
|
811
|
+
|
|
812
|
+
it 'creates user in tenant schema' do
|
|
813
|
+
user = create(:user)
|
|
814
|
+
|
|
815
|
+
PgMultitenantSchemas.with_tenant(tenant) do
|
|
816
|
+
expect(User.find(user.id)).to eq(user)
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
end</code></pre>
|
|
820
|
+
</div>
|
|
821
|
+
</section>
|
|
822
|
+
|
|
823
|
+
<!-- Documentation Section -->
|
|
824
|
+
<section id="docs">
|
|
825
|
+
<div class="container">
|
|
826
|
+
<h2>Complete Documentation</h2>
|
|
827
|
+
|
|
828
|
+
<h3>Core Components</h3>
|
|
829
|
+
<table>
|
|
830
|
+
<thead>
|
|
831
|
+
<tr>
|
|
832
|
+
<th>Component</th>
|
|
833
|
+
<th>Purpose</th>
|
|
834
|
+
<th>Link</th>
|
|
835
|
+
</tr>
|
|
836
|
+
</thead>
|
|
837
|
+
<tbody>
|
|
838
|
+
<tr>
|
|
839
|
+
<td><strong>Context</strong></td>
|
|
840
|
+
<td>Thread-safe tenant context management</td>
|
|
841
|
+
<td><a href="context.md">Documentation</a></td>
|
|
842
|
+
</tr>
|
|
843
|
+
<tr>
|
|
844
|
+
<td><strong>SchemaSwitcher</strong></td>
|
|
845
|
+
<td>Low-level PostgreSQL schema operations</td>
|
|
846
|
+
<td><a href="schema_switcher.md">Documentation</a></td>
|
|
847
|
+
</tr>
|
|
848
|
+
<tr>
|
|
849
|
+
<td><strong>Migrator</strong></td>
|
|
850
|
+
<td>Automated migration management</td>
|
|
851
|
+
<td><a href="migrator.md">Documentation</a></td>
|
|
852
|
+
</tr>
|
|
853
|
+
<tr>
|
|
854
|
+
<td><strong>TenantResolver</strong></td>
|
|
855
|
+
<td>Tenant identification strategies</td>
|
|
856
|
+
<td><a href="tenant_resolver.md">Documentation</a></td>
|
|
857
|
+
</tr>
|
|
858
|
+
<tr>
|
|
859
|
+
<td><strong>Configuration</strong></td>
|
|
860
|
+
<td>Gem configuration and settings</td>
|
|
861
|
+
<td><a href="configuration.md">Documentation</a></td>
|
|
862
|
+
</tr>
|
|
863
|
+
<tr>
|
|
864
|
+
<td><strong>Rails Integration</strong></td>
|
|
865
|
+
<td>Framework components and patterns</td>
|
|
866
|
+
<td><a href="rails_integration.md">Documentation</a></td>
|
|
867
|
+
</tr>
|
|
868
|
+
</tbody>
|
|
869
|
+
</table>
|
|
870
|
+
|
|
871
|
+
<h3>Guides</h3>
|
|
872
|
+
<ul style="margin: 1rem 0 1rem 2rem; color: var(--gray);">
|
|
873
|
+
<li><a href="testing.md">Testing Guide</a> - RSpec test suite and testing patterns</li>
|
|
874
|
+
<li><a href="integration_testing.md">Integration Testing</a> - PostgreSQL integration testing</li>
|
|
875
|
+
<li><a href="errors.md">Error Handling</a> - Exception classes and error management</li>
|
|
876
|
+
<li><a href="github_actions_setup.md">CI/CD Setup</a> - GitHub Actions automation</li>
|
|
877
|
+
</ul>
|
|
878
|
+
|
|
879
|
+
<h3>Troubleshooting</h3>
|
|
880
|
+
<ul style="margin: 1rem 0 1rem 2rem; color: var(--gray);">
|
|
881
|
+
<li><a href="github_actions_permissions_fix.md">GitHub Actions Permissions</a></li>
|
|
882
|
+
<li><a href="local_workflow_testing.md">Local Workflow Testing</a></li>
|
|
883
|
+
</ul>
|
|
884
|
+
</div>
|
|
885
|
+
</section>
|
|
886
|
+
|
|
887
|
+
<!-- Stats Section -->
|
|
888
|
+
<section style="background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); color: white;">
|
|
889
|
+
<div class="container" style="text-align: center;">
|
|
890
|
+
<h2 style="color: white; margin-bottom: 2rem;">Production Ready</h2>
|
|
891
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 2rem; text-align: center;">
|
|
892
|
+
<div>
|
|
893
|
+
<div style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">217</div>
|
|
894
|
+
<div style="font-size: 1.1rem; opacity: 0.95;">Test Examples</div>
|
|
895
|
+
</div>
|
|
896
|
+
<div>
|
|
897
|
+
<div style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">100%</div>
|
|
898
|
+
<div style="font-size: 1.1rem; opacity: 0.95;">Tests Passing</div>
|
|
899
|
+
</div>
|
|
900
|
+
<div>
|
|
901
|
+
<div style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">0</div>
|
|
902
|
+
<div style="font-size: 1.1rem; opacity: 0.95;">CVE Issues</div>
|
|
903
|
+
</div>
|
|
904
|
+
<div>
|
|
905
|
+
<div style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">Rails 8</div>
|
|
906
|
+
<div style="font-size: 1.1rem; opacity: 0.95;">Optimized</div>
|
|
907
|
+
</div>
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
</section>
|
|
911
|
+
|
|
912
|
+
<!-- Footer -->
|
|
913
|
+
<footer>
|
|
914
|
+
<div class="container">
|
|
915
|
+
<p style="margin-bottom: 1rem;">
|
|
916
|
+
<strong>PgMultitenantSchemas</strong> • Modern PostgreSQL schema-based multitenancy for Rails
|
|
917
|
+
</p>
|
|
918
|
+
<p>
|
|
919
|
+
<a href="https://github.com/rubenpazch/pg_multitenant_schemas">GitHub</a> •
|
|
920
|
+
<a href="https://rubygems.org/gems/pg_multitenant_schemas">RubyGems</a> •
|
|
921
|
+
<a href="LICENSE.txt">MIT License</a>
|
|
922
|
+
</p>
|
|
923
|
+
<p style="margin-top: 1.5rem; opacity: 0.8;">
|
|
924
|
+
© 2025 L BYTE EIRL. Built with ❤️ for the Rails community.
|
|
925
|
+
</p>
|
|
926
|
+
</div>
|
|
927
|
+
</footer>
|
|
928
|
+
</body>
|
|
929
|
+
</html>
|