Как загрузить DataSet из хранимой процедуры SQL Server

РазделДанные и привязкаУровеньНовичок Чтение7 минОпубликовано ТегиADO.NETSQL ServerDataSetVB.NET

Хранимая процедура плюс SqlDataAdapter это классический способ наполнить DataSet в приложениях на VB.NET. Показываем аккуратную версию кода: с Using, типизированными параметрами и несколькими таблицами.

Главное. DataSet из хранимой процедуры загружается тремя объектами: SqlConnection в Using, SqlCommand с CommandType.StoredProcedure и типизированными параметрами и SqlDataAdapter.Fill, который сам управляет соединением.

Почему данные берут из процедуры

В приложениях на Web Forms и WinForms выборки часто прячут в хранимые процедуры. Причины практичные: план выполнения кешируется, права можно выдать на процедуру, не открывая таблицы, а SQL меняется без перекомпиляции приложения. Для кода на VB.NET процедура выглядит как функция с параметрами, которая возвращает один или несколько наборов строк.

Чтобы положить результат в DataSet, нужны три объекта ADO.NET: SqlConnection для подключения, SqlCommand с именем процедуры и параметрами и SqlDataAdapter, который выполнит команду и разложит строки по таблицам. Ниже разберём каждый шаг на примере базы Northwind: список товаров выбранной категории.

Процедура для примера

Процедура принимает идентификатор категории и возвращает товары. Обратите внимание, что параметр объявлен как обычный входной. Помечать его OUTPUT здесь незачем: процедура его не меняет, а лишний OUTPUT только запутывает читателя и требует другого объявления на стороне ADO.NET.

ListProductsByCategoryID.sqlSQL
CREATE PROCEDURE dbo.ListProductsByCategoryID
    @CategoryID int
AS
BEGIN
    SET NOCOUNT ON;   -- не отправлять счётчики строк клиенту
    SELECT CategoryID, ProductID, ProductName, UnitPrice
    FROM Products
    WHERE CategoryID = @CategoryID
    ORDER BY ProductName;
END
GO
-- Проверка в Management Studio
EXEC dbo.ListProductsByCategoryID @CategoryID = 1;

Команда и параметры

У SqlCommand задаём CommandType.StoredProcedure, а в CommandText пишем только имя процедуры, без EXEC. Параметр лучше добавлять с явным типом через Parameters.Add(имя, SqlDbType). Метод AddWithValue выводит тип по значению, и строка из TextBox уйдёт как nvarchar, после чего SQL Server будет неявно преобразовывать её в int. В лучшем случае это лишняя работа, в худшем ошибка конвертации на пустой строке.

Значение из поля ввода сначала проверяем. Если пользователь ввёл не число, нет смысла обращаться к базе: достаточно показать сообщение.

Заполнение DataSet через SqlDataAdapter

Адаптеру передаём готовую команду и вызываем Fill. Открывать соединение вручную не обязательно: если оно закрыто, Fill откроет его сам и закроет после чтения. Блок Using гарантирует, что соединение вернётся в пул даже при исключении. В исходных примерах часто встречается свойство, которое открывает новое соединение при каждом обращении и никогда его не закрывает. Под нагрузкой такой код быстро исчерпывает пул подключений.

Default.aspx.vbVB.NET
Imports System.Data
Imports System.Data.SqlClient

Private Function GetProducts(categoryId As Integer) As DataSet
    Dim cs = ConfigurationManager.ConnectionStrings("Northwind").ConnectionString
    Dim ds As New DataSet("Catalog")
    Using cn As New SqlConnection(cs), _
          cmd As New SqlCommand("dbo.ListProductsByCategoryID", cn), _
          da As New SqlDataAdapter(cmd)
        cmd.CommandType = CommandType.StoredProcedure
        cmd.Parameters.Add("@CategoryID", SqlDbType.Int).Value = categoryId
        da.Fill(ds, "Products")   ' Fill сам откроет и закроет соединение
    End Using
    Return ds
End Function

Protected Sub btnList_Click(sender As Object, e As EventArgs) _
        Handles btnList.Click
    Dim id As Integer
    If Not Integer.TryParse(txtCategoryID.Text, id) Then
        lblError.Text = "Введите числовой код категории"
        Return
    End If
    GridView1.DataSource = GetProducts(id).Tables("Products")
    GridView1.DataBind()
End Sub
Конструкция Try ... Catch ex ... Throw ex, которая часто встречается в старом коде, только портит стек вызовов. Если обработать ошибку негде, не ловите её вовсе, а если нужно, пишите просто Throw.

Несколько наборов строк за один вызов

Одна процедура может вернуть несколько SELECT подряд, например категорию и её товары. Fill разложит их по таблицам Table, Table1 и так далее. Чтобы дать таблицам осмысленные имена, используйте TableMappings: сопоставление задаётся до вызова Fill.

После загрузки таблицы можно связать через DataRelation и выводить иерархию, например во вложенном Repeater или TreeView. Это экономит обращения к базе: вместо запроса на каждую категорию выполняется один вызов процедуры.

CatalogService.vbVB.NET
da.TableMappings.Add("Table", "Category")
da.TableMappings.Add("Table1", "Products")
da.Fill(ds)

ds.Relations.Add("CategoryProducts", _
    ds.Tables("Category").Columns("CategoryID"), _
    ds.Tables("Products").Columns("CategoryID"))

DataSet, DataTable или DataReader

DataSet не всегда лучший контейнер. Выбирайте по задаче:

  • DataSet: несколько связанных таблиц, отношения между ними, сериализация в XML, работа без подключения;
  • DataTable: один набор строк для GridView или отчёта, меньше накладных расходов, загрузка через da.Fill(table);
  • SqlDataReader: потоковое чтение большого объёма только вперёд, когда данные сразу превращаются в объекты;
  • типизированный DataSet из дизайнера: проверка имён колонок при компиляции, но сгенерированный код тяжело сопровождать при изменении схемы.

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

В новых проектах DataSet почти не используют: результат процедуры читают сразу в классы. В EF Core для этого есть FromSqlInterpolated, который передаёт значения параметрами и защищает от SQL-инъекций, а Dapper делает то же одной строкой через Query(Of Product) с CommandType.StoredProcedure. Пакет System.Data.SqlClient при этом заменён на Microsoft.Data.SqlClient.

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

Нужно ли открывать соединение перед Fill?

Нет. Если соединение закрыто, Fill откроет и закроет его сам. Открывайте вручную, только когда выполняете несколько команд подряд.

Почему лучше не использовать AddWithValue?

Он определяет тип по значению .NET, и строка уходит как nvarchar. Это ведёт к неявным преобразованиям и иногда к плохим планам запросов.

Как назвать таблицы, если процедура возвращает несколько наборов?

Задайте TableMappings у адаптера до вызова Fill: Table станет первой таблицей с вашим именем, Table1 второй и так далее.

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