From d22e6301d206f86147a1285897f9517ffc636d17 Mon Sep 17 00:00:00 2001 From: Ryuta Kamizono Date: Mon, 24 Aug 2026 13:41:32 +0900 Subject: [PATCH] Show index operator classes in annotations PostgreSQL indexes can specify a per column operator class (`opclass:`), but the index annotation dropped it entirely, so an index like ```ruby t.index ["name"], using: :gist, opclass: :gist_trgm_ops ``` was annotated as `(name) USING gist`, losing the information that distinguishes it from a plain gist index. Operator classes are per column, so they are now rendered next to the column, in the same order that ActiveRecord generates the index SQL in (column, opclass, order): ``` # index_organizations_on_name_trgm (name gist_trgm_ops) USING gist ``` `IndexDefinition` condenses per column options into a single value when every column shares the same one, so `opclasses` (and `orders`) can be either a Hash keyed by column or a bare value. Both are handled now, which also fixes ordered indexes losing their order when every column shares it: `t.index [:a, :b], order: :desc` was annotated as `(a,b)` and is now annotated as `(a DESC,b DESC)`. --- .../index_annotation/index_component.rb | 20 +++- .../annotation_builder_spec.rb | 97 +++++++++++++++++++ spec/support/annotate_test_helpers.rb | 1 + 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/lib/annotate_rb/model_annotator/index_annotation/index_component.rb b/lib/annotate_rb/model_annotator/index_annotation/index_component.rb index 25243f12..cdade2b9 100644 --- a/lib/annotate_rb/model_annotator/index_annotation/index_component.rb +++ b/lib/annotate_rb/model_annotator/index_annotation/index_component.rb @@ -136,13 +136,23 @@ def to_markdown def columns_info Array(index.columns).map do |col| - if index.try(:orders) && index.orders[col.to_s] - "#{col} #{index.orders[col.to_s].upcase}" - else - col.to_s.gsub("\r", '\r').gsub("\n", '\n') - end + column = col.to_s.gsub("\r", '\r').gsub("\n", '\n') + opclass = column_option(:opclasses, col) + order = column_option(:orders, col) + + [column, opclass, order&.upcase].compact.join(" ") end end + + # ActiveRecord condenses per column index options (`opclasses`, `orders`) + # into a single value when every column shares the same one, so the + # option can either be a Hash keyed by column or a bare value. + def column_option(option, col) + value = index.try(option) + value = value[col.to_s] if value.is_a?(Hash) + + value.presence&.to_s + end end end end diff --git a/spec/lib/annotate_rb/model_annotator/index_annotation/annotation_builder_spec.rb b/spec/lib/annotate_rb/model_annotator/index_annotation/annotation_builder_spec.rb index d180f939..c753f2e6 100644 --- a/spec/lib/annotate_rb/model_annotator/index_annotation/annotation_builder_spec.rb +++ b/spec/lib/annotate_rb/model_annotator/index_annotation/annotation_builder_spec.rb @@ -67,6 +67,103 @@ end end + context "index has the same order for every column" do + let(:indexes) do + [ + mock_index("index_rails_02e851e3b8", + columns: %w[firstname surname], + orders: :desc) + ] + end + + let(:expected_result) do + <<~EOS.strip + # + # Indexes + # + # index_rails_02e851e3b8 (firstname DESC,surname DESC) + EOS + end + + it "matches the expected result" do + expect(default_format).to eq(expected_result) + end + end + + context "index includes an operator class" do + let(:indexes) do + [ + mock_index("index_rails_02e851e3b7", columns: ["id"]), + mock_index("index_rails_02e851e3b8", + columns: %w[firstname surname], + opclasses: {"surname" => :text_pattern_ops}), + mock_index("index_rails_02e851e3b9", + columns: %w[firstname surname], + opclasses: {"firstname" => :text_pattern_ops, "surname" => :text_pattern_ops}, + orders: {"surname" => :desc}) + ] + end + + let(:expected_default) do + <<~EOS.strip + # + # Indexes + # + # index_rails_02e851e3b7 (id) + # index_rails_02e851e3b8 (firstname,surname text_pattern_ops) + # index_rails_02e851e3b9 (firstname text_pattern_ops,surname text_pattern_ops DESC) + EOS + end + + let(:expected_markdown) do + <<~EOS.strip + # + # ### Indexes + # + # * `index_rails_02e851e3b7`: + # * **`id`** + # * `index_rails_02e851e3b8`: + # * **`firstname`** + # * **`surname text_pattern_ops`** + # * `index_rails_02e851e3b9`: + # * **`firstname text_pattern_ops`** + # * **`surname text_pattern_ops DESC`** + EOS + end + + it "includes the operator class in default format" do + expect(default_format).to eq(expected_default) + end + + it "includes the operator class in markdown format" do + expect(markdown_format).to eq(expected_markdown) + end + end + + context "index has the same operator class for every column" do + let(:indexes) do + [ + mock_index("index_rails_02e851e3b8", + columns: %w[name], + opclasses: :gist_trgm_ops, + using: "gist") + ] + end + + let(:expected_result) do + <<~EOS.strip + # + # Indexes + # + # index_rails_02e851e3b8 (name gist_trgm_ops) USING gist + EOS + end + + it "matches the expected result" do + expect(default_format).to eq(expected_result) + end + end + context "index includes a where clause" do let(:indexes) do [ diff --git a/spec/support/annotate_test_helpers.rb b/spec/support/annotate_test_helpers.rb index 2e888247..cd8a06e2 100644 --- a/spec/support/annotate_test_helpers.rb +++ b/spec/support/annotate_test_helpers.rb @@ -23,6 +23,7 @@ def mock_index(name, params = {}) unique: params[:unique] || false, nulls_not_distinct: params[:nulls_not_distinct] || false, orders: params[:orders] || {}, + opclasses: params[:opclasses] || {}, where: params[:where], using: params[:using], comment: params[:comment])