diff --git a/Directory.Build.props b/Directory.Build.props
index 23700e2..094a495 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,8 +8,8 @@
en
Copyright © ONIXLabs 2020
https://github.com/onix-labs/onixlabs-dotnet
- 11.2.0
- 11.2.0
- 11.2.0
+ 11.3.0
+ 11.3.0
+ 11.3.0
diff --git a/OnixLabs.Core.UnitTests.Data/Location.cs b/OnixLabs.Core.UnitTests.Data/Location.cs
new file mode 100644
index 0000000..a2814f4
--- /dev/null
+++ b/OnixLabs.Core.UnitTests.Data/Location.cs
@@ -0,0 +1,24 @@
+// Copyright 2020 ONIXLabs
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+namespace OnixLabs.Core.UnitTests.Data;
+
+public sealed record Location(string City, string Country)
+{
+ public static readonly Location London = new("London", "England");
+ public static readonly Location Paris = new("Paris", "France");
+ public static readonly Location Lisbon = new("Lisbon", "Postugal");
+ public static readonly Location Berlin = new("Berlin", "Germany");
+ public static readonly Location Brussels = new("Brussels", "Belgium");
+}
diff --git a/OnixLabs.Core.UnitTests.Data/Person.cs b/OnixLabs.Core.UnitTests.Data/Person.cs
new file mode 100644
index 0000000..03ac914
--- /dev/null
+++ b/OnixLabs.Core.UnitTests.Data/Person.cs
@@ -0,0 +1,26 @@
+// Copyright 2020 ONIXLabs
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System.Collections.Generic;
+
+namespace OnixLabs.Core.UnitTests.Data;
+
+public sealed record Person(string Name, int Age, IEnumerable Locations)
+{
+ public static readonly Person Alice = new("Alice", 12, [Location.London, Location.Paris]);
+ public static readonly Person Bob = new("Bob", 23, [Location.Lisbon, Location.London]);
+ public static readonly Person Charlie = new("Charlie", 34, [Location.Berlin, Location.Brussels]);
+
+ public static readonly IEnumerable People = [Alice, Bob, Charlie];
+}
diff --git a/OnixLabs.Core.UnitTests.Data/PersonSpecifications.cs b/OnixLabs.Core.UnitTests.Data/PersonSpecifications.cs
new file mode 100644
index 0000000..785643b
--- /dev/null
+++ b/OnixLabs.Core.UnitTests.Data/PersonSpecifications.cs
@@ -0,0 +1,37 @@
+// Copyright 2020 ONIXLabs
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System;
+using System.Linq;
+using System.Linq.Expressions;
+
+namespace OnixLabs.Core.UnitTests.Data;
+
+public class PersonSpecification(Expression> expression) :
+ CriteriaSpecification(expression);
+
+public sealed class PersonNameEqualsSpecification(string name) :
+ PersonSpecification(person => person.Name == name);
+
+public sealed class PersonNameStartsWithSpecification(string name) :
+ PersonSpecification(person => person.Name.StartsWith(name));
+
+public sealed class PersonOlderThanSpecification(int age) :
+ PersonSpecification(person => person.Age > age);
+
+public sealed class PersonHasLocationSpecification(Location location) :
+ PersonSpecification(person => person.Locations.Contains(location));
+
+public sealed class PersonHasLocationCitySpecification(string city) :
+ PersonSpecification(person => person.Locations.Any(location => location.City == city));
diff --git a/OnixLabs.Core.UnitTests/OptionalTests.cs b/OnixLabs.Core.UnitTests/OptionalTests.cs
index 7eb8ca9..1b490f9 100644
--- a/OnixLabs.Core.UnitTests/OptionalTests.cs
+++ b/OnixLabs.Core.UnitTests/OptionalTests.cs
@@ -336,7 +336,7 @@ public void OptionalNoneGetValueOrDefaultShouldProduceExpectedResult()
// Then
Assert.Equal(0, actualNumber);
- Assert.Equal(null, actualText);
+ Assert.Null(actualText);
}
[Fact(DisplayName = "Optional Some.GetValueOrDefault with default value should produce the expected result.")]
diff --git a/OnixLabs.Core.UnitTests/SpecificationTests.cs b/OnixLabs.Core.UnitTests/SpecificationTests.cs
new file mode 100644
index 0000000..faddb01
--- /dev/null
+++ b/OnixLabs.Core.UnitTests/SpecificationTests.cs
@@ -0,0 +1,175 @@
+// Copyright 2020 ONIXLabs
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System.Collections.Generic;
+using System.Linq;
+using OnixLabs.Core.Linq;
+using OnixLabs.Core.UnitTests.Data;
+using Xunit;
+
+namespace OnixLabs.Core.UnitTests;
+
+public sealed class SpecificationTests
+{
+ [Fact(DisplayName = "PersonNameEqualsSpecification should return the expected result")]
+ public void PersonNameEqualsSpecificationShouldReturnExpectedResult()
+ {
+ // Given
+ PersonNameEqualsSpecification specification = new("Alice");
+
+ // When
+ IEnumerable result = Person.People.Where(specification).ToList();
+
+ // Then
+ Assert.Single(result);
+ Assert.Contains(Person.Alice, result);
+ }
+
+ [Fact(DisplayName = "PersonNameStartsWithSpecification should return the expected result")]
+ public void PersonNameStartsWithSpecificationShouldReturnExpectedResult()
+ {
+ // Given
+ PersonNameStartsWithSpecification specification = new("A");
+
+ // When
+ IEnumerable result = Person.People.Where(specification).ToList();
+
+ // Then
+ Assert.Single(result);
+ Assert.Contains(Person.Alice, result);
+ }
+
+ [Fact(DisplayName = "PersonOlderThanSpecification should return the expected result")]
+ public void PersonOlderThanSpecificationShouldReturnExpectedResult()
+ {
+ // Given
+ PersonOlderThanSpecification specification = new(20);
+
+ // When
+ IEnumerable result = Person.People.Where(specification).ToList();
+
+ // Then
+ Assert.Equal(2, IEnumerableExtensions.Count(result));
+ Assert.Contains(Person.Bob, result);
+ Assert.Contains(Person.Charlie, result);
+ }
+
+ [Fact(DisplayName = "PersonHasLocationSpecification should return the expected result")]
+ public void PersonHasLocationSpecificationShouldReturnExpectedResult()
+ {
+ // Given
+ PersonHasLocationSpecification specification = new(Location.London);
+
+ // When
+ IEnumerable result = Person.People.Where(specification).ToList();
+
+ // Then
+ Assert.Equal(2, IEnumerableExtensions.Count(result));
+ Assert.Contains(Person.Alice, result);
+ Assert.Contains(Person.Bob, result);
+ }
+
+ [Fact(DisplayName = "PersonHasLocationCitySpecification should return the expected result")]
+ public void PersonHasLocationCitySpecificationShouldReturnExpectedResult()
+ {
+ // Given
+ PersonHasLocationCitySpecification specification = new(Location.London.City);
+
+ // When
+ IEnumerable result = Person.People.Where(specification).ToList();
+
+ // Then
+ Assert.Equal(2, IEnumerableExtensions.Count(result));
+ Assert.Contains(Person.Alice, result);
+ Assert.Contains(Person.Bob, result);
+ }
+
+ [Fact(DisplayName = "PersonSpecification.And should return the expected result")]
+ public void PersonSpecificationAndShouldReturnExpectedResult()
+ {
+ // Given
+ Specification specification = PersonSpecification.And(
+ new PersonOlderThanSpecification(20),
+ new PersonHasLocationCitySpecification("London")
+ );
+
+ // When
+ IEnumerable result = Person.People.Where(specification).ToList();
+
+ // Then
+ Assert.Single(result);
+ Assert.Contains(Person.Bob, result);
+ }
+
+ [Fact(DisplayName = "PersonSpecification.And should return true for an empty collection")]
+ public void PersonSpecificationAndShouldReturnTrueForEmptyCollection()
+ {
+ // Given
+ Specification specification = PersonSpecification.And();
+
+ // When
+ bool result = specification.IsSatisfiedBy(Person.Alice);
+
+ // Then
+ Assert.True(result);
+ }
+
+ [Fact(DisplayName = "PersonSpecification.Or should return the expected result")]
+ public void PersonSpecificationOrShouldReturnExpectedResult()
+ {
+ // Given
+ Specification specification = PersonSpecification.Or(
+ new PersonNameStartsWithSpecification("A"),
+ new PersonHasLocationCitySpecification("Lisbon")
+ );
+
+ // When
+ IEnumerable result = Person.People.Where(specification).ToList();
+
+ // Then
+ Assert.Equal(2, result.Count());
+ Assert.Contains(Person.Alice, result);
+ Assert.Contains(Person.Bob, result);
+ }
+
+ [Fact(DisplayName = "PersonSpecification.Or should return false for an empty collection")]
+ public void PersonSpecificationOrShouldReturnFalseForEmptyCollection()
+ {
+ // Given
+ Specification specification = PersonSpecification.Or();
+
+ // When
+ bool result = specification.IsSatisfiedBy(Person.Alice);
+
+ // Then
+ Assert.False(result);
+ }
+
+ [Fact(DisplayName = "PersonSpecification.Not should return the expected result")]
+ public void PersonSpecificationNotShouldReturnExpectedResult()
+ {
+ // Given
+ Specification specification = PersonSpecification.Or(
+ new PersonNameStartsWithSpecification("A"),
+ new PersonHasLocationCitySpecification("Lisbon")
+ );
+
+ // When
+ IEnumerable result = Person.People.WhereNot(specification).ToList();
+
+ // Then
+ Assert.Single(result);
+ Assert.Contains(Person.Charlie, result);
+ }
+}
diff --git a/OnixLabs.Core/Extensions.DateTime.cs b/OnixLabs.Core/Extensions.DateTime.cs
index ce86296..1380886 100644
--- a/OnixLabs.Core/Extensions.DateTime.cs
+++ b/OnixLabs.Core/Extensions.DateTime.cs
@@ -1,4 +1,4 @@
-// Copyright 2020-2024 ONIXLabs
+// Copyright 2020 ONIXLabs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
diff --git a/OnixLabs.Core/IBinaryConvertible.cs b/OnixLabs.Core/IBinaryConvertible.cs
index 74b07a6..d3d3fe1 100644
--- a/OnixLabs.Core/IBinaryConvertible.cs
+++ b/OnixLabs.Core/IBinaryConvertible.cs
@@ -12,9 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+using System;
+
namespace OnixLabs.Core;
///
-/// Defines a type that is convertible to an instance of .
+/// Defines a type that is convertible to a , or .
///
public interface IBinaryConvertible : ISpanBinaryConvertible, IMemoryBinaryConvertible;
diff --git a/OnixLabs.Core/IMemoryBinaryConvertible.cs b/OnixLabs.Core/IMemoryBinaryConvertible.cs
index 9e7a447..9f6361b 100644
--- a/OnixLabs.Core/IMemoryBinaryConvertible.cs
+++ b/OnixLabs.Core/IMemoryBinaryConvertible.cs
@@ -1,4 +1,4 @@
-// Copyright 2020-2024 ONIXLabs
+// Copyright 2020 ONIXLabs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
diff --git a/OnixLabs.Core/Linq/Extensions.Expression.cs b/OnixLabs.Core/Linq/Extensions.Expression.cs
new file mode 100644
index 0000000..71cb41c
--- /dev/null
+++ b/OnixLabs.Core/Linq/Extensions.Expression.cs
@@ -0,0 +1,112 @@
+// Copyright 2020 ONIXLabs
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System;
+using System.ComponentModel;
+using System.Linq;
+using System.Linq.Expressions;
+
+namespace OnixLabs.Core.Linq;
+
+///
+/// Provides extension methods for .
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+public static class ExpressionExtensions
+{
+ private const string ParameterName = "$param";
+
+ ///
+ /// Combines two instances into a single expression using the logical AND operator.
+ ///
+ /// The left-hand expression to combine.
+ /// The right-hand expression to combine.
+ /// The underlying type of the expression.
+ ///
+ /// Returns a new that combines two instances
+ /// into a single expression using the logical AND operator.
+ ///
+ ///
+ /// Calling this method introduces a new expression parameter (named $param) to ensure both expressions share the same parameter.
+ /// Internal or nested lambda parameters that do not match the replaced parameter remain untouched.
+ ///
+ public static Expression> And(this Expression> left, Expression> right)
+ {
+ ParameterExpression parameter = Expression.Parameter(typeof(T), ParameterName);
+ Expression leftBody = new ReplaceParameterVisitor(left.Parameters.First(), parameter).Visit(left.Body);
+ Expression rightBody = new ReplaceParameterVisitor(right.Parameters.First(), parameter).Visit(right.Body);
+ BinaryExpression binaryExpression = Expression.AndAlso(leftBody, rightBody);
+
+ return Expression.Lambda>(binaryExpression, parameter);
+ }
+
+ ///
+ /// Combines two instances into a single expression using the logical OR operator.
+ ///
+ /// The left-hand expression to combine.
+ /// The right-hand expression to combine.
+ /// The underlying type of the expression.
+ ///
+ /// Returns a new that combines two instances
+ /// into a single expression using the logical OR operator.
+ ///
+ ///
+ /// Calling this method introduces a new expression parameter (named $param) to ensure both expressions share the same parameter.
+ /// Internal or nested lambda parameters that do not match the replaced parameter remain untouched.
+ ///
+ public static Expression> Or(this Expression> left, Expression> right)
+ {
+ ParameterExpression parameter = Expression.Parameter(typeof(T), ParameterName);
+ Expression leftBody = new ReplaceParameterVisitor(left.Parameters.First(), parameter).Visit(left.Body);
+ Expression rightBody = new ReplaceParameterVisitor(right.Parameters.First(), parameter).Visit(right.Body);
+ BinaryExpression binaryExpression = Expression.OrElse(leftBody, rightBody);
+
+ return Expression.Lambda>(binaryExpression, parameter);
+ }
+
+ ///
+ /// Negates the current instance using the logical NOT operator.
+ ///
+ /// The expression to negate.
+ /// The underlying type of the expression.
+ ///
+ /// Returns a new that negates the current instance using the logical NOT operator.
+ ///
+ ///
+ /// Calling this method introduces a new expression parameter (named $param) to ensure a uniform parameter expression.
+ /// Internal or nested lambda parameters that do not match the replaced parameter remain untouched.
+ ///
+ public static Expression> Not(this Expression> expression)
+ {
+ ParameterExpression parameter = Expression.Parameter(typeof(T), ParameterName);
+ Expression expressionBody = new ReplaceParameterVisitor(expression.Parameters.First(), parameter).Visit(expression.Body);
+
+ UnaryExpression unaryExpression = Expression.Not(expressionBody);
+ return Expression.Lambda>(unaryExpression, parameter);
+ }
+
+ ///
+ /// Represents an expression visitor that replaces parameter expressions in an expression tree.
+ ///
+ ///
+ /// This class is useful when combining multiple expression trees that each use their own parameter expressions.
+ /// It ensures all expressions use a consistent parameter expression, which is necessary for creating valid and
+ /// executable combined expressions.
+ ///
+ private sealed class ReplaceParameterVisitor(ParameterExpression source, ParameterExpression target) : ExpressionVisitor
+ {
+ protected override Expression VisitParameter(ParameterExpression node) =>
+ node == source ? target : base.VisitParameter(node);
+ }
+}
diff --git a/OnixLabs.Core/Linq/Extensions.IEnumerable.cs b/OnixLabs.Core/Linq/Extensions.IEnumerable.cs
index 8da96b8..8590a1d 100644
--- a/OnixLabs.Core/Linq/Extensions.IEnumerable.cs
+++ b/OnixLabs.Core/Linq/Extensions.IEnumerable.cs
@@ -38,6 +38,7 @@ public static class IEnumerableExtensions
private const string EnumerableNullExceptionMessage = "Enumerable must not be null.";
private const string SelectorNullExceptionMessage = "Selector must not be null.";
private const string PredicateNullExceptionMessage = "Predicate must not be null.";
+ private const string SpecificationNullExceptionMessage = "Specification must not be null.";
private const string ActionNullExceptionMessage = "Action must not be null.";
///
@@ -110,16 +111,7 @@ public static int Count(this IEnumerable enumerable)
// ReSharper disable PossibleMultipleEnumeration
RequireNotNull(enumerable, EnumerableNullExceptionMessage, nameof(enumerable));
- int count = 0;
-
- // ReSharper disable once HeapView.ObjectAllocation.Possible
- foreach (object? _ in enumerable)
- checked
- {
- ++count;
- }
-
- return count;
+ return Enumerable.Count(enumerable.Cast