Clean up checks to see if DidYouMean is defined

As of Ruby 2.7 DidYouMean is included as a default gem, so there is no
need to check if DidYouMean is defined in the test suite. We still need
to check if the DidYouMean modules are defined in the actual code, as
someone might run Rails with DidYouMean disabled by using the
`--disable-did_you_mean` flag. This is ussually done for performance
reasons.

This commit also includes some of the changes made by Yuki in:
https://github.com/rails/rails/pull/39555
These changes include replacing Jaro with the more accurate
SpellChecker, and using DidYouMean::Correctable for simplere
corrections.

The DidYouMean::SpellChecker does have a treshold for corrections.
If there is not enough similarity it might not return a suggestion.
To stop the tests from failing some test data had to be changed.

For example, `non_existent` does not meet the treshold for `hello`, but
`ello` does:

DidYouMean::SpellChecker.new(dictionary: %w[hello]).correct('non_existent')
=> []
DidYouMean::SpellChecker.new(dictionary: %w[hello]).correct('ello')
=> ["hello"]

The treshold makes sense for spelling errors. But maybe we should add a
different SpellChecker that helps to get a suggestion even if there is
little overlap. For example for when a model only has 2 attributes
(title and body), it's helpful to get a suggestion for `name`

Co-Authored-By: Yuki Nishijima <yk.nishijima@gmail.com>
This commit is contained in:
Petrik 2021-05-31 18:27:47 +02:00
parent 7179dcb269
commit 0409ed57ac
16 changed files with 108 additions and 195 deletions

View File

@ -16,28 +16,13 @@ module AbstractController
super(message) super(message)
end end
class Correction if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker)
def initialize(error) include DidYouMean::Correctable
@error = error
end
def corrections def corrections
if @error.action @corrections ||= DidYouMean::SpellChecker.new(dictionary: controller.class.action_methods).correct(action)
maybe_these = @error.controller.class.action_methods
maybe_these.sort_by { |n|
DidYouMean::Jaro.distance(@error.action.to_s, n)
}.reverse.first(4)
else
[]
end
end end
end end
# We may not have DYM, and DYM might not let us register error handlers
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error)
DidYouMean.correct_error(self, Correction)
end
end end
# AbstractController::Base is a low-level API. Nobody should be # AbstractController::Base is a low-level API. Nobody should be

View File

@ -33,29 +33,18 @@ module ActionController
super(message) super(message)
end end
class Correction if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker)
def initialize(error) include DidYouMean::Correctable
@error = error
end
def corrections def corrections
if @error.method_name @corrections ||= begin
maybe_these = @error.routes.named_routes.helper_names.grep(/#{@error.route_name}/) maybe_these = routes&.named_routes&.helper_names&.grep(/#{route_name}/) || []
maybe_these -= [@error.method_name.to_s] # remove exact match maybe_these -= [method_name.to_s] # remove exact match
maybe_these.sort_by { |n| DidYouMean::SpellChecker.new(dictionary: maybe_these).correct(route_name)
DidYouMean::Jaro.distance(@error.route_name, n)
}.reverse.first(4)
else
[]
end end
end end
end end
# We may not have DYM, and DYM might not let us register error handlers
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error)
DidYouMean.correct_error(self, Correction)
end
end end
class MethodNotAllowed < ActionControllerError #:nodoc: class MethodNotAllowed < ActionControllerError #:nodoc:

View File

@ -27,28 +27,13 @@ module ActionController
super("param is missing or the value is empty: #{param}") super("param is missing or the value is empty: #{param}")
end end
class Correction if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker)
def initialize(error) include DidYouMean::Correctable
@error = error
end
def corrections def corrections
if @error.param && @error.keys @corrections ||= DidYouMean::SpellChecker.new(dictionary: keys).correct(param.to_s)
maybe_these = @error.keys
maybe_these.sort_by { |n|
DidYouMean::Jaro.distance(@error.param.to_s, n)
}.reverse.first(4)
else
[]
end
end end
end end
# We may not have DYM, and DYM might not let us register error handlers
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error)
DidYouMean.correct_error(self, Correction)
end
end end
# Raised when a supplied parameter is not expected and # Raised when a supplied parameter is not expected and

View File

@ -172,14 +172,12 @@ class PerformActionTest < ActionController::TestCase
assert_equal "The action 'non_existent' could not be found for EmptyController", exception.message assert_equal "The action 'non_existent' could not be found for EmptyController", exception.message
end end
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) def test_exceptions_have_suggestions_for_fix
def test_exceptions_have_suggestions_for_fix use_controller SimpleController
use_controller SimpleController exception = assert_raise AbstractController::ActionNotFound do
exception = assert_raise AbstractController::ActionNotFound do get :ello
get :non_existent
end
assert_match "Did you mean?", exception.message
end end
assert_match "Did you mean?", exception.message
end end
def test_action_missing_should_work def test_action_missing_should_work

View File

@ -22,18 +22,16 @@ class ActionControllerRequiredParamsTest < ActionController::TestCase
end end
end end
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) test "exceptions have suggestions for fix" do
test "exceptions have suggestions for fix" do error = assert_raise ActionController::ParameterMissing do
error = assert_raise ActionController::ParameterMissing do post :create, params: { boko: { name: "Mjallo!" } }
post :create, params: { magazine: { name: "Mjallo!" } }
end
assert_match "Did you mean?", error.message
error = assert_raise ActionController::ParameterMissing do
post :create, params: { book: { title: "Mjallo!" } }
end
assert_match "Did you mean?", error.message
end end
assert_match "Did you mean?", error.message
error = assert_raise ActionController::ParameterMissing do
post :create, params: { book: { naem: "Mjallo!" } }
end
assert_match "Did you mean?", error.message
end end
test "required parameters that are present will not raise" do test "required parameters that are present will not raise" do

View File

@ -64,7 +64,7 @@ class DebugExceptionsTest < ActionDispatch::IntegrationTest
[404, { "X-Cascade" => "pass" }, self] [404, { "X-Cascade" => "pass" }, self]
when "/not_found" when "/not_found"
controller = SimpleController.new controller = SimpleController.new
raise AbstractController::ActionNotFound.new(nil, controller, :not_found) raise AbstractController::ActionNotFound.new(nil, controller, :ello)
when "/runtime_error" when "/runtime_error"
raise RuntimeError raise RuntimeError
when "/method_not_allowed" when "/method_not_allowed"
@ -98,7 +98,7 @@ class DebugExceptionsTest < ActionDispatch::IntegrationTest
when "/missing_keys" when "/missing_keys"
raise ActionController::UrlGenerationError, "No route matches" raise ActionController::UrlGenerationError, "No route matches"
when "/parameter_missing" when "/parameter_missing"
raise ActionController::ParameterMissing.new(:missing_param_key, %w(valid_param_key)) raise ActionController::ParameterMissing.new(:invalid_param_key, %w(valid_param_key))
when "/original_syntax_error" when "/original_syntax_error"
eval "broke_syntax =" # `eval` need for raise native SyntaxError at runtime eval "broke_syntax =" # `eval` need for raise native SyntaxError at runtime
when "/syntax_error_into_view" when "/syntax_error_into_view"
@ -318,20 +318,18 @@ class DebugExceptionsTest < ActionDispatch::IntegrationTest
assert_match(/ActionDispatch::Http::MimeNegotiation::InvalidType/, body) assert_match(/ActionDispatch::Http::MimeNegotiation::InvalidType/, body)
end end
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) test "rescue with suggestions" do
test "rescue with suggestions" do @app = DevelopmentApp
@app = DevelopmentApp
get "/not_found", headers: { "action_dispatch.show_exceptions" => true } get "/not_found", headers: { "action_dispatch.show_exceptions" => true }
assert_response 404 assert_response 404
assert_select("b", /Did you mean\?/) assert_select("b", /Did you mean\?/)
assert_select("li", "hello") assert_select("li", "hello")
get "/parameter_missing", headers: { "action_dispatch.show_exceptions" => true } get "/parameter_missing", headers: { "action_dispatch.show_exceptions" => true }
assert_response 400 assert_response 400
assert_select("b", /Did you mean\?/) assert_select("b", /Did you mean\?/)
assert_select("li", "valid_param_key") assert_select("li", "valid_param_key")
end
end end
test "rescue with HTML format for HTML API request" do test "rescue with HTML format for HTML API request" do

View File

@ -4913,11 +4913,9 @@ class TestUrlGenerationErrors < ActionDispatch::IntegrationTest
assert_match message, error.message assert_match message, error.message
end end
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) test "exceptions have suggestions for fix" do
test "exceptions have suggestions for fix" do error = assert_raises(ActionController::UrlGenerationError) { product_path(nil, "id" => "url-tested") }
error = assert_raises(ActionController::UrlGenerationError) { product_path(nil, "id" => "url-tested") } assert_match "Did you mean?", error.message
assert_match "Did you mean?", error.message
end
end end
# FIXME: we should fix all locations that raise this exception to provide # FIXME: we should fix all locations that raise this exception to provide
@ -4926,8 +4924,8 @@ class TestUrlGenerationErrors < ActionDispatch::IntegrationTest
# we don't want to break other code. # we don't want to break other code.
test "correct for empty UrlGenerationError" do test "correct for empty UrlGenerationError" do
err = ActionController::UrlGenerationError.new("oh no!") err = ActionController::UrlGenerationError.new("oh no!")
correction = ActionController::UrlGenerationError::Correction.new(err)
assert_equal [], correction.corrections assert_equal [], err.corrections
end end
end end

View File

@ -627,31 +627,29 @@ module RenderTestCases
assert_match "Missing partial /_true with", e.message assert_match "Missing partial /_true with", e.message
end end
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) def test_render_partial_provides_spellcheck
def test_render_partial_provides_spellcheck e = assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/partail") }
e = assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/partail") } assert_match %r{Did you mean\? test/partial\n *test/partialhtml}, e.message
assert_match %r{Did you mean\? test/partial\n *test/partialhtml}, e.message end
end
def test_spellcheck_doesnt_list_directories def test_spellcheck_doesnt_list_directories
e = assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/directory") } e = assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/directory") }
assert_match %r{Did you mean\?}, e.message assert_match %r{Did you mean\?}, e.message
assert_no_match %r{Did you mean\? test/directory\n}, e.message # test/hello is a directory assert_no_match %r{Did you mean\? test/directory\n}, e.message # test/hello is a directory
end end
def test_spellcheck_only_lists_templates def test_spellcheck_only_lists_templates
e = assert_raises(ActionView::MissingTemplate) { @view.render(template: "test/partial") } e = assert_raises(ActionView::MissingTemplate) { @view.render(template: "test/partial") }
assert_match %r{Did you mean\?}, e.message assert_match %r{Did you mean\?}, e.message
assert_no_match %r{Did you mean\? test/partial\n}, e.message assert_no_match %r{Did you mean\? test/partial\n}, e.message
end end
def test_spellcheck_only_lists_partials def test_spellcheck_only_lists_partials
e = assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/template") } e = assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/template") }
assert_match %r{Did you mean\?}, e.message assert_match %r{Did you mean\?}, e.message
assert_no_match %r{Did you mean\? test/template\n}, e.message assert_no_match %r{Did you mean\? test/template\n}, e.message
end
end end
def test_render_partial_wrong_details_no_spellcheck def test_render_partial_wrong_details_no_spellcheck

View File

@ -13,28 +13,16 @@ module ActiveRecord
end end
end end
class Correction if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker)
def initialize(error) include DidYouMean::Correctable
@error = error
end
def corrections def corrections
if @error.association_name @corrections ||= begin
maybe_these = @error.record.class.reflections.keys maybe_these = record&.class&.reflections&.keys
DidYouMean::SpellChecker.new(dictionary: maybe_these).correct(association_name.to_s)
maybe_these.sort_by { |n|
DidYouMean::Jaro.distance(@error.association_name.to_s, n)
}.reverse.first(4)
else
[]
end end
end end
end end
# We may not have DYM, and DYM might not let us register error handlers
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error)
DidYouMean.correct_error(self, Correction)
end
end end
class InverseOfAssociationNotFoundError < ActiveRecordError #:nodoc: class InverseOfAssociationNotFoundError < ActiveRecordError #:nodoc:
@ -49,28 +37,20 @@ module ActiveRecord
end end
end end
class Correction if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker)
def initialize(error) include DidYouMean::Correctable
@error = error
end
def corrections def corrections
if @error.reflection && @error.associated_class if reflection && associated_class
maybe_these = @error.associated_class.reflections.keys @corrections ||= begin
maybe_these = associated_class.reflections.keys
maybe_these.sort_by { |n| DidYouMean::SpellChecker.new(dictionary: maybe_these).correct(reflection.options[:inverse_of].to_s)
DidYouMean::Jaro.distance(@error.reflection.options[:inverse_of].to_s, n) end
}.reverse.first(4)
else else
[] []
end end
end end
end end
# We may not have DYM, and DYM might not let us register error handlers
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error)
DidYouMean.correct_error(self, Correction)
end
end end
class HasManyThroughAssociationNotFoundError < ActiveRecordError #:nodoc: class HasManyThroughAssociationNotFoundError < ActiveRecordError #:nodoc:
@ -86,29 +66,21 @@ module ActiveRecord
end end
end end
class Correction if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker)
def initialize(error) include DidYouMean::Correctable
@error = error
end
def corrections def corrections
if @error.reflection && @error.owner_class if owner_class && reflection
maybe_these = @error.owner_class.reflections.keys @corrections ||= begin
maybe_these -= [@error.reflection.name.to_s] # remove failing reflection maybe_these = owner_class.reflections.keys
maybe_these -= [reflection.name.to_s] # remove failing reflection
maybe_these.sort_by { |n| DidYouMean::SpellChecker.new(dictionary: maybe_these).correct(reflection.options[:through].to_s)
DidYouMean::Jaro.distance(@error.reflection.options[:through].to_s, n) end
}.reverse.first(4)
else else
[] []
end end
end end
end end
# We may not have DYM, and DYM might not let us register error handlers
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error)
DidYouMean.correct_error(self, Correction)
end
end end
class HasManyThroughAssociationPolymorphicSourceError < ActiveRecordError #:nodoc: class HasManyThroughAssociationPolymorphicSourceError < ActiveRecordError #:nodoc:

View File

@ -848,13 +848,11 @@ class EagerAssociationTest < ActiveRecord::TestCase
assert_match(/Association named 'monkeys' was not found on Post; perhaps you misspelled it\?/, e.message) assert_match(/Association named 'monkeys' was not found on Post; perhaps you misspelled it\?/, e.message)
end end
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) test "exceptions have suggestions for fix" do
test "exceptions have suggestions for fix" do error = assert_raise(ActiveRecord::AssociationNotFoundError) {
error = assert_raise(ActiveRecord::AssociationNotFoundError) { Post.all.merge!(includes: :taggingz).find(6)
Post.all.merge!(includes: :monkeys).find(6) }
} assert_match "Did you mean? tagging\n", error.message
assert_match "Did you mean?", error.message
end
end end
def test_eager_has_many_through_with_order def test_eager_has_many_through_with_order

View File

@ -336,15 +336,13 @@ class InverseHasOneTests < ActiveRecord::TestCase
assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Human.first.confused_face } assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Human.first.confused_face }
end end
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) def test_trying_to_use_inverses_that_dont_exist_should_have_suggestions_for_fix
def test_trying_to_use_inverses_that_dont_exist_should_have_suggestions_for_fix error = assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) {
error = assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Human.first.confused_face
Human.first.confused_face }
}
assert_match "Did you mean?", error.message assert_match "Did you mean?", error.message
assert_equal "super_human", error.corrections.first assert_equal "confused_human", error.corrections.first
end
end end
end end
@ -751,18 +749,16 @@ class InverseBelongsToTests < ActiveRecord::TestCase
end end
def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error
assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.puzzled_human } assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.confused_human }
end end
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) def test_trying_to_use_inverses_that_dont_exist_should_have_suggestions_for_fix
def test_trying_to_use_inverses_that_dont_exist_should_have_suggestions_for_fix error = assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) {
error = assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.confused_human
Face.first.puzzled_human }
}
assert_match "Did you mean?", error.message assert_match "Did you mean?", error.message
assert_equal "confused_face", error.corrections.first assert_equal "confused_face", error.corrections.first
end
end end
def test_building_has_many_parent_association_inverses_one_record def test_building_has_many_parent_association_inverses_one_record

View File

@ -341,13 +341,11 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase
assert_raise(ActiveRecord::HasManyThroughAssociationNotFoundError) { authors(:david).nothings } assert_raise(ActiveRecord::HasManyThroughAssociationNotFoundError) { authors(:david).nothings }
end end
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) def test_exceptions_have_suggestions_for_fix
def test_exceptions_have_suggestions_for_fix error = assert_raise(ActiveRecord::HasManyThroughAssociationNotFoundError) {
error = assert_raise(ActiveRecord::HasManyThroughAssociationNotFoundError) { authors(:david).nothings
authors(:david).nothings }
} assert_match "Did you mean?", error.message
assert_match "Did you mean?", error.message
end
end end
def test_has_many_through_join_model_with_conditions def test_has_many_through_join_model_with_conditions

View File

@ -149,7 +149,7 @@ class Author < ActiveRecord::Base
has_many :categorized_posts, through: :categorizations, source: :post has_many :categorized_posts, through: :categorizations, source: :post
has_many :unique_categorized_posts, -> { distinct }, through: :categorizations, source: :post has_many :unique_categorized_posts, -> { distinct }, through: :categorizations, source: :post
has_many :nothings, through: :kateggorisatons, class_name: "Category" has_many :nothings, through: :kateggorizatons, class_name: "Category"
has_many :author_favorites has_many :author_favorites
has_many :favorite_authors, -> { order("name") }, through: :author_favorites has_many :favorite_authors, -> { order("name") }, through: :author_favorites

View File

@ -8,7 +8,7 @@ class Face < ActiveRecord::Base
# Oracle identifier length is limited to 30 bytes or less, `polymorphic` renamed `poly` # Oracle identifier length is limited to 30 bytes or less, `polymorphic` renamed `poly`
belongs_to :poly_human_without_inverse, polymorphic: true belongs_to :poly_human_without_inverse, polymorphic: true
# These are "broken" inverse_of associations for the purposes of testing # These are "broken" inverse_of associations for the purposes of testing
belongs_to :puzzled_human, class_name: "Human", inverse_of: :puzzled_face belongs_to :confused_human, class_name: "Human", inverse_of: :cnffused_face
belongs_to :puzzled_polymorphic_human, polymorphic: true, inverse_of: :puzzled_polymorphic_face belongs_to :puzzled_polymorphic_human, polymorphic: true, inverse_of: :puzzled_polymorphic_face
validate do validate do

View File

@ -24,7 +24,7 @@ class Human < ActiveRecord::Base
after_add: :add_called, after_add: :add_called,
inverse_of: :polymorphic_human inverse_of: :polymorphic_human
# These are "broken" inverse_of associations for the purposes of testing # These are "broken" inverse_of associations for the purposes of testing
has_one :confused_face, class_name: "Face", inverse_of: :confused_human has_one :confused_face, class_name: "Face", inverse_of: :cnffused_human
has_many :secret_interests, class_name: "Interest", inverse_of: :secret_human has_many :secret_interests, class_name: "Interest", inverse_of: :secret_human
has_one :mixed_case_monkey has_one :mixed_case_monkey

View File

@ -26,7 +26,7 @@ module Rails
include DidYouMean::Correctable include DidYouMean::Correctable
def corrections def corrections
DidYouMean::SpellChecker.new(dictionary: options).correct(key) @corrections ||= DidYouMean::SpellChecker.new(dictionary: options).correct(key)
end end
end end
end end