Skip to content

Commit 906212c

Browse files
authored
Completion for Active Record .where queries (#526)
* Add completion for AR .where queries using AR model's column names * Address PR comments * PR comments addressed and a new test case added
1 parent 44f8c29 commit 906212c

File tree

3 files changed

+182
-0
lines changed

3 files changed

+182
-0
lines changed

lib/ruby_lsp/ruby_lsp_rails/addon.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
require_relative "code_lens"
1414
require_relative "document_symbol"
1515
require_relative "definition"
16+
require_relative "completion"
1617
require_relative "indexing_enhancement"
1718

1819
module RubyLsp
@@ -119,6 +120,18 @@ def create_definition_listener(response_builder, uri, node_context, dispatcher)
119120
Definition.new(@rails_runner_client, response_builder, node_context, index, dispatcher)
120121
end
121122

123+
sig do
124+
override.params(
125+
response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem],
126+
node_context: NodeContext,
127+
dispatcher: Prism::Dispatcher,
128+
uri: URI::Generic,
129+
).void
130+
end
131+
def create_completion_listener(response_builder, node_context, dispatcher, uri)
132+
Completion.new(@rails_runner_client, response_builder, node_context, dispatcher, uri)
133+
end
134+
122135
sig { params(changes: T::Array[{ uri: String, type: Integer }]).void }
123136
def workspace_did_change_watched_files(changes)
124137
if changes.any? { |c| c[:uri].end_with?("db/schema.rb") || c[:uri].end_with?("structure.sql") }
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Rails
6+
class Completion
7+
extend T::Sig
8+
include Requests::Support::Common
9+
10+
sig do
11+
override.params(
12+
client: RunnerClient,
13+
response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem],
14+
node_context: NodeContext,
15+
dispatcher: Prism::Dispatcher,
16+
uri: URI::Generic,
17+
).void
18+
end
19+
def initialize(client, response_builder, node_context, dispatcher, uri)
20+
@response_builder = response_builder
21+
@client = client
22+
@node_context = node_context
23+
dispatcher.register(
24+
self,
25+
:on_call_node_enter,
26+
)
27+
end
28+
29+
sig { params(node: Prism::CallNode).void }
30+
def on_call_node_enter(node)
31+
call_node = @node_context.call_node
32+
return unless call_node
33+
34+
receiver = call_node.receiver
35+
if call_node.name == :where && receiver.is_a?(Prism::ConstantReadNode)
36+
handle_active_record_where_completions(node: node, receiver: receiver)
37+
end
38+
end
39+
40+
private
41+
42+
sig { params(node: Prism::CallNode, receiver: Prism::ConstantReadNode).void }
43+
def handle_active_record_where_completions(node:, receiver:)
44+
resolved_class = @client.model(receiver.name.to_s)
45+
return if resolved_class.nil?
46+
47+
arguments = T.must(@node_context.call_node).arguments&.arguments
48+
indexed_call_node_args = T.let({}, T::Hash[String, Prism::Node])
49+
50+
if arguments
51+
indexed_call_node_args = index_call_node_args(arguments: arguments)
52+
return if indexed_call_node_args.values.any? { |v| v == node }
53+
end
54+
55+
range = range_from_location(node.location)
56+
57+
resolved_class[:columns].each do |column|
58+
next unless column[0].start_with?(node.name.to_s)
59+
next if indexed_call_node_args.key?(column[0])
60+
61+
@response_builder << Interface::CompletionItem.new(
62+
label: column[0],
63+
filter_text: column[0],
64+
label_details: Interface::CompletionItemLabelDetails.new(
65+
description: "Filter #{receiver.name} records by #{column[0]}",
66+
),
67+
text_edit: Interface::TextEdit.new(range: range, new_text: "#{column[0]}: "),
68+
kind: Constant::CompletionItemKind::FIELD,
69+
)
70+
end
71+
end
72+
73+
sig { params(arguments: T::Array[Prism::Node]).returns(T::Hash[String, Prism::Node]) }
74+
def index_call_node_args(arguments:)
75+
indexed_call_node_args = {}
76+
arguments.each do |argument|
77+
next unless argument.is_a?(Prism::KeywordHashNode)
78+
79+
argument.elements.each do |e|
80+
next unless e.is_a?(Prism::AssocNode)
81+
82+
key = e.key
83+
if key.is_a?(Prism::SymbolNode)
84+
indexed_call_node_args[key.value] = e.value
85+
end
86+
end
87+
end
88+
indexed_call_node_args
89+
end
90+
end
91+
end
92+
end
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "test_helper"
5+
6+
module RubyLsp
7+
module Rails
8+
class CompletionTest < ActiveSupport::TestCase
9+
test "on_call_node_enter returns when node_context has no call node" do
10+
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 5 })
11+
# typed: false
12+
where
13+
RUBY
14+
15+
assert_equal(0, response.size)
16+
end
17+
18+
test "on_call_node_enter provides no suggestions when .where is called on a non ActiveRecord model" do
19+
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 20 })
20+
# typed: false
21+
FakeClass.where(crea
22+
RUBY
23+
24+
assert_equal(0, response.size)
25+
end
26+
27+
test "on_call_node_enter provides completions when AR model column name is typed partially" do
28+
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 17 })
29+
# typed: false
30+
User.where(first_
31+
RUBY
32+
33+
assert_equal(1, response.size)
34+
assert_equal("first_name", response[0].label)
35+
assert_equal("first_name", response[0].filter_text)
36+
assert_equal(11, response[0].text_edit.range.start.character)
37+
assert_equal(1, response[0].text_edit.range.start.line)
38+
assert_equal(17, response[0].text_edit.range.end.character)
39+
assert_equal(1, response[0].text_edit.range.end.line)
40+
end
41+
42+
test "on_call_node_enter does not provide column name suggestion if column is already a key in the .where call" do
43+
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 37 })
44+
# typed: false
45+
User.where(id:, first_name:, first_na
46+
RUBY
47+
48+
assert_equal(0, response.size)
49+
end
50+
51+
test "on_call_node_enter doesn't provide completions when typing an argument's value within a .where call" do
52+
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 20 })
53+
# typed: false
54+
User.where(id: creat
55+
RUBY
56+
assert_equal(0, response.size)
57+
end
58+
59+
private
60+
61+
def generate_completions_for_source(source, position)
62+
with_server(source) do |server, uri|
63+
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)
64+
65+
server.process_message(
66+
id: 1,
67+
method: "textDocument/completion",
68+
params: { textDocument: { uri: uri }, position: position },
69+
)
70+
71+
result = pop_result(server)
72+
result.response
73+
end
74+
end
75+
end
76+
end
77+
end

0 commit comments

Comments
 (0)