ec-pg 0.1.1 → 0.1.3
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/README.md +92 -0
- data/Rakefile +5 -0
- data/lib/ec/pg/configuration.rb +2 -0
- data/lib/ec/pg/migrator.rb +122 -0
- data/lib/ec/pg/mixin.rb +14 -0
- data/lib/ec/pg/railtie.rb +6 -1
- data/lib/ec/pg/schema_registry/cloner.rb +60 -0
- data/lib/ec/pg/schema_registry/mixin.rb +40 -0
- data/lib/ec/pg/schema_registry/schema_migration_cloner.rb +19 -0
- data/lib/ec/pg/schema_registry_mixin.rb +72 -0
- data/lib/ec/pg/shard_manager.rb +2 -2
- data/lib/ec/pg/shard_mixin.rb +6 -10
- data/lib/ec/pg/version.rb +1 -1
- data/lib/ec/pg.rb +25 -1
- data/lib/tasks/db.rake +8 -0
- data/lib/tasks/ec_pg_migrate.rake +60 -0
- data/lib/tasks/ec_pg_schema.rake +33 -0
- metadata +38 -2
- data/ec-pg-0.1.0.gem +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 615463774272fc429a2f2dfe75db88f133cdd4507b5d5d94147ff84da214a220
|
|
4
|
+
data.tar.gz: f23f77f305c261f719a37a79d9728aba485a7b214332b08abc87abcab2330421
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27e2de6ff3980f2ff75160147fcfc87dd1ad2b5f4f3965bde1601883c23bfa637fc0c38ce93d6b7a3b4f2d537eb8835252026781e162a7324e3951f2e216ae22
|
|
7
|
+
data.tar.gz: f8135d52f5fd32e97a6e992b6930b0a19b14a27d3e926d6ef03ae19df960baf72ca54557c8d8e5dad0184b6f4360b69cae9be961cac084bc452558362fb7e35f
|
data/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<img src="images/stand-with-palestine.webp" alt="Alt Text" width="200" style="float:right">
|
|
2
|
+
|
|
2
3
|
# ec-pg
|
|
3
4
|
|
|
4
5
|
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.
|
|
@@ -200,6 +201,97 @@ Ec::Pg.current_schema # => 'acme'
|
|
|
200
201
|
|
|
201
202
|
---
|
|
202
203
|
|
|
204
|
+
## Schema lifecycle (create / drop)
|
|
205
|
+
|
|
206
|
+
`SchemaRegistry` handles PostgreSQL schema DDL — separate from `SchemaManager` which only handles `search_path` switching.
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# Create a schema (raises SchemaAlreadyExists if it exists)
|
|
210
|
+
Ec::Pg::SchemaRegistry.create!("tenant_abc")
|
|
211
|
+
|
|
212
|
+
# Drop a schema
|
|
213
|
+
Ec::Pg::SchemaRegistry.drop!("tenant_abc")
|
|
214
|
+
Ec::Pg::SchemaRegistry.drop!("tenant_abc", cascade: true) # drop with all objects
|
|
215
|
+
|
|
216
|
+
# Check existence
|
|
217
|
+
Ec::Pg::SchemaRegistry.exists?("tenant_abc") # => true / false
|
|
218
|
+
|
|
219
|
+
# List all non-system schemas
|
|
220
|
+
Ec::Pg::SchemaRegistry.all # => ["public", "tenant_abc", ...]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Rake tasks
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
rake 'ec_pg:schema:create[tenant_abc]'
|
|
227
|
+
rake 'ec_pg:schema:drop[tenant_abc]'
|
|
228
|
+
rake 'ec_pg:schema:drop[tenant_abc,cascade]'
|
|
229
|
+
rake ec_pg:schema:list
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Migrations
|
|
235
|
+
|
|
236
|
+
`Migrator` runs ActiveRecord migrations inside a specific schema or shard context. Schema migrations track `schema_migrations` in the tenant schema (not the public schema), so tenants can be at different versions.
|
|
237
|
+
|
|
238
|
+
### Schema migrations
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# Migrate to latest
|
|
242
|
+
Ec::Pg::Migrator.migrate_schema("tenant_abc")
|
|
243
|
+
|
|
244
|
+
# Migrate to a specific version
|
|
245
|
+
Ec::Pg::Migrator.migrate_schema("tenant_abc", version: 20240101120000)
|
|
246
|
+
|
|
247
|
+
# Migrate a list of schemas
|
|
248
|
+
Ec::Pg::Migrator.migrate_each_schema(["tenant_a", "tenant_b"])
|
|
249
|
+
|
|
250
|
+
# Roll back
|
|
251
|
+
Ec::Pg::Migrator.rollback_schema("tenant_abc") # 1 step
|
|
252
|
+
Ec::Pg::Migrator.rollback_schema("tenant_abc", steps: 3)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Shard migrations
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
# Migrate a single shard
|
|
259
|
+
Ec::Pg::Migrator.migrate_shard(:shard_one)
|
|
260
|
+
|
|
261
|
+
# Migrate all shards (derives :shard_1..:shard_N from configuration.number_of_shards)
|
|
262
|
+
Ec::Pg::Migrator.migrate_each_shard
|
|
263
|
+
|
|
264
|
+
# Migrate an explicit list of shards
|
|
265
|
+
Ec::Pg::Migrator.migrate_each_shard([:shard_one, :shard_two])
|
|
266
|
+
|
|
267
|
+
# Roll back
|
|
268
|
+
Ec::Pg::Migrator.rollback_shard(:shard_one)
|
|
269
|
+
Ec::Pg::Migrator.rollback_shard(:shard_one, steps: 2)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Custom migration paths can be passed to any method via `paths:`:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
Ec::Pg::Migrator.migrate_schema("tenant_abc", paths: ["db/tenant_migrate"])
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Rake tasks
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
rake 'ec_pg:migrate:schema[tenant_abc]'
|
|
282
|
+
rake 'ec_pg:migrate:schemas[tenant_a,tenant_b]'
|
|
283
|
+
rake 'ec_pg:migrate:rollback_schema[tenant_abc]'
|
|
284
|
+
rake 'ec_pg:migrate:rollback_schema[tenant_abc,3]'
|
|
285
|
+
|
|
286
|
+
rake 'ec_pg:migrate:shard[shard_one]'
|
|
287
|
+
rake ec_pg:migrate:shards
|
|
288
|
+
rake 'ec_pg:migrate:shards[shard_one,shard_two]'
|
|
289
|
+
rake 'ec_pg:migrate:rollback_shard[shard_one]'
|
|
290
|
+
rake 'ec_pg:migrate:rollback_shard[shard_one,2]'
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
203
295
|
## Rack middleware
|
|
204
296
|
|
|
205
297
|
Add `ContextSwitcher` to automatically resolve tenant context from each request:
|
data/Rakefile
CHANGED
data/lib/ec/pg/configuration.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Ec
|
|
|
10
10
|
attr_reader :number_of_shards
|
|
11
11
|
attr_reader :get_context_method
|
|
12
12
|
attr_accessor :context_switch_exclude_paths
|
|
13
|
+
attr_accessor :pg_excluded_names
|
|
13
14
|
|
|
14
15
|
def initialize
|
|
15
16
|
self.logger = Logger.new($stdout)
|
|
@@ -17,6 +18,7 @@ module Ec
|
|
|
17
18
|
self.number_of_shards = 1
|
|
18
19
|
self.rls_mode = :local
|
|
19
20
|
self.context_switch_exclude_paths = []
|
|
21
|
+
self.pg_excluded_names = []
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def number_of_shards=(value)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ec
|
|
4
|
+
module Pg
|
|
5
|
+
# Runs ActiveRecord migrations in the context of a specific schema or shard.
|
|
6
|
+
#
|
|
7
|
+
# == Schema migrations
|
|
8
|
+
#
|
|
9
|
+
# Sets search_path to the target schema before running migrations so that
|
|
10
|
+
# Rails creates (and tracks via schema_migrations) tables inside that schema.
|
|
11
|
+
#
|
|
12
|
+
# Migrator.migrate_schema("tenant_abc")
|
|
13
|
+
# Migrator.migrate_schema("tenant_abc", version: 20240101120000)
|
|
14
|
+
# Migrator.rollback_schema("tenant_abc", steps: 2)
|
|
15
|
+
#
|
|
16
|
+
# Migrator.migrate_each_schema(["tenant_a", "tenant_b"])
|
|
17
|
+
#
|
|
18
|
+
# == Shard migrations
|
|
19
|
+
#
|
|
20
|
+
# Connects to the target shard before running migrations.
|
|
21
|
+
#
|
|
22
|
+
# Migrator.migrate_shard(:shard_one)
|
|
23
|
+
# Migrator.rollback_shard(:shard_one, steps: 1)
|
|
24
|
+
#
|
|
25
|
+
# # Migrate all shards derived from configuration.number_of_shards
|
|
26
|
+
# Migrator.migrate_each_shard
|
|
27
|
+
#
|
|
28
|
+
# # Or supply an explicit list
|
|
29
|
+
# Migrator.migrate_each_shard([:shard_one, :shard_two])
|
|
30
|
+
#
|
|
31
|
+
class Migrator
|
|
32
|
+
class << self
|
|
33
|
+
# Runs pending migrations with search_path set to +schema_name+.
|
|
34
|
+
#
|
|
35
|
+
# @param schema_name [String]
|
|
36
|
+
# @param paths [Array<String>] migration paths (defaults to Rails configured paths)
|
|
37
|
+
# @param version [Integer, nil] target version; migrates to latest when nil
|
|
38
|
+
def migrate_schema(connection, schema_name, paths: nil, version: nil)
|
|
39
|
+
Ec::Pg.dprint("Running migration for: #{schema_name}".red)
|
|
40
|
+
connection.schema_search_path = schema_name
|
|
41
|
+
run_migrations(connection, paths: paths, version: version)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def migrate_all_schemas
|
|
45
|
+
Ec::Pg::SchemaMixin.registered_models.each do |model|
|
|
46
|
+
model.schemas.each do |shard, schemas|
|
|
47
|
+
Ec::Pg.switch(shard: shard) do
|
|
48
|
+
schemas.each do |schema|
|
|
49
|
+
Ec::Pg.dprint("migrating model: #{model}, shard: #{shard}, schema: #{schema}")
|
|
50
|
+
migrate_schema(model.connection, schema)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Calls {migrate_schema} for each name in +schema_names+.
|
|
58
|
+
#
|
|
59
|
+
# @param schema_names [Array<String>]
|
|
60
|
+
def migrate_each_schema(connection, schema_names, paths: nil, version: nil)
|
|
61
|
+
schema_names.each { |name| migrate_schema(connection, name, paths: paths, version: version) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Rolls back +steps+ migrations with search_path set to +schema_name+.
|
|
65
|
+
#
|
|
66
|
+
# @param schema_name [String]
|
|
67
|
+
# @param steps [Integer] number of migrations to roll back (default 1)
|
|
68
|
+
def rollback_schema(schema_name, steps: 1, paths: nil)
|
|
69
|
+
SchemaManager.with_schema(schema_name) do
|
|
70
|
+
build_context(paths).rollback(steps)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Runs pending migrations connected to +shard_name+.
|
|
75
|
+
#
|
|
76
|
+
# @param shard_name [Symbol]
|
|
77
|
+
# @param paths [Array<String>]
|
|
78
|
+
# @param version [Integer, nil]
|
|
79
|
+
def migrate_shard(shard_name, paths: nil, version: nil)
|
|
80
|
+
ShardManager.with_shard(shard_name) do
|
|
81
|
+
run_migrations(paths: paths, version: version)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Calls {migrate_shard} for each name in +shard_names+.
|
|
86
|
+
# When +shard_names+ is omitted, derives +:shard_1+ through +:shard_N+
|
|
87
|
+
# from +configuration.number_of_shards+.
|
|
88
|
+
#
|
|
89
|
+
# @param shard_names [Array<Symbol>, nil]
|
|
90
|
+
def migrate_each_shard(shard_names = nil, paths: nil, version: nil)
|
|
91
|
+
(shard_names || default_shards).each do |name|
|
|
92
|
+
migrate_shard(name, paths: paths, version: version)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Rolls back +steps+ migrations connected to +shard_name+.
|
|
97
|
+
#
|
|
98
|
+
# @param shard_name [Symbol]
|
|
99
|
+
# @param steps [Integer]
|
|
100
|
+
def rollback_shard(shard_name, steps: 1, paths: nil)
|
|
101
|
+
ShardManager.with_shard(shard_name) do
|
|
102
|
+
build_context(paths).rollback(steps)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def run_migrations(connection, paths: nil, version: nil)
|
|
109
|
+
Ec::Pg.dprint("migrations_path: #{paths}")
|
|
110
|
+
|
|
111
|
+
ActiveRecord::Base.establish_connection(connection.pool.db_config)
|
|
112
|
+
connection.pool.migration_context.migrate
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def default_shards
|
|
116
|
+
n = Ec::Pg.configuration.number_of_shards
|
|
117
|
+
(1..n).map { |i| :"shard_#{i}" }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/ec/pg/mixin.rb
ADDED
data/lib/ec/pg/railtie.rb
CHANGED
|
@@ -28,10 +28,15 @@ module Ec
|
|
|
28
28
|
#
|
|
29
29
|
# rake_tasks do
|
|
30
30
|
# load 'tasks/ec-pg-ar.rake'
|
|
31
|
-
# if
|
|
31
|
+
# if Ec::Pg.configuration.db_migrate_tenants
|
|
32
32
|
# require_relative '../support/migration_tasks_enhancer'
|
|
33
33
|
# end
|
|
34
34
|
# end
|
|
35
|
+
|
|
36
|
+
rake_tasks do
|
|
37
|
+
load File.expand_path("../../tasks/ec_pg_migrate.rake", __dir__)
|
|
38
|
+
load File.expand_path("../../tasks/ec_pg_schema.rake", __dir__)
|
|
39
|
+
end
|
|
35
40
|
end
|
|
36
41
|
end
|
|
37
42
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ec
|
|
4
|
+
module Pg
|
|
5
|
+
module SchemaRegistry
|
|
6
|
+
class Cloner < Struct.new(:connection, :schema, keyword_init: true)
|
|
7
|
+
include Mixin
|
|
8
|
+
|
|
9
|
+
PgDumpBlacklistedStatements = [
|
|
10
|
+
/SET search_path/i,
|
|
11
|
+
/SET lock_timeout/i,
|
|
12
|
+
/SET row_security/i,
|
|
13
|
+
/SET idle_in_transaction_session_timeout/i,
|
|
14
|
+
/CREATE SCHEMA #{Ec::Pg.configuration.default_schema}/i,
|
|
15
|
+
/COMMENT ON SCHEMA #{Ec::Pg.configuration.default_schema}/i
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
connection.execute(
|
|
20
|
+
patched_search_path(default_schema_dump)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def patched_search_path(sql)
|
|
25
|
+
search_path = %[SET search_path = "#{schema}", #{Ec::Pg.configuration.default_schema};]
|
|
26
|
+
|
|
27
|
+
swap_schema_qualifier(sql)
|
|
28
|
+
.split("\n")
|
|
29
|
+
.reject{|line| line =~ /\\(un)?restrict/}
|
|
30
|
+
.reject{|line| matches_blacklist?(line, PgDumpBlacklistedStatements).present?}
|
|
31
|
+
.prepend(search_path)
|
|
32
|
+
.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def default_schema_dump
|
|
36
|
+
# TODO: check exit code of pg_dump
|
|
37
|
+
with_pg_env {
|
|
38
|
+
shell_command(
|
|
39
|
+
%(pg_dump -s -x -O -n #{Ec::Pg.configuration.default_schema} #{configurations[:database]})
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def swap_schema_qualifier(sql)
|
|
45
|
+
sql.gsub(/#{Ec::Pg.configuration.default_schema}\.\w*/) do |match|
|
|
46
|
+
if Ec::Pg.configuration.pg_excluded_names.any? {|name| match.include? name}
|
|
47
|
+
match
|
|
48
|
+
else
|
|
49
|
+
match.gsub("#{Ec::Pg.configuration.default_schema}.", %("#{schema}".))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def matches_blacklist?(input, regexps)
|
|
55
|
+
regexps.select {|c| input.match(c)}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ec
|
|
4
|
+
module Pg
|
|
5
|
+
module SchemaRegistry
|
|
6
|
+
module Mixin
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
def with_pg_env(&block)
|
|
10
|
+
pghost = ENV['PGHOST']
|
|
11
|
+
pgport = ENV['PGPORT']
|
|
12
|
+
pguser = ENV['PGUSER']
|
|
13
|
+
pgpassword = ENV['PGPASSWORD']
|
|
14
|
+
|
|
15
|
+
ENV['PGUSER'] = configurations[:superusername] if configurations[:superusername]
|
|
16
|
+
ENV['PGHOST'] = configurations[:host] if configurations[:host]
|
|
17
|
+
ENV['PGPORT'] = configurations[:port].to_s if configurations[:port]
|
|
18
|
+
ENV['PGPASSWORD'] = configurations[:password].to_s if configurations[:password]
|
|
19
|
+
|
|
20
|
+
block.call
|
|
21
|
+
ensure
|
|
22
|
+
ENV['PGHOST'] = pghost
|
|
23
|
+
ENV['PGPORT'] = pgport
|
|
24
|
+
ENV['PGUSER'] = pguser
|
|
25
|
+
ENV['PGPASSWORD'] = pgpassword
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def shell_command(command)
|
|
29
|
+
puts command
|
|
30
|
+
%x(#{command})
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def configurations
|
|
34
|
+
@configurations ||= connection.pool.db_config.configuration_hash
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ec
|
|
4
|
+
module Pg
|
|
5
|
+
module SchemaRegistry
|
|
6
|
+
class SchemaMigrationCloner < Cloner
|
|
7
|
+
def default_schema_dump
|
|
8
|
+
with_pg_env {
|
|
9
|
+
shell_command(
|
|
10
|
+
%(
|
|
11
|
+
pg_dump -a --inserts -t #{Ec::Pg.configuration.default_schema}.schema_migrations -t #{Ec::Pg.configuration.default_schema}.ar_internal_metadata #{configurations[:database]}
|
|
12
|
+
)
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'schema_registry/mixin'
|
|
4
|
+
require_relative 'schema_registry/cloner'
|
|
5
|
+
require_relative 'schema_registry/schema_migration_cloner'
|
|
6
|
+
|
|
7
|
+
module Ec
|
|
8
|
+
module Pg
|
|
9
|
+
module SchemaRegistryMixin
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
include SchemaRegistry::Mixin
|
|
12
|
+
|
|
13
|
+
class SchemaAlreadyExists < StandardError; end
|
|
14
|
+
class SchemaNotFound < StandardError; end
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
def create_schema!(shard: nil, schema: schema)
|
|
18
|
+
Ec::Pg.switch(shard: shard) do
|
|
19
|
+
validate_schema_name!(schema)
|
|
20
|
+
|
|
21
|
+
if connection.schema_exists?(schema)
|
|
22
|
+
raise SchemaAlreadyExists, "Cannot create existing schema '#{schema}'"
|
|
23
|
+
end
|
|
24
|
+
connection.execute(%{CREATE SCHEMA "#{schema}"})
|
|
25
|
+
|
|
26
|
+
Ec::Pg.switch(schema: schema) do
|
|
27
|
+
SchemaRegistry::Cloner.new(connection: connection, schema: schema).call
|
|
28
|
+
SchemaRegistry::SchemaMigrationCloner.new(connection: connection, schema: schema).call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
rescue ::ActiveRecord::ActiveRecordError, PG::Error => exception
|
|
32
|
+
raise SchemaAlreadyExists, "Error while creating tenant #{tenant}: #{ exception.message }"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def drop_schema!(shard: nil, schema: schema)
|
|
37
|
+
Ec::Pg.switch(shard: shard) do
|
|
38
|
+
validate_schema_name!(schema)
|
|
39
|
+
|
|
40
|
+
if connection.schema_exists?(schema)
|
|
41
|
+
connection.execute(%{DROP SCHEMA "#{schema}" CASCADE})
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
rescue ::ActiveRecord::ActiveRecordError, PG::Error => exception
|
|
46
|
+
raise SchemaNotFound, "Error while dropping schema #{schema}: #{exception.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns all non-system PostgreSQL schema names, sorted alphabetically.
|
|
50
|
+
#
|
|
51
|
+
# @return {'shard_1': ['schema1', 'schema2']}
|
|
52
|
+
def schemas
|
|
53
|
+
{}.tap do |hash|
|
|
54
|
+
Ec::Pg.each_shard do |shard|
|
|
55
|
+
hash[shard] = connection.execute(<<~SQL).column_values(0)
|
|
56
|
+
SELECT schema_name
|
|
57
|
+
FROM information_schema.schemata
|
|
58
|
+
WHERE schema_name NOT IN ('information_schema')
|
|
59
|
+
AND schema_name NOT LIKE 'pg_%'
|
|
60
|
+
ORDER BY schema_name
|
|
61
|
+
SQL
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_schema_name!(schema_name)
|
|
67
|
+
SchemaManager.validate_schema_name!(schema_name)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/ec/pg/shard_manager.rb
CHANGED
|
@@ -44,8 +44,8 @@ module Ec
|
|
|
44
44
|
assert_ar_version!
|
|
45
45
|
|
|
46
46
|
Context.with(shard: shard_name) do
|
|
47
|
-
klass.
|
|
48
|
-
klass.
|
|
47
|
+
klass.connected_to(shard: shard_name, role: role) do
|
|
48
|
+
klass.prohibit_shard_swapping(true, &block)
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
rescue ActiveRecord::ConnectionNotEstablished => e
|
data/lib/ec/pg/shard_mixin.rb
CHANGED
|
@@ -38,9 +38,6 @@ module Ec
|
|
|
38
38
|
class_attribute :writing_database_identifier, default: writing_database_identifier
|
|
39
39
|
class_attribute :reading_database_identifier, default: reading_database_identifier
|
|
40
40
|
|
|
41
|
-
# include InstanceMethods
|
|
42
|
-
# extend QueryMethods
|
|
43
|
-
|
|
44
41
|
self.is_sharded = true
|
|
45
42
|
self.abstract_class = true
|
|
46
43
|
self.connects_to(shards: shards_hash)
|
|
@@ -49,23 +46,22 @@ module Ec
|
|
|
49
46
|
private
|
|
50
47
|
def shards_hash
|
|
51
48
|
{}.tap do |hash|
|
|
52
|
-
Ec::Pg.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
reading: database_identifier_for(reading_database_identifier, index)
|
|
49
|
+
Ec::Pg.shards.each do |shard|
|
|
50
|
+
hash[shard] = {
|
|
51
|
+
writing: database_identifier_for(writing_database_identifier, shard),
|
|
52
|
+
reading: database_identifier_for(reading_database_identifier, shard)
|
|
57
53
|
}.compact
|
|
58
54
|
end
|
|
59
55
|
end.symbolize_keys
|
|
60
56
|
end
|
|
61
57
|
|
|
62
|
-
def database_identifier_for(database_identifier,
|
|
58
|
+
def database_identifier_for(database_identifier, shard)
|
|
63
59
|
return unless database_identifier.present?
|
|
64
60
|
|
|
65
61
|
if tenant_mode.to_sym == :solo
|
|
66
62
|
database_identifier
|
|
67
63
|
else
|
|
68
|
-
[database_identifier,
|
|
64
|
+
[database_identifier, shard].join('_')
|
|
69
65
|
end.to_sym
|
|
70
66
|
end
|
|
71
67
|
end
|
data/lib/ec/pg/version.rb
CHANGED
data/lib/ec/pg.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_record"
|
|
4
4
|
require "active_support/concern"
|
|
5
|
+
require "colorize"
|
|
5
6
|
require_relative "pg/version"
|
|
6
7
|
require_relative "pg/context"
|
|
7
8
|
require_relative "pg/tenant_context"
|
|
@@ -22,16 +23,25 @@ module Ec
|
|
|
22
23
|
class Error < StandardError; end
|
|
23
24
|
class InvalidType < Error; end
|
|
24
25
|
|
|
26
|
+
autoload :Mixin, 'ec/pg/mixin.rb'
|
|
25
27
|
autoload :SchemaMixin, 'ec/pg/schema_mixin.rb'
|
|
26
28
|
autoload :SchemaManager, 'ec/pg/schema_manager.rb'
|
|
27
29
|
autoload :ShardMixin, 'ec/pg/shard_mixin.rb'
|
|
28
30
|
autoload :ShardManager, 'ec/pg/shard_manager.rb'
|
|
29
31
|
autoload :RlsMixin, 'ec/pg/rls_mixin.rb'
|
|
30
32
|
autoload :RlsManager, 'ec/pg/rls_manager.rb'
|
|
31
|
-
autoload :ContextSwitcher,
|
|
33
|
+
autoload :ContextSwitcher, 'ec/pg/middleware/context_switcher.rb'
|
|
34
|
+
autoload :SchemaRegistryMixin, 'ec/pg/schema_registry_mixin.rb'
|
|
35
|
+
autoload :Migrator, 'ec/pg/migrator.rb'
|
|
32
36
|
|
|
33
37
|
module_function
|
|
34
38
|
|
|
39
|
+
def dprint(*args)
|
|
40
|
+
if ENV['ECPG_DEBUG']
|
|
41
|
+
puts args
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
35
45
|
def configure
|
|
36
46
|
yield configuration
|
|
37
47
|
end
|
|
@@ -44,6 +54,20 @@ module Ec
|
|
|
44
54
|
TenantContext.switch(shard: shard, schema: schema, &block)
|
|
45
55
|
end
|
|
46
56
|
|
|
57
|
+
def shards
|
|
58
|
+
@shards ||= configuration.number_of_shards.times.map do |index|
|
|
59
|
+
"shard_#{index + 1}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def each_shard(&block)
|
|
64
|
+
shards.each do |shard|
|
|
65
|
+
Ec::Pg.switch(shard: shard) do
|
|
66
|
+
yield(shard)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
47
71
|
def current_shard = TenantContext.current_shard
|
|
48
72
|
def current_schema = TenantContext.current_schema
|
|
49
73
|
|
data/lib/tasks/db.rake
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :ec_pg do
|
|
4
|
+
# ---------------------------------------------------------------------------
|
|
5
|
+
# Migrations
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
namespace :migrate do
|
|
8
|
+
task preload_models: :environment do
|
|
9
|
+
if ENV['CPG_DEBUG']
|
|
10
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
Dir[Rails.root.join('app/models/**/*.rb')].each do |file|
|
|
14
|
+
require file
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "Migrate a single schema. Usage: rake 'ec_pg:migrate:schema[name]'"
|
|
19
|
+
task :schema, [:name] => :environment do |_, args|
|
|
20
|
+
name = args[:name] or abort "Usage: rake 'ec_pg:migrate:schema[schema_name]'"
|
|
21
|
+
Ec::Pg::Migrator.migrate_schema(name)
|
|
22
|
+
puts "Schema '#{name}' migrated."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Migrate comma-separated schemas. Usage: rake 'ec_pg:migrate:schemas[tenant_a,tenant_b]'"
|
|
26
|
+
task :schemas => :preload_models do
|
|
27
|
+
Ec::Pg::Migrator.migrate_all_schemas
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "Roll back a schema. Usage: rake 'ec_pg:migrate:rollback_schema[name]' or '[name,2]'"
|
|
31
|
+
task :rollback_schema, [:name, :steps] => :environment do |_, args|
|
|
32
|
+
name = args[:name] or abort "Usage: rake 'ec_pg:migrate:rollback_schema[name]'"
|
|
33
|
+
steps = args[:steps]&.to_i || 1
|
|
34
|
+
Ec::Pg::Migrator.rollback_schema(name, steps: steps)
|
|
35
|
+
puts "Rolled back #{steps} migration(s) in schema '#{name}'."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "Migrate a single shard. Usage: rake 'ec_pg:migrate:shard[shard_one]'"
|
|
39
|
+
task :shard, [:name] => :environment do |_, args|
|
|
40
|
+
name = args[:name] or abort "Usage: rake 'ec_pg:migrate:shard[shard_name]'"
|
|
41
|
+
Ec::Pg::Migrator.migrate_shard(name.to_sym)
|
|
42
|
+
puts "Shard '#{name}' migrated."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
desc "Migrate all shards (derived from configuration.number_of_shards, or explicit list)"
|
|
46
|
+
task :shards, [:names] => :environment do |_, args|
|
|
47
|
+
shards = args[:names]&.split(",")&.map { |s| s.strip.to_sym }
|
|
48
|
+
Ec::Pg::Migrator.migrate_each_shard(shards)
|
|
49
|
+
puts "All shards migrated."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc "Roll back a shard. Usage: rake 'ec_pg:migrate:rollback_shard[shard_one]' or '[shard_one,2]'"
|
|
53
|
+
task :rollback_shard, [:name, :steps] => :environment do |_, args|
|
|
54
|
+
name = args[:name] or abort "Usage: rake 'ec_pg:migrate:rollback_shard[name]'"
|
|
55
|
+
steps = args[:steps]&.to_i || 1
|
|
56
|
+
Ec::Pg::Migrator.rollback_shard(name.to_sym, steps: steps)
|
|
57
|
+
puts "Rolled back #{steps} migration(s) in shard '#{name}'."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :ec_pg do
|
|
4
|
+
# ---------------------------------------------------------------------------
|
|
5
|
+
# Schema DDL
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
namespace :schema do
|
|
8
|
+
desc "Create a PostgreSQL schema. Usage: rake 'ec_pg:schema:create[name]'"
|
|
9
|
+
task :create, [:model_class, :shard, :schema_name] => :environment do |_, args|
|
|
10
|
+
model = args[:model_class].constantize
|
|
11
|
+
shard = args[:shard]
|
|
12
|
+
schema = args[:schema_name]
|
|
13
|
+
|
|
14
|
+
unless shard.present? && schema.present?
|
|
15
|
+
abort "Usage: rake 'ec_pg:schema:create[Some::ApplicationRecord,shard_1,schema_name]"
|
|
16
|
+
end
|
|
17
|
+
model.create_schema!(shard: shard, schema: schema)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "Drop a PostgreSQL schema. Usage: rake 'ec_pg:schema:drop[name]' or 'ec_pg:schema:drop[name,cascade]'"
|
|
21
|
+
task :drop, [:name, :cascade] => :environment do |_, args|
|
|
22
|
+
name = args[:name] or abort "Usage: rake 'ec_pg:schema:drop[schema_name]'"
|
|
23
|
+
cascade = args[:cascade]&.casecmp?("true") || false
|
|
24
|
+
Ec::Pg::SchemaRegistry.drop!(name, cascade: cascade)
|
|
25
|
+
puts "Schema '#{name}' dropped."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "List all non-system PostgreSQL schemas"
|
|
29
|
+
task list: :environment do
|
|
30
|
+
Ec::Pg::SchemaRegistry.all.each { |s| puts s }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ec-pg
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- gmhawash
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: colorize
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: rails
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -107,6 +121,20 @@ dependencies:
|
|
|
107
121
|
- - ">="
|
|
108
122
|
- !ruby/object:Gem::Version
|
|
109
123
|
version: '0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: database_cleaner-active_record
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
type: :development
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
110
138
|
description: Multitenant implemntations using shards, schemas and RLS
|
|
111
139
|
email:
|
|
112
140
|
- gmhawash@gmail.com
|
|
@@ -120,21 +148,29 @@ files:
|
|
|
120
148
|
- README.md
|
|
121
149
|
- Rakefile
|
|
122
150
|
- config/locales/en.yml
|
|
123
|
-
- ec-pg-0.1.0.gem
|
|
124
151
|
- images/stand-with-palestine.webp
|
|
125
152
|
- lib/ec/pg.rb
|
|
126
153
|
- lib/ec/pg/configuration.rb
|
|
127
154
|
- lib/ec/pg/context.rb
|
|
128
155
|
- lib/ec/pg/middleware/context_switcher.rb
|
|
156
|
+
- lib/ec/pg/migrator.rb
|
|
157
|
+
- lib/ec/pg/mixin.rb
|
|
129
158
|
- lib/ec/pg/railtie.rb
|
|
130
159
|
- lib/ec/pg/rls_manager.rb
|
|
131
160
|
- lib/ec/pg/rls_mixin.rb
|
|
132
161
|
- lib/ec/pg/schema_manager.rb
|
|
133
162
|
- lib/ec/pg/schema_mixin.rb
|
|
163
|
+
- lib/ec/pg/schema_registry/cloner.rb
|
|
164
|
+
- lib/ec/pg/schema_registry/mixin.rb
|
|
165
|
+
- lib/ec/pg/schema_registry/schema_migration_cloner.rb
|
|
166
|
+
- lib/ec/pg/schema_registry_mixin.rb
|
|
134
167
|
- lib/ec/pg/shard_manager.rb
|
|
135
168
|
- lib/ec/pg/shard_mixin.rb
|
|
136
169
|
- lib/ec/pg/tenant_context.rb
|
|
137
170
|
- lib/ec/pg/version.rb
|
|
171
|
+
- lib/tasks/db.rake
|
|
172
|
+
- lib/tasks/ec_pg_migrate.rake
|
|
173
|
+
- lib/tasks/ec_pg_schema.rake
|
|
138
174
|
- sig/ec/pg.rbs
|
|
139
175
|
homepage: https://github.com/binnablus/ec-pg
|
|
140
176
|
licenses:
|
data/ec-pg-0.1.0.gem
DELETED
|
Binary file
|