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 +4 -4
- data/README.md +12 -9
- data/lib/ruby_pg_extras/index_info.rb +31 -1
- data/lib/ruby_pg_extras/index_info_print.rb +13 -0
- data/lib/ruby_pg_extras/missing_fk_indexes.rb +45 -1
- data/lib/ruby_pg_extras/queries/indexes.sql +76 -6
- data/lib/ruby_pg_extras/version.rb +1 -1
- data/spec/index_info_spec.rb +111 -2
- data/spec/missing_fk_indexes_spec.rb +113 -5
- data/spec/spec_helper.rb +87 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a23e8afe840227303cb0b15ddd0c795f94dd4004b45a6ca8239da7724b8ffe3d
|
|
4
|
+
data.tar.gz: fd6d80e6150e1a74ae8d360553dba71de05f9241086894038669f412a39c2376
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
235
|
-
|
|
236
|
-
| users_pkey
|
|
237
|
-
|
|
|
238
|
-
|
|
|
239
|
-
|
|
|
240
|
-
|
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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;
|
data/spec/index_info_spec.rb
CHANGED
|
@@ -12,8 +12,38 @@ describe RubyPgExtras::IndexInfo do
|
|
|
12
12
|
before do
|
|
13
13
|
expect(RubyPgExtras).to receive(:indexes) {
|
|
14
14
|
[
|
|
15
|
-
{
|
|
16
|
-
|
|
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: "
|
|
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
|
|
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
|
|
29
|
-
{ table: "
|
|
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
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-06-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: pg
|