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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +143 -378
- data/app/controllers/pg_reports/dashboard_controller.rb +21 -21
- data/app/views/layouts/pg_reports/application.html.erb +65 -8
- data/app/views/pg_reports/dashboard/_show_modals.html.erb +22 -22
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +55 -57
- data/app/views/pg_reports/dashboard/_show_styles.html.erb +18 -0
- data/app/views/pg_reports/dashboard/index.html.erb +109 -106
- data/app/views/pg_reports/dashboard/show.html.erb +26 -26
- data/config/locales/en.yml +488 -0
- data/config/locales/ru.yml +481 -0
- data/config/locales/uk.yml +481 -0
- data/lib/pg_reports/annotation_parser.rb +13 -1
- data/lib/pg_reports/compatibility.rb +3 -3
- data/lib/pg_reports/dashboard/reports_registry.rb +83 -12
- data/lib/pg_reports/definitions/schema_analysis/always_null_columns.yml +31 -0
- data/lib/pg_reports/definitions/schema_analysis/unused_columns.yml +32 -0
- data/lib/pg_reports/definitions/tables/unused_tables.yml +30 -0
- data/lib/pg_reports/definitions/tables/update_hotspots.yml +32 -0
- data/lib/pg_reports/module_generator.rb +2 -1
- data/lib/pg_reports/modules/schema_analysis.rb +261 -2
- data/lib/pg_reports/modules/system.rb +3 -3
- data/lib/pg_reports/query_monitor.rb +2 -6
- data/lib/pg_reports/report_definition.rb +20 -24
- data/lib/pg_reports/sql/schema_analysis/always_null_columns.sql +25 -0
- data/lib/pg_reports/sql/schema_analysis/unused_columns.sql +36 -0
- data/lib/pg_reports/sql/tables/unused_tables.sql +19 -0
- data/lib/pg_reports/sql/tables/update_hotspots.sql +26 -0
- data/lib/pg_reports/version.rb +1 -1
- 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"
|
|
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"
|
|
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"
|
|
226
|
-
index_correlation: {name: "Index Correlation", description: "Low physical correlation indexes"
|
|
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"
|
|
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"
|
|
272
|
-
checkpoint_stats: {name: "Checkpoint Stats", description: "Checkpoint and bgwriter statistics"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
|
@@ -120,33 +120,29 @@ module PgReports
|
|
|
120
120
|
params = {}
|
|
121
121
|
|
|
122
122
|
# Parameters from parameters section
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|