From b074338091338f0056fe2d5cbfab9be14fd6bd88 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:31:22 -0500 Subject: [PATCH 01/30] feat(abstractions): add shared ValiFlowDiagnostics observability helper Adds ActivitySource-based StartActivity/RecordException helper used across all packages for Activity-based tracing (OpenTelemetry-compatible), plus null guards in ExpressionInspector and a dedicated Abstractions.Tests project. --- CLAUDE.md | 124 --------------- .../Diagnostics/ValiFlowDiagnosticsTests.cs | 73 +++++++++ .../ExpressionInspectorTests.cs | 144 ++++++++++++++++++ .../Vali-Flow.Abstractions.Tests.csproj | 28 ++++ .../Diagnostics/ValiFlowDiagnostics.cs | 63 ++++++++ .../Helpers/ExpressionInspector.cs | 14 +- 6 files changed, 320 insertions(+), 126 deletions(-) delete mode 100644 CLAUDE.md create mode 100644 Vali-Flow.Abstractions.Tests/Diagnostics/ValiFlowDiagnosticsTests.cs create mode 100644 Vali-Flow.Abstractions.Tests/ExpressionInspectorTests.cs create mode 100644 Vali-Flow.Abstractions.Tests/Vali-Flow.Abstractions.Tests.csproj create mode 100644 Vali-Flow.Abstractions/Diagnostics/ValiFlowDiagnostics.cs diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8dd9d88..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,124 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -**Vali-Flow** is a .NET library ecosystem that simplifies data access in Entity Framework Core applications via a fluent specification API. It is distributed as three independent NuGet packages, all built on top of **Vali-Flow.Core** (the expression tree builder — maintained in a separate repository at `/Users/feliperafaelmontenegro/RiderProjects/Vali-Flow.Core`). - -| Package | Version | Purpose | -|---------|---------|---------| -| `Vali-Flow` | 1.1.0 | EF Core evaluator + specification classes | -| `Vali-Flow.InMemory` | 1.0.0 | Synchronous in-memory evaluator (testing/caching) | -| `Vali-Flow.Sql` | 1.0.0 | Expression → parameterized SQL translator (Dapper/ADO.NET) | - -## Vali-Flow.Core Dependency - -All packages depend on **Vali-Flow.Core**, which provides `ValiFlow` (the fluent expression builder) and `ValiSort`. - -- All three packages (`Vali-Flow`, `Vali-Flow.InMemory`, `Vali-Flow.Sql`) reference Core via local `ProjectReference` pointing to `/Users/feliperafaelmontenegro/RiderProjects/Vali-Flow.Core/Vali-Flow.Core/Vali-Flow.Core.csproj`. -- **Do not change these to NuGet references yet** — Core v2.6.0 has not been published to NuGet. Once deployed, replace each `ProjectReference` with ``. -- **Current local Core version is 2.6.0** (net8.0 + net9.0, 911 tests). Core was extensively refactored: bugs fixed, new methods added (IsInEnum, IsDefault, IsCreditCard, IsIPv4/IPv6, IsHexColor, IsSlug, WithMessage(Func), IsLastDayOfMonth, EF Core-safe DateOnly/DateTimeOffset methods). - -## Build & Pack Commands - -```bash -# Build the full solution -dotnet build - -# Build in release -dotnet build --configuration Release - -# Pack individual packages -dotnet pack Vali-Flow/Vali-Flow.csproj --configuration Release -dotnet pack Vali-Flow.InMemory/Vali-Flow.InMemory.csproj --configuration Release -dotnet pack Vali-Flow.Sql/Vali-Flow.Sql.csproj --configuration Release -``` - -> There are currently no automated tests in this repo. The `vali-flow-test/` project is a console demo app, not a test suite. - -## Architecture - -### Package Structure - -``` -Vali-Flow.Core (local — /Users/feliperafaelmontenegro/RiderProjects/Vali-Flow.Core) - ↓ (ProjectReference — all three packages) -┌──────┴───────────────────────────────┐ -│ Vali-Flow (EF Core, async) │ -│ Vali-Flow.InMemory (sync) │ -│ Vali-Flow.Sql (Dapper/ADO.NET) │ -└──────────────────────────────────────┘ -``` - -### Vali-Flow (Main Package) - -**Entry point:** `ValiFlowEvaluator` — a sealed class taking a `DbContext`. Implements `IEvaluatorRead` + `IEvaluatorWrite`. - -**Specifications:** -``` -ISpecification - └─ IBasicSpecification → BasicSpecification (filter + includes + EF options) - └─ IQuerySpecification → QuerySpecification (+ ordering + pagination + top) -``` - -`BasicSpecification` holds: `ValiFlow` filter, `List>` includes, and EF Core hints (AsNoTracking, AsSplitQuery, IgnoreQueryFilters). - -`QuerySpecification` adds: primary `IEfOrderBy`, secondary `List>`, page/pageSize/top, and `ValiSort`. - -**Options** (in `Classes/Options/`): `EfInclude`, `EfOrderBy`, `EfOrderThenBy` — all sealed, generic over the property type. - -**Evaluator read methods:** EvaluateAsync (single entity), EvaluateAnyAsync, EvaluateCountAsync, EvaluateGetFirstAsync, EvaluateGetLastAsync, EvaluateGetFirstFailedAsync, EvaluateGetLastFailedAsync, EvaluateQueryAsync, EvaluateQueryFailedAsync, EvaluateDistinctAsync, EvaluateDuplicatesAsync, aggregate methods (Min/Max/Average/Sum), grouped methods (EvaluateGroupedAsync, EvaluateCountByGroupAsync, EvaluateSumByGroupAsync, EvaluateMinByGroupAsync, EvaluateMaxByGroupAsync, EvaluateAverageByGroupAsync, EvaluateTopByGroupAsync, EvaluateAggregateAsync). - -**Evaluator write methods:** AddAsync, UpdateAsync, DeleteAsync, AddRangeAsync, UpdateRangeAsync, DeleteRangeAsync, DeleteByConditionAsync, UpsertAsync, UpsertRangeAsync, BulkInsertAsync, BulkUpdateAsync, BulkDeleteAsync, BulkInsertOrUpdateAsync, ExecuteTransactionAsync, SaveChangesAsync. - -### Vali-Flow.InMemory - -**Entry point:** `ValiFlowEvaluator` — synchronous, operates on `IEnumerable`. Takes `initialData`, an optional `ValiFlow`, and a `Func getId`. Extends `EvaluatorBase`. - -Mirrors the EF Core evaluator API but synchronous and without a DbContext. Useful for unit testing and caching layers. - -### Vali-Flow.Sql - -**Entry point:** `ValiFlowSqlExtensions` — provides `.ToSql(dialect)` extension method on `ValiFlow`, returning a `SqlResult` (with `Sql` string and `IReadOnlyDictionary Parameters`). - -**Translator:** `ExpressionToSqlVisitor` (sealed, internal) — an `ExpressionVisitor` that walks expression trees and emits dialect-specific SQL. - -**Dialects (all sealed):** -- `SqlServerDialect` — `@param`, `[col]`, `LOWER()` -- `PostgreSqlDialect` — `@param`, `"col"`, native `ILIKE` -- `MySqlDialect` — `@param`, `` `col` ``, case-insensitive `LIKE` -- `SqliteDialect` — `@param`, `"col"`, `LOWER()` - -`SqlResult.ApplyTo(IDbCommand)` applies parameters directly to an ADO.NET command. - -## Key Design Decisions - -- **Specification pattern**: query criteria are decoupled from the evaluator — build a spec, pass it to the evaluator. -- **Deferred saves**: pass `saveChanges: false` to batch operations and call `SaveChangesAsync()` once. -- **Bulk operations**: `BulkInsert/Update/Delete/InsertOrUpdate` delegate to `EFCore.BulkExtensions` — pass a `BulkConfig` to control batch size and identity output. -- **`negateCondition` parameter**: all read methods accept this flag to invert the `ValiFlow` filter (logical NOT). -- **`ValiSort`**: sorting helper from Vali-Flow.Core; used by `QuerySpecification` for dynamic/reflection-based ordering. -- **No xUnit tests yet**: testing is done via the `vali-flow-test` console app. A proper test project (xUnit + FluentAssertions) should be added following the same pattern as `Vali-Flow.Core.Tests`. - -## CRITICAL: Partial Classes & Modifiers - -⚠️ **IMPORTANT WHEN MODIFYING ValiFlowEvaluator:** - -Both `ValiFlowEvaluator` (Vali-Flow) and `ValiFlowEvaluator` (InMemory) are **`partial` classes split across multiple files**: - -**Vali-Flow:** -- `ValiFlowEvaluator.cs` — main declaration (MUST have `public partial class`) -- `ValiFlowEvaluator.Read.cs` — read operations -- `ValiFlowEvaluator.Write.cs` — write operations -- `ValiFlowEvaluator.Aggregates.cs` — aggregates - -**InMemory:** -- Similar split structure - -**GOTCHA:** When removing modifiers (like `sealed`), ALWAYS ensure the main declaration includes `partial`. Forgetting this causes `CS0260: Missing partial modifier` compilation error even though the class looks correct in isolation. - -**Process for modifying these classes:** -1. Edit ValiFlowEvaluator.cs and verify: `public partial class ValiFlowEvaluator` -2. If removing sealed or other modifiers, rebuild with `dotnet build` BEFORE packing NuGet -3. After rebuild succeeds, generate NuGet with `dotnet pack --no-build` diff --git a/Vali-Flow.Abstractions.Tests/Diagnostics/ValiFlowDiagnosticsTests.cs b/Vali-Flow.Abstractions.Tests/Diagnostics/ValiFlowDiagnosticsTests.cs new file mode 100644 index 0000000..732a4af --- /dev/null +++ b/Vali-Flow.Abstractions.Tests/Diagnostics/ValiFlowDiagnosticsTests.cs @@ -0,0 +1,73 @@ +using System.Diagnostics; +using Vali_Flow.Abstractions.Diagnostics; + +namespace Vali_Flow.Abstractions.Tests.Diagnostics; + +public sealed class ValiFlowDiagnosticsTests +{ + private static ActivityListener AttachListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public void StartActivity_NoListenerAttached_ReturnsNull() + { + // Without a listener, ActivitySource.StartActivity short-circuits to null — near-zero cost. + Activity? activity = ValiFlowDiagnostics.StartActivity("Test.Operation"); + + activity.Should().BeNull(); + } + + [Fact] + public void StartActivity_WithListener_SetsTagAndEntityType() + { + using var listener = AttachListener(); + + using Activity? activity = ValiFlowDiagnostics.StartActivity("Test.Operation", tag: "MyReport", entityType: "User"); + + activity.Should().NotBeNull(); + activity!.GetTagItem("vali_flow.tag").Should().Be("MyReport"); + activity.GetTagItem("vali_flow.entity_type").Should().Be("User"); + } + + [Fact] + public void StartActivity_WithoutTagOrEntityType_SetsNoExtraTags() + { + using var listener = AttachListener(); + + using Activity? activity = ValiFlowDiagnostics.StartActivity("Test.Operation"); + + activity.Should().NotBeNull(); + activity!.GetTagItem("vali_flow.tag").Should().BeNull(); + activity.GetTagItem("vali_flow.entity_type").Should().BeNull(); + } + + [Fact] + public void RecordException_NullActivity_DoesNothing() + { + var act = () => ValiFlowDiagnostics.RecordException(null, new InvalidOperationException("boom")); + + act.Should().NotThrow(); + } + + [Fact] + public void RecordException_SetsErrorStatusAndExceptionEvent() + { + using var listener = AttachListener(); + using Activity? activity = ValiFlowDiagnostics.StartActivity("Test.Operation"); + + var ex = new InvalidOperationException("boom"); + ValiFlowDiagnostics.RecordException(activity, ex); + + activity!.Status.Should().Be(ActivityStatusCode.Error); + activity.StatusDescription.Should().Be("boom"); + activity.Events.Should().ContainSingle(e => e.Name == "exception"); + } +} diff --git a/Vali-Flow.Abstractions.Tests/ExpressionInspectorTests.cs b/Vali-Flow.Abstractions.Tests/ExpressionInspectorTests.cs new file mode 100644 index 0000000..18a4fac --- /dev/null +++ b/Vali-Flow.Abstractions.Tests/ExpressionInspectorTests.cs @@ -0,0 +1,144 @@ +using System.Linq.Expressions; +using Vali_Flow.Abstractions.Helpers; + +namespace Vali_Flow.Abstractions.Tests; + +public sealed class ExpressionInspectorTests +{ + private sealed class TestEntity + { + public int Age { get; set; } + public string? Name { get; set; } + } + + // ── IsColumnExpression ────────────────────────────────────────────── + + [Fact] + public void IsColumnExpression_DirectMember_ReturnsTrue() + { + Expression> expr = x => x.Age; + ExpressionInspector.IsColumnExpression(expr.Body).Should().BeTrue(); + } + + [Fact] + public void IsColumnExpression_MemberWrappedInConvert_ReturnsTrue() + { + Expression> expr = x => x.Age; + // x.Age boxed to object produces Convert(MemberExpression) + ExpressionInspector.IsColumnExpression(expr.Body).Should().BeTrue(); + } + + [Fact] + public void IsColumnExpression_Constant_ReturnsFalse() + { + Expression> expr = _ => 18; + ExpressionInspector.IsColumnExpression(expr.Body).Should().BeFalse(); + } + + [Fact] + public void IsColumnExpression_Null_ThrowsArgumentNullException() + { + var act = () => ExpressionInspector.IsColumnExpression(null!); + act.Should().Throw(); + } + + // ── IsNullConstant ─────────────────────────────────────────────────── + + [Fact] + public void IsNullConstant_NullLiteral_ReturnsTrue() + { + Expression> expr = x => x.Name == null; + var binary = (BinaryExpression)expr.Body; + ExpressionInspector.IsNullConstant(binary.Right).Should().BeTrue(); + } + + [Fact] + public void IsNullConstant_NonNullConstant_ReturnsFalse() + { + Expression> expr = x => x.Name == "a"; + var binary = (BinaryExpression)expr.Body; + ExpressionInspector.IsNullConstant(binary.Right).Should().BeFalse(); + } + + [Fact] + public void IsNullConstant_Null_ThrowsArgumentNullException() + { + var act = () => ExpressionInspector.IsNullConstant(null!); + act.Should().Throw(); + } + + [Fact] + public void IsNullConstant_NullWrappedInConvertChecked_ReturnsTrue() + { + // Mirrors the ConvertChecked handling already present in IsColumnExpression. + var convertChecked = Expression.ConvertChecked(Expression.Constant(null, typeof(object)), typeof(string)); + ExpressionInspector.IsNullConstant(convertChecked).Should().BeTrue(); + } + + // ── EvaluateExpression ─────────────────────────────────────────────── + + [Fact] + public void EvaluateExpression_Constant_ReturnsValue() + { + Expression> expr = _ => 42; + ExpressionInspector.EvaluateExpression(expr.Body).Should().Be(42); + } + + [Fact] + public void EvaluateExpression_ClosureCapturedVariable_ReturnsRuntimeValue() + { + int captured = 7; + Expression> expr = _ => captured + 1; + ExpressionInspector.EvaluateExpression(expr.Body).Should().Be(8); + } + + [Fact] + public void EvaluateExpression_ExpressionReferencingUnboundParameter_ThrowsInvalidOperationException() + { + Expression> expr = x => x.Age; + + var act = () => ExpressionInspector.EvaluateExpression(expr.Body); + + act.Should().Throw(); + } + + [Fact] + public void EvaluateExpression_Null_ThrowsArgumentNullException() + { + var act = () => ExpressionInspector.EvaluateExpression(null!); + + act.Should().Throw(); + } + + // ── FlipComparisonOperator ─────────────────────────────────────────── + + [Theory] + [InlineData(ExpressionType.GreaterThan, ExpressionType.LessThan)] + [InlineData(ExpressionType.GreaterThanOrEqual, ExpressionType.LessThanOrEqual)] + [InlineData(ExpressionType.LessThan, ExpressionType.GreaterThan)] + [InlineData(ExpressionType.LessThanOrEqual, ExpressionType.GreaterThanOrEqual)] + [InlineData(ExpressionType.Equal, ExpressionType.Equal)] + [InlineData(ExpressionType.NotEqual, ExpressionType.NotEqual)] + public void FlipComparisonOperator_ReturnsExpectedMirror(ExpressionType input, ExpressionType expected) + { + ExpressionInspector.FlipComparisonOperator(input).Should().Be(expected); + } + + [Fact] + public void FlipComparisonOperator_SemanticsPreserved_ConstantOnLeft() + { + // 18 < x.Age == x.Age > 18 + const int left = 18; + const int age = 25; + + bool original = left < age; + var flipped = ExpressionInspector.FlipComparisonOperator(ExpressionType.LessThan); + bool flippedResult = flipped switch + { + ExpressionType.GreaterThan => age > left, + _ => throw new InvalidOperationException("unexpected flip result") + }; + + flippedResult.Should().Be(original); + } +} diff --git a/Vali-Flow.Abstractions.Tests/Vali-Flow.Abstractions.Tests.csproj b/Vali-Flow.Abstractions.Tests/Vali-Flow.Abstractions.Tests.csproj new file mode 100644 index 0000000..619d5db --- /dev/null +++ b/Vali-Flow.Abstractions.Tests/Vali-Flow.Abstractions.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + Vali_Flow.Abstractions.Tests + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/Vali-Flow.Abstractions/Diagnostics/ValiFlowDiagnostics.cs b/Vali-Flow.Abstractions/Diagnostics/ValiFlowDiagnostics.cs new file mode 100644 index 0000000..62b2515 --- /dev/null +++ b/Vali-Flow.Abstractions/Diagnostics/ValiFlowDiagnostics.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; + +namespace Vali_Flow.Abstractions.Diagnostics; + +/// +/// Shared for the whole Vali-Flow ecosystem. Every evaluator/translator +/// starts an per operation through — this is the +/// "tag"/observability hook across packages: no logging framework dependency, works out of the box +/// with any OpenTelemetry-compatible listener (Application Insights, Jaeger, Datadog, etc.), and does +/// nothing (near-zero cost) when no listener is attached. +/// +/// +/// To observe these activities, register a listener once at application startup, e.g. via +/// OpenTelemetry's AddSource("Vali-Flow"), or a minimal . +/// +public static class ValiFlowDiagnostics +{ + /// The name every Vali-Flow package shares. Use this with AddSource(...). + public const string SourceName = "Vali-Flow"; + + /// The shared instance used by every Vali-Flow package. + public static readonly ActivitySource Source = new(SourceName, "1.0.0"); + + /// + /// Starts an for a Vali-Flow operation (query evaluation, write, translation). + /// Returns null when no listener is attached — callers must null-check before using the result, + /// same as any call. + /// + /// The operation name, e.g. "Vali-Flow.EvaluateQueryAsync". + /// + /// Optional developer-supplied label (the "TagWith"-style tag) identifying why/where this + /// operation was triggered — set as the vali_flow.tag tag when present. + /// + /// Optional entity type name — set as the vali_flow.entity_type tag. + public static Activity? StartActivity(string operationName, string? tag = null, string? entityType = null) + { + Activity? activity = Source.StartActivity(operationName, ActivityKind.Internal); + if (activity == null) return null; + + if (tag != null) activity.SetTag("vali_flow.tag", tag); + if (entityType != null) activity.SetTag("vali_flow.entity_type", entityType); + + return activity; + } + + /// + /// Records a failed operation on the given activity: sets an error status and attaches an + /// OpenTelemetry-style exception event, then returns — the caller is still responsible + /// for rethrowing. Never swallows the exception. + /// + public static void RecordException(Activity? activity, Exception ex) + { + if (activity == null) return; + + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection + { + { "exception.type", ex.GetType().FullName }, + { "exception.message", ex.Message }, + { "exception.stacktrace", ex.StackTrace } + })); + } +} diff --git a/Vali-Flow.Abstractions/Helpers/ExpressionInspector.cs b/Vali-Flow.Abstractions/Helpers/ExpressionInspector.cs index 3226ad5..fdcb567 100644 --- a/Vali-Flow.Abstractions/Helpers/ExpressionInspector.cs +++ b/Vali-Flow.Abstractions/Helpers/ExpressionInspector.cs @@ -14,6 +14,8 @@ public static class ExpressionInspector /// public static bool IsColumnExpression(Expression expr) { + ArgumentNullException.ThrowIfNull(expr); + while (expr is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } u) expr = u.Operand; @@ -25,20 +27,28 @@ public static bool IsColumnExpression(Expression expr) /// including null wrapped in Convert wrappers. /// public static bool IsNullConstant(Expression expr) - => expr is ConstantExpression { Value: null } - || (expr is UnaryExpression { NodeType: ExpressionType.Convert } u && IsNullConstant(u.Operand)); + { + ArgumentNullException.ThrowIfNull(expr); + + return expr is ConstantExpression { Value: null } + || (expr is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } u + && IsNullConstant(u.Operand)); + } /// /// Evaluates a non-column expression (closure capture, constant, or computed value) /// to its runtime value. /// /// The evaluated value, or null for null literals. + /// Thrown when is null. /// /// Thrown when the expression cannot be evaluated to a constant value /// (e.g. it references a non-captured variable). /// public static object? EvaluateExpression(Expression expr) { + ArgumentNullException.ThrowIfNull(expr); + if (expr is ConstantExpression constant) return constant.Value; From 8a5b4ac2655cf26b3b1636522c4732f55609dfe9 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:32:32 -0500 Subject: [PATCH 02/30] feat(vali-flow): add GenericRepository, extract QuerySpecificationBuilder, instrument with diagnostics - GenericRepository wraps ValiFlowEvaluator (GetById/GetAll/GetPaged/Add/Update/Delete/SaveChanges) - Extract query-construction logic from ValiFlowEvaluator into QuerySpecificationBuilder (SRP) - Wrap read/write evaluator methods with ValiFlowDiagnostics Activity tracing, tags derived from specification shape - Fix UpsertRangeAsync duplicate-key bug (dictionary accumulator instead of list) --- Vali-Flow.Tests/GenericRepositoryTests.cs | 155 +++++ Vali-Flow.Tests/ValiFlowEfDiagnosticsTests.cs | 152 +++++ Vali-Flow.Tests/ValiFlowEfQueryTests.cs | 22 + Vali-Flow.Tests/ValiFlowEfWriteTests.cs | 45 ++ .../Evaluators/QuerySpecificationBuilder.cs | 169 ++++++ .../Evaluators/ValiFlowEvaluator.Bridge.cs | 2 + .../Evaluators/ValiFlowEvaluator.Read.cs | 386 ++++++++---- .../Evaluators/ValiFlowEvaluator.Write.cs | 551 +++++++++++++----- .../Classes/Evaluators/ValiFlowEvaluator.cs | 130 +---- .../Classes/Repository/GenericRepository.cs | 83 +++ .../Evaluators/Read/IEvaluatorRead.cs | 16 +- .../Repository/IGenericRepository.cs | 50 ++ 12 files changed, 1369 insertions(+), 392 deletions(-) create mode 100644 Vali-Flow.Tests/GenericRepositoryTests.cs create mode 100644 Vali-Flow.Tests/ValiFlowEfDiagnosticsTests.cs create mode 100644 Vali-Flow/Classes/Evaluators/QuerySpecificationBuilder.cs create mode 100644 Vali-Flow/Classes/Repository/GenericRepository.cs create mode 100644 Vali-Flow/Interfaces/Repository/IGenericRepository.cs diff --git a/Vali-Flow.Tests/GenericRepositoryTests.cs b/Vali-Flow.Tests/GenericRepositoryTests.cs new file mode 100644 index 0000000..f4e39c6 --- /dev/null +++ b/Vali-Flow.Tests/GenericRepositoryTests.cs @@ -0,0 +1,155 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Classes.Repository; +using Vali_Flow.Core.Builder; +using Vali_Flow.Interfaces.Repository; +using Vali_Flow.Tests.Infrastructure; +using Vali_Flow.Tests.Models; +using Xunit; + +namespace Vali_Flow.Tests; + +public sealed class GenericRepositoryTests +{ + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static TestDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static async Task CreateSeededContextAsync() + { + var ctx = CreateContext(); + ctx.Orders.AddRange( + new TestOrder { Id = 1, CustomerName = "Alice", Total = 150m, IsShipped = true, CreatedAt = new DateTime(2024, 1, 1) }, + new TestOrder { Id = 2, CustomerName = "Bob", Total = 320m, IsShipped = false, CreatedAt = new DateTime(2024, 2, 1) }, + new TestOrder { Id = 3, CustomerName = "Carol", Total = 80m, IsShipped = true, CreatedAt = new DateTime(2024, 3, 1) } + ); + await ctx.SaveChangesAsync(); + ctx.ChangeTracker.Clear(); + return ctx; + } + + private static IGenericRepository CreateRepository(TestDbContext ctx) => + new GenericRepository(new ValiFlowEvaluator(ctx), o => o.Id); + + // ── GetByIdAsync ────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByIdAsync_ExistingId_ReturnsEntity() + { + await using var ctx = await CreateSeededContextAsync(); + var repository = CreateRepository(ctx); + + var order = await repository.GetByIdAsync(2); + + order.Should().NotBeNull(); + order!.CustomerName.Should().Be("Bob"); + } + + [Fact] + public async Task GetByIdAsync_MissingId_ReturnsNull() + { + await using var ctx = await CreateSeededContextAsync(); + var repository = CreateRepository(ctx); + + var order = await repository.GetByIdAsync(999); + + order.Should().BeNull(); + } + + // ── GetAllAsync ─────────────────────────────────────────────────────────── + + [Fact] + public async Task GetAllAsync_NoFilter_ReturnsAllEntities() + { + await using var ctx = await CreateSeededContextAsync(); + var repository = CreateRepository(ctx); + + var orders = await repository.GetAllAsync(); + + orders.Should().HaveCount(3); + } + + [Fact] + public async Task GetAllAsync_WithFilter_ReturnsOnlyMatchingEntities() + { + await using var ctx = await CreateSeededContextAsync(); + var repository = CreateRepository(ctx); + var filter = new ValiFlowQuery().IsTrue(o => o.IsShipped); + + var orders = await repository.GetAllAsync(filter); + + orders.Should().HaveCount(2).And.OnlyContain(o => o.IsShipped); + } + + // ── GetPagedAsync ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetPagedAsync_ReturnsCorrectPageAndMetadata() + { + await using var ctx = await CreateSeededContextAsync(); + var repository = CreateRepository(ctx); + + var paged = await repository.GetPagedAsync(null, o => o.Id, page: 1, pageSize: 2); + + paged.Items.Should().HaveCount(2); + paged.TotalCount.Should().Be(3); + paged.Page.Should().Be(1); + paged.PageSize.Should().Be(2); + paged.Items.Select(o => o.Id).Should().BeInAscendingOrder(); + } + + // ── AddAsync / UpdateAsync / DeleteAsync / SaveChangesAsync ────────────── + + [Fact] + public async Task AddAsync_PersistsEntity() + { + await using var ctx = CreateContext(); + var repository = CreateRepository(ctx); + var newOrder = new TestOrder { Id = 10, CustomerName = "Frank", Total = 99m, CreatedAt = DateTime.UtcNow }; + + await repository.AddAsync(newOrder); + + (await repository.GetByIdAsync(10)).Should().NotBeNull(); + } + + [Fact] + public async Task UpdateAsync_PersistsChanges() + { + await using var ctx = await CreateSeededContextAsync(); + var repository = CreateRepository(ctx); + var order = await repository.GetByIdAsync(1); + order!.CustomerName = "Alice Updated"; + + await repository.UpdateAsync(order); + + (await repository.GetByIdAsync(1))!.CustomerName.Should().Be("Alice Updated"); + } + + [Fact] + public async Task DeleteAsync_RemovesEntity() + { + await using var ctx = await CreateSeededContextAsync(); + var repository = CreateRepository(ctx); + var order = await repository.GetByIdAsync(3); + + await repository.DeleteAsync(order!); + + (await repository.GetByIdAsync(3)).Should().BeNull(); + } + + [Fact] + public async Task AddAsync_WithSaveChangesFalse_DoesNotPersistUntilSaveChangesAsyncCalled() + { + await using var ctx = CreateContext(); + var repository = CreateRepository(ctx); + var newOrder = new TestOrder { Id = 20, CustomerName = "Grace", Total = 10m, CreatedAt = DateTime.UtcNow }; + + await repository.AddAsync(newOrder, saveChanges: false); + ctx.ChangeTracker.Clear(); + (await repository.GetByIdAsync(20)).Should().BeNull(); + } +} diff --git a/Vali-Flow.Tests/ValiFlowEfDiagnosticsTests.cs b/Vali-Flow.Tests/ValiFlowEfDiagnosticsTests.cs new file mode 100644 index 0000000..c966cc2 --- /dev/null +++ b/Vali-Flow.Tests/ValiFlowEfDiagnosticsTests.cs @@ -0,0 +1,152 @@ +using System.Diagnostics; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Classes.Specification; +using Vali_Flow.Core.Builder; +using Vali_Flow.Tests.Infrastructure; +using Vali_Flow.Tests.Models; +using Xunit; + +namespace Vali_Flow.Tests; + +/// +/// Verifies that read/write operations open a +/// with the expected name/tags, +/// and mark it as failed when the underlying operation throws. +/// +public sealed class ValiFlowEfDiagnosticsTests +{ + private static TestDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + /// + /// Attaches a listener and parents every captured activity under a private root, so concurrently + /// running tests (xunit parallelizes test classes by default) can never pollute this test's capture + /// list even though they share the same process-wide . + /// + private static (ActivityListener Listener, Activity Root, List Captured) AttachScopedListener() + { + var captured = new List(); + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = captured.Add + }; + ActivitySource.AddActivityListener(listener); + + var root = new Activity("TestRoot").Start(); // becomes Activity.Current -> parents child activities + return (listener, root, captured); + } + + private static Activity GetOwn(List captured, Activity root, string operationName) + => captured.Single(a => a.OperationName == operationName && a.ParentId == root.Id); + + [Fact] + public async Task EvaluateCountAsync_WithTagWith_StartsActivityWithExpectedNameAndTag() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + + await using var ctx = CreateContext(); + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new BasicSpecification(new ValiFlowQuery()) + .WithTagWith("MyReport"); + + await evaluator.EvaluateCountAsync(spec); + + Activity activity = GetOwn(captured, root, "Vali-Flow.EvaluateCountAsync"); + activity.GetTagItem("vali_flow.tag").Should().Be("MyReport"); + activity.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestOrder)); + activity.Status.Should().Be(ActivityStatusCode.Unset); + } + + [Fact] + public async Task EvaluateAnyAsync_SetsHasFilterAndIncludeCountTags() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + + await using var ctx = CreateContext(); + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new BasicSpecification(new ValiFlowQuery().IsTrue(o => o.IsShipped)); + + await evaluator.EvaluateAnyAsync(spec); + + Activity activity = GetOwn(captured, root, "Vali-Flow.EvaluateAnyAsync"); + activity.GetTagItem("vali_flow.has_filter").Should().Be(true); + activity.GetTagItem("vali_flow.include_count").Should().Be(0); + } + + [Fact] + public async Task AddAsync_NullEntity_RecordsActivityAsError() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + + await using var ctx = CreateContext(); + var evaluator = new ValiFlowEvaluator(ctx); + + Func act = () => evaluator.AddAsync(null!); + + await act.Should().ThrowAsync(); + + Activity activity = GetOwn(captured, root, "Vali-Flow.AddAsync"); + activity.Status.Should().Be(ActivityStatusCode.Error); + activity.Events.Should().ContainSingle(e => e.Name == "exception"); + } + + [Fact] + public async Task ExecuteTransactionAsync_OperationThrows_RecordsActivityAsError() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + + await using var ctx = CreateContext(); + var evaluator = new ValiFlowEvaluator(ctx); + + Func act = () => evaluator.ExecuteTransactionAsync( + () => throw new InvalidOperationException("boom")); + + // The InMemory provider does not support transactions, so BeginTransactionAsync itself + // throws — either way the operation fails and the activity must be recorded as errored. + await act.Should().ThrowAsync(); + + Activity activity = GetOwn(captured, root, "Vali-Flow.ExecuteTransactionAsync"); + activity.Status.Should().Be(ActivityStatusCode.Error); + activity.Events.Should().ContainSingle(e => e.Name == "exception"); + } + + [Fact] + public async Task EvaluatePagedAsync_SetsPageAndPageSizeTags() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + + await using var ctx = CreateContext(); + ctx.Orders.Add(new TestOrder { Id = 1, CustomerName = "Alice", Total = 10m, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification(new ValiFlowQuery()) + .WithPage(1) + .WithPageSize(10) + .WithOrderBy(o => o.Id); + + await evaluator.EvaluatePagedAsync(spec); + + Activity activity = GetOwn(captured, root, "Vali-Flow.EvaluatePagedAsync"); + activity.GetTagItem("vali_flow.page").Should().Be(1); + activity.GetTagItem("vali_flow.page_size").Should().Be(10); + activity.GetTagItem("vali_flow.has_pagination").Should().Be(true); + } +} diff --git a/Vali-Flow.Tests/ValiFlowEfQueryTests.cs b/Vali-Flow.Tests/ValiFlowEfQueryTests.cs index 74562eb..78d90f3 100644 --- a/Vali-Flow.Tests/ValiFlowEfQueryTests.cs +++ b/Vali-Flow.Tests/ValiFlowEfQueryTests.cs @@ -318,4 +318,26 @@ public async Task EvaluatePagedAsync_FilteredShipped_PaginationIsCorrect() paged.Items[0].CustomerName.Should().Be("Carol"); paged.Items[1].CustomerName.Should().Be("Alice"); } + + [Fact] + public async Task EvaluatePagedResultAsync_IsAliasOfEvaluatePagedAsync_SameResult() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var spec = new QuerySpecification( + new ValiFlowQuery().IsTrue(o => o.IsShipped)) + .WithOrderBy(o => o.Total) + .WithPagination(1, 2); + + var paged = await ev.EvaluatePagedResultAsync(spec); + + // Same data/behavior as EvaluatePagedAsync_FilteredShipped_PaginationIsCorrect — + // EvaluatePagedResultAsync is a pure alias kept for API symmetry with Vali-Flow.InMemory. + paged.TotalCount.Should().Be(3); + paged.TotalPages.Should().Be(2); + paged.Items.Should().HaveCount(2); + paged.Items[0].CustomerName.Should().Be("Carol"); + paged.Items[1].CustomerName.Should().Be("Alice"); + } } diff --git a/Vali-Flow.Tests/ValiFlowEfWriteTests.cs b/Vali-Flow.Tests/ValiFlowEfWriteTests.cs index fa54008..eb9da9f 100644 --- a/Vali-Flow.Tests/ValiFlowEfWriteTests.cs +++ b/Vali-Flow.Tests/ValiFlowEfWriteTests.cs @@ -168,6 +168,51 @@ public async Task UpsertAsync_EntityExists_UpdatesValues() result.Total.Should().Be(500m); } + // ── UpsertRangeAsync — duplicate keys within the same batch ────────────── + + [Fact] + public async Task UpsertRangeAsync_DuplicateKeysNotYetInDatabase_InsertsOnlyOne() + { + await using var ctx = CreateContext(); + var ev = new ValiFlowEvaluator(ctx); + + // Two incoming entities share the same business key (CustomerName) and neither + // exists in the DB yet. A correct upsert should collapse them into a single row + // (last one wins), not insert both. + var entities = new[] + { + new TestOrder { CustomerName = "Dup", Total = 10m, IsShipped = false, CreatedAt = DateTime.UtcNow }, + new TestOrder { CustomerName = "Dup", Total = 20m, IsShipped = true, CreatedAt = DateTime.UtcNow } + }; + + await ev.UpsertRangeAsync(entities, o => o.CustomerName); + + var rows = await ctx.Orders.AsNoTracking().Where(o => o.CustomerName == "Dup").ToListAsync(); + rows.Should().HaveCount(1); + rows[0].Total.Should().Be(20m); // last occurrence in the batch wins + } + + [Fact] + public async Task UpsertRangeAsync_DuplicateKeysAgainstExistingRow_UpdatesOnceWithLastValue() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + // Alice (Id=1) already exists. Two incoming entities target her by key; the + // batch should still result in exactly one row for Alice with the last value applied. + var entities = new[] + { + new TestOrder { Id = 1, CustomerName = "Alice", Total = 111m, IsShipped = false, CreatedAt = DateTime.UtcNow }, + new TestOrder { Id = 1, CustomerName = "Alice", Total = 222m, IsShipped = true, CreatedAt = DateTime.UtcNow } + }; + + await ev.UpsertRangeAsync(entities, o => o.CustomerName); + + var rows = await ctx.Orders.AsNoTracking().Where(o => o.CustomerName == "Alice").ToListAsync(); + rows.Should().HaveCount(1); + rows[0].Total.Should().Be(222m); + } + // ── SaveChangesAsync — deferred ─────────────────────────────────────────── [Fact] diff --git a/Vali-Flow/Classes/Evaluators/QuerySpecificationBuilder.cs b/Vali-Flow/Classes/Evaluators/QuerySpecificationBuilder.cs new file mode 100644 index 0000000..d0b97be --- /dev/null +++ b/Vali-Flow/Classes/Evaluators/QuerySpecificationBuilder.cs @@ -0,0 +1,169 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Core.Builder; +using Vali_Flow.Interfaces.Options; +using Vali_Flow.Interfaces.Specification; +using Vali_Flow.Utils; + +namespace Vali_Flow.Classes.Evaluators; + +/// +/// Translates an / into an EF Core +/// — filter, includes, tracking/split-query hints, ordering and pagination. +/// Extracted from so query-construction logic (a single, self-contained +/// responsibility) does not share state or private helpers with the read/write/aggregate operations that +/// consume it. +/// +internal sealed class QuerySpecificationBuilder where T : class +{ + private readonly DbContext _dbContext; + + public QuerySpecificationBuilder(DbContext dbContext) + { + _dbContext = dbContext; + } + + public IQueryable BuildBasicQuery(ISpecification specification, bool negateCondition = false) + { + IQueryable query = _dbContext.Set(); + + query = ApplyIgnoreQueryFilters(query, specification.IgnoreQueryFilters); + query = ApplyIgnoreAutoIncludes(query, specification.IgnoreAutoIncludes); + query = ApplyWhere(query, specification.Filter, negateCondition); + + if (specification.AsNoTracking && specification.AsNoTrackingWithIdentityResolution) + throw new InvalidOperationException( + "AsNoTracking and AsNoTrackingWithIdentityResolution are mutually exclusive. " + + "Use WithAsNoTracking(false) before calling WithAsNoTrackingWithIdentityResolution(true)."); + + query = ApplyAsNoTracking(query, specification.AsNoTracking); + query = ApplyAsNoTrackingWithIdentityResolution(query, specification.AsNoTrackingWithIdentityResolution); + query = ApplyIncludes(query, specification.Includes); + query = ApplyAsSplitQuery(query, specification.AsSplitQuery); + query = ApplyTagWith(query, specification.TagWith); + + return query; + } + + public IQueryable BuildQuery(IQuerySpecification specification, bool negateFilter = false) + { + ValidatePaginationAndOrdering(specification); + + IQueryable query = BuildBasicQuery(specification, negateFilter); + query = ApplyOrdering(query, specification); + query = ApplyPagination(query, specification); + return query; + } + + private static void ValidatePaginationAndOrdering(IQuerySpecification specification) + { + EnsureTopNotCombinedWithPaging(specification); + EnsurePageAndPageSizeSetTogether(specification); + EnsurePaginationHasOrdering(specification); + } + + private static void EnsureTopNotCombinedWithPaging(IQuerySpecification specification) + { + if (specification.Top.HasValue && (specification.Page.HasValue || specification.PageSize.HasValue)) + throw new InvalidOperationException( + "Cannot combine Top with Page/PageSize pagination on the same specification. Use one or the other."); + } + + private static void EnsurePageAndPageSizeSetTogether(IQuerySpecification specification) + { + bool pageSetWithoutPageSize = specification.Page.HasValue && specification.Page.Value > 0 && + (!specification.PageSize.HasValue || specification.PageSize.Value <= 0); + bool pageSizeSetWithoutPage = specification.PageSize.HasValue && specification.PageSize.Value > 0 && + (!specification.Page.HasValue || specification.Page.Value <= 0); + if (pageSetWithoutPageSize || pageSizeSetWithoutPage) + throw new InvalidOperationException( + "Both Page and PageSize must be set together. Use WithPagination(page, pageSize)."); + } + + private static void EnsurePaginationHasOrdering(IQuerySpecification specification) + { + bool hasPagination = specification.Page.HasValue || specification.PageSize.HasValue; + bool hasOrdering = specification.OrderBy != null || specification.ValiSort != null; + if (hasPagination && !hasOrdering) + throw new InvalidOperationException( + "Pagination requires an ordering to produce deterministic results. " + + "Set OrderBy or ValiSort on the specification before enabling pagination."); + } + + public IQueryable ApplyOrdering(IQueryable query, IQuerySpecification specification) + { + if (specification.ValiSort != null) + return specification.ValiSort.Apply(query); + + if (specification.OrderBy == null) + { + if (specification.ThenBys != null) + throw new InvalidOperationException( + "ThenBy requires a primary OrderBy. Set OrderBy on the specification before adding ThenBy expressions."); + return query; + } + + IOrderedQueryable orderedQuery = specification.OrderBy.ApplyOrderBy(query); + + if (specification.ThenBys != null) + { + foreach (var thenBy in specification.ThenBys) + orderedQuery = thenBy.ApplyThenBy(orderedQuery); + } + + return orderedQuery; + } + + private IQueryable ApplyWhere(IQueryable query, ValiFlowQuery filter, bool negateCondition = false) + { + Validation.ValidateFilterNotNull(filter); + + Expression> condition = negateCondition ? filter.BuildNegated() : filter.BuildWithGlobal(); + return query.Where(condition); + } + + private IQueryable ApplyPagination(IQueryable query, IQuerySpecification specification) + { + if (specification is { Page: not null, PageSize: not null }) + { + int skip = (specification.Page.Value - Constants.One) * specification.PageSize.Value; + int take = specification.PageSize.Value; + query = query.Skip(skip).Take(take); + } + else if (specification.Top != null) + { + query = query.Take(specification.Top.Value); + } + + return query; + } + + private IQueryable ApplyAsNoTracking(IQueryable query, bool asNoTracking = true) => asNoTracking ? query.AsNoTracking() : query; + + private IQueryable ApplyAsNoTrackingWithIdentityResolution(IQueryable query, bool apply) + => apply ? query.AsNoTrackingWithIdentityResolution() : query; + + private IQueryable ApplyTagWith(IQueryable query, string? tag) + => tag != null ? query.TagWith(tag) : query; + + private IQueryable ApplyIgnoreAutoIncludes(IQueryable query, bool apply) + => apply ? query.IgnoreAutoIncludes() : query; + + private IQueryable ApplyIgnoreQueryFilters(IQueryable query, bool ignoreQueryFilters = false) => + ignoreQueryFilters ? query.IgnoreQueryFilters() : query; + + private IQueryable ApplyAsSplitQuery(IQueryable query, bool asSplitQuery = false) => asSplitQuery ? query.AsSplitQuery() : query; + + private IQueryable ApplyIncludes(IQueryable query, IEnumerable>? includes) + { + if (includes != null) + { + foreach (var include in includes) + { + query = include.ApplyInclude(query); + } + } + + return query; + } +} diff --git a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Bridge.cs b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Bridge.cs index 9da8f4c..b3f7b83 100644 --- a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Bridge.cs +++ b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Bridge.cs @@ -53,6 +53,8 @@ async Task IQueryAggregator.EvaluateMinAsync( IQueryable query = _dbContext.Set().AsNoTracking(); if (filter != null) query = query.Where(filter.Build()); if (!await query.AnyAsync(cancellationToken)) + // TResult : INumber is effectively always a value type in practice (int, double, decimal, ...), + // so default(TResult) here is a real number (e.g. 0), not a hidden null — see XML doc on the interface. return default(TResult)!; return await query.MinAsync(selector, cancellationToken); } diff --git a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Read.cs b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Read.cs index c8591cb..3c1594c 100644 --- a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Read.cs +++ b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Read.cs @@ -1,5 +1,7 @@ +using System.Diagnostics; using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Interfaces.Specification; using Vali_Flow.Core.Builder; using Vali_Flow.Models; @@ -38,9 +40,21 @@ public async Task EvaluateAnyAsync( ) { if (specification == null) throw new ArgumentNullException(nameof(specification)); - IQueryable query = BuildBasicQuery(specification); - return await ExecuteWithExceptionHandlingAsync(() => query.AnyAsync(cancellationToken), - nameof(EvaluateAnyAsync)); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateAnyAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + IQueryable query = BuildBasicQuery(specification); + return await ExecuteWithExceptionHandlingAsync(() => query.AnyAsync(cancellationToken), + nameof(EvaluateAnyAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -56,9 +70,21 @@ public async Task EvaluateCountAsync( ) { if (specification == null) throw new ArgumentNullException(nameof(specification)); - IQueryable query = BuildBasicQuery(specification); - return await ExecuteWithExceptionHandlingAsync(() => query.CountAsync(cancellationToken), - nameof(EvaluateCountAsync)); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateCountAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + IQueryable query = BuildBasicQuery(specification); + return await ExecuteWithExceptionHandlingAsync(() => query.CountAsync(cancellationToken), + nameof(EvaluateCountAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -75,9 +101,21 @@ public async Task EvaluateCountAsync( ) { if (specification == null) throw new ArgumentNullException(nameof(specification)); - var query = BuildBasicQuery(specification, true); - return await ExecuteWithExceptionHandlingAsync(() => query.FirstOrDefaultAsync(cancellationToken), - nameof(EvaluateGetFirstFailedAsync)); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateGetFirstFailedAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + var query = BuildBasicQuery(specification, true); + return await ExecuteWithExceptionHandlingAsync(() => query.FirstOrDefaultAsync(cancellationToken), + nameof(EvaluateGetFirstFailedAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -93,9 +131,21 @@ public async Task EvaluateCountAsync( ) { if (specification == null) throw new ArgumentNullException(nameof(specification)); - var query = BuildBasicQuery(specification); - return await ExecuteWithExceptionHandlingAsync(() => query.FirstOrDefaultAsync(cancellationToken), - nameof(EvaluateGetFirstAsync)); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateGetFirstAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + var query = BuildBasicQuery(specification); + return await ExecuteWithExceptionHandlingAsync(() => query.FirstOrDefaultAsync(cancellationToken), + nameof(EvaluateGetFirstAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -108,8 +158,22 @@ public async Task EvaluateCountAsync( public Task> EvaluateQueryFailedAsync(IQuerySpecification specification) { if (specification == null) throw new ArgumentNullException(nameof(specification)); - IQueryable query = BuildQuery(specification, negateFilter: true); - return Task.FromResult(query); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateQueryFailedAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + activity?.SetTag("vali_flow.has_pagination", specification.Page.HasValue || specification.PageSize.HasValue); + activity?.SetTag("vali_flow.has_order_by", specification.OrderBy != null || specification.ValiSort != null); + IQueryable query = BuildQuery(specification, negateFilter: true); + return Task.FromResult(query); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -122,8 +186,22 @@ public Task> EvaluateQueryFailedAsync(IQuerySpecification speci public Task> EvaluateQueryAsync(IQuerySpecification specification) { if (specification == null) throw new ArgumentNullException(nameof(specification)); - IQueryable query = BuildQuery(specification); - return Task.FromResult(query); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateQueryAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + activity?.SetTag("vali_flow.has_pagination", specification.Page.HasValue || specification.PageSize.HasValue); + activity?.SetTag("vali_flow.has_order_by", specification.OrderBy != null || specification.ValiSort != null); + IQueryable query = BuildQuery(specification); + return Task.FromResult(query); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -197,25 +275,41 @@ public async Task> EvaluateDistinctAsync( ) where TKey : notnull { if (specification == null) throw new ArgumentNullException(nameof(specification)); - if (specification.Page.HasValue != specification.PageSize.HasValue) - throw new InvalidOperationException("Both Page and PageSize must be set together, or neither."); - if (selector == null) throw new ArgumentNullException(nameof(selector)); - bool hasPagination = specification.PageSize.HasValue || specification.Top.HasValue; - bool hasOrdering = specification.OrderBy != null || specification.ValiSort != null; - if (hasPagination && !hasOrdering) - throw new InvalidOperationException("Pagination requires an ordering."); - Func keySelectorFn = selector.Compile(); - IQueryable query = BuildBasicQuery(specification); - query = ApplyOrdering(query, specification); - List items = await query.ToListAsync(cancellationToken); - IEnumerable result = items.GroupBy(keySelectorFn).Select(g => g.First()); - // Pagination applied AFTER grouping so it operates on distinct groups, not raw rows - if (specification is { Page: not null, PageSize: not null }) - result = result.Skip((specification.Page.Value - Constants.One) * specification.PageSize.Value) - .Take(specification.PageSize.Value); - else if (specification.Top != null) - result = result.Take(specification.Top.Value); - return result; + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateDistinctAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + if (specification.Page.HasValue != specification.PageSize.HasValue) + throw new InvalidOperationException("Both Page and PageSize must be set together, or neither."); + if (selector == null) throw new ArgumentNullException(nameof(selector)); + bool hasPagination = specification.PageSize.HasValue || specification.Top.HasValue; + bool hasOrdering = specification.OrderBy != null || specification.ValiSort != null; + if (hasPagination && !hasOrdering) + throw new InvalidOperationException("Pagination requires an ordering."); + + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + activity?.SetTag("vali_flow.has_pagination", hasPagination); + activity?.SetTag("vali_flow.has_order_by", hasOrdering); + + Func keySelectorFn = selector.Compile(); + IQueryable query = BuildBasicQuery(specification); + query = ApplyOrdering(query, specification); + List items = await query.ToListAsync(cancellationToken); + IEnumerable result = items.GroupBy(keySelectorFn).Select(g => g.First()); + // Pagination applied AFTER grouping so it operates on distinct groups, not raw rows + if (specification is { Page: not null, PageSize: not null }) + result = result.Skip((specification.Page.Value - Constants.One) * specification.PageSize.Value) + .Take(specification.PageSize.Value); + else if (specification.Top != null) + result = result.Take(specification.Top.Value); + return result; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -237,27 +331,43 @@ public async Task> EvaluateDuplicatesAsync( ) where TKey : notnull { if (specification == null) throw new ArgumentNullException(nameof(specification)); - if (specification.Page.HasValue != specification.PageSize.HasValue) - throw new InvalidOperationException("Both Page and PageSize must be set together, or neither."); - if (selector == null) throw new ArgumentNullException(nameof(selector)); - bool hasPagination = specification.PageSize.HasValue || specification.Top.HasValue; - bool hasOrdering = specification.OrderBy != null || specification.ValiSort != null; - if (hasPagination && !hasOrdering) - throw new InvalidOperationException("Pagination requires an ordering."); - Func keySelectorFn = selector.Compile(); - IQueryable query = BuildBasicQuery(specification); - query = ApplyOrdering(query, specification); - List items = await query.ToListAsync(cancellationToken); - IEnumerable result = items.GroupBy(keySelectorFn) - .Where(g => g.Count() > Constants.One) - .SelectMany(g => g); - // Pagination applied AFTER grouping so it operates on the duplicate set, not raw rows - if (specification is { Page: not null, PageSize: not null }) - result = result.Skip((specification.Page.Value - Constants.One) * specification.PageSize.Value) - .Take(specification.PageSize.Value); - else if (specification.Top != null) - result = result.Take(specification.Top.Value); - return result; + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateDuplicatesAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + if (specification.Page.HasValue != specification.PageSize.HasValue) + throw new InvalidOperationException("Both Page and PageSize must be set together, or neither."); + if (selector == null) throw new ArgumentNullException(nameof(selector)); + bool hasPagination = specification.PageSize.HasValue || specification.Top.HasValue; + bool hasOrdering = specification.OrderBy != null || specification.ValiSort != null; + if (hasPagination && !hasOrdering) + throw new InvalidOperationException("Pagination requires an ordering."); + + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + activity?.SetTag("vali_flow.has_pagination", hasPagination); + activity?.SetTag("vali_flow.has_order_by", hasOrdering); + + Func keySelectorFn = selector.Compile(); + IQueryable query = BuildBasicQuery(specification); + query = ApplyOrdering(query, specification); + List items = await query.ToListAsync(cancellationToken); + IEnumerable result = items.GroupBy(keySelectorFn) + .Where(g => g.Count() > Constants.One) + .SelectMany(g => g); + // Pagination applied AFTER grouping so it operates on the duplicate set, not raw rows + if (specification is { Page: not null, PageSize: not null }) + result = result.Skip((specification.Page.Value - Constants.One) * specification.PageSize.Value) + .Take(specification.PageSize.Value); + else if (specification.Top != null) + result = result.Take(specification.Top.Value); + return result; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -275,16 +385,30 @@ public async Task> EvaluateDuplicatesAsync( ) { if (specification == null) throw new ArgumentNullException(nameof(specification)); - if (specification.Page.HasValue || specification.PageSize.HasValue) - throw new InvalidOperationException( - "EvaluateGetLastFailedAsync does not support pagination. Use EvaluateQueryAsync for paged queries."); - if (specification.OrderBy == null && specification.ValiSort == null) - throw new InvalidOperationException( - $"{nameof(EvaluateGetLastFailedAsync)} requires an ordering (OrderBy or ValiSort). EF Core cannot translate LastOrDefault without ORDER BY."); - - IQueryable query = BuildQuery(specification, negateFilter: true); - return await ExecuteWithExceptionHandlingAsync(() => query.LastOrDefaultAsync(cancellationToken), - nameof(EvaluateGetLastFailedAsync)); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateGetLastFailedAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + if (specification.Page.HasValue || specification.PageSize.HasValue) + throw new InvalidOperationException( + "EvaluateGetLastFailedAsync does not support pagination. Use EvaluateQueryAsync for paged queries."); + if (specification.OrderBy == null && specification.ValiSort == null) + throw new InvalidOperationException( + $"{nameof(EvaluateGetLastFailedAsync)} requires an ordering (OrderBy or ValiSort). EF Core cannot translate LastOrDefault without ORDER BY."); + + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + activity?.SetTag("vali_flow.has_order_by", true); + + IQueryable query = BuildQuery(specification, negateFilter: true); + return await ExecuteWithExceptionHandlingAsync(() => query.LastOrDefaultAsync(cancellationToken), + nameof(EvaluateGetLastFailedAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -302,16 +426,30 @@ public async Task> EvaluateDuplicatesAsync( ) { if (specification == null) throw new ArgumentNullException(nameof(specification)); - if (specification.Page.HasValue || specification.PageSize.HasValue) - throw new InvalidOperationException( - "EvaluateGetLastAsync does not support pagination. Use EvaluateQueryAsync for paged queries."); - if (specification.OrderBy == null && specification.ValiSort == null) - throw new InvalidOperationException( - $"{nameof(EvaluateGetLastAsync)} requires an ordering (OrderBy or ValiSort). EF Core cannot translate LastOrDefault without ORDER BY."); - - IQueryable query = BuildQuery(specification); - return await ExecuteWithExceptionHandlingAsync(() => query.LastOrDefaultAsync(cancellationToken), - nameof(EvaluateGetLastAsync)); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluateGetLastAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + if (specification.Page.HasValue || specification.PageSize.HasValue) + throw new InvalidOperationException( + "EvaluateGetLastAsync does not support pagination. Use EvaluateQueryAsync for paged queries."); + if (specification.OrderBy == null && specification.ValiSort == null) + throw new InvalidOperationException( + $"{nameof(EvaluateGetLastAsync)} requires an ordering (OrderBy or ValiSort). EF Core cannot translate LastOrDefault without ORDER BY."); + + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + activity?.SetTag("vali_flow.has_order_by", true); + + IQueryable query = BuildQuery(specification); + return await ExecuteWithExceptionHandlingAsync(() => query.LastOrDefaultAsync(cancellationToken), + nameof(EvaluateGetLastAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -333,38 +471,68 @@ public async Task> EvaluatePagedAsync( ) { if (specification == null) throw new ArgumentNullException(nameof(specification)); - if (!specification.Page.HasValue) - throw new ArgumentException( - $"{nameof(EvaluatePagedAsync)} requires Page to be explicitly set on the specification.", - nameof(specification)); - if (!specification.PageSize.HasValue) - throw new ArgumentException( - $"{nameof(EvaluatePagedAsync)} requires PageSize to be explicitly set on the specification.", - nameof(specification)); - - bool hasOrdering = specification.OrderBy != null || specification.ValiSort != null; - if (!hasOrdering) - throw new InvalidOperationException( - $"{nameof(EvaluatePagedAsync)} requires an ordering (OrderBy or ValiSort) for deterministic pagination results."); - - int page = specification.Page.Value; - int pageSize = specification.PageSize.Value; - - if (page < 1) throw new ArgumentOutOfRangeException(nameof(specification), "Page must be greater than or equal to 1."); - if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(specification), "PageSize must be greater than or equal to 1."); - - IQueryable baseQuery = BuildBasicQuery(specification); - - int totalCount = await ExecuteWithExceptionHandlingAsync( - () => baseQuery.CountAsync(cancellationToken), - nameof(EvaluatePagedAsync)); - - IQueryable orderedQuery = ApplyOrdering(baseQuery, specification); - int skip = (page - Constants.One) * pageSize; - IList items = await ExecuteWithExceptionHandlingAsync( - () => orderedQuery.Skip(skip).Take(pageSize).ToListAsync(cancellationToken), - nameof(EvaluatePagedAsync)); - - return new PagedResult(items.AsReadOnly(), totalCount, page, pageSize); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.EvaluatePagedAsync", tag: specification.TagWith, entityType: typeof(T).Name); + try + { + if (!specification.Page.HasValue) + throw new ArgumentException( + $"{nameof(EvaluatePagedAsync)} requires Page to be explicitly set on the specification.", + nameof(specification)); + if (!specification.PageSize.HasValue) + throw new ArgumentException( + $"{nameof(EvaluatePagedAsync)} requires PageSize to be explicitly set on the specification.", + nameof(specification)); + + bool hasOrdering = specification.OrderBy != null || specification.ValiSort != null; + if (!hasOrdering) + throw new InvalidOperationException( + $"{nameof(EvaluatePagedAsync)} requires an ordering (OrderBy or ValiSort) for deterministic pagination results."); + + int page = specification.Page.Value; + int pageSize = specification.PageSize.Value; + + if (page < 1) throw new ArgumentOutOfRangeException(nameof(specification), "Page must be greater than or equal to 1."); + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(specification), "PageSize must be greater than or equal to 1."); + + activity?.SetTag("vali_flow.has_filter", specification.Filter != null); + activity?.SetTag("vali_flow.include_count", specification.Includes?.Count() ?? 0); + activity?.SetTag("vali_flow.has_pagination", true); + activity?.SetTag("vali_flow.has_order_by", hasOrdering); + activity?.SetTag("vali_flow.page", page); + activity?.SetTag("vali_flow.page_size", pageSize); + + IQueryable baseQuery = BuildBasicQuery(specification); + + int totalCount = await ExecuteWithExceptionHandlingAsync( + () => baseQuery.CountAsync(cancellationToken), + nameof(EvaluatePagedAsync)); + + IQueryable orderedQuery = ApplyOrdering(baseQuery, specification); + int skip = (page - Constants.One) * pageSize; + IList items = await ExecuteWithExceptionHandlingAsync( + () => orderedQuery.Skip(skip).Take(pageSize).ToListAsync(cancellationToken), + nameof(EvaluatePagedAsync)); + + return new PagedResult(items.AsReadOnly(), totalCount, page, pageSize); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } + + /// + /// Alias for — provided for API symmetry with + /// Vali-Flow.InMemory's EvaluatePagedResult. See for behavior, + /// requirements, and the count/items round-trip remark. + /// + /// Specification that defines the filter, ordering, pagination, and EF Core query hints. + /// Cancellation token. + /// A with the page items, total count, and pagination metadata. + public Task> EvaluatePagedResultAsync( + IQuerySpecification specification, + CancellationToken cancellationToken = default + ) => EvaluatePagedAsync(specification, cancellationToken); } diff --git a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs index d6ac876..2facacb 100644 --- a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs +++ b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs @@ -1,6 +1,8 @@ +using System.Diagnostics; using System.Linq.Expressions; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Utils; namespace Vali_Flow.Classes.Evaluators; @@ -21,14 +23,25 @@ public async Task AddAsync( CancellationToken cancellationToken = default ) { - Validation.ValidateEntityNotNull(entity); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.AddAsync", tag: null, entityType: typeof(T).Name); + try + { + Validation.ValidateEntityNotNull(entity); + activity?.SetTag("vali_flow.save_changes", saveChanges); - var addedEntity = await ExecuteWithExceptionHandlingAsync( - async () => (await _dbContext.Set().AddAsync(entity, cancellationToken)).Entity, - nameof(AddAsync)); + var addedEntity = await ExecuteWithExceptionHandlingAsync( + async () => (await _dbContext.Set().AddAsync(entity, cancellationToken)).Entity, + nameof(AddAsync)); - await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(AddAsync)); - return addedEntity; + await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(AddAsync)); + return addedEntity; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -45,21 +58,34 @@ public async Task> AddRangeAsync( CancellationToken cancellationToken = default ) { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - IEnumerable entityList = entities.ToList(); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.AddRangeAsync", tag: null, entityType: typeof(T).Name); + try + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + IEnumerable entityList = entities.ToList(); - Validation.ValidateEntitiesNotNull(entityList); - Validation.ValidateEntitiesEmpty(entityList); + Validation.ValidateEntitiesNotNull(entityList); + Validation.ValidateEntitiesEmpty(entityList); - await ExecuteWithExceptionHandlingAsync( - async () => - { - await _dbContext.Set().AddRangeAsync(entityList, cancellationToken); - return entityList; - }, nameof(AddRangeAsync)); + activity?.SetTag("vali_flow.save_changes", saveChanges); + activity?.SetTag("vali_flow.entity_count", entityList.Count()); - await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(AddRangeAsync)); - return entityList; + await ExecuteWithExceptionHandlingAsync( + async () => + { + await _dbContext.Set().AddRangeAsync(entityList, cancellationToken); + return entityList; + }, nameof(AddRangeAsync)); + + await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(AddRangeAsync)); + return entityList; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -76,11 +102,22 @@ public async Task UpdateAsync( CancellationToken cancellationToken = default ) { - Validation.ValidateEntityNotNull(entity); - _dbContext.Set().Update(entity); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.UpdateAsync", tag: null, entityType: typeof(T).Name); + try + { + Validation.ValidateEntityNotNull(entity); + activity?.SetTag("vali_flow.save_changes", saveChanges); + _dbContext.Set().Update(entity); - await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(UpdateAsync)); - return entity; + await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(UpdateAsync)); + return entity; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -97,16 +134,29 @@ public async Task> UpdateRangeAsync( CancellationToken cancellationToken = default ) { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - IEnumerable entityList = entities.ToList(); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.UpdateRangeAsync", tag: null, entityType: typeof(T).Name); + try + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + IEnumerable entityList = entities.ToList(); + + Validation.ValidateEntitiesNotNull(entityList); + Validation.ValidateEntitiesEmpty(entityList); - Validation.ValidateEntitiesNotNull(entityList); - Validation.ValidateEntitiesEmpty(entityList); + activity?.SetTag("vali_flow.save_changes", saveChanges); + activity?.SetTag("vali_flow.entity_count", entityList.Count()); - _dbContext.Set().UpdateRange(entityList); + _dbContext.Set().UpdateRange(entityList); - await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(UpdateRangeAsync)); - return entityList; + await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(UpdateRangeAsync)); + return entityList; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -122,10 +172,21 @@ public async Task DeleteAsync( CancellationToken cancellationToken = default ) { - Validation.ValidateEntityNotNull(entity); - _dbContext.Set().Remove(entity); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.DeleteAsync", tag: null, entityType: typeof(T).Name); + try + { + Validation.ValidateEntityNotNull(entity); + activity?.SetTag("vali_flow.save_changes", saveChanges); + _dbContext.Set().Remove(entity); - await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(DeleteAsync)); + await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(DeleteAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -141,15 +202,28 @@ public async Task DeleteRangeAsync( CancellationToken cancellationToken = default ) { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - IEnumerable entityList = entities.ToList(); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.DeleteRangeAsync", tag: null, entityType: typeof(T).Name); + try + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + IEnumerable entityList = entities.ToList(); - Validation.ValidateEntitiesNotNull(entityList); - Validation.ValidateEntitiesEmpty(entityList); + Validation.ValidateEntitiesNotNull(entityList); + Validation.ValidateEntitiesEmpty(entityList); - _dbContext.Set().RemoveRange(entityList); + activity?.SetTag("vali_flow.save_changes", saveChanges); + activity?.SetTag("vali_flow.entity_count", entityList.Count()); - await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(DeleteRangeAsync)); + _dbContext.Set().RemoveRange(entityList); + + await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(DeleteRangeAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -159,8 +233,18 @@ public async Task DeleteRangeAsync( /// Cancellation token. public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - await ExecuteWithExceptionHandlingAsync(() => _dbContext.SaveChangesAsync(cancellationToken), - nameof(SaveChangesAsync)); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.SaveChangesAsync", tag: null, entityType: typeof(T).Name); + try + { + await ExecuteWithExceptionHandlingAsync(() => _dbContext.SaveChangesAsync(cancellationToken), + nameof(SaveChangesAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -180,21 +264,34 @@ public async Task UpsertAsync( CancellationToken cancellationToken = default ) { - Validation.ValidateEntityNotNull(entity); - if (matchCondition == null) throw new ArgumentNullException(nameof(matchCondition)); - T? existingEntity = await _dbContext.Set().FirstOrDefaultAsync(matchCondition, cancellationToken); - - if (existingEntity == null) + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.UpsertAsync", tag: null, entityType: typeof(T).Name); + try { - await _dbContext.Set().AddAsync(entity, cancellationToken); + Validation.ValidateEntityNotNull(entity); + if (matchCondition == null) throw new ArgumentNullException(nameof(matchCondition)); + activity?.SetTag("vali_flow.save_changes", saveChanges); + + T? existingEntity = await _dbContext.Set().FirstOrDefaultAsync(matchCondition, cancellationToken); + activity?.SetTag("vali_flow.upsert_matched_existing", existingEntity != null); + + if (existingEntity == null) + { + await _dbContext.Set().AddAsync(entity, cancellationToken); + } + else + { + _dbContext.Entry(existingEntity).CurrentValues.SetValues(entity); + } + + await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(UpsertAsync)); + return entity; } - else + catch (Exception ex) { - _dbContext.Entry(existingEntity).CurrentValues.SetValues(entity); + ValiFlowDiagnostics.RecordException(activity, ex); + throw; } - - await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(UpsertAsync)); - return entity; } /// @@ -216,37 +313,72 @@ public async Task> UpsertRangeAsync( CancellationToken cancellationToken = default ) where TProperty : notnull { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - if (keySelector == null) throw new ArgumentNullException(nameof(keySelector)); - IEnumerable entityList = entities.ToList(); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.UpsertRangeAsync", tag: null, entityType: typeof(T).Name); + try + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + if (keySelector == null) throw new ArgumentNullException(nameof(keySelector)); + IEnumerable entityList = entities.ToList(); - Validation.ValidateEntitiesNotNull(entityList); - Validation.ValidateEntitiesEmpty(entityList); + Validation.ValidateEntitiesNotNull(entityList); + Validation.ValidateEntitiesEmpty(entityList); - // Compile keySelector once for in-memory use - var keySelectorFn = keySelector.Compile(); + activity?.SetTag("vali_flow.save_changes", saveChanges); + activity?.SetTag("vali_flow.entity_count", entityList.Count()); - // Materialize keys as a concrete List — EF can translate .Contains() on a local collection - List keys = entityList.Select(keySelectorFn).ToList(); + // Compile keySelector once for in-memory use + var keySelectorFn = keySelector.Compile(); - // Build a predicate that EF can translate: e => keys.Contains(e.Prop) - var param = keySelector.Parameters[0]; - var body = Expression.Call( - typeof(Enumerable), - nameof(Enumerable.Contains), - [typeof(TProperty)], - Expression.Constant(keys), - keySelector.Body - ); - var predicate = Expression.Lambda>(body, param); + // Materialize keys as a concrete List — EF can translate .Contains() on a local collection + List keys = entityList.Select(keySelectorFn).ToList(); - IEnumerable existingEntities = await _dbContext.Set() - .Where(predicate) - .ToListAsync(cancellationToken); + // Build a predicate that EF can translate: e => keys.Contains(e.Prop) + var param = keySelector.Parameters[0]; + var body = Expression.Call( + typeof(Enumerable), + nameof(Enumerable.Contains), + [typeof(TProperty)], + Expression.Constant(keys), + keySelector.Body + ); + var predicate = Expression.Lambda>(body, param); - Dictionary existingEntityDict = existingEntities.ToDictionary(keySelectorFn, e => e); + IEnumerable existingEntities = await _dbContext.Set() + .Where(predicate) + .ToListAsync(cancellationToken); - var newEntities = new List(); + Dictionary existingEntityDict = existingEntities.ToDictionary(keySelectorFn, e => e); + + Dictionary newEntitiesByKey = + PartitionUpsertRange(entityList, keySelectorFn, existingEntityDict); + + if (newEntitiesByKey.Count > 0) + await _dbContext.Set().AddRangeAsync(newEntitiesByKey.Values, cancellationToken); + + await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(UpsertRangeAsync)); + return entityList; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Splits into entities to insert, applying in-place updates to the ones + /// that already exist in as a side effect. + /// Keyed by (not a List) so duplicate keys within the same incoming + /// batch collapse into a single pending insert instead of each being added separately — last occurrence + /// in the batch wins, matching the "update in place" semantics used for keys that already exist in the database. + /// + private Dictionary PartitionUpsertRange( + IEnumerable entityList, + Func keySelectorFn, + Dictionary existingEntityDict) where TProperty : notnull + { + var newEntitiesByKey = new Dictionary(); foreach (T entity in entityList) { @@ -257,15 +389,11 @@ public async Task> UpsertRangeAsync( } else { - newEntities.Add(entity); + newEntitiesByKey[key] = entity; } } - if (newEntities.Any()) - await _dbContext.Set().AddRangeAsync(newEntities, cancellationToken); - - await SaveChangesIfRequestedAsync(saveChanges, cancellationToken, nameof(UpsertRangeAsync)); - return entityList; + return newEntitiesByKey; } /// @@ -282,29 +410,43 @@ public async Task DeleteByConditionAsync( ) { if (condition == null) throw new ArgumentNullException(nameof(condition)); - await ExecuteWithExceptionHandlingAsync(async () => - { - bool isInMemory = _dbContext.Database.ProviderName - ?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) == true; + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.DeleteByConditionAsync", tag: null, entityType: typeof(T).Name); + try + { + await ExecuteWithExceptionHandlingAsync( + () => DeleteByConditionCoreAsync(condition, cancellationToken), + nameof(DeleteByConditionAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } - if (!isInMemory) - { - // ExecuteDeleteAsync translates directly to DELETE FROM … WHERE — no round-trip to load entities - await _dbContext.Set().Where(condition).ExecuteDeleteAsync(cancellationToken); - } - else - { - // Fallback for providers that do not support ExecuteDeleteAsync (e.g., InMemory) - var entities = await _dbContext.Set().Where(condition).ToListAsync(cancellationToken); - if (entities.Count > 0) - { - _dbContext.Set().RemoveRange(entities); - await _dbContext.SaveChangesAsync(cancellationToken); - } - } - return 0; - }, - nameof(DeleteByConditionAsync)); + private async Task DeleteByConditionCoreAsync( + Expression> condition, + CancellationToken cancellationToken) + { + bool isInMemory = _dbContext.Database.ProviderName + ?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) == true; + + if (!isInMemory) + { + // ExecuteDeleteAsync translates directly to DELETE FROM … WHERE — no round-trip to load entities + await _dbContext.Set().Where(condition).ExecuteDeleteAsync(cancellationToken); + return 0; + } + + // Fallback for providers that do not support ExecuteDeleteAsync (e.g., InMemory) + var entities = await _dbContext.Set().Where(condition).ToListAsync(cancellationToken); + if (entities.Count > 0) + { + _dbContext.Set().RemoveRange(entities); + await _dbContext.SaveChangesAsync(cancellationToken); + } + return 0; } /// @@ -341,37 +483,78 @@ public async Task ExecuteUpdateAsync( public async Task ExecuteTransactionAsync(Func operations, CancellationToken cancellationToken = default) { if (operations == null) throw new ArgumentNullException(nameof(operations)); - if (_dbContext.Database.CurrentTransaction != null) + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.ExecuteTransactionAsync", tag: null, entityType: typeof(T).Name); + + bool reusedExistingTransaction = _dbContext.Database.CurrentTransaction != null; + activity?.SetTag("vali_flow.reused_existing_transaction", reusedExistingTransaction); + + if (reusedExistingTransaction) { - await operations(); + await RunWithinExistingTransactionAsync(operations, activity); return; } - await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + await RunWithinNewTransactionAsync(operations, activity, cancellationToken); + } + private async Task RunWithinExistingTransactionAsync(Func operations, Activity? activity) + { try { await operations(); - await transaction.CommitAsync(cancellationToken); } catch (Exception ex) { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + private async Task RunWithinNewTransactionAsync( + Func operations, Activity? activity, CancellationToken cancellationToken) + { + try + { + await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + try { - await transaction.RollbackAsync(cancellationToken); + await operations(); + await transaction.CommitAsync(cancellationToken); } - catch (Exception rollbackEx) + catch (Exception ex) { - throw new AggregateException( - $"Transaction failed and rollback also failed in {nameof(ExecuteTransactionAsync)}.", - ex, - rollbackEx); + Exception failure = await RollbackTransactionAsync(transaction, ex, cancellationToken); + throw failure; } + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } - throw new InvalidOperationException( - $"Error executing transaction in {nameof(ExecuteTransactionAsync)}.", - ex); + private async Task RollbackTransactionAsync( + Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction transaction, Exception ex, + CancellationToken cancellationToken) + { + try + { + await transaction.RollbackAsync(cancellationToken); + } + catch (Exception rollbackEx) + { + return new AggregateException( + $"Transaction failed and rollback also failed in {nameof(ExecuteTransactionAsync)}.", + ex, + rollbackEx); } + + return new InvalidOperationException( + $"Error executing transaction in {nameof(ExecuteTransactionAsync)}.", + ex); } /// @@ -388,19 +571,31 @@ public async Task BulkInsertAsync( CancellationToken cancellationToken = default ) { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - var entityList = entities.ToList(); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.BulkInsertAsync", tag: null, entityType: typeof(T).Name); + try + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + var entityList = entities.ToList(); - Validation.ValidateEntitiesNotNull(entityList); - Validation.ValidateEntitiesEmpty(entityList); + Validation.ValidateEntitiesNotNull(entityList); + Validation.ValidateEntitiesEmpty(entityList); - await ExecuteWithExceptionHandlingAsync( - async () => - { - await _dbContext.BulkInsertAsync(entityList, bulkConfig, cancellationToken: cancellationToken); - return 0; - }, - nameof(BulkInsertAsync)); + activity?.SetTag("vali_flow.entity_count", entityList.Count); + + await ExecuteWithExceptionHandlingAsync( + async () => + { + await _dbContext.BulkInsertAsync(entityList, bulkConfig, cancellationToken: cancellationToken); + return 0; + }, + nameof(BulkInsertAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -417,19 +612,31 @@ public async Task BulkUpdateAsync( CancellationToken cancellationToken = default ) { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - var entityList = entities.ToList(); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.BulkUpdateAsync", tag: null, entityType: typeof(T).Name); + try + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + var entityList = entities.ToList(); - Validation.ValidateEntitiesNotNull(entityList); - Validation.ValidateEntitiesEmpty(entityList); + Validation.ValidateEntitiesNotNull(entityList); + Validation.ValidateEntitiesEmpty(entityList); - await ExecuteWithExceptionHandlingAsync( - async () => - { - await _dbContext.BulkUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken); - return 0; - }, - nameof(BulkUpdateAsync)); + activity?.SetTag("vali_flow.entity_count", entityList.Count); + + await ExecuteWithExceptionHandlingAsync( + async () => + { + await _dbContext.BulkUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken); + return 0; + }, + nameof(BulkUpdateAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -446,19 +653,31 @@ public async Task BulkDeleteAsync( CancellationToken cancellationToken = default ) { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - var entityList = entities.ToList(); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.BulkDeleteAsync", tag: null, entityType: typeof(T).Name); + try + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + var entityList = entities.ToList(); - Validation.ValidateEntitiesNotNull(entityList); - Validation.ValidateEntitiesEmpty(entityList); + Validation.ValidateEntitiesNotNull(entityList); + Validation.ValidateEntitiesEmpty(entityList); - await ExecuteWithExceptionHandlingAsync( - async () => - { - await _dbContext.BulkDeleteAsync(entityList, bulkConfig, cancellationToken: cancellationToken); - return 0; - }, - nameof(BulkDeleteAsync)); + activity?.SetTag("vali_flow.entity_count", entityList.Count); + + await ExecuteWithExceptionHandlingAsync( + async () => + { + await _dbContext.BulkDeleteAsync(entityList, bulkConfig, cancellationToken: cancellationToken); + return 0; + }, + nameof(BulkDeleteAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -475,18 +694,30 @@ public async Task BulkInsertOrUpdateAsync( CancellationToken cancellationToken = default ) { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - var entityList = entities.ToList(); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.BulkInsertOrUpdateAsync", tag: null, entityType: typeof(T).Name); + try + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + var entityList = entities.ToList(); - Validation.ValidateEntitiesNotNull(entityList); - Validation.ValidateEntitiesEmpty(entityList); + Validation.ValidateEntitiesNotNull(entityList); + Validation.ValidateEntitiesEmpty(entityList); - await ExecuteWithExceptionHandlingAsync( - async () => - { - await _dbContext.BulkInsertOrUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken); - return 0; - }, - nameof(BulkInsertOrUpdateAsync)); + activity?.SetTag("vali_flow.entity_count", entityList.Count); + + await ExecuteWithExceptionHandlingAsync( + async () => + { + await _dbContext.BulkInsertOrUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken); + return 0; + }, + nameof(BulkInsertOrUpdateAsync)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } } diff --git a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.cs b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.cs index cdd7001..07e35aa 100644 --- a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.cs +++ b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.cs @@ -6,9 +6,7 @@ using Vali_Flow.Core.Builder; using Vali_Flow.Interfaces.Evaluators.Read; using Vali_Flow.Interfaces.Evaluators.Write; -using Vali_Flow.Interfaces.Options; using Vali_Flow.Interfaces.Specification; -using Vali_Flow.Utils; namespace Vali_Flow.Classes.Evaluators; @@ -20,6 +18,7 @@ namespace Vali_Flow.Classes.Evaluators; public partial class ValiFlowEvaluator : IEvaluatorRead, IEvaluatorWrite where T : class { private readonly DbContext _dbContext; + private readonly QuerySpecificationBuilder _queryBuilder; /// /// Initializes a new instance of the class. @@ -29,57 +28,21 @@ public partial class ValiFlowEvaluator : IEvaluatorRead, IEvaluatorWrite(_dbContext); } #region Methods Private - private IQueryable ApplyAsNoTracking(IQueryable query, bool asNoTracking = true) => asNoTracking ? query.AsNoTracking() : query; - - private IQueryable ApplyAsNoTrackingWithIdentityResolution(IQueryable query, bool apply) - => apply ? query.AsNoTrackingWithIdentityResolution() : query; - - private IQueryable ApplyTagWith(IQueryable query, string? tag) - => tag != null ? query.TagWith(tag) : query; - - private IQueryable ApplyIgnoreAutoIncludes(IQueryable query, bool apply) - => apply ? query.IgnoreAutoIncludes() : query; - - private IQueryable ApplyIncludes(IQueryable query, IEnumerable>? includes) - { - if (includes != null) - { - foreach (var include in includes) - { - query = include.ApplyInclude(query); - } - } + // Query construction (filter, includes, tracking, ordering, pagination) lives in QuerySpecificationBuilder — + // these are thin delegating wrappers kept so Read/Write/Aggregates/Grouped partial files don't need to change. + private IQueryable BuildBasicQuery(ISpecification specification, bool negateCondition = false) + => _queryBuilder.BuildBasicQuery(specification, negateCondition); - return query; - } + private IQueryable BuildQuery(IQuerySpecification specification, bool negateFilter = false) + => _queryBuilder.BuildQuery(specification, negateFilter); private IQueryable ApplyOrdering(IQueryable query, IQuerySpecification specification) - { - if (specification.ValiSort != null) - return specification.ValiSort.Apply(query); - - if (specification.OrderBy == null) - { - if (specification.ThenBys != null) - throw new InvalidOperationException( - "ThenBy requires a primary OrderBy. Set OrderBy on the specification before adding ThenBy expressions."); - return query; - } - - IOrderedQueryable orderedQuery = specification.OrderBy.ApplyOrderBy(query); - - if (specification.ThenBys != null) - { - foreach (var thenBy in specification.ThenBys) - orderedQuery = thenBy.ApplyThenBy(orderedQuery); - } - - return orderedQuery; - } + => _queryBuilder.ApplyOrdering(query, specification); private async Task ExecuteWithExceptionHandlingAsync(Func> operation, string operationName) @@ -95,78 +58,6 @@ private async Task ExecuteWithExceptionHandlingAsync(Func ApplyPagination(IQueryable query, IQuerySpecification specification) - { - if (specification is { Page: not null, PageSize: not null }) - { - int skip = (specification.Page.Value - Constants.One) * specification.PageSize.Value; - int take = specification.PageSize.Value; - query = query.Skip(skip).Take(take); - } - else if (specification.Top != null) - { - query = query.Take(specification.Top.Value); - } - - return query; - } - - private IQueryable ApplyAsSplitQuery(IQueryable query, bool asSplitQuery = false) => asSplitQuery ? query.AsSplitQuery() : query; - - private IQueryable BuildBasicQuery(ISpecification specification, bool negateCondition = false) - { - IQueryable query = _dbContext.Set(); - - query = ApplyIgnoreQueryFilters(query, specification.IgnoreQueryFilters); - query = ApplyIgnoreAutoIncludes(query, specification.IgnoreAutoIncludes); - query = ApplyWhere(query, specification.Filter, negateCondition); - - if (specification.AsNoTracking && specification.AsNoTrackingWithIdentityResolution) - throw new InvalidOperationException( - "AsNoTracking and AsNoTrackingWithIdentityResolution are mutually exclusive. " + - "Use WithAsNoTracking(false) before calling WithAsNoTrackingWithIdentityResolution(true)."); - - query = ApplyAsNoTracking(query, specification.AsNoTracking); - query = ApplyAsNoTrackingWithIdentityResolution(query, specification.AsNoTrackingWithIdentityResolution); - query = ApplyIncludes(query, specification.Includes); - query = ApplyAsSplitQuery(query, specification.AsSplitQuery); - query = ApplyTagWith(query, specification.TagWith); - - return query; - } - - private IQueryable ApplyWhere(IQueryable query, ValiFlowQuery filter, bool negateCondition = false) - { - Validation.ValidateFilterNotNull(filter); - - Expression> condition = negateCondition ? filter.BuildNegated() : filter.BuildWithGlobal(); - return query.Where(condition); - } - - private IQueryable BuildQuery(IQuerySpecification specification, bool negateFilter = false) - { - if (specification.Top.HasValue && (specification.Page.HasValue || specification.PageSize.HasValue)) - throw new InvalidOperationException( - "Cannot combine Top with Page/PageSize pagination on the same specification. Use one or the other."); - - if ((specification.Page.HasValue && specification.Page.Value > 0 && (!specification.PageSize.HasValue || specification.PageSize.Value <= 0)) || - (specification.PageSize.HasValue && specification.PageSize.Value > 0 && (!specification.Page.HasValue || specification.Page.Value <= 0))) - throw new InvalidOperationException( - "Both Page and PageSize must be set together. Use WithPagination(page, pageSize)."); - - bool hasPagination = specification.Page.HasValue || specification.PageSize.HasValue; - bool hasOrdering = specification.OrderBy != null || specification.ValiSort != null; - if (hasPagination && !hasOrdering) - throw new InvalidOperationException( - "Pagination requires an ordering to produce deterministic results. " + - "Set OrderBy or ValiSort on the specification before enabling pagination."); - - IQueryable query = BuildBasicQuery(specification, negateFilter); - query = ApplyOrdering(query, specification); - query = ApplyPagination(query, specification); - return query; - } - private async Task SaveChangesIfRequestedAsync(bool saveChanges, CancellationToken cancellationToken, string operationName) { @@ -178,9 +69,6 @@ await ExecuteWithExceptionHandlingAsync( } } - private IQueryable ApplyIgnoreQueryFilters(IQueryable query, bool ignoreQueryFilters = false) => - ignoreQueryFilters ? query.IgnoreQueryFilters() : query; - private sealed class ParameterReplacerVisitor : ExpressionVisitor { private readonly ParameterExpression _oldParameter; diff --git a/Vali-Flow/Classes/Repository/GenericRepository.cs b/Vali-Flow/Classes/Repository/GenericRepository.cs new file mode 100644 index 0000000..46b9299 --- /dev/null +++ b/Vali-Flow/Classes/Repository/GenericRepository.cs @@ -0,0 +1,83 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Classes.Specification; +using Vali_Flow.Core.Builder; +using Vali_Flow.Interfaces.Repository; +using Vali_Flow.Models; + +namespace Vali_Flow.Classes.Repository; + +/// +/// Convenience repository that wraps a . It does not reimplement any +/// query/persistence logic — every member is a thin delegation to the evaluator's existing read/write surface. +/// +/// The entity type. Must be a reference type. +/// The type of the entity's primary key. +public sealed class GenericRepository : IGenericRepository where T : class where TKey : IEquatable +{ + private readonly ValiFlowEvaluator _evaluator; + private readonly Expression> _idSelector; + + /// + /// Initializes a new instance of . + /// + /// The underlying evaluator this repository delegates to. + /// + /// Expression identifying the entity's primary key (e.g. x => x.Id). + /// has no built-in concept of a primary key — it only works with specifications/filters — so the repository + /// needs this to build an EqualTo filter for . + /// + public GenericRepository(ValiFlowEvaluator evaluator, Expression> idSelector) + { + _evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator)); + _idSelector = idSelector ?? throw new ArgumentNullException(nameof(idSelector)); + } + + /// + public Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default) + { + ValiFlowQuery filter = new ValiFlowQuery().EqualTo(_idSelector, id); + BasicSpecification spec = new(filter); + return _evaluator.EvaluateGetFirstAsync(spec, cancellationToken); + } + + /// + public async Task> GetAllAsync(ValiFlowQuery? filter = null, CancellationToken cancellationToken = default) + { + QuerySpecification spec = filter is null ? new QuerySpecification() : new QuerySpecification(filter); + IQueryable query = await _evaluator.EvaluateQueryAsync(spec); + return await query.ToListAsync(cancellationToken); + } + + /// + public Task> GetPagedAsync( + ValiFlowQuery? filter, + Expression> orderBy, + int page, + int pageSize, + bool ascending = true, + CancellationToken cancellationToken = default + ) where TOrderKey : notnull + { + QuerySpecification spec = filter is null ? new QuerySpecification() : new QuerySpecification(filter); + spec.WithOrderBy(orderBy, ascending).WithPagination(page, pageSize); + return _evaluator.EvaluatePagedResultAsync(spec, cancellationToken); + } + + /// + public Task AddAsync(T entity, bool saveChanges = true, CancellationToken cancellationToken = default) + => _evaluator.AddAsync(entity, saveChanges, cancellationToken); + + /// + public Task UpdateAsync(T entity, bool saveChanges = true, CancellationToken cancellationToken = default) + => _evaluator.UpdateAsync(entity, saveChanges, cancellationToken); + + /// + public Task DeleteAsync(T entity, bool saveChanges = true, CancellationToken cancellationToken = default) + => _evaluator.DeleteAsync(entity, saveChanges, cancellationToken); + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _evaluator.SaveChangesAsync(cancellationToken); +} diff --git a/Vali-Flow/Interfaces/Evaluators/Read/IEvaluatorRead.cs b/Vali-Flow/Interfaces/Evaluators/Read/IEvaluatorRead.cs index 9da3ae5..a15360a 100644 --- a/Vali-Flow/Interfaces/Evaluators/Read/IEvaluatorRead.cs +++ b/Vali-Flow/Interfaces/Evaluators/Read/IEvaluatorRead.cs @@ -152,7 +152,7 @@ Task> EvaluateDuplicatesAsync( /// The specification defining the filtering and inclusion criteria. /// The expression to select the property to evaluate. /// A token to cancel the asynchronous operation. - /// A task that represents the asynchronous operation, returning the minimum value of the selected property. + /// A task that represents the asynchronous operation, returning the minimum value of the selected property, or default(TResult) if no entity matches the specification. Task EvaluateMinAsync( IBasicSpecification specification, Expression> selector, @@ -166,7 +166,7 @@ Task EvaluateMinAsync( /// The specification defining the filtering and inclusion criteria. /// The expression to select the property to evaluate. /// A token to cancel the asynchronous operation. - /// A task that represents the asynchronous operation, returning the maximum value of the selected property. + /// A task that represents the asynchronous operation, returning the maximum value of the selected property, or default(TResult) if no entity matches the specification. Task EvaluateMaxAsync( IBasicSpecification specification, Expression> selector, @@ -658,6 +658,18 @@ Task> EvaluatePagedAsync( CancellationToken cancellationToken = default ); + /// + /// Alias for — provided for API symmetry with + /// Vali-Flow.InMemory's EvaluatePagedResult. + /// + /// The specification defining the filtering, ordering, and pagination criteria. + /// A token to cancel the asynchronous operation. + /// A task that represents the asynchronous operation, returning a with the items and pagination metadata. + Task> EvaluatePagedResultAsync( + IQuerySpecification specification, + CancellationToken cancellationToken = default + ); + /// /// Asynchronously retrieves the top entities satisfying the specification. /// diff --git a/Vali-Flow/Interfaces/Repository/IGenericRepository.cs b/Vali-Flow/Interfaces/Repository/IGenericRepository.cs new file mode 100644 index 0000000..813c93b --- /dev/null +++ b/Vali-Flow/Interfaces/Repository/IGenericRepository.cs @@ -0,0 +1,50 @@ +using System.Linq.Expressions; +using Vali_Flow.Core.Builder; +using Vali_Flow.Models; + +namespace Vali_Flow.Interfaces.Repository; + +/// +/// High-level, mockable convenience wrapper over . +/// Every member delegates to the underlying evaluator — it does not reimplement any query or persistence logic. +/// +/// The entity type. Must be a reference type. +/// The type of the entity's primary key. +public interface IGenericRepository where T : class where TKey : IEquatable +{ + /// + /// Retrieves the entity whose key (as defined by the idSelector passed to the repository constructor) + /// equals , or null if none matches. + /// + Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default); + + /// + /// Retrieves all entities matching (or every entity when is null). + /// + Task> GetAllAsync(ValiFlowQuery? filter = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves a page of entities matching , ordered by . + /// Ordering is required because deterministic pagination needs an ORDER BY. + /// + Task> GetPagedAsync( + ValiFlowQuery? filter, + Expression> orderBy, + int page, + int pageSize, + bool ascending = true, + CancellationToken cancellationToken = default + ) where TOrderKey : notnull; + + /// Adds a single entity. See . + Task AddAsync(T entity, bool saveChanges = true, CancellationToken cancellationToken = default); + + /// Updates a single entity. See . + Task UpdateAsync(T entity, bool saveChanges = true, CancellationToken cancellationToken = default); + + /// Deletes a single entity. See . + Task DeleteAsync(T entity, bool saveChanges = true, CancellationToken cancellationToken = default); + + /// Persists any pending changes tracked by the underlying . + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} From 29ce85665d10dc75467aee55b3578b4b02d9dc28 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:32:58 -0500 Subject: [PATCH 03/30] refactor(inmemory): split evaluator into partial files, fix Update leak, add diagnostics tags - Split ValiFlowEvaluator (904 lines) into Bridge/Read/Write/Grouped partial files - Fix Update(entity, externalList) leaking into internal store on a later bare SaveChanges() - Add optional tag parameter across read/write/grouped methods, wired to ValiFlowDiagnostics Activity --- .../ValiFlowInMemoryDiagnosticsTests.cs | 137 ++++ .../ValiFlowInMemoryWriteTests.cs | 17 + .../Evaluators/ValiFlowEvaluator.Bridge.cs | 37 + .../Evaluators/ValiFlowEvaluator.Grouped.cs | 326 ++++++++ .../Evaluators/ValiFlowEvaluator.Read.cs | 655 +++++++++++++++ .../Evaluators/ValiFlowEvaluator.Write.cs | 193 +++++ .../Classes/Evaluators/ValiFlowEvaluator.cs | 773 +----------------- .../Classes/Stores/InMemoryWriteStore.cs | 8 +- .../Evaluators/Read/IInMemoryAggregator.cs | 15 +- .../Evaluators/Read/IInMemoryEvaluatorCore.cs | 10 +- .../Evaluators/Read/IInMemoryGrouping.cs | 27 +- .../Evaluators/Read/IInMemoryQueryable.cs | 33 +- .../Write/IInMemoryEvaluatorWrite.cs | 20 +- 13 files changed, 1438 insertions(+), 813 deletions(-) create mode 100644 Vali-Flow.InMemory.Tests/ValiFlowInMemoryDiagnosticsTests.cs create mode 100644 Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Bridge.cs create mode 100644 Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Grouped.cs create mode 100644 Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Read.cs create mode 100644 Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Write.cs diff --git a/Vali-Flow.InMemory.Tests/ValiFlowInMemoryDiagnosticsTests.cs b/Vali-Flow.InMemory.Tests/ValiFlowInMemoryDiagnosticsTests.cs new file mode 100644 index 0000000..e66f19c --- /dev/null +++ b/Vali-Flow.InMemory.Tests/ValiFlowInMemoryDiagnosticsTests.cs @@ -0,0 +1,137 @@ +using System.Diagnostics; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.Core.Builder; +using Vali_Flow.InMemory.Classes.Evaluators; +using Vali_Flow.InMemory.Tests.Models; + +namespace Vali_Flow.InMemory.Tests; + +public sealed class ValiFlowInMemoryDiagnosticsTests +{ + private static readonly List Seed = new() + { + new() { Id = 1, Name = "Apple", Category = "Fruit", Price = 1.5m, Stock = 100, IsActive = true }, + new() { Id = 2, Name = "Donut", Category = "Sweet", Price = 2.5m, Stock = 50, IsActive = false }, + }; + + private static ValiFlowEvaluator CreateEvaluator() + => new(Seed, null, p => p.Id); + + /// + /// Attaches a listener and parents every captured activity under a private root, so concurrently + /// running tests (xunit parallelizes test classes by default) can never pollute this test's capture + /// list even though they share the same process-wide . + /// + private static (ActivityListener Listener, Activity Root, List Captured) AttachScopedListener() + { + var captured = new List(); + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = captured.Add + }; + ActivitySource.AddActivityListener(listener); + + var root = new Activity("TestRoot").Start(); // becomes Activity.Current -> parents child activities + return (listener, root, captured); + } + + private static Activity GetOwn(List captured, Activity root, string operationName) + => captured.Single(a => a.OperationName == operationName && a.ParentId == root.Id); + + [Fact] + public void EvaluateAny_WithTag_SetsTagAndEntityType() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + var evaluator = CreateEvaluator(); + var filter = new ValiFlow().IsTrue(p => p.IsActive); + + evaluator.EvaluateAny(valiFlow: filter, tag: "MyReport"); + + var activity = GetOwn(captured, root, "Vali-Flow.InMemory.EvaluateAny"); + activity.GetTagItem("vali_flow.tag").Should().Be("MyReport"); + activity.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestProduct)); + } + + [Fact] + public void EvaluateCount_WithoutTag_SetsNoTag() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + var evaluator = CreateEvaluator(); + + evaluator.EvaluateCount(); + + var activity = GetOwn(captured, root, "Vali-Flow.InMemory.EvaluateCount"); + activity.GetTagItem("vali_flow.tag").Should().BeNull(); + } + + [Fact] + public void EvaluateMin_NoMatchingEntities_RecordsErrorStatusAndRethrows() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + var evaluator = CreateEvaluator(); + // Filter matches nothing -> Min() on an empty int sequence throws InvalidOperationException. + var filter = new ValiFlow().IsTrue(p => p.Id == -1); + + Action act = () => evaluator.EvaluateMin(null, p => p.Stock, filter, tag: "Ingest"); + + act.Should().Throw(); + var activity = GetOwn(captured, root, "Vali-Flow.InMemory.EvaluateMin"); + activity.Status.Should().Be(ActivityStatusCode.Error); + activity.GetTagItem("vali_flow.tag").Should().Be("Ingest"); + activity.Events.Should().ContainSingle(e => e.Name == "exception"); + } + + [Fact] + public void EvaluateGrouped_WithTag_SetsTagAndEntityType() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + var evaluator = CreateEvaluator(); + + evaluator.EvaluateGrouped(null, p => p.Category, tag: "GroupReport"); + + var activity = GetOwn(captured, root, "Vali-Flow.InMemory.EvaluateGrouped"); + activity.GetTagItem("vali_flow.tag").Should().Be("GroupReport"); + activity.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestProduct)); + } + + [Fact] + public void EvaluateTopByGroup_InvalidCount_RecordsErrorStatus() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + var evaluator = CreateEvaluator(); + + Action act = () => evaluator.EvaluateTopByGroup(null, p => p.Category, count: 0, tag: "BadCount"); + + act.Should().Throw(); + var activity = GetOwn(captured, root, "Vali-Flow.InMemory.EvaluateTopByGroup"); + activity.Status.Should().Be(ActivityStatusCode.Error); + } + + [Fact] + public void EvaluatePagedResult_InvalidPage_RecordsErrorStatus() + { + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; + var evaluator = CreateEvaluator(); + + Action act = () => evaluator.EvaluatePagedResult(page: 0, tag: "BadPage"); + + act.Should().Throw(); + var activity = GetOwn(captured, root, "Vali-Flow.InMemory.EvaluatePagedResult"); + activity.Status.Should().Be(ActivityStatusCode.Error); + activity.GetTagItem("vali_flow.tag").Should().Be("BadPage"); + } +} diff --git a/Vali-Flow.InMemory.Tests/ValiFlowInMemoryWriteTests.cs b/Vali-Flow.InMemory.Tests/ValiFlowInMemoryWriteTests.cs index 0b69f17..a6606b1 100644 --- a/Vali-Flow.InMemory.Tests/ValiFlowInMemoryWriteTests.cs +++ b/Vali-Flow.InMemory.Tests/ValiFlowInMemoryWriteTests.cs @@ -179,6 +179,23 @@ public void Update_ToExplicitList_UpdatesListImmediately() inList.Name.Should().Be("Banana Split"); } + [Fact] + public void Update_ToExplicitList_DoesNotLeakIntoInternalStoreOnNextBareSaveChanges() + { + // Update(entity, externalList) applies immediately to externalList — it must NOT also + // queue the change for replay against the internal store on a later bare SaveChanges(). + var seed = CreateMutableSeed(); + var evaluator = CreateEvaluator(seed); // internal store is a separate copy, also containing Id=2 + var externalList = CreateMutableSeed(); // unrelated external list, happens to share the same IDs + var updated = new TestProduct { Id = 2, Name = "Banana Split", Category = "Fruit", Price = 0.9m, Stock = 155, IsActive = true }; + + evaluator.Update(updated, externalList); + evaluator.SaveChanges(); // targets the internal store, unrelated to externalList + + TestProduct? internalEntry = evaluator.GetFirst(null, new ValiFlow().EqualTo(p => p.Id, 2)); + internalEntry!.Name.Should().Be("Banana", "the update was only meant for externalList, not the internal store"); + } + // ----------------------------------------------------------------------- // UpdateRange // ----------------------------------------------------------------------- diff --git a/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Bridge.cs b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Bridge.cs new file mode 100644 index 0000000..35bf0f5 --- /dev/null +++ b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Bridge.cs @@ -0,0 +1,37 @@ +using System.Linq.Expressions; +using Vali_Flow.Abstractions.Interfaces; +using Vali_Flow.Core.Builder; + +namespace Vali_Flow.InMemory.Classes.Evaluators; + +/// Explicit / implementation — provider-agnostic async surface, delegated to . +public partial class ValiFlowEvaluator +{ + Task IQueryReader.EvaluateAnyAsync(ValiFlow? filter, CancellationToken cancellationToken) + => AsyncAdapter.EvaluateAnyAsync(filter, cancellationToken); + + Task IQueryReader.EvaluateCountAsync(ValiFlow? filter, CancellationToken cancellationToken) + => AsyncAdapter.EvaluateCountAsync(filter, cancellationToken); + + Task IQueryReader.EvaluateGetFirstAsync(ValiFlow? filter, CancellationToken cancellationToken) + => AsyncAdapter.EvaluateGetFirstAsync(filter, cancellationToken); + + Task IQueryReader.EvaluateGetLastAsync(ValiFlow? filter, CancellationToken cancellationToken) + => AsyncAdapter.EvaluateGetLastAsync(filter, cancellationToken); + + Task IQueryAggregator.EvaluateMinAsync( + Expression> selector, ValiFlow? filter, CancellationToken cancellationToken) + => AsyncAdapter.EvaluateMinAsync(selector, filter, cancellationToken); + + Task IQueryAggregator.EvaluateMaxAsync( + Expression> selector, ValiFlow? filter, CancellationToken cancellationToken) + => AsyncAdapter.EvaluateMaxAsync(selector, filter, cancellationToken); + + Task IQueryAggregator.EvaluateAverageAsync( + Expression> selector, ValiFlow? filter, CancellationToken cancellationToken) + => AsyncAdapter.EvaluateAverageAsync(selector, filter, cancellationToken); + + Task IQueryAggregator.EvaluateSumAsync( + Expression> selector, ValiFlow? filter, CancellationToken cancellationToken) + => AsyncAdapter.EvaluateSumAsync(selector, filter, cancellationToken); +} diff --git a/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Grouped.cs b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Grouped.cs new file mode 100644 index 0000000..bf855f7 --- /dev/null +++ b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Grouped.cs @@ -0,0 +1,326 @@ +using System.Numerics; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.Core.Builder; + +namespace Vali_Flow.InMemory.Classes.Evaluators; + +public partial class ValiFlowEvaluator +{ + /// + /// Groups matching entities by a key selector and returns a dictionary of lists. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to group entities. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Dictionary mapping each key to its group of entities. + public Dictionary> EvaluateGrouped( + IEnumerable? entities, + Func keySelector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TKey : notnull + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateGrouped", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + Func condition = GetDefaultCondition(valiFlow, negateCondition); + var result = new Dictionary>(); + foreach (T item in dataSource) + { + if (!condition(item)) continue; + TKey key = keySelector(item); + if (!result.TryGetValue(key, out List? group)) + result[key] = group = new List(); + group.Add(item); + } + return result; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the count of matching entities per group key. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to group entities. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Dictionary mapping each key to its entity count. + public Dictionary EvaluateCountByGroup( + IEnumerable? entities, + Func keySelector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TKey : notnull + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateCountByGroup", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) + .GroupBy(keySelector) + .ToDictionary(g => g.Key, g => g.Count()); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the sum of a numeric property per group key for all matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to group entities. + /// Property selector for the numeric value to sum. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Dictionary mapping each key to the sum of the selected property. + public Dictionary EvaluateSumByGroup( + IEnumerable? entities, + Func keySelector, + Func selector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TKey : notnull where TResult : INumber + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateSumByGroup", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) + .GroupBy(keySelector) + .ToDictionary(g => g.Key, g => + g.Aggregate(TResult.Zero, (acc, item) => acc + selector(item))); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the minimum value of a numeric property per group key for all matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to group entities. + /// Property selector for the numeric value to minimize. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Dictionary mapping each key to the minimum of the selected property, or TResult.Zero for empty groups. + public Dictionary EvaluateMinByGroup( + IEnumerable? entities, + Func keySelector, + Func selector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TKey : notnull where TResult : INumber + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateMinByGroup", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) + .GroupBy(keySelector) + .ToDictionary(g => g.Key, g => g.Select(selector).Min() ?? TResult.Zero); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the maximum value of a numeric property per group key for all matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to group entities. + /// Property selector for the numeric value to maximize. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Dictionary mapping each key to the maximum of the selected property, or TResult.Zero for empty groups. + public Dictionary EvaluateMaxByGroup( + IEnumerable? entities, + Func keySelector, + Func selector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TKey : notnull where TResult : INumber + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateMaxByGroup", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) + .GroupBy(keySelector) + .ToDictionary(g => g.Key, g => g.Select(selector).Max() ?? TResult.Zero); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the average of a numeric property per group key for all matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to group entities. + /// Property selector for the numeric value to average. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Dictionary mapping each key to the average of the selected property. + public Dictionary EvaluateAverageByGroup( + IEnumerable? entities, + Func keySelector, + Func selector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TKey : notnull where TResult : INumber + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateAverageByGroup", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + Func condition = GetDefaultCondition(valiFlow, negateCondition); + return dataSource.Where(condition) + .GroupBy(keySelector) + .ToDictionary(g => g.Key, g => ComputeAverage(g.Select(selector))); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns groups where the key appears more than once among matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to detect duplicates. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Dictionary containing only groups with more than one element. + public Dictionary> EvaluateDuplicatesByGroup( + IEnumerable? entities, + Func keySelector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TKey : notnull + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateDuplicatesByGroup", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) + .GroupBy(keySelector) + .Where(g => g.Skip(1).Any()) + .ToDictionary(g => g.Key, g => g.ToList()); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns groups where the key appears exactly once among matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to identify unique groups. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Dictionary containing only singleton groups, mapping each key to its single entity. + public Dictionary EvaluateUniquesByGroup( + IEnumerable? entities, + Func keySelector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TKey : notnull + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateUniquesByGroup", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) + .GroupBy(keySelector) + .Where(g => !g.Skip(1).Any()) + .ToDictionary(g => g.Key, g => g.First()); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the top entities per group key, with optional ordering within each group. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to group entities. + /// Maximum number of entities to include per group. + /// Optional ordering key applied before grouping. + /// When , orders ascending; otherwise descending. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Dictionary mapping each key to its top entities. + /// Thrown when is less than or equal to zero. + public Dictionary> EvaluateTopByGroup( + IEnumerable? entities, + Func keySelector, + int count, + Func? orderBy = null, + bool ascending = true, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TKey : notnull + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateTopByGroup", tag, typeof(T).Name); + try + { + if (count <= 0) throw new ArgumentOutOfRangeException(nameof(count), "Count must be greater than zero."); + IEnumerable dataSource = entities ?? _store.Items; + IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)); + if (orderBy != null) query = ascending ? query.OrderBy(orderBy) : query.OrderByDescending(orderBy); + + return query.GroupBy(keySelector) + .ToDictionary(g => g.Key, g => g.Take(count).ToList()); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } +} diff --git a/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Read.cs b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Read.cs new file mode 100644 index 0000000..5d3a3e5 --- /dev/null +++ b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Read.cs @@ -0,0 +1,655 @@ +using System.Numerics; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.Core.Builder; +using Vali_Flow.InMemory.Classes.Options; +using Vali_Flow.InMemory.Models; + +namespace Vali_Flow.InMemory.Classes.Evaluators; + +public partial class ValiFlowEvaluator +{ + /// + /// Evaluates whether a single entity satisfies the active filter. + /// + /// The entity to evaluate. + /// Optional filter override; uses the instance default when . + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// if the entity matches the (possibly negated) condition. + public bool Evaluate(T entity, ValiFlow? valiFlow = null, bool negateCondition = false, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.Evaluate", tag, typeof(T).Name); + try + { + return GetDefaultCondition(valiFlow, negateCondition)(entity); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Determines whether any entity in the collection satisfies the filter. + /// + /// Source collection; falls back to the internal store when . + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// if at least one entity matches. + public bool EvaluateAny(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateAny", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Any(GetDefaultCondition(valiFlow, negateCondition)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the count of entities that satisfy the filter. + /// + /// Source collection; falls back to the internal store when . + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Number of matching entities. + public int EvaluateCount(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateCount", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Count(GetDefaultCondition(valiFlow, negateCondition)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the first entity that does NOT satisfy the filter. + /// + /// Source collection; falls back to the internal store when . + /// Optional filter override. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// The first non-matching entity, or if none exists. + public T? GetFirstFailed(IEnumerable? entities = null, ValiFlow? valiFlow = null, string? tag = null) + => GetFirst(entities, valiFlow, negateCondition: true, tag: tag); + + /// + /// Returns the first entity that satisfies the filter. + /// + /// Source collection; falls back to the internal store when . + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// The first matching entity, or if none exists. + public T? GetFirst(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.GetFirst", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.FirstOrDefault(GetDefaultCondition(valiFlow, negateCondition)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns all entities that do NOT satisfy the filter, with optional ordering. + /// + /// Source collection; falls back to the internal store when . + /// Primary key selector for ordering. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// Ordered sequence of non-matching entities. + public IEnumerable EvaluateAllFailed( + IEnumerable? entities = null, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateAllFailed", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negated: true)); + return ApplyOrdering(query, orderBy, ascending, thenBys); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns all entities that satisfy the filter, with optional ordering. + /// + /// Source collection; falls back to the internal store when . + /// Primary key selector for ordering. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Ordered sequence of matching entities. + public IEnumerable EvaluateAll( + IEnumerable? entities = null, Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateAll", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)); + return ApplyOrdering(query, orderBy, ascending, thenBys); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns a paged subset of entities that satisfy the filter. + /// + /// Source collection; falls back to the internal store when . + /// 1-based page number. + /// Number of items per page. + /// Primary key selector for ordering. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Sequence of entities for the requested page. + /// Thrown when or is less than 1. + public IEnumerable EvaluatePaged( + IEnumerable? entities = null, + int page = 1, + int pageSize = 10, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, bool negateCondition = false, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluatePaged", tag, typeof(T).Name); + try + { + if (page < 1) throw new ArgumentOutOfRangeException(nameof(page), "Page must be greater than or equal to 1."); + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be greater than or equal to 1."); + IEnumerable dataSource = entities ?? _store.Items; + IEnumerable query = EvaluateAll(dataSource, orderBy, ascending, thenBys, valiFlow, negateCondition); + return query.Skip((page - 1) * pageSize).Take(pageSize); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns a containing the paged data and total count. + /// + /// Source collection; falls back to the internal store when . + /// 1-based page number. + /// Number of items per page. + /// Primary key selector for ordering. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// A with the current page items and the total filtered count. + /// Thrown when or is less than 1. + public PagedResult EvaluatePagedResult( + IEnumerable? entities = null, + int page = 1, + int pageSize = 10, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluatePagedResult", tag, typeof(T).Name); + try + { + if (page < 1) throw new ArgumentOutOfRangeException(nameof(page), "Page must be greater than or equal to 1."); + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be greater than or equal to 1."); + IEnumerable dataSource = entities ?? _store.Items; + var filtered = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)).ToList(); + int totalCount = filtered.Count; + IEnumerable ordered = ApplyOrdering(filtered, orderBy, ascending, thenBys); + List pageItems = ordered.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + return new PagedResult(pageItems, totalCount, page, pageSize); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the top entities that satisfy the filter. + /// + /// Source collection; falls back to the internal store when . + /// Maximum number of entities to return. + /// Primary key selector for ordering. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Sequence of up to matching entities. + /// Thrown when is less than or equal to zero. + public IEnumerable EvaluateTop( + IEnumerable? entities = null, + int count = 10, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateTop", tag, typeof(T).Name); + try + { + if (count <= 0) throw new ArgumentOutOfRangeException(nameof(count), "Count must be greater than zero."); + IEnumerable dataSource = entities ?? _store.Items; + IEnumerable query = EvaluateAll(dataSource, orderBy, ascending, thenBys, valiFlow, negateCondition); + return query.Take(count); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns distinct entities based on a key selector, with optional ordering. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to determine uniqueness. + /// Primary key selector for ordering. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Sequence of distinct matching entities. + /// Thrown when is . + public IEnumerable EvaluateDistinct( + IEnumerable? entities, + Func selector, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateDistinct", tag, typeof(T).Name); + try + { + if (selector == null) throw new ArgumentNullException(nameof(selector)); + IEnumerable dataSource = entities ?? _store.Items; + IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) + .GroupBy(selector) + .Select(g => g.First()); + + return ApplyOrdering(query, orderBy, ascending, thenBys); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns all entities whose key appears more than once in the filtered collection. + /// + /// Source collection; falls back to the internal store when . + /// Key selector used to detect duplicates. + /// Primary key selector for ordering. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// All entities belonging to duplicate groups. + /// Thrown when is . + public IEnumerable EvaluateDuplicates( + IEnumerable? entities, + Func selector, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateDuplicates", tag, typeof(T).Name); + try + { + if (selector == null) throw new ArgumentNullException(nameof(selector)); + IEnumerable dataSource = entities ?? _store.Items; + IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) + .GroupBy(selector) + .Where(g => g.Count() > 1) + .SelectMany(g => g); + + return ApplyOrdering(query, orderBy, ascending, thenBys); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the zero-based index of the first entity that satisfies the filter after ordering. + /// + /// Source collection; falls back to the internal store when . + /// Primary key selector for ordering before index lookup. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Zero-based index of the first match, or -1 if not found. + public int GetFirstMatchIndex( + IEnumerable? entities = null, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.GetFirstMatchIndex", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + List ordered = ApplyOrdering(dataSource, orderBy, ascending, thenBys).ToList(); + Func condition = GetDefaultCondition(valiFlow, negateCondition); + return ordered.FindIndex(item => condition(item)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the zero-based index of the last entity that satisfies the filter after ordering. + /// + /// Source collection; falls back to the internal store when . + /// Primary key selector for ordering before index lookup. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Zero-based index of the last match, or -1 if not found. + public int GetLastMatchIndex( + IEnumerable? entities = null, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.GetLastMatchIndex", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + List ordered = ApplyOrdering(dataSource, orderBy, ascending, thenBys).ToList(); + Func condition = GetDefaultCondition(valiFlow, negateCondition); + return ordered.FindLastIndex(item => condition(item)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the last entity that does NOT satisfy the filter. + /// + /// Source collection; falls back to the internal store when . + /// Primary key selector for ordering. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// The last non-matching entity, or if none exists. + public T? GetLastFailed( + IEnumerable? entities = null, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + string? tag = null + ) => GetLast(entities, orderBy, ascending, thenBys, valiFlow, negateCondition: true, tag: tag); + + /// + /// Returns the last entity that satisfies the filter. + /// + /// Source collection; falls back to the internal store when . + /// Primary key selector for ordering. + /// When , orders ascending; otherwise descending. + /// Optional secondary sort criteria. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// The last matching entity, or if none exists. + public T? GetLast( + IEnumerable? entities = null, + Func? orderBy = null, + bool ascending = true, + IEnumerable>? thenBys = null, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.GetLast", tag, typeof(T).Name); + try + { + return EvaluateAll(entities, orderBy, ascending, thenBys, valiFlow, negateCondition).LastOrDefault(); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the minimum value of a numeric property across all matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Property selector for the numeric value. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Minimum value, or TResult.Zero when no entities match. + public TResult EvaluateMin( + IEnumerable? entities, + Func selector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TResult : INumber + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateMin", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)).Select(selector).Min() ?? TResult.Zero; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the maximum value of a numeric property across all matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Property selector for the numeric value. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Maximum value, or TResult.Zero when no entities match. + public TResult EvaluateMax( + IEnumerable? entities, + Func selector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TResult : INumber + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateMax", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)).Select(selector).Max() ?? TResult.Zero; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the average value of a numeric property across all matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Property selector for the numeric value. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Average as , or 0 when no entities match. + public decimal EvaluateAverage( + IEnumerable? entities, + Func selector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TResult : INumber + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateAverage", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + Func condition = GetDefaultCondition(valiFlow, negateCondition); + return ComputeAverage(dataSource.Where(condition).Select(selector)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Returns the sum of a numeric property across all matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Property selector for the numeric value. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Sum of the selected values, or TResult.Zero when no entities match. + public TResult EvaluateSum( + IEnumerable? entities, + Func selector, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TResult : INumber + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateSum", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource + .Where(GetDefaultCondition(valiFlow, negateCondition)) + .Aggregate(TResult.Zero, (acc, item) => acc + selector(item)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// + /// Applies a custom aggregation function over a numeric property of all matching entities. + /// + /// Source collection; falls back to the internal store when . + /// Property selector for the numeric value. + /// Binary accumulator function applied to successive values. + /// Optional filter override. + /// When , the filter predicate is logically negated. + /// Aggregated result, starting from TResult.Zero. + public TResult EvaluateAggregate( + IEnumerable? entities, + Func selector, + Func aggregator, + ValiFlow? valiFlow = null, + bool negateCondition = false, + string? tag = null + ) where TResult : INumber + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.EvaluateAggregate", tag, typeof(T).Name); + try + { + IEnumerable dataSource = entities ?? _store.Items; + return dataSource + .Where(GetDefaultCondition(valiFlow, negateCondition)) + .Select(selector) + .Aggregate(TResult.Zero, aggregator); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + +} diff --git a/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Write.cs b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Write.cs new file mode 100644 index 0000000..acb9406 --- /dev/null +++ b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.Write.cs @@ -0,0 +1,193 @@ +using Vali_Flow.Abstractions.Diagnostics; + +namespace Vali_Flow.InMemory.Classes.Evaluators; + +public partial class ValiFlowEvaluator +{ + /// Adds a single entity to the store or the supplied list. + /// Entity to add. + /// Optional external list to operate on instead of the internal store. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// if the entity was added successfully. + public bool Add(T entity, List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.Add", tag, typeof(T).Name); + try + { + return _store.Add(entity, entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// Updates a matching entity in the store or the supplied list. + /// Entity with updated values; matched by the configured key selector. + /// Optional external list to operate on instead of the internal store. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// The updated entity, or if no matching entity was found. + public T? Update(T entity, List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.Update", tag, typeof(T).Name); + try + { + return _store.Update(entity, entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// Removes a matching entity from the store or the supplied list. + /// Entity to remove; matched by the configured key selector. + /// Optional external list to operate on instead of the internal store. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// if the entity was found and removed. + public bool Delete(T entity, List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.Delete", tag, typeof(T).Name); + try + { + return _store.Delete(entity, entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// Adds multiple entities to the store or the supplied list. + /// Entities to add. + /// Optional external list to operate on instead of the internal store. + /// Optional diagnostics tag identifying why/where this operation was triggered. + public void AddRange(IEnumerable entitiesToAdd, List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.AddRange", tag, typeof(T).Name); + try + { + _store.AddRange(entitiesToAdd, entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// Updates multiple matching entities in the store or the supplied list. + /// Entities with updated values; each matched by the configured key selector. + /// Optional external list to operate on instead of the internal store. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Sequence of successfully updated entities. + public IEnumerable UpdateRange(IEnumerable entitiesToUpdate, List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.UpdateRange", tag, typeof(T).Name); + try + { + return _store.UpdateRange(entitiesToUpdate, entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// Removes multiple matching entities from the store or the supplied list. + /// Entities to remove; each matched by the configured key selector. + /// Optional external list to operate on instead of the internal store. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Number of entities removed. + public int DeleteRange(IEnumerable entitiesToDelete, List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.DeleteRange", tag, typeof(T).Name); + try + { + return _store.DeleteRange(entitiesToDelete, entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// Inserts or updates a single entity in the store or the supplied list. + /// Entity to insert if not found, or update if already present (matched by key selector). + /// Optional external list to operate on instead of the internal store. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// The inserted or updated entity. + public T Upsert(T entity, List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.Upsert", tag, typeof(T).Name); + try + { + return _store.Upsert(entity, entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// Inserts or updates multiple entities in the store or the supplied list. + /// Entities to insert or update; each matched by the configured key selector. + /// Optional external list to operate on instead of the internal store. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Sequence of inserted or updated entities. + public IEnumerable UpsertRange(IEnumerable entitiesToUpsert, List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.UpsertRange", tag, typeof(T).Name); + try + { + return _store.UpsertRange(entitiesToUpsert, entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// Removes all entities that satisfy an arbitrary predicate. + /// Condition an entity must meet to be removed. + /// Optional external list to operate on instead of the internal store. + /// Optional diagnostics tag identifying why/where this operation was triggered. + /// Number of entities removed. + public int DeleteByCondition(Func predicate, List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.DeleteByCondition", tag, typeof(T).Name); + try + { + return _store.DeleteByCondition(predicate, entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + /// Persists pending changes to the internal store or flushes the supplied list. + /// Optional external list whose changes should be committed. + /// Optional diagnostics tag identifying why/where this operation was triggered. + public void SaveChanges(List? entities = null, string? tag = null) + { + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.InMemory.SaveChanges", tag, typeof(T).Name); + try + { + _store.SaveChanges(entities); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } +} diff --git a/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.cs b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.cs index d78a5e7..62f9d7a 100644 --- a/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.cs +++ b/Vali-Flow.InMemory/Classes/Evaluators/ValiFlowEvaluator.cs @@ -1,4 +1,3 @@ -using System.Linq.Expressions; using System.Numerics; using Vali_Flow.Abstractions.Interfaces; using Vali_Flow.Core.Builder; @@ -6,7 +5,6 @@ using Vali_Flow.InMemory.Classes.Stores; using Vali_Flow.InMemory.Interfaces.Evaluators.Read; using Vali_Flow.InMemory.Interfaces.Evaluators.Write; -using Vali_Flow.InMemory.Models; namespace Vali_Flow.InMemory.Classes.Evaluators; @@ -16,7 +14,7 @@ namespace Vali_Flow.InMemory.Classes.Evaluators; /// /// The type of the entities to evaluate. Must be a reference type. /// The type of the property used as the entity identifier key. Must be non-nullable. -public class ValiFlowEvaluator : IInMemoryEvaluatorRead, IInMemoryEvaluatorWrite +public partial class ValiFlowEvaluator : IInMemoryEvaluatorRead, IInMemoryEvaluatorWrite where T : class where TProperty : notnull { @@ -80,677 +78,6 @@ private Func GetDefaultCondition(ValiFlow? valiFlow = null, bool neg } } - /// - /// Evaluates whether a single entity satisfies the active filter. - /// - /// The entity to evaluate. - /// Optional filter override; uses the instance default when . - /// When , the filter predicate is logically negated. - /// if the entity matches the (possibly negated) condition. - public bool Evaluate(T entity, ValiFlow? valiFlow = null, bool negateCondition = false) - { - return GetDefaultCondition(valiFlow, negateCondition)(entity); - } - - /// - /// Determines whether any entity in the collection satisfies the filter. - /// - /// Source collection; falls back to the internal store when . - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// if at least one entity matches. - public bool EvaluateAny(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false) - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Any(GetDefaultCondition(valiFlow, negateCondition)); - } - - /// - /// Returns the count of entities that satisfy the filter. - /// - /// Source collection; falls back to the internal store when . - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Number of matching entities. - public int EvaluateCount(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false) - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Count(GetDefaultCondition(valiFlow, negateCondition)); - } - - /// - /// Returns the first entity that does NOT satisfy the filter. - /// - /// Source collection; falls back to the internal store when . - /// Optional filter override. - /// The first non-matching entity, or if none exists. - public T? GetFirstFailed(IEnumerable? entities = null, ValiFlow? valiFlow = null) - => GetFirst(entities, valiFlow, negateCondition: true); - - /// - /// Returns the first entity that satisfies the filter. - /// - /// Source collection; falls back to the internal store when . - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// The first matching entity, or if none exists. - public T? GetFirst(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false) - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.FirstOrDefault(GetDefaultCondition(valiFlow, negateCondition)); - } - - /// - /// Returns all entities that do NOT satisfy the filter, with optional ordering. - /// - /// Source collection; falls back to the internal store when . - /// Primary key selector for ordering. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// Ordered sequence of non-matching entities. - public IEnumerable EvaluateAllFailed( - IEnumerable? entities = null, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null - ) - { - IEnumerable dataSource = entities ?? _store.Items; - IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negated: true)); - return ApplyOrdering(query, orderBy, ascending, thenBys); - } - - /// - /// Returns all entities that satisfy the filter, with optional ordering. - /// - /// Source collection; falls back to the internal store when . - /// Primary key selector for ordering. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Ordered sequence of matching entities. - public IEnumerable EvaluateAll( - IEnumerable? entities = null, Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) - { - IEnumerable dataSource = entities ?? _store.Items; - IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)); - return ApplyOrdering(query, orderBy, ascending, thenBys); - } - - /// - /// Returns a paged subset of entities that satisfy the filter. - /// - /// Source collection; falls back to the internal store when . - /// 1-based page number. - /// Number of items per page. - /// Primary key selector for ordering. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Sequence of entities for the requested page. - /// Thrown when or is less than 1. - public IEnumerable EvaluatePaged( - IEnumerable? entities = null, - int page = 1, - int pageSize = 10, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null, bool negateCondition = false - ) - { - if (page < 1) throw new ArgumentOutOfRangeException(nameof(page), "Page must be greater than or equal to 1."); - if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be greater than or equal to 1."); - IEnumerable dataSource = entities ?? _store.Items; - IEnumerable query = EvaluateAll(dataSource, orderBy, ascending, thenBys, valiFlow, negateCondition); - return query.Skip((page - 1) * pageSize).Take(pageSize); - } - - /// - /// Returns a containing the paged data and total count. - /// - /// Source collection; falls back to the internal store when . - /// 1-based page number. - /// Number of items per page. - /// Primary key selector for ordering. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// A with the current page items and the total filtered count. - /// Thrown when or is less than 1. - public PagedResult EvaluatePagedResult( - IEnumerable? entities = null, - int page = 1, - int pageSize = 10, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) - { - if (page < 1) throw new ArgumentOutOfRangeException(nameof(page), "Page must be greater than or equal to 1."); - if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be greater than or equal to 1."); - IEnumerable dataSource = entities ?? _store.Items; - var filtered = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)).ToList(); - int totalCount = filtered.Count; - IEnumerable ordered = ApplyOrdering(filtered, orderBy, ascending, thenBys); - List pageItems = ordered.Skip((page - 1) * pageSize).Take(pageSize).ToList(); - return new PagedResult(pageItems, totalCount, page, pageSize); - } - - /// - /// Returns the top entities that satisfy the filter. - /// - /// Source collection; falls back to the internal store when . - /// Maximum number of entities to return. - /// Primary key selector for ordering. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Sequence of up to matching entities. - /// Thrown when is less than or equal to zero. - public IEnumerable EvaluateTop( - IEnumerable? entities = null, - int count = 10, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) - { - if (count <= 0) throw new ArgumentOutOfRangeException(nameof(count), "Count must be greater than zero."); - IEnumerable dataSource = entities ?? _store.Items; - IEnumerable query = EvaluateAll(dataSource, orderBy, ascending, thenBys, valiFlow, negateCondition); - return query.Take(count); - } - - /// - /// Returns distinct entities based on a key selector, with optional ordering. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to determine uniqueness. - /// Primary key selector for ordering. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Sequence of distinct matching entities. - /// Thrown when is . - public IEnumerable EvaluateDistinct( - IEnumerable? entities, - Func selector, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) - { - if (selector == null) throw new ArgumentNullException(nameof(selector)); - IEnumerable dataSource = entities ?? _store.Items; - IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) - .GroupBy(selector) - .Select(g => g.First()); - - return ApplyOrdering(query, orderBy, ascending, thenBys); - } - - /// - /// Returns all entities whose key appears more than once in the filtered collection. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to detect duplicates. - /// Primary key selector for ordering. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// All entities belonging to duplicate groups. - /// Thrown when is . - public IEnumerable EvaluateDuplicates( - IEnumerable? entities, - Func selector, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) - { - if (selector == null) throw new ArgumentNullException(nameof(selector)); - IEnumerable dataSource = entities ?? _store.Items; - IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) - .GroupBy(selector) - .Where(g => g.Count() > 1) - .SelectMany(g => g); - - return ApplyOrdering(query, orderBy, ascending, thenBys); - } - - /// - /// Returns the zero-based index of the first entity that satisfies the filter after ordering. - /// - /// Source collection; falls back to the internal store when . - /// Primary key selector for ordering before index lookup. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Zero-based index of the first match, or -1 if not found. - public int GetFirstMatchIndex( - IEnumerable? entities = null, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) - { - IEnumerable dataSource = entities ?? _store.Items; - List ordered = ApplyOrdering(dataSource, orderBy, ascending, thenBys).ToList(); - Func condition = GetDefaultCondition(valiFlow, negateCondition); - return ordered.FindIndex(item => condition(item)); - } - - /// - /// Returns the zero-based index of the last entity that satisfies the filter after ordering. - /// - /// Source collection; falls back to the internal store when . - /// Primary key selector for ordering before index lookup. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Zero-based index of the last match, or -1 if not found. - public int GetLastMatchIndex( - IEnumerable? entities = null, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) - { - IEnumerable dataSource = entities ?? _store.Items; - List ordered = ApplyOrdering(dataSource, orderBy, ascending, thenBys).ToList(); - Func condition = GetDefaultCondition(valiFlow, negateCondition); - return ordered.FindLastIndex(item => condition(item)); - } - - /// - /// Returns the last entity that does NOT satisfy the filter. - /// - /// Source collection; falls back to the internal store when . - /// Primary key selector for ordering. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// The last non-matching entity, or if none exists. - public T? GetLastFailed( - IEnumerable? entities = null, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null - ) => GetLast(entities, orderBy, ascending, thenBys, valiFlow, negateCondition: true); - - /// - /// Returns the last entity that satisfies the filter. - /// - /// Source collection; falls back to the internal store when . - /// Primary key selector for ordering. - /// When , orders ascending; otherwise descending. - /// Optional secondary sort criteria. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// The last matching entity, or if none exists. - public T? GetLast( - IEnumerable? entities = null, - Func? orderBy = null, - bool ascending = true, - IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) - { - return EvaluateAll(entities, orderBy, ascending, thenBys, valiFlow, negateCondition).LastOrDefault(); - } - - /// - /// Returns the minimum value of a numeric property across all matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Property selector for the numeric value. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Minimum value, or TResult.Zero when no entities match. - public TResult EvaluateMin( - IEnumerable? entities, - Func selector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TResult : INumber - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)).Select(selector).Min() ?? TResult.Zero; - } - - /// - /// Returns the maximum value of a numeric property across all matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Property selector for the numeric value. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Maximum value, or TResult.Zero when no entities match. - public TResult EvaluateMax( - IEnumerable? entities, - Func selector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TResult : INumber - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)).Select(selector).Max() ?? TResult.Zero; - } - - /// - /// Returns the average value of a numeric property across all matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Property selector for the numeric value. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Average as , or 0 when no entities match. - public decimal EvaluateAverage( - IEnumerable? entities, - Func selector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TResult : INumber - { - IEnumerable dataSource = entities ?? _store.Items; - Func condition = GetDefaultCondition(valiFlow, negateCondition); - return ComputeAverage(dataSource.Where(condition).Select(selector)); - } - - /// - /// Returns the sum of a numeric property across all matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Property selector for the numeric value. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Sum of the selected values, or TResult.Zero when no entities match. - public TResult EvaluateSum( - IEnumerable? entities, - Func selector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TResult : INumber - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource - .Where(GetDefaultCondition(valiFlow, negateCondition)) - .Aggregate(TResult.Zero, (acc, item) => acc + selector(item)); - } - - /// - /// Applies a custom aggregation function over a numeric property of all matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Property selector for the numeric value. - /// Binary accumulator function applied to successive values. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Aggregated result, starting from TResult.Zero. - public TResult EvaluateAggregate( - IEnumerable? entities, - Func selector, - Func aggregator, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TResult : INumber - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource - .Where(GetDefaultCondition(valiFlow, negateCondition)) - .Select(selector) - .Aggregate(TResult.Zero, aggregator); - } - - /// - /// Groups matching entities by a key selector and returns a dictionary of lists. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to group entities. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Dictionary mapping each key to its group of entities. - public Dictionary> EvaluateGrouped( - IEnumerable? entities, - Func keySelector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TKey : notnull - { - IEnumerable dataSource = entities ?? _store.Items; - Func condition = GetDefaultCondition(valiFlow, negateCondition); - var result = new Dictionary>(); - foreach (T item in dataSource) - { - if (!condition(item)) continue; - TKey key = keySelector(item); - if (!result.TryGetValue(key, out List? group)) - result[key] = group = new List(); - group.Add(item); - } - return result; - } - - /// - /// Returns the count of matching entities per group key. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to group entities. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Dictionary mapping each key to its entity count. - public Dictionary EvaluateCountByGroup( - IEnumerable? entities, - Func keySelector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TKey : notnull - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) - .GroupBy(keySelector) - .ToDictionary(g => g.Key, g => g.Count()); - } - - /// - /// Returns the sum of a numeric property per group key for all matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to group entities. - /// Property selector for the numeric value to sum. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Dictionary mapping each key to the sum of the selected property. - public Dictionary EvaluateSumByGroup( - IEnumerable? entities, - Func keySelector, - Func selector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TKey : notnull where TResult : INumber - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) - .GroupBy(keySelector) - .ToDictionary(g => g.Key, g => - g.Aggregate(TResult.Zero, (acc, item) => acc + selector(item))); - } - - /// - /// Returns the minimum value of a numeric property per group key for all matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to group entities. - /// Property selector for the numeric value to minimize. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Dictionary mapping each key to the minimum of the selected property, or TResult.Zero for empty groups. - public Dictionary EvaluateMinByGroup( - IEnumerable? entities, - Func keySelector, - Func selector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TKey : notnull where TResult : INumber - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) - .GroupBy(keySelector) - .ToDictionary(g => g.Key, g => g.Select(selector).Min() ?? TResult.Zero); - } - - /// - /// Returns the maximum value of a numeric property per group key for all matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to group entities. - /// Property selector for the numeric value to maximize. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Dictionary mapping each key to the maximum of the selected property, or TResult.Zero for empty groups. - public Dictionary EvaluateMaxByGroup( - IEnumerable? entities, - Func keySelector, - Func selector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TKey : notnull where TResult : INumber - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) - .GroupBy(keySelector) - .ToDictionary(g => g.Key, g => g.Select(selector).Max() ?? TResult.Zero); - } - - /// - /// Returns the average of a numeric property per group key for all matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to group entities. - /// Property selector for the numeric value to average. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Dictionary mapping each key to the average of the selected property. - public Dictionary EvaluateAverageByGroup( - IEnumerable? entities, - Func keySelector, - Func selector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TKey : notnull where TResult : INumber - { - IEnumerable dataSource = entities ?? _store.Items; - Func condition = GetDefaultCondition(valiFlow, negateCondition); - return dataSource.Where(condition) - .GroupBy(keySelector) - .ToDictionary(g => g.Key, g => ComputeAverage(g.Select(selector))); - } - - /// - /// Returns groups where the key appears more than once among matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to detect duplicates. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Dictionary containing only groups with more than one element. - public Dictionary> EvaluateDuplicatesByGroup( - IEnumerable? entities, - Func keySelector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TKey : notnull - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) - .GroupBy(keySelector) - .Where(g => g.Skip(1).Any()) - .ToDictionary(g => g.Key, g => g.ToList()); - } - - /// - /// Returns groups where the key appears exactly once among matching entities. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to identify unique groups. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Dictionary containing only singleton groups, mapping each key to its single entity. - public Dictionary EvaluateUniquesByGroup( - IEnumerable? entities, - Func keySelector, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TKey : notnull - { - IEnumerable dataSource = entities ?? _store.Items; - return dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)) - .GroupBy(keySelector) - .Where(g => !g.Skip(1).Any()) - .ToDictionary(g => g.Key, g => g.First()); - } - - /// - /// Returns the top entities per group key, with optional ordering within each group. - /// - /// Source collection; falls back to the internal store when . - /// Key selector used to group entities. - /// Maximum number of entities to include per group. - /// Optional ordering key applied before grouping. - /// When , orders ascending; otherwise descending. - /// Optional filter override. - /// When , the filter predicate is logically negated. - /// Dictionary mapping each key to its top entities. - /// Thrown when is less than or equal to zero. - public Dictionary> EvaluateTopByGroup( - IEnumerable? entities, - Func keySelector, - int count, - Func? orderBy = null, - bool ascending = true, - ValiFlow? valiFlow = null, - bool negateCondition = false - ) where TKey : notnull - { - if (count <= 0) throw new ArgumentOutOfRangeException(nameof(count), "Count must be greater than zero."); - IEnumerable dataSource = entities ?? _store.Items; - IEnumerable query = dataSource.Where(GetDefaultCondition(valiFlow, negateCondition)); - if (orderBy != null) query = ascending ? query.OrderBy(orderBy) : query.OrderByDescending(orderBy); - - return query.GroupBy(keySelector) - .ToDictionary(g => g.Key, g => g.Take(count).ToList()); - } - #endregion #region Methods Private @@ -803,102 +130,4 @@ private IEnumerable ApplyOrdering( } #endregion - - #region Methods Write - - /// Adds a single entity to the store or the supplied list. - /// Entity to add. - /// Optional external list to operate on instead of the internal store. - /// if the entity was added successfully. - public bool Add(T entity, List? entities = null) => _store.Add(entity, entities); - - /// Updates a matching entity in the store or the supplied list. - /// Entity with updated values; matched by the configured key selector. - /// Optional external list to operate on instead of the internal store. - /// The updated entity, or if no matching entity was found. - public T? Update(T entity, List? entities = null) => _store.Update(entity, entities); - - /// Removes a matching entity from the store or the supplied list. - /// Entity to remove; matched by the configured key selector. - /// Optional external list to operate on instead of the internal store. - /// if the entity was found and removed. - public bool Delete(T entity, List? entities = null) => _store.Delete(entity, entities); - - /// Adds multiple entities to the store or the supplied list. - /// Entities to add. - /// Optional external list to operate on instead of the internal store. - public void AddRange(IEnumerable entitiesToAdd, List? entities = null) => - _store.AddRange(entitiesToAdd, entities); - - /// Updates multiple matching entities in the store or the supplied list. - /// Entities with updated values; each matched by the configured key selector. - /// Optional external list to operate on instead of the internal store. - /// Sequence of successfully updated entities. - public IEnumerable UpdateRange(IEnumerable entitiesToUpdate, List? entities = null) => - _store.UpdateRange(entitiesToUpdate, entities); - - /// Removes multiple matching entities from the store or the supplied list. - /// Entities to remove; each matched by the configured key selector. - /// Optional external list to operate on instead of the internal store. - /// Number of entities removed. - public int DeleteRange(IEnumerable entitiesToDelete, List? entities = null) => - _store.DeleteRange(entitiesToDelete, entities); - - /// Inserts or updates a single entity in the store or the supplied list. - /// Entity to insert if not found, or update if already present (matched by key selector). - /// Optional external list to operate on instead of the internal store. - /// The inserted or updated entity. - public T Upsert(T entity, List? entities = null) => _store.Upsert(entity, entities); - - /// Inserts or updates multiple entities in the store or the supplied list. - /// Entities to insert or update; each matched by the configured key selector. - /// Optional external list to operate on instead of the internal store. - /// Sequence of inserted or updated entities. - public IEnumerable UpsertRange(IEnumerable entitiesToUpsert, List? entities = null) => - _store.UpsertRange(entitiesToUpsert, entities); - - /// Removes all entities that satisfy an arbitrary predicate. - /// Condition an entity must meet to be removed. - /// Optional external list to operate on instead of the internal store. - /// Number of entities removed. - public int DeleteByCondition(Func predicate, List? entities = null) => - _store.DeleteByCondition(predicate, entities); - - /// Persists pending changes to the internal store or flushes the supplied list. - /// Optional external list whose changes should be committed. - public void SaveChanges(List? entities = null) => _store.SaveChanges(entities); - - #endregion - - #region IQueryReader + IQueryAggregator — provider-agnostic methods - - Task IQueryReader.EvaluateAnyAsync(ValiFlow? filter, CancellationToken cancellationToken) - => AsyncAdapter.EvaluateAnyAsync(filter, cancellationToken); - - Task IQueryReader.EvaluateCountAsync(ValiFlow? filter, CancellationToken cancellationToken) - => AsyncAdapter.EvaluateCountAsync(filter, cancellationToken); - - Task IQueryReader.EvaluateGetFirstAsync(ValiFlow? filter, CancellationToken cancellationToken) - => AsyncAdapter.EvaluateGetFirstAsync(filter, cancellationToken); - - Task IQueryReader.EvaluateGetLastAsync(ValiFlow? filter, CancellationToken cancellationToken) - => AsyncAdapter.EvaluateGetLastAsync(filter, cancellationToken); - - Task IQueryAggregator.EvaluateMinAsync( - Expression> selector, ValiFlow? filter, CancellationToken cancellationToken) - => AsyncAdapter.EvaluateMinAsync(selector, filter, cancellationToken); - - Task IQueryAggregator.EvaluateMaxAsync( - Expression> selector, ValiFlow? filter, CancellationToken cancellationToken) - => AsyncAdapter.EvaluateMaxAsync(selector, filter, cancellationToken); - - Task IQueryAggregator.EvaluateAverageAsync( - Expression> selector, ValiFlow? filter, CancellationToken cancellationToken) - => AsyncAdapter.EvaluateAverageAsync(selector, filter, cancellationToken); - - Task IQueryAggregator.EvaluateSumAsync( - Expression> selector, ValiFlow? filter, CancellationToken cancellationToken) - => AsyncAdapter.EvaluateSumAsync(selector, filter, cancellationToken); - - #endregion } diff --git a/Vali-Flow.InMemory/Classes/Stores/InMemoryWriteStore.cs b/Vali-Flow.InMemory/Classes/Stores/InMemoryWriteStore.cs index 9daa7b2..ae8b8b6 100644 --- a/Vali-Flow.InMemory/Classes/Stores/InMemoryWriteStore.cs +++ b/Vali-Flow.InMemory/Classes/Stores/InMemoryWriteStore.cs @@ -63,13 +63,19 @@ internal bool Add(T entity, List? entities = null) dataSource.FirstOrDefault(e => EqualityComparer.Default.Equals(_getId(e), _getId(entity))); if (existing != null) { - _updatedEntities.Add(entity); if (entities is List list) { + // Explicit external list: apply immediately, never queue — queuing here would + // leak this update into the internal store (or another list) on a later bare + // SaveChanges()/SaveChanges(otherList) call that has nothing to do with this one. var index = list.FindIndex(e => EqualityComparer.Default.Equals(_getId(e), _getId(entity))); if (index >= 0) list[index] = entity; } + else + { + _updatedEntities.Add(entity); + } return entity; } diff --git a/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryAggregator.cs b/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryAggregator.cs index 2eec9c8..9a32de9 100644 --- a/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryAggregator.cs +++ b/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryAggregator.cs @@ -22,7 +22,8 @@ TResult EvaluateMin( IEnumerable? entities, Func selector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TResult : INumber; /// @@ -38,7 +39,8 @@ TResult EvaluateMax( IEnumerable? entities, Func selector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TResult : INumber; /// @@ -54,7 +56,8 @@ decimal EvaluateAverage( IEnumerable? entities, Func selector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TResult : INumber; /// @@ -70,7 +73,8 @@ TResult EvaluateSum( IEnumerable? entities, Func selector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TResult : INumber; /// @@ -88,6 +92,7 @@ TResult EvaluateAggregate( Func selector, Func aggregator, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TResult : INumber; } diff --git a/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryEvaluatorCore.cs b/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryEvaluatorCore.cs index 0d80208..2b4998d 100644 --- a/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryEvaluatorCore.cs +++ b/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryEvaluatorCore.cs @@ -15,7 +15,7 @@ public interface IInMemoryEvaluatorCore where T : class /// The Vali-Flow condition to apply. If null, evaluates to true. /// If true, negates the Vali-Flow condition. /// True if the entity satisfies the condition; otherwise, false. - bool Evaluate(T entity, ValiFlow? valiFlow = null, bool negateCondition = false); + bool Evaluate(T entity, ValiFlow? valiFlow = null, bool negateCondition = false, string? tag = null); /// /// Determines whether any entity in the collection satisfies the specified Vali-Flow condition. @@ -24,7 +24,7 @@ public interface IInMemoryEvaluatorCore where T : class /// The Vali-Flow condition to apply. If null, evaluates to true for non-empty collections. /// If true, negates the Vali-Flow condition. /// True if at least one entity satisfies the condition; otherwise, false. - bool EvaluateAny(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false); + bool EvaluateAny(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false, string? tag = null); /// /// Counts the number of entities in the collection that satisfy the specified Vali-Flow condition. @@ -33,7 +33,7 @@ public interface IInMemoryEvaluatorCore where T : class /// The Vali-Flow condition to apply. If null, counts all entities. /// If true, negates the Vali-Flow condition. /// The number of entities that satisfy the condition. - int EvaluateCount(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false); + int EvaluateCount(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false, string? tag = null); /// /// Retrieves the first entity that satisfies the specified Vali-Flow condition. @@ -42,7 +42,7 @@ public interface IInMemoryEvaluatorCore where T : class /// The ValiFlow condition to apply. If null, returns the first entity. /// If true, negates the Vali-Flow condition. /// The first entity that satisfies the condition, or null if none satisfy. - T? GetFirst(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false); + T? GetFirst(IEnumerable? entities = null, ValiFlow? valiFlow = null, bool negateCondition = false, string? tag = null); /// /// Retrieves the first entity that does not satisfy the specified Vali-Flow condition. @@ -50,5 +50,5 @@ public interface IInMemoryEvaluatorCore where T : class /// The collection of entities to evaluate. /// The Vali-Flow condition to apply. If null, returns the first entity. /// The first entity that fails the condition, or null if none fail. - T? GetFirstFailed(IEnumerable? entities = null, ValiFlow? valiFlow = null); + T? GetFirstFailed(IEnumerable? entities = null, ValiFlow? valiFlow = null, string? tag = null); } diff --git a/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryGrouping.cs b/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryGrouping.cs index 32b62cd..e15a4f6 100644 --- a/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryGrouping.cs +++ b/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryGrouping.cs @@ -22,7 +22,8 @@ Dictionary> EvaluateGrouped( IEnumerable? entities, Func keySelector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TKey : notnull; /// @@ -38,7 +39,8 @@ Dictionary EvaluateCountByGroup( IEnumerable? entities, Func keySelector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TKey : notnull; /// @@ -57,7 +59,8 @@ Dictionary EvaluateSumByGroup( Func keySelector, Func selector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TResult : INumber where TKey : notnull; /// @@ -76,7 +79,8 @@ Dictionary EvaluateMinByGroup( Func keySelector, Func selector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TResult : INumber where TKey : notnull; /// @@ -95,7 +99,8 @@ Dictionary EvaluateMaxByGroup( Func keySelector, Func selector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TResult : INumber where TKey : notnull; /// @@ -114,7 +119,8 @@ Dictionary EvaluateAverageByGroup( Func keySelector, Func selector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TResult : INumber where TKey : notnull; /// @@ -130,7 +136,8 @@ Dictionary> EvaluateDuplicatesByGroup( IEnumerable? entities, Func keySelector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TKey : notnull; /// @@ -146,7 +153,8 @@ Dictionary EvaluateUniquesByGroup( IEnumerable? entities, Func keySelector, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TKey : notnull; /// @@ -169,6 +177,7 @@ Dictionary> EvaluateTopByGroup( Func? orderBy = null, bool ascending = true, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ) where TKey : notnull; } diff --git a/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryQueryable.cs b/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryQueryable.cs index d4f3a97..0e58d18 100644 --- a/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryQueryable.cs +++ b/Vali-Flow.InMemory/Interfaces/Evaluators/Read/IInMemoryQueryable.cs @@ -27,7 +27,8 @@ IEnumerable EvaluateAll( bool ascending = true, IEnumerable>? thenBys = null, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ); /// @@ -45,7 +46,8 @@ IEnumerable EvaluateAllFailed( Func? orderBy = null, bool ascending = true, IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null + ValiFlow? valiFlow = null, + string? tag = null ); /// @@ -69,7 +71,8 @@ IEnumerable EvaluatePaged( bool ascending = true, IEnumerable>? thenBys = null, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ); /// @@ -93,7 +96,8 @@ PagedResult EvaluatePagedResult( bool ascending = true, IEnumerable>? thenBys = null, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ); /// @@ -115,7 +119,8 @@ IEnumerable EvaluateTop( bool ascending = true, IEnumerable>? thenBys = null, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ); /// @@ -137,7 +142,8 @@ IEnumerable EvaluateDistinct( bool ascending = true, IEnumerable>? thenBys = null, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ); /// @@ -159,7 +165,8 @@ IEnumerable EvaluateDuplicates( bool ascending = true, IEnumerable>? thenBys = null, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ); /// @@ -179,7 +186,8 @@ int GetFirstMatchIndex( bool ascending = true, IEnumerable>? thenBys = null, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ); /// @@ -199,7 +207,8 @@ int GetLastMatchIndex( bool ascending = true, IEnumerable>? thenBys = null, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ); /// @@ -219,7 +228,8 @@ int GetLastMatchIndex( bool ascending = true, IEnumerable>? thenBys = null, ValiFlow? valiFlow = null, - bool negateCondition = false + bool negateCondition = false, + string? tag = null ); /// @@ -237,6 +247,7 @@ int GetLastMatchIndex( Func? orderBy = null, bool ascending = true, IEnumerable>? thenBys = null, - ValiFlow? valiFlow = null + ValiFlow? valiFlow = null, + string? tag = null ); } diff --git a/Vali-Flow.InMemory/Interfaces/Evaluators/Write/IInMemoryEvaluatorWrite.cs b/Vali-Flow.InMemory/Interfaces/Evaluators/Write/IInMemoryEvaluatorWrite.cs index 9fa3921..9bbc8a0 100644 --- a/Vali-Flow.InMemory/Interfaces/Evaluators/Write/IInMemoryEvaluatorWrite.cs +++ b/Vali-Flow.InMemory/Interfaces/Evaluators/Write/IInMemoryEvaluatorWrite.cs @@ -12,7 +12,7 @@ public interface IInMemoryEvaluatorWrite /// The entity to add. /// The optional list of entities to add to. If null, uses a default context. /// True if the entity was added successfully; otherwise, false. - public bool Add(T entity, List? entities = null); + public bool Add(T entity, List? entities = null, string? tag = null); /// /// Updates a single entity in the specified collection or a default context if no collection is provided. @@ -20,7 +20,7 @@ public interface IInMemoryEvaluatorWrite /// The entity to update. The entity must exist in the collection for the update to succeed. /// The optional list of entities to update within. If null, uses a default context. /// The updated entity if successful, or null if the entity was not found or the update failed. - public T? Update(T entity, List? entities = null); + public T? Update(T entity, List? entities = null, string? tag = null); /// /// Deletes a single entity from the specified collection or a default context if no collection is provided. @@ -28,14 +28,14 @@ public interface IInMemoryEvaluatorWrite /// The entity to delete. The entity must exist in the collection for the deletion to succeed. /// The optional list of entities to delete from. If null, uses a default context. /// True if the entity was deleted successfully; otherwise, false. - public bool Delete(T entity, List? entities = null); + public bool Delete(T entity, List? entities = null, string? tag = null); /// /// Adds a collection of entities to the specified collection or a default context if no collection is provided. /// /// The collection of entities to add. /// The optional list of entities to add to. If null, uses a default context. - public void AddRange(IEnumerable entitiesToAdd, List? entities = null); + public void AddRange(IEnumerable entitiesToAdd, List? entities = null, string? tag = null); /// /// Updates a collection of entities in the specified collection or a default context if no collection is provided. @@ -43,7 +43,7 @@ public interface IInMemoryEvaluatorWrite /// The collection of entities to update. Entities must exist in the collection for updates to succeed. /// The optional list of entities to update within. If null, uses a default context. /// An enumerable of the updated entities. - public IEnumerable UpdateRange(IEnumerable entitiesToUpdate, List? entities = null); + public IEnumerable UpdateRange(IEnumerable entitiesToUpdate, List? entities = null, string? tag = null); /// /// Deletes a collection of entities from the specified collection or a default context if no collection is provided. @@ -54,14 +54,14 @@ public interface IInMemoryEvaluatorWrite /// The number of entities queued for deletion. /// Changes are applied to the store when is called. /// - public int DeleteRange(IEnumerable entitiesToDelete, List? entities = null); + public int DeleteRange(IEnumerable entitiesToDelete, List? entities = null, string? tag = null); /// /// Saves any pending changes to the specified collection or a default context if no collection is provided. /// This method ensures that all previous write operations are applied. /// /// The optional list of entities to save changes for. If null, uses a default context. - public void SaveChanges(List? entities = null); + public void SaveChanges(List? entities = null, string? tag = null); /// /// Inserts the entity if its key is not found in the store; otherwise updates it. @@ -69,7 +69,7 @@ public interface IInMemoryEvaluatorWrite /// The entity to upsert. /// Optional external list. If null, operates on the internal store. /// The upserted entity. - T Upsert(T entity, List? entities = null); + T Upsert(T entity, List? entities = null, string? tag = null); /// /// Upserts each entity in the collection — inserts if the key is new, updates if found. @@ -77,7 +77,7 @@ public interface IInMemoryEvaluatorWrite /// The entities to upsert. /// Optional external list. If null, operates on the internal store. /// The collection of upserted entities. - IEnumerable UpsertRange(IEnumerable entitiesToUpsert, List? entities = null); + IEnumerable UpsertRange(IEnumerable entitiesToUpsert, List? entities = null, string? tag = null); /// /// Deletes all entities that satisfy the given predicate. @@ -85,5 +85,5 @@ public interface IInMemoryEvaluatorWrite /// The condition to match entities for deletion. /// Optional external list. If null, operates on the internal store. /// The number of entities deleted. - int DeleteByCondition(Func predicate, List? entities = null); + int DeleteByCondition(Func predicate, List? entities = null, string? tag = null); } \ No newline at end of file From 9d38d2b3f8f7630c53773692db2b5ffdb3e74bc2 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:33:23 -0500 Subject: [PATCH 04/30] fix(sql): prevent SQL injection via identifier guard, fix builder bugs, add SetAllFrom and diagnostics - Add SqlIdentifierGuard (regex whitelist) to validate table/schema/type names before interpolation - Fix OrIgnore/OrReplace dialect guard bug, Set()-after-SelectFrom() silent data loss, Where() double-call overwrite - Add AllowDeleteUnmatched() guard before WhenNotMatchedBySourceDelete in SqlMergeBuilder - Add SetAllFrom(entity, exclude...) reflection-based bulk column mapper to Insert/Update builders - Wrap all 6 builders' Build() with ValiFlowDiagnostics Activity tracing using the existing .Tag(...) value --- .../Builder/SqlDeleteBuilderTests.cs | 95 +++++++++ .../Builder/SqlInsertBuilderTests.cs | 185 ++++++++++++++++++ .../Builder/SqlMergeBuilderTests.cs | 114 ++++++++++- .../Builder/SqlTruncateBuilderTests.cs | 55 ++++++ .../Builder/SqlUpdateBuilderTests.cs | 173 ++++++++++++++++ .../SqlIdentifierGuardTests.cs | 71 +++++++ Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs | 82 ++++++++ .../ValiFlowSqlExtensionsTests.cs | 14 ++ Vali-Flow.Sql/Builder/SqlDeleteBuilder.cs | 35 +++- Vali-Flow.Sql/Builder/SqlIdentifierGuard.cs | 43 ++++ Vali-Flow.Sql/Builder/SqlInsertBuilder.cs | 82 +++++++- Vali-Flow.Sql/Builder/SqlMergeBuilder.cs | 48 ++++- Vali-Flow.Sql/Builder/SqlQueryBuilder.cs | 35 +++- Vali-Flow.Sql/Builder/SqlTruncateBuilder.cs | 38 ++-- Vali-Flow.Sql/Builder/SqlUpdateBuilder.cs | 84 ++++++-- .../Extensions/ValiFlowSqlExtensions.cs | 8 +- 16 files changed, 1109 insertions(+), 53 deletions(-) create mode 100644 Vali-Flow.Sql.Tests/SqlIdentifierGuardTests.cs create mode 100644 Vali-Flow.Sql/Builder/SqlIdentifierGuard.cs diff --git a/Vali-Flow.Sql.Tests/Builder/SqlDeleteBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlDeleteBuilderTests.cs index f21c151..c3ca3d2 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlDeleteBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlDeleteBuilderTests.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using FluentAssertions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Core.Builder; using Vali_Flow.Sql.Builder; using Vali_Flow.Sql.Dialects; @@ -27,6 +29,14 @@ public void NoWhere_GeneratesDeleteWithoutWhere() result.Parameters.Should().BeEmpty(); } + [Fact] + public void From_UnsafeTableName_ThrowsArgumentException() + { + var act = () => new SqlDeleteBuilder(Sql).From("Users]; DROP TABLE Users;--"); + + act.Should().Throw().WithParameterName("tableName"); + } + // ── WHERE variants ──────────────────────────────────────────────────────── [Fact] @@ -92,6 +102,44 @@ public void Where_BothPredicateAndBuilder_AreAnded() result.Sql.Should().Contain("AND"); } + [Fact] + public void Where_LambdaExpression_CalledTwice_ThrowsInvalidOperationException() + { + // Previously this silently overwrote the first predicate with the second one + // with no error — inconsistent with the SqlWhereBuilder overload (Where(SqlWhereBuilder)), + // which already guards against being set twice. + var builder = new SqlDeleteBuilder(Sql) + .From("Users") + .Where(x => x.Age < 18); + + builder.Invoking(b => b.Where(x => x.Age > 65)) + .Should().Throw(); + } + + [Fact] + public void Where_ValiFlow_CalledTwice_ThrowsInvalidOperationException() + { + var filter1 = new ValiFlow().EqualTo(x => x.Department, "Sales"); + var filter2 = new ValiFlow().EqualTo(x => x.Department, "IT"); + var builder = new SqlDeleteBuilder(Sql) + .From("Users") + .Where(filter1); + + builder.Invoking(b => b.Where(filter2)) + .Should().Throw(); + } + + [Fact] + public void Where_SqlWhereBuilder_CalledTwice_ThrowsInvalidOperationException() + { + var builder = new SqlDeleteBuilder(Sql) + .From("Users") + .Where(w => w.EqualTo(x => x.Id, 1)); + + builder.Invoking(b => b.Where(w => w.EqualTo(x => x.Id, 2))) + .Should().Throw(); + } + // ── Table + schema ──────────────────────────────────────────────────────── [Fact] @@ -230,4 +278,51 @@ public void Constructor_NullDialect_ThrowsArgumentNullException() var act = () => new SqlDeleteBuilder(null!); act.Should().Throw(); } + + // ── Observability ───────────────────────────────────────────────────────── + + [Fact] + public void Build_WithTag_RecordsActivityWithTagAndEntityType() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + new SqlDeleteBuilder(Sql) + .From("Users") + .AllowDeleteAll() + .Tag("Delete all users") + .Build(); + + seen.Should().NotBeNull(); + seen!.GetTagItem("vali_flow.tag").Should().Be("Delete all users"); + seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); + seen.GetTagItem("vali_flow.has_where").Should().Be(false); + seen.Status.Should().Be(ActivityStatusCode.Unset); + } + + [Fact] + public void Build_WithoutWhereOrAllowDeleteAll_RecordsErrorStatus() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + // No Where() and no AllowDeleteAll() triggers the existing safety guard in Build(). + var act = () => new SqlDeleteBuilder(Sql).From("Users").Build(); + + act.Should().Throw(); + seen.Should().NotBeNull(); + seen!.Status.Should().Be(ActivityStatusCode.Error); + } } diff --git a/Vali-Flow.Sql.Tests/Builder/SqlInsertBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlInsertBuilderTests.cs index ba51c2a..f605477 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlInsertBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlInsertBuilderTests.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using FluentAssertions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Sql.Builder; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Tests.Models; @@ -26,6 +28,22 @@ public void SingleSet_GeneratesInsertWithOneColumn() result.Parameters["pi0"].Should().Be("Alice"); } + [Fact] + public void Into_UnsafeTableName_ThrowsArgumentException() + { + var act = () => new SqlInsertBuilder(Sql).Into("Users]; DROP TABLE Users;--"); + + act.Should().Throw().WithParameterName("tableName"); + } + + [Fact] + public void Into_UnsafeSchema_ThrowsArgumentException() + { + var act = () => new SqlInsertBuilder(Sql).Into("Users", "dbo]; DROP TABLE Users;--"); + + act.Should().Throw().WithParameterName("schema"); + } + [Fact] public void MultipleSet_GeneratesCorrectColumnAndParamOrder() { @@ -379,6 +397,37 @@ public void InsertIgnore_SqlServer_ThrowsInvalidOperationException() .WithMessage("*SqlServer*"); } + [Fact] + public void OrIgnore_MySql_ThrowsInvalidOperationException() + { + // MySQL does not support the SQLite-only "INSERT OR IGNORE INTO" syntax. + // MySqlDialect.InsertConflictPrefix is "IGNORE" (used for its own InsertIgnore() keyword), + // which previously made the OrIgnore() guard pass incorrectly and emit invalid SQL + // ("INSERT OR IGNORE INTO ..." instead of "INSERT IGNORE INTO ..."). + var builder = new SqlInsertBuilder(My) + .Into("Users") + .Set(x => x.Name, "Alice"); + + builder.Invoking(b => b.OrIgnore()) + .Should().Throw() + .WithMessage("*MySQL*"); + } + + [Fact] + public void OrReplace_PostgreSql_ThrowsInvalidOperationException() + { + // PostgreSQL does not support the SQLite-only "INSERT OR REPLACE INTO" syntax either. + // PostgreSqlDialect.SupportsOnConflict is true (for ON CONFLICT DO UPDATE/NOTHING), + // which previously made the OrReplace() guard pass incorrectly. + var builder = new SqlInsertBuilder(Pg) + .Into("Users") + .Set(x => x.Name, "Alice"); + + builder.Invoking(b => b.OrReplace()) + .Should().Throw() + .WithMessage("*PostgreSQL*"); + } + // ── INSERT … SELECT ─────────────────────────────────────────────────────── [Fact] @@ -484,6 +533,25 @@ public void SelectFrom_AndSet_ThrowsInvalidOperationException() .Should().Throw(); } + [Fact] + public void Set_AfterSelectFrom_ThrowsInvalidOperationException() + { + // Reverse of SelectFrom_AndSet_ThrowsInvalidOperationException: calling Set() after + // SelectFrom() was previously NOT guarded. Build() always checks _selectQuery first and + // routes into BuildInsertSelect(), silently discarding whatever was passed to Set() with + // no error raised to the caller — an asymmetric, order-dependent validation gap. + var select = new SqlQueryBuilder(Sql) + .From("Archive") + .Build(); + + var builder = new SqlInsertBuilder(Sql) + .Into("Users") + .SelectFrom(select); + + builder.Invoking(b => b.Set(x => x.Name, "Alice")) + .Should().Throw(); + } + [Fact] public void SelectFrom_NullQuery_ThrowsArgumentNullException() { @@ -516,4 +584,121 @@ public void SelectFrom_MySql_UsesBackticks() result.Sql.Should().StartWith("INSERT INTO `Users`"); result.Sql.Should().Contain("(`Name`)"); } + + // ── SetAllFrom ──────────────────────────────────────────────────────────── + + [Fact] + public void SetAllFrom_MapsAllPublicProperties() + { + var entity = new TestUser + { + Id = 1, + Name = "Alice", + Email = "alice@test.com", + Age = 30, + IsActive = true, + CreatedAt = new DateTime(2024, 1, 1), + Department = "Eng", + Salary = 1000m, + Status = 1, + Counter = 0, + NameBackup = "AliceBackup", + UpdatedAt = new DateTime(2024, 1, 2) + }; + + var result = new SqlInsertBuilder(Sql) + .Into("Users") + .SetAllFrom(entity) + .Build(); + + result.Sql.Should().Contain("[Id]").And.Contain("[Name]").And.Contain("[Email]"); + result.Parameters.Values.Should().Contain("Alice"); + result.Parameters.Values.Should().Contain("alice@test.com"); + result.Parameters.Values.Should().Contain(30); + result.Parameters.Values.Should().Contain(true); + } + + [Fact] + public void SetAllFrom_WithExclusions_SkipsExcludedColumns() + { + var entity = new TestUser { Id = 1, Name = "Bob", Email = "bob@test.com" }; + + var result = new SqlInsertBuilder(Sql) + .Into("Users") + .SetAllFrom(entity, x => x.Id, x => x.CreatedAt, x => x.UpdatedAt) + .Build(); + + result.Sql.Should().NotContain("[Id]"); + result.Sql.Should().NotContain("[CreatedAt]"); + result.Sql.Should().NotContain("[UpdatedAt]"); + result.Sql.Should().Contain("[Name]"); + } + + [Fact] + public void SetAllFrom_NullPropertyValue_StoresDbNull() + { + var entity = new TestUser { Id = 1, Name = "Carl", Email = null! }; + + var result = new SqlInsertBuilder(Sql) + .Into("Users") + .SetAllFrom(entity) + .Build(); + + result.Parameters.Values.Should().Contain(DBNull.Value); + } + + [Fact] + public void SetAllFrom_NullEntity_ThrowsArgumentNullException() + { + var builder = new SqlInsertBuilder(Sql).Into("Users"); + + builder.Invoking(b => b.SetAllFrom(null!)) + .Should().Throw().WithParameterName("entity"); + } + + // ── Observability ───────────────────────────────────────────────────────── + + [Fact] + public void Build_WithTag_RecordsActivityWithTagAndEntityType() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + new SqlInsertBuilder(Sql) + .Into("Users") + .Set(x => x.Name, "Alice") + .Tag("Insert new user") + .Build(); + + seen.Should().NotBeNull(); + seen!.GetTagItem("vali_flow.tag").Should().Be("Insert new user"); + seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); + seen.Status.Should().Be(ActivityStatusCode.Unset); + } + + [Fact] + public void Build_WhenValidationThrows_RecordsErrorStatus() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + // No Set() calls at all triggers the existing "at least one column" guard in Build(). + var act = () => new SqlInsertBuilder(Sql).Into("Users").Build(); + + act.Should().Throw(); + seen.Should().NotBeNull(); + seen!.Status.Should().Be(ActivityStatusCode.Error); + } } diff --git a/Vali-Flow.Sql.Tests/Builder/SqlMergeBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlMergeBuilderTests.cs index 45248ec..5220ccf 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlMergeBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlMergeBuilderTests.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using FluentAssertions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Sql.Builder; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Tests.Models; @@ -32,6 +34,24 @@ public void Build_BasicMerge_GeneratesMergeStatement() result.Sql.Should().EndWith(";"); } + [Fact] + public void Into_UnsafeTableName_ThrowsArgumentException() + { + var act = () => new SqlMergeBuilder(Sql).Into("Users]; DROP TABLE Users;--"); + + act.Should().Throw().WithParameterName("tableName"); + } + + [Fact] + public void Using_UnsafeSourceTable_ThrowsArgumentException() + { + var act = () => new SqlMergeBuilder(Sql) + .Into("Users") + .Using("StagingUsers]; DROP TABLE StagingUsers;--"); + + act.Should().Throw().WithParameterName("sourceTable"); + } + // ── WHEN MATCHED UPDATE ─────────────────────────────────────────────────── [Fact] @@ -82,6 +102,37 @@ public void Build_WhenNotMatchedBySourceDelete_GeneratesDeleteClause() .Using("StagingUsers") .On(t => t.Id, s => s.Id) .WhenNotMatchedBySourceDelete() + .AllowDeleteUnmatched() + .Build(); + + result.Sql.Should().Contain("WHEN NOT MATCHED BY SOURCE THEN DELETE"); + } + + // ── AllowDeleteUnmatched guard ───────────────────────────────────────────── + + [Fact] + public void Build_WhenNotMatchedBySourceDeleteWithoutGuard_ThrowsInvalidOperationException() + { + var builder = new SqlMergeBuilder(Sql) + .Into("Users") + .Using("StagingUsers") + .On(t => t.Id, s => s.Id) + .WhenNotMatchedBySourceDelete(); + + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("*AllowDeleteUnmatched()*"); + } + + [Fact] + public void Build_WhenNotMatchedBySourceDeleteWithGuard_GeneratesDeleteClause() + { + var result = new SqlMergeBuilder(Sql) + .Into("Users") + .Using("StagingUsers") + .On(t => t.Id, s => s.Id) + .WhenNotMatchedBySourceDelete() + .AllowDeleteUnmatched() .Build(); result.Sql.Should().Contain("WHEN NOT MATCHED BY SOURCE THEN DELETE"); @@ -131,6 +182,7 @@ public void Build_WithSchema_GeneratesSchemaQualifiedTarget() .Using("StagingUsers") .On(t => t.Id, s => s.Id) .WhenNotMatchedBySourceDelete() + .AllowDeleteUnmatched() .Build(); result.Sql.Should().Contain("MERGE INTO [dbo].[Users] AS target"); @@ -147,6 +199,7 @@ public void Build_WithoutInto_ThrowsInvalidOperationException() .Using("StagingUsers") .On(t => t.Id, s => s.Id) .WhenNotMatchedBySourceDelete() + .AllowDeleteUnmatched() .Build(); act.Should().Throw() @@ -161,7 +214,8 @@ public void Build_NonSqlServerDialect_ThrowsInvalidOperationException() var builder = new SqlMergeBuilder(new PostgreSqlDialect()) .Using("StagingUsers") .On(t => t.Id, s => s.Id) - .WhenNotMatchedBySourceDelete(); + .WhenNotMatchedBySourceDelete() + .AllowDeleteUnmatched(); builder.Invoking(b => b.Build()) .Should().Throw() @@ -176,7 +230,8 @@ public void Build_NoOnCondition_ThrowsInvalidOperationException() var builder = new SqlMergeBuilder(Sql) .Into("Users") .Using("StagingUsers") - .WhenNotMatchedBySourceDelete(); + .WhenNotMatchedBySourceDelete() + .AllowDeleteUnmatched(); builder.Invoking(b => b.Build()) .Should().Throw() @@ -208,6 +263,7 @@ public void Tag_PrependsSqlComment() .Using("StagingUsers") .On(t => t.Id, s => s.Id) .WhenNotMatchedBySourceDelete() + .AllowDeleteUnmatched() .Tag("Sync staging to production") .Build(); @@ -246,4 +302,58 @@ public void Build_WithOracleDialect_DoesNotUseAsAlias() result.Sql.Should().Contain("target"); result.Sql.Should().Contain("MERGE INTO"); } + + // ── Observability ───────────────────────────────────────────────────────── + + [Fact] + public void Build_WithTag_RecordsActivityWithTagAndTargetEntityType() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + new SqlMergeBuilder(Sql) + .Into("Users") + .Using("StagingUsers") + .On(t => t.Id, s => s.Id) + .WhenMatchedUpdate(b => b.MatchedSetColumn(t => t.Name, s => s.Name)) + .Tag("Sync users") + .Build(); + + seen.Should().NotBeNull(); + seen!.GetTagItem("vali_flow.tag").Should().Be("Sync users"); + seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); + seen.GetTagItem("vali_flow.has_delete_unmatched").Should().Be(false); + seen.Status.Should().Be(ActivityStatusCode.Unset); + } + + [Fact] + public void Build_DeleteUnmatchedWithoutAllow_RecordsErrorStatus() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + // WhenNotMatchedBySourceDelete() without AllowDeleteUnmatched() triggers the existing safety guard. + var act = () => new SqlMergeBuilder(Sql) + .Into("Users") + .Using("StagingUsers") + .On(t => t.Id, s => s.Id) + .WhenNotMatchedBySourceDelete() + .Build(); + + act.Should().Throw(); + seen.Should().NotBeNull(); + seen!.Status.Should().Be(ActivityStatusCode.Error); + } } diff --git a/Vali-Flow.Sql.Tests/Builder/SqlTruncateBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlTruncateBuilderTests.cs index 435ee6d..89dd6f4 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlTruncateBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlTruncateBuilderTests.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using FluentAssertions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Sql.Builder; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Tests.Models; @@ -25,6 +27,14 @@ public void Table_SqlServer_GeneratesTruncate() result.Sql.Should().Be("TRUNCATE TABLE [Users]"); } + [Fact] + public void Table_UnsafeTableName_ThrowsArgumentException() + { + var act = () => new SqlTruncateBuilder(Sql).Table("Users]; DROP TABLE Users;--"); + + act.Should().Throw().WithParameterName("tableName"); + } + [Fact] public void Table_WithSchema_GeneratesSchemaQualified() { @@ -119,4 +129,49 @@ public void Constructor_NullDialect_ThrowsArgumentNullException() var act = () => new SqlTruncateBuilder(null!); act.Should().Throw(); } + + // ── Observability ───────────────────────────────────────────────────────── + + [Fact] + public void Build_WithTag_RecordsActivityWithTagAndEntityType() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + new SqlTruncateBuilder(Sql) + .Table("Users") + .Tag("Truncate users") + .Build(); + + seen.Should().NotBeNull(); + seen!.GetTagItem("vali_flow.tag").Should().Be("Truncate users"); + seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); + seen.Status.Should().Be(ActivityStatusCode.Unset); + } + + [Fact] + public void Build_UnsupportedDialect_RecordsErrorStatus() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + // SQLite does not support TRUNCATE TABLE, triggering the existing guard in Build(). + var act = () => new SqlTruncateBuilder(Sqlite).Table("Users").Build(); + + act.Should().Throw(); + seen.Should().NotBeNull(); + seen!.Status.Should().Be(ActivityStatusCode.Error); + } } diff --git a/Vali-Flow.Sql.Tests/Builder/SqlUpdateBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlUpdateBuilderTests.cs index 38b2fc0..6e43c50 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlUpdateBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlUpdateBuilderTests.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using FluentAssertions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Core.Builder; using Vali_Flow.Sql.Builder; using Vali_Flow.Sql.Dialects; @@ -28,6 +30,24 @@ public void SingleSet_NoWhere_GeneratesUpdateWithoutWhere() result.Parameters["pu0"].Should().Be("Alice"); } + [Fact] + public void Table_UnsafeTableName_ThrowsArgumentException() + { + var act = () => new SqlUpdateBuilder(Sql).Table("Users]; DROP TABLE Users;--"); + + act.Should().Throw().WithParameterName("tableName"); + } + + [Fact] + public void FromTable_UnsafeSourceTable_ThrowsArgumentException() + { + var builder = new SqlUpdateBuilder(Sql).Table("Users"); + + var act = () => builder.FromTable("Orders]; DROP TABLE Orders;--"); + + act.Should().Throw().WithParameterName("sourceTable"); + } + [Fact] public void MultipleSet_GeneratesCorrectSetList() { @@ -110,6 +130,47 @@ public void Where_ValiFlow_AppendsWhereClause() result.Sql.Should().Contain("[IsActive]"); } + [Fact] + public void Where_SqlWhereBuilder_CalledTwice_ThrowsInvalidOperationException() + { + var builder = new SqlUpdateBuilder(Sql) + .Table("Users") + .Set(x => x.Name, "A") + .Where(w => w.EqualTo(x => x.Id, 1)); + + builder.Invoking(b => b.Where(w => w.EqualTo(x => x.Id, 2))) + .Should().Throw(); + } + + [Fact] + public void Where_LambdaExpression_CalledTwice_ThrowsInvalidOperationException() + { + // Previously this silently overwrote the first predicate with the second one + // with no error — inconsistent with the SqlWhereBuilder overload above, which + // already guards against being set twice. + var builder = new SqlUpdateBuilder(Sql) + .Table("Users") + .Set(x => x.Name, "A") + .Where(x => x.Age > 18); + + builder.Invoking(b => b.Where(x => x.Age < 65)) + .Should().Throw(); + } + + [Fact] + public void Where_ValiFlow_CalledTwice_ThrowsInvalidOperationException() + { + var filter1 = new ValiFlow().EqualTo(x => x.IsActive, true); + var filter2 = new ValiFlow().EqualTo(x => x.IsActive, false); + var builder = new SqlUpdateBuilder(Sql) + .Table("Users") + .Set(x => x.Name, "A") + .Where(filter1); + + builder.Invoking(b => b.Where(filter2)) + .Should().Throw(); + } + // ── Table + schema ──────────────────────────────────────────────────────── [Fact] @@ -442,4 +503,116 @@ public void JoinOn_EmptyJoinType_ThrowsArgumentException() builder.Invoking(b => b.JoinOn("", "condition")) .Should().Throw(); } + + // ── SetAllFrom ──────────────────────────────────────────────────────────── + + [Fact] + public void SetAllFrom_MapsAllPropertiesExceptPrimaryKey() + { + var entity = new TestUser + { + Id = 42, + Name = "Alice", + Email = "alice@test.com", + Age = 30, + IsActive = true + }; + + var result = new SqlUpdateBuilder(Sql) + .Table("Users") + .SetAllFrom(entity, x => x.Id) + .AllowUpdateAll() + .Build(); + + result.Sql.Should().NotContain("[Id] ="); + result.Sql.Should().Contain("[Name] ="); + result.Parameters.Values.Should().Contain("Alice"); + result.Parameters.Values.Should().Contain(30); + } + + [Fact] + public void SetAllFrom_WithWhere_CombinesWithPkFilter() + { + var entity = new TestUser { Id = 7, Name = "Bob" }; + + var result = new SqlUpdateBuilder(Sql) + .Table("Users") + .SetAllFrom(entity, x => x.Id) + .Where(w => w.EqualTo(x => x.Id, entity.Id)) + .Build(); + + result.Sql.Should().Contain("WHERE [Id] = @pw0"); + result.Parameters["pw0"].Should().Be(7); + } + + [Fact] + public void SetAllFrom_NullPropertyValue_StoresDbNull() + { + var entity = new TestUser { Id = 1, Name = "Carl", Email = null! }; + + var result = new SqlUpdateBuilder(Sql) + .Table("Users") + .SetAllFrom(entity, x => x.Id) + .AllowUpdateAll() + .Build(); + + result.Parameters.Values.Should().Contain(DBNull.Value); + } + + [Fact] + public void SetAllFrom_NullEntity_ThrowsArgumentNullException() + { + var builder = new SqlUpdateBuilder(Sql).Table("Users"); + + builder.Invoking(b => b.SetAllFrom(null!)) + .Should().Throw().WithParameterName("entity"); + } + + // ── Observability ───────────────────────────────────────────────────────── + + [Fact] + public void Build_WithTag_RecordsActivityWithTagAndEntityType() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + new SqlUpdateBuilder(Sql) + .Table("Users") + .Set(x => x.Name, "Bob") + .AllowUpdateAll() + .Tag("Update user name") + .Build(); + + seen.Should().NotBeNull(); + seen!.GetTagItem("vali_flow.tag").Should().Be("Update user name"); + seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); + seen.GetTagItem("vali_flow.has_where").Should().Be(false); + seen.Status.Should().Be(ActivityStatusCode.Unset); + } + + [Fact] + public void Build_WithoutWhereOrAllowUpdateAll_RecordsErrorStatus() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + // No Where() and no AllowUpdateAll() triggers the existing safety guard in Build(). + var act = () => new SqlUpdateBuilder(Sql).Table("Users").Set(x => x.Name, "Bob").Build(); + + act.Should().Throw(); + seen.Should().NotBeNull(); + seen!.Status.Should().Be(ActivityStatusCode.Error); + } } diff --git a/Vali-Flow.Sql.Tests/SqlIdentifierGuardTests.cs b/Vali-Flow.Sql.Tests/SqlIdentifierGuardTests.cs new file mode 100644 index 0000000..d405e15 --- /dev/null +++ b/Vali-Flow.Sql.Tests/SqlIdentifierGuardTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using Vali_Flow.Sql.Builder; +using Xunit; + +namespace Vali_Flow.Sql.Tests; + +/// +/// Tests for — the SQL injection guard for table/schema/type names. +/// +public sealed class SqlIdentifierGuardTests +{ + [Theory] + [InlineData("Users")] + [InlineData("_users")] + [InlineData("Users_2024")] + [InlineData("dbo")] + public void EnsureValidIdentifier_ValidName_ReturnsSameValue(string name) + { + var result = SqlIdentifierGuard.EnsureValidIdentifier(name, "tableName"); + + result.Should().Be(name); + } + + [Theory] + [InlineData("Users]; DROP TABLE Users;--")] + [InlineData("Users\"; DROP TABLE Users;--")] + [InlineData("Users`; DROP TABLE Users;--")] + [InlineData("Users WHERE 1=1")] + [InlineData("Users; SELECT 1")] + [InlineData("1Users")] + [InlineData("Us ers")] + public void EnsureValidIdentifier_UnsafeName_ThrowsArgumentException(string name) + { + var act = () => SqlIdentifierGuard.EnsureValidIdentifier(name, "tableName"); + + act.Should().Throw().WithParameterName("tableName"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EnsureValidIdentifier_EmptyName_ThrowsArgumentException(string? name) + { + var act = () => SqlIdentifierGuard.EnsureValidIdentifier(name!, "tableName"); + + act.Should().Throw().WithParameterName("tableName"); + } + + [Theory] + [InlineData("INT")] + [InlineData("VARCHAR(50)")] + [InlineData("DECIMAL(18,2)")] + [InlineData("NVARCHAR(MAX)")] + public void EnsureValidTypeName_ValidType_ReturnsSameValue(string typeName) + { + var result = SqlIdentifierGuard.EnsureValidTypeName(typeName, "typeName"); + + result.Should().Be(typeName); + } + + [Theory] + [InlineData("INT); DROP TABLE Users;--")] + [InlineData("VARCHAR(50); SELECT 1")] + public void EnsureValidTypeName_UnsafeType_ThrowsArgumentException(string typeName) + { + var act = () => SqlIdentifierGuard.EnsureValidTypeName(typeName, "typeName"); + + act.Should().Throw().WithParameterName("typeName"); + } +} diff --git a/Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs b/Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs index ab1cd14..9a322e8 100644 --- a/Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs @@ -1,5 +1,7 @@ +using System.Diagnostics; using System.Linq.Expressions; using FluentAssertions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Sql.Builder; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Tests.Models; @@ -25,6 +27,24 @@ public void Build_SelectStar_WhenNoColumnsSpecified() result.Parameters.Should().BeEmpty(); } + [Theory] + [InlineData("Users]; DROP TABLE Users;--")] + [InlineData("Users WHERE 1=1")] + public void From_UnsafeTableName_ThrowsArgumentException(string tableName) + { + var act = () => Sql().From(tableName); + + act.Should().Throw().WithParameterName("tableName"); + } + + [Fact] + public void From_UnsafeSchema_ThrowsArgumentException() + { + var act = () => Sql().From("Users", "dbo]; DROP TABLE Users;--"); + + act.Should().Throw().WithParameterName("schema"); + } + [Fact] public void Build_SelectSpecificColumns() { @@ -1413,6 +1433,14 @@ public void SelectCast_GeneratesCastInSelect() result.Sql.Should().Contain("CAST([Age] AS FLOAT) AS [AgeFloat]"); } + [Fact] + public void SelectCast_UnsafeTypeName_ThrowsArgumentException() + { + var act = () => Sql().From("Users").SelectCast(x => x.Age, "INT); DROP TABLE Users;--", "AgeInt"); + + act.Should().Throw().WithParameterName("typeName"); + } + [Fact] public void SelectConcat_SqlServer_UsesPlus() { @@ -1787,4 +1815,58 @@ public void JoinSubquery_EmptyAlias_ThrowsArgumentException() builder.Invoking(b => b.InnerJoinSubquery(subquery, " ", "ON 1=1")) .Should().Throw(); } + + // ── Observability ───────────────────────────────────────────────────────── + + private static ActivityListener AttachListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public void Build_WithTag_RecordsActivityWithTagAndEntityType() + { + using var listener = AttachListener(); + Activity? seen = null; + using var localListener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(localListener); + + Sql().From("Users").Tag("Get active users").Build(); + + seen.Should().NotBeNull(); + seen!.GetTagItem("vali_flow.tag").Should().Be("Get active users"); + seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); + seen.Status.Should().Be(ActivityStatusCode.Unset); + } + + [Fact] + public void Build_WhenValidationThrows_RecordsErrorStatus() + { + Activity? seen = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => seen = activity + }; + ActivitySource.AddActivityListener(listener); + + // Skip without Take/OrderBy triggers the existing pagination validation guard in Build(). + var act = () => Sql().From("Users").Skip(10).Build(); + + act.Should().Throw(); + seen.Should().NotBeNull(); + seen!.Status.Should().Be(ActivityStatusCode.Error); + } } diff --git a/Vali-Flow.Sql.Tests/ValiFlowSqlExtensionsTests.cs b/Vali-Flow.Sql.Tests/ValiFlowSqlExtensionsTests.cs index 86e0d58..557afa9 100644 --- a/Vali-Flow.Sql.Tests/ValiFlowSqlExtensionsTests.cs +++ b/Vali-Flow.Sql.Tests/ValiFlowSqlExtensionsTests.cs @@ -94,6 +94,13 @@ public void ToSqlCount_NullDialect_ThrowsArgumentNull() act.Should().Throw().WithParameterName("dialect"); } + [Fact] + public void ToSqlCount_UnsafeTableName_ThrowsArgumentException() + { + var act = () => ((ValiFlow?)null).ToSqlCount(SqlServer, "Users]; DROP TABLE Users;--"); + act.Should().Throw().WithParameterName("tableName"); + } + // ── ToSql (ValiFlowQuery overload) ───────────────────────────────────── [Fact] @@ -166,6 +173,13 @@ public void ToSqlCount_ValiFlowQuery_NullDialect_ThrowsArgumentNull() act.Should().Throw().WithParameterName("dialect"); } + [Fact] + public void ToSqlCount_ValiFlowQuery_UnsafeTableName_ThrowsArgumentException() + { + var act = () => ((ValiFlowQuery?)null).ToSqlCount(SqlServer, "Users]; DROP TABLE Users;--"); + act.Should().Throw().WithParameterName("tableName"); + } + [Fact] public void ToSqlCount_ValiFlowQuery_PostgreSQL_UsesDoubleQuotes() { diff --git a/Vali-Flow.Sql/Builder/SqlDeleteBuilder.cs b/Vali-Flow.Sql/Builder/SqlDeleteBuilder.cs index 1ed4f1a..2232cec 100644 --- a/Vali-Flow.Sql/Builder/SqlDeleteBuilder.cs +++ b/Vali-Flow.Sql/Builder/SqlDeleteBuilder.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using System.Linq.Expressions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Core.Builder; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Models; @@ -44,10 +46,8 @@ public SqlDeleteBuilder(ISqlDialect dialect) /// Sets the target table name and optional schema. public SqlDeleteBuilder From(string tableName, string? schema = null) { - if (string.IsNullOrWhiteSpace(tableName)) - throw new ArgumentException("Table name cannot be null or empty.", nameof(tableName)); - _tableName = tableName; - _schema = schema; + _tableName = SqlIdentifierGuard.EnsureValidIdentifier(tableName, nameof(tableName)); + _schema = schema == null ? null : SqlIdentifierGuard.EnsureValidIdentifier(schema, nameof(schema)); return this; } @@ -78,7 +78,11 @@ public SqlDeleteBuilder Where(Action> configure) /// both conditions are combined with AND in the final SQL. public SqlDeleteBuilder Where(Expression> predicate) { - _wherePredicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + if (_wherePredicate != null) + throw new InvalidOperationException( + "Where predicate already set. Combine conditions within a single expression (e.g. with &&)."); + _wherePredicate = predicate; return this; } @@ -86,8 +90,7 @@ public SqlDeleteBuilder Where(Expression> predicate) public SqlDeleteBuilder Where(ValiFlow filter) { if (filter == null) throw new ArgumentNullException(nameof(filter)); - _wherePredicate = filter.Build(); - return this; + return Where(filter.Build()); } // ── OUTPUT / RETURNING ──────────────────────────────────────────────────── @@ -141,6 +144,24 @@ public SqlDeleteBuilder Tag(string description) /// Assembles and returns the final . public SqlQueryResult Build() + { + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.Sql.SqlDeleteBuilder.Build", tag: _tag, entityType: typeof(T).Name); + try + { + SqlQueryResult result = BuildCore(); + bool hasWhere = _wherePredicate != null || _whereBuilder != null; + activity?.SetTag("vali_flow.has_where", hasWhere); + return result; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + private SqlQueryResult BuildCore() { bool hasWhere = _wherePredicate != null || _whereBuilder != null; if (!hasWhere && !_allowDeleteAll) diff --git a/Vali-Flow.Sql/Builder/SqlIdentifierGuard.cs b/Vali-Flow.Sql/Builder/SqlIdentifierGuard.cs new file mode 100644 index 0000000..1f5ca94 --- /dev/null +++ b/Vali-Flow.Sql/Builder/SqlIdentifierGuard.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; + +namespace Vali_Flow.Sql.Builder; + +/// +/// Validates raw SQL identifiers (table/schema names) and type names before they are interpolated +/// into generated SQL. Table/schema/type names cannot be parameterized like regular values +/// (there is no @param equivalent for an identifier), so this is the only line of defense +/// against SQL injection when a consumer builds these strings from untrusted input. +/// +internal static class SqlIdentifierGuard +{ + private static readonly Regex IdentifierPattern = new("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.Compiled); + + // Allows simple type names (INT, VARCHAR) and parameterized ones (DECIMAL(18,2), NVARCHAR(MAX)). + private static readonly Regex TypeNamePattern = + new(@"^[A-Za-z_][A-Za-z0-9_]*(\([A-Za-z0-9_,\s]+\))?$", RegexOptions.Compiled); + + /// Validates a table or schema name. Throws if it contains anything beyond letters, digits and underscores. + public static string EnsureValidIdentifier(string name, string paramName) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Identifier cannot be empty.", paramName); + if (!IdentifierPattern.IsMatch(name)) + throw new ArgumentException( + $"'{name}' is not a valid SQL identifier. Only letters, digits and underscores are allowed " + + "(table/schema names cannot be parameterized, so unsafe characters are rejected to prevent SQL injection).", + paramName); + return name; + } + + /// Validates a SQL type name used in a CAST expression (e.g. "VARCHAR(50)", "DECIMAL(18,2)"). + public static string EnsureValidTypeName(string typeName, string paramName) + { + if (string.IsNullOrWhiteSpace(typeName)) + throw new ArgumentException("Type name cannot be empty.", paramName); + if (!TypeNamePattern.IsMatch(typeName)) + throw new ArgumentException( + $"'{typeName}' is not a valid SQL type name (expected something like 'INT' or 'VARCHAR(50)').", + paramName); + return typeName; + } +} diff --git a/Vali-Flow.Sql/Builder/SqlInsertBuilder.cs b/Vali-Flow.Sql/Builder/SqlInsertBuilder.cs index 2860252..867ec89 100644 --- a/Vali-Flow.Sql/Builder/SqlInsertBuilder.cs +++ b/Vali-Flow.Sql/Builder/SqlInsertBuilder.cs @@ -1,5 +1,8 @@ +using System.Diagnostics; using System.Linq.Expressions; +using System.Reflection; using System.Text.RegularExpressions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Models; @@ -74,10 +77,8 @@ public SqlInsertBuilder(ISqlDialect dialect) /// Sets the target table name and optional schema. public SqlInsertBuilder Into(string tableName, string? schema = null) { - if (string.IsNullOrWhiteSpace(tableName)) - throw new ArgumentException("Table name cannot be null or empty.", nameof(tableName)); - _tableName = tableName; - _schema = schema; + _tableName = SqlIdentifierGuard.EnsureValidIdentifier(tableName, nameof(tableName)); + _schema = schema == null ? null : SqlIdentifierGuard.EnsureValidIdentifier(schema, nameof(schema)); return this; } @@ -90,10 +91,55 @@ public SqlInsertBuilder Into(string tableName, string? schema = null) public SqlInsertBuilder Set(Expression> column, TValue value) { if (column == null) throw new ArgumentNullException(nameof(column)); + if (_selectQuery != null) + throw new InvalidOperationException( + "Cannot combine Set() with SelectFrom(). Use one or the other."); CurrentRow.Add((ExpressionHelper.GetMemberName(column), value)); return this; } + /// + /// Maps every public readable instance property of to a + /// call for the current row, reading each value from + /// via reflection. Properties listed in + /// (typically the primary key) are skipped. + /// + /// + /// Limitations: only public instance properties are considered; indexers + /// (this[int]) and non-public properties are always skipped. Collection/complex + /// navigation properties are mapped like any other property (their raw value is passed to + /// as-is) — this method does not attempt to flatten or serialize them. + /// + public SqlInsertBuilder SetAllFrom(T entity, params Expression>[] exclude) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var excluded = exclude != null + ? new HashSet(exclude.Select(ExpressionHelper.GetMemberName)) + : new HashSet(); + + foreach (var prop in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.GetIndexParameters().Length > 0) continue; + if (!prop.CanRead) continue; + if (excluded.Contains(prop.Name)) continue; + + var value = prop.GetValue(entity); + var parameter = Expression.Parameter(typeof(T), "x"); + var lambda = Expression.Lambda( + typeof(Func<,>).MakeGenericType(typeof(T), prop.PropertyType), + Expression.Property(parameter, prop), + parameter); + + var setMethod = typeof(SqlInsertBuilder) + .GetMethod(nameof(Set))! + .MakeGenericMethod(prop.PropertyType); + setMethod.Invoke(this, new object?[] { lambda, value }); + } + + return this; + } + // ── Multi-row ───────────────────────────────────────────────────────────── /// @@ -169,7 +215,12 @@ public SqlInsertBuilder Tag(string description) /// public SqlInsertBuilder OrIgnore() { - if (!_dialect.SupportsOnConflict && string.IsNullOrEmpty(_dialect.InsertConflictPrefix)) + // "INSERT OR IGNORE INTO" is SQLite-only syntax. MySqlDialect.InsertConflictPrefix is + // "IGNORE" (used for its own InsertIgnore() → "INSERT IGNORE INTO" keyword, unrelated + // to this method) and PostgreSqlDialect.SupportsOnConflict is true (for ON CONFLICT + // DO NOTHING/UPDATE, unrelated to this method too). Checking either of those alone + // previously let MySQL and PostgreSQL through here and produced invalid SQL. + if (_dialect.DialectName != "SQLite") throw new InvalidOperationException( $"OrIgnore() is not supported by dialect '{_dialect.DialectName}'."); _orIgnore = true; @@ -182,7 +233,8 @@ public SqlInsertBuilder OrIgnore() /// public SqlInsertBuilder OrReplace() { - if (!_dialect.SupportsOnConflict && string.IsNullOrEmpty(_dialect.InsertConflictPrefix)) + // Same reasoning as OrIgnore(): "INSERT OR REPLACE INTO" is SQLite-only syntax. + if (_dialect.DialectName != "SQLite") throw new InvalidOperationException( $"OrReplace() is not supported by dialect '{_dialect.DialectName}'."); _orReplace = true; @@ -347,6 +399,24 @@ public SqlInsertBuilder Columns(params Expression>[] columns) /// Assembles and returns the final . public SqlQueryResult Build() + { + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.Sql.SqlInsertBuilder.Build", tag: _tag, entityType: typeof(T).Name); + try + { + SqlQueryResult result = BuildCore(); + activity?.SetTag("vali_flow.row_count", _rows.Count); + activity?.SetTag("vali_flow.is_insert_select", _selectQuery != null); + return result; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + private SqlQueryResult BuildCore() { _paramIndex = 0; diff --git a/Vali-Flow.Sql/Builder/SqlMergeBuilder.cs b/Vali-Flow.Sql/Builder/SqlMergeBuilder.cs index 7fcd4e7..783ce8e 100644 --- a/Vali-Flow.Sql/Builder/SqlMergeBuilder.cs +++ b/Vali-Flow.Sql/Builder/SqlMergeBuilder.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using System.Linq.Expressions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Models; @@ -19,6 +21,7 @@ public sealed class SqlMergeBuilder private readonly List<(string TargetCol, string SourceExpr)> _matchedUpdateClauses = new(); private readonly List<(string TargetCol, string SourceExpr)> _notMatchedInsertClauses = new(); private bool _whenNotMatchedBySourceDelete; + private bool _allowDeleteUnmatched; private string? _tag; private readonly Dictionary _parameters = new(); private int _paramIndex; @@ -32,16 +35,16 @@ public SqlMergeBuilder(ISqlDialect dialect) /// Sets the target table for the MERGE. public SqlMergeBuilder Into(string tableName, string? schema = null) { - _targetTable = tableName ?? throw new ArgumentNullException(nameof(tableName)); - _targetSchema = schema; + _targetTable = SqlIdentifierGuard.EnsureValidIdentifier(tableName, nameof(tableName)); + _targetSchema = schema == null ? null : SqlIdentifierGuard.EnsureValidIdentifier(schema, nameof(schema)); return this; } /// Sets the source table name and alias used in the USING clause. public SqlMergeBuilder Using(string sourceTable, string alias = "src") { - _sourceTable = sourceTable ?? throw new ArgumentNullException(nameof(sourceTable)); - _sourceAlias = alias; + _sourceTable = SqlIdentifierGuard.EnsureValidIdentifier(sourceTable, nameof(sourceTable)); + _sourceAlias = SqlIdentifierGuard.EnsureValidIdentifier(alias, nameof(alias)); return this; } @@ -151,6 +154,20 @@ public SqlMergeBuilder WhenNotMatchedBySourceDelete() return this; } + // ── Safety guard ────────────────────────────────────────────────────────── + + /// + /// Explicitly allows WHEN NOT MATCHED BY SOURCE THEN DELETE to be generated. + /// Without this, Build() throws — that clause deletes every target row that has no + /// matching source row, which can wipe an entire table if the source is empty, + /// filtered, or mismatched. Call this only when deleting unmatched rows is intentional. + /// + public SqlMergeBuilder AllowDeleteUnmatched() + { + _allowDeleteUnmatched = true; + return this; + } + // ── Tag ──────────────────────────────────────────────────────────────────── /// Adds a SQL comment header and console tag for traceability. @@ -166,6 +183,23 @@ public SqlMergeBuilder Tag(string description) /// Builds and returns the parameterized MERGE statement. public SqlQueryResult Build() + { + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.Sql.SqlMergeBuilder.Build", tag: _tag, entityType: typeof(TTarget).Name); + try + { + SqlQueryResult result = BuildCore(); + activity?.SetTag("vali_flow.has_delete_unmatched", _whenNotMatchedBySourceDelete); + return result; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + private SqlQueryResult BuildCore() { if (!_dialect.SupportsMerge) throw new InvalidOperationException( @@ -185,6 +219,12 @@ public SqlQueryResult Build() throw new InvalidOperationException( "At least one of WhenMatchedUpdate(), WhenNotMatchedInsert(), or WhenNotMatchedBySourceDelete() must be called."); + if (_whenNotMatchedBySourceDelete && !_allowDeleteUnmatched) + throw new InvalidOperationException( + "WhenNotMatchedBySourceDelete() deletes every target row with no matching source row. " + + "This can wipe the entire table if the source is empty, filtered, or mismatched. " + + "Call AllowDeleteUnmatched() to explicitly confirm this is intentional."); + var parameters = new Dictionary(_parameters); var sb = new System.Text.StringBuilder(); diff --git a/Vali-Flow.Sql/Builder/SqlQueryBuilder.cs b/Vali-Flow.Sql/Builder/SqlQueryBuilder.cs index d902dd7..370541b 100644 --- a/Vali-Flow.Sql/Builder/SqlQueryBuilder.cs +++ b/Vali-Flow.Sql/Builder/SqlQueryBuilder.cs @@ -1,7 +1,9 @@ using System.Collections.Concurrent; +using System.Diagnostics; using System.Linq.Expressions; using System.Text; using System.Text.RegularExpressions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Core.Builder; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Models; @@ -200,8 +202,7 @@ public SqlQueryBuilder SelectCast( Expression> column, string typeName, string alias) { if (column == null) throw new ArgumentNullException(nameof(column)); - if (string.IsNullOrWhiteSpace(typeName)) - throw new ArgumentException("typeName cannot be empty.", nameof(typeName)); + typeName = SqlIdentifierGuard.EnsureValidTypeName(typeName, nameof(typeName)); if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("alias cannot be empty.", nameof(alias)); var col = _dialect.QuoteIdentifier(ExpressionHelper.GetMemberName(column)); @@ -296,10 +297,8 @@ public SqlQueryBuilder WithRecursive(string name, SqlQueryResult anchor, SqlQ /// public SqlQueryBuilder From(string tableName, string? schema = null) { - if (string.IsNullOrWhiteSpace(tableName)) - throw new ArgumentException("Table name cannot be null or empty.", nameof(tableName)); - _tableName = tableName; - _schema = schema; + _tableName = SqlIdentifierGuard.EnsureValidIdentifier(tableName, nameof(tableName)); + _schema = schema == null ? null : SqlIdentifierGuard.EnsureValidIdentifier(schema, nameof(schema)); return this; } @@ -974,7 +973,12 @@ public SqlQueryBuilder Tag(string description, Action? logger) // ── Preview ─────────────────────────────────────────────────────────────── - /// Returns a preview of the SQL mid-chain (useful in debugger watch window). + /// + /// Returns a preview of the SQL mid-chain (useful in debugger watch window). + /// Intentionally swallows any exception — the builder state may be incomplete mid-chain + /// (e.g. no table set yet), and this method must never throw since it is meant to be + /// evaluated by the debugger on every step, not called from production code paths. + /// public string ToPreviewSql() { try { return BuildInternal().Sql; } @@ -989,6 +993,23 @@ public string ToPreviewSql() // ── Internal ────────────────────────────────────────────────────────────── private SqlQueryResult BuildInternal() + { + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.Sql.SqlQueryBuilder.Build", tag: _tag, entityType: typeof(T).Name); + try + { + SqlQueryResult result = AssembleQuery(); + activity?.SetTag("vali_flow.has_where", _wherePredicate != null || _whereBuilder != null || _whereRaw.Count > 0); + return result; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + private SqlQueryResult AssembleQuery() { if (_tag != null) { diff --git a/Vali-Flow.Sql/Builder/SqlTruncateBuilder.cs b/Vali-Flow.Sql/Builder/SqlTruncateBuilder.cs index 7f0192a..d554df0 100644 --- a/Vali-Flow.Sql/Builder/SqlTruncateBuilder.cs +++ b/Vali-Flow.Sql/Builder/SqlTruncateBuilder.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Models; @@ -26,10 +28,8 @@ public SqlTruncateBuilder(ISqlDialect dialect) /// Optional schema qualifier (e.g. "dbo"). public SqlTruncateBuilder Table(string tableName, string? schema = null) { - if (string.IsNullOrWhiteSpace(tableName)) - throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName)); - _tableName = tableName; - _schema = schema; + _tableName = SqlIdentifierGuard.EnsureValidIdentifier(tableName, nameof(tableName)); + _schema = schema == null ? null : SqlIdentifierGuard.EnsureValidIdentifier(schema, nameof(schema)); return this; } @@ -47,18 +47,28 @@ public SqlTruncateBuilder Tag(string description) /// A with the SQL string and an empty parameters dictionary. public SqlQueryResult Build() { - if (!_dialect.SupportsTruncate) - throw new InvalidOperationException( - $"Dialect '{_dialect.DialectName}' does not support TRUNCATE TABLE. Use DELETE FROM instead."); + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.Sql.SqlTruncateBuilder.Build", tag: _tag, entityType: typeof(T).Name); + try + { + if (!_dialect.SupportsTruncate) + throw new InvalidOperationException( + $"Dialect '{_dialect.DialectName}' does not support TRUNCATE TABLE. Use DELETE FROM instead."); - string quotedTable = _dialect.QuoteTable(_tableName ?? typeof(T).Name); - string tableSql = !string.IsNullOrEmpty(_schema) - ? $"{_dialect.QuoteTable(_schema)}.{quotedTable}" - : quotedTable; + string quotedTable = _dialect.QuoteTable(_tableName ?? typeof(T).Name); + string tableSql = !string.IsNullOrEmpty(_schema) + ? $"{_dialect.QuoteTable(_schema)}.{quotedTable}" + : quotedTable; - string sql = $"TRUNCATE TABLE {tableSql}"; - string finalSql = _tag != null ? $"-- {_tag}\n{sql}" : sql; + string sql = $"TRUNCATE TABLE {tableSql}"; + string finalSql = _tag != null ? $"-- {_tag}\n{sql}" : sql; - return new SqlQueryResult(finalSql, new Dictionary(), _dialect.ParameterPrefix); + return new SqlQueryResult(finalSql, new Dictionary(), _dialect.ParameterPrefix); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } } diff --git a/Vali-Flow.Sql/Builder/SqlUpdateBuilder.cs b/Vali-Flow.Sql/Builder/SqlUpdateBuilder.cs index 26871c4..19748f1 100644 --- a/Vali-Flow.Sql/Builder/SqlUpdateBuilder.cs +++ b/Vali-Flow.Sql/Builder/SqlUpdateBuilder.cs @@ -1,4 +1,7 @@ +using System.Diagnostics; using System.Linq.Expressions; +using System.Reflection; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Core.Builder; using Vali_Flow.Sql.Dialects; using Vali_Flow.Sql.Models; @@ -54,10 +57,8 @@ public SqlUpdateBuilder(ISqlDialect dialect) /// Sets the target table name and optional schema. public SqlUpdateBuilder Table(string tableName, string? schema = null) { - if (string.IsNullOrWhiteSpace(tableName)) - throw new ArgumentException("Table name cannot be null or empty.", nameof(tableName)); - _tableName = tableName; - _schema = schema; + _tableName = SqlIdentifierGuard.EnsureValidIdentifier(tableName, nameof(tableName)); + _schema = schema == null ? null : SqlIdentifierGuard.EnsureValidIdentifier(schema, nameof(schema)); return this; } @@ -74,6 +75,48 @@ public SqlUpdateBuilder Set(Expression> column, TValu return this; } + /// + /// Maps every public readable instance property of to a + /// call, reading each value from via + /// reflection. Properties listed in (typically the primary key + /// used in the WHERE clause) are skipped. + /// + /// + /// Limitations: only public instance properties are considered; indexers + /// (this[int]) and non-public properties are always skipped. Collection/complex + /// navigation properties are mapped like any other property (their raw value is passed to + /// as-is) — this method does not attempt to flatten or serialize them. + /// + public SqlUpdateBuilder SetAllFrom(T entity, params Expression>[] exclude) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var excluded = exclude != null + ? new HashSet(exclude.Select(ExpressionHelper.GetMemberName)) + : new HashSet(); + + foreach (var prop in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.GetIndexParameters().Length > 0) continue; + if (!prop.CanRead) continue; + if (excluded.Contains(prop.Name)) continue; + + var value = prop.GetValue(entity); + var parameter = Expression.Parameter(typeof(T), "x"); + var lambda = Expression.Lambda( + typeof(Func<,>).MakeGenericType(typeof(T), prop.PropertyType), + Expression.Property(parameter, prop), + parameter); + + var setMethod = typeof(SqlUpdateBuilder) + .GetMethod(nameof(Set))! + .MakeGenericMethod(prop.PropertyType); + setMethod.Invoke(this, new object?[] { lambda, value }); + } + + return this; + } + /// /// Adds a raw (non-parameterized) SET clause. /// .SetRaw(x => x.Counter, "Counter + 1") → [Counter] = Counter + 1 @@ -132,7 +175,11 @@ public SqlUpdateBuilder Where(Action> configure) /// both conditions are combined with AND in the final SQL. public SqlUpdateBuilder Where(Expression> predicate) { - _wherePredicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + if (_wherePredicate != null) + throw new InvalidOperationException( + "Where predicate already set. Combine conditions within a single expression (e.g. with &&)."); + _wherePredicate = predicate; return this; } @@ -140,8 +187,7 @@ public SqlUpdateBuilder Where(Expression> predicate) public SqlUpdateBuilder Where(ValiFlow filter) { if (filter == null) throw new ArgumentNullException(nameof(filter)); - _wherePredicate = filter.Build(); - return this; + return Where(filter.Build()); } // ── UPDATE FROM / JOIN ──────────────────────────────────────────────────── @@ -161,10 +207,8 @@ public SqlUpdateBuilder FromTable(string sourceTable, string? alias = null) if (!_dialect.SupportsUpdateFrom) throw new InvalidOperationException( $"Dialect '{_dialect.DialectName}' does not support UPDATE\u2026FROM/JOIN syntax."); - if (string.IsNullOrWhiteSpace(sourceTable)) - throw new ArgumentException("Source table name cannot be null or empty.", nameof(sourceTable)); - _fromTable = sourceTable; - _fromAlias = alias; + _fromTable = SqlIdentifierGuard.EnsureValidIdentifier(sourceTable, nameof(sourceTable)); + _fromAlias = alias == null ? null : SqlIdentifierGuard.EnsureValidIdentifier(alias, nameof(alias)); return this; } @@ -235,6 +279,24 @@ public SqlUpdateBuilder Tag(string description) /// Assembles and returns the final . public SqlQueryResult Build() + { + using var activity = ValiFlowDiagnostics.StartActivity( + "Vali-Flow.Sql.SqlUpdateBuilder.Build", tag: _tag, entityType: typeof(T).Name); + try + { + SqlQueryResult result = BuildCore(); + bool hasWhere = _wherePredicate != null || _whereBuilder != null; + activity?.SetTag("vali_flow.has_where", hasWhere); + return result; + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + private SqlQueryResult BuildCore() { _paramIndex = 0; diff --git a/Vali-Flow.Sql/Extensions/ValiFlowSqlExtensions.cs b/Vali-Flow.Sql/Extensions/ValiFlowSqlExtensions.cs index ebb1a44..24eaf59 100644 --- a/Vali-Flow.Sql/Extensions/ValiFlowSqlExtensions.cs +++ b/Vali-Flow.Sql/Extensions/ValiFlowSqlExtensions.cs @@ -77,7 +77,9 @@ public static SqlQueryResult ToSqlCount( { if (dialect == null) throw new ArgumentNullException(nameof(dialect)); - string table = dialect.QuoteTable(tableName ?? typeof(T).Name); + string table = dialect.QuoteTable(tableName == null + ? typeof(T).Name + : SqlIdentifierGuard.EnsureValidIdentifier(tableName, nameof(tableName))); var parameters = new Dictionary(); string whereClause = string.Empty; @@ -138,7 +140,9 @@ public static SqlQueryResult ToSqlCount( { if (dialect == null) throw new ArgumentNullException(nameof(dialect)); - string table = dialect.QuoteTable(tableName ?? typeof(T).Name); + string table = dialect.QuoteTable(tableName == null + ? typeof(T).Name + : SqlIdentifierGuard.EnsureValidIdentifier(tableName, nameof(tableName))); var parameters = new Dictionary(); string whereClause = string.Empty; From 4187c9fa8e42cbd30c1d63856d722e7cd2800fdb Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:33:39 -0500 Subject: [PATCH 05/30] fix(nosql): translate closure-captured null values to NullNode like literal null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x.Field == nullVariable (closure) threw ArgumentNullException unlike x.Field == null (literal) — TranslateComparison now builds a NullNode when the evaluated value is null, same as the literal path. --- .../ExpressionToIRVisitorTests.cs | 26 ++++++ .../MongoFilterTranslatorTests.cs | 89 +++++++++++++++++++ .../Translators/ExpressionToIRVisitor.cs | 9 ++ 3 files changed, 124 insertions(+) diff --git a/Vali-Flow.NoSql.Tests/ExpressionToIRVisitorTests.cs b/Vali-Flow.NoSql.Tests/ExpressionToIRVisitorTests.cs index 37cd94a..291f28d 100644 --- a/Vali-Flow.NoSql.Tests/ExpressionToIRVisitorTests.cs +++ b/Vali-Flow.NoSql.Tests/ExpressionToIRVisitorTests.cs @@ -89,6 +89,32 @@ public void ToNoSqlIR_NotEqualNull_ReturnsIsNotNullNode() node.Check.Should().Be(NullCheckOp.IsNotNull); } + [Fact] + public void ToNoSqlIR_EqualNullVariable_ReturnsIsNullNode() + { + string? email = null; + Expression> expr = x => x.Email == email; + + var ir = expr.ToNoSqlIR(); + + var node = ir.Should().BeOfType().Subject; + node.Field.Should().Be("Email"); + node.Check.Should().Be(NullCheckOp.IsNull); + } + + [Fact] + public void ToNoSqlIR_NotEqualNullVariable_ReturnsIsNotNullNode() + { + string? email = null; + Expression> expr = x => x.Email != email; + + var ir = expr.ToNoSqlIR(); + + var node = ir.Should().BeOfType().Subject; + node.Field.Should().Be("Email"); + node.Check.Should().Be(NullCheckOp.IsNotNull); + } + // ── Comparison ──────────────────────────────────────────────────────────── [Fact] diff --git a/Vali-Flow.NoSql.Tests/MongoFilterTranslatorTests.cs b/Vali-Flow.NoSql.Tests/MongoFilterTranslatorTests.cs index cf0bd36..2b27e33 100644 --- a/Vali-Flow.NoSql.Tests/MongoFilterTranslatorTests.cs +++ b/Vali-Flow.NoSql.Tests/MongoFilterTranslatorTests.cs @@ -1,5 +1,7 @@ +using System.Diagnostics; using System.Linq.Expressions; using MongoDB.Bson; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Core.Builder; using Vali_Flow.NoSql.IR; using Vali_Flow.NoSql.MongoDB.Extensions; @@ -10,6 +12,49 @@ namespace Vali_Flow.NoSql.Tests; public sealed class MongoFilterTranslatorTests { + private static ActivityListener AttachListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public void ToMongo_WithTag_SetsTagAndEntityTypeOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var flow = new ValiFlow().EqualTo(x => x.Name, "Alice"); + + flow.ToMongo(tag: "report-x"); + + captured.Should().NotBeNull(); + captured!.GetTagItem("vali_flow.tag").Should().Be("report-x"); + captured.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestDocument)); + } + + [Fact] + public void Translate_UnsupportedComparisonOp_SetsErrorStatusOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var node = new ComparisonNode("Age", 18, (ComparisonOp)999); + + Action act = () => MongoFilterTranslator.Translate(node); + + act.Should().Throw(); + captured.Should().NotBeNull(); + captured!.Status.Should().Be(ActivityStatusCode.Error); + } + // ── Equality ────────────────────────────────────────────────────────────── [Fact] @@ -253,6 +298,26 @@ public void Translate_NullNode_ThrowsArgumentNullException() act.Should().Throw(); } + [Fact] + public void ToMongo_NullFlow_ThrowsArgumentNullException() + { + ValiFlow? flow = null; + + Action act = () => flow!.ToMongo(); + + act.Should().Throw(); + } + + [Fact] + public void ToMongo_NullExpression_ThrowsArgumentNullException() + { + Expression>? expr = null; + + Action act = () => expr!.ToMongo(); + + act.Should().Throw(); + } + // ── OCP — CustomValueConverter ──────────────────────────────────────────── [Fact] @@ -387,6 +452,30 @@ public void ToMongo_InNodeWithNullValue_HandlesNullInBsonArray() arr[1].Should().Be(BsonNull.Value); } + // ── Límite práctico de $in ─────────────────────────────────────────────── + + [Fact] + public void ToMongo_ListWithMoreThan10000Items_ThrowsInvalidOperationException() + { + var ids = Enumerable.Range(1, 10_001).Cast().ToList(); + var node = new InNode("Id", ids); + + Action act = () => MongoFilterTranslator.Translate(node); + + act.Should().Throw().WithMessage("*10000*"); + } + + [Fact] + public void ToMongo_ListWithExactly10000Items_DoesNotThrow() + { + var ids = Enumerable.Range(1, 10_000).Cast().ToList(); + var node = new InNode("Id", ids); + + Action act = () => MongoFilterTranslator.Translate(node); + + act.Should().NotThrow(); + } + // ── Lógica anidada ──────────────────────────────────────────────────────── [Fact] diff --git a/Vali-Flow.NoSql/Translators/ExpressionToIRVisitor.cs b/Vali-Flow.NoSql/Translators/ExpressionToIRVisitor.cs index f3860eb..69e7a27 100644 --- a/Vali-Flow.NoSql/Translators/ExpressionToIRVisitor.cs +++ b/Vali-Flow.NoSql/Translators/ExpressionToIRVisitor.cs @@ -119,6 +119,15 @@ private static IConditionNode TranslateComparison(BinaryExpression node) string fieldName = GetMemberName(columnExpr); object? value = EvaluateExpression(valueExpr); + // A captured variable/constant that evaluates to null (e.g. `x.Email == someNullVar`) + // carries the same semantics as a literal `== null` — route it to NullNode instead of + // letting EqualNode's null guard throw an unrelated ArgumentNullException. + if (value is null && effectiveType is ExpressionType.Equal or ExpressionType.NotEqual) + { + return new NullNode(fieldName, + effectiveType == ExpressionType.Equal ? NullCheckOp.IsNull : NullCheckOp.IsNotNull); + } + return effectiveType switch { ExpressionType.Equal => new EqualNode(fieldName, value!, false), From 2ae8b47aa8aeb8c52796459a3515ac285a68e630 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:34:05 -0500 Subject: [PATCH 06/30] fix(nosql): escape RediSearch query injection, cap Mongo/Elasticsearch IN values, add diagnostics - Escape RediSearch query-syntax special characters in LIKE patterns before adding wildcards, fixing a query injection vulnerability (e.g. pattern containing ')' or '|') - Fix customConverter not applied in Redis VisitComparison/numeric VisitIn - Fix swallowed inner exception in Elasticsearch ToDouble - Add MaxInValues caps (Mongo 10_000, Elasticsearch 65_536 matching index.max_terms_count) - Wrap Translate(...) with ValiFlowDiagnostics Activity tracing (tag + entityType) --- .../ElasticsearchFilterTranslatorTests.cs | 82 +++++++++ .../ValiFlowElasticsearchExtensions.cs | 8 +- .../ElasticsearchFilterTranslator.cs | 26 ++- .../Extensions/ValiFlowMongoExtensions.cs | 9 +- .../Translators/MongoFilterTranslator.cs | 31 +++- .../RedisSearchFilterTranslatorTests.cs | 156 +++++++++++++++++ .../ValiFlowRedisSearchExtensions.cs | 8 +- .../RedisSearchFilterTranslator.cs | 158 ++++++++++++------ 8 files changed, 408 insertions(+), 70 deletions(-) diff --git a/Vali-Flow.NoSql.Elasticsearch.Tests/ElasticsearchFilterTranslatorTests.cs b/Vali-Flow.NoSql.Elasticsearch.Tests/ElasticsearchFilterTranslatorTests.cs index 0870021..300098f 100644 --- a/Vali-Flow.NoSql.Elasticsearch.Tests/ElasticsearchFilterTranslatorTests.cs +++ b/Vali-Flow.NoSql.Elasticsearch.Tests/ElasticsearchFilterTranslatorTests.cs @@ -1,6 +1,8 @@ +using System.Diagnostics; using System.Linq.Expressions; using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.QueryDsl; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Core.Builder; using Vali_Flow.NoSql.Elasticsearch.Extensions; using Vali_Flow.NoSql.Elasticsearch.Translators; @@ -11,6 +13,49 @@ namespace Vali_Flow.NoSql.Elasticsearch.Tests; public sealed class ElasticsearchFilterTranslatorTests { + private static ActivityListener AttachListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public void ToElasticsearch_WithTag_SetsTagAndEntityTypeOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var flow = new ValiFlow().EqualTo(x => x.Name, "Alice"); + + flow.ToElasticsearch(tag: "report-x"); + + captured.Should().NotBeNull(); + captured!.GetTagItem("vali_flow.tag").Should().Be("report-x"); + captured.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestDocument)); + } + + [Fact] + public void Translate_UnsupportedComparisonOp_SetsErrorStatusOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var node = new ComparisonNode("Age", 18, (ComparisonOp)999); + + Action act = () => ElasticsearchFilterTranslator.Translate(node); + + act.Should().Throw(); + captured.Should().NotBeNull(); + captured!.Status.Should().Be(ActivityStatusCode.Error); + } + // ── Equality ────────────────────────────────────────────────────────────── [Fact] @@ -313,6 +358,19 @@ public void ToElasticsearch_ValiFlowCombined_ProducesBoolMust() bool_!.Must.Should().HaveCount(2); } + // ── Error handling ─────────────────────────────────────────────────────── + + [Fact] + public void Translate_ComparisonWithNonConvertibleValue_PreservesInnerException() + { + var node = new ComparisonNode("Age", "not-a-number", ComparisonOp.GreaterThan); + + Action act = () => ElasticsearchFilterTranslator.Translate(node); + + act.Should().Throw() + .WithInnerException(); + } + // ── Null guards ─────────────────────────────────────────────────────────── [Fact] @@ -538,6 +596,30 @@ public void ToElasticsearch_TermsWithNullValue_IncludesNullFieldValue() terms.Term.Should().NotBeNull(); } + // ── Límite práctico de terms query (index.max_terms_count) ────────────────── + + [Fact] + public void ToElasticsearch_ListWithMoreThan65536Items_ThrowsInvalidOperationException() + { + var ids = Enumerable.Range(1, 65_537).Cast().ToList(); + var node = new InNode("Id", ids); + + Action act = () => ElasticsearchFilterTranslator.Translate(node); + + act.Should().Throw().WithMessage("*65536*"); + } + + [Fact] + public void ToElasticsearch_ListWithExactly65536Items_DoesNotThrow() + { + var ids = Enumerable.Range(1, 65_536).Cast().ToList(); + var node = new InNode("Id", ids); + + Action act = () => ElasticsearchFilterTranslator.Translate(node); + + act.Should().NotThrow(); + } + // ── Decimal range ───────────────────────────────────────────────────────── [Fact] diff --git a/Vali-Flow.NoSql.Elasticsearch/Extensions/ValiFlowElasticsearchExtensions.cs b/Vali-Flow.NoSql.Elasticsearch/Extensions/ValiFlowElasticsearchExtensions.cs index 7f18e23..fded69c 100644 --- a/Vali-Flow.NoSql.Elasticsearch/Extensions/ValiFlowElasticsearchExtensions.cs +++ b/Vali-Flow.NoSql.Elasticsearch/Extensions/ValiFlowElasticsearchExtensions.cs @@ -38,21 +38,21 @@ public static class ValiFlowElasticsearchExtensions /// var results = await client.SearchAsync<User>(s => s.Query(esFilter)); /// /// - public static Query ToElasticsearch(this ValiFlow flow, Func? customConverter = null) where T : class + public static Query ToElasticsearch(this ValiFlow flow, Func? customConverter = null, string? tag = null) where T : class { if (flow == null) throw new ArgumentNullException(nameof(flow)); - return ElasticsearchFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter); + return ElasticsearchFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter, tag, typeof(T).Name); } /// /// Translates a prebuilt into an Elasticsearch query. /// Use this overload when you already have a compiled expression. /// - public static Query ToElasticsearch(this Expression> expression, Func? customConverter = null) + public static Query ToElasticsearch(this Expression> expression, Func? customConverter = null, string? tag = null) { if (expression == null) throw new ArgumentNullException(nameof(expression)); - return ElasticsearchFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter); + return ElasticsearchFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter, tag, typeof(T).Name); } } diff --git a/Vali-Flow.NoSql.Elasticsearch/Translators/ElasticsearchFilterTranslator.cs b/Vali-Flow.NoSql.Elasticsearch/Translators/ElasticsearchFilterTranslator.cs index 5ee55e4..236d210 100644 --- a/Vali-Flow.NoSql.Elasticsearch/Translators/ElasticsearchFilterTranslator.cs +++ b/Vali-Flow.NoSql.Elasticsearch/Translators/ElasticsearchFilterTranslator.cs @@ -1,5 +1,6 @@ using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.QueryDsl; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.NoSql.IR; using Vali_Flow.NoSql.Translators; @@ -17,6 +18,10 @@ namespace Vali_Flow.NoSql.Elasticsearch.Translators; /// public static class ElasticsearchFilterTranslator { + // Elasticsearch's documented server-side limit on terms-query clause count is + // index.max_terms_count, default 65536. https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html + private const int MaxInValues = 65_536; + /// /// Translates the given into an Elasticsearch . /// @@ -35,11 +40,20 @@ public static class ElasticsearchFilterTranslator /// var results = await client.SearchAsync<User>(s => s.Query(esFilter)); /// /// - public static Query Translate(IConditionNode node, Func? customConverter = null) + public static Query Translate(IConditionNode node, Func? customConverter = null, string? tag = null, string? entityType = null) { if (node == null) throw new ArgumentNullException(nameof(node)); - return node.Accept(new ElasticsearchVisitor(customConverter)); + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.NoSql.Elasticsearch.Translate", tag, entityType); + try + { + return node.Accept(new ElasticsearchVisitor(customConverter)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } private sealed class ElasticsearchVisitor(Func? customConverter) : IConditionNodeVisitor @@ -120,6 +134,12 @@ public Query VisitIn(InNode node) MustNot = [Query.MatchAll(new MatchAllQuery())] }); + if (node.Values.Count > MaxInValues) + throw new InvalidOperationException( + $"Elasticsearch terms query supports at most {MaxInValues} values " + + $"(index.max_terms_count server default); received {node.Values.Count}. " + + "Split the query or batch the values."); + return Query.Terms(new TermsQuery { Field = node.Field!, @@ -154,7 +174,7 @@ private static string EscapeWildcard(string pattern) private static double ToDouble(object value) { try { return Convert.ToDouble(value); } - catch { throw new NotSupportedException($"Cannot convert '{value?.GetType().Name}' to double for a range query."); } + catch (Exception ex) { throw new NotSupportedException($"Cannot convert '{value?.GetType().Name}' to double for a range query.", ex); } } private FieldValue ToFieldValue(object? value) => diff --git a/Vali-Flow.NoSql.MongoDB/Extensions/ValiFlowMongoExtensions.cs b/Vali-Flow.NoSql.MongoDB/Extensions/ValiFlowMongoExtensions.cs index 6861654..8f130e1 100644 --- a/Vali-Flow.NoSql.MongoDB/Extensions/ValiFlowMongoExtensions.cs +++ b/Vali-Flow.NoSql.MongoDB/Extensions/ValiFlowMongoExtensions.cs @@ -24,6 +24,7 @@ public static class ValiFlowMongoExtensions /// /// The entity / document type. /// The ValiFlow builder containing the conditions. + /// Optional override to convert a value into a for types the default conversion doesn't handle. /// /// A filter ready to pass to Find, CountDocuments, etc. /// @@ -37,21 +38,21 @@ public static class ValiFlowMongoExtensions /// var users = await collection.Find(mongoFilter).ToListAsync(); /// /// - public static BsonDocument ToMongo(this ValiFlow flow, Func? customConverter = null) where T : class + public static BsonDocument ToMongo(this ValiFlow flow, Func? customConverter = null, string? tag = null) where T : class { if (flow == null) throw new ArgumentNullException(nameof(flow)); - return MongoFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter); + return MongoFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter, tag, typeof(T).Name); } /// /// Translates a prebuilt into a MongoDB filter. /// Use this overload when you already have a compiled expression. /// - public static BsonDocument ToMongo(this Expression> expression, Func? customConverter = null) + public static BsonDocument ToMongo(this Expression> expression, Func? customConverter = null, string? tag = null) { if (expression == null) throw new ArgumentNullException(nameof(expression)); - return MongoFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter); + return MongoFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter, tag, typeof(T).Name); } } diff --git a/Vali-Flow.NoSql.MongoDB/Translators/MongoFilterTranslator.cs b/Vali-Flow.NoSql.MongoDB/Translators/MongoFilterTranslator.cs index fb8c43b..25f0f8a 100644 --- a/Vali-Flow.NoSql.MongoDB/Translators/MongoFilterTranslator.cs +++ b/Vali-Flow.NoSql.MongoDB/Translators/MongoFilterTranslator.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using MongoDB.Bson; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.NoSql.IR; using Vali_Flow.NoSql.Translators; @@ -18,6 +19,11 @@ namespace Vali_Flow.NoSql.MongoDB.Translators; /// public static class MongoFilterTranslator { + // MongoDB has no documented hard limit on $in array size — the real constraint is the + // 16MB BSON document limit for the whole query. This is a conservative practical cap to + // prevent abuse/oversized queries, not an official MongoDB limit. + private const int MaxInValues = 10_000; + /// /// Translates the given into a MongoDB filter. /// @@ -37,11 +43,20 @@ public static class MongoFilterTranslator /// var users = await collection.Find(filter).ToListAsync(); /// /// - public static BsonDocument Translate(IConditionNode node, Func? customConverter = null) + public static BsonDocument Translate(IConditionNode node, Func? customConverter = null, string? tag = null, string? entityType = null) { if (node == null) throw new ArgumentNullException(nameof(node)); - return node.Accept(new MongoVisitor(customConverter)); + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.NoSql.MongoDB.Translate", tag, entityType); + try + { + return node.Accept(new MongoVisitor(customConverter)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } private sealed class MongoVisitor(Func? customConverter) : IConditionNodeVisitor @@ -85,10 +100,18 @@ public BsonDocument VisitLike(LikeNode node) => new BsonDocument(node.Field, new BsonDocument("$regex", new BsonRegularExpression(BuildRegexPattern(node.Pattern, node.Op), node.CaseSensitive ? "" : "i"))); - public BsonDocument VisitIn(InNode node) => - new BsonDocument(node.Field, + public BsonDocument VisitIn(InNode node) + { + if (node.Values.Count > MaxInValues) + throw new InvalidOperationException( + $"MongoDB $in filter supports at most {MaxInValues} values (practical limit, " + + $"bounded by the 16MB BSON document size); received {node.Values.Count}. " + + "Split the query or batch the values."); + + return new BsonDocument(node.Field, new BsonDocument("$in", new BsonArray(node.Values.Select(v => ToBsonValue(v))))); + } public BsonDocument VisitNull(NullNode node) => node.Check == NullCheckOp.IsNull diff --git a/Vali-Flow.NoSql.Redis.Tests/RedisSearchFilterTranslatorTests.cs b/Vali-Flow.NoSql.Redis.Tests/RedisSearchFilterTranslatorTests.cs index f7005cb..0b9746a 100644 --- a/Vali-Flow.NoSql.Redis.Tests/RedisSearchFilterTranslatorTests.cs +++ b/Vali-Flow.NoSql.Redis.Tests/RedisSearchFilterTranslatorTests.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using System.Linq.Expressions; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Core.Builder; using Vali_Flow.NoSql.IR; using Vali_Flow.NoSql.Redis.Extensions; @@ -9,6 +11,49 @@ namespace Vali_Flow.NoSql.Redis.Tests; public sealed class RedisSearchFilterTranslatorTests { + private static ActivityListener AttachListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public void ToRedisSearch_WithTag_SetsTagAndEntityTypeOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var flow = new ValiFlow().EqualTo(x => x.Name, "Alice"); + + flow.ToRedisSearch(tag: "report-x"); + + captured.Should().NotBeNull(); + captured!.GetTagItem("vali_flow.tag").Should().Be("report-x"); + captured.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestDocument)); + } + + [Fact] + public void Translate_NullNode_SetsErrorStatusOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var node = new NullNode("DeletedAt", NullCheckOp.IsNull); + + Action act = () => RedisSearchFilterTranslator.Translate(node); + + act.Should().Throw(); + captured.Should().NotBeNull(); + captured!.Status.Should().Be(ActivityStatusCode.Error); + } + // ── Equality ────────────────────────────────────────────────────────────── [Fact] @@ -180,6 +225,42 @@ public void ToRedisSearch_StringEndsWith_ProducesLeadingWildcard() q.Should().Be("@Name:*ce"); } + // ── Pattern match — query-syntax injection guard ──────────────────────────── + + [Fact] + public void ToRedisSearch_ContainsWithParenPipeAt_EscapesSpecialChars() + { + // A pattern containing RediSearch query-syntax characters must not be able to break + // out of this field's scope and inject another clause (e.g. "@other:{admin}"). + Expression> expr = x => x.Name.Contains(")|@other:{admin}"); + + string q = expr.ToRedisSearch(); + + q.Should().Be(@"@Name:*\)\|\@other\:\{admin\}*"); + } + + [Fact] + public void ToRedisSearch_StartsWithSpecialChars_EscapesBeforeTrailingWildcard() + { + Expression> expr = x => x.Name.StartsWith("a-b.c"); + + string q = expr.ToRedisSearch(); + + q.Should().Be(@"@Name:a\-b\.c*"); + } + + [Fact] + public void ToRedisSearch_ContainsLiteralAsterisk_EscapesUserAsteriskButKeepsOwnWildcards() + { + // The '*' the user searches for must be escaped so it stays literal, while the + // wildcard '*' markers this translator adds around it remain unescaped. + Expression> expr = x => x.Name.Contains("a*b"); + + string q = expr.ToRedisSearch(); + + q.Should().Be(@"@Name:*a\*b*"); + } + // ── Membership ──────────────────────────────────────────────────────────── [Fact] @@ -326,6 +407,57 @@ public void CustomValueConverter_WhenReturnsNull_FallsThroughToDefault() q.Should().Be(@"@Name:{""Alice""}"); } + [Fact] + public void CustomValueConverter_UsedForComparisonNode_AppliesToRangeBound() + { + // Regression: ComparisonNode (>, >=, <, <=) used to bypass customConverter entirely + // and go straight to the built-in numeric conversion. + Func converter = v => + v is decimal d ? (d * 100).ToString("0", System.Globalization.CultureInfo.InvariantCulture) : null; + + var node = new ComparisonNode("Price", 9.99m, ComparisonOp.GreaterThan); + string q = RedisSearchFilterTranslator.Translate(node, converter); + + q.Should().Be("@Price:[(999 +inf]"); + } + + [Fact] + public void CustomValueConverter_WhenReturnsNullForComparisonNode_FallsThroughToDefaultNumeric() + { + Func converter = _ => null; + + var node = new ComparisonNode("Age", 18, ComparisonOp.GreaterThanOrEqual); + string q = RedisSearchFilterTranslator.Translate(node, converter); + + q.Should().Be("@Age:[18 +inf]"); + } + + [Fact] + public void CustomValueConverter_UsedForInNode_AppliesToEachNumericValue() + { + // Regression: InNode's numeric branch used to bypass customConverter entirely + // and go straight to the built-in numeric conversion for every value. + Func converter = v => + v is decimal d ? (d * 100).ToString("0", System.Globalization.CultureInfo.InvariantCulture) : null; + + var node = new InNode("Price", new List { 1.5m, 2.5m }); + string q = RedisSearchFilterTranslator.Translate(node, converter); + + q.Should().Be("(@Price:[150 150]|@Price:[250 250])"); + } + + [Fact] + public void CustomValueConverter_UsedForInNode_AppliesToEachTagValue() + { + Func converter = v => + v is string s ? $"TAG_{s}" : null; + + var node = new InNode("Category", new List { "a", "b" }); + string q = RedisSearchFilterTranslator.Translate(node, converter); + + q.Should().Be(@"@Category:{""TAG_a""|""TAG_b""}"); + } + // ── Direct node construction ────────────────────────────────────────────── [Fact] @@ -392,6 +524,30 @@ public void ToRedisSearch_InNodeWithMixedNumericAndString_ThrowsInvalidOperation .WithMessage("*mixed*"); } + // ── Límite práctico de IN (OR'd clauses) ───────────────────────────────── + + [Fact] + public void ToRedisSearch_ListWithMoreThan1000Items_ThrowsInvalidOperationException() + { + var ids = Enumerable.Range(1, 1_001).Cast().ToList(); + var node = new InNode("Id", ids); + + Action act = () => RedisSearchFilterTranslator.Translate(node); + + act.Should().Throw().WithMessage("*1000*"); + } + + [Fact] + public void ToRedisSearch_ListWithExactly1000Items_DoesNotThrow() + { + var ids = Enumerable.Range(1, 1_000).Cast().ToList(); + var node = new InNode("Id", ids); + + Action act = () => RedisSearchFilterTranslator.Translate(node); + + act.Should().NotThrow(); + } + // ── Type Coverage adicional ─────────────────────────────────────────────── [Fact] diff --git a/Vali-Flow.NoSql.Redis/Extensions/ValiFlowRedisSearchExtensions.cs b/Vali-Flow.NoSql.Redis/Extensions/ValiFlowRedisSearchExtensions.cs index 4f09c10..e9d6018 100644 --- a/Vali-Flow.NoSql.Redis/Extensions/ValiFlowRedisSearchExtensions.cs +++ b/Vali-Flow.NoSql.Redis/Extensions/ValiFlowRedisSearchExtensions.cs @@ -35,21 +35,21 @@ public static class ValiFlowRedisSearchExtensions /// var results = db.FT().Search("idx:products", new Query(query)); /// /// - public static string ToRedisSearch(this ValiFlow flow, Func? customConverter = null) where T : class + public static string ToRedisSearch(this ValiFlow flow, Func? customConverter = null, string? tag = null) where T : class { if (flow == null) throw new ArgumentNullException(nameof(flow)); - return RedisSearchFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter); + return RedisSearchFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter, tag, typeof(T).Name); } /// /// Translates a prebuilt into a RediSearch query string. /// Use this overload when you already have a compiled expression. /// - public static string ToRedisSearch(this Expression> expression, Func? customConverter = null) + public static string ToRedisSearch(this Expression> expression, Func? customConverter = null, string? tag = null) { if (expression == null) throw new ArgumentNullException(nameof(expression)); - return RedisSearchFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter); + return RedisSearchFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter, tag, typeof(T).Name); } } diff --git a/Vali-Flow.NoSql.Redis/Translators/RedisSearchFilterTranslator.cs b/Vali-Flow.NoSql.Redis/Translators/RedisSearchFilterTranslator.cs index acdae69..919d01e 100644 --- a/Vali-Flow.NoSql.Redis/Translators/RedisSearchFilterTranslator.cs +++ b/Vali-Flow.NoSql.Redis/Translators/RedisSearchFilterTranslator.cs @@ -1,5 +1,7 @@ using System.Globalization; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.NoSql.IR; +using Vali_Flow.NoSql.Translators; namespace Vali_Flow.NoSql.Redis.Translators; @@ -21,13 +23,23 @@ namespace Vali_Flow.NoSql.Redis.Translators; /// public static class RedisSearchFilterTranslator { + // RediSearch has no documented explicit limit on OR'd terms in a query. This is a + // conservative practical cap: each value expands to a full OR clause in the query string, + // so an unbounded list risks hitting Redis's proto-max-bulk-len and produces unwieldy queries. + private const int MaxInValues = 1_000; + /// /// Translates the given into a RediSearch query string. /// /// The root condition node to translate. /// - /// Optional hook for converting custom CLR types to their Redis tag-value string. - /// Called before the built-in type switch. Return null to fall through to the default conversion. + /// Optional hook for converting custom CLR types to their Redis value string. Applied before the + /// built-in type switch for every value-producing node (Equal, Comparison/range, and IN — both its + /// numeric and tag branches), same evaluation order as the other NoSql providers + /// (): custom first, falls back to + /// the default conversion when it returns null. The string is embedded as a quoted tag value + /// for Equal/IN-tag queries, or unquoted for Comparison/IN-numeric range bounds — return the raw + /// value appropriate to the call site (e.g. a numeric string for a type used in range queries). /// Thread-safe: the converter is scoped to this call only. /// /// @@ -39,13 +51,22 @@ public static class RedisSearchFilterTranslator /// var results = db.FT().Search("idx:products", new Query(query)); /// /// - public static string Translate(IConditionNode node, Func? customConverter = null) + public static string Translate(IConditionNode node, Func? customConverter = null, string? tag = null, string? entityType = null) { if (node == null) throw new ArgumentNullException(nameof(node)); - Validate(node); + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.NoSql.Redis.Translate", tag, entityType); + try + { + Validate(node); - return node.Accept(new RedisVisitor(customConverter)); + return node.Accept(new RedisVisitor(customConverter)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } /// @@ -79,29 +100,50 @@ public string VisitNull(NullNode node) => public string VisitEqual(EqualNode node) => BuildEqualQuery(node.Field, node.Value, node.IsNegated); - public string VisitComparison(ComparisonNode node) => node.Op switch + public string VisitComparison(ComparisonNode node) { - // Exclusive bound uses leading '(' before the number: [(v +inf] - ComparisonOp.GreaterThan => $"@{node.Field}:[({ToNumericString(node.Value)} +inf]", - ComparisonOp.GreaterThanOrEqual => $"@{node.Field}:[{ToNumericString(node.Value)} +inf]", - ComparisonOp.LessThan => $"@{node.Field}:[-inf ({ToNumericString(node.Value)}]", - ComparisonOp.LessThanOrEqual => $"@{node.Field}:[-inf {ToNumericString(node.Value)}]", - _ => throw new NotSupportedException($"ComparisonOp.{node.Op} is not mapped.") - }; + // Custom converter runs first (same order as the other NoSql providers via + // ConditionValueResolver), falling back to the built-in numeric conversion. + var bound = ConditionValueResolver.Resolve(node.Value, customConverter, ToNumericString); - public string VisitLike(LikeNode node) => node.Op switch + return node.Op switch + { + // Exclusive bound uses leading '(' before the number: [(v +inf] + ComparisonOp.GreaterThan => $"@{node.Field}:[({bound} +inf]", + ComparisonOp.GreaterThanOrEqual => $"@{node.Field}:[{bound} +inf]", + ComparisonOp.LessThan => $"@{node.Field}:[-inf ({bound}]", + ComparisonOp.LessThanOrEqual => $"@{node.Field}:[-inf {bound}]", + _ => throw new NotSupportedException($"ComparisonOp.{node.Op} is not mapped.") + }; + } + + public string VisitLike(LikeNode node) { - LikeOp.Contains => $"@{node.Field}:*{node.Pattern}*", - LikeOp.StartsWith => $"@{node.Field}:{node.Pattern}*", - LikeOp.EndsWith => $"@{node.Field}:*{node.Pattern}", - _ => throw new NotSupportedException($"LikeOp.{node.Op} is not mapped.") - }; + // Escape RediSearch query-syntax special characters in the user-supplied pattern + // BEFORE adding our own wildcard '*' markers below — otherwise a pattern containing + // characters like ')', '|', '@' could break out of this field's scope and inject + // arbitrary RediSearch query syntax (e.g. "x) | @other:{admin}"). + string escaped = EscapeTextTerm(node.Pattern); + return node.Op switch + { + LikeOp.Contains => $"@{node.Field}:*{escaped}*", + LikeOp.StartsWith => $"@{node.Field}:{escaped}*", + LikeOp.EndsWith => $"@{node.Field}:*{escaped}", + _ => throw new NotSupportedException($"LikeOp.{node.Op} is not mapped.") + }; + } public string VisitIn(InNode node) { // Empty IN → always false (negate match-all) if (node.Values.Count == 0) return "(-*)"; + if (node.Values.Count > MaxInValues) + throw new InvalidOperationException( + $"RediSearch IN query supports at most {MaxInValues} values (practical limit — " + + $"each value expands to a full OR clause in the query string); received {node.Values.Count}. " + + "Split the query or batch the values."); + if (node.Values.Any(v => v == null)) throw new InvalidOperationException( "IN condition with null values is not supported in Redis Search. Remove null values before building the query."); @@ -119,7 +161,12 @@ public string VisitIn(InNode node) if (allNumeric) { // OR'd range queries: (@field:[v1 v1]|@field:[v2 v2]|...) - var parts = node.Values.Select(v => $"@{node.Field}:[{ToNumericString(v!)} {ToNumericString(v!)}]"); + // Custom converter first, same order as Equal/Comparison, falls back to numeric. + var parts = node.Values.Select(v => + { + var num = ConditionValueResolver.Resolve(v!, customConverter, ToNumericString); + return $"@{node.Field}:[{num} {num}]"; + }); return $"({string.Join("|", parts)})"; } @@ -132,17 +179,16 @@ public string VisitIn(InNode node) private string BuildEqualQuery(string field, object value, bool negated) { - // OCP: custom converter takes priority over built-in type detection - if (customConverter != null) + // Custom converter takes priority over built-in type detection (OCP), + // same evaluation order as every other NoSql provider via ConditionValueResolver: + // custom first, fall back to the default conversion only when it returns null. + var custom = customConverter?.Invoke(value); + if (custom != null) { - var custom = customConverter(value); - if (custom != null) - { - var customTag = $"\"{EscapeTagValue(custom)}\""; - return negated - ? $"-@{field}:{{{customTag}}}" - : $"@{field}:{{{customTag}}}"; - } + var customTag = $"\"{EscapeTagValue(custom)}\""; + return negated + ? $"-@{field}:{{{customTag}}}" + : $"@{field}:{{{customTag}}}"; } if (IsNumericOrBool(value)) @@ -154,37 +200,29 @@ private string BuildEqualQuery(string field, object value, bool negated) } // String / other → quoted tag query (DIALECT 2). - // Converter already returned null above — pass null to avoid double-invocation (O1 fix). - var tag = BuildTagValue(value, null); + // Custom converter already ran above and returned null — build the default tag directly. + var tag = $"\"{EscapeTagValue(DefaultTagString(value))}\""; return negated ? $"-@{field}:{{{tag}}}" : $"@{field}:{{{tag}}}"; } - private string BuildTagValue(object? value, Func? converter = null) - { - var conv = converter ?? customConverter; - if (conv != null) - { - var custom = conv(value); - if (custom != null) return $"\"{EscapeTagValue(custom)}\""; - } + private string BuildTagValue(object? value) => + $"\"{EscapeTagValue(ConditionValueResolver.Resolve(value, customConverter, DefaultTagString))}\""; - var str = value switch - { - null => string.Empty, - bool b => b ? "true" : "false", - string s => s, - Enum e => e.ToString(), - _ => value.ToString() ?? string.Empty - }; - return $"\"{EscapeTagValue(str)}\""; - } + private static string DefaultTagString(object? value) => value switch + { + null => string.Empty, + bool b => b ? "true" : "false", + string s => s, + Enum e => e.ToString()!, + _ => value.ToString() ?? string.Empty + }; private static bool IsNumericOrBool(object? value) => value is int or long or double or float or decimal or bool; - private static string ToNumericString(object value) => value switch + private static string ToNumericString(object? value) => value switch { bool b => b ? "1" : "0", int i => i.ToString(CultureInfo.InvariantCulture), @@ -198,6 +236,24 @@ private static bool IsNumericOrBool(object? value) => // Escape \ and " inside DIALECT 2 quoted tag values private static string EscapeTagValue(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + // RediSearch query-syntax special characters that must be backslash-escaped when they + // appear inside an unquoted text term (used for Contains/StartsWith/EndsWith wildcards). + // Backslash is escaped first so escaping the others doesn't double-escape it. + private static readonly char[] TextTermSpecialChars = + { '\\', ',', '.', '<', '>', '{', '}', '[', ']', '"', '\'', ':', ';', '!', + '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '+', '=', '~', '|' }; + + private static string EscapeTextTerm(string value) + { + var sb = new System.Text.StringBuilder(value.Length); + foreach (char c in value) + { + if (Array.IndexOf(TextTermSpecialChars, c) >= 0) sb.Append('\\'); + sb.Append(c); + } + return sb.ToString(); + } } } From d328fde23a37bd64a0b6593849703ddcbfa4adc8 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:34:17 -0500 Subject: [PATCH 07/30] feat(nosql): add diagnostics tracing to DynamoDB translator Translate(...) now opens a ValiFlowDiagnostics Activity (tag + entityType), consistent with the other NoSql providers. --- .../DynamoFilterTranslatorTests.cs | 45 +++++++++++++++++++ .../Extensions/ValiFlowDynamoExtensions.cs | 9 ++-- .../Translators/DynamoFilterTranslator.cs | 20 ++++++--- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/Vali-Flow.NoSql.DynamoDB.Tests/DynamoFilterTranslatorTests.cs b/Vali-Flow.NoSql.DynamoDB.Tests/DynamoFilterTranslatorTests.cs index cc2009f..2aa7e55 100644 --- a/Vali-Flow.NoSql.DynamoDB.Tests/DynamoFilterTranslatorTests.cs +++ b/Vali-Flow.NoSql.DynamoDB.Tests/DynamoFilterTranslatorTests.cs @@ -1,5 +1,7 @@ +using System.Diagnostics; using System.Linq.Expressions; using Amazon.DynamoDBv2.Model; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.Core.Builder; using Vali_Flow.NoSql.DynamoDB.Extensions; using Vali_Flow.NoSql.DynamoDB.Models; @@ -11,6 +13,49 @@ namespace Vali_Flow.NoSql.DynamoDB.Tests; public sealed class DynamoFilterTranslatorTests { + private static ActivityListener AttachListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public void ToDynamoDB_WithTag_SetsTagAndEntityTypeOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var flow = new ValiFlow().EqualTo(x => x.Name, "Alice"); + + flow.ToDynamoDB(tag: "report-x"); + + captured.Should().NotBeNull(); + captured!.GetTagItem("vali_flow.tag").Should().Be("report-x"); + captured.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestDocument)); + } + + [Fact] + public void Translate_EndsWith_SetsErrorStatusOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var node = new LikeNode("Name", "son", LikeOp.EndsWith); + + Action act = () => DynamoFilterTranslator.Translate(node); + + act.Should().Throw(); + captured.Should().NotBeNull(); + captured!.Status.Should().Be(ActivityStatusCode.Error); + } + // ── Equality ────────────────────────────────────────────────────────────── [Fact] diff --git a/Vali-Flow.NoSql.DynamoDB/Extensions/ValiFlowDynamoExtensions.cs b/Vali-Flow.NoSql.DynamoDB/Extensions/ValiFlowDynamoExtensions.cs index 848d8a7..4c90a8d 100644 --- a/Vali-Flow.NoSql.DynamoDB/Extensions/ValiFlowDynamoExtensions.cs +++ b/Vali-Flow.NoSql.DynamoDB/Extensions/ValiFlowDynamoExtensions.cs @@ -24,6 +24,7 @@ public static class ValiFlowDynamoExtensions /// /// The entity / document type. /// The ValiFlow builder containing the conditions. + /// Optional override to convert a value into an for types the default conversion doesn't handle. /// /// A ready to apply to a ScanRequest or QueryRequest. /// @@ -43,21 +44,21 @@ public static class ValiFlowDynamoExtensions /// }; /// /// - public static DynamoFilterExpression ToDynamoDB(this ValiFlow flow, Func? customConverter = null) where T : class + public static DynamoFilterExpression ToDynamoDB(this ValiFlow flow, Func? customConverter = null, string? tag = null) where T : class { if (flow == null) throw new ArgumentNullException(nameof(flow)); - return DynamoFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter); + return DynamoFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter, tag, typeof(T).Name); } /// /// Translates a prebuilt into a DynamoDB . /// Use this overload when you already have a compiled expression. /// - public static DynamoFilterExpression ToDynamoDB(this Expression> expression, Func? customConverter = null) + public static DynamoFilterExpression ToDynamoDB(this Expression> expression, Func? customConverter = null, string? tag = null) { if (expression == null) throw new ArgumentNullException(nameof(expression)); - return DynamoFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter); + return DynamoFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter, tag, typeof(T).Name); } } diff --git a/Vali-Flow.NoSql.DynamoDB/Translators/DynamoFilterTranslator.cs b/Vali-Flow.NoSql.DynamoDB/Translators/DynamoFilterTranslator.cs index 0159e1d..bd39b75 100644 --- a/Vali-Flow.NoSql.DynamoDB/Translators/DynamoFilterTranslator.cs +++ b/Vali-Flow.NoSql.DynamoDB/Translators/DynamoFilterTranslator.cs @@ -1,5 +1,6 @@ using System.Globalization; using Amazon.DynamoDBv2.Model; +using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.NoSql.DynamoDB.Models; using Vali_Flow.NoSql.IR; using Vali_Flow.NoSql.Translators; @@ -37,14 +38,23 @@ public static class DynamoFilterTranslator /// /// A ready to apply to a ScanRequest or QueryRequest. /// - public static DynamoFilterExpression Translate(IConditionNode node, Func? customConverter = null) + public static DynamoFilterExpression Translate(IConditionNode node, Func? customConverter = null, string? tag = null, string? entityType = null) { if (node == null) throw new ArgumentNullException(nameof(node)); - var ctx = new TranslationContext(); - var visitor = new DynamoVisitor(ctx, customConverter); - var expression = node.Accept(visitor); - return new DynamoFilterExpression(expression, ctx.Names, ctx.Values); + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.NoSql.DynamoDB.Translate", tag, entityType); + try + { + var ctx = new TranslationContext(); + var visitor = new DynamoVisitor(ctx, customConverter); + var expression = node.Accept(visitor); + return new DynamoFilterExpression(expression, ctx.Names, ctx.Values); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } } private sealed class DynamoVisitor(TranslationContext ctx, Func? customConverter) From 62d10741ee802c2947df1e7cea3c3faf3e452c66 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:34:46 -0500 Subject: [PATCH 08/30] feat(nosql): add Couchbase, CosmosDb, and Firestore providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vali-Flow.NoSql.Couchbase: N1QL (SQL++) WHERE-fragment translator, no SDK dependency - Vali-Flow.NoSql.CosmosDb: Cosmos SQL API WHERE-fragment translator, no SDK dependency - Vali-Flow.NoSql.Firestore: native Google.Cloud.Firestore.Filter translator (LikeNode and generic NotNode throw NotSupportedException — SDK has no pattern-matching query and no NOT over an arbitrary sub-filter) - Add bilingual docs (en/es) and wire all 3 into the solution --- .../CosmosFilterTranslatorTests.cs | 461 ++++++++++++++++++ .../Models/TestDocument.cs | 12 + .../Vali-Flow.NoSql.CosmosDb.Tests.csproj | 29 ++ .../Extensions/ValiFlowCosmosDbExtensions.cs | 57 +++ .../Models/CosmosFilterExpression.cs | 28 ++ Vali-Flow.NoSql.CosmosDb/README.md | 46 ++ .../Translators/CosmosFilterTranslator.cs | 188 +++++++ .../Vali-Flow.NoSql.CosmosDb.csproj | 47 ++ .../CouchbaseFilterTranslatorTests.cs | 421 ++++++++++++++++ .../Models/TestDocument.cs | 12 + .../Vali-Flow.NoSql.Couchbase.Tests.csproj | 29 ++ .../Extensions/ValiFlowCouchbaseExtensions.cs | 58 +++ .../Models/CouchbaseFilterExpression.cs | 34 ++ Vali-Flow.NoSql.Couchbase/README.md | 48 ++ .../Translators/CouchbaseFilterTranslator.cs | 152 ++++++ .../Vali-Flow.NoSql.Couchbase.csproj | 47 ++ .../ValiFlowFirestoreExtensionsTests.cs | 72 +++ .../Models/TestDocument.cs | 12 + .../FirestoreFilterTranslatorTests.cs | 185 +++++++ .../Vali-Flow.NoSql.Firestore.Tests.csproj | 29 ++ .../Extensions/ValiFlowFirestoreExtensions.cs | 55 +++ Vali-Flow.NoSql.Firestore/README.md | 45 ++ .../Translators/FirestoreFilterTranslator.cs | 104 ++++ .../Vali-Flow.NoSql.Firestore.csproj | 48 ++ docs/en/vali-flow-nosql-cosmosdb.md | 261 ++++++++++ docs/en/vali-flow-nosql-couchbase.md | 266 ++++++++++ docs/en/vali-flow-nosql-firestore.md | 234 +++++++++ docs/es/vali-flow-nosql-cosmosdb.md | 261 ++++++++++ docs/es/vali-flow-nosql-couchbase.md | 266 ++++++++++ docs/es/vali-flow-nosql-firestore.md | 234 +++++++++ vali-flow.sln | 98 ++++ 31 files changed, 3839 insertions(+) create mode 100644 Vali-Flow.NoSql.CosmosDb.Tests/CosmosFilterTranslatorTests.cs create mode 100644 Vali-Flow.NoSql.CosmosDb.Tests/Models/TestDocument.cs create mode 100644 Vali-Flow.NoSql.CosmosDb.Tests/Vali-Flow.NoSql.CosmosDb.Tests.csproj create mode 100644 Vali-Flow.NoSql.CosmosDb/Extensions/ValiFlowCosmosDbExtensions.cs create mode 100644 Vali-Flow.NoSql.CosmosDb/Models/CosmosFilterExpression.cs create mode 100644 Vali-Flow.NoSql.CosmosDb/README.md create mode 100644 Vali-Flow.NoSql.CosmosDb/Translators/CosmosFilterTranslator.cs create mode 100644 Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj create mode 100644 Vali-Flow.NoSql.Couchbase.Tests/CouchbaseFilterTranslatorTests.cs create mode 100644 Vali-Flow.NoSql.Couchbase.Tests/Models/TestDocument.cs create mode 100644 Vali-Flow.NoSql.Couchbase.Tests/Vali-Flow.NoSql.Couchbase.Tests.csproj create mode 100644 Vali-Flow.NoSql.Couchbase/Extensions/ValiFlowCouchbaseExtensions.cs create mode 100644 Vali-Flow.NoSql.Couchbase/Models/CouchbaseFilterExpression.cs create mode 100644 Vali-Flow.NoSql.Couchbase/README.md create mode 100644 Vali-Flow.NoSql.Couchbase/Translators/CouchbaseFilterTranslator.cs create mode 100644 Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj create mode 100644 Vali-Flow.NoSql.Firestore.Tests/Extensions/ValiFlowFirestoreExtensionsTests.cs create mode 100644 Vali-Flow.NoSql.Firestore.Tests/Models/TestDocument.cs create mode 100644 Vali-Flow.NoSql.Firestore.Tests/Translators/FirestoreFilterTranslatorTests.cs create mode 100644 Vali-Flow.NoSql.Firestore.Tests/Vali-Flow.NoSql.Firestore.Tests.csproj create mode 100644 Vali-Flow.NoSql.Firestore/Extensions/ValiFlowFirestoreExtensions.cs create mode 100644 Vali-Flow.NoSql.Firestore/README.md create mode 100644 Vali-Flow.NoSql.Firestore/Translators/FirestoreFilterTranslator.cs create mode 100644 Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj create mode 100644 docs/en/vali-flow-nosql-cosmosdb.md create mode 100644 docs/en/vali-flow-nosql-couchbase.md create mode 100644 docs/en/vali-flow-nosql-firestore.md create mode 100644 docs/es/vali-flow-nosql-cosmosdb.md create mode 100644 docs/es/vali-flow-nosql-couchbase.md create mode 100644 docs/es/vali-flow-nosql-firestore.md diff --git a/Vali-Flow.NoSql.CosmosDb.Tests/CosmosFilterTranslatorTests.cs b/Vali-Flow.NoSql.CosmosDb.Tests/CosmosFilterTranslatorTests.cs new file mode 100644 index 0000000..99c7664 --- /dev/null +++ b/Vali-Flow.NoSql.CosmosDb.Tests/CosmosFilterTranslatorTests.cs @@ -0,0 +1,461 @@ +using System.Diagnostics; +using System.Linq.Expressions; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.CosmosDb.Extensions; +using Vali_Flow.NoSql.CosmosDb.Models; +using Vali_Flow.NoSql.CosmosDb.Translators; +using Vali_Flow.NoSql.CosmosDb.Tests.Models; +using Vali_Flow.NoSql.IR; + +namespace Vali_Flow.NoSql.CosmosDb.Tests; + +public sealed class CosmosFilterTranslatorTests +{ + private static ActivityListener AttachListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public void ToCosmosDb_WithTag_SetsTagAndEntityTypeOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var flow = new ValiFlow().EqualTo(x => x.Name, "Alice"); + + flow.ToCosmosDb(tag: "report-x"); + + captured.Should().NotBeNull(); + captured!.GetTagItem("vali_flow.tag").Should().Be("report-x"); + captured.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestDocument)); + } + + [Fact] + public void Translate_InExceedsMaxValues_SetsErrorStatusOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var values = Enumerable.Range(0, 6_001).Cast().ToList(); + var node = new InNode("Age", values); + + Action act = () => CosmosFilterTranslator.Translate(node); + + act.Should().Throw(); + captured.Should().NotBeNull(); + captured!.Status.Should().Be(ActivityStatusCode.Error); + } + + // ── Equality ────────────────────────────────────────────────────────────── + + [Fact] + public void ToCosmosDb_EqualString_ProducesEqualClause() + { + Expression> expr = x => x.Name == "Alice"; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Name = @p0"); + f.Parameters["@p0"].Should().Be("Alice"); + } + + [Fact] + public void ToCosmosDb_NotEqualString_ProducesNotEqualClause() + { + Expression> expr = x => x.Name != "Bob"; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Name != @p0"); + f.Parameters["@p0"].Should().Be("Bob"); + } + + [Fact] + public void ToCosmosDb_EqualInt_ProducesNumericEqualClause() + { + Expression> expr = x => x.Age == 30; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Age = @p0"); + f.Parameters["@p0"].Should().Be(30); + } + + [Fact] + public void ToCosmosDb_EqualBool_ProducesBoolClause() + { + Expression> expr = x => x.IsActive == true; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.IsActive = @p0"); + f.Parameters["@p0"].Should().Be(true); + } + + [Fact] + public void ToCosmosDb_BoolMemberDirect_ProducesBoolTrueClause() + { + Expression> expr = x => x.IsActive; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.IsActive = @p0"); + f.Parameters["@p0"].Should().Be(true); + } + + // ── Null checks ─────────────────────────────────────────────────────────── + + [Fact] + public void ToCosmosDb_IsNull_ProducesIsNullFunction() + { + Expression> expr = x => x.Email == null; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("IS_NULL(c.Email)"); + f.Parameters.Should().BeEmpty(); + } + + [Fact] + public void ToCosmosDb_IsNotNull_ProducesNotIsNullFunction() + { + Expression> expr = x => x.Email != null; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("NOT IS_NULL(c.Email)"); + } + + // ── Range ───────────────────────────────────────────────────────────────── + + [Fact] + public void ToCosmosDb_GreaterThan_ProducesGtClause() + { + Expression> expr = x => x.Age > 18; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Age > @p0"); + f.Parameters["@p0"].Should().Be(18); + } + + [Fact] + public void ToCosmosDb_GreaterThanOrEqual_ProducesGteClause() + { + Expression> expr = x => x.Age >= 21; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Age >= @p0"); + } + + [Fact] + public void ToCosmosDb_LessThan_ProducesLtClause() + { + Expression> expr = x => x.Age < 65; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Age < @p0"); + } + + [Fact] + public void ToCosmosDb_LessThanOrEqual_ProducesLteClause() + { + Expression> expr = x => x.Age <= 60; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Age <= @p0"); + } + + // ── Pattern match ───────────────────────────────────────────────────────── + + [Fact] + public void ToCosmosDb_StringContains_ProducesContainsFunction() + { + Expression> expr = x => x.Name.Contains("Ali"); + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("CONTAINS(c.Name, @p0)"); + f.Parameters["@p0"].Should().Be("Ali"); + } + + [Fact] + public void ToCosmosDb_StringStartsWith_ProducesStartsWithFunction() + { + Expression> expr = x => x.Name.StartsWith("Al"); + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("STARTSWITH(c.Name, @p0)"); + } + + [Fact] + public void ToCosmosDb_StringEndsWith_ProducesEndsWithFunction() + { + Expression> expr = x => x.Name.EndsWith("ce"); + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("ENDSWITH(c.Name, @p0)"); + f.Parameters["@p0"].Should().Be("ce"); + } + + // ── Membership ──────────────────────────────────────────────────────────── + + [Fact] + public void ToCosmosDb_ListContains_ProducesInClauseWithOnePlaceholderPerValue() + { + var ids = new List { 1, 2, 3 }; + Expression> expr = x => ids.Contains(x.Id); + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Id IN (@p0, @p1, @p2)"); + f.Parameters["@p0"].Should().Be(1); + f.Parameters["@p1"].Should().Be(2); + f.Parameters["@p2"].Should().Be(3); + } + + [Fact] + public void ToCosmosDb_EmptyListContains_ThrowsInvalidOperationException() + { + var ids = new List(); + Expression> expr = x => ids.Contains(x.Id); + + Action act = () => expr.ToCosmosDb(); + + act.Should().Throw().WithMessage("*empty*"); + } + + [Fact] + public void ToCosmosDb_StringListContains_ProducesInClauseWithStringValues() + { + var categories = new List { "Electronics", "Books", "Tools" }; + Expression> expr = x => categories.Contains(x.Category); + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Category IN (@p0, @p1, @p2)"); + f.Parameters["@p0"].Should().Be("Electronics"); + } + + [Fact] + public void ToCosmosDb_ListWithMoreThan6000Items_ThrowsInvalidOperationException() + { + var ids = Enumerable.Range(1, 6001).ToList(); + var node = new InNode("Id", ids.Cast().ToList()); + + Action act = () => CosmosFilterTranslator.Translate(node); + + act.Should().Throw().WithMessage("*6000*"); + } + + [Fact] + public void ToCosmosDb_ListWithExactly6000Items_DoesNotThrow() + { + var ids = Enumerable.Range(1, 6000).ToList(); + var node = new InNode("Id", ids.Cast().ToList()); + + Action act = () => CosmosFilterTranslator.Translate(node); + + act.Should().NotThrow(); + } + + // ── Logical combinators ─────────────────────────────────────────────────── + + [Fact] + public void ToCosmosDb_AndAlso_ProducesAndClause() + { + Expression> expr = x => x.IsActive && x.Age > 18; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("(c.IsActive = @p0 AND c.Age > @p1)"); + f.Parameters.Should().HaveCount(2); + } + + [Fact] + public void ToCosmosDb_OrElse_ProducesOrClause() + { + Expression> expr = x => x.Name == "Alice" || x.Name == "Bob"; + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("(c.Name = @p0 OR c.Name = @p1)"); + } + + [Fact] + public void ToCosmosDb_Not_ProducesNotClause() + { + Expression> expr = x => !(x.Age > 18); + + CosmosFilterExpression f = expr.ToCosmosDb(); + + f.WhereClause.Should().Be("NOT (c.Age > @p0)"); + } + + [Fact] + public void ToCosmosDb_NestedAndOr_ProducesCorrectParentheses() + { + var a = new EqualNode("IsActive", true, false); + var b = new ComparisonNode("Age", 18, ComparisonOp.GreaterThan); + var c = new EqualNode("Name", "Admin", false); + var node = new OrNode(new AndNode(a, b), c); + + CosmosFilterExpression f = CosmosFilterTranslator.Translate(node); + + f.WhereClause.Should().StartWith("("); + f.WhereClause.Should().Contain("AND"); + f.WhereClause.Should().Contain("OR"); + f.WhereClause.Should().MatchRegex(@"\(.*AND.*\).*OR"); + } + + // ── ValiFlow fluent pipeline ─────────────────────────────────────────── + + [Fact] + public void ToCosmosDb_ValiFlowEqualTo_ProducesEqualClause() + { + var flow = new ValiFlow().EqualTo(x => x.Category, "Fruit"); + + CosmosFilterExpression f = flow.ToCosmosDb(); + + f.WhereClause.Should().Be("c.Category = @p0"); + f.Parameters["@p0"].Should().Be("Fruit"); + } + + [Fact] + public void ToCosmosDb_ValiFlowCombined_ProducesAndClause() + { + var flow = new ValiFlow() + .EqualTo(x => x.IsActive, true) + .GreaterThan(x => x.Age, 18); + + CosmosFilterExpression f = flow.ToCosmosDb(); + + f.WhereClause.Should().Be("(c.IsActive = @p0 AND c.Age > @p1)"); + } + + // ── Null guards ─────────────────────────────────────────────────────────── + + [Fact] + public void Translate_NullNode_ThrowsArgumentNullException() + { + Action act = () => CosmosFilterTranslator.Translate(null!); + + act.Should().Throw(); + } + + [Fact] + public void ToCosmosDb_ValiFlowNull_ThrowsArgumentNullException() + { + ValiFlow flow = null!; + + Action act = () => flow.ToCosmosDb(); + + act.Should().Throw(); + } + + [Fact] + public void ToCosmosDb_ExpressionNull_ThrowsArgumentNullException() + { + Expression> expr = null!; + + Action act = () => expr.ToCosmosDb(); + + act.Should().Throw(); + } + + // ── OCP — custom converter ──────────────────────────────────────────────── + + [Fact] + public void CustomConverter_WhenSet_UsedForMatchingType() + { + Func converter = v => v is decimal d ? d.ToString("G", System.Globalization.CultureInfo.InvariantCulture) : null; + + var node = new EqualNode("Price", 9.99m, false); + CosmosFilterExpression f = CosmosFilterTranslator.Translate(node, converter); + + f.Parameters["@p0"].Should().Be("9.99"); + } + + [Fact] + public void CustomConverter_WhenReturnsNull_FallsThroughToDefault() + { + Func converter = _ => null; + + var node = new EqualNode("Name", "Alice", false); + CosmosFilterExpression f = CosmosFilterTranslator.Translate(node, converter); + + f.Parameters["@p0"].Should().Be("Alice"); + } + + // ── Direct node construction / type coverage ────────────────────────────── + + [Fact] + public void Translate_DirectEqualNode_ProducesCorrectClause() + { + var node = new EqualNode("Status", "Active", false); + + CosmosFilterExpression f = CosmosFilterTranslator.Translate(node); + + f.WhereClause.Should().Be("c.Status = @p0"); + f.Parameters["@p0"].Should().Be("Active"); + } + + [Fact] + public void ToCosmosDb_GuidValue_ProducesStringParameter() + { + var guid = Guid.NewGuid(); + var node = new EqualNode("Id", guid, false); + + CosmosFilterExpression f = CosmosFilterTranslator.Translate(node); + + f.Parameters["@p0"].Should().Be(guid.ToString()); + } + + [Fact] + public void ToCosmosDb_EnumValue_ProducesLongParameter() + { + var node = new EqualNode("Status", CosmosTestStatus.Active, false); + + CosmosFilterExpression f = CosmosFilterTranslator.Translate(node); + + f.Parameters["@p0"].Should().Be(1L); + } + + [Fact] + public void ToCosmosDb_DecimalGreaterThan_ProducesNumericComparison() + { + var node = new ComparisonNode("Price", 99.99m, ComparisonOp.GreaterThan); + + CosmosFilterExpression f = CosmosFilterTranslator.Translate(node); + + f.WhereClause.Should().Be("c.Price > @p0"); + f.Parameters["@p0"].Should().Be(99.99m); + } + + [Fact] + public void ToCosmosDb_FallbackTypeObject_ProducesStringParameter() + { + var node = new EqualNode("Misc", new Uri("https://example.com"), false); + + CosmosFilterExpression f = CosmosFilterTranslator.Translate(node); + + f.Parameters["@p0"].Should().Be("https://example.com/"); + } + + private enum CosmosTestStatus { Active = 1 } +} diff --git a/Vali-Flow.NoSql.CosmosDb.Tests/Models/TestDocument.cs b/Vali-Flow.NoSql.CosmosDb.Tests/Models/TestDocument.cs new file mode 100644 index 0000000..a899415 --- /dev/null +++ b/Vali-Flow.NoSql.CosmosDb.Tests/Models/TestDocument.cs @@ -0,0 +1,12 @@ +namespace Vali_Flow.NoSql.CosmosDb.Tests.Models; + +public sealed class TestDocument +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Email { get; set; } + public int Age { get; set; } + public decimal Price { get; set; } + public bool IsActive { get; set; } + public string Category { get; set; } = string.Empty; +} diff --git a/Vali-Flow.NoSql.CosmosDb.Tests/Vali-Flow.NoSql.CosmosDb.Tests.csproj b/Vali-Flow.NoSql.CosmosDb.Tests/Vali-Flow.NoSql.CosmosDb.Tests.csproj new file mode 100644 index 0000000..da9c46c --- /dev/null +++ b/Vali-Flow.NoSql.CosmosDb.Tests/Vali-Flow.NoSql.CosmosDb.Tests.csproj @@ -0,0 +1,29 @@ + + + net9.0 + enable + enable + false + Vali_Flow.NoSql.CosmosDb.Tests + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/Vali-Flow.NoSql.CosmosDb/Extensions/ValiFlowCosmosDbExtensions.cs b/Vali-Flow.NoSql.CosmosDb/Extensions/ValiFlowCosmosDbExtensions.cs new file mode 100644 index 0000000..f64ac6a --- /dev/null +++ b/Vali-Flow.NoSql.CosmosDb/Extensions/ValiFlowCosmosDbExtensions.cs @@ -0,0 +1,57 @@ +using System.Linq.Expressions; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.CosmosDb.Models; +using Vali_Flow.NoSql.CosmosDb.Translators; +using Vali_Flow.NoSql.Extensions; + +namespace Vali_Flow.NoSql.CosmosDb.Extensions; + +/// +/// Extension methods that add Azure Cosmos DB SQL API WHERE-clause-building capabilities to . +/// +/// +/// The returned encapsulates the WHERE clause fragment and the +/// parameter dictionary for use with a Cosmos SQL QueryDefinition. +/// This package depends only on Vali-Flow.NoSql — +/// it is a query builder with no connection, SDK, or execution concerns. +/// +public static class ValiFlowCosmosDbExtensions +{ + /// + /// Translates the conditions built in this instance + /// into an Azure Cosmos DB . + /// + /// The entity / document type. + /// The ValiFlow builder containing the conditions. + /// Optional override to convert a value into its raw Cosmos parameter value for types the default conversion doesn't handle. + /// A ready to append to a Cosmos SQL query. + /// + /// + /// var filter = new ValiFlow<Order>() + /// .EqualTo(x => x.Status, "Active") + /// .GreaterThan(x => x.Total, 100m); + /// + /// CosmosFilterExpression f = filter.ToCosmosDb(); + /// var queryText = $"SELECT * FROM c WHERE {f.WhereClause}"; + /// var query = new QueryDefinition(queryText); + /// foreach (var kv in f.Parameters) query = query.WithParameter(kv.Key, kv.Value); + /// + /// + public static CosmosFilterExpression ToCosmosDb(this ValiFlow flow, Func? customConverter = null, string? tag = null) where T : class + { + if (flow == null) throw new ArgumentNullException(nameof(flow)); + + return CosmosFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter, tag, typeof(T).Name); + } + + /// + /// Translates a prebuilt into an Azure Cosmos DB . + /// Use this overload when you already have a compiled expression. + /// + public static CosmosFilterExpression ToCosmosDb(this Expression> expression, Func? customConverter = null, string? tag = null) + { + if (expression == null) throw new ArgumentNullException(nameof(expression)); + + return CosmosFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter, tag, typeof(T).Name); + } +} diff --git a/Vali-Flow.NoSql.CosmosDb/Models/CosmosFilterExpression.cs b/Vali-Flow.NoSql.CosmosDb/Models/CosmosFilterExpression.cs new file mode 100644 index 0000000..4f25dbb --- /dev/null +++ b/Vali-Flow.NoSql.CosmosDb/Models/CosmosFilterExpression.cs @@ -0,0 +1,28 @@ +namespace Vali_Flow.NoSql.CosmosDb.Models; + +/// +/// Encapsulates the result of translating a Vali-Flow filter into a Cosmos DB SQL API WHERE clause fragment. +/// +/// +/// Pass the two properties directly when building a Cosmos SQL query: +/// +/// var cosmos = filter.ToCosmosDb(); +/// var queryText = $"SELECT * FROM c WHERE {cosmos.WhereClause}"; +/// var query = new QueryDefinition(queryText); +/// foreach (var kv in cosmos.Parameters) query = query.WithParameter(kv.Key, kv.Value); +/// +/// +public sealed class CosmosFilterExpression +{ + /// The WHERE clause fragment, without the leading "WHERE" keyword (e.g. "c.Age > @p0"). + public string WhereClause { get; } + + /// Mapping from parameter placeholders (@p0) to their values. + public IReadOnlyDictionary Parameters { get; } + + internal CosmosFilterExpression(string whereClause, Dictionary parameters) + { + WhereClause = whereClause; + Parameters = parameters.AsReadOnly(); + } +} diff --git a/Vali-Flow.NoSql.CosmosDb/README.md b/Vali-Flow.NoSql.CosmosDb/README.md new file mode 100644 index 0000000..1c69205 --- /dev/null +++ b/Vali-Flow.NoSql.CosmosDb/README.md @@ -0,0 +1,46 @@ +# Vali-Flow.NoSql.CosmosDb + +Azure Cosmos DB SQL API WHERE-clause builder that translates `ValiFlow` filters into native `CosmosFilterExpression` objects. + +See the [main README](../../README.md) for complete documentation and examples. + +## Installation + +```bash +dotnet add package Vali-Flow.NoSql.CosmosDb +``` + +## Quick Example + +```csharp +using Vali_Flow.NoSql.CosmosDb.Extensions; + +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Active") + .GreaterThan(x => x.Total, 100m); + +CosmosFilterExpression f = filter.ToCosmosDb(); + +var queryText = $"SELECT * FROM c WHERE {f.WhereClause}"; +var query = new QueryDefinition(queryText); +foreach (var kv in f.Parameters) query = query.WithParameter(kv.Key, kv.Value); + +using var iterator = container.GetItemQueryIterator(query); +``` + +## Features + +- Translates `ValiFlow` expressions to a Cosmos SQL API WHERE clause fragment +- Native functions: `CONTAINS`, `STARTSWITH`, `ENDSWITH`, `IS_NULL` +- Parameter placeholders (`@p0`, `@p1`, …), one per value — no attribute-name placeholders needed +- No dependency on the `Microsoft.Azure.Cosmos` SDK — pure string/parameter builder + +## Limitations + +- Does not depend on the Cosmos SDK — it only produces the WHERE fragment. You build your own `QueryDefinition` / `Container.GetItemQueryIterator` with it. +- `In` with an empty list throws `InvalidOperationException` — filter the empty case before building the query. +- No upper bound is enforced on `In` list size by this package; very large lists may still hit Cosmos DB's own query size/complexity limits. + +## License + +MIT diff --git a/Vali-Flow.NoSql.CosmosDb/Translators/CosmosFilterTranslator.cs b/Vali-Flow.NoSql.CosmosDb/Translators/CosmosFilterTranslator.cs new file mode 100644 index 0000000..79b3641 --- /dev/null +++ b/Vali-Flow.NoSql.CosmosDb/Translators/CosmosFilterTranslator.cs @@ -0,0 +1,188 @@ +using System.Globalization; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.NoSql.CosmosDb.Models; +using Vali_Flow.NoSql.IR; +using Vali_Flow.NoSql.Translators; + +namespace Vali_Flow.NoSql.CosmosDb.Translators; + +/// +/// Translates a IR tree into an Azure Cosmos DB SQL API +/// — a parameterized WHERE clause fragment. +/// +/// +/// The result uses the standard Cosmos SQL container alias c (e.g. c.Age) and +/// SQL Server-style @p0 parameter placeholders, ready to append to a query text and +/// bind via QueryDefinition.WithParameter. +/// +/// Mapping notes: +/// +/// maps to the native CONTAINS/STARTSWITH/ENDSWITH functions. +/// maps to the native IS_NULL function. +/// expands to one placeholder per value — Cosmos SQL does not accept a single array parameter for IN. +/// +/// +/// +public static class CosmosFilterTranslator +{ + private const string ContainerAlias = "c."; + + // Cosmos SQL expands every IN value into its own @pN placeholder, consuming the query's + // documented 512KB max query size and its parameter-count budget alongside other params in the + // same query. No official per-element IN limit is documented — this is a conservative practical + // cap, not an exact Cosmos limit. + private const int MaxInValues = 6_000; + + /// + /// Translates the given into a . + /// + /// The root condition node to translate. + /// + /// Optional hook for converting custom CLR types into the raw value stored in the parameters dictionary. + /// Called before the built-in type switch. Return null to fall through to the default conversion. + /// Thread-safe: the converter is scoped to this call only. + /// + /// A ready to append to a Cosmos SQL query. + public static CosmosFilterExpression Translate(IConditionNode node, Func? customConverter = null, string? tag = null, string? entityType = null) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.NoSql.CosmosDb.Translate", tag, entityType); + try + { + var ctx = new TranslationContext(); + var visitor = new CosmosVisitor(ctx, customConverter); + var expression = node.Accept(visitor); + return new CosmosFilterExpression(expression, ctx.Parameters); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + private sealed class CosmosVisitor(TranslationContext ctx, Func? customConverter) + : IConditionNodeVisitor + { + public string VisitAnd(AndNode node) => + $"({node.Left.Accept(this)} AND {node.Right.Accept(this)})"; + + public string VisitOr(OrNode node) => + $"({node.Left.Accept(this)} OR {node.Right.Accept(this)})"; + + public string VisitNot(NotNode node) => + $"NOT ({node.Inner.Accept(this)})"; + + public string VisitNull(NullNode node) => NullHelper.Build(node); + + public string VisitEqual(EqualNode node) => EqualHelper.Build(node, ctx, customConverter); + + public string VisitComparison(ComparisonNode node) => ComparisonHelper.Build(node, ctx, customConverter); + + public string VisitLike(LikeNode node) => LikeHelper.Build(node, ctx); + + public string VisitIn(InNode node) => InHelper.Build(node, ctx, customConverter); + } + + // ── Node-specific builders (kept tiny, one responsibility each) ────────── + + private static class NullHelper + { + public static string Build(NullNode node) => + node.Check == NullCheckOp.IsNull + ? $"IS_NULL({Field(node.Field)})" + : $"NOT IS_NULL({Field(node.Field)})"; + } + + private static class EqualHelper + { + public static string Build(EqualNode node, TranslationContext ctx, Func? customConverter) + { + var placeholder = ctx.AddParameter(ConvertValue(node.Value, customConverter)); + return node.IsNegated + ? $"{Field(node.Field)} != {placeholder}" + : $"{Field(node.Field)} = {placeholder}"; + } + } + + private static class ComparisonHelper + { + public static string Build(ComparisonNode node, TranslationContext ctx, Func? customConverter) => + $"{Field(node.Field)} {MapComparisonOp(node.Op)} {ctx.AddParameter(ConvertValue(node.Value, customConverter))}"; + + private static string MapComparisonOp(ComparisonOp op) => op switch + { + ComparisonOp.GreaterThan => ">", + ComparisonOp.GreaterThanOrEqual => ">=", + ComparisonOp.LessThan => "<", + ComparisonOp.LessThanOrEqual => "<=", + _ => throw new NotSupportedException($"ComparisonOp.{op} is not mapped.") + }; + } + + private static class LikeHelper + { + public static string Build(LikeNode node, TranslationContext ctx) => node.Op switch + { + LikeOp.Contains => $"CONTAINS({Field(node.Field)}, {ctx.AddParameter(node.Pattern)})", + LikeOp.StartsWith => $"STARTSWITH({Field(node.Field)}, {ctx.AddParameter(node.Pattern)})", + LikeOp.EndsWith => $"ENDSWITH({Field(node.Field)}, {ctx.AddParameter(node.Pattern)})", + _ => throw new NotSupportedException($"LikeOp.{node.Op} is not mapped.") + }; + } + + private static class InHelper + { + public static string Build(InNode node, TranslationContext ctx, Func? customConverter) + { + if (node.Values.Count == 0) + throw new InvalidOperationException( + "IN condition with empty list is not supported in Cosmos DB SQL queries. Filter the empty case before building the query."); + + if (node.Values.Count > MaxInValues) + throw new InvalidOperationException( + $"Cosmos DB SQL IN expression supports at most {MaxInValues} values (conservative practical " + + $"limit — each value consumes a placeholder against the 512KB max query size and the query's " + + $"parameter budget); received {node.Values.Count}. Split the query or batch the values."); + + var field = Field(node.Field); + var placeholders = node.Values.Select(v => ctx.AddParameter(ConvertValue(v, customConverter))); + return $"{field} IN ({string.Join(", ", placeholders)})"; + } + } + + private static string Field(string fieldName) => $"{ContainerAlias}{fieldName}"; + + private static object? ConvertValue(object? value, Func? customConverter) => + ConditionValueResolver.Resolve(value, customConverter, v => v switch + { + null => null, + bool b => b, + string s => s, + int i => i, + long l => l, + double d => d, + float f => f, + decimal m => m, + Guid g => g.ToString(), + Enum e => Convert.ToInt64(e, CultureInfo.InvariantCulture), + _ => v!.ToString()! + }); + + // ── Private translation context (NOT part of public API) ───────────────── + + private sealed class TranslationContext + { + private int _paramCounter; + + public Dictionary Parameters { get; } = new(); + + public string AddParameter(object? value) + { + var placeholder = $"@p{_paramCounter++}"; + Parameters[placeholder] = value; + return placeholder; + } + } +} diff --git a/Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj b/Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj new file mode 100644 index 0000000..4fba483 --- /dev/null +++ b/Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj @@ -0,0 +1,47 @@ + + + + net8.0;net9.0 + enable + enable + Vali_Flow.NoSql.CosmosDb + Vali-Flow.NoSql.CosmosDb + 1.0.0 + Felipe Rafael Montenegro Morriberon + Vali-Flow.NoSql.CosmosDb + Azure Cosmos DB (SQL API) adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into a parameterized Cosmos SQL WHERE clause fragment. Use .ToCosmosDb() on any ValiFlow<T> or Expression<Func<T, bool>>. + Copyright © 2026 - FM. Todos los derechos reservados. + MIT + true + https://vali-flow-docs.netlify.app/docs/adapters/nosql/cosmosdb/overview + https://github.com/UBF21/vali-flow + git + VF_logo.png + README.md + vali;vali-flow;cosmosdb;azure;sql-api;filter;nosql;dotnet + v1.0.0 — Initial release. Cosmos DB SQL API WHERE clause translator. + true + true + true + embedded + true + snupkg + + + + + True + + + + True + + + + + + + + + + diff --git a/Vali-Flow.NoSql.Couchbase.Tests/CouchbaseFilterTranslatorTests.cs b/Vali-Flow.NoSql.Couchbase.Tests/CouchbaseFilterTranslatorTests.cs new file mode 100644 index 0000000..f1981a0 --- /dev/null +++ b/Vali-Flow.NoSql.Couchbase.Tests/CouchbaseFilterTranslatorTests.cs @@ -0,0 +1,421 @@ +using System.Diagnostics; +using System.Linq.Expressions; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Couchbase.Extensions; +using Vali_Flow.NoSql.Couchbase.Models; +using Vali_Flow.NoSql.Couchbase.Translators; +using Vali_Flow.NoSql.Couchbase.Tests.Models; +using Vali_Flow.NoSql.IR; + +namespace Vali_Flow.NoSql.Couchbase.Tests; + +public sealed class CouchbaseFilterTranslatorTests +{ + private static ActivityListener AttachListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public void ToCouchbase_WithTag_SetsTagAndEntityTypeOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var flow = new ValiFlow().EqualTo(x => x.Name, "Alice"); + + flow.ToCouchbase(tag: "report-x"); + + captured.Should().NotBeNull(); + captured!.GetTagItem("vali_flow.tag").Should().Be("report-x"); + captured.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestDocument)); + } + + [Fact] + public void Translate_InExceedsMaxValues_SetsErrorStatusOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var values = Enumerable.Range(0, 10_001).Cast().ToList(); + var node = new InNode("Age", values); + + Action act = () => CouchbaseFilterTranslator.Translate(node); + + act.Should().Throw(); + captured.Should().NotBeNull(); + captured!.Status.Should().Be(ActivityStatusCode.Error); + } + + // ── Equality ────────────────────────────────────────────────────────────── + + [Fact] + public void ToCouchbase_EqualString_ProducesEqualClause() + { + Expression> expr = x => x.Name == "Alice"; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Name` = $p0"); + f.Parameters["$p0"].Should().Be("Alice"); + } + + [Fact] + public void ToCouchbase_NotEqualString_ProducesNotEqualClause() + { + Expression> expr = x => x.Name != "Bob"; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Name` <> $p0"); + f.Parameters["$p0"].Should().Be("Bob"); + } + + [Fact] + public void ToCouchbase_EqualInt_ProducesEqualClause() + { + Expression> expr = x => x.Age == 30; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Age` = $p0"); + f.Parameters["$p0"].Should().Be(30); + } + + [Fact] + public void ToCouchbase_EqualBool_ProducesEqualClause() + { + Expression> expr = x => x.IsActive == true; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`IsActive` = $p0"); + f.Parameters["$p0"].Should().Be(true); + } + + // ── Null checks ─────────────────────────────────────────────────────────── + + [Fact] + public void ToCouchbase_IsNull_ProducesIsNullClause() + { + Expression> expr = x => x.Email == null; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Email` IS NULL"); + f.Parameters.Should().BeEmpty(); + } + + [Fact] + public void ToCouchbase_IsNotNull_ProducesIsNotNullClause() + { + Expression> expr = x => x.Email != null; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Email` IS NOT NULL"); + } + + // ── Range ───────────────────────────────────────────────────────────────── + + [Fact] + public void ToCouchbase_GreaterThan_ProducesGtClause() + { + Expression> expr = x => x.Age > 18; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Age` > $p0"); + f.Parameters["$p0"].Should().Be(18); + } + + [Fact] + public void ToCouchbase_GreaterThanOrEqual_ProducesGteClause() + { + Expression> expr = x => x.Age >= 21; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Age` >= $p0"); + } + + [Fact] + public void ToCouchbase_LessThan_ProducesLtClause() + { + Expression> expr = x => x.Age < 65; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Age` < $p0"); + } + + [Fact] + public void ToCouchbase_LessThanOrEqual_ProducesLteClause() + { + Expression> expr = x => x.Age <= 60; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Age` <= $p0"); + } + + // ── Pattern match ───────────────────────────────────────────────────────── + + [Fact] + public void ToCouchbase_StringContains_ProducesLikeWithBothWildcards() + { + Expression> expr = x => x.Name.Contains("Ali"); + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Name` LIKE $p0"); + f.Parameters["$p0"].Should().Be("%Ali%"); + } + + [Fact] + public void ToCouchbase_StringStartsWith_ProducesLikeWithTrailingWildcard() + { + Expression> expr = x => x.Name.StartsWith("Al"); + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Name` LIKE $p0"); + f.Parameters["$p0"].Should().Be("Al%"); + } + + [Fact] + public void ToCouchbase_StringEndsWith_ProducesLikeWithLeadingWildcard() + { + Expression> expr = x => x.Name.EndsWith("ce"); + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Name` LIKE $p0"); + f.Parameters["$p0"].Should().Be("%ce"); + } + + // ── Membership ──────────────────────────────────────────────────────────── + + [Fact] + public void ToCouchbase_ListContains_ProducesInClauseWithArrayParameter() + { + var ids = new List { 1, 2, 3 }; + Expression> expr = x => ids.Contains(x.Id); + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("`Id` IN $p0"); + f.Parameters["$p0"].Should().BeEquivalentTo(new List { 1, 2, 3 }); + } + + [Fact] + public void ToCouchbase_EmptyListContains_ThrowsInvalidOperationException() + { + var ids = new List(); + Expression> expr = x => ids.Contains(x.Id); + + Action act = () => expr.ToCouchbase(); + + act.Should().Throw().WithMessage("*empty*"); + } + + [Fact] + public void ToCouchbase_ListWithMoreThan10000Items_ThrowsInvalidOperationException() + { + var ids = Enumerable.Range(1, 10_001).Cast().ToList(); + var node = new InNode("Id", ids); + + Action act = () => CouchbaseFilterTranslator.Translate(node); + + act.Should().Throw().WithMessage("*10000*"); + } + + [Fact] + public void ToCouchbase_ListWithExactly10000Items_DoesNotThrow() + { + var ids = Enumerable.Range(1, 10_000).Cast().ToList(); + var node = new InNode("Id", ids); + + Action act = () => CouchbaseFilterTranslator.Translate(node); + + act.Should().NotThrow(); + } + + // ── Logical combinators ─────────────────────────────────────────────────── + + [Fact] + public void ToCouchbase_AndAlso_ProducesAndClause() + { + Expression> expr = x => x.IsActive && x.Age > 18; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("(`IsActive` = $p0 AND `Age` > $p1)"); + } + + [Fact] + public void ToCouchbase_OrElse_ProducesOrClause() + { + Expression> expr = x => x.Name == "Alice" || x.Name == "Bob"; + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("(`Name` = $p0 OR `Name` = $p1)"); + } + + [Fact] + public void ToCouchbase_Not_ProducesNotClause() + { + Expression> expr = x => !(x.Age > 18); + + CouchbaseFilterExpression f = expr.ToCouchbase(); + + f.WhereClause.Should().Be("NOT (`Age` > $p0)"); + } + + [Fact] + public void ToCouchbase_NestedAndOr_ProducesCorrectParentheses() + { + var a = new EqualNode("IsActive", true, false); + var b = new ComparisonNode("Age", 18, ComparisonOp.GreaterThan); + var c = new EqualNode("Name", "Admin", false); + var node = new OrNode(new AndNode(a, b), c); + + CouchbaseFilterExpression f = CouchbaseFilterTranslator.Translate(node); + + f.WhereClause.Should().Be("((`IsActive` = $p0 AND `Age` > $p1) OR `Name` = $p2)"); + } + + // ── ValiFlow fluent pipeline ─────────────────────────────────────────── + + [Fact] + public void ToCouchbase_ValiFlowEqualTo_ProducesEqualClause() + { + var flow = new ValiFlow().EqualTo(x => x.Category, "Fruit"); + + CouchbaseFilterExpression f = flow.ToCouchbase(); + + f.WhereClause.Should().Be("`Category` = $p0"); + f.Parameters["$p0"].Should().Be("Fruit"); + } + + [Fact] + public void ToCouchbase_ValiFlowCombined_ProducesAndClause() + { + var flow = new ValiFlow() + .EqualTo(x => x.IsActive, true) + .GreaterThan(x => x.Age, 18); + + CouchbaseFilterExpression f = flow.ToCouchbase(); + + f.WhereClause.Should().Be("(`IsActive` = $p0 AND `Age` > $p1)"); + } + + // ── Null guards ─────────────────────────────────────────────────────────── + + [Fact] + public void Translate_NullNode_ThrowsArgumentNullException() + { + Action act = () => CouchbaseFilterTranslator.Translate(null!); + + act.Should().Throw(); + } + + [Fact] + public void ToCouchbase_ValiFlowNull_ThrowsArgumentNullException() + { + ValiFlow flow = null!; + + Action act = () => flow.ToCouchbase(); + + act.Should().Throw(); + } + + [Fact] + public void ToCouchbase_ExpressionNull_ThrowsArgumentNullException() + { + Expression> expr = null!; + + Action act = () => expr.ToCouchbase(); + + act.Should().Throw(); + } + + // ── OCP — custom converter ──────────────────────────────────────────────── + + [Fact] + public void CustomConverter_WhenSet_UsedForMatchingType() + { + Func converter = v => v is decimal d ? $"DEC:{d}" : null; + + var node = new EqualNode("Price", 9.99m, false); + CouchbaseFilterExpression f = CouchbaseFilterTranslator.Translate(node, converter); + + f.Parameters["$p0"].Should().Be("DEC:9.99"); + } + + [Fact] + public void CustomConverter_WhenReturnsNull_FallsThroughToDefault() + { + Func converter = _ => null; + + var node = new EqualNode("Name", "Alice", false); + CouchbaseFilterExpression f = CouchbaseFilterTranslator.Translate(node, converter); + + f.Parameters["$p0"].Should().Be("Alice"); + } + + // ── Type coverage ───────────────────────────────────────────────────────── + + [Fact] + public void ToCouchbase_DecimalValue_ProducesStringParameter() + { + var node = new EqualNode("Price", 9.99m, false); + + CouchbaseFilterExpression f = CouchbaseFilterTranslator.Translate(node); + + f.Parameters["$p0"].Should().Be("9.99"); + } + + [Fact] + public void ToCouchbase_GuidValue_ProducesStringParameter() + { + var guid = Guid.NewGuid(); + var node = new EqualNode("Id", guid, false); + + CouchbaseFilterExpression f = CouchbaseFilterTranslator.Translate(node); + + f.Parameters["$p0"].Should().Be(guid.ToString()); + } + + [Fact] + public void ToCouchbase_EnumValue_ProducesLongParameter() + { + var node = new EqualNode("Status", CouchbaseTestStatus.Active, false); + + CouchbaseFilterExpression f = CouchbaseFilterTranslator.Translate(node); + + f.Parameters["$p0"].Should().Be(1L); + } + + [Fact] + public void ToCouchbase_FallbackTypeObject_ProducesStringParameter() + { + var node = new EqualNode("Misc", new Uri("https://example.com"), false); + + CouchbaseFilterExpression f = CouchbaseFilterTranslator.Translate(node); + + f.Parameters["$p0"].Should().Be("https://example.com/"); + } + + private enum CouchbaseTestStatus { Active = 1 } +} diff --git a/Vali-Flow.NoSql.Couchbase.Tests/Models/TestDocument.cs b/Vali-Flow.NoSql.Couchbase.Tests/Models/TestDocument.cs new file mode 100644 index 0000000..4768389 --- /dev/null +++ b/Vali-Flow.NoSql.Couchbase.Tests/Models/TestDocument.cs @@ -0,0 +1,12 @@ +namespace Vali_Flow.NoSql.Couchbase.Tests.Models; + +public sealed class TestDocument +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Email { get; set; } + public int Age { get; set; } + public decimal Price { get; set; } + public bool IsActive { get; set; } + public string Category { get; set; } = string.Empty; +} diff --git a/Vali-Flow.NoSql.Couchbase.Tests/Vali-Flow.NoSql.Couchbase.Tests.csproj b/Vali-Flow.NoSql.Couchbase.Tests/Vali-Flow.NoSql.Couchbase.Tests.csproj new file mode 100644 index 0000000..8095a9c --- /dev/null +++ b/Vali-Flow.NoSql.Couchbase.Tests/Vali-Flow.NoSql.Couchbase.Tests.csproj @@ -0,0 +1,29 @@ + + + net9.0 + enable + enable + false + Vali_Flow.NoSql.Couchbase.Tests + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/Vali-Flow.NoSql.Couchbase/Extensions/ValiFlowCouchbaseExtensions.cs b/Vali-Flow.NoSql.Couchbase/Extensions/ValiFlowCouchbaseExtensions.cs new file mode 100644 index 0000000..6d77e3f --- /dev/null +++ b/Vali-Flow.NoSql.Couchbase/Extensions/ValiFlowCouchbaseExtensions.cs @@ -0,0 +1,58 @@ +using System.Linq.Expressions; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Couchbase.Models; +using Vali_Flow.NoSql.Couchbase.Translators; +using Vali_Flow.NoSql.Extensions; + +namespace Vali_Flow.NoSql.Couchbase.Extensions; + +/// +/// Extension methods that add Couchbase N1QL (SQL++) WHERE-clause-building capabilities +/// to . +/// +/// +/// The returned encapsulates the WHERE clause fragment +/// and named parameter dictionary for use with a N1QL query. This package depends only on +/// Vali-Flow.NoSql — it is a query builder with no Couchbase SDK or connection concerns. +/// +public static class ValiFlowCouchbaseExtensions +{ + /// + /// Translates the conditions built in this instance + /// into a Couchbase . + /// + /// The entity / document type. + /// The ValiFlow builder containing the conditions. + /// Optional override to convert a value into the N1QL parameter value for types the default conversion doesn't handle. + /// A ready to bind to a N1QL query. + /// + /// + /// var filter = new ValiFlow<Order>() + /// .EqualTo(x => x.Status, "Active") + /// .GreaterThan(x => x.Total, 100m); + /// + /// CouchbaseFilterExpression f = filter.ToCouchbase(); + /// var query = $"SELECT * FROM `orders` WHERE {f.WhereClause}"; + /// var options = new QueryOptions(); + /// foreach (var (name, value) in f.Parameters) + /// options.Parameter(name, value); + /// + /// + public static CouchbaseFilterExpression ToCouchbase(this ValiFlow flow, Func? customConverter = null, string? tag = null) where T : class + { + if (flow == null) throw new ArgumentNullException(nameof(flow)); + + return CouchbaseFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter, tag, typeof(T).Name); + } + + /// + /// Translates a prebuilt into a Couchbase . + /// Use this overload when you already have a compiled expression. + /// + public static CouchbaseFilterExpression ToCouchbase(this Expression> expression, Func? customConverter = null, string? tag = null) + { + if (expression == null) throw new ArgumentNullException(nameof(expression)); + + return CouchbaseFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter, tag, typeof(T).Name); + } +} diff --git a/Vali-Flow.NoSql.Couchbase/Models/CouchbaseFilterExpression.cs b/Vali-Flow.NoSql.Couchbase/Models/CouchbaseFilterExpression.cs new file mode 100644 index 0000000..021534c --- /dev/null +++ b/Vali-Flow.NoSql.Couchbase/Models/CouchbaseFilterExpression.cs @@ -0,0 +1,34 @@ +namespace Vali_Flow.NoSql.Couchbase.Models; + +/// +/// Encapsulates the result of translating a Vali-Flow filter into a Couchbase N1QL (SQL++) WHERE clause. +/// +/// +/// Couchbase has no "native" filter object like MongoDB's BsonDocument or DynamoDB's AttributeValue — +/// N1QL is plain parameterized text, the same approach as SQL. Combine with +/// a full N1QL statement and bind as named parameters ($p0, $p1, ...): +/// +/// var filter = flow.ToCouchbase(); +/// var query = $"SELECT * FROM `bucket` WHERE {filter.WhereClause}"; +/// var options = new QueryOptions(); +/// foreach (var (name, value) in filter.Parameters) +/// options.Parameter(name, value); +/// +/// +public sealed class CouchbaseFilterExpression +{ + /// + /// The N1QL WHERE clause fragment (without the leading WHERE keyword), + /// e.g. "`Name` = $p0". + /// + public string WhereClause { get; } + + /// Named N1QL parameters ($p0, $p1, ...) to bind to the query. + public IReadOnlyDictionary Parameters { get; } + + internal CouchbaseFilterExpression(string whereClause, Dictionary parameters) + { + WhereClause = whereClause; + Parameters = parameters.AsReadOnly(); + } +} diff --git a/Vali-Flow.NoSql.Couchbase/README.md b/Vali-Flow.NoSql.Couchbase/README.md new file mode 100644 index 0000000..edae4c8 --- /dev/null +++ b/Vali-Flow.NoSql.Couchbase/README.md @@ -0,0 +1,48 @@ +# Vali-Flow.NoSql.Couchbase + +Couchbase N1QL (SQL++) WHERE-clause builder that translates `ValiFlow` filters into a parameterized `CouchbaseFilterExpression`. + +See the [main README](../../README.md) for complete documentation and examples. + +## Installation + +```bash +dotnet add package Vali-Flow.NoSql.Couchbase +``` + +## Quick Example + +```csharp +using Vali_Flow.NoSql.Couchbase.Extensions; + +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Active") + .GreaterThan(x => x.Total, 100m); + +CouchbaseFilterExpression f = filter.ToCouchbase(); + +var query = $"SELECT * FROM `orders` WHERE {f.WhereClause}"; +var options = new QueryOptions(); +foreach (var (name, value) in f.Parameters) + options.Parameter(name, value); + +var result = await cluster.QueryAsync(query, options); +``` + +## Features + +- Translates `ValiFlow` expressions to a N1QL WHERE clause fragment +- Named parameters (`$p0`, `$p1`, …) instead of inline literals +- Backtick-quoted identifiers (`` `field` ``), N1QL/MySQL convention +- `LIKE`-based `Contains` / `StartsWith` / `EndsWith` — all three supported +- No Couchbase SDK dependency — pure text + parameter builder + +## Limitations + +- No Couchbase SDK dependency: this package only produces `WhereClause` + `Parameters`. Executing the query (via `ICluster.QueryAsync`, `IBucket`, etc.) is entirely the consumer's responsibility. +- Empty `In` list throws `InvalidOperationException` — filter the empty case before building the query. +- `decimal` values are bound as their invariant-culture string representation, not a native numeric parameter. + +## License + +MIT diff --git a/Vali-Flow.NoSql.Couchbase/Translators/CouchbaseFilterTranslator.cs b/Vali-Flow.NoSql.Couchbase/Translators/CouchbaseFilterTranslator.cs new file mode 100644 index 0000000..6aeef15 --- /dev/null +++ b/Vali-Flow.NoSql.Couchbase/Translators/CouchbaseFilterTranslator.cs @@ -0,0 +1,152 @@ +using System.Globalization; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.NoSql.Couchbase.Models; +using Vali_Flow.NoSql.IR; +using Vali_Flow.NoSql.Translators; + +namespace Vali_Flow.NoSql.Couchbase.Translators; + +/// +/// Translates a IR tree into a Couchbase N1QL (SQL++) +/// . +/// +/// +/// N1QL is plain parameterized text (like SQL), so — unlike Mongo/Elasticsearch/Dynamo — +/// there is no "native" SDK filter object to build; the output is a WHERE clause fragment +/// plus a named-parameter dictionary ($p0, $p1, ...). +/// Identifiers are backtick-quoted (`field`), the same convention N1QL and MySQL share. +/// +public static class CouchbaseFilterTranslator +{ + // The IN values are bound as a single array parameter (not expanded per-value), so the real + // constraint is the max N1QL request size. No official per-element count limit is documented + // by Couchbase — this is a conservative practical cap used as a guardrail. + private const int MaxInValues = 10_000; + + /// + /// Translates the given into a . + /// + /// The root condition node to translate. + /// + /// Optional hook to convert a CLR value into the value bound as an N1QL parameter. + /// Called before the built-in type switch. Return null to fall through to the default conversion. + /// + /// A ready to bind to a N1QL query. + public static CouchbaseFilterExpression Translate(IConditionNode node, Func? customConverter = null, string? tag = null, string? entityType = null) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.NoSql.Couchbase.Translate", tag, entityType); + try + { + var ctx = new TranslationContext(); + var visitor = new CouchbaseVisitor(ctx, customConverter); + var clause = node.Accept(visitor); + return new CouchbaseFilterExpression(clause, ctx.Parameters); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + private sealed class CouchbaseVisitor(TranslationContext ctx, Func? customConverter) + : IConditionNodeVisitor + { + public string VisitAnd(AndNode node) => + $"({node.Left.Accept(this)} AND {node.Right.Accept(this)})"; + + public string VisitOr(OrNode node) => + $"({node.Left.Accept(this)} OR {node.Right.Accept(this)})"; + + public string VisitNot(NotNode node) => + $"NOT ({node.Inner.Accept(this)})"; + + public string VisitNull(NullNode node) => + node.Check == NullCheckOp.IsNull + ? $"{Quote(node.Field)} IS NULL" + : $"{Quote(node.Field)} IS NOT NULL"; + + public string VisitEqual(EqualNode node) => + node.IsNegated + ? $"{Quote(node.Field)} <> {ctx.AddValue(ResolveValue(node.Value))}" + : $"{Quote(node.Field)} = {ctx.AddValue(ResolveValue(node.Value))}"; + + public string VisitComparison(ComparisonNode node) => + $"{Quote(node.Field)} {MapComparisonOp(node.Op)} {ctx.AddValue(ResolveValue(node.Value))}"; + + public string VisitLike(LikeNode node) + { + var pattern = node.Op switch + { + LikeOp.Contains => $"%{node.Pattern}%", + LikeOp.StartsWith => $"{node.Pattern}%", + LikeOp.EndsWith => $"%{node.Pattern}", + _ => throw new NotSupportedException($"LikeOp.{node.Op} is not mapped.") + }; + return $"{Quote(node.Field)} LIKE {ctx.AddValue(pattern)}"; + } + + public string VisitIn(InNode node) + { + if (node.Values.Count == 0) + throw new InvalidOperationException( + "IN condition with empty list is not supported in Couchbase N1QL expressions. Filter the empty case before building the query."); + + if (node.Values.Count > MaxInValues) + throw new InvalidOperationException( + $"Couchbase N1QL IN expression supports at most {MaxInValues} values (practical limit, " + + $"bounded by the max N1QL request size); received {node.Values.Count}. " + + "Split the query or batch the values."); + + var values = node.Values.Select(ResolveValue).ToList(); + return $"{Quote(node.Field)} IN {ctx.AddValue(values)}"; + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private static string MapComparisonOp(ComparisonOp op) => op switch + { + ComparisonOp.GreaterThan => ">", + ComparisonOp.GreaterThanOrEqual => ">=", + ComparisonOp.LessThan => "<", + ComparisonOp.LessThanOrEqual => "<=", + _ => throw new NotSupportedException($"ComparisonOp.{op} is not mapped.") + }; + + private static string Quote(string field) => $"`{field}`"; + + private object? ResolveValue(object? value) => + ConditionValueResolver.Resolve(value, customConverter, v => v switch + { + null => null, + bool b => b, + string s => s, + int i => i, + long l => l, + double d => d, + float f => (double)f, + decimal m => m.ToString(CultureInfo.InvariantCulture), + Guid g => g.ToString(), + Enum e => Convert.ToInt64(e), + _ => v!.ToString()! + }); + } + + // ── Private translation context (NOT part of public API) ───────────────── + + private sealed class TranslationContext + { + private int _paramCounter; + + public Dictionary Parameters { get; } = new(); + + public string AddValue(object? value) + { + var placeholder = $"$p{_paramCounter++}"; + Parameters[placeholder] = value; + return placeholder; + } + } +} diff --git a/Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj b/Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj new file mode 100644 index 0000000..197c3d0 --- /dev/null +++ b/Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj @@ -0,0 +1,47 @@ + + + + net8.0;net9.0 + enable + enable + Vali_Flow.NoSql.Couchbase + Vali-Flow.NoSql.Couchbase + 1.0.0 + Felipe Rafael Montenegro Morriberon + Vali-Flow.NoSql.Couchbase + Couchbase adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into a parameterized N1QL (SQL++) WHERE clause fragment. Use .ToCouchbase() on any ValiFlow<T> or Expression<Func<T, bool>>. + Copyright © 2026 - FM. Todos los derechos reservados. + MIT + true + https://vali-flow-docs.netlify.app/docs/adapters/nosql/couchbase/overview + https://github.com/UBF21/vali-flow + git + VF_logo.png + README.md + vali;vali-flow;couchbase;n1ql;sqlpp;filter;nosql;dotnet + v1.0.0 — Initial release. N1QL (SQL++) WHERE clause translator. + true + true + true + embedded + true + snupkg + + + + + True + + + + True + + + + + + + + + + diff --git a/Vali-Flow.NoSql.Firestore.Tests/Extensions/ValiFlowFirestoreExtensionsTests.cs b/Vali-Flow.NoSql.Firestore.Tests/Extensions/ValiFlowFirestoreExtensionsTests.cs new file mode 100644 index 0000000..614fd13 --- /dev/null +++ b/Vali-Flow.NoSql.Firestore.Tests/Extensions/ValiFlowFirestoreExtensionsTests.cs @@ -0,0 +1,72 @@ +using System.Linq.Expressions; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Firestore.Extensions; +using Vali_Flow.NoSql.Firestore.Tests.Models; + +namespace Vali_Flow.NoSql.Firestore.Tests.Extensions; + +/// Tests for . +public sealed class ValiFlowFirestoreExtensionsTests +{ + [Fact] + public void ToFirestore_ValiFlow_SimpleCondition_ReturnsFilter() + { + var flow = new ValiFlow().EqualTo(x => x.IsActive, true); + + var filter = flow.ToFirestore(); + + filter.Should().NotBeNull(); + } + + [Fact] + public void ToFirestore_ValiFlow_CombinedConditions_ReturnsFilter() + { + var flow = new ValiFlow() + .EqualTo(x => x.IsActive, true) + .GreaterThan(x => x.Age, 18); + + var filter = flow.ToFirestore(); + + filter.Should().NotBeNull(); + } + + [Fact] + public void ToFirestore_NullValiFlow_ThrowsArgumentNullException() + { + ValiFlow? flow = null; + + Action act = () => flow!.ToFirestore(); + + act.Should().Throw().WithParameterName("flow"); + } + + [Fact] + public void ToFirestore_Expression_ReturnsFilter() + { + Expression> expression = x => x.Age > 18; + + var filter = expression.ToFirestore(); + + filter.Should().NotBeNull(); + } + + [Fact] + public void ToFirestore_NullExpression_ThrowsArgumentNullException() + { + Expression>? expression = null; + + Action act = () => expression!.ToFirestore(); + + act.Should().Throw().WithParameterName("expression"); + } + + [Fact] + public void ToFirestore_CustomConverter_IsForwardedToTranslator() + { + var flow = new ValiFlow().EqualTo(x => x.Price, 9.99m); + + var filter = flow.ToFirestore(v => v is decimal d ? (double)d : null); + + filter.Should().NotBeNull(); + } +} diff --git a/Vali-Flow.NoSql.Firestore.Tests/Models/TestDocument.cs b/Vali-Flow.NoSql.Firestore.Tests/Models/TestDocument.cs new file mode 100644 index 0000000..7aac97f --- /dev/null +++ b/Vali-Flow.NoSql.Firestore.Tests/Models/TestDocument.cs @@ -0,0 +1,12 @@ +namespace Vali_Flow.NoSql.Firestore.Tests.Models; + +public sealed class TestDocument +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Email { get; set; } + public int Age { get; set; } + public decimal Price { get; set; } + public bool IsActive { get; set; } + public string Category { get; set; } = string.Empty; +} diff --git a/Vali-Flow.NoSql.Firestore.Tests/Translators/FirestoreFilterTranslatorTests.cs b/Vali-Flow.NoSql.Firestore.Tests/Translators/FirestoreFilterTranslatorTests.cs new file mode 100644 index 0000000..b65ec15 --- /dev/null +++ b/Vali-Flow.NoSql.Firestore.Tests/Translators/FirestoreFilterTranslatorTests.cs @@ -0,0 +1,185 @@ +using System.Diagnostics; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Firestore.Extensions; +using Vali_Flow.NoSql.Firestore.Tests.Models; +using Vali_Flow.NoSql.Firestore.Translators; +using Vali_Flow.NoSql.IR; + +namespace Vali_Flow.NoSql.Firestore.Tests.Translators; + +public class FirestoreFilterTranslatorTests +{ + private static ActivityListener AttachListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public void ToFirestore_WithTag_SetsTagAndEntityTypeOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var flow = new ValiFlow().EqualTo(x => x.Name, "Alice"); + + flow.ToFirestore(tag: "report-x"); + + captured.Should().NotBeNull(); + captured!.GetTagItem("vali_flow.tag").Should().Be("report-x"); + captured.GetTagItem("vali_flow.entity_type").Should().Be(nameof(TestDocument)); + } + + [Fact] + public void Translate_LikeNode_SetsErrorStatusOnActivity() + { + using var listener = AttachListener(); + Activity? captured = null; + listener.ActivityStopped = a => captured = a; + + var node = new LikeNode("Name", "son", LikeOp.Contains); + + Action act = () => FirestoreFilterTranslator.Translate(node); + + act.Should().Throw(); + captured.Should().NotBeNull(); + captured!.Status.Should().Be(ActivityStatusCode.Error); + } + + [Fact] + public void Translate_NullNode_ThrowsArgumentNullException() + { + Action act = () => FirestoreFilterTranslator.Translate(null!); + act.Should().Throw(); + } + + [Fact] + public void VisitEqual_NotNegated_ReturnsEqualToFilter() + { + var node = new EqualNode("Name", "John", isNegated: false); + + var filter = FirestoreFilterTranslator.Translate(node); + + filter.Should().NotBeNull(); + } + + [Fact] + public void VisitEqual_Negated_ReturnsNotEqualToFilter() + { + var node = new EqualNode("Name", "John", isNegated: true); + + var filter = FirestoreFilterTranslator.Translate(node); + + filter.Should().NotBeNull(); + } + + [Theory] + [InlineData(ComparisonOp.GreaterThan)] + [InlineData(ComparisonOp.GreaterThanOrEqual)] + [InlineData(ComparisonOp.LessThan)] + [InlineData(ComparisonOp.LessThanOrEqual)] + public void VisitComparison_SupportedOps_ReturnsFilter(ComparisonOp op) + { + var node = new ComparisonNode("Age", 18, op); + + var filter = FirestoreFilterTranslator.Translate(node); + + filter.Should().NotBeNull(); + } + + [Fact] + public void VisitIn_ReturnsInArrayFilter() + { + var node = new InNode("Category", new List { "a", "b", "c" }); + + var filter = FirestoreFilterTranslator.Translate(node); + + filter.Should().NotBeNull(); + } + + [Fact] + public void VisitNull_IsNull_ReturnsEqualToNullFilter() + { + var node = new NullNode("Email", NullCheckOp.IsNull); + + var filter = FirestoreFilterTranslator.Translate(node); + + filter.Should().NotBeNull(); + } + + [Fact] + public void VisitNull_IsNotNull_ReturnsNotEqualToNullFilter() + { + var node = new NullNode("Email", NullCheckOp.IsNotNull); + + var filter = FirestoreFilterTranslator.Translate(node); + + filter.Should().NotBeNull(); + } + + [Fact] + public void VisitAnd_ReturnsCombinedFilter() + { + var node = new AndNode( + new EqualNode("IsActive", true, isNegated: false), + new ComparisonNode("Age", 18, ComparisonOp.GreaterThan)); + + var filter = FirestoreFilterTranslator.Translate(node); + + filter.Should().NotBeNull(); + } + + [Fact] + public void VisitOr_ReturnsCombinedFilter() + { + var node = new OrNode( + new EqualNode("Category", "a", isNegated: false), + new EqualNode("Category", "b", isNegated: false)); + + var filter = FirestoreFilterTranslator.Translate(node); + + filter.Should().NotBeNull(); + } + + [Theory] + [InlineData(LikeOp.Contains)] + [InlineData(LikeOp.StartsWith)] + [InlineData(LikeOp.EndsWith)] + public void VisitLike_AnyOp_ThrowsNotSupportedException(LikeOp op) + { + var node = new LikeNode("Name", "jo", op, CaseSensitive: true); + + Action act = () => FirestoreFilterTranslator.Translate(node); + + act.Should().Throw() + .WithMessage("*pattern-matching*"); + } + + [Fact] + public void VisitNot_ThrowsNotSupportedException() + { + var node = new NotNode(new EqualNode("IsActive", true, isNegated: false)); + + Action act = () => FirestoreFilterTranslator.Translate(node); + + act.Should().Throw() + .WithMessage("*NOT filter*"); + } + + [Fact] + public void Translate_CustomConverter_IsUsedBeforeFallback() + { + var node = new EqualNode("Price", 42m, isNegated: false); + + var filter = FirestoreFilterTranslator.Translate(node, v => v is decimal d ? (double)d : null); + + filter.Should().NotBeNull(); + } +} diff --git a/Vali-Flow.NoSql.Firestore.Tests/Vali-Flow.NoSql.Firestore.Tests.csproj b/Vali-Flow.NoSql.Firestore.Tests/Vali-Flow.NoSql.Firestore.Tests.csproj new file mode 100644 index 0000000..bb237d6 --- /dev/null +++ b/Vali-Flow.NoSql.Firestore.Tests/Vali-Flow.NoSql.Firestore.Tests.csproj @@ -0,0 +1,29 @@ + + + net9.0 + enable + enable + false + Vali_Flow.NoSql.Firestore.Tests + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/Vali-Flow.NoSql.Firestore/Extensions/ValiFlowFirestoreExtensions.cs b/Vali-Flow.NoSql.Firestore/Extensions/ValiFlowFirestoreExtensions.cs new file mode 100644 index 0000000..8d9c3a0 --- /dev/null +++ b/Vali-Flow.NoSql.Firestore/Extensions/ValiFlowFirestoreExtensions.cs @@ -0,0 +1,55 @@ +using System.Linq.Expressions; +using Google.Cloud.Firestore; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Extensions; +using Vali_Flow.NoSql.Firestore.Translators; + +namespace Vali_Flow.NoSql.Firestore.Extensions; + +/// +/// Extension methods that add Google Cloud Firestore query-building capabilities to . +/// +/// +/// The returned is the native Firestore SDK type — pass it directly to +/// CollectionReference.Where(filter) or Query.Where(filter). +/// +public static class ValiFlowFirestoreExtensions +{ + /// + /// Translates the conditions built in this instance + /// into a native Firestore . + /// + /// The entity / document type. + /// The ValiFlow builder containing the conditions. + /// + /// Optional override to convert a value into one Firestore accepts, for types the default conversion doesn't handle. + /// + /// A ready to pass to Query.Where(filter). + /// + /// + /// var filter = new ValiFlow<User>() + /// .EqualTo(x => x.IsActive, true) + /// .GreaterThan(x => x.Age, 18); + /// + /// Filter firestoreFilter = filter.ToFirestore(); + /// var snapshot = await collection.Where(firestoreFilter).GetSnapshotAsync(); + /// + /// + public static Filter ToFirestore(this ValiFlow flow, Func? customConverter = null, string? tag = null) where T : class + { + if (flow == null) throw new ArgumentNullException(nameof(flow)); + + return FirestoreFilterTranslator.Translate(flow.ToNoSqlIR(), customConverter, tag, typeof(T).Name); + } + + /// + /// Translates a prebuilt into a native Firestore . + /// Use this overload when you already have a compiled expression. + /// + public static Filter ToFirestore(this Expression> expression, Func? customConverter = null, string? tag = null) + { + if (expression == null) throw new ArgumentNullException(nameof(expression)); + + return FirestoreFilterTranslator.Translate(expression.ToNoSqlIR(), customConverter, tag, typeof(T).Name); + } +} diff --git a/Vali-Flow.NoSql.Firestore/README.md b/Vali-Flow.NoSql.Firestore/README.md new file mode 100644 index 0000000..ad10178 --- /dev/null +++ b/Vali-Flow.NoSql.Firestore/README.md @@ -0,0 +1,45 @@ +# Vali-Flow.NoSql.Firestore + +Google Cloud Firestore adapter that translates `ValiFlow` filters into a native `Google.Cloud.Firestore.Filter`. + +See the [main README](../../README.md) for complete documentation and examples. + +## Installation + +```bash +dotnet add package Vali-Flow.NoSql.Firestore +``` + +## Quick Example + +```csharp +using Vali_Flow.NoSql.Firestore.Extensions; + +var filter = new ValiFlow() + .EqualTo(x => x.IsActive, true) + .GreaterThan(x => x.Age, 18); + +Filter firestoreFilter = filter.ToFirestore(); + +CollectionReference collection = firestoreDb.Collection("users"); +Query query = collection.Where(firestoreFilter); +var snapshot = await query.GetSnapshotAsync(); +``` + +## Features + +- Translates `ValiFlow` expressions to a native `Filter` (the real SDK type, not a wrapper) +- Depends on the official `Google.Cloud.Firestore` 4.4.0 SDK +- Custom value converters for complex types +- `AndNode`/`OrNode`/`EqualNode`/`ComparisonNode`/`InNode`/`NullNode` all supported + +## Limitations + +- **No pattern matching.** `Contains`/`StartsWith`/`EndsWith` (`LikeNode`) throw `NotSupportedException` — Firestore has no LIKE/regex query operator. +- **No generic NOT.** `.Not(inner)` (`NotNode`) throws `NotSupportedException` — the SDK exposes no "negate this filter" composition. Only field-level negation via `.NotEqualTo(...)` (`EqualNode.IsNegated`) is supported. + +See the [full reference](../../docs/en/vali-flow-nosql-firestore.md) for exact exception messages and workarounds. + +## License + +MIT diff --git a/Vali-Flow.NoSql.Firestore/Translators/FirestoreFilterTranslator.cs b/Vali-Flow.NoSql.Firestore/Translators/FirestoreFilterTranslator.cs new file mode 100644 index 0000000..d880e22 --- /dev/null +++ b/Vali-Flow.NoSql.Firestore/Translators/FirestoreFilterTranslator.cs @@ -0,0 +1,104 @@ +using Google.Cloud.Firestore; +using Vali_Flow.Abstractions.Diagnostics; +using Vali_Flow.NoSql.IR; +using Vali_Flow.NoSql.Translators; + +namespace Vali_Flow.NoSql.Firestore.Translators; + +/// +/// Translates a IR tree into a native Google Cloud Firestore . +/// +/// +/// The output is accepted directly by Query.Where(filter) — no wrapping needed. +/// +/// Limitations (confirmed against Google.Cloud.Firestore 4.4.0): +/// +/// (Contains/StartsWith/EndsWith) is not supported — Firestore has no +/// pattern-matching/regex query operator. +/// is not supported — the SDK exposes no generic "NOT filter" composition; +/// only field-level negations (NotEqualTo, NotInArray) exist, which are already handled by +/// and would need to be expressed at the node level. +/// +/// +/// +public static class FirestoreFilterTranslator +{ + /// + /// Translates the given into a native Firestore . + /// + /// The root condition node to translate. + /// + /// Optional hook for converting custom CLR types into a value Firestore accepts. + /// Called before the built-in type switch. Return null to fall through to the default conversion. + /// Thread-safe: the converter is scoped to this call only. + /// + /// A ready to pass to Query.Where(filter). + public static Filter Translate(IConditionNode node, Func? customConverter = null, string? tag = null, string? entityType = null) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + using var activity = ValiFlowDiagnostics.StartActivity("Vali-Flow.NoSql.Firestore.Translate", tag, entityType); + try + { + return node.Accept(new FirestoreVisitor(customConverter)); + } + catch (Exception ex) + { + ValiFlowDiagnostics.RecordException(activity, ex); + throw; + } + } + + private sealed class FirestoreVisitor(Func? customConverter) : IConditionNodeVisitor + { + public Filter VisitAnd(AndNode node) => + Filter.And(node.Left.Accept(this), node.Right.Accept(this)); + + public Filter VisitOr(OrNode node) => + Filter.Or(node.Left.Accept(this), node.Right.Accept(this)); + + public Filter VisitNot(NotNode node) => + throw new NotSupportedException( + "Firestore does not support a generic NOT filter over an arbitrary sub-expression. " + + "The SDK only exposes field-level negations (NotEqualTo/NotInArray) via Filter, " + + "which are already handled when the negated node is an EqualNode/InNode directly."); + + public Filter VisitEqual(EqualNode node) => + node.IsNegated + ? Filter.NotEqualTo(node.Field, ToValue(node.Value)) + : Filter.EqualTo(node.Field, ToValue(node.Value)); + + public Filter VisitComparison(ComparisonNode node) => node.Op switch + { + ComparisonOp.GreaterThan => Filter.GreaterThan(node.Field, ToValue(node.Value)), + ComparisonOp.GreaterThanOrEqual => Filter.GreaterThanOrEqualTo(node.Field, ToValue(node.Value)), + ComparisonOp.LessThan => Filter.LessThan(node.Field, ToValue(node.Value)), + ComparisonOp.LessThanOrEqual => Filter.LessThanOrEqualTo(node.Field, ToValue(node.Value)), + _ => throw new NotSupportedException($"ComparisonOp.{node.Op} is not mapped.") + }; + + public Filter VisitLike(LikeNode node) => throw new NotSupportedException( + $"Firestore does not support pattern-matching queries (LikeOp.{node.Op}). " + + "There is no regex/LIKE query operator — filter client-side or use a dedicated search index (e.g. Algolia/Elasticsearch) instead."); + + public Filter VisitIn(InNode node) => + Filter.InArray(node.Field, node.Values.Select(ToValue).ToArray()); + + public Filter VisitNull(NullNode node) => + node.Check == NullCheckOp.IsNull + ? Filter.EqualTo(node.Field, null) + : Filter.NotEqualTo(node.Field, null); + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private object? ToValue(object? value) => + ConditionValueResolver.Resolve(value, customConverter, v => v switch + { + null => null, + Enum e => Convert.ToInt32(e), + DateTimeOffset dto => Timestamp.FromDateTimeOffset(dto), + DateTime dt => Timestamp.FromDateTime(dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime()), + _ => v + }); + } +} diff --git a/Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj b/Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj new file mode 100644 index 0000000..765409f --- /dev/null +++ b/Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj @@ -0,0 +1,48 @@ + + + + net8.0;net9.0 + enable + enable + Vali_Flow.NoSql.Firestore + Vali-Flow.NoSql.Firestore + 1.0.0 + Felipe Rafael Montenegro Morriberon + Vali-Flow.NoSql.Firestore + Google Cloud Firestore adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into a native Google.Cloud.Firestore.Filter. Use .ToFirestore() on any ValiFlow<T> or Expression<Func<T, bool>>. + Copyright © 2026 - FM. Todos los derechos reservados. + MIT + true + https://vali-flow-docs.netlify.app/docs/adapters/nosql/firestore/overview + https://github.com/UBF21/vali-flow + git + VF_logo.png + README.md + vali;vali-flow;firestore;google-cloud;filter;nosql;dotnet + v1.0.0 — Initial release. Firestore native Filter translator. + true + true + true + embedded + true + snupkg + + + + + True + + + + True + + + + + + + + + + + diff --git a/docs/en/vali-flow-nosql-cosmosdb.md b/docs/en/vali-flow-nosql-cosmosdb.md new file mode 100644 index 0000000..5c006de --- /dev/null +++ b/docs/en/vali-flow-nosql-cosmosdb.md @@ -0,0 +1,261 @@ +# Vali-Flow.NoSql.CosmosDb — Complete Reference + +## Table of Contents + +1. [Overview](#overview) +2. [Installation](#installation) +3. [Quick Start](#quick-start) +4. [Extension Methods](#extension-methods) +5. [CosmosFilterExpression](#cosmosfilterexpression) +6. [Supported Operations](#supported-operations) +7. [Type Mapping](#type-mapping) +8. [Custom Value Converter](#custom-value-converter) +9. [Limitations](#limitations) +10. [Full Example](#full-example) + +--- + +## Overview + +**Vali-Flow.NoSql.CosmosDb** translates a `ValiFlow` expression tree into an Azure Cosmos DB SQL API WHERE clause fragment (a `string`) together with the parameter dictionary required to bind it to a `QueryDefinition`. + +The package depends only on `Vali-Flow.NoSql` (the IR layer). Unlike the Mongo/Elasticsearch/DynamoDB translators, it has **no dependency on the `Microsoft.Azure.Cosmos` SDK** — it is a pure string/parameter builder with no connection or execution concerns. You wire the result into your own `QueryDefinition`. + +--- + +## Installation + +```bash +dotnet add package Vali-Flow.NoSql.CosmosDb +``` + +`Vali-Flow.Core` is included as a transitive dependency. The `Microsoft.Azure.Cosmos` SDK is **not** a dependency — add it yourself to actually execute the query. + +--- + +## Quick Start + +```csharp +using Microsoft.Azure.Cosmos; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.CosmosDb.Extensions; +using Vali_Flow.NoSql.CosmosDb.Models; + +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Active") + .GreaterThan(x => x.Total, 100m); + +CosmosFilterExpression f = filter.ToCosmosDb(); + +var queryText = $"SELECT * FROM c WHERE {f.WhereClause}"; +var query = new QueryDefinition(queryText); +foreach (var kv in f.Parameters) + query = query.WithParameter(kv.Key, kv.Value); + +using FeedIterator iterator = container.GetItemQueryIterator(query); +while (iterator.HasMoreResults) +{ + FeedResponse response = await iterator.ReadNextAsync(); + foreach (Order order in response) { /* ... */ } +} +``` + +--- + +## Extension Methods + +Both overloads are in `Vali_Flow.NoSql.CosmosDb.Extensions.ValiFlowCosmosDbExtensions`. + +### `ToCosmosDb(this ValiFlow flow, Func? customConverter = null)` + +Translates the conditions accumulated in a `ValiFlow` builder into a `CosmosFilterExpression`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `flow` | `ValiFlow` | Yes | The builder containing the conditions. | +| `customConverter` | `Func?` | No | Hook for mapping CLR types not handled by the built-in switch into the raw value stored in the parameters dictionary. Return `null` to fall through to the default conversion. | + +**Returns:** `CosmosFilterExpression` — append `WhereClause` to your query text and bind `Parameters` to a `QueryDefinition`. + +### `ToCosmosDb(this Expression> expression, Func? customConverter = null)` + +Same translation for a pre-built `Expression>`. + +```csharp +Expression> expr = u => u.IsActive && u.Age >= 21; +CosmosFilterExpression f = expr.ToCosmosDb(); +``` + +--- + +## CosmosFilterExpression + +`CosmosFilterExpression` is a sealed class that encapsulates the two values a Cosmos SQL query needs: + +| Property | Type | Description | +|----------|------|-------------| +| `WhereClause` | `string` | The WHERE clause fragment, **without** the leading `WHERE` keyword, e.g. `"c.Age > @p0"`. | +| `Parameters` | `IReadOnlyDictionary` | Maps placeholders (`@p0`, `@p1`, …) to their raw values. | + +Field references use the standard Cosmos SQL container alias `c` (e.g. `c.Status`), so there is no attribute-name placeholder layer like DynamoDB's `#f0` — only value placeholders are generated. + +`Parameters` is used directly with `QueryDefinition.WithParameter(name, value)` in a `foreach` loop — no `.ToDictionary()` call is required, since the SDK's `WithParameter` takes one key/value pair at a time rather than a dictionary constructor argument. + +--- + +## Supported Operations + +| ValiFlow method | IR node | Cosmos SQL fragment | +|----------------|---------|---------------------| +| `.EqualTo(x => x.Field, v)` | `EqualNode(IsNegated: false)` | `c.Field = @p0` | +| `.NotEqualTo(x => x.Field, v)` | `EqualNode(IsNegated: true)` | `c.Field != @p0` | +| `.GreaterThan(x => x.Field, v)` | `ComparisonNode(GT)` | `c.Field > @p0` | +| `.GreaterThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(GTE)` | `c.Field >= @p0` | +| `.LessThan(x => x.Field, v)` | `ComparisonNode(LT)` | `c.Field < @p0` | +| `.LessThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(LTE)` | `c.Field <= @p0` | +| `.Contains(x => x.Field, "txt")` | `LikeNode(Contains)` | `CONTAINS(c.Field, @p0)` | +| `.StartsWith(x => x.Field, "pre")` | `LikeNode(StartsWith)` | `STARTSWITH(c.Field, @p0)` | +| `.EndsWith(x => x.Field, "suf")` | `LikeNode(EndsWith)` | `ENDSWITH(c.Field, @p0)` | +| `.In(x => x.Field, list)` | `InNode` | `c.Field IN (@p0, @p1, …)` | +| `.IsNull(x => x.Field)` | `NullNode(IsNull)` | `IS_NULL(c.Field)` | +| `.IsNotNull(x => x.Field)` | `NullNode(IsNotNull)` | `NOT IS_NULL(c.Field)` | +| `.And(a, b)` | `AndNode` | `(a AND b)` | +| `.Or(a, b)` | `OrNode` | `(a OR b)` | +| `.Not(inner)` | `NotNode` | `NOT (inner)` | + +All three `LikeNode` operators map to **native** Cosmos SQL functions — unlike DynamoDB, Cosmos supports `ENDSWITH` directly, so there is no client-side fallback needed for that case. `IS_NULL` is also native — it is a true null check against the JSON document, not an attribute-existence check. + +--- + +## Type Mapping + +The built-in conversion switch handles these CLR types: + +| CLR type | Parameter value produced | +|----------|---------------------------| +| `null` | `null` | +| `bool` | `b` (as-is) | +| `string` | `s` (as-is) | +| `int` | `i` (as-is) | +| `long` | `l` (as-is) | +| `double` | `d` (as-is) | +| `float` | `f` (as-is) | +| `decimal` | `m` (as-is) | +| `Guid` | `g.ToString()` | +| `Enum` | underlying value as `long` (InvariantCulture) | +| anything else | `v.ToString()` | + +Unlike DynamoDB's `AttributeValue` wrapper, Cosmos parameters are plain CLR values — `QueryDefinition.WithParameter` accepts them directly and the Cosmos SQL API handles JSON-native typing (numbers, strings, booleans) on its own. + +--- + +## Custom Value Converter + +Use `customConverter` when your domain types are not in the table above or when you need a different raw value than the default. + +The converter is called **before** the built-in switch. Return `null` to let the default handle the value. + +```csharp +// Domain type +record Money(decimal Amount, string Currency); + +// Converter: store Money as a number (amount only) +CosmosFilterExpression f = new ValiFlow() + .GreaterThan(x => x.Total, new Money(500m, "USD")) + .ToCosmosDb(value => + { + if (value is Money m) + return m.Amount; + return null; + }); +``` + +Storing a `DateTimeOffset` as an ISO-8601 string: + +```csharp +CosmosFilterExpression f = new ValiFlow() + .GreaterThan(x => x.StartsAt, DateTimeOffset.UtcNow) + .ToCosmosDb(value => + { + if (value is DateTimeOffset dto) + return dto.ToString("O"); + return null; + }); +``` + +--- + +## Limitations + +| Limitation | Detail | +|-----------|--------| +| No Cosmos SDK dependency | This package only produces `WhereClause` + `Parameters`. It does not build or run a `QueryDefinition`, open a `Container`, or page through a `FeedIterator` — that is entirely up to the consumer, unlike the Mongo/Elasticsearch/DynamoDB translators which target their SDK's native request/filter types more directly. | +| Empty `In` list | Throws `InvalidOperationException`. Cosmos DB SQL does not accept an empty `IN (...)` list. Filter the empty case before building the query. | +| No enforced `In` size limit | This package does not cap the number of values in an `In` list (unlike DynamoDB's 100-value SDK limit). Very large lists still count against Cosmos DB's own query text length and complexity limits — keep lists reasonably small. | +| One placeholder per `In` value | `IN (@p0, @p1, @p2, …)` — Cosmos SQL does not accept a single array parameter for `IN`, so each value gets its own placeholder, same as the filter's other parameters. | + +--- + +## Full Example + +A realistic filter combining equality, range, membership, and null checks, applied through the `Microsoft.Azure.Cosmos` SDK: + +```csharp +using Microsoft.Azure.Cosmos; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.CosmosDb.Extensions; +using Vali_Flow.NoSql.CosmosDb.Models; + +CosmosClient client = new CosmosClient(connectionString); +Container container = client.GetContainer("myDb", "Orders"); + +// Build the filter +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Processing") + .GreaterThanOrEqualTo(x => x.Total, 100m) + .LessThan(x => x.Total, 5000m) + .In(x => x.RegionCode, new[] { "US", "CA", "MX" }) + .IsNotNull(x => x.CustomerId); + +CosmosFilterExpression f = filter.ToCosmosDb(); + +// f.WhereClause: +// "(((c.Status = @p0 AND c.Total >= @p1) AND c.Total < @p2) AND c.RegionCode IN (@p3, @p4, @p5)) AND NOT IS_NULL(c.CustomerId)" + +var queryText = $"SELECT * FROM c WHERE {f.WhereClause}"; +var query = new QueryDefinition(queryText); +foreach (var kv in f.Parameters) + query = query.WithParameter(kv.Key, kv.Value); + +using FeedIterator iterator = container.GetItemQueryIterator(query); +var results = new List(); +while (iterator.HasMoreResults) +{ + FeedResponse page = await iterator.ReadNextAsync(); + results.AddRange(page); +} +``` + +### Combining with ORDER BY / TOP + +`CosmosFilterExpression` only produces the WHERE fragment — compose the rest of the SQL yourself: + +```csharp +var queryText = $"SELECT TOP 20 * FROM c WHERE {f.WhereClause} ORDER BY c.CreatedAt DESC"; +var query = new QueryDefinition(queryText); +foreach (var kv in f.Parameters) + query = query.WithParameter(kv.Key, kv.Value); +``` + +### Handling the empty `In` list case + +```csharp +var regionCodes = GetRegionCodesFromRequest(); // might be empty + +var builder = new ValiFlow().EqualTo(x => x.Status, "Active"); + +if (regionCodes.Count > 0) + builder = builder.And(builder, new ValiFlow().In(x => x.RegionCode, regionCodes)); + +CosmosFilterExpression f = builder.ToCosmosDb(); // safe — In() only called when the list is non-empty +``` diff --git a/docs/en/vali-flow-nosql-couchbase.md b/docs/en/vali-flow-nosql-couchbase.md new file mode 100644 index 0000000..7d97fa9 --- /dev/null +++ b/docs/en/vali-flow-nosql-couchbase.md @@ -0,0 +1,266 @@ +# Vali-Flow.NoSql.Couchbase — Complete Reference + +## Table of Contents + +1. [Overview](#overview) +2. [Installation](#installation) +3. [Quick Start](#quick-start) +4. [Extension Methods](#extension-methods) +5. [CouchbaseFilterExpression](#couchbasefilterexpression) +6. [Supported Operations](#supported-operations) +7. [Type Mapping](#type-mapping) +8. [Custom Value Converter](#custom-value-converter) +9. [Limitations](#limitations) +10. [Full Example](#full-example) + +--- + +## Overview + +**Vali-Flow.NoSql.Couchbase** translates a `ValiFlow` expression tree into a Couchbase N1QL (SQL++) WHERE clause fragment together with a named-parameter dictionary. + +Unlike MongoDB, Elasticsearch, or DynamoDB, Couchbase's N1QL is plain parameterized text — the same approach as SQL. There is no "native" filter object to build (no `BsonDocument`, no `AttributeValue`), so the output is simply a `string` fragment plus a `Dictionary` of named parameters (`$p0`, `$p1`, …). + +The package depends only on `Vali-Flow.NoSql`. **It has no dependency on the Couchbase .NET SDK** — it is a pure text/parameter builder with no connection or execution concerns. Running the resulting query against a cluster is entirely up to the consumer. + +--- + +## Installation + +```bash +dotnet add package Vali-Flow.NoSql.Couchbase +``` + +`Vali-Flow.Core` is included as a transitive dependency. + +--- + +## Quick Start + +```csharp +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Couchbase.Extensions; +using Vali_Flow.NoSql.Couchbase.Models; + +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Active") + .GreaterThan(x => x.Total, 100m); + +CouchbaseFilterExpression f = filter.ToCouchbase(); + +var query = $"SELECT * FROM `orders` WHERE {f.WhereClause}"; + +var options = new QueryOptions(); +foreach (var (name, value) in f.Parameters) + options.Parameter(name, value); + +IQueryResult result = await cluster.QueryAsync(query, options); +``` + +--- + +## Extension Methods + +Both overloads are in `Vali_Flow.NoSql.Couchbase.Extensions.ValiFlowCouchbaseExtensions`. + +### `ToCouchbase(this ValiFlow flow, Func? customConverter = null)` + +Translates the conditions accumulated in a `ValiFlow` builder into a `CouchbaseFilterExpression`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `flow` | `ValiFlow` | Yes | The builder containing the conditions. | +| `customConverter` | `Func?` | No | Hook for mapping CLR types not handled by the built-in switch. Called **before** the default conversion. Return `null` to fall through to it. | + +Throws `ArgumentNullException` if `flow` is `null`. + +**Returns:** `CouchbaseFilterExpression` — combine `WhereClause` with a full N1QL statement and bind `Parameters`. + +### `ToCouchbase(this Expression> expression, Func? customConverter = null)` + +Same translation for a pre-built `Expression>`. + +```csharp +Expression> expr = u => u.IsActive && u.Age >= 21; +CouchbaseFilterExpression f = expr.ToCouchbase(); +``` + +Throws `ArgumentNullException` if `expression` is `null`. + +--- + +## CouchbaseFilterExpression + +`CouchbaseFilterExpression` is a sealed class that encapsulates the two values a N1QL query needs: + +| Property | Type | Description | +|----------|------|-------------| +| `WhereClause` | `string` | The WHERE clause fragment, **without** the leading `WHERE` keyword, e.g. `` "`Name` = $p0" ``. | +| `Parameters` | `IReadOnlyDictionary` | Named N1QL parameters (`$p0`, `$p1`, …) to bind to the query. | + +The translator generates sequential `$p0`, `$p1`, … placeholders for every literal value in the filter, regardless of node type (equality, comparison, `LIKE` pattern, or `IN` list). + +--- + +## Supported Operations + +| ValiFlow method | IR node | N1QL fragment | +|----------------|---------|----------------| +| `.EqualTo(x => x.Field, v)` | `EqualNode(IsNegated: false)` | `` `Field` = $p0 `` | +| `.NotEqualTo(x => x.Field, v)` | `EqualNode(IsNegated: true)` | `` `Field` <> $p0 `` | +| `.GreaterThan(x => x.Field, v)` | `ComparisonNode(GT)` | `` `Field` > $p0 `` | +| `.GreaterThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(GTE)` | `` `Field` >= $p0 `` | +| `.LessThan(x => x.Field, v)` | `ComparisonNode(LT)` | `` `Field` < $p0 `` | +| `.LessThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(LTE)` | `` `Field` <= $p0 `` | +| `.Contains(x => x.Field, "txt")` | `LikeNode(Contains)` | `` `Field` LIKE $p0 `` with `$p0 = "%txt%"` | +| `.StartsWith(x => x.Field, "pre")` | `LikeNode(StartsWith)` | `` `Field` LIKE $p0 `` with `$p0 = "pre%"` | +| `.EndsWith(x => x.Field, "suf")` | `LikeNode(EndsWith)` | `` `Field` LIKE $p0 `` with `$p0 = "%suf"` | +| `.In(x => x.Field, list)` | `InNode` | `` `Field` IN $p0 `` with `$p0` bound to the **whole array** | +| `.IsNull(x => x.Field)` | `NullNode(IsNull)` | `` `Field` IS NULL `` | +| `.IsNotNull(x => x.Field)` | `NullNode(IsNotNull)` | `` `Field` IS NOT NULL `` | +| `.And(a, b)` | `AndNode` | `(a AND b)` | +| `.Or(a, b)` | `OrNode` | `(a OR b)` | +| `.Not(inner)` | `NotNode` | `NOT (inner)` | + +Identifiers are wrapped in backticks (`` `Field` ``) — the N1QL/MySQL quoting convention. + +Note the `In` row: unlike DynamoDB (which expands to `#f0 IN (:v0, :v1, …)`), Couchbase binds the **entire list as a single array parameter** — `` `Field` IN $p0 `` with `Parameters["$p0"]` set to a `List`. N1QL evaluates `IN` against the bound array directly. + +`EndsWith` is fully supported here — unlike DynamoDB, N1QL's `LIKE` has no restriction on trailing wildcards. + +--- + +## Type Mapping + +The built-in value-resolution switch handles these CLR types: + +| CLR type | Parameter value produced | +|----------|---------------------------| +| `null` | `null` | +| `bool` | `b` (unchanged) | +| `string` | `s` (unchanged) | +| `int` | `i` (unchanged) | +| `long` | `l` (unchanged) | +| `double` | `d` (unchanged) | +| `float` | `(double)f` | +| `decimal` | `m.ToString(CultureInfo.InvariantCulture)` — bound as a **string**, not a native number | +| `Guid` | `g.ToString()` | +| `Enum` | `Convert.ToInt64(e)` — the underlying integer value | +| anything else | `v.ToString()` | + +The `decimal → string` conversion is deliberate: N1QL/JSON numbers are IEEE-754 doubles, which lose precision for money-shaped values. If your Couchbase schema stores the field as a native N1QL number, cast in the query (`TONUMBER($p0)`) or supply a `customConverter` that returns the raw `decimal` (JSON-serializable as a number by the SDK). + +--- + +## Custom Value Converter + +Use `customConverter` when your domain types are not in the table above, or when you need a different parameter representation than the default. + +The converter is called **before** the built-in switch. Return `null` to let the default handle the value. + +```csharp +// Domain type +record Money(decimal Amount, string Currency); + +// Converter: store Money as a native number (amount only) +CouchbaseFilterExpression f = new ValiFlow() + .GreaterThan(x => x.Total, new Money(500m, "USD")) + .ToCouchbase(value => + { + if (value is Money m) + return m.Amount; // bound as a native decimal/number, not a string + return null; + }); +``` + +Storing a `DateTimeOffset` as an ISO-8601 string: + +```csharp +CouchbaseFilterExpression f = new ValiFlow() + .GreaterThan(x => x.StartsAt, DateTimeOffset.UtcNow) + .ToCouchbase(value => + { + if (value is DateTimeOffset dto) + return dto.ToString("O"); + return null; + }); +``` + +--- + +## Limitations + +| Limitation | Detail | +|-----------|--------| +| No Couchbase SDK dependency | This package only builds `WhereClause` + `Parameters`. Unlike `Vali-Flow.NoSql.Mongo`, `.Elasticsearch`, or `.DynamoDB` — which depend on and return objects from their respective provider SDKs — `Vali-Flow.NoSql.Couchbase` depends on nothing Couchbase-specific. You are responsible for wiring the result into a real query via the official `CouchbaseNetClient` SDK (or any other client capable of running N1QL). | +| Empty `In` list | Throws `InvalidOperationException`: *"IN condition with empty list is not supported in Couchbase N1QL expressions. Filter the empty case before building the query."* Filter it out before calling `.In(...)`. | +| `decimal` bound as string | See [Type Mapping](#type-mapping). Supply a `customConverter` if you need a native numeric parameter. | +| Comparison operators | Only `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo` are mapped. Any other `ComparisonOp` throws `NotSupportedException`. | +| `LikeOp` coverage | Only `Contains`, `StartsWith`, `EndsWith` are mapped. Any other `LikeOp` throws `NotSupportedException`. | + +--- + +## Full Example + +A realistic filter combining equality, range, membership, and null checks, executed against a real Couchbase cluster with the official SDK: + +```csharp +using Couchbase; +using Couchbase.Query; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Couchbase.Extensions; +using Vali_Flow.NoSql.Couchbase.Models; + +var cluster = await Cluster.ConnectAsync( + "couchbase://localhost", + "Administrator", + "password"); + +// Build the filter +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Processing") + .GreaterThanOrEqualTo(x => x.Total, 100m) + .LessThan(x => x.Total, 5000m) + .In(x => x.RegionCode, new[] { "US", "CA", "MX" }) + .IsNotNull(x => x.CustomerId); + +CouchbaseFilterExpression f = filter.ToCouchbase(); + +// f.WhereClause: +// "(((`Status` = $p0 AND (`Total` >= $p1 AND (`Total` < $p2 AND (`RegionCode` IN $p3 AND `CustomerId` IS NOT NULL)))))" + +var query = $"SELECT META(o).id, o.* FROM `orders` o WHERE {f.WhereClause}"; + +var options = new QueryOptions(); +foreach (var (name, value) in f.Parameters) + options.Parameter(name, value); + +// NOTE: QueryOptions.Parameter(string, object) is the CouchbaseNetClient SDK's +// named-parameter binding API as of the Couchbase .NET SDK 3.x line. Verify the +// exact overload against the SDK version you reference — some versions expose +// a JObject-based NamedParameters setter instead. +IQueryResult result = await cluster.QueryAsync(query, options); + +await foreach (var order in result) +{ + Console.WriteLine(order.Status); +} +``` + +### Handling a native-numeric `decimal` with a custom converter + +```csharp +// By default decimals bind as strings (see Type Mapping). Override when the +// bucket schema stores Total as a native N1QL number. +var filter = new ValiFlow() + .GreaterThan(x => x.Total, 100m); + +CouchbaseFilterExpression f = filter.ToCouchbase(value => value is decimal d ? (object)d : null); + +var query = $"SELECT * FROM `orders` WHERE {f.WhereClause}"; +var options = new QueryOptions(); +foreach (var (name, value) in f.Parameters) + options.Parameter(name, value); + +IQueryResult result = await cluster.QueryAsync(query, options); +``` diff --git a/docs/en/vali-flow-nosql-firestore.md b/docs/en/vali-flow-nosql-firestore.md new file mode 100644 index 0000000..dc3493f --- /dev/null +++ b/docs/en/vali-flow-nosql-firestore.md @@ -0,0 +1,234 @@ +# Vali-Flow.NoSql.Firestore — Complete Reference + +## Table of Contents + +1. [Overview](#overview) +2. [Installation](#installation) +3. [Quick Start](#quick-start) +4. [Extension Methods](#extension-methods) +5. [Supported Operations](#supported-operations) +6. [Type Mapping](#type-mapping) +7. [Custom Value Converter](#custom-value-converter) +8. [Limitations](#limitations) +9. [Full Example](#full-example) + +--- + +## Overview + +**Vali-Flow.NoSql.Firestore** translates a `ValiFlow` expression tree into a native `Google.Cloud.Firestore.Filter`. Unlike the SQL/MongoDB adapters, this one does not build a document or string — it produces the actual `Filter` object from the official `Google.Cloud.Firestore` SDK (v4.4.0), so the output is accepted directly by `Query.Where(filter)` / `CollectionReference.Where(filter)` with no cast, wrapping, or serialization step. + +Because the adapter is bound to the real SDK type, it inherits the real SDK's capabilities — and its real gaps. Firestore's query engine is intentionally limited compared to a general-purpose database: there is no pattern-matching operator and no way to negate an arbitrary sub-filter. Both limitations are enforced in code (see [Limitations](#limitations)) rather than silently producing an incorrect filter. + +--- + +## Installation + +```bash +dotnet add package Vali-Flow.NoSql.Firestore +``` + +`Vali-Flow.NoSql` (the IR layer) is included as a transitive dependency. `Google.Cloud.Firestore` 4.4.0 is a direct dependency — this package does not try to abstract it away. + +--- + +## Quick Start + +```csharp +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Firestore.Extensions; +using Google.Cloud.Firestore; + +var filter = new ValiFlow() + .EqualTo(x => x.IsActive, true) + .GreaterThan(x => x.Age, 18); + +Filter firestoreFilter = filter.ToFirestore(); + +FirestoreDb firestoreDb = FirestoreDb.Create("my-project-id"); +CollectionReference collection = firestoreDb.Collection("users"); +Query query = collection.Where(firestoreFilter); +QuerySnapshot snapshot = await query.GetSnapshotAsync(); + +foreach (DocumentSnapshot doc in snapshot.Documents) +{ + var user = doc.ConvertTo(); +} +``` + +--- + +## Extension Methods + +Both overloads live in `Vali_Flow.NoSql.Firestore.Extensions.ValiFlowFirestoreExtensions`. + +### `ToFirestore(this ValiFlow flow, Func? customConverter = null)` + +Translates the conditions accumulated in a `ValiFlow` builder into a native `Filter`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `flow` | `ValiFlow` | Yes | The builder containing the conditions. Throws `ArgumentNullException` if `null`. | +| `customConverter` | `Func?` | No | Hook for mapping CLR types not handled by the built-in switch. Return `null` to fall through to the default conversion. Scoped to this call only (thread-safe). | + +**Returns:** `Filter` — pass directly to `Query.Where(filter)`. + +### `ToFirestore(this Expression> expression, Func? customConverter = null)` + +Same translation for a pre-built `Expression>`. + +```csharp +Expression> expr = o => o.Status == "Pending" && o.Total > 50m; +Filter filter = expr.ToFirestore(); +``` + +Both overloads are thin wrappers over `FirestoreFilterTranslator.Translate(IConditionNode, Func?)` — the `ValiFlow` overload calls `flow.ToNoSqlIR()` first, the expression overload calls `expression.ToNoSqlIR()` first. + +--- + +## Supported Operations + +| ValiFlow method | IR node | Firestore `Filter` call | +|----------------|---------|--------------------------| +| `.EqualTo(x => x.Field, v)` | `EqualNode(IsNegated: false)` | `Filter.EqualTo(field, value)` | +| `.NotEqualTo(x => x.Field, v)` | `EqualNode(IsNegated: true)` | `Filter.NotEqualTo(field, value)` | +| `.GreaterThan(x => x.Field, v)` | `ComparisonNode(GreaterThan)` | `Filter.GreaterThan(field, value)` | +| `.GreaterThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(GreaterThanOrEqual)` | `Filter.GreaterThanOrEqualTo(field, value)` | +| `.LessThan(x => x.Field, v)` | `ComparisonNode(LessThan)` | `Filter.LessThan(field, value)` | +| `.LessThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(LessThanOrEqual)` | `Filter.LessThanOrEqualTo(field, value)` | +| `.In(x => x.Field, list)` | `InNode` | `Filter.InArray(field, values)` | +| `.IsNull(x => x.Field)` | `NullNode(IsNull)` | `Filter.EqualTo(field, null)` | +| `.IsNotNull(x => x.Field)` | `NullNode(IsNotNull)` | `Filter.NotEqualTo(field, null)` | +| `.And(a, b)` | `AndNode` | `Filter.And(left, right)` | +| `.Or(a, b)` | `OrNode` | `Filter.Or(left, right)` | +| `.Contains` / `.StartsWith` / `.EndsWith` | `LikeNode` | **throws** `NotSupportedException` | +| `.Not(inner)` | `NotNode` | **throws** `NotSupportedException` | + +`And`/`Or` nest recursively, exactly mirroring the shape of the IR tree — there is no flattening into a single `Filter.And(a, b, c, ...)` call even if the source expression chains more than two conditions; each binary node becomes one nested `Filter.And`/`Filter.Or` call. + +--- + +## Type Mapping + +The built-in switch inside `FirestoreFilterTranslator` (method `ToValue`) handles these CLR types before handing the value to `Filter.*`: + +| CLR type | Value passed to `Filter.*` | +|----------|------------------------------| +| `null` | `null` | +| `Enum` | `int` (via `Convert.ToInt32(e)`) | +| `DateTimeOffset` | `Timestamp.FromDateTimeOffset(dto)` | +| `DateTime` | `Timestamp.FromDateTime(...)` — converted to UTC first if `dt.Kind != DateTimeKind.Utc` | +| anything else (`string`, `int`, `long`, `double`, `bool`, `decimal`, ...) | passed through unchanged — the Firestore SDK serializes these natively | + +Resolution order: `customConverter` (if provided) runs first via `ConditionValueResolver.Resolve`; if it returns `null`, the built-in switch above applies. + +--- + +## Custom Value Converter + +Use `customConverter` for domain types the built-in switch doesn't know about, or to override a default (for example, storing a `decimal` as `double` instead of letting the SDK serialize it as-is). + +The converter runs **before** the built-in switch. Return `null` to let the default handle the value. + +```csharp +// Domain type +record Money(decimal Amount, string Currency); + +// Converter: store Money as a double amount +Filter filter = new ValiFlow() + .GreaterThan(x => x.Price, new Money(100m, "USD")) + .ToFirestore(value => + { + if (value is Money m) + return (double)m.Amount; + return null; // fall through for everything else + }); +``` + +--- + +## Limitations + +Firestore is the provider with the most query-engine gaps among the Vali-Flow NoSql adapters, and this package does **not** hide that behind a silently-wrong filter — unsupported nodes throw immediately at translation time. + +### 1. No pattern matching — `LikeNode` (Contains / StartsWith / EndsWith) + +Firestore has no LIKE, regex, or substring query operator. All three `LikeNode` variants throw, with the specific `LikeOp` named in the message: + +```csharp +new ValiFlow().Contains(x => x.Name, "ann").ToFirestore(); +// throws NotSupportedException: +// "Firestore does not support pattern-matching queries (LikeOp.Contains). +// There is no regex/LIKE query operator — filter client-side or use a +// dedicated search index (e.g. Algolia/Elasticsearch) instead." +``` + +The same happens for `LikeOp.StartsWith` and `LikeOp.EndsWith` — only the operator name in the message changes. **Workaround:** filter the result set client-side after fetching, or integrate a dedicated search index (Algolia, Elasticsearch, Typesense) for text search use cases. + +### 2. No generic NOT over an arbitrary sub-expression — `NotNode` + +The Firestore SDK does not expose a "negate this filter" composition — there is no `Filter.Not(filter)`. Any `.Not(inner)` call throws unconditionally, regardless of what `inner` is: + +```csharp +new ValiFlow().Not(f => f.EqualTo(x => x.IsActive, true)).ToFirestore(); +// throws NotSupportedException: +// "Firestore does not support a generic NOT filter over an arbitrary +// sub-expression. The SDK only exposes field-level negations +// (NotEqualTo/NotInArray) via Filter, which are already handled when +// the negated node is an EqualNode/InNode directly." +``` + +**Workaround:** don't wrap in `.Not(...)`. Express the negation at the field level instead — use `.NotEqualTo(x => x.Field, v)` directly (which becomes `EqualNode.IsNegated = true` → `Filter.NotEqualTo`), rather than `.Not(f => f.EqualTo(x => x.Field, v))`. + +### 3. `.In(...)` has no negated counterpart yet + +The SDK exposes `Filter.NotInArray(field, values)`, and the translator's doc comments acknowledge it — but `InNode` in the current IR has no `IsNegated` flag (unlike `EqualNode`), so there is currently no `ValiFlow` method that reaches `Filter.NotInArray`. This is not a thrown exception, just an operation that is not reachable yet from the fluent API against this provider. + +### 4. Composite/nested `AND`/`OR` map 1:1, with no flattening + +Not a missing feature, but worth knowing: chaining three or more conditions produces nested `Filter.And(a, Filter.And(b, c))` rather than a single flat `Filter.And(a, b, c)`. Firestore accepts nested composite filters, so this is semantically equivalent — just worth knowing if you inspect the generated `Filter` for debugging. + +--- + +## Full Example + +A realistic filter combining equality, range, membership, and null checks, executed against a real `FirestoreDb`: + +```csharp +using Google.Cloud.Firestore; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Firestore.Extensions; + +// Build the filter +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Processing") + .GreaterThanOrEqualTo(x => x.Total, 100m) + .LessThan(x => x.Total, 5000m) + .In(x => x.RegionCode, new[] { "US", "CA", "MX" }) + .IsNotNull(x => x.CustomerId); + +Filter firestoreFilter = filter.ToFirestore(); + +// Execute with the real SDK +FirestoreDb firestoreDb = FirestoreDb.Create("my-project-id"); +CollectionReference collection = firestoreDb.Collection("orders"); +Query query = collection.Where(firestoreFilter).Limit(50); + +QuerySnapshot snapshot = await query.GetSnapshotAsync(); + +foreach (DocumentSnapshot doc in snapshot.Documents) +{ + Order order = doc.ConvertTo(); +} +``` + +### Using the expression overload + +```csharp +Expression> activeAndAffordable = + p => p.IsActive && p.Price < 200m; + +Filter filter = activeAndAffordable.ToFirestore(); +Query query = firestoreDb.Collection("products").Where(filter); +var snapshot = await query.GetSnapshotAsync(); +``` diff --git a/docs/es/vali-flow-nosql-cosmosdb.md b/docs/es/vali-flow-nosql-cosmosdb.md new file mode 100644 index 0000000..ffe314b --- /dev/null +++ b/docs/es/vali-flow-nosql-cosmosdb.md @@ -0,0 +1,261 @@ +# Vali-Flow.NoSql.CosmosDb — Referencia Completa + +## Tabla de Contenidos + +1. [Descripción general](#descripción-general) +2. [Instalación](#instalación) +3. [Inicio rápido](#inicio-rápido) +4. [Métodos de extensión](#métodos-de-extensión) +5. [CosmosFilterExpression](#cosmosfilterexpression) +6. [Operaciones soportadas](#operaciones-soportadas) +7. [Mapeo de tipos](#mapeo-de-tipos) +8. [Conversor de valores personalizado](#conversor-de-valores-personalizado) +9. [Limitaciones](#limitaciones) +10. [Ejemplo completo](#ejemplo-completo) + +--- + +## Descripción general + +**Vali-Flow.NoSql.CosmosDb** traduce un árbol de expresiones `ValiFlow` en un fragmento de cláusula WHERE de Azure Cosmos DB SQL API (un `string`) junto con el diccionario de parámetros necesario para vincularlo a un `QueryDefinition`. + +El paquete depende únicamente de `Vali-Flow.NoSql` (la capa de IR). A diferencia de los traductores de Mongo/Elasticsearch/DynamoDB, **no depende del SDK `Microsoft.Azure.Cosmos`** — es un constructor puro de string/parámetros, sin conexión ni preocupaciones de ejecución. El resultado se integra en tu propio `QueryDefinition`. + +--- + +## Instalación + +```bash +dotnet add package Vali-Flow.NoSql.CosmosDb +``` + +`Vali-Flow.Core` se incluye como dependencia transitiva. El SDK `Microsoft.Azure.Cosmos` **no** es una dependencia — agrégalo tú mismo para ejecutar la consulta. + +--- + +## Inicio rápido + +```csharp +using Microsoft.Azure.Cosmos; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.CosmosDb.Extensions; +using Vali_Flow.NoSql.CosmosDb.Models; + +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Active") + .GreaterThan(x => x.Total, 100m); + +CosmosFilterExpression f = filter.ToCosmosDb(); + +var queryText = $"SELECT * FROM c WHERE {f.WhereClause}"; +var query = new QueryDefinition(queryText); +foreach (var kv in f.Parameters) + query = query.WithParameter(kv.Key, kv.Value); + +using FeedIterator iterator = container.GetItemQueryIterator(query); +while (iterator.HasMoreResults) +{ + FeedResponse response = await iterator.ReadNextAsync(); + foreach (Order order in response) { /* ... */ } +} +``` + +--- + +## Métodos de extensión + +Ambas sobrecargas están en `Vali_Flow.NoSql.CosmosDb.Extensions.ValiFlowCosmosDbExtensions`. + +### `ToCosmosDb(this ValiFlow flow, Func? customConverter = null)` + +Traduce las condiciones acumuladas en un constructor `ValiFlow` en un `CosmosFilterExpression`. + +| Parámetro | Tipo | Requerido | Descripción | +|-----------|------|-----------|-------------| +| `flow` | `ValiFlow` | Sí | El constructor que contiene las condiciones. | +| `customConverter` | `Func?` | No | Hook para mapear tipos CLR no manejados por el switch incorporado hacia el valor crudo guardado en el diccionario de parámetros. Retorna `null` para caer al conversor por defecto. | + +**Retorna:** `CosmosFilterExpression` — agrega `WhereClause` a tu texto de consulta y vincula `Parameters` a un `QueryDefinition`. + +### `ToCosmosDb(this Expression> expression, Func? customConverter = null)` + +Misma traducción para una `Expression>` ya construida. + +```csharp +Expression> expr = u => u.IsActive && u.Age >= 21; +CosmosFilterExpression f = expr.ToCosmosDb(); +``` + +--- + +## CosmosFilterExpression + +`CosmosFilterExpression` es una clase sellada que encapsula los dos valores que necesita una consulta Cosmos SQL: + +| Propiedad | Tipo | Descripción | +|-----------|------|-------------| +| `WhereClause` | `string` | El fragmento de cláusula WHERE, **sin** la palabra clave `WHERE` al inicio, p.ej. `"c.Age > @p0"`. | +| `Parameters` | `IReadOnlyDictionary` | Mapea marcadores de posición (`@p0`, `@p1`, …) a sus valores crudos. | + +Las referencias a campos usan el alias estándar de contenedor de Cosmos SQL `c` (p.ej. `c.Status`), por lo que no existe una capa de marcadores de nombre de atributo como los `#f0` de DynamoDB — solo se generan marcadores de valor. + +`Parameters` se usa directamente con `QueryDefinition.WithParameter(name, value)` dentro de un `foreach` — no se necesita llamar a `.ToDictionary()`, ya que `WithParameter` del SDK recibe un par clave/valor a la vez en lugar de un diccionario completo como argumento del constructor. + +--- + +## Operaciones soportadas + +| Método ValiFlow | Nodo IR | Fragmento Cosmos SQL | +|----------------|---------|---------------------| +| `.EqualTo(x => x.Field, v)` | `EqualNode(IsNegated: false)` | `c.Field = @p0` | +| `.NotEqualTo(x => x.Field, v)` | `EqualNode(IsNegated: true)` | `c.Field != @p0` | +| `.GreaterThan(x => x.Field, v)` | `ComparisonNode(GT)` | `c.Field > @p0` | +| `.GreaterThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(GTE)` | `c.Field >= @p0` | +| `.LessThan(x => x.Field, v)` | `ComparisonNode(LT)` | `c.Field < @p0` | +| `.LessThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(LTE)` | `c.Field <= @p0` | +| `.Contains(x => x.Field, "txt")` | `LikeNode(Contains)` | `CONTAINS(c.Field, @p0)` | +| `.StartsWith(x => x.Field, "pre")` | `LikeNode(StartsWith)` | `STARTSWITH(c.Field, @p0)` | +| `.EndsWith(x => x.Field, "suf")` | `LikeNode(EndsWith)` | `ENDSWITH(c.Field, @p0)` | +| `.In(x => x.Field, list)` | `InNode` | `c.Field IN (@p0, @p1, …)` | +| `.IsNull(x => x.Field)` | `NullNode(IsNull)` | `IS_NULL(c.Field)` | +| `.IsNotNull(x => x.Field)` | `NullNode(IsNotNull)` | `NOT IS_NULL(c.Field)` | +| `.And(a, b)` | `AndNode` | `(a AND b)` | +| `.Or(a, b)` | `OrNode` | `(a OR b)` | +| `.Not(inner)` | `NotNode` | `NOT (inner)` | + +Los tres operadores `LikeNode` se mapean a funciones **nativas** de Cosmos SQL — a diferencia de DynamoDB, Cosmos soporta `ENDSWITH` directamente, así que no hace falta fallback del lado del cliente para ese caso. `IS_NULL` también es nativo — es una verificación real de nulo sobre el documento JSON, no una verificación de existencia de atributo. + +--- + +## Mapeo de tipos + +El switch de conversión incorporado maneja estos tipos CLR: + +| Tipo CLR | Valor de parámetro producido | +|----------|-------------------------------| +| `null` | `null` | +| `bool` | `b` (tal cual) | +| `string` | `s` (tal cual) | +| `int` | `i` (tal cual) | +| `long` | `l` (tal cual) | +| `double` | `d` (tal cual) | +| `float` | `f` (tal cual) | +| `decimal` | `m` (tal cual) | +| `Guid` | `g.ToString()` | +| `Enum` | valor subyacente como `long` (InvariantCulture) | +| cualquier otro | `v.ToString()` | + +A diferencia del wrapper `AttributeValue` de DynamoDB, los parámetros de Cosmos son valores CLR planos — `QueryDefinition.WithParameter` los acepta directamente y la API SQL de Cosmos maneja el tipado nativo de JSON (números, strings, booleanos) por su cuenta. + +--- + +## Conversor de valores personalizado + +Usa `customConverter` cuando tus tipos de dominio no están en la tabla anterior o cuando necesitas un valor crudo distinto al que produce el conversor por defecto. + +El conversor se llama **antes** del switch incorporado. Retorna `null` para dejar que el default maneje el valor. + +```csharp +// Tipo de dominio +record Money(decimal Amount, string Currency); + +// Conversor: almacena Money como número (solo el monto) +CosmosFilterExpression f = new ValiFlow() + .GreaterThan(x => x.Total, new Money(500m, "USD")) + .ToCosmosDb(value => + { + if (value is Money m) + return m.Amount; + return null; + }); +``` + +Almacenando un `DateTimeOffset` como cadena ISO-8601: + +```csharp +CosmosFilterExpression f = new ValiFlow() + .GreaterThan(x => x.StartsAt, DateTimeOffset.UtcNow) + .ToCosmosDb(value => + { + if (value is DateTimeOffset dto) + return dto.ToString("O"); + return null; + }); +``` + +--- + +## Limitaciones + +| Limitación | Detalle | +|-----------|---------| +| Sin dependencia del SDK de Cosmos | Este paquete solo produce `WhereClause` + `Parameters`. No construye ni ejecuta un `QueryDefinition`, no abre un `Container`, ni recorre un `FeedIterator` — eso queda enteramente a cargo del consumidor, a diferencia de los traductores de Mongo/Elasticsearch/DynamoDB que apuntan más directamente a los tipos nativos de request/filtro de su SDK. | +| Lista `In` vacía | Lanza `InvalidOperationException`. Cosmos DB SQL no acepta una lista `IN (...)` vacía. Filtra el caso vacío antes de construir la consulta. | +| Sin límite de tamaño impuesto en `In` | Este paquete no limita la cantidad de valores en una lista `In` (a diferencia del límite de 100 valores del SDK de DynamoDB). Listas muy grandes igual cuentan contra los límites propios de Cosmos DB de longitud y complejidad de la consulta — mantén las listas en un tamaño razonable. | +| Un marcador por valor en `In` | `IN (@p0, @p1, @p2, …)` — Cosmos SQL no acepta un único parámetro de tipo array para `IN`, así que cada valor recibe su propio marcador, igual que el resto de los parámetros del filtro. | + +--- + +## Ejemplo completo + +Un filtro realista que combina igualdad, rango, membresía y verificaciones de nulos, aplicado a través del SDK `Microsoft.Azure.Cosmos`: + +```csharp +using Microsoft.Azure.Cosmos; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.CosmosDb.Extensions; +using Vali_Flow.NoSql.CosmosDb.Models; + +CosmosClient client = new CosmosClient(connectionString); +Container container = client.GetContainer("myDb", "Orders"); + +// Construir el filtro +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Processing") + .GreaterThanOrEqualTo(x => x.Total, 100m) + .LessThan(x => x.Total, 5000m) + .In(x => x.RegionCode, new[] { "US", "CA", "MX" }) + .IsNotNull(x => x.CustomerId); + +CosmosFilterExpression f = filter.ToCosmosDb(); + +// f.WhereClause: +// "(((c.Status = @p0 AND c.Total >= @p1) AND c.Total < @p2) AND c.RegionCode IN (@p3, @p4, @p5)) AND NOT IS_NULL(c.CustomerId)" + +var queryText = $"SELECT * FROM c WHERE {f.WhereClause}"; +var query = new QueryDefinition(queryText); +foreach (var kv in f.Parameters) + query = query.WithParameter(kv.Key, kv.Value); + +using FeedIterator iterator = container.GetItemQueryIterator(query); +var results = new List(); +while (iterator.HasMoreResults) +{ + FeedResponse page = await iterator.ReadNextAsync(); + results.AddRange(page); +} +``` + +### Combinando con ORDER BY / TOP + +`CosmosFilterExpression` solo produce el fragmento WHERE — compón el resto del SQL tú mismo: + +```csharp +var queryText = $"SELECT TOP 20 * FROM c WHERE {f.WhereClause} ORDER BY c.CreatedAt DESC"; +var query = new QueryDefinition(queryText); +foreach (var kv in f.Parameters) + query = query.WithParameter(kv.Key, kv.Value); +``` + +### Manejando el caso de lista `In` vacía + +```csharp +var regionCodes = GetRegionCodesFromRequest(); // puede venir vacía + +var builder = new ValiFlow().EqualTo(x => x.Status, "Active"); + +if (regionCodes.Count > 0) + builder = builder.And(builder, new ValiFlow().In(x => x.RegionCode, regionCodes)); + +CosmosFilterExpression f = builder.ToCosmosDb(); // seguro — In() solo se llama cuando la lista no está vacía +``` diff --git a/docs/es/vali-flow-nosql-couchbase.md b/docs/es/vali-flow-nosql-couchbase.md new file mode 100644 index 0000000..6b9b1ea --- /dev/null +++ b/docs/es/vali-flow-nosql-couchbase.md @@ -0,0 +1,266 @@ +# Vali-Flow.NoSql.Couchbase — Referencia Completa + +## Tabla de Contenidos + +1. [Descripción general](#descripción-general) +2. [Instalación](#instalación) +3. [Inicio rápido](#inicio-rápido) +4. [Métodos de extensión](#métodos-de-extensión) +5. [CouchbaseFilterExpression](#couchbasefilterexpression) +6. [Operaciones soportadas](#operaciones-soportadas) +7. [Mapeo de tipos](#mapeo-de-tipos) +8. [Conversor de valores personalizado](#conversor-de-valores-personalizado) +9. [Limitaciones](#limitaciones) +10. [Ejemplo completo](#ejemplo-completo) + +--- + +## Descripción general + +**Vali-Flow.NoSql.Couchbase** traduce un árbol de expresiones `ValiFlow` en un fragmento de cláusula WHERE de N1QL (SQL++) de Couchbase, junto con un diccionario de parámetros con nombre. + +A diferencia de MongoDB, Elasticsearch o DynamoDB, el N1QL de Couchbase es texto parametrizado plano — el mismo enfoque que SQL. No existe un objeto de filtro "nativo" para construir (no hay `BsonDocument`, no hay `AttributeValue`): el resultado es simplemente un `string` más un `Dictionary` de parámetros con nombre (`$p0`, `$p1`, …). + +El paquete depende únicamente de `Vali-Flow.NoSql`. **No tiene ninguna dependencia del SDK .NET de Couchbase** — es un constructor puro de texto/parámetros, sin preocupaciones de conexión ni de ejecución. Ejecutar la consulta resultante contra un cluster queda enteramente del lado del consumidor. + +--- + +## Instalación + +```bash +dotnet add package Vali-Flow.NoSql.Couchbase +``` + +`Vali-Flow.Core` se incluye como dependencia transitiva. + +--- + +## Inicio rápido + +```csharp +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Couchbase.Extensions; +using Vali_Flow.NoSql.Couchbase.Models; + +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Active") + .GreaterThan(x => x.Total, 100m); + +CouchbaseFilterExpression f = filter.ToCouchbase(); + +var query = $"SELECT * FROM `orders` WHERE {f.WhereClause}"; + +var options = new QueryOptions(); +foreach (var (name, value) in f.Parameters) + options.Parameter(name, value); + +IQueryResult result = await cluster.QueryAsync(query, options); +``` + +--- + +## Métodos de extensión + +Ambas sobrecargas están en `Vali_Flow.NoSql.Couchbase.Extensions.ValiFlowCouchbaseExtensions`. + +### `ToCouchbase(this ValiFlow flow, Func? customConverter = null)` + +Traduce las condiciones acumuladas en un constructor `ValiFlow` en un `CouchbaseFilterExpression`. + +| Parámetro | Tipo | Requerido | Descripción | +|-----------|------|-----------|-------------| +| `flow` | `ValiFlow` | Sí | El constructor que contiene las condiciones. | +| `customConverter` | `Func?` | No | Hook para mapear tipos CLR no manejados por el switch incorporado. Se invoca **antes** de la conversión por defecto. Retorna `null` para caer en ella. | + +Lanza `ArgumentNullException` si `flow` es `null`. + +**Retorna:** `CouchbaseFilterExpression` — combina `WhereClause` con una sentencia N1QL completa y enlaza `Parameters`. + +### `ToCouchbase(this Expression> expression, Func? customConverter = null)` + +Misma traducción para una `Expression>` ya construida. + +```csharp +Expression> expr = u => u.IsActive && u.Age >= 21; +CouchbaseFilterExpression f = expr.ToCouchbase(); +``` + +Lanza `ArgumentNullException` si `expression` es `null`. + +--- + +## CouchbaseFilterExpression + +`CouchbaseFilterExpression` es una clase sellada que encapsula los dos valores que necesita una consulta N1QL: + +| Propiedad | Tipo | Descripción | +|-----------|------|-------------| +| `WhereClause` | `string` | El fragmento de cláusula WHERE, **sin** la palabra clave `WHERE` inicial, p.ej. `` "`Name` = $p0" ``. | +| `Parameters` | `IReadOnlyDictionary` | Parámetros N1QL con nombre (`$p0`, `$p1`, …) a enlazar en la consulta. | + +El traductor genera marcadores secuenciales `$p0`, `$p1`, … para cada valor literal del filtro, sin importar el tipo de nodo (igualdad, comparación, patrón de `LIKE` o lista de `IN`). + +--- + +## Operaciones soportadas + +| Método ValiFlow | Nodo IR | Fragmento N1QL | +|----------------|---------|----------------| +| `.EqualTo(x => x.Field, v)` | `EqualNode(IsNegated: false)` | `` `Field` = $p0 `` | +| `.NotEqualTo(x => x.Field, v)` | `EqualNode(IsNegated: true)` | `` `Field` <> $p0 `` | +| `.GreaterThan(x => x.Field, v)` | `ComparisonNode(GT)` | `` `Field` > $p0 `` | +| `.GreaterThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(GTE)` | `` `Field` >= $p0 `` | +| `.LessThan(x => x.Field, v)` | `ComparisonNode(LT)` | `` `Field` < $p0 `` | +| `.LessThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(LTE)` | `` `Field` <= $p0 `` | +| `.Contains(x => x.Field, "txt")` | `LikeNode(Contains)` | `` `Field` LIKE $p0 `` con `$p0 = "%txt%"` | +| `.StartsWith(x => x.Field, "pre")` | `LikeNode(StartsWith)` | `` `Field` LIKE $p0 `` con `$p0 = "pre%"` | +| `.EndsWith(x => x.Field, "suf")` | `LikeNode(EndsWith)` | `` `Field` LIKE $p0 `` con `$p0 = "%suf"` | +| `.In(x => x.Field, list)` | `InNode` | `` `Field` IN $p0 `` con `$p0` enlazado al **array completo** | +| `.IsNull(x => x.Field)` | `NullNode(IsNull)` | `` `Field` IS NULL `` | +| `.IsNotNull(x => x.Field)` | `NullNode(IsNotNull)` | `` `Field` IS NOT NULL `` | +| `.And(a, b)` | `AndNode` | `(a AND b)` | +| `.Or(a, b)` | `OrNode` | `(a OR b)` | +| `.Not(inner)` | `NotNode` | `NOT (inner)` | + +Los identificadores van entre backticks (`` `Field` ``) — la convención de quoting de N1QL/MySQL. + +Fijate en la fila de `In`: a diferencia de DynamoDB (que expande a `#f0 IN (:v0, :v1, …)`), Couchbase enlaza **toda la lista como un único parámetro array** — `` `Field` IN $p0 `` con `Parameters["$p0"]` apuntando a un `List`. N1QL evalúa el `IN` directamente contra el array enlazado. + +`EndsWith` está completamente soportado acá — a diferencia de DynamoDB, el `LIKE` de N1QL no tiene ninguna restricción sobre comodines al final. + +--- + +## Mapeo de tipos + +El switch de resolución de valores incorporado maneja estos tipos CLR: + +| Tipo CLR | Valor de parámetro producido | +|----------|-------------------------------| +| `null` | `null` | +| `bool` | `b` (sin cambios) | +| `string` | `s` (sin cambios) | +| `int` | `i` (sin cambios) | +| `long` | `l` (sin cambios) | +| `double` | `d` (sin cambios) | +| `float` | `(double)f` | +| `decimal` | `m.ToString(CultureInfo.InvariantCulture)` — se enlaza como **string**, no como número nativo | +| `Guid` | `g.ToString()` | +| `Enum` | `Convert.ToInt64(e)` — el valor entero subyacente | +| cualquier otro | `v.ToString()` | + +La conversión `decimal → string` es deliberada: los números de N1QL/JSON son doubles IEEE-754, que pierden precisión en valores con forma de dinero. Si tu esquema en Couchbase almacena el campo como un número N1QL nativo, castea en la consulta (`TONUMBER($p0)`) o pasá un `customConverter` que retorne el `decimal` crudo (serializable como número por el SDK). + +--- + +## Conversor de valores personalizado + +Usa `customConverter` cuando tus tipos de dominio no están en la tabla anterior, o cuando necesitas una representación de parámetro distinta a la por defecto. + +El conversor se llama **antes** del switch incorporado. Retorna `null` para dejar que el default maneje el valor. + +```csharp +// Tipo de dominio +record Money(decimal Amount, string Currency); + +// Conversor: almacena Money como número nativo (solo el monto) +CouchbaseFilterExpression f = new ValiFlow() + .GreaterThan(x => x.Total, new Money(500m, "USD")) + .ToCouchbase(value => + { + if (value is Money m) + return m.Amount; // se enlaza como decimal/número nativo, no como string + return null; + }); +``` + +Almacenando un `DateTimeOffset` como cadena ISO-8601: + +```csharp +CouchbaseFilterExpression f = new ValiFlow() + .GreaterThan(x => x.StartsAt, DateTimeOffset.UtcNow) + .ToCouchbase(value => + { + if (value is DateTimeOffset dto) + return dto.ToString("O"); + return null; + }); +``` + +--- + +## Limitaciones + +| Limitación | Detalle | +|-----------|---------| +| Sin dependencia del SDK de Couchbase | Este paquete solo construye `WhereClause` + `Parameters`. A diferencia de `Vali-Flow.NoSql.Mongo`, `.Elasticsearch` o `.DynamoDB` — que dependen de y retornan objetos de sus respectivos SDKs de proveedor —, `Vali-Flow.NoSql.Couchbase` no depende de nada específico de Couchbase. Vos sos responsable de conectar el resultado a una consulta real mediante el SDK oficial `CouchbaseNetClient` (o cualquier otro cliente capaz de ejecutar N1QL). | +| Lista `In` vacía | Lanza `InvalidOperationException`: *"IN condition with empty list is not supported in Couchbase N1QL expressions. Filter the empty case before building the query."* Filtrala antes de llamar a `.In(...)`. | +| `decimal` enlazado como string | Ver [Mapeo de tipos](#mapeo-de-tipos). Pasá un `customConverter` si necesitás un parámetro numérico nativo. | +| Operadores de comparación | Solo están mapeados `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`. Cualquier otro `ComparisonOp` lanza `NotSupportedException`. | +| Cobertura de `LikeOp` | Solo están mapeados `Contains`, `StartsWith`, `EndsWith`. Cualquier otro `LikeOp` lanza `NotSupportedException`. | + +--- + +## Ejemplo completo + +Un filtro realista que combina igualdad, rango, membresía y verificaciones de nulos, ejecutado contra un cluster real de Couchbase con el SDK oficial: + +```csharp +using Couchbase; +using Couchbase.Query; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Couchbase.Extensions; +using Vali_Flow.NoSql.Couchbase.Models; + +var cluster = await Cluster.ConnectAsync( + "couchbase://localhost", + "Administrator", + "password"); + +// Construir el filtro +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Processing") + .GreaterThanOrEqualTo(x => x.Total, 100m) + .LessThan(x => x.Total, 5000m) + .In(x => x.RegionCode, new[] { "US", "CA", "MX" }) + .IsNotNull(x => x.CustomerId); + +CouchbaseFilterExpression f = filter.ToCouchbase(); + +// f.WhereClause: +// "(((`Status` = $p0 AND (`Total` >= $p1 AND (`Total` < $p2 AND (`RegionCode` IN $p3 AND `CustomerId` IS NOT NULL)))))" + +var query = $"SELECT META(o).id, o.* FROM `orders` o WHERE {f.WhereClause}"; + +var options = new QueryOptions(); +foreach (var (name, value) in f.Parameters) + options.Parameter(name, value); + +// NOTA: QueryOptions.Parameter(string, object) es la API de enlace de parámetros +// con nombre del SDK CouchbaseNetClient en la línea 3.x del SDK .NET de Couchbase. +// Verificá la sobrecarga exacta contra la versión del SDK que referencies — algunas +// versiones exponen en cambio un setter de NamedParameters basado en JObject. +IQueryResult result = await cluster.QueryAsync(query, options); + +await foreach (var order in result) +{ + Console.WriteLine(order.Status); +} +``` + +### Manejando un `decimal` nativo con un conversor personalizado + +```csharp +// Por defecto los decimales se enlazan como strings (ver Mapeo de tipos). Sobreescribí +// cuando el esquema del bucket almacena Total como número N1QL nativo. +var filter = new ValiFlow() + .GreaterThan(x => x.Total, 100m); + +CouchbaseFilterExpression f = filter.ToCouchbase(value => value is decimal d ? (object)d : null); + +var query = $"SELECT * FROM `orders` WHERE {f.WhereClause}"; +var options = new QueryOptions(); +foreach (var (name, value) in f.Parameters) + options.Parameter(name, value); + +IQueryResult result = await cluster.QueryAsync(query, options); +``` diff --git a/docs/es/vali-flow-nosql-firestore.md b/docs/es/vali-flow-nosql-firestore.md new file mode 100644 index 0000000..e8257ab --- /dev/null +++ b/docs/es/vali-flow-nosql-firestore.md @@ -0,0 +1,234 @@ +# Vali-Flow.NoSql.Firestore — Referencia Completa + +## Tabla de Contenidos + +1. [Resumen](#resumen) +2. [Instalación](#instalación) +3. [Inicio Rápido](#inicio-rápido) +4. [Métodos de Extensión](#métodos-de-extensión) +5. [Operaciones Soportadas](#operaciones-soportadas) +6. [Mapeo de Tipos](#mapeo-de-tipos) +7. [Conversor de Valores Personalizado](#conversor-de-valores-personalizado) +8. [Limitaciones](#limitaciones) +9. [Ejemplo Completo](#ejemplo-completo) + +--- + +## Resumen + +**Vali-Flow.NoSql.Firestore** traduce un árbol de expresiones `ValiFlow` a un `Google.Cloud.Firestore.Filter` nativo. A diferencia de los adaptadores SQL/MongoDB, este paquete no construye un documento ni un string — produce el objeto `Filter` real del SDK oficial `Google.Cloud.Firestore` (v4.4.0), así que el resultado se pasa directo a `Query.Where(filter)` / `CollectionReference.Where(filter)` sin ningún cast, wrapping ni paso de serialización. + +Como el adaptador está atado al tipo real del SDK, hereda tanto las capacidades reales del SDK como sus huecos reales. El motor de queries de Firestore es intencionalmente limitado comparado con una base de datos de propósito general: no hay operador de pattern-matching y no hay forma de negar un sub-filtro arbitrario. Ambas limitaciones están forzadas en el código (ver [Limitaciones](#limitaciones)) en vez de producir silenciosamente un filtro incorrecto. + +--- + +## Instalación + +```bash +dotnet add package Vali-Flow.NoSql.Firestore +``` + +`Vali-Flow.NoSql` (la capa de IR) se incluye como dependencia transitiva. `Google.Cloud.Firestore` 4.4.0 es una dependencia directa — este paquete no intenta abstraerlo. + +--- + +## Inicio Rápido + +```csharp +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Firestore.Extensions; +using Google.Cloud.Firestore; + +var filter = new ValiFlow() + .EqualTo(x => x.IsActive, true) + .GreaterThan(x => x.Age, 18); + +Filter firestoreFilter = filter.ToFirestore(); + +FirestoreDb firestoreDb = FirestoreDb.Create("my-project-id"); +CollectionReference collection = firestoreDb.Collection("users"); +Query query = collection.Where(firestoreFilter); +QuerySnapshot snapshot = await query.GetSnapshotAsync(); + +foreach (DocumentSnapshot doc in snapshot.Documents) +{ + var user = doc.ConvertTo(); +} +``` + +--- + +## Métodos de Extensión + +Ambas sobrecargas viven en `Vali_Flow.NoSql.Firestore.Extensions.ValiFlowFirestoreExtensions`. + +### `ToFirestore(this ValiFlow flow, Func? customConverter = null)` + +Traduce las condiciones acumuladas en un builder `ValiFlow` a un `Filter` nativo. + +| Parámetro | Tipo | Requerido | Descripción | +|-----------|------|-----------|--------------| +| `flow` | `ValiFlow` | Sí | El builder con las condiciones. Lanza `ArgumentNullException` si es `null`. | +| `customConverter` | `Func?` | No | Hook para mapear tipos CLR que el switch incorporado no maneja. Retornar `null` para caer en la conversión por defecto. Está acotado a esta llamada (thread-safe). | + +**Retorna:** `Filter` — pasalo directo a `Query.Where(filter)`. + +### `ToFirestore(this Expression> expression, Func? customConverter = null)` + +Misma traducción para un `Expression>` ya construido. + +```csharp +Expression> expr = o => o.Status == "Pending" && o.Total > 50m; +Filter filter = expr.ToFirestore(); +``` + +Ambas sobrecargas son wrappers delgados sobre `FirestoreFilterTranslator.Translate(IConditionNode, Func?)` — la sobrecarga de `ValiFlow` llama primero a `flow.ToNoSqlIR()`, y la de expresión llama primero a `expression.ToNoSqlIR()`. + +--- + +## Operaciones Soportadas + +| Método ValiFlow | Nodo IR | Llamada a `Filter` de Firestore | +|------------------|---------|-----------------------------------| +| `.EqualTo(x => x.Field, v)` | `EqualNode(IsNegated: false)` | `Filter.EqualTo(field, value)` | +| `.NotEqualTo(x => x.Field, v)` | `EqualNode(IsNegated: true)` | `Filter.NotEqualTo(field, value)` | +| `.GreaterThan(x => x.Field, v)` | `ComparisonNode(GreaterThan)` | `Filter.GreaterThan(field, value)` | +| `.GreaterThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(GreaterThanOrEqual)` | `Filter.GreaterThanOrEqualTo(field, value)` | +| `.LessThan(x => x.Field, v)` | `ComparisonNode(LessThan)` | `Filter.LessThan(field, value)` | +| `.LessThanOrEqualTo(x => x.Field, v)` | `ComparisonNode(LessThanOrEqual)` | `Filter.LessThanOrEqualTo(field, value)` | +| `.In(x => x.Field, list)` | `InNode` | `Filter.InArray(field, values)` | +| `.IsNull(x => x.Field)` | `NullNode(IsNull)` | `Filter.EqualTo(field, null)` | +| `.IsNotNull(x => x.Field)` | `NullNode(IsNotNull)` | `Filter.NotEqualTo(field, null)` | +| `.And(a, b)` | `AndNode` | `Filter.And(left, right)` | +| `.Or(a, b)` | `OrNode` | `Filter.Or(left, right)` | +| `.Contains` / `.StartsWith` / `.EndsWith` | `LikeNode` | **lanza** `NotSupportedException` | +| `.Not(inner)` | `NotNode` | **lanza** `NotSupportedException` | + +`And`/`Or` anidan recursivamente, replicando exactamente la forma del árbol IR — no hay un "flattening" a un único `Filter.And(a, b, c, ...)` aunque la expresión original encadene más de dos condiciones; cada nodo binario se convierte en una llamada anidada `Filter.And`/`Filter.Or`. + +--- + +## Mapeo de Tipos + +El switch incorporado dentro de `FirestoreFilterTranslator` (método `ToValue`) maneja estos tipos CLR antes de pasar el valor a `Filter.*`: + +| Tipo CLR | Valor pasado a `Filter.*` | +|----------|------------------------------| +| `null` | `null` | +| `Enum` | `int` (vía `Convert.ToInt32(e)`) | +| `DateTimeOffset` | `Timestamp.FromDateTimeOffset(dto)` | +| `DateTime` | `Timestamp.FromDateTime(...)` — convertido a UTC primero si `dt.Kind != DateTimeKind.Utc` | +| cualquier otro (`string`, `int`, `long`, `double`, `bool`, `decimal`, ...) | pasa sin cambios — el SDK de Firestore los serializa de forma nativa | + +Orden de resolución: `customConverter` (si se provee) corre primero vía `ConditionValueResolver.Resolve`; si retorna `null`, aplica el switch incorporado de arriba. + +--- + +## Conversor de Valores Personalizado + +Usá `customConverter` para tipos de dominio que el switch incorporado no conoce, o para sobreescribir un default (por ejemplo, almacenar un `decimal` como `double` en vez de dejar que el SDK lo serialice tal cual). + +El conversor corre **antes** del switch incorporado. Retorná `null` para dejar que el default maneje el valor. + +```csharp +// Tipo de dominio +record Money(decimal Amount, string Currency); + +// Conversor: almacenar Money como un double del monto +Filter filter = new ValiFlow() + .GreaterThan(x => x.Price, new Money(100m, "USD")) + .ToFirestore(value => + { + if (value is Money m) + return (double)m.Amount; + return null; // dejar pasar todo lo demás + }); +``` + +--- + +## Limitaciones + +Firestore es el proveedor con más huecos reales en su motor de queries entre los adaptadores NoSql de Vali-Flow, y este paquete **no** lo esconde detrás de un filtro silenciosamente incorrecto — los nodos no soportados lanzan de inmediato al momento de traducir. + +### 1. Sin pattern matching — `LikeNode` (Contains / StartsWith / EndsWith) + +Firestore no tiene operador de query LIKE, regex, ni de substring. Las tres variantes de `LikeNode` lanzan, con el `LikeOp` específico nombrado en el mensaje: + +```csharp +new ValiFlow().Contains(x => x.Name, "ana").ToFirestore(); +// lanza NotSupportedException: +// "Firestore does not support pattern-matching queries (LikeOp.Contains). +// There is no regex/LIKE query operator — filter client-side or use a +// dedicated search index (e.g. Algolia/Elasticsearch) instead." +``` + +Lo mismo pasa con `LikeOp.StartsWith` y `LikeOp.EndsWith` — solo cambia el nombre del operador en el mensaje. **Workaround:** filtrá el resultado del lado del cliente después de obtenerlo, o integrá un índice de búsqueda dedicado (Algolia, Elasticsearch, Typesense) para casos de búsqueda de texto. + +### 2. Sin NOT genérico sobre un sub-árbol arbitrario — `NotNode` + +El SDK de Firestore no expone una composición de "negar este filtro" — no existe un `Filter.Not(filter)`. Cualquier llamada a `.Not(inner)` lanza incondicionalmente, sin importar qué sea `inner`: + +```csharp +new ValiFlow().Not(f => f.EqualTo(x => x.IsActive, true)).ToFirestore(); +// lanza NotSupportedException: +// "Firestore does not support a generic NOT filter over an arbitrary +// sub-expression. The SDK only exposes field-level negations +// (NotEqualTo/NotInArray) via Filter, which are already handled when +// the negated node is an EqualNode/InNode directly." +``` + +**Workaround:** no envuelvas en `.Not(...)`. Expresá la negación a nivel de campo directamente — usá `.NotEqualTo(x => x.Field, v)` (que se convierte en `EqualNode.IsNegated = true` → `Filter.NotEqualTo`), en vez de `.Not(f => f.EqualTo(x => x.Field, v))`. + +### 3. `.In(...)` todavía no tiene contraparte negada + +El SDK expone `Filter.NotInArray(field, values)`, y los comentarios de documentación del traductor lo reconocen — pero `InNode` en el IR actual no tiene un flag `IsNegated` (a diferencia de `EqualNode`), así que por ahora no hay ningún método de `ValiFlow` que llegue a `Filter.NotInArray`. Esto no es una excepción lanzada, simplemente es una operación que todavía no es alcanzable desde la API fluida contra este proveedor. + +### 4. `AND`/`OR` compuestos y anidados mapean 1:1, sin flattening + +No es una funcionalidad faltante, pero vale saberlo: encadenar tres o más condiciones produce un `Filter.And(a, Filter.And(b, c))` anidado en vez de un único `Filter.And(a, b, c)` plano. Firestore acepta filtros compuestos anidados, así que es semánticamente equivalente — solo vale tenerlo presente si inspeccionás el `Filter` generado para debuggear. + +--- + +## Ejemplo Completo + +Un filtro realista que combina igualdad, rango, pertenencia y chequeos de null, ejecutado contra un `FirestoreDb` real: + +```csharp +using Google.Cloud.Firestore; +using Vali_Flow.Core.Builder; +using Vali_Flow.NoSql.Firestore.Extensions; + +// Construir el filtro +var filter = new ValiFlow() + .EqualTo(x => x.Status, "Processing") + .GreaterThanOrEqualTo(x => x.Total, 100m) + .LessThan(x => x.Total, 5000m) + .In(x => x.RegionCode, new[] { "US", "CA", "MX" }) + .IsNotNull(x => x.CustomerId); + +Filter firestoreFilter = filter.ToFirestore(); + +// Ejecutar con el SDK real +FirestoreDb firestoreDb = FirestoreDb.Create("my-project-id"); +CollectionReference collection = firestoreDb.Collection("orders"); +Query query = collection.Where(firestoreFilter).Limit(50); + +QuerySnapshot snapshot = await query.GetSnapshotAsync(); + +foreach (DocumentSnapshot doc in snapshot.Documents) +{ + Order order = doc.ConvertTo(); +} +``` + +### Usando la sobrecarga de expresión + +```csharp +Expression> activeAndAffordable = + p => p.IsActive && p.Price < 200m; + +Filter filter = activeAndAffordable.ToFirestore(); +Query query = firestoreDb.Collection("products").Where(filter); +var snapshot = await query.GetSnapshotAsync(); +``` diff --git a/vali-flow.sln b/vali-flow.sln index 8f65f8f..5468c40 100644 --- a/vali-flow.sln +++ b/vali-flow.sln @@ -34,6 +34,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vali-Flow.NoSql.DynamoDB", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vali-Flow.NoSql.DynamoDB.Tests", "Vali-Flow.NoSql.DynamoDB.Tests\Vali-Flow.NoSql.DynamoDB.Tests.csproj", "{829C5A5A-360C-4CA4-994E-5392ADD76B5E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vali-Flow.Abstractions.Tests", "Vali-Flow.Abstractions.Tests\Vali-Flow.Abstractions.Tests.csproj", "{E7866C39-4CB1-40CE-B243-77B6625C27CB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vali-Flow.NoSql.Couchbase", "Vali-Flow.NoSql.Couchbase\Vali-Flow.NoSql.Couchbase.csproj", "{DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vali-Flow.NoSql.Couchbase.Tests", "Vali-Flow.NoSql.Couchbase.Tests\Vali-Flow.NoSql.Couchbase.Tests.csproj", "{3120575A-19FC-4615-B46F-2220250D3F6C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vali-Flow.NoSql.CosmosDb", "Vali-Flow.NoSql.CosmosDb\Vali-Flow.NoSql.CosmosDb.csproj", "{934F750B-9A32-461C-8408-515434A645FF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vali-Flow.NoSql.CosmosDb.Tests", "Vali-Flow.NoSql.CosmosDb.Tests\Vali-Flow.NoSql.CosmosDb.Tests.csproj", "{BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vali-Flow.NoSql.Firestore", "Vali-Flow.NoSql.Firestore\Vali-Flow.NoSql.Firestore.csproj", "{178A6DDD-0448-4D1D-A794-19BA8E8C55AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vali-Flow.NoSql.Firestore.Tests", "Vali-Flow.NoSql.Firestore.Tests\Vali-Flow.NoSql.Firestore.Tests.csproj", "{C4C04C75-3BF1-465A-B73E-8099203FFB65}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -248,6 +262,90 @@ Global {829C5A5A-360C-4CA4-994E-5392ADD76B5E}.Release|x64.Build.0 = Release|Any CPU {829C5A5A-360C-4CA4-994E-5392ADD76B5E}.Release|x86.ActiveCfg = Release|Any CPU {829C5A5A-360C-4CA4-994E-5392ADD76B5E}.Release|x86.Build.0 = Release|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Debug|x64.ActiveCfg = Debug|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Debug|x64.Build.0 = Debug|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Debug|x86.ActiveCfg = Debug|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Debug|x86.Build.0 = Debug|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Release|Any CPU.Build.0 = Release|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Release|x64.ActiveCfg = Release|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Release|x64.Build.0 = Release|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Release|x86.ActiveCfg = Release|Any CPU + {E7866C39-4CB1-40CE-B243-77B6625C27CB}.Release|x86.Build.0 = Release|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Debug|x64.ActiveCfg = Debug|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Debug|x64.Build.0 = Debug|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Debug|x86.ActiveCfg = Debug|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Debug|x86.Build.0 = Debug|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Release|Any CPU.Build.0 = Release|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Release|x64.ActiveCfg = Release|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Release|x64.Build.0 = Release|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Release|x86.ActiveCfg = Release|Any CPU + {DBD0AF51-D654-46E1-AA97-AD1E4AAA7E1D}.Release|x86.Build.0 = Release|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Debug|x64.ActiveCfg = Debug|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Debug|x64.Build.0 = Debug|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Debug|x86.ActiveCfg = Debug|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Debug|x86.Build.0 = Debug|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Release|Any CPU.Build.0 = Release|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Release|x64.ActiveCfg = Release|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Release|x64.Build.0 = Release|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Release|x86.ActiveCfg = Release|Any CPU + {3120575A-19FC-4615-B46F-2220250D3F6C}.Release|x86.Build.0 = Release|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Debug|x64.Build.0 = Debug|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Debug|x86.Build.0 = Debug|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Release|Any CPU.Build.0 = Release|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Release|x64.ActiveCfg = Release|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Release|x64.Build.0 = Release|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Release|x86.ActiveCfg = Release|Any CPU + {934F750B-9A32-461C-8408-515434A645FF}.Release|x86.Build.0 = Release|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Debug|x64.ActiveCfg = Debug|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Debug|x64.Build.0 = Debug|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Debug|x86.ActiveCfg = Debug|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Debug|x86.Build.0 = Debug|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Release|Any CPU.Build.0 = Release|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Release|x64.ActiveCfg = Release|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Release|x64.Build.0 = Release|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Release|x86.ActiveCfg = Release|Any CPU + {BBC3D777-24A2-4F1A-AFFD-EB20C7951C62}.Release|x86.Build.0 = Release|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Debug|x64.ActiveCfg = Debug|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Debug|x64.Build.0 = Debug|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Debug|x86.ActiveCfg = Debug|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Debug|x86.Build.0 = Debug|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Release|Any CPU.Build.0 = Release|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Release|x64.ActiveCfg = Release|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Release|x64.Build.0 = Release|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Release|x86.ActiveCfg = Release|Any CPU + {178A6DDD-0448-4D1D-A794-19BA8E8C55AD}.Release|x86.Build.0 = Release|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Debug|x64.ActiveCfg = Debug|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Debug|x64.Build.0 = Debug|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Debug|x86.ActiveCfg = Debug|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Debug|x86.Build.0 = Debug|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Release|Any CPU.Build.0 = Release|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Release|x64.ActiveCfg = Release|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Release|x64.Build.0 = Release|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Release|x86.ActiveCfg = Release|Any CPU + {C4C04C75-3BF1-465A-B73E-8099203FFB65}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 0d4cdee70498b64ffbbff93b75f0e4e99baceab6 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:35:00 -0500 Subject: [PATCH 09/30] feat(benchmarks): add EF Core benchmark using EFCore.InMemory provider Adds EfCoreBenchmarks + BenchmarkDbContext, referencing Vali-Flow.csproj directly (ProjectReference, consistent with this project's existing pattern for InMemory/Sql). --- .../Benchmarks/EfCoreBenchmarks.cs | 90 +++++++++++++++++++ .../Benchmarks/InMemoryAggregateBenchmarks.cs | 4 +- .../Models/BenchmarkDbContext.cs | 16 ++++ .../Vali-Flow.Benchmarks.csproj | 2 + 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 Vali-Flow.Benchmarks/Benchmarks/EfCoreBenchmarks.cs create mode 100644 Vali-Flow.Benchmarks/Models/BenchmarkDbContext.cs diff --git a/Vali-Flow.Benchmarks/Benchmarks/EfCoreBenchmarks.cs b/Vali-Flow.Benchmarks/Benchmarks/EfCoreBenchmarks.cs new file mode 100644 index 0000000..b39d8bb --- /dev/null +++ b/Vali-Flow.Benchmarks/Benchmarks/EfCoreBenchmarks.cs @@ -0,0 +1,90 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Benchmarks.Models; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Classes.Specification; +using Vali_Flow.Core.Builder; + +namespace Vali_Flow.Benchmarks.Benchmarks; + +/// +/// Benchmarks for ValiFlowEvaluator<T> (Vali-Flow, EF Core package) against the EF Core +/// InMemory provider. Measures query-building + LINQ translation overhead vs. hand-written +/// LINQ-to-Entities, not real I/O (no real database is involved). +/// Baseline: filtered query enumeration. Paged/Count are secondary references. +/// +[MemoryDiagnoser] +public class EfCoreBenchmarks +{ + [Params(1_000, 10_000, 100_000)] + public int N; + + private BenchmarkDbContext _dbContext = null!; + private readonly Consumer _consumer = new(); + + private ValiFlowEvaluator _evaluator = null!; + private QuerySpecification _filterSpec = null!; + private QuerySpecification _pagedSpec = null!; + + [GlobalSetup] + public void Setup() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"vali-flow-bench-{N}") + .Options; + + _dbContext = new BenchmarkDbContext(options); + _dbContext.Orders.AddRange(BenchmarkDataFactory.Generate(N)); + _dbContext.SaveChanges(); + + _evaluator = new ValiFlowEvaluator(_dbContext); + + _filterSpec = new QuerySpecification( + new ValiFlowQuery().IsTrue(o => o.IsActive)); + + _pagedSpec = new QuerySpecification( + new ValiFlowQuery().IsTrue(o => o.IsActive)) + .WithOrderBy(o => o.Id) + .WithPagination(1, 50); + } + + [GlobalCleanup] + public void Cleanup() => _dbContext.Dispose(); + + // ── Filter (baseline) ───────────────────────────────────────────────────── + + [Benchmark(Baseline = true)] + public void FilterAll_IsActive_Linq() => + _dbContext.Orders.Where(o => o.IsActive).Consume(_consumer); + + [Benchmark] + public async Task FilterAll_IsActive_ValiFlow() + { + var query = await _evaluator.EvaluateQueryAsync(_filterSpec); + query.Consume(_consumer); + } + + // ── Paged (reference — no ratio) ────────────────────────────────────────── + + [Benchmark] + public void Page1x50_IsActive_Linq() => + _dbContext.Orders.Where(o => o.IsActive).OrderBy(o => o.Id).Skip(0).Take(50).Consume(_consumer); + + [Benchmark] + public async Task Page1x50_IsActive_ValiFlow() + { + var result = await _evaluator.EvaluatePagedAsync(_pagedSpec); + return new PagedResultLength(result.Items.Count); + } + + // ── Count (reference — no ratio) ────────────────────────────────────────── + + [Benchmark] + public int Count_IsActive_Linq() => _dbContext.Orders.Count(o => o.IsActive); + + [Benchmark] + public Task Count_IsActive_ValiFlow() => _evaluator.EvaluateCountAsync(_filterSpec); + + public readonly record struct PagedResultLength(int Count); +} diff --git a/Vali-Flow.Benchmarks/Benchmarks/InMemoryAggregateBenchmarks.cs b/Vali-Flow.Benchmarks/Benchmarks/InMemoryAggregateBenchmarks.cs index c99a13c..a7c5e07 100644 --- a/Vali-Flow.Benchmarks/Benchmarks/InMemoryAggregateBenchmarks.cs +++ b/Vali-Flow.Benchmarks/Benchmarks/InMemoryAggregateBenchmarks.cs @@ -1,5 +1,4 @@ using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Engines; using Vali_Flow.Benchmarks.Models; using Vali_Flow.Core.Builder; using Vali_Flow.InMemory.Classes.Evaluators; @@ -16,8 +15,7 @@ public class InMemoryAggregateBenchmarks [Params(1_000, 10_000, 100_000)] public int N; - private List _data = null!; - private readonly Consumer _consumer = new(); + private List _data = null!; private ValiFlowEvaluator _evalIsActive = null!; diff --git a/Vali-Flow.Benchmarks/Models/BenchmarkDbContext.cs b/Vali-Flow.Benchmarks/Models/BenchmarkDbContext.cs new file mode 100644 index 0000000..20865c3 --- /dev/null +++ b/Vali-Flow.Benchmarks/Models/BenchmarkDbContext.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +namespace Vali_Flow.Benchmarks.Models; + +/// +/// Minimal DbContext backed by the EF Core InMemory provider, used only to measure +/// ValiFlowEvaluator<T> query-building/translation overhead — not real I/O. +/// +public sealed class BenchmarkDbContext : DbContext +{ + public BenchmarkDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Orders => Set(); +} diff --git a/Vali-Flow.Benchmarks/Vali-Flow.Benchmarks.csproj b/Vali-Flow.Benchmarks/Vali-Flow.Benchmarks.csproj index 5e1a28f..6ed6d14 100644 --- a/Vali-Flow.Benchmarks/Vali-Flow.Benchmarks.csproj +++ b/Vali-Flow.Benchmarks/Vali-Flow.Benchmarks.csproj @@ -8,8 +8,10 @@ + + From a562c97ba021376b0001472c633aabcffe4b5e46 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 18:35:32 -0500 Subject: [PATCH 10/30] docs: update README and architecture/benchmarks docs for new providers Add the 3 new NoSql providers (Couchbase, CosmosDb, Firestore) to the package table and architecture diagram; update benchmarks docs for the EF Core benchmark. Extend .gitignore for internal-tooling files that must never be tracked. --- .gitignore | 3 +++ README.md | 24 +++++++++++++++--------- docs/en/architecture/overview.md | 23 +++++++++++++++++++---- docs/en/internal/benchmarks.md | 15 ++++++++++++--- docs/es/internal/benchmarks.md | 15 ++++++++++++--- 5 files changed, 61 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index feb2c5a..4e97677 100644 --- a/.gitignore +++ b/.gitignore @@ -404,3 +404,6 @@ FodyWeavers.xsd .claude/ claude-*.md CLAUDE.md +.claudeignore +.guardian.json +.codegraph/ diff --git a/README.md b/README.md index 1375dbd..39d654b 100644 --- a/README.md +++ b/README.md @@ -83,18 +83,21 @@ var sqlOrders = await connection.QueryAsync( | Package | Purpose | Target | Version | |---------|---------|--------|---------| -| **Vali-Flow** | EF Core async evaluator + specifications (read/write) | `DbContext` | [1.1.0](https://www.nuget.org/packages/Vali-Flow) | -| **Vali-Flow.InMemory** | Synchronous in-memory evaluator for testing & caching | `IEnumerable` | [1.0.0](https://www.nuget.org/packages/Vali-Flow.InMemory) | -| **Vali-Flow.Sql** | SQL query builder for parameterized queries | Dapper / ADO.NET | [1.0.0](https://www.nuget.org/packages/Vali-Flow.Sql) | +| **Vali-Flow** | EF Core async evaluator + specifications (read/write) | `DbContext` | [1.3.4](https://www.nuget.org/packages/Vali-Flow) | +| **Vali-Flow.InMemory** | Synchronous in-memory evaluator for testing & caching | `IEnumerable` | [1.1.5](https://www.nuget.org/packages/Vali-Flow.InMemory) | +| **Vali-Flow.Sql** | SQL query builder for parameterized queries | Dapper / ADO.NET | [1.1.1](https://www.nuget.org/packages/Vali-Flow.Sql) | ### NoSQL Packages | Package | Database | Output Type | Version | |---------|----------|-------------|---------| -| **Vali-Flow.NoSql.MongoDB** | MongoDB | `BsonDocument` | [1.0.0](https://www.nuget.org/packages/Vali-Flow.NoSql.MongoDB) | -| **Vali-Flow.NoSql.Elasticsearch** | Elasticsearch | `Query` (Elastic.Clients) | [1.0.0](https://www.nuget.org/packages/Vali-Flow.NoSql.Elasticsearch) | -| **Vali-Flow.NoSql.Redis** | Redis (RediSearch) | Query string | [1.0.0](https://www.nuget.org/packages/Vali-Flow.NoSql.Redis) | -| **Vali-Flow.NoSql.DynamoDB** | AWS DynamoDB | `DynamoFilterExpression` | [1.0.0](https://www.nuget.org/packages/Vali-Flow.NoSql.DynamoDB) | +| **Vali-Flow.NoSql.MongoDB** | MongoDB | `BsonDocument` | [1.1.0](https://www.nuget.org/packages/Vali-Flow.NoSql.MongoDB) | +| **Vali-Flow.NoSql.Elasticsearch** | Elasticsearch | `Query` (Elastic.Clients) | [1.1.0](https://www.nuget.org/packages/Vali-Flow.NoSql.Elasticsearch) | +| **Vali-Flow.NoSql.Redis** | Redis (RediSearch) | Query string | [1.1.0](https://www.nuget.org/packages/Vali-Flow.NoSql.Redis) | +| **Vali-Flow.NoSql.DynamoDB** | AWS DynamoDB | `DynamoFilterExpression` | [1.1.0](https://www.nuget.org/packages/Vali-Flow.NoSql.DynamoDB) | +| **Vali-Flow.NoSql.Couchbase** | Couchbase | N1QL WHERE fragment + parameters | [1.0.0](https://www.nuget.org/packages/Vali-Flow.NoSql.Couchbase) | +| **Vali-Flow.NoSql.CosmosDb** | Azure Cosmos DB (SQL API) | SQL WHERE fragment + parameters | [1.0.0](https://www.nuget.org/packages/Vali-Flow.NoSql.CosmosDb) | +| **Vali-Flow.NoSql.Firestore** | Google Cloud Firestore | `Filter` (Google.Cloud.Firestore) | [1.0.0](https://www.nuget.org/packages/Vali-Flow.NoSql.Firestore) | ### Architecture @@ -103,13 +106,16 @@ Vali-Flow.Core (expression builder — ValiFlow) │ ├─── Vali-Flow (EF Core async) ├─── Vali-Flow.InMemory (sync in-memory) - ├─── Vali-Flow.Sql (SQL: SQL Server, PostgreSQL, MySQL, SQLite) + ├─── Vali-Flow.Sql (SQL: SQL Server, PostgreSQL, MySQL, SQLite, Oracle) │ └─── Vali-Flow.NoSql ├─── Vali-Flow.NoSql.MongoDB (MongoDB BSON) ├─── Vali-Flow.NoSql.Elasticsearch (Elasticsearch Query DSL) ├─── Vali-Flow.NoSql.Redis (RediSearch) - └─── Vali-Flow.NoSql.DynamoDB (DynamoDB filter expressions) + ├─── Vali-Flow.NoSql.DynamoDB (DynamoDB filter expressions) + ├─── Vali-Flow.NoSql.Couchbase (N1QL / SQL++) + ├─── Vali-Flow.NoSql.CosmosDb (Cosmos DB SQL API) + └─── Vali-Flow.NoSql.Firestore (Firestore native Filter) ``` --- diff --git a/docs/en/architecture/overview.md b/docs/en/architecture/overview.md index 8ed77d5..1145efa 100644 --- a/docs/en/architecture/overview.md +++ b/docs/en/architecture/overview.md @@ -10,7 +10,8 @@ │ IR nodes: EqualNode, ComparisonNode, LikeNode, │ │ InNode, NullNode, AndNode, OrNode, NotNode │ └────────────────────────┬────────────────────────────────┘ - │ ProjectReference (all packages) + │ PackageReference (NuGet — see CLAUDE.md for the + │ current repo-state caveat on internal cross-refs) ┌──────────────┼──────────────────┐ │ │ │ ▼ ▼ ▼ @@ -76,6 +77,11 @@ Each adapter has a single responsibility: convert IR nodes to the query format e | `Vali-Flow.NoSql.Elasticsearch` | `Query` | `Elastic.Clients.Elasticsearch` | | `Vali-Flow.NoSql.DynamoDB` | `DynamoFilterExpression` | `AWSSDK.DynamoDBv2` | | `Vali-Flow.NoSql.Redis` | `string` (RediSearch DSL) | `NRedisStack` | +| `Vali-Flow.NoSql.Couchbase` | `CouchbaseFilterExpression` (N1QL text + params) | none — text output, no SDK needed | +| `Vali-Flow.NoSql.CosmosDb` | `CosmosFilterExpression` (SQL API text + params) | none — text output, no SDK needed | +| `Vali-Flow.NoSql.Firestore` | `Filter` (native) | `Google.Cloud.Firestore` | + +> Mongo/Elasticsearch/DynamoDB/Firestore return a **native SDK object** because their query language *is* an object graph. Sql/Couchbase/CosmosDb return **parameterized text** because their query language *is* text — the consumer applies it with their own client. See "Translator design pattern" in the root `CLAUDE.md` before adding a new provider. --- @@ -134,13 +140,22 @@ Vali-Flow.sln ├── Vali-Flow.NoSql.DynamoDB/ DynamoDB adapter ├── Vali-Flow.NoSql.Elasticsearch/ Elasticsearch adapter ├── Vali-Flow.NoSql.Redis/ Redis/RediSearch adapter +├── Vali-Flow.NoSql.Couchbase/ Couchbase (N1QL) adapter +├── Vali-Flow.NoSql.CosmosDb/ Azure Cosmos DB (SQL API) adapter +├── Vali-Flow.NoSql.Firestore/ Google Cloud Firestore adapter ├── Vali-Flow.Abstractions/ Shared interfaces -├── Vali-Flow.Benchmarks/ BenchmarkDotNet benchmarks +├── Vali-Flow.Benchmarks/ BenchmarkDotNet benchmarks (InMemory, Sql, EF Core) ├── Vali-Flow.Tests/ EF Core evaluator tests ├── Vali-Flow.InMemory.Tests/ InMemory evaluator tests ├── Vali-Flow.Sql.Tests/ SQL translator tests -├── Vali-Flow.NoSql.Tests/ NoSQL base tests -└── Vali-Flow.NoSql.DynamoDB.Tests/ DynamoDB tests +├── Vali-Flow.NoSql.Tests/ NoSQL base + MongoDB tests +├── Vali-Flow.NoSql.DynamoDB.Tests/ +├── Vali-Flow.NoSql.Elasticsearch.Tests/ +├── Vali-Flow.NoSql.Redis.Tests/ +├── Vali-Flow.NoSql.Couchbase.Tests/ +├── Vali-Flow.NoSql.CosmosDb.Tests/ +├── Vali-Flow.NoSql.Firestore.Tests/ +└── Vali-Flow.Abstractions.Tests/ ``` All packages target `net8.0` and `net9.0`. diff --git a/docs/en/internal/benchmarks.md b/docs/en/internal/benchmarks.md index 92d0cff..958af16 100644 --- a/docs/en/internal/benchmarks.md +++ b/docs/en/internal/benchmarks.md @@ -1,6 +1,6 @@ # Vali-Flow Benchmarks -The `Vali-Flow.Benchmarks` project measures the performance of the InMemory evaluator and the SQL builder using [BenchmarkDotNet](https://benchmarkdotnet.org/). +The `Vali-Flow.Benchmarks` project measures the performance of the InMemory evaluator, the SQL builder, and the EF Core evaluator using [BenchmarkDotNet](https://benchmarkdotnet.org/). --- @@ -20,6 +20,7 @@ To run a specific benchmark class: ```bash dotnet run --configuration Release -- --filter "*InMemoryFilter*" dotnet run --configuration Release -- --filter "*SqlBuilder*" +dotnet run --configuration Release -- --filter "*EfCore*" ``` --- @@ -45,6 +46,14 @@ All InMemory benchmarks parameterize over `N ∈ { 1 000, 10 000, 100 000 }` to |-------|-----------------| | `SqlBuilderBenchmarks` | `SqlQueryBuilder` SELECT with JOINs, INSERT, UPDATE — construction cost only (no DB round-trip) | +### EF Core evaluator + +Uses the EF Core **InMemory provider** (`Microsoft.EntityFrameworkCore.InMemory`) — this measures the overhead of `ValiFlowEvaluator` / `QuerySpecificationBuilder` (query construction + LINQ translation), not real database I/O. Parameterized over `N ∈ { 1 000, 10 000, 100 000 }`. + +| Class | What it measures | +|-------|-----------------| +| `EfCoreBenchmarks` | `EvaluateQueryAsync` filter (baseline: raw LINQ `.Where()` on the same `DbContext`), `EvaluatePagedAsync`, `EvaluateCountAsync` | + --- ## Interpreting results @@ -76,8 +85,8 @@ BenchmarkDotNet reports: ## Known limitations -- **No NoSQL benchmarks** — `Vali-Flow.NoSql.*` translators are not benchmarked. Translation is CPU-only (no I/O) and the trees are small, so overhead is expected to be sub-microsecond. -- **No EF Core benchmarks** — EF Core performance is dominated by the database round-trip, not the Vali-Flow layer. Profiling should be done with actual query plans, not microbenchmarks. +- **No NoSQL benchmarks** — `Vali-Flow.NoSql.*` translators (7 providers) are not benchmarked. Translation is CPU-only (no I/O) and the trees are small, so overhead is expected to be sub-microsecond. +- **EF Core benchmarks measure construction, not real I/O** — `EfCoreBenchmarks` uses the InMemory EF Core provider, so it isolates `ValiFlowEvaluator`/`QuerySpecificationBuilder` overhead. Real database performance is dominated by the round-trip and query plan, not this layer — profile actual query plans against a real provider for that. --- diff --git a/docs/es/internal/benchmarks.md b/docs/es/internal/benchmarks.md index 648bbcf..cdd1e92 100644 --- a/docs/es/internal/benchmarks.md +++ b/docs/es/internal/benchmarks.md @@ -1,6 +1,6 @@ # Benchmarks de Vali-Flow -El proyecto `Vali-Flow.Benchmarks` mide el rendimiento del evaluador InMemory y del constructor SQL usando [BenchmarkDotNet](https://benchmarkdotnet.org/). +El proyecto `Vali-Flow.Benchmarks` mide el rendimiento del evaluador InMemory, del constructor SQL y del evaluador EF Core usando [BenchmarkDotNet](https://benchmarkdotnet.org/). --- @@ -20,6 +20,7 @@ Para ejecutar una clase de benchmark específica: ```bash dotnet run --configuration Release -- --filter "*InMemoryFilter*" dotnet run --configuration Release -- --filter "*SqlBuilder*" +dotnet run --configuration Release -- --filter "*EfCore*" ``` --- @@ -45,6 +46,14 @@ Todos los benchmarks InMemory parametrizan sobre `N ∈ { 1 000, 10 000, 100 000 |-------|----------| | `SqlBuilderBenchmarks` | `SqlQueryBuilder` SELECT con JOINs, INSERT, UPDATE — solo el costo de construcción (sin round-trip a BD) | +### Evaluador EF Core + +Usa el proveedor **InMemory de EF Core** (`Microsoft.EntityFrameworkCore.InMemory`) — mide el overhead de `ValiFlowEvaluator`/`QuerySpecificationBuilder` (construcción de query + traducción LINQ), no I/O real de base de datos. Parametrizado sobre `N ∈ { 1 000, 10 000, 100 000 }`. + +| Clase | Qué mide | +|-------|----------| +| `EfCoreBenchmarks` | Filtro con `EvaluateQueryAsync` (baseline: LINQ crudo `.Where()` sobre el mismo `DbContext`), `EvaluatePagedAsync`, `EvaluateCountAsync` | + --- ## Interpretar los resultados @@ -76,8 +85,8 @@ BenchmarkDotNet reporta: ## Limitaciones conocidas -- **Sin benchmarks NoSQL** — los traductores `Vali-Flow.NoSql.*` no tienen benchmarks. La traducción es solo CPU (sin I/O) y los árboles son pequeños, por lo que el overhead esperado es sub-microsegundo. -- **Sin benchmarks EF Core** — el rendimiento de EF Core está dominado por el round-trip a la base de datos, no por la capa de Vali-Flow. El profiling debe hacerse con planes de query reales, no con microbenchmarks. +- **Sin benchmarks NoSQL** — los traductores `Vali-Flow.NoSql.*` (7 proveedores) no tienen benchmarks. La traducción es solo CPU (sin I/O) y los árboles son pequeños, por lo que el overhead esperado es sub-microsegundo. +- **Los benchmarks de EF Core miden construcción, no I/O real** — `EfCoreBenchmarks` usa el proveedor InMemory de EF Core, así que aísla el overhead de `ValiFlowEvaluator`/`QuerySpecificationBuilder`. El rendimiento real contra una base de datos está dominado por el round-trip y el plan de query, no por esta capa — para eso hace falta profiling contra un proveedor real. --- From 63af9786086e7f2cc3a6c0130fc1ddd3d4eb5068 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 20:10:38 -0500 Subject: [PATCH 11/30] fix(vali-flow): preserve tracked entity's PK in UpsertRangeAsync.PartitionUpsertRange CurrentValues.SetValues(entity) copies every scalar property by name, including the primary key. When upserting by a business key (e.g. a Sku), the caller's incoming entity normally carries a freshly-generated Id unrelated to the existing row's PK, and EF throws 'is part of a key and so cannot be modified' instead of updating in place. Align the incoming entity's PK value(s) with the tracked entity's PK before SetValues, via FindPrimaryKey() + reflection (supports composite keys). Found while stress-testing the EF Core evaluator under realistic upsert payloads (UpsertRangeAsync with duplicate business keys under concurrent load). Also bump Vali-Flow.Abstractions pin from 1.0.0 to 1.1.0 to match the version every other package in the solution already pins. --- Vali-Flow.Tests/ValiFlowEfWriteTests.cs | 23 +++++++++++++++++++ .../Evaluators/ValiFlowEvaluator.Write.cs | 22 +++++++++++++++++- Vali-Flow/Vali-Flow.csproj | 2 +- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Vali-Flow.Tests/ValiFlowEfWriteTests.cs b/Vali-Flow.Tests/ValiFlowEfWriteTests.cs index eb9da9f..07b1415 100644 --- a/Vali-Flow.Tests/ValiFlowEfWriteTests.cs +++ b/Vali-Flow.Tests/ValiFlowEfWriteTests.cs @@ -213,6 +213,29 @@ public async Task UpsertRangeAsync_DuplicateKeysAgainstExistingRow_UpdatesOnceWi rows[0].Total.Should().Be(222m); } + [Fact] + public async Task UpsertRangeAsync_IncomingEntityHasDifferentIdThanExistingRow_UpdatesInPlaceWithoutThrowing() + { + // Regression test: callers upserting by a business key (CustomerName here) normally build a + // fresh entity per request and have no reason to know -- or preserve -- the existing row's PK. + // Before the PartitionUpsertRange fix, EF's CurrentValues.SetValues(entity) tried to overwrite + // the tracked entity's Id with the incoming (unrelated, freshly-generated) Id and threw + // InvalidOperationException ("... is part of a key and so cannot be modified"), found while + // stress-testing UpsertRangeAsync with realistic upsert payloads (Vali-Flow EF Core stress run). + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var incoming = new TestOrder { Id = 999, CustomerName = "Alice", Total = 999m, IsShipped = true, CreatedAt = DateTime.UtcNow }; + + var act = async () => await ev.UpsertRangeAsync(new[] { incoming }, o => o.CustomerName); + await act.Should().NotThrowAsync(); + + var rows = await ctx.Orders.AsNoTracking().Where(o => o.CustomerName == "Alice").ToListAsync(); + rows.Should().HaveCount(1); + rows[0].Id.Should().Be(1); // existing PK preserved, not overwritten by the incoming Id=999 + rows[0].Total.Should().Be(999m); // non-key fields still applied + } + // ── SaveChangesAsync — deferred ─────────────────────────────────────────── [Fact] diff --git a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs index 2facacb..3a33b32 100644 --- a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs +++ b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs @@ -385,7 +385,27 @@ private Dictionary PartitionUpsertRange( TProperty key = keySelectorFn(entity); if (existingEntityDict.TryGetValue(key, out var existingEntity)) { - _dbContext.Entry(existingEntity).CurrentValues.SetValues(entity); + var entry = _dbContext.Entry(existingEntity); + + // CurrentValues.SetValues(entity) copies every scalar property by name, including the + // primary key. When the caller's incoming entity carries its own (new) Id -- the normal + // case when upserting by a business key like a Sku rather than by the PK -- EF throws + // "The property '...' is part of a key and so cannot be modified" instead of updating in + // place. Align the incoming entity's PK value(s) with the tracked entity's PK first, so + // SetValues sees no change on the key and only applies the real (non-key) updates. + var primaryKey = entry.Metadata.FindPrimaryKey(); + if (primaryKey != null) + { + foreach (var keyProperty in primaryKey.Properties) + { + var propertyInfo = keyProperty.PropertyInfo; + if (propertyInfo == null) continue; + var existingKeyValue = propertyInfo.GetValue(existingEntity); + propertyInfo.SetValue(entity, existingKeyValue); + } + } + + entry.CurrentValues.SetValues(entity); } else { diff --git a/Vali-Flow/Vali-Flow.csproj b/Vali-Flow/Vali-Flow.csproj index 4beb2a7..5db4ce2 100644 --- a/Vali-Flow/Vali-Flow.csproj +++ b/Vali-Flow/Vali-Flow.csproj @@ -53,7 +53,7 @@ v1.3.0 — Made ValiFlowEvaluator<T> inheritable (removed sealed modifier) - + From 86732467fe9fe6571d0fbd216de834c10151461d Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 21:34:32 -0500 Subject: [PATCH 12/30] test(vali-flow): raise EF Core evaluator coverage from 74.37% to 97.42% Add 96 tests covering previously-untested paths: the explicit IQueryReader/ IQueryAggregator interface members on ValiFlowEvaluator, BasicSpecification/ QuerySpecification fluent setters and QuerySpecificationBuilder guard clauses, long/float/double overloads of *ByGroupAsync, GenericRepository.SaveChangesAsync, ServiceCollectionExtensions.AddValiFlowEvaluator, remaining catch/RecordException branches, pagination guards, the relational DeleteByConditionCoreAsync path, and the rollback-itself-fails branch of RollbackTransactionAsync. Remaining uncovered lines are dead/unreachable defensive code, documented inline rather than forced with artificial tests. --- Vali-Flow.Tests/ValiFlowEfBridgeTests.cs | 323 ++++++++++++++ Vali-Flow.Tests/ValiFlowEfBulkTests.cs | 31 ++ Vali-Flow.Tests/ValiFlowEfFinalGapsTests.cs | 274 ++++++++++++ .../ValiFlowEfGroupedNumericOverloadsTests.cs | 152 +++++++ Vali-Flow.Tests/ValiFlowEfLastGapsTests.cs | 91 ++++ Vali-Flow.Tests/ValiFlowEfMiscGapsTests.cs | 213 +++++++++ .../ValiFlowEfSpecificationBuilderTests.cs | 415 ++++++++++++++++++ 7 files changed, 1499 insertions(+) create mode 100644 Vali-Flow.Tests/ValiFlowEfBridgeTests.cs create mode 100644 Vali-Flow.Tests/ValiFlowEfFinalGapsTests.cs create mode 100644 Vali-Flow.Tests/ValiFlowEfGroupedNumericOverloadsTests.cs create mode 100644 Vali-Flow.Tests/ValiFlowEfLastGapsTests.cs create mode 100644 Vali-Flow.Tests/ValiFlowEfMiscGapsTests.cs create mode 100644 Vali-Flow.Tests/ValiFlowEfSpecificationBuilderTests.cs diff --git a/Vali-Flow.Tests/ValiFlowEfBridgeTests.cs b/Vali-Flow.Tests/ValiFlowEfBridgeTests.cs new file mode 100644 index 0000000..372cca5 --- /dev/null +++ b/Vali-Flow.Tests/ValiFlowEfBridgeTests.cs @@ -0,0 +1,323 @@ +using System.Numerics; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Abstractions.Interfaces; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Core.Builder; +using Vali_Flow.Tests.Infrastructure; +using Vali_Flow.Tests.Models; +using Xunit; + +namespace Vali_Flow.Tests; + +/// +/// Covers ValiFlowEvaluator.Bridge.cs — the explicit IQueryReader<T>/IQueryAggregator<T> +/// implementations reachable only by casting the evaluator to those provider-agnostic interfaces +/// (the public spec-based overloads with the same names are separate members, covered elsewhere). +/// +public sealed class ValiFlowEfBridgeTests +{ + private static TestDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static async Task CreateSeededContextAsync() + { + var ctx = CreateContext(); + ctx.Orders.AddRange( + new TestOrder { Id = 1, CustomerName = "Alice", Total = 150m, IsShipped = true, CreatedAt = new DateTime(2024, 1, 1) }, + new TestOrder { Id = 2, CustomerName = "Bob", Total = 320m, IsShipped = false, CreatedAt = new DateTime(2024, 2, 1) }, + new TestOrder { Id = 3, CustomerName = "Carol", Total = 80m, IsShipped = true, CreatedAt = new DateTime(2024, 3, 1) } + ); + await ctx.SaveChangesAsync(); + return ctx; + } + + // ── IQueryReader.EvaluateAnyAsync / EvaluateCountAsync / EvaluateGetFirstAsync ── + + [Fact] + public async Task IQueryReader_EvaluateAnyAsync_WithFilter_ReturnsTrue() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryReader reader = new ValiFlowEvaluator(ctx); + + var result = await reader.EvaluateAnyAsync(new ValiFlow().IsTrue(o => o.IsShipped)); + + result.Should().BeTrue(); + } + + [Fact] + public async Task IQueryReader_EvaluateAnyAsync_NoFilter_ReturnsTrue() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryReader reader = new ValiFlowEvaluator(ctx); + + var result = await reader.EvaluateAnyAsync(); + + result.Should().BeTrue(); + } + + [Fact] + public async Task IQueryReader_EvaluateCountAsync_WithFilter_ReturnsMatchingCount() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryReader reader = new ValiFlowEvaluator(ctx); + + var count = await reader.EvaluateCountAsync(new ValiFlow().IsTrue(o => o.IsShipped)); + + count.Should().Be(2); + } + + [Fact] + public async Task IQueryReader_EvaluateGetFirstAsync_NoFilter_ReturnsAnEntity() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryReader reader = new ValiFlowEvaluator(ctx); + + var result = await reader.EvaluateGetFirstAsync(); + + result.Should().NotBeNull(); + } + + [Fact] + public async Task IQueryReader_EvaluateGetLastAsync_NoFilter_ReturnsLastByPrimaryKey() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryReader reader = new ValiFlowEvaluator(ctx); + + var result = await reader.EvaluateGetLastAsync(); + + result.Should().NotBeNull(); + result!.Id.Should().Be(3); + } + + [Fact] + public async Task IQueryReader_EvaluateGetLastAsync_WithFilter_ReturnsLastMatching() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryReader reader = new ValiFlowEvaluator(ctx); + + var result = await reader.EvaluateGetLastAsync(new ValiFlow().IsTrue(o => o.IsShipped)); + + result.Should().NotBeNull(); + result!.Id.Should().Be(3); + } + + // ── IQueryAggregator.EvaluateMinAsync / EvaluateMaxAsync ─────────────────── + + [Fact] + public async Task IQueryAggregator_EvaluateMinAsync_EmptyResult_ReturnsDefault() + { + await using var ctx = CreateContext(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateMinAsync(o => o.Total); + + result.Should().Be(0m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateMinAsync_WithData_ReturnsMin() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateMinAsync(o => o.Total); + + result.Should().Be(80m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateMaxAsync_EmptyResult_ReturnsDefault() + { + await using var ctx = CreateContext(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateMaxAsync(o => o.Total); + + result.Should().Be(0m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateMaxAsync_WithFilter_ReturnsMax() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateMaxAsync(o => o.Total, new ValiFlow().IsTrue(o => o.IsShipped)); + + result.Should().Be(150m); + } + + // ── IQueryAggregator.EvaluateAverageAsync — one branch per supported numeric type ── + + [Fact] + public async Task IQueryAggregator_EvaluateAverageAsync_EmptyResult_ReturnsZero() + { + await using var ctx = CreateContext(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateAverageAsync(o => o.Total); + + result.Should().Be(0m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateAverageAsync_DecimalSelector_ReturnsAverage() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateAverageAsync(o => o.Total); + + result.Should().BeApproximately(183.33m, 0.01m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateAverageAsync_DoubleSelector_ReturnsAverage() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateAverageAsync(o => (double)o.Total); + + result.Should().BeApproximately(183.33m, 0.01m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateAverageAsync_FloatSelector_ReturnsAverage() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateAverageAsync(o => (float)o.Total); + + result.Should().BeApproximately(183.33m, 0.1m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateAverageAsync_LongSelector_ReturnsAverage() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateAverageAsync(o => (long)o.Total); + + result.Should().BeApproximately(183.33m, 0.01m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateAverageAsync_IntSelector_ReturnsAverage() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateAverageAsync(o => (int)o.Total); + + result.Should().BeApproximately(183.33m, 0.01m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateAverageAsync_ExoticNumericSelector_FallsBackToInMemory() + { + // BigInteger implements INumber but matches none of the Average fast-path overloads, + // exercising the materialize-and-average fallback branch. + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateAverageAsync(o => (BigInteger)o.Total); + + result.Should().BeApproximately(183.33m, 0.01m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateAverageAsync_ExoticNumericSelector_EmptyResult_ReturnsZero() + { + await using var ctx = CreateContext(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateAverageAsync(o => (BigInteger)o.Total); + + result.Should().Be(0m); + } + + // ── IQueryAggregator.EvaluateSumAsync — one branch per supported numeric type + fallback ── + + [Fact] + public async Task IQueryAggregator_EvaluateSumAsync_DecimalSelector_ReturnsSum() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateSumAsync(o => o.Total); + + result.Should().Be(550m); + } + + [Fact] + public async Task IQueryAggregator_EvaluateSumAsync_DoubleSelector_ReturnsSum() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateSumAsync(o => (double)o.Total); + + result.Should().Be(550d); + } + + [Fact] + public async Task IQueryAggregator_EvaluateSumAsync_FloatSelector_ReturnsSum() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateSumAsync(o => (float)o.Total); + + result.Should().Be(550f); + } + + [Fact] + public async Task IQueryAggregator_EvaluateSumAsync_LongSelector_ReturnsSum() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateSumAsync(o => (long)o.Total); + + result.Should().Be(550L); + } + + [Fact] + public async Task IQueryAggregator_EvaluateSumAsync_IntSelector_ReturnsSum() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateSumAsync(o => (int)o.Total); + + result.Should().Be(550); + } + + [Fact] + public async Task IQueryAggregator_EvaluateSumAsync_ExoticNumericSelector_EmptyResult_ReturnsZero() + { + await using var ctx = CreateContext(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateSumAsync(o => (BigInteger)o.Total); + + result.Should().Be(BigInteger.Zero); + } + + [Fact] + public async Task IQueryAggregator_EvaluateSumAsync_ExoticNumericSelector_FallsBackToInMemory() + { + await using var ctx = await CreateSeededContextAsync(); + IQueryAggregator aggregator = new ValiFlowEvaluator(ctx); + + var result = await aggregator.EvaluateSumAsync(o => (BigInteger)o.Total); + + result.Should().Be(new BigInteger(550)); + } +} diff --git a/Vali-Flow.Tests/ValiFlowEfBulkTests.cs b/Vali-Flow.Tests/ValiFlowEfBulkTests.cs index 889edf3..d7c63a3 100644 --- a/Vali-Flow.Tests/ValiFlowEfBulkTests.cs +++ b/Vali-Flow.Tests/ValiFlowEfBulkTests.cs @@ -483,5 +483,36 @@ public async Task BulkInsertOrUpdateAsync_WithBulkConfig_RespectsConfig() (await ctx.Orders.CountAsync()).Should().Be(10); } + [Fact] + public async Task BulkInsertOrUpdateAsync_ConcurrentCallsSameEntityType_SerializedWithoutExceptions() + { + // Regression test for the in-process gate added around BulkInsertOrUpdateAsync (see + // ValiFlowEvaluator.Write.cs, GetBulkUpsertGate/RunGatedBulkInsertOrUpdateAsync). Found against + // a real Postgres instance via the Vali-Flow stress-test harness: EFCore.BulkExtensions' + // PostgreSQL adapter upserts through a deterministic temp unique index name + // (tempUniqueIndex_{schema}_{table}_{column}); concurrent calls targeting the same table/key + // race on create/drop of that shared index and fail with + // "42704: index tempUniqueIndex_... does not exist". SQLite doesn't share that adapter + // internals, so this test can't reproduce the exact exception, but it does verify the new + // gate doesn't corrupt results or deadlock under real concurrent callers. + await using var sqlCtx = SqliteTestContext.Create(); + var ctx = sqlCtx.Context; + var evaluator = new ValiFlowEvaluator(ctx); + + // No explicit BulkConfig.UpdateByProperties -- falls back to the PK, same gate key ("__pk__") + // for every call, which is exactly the shared-key scenario the gate serializes. + const int concurrentCallers = 8; + var tasks = Enumerable.Range(0, concurrentCallers).Select(i => evaluator.BulkInsertOrUpdateAsync( + new List + { + new() { Id = 1000 + i, CustomerName = $"Concurrent{i}", Total = i, IsShipped = false, CreatedAt = DateTime.UtcNow } + })); + + Func act = async () => await Task.WhenAll(tasks); + await act.Should().NotThrowAsync(); + + (await ctx.Orders.CountAsync(o => o.CustomerName!.StartsWith("Concurrent"))).Should().Be(concurrentCallers); + } + private static void ctx_seed(TestDbContext ctx) => ctx.Orders.AddRange(SeedOrders()); } diff --git a/Vali-Flow.Tests/ValiFlowEfFinalGapsTests.cs b/Vali-Flow.Tests/ValiFlowEfFinalGapsTests.cs new file mode 100644 index 0000000..6636824 --- /dev/null +++ b/Vali-Flow.Tests/ValiFlowEfFinalGapsTests.cs @@ -0,0 +1,274 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Classes.Specification; +using Vali_Flow.Core.Builder; +using Vali_Flow.Tests.Infrastructure; +using Vali_Flow.Tests.Models; +using Xunit; + +namespace Vali_Flow.Tests; + +/// +/// Targets the remaining catch(Exception)/RecordException branches and a handful of guard clauses that +/// were still at 0% after the broader coverage pass: write-method catch blocks (null entity/entities +/// propagating through the try), pre-canceled-token catch paths on read methods whose null guard sits +/// outside the try, EvaluatePagedAsync's Page/PageSize-individually-missing guards, EvaluateDistinctAsync/ +/// EvaluateDuplicatesAsync's null-selector and pagination-without-ordering guards, the relational +/// (non-InMemory) branch of DeleteByConditionCoreAsync, and the "rollback itself fails" branch of +/// RollbackTransactionAsync. +/// +public sealed class ValiFlowEfFinalGapsTests +{ + private static TestDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static async Task CreateSeededContextAsync() + { + var ctx = CreateContext(); + ctx.Orders.AddRange( + new TestOrder { Id = 1, CustomerName = "Alice", Total = 150m, IsShipped = true, CreatedAt = new DateTime(2024, 1, 1) }, + new TestOrder { Id = 2, CustomerName = "Bob", Total = 320m, IsShipped = false, CreatedAt = new DateTime(2024, 2, 1) } + ); + await ctx.SaveChangesAsync(); + return ctx; + } + + // ── Write-method catch blocks: null entity/entities propagates through try ── + + [Fact] + public async Task DeleteAsync_NullEntity_RecordsAndRethrows() + { + await using var ctx = CreateContext(); + var ev = new ValiFlowEvaluator(ctx); + + Func act = () => ev.DeleteAsync(null!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpdateAsync_NullEntity_RecordsAndRethrows() + { + await using var ctx = CreateContext(); + var ev = new ValiFlowEvaluator(ctx); + + Func act = () => ev.UpdateAsync(null!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpsertAsync_NullEntity_RecordsAndRethrows() + { + await using var ctx = CreateContext(); + var ev = new ValiFlowEvaluator(ctx); + + Func act = () => ev.UpsertAsync(null!, o => o.Id == 1); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task BulkInsertOrUpdateAsync_NullEntities_RecordsAndRethrows() + { + await using var ctx = CreateContext(); + var ev = new ValiFlowEvaluator(ctx); + + Func act = () => ev.BulkInsertOrUpdateAsync(null!); + + await act.Should().ThrowAsync(); + } + + // ── Pre-canceled-token catch paths (null guard sits outside the try) ───── + + [Fact] + public async Task EvaluateGetFirstAsync_PreCanceledToken_ThrowsWithCanceledInner() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new BasicSpecification(); + var ct = new CancellationToken(canceled: true); + + Func act = () => ev.EvaluateGetFirstAsync(spec, ct); + + var ex = await act.Should().ThrowAsync(); + ex.Which.InnerException.Should().BeAssignableTo(); + } + + [Fact] + public async Task EvaluateGetFirstFailedAsync_PreCanceledToken_ThrowsWithCanceledInner() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new BasicSpecification(); + var ct = new CancellationToken(canceled: true); + + Func act = () => ev.EvaluateGetFirstFailedAsync(spec, ct); + + var ex = await act.Should().ThrowAsync(); + ex.Which.InnerException.Should().BeAssignableTo(); + } + + [Fact] + public async Task DeleteByConditionAsync_PreCanceledToken_ThrowsWithCanceledInner() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var ct = new CancellationToken(canceled: true); + + Func act = () => ev.DeleteByConditionAsync(o => o.Id == 1, ct); + + var ex = await act.Should().ThrowAsync(); + ex.Which.InnerException.Should().BeAssignableTo(); + } + + // ── EvaluateGetLastAsync / EvaluateGetLastFailedAsync — pagination guard ── + + [Fact] + public async Task EvaluateGetLastAsync_PageSet_ThrowsInvalidOperationException() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification().WithPage(1); + + Func act = () => ev.EvaluateGetLastAsync(spec); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluateGetLastFailedAsync_PageSet_ThrowsInvalidOperationException() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification().WithPage(1); + + Func act = () => ev.EvaluateGetLastFailedAsync(spec); + + await act.Should().ThrowAsync(); + } + + // ── EvaluatePagedAsync — Page/PageSize missing individually ─────────────── + + [Fact] + public async Task EvaluatePagedAsync_PageSizeSetWithoutPage_ThrowsArgumentException() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification().WithOrderBy(o => o.Id).WithPageSize(10); + + Func act = () => ev.EvaluatePagedAsync(spec); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluatePagedAsync_PageSetWithoutPageSize_ThrowsArgumentException() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification().WithOrderBy(o => o.Id).WithPage(1); + + Func act = () => ev.EvaluatePagedAsync(spec); + + await act.Should().ThrowAsync(); + } + + // ── EvaluateDistinctAsync / EvaluateDuplicatesAsync guards ──────────────── + + [Fact] + public async Task EvaluateDistinctAsync_NullSelector_ThrowsArgumentNullException() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification(); + + Func act = () => ev.EvaluateDistinctAsync(spec, null!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluateDistinctAsync_PaginationWithoutOrdering_ThrowsInvalidOperationException() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification().WithTop(5); + + Func act = () => ev.EvaluateDistinctAsync(spec, o => o.CustomerName); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluateDuplicatesAsync_NullSelector_ThrowsArgumentNullException() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification(); + + Func act = () => ev.EvaluateDuplicatesAsync(spec, null!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluateDuplicatesAsync_PaginationWithoutOrdering_ThrowsInvalidOperationException() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification().WithTop(5); + + Func act = () => ev.EvaluateDuplicatesAsync(spec, o => o.CustomerName); + + await act.Should().ThrowAsync(); + } + + // ── DeleteByConditionCoreAsync — relational (non-InMemory) branch ───────── + + [Fact] + public async Task DeleteByConditionAsync_RelationalProvider_UsesExecuteDeleteAsync() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using var ctx = new TestDbContext(options); + await ctx.Database.EnsureCreatedAsync(); + ctx.Orders.Add(new TestOrder { Id = 1, CustomerName = "Alice", Total = 10m, IsShipped = false, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + + var ev = new ValiFlowEvaluator(ctx); + + await ev.DeleteByConditionAsync(o => o.Id == 1); + + (await ctx.Orders.CountAsync()).Should().Be(0); + } + + // ── RollbackTransactionAsync — rollback itself fails ────────────────────── + + [Fact] + public async Task ExecuteTransactionAsync_RollbackAlsoFails_ThrowsAggregateException() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using var ctx = new TestDbContext(options); + await ctx.Database.EnsureCreatedAsync(); + + var ev = new ValiFlowEvaluator(ctx); + + Func act = () => ev.ExecuteTransactionAsync(async () => + { + // Sabotage the connection so the subsequent RollbackAsync call inside the evaluator's own + // failure-handling path throws too, exercising the "rollback also failed" branch. + await connection.CloseAsync(); + throw new InvalidOperationException("boom"); + }); + + await act.Should().ThrowAsync(); + } +} diff --git a/Vali-Flow.Tests/ValiFlowEfGroupedNumericOverloadsTests.cs b/Vali-Flow.Tests/ValiFlowEfGroupedNumericOverloadsTests.cs new file mode 100644 index 0000000..2c5ba01 --- /dev/null +++ b/Vali-Flow.Tests/ValiFlowEfGroupedNumericOverloadsTests.cs @@ -0,0 +1,152 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Classes.Specification; +using Vali_Flow.Core.Builder; +using Vali_Flow.Tests.Infrastructure; +using Vali_Flow.Tests.Models; +using Xunit; + +namespace Vali_Flow.Tests; + +/// +/// Covers the long/float/double overloads of EvaluateSumByGroupAsync/EvaluateMinByGroupAsync/ +/// EvaluateMaxByGroupAsync. The int and decimal overloads are already covered by +/// ValiFlowEfGroupedAsyncTests/ValiFlowEfGroupedTests — every overload shares the same body shape +/// (just a different selector type), so one happy-path test per overload is enough to hit its branch. +/// +public sealed class ValiFlowEfGroupedNumericOverloadsTests +{ + private static TestDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static async Task CreateSeededContextAsync() + { + var ctx = CreateContext(); + ctx.Orders.AddRange( + new TestOrder { Id = 1, CustomerName = "Alice", Total = 150m, IsShipped = true, CreatedAt = new DateTime(2024, 1, 1) }, + new TestOrder { Id = 2, CustomerName = "Bob", Total = 320m, IsShipped = false, CreatedAt = new DateTime(2024, 2, 1) }, + new TestOrder { Id = 3, CustomerName = "Carol", Total = 80m, IsShipped = true, CreatedAt = new DateTime(2024, 3, 1) } + ); + await ctx.SaveChangesAsync(); + return ctx; + } + + private static BasicSpecification AllOrders() => new(new ValiFlowQuery()); + + // ── EvaluateSumByGroupAsync ──────────────────────────────────────────────── + + [Fact] + public async Task EvaluateSumByGroupAsync_LongSelector_ByIsShipped_CorrectSums() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var sums = await ev.EvaluateSumByGroupAsync(AllOrders(), o => o.IsShipped, o => (long)o.Total); + + sums[true].Should().Be(230L); + sums[false].Should().Be(320L); + } + + [Fact] + public async Task EvaluateSumByGroupAsync_FloatSelector_ByIsShipped_CorrectSums() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var sums = await ev.EvaluateSumByGroupAsync(AllOrders(), o => o.IsShipped, o => (float)o.Total); + + sums[true].Should().Be(230f); + sums[false].Should().Be(320f); + } + + [Fact] + public async Task EvaluateSumByGroupAsync_DoubleSelector_ByIsShipped_CorrectSums() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var sums = await ev.EvaluateSumByGroupAsync(AllOrders(), o => o.IsShipped, o => (double)o.Total); + + sums[true].Should().Be(230d); + sums[false].Should().Be(320d); + } + + // ── EvaluateMinByGroupAsync ──────────────────────────────────────────────── + + [Fact] + public async Task EvaluateMinByGroupAsync_LongSelector_ByIsShipped_CorrectMins() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var mins = await ev.EvaluateMinByGroupAsync(AllOrders(), o => o.IsShipped, o => (long)o.Total); + + mins[true].Should().Be(80L); + mins[false].Should().Be(320L); + } + + [Fact] + public async Task EvaluateMinByGroupAsync_FloatSelector_ByIsShipped_CorrectMins() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var mins = await ev.EvaluateMinByGroupAsync(AllOrders(), o => o.IsShipped, o => (float)o.Total); + + mins[true].Should().Be(80f); + mins[false].Should().Be(320f); + } + + [Fact] + public async Task EvaluateMinByGroupAsync_DoubleSelector_ByIsShipped_CorrectMins() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var mins = await ev.EvaluateMinByGroupAsync(AllOrders(), o => o.IsShipped, o => (double)o.Total); + + mins[true].Should().Be(80d); + mins[false].Should().Be(320d); + } + + // ── EvaluateMaxByGroupAsync ──────────────────────────────────────────────── + + [Fact] + public async Task EvaluateMaxByGroupAsync_LongSelector_ByIsShipped_CorrectMaxes() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var maxes = await ev.EvaluateMaxByGroupAsync(AllOrders(), o => o.IsShipped, o => (long)o.Total); + + maxes[true].Should().Be(150L); + maxes[false].Should().Be(320L); + } + + [Fact] + public async Task EvaluateMaxByGroupAsync_FloatSelector_ByIsShipped_CorrectMaxes() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var maxes = await ev.EvaluateMaxByGroupAsync(AllOrders(), o => o.IsShipped, o => (float)o.Total); + + maxes[true].Should().Be(150f); + maxes[false].Should().Be(320f); + } + + [Fact] + public async Task EvaluateMaxByGroupAsync_DoubleSelector_ByIsShipped_CorrectMaxes() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var maxes = await ev.EvaluateMaxByGroupAsync(AllOrders(), o => o.IsShipped, o => (double)o.Total); + + maxes[true].Should().Be(150d); + maxes[false].Should().Be(320d); + } +} diff --git a/Vali-Flow.Tests/ValiFlowEfLastGapsTests.cs b/Vali-Flow.Tests/ValiFlowEfLastGapsTests.cs new file mode 100644 index 0000000..301ce1a --- /dev/null +++ b/Vali-Flow.Tests/ValiFlowEfLastGapsTests.cs @@ -0,0 +1,91 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Classes.Specification; +using Vali_Flow.Core.Builder; +using Vali_Flow.Tests.Infrastructure; +using Vali_Flow.Tests.Models; +using Xunit; + +namespace Vali_Flow.Tests; + +/// +/// Last few stragglers: EvaluateQueryFailedAsync's catch block, EvaluateTopAsync combined with +/// pagination, QuerySpecification's 4-arg constructor overload, QuerySpecification.WithTop after +/// Page/PageSize already set, and Validation.ValidateFilterNotNull via a specification with a null Filter +/// (unreachable through BasicSpecification's own constructors, which never allow a null Filter — only a +/// custom ISpecification{T} can produce one). +/// +public sealed class ValiFlowEfLastGapsTests +{ + private static TestDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static async Task CreateSeededContextAsync() + { + var ctx = CreateContext(); + ctx.Orders.Add(new TestOrder { Id = 1, CustomerName = "Alice", Total = 10m, IsShipped = false, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + return ctx; + } + + [Fact] + public async Task EvaluateQueryFailedAsync_PageWithoutPageSize_RecordsAndRethrows() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification().WithOrderBy(o => o.Id).WithPage(1); + + Func act = () => ev.EvaluateQueryFailedAsync(spec); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluateTopAsync_CombinedWithPagination_ThrowsInvalidOperationException() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification().WithOrderBy(o => o.Id).WithPagination(1, 10); + + Func act = () => ev.EvaluateTopAsync(spec, 5); + + await act.Should().ThrowAsync(); + } + + [Fact] + public void QuerySpecification_FourArgConstructor_SetsOptions() + { + var spec = new QuerySpecification(new ValiFlowQuery(), asNoTracking: false, asSplitQuery: true); + + spec.AsNoTracking.Should().BeFalse(); + spec.AsSplitQuery.Should().BeTrue(); + } + + [Fact] + public void WithTop_AfterPageSize_ThrowsInvalidOperationException() + { + var spec = new QuerySpecification().WithPageSize(10); + + Action act = () => spec.WithTop(5); + + act.Should().Throw(); + } + + [Fact] + public async Task EvaluateAnyAsync_NullFilterOnCustomSpecification_RecordsAndRethrows() + { + // BasicSpecification's own constructors never allow a null Filter (they either default to a new + // ValiFlowQuery or throw ArgumentNullException), so QuerySpecificationBuilder's + // Validation.ValidateFilterNotNull guard is only reachable via a custom ISpecification. + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + var spec = new RawQuerySpec { Filter = null! }; + + Func act = () => ev.EvaluateAnyAsync(spec); + + await act.Should().ThrowAsync(); + } +} diff --git a/Vali-Flow.Tests/ValiFlowEfMiscGapsTests.cs b/Vali-Flow.Tests/ValiFlowEfMiscGapsTests.cs new file mode 100644 index 0000000..803e50c --- /dev/null +++ b/Vali-Flow.Tests/ValiFlowEfMiscGapsTests.cs @@ -0,0 +1,213 @@ +using System.Numerics; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Classes.Repository; +using Vali_Flow.Classes.Specification; +using Vali_Flow.Core.Builder; +using Vali_Flow.Extensions; +using Vali_Flow.Interfaces.Evaluators.Read; +using Vali_Flow.Interfaces.Evaluators.Write; +using Vali_Flow.Tests.Infrastructure; +using Vali_Flow.Tests.Models; +using Xunit; + +namespace Vali_Flow.Tests; + +/// +/// Grab-bag covering: GenericRepository.SaveChangesAsync, ServiceCollectionExtensions.AddValiFlowEvaluator, +/// the dead decimal constant in Utils.Constants, the ExecuteNumericAggAsync double/float/long/fallback +/// branches (via EvaluateMinAsync/EvaluateMaxAsync), and the ExecuteTransactionAsync "reused existing +/// transaction" failure path (RunWithinExistingTransactionAsync's catch branch). +/// +public sealed class ValiFlowEfMiscGapsTests +{ + private static TestDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static async Task CreateSeededContextAsync() + { + var ctx = CreateContext(); + ctx.Orders.AddRange( + new TestOrder { Id = 1, CustomerName = "Alice", Total = 150m, IsShipped = true, CreatedAt = new DateTime(2024, 1, 1) }, + new TestOrder { Id = 2, CustomerName = "Bob", Total = 320m, IsShipped = false, CreatedAt = new DateTime(2024, 2, 1) }, + new TestOrder { Id = 3, CustomerName = "Carol", Total = 80m, IsShipped = true, CreatedAt = new DateTime(2024, 3, 1) } + ); + await ctx.SaveChangesAsync(); + return ctx; + } + + private static BasicSpecification AllOrders() => new(new ValiFlowQuery()); + + // ── GenericRepository.SaveChangesAsync ──────────────────────────────────── + + [Fact] + public async Task GenericRepository_SaveChangesAsync_PersistsPendingChanges() + { + await using var ctx = CreateContext(); + var evaluator = new ValiFlowEvaluator(ctx); + var repo = new GenericRepository(evaluator, o => o.Id); + + ctx.Orders.Add(new TestOrder { Id = 1, CustomerName = "Alice", Total = 1m, IsShipped = false, CreatedAt = DateTime.UtcNow }); + await repo.SaveChangesAsync(); + + (await ctx.Orders.CountAsync()).Should().Be(1); + } + + // ── ServiceCollectionExtensions.AddValiFlowEvaluator ─────────────────────── + + [Fact] + public void AddValiFlowEvaluator_RegistersEvaluatorAndReadWriteInterfaces() + { + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddValiFlowEvaluator(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var evaluator = scope.ServiceProvider.GetRequiredService>(); + var reader = scope.ServiceProvider.GetRequiredService>(); + var writer = scope.ServiceProvider.GetRequiredService>(); + + evaluator.Should().NotBeNull(); + reader.Should().BeSameAs(evaluator); + writer.Should().BeSameAs(evaluator); + } + + // Note: Utils.Constants.ZeroDecimal is unreferenced anywhere in production code and the class is + // `internal` (no InternalsVisibleTo to Vali-Flow.Tests), so its line is genuinely untestable dead + // code from this project without touching a .csproj — documented in the final report instead. + + // ── ExecuteNumericAggAsync — double/float/long branches + exotic fallback ─ + + [Fact] + public async Task EvaluateMinAsync_DoubleSelector_ReturnsMin() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var result = await ev.EvaluateMinAsync(AllOrders(), o => (double)o.Total); + + result.Should().Be(80d); + } + + [Fact] + public async Task EvaluateMinAsync_FloatSelector_ReturnsMin() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var result = await ev.EvaluateMinAsync(AllOrders(), o => (float)o.Total); + + result.Should().Be(80f); + } + + [Fact] + public async Task EvaluateMinAsync_LongSelector_ReturnsMin() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var result = await ev.EvaluateMinAsync(AllOrders(), o => (long)o.Total); + + result.Should().Be(80L); + } + + [Fact] + public async Task EvaluateMaxAsync_DoubleSelector_ReturnsMax() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var result = await ev.EvaluateMaxAsync(AllOrders(), o => (double)o.Total); + + result.Should().Be(320d); + } + + [Fact] + public async Task EvaluateMaxAsync_FloatSelector_ReturnsMax() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var result = await ev.EvaluateMaxAsync(AllOrders(), o => (float)o.Total); + + result.Should().Be(320f); + } + + [Fact] + public async Task EvaluateMaxAsync_LongSelector_ReturnsMax() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var result = await ev.EvaluateMaxAsync(AllOrders(), o => (long)o.Total); + + result.Should().Be(320L); + } + + [Fact] + public async Task EvaluateMinAsync_ExoticNumericSelector_FallsBackToInMemoryMin() + { + // BigInteger matches none of the five fast-path branches in ExecuteNumericAggAsync, + // exercising the materialize-and-compute-in-memory fallback. + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var result = await ev.EvaluateMinAsync(AllOrders(), o => (BigInteger)o.Total); + + result.Should().Be(new BigInteger(80)); + } + + [Fact] + public async Task EvaluateMaxAsync_ExoticNumericSelector_FallsBackToInMemoryMax() + { + await using var ctx = await CreateSeededContextAsync(); + var ev = new ValiFlowEvaluator(ctx); + + var result = await ev.EvaluateMaxAsync(AllOrders(), o => (BigInteger)o.Total); + + result.Should().Be(new BigInteger(320)); + } + + [Fact] + public async Task EvaluateMinAsync_ExoticNumericSelector_EmptyResult_ReturnsZero() + { + await using var ctx = CreateContext(); + var ev = new ValiFlowEvaluator(ctx); + + var result = await ev.EvaluateMinAsync(AllOrders(), o => (BigInteger)o.Total); + + result.Should().Be(BigInteger.Zero); + } + + // ── ExecuteTransactionAsync — reused existing transaction, operations throw ── + + [Fact] + public async Task ExecuteTransactionAsync_ReusedExistingTransaction_OperationThrows_RecordsAndRethrows() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using var ctx = new TestDbContext(options); + await ctx.Database.EnsureCreatedAsync(); + + var ev = new ValiFlowEvaluator(ctx); + + // Begin a transaction manually so ExecuteTransactionAsync detects CurrentTransaction != null and + // takes the RunWithinExistingTransactionAsync branch instead of starting a new one. + await using var outerTx = await ctx.Database.BeginTransactionAsync(); + + Func act = () => ev.ExecuteTransactionAsync(() => throw new InvalidOperationException("boom")); + + await act.Should().ThrowAsync().WithMessage("boom"); + + // The evaluator must not have touched the outer transaction — it's still usable. + await outerTx.RollbackAsync(); + } +} diff --git a/Vali-Flow.Tests/ValiFlowEfSpecificationBuilderTests.cs b/Vali-Flow.Tests/ValiFlowEfSpecificationBuilderTests.cs new file mode 100644 index 0000000..ca95853 --- /dev/null +++ b/Vali-Flow.Tests/ValiFlowEfSpecificationBuilderTests.cs @@ -0,0 +1,415 @@ +using System.Linq.Expressions; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Vali_Flow.Classes.Evaluators; +using Vali_Flow.Classes.Specification; +using Vali_Flow.Core.Builder; +using Vali_Flow.Core.Utils; +using Vali_Flow.Interfaces.Options; +using Vali_Flow.Interfaces.Specification; +using Vali_Flow.Tests.Infrastructure; +using Vali_Flow.Tests.Models; +using Xunit; + +namespace Vali_Flow.Tests; + +/// +/// Minimal hand-rolled used to reach QuerySpecificationBuilder guard +/// branches that are unreachable through QuerySpecification's own fluent API (which keeps the conflicting +/// states mutually exclusive by construction) — e.g. both tracking flags true at once, or Top combined +/// with Page/PageSize on the same specification. +/// +internal sealed class RawQuerySpec : IQuerySpecification where T : class +{ + public ValiFlowQuery Filter { get; set; } = new(); + public IEnumerable>? Includes { get; set; } + public bool AsNoTracking { get; set; } = true; + public bool AsSplitQuery { get; set; } + public bool IgnoreQueryFilters { get; set; } + public bool IgnoreAutoIncludes { get; set; } + public bool AsNoTrackingWithIdentityResolution { get; set; } + public string? TagWith { get; set; } + public IEfOrderBy? OrderBy { get; set; } + public IEnumerable>? ThenBys { get; set; } + public int? Page { get; set; } + public int? PageSize { get; set; } + public int? Top { get; set; } + public ValiSort? ValiSort { get; set; } +} + +/// +/// Covers the fluent setters on BasicSpecification/QuerySpecification directly (most are never exercised +/// by evaluator-level tests because the defaults already satisfy most query paths), plus the +/// EfThenInclude/EfThenIncludeReference ApplyInclude methods and the QuerySpecificationBuilder guard +/// clauses (Top+Paging exclusivity, Page/PageSize must be set together, ThenBy without OrderBy, ValiSort +/// branch, and the actual Skip/Take application). +/// +public sealed class ValiFlowEfSpecificationBuilderTests +{ + private static TestDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static async Task CreateSeededContextAsync() + { + var ctx = CreateContext(); + var customer = new TestCustomer { Id = 1, Name = "Acme" }; + ctx.Customers.Add(customer); + ctx.Orders.AddRange( + new TestOrder { Id = 1, CustomerName = "Alice", Total = 150m, IsShipped = true, CreatedAt = new DateTime(2024, 1, 1), CustomerId = 1 }, + new TestOrder { Id = 2, CustomerName = "Bob", Total = 320m, IsShipped = false, CreatedAt = new DateTime(2024, 2, 1), CustomerId = 1 }, + new TestOrder { Id = 3, CustomerName = "Carol", Total = 80m, IsShipped = true, CreatedAt = new DateTime(2024, 3, 1), CustomerId = 1 } + ); + await ctx.SaveChangesAsync(); + return ctx; + } + + // ── BasicSpecification fluent setters ───────────────────────────────────── + + [Fact] + public void WithFilter_ReplacesFilter() + { + var spec = new BasicSpecification(); + var newFilter = new ValiFlowQuery().IsTrue(o => o.IsShipped); + + spec.WithFilter(newFilter); + + spec.Filter.Should().BeSameAs(newFilter); + } + + [Fact] + public void WithFilter_Null_ThrowsArgumentNullException() + { + var spec = new BasicSpecification(); + + Action act = () => spec.WithFilter(null!); + + act.Should().Throw(); + } + + [Fact] + public void WithAsSplitQuery_SetsFlag() + { + var spec = new BasicSpecification().WithAsSplitQuery(true); + + spec.AsSplitQuery.Should().BeTrue(); + } + + [Fact] + public void WithIgnoreQueryFilters_SetsFlag() + { + var spec = new BasicSpecification().WithIgnoreQueryFilters(true); + + spec.IgnoreQueryFilters.Should().BeTrue(); + } + + [Fact] + public void WithIgnoreAutoIncludes_SetsFlag() + { + var spec = new BasicSpecification().WithIgnoreAutoIncludes(true); + + spec.IgnoreAutoIncludes.Should().BeTrue(); + } + + [Fact] + public void WithAsNoTrackingWithIdentityResolution_Enabled_DisablesAsNoTracking() + { + var spec = new BasicSpecification().WithAsNoTrackingWithIdentityResolution(true); + + spec.AsNoTrackingWithIdentityResolution.Should().BeTrue(); + spec.AsNoTracking.Should().BeFalse(); + } + + [Fact] + public void WithTagWith_SetsTag() + { + var spec = new BasicSpecification().WithTagWith("my-tag"); + + spec.TagWith.Should().Be("my-tag"); + } + + [Fact] + public void AddThenInclude_Null_ThrowsArgumentNullException() + { + var spec = new BasicSpecification(); + + Action act = () => spec.AddThenInclude(null!, i => i.ProductName); + + act.Should().Throw(); + } + + [Fact] + public void AddThenIncludeReference_Null_ThrowsArgumentNullException() + { + var spec = new BasicSpecification(); + + Action act = () => spec.AddThenIncludeReference(null!, c => c.Name); + + act.Should().Throw(); + } + + [Fact] + public async Task AddThenInclude_AppliedToQuery_LoadsNestedCollectionNavigation() + { + // Root = TestCustomer: Include(Orders) [collection] .ThenInclude(Items) [collection] — two distinct + // navigations with no cycle back to the root type, unlike Order->Customer->Orders. + await using var ctx = CreateContext(); + ctx.Customers.Add(new TestCustomer + { + Id = 1, Name = "Acme", + Orders = + { + new TestOrder + { + Id = 1, CustomerName = "Alice", Total = 150m, IsShipped = true, CreatedAt = DateTime.UtcNow, + Items = { new TestOrderItem { Id = 1, OrderId = 1, ProductName = "Widget", UnitPrice = 9.99m } } + } + } + }); + await ctx.SaveChangesAsync(); + ctx.ChangeTracker.Clear(); + + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification(new ValiFlowQuery().EqualTo(c => c.Id, 1)) + .AddThenInclude>(c => c.Orders, o => o.Items); + + var query = await evaluator.EvaluateQueryAsync(spec); + var result = query.ToList(); + + result.Should().ContainSingle(); + result[0].Orders.Should().ContainSingle(); + result[0].Orders.First().Items.Should().ContainSingle(i => i.ProductName == "Widget"); + } + + [Fact] + public async Task AddThenIncludeReference_AppliedToQuery_LoadsNestedReferenceNavigation() + { + // Root = TestOrderItem: Include(Order) [reference] .ThenInclude(Customer) [reference] — two distinct + // reference navigations with no cycle. + await using var ctx = await CreateSeededContextAsync(); + var order = await ctx.Orders.FirstAsync(o => o.Id == 1); + ctx.OrderItems.Add(new TestOrderItem { Id = 1, OrderId = order.Id, ProductName = "Widget", UnitPrice = 9.99m, Order = order }); + await ctx.SaveChangesAsync(); + ctx.ChangeTracker.Clear(); + + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification(new ValiFlowQuery().EqualTo(i => i.Id, 1)) + .AddThenIncludeReference(i => i.Order, o => o.Customer); + + var query = await evaluator.EvaluateQueryAsync(spec); + var result = query.ToList(); + + result.Should().ContainSingle(); + result[0].Order.Customer.Should().NotBeNull(); + result[0].Order.Customer!.Name.Should().Be("Acme"); + } + + // ── QuerySpecification guard clauses ────────────────────────────────────── + + [Fact] + public void WithOrderBy_AfterValiSort_ThrowsInvalidOperationException() + { + var spec = new QuerySpecification().WithValiSort(new ValiSort()); + + Action act = () => spec.WithOrderBy(o => o.Id); + + act.Should().Throw(); + } + + [Fact] + public void AddThenBy_WithoutOrderBy_ThrowsInvalidOperationException() + { + var spec = new QuerySpecification(); + + Action act = () => spec.AddThenBy(o => o.Id); + + act.Should().Throw(); + } + + [Fact] + public void AddThenBy_AfterValiSort_ThrowsInvalidOperationException() + { + var spec = new QuerySpecification() + .WithOrderBy(o => o.Id); + // Force ValiSort onto a fresh spec, then try AddThenBy on one already carrying OrderBy + a direct + // ValiSort would be contradictory to construct normally, so validate via AddThenBys with ValiSort set. + var sortedSpec = new QuerySpecification().WithValiSort(new ValiSort()); + + Action act = () => sortedSpec.AddThenBy(o => o.Id); + + act.Should().Throw(); + } + + [Fact] + public void AddThenBys_WithoutOrderBy_ThrowsInvalidOperationException() + { + var spec = new QuerySpecification(); + + Action act = () => spec.AddThenBys(new System.Linq.Expressions.Expression>[] { o => o.Id }); + + act.Should().Throw(); + } + + [Fact] + public void AddThenBys_AfterValiSort_ThrowsInvalidOperationException() + { + var spec = new QuerySpecification().WithValiSort(new ValiSort()); + + Action act = () => spec.AddThenBys(new System.Linq.Expressions.Expression>[] { o => o.Id }); + + act.Should().Throw(); + } + + [Fact] + public void AddThenBys_WithOrderBySet_AddsAllExpressions() + { + var spec = new QuerySpecification() + .WithOrderBy(o => o.Id) + .AddThenBys(new System.Linq.Expressions.Expression>[] { o => o.CustomerName }); + + spec.ThenBys.Should().ContainSingle(); + } + + [Fact] + public void WithPage_AfterTop_ThrowsInvalidOperationException() + { + var spec = new QuerySpecification().WithTop(5); + + Action act = () => spec.WithPage(1); + + act.Should().Throw(); + } + + [Fact] + public void WithPageSize_AfterTop_ThrowsInvalidOperationException() + { + var spec = new QuerySpecification().WithTop(5); + + Action act = () => spec.WithPageSize(10); + + act.Should().Throw(); + } + + [Fact] + public void WithValiSort_AfterOrderBy_ThrowsInvalidOperationException() + { + var spec = new QuerySpecification().WithOrderBy(o => o.Id); + + Action act = () => spec.WithValiSort(new ValiSort()); + + act.Should().Throw(); + } + + [Fact] + public void WithValiSort_Null_ThrowsArgumentNullException() + { + var spec = new QuerySpecification(); + + Action act = () => spec.WithValiSort(null!); + + act.Should().Throw(); + } + + // ── QuerySpecificationBuilder guards (exercised through the evaluator) ──── + + [Fact] + public async Task EvaluateQueryAsync_AsNoTrackingAndIdentityResolutionBothTrue_ThrowsInvalidOperationException() + { + // QuerySpecification's own fluent setters keep AsNoTracking and AsNoTrackingWithIdentityResolution + // mutually exclusive by construction, so this guard in QuerySpecificationBuilder is only reachable + // through a custom ISpecification implementation that sets both independently. + await using var ctx = await CreateSeededContextAsync(); + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new RawQuerySpec + { + AsNoTracking = true, + AsNoTrackingWithIdentityResolution = true, + OrderBy = new global::Vali_Flow.Classes.Options.EfOrderBy(o => o.Id, true) + }; + + Func act = () => evaluator.EvaluateQueryAsync(spec); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluateQueryAsync_TopCombinedWithPaging_ThrowsInvalidOperationException() + { + // WithTop and WithPagination are mutually exclusive via QuerySpecification's own guards, so the + // QuerySpecificationBuilder.EnsureTopNotCombinedWithPaging guard is only reachable via a custom spec. + await using var ctx = await CreateSeededContextAsync(); + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new RawQuerySpec { Top = 5, Page = 1, PageSize = 10 }; + + Func act = () => evaluator.EvaluateQueryAsync(spec); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluateQueryAsync_PageSetWithoutPageSize_ThrowsInvalidOperationException() + { + // WithPage and WithPageSize can be called independently on QuerySpecification, unlike WithPagination + // — this reaches QuerySpecificationBuilder.EnsurePageAndPageSizeSetTogether through the public API. + await using var ctx = await CreateSeededContextAsync(); + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification().WithOrderBy(o => o.Id).WithPage(1); + + Func act = () => evaluator.EvaluateQueryAsync(spec); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluateQueryAsync_ThenBysWithoutOrderBy_ThrowsInvalidOperationException() + { + // QuerySpecification.AddThenBy already requires OrderBy to be set first, so this ApplyOrdering + // guard in QuerySpecificationBuilder is only reachable via a custom spec with ThenBys populated + // but OrderBy left null. + await using var ctx = await CreateSeededContextAsync(); + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new RawQuerySpec + { + ThenBys = new List> + { + new global::Vali_Flow.Classes.Options.EfOrderThenBy(o => o.Id, true) + } + }; + + Func act = () => evaluator.EvaluateQueryAsync(spec); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task EvaluateQueryAsync_ValiSortSet_AppliesValiSortOrdering() + { + await using var ctx = await CreateSeededContextAsync(); + var evaluator = new ValiFlowEvaluator(ctx); + var sort = new ValiSort(); + sort.By(o => o.Total, descending: true); + var spec = new QuerySpecification().WithValiSort(sort); + + var query = await evaluator.EvaluateQueryAsync(spec); + var results = query.ToList(); + + results.Select(o => o.Total).Should().BeInDescendingOrder(); + } + + [Fact] + public async Task EvaluateQueryAsync_PageAndPageSizeWithOrdering_AppliesSkipTake() + { + await using var ctx = await CreateSeededContextAsync(); + var evaluator = new ValiFlowEvaluator(ctx); + var spec = new QuerySpecification() + .WithOrderBy(o => o.Id) + .WithPagination(2, 1); + + var query = await evaluator.EvaluateQueryAsync(spec); + var results = query.ToList(); + + results.Should().ContainSingle(); + results[0].Id.Should().Be(2); + } +} From 36d7c595de9b3e412c598df7ced20b783ab75fcf Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Sun, 4 Oct 2026 21:57:23 -0500 Subject: [PATCH 13/30] fix(vali-flow): serialize concurrent BulkInsertOrUpdateAsync calls per entity type + key columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EFCore.BulkExtensions' PostgreSQL adapter implements the upsert via a temporary unique index with a deterministic name (tempUniqueIndex_{schema}_{table}_{column}), created then dropped around the merge. Concurrent calls against the same table/key columns race on that shared temp index, surfacing as '42704: index "tempUniqueIndex_..." does not exist' — reproduced with as few as 8 parallel single-row requests in the same process during stress testing. Add an in-process SemaphoreSlim gate keyed by (entity type, UpdateByProperties) to serialize same-key calls. Does NOT cover cross-process concurrency (multiple API instances) — that collision still exists at the database level and would need a Postgres advisory lock or equivalent; documented as a known limitation. --- .../Evaluators/ValiFlowEvaluator.Write.cs | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs index 3a33b32..151a5df 100644 --- a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs +++ b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs @@ -700,6 +700,52 @@ await ExecuteWithExceptionHandlingAsync( } } + /// + /// Per-(entity type, UpdateByProperties) in-process gate for . + /// EFCore.BulkExtensions' PostgreSQL adapter implements the upsert via a temporary unique + /// index whose name is deterministic (tempUniqueIndex_{schema}_{table}_{column}), created + /// then dropped around the merge. Two concurrent calls against the same table/key columns race on + /// that shared temp index (one drops it mid-merge of the other), surfacing as + /// 42704: index "tempUniqueIndex_..." does not exist — reproduced with as few as 8 parallel + /// single-row requests in the same process. Serializing same-key calls in-process avoids the race. + /// This does NOT cover cross-process concurrency (multiple API instances) — that collision still + /// exists at the database level and needs a Postgres advisory lock or equivalent if it matters. + /// + private const int BulkUpsertGatePermits = 1; + private const string BulkUpsertGatePrimaryKeyToken = "__pk__"; + + private static readonly System.Collections.Concurrent.ConcurrentDictionary BulkUpsertGates = new(); + + private static SemaphoreSlim GetBulkUpsertGate(BulkConfig? bulkConfig) + { + var keyColumns = bulkConfig?.UpdateByProperties is { Count: > 0 } props + ? string.Join(",", props) + : BulkUpsertGatePrimaryKeyToken; + var key = $"{typeof(T).FullName}:{keyColumns}"; + return BulkUpsertGates.GetOrAdd(key, static _ => new SemaphoreSlim(BulkUpsertGatePermits, BulkUpsertGatePermits)); + } + + private async Task RunGatedBulkInsertOrUpdateAsync( + List entityList, BulkConfig? bulkConfig, CancellationToken cancellationToken) + { + var gate = GetBulkUpsertGate(bulkConfig); + await gate.WaitAsync(cancellationToken); + try + { + await ExecuteWithExceptionHandlingAsync( + async () => + { + await _dbContext.BulkInsertOrUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken); + return 0; + }, + nameof(BulkInsertOrUpdateAsync)); + } + finally + { + gate.Release(); + } + } + /// /// Inserts or updates a large collection of entities in bulk using EFCore.BulkExtensions. /// Each entity is inserted if it does not exist, or updated if it does, based on the configured primary key or properties. @@ -726,13 +772,7 @@ public async Task BulkInsertOrUpdateAsync( activity?.SetTag("vali_flow.entity_count", entityList.Count); - await ExecuteWithExceptionHandlingAsync( - async () => - { - await _dbContext.BulkInsertOrUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken); - return 0; - }, - nameof(BulkInsertOrUpdateAsync)); + await RunGatedBulkInsertOrUpdateAsync(entityList, bulkConfig, cancellationToken); } catch (Exception ex) { From 4750f1ae3b5b2a29bdadbc58a9128bceba4a4d4d Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Mon, 5 Oct 2026 20:22:32 -0500 Subject: [PATCH 14/30] fix(vali-flow): generic transient retry for bulk upsert concurrency across SQL Server, PostgreSQL, MySQL, and SQLite Adds BulkUpsertRetryPolicy: a provider-agnostic retry (exponential backoff + jitter, no hard dependency on any ADO.NET provider) for the cross-process concurrency window the in-process semaphore gate (added for the stress-test finding on BulkInsertOrUpdateAsync) doesn't cover. Detects transiency via message-chain markers for the documented concurrency failure mode of each of the four most-used relational engines this ecosystem targets: - PostgreSQL/MySQL: EFCore.BulkExtensions' temporary unique index/constraint race when matching by BulkConfig.UpdateByProperties (the original finding). - SQL Server: deadlock victim on the bulk merge (documented upstream, EFCore.BulkExtensions issue #46). - SQLite: SQLITE_BUSY 'database is locked' under a concurrent writer. Oracle is deliberately not covered -- supported by EFCore.BulkExtensions but a materially less common deployment target, and its failure wording for this race hasn't been characterized. Wires the same gate+retry path into BulkUpdateAsync (previously only BulkInsertOrUpdateAsync was covered) since both share the identical BulkConfig.UpdateByProperties exposure. Adds InternalsVisibleTo so the retry policy can be unit-tested in isolation (21 new tests, no EF Core/database involved -- the race itself can't be reproduced deterministically against the InMemory/SQLite test providers) plus a BulkUpdateAsync concurrency regression test mirroring the existing BulkInsertOrUpdateAsync one. --- Vali-Flow.Tests/BulkUpsertRetryPolicyTests.cs | 195 ++++++++++++++++++ Vali-Flow.Tests/ValiFlowEfBulkTests.cs | 22 ++ Vali-Flow/AssemblyInfo.cs | 3 + .../Evaluators/BulkUpsertRetryPolicy.cs | 158 ++++++++++++++ .../Evaluators/ValiFlowEvaluator.Write.cs | 44 ++-- 5 files changed, 401 insertions(+), 21 deletions(-) create mode 100644 Vali-Flow.Tests/BulkUpsertRetryPolicyTests.cs create mode 100644 Vali-Flow/AssemblyInfo.cs create mode 100644 Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs diff --git a/Vali-Flow.Tests/BulkUpsertRetryPolicyTests.cs b/Vali-Flow.Tests/BulkUpsertRetryPolicyTests.cs new file mode 100644 index 0000000..1a09802 --- /dev/null +++ b/Vali-Flow.Tests/BulkUpsertRetryPolicyTests.cs @@ -0,0 +1,195 @@ +using FluentAssertions; +using Vali_Flow.Classes.Evaluators; +using Xunit; + +namespace Vali_Flow.Tests; + +/// +/// Unit tests for in isolation — no EF Core/database involved, since the +/// race it retries (concurrent bulk upsert temp-index/deadlock/busy-file failures) can't be reproduced +/// deterministically against the InMemory provider. Exercises the retry loop and transient-detection logic +/// directly via injected failing/succeeding operations and synthetic exceptions. +/// +public sealed class BulkUpsertRetryPolicyTests +{ + // ── IsTransientTempIndexFailure ────────────────────────────────────────── + + [Theory] + [InlineData("42704: index \"tempUniqueIndex_public_orders_sku\" does not exist")] + [InlineData("relation \"tempUniqueConstraint_x\" does not exist")] + [InlineData("Table 'db.tempUniqueConstraint_orders' doesn't exist")] + [InlineData("Unknown table 'tempUniqueIndex_orders' in information_schema")] + [InlineData("Transaction (Process ID 61) was deadlocked on lock resources with another process and has been chosen as the deadlock victim")] + [InlineData("database is locked")] + [InlineData("SQLite Error 5: 'database is locked'")] + public void IsTransientTempIndexFailure_KnownEngineMarkers_ReturnsTrue(string message) + { + var ex = new InvalidOperationException(message); + + BulkUpsertRetryPolicy.IsTransientTempIndexFailure(ex).Should().BeTrue(); + } + + [Fact] + public void IsTransientTempIndexFailure_MarkerInInnerException_ReturnsTrue() + { + var inner = new InvalidOperationException("42704: index \"tempUniqueIndex_x\" does not exist"); + var outer = new Exception("Bulk operation failed", inner); + + BulkUpsertRetryPolicy.IsTransientTempIndexFailure(outer).Should().BeTrue(); + } + + [Theory] + [InlineData("duplicate key value violates unique constraint \"orders_pkey\"")] + [InlineData("Column 'Name' cannot be null")] + [InlineData("Connection string is invalid")] + public void IsTransientTempIndexFailure_UnrelatedFailures_ReturnsFalse(string message) + { + var ex = new InvalidOperationException(message); + + BulkUpsertRetryPolicy.IsTransientTempIndexFailure(ex).Should().BeFalse(); + } + + [Fact] + public void IsTransientTempIndexFailure_Null_ReturnsFalse() + { + BulkUpsertRetryPolicy.IsTransientTempIndexFailure(null).Should().BeFalse(); + } + + // ── ExecuteAsync ────────────────────────────────────────────────────────── + + [Fact] + public async Task ExecuteAsync_SucceedsFirstTry_RunsOperationOnceAndDoesNotRetry() + { + var callCount = 0; + var retryCount = 0; + + await BulkUpsertRetryPolicy.ExecuteAsync( + () => { callCount++; return Task.CompletedTask; }, + CancellationToken.None, + onRetry: (_, _) => retryCount++); + + callCount.Should().Be(1); + retryCount.Should().Be(0); + } + + [Fact] + public async Task ExecuteAsync_TransientFailureThenSuccess_RetriesAndSucceeds() + { + var callCount = 0; + var retriedAttempts = new List(); + + await BulkUpsertRetryPolicy.ExecuteAsync( + () => + { + callCount++; + if (callCount < 3) throw new InvalidOperationException("database is locked"); + return Task.CompletedTask; + }, + CancellationToken.None, + onRetry: (attempt, _) => retriedAttempts.Add(attempt), + baseDelay: TimeSpan.FromMilliseconds(1), + maxDelay: TimeSpan.FromMilliseconds(5)); + + callCount.Should().Be(3); + retriedAttempts.Should().Equal(1, 2); + } + + [Fact] + public async Task ExecuteAsync_NonTransientFailure_ThrowsImmediatelyWithoutRetrying() + { + var callCount = 0; + + Func act = () => BulkUpsertRetryPolicy.ExecuteAsync( + () => { callCount++; throw new InvalidOperationException("Column 'Name' cannot be null"); }, + CancellationToken.None, + baseDelay: TimeSpan.FromMilliseconds(1)); + + await act.Should().ThrowAsync().WithMessage("Column 'Name' cannot be null"); + callCount.Should().Be(1); + } + + [Fact] + public async Task ExecuteAsync_TransientFailureExhaustsMaxAttempts_ThrowsLastException() + { + var callCount = 0; + + Func act = () => BulkUpsertRetryPolicy.ExecuteAsync( + () => { callCount++; throw new InvalidOperationException("database is locked"); }, + CancellationToken.None, + maxAttempts: 3, + baseDelay: TimeSpan.FromMilliseconds(1), + maxDelay: TimeSpan.FromMilliseconds(5)); + + await act.Should().ThrowAsync().WithMessage("database is locked"); + callCount.Should().Be(3); + } + + [Fact] + public async Task ExecuteAsync_CustomIsTransientPredicate_OverridesDefaultDetection() + { + var callCount = 0; + + await BulkUpsertRetryPolicy.ExecuteAsync( + () => + { + callCount++; + if (callCount < 2) throw new ArgumentException("custom-marker"); + return Task.CompletedTask; + }, + CancellationToken.None, + isTransient: ex => ex is ArgumentException, + baseDelay: TimeSpan.FromMilliseconds(1)); + + callCount.Should().Be(2); + } + + [Fact] + public async Task ExecuteAsync_CancellationRequestedBeforeAttempt_ThrowsOperationCanceled() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Func act = () => BulkUpsertRetryPolicy.ExecuteAsync( + () => Task.CompletedTask, + cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ExecuteAsync_CancellationDuringBackoffDelay_PropagatesWithoutFurtherRetries() + { + using var cts = new CancellationTokenSource(); + var callCount = 0; + + Func act = () => BulkUpsertRetryPolicy.ExecuteAsync( + () => + { + callCount++; + cts.Cancel(); + throw new InvalidOperationException("database is locked"); + }, + cts.Token, + baseDelay: TimeSpan.FromMilliseconds(50)); + + await act.Should().ThrowAsync(); + callCount.Should().Be(1); + } + + [Fact] + public void ExecuteAsync_NullOperation_ThrowsArgumentNullException() + { + Func act = () => BulkUpsertRetryPolicy.ExecuteAsync(null!, CancellationToken.None); + + act.Should().ThrowAsync(); + } + + [Fact] + public void ExecuteAsync_MaxAttemptsLessThanOne_ThrowsArgumentOutOfRangeException() + { + Func act = () => BulkUpsertRetryPolicy.ExecuteAsync( + () => Task.CompletedTask, CancellationToken.None, maxAttempts: 0); + + act.Should().ThrowAsync(); + } +} diff --git a/Vali-Flow.Tests/ValiFlowEfBulkTests.cs b/Vali-Flow.Tests/ValiFlowEfBulkTests.cs index d7c63a3..d5a0440 100644 --- a/Vali-Flow.Tests/ValiFlowEfBulkTests.cs +++ b/Vali-Flow.Tests/ValiFlowEfBulkTests.cs @@ -514,5 +514,27 @@ public async Task BulkInsertOrUpdateAsync_ConcurrentCallsSameEntityType_Serializ (await ctx.Orders.CountAsync(o => o.CustomerName!.StartsWith("Concurrent"))).Should().Be(concurrentCallers); } + [Fact] + public async Task BulkUpdateAsync_ConcurrentCallsSameEntityType_SharesGateWithInsertOrUpdateAndDoesNotThrow() + { + // BulkUpdateAsync shares the exact same temp-index/deadlock exposure as BulkInsertOrUpdateAsync + // when matching by BulkConfig.UpdateByProperties (see BulkUpsertRetryPolicy) -- it now goes + // through the same gate+retry path (RunGatedBulkUpsertAsync). Verifies concurrent calls against + // the same entity type don't corrupt results or deadlock. + await using var sqlCtx = SqliteTestContext.Create(); + var ctx = sqlCtx.Context; + ctx_seed(ctx); + await ctx.SaveChangesAsync(); + var evaluator = new ValiFlowEvaluator(ctx); + + var seeded = await ctx.Orders.AsNoTracking().ToListAsync(); + const int concurrentCallers = 8; + var tasks = seeded.Take(concurrentCallers).Select((o, i) => evaluator.BulkUpdateAsync( + new List { new() { Id = o.Id, CustomerName = o.CustomerName, Total = 100 + i, IsShipped = true, CreatedAt = o.CreatedAt } })); + + Func act = async () => await Task.WhenAll(tasks); + await act.Should().NotThrowAsync(); + } + private static void ctx_seed(TestDbContext ctx) => ctx.Orders.AddRange(SeedOrders()); } diff --git a/Vali-Flow/AssemblyInfo.cs b/Vali-Flow/AssemblyInfo.cs new file mode 100644 index 0000000..a9faba7 --- /dev/null +++ b/Vali-Flow/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Vali-Flow.Tests")] diff --git a/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs b/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs new file mode 100644 index 0000000..a87c4b1 --- /dev/null +++ b/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs @@ -0,0 +1,158 @@ +namespace Vali_Flow.Classes.Evaluators; + +/// +/// Generic transient-failure retry for bulk upsert/update operations, covering the concurrency pitfalls +/// EFCore.BulkExtensions itself documents for the most common relational providers: +/// +/// PostgreSQL / MySQL: matching by BulkConfig.UpdateByProperties (instead of +/// the primary key) makes the library create then drop a deterministically-named temporary unique +/// index/constraint around the merge. Two concurrent calls against the same table/key columns can race on +/// that shared object (one drops it mid-merge of the other), surfacing as e.g. PostgreSQL's +/// 42704 "index ... does not exist". +/// SQL Server: the library's own README documents deadlocks as a known concurrency +/// issue for bulk merge operations (see upstream issue #46) — the server picks a victim transaction and kills +/// it with "Transaction (Process ID ...) was deadlocked ... and has been chosen as the deadlock victim", +/// independent of the temp-table mechanism above (SQL Server's temp table names are randomized by default, +/// so they don't collide the way Postgres/MySQL's deterministic names do). +/// SQLite: file-level write serialization means any concurrent writer (including a +/// second process) hitting the same database file while a bulk operation holds its write lock fails with +/// SQLITE_BUSY "database is locked" — a classic transient condition SQLite itself expects callers to +/// retry. +/// +/// These are the four most-used relational engines this ecosystem targets (see Vali-Flow.Sql's +/// dialect support); Oracle is deliberately not covered here — it is supported by EFCore.BulkExtensions +/// but is a materially less common deployment target, and its concurrency failure wording hasn't been +/// characterized against this specific race. +/// The in-process gate (see ValiFlowEvaluator.Write.cs, +/// GetBulkUpsertGate/RunGatedBulkUpsertAsync) already serializes same-process callers for the +/// first case, but neither case is covered once a second process (another instance of the API) is involved — +/// this retry is the generic, provider-agnostic fallback for that remaining window, for whichever of the +/// supported engines is in use. +/// +/// +/// Deliberately detects transiency by inspecting the exception message chain for markers of these specific +/// failure modes, not by referencing a provider-specific exception type (Npgsql/MySqlConnector/ +/// Microsoft.Data.SqlClient) — Vali-Flow has no package dependency on any ADO.NET provider and +/// this keeps it that way. +/// +internal static class BulkUpsertRetryPolicy +{ + /// Default maximum attempts (1 initial try + up to 3 retries). + internal const int DefaultMaxAttempts = 4; + + /// Base delay for the exponential backoff between retries. + internal static readonly TimeSpan DefaultBaseDelay = TimeSpan.FromMilliseconds(50); + + /// Upper bound on any single computed delay, so backoff never grows unbounded. + internal static readonly TimeSpan DefaultMaxDelay = TimeSpan.FromMilliseconds(400); + + /// Upper bound (exclusive) of the random jitter added to each delay, to avoid a thundering herd of retries. + internal const int DefaultJitterMs = 50; + + /// Base of the exponential backoff growth (delay doubles on each successive retry). + private const double ExponentialBackoffBase = 2; + + // Substrings observed in the provider error message chain for the two concurrency failure modes above. + private static readonly string[] TransientMarkers = + { + // PostgreSQL / MySQL — temp unique index/constraint race around a custom UpdateByProperties match. + "tempUniqueIndex", // the deterministic name EFCore.BulkExtensions gives the object it creates/drops + "tempUniqueConstraint", + "does not exist", // PostgreSQL 42704 — one caller dropped the index between another's CREATE and its use + "doesn't exist", // MySQL wording for the equivalent race + "unknown table", + + // SQL Server — deadlock victim on the bulk merge (documented upstream, EFCore.BulkExtensions issue #46). + "deadlock", // covers both "was deadlocked" and "deadlock victim" wording + "deadlocked", + + // SQLite — SQLITE_BUSY: file-level write lock held by a concurrent writer (another process or thread). + "database is locked", + "sqlite_busy" + }; + + /// + /// Walks the exception's chain looking for a message matching one + /// of . Returns false for anything else — this must stay narrow: + /// a real constraint violation, a bad connection string, or any other failure should propagate immediately, + /// not be silently retried and delayed. + /// + internal static bool IsTransientTempIndexFailure(Exception? ex) + { + for (var current = ex; current != null; current = current.InnerException) + { + var message = current.Message; + if (string.IsNullOrEmpty(message)) continue; + + foreach (var marker in TransientMarkers) + { + if (message.Contains(marker, StringComparison.OrdinalIgnoreCase)) + return true; + } + } + + return false; + } + + /// + /// Runs , retrying on failures classified as transient by + /// (defaults to ), with exponential + /// backoff + jitter between attempts. Non-transient exceptions, and the last attempt's exception once + /// is exhausted, propagate unchanged — this never swallows a real failure. + /// + /// The operation to run. Invoked at least once. + /// + /// Checked before every attempt and honored during the backoff delay via ; + /// an from either path propagates immediately without being retried. + /// + /// + /// Optional callback invoked right before each retry delay, with the 1-based attempt number that just + /// failed and the exception that triggered the retry — used by the caller to tag diagnostics. + /// + /// Predicate deciding whether an exception should be retried. Defaults to . + /// Maximum total attempts, including the first. Must be at least 1. + /// Base delay for the exponential backoff (attempt 1 waits ~baseDelay, attempt 2 ~2x, etc.). + /// Upper bound applied to every computed delay before jitter is added. + internal static async Task ExecuteAsync( + Func operation, + CancellationToken cancellationToken, + Action? onRetry = null, + Func? isTransient = null, + int maxAttempts = DefaultMaxAttempts, + TimeSpan? baseDelay = null, + TimeSpan? maxDelay = null) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts), "maxAttempts must be at least 1."); + + isTransient ??= IsTransientTempIndexFailure; + var effectiveBaseDelay = baseDelay ?? DefaultBaseDelay; + var effectiveMaxDelay = maxDelay ?? DefaultMaxDelay; + + for (var attempt = 1; ; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await operation().ConfigureAwait(false); + return; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (attempt < maxAttempts && isTransient(ex)) + { + onRetry?.Invoke(attempt, ex); + + var exponential = effectiveBaseDelay.TotalMilliseconds * Math.Pow(ExponentialBackoffBase, attempt - 1); + var capped = Math.Min(exponential, effectiveMaxDelay.TotalMilliseconds); + var jitter = Random.Shared.Next(0, DefaultJitterMs); + var delay = TimeSpan.FromMilliseconds(capped + jitter); + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs index 151a5df..7d4c88b 100644 --- a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs +++ b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs @@ -644,13 +644,9 @@ public async Task BulkUpdateAsync( activity?.SetTag("vali_flow.entity_count", entityList.Count); - await ExecuteWithExceptionHandlingAsync( - async () => - { - await _dbContext.BulkUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken); - return 0; - }, - nameof(BulkUpdateAsync)); + await RunGatedBulkUpsertAsync( + () => _dbContext.BulkUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken), + bulkConfig, nameof(BulkUpdateAsync), cancellationToken, activity); } catch (Exception ex) { @@ -701,15 +697,15 @@ await ExecuteWithExceptionHandlingAsync( } /// - /// Per-(entity type, UpdateByProperties) in-process gate for . - /// EFCore.BulkExtensions' PostgreSQL adapter implements the upsert via a temporary unique - /// index whose name is deterministic (tempUniqueIndex_{schema}_{table}_{column}), created - /// then dropped around the merge. Two concurrent calls against the same table/key columns race on - /// that shared temp index (one drops it mid-merge of the other), surfacing as - /// 42704: index "tempUniqueIndex_..." does not exist — reproduced with as few as 8 parallel - /// single-row requests in the same process. Serializing same-key calls in-process avoids the race. - /// This does NOT cover cross-process concurrency (multiple API instances) — that collision still - /// exists at the database level and needs a Postgres advisory lock or equivalent if it matters. + /// Per-(entity type, UpdateByProperties) in-process gate + generic transient retry for + /// and . Both delegate to + /// EFCore.BulkExtensions, which — for PostgreSQL/MySQL matching by a custom + /// , or for a SQL Server merge deadlock — can fail + /// transiently under real concurrency; see for the full + /// per-engine rationale. The semaphore serializes same-process callers for the same + /// type+key-columns (reproduced with as few as 8 parallel single-row requests); the retry on top + /// covers what the semaphore cannot: cross-process concurrency (multiple API instances), where the + /// collision still exists at the database level. /// private const int BulkUpsertGatePermits = 1; private const string BulkUpsertGatePrimaryKeyToken = "__pk__"; @@ -725,8 +721,9 @@ private static SemaphoreSlim GetBulkUpsertGate(BulkConfig? bulkConfig) return BulkUpsertGates.GetOrAdd(key, static _ => new SemaphoreSlim(BulkUpsertGatePermits, BulkUpsertGatePermits)); } - private async Task RunGatedBulkInsertOrUpdateAsync( - List entityList, BulkConfig? bulkConfig, CancellationToken cancellationToken) + private async Task RunGatedBulkUpsertAsync( + Func bulkOperation, BulkConfig? bulkConfig, string operationName, + CancellationToken cancellationToken, Activity? activity) { var gate = GetBulkUpsertGate(bulkConfig); await gate.WaitAsync(cancellationToken); @@ -735,10 +732,13 @@ private async Task RunGatedBulkInsertOrUpdateAsync( await ExecuteWithExceptionHandlingAsync( async () => { - await _dbContext.BulkInsertOrUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken); + await BulkUpsertRetryPolicy.ExecuteAsync( + bulkOperation, + cancellationToken, + onRetry: (attempt, _) => activity?.SetTag("vali_flow.bulk_upsert_retry_count", attempt)); return 0; }, - nameof(BulkInsertOrUpdateAsync)); + operationName); } finally { @@ -772,7 +772,9 @@ public async Task BulkInsertOrUpdateAsync( activity?.SetTag("vali_flow.entity_count", entityList.Count); - await RunGatedBulkInsertOrUpdateAsync(entityList, bulkConfig, cancellationToken); + await RunGatedBulkUpsertAsync( + () => _dbContext.BulkInsertOrUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken), + bulkConfig, nameof(BulkInsertOrUpdateAsync), cancellationToken, activity); } catch (Exception ex) { From 8383ed1241723fc6200c28892439146ddb7175a6 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Mon, 5 Oct 2026 20:37:30 -0500 Subject: [PATCH 15/30] fix(vali-flow): raise BulkUpsertRetryPolicy default budget from 4 to 7 attempts Validated the retry against the real cross-process scenario it targets: 2 API instances, 50 req/s combined, 15 shared business keys via BulkInsertOrUpdateAsync. The mechanism worked (113 retries triggered, 0 duplicate rows), but the original 4-attempt / 400ms-max-delay budget (~450ms total) left ~2% of requests (8/405) exhausting all retries and propagating a 500 (40P01 deadlock detected, 42704 index does not exist) under sustained contention. Raised to 7 attempts / 900ms max delay to give the losing side of the race more chances to land after the winner releases the temp index/lock. Also confirmed Postgres' actual deadlock error code (40P01, not the originally assumed 42704) still matches the existing generic "deadlock" substring marker without any change needed there. --- .../Classes/Evaluators/BulkUpsertRetryPolicy.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs b/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs index a87c4b1..8f6559e 100644 --- a/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs +++ b/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs @@ -37,14 +37,23 @@ namespace Vali_Flow.Classes.Evaluators; /// internal static class BulkUpsertRetryPolicy { - /// Default maximum attempts (1 initial try + up to 3 retries). - internal const int DefaultMaxAttempts = 4; + /// + /// Default maximum attempts (1 initial try + up to 6 retries). Raised from an initial 4 after a stress + /// run reproduced the real cross-process scenario this retry targets (2 API instances, 50 req/s combined, + /// 15 shared business keys): 4 attempts (~450ms total backoff budget) left ~2% of requests (8/405) + /// exhausting all retries and propagating a 500 under sustained contention. 7 attempts gives the losing + /// side of the race materially more chances to land after the winner releases the temp index/lock. + /// + internal const int DefaultMaxAttempts = 7; /// Base delay for the exponential backoff between retries. internal static readonly TimeSpan DefaultBaseDelay = TimeSpan.FromMilliseconds(50); - /// Upper bound on any single computed delay, so backoff never grows unbounded. - internal static readonly TimeSpan DefaultMaxDelay = TimeSpan.FromMilliseconds(400); + /// + /// Upper bound on any single computed delay, so backoff never grows unbounded. Raised from 400ms to 900ms + /// alongside — see that constant's remarks for the measured rationale. + /// + internal static readonly TimeSpan DefaultMaxDelay = TimeSpan.FromMilliseconds(900); /// Upper bound (exclusive) of the random jitter added to each delay, to avoid a thundering herd of retries. internal const int DefaultJitterMs = 50; From d946be1fd747ced0dd72853625d96fb9e3f57ba6 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Mon, 5 Oct 2026 20:51:25 -0500 Subject: [PATCH 16/30] fix(vali-flow): raise BulkUpsertRetryPolicy budget from 7 to 12 attempts Second measured iteration: 7 attempts cut the propagated-error rate from ~2% (8/405) to 0.12% (1/855) under the same artificially-high-contention benchmark (2 API instances, 50 req/s combined, 15 shared keys) -- real improvement, not zero. Raised to 12 attempts (~5.4s worst-case backoff budget) to push residual failure rate further down under this deliberately narrow pool. Documented in the constant's XML remarks that no finite retry budget can offer a 0% proof for a queueing race -- only diminishing residual probability at the cost of added worst-case latency. --- .../Evaluators/BulkUpsertRetryPolicy.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs b/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs index 8f6559e..3692487 100644 --- a/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs +++ b/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs @@ -38,20 +38,28 @@ namespace Vali_Flow.Classes.Evaluators; internal static class BulkUpsertRetryPolicy { /// - /// Default maximum attempts (1 initial try + up to 6 retries). Raised from an initial 4 after a stress - /// run reproduced the real cross-process scenario this retry targets (2 API instances, 50 req/s combined, - /// 15 shared business keys): 4 attempts (~450ms total backoff budget) left ~2% of requests (8/405) - /// exhausting all retries and propagating a 500 under sustained contention. 7 attempts gives the losing - /// side of the race materially more chances to land after the winner releases the temp index/lock. + /// Default maximum attempts (1 initial try + up to 11 retries). History, measured against the real + /// cross-process scenario this retry targets (2 API instances, 50 req/s combined, 15 shared business + /// keys, BulkInsertOrUpdateAsync matching by a custom key): + /// + /// 4 attempts (~450ms budget): ~2% of requests (8/405) exhausted retries and propagated a 500. + /// 7 attempts (~2.4s budget): down to 0.12% (1/855) — real improvement, not zero. + /// 12 attempts (~5.4s budget, current): chosen to push further into this specific + /// artificially-high-contention benchmark (15 keys shared by 2 processes at a sustained 50 req/s is a + /// deliberately narrow pool, not representative of typical traffic). Zero residual failure under this + /// exact scenario is not guaranteed by construction — the race is a queue, not a fixed number of + /// contenders, so no finite retry budget can offer a 0% proof, only diminishing residual probability at + /// the cost of added worst-case latency for the caller stuck re-trying. + /// /// - internal const int DefaultMaxAttempts = 7; + internal const int DefaultMaxAttempts = 12; /// Base delay for the exponential backoff between retries. internal static readonly TimeSpan DefaultBaseDelay = TimeSpan.FromMilliseconds(50); /// - /// Upper bound on any single computed delay, so backoff never grows unbounded. Raised from 400ms to 900ms - /// alongside — see that constant's remarks for the measured rationale. + /// Upper bound on any single computed delay, so backoff never grows unbounded. Raised alongside + /// — see that constant's remarks for the measured rationale. /// internal static readonly TimeSpan DefaultMaxDelay = TimeSpan.FromMilliseconds(900); From 06c748332b0aa59e83a0ad358721fda95142da6b Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Mon, 5 Oct 2026 21:21:22 -0500 Subject: [PATCH 17/30] fix(vali-flow): gate bulk upsert contention per table, not per UpdateByProperties key set A stress run sweeping the shared business-key pool from 15 up to 1,000 and 10,000 distinct SKUs found the retry activation rate unchanged across every pool size (consistently ~20-25 activations per instance), ruling out key collision as the cause. The real contention is PostgreSQL's CREATE INDEX CONCURRENTLY (part of the merge EFCore.BulkExtensions runs for a non-PK match), which serializes against any other transaction on the same table regardless of which rows/columns it touches. The in-process gate was keyed by (type, UpdateByProperties columns), which would let two same-process calls with different UpdateByProperties on the same table run concurrently and still race -- re-keyed to type (table) alone, matching the real contention boundary. GetBulkUpsertGate no longer needs the BulkConfig parameter. --- Vali-Flow.Tests/ValiFlowEfBulkTests.cs | 4 +-- .../Evaluators/BulkUpsertRetryPolicy.cs | 11 ++++--- .../Evaluators/ValiFlowEvaluator.Write.cs | 33 +++++++++++-------- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/Vali-Flow.Tests/ValiFlowEfBulkTests.cs b/Vali-Flow.Tests/ValiFlowEfBulkTests.cs index d5a0440..6adbd2b 100644 --- a/Vali-Flow.Tests/ValiFlowEfBulkTests.cs +++ b/Vali-Flow.Tests/ValiFlowEfBulkTests.cs @@ -499,8 +499,8 @@ public async Task BulkInsertOrUpdateAsync_ConcurrentCallsSameEntityType_Serializ var ctx = sqlCtx.Context; var evaluator = new ValiFlowEvaluator(ctx); - // No explicit BulkConfig.UpdateByProperties -- falls back to the PK, same gate key ("__pk__") - // for every call, which is exactly the shared-key scenario the gate serializes. + // The gate is keyed per entity type (table), not per UpdateByProperties -- every call below + // shares the same gate regardless of which key columns it matches on. const int concurrentCallers = 8; var tasks = Enumerable.Range(0, concurrentCallers).Select(i => evaluator.BulkInsertOrUpdateAsync( new List diff --git a/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs b/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs index 3692487..c0f3648 100644 --- a/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs +++ b/Vali-Flow/Classes/Evaluators/BulkUpsertRetryPolicy.cs @@ -5,10 +5,13 @@ namespace Vali_Flow.Classes.Evaluators; /// EFCore.BulkExtensions itself documents for the most common relational providers: /// /// PostgreSQL / MySQL: matching by BulkConfig.UpdateByProperties (instead of -/// the primary key) makes the library create then drop a deterministically-named temporary unique -/// index/constraint around the merge. Two concurrent calls against the same table/key columns can race on -/// that shared object (one drops it mid-merge of the other), surfacing as e.g. PostgreSQL's -/// 42704 "index ... does not exist". +/// the primary key) makes the library create then drop a temporary unique index/constraint around the merge +/// via CREATE INDEX CONCURRENTLY (PostgreSQL) or an equivalent DDL statement (MySQL), which serializes +/// against *any other transaction on the same table* — not just ones touching the same rows/columns. A +/// stress run sweeping the shared-key pool from 15 up to 10,000 distinct keys found the retry activation +/// rate unchanged across every pool size, ruling out key collision as the cause: this is table-level DDL +/// contention proportional to concurrent request rate on that table, surfacing as e.g. PostgreSQL's +/// 42704 "index ... does not exist" when one caller's DDL interleaves with another's. /// SQL Server: the library's own README documents deadlocks as a known concurrency /// issue for bulk merge operations (see upstream issue #46) — the server picks a victim transaction and kills /// it with "Transaction (Process ID ...) was deadlocked ... and has been chosen as the deadlock victim", diff --git a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs index 7d4c88b..7578ce3 100644 --- a/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs +++ b/Vali-Flow/Classes/Evaluators/ValiFlowEvaluator.Write.cs @@ -646,7 +646,7 @@ public async Task BulkUpdateAsync( await RunGatedBulkUpsertAsync( () => _dbContext.BulkUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken), - bulkConfig, nameof(BulkUpdateAsync), cancellationToken, activity); + nameof(BulkUpdateAsync), cancellationToken, activity); } catch (Exception ex) { @@ -702,30 +702,35 @@ await ExecuteWithExceptionHandlingAsync( /// EFCore.BulkExtensions, which — for PostgreSQL/MySQL matching by a custom /// , or for a SQL Server merge deadlock — can fail /// transiently under real concurrency; see for the full - /// per-engine rationale. The semaphore serializes same-process callers for the same - /// type+key-columns (reproduced with as few as 8 parallel single-row requests); the retry on top - /// covers what the semaphore cannot: cross-process concurrency (multiple API instances), where the - /// collision still exists at the database level. + /// per-engine rationale. /// + /// + /// Gated per entity type (table), not per key set. + /// A stress run that swept the shared-key pool from 15 up to 10,000 SKUs found the retry activation + /// rate stayed flat across every pool size — ruling out key collision as the cause. The real + /// contention is PostgreSQL's CREATE INDEX CONCURRENTLY (part of the merge EFCore.BulkExtensions + /// runs for a non-PK match), which serializes against *any* other transaction on the *same table*, + /// regardless of which rows/columns either one touches. Gating by key set would let two same-process + /// calls with different UpdateByProperties on the same table run concurrently and still race — + /// gating by type alone is what actually matches the real contention boundary. The retry on top covers + /// what the semaphore cannot: cross-process concurrency (multiple API instances), where the collision + /// still exists at the database level. + /// private const int BulkUpsertGatePermits = 1; - private const string BulkUpsertGatePrimaryKeyToken = "__pk__"; private static readonly System.Collections.Concurrent.ConcurrentDictionary BulkUpsertGates = new(); - private static SemaphoreSlim GetBulkUpsertGate(BulkConfig? bulkConfig) + private static SemaphoreSlim GetBulkUpsertGate() { - var keyColumns = bulkConfig?.UpdateByProperties is { Count: > 0 } props - ? string.Join(",", props) - : BulkUpsertGatePrimaryKeyToken; - var key = $"{typeof(T).FullName}:{keyColumns}"; + var key = typeof(T).FullName!; return BulkUpsertGates.GetOrAdd(key, static _ => new SemaphoreSlim(BulkUpsertGatePermits, BulkUpsertGatePermits)); } private async Task RunGatedBulkUpsertAsync( - Func bulkOperation, BulkConfig? bulkConfig, string operationName, + Func bulkOperation, string operationName, CancellationToken cancellationToken, Activity? activity) { - var gate = GetBulkUpsertGate(bulkConfig); + var gate = GetBulkUpsertGate(); await gate.WaitAsync(cancellationToken); try { @@ -774,7 +779,7 @@ public async Task BulkInsertOrUpdateAsync( await RunGatedBulkUpsertAsync( () => _dbContext.BulkInsertOrUpdateAsync(entityList, bulkConfig, cancellationToken: cancellationToken), - bulkConfig, nameof(BulkInsertOrUpdateAsync), cancellationToken, activity); + nameof(BulkInsertOrUpdateAsync), cancellationToken, activity); } catch (Exception ex) { From 2202ca7ea97a5a329c537637bf4d1b57ad5af6a8 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Mon, 5 Oct 2026 21:45:50 -0500 Subject: [PATCH 18/30] fix(tests): eliminate flaky Activity-capture tests across Sql/EF-Core/InMemory test assemblies A full-solution test run surfaced an intermittent failure in Vali-Flow.Sql.Tests ('Sequence contains no matching element') in the diagnostics tests added by an earlier commit. Root cause: each test parents its captured System.Diagnostics. Activity under a private root and filters by ParentId to survive cross-test pollution on the shared process-wide ValiFlowDiagnostics.Source listener -- but under xUnit's default test-class-level parallelism this is not fully deterministic; it just has lower collision probability with fewer competing Activity-asserting test classes, which is why it hadn't surfaced yet in Vali-Flow.Tests/Vali-Flow.InMemory.Tests (same pattern, fewer classes) and did in Vali-Flow.Sql.Tests (6 builder test classes all asserting on Activities). Fixes, in order: 1. Rewrote the 6 duplicated per-file ActivityListener setups in Sql.Tests (SqlDeleteBuilderTests, SqlInsertBuilderTests, SqlMergeBuilderTests, SqlTruncateBuilderTests, SqlUpdateBuilderTests, SqlQueryBuilderTests) to use the same AttachScopedListener/GetOwn helper pattern already used elsewhere, removing a dead/unused AttachListener() helper and a redundant double-listener in SqlQueryBuilderTests along the way. 2. Added [assembly: CollectionBehavior(DisableTestParallelization = true)] to Vali-Flow.Sql.Tests, Vali-Flow.Tests, and Vali-Flow.InMemory.Tests -- removes the race at its source instead of chasing per-test correlation tokens. Verified clean across 8 consecutive Sql.Tests runs and 5 consecutive full-solution runs. Cost: Sql.Tests went from ~250ms to ~800ms, negligible. --- Vali-Flow.InMemory.Tests/AssemblyInfo.cs | 10 ++++ Vali-Flow.Sql.Tests/AssemblyInfo.cs | 10 ++++ .../Builder/SqlDeleteBuilderTests.cs | 50 +++++++++++-------- .../Builder/SqlInsertBuilderTests.cs | 50 +++++++++++-------- .../Builder/SqlMergeBuilderTests.cs | 50 +++++++++++-------- .../Builder/SqlTruncateBuilderTests.cs | 50 +++++++++++-------- .../Builder/SqlUpdateBuilderTests.cs | 50 +++++++++++-------- Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs | 46 ++++++++--------- Vali-Flow.Tests/AssemblyInfo.cs | 10 ++++ 9 files changed, 202 insertions(+), 124 deletions(-) create mode 100644 Vali-Flow.InMemory.Tests/AssemblyInfo.cs create mode 100644 Vali-Flow.Sql.Tests/AssemblyInfo.cs create mode 100644 Vali-Flow.Tests/AssemblyInfo.cs diff --git a/Vali-Flow.InMemory.Tests/AssemblyInfo.cs b/Vali-Flow.InMemory.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..521adca --- /dev/null +++ b/Vali-Flow.InMemory.Tests/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using Xunit; + +// ValiFlowInMemoryDiagnosticsTests asserts on System.Diagnostics.Activity captured via a process-wide +// ActivityListener on ValiFlowDiagnostics.Source, parenting each test's activities under a private +// root and filtering by ParentId. That reduces -- but does not fully eliminate -- cross-test +// collisions under xUnit's default test-class-level parallelism: the identical pattern was observed +// intermittently failing with "Sequence contains no matching element" in Vali-Flow.Sql.Tests (which +// has more Activity-asserting test classes competing concurrently). Disabling parallelization for +// this assembly removes that race at its source. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/Vali-Flow.Sql.Tests/AssemblyInfo.cs b/Vali-Flow.Sql.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..b4f34e7 --- /dev/null +++ b/Vali-Flow.Sql.Tests/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using Xunit; + +// Several test classes in this project assert on System.Diagnostics.Activity captured via a +// process-wide ActivityListener on ValiFlowDiagnostics.Source (see e.g. SqlDeleteBuilderTests' +// AttachScopedListener/GetOwn helpers). Parenting each test's activities under a private root and +// filtering by ParentId reduces -- but does not eliminate -- cross-test collisions under xUnit's +// default test-class-level parallelism: it was still observed intermittently failing with "Sequence +// contains no matching element" across the 6 SQL builder test classes. Disabling parallelization for +// this assembly removes the race at its source; the full suite still runs in well under a second. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/Vali-Flow.Sql.Tests/Builder/SqlDeleteBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlDeleteBuilderTests.cs index c3ca3d2..a031f32 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlDeleteBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlDeleteBuilderTests.cs @@ -13,6 +13,27 @@ public sealed class SqlDeleteBuilderTests { private static readonly ISqlDialect Sql = new SqlServerDialect(); private static readonly ISqlDialect Pg = new PostgreSqlDialect(); + + // Parents every captured activity under a private root so concurrently running tests (xunit + // parallelizes test classes by default) can never pollute this test's capture, even though they + // share the same process-wide ValiFlowDiagnostics.Source. + private static (ActivityListener Listener, Activity Root, List Captured) AttachScopedListener() + { + var captured = new List(); + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = captured.Add + }; + ActivitySource.AddActivityListener(listener); + + var root = new Activity("TestRoot").Start(); + return (listener, root, captured); + } + + private static Activity GetOwn(List captured, Activity root) + => captured.Single(a => a.ParentId == root.Id); private static readonly ISqlDialect My = new MySqlDialect(); // ── Basic DELETE ────────────────────────────────────────────────────────── @@ -284,14 +305,9 @@ public void Constructor_NullDialect_ThrowsArgumentNullException() [Fact] public void Build_WithTag_RecordsActivityWithTagAndEntityType() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; new SqlDeleteBuilder(Sql) .From("Users") @@ -299,8 +315,8 @@ public void Build_WithTag_RecordsActivityWithTagAndEntityType() .Tag("Delete all users") .Build(); - seen.Should().NotBeNull(); - seen!.GetTagItem("vali_flow.tag").Should().Be("Delete all users"); + var seen = GetOwn(captured, root); + seen.GetTagItem("vali_flow.tag").Should().Be("Delete all users"); seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); seen.GetTagItem("vali_flow.has_where").Should().Be(false); seen.Status.Should().Be(ActivityStatusCode.Unset); @@ -309,20 +325,14 @@ public void Build_WithTag_RecordsActivityWithTagAndEntityType() [Fact] public void Build_WithoutWhereOrAllowDeleteAll_RecordsErrorStatus() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; // No Where() and no AllowDeleteAll() triggers the existing safety guard in Build(). var act = () => new SqlDeleteBuilder(Sql).From("Users").Build(); act.Should().Throw(); - seen.Should().NotBeNull(); - seen!.Status.Should().Be(ActivityStatusCode.Error); + GetOwn(captured, root).Status.Should().Be(ActivityStatusCode.Error); } } diff --git a/Vali-Flow.Sql.Tests/Builder/SqlInsertBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlInsertBuilderTests.cs index f605477..73512f4 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlInsertBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlInsertBuilderTests.cs @@ -14,6 +14,27 @@ public sealed class SqlInsertBuilderTests private static readonly ISqlDialect Pg = new PostgreSqlDialect(); private static readonly ISqlDialect My = new MySqlDialect(); + // Parents every captured activity under a private root so concurrently running tests (xunit + // parallelizes test classes by default) can never pollute this test's capture, even though they + // share the same process-wide ValiFlowDiagnostics.Source. + private static (ActivityListener Listener, Activity Root, List Captured) AttachScopedListener() + { + var captured = new List(); + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = captured.Add + }; + ActivitySource.AddActivityListener(listener); + + var root = new Activity("TestRoot").Start(); + return (listener, root, captured); + } + + private static Activity GetOwn(List captured, Activity root) + => captured.Single(a => a.ParentId == root.Id); + // ── Basic INSERT ────────────────────────────────────────────────────────── [Fact] @@ -661,14 +682,9 @@ public void SetAllFrom_NullEntity_ThrowsArgumentNullException() [Fact] public void Build_WithTag_RecordsActivityWithTagAndEntityType() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; new SqlInsertBuilder(Sql) .Into("Users") @@ -676,8 +692,8 @@ public void Build_WithTag_RecordsActivityWithTagAndEntityType() .Tag("Insert new user") .Build(); - seen.Should().NotBeNull(); - seen!.GetTagItem("vali_flow.tag").Should().Be("Insert new user"); + var seen = GetOwn(captured, root); + seen.GetTagItem("vali_flow.tag").Should().Be("Insert new user"); seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); seen.Status.Should().Be(ActivityStatusCode.Unset); } @@ -685,20 +701,14 @@ public void Build_WithTag_RecordsActivityWithTagAndEntityType() [Fact] public void Build_WhenValidationThrows_RecordsErrorStatus() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; // No Set() calls at all triggers the existing "at least one column" guard in Build(). var act = () => new SqlInsertBuilder(Sql).Into("Users").Build(); act.Should().Throw(); - seen.Should().NotBeNull(); - seen!.Status.Should().Be(ActivityStatusCode.Error); + GetOwn(captured, root).Status.Should().Be(ActivityStatusCode.Error); } } diff --git a/Vali-Flow.Sql.Tests/Builder/SqlMergeBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlMergeBuilderTests.cs index 5220ccf..1c9793d 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlMergeBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlMergeBuilderTests.cs @@ -12,6 +12,27 @@ public sealed class SqlMergeBuilderTests { private static readonly ISqlDialect Sql = new SqlServerDialect(); + // Parents every captured activity under a private root so concurrently running tests (xunit + // parallelizes test classes by default) can never pollute this test's capture, even though they + // share the same process-wide ValiFlowDiagnostics.Source. + private static (ActivityListener Listener, Activity Root, List Captured) AttachScopedListener() + { + var captured = new List(); + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = captured.Add + }; + ActivitySource.AddActivityListener(listener); + + var root = new Activity("TestRoot").Start(); + return (listener, root, captured); + } + + private static Activity GetOwn(List captured, Activity root) + => captured.Single(a => a.ParentId == root.Id); + // ── Basic MERGE ─────────────────────────────────────────────────────────── [Fact] @@ -308,14 +329,9 @@ public void Build_WithOracleDialect_DoesNotUseAsAlias() [Fact] public void Build_WithTag_RecordsActivityWithTagAndTargetEntityType() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; new SqlMergeBuilder(Sql) .Into("Users") @@ -325,8 +341,8 @@ public void Build_WithTag_RecordsActivityWithTagAndTargetEntityType() .Tag("Sync users") .Build(); - seen.Should().NotBeNull(); - seen!.GetTagItem("vali_flow.tag").Should().Be("Sync users"); + var seen = GetOwn(captured, root); + seen.GetTagItem("vali_flow.tag").Should().Be("Sync users"); seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); seen.GetTagItem("vali_flow.has_delete_unmatched").Should().Be(false); seen.Status.Should().Be(ActivityStatusCode.Unset); @@ -335,14 +351,9 @@ public void Build_WithTag_RecordsActivityWithTagAndTargetEntityType() [Fact] public void Build_DeleteUnmatchedWithoutAllow_RecordsErrorStatus() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; // WhenNotMatchedBySourceDelete() without AllowDeleteUnmatched() triggers the existing safety guard. var act = () => new SqlMergeBuilder(Sql) @@ -353,7 +364,6 @@ public void Build_DeleteUnmatchedWithoutAllow_RecordsErrorStatus() .Build(); act.Should().Throw(); - seen.Should().NotBeNull(); - seen!.Status.Should().Be(ActivityStatusCode.Error); + GetOwn(captured, root).Status.Should().Be(ActivityStatusCode.Error); } } diff --git a/Vali-Flow.Sql.Tests/Builder/SqlTruncateBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlTruncateBuilderTests.cs index 89dd6f4..5a79aec 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlTruncateBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlTruncateBuilderTests.cs @@ -15,6 +15,27 @@ public sealed class SqlTruncateBuilderTests private static readonly ISqlDialect My = new MySqlDialect(); private static readonly ISqlDialect Sqlite = new SqliteDialect(); + // Parents every captured activity under a private root so concurrently running tests (xunit + // parallelizes test classes by default) can never pollute this test's capture, even though they + // share the same process-wide ValiFlowDiagnostics.Source. + private static (ActivityListener Listener, Activity Root, List Captured) AttachScopedListener() + { + var captured = new List(); + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = captured.Add + }; + ActivitySource.AddActivityListener(listener); + + var root = new Activity("TestRoot").Start(); + return (listener, root, captured); + } + + private static Activity GetOwn(List captured, Activity root) + => captured.Single(a => a.ParentId == root.Id); + // ── Basic TRUNCATE ──────────────────────────────────────────────────────── [Fact] @@ -135,22 +156,17 @@ public void Constructor_NullDialect_ThrowsArgumentNullException() [Fact] public void Build_WithTag_RecordsActivityWithTagAndEntityType() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; new SqlTruncateBuilder(Sql) .Table("Users") .Tag("Truncate users") .Build(); - seen.Should().NotBeNull(); - seen!.GetTagItem("vali_flow.tag").Should().Be("Truncate users"); + var seen = GetOwn(captured, root); + seen.GetTagItem("vali_flow.tag").Should().Be("Truncate users"); seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); seen.Status.Should().Be(ActivityStatusCode.Unset); } @@ -158,20 +174,14 @@ public void Build_WithTag_RecordsActivityWithTagAndEntityType() [Fact] public void Build_UnsupportedDialect_RecordsErrorStatus() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; // SQLite does not support TRUNCATE TABLE, triggering the existing guard in Build(). var act = () => new SqlTruncateBuilder(Sqlite).Table("Users").Build(); act.Should().Throw(); - seen.Should().NotBeNull(); - seen!.Status.Should().Be(ActivityStatusCode.Error); + GetOwn(captured, root).Status.Should().Be(ActivityStatusCode.Error); } } diff --git a/Vali-Flow.Sql.Tests/Builder/SqlUpdateBuilderTests.cs b/Vali-Flow.Sql.Tests/Builder/SqlUpdateBuilderTests.cs index 6e43c50..6a33ae3 100644 --- a/Vali-Flow.Sql.Tests/Builder/SqlUpdateBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/Builder/SqlUpdateBuilderTests.cs @@ -15,6 +15,27 @@ public sealed class SqlUpdateBuilderTests private static readonly ISqlDialect Pg = new PostgreSqlDialect(); private static readonly ISqlDialect My = new MySqlDialect(); + // Parents every captured activity under a private root so concurrently running tests (xunit + // parallelizes test classes by default) can never pollute this test's capture, even though they + // share the same process-wide ValiFlowDiagnostics.Source. + private static (ActivityListener Listener, Activity Root, List Captured) AttachScopedListener() + { + var captured = new List(); + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = captured.Add + }; + ActivitySource.AddActivityListener(listener); + + var root = new Activity("TestRoot").Start(); + return (listener, root, captured); + } + + private static Activity GetOwn(List captured, Activity root) + => captured.Single(a => a.ParentId == root.Id); + // ── Basic UPDATE ────────────────────────────────────────────────────────── [Fact] @@ -573,14 +594,9 @@ public void SetAllFrom_NullEntity_ThrowsArgumentNullException() [Fact] public void Build_WithTag_RecordsActivityWithTagAndEntityType() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; new SqlUpdateBuilder(Sql) .Table("Users") @@ -589,8 +605,8 @@ public void Build_WithTag_RecordsActivityWithTagAndEntityType() .Tag("Update user name") .Build(); - seen.Should().NotBeNull(); - seen!.GetTagItem("vali_flow.tag").Should().Be("Update user name"); + var seen = GetOwn(captured, root); + seen.GetTagItem("vali_flow.tag").Should().Be("Update user name"); seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); seen.GetTagItem("vali_flow.has_where").Should().Be(false); seen.Status.Should().Be(ActivityStatusCode.Unset); @@ -599,20 +615,14 @@ public void Build_WithTag_RecordsActivityWithTagAndEntityType() [Fact] public void Build_WithoutWhereOrAllowUpdateAll_RecordsErrorStatus() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; // No Where() and no AllowUpdateAll() triggers the existing safety guard in Build(). var act = () => new SqlUpdateBuilder(Sql).Table("Users").Set(x => x.Name, "Bob").Build(); act.Should().Throw(); - seen.Should().NotBeNull(); - seen!.Status.Should().Be(ActivityStatusCode.Error); + GetOwn(captured, root).Status.Should().Be(ActivityStatusCode.Error); } } diff --git a/Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs b/Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs index 9a322e8..4749112 100644 --- a/Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs +++ b/Vali-Flow.Sql.Tests/SqlQueryBuilderTests.cs @@ -1818,34 +1818,38 @@ public void JoinSubquery_EmptyAlias_ThrowsArgumentException() // ── Observability ───────────────────────────────────────────────────────── - private static ActivityListener AttachListener() + // Parents every captured activity under a private root so concurrently running tests (xunit + // parallelizes test classes by default) can never pollute this test's capture, even though they + // share the same process-wide ValiFlowDiagnostics.Source. + private static (ActivityListener Listener, Activity Root, List Captured) AttachScopedListener() { + var captured = new List(); var listener = new ActivityListener { ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = captured.Add }; ActivitySource.AddActivityListener(listener); - return listener; + + var root = new Activity("TestRoot").Start(); + return (listener, root, captured); } + private static Activity GetOwn(List captured, Activity root) + => captured.Single(a => a.ParentId == root.Id); + [Fact] public void Build_WithTag_RecordsActivityWithTagAndEntityType() { - using var listener = AttachListener(); - Activity? seen = null; - using var localListener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(localListener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; Sql().From("Users").Tag("Get active users").Build(); - seen.Should().NotBeNull(); - seen!.GetTagItem("vali_flow.tag").Should().Be("Get active users"); + var seen = GetOwn(captured, root); + seen.GetTagItem("vali_flow.tag").Should().Be("Get active users"); seen.GetTagItem("vali_flow.entity_type").Should().Be("TestUser"); seen.Status.Should().Be(ActivityStatusCode.Unset); } @@ -1853,20 +1857,14 @@ public void Build_WithTag_RecordsActivityWithTagAndEntityType() [Fact] public void Build_WhenValidationThrows_RecordsErrorStatus() { - Activity? seen = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == ValiFlowDiagnostics.SourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => seen = activity - }; - ActivitySource.AddActivityListener(listener); + var (listener, root, captured) = AttachScopedListener(); + using var l = listener; + using var r = root; // Skip without Take/OrderBy triggers the existing pagination validation guard in Build(). var act = () => Sql().From("Users").Skip(10).Build(); act.Should().Throw(); - seen.Should().NotBeNull(); - seen!.Status.Should().Be(ActivityStatusCode.Error); + GetOwn(captured, root).Status.Should().Be(ActivityStatusCode.Error); } } diff --git a/Vali-Flow.Tests/AssemblyInfo.cs b/Vali-Flow.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..db84307 --- /dev/null +++ b/Vali-Flow.Tests/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using Xunit; + +// ValiFlowEfDiagnosticsTests asserts on System.Diagnostics.Activity captured via a process-wide +// ActivityListener on ValiFlowDiagnostics.Source, parenting each test's activities under a private +// root and filtering by ParentId. That reduces -- but does not fully eliminate -- cross-test +// collisions under xUnit's default test-class-level parallelism: the identical pattern was observed +// intermittently failing with "Sequence contains no matching element" in Vali-Flow.Sql.Tests (which +// has more Activity-asserting test classes competing concurrently). Disabling parallelization for +// this assembly removes that race at its source. +[assembly: CollectionBehavior(DisableTestParallelization = true)] From 14b0705c270a61dccd52cd9a92b5f7c99e90c073 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 02:41:52 -0500 Subject: [PATCH 19/30] fix(nosql-couchbase): bind decimal values as native N1QL numbers, not strings ResolveValue converted decimal to a string (m.ToString(InvariantCulture)) for every operator (=, <>, <, <=, >, >=). N1QL has no decimal type and does NOT coerce between string and number in comparisons (its collation order puts number < string unconditionally) -- a string-bound parameter silently matched zero rows against a numeric field, for every operator, 100% of the time. Found running the translator's N1QL output against a real Couchbase Server (Caso A stress validation): EqualNode(decimal) and ComparisonNode(decimal) failed in all 3 tested scales (1K/20K/100K docs) while the equivalent int field worked correctly. Mongo/Elasticsearch/DynamoDB translators already preserve the native numeric type for decimal; Couchbase was the only one converting it to string. --- .../CouchbaseFilterTranslatorTests.cs | 8 ++++++-- .../Translators/CouchbaseFilterTranslator.cs | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Vali-Flow.NoSql.Couchbase.Tests/CouchbaseFilterTranslatorTests.cs b/Vali-Flow.NoSql.Couchbase.Tests/CouchbaseFilterTranslatorTests.cs index f1981a0..9ff5743 100644 --- a/Vali-Flow.NoSql.Couchbase.Tests/CouchbaseFilterTranslatorTests.cs +++ b/Vali-Flow.NoSql.Couchbase.Tests/CouchbaseFilterTranslatorTests.cs @@ -377,13 +377,17 @@ public void CustomConverter_WhenReturnsNull_FallsThroughToDefault() // ── Type coverage ───────────────────────────────────────────────────────── [Fact] - public void ToCouchbase_DecimalValue_ProducesStringParameter() + public void ToCouchbase_DecimalValue_ProducesNumericParameter() { + // Regression: N1QL has no implicit coercion between string and number — a decimal + // bound as a string silently matched zero rows against a numeric field for every + // comparison op, confirmed against a real Couchbase cluster. Must bind as a number. var node = new EqualNode("Price", 9.99m, false); CouchbaseFilterExpression f = CouchbaseFilterTranslator.Translate(node); - f.Parameters["$p0"].Should().Be("9.99"); + f.Parameters["$p0"].Should().Be(9.99m); + f.Parameters["$p0"].Should().BeOfType(); } [Fact] diff --git a/Vali-Flow.NoSql.Couchbase/Translators/CouchbaseFilterTranslator.cs b/Vali-Flow.NoSql.Couchbase/Translators/CouchbaseFilterTranslator.cs index 6aeef15..b1c1398 100644 --- a/Vali-Flow.NoSql.Couchbase/Translators/CouchbaseFilterTranslator.cs +++ b/Vali-Flow.NoSql.Couchbase/Translators/CouchbaseFilterTranslator.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Vali_Flow.Abstractions.Diagnostics; using Vali_Flow.NoSql.Couchbase.Models; using Vali_Flow.NoSql.IR; @@ -127,7 +126,11 @@ public string VisitIn(InNode node) long l => l, double d => d, float f => (double)f, - decimal m => m.ToString(CultureInfo.InvariantCulture), + // Bound as a native N1QL number, not a string — Couchbase/N1QL has no decimal + // type and does NOT coerce between string and number in comparisons (number < + // string in its collation order), so a string-bound parameter silently matches + // zero rows against a numeric field for every op (=, <>, <, <=, >, >=). + decimal m => m, Guid g => g.ToString(), Enum e => Convert.ToInt64(e), _ => v!.ToString()! From 193756b5cfe9a8bb167f09311d1b88c7ae94f791 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 12:45:12 -0500 Subject: [PATCH 20/30] docs: add release pipeline design spec --- .../2026-10-06-release-pipeline-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-06-release-pipeline-design.md diff --git a/docs/superpowers/specs/2026-10-06-release-pipeline-design.md b/docs/superpowers/specs/2026-10-06-release-pipeline-design.md new file mode 100644 index 0000000..1d475b6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-06-release-pipeline-design.md @@ -0,0 +1,145 @@ +# Design: Release pipeline for Vali-Flow (GitHub Actions) + +**Date:** 2026-10-06 +**Status:** Approved + +## Problem + +The Vali-Flow repository ships 12 independent NuGet packages with a two-level dependency chain +(`Vali-Flow.Abstractions` → `Vali-Flow`/`Vali-Flow.InMemory`/`Vali-Flow.Sql`/`Vali-Flow.NoSql` → +the 7 NoSQL provider packages), and has **no CI/CD at all** today — no `.github/workflows/` +directory exists. Publishing has been manual. This session's work (observability, bug fixes found +via stress testing, new providers) needs to go out, but every package's committed `` +already equals what's currently live on NuGet.org — nothing can be republished without bumping +versions first, and the dependency chain means publishing out of order will push packages whose +declared `PackageReference` points at a version that isn't live yet. + +Two sibling repositories in the same ecosystem (`Vali-Mediator`, `Vali-Flow.Core`) already solved +this with a `ci.yml` + `release.yml` pair. Vali-Flow's job is to extend that same pattern to a third +dependency wave, not invent a new one. + +## Non-goals + +- This spec does not design the test/coverage CI that will also eventually run on every PR — that + is `ci.yml`'s job, reused here, but a from-scratch CI design is out of scope; it follows the + already-proven shape from `Vali-Flow.Core`. +- This spec does not perform the version bump or revert the 11 `.csproj` files currently on + `ProjectReference` (temporary, for this session's stress testing) back to `PackageReference`. + That is a prerequisite carried out before the pipeline can run, tracked separately. +- No changes to `Vali-Flow.Core` (unchanged this session, already published at the version every + package here pins). + +## Reference pattern (what we're extending) + +`Vali-Flow.Core/.github/workflows/{ci.yml,release.yml}` and `Vali-Mediator`'s equivalent files +already implement, for a **two-wave** dependency chain (core, then everything else): + +- `ci.yml`: `workflow_call`-able. Jobs: `build` → `test` (matrix `{ubuntu-latest, windows-latest} × + {net8.0, net9.0}`, coverage collected via `--collect:"XPlat Code Coverage"`) → `coverage` (merges + Cobertura reports across every OS/TFM cell, per-library gate) → `pack` (applies an optional + prerelease suffix, packs every project, verifies every `.nupkg` contains both TFMs) → `ci-ok` + (single required status check). +- `release.yml`: `workflow_dispatch` only, inputs `version-suffix` (optional prerelease label) and + `publish` (boolean, default false). Calls `ci.yml`. If `publish` is true, a second job + (`environment: nuget`, gated on GitHub's environment-approval + required reviewers) downloads the + packed artifact, pushes the core package, polls `https://api.nuget.org/v3-flatcontainer//index.json` + every 30s (up to 40 attempts) until the core version is indexed, then pushes every dependent + package. `--skip-duplicate` on every push makes a re-run safe. + +Vali-Flow reuses this shape unchanged except for the publish job, which needs a third wave. + +## Design + +### 1. `ci.yml` (new, reusable) + +Same shape as `Vali-Flow.Core`'s: `build` (restore + build the whole `.sln`) → `test` (matrix +`{ubuntu-latest, windows-latest} × {net8.0, net9.0}`, runs all 11 test projects, collects per-cell +Cobertura coverage, uploads a `.trx`-based summary table and a merged full-trace artifact) → +`coverage` (merges Cobertura across all 4 cells per library, **95% line-coverage gate per +package** — the 12 packages below are "Library" rows the Python merge script checks by name) → +`pack` (packs all 12 projects with the optional `version-suffix` prerelease label applied via the +same `Directory.Build.targets` injection trick as `Vali-Flow.Core`, verifies every `.nupkg` has +both `lib/net8.0` and `lib/net9.0`) → `ci-ok`. + +**Coverage libraries list** (mirrors `Vali-Flow.Core`'s `LIBS` array, one row per package): +`Vali-Flow.Abstractions`, `Vali-Flow`, `Vali-Flow.InMemory`, `Vali-Flow.Sql`, `Vali-Flow.NoSql`, +`Vali-Flow.NoSql.MongoDB`, `Vali-Flow.NoSql.Elasticsearch`, `Vali-Flow.NoSql.Redis`, +`Vali-Flow.NoSql.DynamoDB`, `Vali-Flow.NoSql.Couchbase`, `Vali-Flow.NoSql.CosmosDb`, +`Vali-Flow.NoSql.Firestore`. `Vali-Flow.Benchmarks` is excluded (no tests, it's a BenchmarkDotNet +project). + +**Threshold note** (same framing as `Vali-Flow.Core`'s own comment): 95% is a starting gate, not an +audited baseline for the 10 packages that weren't measured this session (`Vali-Flow` itself was: +97.42%). The first real CI run will show every package's actual number; any package under 95% needs +either more tests or an explicitly-justified threshold adjustment at that point — not a silent +pre-emptive lowering now. + +### 2. `release.yml` (new) + +Identical `workflow_dispatch` inputs and `ci` job as the reference pattern. The `publish` job +(same `environment: nuget`, same `NUGET_API_KEY` secret, same `--skip-duplicate` everywhere) grows +from 2 steps-with-a-wait to **3 waves**: + +``` +Wave 1: push Vali-Flow.Abstractions..nupkg + wait until nuget.org indexes vali-flow.abstractions/ + +Wave 2: push Vali-Flow..nupkg, Vali-Flow.InMemory..nupkg, + Vali-Flow.Sql..nupkg, Vali-Flow.NoSql..nupkg + wait until nuget.org indexes vali-flow.nosql/ + (Vali-Flow/InMemory/Sql don't gate wave 3 — only NoSql does, since only the + 7 providers in wave 3 depend on it) + +Wave 3: push the 7 Vali-Flow.NoSql.{MongoDB,Elasticsearch,Redis,DynamoDB,Couchbase,CosmosDb,Firestore} + ..nupkg packages +``` + +Each wave's push step globs its packages by filename prefix, same technique the reference pattern +uses to distinguish the core from everything else (e.g. `Vali-Flow.[0-9]*.nupkg` matches only the +main package, not `Vali-Flow.InMemory.*` or `Vali-Flow.Sql.*`, because of the literal dot before the +version digit). The indexed-version check script is parameterized by package id instead of +hardcoded, since there are now two intermediate gates instead of one. + +### 3. Operational prerequisites (not part of this pipeline's code, but required before first use) + +- **GitHub environment `nuget`** must exist on this repo (Settings → Environments) with a + `NUGET_API_KEY` secret and required reviewers configured — identical setup to `Vali-Mediator`/ + `Vali-Flow.Core`. The user sets this up; the pipeline cannot create its own secrets or approval + gates. +- **Version bump + reference cleanup**: every package's `` must be bumped past what's + already live (table below) and the 11 `.csproj` files currently on a temporary `ProjectReference` + (from this session's stress testing) must be reverted to `PackageReference` pinned at the new + version numbers, before any release run — otherwise `pack` either fails to resolve the reference + or ships a package that depends on a version not yet on NuGet. + + | Package | Published (= current csproj) | → New version | + |---|---|---| + | Vali-Flow.Abstractions | 1.1.0 | 1.2.0 | + | Vali-Flow.NoSql | 1.1.0 | 1.1.1 | + | Vali-Flow | 1.3.4 | 1.4.0 | + | Vali-Flow.InMemory | 1.1.5 | 1.2.0 | + | Vali-Flow.Sql | 1.1.1 | 1.2.0 | + | Vali-Flow.NoSql.MongoDB | 1.1.0 | 1.2.0 | + | Vali-Flow.NoSql.Elasticsearch | 1.1.0 | 1.2.0 | + | Vali-Flow.NoSql.Redis | 1.1.0 | 1.2.0 | + | Vali-Flow.NoSql.DynamoDB | 1.1.0 | 1.2.0 | + | Vali-Flow.NoSql.Couchbase | 1.0.0 | 1.1.0 | + | Vali-Flow.NoSql.CosmosDb | 1.0.0 | 1.1.0 | + | Vali-Flow.NoSql.Firestore | 1.0.0 | 1.1.0 | + + All bumps are minor (additive API surface / non-breaking fixes only — no package introduced a + breaking change this session). + +## Testing + +The pipeline's own correctness is validated by running it: a `publish: false` dry run exercises +`build`/`test`/`coverage`/`pack` without touching NuGet, which is the safe way to validate the +workflow YAML and the coverage/pack scripts before ever approving a real publish. The first real +`publish: true` run is the actual end-to-end test of the 3-wave ordering and indexing waits. + +## Error handling + +Same as the reference pattern: `--skip-duplicate` on every `dotnet nuget push` makes any step safe +to re-run after a transient failure (e.g. the indexing wait timing out at 40×30s) without +double-publishing. The `coverage` and `pack` verification steps fail the whole run before anything +is ever pushed, so a broken package can't reach NuGet even on a rushed manual dispatch. From c38d0f26c25c881e12aba74aebecddcc6a41df5b Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 13:01:05 -0500 Subject: [PATCH 21/30] chore: gitignore .superpowers/ (subagent-driven-development scratch workspace) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4e97677..bd8c8c1 100644 --- a/.gitignore +++ b/.gitignore @@ -407,3 +407,5 @@ CLAUDE.md .claudeignore .guardian.json .codegraph/ + +.superpowers/ From f16c2bb8e97adb5123c79c83c3b59f632e30fde1 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 13:03:35 -0500 Subject: [PATCH 22/30] chore(abstractions): bump version to 1.2.0 for release --- Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj b/Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj index 2e5ddd6..b914c86 100644 --- a/Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj +++ b/Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj @@ -14,7 +14,7 @@ git true Copyright © 2025 - FM. All rights reserved. - 1.1.0 + 1.2.0 VF_logo.png Vali-Flow.Abstractions Vali-Flow.Abstractions @@ -29,7 +29,9 @@ true snupkg - v1.1.0 — Enhanced documentation and internal contracts alignment with ecosystem refactor. + v1.2.0 — Add ValiFlowDiagnostics: a shared ActivitySource-based observability helper (StartActivity/RecordException) used across every Vali-Flow package for Activity-based tracing. No breaking changes. + +v1.1.0 — Enhanced documentation and internal contracts alignment with ecosystem refactor. From 409e67180d983f507b1d364d2bfdc21cb1efb3f9 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 13:10:22 -0500 Subject: [PATCH 23/30] chore(wave-2): bump versions and re-point Vali-Flow.Abstractions to PackageReference 1.2.0 --- Vali-Flow.InMemory/Vali-Flow.InMemory.csproj | 8 +++++--- Vali-Flow.NoSql/Vali-Flow.NoSql.csproj | 6 +++--- Vali-Flow.Sql/Vali-Flow.Sql.csproj | 8 +++++--- Vali-Flow/Vali-Flow.csproj | 8 +++++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Vali-Flow.InMemory/Vali-Flow.InMemory.csproj b/Vali-Flow.InMemory/Vali-Flow.InMemory.csproj index c4d58c0..b77a356 100644 --- a/Vali-Flow.InMemory/Vali-Flow.InMemory.csproj +++ b/Vali-Flow.InMemory/Vali-Flow.InMemory.csproj @@ -11,7 +11,7 @@ Vali-Flow.InMemory Vali-Flow.InMemory Vali-Flow.InMemory - 1.1.5 + 1.2.0 Felipe Rafael Montenegro Morriberon Copyright © 2026 - FM. All rights reserved. @@ -37,7 +37,9 @@ snupkg Synchronous in-memory evaluator for Vali-Flow.Core — filter, page, aggregate and write in-memory collections using the same fluent ValiFlow<T> specification API. Ideal for unit testing and caching layers without a database dependency. - v1.1.5 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. + v1.2.0 — Fix Update(entity, externalList) leaking into the internal store on a later bare SaveChanges() call. Split ValiFlowEvaluator<T,TProperty> into partial files (Bridge/Read/Write/Grouped). Add an optional tag parameter across read/write/grouped methods, wired to ValiFlowDiagnostics Activity tracing. No breaking changes. + +v1.1.5 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. v1.1.4 — Remove sealed modifiers from all classes (ValiFlowEvaluator, AsyncInMemoryAdapter, InMemoryWriteStore, PagedResult). Now fully inheritable for custom implementations. No breaking changes. @@ -45,7 +47,7 @@ v1.1.4 — Remove sealed modifiers from all classes (ValiFlowEvaluator, AsyncInM - + diff --git a/Vali-Flow.NoSql/Vali-Flow.NoSql.csproj b/Vali-Flow.NoSql/Vali-Flow.NoSql.csproj index 8406aeb..4528c57 100644 --- a/Vali-Flow.NoSql/Vali-Flow.NoSql.csproj +++ b/Vali-Flow.NoSql/Vali-Flow.NoSql.csproj @@ -6,7 +6,7 @@ enable Vali_Flow.NoSql Vali-Flow.NoSql - 1.1.0 + 1.1.1 Felipe Rafael Montenegro Morriberon Provider-agnostic NoSql IR for Vali-Flow.Core — converts expression trees to a neutral condition node tree that can be translated to MongoDB, Elasticsearch, or any document store. MIT @@ -22,13 +22,13 @@ embedded true snupkg - v1.1.0 — Enhanced documentation and contracts alignment, improved adapter integration pattern. + v1.1.1 — Fix: x.Field == nullVariable (a closure-captured null) now translates to NullNode like the x.Field == null literal path instead of throwing ArgumentNullException. No breaking changes. - + diff --git a/Vali-Flow.Sql/Vali-Flow.Sql.csproj b/Vali-Flow.Sql/Vali-Flow.Sql.csproj index 2242e96..5e7e07e 100644 --- a/Vali-Flow.Sql/Vali-Flow.Sql.csproj +++ b/Vali-Flow.Sql/Vali-Flow.Sql.csproj @@ -11,7 +11,7 @@ Vali-Flow.Sql Vali-Flow.Sql Vali-Flow.Sql - 1.1.1 + 1.2.0 Felipe Rafael Montenegro Morriberon Copyright © 2026 - FM. All rights reserved. @@ -37,7 +37,9 @@ snupkg SQL translator for Vali-Flow.Core — converts ValiFlow<T> expression trees to parameterized SQL for Dapper and ADO.NET. Supports SQL Server, PostgreSQL, MySQL, and SQLite dialects. - v1.1.1 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. + v1.2.0 — Add SqlIdentifierGuard (regex whitelist validation for table/schema/type names) to every builder, fixing a SQL injection vector. Add SetAllFrom(entity, exclude...) reflection-based bulk column mapper to Insert/Update builders. Fix OrIgnore/OrReplace dialect guard, Set()-after-SelectFrom() silent data loss, and Where()-called-twice silent overwrite bugs. Instrument all 6 builders' Build() with ValiFlowDiagnostics Activity tracing. No breaking changes. + +v1.1.1 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. v1.1.0 — Initial release. Expression-to-SQL translator with dialect support for SQL Server, PostgreSQL, MySQL, and SQLite. @@ -45,7 +47,7 @@ v1.1.0 — Initial release. Expression-to-SQL translator with dialect support fo - + diff --git a/Vali-Flow/Vali-Flow.csproj b/Vali-Flow/Vali-Flow.csproj index 5db4ce2..bfc706a 100644 --- a/Vali-Flow/Vali-Flow.csproj +++ b/Vali-Flow/Vali-Flow.csproj @@ -14,7 +14,7 @@ Vali-Flow Vali-Flow Vali-Flow - 1.3.4 + 1.4.0 Felipe Rafael Montenegro Morriberon Copyright © 2026 - FM. All rights reserved. @@ -40,7 +40,9 @@ snupkg Vali-Flow is a .NET library that simplifies data access in Entity Framework Core applications via a fluent specification API. Provides ValiFlowEvaluator for querying and writing data, QuerySpecification and BasicSpecification for defining query criteria, and bulk operation support via EFCore.BulkExtensions. - v1.3.4 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. + v1.4.0 — Add GenericRepository<T,TKey> (GetById/GetAll/GetPaged/Add/Update/Delete/SaveChanges, wraps ValiFlowEvaluator<T>). Instrument read/write evaluator methods with ValiFlowDiagnostics Activity tracing. Fix UpsertRangeAsync duplicate-key bug and a PK-overwrite bug in upsert-by-business-key scenarios. Serialize concurrent BulkInsertOrUpdateAsync/BulkUpdateAsync calls per entity type with a generic transient-failure retry (SQL Server/PostgreSQL/MySQL/SQLite). No breaking changes. + +v1.3.4 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. v1.3.3 — Updated Vali-Flow.Core dependency to 2.0.1. @@ -53,7 +55,7 @@ v1.3.0 — Made ValiFlowEvaluator<T> inheritable (removed sealed modifier) - + From 7c771bb8a5a94a601577c60d40003699c5edeff2 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 13:11:27 -0500 Subject: [PATCH 24/30] docs: add release pipeline implementation plan --- .../plans/2026-10-06-release-pipeline.md | 1322 +++++++++++++++++ 1 file changed, 1322 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-06-release-pipeline.md diff --git a/docs/superpowers/plans/2026-10-06-release-pipeline.md b/docs/superpowers/plans/2026-10-06-release-pipeline.md new file mode 100644 index 0000000..fb65085 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-release-pipeline.md @@ -0,0 +1,1322 @@ +# Release Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bump all 12 Vali-Flow package versions, revert the 11 temporary `ProjectReference`s back to `PackageReference`, and add `.github/workflows/ci.yml` + `.github/workflows/release.yml` replicating the proven `Vali-Flow.Core`/`Vali-Mediator` pattern extended to this repo's 3-tier dependency chain. + +**Architecture:** Two-stage mechanical prep (version bumps, then reference reverts, in dependency order so nothing ever points at a version that doesn't exist yet) followed by two new workflow files that reuse the exact job shapes already proven in `Vali-Flow.Core`. No application code changes — this plan only touches `.csproj` metadata and adds CI/CD config. + +**Tech Stack:** .NET SDK 9.0.3 (net8.0/net9.0 multi-target), GitHub Actions, `dotnet pack`/`dotnet nuget push`, Python 3 (inline, for the coverage-merge and package-verification scripts — same tool the reference pattern already uses). + +**Spec:** `docs/superpowers/specs/2026-10-06-release-pipeline-design.md` + +## Global Constraints + +- Coverage gate: **95% line coverage per package**, enforced by `ci.yml`'s `coverage` job. Starting gate, not an audited baseline for the 10 packages not measured this session. +- Every package must multi-target `net8.0;net9.0` (already true for all 12 — do not change `TargetFrameworks`). +- No breaking changes in this plan — every version bump is **minor** (new/changed major version is out of scope; if a task's diff looks breaking, stop and flag it instead of bumping differently). +- Never create the GitHub `nuget` environment or its `NUGET_API_KEY` secret — that's the user's manual prerequisite, out of scope for every task below. +- Every `.csproj` edit must preserve the file's existing indentation style (some use 4 spaces, some use 2 — match whatever the file already uses, don't reformat the whole file). +- No task in this plan runs `git push`, triggers the real GitHub Actions workflow, or runs `dotnet nuget push` — this plan stops at a local, verified dry run. + +--- + +### Task 1: Bump `Vali-Flow.Abstractions` to 1.2.0 (wave 1) + +**Files:** +- Modify: `Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: `Vali-Flow.Abstractions` version `1.2.0`, which Tasks 2 and 4 depend on by exact string. + +- [ ] **Step 1: Confirm the current state before editing** + +Run: `grep -n "" "Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj"` +Expected output: `17: 1.1.0` + +- [ ] **Step 2: Bump the version and add a release note** + +In `Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj`, replace: + +```xml + 1.1.0 +``` + +with: + +```xml + 1.2.0 +``` + +And replace: + +```xml + v1.1.0 — Enhanced documentation and internal contracts alignment with ecosystem refactor. +``` + +with: + +```xml + v1.2.0 — Add ValiFlowDiagnostics: a shared ActivitySource-based observability helper (StartActivity/RecordException) used across every Vali-Flow package for Activity-based tracing. No breaking changes. + +v1.1.0 — Enhanced documentation and internal contracts alignment with ecosystem refactor. +``` + +- [ ] **Step 3: Verify the package builds and packs at the new version** + +Run: `dotnet pack Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj --configuration Release -o /tmp/pack-check` +Expected: `Compilación correcta` (or `Build succeeded`), and `/tmp/pack-check/Vali-Flow.Abstractions.1.2.0.nupkg` exists. + +Run: `ls /tmp/pack-check/Vali-Flow.Abstractions.1.2.0.nupkg` +Expected: the file is listed (no "No such file" error). + +- [ ] **Step 4: Commit** + +```bash +git add Vali-Flow.Abstractions/Vali-Flow.Abstractions.csproj +git commit -m "chore(abstractions): bump version to 1.2.0 for release" +``` + +--- + +### Task 2: Bump and re-point the 4 wave-2 packages (`Vali-Flow`, `Vali-Flow.InMemory`, `Vali-Flow.Sql`, `Vali-Flow.NoSql`) + +**Files:** +- Modify: `Vali-Flow/Vali-Flow.csproj` +- Modify: `Vali-Flow.InMemory/Vali-Flow.InMemory.csproj` +- Modify: `Vali-Flow.Sql/Vali-Flow.Sql.csproj` +- Modify: `Vali-Flow.NoSql/Vali-Flow.NoSql.csproj` + +**Interfaces:** +- Consumes: `Vali-Flow.Abstractions` version `1.2.0` (from Task 1) — every file below must pin exactly this string in its `PackageReference`. +- Produces: `Vali-Flow` 1.4.0, `Vali-Flow.InMemory` 1.2.0, `Vali-Flow.Sql` 1.2.0, `Vali-Flow.NoSql` 1.1.1 — `Vali-Flow.NoSql`'s version is consumed by Task 3. + +Each of the 4 files gets the same two changes: (a) swap the `ProjectReference` to `Vali-Flow.Abstractions` back to a `PackageReference` pinned at `1.2.0`, (b) bump its own `` + add a release note. Do all 4 in this one task since they share the same dependency edge and are meaningless to test independently (none of them compile standalone against the old `Vali-Flow.Abstractions` NuGet package — that package doesn't have `ValiFlowDiagnostics` yet, which is exactly why this task exists). + +- [ ] **Step 1: `Vali-Flow/Vali-Flow.csproj` — re-point to PackageReference and bump version** + +Replace: + +```xml + 1.3.4 +``` + +with: + +```xml + 1.4.0 +``` + +Replace: + +```xml + v1.3.4 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. +``` + +with: + +```xml + v1.4.0 — Add GenericRepository<T,TKey> (GetById/GetAll/GetPaged/Add/Update/Delete/SaveChanges, wraps ValiFlowEvaluator<T>). Instrument read/write evaluator methods with ValiFlowDiagnostics Activity tracing. Fix UpsertRangeAsync duplicate-key bug and a PK-overwrite bug in upsert-by-business-key scenarios. Serialize concurrent BulkInsertOrUpdateAsync/BulkUpdateAsync calls per entity type with a generic transient-failure retry (SQL Server/PostgreSQL/MySQL/SQLite). No breaking changes. + +v1.3.4 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. +``` + +Replace: + +```xml + + + + + + +``` + +with: + +```xml + + + +``` + +- [ ] **Step 2: `Vali-Flow.InMemory/Vali-Flow.InMemory.csproj` — re-point to PackageReference and bump version** + +Replace: + +```xml + 1.1.5 +``` + +with: + +```xml + 1.2.0 +``` + +Replace: + +```xml + v1.1.5 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. +``` + +with: + +```xml + v1.2.0 — Fix Update(entity, externalList) leaking into the internal store on a later bare SaveChanges() call. Split ValiFlowEvaluator<T,TProperty> into partial files (Bridge/Read/Write/Grouped). Add an optional tag parameter across read/write/grouped methods, wired to ValiFlowDiagnostics Activity tracing. No breaking changes. + +v1.1.5 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. +``` + +Replace: + +```xml + + + + + + + + +``` + +with: + +```xml + + + + + +``` + +- [ ] **Step 3: `Vali-Flow.Sql/Vali-Flow.Sql.csproj` — re-point to PackageReference and bump version** + +Replace: + +```xml + 1.1.1 +``` + +with: + +```xml + 1.2.0 +``` + +Replace: + +```xml + v1.1.1 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. +``` + +with: + +```xml + v1.2.0 — Add SqlIdentifierGuard (regex whitelist validation for table/schema/type names) to every builder, fixing a SQL injection vector. Add SetAllFrom(entity, exclude...) reflection-based bulk column mapper to Insert/Update builders. Fix OrIgnore/OrReplace dialect guard, Set()-after-SelectFrom() silent data loss, and Where()-called-twice silent overwrite bugs. Instrument all 6 builders' Build() with ValiFlowDiagnostics Activity tracing. No breaking changes. + +v1.1.1 — Updated Vali-Flow.Core dependency to 2.0.2 (fixes IsNull/NotNull WHERE 0=1 bug with EF Core GlobalQueryFilter). No API changes. +``` + +Replace: + +```xml + + + + + + + + +``` + +with: + +```xml + + + + + +``` + +- [ ] **Step 4: `Vali-Flow.NoSql/Vali-Flow.NoSql.csproj` — re-point to PackageReference and bump version** + +Replace: + +```xml + 1.1.0 +``` + +with: + +```xml + 1.1.1 +``` + +Replace: + +```xml + v1.1.0 — Enhanced documentation and contracts alignment, improved adapter integration pattern. +``` + +with: + +```xml + v1.1.1 — Fix: x.Field == nullVariable (a closure-captured null) now translates to NullNode like the x.Field == null literal path instead of throwing ArgumentNullException. No breaking changes. +``` + +Replace: + +```xml + + + + + + + + +``` + +with: + +```xml + + + + + +``` + +- [ ] **Step 5: Verify all 4 packages build and pack against the real `Vali-Flow.Abstractions` 1.2.0 NuGet package** + +This is the real test of this task: these 4 `.csproj` now declare a `PackageReference` to `Vali-Flow.Abstractions` `1.2.0`, but that version is **not yet on NuGet.org** (Task 1 only packed it locally, it was never pushed — this plan never pushes). `dotnet restore`/`dotnet pack` will fail to resolve that package unless it's available from a local source. Add the local pack output from Task 1 as a NuGet source for this verification only: + +Run: `dotnet nuget add source /tmp/pack-check --name local-verify-only 2>&1 || true` +(The `|| true` tolerates "source already exists" if this step is re-run.) + +Run: `dotnet pack Vali-Flow/Vali-Flow.csproj --configuration Release -o /tmp/pack-check` +Expected: `Compilación correcta`, `/tmp/pack-check/Vali-Flow.1.4.0.nupkg` exists. + +Run: `dotnet pack Vali-Flow.InMemory/Vali-Flow.InMemory.csproj --configuration Release -o /tmp/pack-check` +Expected: `Compilación correcta`, `/tmp/pack-check/Vali-Flow.InMemory.1.2.0.nupkg` exists. + +Run: `dotnet pack Vali-Flow.Sql/Vali-Flow.Sql.csproj --configuration Release -o /tmp/pack-check` +Expected: `Compilación correcta`, `/tmp/pack-check/Vali-Flow.Sql.1.2.0.nupkg` exists. + +Run: `dotnet pack Vali-Flow.NoSql/Vali-Flow.NoSql.csproj --configuration Release -o /tmp/pack-check` +Expected: `Compilación correcta`, `/tmp/pack-check/Vali-Flow.NoSql.1.1.1.nupkg` exists. + +Run: `dotnet nuget remove source local-verify-only` +(Cleanup — this local source must not linger in the machine's global NuGet config after this task.) + +- [ ] **Step 6: Commit** + +```bash +git add Vali-Flow/Vali-Flow.csproj Vali-Flow.InMemory/Vali-Flow.InMemory.csproj Vali-Flow.Sql/Vali-Flow.Sql.csproj Vali-Flow.NoSql/Vali-Flow.NoSql.csproj +git commit -m "chore(wave-2): bump versions and re-point Vali-Flow.Abstractions to PackageReference 1.2.0" +``` + +--- + +### Task 3: Bump and re-point the 7 wave-3 NoSQL provider packages + +**Files:** +- Modify: `Vali-Flow.NoSql.MongoDB/Vali-Flow.NoSql.MongoDB.csproj` +- Modify: `Vali-Flow.NoSql.Elasticsearch/Vali-Flow.NoSql.Elasticsearch.csproj` +- Modify: `Vali-Flow.NoSql.Redis/Vali-Flow.NoSql.Redis.csproj` +- Modify: `Vali-Flow.NoSql.DynamoDB/Vali-Flow.NoSql.DynamoDB.csproj` +- Modify: `Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj` +- Modify: `Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj` +- Modify: `Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj` + +**Interfaces:** +- Consumes: `Vali-Flow.NoSql` version `1.1.1` (from Task 2) — every file below must pin exactly this string. +- Produces: 7 packages ready for wave 3 of `release.yml` (Task 5). + +- [ ] **Step 1: `Vali-Flow.NoSql.MongoDB` — bump version and re-point** + +Replace: + +```xml + 1.1.0 +``` + +with: + +```xml + 1.2.0 +``` + +Replace: + +```xml + v1.1.0 — Enhanced documentation and adapter integration pattern alignment. +``` + +with: + +```xml + v1.2.0 — Add a MaxInValues cap (10,000) to InNode translation, a conservative estimate to avoid unbounded $in clauses. Instrument Translate(...) with ValiFlowDiagnostics Activity tracing (tag + entityType). No breaking changes. +``` + +Replace: + +```xml + + + + + + + + +``` + +with: + +```xml + + + + + +``` + +- [ ] **Step 2: `Vali-Flow.NoSql.Elasticsearch` — bump version and re-point** + +Replace: + +```xml + 1.1.0 +``` + +with: + +```xml + 1.2.0 +``` + +Replace: + +```xml + v1.1.0 — Enhanced documentation and adapter integration pattern alignment. +``` + +with: + +```xml + v1.2.0 — Fix a swallowed inner exception in ToDouble (now wrapped with context instead of lost). Add a MaxInValues cap (65,536, matching index.max_terms_count). Instrument Translate(...) with ValiFlowDiagnostics Activity tracing. No breaking changes. +``` + +Replace: + +```xml + + + + + + + + +``` + +with: + +```xml + + + + + +``` + +- [ ] **Step 3: `Vali-Flow.NoSql.Redis` — bump version and re-point** + +Replace: + +```xml + 1.1.0 +``` + +with: + +```xml + 1.2.0 +``` + +Replace: + +```xml + v1.1.0 — Enhanced documentation and adapter integration pattern alignment. +``` + +with: + +```xml + v1.2.0 — Security fix: escape RediSearch query-syntax special characters in LIKE patterns before adding wildcards, closing a query-injection vector (e.g. a pattern containing ')' or '|' could previously break out of the field's scope). Fix customConverter not being applied in VisitComparison/numeric VisitIn. Add a MaxInValues cap (1,000). Instrument Translate(...) with ValiFlowDiagnostics Activity tracing. No breaking changes. +``` + +Replace: + +```xml + + + + + + + + +``` + +with: + +```xml + + + + + +``` + +- [ ] **Step 4: `Vali-Flow.NoSql.DynamoDB` — bump version and re-point** + +Replace: + +```xml + 1.1.0 +``` + +with: + +```xml + 1.2.0 +``` + +Replace: + +```xml + v1.1.0 — Enhanced documentation and adapter integration pattern alignment. +``` + +with: + +```xml + v1.2.0 — Add a MaxInValues cap. Instrument Translate(...) with ValiFlowDiagnostics Activity tracing (tag + entityType). No breaking changes. +``` + +Replace: + +```xml + + + + + + + + +``` + +with: + +```xml + + + + + +``` + +- [ ] **Step 5: `Vali-Flow.NoSql.Couchbase` — bump version and re-point** + +Replace: + +```xml + 1.0.0 +``` + +with: + +```xml + 1.1.0 +``` + +Replace: + +```xml + v1.0.0 — Initial release. N1QL (SQL++) WHERE clause translator. +``` + +with: + +```xml + v1.1.0 — Fix: decimal values were bound as N1QL string parameters instead of native numbers, making every comparison operator (=, <>, >, >=, <, <=) against a decimal field silently match zero rows (N1QL does not coerce between string and number). Add a MaxInValues cap. Instrument Translate(...) with ValiFlowDiagnostics Activity tracing. No breaking changes. +``` + +Replace: + +```xml + + + + + + + +``` + +with: + +```xml + + + + +``` + +- [ ] **Step 6: `Vali-Flow.NoSql.CosmosDb` — bump version and re-point** + +Replace: + +```xml + 1.0.0 +``` + +with: + +```xml + 1.1.0 +``` + +Replace: + +```xml + v1.0.0 — Initial release. Cosmos DB SQL API WHERE clause translator. +``` + +with: + +```xml + v1.1.0 — Add a MaxInValues cap. Instrument Translate(...) with ValiFlowDiagnostics Activity tracing. No breaking changes. +``` + +Replace: + +```xml + + + + + + + +``` + +with: + +```xml + + + + +``` + +- [ ] **Step 7: `Vali-Flow.NoSql.Firestore` — bump version and re-point** + +Replace: + +```xml + 1.0.0 +``` + +with: + +```xml + 1.1.0 +``` + +Replace: + +```xml + v1.0.0 — Initial release. Firestore native Filter translator. +``` + +with: + +```xml + v1.1.0 — Instrument Translate(...) with ValiFlowDiagnostics Activity tracing (tag + entityType). No breaking changes. +``` + +Replace: + +```xml + + + + + + + + +``` + +with: + +```xml + + + + + +``` + +- [ ] **Step 8: Verify all 7 providers build and pack against the real `Vali-Flow.NoSql` 1.1.1 NuGet package** + +Same technique as Task 2 Step 5 — these 7 `.csproj` now reference `Vali-Flow.NoSql` `1.1.1`, which isn't on NuGet.org yet; point `dotnet` at the local pack output. + +Run: `dotnet nuget add source /tmp/pack-check --name local-verify-only 2>&1 || true` + +Run each of the following and expect `Compilación correcta` plus the named `.nupkg` present in `/tmp/pack-check`: + +```bash +dotnet pack Vali-Flow.NoSql.MongoDB/Vali-Flow.NoSql.MongoDB.csproj --configuration Release -o /tmp/pack-check +dotnet pack Vali-Flow.NoSql.Elasticsearch/Vali-Flow.NoSql.Elasticsearch.csproj --configuration Release -o /tmp/pack-check +dotnet pack Vali-Flow.NoSql.Redis/Vali-Flow.NoSql.Redis.csproj --configuration Release -o /tmp/pack-check +dotnet pack Vali-Flow.NoSql.DynamoDB/Vali-Flow.NoSql.DynamoDB.csproj --configuration Release -o /tmp/pack-check +dotnet pack Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj --configuration Release -o /tmp/pack-check +dotnet pack Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj --configuration Release -o /tmp/pack-check +dotnet pack Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj --configuration Release -o /tmp/pack-check +``` + +Expected `.nupkg` files: `Vali-Flow.NoSql.MongoDB.1.2.0.nupkg`, `Vali-Flow.NoSql.Elasticsearch.1.2.0.nupkg`, `Vali-Flow.NoSql.Redis.1.2.0.nupkg`, `Vali-Flow.NoSql.DynamoDB.1.2.0.nupkg`, `Vali-Flow.NoSql.Couchbase.1.1.0.nupkg`, `Vali-Flow.NoSql.CosmosDb.1.1.0.nupkg`, `Vali-Flow.NoSql.Firestore.1.1.0.nupkg`. + +Run: `dotnet nuget remove source local-verify-only` + +- [ ] **Step 9: Commit** + +```bash +git add Vali-Flow.NoSql.MongoDB/Vali-Flow.NoSql.MongoDB.csproj Vali-Flow.NoSql.Elasticsearch/Vali-Flow.NoSql.Elasticsearch.csproj Vali-Flow.NoSql.Redis/Vali-Flow.NoSql.Redis.csproj Vali-Flow.NoSql.DynamoDB/Vali-Flow.NoSql.DynamoDB.csproj Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj +git commit -m "chore(wave-3): bump versions and re-point Vali-Flow.NoSql to PackageReference 1.1.1 in the 7 NoSQL providers" +``` + +--- + +### Task 4: Create `.github/workflows/ci.yml` + +**Files:** +- Create: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: the 11 test project names and 12 package project names (all real paths in this repo, listed below). +- Produces: a `workflow_call`-able workflow with a `version-suffix` input, used by Task 5's `release.yml` as `uses: ./.github/workflows/ci.yml`. + +- [ ] **Step 1: Write `.github/workflows/ci.yml`** + +```yaml +name: CI + +# Pull requests, pushes to main and reuse from release.yml (workflow_call). Single required check +# for branch protection: "CI OK". +on: + pull_request: + push: + branches: [main] + workflow_call: + inputs: + version-suffix: + description: Prerelease label appended to every package version (for example rc.1). Empty packs the versions declared in the csproj files. + type: string + default: '' + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Restore + run: dotnet restore vali-flow.sln + + - name: Build + run: dotnet build vali-flow.sln -c Release --no-restore + + test: + name: test / ${{ matrix.os }} / ${{ matrix.tfm }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + tfm: [net8.0, net9.0] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Environment + shell: bash + run: | + mkdir -p logs + { + echo "### ${{ matrix.os }} / ${{ matrix.tfm }}" + uname -a || true + dotnet --info + } 2>&1 | tee logs/00-environment.log + + - name: Restore + shell: bash + run: | + set -o pipefail + dotnet restore vali-flow.sln 2>&1 | tee logs/01-restore.log + + - name: Build + shell: bash + run: | + set -o pipefail + dotnet build vali-flow.sln -c Release --no-restore -v:minimal -bl:logs/build.binlog 2>&1 | tee logs/02-build.log + + # The 11 test projects, one per package family (Vali-Flow.NoSql.Tests also covers the + # MongoDB translator — there is no separate Vali-Flow.NoSql.MongoDB.Tests project). + - name: Unit tests with coverage on ${{ matrix.tfm }} + shell: bash + run: | + set -o pipefail + status=0 + projects="Vali-Flow.Abstractions.Tests Vali-Flow.Tests Vali-Flow.InMemory.Tests Vali-Flow.Sql.Tests Vali-Flow.NoSql.Tests Vali-Flow.NoSql.Elasticsearch.Tests Vali-Flow.NoSql.Redis.Tests Vali-Flow.NoSql.DynamoDB.Tests Vali-Flow.NoSql.Couchbase.Tests Vali-Flow.NoSql.CosmosDb.Tests Vali-Flow.NoSql.Firestore.Tests" + for project in $projects; do + echo "::group::$project on ${{ matrix.tfm }}" + dotnet test "$project" -c Release --no-build -f "${{ matrix.tfm }}" \ + --logger "console;verbosity=detailed" \ + --logger "trx;LogFileName=$project-${{ matrix.tfm }}.trx" \ + --collect:"XPlat Code Coverage" --results-directory "TestResults/${{ matrix.tfm }}/$project" \ + 2>&1 | tee "logs/03-test-$project.log" || status=1 + echo "::endgroup::" + done + exit $status + + - name: Test totals + if: always() + shell: bash + run: | + python3 - <<'PY' + import glob + import os + import xml.etree.ElementTree as ET + + rows = ["| Project | Total | Passed | Failed | Skipped |", "|---|---:|---:|---:|---:|"] + for path in sorted(glob.glob("TestResults/**/*.trx", recursive=True)): + counters = ET.parse(path).getroot().find(".//{*}Counters") + if counters is None: + continue + name = os.path.basename(path)[:-4] + failed = int(counters.get("failed", 0)) + skipped = int(counters.get("total", 0)) - int(counters.get("executed", 0)) + rows.append(f"| {name} | {counters.get('total')} | {counters.get('passed')} | {failed}{' (!)' if failed else ''} | {skipped} |") + report = "### Tests ${{ matrix.os }} / ${{ matrix.tfm }}\n\n" + "\n".join(rows) + "\n" + print(report) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle: + handle.write(report + "\n") + PY + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.os }}-${{ matrix.tfm }} + path: TestResults/**/coverage.cobertura.xml + if-no-files-found: error + retention-days: 7 + + - name: Upload full trace + if: always() + uses: actions/upload-artifact@v4 + with: + name: trace-${{ matrix.os }}-${{ matrix.tfm }} + path: | + logs/** + TestResults/**/*.trx + if-no-files-found: warn + retention-days: 14 + + trace: + name: Full trace (single file) + needs: test + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + pattern: trace-* + path: trace + + - name: Merge every log into one file + shell: bash + run: | + out=full-trace.txt + : > "$out" + while IFS= read -r f; do + rel="${f#trace/}" + { + echo + echo "==================================================================" + echo "== ${rel%%/*} :: ${rel#*/}" + echo "==================================================================" + cat "$f" + } >> "$out" + done < <(find trace -name '*.log' | sort) + wc -l "$out" + { + echo "### Full trace" + echo + echo "Download the **full-trace** artifact of this run: one text file with the environment, restore, build and" + echo "detailed test output of every OS x target framework cell." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the single-file trace + uses: actions/upload-artifact@v4 + with: + name: full-trace + path: full-trace.txt + if-no-files-found: error + retention-days: 14 + + coverage: + # NOTE: 95% is a starting gate, not an audited baseline for the 10 packages not measured in the + # session that stood this pipeline up (Vali-Flow itself was: 97.42%). Adjust per-library after + # the first green run shows the real numbers. + name: Coverage gate (>= 95% per library) + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + pattern: coverage-* + path: coverage + + - name: Merge reports and enforce the gate + env: + THRESHOLD: '95' + run: | + python3 - <<'PY' + import glob + import os + import sys + import xml.etree.ElementTree as ET + + LIBS = [ + "Vali-Flow.Abstractions", "Vali-Flow", "Vali-Flow.InMemory", "Vali-Flow.Sql", + "Vali-Flow.NoSql", "Vali-Flow.NoSql.MongoDB", "Vali-Flow.NoSql.Elasticsearch", + "Vali-Flow.NoSql.Redis", "Vali-Flow.NoSql.DynamoDB", "Vali-Flow.NoSql.Couchbase", + "Vali-Flow.NoSql.CosmosDb", "Vali-Flow.NoSql.Firestore", + ] + THRESHOLD = float(os.environ["THRESHOLD"]) + OSES = ["ubuntu-latest", "windows-latest"] + TFMS = ["net8.0", "net9.0"] + + missing = [ + f"coverage-{os_name}-{tfm}" + for os_name in OSES + for tfm in TFMS + if not glob.glob(f"coverage/coverage-{os_name}-{tfm}/**/coverage.cobertura.xml", recursive=True) + ] + + lines = {lib: {} for lib in LIBS} + reports = glob.glob("coverage/**/coverage.cobertura.xml", recursive=True) + for path in reports: + for package in ET.parse(path).getroot().iter("package"): + store = lines.get(package.get("name")) + if store is None: + continue + for cls in package.iter("class"): + parts = cls.get("filename").replace("\\", "/").split("/") + file_key = "/".join(parts[-2:]) + for line in cls.iter("line"): + key = (file_key, int(line.get("number"))) + store[key] = max(store.get(key, 0), int(line.get("hits"))) + + rows = ["| Library | Lines | Covered | Line coverage | Gate |", "|---|---:|---:|---:|:---:|"] + failed = [] + for lib in LIBS: + total = len(lines[lib]) + covered = sum(1 for hits in lines[lib].values() if hits > 0) + pct = 100.0 * covered / total if total else 0.0 + ok = total > 0 and pct >= THRESHOLD + if not ok: + failed.append(lib) + rows.append(f"| {lib} | {total} | {covered} | {pct:.1f}% | {'pass' if ok else 'FAIL'} |") + + cells = ", ".join(f"{o} / {t}" for o in OSES for t in TFMS) + report = (f"### Unit-test line coverage (gate >= {THRESHOLD:g}%, {len(reports)} reports merged)\n\n" + + "\n".join(rows) + + f"\n\nExpected cells: {cells}\n" + + (f"\nMissing reports: {', '.join(missing)}\n" if missing else "\nAll 4 OS x TFM reports present.\n")) + print(report) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle: + handle.write(report + "\n") + if missing: + sys.exit("Coverage gate failed: missing reports for " + ", ".join(missing)) + if failed: + sys.exit("Coverage gate failed for: " + ", ".join(failed)) + PY + + pack: + name: Pack NuGet packages + needs: build + runs-on: ubuntu-latest + env: + VERSION_SUFFIX: ${{ inputs.version-suffix }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Apply the prerelease label + if: ${{ inputs.version-suffix != '' }} + run: | + if ! [[ "$VERSION_SUFFIX" =~ ^[0-9A-Za-z.-]+$ ]]; then + echo "::error::version-suffix may only contain letters, digits, dots and hyphens" + exit 1 + fi + cat > Directory.Build.targets <<'XML' + + + $(Version)-$(PrereleaseLabel) + + + XML + + - name: Pack every package in dependency order + run: | + for project in Vali-Flow.Abstractions Vali-Flow Vali-Flow.InMemory Vali-Flow.Sql Vali-Flow.NoSql Vali-Flow.NoSql.MongoDB Vali-Flow.NoSql.Elasticsearch Vali-Flow.NoSql.Redis Vali-Flow.NoSql.DynamoDB Vali-Flow.NoSql.Couchbase Vali-Flow.NoSql.CosmosDb Vali-Flow.NoSql.Firestore; do + dotnet pack "$project/$project.csproj" -c Release -o artifacts ${VERSION_SUFFIX:+-p:PrereleaseLabel="$VERSION_SUFFIX"} + done + + - name: Verify package contents + run: | + python3 - <<'PY' + import glob + import sys + import xml.etree.ElementTree as ET + import zipfile + + PACKAGES = { + "Vali-Flow.Abstractions", "Vali-Flow", "Vali-Flow.InMemory", "Vali-Flow.Sql", + "Vali-Flow.NoSql", "Vali-Flow.NoSql.MongoDB", "Vali-Flow.NoSql.Elasticsearch", + "Vali-Flow.NoSql.Redis", "Vali-Flow.NoSql.DynamoDB", "Vali-Flow.NoSql.Couchbase", + "Vali-Flow.NoSql.CosmosDb", "Vali-Flow.NoSql.Firestore", + } + TFMS = {"net8.0", "net9.0"} + + errors = [] + found = {} + for path in sorted(glob.glob("artifacts/*.nupkg")): + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + root = ET.fromstring(archive.read(next(n for n in names if n.endswith(".nuspec")))) + ns = {"n": root.tag.split("}")[0].strip("{")} + pid = root.find("n:metadata/n:id", ns).text + version = root.find("n:metadata/n:version", ns).text + libs = {n.split("/")[1] for n in names if n.startswith("lib/") and n.endswith(".dll")} + found[pid] = (version, libs) + + for pid in sorted(PACKAGES - set(found)): + errors.append(f"{pid}: package missing from artifacts/") + for pid, (version, libs) in sorted(found.items()): + if libs != TFMS: + errors.append(f"{pid} {version}: lib/ has {sorted(libs)}, expected {sorted(TFMS)}") + print(f"{pid} {version}: lib {sorted(libs)}") + if errors: + sys.exit("Package check failed:\n " + "\n ".join(errors)) + print("Package check passed.") + PY + + - name: Upload packages + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: artifacts/* + if-no-files-found: error + retention-days: 14 + + ci-ok: + name: CI OK + if: always() + needs: [build, test, coverage, pack] + runs-on: ubuntu-latest + steps: + - name: Every job must have succeeded + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + if echo "$RESULTS" | grep -Eq '"(failure|cancelled|skipped)"'; then + exit 1 + fi +``` + +- [ ] **Step 2: Validate the YAML is syntactically well-formed** + +Run: `python3 -c "import yaml, sys; yaml.safe_load(open('.github/workflows/ci.yml'))" 2>&1 || python3 -c "import json; print('yaml module unavailable, skipping — will be validated by GitHub on first push')"` +Expected: no exception printed (either the yaml module parses it cleanly, or the fallback message prints — either way, no traceback). + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add reusable CI workflow (build, test matrix, 95% coverage gate, pack)" +``` + +--- + +### Task 5: Create `.github/workflows/release.yml` + +**Files:** +- Create: `.github/workflows/release.yml` + +**Interfaces:** +- Consumes: `.github/workflows/ci.yml` (Task 4) via `uses: ./.github/workflows/ci.yml`. +- Produces: the manually-triggered release workflow. Nothing downstream in this plan depends on it — this is the final deliverable. + +- [ ] **Step 1: Write `.github/workflows/release.yml`** + +```yaml +name: Release + +# Manual only. By default it runs the full CI (net8.0/net9.0 on Linux and Windows) and uploads the +# packed NuGet packages as a workflow artifact; it publishes NOTHING. Publishing needs BOTH +# `publish: true` and an approval of the `nuget` environment (Settings > Environments > nuget > +# Required reviewers), and uses the NUGET_API_KEY secret of that environment. +# +# Publication order — 3 waves, because this repo has a 2-level dependency chain on top of the core: +# Wave 1: Vali-Flow.Abstractions alone. +# Wave 2: Vali-Flow, Vali-Flow.InMemory, Vali-Flow.Sql, Vali-Flow.NoSql (all depend on Abstractions). +# Wave 3: the 7 NoSQL provider packages (all depend on Vali-Flow.NoSql from wave 2). +# Each wave waits for NuGet.org to index the package the next wave needs before proceeding. +on: + workflow_dispatch: + inputs: + version-suffix: + description: Prerelease label appended to every package version, for example rc.1 (empty = versions from the csproj files) + type: string + required: false + default: '' + publish: + description: Publish the packages to NuGet.org after the approval of the nuget environment + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + ci: + name: CI (build, test on net8.0/net9.0, coverage gate, pack) + uses: ./.github/workflows/ci.yml + with: + version-suffix: ${{ inputs.version-suffix }} + + publish: + name: Publish to NuGet (needs approval) + needs: ci + if: ${{ inputs.publish }} + runs-on: ubuntu-latest + environment: nuget + env: + NUGET_SOURCE: https://api.nuget.org/v3/index.json + steps: + - uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: artifacts + + - name: Every package must contain net8.0 and net9.0 + run: | + for package in artifacts/*.nupkg; do + for tfm in net8.0 net9.0; do + unzip -l "$package" | grep -q "lib/$tfm/" || { echo "::error::$package has no lib/$tfm"; exit 1; } + done + done + + - name: Check NUGET_API_KEY is configured + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::error::NUGET_API_KEY is not set in the nuget environment" + exit 1 + fi + + # ---- Wave 1: Vali-Flow.Abstractions alone ---- + - name: 'Wave 1: push Vali-Flow.Abstractions' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + # Vali-Flow.Abstractions is the only package whose id is followed by a version digit + # among files matching "Vali-Flow.Abstractions.*" (there is no other package with that + # prefix), so a plain glob is unambiguous here. + dotnet nuget push artifacts/Vali-Flow.Abstractions.[0-9]*.nupkg --source "$NUGET_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate + + - name: 'Wave 1: wait until NuGet.org lists Vali-Flow.Abstractions' + run: | + pkg=$(basename artifacts/Vali-Flow.Abstractions.[0-9]*.nupkg .nupkg) + version=$(echo "${pkg#Vali-Flow.Abstractions.}" | tr '[:upper:]' '[:lower:]') + for attempt in $(seq 1 40); do + if curl -fsS "https://api.nuget.org/v3-flatcontainer/vali-flow.abstractions/index.json" | grep -q "\"$version\""; then + echo "Vali-Flow.Abstractions $version is indexed" + exit 0 + fi + echo "attempt $attempt/40: Vali-Flow.Abstractions $version not indexed yet" + sleep 30 + done + echo "::error::Vali-Flow.Abstractions $version was not indexed in time; re-run this job (--skip-duplicate makes it safe)" + exit 1 + + # ---- Wave 2: Vali-Flow, Vali-Flow.InMemory, Vali-Flow.Sql, Vali-Flow.NoSql ---- + - name: 'Wave 2: push Vali-Flow, Vali-Flow.InMemory, Vali-Flow.Sql, Vali-Flow.NoSql' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + # Vali-Flow.[0-9]* matches only the main package (not Vali-Flow.InMemory.*, Vali-Flow.Sql.*, + # or Vali-Flow.NoSql.* — those have a literal "." before a letter, not a digit). + for pattern in 'Vali-Flow.[0-9]*' 'Vali-Flow.InMemory.[0-9]*' 'Vali-Flow.Sql.[0-9]*' 'Vali-Flow.NoSql.[0-9]*'; do + dotnet nuget push "artifacts/$pattern.nupkg" --source "$NUGET_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate + done + + - name: 'Wave 2: wait until NuGet.org lists Vali-Flow.NoSql' + run: | + # Only Vali-Flow.NoSql gates wave 3 — the 7 providers depend on it, not on Vali-Flow/InMemory/Sql. + pkg=$(basename artifacts/Vali-Flow.NoSql.[0-9]*.nupkg .nupkg) + version=$(echo "${pkg#Vali-Flow.NoSql.}" | tr '[:upper:]' '[:lower:]') + for attempt in $(seq 1 40); do + if curl -fsS "https://api.nuget.org/v3-flatcontainer/vali-flow.nosql/index.json" | grep -q "\"$version\""; then + echo "Vali-Flow.NoSql $version is indexed" + exit 0 + fi + echo "attempt $attempt/40: Vali-Flow.NoSql $version not indexed yet" + sleep 30 + done + echo "::error::Vali-Flow.NoSql $version was not indexed in time; re-run this job (--skip-duplicate makes it safe)" + exit 1 + + # ---- Wave 3: the 7 NoSQL provider packages ---- + - name: 'Wave 3: push the 7 NoSQL provider packages' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + for package in artifacts/Vali-Flow.NoSql.*.nupkg; do + case "$(basename "$package")" in + Vali-Flow.NoSql.[0-9]*) continue ;; # already pushed in wave 2 + esac + dotnet nuget push "$package" --source "$NUGET_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate + done +``` + +- [ ] **Step 2: Validate the YAML is syntactically well-formed** + +Run: `python3 -c "import yaml, sys; yaml.safe_load(open('.github/workflows/release.yml'))" 2>&1 || python3 -c "import json; print('yaml module unavailable, skipping — will be validated by GitHub on first push')"` +Expected: no traceback. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: add manual release workflow (3-wave dependency-ordered NuGet publish)" +``` + +--- + +### Task 6: Full local dry-run validation + +**Files:** none created or modified — this task only runs commands. + +**Interfaces:** +- Consumes: the final state of all 12 `.csproj` (Tasks 1-3) and both workflow files (Tasks 4-5). +- Produces: a go/no-go signal for whether this repo is actually ready for a real `workflow_dispatch` run. This is the task the plan's "Goal" and "Testing" section in the spec point to. + +- [ ] **Step 1: Full solution build from a clean restore** + +Run: `dotnet restore vali-flow.sln && dotnet build vali-flow.sln --configuration Release --no-restore` +Expected: `0 Errores` / `0 Error(s)`. If this fails with a missing-package error for `Vali-Flow.Abstractions` 1.2.0 or `Vali-Flow.NoSql` 1.1.1, a reference from Tasks 2/3 was left on the old `ProjectReference` or missed the version bump — go back and check `git diff` against Tasks 2/3's exact replacements. + +- [ ] **Step 2: Full test suite** + +Run: `dotnet test vali-flow.sln --configuration Release --no-build` +Expected: every one of the 11 test projects reports `Correctas!` / 0 failures (the exact counts don't matter here — this plan didn't change any production logic, only package metadata and dependency wiring — but a sudden failure means a reference edit broke something and must be investigated before continuing). + +- [ ] **Step 3: Pack all 12 packages at their final bumped versions, with no local-source workaround** + +This step is the real end-to-end check: if Tasks 1-3 are fully consistent, every package now resolves its sibling dependency as a normal transitive `PackageReference`-of-a-`PackageReference` chain once wave 1/2 packages are copied into a local feed — simulate that without ever touching the real NuGet.org: + +```bash +mkdir -p /tmp/local-feed +for project in Vali-Flow.Abstractions Vali-Flow Vali-Flow.InMemory Vali-Flow.Sql Vali-Flow.NoSql Vali-Flow.NoSql.MongoDB Vali-Flow.NoSql.Elasticsearch Vali-Flow.NoSql.Redis Vali-Flow.NoSql.DynamoDB Vali-Flow.NoSql.Couchbase Vali-Flow.NoSql.CosmosDb Vali-Flow.NoSql.Firestore; do + dotnet pack "$project/$project.csproj" --configuration Release -o /tmp/local-feed +done +ls /tmp/local-feed/*.nupkg | wc -l +``` + +Expected: `24` lines from the `ls | wc -l` (12 packages × `.nupkg` + `.snupkg` each — if the count is `12`, symbol packages aren't being produced, which is a packaging regression worth flagging before moving on, not silently accepted). + +Expected version list — run `ls /tmp/local-feed/*.nupkg | sort` and confirm it matches exactly: + +``` +Vali-Flow.1.4.0.nupkg +Vali-Flow.Abstractions.1.2.0.nupkg +Vali-Flow.InMemory.1.2.0.nupkg +Vali-Flow.NoSql.1.1.1.nupkg +Vali-Flow.NoSql.Couchbase.1.1.0.nupkg +Vali-Flow.NoSql.CosmosDb.1.1.0.nupkg +Vali-Flow.NoSql.DynamoDB.1.2.0.nupkg +Vali-Flow.NoSql.Elasticsearch.1.2.0.nupkg +Vali-Flow.NoSql.Firestore.1.1.0.nupkg +Vali-Flow.NoSql.MongoDB.1.2.0.nupkg +Vali-Flow.NoSql.Redis.1.2.0.nupkg +Vali-Flow.Sql.1.2.0.nupkg +``` + +- [ ] **Step 4: Confirm no stray local NuGet source was left behind on this machine** + +Run: `dotnet nuget list source` +Expected: no entry named `local-verify-only` (added and removed in Tasks 2 and 3 — if it's still listed, remove it now with `dotnet nuget remove source local-verify-only`, since leaving a `/tmp` path as a permanent NuGet source would silently break restores on this machine after `/tmp` is cleared). + +- [ ] **Step 5: Report the dry-run result** + +No commit for this task (nothing was created or modified). Summarize in the chat: build result, test result, the 12-package version/pack confirmation, and that `.github/workflows/{ci.yml,release.yml}` exist and are ready for the user to exercise with a real `workflow_dispatch` (`publish: false` first, to validate the GitHub-hosted runners end-to-end before ever approving a real publish) once the `nuget` GitHub environment prerequisite is in place. From 82a3480e5b02f734245fcaa74206fdc3fcc8030a Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 13:18:59 -0500 Subject: [PATCH 25/30] chore(wave-3): bump versions and re-point Vali-Flow.NoSql to PackageReference 1.1.1 in the 7 NoSQL providers --- Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj | 6 +++--- Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj | 6 +++--- Vali-Flow.NoSql.DynamoDB/Vali-Flow.NoSql.DynamoDB.csproj | 6 +++--- .../Vali-Flow.NoSql.Elasticsearch.csproj | 6 +++--- Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj | 6 +++--- Vali-Flow.NoSql.MongoDB/Vali-Flow.NoSql.MongoDB.csproj | 6 +++--- Vali-Flow.NoSql.Redis/Vali-Flow.NoSql.Redis.csproj | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj b/Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj index 4fba483..94cd3a9 100644 --- a/Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj +++ b/Vali-Flow.NoSql.CosmosDb/Vali-Flow.NoSql.CosmosDb.csproj @@ -6,7 +6,7 @@ enable Vali_Flow.NoSql.CosmosDb Vali-Flow.NoSql.CosmosDb - 1.0.0 + 1.1.0 Felipe Rafael Montenegro Morriberon Vali-Flow.NoSql.CosmosDb Azure Cosmos DB (SQL API) adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into a parameterized Cosmos SQL WHERE clause fragment. Use .ToCosmosDb() on any ValiFlow<T> or Expression<Func<T, bool>>. @@ -19,7 +19,7 @@ VF_logo.png README.md vali;vali-flow;cosmosdb;azure;sql-api;filter;nosql;dotnet - v1.0.0 — Initial release. Cosmos DB SQL API WHERE clause translator. + v1.1.0 — Add a MaxInValues cap. Instrument Translate(...) with ValiFlowDiagnostics Activity tracing. No breaking changes. true true true @@ -41,7 +41,7 @@ - + diff --git a/Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj b/Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj index 197c3d0..2d96429 100644 --- a/Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj +++ b/Vali-Flow.NoSql.Couchbase/Vali-Flow.NoSql.Couchbase.csproj @@ -6,7 +6,7 @@ enable Vali_Flow.NoSql.Couchbase Vali-Flow.NoSql.Couchbase - 1.0.0 + 1.1.0 Felipe Rafael Montenegro Morriberon Vali-Flow.NoSql.Couchbase Couchbase adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into a parameterized N1QL (SQL++) WHERE clause fragment. Use .ToCouchbase() on any ValiFlow<T> or Expression<Func<T, bool>>. @@ -19,7 +19,7 @@ VF_logo.png README.md vali;vali-flow;couchbase;n1ql;sqlpp;filter;nosql;dotnet - v1.0.0 — Initial release. N1QL (SQL++) WHERE clause translator. + v1.1.0 — Fix: decimal values were bound as N1QL string parameters instead of native numbers, making every comparison operator (=, <>, >, >=, <, <=) against a decimal field silently match zero rows (N1QL does not coerce between string and number). Add a MaxInValues cap. Instrument Translate(...) with ValiFlowDiagnostics Activity tracing. No breaking changes. true true true @@ -41,7 +41,7 @@ - + diff --git a/Vali-Flow.NoSql.DynamoDB/Vali-Flow.NoSql.DynamoDB.csproj b/Vali-Flow.NoSql.DynamoDB/Vali-Flow.NoSql.DynamoDB.csproj index 1c29d69..d0959b4 100644 --- a/Vali-Flow.NoSql.DynamoDB/Vali-Flow.NoSql.DynamoDB.csproj +++ b/Vali-Flow.NoSql.DynamoDB/Vali-Flow.NoSql.DynamoDB.csproj @@ -6,7 +6,7 @@ enable Vali_Flow.NoSql.DynamoDB Vali-Flow.NoSql.DynamoDB - 1.1.0 + 1.2.0 Felipe Rafael Montenegro Morriberon Vali-Flow.NoSql.DynamoDB AWS DynamoDB adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into DynamoDB FilterExpression strings with ExpressionAttributeNames and ExpressionAttributeValues. Use .ToDynamoDB() on any ValiFlow<T> or Expression<Func<T, bool>>. @@ -19,7 +19,7 @@ VF_logo.png README.md vali;vali-flow;dynamodb;aws;filter;nosql;dotnet - v1.1.0 — Enhanced documentation and adapter integration pattern alignment. + v1.2.0 — Add a MaxInValues cap. Instrument Translate(...) with ValiFlowDiagnostics Activity tracing (tag + entityType). No breaking changes. true true true @@ -41,8 +41,8 @@ - + diff --git a/Vali-Flow.NoSql.Elasticsearch/Vali-Flow.NoSql.Elasticsearch.csproj b/Vali-Flow.NoSql.Elasticsearch/Vali-Flow.NoSql.Elasticsearch.csproj index a49b914..ed1dbcf 100644 --- a/Vali-Flow.NoSql.Elasticsearch/Vali-Flow.NoSql.Elasticsearch.csproj +++ b/Vali-Flow.NoSql.Elasticsearch/Vali-Flow.NoSql.Elasticsearch.csproj @@ -6,7 +6,7 @@ enable Vali_Flow.NoSql.Elasticsearch Vali-Flow.NoSql.Elasticsearch - 1.1.0 + 1.2.0 Felipe Rafael Montenegro Morriberon Vali-Flow.NoSql.Elasticsearch Elasticsearch adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into Elastic.Clients.Elasticsearch Query objects. Use .ToElasticsearch() on any ValiFlow<T> or Expression<Func<T, bool>>. @@ -24,7 +24,7 @@ embedded true snupkg - v1.1.0 — Enhanced documentation and adapter integration pattern alignment. + v1.2.0 — Fix a swallowed inner exception in ToDouble (now wrapped with context instead of lost). Add a MaxInValues cap (65,536, matching index.max_terms_count). Instrument Translate(...) with ValiFlowDiagnostics Activity tracing. No breaking changes. true @@ -41,8 +41,8 @@ - + diff --git a/Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj b/Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj index 765409f..459237b 100644 --- a/Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj +++ b/Vali-Flow.NoSql.Firestore/Vali-Flow.NoSql.Firestore.csproj @@ -6,7 +6,7 @@ enable Vali_Flow.NoSql.Firestore Vali-Flow.NoSql.Firestore - 1.0.0 + 1.1.0 Felipe Rafael Montenegro Morriberon Vali-Flow.NoSql.Firestore Google Cloud Firestore adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into a native Google.Cloud.Firestore.Filter. Use .ToFirestore() on any ValiFlow<T> or Expression<Func<T, bool>>. @@ -19,7 +19,7 @@ VF_logo.png README.md vali;vali-flow;firestore;google-cloud;filter;nosql;dotnet - v1.0.0 — Initial release. Firestore native Filter translator. + v1.1.0 — Instrument Translate(...) with ValiFlowDiagnostics Activity tracing (tag + entityType). No breaking changes. true true true @@ -42,7 +42,7 @@ - + diff --git a/Vali-Flow.NoSql.MongoDB/Vali-Flow.NoSql.MongoDB.csproj b/Vali-Flow.NoSql.MongoDB/Vali-Flow.NoSql.MongoDB.csproj index ec8a844..9258ac7 100644 --- a/Vali-Flow.NoSql.MongoDB/Vali-Flow.NoSql.MongoDB.csproj +++ b/Vali-Flow.NoSql.MongoDB/Vali-Flow.NoSql.MongoDB.csproj @@ -6,7 +6,7 @@ enable Vali_Flow.NoSql.MongoDB Vali-Flow.NoSql.MongoDB - 1.1.0 + 1.2.0 Felipe Rafael Montenegro Morriberon Vali-Flow.NoSql.MongoDB MongoDB adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into BsonDocument filters using the official MongoDB.Bson library. Use .ToMongo() on any ValiFlow<T> or Expression<Func<T, bool>>. @@ -18,7 +18,7 @@ git VF_logo.png README.md - v1.1.0 — Enhanced documentation and adapter integration pattern alignment. + v1.2.0 — Add a MaxInValues cap (10,000) to InNode translation, a conservative estimate to avoid unbounded $in clauses. Instrument Translate(...) with ValiFlowDiagnostics Activity tracing (tag + entityType). No breaking changes. vali;vali-flow;mongodb;bson;filter;nosql;dotnet true true @@ -41,8 +41,8 @@ - + diff --git a/Vali-Flow.NoSql.Redis/Vali-Flow.NoSql.Redis.csproj b/Vali-Flow.NoSql.Redis/Vali-Flow.NoSql.Redis.csproj index 7dbc1a4..c723110 100644 --- a/Vali-Flow.NoSql.Redis/Vali-Flow.NoSql.Redis.csproj +++ b/Vali-Flow.NoSql.Redis/Vali-Flow.NoSql.Redis.csproj @@ -6,7 +6,7 @@ enable Vali_Flow.NoSql.Redis Vali-Flow.NoSql.Redis - 1.1.0 + 1.2.0 Felipe Rafael Montenegro Morriberon Vali-Flow.NoSql.Redis Redis RediSearch adapter for Vali-Flow.NoSql — translates the provider-agnostic IR tree into RediSearch query strings compatible with NRedisStack (DIALECT 2). Use .ToRedisSearch() on any ValiFlow<T> or Expression<Func<T, bool>>. @@ -19,7 +19,7 @@ VF_logo.png README.md vali;vali-flow;redis;redisearch;query;filter;nosql;dotnet - v1.1.0 — Enhanced documentation and adapter integration pattern alignment. + v1.2.0 — Security fix: escape RediSearch query-syntax special characters in LIKE patterns before adding wildcards, closing a query-injection vector (e.g. a pattern containing ')' or '|' could previously break out of the field's scope). Fix customConverter not being applied in VisitComparison/numeric VisitIn. Add a MaxInValues cap (1,000). Instrument Translate(...) with ValiFlowDiagnostics Activity tracing. No breaking changes. true true true @@ -41,8 +41,8 @@ - + From 66b0f3c14168197bc01a9b5dbc018c8a1e1af321 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 13:24:20 -0500 Subject: [PATCH 26/30] fix(packaging): add missing VF_logo.png to Couchbase/CosmosDb/Firestore These 3 providers were scaffolded without the package icon file this session, causing dotnet pack to fail with NU5046 (PackageIcon set in csproj but file absent). Copied from Vali-Flow.NoSql, which ships the same icon. --- Vali-Flow.NoSql.CosmosDb/VF_logo.png | Bin 0 -> 159352 bytes Vali-Flow.NoSql.Couchbase/VF_logo.png | Bin 0 -> 159352 bytes Vali-Flow.NoSql.Firestore/VF_logo.png | Bin 0 -> 159352 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Vali-Flow.NoSql.CosmosDb/VF_logo.png create mode 100644 Vali-Flow.NoSql.Couchbase/VF_logo.png create mode 100644 Vali-Flow.NoSql.Firestore/VF_logo.png diff --git a/Vali-Flow.NoSql.CosmosDb/VF_logo.png b/Vali-Flow.NoSql.CosmosDb/VF_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1e093e9c4c0135787e2036c50023ca3f2235e965 GIT binary patch literal 159352 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

s+0X#xb~Wj~ylyecD2Qu1?;SVV!0) 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 literal 0 HcmV?d00001 diff --git a/Vali-Flow.NoSql.Couchbase/VF_logo.png b/Vali-Flow.NoSql.Couchbase/VF_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1e093e9c4c0135787e2036c50023ca3f2235e965 GIT binary patch literal 159352 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

s+0X#xb~Wj~ylyecD2Qu1?;SVV!0) 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 literal 0 HcmV?d00001 diff --git a/Vali-Flow.NoSql.Firestore/VF_logo.png b/Vali-Flow.NoSql.Firestore/VF_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1e093e9c4c0135787e2036c50023ca3f2235e965 GIT binary patch literal 159352 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

s+0X#xb~Wj~ylyecD2Qu1?;SVV!0) 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 literal 0 HcmV?d00001 From 0736c8d9376d0b3a1d128064d8663e1fb3381fed Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 04:50:19 -0500 Subject: [PATCH 27/30] docs: catch up CHANGELOG.md for Oct 2-6 work (3 new NoSQL providers, GenericRepository, diagnostics, security fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Root CHANGELOG.md [Unreleased]: documents everything shipped since the last entry (1.3.3, April) that was never logged — 3 new NoSQL translator packages (Couchbase, CosmosDb, Firestore), GenericRepository, ValiFlowDiagnostics observability helper, the SQL identifier injection fix, the RediSearch query injection fix, Mongo/Elasticsearch IN-value caps, the bulk upsert retry policy (and its budget tuning 4->7->12), and several smaller bug fixes across Sql/NoSql/InMemory/EF Core. - Created CHANGELOG.md for the 3 new packages (Couchbase/CosmosDb/Firestore had none), following the existing per-package format. - Fixed Vali-Flow.NoSql.MongoDB/CHANGELOG.md: described a stale API (ToMongoFilter() -> FilterDefinition) that doesn't match the real current one (ToMongo() -> BsonDocument, verified against docs/en/vali-flow-nosql-mongodb.md and the actual extension method source) — found independently by 3 separate doc-porting passes while researching the new providers. --- CHANGELOG.md | 29 ++++++++++++++++++++++++++ Vali-Flow.NoSql.CosmosDb/CHANGELOG.md | 19 +++++++++++++++++ Vali-Flow.NoSql.Couchbase/CHANGELOG.md | 19 +++++++++++++++++ Vali-Flow.NoSql.Firestore/CHANGELOG.md | 24 +++++++++++++++++++++ Vali-Flow.NoSql.MongoDB/CHANGELOG.md | 10 ++++++--- 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 Vali-Flow.NoSql.CosmosDb/CHANGELOG.md create mode 100644 Vali-Flow.NoSql.Couchbase/CHANGELOG.md create mode 100644 Vali-Flow.NoSql.Firestore/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a15f550..08584d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and ## [Unreleased] +### Added + +- **`Vali-Flow.NoSql.Couchbase`, `Vali-Flow.NoSql.CosmosDb`, `Vali-Flow.NoSql.Firestore`** — three new NoSQL translator packages, same pattern as the existing MongoDB/DynamoDB/Elasticsearch/Redis adapters (pure `ValiFlow` expression tree → native query/filter translators, no connection or execution concerns). +- **`GenericRepository`** (`Vali-Flow`) — a thin repository wrapping `ValiFlowEvaluator` with `GetById`/`GetAll`/`GetPaged`/`Add`/`Update`/`Delete`/`SaveChanges`, for consumers who want a conventional repository surface instead of calling the evaluator directly. +- **`ValiFlowDiagnostics`** (`Vali-Flow.Abstractions`) — shared `ActivitySource`-based `StartActivity`/`RecordException` helper for OpenTelemetry-compatible tracing, now wired through the EF Core evaluator, `Vali-Flow.InMemory`, `Vali-Flow.Sql`'s six builders, and the Mongo/DynamoDB/Elasticsearch/Redis translators. +- EF Core benchmark using the `EFCore.InMemory` provider (`Vali-Flow.Benchmarks`). +- `SqlInsertBuilder`/`SqlUpdateBuilder.SetAllFrom(entity, exclude...)` — reflection-based bulk column mapper. + +### Fixed + +- **Security — SQL identifier injection**: `Vali-Flow.Sql`'s builders interpolated table/schema/type names directly; added `SqlIdentifierGuard` (regex whitelist) validating all identifiers before interpolation. +- **Security — RediSearch query injection**: `Vali-Flow.NoSql.Redis` didn't escape RediSearch query-syntax special characters (e.g. `)`/`|`) in `LIKE` patterns before wrapping them in wildcards, allowing query-structure injection. Now escaped before wildcarding. +- **`Vali-Flow.NoSql` (Mongo/Elasticsearch)**: closure-captured `null` values in a `ValiFlow` condition weren't translated to the same `NullNode` as a literal `null`, producing inconsistent filters between `.EqualTo(x => x.Prop, someNullVariable)` and `.EqualTo(x => x.Prop, null)`. +- **`Vali-Flow.NoSql.Redis`**: `customConverter` wasn't applied in `VisitComparison`/numeric `VisitIn`, silently ignoring custom type mappings for comparison and `IN` conditions. +- **`Vali-Flow.NoSql.Elasticsearch`**: swallowed inner exception in `ToDouble` conversion — failures surfaced as a generic error with no root cause. +- **`Vali-Flow.NoSql` (Mongo/Elasticsearch)**: unbounded `IN` value lists could exceed the engine's own limits; added `MaxInValues` caps (Mongo 10,000; Elasticsearch 65,536, matching `index.max_terms_count`). +- **`Vali-Flow.NoSql.Couchbase`**: `decimal` values were bound as N1QL string literals instead of native numbers. +- **`Vali-Flow.Sql`**: `OrIgnore`/`OrReplace` dialect guard bug, `Set()`-after-`SelectFrom()` silent data loss, `Where()` double-call silently overwriting the first condition instead of combining — all fixed. Added `AllowDeleteUnmatched()` guard required before `WhenNotMatchedBySourceDelete` in `SqlMergeBuilder` (prevents accidental unguarded deletes in a MERGE). +- **`Vali-Flow` (EF Core evaluator)**: `UpsertRangeAsync` dropped entries on duplicate keys (list accumulator instead of dictionary) — fixed to a dictionary accumulator. `UpsertRangeAsync.PartitionUpsertRange` could discard a tracked entity's primary key — now preserved. `BulkInsertOrUpdateAsync` could race under concurrent calls for the same entity type + key columns — calls are now serialized per that combination. +- **`Vali-Flow` (EF Core evaluator) — bulk upsert contention under concurrency**: replaced the ad-hoc retry with a generic transient-retry policy (`BulkUpsertRetryPolicy`) covering SQL Server, PostgreSQL, MySQL, and SQLite, gated per table (not per `UpdateByProperties` key set) to avoid serializing unrelated upserts against each other. Retry budget raised in two steps (4 → 7 → 12 attempts) based on observed contention under load. +- **`Vali-Flow.InMemory`**: `Update(entity, externalList)` could leak the updated entity into the internal store on a later bare `SaveChanges()` call. + +### Changed + +- **`Vali-Flow.InMemory`**: `ValiFlowEvaluator` (904 lines) split into `Bridge`/`Read`/`Write`/`Grouped` partial files; no public API change. +- **`Vali-Flow` (EF Core evaluator)**: query-construction logic extracted from `ValiFlowEvaluator` into a dedicated `QuerySpecificationBuilder` (single-responsibility split, no public API change). +- EF Core evaluator test coverage raised from 74.37% to 97.42%. +- Eliminated flaky `Activity`-capture tests across the `Sql`/EF-Core/`InMemory` test assemblies (timing-dependent assertions replaced with deterministic capture). + --- ## Vali-Flow — 1.3.3 diff --git a/Vali-Flow.NoSql.CosmosDb/CHANGELOG.md b/Vali-Flow.NoSql.CosmosDb/CHANGELOG.md new file mode 100644 index 0000000..55e3a7d --- /dev/null +++ b/Vali-Flow.NoSql.CosmosDb/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog — Vali-Flow.NoSql.CosmosDb + +All notable changes to this package are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning: [SemVer](https://semver.org/) + +--- + +## [Unreleased] + +--- + +## [1.0.0] — Initial release + +### Added +- `CosmosFilterExpression` — translates Vali-Flow IR nodes into an Azure Cosmos DB SQL API WHERE clause fragment plus a parameter dictionary, with no dependency on the `Microsoft.Azure.Cosmos` SDK +- Support for: equality, comparison, AND/OR/NOT, IN list, CONTAINS/STARTSWITH/ENDSWITH (native Cosmos SQL functions), IS_NULL/NOT IS_NULL +- `ValiFlowCosmosDbExtensions.ToCosmosDb()` — extension methods on `ValiFlow` and `Expression>` +- Custom value converter hook for domain types not covered by the built-in CLR type mapping +- XML documentation on all public types and members diff --git a/Vali-Flow.NoSql.Couchbase/CHANGELOG.md b/Vali-Flow.NoSql.Couchbase/CHANGELOG.md new file mode 100644 index 0000000..1d1d90e --- /dev/null +++ b/Vali-Flow.NoSql.Couchbase/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog — Vali-Flow.NoSql.Couchbase + +All notable changes to this package are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning: [SemVer](https://semver.org/) + +--- + +## [Unreleased] + +--- + +## [1.0.0] — Initial release + +### Added +- `CouchbaseFilterExpression` — sealed class exposing `WhereClause` (string, no leading `WHERE`) and `Parameters` (`IReadOnlyDictionary`, named `$p0`, `$p1`, …) +- `ValiFlowCouchbaseExtensions.ToCouchbase()` — extension method on `ValiFlow` and on a pre-built `Expression>` +- Support for: equality, comparison (`GreaterThan`/`GreaterThanOrEqualTo`/`LessThan`/`LessThanOrEqualTo`), AND/OR/NOT, IN (bound as a single array parameter), LIKE (`Contains`/`StartsWith`/`EndsWith`), null checks +- `customConverter` hook for mapping CLR types not handled by the built-in value-resolution switch +- No dependency on the Couchbase .NET SDK — pure N1QL text/parameter builder, no connection or execution concerns diff --git a/Vali-Flow.NoSql.Firestore/CHANGELOG.md b/Vali-Flow.NoSql.Firestore/CHANGELOG.md new file mode 100644 index 0000000..7402c45 --- /dev/null +++ b/Vali-Flow.NoSql.Firestore/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog — Vali-Flow.NoSql.Firestore + +All notable changes to this package are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning: [SemVer](https://semver.org/) + +--- + +## [Unreleased] + +--- + +## [1.0.0] — Initial release + +### Added +- `FirestoreFilterTranslator` — translates Vali-Flow IR nodes into a native `Google.Cloud.Firestore.Filter` (Google.Cloud.Firestore 4.4.0) +- `ValiFlowFirestoreExtensions.ToFirestore()` — extension methods on `ValiFlow` and on `Expression>` +- Support for: equality (`EqualTo`/`NotEqualTo`), comparison (`GreaterThan`/`GreaterThanOrEqualTo`/`LessThan`/`LessThanOrEqualTo`), `In`, null checks, `And`/`Or` +- Built-in CLR type mapping (`Enum` → `int`, `DateTime`/`DateTimeOffset` → `Timestamp`, pass-through for primitives) with `customConverter` override hook +- XML documentation on all public types and members + +### Known limitations +- `Contains`/`StartsWith`/`EndsWith` throw `NotSupportedException` — Firestore has no pattern-matching query operator +- `.Not(inner)` throws `NotSupportedException` — the SDK exposes no generic filter negation +- Negated `.In(...)` (`Filter.NotInArray`) not yet reachable — `InNode` has no `IsNegated` flag diff --git a/Vali-Flow.NoSql.MongoDB/CHANGELOG.md b/Vali-Flow.NoSql.MongoDB/CHANGELOG.md index df0ae53..b1e99e4 100644 --- a/Vali-Flow.NoSql.MongoDB/CHANGELOG.md +++ b/Vali-Flow.NoSql.MongoDB/CHANGELOG.md @@ -12,7 +12,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning: ## [1.0.0] — Initial release ### Added -- `MongoFilterTranslator` — translates Vali-Flow IR nodes into MongoDB `FilterDefinition` (MongoDB.Driver) -- Support for: equality, comparison, AND/OR/NOT, IN list, LIKE (regex-based), null checks -- `ValiFlowMongoExtensions.ToMongoFilter()` — extension method on `ValiFlow` +- `MongoFilterTranslator` — translates Vali-Flow IR nodes into a MongoDB `BsonDocument` filter (depends only on `MongoDB.Bson`, not the full `MongoDB.Driver`) +- `ValiFlowMongoExtensions.ToMongo()` — extension method on `ValiFlow` and on `Expression>`, with an optional `customConverter` hook for CLR types not handled by the built-in switch +- Support for: equality/inequality, comparison (GT/GTE/LT/LTE), Contains/StartsWith/EndsWith (case-insensitive regex, special characters escaped), IN list, null checks, AND/OR/NOT - XML documentation on all public types and members + +### Fixed +- Closure-captured `null` values now translate to the same `{ field: null }` node as a literal `null` (were previously inconsistent). +- `.In(...)` value lists are now capped at 10,000 entries. See the root [CHANGELOG.md](../CHANGELOG.md) `[Unreleased]` entry for details. From 6cf6f5a833f8ab990e83b0a037c88a4629fb0eed Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 13:04:12 -0500 Subject: [PATCH 28/30] docs: state .NET 10 forward compatibility wherever .NET 8/9 is mentioned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All packages build for net8.0/net9.0 only, but run unmodified on .NET 10 via forward compatibility. Documented that explicitly everywhere a .NET version requirement is stated (README, architecture overview, getting started guide, EF Core evaluator requirements) — en + es. --- README.md | 2 +- docs/en/architecture/overview.md | 2 +- docs/en/guides/getting-started.md | 2 +- docs/en/vali-flow-ef-core.md | 2 +- docs/es/architecture/overview.md | 2 +- docs/es/guides/getting-started.md | 2 +- docs/es/vali-flow-ef-core.md | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 39d654b..75862ca 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Vali-Flow is a comprehensive .NET library ecosystem for building reusable, compo All built on **Vali-Flow.Core** — a lightweight expression builder with zero additional dependencies. -**Supported platforms:** .NET 8.0, .NET 9.0 +**Supported platforms:** .NET 8.0, .NET 9.0 — all packages also run unmodified on **.NET 10** thanks to .NET's forward compatibility (a library built for an earlier TFM runs fine under a newer runtime). --- diff --git a/docs/en/architecture/overview.md b/docs/en/architecture/overview.md index 1145efa..9919eda 100644 --- a/docs/en/architecture/overview.md +++ b/docs/en/architecture/overview.md @@ -158,7 +158,7 @@ Vali-Flow.sln └── Vali-Flow.Abstractions.Tests/ ``` -All packages target `net8.0` and `net9.0`. +All packages target `net8.0` and `net9.0`, and also run unmodified on **.NET 10** thanks to .NET's forward compatibility. --- diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index d36bacc..4a4b372 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -4,7 +4,7 @@ This guide walks you through installing and using the Vali-Flow ecosystem from s ## Prerequisites -- .NET 8 or .NET 9 +- .NET 8 or .NET 9 (also runs on .NET 10 via forward compatibility) - A C# project (any type: web API, console, test project, etc.) --- diff --git a/docs/en/vali-flow-ef-core.md b/docs/en/vali-flow-ef-core.md index ecc2bf9..9936eef 100644 --- a/docs/en/vali-flow-ef-core.md +++ b/docs/en/vali-flow-ef-core.md @@ -78,7 +78,7 @@ dotnet add package Vali-Flow **Requirements** -- .NET 8 or later +- .NET 8, 9, or 10 (built for net8.0/net9.0, runs unmodified on .NET 10 via forward compatibility) - Entity Framework Core 8 or later --- diff --git a/docs/es/architecture/overview.md b/docs/es/architecture/overview.md index d7fa2f5..a6acbd3 100644 --- a/docs/es/architecture/overview.md +++ b/docs/es/architecture/overview.md @@ -143,7 +143,7 @@ Vali-Flow.sln └── Vali-Flow.NoSql.DynamoDB.Tests/ Tests de DynamoDB ``` -Todos los paquetes apuntan a `net8.0` y `net9.0`. +Todos los paquetes apuntan a `net8.0` y `net9.0`, y tambien corren sin modificaciones en **.NET 10** gracias a la compatibilidad hacia adelante de .NET. --- diff --git a/docs/es/guides/getting-started.md b/docs/es/guides/getting-started.md index ab92d12..adf6a56 100644 --- a/docs/es/guides/getting-started.md +++ b/docs/es/guides/getting-started.md @@ -4,7 +4,7 @@ Esta guía te lleva a través de la instalación y uso del ecosistema Vali-Flow ## Requisitos previos -- .NET 8 o .NET 9 +- .NET 8 o .NET 9 (tambien corre en .NET 10 por compatibilidad hacia adelante) - Un proyecto C# (cualquier tipo: Web API, consola, proyecto de tests, etc.) --- diff --git a/docs/es/vali-flow-ef-core.md b/docs/es/vali-flow-ef-core.md index 94f2958..2f12bd5 100644 --- a/docs/es/vali-flow-ef-core.md +++ b/docs/es/vali-flow-ef-core.md @@ -78,7 +78,7 @@ dotnet add package Vali-Flow **Requisitos** -- .NET 8 o superior +- .NET 8, 9 o 10 (compilado para net8.0/net9.0, corre sin modificaciones en .NET 10 por compatibilidad hacia adelante) - Entity Framework Core 8 o superior --- From 87e90f74bbffd7ae0cf77b35c05a0a125aa9ca6b Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 13:35:40 -0500 Subject: [PATCH 29/30] ci: add reusable CI workflow (build, test matrix, 95% coverage gate, pack) --- .github/workflows/ci.yml | 366 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6e47ad1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,366 @@ +name: CI + +# Pull requests, pushes to main and reuse from release.yml (workflow_call). Single required check +# for branch protection: "CI OK". +on: + pull_request: + push: + branches: [main] + workflow_call: + inputs: + version-suffix: + description: Prerelease label appended to every package version (for example rc.1). Empty packs the versions declared in the csproj files. + type: string + default: '' + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Restore + run: dotnet restore vali-flow.sln + + - name: Build + run: dotnet build vali-flow.sln -c Release --no-restore + + test: + name: test / ${{ matrix.os }} / ${{ matrix.tfm }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + tfm: [net8.0, net9.0] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Environment + shell: bash + run: | + mkdir -p logs + { + echo "### ${{ matrix.os }} / ${{ matrix.tfm }}" + uname -a || true + dotnet --info + } 2>&1 | tee logs/00-environment.log + + - name: Restore + shell: bash + run: | + set -o pipefail + dotnet restore vali-flow.sln 2>&1 | tee logs/01-restore.log + + - name: Build + shell: bash + run: | + set -o pipefail + dotnet build vali-flow.sln -c Release --no-restore -v:minimal -bl:logs/build.binlog 2>&1 | tee logs/02-build.log + + # The 11 test projects, one per package family (Vali-Flow.NoSql.Tests also covers the + # MongoDB translator — there is no separate Vali-Flow.NoSql.MongoDB.Tests project). + - name: Unit tests with coverage on ${{ matrix.tfm }} + shell: bash + run: | + set -o pipefail + status=0 + projects="Vali-Flow.Abstractions.Tests Vali-Flow.Tests Vali-Flow.InMemory.Tests Vali-Flow.Sql.Tests Vali-Flow.NoSql.Tests Vali-Flow.NoSql.Elasticsearch.Tests Vali-Flow.NoSql.Redis.Tests Vali-Flow.NoSql.DynamoDB.Tests Vali-Flow.NoSql.Couchbase.Tests Vali-Flow.NoSql.CosmosDb.Tests Vali-Flow.NoSql.Firestore.Tests" + for project in $projects; do + echo "::group::$project on ${{ matrix.tfm }}" + dotnet test "$project" -c Release --no-build -f "${{ matrix.tfm }}" \ + --logger "console;verbosity=detailed" \ + --logger "trx;LogFileName=$project-${{ matrix.tfm }}.trx" \ + --collect:"XPlat Code Coverage" --results-directory "TestResults/${{ matrix.tfm }}/$project" \ + 2>&1 | tee "logs/03-test-$project.log" || status=1 + echo "::endgroup::" + done + exit $status + + - name: Test totals + if: always() + shell: bash + run: | + python3 - <<'PY' + import glob + import os + import xml.etree.ElementTree as ET + + rows = ["| Project | Total | Passed | Failed | Skipped |", "|---|---:|---:|---:|---:|"] + for path in sorted(glob.glob("TestResults/**/*.trx", recursive=True)): + counters = ET.parse(path).getroot().find(".//{*}Counters") + if counters is None: + continue + name = os.path.basename(path)[:-4] + failed = int(counters.get("failed", 0)) + skipped = int(counters.get("total", 0)) - int(counters.get("executed", 0)) + rows.append(f"| {name} | {counters.get('total')} | {counters.get('passed')} | {failed}{' (!)' if failed else ''} | {skipped} |") + report = "### Tests ${{ matrix.os }} / ${{ matrix.tfm }}\n\n" + "\n".join(rows) + "\n" + print(report) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle: + handle.write(report + "\n") + PY + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.os }}-${{ matrix.tfm }} + path: TestResults/**/coverage.cobertura.xml + if-no-files-found: error + retention-days: 7 + + - name: Upload full trace + if: always() + uses: actions/upload-artifact@v4 + with: + name: trace-${{ matrix.os }}-${{ matrix.tfm }} + path: | + logs/** + TestResults/**/*.trx + if-no-files-found: warn + retention-days: 14 + + trace: + name: Full trace (single file) + needs: test + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + pattern: trace-* + path: trace + + - name: Merge every log into one file + shell: bash + run: | + out=full-trace.txt + : > "$out" + while IFS= read -r f; do + rel="${f#trace/}" + { + echo + echo "==================================================================" + echo "== ${rel%%/*} :: ${rel#*/}" + echo "==================================================================" + cat "$f" + } >> "$out" + done < <(find trace -name '*.log' | sort) + wc -l "$out" + { + echo "### Full trace" + echo + echo "Download the **full-trace** artifact of this run: one text file with the environment, restore, build and" + echo "detailed test output of every OS x target framework cell." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the single-file trace + uses: actions/upload-artifact@v4 + with: + name: full-trace + path: full-trace.txt + if-no-files-found: error + retention-days: 14 + + coverage: + # NOTE: 95% is a starting gate, not an audited baseline for the 10 packages not measured in the + # session that stood this pipeline up (Vali-Flow itself was: 97.42%). Adjust per-library after + # the first green run shows the real numbers. + name: Coverage gate (>= 95% per library) + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + pattern: coverage-* + path: coverage + + - name: Merge reports and enforce the gate + env: + THRESHOLD: '95' + run: | + python3 - <<'PY' + import glob + import os + import sys + import xml.etree.ElementTree as ET + + LIBS = [ + "Vali-Flow.Abstractions", "Vali-Flow", "Vali-Flow.InMemory", "Vali-Flow.Sql", + "Vali-Flow.NoSql", "Vali-Flow.NoSql.MongoDB", "Vali-Flow.NoSql.Elasticsearch", + "Vali-Flow.NoSql.Redis", "Vali-Flow.NoSql.DynamoDB", "Vali-Flow.NoSql.Couchbase", + "Vali-Flow.NoSql.CosmosDb", "Vali-Flow.NoSql.Firestore", + ] + THRESHOLD = float(os.environ["THRESHOLD"]) + OSES = ["ubuntu-latest", "windows-latest"] + TFMS = ["net8.0", "net9.0"] + + missing = [ + f"coverage-{os_name}-{tfm}" + for os_name in OSES + for tfm in TFMS + if not glob.glob(f"coverage/coverage-{os_name}-{tfm}/**/coverage.cobertura.xml", recursive=True) + ] + + lines = {lib: {} for lib in LIBS} + reports = glob.glob("coverage/**/coverage.cobertura.xml", recursive=True) + for path in reports: + for package in ET.parse(path).getroot().iter("package"): + store = lines.get(package.get("name")) + if store is None: + continue + for cls in package.iter("class"): + parts = cls.get("filename").replace("\\", "/").split("/") + file_key = "/".join(parts[-2:]) + for line in cls.iter("line"): + key = (file_key, int(line.get("number"))) + store[key] = max(store.get(key, 0), int(line.get("hits"))) + + rows = ["| Library | Lines | Covered | Line coverage | Gate |", "|---|---:|---:|---:|:---:|"] + failed = [] + for lib in LIBS: + total = len(lines[lib]) + covered = sum(1 for hits in lines[lib].values() if hits > 0) + pct = 100.0 * covered / total if total else 0.0 + ok = total > 0 and pct >= THRESHOLD + if not ok: + failed.append(lib) + rows.append(f"| {lib} | {total} | {covered} | {pct:.1f}% | {'pass' if ok else 'FAIL'} |") + + cells = ", ".join(f"{o} / {t}" for o in OSES for t in TFMS) + report = (f"### Unit-test line coverage (gate >= {THRESHOLD:g}%, {len(reports)} reports merged)\n\n" + + "\n".join(rows) + + f"\n\nExpected cells: {cells}\n" + + (f"\nMissing reports: {', '.join(missing)}\n" if missing else "\nAll 4 OS x TFM reports present.\n")) + print(report) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle: + handle.write(report + "\n") + if missing: + sys.exit("Coverage gate failed: missing reports for " + ", ".join(missing)) + if failed: + sys.exit("Coverage gate failed for: " + ", ".join(failed)) + PY + + pack: + name: Pack NuGet packages + needs: build + runs-on: ubuntu-latest + env: + VERSION_SUFFIX: ${{ inputs.version-suffix }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Apply the prerelease label + if: ${{ inputs.version-suffix != '' }} + run: | + if ! [[ "$VERSION_SUFFIX" =~ ^[0-9A-Za-z.-]+$ ]]; then + echo "::error::version-suffix may only contain letters, digits, dots and hyphens" + exit 1 + fi + cat > Directory.Build.targets <<'XML' + + + $(Version)-$(PrereleaseLabel) + + + XML + + - name: Pack every package in dependency order + run: | + for project in Vali-Flow.Abstractions Vali-Flow Vali-Flow.InMemory Vali-Flow.Sql Vali-Flow.NoSql Vali-Flow.NoSql.MongoDB Vali-Flow.NoSql.Elasticsearch Vali-Flow.NoSql.Redis Vali-Flow.NoSql.DynamoDB Vali-Flow.NoSql.Couchbase Vali-Flow.NoSql.CosmosDb Vali-Flow.NoSql.Firestore; do + dotnet pack "$project/$project.csproj" -c Release -o artifacts ${VERSION_SUFFIX:+-p:PrereleaseLabel="$VERSION_SUFFIX"} + done + + - name: Verify package contents + run: | + python3 - <<'PY' + import glob + import sys + import xml.etree.ElementTree as ET + import zipfile + + PACKAGES = { + "Vali-Flow.Abstractions", "Vali-Flow", "Vali-Flow.InMemory", "Vali-Flow.Sql", + "Vali-Flow.NoSql", "Vali-Flow.NoSql.MongoDB", "Vali-Flow.NoSql.Elasticsearch", + "Vali-Flow.NoSql.Redis", "Vali-Flow.NoSql.DynamoDB", "Vali-Flow.NoSql.Couchbase", + "Vali-Flow.NoSql.CosmosDb", "Vali-Flow.NoSql.Firestore", + } + TFMS = {"net8.0", "net9.0"} + + errors = [] + found = {} + for path in sorted(glob.glob("artifacts/*.nupkg")): + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + root = ET.fromstring(archive.read(next(n for n in names if n.endswith(".nuspec")))) + ns = {"n": root.tag.split("}")[0].strip("{")} + pid = root.find("n:metadata/n:id", ns).text + version = root.find("n:metadata/n:version", ns).text + libs = {n.split("/")[1] for n in names if n.startswith("lib/") and n.endswith(".dll")} + found[pid] = (version, libs) + + for pid in sorted(PACKAGES - set(found)): + errors.append(f"{pid}: package missing from artifacts/") + for pid, (version, libs) in sorted(found.items()): + if libs != TFMS: + errors.append(f"{pid} {version}: lib/ has {sorted(libs)}, expected {sorted(TFMS)}") + print(f"{pid} {version}: lib {sorted(libs)}") + if errors: + sys.exit("Package check failed:\n " + "\n ".join(errors)) + print("Package check passed.") + PY + + - name: Upload packages + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: artifacts/* + if-no-files-found: error + retention-days: 14 + + ci-ok: + name: CI OK + if: always() + needs: [build, test, coverage, pack] + runs-on: ubuntu-latest + steps: + - name: Every job must have succeeded + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + if echo "$RESULTS" | grep -Eq '"(failure|cancelled|skipped)"'; then + exit 1 + fi From 1b94921ebd4ac9c300a1068197602a2161be5250 Mon Sep 17 00:00:00 2001 From: Felipe Rafael Montenegro Morriberon Date: Tue, 6 Oct 2026 13:41:10 -0500 Subject: [PATCH 30/30] ci: add manual release workflow (3-wave dependency-ordered NuGet publish) --- .github/workflows/release.yml | 133 ++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b7ee5e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: Release + +# Manual only. By default it runs the full CI (net8.0/net9.0 on Linux and Windows) and uploads the +# packed NuGet packages as a workflow artifact; it publishes NOTHING. Publishing needs BOTH +# `publish: true` and an approval of the `nuget` environment (Settings > Environments > nuget > +# Required reviewers), and uses the NUGET_API_KEY secret of that environment. +# +# Publication order — 3 waves, because this repo has a 2-level dependency chain on top of the core: +# Wave 1: Vali-Flow.Abstractions alone. +# Wave 2: Vali-Flow, Vali-Flow.InMemory, Vali-Flow.Sql, Vali-Flow.NoSql (all depend on Abstractions). +# Wave 3: the 7 NoSQL provider packages (all depend on Vali-Flow.NoSql from wave 2). +# Each wave waits for NuGet.org to index the package the next wave needs before proceeding. +on: + workflow_dispatch: + inputs: + version-suffix: + description: Prerelease label appended to every package version, for example rc.1 (empty = versions from the csproj files) + type: string + required: false + default: '' + publish: + description: Publish the packages to NuGet.org after the approval of the nuget environment + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + ci: + name: CI (build, test on net8.0/net9.0, coverage gate, pack) + uses: ./.github/workflows/ci.yml + with: + version-suffix: ${{ inputs.version-suffix }} + + publish: + name: Publish to NuGet (needs approval) + needs: ci + if: ${{ inputs.publish }} + runs-on: ubuntu-latest + environment: nuget + env: + NUGET_SOURCE: https://api.nuget.org/v3/index.json + steps: + - uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: artifacts + + - name: Every package must contain net8.0 and net9.0 + run: | + for package in artifacts/*.nupkg; do + for tfm in net8.0 net9.0; do + unzip -l "$package" | grep -q "lib/$tfm/" || { echo "::error::$package has no lib/$tfm"; exit 1; } + done + done + + - name: Check NUGET_API_KEY is configured + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::error::NUGET_API_KEY is not set in the nuget environment" + exit 1 + fi + + # ---- Wave 1: Vali-Flow.Abstractions alone ---- + - name: 'Wave 1: push Vali-Flow.Abstractions' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + # Vali-Flow.Abstractions is the only package whose id is followed by a version digit + # among files matching "Vali-Flow.Abstractions.*" (there is no other package with that + # prefix), so a plain glob is unambiguous here. + dotnet nuget push artifacts/Vali-Flow.Abstractions.[0-9]*.nupkg --source "$NUGET_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate + + - name: 'Wave 1: wait until NuGet.org lists Vali-Flow.Abstractions' + run: | + pkg=$(basename artifacts/Vali-Flow.Abstractions.[0-9]*.nupkg .nupkg) + version=$(echo "${pkg#Vali-Flow.Abstractions.}" | tr '[:upper:]' '[:lower:]') + for attempt in $(seq 1 40); do + if curl -fsS "https://api.nuget.org/v3-flatcontainer/vali-flow.abstractions/index.json" | grep -q "\"$version\""; then + echo "Vali-Flow.Abstractions $version is indexed" + exit 0 + fi + echo "attempt $attempt/40: Vali-Flow.Abstractions $version not indexed yet" + sleep 30 + done + echo "::error::Vali-Flow.Abstractions $version was not indexed in time; re-run this job (--skip-duplicate makes it safe)" + exit 1 + + # ---- Wave 2: Vali-Flow, Vali-Flow.InMemory, Vali-Flow.Sql, Vali-Flow.NoSql ---- + - name: 'Wave 2: push Vali-Flow, Vali-Flow.InMemory, Vali-Flow.Sql, Vali-Flow.NoSql' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + # Vali-Flow.[0-9]* matches only the main package (not Vali-Flow.InMemory.*, Vali-Flow.Sql.*, + # or Vali-Flow.NoSql.* — those have a literal "." before a letter, not a digit). + for pattern in 'Vali-Flow.[0-9]*' 'Vali-Flow.InMemory.[0-9]*' 'Vali-Flow.Sql.[0-9]*' 'Vali-Flow.NoSql.[0-9]*'; do + dotnet nuget push "artifacts/$pattern.nupkg" --source "$NUGET_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate + done + + - name: 'Wave 2: wait until NuGet.org lists Vali-Flow.NoSql' + run: | + # Only Vali-Flow.NoSql gates wave 3 — the 7 providers depend on it, not on Vali-Flow/InMemory/Sql. + pkg=$(basename artifacts/Vali-Flow.NoSql.[0-9]*.nupkg .nupkg) + version=$(echo "${pkg#Vali-Flow.NoSql.}" | tr '[:upper:]' '[:lower:]') + for attempt in $(seq 1 40); do + if curl -fsS "https://api.nuget.org/v3-flatcontainer/vali-flow.nosql/index.json" | grep -q "\"$version\""; then + echo "Vali-Flow.NoSql $version is indexed" + exit 0 + fi + echo "attempt $attempt/40: Vali-Flow.NoSql $version not indexed yet" + sleep 30 + done + echo "::error::Vali-Flow.NoSql $version was not indexed in time; re-run this job (--skip-duplicate makes it safe)" + exit 1 + + # ---- Wave 3: the 7 NoSQL provider packages ---- + - name: 'Wave 3: push the 7 NoSQL provider packages' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + for package in artifacts/Vali-Flow.NoSql.*.nupkg; do + case "$(basename "$package")" in + Vali-Flow.NoSql.[0-9]*) continue ;; # already pushed in wave 2 + esac + dotnet nuget push "$package" --source "$NUGET_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate + done