33
Making Dapper and JSON friends
Let's speak about improvements that we can bring to the Dapper. Improvements in the 'quality of life' category, to be precise. I think that everyone, who had some experience with SQL in the past, had a situation like that. You have a model that you want to show to the user. The data is stored in the SQL database. The model itself is straightforward from the C# perspective.
Here is it:
class User
{
public int Id { get; set;}
public string Login { get; set;}
public PhoneNumber[] Phones { get; set;}
}
class PhoneNumber
{
public string Number { get; set; }
public PhoneNumberKind Kind { get; set; }
}
enum PhoneNumberKind
{
Office,
Mobile,
Home
}
It consists of a few fields and an array inside. As you may guess, the main problem will be the array because SQL doesn't have an easy way to put a list of items in the column. So, to fill the model, we will need to execute multiple queries or play around with a SQL reader. Still, both cases require additional manipulation with numerous rows to construct a model.
Sample with multiple requests:
var usersData = await connection.QueryAsync<(int Id, string Login)>("select Id, Login from Users");
var phoneNumbersData = await connection.QueryAsync<(int UserId, string Number, PhoneNumberKind Kind)>(
"select UserId, Number, Kind from PhoneNumbers where UserId in @UserIds", new { UserIds = usersData.Select(o => o.Id)});
var phoneNumbersByUserId = phoneNumbersData
.GroupBy(o => o.UserId)
.ToDictionary(o => o.Key, o => o.ToArray());
var users = usersData
.Select(o =>
new User()
{
Id = o.Id,
Login = o.Login,
PhoneNumbers = phoneNumbersByUserId.ContainsKey(o.Id) ?
phoneNumbersByUserId[o.Id]
.Select(phone => new PhoneNumber
{
Number = phone.Number,
Kind = phone.Kind
})
.ToArray() :
Array.Empty<PhoneNumber>()
})
.ToArray();
As for me, such a solution looks clunky and has huge potential for mistakes. Also, in this sample, we are keeping the whole result set in the memory. That is not great either. To avoid it, we need to introduce batching. At the same time, we may need to add more joins. All of that makes logic even more complex.
At this point, we can remember the fact that the modern SQL databases have support for JSON. The MSSQL server, for example, can execute selectors and pack results into columns as a JSON. In our case, the query can be simplified to look like that:
select
Id,
Login,
(select UserId, Number, Kind from PhoneNumbers where UserId = u.Id FOR JSON PATH) as PhonesJson
from Users u
The result of the query will be next:
Id | Login | PhonesJson |
---|---|---|
16 | Karita_Ringo | [{"UserId":16,"Number":"3377787","Kind":2}] |
17 | Chiquia_Dud | NULL |
18 | Caprice_Hugibert | [{"UserId":18,"Number":"8454027","Kind":0}] |
19 | Pat_Tedie | [{"UserId":19,"Number":"4646231","Kind":1}] |
20 | Vally_Fitzgerald | [{"UserId":20,"Number":"1836548","Kind":2}] |
21 | Jasmina_Xerxes | NULL |
22 | Kristy_Nathaniel | [{"UserId":22,"Number":"7018212","Kind":1}] |
23 | Farica_Ives | [{"UserId":23,"Number":"0874725","Kind":2}] |
24 | Clementine_Gun | NULL |
25 | Jerry_Whit | [{"UserId":25,"Number":"0013362","Kind":0}] |
Now lets fill the User
model based on the new query:
var usersData = await connection.QueryAsync<(int Id, string Login, string PhonesJson)>(@"
select
Id,
Login,
(select UserId, Number, Kind from PhoneNumbers where UserId = u.Id FOR JSON PATH) as PhonesJson
from Users u");
var users = usersData
.Select(o =>
new User
{
Id = o.Id,
Login = o.Login,
PhoneNumbers = !string.IsNullOrEmpty(o.PhonesJson) ?
JsonSerializer.Deserialize<PhoneNumber[]>(o.PhonesJson) :
Array.Empty<PhoneNumber>()
})
.ToArray();
Done! The logic flow now is much simple. At the same time, this approach allows us to enable un-buffered mode and fetch rows in batches. In some scenarios, it can be a life-saving feature. We can start processing much earlier and free memory from processed rows. And all of this without the need to build complex logic.
Can we make our solution even better? Yes, we can. The Dapper
has a way to add custom user-defined types. So, what if we can create a type like Json<T>
and use it as a column type? It can achieve it via the SqlMapper.TypeHandler
. Lets create our JsonTypeHandler<T>
.
For System.Text.Json
it will look like that:
public class JsonTypeHandler<T> : SqlMapper.TypeHandler<Json<T>>
{
public override void SetValue(IDbDataParameter parameter, Json<T> value)
{
parameter.Value = JsonSerializer.Serialize(value.Value);
}
public override Json<T> Parse(object value)
{
if (value is string json)
{
return new Json<T>(JsonSerializer.Deserialize<T>(json));
}
return new Json<T>(default);
}
}
If we want to use the Json<T>
with PhoneNumber[]
then we need to add it to SqlMapper
like that:
SqlMapper.AddTypeHandler(new JsonTypeHandler<PhoneNumber[]>());
Now, lets have a look at updated sample:
var usersData = await connection.QueryAsync<(int Id, string Login, Json<PhoneNumber[]>? PhoneNumbers)>(@"
select
Id,
Login,
(select UserId, Number, Kind from PhoneNumbers where UserId = u.Id FOR JSON PATH) as PhoneNumbers
from Users u");
var users = usersData
.Select(o =>
new User
{
Id = o.Id,
Login = o.Login,
PhoneNumbers = o.PhoneNumbers?.Value ?? Array.Empty<PhoneNumber>()
})
.ToArray();
Such an approach allows us to expose our intention about the query more clearly. At the same time, keep the simplicity of the source code.
But there is an issue. If we want to use other Json<T>
of different types like Json<User[]>
, Json<EmailDetails>
, Json<Role>
and so on. We will need to manually add type handlers to SqlMapper
for each of them. Like that:
SqlMapper.AddTypeHandler(new JsonTypeHandler<User[]>());
SqlMapper.AddTypeHandler(new JsonTypeHandler<EmailDetails>());
SqlMapper.AddTypeHandler(new JsonTypeHandler<Role>());
That's not great. It completely ruins the ease of use. I have tried to find a way to register all types at once, but the Dapper
documentation didn't provide any way to achieve such behaviour. So, the only way is to add type handlers of each type. It would be a disaster, but it is the C#! It has a lot of tricks up his sleeves!
The trick that we will use is source generators! By itself, it is like an extension for the Roslyn compiler. It allows to analyze the existing source code and, based on it, generate a new one. So, the idea is simple. Let's look for usages of Json<T>
and create a module initializer(another new trick) that will add all required types to SqlMapper
auto-magically.
The source generator consists of the syntax receiver and the source generator. Our syntax receiver will be really stupid. Its single task will be to find the Json<T>
.
It looks like that:
public class DapperSourceGeneratorSyntaxReceiver : ISyntaxReceiver
{
public List<GenericNameSyntax> Types { get; } = new();
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
// select all generic type usages of Json<>
if (syntaxNode is GenericNameSyntax { Identifier.ValueText: "Json", } type)
{
Types.Add(type);
}
}
}
The source generator is a bit more complex, but the basic idea should be clear.
[Generator]
public class DapperJsonSourceGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// register our syntax reciver
context.RegisterForSyntaxNotifications(() => new DapperSourceGeneratorSyntaxReceiver());
}
public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxReceiver is not DapperSourceGeneratorSyntaxReceiver syntaxReceiver)
{
return;
}
try
{
// neccesary to make sure that it is our Json<T>
var jsonType = context.Compilation.GetTypeByMetadataName("Dapper.Json.Json`1");
var types = syntaxReceiver.Types
.Select(o =>
{
var semanticModel = context.Compilation.GetSemanticModel(o.SyntaxTree);
var type = semanticModel.GetTypeInfo(o);
if (type.Type is INamedTypeSymbol namedTypeSymbol &&
namedTypeSymbol.ConstructedFrom.Equals(jsonType))
{
// extracting the generic type
return namedTypeSymbol.TypeArguments.First();
}
return null;
})
.Where(o => o != null)
.Distinct(SymbolEqualityComparer.Default)
.ToArray();
// create a module initializer and add all types to SqlMapper
var source = @$"using System;
namespace Dapper.Json
{{
public static class DapperJsonModuleInitializer
{{
[global::System.Runtime.CompilerServices.ModuleInitializer]
public static void Init()
{{
{types.Select(o => $@" SqlMapper.AddTypeHandler(new JsonTypeHandler<{o.ToGlobalName()}>());").JoinWithNewLine()}
}}
}}
}}
";
context.AddSource("Dapper.Json.g.cs", source.ToSourceText());
}
catch (Exception e)
{
// some error handeling to simplify source generator debuging experiance
context.AddSource("Dapper.Json.Error.g.cs", @$"
namespace Dapper.Json
{{
public static class DapperJsonError
{{
public static string Error = ""{e.Message}"";
}}
}}");
}
}
}
The final module initializer may look like that:
using System;
namespace Dapper.Json
{
public static class DapperJsonModuleInitializer
{
[global::System.Runtime.CompilerServices.ModuleInitializer]
public static void Init()
{
SqlMapper.AddTypeHandler(new JsonTypeHandler<global::PhoneNumber[]>());
SqlMapper.AddTypeHandler(new JsonTypeHandler<global::User[]>());
SqlMapper.AddTypeHandler(new JsonTypeHandler<global::EmailDetails>());
SqlMapper.AddTypeHandler(new JsonTypeHandler<global::Role>());
}
}
}
Done! Whenever we use the Json<T>
, it will automatically be added to SqlMapper
.
The complete instruction you can find at the repository on github. There is nothing complicated. You need to install two NuGet packages, and you are ready to go.
That is all for today. Feel free to leave a comment or create an issue on GitHub.
Thanks for your time!
33