
Привязка ASP.NET TreeView к реляционным данным из DataSet
TreeView не принимает DataTable напрямую: ему нужен иерархический источник. Разбираем, как без XSLT превратить две связанные таблицы в XML, который дерево понимает с первого раза.
Почему TreeView не берёт DataTable
Большинство серверных контролов Web Forms охотно принимают в DataSource что угодно перечисляемое: DataTable, ArrayList, массив. GridView, DropDownList, Repeater привязываются за две строки, и к этому быстро привыкаешь. TreeView выбивается из ряда: он работает только с источниками, реализующими IHierarchicalDataSource. Плоская таблица, даже со ссылкой на саму себя через ParentID, ему не подходит.
Штатных иерархических источников в ASP.NET два: SiteMapDataSource и XmlDataSource. Первый годится только для карты сайта, поэтому практический путь один: получить из базы нужные строки, превратить их в XML правильной формы и отдать дереву через XmlDataSource. Вся статья про то, как сделать этот XML без ручной сборки строк и без отдельного XSLT-файла.
План действий
Задача типовая: категории товаров на первом уровне, сами товары на втором. Данные лежат в двух таблицах, связанных по CategoryID. Последовательность шагов такая:
- загрузить обе таблицы в один DataSet через SqlDataAdapter;
- описать связь между ними объектом DataRelation;
- включить у связи свойство Nested, чтобы дочерние строки попали внутрь родительских;
- перевести все колонки в режим MappingType.Attribute;
- выгрузить DataSet в строку методом WriteXml;
- положить строку в XmlDataSource.Data и настроить TreeNodeBinding для каждого уровня.
Какой XML ждёт дерево
XmlDataSource превращает каждый элемент в узел, а значения для текста и value берёт из атрибутов. Значит, нужна такая структура: элемент Categories с атрибутами CategoryID и CategoryName, а внутри него элементы Products со своими атрибутами.
Если вызвать DataSet.GetXml() «как есть», получится другое. Каждая колонка станет отдельным дочерним элементом, а товары окажутся рядом с категориями, а не внутри них. Формально XML корректный, но TreeView покажет дерево из узлов «CategoryID», «CategoryName» и прочих служебных имён.
Первое, что приходит в голову: прогнать результат через XSLT. Это работает, но заводит в проекте лишний файл, который нужно помнить при каждом изменении запроса. Есть способ проще: DataSet умеет сам писать атрибуты, если его попросить.
<NewDataSet>
<!-- категория: значения в атрибутах -->
<Categories CategoryID="1" CategoryName="Beverages">
<!-- товары вложены внутрь категории -->
<Products CategoryID="1" ProductID="1" ProductName="Chai" />
<Products CategoryID="1" ProductID="2" ProductName="Chang" />
</Categories>
<Categories CategoryID="2" CategoryName="Condiments">
<Products CategoryID="2" ProductID="3" ProductName="Aniseed Syrup" />
</Categories>
</NewDataSet>Загружаем данные и строим связь
Обе выборки заливаются в один DataSet под понятными именами таблиц. Эти имена потом станут именами XML-элементов и значениями DataMember в привязках, поэтому выбирайте их осознанно.
Ключевая строка здесь CategoryRelation.Nested = True. Без неё WriteXml выведет таблицы друг за другом, и вложенности не будет. Четвёртый параметр конструктора DataRelation создаёт ограничения внешнего ключа: если в Products окажется товар с несуществующей категорией, Fill упадёт сразу, и это лучше, чем молча потерянный узел.
Private Function GetRelationalData() As DataSet
Dim ds As New DataSet()
Using con As New SqlConnection(ConnString)
' обе выборки в один DataSet
Using daCat As New SqlDataAdapter(
"SELECT CategoryID, CategoryName FROM Categories", con)
daCat.Fill(ds, "Categories")
End Using
Using daProd As New SqlDataAdapter(
"SELECT CategoryID, ProductID, ProductName FROM Products", con)
daProd.Fill(ds, "Products")
End Using
End Using
Dim rel As New DataRelation("CategoryProducts",
ds.Tables("Categories").Columns("CategoryID"),
ds.Tables("Products").Columns("CategoryID"), True)
rel.Nested = True ' дочерние строки внутри родительских
ds.Relations.Add(rel)
Return ds
End FunctionАтрибуты вместо элементов и выгрузка в XmlDataSource
Форму вывода каждой колонки определяет свойство DataColumn.ColumnMapping. По умолчанию там Element, отсюда и лишние вложенные теги. Достаточно пройтись по всем колонкам всех таблиц и выставить MappingType.Attribute, после чего WriteXml выдаёт ровно тот XML, что нужен дереву.
Режим XmlWriteMode.IgnoreSchema убирает из вывода встроенную XSD-схему, она дереву не нужна. XPath «/*/*» отрезает корневой NewDataSet: первым уровнем дерева станут категории, а не один общий узел. Уникальный ID для XmlDataSource нужен, когда источник создаётся в коде: без него кеш контрола может вернуть данные от прошлого запроса.
Private Function ToHierarchical(ds As DataSet) As XmlDataSource
' все колонки пишем атрибутами
For Each t As DataTable In ds.Tables
For Each c As DataColumn In t.Columns
c.ColumnMapping = MappingType.Attribute
Next
Next
Dim xml As String
Using sw As New StringWriter()
ds.WriteXml(sw, XmlWriteMode.IgnoreSchema)
xml = sw.ToString()
End Using
Return New XmlDataSource() With {
.ID = "xds_" & Guid.NewGuid().ToString("N"),
.EnableCaching = False, ' данные свежие на каждый запрос
.Data = xml,
.XPath = "/*/*" ' пропускаем корень NewDataSet
}
End FunctionПривязка уровней дерева
Осталось объяснить TreeView, какой атрибут показывать на каждом уровне. Для этого служит коллекция DataBindings: на каждый XML-элемент заводится свой TreeNodeBinding с DataMember, TextField и ValueField. Привязки можно описать и в разметке, но в коде их легче держать рядом с запросом.
Значение узла (ValueField) пригодится в обработчике SelectedNodeChanged: по нему вы узнаете, какая категория или какой товар выбран, не разбирая текст.
Protected Sub Page_Load(sender As Object, e As EventArgs) Handles Me.Load
If IsPostBack Then Return
trvProducts.DataBindings.Add(New TreeNodeBinding() With {
.DataMember = "Categories",
.TextField = "CategoryName",
.ValueField = "CategoryID"})
trvProducts.DataBindings.Add(New TreeNodeBinding() With {
.DataMember = "Products",
.TextField = "ProductName",
.ValueField = "ProductID"})
trvProducts.DataSource = ToHierarchical(GetRelationalData())
trvProducts.DataBind()
trvProducts.CollapseAll() ' стартуем со свёрнутыми категориями
End SubТипичные ошибки
Почти все проблемы с этим подходом укладываются в короткий список. Если дерево показывает не то, сначала выведите строку xml в отладчике и сравните с ожидаемой формой.
- в узлах видны имена тегов вместо данных: не выставлен ColumnMapping или не добавлены TreeNodeBinding;
- товары висят на одном уровне с категориями: забыли rel.Nested = True;
- первый узел называется NewDataSet: не задан XPath или задан как «/*»;
- после правки в базе дерево показывает старые данные: включено кеширование XmlDataSource с постоянным ID;
- на больших справочниках страница тяжелеет: TreeView рендерит все узлы сразу, подумайте о PopulateOnDemand.
Как это делают сегодня
Вопросы и ответы
Можно ли привязать TreeView к одной таблице с колонкой ParentID?
Напрямую нет. Можно создать DataRelation внутри одной таблицы с Nested = True, тогда WriteXml выдаст рекурсивную вложенность. Привязка в этом случае одна, с DataMember по имени таблицы.
Чем плох вариант с XSLT?
Он работает, но добавляет отдельный файл трансформации, который ломается при каждом изменении набора колонок. Режим ColumnMapping решает ту же задачу одной строкой в коде.
Как получить выбранный товар на сервере?
Обработайте событие SelectedNodeChanged и прочитайте trvProducts.SelectedNode.Value. Глубина узла (Depth) подскажет, категория это или товар.