yiffspace-fixers 0.0.1
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 +6 -0
- data/LICENSE +20 -0
- data/README.md +60 -0
- data/Rakefile +16 -0
- data/lib/generators/yiffspace/fixer/USAGE +40 -0
- data/lib/generators/yiffspace/fixer/fixer_generator.rb +86 -0
- data/lib/generators/yiffspace/fixer/templates/fixer.rb +4 -0
- data/lib/generators/yiffspace/install/fixes_generator.rb +21 -0
- data/lib/generators/yiffspace/install/templates/create_fixes.rb.erb +18 -0
- data/lib/tasks/fixes.rake +86 -0
- data/lib/yiffspace/configuration/fixer_templates.rb +87 -0
- data/lib/yiffspace/fix_tracker.rb +143 -0
- data/lib/yiffspace/fixer_template.rb +44 -0
- data/lib/yiffspace/fixers/engine.rb +17 -0
- data/lib/yiffspace/fixers/requires_fix.rb +63 -0
- data/lib/yiffspace/fixers/version.rb +7 -0
- data/lib/yiffspace/fixers.rb +31 -0
- data/lib/yiffspace/migration_sync.rb +134 -0
- metadata +107 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2deff4a56be5dc1abebbb670f5a509a11f4d257cc2daba5033b9bb87820fb6d4
|
|
4
|
+
data.tar.gz: b19fd72d813bc38fa5a915c876eb4e47f7c15fcf0d051ef3851a7afd9fe3eded
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d1225851f6be5102fe8598353e347e1a4cbd2f771c2139e8156c850fb50d8a6c136c51a6da3ef0d8dd9f23fdc4586dbc375d129b32461163fb9bdf7b2182ffa9
|
|
7
|
+
data.tar.gz: 9c1e989b7c8f83314de5b30d6ab7e5b9499611b6bcc5569b9459156fd2761d52dcc8f391c330205c2db29d50f4bb9802209b26f6dd39d4491025d63113bd206d
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Donovan_DMC
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# YiffSpace::Fixers
|
|
2
|
+
|
|
3
|
+
One-time `db/fixes/*.rb` scripts, tracked the same way ActiveRecord's own
|
|
4
|
+
`schema_migrations` tracks migrations - for https://yiff.space and related
|
|
5
|
+
projects. Lives alongside, and depends on, the [`yiffspace`](../yiffspace)
|
|
6
|
+
gem in this repo.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add this line to your application's Gemfile:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem "yiffspace-fixers"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
And then execute:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
$ bundle install
|
|
20
|
+
$ bin/rails generate yiffspace:install:fixes
|
|
21
|
+
$ bin/rails db:migrate
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
$ bin/rails generate yiffspace:fixer some_description
|
|
28
|
+
$ bin/rails fixes:list
|
|
29
|
+
$ bin/rails fixes:migrate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A fix can require that a migration has already been applied, and a migration can require that a
|
|
33
|
+
fix has already been applied - useful when one depends on schema or data the other provides.
|
|
34
|
+
`bin/rails fixes:migrate_all` runs both `db/migrate` and `db/fixes` together, in whichever order
|
|
35
|
+
those requirements demand:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# db/fixes/12_backfill_widget_type.rb
|
|
39
|
+
YiffSpace::FixTracker.requires_migration!("20260822004454")
|
|
40
|
+
|
|
41
|
+
# db/migrate/20260901000000_remove_legacy_widget_column.rb
|
|
42
|
+
class RemoveLegacyWidgetColumn < ActiveRecord::Migration[8.1]
|
|
43
|
+
requires_fix(12)
|
|
44
|
+
|
|
45
|
+
def change
|
|
46
|
+
...
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
See `YiffSpace::FixTracker`, `YiffSpace::FixerTemplate`, `YiffSpace::Fixers::RequiresFix`, and
|
|
52
|
+
`YiffSpace::MigrationSync` for the full API.
|
|
53
|
+
|
|
54
|
+
## Contributing
|
|
55
|
+
|
|
56
|
+
Go away
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require("bundler/setup")
|
|
4
|
+
|
|
5
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
6
|
+
load("rails/tasks/engine.rake")
|
|
7
|
+
|
|
8
|
+
load("rails/tasks/statistics.rake")
|
|
9
|
+
|
|
10
|
+
require("bundler/gem_tasks")
|
|
11
|
+
|
|
12
|
+
# CI releases from an already-tagged, already-pushed commit checked out at a detached HEAD, so
|
|
13
|
+
# the default release task's git tag/push step has nothing to do and fails trying to push a
|
|
14
|
+
# branch ref that does not exist in that checkout.
|
|
15
|
+
Rake::Task["release"].clear
|
|
16
|
+
task("release" => "release:rubygem_push")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generates a new one-time fix script tracked by YiffSpace::FixTracker.
|
|
3
|
+
|
|
4
|
+
Example:
|
|
5
|
+
bin/rails generate yiffspace:fixer fixer_name
|
|
6
|
+
|
|
7
|
+
This will create:
|
|
8
|
+
db/fixes/<id>_fixer_name.rb
|
|
9
|
+
|
|
10
|
+
bin/rails generate yiffspace:fixer fixer_name --steps 2
|
|
11
|
+
|
|
12
|
+
This will create:
|
|
13
|
+
db/fixes/<id>_1_fixer_name.rb
|
|
14
|
+
db/fixes/<id>_2_fixer_name.rb
|
|
15
|
+
|
|
16
|
+
A host app can define named templates, auto-discovered from db/fixer_templates/*.rb (see
|
|
17
|
+
YiffSpace::FixerTemplate and YiffSpace.config.fixer_templates_path) - no registration needed,
|
|
18
|
+
just define a subclass. Each `step` block's return value becomes that step's file content:
|
|
19
|
+
|
|
20
|
+
# db/fixer_templates/elasticsearch_template.rb
|
|
21
|
+
class ElasticsearchTemplate < YiffSpace::FixerTemplate
|
|
22
|
+
short("e")
|
|
23
|
+
|
|
24
|
+
step { "# add the elasticsearch index\n" }
|
|
25
|
+
step { "# backfill the elasticsearch data\n" }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
bin/rails generate yiffspace:fixer fixer_name --template elasticsearch
|
|
29
|
+
bin/rails generate yiffspace:fixer fixer_name -t elasticsearch
|
|
30
|
+
bin/rails generate yiffspace:fixer fixer_name -e
|
|
31
|
+
|
|
32
|
+
All three create the 2 steps above; `short` is optional - a template is always reachable via
|
|
33
|
+
--template/-t, the shortname flag is just a convenience.
|
|
34
|
+
|
|
35
|
+
A host app can also register a preset with just a step count (no per-step content - every
|
|
36
|
+
step falls back to the generic fixer.rb template) directly in an initializer instead:
|
|
37
|
+
|
|
38
|
+
YiffSpace.configure do |config|
|
|
39
|
+
config.fixer_templates.register(:legacy, steps: 3)
|
|
40
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yiffspace
|
|
4
|
+
# Scaffolds a new db/fixes/*.rb script for YiffSpace::FixTracker (see `rake fixes:list`).
|
|
5
|
+
#
|
|
6
|
+
# `--steps N` splits the fix into N ordered steps sharing an id, each one a copy of the generic
|
|
7
|
+
# fixer.rb template. A host app can instead define a YiffSpace::FixerTemplate subclass (see that
|
|
8
|
+
# class) to control both the step count and each step's file content, auto-discovered from
|
|
9
|
+
# YiffSpace.config.fixer_templates_path - reachable here with `--template <name>`/`-t <name>`,
|
|
10
|
+
# or with the template's own shortname flag if it registered one (e.g. `-e`).
|
|
11
|
+
class FixerGenerator < Rails::Generators::NamedBase
|
|
12
|
+
source_root(File.expand_path("templates", __dir__))
|
|
13
|
+
# No short alias - -s/-f/-p/-q are already claimed by Rails::Generators::Base's own runtime
|
|
14
|
+
# options (--skip/--force/--pretend/--quiet), and Thor silently lets the last-defined
|
|
15
|
+
# class_option win a collision instead of erroring.
|
|
16
|
+
class_option(:steps, type: :numeric, default: 1,
|
|
17
|
+
desc: "Split the fix into N ordered steps sharing an id, e.g. 12_1_name.rb, 12_2_name.rb")
|
|
18
|
+
class_option(:template, type: :string, default: nil, aliases: ["-t"],
|
|
19
|
+
desc: "Use a fixer template registered with YiffSpace.config.fixer_templates")
|
|
20
|
+
|
|
21
|
+
YiffSpace.config.fixer_templates.each do |template|
|
|
22
|
+
next unless template.short
|
|
23
|
+
|
|
24
|
+
class_option(template.name.to_sym, type: :boolean, default: false, aliases: ["-#{template.short}"],
|
|
25
|
+
desc: "Shortcut for --template #{template.name}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_fixer
|
|
29
|
+
content_blocks = resolve_content_blocks
|
|
30
|
+
|
|
31
|
+
id = Dir[File.join(destination_root, "db/fixes/*.rb")].map { |f| File.basename(f, ".rb").split("_").first.to_i }.max.to_i + 1
|
|
32
|
+
|
|
33
|
+
if content_blocks.size == 1
|
|
34
|
+
create_fixer_file("db/fixes/#{id}_#{file_name}.rb", content_blocks.first)
|
|
35
|
+
else
|
|
36
|
+
content_blocks.each_with_index { |block, index| create_fixer_file("db/fixes/#{id}_#{index + 1}_#{file_name}.rb", block) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# One entry per step - a block returning that step's file content, or nil to fall back to
|
|
43
|
+
# copying the generic fixer.rb template (a plain --steps N, or a manually
|
|
44
|
+
# YiffSpace.config.fixer_templates.register'd template, has no per-step content).
|
|
45
|
+
def resolve_content_blocks
|
|
46
|
+
template = resolve_template
|
|
47
|
+
return template.content_blocks if template
|
|
48
|
+
|
|
49
|
+
Array.new(steps_from_flag)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_template
|
|
53
|
+
name = template_name
|
|
54
|
+
return nil if name.nil?
|
|
55
|
+
|
|
56
|
+
template = YiffSpace.config.fixer_templates[name]
|
|
57
|
+
raise(Thor::Error, "no fixer template named #{name.inspect} is registered") unless template
|
|
58
|
+
|
|
59
|
+
template
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The template chosen via `--template <name>`, or via a registered template's own shortname
|
|
63
|
+
# flag (e.g. `-e` for a template registered with `short: "e"`) - nil if neither was passed.
|
|
64
|
+
def template_name
|
|
65
|
+
return options["template"] if options["template"]
|
|
66
|
+
|
|
67
|
+
YiffSpace.config.fixer_templates.find { |template| template.short && options[template.name] }&.name
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def steps_from_flag
|
|
71
|
+
steps = options["steps"].to_i
|
|
72
|
+
raise(Thor::Error, "--steps must be at least 1") if steps < 1
|
|
73
|
+
|
|
74
|
+
steps
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def create_fixer_file(path, content_block)
|
|
78
|
+
if content_block
|
|
79
|
+
create_file(path, content_block.call)
|
|
80
|
+
chmod(path, "+x")
|
|
81
|
+
else
|
|
82
|
+
copy_file("fixer.rb", path, mode: :preserve)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yiffspace
|
|
4
|
+
module Install
|
|
5
|
+
# Installs YiffSpace::FixTracker into a host app: `bin/rails generate yiffspace:install:fixes`
|
|
6
|
+
# copies the `fixes` tracking table migration. Run `bin/rails db:migrate` afterwards.
|
|
7
|
+
class FixesGenerator < Rails::Generators::Base
|
|
8
|
+
include(Rails::Generators::Migration)
|
|
9
|
+
|
|
10
|
+
source_root(File.expand_path("templates", __dir__))
|
|
11
|
+
|
|
12
|
+
def self.next_migration_number(dirname)
|
|
13
|
+
ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template("create_fixes.rb.erb", "db/migrate/create_fixes.rb")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tracks which db/fixes/*.rb scripts have been applied, the same way schema_migrations tracks
|
|
4
|
+
# migrations - see YiffSpace::FixTracker and `rake fixes:migrate`/`rake fixes:list`.
|
|
5
|
+
#
|
|
6
|
+
# `id` is the fix's own leading number (e.g. 12, 23), not a surrogate key, so it isn't unique on
|
|
7
|
+
# its own - a fix can be split into ordered sub-steps (12_1, 12_2, ...) that share an id and are
|
|
8
|
+
# distinguished by `index`. `index` is null for fixes with no subtype.
|
|
9
|
+
class CreateFixes < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
|
|
10
|
+
def change
|
|
11
|
+
create_table(:fixes, id: false) do |t|
|
|
12
|
+
t.integer(:id, null: false) # not a surrogate key, this is the fix's own leading number
|
|
13
|
+
t.integer(:index)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index(:fixes, %i[id index], unique: true, nulls_not_distinct: true)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Keep structure.sql's fixes rows in sync with the database on every schema dump, the same way
|
|
4
|
+
# ActiveRecord keeps schema_migrations rows in sync - not just when `fixes:migrate` runs.
|
|
5
|
+
Rake::Task["db:schema:dump"].enhance do
|
|
6
|
+
YiffSpace::FixTracker.dump_structure_sql!
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
namespace(:fixes) do
|
|
10
|
+
desc("List available fix scripts in db/fixes, marking which have already been applied")
|
|
11
|
+
task(list: :environment) do
|
|
12
|
+
applied = YiffSpace::FixTracker.applied
|
|
13
|
+
YiffSpace::FixTracker.all_fixes.each do |name|
|
|
14
|
+
puts("#{applied.include?(YiffSpace::FixTracker.key_for(name)) ? '[x]' : '[ ]'} #{name}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc("Run a fix script from db/fixes by id or name, e.g. `rake fixes:run[1]` - re-runs even if already recorded as applied")
|
|
19
|
+
task(:run, [:name] => :environment) do |_task, args|
|
|
20
|
+
name = args[:name].to_s.delete_suffix(".rb")
|
|
21
|
+
abort("Usage: rake fixes:run[id_or_name] (see `rake fixes:list`)") if name.blank?
|
|
22
|
+
|
|
23
|
+
candidates = Dir["db/fixes/*.rb"]
|
|
24
|
+
exact = candidates.find { |path| File.basename(path, ".rb") == name }
|
|
25
|
+
matches = exact ? [exact] : candidates.select { |path| File.basename(path, ".rb").start_with?("#{name}_") || File.basename(path, ".rb").include?(name) }
|
|
26
|
+
|
|
27
|
+
if matches.empty?
|
|
28
|
+
abort("No fix matches \"#{name}\". Run `rake fixes:list` to see available fixes.")
|
|
29
|
+
elsif matches.size > 1
|
|
30
|
+
abort("Multiple fixes match \"#{name}\", be more specific:\n#{matches.map { |m| " #{File.basename(m, '.rb')}" }.join("\n")}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
puts("Running #{File.basename(matches.first, '.rb')}...")
|
|
34
|
+
name = YiffSpace::FixTracker.run!(matches.first)
|
|
35
|
+
puts("Recorded #{name} as applied")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc("Apply all fix scripts not yet recorded as applied, in order, then dump the schema like db:migrate does")
|
|
39
|
+
task(migrate: :environment) do
|
|
40
|
+
pending = YiffSpace::FixTracker.pending
|
|
41
|
+
|
|
42
|
+
if pending.empty?
|
|
43
|
+
puts("No pending fixes.")
|
|
44
|
+
else
|
|
45
|
+
pending.each do |name|
|
|
46
|
+
puts("Running #{name}...")
|
|
47
|
+
YiffSpace::FixTracker.run!(YiffSpace::FixTracker.fix_path(name))
|
|
48
|
+
puts("Recorded #{name} as applied")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Rake::Task["db:_dump"].invoke
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Split into its own namespace block, rather than growing the one above past
|
|
57
|
+
# Metrics/BlockLength's max - Rake happily reopens a namespace.
|
|
58
|
+
namespace(:fixes) do
|
|
59
|
+
desc("Apply all pending db/migrate migrations and db/fixes fixes together, in whichever order their requires_fix/requires_migration! declarations demand")
|
|
60
|
+
task(migrate_all: :environment) do
|
|
61
|
+
steps = YiffSpace::MigrationSync.plan
|
|
62
|
+
|
|
63
|
+
if steps.empty?
|
|
64
|
+
puts("Nothing to migrate or fix.")
|
|
65
|
+
else
|
|
66
|
+
steps.each do |step|
|
|
67
|
+
puts(YiffSpace::MigrationSync.describe(step))
|
|
68
|
+
YiffSpace::MigrationSync.apply(step)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Rake::Task["db:_dump"].invoke
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
desc("Record all of the current fixes as having been applied")
|
|
76
|
+
task(load: :environment) do
|
|
77
|
+
list = YiffSpace::FixTracker.all_fixes.map { YiffSpace::FixTracker.key_for(it) }
|
|
78
|
+
existing = ActiveRecord::Base.connection.select_all('SELECT id, "index" FROM fixes ORDER BY id, "index"').rows
|
|
79
|
+
list.reject! { |id, index| existing.any? { |e| e[0] == id && e[1] == index } }
|
|
80
|
+
unless list.empty?
|
|
81
|
+
values = list.map { |id, index| "(#{id}, #{index.nil? ? 'NULL' : index})" }.join(", ")
|
|
82
|
+
ActiveRecord::Base.connection.execute(%(INSERT INTO fixes (id, "index") VALUES #{values}))
|
|
83
|
+
end
|
|
84
|
+
puts("Loaded #{list.length} #{'fix'.pluralize(list.length)}")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YiffSpace
|
|
4
|
+
class Configuration
|
|
5
|
+
# Named presets for `bin/rails generate yiffspace:fixer`, so `--template <name>` (or a
|
|
6
|
+
# registered shortname flag) can stand in for `--steps N`. Populated two ways:
|
|
7
|
+
#
|
|
8
|
+
# - Automatically, from YiffSpace::FixerTemplate subclasses found in
|
|
9
|
+
# YiffSpace.config.fixer_templates_path (the common case - see FixerTemplate).
|
|
10
|
+
# - Manually, via #register, for a preset that just needs a step count and no per-step
|
|
11
|
+
# content (every step falls back to the generic fixer.rb template).
|
|
12
|
+
#
|
|
13
|
+
# See YiffSpace::Configuration#fixer_templates and Yiffspace::FixerGenerator.
|
|
14
|
+
class FixerTemplates
|
|
15
|
+
include(Enumerable)
|
|
16
|
+
|
|
17
|
+
Template = Struct.new(:name, :short, :content_blocks, keyword_init: true) do
|
|
18
|
+
def steps
|
|
19
|
+
content_blocks.size
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# -t is yiffspace:fixer's own --template flag; -f/-p/-q/-s are Rails::Generators::Base's
|
|
24
|
+
# built-in runtime options (--force/--pretend/--quiet/--skip).
|
|
25
|
+
RESERVED_SHORTS = %w[t f p q s].freeze
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@templates = {}
|
|
29
|
+
@discovered = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# short is optional - a template is always reachable via `--template <name>`, a shortname
|
|
33
|
+
# flag (e.g. `-e`) is just a convenient alias for it.
|
|
34
|
+
def register(name, steps:, short: nil)
|
|
35
|
+
raise(ArgumentError, "steps must be a positive integer, got #{steps.inspect}") unless steps.is_a?(Integer) && steps.positive?
|
|
36
|
+
|
|
37
|
+
add(name.to_s, short: short, content_blocks: Array.new(steps))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def [](name)
|
|
41
|
+
discover!
|
|
42
|
+
@templates[name.to_s]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def each(&)
|
|
46
|
+
discover!
|
|
47
|
+
@templates.each_value(&)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def add(name, short:, content_blocks:)
|
|
53
|
+
raise(ArgumentError, "a fixer template named #{name.inspect} is already registered") if @templates.key?(name)
|
|
54
|
+
|
|
55
|
+
short = short.to_s.delete_prefix("-") if short
|
|
56
|
+
if short
|
|
57
|
+
raise(ArgumentError, "\"-#{short}\" is reserved by yiffspace:fixer's own options") if RESERVED_SHORTS.include?(short)
|
|
58
|
+
|
|
59
|
+
existing = @templates.values.find { |template| template.short == short }
|
|
60
|
+
raise(ArgumentError, "\"-#{short}\" is already registered to the #{existing.name.inspect} fixer template") if existing
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@templates[name] = Template.new(name: name, short: short, content_blocks: content_blocks)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Scans YiffSpace.config.fixer_templates_path for YiffSpace::FixerTemplate subclasses and
|
|
67
|
+
# registers each one - runs once, the first time a template is looked up (#[] or #each),
|
|
68
|
+
# so it sees whatever the host app registered manually beforehand.
|
|
69
|
+
def discover!
|
|
70
|
+
return if @discovered
|
|
71
|
+
|
|
72
|
+
@discovered = true
|
|
73
|
+
path = YiffSpace.config.fixer_templates_path
|
|
74
|
+
return unless path && Dir.exist?(path)
|
|
75
|
+
|
|
76
|
+
before = FixerTemplate.subclasses
|
|
77
|
+
Dir[File.join(path, "*.rb")].each { |file| require(file) }
|
|
78
|
+
|
|
79
|
+
(FixerTemplate.subclasses - before).each do |klass|
|
|
80
|
+
raise(ArgumentError, "#{klass.name} has no step blocks - add at least one `step { ... }`") if klass.steps.empty?
|
|
81
|
+
|
|
82
|
+
add(klass.template_name, short: klass.short, content_blocks: klass.steps)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YiffSpace
|
|
4
|
+
# Tracks which db/fixes/*.rb scripts a host app has applied, in a `fixes` table (see
|
|
5
|
+
# db/migrate/*_create_fixes.rb) the same way ActiveRecord's own schema_migrations table
|
|
6
|
+
# tracks migrations. Used by the `fixes:*` rake tasks (lib/tasks/fixes.rake).
|
|
7
|
+
module FixTracker
|
|
8
|
+
# Raised by requires_migration! when the required migration hasn't been applied yet.
|
|
9
|
+
class MigrationRequired < StandardError; end
|
|
10
|
+
|
|
11
|
+
# A fix script calls this as its first statement to declare that a migration must already be
|
|
12
|
+
# applied before it can run, e.g.:
|
|
13
|
+
#
|
|
14
|
+
# YiffSpace::FixTracker.requires_migration!("20260822004454")
|
|
15
|
+
#
|
|
16
|
+
# Written as a literal string call like that (not built dynamically) so YiffSpace::MigrationSync
|
|
17
|
+
# can also read it back out of the file with #required_migration_for, without loading/running
|
|
18
|
+
# the fix, to order `fixes:migrate_all` correctly.
|
|
19
|
+
REQUIRES_MIGRATION_PATTERN = /^\s*YiffSpace::FixTracker\.requires_migration!\(\s*["']([^"']+)["']\s*\)/
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def fix_path(name)
|
|
24
|
+
Rails.root.glob("db/fixes/#{name}.rb").first
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def requires_migration!(version)
|
|
28
|
+
return if migration_applied?(version)
|
|
29
|
+
|
|
30
|
+
raise(MigrationRequired, "migration #{version} must be applied before this fix can run")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def migration_applied?(version)
|
|
34
|
+
migration_context.get_all_versions.include?(version.to_i)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Statically reads a fix script's declared requires_migration! version, if any, without
|
|
38
|
+
# loading/running it - nil if the fix has no such call.
|
|
39
|
+
def required_migration_for(name)
|
|
40
|
+
path = fix_path(name)
|
|
41
|
+
return nil unless path
|
|
42
|
+
|
|
43
|
+
path.read.match(REQUIRES_MIGRATION_PATTERN)&.captures&.first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolves a YiffSpace::Fixers::RequiresFix id_or_name to the fix name(s) it refers to - an
|
|
47
|
+
# Integer id covers every step sharing that id (e.g. 2 -> %w[2_1_first_step 2_2_second_step]),
|
|
48
|
+
# a name matches that one fix file exactly.
|
|
49
|
+
def resolve(id_or_name)
|
|
50
|
+
if id_or_name.is_a?(Integer)
|
|
51
|
+
all_fixes.select { |name| key_for(name).first == id_or_name }
|
|
52
|
+
else
|
|
53
|
+
name = id_or_name.to_s.delete_suffix(".rb")
|
|
54
|
+
all_fixes.select { |candidate| candidate == name }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def migration_context
|
|
59
|
+
ActiveRecord::Tasks::DatabaseTasks.migration_connection_pool.migration_context
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# db/fixes filenames are "<id>_description.rb", occasionally "<id>_<index>_description.rb"
|
|
63
|
+
# (e.g. 1_1_..., 1_2_...) for a handful of fixes split into ordered steps - the `fixes`
|
|
64
|
+
# table mirrors this as an (id, index) pair, with index null for fixes with no subtype.
|
|
65
|
+
def key_for(name)
|
|
66
|
+
id, maybe_index = name.split("_", 3)
|
|
67
|
+
index = maybe_index =~ /\A\d+\z/ ? maybe_index.to_i : nil
|
|
68
|
+
[id.to_i, index]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Sort on the (id, index) parts, not the filename string, so 12 doesn't sort before 2_2.
|
|
72
|
+
def sort_key(name)
|
|
73
|
+
id, index = key_for(name)
|
|
74
|
+
[id, index || 0]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Only numbered one-time fixes are tracked/auto-applied - a host app's db/fixes may also hold
|
|
78
|
+
# reusable on-demand maintenance scripts that aren't meant to run automatically as part of
|
|
79
|
+
# `fixes:migrate`. Those stay reachable via `fixes:run[name]`.
|
|
80
|
+
def all_fixes
|
|
81
|
+
Rails.root.glob("db/fixes/*.rb").map { |path| File.basename(path, ".rb") }.grep(/\A\d/).sort_by { |name| sort_key(name) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def applied
|
|
85
|
+
ActiveRecord::Base.connection.select_rows('SELECT id, "index" FROM fixes').to_set
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def applied?(name)
|
|
89
|
+
id, index = key_for(name)
|
|
90
|
+
conn = ActiveRecord::Base.connection
|
|
91
|
+
index_clause = index.nil? ? '"index" IS NULL' : %("index" = #{index})
|
|
92
|
+
conn.select_value("SELECT 1 FROM fixes WHERE id = #{id} AND #{index_clause}").present?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def pending
|
|
96
|
+
applied_keys = applied
|
|
97
|
+
all_fixes.reject { |name| applied_keys.include?(key_for(name)) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def record!(name)
|
|
101
|
+
id, index = key_for(name)
|
|
102
|
+
conn = ActiveRecord::Base.connection
|
|
103
|
+
conn.execute(<<~SQL.squish)
|
|
104
|
+
INSERT INTO fixes (id, "index") VALUES (#{id}, #{index || 'NULL'})
|
|
105
|
+
ON CONFLICT (id, "index") DO NOTHING
|
|
106
|
+
SQL
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run!(path)
|
|
110
|
+
name = File.basename(path, ".rb")
|
|
111
|
+
load(File.expand_path(path))
|
|
112
|
+
record!(name)
|
|
113
|
+
name
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Mirrors how ActiveRecord appends `INSERT INTO "schema_migrations"` to structure.sql after
|
|
117
|
+
# a schema dump (see Connection#dump_schema_versions) - appends the currently-applied fixes
|
|
118
|
+
# the same way, so loading structure.sql into a fresh database marks them applied too instead
|
|
119
|
+
# of leaving `fixes:migrate` to re-run every historical fix. Hooked into `db:schema:dump` in
|
|
120
|
+
# lib/tasks/fixes.rake, so it runs on every schema dump, not just `fixes:migrate`.
|
|
121
|
+
def dump_structure_sql!
|
|
122
|
+
db_config = ActiveRecord::Base.connection_db_config
|
|
123
|
+
return unless db_config.schema_format == :sql
|
|
124
|
+
|
|
125
|
+
filename = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config)
|
|
126
|
+
return unless filename && File.exist?(filename)
|
|
127
|
+
|
|
128
|
+
conn = ActiveRecord::Base.connection
|
|
129
|
+
return unless conn.table_exists?(:fixes)
|
|
130
|
+
|
|
131
|
+
rows = conn.select_rows('SELECT id, "index" FROM fixes ORDER BY id DESC, "index" DESC NULLS LAST')
|
|
132
|
+
return if rows.empty?
|
|
133
|
+
|
|
134
|
+
values = rows.map { |id, index| "(#{id}, #{index || 'NULL'})" }.join(",\n")
|
|
135
|
+
File.open(filename, "a") do |f|
|
|
136
|
+
f.puts(<<~TEXT)
|
|
137
|
+
INSERT INTO "fixes" (id, "index") VALUES
|
|
138
|
+
#{values};
|
|
139
|
+
TEXT
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YiffSpace
|
|
4
|
+
# Base class for a `bin/rails generate yiffspace:fixer` template, auto-discovered from *.rb
|
|
5
|
+
# files in YiffSpace.config.fixer_templates_path (default db/fixer_templates) - no explicit
|
|
6
|
+
# registration needed, just define a subclass:
|
|
7
|
+
#
|
|
8
|
+
# class ElasticsearchTemplate < YiffSpace::FixerTemplate
|
|
9
|
+
# short("e")
|
|
10
|
+
#
|
|
11
|
+
# step { "..." }
|
|
12
|
+
# step { "..." }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# The template's name comes from the class name with a trailing "Template" dropped and
|
|
16
|
+
# kebab-cased (ElasticsearchTemplate -> "elasticsearch", OpenSearchTemplate -> "open-search"),
|
|
17
|
+
# reachable as `--template <name>` (see Yiffspace::FixerGenerator). Its step count is the
|
|
18
|
+
# number of `step` blocks - each one's return value becomes that step's fix file content,
|
|
19
|
+
# instead of the generic fixer.rb template a plain `--steps N` copies for every step.
|
|
20
|
+
class FixerTemplate
|
|
21
|
+
class << self
|
|
22
|
+
# Optional - a template is always reachable via `--template <name>`, a registered
|
|
23
|
+
# shortname flag (e.g. `-e`) is just a convenient alias for it.
|
|
24
|
+
def short(value = nil)
|
|
25
|
+
@short = value.to_s.delete_prefix("-") unless value.nil?
|
|
26
|
+
@short
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def step(&block)
|
|
30
|
+
raise(ArgumentError, "#{name}.step requires a block") unless block
|
|
31
|
+
|
|
32
|
+
steps << block
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def steps
|
|
36
|
+
@steps ||= []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def template_name
|
|
40
|
+
name.demodulize.delete_suffix("Template").underscore.dasherize
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require("rails")
|
|
4
|
+
|
|
5
|
+
module YiffSpace
|
|
6
|
+
module Fixers
|
|
7
|
+
# No app/ of its own - this exists so Rails::Engine's default rake_tasks block picks up
|
|
8
|
+
# lib/tasks/fixes.rake (config.root anchors where it looks for lib/tasks/**/*.rake).
|
|
9
|
+
class Engine < ::Rails::Engine
|
|
10
|
+
config.root = File.expand_path("../../..", __dir__)
|
|
11
|
+
|
|
12
|
+
initializer("yiffspace.fixers.requires_fix") do
|
|
13
|
+
ActiveRecord::Migration.include(YiffSpace::Fixers::RequiresFix)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YiffSpace
|
|
4
|
+
module Fixers
|
|
5
|
+
# Adds `requires_fix` to ActiveRecord::Migration - the migration-side mirror of
|
|
6
|
+
# YiffSpace::FixTracker.requires_migration! - so a migration can declare that one or more
|
|
7
|
+
# db/fixes/*.rb scripts must already be applied before it runs, e.g.:
|
|
8
|
+
#
|
|
9
|
+
# class BackfillWidgetType < ActiveRecord::Migration[8.1]
|
|
10
|
+
# requires_fix(3)
|
|
11
|
+
#
|
|
12
|
+
# def change
|
|
13
|
+
# ...
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Checked whenever the migration actually runs (so plain `db:migrate` still refuses to run it
|
|
18
|
+
# out of order), and read back ahead of time by YiffSpace::MigrationSync to order
|
|
19
|
+
# `fixes:migrate_all` correctly. Included into ActiveRecord::Migration by
|
|
20
|
+
# YiffSpace::Fixers::Engine.
|
|
21
|
+
module RequiresFix
|
|
22
|
+
# Raised when a migration runs before one of its required fixes has been applied.
|
|
23
|
+
class FixRequired < StandardError; end
|
|
24
|
+
|
|
25
|
+
def self.included(base)
|
|
26
|
+
base.extend(ClassMethods)
|
|
27
|
+
base.prepend(InstanceMethods)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module ClassMethods
|
|
31
|
+
# id_or_name: a fix's leading id (e.g. 3, covers every step sharing that id) or its full
|
|
32
|
+
# file name (e.g. "2_1_first_step") for one specific step - see YiffSpace::FixTracker#resolve.
|
|
33
|
+
def requires_fix(id_or_name)
|
|
34
|
+
required_fixes << id_or_name
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def required_fixes
|
|
38
|
+
@required_fixes ||= []
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
module InstanceMethods
|
|
43
|
+
def migrate(direction)
|
|
44
|
+
check_required_fixes! if direction == :up
|
|
45
|
+
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def check_required_fixes!
|
|
52
|
+
self.class.required_fixes.each do |id_or_name|
|
|
53
|
+
names = YiffSpace::FixTracker.resolve(id_or_name)
|
|
54
|
+
raise(FixRequired, "#{self.class.name} requires fix #{id_or_name.inspect}, which does not exist") if names.empty?
|
|
55
|
+
|
|
56
|
+
missing = names.reject { |name| YiffSpace::FixTracker.applied?(name) }
|
|
57
|
+
raise(FixRequired, "#{self.class.name} requires #{missing.join(', ')} to be applied first") if missing.any?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require("yiffspace/core")
|
|
4
|
+
require("zeitwerk")
|
|
5
|
+
|
|
6
|
+
loader = Zeitwerk::Loader.for_gem_extension(YiffSpace)
|
|
7
|
+
loader.ignore("#{__dir__}/fixers/engine.rb")
|
|
8
|
+
loader.setup
|
|
9
|
+
|
|
10
|
+
# Require the engine eagerly so it registers with Rails before the host app's
|
|
11
|
+
# active_support.initialize_per_engine_zeitwerk_loaders initializer runs - same reasoning as
|
|
12
|
+
# yiffspace's own engine.rb/yiffspace-auth's auth/engine.rb.
|
|
13
|
+
require_relative("fixers/engine") if defined?(Rails)
|
|
14
|
+
|
|
15
|
+
module YiffSpace
|
|
16
|
+
class Configuration
|
|
17
|
+
# Named `--steps` presets for `bin/rails generate yiffspace:fixer`. See
|
|
18
|
+
# YiffSpace::Configuration::FixerTemplates.
|
|
19
|
+
def fixer_templates
|
|
20
|
+
@fixer_templates ||= FixerTemplates.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Directory scanned for YiffSpace::FixerTemplate subclasses (see FixerTemplate) - defaults to
|
|
24
|
+
# db/fixer_templates in the host app.
|
|
25
|
+
attr_writer(:fixer_templates_path)
|
|
26
|
+
|
|
27
|
+
def fixer_templates_path
|
|
28
|
+
@fixer_templates_path ||= Rails.root.join("db/fixer_templates")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YiffSpace
|
|
4
|
+
# Computes and applies pending db/migrate/*.rb migrations and db/fixes/*.rb fixes together, in
|
|
5
|
+
# whichever order their cross requirements demand - a fix can require a migration
|
|
6
|
+
# (YiffSpace::FixTracker.requires_migration!) and a migration can require a fix
|
|
7
|
+
# (YiffSpace::Fixers::RequiresFix#requires_fix). Migrations stay ordered among themselves by
|
|
8
|
+
# version, fixes among themselves by YiffSpace::FixTracker.sort_key, and those cross
|
|
9
|
+
# requirements are woven in via a topological sort. #plan/#apply are used by `rake
|
|
10
|
+
# fixes:migrate_all` (see lib/tasks/fixes.rake).
|
|
11
|
+
module MigrationSync
|
|
12
|
+
# A single item in the computed #plan - kind is :migration (ref a MigrationProxy) or :fix
|
|
13
|
+
# (ref a fix name).
|
|
14
|
+
Step = Struct.new(:kind, :ref, :key)
|
|
15
|
+
|
|
16
|
+
# Raised when a fix/migration requires something that isn't findable, or requirements form a
|
|
17
|
+
# cycle (e.g. a fix requires a migration that itself requires that same fix).
|
|
18
|
+
class UnresolvableOrder < StandardError; end
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# The ordered list of pending Steps - migrations and fixes interleaved wherever a
|
|
23
|
+
# requires_fix/requires_migration! forces it, otherwise each kept in its own natural order.
|
|
24
|
+
def plan
|
|
25
|
+
migrations = pending_migrations
|
|
26
|
+
fixes = FixTracker.pending
|
|
27
|
+
|
|
28
|
+
steps = {}
|
|
29
|
+
migrations.each { |m| steps[[:migration, m.version]] = Step.new(:migration, m, [:migration, m.version]) }
|
|
30
|
+
fixes.each { |f| steps[[:fix, f]] = Step.new(:fix, f, [:fix, f]) }
|
|
31
|
+
|
|
32
|
+
edges = Hash.new { |h, k| h[k] = [] }
|
|
33
|
+
indegree = steps.each_key.index_with(0)
|
|
34
|
+
|
|
35
|
+
link = lambda do |before, after|
|
|
36
|
+
next unless steps.key?(before) && steps.key?(after)
|
|
37
|
+
|
|
38
|
+
edges[before] << after
|
|
39
|
+
indegree[after] += 1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Preserve each kind's own natural order as implicit dependencies.
|
|
43
|
+
migrations.each_cons(2) { |a, b| link.call([:migration, a.version], [:migration, b.version]) }
|
|
44
|
+
fixes.each_cons(2) { |a, b| link.call([:fix, a], [:fix, b]) }
|
|
45
|
+
|
|
46
|
+
link_fix_requirements(fixes, link)
|
|
47
|
+
link_migration_requirements(migrations, link)
|
|
48
|
+
|
|
49
|
+
topological_sort(steps, edges, indegree)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def link_fix_requirements(fixes, link)
|
|
53
|
+
fixes.each do |name|
|
|
54
|
+
version = FixTracker.required_migration_for(name)
|
|
55
|
+
next unless version
|
|
56
|
+
raise(UnresolvableOrder, "#{name} requires migration #{version}, which does not exist") unless migration_exists?(version)
|
|
57
|
+
|
|
58
|
+
link.call([:migration, version.to_i], [:fix, name])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def link_migration_requirements(migrations, link)
|
|
63
|
+
migrations.each do |m|
|
|
64
|
+
migration_class_for(m).required_fixes.each do |id_or_name|
|
|
65
|
+
names = FixTracker.resolve(id_or_name)
|
|
66
|
+
raise(UnresolvableOrder, "#{m.name} requires fix #{id_or_name.inspect}, which does not exist") if names.empty?
|
|
67
|
+
|
|
68
|
+
names.each { |name| link.call([:fix, name], [:migration, m.version]) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Kahn's algorithm - ready nodes are processed FIFO in `steps`' insertion order (migrations
|
|
74
|
+
# before fixes when both are ready at once), so requirements only ever reorder things when
|
|
75
|
+
# they actually have to.
|
|
76
|
+
def topological_sort(steps, edges, indegree)
|
|
77
|
+
ready = indegree.select { |_, count| count.zero? }.keys
|
|
78
|
+
ordered = []
|
|
79
|
+
|
|
80
|
+
until ready.empty?
|
|
81
|
+
key = ready.shift
|
|
82
|
+
ordered << steps.fetch(key)
|
|
83
|
+
|
|
84
|
+
edges[key].each do |dependent|
|
|
85
|
+
indegree[dependent] -= 1
|
|
86
|
+
ready << dependent if indegree[dependent].zero?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return ordered if ordered.size == steps.size
|
|
91
|
+
|
|
92
|
+
stuck = (steps.keys - ordered.map(&:key)).map { |kind, ref| "#{kind}:#{ref}" }
|
|
93
|
+
raise(UnresolvableOrder, "circular requirement between #{stuck.join(', ')}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def describe(step)
|
|
97
|
+
case step.kind
|
|
98
|
+
when :migration then "Migrating #{step.ref.name} (#{step.ref.version})..."
|
|
99
|
+
when :fix then "Running #{step.ref}..."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Applies a single Step from #plan - a migration is brought up to (and including) its own
|
|
104
|
+
# version, a fix is run and recorded, same as FixTracker.run!.
|
|
105
|
+
def apply(step)
|
|
106
|
+
case step.kind
|
|
107
|
+
when :migration
|
|
108
|
+
migration_context.up(step.ref.version) { |candidate| candidate.version == step.ref.version }
|
|
109
|
+
when :fix
|
|
110
|
+
FixTracker.run!(FixTracker.fix_path(step.ref))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def pending_migrations
|
|
115
|
+
applied = migration_context.get_all_versions
|
|
116
|
+
migration_context.migrations.reject { |m| applied.include?(m.version) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def migration_exists?(version)
|
|
120
|
+
migration_context.migrations.any? { |m| m.version == version.to_i }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# MigrationProxy defers loading the migration class until needed, via a private #migration
|
|
124
|
+
# accessor - reading `required_fixes` off it needs that class loaded early too, so reach past
|
|
125
|
+
# that privacy rather than reimplementing Rails' own file-loading/constantizing.
|
|
126
|
+
def migration_class_for(proxy)
|
|
127
|
+
proxy.send(:migration).class
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def migration_context
|
|
131
|
+
FixTracker.migration_context
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: yiffspace-fixers
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Donovan_DMC
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-09-04 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: yiffspace-core
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.2.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.2.0
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: zeitwerk
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.6'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.6'
|
|
54
|
+
description: One-time db/fixes scripts and their generators, for https://yiff.space
|
|
55
|
+
and related projects
|
|
56
|
+
email:
|
|
57
|
+
- hewwo@yiff.rocks
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE
|
|
64
|
+
- README.md
|
|
65
|
+
- Rakefile
|
|
66
|
+
- lib/generators/yiffspace/fixer/USAGE
|
|
67
|
+
- lib/generators/yiffspace/fixer/fixer_generator.rb
|
|
68
|
+
- lib/generators/yiffspace/fixer/templates/fixer.rb
|
|
69
|
+
- lib/generators/yiffspace/install/fixes_generator.rb
|
|
70
|
+
- lib/generators/yiffspace/install/templates/create_fixes.rb.erb
|
|
71
|
+
- lib/tasks/fixes.rake
|
|
72
|
+
- lib/yiffspace/configuration/fixer_templates.rb
|
|
73
|
+
- lib/yiffspace/fix_tracker.rb
|
|
74
|
+
- lib/yiffspace/fixer_template.rb
|
|
75
|
+
- lib/yiffspace/fixers.rb
|
|
76
|
+
- lib/yiffspace/fixers/engine.rb
|
|
77
|
+
- lib/yiffspace/fixers/requires_fix.rb
|
|
78
|
+
- lib/yiffspace/fixers/version.rb
|
|
79
|
+
- lib/yiffspace/migration_sync.rb
|
|
80
|
+
homepage: https://yiff.space
|
|
81
|
+
licenses:
|
|
82
|
+
- MIT
|
|
83
|
+
metadata:
|
|
84
|
+
allowed_push_host: https://rubygems.org
|
|
85
|
+
homepage_uri: https://yiff.space
|
|
86
|
+
source_code_uri: https://github.com/YiffSpace/Gem/tree/master/yiffspace-fixers
|
|
87
|
+
changelog_uri: https://github.com/YiffSpace/Gem/blob/master/yiffspace-fixers/CHANGELOG.md
|
|
88
|
+
rubygems_mfa_required: 'true'
|
|
89
|
+
rdoc_options: []
|
|
90
|
+
require_paths:
|
|
91
|
+
- lib
|
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: 3.4.1
|
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '0'
|
|
102
|
+
requirements: []
|
|
103
|
+
rubygems_version: 3.6.2
|
|
104
|
+
specification_version: 4
|
|
105
|
+
summary: One-time db/fixes scripts and their generators, for https://yiff.space and
|
|
106
|
+
related projects
|
|
107
|
+
test_files: []
|