package shine.db; import java.sql.SQLException; import java.sql.ResultSet; import java.sql.Statement; import java.util.ArrayList; import java.util.List; /** * DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД. * * Мы специально сделали триггеры максимально "совместимыми": * - НЕТ динамических сообщений в RAISE(...): только фиксированные строки. * (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.) * - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него: * INSERT OR IGNORE + UPDATE * (Старые SQLite не знают UPSERT.) * * ============================================================================= * ОПИСАНИЕ ТРИГГЕРОВ * ============================================================================= * * [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks) * Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number). * * Зачем это нужно: * - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность, * где каждый следующий блок явно ссылается на предыдущий блок линии * и подтверждает, что ссылка не подменена. * * Когда срабатывает: * - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей. * - Если line-поля не переданы — триггер вообще не работает (это важно). * * Что проверяет: * A) line-поля допускаются только для msg_type: * 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM) * B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных") * C) prev-блок линии существует в той же цепочке bch_name * D) prev_hash совпадает с block_hash найденного prev-блока * E) line_code корректный: * - либо первый шаг после root: prev_line_number == line_code * - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code * F) this_line_number: * - первый шаг после root: * TEXT: this_line_number = 0 * TECH/CONNECTION/USER_PARAM: this_line_number = 1 * - обычный шаг: * TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг) * TECH/CONNECTION/USER_PARAM: строго prev.this + 1 * * Какие ошибки кидает: * - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE * - LINE_ERR_PARTIAL_FIELDS * - LINE_ERR_NO_PREV * - LINE_ERR_PREV_HASH_MISMATCH * - LINE_ERR_LINE_CODE_MISMATCH * - LINE_ERR_FIRST_STEP_BAD_THIS * - LINE_ERR_THIS_LINE_BAD_STEP * * [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3) * Поддерживает таблицу connections_state как "текущее состояние" отношений: * - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние * - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние * * [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE) * Поддерживает likes_count в message_stats для цели (to_*). * * [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY) * Поддерживает replies_count в message_stats. * * [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT) * Логика edit: * - помечает исходный блок edited_by_block_number = NEW.block_number * - увеличивает edits_count в message_stats */ public final class DatabaseTriggersInstaller { private DatabaseTriggersInstaller() {} public static void createAllTriggers(Statement st) throws SQLException { dropTriggersByPrefix(st, "trg_blocks_"); // На всякий случай убираем старые "криво названные" триггеры, // если они когда-то попадали в БД. st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;"); st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;"); st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai;"); st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai;"); st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai;"); st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai;"); createLineIntegrityTrigger(st); createConnectionStateTrigger(st); createMessageStatsLikeTrigger(st); createMessageStatsReplyTrigger(st); createEditApplyTrigger(st); } private static void dropTriggersByPrefix(Statement st, String prefix) throws SQLException { List triggerNames = new ArrayList<>(); String sql = "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '" + prefix + "%'"; try (ResultSet rs = st.executeQuery(sql)) { while (rs.next()) { String name = rs.getString("name"); if (name != null && !name.isBlank()) { triggerNames.add(name); } } } for (String name : triggerNames) { String safeName = name.replace("\"", "\"\""); st.executeUpdate("DROP TRIGGER IF EXISTS \"" + safeName + "\""); } } private static void createLineIntegrityTrigger(Statement st) throws SQLException { st.executeUpdate(""" CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi BEFORE INSERT ON blocks WHEN NEW.line_code IS NOT NULL OR NEW.prev_line_number IS NOT NULL OR NEW.prev_line_hash IS NOT NULL OR NEW.this_line_number IS NOT NULL BEGIN SELECT RAISE(ABORT, 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE') WHERE NOT (NEW.msg_type IN (0, 1, 3, 4)); SELECT RAISE(ABORT, 'LINE_ERR_PARTIAL_FIELDS') WHERE NEW.line_code IS NULL OR NEW.prev_line_number IS NULL OR NEW.prev_line_hash IS NULL OR NEW.this_line_number IS NULL; SELECT RAISE(ABORT, 'LINE_ERR_NO_PREV') WHERE NOT EXISTS( SELECT 1 FROM blocks p WHERE p.bch_name = NEW.bch_name AND p.block_number = NEW.prev_line_number LIMIT 1 ); SELECT RAISE(ABORT, 'LINE_ERR_PREV_HASH_MISMATCH') WHERE NOT EXISTS( SELECT 1 FROM blocks p WHERE p.bch_name = NEW.bch_name AND p.block_number = NEW.prev_line_number AND p.block_hash = NEW.prev_line_hash LIMIT 1 ); SELECT RAISE(ABORT, 'LINE_ERR_LINE_CODE_MISMATCH') WHERE NEW.prev_line_number <> NEW.line_code AND NOT EXISTS( SELECT 1 FROM blocks p WHERE p.bch_name = NEW.bch_name AND p.block_number = NEW.prev_line_number AND p.line_code = NEW.line_code LIMIT 1 ); SELECT RAISE(ABORT, 'LINE_ERR_FIRST_STEP_BAD_THIS') WHERE NEW.prev_line_number = NEW.line_code AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END); SELECT RAISE(ABORT, 'LINE_ERR_THIS_LINE_BAD_STEP') WHERE NEW.prev_line_number <> NEW.line_code AND NOT EXISTS( SELECT 1 FROM blocks p WHERE p.bch_name = NEW.bch_name AND p.block_number = NEW.prev_line_number AND p.this_line_number IS NOT NULL AND ( (NEW.msg_type = 1 AND (NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1) ) OR (NEW.msg_type IN (0,3,4) AND NEW.this_line_number = p.this_line_number + 1) ) LIMIT 1 ); END; """); } private static void createConnectionStateTrigger(Statement st) throws SQLException { int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND; int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT; int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW; int SPOUSE = (int) DatabaseInitializer.CONNECTION_SPOUSE; int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT; int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD; int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING; int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON; int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED; int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN; int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND; int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT; int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW; int UNSPOUSE = (int) DatabaseInitializer.CONNECTION_UNSPOUSE; int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT; int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD; int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING; int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON; int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED; int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN; st.executeUpdate(""" CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai AFTER INSERT ON blocks WHEN NEW.msg_type = 3 BEGIN -- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*: -- 1) если записи нет — создаём INSERT OR IGNORE INTO connections_state ( login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash ) SELECT NEW.login, NEW.msg_sub_type, COALESCE( NEW.to_login, ( SELECT su.login FROM solana_users su WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE LIMIT 1 ), CASE WHEN NEW.to_bch_name IS NOT NULL AND length(NEW.to_bch_name) > 4 AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) ELSE NULL END ), NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d) AND COALESCE( NEW.to_login, ( SELECT su.login FROM solana_users su WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE LIMIT 1 ), CASE WHEN NEW.to_bch_name IS NOT NULL AND length(NEW.to_bch_name) > 4 AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) ELSE NULL END ) IS NOT NULL AND NEW.to_bch_name IS NOT NULL; -- 2) если запись есть — обновляем актуальные to_* UPDATE connections_state SET to_bch_name = NEW.to_bch_name, to_block_number = NEW.to_block_number, to_block_hash = NEW.to_block_hash WHERE login = NEW.login AND rel_type = NEW.msg_sub_type AND to_login = COALESCE( NEW.to_login, ( SELECT su.login FROM solana_users su WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE LIMIT 1 ), CASE WHEN NEW.to_bch_name IS NOT NULL AND length(NEW.to_bch_name) > 4 AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) ELSE NULL END ) AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d) AND COALESCE( NEW.to_login, ( SELECT su.login FROM solana_users su WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE LIMIT 1 ), CASE WHEN NEW.to_bch_name IS NOT NULL AND length(NEW.to_bch_name) > 4 AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) ELSE NULL END ) IS NOT NULL AND NEW.to_bch_name IS NOT NULL; -- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*: -- удаляем соответствующее "позитивное" состояние DELETE FROM connections_state WHERE login = NEW.login AND to_login = COALESCE( NEW.to_login, ( SELECT su.login FROM solana_users su WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE LIMIT 1 ), CASE WHEN NEW.to_bch_name IS NOT NULL AND length(NEW.to_bch_name) > 4 AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) ELSE NULL END ) AND rel_type = CASE NEW.msg_sub_type WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d ELSE rel_type END AND COALESCE( NEW.to_login, ( SELECT su.login FROM solana_users su WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE LIMIT 1 ), CASE WHEN NEW.to_bch_name IS NOT NULL AND length(NEW.to_bch_name) > 4 AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) ELSE NULL END ) IS NOT NULL AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d); END; """.formatted( FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN, FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN, UNFRIEND, FRIEND, UNCONTACT, CONTACT, UNFOLLOW, FOLLOW, UNSPOUSE, SPOUSE, UNPARENT, PARENT, UNCHILD, CHILD, UNSIBLING, SIBLING, UNKNOWN, KNOWN, SHINE_UNCONF, SHINE_CONF, SHINE_UNSEEN, SHINE_SEEN, UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN )); } private static void createMessageStatsLikeTrigger(Statement st) throws SQLException { int LIKE = (int) DatabaseInitializer.REACTION_LIKE; int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE; st.executeUpdate(""" CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai AFTER INSERT ON blocks WHEN NEW.msg_type = 2 AND NEW.msg_sub_type IN (%d, %d) BEGIN -- ensure target stats row exists INSERT OR IGNORE INTO message_stats ( to_login, to_bch_name, to_block_number, to_block_hash, likes_count, replies_count, edits_count ) SELECT NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, 0, 0, 0 WHERE NEW.to_login IS NOT NULL AND NEW.to_bch_name IS NOT NULL AND NEW.to_block_number IS NOT NULL AND NEW.to_block_hash IS NOT NULL; -- apply delta by state transition (none/unlike->like = +1, like->unlike = -1) UPDATE message_stats SET likes_count = MAX( 0, likes_count + ( CASE WHEN NEW.msg_sub_type = %d AND COALESCE(( SELECT b.msg_sub_type FROM blocks b WHERE b.login = NEW.login AND b.bch_name = NEW.bch_name AND b.msg_type = 2 AND b.to_login = NEW.to_login AND b.to_bch_name = NEW.to_bch_name AND b.to_block_number = NEW.to_block_number AND b.to_block_hash = NEW.to_block_hash AND b.block_number < NEW.block_number ORDER BY b.block_number DESC LIMIT 1 ), -1) <> %d THEN 1 WHEN NEW.msg_sub_type = %d AND COALESCE(( SELECT b.msg_sub_type FROM blocks b WHERE b.login = NEW.login AND b.bch_name = NEW.bch_name AND b.msg_type = 2 AND b.to_login = NEW.to_login AND b.to_bch_name = NEW.to_bch_name AND b.to_block_number = NEW.to_block_number AND b.to_block_hash = NEW.to_block_hash AND b.block_number < NEW.block_number ORDER BY b.block_number DESC LIMIT 1 ), -1) = %d THEN -1 ELSE 0 END ) ) WHERE to_login = NEW.to_login AND to_bch_name = NEW.to_bch_name AND to_block_number = NEW.to_block_number AND to_block_hash = NEW.to_block_hash AND NEW.to_login IS NOT NULL AND NEW.to_bch_name IS NOT NULL AND NEW.to_block_number IS NOT NULL AND NEW.to_block_hash IS NOT NULL; -- persist latest actor->target reaction state INSERT OR IGNORE INTO reactions_state ( from_login, from_bch_name, reaction_type, to_login, to_bch_name, to_block_number, to_block_hash, last_sub_type ) SELECT NEW.login, NEW.bch_name, %d, NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, NEW.msg_sub_type WHERE NEW.to_login IS NOT NULL AND NEW.to_bch_name IS NOT NULL AND NEW.to_block_number IS NOT NULL AND NEW.to_block_hash IS NOT NULL; UPDATE reactions_state SET last_sub_type = NEW.msg_sub_type WHERE from_login = NEW.login AND from_bch_name = NEW.bch_name AND reaction_type = %d AND to_login = NEW.to_login AND to_bch_name = NEW.to_bch_name AND to_block_number = NEW.to_block_number AND to_block_hash = NEW.to_block_hash AND NEW.to_login IS NOT NULL AND NEW.to_bch_name IS NOT NULL AND NEW.to_block_number IS NOT NULL AND NEW.to_block_hash IS NOT NULL; END; """.formatted( LIKE, UNLIKE, LIKE, LIKE, UNLIKE, LIKE, LIKE, LIKE )); } private static void createMessageStatsReplyTrigger(Statement st) throws SQLException { int REPLY = (int) DatabaseInitializer.TEXT_REPLY; st.executeUpdate(""" CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai AFTER INSERT ON blocks WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d BEGIN INSERT OR IGNORE INTO message_stats ( to_login, to_bch_name, to_block_number, to_block_hash, likes_count, replies_count, edits_count ) SELECT NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, 0, 0, 0 WHERE NEW.to_login IS NOT NULL AND NEW.to_bch_name IS NOT NULL AND NEW.to_block_number IS NOT NULL AND NEW.to_block_hash IS NOT NULL; UPDATE message_stats SET replies_count = replies_count + 1 WHERE to_login = NEW.to_login AND to_bch_name = NEW.to_bch_name AND to_block_number = NEW.to_block_number AND to_block_hash = NEW.to_block_hash AND NEW.to_login IS NOT NULL AND NEW.to_bch_name IS NOT NULL AND NEW.to_block_number IS NOT NULL AND NEW.to_block_hash IS NOT NULL; END; """.formatted(REPLY)); } private static void createEditApplyTrigger(Statement st) throws SQLException { int EDIT_POST = (int) DatabaseInitializer.TEXT_EDIT_POST; int EDIT_REPLY = (int) DatabaseInitializer.TEXT_EDIT_REPLY; st.executeUpdate(""" CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai AFTER INSERT ON blocks WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d) BEGIN -- 1) помечаем исходный блок, что его "перекрыл" этот edit UPDATE blocks SET edited_by_block_number = NEW.block_number WHERE login = NEW.login AND bch_name = NEW.bch_name AND block_number = NEW.to_block_number AND NEW.to_block_number IS NOT NULL; -- 2) создаём stats-строку если её не было INSERT OR IGNORE INTO message_stats ( to_login, to_bch_name, to_block_number, to_block_hash, likes_count, replies_count, edits_count ) SELECT NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, 0, 0, 0 WHERE NEW.to_login IS NOT NULL AND NEW.to_bch_name IS NOT NULL AND NEW.to_block_number IS NOT NULL AND NEW.to_block_hash IS NOT NULL; -- 3) +1 edit UPDATE message_stats SET edits_count = edits_count + 1 WHERE to_login = NEW.to_login AND to_bch_name = NEW.to_bch_name AND to_block_number = NEW.to_block_number AND to_block_hash = NEW.to_block_hash AND NEW.to_login IS NOT NULL AND NEW.to_bch_name IS NOT NULL AND NEW.to_block_number IS NOT NULL AND NEW.to_block_hash IS NOT NULL; END; """.formatted(EDIT_POST, EDIT_REPLY)); } }