Replace Search::Index with Search::Records

Lean on ActiveRecord models for searching and strip out the raw SQL.
Replaces the search_index_* tables with sharded search_records_* tables
as that allows us to use a Search::Record model name.

A Class is dynamically created for each record table shard so that we
and we can access it via the Search::Record.for_account(account_id)
method.
This commit is contained in:
Donal McBreen
2025-11-13 18:08:29 +00:00
committed by Mike Dalessio
parent fe6df7085f
commit 28efe28f24
15 changed files with 484 additions and 427 deletions
+15 -26
View File
@@ -5,37 +5,26 @@ module Card::Searchable
include ::Searchable
scope :mentioning, ->(query, user:) do
query = Search::Query.wrap(query)
search_records = Search::Record.for_account(user.account_id)
if query.valid?
table_name = Searchable.search_index_table_name(user.account_id)
uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
serialized_board_ids = user.board_ids.map { |id| uuid_type.serialize(id) }
joins("INNER JOIN #{table_name} ON #{table_name}.card_id = cards.id AND #{table_name}.board_id = cards.board_id")
.where("#{table_name}.board_id IN (?)", serialized_board_ids)
.where("MATCH(#{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", query.to_s)
.distinct
else
none
end
joins(search_records.card_join)
.merge(search_records.for_query(query: Search::Query.wrap(query), user: user))
end
end
private
def search_title
Search::Stemmer.stem title
end
def search_title
title
end
def search_content
Search::Stemmer.stem description.to_plain_text
end
def search_content
description.to_plain_text
end
def search_card_id
id
end
def search_card_id
id
end
def search_board_id
board_id
end
def search_board_id
board_id
end
end
+12 -13
View File
@@ -5,20 +5,19 @@ module Comment::Searchable
include ::Searchable
end
private
def search_title
nil
end
def search_title
nil
end
def search_content
Search::Stemmer.stem body.to_plain_text
end
def search_content
body.to_plain_text
end
def search_card_id
card_id
end
def search_card_id
card_id
end
def search_board_id
card.board_id
end
def search_board_id
card.board_id
end
end
+28 -46
View File
@@ -1,10 +1,6 @@
module Searchable
extend ActiveSupport::Concern
def self.search_index_table_name(account_id)
"search_index_#{Zlib.crc32(account_id) % 16}"
end
included do
after_create_commit :create_in_search_index
after_update_commit :update_in_search_index
@@ -17,54 +13,40 @@ module Searchable
private
def create_in_search_index
table_name = Searchable.search_index_table_name(account_id)
uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
self.class.connection.execute self.class.sanitize_sql([
"INSERT INTO #{table_name} (id, searchable_type, searchable_id, card_id, board_id, title, content, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
uuid_type.serialize(ActiveRecord::Type::Uuid.generate),
self.class.name,
uuid_type.serialize(id),
uuid_type.serialize(search_card_id),
uuid_type.serialize(search_board_id),
search_title,
search_content,
created_at
])
Search::Record.for_account(account_id).create!(search_record_attributes)
end
def update_in_search_index
table_name = Searchable.search_index_table_name(account_id)
uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
result = self.class.connection.execute(self.class.sanitize_sql([
"UPDATE #{table_name} SET card_id = ?, board_id = ?, title = ?, content = ?, created_at = ? WHERE searchable_type = ? AND searchable_id = ?",
uuid_type.serialize(search_card_id),
uuid_type.serialize(search_board_id),
search_title,
search_content,
created_at,
self.class.name,
uuid_type.serialize(id)
]))
create_in_search_index if result.affected_rows == 0
Search::Record.for_account(account_id).upsert_all(
[search_record_attributes.merge(id: ActiveRecord::Type::Uuid.generate)],
update_only: [:card_id, :board_id, :title, :content, :created_at]
)
end
def remove_from_search_index
table_name = Searchable.search_index_table_name(account_id)
uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
self.class.connection.execute self.class.sanitize_sql([
"DELETE FROM #{table_name} WHERE searchable_type = ? AND searchable_id = ?",
self.class.name,
uuid_type.serialize(id)
])
Search::Record.for_account(account_id).where(
searchable_type: self.class.name,
searchable_id: id
).delete_all
end
# Models must implement these methods:
# - search_title: returns title string or nil
# - search_content: returns content string
# - search_card_id: returns the card id (self.id for cards, card_id for comments)
# - search_board_id: returns the board id
def search_record_attributes
{
account_id: account_id,
searchable_type: self.class.name,
searchable_id: id,
card_id: search_card_id,
board_id: search_board_id,
title: Search::Stemmer.stem(search_title),
content: Search::Stemmer.stem(search_content),
created_at: created_at
}
end
# Models must implement these methods:
# - account_id: returns the account id
# - search_title: returns title string or nil
# - search_content: returns content string
# - search_card_id: returns the card id (self.id for cards, card_id for comments)
# - search_board_id: returns the board id
end
+3 -43
View File
@@ -1,49 +1,9 @@
class Search
attr_reader :user, :query
module Search
def self.table_name_prefix
"search_"
end
def initialize(user, query)
@user = user
@query = Query.wrap(query)
def self.results(query:, user:)
Record.for_account(user.account_id).search(query: Query.wrap(query), user: user)
end
def results
if query.valid? && board_ids.any?
perform_search
else
Search::Result.none
end
end
private
def board_ids
@board_ids ||= user.board_ids
end
def perform_search
query_string = query.to_s
sanitized_raw_query = Search::Result.connection.quote(query.terms)
table_name = Searchable.search_index_table_name(user.account_id)
uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
serialized_board_ids = board_ids.map { |id| uuid_type.serialize(id) }
Search::Result.from(table_name)
.joins("INNER JOIN cards ON #{table_name}.card_id = cards.id")
.joins("INNER JOIN boards ON cards.board_id = boards.id")
.where("#{table_name}.board_id IN (?)", serialized_board_ids)
.where("MATCH(#{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", query_string)
.select([
"#{table_name}.card_id as card_id",
"CASE WHEN #{table_name}.searchable_type = 'Comment' THEN #{table_name}.searchable_id ELSE NULL END as comment_id",
"boards.name as board_name",
"cards.creator_id",
"cards.number as number",
"#{table_name}.created_at as created_at",
"#{sanitized_raw_query} AS query"
].join(","))
.order("#{table_name}.created_at DESC")
end
end
+90
View File
@@ -0,0 +1,90 @@
class Search::Record < ApplicationRecord
self.abstract_class = true
SHARD_COUNT = 16
SHARD_CLASSES = SHARD_COUNT.times.map do |shard_id|
Class.new(self) do
self.table_name = "search_records_#{shard_id}"
def self.name
"Search::Record"
end
end
end.freeze
belongs_to :searchable, polymorphic: true
belongs_to :card
# Virtual attributes from search query
attribute :query, :string
validates :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :created_at, presence: true
class << self
def for_account(account_id)
SHARD_CLASSES[shard_id_for_account(account_id)]
end
def shard_id_for_account(account_id)
Zlib.crc32(account_id.to_s) % SHARD_COUNT
end
def card_join
"INNER JOIN #{table_name} ON #{table_name}.card_id = cards.id"
end
end
scope :for_query, ->(query:, user:) do
if query.valid? && user.board_ids.any?
matching(query.to_s).for_user(user)
else
none
end
end
scope :matching, ->(query) do
where("MATCH(#{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", query)
end
scope :for_user, ->(user) do
where(account_id: user.account_id, board_id: user.board_ids)
end
scope :search, ->(query:, user:) do
for_query(query: query, user: user)
.includes(:searchable, card: [:board, :creator])
.select(:id, :searchable_type, :searchable_id, :card_id, :board_id, :account_id, :created_at, "#{connection.quote(query.terms)} AS query")
.order(created_at: :desc)
end
def source
searchable_type == "Comment" ? searchable : card
end
def comment
searchable if searchable_type == "Comment"
end
def card_title
highlight(card.title, show: :full) if card_id
end
def card_description
highlight(card.description.to_plain_text, show: :snippet) if card_id
end
def comment_body
highlight(comment.body.to_plain_text, show: :snippet) if comment
end
private
def highlight(text, show:)
if text.present? && attribute?(:query)
highlighter = Search::Highlighter.new(query)
show == :snippet ? highlighter.snippet(text) : highlighter.highlight(text)
else
text
end
end
end
+1 -1
View File
@@ -6,7 +6,7 @@ module User::Searcher
end
def search(terms)
Search.new(self, terms).results
Search.results(query: terms, user: self)
end
def remember_search(terms)
+3 -3
View File
@@ -2,7 +2,7 @@
<%= link_to result.source, class: "search__result", data: { turbo_frame: "_top", action: "bar#reset" } do %>
<div>
<h3 class="search__title txt--medium margin-none">
# <%= result.number %> <%= result.card_title %>
# <%= result.card.number %> <%= result.card_title %>
</h3>
<% if result.card_description.present? %>
@@ -12,7 +12,7 @@
<% else
if result.comment_body.present? %>
<div class="search__excerpt search__excerpt--comment">
<%= avatar_preview_tag result.creator %>
<%= avatar_preview_tag result.card.creator %>
<div>
<%= result.comment_body %>
</div>
@@ -20,6 +20,6 @@
<% end %>
<% end %>
</div>
<div class="overflow-ellipsis translucent txt-ink"><%= result.board_name %></div>
<div class="overflow-ellipsis translucent txt-ink"><%= result.card.board.name %></div>
<% end %>
</li>
@@ -0,0 +1,34 @@
class CreateSearchRecordShards < ActiveRecord::Migration[8.2]
SHARD_COUNT = 16
def up
# Create 16 sharded search_records tables
SHARD_COUNT.times do |shard_id|
create_table "search_records_#{shard_id}", id: :uuid do |t|
t.uuid :account_id, null: false
t.string :searchable_type, null: false
t.uuid :searchable_id, null: false
t.uuid :card_id, null: false
t.uuid :board_id, null: false
t.string :title
t.text :content
t.datetime :created_at, null: false
t.index [:searchable_type, :searchable_id], unique: true
t.index :account_id
t.index [:content, :title], type: :fulltext
end
end
# Drop old search_index tables
SHARD_COUNT.times do |shard_id|
drop_table "search_index_#{shard_id}", if_exists: true
end
end
def down
SHARD_COUNT.times do |shard_id|
drop_table "search_records_#{shard_id}"
end
end
end
Generated
+225 -193
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_11_13_163145) do
ActiveRecord::Schema[8.2].define(version: 2025_11_13_190256) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -396,198 +396,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_13_163145) do
t.index ["reacter_id"], name: "index_reactions_on_reacter_id"
end
create_table "search_index_0", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si0_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si0_type_id", unique: true
end
create_table "search_index_1", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si1_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si1_type_id", unique: true
end
create_table "search_index_10", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si10_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si10_type_id", unique: true
end
create_table "search_index_11", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si11_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si11_type_id", unique: true
end
create_table "search_index_12", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si12_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si12_type_id", unique: true
end
create_table "search_index_13", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si13_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si13_type_id", unique: true
end
create_table "search_index_14", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si14_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si14_type_id", unique: true
end
create_table "search_index_15", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si15_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si15_type_id", unique: true
end
create_table "search_index_2", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si2_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si2_type_id", unique: true
end
create_table "search_index_3", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si3_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si3_type_id", unique: true
end
create_table "search_index_4", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si4_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si4_type_id", unique: true
end
create_table "search_index_5", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si5_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si5_type_id", unique: true
end
create_table "search_index_6", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si6_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si6_type_id", unique: true
end
create_table "search_index_7", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si7_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si7_type_id", unique: true
end
create_table "search_index_8", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si8_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si8_type_id", unique: true
end
create_table "search_index_9", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["content", "title"], name: "idx_si9_fulltext", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "idx_si9_type_id", unique: true
end
create_table "search_queries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
@@ -600,6 +408,230 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_13_163145) do
t.index ["user_id"], name: "index_search_queries_on_user_id"
end
create_table "search_records_0", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_0_on_account_id"
t.index ["content", "title"], name: "index_search_records_0_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_0_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_1", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_1_on_account_id"
t.index ["content", "title"], name: "index_search_records_1_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_1_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_10", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_10_on_account_id"
t.index ["content", "title"], name: "index_search_records_10_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_10_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_11", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_11_on_account_id"
t.index ["content", "title"], name: "index_search_records_11_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_11_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_12", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_12_on_account_id"
t.index ["content", "title"], name: "index_search_records_12_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_12_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_13", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_13_on_account_id"
t.index ["content", "title"], name: "index_search_records_13_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_13_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_14", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_14_on_account_id"
t.index ["content", "title"], name: "index_search_records_14_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_14_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_15", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_15_on_account_id"
t.index ["content", "title"], name: "index_search_records_15_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_15_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_2", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_2_on_account_id"
t.index ["content", "title"], name: "index_search_records_2_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_2_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_3", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_3_on_account_id"
t.index ["content", "title"], name: "index_search_records_3_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_3_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_4", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_4_on_account_id"
t.index ["content", "title"], name: "index_search_records_4_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_4_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_5", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_5_on_account_id"
t.index ["content", "title"], name: "index_search_records_5_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_5_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_6", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_6_on_account_id"
t.index ["content", "title"], name: "index_search_records_6_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_6_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_7", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_7_on_account_id"
t.index ["content", "title"], name: "index_search_records_7_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_7_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_8", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_8_on_account_id"
t.index ["content", "title"], name: "index_search_records_8_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_8_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_records_9", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "card_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", null: false
t.string "title"
t.index ["account_id"], name: "index_search_records_9_on_account_id"
t.index ["content", "title"], name: "index_search_records_9_on_content_and_title", type: :fulltext
t.index ["searchable_type", "searchable_id"], name: "index_search_records_9_on_searchable_type_and_searchable_id", unique: true
end
create_table "search_results", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+3 -3
View File
@@ -1,9 +1,9 @@
namespace :search do
desc "Reindex all cards and comments in the search index"
task reindex: :environment do
puts "Clearing search index shards..."
16.times do |i|
ActiveRecord::Base.connection.execute("DELETE FROM search_index_#{i}")
puts "Clearing search records..."
Search::Record::SHARD_COUNT.times do |shard_id|
ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}")
end
puts "Reindexing cards..."
+17 -16
View File
@@ -1,31 +1,32 @@
require "test_helper"
class SearchesControllerTest < ActionDispatch::IntegrationTest
include SearchTestHelper
setup do
Card.all.each(&:reindex)
Comment.all.each(&:reindex)
@board.update!(all_access: true)
@card = @board.cards.create!(title: "Layout is broken", creator: @user)
@comment_card = @board.cards.create!(title: "Some card", creator: @user)
@comment_card.comments.create!(body: "overflowing text issue", creator: @user)
sign_in_as :kevin
untenanted { sign_in_as @user }
end
test "show" do
skip("TODO:PLANB: search")
get search_path(q: "broken")
test "search" do
# Searching by card title
get search_path(q: "broken", script_name: "/#{@account.external_account_id}")
assert_select "li", text: /Layout is broken/
end
test "show with card id" do
skip("TODO:PLANB: search")
get search_path(q: cards(:logo).id)
# Searching by comment
get search_path(q: "overflowing", script_name: "/#{@account.external_account_id}")
assert_select "li", text: /Some card/
# Searching by card id
get search_path(q: @card.id, script_name: "/#{@account.external_account_id}")
assert_select "form[data-controller='auto-submit']"
end
test "show with non-existent card id" do
skip("TODO:PLANB: search")
get search_path(q: "999999")
# Searching with non-existent card id
get search_path(q: "999999", script_name: "/#{@account.external_account_id}")
assert_select "form[data-controller='auto-submit']", count: 0
assert_select ".search__empty", text: "No matches"
end
+1 -16
View File
@@ -1,22 +1,7 @@
require "test_helper"
class Card::SearchableTest < ActiveSupport::TestCase
self.use_transactional_tests = false
setup do
16.times { |i| ActiveRecord::Base.connection.execute "DELETE FROM search_index_#{i}" }
Account.find_by(name: "Search Test")&.destroy
@account = Account.create!(name: "Search Test")
@user = User.create!(name: "Test User", account: @account)
@board = Board.create!(name: "Test Board", account: @account, creator: @user)
Current.account = @account
end
teardown do
16.times { |i| ActiveRecord::Base.connection.execute "DELETE FROM search_index_#{i}" }
Account.find_by(name: "Search Test")&.destroy
end
include SearchTestHelper
test "card search" do
# Searching by title
+10 -46
View File
@@ -1,58 +1,28 @@
require "test_helper"
class Comment::SearchableTest < ActiveSupport::TestCase
self.use_transactional_tests = false
include SearchTestHelper
setup do
16.times { |i| ActiveRecord::Base.connection.execute "DELETE FROM search_index_#{i}" }
Account.find_by(name: "Search Test")&.destroy
@account = Account.create!(name: "Search Test")
@user = User.create!(name: "Test User", account: @account)
@board = Board.create!(name: "Test Board", account: @account, creator: @user)
@card = @board.cards.create!(title: "Test Card", creator: @user)
Current.account = @account
end
teardown do
16.times { |i| ActiveRecord::Base.connection.execute "DELETE FROM search_index_#{i}" }
Account.find_by(name: "Search Test")&.destroy
end
test "comment search" do
table_name = Searchable.search_index_table_name(@account.id)
uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
# Comment is indexed on create
comment = @card.comments.create!(body: "searchable comment text", creator: @user)
result = ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql([
"SELECT COUNT(*) FROM #{table_name} WHERE searchable_type = 'Comment' AND searchable_id = ?",
uuid_type.serialize(comment.id)
])
).first[0]
assert_equal 1, result
record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment.id)
assert_not_nil record
# Comment is updated in index
comment.update!(body: "updated text")
content = ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql([
"SELECT content FROM #{table_name} WHERE searchable_type = 'Comment' AND searchable_id = ?",
uuid_type.serialize(comment.id)
])
).first[0]
assert_match /updat/, content
record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment.id)
assert_match /updat/, record.content
# Comment is removed from index on destroy
comment_id = comment.id
comment.destroy
result = ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql([
"SELECT COUNT(*) FROM #{table_name} WHERE searchable_type = 'Comment' AND searchable_id = ?",
uuid_type.serialize(comment_id)
])
).first[0]
assert_equal 0, result
record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment_id)
assert_nil record
# Finding cards via comment search
card_with_comment = @board.cards.create!(title: "Card One", creator: @user)
@@ -64,14 +34,8 @@ class Comment::SearchableTest < ActiveSupport::TestCase
# Comment stores parent card_id and board_id
new_comment = @card.comments.create!(body: "test comment", creator: @user)
row = ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql([
"SELECT card_id, board_id FROM #{table_name} WHERE searchable_type = 'Comment' AND searchable_id = ?",
uuid_type.serialize(new_comment.id)
])
).first
# Deserialize binary UUIDs from result
assert_equal @card.id, uuid_type.deserialize(row[0])
assert_equal @board.id, uuid_type.deserialize(row[1])
record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: new_comment.id)
assert_equal @card.id, record.card_id
assert_equal @board.id, record.board_id
end
end
+6 -21
View File
@@ -1,22 +1,7 @@
require "test_helper"
class SearchTest < ActiveSupport::TestCase
self.use_transactional_tests = false
setup do
16.times { |i| ActiveRecord::Base.connection.execute "DELETE FROM search_index_#{i}" }
Account.find_by(name: "Search Test")&.destroy
@account = Account.create!(name: "Search Test")
@user = User.create!(name: "Test User", account: @account)
@board = Board.create!(name: "Test Board", account: @account, creator: @user)
Current.account = @account
end
teardown do
16.times { |i| ActiveRecord::Base.connection.execute "DELETE FROM search_index_#{i}" }
Account.find_by(name: "Search Test")&.destroy
end
include SearchTestHelper
test "search" do
# Search cards and comments
@@ -24,11 +9,11 @@ class SearchTest < ActiveSupport::TestCase
comment_card = @board.cards.create!(title: "Some card", creator: @user)
comment_card.comments.create!(body: "overflowing text", creator: @user)
results = Search.new(@user, "layout").results
results = Search.results(query: "layout", user: @user)
assert results.find { |it| it.card_id == card.id }
results = Search.new(@user, "overflowing").results
assert results.find { |it| it.card_id == comment_card.id && it.comment_id.present? }
results = Search.results(query: "overflowing", user: @user)
assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == "Comment" }
# Don't include inaccessible boards
other_user = User.create!(name: "Other User", account: @account)
@@ -36,13 +21,13 @@ class SearchTest < ActiveSupport::TestCase
accessible_card = @board.cards.create!(title: "searchable content", creator: @user)
inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user)
results = Search.new(@user, "searchable").results
results = Search.results(query: "searchable", user: @user)
assert results.find { |it| it.card_id == accessible_card.id }
assert_not results.find { |it| it.card_id == inaccessible_card.id }
# Empty board_ids returns no results
user_without_access = User.create!(name: "No Access User", account: @account)
results = Search.new(user_without_access, "anything").results
results = Search.results(query: "anything", user: user_without_access)
assert_empty results
end
end
+36
View File
@@ -0,0 +1,36 @@
module SearchTestHelper
extend ActiveSupport::Concern
included do
self.use_transactional_tests = false
setup :setup_search_test
teardown :teardown_search_test
end
def setup_search_test
clear_search_records
Account.find_by(name: "Search Test")&.destroy
Identity.find_by(email_address: "test@example.com")&.destroy
@account = Account.create!(name: "Search Test", external_account_id: ActiveRecord::FixtureSet.identify("search_test"))
@identity = Identity.create!(email_address: "test@example.com")
@membership = Membership.create!(identity: @identity, tenant: @account.external_account_id)
@user = User.create!(name: "Test User", account: @account, membership: @membership)
@board = Board.create!(name: "Test Board", account: @account, creator: @user)
Current.account = @account
end
def teardown_search_test
clear_search_records
Account.find_by(name: "Search Test")&.destroy
Identity.find_by(email_address: "test@example.com")&.destroy
end
private
def clear_search_records
Search::Record::SHARD_COUNT.times do |shard_id|
ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}")
end
end
end