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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e02b861e967a6d80fde6d69853c8222cbb16bee08c698d4566673f7f3baf34ec
4
+ data.tar.gz: dd3e18fb6ee29dbb2882112062c87659a3d62569773d62256cdb1f7866142fcd
5
+ SHA512:
6
+ metadata.gz: d96ec25095737a5cb6a48b8fd6abcd63635f1bd6487df203d7e910f180f8378654a95b91113f04a09fa908997e8f373c9d8eb9462346809c3ce053ef9b17a625
7
+ data.tar.gz: 86548b278ba6c311b89ab96f23e887bebd57cd40d3156d0869bdd76e6ae1850f891a225b8c1e50ad556899bc4649c9cee3b250ec7a91bc9bf98eacc7a74b3293
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-05-27
8
+
9
+ ### Added
10
+
11
+ - Initial release: a development-only, self-mounting Rails engine that serves a
12
+ GUI for common database migrations at `/rails/migrate`.
13
+ - Browse tables, columns, indexes, and foreign keys.
14
+ - Create models and migrations with column types, `null` / `default`, indexes
15
+ (including unique), and `references` / foreign keys.
16
+ - Add, edit, and drop columns; add and drop indexes.
17
+ - Run, roll back, and delete pending migrations.
18
+ - A live preview of the migration and model code each action will generate.
19
+ - `SlashMigrate.configure` for `mount_path` and `enabled_environments`.
20
+
21
+ [0.1.0]: https://github.com/firstdraft/slash_migrate/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Raghu Betina
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,74 @@
1
+ # slash_migrate
2
+
3
+ A development-only Rails engine that gives you a GUI for everyday database
4
+ migrations. Mount it, visit `/rails/migrate`, and create models, design columns
5
+ and indexes, and modify existing tables — while it shows you the exact migration
6
+ code it will write, so you pick up the commands as you go.
7
+
8
+ It's built for people still getting comfortable with Active Record migrations.
9
+
10
+ > **Development only.** slash_migrate writes real files to `db/migrate` and
11
+ > `app/models` and runs real migrations against your database. It mounts in your
12
+ > development environment only (configurable) and should never be loaded in
13
+ > production.
14
+
15
+ ## Installation
16
+
17
+ Add it to the `development` group of your `Gemfile`:
18
+
19
+ ```ruby
20
+ group :development do
21
+ gem "slash_migrate"
22
+ end
23
+ ```
24
+
25
+ Then install:
26
+
27
+ ```bash
28
+ bundle install
29
+ ```
30
+
31
+ There's no `routes.rb` change to make — the engine mounts itself at
32
+ `/rails/migrate` in development. Start your server and visit
33
+ <http://localhost:3000/rails/migrate>.
34
+
35
+ ## What you can do
36
+
37
+ - Browse your tables, columns, indexes, and foreign keys.
38
+ - Create a model and its migration, with column types, `null` / `default`,
39
+ indexes (including unique), and `references` / foreign keys.
40
+ - Add, edit, or drop columns on an existing table.
41
+ - Add or drop indexes.
42
+ - Run, roll back, or delete pending migrations.
43
+
44
+ Every screen previews the migration (and model) code it will generate before you
45
+ commit to it.
46
+
47
+ ## Configuration
48
+
49
+ The defaults suit most apps. To change them, add an initializer:
50
+
51
+ ```ruby
52
+ # config/initializers/slash_migrate.rb
53
+ SlashMigrate.configure do |config|
54
+ config.mount_path = "/rails/migrate" # where the engine mounts
55
+ config.enabled_environments = ["development"] # environments it runs in
56
+ end
57
+ ```
58
+
59
+ The engine mounts itself when routes are drawn, which happens after your
60
+ initializers run — so overriding these values here takes effect.
61
+
62
+ ## Requirements
63
+
64
+ - Ruby >= 3.4
65
+ - Rails >= 8.1.3
66
+
67
+ ## Contributing
68
+
69
+ Bug reports and pull requests are welcome at
70
+ <https://github.com/firstdraft/slash_migrate>.
71
+
72
+ ## License
73
+
74
+ Released under the [MIT License](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ module SlashMigrate
2
+ class ApplicationController < ActionController::Base
3
+ # Make the engine's view helpers (sm_icon, ruby_code_body, callouts, …)
4
+ # available in every engine view, regardless of how the host app configures
5
+ # helper inclusion.
6
+ helper SlashMigrate::ApplicationHelper
7
+
8
+ # A builder refuses to write a migration whose name duplicates an existing
9
+ # one (two same-named migrations break db:migrate); surface it as a friendly
10
+ # alert rather than a 500.
11
+ rescue_from MigrationFileWriter::DuplicateError do |error|
12
+ redirect_to migrations_path, alert: error.message
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ module SlashMigrate
2
+ # Serves the engine's vendored, precompiled JS/CSS straight from the gem, so
3
+ # the UI works identically no matter how the host app handles assets (or
4
+ # whether it has an asset pipeline at all). Files live under lib/ specifically
5
+ # so the host's pipeline never scans or fingerprints them.
6
+ class AssetsController < ActionController::Base
7
+ # These are static files, served by GET. Rails' forgery protection otherwise
8
+ # raises InvalidCrossOriginRequest when returning JavaScript over a plain GET
9
+ # (its cross-origin <script> defense), which would block our own bundle.
10
+ skip_forgery_protection
11
+
12
+ ASSET_DIR = SlashMigrate::Engine.root.join("lib/slash_migrate/assets")
13
+ CONTENT_TYPES = {".js" => "text/javascript", ".css" => "text/css"}.freeze
14
+
15
+ def show
16
+ # File.basename strips any path components, so :name can only ever resolve
17
+ # to a file directly inside ASSET_DIR — no directory traversal.
18
+ path = ASSET_DIR.join(File.basename(params[:name].to_s))
19
+ raise ActionController::RoutingError, "Asset not found: #{params[:name]}" unless path.file?
20
+
21
+ send_file path,
22
+ type: CONTENT_TYPES.fetch(path.extname, "application/octet-stream"),
23
+ disposition: "inline"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,106 @@
1
+ module SlashMigrate
2
+ class ColumnsController < ApplicationController
3
+ before_action :require_table
4
+
5
+ def new
6
+ @migration = AddColumnsMigration.new(table: @table)
7
+ @existing_tables = inspector.table_names
8
+ end
9
+
10
+ def preview
11
+ @migration = AddColumnsMigration.from_params(table: @table, rows: params[:attributes])
12
+ @hint = "Add a column to see the migration it will generate." unless @migration.any?
13
+ render :preview, layout: false
14
+ rescue => e
15
+ @error = e.message
16
+ render :preview, layout: false
17
+ end
18
+
19
+ def create
20
+ migration = AddColumnsMigration.from_params(table: @table, rows: params[:attributes])
21
+
22
+ unless migration.any?
23
+ redirect_to(new_table_column_path(@table), alert: "Add at least one column.")
24
+ return
25
+ end
26
+
27
+ written = migration.write!
28
+ redirect_to migrations_path,
29
+ notice: "Created #{written.join(", ")}. Run it below to apply."
30
+ end
31
+
32
+ def edit
33
+ @column = find_column
34
+ head :not_found and return unless @column
35
+
36
+ @drop_migration = DropColumnMigration.new(table: @table, column: @column)
37
+ @drop_caveat = drop_caveat
38
+ end
39
+
40
+ def drop
41
+ column = find_column
42
+ head :not_found and return unless column
43
+
44
+ written = DropColumnMigration.new(table: @table, column: column).write!
45
+ redirect_to migrations_path,
46
+ notice: "Created #{written.join(", ")}. Run it below to apply."
47
+ end
48
+
49
+ def update_preview
50
+ original = find_column
51
+ @migration = original && EditColumnMigration.new(table: @table, original: original, desired: desired_column)
52
+ @hint = "Change a value to see the migration it will generate." unless @migration&.changed?
53
+ render :update_preview, layout: false
54
+ rescue => e
55
+ @error = e.message
56
+ render :update_preview, layout: false
57
+ end
58
+
59
+ def update
60
+ original = find_column
61
+ head :not_found and return unless original
62
+
63
+ migration = EditColumnMigration.new(table: @table, original: original, desired: desired_column)
64
+
65
+ unless migration.changed?
66
+ redirect_to(edit_table_column_path(@table, params[:name]), alert: "No changes to apply.")
67
+ return
68
+ end
69
+
70
+ written = migration.write!
71
+ redirect_to migrations_path,
72
+ notice: "Created #{written.join(", ")}. Run it below to apply."
73
+ end
74
+
75
+ private
76
+
77
+ def require_table
78
+ @table = params[:table_id]
79
+ head :not_found unless inspector.exists?(@table)
80
+ end
81
+
82
+ def find_column
83
+ ar_column = inspector.table(@table).columns.find { |column| column.name == params[:name] }
84
+ ar_column && Column.from_schema(ar_column)
85
+ end
86
+
87
+ # remove_column re-adds the column on rollback, but not the index or foreign
88
+ # key that were on it — so say so when either is present.
89
+ def drop_caveat
90
+ table = inspector.table(@table)
91
+ dependents = []
92
+ dependents << "index" if table.indexes.any? { |index| Array(index.columns).include?(@column.name) }
93
+ dependents << "foreign key" if table.foreign_keys.any? { |fk| fk.column == @column.name }
94
+ base = "db:rollback re-adds the column, but the data in it is gone for good"
95
+ dependents.empty? ? "#{base}." : "#{base} — and its #{dependents.join(" and ")} won't be restored."
96
+ end
97
+
98
+ def desired_column
99
+ Column.from_params(params.fetch(:column, {}))
100
+ end
101
+
102
+ def inspector
103
+ @inspector ||= SchemaInspector.new
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,54 @@
1
+ module SlashMigrate
2
+ class IndexesController < ApplicationController
3
+ before_action :require_table
4
+
5
+ def new
6
+ @columns = inspector.table(@table).columns.map(&:name)
7
+ @migration = AddIndexMigration.new(table: @table)
8
+ end
9
+
10
+ def preview
11
+ @migration = build_migration
12
+ @hint = "Pick a column to index to see the migration it will generate." unless @migration.any?
13
+ render :preview, layout: false
14
+ rescue => e
15
+ @error = e.message
16
+ render :preview, layout: false
17
+ end
18
+
19
+ def create
20
+ migration = build_migration
21
+
22
+ unless migration.any?
23
+ redirect_to(new_table_index_path(@table), alert: "Pick at least one column to index.")
24
+ return
25
+ end
26
+
27
+ written = migration.write!
28
+ redirect_to migrations_path, notice: "Created #{written.join(", ")}. Run it below to apply."
29
+ end
30
+
31
+ def drop
32
+ index = inspector.table(@table).indexes.find { |candidate| candidate.name == params[:name] }
33
+ head :not_found and return unless index
34
+
35
+ written = DropIndexMigration.new(table: @table, index: index).write!
36
+ redirect_to migrations_path, notice: "Created #{written.join(", ")}. Run it below to apply."
37
+ end
38
+
39
+ private
40
+
41
+ def build_migration
42
+ AddIndexMigration.from_params(table: @table, columns: params[:columns], unique: params[:unique], name: params[:name])
43
+ end
44
+
45
+ def require_table
46
+ @table = params[:table_id]
47
+ head :not_found unless inspector.exists?(@table)
48
+ end
49
+
50
+ def inspector
51
+ @inspector ||= SchemaInspector.new
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,41 @@
1
+ module SlashMigrate
2
+ class MigrationsController < ApplicationController
3
+ def index
4
+ @migrations = runner.status
5
+ @pending = runner.pending?
6
+ end
7
+
8
+ def run
9
+ finish(runner.migrate, "Migrated.", "rails db:migrate")
10
+ end
11
+
12
+ def rollback
13
+ finish(runner.rollback, "Rolled back the last migration.", "rails db:rollback")
14
+ end
15
+
16
+ def destroy
17
+ result = runner.delete(params[:version])
18
+ flash[result.success? ? :notice : :alert] = result.output
19
+ redirect_to migrations_path
20
+ end
21
+
22
+ private
23
+
24
+ def runner
25
+ @runner ||= MigrationRunner.new
26
+ end
27
+
28
+ # Post/redirect/get: stash the (truncated) command output in the flash so a
29
+ # refresh doesn't re-run the task.
30
+ def finish(result, message, command)
31
+ flash[:migrate_output] = result.output.to_s.last(3000)
32
+ flash[:migrate_command] = command
33
+ if result.success?
34
+ flash[:notice] = message
35
+ else
36
+ flash[:alert] = "Command failed — see the output below."
37
+ end
38
+ redirect_to migrations_path
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,49 @@
1
+ module SlashMigrate
2
+ class ModelsController < ApplicationController
3
+ def new
4
+ @builder = MigrationBuilder.new(name: "")
5
+ @existing_tables = inspector.table_names
6
+ end
7
+
8
+ # Live preview: rebuilds the migration + model source on every (debounced)
9
+ # form change and streams it back. Pure string building, no disk writes.
10
+ def preview
11
+ @builder = build_builder
12
+
13
+ if @builder.name_present?
14
+ @table_exists = inspector.exists?(@builder.table_name)
15
+ else
16
+ @hint = "Enter a model name to see the migration it will generate."
17
+ end
18
+
19
+ render :preview, layout: false
20
+ rescue => e
21
+ # Partial/invalid input shouldn't 500 the live preview; show the problem.
22
+ @error = e.message
23
+ render :preview, layout: false
24
+ end
25
+
26
+ def create
27
+ builder = build_builder
28
+
29
+ if inspector.exists?(builder.table_name)
30
+ redirect_to(new_model_path, alert: "A table named #{builder.table_name} already exists.")
31
+ return
32
+ end
33
+
34
+ written = builder.write!
35
+ redirect_to migrations_path,
36
+ notice: "Created #{written.join(", ")}. Run it below to apply."
37
+ end
38
+
39
+ private
40
+
41
+ def build_builder
42
+ MigrationBuilder.from_params(name: params[:model_name], rows: params[:attributes])
43
+ end
44
+
45
+ def inspector
46
+ @inspector ||= SchemaInspector.new
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,19 @@
1
+ module SlashMigrate
2
+ class TablesController < ApplicationController
3
+ def index
4
+ @table_names = inspector.table_names
5
+ end
6
+
7
+ def show
8
+ head :not_found and return unless inspector.exists?(params[:id])
9
+
10
+ @table = inspector.table(params[:id])
11
+ end
12
+
13
+ private
14
+
15
+ def inspector
16
+ @inspector ||= SchemaInspector.new
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,168 @@
1
+ module SlashMigrate
2
+ module ApplicationHelper
3
+ # Inline 16px SVG icons, ported from the design's stroke set. Returned with
4
+ # width/height of 1em so they scale with surrounding text; per-context CSS
5
+ # (.btn svg, .nav-link svg, …) overrides the size where needed.
6
+ ICON_PATHS = {
7
+ database: %(<ellipse cx="8" cy="3.5" rx="5.5" ry="2"/><path d="M2.5 3.5v9c0 1.1 2.46 2 5.5 2s5.5-.9 5.5-2v-9"/><path d="M2.5 8c0 1.1 2.46 2 5.5 2s5.5-.9 5.5-2"/>),
8
+ plus: %(<path d="M8 3v10M3 8h10"/>),
9
+ arrow_right: %(<path d="M3 8h10M9 4l4 4-4 4"/>),
10
+ arrow_left: %(<path d="M13 8H3M7 4L3 8l4 4"/>),
11
+ chevron: %(<path d="M6 4l4 4-4 4"/>),
12
+ history: %(<path d="M2.5 8a5.5 5.5 0 1 0 1.6-3.9"/><path d="M2 2v3h3"/><path d="M8 5v3.5l2 1.2"/>),
13
+ info: %(<circle cx="8" cy="8" r="6"/><path d="M8 7.2v3.6M8 5.2v.01"/>),
14
+ warn: %(<path d="M8 2.5l6 11H2l6-11Z"/><path d="M8 6.5v3M8 11.4v.01"/>),
15
+ check: %(<path d="M3 8.5l3.5 3.5L13 5"/>),
16
+ x: %(<path d="M4 4l8 8M12 4l-8 8"/>),
17
+ trash: %(<path d="M3 4.5h10M6.5 4.5V3a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1.5M5 4.5l.5 8a1 1 0 0 0 1 .9h3a1 1 0 0 0 1-.9l.5-8"/>),
18
+ play: %(<path d="M5 3.5v9l8-4.5z"/>),
19
+ rotate: %(<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M14 2v3h-3"/>),
20
+ file: %(<path d="M9 1.5H4.5A1.5 1.5 0 0 0 3 3v10a1.5 1.5 0 0 0 1.5 1.5h7A1.5 1.5 0 0 0 13 13V5.5z"/><path d="M9 1.5V5.5H13"/>),
21
+ terminal: %(<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 7l2 1.5L5 10M8.5 10h2.5"/>),
22
+ key: %(<circle cx="6" cy="10" r="3"/><path d="M8.1 7.9 13 3M11 5l2 2M9.5 6.5L11.5 8.5"/>),
23
+ lock: %(<rect x="3.5" y="7" width="9" height="6.5" rx="1.5"/><path d="M5.5 7V5.2a2.5 2.5 0 0 1 5 0V7"/>)
24
+ }.freeze
25
+ FILLED_ICONS = %i[play].freeze
26
+
27
+ def sm_icon(name)
28
+ name = name.to_sym
29
+ inner = ICON_PATHS.fetch(name)
30
+ head =
31
+ if FILLED_ICONS.include?(name)
32
+ %(<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">)
33
+ else
34
+ %(<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">)
35
+ end
36
+ (head + inner + "</svg>").html_safe
37
+ end
38
+
39
+ # A top-nav link with its icon and active state.
40
+ def nav_link(label, url, icon, active: false)
41
+ link_to url, class: "nav-link#{" is-active" if active}" do
42
+ safe_join([sm_icon(icon), label], " ")
43
+ end
44
+ end
45
+
46
+ # The column types worth showing first, in teaching order. Anything else the
47
+ # database supports follows, alphabetically.
48
+ COMMON_COLUMN_TYPES = %w[string text integer boolean references datetime date decimal float].freeze
49
+
50
+ # The column types this app's database actually supports, read from the
51
+ # adapter (so Postgres surfaces uuid/jsonb/etc., SQLite its smaller set), with
52
+ # the everyday types pinned to the top. `references` is a Rails association
53
+ # helper rather than a native type, so it's added unless asked otherwise
54
+ # (change_column can't target it). Note: virtual types a gem adds via the
55
+ # table-definition API — e.g. money-rails' monetize — aren't reported here.
56
+ def available_column_types(include_references: true)
57
+ native = ActiveRecord::Base.connection.native_database_types.keys.map(&:to_s) - ["primary_key"]
58
+ native << "references" if include_references
59
+ native.uniq!
60
+ common = COMMON_COLUMN_TYPES.select { |type| native.include?(type) }
61
+ common + (native - common).sort
62
+ end
63
+
64
+ # The 14-digit UTC version a migration generated right now would carry. Shown
65
+ # in preview filenames and ticked forward each second client-side, so students
66
+ # see where that number comes from.
67
+ def migration_version
68
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
69
+ end
70
+
71
+ # Which nav item to highlight, derived from the controller handling the request.
72
+ def nav_section
73
+ case controller_name
74
+ when "models" then :new
75
+ when "migrations" then :migrations
76
+ else :tables # tables, columns, indexes all live under "Tables"
77
+ end
78
+ end
79
+
80
+ # Renders Ruby source as the syntax-highlighted lines of a `.code-body`. A
81
+ # small, purpose-built tokenizer — enough to colour migrations and models,
82
+ # not a general Ruby parser.
83
+ def ruby_code_body(source)
84
+ lines = source.to_s.split("\n", -1)
85
+ lines.pop if lines.last == "" # trailing newline shouldn't draw a blank line
86
+ rendered = lines.map { |line| "<span>#{highlight_ruby_line(line)}</span>" }.join
87
+ %(<div class="code-lines">#{rendered}</div>).html_safe
88
+ end
89
+
90
+ # Renders raw command output as plain monochrome lines (no gutter, no tinting)
91
+ # — the way a real terminal shows it.
92
+ def terminal_body(output)
93
+ lines = output.to_s.split("\n", -1)
94
+ lines.pop if lines.last == ""
95
+ rendered = lines.map { |line| "<span>#{line.empty? ? "&nbsp;" : ERB::Util.html_escape(line)}</span>" }.join
96
+ %(<div class="code-lines">#{rendered}</div>).html_safe
97
+ end
98
+
99
+ private
100
+
101
+ RUBY_KEYWORDS = %w[
102
+ class def end if elsif else unless do return nil true false module
103
+ up down change
104
+ ].freeze
105
+ RUBY_METHODS = %w[
106
+ create_table add_column remove_column change_column rename_column rename_table
107
+ add_reference remove_reference add_index remove_index drop_table change_table
108
+ add_foreign_key remove_foreign_key change_column_null change_column_default execute
109
+ belongs_to has_many has_one validates references string text integer bigint
110
+ float decimal boolean date datetime time timestamps binary json
111
+ null default limit precision scale index unique foreign_key to_table from to comment
112
+ ].freeze
113
+ RUBY_TOKEN = %r{
114
+ "(?:[^"\\]|\\.)*" # double-quoted string
115
+ |'(?:[^'\\]|\\.)*' # single-quoted string
116
+ |:[a-z_]\w* # symbol
117
+ |[A-Z][\w:]* # constant or class name
118
+ |[a-z_]\w*[?!]? # identifier or method
119
+ |\d+\.\d+ # float
120
+ |\d+ # integer
121
+ |\#.* # comment to end of line
122
+ |[^\s\w] # punctuation
123
+ }x
124
+
125
+ def highlight_ruby_line(line)
126
+ return "&nbsp;" if line.empty?
127
+
128
+ leading = line[/\A\s*/]
129
+ rest = line[leading.length..]
130
+ out = +""
131
+ out << ERB::Util.html_escape(leading) unless leading.empty?
132
+ return out + span("cm", rest) if rest.start_with?("#")
133
+
134
+ last = 0
135
+ rest.scan(RUBY_TOKEN) do
136
+ token = Regexp.last_match[0]
137
+ start = Regexp.last_match.begin(0)
138
+ out << ERB::Util.html_escape(rest[last...start]) if start > last
139
+ out << span(ruby_token_class(token), token)
140
+ last = Regexp.last_match.end(0)
141
+ end
142
+ out << ERB::Util.html_escape(rest[last..]) if last < rest.length
143
+ out
144
+ end
145
+
146
+ def ruby_token_class(token)
147
+ case token
148
+ when /\A["']/ then "s"
149
+ when /\A:/ then "sy"
150
+ when /\A#/ then "cm"
151
+ when /\A\d/ then "n"
152
+ when /\A[A-Z]/ then "c"
153
+ else
154
+ if RUBY_KEYWORDS.include?(token)
155
+ "k"
156
+ elsif RUBY_METHODS.include?(token)
157
+ "m"
158
+ else
159
+ "p"
160
+ end
161
+ end
162
+ end
163
+
164
+ def span(klass, text)
165
+ %(<em class="tk-#{klass}">#{ERB::Util.html_escape(text)}</em>)
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,4 @@
1
+ module SlashMigrate
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module SlashMigrate
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module SlashMigrate
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end