Created
March 27, 2025 13:02
-
-
Save teoadal/8dd660c7d4f6b3f6d7dac4acb9b4a877 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.ComponentModel.DataAnnotations; | |
using BenchmarkDotNet.Order; | |
using Dapper; | |
using LinqToDB; | |
using Microsoft.EntityFrameworkCore; | |
using Microsoft.EntityFrameworkCore.Infrastructure; | |
using Npgsql; | |
namespace ConsoleApp1; | |
[SimpleJob(RuntimeMoniker.Net90)] | |
[Orderer(SummaryOrderPolicy.FastestToSlowest)] | |
[MeanColumn, MemoryDiagnoser] | |
public class DbReadBench : IDisposable | |
{ | |
public const string ConnectionString = "Server=localhost;Port=5432;Database=postgres;User Id=postgres;Password=123456;"; | |
private const int BlogCount = 100; | |
private static readonly CancellationToken Cancellation = CancellationToken.None; | |
private const int PostPerBlogCount = 200; | |
private PooledDbContextFactory<BloggingContext> _entityFramework = null!; | |
private NpgsqlDataSource _dapper = null!; | |
private static Dictionary<Guid, Blog>? _dapperBlogCache; | |
[Benchmark] | |
public async Task<List<Blog>> Ef() | |
{ | |
await using var context = await _entityFramework.CreateDbContextAsync(Cancellation); | |
var sql = context.Blogs | |
.Include(static b => b.Posts) | |
.AsNoTracking(); | |
var blogs = await EntityFrameworkQueryableExtensions.ToListAsync(sql, Cancellation); | |
return blogs; | |
} | |
[Benchmark(Baseline = true)] | |
public async Task<List<Blog>> Dapper_TwoQuery() | |
{ | |
await using var connection = await _dapper.OpenConnectionAsync(Cancellation); | |
var blogRows = await connection.QueryAsync<Blog>("SELECT * FROM \"Blogs\" ORDER BY \"Id\"", Cancellation); | |
var cache = Interlocked.Exchange(ref _dapperBlogCache, null) ?? new Dictionary<Guid, Blog>(256); | |
foreach (var blogRow in blogRows) | |
{ | |
cache.Add(blogRow.Id, blogRow); | |
} | |
var posts = await connection.QueryAsync<Post>("SELECT * FROM \"Posts\" WHERE \"BlogId\" = ANY(@ids)", new { ids = cache.Keys.ToArray() }); | |
foreach (var post in posts) | |
{ | |
if (!cache.TryGetValue(post.BlogId, out var existsBlog)) continue; | |
existsBlog.Posts.Add(post); | |
post.Blog = existsBlog; | |
} | |
var blogs = cache.Values.ToList(); | |
cache.Clear(); | |
Interlocked.Exchange(ref _dapperBlogCache, cache); | |
return blogs; | |
} | |
[Benchmark] | |
public async Task<List<Blog>> Dapper_Join() | |
{ | |
await using var connection = await _dapper.OpenConnectionAsync(Cancellation); | |
var cache = Interlocked.Exchange(ref _dapperBlogCache, null) ?? new Dictionary<Guid, Blog>(256); | |
await connection.QueryAsync<Blog, Post, Blog>("SELECT b.\"Id\", b.\"IsDeleted\", b.\"Rating\", b.\"Url\", p.\"Id\", p.\"Content\", p.\"IsDeleted\", p.\"Title\" FROM \"Blogs\" b LEFT JOIN \"Posts\" p ON b.\"Id\" = p.\"BlogId\" ORDER BY b.\"Id\"", (blog, post) => | |
{ | |
if (!cache.TryGetValue(blog.Id, out var blogInstance)) | |
{ | |
blogInstance = blog; | |
cache.Add(blog.Id, blogInstance); | |
} | |
post.Blog = blogInstance; | |
post.BlogId = blogInstance.Id; | |
blogInstance.Posts.Add(post); | |
return blogInstance; | |
}, splitOn: "Id"); | |
var blogs = cache.Values.ToList(); | |
cache.Clear(); | |
Interlocked.Exchange(ref _dapperBlogCache, cache); | |
return blogs; | |
} | |
[Benchmark] | |
public async Task<List<Blog>> Linq2Db() | |
{ | |
await using var db = new Blogging(); | |
var sql = db.Blog.LoadWithAsTable(static b => b.Posts); | |
var blogs = await AsyncExtensions.ToListAsync(sql, Cancellation); | |
return blogs; | |
} | |
[GlobalCleanup] | |
public void Dispose() | |
{ | |
_dapper?.Dispose(); | |
} | |
[GlobalSetup] | |
public void Setup() | |
{ | |
var random = new Random(123); | |
_dapper = NpgsqlDataSource.Create(ConnectionString); | |
_entityFramework = new PooledDbContextFactory<BloggingContext>( | |
new DbContextOptionsBuilder<BloggingContext>() | |
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) | |
.UseNpgsql(ConnectionString) | |
.Options); | |
using var efContext = _entityFramework.CreateDbContext(); | |
var blogs = efContext.Blogs; | |
var posts = efContext.Posts; | |
if (blogs.Count() == BlogCount) | |
{ | |
Console.WriteLine("Data already exists"); | |
return; | |
} | |
posts | |
.Where(p => p.IsDeleted == false) | |
.ExecuteDelete(); | |
blogs | |
.Where(b => b.IsDeleted == false) | |
.ExecuteDelete(); | |
efContext.SaveChanges(); | |
for (var blog = 0; blog < BlogCount; blog++) | |
{ | |
var blogId = Guid.NewGuid(); | |
blogs.Add(new Blog | |
{ | |
Id = blogId, | |
IsDeleted = false, | |
Rating = random.Next(), | |
Url = CreateRandomString(random, 128) | |
}); | |
for (var post = 0; post < PostPerBlogCount; post++) | |
{ | |
posts.Add(new Post | |
{ | |
Id = Guid.NewGuid(), | |
BlogId = blogId, | |
Content = CreateRandomString(random, 1024), | |
IsDeleted = false, | |
Title = CreateRandomString(random, 128), | |
}); | |
} | |
} | |
efContext.SaveChanges(); | |
} | |
private static string CreateRandomString(Random random, int length) | |
{ | |
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; | |
Span<char> buffer = stackalloc char[length]; | |
random.GetItems(chars, buffer); | |
return buffer[..length].ToString(); | |
} | |
} | |
public sealed class BloggingContext : DbContext | |
{ | |
public DbSet<Blog> Blogs { get; set; } | |
public DbSet<Post> Posts { get; set; } | |
public BloggingContext() : this(new DbContextOptionsBuilder<BloggingContext>() | |
.UseNpgsql(DbReadBench.ConnectionString).Options) | |
{ | |
} | |
public BloggingContext(DbContextOptions<BloggingContext> options) : base(options) | |
{ | |
} | |
} | |
public sealed class Blogging : LinqToDB.Data.DataConnection | |
{ | |
public Blogging() : base(new DataOptions().UsePostgreSQL(DbReadBench.ConnectionString)) { } | |
public ITable<Blog> Blog => this.GetTable<Blog>(); | |
public ITable<Post> Post => this.GetTable<Post>(); | |
} | |
[LinqToDB.Mapping.Table("Blogs")] | |
public sealed class Blog | |
{ | |
[LinqToDB.Mapping.PrimaryKey, LinqToDB.Mapping.Identity, LinqToDB.Mapping.Column] | |
public required Guid Id { get; set; } | |
[LinqToDB.Mapping.Column] | |
public required int Rating { get; set; } | |
[LinqToDB.Mapping.Column, MaxLength(256)] | |
public required string Url { get; set; } | |
[LinqToDB.Mapping.Column] | |
public required bool IsDeleted { get; set; } | |
[LinqToDB.Mapping.Association(ThisKey = nameof(Id), OtherKey = nameof(Post.BlogId))] | |
public List<Post> Posts { get; set; } = []; | |
} | |
[LinqToDB.Mapping.Table("Posts")] | |
public sealed class Post | |
{ | |
[LinqToDB.Mapping.PrimaryKey, LinqToDB.Mapping.Identity, LinqToDB.Mapping.Column] | |
public required Guid Id { get; set; } | |
[LinqToDB.Mapping.Column, MaxLength(2048)] | |
public required string Content { get; set; } | |
[LinqToDB.Mapping.Column, MaxLength(256)] | |
public required string Title { get; set; } | |
[LinqToDB.Mapping.Column] | |
public required bool IsDeleted { get; set; } | |
[LinqToDB.Mapping.Column] | |
public required Guid BlogId { get; set; } | |
[LinqToDB.Mapping.Association(ThisKey = nameof(BlogId), OtherKey=nameof(Blog.Id))] | |
public Blog Blog { get; set; } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment