ORMs estão na moda nos dias de hoje por uma boa razão: eles podem fazer o desenvolvimento de aplicações baseadas em banco de dados rápido e sem dor. Mas frameworks de ORM são bem restritos, eles esperam que os desenvolvedores sigam certas regras e são as vezes bem difíceis de se usar quando isso não é feito. Uma regra comum é que uma stored procedure pode retornar apenas 1 conjunto de resultados e este tem que ter uma lista consistente de colunas. Infelizmente existem várias stored procedures onde seus retornos variam de acordo com suas lógicas internas. Por exemplo, uma stored procedure pode ter um parâmetro que define quais colunas são retornadas e outro indicando se devem ser retornadas todas as linhas ou somente parte delas. Ou talvez este resultado varia de acordo com algum parâmetro interno e a aplicação precisa examinar a saída para determinar a estrutura a ser usada em tempo de execução.
Quando um desenvolvedor .NET encontra um conjunto de stored procedures que simplesmente não foi projetado para trabalhar com ORMs, normalmente ele utiliza DataTables. Com o novo suporte a tipagem dinâmica encontrado no .NET 4.0, outra solução se apresenta. E se tudo, incluindo o nome da stored procedure, parâmetros SQL e os objetos resultantes fossem avaliados em tempo de execução?
Aqui vai um exemplo de código em VB e C#. Você vai notar que VB requerer Option Strict para funcionar enquanto c# utiliza diretamente sua nova palavra reservada "dynamic".
VB
Using con As New SqlClient.SqlConnection(connectionString)
Dim customer = con.CallSingleProc.CustomerSelect(AccountKey:=12345)
Console.WriteLine(customer.FirstName & " " & customer.LastName)
Dim orders As IList = con.CallListProc.OrderSearch(AccountKey:=12345, MinCreatedDate:=Now.AddDays(-7), MaxCreatedDate:=Now)
Dim totalValue = Aggregate order In orders Into Sum(CDec(order.TotalOrderValue))
Console.WriteLine("This customer ordered a total of $" & totalValue & " last week")
For Each order In orders
Console.WriteLine(vbTab & "Order Key: " & order.OrderKey & " Value: $" & order.TotalOrderValue)
Next
End Using
C#
using (var con = new SqlConnection(connectionString))
{
var customer = con.CallSingleProc().CustomerSelect(AccountKey: 12345);
Console.WriteLine(customer.FirstName + " " + customer.LastName);
IList<dynamic> orders = con.CallListProc().OrderSearch(AccountKey: 12345, MinCreatedDate: DateTime.Now.AddDays(-7), MaxCreatedDate: DateTime.Now);
var totalValue = orders.Sum(order => (decimal)order.TotalOrderValue);
Console.WriteLine("This customer ordered a total of $" + totalValue + " last week");
foreach (var order in orders)
{
Console.WriteLine("\tOrder Key: " + order.OrderKey + " Value: $" + order.TotalOrderValue);
}
}
Isto parece código .NET normal, mas muitos desses métodos e propriedades simplesmente não existem. Aqui vai o mesmo código com membros inexistentes grifados.
VB
Using con As New SqlClient.SqlConnection(connectionString)
Dim customer = con.CallSingleProc.CustomerSelect(AccountKey:=12345)
Console.WriteLine(customer.FirstName & " " & customer.LastName)
Dim orders As IList = con.CallListProc.OrderSearch(AccountKey:=12345, MinCreatedDate:=Now.AddDays(-7), MaxCreatedDate:=Now)
Dim totalValue = Aggregate order In orders Into Sum(CDec(order.TotalOrderValue))
Console.WriteLine("This customer ordered a total of $" & totalValue & " last week")
For Each order In orders
Console.WriteLine(vbTab & "Order Key: " & order.OrderKey & " Value: $" & order.TotalOrderValue)
Next
End Using
C#
using (var con = new SqlConnection(connectionString))
{
var customer = con.CallSingleProc().CustomerSelect(AccountKey: 12345);
Console.WriteLine(customer.FirstName + " " + customer.LastName);
IList<dynamic> orders = con.CallListProc().OrderSearch(AccountKey: 12345, MinCreatedDate: DateTime.Now.AddDays(-7), MaxCreatedDate: DateTime.Now);
var totalValue = orders.Sum(order => (decimal)order.TotalOrderValue);
Console.WriteLine("This customer ordered a total of $" + totalValue + " last week");
foreach (var order in orders)
{
Console.WriteLine("\tOrder Key: " + order.OrderKey + " Value: $" + order.TotalOrderValue);
}
}
Neste ponto alguns tradicionalistas vão começar a falar sobre os riscos que conexões tardias trazem, como por exemplo erros de sintaxe que só vão aparecer em tempo de execução. Enquanto isso é certamente uma possibilidade, nós não estamos muito pior que a situação anterior. Quando deixamos nomes de stored procedures e colunas em texto, nós vamos enfrentar o mesmo problema de erros aparecerem somente em tempo de execução.
Para isto funcionar, nós precisamos de algumas coisas, como por exemplo uma forma de mudar de um contexto tipado para um contexto dinâmico. Para isso nós tempos que escolher um conjunto de métodos de extensão que retornam "System.Object". Em VB isto é suficiente para começar a conexão tardia, mas não em C#. Para o C# mudar de modo, você também tem que decorar o valor de retorno com um atributo dinâmico.
Public Module MicroOrm
''' <summary>
''' Chama a stored procedure e retorna um valor escalar.
''' </summary>
''' <returns>Nulo ou um único valor</returns>
''' <remarks>Somente a primeira coluna da primeira linha do primeiro resultset é retornada. Todo o resto é ignorado. Nulos da base de dados são convertidos para nulos CLR.</remarks>
<Extension()>
Public Function CallScalarProc(ByVal connection As SqlConnection) As <Dynamic()> Object
Return New MicroProcCaller(connection, Scalar)
End Function
''' <summary>
''' Chama uma stored procedure que retorna um único objeto.
''' </summary>
''' <returns>Nulo ou um MicroDataObject</returns>
''' <remarks>Somente a primeira linha de um resultset é retornada. Todo o resto é ignorado. Nulos da base de dados são convertidos para nulos CLR.</remarks>
<Extension()>
Public Function CallSingleProc(ByVal connection As SqlConnection) As <Dynamic()> Object
Return New MicroProcCaller(connection, [Single])
End Function
''' <summary>
''' Chama uma stored procedure que retorna uma lista de objetos.
''' </summary>
''' <returns>Uma lista de MicroDataObject. Há um MicroDataObject por linha.</returns>
''' <remarks>Somente o primeiro resultset é retornado. Nulos da base de dados são convertidos para nulos CLR.</remarks>
<Extension()>
Public Function CallListProc(ByVal connection As SqlConnection) As <Dynamic()> Object
Return New MicroProcCaller(connection, List)
End Function
''' <summary>
''' Chama uma stored procedure que retorna uma lista com listas de objetos.
''' </summary>
''' <returns>Uma lista contendo listas de MicroDataObject. Há uma lista por resultset e um MicroDataObject por linha de cada resultset.</returns>
''' <remarks>Nulos da base de dados são convertidos para nulos CLR.</remarks>
<Extension()>
Public Function CallMultipleListProc(ByVal connection As SqlConnection) As <Dynamic()> Object
Return New MicroProcCaller(connection, MultipleLists)
End Function
End Module
Para mostrar a diferença, aqui vai uma função usando C#.
public static class MicroOrm
{
public static dynamic CallSingleProc(this SqlConnection connection)
{
return new MicroProcCaller(connection, CallingOptions.Single);
}
}
Para completar a história, aqui vai o construtor da classe MicroProcCaller. Note que a classe é marcada como Friend (C# internal). Isto é feito porque ninguém deve nunca declarar uma variável deste tipo; ela somente funciona em um contexto dinâmico. Esta classe é também transitória; consumidores não devem segurar uma referência para ela.
Friend Class MicroProcCaller
Inherits Dynamic.DynamicObject
Private m_Connection As SqlConnection
Private m_Options As CallingOptions
Public Sub New(ByVal connection As SqlConnection, ByVal options As CallingOptions)
m_Connection = connection
m_Options = options
End Sub
End Class
Public Enum CallingOptions
Scalar = 0
[Single] = 1
List = 2
MultipleLists = 3
End Enum
Agora que estamos em um contexto dinâmico, nós precisamos de uma forma de traduzir a chamada deste método de conexão tardia em uma stored procedure. Existem várias formas de se fazer isso, mas a mais fácil é extender a classe de DynamicObject e sobrescrever o método TryInvokeMember. Seguem numeradas abaixo as etapas a serem realizadas:
- Decidir se esta função é responsável por gerenciar o ciclo de vida do objeto de conexão.
- Criar um SqlCommand usando um método com o mesmo nome da stored procedure. O nome do método que foi chamado pode ser achado no "conector".
- Já que chamadas para stored procedures utilizando o Data.SqlClient não suporta parâmetros sem nome, ter certeza que todos os parâmetros foram nomeados.
- Iterar sobre a lista de argumentos, criando objetos SqlParameter.
- Criar os resultados e armazená-los em um parâmetro de resultado. (Detalhes sobre isso serão mostrados depois).
- Retornar verdadeiro, indicando que o método foi processado com sucesso.
Public Overrides Function TryInvokeMember(
ByVal binder As System.Dynamic.InvokeMemberBinder,
ByVal args() As Object,
ByRef result As Object) As Boolean
Dim manageConnectionLifespan = (m_Connection.State = ConnectionState.Closed)
If manageConnectionLifespan Then m_Connection.Open()
Try
Using cmd As New SqlClient.SqlCommand(binder.Name, m_Connection)
cmd.CommandType = CommandType.StoredProcedure
If binder.CallInfo.ArgumentNames.Count <> binder.CallInfo.ArgumentCount Then
Throw New ArgumentException("All parameters must be named")
End If
For i = 0 To binder.CallInfo.ArgumentCount - 1
Dim param As New SqlClient.SqlParameter
param.ParameterName = "@" & binder.CallInfo.ArgumentNames(i)
param.Value = If(args(i) Is Nothing, DBNull.Value, args(i))
cmd.Parameters.Add(param)
Next
Select Case m_Options
Case CallingOptions.Scalar
result = ExecuteScalar(cmd)
Case CallingOptions.Single
result = ExecuteSingle(cmd)
Case CallingOptions.List
result = ExecuteList(cmd)
Case CallingOptions.MultipleLists
result = ExecuteMultpleLists(cmd)
Case Else
Throw New ArgumentOutOfRangeException("options")
End Select
End Using
Finally
If manageConnectionLifespan Then m_Connection.Close()
End Try
Return True
End Function
ExecuteScalar é bem simples, o único motivo dele ter seu próprio método é por consistência.
Private Function ExecuteScalar(ByVal command As SqlCommand) As Object
Dim temp = command.ExecuteScalar
If temp Is DBNull.Value Then Return Nothing Else Return temp
End Function
Para o resto dos variantes, consumidores estão esperando propriedades reais ou pelo menos algo que pareça uma propriedade. Uma opção seria gerar o código das classes baseando-se no conteúdo gerado em tempo de execução pelos conjuntos de resultados. Mas gerar código em tempo de execução é caro e não vamos ganhar muito já que nossos consumidores não podem acessar as classes pelo nome. Então ao invés disso, vamos manter o tema de código dinâmico e usar um objeto dinâmico prototípico.
Friend Class MicroDataObject
Inherits Dynamic.DynamicObject
Private m_Values As New Dictionary(Of String, Object)(StringComparer.OrdinalIgnoreCase)
Public Overrides Function TryGetMember(ByVal binder As System.Dynamic.GetMemberBinder, ByRef result As Object) As Boolean
If m_Values.ContainsKey(binder.Name) Then result = m_Values(binder.Name) Else Throw New System.MissingMemberException("The property " & binder.Name & " does not exist")
Return True
End Function
Public Overrides Function TrySetMember(ByVal binder As System.Dynamic.SetMemberBinder, ByVal value As Object) As Boolean
SetMember(binder.Name, value)
Return True
End Function
Public Overrides Function GetDynamicMemberNames() As System.Collections.Generic.IEnumerable(Of String)
Return m_Values.Keys
End Function
Friend Sub SetMember(ByVal propertyName As String, ByVal value As Object)
If value Is DBNull.Value Then m_Values(propertyName) = Nothing Else m_Values(propertyName) = value
End Sub
End Class
Novamente, já que nenhuma classe deve ter dependência com este objeto nós o marcamos como Friend (C# internal). Assim precisamos sobrescrever 3 métodos: um para setar as propriedades, um para retorna-lase uma para listar seus nomes. Também há uma porta dos fundos para inicializar a classe usando código estático.
Private Function ExecuteSingle(ByVal command As SqlCommand) As Object
Using reader = command.ExecuteReader
If reader.Read Then
Dim dataObject As New MicroDataObject
For i = 0 To reader.FieldCount - 1
dataObject.SetMember(reader.GetName(i), reader.GetValue(i))
Next
Return dataObject
Else
Return Nothing
End If
End Using
End Function
Private Function ExecuteList(ByVal command As SqlCommand) As List(Of MicroDataObject)
Dim resultList = New List(Of MicroDataObject)
Using reader = command.ExecuteReader
Do While reader.Read
Dim dataObject As New MicroDataObject
For i = 0 To reader.FieldCount - 1
dataObject.SetMember(reader.GetName(i), reader.GetValue(i))
Next
resultList.Add(dataObject)
Loop
End Using
Return resultList
End Function
Private Function ExecuteMultpleLists(ByVal command As SqlCommand) As List(Of List(Of MicroDataObject))
Dim resultSet As New List(Of List(Of MicroDataObject))
Using reader = command.ExecuteReader
Do
Dim resultList = New List(Of MicroDataObject)
Do While reader.Read
Dim dataObject As New MicroDataObject
For i = 0 To reader.FieldCount - 1
dataObject.SetMember(reader.GetName(i), reader.GetValue(i))
Next
resultList.Add(dataObject)
Loop
resultSet.Add(resultList)
Loop While reader.NextResult
End Using
Return resultSet
End Function