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
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
diff --git a/.gitignore b/.gitignore
index feb2c5a..bd8c8c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -404,3 +404,8 @@ FodyWeavers.xsd
.claude/
claude-*.md
CLAUDE.md
+.claudeignore
+.guardian.json
+.codegraph/
+
+.superpowers/
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/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/README.md b/README.md
index 1375dbd..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).
---
@@ -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/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;
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.
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 @@
+
+
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.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;
+ }
+ }
+
+ ///