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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/MIT-LICENSE +20 -0
- data/README.md +137 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/rails_studio/application.css +15 -0
- data/app/controllers/rails_studio/api/console_controller.rb +17 -0
- data/app/controllers/rails_studio/api/overview_controller.rb +11 -0
- data/app/controllers/rails_studio/api/query_controller.rb +18 -0
- data/app/controllers/rails_studio/api/records_controller.rb +96 -0
- data/app/controllers/rails_studio/api/tables_controller.rb +12 -0
- data/app/controllers/rails_studio/application_controller.rb +39 -0
- data/app/controllers/rails_studio/dashboard_controller.rb +59 -0
- data/app/helpers/rails_studio/application_helper.rb +4 -0
- data/app/jobs/rails_studio/application_job.rb +4 -0
- data/app/mailers/rails_studio/application_mailer.rb +6 -0
- data/app/models/rails_studio/application_record.rb +5 -0
- data/app/models/rails_studio/console.rb +150 -0
- data/app/models/rails_studio/database.rb +76 -0
- data/app/models/rails_studio/query.rb +114 -0
- data/app/models/rails_studio/read_only_error.rb +5 -0
- data/app/models/rails_studio/table.rb +495 -0
- data/app/views/layouts/rails_studio/application.html.erb +17 -0
- data/app/views/rails_studio/dashboard/index.html.erb +51 -0
- data/config/routes.rb +23 -0
- data/lib/rails_studio/configuration.rb +32 -0
- data/lib/rails_studio/engine.rb +5 -0
- data/lib/rails_studio/version.rb +3 -0
- data/lib/rails_studio.rb +52 -0
- data/lib/tasks/rails_studio_tasks.rake +4 -0
- data/public/assets/index.css +1 -0
- data/public/assets/index.js +367 -0
- metadata +91 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsStudio
|
|
4
|
+
class Database
|
|
5
|
+
class << self
|
|
6
|
+
def current
|
|
7
|
+
new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def connection
|
|
11
|
+
ActiveRecord::Base.connection
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def connection
|
|
16
|
+
self.class.connection
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def adapter
|
|
20
|
+
connection.adapter_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name
|
|
24
|
+
if connection.pool&.db_config&.database.present?
|
|
25
|
+
File.basename(connection.pool.db_config.database.to_s)
|
|
26
|
+
elsif connection.respond_to?(:current_database) && connection.current_database.present?
|
|
27
|
+
connection.current_database
|
|
28
|
+
elsif connection.raw_connection.respond_to?(:filename) && connection.raw_connection.filename.present?
|
|
29
|
+
File.basename(connection.raw_connection.filename)
|
|
30
|
+
else
|
|
31
|
+
"database"
|
|
32
|
+
end
|
|
33
|
+
rescue StandardError
|
|
34
|
+
"database"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tables
|
|
38
|
+
excluded = RailsStudio.configuration.excluded_tables
|
|
39
|
+
(connection.tables - excluded).sort.map do |table_name|
|
|
40
|
+
Table.new(table_name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def table(table_name)
|
|
45
|
+
Table.find(table_name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def configured_databases
|
|
49
|
+
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).map do |cfg|
|
|
50
|
+
{
|
|
51
|
+
name: cfg.name,
|
|
52
|
+
adapter: cfg.adapter,
|
|
53
|
+
database: cfg.database
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def overview
|
|
61
|
+
all_tables = tables
|
|
62
|
+
{
|
|
63
|
+
database: {
|
|
64
|
+
adapter: adapter,
|
|
65
|
+
database_name: name,
|
|
66
|
+
rails_version: Rails.version,
|
|
67
|
+
ruby_version: RUBY_VERSION,
|
|
68
|
+
read_only: RailsStudio.configuration.read_only?,
|
|
69
|
+
configured_databases: configured_databases
|
|
70
|
+
},
|
|
71
|
+
tables: all_tables.map(&:summary),
|
|
72
|
+
total_tables: all_tables.size
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsStudio
|
|
4
|
+
class Query
|
|
5
|
+
attr_reader :sql
|
|
6
|
+
|
|
7
|
+
WRITE_COMMANDS = %w[INSERT UPDATE DELETE DROP TRUNCATE ALTER CREATE MERGE REPLACE].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def execute(sql)
|
|
11
|
+
new(sql).execute
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(sql)
|
|
16
|
+
@sql = sql.to_s.strip
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def execute
|
|
20
|
+
if sql.blank?
|
|
21
|
+
return { columns: [], rows: [], count: 0, duration_ms: 0, message: "Query was empty." }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
validate_expression_type!
|
|
25
|
+
validate_read_only!
|
|
26
|
+
|
|
27
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
28
|
+
|
|
29
|
+
result = if RailsStudio.configuration.read_only? && ActiveRecord::Base.respond_to?(:while_preventing_writes)
|
|
30
|
+
ActiveRecord::Base.while_preventing_writes do
|
|
31
|
+
ActiveRecord::Base.connection.exec_query(sql)
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
ActiveRecord::Base.connection.exec_query(sql)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
38
|
+
|
|
39
|
+
affected_rows = result.affected_rows if result.respond_to?(:affected_rows)
|
|
40
|
+
message = format_message(result, affected_rows)
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
columns: result.columns,
|
|
44
|
+
rows: result.rows,
|
|
45
|
+
count: result.rows.size,
|
|
46
|
+
affected_rows: affected_rows,
|
|
47
|
+
message: message,
|
|
48
|
+
duration_ms: duration_ms
|
|
49
|
+
}.merge(source_table_info)
|
|
50
|
+
rescue ActiveRecord::ReadOnlyError => e
|
|
51
|
+
raise RailsStudio::ReadOnlyError, "Write query attempted while in read-only mode: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_expression_type!
|
|
57
|
+
if sql =~ /^[A-Z][a-zA-Z0-9_]*(::[A-Z][a-zA-Z0-9_]*)*\.(all|where|find|first|last|count|pluck|limit|order|create|new|destroy|update|delete|group|joins|includes|select)\b/ ||
|
|
58
|
+
sql =~ /^(Rails|ActiveRecord|ENV)\b/
|
|
59
|
+
raise ActiveRecord::StatementInvalid,
|
|
60
|
+
"It appears you entered an Active Record expression ('#{sql}'). " \
|
|
61
|
+
"SQL Runner only executes raw SQL queries. " \
|
|
62
|
+
"Please use the Rails Console terminal at the bottom of the screen to run Ruby & Active Record expressions directly."
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_read_only!
|
|
67
|
+
return unless RailsStudio.configuration.read_only?
|
|
68
|
+
|
|
69
|
+
stripped = sql.gsub(%r{/\*.*?\*/}m, "").gsub(/--.*$/, "").strip
|
|
70
|
+
first_word = stripped.split(/\s+/).first.to_s.upcase
|
|
71
|
+
|
|
72
|
+
unless %w[SELECT EXPLAIN SHOW DESC DESCRIBE PRAGMA WITH].include?(first_word)
|
|
73
|
+
raise RailsStudio::ReadOnlyError, "Only read queries (SELECT, EXPLAIN) are permitted in read-only mode."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if first_word == "WITH" || first_word == "EXPLAIN"
|
|
77
|
+
no_literals = stripped.gsub(/'(?:''|[^'])*'/, "")
|
|
78
|
+
if WRITE_COMMANDS.any? { |cmd| no_literals =~ /\b#{cmd}\b/i }
|
|
79
|
+
raise RailsStudio::ReadOnlyError, "Modifying operations inside #{first_word} queries are disabled in read-only mode."
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def source_table_info
|
|
85
|
+
stripped = sql.gsub(%r{/\*.*?\*/}m, "").gsub(/--[^\n]*/, "")
|
|
86
|
+
first_word = stripped.split(/\s+/).first.to_s.upcase
|
|
87
|
+
return {} unless first_word == "SELECT"
|
|
88
|
+
return {} if stripped =~ /\b(JOIN|UNION|INTERSECT|EXCEPT)\b/i
|
|
89
|
+
|
|
90
|
+
froms = stripped.scan(/\bFROM\s+(?:["'`\[]?)([A-Za-z_][A-Za-z0-9_.]*)/i).flatten
|
|
91
|
+
return {} unless froms.size == 1
|
|
92
|
+
|
|
93
|
+
table_name = froms.first.to_s.split(".").last
|
|
94
|
+
return {} unless Table.exists?(table_name)
|
|
95
|
+
|
|
96
|
+
pks = Table.new(table_name).primary_keys
|
|
97
|
+
return {} if pks.empty?
|
|
98
|
+
|
|
99
|
+
{ table_name: table_name, primary_keys: pks }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def format_message(result, affected_rows)
|
|
103
|
+
if result.columns.empty?
|
|
104
|
+
if affected_rows && affected_rows > 0
|
|
105
|
+
"Query executed successfully (#{affected_rows} #{affected_rows == 1 ? 'row' : 'rows'} affected)."
|
|
106
|
+
else
|
|
107
|
+
"Query executed successfully."
|
|
108
|
+
end
|
|
109
|
+
else
|
|
110
|
+
"#{result.rows.size} #{result.rows.size == 1 ? 'row' : 'rows'} returned."
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module RailsStudio
|
|
6
|
+
class Table
|
|
7
|
+
attr_reader :name
|
|
8
|
+
|
|
9
|
+
MODEL_CACHE = Concurrent::Map.new
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def all
|
|
13
|
+
Database.current.tables
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find(name)
|
|
17
|
+
table = new(name)
|
|
18
|
+
table.ensure_exists!
|
|
19
|
+
table
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exists?(name)
|
|
23
|
+
excluded = RailsStudio.configuration.excluded_tables
|
|
24
|
+
(connection.tables - excluded).include?(name.to_s)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def connection
|
|
28
|
+
ActiveRecord::Base.connection
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def clear_cache!
|
|
32
|
+
MODEL_CACHE.clear
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(name)
|
|
37
|
+
@name = name.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def connection
|
|
41
|
+
self.class.connection
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ensure_exists!
|
|
45
|
+
unless self.class.exists?(name)
|
|
46
|
+
raise ActiveRecord::RecordNotFound, "Table '#{name}' not found or excluded."
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def primary_keys
|
|
51
|
+
@primary_keys ||= begin
|
|
52
|
+
if connection.respond_to?(:primary_keys)
|
|
53
|
+
Array(connection.primary_keys(name)).map(&:to_s).compact
|
|
54
|
+
else
|
|
55
|
+
Array(connection.primary_key(name)).map(&:to_s).compact
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError
|
|
58
|
+
Array(connection.primary_key(name)).map(&:to_s).compact rescue []
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def columns
|
|
63
|
+
@columns ||= connection.columns(name) rescue []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def row_count
|
|
67
|
+
quoted = connection.quote_table_name(name)
|
|
68
|
+
connection.select_value("SELECT COUNT(*) FROM #{quoted}").to_i
|
|
69
|
+
rescue StandardError
|
|
70
|
+
0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def summary
|
|
74
|
+
{
|
|
75
|
+
name: name,
|
|
76
|
+
row_count: row_count,
|
|
77
|
+
columns_count: columns.size,
|
|
78
|
+
primary_keys: primary_keys,
|
|
79
|
+
foreign_keys_count: foreign_keys.size
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def schema
|
|
84
|
+
pks = primary_keys
|
|
85
|
+
fks = foreign_keys
|
|
86
|
+
model = app_model
|
|
87
|
+
enums = model&.respond_to?(:defined_enums) ? (model.defined_enums rescue {}) : {}
|
|
88
|
+
|
|
89
|
+
columns_data = columns.map do |col|
|
|
90
|
+
fk_info = fks.find { |fk| fk[:column].to_s == col.name.to_s }
|
|
91
|
+
is_pk = pks.include?(col.name.to_s)
|
|
92
|
+
col_enums = enums[col.name.to_s]&.keys || enums[col.name.to_sym]&.keys
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
name: col.name,
|
|
96
|
+
type: col.type.to_s,
|
|
97
|
+
sql_type: col.sql_type.to_s,
|
|
98
|
+
null: col.null,
|
|
99
|
+
default: col.default,
|
|
100
|
+
comment: col.respond_to?(:comment) ? col.comment : nil,
|
|
101
|
+
primary: is_pk,
|
|
102
|
+
foreign_key: fk_info,
|
|
103
|
+
enum_values: col_enums
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
table_name: name,
|
|
109
|
+
primary_keys: pks,
|
|
110
|
+
columns: columns_data,
|
|
111
|
+
foreign_keys: fks,
|
|
112
|
+
associations: associations,
|
|
113
|
+
indexes: indexes,
|
|
114
|
+
row_count: row_count
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def foreign_keys
|
|
119
|
+
@foreign_keys ||= begin
|
|
120
|
+
fks = connection.foreign_keys(name) rescue []
|
|
121
|
+
result = fks.map do |fk|
|
|
122
|
+
{
|
|
123
|
+
column: fk.options[:column] || fk.column,
|
|
124
|
+
to_table: fk.to_table,
|
|
125
|
+
primary_key: fk.primary_key || "id",
|
|
126
|
+
name: fk.name
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if model = app_model
|
|
131
|
+
model.reflect_on_all_associations(:belongs_to).each do |assoc|
|
|
132
|
+
fk_col = assoc.foreign_key.to_s
|
|
133
|
+
next if result.any? { |f| f[:column].to_s == fk_col }
|
|
134
|
+
|
|
135
|
+
target_table = assoc.klass.table_name rescue assoc.plural_name
|
|
136
|
+
target_pk = assoc.klass.primary_key rescue "id"
|
|
137
|
+
result << {
|
|
138
|
+
column: fk_col,
|
|
139
|
+
to_table: target_table.to_s,
|
|
140
|
+
primary_key: target_pk.to_s,
|
|
141
|
+
name: "assoc_#{assoc.name}",
|
|
142
|
+
inferred: true
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
result
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def associations
|
|
152
|
+
return [] unless model = app_model
|
|
153
|
+
|
|
154
|
+
model.reflect_on_all_associations.map do |assoc|
|
|
155
|
+
{
|
|
156
|
+
name: assoc.name.to_s,
|
|
157
|
+
macro: assoc.macro.to_s,
|
|
158
|
+
foreign_key: assoc.foreign_key.to_s,
|
|
159
|
+
target_table: (assoc.klass.table_name rescue assoc.plural_name).to_s,
|
|
160
|
+
polymorphic: assoc.polymorphic?
|
|
161
|
+
}
|
|
162
|
+
end rescue []
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def indexes
|
|
166
|
+
connection.indexes(name).map do |idx|
|
|
167
|
+
{
|
|
168
|
+
name: idx.name,
|
|
169
|
+
columns: Array(idx.columns).map(&:to_s),
|
|
170
|
+
unique: idx.unique
|
|
171
|
+
}
|
|
172
|
+
end rescue []
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def app_model
|
|
176
|
+
@app_model ||= begin
|
|
177
|
+
found = ActiveRecord::Base.descendants.find { |m| !m.abstract_class? && m.table_name == name }
|
|
178
|
+
if found
|
|
179
|
+
found
|
|
180
|
+
else
|
|
181
|
+
candidate_name = name.singularize.camelize
|
|
182
|
+
candidate = candidate_name.safe_constantize
|
|
183
|
+
candidate if candidate && candidate < ActiveRecord::Base && candidate.table_name == name
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def dynamic_model
|
|
189
|
+
target_table_name = name
|
|
190
|
+
MODEL_CACHE.compute_if_absent(target_table_name) do
|
|
191
|
+
pks = primary_keys
|
|
192
|
+
existing_model = app_model
|
|
193
|
+
|
|
194
|
+
parent_class = if existing_model && existing_model < ActiveRecord::Base && existing_model.respond_to?(:connection_pool) && existing_model.connection_specification_name != ActiveRecord::Base.connection_specification_name
|
|
195
|
+
Class.new(ActiveRecord::Base) do
|
|
196
|
+
self.abstract_class = true
|
|
197
|
+
establish_connection existing_model.connection_pool.db_config
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
ActiveRecord::Base
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
Class.new(parent_class) do
|
|
204
|
+
self.table_name = target_table_name
|
|
205
|
+
self.inheritance_column = nil
|
|
206
|
+
|
|
207
|
+
if pks.size == 1
|
|
208
|
+
self.primary_key = pks.first
|
|
209
|
+
elsif pks.size > 1
|
|
210
|
+
self.primary_key = pks
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def records(page: 1, per_page: 50, sort_by: nil, sort_order: "asc", filters: [])
|
|
217
|
+
model = dynamic_model
|
|
218
|
+
valid_cols = model.column_names
|
|
219
|
+
|
|
220
|
+
page = [ page.to_i, 1 ].max
|
|
221
|
+
per_page_config = RailsStudio.configuration.default_per_page
|
|
222
|
+
max_per_page = RailsStudio.configuration.max_per_page
|
|
223
|
+
per_page = [ [ (per_page || per_page_config).to_i, 1 ].max, max_per_page ].min
|
|
224
|
+
|
|
225
|
+
scope = model.all
|
|
226
|
+
scope = apply_filters(scope, model, valid_cols, filters)
|
|
227
|
+
|
|
228
|
+
total_count = scope.count rescue 0
|
|
229
|
+
|
|
230
|
+
if sort_by.present? && valid_cols.include?(sort_by.to_s)
|
|
231
|
+
direction = sort_order.to_s.downcase == "desc" ? :desc : :asc
|
|
232
|
+
scope = scope.order(model.arel_table[sort_by.to_sym].send(direction))
|
|
233
|
+
else
|
|
234
|
+
pks = Array(model.primary_key)
|
|
235
|
+
if pks.present? && pks.all? { |k| valid_cols.include?(k.to_s) }
|
|
236
|
+
order_hash = pks.each_with_object({}) { |pk, h| h[pk.to_sym] = :desc }
|
|
237
|
+
scope = scope.order(order_hash)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
offset = (page - 1) * per_page
|
|
242
|
+
record_list = scope.limit(per_page).offset(offset).to_a
|
|
243
|
+
total_pages = (total_count.to_f / per_page).ceil
|
|
244
|
+
|
|
245
|
+
{
|
|
246
|
+
records: record_list.map(&:attributes),
|
|
247
|
+
total_count: total_count,
|
|
248
|
+
page: page,
|
|
249
|
+
per_page: per_page,
|
|
250
|
+
total_pages: [ total_pages, 1 ].max
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def find_record(id)
|
|
255
|
+
record = find_by_id(dynamic_model, id)
|
|
256
|
+
record&.attributes
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def find_record!(id)
|
|
260
|
+
find_by_id!(dynamic_model, id)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def create_record(attributes)
|
|
264
|
+
ensure_writable!
|
|
265
|
+
model = dynamic_model
|
|
266
|
+
sanitized = sanitize_attributes(model, attributes)
|
|
267
|
+
record = model.new(sanitized)
|
|
268
|
+
record.save!
|
|
269
|
+
record.attributes
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def update_record(id, attributes)
|
|
273
|
+
ensure_writable!
|
|
274
|
+
model = dynamic_model
|
|
275
|
+
record = find_record!(id)
|
|
276
|
+
sanitized = sanitize_attributes(model, attributes)
|
|
277
|
+
record.assign_attributes(sanitized)
|
|
278
|
+
record.save!
|
|
279
|
+
record.attributes
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def destroy_record(id)
|
|
283
|
+
ensure_writable!
|
|
284
|
+
record = find_record!(id)
|
|
285
|
+
record.destroy!
|
|
286
|
+
true
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def batch_process(updates: [], creates: [], deletes: [])
|
|
290
|
+
ensure_writable!
|
|
291
|
+
model = dynamic_model
|
|
292
|
+
results = { updated: 0, created: 0, deleted: 0 }
|
|
293
|
+
|
|
294
|
+
ActiveRecord::Base.transaction do
|
|
295
|
+
Array(deletes).each do |id|
|
|
296
|
+
record = find_by_id(model, id)
|
|
297
|
+
if record
|
|
298
|
+
record.destroy!
|
|
299
|
+
results[:deleted] += 1
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
Array(updates).each do |item|
|
|
304
|
+
id = item[:id] || item["id"]
|
|
305
|
+
changes = item[:changes] || item["changes"] || {}
|
|
306
|
+
next if id.blank? || changes.blank?
|
|
307
|
+
|
|
308
|
+
record = find_by_id!(model, id)
|
|
309
|
+
sanitized = sanitize_attributes(model, changes)
|
|
310
|
+
record.assign_attributes(sanitized)
|
|
311
|
+
record.save!
|
|
312
|
+
results[:updated] += 1
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
Array(creates).each do |item|
|
|
316
|
+
next if item.blank?
|
|
317
|
+
|
|
318
|
+
sanitized = sanitize_attributes(model, item)
|
|
319
|
+
record = model.new(sanitized)
|
|
320
|
+
record.save!
|
|
321
|
+
results[:created] += 1
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
results
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
def ensure_writable!
|
|
331
|
+
if RailsStudio.configuration.read_only?
|
|
332
|
+
raise RailsStudio::ReadOnlyError, "Rails Studio is currently in read-only mode."
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def find_by_id(model, id)
|
|
337
|
+
pks = Array(model.primary_key)
|
|
338
|
+
if pks.size <= 1
|
|
339
|
+
model.find_by(model.primary_key => id)
|
|
340
|
+
else
|
|
341
|
+
id_hash = parse_composite_id(pks, id)
|
|
342
|
+
model.find_by(id_hash)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def find_by_id!(model, id)
|
|
347
|
+
record = find_by_id(model, id)
|
|
348
|
+
raise ActiveRecord::RecordNotFound, "Record with id #{id.inspect} not found in #{name}" unless record
|
|
349
|
+
|
|
350
|
+
record
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def parse_composite_id(pks, id)
|
|
354
|
+
if id.is_a?(String) && id.start_with?("{") && id.end_with?("}")
|
|
355
|
+
parsed = JSON.parse(id) rescue nil
|
|
356
|
+
id = parsed if parsed.is_a?(Hash)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
id = id.to_unsafe_h if id.respond_to?(:to_unsafe_h)
|
|
360
|
+
|
|
361
|
+
if id.is_a?(Hash)
|
|
362
|
+
id.symbolize_keys.slice(*pks.map(&:to_sym))
|
|
363
|
+
elsif id.is_a?(Array)
|
|
364
|
+
pks.each_with_index.each_with_object({}) { |(pk, i), h| h[pk.to_sym] = id[i] }
|
|
365
|
+
elsif id.is_a?(String) && id.include?(",")
|
|
366
|
+
parts = id.split(",", pks.size)
|
|
367
|
+
pks.each_with_index.each_with_object({}) { |(pk, i), h| h[pk.to_sym] = parts[i] }
|
|
368
|
+
else
|
|
369
|
+
{ pks.first.to_sym => id }
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def sanitize_attributes(model, attributes)
|
|
374
|
+
attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
|
|
375
|
+
return {} unless attributes.is_a?(Hash)
|
|
376
|
+
|
|
377
|
+
valid_cols = model.column_names
|
|
378
|
+
cols_map = model.columns_hash
|
|
379
|
+
enums = (app_model&.defined_enums rescue {}) || {}
|
|
380
|
+
|
|
381
|
+
attributes.each_with_object({}) do |(key, value), hash|
|
|
382
|
+
col_name = key.to_s
|
|
383
|
+
next unless valid_cols.include?(col_name)
|
|
384
|
+
|
|
385
|
+
col = cols_map[col_name]
|
|
386
|
+
hash[col_name] = cast_value_for_column(col, value, enums[col_name] || enums[col_name.to_sym])
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def cast_value_for_column(col, value, enum_mapping = nil)
|
|
391
|
+
return nil if value.nil? || (value == "" && col.null && col.type != :string && col.type != :text)
|
|
392
|
+
|
|
393
|
+
if enum_mapping.is_a?(Hash)
|
|
394
|
+
str_val = value.to_s
|
|
395
|
+
return enum_mapping[str_val] if enum_mapping.key?(str_val)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
case col.type
|
|
399
|
+
when :json, :jsonb
|
|
400
|
+
value.is_a?(String) ? (JSON.parse(value) rescue value) : value
|
|
401
|
+
when :boolean
|
|
402
|
+
ActiveModel::Type::Boolean.new.cast(value)
|
|
403
|
+
when :integer
|
|
404
|
+
value.to_s.strip.to_i rescue value
|
|
405
|
+
when :float, :decimal
|
|
406
|
+
value.to_s.strip.to_f rescue value
|
|
407
|
+
else
|
|
408
|
+
value
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def apply_filters(scope, model, valid_cols, filters)
|
|
413
|
+
return scope if filters.blank?
|
|
414
|
+
|
|
415
|
+
arel = model.arel_table
|
|
416
|
+
is_postgres = scope.connection.adapter_name =~ /postgres/i
|
|
417
|
+
|
|
418
|
+
filters.each do |filter|
|
|
419
|
+
next unless filter.is_a?(Hash)
|
|
420
|
+
|
|
421
|
+
col = filter[:column] || filter["column"]
|
|
422
|
+
op = (filter[:op] || filter["op"]).to_s.downcase
|
|
423
|
+
val = filter[:value] || filter["value"]
|
|
424
|
+
|
|
425
|
+
next unless valid_cols.include?(col.to_s)
|
|
426
|
+
|
|
427
|
+
col_arel = arel[col.to_sym]
|
|
428
|
+
col_meta = model.columns_hash[col.to_s]
|
|
429
|
+
col_type = col_meta&.type
|
|
430
|
+
casted_val = cast_filter_value(col_type, val)
|
|
431
|
+
|
|
432
|
+
case op
|
|
433
|
+
when "eq", "="
|
|
434
|
+
scope = scope.where(col_arel.eq(casted_val))
|
|
435
|
+
when "not_eq", "!="
|
|
436
|
+
scope = scope.where(col_arel.not_eq(casted_val))
|
|
437
|
+
when "contains", "like"
|
|
438
|
+
sanitized = ActiveRecord::Base.sanitize_sql_like(val.to_s)
|
|
439
|
+
scope = if is_postgres
|
|
440
|
+
scope.where(col_arel.matches("%#{sanitized}%", nil, false))
|
|
441
|
+
else
|
|
442
|
+
scope.where(col_arel.matches("%#{sanitized}%"))
|
|
443
|
+
end
|
|
444
|
+
when "starts_with"
|
|
445
|
+
sanitized = ActiveRecord::Base.sanitize_sql_like(val.to_s)
|
|
446
|
+
scope = if is_postgres
|
|
447
|
+
scope.where(col_arel.matches("#{sanitized}%", nil, false))
|
|
448
|
+
else
|
|
449
|
+
scope.where(col_arel.matches("#{sanitized}%"))
|
|
450
|
+
end
|
|
451
|
+
when "ends_with"
|
|
452
|
+
sanitized = ActiveRecord::Base.sanitize_sql_like(val.to_s)
|
|
453
|
+
scope = if is_postgres
|
|
454
|
+
scope.where(col_arel.matches("%#{sanitized}", nil, false))
|
|
455
|
+
else
|
|
456
|
+
scope.where(col_arel.matches("%#{sanitized}"))
|
|
457
|
+
end
|
|
458
|
+
when "gt", ">"
|
|
459
|
+
scope = scope.where(col_arel.gt(casted_val))
|
|
460
|
+
when "gte", ">="
|
|
461
|
+
scope = scope.where(col_arel.gteq(casted_val))
|
|
462
|
+
when "lt", "<"
|
|
463
|
+
scope = scope.where(col_arel.lt(casted_val))
|
|
464
|
+
when "lte", "<="
|
|
465
|
+
scope = scope.where(col_arel.lteq(casted_val))
|
|
466
|
+
when "is_null"
|
|
467
|
+
scope = scope.where(col_arel.eq(nil))
|
|
468
|
+
when "is_not_null"
|
|
469
|
+
scope = scope.where(col_arel.not_eq(nil))
|
|
470
|
+
when "in"
|
|
471
|
+
vals = val.is_a?(Array) ? val : val.to_s.split(",").map(&:strip)
|
|
472
|
+
vals = vals.map { |v| cast_filter_value(col_type, v) }
|
|
473
|
+
scope = scope.where(col_arel.in(vals))
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
scope
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def cast_filter_value(col_type, val)
|
|
481
|
+
return val if val.nil?
|
|
482
|
+
|
|
483
|
+
case col_type
|
|
484
|
+
when :integer
|
|
485
|
+
val.to_s.strip.to_i rescue val
|
|
486
|
+
when :float, :decimal
|
|
487
|
+
val.to_s.strip.to_f rescue val
|
|
488
|
+
when :boolean
|
|
489
|
+
ActiveModel::Type::Boolean.new.cast(val)
|
|
490
|
+
else
|
|
491
|
+
val
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Rails studio</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= stylesheet_link_tag "rails_studio/application", media: "all" %>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
|
|
14
|
+
<%= yield %>
|
|
15
|
+
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|