From 525e53a2f7b3c7f481f15e40a86c72e2a4624ab2 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 21 Nov 2025 15:21:11 +0000 Subject: [PATCH] Add FTS ActiveRecord model Needs some special handling for the FTS virtual table format. Set the primary key to rowid and manually specify the columns. --- app/models/search/record/sqlite.rb | 24 ++++++---------------- app/models/search/record/sqlite/fts.rb | 21 +++++++++++++++++++ test/models/card/searchable_test.rb | 28 ++++++++++++++++++++++++++ test/models/comment/searchable_test.rb | 14 +++++++++++++ 4 files changed, 69 insertions(+), 18 deletions(-) create mode 100644 app/models/search/record/sqlite/fts.rb diff --git a/app/models/search/record/sqlite.rb b/app/models/search/record/sqlite.rb index cba6a1cde..006a48a5e 100644 --- a/app/models/search/record/sqlite.rb +++ b/app/models/search/record/sqlite.rb @@ -2,21 +2,17 @@ module Search::Record::SQLite extend ActiveSupport::Concern included do - # Override the UUID id attribute from ApplicationRecord - # FTS tables require integer rowids + # Override default UUID id attribute, as FTS5 uses rowid integer primary key attribute :id, :integer, default: nil - - # Virtual attributes from FTS5 functions attribute :result_title, :string attribute :result_content, :string + has_one :search_records_fts, -> { with_rowid }, class_name: "Search::Record::SQLite::Fts", foreign_key: :rowid, primary_key: :id + after_save :upsert_to_fts5_table after_destroy :delete_from_fts5_table - scope :matching, ->(query, account_id) do - joins("INNER JOIN search_records_fts ON search_records_fts.rowid = #{table_name}.id") - .where("search_records_fts MATCH ?", query) - end + scope :matching, ->(query, account_id) { joins(:search_records_fts).where("search_records_fts MATCH ?", query) } end class_methods do @@ -54,18 +50,10 @@ module Search::Record::SQLite end def upsert_to_fts5_table - self.class.connection.exec_query( - "INSERT OR REPLACE INTO search_records_fts(rowid, title, content) VALUES (?, ?, ?)", - "Search::Record Upsert FTS5", - [id, title, content] - ) + Fts.upsert(id, title, content) end def delete_from_fts5_table - self.class.connection.exec_query( - "DELETE FROM search_records_fts WHERE rowid = ?", - "Search::Record Delete FTS5", - [id] - ) + search_records_fts&.destroy end end diff --git a/app/models/search/record/sqlite/fts.rb b/app/models/search/record/sqlite/fts.rb new file mode 100644 index 000000000..fa00dab17 --- /dev/null +++ b/app/models/search/record/sqlite/fts.rb @@ -0,0 +1,21 @@ +class Search::Record::SQLite::Fts < ApplicationRecord + self.table_name = "search_records_fts" + self.primary_key = "rowid" + + # FTS5 virtual table columns + attribute :rowid, :integer + attribute :title, :string + attribute :content, :string + + # FTS5 virtual tables don't expose rowid in the schema by default + # We need to explicitly select it when loading records + scope :with_rowid, -> { select(:rowid, :title, :content) } + + def self.upsert(rowid, title, content) + connection.exec_query( + "INSERT OR REPLACE INTO search_records_fts(rowid, title, content) VALUES (?, ?, ?)", + "Search::Record::SqliteFts Upsert", + [rowid, title, content] + ) + end +end diff --git a/test/models/card/searchable_test.rb b/test/models/card/searchable_test.rb index 2dc8f13a6..c0a99ab23 100644 --- a/test/models/card/searchable_test.rb +++ b/test/models/card/searchable_test.rb @@ -41,4 +41,32 @@ class Card::SearchableTest < ActiveSupport::TestCase assert_match(/Current.account must be set to save Card/, error.message) end + + test "deleting card removes search record and FTS entry" do + card = @board.cards.create!(title: "Card to delete", creator: @user) + + # Verify search record exists + search_record = Search::Record.find_by(searchable_type: "Card", searchable_id: card.id) + assert_not_nil search_record, "Search record should exist after card creation" + + # For SQLite, verify FTS entry exists + if Search::Record.connection.adapter_name == "SQLite" + fts_entry = search_record.search_records_fts + assert_not_nil fts_entry, "FTS entry should exist" + assert_equal card.title, fts_entry.title + end + + # Delete the card + card.destroy + + # Verify search record is deleted + search_record = Search::Record.find_by(searchable_type: "Card", searchable_id: card.id) + assert_nil search_record, "Search record should be deleted after card deletion" + + # For SQLite, verify FTS entry is deleted + if Search::Record.connection.adapter_name == "SQLite" + fts_count = Search::Record::SQLite::Fts.where(rowid: card.id).count + assert_equal 0, fts_count, "FTS entry should be deleted" + end + end end diff --git a/test/models/comment/searchable_test.rb b/test/models/comment/searchable_test.rb index 3c7dd461f..b8b77a8b0 100644 --- a/test/models/comment/searchable_test.rb +++ b/test/models/comment/searchable_test.rb @@ -20,10 +20,24 @@ class Comment::SearchableTest < ActiveSupport::TestCase # Comment is removed from index on destroy comment_id = comment.id + search_record_id = record.id + + # For SQLite, verify FTS entry exists before deletion + if Search::Record.connection.adapter_name == "SQLite" + fts_entry = record.search_records_fts + assert_not_nil fts_entry, "FTS entry should exist before comment deletion" + end + comment.destroy record = Search::Record.find_by(searchable_type: "Comment", searchable_id: comment_id) assert_nil record + # For SQLite, verify FTS entry is also deleted + if Search::Record.connection.adapter_name == "SQLite" + fts_count = Search::Record::SQLite::Fts.where(rowid: search_record_id).count + assert_equal 0, fts_count, "FTS entry should be deleted after comment deletion" + end + # Finding cards via comment search card_with_comment = @board.cards.create!(title: "Card One", creator: @user) card_with_comment.comments.create!(body: "unique searchable phrase", creator: @user)