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.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ec
4
+ module Pg
5
+ # Mixin for ActiveRecord models that participate in multi-tenancy.
6
+ #
7
+ # Include via +acts_as_rls+ in your model or add it to ApplicationRecord.
8
+ #
9
+ # == Usage
10
+ #
11
+ # class ApplicationRecord < ActiveRecord::Base
12
+ # include Ec::Pg::RlsMixin
13
+ # acts_as_rls
14
+ # end
15
+ #
16
+ # == What it does
17
+ #
18
+ # * Adds an +around_action+-compatible callback that applies RLS before
19
+ # every query on the model when a tenant is active in the thread context.
20
+ # * Provides a +with_rls+ class method for explicit scoping.
21
+ # * Optionally raises TenantNotSet when required: true and no tenant is set.
22
+ #
23
+ module RlsMixin
24
+ class RlsError < StandardError; end
25
+
26
+ extend ActiveSupport::Concern
27
+ RlsModes = %i(local session)
28
+
29
+ included do
30
+ class_attribute :is_rls, default: false
31
+ class_attribute :registered_variables, default: {}
32
+ class_attribute :rls_mode, default: :local
33
+ end
34
+
35
+ class_methods do
36
+ def acts_as_rls(mode: nil, variables: {})
37
+ validate_variables!(variables)
38
+
39
+ self.is_rls = true
40
+ self.rls_mode = mode || Ec::Pg.configuration.rls_mode || :local
41
+ self.registered_variables = variables
42
+ end
43
+
44
+ # Executes +block+ with the AR connection switched to +shard_name+.
45
+ #
46
+ # @param shard_name [Symbol] the registered shard key
47
+ # @param role [Symbol] :writing (default) or :reading
48
+ # @param klass [Class] the AR base class whose connection pool to switch
49
+ # (default: ActiveRecord::Base)
50
+ # @yield block to run in shard context
51
+ # @return the return value of +block+
52
+ def with_rls(variables: {}, &block)
53
+ RlsManager.with_rls(
54
+ rls_mode: self.rls_mode,
55
+ registered_variables: self.registered_variables,
56
+ variables: variables,
57
+ connection: self.connection,
58
+ &block
59
+ )
60
+ end
61
+
62
+ def clear!
63
+ self.registered_variables = {}
64
+ end
65
+
66
+ private
67
+ # Prevents variable name injection. Only allows dot-separated identifiers.
68
+ ValidVariablesRegexp = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/
69
+
70
+ def validate_variables!(variables)
71
+ variables.each do |key, variable|
72
+ unless ValidVariablesRegexp.match?(variable)
73
+ raise RlsError, "Invalid RLS variable name: #{key}: #{variable.inspect}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ec
4
+ module Pg
5
+ # Manages PostgreSQL schema switching via the +search_path+ session variable.
6
+ #
7
+ # When you have one Postgres database with a schema-per-tenant layout:
8
+ #
9
+ # CREATE SCHEMA tenant_abc;
10
+ # CREATE TABLE tenant_abc.records (...);
11
+ #
12
+ # This manager updates the connection's search_path so that ActiveRecord's
13
+ # unqualified table references resolve to the correct tenant schema.
14
+ #
15
+ # == Usage
16
+ #
17
+ # SchemaManager.with_schema("tenant_abc") { Record.all }
18
+ #
19
+ # The search_path is reset to the previous value (or the configured default)
20
+ # when the block exits, even if an exception is raised.
21
+ #
22
+ # == Thread safety
23
+ #
24
+ # +with_schema+ stores the schema name in the thread context AND updates the
25
+ # underlying database connection. When using connection pooling, each thread
26
+ # checks out its own connection, so search_path changes are thread-safe.
27
+ #
28
+ module SchemaManager
29
+ class InvalidSchema < StandardError; end
30
+
31
+ module_function
32
+
33
+ # Executes +block+ with the Postgres search_path set to +schema_name+,
34
+ # followed by any +shared_schemas+ from the configuration.
35
+ #
36
+ # @param schema_name [String] tenant schema (e.g. "tenant_abc")
37
+ # @yield block to execute within the schema context
38
+ # @return the return value of the block
39
+ def with_schema(schema_name, &block)
40
+ schema_name = schema_name.to_s.strip
41
+ validate_schema_name!(schema_name)
42
+
43
+ Context.with(schema: schema_name) do
44
+ apply_schema(schema_name)
45
+ block.call
46
+ end
47
+ ensure
48
+ # Restore previous schema on the same connection if we changed it.
49
+ # Guard against the case where the connection was already returned to
50
+ # the pool during the block.
51
+ restore_schema
52
+ end
53
+
54
+ # Returns the schema currently active in thread context.
55
+ def current_schema
56
+ Context.schema || Ec::Pg.configuration.default_schema
57
+ end
58
+
59
+ # Returns true if a non-default schema is active.
60
+ def active?
61
+ !Context.schema.nil?
62
+ end
63
+
64
+ # Applies the search_path on an existing connection without block scoping.
65
+ # Useful in middleware or Rack apps that manage connection lifecycle manually.
66
+ def apply!(schema_name)
67
+ schema_name = schema_name.to_s.strip
68
+ validate_schema_name!(schema_name)
69
+
70
+ Context.set(schema: schema_name)
71
+ apply_schema(schema_name)
72
+ end
73
+
74
+ # Resets search_path to the configured default schema on +connection+.
75
+ def reset!
76
+ Context.delete(:schema)
77
+ restore_schema
78
+ end
79
+
80
+ private
81
+
82
+ # Builds the search_path string and executes SET search_path on the connection.
83
+ def apply_schema(schema_name)
84
+ registered_connections.each do |connection|
85
+ shared = Ec::Pg.configuration.shared_schemas
86
+ path = ([schema_name] + shared).uniq.join(", ")
87
+ connection.schema_search_path = path
88
+ end
89
+ end
90
+
91
+ # Restores the search_path to the configured default schema(s).
92
+ def restore_schema
93
+ default = Ec::Pg.configuration.default_schema
94
+ shared = Ec::Pg.configuration.shared_schemas
95
+ path = ([default] + shared).uniq.join(", ")
96
+
97
+ registered_connections.each do |connection|
98
+ if connection && connection.pool.present?
99
+ connection.schema_search_path = path
100
+ end
101
+ end
102
+ rescue StandardError
103
+ # Swallow errors during cleanup (connection may have been returned to pool)
104
+ end
105
+
106
+ def registered_connections
107
+ Ec::Pg::SchemaMixin
108
+ .registered_models
109
+ .map(&:connection)
110
+ end
111
+
112
+ # Guards against SQL injection via schema names.
113
+ ValidSchemaRegex = /\A[a-zA-Z][a-zA-Z0-9_$]*\z/
114
+
115
+ def validate_schema_name!(name)
116
+ return if ValidSchemaRegex.match?(name)
117
+
118
+ raise InvalidSchema,
119
+ "Invalid PostgreSQL schema name: #{name.inspect}. " \
120
+ "Only alphanumeric characters and underscores are allowed."
121
+ end
122
+
123
+ module_function :apply_schema, :restore_schema, :validate_schema_name!, :registered_connections
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ec
4
+ module Pg
5
+ # Mixin for ActiveRecord models that participate in multi-tenancy.
6
+ #
7
+ # Include via +acts_as_sharded+ in your model or add it to ApplicationRecord.
8
+ #
9
+ # == Usage
10
+ #
11
+ # class ApplicationRecord < ActiveRecord::Base
12
+ # include Ec::Pg::SchemaMixin
13
+ # acts_as_namespaced
14
+ # end
15
+ #
16
+ # == What it does
17
+ #
18
+ # * Adds an +around_action+-compatible callback that applies RLS before
19
+ # every query on the model when a tenant is active in the thread context.
20
+ # * Provides a +with_tenant+ class method for explicit scoping.
21
+ # * Optionally raises TenantNotSet when required: true and no tenant is set.
22
+ #
23
+ module SchemaMixin
24
+ extend ActiveSupport::Concern
25
+ mattr_accessor :registered_models, default: []
26
+ included do
27
+ class_attribute :is_namespaced, default: false
28
+ end
29
+
30
+ class_methods do
31
+ def acts_as_namespaced
32
+ self.is_namespaced = true
33
+
34
+ register(self)
35
+ end
36
+
37
+ def registered_models
38
+ Ec::Pg::SchemaMixin.registered_models
39
+ end
40
+
41
+ def clear!
42
+ Ec::Pg::SchemaMixin.registered_models = []
43
+ end
44
+
45
+ private
46
+ def register(klass)
47
+ Ec::Pg::SchemaMixin.registered_models += [klass]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,94 @@
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 ShardManager
27
+ class ShardNotFound < StandardError; end
28
+ class UnsupportedActiveRecordVersion < StandardError; end
29
+
30
+ MinimumARVersion = Gem::Version.new("7.1")
31
+
32
+ module_function
33
+
34
+ # Executes +block+ with the AR connection switched to +shard_name+.
35
+ #
36
+ # @param shard_name [Symbol] the registered shard key
37
+ # @param role [Symbol] :writing (default) or :reading
38
+ # @param klass [Class] the AR base class whose connection pool to switch
39
+ # (default: ActiveRecord::Base)
40
+ # @yield block to run in shard context
41
+ # @return the return value of +block+
42
+ def with_shard(shard_name, role: :writing, klass: ActiveRecord::Base, &block)
43
+ shard_name = shard_name.to_sym
44
+ assert_ar_version!
45
+
46
+ Context.with(shard: shard_name) do
47
+ klass.prohibit_shard_swapping(true) do
48
+ klass.connected_to(shard: shard_name, role: role, &block)
49
+ end
50
+ end
51
+ rescue ActiveRecord::ConnectionNotEstablished => e
52
+ raise ShardNotFound,
53
+ "Could not connect to shard #{shard_name.inspect}. " \
54
+ "Make sure it is registered via connects_to. Original error: #{e.message}"
55
+ end
56
+
57
+ # Returns true when the thread is operating inside a +with_shard+ block
58
+ # (or when the shard was set via thread-local assignment).
59
+ def active?
60
+ !Context.shard.nil?
61
+ end
62
+
63
+ # Returns the shard currently set in thread context, or +:default+ when none
64
+ # is set (mirrors how AR treats the default shard).
65
+ def current_shard
66
+ Context.shard || :default
67
+ end
68
+
69
+ # Returns an array of shard names registered on +klass+.
70
+ # def registered_shards(klass: ActiveRecord::Base)
71
+ # klass
72
+ # .connection_handler
73
+ # .connection_pools
74
+ # .map(&:db_config)
75
+ # .map(&:name)
76
+ # rescue StandardError
77
+ # []
78
+ # end
79
+
80
+ private
81
+
82
+ def assert_ar_version!
83
+ ar_version = Gem::Version.new(ActiveRecord.version.to_s)
84
+ return if ar_version >= MinimumARVersion
85
+
86
+ raise UnsupportedActiveRecordVersion,
87
+ "activerecord-multi-tenant shard switching requires ActiveRecord >= 6.1. " \
88
+ "Current version: #{ar_version}"
89
+ end
90
+
91
+ module_function :assert_ar_version!
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ec
4
+ module Pg
5
+ # Mixin for ActiveRecord models that participate in multi-tenancy.
6
+ #
7
+ # Include via +acts_as_sharded+ in your model or add it to ApplicationRecord.
8
+ #
9
+ # == Usage
10
+ #
11
+ # class ApplicationRecord < ActiveRecord::Base
12
+ # include Ec::Pg::ShardMixin
13
+ # acts_as_sharded
14
+ # end
15
+ #
16
+ # == What it does
17
+ #
18
+ # * Adds an +around_action+-compatible callback that applies RLS before
19
+ # every query on the model when a tenant is active in the thread context.
20
+ # * Provides a +with_tenant+ class method for explicit scoping.
21
+ # * Optionally raises TenantNotSet when required: true and no tenant is set.
22
+ #
23
+ module ShardMixin
24
+ extend ActiveSupport::Concern
25
+
26
+ included do
27
+ class_attribute :is_sharded, default: false
28
+ end
29
+
30
+ class_methods do
31
+ # Declares this model as sharded
32
+ #
33
+ # @param mode [Symbol] :solo, or :sharded
34
+ # @param writing_database_identifier [Symbol or String] database used for writing
35
+ # @param reading_database_identifier [Symbol or String] database used for reading (optional)
36
+ def acts_as_sharded(mode:, writing_database_identifier:, reading_database_identifier: nil)
37
+ class_attribute :tenant_mode, default: mode
38
+ class_attribute :writing_database_identifier, default: writing_database_identifier
39
+ class_attribute :reading_database_identifier, default: reading_database_identifier
40
+
41
+ # include InstanceMethods
42
+ # extend QueryMethods
43
+
44
+ self.is_sharded = true
45
+ self.abstract_class = true
46
+ self.connects_to(shards: shards_hash)
47
+ end
48
+
49
+ private
50
+ def shards_hash
51
+ {}.tap do |hash|
52
+ Ec::Pg.configuration.number_of_shards.times do |index|
53
+ shard_key = "shard_#{index + 1}"
54
+ hash[shard_key] = {
55
+ writing: database_identifier_for(writing_database_identifier, index),
56
+ reading: database_identifier_for(reading_database_identifier, index)
57
+ }.compact
58
+ end
59
+ end.symbolize_keys
60
+ end
61
+
62
+ def database_identifier_for(database_identifier, index)
63
+ return unless database_identifier.present?
64
+
65
+ if tenant_mode.to_sym == :solo
66
+ database_identifier
67
+ else
68
+ [database_identifier, "shard", index + 1].join('_')
69
+ end.to_sym
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ec
4
+ module Pg
5
+ module TenantContext
6
+ # Raised when require_tenant is true and a query is attempted without a tenant.
7
+ class TenantNotSet < StandardError; end
8
+
9
+ module_function
10
+
11
+ # -------------------------------------------------------------------------
12
+ # Block-scoped (preferred)
13
+ # -------------------------------------------------------------------------
14
+
15
+ # Executes +block+ within the given tenant context.
16
+ #
17
+ # @param shard [Symbol, nil] shard to connect to (skipped when nil)
18
+ # @param schema [String, nil] Postgres schema to set (skipped when nil)
19
+ def switch(shard: nil, schema: nil, &block)
20
+
21
+ # Layer composition: innermost layer first so that the outermost wrapper
22
+ # controls the connection that the inner layers receive.
23
+
24
+ run = block
25
+
26
+ # 1. Schema (innermost)
27
+ if schema
28
+ inner_run = run
29
+ run = -> {SchemaManager.with_schema(schema, &inner_run)}
30
+ end
31
+
32
+ # 2. Shard (outermost — switches the connection first)
33
+ if shard
34
+ inner_run = run
35
+ run = -> {ShardManager.with_shard(shard, &inner_run)}
36
+ end
37
+
38
+ if run.present?
39
+ run.call
40
+ else
41
+ raise TenantNotSet, "No schema or shard specified"
42
+ end
43
+ end
44
+
45
+ # Convenience alias that shard and only switches schema.
46
+ def with_schema(schema_name, &block)
47
+ SchemaManager.with_schema(schema_name, &block)
48
+ end
49
+
50
+ # Convenience alias that skips schema and only switches shard.
51
+ def with_shard(shard_name, role: :writing, &block)
52
+ ShardManager.with_shard(shard_name, role: role, &block)
53
+ end
54
+
55
+ # -------------------------------------------------------------------------
56
+ # Thread-local (stateful)
57
+ # -------------------------------------------------------------------------
58
+
59
+ def current_shard
60
+ ShardManager.current_shard
61
+ end
62
+
63
+ def current_schema
64
+ SchemaManager.current_schema
65
+ end
66
+
67
+ # Clears all multi-tenant context for the current thread.
68
+ def clear_context!
69
+ Context.clear!
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ec
4
+ module Pg
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/ec/pg.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/concern"
5
+ require_relative "pg/version"
6
+ require_relative "pg/context"
7
+ require_relative "pg/tenant_context"
8
+ require_relative "pg/configuration"
9
+
10
+ if ENV['RAILS_ENV'] == 'test'
11
+ require 'debug'
12
+ end
13
+
14
+ if defined?(Rails::Railtie)
15
+ require_relative "pg/railtie"
16
+ end
17
+
18
+ I18n.load_path += Dir.glob( File.expand_path("../../../config/locales/*.yml", __FILE__) )
19
+
20
+ module Ec
21
+ module Pg
22
+ class Error < StandardError; end
23
+ class InvalidType < Error; end
24
+
25
+ autoload :SchemaMixin, 'ec/pg/schema_mixin.rb'
26
+ autoload :SchemaManager, 'ec/pg/schema_manager.rb'
27
+ autoload :ShardMixin, 'ec/pg/shard_mixin.rb'
28
+ autoload :ShardManager, 'ec/pg/shard_manager.rb'
29
+ autoload :RlsMixin, 'ec/pg/rls_mixin.rb'
30
+ autoload :RlsManager, 'ec/pg/rls_manager.rb'
31
+ autoload :ContextSwitcher, 'ec/pg/middleware/context_switcher.rb'
32
+
33
+ module_function
34
+
35
+ def configure
36
+ yield configuration
37
+ end
38
+
39
+ def configuration
40
+ @configuration ||= Ec::Pg::Configuration.new
41
+ end
42
+
43
+ def switch(shard: nil, schema: nil, &block)
44
+ TenantContext.switch(shard: shard, schema: schema, &block)
45
+ end
46
+
47
+ def current_shard = TenantContext.current_shard
48
+ def current_schema = TenantContext.current_schema
49
+
50
+ # Clears all thread-local context.
51
+ def clear_context! = TenantContext.clear_context!
52
+ end
53
+ end
data/sig/ec/pg.rbs ADDED
@@ -0,0 +1,6 @@
1
+ module Ec
2
+ module Pg
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end