rails_studio 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 (33) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +137 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/rails_studio/application.css +15 -0
  7. data/app/controllers/rails_studio/api/console_controller.rb +17 -0
  8. data/app/controllers/rails_studio/api/overview_controller.rb +11 -0
  9. data/app/controllers/rails_studio/api/query_controller.rb +18 -0
  10. data/app/controllers/rails_studio/api/records_controller.rb +96 -0
  11. data/app/controllers/rails_studio/api/tables_controller.rb +12 -0
  12. data/app/controllers/rails_studio/application_controller.rb +39 -0
  13. data/app/controllers/rails_studio/dashboard_controller.rb +59 -0
  14. data/app/helpers/rails_studio/application_helper.rb +4 -0
  15. data/app/jobs/rails_studio/application_job.rb +4 -0
  16. data/app/mailers/rails_studio/application_mailer.rb +6 -0
  17. data/app/models/rails_studio/application_record.rb +5 -0
  18. data/app/models/rails_studio/console.rb +150 -0
  19. data/app/models/rails_studio/database.rb +76 -0
  20. data/app/models/rails_studio/query.rb +114 -0
  21. data/app/models/rails_studio/read_only_error.rb +5 -0
  22. data/app/models/rails_studio/table.rb +495 -0
  23. data/app/views/layouts/rails_studio/application.html.erb +17 -0
  24. data/app/views/rails_studio/dashboard/index.html.erb +51 -0
  25. data/config/routes.rb +23 -0
  26. data/lib/rails_studio/configuration.rb +32 -0
  27. data/lib/rails_studio/engine.rb +5 -0
  28. data/lib/rails_studio/version.rb +3 -0
  29. data/lib/rails_studio.rb +52 -0
  30. data/lib/tasks/rails_studio_tasks.rake +4 -0
  31. data/public/assets/index.css +1 -0
  32. data/public/assets/index.js +367 -0
  33. metadata +91 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 527dfed966038785c8daa627f6dd25d9dd1c161eb72cae0e6a1f292573e089e1
4
+ data.tar.gz: fddc652d53d6ecd07542ef13ff6fa531d3cb7fb3afe75672eb05def6bff9ef2c
5
+ SHA512:
6
+ metadata.gz: 3815a4daac5fa289c97c873fbcde262c91c2953e8a1e30d6226e98d1f26144ab96cb4d3e8683ae0baa8e5fbbf92aecd05aa2766bd877c521d6f47ccd52ceba6b
7
+ data.tar.gz: 9824be2b206f1032848d8bbb3306b866e7efbafb811edfb3e57e460aceefa903bfc7ef7bdb2d6473ac350e3ce61866e79a4e280192e0154fba963b771f3d9268
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-21
11
+
12
+ ### Added
13
+
14
+ - First public release of Rails Studio as a mountable Rails engine
15
+ - Spreadsheet-like table browser with inline editing and staged commits
16
+ - Foreign key drawer and relation navigation
17
+ - SQL query runner and interactive Rails console
18
+ - Command palette, keyboard shortcuts, and light/dark themes
19
+
20
+ [Unreleased]: https://github.com/yuler/rails_studio/compare/v0.1.0...HEAD
21
+ [0.1.0]: https://github.com/yuler/rails_studio/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright yuler
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,137 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="assets/logo-light.svg">
5
+ <img alt="Rails Studio" src="assets/logo.svg" width="160">
6
+ </picture>
7
+ </p>
8
+
9
+ <p align="center">
10
+ <strong>A modern, fast, and beautiful Web Database Studio for Rails development.</strong><br>
11
+ Inspired by <a href="https://github.com/prisma/studio">Prisma Studio</a> and <a href="https://orm.drizzle.team/drizzle-studio/overview">Drizzle Studio</a>.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://rubygems.org/gems/rails_studio"><img src="https://img.shields.io/gem/v/rails_studio?color=e11d48&label=gem" alt="Gem Version"></a>
16
+ <a href="https://github.com/yuler/rails_studio/blob/main/MIT-LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
17
+ <a href="https://rubyonrails.org/"><img src="https://img.shields.io/badge/rails-%3E%3D%207.0-red.svg" alt="Rails 7+"></a>
18
+ <a href="#-inspiration"><img src="https://img.shields.io/badge/inspired%20by-Prisma%20%26%20Drizzle%20Studio-6366f1.svg" alt="Inspiration"></a>
19
+ </p>
20
+
21
+ <p align="center">
22
+ <video src="assets/intro.mp4" poster="assets/intro-poster.jpg" width="800" autoplay loop muted playsinline controls>
23
+ <a href="assets/intro.mp4"><img src="assets/intro-poster.jpg" alt="Rails Studio intro: browse tables, filter, inline edit, SQL, and Rails console" width="800"></a>
24
+ </video>
25
+ </p>
26
+
27
+ ---
28
+
29
+ Rails Studio is packaged as a **mountable Rails Engine** with zero dependencies required by the host application (pre-compiled SPA bundled with the gem — **no Node.js or NPM needed in your Rails app!**).
30
+
31
+ ## 💡 Inspiration
32
+
33
+ Rails Studio was heavily inspired by the remarkable developer experience of two pioneering tools in the modern TypeScript & full-stack ecosystem:
34
+
35
+ - **[Prisma Studio](https://github.com/prisma/studio)** — The visual, spreadsheet-like database browser for the Prisma ecosystem.
36
+ - **[Drizzle Studio](https://orm.drizzle.team/drizzle-studio/overview)** — The lightning-fast, modern database studio for Drizzle ORM.
37
+
38
+ We wanted to bring that exact same fluid, intuitive, spreadsheet-like database exploration and editing experience to the **Ruby on Rails** and **Active Record** ecosystem — with zero configuration, packaged cleanly as a mountable Rails engine.
39
+
40
+ ---
41
+
42
+ ## ✨ Features
43
+
44
+ - **🚀 One-Line Integration**: Mount directly inside your Rails `routes.rb` under development with zero configuration.
45
+ - **📦 Zero-Dependency Delivery**: Pre-compiled SPA bundled with the gem—no Node.js, Webpack, or asset pipeline setup required.
46
+ - **📊 Spreadsheet-like UX**: Browse, filter, sort, and inline-edit records in an interactive table with column badges and sticky headers.
47
+ - **🔗 Association Navigation**: Click foreign key badges to inspect and traverse referenced records in a side drawer.
48
+ - **💾 Git-like Staging**: Review edited cells locally before committing multiple updates atomically in a single database transaction.
49
+ - **⚡ SQL Query Runner**: Execute arbitrary SQL queries with query templates, execution timers, and CSV export.
50
+ - **💻 Embedded Rails Console**: Run Ruby and Active Record code in an integrated browser terminal with auto-completion and SQL traces.
51
+ - **⌨️ Keyboard-First & Command Palette**: Navigate tables and trigger actions rapidly via `Cmd+K` and built-in keyboard shortcuts.
52
+ - **🎨 Dark & Light Modes**: Seamless theme switching with automatic system detection and persistent user preferences.
53
+ - **🗄️ Database Agnostic**: Works out of the box with PostgreSQL, MySQL, SQLite, Trilogy, and any adapter supported by Active Record.
54
+ - **🧩 Modern Rails Ready**: Fully compatible with Rails 7.1+, 8.0+, composite primary keys, and multi-database architectures.
55
+
56
+ ---
57
+
58
+ ## 🔍 How It Works
59
+
60
+ Rails Studio operates as a lightweight, self-contained Rails Engine:
61
+
62
+ 1. **Mountable Engine**: Plugs directly into your Rails application pipeline via `config/routes.rb` without affecting your application logic.
63
+ 2. **Schema & Association Reflection**: Dynamically introspects your Active Record models, column types, and relationships using your existing database connections.
64
+ 3. **Pre-bundled SPA**: Delivers a modern React frontend directly from the gem's static assets, requiring no JavaScript toolchain in your host app.
65
+ 4. **Safe Transactions & In-Process Console**: Communicates with internal engine API endpoints to stage edits, commit atomic transactions, and evaluate console commands safely within your Rails runtime.
66
+
67
+ ---
68
+
69
+ ## 🚀 Quickstart
70
+
71
+ Add to your `Gemfile` and mount the engine in `config/routes.rb`:
72
+
73
+ ```ruby
74
+ # Gemfile
75
+ group :development do
76
+ gem "rails_studio"
77
+ end
78
+
79
+ # config/routes.rb
80
+ Rails.application.routes.draw do
81
+ mount RailsStudio::Engine => "/rails_studio" if Rails.env.development?
82
+ end
83
+ ```
84
+
85
+ Start your server (`bin/rails server`) and visit [http://localhost:3000/rails_studio](http://localhost:3000/rails_studio).
86
+
87
+ ---
88
+
89
+ ## ⚙️ Configuration (Optional)
90
+
91
+ You can customize Rails Studio by adding an initializer: `config/initializers/rails_studio.rb`:
92
+
93
+ ```ruby
94
+ RailsStudio.configure do |config|
95
+ # Allow access in production (Default: false, strongly recommended to keep false)
96
+ config.allow_in_production = false
97
+
98
+ # Read-only mode: prevents updates, creates, and deletes (Default: false)
99
+ config.read_only = false
100
+
101
+ # Default pagination records per page (Default: 50)
102
+ config.default_per_page = 50
103
+
104
+ # Maximum records allowed per page (Default: 200)
105
+ config.max_per_page = 200
106
+
107
+ # Tables excluded from sidebar and inspection
108
+ config.excluded_tables += %w[my_secret_table]
109
+ end
110
+ ```
111
+
112
+ ---
113
+
114
+ ## 🛠️ Development
115
+
116
+ We use [`mise`](https://mise.jdx.dev/) to manage development tasks:
117
+
118
+ ```bash
119
+ mise run setup # Install dependencies, build assets & prepare test DB
120
+ mise run dev # Start Rails server with frontend build watch
121
+ mise run build # Compile frontend assets into public/assets/
122
+ mise run test # Run automated test suite
123
+ ```
124
+
125
+ > **Manual setup**: `bundle install`, `pnpm --dir frontend install && pnpm --dir frontend build`, `cd test/dummy && bin/rails db:migrate db:seed`, then `bin/rails server`.
126
+
127
+ ---
128
+
129
+ ## 📦 Releasing
130
+
131
+ Maintainers: bump `lib/rails_studio/version.rb`, update `CHANGELOG.md`, run `mise run build`, then `bundle exec rake release`. See [RELEASING.md](RELEASING.md).
132
+
133
+ ---
134
+
135
+ ## 📄 License
136
+
137
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsStudio
4
+ module Api
5
+ class ConsoleController < ApplicationController
6
+ def execute
7
+ result = Console.execute(params[:command])
8
+ render json: result
9
+ end
10
+
11
+ def completions
12
+ result = Console.completions
13
+ render json: result
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsStudio
4
+ module Api
5
+ class OverviewController < ApplicationController
6
+ def show
7
+ render json: Database.current.overview
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsStudio
4
+ module Api
5
+ class QueryController < ApplicationController
6
+ def execute
7
+ result = Query.execute(params[:sql])
8
+ render json: result
9
+ rescue ActiveRecord::StatementInvalid => e
10
+ render json: { error: e.message }, status: :unprocessable_entity
11
+ rescue RailsStudio::ReadOnlyError => e
12
+ render json: { error: e.message }, status: :forbidden
13
+ rescue StandardError => e
14
+ render json: { error: e.message }, status: :internal_server_error
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsStudio
4
+ module Api
5
+ class RecordsController < ApplicationController
6
+ def index
7
+ table = Table.find(params[:table_name])
8
+ filters = parse_filters(params[:filters])
9
+
10
+ result = table.records(
11
+ page: params[:page],
12
+ per_page: params[:per_page],
13
+ sort_by: params[:sort_by],
14
+ sort_order: params[:sort_order],
15
+ filters: filters
16
+ )
17
+
18
+ render json: result
19
+ end
20
+
21
+ def show
22
+ table = Table.find(params[:table_name])
23
+ record = table.find_record(params[:id])
24
+ if record
25
+ render json: { record: record }
26
+ else
27
+ render json: { error: "Record not found" }, status: :not_found
28
+ end
29
+ end
30
+
31
+ def create
32
+ table = Table.find(params[:table_name])
33
+ record = table.create_record(record_params)
34
+ render json: { success: true, record: record }, status: :created
35
+ rescue ActiveRecord::RecordInvalid => e
36
+ render json: { success: false, error: e.message, errors: e.record.errors.full_messages }, status: :unprocessable_entity
37
+ rescue ActiveRecord::StatementInvalid => e
38
+ render json: { success: false, error: e.message }, status: :unprocessable_entity
39
+ end
40
+
41
+ def update
42
+ table = Table.find(params[:table_name])
43
+ record = table.update_record(params[:id], record_params)
44
+ render json: { success: true, record: record }
45
+ rescue ActiveRecord::RecordInvalid => e
46
+ render json: { success: false, error: e.message, errors: e.record.errors.full_messages }, status: :unprocessable_entity
47
+ rescue ActiveRecord::StatementInvalid => e
48
+ render json: { success: false, error: e.message }, status: :unprocessable_entity
49
+ end
50
+
51
+ def destroy
52
+ table = Table.find(params[:table_name])
53
+ table.destroy_record(params[:id])
54
+ render json: { success: true }
55
+ end
56
+
57
+ def batch
58
+ table = Table.find(params[:table_name])
59
+ results = table.batch_process(
60
+ updates: params[:updates] || [],
61
+ creates: params[:creates] || [],
62
+ deletes: params[:deletes] || []
63
+ )
64
+
65
+ render json: { success: true, results: results }
66
+ rescue StandardError => e
67
+ render json: { success: false, error: e.message }, status: :unprocessable_entity
68
+ end
69
+
70
+ private
71
+
72
+ def record_params
73
+ if params[:record].respond_to?(:permit!)
74
+ params.require(:record).permit!.to_h
75
+ elsif params[:record].is_a?(Hash)
76
+ params[:record]
77
+ else
78
+ {}
79
+ end
80
+ end
81
+
82
+ def parse_filters(raw_filters)
83
+ return [] if raw_filters.blank?
84
+
85
+ if raw_filters.is_a?(String)
86
+ parsed = JSON.parse(raw_filters) rescue []
87
+ parsed.is_a?(Array) ? parsed : []
88
+ elsif raw_filters.is_a?(Array)
89
+ raw_filters.map { |f| f.respond_to?(:to_unsafe_h) ? f.to_unsafe_h : f }
90
+ else
91
+ []
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsStudio
4
+ module Api
5
+ class TablesController < ApplicationController
6
+ def schema
7
+ table = Table.find(params[:table_name])
8
+ render json: table.schema
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsStudio
4
+ class ApplicationController < ActionController::Base
5
+ skip_forgery_protection
6
+ before_action :ensure_access_allowed
7
+
8
+ rescue_from StandardError, with: :handle_standard_error
9
+ rescue_from ActionDispatch::Http::Parameters::ParseError, with: :handle_parse_error
10
+ rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
11
+ rescue_from RailsStudio::ReadOnlyError, with: :handle_read_only
12
+
13
+ private
14
+
15
+ def ensure_access_allowed
16
+ return if RailsStudio.configuration.allow_in_production?
17
+ return if Rails.env.development? || Rails.env.test?
18
+
19
+ render plain: "Rails Studio is only accessible in development or test environment.", status: :forbidden
20
+ end
21
+
22
+ def handle_not_found(exception)
23
+ render json: { error: exception.message }, status: :not_found
24
+ end
25
+
26
+ def handle_parse_error(exception)
27
+ render json: { error: "Invalid JSON format: #{exception.message}" }, status: :bad_request
28
+ end
29
+
30
+ def handle_read_only(exception)
31
+ render json: { error: exception.message }, status: :forbidden
32
+ end
33
+
34
+ def handle_standard_error(exception)
35
+ logger.error("[RailsStudio] #{exception.class}: #{exception.message}\n#{exception.backtrace&.first(5)&.join("\n")}")
36
+ render json: { error: exception.message }, status: :internal_server_error
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsStudio
4
+ class DashboardController < ApplicationController
5
+ LOGO_FILES = %w[logo.svg logo-light.svg logo-dark.svg].freeze
6
+
7
+ def index
8
+ script_name = request.script_name.presence || ""
9
+ # Normalizes prefix (removes trailing slash)
10
+ @base_path = script_name.sub(%r{/\z}, "")
11
+
12
+ respond_to do |format|
13
+ format.html { render "rails_studio/dashboard/index", layout: false }
14
+ format.all { render "rails_studio/dashboard/index", layout: false, content_type: "text/html" }
15
+ end
16
+ end
17
+
18
+ def asset
19
+ relative_path = params[:path].to_s
20
+ relative_path = "#{relative_path}.#{params[:format]}" if params[:format].present? && !relative_path.end_with?(".#{params[:format]}")
21
+ public_dir = File.expand_path(File.join(Engine.root, "public", "assets"))
22
+ file_path = File.expand_path(File.join(public_dir, relative_path))
23
+
24
+ unless File.file?(file_path)
25
+ logo_dir = File.expand_path(File.join(Engine.root, "assets"))
26
+ logo_path = File.expand_path(File.join(logo_dir, File.basename(relative_path)))
27
+ if LOGO_FILES.include?(File.basename(logo_path)) && logo_path.start_with?("#{logo_dir}/") && File.file?(logo_path)
28
+ file_path = logo_path
29
+ end
30
+ end
31
+
32
+ served_from_public = file_path == public_dir || file_path.start_with?("#{public_dir}/")
33
+ served_from_logos = LOGO_FILES.include?(File.basename(file_path)) &&
34
+ file_path.start_with?("#{File.expand_path(File.join(Engine.root, "assets"))}/")
35
+
36
+ if (served_from_public || served_from_logos) && File.file?(file_path)
37
+ ext = File.extname(file_path).delete_prefix(".")
38
+ content_type = case ext
39
+ when "js", "mjs" then "application/javascript"
40
+ when "css" then "text/css"
41
+ when "svg" then "image/svg+xml"
42
+ when "json" then "application/json"
43
+ when "woff2" then "font/woff2"
44
+ when "woff" then "font/woff"
45
+ when "ttf" then "font/ttf"
46
+ when "png" then "image/png"
47
+ when "jpg", "jpeg" then "image/jpeg"
48
+ when "ico" then "image/x-icon"
49
+ else "application/octet-stream"
50
+ end
51
+
52
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable" if Rails.env.production?
53
+ send_file file_path, type: content_type, disposition: "inline"
54
+ else
55
+ head :not_found
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,4 @@
1
+ module RailsStudio
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module RailsStudio
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module RailsStudio
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module RailsStudio
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "pp"
5
+
6
+ module RailsStudio
7
+ class Console
8
+ EVAL_MUTEX = Mutex.new
9
+
10
+ DANGEROUS_PATTERNS = [
11
+ /\b(create!|create|destroy_all|destroy|delete_all|delete|update_all|update!|update|drop_table|execute|truncate)\b/,
12
+ /\b(save!|save|update_attribute|update_column|update_columns|toggle!)\b/
13
+ ].freeze
14
+
15
+ class << self
16
+ def execute(command)
17
+ clean_code = command.to_s.strip
18
+ if clean_code.blank?
19
+ return { result: nil, result_type: "NilClass", stdout: "", queries: [], duration_ms: 0 }
20
+ end
21
+
22
+ if RailsStudio.configuration.read_only?
23
+ validate_read_only_code!(clean_code)
24
+ end
25
+
26
+ queries = []
27
+ current_thread = Thread.current
28
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
29
+ next unless Thread.current == current_thread
30
+
31
+ event = ActiveSupport::Notifications::Event.new(*args)
32
+ unless event.payload[:name] == "SCHEMA"
33
+ queries << {
34
+ sql: event.payload[:sql],
35
+ duration_ms: event.duration.round(2)
36
+ }
37
+ end
38
+ end
39
+
40
+ stdout_buf = StringIO.new
41
+ duration_ms = 0
42
+
43
+ begin
44
+ result = nil
45
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
+
47
+ EVAL_MUTEX.synchronize do
48
+ old_stdout = $stdout
49
+ $stdout = stdout_buf
50
+ begin
51
+ result = if RailsStudio.configuration.read_only? && ActiveRecord::Base.respond_to?(:while_preventing_writes)
52
+ ActiveRecord::Base.while_preventing_writes do
53
+ TOPLEVEL_BINDING.eval(clean_code)
54
+ end
55
+ else
56
+ TOPLEVEL_BINDING.eval(clean_code)
57
+ end
58
+ ensure
59
+ $stdout = old_stdout
60
+ end
61
+ end
62
+
63
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
64
+
65
+ formatted_result = if result.nil?
66
+ "nil"
67
+ elsif result.is_a?(String)
68
+ result.inspect
69
+ else
70
+ PP.pp(result, String.new, 100).strip
71
+ end
72
+
73
+ {
74
+ result: formatted_result,
75
+ result_type: result.class.name,
76
+ stdout: stdout_buf.string,
77
+ queries: queries,
78
+ duration_ms: duration_ms
79
+ }
80
+ rescue Exception => e
81
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2) rescue 0
82
+ {
83
+ error: {
84
+ class: e.class.name,
85
+ message: e.message,
86
+ backtrace: clean_backtrace(e.backtrace)
87
+ },
88
+ stdout: stdout_buf.string,
89
+ queries: queries,
90
+ duration_ms: duration_ms
91
+ }
92
+ ensure
93
+ ActiveSupport::Notifications.unsubscribe(subscriber)
94
+ end
95
+ end
96
+
97
+ def completions
98
+ Rails.application.eager_load! rescue nil
99
+
100
+ all_models = ActiveRecord::Base.descendants.reject(&:abstract_class?).select { |m| m.name.present? }
101
+ app_models = all_models.reject { |m| m.name.to_s.start_with?("Action", "ActiveStorage") }
102
+ framework_models = all_models.select { |m| m.name.to_s.start_with?("Action", "ActiveStorage") }
103
+ sorted_models = app_models + framework_models
104
+
105
+ models = sorted_models.map do |model|
106
+ {
107
+ name: model.name,
108
+ table_name: model.table_name,
109
+ columns: (model.column_names rescue []),
110
+ associations: (model.reflect_on_all_associations.map(&:name).map(&:to_s) rescue [])
111
+ }
112
+ end
113
+
114
+ common_methods = %w[
115
+ all count first last find find_by where pluck order limit
116
+ group having select distinct joins includes create create!
117
+ new build destroy_all delete_all update_all to_sql explain
118
+ columns column_names table_name primary_key
119
+ ]
120
+
121
+ rails_globals = %w[
122
+ Rails.env Rails.application Rails.version Rails.root
123
+ ActiveRecord::Base
124
+ ]
125
+
126
+ {
127
+ models: models,
128
+ common_methods: common_methods,
129
+ globals: rails_globals
130
+ }
131
+ end
132
+
133
+ private
134
+
135
+ def validate_read_only_code!(code)
136
+ if DANGEROUS_PATTERNS.any? { |pattern| code =~ pattern }
137
+ raise RailsStudio::ReadOnlyError, "Modifying operations are disabled in read-only mode."
138
+ end
139
+ end
140
+
141
+ def clean_backtrace(backtrace)
142
+ return [] unless backtrace.is_a?(Array)
143
+
144
+ backtrace.first(6).map do |line|
145
+ line.sub(Rails.root.to_s, ".")
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end