diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index 17e965298..017c1d500 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -13,21 +13,79 @@ module Searchable private def create_in_search_index - Search::Record.for_account(account_id).create!(search_record_attributes) + 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 end def update_in_search_index - 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 ] - ) + 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 ] + ) + end end def remove_from_search_index - Search::Record.for_account(account_id).where( - searchable_type: self.class.name, - searchable_id: id - ).delete_all + 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 + 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}" + ) end def search_record_attributes @@ -37,9 +95,9 @@ module Searchable 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 + title: search_title, + content: search_content, + created_at: created_at || Time.current } end diff --git a/app/models/search/query.rb b/app/models/search/query.rb index d54142f9e..7d96d5170 100644 --- a/app/models/search/query.rb +++ b/app/models/search/query.rb @@ -16,7 +16,10 @@ class Search::Query < ApplicationRecord end def to_s - Search::Stemmer.stem(terms.to_s) + # Return unstemmed terms - each adapter handles stemming appropriately: + # - SQLite: FTS5 Porter tokenizer stems automatically + # - MySQL: Search::Record.matching applies stemming + terms.to_s end private diff --git a/app/models/search/record.rb b/app/models/search/record.rb index 72cf39865..3f08ddfea 100644 --- a/app/models/search/record.rb +++ b/app/models/search/record.rb @@ -3,6 +3,7 @@ class Search::Record < ApplicationRecord SHARD_COUNT = 16 + # MySQL sharded table classes SHARD_CLASSES = SHARD_COUNT.times.map do |shard_id| Class.new(self) do self.table_name = "search_records_#{shard_id}" @@ -13,8 +14,8 @@ class Search::Record < ApplicationRecord end end.freeze - belongs_to :searchable, polymorphic: true - belongs_to :card + belongs_to :searchable, polymorphic: true, optional: true + belongs_to :card, optional: true # Virtual attributes from search query attribute :query, :string @@ -22,8 +23,29 @@ class Search::Record < ApplicationRecord validates :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :created_at, presence: true class << self + def sqlite? + connection.adapter_name == "SQLite" + end + def for_account(account_id) - SHARD_CLASSES[shard_id_for_account(account_id)] + if sqlite? + # SQLite uses a single table, no sharding - use a non-abstract subclass + @sqlite_class ||= Class.new(self) do + self.abstract_class = false + self.table_name = "search_records" + + # Override the UUID id attribute from ApplicationRecord + # SQLite uses integer auto-increment primary key (no default) + attribute :id, :integer, default: nil + + def self.name + "Search::Record" + end + end + else + # MySQL uses sharded tables + SHARD_CLASSES[shard_id_for_account(account_id)] + end end def shard_id_for_account(account_id) @@ -33,6 +55,41 @@ class Search::Record < ApplicationRecord def card_join "INNER JOIN #{table_name} ON #{table_name}.card_id = cards.id" end + + # Convert MySQL BOOLEAN MODE syntax to SQLite FTS5 syntax + def convert_to_fts5_query(query) + query = query.to_s.dup + + # Handle quoted phrases - these work the same in both + # Extract them to protect during processing + phrases = [] + query.gsub!(/"([^"]+)"/) do + phrases << $1 + "__PHRASE_#{phrases.length - 1}__" + end + + # Split into tokens + tokens = query.split(/\s+/) + + processed_tokens = tokens.map do |token| + if token.start_with?("__PHRASE_") + # Restore phrase + idx = token[/\d+/].to_i + "\"#{phrases[idx]}\"" + elsif token.start_with?("+") + # Remove + prefix (FTS5 ANDs by default) + token[1..] + elsif token.start_with?("-") + # Convert -term to NOT term + "NOT #{token[1..]}" + else + token + end + end + + # Join with AND (FTS5 uses AND/OR/NOT operators) + processed_tokens.reject(&:blank?).join(" ") + end end scope :for_query, ->(query:, user:) do @@ -44,7 +101,18 @@ class Search::Record < ApplicationRecord end scope :matching, ->(query) do - where("MATCH(#{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", query) + if sqlite? + # SQLite FTS5: join on rowid for fast lookup with native highlighting + # Porter tokenizer handles stemming automatically + fts_query = convert_to_fts5_query(query) + + joins("INNER JOIN search_records_fts ON search_records_fts.rowid = #{table_name}.id") + .where("search_records_fts MATCH ?", fts_query) + else + # MySQL FULLTEXT: manually stem query terms + stemmed_query = Search::Stemmer.stem(query) + where("MATCH(#{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", stemmed_query) + end end scope :for_user, ->(user) do @@ -52,10 +120,36 @@ class Search::Record < ApplicationRecord end scope :search, ->(query:, user:) do - for_query(query: query, user: user) + relation = 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) + + if sqlite? + # SQLite: matching scope already selected all columns + FTS5 highlight columns + # Re-select to add query terms (ActiveRecord replaces the select list) + opening_mark = Search::Highlighter::OPENING_MARK + closing_mark = Search::Highlighter::CLOSING_MARK + ellipsis = Search::Highlighter::ELIPSIS + + relation.select( + "#{table_name}.id", + "#{table_name}.account_id", + "#{table_name}.searchable_type", + "#{table_name}.searchable_id", + "#{table_name}.card_id", + "#{table_name}.board_id", + "#{table_name}.title", + "#{table_name}.content", + "#{table_name}.created_at", + "highlight(search_records_fts, 0, #{connection.quote(opening_mark)}, #{connection.quote(closing_mark)}) AS highlighted_title", + "highlight(search_records_fts, 1, #{connection.quote(opening_mark)}, #{connection.quote(closing_mark)}) AS highlighted_content", + "snippet(search_records_fts, 1, #{connection.quote(opening_mark)}, #{connection.quote(closing_mark)}, #{connection.quote(ellipsis)}, 20) AS content_snippet", + "#{connection.quote(query.terms)} AS query" + ) + else + # MySQL: select specific columns needed + relation.select(:id, :searchable_type, :searchable_id, :card_id, :board_id, :account_id, :created_at, "#{connection.quote(query.terms)} AS query") + end end def source @@ -67,15 +161,39 @@ class Search::Record < ApplicationRecord end def card_title - highlight(card.title, show: :full) if card_id + if card_id + if self.class.sqlite? && attribute?(:highlighted_title) + # Use FTS5 native highlighting (already HTML-safe from FTS5) + highlighted_title.html_safe + else + # MySQL: use Ruby highlighter + highlight(card.title, show: :full) + end + end end def card_description - highlight(card.description.to_plain_text, show: :snippet) if card_id + if card_id + if self.class.sqlite? && attribute?(:content_snippet) + # Use FTS5 native snippet for content (already HTML-safe from FTS5) + content_snippet.html_safe if content_snippet.present? + else + # MySQL: use Ruby highlighter + highlight(card.description.to_plain_text, show: :snippet) + end + end end def comment_body - highlight(comment.body.to_plain_text, show: :snippet) if comment + if comment + if self.class.sqlite? && attribute?(:content_snippet) + # Use FTS5 native snippet for content (already HTML-safe from FTS5) + content_snippet.html_safe if content_snippet.present? + else + # MySQL: use Ruby highlighter + highlight(comment.body.to_plain_text, show: :snippet) + end + end end private diff --git a/config/database.yml b/config/database.yml index 9cd4820a5..39396ffb3 100644 --- a/config/database.yml +++ b/config/database.yml @@ -32,11 +32,13 @@ development: primary: <<: *default database: db/development.sqlite3 + schema_dump: schema_sqlite.rb <% else %> primary: <<: *default database: fizzy_development port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + schema_dump: schema.rb replica: <<: *default database: fizzy_development @@ -67,11 +69,13 @@ test: primary: <<: *default database: db/test.sqlite3 + schema_dump: schema_sqlite.rb <% else %> primary: <<: *default database: fizzy_test port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + schema_dump: schema.rb replica: <<: *default database: fizzy_test diff --git a/config/initializers/sqlite_compatibility.rb b/config/initializers/sqlite_compatibility.rb index 3cff5522f..b0d192912 100644 --- a/config/initializers/sqlite_compatibility.rb +++ b/config/initializers/sqlite_compatibility.rb @@ -16,8 +16,6 @@ module SQLiteCompatibility # Override index method to filter out MySQL-specific index options def index(column_name, **options) if @conn.is_a?(ActiveRecord::ConnectionAdapters::SQLite3Adapter) - # Skip fulltext indexes entirely for SQLite - return if options[:type] == :fulltext # SQLite doesn't support length-limited indexes options = options.except(:length) end diff --git a/config/initializers/sqlite_schema_dumper.rb b/config/initializers/sqlite_schema_dumper.rb new file mode 100644 index 000000000..c1a43417c --- /dev/null +++ b/config/initializers/sqlite_schema_dumper.rb @@ -0,0 +1,33 @@ +# Fix for SQLite FTS5 virtual table schema dumping +# Rails has a bug where it doesn't handle FTS5 content= and content_rowid= options + +module SQLiteFTS5SchemaDumperFix + # Override the virtual_tables method to handle FTS5 syntax properly + def virtual_tables(stream) + # Query sqlite_master for all virtual tables + virtual_table_sqls = @connection.select_rows( + "SELECT name, sql FROM sqlite_master WHERE type='table' AND sql LIKE 'CREATE VIRTUAL TABLE%'" + ) + + virtual_table_sqls.each do |table_name, sql| + # Just output the raw SQL since create_virtual_table doesn't handle our syntax + stream.puts " execute #{sql.inspect}" + stream.puts + end + end +end + +if ENV.fetch("DATABASE_ADAPTER", "mysql") == "sqlite" + ActiveSupport.on_load(:active_record) do + # Ensure schema dumper is loaded + begin + require 'active_record/connection_adapters/sqlite3/schema_dumper' + rescue LoadError + # SQLite3 adapter not available + end + + if defined?(ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper) + ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SQLiteFTS5SchemaDumperFix) + end + end +end diff --git a/config/initializers/uuid_primary_keys.rb b/config/initializers/uuid_primary_keys.rb index a1a9dd8ec..1b41315ec 100644 --- a/config/initializers/uuid_primary_keys.rb +++ b/config/initializers/uuid_primary_keys.rb @@ -1,4 +1,15 @@ # Automatically use UUID type for all binary(16) columns + +# Define schema dumper module outside on_load so it can be prepended +module SchemaDumperUuidType + # Map binary(16) and blob(16) columns to :uuid type in schema.rb + def schema_type(column) + return :uuid if column.sql_type == "binary(16)" + return :uuid if column.sql_type == "blob(16)" + super + end +end + ActiveSupport.on_load(:active_record) do module MysqlUuidAdapter # Add UUID to MySQL's native database types @@ -21,7 +32,7 @@ ActiveSupport.on_load(:active_record) do end module SqliteUuidAdapter - # Add UUID to SQLite's native database types + # Add UUID to SQLite's native database types (instance method) def native_database_types @native_database_types_with_uuid ||= super.merge(uuid: { name: "blob", limit: 16 }) end @@ -51,23 +62,42 @@ ActiveSupport.on_load(:active_record) do if defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter) ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteUuidAdapter) - end - module SchemaDumperUuidType - # Map binary(16) columns to :uuid type in schema.rb - def schema_type(column) - if column.sql_type == "binary(16)" - :uuid - else - super + # Also add UUID to class-level native_database_types + ActiveRecord::ConnectionAdapters::SQLite3Adapter.class_eval do + @native_database_types = nil # Clear cache + + class << self + alias_method :original_native_database_types, :native_database_types + + def native_database_types + @native_database_types_with_uuid ||= original_native_database_types.merge(uuid: { name: "blob", limit: 16 }) + end end end end + # Ensure schema dumper classes are loaded before prepending + begin + require 'active_record/connection_adapters/mysql/schema_dumper' + rescue LoadError + # MySQL adapter not available + end + + begin + require 'active_record/connection_adapters/sqlite3/schema_dumper' + rescue LoadError + # SQLite3 adapter not available + end + if defined?(ActiveRecord::ConnectionAdapters::MySQL::SchemaDumper) ActiveRecord::ConnectionAdapters::MySQL::SchemaDumper.prepend(SchemaDumperUuidType) end + if defined?(ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper) + ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SchemaDumperUuidType) + end + module TableDefinitionUuidSupport def uuid(name, **options) column(name, :uuid, **options) diff --git a/db/migrate/20251112093037_create_search_indices.rb b/db/migrate/20251112093037_create_search_indices.rb index 441284590..26ca71ee5 100644 --- a/db/migrate/20251112093037_create_search_indices.rb +++ b/db/migrate/20251112093037_create_search_indices.rb @@ -1,5 +1,8 @@ class CreateSearchIndices < ActiveRecord::Migration[8.2] def up + # Skip for SQLite - it doesn't use these tables + return if connection.adapter_name == "SQLite" + 16.times do |i| create_table "search_index_#{i}".to_sym, id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.string :searchable_type, null: false @@ -17,6 +20,9 @@ class CreateSearchIndices < ActiveRecord::Migration[8.2] end def down + # Skip for SQLite - it doesn't use these tables + return if connection.adapter_name == "SQLite" + 16.times do |i| drop_table "search_index_#{i}".to_sym end diff --git a/db/migrate/20251113190256_create_search_record_shards.rb b/db/migrate/20251113190256_create_search_record_shards.rb index 5afb2ab8e..83f8f626e 100644 --- a/db/migrate/20251113190256_create_search_record_shards.rb +++ b/db/migrate/20251113190256_create_search_record_shards.rb @@ -2,6 +2,9 @@ class CreateSearchRecordShards < ActiveRecord::Migration[8.2] SHARD_COUNT = 16 def change + # Skip for SQLite - it uses a single search_records table instead + return if connection.adapter_name == "SQLite" + # Create 16 sharded search_records tables SHARD_COUNT.times do |shard_id| create_table "search_records_#{shard_id}", id: :uuid do |t| diff --git a/db/migrate/20251120110206_add_search_records.rb b/db/migrate/20251120110206_add_search_records.rb new file mode 100644 index 000000000..866a39b48 --- /dev/null +++ b/db/migrate/20251120110206_add_search_records.rb @@ -0,0 +1,37 @@ +class AddSearchRecords < ActiveRecord::Migration[8.2] + def up + return unless connection.adapter_name == "SQLite" + + # Create regular table with integer primary key for FTS5 rowid compatibility + create_table :search_records 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 + end + + # Create FTS5 virtual table using Porter stemmer + # No triggers needed - Searchable concern handles sync via callbacks + execute <<-SQL + CREATE VIRTUAL TABLE search_records_fts USING fts5( + title, + content, + tokenize='porter' + ) + SQL + end + + def down + return unless connection.adapter_name == "SQLite" + + execute "DROP TABLE IF EXISTS search_records_fts" + drop_table :search_records, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index e6a403fee..6164fd37f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_20_203100) do +ActiveRecord::Schema[8.2].define(version: 2025_11_20_110206) 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 @@ -46,7 +46,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_20_203100) do create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false - t.text "body", size: :long + t.text "body" t.datetime "created_at", null: false t.string "name", null: false t.uuid "record_id", null: false @@ -408,6 +408,21 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_20_203100) do t.index ["user_id"], name: "index_search_queries_on_user_id" end + create_table "search_records", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.string "account_key", 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_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_on_searchable_type_and_searchable_id", unique: true + 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 diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb new file mode 100644 index 000000000..387e0aa06 --- /dev/null +++ b/db/schema_sqlite.rb @@ -0,0 +1,578 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.2].define(version: 2025_11_20_110206) do + create_table "accesses", id: :uuid, force: :cascade do |t| + t.datetime "accessed_at" + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.datetime "created_at", null: false + t.string "involvement", default: "access_only", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id", "accessed_at"], name: "index_accesses_on_account_id_and_accessed_at" + t.index ["board_id", "user_id"], name: "index_accesses_on_board_id_and_user_id", unique: true + t.index ["board_id"], name: "index_accesses_on_board_id" + t.index ["user_id"], name: "index_accesses_on_user_id" + end + + create_table "account_join_codes", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.string "code", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "usage_count", default: 0, null: false + t.bigint "usage_limit", default: 10, null: false + t.index ["account_id", "code"], name: "index_account_join_codes_on_account_id_and_code", unique: true + end + + create_table "accounts", id: :uuid, force: :cascade do |t| + t.bigint "cards_count", default: 0, null: false + t.datetime "created_at", null: false + t.bigint "external_account_id" + t.string "name", null: false + t.datetime "updated_at", null: false + t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true + end + + create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.text "body" + t.datetime "created_at", null: false + t.string "name", null: false + t.uuid "record_id", null: false + t.string "record_type", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_action_text_rich_texts_on_account_id" + t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true + end + + create_table "active_storage_attachments", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "blob_id", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.uuid "record_id", null: false + t.string "record_type", null: false + t.index ["account_id"], name: "index_active_storage_attachments_on_account_id" + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.string "content_type" + t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false + t.index ["account_id"], name: "index_active_storage_blobs_on_account_id" + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "blob_id", null: false + t.string "variation_digest", null: false + t.index ["account_id"], name: "index_active_storage_variant_records_on_account_id" + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "assignees_filters", id: false, force: :cascade do |t| + t.uuid "assignee_id", null: false + t.uuid "filter_id", null: false + t.index ["assignee_id"], name: "index_assignees_filters_on_assignee_id" + t.index ["filter_id"], name: "index_assignees_filters_on_filter_id" + end + + create_table "assigners_filters", id: false, force: :cascade do |t| + t.uuid "assigner_id", null: false + t.uuid "filter_id", null: false + t.index ["assigner_id"], name: "index_assigners_filters_on_assigner_id" + t.index ["filter_id"], name: "index_assigners_filters_on_filter_id" + end + + create_table "assignments", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "assignee_id", null: false + t.uuid "assigner_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_assignments_on_account_id" + t.index ["assignee_id", "card_id"], name: "index_assignments_on_assignee_id_and_card_id", unique: true + t.index ["card_id"], name: "index_assignments_on_card_id" + end + + create_table "board_publications", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.datetime "created_at", null: false + t.string "key" + t.datetime "updated_at", null: false + t.index ["account_id", "key"], name: "index_board_publications_on_account_id_and_key" + t.index ["board_id"], name: "index_board_publications_on_board_id" + end + + create_table "boards", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.boolean "all_access", default: false, null: false + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_boards_on_account_id" + t.index ["creator_id"], name: "index_boards_on_creator_id" + end + + create_table "boards_filters", id: false, force: :cascade do |t| + t.uuid "board_id", null: false + t.uuid "filter_id", null: false + t.index ["board_id"], name: "index_boards_filters_on_board_id" + t.index ["filter_id"], name: "index_boards_filters_on_filter_id" + end + + create_table "card_activity_spikes", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_card_activity_spikes_on_account_id" + t.index ["card_id"], name: "index_card_activity_spikes_on_card_id" + end + + create_table "card_engagements", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id" + t.datetime "created_at", null: false + t.string "status", default: "doing", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "status"], name: "index_card_engagements_on_account_id_and_status" + t.index ["card_id"], name: "index_card_engagements_on_card_id" + end + + create_table "card_goldnesses", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_card_goldnesses_on_account_id" + t.index ["card_id"], name: "index_card_goldnesses_on_card_id", unique: true + end + + create_table "card_not_nows", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "user_id" + t.index ["account_id"], name: "index_card_not_nows_on_account_id" + t.index ["card_id"], name: "index_card_not_nows_on_card_id", unique: true + t.index ["user_id"], name: "index_card_not_nows_on_user_id" + end + + create_table "cards", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.uuid "column_id" + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.date "due_on" + t.datetime "last_active_at", null: false + t.bigint "number", null: false + t.string "status", default: "drafted", null: false + t.string "title" + t.datetime "updated_at", null: false + t.index ["account_id", "last_active_at", "status"], name: "index_cards_on_account_id_and_last_active_at_and_status" + t.index ["account_id", "number"], name: "index_cards_on_account_id_and_number", unique: true + t.index ["board_id"], name: "index_cards_on_board_id" + t.index ["column_id"], name: "index_cards_on_column_id" + end + + create_table "closers_filters", id: false, force: :cascade do |t| + t.uuid "closer_id", null: false + t.uuid "filter_id", null: false + t.index ["closer_id"], name: "index_closers_filters_on_closer_id" + t.index ["filter_id"], name: "index_closers_filters_on_filter_id" + end + + create_table "closures", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "user_id" + t.index ["account_id"], name: "index_closures_on_account_id" + t.index ["card_id", "created_at"], name: "index_closures_on_card_id_and_created_at" + t.index ["card_id"], name: "index_closures_on_card_id", unique: true + t.index ["user_id"], name: "index_closures_on_user_id" + end + + create_table "columns", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.string "color", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.integer "position", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_columns_on_account_id" + t.index ["board_id", "position"], name: "index_columns_on_board_id_and_position" + t.index ["board_id"], name: "index_columns_on_board_id" + end + + create_table "comments", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_comments_on_account_id" + t.index ["card_id"], name: "index_comments_on_card_id" + end + + create_table "creators_filters", id: false, force: :cascade do |t| + t.uuid "creator_id", null: false + t.uuid "filter_id", null: false + t.index ["creator_id"], name: "index_creators_filters_on_creator_id" + t.index ["filter_id"], name: "index_creators_filters_on_filter_id" + end + + create_table "entropies", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.bigint "auto_postpone_period", default: 2592000, null: false + t.uuid "container_id", null: false + t.string "container_type", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_entropies_on_account_id" + t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" + t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true + end + + create_table "events", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.string "action", null: false + t.uuid "board_id", null: false + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.uuid "eventable_id", null: false + t.string "eventable_type", null: false + t.json "particulars", default: -> { "json_object()" } + t.datetime "updated_at", null: false + t.index ["account_id", "action"], name: "index_events_on_account_id_and_action" + t.index ["board_id", "action", "created_at"], name: "index_events_on_board_id_and_action_and_created_at" + t.index ["board_id"], name: "index_events_on_board_id" + t.index ["creator_id"], name: "index_events_on_creator_id" + t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" + end + + create_table "filters", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.json "fields", default: -> { "json_object()" }, null: false + t.string "params_digest", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_filters_on_account_id" + t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true + end + + create_table "filters_tags", id: false, force: :cascade do |t| + t.uuid "filter_id", null: false + t.uuid "tag_id", null: false + t.index ["filter_id"], name: "index_filters_tags_on_filter_id" + t.index ["tag_id"], name: "index_filters_tags_on_tag_id" + end + + create_table "identities", id: :uuid, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email_address", null: false + t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_identities_on_email_address", unique: true + end + + create_table "magic_links", id: :uuid, force: :cascade do |t| + t.string "code", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.uuid "identity_id" + t.datetime "updated_at", null: false + t.index ["code"], name: "index_magic_links_on_code", unique: true + t.index ["expires_at"], name: "index_magic_links_on_expires_at" + t.index ["identity_id"], name: "index_magic_links_on_identity_id" + end + + create_table "mentions", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "mentionee_id", null: false + t.uuid "mentioner_id", null: false + t.uuid "source_id", null: false + t.string "source_type", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_mentions_on_account_id" + t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id" + t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id" + t.index ["source_type", "source_id"], name: "index_mentions_on_source" + end + + create_table "notification_bundles", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.datetime "ends_at", null: false + t.datetime "starts_at", null: false + t.integer "status", default: 0, null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_notification_bundles_on_account_id" + t.index ["ends_at", "status"], name: "index_notification_bundles_on_ends_at_and_status" + t.index ["user_id", "starts_at", "ends_at"], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5" + t.index ["user_id", "status"], name: "index_notification_bundles_on_user_id_and_status" + end + + create_table "notifications", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "creator_id" + t.datetime "read_at" + t.uuid "source_id", null: false + t.string "source_type", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_notifications_on_account_id" + t.index ["creator_id"], name: "index_notifications_on_creator_id" + t.index ["source_type", "source_id"], name: "index_notifications_on_source" + t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } + t.index ["user_id"], name: "index_notifications_on_user_id" + end + + create_table "pins", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_pins_on_account_id" + t.index ["card_id", "user_id"], name: "index_pins_on_card_id_and_user_id", unique: true + t.index ["card_id"], name: "index_pins_on_card_id" + t.index ["user_id"], name: "index_pins_on_user_id" + end + + create_table "push_subscriptions", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.string "auth_key" + t.datetime "created_at", null: false + t.text "endpoint" + t.string "p256dh_key" + t.datetime "updated_at", null: false + t.string "user_agent" + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_push_subscriptions_on_account_id" + t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true + end + + create_table "reactions", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "comment_id", null: false + t.string "content", limit: 16, null: false + t.datetime "created_at", null: false + t.uuid "reacter_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_reactions_on_account_id" + t.index ["comment_id"], name: "index_reactions_on_comment_id" + t.index ["reacter_id"], name: "index_reactions_on_reacter_id" + end + + create_table "search_queries", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.string "terms", limit: 2000, null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_search_queries_on_account_id" + t.index ["user_id", "terms"], name: "index_search_queries_on_user_id_and_terms" + t.index ["user_id", "updated_at"], name: "index_search_queries_on_user_id_and_updated_at", unique: true + t.index ["user_id"], name: "index_search_queries_on_user_id" + end + + create_table "search_records", 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_on_account_id" + t.index ["searchable_type", "searchable_id"], name: "index_search_records_on_searchable_type_and_searchable_id", unique: true + end + + create_table "sessions", id: :uuid, force: :cascade do |t| + t.datetime "created_at", null: false + t.uuid "identity_id", null: false + t.string "ip_address" + t.datetime "updated_at", null: false + t.string "user_agent" + t.index ["identity_id"], name: "index_sessions_on_identity_id" + end + + create_table "steps", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.boolean "completed", default: false, null: false + t.text "content", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_steps_on_account_id" + t.index ["card_id", "completed"], name: "index_steps_on_card_id_and_completed" + t.index ["card_id"], name: "index_steps_on_card_id" + end + + create_table "taggings", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.uuid "tag_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_taggings_on_account_id" + t.index ["card_id", "tag_id"], name: "index_taggings_on_card_id_and_tag_id", unique: true + t.index ["tag_id"], name: "index_taggings_on_tag_id" + end + + create_table "tags", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.string "title" + t.datetime "updated_at", null: false + t.index ["account_id", "title"], name: "index_tags_on_account_id_and_title", unique: true + end + + create_table "user_settings", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.integer "bundle_email_frequency", default: 0, null: false + t.datetime "created_at", null: false + t.string "timezone_name" + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_user_settings_on_account_id" + t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency" + t.index ["user_id"], name: "index_user_settings_on_user_id" + end + + create_table "users", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.uuid "identity_id" + t.string "name", null: false + t.string "role", default: "member", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true + t.index ["account_id", "role"], name: "index_users_on_account_id_and_role" + t.index ["identity_id"], name: "index_users_on_identity_id" + end + + create_table "watches", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.boolean "watching", default: true, null: false + t.index ["account_id"], name: "index_watches_on_account_id" + t.index ["card_id"], name: "index_watches_on_card_id" + t.index ["user_id", "card_id"], name: "index_watches_on_user_id_and_card_id" + t.index ["user_id"], name: "index_watches_on_user_id" + end + + create_table "webhook_delinquency_trackers", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.integer "consecutive_failures_count", default: 0 + t.datetime "created_at", null: false + t.datetime "first_failure_at" + t.datetime "updated_at", null: false + t.uuid "webhook_id", null: false + t.index ["account_id"], name: "index_webhook_delinquency_trackers_on_account_id" + t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id" + end + + create_table "webhook_deliveries", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "event_id", null: false + t.text "request" + t.text "response" + t.string "state", null: false + t.datetime "updated_at", null: false + t.uuid "webhook_id", null: false + t.index ["account_id"], name: "index_webhook_deliveries_on_account_id" + t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" + t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" + end + + create_table "webhooks", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.boolean "active", default: true, null: false + t.uuid "board_id", null: false + t.datetime "created_at", null: false + t.string "name" + t.string "signing_secret", null: false + t.text "subscribed_actions" + t.datetime "updated_at", null: false + t.text "url", null: false + t.index ["account_id"], name: "index_webhooks_on_account_id" + t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions" + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "board_publications", "boards" + add_foreign_key "card_activity_spikes", "cards" + add_foreign_key "card_goldnesses", "cards" + add_foreign_key "card_not_nows", "cards" + add_foreign_key "card_not_nows", "users" + add_foreign_key "cards", "columns" + add_foreign_key "closures", "cards" + add_foreign_key "closures", "users" + add_foreign_key "columns", "boards" + add_foreign_key "comments", "cards" + add_foreign_key "events", "boards" + add_foreign_key "magic_links", "identities" + add_foreign_key "mentions", "users", column: "mentionee_id" + add_foreign_key "mentions", "users", column: "mentioner_id" + add_foreign_key "notification_bundles", "users" + add_foreign_key "notifications", "users" + add_foreign_key "notifications", "users", column: "creator_id" + add_foreign_key "pins", "cards" + add_foreign_key "pins", "users" + add_foreign_key "push_subscriptions", "users" + add_foreign_key "search_queries", "users" + add_foreign_key "sessions", "identities" + add_foreign_key "steps", "cards" + add_foreign_key "taggings", "cards" + add_foreign_key "taggings", "tags" + add_foreign_key "user_settings", "users" + add_foreign_key "users", "identities" + add_foreign_key "watches", "cards" + add_foreign_key "watches", "users" + add_foreign_key "webhook_delinquency_trackers", "webhooks" + add_foreign_key "webhook_deliveries", "events" + add_foreign_key "webhook_deliveries", "webhooks" + add_foreign_key "webhooks", "boards" + execute "CREATE VIRTUAL TABLE search_records_fts USING fts5(\n title,\n content,\n tokenize='porter'\n )" + +end diff --git a/test/test_helpers/search_test_helper.rb b/test/test_helpers/search_test_helper.rb index f35f1c2a8..7132c2ed2 100644 --- a/test/test_helpers/search_test_helper.rb +++ b/test/test_helpers/search_test_helper.rb @@ -28,8 +28,13 @@ module SearchTestHelper private def clear_search_records - Search::Record::SHARD_COUNT.times do |shard_id| - ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}") + if Search::Record.sqlite? + ActiveRecord::Base.connection.execute("DELETE FROM search_records") + ActiveRecord::Base.connection.execute("DELETE FROM search_records_fts") + else + Search::Record::SHARD_COUNT.times do |shard_id| + ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}") + end end end end