Урок 5. Удаление связанных данных
Last updated
Last updated
На прошлых уроках мы создали формы для списка клиентов и для редактирования информации о клиенте. Познакомились с , суть которого заключается в проверке наличия изменений на форме и предупреждении пользователя о них при закрытии формы без сохранения.
На этом уроке рассмотрим различные способы обработки удаления связанных данных: от запрета удаления до архивации.
Если хотите начать практику с этого урока, то вам необходимо развернуть учебный проект по инструкции в статье .
При разворачивании проекта используйте 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. И тогда при удалении записи города автоматически будут удаляться все связанные с ней записи клиентов.
Такой подход подойдет к тем таблицам, на данных из которых не строится никаких отчетов, так как при удалении записи из основной таблицы мы теряем всю важную информацию в зависимых таблицах. Например, у нас будет отчет по прибыли, в котором будем учитывать все заказы от всех клиентов. И когда мы удалим город, который нам больше не нужен в программе, каскадно удаляться все клиенты, привязанные к этому городу, и все заказы этих клиентов. Таким образом, мы потеряем большую часть важных данных.
В таблицу в базе данных необходимо добавить колонку 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:
Так как на форме получаем все архивные и актуальные города, необходимо реализовать фильтр этого списка, чтобы отображать в таблице на форме только те данные, которые необходимы пользователю.
В качестве значения тэга <Top>
ожидается целое положительное число. Для этого в тэге <Expression>
указано выражение округления результата деления.
В тэге <Value>
укажем значение False, тем самым задав фильтру в качестве значения по умолчанию значение "Актуальные".
Скорректируем значение тэга <Width>
у HeadLabel с учетом добавленного фильтра ArchiveFilterComboBox:
Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим расположение объектов.
Атрибут FilterNullValue
тэга <Filter>
со значением False означает, что если значение фильтра будет равно NULL, то в таблице будет отображаться полный список записей со всеми изменениями, которые мы вносили в таблицу.
Таким образом, все строки, для которых выражение из тэга <Expression>
имеет значение True, будут краситься в цвет TableArchiveColor. Если фильтр ArchiveFilterComboBox будет иметь значение Null (Все записи), то это будет восприниматься как False.
Общий синтаксис таблицы CityDatabaseTable выглядит так:
Перейдем в приложение и проверим работу фильтра на списке городов. Но для начала через таблицу в базе данных сделаем один из городов архивным.
Теперь можем скорректировать описание CityDatabaseTableSetDataConnection, добавив параметр Archive. Таким образом, общий синтаксис соединения с данными для отправки будет выглядеть так:
Скачайте архив с изображениями и разархивируйте его в папку проекта \Template\Projects\1. Template\Forms\Images\24x24.
Для переноса записи в архив и восстановления записи из архива будем использовать одну и ту же кнопку, меняя ее иконку и логику в зависимости от значения в колонке Archive выбранной строки в таблице. Для этого создадим условие, которое будет проверять, находится ли выбранная запись в архиве:
Хорошей практикой является уточнять у пользователя, действительно ли он хочет совершить действие. Давайте создадим команду с таким вопросом, которую будем вызывать при нажатии на кнопку архива:
Теперь под описанием объекта CityDeleteButton добавим описание кнопки для работы с архивом:
Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим расположение объектов.
А сейчас мы познакомимся с другой возможностью получить результат этой команды.
При проверке параметра Updated мы тоже можем использовать такой синтаксис.
Создадим команду, которая будет менять значение в колонке Archive выделенной строки таблицы CityDatabaseTable на противоположное:
Добавим вызов этой команды в ранее созданный <Execution>
:
Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим работу кнопки редактирования архива.
Но в нашем случае нужно принудительно обновить значение ArchiveFilterComboBox, чтобы пересчитался фильтр в колонке таблицы DatabaseTable.
Чтобы решить проблему с обновлением фильтра после отправки записи в архив, будем использовать переменную для сохранения текущего значения фильтра, сбрасывать значение фильтра и подставлять значение из переменной.
Создадим переменную, в которую будем сохранять текущее значение фильтра:
Создадим вторую команду типа ValueSetCommand для присваивания нового значения фильтру:
Отредактируем ранее созданный <Execution>
так, чтобы после выполнения команды CityArchiveUpdateRowValueSetCommand происходило обновление фильтра:
Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим работу фильтра при редактировании архива.
Как говорилось ранее, выпадающие списки должны содержать актуальные записи. Таким образом, в запросе CityShortSelectSqlQuery для получения списка данных для CityComboBox мы должны исключать архивные записи городов.
Перейдем в файл описания работы серверной части приложения (Template.xml) и дополним наш запрос условием WHERE
:
Перейдем в приложение, откроем карточку клиента и проверим выпадающий список городов.
Здесь стоит обратить внимание на один момент: если город выбранного клиента находится в архиве, то в карточке клиента мы увидим, что его город потерялся:
Чтобы выпадающий список CityComboBox содержал еще и архивный город, на который ссылается запись клиента, необходимо с формы прокидывать в запрос идентификатор этого города. И в запросе в предложении WHERE
проверять этот идентификатор.
Вернемся в файл описания работы серверной части приложения (Template.xml) и дополним предложение WHERE
проверкой параметра CityId:
Перейдем в файл карточки клиента (TemplateClientEdit.xml) и дополним соединение с данными CityShortPrimaryGetDataConnection параметром CityId, полученным из записи клиента в ClientPrimaryGetDataConnection:
Перейдем в приложение, откроем карточку клиента и проверим выпадающий список городов.
Отлично! Возможности отправлять записи городов в архив и восстанавливать их из архива реализовали. И в карточке клиента, который ссылается на архивную запись города, корректно отображается наименование города. Осталось поправить процесс удаления записи города, добавив проверку ссылок на идентификатор удаляемого города.
Для начала давайте обратим внимание на грамотный прием взаимодействия с пользователем, который мы использовали ранее в уроке.
На кнопке редактирования архива мы просили пользователя подтвердить свое намерение совершить действие. Это правильный подход, так как пользователь мог случайно нажать на кнопку. И если с кнопкой переноса в архив это нестрашно, пользователь всегда сможет восстановить запись из архива, то с кнопкой удаления это критично. Особенно на формах, где удаление происходит сразу в таблице в базе данных, как это реализовано на форме списка клиентов.
Отлично! Теперь вернемся к файлу списка городов (TemplateCityList.xml), где реализуем проверку ссылок на идентификатор записи при попытки ее удаления.
Мы можем добавить проверку ссылок на идентификатор записи в момент получения списка городов. Далее на форме в таблицу будем выводить колонку IsUsed, значение в которой будем проверять при нажатии на кнопку "Удалить". И если выбранный город используется, то выводить сообщение пользователю и предлагать отправить запись в архив.
Но такой вариант неудобен тем, что пока открыто окно со списком городов, другой пользователь может создать клиента с привязкой к городу, который изначально не использовался. И мы об этом не узнаем, если не обновим список, повторно отправив на сервер запрос на формирование списка. Но это даст дополнительную нагрузку на сервер. В нашем случае запрос простой и быстро построит нужный список. Но лучше избегать лишнего обращения на сервер. Поэтому проверку ссылок на идентификатор записи перенесем в запрос на удаление записи. И если запись будет использоваться в момент удаления, то будем сразу отправлять ее в архив, уведомив об этом пользователя.
Перейдем в программу для управления СУБД PostgreSQL, и выполним следующий скрипт:
Здесь мы создаем функцию для удаления конкретной записи в таблице template.city. В качестве параметра функция принимает идентификатор записи города.
Первым делом мы проверяем, существует ли запись. Если не существует, то возвращаем true, как если бы запись была успешно удалена.
Затем мы проверяем, используется ли запись. Если запись используется, то отправляем ее в архив и возвращаем false, иначе спокойно удаляем запись из таблицы в базе данных.
Перейдем в файл описания работы серверной части приложения (Template.xml) и переделаем запрос на удаление города на вызов функции:
Перейдем в приложение и попробуем удалить запись города, на которую ссылается хотя бы одна запись клиента.
Поэтому на форме списка городов мы не будем делать уведомления о переносе в архив записи. А вот на форме списка клиентов мы легко сможем отловить результат команды ClientDeleteSaveCommand и вывести уведомление. Но это будет на следующем уроке, когда добавим сущность, которая будет ссылаться на запись клиента.
На этом уроке мы рассмотрели разные способы удаления связанных записей и остановились на самом подходящем в нашем случае варианте: архивирование записей. На форме списка городов реализовали работу с архивом: отправка записи в архив, восстановление записи из архива и просмотр архивных записей. Так же доработали интерфейс нашего приложения и сделали его более дружелюбным.
В архиве присутствуют xml-файлы форм и серверный xml-файл, также лежит бэкап базы данных и файл с запросами на изменение структуры базы данных - с помощью файлов можете проверить себя.
Для фильтра архивных и актуальных записей будем использовать выпадающий список . Для этого в контейнер HeadPanel следом за заголовком формы HeadLabel добавим код:
В предыдущем уроке в разделе рассмотрели get-проперти у формы, которое позволяет узнать о наличии изменений на форме, чтобы уведомить пользователя о необходимости сохранить изменения при закрытии формы. Но фильтра архивных и актуальных записей не является объектом, изменение которого нужно отслеживать. Поэтому в тэге <MyObject>
указали атрибут ChangeForm
со значением False - так форма будет игнорировать этот элемент.
Так как ComboBox в качестве значения тэга <ValueList>
ожидает таблицу с одним, двумя или более столбцами, то используем знакомую структуру данных с типом .
Для фильтрации данных в таблице на форме будем использовать тэг у колонки таблицы . В таблице CityDatabaseTable создадим колонку Archive:
Теперь настроим условное форматирование ячеек таблицы (), чтобы при отображении всех записей архивные записи выделялись цветом. Для этого в описание таблицы CityDatabaseTable добавим следующий код:
Теперь нам необходимо создать <Execution>
который будет отрабатывать нажатие кнопки Yes в диалоговом окне CityArchiveMessageBoxCommand. В предыдущих уроках мы создавали условие типа , в котором сверяли результат команды со строковой константой:
Атрибут Parameter
мы уже использовали, когда на родительской форме проверяли значение параметра Updated, полученного от дочерней формы в команде типа .
В этот раз мы не будем создавать условие , а напрямую обратимся к результату команды CityArchiveMessageBoxCommand в описании <Execution>
:
Мы столкнулись с проблемой: если значение фильтра стоит "Актуальные", то при отправке записи в архив она продолжает отображаться в таблице, что неверно. Это связано с тем, что мы напрямую отредактировали запись в таблице, и к ней не применился фильтр. Это особенность фильтра в колонке таблицы DatabaseTable. В одном из следующих уроков мы подробно рассмотрим все варианты фильтрации данных на формах.
Данная проблема не возникнет, если вместо изменения строки в таблице DatabaseTable будем изменять запись в DataConnection, который является источником данных для таблицы, или будем отправлять запрос на сервер, а после вызывать команду , чтобы обновить соединение с данными для таблицы CityDatabaseTable. При изменении источника данных в SourceDataConnection таблица перерисует строки с учетом фильтров в колонках.
Добавим команду типа , с помощью которой будем сохранять значение фильтра:
Будем использовать универсальное значение , чтобы в момент обращения к команде передавать в нее NULL, когда будем сбрасывать фильтр, и ArchiveFilterVariable, когда будем возвращать сохраненное значение.
Самостоятельно реализуйте команду для подтверждения удаления записи города и записи клиента на главной форме (TemplateStart.xml).
Также будет логичным запретить удаление и ограничить редактирование архивных записей. Для этого самостоятельно реализуйте нужные проверки на кнопках редактирования и удаления города и (TemplateCityList.xml), используя на кнопках режим . В тэге кнопки используйте конструкцию для отображение нужного текста сообщения в зависимости от условия блокировки кнопки. Не забудьте про обработку двойного клика по строке таблицы. При попытке открыть карточку архивной записи для редактирования будет лучшим решением открывать ее в режиме ReadOnly, выставляя всем объектам на форме Enabled = False или ReadOnly = True для TextBox и отображать в заголовке формы Label с отметкой, что запись находится в архиве. Но в учебном проекте достаточно выводить уведомление с просьбой восстановить запись из архива.
К сожалению, при использовании мы не всегда сможем на форме из результата команды корректно отловить значение, возвращаемое функцией template.city_try_delete(smallint)
. Это связано с тем, что в результат команды будет записан результат последнего выполненного запроса. Как следствие, мы не сможем уведомить пользователя, что какая-то запись при удалении была перемещена в архив, если пользователь попытался удалить несколько записей.