Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@
</PropertyGroup>

<ItemGroup>
<None Include="../../pozitronicon.png" Pack="true" PackagePath="\" />
<None Include="../../readme-nuget.md" Pack="true" PackagePath="\" />
<None Include="../../LICENSE.txt" Pack="true" PackagePath="\" />
<None Include="../../pozitronicon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include="../../readme-nuget.md" Pack="true" PackagePath="\" Visible="false" />
<None Include="../../LICENSE.txt" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>

</Project>
20 changes: 20 additions & 0 deletions src/QuerySpecification/Specification.cs
Original file line number Diff line number Diff line change
Expand Up @@ -545,4 +545,24 @@ internal void AddOrUpdateFlag(SpecFlags flag, bool value)
AddInternal(ItemType.Flags, null!, (int)flag);
}
}

internal Specification<T> Clone()
{
if (IsEmpty) return new Specification<T>();

var items = _items;
var newItems = new SpecItem[items.Length];
Array.Copy(items, newItems, items.Length);
return new Specification<T> { _items = newItems };
}

internal Specification<T, TResult> Clone<TResult>()
{
if (IsEmpty) return new Specification<T, TResult>();

var items = _items;
var newItems = new SpecItem[items.Length];
Array.Copy(items, newItems, items.Length);
return new Specification<T, TResult> { _items = newItems };
}
}
35 changes: 35 additions & 0 deletions src/QuerySpecification/SpecificationExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Pozitron.QuerySpecification;

/// <summary>
/// Extension methods for specifications.
/// </summary>
public static class SpecificationExtensions
{
/// <summary>
/// Creates a new specification by applying a projection specification to the current specification.
/// </summary>
/// <remarks>This method clones the source specification and applies the projection specification's select
/// statements to create a new specification. The input specifications remain unchanged.</remarks>
/// <typeparam name="T">The type of the entity.</typeparam>
/// <typeparam name="TResult">The type of the result.</typeparam>
/// <param name="source">The source specification to which the projection will be applied. Cannot be <see langword="null"/>.</param>
/// <param name="projectionSpec">The projection specification that defines the transformation to apply to the source specification. Cannot be
/// <see langword="null"/>.</param>
/// <returns>A new <see cref="Specification{T, TResult}"/> that represents the result of applying the projection to the
/// source specification.</returns>
public static Specification<T, TResult> WithProjectionOf<T, TResult>(this Specification<T> source, Specification<T, TResult> projectionSpec)
{
var newSpec = source.Clone<TResult>();

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;
}
}
149 changes: 149 additions & 0 deletions tests/QuerySpecification.Tests/SpecificationExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> Names, Address Address);

[Fact]
public void WithProjectionOf_ReturnsCopyWithProjection_GivenProjectionSpec()
{
var spec = new Specification<Person>();
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<Person, string>();
projectionSpec.Query.Select(x => x.Name);

var newSpec = spec.WithProjectionOf(projectionSpec);

newSpec.Should().NotBeSameAs(spec);
Accessors<Person>.Items(newSpec).Should().NotBeSameAs(Accessors<Person>.Items(spec));
Accessors<Person>.Items(newSpec)!.Take(8).Should().Equal(Accessors<Person>.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<Person>();
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<Person, string>();

var newSpec = spec.WithProjectionOf(projectionSpec);

newSpec.Should().NotBeSameAs(spec);
Accessors<Person>.Items(newSpec).Should().NotBeSameAs(Accessors<Person>.Items(spec));
Accessors<Person>.Items(newSpec)!.Take(8).Should().Equal(Accessors<Person>.Items(spec)!.Take(8));
Accessors<Person>.Items(newSpec).Should().NotContain(x => x.Type == ItemType.Select);

newSpec.Selector.Should().BeNull();
}

[Fact]
public void WithProjectionOf_ReturnsBaseCopy_GivenProjectionSpecWithNullSelect()
{
var spec = new Specification<Person>();
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<Person, string>();
projectionSpec.AddOrUpdateInternal(ItemType.Select, null!); // Explicitly set Selector to null

var newSpec = spec.WithProjectionOf(projectionSpec);

newSpec.Should().NotBeSameAs(spec);
Accessors<Person>.Items(newSpec).Should().NotBeSameAs(Accessors<Person>.Items(spec));
Accessors<Person>.Items(newSpec)!.Take(8).Should().Equal(Accessors<Person>.Items(spec)!.Take(8));
Accessors<Person>.Items(newSpec).Should().NotContain(x => x.Type == ItemType.Select);

newSpec.Selector.Should().BeNull();
}

private class Accessors<T>
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
public static extern ref SpecItem[]? Items(Specification<T> @this);
}
}
78 changes: 78 additions & 0 deletions tests/QuerySpecification.Tests/SpecificationInternalsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Customer>();
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<Specification<Customer>>();
Accessors<Customer>.Items(clonedSpec).Should().NotBeSameAs(Accessors<Customer>.Items(spec));
Accessors<Customer>.Items(clonedSpec)!.Should().Equal(Accessors<Customer>.Items(spec)!);
}

[Fact]
public void Clone_ReturnsEmptyNewSpec_GivenEmptySpec()
{
var spec = new Specification<Customer>();

var clonedSpec = spec.Clone();

clonedSpec.Should().NotBeSameAs(spec);
clonedSpec.Should().BeOfType<Specification<Customer>>();
Accessors<Customer>.Items(clonedSpec).Should().BeNull();
}

[Fact]
public void Clone_ReturnsNewProjectionSpecWithSameItems_GivenTResult()
{
var spec = new Specification<Customer>();
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<string>();

clonedSpec.Should().NotBeSameAs(spec);
clonedSpec.Should().BeOfType<Specification<Customer, string>>();
Accessors<Customer>.Items(clonedSpec).Should().NotBeSameAs(Accessors<Customer>.Items(spec));
Accessors<Customer>.Items(clonedSpec)!.Should().Equal(Accessors<Customer>.Items(spec)!);
}

[Fact]
public void Clone_ReturnsEmptyNewProjectionSpec_GivenEmptySpecAndTResult()
{
var spec = new Specification<Customer>();

var clonedSpec = spec.Clone<string>();

clonedSpec.Should().NotBeSameAs(spec);
clonedSpec.Should().BeOfType<Specification<Customer, string>>();
Accessors<Customer>.Items(clonedSpec).Should().BeNull();
}

[Fact]
public void SpecFlags_ContainItemsWithPowerOfTwo()
{
Expand Down