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 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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "deckard"
5
+ require "deckard/cli"
6
+
7
+ exit Deckard::CLI.new.run(ARGV)
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deckard
4
+ VERSION = "0.2.0"
5
+ 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: []