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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +74 -0
  5. data/Rakefile +6 -0
  6. data/app/controllers/slash_migrate/application_controller.rb +15 -0
  7. data/app/controllers/slash_migrate/assets_controller.rb +26 -0
  8. data/app/controllers/slash_migrate/columns_controller.rb +106 -0
  9. data/app/controllers/slash_migrate/indexes_controller.rb +54 -0
  10. data/app/controllers/slash_migrate/migrations_controller.rb +41 -0
  11. data/app/controllers/slash_migrate/models_controller.rb +49 -0
  12. data/app/controllers/slash_migrate/tables_controller.rb +19 -0
  13. data/app/helpers/slash_migrate/application_helper.rb +168 -0
  14. data/app/jobs/slash_migrate/application_job.rb +4 -0
  15. data/app/mailers/slash_migrate/application_mailer.rb +6 -0
  16. data/app/models/slash_migrate/application_record.rb +5 -0
  17. data/app/services/slash_migrate/add_columns_migration.rb +59 -0
  18. data/app/services/slash_migrate/add_index_migration.rb +68 -0
  19. data/app/services/slash_migrate/column.rb +198 -0
  20. data/app/services/slash_migrate/drop_column_migration.rb +35 -0
  21. data/app/services/slash_migrate/drop_index_migration.rb +61 -0
  22. data/app/services/slash_migrate/edit_column_migration.rb +110 -0
  23. data/app/services/slash_migrate/migration_builder.rb +115 -0
  24. data/app/services/slash_migrate/migration_file_writer.rb +71 -0
  25. data/app/services/slash_migrate/migration_runner.rb +91 -0
  26. data/app/services/slash_migrate/schema_inspector.rb +55 -0
  27. data/app/views/layouts/slash_migrate/application.html.erb +40 -0
  28. data/app/views/slash_migrate/columns/_preview.html.erb +10 -0
  29. data/app/views/slash_migrate/columns/_update_preview.html.erb +17 -0
  30. data/app/views/slash_migrate/columns/edit.html.erb +83 -0
  31. data/app/views/slash_migrate/columns/new.html.erb +33 -0
  32. data/app/views/slash_migrate/columns/preview.html.erb +3 -0
  33. data/app/views/slash_migrate/columns/update_preview.html.erb +3 -0
  34. data/app/views/slash_migrate/indexes/_preview.html.erb +10 -0
  35. data/app/views/slash_migrate/indexes/new.html.erb +57 -0
  36. data/app/views/slash_migrate/indexes/preview.html.erb +3 -0
  37. data/app/views/slash_migrate/migrations/index.html.erb +71 -0
  38. data/app/views/slash_migrate/models/_name_help.html.erb +13 -0
  39. data/app/views/slash_migrate/models/_preview.html.erb +21 -0
  40. data/app/views/slash_migrate/models/_row.html.erb +37 -0
  41. data/app/views/slash_migrate/models/new.html.erb +38 -0
  42. data/app/views/slash_migrate/models/preview.html.erb +9 -0
  43. data/app/views/slash_migrate/shared/_breadcrumbs.html.erb +12 -0
  44. data/app/views/slash_migrate/shared/_callout.html.erb +12 -0
  45. data/app/views/slash_migrate/shared/_code_file.html.erb +24 -0
  46. data/app/views/slash_migrate/shared/_column_builder.html.erb +37 -0
  47. data/app/views/slash_migrate/shared/_flow_arrow.html.erb +6 -0
  48. data/app/views/slash_migrate/shared/_terminal.html.erb +18 -0
  49. data/app/views/slash_migrate/tables/index.html.erb +36 -0
  50. data/app/views/slash_migrate/tables/show.html.erb +81 -0
  51. data/config/routes.rb +33 -0
  52. data/lib/slash_migrate/assets/controllers.js +250 -0
  53. data/lib/slash_migrate/assets/slash_migrate.css +381 -0
  54. data/lib/slash_migrate/assets/stimulus.min.js +2588 -0
  55. data/lib/slash_migrate/assets/turbo.min.js +7298 -0
  56. data/lib/slash_migrate/configuration.rb +17 -0
  57. data/lib/slash_migrate/engine.rb +33 -0
  58. data/lib/slash_migrate/pending_migration_check_proxy.rb +32 -0
  59. data/lib/slash_migrate/version.rb +3 -0
  60. data/lib/slash_migrate.rb +16 -0
  61. data/lib/tasks/slash_migrate_tasks.rake +4 -0
  62. 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,3 @@
1
+ <turbo-stream action="update" target="sm-preview">
2
+ <template><%= render "preview" %></template>
3
+ </turbo-stream>
@@ -0,0 +1,3 @@
1
+ <turbo-stream action="update" target="sm-edit-preview">
2
+ <template><%= render "update_preview" %></template>
3
+ </turbo-stream>
@@ -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,3 @@
1
+ <turbo-stream action="update" target="sm-preview">
2
+ <template><%= render "preview" %></template>
3
+ </turbo-stream>
@@ -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>