From dea033d3398b63018600aed91cde0b531994cacd Mon Sep 17 00:00:00 2001
From: Fati Iseni <fati.iseni@pozitrongroup.com>
Date: Thu, 12 Jun 2025 22:45:05 +0200
Subject: [PATCH] Refactor the state of where expressions as OneOrMany.

---
 .../Evaluators/WhereEvaluator.cs              | 26 ++++++++++++++++---
 .../Internals/OneOrMany.cs                    |  5 ++--
 src/Ardalis.Specification/Specification.cs    | 12 ++++-----
 .../Validators/WhereValidator.cs              | 13 ++++++++--
 .../Evaluators/WhereEvaluatorTests.cs         | 16 +++++++++++-
 5 files changed, 57 insertions(+), 15 deletions(-)

diff --git a/src/Ardalis.Specification/Evaluators/WhereEvaluator.cs b/src/Ardalis.Specification/Evaluators/WhereEvaluator.cs
index 5aff7fdd..ac184bb4 100644
--- a/src/Ardalis.Specification/Evaluators/WhereEvaluator.cs
+++ b/src/Ardalis.Specification/Evaluators/WhereEvaluator.cs
@@ -9,9 +9,18 @@ private WhereEvaluator() { }
 
     public IQueryable<T> GetQuery<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
     {
-        foreach (var info in specification.WhereExpressions)
+        if (specification is Specification<T> spec)
         {
-            query = query.Where(info.Filter);
+            if (spec.OneOrManyWhereExpressions.IsEmpty) return query;
+            if (spec.OneOrManyWhereExpressions.SingleOrDefault is { } whereExpression)
+            {
+                return query.Where(whereExpression.Filter);
+            }
+        }
+
+        foreach (var whereExpression in specification.WhereExpressions)
+        {
+            query = query.Where(whereExpression.Filter);
         }
 
         return query;
@@ -19,9 +28,18 @@ public IQueryable<T> GetQuery<T>(IQueryable<T> query, ISpecification<T> specific
 
     public IEnumerable<T> Evaluate<T>(IEnumerable<T> query, ISpecification<T> specification)
     {
-        foreach (var info in specification.WhereExpressions)
+        if (specification is Specification<T> spec)
+        {
+            if (spec.OneOrManyWhereExpressions.IsEmpty) return query;
+            if (spec.OneOrManyWhereExpressions.SingleOrDefault is { } whereExpression)
+            {
+                return query.Where(whereExpression.FilterFunc);
+            }
+        }
+
+        foreach (var whereExpression in specification.WhereExpressions)
         {
-            query = query.Where(info.FilterFunc);
+            query = query.Where(whereExpression.FilterFunc);
         }
 
         return query;
diff --git a/src/Ardalis.Specification/Internals/OneOrMany.cs b/src/Ardalis.Specification/Internals/OneOrMany.cs
index 3173abc3..b6a3d98f 100644
--- a/src/Ardalis.Specification/Internals/OneOrMany.cs
+++ b/src/Ardalis.Specification/Internals/OneOrMany.cs
@@ -2,6 +2,7 @@
 
 internal struct OneOrMany<T> where T : class
 {
+    private const int DEFAULT_CAPACITY = 2;
     private object? _value;
 
     public readonly bool IsEmpty => _value is null;
@@ -58,11 +59,11 @@ public void AddSorted(T item, IComparer<T> comparer)
         {
             if (comparer.Compare(item, singleValue) <= 0)
             {
-                _value = new List<T>(2) { item, singleValue };
+                _value = new List<T>(DEFAULT_CAPACITY) { item, singleValue };
             }
             else
             {
-                _value = new List<T>(2) { singleValue, item };
+                _value = new List<T>(DEFAULT_CAPACITY) { singleValue, item };
             }
         }
     }
diff --git a/src/Ardalis.Specification/Specification.cs b/src/Ardalis.Specification/Specification.cs
index 2fd181bf..798edc7f 100644
--- a/src/Ardalis.Specification/Specification.cs
+++ b/src/Ardalis.Specification/Specification.cs
@@ -26,7 +26,6 @@ public class Specification<T, TResult> : Specification<T>, ISpecification<T, TRe
 /// <inheritdoc cref="ISpecification{T}"/>
 public class Specification<T> : ISpecification<T>
 {
-    private const int DEFAULT_CAPACITY_WHERE = 2;
     private const int DEFAULT_CAPACITY_SEARCH = 2;
     private const int DEFAULT_CAPACITY_ORDER = 2;
     private const int DEFAULT_CAPACITY_INCLUDE = 2;
@@ -42,7 +41,7 @@ public class Specification<T> : ISpecification<T>
 
     // The state is null initially, but we're spending 8 bytes per reference (on x64).
     // This will be reconsidered for version 10 where we may store the whole state as a single array of structs.
-    private List<WhereExpressionInfo<T>>? _whereExpressions;
+    private OneOrMany<WhereExpressionInfo<T>> _whereExpressions = new();
     private List<SearchExpressionInfo<T>>? _searchExpressions;
     private List<OrderExpressionInfo<T>>? _orderExpressions;
     private List<IncludeExpressionInfo>? _includeExpressions;
@@ -94,7 +93,7 @@ public class Specification<T> : ISpecification<T>
 
 
     // Specs are not intended to be thread-safe, so we don't need to worry about thread-safety here.
-    internal void Add(WhereExpressionInfo<T> whereExpression) => (_whereExpressions ??= new(DEFAULT_CAPACITY_WHERE)).Add(whereExpression);
+    internal void Add(WhereExpressionInfo<T> whereExpression) => _whereExpressions.Add(whereExpression);
     internal void Add(OrderExpressionInfo<T> orderExpression) => (_orderExpressions ??= new(DEFAULT_CAPACITY_ORDER)).Add(orderExpression);
     internal void Add(IncludeExpressionInfo includeExpression) => (_includeExpressions ??= new(DEFAULT_CAPACITY_INCLUDE)).Add(includeExpression);
     internal void Add(string includeString) => (_includeStrings ??= new(DEFAULT_CAPACITY_INCLUDESTRING)).Add(includeString);
@@ -125,7 +124,7 @@ internal void Add(SearchExpressionInfo<T> searchExpression)
     public Dictionary<string, object> Items => _items ??= [];
 
     /// <inheritdoc/>
-    public IEnumerable<WhereExpressionInfo<T>> WhereExpressions => _whereExpressions ?? Enumerable.Empty<WhereExpressionInfo<T>>();
+    public IEnumerable<WhereExpressionInfo<T>> WhereExpressions => _whereExpressions.Values;
 
     /// <inheritdoc/>
     public IEnumerable<SearchExpressionInfo<T>> SearchCriterias => _searchExpressions ?? Enumerable.Empty<SearchExpressionInfo<T>>();
@@ -142,6 +141,7 @@ internal void Add(SearchExpressionInfo<T> searchExpression)
     /// <inheritdoc/>
     public IEnumerable<string> QueryTags => _queryTags.Values;
 
+    internal OneOrMany<WhereExpressionInfo<T>> OneOrManyWhereExpressions => _whereExpressions;
     internal OneOrMany<string> OneOrManyQueryTags => _queryTags;
 
     /// <inheritdoc/>
@@ -174,9 +174,9 @@ void ISpecification<T>.CopyTo(Specification<T> otherSpec)
         // The expression containers are immutable, having the same instance is fine.
         // We'll just create new collections.
 
-        if (_whereExpressions is not null)
+        if (!_whereExpressions.IsEmpty)
         {
-            otherSpec._whereExpressions = _whereExpressions.ToList();
+            otherSpec._whereExpressions = _whereExpressions.Clone();
         }
 
         if (_includeExpressions is not null)
diff --git a/src/Ardalis.Specification/Validators/WhereValidator.cs b/src/Ardalis.Specification/Validators/WhereValidator.cs
index 2068d9d1..178d7c3d 100644
--- a/src/Ardalis.Specification/Validators/WhereValidator.cs
+++ b/src/Ardalis.Specification/Validators/WhereValidator.cs
@@ -7,9 +7,18 @@ private WhereValidator() { }
 
     public bool IsValid<T>(T entity, ISpecification<T> specification)
     {
-        foreach (var info in specification.WhereExpressions)
+        if (specification is Specification<T> spec)
         {
-            if (info.FilterFunc(entity) == false) return false;
+            if (spec.OneOrManyWhereExpressions.IsEmpty) return true;
+            if (spec.OneOrManyWhereExpressions.SingleOrDefault is { } whereExpression)
+            {
+                return whereExpression.FilterFunc(entity);
+            }
+        }
+
+        foreach (var whereExpression in specification.WhereExpressions)
+        {
+            if (whereExpression.FilterFunc(entity) == false) return false;
         }
 
         return true;
diff --git a/tests/Ardalis.Specification.Tests/Evaluators/WhereEvaluatorTests.cs b/tests/Ardalis.Specification.Tests/Evaluators/WhereEvaluatorTests.cs
index 2e3f5002..a4987c1e 100644
--- a/tests/Ardalis.Specification.Tests/Evaluators/WhereEvaluatorTests.cs
+++ b/tests/Ardalis.Specification.Tests/Evaluators/WhereEvaluatorTests.cs
@@ -7,7 +7,7 @@ public class WhereEvaluatorTests
     public record Customer(int Id);
 
     [Fact]
-    public void Filters_GivenWhereExpression()
+    public void Filters_GivenSingleWhereExpression()
     {
         List<Customer> input = [new(1), new(2), new(3), new(4), new(5)];
         List<Customer> expected = [new(4), new(5)];
@@ -19,6 +19,20 @@ public void Filters_GivenWhereExpression()
         Assert(spec, input, expected);
     }
 
+    [Fact]
+    public void Filters_GivenMultipleWhereExpressions()
+    {
+        List<Customer> input = [new(1), new(2), new(3), new(4), new(5)];
+        List<Customer> expected = [new(4)];
+
+        var spec = new Specification<Customer>();
+        spec.Query
+            .Where(x => x.Id > 3)
+            .Where(x => x.Id < 5);
+
+        Assert(spec, input, expected);
+    }
+
     [Fact]
     public void DoesNotFilter_GivenNoWhereExpression()
     {
