ruby-pg-extras 5.6.14 → 5.6.15

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76983587160dcdfb1e9cc45f5e42b1329d08f74977f5c7691c7fafeeefc61fe4
4
- data.tar.gz: 4ed633c71a3b2f4c12be8911b73ea59b750f2e7c569c6ae9913d62cab68b45e3
3
+ metadata.gz: e20ed34b0677aa7e50c14ca2d5a511d4854f0eb34a1ad7e04a4f94fe2349895b
4
+ data.tar.gz: ef35f2a431195ba26a0271808315b54985254df379412ffb4eef9dc4ff81f7e8
5
5
  SHA512:
6
- metadata.gz: 34423951a2e88e0c2e333e3cf2acc81aa8fd3b85eb76a410d2b6a5f195c22f584b475a9ee23585f012579c203496eee037d79889325f65b3b7d224a607fe98ac
7
- data.tar.gz: cb3ff5a3d3ef075bd1cfe5c4604bf7dfcd821b9c11985868fa98d910213e1e767b765fe92c6acc6b1bb910d35cc12435402e97364cc26b1dacc2cd5d0212b55f
6
+ metadata.gz: 6145da151dc40da7d0f79a3a2c9ff3d059dd9b320e44b751f6d5a9d0b00d395a78aff3c8bd1bf51453aa65ccb7cf12953ec73e54b4a350d7ee34ec61b5f661a0
7
+ data.tar.gz: 72861cc4517a977e39266f2617094db82be77ba31f932831785540c3bc3ae078e1deccbc32b5b025343bbbe2e2c2d25529e24dad170ea9e8969795dfe3ee64de
@@ -15,13 +15,6 @@ jobs:
15
15
  ruby-version: ['3.4', '3.3', '3.2', '3.1', '3.0', '2.7']
16
16
  steps:
17
17
  - uses: actions/checkout@v4
18
- - name: Run PostgreSQL 12
19
- run: |
20
- docker run --env POSTGRES_USER=postgres \
21
- --env POSTGRES_DB=ruby-pg-extras-test \
22
- --env POSTGRES_PASSWORD=secret \
23
- -d -p 5432:5432 postgres:12.20-alpine \
24
- postgres -c shared_preload_libraries=pg_stat_statements
25
18
  - name: Run PostgreSQL 13
26
19
  run: |
27
20
  docker run --env POSTGRES_USER=postgres \
@@ -71,11 +64,6 @@ jobs:
71
64
  bundle config set --local path 'vendor/bundle'
72
65
  bundle install
73
66
  sleep 5
74
- - name: Run tests for PG 12
75
- env:
76
- PG_VERSION: 12
77
- run: |
78
- bundle exec rspec spec/
79
67
  - name: Run tests for PG 13
80
68
  env:
81
69
  PG_VERSION: 13
data/README.md CHANGED
@@ -647,17 +647,60 @@ This command displays an estimation of table "bloat" – space allocated to a re
647
647
 
648
648
  RubyPgExtras.vacuum_stats
649
649
 
650
- schema | table | last_vacuum | last_autovacuum | rowcount | dead_rowcount | autovacuum_threshold | expect_autovacuum
651
- --------+-----------------------+-------------+------------------+----------------+----------------+----------------------+-------------------
652
- public | log_table | | 2013-04-26 17:37 | 18,030 | 0 | 3,656 |
653
- public | data_table | | 2013-04-26 13:09 | 79 | 28 | 66 |
654
- public | other_table | | 2013-04-26 11:41 | 41 | 47 | 58 |
655
- public | queue_table | | 2013-04-26 17:39 | 12 | 8,228 | 52 | yes
656
- public | picnic_table | | | 13 | 0 | 53 |
650
+ schema | table | last_manual_vacuum | manual_vacuum_count | last_autovacuum | autovacuum_count | rowcount | dead_rowcount | dead_tup_autovacuum_threshold | n_ins_since_vacuum | insert_autovacuum_threshold | expect_autovacuum
651
+ --------+-----------------------+--------------------+---------------------+------------------+------------------+----------------+----------------+-------------------------------+--------------------+-----------------------------+-------------------
652
+ public | log_table | | 0 | 2013-04-26 17:37 | 5 | 18,030 | 0 | 3,656 | 0 | 3,606 |
653
+ public | data_table | | 0 | 2013-04-26 13:09 | 3 | 79 | 28 | 66 | 10 | 16 | yes (dead_tuples)
654
+ public | other_table | | 0 | 2013-04-26 11:41 | 4 | 41 | 47 | 58 | 2,000 | 1,008 | yes (dead_tuples & inserts)
657
655
  (truncated results for brevity)
658
656
  ```
659
657
 
660
- This command displays statistics related to vacuum operations for each table, including an estimation of dead rows, last autovacuum and the current autovacuum threshold. This command can be useful when determining if current vacuum thresholds require adjustments, and to determine when the table was last vacuumed.
658
+ This command displays statistics related to vacuum operations for each table, including last manual vacuum and autovacuum timestamps and counters, an estimation of dead rows, dead-tuple-based autovacuum threshold, number of rows inserted since the last VACUUM (`n_ins_since_vacuum`) and the insert-based autovacuum threshold introduced in PostgreSQL 13 ([PostgreSQL autovacuum configuration](https://www.postgresql.org/docs/current/runtime-config-vacuum.html#RUNTIME-CONFIG-AUTOVACUUM)). It helps determine if current autovacuum thresholds (both dead-tuple and insert-based) are appropriate, and whether an automatic vacuum is expected to be triggered soon.
659
+
660
+ ### `vacuum_progress`
661
+
662
+ ```ruby
663
+
664
+ RubyPgExtras.vacuum_progress
665
+
666
+ database | schema | table | pid | phase | heap_blks_total | heap_blks_scanned | heap_blks_vacuumed | index_vacuum_count
667
+ ----------+--------+----------+-------+---------------------+-----------------+-------------------+--------------------+--------------------
668
+ app_db | public | users | 12345 | scanning heap | 125000 | 32000 | 0 | 0
669
+ app_db | public | orders | 12346 | vacuuming indexes | 80000 | 80000 | 75000 | 3
670
+ (truncated results for brevity)
671
+ ```
672
+
673
+ This command shows the current progress of `VACUUM` / autovacuum operations by reading `pg_stat_progress_vacuum` ([VACUUM progress reporting docs](https://www.postgresql.org/docs/current/progress-reporting.html#VACUUM-PROGRESS-REPORTING)). It can be used to see which tables are being vacuumed right now, how far each operation has progressed, and how many index vacuum cycles have been performed.
674
+
675
+ ### `analyze_progress`
676
+
677
+ ```ruby
678
+
679
+ RubyPgExtras.analyze_progress
680
+
681
+ database | schema | table | pid | phase | sample_blks_total | sample_blks_scanned | ext_stats_total | ext_stats_computed
682
+ ----------+--------+----------+-------+----------------------+-------------------+---------------------+-----------------+--------------------
683
+ app_db | public | users | 22345 | acquiring sample rows| 5000 | 1200 | 2 | 0
684
+ app_db | public | orders | 22346 | computing statistics | 8000 | 8000 | 1 | 1
685
+ (truncated results for brevity)
686
+ ```
687
+
688
+ This command displays the current progress of `ANALYZE` and auto-analyze operations using `pg_stat_progress_analyze` ([ANALYZE progress reporting docs](https://www.postgresql.org/docs/current/progress-reporting.html#ANALYZE-PROGRESS-REPORTING)). It helps understand how far statistics collection has progressed for each active analyze and whether extended statistics are being computed.
689
+
690
+ ### `vacuum_io_stats`
691
+
692
+ ```ruby
693
+
694
+ RubyPgExtras.vacuum_io_stats
695
+
696
+ backend_type | object | context | reads | writes | writebacks | extends | evictions | reuses | fsyncs | stats_reset
697
+ --------------------+----------+----------+---------+---------+-----------+---------+-----------+---------+--------+-------------------------------
698
+ autovacuum worker | relation | vacuum | 5824251 | 3028684 | 0 | 0 | 2588 | 5821460 | 0 | 2025-01-10 11:50:27.583875+00
699
+ autovacuum launcher| relation | autovacuum| 16306 | 2494 | 0 | 2915 | 17785 | 0 | 0 | 2025-01-10 11:50:27.583875+00
700
+ (truncated results for brevity)
701
+ ```
702
+
703
+ This command surfaces cumulative I/O statistics for autovacuum-related VACUUM activity, based on the `pg_stat_io` view introduced in PostgreSQL 16 ([pg_stat_io documentation](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-IO-VIEW)). It shows how many blocks autovacuum workers have read and written, how many buffer evictions and ring-buffer reuses occurred, and when the statistics were last reset; this is useful for determining whether autovacuum is responsible for I/O spikes, as described in the pganalyze article on `pg_stat_io` ([Tracking cumulative I/O activity by autovacuum and manual VACUUMs](https://pganalyze.com/blog/pg-stat-io#tracking-cumulative-io-activity-by-autovacuum-and-manual-vacuums)). On PostgreSQL versions below 16 this method returns a single informational row indicating that the feature is unavailable.
661
704
 
662
705
  ### `kill_all`
663
706
 
data/Rakefile CHANGED
@@ -5,5 +5,5 @@ RSpec::Core::RakeTask.new(:spec)
5
5
 
6
6
  desc "Test all PG versions"
7
7
  task :test_all do
8
- system("PG_VERSION=12 bundle exec rspec spec/ && PG_VERSION=13 bundle exec rspec spec/ && PG_VERSION=14 bundle exec rspec spec/ && PG_VERSION=15 bundle exec rspec spec/ && PG_VERSION=16 bundle exec rspec spec/ && PG_VERSION=17 bundle exec rspec spec/")
8
+ system("PG_VERSION=13 bundle exec rspec spec/ && PG_VERSION=14 bundle exec rspec spec/ && PG_VERSION=15 bundle exec rspec spec/ && PG_VERSION=16 bundle exec rspec spec/ && PG_VERSION=17 bundle exec rspec spec/")
9
9
  end
@@ -1,13 +1,4 @@
1
1
  services:
2
- postgres12:
3
- image: postgres:12.20-alpine
4
- command: postgres -c shared_preload_libraries=pg_stat_statements
5
- environment:
6
- POSTGRES_USER: postgres
7
- POSTGRES_DB: ruby-pg-extras-test
8
- POSTGRES_PASSWORD: secret
9
- ports:
10
- - '5432:5432'
11
2
  postgres13:
12
3
  image: postgres:13.16-alpine
13
4
  command: postgres -c shared_preload_libraries=pg_stat_statements
@@ -7,6 +7,7 @@ require "ruby_pg_extras/size_parser"
7
7
  require "ruby_pg_extras/diagnose_data"
8
8
  require "ruby_pg_extras/diagnose_print"
9
9
  require "ruby_pg_extras/detect_fk_column"
10
+ require "ruby_pg_extras/ignore_list"
10
11
  require "ruby_pg_extras/missing_fk_indexes"
11
12
  require "ruby_pg_extras/missing_fk_constraints"
12
13
  require "ruby_pg_extras/index_info"
@@ -26,7 +27,9 @@ module RubyPgExtras
26
27
  long_running_queries mandelbrot outliers
27
28
  records_rank seq_scans table_index_scans table_indexes_size
28
29
  table_size total_index_size total_table_size
29
- unused_indexes duplicate_indexes vacuum_stats kill_all kill_pid
30
+ unused_indexes duplicate_indexes vacuum_stats vacuum_progress vacuum_io_stats
31
+ analyze_progress
32
+ kill_all kill_pid
30
33
  pg_stat_statements_reset buffercache_stats
31
34
  buffercache_usage ssl_used connections
32
35
  table_schema table_schemas
@@ -50,6 +53,11 @@ module RubyPgExtras
50
53
  outliers: { limit: 10 },
51
54
  outliers_legacy: { limit: 10 },
52
55
  outliers_17: { limit: 10 },
56
+ vacuum_progress: {},
57
+ vacuum_progress_17: {},
58
+ vacuum_io_stats: {},
59
+ vacuum_io_stats_legacy: {},
60
+ analyze_progress: {},
53
61
  buffercache_stats: { limit: 10 },
54
62
  buffercache_usage: { limit: 20 },
55
63
  unused_indexes: { max_scans: 50, schema: DEFAULT_SCHEMA },
@@ -92,6 +100,25 @@ module RubyPgExtras
92
100
  end
93
101
  end
94
102
 
103
+ # vacuum_progress uses pg_stat_progress_vacuum only and does not depend on pg_stat_statements,
104
+ # so we switch it based on the server_version_num instead of the pg_stat_statements version.
105
+ if query_name == :vacuum_progress
106
+ server_version_num = conn.send(exec_method, "SHOW server_version_num").to_a[0].values[0].to_i
107
+ if server_version_num >= 170000
108
+ query_name = :vacuum_progress_17
109
+ end
110
+ end
111
+
112
+ # vacuum_io_stats relies on pg_stat_io which is available starting from PostgreSQL 16.
113
+ # For older versions we fall back to vacuum_io_stats_legacy which just indicates
114
+ # that this feature is not available on the current server.
115
+ if query_name == :vacuum_io_stats
116
+ server_version_num = conn.send(exec_method, "SHOW server_version_num").to_a[0].values[0].to_i
117
+ if server_version_num < 160000
118
+ query_name = :vacuum_io_stats_legacy
119
+ end
120
+ end
121
+
95
122
  REQUIRED_ARGS.fetch(query_name) { [] }.each do |arg_name|
96
123
  if args[arg_name].nil?
97
124
  raise ArgumentError, "'#{arg_name}' is required"
@@ -169,7 +196,7 @@ module RubyPgExtras
169
196
  end
170
197
 
171
198
  def self.missing_fk_constraints(args: {}, in_format: :display_table)
172
- RubyPgExtras::MissingFkConstraints.call(args[:table_name])
199
+ RubyPgExtras::MissingFkConstraints.call(args[:table_name], ignore_list: args[:ignore_list])
173
200
  end
174
201
 
175
202
  def self.display_result(result, title:, in_format:)
@@ -34,16 +34,25 @@ module RubyPgExtras
34
34
  end
35
35
 
36
36
  def call(column_name, tables)
37
- return false unless column_name =~ /_id$/
38
- table_name = column_name.split("_").first
39
- table_name = pluralize(table_name)
40
- tables.include?(table_name)
37
+ # Heuristic: Rails-style foreign keys are usually named `<table_singular>_id`.
38
+ # We accept underscores in the prefix (e.g. `account_user_id` -> `account_users`).
39
+ match = /\A(?<table_singular>.+)_id\z/i.match(column_name.to_s)
40
+ return false unless match
41
+
42
+ table_singular = match[:table_singular]
43
+ return false if table_singular.empty?
44
+
45
+ tables.include?(pluralize(table_singular))
41
46
  end
42
47
 
43
48
  def pluralize(word)
44
- return word if UNCOUNTABLE.include?(word.downcase)
45
- return IRREGULAR[word] if IRREGULAR.key?(word)
46
- return IRREGULAR.invert[word] if IRREGULAR.value?(word)
49
+ # Table names from Postgres are typically lowercase. Normalize before applying rules.
50
+ word = word.to_s.downcase
51
+
52
+ return word if UNCOUNTABLE.include?(word)
53
+ return IRREGULAR.fetch(word) if IRREGULAR.key?(word)
54
+ # If the word is already an irregular plural (e.g. "people"), keep it as-is.
55
+ return word if IRREGULAR.value?(word)
47
56
 
48
57
  PLURAL_RULES.reverse.each do |(rule, replacement)|
49
58
  return word.gsub(rule, replacement) if word.match?(rule)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyPgExtras
4
+ # Parses and matches ignore patterns like:
5
+ # - "*" (ignore everything)
6
+ # - "posts.*" (ignore all columns on a table)
7
+ # - "category_id" (ignore this column name on all tables)
8
+ # - "posts.topic_id" (ignore a specific table+column)
9
+ class IgnoreList
10
+ def initialize(ignore_list)
11
+ @rules = normalize(ignore_list)
12
+ end
13
+
14
+ def ignored?(table:, column_name:)
15
+ @rules.any? do |rule|
16
+ next true if rule == "*"
17
+ next true if rule == "#{table}.*"
18
+ next true if rule == column_name
19
+ next true if rule == "#{table}.#{column_name}"
20
+ false
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def normalize(ignore_list)
27
+ entries =
28
+ case ignore_list
29
+ when nil
30
+ []
31
+ when String
32
+ ignore_list.split(",")
33
+ when Array
34
+ ignore_list
35
+ else
36
+ raise ArgumentError, "ignore_list must be a String or Array"
37
+ end
38
+
39
+ entries
40
+ .map { |v| v.to_s.strip }
41
+ .reject(&:empty?)
42
+ .uniq
43
+ end
44
+ end
45
+ end
46
+
47
+
@@ -2,42 +2,68 @@
2
2
 
3
3
  module RubyPgExtras
4
4
  class MissingFkConstraints
5
- def self.call(table_name)
6
- new.call(table_name)
5
+ # ignore_list: array (or comma-separated string) of entries like:
6
+ # - "posts.category_id" (ignore a specific table+column)
7
+ # - "category_id" (ignore this column name for all tables)
8
+ # - "posts.*" (ignore all columns on a table)
9
+ # - "*" (ignore everything)
10
+ def self.call(table_name, ignore_list: nil)
11
+ new.call(table_name, ignore_list: ignore_list)
7
12
  end
8
13
 
9
- def call(table_name)
10
- tables = if table_name
14
+ def call(table_name, ignore_list: nil)
15
+ ignore_list_matcher = IgnoreList.new(ignore_list)
16
+
17
+ tables =
18
+ if table_name
11
19
  [table_name]
12
20
  else
13
21
  all_tables
14
22
  end
15
23
 
16
- schemas = query_module.table_schemas(in_format: :hash)
17
- foreign_keys = query_module.foreign_keys(in_format: :hash)
24
+ schemas_by_table = query_module
25
+ .table_schemas(in_format: :hash)
26
+ .group_by { |row| row.fetch("table_name") }
18
27
 
19
- tables.reduce([]) do |agg, table|
20
- schema = schemas.select { |row| row.fetch("table_name") == table }
21
- fk_columns = foreign_keys.select { |row| row.fetch("table_name") == table }
28
+ fk_columns_by_table = query_module
29
+ .foreign_keys(in_format: :hash)
30
+ .group_by { |row| row.fetch("table_name") }
31
+ .transform_values { |rows| rows.map { |row| row.fetch("column_name") } }
22
32
 
23
- fk_columns = schema.filter_map do |row|
24
- if DetectFkColumn.call(row.fetch("column_name"), all_tables)
25
- row.fetch("column_name")
26
- end
27
- end
33
+ tables.each_with_object([]) do |table, agg|
34
+ schema = schemas_by_table.fetch(table, [])
35
+ fk_columns_for_table = fk_columns_by_table.fetch(table, [])
36
+ schema_column_names = schema.map { |row| row.fetch("column_name") }
37
+
38
+ candidate_fk_columns = schema.filter_map do |row|
39
+ column_name = row.fetch("column_name")
40
+
41
+ # Skip columns explicitly excluded via ignore list.
42
+ next if ignore_list_matcher.ignored?(table: table, column_name: column_name)
28
43
 
29
- fk_columns.each do |column_name|
30
- if foreign_keys.none? { |row| row.fetch("column_name") == column_name }
31
- agg.push(
32
- {
33
- table: table,
34
- column_name: column_name,
35
- }
36
- )
37
- end
44
+ # Skip columns that already have a foreign key constraint on this table.
45
+ next if fk_columns_for_table.include?(column_name)
46
+
47
+ # Skip columns that don't look like an FK candidate based on naming conventions.
48
+ next unless DetectFkColumn.call(column_name, all_tables)
49
+
50
+ # Rails polymorphic associations use <name>_id + <name>_type and can't have FK constraints.
51
+ candidate_prefix = column_name.delete_suffix("_id")
52
+ polymorphic_type_column = "#{candidate_prefix}_type"
53
+ # Skip polymorphic associations (cannot be expressed as a real FK constraint).
54
+ next if schema_column_names.include?(polymorphic_type_column)
55
+
56
+ column_name
38
57
  end
39
58
 
40
- agg
59
+ candidate_fk_columns.each do |column_name|
60
+ agg.push(
61
+ {
62
+ table: table,
63
+ column_name: column_name,
64
+ }
65
+ )
66
+ end
41
67
  end
42
68
  end
43
69
 
@@ -7,26 +7,22 @@ module RubyPgExtras
7
7
  end
8
8
 
9
9
  def call(table_name)
10
+ indexes_info = query_module.indexes(in_format: :hash)
11
+ foreign_keys = query_module.foreign_keys(in_format: :hash)
12
+
10
13
  tables = if table_name
11
14
  [table_name]
12
15
  else
13
- all_tables
16
+ foreign_keys.map { |row| row.fetch("table_name") }.uniq
14
17
  end
15
18
 
16
- indexes_info = query_module.indexes(in_format: :hash)
17
- schemas = query_module.table_schemas(in_format: :hash)
18
-
19
19
  tables.reduce([]) do |agg, table|
20
20
  index_info = indexes_info.select { |row| row.fetch("tablename") == table }
21
- schema = schemas.select { |row| row.fetch("table_name") == table }
21
+ table_fks = foreign_keys.select { |row| row.fetch("table_name") == table }
22
22
 
23
- fk_columns = schema.filter_map do |row|
24
- if DetectFkColumn.call(row.fetch("column_name"), all_tables)
25
- row.fetch("column_name")
26
- end
27
- end
23
+ table_fks.each do |fk|
24
+ column_name = fk.fetch("column_name")
28
25
 
29
- fk_columns.each do |column_name|
30
26
  if index_info.none? { |row| row.fetch("columns").split(",").first == column_name }
31
27
  agg.push(
32
28
  {
@@ -43,10 +39,6 @@ module RubyPgExtras
43
39
 
44
40
  private
45
41
 
46
- def all_tables
47
- @_all_tables ||= query_module.table_size(in_format: :hash).map { |row| row.fetch("name") }
48
- end
49
-
50
42
  def query_module
51
43
  RubyPgExtras
52
44
  end
@@ -0,0 +1,27 @@
1
+ /* Current ANALYZE progress as reported by pg_stat_progress_analyze */
2
+
3
+ SELECT
4
+ a.datname AS database,
5
+ n.nspname AS schema,
6
+ c.relname AS table,
7
+ p.pid,
8
+ p.phase,
9
+ p.sample_blks_total,
10
+ p.sample_blks_scanned,
11
+ p.ext_stats_total,
12
+ p.ext_stats_computed,
13
+ p.child_tables_total,
14
+ p.child_tables_done,
15
+ p.current_child_table_relid
16
+ FROM
17
+ pg_stat_progress_analyze p
18
+ LEFT JOIN pg_class c ON p.relid = c.oid
19
+ LEFT JOIN pg_namespace n ON c.relnamespace = n.oid
20
+ LEFT JOIN pg_stat_activity a ON p.pid = a.pid
21
+ ORDER BY
22
+ a.datname,
23
+ n.nspname,
24
+ c.relname,
25
+ p.pid;
26
+
27
+
@@ -0,0 +1,31 @@
1
+ /* I/O statistics for autovacuum backends from pg_stat_io (PostgreSQL 16+) */
2
+
3
+ SELECT
4
+ backend_type,
5
+ object,
6
+ context,
7
+ reads,
8
+ read_time,
9
+ writes,
10
+ write_time,
11
+ writebacks,
12
+ writeback_time,
13
+ extends,
14
+ extend_time,
15
+ fsyncs,
16
+ fsync_time,
17
+ reuses,
18
+ evictions,
19
+ stats_reset
20
+ FROM
21
+ pg_stat_io
22
+ WHERE
23
+ backend_type IN ('autovacuum worker', 'autovacuum launcher')
24
+ AND object = 'relation'
25
+ AND context IN ('vacuum', 'autovacuum')
26
+ ORDER BY
27
+ backend_type,
28
+ context,
29
+ object;
30
+
31
+
@@ -0,0 +1,5 @@
1
+ /* I/O statistics for autovacuum backends are only available via pg_stat_io (PostgreSQL 16+). */
2
+
3
+ SELECT 'Upgrade to PostgreSQL 16 or newer to use this feature' AS "feature not available";
4
+
5
+
@@ -0,0 +1,26 @@
1
+ /* Current VACUUM progress as reported by pg_stat_progress_vacuum */
2
+
3
+ SELECT
4
+ a.datname AS database,
5
+ n.nspname AS schema,
6
+ c.relname AS table,
7
+ p.pid,
8
+ p.phase,
9
+ p.heap_blks_total,
10
+ p.heap_blks_scanned,
11
+ p.heap_blks_vacuumed,
12
+ p.index_vacuum_count,
13
+ p.max_dead_tuples,
14
+ p.num_dead_tuples
15
+ FROM
16
+ pg_stat_progress_vacuum p
17
+ LEFT JOIN pg_class c ON p.relid = c.oid
18
+ LEFT JOIN pg_namespace n ON c.relnamespace = n.oid
19
+ LEFT JOIN pg_stat_activity a ON p.pid = a.pid
20
+ ORDER BY
21
+ a.datname,
22
+ n.nspname,
23
+ c.relname,
24
+ p.pid;
25
+
26
+
@@ -0,0 +1,29 @@
1
+ /* Current VACUUM progress as reported by pg_stat_progress_vacuum */
2
+
3
+ SELECT
4
+ a.datname AS database,
5
+ n.nspname AS schema,
6
+ c.relname AS table,
7
+ p.pid,
8
+ p.phase,
9
+ p.heap_blks_total,
10
+ p.heap_blks_scanned,
11
+ p.heap_blks_vacuumed,
12
+ p.index_vacuum_count,
13
+ p.indexes_total,
14
+ p.indexes_processed,
15
+ p.num_dead_item_ids,
16
+ p.dead_tuple_bytes,
17
+ p.max_dead_tuple_bytes
18
+ FROM
19
+ pg_stat_progress_vacuum p
20
+ LEFT JOIN pg_class c ON p.relid = c.oid
21
+ LEFT JOIN pg_namespace n ON c.relnamespace = n.oid
22
+ LEFT JOIN pg_stat_activity a ON p.pid = a.pid
23
+ ORDER BY
24
+ a.datname,
25
+ n.nspname,
26
+ c.relname,
27
+ p.pid;
28
+
29
+
@@ -1,4 +1,4 @@
1
- /* Dead rows and whether an automatic vacuum is expected to be triggered */
1
+ /* Dead rows, new inserts since last VACUUM and whether an automatic vacuum is expected to be triggered */
2
2
 
3
3
  WITH table_opts AS (
4
4
  SELECT
@@ -17,22 +17,52 @@ WITH table_opts AS (
17
17
  WHEN relopts LIKE '%autovacuum_vacuum_scale_factor%'
18
18
  THEN substring(relopts, '.*autovacuum_vacuum_scale_factor=([0-9.]+).*')::real
19
19
  ELSE current_setting('autovacuum_vacuum_scale_factor')::real
20
- END AS autovacuum_vacuum_scale_factor
20
+ END AS autovacuum_vacuum_scale_factor,
21
+ CASE
22
+ WHEN relopts LIKE '%autovacuum_vacuum_insert_threshold%'
23
+ THEN substring(relopts, '.*autovacuum_vacuum_insert_threshold=([0-9.]+).*')::integer
24
+ ELSE current_setting('autovacuum_vacuum_insert_threshold')::integer
25
+ END AS autovacuum_vacuum_insert_threshold,
26
+ CASE
27
+ WHEN relopts LIKE '%autovacuum_vacuum_insert_scale_factor%'
28
+ THEN substring(relopts, '.*autovacuum_vacuum_insert_scale_factor=([0-9.]+).*')::real
29
+ ELSE current_setting('autovacuum_vacuum_insert_scale_factor')::real
30
+ END AS autovacuum_vacuum_insert_scale_factor
21
31
  FROM
22
32
  table_opts
23
33
  )
24
34
  SELECT
25
35
  vacuum_settings.nspname AS schema,
26
36
  vacuum_settings.relname AS table,
27
- to_char(psut.last_vacuum, 'YYYY-MM-DD HH24:MI') AS last_vacuum,
37
+ to_char(psut.last_vacuum, 'YYYY-MM-DD HH24:MI') AS last_manual_vacuum,
38
+ to_char(psut.vacuum_count, '9G999G999G999') AS manual_vacuum_count,
28
39
  to_char(psut.last_autovacuum, 'YYYY-MM-DD HH24:MI') AS last_autovacuum,
40
+ to_char(psut.autovacuum_count, '9G999G999G999') AS autovacuum_count,
29
41
  to_char(pg_class.reltuples, '9G999G999G999') AS rowcount,
30
42
  to_char(psut.n_dead_tup, '9G999G999G999') AS dead_rowcount,
31
- to_char(autovacuum_vacuum_threshold
32
- + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples), '9G999G999G999') AS autovacuum_threshold,
43
+ to_char(
44
+ autovacuum_vacuum_threshold
45
+ + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples),
46
+ '9G999G999G999'
47
+ ) AS dead_tup_autovacuum_threshold,
48
+ to_char(psut.n_ins_since_vacuum, '9G999G999G999') AS n_ins_since_vacuum,
49
+ to_char(
50
+ autovacuum_vacuum_insert_threshold
51
+ + (autovacuum_vacuum_insert_scale_factor::numeric * pg_class.reltuples),
52
+ '9G999G999G999'
53
+ ) AS insert_autovacuum_threshold,
33
54
  CASE
34
- WHEN autovacuum_vacuum_threshold + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples) < psut.n_dead_tup
35
- THEN 'yes'
55
+ WHEN psut.n_dead_tup >= autovacuum_vacuum_threshold
56
+ + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples)
57
+ AND psut.n_ins_since_vacuum >= autovacuum_vacuum_insert_threshold
58
+ + (autovacuum_vacuum_insert_scale_factor::numeric * pg_class.reltuples)
59
+ THEN 'yes (dead_tuples & inserts)'
60
+ WHEN psut.n_dead_tup >= autovacuum_vacuum_threshold
61
+ + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples)
62
+ THEN 'yes (dead_tuples)'
63
+ WHEN psut.n_ins_since_vacuum >= autovacuum_vacuum_insert_threshold
64
+ + (autovacuum_vacuum_insert_scale_factor::numeric * pg_class.reltuples)
65
+ THEN 'yes (inserts)'
36
66
  END AS expect_autovacuum
37
67
  FROM
38
68
  pg_stat_user_tables psut INNER JOIN pg_class ON psut.relid = pg_class.oid
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyPgExtras
4
- VERSION = "5.6.14"
4
+ VERSION = "5.6.15"
5
5
  end
@@ -34,5 +34,41 @@ describe RubyPgExtras::DetectFkColumn do
34
34
  expect(result).to eq(true)
35
35
  end
36
36
  end
37
+
38
+ context "matching table with underscored prefix" do
39
+ let(:column_name) { "account_user_id" }
40
+ let(:tables) { ["account_users", "users"] }
41
+
42
+ it "returns true" do
43
+ expect(result).to eq(true)
44
+ end
45
+ end
46
+
47
+ context "matching table with uncountable noun" do
48
+ let(:column_name) { "fish_id" }
49
+ let(:tables) { ["fish", "users"] }
50
+
51
+ it "returns true" do
52
+ expect(result).to eq(true)
53
+ end
54
+ end
55
+
56
+ context "matching table with mixed-case column name" do
57
+ let(:column_name) { "User_id" }
58
+ let(:tables) { ["users", "posts"] }
59
+
60
+ it "returns true" do
61
+ expect(result).to eq(true)
62
+ end
63
+ end
64
+
65
+ context "matching table with irregular plural already pluralized" do
66
+ let(:column_name) { "people_id" }
67
+ let(:tables) { ["people", "users"] }
68
+
69
+ it "returns true" do
70
+ expect(result).to eq(true)
71
+ end
72
+ end
37
73
  end
38
74
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "ruby-pg-extras"
5
+
6
+ describe RubyPgExtras::IgnoreList do
7
+ describe "#ignored?" do
8
+ it "returns false when ignore_list is nil" do
9
+ list = described_class.new(nil)
10
+ expect(list.ignored?(table: "posts", column_name: "topic_id")).to eq(false)
11
+ end
12
+
13
+ it "supports '*' to ignore everything" do
14
+ list = described_class.new(["*"])
15
+ expect(list.ignored?(table: "posts", column_name: "topic_id")).to eq(true)
16
+ expect(list.ignored?(table: "users", column_name: "customer_id")).to eq(true)
17
+ end
18
+
19
+ it "supports 'table.*' to ignore all columns for a table" do
20
+ list = described_class.new(["posts.*"])
21
+ expect(list.ignored?(table: "posts", column_name: "topic_id")).to eq(true)
22
+ expect(list.ignored?(table: "posts", column_name: "user_id")).to eq(true)
23
+ expect(list.ignored?(table: "users", column_name: "topic_id")).to eq(false)
24
+ end
25
+
26
+ it "supports 'column' to ignore a column name globally" do
27
+ list = described_class.new(["topic_id"])
28
+ expect(list.ignored?(table: "posts", column_name: "topic_id")).to eq(true)
29
+ expect(list.ignored?(table: "users", column_name: "topic_id")).to eq(true)
30
+ expect(list.ignored?(table: "posts", column_name: "user_id")).to eq(false)
31
+ end
32
+
33
+ it "supports 'table.column' to ignore a specific table+column" do
34
+ list = described_class.new(["posts.topic_id"])
35
+ expect(list.ignored?(table: "posts", column_name: "topic_id")).to eq(true)
36
+ expect(list.ignored?(table: "users", column_name: "topic_id")).to eq(false)
37
+ expect(list.ignored?(table: "posts", column_name: "user_id")).to eq(false)
38
+ end
39
+
40
+ it "accepts ignore_list as a comma-separated string" do
41
+ list = described_class.new("posts.topic_id, customer_id")
42
+ expect(list.ignored?(table: "posts", column_name: "topic_id")).to eq(true)
43
+ expect(list.ignored?(table: "users", column_name: "customer_id")).to eq(true)
44
+ expect(list.ignored?(table: "users", column_name: "topic_id")).to eq(false)
45
+ end
46
+
47
+ it "strips whitespace, drops empty entries and de-duplicates" do
48
+ list = described_class.new([" posts.topic_id ", "", "posts.topic_id", " "])
49
+ expect(list.ignored?(table: "posts", column_name: "topic_id")).to eq(true)
50
+ expect(list.ignored?(table: "users", column_name: "topic_id")).to eq(false)
51
+ end
52
+
53
+ it "raises ArgumentError for unsupported ignore_list types" do
54
+ expect { described_class.new(123) }.to raise_error(ArgumentError)
55
+ expect { described_class.new({}) }.to raise_error(ArgumentError)
56
+ end
57
+ end
58
+ end
59
+
60
+
@@ -6,21 +6,51 @@ require "ruby-pg-extras"
6
6
  describe "#missing_fk_constraints" do
7
7
  it "detects missing foreign keys for all tables" do
8
8
  result = RubyPgExtras.missing_fk_constraints(in_format: :hash)
9
- expect(result.size).to eq(2)
10
- expect(result[0]).to eq({
11
- table: "users", column_name: "company_id",
12
- })
13
- expect(result[1]).to eq({
14
- table: "posts", column_name: "topic_id",
15
- })
9
+ expect(result).to match_array([
10
+ { table: "users", column_name: "customer_id" },
11
+ { table: "posts", column_name: "category_id" },
12
+ ])
16
13
  end
17
14
 
18
15
  it "detects missing foreign_keys for a specific table" do
19
16
  result = RubyPgExtras.missing_fk_constraints(args: { table_name: "posts" }, in_format: :hash)
20
17
 
21
- expect(result.size).to eq(1)
22
- expect(result[0]).to eq({
23
- table: "posts", column_name: "topic_id",
24
- })
18
+ expect(result).to eq([
19
+ { table: "posts", column_name: "category_id" },
20
+ ])
21
+ end
22
+
23
+ it "does not report columns that already have foreign key constraints" do
24
+ result = RubyPgExtras.missing_fk_constraints(args: { table_name: "users" }, in_format: :hash)
25
+ expect(result).to eq([
26
+ { table: "users", column_name: "customer_id" },
27
+ ])
28
+ end
29
+
30
+ it "does not report polymorphic associations (<name>_id with <name>_type)" do
31
+ result = RubyPgExtras.missing_fk_constraints(args: { table_name: "events" }, in_format: :hash)
32
+ expect(result).to eq([])
33
+ end
34
+
35
+ it "supports ignoring a specific table+column via args" do
36
+ result = RubyPgExtras.missing_fk_constraints(
37
+ args: { ignore_list: ["posts.category_id"] },
38
+ in_format: :hash
39
+ )
40
+
41
+ expect(result).to eq([
42
+ { table: "users", column_name: "customer_id" },
43
+ ])
44
+ end
45
+
46
+ it "supports ignoring a column name globally via args" do
47
+ result = RubyPgExtras.missing_fk_constraints(
48
+ args: { ignore_list: ["customer_id"] },
49
+ in_format: :hash
50
+ )
51
+
52
+ expect(result).to eq([
53
+ { table: "posts", column_name: "category_id" },
54
+ ])
25
55
  end
26
56
  end
@@ -6,20 +6,16 @@ require "ruby-pg-extras"
6
6
  describe "missing_fk_indexes" do
7
7
  it "detects missing indexes for all tables" do
8
8
  result = RubyPgExtras.missing_fk_indexes(in_format: :hash)
9
- expect(result.size).to eq(2)
10
- expect(result[0]).to eq({
11
- table: "users", column_name: "company_id",
12
- })
13
- expect(result[1]).to eq({
14
- table: "posts", column_name: "topic_id",
15
- })
9
+ expect(result).to match_array([
10
+ { table: "users", column_name: "company_id" },
11
+ { table: "posts", column_name: "topic_id" },
12
+ ])
16
13
  end
17
14
 
18
15
  it "detects missing indexes for specific table" do
19
16
  result = RubyPgExtras.missing_fk_indexes(args: { table_name: "posts" }, in_format: :hash)
20
- expect(result.size).to eq(1)
21
- expect(result[0]).to eq({
22
- table: "posts", column_name: "topic_id",
23
- })
17
+ expect(result).to eq([
18
+ { table: "posts", column_name: "topic_id" },
19
+ ])
24
20
  end
25
21
  end
data/spec/smoke_spec.rb CHANGED
@@ -33,7 +33,7 @@ describe RubyPgExtras do
33
33
  describe "table_foreign_keys" do
34
34
  it "returns a correct fk info" do
35
35
  result = RubyPgExtras.table_foreign_keys(args: { table_name: :posts }, in_format: :hash)
36
- expect(result.size).to eq(1)
36
+ expect(result.size).to eq(2)
37
37
  expect(result[0].keys).to eq(["table_name", "constraint_name", "column_name", "foreign_table_name", "foreign_column_name"])
38
38
  end
39
39
 
@@ -47,7 +47,7 @@ describe RubyPgExtras do
47
47
  describe "table_schema" do
48
48
  it "returns a correct schema" do
49
49
  result = RubyPgExtras.table_schema(args: { table_name: :users }, in_format: :hash)
50
- expect(result.size).to eq(3)
50
+ expect(result.size).to eq(4)
51
51
  expect(result[0].keys).to eq(["column_name", "data_type", "is_nullable", "column_default"])
52
52
  end
53
53
 
data/spec/spec_helper.rb CHANGED
@@ -7,7 +7,6 @@ require_relative "../lib/ruby-pg-extras"
7
7
  pg_version = ENV["PG_VERSION"]
8
8
 
9
9
  PG_PORTS = {
10
- "12" => "5432",
11
10
  "13" => "5433",
12
11
  "14" => "5434",
13
12
  "15" => "5435",
@@ -22,36 +21,65 @@ ENV["DATABASE_URL"] ||= "postgresql://postgres:secret@localhost:#{port}/ruby-pg-
22
21
  RSpec.configure do |config|
23
22
  config.before(:suite) do
24
23
  DB_SCHEMA = <<-SQL
24
+ DROP TABLE IF EXISTS categories;
25
+ DROP TABLE IF EXISTS customers;
26
+ DROP TABLE IF EXISTS events;
27
+ DROP TABLE IF EXISTS subjects;
25
28
  DROP TABLE IF EXISTS posts;
26
29
  DROP TABLE IF EXISTS users;
27
30
  DROP TABLE IF EXISTS topics;
28
31
  DROP TABLE IF EXISTS companies;
29
32
 
33
+ CREATE TABLE companies (
34
+ id SERIAL PRIMARY KEY,
35
+ name VARCHAR(255)
36
+ );
37
+
30
38
  CREATE TABLE users (
31
39
  id SERIAL PRIMARY KEY,
32
40
  email VARCHAR(255),
33
- company_id INTEGER
41
+ company_id INTEGER,
42
+ customer_id INTEGER,
43
+ CONSTRAINT fk_users_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
44
+ );
45
+
46
+ CREATE TABLE topics (
47
+ id SERIAL PRIMARY KEY,
48
+ title VARCHAR(255)
34
49
  );
35
50
 
36
51
  CREATE TABLE posts (
37
52
  id SERIAL PRIMARY KEY,
38
53
  user_id INTEGER NOT NULL,
39
54
  topic_id INTEGER,
55
+ category_id INTEGER,
40
56
  external_id INTEGER,
41
57
  title VARCHAR(255),
42
- CONSTRAINT fk_posts_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
58
+ CONSTRAINT fk_posts_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
59
+ CONSTRAINT fk_posts_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
43
60
  );
44
61
 
45
- CREATE TABLE topics (
62
+ CREATE TABLE customers (
46
63
  id SERIAL PRIMARY KEY,
47
- title VARCHAR(255)
64
+ name VARCHAR(255)
48
65
  );
49
66
 
50
- CREATE TABLE companies (
67
+ CREATE TABLE categories (
68
+ id SERIAL PRIMARY KEY,
69
+ name VARCHAR(255)
70
+ );
71
+
72
+ CREATE TABLE subjects (
51
73
  id SERIAL PRIMARY KEY,
52
74
  name VARCHAR(255)
53
75
  );
54
76
 
77
+ CREATE TABLE events (
78
+ id SERIAL PRIMARY KEY,
79
+ subject_id INTEGER,
80
+ subject_type VARCHAR(255)
81
+ );
82
+
55
83
  CREATE INDEX index_posts_on_user_id ON posts(user_id, topic_id);
56
84
  SQL
57
85
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-pg-extras
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.6.14
4
+ version: 5.6.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - pawurb
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-26 00:00:00.000000000 Z
11
+ date: 2025-12-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg
@@ -114,12 +114,14 @@ files:
114
114
  - lib/ruby_pg_extras/detect_fk_column.rb
115
115
  - lib/ruby_pg_extras/diagnose_data.rb
116
116
  - lib/ruby_pg_extras/diagnose_print.rb
117
+ - lib/ruby_pg_extras/ignore_list.rb
117
118
  - lib/ruby_pg_extras/index_info.rb
118
119
  - lib/ruby_pg_extras/index_info_print.rb
119
120
  - lib/ruby_pg_extras/missing_fk_constraints.rb
120
121
  - lib/ruby_pg_extras/missing_fk_indexes.rb
121
122
  - lib/ruby_pg_extras/queries/add_extensions.sql
122
123
  - lib/ruby_pg_extras/queries/all_locks.sql
124
+ - lib/ruby_pg_extras/queries/analyze_progress.sql
123
125
  - lib/ruby_pg_extras/queries/bloat.sql
124
126
  - lib/ruby_pg_extras/queries/blocking.sql
125
127
  - lib/ruby_pg_extras/queries/buffercache_stats.sql
@@ -162,6 +164,10 @@ files:
162
164
  - lib/ruby_pg_extras/queries/total_index_size.sql
163
165
  - lib/ruby_pg_extras/queries/total_table_size.sql
164
166
  - lib/ruby_pg_extras/queries/unused_indexes.sql
167
+ - lib/ruby_pg_extras/queries/vacuum_io_stats.sql
168
+ - lib/ruby_pg_extras/queries/vacuum_io_stats_legacy.sql
169
+ - lib/ruby_pg_extras/queries/vacuum_progress.sql
170
+ - lib/ruby_pg_extras/queries/vacuum_progress_17.sql
165
171
  - lib/ruby_pg_extras/queries/vacuum_stats.sql
166
172
  - lib/ruby_pg_extras/size_parser.rb
167
173
  - lib/ruby_pg_extras/table_info.rb
@@ -172,6 +178,7 @@ files:
172
178
  - spec/detect_fk_column_spec.rb
173
179
  - spec/diagnose_data_spec.rb
174
180
  - spec/diagnose_print_spec.rb
181
+ - spec/ignore_list_spec.rb
175
182
  - spec/index_info_spec.rb
176
183
  - spec/missing_fk_constraints_spec.rb
177
184
  - spec/missing_fk_indexes_spec.rb
@@ -207,6 +214,7 @@ test_files:
207
214
  - spec/detect_fk_column_spec.rb
208
215
  - spec/diagnose_data_spec.rb
209
216
  - spec/diagnose_print_spec.rb
217
+ - spec/ignore_list_spec.rb
210
218
  - spec/index_info_spec.rb
211
219
  - spec/missing_fk_constraints_spec.rb
212
220
  - spec/missing_fk_indexes_spec.rb