ruby-pg-extras 5.6.18 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0de82c2381f728d09ea04bad7af74d9fce31ab7f73c5ffa6fa1b52741d0e25bf
4
- data.tar.gz: a24730c633b7a9747b074d574d701aa5fa4f2cc1f764c7ce684c5934d3ab29d4
3
+ metadata.gz: a23e8afe840227303cb0b15ddd0c795f94dd4004b45a6ca8239da7724b8ffe3d
4
+ data.tar.gz: fd6d80e6150e1a74ae8d360553dba71de05f9241086894038669f412a39c2376
5
5
  SHA512:
6
- metadata.gz: 275eee2d2e060752bbde7bc2be03d3cbb1a9af878b1fcc909016613eaa5ad7ccd00a24b59e0238f7e981f692bf984971a1813004109ad31cbd98a6329da93962
7
- data.tar.gz: 70b3e1d3b5123bf7e472c1122b1471742aae4693193854f4825ed10c3fae519b4c2313d889fcaded7e50c7b9db7d387f72af26d46404027cccde62f2a41bde15
6
+ metadata.gz: 2cc3296eb55474f029b0189ab1e7356a209690da28c97ef63898f55f16adaf544d940fd553454e4172e0a46d8a3bee4722e4ee6e56109cb686a969161f137d52
7
+ data.tar.gz: 1b06eab3cd36804cced29754d5506386d2a4e7eefd445d7b4edc3ac6737b04ace5e27f0aa7913ff5a8ed8e796beda5766cd6ce9a1f9c31172b9186505fba3bfa
data/README.md CHANGED
@@ -120,6 +120,8 @@ Keep reading to learn about methods that `diagnose` uses under the hood.
120
120
 
121
121
  This method lists **actual foreign key columns** (based on existing foreign key constraints) which don't have a supporting index. It's recommended to always index foreign key columns because they are commonly used for lookups and join conditions.
122
122
 
123
+ Composite indexes only support a foreign key when the foreign key column is the leftmost key column. For example, an index on `(user_id, topic_id)` supports `user_id` lookups, but `topic_id` still needs its own index or an index that starts with `topic_id`. Partial indexes are ignored for this check unless their predicate is exactly the foreign key column `IS NOT NULL`, because foreign key checks only need non-null values.
124
+
123
125
  You can add indexes on the columns returned by this query and later check if they are receiving scans using the [unused_indexes method](#unused_indexes). Please remember that each index decreases write performance and autovacuuming overhead, so be careful when adding multiple indexes to often updated tables.
124
126
 
125
127
  ```ruby
@@ -225,20 +227,21 @@ RubyPgExtras.table_foreign_keys(args: { table_name: "users" })
225
227
 
226
228
  ### `index_info`
227
229
 
228
- This method returns summary info about database indexes. You can check index size, how often it is used and what percentage of its total size are NULL values. Like the previous method, it aggregates data from other helper methods in an easy-to-digest format.
230
+ This method returns summary info about database indexes. You can check index size, how often it is used, whether it is unique/primary/partial, what predicate it uses, and what percentage of its total size are NULL values. Like the previous method, it aggregates data from other helper methods in an easy-to-digest format.
231
+
232
+ Index columns are read from PostgreSQL catalog metadata instead of parsing `CREATE INDEX` strings. This keeps expression indexes, operator classes, sort order, collations, partial predicates, and `INCLUDE` columns represented correctly. `Columns` shows the display form of key columns, while `Included columns` shows non-key columns added with `INCLUDE (...)`.
229
233
 
230
234
  ```ruby
231
235
 
232
236
  RubyPgExtras.index_info(args: { table_name: "users" })
233
237
 
234
- | Index name | Table name | Columns | Index size | Index scans | Null frac |
235
- +-------------------------------+------------+----------------+------------+-------------+-----------+
236
- | users_pkey | users | id | 1152 kB | 163007 | 0.00% |
237
- | index_users_on_slack_id | users | slack_id | 1080 kB | 258870 | 0.00% |
238
- | index_users_on_team_id | users | team_id | 816 kB | 70962 | 0.00% |
239
- | index_users_on_uuid | users | uuid | 1032 kB | 0 | 0.00% |
240
- | index_users_on_block_uuid | users | block_uuid | 776 kB | 19502 | 100.00% |
241
- | index_users_on_api_auth_token | users | api_auth_token | 1744 kB | 156 | 0.00% |
238
+ | Index name | Table name | Columns | Included columns | Method | Unique | Primary | Partial | Predicate | Index size | Index scans | Null frac |
239
+ +------------------------------+------------+----------------------------+------------------+--------+--------+---------+---------+--------------------+------------+-------------+-----------+
240
+ | users_pkey | users | id | | btree | true | true | false | | 1152 kB | 163007 | 0.00% |
241
+ | index_users_on_email_pattern | users | email text_pattern_ops | | btree | false | false | false | | 1080 kB | 258870 | 0.00% |
242
+ | index_users_on_created_at | users | created_at DESC NULLS LAST | | btree | false | false | false | | 816 kB | 70962 | 0.00% |
243
+ | index_users_on_active_email | users | email | | btree | false | false | true | deleted_at IS NULL | 1032 kB | 0 | 0.00% |
244
+ | index_users_on_email_include | users | email | id | btree | false | false | false | | 776 kB | 19502 | 100.00% |
242
245
 
243
246
  ```
244
247
 
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
1
5
  module RubyPgExtras
2
6
  class IndexInfo
3
7
  def self.call(table_name = nil)
@@ -19,7 +23,17 @@ module RubyPgExtras
19
23
  {
20
24
  index_name: index_name,
21
25
  table_name: index_data.fetch("tablename"),
22
- columns: index_data.fetch("columns").split(",").map(&:strip),
26
+ # Prefer JSON arrays from indexes.sql so expressions containing commas are not split incorrectly.
27
+ columns: array_value(index_data, json_key: "columns_json", fallback_key: "columns"),
28
+ # Clean key column names are used separately from display columns that may include opclasses/order/collations.
29
+ key_columns: array_value(index_data, json_key: "key_column_names", fallback_key: "key_columns"),
30
+ # INCLUDE columns are stored separately because they are not part of the index search key.
31
+ included_columns: array_value(index_data, json_key: "included_columns_json", fallback_key: "included_columns"),
32
+ index_method: index_data.fetch("index_method", "N/A"),
33
+ unique: boolean_value(index_data.fetch("is_unique", false)),
34
+ primary: boolean_value(index_data.fetch("is_primary", false)),
35
+ partial: boolean_value(index_data.fetch("is_partial", false)),
36
+ predicate: index_data.fetch("predicate", nil),
23
37
  index_size: index_size_data.find do |el|
24
38
  el.fetch("name") == index_name
25
39
  end.fetch("size", "N/A"),
@@ -54,6 +68,22 @@ module RubyPgExtras
54
68
 
55
69
  private
56
70
 
71
+ def array_value(index_data, json_key:, fallback_key:)
72
+ # Older/stubbed callers may only provide comma-separated fields, so keep a fallback path.
73
+ if index_data.key?(json_key) && index_data.fetch(json_key) != nil
74
+ JSON.parse(index_data.fetch(json_key)).compact
75
+ elsif index_data.key?(fallback_key) && index_data.fetch(fallback_key) != nil
76
+ index_data.fetch(fallback_key).split(",").map(&:strip).reject(&:empty?)
77
+ else
78
+ []
79
+ end
80
+ end
81
+
82
+ def boolean_value(value)
83
+ # PG::Result returns booleans as "t"/"f"; specs may provide real Ruby booleans.
84
+ [true, "t", "true"].include?(value)
85
+ end
86
+
57
87
  def query_module
58
88
  RubyPgExtras
59
89
  end
@@ -14,6 +14,13 @@ module RubyPgExtras
14
14
  el.fetch(:index_name),
15
15
  el.fetch(:table_name),
16
16
  el.fetch(:columns).join(", "),
17
+ # Included columns are displayed separately because they do not affect the index key order.
18
+ el.fetch(:included_columns, []).join(", "),
19
+ el.fetch(:index_method, "N/A"),
20
+ el.fetch(:unique, false),
21
+ el.fetch(:primary, false),
22
+ el.fetch(:partial, false),
23
+ el.fetch(:predicate, nil),
17
24
  el.fetch(:index_size),
18
25
  el.fetch(:index_scans),
19
26
  el.fetch(:null_frac),
@@ -25,6 +32,12 @@ module RubyPgExtras
25
32
  "Index name",
26
33
  "Table name",
27
34
  "Columns",
35
+ "Included columns",
36
+ "Method",
37
+ "Unique",
38
+ "Primary",
39
+ "Partial",
40
+ "Predicate",
28
41
  "Index size",
29
42
  "Index scans",
30
43
  "Null frac",
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module RubyPgExtras
4
6
  class MissingFkIndexes
5
7
  # ignore_list: array (or comma-separated string) of entries like:
@@ -33,7 +35,7 @@ module RubyPgExtras
33
35
  # Skip columns explicitly excluded via ignore list.
34
36
  next if ignore_list_matcher.ignored?(table: table, column_name: column_name)
35
37
 
36
- if index_info.none? { |row| row.fetch("columns").split(",").first == column_name }
38
+ if index_info.none? { |row| usable_index?(row, column_name: column_name) }
37
39
  agg.push(
38
40
  {
39
41
  table: table,
@@ -49,6 +51,48 @@ module RubyPgExtras
49
51
 
50
52
  private
51
53
 
54
+ def usable_index?(row, column_name:)
55
+ # PostgreSQL can use a composite index for FK checks only when the FK column is leftmost.
56
+ return false unless first_key_column(row) == column_name
57
+
58
+ # A full index on the FK column is always usable once the leftmost-column check passes.
59
+ return true unless boolean_value(row.fetch("is_partial", false))
60
+
61
+ # Nullable FK checks only need non-null values, so this partial index is still usable.
62
+ not_null_predicate_on_column?(row.fetch("predicate", nil), column_name: column_name)
63
+ end
64
+
65
+ def first_key_column(row)
66
+ # New index metadata exposes clean key column names; fall back for legacy/stubbed rows.
67
+ if row.key?("key_column_names") && row.fetch("key_column_names") != nil
68
+ JSON.parse(row.fetch("key_column_names")).first
69
+ elsif row.key?("key_columns") && row.fetch("key_columns") != nil
70
+ row.fetch("key_columns").split(",").map(&:strip).first
71
+ else
72
+ row.fetch("columns").split(",").map(&:strip).first
73
+ end
74
+ end
75
+
76
+ def not_null_predicate_on_column?(predicate, column_name:)
77
+ normalized_predicate = normalized_predicate(predicate)
78
+
79
+ # Keep this intentionally narrow: only `fk_column IS NOT NULL` guarantees FK coverage.
80
+ normalized_predicate.match?(/\A"?#{Regexp.escape(column_name)}"?\s+IS\s+NOT\s+NULL\z/i)
81
+ end
82
+
83
+ def normalized_predicate(predicate)
84
+ predicate.to_s.strip.then do |value|
85
+ # pg_get_expr can wrap simple predicates in parentheses, e.g. `(topic_id IS NOT NULL)`.
86
+ value = value[1...-1].strip while value.start_with?("(") && value.end_with?(")")
87
+ value
88
+ end
89
+ end
90
+
91
+ def boolean_value(value)
92
+ # PG::Result returns booleans as "t"/"f"; specs may provide real Ruby booleans.
93
+ [true, "t", "true"].include?(value)
94
+ end
95
+
52
96
  def query_module
53
97
  RubyPgExtras
54
98
  end
@@ -1,9 +1,79 @@
1
1
  /* List all the indexes with their corresponding tables and columns. */
2
2
 
3
3
  SELECT
4
- schemaname,
5
- indexname,
6
- tablename,
7
- rtrim(split_part(split_part(indexdef, ' WHERE', 1), '(', 2), ')') as columns
8
- FROM pg_indexes
9
- where tablename in (select relname from pg_statio_user_tables);
4
+ n.nspname AS schemaname,
5
+ i.relname AS indexname,
6
+ t.relname AS tablename,
7
+ string_agg(key_column.display_column, ', ' ORDER BY key_column.position) AS columns,
8
+ json_agg(key_column.display_column ORDER BY key_column.position)::text AS columns_json,
9
+ COALESCE(
10
+ string_agg(key_column.attname, ', ' ORDER BY key_column.position) FILTER (WHERE key_column.attname IS NOT NULL),
11
+ ''
12
+ ) AS key_columns,
13
+ json_agg(key_column.attname ORDER BY key_column.position)::text AS key_column_names,
14
+ COALESCE(included_columns.columns, '') AS included_columns,
15
+ COALESCE(included_columns.columns_json, '[]') AS included_columns_json,
16
+ am.amname AS index_method,
17
+ ix.indisunique AS is_unique,
18
+ ix.indisprimary AS is_primary,
19
+ (ix.indpred IS NOT NULL) AS is_partial,
20
+ pg_get_expr(ix.indpred, ix.indrelid) AS predicate
21
+ FROM pg_index ix
22
+ JOIN pg_class i ON i.oid = ix.indexrelid
23
+ JOIN pg_class t ON t.oid = ix.indrelid
24
+ JOIN pg_namespace n ON n.oid = t.relnamespace
25
+ JOIN pg_am am ON am.oid = i.relam
26
+ -- Expand each index into one row per key position so column/opclass/collation/options stay aligned.
27
+ CROSS JOIN LATERAL (
28
+ SELECT
29
+ key_position.position,
30
+ a.attname,
31
+ concat_ws(
32
+ ' ',
33
+ pg_get_indexdef(i.oid, key_position.position, true),
34
+ CASE
35
+ WHEN c.oid IS NOT NULL AND c.collname <> 'default' AND (a.attcollation IS NULL OR c.oid <> a.attcollation)
36
+ THEN 'COLLATE ' || quote_ident(c.collname)
37
+ END,
38
+ CASE
39
+ WHEN oc.oid IS NOT NULL AND oc.opcdefault = false THEN oc.opcname
40
+ END,
41
+ CASE
42
+ WHEN (index_option.option_value & 1) = 1 THEN 'DESC'
43
+ END,
44
+ CASE
45
+ WHEN (index_option.option_value & 2) = 2 THEN 'NULLS FIRST'
46
+ WHEN (index_option.option_value & 1) = 1 THEN 'NULLS LAST'
47
+ END
48
+ ) AS display_column
49
+ FROM generate_series(1, ix.indnkeyatts) AS key_position(position)
50
+ LEFT JOIN pg_attribute a
51
+ ON a.attrelid = t.oid
52
+ AND a.attnum = (string_to_array(ix.indkey::text, ' '))[key_position.position]::int
53
+ LEFT JOIN pg_opclass oc
54
+ ON oc.oid = (string_to_array(ix.indclass::text, ' '))[key_position.position]::oid
55
+ LEFT JOIN pg_collation c
56
+ ON c.oid = (string_to_array(ix.indcollation::text, ' '))[key_position.position]::oid
57
+ CROSS JOIN LATERAL (
58
+ SELECT COALESCE((string_to_array(ix.indoption::text, ' '))[key_position.position]::int, 0) AS option_value
59
+ ) index_option
60
+ ) key_column
61
+ -- INCLUDE columns are stored after key columns in pg_index and must be reported separately.
62
+ LEFT JOIN LATERAL (
63
+ SELECT
64
+ string_agg(pg_get_indexdef(i.oid, included_position.position, true), ', ' ORDER BY included_position.position) AS columns,
65
+ json_agg(pg_get_indexdef(i.oid, included_position.position, true) ORDER BY included_position.position)::text AS columns_json
66
+ FROM generate_series(ix.indnkeyatts + 1, ix.indnatts) AS included_position(position)
67
+ ) included_columns ON true
68
+ WHERE t.oid IN (SELECT relid FROM pg_statio_user_tables)
69
+ GROUP BY
70
+ n.nspname,
71
+ i.relname,
72
+ t.relname,
73
+ included_columns.columns,
74
+ included_columns.columns_json,
75
+ am.amname,
76
+ ix.indisunique,
77
+ ix.indisprimary,
78
+ ix.indpred,
79
+ ix.indrelid;
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyPgExtras
4
- VERSION = "5.6.18"
4
+ VERSION = "5.7.0"
5
5
  end
@@ -12,8 +12,38 @@ describe RubyPgExtras::IndexInfo do
12
12
  before do
13
13
  expect(RubyPgExtras).to receive(:indexes) {
14
14
  [
15
- { "schemaname" => "public", "indexname" => "index_users_on_api_auth_token", "tablename" => "users", "columns" => "api_auth_token, column2" },
16
- { "schemaname" => "public", "indexname" => "index_teams_on_slack_id", "tablename" => "teams", "columns" => "slack_id" },
15
+ {
16
+ "schemaname" => "public",
17
+ "indexname" => "index_users_on_api_auth_token",
18
+ "tablename" => "users",
19
+ "columns" => "api_auth_token, column2",
20
+ "columns_json" => '["api_auth_token","column2"]',
21
+ "key_columns" => "api_auth_token, column2",
22
+ "key_column_names" => '["api_auth_token","column2"]',
23
+ "included_columns" => "",
24
+ "included_columns_json" => "[]",
25
+ "index_method" => "btree",
26
+ "is_unique" => "f",
27
+ "is_primary" => "f",
28
+ "is_partial" => "f",
29
+ "predicate" => nil,
30
+ },
31
+ {
32
+ "schemaname" => "public",
33
+ "indexname" => "index_teams_on_slack_id",
34
+ "tablename" => "teams",
35
+ "columns" => "slack_id",
36
+ "columns_json" => '["slack_id"]',
37
+ "key_columns" => "slack_id",
38
+ "key_column_names" => '["slack_id"]',
39
+ "included_columns" => "external_id",
40
+ "included_columns_json" => '["external_id"]',
41
+ "index_method" => "btree",
42
+ "is_unique" => "t",
43
+ "is_primary" => "f",
44
+ "is_partial" => "t",
45
+ "predicate" => "external_id IS NOT NULL",
46
+ },
17
47
  ]
18
48
  }
19
49
 
@@ -43,6 +73,23 @@ describe RubyPgExtras::IndexInfo do
43
73
  RubyPgExtras::IndexInfoPrint.call(result)
44
74
  }.not_to raise_error
45
75
  end
76
+
77
+ it "returns structured index metadata" do
78
+ # The service keeps display columns and logical key columns as separate arrays.
79
+ expect(result).to include(
80
+ include(
81
+ index_name: "index_teams_on_slack_id",
82
+ columns: ["slack_id"],
83
+ key_columns: ["slack_id"],
84
+ included_columns: ["external_id"],
85
+ index_method: "btree",
86
+ unique: true,
87
+ primary: false,
88
+ partial: true,
89
+ predicate: "external_id IS NOT NULL",
90
+ )
91
+ )
92
+ end
46
93
  end
47
94
 
48
95
  context "real data" do
@@ -51,6 +98,68 @@ describe RubyPgExtras::IndexInfo do
51
98
  RubyPgExtras::IndexInfoPrint.call(result)
52
99
  }.not_to raise_error
53
100
  end
101
+
102
+ it "handles expression indexes without splitting on parentheses" do
103
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_lower_email" }
104
+
105
+ # Expression indexes can contain nested parentheses, so DDL string splitting is unsafe.
106
+ expect(index.fetch(:columns).first).to include("lower")
107
+ end
108
+
109
+ it "keeps partial index predicates separate from columns" do
110
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_email_partial" }
111
+
112
+ # Partial index predicates describe row coverage and should not be mixed into columns.
113
+ expect(index).to include(
114
+ columns: ["email"],
115
+ key_columns: ["email"],
116
+ partial: true,
117
+ )
118
+ expect(index.fetch(:predicate)).to include("customer_id IS NOT NULL")
119
+ end
120
+
121
+ it "keeps operator classes in display columns and clean key column names" do
122
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_email_pattern" }
123
+
124
+ # Opclasses affect index behavior, but FK checks still need the plain column name.
125
+ expect(index.fetch(:columns)).to eq(["email text_pattern_ops"])
126
+ expect(index.fetch(:key_columns)).to eq(["email"])
127
+ end
128
+
129
+ it "keeps sort order in display columns and clean key column names" do
130
+ index = result.find { |el| el.fetch(:index_name) == "index_posts_on_external_id_desc" }
131
+
132
+ # Sort/null options are display metadata, not part of the logical column name.
133
+ expect(index.fetch(:columns)).to eq(["external_id DESC NULLS LAST"])
134
+ expect(index.fetch(:key_columns)).to eq(["external_id"])
135
+ end
136
+
137
+ it "keeps per-position options on composite indexes" do
138
+ index = result.find { |el| el.fetch(:index_name) == "index_posts_on_user_id_desc_and_title_pattern" }
139
+
140
+ # Each position can have distinct options, so the query must pair metadata by position.
141
+ expect(index.fetch(:columns)).to eq(["user_id DESC NULLS FIRST", 'title COLLATE "C" text_pattern_ops'])
142
+ expect(index.fetch(:key_columns)).to eq(["user_id", "title"])
143
+ end
144
+
145
+ it "keeps collations in display columns and clean key column names" do
146
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_email_collate_c" }
147
+
148
+ # Explicit non-default collations should remain visible in index_info output.
149
+ expect(index.fetch(:columns).first).to include('COLLATE "C"')
150
+ expect(index.fetch(:key_columns)).to eq(["email"])
151
+ end
152
+
153
+ it "keeps included columns separate from key columns" do
154
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_email_include_customer_id" }
155
+
156
+ # INCLUDE columns can support index-only scans but are not search key columns.
157
+ expect(index).to include(
158
+ columns: ["email"],
159
+ key_columns: ["email"],
160
+ included_columns: ["customer_id"],
161
+ )
162
+ end
54
163
  end
55
164
  end
56
165
  end
@@ -7,13 +7,18 @@ 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
9
  expect(result).to match_array([
10
- { table: "users", column_name: "company_id" },
10
+ { table: "expression_not_null_partial_indexed_posts", column_name: "topic_id" },
11
+ { table: "expression_indexed_posts", column_name: "topic_id" },
12
+ { table: "null_partial_indexed_posts", column_name: "topic_id" },
13
+ { table: "partial_indexed_posts", column_name: "topic_id" },
11
14
  { table: "posts", column_name: "topic_id" },
12
15
  ])
13
16
  end
14
17
 
15
- it "detects missing indexes for specific table" do
18
+ it "detects foreign keys that are only indexed after another column" do
16
19
  result = RubyPgExtras.missing_fk_indexes(args: { table_name: "posts" }, in_format: :hash)
20
+
21
+ # posts.topic_id exists in index_posts_on_user_id, but not as the leftmost key column.
17
22
  expect(result).to eq([
18
23
  { table: "posts", column_name: "topic_id" },
19
24
  ])
@@ -25,8 +30,11 @@ describe "missing_fk_indexes" do
25
30
  in_format: :hash
26
31
  )
27
32
 
28
- expect(result).to eq([
29
- { table: "users", column_name: "company_id" },
33
+ expect(result).to match_array([
34
+ { table: "expression_not_null_partial_indexed_posts", column_name: "topic_id" },
35
+ { table: "expression_indexed_posts", column_name: "topic_id" },
36
+ { table: "null_partial_indexed_posts", column_name: "topic_id" },
37
+ { table: "partial_indexed_posts", column_name: "topic_id" },
30
38
  ])
31
39
  end
32
40
 
@@ -36,8 +44,108 @@ describe "missing_fk_indexes" do
36
44
  in_format: :hash
37
45
  )
38
46
 
39
- expect(result).to eq([
47
+ expect(result).to match_array([
48
+ { table: "expression_not_null_partial_indexed_posts", column_name: "topic_id" },
49
+ { table: "expression_indexed_posts", column_name: "topic_id" },
50
+ { table: "null_partial_indexed_posts", column_name: "topic_id" },
51
+ { table: "partial_indexed_posts", column_name: "topic_id" },
40
52
  { table: "posts", column_name: "topic_id" },
41
53
  ])
42
54
  end
55
+
56
+ it "does not flag foreign keys covered by sorted indexes" do
57
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "users" }, in_format: :hash)
58
+
59
+ # users.company_id is leftmost in a sorted index, so it still supports FK checks.
60
+ expect(result).to eq([])
61
+ end
62
+
63
+ it "detects foreign keys covered only by an expression index" do
64
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "expression_indexed_posts" }, in_format: :hash)
65
+
66
+ # Expression indexes do not support raw FK lookups such as WHERE topic_id = ?.
67
+ expect(result).to eq([
68
+ { table: "expression_indexed_posts", column_name: "topic_id" },
69
+ ])
70
+ end
71
+
72
+ it "detects foreign keys covered by an expression index with a not-null predicate" do
73
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "expression_not_null_partial_indexed_posts" }, in_format: :hash)
74
+
75
+ # The predicate is compatible, but the index key is topic_id::text rather than raw topic_id.
76
+ expect(result).to eq([
77
+ { table: "expression_not_null_partial_indexed_posts", column_name: "topic_id" },
78
+ ])
79
+ end
80
+
81
+ it "detects foreign keys covered only by a partial index with an unrelated predicate" do
82
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "partial_indexed_posts" }, in_format: :hash)
83
+
84
+ # WHERE id > 0 is unrelated to topic_id and does not guarantee coverage for every FK value.
85
+ expect(result).to eq([
86
+ { table: "partial_indexed_posts", column_name: "topic_id" },
87
+ ])
88
+ end
89
+
90
+ it "does not flag foreign keys covered by a not-null partial index" do
91
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "not_null_partial_indexed_posts" }, in_format: :hash)
92
+
93
+ # FK checks only need non-null values, so WHERE topic_id IS NOT NULL still covers them.
94
+ expect(result).to eq([])
95
+ end
96
+
97
+ it "normalizes nested parentheses around not-null predicates" do
98
+ allow(RubyPgExtras).to receive(:indexes).and_return([
99
+ {
100
+ "tablename" => "stubbed_posts",
101
+ "columns" => "topic_id",
102
+ "key_column_names" => '["topic_id"]',
103
+ "is_partial" => "t",
104
+ "predicate" => "((topic_id IS NOT NULL))",
105
+ },
106
+ ])
107
+ allow(RubyPgExtras).to receive(:foreign_keys).and_return([
108
+ { "table_name" => "stubbed_posts", "column_name" => "topic_id" },
109
+ ])
110
+
111
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "stubbed_posts" }, in_format: :hash)
112
+
113
+ expect(result).to eq([])
114
+ end
115
+
116
+ it "detects foreign keys covered only by a null partial index" do
117
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "null_partial_indexed_posts" }, in_format: :hash)
118
+
119
+ # WHERE topic_id IS NULL cannot support FK lookups for concrete topic_id values.
120
+ expect(result).to eq([
121
+ { table: "null_partial_indexed_posts", column_name: "topic_id" },
122
+ ])
123
+ end
124
+
125
+ it "does not flag foreign keys covered by indexes with included columns" do
126
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "included_indexed_posts" }, in_format: :hash)
127
+
128
+ # INCLUDE columns are ignored for key matching; topic_id is still the leftmost key column.
129
+ expect(result).to eq([])
130
+ end
131
+
132
+ it "does not flag foreign keys covered by indexes with operator classes" do
133
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "opclass_indexed_codes" }, in_format: :hash)
134
+
135
+ # The display column includes text_pattern_ops, but the logical key column remains code.
136
+ expect(result).to eq([])
137
+ end
138
+
139
+ it "does not flag foreign keys covered by indexes with collations" do
140
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "collated_indexed_codes" }, in_format: :hash)
141
+
142
+ # The display column includes COLLATE "C", but the logical key column remains code.
143
+ expect(result).to eq([])
144
+ end
145
+
146
+ it "does not flag foreign keys covered by sorted indexes on their own table" do
147
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "sorted_indexed_posts" }, in_format: :hash)
148
+
149
+ expect(result).to eq([])
150
+ end
43
151
  end
data/spec/spec_helper.rb CHANGED
@@ -26,7 +26,17 @@ DROP TABLE IF EXISTS categories;
26
26
  DROP TABLE IF EXISTS customers;
27
27
  DROP TABLE IF EXISTS events;
28
28
  DROP TABLE IF EXISTS subjects;
29
+ DROP TABLE IF EXISTS collated_indexed_codes;
30
+ DROP TABLE IF EXISTS opclass_indexed_codes;
31
+ DROP TABLE IF EXISTS included_indexed_posts;
32
+ DROP TABLE IF EXISTS sorted_indexed_posts;
33
+ DROP TABLE IF EXISTS null_partial_indexed_posts;
34
+ DROP TABLE IF EXISTS not_null_partial_indexed_posts;
35
+ DROP TABLE IF EXISTS partial_indexed_posts;
36
+ DROP TABLE IF EXISTS expression_not_null_partial_indexed_posts;
37
+ DROP TABLE IF EXISTS expression_indexed_posts;
29
38
  DROP TABLE IF EXISTS posts;
39
+ DROP TABLE IF EXISTS codes;
30
40
  DROP TABLE IF EXISTS users;
31
41
  DROP TABLE IF EXISTS topics;
32
42
  DROP TABLE IF EXISTS companies;
@@ -49,6 +59,10 @@ CREATE TABLE topics (
49
59
  title VARCHAR(255)
50
60
  );
51
61
 
62
+ CREATE TABLE codes (
63
+ code TEXT PRIMARY KEY
64
+ );
65
+
52
66
  CREATE TABLE posts (
53
67
  id SERIAL PRIMARY KEY,
54
68
  user_id INTEGER NOT NULL,
@@ -60,6 +74,60 @@ CREATE TABLE posts (
60
74
  CONSTRAINT fk_posts_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
61
75
  );
62
76
 
77
+ CREATE TABLE expression_indexed_posts (
78
+ id SERIAL PRIMARY KEY,
79
+ topic_id INTEGER,
80
+ CONSTRAINT fk_expression_indexed_posts_topic FOREIGN KEY (topic_id) REFERENCES topics(id)
81
+ );
82
+
83
+ CREATE TABLE expression_not_null_partial_indexed_posts (
84
+ id SERIAL PRIMARY KEY,
85
+ topic_id INTEGER,
86
+ CONSTRAINT fk_expression_not_null_partial_indexed_posts_topic FOREIGN KEY (topic_id) REFERENCES topics(id)
87
+ );
88
+
89
+ CREATE TABLE partial_indexed_posts (
90
+ id SERIAL PRIMARY KEY,
91
+ topic_id INTEGER,
92
+ CONSTRAINT fk_partial_indexed_posts_topic FOREIGN KEY (topic_id) REFERENCES topics(id)
93
+ );
94
+
95
+ CREATE TABLE sorted_indexed_posts (
96
+ id SERIAL PRIMARY KEY,
97
+ topic_id INTEGER,
98
+ CONSTRAINT fk_sorted_indexed_posts_topic FOREIGN KEY (topic_id) REFERENCES topics(id)
99
+ );
100
+
101
+ CREATE TABLE not_null_partial_indexed_posts (
102
+ id SERIAL PRIMARY KEY,
103
+ topic_id INTEGER,
104
+ CONSTRAINT fk_not_null_partial_indexed_posts_topic FOREIGN KEY (topic_id) REFERENCES topics(id)
105
+ );
106
+
107
+ CREATE TABLE null_partial_indexed_posts (
108
+ id SERIAL PRIMARY KEY,
109
+ topic_id INTEGER,
110
+ CONSTRAINT fk_null_partial_indexed_posts_topic FOREIGN KEY (topic_id) REFERENCES topics(id)
111
+ );
112
+
113
+ CREATE TABLE included_indexed_posts (
114
+ id SERIAL PRIMARY KEY,
115
+ topic_id INTEGER,
116
+ CONSTRAINT fk_included_indexed_posts_topic FOREIGN KEY (topic_id) REFERENCES topics(id)
117
+ );
118
+
119
+ CREATE TABLE opclass_indexed_codes (
120
+ id SERIAL PRIMARY KEY,
121
+ code TEXT,
122
+ CONSTRAINT fk_opclass_indexed_codes_code FOREIGN KEY (code) REFERENCES codes(code)
123
+ );
124
+
125
+ CREATE TABLE collated_indexed_codes (
126
+ id SERIAL PRIMARY KEY,
127
+ code TEXT,
128
+ CONSTRAINT fk_collated_indexed_codes_code FOREIGN KEY (code) REFERENCES codes(code)
129
+ );
130
+
63
131
  CREATE TABLE customers (
64
132
  id SERIAL PRIMARY KEY,
65
133
  name VARCHAR(255)
@@ -82,6 +150,25 @@ CREATE TABLE events (
82
150
  );
83
151
 
84
152
  CREATE INDEX index_posts_on_user_id ON posts(user_id, topic_id);
153
+ CREATE INDEX index_expression_indexed_posts_on_topic_id_expression ON expression_indexed_posts((topic_id::text));
154
+ CREATE INDEX index_expr_not_null_partial_posts_on_topic_id ON expression_not_null_partial_indexed_posts((topic_id::text)) WHERE topic_id IS NOT NULL;
155
+ CREATE INDEX index_partial_indexed_posts_on_topic_id ON partial_indexed_posts(topic_id) WHERE id > 0;
156
+ CREATE INDEX index_sorted_indexed_posts_on_topic_id ON sorted_indexed_posts(topic_id DESC NULLS LAST);
157
+ CREATE INDEX index_not_null_partial_indexed_posts_on_topic_id ON not_null_partial_indexed_posts(topic_id) WHERE topic_id IS NOT NULL;
158
+ CREATE INDEX index_null_partial_indexed_posts_on_topic_id ON null_partial_indexed_posts(topic_id) WHERE topic_id IS NULL;
159
+ CREATE INDEX index_included_indexed_posts_on_topic_id ON included_indexed_posts(topic_id) INCLUDE (id);
160
+ CREATE INDEX index_opclass_indexed_codes_on_code ON opclass_indexed_codes(code text_pattern_ops);
161
+ CREATE INDEX index_collated_indexed_codes_on_code ON collated_indexed_codes(code COLLATE "C");
162
+
163
+ -- Advanced index forms used to verify catalog-based index metadata parsing.
164
+ CREATE INDEX index_users_on_company_id_desc ON users(company_id DESC NULLS LAST);
165
+ CREATE INDEX index_users_on_lower_email ON users((lower(email)));
166
+ CREATE INDEX index_users_on_email_partial ON users(email) WHERE customer_id IS NOT NULL;
167
+ CREATE INDEX index_users_on_email_pattern ON users(email text_pattern_ops);
168
+ CREATE INDEX index_posts_on_external_id_desc ON posts(external_id DESC NULLS LAST);
169
+ CREATE INDEX index_posts_on_user_id_desc_and_title_pattern ON posts(user_id DESC, title COLLATE "C" text_pattern_ops);
170
+ CREATE INDEX index_users_on_email_collate_c ON users(email COLLATE "C");
171
+ CREATE INDEX index_users_on_email_include_customer_id ON users(email) INCLUDE (customer_id);
85
172
  SQL
86
173
 
87
174
  RubyPgExtras.connection.exec(DB_SCHEMA)
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.18
4
+ version: 5.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - pawurb
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-11 00:00:00.000000000 Z
11
+ date: 2026-06-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg