From 631ee7efc7231c8864102e78ce1590967cdb83e2 Mon Sep 17 00:00:00 2001 From: Joshua Young Date: Thu, 3 Jul 2025 23:49:30 +1000 Subject: [PATCH] Add support for `:through` associations --- lib/ruby_lsp/ruby_lsp_rails/definition.rb | 13 ++++- test/dummy/app/models/country.rb | 1 + test/dummy/app/models/flag.rb | 5 ++ test/dummy/app/models/user.rb | 3 +- .../db/migrate/20250703132109_create_flags.rb | 9 +++ test/dummy/db/schema.rb | 10 +++- test/ruby_lsp_rails/definition_test.rb | 56 ++++++++++++++++++- 7 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 test/dummy/app/models/flag.rb create mode 100644 test/dummy/db/migrate/20250703132109_create_flags.rb diff --git a/lib/ruby_lsp/ruby_lsp_rails/definition.rb b/lib/ruby_lsp/ruby_lsp_rails/definition.rb index 20a0869f..7932f439 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/definition.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/definition.rb @@ -108,12 +108,23 @@ def handle_association(node) return unless first_argument.is_a?(Prism::SymbolNode) association_name = first_argument.unescaped + handle_association_name(association_name) + + through_association_name = node.arguments.arguments + .filter_map { |arg| arg.elements if arg.is_a?(Prism::KeywordHashNode) } + .flatten + .find { |elem| elem.key.value == "through" } + &.value + &.unescaped + handle_association_name(through_association_name) if through_association_name + end + #: (String association_name) -> void + def handle_association_name(association_name) result = @client.association_target_location( model_name: @nesting.join("::"), association_name: association_name, ) - return unless result @response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location)) diff --git a/test/dummy/app/models/country.rb b/test/dummy/app/models/country.rb index 8a28ba43..2e8dc6ff 100644 --- a/test/dummy/app/models/country.rb +++ b/test/dummy/app/models/country.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true class Country < ApplicationRecord + has_one :flag, dependent: :destroy end diff --git a/test/dummy/app/models/flag.rb b/test/dummy/app/models/flag.rb new file mode 100644 index 00000000..b4970866 --- /dev/null +++ b/test/dummy/app/models/flag.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Flag < ApplicationRecord + belongs_to :country +end diff --git a/test/dummy/app/models/user.rb b/test/dummy/app/models/user.rb index c696c86d..4124b35c 100644 --- a/test/dummy/app/models/user.rb +++ b/test/dummy/app/models/user.rb @@ -5,7 +5,8 @@ class User < ApplicationRecord validates :first_name, presence: true has_one :profile scope :adult, -> { where(age: 18..) } - has_one :location, class_name: "Country" + belongs_to :location, class_name: "Country" + has_one :country_flag, through: :location, source: :flag attr_readonly :last_name diff --git a/test/dummy/db/migrate/20250703132109_create_flags.rb b/test/dummy/db/migrate/20250703132109_create_flags.rb new file mode 100644 index 00000000..23ffa324 --- /dev/null +++ b/test/dummy/db/migrate/20250703132109_create_flags.rb @@ -0,0 +1,9 @@ +class CreateFlags < ActiveRecord::Migration[8.0] + def change + create_table :flags do |t| + t.references :country, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index fa61c344..f103abac 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2024_10_25_225348) do +ActiveRecord::Schema[8.0].define(version: 2025_07_03_132109) do create_table "composite_primary_keys", primary_key: ["order_id", "product_id"], force: :cascade do |t| t.integer "order_id" t.integer "product_id" @@ -25,6 +25,13 @@ t.datetime "updated_at", null: false end + create_table "flags", force: :cascade do |t| + t.integer "country_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["country_id"], name: "index_flags_on_country_id" + end + create_table "memberships", force: :cascade do |t| t.integer "user_id", null: false t.integer "organization_id", null: false @@ -58,6 +65,7 @@ t.index ["country_id"], name: "index_users_on_country_id" end + add_foreign_key "flags", "countries" add_foreign_key "memberships", "organizations" add_foreign_key "memberships", "users" add_foreign_key "users", "countries" diff --git a/test/ruby_lsp_rails/definition_test.rb b/test/ruby_lsp_rails/definition_test.rb index 581cb10a..52928840 100644 --- a/test/ruby_lsp_rails/definition_test.rb +++ b/test/ruby_lsp_rails/definition_test.rb @@ -52,6 +52,32 @@ class Organization < ActiveRecord::Base assert_equal(2, response[0].range.end.line) end + test "recognizes has_many :through model associations" do + response = generate_definitions_for_source(<<~RUBY, { line: 4, character: 29 }) + # typed: false + + class Organization < ActiveRecord::Base + has_many :memberships + has_many :users, through: :memberships + end + RUBY + + assert_equal(2, response.size) + + assert_equal( + URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "user.rb")).to_s, + response[0].uri, + ) + assert_equal( + URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "membership.rb")).to_s, + response[1].uri, + ) + assert_equal(2, response[0].range.start.line) + assert_equal(2, response[0].range.end.line) + assert_equal(2, response[1].range.start.line) + assert_equal(2, response[1].range.end.line) + end + test "recognizes belongs_to model associations" do response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 14 }) # typed: false @@ -90,6 +116,32 @@ class User < ActiveRecord::Base assert_equal(2, response[0].range.end.line) end + test "recognizes has_one :through model associations" do + response = generate_definitions_for_source(<<~RUBY, { line: 4, character: 35 }) + # typed: false + + class User < ActiveRecord::Base + belongs_to :location, class_name: "Country" + has_one :country_flag, through: :location, source: :flag + end + RUBY + + assert_equal(2, response.size) + + assert_equal( + URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "flag.rb")).to_s, + response[0].uri, + ) + assert_equal( + URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "country.rb")).to_s, + response[1].uri, + ) + assert_equal(2, response[0].range.start.line) + assert_equal(2, response[0].range.end.line) + assert_equal(2, response[1].range.start.line) + assert_equal(2, response[1].range.end.line) + end + test "recognizes has_and_belongs_to_many model associations" do response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 27 }) # typed: false @@ -110,11 +162,11 @@ class Profile < ActiveRecord::Base end test "handles class_name argument for associations" do - response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 11 }) + response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 14 }) # typed: false class User < ActiveRecord::Base - has_one :location, class_name: "Country" + belongs_to :location, class_name: "Country" end RUBY