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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsStudio
4
+ class ReadOnlyError < StandardError; end
5
+ 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>