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
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,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? ? " " : 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 " " 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
|