ec-pg 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/Guardfile +70 -0
- data/LICENSE.txt +21 -0
- data/README.md +254 -0
- data/Rakefile +8 -0
- data/config/locales/en.yml +2 -0
- data/lib/ec/pg/configuration.rb +47 -0
- data/lib/ec/pg/context.rb +76 -0
- data/lib/ec/pg/middleware/context_switcher.rb +34 -0
- data/lib/ec/pg/railtie.rb +37 -0
- data/lib/ec/pg/rls_manager.rb +121 -0
- data/lib/ec/pg/rls_mixin.rb +80 -0
- data/lib/ec/pg/schema_manager.rb +126 -0
- data/lib/ec/pg/schema_mixin.rb +52 -0
- data/lib/ec/pg/shard_manager.rb +94 -0
- data/lib/ec/pg/shard_mixin.rb +74 -0
- data/lib/ec/pg/tenant_context.rb +73 -0
- data/lib/ec/pg/version.rb +7 -0
- data/lib/ec/pg.rb +53 -0
- data/sig/ec/pg.rbs +6 -0
- metadata +160 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6e69e45c69cb62b8cc005170eadd586bb1d9e5531255aed6c9102130f483ccb2
|
|
4
|
+
data.tar.gz: 784dd9d174de14fd939609f611cc568f987841d325825889d5fdec9161fef105
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5b0db8b630775523543fa285d1e19d12812914adac35dda60e801b48375d80c31a413c0e4eb7d245ef3bfe2016421cb49aa43f238daa0de7787ac87c55391fb6
|
|
7
|
+
data.tar.gz: 1809e37d26dde7ec53fda58133856e62295e6702478d7ea82462965483ec634317c8ff0570aa64d93f6d820674f895197b1a49ede5891e6491df58d015fb6a84
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"ec-pg" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["gmhawash@gmail.com"](mailto:"gmhawash@gmail.com").
|
data/Guardfile
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# A sample Guardfile
|
|
2
|
+
# More info at https://github.com/guard/guard#readme
|
|
3
|
+
|
|
4
|
+
## Uncomment and set this to only include directories you want to watch
|
|
5
|
+
# directories %w(app lib config test spec features) \
|
|
6
|
+
# .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")}
|
|
7
|
+
|
|
8
|
+
## Note: if you are using the `directories` clause above and you are not
|
|
9
|
+
## watching the project directory ('.'), then you will want to move
|
|
10
|
+
## the Guardfile to a watched dir and symlink it back, e.g.
|
|
11
|
+
#
|
|
12
|
+
# $ mkdir config
|
|
13
|
+
# $ mv Guardfile config/
|
|
14
|
+
# $ ln -s config/Guardfile .
|
|
15
|
+
#
|
|
16
|
+
# and, you'll have to watch "config/Guardfile" instead of "Guardfile"
|
|
17
|
+
|
|
18
|
+
# Note: The cmd option is now required due to the increasing number of ways
|
|
19
|
+
# rspec may be run, below are examples of the most common uses.
|
|
20
|
+
# * bundler: 'bundle exec rspec'
|
|
21
|
+
# * bundler binstubs: 'bin/rspec'
|
|
22
|
+
# * spring: 'bin/rspec' (This will use spring if running and you have
|
|
23
|
+
# installed the spring binstubs per the docs)
|
|
24
|
+
# * zeus: 'zeus rspec' (requires the server to be started separately)
|
|
25
|
+
# * 'just' rspec: 'rspec'
|
|
26
|
+
|
|
27
|
+
guard :rspec, cmd: "bundle exec rspec" do
|
|
28
|
+
require "guard/rspec/dsl"
|
|
29
|
+
dsl = Guard::RSpec::Dsl.new(self)
|
|
30
|
+
|
|
31
|
+
# Feel free to open issues for suggestions and improvements
|
|
32
|
+
|
|
33
|
+
# RSpec files
|
|
34
|
+
rspec = dsl.rspec
|
|
35
|
+
watch(rspec.spec_helper) { rspec.spec_dir }
|
|
36
|
+
watch(rspec.spec_support) { rspec.spec_dir }
|
|
37
|
+
watch(rspec.spec_files)
|
|
38
|
+
|
|
39
|
+
# Ruby files
|
|
40
|
+
ruby = dsl.ruby
|
|
41
|
+
dsl.watch_spec_files_for(ruby.lib_files)
|
|
42
|
+
|
|
43
|
+
# Rails files
|
|
44
|
+
rails = dsl.rails(view_extensions: %w(erb haml slim))
|
|
45
|
+
dsl.watch_spec_files_for(rails.app_files)
|
|
46
|
+
dsl.watch_spec_files_for(rails.views)
|
|
47
|
+
|
|
48
|
+
watch(rails.controllers) do |m|
|
|
49
|
+
[
|
|
50
|
+
rspec.spec.call("routing/#{m[1]}_routing"),
|
|
51
|
+
rspec.spec.call("controllers/#{m[1]}_controller"),
|
|
52
|
+
rspec.spec.call("acceptance/#{m[1]}")
|
|
53
|
+
]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Rails config changes
|
|
57
|
+
watch(rails.spec_helper) { rspec.spec_dir }
|
|
58
|
+
watch(rails.routes) { "#{rspec.spec_dir}/routing" }
|
|
59
|
+
watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
|
|
60
|
+
|
|
61
|
+
# Capybara features specs
|
|
62
|
+
watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") }
|
|
63
|
+
watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") }
|
|
64
|
+
|
|
65
|
+
# Turnip features and steps
|
|
66
|
+
watch(%r{^spec/acceptance/(.+)\.feature$})
|
|
67
|
+
watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
|
|
68
|
+
Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
|
|
69
|
+
end
|
|
70
|
+
end
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gmhawash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# ec-pg
|
|
2
|
+
|
|
3
|
+
Multi-tenancy for Rails + PostgreSQL. Supports three isolation strategies — schema-per-tenant, database sharding, and row-level security (RLS) — with thread-safe context management and optional Rack middleware for automatic per-request switching.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Ruby >= 3.2.0
|
|
8
|
+
- Rails / ActiveRecord >= 7.1
|
|
9
|
+
- PostgreSQL adapter (`pg`)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem 'ec-pg', github: 'binnablus/ec-pg'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Strategies
|
|
18
|
+
|
|
19
|
+
| Strategy | Isolation level | Best for |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Schema-per-tenant | PostgreSQL schema (`search_path`) | Strong isolation, moderate tenant count |
|
|
22
|
+
| Sharding | ActiveRecord multi-database | Horizontal scale, data locality |
|
|
23
|
+
| Row-Level Security | PostgreSQL RLS policies | Lightweight isolation in a shared schema |
|
|
24
|
+
|
|
25
|
+
The three strategies can be used independently or combined.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# config/initializers/ec_pg.rb
|
|
33
|
+
Ec::Pg.configure do |c|
|
|
34
|
+
c.default_schema = 'public' # schema when none is active
|
|
35
|
+
c.shared_schemas = ['public'] # always appended to search_path
|
|
36
|
+
c.number_of_shards = 4 # total shards in the cluster
|
|
37
|
+
c.rls_mode = :local # :local (per-transaction) or :session
|
|
38
|
+
|
|
39
|
+
# Required when using the ContextSwitcher middleware.
|
|
40
|
+
# Return a hash with :shard and/or :schema keys.
|
|
41
|
+
c.get_context_method = ->(request) {
|
|
42
|
+
subdomain = request.host.split('.').first
|
|
43
|
+
{ schema: subdomain }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Paths that bypass context switching (substring match)
|
|
47
|
+
c.context_switch_exclude_paths = ['health', 'status', 'metrics']
|
|
48
|
+
|
|
49
|
+
c.logger = Logger.new($stdout)
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Schema-per-tenant
|
|
56
|
+
|
|
57
|
+
Each tenant lives in its own PostgreSQL schema. The gem sets `search_path` for the duration of a block and restores it automatically.
|
|
58
|
+
|
|
59
|
+
### Model setup
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
63
|
+
include Ec::Pg::SchemaMixin
|
|
64
|
+
acts_as_namespaced
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Usage
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# Block form — preferred
|
|
72
|
+
Ec::Pg::SchemaManager.with_schema('acme') do
|
|
73
|
+
User.all # queries acme.users
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Stateful form
|
|
77
|
+
Ec::Pg::SchemaManager.apply!('acme')
|
|
78
|
+
User.all
|
|
79
|
+
Ec::Pg::SchemaManager.reset!
|
|
80
|
+
|
|
81
|
+
# Inspect current schema
|
|
82
|
+
Ec::Pg::SchemaManager.current_schema # => 'acme'
|
|
83
|
+
Ec::Pg::SchemaManager.active? # => true
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Schema names are validated (alphanumeric + underscores only) to prevent SQL injection.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Sharding
|
|
91
|
+
|
|
92
|
+
Wraps ActiveRecord's `connected_to` to route queries to the right shard.
|
|
93
|
+
|
|
94
|
+
### database.yml
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
production:
|
|
98
|
+
primary: &primary
|
|
99
|
+
adapter: postgresql
|
|
100
|
+
# ...
|
|
101
|
+
shard_one:
|
|
102
|
+
<<: *primary
|
|
103
|
+
database: app_shard_one
|
|
104
|
+
shard_two:
|
|
105
|
+
<<: *primary
|
|
106
|
+
database: app_shard_two
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Model setup
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
113
|
+
include Ec::Pg::ShardMixin
|
|
114
|
+
|
|
115
|
+
# :solo — multiple shards pointing to the same DB (dev/test)
|
|
116
|
+
# :sharded — separate database per shard
|
|
117
|
+
acts_as_sharded(
|
|
118
|
+
mode: :sharded,
|
|
119
|
+
writing_database_identifier: :shard_one,
|
|
120
|
+
reading_database_identifier: :shard_one_replica # optional
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Usage
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
Ec::Pg::ShardManager.with_shard(:shard_one) do
|
|
129
|
+
User.all # routed to shard_one
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
Ec::Pg::ShardManager.with_shard(:shard_two, role: :reading) do
|
|
133
|
+
Report.all
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
Ec::Pg::ShardManager.current_shard # => :shard_one (or :default)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Row-Level Security
|
|
142
|
+
|
|
143
|
+
Sets PostgreSQL session variables that your RLS policies can read (e.g. `current_setting('app.tenant_id')`).
|
|
144
|
+
|
|
145
|
+
### PostgreSQL policy example
|
|
146
|
+
|
|
147
|
+
```sql
|
|
148
|
+
CREATE POLICY tenant_isolation ON users
|
|
149
|
+
USING (tenant_id = current_setting('app.tenant_id')::uuid);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Model setup
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
156
|
+
include Ec::Pg::RlsMixin
|
|
157
|
+
|
|
158
|
+
acts_as_rls(
|
|
159
|
+
mode: :local, # variables reset when transaction ends
|
|
160
|
+
variables: {
|
|
161
|
+
tenant_id: 'app.tenant_id',
|
|
162
|
+
user_id: 'app.user_id'
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Usage
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
User.with_rls(variables: { tenant_id: 'acme-uuid', user_id: 42 }) do
|
|
172
|
+
User.all # policy restricts to acme rows
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
#### Modes
|
|
177
|
+
|
|
178
|
+
| Mode | Variable lifetime |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `:local` (default) | Reset at transaction end |
|
|
181
|
+
| `:session` | Persist until explicitly cleared |
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Combining strategies
|
|
186
|
+
|
|
187
|
+
Use `Ec::Pg.switch` to set both shard and schema in one call:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
Ec::Pg.switch(shard: :shard_two, schema: 'acme') do
|
|
191
|
+
User.all
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Inspect
|
|
195
|
+
Ec::Pg.current_shard # => :shard_two
|
|
196
|
+
Ec::Pg.current_schema # => 'acme'
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Rack middleware
|
|
202
|
+
|
|
203
|
+
Add `ContextSwitcher` to automatically resolve tenant context from each request:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# config/application.rb
|
|
207
|
+
config.middleware.use Ec::Pg::Middleware::ContextSwitcher
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`get_context_method` (configured above) is called on every request. If it raises, the middleware returns `422 Unprocessable Entity` with a JSON error body. Paths matching any entry in `context_switch_exclude_paths` bypass the switcher entirely.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Thread safety
|
|
215
|
+
|
|
216
|
+
Context is stored in thread-local variables. Each thread/fiber has its own isolated context, and block forms (`with_schema`, `with_shard`, `with_rls`) always restore the previous context — even when an exception is raised.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Low-level context API
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
# Read
|
|
224
|
+
Ec::Pg::Context.shard # => :shard_one or nil
|
|
225
|
+
Ec::Pg::Context.schema # => 'acme' or nil
|
|
226
|
+
Ec::Pg::Context.current # => { shard: :shard_one, schema: 'acme' }
|
|
227
|
+
Ec::Pg::Context.active? # => true if any key is set
|
|
228
|
+
|
|
229
|
+
# Write
|
|
230
|
+
Ec::Pg::Context.set(shard: :shard_one, schema: 'acme')
|
|
231
|
+
Ec::Pg::Context.delete(:shard)
|
|
232
|
+
Ec::Pg::Context.clear!
|
|
233
|
+
|
|
234
|
+
# Temporary override (block-scoped)
|
|
235
|
+
Ec::Pg::Context.with(schema: 'other') { ... }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Development
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
bin/setup # install dependencies
|
|
244
|
+
rake spec # run test suite
|
|
245
|
+
bin/console # interactive prompt
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Contributing
|
|
249
|
+
|
|
250
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/binnablus/ec-pg. Contributors are expected to follow the [code of conduct](https://github.com/binnablus/ec-pg/blob/master/CODE_OF_CONDUCT.md).
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
module Ec
|
|
4
|
+
module Pg
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :logger
|
|
7
|
+
attr_accessor :default_schema
|
|
8
|
+
attr_accessor :shared_schemas
|
|
9
|
+
attr_reader :rls_mode
|
|
10
|
+
attr_reader :number_of_shards
|
|
11
|
+
attr_reader :get_context_method
|
|
12
|
+
attr_accessor :context_switch_exclude_paths
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
self.logger = Logger.new($stdout)
|
|
16
|
+
self.default_schema = 'public'
|
|
17
|
+
self.number_of_shards = 1
|
|
18
|
+
self.rls_mode = :local
|
|
19
|
+
self.context_switch_exclude_paths = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def number_of_shards=(value)
|
|
23
|
+
if value.is_a?(Integer)
|
|
24
|
+
@number_of_shards = value
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError, "'#{value}': number_of_shards must be an integer"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rls_mode=(value)
|
|
31
|
+
if RlsMixin::RlsModes.include?(value.to_sym)
|
|
32
|
+
@rls_mode = value.to_sym
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "'#{value}': rls_mode must be :local or :session"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get_context_method=(value)
|
|
39
|
+
if value.is_a?(Proc)
|
|
40
|
+
@get_context_method = value
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError, "'#{value}': must be a proc or lambda"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ec
|
|
4
|
+
module Pg
|
|
5
|
+
# Thread-safe store for the current multi-tenant context.
|
|
6
|
+
#
|
|
7
|
+
# Each key is namespaced under a per-thread hash so that Sidekiq workers,
|
|
8
|
+
# Puma threads, and Fiber-based servers never bleed context across requests.
|
|
9
|
+
#
|
|
10
|
+
# Usage (low-level):
|
|
11
|
+
#
|
|
12
|
+
# Context.set(tenant_id: "abc")
|
|
13
|
+
# Context.get(:tenant_id) # => "abc"
|
|
14
|
+
# Context.with(tenant_id: "xyz") { ... } # restores previous value on exit
|
|
15
|
+
# Context.clear!
|
|
16
|
+
#
|
|
17
|
+
module Context
|
|
18
|
+
ThreadKey = :__ec_pg_activerecord_multi_tenant
|
|
19
|
+
ManagedKeys = %i[shard schema].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Convenience accessors -------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def shard; get(:shard); end
|
|
26
|
+
def shard=(val); set(shard: val); end
|
|
27
|
+
def schema; get(:schema); end
|
|
28
|
+
def schema=(val); set(schema: val); end
|
|
29
|
+
|
|
30
|
+
# Returns a shallow copy of the entire current context hash.
|
|
31
|
+
def current
|
|
32
|
+
store.dup
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the value stored under +key+, or +nil+.
|
|
36
|
+
def get(key)
|
|
37
|
+
store[key.to_sym]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Stores +value+ under +key+ for the current thread.
|
|
41
|
+
def set(key_values = {})
|
|
42
|
+
store.merge!(key_values)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Removes +key+ from the current thread's context.
|
|
46
|
+
def delete(key)
|
|
47
|
+
store.delete(key.to_sym)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Yields with +key+ temporarily set to +value+, then restores the
|
|
51
|
+
# previous value (or removes the key if it wasn't set before).
|
|
52
|
+
def with(key_values = {})
|
|
53
|
+
keys = key_values.keys
|
|
54
|
+
stashed = store.slice(*keys)
|
|
55
|
+
set(key_values)
|
|
56
|
+
yield
|
|
57
|
+
ensure
|
|
58
|
+
store.except!(*keys)
|
|
59
|
+
set(stashed)
|
|
60
|
+
store.compact!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def active?
|
|
64
|
+
ManagedKeys.any? { |k| store.key?(k) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def clear!
|
|
68
|
+
Thread.current[ThreadKey] = {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def store
|
|
72
|
+
Thread.current[ThreadKey] ||= {}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Ec
|
|
2
|
+
module Pg
|
|
3
|
+
class ContextSwitcher
|
|
4
|
+
def initialize(app)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(env)
|
|
9
|
+
request = ActionDispatch::Request.new(env)
|
|
10
|
+
|
|
11
|
+
ignore_paths = Ec::Pg.configuration.context_switch_exclude_paths || []
|
|
12
|
+
|
|
13
|
+
if ignore_paths.any? && request.path =~ Regexp.new("^\/(#{ignore_paths.join('|')})")
|
|
14
|
+
@app.call(env)
|
|
15
|
+
else
|
|
16
|
+
context = get_selected_context(request)
|
|
17
|
+
|
|
18
|
+
Ec::Pg.switch(shard: context[:shard], schema: context[:schema]) do
|
|
19
|
+
@app.call(env)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
rescue => e
|
|
24
|
+
Ec::Pg.configuration.logger.error(ActiveSupport::LogSubscriber.new.send(:color, e.inspect, :red))
|
|
25
|
+
[422, {"Content-Type" => 'application/json'}, [{"message": "Schema Context Not Found"}.to_json]]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
def get_selected_context(request)
|
|
30
|
+
Ec::Pg.configuration.get_context_method.call(request)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ec
|
|
4
|
+
module Pg
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
# config.before_initialize do
|
|
7
|
+
# Ec::Pg.reset!
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# config.to_prepare do
|
|
11
|
+
# Ec::Pg::Schema.init!
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# config.after_initialize do
|
|
15
|
+
# if ENV['AR_DEBUG']
|
|
16
|
+
# ActiveSupport::Notifications.subscribe "sql.active_record" do |*args|
|
|
17
|
+
# event = ActiveSupport::Notifications::Event.new(*args)
|
|
18
|
+
#
|
|
19
|
+
# event.name # => "process_action.action_controller"
|
|
20
|
+
# event.duration # => 10 (in milliseconds)
|
|
21
|
+
# event.payload # => {:extra=>information}
|
|
22
|
+
#
|
|
23
|
+
# puts event.payload[:sql].green
|
|
24
|
+
# puts event.payload[:connection].instance_variable_get('@config')
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# rake_tasks do
|
|
30
|
+
# load 'tasks/ec-pg-ar.rake'
|
|
31
|
+
# if DH::PG.configuration.db_migrate_tenants
|
|
32
|
+
# require_relative '../support/migration_tasks_enhancer'
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Ec
|
|
3
|
+
module Pg
|
|
4
|
+
# Manages ActiveRecord database shard switching.
|
|
5
|
+
#
|
|
6
|
+
# Leverages ActiveRecord's native multi-database support (AR 6.1+) via
|
|
7
|
+
# +connected_to(shard:)+. Falls back to manual connection swapping for
|
|
8
|
+
# connection configurations that are not registered as named shards.
|
|
9
|
+
#
|
|
10
|
+
# == Configuration in database.yml (Rails multi-db style)
|
|
11
|
+
#
|
|
12
|
+
# production:
|
|
13
|
+
# primary:
|
|
14
|
+
# <<: *default
|
|
15
|
+
# database: app_primary
|
|
16
|
+
# db_sharded_shard_1:
|
|
17
|
+
# <<: *default
|
|
18
|
+
# database: app_shard_one
|
|
19
|
+
# migrations_paths: db/migrate_shards
|
|
20
|
+
#
|
|
21
|
+
# == Usage
|
|
22
|
+
#
|
|
23
|
+
# ShardManager.with_shard(:shard_one) { User.all }
|
|
24
|
+
# ShardManager.with_shard(:shard_one, role: :reading) { User.all }
|
|
25
|
+
#
|
|
26
|
+
module RlsManager
|
|
27
|
+
class UnregisteredVariable < StandardError; end
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Executes +block+ with the AR connection switched to +shard_name+.
|
|
31
|
+
#
|
|
32
|
+
# @param shard_name [Symbol] the registered shard key
|
|
33
|
+
# @param role [Symbol] :writing (default) or :reading
|
|
34
|
+
# @param klass [Class] the AR base class whose connection pool to switch
|
|
35
|
+
# (default: ActiveRecord::Base)
|
|
36
|
+
# @yield block to run in shard context
|
|
37
|
+
# @return the return value of +block+
|
|
38
|
+
def with_rls(rls_mode: nil, registered_variables: {}, variables: {}, connection: nil, &block)
|
|
39
|
+
connection ||= ActiveRecord::Base.connection
|
|
40
|
+
rls_mode ||= Ec::Pg.configuration.rls_mode || :local
|
|
41
|
+
|
|
42
|
+
selected_variables = variable_value_for(registered_variables, variables)
|
|
43
|
+
|
|
44
|
+
if rls_mode == :local
|
|
45
|
+
wrap_in_transaction(connection) do
|
|
46
|
+
apply_rls(connection, rls_mode, selected_variables)
|
|
47
|
+
block.call
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
begin
|
|
51
|
+
apply_rls(connection, rls_mode, selected_variables)
|
|
52
|
+
block.call
|
|
53
|
+
ensure
|
|
54
|
+
reset!(connection: connection, variables: selected_variables.keys)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resets the RLS variable to its default (empty) value.
|
|
60
|
+
# For :session mode this uses RESET; for :local mode this is a no-op
|
|
61
|
+
# because the variable resets automatically at transaction end.
|
|
62
|
+
def reset!(connection:, variables:)
|
|
63
|
+
variables.each do |variable|
|
|
64
|
+
reset_variable!(connection, variable)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
# Executes the SQL to set the RLS variable.
|
|
70
|
+
def apply_rls(connection, rls_mode, selected_variables)
|
|
71
|
+
connection.execute(sanitized_query(rls_mode, selected_variables))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sanitized_query(mode, selected_variables)
|
|
75
|
+
local = if mode == :local
|
|
76
|
+
'LOCAL'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
selected_variables.map do |variable, value|
|
|
80
|
+
("SET %s #{variable} = #{value};" % local).squeeze(' ')
|
|
81
|
+
end.join(' ')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Wraps the block in a transaction, reusing an existing one if open.
|
|
85
|
+
def wrap_in_transaction(connection, &block)
|
|
86
|
+
if connection.transaction_open?
|
|
87
|
+
block.call
|
|
88
|
+
else
|
|
89
|
+
connection.transaction(&block)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def variable_value_for(registered_variables, variables)
|
|
94
|
+
{}.tap do |hash|
|
|
95
|
+
variables.each do |key, value|
|
|
96
|
+
if registered_variables.has_key?(key)
|
|
97
|
+
hash[registered_variables[key]] = value
|
|
98
|
+
else
|
|
99
|
+
raise UnregisteredVariable, "'#{key}' has not been registered through acts_as_rls."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def reset_variable!(connection, variable)
|
|
106
|
+
connection.execute("RESET #{variable}")
|
|
107
|
+
rescue StandardError
|
|
108
|
+
# Swallow reset errors: connection may have been released or variable
|
|
109
|
+
# may not support RESET (application-level variables cannot be RESET in
|
|
110
|
+
# some Postgres versions; in that case use SET to empty string instead).
|
|
111
|
+
begin
|
|
112
|
+
connection.execute("SET #{variable} TO DEFAULT")
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
module_function :sanitized_query, :apply_rls, :wrap_in_transaction, :variable_value_for, :reset_variable!
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|