From 6cdcb459b95de42de9163027d10c8e084ddbe0bf Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 01:08:53 +0200 Subject: [PATCH] Search for operator words instead of parsing them The search box keeps only word characters, but FTS5 still reads AND, OR, NOT and NEAR in the query as operators, so searching for "AND" or for "salt AND" raised "fts5: syntax error" and answered with a 500. Quoting each word makes FTS5 look for it as text, which is how it already treats the same words in lower case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C --- app/models/message/searchable.rb | 10 +++++++++- test/controllers/searches_controller_test.rb | 18 ++++++++++++++++++ test/models/message_test.rb | 6 ++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/models/message/searchable.rb b/app/models/message/searchable.rb index 58ea47f..b792cd0 100644 --- a/app/models/message/searchable.rb +++ b/app/models/message/searchable.rb @@ -6,7 +6,15 @@ module Message::Searchable after_update_commit :update_in_index after_destroy_commit :remove_from_index - scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered } + scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered } + end + + class_methods do + # Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than + # parsing them as operators, which fails on a query like "AND" or "salt AND". + def match_terms(query) + query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ") + end end private diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb index 80abfb4..34ff01b 100644 --- a/test/controllers/searches_controller_test.rb +++ b/test/controllers/searches_controller_test.rb @@ -20,6 +20,24 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest assert_select ".message", text: /Hello world!/ end + test "operator words are searched for as words" do + rooms(:designers).messages.create! body: "Salt and pepper", client_message_id: "operators", creator: users(:david) + + get searches_url, params: { q: "salt AND" } + + assert_response :success + assert_select ".message", text: /Salt and pepper/ + end + + test "searching for an operator word alone doesn't fail" do + %w[ AND OR NOT ].each do |word| + get searches_url, params: { q: word } + + assert_response :success + assert_select ".message", count: 0 + end + end + test "unreachable messages are not found" do memberships(:david_designers).destroy! diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 5ffecea..e6add9a 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -65,6 +65,12 @@ class MessageTest < ActiveSupport::TestCase assert_equal room.messages.ordered.first(Message::Pagination::PAGE_SIZE).map(&:id), room.messages.first_page.map(&:id) end + test "search reads quotes and operator words in the query as text" do + message = rooms(:designers).messages.create!(body: "Say hi, NOT bye", client_message_id: "quoted", creator: users(:david)) + + assert_equal [ message ], Message.search(%(say "hi NOT)).to_a + end + private def create_new_message_in(room) room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "123")