diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/QueryTagEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/QueryTagEvaluator.cs new file mode 100644 index 00000000..5066f881 --- /dev/null +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/QueryTagEvaluator.cs @@ -0,0 +1,28 @@ +namespace Pozitron.QuerySpecification; + +/// +/// Evaluator to apply the query tags to the query. +/// +public sealed class QueryTagEvaluator : IEvaluator +{ + + /// + /// Gets the singleton instance of the class. + /// + public static QueryTagEvaluator Instance = new(); + private QueryTagEvaluator() { } + + /// + public IQueryable Evaluate(IQueryable source, Specification specification) where T : class + { + foreach (var item in specification.Items) + { + if (item.Type == ItemType.QueryTag && item.Reference is string tag) + { + source = source.TagWith(tag); + } + } + + return source; + } +} diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs index 9b9590ae..aa3ab776 100644 --- a/src/QuerySpecification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs @@ -33,6 +33,7 @@ public SpecificationEvaluator() AsTrackingEvaluator.Instance, AsSplitQueryEvaluator.Instance, IgnoreAutoIncludesEvaluator.Instance, + QueryTagEvaluator.Instance, ]; } diff --git a/src/QuerySpecification/Builders/Builder_TagWith.cs b/src/QuerySpecification/Builders/Builder_TagWith.cs new file mode 100644 index 00000000..375598ba --- /dev/null +++ b/src/QuerySpecification/Builders/Builder_TagWith.cs @@ -0,0 +1,75 @@ +namespace Pozitron.QuerySpecification; + +public static partial class SpecificationBuilderExtensions +{ + /// + /// Adds a query tag to the specification. + /// + /// The type of the entity. + /// The type of the result. + /// The specification builder. + /// The query tag. + /// The updated specification builder. + public static ISpecificationBuilder TagWith( + this ISpecificationBuilder builder, + string tag) + { + TagWith(builder, tag, true); + return builder; + } + + /// + /// Adds a query tag to the specification if the condition is true. + /// + /// The type of the entity. + /// The type of the result. + /// The specification builder. + /// The query tag. + /// The condition to evaluate. + /// The updated specification builder. + public static ISpecificationBuilder TagWith( + this ISpecificationBuilder builder, + string tag, + bool condition) + { + if (condition) + { + builder.Specification.AddInternal(ItemType.QueryTag, tag); + } + + return builder; + } + + /// + /// Adds a query tag to the specification. + /// + /// The type of the entity. + /// The specification builder. + /// The query tag. + /// The updated specification builder. + public static ISpecificationBuilder TagWith( + this ISpecificationBuilder builder, + string tag) + => TagWith(builder, tag, true); + + /// + /// Adds a query tag to the specification if the condition is true. + /// + /// The type of the entity. + /// The specification builder. + /// The query tag. + /// The condition to evaluate. + /// The updated specification builder. + public static ISpecificationBuilder TagWith( + this ISpecificationBuilder builder, + string tag, + bool condition) + { + if (condition) + { + builder.Specification.AddInternal(ItemType.QueryTag, tag); + } + + return builder; + } +} diff --git a/src/QuerySpecification/Internals/ItemType.cs b/src/QuerySpecification/Internals/ItemType.cs index 4cc0421d..07345ac5 100644 --- a/src/QuerySpecification/Internals/ItemType.cs +++ b/src/QuerySpecification/Internals/ItemType.cs @@ -13,4 +13,6 @@ internal static class ItemType // We can save 16 bytes (on x64) by storing both Flags and Paging in the same item. public const int Paging = -8; // Stored in the reference public const int Flags = -8; // Stored in the bag + + public const int QueryTag = -9; } diff --git a/src/QuerySpecification/Specification.cs b/src/QuerySpecification/Specification.cs index 014e4bc0..25db04a9 100644 --- a/src/QuerySpecification/Specification.cs +++ b/src/QuerySpecification/Specification.cs @@ -176,6 +176,13 @@ public virtual bool IsSatisfiedBy(T entity) ? Enumerable.Empty() : new SpecSelectIterator(_items, ItemType.IncludeString, (x, bag) => x); + /// + /// Gets the Query tags. + /// + public IEnumerable QueryTags => _items is null + ? Enumerable.Empty() + : new SpecSelectIterator(_items, ItemType.QueryTag, (x, bag) => x); + /// /// Gets the number of items to take. /// diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/QueryTagEvaluatorTests.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/QueryTagEvaluatorTests.cs new file mode 100644 index 00000000..bc31dfd9 --- /dev/null +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/QueryTagEvaluatorTests.cs @@ -0,0 +1,135 @@ +namespace Tests.Evaluators; + +[Collection("SharedCollection")] +public class QueryTagEvaluatorTests(TestFactory factory) : IntegrationTest(factory) +{ + private static readonly QueryTagEvaluator _evaluator = QueryTagEvaluator.Instance; + + [Fact] + public void QueriesMatch_GivenTag() + { + var tag = "asd"; + + var spec = new Specification(); + spec.Query.TagWith(tag); + + var actual = _evaluator.Evaluate(DbContext.Countries, spec) + .ToQueryString(); + + var expected = DbContext.Countries + .TagWith(tag) + .ToQueryString(); + + actual.Should().Be(expected); + } + + [Fact] + public void QueriesMatch_GivenMultipleTags() + { + var tag1 = "asd"; + var tag2 = "qwe"; + + var spec = new Specification(); + spec.Query.TagWith(tag1); + spec.Query.TagWith(tag2); + + var actual = _evaluator.Evaluate(DbContext.Countries, spec) + .ToQueryString(); + + var expected = DbContext.Countries + .TagWith(tag1) + .TagWith(tag2) + .ToQueryString(); + + actual.Should().Be(expected); + } + + + [Fact] + public void DoesNothing_GivenNoTag() + { + var spec = new Specification(); + + var actual = _evaluator.Evaluate(DbContext.Countries, spec) + .Expression + .ToString(); + + var expected = DbContext.Countries + .AsQueryable() + .Expression + .ToString(); + + actual.Should().Be(expected); + } + + [Fact] + public void Applies_GivenSingleTag() + { + var tag = "asd"; + + var spec = new Specification(); + spec.Query.TagWith(tag); + + var actual = _evaluator.Evaluate(DbContext.Countries, spec) + .Expression + .ToString(); + + var expected = DbContext.Countries + .TagWith(tag) + .Expression + .ToString(); + + actual.Should().Be(expected); + } + + [Fact] + public void Applies_GivenTwoTags() + { + var tag1 = "asd"; + var tag2 = "qwe"; + + var spec = new Specification(); + spec.Query + .TagWith(tag1) + .TagWith(tag2); + + var actual = _evaluator.Evaluate(DbContext.Countries, spec) + .Expression + .ToString(); + + var expected = DbContext.Countries + .TagWith(tag1) + .TagWith(tag2) + .Expression + .ToString(); + + actual.Should().Be(expected); + } + + [Fact] + public void Applies_GivenMultipleTags() + { + var tag1 = "asd"; + var tag2 = "qwe"; + var tag3 = "zxc"; + + var spec = new Specification(); + spec.Query + .TagWith(tag1) + .TagWith(tag2) + .TagWith(tag3); + + var actual = _evaluator.Evaluate(DbContext.Countries, spec) + .Expression + .ToString(); + + var expected = DbContext.Countries + .TagWith(tag1) + .TagWith(tag2) + .TagWith(tag3) + .Expression + .ToString(); + + actual.Should().Be(expected); + } +} diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/SpecificationEvaluatorTests.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/SpecificationEvaluatorTests.cs index f55c6ea2..7cd7101b 100644 --- a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/SpecificationEvaluatorTests.cs +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/SpecificationEvaluatorTests.cs @@ -60,6 +60,8 @@ public void GivenFullQuery() var spec = new Specification(); spec.Query + .TagWith("full test query") + .TagWith("another tag") .Where(x => x.Id > id) .Where(x => x.Name == name) .Like(x => x.Name, $"%{storeTerm}%") @@ -81,6 +83,8 @@ public void GivenFullQuery() // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores + .TagWith("full test query") + .TagWith("another tag") .Where(x => x.Id > id) .Where(x => x.Name == name) .Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%") @@ -166,6 +170,7 @@ public void GivenFullQueryWithSelect() var spec = new Specification(); spec.Query + .TagWith("full test query with select") .Where(x => x.Id > id) .Where(x => x.Name == name) .Like(x => x.Name, $"%{storeTerm}%") @@ -188,6 +193,7 @@ public void GivenFullQueryWithSelect() // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores + .TagWith("full test query with select") .Where(x => x.Id > id) .Where(x => x.Name == name) .Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%") @@ -221,6 +227,7 @@ public void GivenFullQueryWithSelectMany() var spec = new Specification(); spec.Query + .TagWith("full test query with select many") .Where(x => x.Id > id) .Where(x => x.Name == name) .Like(x => x.Name, $"%{storeTerm}%") @@ -243,6 +250,7 @@ public void GivenFullQueryWithSelectMany() // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores + .TagWith("full test query with select many") .Where(x => x.Id > id) .Where(x => x.Name == name) .Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%") @@ -382,7 +390,7 @@ public void DerivedSpecificationEvaluatorCanAlterDefaultEvaluator() var evaluator = new SpecificationEvaluatorDerived(); var result = EvaluatorsOf(evaluator); - result.Should().HaveCount(13); + result.Should().HaveCount(14); result[0].Should().BeOfType(); result[1].Should().BeOfType(); result[2].Should().BeOfType(); @@ -395,7 +403,8 @@ public void DerivedSpecificationEvaluatorCanAlterDefaultEvaluator() result[9].Should().BeOfType(); result[10].Should().BeOfType(); result[11].Should().BeOfType(); - result[12].Should().BeOfType(); + result[12].Should().BeOfType(); + result[13].Should().BeOfType(); } private class SpecificationEvaluatorDerived : SpecificationEvaluator diff --git a/tests/QuerySpecification.Tests/Builders/Builder_TagWith.cs b/tests/QuerySpecification.Tests/Builders/Builder_TagWith.cs new file mode 100644 index 00000000..b00dbe23 --- /dev/null +++ b/tests/QuerySpecification.Tests/Builders/Builder_TagWith.cs @@ -0,0 +1,104 @@ +namespace Tests.Builders; + +public class Builder_TagWith +{ + public record Customer(int Id, string Name); + + [Fact] + public void DoesNothing_GivenNoTag() + { + var spec1 = new Specification(); + var spec2 = new Specification(); + + spec1.QueryTags.Should().BeSameAs(Enumerable.Empty()); + spec2.QueryTags.Should().BeSameAs(Enumerable.Empty()); + } + + [Fact] + public void DoesNothing_GivenTagWithFalseCondition() + { + var spec1 = new Specification(); + spec1.Query + .TagWith("asd", false); + + var spec2 = new Specification(); + spec2.Query + .TagWith("asd", false); + + spec1.QueryTags.Should().BeSameAs(Enumerable.Empty()); + spec2.QueryTags.Should().BeSameAs(Enumerable.Empty()); + } + + [Fact] + public void SetsTag_GivenSingleTag() + { + var tag = "asd"; + + var spec1 = new Specification(); + spec1.Query + .TagWith(tag); + + var spec2 = new Specification(); + spec2.Query + .TagWith(tag); + + spec1.QueryTags.Should().ContainSingle(); + spec1.QueryTags.First().Should().Be(tag); + spec2.QueryTags.Should().ContainSingle(); + spec2.QueryTags.First().Should().Be(tag); + } + + [Fact] + public void SetsTags_GivenTwoTags() + { + var tag1 = "asd"; + var tag2 = "qwe"; + + var spec1 = new Specification(); + spec1.Query + .TagWith(tag1) + .TagWith(tag2); + + var spec2 = new Specification(); + spec2.Query + .TagWith(tag1) + .TagWith(tag2); + + spec1.QueryTags.Should().HaveCount(2); + spec1.QueryTags.First().Should().Be(tag1); + spec1.QueryTags.Skip(1).First().Should().Be(tag2); + spec2.QueryTags.Should().HaveCount(2); + spec2.QueryTags.First().Should().Be(tag1); + spec2.QueryTags.Skip(1).First().Should().Be(tag2); + } + + [Fact] + public void SetsTags_GivenMultipleTags() + { + var tag1 = "asd"; + var tag2 = "qwe"; + var tag3 = "zxc"; + + var spec1 = new Specification(); + spec1.Query + .TagWith(tag1) + .TagWith(tag2) + .TagWith(tag3); + + var spec2 = new Specification(); + spec2.Query + .TagWith(tag1) + .TagWith(tag2) + .TagWith(tag3); + + spec1.QueryTags.Should().HaveCount(3); + spec1.QueryTags.First().Should().Be(tag1); + spec1.QueryTags.Skip(1).First().Should().Be(tag2); + spec1.QueryTags.Skip(2).First().Should().Be(tag3); + + spec2.QueryTags.Should().HaveCount(3); + spec2.QueryTags.First().Should().Be(tag1); + spec2.QueryTags.Skip(1).First().Should().Be(tag2); + spec2.QueryTags.Skip(2).First().Should().Be(tag3); + } +}