Compare commits

...

3 Commits

Author SHA1 Message Date
Maksym Sadovnychyy
627eb52a3b Merge remote-tracking branch 'origin/dev' 2026-03-02 18:54:24 +01:00
Maksym Sadovnychyy
79968ab79b (bugfix): add null-safe deep clone/equality checks and cover IQueryable predicate composition 2026-03-02 18:53:55 +01:00
Maksym Sadovnychyy
9467bde0e7 (docs): extend commit types with industry-standard categories 2026-03-02 18:48:26 +01:00
20 changed files with 139 additions and 47 deletions

3
.gitignore vendored
View File

@ -261,5 +261,6 @@ paket-files/
__pycache__/
*.pyc
/.cursor
/.vscode
/staging

View File

@ -1,10 +1,23 @@
# Changelog
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.6.5 - 2026-02-02
### Changed
- Replaced explicit `ArgumentNullException` throws with `ArgumentNullException.ThrowIfNull` in `ExpressionExtensions`, `NetworkConnection`, `Base32Encoder`, `StringExtensions.CSVToDataTable`, `FileLoggerProvider`, and `JsonFileLoggerProvider`.
- **Base32Encoder**: empty input now throws `ArgumentException` instead of `ArgumentNullException` for clearer semantics.
- **StringExtensions.CSVToDataTable**: null file path throws via `ThrowIfNull`; empty/whitespace path throws `ArgumentException`.
- **ObjectExtensions**: `DeepClone` returns `default` for null input; `DeepEqual` explicitly treats (null, null) as true and (null, non-null) as false. Replaced obsolete `FormatterServices.GetUninitializedObject` with `RuntimeHelpers.GetUninitializedObject`. Fixed nullability in `ReferenceEqualityComparer` to match `IEqualityComparer<object>`.
- **TotpGenerator**: recovery code generation uses range syntax (`code[..4]`, `code[4..8]`) instead of `Substring`.
### Fixed
- **ExceptionExtensions.ExtractMessages**: null check added to avoid `NullReferenceException` when passed null.
- **BaseFileLogger.RemoveExpiredLogFiles**: guard added before `Substring(4)` so malformed log file names do not throw.
## v1.6.4 - 2026-02-21
### Added

View File

@ -46,7 +46,14 @@ This project uses the following commit message format:
| `(feature):` | New feature or enhancement |
| `(bugfix):` | Bug fix |
| `(refactor):` | Code refactoring without functional changes |
| `(chore):` | Maintenance tasks (dependencies, CI, documentation) |
| `(perf):` | Performance improvement without changing behavior |
| `(test):` | Add or update tests |
| `(docs):` | Documentation-only changes |
| `(build):` | Build system, dependencies, packaging, or project file changes |
| `(ci):` | CI/CD pipeline or automation changes |
| `(style):` | Formatting or non-functional code style changes |
| `(revert):` | Revert a previous commit |
| `(chore):` | General maintenance tasks that do not fit the types above |
### Examples
@ -54,6 +61,13 @@ This project uses the following commit message format:
(feature): add support for custom JWT claims
(bugfix): fix multithreading issue in file logger
(refactor): simplify expression extension methods
(perf): reduce allocations in Base32 encoder
(test): add coverage for IQueryable predicate composition
(docs): clarify release workflow prerequisites
(build): update package metadata in MaksIT.Core.csproj
(ci): update GitHub Actions workflow for .NET 10
(style): normalize using directives in extension tests
(revert): revert breaking change in network connection handling
(chore): update copyright year to 2026
```

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 49.6%">
<title>Branch Coverage: 49.6%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 50.3%">
<title>Branch Coverage: 50.3%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">49.6%</text>
<text x="128.75" y="14" fill="#fff">49.6%</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">50.3%</text>
<text x="128.75" y="14" fill="#fff">50.3%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,21 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="134.5" height="20" role="img" aria-label="Line Coverage: 60%">
<title>Line Coverage: 60%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 60.1%">
<title>Line Coverage: 60.1%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="134.5" height="20" rx="3" fill="#fff"/>
<rect width="137" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="40" height="20" fill="#97ca00"/>
<rect width="134.5" height="20" fill="url(#s)"/>
<rect x="94.5" width="42.5" height="20" fill="#97ca00"/>
<rect width="137" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="114.5" y="15" fill="#010101" fill-opacity=".3">60%</text>
<text x="114.5" y="14" fill="#fff">60%</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">60.1%</text>
<text x="115.75" y="14" fill="#fff">60.1%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -77,4 +77,10 @@ public class ExceptionExtensionsTests {
Assert.Single(messages);
Assert.Equal("", messages[0]);
}
[Fact]
public void ExtractMessages_WhenNull_ThrowsArgumentNullException() {
Exception? exception = null;
Assert.Throws<ArgumentNullException>(() => exception!.ExtractMessages());
}
}

View File

@ -1,4 +1,5 @@
using System.Linq.Expressions;
using System.Linq;
using System.Linq.Expressions;
using MaksIT.Core.Extensions;
@ -23,6 +24,28 @@ public class ExpressionExtensionsTests {
Assert.False(compiledPredicate(new TestEntity { Age = 20, Name = "Bob" }));
}
/// <summary>
/// Ensures AndAlso produces an expression that works with IQueryable.Where (as used by EF Core).
/// </summary>
[Fact]
public void AndAlso_ShouldWorkWithIQueryableWhere() {
var source = new List<TestEntity> {
new() { Age = 20, Name = "Alice" },
new() { Age = 17, Name = "Alice" },
new() { Age = 20, Name = "Bob" },
new() { Age = 25, Name = "Amy" }
};
Expression<Func<TestEntity, bool>> first = x => x.Age > 18;
Expression<Func<TestEntity, bool>> second = x => (x.Name ?? "").StartsWith("A");
var combined = first.AndAlso(second);
var result = source.AsQueryable().Where(combined).ToList();
Assert.Equal(2, result.Count);
Assert.Contains(result, e => e.Name == "Alice" && e.Age == 20);
Assert.Contains(result, e => e.Name == "Amy" && e.Age == 25);
}
[Fact]
public void OrElse_ShouldCombineTwoPredicatesWithOrCondition() {
// Arrange

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
@ -235,6 +235,13 @@ namespace MaksIT.Core.Tests.Extensions {
Assert.Same(s, s2);
}
[Fact]
public void DeepClone_WhenNull_ReturnsDefault() {
Person? source = null;
var result = source.DeepClone();
Assert.Null(result);
}
[Fact]
public void DeepEqual_ShouldReturnTrue_ForEqualGraphs() {
// Arrange

View File

@ -1,4 +1,4 @@
namespace MaksIT.Core.Extensions;
namespace MaksIT.Core.Extensions;
public static class ExceptionExtensions {
/// <summary>
@ -7,6 +7,7 @@ public static class ExceptionExtensions {
/// <param name="exception">The exception to extract messages from.</param>
/// <returns>A list of exception messages.</returns>
public static List<string> ExtractMessages(this Exception exception) {
ArgumentNullException.ThrowIfNull(exception);
var messages = new List<string>();
var current = exception;

View File

@ -1,10 +1,19 @@
using System.Linq.Expressions;
using System.Linq.Expressions;
namespace MaksIT.Core.Extensions;
/// <summary>
/// Extension methods for combining and negating expression predicates.
/// AndAlso and OrElse use parameter replacement (no Expression.Invoke), so the result
/// is safe for use with IQueryable and EF Core (translatable to SQL).
/// </summary>
public static class ExpressionExtensions {
/// <summary>
/// Combines two predicates with AND. Uses a single parameter and Expression.AndAlso;
/// safe for IQueryable/EF Core (no Invoke).
/// </summary>
public static Expression<Func<T, bool>> AndAlso<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second) {
ArgumentNullException.ThrowIfNull(first);
ArgumentNullException.ThrowIfNull(second);
@ -17,6 +26,10 @@ public static class ExpressionExtensions {
return Expression.Lambda<Func<T, bool>>(combinedBody, parameter);
}
/// <summary>
/// Combines two predicates with OR. Uses a single parameter and Expression.OrElse;
/// safe for IQueryable/EF Core (no Invoke).
/// </summary>
public static Expression<Func<T, bool>> OrElse<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second) {
ArgumentNullException.ThrowIfNull(first);
ArgumentNullException.ThrowIfNull(second);
@ -30,7 +43,7 @@ public static class ExpressionExtensions {
}
public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> expression) {
if (expression == null) throw new ArgumentNullException(nameof(expression));
ArgumentNullException.ThrowIfNull(expression);
var parameter = expression.Parameters[0];
var body = Expression.Not(expression.Body);

View File

@ -1,6 +1,5 @@
using System.Reflection;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Text.Json;
using System.Text.Json.Serialization;
@ -42,6 +41,7 @@ public static class ObjectExtensions {
/// Creates a deep clone of the object, preserving reference identity and supporting cycles.
/// </summary>
public static T DeepClone<T>(this T source) {
if (source is null) return default!;
return (T)DeepCloneInternal(source, new Dictionary<object, object>(ReferenceEqualityComparer.Instance));
}
@ -49,7 +49,9 @@ public static class ObjectExtensions {
/// Deeply compares two objects for structural equality (fields, including private ones).
/// </summary>
public static bool DeepEqual<T>(this T a, T b) {
return DeepEqualInternal(a, b, new HashSet<(object, object)>(ReferencePairComparer.Instance));
if (a is null && b is null) return true;
if (a is null || b is null) return false;
return DeepEqualInternal(a!, b!, new HashSet<(object, object)>(ReferencePairComparer.Instance));
}
/// <summary>
@ -84,7 +86,7 @@ public static class ObjectExtensions {
return CloneStruct(source, type, visited);
// Reference type: allocate uninitialized object, then copy fields
var clone = FormatterServices.GetUninitializedObject(type);
var clone = RuntimeHelpers.GetUninitializedObject(type);
visited[source] = clone;
CopyAllFields(source, clone, type, visited);
return clone;
@ -249,8 +251,8 @@ public static class ObjectExtensions {
private sealed class ReferenceEqualityComparer : IEqualityComparer<object> {
public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer();
public new bool Equals(object x, object y) => ReferenceEquals(x, y);
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
public int GetHashCode(object? obj) => RuntimeHelpers.GetHashCode(obj!);
}
private sealed class ReferencePairComparer : IEqualityComparer<(object, object)> {

View File

@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
@ -246,8 +246,8 @@ namespace MaksIT.Core.Extensions {
public static string ToKebabCase(this string input) => input.ToCase(StringCaseStyle.KebabCase);
public static DataTable CSVToDataTable(this string filePath) {
if (string.IsNullOrEmpty(filePath))
throw new ArgumentNullException(nameof(filePath));
ArgumentNullException.ThrowIfNull(filePath);
if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException("File path cannot be empty.", nameof(filePath));
using var sr = new StreamReader(filePath);

View File

@ -78,7 +78,8 @@ public abstract class BaseFileLogger : ILogger, IDisposable {
foreach (var logFile in logFiles) {
var fileName = Path.GetFileNameWithoutExtension(logFile);
if (DateTime.TryParseExact(fileName.Substring(4), "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var logDate)) {
if (fileName.Length >= 4 && fileName.StartsWith("log_", StringComparison.Ordinal) &&
DateTime.TryParseExact(fileName.Substring(4), "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var logDate)) {
if (logDate < expirationDate) {
File.Delete(logFile);
}

View File

@ -8,7 +8,8 @@ public class FileLoggerProvider : ILoggerProvider {
private readonly TimeSpan _retentionPeriod;
public FileLoggerProvider(string folderPath, TimeSpan? retentionPeriod = null) {
_folderPath = folderPath ?? throw new ArgumentNullException(nameof(folderPath));
ArgumentNullException.ThrowIfNull(folderPath);
_folderPath = folderPath;
_retentionPeriod = retentionPeriod ?? TimeSpan.FromDays(7); // Default retention period is 7 days
}

View File

@ -8,7 +8,8 @@ public class JsonFileLoggerProvider : ILoggerProvider {
private readonly TimeSpan _retentionPeriod;
public JsonFileLoggerProvider(string folderPath, TimeSpan? retentionPeriod = null) {
_folderPath = folderPath ?? throw new ArgumentNullException(nameof(folderPath));
ArgumentNullException.ThrowIfNull(folderPath);
_folderPath = folderPath;
_retentionPeriod = retentionPeriod ?? TimeSpan.FromDays(7); // Default retention period is 7 days
}

View File

@ -12,7 +12,7 @@
<!-- NuGet package metadata -->
<PackageId>MaksIT.Core</PackageId>
<Version>1.6.4</Version>
<Version>1.6.5</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.Core</Product>

View File

@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
@ -27,9 +27,9 @@ public class NetworkConnection : IDisposable {
throw new PlatformNotSupportedException("NetworkConnection is only supported on Windows.");
}
if (logger == null) throw new ArgumentNullException(nameof(logger));
if (networkName == null) throw new ArgumentNullException(nameof(networkName));
if (credentials == null) throw new ArgumentNullException(nameof(credentials));
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(networkName);
ArgumentNullException.ThrowIfNull(credentials);
var netResource = new NetResource {
Scope = ResourceScope.GlobalNetwork,

View File

@ -1,4 +1,4 @@
using System.Text;
using System.Text;
using System.Diagnostics.CodeAnalysis;
@ -14,9 +14,8 @@ public static class Base32Encoder {
[NotNullWhen(false)] out string? errorMessage
) {
try {
if (data == null || data.Length == 0) {
throw new ArgumentNullException(nameof(data));
}
ArgumentNullException.ThrowIfNull(data);
if (data.Length == 0) throw new ArgumentException("Data cannot be empty.", nameof(data));
var result = new StringBuilder();
int buffer = data[0];
@ -66,9 +65,8 @@ public static class Base32Encoder {
[NotNullWhen(false)] out string? errorMessage
) {
try {
if (string.IsNullOrEmpty(base32)) {
throw new ArgumentNullException(nameof(base32));
}
ArgumentNullException.ThrowIfNull(base32);
if (string.IsNullOrWhiteSpace(base32)) throw new ArgumentException("Base32 string cannot be empty.", nameof(base32));
base32 = base32.TrimEnd(PaddingChar.ToCharArray());
int byteCount = base32.Length * 5 / 8;

View File

@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
namespace MaksIT.Core.Security;
@ -132,8 +132,8 @@ public static class TotpGenerator {
recoveryCodes = new List<string>();
for (int i = 0; i < defaultCodeCount; i++) {
var code = Guid.NewGuid().ToString("N").Substring(0, 8); // Generate an 8-character code
var formattedCode = $"{code.Substring(0, 4)}-{code.Substring(4, 4)}"; // Format as XXXX-XXXX
var code = Guid.NewGuid().ToString("N")[..8]; // Generate an 8-character code
var formattedCode = $"{code[..4]}-{code[4..8]}"; // Format as XXXX-XXXX
recoveryCodes.Add(formattedCode);
}

View File

@ -1,9 +1,15 @@
using System.Linq.Dynamic.Core;
using System.Linq.Dynamic.Core;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
using MaksIT.Core.Abstractions.Webapi;
using MaksIT.Core.Extensions;
namespace MaksIT.Core.Webapi.Models;
/// <summary>
/// Base request model for paged list operations with optional filter and sort.
/// BuildFilterExpression produces expressions suitable for IQueryable/EF Core (single parameter, translatable operations).
/// </summary>
public class PagedRequest : RequestModelBase {
public int PageSize { get; set; } = 100;
public int PageNumber { get; set; } = 1;
@ -16,6 +22,11 @@ public class PagedRequest : RequestModelBase {
return BuildFilterExpression<T>(Filters);
}
/// <summary>
/// Parses the filter string into an Expression&lt;Func&lt;T, bool&gt;&gt; for use with IQueryable/EF Core.
/// Uses a single parameter; avoid filter syntax that would require non-translatable operations.
/// Supported: property access, ==, !=, &amp;&amp;, ||, !, Contains, StartsWith, EndsWith, ToLower() for strings.
/// </summary>
public virtual Expression<Func<T, bool>> BuildFilterExpression<T>(string? filters) {
if (string.IsNullOrWhiteSpace(filters))
return x => true; // Returns an expression that doesn't filter anything.