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()
{