Skip to content

Commit bb1e48a

Browse files
committed
Add definition support for :through associations
1 parent 1b75ae3 commit bb1e48a

File tree

7 files changed

+130
-9
lines changed

7 files changed

+130
-9
lines changed

lib/ruby_lsp/ruby_lsp_rails/definition.rb

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def handle_possible_dsl(node)
8080
return unless arguments
8181

8282
if Support::Associations::ALL.include?(message)
83-
handle_association(call_node)
83+
handle_association(node, call_node)
8484
elsif Support::Callbacks::ALL.include?(message)
8585
handle_callback(node, call_node, arguments)
8686
handle_if_unless_conditional(node, call_node, arguments)
@@ -125,18 +125,35 @@ def handle_validation(node, call_node, arguments)
125125
collect_definitions(name)
126126
end
127127

128-
#: (Prism::CallNode node) -> void
129-
def handle_association(node)
130-
first_argument = node.arguments&.arguments&.first
128+
#: ((Prism::SymbolNode | Prism::StringNode) clicked_node, Prism::CallNode call_node) -> void
129+
def handle_association(clicked_node, call_node)
130+
arguments = call_node.arguments&.arguments
131+
return unless arguments
132+
133+
first_argument = arguments.first
131134
return unless first_argument.is_a?(Prism::SymbolNode)
132135

133136
association_name = first_argument.unescaped
134137

138+
through_element = arguments
139+
.filter_map { |arg| arg.elements if arg.is_a?(Prism::KeywordHashNode) }
140+
.flatten
141+
.find { |elem| elem.key.value == "through" }
142+
143+
if clicked_node == first_argument
144+
handle_association_name(association_name)
145+
elsif through_element && clicked_node == through_element.value
146+
through_association_name = through_element.value.unescaped
147+
handle_association_name(through_association_name)
148+
end
149+
end
150+
151+
#: (String association_name) -> void
152+
def handle_association_name(association_name)
135153
result = @client.association_target(
136154
model_name: @nesting.join("::"),
137155
association_name: association_name,
138156
)
139-
140157
return unless result
141158

142159
@response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location))

test/dummy/app/models/country.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# frozen_string_literal: true
22

33
class Country < ApplicationRecord
4+
has_one :flag, dependent: :destroy
45
end

test/dummy/app/models/flag.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class Flag < ApplicationRecord
4+
belongs_to :country
5+
end

test/dummy/app/models/user.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ class User < ApplicationRecord
55
validates :first_name, presence: true
66
has_one :profile
77
scope :adult, -> { where(age: 18..) }
8-
has_one :location, class_name: "Country"
8+
belongs_to :location, class_name: "Country"
9+
has_one :country_flag, through: :location, source: :flag
910

1011
attr_readonly :last_name
1112

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class CreateFlags < ActiveRecord::Migration[8.0]
2+
def change
3+
create_table :flags do |t|
4+
t.references :country, null: false, foreign_key: true
5+
6+
t.timestamps
7+
end
8+
end
9+
end

test/dummy/db/schema.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[8.0].define(version: 2024_10_25_225348) do
13+
ActiveRecord::Schema[8.0].define(version: 2025_07_03_132109) do
1414
create_table "composite_primary_keys", primary_key: ["order_id", "product_id"], force: :cascade do |t|
1515
t.integer "order_id"
1616
t.integer "product_id"
@@ -25,6 +25,13 @@
2525
t.datetime "updated_at", null: false
2626
end
2727

28+
create_table "flags", force: :cascade do |t|
29+
t.integer "country_id", null: false
30+
t.datetime "created_at", null: false
31+
t.datetime "updated_at", null: false
32+
t.index ["country_id"], name: "index_flags_on_country_id"
33+
end
34+
2835
create_table "memberships", force: :cascade do |t|
2936
t.integer "user_id", null: false
3037
t.integer "organization_id", null: false
@@ -58,6 +65,7 @@
5865
t.index ["country_id"], name: "index_users_on_country_id"
5966
end
6067

68+
add_foreign_key "flags", "countries"
6169
add_foreign_key "memberships", "organizations"
6270
add_foreign_key "memberships", "users"
6371
add_foreign_key "users", "countries"

test/ruby_lsp_rails/definition_test.rb

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,46 @@ class Organization < ActiveRecord::Base
5353
assert_equal(2, response[0].range.end.line)
5454
end
5555

56+
test "recognizes main association on has_many :through association" do
57+
response = generate_definitions_for_source(<<~RUBY, { line: 4, character: 12 })
58+
# typed: false
59+
60+
class Organization < ActiveRecord::Base
61+
has_many :memberships
62+
has_many :users, through: :memberships
63+
end
64+
RUBY
65+
66+
assert_equal(1, response.size)
67+
68+
assert_equal(
69+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "user.rb")).to_s,
70+
response[0].uri,
71+
)
72+
assert_equal(2, response[0].range.start.line)
73+
assert_equal(2, response[0].range.end.line)
74+
end
75+
76+
test "recognizes through association on has_many :through association" do
77+
response = generate_definitions_for_source(<<~RUBY, { line: 4, character: 29 })
78+
# typed: false
79+
80+
class Organization < ActiveRecord::Base
81+
has_many :memberships
82+
has_many :users, through: :memberships
83+
end
84+
RUBY
85+
86+
assert_equal(1, response.size)
87+
88+
assert_equal(
89+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "membership.rb")).to_s,
90+
response[0].uri,
91+
)
92+
assert_equal(2, response[0].range.start.line)
93+
assert_equal(2, response[0].range.end.line)
94+
end
95+
5696
test "recognizes belongs_to model associations" do
5797
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 14 })
5898
# typed: false
@@ -91,6 +131,46 @@ class User < ActiveRecord::Base
91131
assert_equal(2, response[0].range.end.line)
92132
end
93133

134+
test "recognizes main association on has_one :through association" do
135+
response = generate_definitions_for_source(<<~RUBY, { line: 4, character: 19 })
136+
# typed: false
137+
138+
class User < ActiveRecord::Base
139+
belongs_to :location, class_name: "Country"
140+
has_one :country_flag, through: :location, source: :flag
141+
end
142+
RUBY
143+
144+
assert_equal(1, response.size)
145+
146+
assert_equal(
147+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "flag.rb")).to_s,
148+
response[0].uri,
149+
)
150+
assert_equal(2, response[0].range.start.line)
151+
assert_equal(2, response[0].range.end.line)
152+
end
153+
154+
test "recognizes through association on has_one :through association" do
155+
response = generate_definitions_for_source(<<~RUBY, { line: 4, character: 35 })
156+
# typed: false
157+
158+
class User < ActiveRecord::Base
159+
belongs_to :location, class_name: "Country"
160+
has_one :country_flag, through: :location, source: :flag
161+
end
162+
RUBY
163+
164+
assert_equal(1, response.size)
165+
166+
assert_equal(
167+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "country.rb")).to_s,
168+
response[0].uri,
169+
)
170+
assert_equal(2, response[0].range.start.line)
171+
assert_equal(2, response[0].range.end.line)
172+
end
173+
94174
test "recognizes has_and_belongs_to_many model associations" do
95175
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 27 })
96176
# typed: false
@@ -111,11 +191,11 @@ class Profile < ActiveRecord::Base
111191
end
112192

113193
test "handles class_name argument for associations" do
114-
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 11 })
194+
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 14 })
115195
# typed: false
116196
117197
class User < ActiveRecord::Base
118-
has_one :location, class_name: "Country"
198+
belongs_to :location, class_name: "Country"
119199
end
120200
RUBY
121201

0 commit comments

Comments
 (0)