DBTreeView своими руками
Автор: Елена Филиппова
Специально для Королевства Delphi
Введение
В статье речь пойдет об отображении данных, хранящихся в БД и имеющих иерархическую (древовидную) структуру. Визуальное представление таких данных требует соответствующего инструмента. Существует немало компонент, которые позволяют представлять данные в виде дерева — для краткости будем называть их все DB TreeView. Компоненты эти довольно удобны, но, как правило, "заточены" под определенные задачи и каждый "шаг в сторону" в структуре данных заставляет многих пускаться в поиски. И на Круглом Столе появляются вопросы: "помогите найти компонент DB TreeView, который позволяет делать еще и ..." и так далее.
А ведь в Delphi существует стандартный компонент для представления древовидных данных, это знакомый всем TTreeView, его возможностей хватает с лихвой практически для всех задач по отображению деревьев. Сделать из TreeView самый настоящий DB TreeView, да еще полностью контролировать его развитие, более перспективный путь, нежели каждый раз искать новый чужой компонент.
Весь этот материал основан на моем личном опыте и, естественно, не обязательно является самым оптимальным вариантом. Это частное мнение, которым я просто хочу поделиться.
Рассмотрим два принципиально разных случая:
- Дерево подразделений
- Данные представляют собой классическую древовидную структуру. Глубина вложенности дерева не ограничена, заранее не известна и не одинакова для разных ветвей. Дерево может не иметь общего корневого узла, то есть распадаться на несколько деревьев.;
- Дерево аналитических признаков
- Реально данные не являются иерархическими, но могут быть представлены в таком виде. Глубина вложенности дерева одинакова для всех его веток и фиксирована в данном примере. Правило построения иерархии может меняться в режиме run-time.
Отличаются эти примеры стуктурой данных и, соответственно, способом формирования дерева.
Тем не менее сущестуют общие правила для генерации дерева. Во-первых, я исхожу из предположения, что все дерево сразу с большой степенью вероятности не понадобится пользователю. Более того, сам процесс формирования TTreeView довольно длительный и занимает ощутимое время, особенно если работать с большим объемом БД.
Гораздо правильнее строить отдельные ветки дерева динамически, только в тот момент, когда они наверняка понадобятся.
То есть, в самый начальный момент необходимо сформировать только самый верхний уровень дерева. Затем, по мере требования, можно достраивать очередную ветвь.
Какой же момент можно считать моментом требования? В принципе есть два равнозначных варианта и отличаются они только внешним видом.
- Первый: по двойному клику на текущей ветви дерева проверять, была ли она достроена, если нет то формировать ее и генерировать событие OnExpanding, раскрывая новую ветвь.
- Второй: при попытке "раскрыть" ветку проводить аналогичные действия.
Основное отличие этих подходов: в первом случае пользователю необходимо сделать явно лишнее и не очень естественное для дерева движение , то есть двойной клик. А во втором случае мы должны обеспечить возможность раскрытия для каждой ветки дерева, ведь если у ветви нет значка [+] , то и попытаться раскрыть ее будет невозможно. А значит для всех новых, свеже сформированных ветвей, принудительно нужно обеспечить существования фиктивной дочерней ветви.
Можете сами выбрать, что Вам удобнее, я использую второй вариант.
На сервере http://ib.demo.ru/
была опубликована статья Кузьменко Дмитрия "Древовидные (иерархические) структуры данных в реляционных базах данных",
часть которой посвящена теме визуального представления древовидных данных. В этой статье предлагалось хранить в таблице дополнительное поле с количеством дочерних записей для каждой ветви именно для того, чтобы иметь возможность принимать решение рисовать или не рисовать знак [+] возле текущей ветки дерева. То есть на ходу определять, имеет ли шанс эта ветка быть раскрытой.
Следовательно, только для процесса визуализации требуется изменять структуру данных, добавлять триггеры для сохранения целостности дополнительной информации.
Каждый способ имеет свои достоинства и недостатки, но мне кажется несколько неоправданным такой подход, когда структура таблиц зависит от способа их отображаения. В примерах, которые иллюстрируют материал этой статьи применяется использование фиктивных дочерних ветвей для обеспечения появления у каждой ветви значка [+].
Примечание:
Такой вопрос, как способы представления иерархических данных в БД, не является предметом данной статьи.
Пример построен на локальных Paradox-таблицах. Следовательно для переноса его на SQL-серверные БД стоит учитывать их особенности.
Дерево подразделеений
Пусть у нас существует таблица подразделений, каждое из которых может иметь свои внутренние подразделения. Необходимо отображать эти данные в виде дерева.
Используемая в примере таблица — COMPANY.DB
Структура данных реализована классическим деревом: каждая запись о подразделении представлена полями
- CompanyID — номер подразделения
- ParentID — номер родительского подразделения
- Name — название.
Для тех подразделений, которые не имеют головных над собой, поле ParentID равно 0.
Формировать уровни дерева мы будем с помощью запроса к таблице ( компонент qTreeCompanies : TQuery ).
Select * From COMPANY
Where ParentID=:ParentID
|
Параметр ParentID будет определять, какую ветку мы сейчас достраиваем. То есть, к какой ищем дочерние подразделения.
Процедура, формирующая очередной уровень (дочерний для ветви Node ) реализована следующим образом:
Procedure TFormTree.ExpandLevel( Node : TTreeNode);
Var ID , i : Integer;
TreeNode : TTreeNode;
Begin
// Для самого верхнего уровня выбрать только тех,
// кто не имеет родителей.
IF Node = nil Then ID:=0
Else ID:=Integer(Node.Data);
qTreeCompanies.Close;
qTreeCompanies.ParamByName('ParentID').AsInteger:=ID;
qTreeCompanies.Open;
TreeCompanies.Items.BeginUpdate;
// Для каждой строки из полученного набора данных
// формируем ветвь в TreeView, как дочерние ветки к той,
// которую мы только что "раскрыли"
For i:=1 To qTreeCompanies.RecordCount Do
Begin
// Запишем в поле Data ветки ее идентификационный номер(ID) в таблице
TreeNode:=TreeCompanies.Items.AddChildObject(Node ,
qTreeCompanies.FieldByName('Name').AsString ,
Pointer(qTreeCompanies.FieldByName('ID').AsInteger));
TreeNode.ImageIndex:=1;
TreeNode.SelectedIndex:=2;
// Добавим фиктивную (пустую) дочернюю ветвь только для того,
// чтобы был отрисован [+] на ветке и ее можно было бы раскрыть
TreeCompanies.Items.AddChildObject(TreeNode , '' , nil);
qTreeCompanies.Next;
End;
TreeCompanies.Items.EndUpdate;
End;
|
Теперь позаботимся о том, чтобы она вызывалась в нужный нам момент времени. На событие OnExpanding проверим, есть ли у текущей ветки фиктивная дочерняя ветвь и, если она есть, сформируем реальную ветку, предварительно удалив фиктивную.
if Node.getFirstChild.Data = nil then
begin
Node.DeleteChildren;
ExpandLevel(Node);
end;
|
На форме в проекте кроме дерева расположена еще и сетка (Grid), в которой отображаются записи текущего уровня подразделений. Это, по сути, список дочерних ветвей для текущей ветки дерева. Для того, чтобы синхронизировать TreeView и DBGrid используем нехитрый прием — на событие TTreeView.OnChange (шаг по ветке) добавим следующий код:
IF TreeCompanies.Selected <> nil Then
Begin
// ID родительской ветки , для нее и ищем все дочерние
ID:=Integer(TreeCompanies.Selected.Data);
qCompanies.Close;
qCompanies.ParamByName('ParentID').AsInteger:=ID;
qCompanies.Open;
End;
|
Помните, в процедуре ExpandLevel мы записывали в поле Data каждой ветки ее идентификационный номер? Вот его то мы сейчас и используем.
Для полного ощущения передвижения по Grid'у, как по дереву можно добавить эффект "проваливания" на более глубокий уровень. По двойному клику на записи в Grid'е пользователь проваливается на один уровень вниз по иерархии, если такой уровень, конечно же еще есть.
На событие OnDblClick для грида:
ID:=qCompanies.FieldByName('ID').AsInteger;
// принудительное "невидимое" раскрытие той ветки, на которой стоим
TreeCompanies.OnExpanding(TreeCompanies ,TreeCompanies.Selected , Allow);
// Перебираем все получившиеся дочерние ветки и ищем ту, ID которой
// совпадает с ID строки в правой таблице. То есть ищем ветку в дереве,
// которая соответсвует той записи в таблице, на которой мы стоим
// Как только нашли, визуально раскрываем ветку и делаем ее выделенной,
// то есть визуально "встаем" на нее в дереве
FOR i:=0 To TreeCompanies.Selected.Count-1 Do
IF Integer(TreeCompanies.Selected.Item[i].Data) = ID Then
Begin
TreeCompanies.Selected.Item[i].Expand(False);
TreeCompanies.Selected.Item[i].Selected:=True;
TreeCompanies.Repaint;
Exit;
End;
|
Параллельно с этим раскрывается соответствующая ветка самого дерева. Очень эффектно. :о)
В проекте реализована возможность добавления новых ветвей, то есть новых подразделений. Нажимте на TreeView правую кнопку мышки и достраивайте наше дерево, как Вам угодно!
Дерево аналитических признаков
"Куст — это пучок веток, растущих из одного места"
из военных афоризмов
Пусть у нас есть таблица документов, каждый документ, например, описывает некоторую операцию по покупке( или продаже ) товара. В этой операции участвуют: определенный товар, клиент, у которого куплен (или которому продан) этот товар, и город, в котором данная операция совершена. Таким образом, мы имеем таблицу документов, каждая запись в которой наделена тремя аналитическими признаками: Город, Клиент и Товар.
По сути своей эти данные не являются иерархическими, и никакой явной зависимости между документами не прослеживается. Но тем не менее эта связь существует — одинаковые аналитические признаки. Например, в одну группу можно объединить все документы, имеющие отношение к определенному товару или клиенту.
Если для анализа данных пользователю необходимо работать с документами по зафиксированным аналитическим признакам, то возникает задача визуального представления таких данных. К примеру, заказчик требует показывать ему все документы для определенного города, а потом, для определенного клиента и товара, но в том же городе. Однако желательно видеть информацию ровно в обратном порядке: сначала выбрать документы с нужным товаром, затем увидеть все города, где работали с этим товаром, выбирать нужный город и так далее.
Явно прослеживается древовидная структура, где каждый уровень дерева суть аналитический признак. Именно поэтому, как говорилось выше, в данном дереве фиксирована глубина вложенности. Она однозначно определяется количеством аналитики для документа.
Описание примера, реализующего данную задачу
Используемые таблицы:
Таблица документов DOCUMENTS.DB , где каждый документ определен полями:
- DocumentID : номер документа
- Name : название
- CityID : ID города
- ClientID : ID клиента
- GoodsID : ID товара
Таблицы аналитики, соответственно CITIES.DB, CLIENTS.DB и GOODS.DB, содержат поля названия Name и номера (CityID , ClientID , GoodID).
Так как порядок следования аналитики произвольный, зараннее невозможно написать текст SQL-запроса, который будет возвращать данные для очередного уровня дерева. Этот текст придется формировать в run-time, когда все данные будут известны.
Чтобы не зашиваться именно на такой список аналитики, который приведен в примере, стоит затратить немного больше сил и обеспечить себе некоторую универсальность. Для этого добавим еще одну таблицу (таблицу сущностей) Entities, содержащую описание используемой аналитики.
Поля таблицы ENTITIES.DB
- EntityID — идентификатор
- Name — русское название аналитического признака
- TableName — название таблицы, в которой он содержится
- KeyColumn — ключевой столбец этой таблицы
- OrderNo — номер по порядку в списке используемых признаков
- IsSelect — выбран или не выбран в текущий список признаков
- ImageIndex — номер изображения (используется в отображении дерева)
В нашем случае эта таблица будет выглядеть так:
EntityID |
Name |
TableName |
KeyColumn |
... |
1 |
Город |
CITIES |
CityID |
... |
2 |
Товар |
GOODS |
GoodID |
... |
3 |
Клиент |
CLIENTS |
ClientID |
... |
В примере я использую список ListEntities (TCollection), каждый элемент которого содержит поля TableName, KeyColumn и ImageIndex. Элементы в этом списке расположены в том порядке, в каком будет строиться дерево. Заполняется этот список только той аналитикой, которая требуется для конкретного дерева. Например, только города и клиенты или товары и клиенты, или сразу все вместе. Следовательно этот список (ListEntities) и содержит полную информацию для построения дерева в каждый конкретный момент.
Заполнение списка аналитики проводится в модуле setupEntities.pas.
//считывает аналитику из таблицы
procedure GetEntities( var List : TEntityLists );
// вызывает диалог для настойки аналитики пользователем
function SetEntities( var List : TEntityLists ) : Boolean;
|
Формирование дерева в этом случае полностью аналогично предыдущему примеру, с той только разницей, которая касается формирования текста запроса для каждого уровня дерева.
Так же в дерево добавляется единый для всех веток самый верхний корневой узел. С точки зрения аналитики это "фиктивный" узел, так как он будет отображать ВСЕ документы, без указания конкретного значения аналитического признака. Кроме того, достраивается еще один, самый нижний уровень — список документов по зафиксированной для текущей ветки аналитики.
Наличие этих "фиктивных" узлов совершенно не обязательно, но, на мой взгляд, очень логично.
В итоге, глубина вложенности дерева будет равна "количество аналитики" + 2.
Итак, процедура ExpandLevel будет модифицирована следующим образом:
Procedure TFormTree.ExpandLevelAnalytic(Node : TTreeNode );
Var NewItem : TListsItem;
ImageIndex ,
Level , i : Integer;
TreeNode : TTreeNode;
Sql,Name : String;
Begin
IF Node = nil Then Exit;
TreeAnalytic.Items.BeginUpdate;
Level:=Node.Level + 1; // уровень, который будет раскрываться
// Самому первому аналитическому признаку в списке ListEntities
// соответсвует _второй_ физический уровень веток дерева.
// Так как самый верхний уровень дерева фиктивный -"все документы"
// Отсюда и игра с (+/-) 1 при обращении к списку
qTreeAnalytic.Close;
// Определим, на каком типе уровня мы сейчас находимся
//
IF Level > ListEntities.Count
Then Begin // Уровень документов, аналитка закончилась
Sql:='SELECT * FROM Documents Where '+ GetSqlPath(Node);
Name:= 'DocumentID';
ImageIndex:=3;
End
Else Begin // Очередной уровень аналитики
Sql:='SELECT DISTINCT '+ ListEntities[Level-1].AsString + '.* ' +
' FROM Documents , ' + ListEntities[Level-1].AsString + ' WHERE ' +
ListEntities[Level-1].AsString + '.' +ListEntities[Level-1].Name + '=' +
'Documents.'+ListEntities[Level-1].Name + ' AND ' + GetSqlPath(Node) ;
Name:=ListEntities[Level-1].Name;
ImageIndex:=ListEntities[Level-1].ImageIndex;
End;
qTreeAnalytic.Sql.Clear;
qTreeAnalytic.Sql.Add(Sql);
qTreeAnalytic.Open;
// Получен очередной уровень ветвей дерева
For i:=1 To qTreeAnalytic.RecordCount Do
Begin
NewItem:=List.AddItem(qTreeAnalytic.FieldByName(Name).AsInteger , Name);
TreeNode:=TreeAnalytic.Items.AddChildObject( Node ,
qTreeAnalytic.FieldByName('Name').AsString, NewItem );
TreeNode.ImageIndex:=ImageIndex;
TreeNode.SelectedIndex:=TreeNode.ImageIndex;
// Фиктивная дочерняя ветка ТОЛЬКО для уровней аналитики,
// так как документы - последний уровень, за которым ничего и не может быть
IF Level <= ListEntities.Count
Then TreeAnalytic.Items.AddChild(TreeNode , '' );
qTreeAnalytic.Next;
End;
TreeAnalytic.Items.EndUpdate;
End;
|
В предыдущем примере мы запоминали ID строки из таблицы в поле Data каждой ветви дерева. Сейчас нам не годится такой вариант, так как аналитический признак определяется не одним идентификатором, а целым элементом списка ListEntities, вот его то и надо запоминать. Поэтому в поле Data сохраняется ссылка на конкретный элемент этого списка. Благо это Pointer и записать туда можно все, что угодно.
В процедуре используется функция GetSqlPath, которая возвращает полный путь от корня до указанной ветки дерева. Полный путь это есть зафиксированные значения для каждого уровня аналитики. Эти значения необходимы для того, чтобы верно построить запрос. То есть мы фактически формируем дополнительный фильтр для последующих выборок, напрмер — получаем всех клиентов для конкретного города и указанного товара.
Function TFormTree.GetSqlPath( Node : TTreeNode ) : String;
Begin
Result:=' 0=0 ' ;
// Участвуют все ветви дерева, кроме самого верхнего фиктивного уровня
While Node.Level > 0 Do
Begin
Result:= Result + ' AND ' +
'Documents.' + TListsItem(Node.Data).Name + '=' +
TListsItem(Node.Data).AsString ;
// Делаем шаг назад по ветке дерева
Node:=Node.Parent;
End;
End;
|
Пример текстов SQL запроса, который будет сформирован при движении по дереву:
Уровень "товары" — Все товары, которые встречаются в документах, созданных в городе номер 6 и для клиента номер 3
SELECT DISTINCT Goods.* FROM Documents , Goods
WHERE Goods.GoodsID=Documents.GoodsID AND 0=0
AND Documents.CityID=6 AND Documents.ClientID=3
|
Последний уровень "документы" — Все документы, созданные в городе номер 6 и для клиента номер 3, по товару номер 1
SELECT * FROM Documents Where 0=0 AND
Documents.GoodsID=1 AND Documents.CityID=6 AND Documents.ClientID=3
|
Такой подход позволяет легко расширять набор аналитических признаков, которые должны использоваться в программе, практически без изменения кода клиентского приложения. Достаточно изменить структуру таблицы DOCUMENTS и дополнить таблицу ENTITIES.
В некоторой степени можно сказать, что таблица ENTITIES содержит метаданные о структуре базы. Правда с большой натяжкой, так как в данном примере структура просто элементарна, а связи слишком просты и не поддерживают никакой глубины вложения (как, например, в таком случае, когда город не указан явно в документе, но может быть вытянут из таблицы клиентов и так далее).
Для получения набора данных не обязательно использовать именно запросы, точно так же можно использовать хранимые процедуры для SQL-серверных СУБД. Изменится структура метаданных, но не изменится принцип формирования дерева.
На мой взгляд, в качестве примера, стоит внимательно рассмотреть такой подход, чтобы вы могли в своих конкретных задачах на его основе конструировать реальные метаданные и без проблем модифицировать дерево аналитических признаков.
Итак...
Итак, были рассмотрены две принципиально разные задачи, а реализация DBTreeView оказалась практически идентична. Собственно, этот факт и является важным результатом статьи — воспользуйтесь примерами, добавьте собственный функционал и создайте для себя несложный компонент для отображения древовидной структуры. Это не значит, что не стоит пользоваться сторонними компонентами, ни в коем случае. Просто если существующие вас не устраивают полностью, вы будете знать, как это исправить.
Для иллюстрации материала статьи подготовлен
проект TreeDB :
Проект откомпилирован в Delphi 5, использует BDE и настроен на алиас TreeDB
|