diff --git a/src/Directory.Build.props b/src/Directory.Build.props index a08a952c..6a5bf855 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -38,9 +38,9 @@ - - - + + + diff --git a/src/QuerySpecification/Specification.cs b/src/QuerySpecification/Specification.cs index 2a1e786a..de0791fd 100644 --- a/src/QuerySpecification/Specification.cs +++ b/src/QuerySpecification/Specification.cs @@ -545,4 +545,24 @@ internal void AddOrUpdateFlag(SpecFlags flag, bool value) AddInternal(ItemType.Flags, null!, (int)flag); } } + + internal Specification Clone() + { + if (IsEmpty) return new Specification(); + + var items = _items; + var newItems = new SpecItem[items.Length]; + Array.Copy(items, newItems, items.Length); + return new Specification { _items = newItems }; + } + + internal Specification Clone() + { + if (IsEmpty) return new Specification(); + + var items = _items; + var newItems = new SpecItem[items.Length]; + Array.Copy(items, newItems, items.Length); + return new Specification { _items = newItems }; + } } diff --git a/src/QuerySpecification/SpecificationExtensions.cs b/src/QuerySpecification/SpecificationExtensions.cs new file mode 100644 index 00000000..26f8629d --- /dev/null +++ b/src/QuerySpecification/SpecificationExtensions.cs @@ -0,0 +1,35 @@ +namespace Pozitron.QuerySpecification; + +/// +/// Extension methods for specifications. +/// +public static class SpecificationExtensions +{ + /// + /// Creates a new specification by applying a projection specification to the current specification. + /// + /// This method clones the source specification and applies the projection specification's select + /// statements to create a new specification. The input specifications remain unchanged. + /// The type of the entity. + /// The type of the result. + /// The source specification to which the projection will be applied. Cannot be . + /// The projection specification that defines the transformation to apply to the source specification. Cannot be + /// . + /// A new that represents the result of applying the projection to the + /// source specification. + public static Specification WithProjectionOf(this Specification source, Specification projectionSpec) + { + var newSpec = source.Clone(); + + foreach (var item in projectionSpec.Items) + { + if (item.Type == ItemType.Select && item.Reference is not null) + { + newSpec.AddOrUpdateInternal(item.Type, item.Reference, item.Bag); + return newSpec; + } + } + + return newSpec; + } +} diff --git a/tests/QuerySpecification.Tests/SpecificationExtensionsTests.cs b/tests/QuerySpecification.Tests/SpecificationExtensionsTests.cs new file mode 100644 index 00000000..6a61fde4 --- /dev/null +++ b/tests/QuerySpecification.Tests/SpecificationExtensionsTests.cs @@ -0,0 +1,149 @@ +using System.Runtime.CompilerServices; + +namespace Tests; + +public class SpecificationExtensionsTests +{ + private record Address(int Id, string Street); + private record Person(int Id, string Name, List Names, Address Address); + + [Fact] + public void WithProjectionOf_ReturnsCopyWithProjection_GivenProjectionSpec() + { + var spec = new Specification(); + spec.Query + .Where(x => x.Name == "test") + .Include(x => x.Address) + .Include("Address") + .OrderBy(x => x.Id) + .Like(x => x.Name, "test") + .Take(2) + .Skip(3) + .WithCacheKey("testKey") + .IgnoreQueryFilters() + .IgnoreQueryFilters() + .AsSplitQuery() + .AsNoTracking() + .TagWith("testQuery1"); + + var projectionSpec = new Specification(); + projectionSpec.Query.Select(x => x.Name); + + var newSpec = spec.WithProjectionOf(projectionSpec); + + newSpec.Should().NotBeSameAs(spec); + Accessors.Items(newSpec).Should().NotBeSameAs(Accessors.Items(spec)); + Accessors.Items(newSpec)!.Take(8).Should().Equal(Accessors.Items(spec)!.Take(8)); + + // Unnecessary, but let's assert the states individually as well. + // Compare expressions by their string representation for logical equivalence + + newSpec.WhereExpressions.Should().NotBeSameAs(spec.WhereExpressions); + newSpec.WhereExpressions.Should().BeEquivalentTo(spec.WhereExpressions); + newSpec.WhereExpressions.Select(x => x.Filter.ToString()) + .Should().BeEquivalentTo(spec.WhereExpressions.Select(x => x.Filter.ToString())); + + newSpec.IncludeExpressions.Should().NotBeSameAs(spec.IncludeExpressions); + newSpec.IncludeExpressions.Should().BeEquivalentTo(spec.IncludeExpressions); + newSpec.IncludeExpressions.Select(x => x.LambdaExpression.ToString()) + .Should().Equal(spec.IncludeExpressions.Select(x => x.LambdaExpression.ToString())); + + newSpec.OrderExpressions.Should().NotBeSameAs(spec.OrderExpressions); + newSpec.OrderExpressions.Should().BeEquivalentTo(spec.OrderExpressions); + newSpec.OrderExpressions.Select(x => x.KeySelector.ToString()) + .Should().Equal(spec.OrderExpressions.Select(x => x.KeySelector.ToString())); + + newSpec.LikeExpressions.Should().NotBeSameAs(spec.LikeExpressions); + newSpec.LikeExpressions.Should().BeEquivalentTo(spec.LikeExpressions); + newSpec.LikeExpressions.Select(x => x.KeySelector.ToString()) + .Should().Equal(spec.LikeExpressions.Select(x => x.KeySelector.ToString())); + + newSpec.IncludeStrings.Should().NotBeSameAs(spec.IncludeStrings); + newSpec.IncludeStrings.Should().Equal(spec.IncludeStrings); + + 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); + newSpec.IgnoreQueryFilters.Should().Be(spec.IgnoreQueryFilters); + newSpec.IgnoreAutoIncludes.Should().Be(spec.IgnoreAutoIncludes); + newSpec.AsSplitQuery.Should().Be(spec.AsSplitQuery); + newSpec.AsNoTracking.Should().Be(spec.AsNoTracking); + newSpec.AsNoTrackingWithIdentityResolution.Should().Be(spec.AsNoTrackingWithIdentityResolution); + newSpec.AsTracking.Should().Be(spec.AsTracking); + + // Assert that the projection is set from projectionSpec + newSpec.Selector.Should().Be(projectionSpec.Selector); + } + + [Fact] + public void WithProjectionOf_ReturnsBaseCopy_GivenProjectionSpecWithNoSelect() + { + var spec = new Specification(); + spec.Query + .Where(x => x.Name == "test") + .Include(x => x.Address) + .Include("Address") + .OrderBy(x => x.Id) + .Like(x => x.Name, "test") + .Take(2) + .Skip(3) + .WithCacheKey("testKey") + .IgnoreQueryFilters() + .IgnoreQueryFilters() + .AsSplitQuery() + .AsNoTracking() + .TagWith("testQuery1"); + + var projectionSpec = new Specification(); + + var newSpec = spec.WithProjectionOf(projectionSpec); + + newSpec.Should().NotBeSameAs(spec); + Accessors.Items(newSpec).Should().NotBeSameAs(Accessors.Items(spec)); + Accessors.Items(newSpec)!.Take(8).Should().Equal(Accessors.Items(spec)!.Take(8)); + Accessors.Items(newSpec).Should().NotContain(x => x.Type == ItemType.Select); + + newSpec.Selector.Should().BeNull(); + } + + [Fact] + public void WithProjectionOf_ReturnsBaseCopy_GivenProjectionSpecWithNullSelect() + { + var spec = new Specification(); + spec.Query + .Where(x => x.Name == "test") + .Include(x => x.Address) + .Include("Address") + .OrderBy(x => x.Id) + .Like(x => x.Name, "test") + .Take(2) + .Skip(3) + .WithCacheKey("testKey") + .IgnoreQueryFilters() + .IgnoreQueryFilters() + .AsSplitQuery() + .AsNoTracking() + .TagWith("testQuery1"); + + var projectionSpec = new Specification(); + projectionSpec.AddOrUpdateInternal(ItemType.Select, null!); // Explicitly set Selector to null + + var newSpec = spec.WithProjectionOf(projectionSpec); + + newSpec.Should().NotBeSameAs(spec); + Accessors.Items(newSpec).Should().NotBeSameAs(Accessors.Items(spec)); + Accessors.Items(newSpec)!.Take(8).Should().Equal(Accessors.Items(spec)!.Take(8)); + Accessors.Items(newSpec).Should().NotContain(x => x.Type == ItemType.Select); + + newSpec.Selector.Should().BeNull(); + } + + private class Accessors + { + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")] + public static extern ref SpecItem[]? Items(Specification @this); + } +} diff --git a/tests/QuerySpecification.Tests/SpecificationInternalsTests.cs b/tests/QuerySpecification.Tests/SpecificationInternalsTests.cs index dbab812d..8b759db6 100644 --- a/tests/QuerySpecification.Tests/SpecificationInternalsTests.cs +++ b/tests/QuerySpecification.Tests/SpecificationInternalsTests.cs @@ -257,6 +257,84 @@ public void AddOrUpdateFlag_UpdatesFlags_GivenFlagAndSetFalse() items![1].Type.Should().Be(ItemType.Flags); } + [Fact] + public void Clone_ReturnsNewSpecWithSameItems() + { + var spec = new Specification(); + spec.Query + .Where(x => x.Name == "test") + .Include(x => x.Address) + .Include("Address") + .OrderBy(x => x.Id) + .Like(x => x.Name, "test") + .Take(2) + .Skip(3) + .WithCacheKey("testKey") + .IgnoreQueryFilters() + .IgnoreQueryFilters() + .AsSplitQuery() + .AsNoTracking() + .TagWith("testQuery1"); + + var clonedSpec = spec.Clone(); + + clonedSpec.Should().NotBeSameAs(spec); + clonedSpec.Should().BeOfType>(); + Accessors.Items(clonedSpec).Should().NotBeSameAs(Accessors.Items(spec)); + Accessors.Items(clonedSpec)!.Should().Equal(Accessors.Items(spec)!); + } + + [Fact] + public void Clone_ReturnsEmptyNewSpec_GivenEmptySpec() + { + var spec = new Specification(); + + var clonedSpec = spec.Clone(); + + clonedSpec.Should().NotBeSameAs(spec); + clonedSpec.Should().BeOfType>(); + Accessors.Items(clonedSpec).Should().BeNull(); + } + + [Fact] + public void Clone_ReturnsNewProjectionSpecWithSameItems_GivenTResult() + { + var spec = new Specification(); + spec.Query + .Where(x => x.Name == "test") + .Include(x => x.Address) + .Include("Address") + .OrderBy(x => x.Id) + .Like(x => x.Name, "test") + .Take(2) + .Skip(3) + .WithCacheKey("testKey") + .IgnoreQueryFilters() + .IgnoreQueryFilters() + .AsSplitQuery() + .AsNoTracking() + .TagWith("testQuery1"); + + var clonedSpec = spec.Clone(); + + clonedSpec.Should().NotBeSameAs(spec); + clonedSpec.Should().BeOfType>(); + Accessors.Items(clonedSpec).Should().NotBeSameAs(Accessors.Items(spec)); + Accessors.Items(clonedSpec)!.Should().Equal(Accessors.Items(spec)!); + } + + [Fact] + public void Clone_ReturnsEmptyNewProjectionSpec_GivenEmptySpecAndTResult() + { + var spec = new Specification(); + + var clonedSpec = spec.Clone(); + + clonedSpec.Should().NotBeSameAs(spec); + clonedSpec.Should().BeOfType>(); + Accessors.Items(clonedSpec).Should().BeNull(); + } + [Fact] public void SpecFlags_ContainItemsWithPowerOfTwo() {