slash_migrate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/MIT-LICENSE +20 -0
- data/README.md +74 -0
- data/Rakefile +6 -0
- data/app/controllers/slash_migrate/application_controller.rb +15 -0
- data/app/controllers/slash_migrate/assets_controller.rb +26 -0
- data/app/controllers/slash_migrate/columns_controller.rb +106 -0
- data/app/controllers/slash_migrate/indexes_controller.rb +54 -0
- data/app/controllers/slash_migrate/migrations_controller.rb +41 -0
- data/app/controllers/slash_migrate/models_controller.rb +49 -0
- data/app/controllers/slash_migrate/tables_controller.rb +19 -0
- data/app/helpers/slash_migrate/application_helper.rb +168 -0
- data/app/jobs/slash_migrate/application_job.rb +4 -0
- data/app/mailers/slash_migrate/application_mailer.rb +6 -0
- data/app/models/slash_migrate/application_record.rb +5 -0
- data/app/services/slash_migrate/add_columns_migration.rb +59 -0
- data/app/services/slash_migrate/add_index_migration.rb +68 -0
- data/app/services/slash_migrate/column.rb +198 -0
- data/app/services/slash_migrate/drop_column_migration.rb +35 -0
- data/app/services/slash_migrate/drop_index_migration.rb +61 -0
- data/app/services/slash_migrate/edit_column_migration.rb +110 -0
- data/app/services/slash_migrate/migration_builder.rb +115 -0
- data/app/services/slash_migrate/migration_file_writer.rb +71 -0
- data/app/services/slash_migrate/migration_runner.rb +91 -0
- data/app/services/slash_migrate/schema_inspector.rb +55 -0
- data/app/views/layouts/slash_migrate/application.html.erb +40 -0
- data/app/views/slash_migrate/columns/_preview.html.erb +10 -0
- data/app/views/slash_migrate/columns/_update_preview.html.erb +17 -0
- data/app/views/slash_migrate/columns/edit.html.erb +83 -0
- data/app/views/slash_migrate/columns/new.html.erb +33 -0
- data/app/views/slash_migrate/columns/preview.html.erb +3 -0
- data/app/views/slash_migrate/columns/update_preview.html.erb +3 -0
- data/app/views/slash_migrate/indexes/_preview.html.erb +10 -0
- data/app/views/slash_migrate/indexes/new.html.erb +57 -0
- data/app/views/slash_migrate/indexes/preview.html.erb +3 -0
- data/app/views/slash_migrate/migrations/index.html.erb +71 -0
- data/app/views/slash_migrate/models/_name_help.html.erb +13 -0
- data/app/views/slash_migrate/models/_preview.html.erb +21 -0
- data/app/views/slash_migrate/models/_row.html.erb +37 -0
- data/app/views/slash_migrate/models/new.html.erb +38 -0
- data/app/views/slash_migrate/models/preview.html.erb +9 -0
- data/app/views/slash_migrate/shared/_breadcrumbs.html.erb +12 -0
- data/app/views/slash_migrate/shared/_callout.html.erb +12 -0
- data/app/views/slash_migrate/shared/_code_file.html.erb +24 -0
- data/app/views/slash_migrate/shared/_column_builder.html.erb +37 -0
- data/app/views/slash_migrate/shared/_flow_arrow.html.erb +6 -0
- data/app/views/slash_migrate/shared/_terminal.html.erb +18 -0
- data/app/views/slash_migrate/tables/index.html.erb +36 -0
- data/app/views/slash_migrate/tables/show.html.erb +81 -0
- data/config/routes.rb +33 -0
- data/lib/slash_migrate/assets/controllers.js +250 -0
- data/lib/slash_migrate/assets/slash_migrate.css +381 -0
- data/lib/slash_migrate/assets/stimulus.min.js +2588 -0
- data/lib/slash_migrate/assets/turbo.min.js +7298 -0
- data/lib/slash_migrate/configuration.rb +17 -0
- data/lib/slash_migrate/engine.rb +33 -0
- data/lib/slash_migrate/pending_migration_check_proxy.rb +32 -0
- data/lib/slash_migrate/version.rb +3 -0
- data/lib/slash_migrate.rb +16 -0
- data/lib/tasks/slash_migrate_tasks.rake +4 -0
- metadata +121 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module SlashMigrate
|
|
2
|
+
# Writes a migration file on behalf of every builder, so file creation is
|
|
3
|
+
# collision-safe in one place:
|
|
4
|
+
#
|
|
5
|
+
# - the version is collision-free (mirrors Rails' own next_migration_number),
|
|
6
|
+
# so two migrations generated in the same second still get distinct,
|
|
7
|
+
# ordered versions rather than clashing;
|
|
8
|
+
# - the write is exclusive, so a re-submitted form can't clobber a file;
|
|
9
|
+
# - a second pending migration with the same name is refused, since duplicate
|
|
10
|
+
# migration classes break db:migrate — caught here with a clear message
|
|
11
|
+
# instead of a cryptic failure when the student runs it.
|
|
12
|
+
class MigrationFileWriter
|
|
13
|
+
DuplicateError = Class.new(StandardError)
|
|
14
|
+
|
|
15
|
+
def self.write(basename:, source:)
|
|
16
|
+
new(basename: basename, source: source).write
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# basename: the slug with no version and no extension, e.g. "create_posts".
|
|
20
|
+
def initialize(basename:, source:)
|
|
21
|
+
@basename = basename
|
|
22
|
+
@source = source
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def write
|
|
26
|
+
if (existing = existing_with_same_name)
|
|
27
|
+
raise DuplicateError, "A migration named #{@basename.camelize} already exists (#{existing}). " \
|
|
28
|
+
"Run or delete it before generating another."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
path = migrate_dir.join("#{next_version}_#{@basename}.rb")
|
|
32
|
+
# A brand-new app has no db/migrate until its first migration — and that
|
|
33
|
+
# first migration is exactly what a student generates here — so create it.
|
|
34
|
+
migrate_dir.mkpath
|
|
35
|
+
path.open(File::WRONLY | File::CREAT | File::EXCL) { |file| file.write(@source) }
|
|
36
|
+
path.relative_path_from(Rails.root).to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# A version that is both a current UTC timestamp and strictly greater than
|
|
42
|
+
# every existing migration — the same rule Rails' generators use, so a
|
|
43
|
+
# same-second collision still yields a distinct, ordered version (and one
|
|
44
|
+
# that stays within Rails' "not in the future" validity window).
|
|
45
|
+
def next_version
|
|
46
|
+
number = current_migration_number + 1
|
|
47
|
+
if ActiveRecord.timestamped_migrations
|
|
48
|
+
[Time.now.utc.strftime("%Y%m%d%H%M%S"), format("%.14d", number)].max
|
|
49
|
+
else
|
|
50
|
+
format("%.14d", number)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def current_migration_number
|
|
55
|
+
existing_files.map { |path| File.basename(path).to_i }.max || 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def existing_with_same_name
|
|
59
|
+
existing_files.map { |path| File.basename(path) }
|
|
60
|
+
.find { |name| name.sub(/\A\d+_/, "").chomp(".rb") == @basename }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def existing_files
|
|
64
|
+
Dir.glob(migrate_dir.join("*.rb"))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def migrate_dir
|
|
68
|
+
Rails.root.join("db/migrate")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module SlashMigrate
|
|
4
|
+
# Reports migration status and runs db:migrate / db:rollback.
|
|
5
|
+
#
|
|
6
|
+
# Status is computed directly from the migration files and the
|
|
7
|
+
# schema_migrations table rather than through MigrationContext, whose
|
|
8
|
+
# migrations_paths are relative to the process cwd (which isn't Rails.root in
|
|
9
|
+
# a mounted-engine dev setup). Running shells out to bin/rails so students see
|
|
10
|
+
# the real task output; chdir keeps it anchored to the host app.
|
|
11
|
+
class MigrationRunner
|
|
12
|
+
Migration = Struct.new(:version, :name, :applied, keyword_init: true) do
|
|
13
|
+
def applied? = applied
|
|
14
|
+
def status = applied ? "up" : "down"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Result = Struct.new(:output, :success, keyword_init: true) do
|
|
18
|
+
def success? = success
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def status
|
|
22
|
+
applied = applied_versions
|
|
23
|
+
migration_files.map do |path|
|
|
24
|
+
version, name = parse(path)
|
|
25
|
+
Migration.new(version: version, name: name, applied: applied.include?(version))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def pending?
|
|
30
|
+
status.any? { |migration| !migration.applied? }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def applied_any?
|
|
34
|
+
status.any?(&:applied?)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def migrate
|
|
38
|
+
run("db:migrate")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def rollback
|
|
42
|
+
run("db:rollback")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Deletes a migration file, but only when it hasn't been run (pending, or
|
|
46
|
+
# already rolled back). Deleting an applied migration would orphan its
|
|
47
|
+
# schema_migrations row and leave it unreversible, so we refuse.
|
|
48
|
+
def delete(version)
|
|
49
|
+
version = version.to_s
|
|
50
|
+
migration = status.find { |candidate| candidate.version == version }
|
|
51
|
+
|
|
52
|
+
return Result.new(output: "No migration #{version} found.", success: false) unless migration
|
|
53
|
+
if migration.applied?
|
|
54
|
+
return Result.new(output: "“#{migration.name}” has already been run — roll it back before deleting.", success: false)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
path = migration_files.find { |file| File.basename(file).start_with?("#{version}_") }
|
|
58
|
+
File.delete(path) if path
|
|
59
|
+
Result.new(output: "Deleted “#{migration.name}”.", success: true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def run(task)
|
|
65
|
+
output, process = Bundler.with_unbundled_env do
|
|
66
|
+
Open3.capture2e({"RAILS_ENV" => Rails.env.to_s}, rails_bin, task, chdir: Rails.root.to_s)
|
|
67
|
+
end
|
|
68
|
+
Result.new(output: output, success: process.success?)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def rails_bin
|
|
72
|
+
Rails.root.join("bin/rails").to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def migration_files
|
|
76
|
+
Dir.glob(Rails.root.join("db/migrate/*.rb")).sort
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse(path)
|
|
80
|
+
version, slug = File.basename(path, ".rb").split("_", 2)
|
|
81
|
+
[version, slug.to_s.humanize]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def applied_versions
|
|
85
|
+
connection = ActiveRecord::Base.connection
|
|
86
|
+
return [] unless connection.table_exists?("schema_migrations")
|
|
87
|
+
|
|
88
|
+
connection.select_values("SELECT version FROM schema_migrations").map(&:to_s)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module SlashMigrate
|
|
2
|
+
# Reads the live database schema through the Active Record connection. This is
|
|
3
|
+
# the single source of truth the rest of the engine builds on: the table
|
|
4
|
+
# browser renders from it, and the migration builder relies on it to know the
|
|
5
|
+
# current type/default/index of a column so every generated migration can be
|
|
6
|
+
# made reversible.
|
|
7
|
+
class SchemaInspector
|
|
8
|
+
# Active Record's own bookkeeping tables. Never shown or touched.
|
|
9
|
+
INTERNAL_TABLES = %w[schema_migrations ar_internal_metadata].freeze
|
|
10
|
+
|
|
11
|
+
def table_names
|
|
12
|
+
(connection.tables - INTERNAL_TABLES).sort
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def exists?(name)
|
|
16
|
+
table_names.include?(name.to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def table(name)
|
|
20
|
+
Table.new(name.to_s, connection)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def connection
|
|
26
|
+
ActiveRecord::Base.connection
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A thin, read-only view over one table's columns, indexes and foreign keys.
|
|
30
|
+
class Table
|
|
31
|
+
attr_reader :name
|
|
32
|
+
|
|
33
|
+
def initialize(name, connection)
|
|
34
|
+
@name = name
|
|
35
|
+
@connection = connection
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def columns
|
|
39
|
+
@connection.columns(@name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def indexes
|
|
43
|
+
@connection.indexes(@name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def foreign_keys
|
|
47
|
+
@connection.foreign_keys(@name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def primary_key
|
|
51
|
+
@connection.primary_key(@name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>slash_migrate</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= csp_meta_tag %>
|
|
9
|
+
|
|
10
|
+
<%= yield :head %>
|
|
11
|
+
|
|
12
|
+
<%# Self-contained: the engine serves its own CSS/JS so it never depends on the
|
|
13
|
+
host app's asset pipeline (see AssetsController). %>
|
|
14
|
+
<link rel="stylesheet" href="<%= asset_file_path(name: "slash_migrate.css") %>">
|
|
15
|
+
<script src="<%= asset_file_path(name: "turbo.min.js") %>" defer></script>
|
|
16
|
+
<script src="<%= asset_file_path(name: "stimulus.min.js") %>" defer></script>
|
|
17
|
+
<script src="<%= asset_file_path(name: "controllers.js") %>" defer></script>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<header class="nav">
|
|
21
|
+
<%= link_to root_path, class: "nav-brand" do %><em>/</em>rails<em>/</em>migrate<% end %>
|
|
22
|
+
<span class="nav-spacer"></span>
|
|
23
|
+
<nav class="nav-links">
|
|
24
|
+
<%= nav_link "Tables", root_path, :database, active: nav_section == :tables %>
|
|
25
|
+
<%= nav_link "New table", new_model_path, :plus, active: nav_section == :new %>
|
|
26
|
+
<%= nav_link "Migrations", migrations_path, :history, active: nav_section == :migrations %>
|
|
27
|
+
</nav>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<main class="page <%= content_for(:page_class) %>">
|
|
31
|
+
<% if flash[:notice].present? %>
|
|
32
|
+
<%= render layout: "slash_migrate/shared/callout", locals: {kind: "ok"} do %><%= flash[:notice] %><% end %>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if flash[:alert].present? %>
|
|
35
|
+
<%= render layout: "slash_migrate/shared/callout", locals: {kind: "warn"} do %><%= flash[:alert] %><% end %>
|
|
36
|
+
<% end %>
|
|
37
|
+
<%= yield %>
|
|
38
|
+
</main>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<% if @error.present? %>
|
|
2
|
+
<%= render layout: "slash_migrate/shared/callout", locals: {kind: "warn", title: "Can't build that yet"} do %><%= @error %><% end %>
|
|
3
|
+
<% elsif !@migration.any? %>
|
|
4
|
+
<p class="preview-hint"><%= @hint || "Add a column to see the migration it will generate." %></p>
|
|
5
|
+
<% else %>
|
|
6
|
+
<%= render "slash_migrate/shared/code_file",
|
|
7
|
+
path: "db/migrate/", name: "#{@migration.migration_basename}.rb", kind: "migration", timestamped: true,
|
|
8
|
+
source: @migration.migration_source,
|
|
9
|
+
foot: "db:rollback undoes this by removing the new column(s).", foot_kind: "info" %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<% if @error.present? %>
|
|
2
|
+
<%= render layout: "slash_migrate/shared/callout", locals: {kind: "warn", title: "Can't build that yet"} do %><%= @error %><% end %>
|
|
3
|
+
<% elsif @migration.nil? || !@migration.changed? %>
|
|
4
|
+
<p class="preview-hint"><%= @hint || "Change a value to see the migration it will generate." %></p>
|
|
5
|
+
<% else %>
|
|
6
|
+
<% if @migration.tightening_null? %>
|
|
7
|
+
<%= render layout: "slash_migrate/shared/callout", locals: {kind: "warn", title: "This may fail on existing rows"} do %>
|
|
8
|
+
Adding <code>NOT NULL</code> fails if existing rows hold NULL — backfill them (or set a default) first.
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<%= render "slash_migrate/shared/code_file",
|
|
13
|
+
path: "db/migrate/", name: "#{@migration.migration_basename}.rb", kind: "migration", timestamped: true,
|
|
14
|
+
source: @migration.migration_source,
|
|
15
|
+
foot: (@migration.reversible_as_change? ? "A rename is safe to roll back — no data is lost." : "Not auto-reversible, so it's written as explicit up / down — and a type change can truncate or lose existing data."),
|
|
16
|
+
foot_kind: (@migration.reversible_as_change? ? "ok" : "warn") %>
|
|
17
|
+
<% end %>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<% content_for :page_class, "page-form" %>
|
|
2
|
+
|
|
3
|
+
<div class="page-head">
|
|
4
|
+
<div>
|
|
5
|
+
<%= render "slash_migrate/shared/breadcrumbs", trail: [{label: "Tables", url: root_path}, {label: @table, url: table_path(@table)}, {label: "Edit #{@column.name}"}] %>
|
|
6
|
+
<h1 class="page-title">Edit <span class="mono"><%= @column.name %></span> on <span class="mono"><%= @table %></span></h1>
|
|
7
|
+
<p class="page-sub">Change the column's name, type, nullability, or default. The migration below updates as you edit.</p>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<%= form_with url: table_column_path(@table, @column.name), method: :patch, class: "stack",
|
|
12
|
+
data: {
|
|
13
|
+
controller: "live-preview",
|
|
14
|
+
action: "input->live-preview#scheduleRefresh change->live-preview#scheduleRefresh",
|
|
15
|
+
"live-preview-url-value": update_preview_table_column_path(@table, @column.name)
|
|
16
|
+
} do |form| %>
|
|
17
|
+
|
|
18
|
+
<div class="panel panel-pad">
|
|
19
|
+
<div class="form-col">
|
|
20
|
+
<div class="field">
|
|
21
|
+
<label class="label" for="col_name">Name</label>
|
|
22
|
+
<input type="text" id="col_name" name="column[name]" value="<%= @column.name %>" class="input mono" autocomplete="off">
|
|
23
|
+
<p class="help-text">Currently <span class="chip-soft"><%= @column.name %></span>. Renaming generates a <code>rename_column</code>.</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="field">
|
|
26
|
+
<label class="label" for="col_type">Type</label>
|
|
27
|
+
<select id="col_type" name="column[type]" class="select">
|
|
28
|
+
<% (available_column_types(include_references: false) | [@column.type]).each do |type| %>
|
|
29
|
+
<option value="<%= type %>"<% if type == @column.type %> selected<% end %>><%= type %></option>
|
|
30
|
+
<% end %>
|
|
31
|
+
</select>
|
|
32
|
+
<p class="help-text">Currently <span class="chip-soft"><%= @column.type %><%= "(#{@column.limit})" if @column.limit %></span>. A type change isn't auto-reversible.</p>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="field">
|
|
35
|
+
<label class="label" for="col_null">Nullability</label>
|
|
36
|
+
<select id="col_null" name="column[null]" class="select">
|
|
37
|
+
<option value=""<% if @column.allow_null? %> selected<% end %>>allow null</option>
|
|
38
|
+
<option value="not_null"<% unless @column.allow_null? %> selected<% end %>>NOT NULL</option>
|
|
39
|
+
</select>
|
|
40
|
+
<p class="help-text">Currently <span class="chip-soft"><%= @column.allow_null? ? "allows null" : "NOT NULL" %></span>.</p>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="field">
|
|
43
|
+
<label class="label" for="col_default">Default</label>
|
|
44
|
+
<input type="text" id="col_default" name="column[default]" value="<%= @column.default %>" placeholder="(none)" class="input mono" autocomplete="off">
|
|
45
|
+
<p class="help-text">Currently <span class="chip-soft"><%= @column.default.nil? ? "no default" : @column.default %></span>. Leave blank to keep it that way.</p>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<%= render "slash_migrate/shared/flow_arrow" %>
|
|
51
|
+
|
|
52
|
+
<div id="sm-edit-preview">
|
|
53
|
+
<%= render "update_preview" %>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="actions-stack">
|
|
57
|
+
<%= form.submit "Generate migration", class: "btn btn-accent" %>
|
|
58
|
+
<%= link_to "Reset", edit_table_column_path(@table, @column.name), class: "btn btn-ghost" %>
|
|
59
|
+
</div>
|
|
60
|
+
<% end %>
|
|
61
|
+
|
|
62
|
+
<div class="panel danger-zone" style="margin-top:var(--gap-lg)">
|
|
63
|
+
<div class="panel-head">
|
|
64
|
+
<div>
|
|
65
|
+
<h2>Drop this column</h2>
|
|
66
|
+
<div class="panel-sub">Generates a separate migration to remove <span class="mono"><%= @column.name %></span> from <span class="mono"><%= @table %></span>.</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="panel-pad">
|
|
70
|
+
<div class="stack">
|
|
71
|
+
<%= render "slash_migrate/shared/code_file",
|
|
72
|
+
path: "db/migrate/", name: "#{@drop_migration.migration_basename}.rb", kind: "migration · preview", timestamped: true,
|
|
73
|
+
source: @drop_migration.migration_source,
|
|
74
|
+
foot: @drop_caveat, foot_kind: "warn" %>
|
|
75
|
+
<div class="actions-stack">
|
|
76
|
+
<%= button_to drop_table_column_path(@table, @column.name), method: :post, class: "btn btn-danger-soft",
|
|
77
|
+
data: {turbo_confirm: "Generate a migration to drop #{@column.name}?"} do %>
|
|
78
|
+
<%= sm_icon(:trash) %> Generate drop migration
|
|
79
|
+
<% end %>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<% content_for :page_class, "page-form" %>
|
|
2
|
+
|
|
3
|
+
<div class="page-head">
|
|
4
|
+
<div>
|
|
5
|
+
<%= render "slash_migrate/shared/breadcrumbs", trail: [{label: "Tables", url: root_path}, {label: @table, url: table_path(@table)}, {label: "Add columns"}] %>
|
|
6
|
+
<h1 class="page-title">Add columns to <span class="mono"><%= @table %></span></h1>
|
|
7
|
+
<p class="page-sub">Append one or more columns to the existing table. A timestamped <span class="mono">add_column</span> migration will be created.</p>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<%= form_with url: table_columns_path(@table), method: :post, class: "stack",
|
|
12
|
+
data: {
|
|
13
|
+
controller: "model-form",
|
|
14
|
+
action: "input->model-form#scheduleRefresh change->model-form#scheduleRefresh",
|
|
15
|
+
"model-form-preview-url-value": preview_table_columns_path(@table)
|
|
16
|
+
} do |form| %>
|
|
17
|
+
|
|
18
|
+
<div class="panel panel-pad">
|
|
19
|
+
<h2 class="section-title" style="margin-top:0">New columns</h2>
|
|
20
|
+
<%= render "slash_migrate/shared/column_builder", tables: @existing_tables %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<%= render "slash_migrate/shared/flow_arrow" %>
|
|
24
|
+
|
|
25
|
+
<div id="sm-preview">
|
|
26
|
+
<%= render "preview" %>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="actions-stack">
|
|
30
|
+
<%= form.submit "Generate migration", class: "btn btn-accent" %>
|
|
31
|
+
<%= link_to "Reset", new_table_column_path(@table), class: "btn btn-ghost" %>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<% if @error.present? %>
|
|
2
|
+
<%= render layout: "slash_migrate/shared/callout", locals: {kind: "warn", title: "Can't build that yet"} do %><%= @error %><% end %>
|
|
3
|
+
<% elsif !@migration.any? %>
|
|
4
|
+
<p class="preview-hint"><%= @hint || "Pick a column to index to see the migration it will generate." %></p>
|
|
5
|
+
<% else %>
|
|
6
|
+
<%= render "slash_migrate/shared/code_file",
|
|
7
|
+
path: "db/migrate/", name: "#{@migration.migration_basename}.rb", kind: "migration", timestamped: true,
|
|
8
|
+
source: @migration.migration_source,
|
|
9
|
+
foot: "db:rollback drops the index — your table data isn't touched.", foot_kind: "ok" %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<% content_for :page_class, "page-form" %>
|
|
2
|
+
|
|
3
|
+
<div class="page-head">
|
|
4
|
+
<div>
|
|
5
|
+
<%= render "slash_migrate/shared/breadcrumbs", trail: [{label: "Tables", url: root_path}, {label: @table, url: table_path(@table)}, {label: "Add index"}] %>
|
|
6
|
+
<h1 class="page-title">Add index to <span class="mono"><%= @table %></span></h1>
|
|
7
|
+
<p class="page-sub">Pick one or more columns to index — useful for speeding up lookups on foreign keys, or columns you filter or sort by often.</p>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<%= form_with url: table_indexes_path(@table), method: :post, class: "stack",
|
|
12
|
+
data: {
|
|
13
|
+
controller: "live-preview",
|
|
14
|
+
action: "input->live-preview#scheduleRefresh change->live-preview#scheduleRefresh",
|
|
15
|
+
"live-preview-url-value": preview_table_indexes_path(@table)
|
|
16
|
+
} do |form| %>
|
|
17
|
+
|
|
18
|
+
<div class="panel panel-pad">
|
|
19
|
+
<div class="form-col">
|
|
20
|
+
<div class="field">
|
|
21
|
+
<label class="label">Columns to index</label>
|
|
22
|
+
<div class="checks">
|
|
23
|
+
<% @columns.each do |column| %>
|
|
24
|
+
<label class="check"><input type="checkbox" name="columns[]" value="<%= column %>"> <%= column %></label>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div>
|
|
27
|
+
<p class="help-text">Two or more columns make a <strong>composite index</strong> — useful when you filter by them together.</p>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="field">
|
|
31
|
+
<label class="label" for="idx_unique">Unique?</label>
|
|
32
|
+
<select id="idx_unique" name="unique" class="select">
|
|
33
|
+
<option value="">no — duplicate values allowed</option>
|
|
34
|
+
<option value="unique">yes — enforce uniqueness</option>
|
|
35
|
+
</select>
|
|
36
|
+
<p class="help-text">A unique index doubles as a constraint — inserts that duplicate the indexed value(s) will fail.</p>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="field">
|
|
40
|
+
<label class="label" for="idx_name">Index name <span class="help">optional</span></label>
|
|
41
|
+
<input type="text" id="idx_name" name="name" class="input mono" placeholder="index_<%= @table %>_on_…" autocomplete="off">
|
|
42
|
+
<p class="help-text">Leave blank to let Rails name it — the conventional pick.</p>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<%= render "slash_migrate/shared/flow_arrow" %>
|
|
48
|
+
|
|
49
|
+
<div id="sm-preview">
|
|
50
|
+
<%= render "preview" %>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="actions-stack">
|
|
54
|
+
<%= form.submit "Generate migration", class: "btn btn-accent" %>
|
|
55
|
+
<%= link_to "Reset", new_table_index_path(@table), class: "btn btn-ghost" %>
|
|
56
|
+
</div>
|
|
57
|
+
<% end %>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<%
|
|
2
|
+
applied = @migrations.count(&:applied?)
|
|
3
|
+
pending = @migrations.size - applied
|
|
4
|
+
%>
|
|
5
|
+
|
|
6
|
+
<div class="page-head">
|
|
7
|
+
<div>
|
|
8
|
+
<h1 class="page-title">Migrations</h1>
|
|
9
|
+
<p class="page-sub">
|
|
10
|
+
<%= applied %> applied · <%= pending %> pending. This list mirrors the files in <span class="mono">db/migrate</span>.
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="row">
|
|
14
|
+
<% if @migrations.any?(&:applied?) %>
|
|
15
|
+
<%= button_to rollback_migrations_path, class: "btn btn-ghost",
|
|
16
|
+
data: {turbo_confirm: "Roll back the most recent migration? This reverses its changes."} do %>
|
|
17
|
+
<%= sm_icon(:rotate) %> Roll back last
|
|
18
|
+
<% end %>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% if @pending %>
|
|
21
|
+
<%= button_to run_migrations_path, class: "btn btn-accent" do %><%= sm_icon(:play) %> Run pending<% end %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<% if flash[:migrate_output].present? %>
|
|
27
|
+
<% failed = flash[:alert].present? %>
|
|
28
|
+
<div style="margin-bottom:var(--gap-lg)">
|
|
29
|
+
<%= render "slash_migrate/shared/terminal",
|
|
30
|
+
command: (flash[:migrate_command] || "rails db:migrate"),
|
|
31
|
+
output: flash[:migrate_output],
|
|
32
|
+
meta: (failed ? "exited 1" : "output"),
|
|
33
|
+
meta_style: (failed ? "color:#E8B07F" : nil) %>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<div class="panel">
|
|
38
|
+
<% if @migrations.empty? %>
|
|
39
|
+
<div class="panel-pad muted">No migration files yet. Create a model or add columns, and the migration shows up here.</div>
|
|
40
|
+
<% else %>
|
|
41
|
+
<table class="table">
|
|
42
|
+
<thead>
|
|
43
|
+
<tr><th>Status</th><th>Version</th><th>Migration</th><th></th></tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
<% @migrations.each do |migration| %>
|
|
47
|
+
<tr>
|
|
48
|
+
<td>
|
|
49
|
+
<span class="badge <%= migration.applied? ? "is-up" : "is-pending" %>"><%= migration.applied? ? "up" : "pending" %></span>
|
|
50
|
+
</td>
|
|
51
|
+
<td><span class="mono muted"><%= migration.version %></span></td>
|
|
52
|
+
<td><span class="mono"><%= migration.name %></span></td>
|
|
53
|
+
<td class="col-actions">
|
|
54
|
+
<% if migration.applied? %>
|
|
55
|
+
<span class="has-tip" tabindex="0">
|
|
56
|
+
<button type="button" class="btn btn-sm btn-disabled" disabled><%= sm_icon(:trash) %> Delete file</button>
|
|
57
|
+
<span class="tip">This migration has been run. Roll it back with <kbd>db:rollback</kbd> first — deleting its file would orphan the <kbd>schema_migrations</kbd> record and leave the change unreversible.</span>
|
|
58
|
+
</span>
|
|
59
|
+
<% else %>
|
|
60
|
+
<%= button_to migration_path(migration.version), method: :delete, class: "btn btn-sm btn-ghost",
|
|
61
|
+
data: {turbo_confirm: "Delete the “#{migration.name}” migration file? It hasn't been run."} do %>
|
|
62
|
+
<%= sm_icon(:trash) %> Delete file
|
|
63
|
+
<% end %>
|
|
64
|
+
<% end %>
|
|
65
|
+
</td>
|
|
66
|
+
</tr>
|
|
67
|
+
<% end %>
|
|
68
|
+
</tbody>
|
|
69
|
+
</table>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# Helper text under the Model name field. Folds the singular-name warning in
|
|
2
|
+
here (rather than a preview callout) when the typed name is plural. Driven by
|
|
3
|
+
the live-preview Turbo Stream so it stays accurate via Rails' inflector. %>
|
|
4
|
+
<% if @builder&.pluralized_input? %>
|
|
5
|
+
<p class="help-text is-warn">
|
|
6
|
+
Model names are singular, so we'll generate <code><%= @builder.model_class_name %></code> on the
|
|
7
|
+
<code><%= @builder.table_name %></code> table. Want a plural class name? Teach Rails the inflection in
|
|
8
|
+
<code>config/initializers/inflections.rb</code> — see
|
|
9
|
+
<%= link_to "the inflections guide", "https://guides.rubyonrails.org/active_support_core_extensions.html#inflections", target: "_blank", rel: "noopener" %>.
|
|
10
|
+
</p>
|
|
11
|
+
<% else %>
|
|
12
|
+
<p class="help-text">Singular, by Rails convention — it maps to a table named after the plural.</p>
|
|
13
|
+
<% end %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<% if @error.present? %>
|
|
2
|
+
<%= render layout: "slash_migrate/shared/callout", locals: {kind: "warn", title: "Can't build that yet"} do %><%= @error %><% end %>
|
|
3
|
+
<% elsif !@builder.name_present? %>
|
|
4
|
+
<p class="preview-hint"><%= @hint || "Enter a model name to see the migration it will generate." %></p>
|
|
5
|
+
<% else %>
|
|
6
|
+
<% if @table_exists %>
|
|
7
|
+
<%= render layout: "slash_migrate/shared/callout", locals: {kind: "warn", title: "That table already exists"} do %>
|
|
8
|
+
A table named <span class="mono"><%= @builder.table_name %></span> is already in the database — creating it again would add a duplicate migration.
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<%= render "slash_migrate/shared/code_file",
|
|
13
|
+
path: "db/migrate/", name: "#{@builder.migration_basename}.rb", kind: "migration", timestamped: true,
|
|
14
|
+
source: @builder.migration_source,
|
|
15
|
+
foot: "db:rollback undoes this by dropping the table.", foot_kind: "info" %>
|
|
16
|
+
|
|
17
|
+
<%= render "slash_migrate/shared/code_file",
|
|
18
|
+
path: "app/models/", name: @builder.model_filename, kind: "model",
|
|
19
|
+
source: @builder.model_source,
|
|
20
|
+
foot: "New file — won't overwrite #{@builder.model_filename} if it already exists.", foot_kind: "info" %>
|
|
21
|
+
<% end %>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<%# One column-row in the builder. The grid (.col-row) lays out the controls;
|
|
2
|
+
Stimulus (model-form) toggles the default input vs. the "references table"
|
|
3
|
+
picker, which share the fourth grid cell, and keeps the picker's first option
|
|
4
|
+
showing the table Rails would infer from the column name.
|
|
5
|
+
Local: self_option: include the "(this table)" self-reference option — only
|
|
6
|
+
useful when creating a new table (it isn't in the table list yet). %>
|
|
7
|
+
<div class="col-row" data-row>
|
|
8
|
+
<input type="text" name="attributes[][name]" placeholder="name" class="input mono" autocomplete="off" data-action="input->model-form#nameInput">
|
|
9
|
+
<select name="attributes[][type]" class="select" data-action="model-form#typeChanged">
|
|
10
|
+
<% available_column_types.each do |type| %>
|
|
11
|
+
<option value="<%= type %>"><%= type %></option>
|
|
12
|
+
<% end %>
|
|
13
|
+
</select>
|
|
14
|
+
<select name="attributes[][null]" class="select" title="Nullability">
|
|
15
|
+
<option value="">allow null</option>
|
|
16
|
+
<option value="not_null">NOT NULL</option>
|
|
17
|
+
</select>
|
|
18
|
+
<input type="text" name="attributes[][default]" placeholder="default" class="input mono" autocomplete="off" data-row-default>
|
|
19
|
+
<%# Shown only for reference columns (Stimulus swaps it with the default field).
|
|
20
|
+
Only real tables are offered, so the foreign key can never point at one that
|
|
21
|
+
doesn't exist; "no foreign key" makes just the _id column. %>
|
|
22
|
+
<select name="attributes[][to_table]" class="select" title="Foreign key — which table does it reference?" data-row-table data-action="change->model-form#pickerTouched" style="display:none">
|
|
23
|
+
<option value="" disabled selected>Select a table</option>
|
|
24
|
+
<% if local_assigns[:self_option] %>
|
|
25
|
+
<option value="<%= SlashMigrate::MigrationBuilder::SELF_TABLE %>" data-self-suggestion>→ this table</option>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% Array(tables).each do |table| %>
|
|
28
|
+
<option value="<%= table %>">→ <%= table %></option>
|
|
29
|
+
<% end %>
|
|
30
|
+
</select>
|
|
31
|
+
<select name="attributes[][index]" class="select" title="Index">
|
|
32
|
+
<option value="">no index</option>
|
|
33
|
+
<option value="index">index</option>
|
|
34
|
+
<option value="uniq">unique</option>
|
|
35
|
+
</select>
|
|
36
|
+
<button type="button" class="btn-icon" data-action="model-form#removeRow" aria-label="Remove column"><%= sm_icon(:x) %></button>
|
|
37
|
+
</div>
|