Урок 5. Удаление связанных данных

На прошлых уроках мы создали формы для списка клиентов и для редактирования информации о клиенте. Познакомились с паттерном 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

В таблицу в базе данных необходимо добавить колонку deleted типа boolean, которая является признаком удаленной записи. Запрос на удаление заменить на update-запрос, в котором полю deleted присваивать значение true. И все запросы на получение списка записей из таблицы дополнить проверкой этого поля, чтобы исключить неактуальные данные.

Чтобы на форме для существующего клиента выпадающий список CityComboBox содержал не только актуальные города, но и удаленный город, на который ссылается запись клиента, необходимо в результат запроса на формирование списка городов включить запись удаленного города. Для этого с помощью UNION добавить select-запрос на получение записи города по его идентификатору, который необходимо передавать с формы.

Таким образом, в базе остаются все когда-либо добавленные записи. Это можно использовать для ведения журнала удаленных записей. Например, такой отчет будет необходим для отслеживания удаленных заказов. Для этого в таблицу можно дополнительно сохранять дату удаления и комментарий.

Но не всегда нужно при удалении оставлять в базе записи, на которые не ссылаются другие сущности. Например, в списке городов можно спокойно позволять пользователю удалять неиспользуемые города. А иногда может возникать необходимость восстанавливать удаленные используемые записи. В этом случае отличным решением будет следующий способ.

Архивирование записей

Суть этого способа заключается в том, что при попытке удалить запись проверяем, используется ли она в других таблицах. Если запись используется, то отправляем ее в архив, иначе удаляем из базы. Если мы таким образом отправили запись в архив, то отображаем пользователю соответствующее уведомление.

Архивные записи помечаем через колонку archive типа boolean в таблице в базе данных. И все запросы на получение списка записей из этой таблицы, кроме основного, дополняем проверкой этого поля, чтобы исключить архивные записи. Если запрос используется для выпадающего списка в карточке другой сущности, то дополняем его select-запросом на получение записи по идентификатору, который передаем с формы.

На форме списка предоставляем возможность просматривать не только актуальные записи, но и архивные записи. Пользователь может напрямую отправлять в архив любые записи из списка, а не только используемые, а так же восстанавливать записи из архива.

Остановимся на этом способе и реализуем его для списка городов.

Архивирование данных

Признак архивной записи

Перейдем в программу для управления СУБД PostgreSQL и в таблицу template.city добавим колонку, которую будем использовать для пометки архивных записей. Для этого выполним запрос:

ALTER TABLE template.city
  ADD COLUMN archive boolean NOT NULL DEFAULT false;

Перейдем в файл описания работы серверной части приложения (Template.xml). В запрос CitySelectSqlQuery добавим поле Archive:

Template.xml
<SqlQuery Name="CitySelectSqlQuery">
  <Text>
    SELECT
      city_id AS "CityId",
      title AS "Title",
      archive AS "Archive"
    FROM
      template.city
    ORDER BY title;
  </Text>
</SqlQuery>

Так же скорректируем запросы CityInsertSqlQuery и CityUpdateSqlQuery, добавив сохранение поля Archive:

Template.xml
<SqlQuery Name="CityInsertSqlQuery">
  <Text>
    INSERT INTO template.city (
      title,
      archive
    )
    VALUES (
      {Title},
      {Archive}
    );
  </Text>
</SqlQuery>

<SqlQuery Name="CityUpdateSqlQuery">
  <Text>
    UPDATE template.city
    SET
      title = {Title},
      archive = {Archive}
    WHERE
      city_id = {CityId};
  </Text>
</SqlQuery>

Перейдем в файл списка городов (TemplateCityList.xml) и в соединение с данными CityPrimaryGetDataConnection добавим поле Archive:

TemplateCityList.xml
<DataConnection Name="CityPrimaryGetDataConnection" Type="PrimaryGetDataConnection" Assembly="DataConnections">
  <SqlQuery Name="CitySelectSqlQuery" Type="Select">
    <Workflow Name="Template" />
    <Fields>
      <Field Name="CityId" />
      <Field Name="Title" />
      <Field Name="Archive" />
    </Fields>
  </SqlQuery>
</DataConnection>

Так как на форме получаем все архивные и актуальные города, необходимо реализовать фильтр этого списка, чтобы отображать в таблице на форме только те данные, которые необходимы пользователю.

Фильтрация данных

Для фильтра архивных и актуальных записей будем использовать выпадающий список ComboBox. Для этого в контейнер HeadPanel следом за заголовком формы HeadLabel добавим код:

TemplateCityList.xml
<MyObject Name="ArchiveFilterComboBox" Type="ComboBox" Assembly="BaseControls" ChangeForm="False">
  <Top>
    <Calculate>
      <Expression>Ceiling(({0} - {1}) / 2)</Expression>
      <Items>
        <Item>
          <Object Name="HeadPanel">
            <Property Name="Height" />
          </Object>
        </Item>
        <Item>
          <Object Name="ArchiveFilterComboBox">
            <Property Name="Height" />
          </Object>
        </Item>
      </Items>
    </Calculate>
  </Top>
  <Right>
    <Calculate>
      <Expression>{0}-10</Expression>
      <Items>
        <Item>
          <Object Name="HeadPanel">
            <Property Name="Width" />
          </Object>
        </Item>
      </Items>
    </Calculate>
  </Right>
  <Width>100</Width>
  <NullValue Show="True" Title="Все" />
  <AutoCompleteMode>SmartSuggest</AutoCompleteMode>
  <ValueList>
    <Structure Type="Table">
      <Row>
        <Item>False</Item>
        <Item>Актуальные</Item>
      </Row>
      <Row>
        <Item>True</Item>
        <Item>Архивные</Item>
      </Row>
    </Structure>
  </ValueList>
  <Value>False</Value>
</MyObject>

В предыдущем уроке в разделе Проверка изменений на форме рассмотрели 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:

<Width>
  <Calculate>
    <Expression>{0} - {1} - 10</Expression>
    <Items>
      <Item>
        <Object Name="ArchiveFilterComboBox">
          <Property Name="Left" />
        </Object>
      </Item>
      <Item>
        <Object Name="HeadLabel">
          <Property Name="Left" />
        </Object>
      </Item>
    </Items>
  </Calculate>
</Width>

Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим расположение объектов.

Для фильтрации данных в таблице на форме будем использовать тэг <Filter> у колонки таблицы DatabaseTable. В таблице CityDatabaseTable создадим колонку Archive:

<Column Name="Archive" Type="DatabaseTableColumnCheckBox" Assembly="DatabaseTableColumnControls">
  <Visible>False</Visible>
  <Filter FilterNullValue="False">
    <Object Name="ArchiveFilterComboBox" />
  </Filter>
</Column>

Атрибут FilterNullValue тэга <Filter> со значением False означает, что если значение фильтра будет равно NULL, то в таблице будет отображаться полный список записей со всеми изменениями, которые мы вносили в таблицу.

Теперь настроим условное форматирование ячеек таблицы (Formatting), чтобы при отображении всех записей архивные записи выделялись цветом. Для этого в описание таблицы CityDatabaseTable добавим следующий код:

<Formatting>
  <BackColor Name="TableArchiveColor">
    <Expression>Archive AND {0}</Expression>
    <Items>
      <Item>
        <Not>
          <Object Name="ArchiveFilterComboBox" />
        </Not>
      </Item>
    </Items>
  </BackColor>
</Formatting>

Таким образом, все строки, для которых выражение из тэга <Expression> имеет значение True, будут краситься в цвет TableArchiveColor. Если фильтр ArchiveFilterComboBox будет иметь значение Null (Все записи), то это будет восприниматься как False.

Общий синтаксис таблицы CityDatabaseTable выглядит так:

TemplateCityList.xml
<MyObject Name="CityDatabaseTable" Type="DatabaseTable" Assembly="ComplexControls">
  <Top>5</Top>
  <Left>10</Left>
  <Height>
    <Calculate>
      <Expression>{0} - {1}*2</Expression>
      <Items>
        <Item>
          <Object Name="ContentPanel">
            <Property Name="Height" />
          </Object>
        </Item>
        <Item>
          <Object Name="CityDatabaseTable">
            <Property Name="Top" />
          </Object>
        </Item>
      </Items>
    </Calculate>
  </Height>
  <Width>
    <Calculate>
      <Expression>{0} - {1}*2 - {2} - 5</Expression>
      <Items>
        <Item>
          <Object Name="ContentPanel">
            <Property Name="Width" />
          </Object>
        </Item>
        <Item>
          <Object Name="CityDatabaseTable">
            <Property Name="Left" />
          </Object>
        </Item>
        <Item>
          <Object Name="CityAddButton">
            <Property Name="Width" />
          </Object>
        </Item>
      </Items>
    </Calculate>
  </Width>
  <AllowResizeColumns Value="False" />
  <AllowResizeRows Value="False" />
  <AllowInsert>False</AllowInsert>
  <AllowUpdate>False</AllowUpdate>
  <AllowDelete>False</AllowDelete>
  <AutoSizeColumnsMode Value="Fill" />
  <SourceDataConnection Name="CityPrimaryGetDataConnection" />
  <Columns>
    <Column Name="CityId" Type="DatabaseTableColumnTextBox" Assembly="DatabaseTableColumnControls">
      <Visible>False</Visible>
    </Column>
    <Column Name="RowNumber" Type="DatabaseTableColumnTextBox" Assembly="DatabaseTableColumnControls">
      <Title>№</Title>
      <Width>30</Width>
      <AutoFill Type="RowNumber" />
      <Alignment Value="MiddleCenter" />
      <AutoSizeMode Value="None" />
    </Column>
    <Column Name="Title" Type="DatabaseTableColumnTextBox" Assembly="DatabaseTableColumnControls">
      <Title>Наименование</Title>
      <Width>150</Width>
      <AutoSizeMode Value="Fill" />
    </Column>
    <Column Name="Archive" Type="DatabaseTableColumnCheckBox" Assembly="DatabaseTableColumnControls">
      <Visible>False</Visible>
      <Filter FilterNullValue="False">
        <Object Name="ArchiveFilterComboBox" />
      </Filter>
    </Column>
  </Columns>
  <Formatting>
    <BackColor Name="TableArchiveColor">
      <Expression>Archive AND {0}</Expression>
      <Items>
        <Item>
          <Not>
            <Object Name="ArchiveFilterComboBox" />
          </Not>
        </Item>
      </Items>
    </BackColor>
  </Formatting>
</MyObject>

Перейдем в приложение и проверим работу фильтра на списке городов. Но для начала через таблицу в базе данных сделаем один из городов архивным.

Теперь можем скорректировать описание CityDatabaseTableSetDataConnection, добавив параметр Archive. Таким образом, общий синтаксис соединения с данными для отправки будет выглядеть так:

TemplateCityList.xml
<DataConnection Name="CityDatabaseTableSetDataConnection" Type="DatabaseTableSetDataConnection" Assembly="ComplexDataConnections">
  <Workflow Name="Template" />
  <DatabaseTable Name="CityDatabaseTable" />
  <Parameters>
    <Parameter NativeName="CityId">
      <Column Name="CityId" />
    </Parameter>
    <Parameter NativeName="Title">
      <Column Name="Title" />
    </Parameter>
    <Parameter NativeName="Archive">
      <Column Name="Archive" />
    </Parameter>
  </Parameters>
  <SqlQueries>
    <SqlQuery Name="CityInsertSqlQuery" Type="Insert" />
    <SqlQuery Name="CityUpdateSqlQuery" Type="Update" />
    <SqlQuery Name="CityDeleteSqlQuery" Type="Delete" />
  </SqlQueries>
</DataConnection>

Управления архивом

Скачайте архив с изображениями и разархивируйте его в папку проекта \Template\Projects\1. Template\Forms\Images\24x24.

Для переноса записи в архив и восстановления записи из архива будем использовать одну и ту же кнопку, меняя ее иконку и логику в зависимости от значения в колонке Archive выбранной строки в таблице. Для этого создадим условие, которое будет проверять, находится ли выбранная запись в архиве:

TemplateCityList.xml
<Condition Name="SelectedCityIsArchiveCondition" Type="EqualCondition" Assembly="Conditions">
  <Items>
    <Item>
      <Object Name="CityDatabaseTable">
        <Property Name="SelectedRowCellValueByColumnName">
          <Parameters>
            <Parameter Name="ColumnName">Archive</Parameter>
          </Parameters>
        </Property>
      </Object>
    </Item>
    <Item>True</Item>
  </Items>
</Condition>

Хорошей практикой является уточнять у пользователя, действительно ли он хочет совершить действие. Давайте создадим команду с таким вопросом, которую будем вызывать при нажатии на кнопку архива:

TemplateCityList.xml
<Command Name="CityArchiveMessageBoxCommand" Type="MessageBoxCommand" Assembly="Commands">
  <Caption>Архив</Caption>
  <Text>
    <Switch>
      <Case>
        <When>
          <Condition Name="SelectedCityIsArchiveCondition" />
        </When>
        <Then>Вы действительно хотите восстановить выбранную запись из архива?</Then>
      </Case>
      <Case>Вы действительно хотите переместить выбранную запись в архив?</Case>
    </Switch>
  </Text>
  <Icon Type="Question" />
  <Buttons Type="YesNo" />
</Command>

Теперь под описанием объекта CityDeleteButton добавим описание кнопки для работы с архивом:

TemplateCityList.xml
<MyObject Name="CityArchiveButton" Type="Button" Assembly="BaseControls">
  <Top>
    <Calculate>
      <Expression>{0} + 5</Expression>
      <Items>
        <Item>
          <Object Name="CityDeleteButton">
            <Property Name="Bottom" />
          </Object>
        </Item>
      </Items>
    </Calculate>
  </Top>
  <Left>
    <Object Name="CityAddButton">
      <Property Name="Left" />
    </Object>
  </Left>
  <Width>
    <Object Name="CityAddButton">
      <Property Name="Width" />
    </Object>
  </Width>
  <Height>
    <Object Name="CityAddButton">
      <Property Name="Height" />
    </Object>
  </Height>
  <Hint>
    <Switch>
      <Case>
        <When>
          <Or>
            <Condition Name="SelectedCityIsArchiveCondition" />
            <Object Name="ArchiveFilterComboBox" />
          </Or>
        </When>
        <Then>Восстановить из архива</Then>
      </Case>
      <Case>Перенести в архив</Case>
    </Switch>
  </Hint>
  <BackgroundImage>
    <Switch>
      <Case>
        <When>
          <Or>
            <Condition Name="SelectedCityIsArchiveCondition" />
            <Object Name="ArchiveFilterComboBox" />
          </Or>
        </When>
        <Then>Images\24x24\unarchive.png</Then>
      </Case>
      <Case>Images\24x24\archive.png</Case>
    </Switch>
  </BackgroundImage>
  <BackgroundImageLayout>Center</BackgroundImageLayout>
  <FlatStyle>Flat</FlatStyle>
  <FlatBorderSize>1</FlatBorderSize>
  <FlatBorderColor>ButtonFlatBorderColor</FlatBorderColor>
  <FlatMouseDownBackColor>ButtonFlatMouseDownBackColor</FlatMouseDownBackColor>
  <FlatMouseOverBackColor>ButtonFlatMouseOverBackColor</FlatMouseOverBackColor>
  <Enabled>
    <Condition Name="CitySelectedCondition" />
  </Enabled>
  <DisabledMode>True</DisabledMode>
  <DisabledText>
    <Switch>
      <Case>
        <When>
          <Object Name="ArchiveFilterComboBox" />
        </When>
        <Then>Для восстановления из архива выберите запись.</Then>
      </Case>
      <Case>Для переноса в архив выберите запись.</Case>
    </Switch>
  </DisabledText>
  <Commands>
    <Command Name="CityArchiveMessageBoxCommand" />
  </Commands>
</MyObject>

Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим расположение объектов.

Обработка результата MessageBox

Теперь нам необходимо создать <Execution> который будет отрабатывать нажатие кнопки Yes в диалоговом окне CityArchiveMessageBoxCommand. В предыдущих уроках мы создавали условие типа EqualCondition, в котором сверяли результат команды MessageBoxCommand со строковой константой:

<Condition Name="SaveOnCloseMessageBoxCommandYesEqualCondition" Type="EqualCondition" Assembly="Conditions">
  <AlwaysChange Value="True" />
  <Items>
    <Item>
      <Command Name="SaveOnCloseMessageBoxCommand" />
    </Item>
    <Item>Yes</Item>
  </Items>
</Condition>

А сейчас мы познакомимся с другой возможностью получить результат этой команды.

Команда 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>:

TemplateCityList.xml
<Execution>
  <ConditionExpression>
    <Command Name="CityArchiveMessageBoxCommand" Parameter="Yes" />
  </ConditionExpression>
  <Commands>
  </Commands>
</Execution>

При проверке параметра Updated мы тоже можем использовать такой синтаксис.

Редактирование архива

Создадим команду, которая будет менять значение в колонке Archive выделенной строки таблицы CityDatabaseTable на противоположное:

<Command Name="CityArchiveUpdateRowValueSetCommand" Type="ValueSetCommand" Assembly="Commands">
  <Object Name="CityDatabaseTable">
    <Property Name="UpdateRow">
      <Parameters>
        <Parameter Name="RowIndex">
          <Object Name="CityDatabaseTable">
            <Property Name="SelectedRowIndex" />
          </Object>
        </Parameter>
        <Parameter Name="ColumnNames">
          <Structure Type="List">
            <Item>Archive</Item>
          </Structure>
        </Parameter>
        <Parameter Name="Values">
          <Structure Type="List">
            <Item>
              <Not>
                <Object Name="CityDatabaseTable">
                  <Property Name="SelectedRowCellValueByColumnName">
                    <Parameters>
                      <Parameter Name="ColumnName">Archive</Parameter>
                    </Parameters>
                  </Property>
                </Object>
              </Not>
            </Item>
          </Structure>
        </Parameter>
      </Parameters>
    </Property>
  </Object>
</Command>

Добавим вызов этой команды в ранее созданный <Execution>:

TemplateCityList.xml
<Execution>
  <ConditionExpression>
    <Command Name="CityArchiveMessageBoxCommand" Parameter="Yes" />
  </ConditionExpression>
  <Commands>
    <Command Name="CityArchiveUpdateRowValueSetCommand" />
  </Commands>
</Execution>

Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим работу кнопки редактирования архива.

Мы столкнулись с проблемой: если значение фильтра стоит "Актуальные", то при отправке записи в архив она продолжает отображаться в таблице, что неверно. Это связано с тем, что мы напрямую отредактировали запись в таблице, и к ней не применился фильтр. Это особенность фильтра <Filter> в колонке таблицы DatabaseTable. В одном из следующих уроков мы подробно рассмотрим все варианты фильтрации данных на формах.

Данная проблема не возникнет, если вместо изменения строки в таблице DatabaseTable будем изменять запись в DataConnection, который является источником данных для таблицы, или будем отправлять запрос на сервер, а после вызывать команду DataConnectionRefreshCommand, чтобы обновить соединение с данными для таблицы CityDatabaseTable. При изменении источника данных в SourceDataConnection таблица перерисует строки с учетом фильтров в колонках.

Но в нашем случае нужно принудительно обновить значение ArchiveFilterComboBox, чтобы пересчитался фильтр в колонке таблицы DatabaseTable.

Обновление фильтра

Чтобы решить проблему с обновлением фильтра после отправки записи в архив, будем использовать переменную для сохранения текущего значения фильтра, сбрасывать значение фильтра и подставлять значение из переменной.

Создадим переменную, в которую будем сохранять текущее значение фильтра:

TemplateCityList.xml
<MyObject Name="ArchiveFilterVariable" Type="Variable" Assembly="SimpleControls">
  <Value />
</MyObject>

Добавим команду типа ValueSetCommand, с помощью которой будем сохранять значение фильтра:

TemplateCityList.xml
<Command Name="ArchiveFilterVariableValueSetCommand" Type="ValueSetCommand" Assembly="Commands">
  <Object Name="ArchiveFilterVariable">
    <Object Name="ArchiveFilterComboBox"/>
  </Object>
</Command>

Создадим вторую команду типа ValueSetCommand для присваивания нового значения фильтру:

TemplateCityList.xml
<Command Name="ArchiveFilterComboBoxValueSetCommand" Type="ValueSetCommand" Assembly="Commands">
  <Object Name="ArchiveFilterComboBox">
    <Input />
  </Object>
</Command>

Будем использовать универсальное значение <Input>, чтобы в момент обращения к команде передавать в нее NULL, когда будем сбрасывать фильтр, и ArchiveFilterVariable, когда будем возвращать сохраненное значение.

Отредактируем ранее созданный <Execution> так, чтобы после выполнения команды CityArchiveUpdateRowValueSetCommand происходило обновление фильтра:

TemplateCityList.xml
<Execution>
  <ConditionExpression>
    <Command Name="CityArchiveMessageBoxCommand" Parameter="Yes" />
  </ConditionExpression>
  <Commands>
    <Command Name="CityArchiveUpdateRowValueSetCommand" />
    
    <Command Name="ArchiveFilterVariableValueSetCommand" />
    <Command Name="ArchiveFilterComboBoxValueSetCommand">%NULL%</Command>
    <Command Name="ArchiveFilterComboBoxValueSetCommand">
      <Object Name="ArchiveFilterVariable" />
    </Command>
  </Commands>
</Execution>

Перейдем в приложение и откроем список городов. Убедимся, что форма успешно загружена, и проверим работу фильтра при редактировании архива.

Исключение архивных записей из запросов

Как говорилось ранее, выпадающие списки должны содержать актуальные записи. Таким образом, в запросе CityShortSelectSqlQuery для получения списка данных для CityComboBox мы должны исключать архивные записи городов.

Перейдем в файл описания работы серверной части приложения (Template.xml) и дополним наш запрос условием WHERE:

Template.xml
<SqlQuery Name="CityShortSelectSqlQuery">
  <Text>
    SELECT
      city_id AS "CityId",
      title AS "Title"
    FROM
      template.city
    WHERE
      NOT archive
    ORDER BY title;
  </Text>
</SqlQuery>

Перейдем в приложение, откроем карточку клиента и проверим выпадающий список городов.

Здесь стоит обратить внимание на один момент: если город выбранного клиента находится в архиве, то в карточке клиента мы увидим, что его город потерялся:

Чтобы выпадающий список CityComboBox содержал еще и архивный город, на который ссылается запись клиента, необходимо с формы прокидывать в запрос идентификатор этого города. И в запросе в предложении WHERE проверять этот идентификатор.

Вернемся в файл описания работы серверной части приложения (Template.xml) и дополним предложение WHEREпроверкой параметра CityId:

Template.xml
<SqlQuery Name="CityShortSelectSqlQuery">
  <Text>
    SELECT
      city_id AS "CityId",
      title AS "Title"
    FROM
      template.city
    WHERE
      NOT archive OR
      city_id = {CityId}
    ORDER BY title;
  </Text>
</SqlQuery>

Перейдем в файл карточки клиента (TemplateClientEdit.xml) и дополним соединение с данными CityShortPrimaryGetDataConnection параметром CityId, полученным из записи клиента в ClientPrimaryGetDataConnection:

TemplateClientEdit.xml
<DataConnection Name="CityShortPrimaryGetDataConnection" Type="PrimaryGetDataConnection" Assembly="DataConnections">
  <SqlQuery Name="CityShortSelectSqlQuery" Type="Select">
    <Workflow Name="Template" />
    <Fields>
      <Field Name="CityId" />
      <Field Name="Title" />
    </Fields>
    <Parameters>
      <Parameter NativeName="CityId">
        <Value>
          <DataConnection SourceDataConnection="ClientPrimaryGetDataConnection">
            <Fields>
              <Field Name="CityId"/>
            </Fields>
          </DataConnection>
        </Value>
      </Parameter>
    </Parameters>
  </SqlQuery>
</DataConnection>

Перейдем в приложение, откроем карточку клиента и проверим выпадающий список городов.

Отлично! Возможности отправлять записи городов в архив и восстанавливать их из архива реализовали. И в карточке клиента, который ссылается на архивную запись города, корректно отображается наименование города. Осталось поправить процесс удаления записи города, добавив проверку ссылок на идентификатор удаляемого города.

Удаление с проверкой зависимостей

Для начала давайте обратим внимание на грамотный прием взаимодействия с пользователем, который мы использовали ранее в уроке.

На кнопке редактирования архива мы просили пользователя подтвердить свое намерение совершить действие. Это правильный подход, так как пользователь мог случайно нажать на кнопку. И если с кнопкой переноса в архив это нестрашно, пользователь всегда сможет восстановить запись из архива, то с кнопкой удаления это критично. Особенно на формах, где удаление происходит сразу в таблице в базе данных, как это реализовано на форме списка клиентов.

Самостоятельно реализуйте команду 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.

Примеры использования:

SELECT * 
FROM
  template.is_used('city', 'city_id', ARRAY[]::text[], ARRAY[]::text[], 1);

Перейдем в программу для управления СУБД PostgreSQL, и выполним следующий скрипт:

CREATE OR REPLACE FUNCTION template.city_try_delete(in_city_id smallint)
  RETURNS boolean AS
$BODY$
BEGIN

  PERFORM * FROM template.city WHERE city_id = in_city_id;
  IF NOT FOUND THEN
    RETURN true;
  END IF;

  IF (
    SELECT used
    FROM template.is_used('city', 'city_id', ARRAY[]::text[], ARRAY[]::text[], in_city_id)
  )
  THEN
    UPDATE template.city
    SET
      archive = true
    WHERE
      city_id = in_city_id;

    RETURN false;
  ELSE
    DELETE FROM template.city
    WHERE
      city_id = in_city_id;
      
    RETURN true;
  END IF;
END;
$BODY$
  LANGUAGE plpgsql;

Здесь мы создаем функцию для удаления конкретной записи в таблице template.city. В качестве параметра функция принимает идентификатор записи города.

Первым делом мы проверяем, существует ли запись. Если не существует, то возвращаем true, как если бы запись была успешно удалена.

Затем мы проверяем, используется ли запись. Если запись используется, то отправляем ее в архив и возвращаем false, иначе спокойно удаляем запись из таблицы в базе данных.

Перейдем в файл описания работы серверной части приложения (Template.xml) и переделаем запрос на удаление города на вызов функции:

Template.xml
<SqlQuery Name="CityDeleteSqlQuery">
  <Text>
    SELECT template.city_try_delete({CityId}::smallint);
  </Text>
</SqlQuery>

Перейдем в приложение и попробуем удалить запись города, на которую ссылается хотя бы одна запись клиента.

К сожалению, при использовании DatabaseTableSetDataConnection мы не всегда сможем на форме из результата команды SaveCommand корректно отловить значение, возвращаемое функцией template.city_try_delete(smallint). Это связано с тем, что в результат команды будет записан результат последнего выполненного запроса. Как следствие, мы не сможем уведомить пользователя, что какая-то запись при удалении была перемещена в архив, если пользователь попытался удалить несколько записей.

Поэтому на форме списка городов мы не будем делать уведомления о переносе в архив записи. А вот на форме списка клиентов мы легко сможем отловить результат команды ClientDeleteSaveCommand и вывести уведомление. Но это будет на следующем уроке, когда добавим сущность, которая будет ссылаться на запись клиента.

Итоги

На этом уроке мы рассмотрели разные способы удаления связанных записей и остановились на самом подходящем в нашем случае варианте: архивирование записей. На форме списка городов реализовали работу с архивом: отправка записи в архив, восстановление записи из архива и просмотр архивных записей. Так же доработали интерфейс нашего приложения и сделали его более дружелюбным.

Ответы

В архиве присутствуют xml-файлы форм и серверный xml-файл, также лежит бэкап базы данных и файл с запросами на изменение структуры базы данных - с помощью файлов можете проверить себя.

Last updated