@@ -31,6 +31,9 @@
|
||||
!/tmp/storage/.keep
|
||||
/data
|
||||
|
||||
*.sqlite3
|
||||
*.sqlite3_*
|
||||
|
||||
/public/assets
|
||||
|
||||
# Ignore master key for decrypting credentials and more.
|
||||
|
||||
@@ -10,3 +10,4 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
|
||||
AllCops:
|
||||
Exclude:
|
||||
- 'db/migrate/**/*'
|
||||
- 'db/schema*.rb'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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?("<b>")
|
||||
# But should preserve highlight marks around "testing"
|
||||
assert_match(/<mark class="circled-text"><span><\/span>testing<\/mark>/, response.body)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user