Привязка ASP.NET TreeView к реляционным данным из DataSet

РазделДанные и привязкаУровеньСредний Чтение8 минОпубликовано ТегиASP.NETTreeViewDataSetXML

TreeView не принимает DataTable напрямую: ему нужен иерархический источник. Разбираем, как без XSLT превратить две связанные таблицы в XML, который дерево понимает с первого раза.

Главное. TreeView нужен иерархический источник. DataRelation с Nested и колонки в режиме Attribute позволяют получить правильный XML прямо из DataSet, без XSLT и ручной сборки строк.

Почему 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 для каждого уровня.
Связь удобнее строить между двумя таблицами, а не внутри одной самоссылающейся. Для дерева произвольной глубины DataRelation тоже сработает, но XPath и привязки придётся продумывать отдельно.

Какой XML ждёт дерево

XmlDataSource превращает каждый элемент в узел, а значения для текста и value берёт из атрибутов. Значит, нужна такая структура: элемент Categories с атрибутами CategoryID и CategoryName, а внутри него элементы Products со своими атрибутами.

Если вызвать DataSet.GetXml() «как есть», получится другое. Каждая колонка станет отдельным дочерним элементом, а товары окажутся рядом с категориями, а не внутри них. Формально XML корректный, но TreeView покажет дерево из узлов «CategoryID», «CategoryName» и прочих служебных имён.

Первое, что приходит в голову: прогнать результат через XSLT. Это работает, но заводит в проекте лишний файл, который нужно помнить при каждом изменении запроса. Есть способ проще: DataSet умеет сам писать атрибуты, если его попросить.

Нужная форма XMLXML
<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 упадёт сразу, и это лучше, чем молча потерянный узел.

Default.aspx.vbVB.NET
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 нужен, когда источник создаётся в коде: без него кеш контрола может вернуть данные от прошлого запроса.

Default.aspx.vbVB.NET
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: по нему вы узнаете, какая категория или какой товар выбран, не разбирая текст.

Default.aspx.vbVB.NET
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.

Как это делают сегодня

В ASP.NET Core нет серверного TreeView: дерево строят на клиенте или в Blazor. Данные обычно отдают сразу вложенным JSON из EF Core (Include по навигационному свойству, затем Select в DTO с коллекцией детей), а рисует их компонент дерева из MudBlazor, Radzen или Fluent UI. Для больших справочников узлы подгружают лениво по клику через отдельный API-запрос.

Вопросы и ответы

Можно ли привязать TreeView к одной таблице с колонкой ParentID?

Напрямую нет. Можно создать DataRelation внутри одной таблицы с Nested = True, тогда WriteXml выдаст рекурсивную вложенность. Привязка в этом случае одна, с DataMember по имени таблицы.

Чем плох вариант с XSLT?

Он работает, но добавляет отдельный файл трансформации, который ломается при каждом изменении набора колонок. Режим ColumnMapping решает ту же задачу одной строкой в коде.

Как получить выбранный товар на сервере?

Обработайте событие SelectedNodeChanged и прочитайте trvProducts.SelectedNode.Value. Глубина узла (Depth) подскажет, категория это или товар.

Читайте также