diff --git a/.gitignore b/.gitignore index ce9d491..85ed164 100644 --- a/.gitignore +++ b/.gitignore @@ -261,5 +261,6 @@ paket-files/ __pycache__/ *.pyc - +/.cursor +/.vscode /staging \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c2523c1..35da191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. +- **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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4922ff5..60e1f92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ``` diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg index 42f1ab4..1bf3db1 100644 --- a/assets/badges/coverage-branches.svg +++ b/assets/badges/coverage-branches.svg @@ -1,5 +1,5 @@ - - Branch Coverage: 49.6% + + Branch Coverage: 50.3% @@ -15,7 +15,7 @@ Branch Coverage - - 49.6% + + 50.3% diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg index af0d97b..aa072ca 100644 --- a/assets/badges/coverage-lines.svg +++ b/assets/badges/coverage-lines.svg @@ -1,21 +1,21 @@ - - Line Coverage: 60% + + Line Coverage: 60.1% - + - - + + Line Coverage - - 60% + + 60.1% diff --git a/src/MaksIT.Core.Tests/Extensions/ExceptionExtensionsTests.cs b/src/MaksIT.Core.Tests/Extensions/ExceptionExtensionsTests.cs index 674021a..9111f36 100644 --- a/src/MaksIT.Core.Tests/Extensions/ExceptionExtensionsTests.cs +++ b/src/MaksIT.Core.Tests/Extensions/ExceptionExtensionsTests.cs @@ -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(() => exception!.ExtractMessages()); + } } diff --git a/src/MaksIT.Core.Tests/Extensions/ExpressionExtensionsTests.cs b/src/MaksIT.Core.Tests/Extensions/ExpressionExtensionsTests.cs index 6239dd8..f0abf91 100644 --- a/src/MaksIT.Core.Tests/Extensions/ExpressionExtensionsTests.cs +++ b/src/MaksIT.Core.Tests/Extensions/ExpressionExtensionsTests.cs @@ -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" })); } + /// + /// Ensures AndAlso produces an expression that works with IQueryable.Where (as used by EF Core). + /// + [Fact] + public void AndAlso_ShouldWorkWithIQueryableWhere() { + var source = new List { + new() { Age = 20, Name = "Alice" }, + new() { Age = 17, Name = "Alice" }, + new() { Age = 20, Name = "Bob" }, + new() { Age = 25, Name = "Amy" } + }; + Expression> first = x => x.Age > 18; + Expression> 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 diff --git a/src/MaksIT.Core.Tests/Extensions/ObjectExtensionsTests.cs b/src/MaksIT.Core.Tests/Extensions/ObjectExtensionsTests.cs index ae176f7..99df181 100644 --- a/src/MaksIT.Core.Tests/Extensions/ObjectExtensionsTests.cs +++ b/src/MaksIT.Core.Tests/Extensions/ObjectExtensionsTests.cs @@ -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 diff --git a/src/MaksIT.Core/Extensions/ExceptionExtensions.cs b/src/MaksIT.Core/Extensions/ExceptionExtensions.cs index af25f6b..e838c2d 100644 --- a/src/MaksIT.Core/Extensions/ExceptionExtensions.cs +++ b/src/MaksIT.Core/Extensions/ExceptionExtensions.cs @@ -1,4 +1,4 @@ -namespace MaksIT.Core.Extensions; +namespace MaksIT.Core.Extensions; public static class ExceptionExtensions { /// @@ -7,6 +7,7 @@ public static class ExceptionExtensions { /// The exception to extract messages from. /// A list of exception messages. public static List ExtractMessages(this Exception exception) { + ArgumentNullException.ThrowIfNull(exception); var messages = new List(); var current = exception; diff --git a/src/MaksIT.Core/Extensions/ExpressionExtensions.cs b/src/MaksIT.Core/Extensions/ExpressionExtensions.cs index c2e718e..c118518 100644 --- a/src/MaksIT.Core/Extensions/ExpressionExtensions.cs +++ b/src/MaksIT.Core/Extensions/ExpressionExtensions.cs @@ -1,10 +1,19 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; namespace MaksIT.Core.Extensions; +/// +/// 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). +/// public static class ExpressionExtensions { + /// + /// Combines two predicates with AND. Uses a single parameter and Expression.AndAlso; + /// safe for IQueryable/EF Core (no Invoke). + /// public static Expression> AndAlso(this Expression> first, Expression> second) { ArgumentNullException.ThrowIfNull(first); ArgumentNullException.ThrowIfNull(second); @@ -17,6 +26,10 @@ public static class ExpressionExtensions { return Expression.Lambda>(combinedBody, parameter); } + /// + /// Combines two predicates with OR. Uses a single parameter and Expression.OrElse; + /// safe for IQueryable/EF Core (no Invoke). + /// public static Expression> OrElse(this Expression> first, Expression> second) { ArgumentNullException.ThrowIfNull(first); ArgumentNullException.ThrowIfNull(second); @@ -30,7 +43,7 @@ public static class ExpressionExtensions { } public static Expression> Not(this Expression> expression) { - if (expression == null) throw new ArgumentNullException(nameof(expression)); + ArgumentNullException.ThrowIfNull(expression); var parameter = expression.Parameters[0]; var body = Expression.Not(expression.Body); diff --git a/src/MaksIT.Core/Extensions/ObjectExtensions.cs b/src/MaksIT.Core/Extensions/ObjectExtensions.cs index 86f311c..a6339ce 100644 --- a/src/MaksIT.Core/Extensions/ObjectExtensions.cs +++ b/src/MaksIT.Core/Extensions/ObjectExtensions.cs @@ -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. /// public static T DeepClone(this T source) { + if (source is null) return default!; return (T)DeepCloneInternal(source, new Dictionary(ReferenceEqualityComparer.Instance)); } @@ -49,7 +49,9 @@ public static class ObjectExtensions { /// Deeply compares two objects for structural equality (fields, including private ones). /// public static bool DeepEqual(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)); } /// @@ -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 { 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)> { diff --git a/src/MaksIT.Core/Extensions/StringExtensions.cs b/src/MaksIT.Core/Extensions/StringExtensions.cs index 73dc083..728e539 100644 --- a/src/MaksIT.Core/Extensions/StringExtensions.cs +++ b/src/MaksIT.Core/Extensions/StringExtensions.cs @@ -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); diff --git a/src/MaksIT.Core/Logging/BaseFileLogger.cs b/src/MaksIT.Core/Logging/BaseFileLogger.cs index cc0abf2..e7ebe5c 100644 --- a/src/MaksIT.Core/Logging/BaseFileLogger.cs +++ b/src/MaksIT.Core/Logging/BaseFileLogger.cs @@ -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); } diff --git a/src/MaksIT.Core/Logging/FileLoggerProvider.cs b/src/MaksIT.Core/Logging/FileLoggerProvider.cs index 3b234cf..1502ca9 100644 --- a/src/MaksIT.Core/Logging/FileLoggerProvider.cs +++ b/src/MaksIT.Core/Logging/FileLoggerProvider.cs @@ -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 } diff --git a/src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs b/src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs index 7cee394..2acae74 100644 --- a/src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs +++ b/src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs @@ -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 } diff --git a/src/MaksIT.Core/MaksIT.Core.csproj b/src/MaksIT.Core/MaksIT.Core.csproj index f26ee43..7d1bedb 100644 --- a/src/MaksIT.Core/MaksIT.Core.csproj +++ b/src/MaksIT.Core/MaksIT.Core.csproj @@ -12,7 +12,7 @@ MaksIT.Core - 1.6.4 + 1.6.5 Maksym Sadovnychyy MAKS-IT MaksIT.Core diff --git a/src/MaksIT.Core/Networking/Windows/NetworkConnection.cs b/src/MaksIT.Core/Networking/Windows/NetworkConnection.cs index 14995d3..1fa01c4 100644 --- a/src/MaksIT.Core/Networking/Windows/NetworkConnection.cs +++ b/src/MaksIT.Core/Networking/Windows/NetworkConnection.cs @@ -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, diff --git a/src/MaksIT.Core/Security/Base32Encoder.cs b/src/MaksIT.Core/Security/Base32Encoder.cs index 26e5036..5b79218 100644 --- a/src/MaksIT.Core/Security/Base32Encoder.cs +++ b/src/MaksIT.Core/Security/Base32Encoder.cs @@ -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; diff --git a/src/MaksIT.Core/Security/TotpGenerator.cs b/src/MaksIT.Core/Security/TotpGenerator.cs index be23f89..490c1cd 100644 --- a/src/MaksIT.Core/Security/TotpGenerator.cs +++ b/src/MaksIT.Core/Security/TotpGenerator.cs @@ -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(); 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); } diff --git a/src/MaksIT.Core/Webapi/Models/PagedRequest.cs b/src/MaksIT.Core/Webapi/Models/PagedRequest.cs index eb2b5fc..8458907 100644 --- a/src/MaksIT.Core/Webapi/Models/PagedRequest.cs +++ b/src/MaksIT.Core/Webapi/Models/PagedRequest.cs @@ -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; + +/// +/// Base request model for paged list operations with optional filter and sort. +/// BuildFilterExpression produces expressions suitable for IQueryable/EF Core (single parameter, translatable operations). +/// 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(Filters); } + /// + /// Parses the filter string into an Expression<Func<T, bool>> for use with IQueryable/EF Core. + /// Uses a single parameter; avoid filter syntax that would require non-translatable operations. + /// Supported: property access, ==, !=, &&, ||, !, Contains, StartsWith, EndsWith, ToLower() for strings. + /// public virtual Expression> BuildFilterExpression(string? filters) { if (string.IsNullOrWhiteSpace(filters)) return x => true; // Returns an expression that doesn't filter anything.