Last active
January 16, 2019 14:21
-
-
Save otto-gebb/65cf0efacf4daed8f4ade91295ce865a to your computer and use it in GitHub Desktop.
This file contains 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
#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