[C# 自作マッパー]Dapperに似た軽量ORマッパーを自作した話

今回、ORマッパーにDapperを使おうと思っていたところ、思わぬ壁に当たったため自作してみました。

要件

1.RubyやLaravelのようにリッチなものは必要ない。
2.Modelとテーブルのカラム名が全く違っても使える(!)
3.とりあえずシンプルなCRUDが達成できればいい。

この(2)が曲者で、 Dapperでは直感的に実装することができなくなってしまいます。スネークケースをキャメルケースに直すくらいならできるみたいですが。実案件ではどうしてもここが障壁になることがあると思います。

個人的にはこれくらい薄いラッパーの方が使いやすくて好きです。特に帳票やデータ解析が絡むとSQLを書く前提の方が管理しやすかったり。シンプルなCRUDだけで事足りるならSQLを書くことはむしろ保守性を下げる要因とみなされるでしょうけど。

nuGetの追加

必要なパッケージは「Microsoft.Data.SqlClient」と「System.Reflection」です。それぞれインストールしてください。

マッパークラスの作成

早速コード。

using Microsoft.Data.SqlClient;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

namespace ネームスペース
{
    public static class Mapper
    {

        public static List<Model> Query<Model>(this SqlConnection connection, SqlTransaction? transaction, string sql,  params (string paramName,object value)[] parameters) where Model : class, new()
        {
            var param = new List<SqlParameter>();
            foreach(var item in parameters)
            {
                param.Add(new SqlParameter( item.paramName, item.value));
            }
            return Query<Model>(connection, transaction, sql, param);
        }
        public static List<Model> Query<Model>(this SqlConnection connection, SqlTransaction? transaction, string sql, List<SqlParameter> parameters) where Model : class, new()
        {
            List<Model> models = [];
            var modelType = typeof(Model);
            var propertyMaps = GetPropertyAndMapColumnNames(modelType);

            using (SqlCommand command = new SqlCommand(sql, connection))
            {   
                if(transaction != null)
                    command.Transaction = transaction;

                if (parameters != null)
                {
                    foreach (var parameter in parameters)
                    {
                        command.Parameters.Add(parameter);
                    }
                }
                using (SqlDataReader reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        var model = new Model();
                        foreach (var pair in propertyMaps)
                        {
                            pair.Key.SetValue(model, reader[pair.Value.Name]);
                        }
                        models.Add(model);
                    }
                }
            }
            return models;
        }

        public static List<Model> QueryWhere<Model>(this SqlConnection connection, SqlTransaction? transaction, string sqlWhere,  List<SqlParameter> parameters) where Model : class, new()
        {
            List<Model> models = [];
            var modelType = typeof(Model);
            var propertyMaps = GetPropertyAndMapColumnNames(modelType);

            using (SqlCommand command = new SqlCommand("select * from " + GetTableName(modelType) + " " + sqlWhere, connection))
            {
                if (transaction != null)
                    command.Transaction = transaction;

                if (parameters != null)
                {
                    foreach (var parameter in parameters)
                    {
                        command.Parameters.Add(parameter);
                    }
                }
                using (SqlDataReader reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        var model = new Model();
                        foreach (var pair in propertyMaps)
                        {
                            pair.Key.SetValue(model, reader[pair.Value.Name]);
                        }
                        models.Add(model);
                    }
                }
            }
            return models;
        }

        public static List<SqlParameter> CreateParams(params (string name, object value)[] parameters)
        {
            List<SqlParameter> sqlParameters = new List<SqlParameter>();
            foreach (var param in parameters)
            {
                sqlParameters.Add(new SqlParameter() { ParameterName = param.name, Value = param.value });
            }
            return sqlParameters;
        }

        private static Dictionary<PropertyInfo, MapColumn> GetPropertyAndMapColumnNames(Type type)
        {
            Dictionary<PropertyInfo, MapColumn> propertyNameAndColumnNames = new Dictionary<PropertyInfo, MapColumn>();
            foreach (var property in type.GetProperties())
            {
                if (property.IsDefined(typeof(MapColumnAttribute), true))
                {
                    bool isPrimaryKey = false;
                    MapColumnAttribute attribute = (MapColumnAttribute)property.GetCustomAttribute(typeof(MapColumnAttribute), true)!;
                    if (property.IsDefined(typeof(MapPrimaryKeyAttribute), true))
                    {
                        isPrimaryKey = true;
                    }
                    propertyNameAndColumnNames.Add(property, new MapColumn(attribute.ColumnName, isPrimaryKey));
                }
            }
            return propertyNameAndColumnNames;
        }

        private static string GetTableName(Type type)
        {
            if (type.IsDefined(typeof(MapTableAttribute), true))
            {
                MapTableAttribute attribute = (MapTableAttribute)type.GetCustomAttribute(typeof(MapTableAttribute), true)!;
                return attribute.TableName;
            }
            return "";
        }

        public static int Insert<Model>(this SqlConnection connection, SqlTransaction? transaction, Model model ) where Model : class, new()
        {
            var modelType = typeof(Model);
            var propertyMaps = GetPropertyAndMapColumnNames(modelType);
            var tableName = GetTableName(modelType);
            var parameters = new List<SqlParameter>();

            string sql = "";
            sql += "insert into " + tableName + " values (";
            string sqlValues = "";
            foreach (var pair in propertyMaps)
            {
                if (!string.IsNullOrEmpty(sqlValues))
                    sqlValues += ", ";
                var value = pair.Key.GetValue(model);
                sqlValues += "@" + pair.Value.Name;
                parameters.Add(new SqlParameter("@" + pair.Value.Name, value));
            }
            sql += sqlValues + ")";

            int resultCount = 0;
            using (SqlCommand command = new SqlCommand(sql, connection))
            {
                if (transaction != null)
                    command.Transaction = transaction;

                foreach (var parameter in parameters)
                {
                    command.Parameters.Add(parameter);
                }
                resultCount = command.ExecuteNonQuery();
            }
            return resultCount;
        }

        public static int Update<Model>(this SqlConnection connection, SqlTransaction? transaction, Model model ) where Model : class, new()
        {
            var modelType = typeof(Model);
            var propertyMaps = GetPropertyAndMapColumnNames(modelType);
            var tableName = GetTableName(modelType);
            var parameters = new List<SqlParameter>();

            string sql = "";
            var sqlWhere = "";
            sql += "update " + tableName + " set ";
            string sqlValues = "";
            foreach (var pair in propertyMaps)
            {
                var value = pair.Key.GetValue(model);
                if (pair.Value.IsPrimaryKey)
                {
                    if (!string.IsNullOrEmpty(sqlWhere))
                        sqlWhere += " and ";

                    sqlWhere += pair.Value.Name + " = @" + pair.Value.Name;
                }
                else
                {
                    if (!string.IsNullOrEmpty(sqlValues))
                        sqlValues += ", ";

                    sqlValues += pair.Value.Name + " = @" + pair.Value.Name;
                }
                parameters.Add(new SqlParameter("@" + pair.Value.Name, value));
            }
            if (string.IsNullOrEmpty(sqlWhere)) throw new InvalidOperationException("not set Primarykey at Model class.");
            sql += sqlValues + " where " + sqlWhere;

            int resultCount = 0;
            using (SqlCommand command = new SqlCommand(sql, connection))
            {
                if (transaction != null)
                    command.Transaction = transaction;

                foreach (var parameter in parameters)
                {
                    command.Parameters.Add(parameter);
                }
                resultCount = command.ExecuteNonQuery();
            }
            return resultCount;
        }
        public static int Delete<Model>(this SqlConnection connection, SqlTransaction? transaction, Model model) where Model : class, new()
        {
            var modelType = typeof(Model);
            var propertyMaps = GetPropertyAndMapColumnNames(modelType);
            var tableName = GetTableName(modelType);
            var parameters = new List<SqlParameter>();

            string sql = "";
            var sqlWhere = "";
            sql += "delete from " + tableName;
            foreach (var pair in propertyMaps)
            {
                if (pair.Value.IsPrimaryKey)
                {
                    var value = pair.Key.GetValue(model);
                    if (!string.IsNullOrEmpty(sqlWhere))
                        sqlWhere += " and ";

                    sqlWhere += pair.Value.Name + " = @" + pair.Value.Name;
                    parameters.Add(new SqlParameter("@" + pair.Value.Name, value));
                }
            }
            if (string.IsNullOrEmpty(sqlWhere)) throw new InvalidOperationException("not set Primarykey at Model class.");
            sql += " where " + sqlWhere;

            int resultCount = 0;
            using (SqlCommand command = new SqlCommand(sql, connection))
            {
                if (transaction != null)
                    command.Transaction = transaction;

                foreach (var parameter in parameters)
                {
                    command.Parameters.Add(parameter);
                }
                resultCount = command.ExecuteNonQuery();
            }
            return resultCount;
        }

        public static int Delete<Model>(this SqlConnection connection, SqlTransaction? transaction, string whereBlock,  params SqlParameter[] parameters)
        {
            return Delete<Model>(connection, transaction, whereBlock,  parameters.ToList());
        }

        public static int Delete<Model>(this SqlConnection connection, SqlTransaction? transaction, string whereBlock, List<SqlParameter> parameters)
        {
            var modelType = typeof(Model);
            var tableName = GetTableName(modelType);

            if (string.IsNullOrEmpty(whereBlock))
                throw new InvalidOperationException("not set [where]block.");
            if (parameters.Count < 1)
                throw new InvalidOperationException("not set params");

            string sql = "delete from " + tableName + " where " + whereBlock;

            int resultCount = 0;
            using (SqlCommand command = new SqlCommand(sql, connection))
            {
                if (transaction != null)
                    command.Transaction = transaction;

                foreach (var parameter in parameters)
                {
                    command.Parameters.Add(parameter);
                }
                resultCount = command.ExecuteNonQuery();
            }
            return resultCount;
        }
    }

    public class MapColumn
    {
        public string Name { get; set; } = "";
        public bool IsPrimaryKey { get; set; } = false;

        public MapColumn(string name, bool isPrimaryKey = false)
        {
            Name = name;
            IsPrimaryKey = isPrimaryKey;
        }
    }

    public class MapTableAttribute : Attribute
    {
        public string TableName { get; } = "";
        public MapTableAttribute(string name)
        {
            TableName = name;
        }
    }

    public class MapColumnAttribute : Attribute
    {
        public string ColumnName { get; } = "";
        public MapColumnAttribute(string name)
        {
            ColumnName = name;
        }
    }

    public class MapPrimaryKeyAttribute : Attribute
    {
        public bool IsSet { get; } = true;
        public MapPrimaryKeyAttribute()
        {
        }
    }
}

使い方

モデルには属性でテーブル名とカラム名を指定。

	[MapTable("Test")]
	public class Test
	{
		[MapColumn("id")]
		public int TestId { get; set; }

		[MapColumn("name")]
		public string TestName { get; set; }

		public int NoColumn { get; set; } = 111;
	}

テーブルと結びつけたい場合はMapColumnで指定。結びつけなくて良いものは未指定。
MapTableでテーブル名を指定できるようにしています。

DBへの処理部分はこちら。パラメータ有りと無しそれぞれ。

			using (SqlConnection connection = new SqlConnection(connectionString))
			{
				connection.Open();
				var items = connection.Query<Test>(null,"select * from test");
				dataGridView1.DataSource = items;
				connection.Close();
			}


//パラメータあり
			using (SqlConnection connection = new SqlConnection(connectionString))
			{
				connection.Open();

				var items = connection.Query<Test>(null,"select * from test where id = @id",Mapper.CreateParams(("@id","1")));
				dataGridView1.DataSource = items;
				connection.Close();
			}

パラメータはList<SqlParameter>型でも渡せるので、処理の中で柔軟にパラメータを追加していくことも可能です。

トランザクションは明示的にnullなのか(管理しないのか)を把握できるようにしてます。

速度

今回5万件のデータをDataGridViewに表示するまででテストしてみましたが、やはりDapperの方が早かったです。しっかりと平均値を出したわけではありませんが、大体Dapperだと70%くらいになります。これくらいの差であれば、柔軟に列名とマッピングできるならありかなと思ってます。

レンタルサーバはConohaWINGが最強にオススメ!

muchilogでは今までいろんなレンタルサーバやSaaSを使ってきましたが、今では全てをConohaWINGにて運営しています。

■今まで使ってきたサービス一覧
・さくらレンタルサーバ(ベーシックプラン)
・カゴヤ
・Azure
・AWS

今ではこのブログは勿論、webサービスのバックエンドやアプリのサーバ機能もConohaWINGで動かしています。
そんなConohaWINGのメリットをいくつか紹介します!

【国内最速No.1】高性能レンタルサーバーConoHa WING

無料で最大2個の独自ドメインが使える!

サイト運営に必要な独自ドメインをなんと無料で取得することが可能です!これだけでも月100円以上は運営費が節約できます。

優れた速度と安定感

私がAzureやさくらを解約した大きな理由はこれ。Conohaは非常に安定して稼働しており、ダウンタイムがほぼ発生しません。発生した場合は潔くお知らせしてくれます。
また、このブログは勿論、バックエンドとして動いてるプログラムも処理速度が大幅に向上しました(体感で倍速以上)。 Azure等のSaaSからレンタルサーバに移行するって普通考えられませんよね?しかし実際に大きなメリットを感じているのです。

頻繁に開催されるキャンペーン

ConohaWINGは半額に迫るようなキャンペーンを頻繁に開催しています。このキャンペーンによって、性能的には業界トップクラスであるにも関わらず、 月額料金換算で最安値クラスで使えるのです。
基本的に長期一括契約の方がお得になるため、muchilogでは最長で契約することをオススメします。価格と性能のバランスを考えれば他に乗り換えることも考えれられませんし。

レンタルサーバは必須です!

web系のプログラミング学習を進める際には、レンタルサーバの契約は必須と思ってください。ローカルの環境と本番環境で動作が違うことは良くありますし、ポートフォリオを公開するのも大切です。

学習や制作に集中するためにも、満足度、速度、安定度全てのレベルが高いConohaWINGを最強にオススメします!

web系サービス開発会社に転職したい!

muchilogではIT系へ転職したいという方には「自社サービスを運営しているweb系企業」への転職をおすすめしています。

web系は「自由な社風」「成長できる環境」「ホワイトな労働条件」であることが多いからです。

そんなweb系企業への転職を確実にするためのプログラミングスクールが登場しました。


RUNTEQ

RUNTEQはただのスクールじゃない!

RUNTEQはweb系の開発会社。開発会社が運営するスクールなので必要とされる技術力は勿論習得可能。しかしそれだけではないのです。

特にweb系で転職・就職活動を行う際にはポートフォリオを求められることがあります。ポートフォリオとは自分自身の作品のことで、技術力や企画力を示すものです。

RUNTEQではポートフォリオの作成を企画段階からサポートしてくれます!

他のプログラミングスクールでは提携企業の派遣やアルバイトとして就職するしかなかったりもしますが、RUNTEQはあなたの市場価値を高める方法まで教えてくれるということですね。

また、RUNTEQが扱う教材はどれも第一線で当たり前に使われている技術。特に未経験者の独学ではどうしてもスキルセットに穴が出来てしまいます。その点RUNTEQは確実に現場力を養えます。

そして2020年2月〜2021年12月までの内定者の98%がweb系企業に内定しています。これはRUNTEQの指導力とサポートが優れていることの証拠でしょう。

まずは無料説明会に申し込んではいかがでしょうか?

RUNTEQ


C#
muchiをフォローする
MUCHILOG
タイトルとURLをコピーしました