diff --git a/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/TagWithEvaluator.cs b/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/TagWithEvaluator.cs index 6a173a93..7d294b13 100644 --- a/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/TagWithEvaluator.cs +++ b/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/TagWithEvaluator.cs @@ -9,9 +9,20 @@ private TagWithEvaluator() { } public IQueryable GetQuery(IQueryable query, ISpecification specification) where T : class { - if (specification.QueryTag is not null) + if (specification is Specification spec) { - query = query.TagWith(specification.QueryTag); + if (spec.OneOrManyQueryTags.IsEmpty) return query; + + if (spec.OneOrManyQueryTags.HasSingleItem) + { + query = query.TagWith(spec.OneOrManyQueryTags.Single); + return query; + } + } + + foreach (var tag in specification.QueryTags) + { + query = query.TagWith(tag); } return query; diff --git a/src/Ardalis.Specification/Ardalis.Specification.csproj b/src/Ardalis.Specification/Ardalis.Specification.csproj index 3e217dc0..513030de 100644 --- a/src/Ardalis.Specification/Ardalis.Specification.csproj +++ b/src/Ardalis.Specification/Ardalis.Specification.csproj @@ -23,4 +23,10 @@ + + + + + + diff --git a/src/Ardalis.Specification/Builders/Builder_TagWith.cs b/src/Ardalis.Specification/Builders/Builder_TagWith.cs index ee29319d..240ef8fa 100644 --- a/src/Ardalis.Specification/Builders/Builder_TagWith.cs +++ b/src/Ardalis.Specification/Builders/Builder_TagWith.cs @@ -63,7 +63,7 @@ public static ISpecificationBuilder TagWith( { if (condition) { - builder.Specification.QueryTag = tag; + builder.Specification.AddQueryTag(tag); } return builder; diff --git a/src/Ardalis.Specification/ISpecification.cs b/src/Ardalis.Specification/ISpecification.cs index 9c6a3d78..761ae910 100644 --- a/src/Ardalis.Specification/ISpecification.cs +++ b/src/Ardalis.Specification/ISpecification.cs @@ -84,9 +84,9 @@ public interface ISpecification Func, IEnumerable>? PostProcessingAction { get; } /// - /// A query tag to help correlate specification with generated SQL queries captured in logs + /// Query tags to help correlate specification with generated SQL queries captured in logs. /// - string? QueryTag { get; } + IEnumerable QueryTags { get; } /// /// Return whether or not the results should be cached. diff --git a/src/Ardalis.Specification/Internals/OneOrMany.cs b/src/Ardalis.Specification/Internals/OneOrMany.cs new file mode 100644 index 00000000..80f44831 --- /dev/null +++ b/src/Ardalis.Specification/Internals/OneOrMany.cs @@ -0,0 +1,82 @@ +namespace Ardalis.Specification; + +internal struct OneOrMany +{ + private object? _value; + + public readonly bool IsEmpty => _value is null; + public readonly bool HasSingleItem => _value is T; + + public void Add(T item) + { + if (_value is null) + { + _value = item; + return; + } + + if (_value is List list) + { + list.Add(item); + return; + } + + if (_value is T singleValue) + { + _value = new List(2) { singleValue, item }; + return; + } + } + + public readonly T Single + { + get + { + if (_value is T singleValue) + { + return singleValue; + } + + throw new InvalidOperationException("The value is not a single item."); + } + } + + public readonly IEnumerable Values + { + get + { + if (_value is null) + { + return Enumerable.Empty(); + } + + if (_value is List tags) + { + return tags; + } + + if (_value is T singleValue) + { + return new[] { singleValue }; + } + + throw new InvalidOperationException("The value is neither a single item nor a list of items."); + } + } + + public readonly OneOrMany Clone() + { + var clone = new OneOrMany(); + + if (_value is T singleValue) + { + clone._value = singleValue; + } + else if (_value is List list) + { + clone._value = list.ToList(); + } + + return clone; + } +} diff --git a/src/Ardalis.Specification/Specification.cs b/src/Ardalis.Specification/Specification.cs index 8536a007..2fd181bf 100644 --- a/src/Ardalis.Specification/Specification.cs +++ b/src/Ardalis.Specification/Specification.cs @@ -48,6 +48,7 @@ public class Specification : ISpecification private List? _includeExpressions; private List? _includeStrings; private Dictionary? _items; + private OneOrMany _queryTags = new(); public ISpecificationBuilder Query => new SpecificationBuilder(this); protected virtual IInMemorySpecificationEvaluator Evaluator => InMemorySpecificationEvaluator.Default; @@ -56,9 +57,6 @@ public class Specification : ISpecification /// public Func, IEnumerable>? PostProcessingAction { get; internal set; } - /// - public string? QueryTag { get; internal set; } - /// public string? CacheKey { get; internal set; } @@ -121,6 +119,7 @@ internal void Add(SearchExpressionInfo searchExpression) _searchExpressions.Insert(index, searchExpression); } } + internal void AddQueryTag(string queryTag) => _queryTags.Add(queryTag); /// public Dictionary Items => _items ??= []; @@ -140,6 +139,11 @@ internal void Add(SearchExpressionInfo searchExpression) /// public IEnumerable IncludeStrings => _includeStrings ?? Enumerable.Empty(); + /// + public IEnumerable QueryTags => _queryTags.Values; + + internal OneOrMany OneOrManyQueryTags => _queryTags; + /// public virtual IEnumerable Evaluate(IEnumerable entities) { @@ -157,7 +161,6 @@ public virtual bool IsSatisfiedBy(T entity) void ISpecification.CopyTo(Specification otherSpec) { otherSpec.PostProcessingAction = PostProcessingAction; - otherSpec.QueryTag = QueryTag; otherSpec.CacheKey = CacheKey; otherSpec.Take = Take; otherSpec.Skip = Skip; @@ -196,6 +199,11 @@ void ISpecification.CopyTo(Specification otherSpec) otherSpec._searchExpressions = _searchExpressions.ToList(); } + if (!_queryTags.IsEmpty) + { + otherSpec._queryTags = _queryTags.Clone(); + } + if (_items is not null) { otherSpec._items = new Dictionary(_items); diff --git a/tests/Ardalis.Specification.EntityFrameworkCore.Tests/Evaluators/TagWithEvaluatorTests.cs b/tests/Ardalis.Specification.EntityFrameworkCore.Tests/Evaluators/TagWithEvaluatorTests.cs index f4435e2e..4d9dd99f 100644 --- a/tests/Ardalis.Specification.EntityFrameworkCore.Tests/Evaluators/TagWithEvaluatorTests.cs +++ b/tests/Ardalis.Specification.EntityFrameworkCore.Tests/Evaluators/TagWithEvaluatorTests.cs @@ -24,7 +24,46 @@ public void QueriesMatch_GivenTag() } [Fact] - public void Applies_GivenTag() + 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.GetQuery(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.GetQuery(DbContext.Countries, spec) + .Expression + .ToString(); + + var expected = DbContext.Countries + .AsQueryable() + .Expression + .ToString(); + + actual.Should().Be(expected); + } + + [Fact] + public void Applies_GivenSingleTag() { var tag = "asd"; @@ -42,4 +81,55 @@ public void Applies_GivenTag() 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.GetQuery(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.GetQuery(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/Ardalis.Specification.Tests/Builders/Builder_TagWith.cs b/tests/Ardalis.Specification.Tests/Builders/Builder_TagWith.cs index 1f2a6b79..b00dbe23 100644 --- a/tests/Ardalis.Specification.Tests/Builders/Builder_TagWith.cs +++ b/tests/Ardalis.Specification.Tests/Builders/Builder_TagWith.cs @@ -10,8 +10,8 @@ public void DoesNothing_GivenNoTag() var spec1 = new Specification(); var spec2 = new Specification(); - spec1.QueryTag.Should().BeNull(); - spec2.QueryTag.Should().BeNull(); + spec1.QueryTags.Should().BeSameAs(Enumerable.Empty()); + spec2.QueryTags.Should().BeSameAs(Enumerable.Empty()); } [Fact] @@ -25,12 +25,12 @@ public void DoesNothing_GivenTagWithFalseCondition() spec2.Query .TagWith("asd", false); - spec1.QueryTag.Should().BeNull(); - spec2.QueryTag.Should().BeNull(); + spec1.QueryTags.Should().BeSameAs(Enumerable.Empty()); + spec2.QueryTags.Should().BeSameAs(Enumerable.Empty()); } [Fact] - public void SetsTag_GivenTag() + public void SetsTag_GivenSingleTag() { var tag = "asd"; @@ -42,7 +42,63 @@ public void SetsTag_GivenTag() spec2.Query .TagWith(tag); - spec1.QueryTag.Should().Be(tag); - spec2.QueryTag.Should().Be(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); } } diff --git a/tests/Ardalis.Specification.Tests/Internals/OneOrManyTests.cs b/tests/Ardalis.Specification.Tests/Internals/OneOrManyTests.cs new file mode 100644 index 00000000..ca96a4ba --- /dev/null +++ b/tests/Ardalis.Specification.Tests/Internals/OneOrManyTests.cs @@ -0,0 +1,205 @@ +#if NET8_0_OR_GREATER + +namespace Tests.Internals; + +public class OneOrManyTests +{ + [Fact] + public void IsEmpty_ReturnTrue_GivenEmptyStruct() + { + var oneOrMany = new OneOrMany(); + + oneOrMany.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void IsEmpty_ReturnFalse_GivenItems() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = "foo"; + + oneOrMany.IsEmpty.Should().BeFalse(); + } + + [Fact] + public void HasSingleItem_ReturnFalse_GivenEmptyStruct() + { + var oneOrMany = new OneOrMany(); + + oneOrMany.HasSingleItem.Should().BeFalse(); + } + + [Fact] + public void HasSingleItem_ReturnTrue_GivenSingleItem() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = "foo"; + + oneOrMany.HasSingleItem.Should().BeTrue(); + } + + [Fact] + public void HasSingleItem_ReturnFalse_GivenMultipleItems() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = new List { "foo", "bar" }; + + oneOrMany.HasSingleItem.Should().BeFalse(); + } + + [Fact] + public void Add_CreatesSingleItem_GivenEmptyStruct() + { + var oneOrMany = new OneOrMany(); + oneOrMany.Add("foo"); + + var value = Accessors.ValueOf(ref oneOrMany); + value.Should().BeOfType(); + value.Should().Be("foo"); + } + + [Fact] + public void Add_CreatesListWithTwoItems_GivenSingleItem() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = "foo"; + + oneOrMany.Add("bar"); + + var value = Accessors.ValueOf(ref oneOrMany); + value.Should().BeOfType>(); + value.Should().BeEquivalentTo(new List { "foo", "bar" }); + } + + [Fact] + public void Add_AddsToTheList_GivenTwoItems() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = new List { "foo", "bar" }; + + oneOrMany.Add("baz"); + + var value = Accessors.ValueOf(ref oneOrMany); + value.Should().BeOfType>(); + value.Should().BeEquivalentTo(new List { "foo", "bar", "baz" }); + } + + [Fact] + public void Add_DoesNothing_GivenInvalidState() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = new string[] { "foo", "bar" }; + + oneOrMany.Add("baz"); + + var value = Accessors.ValueOf(ref oneOrMany); + value.Should().BeOfType(); + value.Should().BeEquivalentTo(new string[] { "foo", "bar" }); + } + + [Fact] + public void Single_ReturnsSingleItem_GivenSingleItem() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = "foo"; + + oneOrMany.Single.Should().Be("foo"); + } + + [Fact] + public void Single_Throws_GivenEmptyStruct() + { + var oneOrMany = new OneOrMany(); + + var action = () => oneOrMany.Single; + action.Should().Throw(); + } + + [Fact] + public void Single_Throws_GivenMultipleItems() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = new string[] { "foo", "bar" }; + + var action = () => _ = oneOrMany.Single; + action.Should().Throw(); + } + + [Fact] + public void Values_ReturnsEmpty_GivenEmptyStruct() + { + var oneOrMany = new OneOrMany(); + + oneOrMany.Values.Should().BeEmpty(); + oneOrMany.Values.Should().BeSameAs(Enumerable.Empty()); + } + + [Fact] + public void Values_ReturnsSingleItemEnumerable_GivenSingleItem() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = "foo"; + + oneOrMany.Values.Should().ContainSingle(); + oneOrMany.Values.Should().BeEquivalentTo(new List { "foo" }); + } + + [Fact] + public void Values_ReturnsEnumerable_GivenMultipleItems() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = new List { "foo", "bar" }; + + oneOrMany.Values.Should().BeEquivalentTo(new List { "foo", "bar" }); + } + + [Fact] + public void Values_Throws_GivenInvalidState() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = new string[] { "foo", "bar" }; + + var action = () => _ = oneOrMany.Values; + action.Should().Throw(); + } + + [Fact] + public void Clone_ReturnEqualStruct_GivenSingleItem() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = "foo"; + + var clone = oneOrMany.Clone(); + + clone.Values.Should().BeEquivalentTo(oneOrMany.Values); + } + + [Fact] + public void Clone_ReturnEqualStruct_GivenMultipleItems() + { + var oneOrMany = new OneOrMany(); + Accessors.ValueOf(ref oneOrMany) = new List { "foo", "bar" }; + + var clone = oneOrMany.Clone(); + + clone.Values.Should().BeEquivalentTo(oneOrMany.Values); + } + + [Fact] + public void Clone_ReturnsNewEmptyStruct_GivenEmptyStruct() + { + var oneOrMany = new OneOrMany(); + + var clone = oneOrMany.Clone(); + + clone.Values.Should().BeEquivalentTo(oneOrMany.Values); + } + + private class Accessors + { + [System.Runtime.CompilerServices.UnsafeAccessor(System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = "_value")] + public static extern ref object? ValueOf(ref OneOrMany @this); + } +} + +#endif diff --git a/tests/Ardalis.Specification.Tests/SpecificationExtensionsTests.cs b/tests/Ardalis.Specification.Tests/SpecificationExtensionsTests.cs index 0780aa8d..5e11fc4b 100644 --- a/tests/Ardalis.Specification.Tests/SpecificationExtensionsTests.cs +++ b/tests/Ardalis.Specification.Tests/SpecificationExtensionsTests.cs @@ -23,7 +23,7 @@ public void WithProjectionOf_ReturnsCopyWithProjection() .IgnoreQueryFilters() .AsSplitQuery() .AsNoTracking() - .TagWith("testQuery") + .TagWith("testQuery1") .PostProcessingAction(x => x.Where(x => x.Id > 0)); var projectionSpec = new Specification(); @@ -51,6 +51,9 @@ public void WithProjectionOf_ReturnsCopyWithProjection() newSpec.SearchCriterias.Should().NotBeSameAs(spec.SearchCriterias); newSpec.SearchCriterias.Should().Equal(spec.SearchCriterias); + newSpec.QueryTags.Should().NotBeSameAs(spec.QueryTags); + newSpec.QueryTags.Should().Equal(spec.QueryTags); + newSpec.Take.Should().Be(spec.Take); newSpec.Skip.Should().Be(spec.Skip); newSpec.CacheKey.Should().Be(spec.CacheKey); @@ -60,9 +63,26 @@ public void WithProjectionOf_ReturnsCopyWithProjection() newSpec.AsNoTracking.Should().Be(spec.AsNoTracking); newSpec.AsNoTrackingWithIdentityResolution.Should().Be(spec.AsNoTrackingWithIdentityResolution); newSpec.AsTracking.Should().Be(spec.AsTracking); - newSpec.QueryTag.Should().Be(spec.QueryTag); newSpec.PostProcessingAction.Should().BeSameAs(projectionSpec.PostProcessingAction); ((Specification)newSpec).PostProcessingAction.Should().BeSameAs(spec.PostProcessingAction); } + + [Fact] + public void WithProjectionOf_ReturnsCopyWithProjection_GivenSpecWithMultipleTags() + { + var spec = new Specification(); + spec.Query + .TagWith("testQuery1") + .TagWith("testQuery2"); + + var projectionSpec = new Specification(); + projectionSpec.Query.Select(x => x.Name); + projectionSpec.Query.SelectMany(x => x.Names); + + var newSpec = spec.WithProjectionOf(projectionSpec); + + newSpec.QueryTags.Should().NotBeSameAs(spec.QueryTags); + newSpec.QueryTags.Should().Equal(spec.QueryTags); + } }