pg_reports 0.6.0 → 0.6.2

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +143 -378
  4. data/app/controllers/pg_reports/dashboard_controller.rb +21 -21
  5. data/app/views/layouts/pg_reports/application.html.erb +65 -8
  6. data/app/views/pg_reports/dashboard/_show_modals.html.erb +22 -22
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +55 -57
  8. data/app/views/pg_reports/dashboard/_show_styles.html.erb +18 -0
  9. data/app/views/pg_reports/dashboard/index.html.erb +109 -106
  10. data/app/views/pg_reports/dashboard/show.html.erb +26 -26
  11. data/config/locales/en.yml +488 -0
  12. data/config/locales/ru.yml +481 -0
  13. data/config/locales/uk.yml +481 -0
  14. data/lib/pg_reports/annotation_parser.rb +13 -1
  15. data/lib/pg_reports/compatibility.rb +3 -3
  16. data/lib/pg_reports/dashboard/reports_registry.rb +83 -12
  17. data/lib/pg_reports/definitions/schema_analysis/always_null_columns.yml +31 -0
  18. data/lib/pg_reports/definitions/schema_analysis/unused_columns.yml +32 -0
  19. data/lib/pg_reports/definitions/tables/unused_tables.yml +30 -0
  20. data/lib/pg_reports/definitions/tables/update_hotspots.yml +32 -0
  21. data/lib/pg_reports/module_generator.rb +2 -1
  22. data/lib/pg_reports/modules/schema_analysis.rb +261 -2
  23. data/lib/pg_reports/modules/system.rb +3 -3
  24. data/lib/pg_reports/query_monitor.rb +2 -6
  25. data/lib/pg_reports/report_definition.rb +20 -24
  26. data/lib/pg_reports/sql/schema_analysis/always_null_columns.sql +25 -0
  27. data/lib/pg_reports/sql/schema_analysis/unused_columns.sql +36 -0
  28. data/lib/pg_reports/sql/tables/unused_tables.sql +19 -0
  29. data/lib/pg_reports/sql/tables/update_hotspots.sql +26 -0
  30. data/lib/pg_reports/version.rb +1 -1
  31. metadata +9 -1
@@ -192,6 +192,43 @@ module PgReports
192
192
  missing_validations: {
193
193
  thresholds: {},
194
194
  problem_fields: []
195
+ },
196
+ unused_columns: {
197
+ thresholds: {},
198
+ problem_fields: ["column_name"]
199
+ },
200
+ always_null_columns: {
201
+ thresholds: {null_pct: {warning: 99, critical: 100}},
202
+ problem_fields: ["column_name", "null_pct"]
203
+ },
204
+ polymorphic_without_index: {
205
+ thresholds: {},
206
+ problem_fields: ["coverage"]
207
+ },
208
+ counter_cache_issues: {
209
+ thresholds: {},
210
+ problem_fields: ["issue", "expected_column"]
211
+ },
212
+ soft_delete_without_scope: {
213
+ thresholds: {},
214
+ problem_fields: ["status", "soft_delete_column"]
215
+ },
216
+ orphan_tables: {
217
+ thresholds: {},
218
+ problem_fields: ["classification"]
219
+ },
220
+
221
+ # === TABLES (extra) ===
222
+ update_hotspots: {
223
+ thresholds: {
224
+ updates_per_row: {warning: 10, critical: 100},
225
+ hot_update_pct: {warning: 50, critical: 20, inverted: true}
226
+ },
227
+ problem_fields: ["updates_per_row", "hot_update_pct"]
228
+ },
229
+ unused_tables: {
230
+ thresholds: {total_size_mb: {warning: 10, critical: 100}},
231
+ problem_fields: ["table_name"]
195
232
  }
196
233
  }.freeze
197
234
 
@@ -206,7 +243,7 @@ module PgReports
206
243
  expensive_queries: {name: "Expensive Queries", description: "Queries consuming most total time"},
207
244
  missing_index_queries: {name: "Missing Index Queries", description: "Queries potentially missing indexes"},
208
245
  low_cache_hit_queries: {name: "Low Cache Hit", description: "Queries with poor cache utilization"},
209
- temp_file_queries: {name: "Temp File Queries", description: "Queries spilling to disk", new: true},
246
+ temp_file_queries: {name: "Temp File Queries", description: "Queries spilling to disk"},
210
247
  all_queries: {name: "All Queries", description: "All query statistics"}
211
248
  }
212
249
  },
@@ -219,11 +256,11 @@ module PgReports
219
256
  duplicate_indexes: {name: "Duplicate Indexes", description: "Redundant indexes"},
220
257
  invalid_indexes: {name: "Invalid Indexes", description: "Indexes that failed to build"},
221
258
  missing_indexes: {name: "Missing Indexes", description: "Tables potentially missing indexes"},
222
- inefficient_indexes: {name: "Inefficient Indexes", description: "Indexes with high read-to-fetch ratio", new: true},
259
+ inefficient_indexes: {name: "Inefficient Indexes", description: "Indexes with high read-to-fetch ratio"},
223
260
  index_usage: {name: "Index Usage", description: "Index scan statistics"},
224
261
  bloated_indexes: {name: "Bloated Indexes", description: "Indexes with high bloat"},
225
- fk_without_indexes: {name: "FK Without Indexes", description: "Foreign keys missing indexes", new: true},
226
- index_correlation: {name: "Index Correlation", description: "Low physical correlation indexes", new: true},
262
+ fk_without_indexes: {name: "FK Without Indexes", description: "Foreign keys missing indexes"},
263
+ index_correlation: {name: "Index Correlation", description: "Low physical correlation indexes"},
227
264
  index_sizes: {name: "Index Sizes", description: "Index disk usage"}
228
265
  }
229
266
  },
@@ -238,8 +275,10 @@ module PgReports
238
275
  row_counts: {name: "Row Counts", description: "Table row counts"},
239
276
  cache_hit_ratios: {name: "Cache Hit Ratios", description: "Table cache statistics"},
240
277
  seq_scans: {name: "Sequential Scans", description: "Tables with high sequential scans"},
241
- tables_without_pk: {name: "No Primary Key", description: "Tables missing primary keys", new: true},
242
- recently_modified: {name: "Recently Modified", description: "Tables with recent activity"}
278
+ tables_without_pk: {name: "No Primary Key", description: "Tables missing primary keys"},
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}
243
282
  }
244
283
  },
245
284
  connections: {
@@ -268,8 +307,8 @@ module PgReports
268
307
  settings: {name: "Settings", description: "PostgreSQL configuration"},
269
308
  extensions: {name: "Extensions", description: "Installed extensions"},
270
309
  activity_overview: {name: "Activity Overview", description: "Current activity summary"},
271
- wraparound_risk: {name: "Wraparound Risk", description: "Transaction ID wraparound proximity", new: true},
272
- checkpoint_stats: {name: "Checkpoint Stats", description: "Checkpoint and bgwriter statistics", new: true},
310
+ wraparound_risk: {name: "Wraparound Risk", description: "Transaction ID wraparound proximity"},
311
+ checkpoint_stats: {name: "Checkpoint Stats", description: "Checkpoint and bgwriter statistics"},
273
312
  cache_stats: {name: "Cache Stats", description: "Database cache statistics"}
274
313
  }
275
314
  },
@@ -278,21 +317,53 @@ module PgReports
278
317
  icon: "🔍",
279
318
  color: "#06b6d4",
280
319
  reports: {
281
- missing_validations: {name: "Missing Validations", description: "Unique indexes without model validations"}
320
+ 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}
282
327
  }
283
328
  }
284
329
  }.freeze
285
330
 
286
331
  def self.all
287
- REPORTS
332
+ REPORTS.each_with_object({}) do |(cat_key, cat), result|
333
+ result[cat_key] = localized_category(cat_key, cat)
334
+ end
288
335
  end
289
336
 
290
337
  def self.find(category, report)
291
- REPORTS.dig(category.to_sym, :reports, report.to_sym)
338
+ rep = REPORTS.dig(category.to_sym, :reports, report.to_sym)
339
+ return nil unless rep
340
+
341
+ localized_report(report.to_sym, rep)
292
342
  end
293
343
 
294
344
  def self.category(category)
295
- REPORTS[category.to_sym]
345
+ cat = REPORTS[category.to_sym]
346
+ return nil unless cat
347
+
348
+ localized_category(category.to_sym, cat)
349
+ end
350
+
351
+ # Build category hash with localized name and report names
352
+ def self.localized_category(cat_key, cat)
353
+ cat.merge(
354
+ name: I18n.t("pg_reports.categories.#{cat_key}", default: cat[:name]),
355
+ reports: cat[:reports].each_with_object({}) do |(rep_key, rep), reports|
356
+ reports[rep_key] = localized_report(rep_key, rep)
357
+ end
358
+ )
359
+ end
360
+
361
+ # Build report hash with localized name and description
362
+ def self.localized_report(rep_key, rep)
363
+ rep.merge(
364
+ name: I18n.t("pg_reports.reports.#{rep_key}.name", default: rep[:name]),
365
+ description: I18n.t("pg_reports.reports.#{rep_key}.description", default: rep[:description])
366
+ )
296
367
  end
297
368
 
298
369
  # Returns full documentation for a report including I18n translations
@@ -0,0 +1,31 @@
1
+ # Columns where 100% of rows are NULL
2
+ # Strong indicator the application no longer populates these fields
3
+
4
+ report:
5
+ name: always_null_columns
6
+ module: schema_analysis
7
+ description: "Nullable columns that contain only NULL values"
8
+
9
+ sql:
10
+ category: schema_analysis
11
+ file: always_null_columns
12
+
13
+ title: "Always-NULL Columns"
14
+
15
+ columns:
16
+ - schema
17
+ - table_name
18
+ - column_name
19
+ - data_type
20
+ - null_pct
21
+ - column_default
22
+ - estimated_rows
23
+
24
+ parameters:
25
+ limit:
26
+ type: integer
27
+ default: 100
28
+ description: "Maximum number of results"
29
+
30
+ problem_explanations:
31
+ column_name: always_null_column
@@ -0,0 +1,32 @@
1
+ # Columns whose value has never changed since table creation
2
+ # Detects dead fields that the application code no longer touches
3
+
4
+ report:
5
+ name: unused_columns
6
+ module: schema_analysis
7
+ description: "Columns that appear to have never been updated (still hold a single value)"
8
+
9
+ sql:
10
+ category: schema_analysis
11
+ file: unused_columns
12
+
13
+ title: "Unused Columns"
14
+
15
+ columns:
16
+ - schema
17
+ - table_name
18
+ - column_name
19
+ - data_type
20
+ - sole_value
21
+ - column_default
22
+ - null_pct
23
+ - estimated_rows
24
+
25
+ parameters:
26
+ limit:
27
+ type: integer
28
+ default: 100
29
+ description: "Maximum number of results"
30
+
31
+ problem_explanations:
32
+ column_name: unused_column
@@ -0,0 +1,30 @@
1
+ # Tables never read since the last stats reset
2
+ # Zero seq_scan and zero idx_scan -- code does not query them
3
+
4
+ report:
5
+ name: unused_tables
6
+ module: tables
7
+ description: "Tables with zero read activity since the last statistics reset"
8
+
9
+ sql:
10
+ category: tables
11
+ file: unused_tables
12
+
13
+ title: "Unused Tables"
14
+
15
+ columns:
16
+ - schema
17
+ - table_name
18
+ - live_rows
19
+ - total_size_mb
20
+ - last_analyzed
21
+ - db_stats_since
22
+
23
+ parameters:
24
+ limit:
25
+ type: integer
26
+ default: 50
27
+ description: "Maximum number of results"
28
+
29
+ problem_explanations:
30
+ table_name: unused_table
@@ -0,0 +1,32 @@
1
+ # Tables with disproportionately heavy update activity
2
+ # High updates_per_row signals same rows updated repeatedly; low hot_update_pct signals indexed columns are being updated
3
+
4
+ report:
5
+ name: update_hotspots
6
+ module: tables
7
+ description: "Tables with high update-to-row ratio or poor HOT update efficiency"
8
+
9
+ sql:
10
+ category: tables
11
+ file: update_hotspots
12
+
13
+ title: "Update Hotspots"
14
+
15
+ columns:
16
+ - schema
17
+ - table_name
18
+ - live_rows
19
+ - updates
20
+ - updates_per_row
21
+ - hot_update_pct
22
+ - dead_rows
23
+
24
+ parameters:
25
+ limit:
26
+ type: integer
27
+ default: 30
28
+ description: "Maximum number of results"
29
+
30
+ problem_explanations:
31
+ updates_per_row: hot_rows
32
+ hot_update_pct: low_hot_update
@@ -17,7 +17,8 @@ module PgReports
17
17
  private
18
18
 
19
19
  def self.get_module(module_name)
20
- PgReports::Modules.const_get(module_name.capitalize)
20
+ const_name = module_name.to_s.split("_").map(&:capitalize).join
21
+ PgReports::Modules.const_get(const_name)
21
22
  rescue NameError
22
23
  # Module doesn't exist, skip it
23
24
  # We don't auto-create modules to avoid conflicts
@@ -64,6 +64,155 @@ module PgReports
64
64
  )
65
65
  end
66
66
 
67
+ # Polymorphic associations missing a composite (type, id) index
68
+ # @return [Report]
69
+ def polymorphic_without_index(**_params)
70
+ eager_load_models!
71
+ results = []
72
+
73
+ each_concrete_model do |model|
74
+ polymorphic_belongs_to(model).each do |assoc|
75
+ type_col = "#{assoc.name}_type"
76
+ id_col = "#{assoc.name}_id"
77
+
78
+ next unless model.column_names.include?(type_col) && model.column_names.include?(id_col)
79
+
80
+ # Expression/functional indexes report `columns` as a String — drop them; we only care about column-list indexes.
81
+ indexes = ActiveRecord::Base.connection.indexes(model.table_name).select { |i| i.columns.is_a?(Array) }
82
+ composite = indexes.find { |idx| (idx.columns & [type_col, id_col]).size == 2 }
83
+ next if composite
84
+
85
+ results << {
86
+ "schema" => "public",
87
+ "table_name" => model.table_name,
88
+ "model_name" => model.name,
89
+ "association" => assoc.name.to_s,
90
+ "type_column" => type_col,
91
+ "id_column" => id_col,
92
+ "coverage" => coverage_label(indexes, type_col, id_col),
93
+ "suggestion" => "add_index :#{model.table_name}, [:#{type_col}, :#{id_col}]"
94
+ }
95
+ end
96
+ end
97
+
98
+ Report.new(
99
+ title: "Polymorphic Associations Without Composite Index",
100
+ data: results,
101
+ columns: %w[table_name model_name association coverage suggestion]
102
+ )
103
+ end
104
+
105
+ # belongs_to ..., counter_cache: ... whose counter column is missing on the parent
106
+ # @return [Report]
107
+ def counter_cache_issues(**_params)
108
+ eager_load_models!
109
+ results = []
110
+
111
+ each_concrete_model do |model|
112
+ counter_belongs_to(model).each do |assoc|
113
+ counter_col = counter_cache_column_name(model, assoc)
114
+ parent = parent_class_for(assoc)
115
+ next unless parent && parent.table_exists?
116
+
117
+ unless parent.column_names.include?(counter_col)
118
+ results << {
119
+ "schema" => "public",
120
+ "child_model" => model.name,
121
+ "child_table" => model.table_name,
122
+ "parent_model" => parent.name,
123
+ "parent_table" => parent.table_name,
124
+ "expected_column" => counter_col,
125
+ "issue" => "missing_column",
126
+ "suggestion" => "add_column :#{parent.table_name}, :#{counter_col}, :integer, default: 0, null: false"
127
+ }
128
+ end
129
+ end
130
+ end
131
+
132
+ Report.new(
133
+ title: "Counter Cache Issues",
134
+ data: results,
135
+ columns: %w[child_model parent_model expected_column issue suggestion]
136
+ )
137
+ end
138
+
139
+ # Tables with soft-delete column but no model scope filtering it
140
+ # @return [Report]
141
+ def soft_delete_without_scope(**_params)
142
+ eager_load_models!
143
+ soft_delete_columns = %w[deleted_at discarded_at archived_at]
144
+ results = []
145
+
146
+ ActiveRecord::Base.connection.tables.each do |table|
147
+ next if internal_table?(table)
148
+
149
+ columns = ActiveRecord::Base.connection.columns(table).map(&:name)
150
+ soft_col = (columns & soft_delete_columns).first
151
+ next unless soft_col
152
+
153
+ model = find_model_for_table(table)
154
+ if model.nil?
155
+ results << {
156
+ "schema" => "public",
157
+ "table_name" => table,
158
+ "model_name" => "(no model)",
159
+ "soft_delete_column" => soft_col,
160
+ "status" => "no_model",
161
+ "suggestion" => "Create a model or filter manually in queries"
162
+ }
163
+ next
164
+ end
165
+
166
+ next if model_filters_soft_delete?(model, soft_col)
167
+
168
+ results << {
169
+ "schema" => "public",
170
+ "table_name" => table,
171
+ "model_name" => model.name,
172
+ "soft_delete_column" => soft_col,
173
+ "status" => "no_scope",
174
+ "suggestion" => "default_scope { where(#{soft_col}: nil) } or use discard/paranoia"
175
+ }
176
+ end
177
+
178
+ Report.new(
179
+ title: "Soft Delete Without Scope",
180
+ data: results,
181
+ columns: %w[table_name model_name soft_delete_column status suggestion]
182
+ )
183
+ end
184
+
185
+ # Tables with no corresponding Rails model (legacy or HABTM)
186
+ # @return [Report]
187
+ def orphan_tables(**_params)
188
+ eager_load_models!
189
+ results = []
190
+
191
+ ActiveRecord::Base.connection.tables.each do |table|
192
+ next if internal_table?(table)
193
+ next if find_model_for_table(table)
194
+
195
+ columns = ActiveRecord::Base.connection.columns(table)
196
+ row_count = approximate_row_count(table)
197
+
198
+ results << {
199
+ "schema" => "public",
200
+ "table_name" => table,
201
+ "row_count" => row_count,
202
+ "column_count" => columns.size,
203
+ "classification" => classify_orphan(columns)
204
+ }
205
+ end
206
+
207
+ results.sort_by! { |r| -r["row_count"].to_i }
208
+
209
+ Report.new(
210
+ title: "Orphan Tables (No Rails Model)",
211
+ data: results,
212
+ columns: %w[table_name row_count column_count classification]
213
+ )
214
+ end
215
+
67
216
  private
68
217
 
69
218
  def executor
@@ -125,7 +274,7 @@ module PgReports
125
274
  if column_names.size > 1
126
275
  # For composite indexes, we need to check if there's a validation with scope
127
276
  primary_column = column_names.first.to_sym
128
- scope_columns = column_names[1..-1].map(&:to_sym)
277
+ scope_columns = column_names[1..].map(&:to_sym)
129
278
 
130
279
  has_composite = uniqueness_validators.any? do |validator|
131
280
  validator.attributes.include?(primary_column) &&
@@ -145,10 +294,120 @@ module PgReports
145
294
  "validates :#{column_names.first}, uniqueness: true"
146
295
  else
147
296
  primary = column_names.first
148
- scopes = column_names[1..-1]
297
+ scopes = column_names[1..]
149
298
  "validates :#{primary}, uniqueness: { scope: #{scopes.inspect} }"
150
299
  end
151
300
  end
301
+
302
+ # Eager-load all application models so descendants is complete in development
303
+ def eager_load_models!
304
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
305
+ Rails.application.eager_load!
306
+ rescue
307
+ # Best-effort; don't crash the report if a model fails to autoload
308
+ end
309
+
310
+ # Yield each concrete (non-abstract) ActiveRecord model that has a backing table
311
+ def each_concrete_model
312
+ ActiveRecord::Base.descendants.each do |model|
313
+ next if model.abstract_class?
314
+ next if model.name.nil?
315
+ next unless model.table_exists?
316
+ yield model
317
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
318
+ # Skip models whose table cannot be inspected
319
+ end
320
+ end
321
+
322
+ def polymorphic_belongs_to(model)
323
+ model.reflect_on_all_associations(:belongs_to).select { |a| a.options[:polymorphic] }
324
+ end
325
+
326
+ def counter_belongs_to(model)
327
+ model.reflect_on_all_associations(:belongs_to).reject { |a| a.options[:polymorphic] }.select { |a| a.options[:counter_cache] }
328
+ end
329
+
330
+ # Resolve the counter_cache column name from belongs_to options.
331
+ # counter_cache: true → "<child_table_name>_count" (Rails default)
332
+ # counter_cache: :col → "col"
333
+ # counter_cache: "col" → "col"
334
+ # counter_cache: { active: true, column: "col" } → "col" (Rails 7.1+ form)
335
+ # counter_cache: { active: true } → falls back to default
336
+ def counter_cache_column_name(child_model, assoc)
337
+ opt = assoc.options[:counter_cache]
338
+ column = case opt
339
+ when true
340
+ nil
341
+ when Hash
342
+ opt[:column] || opt["column"]
343
+ else
344
+ opt
345
+ end
346
+ return column.to_s if column
347
+
348
+ "#{child_model.table_name.split(".").last}_count"
349
+ end
350
+
351
+ def parent_class_for(assoc)
352
+ assoc.klass
353
+ rescue NameError
354
+ nil
355
+ end
356
+
357
+ # Does the model's default scope filter out soft-deleted rows on the given column?
358
+ # Detects: paranoia, discard, hand-rolled default_scope referencing the column.
359
+ def model_filters_soft_delete?(model, column)
360
+ return true if model.respond_to?(:paranoid?) && model.paranoid?
361
+ return true if model.respond_to?(:discard_column) && model.discard_column.to_s == column
362
+
363
+ default_sql = model.all.to_sql
364
+ default_sql.include?(column)
365
+ rescue
366
+ false
367
+ end
368
+
369
+ def coverage_label(indexes, type_col, id_col)
370
+ column_list_indexes = indexes.select { |i| i.columns.is_a?(Array) }
371
+ type_indexed = column_list_indexes.any? { |i| i.columns.first == type_col }
372
+ id_indexed = column_list_indexes.any? { |i| i.columns.first == id_col }
373
+
374
+ if type_indexed && id_indexed
375
+ "type and id indexed separately"
376
+ elsif type_indexed
377
+ "only type indexed"
378
+ elsif id_indexed
379
+ "only id indexed"
380
+ else
381
+ "neither indexed"
382
+ end
383
+ end
384
+
385
+ def internal_table?(name)
386
+ %w[schema_migrations ar_internal_metadata].include?(name)
387
+ end
388
+
389
+ def approximate_row_count(table)
390
+ sql = "SELECT n_live_tup FROM pg_stat_user_tables WHERE relname = #{ActiveRecord::Base.connection.quote(table)}"
391
+ ActiveRecord::Base.connection.select_value(sql).to_i
392
+ rescue
393
+ 0
394
+ end
395
+
396
+ # Classify an orphan table based on column shape.
397
+ # Two FK columns + nothing else (or just timestamps) → likely HABTM join table.
398
+ def classify_orphan(columns)
399
+ col_names = columns.map(&:name)
400
+ fk_cols = col_names.select { |c| c.end_with?("_id") }
401
+ non_meta = col_names - %w[id created_at updated_at]
402
+
403
+ if fk_cols.size == 2 && (non_meta - fk_cols).empty?
404
+ "join_table_candidate"
405
+ elsif fk_cols.size >= 2
406
+ "join_model_without_class"
407
+ else
408
+ "legacy"
409
+ end
410
+ end
152
411
  end
153
412
  end
154
413
  end
@@ -139,7 +139,7 @@ module PgReports
139
139
  # Checkpoint stats — uses version-specific SQL because PostgreSQL 17+
140
140
  # moved checkpoint columns from pg_stat_bgwriter to pg_stat_checkpointer
141
141
  def checkpoint_stats(limit: 10)
142
- sql_file = pg_version >= 170_000 ? :checkpoint_stats : :checkpoint_stats_legacy
142
+ sql_file = (pg_version >= 170_000) ? :checkpoint_stats : :checkpoint_stats_legacy
143
143
  data = executor.execute_from_file(:system, sql_file)
144
144
  data = data.first(limit) if limit
145
145
 
@@ -147,8 +147,8 @@ module PgReports
147
147
  title: "Checkpoint Statistics",
148
148
  data: data,
149
149
  columns: %w[checkpoints_timed checkpoints_requested checkpoint_write_time_sec
150
- checkpoint_sync_time_sec buffers_checkpoint buffers_clean
151
- bgwriter_stops buffers_alloc requested_pct stats_reset]
150
+ checkpoint_sync_time_sec buffers_checkpoint buffers_clean
151
+ bgwriter_stops buffers_alloc requested_pct stats_reset]
152
152
  )
153
153
  end
154
154
 
@@ -27,13 +27,9 @@ module PgReports
27
27
  ensure_subscription_if_enabled
28
28
  end
29
29
 
30
- def enabled
31
- @enabled
32
- end
30
+ attr_reader :enabled
33
31
 
34
- def session_id
35
- @session_id
36
- end
32
+ attr_reader :session_id
37
33
 
38
34
  def start
39
35
  @mutex.synchronize do
@@ -120,33 +120,29 @@ module PgReports
120
120
  params = {}
121
121
 
122
122
  # Parameters from parameters section
123
- if config["parameters"]
124
- config["parameters"].each do |name, param_config|
125
- params[name] = {
126
- type: param_config["type"],
127
- default: param_config["default"],
128
- description: param_config["description"],
129
- label: name.to_s.titleize
130
- }
131
- end
123
+ config["parameters"]&.each do |name, param_config|
124
+ params[name] = {
125
+ type: param_config["type"],
126
+ default: param_config["default"],
127
+ description: I18n.t("pg_reports.parameters.#{name}.description", default: param_config["description"]),
128
+ label: I18n.t("pg_reports.parameters.#{name}.label", default: name.to_s.titleize)
129
+ }
132
130
  end
133
131
 
134
132
  # Add threshold parameters from filters (config-based)
135
- if config["filters"]
136
- config["filters"].each do |filter|
137
- if filter["value"]["source"] == "config"
138
- config_key = filter["value"]["key"]
139
- field_name = filter["field"]
140
-
141
- params["#{field_name}_threshold"] = {
142
- type: filter["cast"] || "integer",
143
- default: PgReports.config.public_send(config_key),
144
- description: "Override threshold for #{field_name}",
145
- label: "#{field_name.titleize} Threshold",
146
- current_config: PgReports.config.public_send(config_key),
147
- is_threshold: true
148
- }
149
- end
133
+ config["filters"]&.each do |filter|
134
+ if filter["value"]["source"] == "config"
135
+ config_key = filter["value"]["key"]
136
+ field_name = filter["field"]
137
+
138
+ params["#{field_name}_threshold"] = {
139
+ type: filter["cast"] || "integer",
140
+ default: PgReports.config.public_send(config_key),
141
+ description: I18n.t("pg_reports.parameters.threshold_description", field: field_name),
142
+ label: I18n.t("pg_reports.parameters.threshold_label", field: field_name.titleize),
143
+ current_config: PgReports.config.public_send(config_key),
144
+ is_threshold: true
145
+ }
150
146
  end
151
147
  end
152
148