Проверка триггеров аудита SQLite для iOS на облачном Mac
Если основная разработка проекта велась на Windows, а сборка iOS выполняется на облачном Mac, обновления базы данных могут выглядеть корректными, хотя в журнале аудита появляются лишние записи, пропускаются изменения или после отката интерфейс показывает неверное состояние. Вместо того чтобы искать причину на этапе приёмки готовой сборки, сначала проверьте триггеры SQLite на минимальном исполняемом SQL-сценарии, а затем повторите проверку через соединение, которое действительно использует приложение.
Сначала определите, что означает запись аудита
В этой статье мы регистрируем только изменения заголовка, зафиксированные в базе данных, а не каждое нажатие кнопки сохранения. Повторная запись того же значения не должна добавлять запись в журнал; переход от строки к NULL должен регистрироваться; при откате бизнес-транзакции записи журнала тоже должны откатываться. Сначала оформите эти три условия как контракт, а уже затем реализуйте триггер.
Подготовьте утилиту sqlite3 на облачном Mac и сохраните результат команды sqlite3 --version. Если приложение поставляется со встроенной библиотекой базы данных, дополнительно выполните SELECT sqlite_version() через соединение приложения: версия утилиты командной строки не определяет версию, фактически работающую в iOS.
Триггер записывает данные в обычную таблицу аудита в той же транзакции, что и бизнес-операция. Такой механизм подходит для отслеживания зафиксированных изменений, но не для независимого сохранения неудачных попыток и не обеспечивает защищённый от подмены аудит безопасности.
Используйте в тестах только искусственно созданные записи, не копируйте рабочую базу данных. Старые и новые значения в таблице аудита могут содержать пользовательские данные, поэтому хранить их бессрочно ради удобства диагностики нельзя.
Создайте SQL-сценарий, который завершается ошибкой при нарушении условий
Сохраните следующий код в файл trigger-check.sql. Ограничение во временной таблице проверок превращает неверное количество записей в ошибку SQL. Если лишь выводить результаты запросов и не проверять код завершения, такая проверка не сможет блокировать дальнейшие этапы при ошибке.
.bail on
PRAGMA recursive_triggers = OFF;
CREATE TABLE note (
id INTEGER PRIMARY KEY,
title TEXT
);
CREATE TABLE note_audit (
seq INTEGER PRIMARY KEY,
note_id INTEGER NOT NULL,
old_title TEXT,
new_title TEXT
);
CREATE TRIGGER note_title_audit
AFTER UPDATE OF title ON note
WHEN OLD.title IS NOT NEW.title
BEGIN
INSERT INTO note_audit(note_id, old_title, new_title)
VALUES(NEW.id, OLD.title, NEW.title);
END;
CREATE TEMP TABLE assertion (
ok INTEGER NOT NULL CHECK(ok = 1)
);
INSERT INTO note VALUES(1, 'draft');
UPDATE note SET title = title WHERE id = 1;
INSERT INTO assertion
SELECT count(*) = 0 FROM note_audit;
UPDATE note SET title = NULL WHERE id = 1;
INSERT INTO assertion
SELECT count(*) = 1 FROM note_audit;
INSERT INTO assertion
SELECT old_title IS 'draft' AND new_title IS NULL
FROM note_audit WHERE seq = 1;
UPDATE note SET title = NULL WHERE id = 1;
INSERT INTO assertion
SELECT count(*) = 1 FROM note_audit;
UPDATE note SET title = 'ready' WHERE id = 1;
INSERT INTO assertion
SELECT count(*) = 2 FROM note_audit;
BEGIN;
UPDATE note SET title = 'temporary' WHERE id = 1;
ROLLBACK;
INSERT INTO assertion
SELECT title IS 'ready' FROM note WHERE id = 1;
INSERT INTO assertion
SELECT count(*) = 2 FROM note_audit;
Выполните sqlite3 :memory: < trigger-check.sql. При успехе вывод может отсутствовать; при ошибке команда должна возвращать ненулевой код завершения. Команды с точкой относятся к оболочке командной строки — передавать их без изменений в интерфейс выполнения SQL приложения нельзя.
Ключевой элемент примера — IS NOT: этот оператор различает NULL и значение, отличное от NULL. Если заменить его на !=, сравнения с участием NULL не будут давать истинный результат, из-за чего записи в журнале могут пропускаться.
Убедитесь на заведомо ошибочных вариантах, что проверка действительно работает
Сначала удалите условие WHEN и повторите запуск: уже первое обновление без изменения значения должно привести к нарушению проверочного условия. Затем замените условие на OLD.title != NEW.title — тест перехода к NULL должен завершиться ошибкой. Если ни одна из двух ошибок не обнаружена, проверьте, не теряется ли код завершения в скрипте и не выполняется ли по-прежнему старый файл.
Ещё одна ловушка — имя столбца в UPDATE OF: SQLite допускает несуществующие имена столбцов в определении и не отказывается создавать триггер только из-за опечатки. Временно замените title в этой части примера на titel. При фактическом изменении заголовка проверка должна завершиться ошибкой из-за отсутствующей записи в журнале.
Не ограничивайтесь подсчётом триггеров. Прочитайте фактическое определение через sqlite_schema, сверьте столбцы с помощью PRAGMA table_info(note), а затем обеспечьте хотя бы одно реальное изменение каждого поля, для которого ведётся аудит. Только так можно выявить ситуацию «триггер есть, но он не работает».
Включите в приёмку рекурсию и обновление нескольких строк
Текущий триггер записывает данные только в таблицу аудита и не изменяет note повторно, поэтому не зависит от возможности рекурсивного вызова самого себя. Запустите сценарий отдельно при значениях OFF и ON для рекурсивных триггеров — результаты должны совпадать. При инициализации соединения приложения явно задавайте эту настройку и считывайте её обратно, не полагаясь на значения по умолчанию в инструментах.
Если позже появится триггер, который корректирует заголовок после обновления, повторная запись в исходную таблицу может вызвать рекурсию или дополнительные записи в журнале. Не пытайтесь скрыть проблему настройкой глубины рекурсии. Сначала определите, где выполняется нормализация: в приложении до записи или в одном ответственном за неё триггере.
Затем добавьте несколько записей с разными первичными ключами и измените все заголовки одним оператором обновления. Триггеры SQLite выполняются для каждой строки, поэтому проверяйте содержимое журнала для каждого первичного ключа, а не считайте, что порядковые номера записей отражают порядок обработки бизнес-данных. Для пустой строки, NULL и обновления, не затронувшего ни одной записи, также нужны отдельные тесты.
Проведите заключительную проверку через соединение iOS-приложения
После успешного запуска в командной строке перенесите создание таблиц и операции в интеграционные тесты базы данных, используя реальную обёртку проекта для работы с БД и его интерфейс транзакций. Проверяйте код возврата каждого выполнения и не превращайте ошибки ограничений в пустой результат. После ожидаемой ошибки также убедитесь, что соединение по-прежнему позволяет выполнять запросы.
В завершение смоделируйте сбой записи аудита: добавьте в отдельную тестовую базу ограничение, запрещающее определённое новое значение, и обновите бизнес-таблицу. Убедитесь, что обновление возвращает ошибку, а бизнес-значение и журнал не оказываются частично зафиксированы. После этого вызывающий код должен явно завершить или откатить транзакцию: нельзя предполагать, что любая ошибка автоматически откатывает её целиком.
При передаче результата сохраните тестовый сценарий, фактическое определение триггера, версии базы данных в обеих средах и результаты тестов с ожидаемым сбоем. Если выполнить эти проверки в течение срока аренды, при следующей смене машины достаточно будет повторить тот же набор контрактных тестов, а не судить о надёжности аудита по тому, что кнопка сохранения не выдала ошибку.
Часто задаваемые вопросы
Сработает ли триггер при записи прежнего значения?
Да. UPDATE OF проверяет наличие столбца в запросе, а не изменение значения. Условие WHEN OLD.title IS NOT NEW.title отбирает реальные изменения, включая переходы NULL.
Сохранятся ли записи аудита после отката?
Нет, если они записаны в обычную таблицу в той же транзакции. Откат отменяет и изменение данных, и запись аудита. Для неудачных попыток нужен отдельный механизм.
Арендуйте выделенный Mac для сборки приложений.
Выберите ближайший узел, затем проверьте, сколько памяти и места на диске требуется проекту, и подберите период оплаты. В вашем распоряжении будет отдельный физический Mac, а не виртуальная машина. Актуальную доступность можно проверить в панели управления.