diff --git a/src/ApplicationCore/Helpers/Query/IncludeAggregator.cs b/src/ApplicationCore/Helpers/Query/IncludeAggregator.cs new file mode 100644 index 0000000..fe3c26b --- /dev/null +++ b/src/ApplicationCore/Helpers/Query/IncludeAggregator.cs @@ -0,0 +1,26 @@ +using Microsoft.eShopWeb.ApplicationCore.Interfaces; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace Microsoft.eShopWeb.ApplicationCore.Helpers.Query +{ + public class IncludeAggregator + { + public IncludeQuery Include(Expression> selector) + { + var visitor = new IncludeVisitor(); + visitor.Visit(selector); + + var pathMap = new Dictionary(); + var query = new IncludeQuery(pathMap); + + if (!string.IsNullOrEmpty(visitor.Path)) + { + pathMap[query] = visitor.Path; + } + + return query; + } + } +} diff --git a/src/ApplicationCore/Helpers/Query/IncludeQuery.cs b/src/ApplicationCore/Helpers/Query/IncludeQuery.cs new file mode 100644 index 0000000..e8c9c9d --- /dev/null +++ b/src/ApplicationCore/Helpers/Query/IncludeQuery.cs @@ -0,0 +1,19 @@ +using Microsoft.eShopWeb.ApplicationCore.Interfaces; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.eShopWeb.ApplicationCore.Helpers.Query +{ + public class IncludeQuery : IIncludeQuery + { + public Dictionary PathMap { get; } = new Dictionary(); + public IncludeVisitor Visitor { get; } = new IncludeVisitor(); + + public IncludeQuery(Dictionary pathMap) + { + PathMap = pathMap; + } + + public HashSet Paths => PathMap.Select(x => x.Value).ToHashSet(); + } +} diff --git a/src/ApplicationCore/Helpers/Query/IncludeQueryExtensions.cs b/src/ApplicationCore/Helpers/Query/IncludeQueryExtensions.cs new file mode 100644 index 0000000..60c154a --- /dev/null +++ b/src/ApplicationCore/Helpers/Query/IncludeQueryExtensions.cs @@ -0,0 +1,66 @@ +using Microsoft.eShopWeb.ApplicationCore.Interfaces; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace Microsoft.eShopWeb.ApplicationCore.Helpers.Query +{ + public static class IncludeQueryExtensions + { + public static IIncludeQuery Include( + this IIncludeQuery query, + Expression> selector) + { + query.Visitor.Visit(selector); + + var includeQuery = new IncludeQuery(query.PathMap); + query.PathMap[includeQuery] = query.Visitor.Path; + + return includeQuery; + } + + public static IIncludeQuery ThenInclude( + this IIncludeQuery query, + Expression> selector) + { + query.Visitor.Visit(selector); + + // If the visitor did not generated a path, return a new IncludeQuery with an unmodified PathMap. + if (string.IsNullOrEmpty(query.Visitor.Path)) + { + return new IncludeQuery(query.PathMap); + } + + var pathMap = query.PathMap; + var existingPath = pathMap[query]; + pathMap.Remove(query); + + var includeQuery = new IncludeQuery(query.PathMap); + pathMap[includeQuery] = $"{existingPath}.{query.Visitor.Path}"; + + return includeQuery; + } + + public static IIncludeQuery ThenInclude( + this IIncludeQuery> query, + Expression> selector) + { + query.Visitor.Visit(selector); + + // If the visitor did not generated a path, return a new IncludeQuery with an unmodified PathMap. + if (string.IsNullOrEmpty(query.Visitor.Path)) + { + return new IncludeQuery(query.PathMap); + } + + var pathMap = query.PathMap; + var existingPath = pathMap[query]; + pathMap.Remove(query); + + var includeQuery = new IncludeQuery(query.PathMap); + pathMap[includeQuery] = $"{existingPath}.{query.Visitor.Path}"; + + return includeQuery; + } + } +} diff --git a/src/ApplicationCore/Helpers/Query/IncludeVisitor.cs b/src/ApplicationCore/Helpers/Query/IncludeVisitor.cs new file mode 100644 index 0000000..f6be55f --- /dev/null +++ b/src/ApplicationCore/Helpers/Query/IncludeVisitor.cs @@ -0,0 +1,16 @@ +using System.Linq.Expressions; + +namespace Microsoft.eShopWeb.ApplicationCore.Helpers.Query +{ + public class IncludeVisitor : ExpressionVisitor + { + public string Path { get; private set; } = string.Empty; + + protected override Expression VisitMember(MemberExpression node) + { + Path = string.IsNullOrEmpty(Path) ? node.Member.Name : $"{node.Member.Name}.{Path}"; + + return base.VisitMember(node); + } + } +} diff --git a/src/ApplicationCore/Interfaces/IIncludeQuery.cs b/src/ApplicationCore/Interfaces/IIncludeQuery.cs new file mode 100644 index 0000000..9046cd6 --- /dev/null +++ b/src/ApplicationCore/Interfaces/IIncludeQuery.cs @@ -0,0 +1,16 @@ +using Microsoft.eShopWeb.ApplicationCore.Helpers.Query; +using System.Collections.Generic; + +namespace Microsoft.eShopWeb.ApplicationCore.Interfaces +{ + public interface IIncludeQuery + { + Dictionary PathMap { get; } + IncludeVisitor Visitor { get; } + HashSet Paths { get; } + } + + public interface IIncludeQuery : IIncludeQuery + { + } +} diff --git a/src/ApplicationCore/Specifications/BaseSpecification.cs b/src/ApplicationCore/Specifications/BaseSpecification.cs index 2b440e5..16d65e5 100644 --- a/src/ApplicationCore/Specifications/BaseSpecification.cs +++ b/src/ApplicationCore/Specifications/BaseSpecification.cs @@ -2,6 +2,7 @@ using System; using System.Linq.Expressions; using System.Collections.Generic; +using Microsoft.eShopWeb.ApplicationCore.Helpers.Query; namespace Microsoft.eShopWeb.ApplicationCore.Specifications { @@ -26,6 +27,13 @@ namespace Microsoft.eShopWeb.ApplicationCore.Specifications { Includes.Add(includeExpression); } + + protected virtual void AddIncludes(Func, IIncludeQuery> includeGenerator) + { + var includeQuery = includeGenerator(new IncludeAggregator()); + IncludeStrings.AddRange(includeQuery.Paths); + } + protected virtual void AddInclude(string includeString) { IncludeStrings.Add(includeString); diff --git a/src/ApplicationCore/Specifications/CustomerOrdersWithItemsSpecification.cs b/src/ApplicationCore/Specifications/CustomerOrdersWithItemsSpecification.cs index d49b78a..662ec4e 100644 --- a/src/ApplicationCore/Specifications/CustomerOrdersWithItemsSpecification.cs +++ b/src/ApplicationCore/Specifications/CustomerOrdersWithItemsSpecification.cs @@ -1,4 +1,5 @@ using Microsoft.eShopWeb.ApplicationCore.Entities.OrderAggregate; +using Microsoft.eShopWeb.ApplicationCore.Helpers.Query; namespace Microsoft.eShopWeb.ApplicationCore.Specifications { @@ -7,8 +8,7 @@ namespace Microsoft.eShopWeb.ApplicationCore.Specifications public CustomerOrdersWithItemsSpecification(string buyerId) : base(o => o.BuyerId == buyerId) { - AddInclude(o => o.OrderItems); - AddInclude($"{nameof(Order.OrderItems)}.{nameof(OrderItem.ItemOrdered)}"); + AddIncludes(query => query.Include(o => o.OrderItems).ThenInclude(i => i.ItemOrdered)); } } } diff --git a/tests/UnitTests/ApplicationCore/Helpers/Query/Book.cs b/tests/UnitTests/ApplicationCore/Helpers/Query/Book.cs new file mode 100644 index 0000000..97bab4f --- /dev/null +++ b/tests/UnitTests/ApplicationCore/Helpers/Query/Book.cs @@ -0,0 +1,16 @@ +using System; + +namespace Microsoft.eShopWeb.UnitTests.ApplicationCore.Helpers.Query +{ + public class Book + { + public string Title { get; set; } + public DateTime PublishingDate { get; set; } + public Person Author { get; set; } + + public int GetNumberOfSales() + { + return 0; + } + } +} diff --git a/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeAggregatorTests/Include.cs b/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeAggregatorTests/Include.cs new file mode 100644 index 0000000..53abf08 --- /dev/null +++ b/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeAggregatorTests/Include.cs @@ -0,0 +1,48 @@ +using Microsoft.eShopWeb.ApplicationCore.Helpers.Query; +using Xunit; + +namespace Microsoft.eShopWeb.UnitTests.ApplicationCore.Helpers.Query.IncludeAggregatorTests +{ + public class Include + { + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeSimpleType() + { + var includeAggregator = new IncludeAggregator(); + + // There may be ORM libraries where including a simple type makes sense. + var includeQuery = includeAggregator.Include(p => p.Age); + + Assert.Contains(includeQuery.Paths, path => path == nameof(Person.Age)); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeFunction() + { + var includeAggregator = new IncludeAggregator(); + + // This include does not make much sense, but it should at least do not modify the paths. + var includeQuery = includeAggregator.Include(p => p.FavouriteBook.GetNumberOfSales()); + + Assert.Contains(includeQuery.Paths, path => path == nameof(Person.FavouriteBook)); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeObject() + { + var includeAggregator = new IncludeAggregator(); + var includeQuery = includeAggregator.Include(p => p.FavouriteBook.Author); + + Assert.Contains(includeQuery.Paths, path => path == $"{nameof(Person.FavouriteBook)}.{nameof(Book.Author)}"); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeCollection() + { + var includeAggregator = new IncludeAggregator(); + var includeQuery = includeAggregator.Include(o => o.Author.Friends); + + Assert.Contains(includeQuery.Paths, path => path == $"{nameof(Book.Author)}.{nameof(Person.Friends)}"); + } + } +} diff --git a/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeQueryTests/Include.cs b/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeQueryTests/Include.cs new file mode 100644 index 0000000..2f95b1f --- /dev/null +++ b/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeQueryTests/Include.cs @@ -0,0 +1,80 @@ +using Microsoft.eShopWeb.ApplicationCore.Helpers.Query; +using Microsoft.eShopWeb.UnitTests.Builders; +using Xunit; + +namespace Microsoft.eShopWeb.UnitTests.ApplicationCore.Helpers.Query.IncludeQueryTests +{ + public class Include + { + private IncludeQueryBuilder _includeQueryBuilder = new IncludeQueryBuilder(); + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeSimpleType() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + + // There may be ORM libraries where including a simple type makes sense. + var newIncludeQuery = includeQuery.Include(b => b.Title); + + Assert.Contains(newIncludeQuery.Paths, path => path == nameof(Book.Title)); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeFunction() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + + // This include does not make much sense, but it should at least do not modify paths. + var newIncludeQuery = includeQuery.Include(b => b.GetNumberOfSales()); + + // The resulting paths should not include number of sales. + Assert.DoesNotContain(newIncludeQuery.Paths, path => path == nameof(Book.GetNumberOfSales)); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeObject() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + var newIncludeQuery = includeQuery.Include(b => b.Author); + + Assert.Contains(newIncludeQuery.Paths, path => path == nameof(Book.Author)); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeCollection() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + + var newIncludeQuery = includeQuery.Include(b => b.Author.Friends); + var expectedPath = $"{nameof(Book.Author)}.{nameof(Person.Friends)}"; + + Assert.Contains(newIncludeQuery.Paths, path => path == expectedPath); + } + + [Fact] + public void Should_IncreaseNumberOfPathsByOne() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + var numberOfPathsBeforeInclude = includeQuery.Paths.Count; + + var newIncludeQuery = includeQuery.Include(b => b.Author.Friends); + var numberOfPathsAferInclude = newIncludeQuery.Paths.Count; + + var expectedNumerOfPaths = numberOfPathsBeforeInclude + 1; + + Assert.Equal(expectedNumerOfPaths, numberOfPathsAferInclude); + } + + [Fact] + public void Should_NotModifyAnotherPath() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + var pathsBeforeInclude = includeQuery.Paths; + + var newIncludeQuery = includeQuery.Include(b => b.Author.Friends); + var pathsAfterInclude = newIncludeQuery.Paths; + + Assert.Subset(pathsAfterInclude, pathsBeforeInclude); + } + } +} diff --git a/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeQueryTests/ThenInclude.cs b/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeQueryTests/ThenInclude.cs new file mode 100644 index 0000000..e6e30d5 --- /dev/null +++ b/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeQueryTests/ThenInclude.cs @@ -0,0 +1,78 @@ +using Microsoft.eShopWeb.ApplicationCore.Helpers.Query; +using Microsoft.eShopWeb.UnitTests.Builders; +using System.Linq; +using Xunit; + +namespace Microsoft.eShopWeb.UnitTests.ApplicationCore.Helpers.Query.IncludeQueryTests +{ + public class ThenInclude + { + private IncludeQueryBuilder _includeQueryBuilder = new IncludeQueryBuilder(); + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeSimpleType() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + var pathBeforeInclude = includeQuery.Paths.First(); + + // There may be ORM libraries where including a simple type makes sense. + var newIncludeQuery = includeQuery.ThenInclude(p => p.Age); + var pathAfterInclude = newIncludeQuery.Paths.First(); + var expectedPath = $"{pathBeforeInclude}.{nameof(Person.Age)}"; + + Assert.Equal(expectedPath, pathAfterInclude); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeFunction() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + var pathBeforeInclude = includeQuery.Paths.First(); + + // This include does not make much sense, but it should at least not modify the paths. + var newIncludeQuery = includeQuery.ThenInclude(p => p.GetQuote()); + var pathAfterInclude = newIncludeQuery.Paths.First(); + + Assert.Equal(pathBeforeInclude, pathAfterInclude); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeObject() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + var pathBeforeInclude = includeQuery.Paths.First(); + + var newIncludeQuery = includeQuery.ThenInclude(p => p.FavouriteBook); + var pathAfterInclude = newIncludeQuery.Paths.First(); + var expectedPath = $"{pathBeforeInclude}.{nameof(Person.FavouriteBook)}"; + + Assert.Equal(expectedPath, pathAfterInclude); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludeCollection() + { + var includeQuery = _includeQueryBuilder.WithObjectAsPreviousProperty(); + var pathBeforeInclude = includeQuery.Paths.First(); + + var newIncludeQuery = includeQuery.ThenInclude(p => p.Friends); + var pathAfterInclude = newIncludeQuery.Paths.First(); + var expectedPath = $"{pathBeforeInclude}.{nameof(Person.Friends)}"; + + Assert.Equal(expectedPath, pathAfterInclude); + } + + [Fact] + public void Should_ReturnIncludeQueryWithCorrectPath_IfIncludePropertyOverCollection() + { + var includeQuery = _includeQueryBuilder.WithCollectionAsPreviousProperty(); + var pathBeforeInclude = includeQuery.Paths.First(); + + var newIncludeQuery = includeQuery.ThenInclude(p => p.FavouriteBook); + var pathAfterInclude = newIncludeQuery.Paths.First(); + var expectedPath = $"{pathBeforeInclude}.{nameof(Person.FavouriteBook)}"; + + Assert.Equal(expectedPath, pathAfterInclude); + } + } +} diff --git a/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeVisitorTests/Visit.cs b/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeVisitorTests/Visit.cs new file mode 100644 index 0000000..58f0157 --- /dev/null +++ b/tests/UnitTests/ApplicationCore/Helpers/Query/IncludeVisitorTests/Visit.cs @@ -0,0 +1,55 @@ +using Microsoft.eShopWeb.ApplicationCore.Helpers.Query; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Xunit; + +namespace Microsoft.eShopWeb.UnitTests.ApplicationCore.Helpers.Query.IncludeVisitorTests +{ + public class Visit + { + [Fact] + public void Should_SetPath_IfPassedExpressionWithSimpleType() + { + var visitor = new IncludeVisitor(); + Expression> expression = (book) => book.Author.FirstName; + visitor.Visit(expression); + + var expectedPath = $"{nameof(Book.Author)}.{nameof(Person.FirstName)}"; + Assert.Equal(expectedPath, visitor.Path); + } + + [Fact] + public void Should_SetPath_IfPassedExpressionWithObject() + { + var visitor = new IncludeVisitor(); + Expression> expression = (book) => book.Author.FavouriteBook; + visitor.Visit(expression); + + var expectedPath = $"{nameof(Book.Author)}.{nameof(Person.FavouriteBook)}"; + Assert.Equal(expectedPath, visitor.Path); + } + + [Fact] + public void Should_SetPath_IfPassedExpressionWithCollection() + { + var visitor = new IncludeVisitor(); + Expression>> expression = (book) => book.Author.Friends; + visitor.Visit(expression); + + var expectedPath = $"{nameof(Book.Author)}.{nameof(Person.Friends)}"; + Assert.Equal(expectedPath, visitor.Path); + } + + [Fact] + public void Should_SetPath_IfPassedExpressionWithFunction() + { + var visitor = new IncludeVisitor(); + Expression> expression = (book) => book.Author.GetQuote(); + visitor.Visit(expression); + + var expectedPath = nameof(Book.Author); + Assert.Equal(expectedPath, visitor.Path); + } + } +} diff --git a/tests/UnitTests/ApplicationCore/Helpers/Query/Person.cs b/tests/UnitTests/ApplicationCore/Helpers/Query/Person.cs new file mode 100644 index 0000000..bcbb2c3 --- /dev/null +++ b/tests/UnitTests/ApplicationCore/Helpers/Query/Person.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Microsoft.eShopWeb.UnitTests.ApplicationCore.Helpers.Query +{ + public class Person + { + public int Age { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + + public Book FavouriteBook { get; set; } + public List Friends { get; set; } + + public string GetQuote() + { + return string.Empty; + } + } +} diff --git a/tests/UnitTests/Builders/IncludeQueryBuilder.cs b/tests/UnitTests/Builders/IncludeQueryBuilder.cs new file mode 100644 index 0000000..4657d65 --- /dev/null +++ b/tests/UnitTests/Builders/IncludeQueryBuilder.cs @@ -0,0 +1,29 @@ +using Microsoft.eShopWeb.ApplicationCore.Helpers.Query; +using Microsoft.eShopWeb.ApplicationCore.Interfaces; +using Microsoft.eShopWeb.UnitTests.ApplicationCore.Helpers.Query; +using System; +using System.Collections.Generic; + +namespace Microsoft.eShopWeb.UnitTests.Builders +{ + public class IncludeQueryBuilder + { + public IncludeQuery> WithCollectionAsPreviousProperty() + { + var pathMap = new Dictionary(); + var query = new IncludeQuery>(pathMap); + pathMap[query] = nameof(Person.Friends); + + return query; + } + + public IncludeQuery WithObjectAsPreviousProperty() + { + var pathMap = new Dictionary(); + var query = new IncludeQuery(pathMap); + pathMap[query] = nameof(Book.Author); + + return query; + } + } +}