Опубликован: 07.05.2010 | Уровень: специалист | Доступ: платный
Лекция 10:

Сохранение древовидных структур в базе данных

< Лекция 9 || Лекция 10: 12 || Лекция 11 >
Аннотация: Эта лекция посвящена сохранению древовидных структур в базе данных. Вы узнаете, как можно сохранить дерево с неограниченным количеством ветвей в таблицу, и как считать его обратно в компонент TreeView.

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

Типичный пример дерева - всем знакомое дерево каталогов. Примеров таких структур множество - это могут быть отделы в каком-либо учреждении или разделы библиотеки. Посмотрим на рисунок с фрагментом дерева разделов библиотеки:

Дерево разделов

Рис. 10.1. Дерево разделов

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

Самый простой способ сохранения структуры дерева и ее считывания обратно - воспользоваться тем, что дерево - это список узлов, и имеет хорошо знакомые нам методы:

//сохраняем в файл:
  TreeView1.SaveToFile('myfile.txt');
  //читаем из файла:
  TreeView1.LoadFromFile('myfile.txt');

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

Когда программист впервые сталкивается с необходимостью хранения древовидных структур в базе данных, обычно он первым делом подключается к Интернету и ищет какой-нибудь компонент, который бы позволил это делать. Но не все нестандартные компоненты работают качественно, да и зачем искать какой-то новый компонент, когда имеется стандартный TreeView на вкладке Win32 Палитры компонентов? Именно с этим компонентом мы и будем работать в данной лекции.

Рецептов работы с деревьями в базах данных много, мы рассмотрим лишь один из них, достаточно эффективный и в то же время простой. Смысл этого способа состоит в том, чтобы в каждой записи таблицы сохранять номер узла раздела, номер его родителя, если он есть, и название узла. В случае если узел не имеет родителя (главный узел, например, "Художественная литература" в рисунке 10.1), то в соответствующее поле запишем ноль.

Подготовка проекта

Для реализации примера нам потребуется новая база данных. Загрузите MS Access и создайте базу данных " TreeBD ", а в ней таблицу " Razdels ". Вообще-то, в базе данных MS Access как таблицы, так и поля могут иметь русские названия, однако мы будем использовать средства SQL, который не всегда корректно обрабатывает русские идентификаторы. Кроме того, данный способ можно использовать в любой СУБД, а далеко не все из них так предупредительны, как MS Access, поэтому название таблицы и ее полей выполним латиницей.

Таблица будет иметь три поля:

Таблица 10.1 . Поля таблицы "Разделы"
Имя поля Тип поля Дополнение
1 R_Num Счетчик Ключевое поле
2 R_ Parent Числовой Целое
3 R_Name Текстовый Длина 50 символов

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

Далее создадим в Delphi новый проект и простую форму:

Форма для работы с деревом

Рис. 10.2 . Форма для работы с деревом

Как всегда, назовите форму fMain, в свойстве Caption напишите "Реализация сохранения дерева в БД", модуль формы сохраните как Main, а проект в целом назовите, например, TreeToBD. Сделанная база данных TreeBD должна быть в той же папке, что и проект.

Далее установите компонент TreeView (дерево) с вкладки Win32. Его свойству Align присвойте alLeft, чтобы дерево заняло весь левый край. Затем можете установить сплиттер - разделитель, ухватившись за который пользователь сможет менять ширину дерева. Компонент Splitter находится на вкладке Additional и его свойство Align по умолчанию равно alLeft - разделитель "прилепится" к правому краю дерева.

Правее установите сетку DBGrid с вкладки Data Controls, и его свойству Align присвойте alClient, чтобы сетка заняла все оставшееся место. Ни главное меню, ни панель инструментов нам здесь не потребуются, используем лишь два всплывающих PopupMenu - первый для дерева, второй для сетки (выберите соответствующие PopupMenu в свойстве PopupMenu этих компонентов).

Далее с вкладки ADO нам потребуется компонент ADOConnection для соединения с базой данных, таблица ADOTable и запрос ADOQuery для вспомогательных нужд. С вкладки Data Access - компонент DataSource, для связи сетки с таблицей. Подключите ADOConnection к базе данных и откройте соединение ( "ADO. Связь с таблицей MS Access" ). Таблицу подключите к ADOConnection (свойство Connection ), затем выберите в свойстве TableName нашу таблицу " Razdels ", а свойство Name переименуйте в tRazdels - так будем обращаться к таблице. Для удобства отображения названия полей откройте редактор полей таблицы (дважды щелкнув по ней), добавьте все поля и у каждого поля измените свойство DisplayLabel, соответственно, на "№", "Родитель" и "Название". Не забудьте открыть таблицу.

Компонент DataSource подключите к tRazdels, а сетку - к DataSource, в сетке должны отобразиться поля. Кроме того, переименуйте свойство Name запроса ADOQuery1 в Q1, ведь нам часто придется обращаться к нему по имени. Запрос также подключите к ADOConnection, но делать его активным не нужно.

На этом приготовления закончены.

Создание и сохранение в таблицу дерева разделов

Работа с деревьями состоит из двух этапов:

  1. Сохранение дерева в таблицу.
  2. Считывание дерева из таблицы.

В этом разделе лекции разберем первый этап. Щелкните дважды по компоненту PopupMenu1, который "привязан" к дереву, и создайте в нем следующие разделы:

  • Создать главный раздел
  • Добавить подраздел к выделенному
  • Переименовать выделенный
  • Удалить выделенный
  • -
  • Свернуть дерево
  • Развернуть дерево

Все эти команды относятся к работе с разделами дерева. Прежде всего, создадим обработчик для команды "Создать главный раздел". Листинг процедуры смотрите ниже:

{Создать главный раздел}
procedure TfMain.N1Click(Sender: TObject);
var
  s: String; //для получения имени раздела (подраздела)
  NewRazd: TTreeNode; //для создания нового узла дерева
begin
  //вначале очистим s
  s:= '';
  //Получим в s имя нового раздела:
  if not InputQuery('Ввод имени раздела',
    'Введите заголовок раздела:', s) then Exit;
  //снимаем возможное выделение у дерева:
  TreeView1.Selected:= nil;
  //создаем главный раздел (ветвь):
  NewRazd:= TreeView1.Items.Add(TreeView1.Selected, s);
  //Сразу же сохраняем его в базу:
  tRazdels.Append; //добавляем запись
  tRazdels['R_Parent']:= 0; //не имеет родителя
  //присваиваем значение созданного раздела:
  tRazdels['R_Name']:= NewRazd.Text;
  //сохраняем изменения в базе:
  tRazdels.Post;
end;

Разберем код. Переменная NewRazd имеет тип TTreeNode, к которому относятся все разделы и подразделы (узлы) дерева. В текстовую переменную s с помощью функции InputQuery() мы получаем имя нового главного узла. Функция имеет три строковых параметра:

  1. Заголовок окна.
  2. Пояснительная строка.
  3. Переменная, куда будет записан введенный пользователем текст.

Если переменная, передаваемая в качестве третьего параметра, пуста, то поле ввода будет пустым. Если же в ней содержался текст - он будет выведен как текст "по умолчанию". Функция возвращает True, если пользователь ввел (или изменил) текст, и False в противном случае. В результате работы функции для пользователя будет выведено простое окно с запросом:

Окно функции InputQuery()

Рис. 10.3 . Окно функции InputQuery()

Далее строкой

TreeView1.Selected:= nil;

мы снимаем выделение, если какой либо раздел был выделен, ведь мы создаем главный раздел, не имеющий родителя. Свойство Selected компонента TreeView указывает на выделенный узел и позволяет производить с ним различные действия, например, получить текст узла:

TreeView1.Selected.Text;

А присваиваемое значение nil (ничто) снимает всякое выделение, если таковое было. Далее мы создаем сам узел:

NewRazd:= TreeView1.Items.Add(TreeView1.Selected, s);

Разберем эту строку подробней. Переменная NewRazd - это новый узел дерева. Каждый узел - объект, обладающий своими свойствами и методами. Все узлы хранятся в списке - свойстве Items дерева TreeView, а метод Add() этого свойства позволяет добавить новый узел. У метода два параметра - выделенный узел (у нас он равен nil ) и строка текста, которая будет присвоена новому узлу. Таким образом, в дереве появляется новый главный узел.

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

tRazdels.Append; //добавляем запись
  tRazdels['R_Parent']:= 0; //не имеет родителя
  //присваиваем значение созданного раздела:
  tRazdels['R_Name']:= NewRazd.Text;
  //сохраняем изменения в базе:
  tRazdels.Post;

Вы помните, что такие методы, как Append или Insert автоматически переводят таблицу в режим редактирования, поэтому вызывать метод Edit излишне?

Обратите внимание на то, что мы сохраняем ноль в поле "R_ Parent ", так как это - главный раздел, не имеющий родителя. Свойство Text нового узла NewRazd содержит название нового узла, которое мы присваиваем полю "R_Name".

Далее сгенерируем процедуру для команды меню "Добавить подраздел к выделенному":

{Добавить подраздел к выделенному разделу(подразделу)}
procedure TfMain.N2Click(Sender: TObject);
var
  s: String; //для получения имени раздела (подраздела)
  z: String; //для формирования заголовка окна
  NewRazd: TTreeNode; //для создания нового узла дерева
begin
  //Проверим - есть ли выделенный раздел?
  //Если нет - выходим:
  if TreeView1.Selected = nil then Exit;
  //вначале очистим s
  s:= '';
  //сформируем заголовок окна запроса:
  z:= 'Раздел " + TreeView1.Selected.Text +
     '";
  //Получим в s имя нового раздела:
  if not InputQuery(PChar(z), 'Введите заголовок подраздела:',
                    s) then Exit;
  //создаем подраздел:
  NewRazd:= TreeView1.Items.AddChild(TreeView1.Selected, s);

  //перед сохранением подраздела в базу, прежде получим
  //номер его родителя:
  Q1.SQL.Clear;
  Q1.SQL.Add('select * from Razdels 
   where R_Name="+
                  NewRazd.Parent.Text+");
  Q1.Open;

  //Теперь сохраняем его в базу:
  tRazdels.Append; //добавляем запись
  //присваиваем № родителя:
  tRazdels['R_Parent']:= Q1['R_Num'];
  //присваиваем название узла:
  tRazdels['R_Name']:= NewRazd.Text;
  //сохраняем изменения в базе:
  tRazdels.Post;
end;

Код этой процедуры очень похож на код предыдущей, но есть и отличия. Прежде всего, мы проверяем - а имеется ли выделенный раздел? Ведь фокус ввода мог быть и на сетке DBGrid, когда пользователь щелкнул правой кнопкой по дереву, и выбрал эту команду. В этом случае, если не делать проверки, мы получим ошибку, пытаясь добавить дочерний узел к пустоте.

Далее, мы ввели строковую переменную z, чтобы сформировать запрос. Ведь пользователю будет удобней, если в окне InputQuery() он сразу увидит, к какому именно разделу он добавляет подраздел.

Затем, при добавлении дочернего узла вместо метода Add() мы используем метод AddChild().

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

Q1.SQL.Add('select * from Razdels where R_Name='"'+
                  NewRazd.Parent.Text+'"');

Запрос формирует набор данных с единственной строкой - записью родителя добавляемого элемента. Поле Q1['R_Num'], как вы понимаете, хранит номер этого родителя в запросе.

Код процедуры переименования выделенного раздела выглядит так:

{Переименовать выделенный раздел (подраздел)}
procedure TfMain.N3Click(Sender: TObject);
var
  s: String; //для получения имени раздела (подраздела)
  z: String; //для формирования заголовка окна
begin
  //Проверим - есть ли выделенный раздел?
  //Если нет - выходим:
  if TreeView1.Selected = nil then Exit;
  //получаем текущий текст:
  s:= TreeView1.Selected.Text;
  //формируем заголовок:
  z:= 'Редактирование "' + s + '"';
  //если не изменили, выходим:
  if not InputQuery(PChar(z), 'Введите новый заголовок:', s) then Exit;
  //находим эту запись в таблице, учитывая, что ее по каким то
  //причинам может и не быть:
  if not tRazdels.Locate('R_Name', TreeView1.Selected.Text, []) 
then begin
    ShowMessage('Ошибка! Указанный раздел не существует в таблице.');
    Exit;
  end; //if
  //если до сих пор не вышли из процедуры, значит запись найдена,
  //и является текущей. изменяем ее:
  tRazdels.Edit;
  tRazdels['R_Name']:= s;
  tRazdels.Post;
  //теперь меняем текст выделенного узла:
  TreeView1.Selected.Text := s;
end;

Здесь комментарии достаточно подробны, чтобы вы разобрались с кодом. Следует обратить внимание на то, что вначале мы исправляем запись в таблице, и только потом - в узле. Если бы мы сначала исправили текст узла, как бы затем нашли старую запись в таблице? Пришлось бы вводить дополнительную переменную для хранения старого текста.

Удаляется выделенный узел еще проще:

{Удалить выделенный раздел (подраздел)}
procedure TfMain.N4Click(Sender: TObject);
var
  s: String; //для строки запроса
begin
  //Проверим - есть ли выделенный раздел?
  //Если нет - выходим:
  if TreeView1.Selected = nil then Exit;
  //иначе формируем строку запроса:
  s:= 'Удалить "' + 
TreeView1.Selected.Text + '"?';
  //запросим подтверждение у пользователя:
  if Application.MessageBox(PChar(s), 'Внимание!',
      MB_YESNOCANCEL+MB_ICONQUESTION) <> IDYES then Exit;
  //если не вышли - пользователь желает удалить раздел.
  //найдем и удалим его вначале из таблицы:
  if tRazdels.Locate('R_Name', TreeView1.Selected.Text, []) then
     tRazdels.Delete;
  //теперь удаляем раздел из дерева:
  TreeView1.Items.Delete(TreeView1.Selected);
end;

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

{свернуть дерево}
TreeView1.FullCollapse;

{развернуть дерево}
TreeView1.FullExpand;

Итак, метод FullCollapse дерева TreeView сворачивает его узлы, а метод FullExpand разворачивает.

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

< Лекция 9 || Лекция 10: 12 || Лекция 11 >
Евгений Медведев
Евгений Медведев

В лекции №2 вставляю модуль данных. При попытке заменить name на  fDM выдает ошибку: "The project already contains a form or module named fDM!". Что делать? 

Анна Зеленина
Анна Зеленина

При вводе типов успешно сохраняется только 1я строчка. При попытке ввести второй тип вылезает сообщение об ошибке "project mymenu.exe raised exception class EOleException with message 'Microsoft Драйвер ODBC Paradox В операции должен использоваться обновляемый запрос'. 

Назерке Сейтсаданова
Назерке Сейтсаданова
Казахстан, Усть-Каменагорск, ВКГУ им. С.Аманжолова
Наталья Статченко
Наталья Статченко
Россия, Благовещенск