deckard 0.2.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 +58 -0
- data/LICENSE.txt +21 -0
- data/README.md +205 -0
- data/exe/deckard +7 -0
- data/lib/deckard/active_record/dump.rb +94 -0
- data/lib/deckard/active_record/load.rb +59 -0
- data/lib/deckard/active_record.rb +36 -0
- data/lib/deckard/cli.rb +145 -0
- data/lib/deckard/dumper.rb +87 -0
- data/lib/deckard/errors.rb +24 -0
- data/lib/deckard/loader.rb +103 -0
- data/lib/deckard/model_config.rb +129 -0
- data/lib/deckard/plan_report.rb +32 -0
- data/lib/deckard/status.rb +28 -0
- data/lib/deckard/version.rb +5 -0
- data/lib/deckard.rb +33 -0
- metadata +96 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d27aab47bd5883ad518fe7910ae3faf5f7a3c617430bd0668345dacd652252a6
|
|
4
|
+
data.tar.gz: 8b232443e73afa31f061a082327c5b3c541912f83127f18ba968e99659f0f167
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e851c6424c4c790437c294461f64e2444a34297c67507b3fa53c36cdc3af2008462ee41f12465908f4187bbd8b02b0ea6ea5cfa9c8f3509d31cbc86de21f69f3
|
|
7
|
+
data.tar.gz: 354bd7706454a95d042b85d2d80166215cfa0cd9c8ae31c5dfee06f3de0d16f51b277505eaec6a992bdd689ff189044ea536a0220bde49a99484603d2fcd585f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to deckard are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/). Before 1.0, minor versions may
|
|
6
|
+
change the stream format and the Ruby API.
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - Unreleased
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- A model's `replicate` block is the plan for dumps rooted at that model,
|
|
13
|
+
and the only plan those dumps consult. `model "Name" do ... end` entries
|
|
14
|
+
inside it say how each class the dump reaches is dumped and matched. A
|
|
15
|
+
reached class's own block is not consulted, so there is nothing to merge
|
|
16
|
+
and no precedence. Classes are named as strings, and an STI subclass
|
|
17
|
+
follows its nearest declared ancestor's entry.
|
|
18
|
+
- A record reached only through `belongs_to` carries its row and its own
|
|
19
|
+
dependencies, nothing it owns: the collections its entry names apply to
|
|
20
|
+
the root and to records reached through `has_one` or a planned
|
|
21
|
+
collection. A record dumped first as a dependency and reached later as
|
|
22
|
+
owned, or as a later root, is walked then and written once.
|
|
23
|
+
- Natural keys travel in the stream. Each replicant is
|
|
24
|
+
`[type, id, attributes, natural_key]`, the stream version is 2, and a
|
|
25
|
+
load consults no plan on the destination.
|
|
26
|
+
- Plans are validated before a dump starts: every named class must be a
|
|
27
|
+
loaded ActiveRecord model and every named association and attribute must
|
|
28
|
+
exist. Problems are reported together as `ConfigurationError`. The
|
|
29
|
+
executable eager loads the application (through Zeitwerk, when present)
|
|
30
|
+
so every `replicate` block has run first.
|
|
31
|
+
- `natural_key` and `omit_fields` store attribute names as strings.
|
|
32
|
+
- When a natural key matches no destination row and the insert that follows
|
|
33
|
+
fails, `InsertError` names the key that matched nothing.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `deckard --plan MODEL [--format text|json]` prints the plan for dumps
|
|
38
|
+
rooted at a model. `ModelConfig#to_h` builds it as data and
|
|
39
|
+
`Deckard::PlanReport` renders it.
|
|
40
|
+
- `docs/traversal.md`: how a dump walks the graph, with a worked example.
|
|
41
|
+
- `bin/rspec`.
|
|
42
|
+
|
|
43
|
+
### Removed
|
|
44
|
+
|
|
45
|
+
- Per-dump options on `dump` (`associations:`, `omit_fields:`,
|
|
46
|
+
`omit_associations:`). `dump(object)` and `dump_replicant(dumper)` are the
|
|
47
|
+
whole API.
|
|
48
|
+
- Per-class configuration inheritance. Plans are keyed by root class name.
|
|
49
|
+
- `Deckard::UnsupportedAssociation`. A `has_and_belongs_to_many` or
|
|
50
|
+
`has_many :through` association named by a plan is a
|
|
51
|
+
`ConfigurationError` like any other plan problem.
|
|
52
|
+
|
|
53
|
+
## [0.1.0]
|
|
54
|
+
|
|
55
|
+
Initial development version: `-r`/`-d`/`-l` executable, versioned Marshal
|
|
56
|
+
stream, ActiveRecord dumping with automatic `belongs_to` and `has_one`
|
|
57
|
+
traversal, opt-in `has_many`, natural keys, field and association omission,
|
|
58
|
+
custom-object protocol, transactional loads.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Sanheim
|
|
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,205 @@
|
|
|
1
|
+
# Deckard
|
|
2
|
+
|
|
3
|
+
Deckard copies selected ActiveRecord records, and the records they need,
|
|
4
|
+
from one Rails environment into another. Pick a record, and its plan says
|
|
5
|
+
what comes with it. Rows land with new primary keys, foreign keys are
|
|
6
|
+
rewritten to match, and the load commits only when the stream ends cleanly.
|
|
7
|
+
Its usual job is pulling one production record and its graph into a local
|
|
8
|
+
development database to debug against real data.
|
|
9
|
+
|
|
10
|
+
It is inspired by [replicate](https://github.com/rtomayko/replicate), rebuilt
|
|
11
|
+
for ActiveRecord 8 and PostgreSQL.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
Add the gem to both applications:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "deckard"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Put a plan on the model you dump from. It names the collections that come
|
|
22
|
+
along and how each model the dump reaches is matched on the destination:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class Order < ActiveRecord::Base
|
|
26
|
+
belongs_to :customer
|
|
27
|
+
has_many :line_items
|
|
28
|
+
|
|
29
|
+
replicate do
|
|
30
|
+
associations :line_items
|
|
31
|
+
|
|
32
|
+
model "LineItem" do
|
|
33
|
+
associations :adjustments
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
model "Customer" do
|
|
37
|
+
natural_key :email
|
|
38
|
+
omit_fields :password_digest
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
From your development machine, dump on the remote side and load locally in
|
|
45
|
+
one pipe. SSH is the transport; nothing lands on disk:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
ssh prod.example.org "deckard -r /app/config/environment -d 'Order.find(1234)'" \
|
|
49
|
+
| deckard -r ./config/environment -l
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Standard error on each end reports what moved:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
loaded 9 total objects:
|
|
56
|
+
|
|
57
|
+
Adjustment 3
|
|
58
|
+
Customer 1
|
|
59
|
+
LineItem 4
|
|
60
|
+
Order 1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The customer was matched by email, so an existing local customer was updated
|
|
64
|
+
rather than duplicated. Everything else is a new row with a new id.
|
|
65
|
+
|
|
66
|
+
To keep a dump around, write it to a file and load it later:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
deckard -r ./config/environment -d "Order.find(1234)" > order.dump
|
|
70
|
+
deckard -r ./config/environment -l < order.dump
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## What a dump carries
|
|
74
|
+
|
|
75
|
+
Dumping a record includes the rows it depends on (`belongs_to`), the record
|
|
76
|
+
itself, the rows it owns one-to-one (`has_one`), and the collections its plan
|
|
77
|
+
names. `has_many` collections are never followed on their own.
|
|
78
|
+
|
|
79
|
+
A dump follows its root's plan and nothing else: `dump Order.find(1)` uses
|
|
80
|
+
Order's block for every record it reaches, and `dump LineItem.find(1)` uses
|
|
81
|
+
LineItem's. A reached class's own block is not consulted, so there is nothing
|
|
82
|
+
to merge and no precedence to learn.
|
|
83
|
+
|
|
84
|
+
A record reached only through `belongs_to` is a dependency: it arrives as a
|
|
85
|
+
row with its own dependencies, so foreign keys resolve, and nothing it owns,
|
|
86
|
+
whatever its entry says. The line item's product comes along; the product's
|
|
87
|
+
reviews do not. That rule is what keeps a dump the size of one record's
|
|
88
|
+
ownership tree. [docs/traversal.md](docs/traversal.md) draws it.
|
|
89
|
+
|
|
90
|
+
Each record handed to `dump` is a root, so `dump Order.where(customer_id: 7)`
|
|
91
|
+
gives every order the full plan, and a record two dumps share lands once.
|
|
92
|
+
|
|
93
|
+
## Plans
|
|
94
|
+
|
|
95
|
+
The `replicate` block offers five methods:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
replicate do
|
|
99
|
+
associations :line_items # opt this has_many into dumps
|
|
100
|
+
natural_key :number # reuse an existing destination row
|
|
101
|
+
omit_fields :internal_notes # keep a field out of the stream
|
|
102
|
+
omit_associations :warehouse # do not traverse this association
|
|
103
|
+
|
|
104
|
+
model "LineItem" do # the same four, for a class the dump reaches
|
|
105
|
+
associations :adjustments
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Classes are named as strings, so the root never forces them to load first,
|
|
111
|
+
and an STI subclass follows the entry for its nearest declared ancestor. Each
|
|
112
|
+
record travels with the natural key its plan gave it, so the destination
|
|
113
|
+
needs no plan of its own. That also means a natural key is repeated in every
|
|
114
|
+
plan whose dumps reach the class: a Customer entry in Order's plan and one in
|
|
115
|
+
Invoice's, each saying how a customer is matched.
|
|
116
|
+
|
|
117
|
+
Before dumping, the `deckard` executable eager loads the application so
|
|
118
|
+
every `replicate` block has run, then validates every plan: each named class
|
|
119
|
+
must be a loaded ActiveRecord model, and each named association and attribute
|
|
120
|
+
must exist on it. A bad plan fails there, reporting every problem at once,
|
|
121
|
+
before any record is dumped. Loading consults no plan at all.
|
|
122
|
+
|
|
123
|
+
To see a plan before pointing it at production, print it:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
deckard -r ./config/environment --plan Order
|
|
127
|
+
deckard -r ./config/environment --plan Order --format json
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
Order
|
|
132
|
+
associations line_items
|
|
133
|
+
|
|
134
|
+
LineItem
|
|
135
|
+
associations adjustments
|
|
136
|
+
|
|
137
|
+
Customer
|
|
138
|
+
natural key email
|
|
139
|
+
omit fields password_digest
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Dumping
|
|
143
|
+
|
|
144
|
+
`-d` evaluates a Ruby expression and streams the result to standard output.
|
|
145
|
+
Diagnostics go to standard error; standard output carries only the stream.
|
|
146
|
+
Application boot code that prints to stdout (a logger pointed at `STDOUT`, a
|
|
147
|
+
stray `puts` in an initializer) cannot corrupt it: before requiring the
|
|
148
|
+
application, deckard keeps the original stdout for itself and points file
|
|
149
|
+
descriptor 1 at stderr.
|
|
150
|
+
|
|
151
|
+
For more involved selection, pass a Ruby file instead of an expression. The
|
|
152
|
+
script runs in a context exposing `dump(object)`:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# config/deckard/dump-repo.rb
|
|
156
|
+
repo = Repository.find_by!(name: ARGV.first)
|
|
157
|
+
dump repo
|
|
158
|
+
dump repo.issues
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
deckard -r ./config/environment -d config/deckard/dump-repo.rb tilt > repos.dump
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Extra command-line arguments reach the script through `ARGV`, and `-d -`
|
|
166
|
+
reads the script from standard input. An expression given to `-d` runs in
|
|
167
|
+
the same context, so it may call `dump` directly. While stderr is a
|
|
168
|
+
terminal, a live object counter shows progress on both ends of the pipe.
|
|
169
|
+
|
|
170
|
+
## Loading
|
|
171
|
+
|
|
172
|
+
`-l` reads a stream from standard input and loads it inside one transaction,
|
|
173
|
+
bypassing validations and callbacks: it copies data, it does not run your
|
|
174
|
+
application. If the source dies mid-stream, nothing is committed.
|
|
175
|
+
|
|
176
|
+
Loading refuses to run when the application environment is production. Pass
|
|
177
|
+
`--force` to override.
|
|
178
|
+
|
|
179
|
+
## Security
|
|
180
|
+
|
|
181
|
+
The stream is Ruby `Marshal` data: load streams only from applications and
|
|
182
|
+
operators you trust, over an authenticated transport such as SSH. Deckard is
|
|
183
|
+
an internal operator tool, not a public import format. Dumped production data
|
|
184
|
+
lands unmasked in the destination database; treat dumps accordingly. That
|
|
185
|
+
includes ActiveRecord-encrypted attributes: they travel through the stream as
|
|
186
|
+
plaintext and are re-encrypted with the destination's keys on load.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
`bundle exec rake` runs the unit/integration suite (specs against a local
|
|
191
|
+
PostgreSQL 18, lint, and a style ratchet); `bin/rspec spec/deckard/stream_spec.rb`
|
|
192
|
+
runs one file. The specs run against a small forum
|
|
193
|
+
schema managed with ActiveRecord's own migration and schema tooling under
|
|
194
|
+
`spec/db`; `bundle exec rake -T db` lists the database tasks, and
|
|
195
|
+
`bundle exec rake db:reset` rebuilds and seeds the test databases from scratch.
|
|
196
|
+
`bundle exec rake e2e` runs the full-stack test: a real Rails app streaming
|
|
197
|
+
between two docker compose containers. `bundle exec rake test-all` runs both
|
|
198
|
+
host-side checks and the full-stack test. See `docs/spec.md` for the v1.0
|
|
199
|
+
specification and `docs/traversal.md` for how a dump walks the graph.
|
|
200
|
+
|
|
201
|
+
Crow CI runs `bundle exec rake` on pull requests, default-branch pushes, and
|
|
202
|
+
manual runs using Ruby 4.0.6 and an isolated PostgreSQL 18.6 service. The
|
|
203
|
+
workflow is in `.crow/ruby.yaml`; it covers specs, Standard lint, and the
|
|
204
|
+
style ratchet. The Docker end-to-end harness and performance suite remain
|
|
205
|
+
separate local tasks.
|
data/exe/deckard
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deckard
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Dumps one record by the root's plan (docs/traversal.md), and the records
|
|
6
|
+
# it reaches by the same plan.
|
|
7
|
+
class Dump
|
|
8
|
+
# Stored generated columns (GENERATED ALWAYS AS ... STORED) are never
|
|
9
|
+
# dumped: the destination database computes them, and PostgreSQL
|
|
10
|
+
# rejects explicit inserts into them. Computed once per model class.
|
|
11
|
+
GENERATED_COLUMNS = Hash.new do |cache, model|
|
|
12
|
+
cache[model] = model.columns.select(&:virtual?).map(&:name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.source_id(record)
|
|
16
|
+
if record.class.primary_key.is_a?(Array)
|
|
17
|
+
raise DumpError, "#{record.class} has a composite primary key, which deckard does not support"
|
|
18
|
+
end
|
|
19
|
+
record.id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(record, dumper, plan, owned:)
|
|
23
|
+
@record = record
|
|
24
|
+
@model = record.class
|
|
25
|
+
@source_id = self.class.source_id(record)
|
|
26
|
+
@dumper = dumper
|
|
27
|
+
@plan = plan
|
|
28
|
+
@owned = owned
|
|
29
|
+
@entry = plan.for(@model)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call
|
|
33
|
+
@dumper.visit(@model.name, @source_id, walk: @owned) do
|
|
34
|
+
attributes = @record.attributes.except(*GENERATED_COLUMNS[@model], *@entry.omitted_fields)
|
|
35
|
+
dump_belongs_to(attributes)
|
|
36
|
+
@dumper.write(@model.name, @source_id, attributes, @entry.natural_key_attributes)
|
|
37
|
+
next unless @owned
|
|
38
|
+
|
|
39
|
+
owned = @model.reflect_on_all_associations(:has_one).map(&:name) | @entry.extra_associations
|
|
40
|
+
(owned - @entry.omitted_associations).each { |name| dump_owned(@record.public_send(name)) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def dump_belongs_to(attributes)
|
|
47
|
+
@model.reflect_on_all_associations(:belongs_to).each do |reflection|
|
|
48
|
+
foreign_key = reflection.foreign_key.to_s
|
|
49
|
+
next if @entry.omitted_associations.include?(reflection.name)
|
|
50
|
+
next if encode_dumped_parent(attributes, reflection, foreign_key)
|
|
51
|
+
|
|
52
|
+
referenced = @record.public_send(reflection.name)
|
|
53
|
+
next if referenced.nil?
|
|
54
|
+
|
|
55
|
+
self.class.new(referenced, @dumper, @plan, owned: false).call
|
|
56
|
+
referenced_id = self.class.source_id(referenced)
|
|
57
|
+
unless @dumper.dumped?(referenced.class.name, referenced_id)
|
|
58
|
+
raise DumpError,
|
|
59
|
+
"dependency cycle detected: #{@model}(#{@source_id}).#{reflection.name} " \
|
|
60
|
+
"references #{referenced.class.name}(#{referenced_id}), which cannot be emitted first"
|
|
61
|
+
end
|
|
62
|
+
next if @entry.omitted_fields.include?(foreign_key)
|
|
63
|
+
# A belongs_to whose primary_key option targets a non-primary-key
|
|
64
|
+
# column (belongs_to :account, primary_key: :login) carries a natural
|
|
65
|
+
# value, not a source ID. It needs no remapping and must not be
|
|
66
|
+
# replaced with a destination primary key.
|
|
67
|
+
next unless reflection.association_primary_key(referenced.class) == referenced.class.primary_key
|
|
68
|
+
|
|
69
|
+
attributes[foreign_key] = [:id, referenced.class.name, referenced_id]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# When the parent is already in the stream under the association's
|
|
74
|
+
# declared class, encode the reference from the foreign key alone
|
|
75
|
+
# instead of loading the parent again. A parent dumped as an STI
|
|
76
|
+
# subclass misses this check and takes the loading path.
|
|
77
|
+
def encode_dumped_parent(attributes, reflection, foreign_key)
|
|
78
|
+
return false if reflection.polymorphic?
|
|
79
|
+
return false unless reflection.association_primary_key == reflection.klass.primary_key
|
|
80
|
+
|
|
81
|
+
value = @record[foreign_key]
|
|
82
|
+
return false if value.nil? || !@dumper.dumped?(reflection.klass.name, value)
|
|
83
|
+
|
|
84
|
+
attributes[foreign_key] = [:id, reflection.klass.name, value] unless @entry.omitted_fields.include?(foreign_key)
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def dump_owned(associated)
|
|
89
|
+
records = associated.respond_to?(:find_each) ? associated.find_each : Array(associated)
|
|
90
|
+
records.each { |record| self.class.new(record, @dumper, @plan, owned: true).call }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deckard
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Loads one replicant tuple into a model's table.
|
|
6
|
+
class Load
|
|
7
|
+
def initialize(model, type, source_id, attributes, natural_key)
|
|
8
|
+
@model = model
|
|
9
|
+
@type = type
|
|
10
|
+
@source_id = source_id
|
|
11
|
+
@attributes = attributes
|
|
12
|
+
@natural_key = natural_key
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
if @model.primary_key.is_a?(Array)
|
|
17
|
+
raise LoadError, "#{@model.name} has a composite primary key, which deckard does not support"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@natural_key.empty? ? insert : load_by_natural_key
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# `because` says what led to the insert when it was not the only path,
|
|
26
|
+
# so a failure names the natural key that matched nothing.
|
|
27
|
+
def insert(because = "")
|
|
28
|
+
result = @model.insert_all!([@attributes.except(@model.primary_key)], returning: [@model.primary_key])
|
|
29
|
+
destination_id = result.rows.first.first
|
|
30
|
+
[destination_id, @model.find(destination_id)]
|
|
31
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
32
|
+
raise InsertError,
|
|
33
|
+
"#{@type} source_id=#{@source_id} #{because}could not be inserted: #{Deckard.error_detail(e)}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def load_by_natural_key
|
|
37
|
+
lookup = @attributes.slice(*@natural_key)
|
|
38
|
+
matches = @model.where(lookup).limit(2).to_a
|
|
39
|
+
|
|
40
|
+
case matches.size
|
|
41
|
+
when 0
|
|
42
|
+
insert("matched no destination row by natural key (#{lookup.keys.join(", ")}) and ")
|
|
43
|
+
when 1
|
|
44
|
+
record = matches.first
|
|
45
|
+
begin
|
|
46
|
+
record.update_columns(@attributes.except(@model.primary_key))
|
|
47
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
48
|
+
raise InsertError,
|
|
49
|
+
"#{@type} source_id=#{@source_id} could not be updated via natural key: #{Deckard.error_detail(e)}"
|
|
50
|
+
end
|
|
51
|
+
[record.id, record]
|
|
52
|
+
else
|
|
53
|
+
raise LoadError,
|
|
54
|
+
"#{@type} source_id=#{@source_id}: natural key (#{lookup.keys.join(", ")}) matches more than one destination row"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deckard
|
|
4
|
+
# Implements the replicant protocol for ActiveRecord models. Included into
|
|
5
|
+
# ActiveRecord::Base when ActiveRecord loads (see lib/deckard.rb). Models
|
|
6
|
+
# gain exactly three methods: the `replicate` configuration DSL,
|
|
7
|
+
# `dump_replicant`, and `load_replicant`. Traversal and loading live in
|
|
8
|
+
# the plain objects Dump (lib/deckard/active_record/dump.rb) and Load
|
|
9
|
+
# (lib/deckard/active_record/load.rb) so nothing else lands in the model
|
|
10
|
+
# namespace.
|
|
11
|
+
module ActiveRecord
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend ClassMethods
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Records reached from this one are dumped by its plan directly, so an
|
|
17
|
+
# override of this method applies to roots only.
|
|
18
|
+
def dump_replicant(dumper)
|
|
19
|
+
Dump.new(self, dumper, ModelConfig.plan_for(self.class), owned: true).call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module ClassMethods
|
|
23
|
+
# The `replicate do ... end` DSL: the plan for dumps rooted here.
|
|
24
|
+
def replicate(&block)
|
|
25
|
+
ModelConfig.declare(name, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Load one streamed replicant: reuse an existing row when its natural
|
|
29
|
+
# key matches, otherwise insert a new row with a destination-generated
|
|
30
|
+
# primary key. Both paths bypass validations and callbacks.
|
|
31
|
+
def load_replicant(type, source_id, attributes, natural_key)
|
|
32
|
+
Load.new(self, type, source_id, attributes, natural_key).call
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/deckard/cli.rb
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "plan_report"
|
|
5
|
+
require_relative "status"
|
|
6
|
+
|
|
7
|
+
module Deckard
|
|
8
|
+
# The deckard executable: -r requires the application environment, -d
|
|
9
|
+
# dumps a Ruby expression or dump script to stdout, -l loads a stream
|
|
10
|
+
# from stdin, --plan prints a root model's plan. Required from
|
|
11
|
+
# exe/deckard, not from the library itself.
|
|
12
|
+
class CLI
|
|
13
|
+
# Evaluation context for dump scripts and -d expressions: exposes only
|
|
14
|
+
# dump(object). Scripts see any extra command-line arguments in ARGV.
|
|
15
|
+
class DumpScript
|
|
16
|
+
def initialize(dumper)
|
|
17
|
+
@dumper = dumper
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def dump(object)
|
|
21
|
+
@dumper.dump(object)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@stdin = $stdin
|
|
27
|
+
@stdout = $stdout
|
|
28
|
+
@stderr = $stderr
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(argv)
|
|
32
|
+
options = parse(argv)
|
|
33
|
+
reserve_stdout if options[:dump]
|
|
34
|
+
require File.expand_path(options[:require]) if options[:require]
|
|
35
|
+
|
|
36
|
+
if options[:dump]
|
|
37
|
+
dump(options[:dump])
|
|
38
|
+
elsif options[:plan]
|
|
39
|
+
print_plan(options[:plan], options[:format])
|
|
40
|
+
else
|
|
41
|
+
load_stream(options)
|
|
42
|
+
end
|
|
43
|
+
0
|
|
44
|
+
rescue Error => e
|
|
45
|
+
@stderr.puts "#{e.class}: #{e.message}"
|
|
46
|
+
1
|
|
47
|
+
rescue Errno::EPIPE
|
|
48
|
+
@stderr.puts "deckard: output pipe closed before the stream completed"
|
|
49
|
+
1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Application boot code may print to stdout, which would corrupt the
|
|
55
|
+
# stream. Keep a private copy of the original stdout for the dumper and
|
|
56
|
+
# point file descriptor 1 at stderr, so anything else this process or
|
|
57
|
+
# its children write to stdout lands on stderr instead.
|
|
58
|
+
# rubocop:disable Style/GlobalStdStream -- the process-level streams are the point
|
|
59
|
+
def reserve_stdout
|
|
60
|
+
@stdout = STDOUT.dup
|
|
61
|
+
STDOUT.reopen(STDERR)
|
|
62
|
+
$stdout = STDOUT
|
|
63
|
+
end
|
|
64
|
+
# rubocop:enable Style/GlobalStdStream
|
|
65
|
+
|
|
66
|
+
def parse(argv)
|
|
67
|
+
options = Optimist.options(argv) do
|
|
68
|
+
version "deckard #{VERSION}"
|
|
69
|
+
banner <<~BANNER
|
|
70
|
+
deckard: stream ActiveRecord objects between Rails environments
|
|
71
|
+
|
|
72
|
+
dump: deckard -r ./config/environment -d "User.find(1)" > user.dump
|
|
73
|
+
load: deckard -r ./config/environment -l < user.dump
|
|
74
|
+
pipe: ssh example.org "deckard -r /app/config/environment -d 'User.find(1)'" | deckard -r ./config/environment -l
|
|
75
|
+
plan: deckard -r ./config/environment --plan User
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
BANNER
|
|
79
|
+
opt :require, "Ruby file to require first (usually config/environment)", type: :string
|
|
80
|
+
opt :dump, "Dump the result of a Ruby expression, or run a dump script (a file, or - for stdin)", type: :string
|
|
81
|
+
opt :load, "Load a deckard stream from standard input"
|
|
82
|
+
opt :plan, "Print the plan for dumps rooted at MODEL", type: :string
|
|
83
|
+
opt :format, "Plan output: #{PlanReport::FORMATS.join(" or ")}", default: "text"
|
|
84
|
+
opt :force, "Allow loading into a production environment"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
unless [options[:dump], options[:plan], options[:load] || nil].compact.size == 1
|
|
88
|
+
Optimist.die "exactly one of -d, -l or --plan is required"
|
|
89
|
+
end
|
|
90
|
+
Optimist.die :format, "must be one of #{PlanReport::FORMATS.join(", ")}" unless PlanReport::FORMATS.include?(options[:format])
|
|
91
|
+
options
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Every replicate block must have run before a plan is read, and Rails
|
|
95
|
+
# only eager loads on its own when config.eager_load is on.
|
|
96
|
+
def eager_load
|
|
97
|
+
Zeitwerk::Loader.eager_load_all if defined?(Zeitwerk::Loader)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def dump(target)
|
|
101
|
+
eager_load
|
|
102
|
+
ModelConfig.validate!
|
|
103
|
+
@stdout.binmode
|
|
104
|
+
dumper = Dumper.new(@stdout) { |counts| Status.progress("dumping", counts, @stderr) }
|
|
105
|
+
script = DumpScript.new(dumper)
|
|
106
|
+
|
|
107
|
+
if target == "-"
|
|
108
|
+
script.instance_eval(@stdin.read, "<stdin>")
|
|
109
|
+
elsif File.exist?(target)
|
|
110
|
+
script.instance_eval(File.read(target), target)
|
|
111
|
+
else
|
|
112
|
+
dumper.dump(script.instance_eval(target, "deckard -d"))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
dumper.complete
|
|
116
|
+
@stdout.flush
|
|
117
|
+
Status.report("dumped", dumper.counts, @stderr)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def print_plan(model_name, format)
|
|
121
|
+
eager_load
|
|
122
|
+
klass = model_name.safe_constantize
|
|
123
|
+
raise ConfigurationError, "#{model_name.inspect} is not a loaded ActiveRecord model" unless klass.respond_to?(:replicate)
|
|
124
|
+
|
|
125
|
+
plan = ModelConfig.plan_for(klass)
|
|
126
|
+
raise ConfigurationError, "#{klass} has no replicate block" if plan.equal?(ModelConfig::NONE)
|
|
127
|
+
|
|
128
|
+
problems = plan.problems
|
|
129
|
+
raise ConfigurationError, problems.join("\n") unless problems.empty?
|
|
130
|
+
|
|
131
|
+
@stdout.write PlanReport.render(plan.to_h, format)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def load_stream(options)
|
|
135
|
+
if Deckard.production_environment? && !options[:force]
|
|
136
|
+
raise LoadError, "refusing to load into a production environment (pass --force to override)"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@stdin.binmode
|
|
140
|
+
loader = Loader.new(@stdin) { |counts| Status.progress("loading", counts, @stderr) }
|
|
141
|
+
loader.load
|
|
142
|
+
Status.report("loaded", loader.counts, @stderr)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deckard
|
|
4
|
+
# Writes a versioned Marshal stream of replicant tuples to an output IO.
|
|
5
|
+
# Objects are deduplicated by [type, source_id]: a repeated write is a no-op.
|
|
6
|
+
class Dumper
|
|
7
|
+
attr_reader :counts
|
|
8
|
+
|
|
9
|
+
# An optional block is called with the running counts after every
|
|
10
|
+
# written replicant; the CLI uses it for a progress line.
|
|
11
|
+
def initialize(output, &after_write)
|
|
12
|
+
@output = output
|
|
13
|
+
@after_write = after_write
|
|
14
|
+
@dumped = Set.new
|
|
15
|
+
@walked = Set.new
|
|
16
|
+
@in_progress = Set.new
|
|
17
|
+
@counts = Hash.new(0)
|
|
18
|
+
write_frame(STREAM_HEADER)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Dump one object, or each element of an enumerable. The object must
|
|
22
|
+
# implement dump_replicant(dumper) and write itself (and any
|
|
23
|
+
# dependencies, first) via #write.
|
|
24
|
+
def dump(object)
|
|
25
|
+
return if object.nil?
|
|
26
|
+
|
|
27
|
+
if object.respond_to?(:dump_replicant)
|
|
28
|
+
object.dump_replicant(self)
|
|
29
|
+
elsif object.respond_to?(:find_each)
|
|
30
|
+
object.find_each { |item| dump(item) }
|
|
31
|
+
elsif object.respond_to?(:each)
|
|
32
|
+
object.each { |item| dump(item) }
|
|
33
|
+
else
|
|
34
|
+
raise DumpError, "#{object.class} does not implement dump_replicant"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Runs the block for a [type, id] not yet written, or written but not
|
|
39
|
+
# yet walked when walk is true. A record visited again while its own
|
|
40
|
+
# visit is in progress is a dependency cycle: the block is skipped, and
|
|
41
|
+
# callers that need the identity written first check #dumped? and raise.
|
|
42
|
+
def visit(type, id, walk:)
|
|
43
|
+
key = [type, id]
|
|
44
|
+
return if @in_progress.include?(key) || (walk ? @walked : @dumped).include?(key)
|
|
45
|
+
|
|
46
|
+
@walked.add(key) if walk
|
|
47
|
+
@in_progress.add(key)
|
|
48
|
+
begin
|
|
49
|
+
yield
|
|
50
|
+
ensure
|
|
51
|
+
@in_progress.delete(key)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def dumped?(type, id)
|
|
56
|
+
@dumped.include?([type, id])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Called by dump_replicant implementations to emit one replicant tuple.
|
|
60
|
+
# natural_key names the attributes by which the destination matches an
|
|
61
|
+
# existing record to reuse; empty means always insert.
|
|
62
|
+
def write(type, id, attributes, natural_key = [])
|
|
63
|
+
type = type.to_s
|
|
64
|
+
return unless @dumped.add?([type, id])
|
|
65
|
+
|
|
66
|
+
write_frame([type, id, attributes, natural_key])
|
|
67
|
+
@counts[type] += 1
|
|
68
|
+
@after_write&.call(@counts)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Write the successful-end marker. A stream without it is treated as
|
|
72
|
+
# truncated and never commits on the destination.
|
|
73
|
+
def complete
|
|
74
|
+
write_frame(STREAM_END)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def write_frame(frame)
|
|
80
|
+
Marshal.dump(frame, @output)
|
|
81
|
+
rescue Errno::EPIPE
|
|
82
|
+
raise
|
|
83
|
+
rescue IOError, SystemCallError => e
|
|
84
|
+
raise OutputError, "could not write dump stream: #{Deckard.error_detail(e)}", cause: e
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deckard
|
|
4
|
+
def self.error_detail(error)
|
|
5
|
+
detail = error.message.to_s.lines.first.to_s.strip
|
|
6
|
+
detail.empty? ? "(no message)" : detail
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
class ConfigurationError < Error; end
|
|
12
|
+
|
|
13
|
+
class DumpError < Error; end
|
|
14
|
+
|
|
15
|
+
class OutputError < DumpError; end
|
|
16
|
+
|
|
17
|
+
class LoadError < Error; end
|
|
18
|
+
|
|
19
|
+
class UnresolvedReference < LoadError; end
|
|
20
|
+
|
|
21
|
+
class InvalidStream < LoadError; end
|
|
22
|
+
|
|
23
|
+
class InsertError < LoadError; end
|
|
24
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deckard
|
|
4
|
+
# Reads a Deckard stream incrementally from an input IO, resolving
|
|
5
|
+
# [:id, type, source_id] reference tuples through the source-to-destination
|
|
6
|
+
# ID map and handing each replicant, with the natural key it travels
|
|
7
|
+
# with, to its class's load_replicant.
|
|
8
|
+
class Loader
|
|
9
|
+
attr_reader :counts
|
|
10
|
+
|
|
11
|
+
# An optional block is called with the running counts after every
|
|
12
|
+
# loaded replicant; the CLI uses it for a progress line.
|
|
13
|
+
def initialize(input, &after_load)
|
|
14
|
+
@input = input
|
|
15
|
+
@after_load = after_load
|
|
16
|
+
@id_map = {}
|
|
17
|
+
@counts = Hash.new(0)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load
|
|
21
|
+
with_transaction do
|
|
22
|
+
header = read_frame
|
|
23
|
+
unless header == STREAM_HEADER
|
|
24
|
+
raise InvalidStream, "invalid stream header (expected #{STREAM_HEADER.inspect})"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
loop do
|
|
28
|
+
frame = read_frame
|
|
29
|
+
break if frame == STREAM_END
|
|
30
|
+
load_replicant(frame)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# The whole load runs in one destination transaction, committing only
|
|
38
|
+
# after a valid end marker. Without a database connection (custom-object
|
|
39
|
+
# streams outside a booted app) the load runs bare.
|
|
40
|
+
def with_transaction(&block)
|
|
41
|
+
return yield unless defined?(::ActiveRecord::Base)
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
::ActiveRecord::Base.connection_pool
|
|
45
|
+
rescue ::ActiveRecord::ConnectionNotEstablished
|
|
46
|
+
return yield
|
|
47
|
+
end
|
|
48
|
+
::ActiveRecord::Base.transaction(&block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_frame
|
|
52
|
+
Marshal.load(@input)
|
|
53
|
+
rescue EOFError
|
|
54
|
+
raise InvalidStream, "stream ended without an end marker"
|
|
55
|
+
rescue TypeError, ArgumentError => e
|
|
56
|
+
raise InvalidStream, "corrupt stream frame: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def load_replicant(frame)
|
|
60
|
+
unless frame.is_a?(Array) && frame.size == 4 && frame[0].is_a?(String) && frame[2].is_a?(Hash) && frame[3].is_a?(Array)
|
|
61
|
+
raise InvalidStream, "malformed stream frame (expected [type, id, attributes, natural_key] tuple)"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
type, source_id, attributes, natural_key = frame
|
|
65
|
+
resolved = resolve_references(type, source_id, attributes)
|
|
66
|
+
destination_id, _object = replicant_class(type).load_replicant(type, source_id, resolved, natural_key)
|
|
67
|
+
@id_map[[type, source_id]] = destination_id
|
|
68
|
+
@counts[type] += 1
|
|
69
|
+
@after_load&.call(@counts)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def resolve_references(type, source_id, attributes)
|
|
73
|
+
attributes.each_with_object({}) do |(name, value), resolved|
|
|
74
|
+
resolved[name] = reference?(value) ? resolve_reference(type, source_id, name, value) : value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reference?(value)
|
|
79
|
+
value.is_a?(Array) && value.size == 3 && value[0] == :id
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_reference(type, source_id, name, reference)
|
|
83
|
+
_, ref_type, ref_id = reference
|
|
84
|
+
@id_map.fetch([ref_type, ref_id]) do
|
|
85
|
+
raise UnresolvedReference,
|
|
86
|
+
"#{type}(#{source_id}).#{name} references #{ref_type}(#{ref_id}), which has not been loaded"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def replicant_class(type)
|
|
91
|
+
klass = begin
|
|
92
|
+
Object.const_get(type)
|
|
93
|
+
rescue NameError
|
|
94
|
+
raise LoadError, "cannot load #{type}: class is not defined"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
unless klass.respond_to?(:load_replicant)
|
|
98
|
+
raise LoadError, "cannot load #{type}: #{klass} does not implement load_replicant"
|
|
99
|
+
end
|
|
100
|
+
klass
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
module Deckard
|
|
6
|
+
# A replication plan: a root model's `replicate do ... end` block. The plan
|
|
7
|
+
# is an entry for the root plus, through `model`, an entry for each class
|
|
8
|
+
# its dumps reach, all in one table shared by the plan's entries. A class
|
|
9
|
+
# finds its entry through its ancestors, so an STI subclass follows its
|
|
10
|
+
# nearest declared ancestor. See docs/traversal.md.
|
|
11
|
+
class ModelConfig
|
|
12
|
+
attr_reader :name, :extra_associations, :natural_key_attributes, :omitted_fields, :omitted_associations
|
|
13
|
+
|
|
14
|
+
@plans = {}
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def declare(name, &block)
|
|
18
|
+
@plans[name] = new(name).tap { |plan| plan.instance_eval(&block) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def plan_for(klass)
|
|
22
|
+
@plans.values_at(*klass.ancestors.map(&:name)).compact.first || NONE
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Every problem in every plan, so one run fixes them all.
|
|
26
|
+
def validate!
|
|
27
|
+
problems = @plans.each_value.flat_map(&:problems)
|
|
28
|
+
raise ConfigurationError, problems.join("\n") unless problems.empty?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(name = nil, entries = {})
|
|
33
|
+
@name = name
|
|
34
|
+
@entries = entries
|
|
35
|
+
@entries[name] = self if name
|
|
36
|
+
@resolved = {}
|
|
37
|
+
@extra_associations = []
|
|
38
|
+
@natural_key_attributes = []
|
|
39
|
+
@omitted_fields = []
|
|
40
|
+
@omitted_associations = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# DSL: association names to dump beyond the automatic belongs_to and
|
|
44
|
+
# has_one traversal. Additive across calls.
|
|
45
|
+
def associations(*names)
|
|
46
|
+
@extra_associations |= names.map(&:to_sym)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# DSL: attributes identifying an existing destination record to reuse.
|
|
50
|
+
# Calling it again replaces the key.
|
|
51
|
+
def natural_key(*attributes)
|
|
52
|
+
@natural_key_attributes = attributes.map(&:to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# DSL: fields to exclude from dumped attributes. Additive across calls.
|
|
56
|
+
def omit_fields(*names)
|
|
57
|
+
@omitted_fields |= names.map(&:to_s)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# DSL: associations not to traverse. Additive across calls.
|
|
61
|
+
def omit_associations(*names)
|
|
62
|
+
@omitted_associations |= names.map(&:to_sym)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# DSL: the entry for a class this plan's dumps reach.
|
|
66
|
+
def model(name, &block)
|
|
67
|
+
(@entries[name.to_s] ||= self.class.new(name.to_s, @entries)).instance_eval(&block)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The entry that applies to klass, checked against it the first time.
|
|
71
|
+
def for(klass)
|
|
72
|
+
@resolved[klass] ||= (@entries.values_at(*klass.ancestors.map(&:name)).compact.first || NONE).tap do |entry|
|
|
73
|
+
problems = entry.problems_for(klass)
|
|
74
|
+
raise ConfigurationError, problems.join("\n") unless problems.empty?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def problems
|
|
79
|
+
@entries.each_value.flat_map do |entry|
|
|
80
|
+
klass = entry.name.safe_constantize
|
|
81
|
+
next ["#{entry.name.inspect} is named in a replicate block, but is not a loaded ActiveRecord model"] unless klass.respond_to?(:replicate)
|
|
82
|
+
|
|
83
|
+
entry.problems_for(klass)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Everything this entry names that the model lacks or deckard cannot
|
|
88
|
+
# traverse.
|
|
89
|
+
def problems_for(model)
|
|
90
|
+
problems = @extra_associations.filter_map do |name|
|
|
91
|
+
reflection = model.reflect_on_association(name)
|
|
92
|
+
if reflection.nil?
|
|
93
|
+
"#{model} has no #{name.inspect} association"
|
|
94
|
+
elsif reflection.macro == :has_and_belongs_to_many
|
|
95
|
+
"#{model}.#{name} is a has_and_belongs_to_many association, " \
|
|
96
|
+
"which deckard does not support; use an explicit join model and replicate that association instead"
|
|
97
|
+
elsif reflection.macro == :has_many && reflection.through_reflection
|
|
98
|
+
"#{model}.#{name} is a has_many :through association, " \
|
|
99
|
+
"which deckard does not support; replicate :#{reflection.through_reflection.name} instead"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
attributes = @natural_key_attributes + @omitted_fields
|
|
103
|
+
unless attributes.empty? || model.abstract_class?
|
|
104
|
+
missing = attributes - model.attribute_names
|
|
105
|
+
problems += missing.map { |attribute| "#{model} has no #{attribute.inspect} attribute" }
|
|
106
|
+
end
|
|
107
|
+
problems + (@natural_key_attributes & @omitted_fields).map do |attribute|
|
|
108
|
+
"#{model} names #{attribute.inspect} as both a natural key attribute and an omitted field"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The plan as data, root entry first: what PlanReport renders.
|
|
113
|
+
def to_h
|
|
114
|
+
entries = @entries.each_value.map do |entry|
|
|
115
|
+
{
|
|
116
|
+
"model" => entry.name,
|
|
117
|
+
"associations" => entry.extra_associations.map(&:to_s),
|
|
118
|
+
"natural_key" => entry.natural_key_attributes,
|
|
119
|
+
"omit_fields" => entry.omitted_fields,
|
|
120
|
+
"omit_associations" => entry.omitted_associations.map(&:to_s)
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
{"root" => @name, "entries" => entries}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The entry for a class nothing declared: defaults only.
|
|
127
|
+
NONE = new.freeze
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Deckard
|
|
6
|
+
# Renders a plan's #to_h for people (text) or tools (json). Rendering
|
|
7
|
+
# never looks at the plan itself, so a new format is a new method here.
|
|
8
|
+
module PlanReport
|
|
9
|
+
FORMATS = %w[text json].freeze
|
|
10
|
+
|
|
11
|
+
LINES = {
|
|
12
|
+
"associations" => "associations",
|
|
13
|
+
"natural_key" => "natural key",
|
|
14
|
+
"omit_fields" => "omit fields",
|
|
15
|
+
"omit_associations" => "omit associations"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.render(plan, format)
|
|
19
|
+
(format == "json") ? "#{JSON.pretty_generate(plan)}\n" : text(plan)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.text(plan)
|
|
23
|
+
blocks = plan["entries"].map do |entry|
|
|
24
|
+
lines = LINES.filter_map do |key, label|
|
|
25
|
+
" #{label.ljust(18)}#{entry[key].join(", ")}" unless entry[key].empty?
|
|
26
|
+
end
|
|
27
|
+
[entry["model"], *lines].join("\n")
|
|
28
|
+
end
|
|
29
|
+
"#{blocks.join("\n\n")}\n"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deckard
|
|
4
|
+
# Writes progress and completion counts to standard error: a live counter
|
|
5
|
+
# while a terminal is attached, then a total line and per-type counts
|
|
6
|
+
# sorted by type name. Standard output is never touched - it belongs to
|
|
7
|
+
# the stream.
|
|
8
|
+
class Status
|
|
9
|
+
def self.progress(action, counts, stderr)
|
|
10
|
+
return unless stderr.tty?
|
|
11
|
+
|
|
12
|
+
stderr.write "\r#{action} #{counts.values.sum} objects"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.report(action, counts, stderr)
|
|
16
|
+
stderr.write "\r\e[K" if stderr.tty?
|
|
17
|
+
stderr.puts "#{action} #{counts.values.sum} total objects:"
|
|
18
|
+
return if counts.empty?
|
|
19
|
+
|
|
20
|
+
stderr.puts
|
|
21
|
+
name_width = counts.keys.map(&:length).max + 2
|
|
22
|
+
count_width = counts.values.max.to_s.length
|
|
23
|
+
counts.sort.each do |type, count|
|
|
24
|
+
stderr.puts "#{type.ljust(name_width)}#{count.to_s.rjust(count_width)}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/deckard.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "deckard/version"
|
|
4
|
+
require_relative "deckard/errors"
|
|
5
|
+
require_relative "deckard/dumper"
|
|
6
|
+
require_relative "deckard/loader"
|
|
7
|
+
require_relative "deckard/model_config"
|
|
8
|
+
require_relative "deckard/active_record/dump"
|
|
9
|
+
require_relative "deckard/active_record/load"
|
|
10
|
+
require_relative "deckard/active_record"
|
|
11
|
+
|
|
12
|
+
require "active_support/lazy_load_hooks"
|
|
13
|
+
ActiveSupport.on_load(:active_record) do
|
|
14
|
+
include Deckard::ActiveRecord
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module Deckard
|
|
18
|
+
# Whether the booted application looks like production. The loader
|
|
19
|
+
# refuses to run there unless the operator passes --force.
|
|
20
|
+
def self.production_environment?
|
|
21
|
+
env = if defined?(::Rails) && ::Rails.respond_to?(:env)
|
|
22
|
+
::Rails.env.to_s
|
|
23
|
+
else
|
|
24
|
+
ENV["RAILS_ENV"] || ENV["RACK_ENV"]
|
|
25
|
+
end
|
|
26
|
+
env == "production"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Stream protocol frames. The header opens every stream; the end marker
|
|
30
|
+
# distinguishes a complete stream from one whose source died mid-dump.
|
|
31
|
+
STREAM_HEADER = [:deckard, 2].freeze
|
|
32
|
+
STREAM_END = [:deckard_end, 2].freeze
|
|
33
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: deckard
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Rob Sanheim
|
|
8
|
+
bindir: exe
|
|
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: '8.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '8.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: optimist
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.2'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.2'
|
|
46
|
+
description: Copy selected ActiveRecord records and their associations between PostgreSQL
|
|
47
|
+
databases, with destination-generated primary keys, remapped foreign keys, and transactional
|
|
48
|
+
loads.
|
|
49
|
+
email:
|
|
50
|
+
- rsanheim@gmail.com
|
|
51
|
+
executables:
|
|
52
|
+
- deckard
|
|
53
|
+
extensions: []
|
|
54
|
+
extra_rdoc_files: []
|
|
55
|
+
files:
|
|
56
|
+
- CHANGELOG.md
|
|
57
|
+
- LICENSE.txt
|
|
58
|
+
- README.md
|
|
59
|
+
- exe/deckard
|
|
60
|
+
- lib/deckard.rb
|
|
61
|
+
- lib/deckard/active_record.rb
|
|
62
|
+
- lib/deckard/active_record/dump.rb
|
|
63
|
+
- lib/deckard/active_record/load.rb
|
|
64
|
+
- lib/deckard/cli.rb
|
|
65
|
+
- lib/deckard/dumper.rb
|
|
66
|
+
- lib/deckard/errors.rb
|
|
67
|
+
- lib/deckard/loader.rb
|
|
68
|
+
- lib/deckard/model_config.rb
|
|
69
|
+
- lib/deckard/plan_report.rb
|
|
70
|
+
- lib/deckard/status.rb
|
|
71
|
+
- lib/deckard/version.rb
|
|
72
|
+
homepage: https://github.com/rsanheim/deckard
|
|
73
|
+
licenses:
|
|
74
|
+
- MIT
|
|
75
|
+
metadata:
|
|
76
|
+
allowed_push_host: https://rubygems.org
|
|
77
|
+
homepage_uri: https://github.com/rsanheim/deckard
|
|
78
|
+
source_code_uri: https://github.com/rsanheim/deckard/tree/main
|
|
79
|
+
rdoc_options: []
|
|
80
|
+
require_paths:
|
|
81
|
+
- lib
|
|
82
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: 3.2.0
|
|
87
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '0'
|
|
92
|
+
requirements: []
|
|
93
|
+
rubygems_version: 4.0.21
|
|
94
|
+
specification_version: 4
|
|
95
|
+
summary: Stream ActiveRecord objects between Rails environments
|
|
96
|
+
test_files: []
|