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.
This commit is contained in:
Donal McBreen
2025-11-21 15:21:11 +00:00
parent 04c9d9268b
commit 525e53a2f7
4 changed files with 69 additions and 18 deletions
+6 -18
View File
@@ -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
+21
View File
@@ -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
+28
View File
@@ -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
+14
View File
@@ -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)