
Как сделать Distinct по DataTable с помощью DataView.ToTable
Нужно получить уникальные значения из DataTable, а в базу за ними идти уже поздно? С .NET 2.0 для этого есть встроенный метод, и самописная сортировка с удалением дублей больше не нужна.
Откуда берётся задача
Обычный сценарий: страница уже получила из базы DataTable с заказами, и теперь из тех же данных нужно заполнить выпадающий список городов или стран. Второй запрос SELECT DISTINCT к серверу выглядит расточительно, особенно если таблица пришла из кеша или из веб-сервиса, а не из SQL.
Во времена .NET 1.0 и 1.1 встроенного решения не было. На форумах советовали писать свою функцию: отсортировать строки по нужным колонкам, пройти циклом, сравнивать каждую строку с предыдущей и удалять совпадения. Такой код работает, но он длинный, его легко сломать при смене набора колонок, а сравнение с DBNull в нём обычно забывают.
Если вы сопровождаете такой код, не спешите переписывать всё сразу: сначала найдите все места, где вызывается самописная функция, и проверьте, по скольким колонкам она на самом деле должна считать уникальность.
Старый способ: сортировка и сравнение
Для понимания полезно увидеть, от чего мы избавляемся. Вот компактная версия ручного алгоритма для одной колонки. Её ещё можно встретить в старых проектах, и при сопровождении такую функцию стоит заменить.
' Ручной Distinct по одной колонке (.NET 1.x)
Public Function DistinctOld(src As DataTable, col As String) As DataTable
Dim result As DataTable = src.Clone()
Dim rows() As DataRow = src.Select("", col) ' сортировка
Dim prev As Object = Nothing
For Each r As DataRow In rows
' сравниваем с предыдущим значением
If prev Is Nothing OrElse Not prev.Equals(r(col)) Then
result.ImportRow(r)
prev = r(col)
End If
Next
Return result
End FunctionВстроенное решение: ToTable(True, ...)
Начиная с .NET Framework 2.0 у класса DataView есть перегрузка ToTable(distinct As Boolean, ParamArray columnNames() As String). Первый параметр включает удаление дублей, остальные перечисляют колонки, которые попадут в новую таблицу. Уникальность считается по комбинации именно этих колонок.
DataView можно не создавать вручную: у каждой таблицы есть готовое представление DefaultView. Результат всегда новая независимая таблица, исходные данные не меняются.
' уникальные города из уже загруженных заказов
Dim cities As DataTable =
ordersTable.DefaultView.ToTable(True, "ShipCity")
ddlCity.DataSource = cities
ddlCity.DataTextField = "ShipCity"
ddlCity.DataValueField = "ShipCity"
ddlCity.DataBind()
' уникальные пары страна + город
Dim pairs As DataTable =
ordersTable.DefaultView.ToTable(True, "ShipCountry", "ShipCity")Фильтр и сортировка до Distinct
ToTable учитывает текущее состояние DataView: RowFilter, Sort и RowStateFilter. Поэтому порядок действий простой. Сначала создаёте представление, задаёте фильтр и сортировку, затем вызываете ToTable. Результат будет и отфильтрован, и упорядочен.
Лучше заводить отдельный New DataView, а не менять DefaultView. Представление по умолчанию может использоваться другими контролами на странице, и неожиданный фильтр в нём превращается в трудноуловимую ошибку.
Dim view As New DataView(ordersTable)
view.RowFilter = "ShipCountry = 'Germany'" ' только Германия
view.Sort = "ShipCity ASC" ' по алфавиту
Dim germanCities As DataTable = view.ToTable(True, "ShipCity")
' пример с переменной: экранируем апострофы
Dim country As String = txtCountry.Text.Replace("'", "''")
view.RowFilter = String.Format("ShipCountry = '{0}'", country)Подводные камни
Метод простой, но у него есть особенности, о которых полезно знать заранее:
- строки сравниваются с учётом свойства CaseSensitive таблицы: по умолчанию «Berlin» и «berlin» считаются одинаковыми, при CaseSensitive = True это разные значения;
- DBNull считается обычным значением, и одна строка с пустым городом в результате останется;
- пробелы в конце строки делают значения разными, так что «Berlin » и «Berlin» дадут две записи;
- в результат не попадают колонки, не перечисленные в параметрах, поэтому взять «первый попавшийся» OrderID вместе с уникальным городом этим методом нельзя;
- для таблиц на сотни тысяч строк ToTable заметно нагружает память: в таком случае лучше выполнить DISTINCT в SQL.
Альтернатива через LINQ
В проектах на .NET 3.5 и новее тот же результат получают через LINQ to DataSet. Это удобно, когда нужен не DataTable, а просто список значений, например для привязки к контролу или для дальнейшей обработки в коде.
Для сравнения целых строк есть DataRowComparer.Default, он сравнивает все поля. Если же нужна таблица на выходе, CopyToDataTable превращает последовательность строк обратно в DataTable.
' Imports System.Data.DataSetExtensions
Dim cityList As List(Of String) =
ordersTable.AsEnumerable().
Select(Function(r) r.Field(Of String)("ShipCity")).
Where(Function(c) Not String.IsNullOrWhiteSpace(c)).
Select(Function(c) c.Trim()).
Distinct(StringComparer.OrdinalIgnoreCase).
OrderBy(Function(c) c).
ToList()Как это делают сегодня
Вопросы и ответы
Сохраняется ли порядок строк после ToTable(True)?
Да, строки идут в порядке DataView, то есть с учётом Sort. Если сортировка не задана, порядок соответствует исходной таблице.
Можно ли сделать Distinct по одной колонке, но вернуть все поля?
ToTable так не умеет. Используйте LINQ: группировку GroupBy по ключу с выбором первой строки группы или DistinctBy в .NET 6 и новее.
Меняет ли ToTable исходную таблицу?
Нет. Метод всегда создаёт новую независимую DataTable, исходные строки и их состояние остаются прежними.