From a4850fbe73ff53a036c3425ed4e5de38590b2d91 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 20 Nov 2025 16:52:34 +0000 Subject: [PATCH] Remove adapter specific logic from searchable --- app/models/concerns/searchable.rb | 70 +++---------------------------- app/models/search/record.rb | 39 +++++++++++++++++ 2 files changed, 45 insertions(+), 64 deletions(-) diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index 017c1d500..15b01ad09 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -13,79 +13,21 @@ module Searchable private def create_in_search_index - search_class = Search::Record.for_account(account_id) - - if Search::Record.sqlite? - # SQLite: create with unstemmed content, FTS5 handles stemming - record = search_class.create!(search_record_attributes) - upsert_to_fts5(record.id) - else - # MySQL: create with stemmed content for FULLTEXT search - attrs = search_record_attributes.merge( - title: Search::Stemmer.stem(search_record_attributes[:title]), - content: Search::Stemmer.stem(search_record_attributes[:content]) - ) - search_class.create!(attrs) - end + search_record_class.create!(search_record_attributes) end def update_in_search_index - search_class = Search::Record.for_account(account_id) - - if Search::Record.sqlite? - # SQLite: find or create record, then upsert to FTS5 - record = search_class.find_or_initialize_by( - searchable_type: self.class.name, - searchable_id: id - ) - record.assign_attributes(search_record_attributes) - record.save! - upsert_to_fts5(record.id) - else - # MySQL: use upsert_all with stemmed content - attrs = search_record_attributes.merge( - id: ActiveRecord::Type::Uuid.generate, - title: Search::Stemmer.stem(search_record_attributes[:title]), - content: Search::Stemmer.stem(search_record_attributes[:content]) - ) - search_class.upsert_all( - [ attrs ], - update_only: [ :card_id, :board_id, :title, :content, :created_at ] - ) + search_record_class.find_or_initialize_by(searchable_type: self.class.name, searchable_id: id).tap do |record| + record.update!(search_record_attributes) end end def remove_from_search_index - search_class = Search::Record.for_account(account_id) - record = search_class.find_by(searchable_type: self.class.name, searchable_id: id) - - if record - # For SQLite, delete from FTS5 first - if Search::Record.sqlite? - delete_from_fts5(record.id) - end - - record.delete - end + search_record_class.find_by(searchable_type: self.class.name, searchable_id: id)&.destroy end - def upsert_to_fts5(record_id) - # Use raw unstemmed text - FTS5 Porter tokenizer handles stemming automatically - title = search_title - content = search_content - - # Note: FTS5 virtual tables don't work properly with bound parameters in SQLite, - # so we need to use string interpolation with proper quoting - conn = ActiveRecord::Base.connection - sql = "INSERT OR REPLACE INTO search_records_fts(rowid, title, content) VALUES (#{record_id}, #{conn.quote(title)}, #{conn.quote(content)})" - conn.execute(sql) - end - - def delete_from_fts5(record_id) - # Note: Use string interpolation for consistency (rowid is always an integer, so safe) - ActiveRecord::Base.connection.execute( - "DELETE FROM search_records_fts WHERE rowid = #{record_id}" - ) + def search_record_class + Search::Record.for_account(account_id) end def search_record_attributes diff --git a/app/models/search/record.rb b/app/models/search/record.rb index 3f08ddfea..12586b2a3 100644 --- a/app/models/search/record.rb +++ b/app/models/search/record.rb @@ -22,11 +22,31 @@ class Search::Record < ApplicationRecord validates :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :created_at, presence: true + before_save :stem_content, unless: :sqlite? + after_save :upsert_to_fts5_table, if: :sqlite? + after_destroy :delete_from_fts5_table, if: :sqlite? + class << self def sqlite? connection.adapter_name == "SQLite" end + def upsert_to_fts5(record_id, title:, content:) + # Use raw unstemmed text - FTS5 Porter tokenizer handles stemming automatically + # Note: FTS5 virtual tables don't work properly with bound parameters in SQLite, + # so we need to use string interpolation with proper quoting + conn = connection + sql = "INSERT OR REPLACE INTO search_records_fts(rowid, title, content) VALUES (#{record_id}, #{conn.quote(title)}, #{conn.quote(content)})" + conn.execute(sql) + end + + def delete_from_fts5(record_id) + # Note: Use string interpolation for consistency (rowid is always an integer, so safe) + connection.execute( + "DELETE FROM search_records_fts WHERE rowid = #{record_id}" + ) + end + def for_account(account_id) if sqlite? # SQLite uses a single table, no sharding - use a non-abstract subclass @@ -152,6 +172,10 @@ class Search::Record < ApplicationRecord end end + def sqlite? + self.class.sqlite? + end + def source searchable_type == "Comment" ? searchable : card end @@ -197,6 +221,21 @@ class Search::Record < ApplicationRecord end private + def stem_content + # MySQL: stem content for FULLTEXT search + self.title = Search::Stemmer.stem(title) if title_changed? + self.content = Search::Stemmer.stem(content) if content_changed? + end + + def upsert_to_fts5_table + # Use raw unstemmed text - FTS5 Porter tokenizer handles stemming automatically + self.class.upsert_to_fts5(id, title: title, content: content) + end + + def delete_from_fts5_table + self.class.delete_from_fts5(id) + end + def highlight(text, show:) if text.present? && attribute?(:query) highlighter = Search::Highlighter.new(query)