Skip to content

Instantly share code, notes, and snippets.

@otto-gebb
Last active January 16, 2019 14:21
Show Gist options
  • Save otto-gebb/65cf0efacf4daed8f4ade91295ce865a to your computer and use it in GitHub Desktop.
Save otto-gebb/65cf0efacf4daed8f4ade91295ce865a to your computer and use it in GitHub Desktop.
#pragma warning disable 1591
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using DomainDbHelpers.Domain;
using DomainDbHelpers.DomainHelpers;
using DomainDbHelpers.Persistence;
using DomainDbHelpers.PersistenceHelpers;
using LinqKit;
using Microsoft.EntityFrameworkCore;
/*
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<LangVersion>8.0</LangVersion>
<NullableReferenceTypes>true</NullableReferenceTypes>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LinqKit.Core" Version="1.1.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="2.2.0" />
</ItemGroup>
</Project>
*/
namespace DomainDbHelpers
{
class Program
{
static void Main(string[] args)
{
QueryableExtensions.UnsafeConfigureNotFoundException(
(type, spec) => new ResourceNotFoundException(
$"Entity '{type.Name}' was not found by spec {spec}."
));
DateTimeOffset time = DateTimeOffset.UtcNow;
using (var db = new BloggingContext())
{
if (db.Database.EnsureCreated())
{
for (int i = 0; i < 10; i++)
{
var created = time.AddDays(-i);
Blog blog = new Blog($"url{i}", createdAt: created);
Post post = blog.CreatePost("title", "content", created);
db.Blogs!.Add(blog);
db.Posts!.Add(post);
}
db.SaveChanges();
}
}
using (var db = new BloggingContext())
{
var q = new Domain.BlogPostSearchQuery
{
CreatedBefore = time.AddDays(-5)
};
Page<Post> posts = db.Posts!
.GetPage(Post.MatchesSearchCriteria(q), 10, 2)
.GetAwaiter()
.GetResult();
Console.WriteLine(
$"Found {posts.TotalCount} posts, "
+ $"page number {posts.PageNumber}, page size {posts.PageSize}.");
q = new Domain.BlogPostSearchQuery
{
Title = "not_found"
};
try
{
Post post =
db.Posts!.GetOne(Post.MatchesSearchCriteria(q)).GetAwaiter().GetResult();
}
catch (ResourceNotFoundException ex)
{
Console.WriteLine(ex.Message);
}
}
}
}
public class ResourceNotFoundException : Exception
{
public ResourceNotFoundException(string message) : base(message)
{
}
}
}
namespace DomainDbHelpers.Domain
{
public class BlogPostSearchQuery
{
public string? BlogUrl { get; set; }
public string? Title { get; set; }
public DateTimeOffset? CreatedBefore { get; set; }
}
public class Blog
{
public Blog(string url, DateTimeOffset createdAt)
{
Url = url;
BlogId = Guid.NewGuid();
Posts = new List<Post>();
CreatedAt = createdAt;
}
protected Blog()
{
Url = "";
}
public Guid BlogId { get; protected set; }
public string Url { get; protected set; }
public DateTimeOffset CreatedAt { get; protected set; }
public List<Post>? Posts { get; protected set; }
internal Post CreatePost(string title, string content, DateTimeOffset createdAt)
{
return new Post(this, title, content, createdAt);
}
}
public class Post
{
public Post(Blog blog, string title, string content, DateTimeOffset createdAt)
{
PostId = Guid.NewGuid();
Blog = blog;
BlogId = blog.BlogId;
Title = title;
Content = content;
CreatedAt = createdAt;
}
protected Post()
{
// Fill with non-null values to persuade null checker that these props are loaded from DB.
Title = "";
Content = "";
}
public static Specification<Post> MatchesSearchCriteria(BlogPostSearchQuery q) {
var pred = Linq.Expr((Post post) => true);
if (q.BlogUrl != null)
{
pred = pred.And(x => x.Blog!.Url.Contains(q.BlogUrl));
}
if (q.CreatedBefore != null)
{
pred = pred.And(x => x.CreatedAt < q.CreatedBefore);
}
if (q.Title != null)
{
pred = pred.And(x => x.Title.Contains(q.Title));
}
return new Specification<Post>(nameof(MatchesSearchCriteria), pred.Expand());
}
public Guid PostId { get; protected set; }
public string Title { get; protected set; }
public string Content { get; protected set; }
public DateTimeOffset CreatedAt { get; protected set; }
public Guid BlogId { get; protected set; }
public Blog? Blog { get; protected set; }
}
}
namespace DomainDbHelpers.Persistence
{
public class BloggingContext : DbContext
{
// To run Postgres locally, execute
// docker run -it --rm -p 5432:5432 postgres
private const string _connectionString =
"Host=localhost;Database=tmp_db;Username=postgres;Password=postgres";
public DbSet<Blog>? Blogs { get; set; }
public DbSet<Post>? Posts { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(_connectionString);
}
}
}
namespace DomainDbHelpers.DomainHelpers
{
/// <summary>
/// Represents a filter over entities.
/// </summary>
/// <typeparam name="TEntity">
/// The entity type.
/// </typeparam>
public class Specification<TEntity>
{
/// <summary>
/// Initializes a new instance of the <see cref="Specification{TEntity}"/> class.
/// </summary>
/// <param name="name">The name of this specification (for better diagnostic).</param>
/// <param name="predicate">The predicate.</param>
/// <param name="parameters">The parameters.</param>
public Specification(
string name,
Expression<Func<TEntity, bool>> predicate,
params object[] parameters)
{
Name = name;
Parameters = parameters;
Predicate = predicate;
}
/// <summary>
/// Initializes a new instance of the <see cref="Specification{TEntity}"/> class.
/// </summary>
protected Specification()
{
Name = GetType().Name;
Parameters = new object[0];
Predicate = e => false;
}
/// <summary>
/// Gets the name of this specification.
/// </summary>
public string Name { get; protected set; }
/// <summary>
/// Gets the predicate which returns true
/// for entities satisfying this specification.
/// </summary>
public Expression<Func<TEntity, bool>> Predicate { get; protected set; }
/// <summary>
/// Gets the parameters of this specification.
/// </summary>
protected object[] Parameters { get; private set; }
/// <summary>
/// Returns a string that represents the current object.
/// </summary>
/// <returns>
/// A string that represents the current object.
/// </returns>
public override string ToString()
{
string parameters = string.Join(", ", Parameters.Select(x => x ?? "(null)"));
return $"{Name}({parameters})";
}
}
}
namespace DomainDbHelpers.PersistenceHelpers
{
/// <summary>
/// A contigous subsequence of entities taken at a certain offset from results of a query.
/// </summary>
/// <typeparam name="TEntity">The entity type.</typeparam>
public class Page<TEntity>
{
/// <summary>
/// Initializes a new instance of the <see cref="Page{TEntity}"/> class.
/// </summary>
/// <param name="totalCount">
/// The total number of entities satisfying the query specifications.
/// </param>
/// <param name="pageNumber">
/// The number of the actually returned page.
/// </param>
/// <param name="pageSize">
/// The size of the actually returned page (if too big one was requested).
/// </param>
/// <param name="entities">
/// The collection of entities belonging to the requested page.
/// </param>
public Page(
int totalCount,
int pageNumber,
int pageSize,
TEntity[] entities)
{
if (totalCount < 0)
{
throw new ArgumentOutOfRangeException(
nameof(totalCount),
$"The count was out of range. TotalCount={totalCount}.");
}
if (entities == null)
{
throw new ArgumentNullException(nameof(entities));
}
TotalCount = totalCount;
PageSize = pageSize;
PageNumber = pageNumber;
Entities = entities;
}
/// <summary>
/// Gets the total number of entities in the database that satisfy the query conditions.
/// </summary>
public int TotalCount { get; private set; }
/// <summary>
/// Gets the number of the actually returned page.
/// </summary>
public int PageNumber { get; private set; }
/// <summary>
/// Gets the size of the actually returned page (if too big one was requested).
/// </summary>
public int PageSize { get; private set; }
/// <summary>
/// Gets the collection of entities belonging to the requested page.
/// </summary>
public TEntity[] Entities { get; private set; }
}
public static class QueryableExtensions
{
/// <summary>
/// Creates a "not found" exception based on the specified entity type and specification.
/// </summary>
private static Func<Type, string, Exception> _createException = DefaultExceptionFactory;
public static void UnsafeConfigureNotFoundException(
Func<Type, string, Exception> exceptionFactory)
{
_createException = exceptionFactory;
}
private static Exception DefaultExceptionFactory(Type entityType, string specification)
{
return new InvalidOperationException(
$"The entity of type '{entityType.Name}' was not found. "
+ $"Specification: {specification}.");
}
/// <summary>
/// Gets the single entity based on the specification.
/// Throws if the entity is not found.
/// Throws if more than one entity satisfies the specification.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <param name="repository">The entity repository.</param>
/// <param name="spec">The specification.</param>
/// <returns>The entity satisfying the specification.</returns>
public static async Task<T> GetOne<T>(
this IQueryable<T> repository,
Specification<T> spec)
where T: class
{
T entity = await repository.GetOneOrDefault(spec);
if (entity == null)
{
throw _createException(typeof(T), spec.ToString());
}
return entity;
}
/// <summary>
/// Gets the single entity based on the specification.
/// Returns <c>null</c> if the entity is not found.
/// Throws if more than one entity satisfies the specification.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <param name="repository">The entity repository.</param>
/// <param name="spec">The specification.</param>
/// <returns>
/// The entity satisfying the specification or <c>null</c> if no such entity exists.
/// </returns>
public static async Task<T> GetOneOrDefault<T>(
this IQueryable<T> repository,
Specification<T> spec)
where T: class
{
return await repository.SingleOrDefaultAsync(spec.Predicate);
}
/// <summary>
/// Gets a list of entities based on the specification.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <param name="repository">The entity repository.</param>
/// <param name="spec">The specification.</param>
/// <returns>The entities satisfying the specification.</returns>
public static async Task<List<T>> Get<T>(
this IQueryable<T> repository,
Specification<T> spec)
where T: class
{
return await repository.Where(spec.Predicate).ToListAsync();
}
/// <summary>
/// Gets a page of entities based on the specification. The returned page number and size
/// may differ from requested.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <param name="repository">The entity repository.</param>
/// <param name="spec">The specification to filter entities.</param>
/// <param name="pageNumber">The requested page number.</param>
/// <param name="pageSize">The requested page size.</param>
/// <param name="maxPageSize">The maximum allowed page size.</param>
/// <returns>The page of entities.</returns>
public static async Task<Page<T>> GetPage<T>(
this IQueryable<T> repository,
Specification<T> spec,
int pageNumber,
int pageSize,
int maxPageSize = 100)
where T: class
{
int count = await repository.CountAsync();
(int realPageNumber, int realPageSize) =
GetRealPageNumberAndSize(pageNumber, pageSize, count, maxPageSize);
T[] entities = await repository
.Skip((realPageNumber - 1) * realPageSize)
.Take(realPageSize)
.ToArrayAsync();
return new Page<T>(count, realPageNumber, realPageSize, entities);
}
/// <summary>
/// Clips the desired page number and size to the allowed range, returning
/// the last available page number, if the requested one is too big, and the corrected
/// page size.
/// </summary>
/// <param name="requestedPageNumber">The page number requested by the caller.</param>
/// <param name="count">The total count of records satisfying the query in the DB.</param>
/// <param name="maxPageSize">The maximum allowed page size.</param>
/// <param name="requestedPageSize">The page size.</param>
/// <returns>
/// The pair (real page number, real page size).
/// </returns>
private static (int, int) GetRealPageNumberAndSize(
int requestedPageNumber,
int requestedPageSize,
int count,
int maxPageSize)
{
int pageSize = requestedPageSize > maxPageSize
? maxPageSize
: requestedPageSize < 1 ? 1 : requestedPageSize;
int totalPages = count / pageSize;
if (totalPages * pageSize < count || count == 0)
{
totalPages += 1;
}
int pageNumber = requestedPageNumber < 1
? 1
: requestedPageNumber > totalPages ? totalPages : requestedPageNumber;
return (pageNumber, pageSize);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment