Урок 5. Удаление связанных данных
Last updated
Last updated
На прошлых уроках мы создали формы для списка клиентов и для редактирования информации о клиенте. Познакомились с паттерном onClose, суть которого заключается в проверке наличия изменений на форме и предупреждении пользователя о них при закрытии формы без сохранения.
На этом уроке рассмотрим различные способы обработки удаления связанных данных: от запрета удаления до архивации.
Если хотите начать практику с этого урока, то вам необходимо развернуть учебный проект по инструкции в статье Разворачивание проекта.
При разворачивании проекта используйте backup базы данных, который можете найти в архиве из раздела Ответы прошлого урока. Скопируйте папки Forms, Workflow и Patterns в папку с развернутым проектом, например, в папку D:\WT\Projects\Template\Projects\1. Template.
Инструкция по подключению шаблонов находится по ссылке.
У нас есть два списка: список клиентов и список городов. В базе данных оба списка представлены таблицами и связаны между собой через внешний ключ:
Таким образом, каждый клиент имеет ссылку на определенный город, который хранится в таблице template.city. Удаление записи города, на который не ссылается ни одна запись из таблицы template.client, пройдет без проблем. Но что делать, если мы захотим удалить город, который используется в записи клиента?
На данный момент PostgreSQL не даст нам удалить используемый город, выкинув ошибку о нарушении ограничения внешнего ключа:
Подобную ошибку мы увидим и в журнале событий Windows, если будем удалять город через интерфейс нашей программы:
Для решения этой проблемы есть несколько способов:
Запрет на удаление используемых записей;
Каскадное удаление связанных записей;
Использование флага deleted в таблице в базе данных;
Архивирование записей.
Дальше подробно рассмотрим каждый из способов.
Запрос на получение списка городов (CitySelectSqlQuery) необходимо дополнить полем IsUsed, которое будет иметь значение True, если идентификатор записи встречается в таблице template.client. Далее на форме на кнопку удаления города нужно повесить проверку значения в колонке IsUsed выделенной строки таблицы. Если город используется, то будем уведомлять пользователя о невозможности удаления записи. Но здесь возникает проблема с устареванием данных, которую можно решить добавлением на кнопку удаления команды на обновление списка в таблице.
Можно проверку использования перенести в запрос на удаление, тогда мы всегда будем иметь доступ к актуальной информации. А на форму возвращать результат попытки удаления. И если он отрицательный, то выводить сообщение пользователю, что удаление невозможно.
Однако такой подход нас не устраивает, так как не позволяет пользователю актуализировать список. Со временем в таблице накопятся устаревшие данные, которые затруднят работу с выпадающим списком на формах.
Второй способ является самым простым в исполнении, так как для этого всего лишь необходимо в описании внешнего ключа для выражения ON DELETE указать опцию CASCADE. И тогда при удалении записи города автоматически будут удаляться все связанные с ней записи клиентов.
С помощью выражений ON UPDATE и ON DELETE можно установить действия, которые выполняются при изменении и удалении связанной записи из основной таблицы.
NO ACTION: действие по умолчанию, предотвращает какие-либо действия в зависимой таблице при удалении или изменении связанных записей в основной таблице. Генерирует ошибку. В отличие от RESTRICT
позволяет отложить проверку на связанность между таблицами.
RESTRICT: предотвращает какие-либо действия в зависимой таблице при удалении или изменении связанных строк в основной таблице.
CASCADE: автоматически удаляет или изменяет строки из зависимой таблицы при удалении или изменении связанных строк в основной таблице.
SET NULL: при удалении связанной строки из основной таблицы устанавливает для столбца внешнего ключа значение NULL.
SET DEFAULT: при удалении связанной строки из основной таблицы устанавливает для столбца внешнего ключа значение по умолчанию, которое задается с помощью атрибута DEFAULT. Если для столбца не задано значение по умолчанию, то в качестве него применяется значение NULL.
Такой подход подойдет к тем таблицам, на данных из которых не строится никаких отчетов, так как при удалении записи из основной таблицы мы теряем всю важную информацию в зависимых таблицах. Например, у нас будет отчет по прибыли, в котором будем учитывать все заказы от всех клиентов. И когда мы удалим город, который нам больше не нужен в программе, каскадно удаляться все клиенты, привязанные к этому городу, и все заказы этих клиентов. Таким образом, мы потеряем большую часть важных данных.
В таблицу в базе данных необходимо добавить колонку deleted типа boolean, которая является признаком удаленной записи. Запрос на удаление заменить на update-запрос, в котором полю deleted присваивать значение true. И все запросы на получение списка записей из таблицы дополнить проверкой этого поля, чтобы исключить неактуальные данные.
Чтобы на форме для существующего клиента выпадающий список CityComboBox содержал не только актуальные города, но и удаленный город, на который ссылается запись клиента, необходимо в результат запроса на формирование списка городов включить запись удаленного города. Для этого с помощью UNION добавить select-запрос на получение записи города по его идентификатору, который необходимо передавать с формы.
Таким образом, в базе остаются все когда-либо добавленные записи. Это можно использовать для ведения журнала удаленных записей. Например, такой отчет будет необходим для отслеживания удаленных заказов. Для этого в таблицу можно дополнительно сохранять дату удаления и комментарий.
Но не всегда нужно при удалении оставлять в базе записи, на которые не ссылаются другие сущности. Например, в списке городов можно спокойно позволять пользователю удалять неиспользуемые города. А иногда может возникать необходимость восстанавливать удаленные используемые записи. В этом случае отличным решением будет следующий способ.
Суть этого способа заключается в том, что при попытке удалить запись проверяем, используется ли она в других таблицах. Если запись используется, то отправляем ее в архив, иначе удаляем из базы. Если мы таким образом отправили запись в архив, то отображаем пользователю соответствующее уведомление.
Архивные записи помечаем через колонку archive типа boolean в таблице в базе данных. И все запросы на получение списка записей из этой таблицы, кроме основного, дополняем проверкой этого поля, чтобы исключить архивные записи. Если запрос используется для выпадающего списка в карточке другой сущности, то дополняем его select-запросом на получение записи по идентификатору, который передаем с формы.
На форме списка предоставляем возможность просматривать не только актуальные записи, но и архивные записи. Пользователь может напрямую отправлять в архив любые записи из списка, а не только используемые, а так же восстанавливать записи из архива.
Остановимся на этом способе и реализуем его для списка городов.
Перейдем в программу для управления СУБД PostgreSQL и в таблицу template.city добавим колонку, которую будем использовать для пометки архивных записей. Для этого выполним запрос:
Перейдем в файл описания работы серверной части приложения (Template.xml). В запрос CitySelectSqlQuery добавим поле Archive:
Так же скорректируем запросы CityInsertSqlQuery и CityUpdateSqlQuery, добавив сохранение поля Archive:
Перейдем в файл списка городов (TemplateCityList.xml) и в соединение с данными CityPrimaryGetDataConnection добавим поле Archive:
Так как на форме получаем все архивные и актуальные города, необходимо реализовать фильтр этого списка, чтобы отображать в таблице на форме только те данные, которые необходимы пользователю.
Для фильтра архивных и актуальных записей будем использовать выпадающий список ComboBox. Для этого в контейнер HeadPanel следом за заголовком формы HeadLabel добавим код:
В предыдущем уроке в разделе Проверка изменений на форме рассмотрели get-проперти FormChanged у формы, которое позволяет узнать о наличии изменений на форме, чтобы уведомить пользователя о необходимости сохранить изменения при закрытии формы. Но фильтра архивных и актуальных записей не является объектом, изменение которого нужно отслеживать. Поэтому в тэге <MyObject>
указали атрибут ChangeForm
со значением False - так форма будет игнорировать этот элемент.
В качестве значения тэга <Top>
ожидается целое положительное число. Для этого в тэге <Expression>
указано выражение округления результата деления.
Для округления используются следующие методы:
Floor(value)
- округляет вниз по направлению к отрицательной бесконечности;
Ceiling(value)
- округляет вверх по направлению к положительной бесконечности;
Truncate(value)
- округляет вниз или вверх по направлению к нулю;
Round(value, digits)
- округляет к ближайшему числу с заданным количеством знаков после запятой.
Так как ComboBox в качестве значения тэга <ValueList>
ожидает таблицу с одним, двумя или более столбцами, то используем знакомую структуру данных Structure с типом Table.
В тэге <Value>
укажем значение False, тем самым задав фильтру в качестве значения по умолчанию значение "Актуальные".
Скорректируем значение тэга <Width>
у HeadLabel с учетом добавленного фильтра ArchiveFilterComboBox:
Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим расположение объектов.
Для фильтрации данных в таблице на форме будем использовать тэг <Filter>
у колонки таблицы DatabaseTable. В таблице CityDatabaseTable создадим колонку Archive:
Атрибут FilterNullValue
тэга <Filter>
со значением False означает, что если значение фильтра будет равно NULL, то в таблице будет отображаться полный список записей со всеми изменениями, которые мы вносили в таблицу.
Теперь настроим условное форматирование ячеек таблицы (Formatting), чтобы при отображении всех записей архивные записи выделялись цветом. Для этого в описание таблицы CityDatabaseTable добавим следующий код:
Таким образом, все строки, для которых выражение из тэга <Expression>
имеет значение True, будут краситься в цвет TableArchiveColor. Если фильтр ArchiveFilterComboBox будет иметь значение Null (Все записи), то это будет восприниматься как False.
Общий синтаксис таблицы CityDatabaseTable выглядит так:
Перейдем в приложение и проверим работу фильтра на списке городов. Но для начала через таблицу в базе данных сделаем один из городов архивным.
Теперь можем скорректировать описание CityDatabaseTableSetDataConnection, добавив параметр Archive. Таким образом, общий синтаксис соединения с данными для отправки будет выглядеть так:
Скачайте архив с изображениями и разархивируйте его в папку проекта \Template\Projects\1. Template\Forms\Images\24x24.
Для переноса записи в архив и восстановления записи из архива будем использовать одну и ту же кнопку, меняя ее иконку и логику в зависимости от значения в колонке Archive выбранной строки в таблице. Для этого создадим условие, которое будет проверять, находится ли выбранная запись в архиве:
Хорошей практикой является уточнять у пользователя, действительно ли он хочет совершить действие. Давайте создадим команду с таким вопросом, которую будем вызывать при нажатии на кнопку архива:
Теперь под описанием объекта CityDeleteButton добавим описание кнопки для работы с архивом:
Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим расположение объектов.
Теперь нам необходимо создать <Execution>
который будет отрабатывать нажатие кнопки Yes в диалоговом окне CityArchiveMessageBoxCommand. В предыдущих уроках мы создавали условие типа EqualCondition, в котором сверяли результат команды MessageBoxCommand со строковой константой:
А сейчас мы познакомимся с другой возможностью получить результат этой команды.
Команда MessageBoxCommand возвращает словарь со следующими элементами:
Value - один из типов результата работы диалогового окна (OK, Cancel, Abort, Retry, Ignore, Yes или No);
OK - признак, определяющий, была ли нажата кнопка "OK" в диалоговом окне (True/False);
Cancel - признак, определяющий, была ли нажата кнопка "Cancel" в диалоговом окне (True/False);
Abort - признак, определяющий, была ли нажата кнопка "Abort" в диалоговом окне (True/False);
Retry - признак, определяющий, была ли нажата кнопка "Retry" в диалоговом окне (True/False);
Ignore - признак, определяющий, была ли нажата кнопка "Ignore" в диалоговом окне (True/False);
Yes - признак, определяющий, была ли нажата кнопка "Yes" в диалоговом окне (True/False);
No - признак, определяющий, была ли нажата кнопка "No" в диалоговом окне (True/False).
Когда мы обращаемся к команде по имени <Command Name="MyMessageBoxCommand" />
, то по умолчанию получаем значение, которое хранится в словаре по ключу "Value". Такой синтаксис справедлив для всех команд. А чтобы получить значение по другому ключу, например, по ключу "Yes", нужно указать этот ключ в качестве значения атрибута Parameter
у тэга <Command>
: <Command Name="MyMessageBoxCommand" Parameter="Yes" />
Атрибут Parameter
мы уже использовали, когда на родительской форме проверяли значение параметра Updated, полученного от дочерней формы в команде типа FormShowCommand.
В этот раз мы не будем создавать условие EqualCondition, а напрямую обратимся к результату команды CityArchiveMessageBoxCommand в описании <Execution>
:
При проверке параметра Updated мы тоже можем использовать такой синтаксис.
Создадим команду, которая будет менять значение в колонке Archive выделенной строки таблицы CityDatabaseTable на противоположное:
Добавим вызов этой команды в ранее созданный <Execution>
:
Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим работу кнопки редактирования архива.
Мы столкнулись с проблемой: если значение фильтра стоит "Актуальные", то при отправке записи в архив она продолжает отображаться в таблице, что неверно. Это связано с тем, что мы напрямую отредактировали запись в таблице, и к ней не применился фильтр. Это особенность фильтра <Filter>
в колонке таблицы DatabaseTable. В одном из следующих уроков мы подробно рассмотрим все варианты фильтрации данных на формах.
Данная проблема не возникнет, если вместо изменения строки в таблице DatabaseTable будем изменять запись в DataConnection, который является источником данных для таблицы, или будем отправлять запрос на сервер, а после вызывать команду DataConnectionRefreshCommand, чтобы обновить соединение с данными для таблицы CityDatabaseTable. При изменении источника данных в SourceDataConnection таблица перерисует строки с учетом фильтров в колонках.
Но в нашем случае нужно принудительно обновить значение ArchiveFilterComboBox, чтобы пересчитался фильтр в колонке таблицы DatabaseTable.
Чтобы решить проблему с обновлением фильтра после отправки записи в архив, будем использовать переменную для сохранения текущего значения фильтра, сбрасывать значение фильтра и подставлять значение из переменной.
Создадим переменную, в которую будем сохранять текущее значение фильтра:
Добавим команду типа ValueSetCommand, с помощью которой будем сохранять значение фильтра:
Создадим вторую команду типа ValueSetCommand для присваивания нового значения фильтру:
Будем использовать универсальное значение <Input>
, чтобы в момент обращения к команде передавать в нее NULL, когда будем сбрасывать фильтр, и ArchiveFilterVariable, когда будем возвращать сохраненное значение.
Отредактируем ранее созданный <Execution>
так, чтобы после выполнения команды CityArchiveUpdateRowValueSetCommand происходило обновление фильтра:
Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим работу фильтра при редактировании архива.
Как говорилось ранее, выпадающие списки должны содержать актуальные записи. Таким образом, в запросе CityShortSelectSqlQuery для получения списка данных для CityComboBox мы должны исключать архивные записи городов.
Перейдем в файл описания работы серверной части приложения (Template.xml) и дополним наш запрос условием WHERE
:
Перейдем в приложение, откроем карточку клиента и проверим выпадающий список городов.
Здесь стоит обратить внимание на один момент: если город выбранного клиента находится в архиве, то в карточке клиента мы увидим, что его город потерялся:
Чтобы выпадающий список CityComboBox содержал еще и архивный город, на который ссылается запись клиента, необходимо с формы прокидывать в запрос идентификатор этого города. И в запросе в предложении WHERE
проверять этот идентификатор.
Вернемся в файл описания работы серверной части приложения (Template.xml) и дополним предложение WHERE
проверкой параметра CityId:
Перейдем в файл карточки клиента (TemplateClientEdit.xml) и дополним соединение с данными CityShortPrimaryGetDataConnection параметром CityId, полученным из записи клиента в ClientPrimaryGetDataConnection:
Перейдем в приложение, откроем карточку клиента и проверим выпадающий список городов.
Отлично! Возможности отправлять записи городов в архив и восстанавливать их из архива реализовали. И в карточке клиента, который ссылается на архивную запись города, корректно отображается наименование города. Осталось поправить процесс удаления записи города, добавив проверку ссылок на идентификатор удаляемого города.
Для начала давайте обратим внимание на грамотный прием взаимодействия с пользователем, который мы использовали ранее в уроке.
На кнопке редактирования архива мы просили пользователя подтвердить свое намерение совершить действие. Это правильный подход, так как пользователь мог случайно нажать на кнопку. И если с кнопкой переноса в архив это нестрашно, пользователь всегда сможет восстановить запись из архива, то с кнопкой удаления это критично. Особенно на формах, где удаление происходит сразу в таблице в базе данных, как это реализовано на форме списка клиентов.
Самостоятельно реализуйте команду MessageBoxCommand для подтверждения удаления записи города и записи клиента на главной форме (TemplateStart.xml).
Также будет логичным запретить удаление и ограничить редактирование архивных записей. Для этого самостоятельно реализуйте нужные проверки на кнопках редактирования и удаления города и (TemplateCityList.xml), используя на кнопках режим DisabledMode. В тэге <DisabledText>
кнопки используйте конструкцию <Switch>
для отображение нужного текста сообщения в зависимости от условия блокировки кнопки. Не забудьте про обработку двойного клика по строке таблицы. При попытке открыть карточку архивной записи для редактирования будет лучшим решением открывать ее в режиме ReadOnly, выставляя всем объектам на форме Enabled = False или ReadOnly = True для TextBox и отображать в заголовке формы Label с отметкой, что запись находится в архиве. Но в учебном проекте достаточно выводить уведомление с просьбой восстановить запись из архива.
Отлично! Теперь вернемся к файлу списка городов (TemplateCityList.xml), где реализуем проверку ссылок на идентификатор записи при попытки ее удаления.
Мы можем добавить проверку ссылок на идентификатор записи в момент получения списка городов. Далее на форме в таблицу будем выводить колонку IsUsed, значение в которой будем проверять при нажатии на кнопку "Удалить". И если выбранный город используется, то выводить сообщение пользователю и предлагать отправить запись в архив.
Но такой вариант неудобен тем, что пока открыто окно со списком городов, другой пользователь может создать клиента с привязкой к городу, который изначально не использовался. И мы об этом не узнаем, если не обновим список, повторно отправив на сервер запрос на формирование списка. Но это даст дополнительную нагрузку на сервер. В нашем случае запрос простой и быстро построит нужный список. Но лучше избегать лишнего обращения на сервер. Поэтому проверку ссылок на идентификатор записи перенесем в запрос на удаление записи. И если запись будет использоваться в момент удаления, то будем сразу отправлять ее в архив, уведомив об этом пользователя.
В базе уже есть функция template.is_used(text, text, text[], text[], bigint)
, которая динамически строит запрос для проверки использования идентификаторов из одной таблицы в остальных. Функция возвращает таблицу из двух колонок: идентификатор записи (item_id) и признак использования (used). Функция принимает параметры:
in_table_name (text) - имя таблицы, первичные ключи которой необходимо проверять на использование;
in_key (text) - имя колонки с ключами, которые нужно проверять;
in_table_arr (text[]) - список имен таблиц, которые должны быть исключены из проверки использования;
in_column_arr (text[]) - список имен колонок, которые должны быть исключены из проверки использования;
in_id (bigint) - идентификатор записи, которую нужно проверить. Необязательный параметр, по умолчанию имеет значение NULL. Если параметр указан, то функция вернет признак использования только для этой записи. Иначе - для всех идентификаторов из таблицы in_table_name.
Примеры использования:
Перейдем в программу для управления СУБД PostgreSQL, и выполним следующий скрипт:
Здесь мы создаем функцию для удаления конкретной записи в таблице template.city. В качестве параметра функция принимает идентификатор записи города.
Первым делом мы проверяем, существует ли запись. Если не существует, то возвращаем true, как если бы запись была успешно удалена.
Затем мы проверяем, используется ли запись. Если запись используется, то отправляем ее в архив и возвращаем false, иначе спокойно удаляем запись из таблицы в базе данных.
Перейдем в файл описания работы серверной части приложения (Template.xml) и переделаем запрос на удаление города на вызов функции:
Перейдем в приложение и попробуем удалить запись города, на которую ссылается хотя бы одна запись клиента.
К сожалению, при использовании DatabaseTableSetDataConnection мы не всегда сможем на форме из результата команды SaveCommand корректно отловить значение, возвращаемое функцией template.city_try_delete(smallint)
. Это связано с тем, что в результат команды будет записан результат последнего выполненного запроса. Как следствие, мы не сможем уведомить пользователя, что какая-то запись при удалении была перемещена в архив, если пользователь попытался удалить несколько записей.
Поэтому на форме списка городов мы не будем делать уведомления о переносе в архив записи. А вот на форме списка клиентов мы легко сможем отловить результат команды ClientDeleteSaveCommand и вывести уведомление. Но это будет на следующем уроке, когда добавим сущность, которая будет ссылаться на запись клиента.
На этом уроке мы рассмотрели разные способы удаления связанных записей и остановились на самом подходящем в нашем случае варианте: архивирование записей. На форме списка городов реализовали работу с архивом: отправка записи в архив, восстановление записи из архива и просмотр архивных записей. Так же доработали интерфейс нашего приложения и сделали его более дружелюбным.
В архиве присутствуют xml-файлы форм и серверный xml-файл, также лежит бэкап базы данных и файл с запросами на изменение структуры базы данных - с помощью файлов можете проверить себя.