SqlDataReader Mapper

SqlDataReader simple mapper extension with XML support

In my case I have following setup:

USE master
GO
IF EXISTS(SELECT * FROM sys.databases WHERE name='DataMapperTests')
BEGIN
	ALTER DATABASE DataMapperTests SET SINGLE_USER WITH ROLLBACK IMMEDIATE
	DROP DATABASE DataMapperTests
END
CREATE DATABASE DataMapperTests
GO
USE DataMapperTests
GO

PRINT 'Creating tables'
GO

CREATE TABLE Tag (
	Id INT IDENTITY(1, 1) PRIMARY KEY,
	Name NVARCHAR(50) NOT NULL
)

CREATE TABLE Post (
	Id INT IDENTITY(1, 1) PRIMARY KEY,
	Title NVARCHAR(50) NOT NULL,
	Published BIT NOT NULL DEFAULT 0,
	CreatedAt DATETIME2 NOT NULL DEFAULT CONVERT(DATE, GETDATE()),
	PublishedAt DATETIME2
)

CREATE TABLE PostTags (
	PostId INT NOT NULL,
	TagId INT NOT NULL,
	PRIMARY KEY (PostId, TagId),
	CONSTRAINT FK_Post FOREIGN KEY (PostId) REFERENCES Post(Id) ON DELETE CASCADE,
	CONSTRAINT FK_Tag FOREIGN KEY (TagId) REFERENCES Tag(Id) ON DELETE CASCADE
)
GO

PRINT 'Seed tables'
GO

SET NOCOUNT ON

SET IDENTITY_INSERT Tag ON
INSERT INTO Tag (Id, Name) VALUES
	(1, 'Tag 1'),
	(2, 'Tag 2');
SET IDENTITY_INSERT Tag OFF
GO

SET IDENTITY_INSERT Post ON
INSERT INTO Post (Id, Title, Published, PublishedAt) VALUES
	(1, 'Post 1', 1, CONVERT(DATE, GETDATE())),
	(2, 'Post 2', 0, NULL),
	(3, 'Post 3', 1, CONVERT(DATE, GETDATE()));
SET IDENTITY_INSERT Post OFF
GO

INSERT INTO PostTags (PostId, TagId) VALUES
	(1, 1),
	(2, 1), (2, 2);
GO

PRINT 'Create view'
GO

CREATE VIEW SampleView AS
SELECT
	Id,
	Title,
	CreatedAt,
	Published,
	PublishedAt,

	ISNULL((SELECT DISTINCT
		LTRIM(RTRIM(T.Name))
		FROM PostTags AS PT
		JOIN Tag AS T ON PT.TagId = T.Id AND PT.PostId = P.Id
		FOR XML PATH ('string'), ROOT('ArrayOfString'), TYPE), '<ArrayOfString></ArrayOfString>') AS ListString,

	ISNULL((SELECT DISTINCT
		LTRIM(RTRIM(T.Id))
		FROM PostTags AS PT
		JOIN Tag AS T ON PT.TagId = T.Id AND PT.PostId = P.Id
		FOR XML PATH ('int'), ROOT('ArrayOfInt'), TYPE), '<ArrayOfInt></ArrayOfInt>') AS ListInt,

	ISNULL((SELECT DISTINCT
		T.Id AS Id,
		LTRIM(RTRIM(T.Name)) AS Name
		FROM PostTags AS PT
		JOIN Tag AS T ON PT.TagId = T.Id AND PT.PostId = P.Id
		FOR XML PATH ('Tag'), ROOT('ArrayOfTag'), TYPE), '<ArrayOfTag></ArrayOfTag>') AS ListTag

FROM Post AS P
GO

Sample view is returning following data:

Name Type
Id int
Title string
CreatedAt DateTime
Published bool
PublishedAt DateTime
ListString List<string>
ListInt List<int>
ListTag List<Tag>

Our models are:

public class Tag
{
	public int Id { get; set; }
	public string Name { get; set; }
}

public class Post
{
	public int Id { get; set; }
	public string Title { get; set; }
	public bool Published { get; set; }
	public DateTime PublishedAt { get; set; }
	public DateTime CreatedAt { get; set; }

	public List<int> ListInt { get; set; }
	public List<string> ListString { get; set; }
	public List<Tag> ListTag { get; set; }
}

Here is SqlDataReaderMapperExtension:

public static class SqlDataReaderExtensions
{
	private static readonly MemoryCache Cache = MemoryCache.Default;

	public static T Map<T>(this SqlDataReader reader) where T : new()
	{
		var properties = GetProperties(typeof(T));

		var item = new T();

		for (var i = 0; i < reader.FieldCount; i++)
		{
			if (reader.IsDBNull(i)) continue;

			var property = properties[NormalizeKey(reader.GetName(i))].FirstOrDefault();

			if (property == null) continue;

			if (reader.GetFieldType(i) == property.PropertyType)
				property.SetValue(item, reader[i]);
			else if (reader.GetProviderSpecificFieldType(i) == typeof(SqlXml))
				property.SetValue(item, new XmlSerializer(property.PropertyType).Deserialize(reader.GetXmlReader(i)));
		}

		return item;
	}

	private static ILookup<string, PropertyInfo> GetProperties(Type type)
	{
		var cacheKey = $"{nameof(SqlDataReaderExtensions)}.{type.FullName}";
		var result = Cache.Get(cacheKey) as ILookup<string, PropertyInfo>;

		if (result != null) return result;

		result = type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.CanWrite).ToLookup(p => NormalizeKey(p.Name));
		Cache.Add(cacheKey, result, null);

		return result;
	}

	private static string NormalizeKey(string name)
	{
		return name.Replace("_", "").ToLower().Trim();
	}
}

which will allow you to get objects like this:

using (var command = new SqlCommand(Query, GetSqlConnection()))
{
	command.Connection.Open();
	using (var reader = command.ExecuteReader())
	{
		if (reader.HasRows)
		{
			while (reader.Read())
			{
				var post = reader.Map<Post>();
			}
		}
	}
}

and here is event shorten way with command extension

public static class SqlCommandExtensions
{
	public static IEnumerable<T> ExecuteReader<T>(this SqlCommand command) where T : new()
	{
		command.Connection.Open();
		using (var reader = command.ExecuteReader())
		{
			if (!reader.HasRows) yield break;

			while (reader.Read())
			{
				yield return reader.Map<T>();
			}
		}
		command.Connection.Close();
	}
}

and now you can do something like this:

using (var command = new SqlCommand(Query, GetSqlConnection()))
{
	var posts = command.ExecuteReader<Post>();
}

Extension were written to handle xml in first place and for demo purposes