Вакцинирование продом
Об авторе
Всем привет. Меня зовут Кирилл. За 10 лет в страховой отрасли прошел путь от IT-эникейщика до ТимЛида группы разработки страховых продуктов аквизиции. В этой статье поделюсь простыми приёмами по перешагиванию через очевидные грабли, чтобы движение через Зону Жизненного Опыта, Получаемую в Процессе было менее болезненно.
Приёмы
1. Резервная копия данных
Байка из жизни #1
Решил добавить столбец к табличке в Pervasive на проде. Размер таблицы в топ 5 базы. Запустил консольную утилиту по перестройке файла данных, подождал полчасика и закрыл – думал зависла. Файл данных таблицы, неожиданно (как же ещё) стал невалидным.
Ночь, сервак на другом конце Москвы в БЦ, неработающая ключевая таблица – утром ждет разнос. В судорожных попытках вспомнить порядок действий приходит понимание – есть бэкап! Процесс восстановления пощекотал нервы – «а вдруг не…», но обошлось.
После этого случая, выработал для себя 2 железных правила:
- Делай бекап/снапшот всякий раз перед изменением (даже для мелкого update’а);
- Проверяй решение на другом инстансе, до деплоя на прод.
Самый простой способ помочь завтрашнему себе – сделать скрипты восстановления данных при их модифицировании (Insert/Update/Delete).
Для операции Delete достаточно выгрузить данные из IDE – на основании Select’а формируется датасет и просто экспортируется в файл/кэш как набор Insert-скриптов.
Важно выгружать все данные по Foreign Keys, явным или нет, в виде отдельных Insert-инструкций. Особенно чувствительно, когда таблицы с данными каскадные либо со специфичной логикой в API/триггерах удаления. Варианты архитектуры, когда в Delete API реализован Insert/update/Post-сервисы стоит рассматривать отдельно – у данных может быть разная критичность.
Как это выглядит в PL\SQL Developer
Второй по сложности оператор DML – Insert. Можно просто сохранить список Primary Keys (PK). Или написать запрос, который отберёт созданные записи по специфичным маркерам. При наличии в таблице поля с автором создания (CreatedBy), датой создания, или другим особым полем, задача сильно упрощается.
На практике, чаще всего, сохраняется запрос создания, по которому можно написать обратный скрипт удаления. На мой взгляд, список PK надежнее – вероятность затереть что-то чужое сводится почти к 0.
Третья и самая сложная DML-операция – Update. Здесь возможны различные комбинации способов восстановления. Наиболее сложный случай в практике – восстановить данные по прошествии времени (полгода-год), когда над данными пользователь уже активно
поиздевалсяпоработал. В итоге потребовалось формализовать последовательность действий пользователя в виде скрипта. Прогнать скрипт на согласованном массиве данных – части списка для восстановления. И, естественно, проверить с двух сторон: бизнеса и DevTeam.
Подитог
Бэкапы и снапшоты удобны тогда, когда есть опыт и возможность их использования. Наличие прямого доступа к файловой системе, либо удобный интерфейс инкрементального восстановления только облегчают задачу. Хотя работа через консольные приложения по-прежнему имеет своих ярых адептов.
Наиболее понятный вариант – накатить снапшот/бэкап при полной остановке инстанса в монопольном режиме работы. Минус этого варианта – ограниченность применения для промышленных решений 24/7/365, так как незапланированные простои даже в минуту порождают лавины алертов на ответственных. Поэтому ради восстановления пары-тройки записей в таблице, городить огород со специализированными схемами/таблицами – занятие на любителя. Порой легче уговорить пользователя завести данные заново.
Из практики
Работу со скриптами отката можно сильно облегчить через архитектуру БД – создать теневые таблицы. Решение спорное, т.к. для каждой таблицы приходится индивидуально отвечать на вопрос «что лучше?»: полные логи или малое потребление дискового пространства.
В Компании есть реализация механизма – историчные таблицы, доступные для просмотра пользователю. В них сохраняются предыдущие значения записей основных таблиц. В итоге есть читабельный лог изменения данных.
До Update
Сам Update
Update Message Set ExternalID = '123' Where ID = 1;
Update Message Set ExternalID = '321' Where ID = 1;
Update Message Set ExternalID = '789' Where ID = 1;
После Update
2. Feature Toggle (FT)
Байка из жизни #2
Команда целый инкремент перепиливает алгоритм расчета премии по страховому продукту. Затронуты как фронт, так и несколько систем бэка. Деплой идет тремя параллельными релизами в разное время в течение дня. День релиза определен, доработки закреплены, влиты в регрессные сборки, релиз-менеджеры финализируют тестирование и готовятся к деплою. Сутки до выкладки… и возникает вопрос, как поведет себя прод в промежутках между выкладками релизных сборок разных систем: фронт доехал, бэк в процессе, а кусок системы расчета тарифов уже вовсю работает. Страшновато. Начинаешь спешно просматривать все pull request’ы по выкладке, выясняя, что в итоге отвалится. В тот раз пронесло, благодаря требованиям к обратной совместимости (обработка null для нового входящего параметра) – неконсистентность кода прошла незаметной.
Итогом стало принятие на уровне команды парочки подходов:
- После выкладки крупных изменений, проверяем сами на проде, до анонсирования бизнесу;
- Декомпозиция Features на User Story таким образом, чтобы можно было их деплоить независимо (привет CI/CD);
- Обязательно наличие FT (сейчас наличие FT является НФТ для большей части доработок со стороны Компании).
Байка из жизни #3
В другой раз выкатывали изменения UI внутреннего страхового продукта. Допилили, залили, включились в релиз, и на регрессном стеде смежная команда находит баг (да-да, это случается, и это нормально) в одной из проверок UI. Анализ дефекта показывает, что не учли крупный бизнес-кейс – его доработка требует качественного тестирования и спокойной головы при написании кода. Хорошо, что доработку проверки зафичетоглили под отдельным кодом FT, т.е. она может быть отключена независимо. Подключаем бизнес и принимаем решение, что проверка некритична и можем запускаться без неё. FT отключается, задача тех долга на исправление закидывается в бэклог ближайшего спринта, дефект закрывается и релиз выходит без сбоев. Танцуют Все!
Что вынес для себя:
- Несколько кодов FT для одной стори хоть и усложняет обслуживание техдолга, но экономит нервы и время при ловле багов4
- Если UI поддерживает FT – используй.
Практика FT чуть сложнее резервной копии данных. Для нее необходим некий механизм логических переключателей – будь то таблица в БД, конфиг-файлы или настройки метаданных. Плюс требуется внутреннее согласие с ростом техдолга и необходимостью его обслуживания.
Сразу уточню, описание дано для Release Toggles. Их срок жизни – не дольше 2-3 релизных циклов (2 статья из Уголовного кодекса разработчика достаточно ярко подсвечивают столь жесткое требование). Необходимы для уменьшения количества внепланов/критических релизов. Польза в том, чтобы даже при полном отказе прода, его можно было легко (в идеале мгновенно, в процессе деплоя) реанимировать, отключив новый код.
Долгоживущие FT A/B тестирования, несут в себе другую ценность и на спокойствие разработчика при выкладке кода особо не влияют.
Базовые принципы:
Выключенный FT код не меняет, то есть курсор выполнения проходит ровно все те же блоки в том же порядке, что и без FT;
FT не используются повторно. На проде точно, а другие инстансы – на совести разработчика;
4 минута выступления Doug Sven - как не надо делать
Knightmare: A DevOps Предостерегающая история
4 минута видео
FT обязательно выпиливать после подтверждения отсутствия критических отказов. Мелкий косяк через 3-4 месяца активного (!) использования кода – явление настолько тривиальное, что ради него откатывать все решение нерационально.
Для разработчика последний пункт самый спорный. Однако в гигантской кодовой базе, где понимание работы даже в рамках пакета может быть неполным, лучше сразу выпилить неработающий код, чем всякий раз при разборе инцидентов вспоминать «а оно еще надо»?
3.Тесты сходимости/модульные тесты
Байка из жизни #4
Перепала как-то задача переписать запрос Oracle онлайн-канала – периодически оптимизатор выбирает план с full scan по датасету в 3-4m записей. Это был не первый подход к снаряду. Очевидные вещи в виде разбиения на запросы с union уже были применены. Нужен индекс. Так как таблица большая и с активными I/O операциями, DBA просят обоснования, что создание индекаса действительно поможет.
Просят – пожалуйста. Сделано 2 версии запроса, обернуты в FT и влиты в тестовый стенд. Дальше, через RunStats анализ используемых ресурсов, потом прогон на совпадение результатов по нескольким сотням примеров. Сошлись и это хорошо.
Следом то же самое на регрессе и проде – запускаешь скрипт и идешь пить чай. Приходишь и смотришь результаты – новая версия работает быстрее, ошибок нет.
Самый приятный бонус данного подхода – один раз настраиваешь тесты (при разработке), и потом не паришься, когда нужно допилить функционал (протестировать руками даже 20 кейсов 3-4 раза подряд желания вызывает мало)
За этот подход спасибо коллегам. Крайне полезен при рефакторинге легаси. Суть – взять данные в базе (список договоров) и прогнать по максимуму через обе реализации метода, старую и новую. Так как enterprise-база кишит тестовыми примерами разной степени кривизны, при выборке в несколько тысяч-десятков тысяч примеров, шансов пропустить критический косяк очень мало. Добавив еще и сравнением производительности, получается хорошая серебряная пуля при разговоре с архитекторами.
Практика прекрасно сочетается с FT. В рамках одного запуска кода легко сравнить старое и новое решения.
Примеры кода
declare
vTime1 number := 0;
vTime2 number := 0;
procedure prc(pID in number, pRowID IN ROWID)
is
function find(pSeconds out number) return number
is
vResult NUMBER;
begin
pSeconds := dbms_utility.get_time/100;
-- Тестируемый метод
vResult := TestPack.TestMethod;
pSeconds := (dbms_utility.get_time/100 - pSeconds);
return vResult;
end;
procedure TestCase(pCase in varchar2)
is
vResult1 number;
vResult2 number;
vSeconds1 number;
vSeconds2 number;
vDiff VARCHAR2(100);
begin
SAVEPOINT SP;
ftoggle.Disable('FT_12345');
vResult1 := find(vSeconds1);
vTime1 := vTime1 + vSeconds1;
ftoggle.Enable('FT_12345');
vResult2 := find(vSeconds2);
vTime2 := vTime2 + vSeconds2;
RollBack To SP;
if nvl(vResult1, -1) <> nvl(vResult2, -1) then
vDiff := ';[' || pCase || '] ' || pAgrISN || ': ' || vResult1 || '<>' || vResult2;
end if;
Update TMP_Table t
Set T.ERR = T.ERR||NVL2(T.ERR||vDiff, ';', Null)||vDiff,
T.STATUS = Decode(pCase, '1', Nvl(T.STATUS,0) + vSeconds1*1000000 + vSeconds2, T.STATUS))
Where T.RowID = pRowID;
end;
begin
GetTestData(pID);
TestCase('1');
TestCase('2');
TestCase('3');
end;
begin
for vCur in (select to_number(t.fio) As ID, T.RowID from TMP_Table t Where T.Status Is Null)
loop
prc(vCur.ID, vCur.RowID);
Update TMP_Table t Set T.ERR = 'Compare' Where T.RowID = vCur.Rowid And T.ERR Is Null;
end loop;
end;
Логичное развитие тестов сходимости – модульные тесты (Unit tests, UT). Когда проверки присутствуют в базе одновременно с продовым кодом (за теорией – к Roy Osherove).
Польза UT не однозначна на старте проекта. Однако при развитии решения, благодаря UT стоимость рефакторинга снижается (см. техдолг). Наиболее очевидный аспект – меньше времени тратится на ретест при изменении кода.
Вопросы, как писать UT на Oracle достойны отдельной статьи. Здесь же приведу пару примеров реализации в формате тестопригодного дизайна на Oracle.
Пример UT#1
Проверяемый метод
-- тип для расчета периодов
TYPE TPERIOD IS RECORD (
pBeg DATE,
pEnd DATE
);
TYPE TPERIODTABLE IS TABLE OF TPERIOD;
/**
* ==========================================================================<br/>
* Расчет периодов многолетнего страхования <br/>
* ==========================================================================<br/>
* @param pBegin - начало периода
* @param pEnd - окончание периода
* @return - таблица с периодами
*/
FUNCTION CalcPeriods(pBegin IN DATE, pEnd IN DATE) RETURN TPERIODTABLE
AS
vResult TPERIODTABLE := TPERIODTABLE();
vPeriod TPERIOD;
vBegin DATE := TRUNC(pBegin);
vEnd DATE := TRUNC(pEnd);
vMonths NUMBER := GREATEST(months_between(vEnd, vBegin), 1);
-- Необходимо корректировать 1 день в феврале високосного года
NeedCorrectLeapYear BOOLEAN := (TO_CHAR(vBegin,'dd.mm') = '28.02') AND (TO_CHAR(vBegin,'mm') = TO_CHAR(vBegin + 1,'mm'));
BEGIN
--Договор может закончиться в том же месяце, но через несколько дней. Добавим месяц.
IF TRUNC(vMonths) < vMonths THEN
vMonths := TRUNC(vMonths)+1;
END IF;
FOR cMonth IN 1..vMonths
LOOP
IF MOD(cMonth - 1, 12) = 0 THEN
vPeriod.pbeg := add_months(vBegin, cMonth - 1);
vPeriod.pend := add_months(vPeriod.pbeg, 12) - 1;
IF vPeriod.pend > pend THEN
vPeriod.pend := pend;
END IF;
IF (NeedCorrectLeapYear AND (TO_CHAR(vPeriod.pend,'dd.mm') = '28.02'))
THEN
vPeriod.pend := vPeriod.pend - 1;
END IF;
vResult.extend;
vResult(vResult.last) := vPeriod;
END IF;
END LOOP;
RETURN vResult;
END CalcPeriods;
Тесты
/**
* ========================================================================<br/>
* Получить параметры вызова метода CalcPeriods по-умолчанию<br/>
* ========================================================================<br/>
* @param pDateBeg - Дата начала периода
* @param pAddMonths - Длительность периода в месяцах
* @return - Запись типа TPERIOD с данными периода
* ! Важно - Процедура использует Oracle Add_months, поведение которой для 29.02 специфично
*/
FUNCTION GetDefaultValues (pDateBeg IN DATE DEFAULT TRUNC(SYSDATE),
pAddMonths IN INTEGER DEFAULT 12) RETURN TPERIOD
IS
vResult TPERIOD;
BEGIN
vResult.pBeg := pDateBeg;
vResult.pEnd := Add_months(vResult.pBeg, pAddMonths) - 1;
RETURN vResult;
END GetDefaultValues;
/**
* ========================================================================<br/>
* Сформировать CLOB по периоду<br/>
* ========================================================================<br/>
* @param pPeriod - Тип с информацией по периоду
* @return - Структурированный CLOB с данными
*/
FUNCTION getPeriodClob(pPeriod IN TPERIOD
) RETURN CLOB
IS
BEGIN
RETURN TO_CLOB('<PERIOD>'||
'<DATEBEGIN>'||TO_CHAR(pPeriod.pbeg, 'DD.MM.YYYY')||'</DATEBEGIN>'||
'<DATEEND>'||TO_CHAR(pPeriod.pend, 'DD.MM.YYYY')||'</DATEEND>'||
'</PERIOD>');
END getPeriodClob;
/**
* ========================================================================<br/>
* Сформировать CLOB по периоду (reload)<br/>
* ========================================================================<br/>
* @param pBeg - Дата начала периода
* @param pBeg - Дата окончания периода
* @return - Структурированный CLOB с данными
*/
FUNCTION getPeriodClob(pBeg IN DATE, pEnd IN DATE) RETURN CLOB
IS
vPeriod TPERIOD;
BEGIN
vPeriod.pBeg := pBeg;
vPeriod.pEnd := pEnd;
RETURN getPeriodClob(vPeriod);
END getPeriodClob;
/**
* ========================================================================<br/>
* Тест разбиения периода на 1 год<br/>
* ========================================================================<br/>
*/
PROCEDURE PER_1YEAR
IS
vDefPeriod TPERIOD;
vPeriods TPERIODTABLE;
vRes XMLType;
vResOK XMLType;
BEGIN
vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('01.01.2020', 'DD.MM.YYYY'));
vResOK := XMLTYPE('<PERIODS>'||getPeriodClob(pPeriod => vDefPeriod)||'</PERIODS>');
vPeriods := CalcPeriods (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend,);
vRes := getPeriodsXML(pPeriods => vPeriods);
UNITTEST.CheckTestResult('PER_1YEAR', vRes, vResOK);
END PER_1YEAR;
/**
* ========================================================================<br/>
* Тест разбиения периода на 2 года<br/>
* ========================================================================<br/>
*/
PROCEDURE PER_2YEAR
IS
vDefPeriod TPERIOD;
vPeriods TPERIODTABLE;
vOkPeriods TPERIODTABLE;
vRes XMLType;
vResOK XMLType;
BEGIN
vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('01.01.2020', 'DD.MM.YYYY'), pAddMonths => 24);
-- Определить даты интервалов
Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
From (
Select vDefPeriod.pbeg As dBeg, TO_DATE('31.12.2020', 'DD.MM.YYYY') As dEnd From Dual
Union All
Select TO_DATE('01.01.2021', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
) T;
vResOK := getPeriodsXML(pPeriods => vOkPeriods);
vPeriods := CalcPeriods (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
vRes := getPeriodsXML(pPeriods => vPeriods);
UNITTEST.CheckTestResult('PER_2YEAR', vRes, vResOK);
END PER_2YEAR;
/**
* ========================================================================<br/>
* Тест разбиения периода на 400 дней<br/>
* ========================================================================<br/>
*/
PROCEDURE PER_400DAYS
IS
vDefPeriod TPERIOD;
vPeriods TPERIODTABLE;
vOkPeriods TPERIODTABLE;
vRes XMLType;
vResOK XMLType;
BEGIN
vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('17.01.2020', 'DD.MM.YYYY'), pAddMonths => 12);
vDefPeriod.pend := vDefPeriod.pbeg + 400;
-- Определить даты интервалов
Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
From (
Select vDefPeriod.pbeg As dBeg, TO_DATE('16.01.2021', 'DD.MM.YYYY') As dEnd From Dual
Union All
Select TO_DATE('17.01.2021', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
) T;
vResOK := getPeriodsXML(pPeriods => vOkPeriods);
vPeriods := CalcPeriods (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
vRes := getPeriodsXML(pPeriods => vPeriods);
UNITTEST.CheckTestResult('PER_400DAYS', vRes, vResOK);
END PER_400DAYS;
/**
* ========================================================================<br/>
* Тест разбиения периода на 4 года(40 месяцев)<br/>
* ========================================================================<br/>
*/
PROCEDURE PER_40MONTHS
IS
vDefPeriod TPERIOD;
vPeriods TPERIODTABLE;
vOkPeriods TPERIODTABLE;
vRes XMLType;
vResOK XMLType;
BEGIN
vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('01.01.2020', 'DD.MM.YYYY'), pAddMonths => 40);
-- Определить даты интервалов
Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
From (
Select vDefPeriod.pbeg As dBeg, TO_DATE('31.12.2020', 'DD.MM.YYYY') As dEnd From Dual
Union All
Select TO_DATE('01.01.2021', 'DD.MM.YYYY'), TO_DATE('31.12.2021', 'DD.MM.YYYY') From Dual
Union All
Select TO_DATE('01.01.2022', 'DD.MM.YYYY'), TO_DATE('31.12.2022', 'DD.MM.YYYY') From Dual
Union All
Select TO_DATE('01.01.2023', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
) T;
vResOK := getPeriodsXML(pPeriods => vOkPeriods);
vPeriods := CalcPeriods (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
vRes := getPeriodsXML(pPeriods => vPeriods);
UNITTEST.CheckTestResult('PER_40MONTHS', vRes, vResOK);
END PER_40MONTHS;
/**
* ========================================================================<br/>
* Тест разбиения периода на 3 года c 27.02 пред високосного года<br/>
* ========================================================================<br/>
*/
PROCEDURE PER_3YEAR_2702
IS
vDefPeriod TPERIOD;
vPeriods TPERIODTABLE;
vOkPeriods TPERIODTABLE;
vRes XMLType;
vResOK XMLType;
BEGIN
vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('27.02.2023', 'DD.MM.YYYY'), pAddMonths => 36);
-- Определить даты интервалов
Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
From (
Select vDefPeriod.pbeg As dBeg, TO_DATE('26.02.2024', 'DD.MM.YYYY') As dEnd From Dual
Union All
Select TO_DATE('27.02.2024', 'DD.MM.YYYY'), TO_DATE('26.02.2025', 'DD.MM.YYYY') From Dual
Union All
Select TO_DATE('27.02.2025', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
) T;
vResOK := getPeriodsXML(pPeriods => vOkPeriods);
vPeriods := CalcPeriods (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
vRes := getPeriodsXML(pPeriods => vPeriods);
UNITTEST.CheckTestResult('PER_3YEAR_2702', vRes, vResOK);
END PER_3YEAR_2702;
/**
* ========================================================================<br/>
* Тест разбиения периода на 3 года c 28.02 пред високосного года<br/>
* ========================================================================<br/>
*/
PROCEDURE PER_3YEAR_2802
IS
vDefPeriod TPERIOD;
vPeriods TPERIODTABLE;
vOkPeriods TPERIODTABLE;
vRes XMLType;
vResOK XMLType;
BEGIN
vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('28.02.2023', 'DD.MM.YYYY'), pAddMonths => 36);
-- Определить даты интервалов
Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
From (
Select vDefPeriod.pbeg As dBeg, TO_DATE('28.02.2024', 'DD.MM.YYYY') As dEnd From Dual
Union All
Select TO_DATE('29.02.2024', 'DD.MM.YYYY'), TO_DATE('27.02.2025', 'DD.MM.YYYY') From Dual
Union All
Select TO_DATE('28.02.2025', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
) T;
vResOK := getPeriodsXML(pPeriods => vOkPeriods);
vPeriods := CalcPeriods (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
vRes := getPeriodsXML(pPeriods => vPeriods);
UNITTEST.CheckTestResult('PER_3YEAR_2802', vRes, vResOK);
END PER_3YEAR_2802;
Пример UT#2
Проверяемый метод
/**
* ========================================================================<br/>
* Сохранение внешних ссылок на объекты <br/>
* ========================================================================<br/>
* @param pList - CLOB с данными в формате JSON со списком GUID
* @return - Результат обработки (1 - нет ошибок, -1 - ошибка)
*/
FUNCTION SaveExternalLink(pJSON IN CLOB) RETURN NUMBER
IS
vResult NUMBER(1) := 1;
BEGIN
IF pJSON IS NULL THEN
RETURN vResult;
END IF;
BEGIN
Insert Into Message M
(ClassName, InsNumber, ExternalID)
Select Distinct J.ClassName, J.InsNumber, J.ExternalID
From JSON_Table(pJSON, '$.objects[*]'
Columns(ClassName Varchar2(32) path '$.name',
InsNumber Number path '$.insNumber',
ExternalID Varchar2(4000) path '$.externalID')) J
Where J.ClassName Is Not Null
And J.InsNumber Is Not Null
And J.GUID Is Not Null
And Not Exists
(Select Null From Message Me
Where ME.ExternalID = J.ExternalID Or
(J.ClassName Is Not Null And
ME.ClassName = J.ClassName And
ME.InsNumber = J.InsNumber)
);
EXCEPTION
WHEN OTHERS THEN
vResult := -1;
END;
RETURN vResult;
END SaveExternalLink;
Тесты
/**
* ========================================================================<br/>
* Сформировать CLOB по объекту <br/>
* ========================================================================<br/>
* @param pClassName - Класс объекта
* @param pInsNumber - Номер заявки
* @param pExternalID - Значение внешней ссылки
* @return - Структурированный CLOB с данными
*/
FUNCTION getObjectClob(pClassName IN VARCHAR2,
pInsNumber IN VARCHAR2,
pExternalID IN VARCHAR2) RETURN CLOB
IS
BEGIN
RETURN TO_CLOB('<OBJECT>'||
'<CLASSNAME>'||pClassName||'</CLASSNAME>'||
'<INSNUMBER>'||pInsNumber||'</INSNUMBER>'||
'<EXTERNALID>'||pExternalID||'</EXTERNALID>'||
'</OBJECT>');
END getObjectClob;
/**
* ========================================================================<br/>
* Сформировать XML со списком внеших ссылок по номеру заявки <br/>
* ========================================================================<br/>
* @param pInsNumber - Список номеров заявок
* @return - XML с данными
*/
FUNCTION getObjectsXMLByInsNum(pInsNumber IN TNUM) RETURN XMLType
IS
vResult CLOB;
BEGIN
IF pInsNumber.Count > 0 THEN
FOR vCur IN (Select M.ClassName, M.InsNumber, M.ExternalID
From Message M, Table(pInsNumber) R
Where M.InsNumber = R.column_value
Order By ClassName, InsNumber)
LOOP
vResult := vResult || getObjectClob(vCur.ClassName, vCur.InsNumber, vCur.ExternalID);
END LOOP;
END IF;
RETURN XMLTYPE('<OBJECTS>'||vResult||'</OBJECTS>');
END getObjectsXMLByInsNum;
/**
* ========================================================================<br/>
* Сформировать XML со списком внеших ссылок по номеру заявки <br/>
* ========================================================================<br/>
* @param pXML - XML с данными
* @return - CLOB формата JSON с данными
*/
FUNCTION ConvertXMLToJSONClob(pXML IN XMLTYPE) RETURN CLOB
IS
vResult CLOB;
BEGIN
IF pXML IS NULL THEN
RETURN vResult;
END IF;
Select JSON_Object(Key 'objects' Value
JSON_ArrayAgg(
JSON_Object(Key 'className' Value X.ClassName,
Key 'insNumber' Value X.InsNumber,
Key 'externalID' Value X.ExternalID)
Order by X.InsNumber, X.ClassName
Returning VarChar2(32000)))
Into vResult
From XMLTable('/OBJECTS/OBJECT' Passing pXML Columns
ClassName Path 'CLASSNAME',
InsNumber Path 'INSNUMBER',
ExternalID Path 'EXTERNALID')X;
RETURN vResult;
END ConvertXMLToJSONClob;
/**
* ========================================================================<br/>
* Параметрическое сохранение списка ExternalID на основании XML (OK) <br/>
* ========================================================================<br/>
* @param pTarget - CLOB с данными для проверки
* @param pList - CLOB с данными для загрузки
* @param pInsNumber - Номер проверяемой заявки
* @param pTestCaption - Название теста
*/
PROCEDURE SaveExternalID_ByXML(pTarget IN CLOB,
pList IN CLOB,
pInsNumber IN TNUM,
pTestCaption IN VARCHAR2)
IS
vRes XMLType;
vResOK XMLType := XMLTYPE('<OBJECTS>'||pTarget||'</OBJECTS>');
vList CLOB := ConvertXMLToJSONClob(XMLTYPE('<OBJECTS>'||pList||'</OBJECTS>'));
vResult NUMBER;
BEGIN
SAVEPOINT SP;
BEGIN
vResult:= SaveExternalLink(pJSON => vList);
vRes := getObjectsXMLByInsNum(pInsNumber => pInsNumber);
EXCEPTION
WHEN OTHERS THEN
vRes := XMLType('<ERR>' || RegExp_replace(SQLERRM, '(.)([\(|\[].*)', '\1') || '</ERR>');
END;
ROLLBACK TO SP;
UNITTEST.CheckTestResult(pTestCaption, vRes, vResOK);
END SaveExternalID_ByXML;
/**
* ========================================================================<br/>
* Параметрическое сохранение списка ExternalID на основании XML (OK) <br/>
* ========================================================================<br/>
* @param pList - CLOB с данными для загрузки
* @param pErrCode - Код ожидаемой ошибки
* @param pTestCaption - Название теста
*/
PROCEDURE SaveExternalID_ByXML_Raise(pList IN CLOB,
pErrCode IN VARCHAR2,
pTestCaption IN VARCHAR2
)
IS
vRes XMLType := XMLTYPE('<OBJECTS>NOT OK</OBJECTS>');
vResOK XMLType := XMLTYPE('<OBJECTS>Correct error</OBJECTS>');
vList CLOB := ConvertXMLToJSONClob(XMLTYPE('<OBJECTS>'||pList||'</OBJECTS>'));
vResult NUMBER;
BEGIN
SAVEPOINT SP;
BEGIN
vResult:= SaveExternalLink(pJSON => vList);
EXCEPTION
WHEN OTHERS THEN
IF INSTR(SQLERRM, pErrCode) > 0 THEN
vRes := vResOK;
ELSE
vRes := XMLType('<ERR>' || RegExp_replace(SQLERRM, '(.)([\(|\[].*)', '\1') || '</ERR>');
END IF;
END;
ROLLBACK TO SP;
UNITTEST.CheckTestResult(pTestCaption, vRes, vResOK);
END SaveExternalID_ByXML_Raise;
/**
* ========================================================================<br/>
* Сохранение NULL-значения (OK) <br/>
* ========================================================================<br/>
*/
PROCEDURE SaveExternalID_NullList_OK
IS
vInsNumber NUMBER;
BEGIN
SaveExternalID_ByXML(pTarget => NULL,
pList => NULL,
pInsNumber => TNum(vInsNumber),
pTestCaption => 'Сохранение NULL-значения (OK)');
END SaveExternalID_NullList_OK;
/**
* ========================================================================<br/>
* Сохранение пустого списка ExternalID (OK) <br/>
* ========================================================================<br/>
*/
PROCEDURE SaveExternalID_EmptyList_OK
IS
vInsNumber NUMBER;
BEGIN
SaveExternalID_ByXML(pTarget => NULL,
pList => NULL,
pInsNumber => TNum(vInsNumber),
pTestCaption => 'Сохранение пустого списка (OK)');
END SaveExternalID_EmptyList_OK;
/**
* ========================================================================<br/>
* Сохранение списка ExternalID из 1 элемента по заявке (тип не заполнен) (OK) <br/>
* ========================================================================<br/>
*/
PROCEDURE SaveExternalID_1Elem_EmptyClass_OK
IS
vInsNumber NUMBER;
vList CLOB := getObjectClob(NULL, 12345, 'e65f9824-90f2-7da9-99f0-a23418a3e980');
BEGIN
SaveExternalID_ByXML(pTarget => NULL,
pList => vList,
pInsNumber => TNum(vInsNumber),
pTestCaption => 'Сохранение списка ExternalID из 1 элемента по заявке (тип не заполнен)(OK)');
END SaveExternalID_1Elem_EmptyClass_OK;
/**
* ========================================================================<br/>
* Сохранение списка ExternalID из 1 элемента по заявке (некорректный тип) (ERR) <br/>
* ========================================================================<br/>
*/
PROCEDURE SaveExternalID_1Elem_IncClass_ERR
IS
vInsNumber NUMBER := 12345;
vList CLOB := getObjectClob('TESTERR', vInsNumber, 'e65f9824-90f2-7da9-99f0-a23418a3e980');
BEGIN
SaveExternalID_ByXML_Raise(pList => vList,
pErrCode => 'ERROR_CODE_1',
pTestCaption => 'Сохранение списка ExternalID из 1 элемента по заявке (некорректный тип)(ERR)');
END SaveExternalID_1Elem_IncClass_ERR;
/**
* ========================================================================<br/>
* Сохранение списка ExternalID из 1 элемента с некорректным номером заявки (OK)<br/>
* ========================================================================<br/>
*/
PROCEDURE SaveExternalID_1Elem_IncIN_OK
IS
vInsNumber NUMBER;
vList CLOB := getObjectClob('TYPE_1', '12345_SS', 'e65f9824-90f2-7da9-99f0-a23418a3e980');
BEGIN
SaveExternalID_ByXML(pTarget => NULL,
pList => vList,
pInsNumber => TNum(vInsNumber),
pTestCaption => 'Сохранение списка ExternalID из 1 элемента с некорректным номером заявки (OK)');
END SaveExternalID_1Elem_IncIN_OK;
Резюме
Как видно, предлагаемые практики не требуют больших усилий в части использования, но очень эффективны. Критерий их применения – «как быстро смогу починить упавший из-за моих доработок прод». Буду признателен в комментариях за описания других техник, минимизирующих ошибку разработчика.