activerecord-postgresql-branched 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/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +149 -0
- data/lib/active_record/connection_adapters/postgresql/branched/adapter.rb +66 -0
- data/lib/active_record/connection_adapters/postgresql/branched/branch_manager.rb +161 -0
- data/lib/active_record/connection_adapters/postgresql/branched/railtie.rb +96 -0
- data/lib/active_record/connection_adapters/postgresql/branched/schema_dumper.rb +65 -0
- data/lib/active_record/connection_adapters/postgresql/branched/shadow.rb +51 -0
- data/lib/activerecord-postgresql-branched.rb +8 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 39dd95c063dde3c85a789ac3d6b5a0775bd0c95e39b7e68f2a2c33f9d50b645f
|
|
4
|
+
data.tar.gz: 2eaf350fe45a3cbb60aa16c2024c51af3e219ac99f8bc3e02e46fef4c68af2ec
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5b9b1813347f21ce285180b6ac2e9f3a88e1e752ec9521fc615dcd7ee8392310b3ac81c60875cdc4861b6c86db0497c0274d8a405e070e38ae6ac2450ec28592
|
|
7
|
+
data.tar.gz: 0fa9c515e10ff4a8cdf21537b5b271198f8b804df8e14fba9ea06afdb830052423b7c014781476fd6600da8188009a89298381aa888ead775ce8bf57aaf6129f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- PostgreSQL adapter that isolates each git branch in its own Postgres schema
|
|
8
|
+
- Shadow rule: automatically copies public tables before branch-local DDL
|
|
9
|
+
- Clean `schema.rb` output with no branch schema references
|
|
10
|
+
- `schema_migrations` and `ar_internal_metadata` isolated per branch
|
|
11
|
+
- Schema names truncated to 63 bytes with collision-safe hashing
|
|
12
|
+
- Rake tasks: `db:branch:reset`, `discard`, `list`, `diff`, `prune`
|
|
13
|
+
- Branch override via `branch_override` config or `PGBRANCH` env var
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carl Dawson
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# activerecord-postgresql-branched
|
|
2
|
+
|
|
3
|
+
Database isolation for parallel development. Each git branch (or agent) gets its own Postgres schema. Migrations run in isolation. Nobody steps on anyone else's work.
|
|
4
|
+
|
|
5
|
+
Built for teams running multiple AI coding agents in parallel, each on its own worktree, all sharing one database.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
You have three agents working simultaneously in three worktrees:
|
|
10
|
+
|
|
11
|
+
- `agent-0` adds a `payments` table and alters `users`
|
|
12
|
+
- `agent-1` adds a `bio` column to `users`
|
|
13
|
+
- `agent-2` drops a legacy table and adds indexes
|
|
14
|
+
|
|
15
|
+
They share one dev database. Every migration collides. Every agent breaks every other agent. You spend more time untangling the database than reviewing the code.
|
|
16
|
+
|
|
17
|
+
This adapter makes branching the database as cheap as branching the code.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Gemfile
|
|
23
|
+
gem 'activerecord-postgresql-branched'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
# config/database.yml
|
|
28
|
+
development:
|
|
29
|
+
adapter: postgresql_branched
|
|
30
|
+
database: myapp_development
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That's it. No Postgres extensions, no environment variables, no initializers.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
On connection, the adapter reads the current git branch, creates a dedicated Postgres schema for it, and sets `search_path`:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
git branch: feature/payments
|
|
41
|
+
schema: branch_feature_payments
|
|
42
|
+
search_path: branch_feature_payments, public
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
New tables go into the branch schema. Queries against existing tables fall through to `public` via standard Postgres name resolution.
|
|
46
|
+
|
|
47
|
+
When a migration modifies a table that exists in `public`, the adapter shadows it first -- copies the table and its data into the branch schema, then applies the DDL to the copy. The public table is never touched.
|
|
48
|
+
|
|
49
|
+
On the primary branch (`main` by default), the adapter stands aside entirely. Migrations land in `public` as normal. When `public` advances, every branch sees the changes immediately via `search_path` fallthrough.
|
|
50
|
+
|
|
51
|
+
## Agentic workflows
|
|
52
|
+
|
|
53
|
+
Give each agent its own branch identity:
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
# config/database.yml
|
|
57
|
+
development:
|
|
58
|
+
adapter: postgresql_branched
|
|
59
|
+
database: myapp_development
|
|
60
|
+
branch_override: <%= ENV.fetch("AGENT_BRANCH", nil) %>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Launch agents with isolated schemas
|
|
65
|
+
AGENT_BRANCH=agent-0 bundle exec rails ...
|
|
66
|
+
AGENT_BRANCH=agent-1 bundle exec rails ...
|
|
67
|
+
AGENT_BRANCH=agent-2 bundle exec rails ...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or in a worktree-per-agent setup, each worktree is on its own git branch and the adapter picks it up automatically. No configuration needed beyond the adapter name.
|
|
71
|
+
|
|
72
|
+
### Cleanup
|
|
73
|
+
|
|
74
|
+
After agents finish:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
rails db:branch:prune
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Compares branch schemas against `git branch --list` and drops any that no longer have a corresponding local branch. One command, all stale schemas gone.
|
|
81
|
+
|
|
82
|
+
For specific branches:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
rails db:branch:discard BRANCH=agent-0
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Rake tasks
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
rails db:branch:reset # drop and recreate current branch schema
|
|
92
|
+
rails db:branch:discard # drop current branch schema (or BRANCH=name)
|
|
93
|
+
rails db:branch:list # list all branch schemas and their sizes
|
|
94
|
+
rails db:branch:diff # show objects in this branch vs public
|
|
95
|
+
rails db:branch:prune # drop schemas for branches no longer in git
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
development:
|
|
102
|
+
adapter: postgresql_branched
|
|
103
|
+
database: myapp_development
|
|
104
|
+
primary_branch: main # default, can be 'master', 'trunk', etc.
|
|
105
|
+
branch_override: agent-0 # bypass git, set branch explicitly
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The `PGBRANCH` or `BRANCH` environment variables also work for explicit branch selection.
|
|
109
|
+
|
|
110
|
+
## Rebasing
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
git fetch && git rebase origin/main
|
|
114
|
+
rails db:branch:reset
|
|
115
|
+
rails db:migrate
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`db:branch:reset` drops the branch schema. `search_path` falls through to the updated `public`. Re-running `db:migrate` reapplies your branch's migrations on top of the new baseline.
|
|
119
|
+
|
|
120
|
+
## The merge story
|
|
121
|
+
|
|
122
|
+
The adapter does not merge. Git does.
|
|
123
|
+
|
|
124
|
+
1. Agent writes migrations on its branch
|
|
125
|
+
2. Adapter isolates them in a branch schema automatically
|
|
126
|
+
3. PR merged into `main`
|
|
127
|
+
4. Team pulls `main`, runs `db:migrate`
|
|
128
|
+
5. Adapter stands aside (primary branch), migrations land in `public`
|
|
129
|
+
6. `schema.rb` updated and committed
|
|
130
|
+
7. All active branch schemas see updated `public` via fallthrough
|
|
131
|
+
|
|
132
|
+
## schema.rb
|
|
133
|
+
|
|
134
|
+
`db:schema:dump` presents a unified view of the current branch as if everything lived in `public`:
|
|
135
|
+
|
|
136
|
+
- Branch-local tables appear without the `branch_` prefix
|
|
137
|
+
- Shadowed tables show the branch version
|
|
138
|
+
- Public tables the branch hasn't touched are included as normal
|
|
139
|
+
- No schema references, no branch artifacts
|
|
140
|
+
|
|
141
|
+
The diff for a schema change looks exactly as it always has.
|
|
142
|
+
|
|
143
|
+
## Limitations
|
|
144
|
+
|
|
145
|
+
- **Rails + Postgres only** -- uses Postgres schemas and `search_path`
|
|
146
|
+
- **Dev only** -- production and staging should use the standard `postgresql` adapter
|
|
147
|
+
- **No `schema_search_path` in database.yml** -- it will conflict with the adapter
|
|
148
|
+
- **Non-ActiveRecord DDL** -- raw SQL outside of migrations bypasses the shadow rule
|
|
149
|
+
- **Sequences on shadowed tables** -- `rename_table` on a shadowed table with serial columns works, but the sequence keeps its original name
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module ActiveRecord
|
|
2
|
+
module ConnectionAdapters
|
|
3
|
+
module PostgreSQL
|
|
4
|
+
module Branched
|
|
5
|
+
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
|
6
|
+
ADAPTER_NAME = "PostgreSQL Branched"
|
|
7
|
+
|
|
8
|
+
SHADOW_BEFORE = %i[
|
|
9
|
+
add_column
|
|
10
|
+
remove_column
|
|
11
|
+
rename_column
|
|
12
|
+
change_column
|
|
13
|
+
change_column_default
|
|
14
|
+
change_column_null
|
|
15
|
+
change_column_comment
|
|
16
|
+
change_table_comment
|
|
17
|
+
add_index
|
|
18
|
+
remove_index
|
|
19
|
+
rename_index
|
|
20
|
+
drop_table
|
|
21
|
+
change_table
|
|
22
|
+
bulk_change_table
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(...)
|
|
26
|
+
super
|
|
27
|
+
@branch_manager = BranchManager.new(self, @config)
|
|
28
|
+
@shadow = Shadow.new(self, @branch_manager.branch_schema) unless @branch_manager.primary_branch?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configure_connection
|
|
32
|
+
super
|
|
33
|
+
@branch_manager.activate
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
SHADOW_BEFORE.each do |method|
|
|
37
|
+
define_method(method) do |table_name, *args, **kwargs, &block|
|
|
38
|
+
@shadow&.call(table_name)
|
|
39
|
+
super(table_name, *args, **kwargs, &block)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# rename_table needs special handling: the shadow table's sequences
|
|
44
|
+
# live in public, but Rails' rename_table tries to rename them using
|
|
45
|
+
# the branch schema. The table and index renames succeed before the
|
|
46
|
+
# sequence rename fails, so we rescue the sequence error.
|
|
47
|
+
def rename_table(table_name, new_name, **options)
|
|
48
|
+
@shadow&.call(table_name)
|
|
49
|
+
super
|
|
50
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
51
|
+
raise if @branch_manager.primary_branch?
|
|
52
|
+
raise unless e.cause.is_a?(PG::UndefinedTable)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_reader :branch_manager
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
63
|
+
"postgresql_branched",
|
|
64
|
+
"ActiveRecord::ConnectionAdapters::PostgreSQL::Branched::Adapter",
|
|
65
|
+
"activerecord-postgresql-branched"
|
|
66
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
module ActiveRecord
|
|
2
|
+
module ConnectionAdapters
|
|
3
|
+
module PostgreSQL
|
|
4
|
+
module Branched
|
|
5
|
+
class BranchManager
|
|
6
|
+
attr_reader :branch, :branch_schema
|
|
7
|
+
|
|
8
|
+
def initialize(connection, config)
|
|
9
|
+
@connection = connection
|
|
10
|
+
@config = config
|
|
11
|
+
@branch = resolve_branch
|
|
12
|
+
@branch_schema = self.class.sanitise(@branch)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def activate
|
|
16
|
+
return if primary_branch?
|
|
17
|
+
|
|
18
|
+
ensure_schema
|
|
19
|
+
set_search_path
|
|
20
|
+
shadow_migration_tables
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def primary_branch?
|
|
24
|
+
@branch == primary_branch_name
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reset
|
|
28
|
+
drop_schema
|
|
29
|
+
ensure_schema
|
|
30
|
+
set_search_path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def discard(branch_name = @branch)
|
|
34
|
+
schema = self.class.sanitise(branch_name)
|
|
35
|
+
|
|
36
|
+
if schema == self.class.sanitise(primary_branch_name)
|
|
37
|
+
raise "Cannot discard the primary branch schema"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@connection.execute("DROP SCHEMA IF EXISTS #{quote(schema)} CASCADE")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def list
|
|
44
|
+
@connection.select_rows(<<~SQL)
|
|
45
|
+
SELECT s.schema_name,
|
|
46
|
+
COALESCE(pg_size_pretty(sum(pg_total_relation_size(
|
|
47
|
+
quote_ident(t.table_schema) || '.' || quote_ident(t.table_name)
|
|
48
|
+
))), '0 bytes') AS size
|
|
49
|
+
FROM information_schema.schemata s
|
|
50
|
+
LEFT JOIN information_schema.tables t
|
|
51
|
+
ON t.table_schema = s.schema_name AND t.table_type = 'BASE TABLE'
|
|
52
|
+
WHERE s.schema_name LIKE 'branch_%'
|
|
53
|
+
GROUP BY s.schema_name
|
|
54
|
+
ORDER BY s.schema_name
|
|
55
|
+
SQL
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def diff
|
|
59
|
+
return [] if primary_branch?
|
|
60
|
+
|
|
61
|
+
@connection.select_values(<<~SQL)
|
|
62
|
+
SELECT table_name FROM information_schema.tables
|
|
63
|
+
WHERE table_schema = #{@connection.quote(@branch_schema)}
|
|
64
|
+
AND table_type = 'BASE TABLE'
|
|
65
|
+
ORDER BY table_name
|
|
66
|
+
SQL
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
MAX_SCHEMA_LENGTH = 63
|
|
70
|
+
PREFIX = "branch_"
|
|
71
|
+
|
|
72
|
+
def self.sanitise(branch)
|
|
73
|
+
slug = branch.downcase.gsub(/[\/\-\.]/, "_").gsub(/[^a-z0-9_]/, "")
|
|
74
|
+
schema = PREFIX + slug
|
|
75
|
+
|
|
76
|
+
return schema if schema.bytesize <= MAX_SCHEMA_LENGTH
|
|
77
|
+
|
|
78
|
+
# Truncate and append a short hash to avoid collisions
|
|
79
|
+
hash = Digest::SHA256.hexdigest(slug)[0, 8]
|
|
80
|
+
max_slug = MAX_SCHEMA_LENGTH - PREFIX.bytesize - 9 # 9 = underscore + 8 char hash
|
|
81
|
+
PREFIX + slug[0, max_slug] + "_" + hash
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def prune(keep: nil)
|
|
85
|
+
active_schemas = if keep
|
|
86
|
+
Array(keep).map { |b| self.class.sanitise(b) }.to_set
|
|
87
|
+
else
|
|
88
|
+
git_branches = `git branch --list 2>/dev/null`.lines.map { |l| l.strip.delete_prefix("* ") }
|
|
89
|
+
if git_branches.empty?
|
|
90
|
+
raise "No git branches found. Pass branch names explicitly: prune(keep: ['main', 'feature/x'])"
|
|
91
|
+
end
|
|
92
|
+
git_branches.map { |b| self.class.sanitise(b) }.to_set
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
all_branch_schemas = @connection.select_values(<<~SQL)
|
|
96
|
+
SELECT schema_name FROM information_schema.schemata
|
|
97
|
+
WHERE schema_name LIKE 'branch_%'
|
|
98
|
+
SQL
|
|
99
|
+
|
|
100
|
+
stale = all_branch_schemas.reject { |s| active_schemas.include?(s) }
|
|
101
|
+
stale.each do |schema|
|
|
102
|
+
@connection.execute("DROP SCHEMA IF EXISTS #{quote(schema)} CASCADE")
|
|
103
|
+
end
|
|
104
|
+
stale
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.resolve_branch_name(config)
|
|
108
|
+
config[:branch_override]&.to_s ||
|
|
109
|
+
ENV["BRANCH"] ||
|
|
110
|
+
ENV["PGBRANCH"] ||
|
|
111
|
+
git_branch
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def resolve_branch
|
|
117
|
+
name = self.class.resolve_branch_name(@config)
|
|
118
|
+
|
|
119
|
+
if name.nil? || name.empty?
|
|
120
|
+
raise "Could not determine git branch. " \
|
|
121
|
+
"Set branch_override in database.yml or the PGBRANCH environment variable."
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
name
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def primary_branch_name
|
|
128
|
+
(@config[:primary_branch] || "main").to_s
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def ensure_schema
|
|
132
|
+
@connection.execute("CREATE SCHEMA IF NOT EXISTS #{quote(@branch_schema)}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def drop_schema
|
|
136
|
+
@connection.execute("DROP SCHEMA IF EXISTS #{quote(@branch_schema)} CASCADE")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def set_search_path
|
|
140
|
+
@connection.schema_search_path = "#{@branch_schema}, public"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def shadow_migration_tables
|
|
144
|
+
shadow = Shadow.new(@connection, @branch_schema)
|
|
145
|
+
shadow.call(ActiveRecord::Base.schema_migrations_table_name)
|
|
146
|
+
shadow.call(ActiveRecord::Base.internal_metadata_table_name)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def quote(identifier)
|
|
150
|
+
@connection.quote_column_name(identifier)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.git_branch
|
|
154
|
+
result = `git branch --show-current 2>/dev/null`.strip
|
|
155
|
+
result.empty? ? nil : result
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module ActiveRecord
|
|
2
|
+
module ConnectionAdapters
|
|
3
|
+
module PostgreSQL
|
|
4
|
+
module Branched
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
rake_tasks do
|
|
7
|
+
namespace :db do
|
|
8
|
+
namespace :branch do
|
|
9
|
+
desc "Drop and recreate the current branch schema"
|
|
10
|
+
task reset: :load_config do
|
|
11
|
+
manager = branch_manager
|
|
12
|
+
|
|
13
|
+
if manager.primary_branch?
|
|
14
|
+
puts "On primary branch (#{manager.branch}), nothing to reset."
|
|
15
|
+
next
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
manager.reset
|
|
19
|
+
puts "Reset branch schema #{manager.branch_schema}. Run db:migrate to reapply branch migrations."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc "Drop the current branch schema entirely"
|
|
23
|
+
task discard: :load_config do
|
|
24
|
+
manager = branch_manager
|
|
25
|
+
branch = ENV["BRANCH"] || manager.branch
|
|
26
|
+
schema = BranchManager.sanitise(branch)
|
|
27
|
+
|
|
28
|
+
manager.discard(branch)
|
|
29
|
+
puts "Discarded branch schema #{schema}."
|
|
30
|
+
rescue => e
|
|
31
|
+
puts e.message
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc "List all branch schemas and their sizes"
|
|
35
|
+
task list: :load_config do
|
|
36
|
+
rows = branch_manager.list
|
|
37
|
+
|
|
38
|
+
if rows.empty?
|
|
39
|
+
puts "No branch schemas found."
|
|
40
|
+
else
|
|
41
|
+
puts "Branch schemas:"
|
|
42
|
+
rows.each { |name, size| puts " #{name} (#{size})" }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc "Drop schemas for branches that no longer exist in git"
|
|
47
|
+
task prune: :load_config do
|
|
48
|
+
pruned = branch_manager.prune
|
|
49
|
+
|
|
50
|
+
if pruned.empty?
|
|
51
|
+
puts "No stale branch schemas found."
|
|
52
|
+
else
|
|
53
|
+
puts "Pruned #{pruned.size} stale branch schema#{"s" if pruned.size > 1}:"
|
|
54
|
+
pruned.each { |s| puts " #{s}" }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "Show objects in the current branch schema vs public"
|
|
59
|
+
task diff: :load_config do
|
|
60
|
+
manager = branch_manager
|
|
61
|
+
|
|
62
|
+
if manager.primary_branch?
|
|
63
|
+
puts "On primary branch, no diff."
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
tables = manager.diff
|
|
68
|
+
|
|
69
|
+
if tables.empty?
|
|
70
|
+
puts "No branch-local objects in #{manager.branch_schema}."
|
|
71
|
+
else
|
|
72
|
+
puts "Branch-local objects in #{manager.branch_schema}:"
|
|
73
|
+
tables.each { |t| puts " #{t}" }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def branch_manager
|
|
80
|
+
connection = ActiveRecord::Base.lease_connection
|
|
81
|
+
BranchManager.new(connection, connection.instance_variable_get(:@config))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
initializer "postgresql_branched.schema_dumper" do
|
|
86
|
+
ActiveSupport.on_load(:active_record) do
|
|
87
|
+
ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper.prepend(
|
|
88
|
+
ActiveRecord::ConnectionAdapters::PostgreSQL::Branched::SchemaDumperExtension
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require "stringio"
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module PostgreSQL
|
|
6
|
+
module Branched
|
|
7
|
+
module SchemaDumperExtension
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def on_branch?
|
|
11
|
+
@connection.respond_to?(:branch_manager) &&
|
|
12
|
+
@connection.branch_manager &&
|
|
13
|
+
!@connection.branch_manager.primary_branch?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(connection, options = {})
|
|
17
|
+
super
|
|
18
|
+
@dump_schemas = ["public"] if on_branch?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def schemas(stream)
|
|
22
|
+
return if on_branch?
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tables(stream)
|
|
27
|
+
return super unless on_branch?
|
|
28
|
+
|
|
29
|
+
table_names = @connection.tables.uniq.sort
|
|
30
|
+
table_names.reject! { |t| ignored?(t) }
|
|
31
|
+
|
|
32
|
+
table_names.each_with_index do |table_name, index|
|
|
33
|
+
table(table_name, stream)
|
|
34
|
+
stream.puts if index < table_names.size - 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if @connection.supports_foreign_keys?
|
|
38
|
+
fk_stream = StringIO.new
|
|
39
|
+
table_names.each { |tbl| foreign_keys(tbl, fk_stream) }
|
|
40
|
+
fk_string = fk_stream.string
|
|
41
|
+
if fk_string.length > 0
|
|
42
|
+
stream.puts
|
|
43
|
+
stream.print fk_string
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def types(stream)
|
|
49
|
+
return super unless on_branch?
|
|
50
|
+
|
|
51
|
+
enums = @connection.enum_types
|
|
52
|
+
if enums.any?
|
|
53
|
+
stream.puts " # Custom types defined in this database."
|
|
54
|
+
stream.puts " # Note that some types may not work with other database engines. Be careful if changing database."
|
|
55
|
+
enums.sort.each do |name, values|
|
|
56
|
+
stream.puts " create_enum #{name.inspect}, #{values.inspect}"
|
|
57
|
+
end
|
|
58
|
+
stream.puts
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module ActiveRecord
|
|
2
|
+
module ConnectionAdapters
|
|
3
|
+
module PostgreSQL
|
|
4
|
+
module Branched
|
|
5
|
+
class Shadow
|
|
6
|
+
def initialize(connection, branch_schema)
|
|
7
|
+
@connection = connection
|
|
8
|
+
@branch_schema = branch_schema
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(table_name)
|
|
12
|
+
table = table_name.to_s
|
|
13
|
+
return unless exists_in_public?(table)
|
|
14
|
+
return if already_shadowed?(table)
|
|
15
|
+
|
|
16
|
+
create_shadow(table)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def exists_in_public?(table)
|
|
22
|
+
@connection.select_value(<<~SQL) == 1
|
|
23
|
+
SELECT 1 FROM information_schema.tables
|
|
24
|
+
WHERE table_schema = 'public' AND table_name = #{@connection.quote(table)}
|
|
25
|
+
SQL
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def already_shadowed?(table)
|
|
29
|
+
@connection.select_value(<<~SQL) == 1
|
|
30
|
+
SELECT 1 FROM information_schema.tables
|
|
31
|
+
WHERE table_schema = #{@connection.quote(@branch_schema)}
|
|
32
|
+
AND table_name = #{@connection.quote(table)}
|
|
33
|
+
SQL
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create_shadow(table)
|
|
37
|
+
quoted_table = @connection.quote_column_name(table)
|
|
38
|
+
@connection.execute(<<~SQL)
|
|
39
|
+
CREATE TABLE #{@branch_schema}.#{quoted_table}
|
|
40
|
+
(LIKE public.#{quoted_table} INCLUDING ALL)
|
|
41
|
+
SQL
|
|
42
|
+
@connection.execute(<<~SQL)
|
|
43
|
+
INSERT INTO #{@branch_schema}.#{quoted_table}
|
|
44
|
+
SELECT * FROM public.#{quoted_table}
|
|
45
|
+
SQL
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
require "digest/sha2"
|
|
2
|
+
require "set"
|
|
3
|
+
require "active_record/connection_adapters/postgresql_adapter"
|
|
4
|
+
require "active_record/connection_adapters/postgresql/branched/branch_manager"
|
|
5
|
+
require "active_record/connection_adapters/postgresql/branched/shadow"
|
|
6
|
+
require "active_record/connection_adapters/postgresql/branched/schema_dumper"
|
|
7
|
+
require "active_record/connection_adapters/postgresql/branched/adapter"
|
|
8
|
+
require "active_record/connection_adapters/postgresql/branched/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-postgresql-branched
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Carl Dawson
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: railties
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: pg
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
description: A Rails database adapter that gives each git branch its own Postgres
|
|
55
|
+
schema. Migrations run in isolation. Nobody steps on anyone else's work.
|
|
56
|
+
executables: []
|
|
57
|
+
extensions: []
|
|
58
|
+
extra_rdoc_files: []
|
|
59
|
+
files:
|
|
60
|
+
- CHANGELOG.md
|
|
61
|
+
- LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- lib/active_record/connection_adapters/postgresql/branched/adapter.rb
|
|
64
|
+
- lib/active_record/connection_adapters/postgresql/branched/branch_manager.rb
|
|
65
|
+
- lib/active_record/connection_adapters/postgresql/branched/railtie.rb
|
|
66
|
+
- lib/active_record/connection_adapters/postgresql/branched/schema_dumper.rb
|
|
67
|
+
- lib/active_record/connection_adapters/postgresql/branched/shadow.rb
|
|
68
|
+
- lib/activerecord-postgresql-branched.rb
|
|
69
|
+
homepage: https://github.com/carldaws/activerecord-postgresql-branched
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
metadata:
|
|
73
|
+
source_code_uri: https://github.com/carldaws/activerecord-postgresql-branched
|
|
74
|
+
changelog_uri: https://github.com/carldaws/activerecord-postgresql-branched/blob/main/CHANGELOG.md
|
|
75
|
+
rdoc_options: []
|
|
76
|
+
require_paths:
|
|
77
|
+
- lib
|
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.1'
|
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
requirements: []
|
|
89
|
+
rubygems_version: 4.0.6
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: Branch-aware PostgreSQL adapter for ActiveRecord
|
|
92
|
+
test_files: []
|