型無し DataRow を、データクラスに変換して使いたい

DataRow 使いやすくなるといいね。

目次

問題提起

型無し DataRow のまま、型付 DataRow みたいに扱いたい!以下なんか考えたくない!という問題に対する妥当解はあるのかを追い求めています。

  • ダブルコーテーションがキー入力しづらい、指がつってしまう
  • フィールド名なんて覚えていない、長いやつある、インテリセンスはどこ?
  • わざわざ Object 型から任意の該当型に型変換したくない

サンプルデータ

こういう感じの DB にあるテーブルから、データを取得してきたとします。偶数だったらちゃんとしたデータ、奇数だったら DBNull が入ったデータです。

Dim dt As New DataTable("Member")
dt.Columns.Add("ID", GetType(Integer))
dt.Columns.Add("Name", GetType(String))
dt.Columns.Add("Age", GetType(Integer))
dt.PrimaryKey = New DataColumn() {dt.Columns("ID")}

' データ登録
For i As Integer = 0 To 100

    If i Mod 2 = 0 Then
        dt.Rows.Add(New Object() {i, $"taro{i}", i + 200})
    Else
        dt.Rows.Add(New Object() {i, DBNull.Value, DBNull.Value})
    End If

Next

案1、匿名型のデータクラスに変換して使う

その場で匿名型を作って使う案です。でもこの案はダメです。匿名型を作る ところで、いつもの DataRow だからです。

取得と同時に型変換するための Field 拡張メソッド、その中で DBNull に対応するための Null 許容型までは百歩譲ったとして、結局フィールド名を文字列指定してるやないかい!という点がアウトでした。

' データ取得
For Each row As DataRow In dt.Rows

    Dim user = New With {
        .ID = row.Field(Of Integer)("ID"),
        .Name = row.Field(Of String)("Name"),
        .Age = row.Field(Of Integer?)("Age")
    }

    ' DBNull の場合、String -> Nothing(=""), Integer?(Nullable(Of Integer)のこと) -> 0
    Dim result As String =
        $"ID = {user.ID}, Name = {user.Name}, Age = {user.Age.GetValueOrDefault()}"

    Console.WriteLine(result)

Next

出力結果

ID = 0, Name = taro0, Age = 200
ID = 1, Name = , Age = 0
ID = 2, Name = taro2, Age = 202
ID = 3, Name = , Age = 0
ID = 4, Name = taro4, Age = 204
・・・
・・・
・・・

案2、あらかじめ型を定義しておいて、その型にデータをセットして使う

やっぱり無からインテリセンス対応するのは無理ゲーだったという結果です。で、アンチ型付DataSet派の自作ORマッピング - レベルエンター山本大のブログ という記事を見つけてこれやな!と思った次第です。自作拡張メソッド&クラスの定義するパターンです。コード見たほうが早いかもね。

拡張メソッドの準備

Imports System.Runtime.CompilerServices
Imports System.Reflection


Module DataRowExtensions

    <Extension()>
    Public Function GetRowData(Of TClass As New)(ByVal self As DataRow) As TClass

        Dim resultData As New TClass
        Dim t As Type = resultData.GetType
        Dim pis() As PropertyInfo = t.GetProperties

        For Each pi As PropertyInfo In pis

            If self(pi.Name) Is DBNull.Value Then
                pi.SetValue(resultData, Nothing)
            Else
                pi.SetValue(resultData, self(pi.Name))
            End If

        Next

        Return resultData

    End Function

End Module

ジェネリック指定している T 型のクラス(TClass)は、制約としてインスタンス生成できることを条件に指定しています。これを指定しないと戻り値となるデータクラスがインスタンス生成できません。

処理内容としては以下の通りです。

  1. データクラスをインスタンス生成
  2. データクラスのプロパティメンバー名を知りたいため(後で使う)、リフレクションして全部取得
  3. プロパティメンバー数分、ループ(1つ1つのプロパティに対して、値をセットしていく。DataRow からデータクラスへの入れ替え作業)
  4. あるプロパティの値が DBNull の場合、Nothing をセットする。クラスの場合、Nothing は規定値を返してくれるので、String → Nothing, Integer → 0, Integer? → Nothing みたいな感じで規定値がセットされる。逆に値がある場合は、そのまま値がセットされる。

DB テーブルに合わせたクラスの定義

Class MemberData
    Public Property ID As Integer = 0
    Public Property Name As String = String.Empty
    Public Property Age As Integer? = 0
End Class

そしてデータ取得

' データ取得
For Each row As DataRow In dt.Rows

    Dim user As MemberData = row.GetRowData(Of MemberData)()

    ' DBNull の場合、String -> Nothing(=""), Integer?(Nullable(Of Integer)のこと) -> 0
    Dim result As String =
        $"ID = {user.ID}, Name = {user.Name}, Age = {user.Age.GetValueOrDefault()}"

    Console.WriteLine(result)

Next

出力結果

ID = 0, Name = taro0, Age = 200
ID = 1, Name = , Age = 0
ID = 2, Name = taro2, Age = 202
ID = 3, Name = , Age = 0
ID = 4, Name = taro4, Age = 204
・・・
・・・
・・・

Integer が DBNull というか Nothing だったら 0 扱いしたくないの

という場合は、こんな感じで好きなように判定します。

' 値が無い場合
If Not user.Age.HasValue Then

End If

別案

元も子もないですが、列名を文字列指定していい場合は、DataRow の拡張メソッドを作った方が早いです。

DataRow の拡張モジュール

Imports System.Runtime.CompilerServices

Public Module DataRowExtensions

    ' DBNull と値を分けて扱いたい場合向け
    <Extension()>
    Public Function GetInteger(ByVal self As DataRow, ByVal columnName As String) As Integer?

        Dim result As Integer? = self.Field(Of Integer?)(columnName)
        Return result

    End Function

    ' DBNull を考慮したくない、しなくてもいい設計の場合向け
    <Extension()>
    Public Function GetIntegerOrDefault(ByVal self As DataRow, ByVal columnName As String) As Integer

        Dim result As Integer? = self.Field(Of Integer?)(columnName)
        Return result.GetValueOrDefault()

    End Function

    ' ...

    <Extension()>
    Public Function GetStringOrDefault(ByVal self As DataRow, ByVal columnName As String) As String

        ' DBNull の場合、Nothing になる
        Dim result As String = self.Field(Of String)(columnName)

        If result Is Nothing Then
            result = String.Empty
        End If

        Return result

    End Function

    <Extension()>
    Public Function GetBooleanOrDefault(ByVal self As DataRow, ByVal columnName As String) As Boolean

        Dim result As Boolean? = self.Field(Of Boolean?)(columnName)
        Return result.GetValueOrDefault()

    End Function

    <Extension()>
    Public Function GetDateTimeOrDefault(ByVal self As DataRow, ByVal columnName As String) As DateTime

        Dim result As DateTime? = self.Field(Of DateTime?)(columnName)
        Return result.GetValueOrDefault()

    End Function



End Module

使う側

Module Module1

    Sub Main()

        ' 列名とデータ登録
        Dim table As New DataTable
        table.Columns.Add("Id", GetType(Integer))
        table.Columns.Add("Name", GetType(String))
        table.Columns.Add("IsMale", GetType(Boolean))
        table.Columns.Add("CreatedTime", GetType(DateTime))
        table.Columns.Add("UpdatedTime", GetType(DateTime))

        Dim items = Enumerable.Range(1, 5)
        For Each item In items

            table.Rows.Add(New Object() {
                           item,
                           If(item Mod 2 = 0, $"taro{item}", $"hanako{item}"),
                           If(item Mod 2 = 0, True, False),
                           DateTime.Now,
                           DateTime.Now
                           })

        Next

        ' 取得
        For Each row As DataRow In table.Rows

            Dim id As Integer = row.GetIntegerOrDefault("Id")
            Dim name As String = row.GetStringOrDefault("Name")
            Dim isMale As Boolean = row.GetBooleanOrDefault("IsMale")
            Dim createdTime As DateTime = row.GetDateTimeOrDefault("CreatedTime")
            Dim updatedTime As DateTime = row.GetDateTimeOrDefault("UpdatedTime")

            Console.WriteLine($"Id = {id}, Name = {name,-10}, IsMale = {isMale}")

        Next

        Console.ReadKey()

    End Sub

End Module