Merge pull request #1669 from basecamp/sqlite

Sqlite
This commit is contained in:
Donal McBreen
2025-11-26 10:32:09 +00:00
committed by GitHub
42 changed files with 1330 additions and 201 deletions
+3
View File
@@ -31,6 +31,9 @@
!/tmp/storage/.keep
/data
*.sqlite3
*.sqlite3_*
/public/assets
# Ignore master key for decrypting credentials and more.
+1
View File
@@ -10,3 +10,4 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
AllCops:
Exclude:
- 'db/migrate/**/*'
- 'db/schema*.rb'
+1 -1
View File
@@ -1,7 +1,7 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
connects_to database: { writing: :primary, reading: :replica }
configure_replica_connections
attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
end
+1 -2
View File
@@ -66,8 +66,7 @@ module Board::Accessible
#
# 1. Mention->Card
# 2. Mention->Comment->Card
uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
board_id_binary = uuid_type.serialize(id)
board_id_binary = ActiveRecord::Type::Uuid.new.serialize(id)
user.mentions
.joins("LEFT JOIN cards ON mentions.source_id = cards.id AND mentions.source_type = 'Card'")
+3 -3
View File
@@ -7,7 +7,7 @@ module Card::Entropic
.joins(board: :account)
.left_outer_joins(board: :entropy)
.joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id")
.where("last_active_at <= DATE_SUB(?, INTERVAL COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) SECOND)", Time.now)
.where("last_active_at <= #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)')}", Time.now)
end
scope :postponing_soon, -> do
@@ -16,8 +16,8 @@ module Card::Entropic
.joins(board: :account)
.left_outer_joins(board: :entropy)
.joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id")
.where("last_active_at > DATE_SUB(?, INTERVAL COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) SECOND)", now)
.where("last_active_at <= DATE_SUB(?, INTERVAL CAST(COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) * 0.75 AS SIGNED) SECOND)", now)
.where("last_active_at > #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)')}", now)
.where("last_active_at <= #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) * 0.75')}", now)
end
delegate :auto_postpone_period, to: :board
+2 -4
View File
@@ -5,10 +5,8 @@ module Card::Searchable
include ::Searchable
scope :mentioning, ->(query, user:) do
search_records = Search::Record.for_account(user.account_id)
joins(search_records.card_join)
.merge(search_records.for_query(query: Search::Query.wrap(query), user: user))
search_record_class = Search::Record.for(user.account_id)
joins(search_record_class.card_join).merge(search_record_class.for_query(query, user: user))
end
end
+9 -12
View File
@@ -13,37 +13,34 @@ module Searchable
private
def create_in_search_index
Search::Record.for_account(account_id).create!(search_record_attributes)
search_record_class.create!(search_record_attributes)
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, :account_key ]
)
search_record_class.upsert!(search_record_attributes)
end
def remove_from_search_index
Search::Record.for_account(account_id).where(
searchable_type: self.class.name,
searchable_id: id
).delete_all
search_record_class.find_by(searchable_type: self.class.name, searchable_id: id)&.destroy
end
def search_record_attributes
{
account_id: account_id,
account_key: "account#{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),
title: search_title,
content: search_content,
created_at: created_at
}
end
def search_record_class
Search::Record.for(account_id)
end
# Models must implement these methods:
# - account_id: returns the account id
# - search_title: returns title string or nil
-4
View File
@@ -2,8 +2,4 @@ module Search
def self.table_name_prefix
"search_"
end
def self.results(query:, user:)
Record.for_account(user.account_id).search(query: Query.wrap(query), user: user)
end
end
+2 -4
View File
@@ -5,6 +5,8 @@ class Search::Query < ApplicationRecord
validates :terms, presence: true
before_validation :sanitize_terms
delegate :to_s, to: :terms
class << self
def wrap(query)
if query.is_a?(self)
@@ -15,10 +17,6 @@ class Search::Query < ApplicationRecord
end
end
def to_s
Search::Stemmer.stem(terms.to_s)
end
private
def sanitize_terms
self.terms = sanitize(terms)
+17 -58
View File
@@ -1,33 +1,20 @@
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
include const_get(connection.adapter_name)
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
def upsert!(attributes)
record = find_by(searchable_type: attributes[:searchable_type], searchable_id: attributes[:searchable_id])
if record
record.update!(attributes)
record
else
create!(attributes)
end
end
def card_join
@@ -35,29 +22,23 @@ class Search::Record < ApplicationRecord
end
end
scope :for_query, ->(query:, user:) do
scope :for_query, ->(query, user:) do
query = Search::Query.wrap(query)
if query.valid? && user.board_ids.any?
matching(query.to_s, user.account_id).for_user(user)
matching(query.to_s, user.account_id).where(account_id: user.account_id, board_id: user.board_ids)
else
none
end
end
scope :matching, ->(query, account_id) do
account_key = "account#{account_id}"
full_query = "+#{account_key} +(#{query})"
where("MATCH(#{table_name}.account_key, #{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", full_query)
end
scope :search, ->(query, user:) do
query = Search::Query.wrap(query)
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)
for_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)
.select(:id, :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :title, :content, :created_at, *search_fields(query))
end
def source
@@ -67,26 +48,4 @@ class Search::Record < ApplicationRecord
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
+59
View File
@@ -0,0 +1,59 @@
module Search::Record::SQLite
extend ActiveSupport::Concern
included do
# Override default UUID id attribute, as FTS5 uses rowid integer primary key
attribute :id, :integer, default: nil
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, dependent: :destroy
after_save :upsert_to_fts5_table
scope :matching, ->(query, account_id) { joins(:search_records_fts).where("search_records_fts MATCH ?", query) }
end
class_methods do
def search_fields(query)
opening_mark = connection.quote(Search::Highlighter::OPENING_MARK)
closing_mark = connection.quote(Search::Highlighter::CLOSING_MARK)
ellipsis = connection.quote(Search::Highlighter::ELIPSIS)
[ "highlight(search_records_fts, 0, #{opening_mark}, #{closing_mark}) AS result_title",
"snippet(search_records_fts, 1, #{opening_mark}, #{closing_mark}, #{ellipsis}, 20) AS result_content",
"#{connection.quote(query.terms)} AS query" ]
end
def for(account_id)
self
end
end
def card_title
escape_fts_highlight(result_title || card.title)
end
def card_description
escape_fts_highlight(result_content) unless comment
end
def comment_body
escape_fts_highlight(result_content) if comment
end
private
def escape_fts_highlight(html)
return nil unless html.present?
CGI.escapeHTML(html)
.gsub(CGI.escapeHTML(Search::Highlighter::OPENING_MARK), Search::Highlighter::OPENING_MARK)
.gsub(CGI.escapeHTML(Search::Highlighter::CLOSING_MARK), Search::Highlighter::CLOSING_MARK)
.html_safe
end
def upsert_to_fts5_table
Fts.upsert(id, title, content)
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::SQLite::Fts Upsert",
[ rowid, title, content ]
)
end
end
+70
View File
@@ -0,0 +1,70 @@
module Search::Record::Trilogy
extend ActiveSupport::Concern
SHARD_COUNT = 16
included do
self.abstract_class = true
before_save :set_account_key, :stem_content
scope :matching, ->(query, account_id) do
full_query = "+account#{account_id} +(#{Search::Stemmer.stem(query)})"
where("MATCH(#{table_name}.account_key, #{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", full_query)
end
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
end
class_methods do
def shard_id_for_account(account_id)
Zlib.crc32(account_id.to_s) % SHARD_COUNT
end
def search_fields(query)
"#{connection.quote(query.terms)} AS query"
end
def for(account_id)
SHARD_CLASSES[shard_id_for_account(account_id)]
end
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 stem_content
self.title = Search::Stemmer.stem(title) if title_changed?
self.content = Search::Stemmer.stem(content) if content_changed?
end
def set_account_key
self.account_key = "account#{account_id}"
end
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.results(query: terms, user: self)
Search::Record.for(account_id).search(terms, user: self)
end
def remember_search(terms)
+46
View File
@@ -1,5 +1,28 @@
{
"ignored_warnings": [
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "4ea2e6d704b817af1d896dcdf148a89da8b9e428b3327497631ef8d9ed587307",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/card/entropic.rb",
"line": 19,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "active.joins(:board => :account).left_outer_joins(:board => :entropy).joins(\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\").where(\"last_active_at > #{connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")}\", Time.now)",
"render_path": null,
"location": {
"type": "method",
"class": "Card::Entropic",
"method": null
},
"user_input": "connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")",
"confidence": "Weak",
"cwe_id": [
89
],
"note": "No user input allowed"
},
{
"warning_type": "Dangerous Send",
"warning_code": 23,
@@ -68,6 +91,29 @@
470
],
"note": ""
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "bbd8fe3cbbc6b393df770d3142d6fa46e2b9441b21f48a52175211acaae4efad",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/card/entropic.rb",
"line": 10,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "active.joins(:board => :account).left_outer_joins(:board => :entropy).joins(\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\").where(\"last_active_at <= #{connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")}\", Time.now)",
"render_path": null,
"location": {
"type": "method",
"class": "Card::Entropic",
"method": null
},
"user_input": "connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")",
"confidence": "Weak",
"cwe_id": [
89
],
"note": "No user input allowed"
}
],
"brakeman_version": "7.1.0"
+27 -4
View File
@@ -1,4 +1,7 @@
<%
database_adapter = ENV.fetch("DATABASE_ADAPTER", "mysql")
use_sqlite = database_adapter == "sqlite"
if ENV["MIGRATE"].present?
mysql_app_user_key = "MYSQL_ALTER_USER"
mysql_app_password_key = "MYSQL_ALTER_PASSWORD"
@@ -14,6 +17,11 @@
%>
default: &default
<% if use_sqlite %>
adapter: sqlite3
pool: 5
timeout: 5000
<% else %>
adapter: trilogy
host: <%= ENV.fetch "FIZZY_DB_HOST", "127.0.0.1" %>
port: <%= ENV.fetch "FIZZY_DB_PORT", 3306 %>
@@ -22,8 +30,15 @@ default: &default
variables:
transaction_isolation: READ-COMMITTED
max_execution_time: <%= max_execution_time_ms %>
<% end %>
development:
<% if use_sqlite %>
primary:
<<: *default
database: storage/development.sqlite3
schema_dump: schema_sqlite.rb
<% else %>
primary:
<<: *default
database: fizzy_development
@@ -31,28 +46,35 @@ development:
replica:
<<: *default
database: fizzy_development
replica: true
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
replica: true
cable:
<<: *default
database: development_cable
migrations_paths: db/cable_migrate
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
migrations_paths: db/cable_migrate
cache:
<<: *default
database: development_cache
migrations_paths: db/cache_migrate
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
migrations_paths: db/cache_migrate
queue:
<<: *default
database: development_queue
migrations_paths: db/queue_migrate
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
migrations_paths: db/queue_migrate
<% end %>
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<% if use_sqlite %>
primary:
<<: *default
database: storage/test.sqlite3
schema_dump: schema_sqlite.rb
<% else %>
primary:
<<: *default
database: fizzy_test
@@ -62,6 +84,7 @@ test:
database: fizzy_test
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
replica: true
<% end %>
production: &production
primary:
+1 -2
View File
@@ -4,9 +4,8 @@ ActiveSupport.on_load(:active_storage_blob) do
end
end
# Use DB read/write splitting for Active Storage models
ActiveSupport.on_load(:active_storage_record) do
connects_to database: { writing: :primary, reading: :replica }
configure_replica_connections
end
module ActiveStorageControllerExtensions
+5 -1
View File
@@ -1,3 +1,5 @@
require_relative "extensions"
class DatabaseRoleLogger
def initialize(app)
@app = app
@@ -10,4 +12,6 @@ class DatabaseRoleLogger
end
end
Rails.application.config.middleware.insert_after ActiveRecord::Middleware::DatabaseSelector, DatabaseRoleLogger
if ActiveRecord::Base.replica_configured?
Rails.application.config.middleware.insert_after ActiveRecord::Middleware::DatabaseSelector, DatabaseRoleLogger
end
+3
View File
@@ -1,7 +1,10 @@
require "deployment"
require_relative "extensions"
if ActiveRecord::Base.replica_configured?
Rails.application.configure do
config.active_record.database_selector = { delay: 0.seconds }
config.active_record.database_resolver = Deployment::DatabaseResolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
end
end
@@ -0,0 +1,22 @@
# 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
ActiveSupport.on_load(:active_record_sqlite3adapter) do
ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SQLiteFTS5SchemaDumperFix)
end
@@ -0,0 +1,70 @@
# Apply MySQL-compatible column limits when defining tables.
#
# For string columns: defaults to 255 (MySQL's VARCHAR default)
#
# For text columns: converts MySQL's `size:` option to equivalent limits:
# - (blank/default): 65,535 (TEXT)
# - size: :tiny: 255 (TINYTEXT)
# - size: :medium: 16,777,215 (MEDIUMTEXT)
# - size: :long: 4,294,967,295 (LONGTEXT)
#
module TableDefinitionColumnLimits
TEXT_SIZE_TO_LIMIT = {
tiny: 255,
medium: 16_777_215,
long: 4_294_967_295
}.freeze
TEXT_DEFAULT_LIMIT = 65_535
STRING_DEFAULT_LIMIT = 255
def column(name, type, **options)
if type == :string
options[:limit] ||= STRING_DEFAULT_LIMIT
end
if type == :text
if options.key?(:size)
size = options.delete(:size)
options[:limit] ||= TEXT_SIZE_TO_LIMIT.fetch(size) do
raise ArgumentError, "Unknown text size: #{size.inspect}. Use :tiny, :medium, or :long"
end
else
options[:limit] ||= TEXT_DEFAULT_LIMIT
end
end
super
end
end
# For SQLite: append inline CHECK constraints to enforce string/text length limits.
# since SQLite doesn't natively enforce VARCHAR/TEXT length limits.
module SQLiteColumnLimitCheckConstraints
def add_column_options!(sql, options)
super
column = options[:column]
if column && column.limit && %i[string text].include?(column.type)
check_expr = if column.type == :string
# VARCHAR limits are in characters
%(length("#{column.name}") <= #{column.limit})
else
# TEXT limits are in bytes
%(length(CAST("#{column.name}" AS BLOB)) <= #{column.limit})
end
sql << " CHECK(#{check_expr})"
end
sql
end
end
ActiveSupport.on_load(:active_record) do
ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionColumnLimits)
end
ActiveSupport.on_load(:active_record_sqlite3adapter) do
ActiveRecord::ConnectionAdapters::SQLite3::SchemaCreation.prepend(SQLiteColumnLimitCheckConstraints)
end
@@ -43,6 +43,4 @@ module AccountSlug
def self.encode(id) FORMAT % id end
end
Rails.application.config.middleware.tap do |stack|
stack.insert_before ActiveRecord::Middleware::DatabaseSelector, AccountSlug::Extractor
end
Rails.application.config.middleware.insert_after Rack::TempfileReaper, AccountSlug::Extractor
+67 -25
View File
@@ -1,41 +1,83 @@
# Automatically use UUID type for all binary(16) columns
ActiveSupport.on_load(:active_record) do
module MysqlUuidAdapter
# Add UUID to MySQL's native database types
module MysqlUuidAdapter
extend ActiveSupport::Concern
# Override lookup_cast_type to recognize binary(16) as UUID type
def lookup_cast_type(sql_type)
if sql_type == "binary(16)"
ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
else
super
end
end
class_methods do
def native_database_types
@native_database_types_with_uuid ||= super.merge(uuid: { name: "binary", limit: 16 })
end
end
end
# Override lookup_cast_type to recognize binary(16) as UUID type
def lookup_cast_type(sql_type)
if sql_type == "binary(16)"
ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
else
super
end
module SqliteUuidAdapter
extend ActiveSupport::Concern
# Override lookup_cast_type to recognize BLOB as UUID type
def lookup_cast_type(sql_type)
if sql_type == "blob(16)"
ActiveRecord::Type.lookup(:uuid, adapter: :sqlite3)
else
super
end
end
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MysqlUuidAdapter)
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
end
# Override fetch_type_metadata to preserve UUID type and limit
def fetch_type_metadata(sql_type)
if sql_type == "blob(16)"
ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
sql_type: sql_type,
type: :uuid,
limit: 16
)
else
super
end
end
ActiveRecord::ConnectionAdapters::MySQL::SchemaDumper.prepend(SchemaDumperUuidType)
module TableDefinitionUuidSupport
def uuid(name, **options)
column(name, :uuid, **options)
class_methods do
def native_database_types
@native_database_types_with_uuid ||= super.merge(uuid: { name: "blob", limit: 16 })
end
end
end
module SchemaDumperUuidType
# Map binary(16) and blob(16) columns to :uuid type in schema.rb
def schema_type(column)
if column.sql_type == "binary(16)" || column.sql_type == "blob(16)"
:uuid
else
super
end
end
end
module TableDefinitionUuidSupport
def uuid(name, **options)
column(name, :uuid, **options)
end
end
ActiveSupport.on_load(:active_record) do
ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionUuidSupport)
end
ActiveSupport.on_load(:active_record_trilogyadapter) do
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MysqlUuidAdapter)
ActiveRecord::ConnectionAdapters::MySQL::SchemaDumper.prepend(SchemaDumperUuidType)
end
ActiveSupport.on_load(:active_record_sqlite3adapter) do
ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteUuidAdapter)
ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SchemaDumperUuidType)
end
+43 -43
View File
@@ -4,7 +4,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
t.datetime "accessed_at"
t.uuid "board_id", null: false
t.datetime "created_at", null: false
t.string "involvement", default: "access_only", null: false
t.string "involvement", limit: 255, default: "access_only", null: false
t.datetime "updated_at", null: false
t.uuid "user_id", null: false
t.index ["accessed_at"], name: "index_accesses_on_accessed_at", order: :desc
@@ -15,7 +15,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "account_join_codes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.uuid "account_id"
t.string "code", null: false
t.string "code", limit: 255, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "usage_count", default: 0, null: false
@@ -26,7 +26,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "accounts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.datetime "created_at", null: false
t.integer "external_account_id"
t.string "name", null: false
t.string "name", limit: 255, null: false
t.datetime "updated_at", null: false
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
end
@@ -34,9 +34,9 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.text "body", size: :long
t.datetime "created_at", null: false
t.string "name", null: false
t.string "name", limit: 255, null: false
t.uuid "record_id", null: false
t.string "record_type", null: false
t.string "record_type", limit: 255, null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
end
@@ -44,28 +44,28 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "active_storage_attachments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.uuid "blob_id", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.string "name", limit: 255, null: false
t.uuid "record_id", null: false
t.string "record_type", null: false
t.string "record_type", limit: 255, null: false
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, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.bigint "byte_size", null: false
t.string "checksum"
t.string "content_type"
t.string "checksum", limit: 255
t.string "content_type", limit: 255
t.datetime "created_at", null: false
t.string "filename", null: false
t.string "key", null: false
t.string "filename", limit: 255, null: false
t.string "key", limit: 255, null: false
t.text "metadata"
t.string "service_name", null: false
t.string "service_name", limit: 255, null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.uuid "blob_id", null: false
t.string "variation_digest", null: false
t.string "variation_digest", limit: 255, null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
@@ -96,7 +96,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "board_publications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.uuid "board_id", null: false
t.datetime "created_at", null: false
t.string "key"
t.string "key", limit: 255
t.datetime "updated_at", null: false
t.index ["board_id"], name: "index_board_publications_on_board_id"
t.index ["key"], name: "index_board_publications_on_key", unique: true
@@ -107,7 +107,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
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.string "name", limit: 255, null: false
t.datetime "updated_at", null: false
t.index ["creator_id"], name: "index_boards_on_creator_id"
end
@@ -129,7 +129,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "card_engagements", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.uuid "card_id"
t.datetime "created_at", null: false
t.string "status", default: "doing", null: false
t.string "status", limit: 255, default: "doing", null: false
t.datetime "updated_at", null: false
t.index ["card_id"], name: "index_card_engagements_on_card_id"
t.index ["status"], name: "index_card_engagements_on_status"
@@ -159,8 +159,8 @@ class InitialSchema < ActiveRecord::Migration[8.2]
t.uuid "creator_id", null: false
t.date "due_on"
t.datetime "last_active_at", null: false
t.string "status", default: "drafted", null: false
t.string "title"
t.string "status", limit: 255, default: "drafted", null: false
t.string "title", limit: 255
t.datetime "updated_at", null: false
t.index ["board_id"], name: "index_cards_on_board_id"
t.index ["column_id"], name: "index_cards_on_column_id"
@@ -187,9 +187,9 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "columns", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.uuid "account_id"
t.uuid "board_id", null: false
t.string "color", null: false
t.string "color", limit: 255, null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.string "name", limit: 255, null: false
t.integer "position", default: 0, null: false
t.datetime "updated_at", null: false
t.index ["board_id", "position"], name: "index_columns_on_board_id_and_position"
@@ -215,7 +215,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "entropies", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.bigint "auto_postpone_period", default: 2592000, null: false
t.uuid "container_id", null: false
t.string "container_type", null: false
t.string "container_type", limit: 255, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517"
@@ -224,12 +224,12 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "events", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.uuid "account_id"
t.string "action", null: false
t.string "action", limit: 255, 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.string "eventable_type", limit: 255, null: false
t.json "particulars", default: -> { "(json_object())" }
t.datetime "updated_at", null: false
t.index ["action"], name: "index_events_on_summary_id_and_action"
@@ -244,7 +244,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
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.string "params_digest", limit: 255, null: false
t.datetime "updated_at", null: false
t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true
end
@@ -258,13 +258,13 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "identities", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.datetime "created_at", null: false
t.string "email_address", null: false
t.string "email_address", limit: 255, 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, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.string "code", null: false
t.string "code", limit: 255, null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.uuid "identity_id"
@@ -277,8 +277,8 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "memberships", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.datetime "created_at", null: false
t.uuid "identity_id", null: false
t.string "join_code"
t.string "tenant", null: false
t.string "join_code", limit: 255
t.string "tenant", limit: 255, null: false
t.datetime "updated_at", null: false
t.index ["identity_id"], name: "index_memberships_on_identity_id"
t.index ["tenant", "identity_id"], name: "index_memberships_on_tenant_and_identity_id", unique: true
@@ -290,7 +290,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
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.string "source_type", limit: 255, null: false
t.datetime "updated_at", null: false
t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id"
t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id"
@@ -316,7 +316,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
t.uuid "creator_id"
t.datetime "read_at"
t.uuid "source_id", null: false
t.string "source_type", null: false
t.string "source_type", limit: 255, null: false
t.datetime "updated_at", null: false
t.uuid "user_id", null: false
t.index ["creator_id"], name: "index_notifications_on_creator_id"
@@ -337,12 +337,12 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "push_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.uuid "account_id"
t.string "auth_key"
t.string "auth_key", limit: 255
t.datetime "created_at", null: false
t.string "endpoint"
t.string "p256dh_key"
t.text "endpoint"
t.string "p256dh_key", limit: 255
t.datetime "updated_at", null: false
t.string "user_agent"
t.string "user_agent", limit: 255
t.uuid "user_id", null: false
t.index ["endpoint", "p256dh_key", "auth_key"], name: "idx_on_endpoint_p256dh_key_auth_key_7553014576"
t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint"
@@ -379,9 +379,9 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "sessions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.datetime "created_at", null: false
t.uuid "identity_id", null: false
t.string "ip_address"
t.string "ip_address", limit: 255
t.datetime "updated_at", null: false
t.string "user_agent"
t.string "user_agent", limit: 255
t.index ["identity_id"], name: "index_sessions_on_identity_id"
end
@@ -408,7 +408,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "tags", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.uuid "account_id"
t.datetime "created_at", null: false
t.string "title"
t.string "title", limit: 255
t.datetime "updated_at", null: false
t.index ["title"], name: "index_tags_on_title", unique: true
end
@@ -416,7 +416,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
create_table "user_settings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.integer "bundle_email_frequency", default: 0, null: false
t.datetime "created_at", null: false
t.string "timezone_name"
t.string "timezone_name", limit: 255
t.datetime "updated_at", null: false
t.uuid "user_id", null: false
t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency"
@@ -428,8 +428,8 @@ class InitialSchema < ActiveRecord::Migration[8.2]
t.boolean "active", default: true, null: false
t.datetime "created_at", null: false
t.uuid "membership_id"
t.string "name", null: false
t.string "role", default: "member", null: false
t.string "name", limit: 255, null: false
t.string "role", limit: 255, default: "member", null: false
t.datetime "updated_at", null: false
t.index ["membership_id"], name: "index_users_on_membership_id"
t.index ["role"], name: "index_users_on_role"
@@ -460,7 +460,7 @@ class InitialSchema < ActiveRecord::Migration[8.2]
t.uuid "event_id", null: false
t.text "request"
t.text "response"
t.string "state", null: false
t.string "state", limit: 255, null: false
t.datetime "updated_at", null: false
t.uuid "webhook_id", null: false
t.index ["event_id"], name: "index_webhook_deliveries_on_event_id"
@@ -472,8 +472,8 @@ class InitialSchema < ActiveRecord::Migration[8.2]
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.string "name", limit: 255
t.string "signing_secret", limit: 255, null: false
t.text "subscribed_actions"
t.datetime "updated_at", null: false
t.text "url", null: false
@@ -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
@@ -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|
@@ -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, limit: 255, null: false
t.uuid :searchable_id, null: false
t.uuid :card_id, null: false
t.uuid :board_id, null: false
t.string :title, limit: 255
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
@@ -1,13 +1,15 @@
class AddUniqueIndexToCardActivitySpikesOnCardId < ActiveRecord::Migration[8.2]
def change
reversible do |dir|
dir.up do
execute <<-SQL
DELETE s1 FROM card_activity_spikes s1
INNER JOIN card_activity_spikes s2
WHERE s1.card_id = s2.card_id
AND s1.updated_at < s2.updated_at
SQL
if ActiveRecord::Base.connection.adapter_name != "SQLite"
reversible do |dir|
dir.up do
execute <<-SQL
DELETE s1 FROM card_activity_spikes s1
INNER JOIN card_activity_spikes s2
WHERE s1.card_id = s2.card_id
AND s1.updated_at < s2.updated_at
SQL
end
end
end
@@ -1,5 +1,7 @@
class AddAccountKeyToSearchRecords < ActiveRecord::Migration[8.2]
def up
return if ActiveRecord::Base.connection.adapter_name == "SQLite"
16.times do |shard_id|
table_name = "search_records_#{shard_id}"
@@ -9,6 +11,8 @@ class AddAccountKeyToSearchRecords < ActiveRecord::Migration[8.2]
end
def down
return if ActiveRecord::Base.connection.adapter_name == "SQLite"
16.times do |shard_id|
table_name = "search_records_#{shard_id}"
@@ -1,14 +1,15 @@
class RemoveOldFulltextIndexesFromSearchRecords < ActiveRecord::Migration[8.2]
def up
# Remove the old fulltext indexes (content, title) from all 16 search_records shards
# We're keeping the new indexes (account_key, content, title) for tenant-aware searching
return if ActiveRecord::Base.connection.adapter_name == "SQLite"
(0..15).each do |shard|
remove_index "search_records_#{shard}", name: "index_search_records_#{shard}_on_content_and_title"
end
end
def down
# Re-create the old fulltext indexes in case we need to rollback
return if ActiveRecord::Base.connection.adapter_name == "SQLite"
(0..15).each do |shard|
add_index "search_records_#{shard}", [ :content, :title ], type: :fulltext, name: "index_search_records_#{shard}_on_content_and_title"
end
+543
View File
@@ -0,0 +1,543 @@
# 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_25_130010) 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", limit: 255, 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", limit: 255, 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", limit: 255, 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", limit: 4294967295
t.datetime "created_at", null: false
t.string "name", limit: 255, null: false
t.uuid "record_id", null: false
t.string "record_type", limit: 255, 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", limit: 255, null: false
t.uuid "record_id", null: false
t.string "record_type", limit: 255, 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", limit: 255
t.string "content_type", limit: 255
t.datetime "created_at", null: false
t.string "filename", limit: 255, null: false
t.string "key", limit: 255, null: false
t.text "metadata", limit: 65535
t.string "service_name", limit: 255, 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", limit: 255, 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", limit: 255
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", limit: 255, 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", unique: true
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", limit: 255, 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", limit: 255, default: "drafted", null: false
t.string "title", limit: 255
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", limit: 255, null: false
t.datetime "created_at", null: false
t.string "name", limit: 255, 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", limit: 255, 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", limit: 255, 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", limit: 255, 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", limit: 255, 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", limit: 255, null: false
t.boolean "staff", default: false, 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", limit: 255, 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", limit: 255, 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", limit: 255, 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", limit: 255
t.datetime "created_at", null: false
t.text "endpoint", limit: 65535
t.string "p256dh_key", limit: 255
t.datetime "updated_at", null: false
t.string "user_agent", limit: 4096
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", limit: 65535
t.datetime "created_at", null: false
t.uuid "searchable_id", null: false
t.string "searchable_type", limit: 255, null: false
t.string "title", limit: 255
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", limit: 255
t.datetime "updated_at", null: false
t.string "user_agent", limit: 4096
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", limit: 65535, 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", limit: 255
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", limit: 255
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", limit: 255, null: false
t.string "role", limit: 255, 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", limit: 65535
t.text "response", limit: 65535
t.string "state", limit: 255, 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", limit: 255
t.string "signing_secret", limit: 255, null: false
t.text "subscribed_actions", limit: 65535
t.datetime "updated_at", null: false
t.text "url", limit: 65535, 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
execute "CREATE VIRTUAL TABLE search_records_fts USING fts5(\n title,\n content,\n tokenize='porter'\n )"
end
+6 -4
View File
@@ -8,10 +8,12 @@ module Fizzy
Queenbee.host_app = Fizzy
initializer "fizzy_saas.transaction_pinning" do |app|
app.config.middleware.insert_after(
ActiveRecord::Middleware::DatabaseSelector,
TransactionPinning::Middleware
)
if ActiveRecord::Base.replica_configured?
app.config.middleware.insert_after(
ActiveRecord::Middleware::DatabaseSelector,
TransactionPinning::Middleware
)
end
end
config.to_prepare do
@@ -0,0 +1,53 @@
# frozen_string_literal: true
# Adds database-agnostic date arithmetic methods to ActiveRecord adapters.
# This allows code to perform date calculations without checking which database adapter is in use.
#
# Usage:
# connection.date_subtract("created_at", "3600")
# # MySQL/Trilogy: "DATE_SUB(created_at, INTERVAL 3600 SECOND)"
# # SQLite: "datetime(created_at, '-' || (3600) || ' seconds')"
# Module for MySQL-based adapters (Trilogy, Mysql2, etc.)
module MysqlDateArithmetic
# Generates SQL for subtracting seconds from a date/time column in MySQL.
#
# @param date_column [String] The date/time column or expression
# @param seconds_expression [String] SQL expression that evaluates to number of seconds
# @return [String] MySQL DATE_SUB expression
#
# Example:
# date_subtract("last_active_at", "COALESCE(auto_postpone_period, 3600)")
# # => "DATE_SUB(last_active_at, INTERVAL COALESCE(auto_postpone_period, 3600) SECOND)"
def date_subtract(date_column, seconds_expression)
"DATE_SUB(#{date_column}, INTERVAL #{seconds_expression} SECOND)"
end
end
# Module for SQLite adapter
module SqliteDateArithmetic
# Generates SQL for subtracting seconds from a date/time column in SQLite.
#
# @param date_column [String] The date/time column or expression
# @param seconds_expression [String] SQL expression that evaluates to number of seconds
# @return [String] SQLite datetime expression
#
# Example:
# date_subtract("last_active_at", "COALESCE(auto_postpone_period, 3600)")
# # => "datetime(last_active_at, '-' || (COALESCE(auto_postpone_period, 3600)) || ' seconds')"
def date_subtract(date_column, seconds_expression)
"datetime(#{date_column}, '-' || (#{seconds_expression}) || ' seconds')"
end
end
ActiveSupport.on_load(:active_record) do
# Prepend MySQL date arithmetic to AbstractMysqlAdapter (covers Trilogy, Mysql2, etc.)
if defined?(ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter)
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MysqlDateArithmetic)
end
# Prepend SQLite date arithmetic to SQLite3Adapter
if defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter)
ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteDateArithmetic)
end
end
@@ -0,0 +1,40 @@
# frozen_string_literal: true
# Adds a helper method to check if replica database connections are configured
# and automatically configures read/write splitting when replicas are available.
#
# Usage:
# class ApplicationRecord < ActiveRecord::Base
# configure_replica_connections
# end
module ActiveRecordReplicaSupport
extend ActiveSupport::Concern
class_methods do
# Automatically configures connects_to for read/write splitting if a replica
# database is configured for the current environment. This is a no-op if no
# replica configuration exists.
#
# Example:
# class ApplicationRecord < ActiveRecord::Base
# configure_replica_connections
# end
def configure_replica_connections
if replica_configured?
connects_to database: { writing: :primary, reading: :replica }
end
end
# Returns true if a replica database configuration exists for the current
# environment. This allows different database adapters to opt in or out of
# read/write splitting based on their database.yml configuration.
#
# Example:
# ApplicationRecord.replica_configured? # => true for MySQL, false for SQLite
def replica_configured?
configurations.find_db_config("replica").present?
end
end
end
ActiveRecord::Base.include ActiveRecordReplicaSupport
+2 -1
View File
@@ -39,5 +39,6 @@ module ActiveRecord
end
end
# Register the UUID type for Trilogy adapter
# Register the UUID type for Trilogy (MySQL) and SQLite3 adapters
ActiveRecord::Type.register(:uuid, ActiveRecord::Type::Uuid, adapter: :trilogy)
ActiveRecord::Type.register(:uuid, ActiveRecord::Type::Uuid, adapter: :sqlite3)
+9 -8
View File
@@ -2,19 +2,20 @@ namespace :search do
desc "Reindex all cards and comments in the search index"
task reindex: :environment do
puts "Clearing search records..."
Search::Record::SHARD_COUNT.times do |shard_id|
ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}")
if ActiveRecord::Base.connection.adapter_name == "SQLite"
ActiveRecord::Base.connection.execute("DELETE FROM search_records")
ActiveRecord::Base.connection.execute("DELETE FROM search_records_fts")
else
Search::Record::Trilogy::SHARD_COUNT.times do |shard_id|
ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}")
end
end
puts "Reindexing cards..."
Card.find_each do |card|
card.reindex
end
Card.includes(:rich_text_description).find_each(&:reindex)
puts "Reindexing comments..."
Comment.find_each do |comment|
comment.reindex
end
Comment.includes(:rich_text_body, :card).find_each(&:reindex)
puts "Done! Reindexed #{Card.count} cards and #{Comment.count} comments."
end
@@ -30,6 +30,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
assert_select "li .search__title", text: /Just haggis/, count: 2 # card title shows up in two entries
assert_select "li .search__excerpt", text: /More haggis/ # one entry for the card description
assert_select "li .search__excerpt--comment", text: /I love haggis/ # one entry for the comment
assert_match(/<mark class="circled-text"><span><\/span>haggis<\/mark>/, response.body)
# Searching by card id
get search_path(q: @card.id, script_name: "/#{@account.external_account_id}")
@@ -40,4 +41,26 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
assert_select "form[data-controller='auto-submit']", count: 0
assert_select ".search__empty", text: "No matches"
end
test "search highlights matched terms with proper HTML marks" do
@board.cards.create!(title: "Testing search highlighting", creator: @user)
get search_path(q: "highlighting", script_name: "/#{@account.external_account_id}")
assert_response :success
end
test "search preserves highlight marks but escapes surrounding HTML" do
@board.cards.create!(
title: "<b>Bold</b> testing content",
creator: @user
)
get search_path(q: "testing", script_name: "/#{@account.external_account_id}")
assert_response :success
# Should escape <b> tags
assert response.body.include?("&lt;b&gt;")
# But should preserve highlight marks around "testing"
assert_match(/<mark class="circled-text"><span><\/span>testing<\/mark>/, response.body)
end
end
+29
View File
@@ -31,4 +31,33 @@ class Card::SearchableTest < ActiveSupport::TestCase
assert_includes results, card_in_board
assert_not_includes results, card_in_other_board
end
test "deleting card removes search record and FTS entry" do
search_record_class = Search::Record.for(@user.account_id)
card = @board.cards.create!(title: "Card to delete", creator: @user)
# Verify search record exists
search_record = search_record_class.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_class.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_class.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_class.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
+56
View File
@@ -0,0 +1,56 @@
require "test_helper"
class ColumnLimitsTest < ActiveSupport::TestCase
# Database errors for exceeding column limits:
# - MySQL: ActiveRecord::ValueTooLong
# - SQLite: ActiveRecord::CheckViolation
COLUMN_LIMIT_ERRORS = [ ActiveRecord::ValueTooLong, ActiveRecord::CheckViolation ]
test "account name rejects strings over 255 characters" do
account = Account.new(name: "a" * 256)
assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! }
end
test "account name accepts strings up to 255 characters" do
account = Account.new(name: "a" * 255)
assert account.save
end
test "account name accepts 255 emoji characters" do
account = Account.new(name: "🎉" * 255)
assert account.save
end
test "account name rejects 256 emoji characters" do
account = Account.new(name: "🎉" * 256)
assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! }
end
# Test text column limits (65535 bytes for TEXT)
test "step content rejects text over 65535 bytes" do
step = Step.new(content: "a" * 65536, card: cards(:logo))
assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! }
end
test "step content accepts text up to 65535 bytes" do
step = Step.new(content: "a" * 65535, card: cards(:logo))
assert step.save
end
test "step content counts bytes not characters for text columns" do
# 20000 emoji = 20000 chars but 80000 bytes (over 65535 limit)
step = Step.new(content: "🎉" * 20000, card: cards(:logo))
assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! }
end
test "ActionText::RichText name rejects strings over 255 characters" do
rich_text = ActionText::RichText.new(name: "a" * 256, record: cards(:logo))
assert_raises(*COLUMN_LIMIT_ERRORS) { rich_text.save! }
end
test "ActiveStorage::Blob filename rejects strings over 255 characters" do
Current.account = accounts(:"37s")
blob = ActiveStorage::Blob.new(filename: "a" * 256, key: "test-key", byte_size: 0, checksum: "test", service_name: "local")
assert_raises(*COLUMN_LIMIT_ERRORS) { blob.save! }
end
end
+19 -4
View File
@@ -8,22 +8,37 @@ class Comment::SearchableTest < ActiveSupport::TestCase
end
test "comment search" do
search_record_class = Search::Record.for(@user.account_id)
# Comment is indexed on create
comment = @card.comments.create!(body: "searchable comment text", creator: @user)
record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment.id)
record = search_record_class.find_by(searchable_type: "Comment", searchable_id: comment.id)
assert_not_nil record
# Comment is updated in index
comment.update!(body: "updated text")
record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment.id)
record = search_record_class.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
search_record_id = record.id
# For SQLite, verify FTS entry exists before deletion
if search_record_class.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.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment_id)
record = search_record_class.find_by(searchable_type: "Comment", searchable_id: comment_id)
assert_nil record
# For SQLite, verify FTS entry is also deleted
if search_record_class.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)
@@ -34,7 +49,7 @@ class Comment::SearchableTest < ActiveSupport::TestCase
# Comment stores parent card_id and board_id
new_comment = @card.comments.create!(body: "test comment", creator: @user)
record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: new_comment.id)
record = search_record_class.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
+4 -4
View File
@@ -9,10 +9,10 @@ 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.results(query: "layout", user: @user)
results = Search::Record.for(@user.account_id).search("layout", user: @user)
assert results.find { |it| it.card_id == card.id }
results = Search.results(query: "overflowing", user: @user)
results = Search::Record.for(@user.account_id).search("overflowing", user: @user)
assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == "Comment" }
# Don't include inaccessible boards
@@ -21,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.results(query: "searchable", user: @user)
results = Search::Record.for(@user.account_id).search("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.results(query: "anything", user: user_without_access)
results = Search::Record.for(user_without_access.account_id).search("anything", user: user_without_access)
assert_empty results
end
end
+7 -2
View File
@@ -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 ActiveRecord::Base.connection.adapter_name == "SQLite"
ActiveRecord::Base.connection.execute("DELETE FROM search_records")
ActiveRecord::Base.connection.execute("DELETE FROM search_records_fts")
else
Search::Record::Trilogy::SHARD_COUNT.times do |shard_id|
ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}")
end
end
end
end