Compare commits

..

No commits in common. "627eb52a3bcd5249bf1dc0f0bab9d5af9f829672" and "6dfdf4a8629dda8664dfa2f0a7c2c5bf9ae5dcb7" have entirely different histories.

20 changed files with 47 additions and 139 deletions

3
.gitignore vendored
View File

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

View File

@ -1,23 +1,10 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. 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/), 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). 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 ## v1.6.4 - 2026-02-21
### Added ### Added

View File

@ -46,14 +46,7 @@ This project uses the following commit message format:
| `(feature):` | New feature or enhancement | | `(feature):` | New feature or enhancement |
| `(bugfix):` | Bug fix | | `(bugfix):` | Bug fix |
| `(refactor):` | Code refactoring without functional changes | | `(refactor):` | Code refactoring without functional changes |
| `(perf):` | Performance improvement without changing behavior | | `(chore):` | Maintenance tasks (dependencies, CI, documentation) |
| `(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 ### Examples
@ -61,13 +54,6 @@ This project uses the following commit message format:
(feature): add support for custom JWT claims (feature): add support for custom JWT claims
(bugfix): fix multithreading issue in file logger (bugfix): fix multithreading issue in file logger
(refactor): simplify expression extension methods (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 (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: 50.3%"> <svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 49.6%">
<title>Branch Coverage: 50.3%</title> <title>Branch Coverage: 49.6%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" 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"> <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 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 x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">50.3%</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">50.3%</text> <text x="128.75" y="14" fill="#fff">49.6%</text>
</g> </g>
</svg> </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="137" height="20" role="img" aria-label="Line Coverage: 60.1%"> <svg xmlns="http://www.w3.org/2000/svg" width="134.5" height="20" role="img" aria-label="Line Coverage: 60%">
<title>Line Coverage: 60.1%</title> <title>Line Coverage: 60%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
</linearGradient> </linearGradient>
<clipPath id="r"> <clipPath id="r">
<rect width="137" height="20" rx="3" fill="#fff"/> <rect width="134.5" height="20" rx="3" fill="#fff"/>
</clipPath> </clipPath>
<g clip-path="url(#r)"> <g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/> <rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="42.5" height="20" fill="#97ca00"/> <rect x="94.5" width="40" height="20" fill="#97ca00"/>
<rect width="137" height="20" fill="url(#s)"/> <rect width="134.5" height="20" fill="url(#s)"/>
</g> </g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <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 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 x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">60.1%</text> <text aria-hidden="true" x="114.5" y="15" fill="#010101" fill-opacity=".3">60%</text>
<text x="115.75" y="14" fill="#fff">60.1%</text> <text x="114.5" y="14" fill="#fff">60%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

View File

@ -1,5 +1,4 @@
using System.Linq; using System.Linq.Expressions;
using System.Linq.Expressions;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
@ -24,28 +23,6 @@ public class ExpressionExtensionsTests {
Assert.False(compiledPredicate(new TestEntity { Age = 20, Name = "Bob" })); 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] [Fact]
public void OrElse_ShouldCombineTwoPredicatesWithOrCondition() { public void OrElse_ShouldCombineTwoPredicatesWithOrCondition() {
// Arrange // Arrange

View File

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

View File

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

View File

@ -1,19 +1,10 @@
using System.Linq.Expressions; using System.Linq.Expressions;
namespace MaksIT.Core.Extensions; 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 { 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) { public static Expression<Func<T, bool>> AndAlso<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second) {
ArgumentNullException.ThrowIfNull(first); ArgumentNullException.ThrowIfNull(first);
ArgumentNullException.ThrowIfNull(second); ArgumentNullException.ThrowIfNull(second);
@ -26,10 +17,6 @@ public static class ExpressionExtensions {
return Expression.Lambda<Func<T, bool>>(combinedBody, parameter); 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) { public static Expression<Func<T, bool>> OrElse<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second) {
ArgumentNullException.ThrowIfNull(first); ArgumentNullException.ThrowIfNull(first);
ArgumentNullException.ThrowIfNull(second); ArgumentNullException.ThrowIfNull(second);
@ -43,7 +30,7 @@ public static class ExpressionExtensions {
} }
public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> expression) { public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> expression) {
ArgumentNullException.ThrowIfNull(expression); if (expression == null) throw new ArgumentNullException(nameof(expression));
var parameter = expression.Parameters[0]; var parameter = expression.Parameters[0];
var body = Expression.Not(expression.Body); var body = Expression.Not(expression.Body);

View File

@ -1,5 +1,6 @@
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@ -41,7 +42,6 @@ public static class ObjectExtensions {
/// Creates a deep clone of the object, preserving reference identity and supporting cycles. /// Creates a deep clone of the object, preserving reference identity and supporting cycles.
/// </summary> /// </summary>
public static T DeepClone<T>(this T source) { public static T DeepClone<T>(this T source) {
if (source is null) return default!;
return (T)DeepCloneInternal(source, new Dictionary<object, object>(ReferenceEqualityComparer.Instance)); return (T)DeepCloneInternal(source, new Dictionary<object, object>(ReferenceEqualityComparer.Instance));
} }
@ -49,9 +49,7 @@ public static class ObjectExtensions {
/// Deeply compares two objects for structural equality (fields, including private ones). /// Deeply compares two objects for structural equality (fields, including private ones).
/// </summary> /// </summary>
public static bool DeepEqual<T>(this T a, T b) { public static bool DeepEqual<T>(this T a, T b) {
if (a is null && b is null) return true; return DeepEqualInternal(a, b, new HashSet<(object, object)>(ReferencePairComparer.Instance));
if (a is null || b is null) return false;
return DeepEqualInternal(a!, b!, new HashSet<(object, object)>(ReferencePairComparer.Instance));
} }
/// <summary> /// <summary>
@ -86,7 +84,7 @@ public static class ObjectExtensions {
return CloneStruct(source, type, visited); return CloneStruct(source, type, visited);
// Reference type: allocate uninitialized object, then copy fields // Reference type: allocate uninitialized object, then copy fields
var clone = RuntimeHelpers.GetUninitializedObject(type); var clone = FormatterServices.GetUninitializedObject(type);
visited[source] = clone; visited[source] = clone;
CopyAllFields(source, clone, type, visited); CopyAllFields(source, clone, type, visited);
return clone; return clone;
@ -251,8 +249,8 @@ public static class ObjectExtensions {
private sealed class ReferenceEqualityComparer : IEqualityComparer<object> { private sealed class ReferenceEqualityComparer : IEqualityComparer<object> {
public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer(); public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer();
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y); public new bool Equals(object x, object y) => ReferenceEquals(x, y);
public int GetHashCode(object? obj) => RuntimeHelpers.GetHashCode(obj!); public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
} }
private sealed class ReferencePairComparer : IEqualityComparer<(object, object)> { 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.Data;
using System.Globalization; using System.Globalization;
using System.Security.Cryptography; 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 string ToKebabCase(this string input) => input.ToCase(StringCaseStyle.KebabCase);
public static DataTable CSVToDataTable(this string filePath) { public static DataTable CSVToDataTable(this string filePath) {
ArgumentNullException.ThrowIfNull(filePath); if (string.IsNullOrEmpty(filePath))
if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException("File path cannot be empty.", nameof(filePath)); throw new ArgumentNullException(nameof(filePath));
using var sr = new StreamReader(filePath); using var sr = new StreamReader(filePath);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,9 @@
using System.Linq.Dynamic.Core; using System.Linq.Dynamic.Core;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using MaksIT.Core.Abstractions.Webapi; using MaksIT.Core.Abstractions.Webapi;
using MaksIT.Core.Extensions; 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 class PagedRequest : RequestModelBase {
public int PageSize { get; set; } = 100; public int PageSize { get; set; } = 100;
public int PageNumber { get; set; } = 1; public int PageNumber { get; set; } = 1;
@ -22,11 +16,6 @@ public class PagedRequest : RequestModelBase {
return BuildFilterExpression<T>(Filters); 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) { public virtual Expression<Func<T, bool>> BuildFilterExpression<T>(string? filters) {
if (string.IsNullOrWhiteSpace(filters)) if (string.IsNullOrWhiteSpace(filters))
return x => true; // Returns an expression that doesn't filter anything. return x => true; // Returns an expression that doesn't filter anything.