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 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
@@ -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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,2 @@
1
+ en:
2
+ example: Example
@@ -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