pg_reports 0.9.1 → 1.0.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +126 -80
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
  8. data/app/views/pg_reports/dashboard/index.html.erb +310 -124
  9. data/bin/pg_reports +2 -2
  10. data/config/brakeman.ignore +10 -56
  11. data/config/locales/en.yml +39 -8
  12. data/config/locales/ru.yml +38 -8
  13. data/config/locales/uk.yml +38 -8
  14. data/config/routes.rb +1 -0
  15. data/lib/pg_reports/access_policy.rb +39 -0
  16. data/lib/pg_reports/compatibility.rb +10 -2
  17. data/lib/pg_reports/configuration.rb +33 -2
  18. data/lib/pg_reports/connection/error_translator.rb +21 -0
  19. data/lib/pg_reports/connection/target.rb +43 -10
  20. data/lib/pg_reports/csv_cell.rb +20 -0
  21. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  22. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  23. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  24. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  25. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  26. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  27. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  28. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  29. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  30. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  31. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  32. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  33. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  34. data/lib/pg_reports/drop_index_migration.rb +56 -0
  35. data/lib/pg_reports/engine.rb +4 -3
  36. data/lib/pg_reports/executor.rb +46 -9
  37. data/lib/pg_reports/modules/queries.rb +1 -0
  38. data/lib/pg_reports/query_monitor.rb +478 -192
  39. data/lib/pg_reports/rate_limiter.rb +63 -0
  40. data/lib/pg_reports/report.rb +1 -1
  41. data/lib/pg_reports/report_definition.rb +11 -1
  42. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  43. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  44. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  45. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  46. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  47. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  48. data/lib/pg_reports/sql_redactor.rb +32 -0
  49. data/lib/pg_reports/standalone.rb +83 -20
  50. data/lib/pg_reports/version.rb +1 -1
  51. data/lib/pg_reports.rb +7 -2
  52. metadata +17 -9
@@ -4,6 +4,38 @@ module PgReports
4
4
  class DashboardController < ActionController::Base
5
5
  layout "pg_reports/application"
6
6
 
7
+ INDEX_LOOKUP = <<~SQL
8
+ #{Executor::QUERY_TAG}
9
+ SELECT schemaname, tablename, indexname, indexdef
10
+ FROM pg_indexes
11
+ WHERE indexname = $1 AND ($2::text = '' OR schemaname = $2)
12
+ SQL
13
+
14
+ MAX_RESULT_ROWS = 100
15
+ # Pages that draw the database selector, the only thing that shows a size
16
+ DATABASE_SELECTOR_ACTIONS = %w[index show].freeze
17
+ RATE_LIMITED_ACTIONS = %i[
18
+ explain_analyze execute_query run_query migration_preview create_migration
19
+ reset_statistics enable_pg_stat_statements send_to_telegram
20
+ start_query_monitoring load_query_history
21
+ ].freeze
22
+ # PostgreSQL's own limit. A `$99999999` in captured text must not allocate.
23
+ MAX_BIND_PARAMETERS = 65_535
24
+ TYPED_PLACEHOLDER = /\b(interval|timestamptz|timestamp|date|time)\s+\$(\d+)/i
25
+ # What a read-only transaction does not stop: these work outside the database's
26
+ # own data, leave something behind on a pooled connection, or run SQL held in a
27
+ # string, which would put the name of anything else here out of reach of a check
28
+ # that only reads the text. This narrows what a mistake can do; it is not a
29
+ # sandbox.
30
+ ADMIN_FUNCTIONS = /\b(?:
31
+ pg_read_file | pg_read_binary_file | pg_ls_\w+ | pg_stat_file | pg_file_\w+ |
32
+ pg_terminate_backend | pg_cancel_backend | pg_reload_conf | pg_rotate_logfile |
33
+ pg_stat_reset\w* | pg_stat_statements_reset | pg_advisory_\w+ | pg_switch_wal |
34
+ pg_promote | pg_wal_replay_\w+ | pg_create_\w+ | pg_drop_\w+ | pg_logical_\w+ |
35
+ pg_replication_\w+ | lo_import | lo_export | dblink\w* |
36
+ query_to_xml\w* | ts_stat | ts_rewrite
37
+ )\b/x
38
+
7
39
  # CSRF protection. ActionController::Base does NOT enforce this by default;
8
40
  # we opt in explicitly because every state-changing endpoint here
9
41
  # (switch_*, execute_query, create_migration, reset_statistics, telegram
@@ -11,23 +43,23 @@ module PgReports
11
43
  # ships authenticity_token in forms and X-CSRF-Token in XHR.
12
44
  protect_from_forgery with: :exception
13
45
 
14
- before_action :authenticate_dashboard!, if: -> { PgReports.config.dashboard_auth.present? }
46
+ before_action :authenticate_dashboard!
15
47
  around_action :within_selected_locale
16
48
  before_action :set_categories
17
49
  before_action :resolve_database_selection
18
50
  around_action :within_selected_database
19
- before_action :block_query_monitor_in_standalone, only: %i[
51
+ before_action :require_query_monitor, only: %i[
20
52
  start_query_monitoring stop_query_monitoring query_monitor_status
21
53
  query_monitor_feed load_query_history download_query_monitor
22
54
  ]
23
- before_action :enforce_rate_limit!, only: %i[
24
- explain_analyze execute_query run_query create_migration
25
- ]
55
+ before_action :enforce_rate_limit!, only: RATE_LIMITED_ACTIONS
56
+ before_action :require_migration_creation, only: %i[migration_preview create_migration]
26
57
 
27
58
  helper_method :category_disabled_reason, :category_disabled?
28
59
  helper_method :dashboard_locales, :selected_locale
29
60
 
30
61
  def index
62
+ Compatibility.check_postgresql_once!
31
63
  @pg_stat_status = pg_stat_status
32
64
  @current_database = PgReports.system.current_database
33
65
  end
@@ -154,11 +186,8 @@ module PgReports
154
186
 
155
187
  # Load filter parameters from YAML
156
188
  @report_filters = load_report_filters(@category, @report_key)
157
-
158
- @report = execute_report(@category, @report_key)
159
189
  rescue => e
160
190
  @error = report_error_message(e)
161
- @report = nil
162
191
  end
163
192
 
164
193
  def run
@@ -287,11 +316,9 @@ module PgReports
287
316
  return
288
317
  end
289
318
 
290
- # Substitute parameters if provided
291
- final_query = substitute_params(query, query_params)
319
+ binds = query_binds(query, query_params)
292
320
 
293
- # Check for remaining unsubstituted parameters
294
- if final_query.match?(/\$\d+/)
321
+ if binds.nil?
295
322
  render json: {
296
323
  success: false,
297
324
  error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
@@ -300,9 +327,9 @@ module PgReports
300
327
  end
301
328
 
302
329
  explain_output = nil
303
- with_statement_timeout do
304
- result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{final_query}")
305
- explain_output = result.map { |r| r["QUERY PLAN"] }.join("\n")
330
+ in_read_only_transaction do
331
+ rows = select_rows("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{cast_typed_placeholders(query)}", binds)
332
+ explain_output = rows.map { |r| r["QUERY PLAN"] }.join("\n")
306
333
  end
307
334
 
308
335
  # Analyze the EXPLAIN output
@@ -317,7 +344,7 @@ module PgReports
317
344
  problems: analysis[:problems],
318
345
  summary: analysis[:summary]
319
346
  }
320
- rescue ActiveRecord::QueryCanceled
347
+ rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
321
348
  render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
322
349
  rescue => e
323
350
  render json: {success: false, error: e.message}, status: :unprocessable_entity
@@ -354,11 +381,9 @@ module PgReports
354
381
  return
355
382
  end
356
383
 
357
- # Substitute parameters if provided
358
- final_query = substitute_params(query, query_params)
384
+ binds = query_binds(query, query_params)
359
385
 
360
- # Check for remaining unsubstituted parameters
361
- if final_query.match?(/\$\d+/)
386
+ if binds.nil?
362
387
  render json: {
363
388
  success: false,
364
389
  error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
@@ -366,42 +391,8 @@ module PgReports
366
391
  return
367
392
  end
368
393
 
369
- # Execute with LIMIT to prevent huge result sets
370
- limited_query = add_limit_if_missing(final_query, 100)
371
-
372
- rows = columns = nil
373
- total_count = 0
374
- truncated = false
375
- execution_time = nil
376
-
377
- with_statement_timeout do
378
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
379
- result = ActiveRecord::Base.connection.execute(limited_query)
380
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
381
- execution_time = ((end_time - start_time) * 1000).round(2)
382
-
383
- rows = result.to_a
384
- columns = rows.first&.keys || []
385
- total_count = rows.size
386
-
387
- # Check if we need to get total count
388
- if rows.size >= 100
389
- count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{final_query}) AS count_query")
390
- total_count = count_result.first["count"].to_i
391
- truncated = total_count > 100
392
- end
393
- end
394
-
395
- render json: {
396
- success: true,
397
- columns: columns,
398
- rows: rows,
399
- count: rows.size,
400
- total_count: total_count,
401
- truncated: truncated,
402
- execution_time: execution_time
403
- }
404
- rescue ActiveRecord::QueryCanceled
394
+ render json: run_limited_select(cast_typed_placeholders(query), binds)
395
+ rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
405
396
  render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
406
397
  rescue => e
407
398
  render json: {success: false, error: e.message}, status: :unprocessable_entity
@@ -436,78 +427,41 @@ module PgReports
436
427
  return
437
428
  end
438
429
 
439
- limited_query = add_limit_if_missing(raw_query, 100)
440
-
441
- rows = columns = nil
442
- total_count = 0
443
- truncated = false
444
- execution_time = nil
445
-
446
- with_statement_timeout do
447
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
448
- result = ActiveRecord::Base.connection.execute(limited_query)
449
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
450
- execution_time = ((end_time - start_time) * 1000).round(2)
451
-
452
- rows = result.to_a
453
- columns = rows.first&.keys || []
454
- total_count = rows.size
455
-
456
- if rows.size >= 100
457
- count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{raw_query}) AS count_query")
458
- total_count = count_result.first["count"].to_i
459
- truncated = total_count > 100
460
- end
461
- end
462
-
463
- render json: {
464
- success: true,
465
- columns: columns,
466
- rows: rows,
467
- count: rows.size,
468
- total_count: total_count,
469
- truncated: truncated,
470
- execution_time: execution_time
471
- }
472
- rescue ActiveRecord::QueryCanceled
430
+ render json: run_limited_select(raw_query)
431
+ rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
473
432
  render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
474
433
  rescue => e
475
434
  render json: {success: false, error: e.message}, status: :unprocessable_entity
476
435
  end
477
436
 
478
- def create_migration
479
- unless PgReports.config.allow_migration_creation
480
- render json: {
481
- success: false,
482
- error: I18n.t("pg_reports.ui.errors.migration_dev_only")
483
- }, status: :forbidden
484
- return
485
- end
486
-
487
- file_name = params[:file_name]
488
- code = params[:code]
437
+ # POST /migration_preview
438
+ def migration_preview
439
+ migration = drop_index_migration or return
489
440
 
490
- if file_name.blank? || code.blank?
491
- render json: {success: false, error: I18n.t("pg_reports.ui.errors.filename_code_required")}, status: :unprocessable_entity
492
- return
493
- end
441
+ render json: {success: true, code: migration.code, file_name: migration.file_name(migration_timestamp)}
442
+ rescue => e
443
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
444
+ end
494
445
 
495
- # Sanitize file name
496
- safe_file_name = file_name.gsub(/[^a-z0-9_.]/, "")
497
- unless safe_file_name.match?(/\A\d{14}_\w+\.rb\z/)
498
- render json: {success: false, error: I18n.t("pg_reports.ui.errors.invalid_filename_format")}, status: :unprocessable_entity
499
- return
500
- end
446
+ # POST /create_migration
447
+ # Takes the index to drop, never code: the file is built here from what the
448
+ # database reports, because `rails db:migrate` will execute it.
449
+ def create_migration
450
+ migration = drop_index_migration or return
501
451
 
502
- # Find migrations directory
503
452
  migrations_path = Rails.root.join("db", "migrate")
504
453
  unless migrations_path.exist?
505
454
  render json: {success: false, error: I18n.t("pg_reports.ui.errors.migrations_dir_not_found")}, status: :unprocessable_entity
506
455
  return
507
456
  end
508
457
 
509
- file_path = migrations_path.join(safe_file_name)
510
- File.write(file_path, code)
458
+ if migrations_path.glob("*_#{migration.name}.rb").any?
459
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_exists")}, status: :unprocessable_entity
460
+ return
461
+ end
462
+
463
+ file_path = migrations_path.join(migration.file_name(migration_timestamp))
464
+ File.write(file_path, migration.code, mode: "wx")
511
465
 
512
466
  render json: {success: true, file_path: file_path.to_s, message: I18n.t("pg_reports.ui.success.migration_created")}
513
467
  rescue => e
@@ -515,11 +469,7 @@ module PgReports
515
469
  end
516
470
 
517
471
  def start_query_monitoring
518
- monitor = PgReports::QueryMonitor.instance
519
- Rails.logger.info("PgReports: start_query_monitoring called. Instance: #{monitor.object_id}")
520
-
521
- result = monitor.start
522
- Rails.logger.info("PgReports: start result: #{result.inspect}")
472
+ result = PgReports::QueryMonitor.instance.start(source: params[:source])
523
473
 
524
474
  if result[:success]
525
475
  render json: result
@@ -527,7 +477,6 @@ module PgReports
527
477
  render json: result, status: :unprocessable_entity
528
478
  end
529
479
  rescue => e
530
- Rails.logger.error("PgReports: start_query_monitoring error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
531
480
  render json: {success: false, error: e.message}, status: :unprocessable_entity
532
481
  end
533
482
 
@@ -546,39 +495,29 @@ module PgReports
546
495
  end
547
496
 
548
497
  def query_monitor_status
549
- monitor = PgReports::QueryMonitor.instance
550
- status = monitor.status
551
-
552
- render json: {
553
- success: true,
554
- enabled: status[:enabled],
555
- session_id: status[:session_id],
556
- query_count: status[:query_count],
557
- history_available: status[:history_available]
558
- }
498
+ render json: PgReports::QueryMonitor.instance.status.merge(success: true)
559
499
  rescue => e
560
500
  render json: {success: false, error: e.message}, status: :unprocessable_entity
561
501
  end
562
502
 
503
+ # A session that has ended is an ordinary state here, not an error: it can be
504
+ # stopped from another tab or run out its time, and the viewer keeps the rows.
563
505
  def query_monitor_feed
564
506
  monitor = PgReports::QueryMonitor.instance
565
507
 
566
- unless monitor.enabled
567
- Rails.logger.warn("PgReports: query_monitor_feed called but monitoring not active. Instance: #{monitor.object_id}, enabled: #{monitor.enabled}, session_id: #{monitor.session_id}")
568
- render json: {success: false, message: "Monitoring not active"}
569
- return
570
- end
571
-
572
- limit = params[:limit]&.to_i || 50
573
- session_id = params[:session_id]
574
-
575
- queries = monitor.queries(limit: limit, session_id: session_id)
508
+ since = params[:since].to_i
509
+ queries = monitor.queries(
510
+ limit: params[:limit]&.to_i || 50,
511
+ session_id: params[:session_id],
512
+ since: since
513
+ )
576
514
 
577
- render json: {
515
+ render json: monitor.status.merge(
578
516
  success: true,
579
517
  queries: queries,
518
+ last_seq: queries.last ? queries.last[:seq] : since,
580
519
  timestamp: Time.current.to_i
581
- }
520
+ )
582
521
  rescue => e
583
522
  render json: {success: false, error: e.message}, status: :unprocessable_entity
584
523
  end
@@ -621,7 +560,7 @@ module PgReports
621
560
  type: "text/csv; charset=utf-8",
622
561
  disposition: "attachment"
623
562
  when "json"
624
- send_data queries.to_json,
563
+ send_data JSON.generate(queries),
625
564
  filename: "#{filename}.json",
626
565
  type: "application/json; charset=utf-8",
627
566
  disposition: "attachment"
@@ -639,7 +578,12 @@ module PgReports
639
578
  private
640
579
 
641
580
  def authenticate_dashboard!
642
- instance_exec(&PgReports.config.dashboard_auth)
581
+ auth = PgReports.config.dashboard_auth
582
+ return if auth == AccessPolicy::HOST_MANAGED
583
+ return instance_exec(&auth) if auth.present?
584
+ return if AccessPolicy.open_access_allowed?(request)
585
+
586
+ render plain: AccessPolicy::DASHBOARD_DENIED, status: :forbidden
643
587
  end
644
588
 
645
589
  def set_categories
@@ -706,7 +650,10 @@ module PgReports
706
650
 
707
651
  begin
708
652
  registry.with_context(target: @selected_target) do
709
- @available_databases = registry.fetch(@selected_target).list_databases(current: @target_default_database)
653
+ @available_databases = registry.fetch(@selected_target).list_databases(
654
+ current: @target_default_database,
655
+ sizes: DATABASE_SELECTOR_ACTIONS.include?(action_name)
656
+ )
710
657
  end
711
658
  rescue => e
712
659
  @database_error = PgReports::Connection::ErrorTranslator.translate(e)
@@ -789,7 +736,11 @@ module PgReports
789
736
  else raise ArgumentError, "Unknown category: #{category}"
790
737
  end
791
738
 
792
- unless mod.respond_to?(report_key)
739
+ # The report name comes from the URL and the dispatch below is public_send,
740
+ # so the registry decides what can run. respond_to? would also admit
741
+ # reset_statistics!, enable_pg_stat_statements! and Module#freeze, reachable
742
+ # by a plain GET that CSRF protection does not cover.
743
+ unless Dashboard::ReportsRegistry.find(category, report_key)
793
744
  raise ArgumentError, "Unknown report: #{report_key}"
794
745
  end
795
746
 
@@ -875,100 +826,140 @@ module PgReports
875
826
  end
876
827
  end
877
828
 
878
- # SQL Query Monitor taps ActiveSupport::Notifications in the host
879
- # application's process. In standalone mode there is no host app — pg_reports
880
- # is the only process running, so there's nothing meaningful to observe.
881
829
  # Blocks the API even if a client calls it directly (the UI panel is also
882
- # hidden in standalone, see dashboard/index view).
883
- def block_query_monitor_in_standalone
884
- return unless PgReports.config.standalone
830
+ # hidden when the monitor is unavailable, see dashboard/index view).
831
+ def require_query_monitor
832
+ return if PgReports.config.query_monitor_available?
885
833
 
886
- render json: {
887
- success: false,
888
- error: I18n.t("pg_reports.ui.errors.query_monitor_unavailable_standalone")
889
- }, status: :forbidden
834
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_monitor_disabled")}, status: :forbidden
890
835
  end
891
836
 
892
- # Soft per-IP throttle for the dashboard's privileged raw-query and
893
- # migration endpoints. This is not a hardened distributed rate limiter —
894
- # just a best-effort guard against a single client hammering these
895
- # expensive/privileged actions, backed by Rails.cache (works with or
896
- # without a shared cache backend across processes). Fails open if the
897
- # cache is unavailable, consistent with the rest of the dashboard (see
898
- # #resolve_database_selection, #retrieve_query_by_hash).
837
+ # Per-IP throttle for what is expensive, privileged or leaves the building. See
838
+ # RateLimiter for what it can and cannot promise.
899
839
  def enforce_rate_limit!
900
840
  limit = PgReports.config.raw_query_rate_limit
901
841
  return if limit.nil?
902
842
 
903
- window = PgReports.config.raw_query_rate_limit_window_seconds
904
- key = "pg_reports:rate_limit:#{request.remote_ip}:#{params[:action]}"
905
-
906
- count = begin
907
- current = (Rails.cache.read(key) || 0) + 1
908
- Rails.cache.write(key, current, expires_in: window)
909
- current
910
- rescue => e
911
- Rails.logger.warn("PgReports: Rate limit cache unavailable: #{e.message}") if defined?(Rails.logger)
912
- return
913
- end
914
-
915
- return if count <= limit
843
+ retry_after = RateLimiter.instance.retry_after(
844
+ "#{request.remote_ip}:#{params[:action]}",
845
+ limit: limit,
846
+ window: PgReports.config.raw_query_rate_limit_window_seconds
847
+ )
848
+ return unless retry_after
916
849
 
850
+ response.set_header("Retry-After", retry_after.to_s)
917
851
  render json: {
918
852
  success: false,
919
853
  error: I18n.t("pg_reports.ui.errors.rate_limit_exceeded")
920
854
  }, status: :too_many_requests
921
855
  end
922
856
 
923
- def on_primary_default_database?
924
- return true if @target_default_database.nil?
857
+ def require_migration_creation
858
+ return if PgReports.config.allow_migration_creation
925
859
 
926
- @selected_target == PgReports.connection_registry.default_name &&
927
- @selected_database == @target_default_database
860
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_dev_only")}, status: :forbidden
928
861
  end
929
862
 
930
- def substitute_params(query, params_hash)
931
- result = query.dup
863
+ # The migration for the index named in the request, or nil once a response has
864
+ # been rendered. A migration runs on the application's own database, so one
865
+ # built from an index elsewhere would drop nothing, or the wrong index.
866
+ def drop_index_migration
867
+ unless on_primary_default_database?
868
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_primary_only")}, status: :unprocessable_entity
869
+ return
870
+ end
932
871
 
933
- # Sort by param number descending to replace $10 before $1
934
- params_hash.keys.map(&:to_i).sort.reverse_each do |num|
935
- value = params_hash[num.to_s] || params_hash[num]
936
- next if value.nil? || value.to_s.empty?
872
+ rows = select_rows(INDEX_LOOKUP, [params[:index_name].to_s, params[:schema].to_s])
937
873
 
938
- # Quote the value appropriately
939
- quoted_value = quote_param_value(value)
940
- result = result.gsub("$#{num}", quoted_value)
874
+ # More than one means the same name in two schemas, and no way to tell which
875
+ unless rows.one?
876
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.index_not_found")}, status: :unprocessable_entity
877
+ return
941
878
  end
942
879
 
943
- result
880
+ DropIndexMigration.new(rows.first, version: ActiveRecord::Migration.current_version)
944
881
  end
945
882
 
946
- def quote_param_value(value)
947
- str = value.to_s
883
+ def migration_timestamp
884
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
885
+ end
948
886
 
949
- # Check if it looks like NULL
950
- if str.downcase == "null"
951
- "NULL"
952
- # Check if it looks like a boolean
953
- elsif str.downcase.in?(["true", "false"])
954
- str.downcase
955
- else
956
- # Quote as string by default - PostgreSQL will handle type casting
957
- # This ensures compatibility with both text and numeric columns
958
- "'#{str.gsub("'", "''")}'"
887
+ def on_primary_default_database?
888
+ return true if @target_default_database.nil?
889
+
890
+ @selected_target == PgReports.connection_registry.default_name &&
891
+ @selected_database == @target_default_database
892
+ end
893
+
894
+ # Values for the query's $n placeholders, in order, or nil if one is missing.
895
+ # They travel as bind parameters and are never written into the SQL: the text
896
+ # of a captured query can hold a `$1` anywhere, including inside a string
897
+ # literal, where a substituted and quoted value would end that literal.
898
+ def query_binds(query, params_hash)
899
+ numbers = query.scan(/\$(\d+)/).flatten.map(&:to_i).uniq
900
+ return [] if numbers.empty?
901
+ return nil if numbers.max > MAX_BIND_PARAMETERS
902
+
903
+ binds = Array.new(numbers.max)
904
+ numbers.each do |number|
905
+ value = params_hash[number.to_s].to_s
906
+ return nil if value.empty?
907
+
908
+ binds[number - 1] = (value.downcase == "null") ? nil : value
959
909
  end
910
+ binds
960
911
  end
961
912
 
962
- def add_limit_if_missing(query, limit)
963
- # Simple check - if query doesn't end with LIMIT clause, add one
964
- normalized = query.strip.gsub(/\s+/, " ").downcase
913
+ # pg_stat_statements reduces `interval '1 day'` to `interval $1`, which is only
914
+ # valid for a literal. The cast form means the same and accepts a parameter.
915
+ def cast_typed_placeholders(query)
916
+ query.gsub(TYPED_PLACEHOLDER) { "$#{$2}::#{$1}" }
917
+ end
965
918
 
966
- if normalized.match?(/\blimit\s+\d+\s*(?:offset\s+\d+\s*)?\z/i)
967
- # Already has LIMIT
968
- query
969
- else
970
- "#{query} LIMIT #{limit}"
919
+ # The extended protocol refuses more than one statement, which is what keeps a
920
+ # query to the single statement that was validated. connection.execute uses
921
+ # the simple protocol, where text after a `;` would simply run.
922
+ def select_rows(sql, binds = [])
923
+ PgReports.config.connection.raw_connection.exec_params(sql, binds).to_a
924
+ end
925
+
926
+ def run_limited_select(query, binds = [])
927
+ rows = nil
928
+ total_count = 0
929
+ truncated = false
930
+ execution_time = nil
931
+
932
+ in_read_only_transaction do
933
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
934
+ rows = select_rows("SELECT * FROM (#{wrapped(query)}) AS pg_reports_limited LIMIT #{MAX_RESULT_ROWS}", binds)
935
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
936
+ execution_time = ((end_time - start_time) * 1000).round(2)
937
+
938
+ total_count = rows.size
939
+
940
+ if rows.size >= MAX_RESULT_ROWS
941
+ count = select_rows("SELECT COUNT(*) FROM (#{wrapped(query)}) AS count_query", binds)
942
+ total_count = count.first["count"].to_i
943
+ truncated = total_count > MAX_RESULT_ROWS
944
+ end
971
945
  end
946
+
947
+ {
948
+ success: true,
949
+ columns: rows.first&.keys || [],
950
+ rows: rows,
951
+ count: rows.size,
952
+ total_count: total_count,
953
+ truncated: truncated,
954
+ execution_time: execution_time
955
+ }
956
+ end
957
+
958
+ # On lines of its own, so that a query ending in a -- comment cannot comment out
959
+ # the closing parenthesis. As a subquery, the row limit holds whatever LIMIT the
960
+ # query itself asks for, and nothing has to be read out of its text.
961
+ def wrapped(query)
962
+ "\n#{query}\n"
972
963
  end
973
964
 
974
965
  # Generate a secure hash for a query and store it in cache
@@ -1036,18 +1027,36 @@ module PgReports
1036
1027
  raise SecurityError, "Dangerous keyword detected: #{keyword.upcase}"
1037
1028
  end
1038
1029
  end
1030
+
1031
+ if (function = normalized[ADMIN_FUNCTIONS])
1032
+ raise SecurityError, "Administrative function not allowed: #{function}"
1033
+ end
1034
+
1035
+ # U&"pg\005fls_dir" names a function without ever spelling it
1036
+ if normalized.include?('u&"')
1037
+ raise SecurityError, "Unicode-escaped identifiers are not allowed"
1038
+ end
1039
1039
  end
1040
1040
 
1041
- # Bounds how long a single raw-query execution (Execute Query / EXPLAIN
1042
- # ANALYZE / SQL Console) can run, so a runaway query can't hang the
1043
- # connection indefinitely. SET LOCAL only takes effect inside a
1044
- # transaction and reverts automatically at its end (commit or rollback) —
1045
- # safe here since every caller only ever runs SELECTs.
1046
- def with_statement_timeout
1047
- ActiveRecord::Base.transaction do
1041
+ # The envelope of every raw query (Execute Query / EXPLAIN ANALYZE / SQL
1042
+ # Console), on the connection for the selected target and database, which is
1043
+ # the one #select_rows uses: settings only bind the query on that connection.
1044
+ #
1045
+ # READ ONLY makes PostgreSQL refuse writes that text checks cannot see, such as
1046
+ # SELECT INTO or a volatile function. SET LOCAL bounds the run time.
1047
+ #
1048
+ # It always ends in ROLLBACK. Nothing here has anything to commit, and
1049
+ # set_config(..., false) from a SELECT survives a COMMIT: the connection goes
1050
+ # back to the pool, possibly the host application's, with that setting on it.
1051
+ def in_read_only_transaction
1052
+ connection = PgReports.config.connection
1053
+
1054
+ connection.transaction do
1055
+ connection.execute("SET TRANSACTION READ ONLY")
1048
1056
  timeout_ms = PgReports.config.raw_query_statement_timeout_ms.to_i
1049
- ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = #{timeout_ms}") if timeout_ms.positive?
1057
+ connection.execute("SET LOCAL statement_timeout = #{timeout_ms}") if timeout_ms.positive?
1050
1058
  yield
1059
+ raise ActiveRecord::Rollback
1051
1060
  end
1052
1061
  end
1053
1062
 
@@ -1068,9 +1077,9 @@ module PgReports
1068
1077
  csv << [
1069
1078
  query[:timestamp],
1070
1079
  query[:duration_ms],
1071
- query[:name],
1072
- query[:sql],
1073
- query.dig(:source_location, :file),
1080
+ CsvCell.safe(query[:name]),
1081
+ CsvCell.safe(query[:sql]),
1082
+ CsvCell.safe(query.dig(:source_location, :file)),
1074
1083
  query.dig(:source_location, :line)
1075
1084
  ]
1076
1085
  end