pg_reports 0.7.0 → 0.8.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.
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ module Connection
5
+ # Represents a single named PostgreSQL target (host+credentials).
6
+ # Holds a memoized AR-subclass-backed connection per database it has been
7
+ # asked for, so switching between databases on the same target reuses pools.
8
+ #
9
+ # The :primary target wraps ActiveRecord::Base directly when accessed at its
10
+ # default database — we don't open a parallel pool to the host app's DB.
11
+ # For non-default databases we create an isolated AR subclass that has its
12
+ # own pool, so the host application's pool is never affected.
13
+ class Target
14
+ class ConnectionFailed < PgReports::Error; end
15
+
16
+ attr_reader :name
17
+
18
+ def initialize(name, spec)
19
+ @name = name.to_sym
20
+ @spec = normalize_spec(spec)
21
+ @pools = {} # database (string) => AR class
22
+ end
23
+
24
+ # Configuration hash used as a template for derived databases.
25
+ def spec
26
+ @spec.dup
27
+ end
28
+
29
+ def default_database
30
+ @spec[:database]&.to_s
31
+ end
32
+
33
+ # Resolve the AR class backing the connection for `database` (nil = default).
34
+ # Returns a class responding to `.connection` (ActiveRecord::Base or subclass).
35
+ def ar_class_for(database = nil)
36
+ db = (database || default_database).to_s
37
+ raise ArgumentError, "Cannot resolve connection: target #{name.inspect} has no default database and none was given" if db.empty?
38
+
39
+ @pools[db] ||= build_pool_class(db)
40
+ end
41
+
42
+ # Returns the active PG connection for `database`, opening it if needed.
43
+ def connection_for(database = nil)
44
+ ar_class_for(database).connection
45
+ rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad => e
46
+ raise ConnectionFailed, "Cannot connect to #{name}/#{database || default_database}: #{e.message}"
47
+ end
48
+
49
+ # List all databases visible on this target's cluster (using pg_database).
50
+ # Result rows: { "name" => String, "size" => String, "current" => Boolean }
51
+ def list_databases(current: nil)
52
+ rows = connection_for.exec_query(<<~SQL).to_a
53
+ SELECT datname AS name,
54
+ pg_size_pretty(pg_database_size(datname)) AS size
55
+ FROM pg_database
56
+ WHERE datistemplate = false AND datallowconn = true
57
+ ORDER BY datname
58
+ SQL
59
+ current_db = (current || default_database).to_s
60
+ rows.each { |r| r["current"] = (r["name"].to_s == current_db) }
61
+ rows
62
+ end
63
+
64
+ # Close all derived pools we own. The :primary AR::Base pool is never touched.
65
+ def disconnect!
66
+ @pools.each_value do |klass|
67
+ next if klass.equal?(ActiveRecord::Base)
68
+ klass.connection_pool.disconnect! if klass.connection_pool.connected?
69
+ rescue
70
+ # Best-effort cleanup
71
+ end
72
+ @pools.clear
73
+ end
74
+
75
+ private
76
+
77
+ def normalize_spec(spec)
78
+ hash = case spec
79
+ when Hash
80
+ spec.transform_keys(&:to_sym)
81
+ when ActiveRecord::DatabaseConfigurations::HashConfig
82
+ spec.configuration_hash.transform_keys(&:to_sym)
83
+ else
84
+ raise ArgumentError, "Unsupported spec type: #{spec.class}"
85
+ end
86
+
87
+ # Ensure adapter defaults to postgresql; we only support PG.
88
+ hash[:adapter] ||= "postgresql"
89
+ hash
90
+ end
91
+
92
+ def build_pool_class(database)
93
+ # When asked for the primary target's default database, reuse AR::Base
94
+ # so we don't open a parallel pool to the same DB the host app uses.
95
+ if name == :primary && database == default_database
96
+ return ActiveRecord::Base
97
+ end
98
+
99
+ klass = Class.new(ActiveRecord::Base) { self.abstract_class = true }
100
+ const_name = "Pool_#{name}_#{database}".gsub(/\W/, "_")
101
+ if PgReports::Connection.const_defined?(const_name, false)
102
+ PgReports::Connection.send(:remove_const, const_name)
103
+ end
104
+ PgReports::Connection.const_set(const_name, klass)
105
+
106
+ klass.establish_connection(@spec.merge(database: database))
107
+ klass
108
+ end
109
+ end
110
+ end
111
+ end
@@ -277,8 +277,8 @@ module PgReports
277
277
  seq_scans: {name: "Sequential Scans", description: "Tables with high sequential scans"},
278
278
  tables_without_pk: {name: "No Primary Key", description: "Tables missing primary keys"},
279
279
  recently_modified: {name: "Recently Modified", description: "Tables with recent activity"},
280
- update_hotspots: {name: "Update Hotspots", description: "Same rows or indexed columns updated repeatedly", new: true},
281
- unused_tables: {name: "Unused Tables", description: "Tables never queried since the last stats reset", new: true}
280
+ update_hotspots: {name: "Update Hotspots", description: "Same rows or indexed columns updated repeatedly"},
281
+ unused_tables: {name: "Unused Tables", description: "Tables never queried since the last stats reset"}
282
282
  }
283
283
  },
284
284
  connections: {
@@ -316,14 +316,19 @@ module PgReports
316
316
  name: "Schema Analysis",
317
317
  icon: "🔍",
318
318
  color: "#06b6d4",
319
+ # These reports introspect the host application's ActiveRecord models,
320
+ # which are bound to the default database. Running them against a
321
+ # different database in the cluster returns rows that may not map to
322
+ # any model. The dashboard greys the category out in that case.
323
+ target_constraint: :primary_default_database_only,
319
324
  reports: {
320
325
  missing_validations: {name: "Missing Validations", description: "Unique indexes without model validations"},
321
- unused_columns: {name: "Unused Columns", description: "Columns that have only ever held a single value", new: true},
322
- always_null_columns: {name: "Always-NULL Columns", description: "Nullable columns that contain only NULL", new: true},
323
- polymorphic_without_index: {name: "Polymorphic Without Index", description: "Polymorphic associations missing composite index", new: true},
324
- counter_cache_issues: {name: "Counter Cache Issues", description: "counter_cache declarations whose target column is missing", new: true},
325
- soft_delete_without_scope: {name: "Soft Delete Without Scope", description: "Soft-delete columns with no model scope filtering them", new: true},
326
- orphan_tables: {name: "Orphan Tables", description: "DB tables without a corresponding Rails model", new: true}
326
+ unused_columns: {name: "Unused Columns", description: "Columns that have only ever held a single value"},
327
+ always_null_columns: {name: "Always-NULL Columns", description: "Nullable columns that contain only NULL"},
328
+ polymorphic_without_index: {name: "Polymorphic Without Index", description: "Polymorphic associations missing composite index"},
329
+ counter_cache_issues: {name: "Counter Cache Issues", description: "counter_cache declarations whose target column is missing"},
330
+ soft_delete_without_scope: {name: "Soft Delete Without Scope", description: "Soft-delete columns with no model scope filtering them"},
331
+ orphan_tables: {name: "Orphan Tables", description: "DB tables without a corresponding Rails model"}
327
332
  }
328
333
  }
329
334
  }.freeze
@@ -398,6 +403,15 @@ module PgReports
398
403
  def self.problem_fields(report)
399
404
  REPORT_CONFIG.dig(report.to_sym, :problem_fields) || []
400
405
  end
406
+
407
+ # Returns the target constraint declared on a category, or nil.
408
+ # Currently the only constraint is :primary_default_database_only, which
409
+ # means "only meaningful when the dashboard is pointing at the host app's
410
+ # primary target on its default database" — used by Schema Analysis,
411
+ # which depends on ActiveRecord::Base.descendants of the host app.
412
+ def self.target_constraint(category)
413
+ REPORTS.dig(category.to_sym, :target_constraint)
414
+ end
401
415
  end
402
416
  end
403
417
  end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgReports
4
- # Executes SQL queries and returns results
4
+ # Executes SQL queries and returns results.
5
+ #
6
+ # The connection is resolved lazily on every #execute call so that thread-local
7
+ # context set by PgReports.with_target / with_database is honored even when an
8
+ # Executor instance has been memoized at the module level.
5
9
  class Executor
6
10
  def initialize(connection: nil)
7
- @connection = connection || PgReports.config.connection
11
+ @connection_override = connection
8
12
  end
9
13
 
10
14
  # Execute SQL from a file and return results as array of hashes
@@ -16,10 +20,15 @@ module PgReports
16
20
  # Execute raw SQL and return results as array of hashes
17
21
  def execute(sql, **params)
18
22
  processed_sql = interpolate_params(sql, params)
19
- result = @connection.exec_query(processed_sql)
23
+ result = connection.exec_query(processed_sql)
20
24
  result.to_a
21
25
  end
22
26
 
27
+ # Resolved on every call: explicit override > thread-local > registry default.
28
+ def connection
29
+ @connection_override || PgReports.config.connection
30
+ end
31
+
23
32
  private
24
33
 
25
34
  # Simple parameter interpolation for SQL
@@ -40,11 +49,11 @@ module PgReports
40
49
  when Integer, Float
41
50
  value.to_s
42
51
  when String
43
- @connection.quote(value)
52
+ connection.quote(value)
44
53
  when Array
45
54
  "(#{value.map { |v| quote_value(v) }.join(", ")})"
46
55
  else
47
- @connection.quote(value.to_s)
56
+ connection.quote(value.to_s)
48
57
  end
49
58
  end
50
59
  end
@@ -173,7 +173,10 @@ module PgReports
173
173
  private
174
174
 
175
175
  def pg_version
176
- @pg_version ||= begin
176
+ # Cache per-connection so switching targets/databases re-resolves the version.
177
+ cache = (Thread.current[:pg_reports_pg_version_cache] ||= {})
178
+ key = executor.connection.object_id
179
+ cache[key] ||= begin
177
180
  result = executor.execute("SELECT current_setting('server_version_num')::int AS v")
178
181
  result.first&.fetch("v", 0).to_i
179
182
  end
@@ -290,13 +290,16 @@ module PgReports
290
290
  # when gem is installed from RubyGems
291
291
  next if path.include?("/query_monitor.rb")
292
292
 
293
- # Filter queries from pg_reports internal modules only:
294
- # - Installed gem: /gems/pg_reports-X.Y.Z/lib/
295
- # - Local gem: /pg_reports/lib/pg_reports/modules/
296
- # Note: We intentionally DO NOT filter dashboard_controller.rb
297
- # to allow monitoring of user application queries made during dashboard page loads
298
- path.match?(%r{/gems/pg_reports[-\d.]+/lib/}) ||
299
- path.match?(%r{/pg_reports/lib/pg_reports/modules/})
293
+ # Filter queries from pg_reports internal code:
294
+ # - Installed gem: /gems/pg_reports-X.Y.Z/lib/ or /gems/pg_reports-X.Y.Z/app/
295
+ # - Local gem: /pg_reports/lib/pg_reports/ or /pg_reports/app/(controllers|views)/pg_reports/
296
+ # This includes the dashboard controller's own SQL execution endpoints
297
+ # (execute_query, explain_analyze) which call AR directly without going
298
+ # through Executor — without this match nothing in the caller stack
299
+ # would identify the query as ours.
300
+ path.match?(%r{/gems/pg_reports[-\d.]+/(lib|app)/}) ||
301
+ path.match?(%r{/pg_reports/lib/pg_reports/}) ||
302
+ path.match?(%r{/pg_reports/app/(controllers|views)/pg_reports/})
300
303
  end
301
304
  end
302
305
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgReports
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/pg_reports.rb CHANGED
@@ -6,6 +6,9 @@ require "active_record"
6
6
 
7
7
  require_relative "pg_reports/version"
8
8
  require_relative "pg_reports/error"
9
+ require_relative "pg_reports/connection/target"
10
+ require_relative "pg_reports/connection/registry"
11
+ require_relative "pg_reports/connection/error_translator"
9
12
  require_relative "pg_reports/compatibility"
10
13
  require_relative "pg_reports/configuration"
11
14
  require_relative "pg_reports/sql_loader"
@@ -138,6 +141,56 @@ module PgReports
138
141
  ReportLoader.reload!
139
142
  ModuleGenerator.generate!
140
143
  end
144
+
145
+ # Connection registry — multi-target / multi-database support.
146
+ # The :primary target is auto-discovered from ActiveRecord on first access.
147
+ def connection_registry
148
+ @connection_registry ||= Connection::Registry.new
149
+ end
150
+
151
+ # Run a block against a specific target (and optionally a specific database
152
+ # on that target). Honored by Executor and any code routing through
153
+ # PgReports.config.connection.
154
+ #
155
+ # PgReports.with_target(:analytics) { PgReports.slow_queries }
156
+ # PgReports.with_target(:primary, database: "logs") { PgReports.table_sizes }
157
+ def with_target(name, database: nil, &block)
158
+ connection_registry.with_context(target: name, database: database, &block)
159
+ end
160
+
161
+ # Switch only the database on whatever target is currently active
162
+ # (defaults to the registry's default target).
163
+ #
164
+ # PgReports.with_database("reporting") { PgReports.database_sizes }
165
+ def with_database(database, &block)
166
+ target = connection_registry.current_name || connection_registry.default_name
167
+ connection_registry.with_context(target: target, database: database, &block)
168
+ end
169
+
170
+ # Name of the currently effective target (taking with_target into account).
171
+ def current_target_name
172
+ connection_registry.current_name || connection_registry.default_name
173
+ end
174
+
175
+ # Name of the currently effective database (taking with_database into account).
176
+ def current_database_name
177
+ connection_registry.current_database_name
178
+ end
179
+
180
+ # List databases on the currently active target's cluster.
181
+ # Each row: { "name" => String, "size" => String, "current" => Boolean }
182
+ def list_databases
183
+ target = connection_registry.fetch
184
+ target.list_databases(current: current_database_name)
185
+ end
186
+
187
+ # List of registered targets, each as { name:, default_database:, current: }.
188
+ def list_targets
189
+ current = current_target_name
190
+ connection_registry.targets.map do |t|
191
+ {name: t.name, default_database: t.default_database, current: t.name == current}
192
+ end
193
+ end
141
194
  end
142
195
  end
143
196
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_reports
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eldar Avatov
@@ -150,10 +150,12 @@ files:
150
150
  - app/controllers/pg_reports/dashboard_controller.rb
151
151
  - app/controllers/pg_reports/metrics_controller.rb
152
152
  - app/views/layouts/pg_reports/application.html.erb
153
+ - app/views/pg_reports/dashboard/_database_selector.html.erb
153
154
  - app/views/pg_reports/dashboard/_fake_source_data.html.erb
154
155
  - app/views/pg_reports/dashboard/_show_modals.html.erb
155
156
  - app/views/pg_reports/dashboard/_show_scripts.html.erb
156
157
  - app/views/pg_reports/dashboard/_show_styles.html.erb
158
+ - app/views/pg_reports/dashboard/_target_selector.html.erb
157
159
  - app/views/pg_reports/dashboard/index.html.erb
158
160
  - app/views/pg_reports/dashboard/show.html.erb
159
161
  - config/locales/en.yml
@@ -164,6 +166,9 @@ files:
164
166
  - lib/pg_reports/annotation_parser.rb
165
167
  - lib/pg_reports/compatibility.rb
166
168
  - lib/pg_reports/configuration.rb
169
+ - lib/pg_reports/connection/error_translator.rb
170
+ - lib/pg_reports/connection/registry.rb
171
+ - lib/pg_reports/connection/target.rb
167
172
  - lib/pg_reports/dashboard/reports_registry.rb
168
173
  - lib/pg_reports/definitions/connections/active_connections.yml
169
174
  - lib/pg_reports/definitions/connections/blocking_queries.yml
@@ -289,16 +294,6 @@ metadata:
289
294
  homepage_uri: https://github.com/deadalice/pg_reports
290
295
  source_code_uri: https://github.com/deadalice/pg_reports
291
296
  changelog_uri: https://github.com/deadalice/pg_reports/blob/main/CHANGELOG.md
292
- post_install_message: |
293
- Thanks for installing pg_reports v0.7.0!
294
-
295
- New in 0.7.0 — experimental Grafana / Prometheus support
296
- ────────────────────────────────────────────────────────
297
- Expose selected reports at <mount_point>/metrics in Prometheus exposition
298
- format and import a ready-to-use Grafana dashboard:
299
-
300
- Setup guide:
301
- https://github.com/deadalice/pg_reports/blob/main/docs/grafana.md
302
297
  rdoc_options: []
303
298
  require_paths:
304
299
  - lib