seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

ADO.NETの型つきデータセットを自分で実装する方法

概要

 Visual StudioのExpressでない有償版か、もしくはExpressであってもmdfファイルをローカルに持つSQLServerへのデータベースアクセスでは、Visual Studioのアドインまたは内蔵されるツールによって、データベースへのアクセスをタイプセーフにアクセスできるようにする「型つきデータセット」なるソースコードを自動的に生成することができる。

 たとえば、Oracleの場合であれば、Oracle Develoer Tools for Visual Studioを導入することで、テーブルへのアクセスを型つき(タイプセーフ)で行うことができるようになる。


 ただし、Visual StudioのExpress版の場合、アドイン形式でのツール類は一切動作しないため、Oracle Developer Tools for Visual Studioを使うことはできない。*1

 たとえば、Visual Web Developer 2010 Express + Oracle11g Express で、開発環境も運用環境もタダでやりたい場合は、どうしたらいいのか、という話になる。


 もちろん、型なしでADO.NETを使うならツールは不要なのだけれど、そのかわり、かなりの手間が要求されることになる。

 どうせ手間をかけるならば、一度自前で型つきデータセットを作ったほうが、のちのちの生産性は高いと思われる。

DotNETのデータアクセス方法について

DotNETの標準のデータアクセス技術は「ADO.NET」である。

接続型と非接続型

ADO.NETでは、接続型と非接続型のデータアクセス方法が用意されている。

  1. 接続型は、DataReaderを用いて(JavaでいうところのJDBCのResultSet)、クエリ結果のカーソルをループして結果カラムを個別に取り出すような方法である。データの読み取り・書込みはプログラマが行う。
  2. 非接続型は、DataSetというデータの入れ物があって、その中にDataTableという表形式のデータを格納しており、データベースからDataTableの中にデータを取り込んだり、DataTableに追加・削除・更新した結果をデータベースに反映したり、といった使い方をする。データの読み取り・書込みは一括で行われプログラマが細かく指定する必要はなく、また、データの読み込みと書込みの間はデータベースに接続している必要はないという特徴がある。

接続型の使い方は、JavaJDBCとも類似する一般的なもので、DotNETでも使い方はまったく同じである。

接続型を使ってプログラミングする場合、ツールの恩恵を全く受けないため、Express版であってもなくても、コーディングのやり方はかわらない。


対する非接続型は、テーブルのデータをメモリに持つ形式である。

テーブルを読んでメモリに入れ、メモリの内容をテーブルに書き込むまでの間は、メモリだけの操作となるためコネクションを切断してもOKという意味で非接続型なのである。

メモリに保持する都合上、必然的にDataTable, DataRowというクラス構造になっているが、これはORMapperというほどのものではないし、JavaEEのEntityBeanのような高貴な概念に基づくものではなく、きわめて単純に実利的にデータを保持しておく入れ物でしかない。

要するに、非接続型とは、単純にクエリした結果を一括してデータテーブルという表形式のメモリに入れたり、そこに加えた変更をテーブルに一括して書き込んでいるだけである。


これを実現するための、テーブルとデータテーブルとの間をつなぐためのSQL文とバインドパラメータ、およびデータテーブル上のカラム名とを関連づけるための設定をDataAdapterというオブジェクトによって行っている。DataAdapterに対してFillメソッドを呼び出すことでデータベースからデータテーブルに読み込ませ、DataAdapterに対してUpdateメソッドを呼び出すことでデータテーブルからテーブルへの追加・更新・削除を実行する、というものである。


非接続型も、ツールの支援を受けていない素のADO.NETは「型なし」であり、カラム名は文字列で指定し、データ型はカラムにアクセスするたびにキャストするか型指定してプログラマが管理する必要がある。


カラムへのアクセスを文字列で行い、属性はプログラマが逐一指定するというコーディングをするつもりならば、非接続型のプログラミングであっても、ツールによる恩恵は不要であるため、Express版であろうとなかろうと、やることはかわらない。

Oracle Data Provider for .NET (ODP.NET)とは?

ADO.NET上記のような仕組み(インターフェイス)そのものはデータベースを問わず共通に扱うことができるようになっているが、実装はデータベースごとに異なる。


標準ではSQLServer用と、OLEDBとのブリッジ用が用意されているが、OLEDBブリッジは古来のADO用のドライバを経由するものであり、JavaでいうところのTYPE1ドライバ(JDBC-ODBCブリッジ)みたいなものであるから、個々のデータベース専用のものがあれば、そちらを使うほうが良い。


そして、ODP.NETが、ADO.NETの仕組みでOracleデータベースにアクセスすることができるようになるADO.NETOracle専用版である。

先の「Oracle Developer Tools for Visual Studio」を入れるとODP.NETもインストールされるが、運用環境、あるいはVisual Studio Expressの開発環境ではODP.NET単体をインストールすれば良い。


Oracle Developer Tools for Visual Studio」は、非接続型のデータアクセス時に型つきでプログラミングできるように支援するツールをVisual Studioに組み込むものである。(しかし、前述のとおり、Express版では使えない...。)

型なしデータセットと型つきデータセットの違い

型つきデータセットのメリット

非接続型のデータアクセス方法は、「型つき」と「型なし」に分けられる。

  1. 型なしデータセットが、まず基本形である。ADO.NETのリファレンスに記載されているDataSet, DataTable, DataRowなどのクラスは、それ自体が「型なし」である。データテーブルにはカラム名を文字列で指定してアクセスし、格納されているデータの型もプログラマがデータベースの型を把握してキャストするなり、カラムごとに個々に明示的に型を指定する必要がある。当然、カラム名やデータ型を間違えても実行時まで判明しない。
  2. 型ありは、DataSet, DataTable, DataRowを拡張(継承)して、データベースのテーブルのカラム名にあわせたプロパティを定義し、それらのプロパティの型もデータベース上の属性と一致するように定義したものである。そのため、インテリセンスによるソースコードの入力補完が有効となるし、型の不一致やプロパティ名の不一致の、つまらないけど検証するのがめんどくさいミスは、事前にコンパイルエラーになるため間違いが起きにくくなる利点がある。

当然ながら「型ありデータセット」を使うほうが、開発が楽になるのは間違いない。

型つきデータセットの実現方法

では、型ありデータセットが、どんな魔法でDataTableなりDataRowを拡張しているかというと、なんのことはない。


これはコードジェネレータなのである。


Visual Studio内蔵、もしくはOracle Developer ToolsなどのアドインをVisual Studioに統合することで、データベース上のテーブルの構造を認識し、それに合わせたプロパティを設定するようにDataTable, DataRowを継承したソースコードを自動的に生成してくれるのである。

また、これらのデータセットとデータベースとをつなげるためにはDataAdapterによるSQL文やバインド変数、カラム名の指定などの準備が必要となるが、これらも「TableAdapter」という名前でソースコードを自動生成してくれるので、これらの手作業による実装も不要になるのである。


もちろん、コードジェネレーターであるので、テーブル構造が変更されたら再生成しなければならない。

しかし、もし、生成されたコードに不足があって手作業でソースコードを追記していたら再生成はどうなるのか?。

もちろん、それもうまくできていて、DotNETにはPartial Classという仕組みがあって1つのクラスを複数のソースファイルに分割して記述できるジェネレータに大変都合のよい仕組みがあり、手作業用のソースコードと自動生成用のソースコードに分けておけば、これらの問題は、大方、解決できる。(aspxとコードビハインドでおなじみのように。)

型つきデータセットを手作業で実装する方法 (C#4.0)

ここから、本題の「型つきデータセット」を自前で作成する方法である。


ADO.NETの非接続型に登場するのは以下のクラスである。

  • DataSet
  • DataTable --> 型つきデータテーブルに拡張する
  • DataRow --> 型つきデータロウに拡張する
  • DataAdapter, TableAdaper --> 型つきデータテーブルとSQLを連携させるコードを作成する

このうち、DataSetはDataTableのコレクションにすぎないので型の有無は関係ない。


DataTableは、型つきにして、実際のテーブル名でアクセスできるようにする。たとえば「PersonDataTable」のようなクラス名になろう。


DataRowは、DataTableに格納される行データの定義で、中身はカラムのコレクションである。テーブルのカラム定義にあわせた型つきのクラスになる。PersonDataRowみたいなクラス名になろう。


DataAdapterは、Fill(テーブルからデータテーブルへの取り込み)とUpdate(データテーブルからテーブルへの反映)を実施するには必須であり、定型的なSELECT, INSERT, UPDATE, DELETEのSQL文と、カラム名とバインド変数の対応、そのカラム名とデータテーブル上のカラム名との関連付けなどを行う必要がある。


このDataTableとDataAdapterを関連づけるための定型的な設定コードを準備するのがTableAdapterクラスである。

ここでは「型つき」のメリットを享受するのが目的なので、ここは部分的にのみ対応する簡易のテーブルアダプタクラスを作成する。
(Visual Studioが生成するリッチなTableAdapterは手作業では大変すぎるので。)

対象テーブルの定義

以下の非常に単純な、数カラムしかないテーブルを、型つきにしてみる。

CREATE TABLE TESTTBL (
  IDX NUMBER(8) PRIMARY KEY
, VAL1 NVARCHAR2(40)
, VAL2 NUMBER(10)
, UPD TIMESTAMP NOT NULL
);
DataTableの拡張方法

DataTableは、以下のように拡張する。

ここでの重要な作業は、テーブルにいくつかのカラムがあるので、これをDataColumnオブジェクトとしてメンバに定義することにある。

  1. テーブルのカラム名
  2. データ型

また、プライマリキーを設定することでデータテーブルからキーで検索することが可能になるため、プライマリキーの設定も行う。

(プライマリキーを設定すると自動的に一意制約になるので、制約チェックをOnであれば、キーに該当する値が同一のデータテーブル上に複数存在することは許されなくなる。)

    /// <summary>
    /// TESTTBLテーブル用の型つきDataTable拡張
    /// </summary>
    [Serializable]
    public partial class TESTTBLTable : DataTable
    {
        /// <summary>
        /// IDXカラム
        /// </summary>
        public DataColumn IDX { get; protected internal set; }

        /// <summary>
        /// VAL1カラム
        /// </summary>
        public DataColumn VAL1 { get; protected internal set; }

        /// <summary>
        /// VAL2カラム
        /// </summary>
        public DataColumn VAL2 { get; protected internal set; }

        /// <summary>
        /// UPDカラム
        /// </summary>
        public DataColumn UPD { get; protected internal set; }


        /// <summary>
        /// デフォルトコンストラクタ
        /// </summary>
        public TESTTBLTable()
        {
            this.TableName = "TESTTBL";

            this.BeginInit();
            this.InitCls();
            this.EndInit();
        }

        // 逆シリアライズ時に必要
        protected TESTTBLTable(SerializationInfo info, StreamingContext context)
            : base(info, context)
        {
            this.InitVars(); // カラムメンバの復元
        }

        // クローン
        public override DataTable Clone()
        {
            TESTTBLTable cln = ((TESTTBLTable)(base.Clone()));
            cln.InitVars(); // カラムメンバの復元
            return cln;
        }

        // カラムの定義
        internal void InitCls()
        {
            this.CaseSensitive = true; // Oracleは大文字・小文字、ひらがな・カタカナを区別する為。

            // カラム定義をメンバに設定
            this.IDX = new DataColumn("IDX", typeof(int));
            this.VAL1 = new DataColumn("VAL1", typeof(string));
            this.VAL2 = new DataColumn("VAL2", typeof(long));
            this.UPD = new DataColumn("UPD", typeof(DateTime));

            // カラムの設定
            Columns.Add(this.IDX);
            Columns.Add(this.VAL1);
            Columns.Add(this.VAL2);
            Columns.Add(this.UPD);

            // プライマリキーの設定
            var pk1 = this.IDX;
            PrimaryKey = new DataColumn[] { pk1 };
        }

        // カラムの復元
        internal void InitVars()
        {
            this.CaseSensitive = true; // Oracleは大文字・小文字、ひらがな・カタカナを区別する為。

            // 逆シリアライズされたカラムプロパティから、このクラスのカラムフィールドを復元
            this.IDX = base.Columns["IDX"];
            this.VAL1 = base.Columns["VAL1"];
            this.VAL2 = base.Columns["VAL2"];
            this.UPD = base.Columns["UPD"];
        }

        // 型つきDataRowとして拡張したクラスを使うようにオーバーライドする.
        protected override DataRow NewRowFromBuilder(DataRowBuilder builder)
        {
            return new TESTTBLRow(builder);
        }

        // 型つきDataRowとして拡張したクラスを使うようにオーバーライドする.
        protected override Type GetRowType()
        {
            return typeof(TESTTBLRow);
        }

        /// <summary>
        /// 型つきDataRowを作成して返します.
        /// (コンビニエンスメソッド)
        /// </summary>
        /// <returns>型つきの新規レコード</returns>
        public TESTTBLRow NewTESTTBLRow()
        {
            return (TESTTBLRow)NewRow();
        }

        /// <summary>
        /// 型つきDataRowをインデクサによってアクセス可能にします.
        /// (コンビニエンスメソッド)
        /// </summary>
        /// <param name="idx">インデックス</param>
        /// <returns>型つきのDataRow</returns>
        public TESTTBLRow this[int idx]
        {
            get
            {
                return (TESTTBLRow)Rows[idx];
            }
        }
    }

加えて、行データも型つきのDataRow派生クラスとするため、NewRowFromBuilder, GetRowTypeも、それぞれ型つきクラスを返すようにオーバーライドする。


そのほかの実装上の注意としては、定義したカラムなどの独自メンバはシリアライズやクローンでは引き継がれないため、
クローンや逆シリアライズのタイミングでメンバを復元する必要がある
、という点である。


また、DataTableのデフォルト設定では、データ中の大文字・小文字や、ひらがなカタカナの区別をしないため、
Oracleでは一意制約違反にならないデータでも、データセットでは一意制約違反になってしまうため、
Oracleの場合には、必ずCaseSensitiveをtrueに設定する必要がある。

DataRowの拡張方法

DataRowは、以下のように拡張する。

    /// <summary>
    /// TESTTBLテーブルの行データを示す型つきDataRowの拡張
    /// </summary>
    [Serializable]
    public partial class TESTTBLRow : DataRow
    {
        // ダウンキャスト済みTESTTBLTableへの参照
        private TESTTBLTable parent;

        /// <summary>
        /// DataRowのコンストラクタ, 生成できるのはDataTable内のNewRowFromBuilderメソッドからのみ.
        /// </summary>
        /// <param name="builder">ビルダ</param>
        internal TESTTBLRow(DataRowBuilder builder)
            : base(builder)
        {
            // TESTTBLTableへの参照をダウンキャストして保存する.
            this.parent = (TESTTBLTable)(this.Table);
        }

        /// <summary>
        /// IDX
        /// </summary>
        public int IDX
        {
            get
            {
                return this.Field<int>(parent.IDX);
            }
            set
            {
                this.SetField<int>(parent.IDX, value);
            }
        }

        /// <summary>
        /// VAL1
        /// </summary>
        public string VAL1
        {
            get
            {
                return this.Field<string>(parent.VAL1);
            }
            set
            {
                this.SetField<string>(parent.VAL1, value);
            }
        }

        /// <summary>
        /// VAL2
        /// </summary>
        public long? VAL2
        {
            get
            {
                return this.Field<long?>(parent.VAL2);
            }
            set
            {
                this.SetField<long?>(parent.VAL2, value);
            }
        }

        /// <summary>
        /// UPD
        /// </summary>
        public DateTime UPD
        {
            get
            {
                return this.Field<DateTime>(parent.UPD);
            }
            set
            {
                this.SetField<DateTime>(parent.UPD, value);
            }
        }
    }

ここでの注意点は、カラムに対応するように作成した型つきプロパティ名から、元のDataRowのカラムデータにアクセスするときに文字列データである「カラム名」ではなく、DataTable派生クラスで定義したDataColumnオブジェクトを使うことにある。

カラム名を文字列として指定しても当然、カラムデータの読み書きは可能である。が、DataColumnをカラムの識別方法として使用したほうが、幾分かパフォーマンスが良い結果になるようである。

アクセス数が少ないアクセッサであれば効率優先に考える必要はないかもしれないが、データテーブルのカラムへのアクセス数は、レコード件数 x カラム数 x ロジックにより数回以上 というのが普通であろうから、アクセス数は非常に多いと予想される。

したがって、アクセス効率が良いとわかっているDataColumnをパラメータとする方法を採用したほうが良いであろう。

それ以外の点については、とくに注意すべきことはない。

TableAdapterの作成

Visual Studioが生成するTableAdapterも同様であるが、TableAdapterというクラスは、そもそもADO.NETのクラスライブラリには存在しない。

これは単純に、DataTableとDataAdapterの関係などの諸設定を初期化し、FillやUpdateを行うためのロジックを記載した、普通のクラスなのである。

ただし、非接続型のデータベースアクセスでは、この処理は定型的なものとなるため、Visual Studioで型つきデータセットを作成すれば自動的に生成される。

自前で型つきデータセットを用意するならば、やはり、これに類似するクラスは欲しくなるところである。

    /// <summary>
    /// FillとUpdateをサポートする簡易テーブルアダプタの実装.
    /// バインド変数の記述方法などでDB間で差異があるため、このクラスはDB毎に実装が異なる.
    /// ここではOracle用のDataAdapterとなる.
    /// </summary>
    public partial class TESTTBLTableAdaptor : IDisposable
    {
        /// <summary>
        /// 全件取得SQL
        /// </summary>
        public const string SQL_SELECT_ALL = "select IDX, VAL1, VAL2, UPD from TESTTBL";

        /// <summary>
        /// 挿入DML
        /// </summary>
        public const string SQL_INSERT = "insert into TESTTBL(IDX, VAL1, VAL2, UPD) values (:IDX, :VAL1, :VAL2, :UPD)";

        /// <summary>
        /// 更新DML
        /// </summary>
        public const string SQL_UPDATE = "update TESTTBL set VAL1 = :VAL1, VAL2 = :VAL2, UPD = :UPD where IDX = :IDX";

        /// <summary>
        /// 削除DML
        /// </summary>
        public const string SQL_DELETE = "delete from TESTTBL where IDX = :IDX";

        /// <summary>
        /// コネクション
        /// </summary>
        private Oracle.DataAccess.Client.OracleConnection connection;

        /// <summary>
        /// データアダプタ
        /// </summary>
        private Oracle.DataAccess.Client.OracleDataAdapter adapter;

        /// <summary>
        /// 破棄リスト.
        /// 先頭のものほど新しく作成されたオブジェクト.
        /// </summary>
        private LinkedList<IDisposable> disposables = new LinkedList<IDisposable>();

        /// <summary>
        /// データアダプタを取得する.
        /// 初回取得時に初期化する.
        /// </summary>
        protected internal Oracle.DataAccess.Client.OracleDataAdapter Adapter
        {
            get
            {
                if (adapter == null)
                {
                    InitAdapter();
                }
                return adapter;
            }
        }

        /// <summary>
        /// コネクションを取得する.
        /// 初回取得時に初期化する.
        /// </summary>
        protected internal Oracle.DataAccess.Client.OracleConnection Connection
        {
            get
            {
                if (connection == null)
                {
                    InitConnection();
                }
                return connection;
            }
        }

        /// <summary>
        /// コネクションを初期化する.
        /// </summary>
        private void InitConnection()
        {
            System.Diagnostics.Debug.Assert(connection == null);

            connection = new global::Oracle.DataAccess.Client.OracleConnection();
            connection.ConnectionString = ConfigurationManager
                .ConnectionStrings["ConnectionString"].ConnectionString;
        }

        /// <summary>
        /// データアダプタを初期化する.
        /// </summary>
        private void InitAdapter()
        {
            System.Diagnostics.Debug.Assert(adapter == null);

            // テーブルのカラム名とデータテーブルのカラム名との対応付け
            DataTableMapping mapping = new DataTableMapping();
            mapping.SourceTable = "TESTTBL";
            mapping.DataSetTable = "TESTTBL";
            mapping.ColumnMappings.Add(new DataColumnMapping("IDX", "IDX"));
            mapping.ColumnMappings.Add(new DataColumnMapping("VAL1", "VAL1"));
            mapping.ColumnMappings.Add(new DataColumnMapping("VAL2", "VAL2"));
            mapping.ColumnMappings.Add(new DataColumnMapping("UPD", "UPD"));

            // データアダプタの作成 (ORACLE用)
            adapter = new Oracle.DataAccess.Client.OracleDataAdapter();
            adapter.TableMappings.Add(mapping);

            // バインド変数のファクトリ
            // バインド変数はNCHAR/NVARCHAR2などのカラムの属性と一致させる必要がある.
            // また、Adapterを使う場合は、データテーブル上のカラムと対応づける必要がある.
            Func<string, Oracle.DataAccess.Client.OracleDbType, DataRowVersion, bool,
                Oracle.DataAccess.Client.OracleParameter> BindParam
                    = (name, typ, version, nullable) =>
                        {
                            var bindParam = new Oracle.DataAccess.Client.OracleParameter(name, typ);
                            bindParam.SourceColumn = name; // データテーブル上のカラム名
                            bindParam.SourceVersion = version; // データテーブル上の値のバージョン
                            bindParam.IsNullable = nullable; // NULL可?
                            disposables.AddFirst(bindParam);
                            return bindParam;
                        };

            // 挿入コマンド
            adapter.InsertCommand = new Oracle.DataAccess.Client.OracleCommand();
            disposables.AddFirst(adapter.InsertCommand);
            adapter.InsertCommand.Connection = Connection;
            adapter.InsertCommand.CommandType = CommandType.Text;
            adapter.InsertCommand.CommandText = SQL_INSERT;
            adapter.InsertCommand.BindByName = true;

            adapter.InsertCommand.Parameters.Add(BindParam("IDX",
                Oracle.DataAccess.Client.OracleDbType.Int32, DataRowVersion.Current, false));
            adapter.InsertCommand.Parameters.Add(BindParam("VAL1",
                Oracle.DataAccess.Client.OracleDbType.NVarchar2, DataRowVersion.Current, true));
            adapter.InsertCommand.Parameters.Add(BindParam("VAL2",
                Oracle.DataAccess.Client.OracleDbType.Int32, DataRowVersion.Current, true));
            adapter.InsertCommand.Parameters.Add(BindParam("UPD",
                Oracle.DataAccess.Client.OracleDbType.TimeStamp, DataRowVersion.Current, false));

            // 更新コマンド
            adapter.UpdateCommand = new Oracle.DataAccess.Client.OracleCommand();
            disposables.AddFirst(adapter.UpdateCommand);
            adapter.UpdateCommand.Connection = Connection;
            adapter.UpdateCommand.CommandType = CommandType.Text;
            adapter.UpdateCommand.CommandText = SQL_UPDATE;
            adapter.UpdateCommand.BindByName = true;

            adapter.UpdateCommand.Parameters.Add(BindParam("IDX",
                Oracle.DataAccess.Client.OracleDbType.Int32, DataRowVersion.Original, false)); // 元の値
            adapter.UpdateCommand.Parameters.Add(BindParam("VAL1",
                Oracle.DataAccess.Client.OracleDbType.NVarchar2, DataRowVersion.Current, true));
            adapter.UpdateCommand.Parameters.Add(BindParam("VAL2",
                Oracle.DataAccess.Client.OracleDbType.Int32, DataRowVersion.Current, true));
            adapter.UpdateCommand.Parameters.Add(BindParam("UPD",
                Oracle.DataAccess.Client.OracleDbType.TimeStamp, DataRowVersion.Current, false));

            // 削除コマンド
            adapter.DeleteCommand = new Oracle.DataAccess.Client.OracleCommand();
            disposables.AddFirst(adapter.DeleteCommand);
            adapter.DeleteCommand.Connection = Connection;
            adapter.DeleteCommand.CommandType = CommandType.Text;
            adapter.DeleteCommand.CommandText = SQL_DELETE;
            adapter.DeleteCommand.BindByName = true;
            adapter.DeleteCommand.Parameters.Add(BindParam("IDX",
                Oracle.DataAccess.Client.OracleDbType.Int32, DataRowVersion.Original, false));  // 元の値
        }

        // DataAdapterが使用しているリソースを解放する.
        public void Dispose()
        {
            try
            {
                // アダプタの解放
                if (adapter != null)
                {
                    foreach (var disposable in disposables)
                    {
                        disposable.Dispose();
                    }
                    disposables.Clear();

                    adapter.Dispose();
                    adapter = null;
                }
            }
            finally
            {
                // コネクションの解放
                if (connection != null)
                {
                    connection.Dispose();
                    connection = null;
                }
            }
        }

        /// <summary>
        /// 全件取り出し
        /// </summary>
        /// <param name="conn">Oracleコネクション</param>
        /// <param name="tbl">データの格納先</param>
        public void Fill(TESTTBLTable tbl)
        {
            System.Diagnostics.Debug.Assert(tbl != null);

            using (var cmd = new Oracle.DataAccess.Client.OracleCommand())
            {
                cmd.Connection = Connection;
                cmd.CommandType = CommandType.Text;
                cmd.CommandText = SQL_SELECT_ALL;

                using (var adp = new Oracle.DataAccess.Client.OracleDataAdapter(cmd))
                {
                    // データテーブルの定義にないカラムがSQLから返された場合はカラムを追加する
                    adp.MissingSchemaAction = MissingSchemaAction.Add;

                    // データベースのクエリ結果をデータテーブルに一括格納する.
                    adp.Fill(tbl);
                }
            }
        }

        /// <summary>
        /// 追加・更新・削除
        /// </summary>
        /// <param name="conn">コネクション</param>
        /// <param name="tbl">データテーブル</param>
        public void Update(TESTTBLTable tbl)
        {
            System.Diagnostics.Debug.Assert(tbl != null);

            Adapter.Update(tbl);
        }
    }

テーブルアクセスのロジックなので、SELECT文の種類が増えたりすれば大きくなることが予想される。

なので、もしツールによる自動生成を考えるならば、このクラスこそがpartial classであることが重要である。


このクラスは、基本的にはDataAdapterとSQL文とバインド変数とDataTableの4者の関連づけを行うものである。


とくに重要なのはバインド変数の関連付けである。

DataAdapterがSQL上のバインド変数とデータテーブル上のカラムとを関連づけるために必要なパラメータとして、以下のプロパティがある。

  • ParameterName バインド変数名
  • OracleDbType Oracleのデータ型
  • SourceColumn データテーブルのカラム名
  • SourceVersion データテーブル上のデータのバージョン

データパラメータに、これらのプロパティが設定されていないと、DataAdapterは正しく機能しない。


とくにSourceColumnはUpdateで重要である。

Fillの場合はDataReaderで受け取ったカラム名からマッピングされるが、

Updateする場合にはデータテーブルのカラム名から自動的にバインド変数名に変換されるわけではない為、ソースカラムを指定する必要がある。


また、このときバインド変数に渡す値が、Fillされた時点のオリジナルの値であるべきなのか、何らかの変更が加えられたかもしれない現在値であるべきなのかをSourceVersionとして指定する。


たとえば、UPDATE文やDELETE文に使う行を特定するカラムであれば現在値でなく、Fillされた時点のオリジナル値であるべきである。


今回の例では実装していないが、ロングトランザクションを実装するために、もし、全カラムがFill時点と変更がない場合のみ更新または削除する、というロジックにする場合には、すべてのカラムに対して新旧のバインド変数(と、それを指定するパラメータ)を用意する必要がある。


また、上記TableAdapterは実証コードを動かすための最低限の実装のため、それ自身でコネクションを取得しているが、もし複数テーブルある場合には個々ではコネクションを取得しないように修正する必要がある。(TransactionScopeではOpenしたコネクションの回数が2回以上あると分散トランザクションとみなされてしまうため、複数テーブルであっても1個のコネクションを使い、トランザクション中に一度だけOpenするような実装にしないと不味いと思われる。なお、DataAdapterはコネクションが開いていない場合は自動的に開き、使い終わったら閉じるので、コネクションを開いていない状態でFillやUpdateを二回以上呼び出すと二回以上Open/Closeが呼び出されることになる。)

実験コード

上記、自前の型つきデータテーブルを作成できたので、これを実際に使ってみる。

namespace TypeSafeDataSetSample
{
    class Program
    {
        static void Main(string[] args)
        {
            // 型つきデータテーブル用テーブルアダプタの作成
            using (var adp = new TESTTBLTableAdaptor())
            {
                // 型つきデータテーブルの作成
                var tbl = new TESTTBLTable();

                // 全件とりだし
                adp.Fill(tbl);

                // LinQによるソート
                var rows = (from row in tbl.AsEnumerable().Cast<TESTTBLRow>()
                            orderby row.IDX descending // IDXの逆順
                            select row).ToList();

                foreach (TESTTBLRow row in rows)
                {
                    Console.WriteLine(row.IDX + ":" + row.VAL1 + ":" + row.VAL2 + ":" + row.UPD);
                }

                // 最少IDX
                int? minIdx = rows.Min(row => (int?)row.IDX);

                // 最大IDX
                int maxIdx = rows.Max(row => (int?)row.IDX) ?? 0;

                // 最少IDXを削除する
                if (minIdx.HasValue)
                {
                    var minRow = rows.Where(row => row.IDX == minIdx).First();
                    minRow.Delete();
                }

                // 新規に追加する
                foreach (int cnt in Enumerable.Range(1, 10))
                {
                    var newRow = tbl.NewTESTTBLRow();
                    newRow.IDX = maxIdx + cnt;
                    newRow.VAL1 = maxIdx + cnt + "@";
                    newRow.VAL2 = (maxIdx + cnt) * 100;
                    newRow.UPD = DateTime.Now;
                    tbl.Rows.Add(newRow);
                }

                // 更新
                foreach (TESTTBLRow row in rows)
                {
                    if (row.RowState == DataRowState.Unchanged)
                    {
                        // 追加も削除も変更もされていない場合
                        // 値を変更する.
                        row.VAL2 = row.VAL2 + 10;
                        row.UPD = DateTime.Now;
                    }
                }

                // データベースに反映する
                adp.Update(tbl);
            }

            Console.Read();
        }
    }
}

上記例のように、Rowに対してIDX, VAL1, VAL2, UPDのようなカラム名を文字列ではなく、プロパティとして指定してアクセスできているほか、属性をわざわざキャストしたり明示的に指定したりする必要がなくなっている。

結論

上記例でみるとおり、もし、これが型つきでないとすれば、Linqとかの扱いもめんどくさいことになっているであろうし、カラム名や属性の変更があった場合もカラム名の文字列や型のキャストの修正漏れがないかなどはコンパイルしたぐらいではわからないので、大変めんどくさいことになると予想される。


また、使ってみればわかるが、型つきであることはインテリセンスが効くというのが大きなメリットで、とてもコードが書きやすくなる。

やはり、「型つき」はExpress版で開発するとしても、ぜひ、用意すべきものである。


ただし、カラムが修正されるたびに型つきデータセットを手作業で直していれば漏れも出てくるし、頻繁にテーブル設計が修正されるのであればソースに反映する修正の手間も大きい。


上記のソースで見て取れるように、個々の名前と型が違うぐらいで定型的なコードである。

テーブル構造(テーブル名、カラム名、属性、PK)さえデータ化できればテンプレートエンジンを使って自動生成するのも簡単である。

データベースの状態と型つきデータセットの状態を常に正しく同期させるには、やはり、これらを生成するためのツールを別途用意するのが望ましいだろう。*2 *3



また、今回はVisual Web Developer 2010 Express版 + Oracle11gExpressでの開発のために調べた結果であるが、この過程で、型つきデータセットVisual Studioが、どのように作っているのか、その仕組みについても理解を深めることができた点が、私にとっては一番の収穫だったようにも思う。

*1:これら限らず、たとえばSVNやGitでソースコード管理したい場合でも、サードパーティオープンソースのツールと統合することができない。加えて、MS純正のTeam Foundationですら、Express版では連携できない。

*2:もちろん、ジェネレータは作成しました。が、やっつけ仕事のうえに、単なるジェネレーターなので特に新しい知見もなく淡々と書いただけなので省略。

*3:VS2010以降は標準でT4テキストテンプレートエンジンによるコード生成ができるので、これを使うと良いかもしれない。(2014/6/6追記)