diff --git a/benchmarks/repobench/repobench.csproj b/benchmarks/repobench/repobench.csproj index a3070202..4dc1acf0 100644 --- a/benchmarks/repobench/repobench.csproj +++ b/benchmarks/repobench/repobench.csproj @@ -7,6 +7,7 @@ enable enable Kista.Benchmarks + false diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index af77d130..7907f625 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -20,6 +20,7 @@ * [Overview](filtering/index.md) * [Filter Cache](filtering/filter-cache.md) * [The Entity Manager](entity-manager/README.md) + * [Entity Validation](entity-manager/entity-validation.md) * [HTTP Request Cancellation](entity-manager/http-request-cancellation.md) * [Caching Entities](entity-manager/caching-entities.md) * [Sample Application](sample-app.md) diff --git a/docs/custom-repository/registration.md b/docs/custom-repository/registration.md index d72f9e28..5104b46d 100644 --- a/docs/custom-repository/registration.md +++ b/docs/custom-repository/registration.md @@ -4,7 +4,13 @@ This page covers how to register custom repositories with the DI container using ## Basic Registration -Use `AddRepository()` on the repository context builder: +Use `AddRepository()` on the repository context builder. The driver's open-generic registrations (e.g. `InMemoryRepository<>` from `.UseInMemory()`) serve as defaults for all entities; `AddRepository()` registers a concrete type that overrides the open generic for that specific entity: + +```csharp +builder.Services.AddRepositoryContext() + .UseInMemory() // default: InMemoryRepository for all entities + .AddRepository(); // override: SpecialProductRepo for Product only +``` ```csharp builder.Services.AddRepositoryContext() diff --git a/docs/entity-manager/README.md b/docs/entity-manager/README.md index 06045a29..0a7c2020 100644 --- a/docs/entity-manager/README.md +++ b/docs/entity-manager/README.md @@ -1,56 +1,91 @@ # The Entity Manager + > **Renamed:** This project was renamed from **Deveel.Repository** to **Kista** on **May 26, 2025**. The name *Kista* is Old Norse for "chest" or "repository", better reflecting the project purpose as a data access framework. -The `EntityManager` class is an optional application-layer service that wraps an `IRepository` and enriches every operation with cross-cutting concerns: +The `EntityManager` (and `EntityManager`) is an optional application-layer service that wraps an `IRepository` and enriches every operation with cross-cutting concerns: -- **Validation** — entities are validated (via `IEntityValidator`) before being added or updated. -- **Caching** — frequently accessed entities can be served from a second-level cache (via `IEntityCache`). +- **Validation** — entities are validated before being added or updated. +- **Caching** — frequently accessed entities can be served from a second-level cache. - **Timestamping** — entities implementing `IHaveTimeStamp` are automatically stamped on create/update. - **Structured error handling** — operations return structured error results rather than throwing raw exceptions. - **Logging** — all operations are logged through the standard `ILogger` infrastructure. -`EntityManager` is designed to be used at the **application service layer**, sitting between your controllers / use-case handlers and the underlying repository. +`EntityManager` is designed to sit between your controllers / use-case handlers and the underlying repository, at the **application service layer**. -## Constructor +## Prerequisites -```csharp -public EntityManager( - IRepository repository, - IEntityValidator? validator = null, - IEntityCache? cache = null, - ISystemTime? systemTime = null, - IOperationErrorFactory? errorFactory = null, - IServiceProvider? services = null, - ILoggerFactory? loggerFactory = null) -``` +The entity manager is packaged separately from the core repository: + +| Package | Description | +|---------|-------------| +| `Kista.Manager` | Base manager, validation abstractions, error factories | +| `Kista.Manager.EasyCaching` | Second-level caching via EasyCaching | +| `Kista.Manager.AspNetCore` | Automatic HTTP request cancellation for ASP.NET Core | +| `Kista.Manager.DynamicLinq` | Dynamic LINQ query extensions for the manager | -All parameters beyond `repository` are optional and resolved from the DI container at registration time (see below). +```bash +dotnet add package Kista.Manager +``` ## Registration -Use `AddEntityManager` to register a concrete manager type (or a custom sub-class): +There are two ways to register an entity manager: **per-repository** (explicit, recommended) and **global** (convenience for bulk registration). + +### Per-repository (recommended) + +Use `WithManagement()` on the `RepositoryBuilder` returned by `AddRepository()`. This binds the manager to a specific repository and entity type: ```csharp -// Program.cs +services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt + .WithValidator() + .WithCacheKeyGenerator() + .WithOperationErrorFactory() + .WithEasyCaching(opts => + { + opts.DefaultExpiration = TimeSpan.FromMinutes(15); + }))) + .UseInMemory(); +``` -// Register the default EntityManager for MyEntity -builder.Services.AddManagerFor(); +To register a manager with no additional services (the EntityManager itself is still registered): -// Or register a custom sub-class that derives from EntityManager -builder.Services.AddEntityManager(); +```csharp +services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement()) + .UseInMemory(); ``` -After registration, the DI container provides both the concrete type and all `EntityManager` base-type projections. +### Global (convenience) + +Use `WithManagement()` on the `RepositoryContextBuilder` to register managers for **all tracked entity types** in one call: + +```csharp +services.AddRepositoryContext() + .AddRepository(_ => { }) + .AddRepository(_ => { }) + .WithManagement() // registers EntityManager and EntityManager + .UseInMemory(); +``` -To register a custom validator: +You can pass a `ManagementOptions` delegate to control auto-registration: ```csharp -builder.Services.AddEntityValidator(); +services.AddRepositoryContext() + .AddRepository(_ => { }) + .WithManagement(opts => + { + opts.AutoRegisterManagers = true; // default: true + }); ``` +> Both approaches coexist. Use per-repository when you need entity-specific configuration; use global when you want quick setup with no extras. + ## Custom Managers -Derive from `EntityManager` to add domain-specific business operations: +Derive from `EntityManager` (or `EntityManager`) to add domain-specific business operations: ```csharp public class OrderManager : EntityManager @@ -66,7 +101,7 @@ public class OrderManager : EntityManager { var order = await FindAsync(orderId, ct); if (order == null) - return NotFound("ORDER_NOT_FOUND"); + return OperationResult.Fail(...); order.Ship(); return await UpdateAsync(order, ct); @@ -74,22 +109,57 @@ public class OrderManager : EntityManager } ``` +Register a custom manager by registering it directly in the DI container: + +```csharp +services.AddScoped(); +``` + +The base `EntityManager` is registered automatically when using `WithManagement()`, so DI can resolve both the base type and your custom type. + +## Operation Results + +EntityManager methods return `OperationResult` (for void-like operations) or `OperationResult` (for operations that return a value), rather than throwing exceptions for expected failures: + +| Method | Returns | +|--------|---------| +| `AddAsync(entity)` | `OperationResult` | +| `UpdateAsync(entity)` | `OperationResult` | +| `RemoveAsync(entity)` | `OperationResult` | +| `FindAsync(key)` | `OperationResult` | +| `FindFirstAsync(query)` | `OperationResult` | + +```csharp +var result = await manager.AddAsync(person); +if (result.IsSuccess) +{ + // entity added +} +else +{ + var error = result.Error; + // error.ErrorCode, error.Message +} +``` + +This allows the caller to handle validation failures, not-found conditions, and infrastructure errors uniformly without try/catch blocks. + ## Operation Cancellation -Every async method accepts an optional `CancellationToken`. When no token is supplied (or `null` is passed), the manager checks for an `IOperationCancellationSource` registered in the DI container and uses its token automatically. +Every async method accepts an optional `CancellationToken`. When no token is supplied, the manager checks for an `IOperationCancellationSource` registered in the DI container and uses its token automatically. -This is useful for tying operation lifetime to an HTTP request: +For ASP.NET Core, install `Kista.Manager.AspNetCore` and call `AddHttpRequestTokenSource()`: ```csharp -// Register the ASP.NET Core HTTP cancellation source -// (provided by Kista.Manager.AspNetCore) builder.Services.AddHttpRequestTokenSource(); ``` -With this in place, when the HTTP request is aborted, all in-flight repository operations are cancelled without any additional code in your manager. +When the HTTP client disconnects, all in-flight repository operations are cancelled automatically. See [HTTP Request Cancellation](http-request-cancellation.md) for full details. -## Caching +## See Also -See [Caching Entities](caching-entities.md) for details on integrating second-level caching via [EasyCaching](https://easycaching.readthedocs.io/en/latest/). +- [Entity Validation](entity-validation.md) — validators, error factories, and the validation flow +- [Caching Entities](caching-entities.md) — cache registration, key generators, serialization +- [HTTP Request Cancellation](http-request-cancellation.md) — automatic cancellation via ASP.NET Core diff --git a/docs/entity-manager/caching-entities.md b/docs/entity-manager/caching-entities.md index ba810fcd..d001dea3 100644 --- a/docs/entity-manager/caching-entities.md +++ b/docs/entity-manager/caching-entities.md @@ -1,7 +1,8 @@ # Caching Entities + > **Renamed:** This project was renamed from **Deveel.Repository** to **Kista** on **May 26, 2025**. The name *Kista* is Old Norse for "chest" or "repository", better reflecting the project purpose as a data access framework. -The `EntityManager` supports an optional second-level cache via the `IEntityCache` service. When registered, the manager transparently caches entities on write and serves them from the cache on subsequent reads, reducing the number of calls to the underlying repository. +The `EntityManager` supports an optional second-level cache via the `IEntityCache` service. When registered, the manager transparently caches entities on write and serves them from the cache on subsequent reads, reducing calls to the underlying repository. ## Installation @@ -11,28 +12,58 @@ Install the EasyCaching integration package: dotnet add package Kista.Manager.EasyCaching ``` +EasyCaching must be configured globally with a provider (e.g., in-memory, Redis, Memcached): + +```csharp +builder.Services.AddEasyCaching(options => + options.UseInMemory("default")); +``` + ## Registration -Register EasyCaching and the entity cache in the DI container: +### Via EntityManagerBuilder (recommended) + +When using the per-repository `WithManagement()` callback, use `WithEasyCaching()` on the `EntityManagerBuilder`: ```csharp -// Program.cs -builder.Services.AddEasyCaching(options => -{ - options.UseInMemory("default"); -}); +services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt + .WithEasyCaching(opts => + { + opts.DefaultExpiration = TimeSpan.FromMinutes(15); + }))) + .UseInMemory(); +``` + +### Global registration + +Enable caching for all tracked entity types via the `RepositoryContextBuilder`: + +```csharp +services.AddRepositoryContext() + .AddRepository(_ => { }) + .AddRepository(_ => { }) + .WithEasyCaching(opts => + { + opts.DefaultExpiration = TimeSpan.FromMinutes(5); + }); +``` + +### Direct registration (legacy) -// Register the default EntityEasyCache +The following methods on `IServiceCollection` are still supported but deprecated in favor of the fluent builder: + +```csharp builder.Services.AddEntityEasyCacheFor(); -// Then register the manager (which picks up the cache automatically) builder.Services.AddManagerFor(); ``` You can configure cache options inline or from configuration: ```csharp -// Inline configuration +// Inline builder.Services.AddEntityEasyCacheFor(options => { options.Expiration = TimeSpan.FromMinutes(5); @@ -55,26 +86,49 @@ The `EntityManager` intercepts the following operations to read from or | `RemoveAsync` | `RemoveAsync` | Evicts the entity from the cache after removal. | | `RemoveRangeAsync` | `RemoveAsync` (batch) | Evicts all removed entities from the cache. | +### Cache invalidation + +The manager automatically invalidates cached entries when entities are updated or removed. There is no time-based invalidation by default; expiration is controlled by the EasyCaching provider configuration (e.g., `DefaultExpiration` in `WithEasyCaching()`). + +If an entity is modified outside the manager (directly through the repository), cached entries may become stale. In that case, call `RemoveAsync` through the manager or evict entries directly using the cache provider. + +### Cache on Find vs FindFirst + +`FindAsync` always checks the cache first. `FindFirstAsync` does **not** cache results, since queries are dynamic and caching their output is not generally safe without additional semantics. + ## Cache Keys -By default, the primary key of the entity is used to derive the cache key. To customize key generation, implement `IEntityCacheKeyGenerator` and register it: +By default, the entity's primary key (`IHaveKey.Key`) is converted to a string and used as the cache key. The default key format is `{EntityType.Name}:{key}`. + +To customize key generation, implement `IEntityCacheKeyGenerator` and register it via the `EntityManagerBuilder`: ```csharp -public class MyEntityCacheKeyGenerator : IEntityCacheKeyGenerator +public class PersonKeyGenerator : IEntityCacheKeyGenerator { - public IEnumerable GetKeys(MyEntity entity) - { - yield return $"myentity:{entity.Id}"; - yield return $"myentity:by-name:{entity.Name}"; - } + public string GenerateKey(object key) => $"person:{key}"; + public string[] GenerateAllKeys(Person entity) => + [$"person:{entity.Id}", $"person:email:{entity.Email}"]; } +services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt + .WithCacheKeyGenerator())); +``` + +The `WithCacheKeyGenerator()` method scans the type for implemented `IEntityCacheKeyGenerator<>` interfaces and registers each one matching the builder's current entity type. + +Multiple keys from `GenerateAllKeys` enable lookups by alternate identifiers (e.g., email, external ID) while keeping a single entry in the cache. + +For legacy direct registration (deprecated): + +```csharp builder.Services.AddEntityCacheKeyGenerator(); ``` ## Cache Serialization -In some scenarios, entities must be converted to a serializable form before being stored in the cache (e.g., when the cache provider serializes objects to JSON or binary). To handle this, derive from `EntityEasyCache` and implement `IEntityEasyCacheConverter`: +Some cache providers (Redis, distributed caches) require entities to be serialized to a specific format. To handle this, implement `IEntityEasyCacheConverter` and register a typed cache: ```csharp public class MyEntityCacheConverter @@ -92,3 +146,8 @@ Then register a typed `EntityEasyCache`: ```csharp builder.Services.AddEntityEasyCache>(); ``` + +## See Also + +- [Entity Validation](entity-validation.md) — configure validators and error factories +- [HTTP Request Cancellation](http-request-cancellation.md) — automatic cancellation via ASP.NET Core diff --git a/docs/entity-manager/entity-validation.md b/docs/entity-manager/entity-validation.md new file mode 100644 index 00000000..f41a7ef8 --- /dev/null +++ b/docs/entity-manager/entity-validation.md @@ -0,0 +1,137 @@ +# Entity Validation + +> **Renamed:** This project was renamed from **Deveel.Repository** to **Kista** on **May 26, 2025**. The name *Kista* is Old Norse for "chest" or "repository", better reflecting the project purpose as a data access framework. + +The `EntityManager` validates entities before creating or updating them. Validation is pluggable: implement a validator interface, register it via the fluent builder, and the manager invokes it automatically on every `AddAsync` and `UpdateAsync` call. + +## How Validation Works + +When `AddAsync` or `UpdateAsync` is called, the manager: + +1. Collects all registered `IEntityValidator` instances from the DI container. +2. Calls `ValidateAsync` on each validator, passing the manager instance and the entity. +3. If **any** validator yields validation errors, the operation returns `OperationResult.Fail` with the aggregated errors — the entity is **not** persisted. +4. If no errors are produced, the operation proceeds to the repository. + +## Validator Interface + +Implement `IEntityValidator` (or `IEntityValidator` for keyed entities): + +```csharp +public class PersonValidator : IEntityValidator +{ + public async IAsyncEnumerable ValidateAsync( + EntityManager manager, + Person entity, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(entity.Name)) + yield return new ValidationResult("Name is required"); + + if (entity.Age < 0) + yield return new ValidationResult("Age must be non-negative"); + } +} +``` + +The interface uses `IAsyncEnumerable`, allowing validators to yield zero, one, or multiple errors. Validation is lazily enumerated: all results are collected before the operation proceeds or fails. + +### Rules + +- Validators run **before** any repository write. +- Multiple validators can be registered for the same entity type; all are invoked. +- Validation errors are surfaced as structured `ValidationResult` objects inside the `OperationResult.Error`. +- Validators receive the `EntityManager` instance, enabling cross-entity validation (e.g., checking uniqueness via the manager). + +## Registration + +### Via EntityManagerBuilder (recommended) + +Use `WithValidator()` on the `EntityManagerBuilder`: + +```csharp +services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt + .WithValidator())) + .UseInMemory(); +``` + +The validator type is **scanned** for all implemented `IEntityValidator<>` and `IEntityValidator<,>` interfaces. Only those matching the builder's current entity type are registered. This means a single class can implement validators for multiple entities: + +```csharp +public class CompositeValidator : + IEntityValidator, + IEntityValidator +{ + // ... +} + +// Both Person and Order validators are registered +services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt.WithValidator())) + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt.WithValidator())); +``` + +### Legacy registration + +Register validators directly on `IServiceCollection`: + +```csharp +builder.Services.AddEntityValidator(); +``` + +This is still supported but the fluent builder is preferred. + +## Error Factories + +Error factories control the error codes and messages returned when operations fail. They are tightly coupled to validation because validation failures are returned through the same `OperationResult` mechanism. + +### Implementing an Error Factory + +Extend `OperationErrorFactory` (the concrete class, not merely `IOperationErrorFactory`): + +```csharp +public class PersonErrorFactory : OperationErrorFactory { } +``` + +The base `OperationErrorFactory` provides default error codes for common failure modes (not-found, validation failed, concurrency conflict). Override methods to customize: + +```csharp +public class PersonErrorFactory : OperationErrorFactory +{ + protected override string GetNotFoundErrorCode() => "PERSON_NOT_FOUND"; + protected override string GetValidationErrorCode() => "PERSON_INVALID"; +} +``` + +### Registration + +```csharp +services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt + .WithValidator() + .WithOperationErrorFactory())) + .UseInMemory(); +``` + +> The factory type **must** extend `OperationErrorFactory` (the concrete class), not merely implement `IOperationErrorFactory`. This constraint exists because the registration code wraps the factory in an `OperationErrorFactoryDecorator`, which requires the base class. + +### Why extend OperationErrorFactory? + +The decorator pattern adds entity-type context to errors at runtime. The decorator's constructor signature takes `OperationErrorFactory`, so a custom factory that only implements the interface cannot be wrapped. Extending the concrete class ensures compatibility. + +## Summary + +| Concern | Interface / Base | Registration | +|---------|-----------------|-------------| +| Validation | `IEntityValidator` | `WithValidator()` | +| Error codes | `OperationErrorFactory` (class) | `WithOperationErrorFactory()` | + +## See Also + +- [Caching Entities](caching-entities.md) — second-level cache configuration +- [HTTP Request Cancellation](http-request-cancellation.md) — automatic cancellation via ASP.NET Core diff --git a/docs/index.md b/docs/index.md index 178d52c8..99b5cb41 100644 --- a/docs/index.md +++ b/docs/index.md @@ -86,6 +86,8 @@ builder.Services.AddRepositoryContext() .WithConnectionString("mongodb://...")); ``` +Each `Use*()` method registers **open-generic** repository types (`InMemoryRepository<>`, `EntityRepository<>`, `MongoRepository<>`) with the DI container. This means `IRepository` resolves automatically — you do **not** need to write a repository class for every entity type. When you also register a concrete repository via `AddRepository()`, the closed generic takes precedence over the open generic for that specific entity, letting you mix bulk defaults with per-entity customization. + Each driver call returns to the parent builder, allowing you to chain multiple concerns: ```csharp diff --git a/docs/plans/entity-manager-builder.md b/docs/plans/entity-manager-builder.md new file mode 100644 index 00000000..7f16935b --- /dev/null +++ b/docs/plans/entity-manager-builder.md @@ -0,0 +1,102 @@ +# EntityManagerBuilder Plan + +> **Status: Implemented** — See `EntityManagerBuilder.cs`, `RepositoryBuilderExtensions.cs`, and `EntityManagerBuilderTests.cs`. + +## Goal +Create an `EntityManagerBuilder` that provides a unified, scoped fluent API for registering entity-specific services (validators, cache key generators, error factories, caching) within `WithManagement()`, harmonized with `RepositoryBuilder` (per-repository level). + +## Design Decisions + +| Decision | Choice | +|----------|--------| +| Per-entity cache options | **Provider-specific** — `EasyCachingOptions`, etc. | +| Builder surface area | **Callback return only** — `EntityManagerBuilder` is scoped via `RepositoryBuilder.WithManagement()` callback | +| Entity-scoped methods | **No `*For` suffix** — entity type implied from `RepositoryBuilder` context | +| Registration level | **Per-repository** — `WithManagement()` is on `RepositoryBuilder` (opt-in); global `WithManagement()` on `RepositoryContextBuilder` remains for bulk registration | + +## API Surface + +### Entry point — two `WithManagement()` overloads on `RepositoryBuilder` (extension methods in `Kista.Manager`) + +```csharp +// Overload 1: Simple — registers EntityManager only +public static RepositoryBuilder WithManagement( + this RepositoryBuilder builder, + ServiceLifetime lifetime = ServiceLifetime.Scoped) + +// Overload 2: Callback — registers EntityManager + configures optional services +public static RepositoryBuilder WithManagement( + this RepositoryBuilder builder, + Action configure, + ServiceLifetime lifetime = ServiceLifetime.Scoped) +``` + +### EntityManagerBuilder methods (all return `EntityManagerBuilder`) + +| Method | What it registers | +|--------|-------------------| +| `WithValidator()` | Scans `IEntityValidator<>` / `IEntityValidator<,>` interfaces on `TValidator`, filters for `EntityType` | +| `WithCacheKeyGenerator()` | Scans `IEntityCacheKeyGenerator<>` interfaces on `TGenerator`, filters for `EntityType` | +| `WithOperationErrorFactory()` | Delegates to `services.AddOperationErrorFactory(EntityType, typeof(TFactory))` | +| `WithEasyCaching(Action?)` | Per-entity EasyCaching (extension in `Kista.Manager.EasyCaching`) | + +### Target fluent usage + +```csharp +services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt + .WithValidator() + .WithCacheKeyGenerator() + .WithOperationErrorFactory() + .WithEasyCaching(opts => { + opts.DefaultExpiration = TimeSpan.FromMinutes(15); + }))) + .AddRepository(repo => repo + .WithManagement()) // EntityManager only, no extras + .WithManagement(mgmt => mgmt // global — bulk register for all OTHER repos + .WithEasyCaching()) + .UseInMemory(); +``` + +## Files Created + +### `src/Kista.Manager/EntityManagerBuilder.cs` + +New class in `Kista` namespace with `internal` constructor accepting `RepositoryBuilder` + `ServiceLifetime`. No `*For` suffix on methods — entity type implied from context. Methods: + +- `WithValidator()` — runtime scanning of `IEntityValidator<>` / `IEntityValidator<,>` filtered by `EntityType`, registered with `TryAdd` +- `WithCacheKeyGenerator()` — runtime scanning of `IEntityCacheKeyGenerator<>` filtered by `EntityType`, registered with `TryAdd` +- `WithOperationErrorFactory()` — delegates to `services.AddOperationErrorFactory(EntityType, typeof(TFactory))` + +### `src/Kista.Manager/RepositoryBuilderExtensions.cs` + +Extension methods on `RepositoryBuilder`: + +- `WithManagement(ServiceLifetime)` — registers `EntityManager` (or `EntityManager` if key is `object`) +- `WithManagement(Action, ServiceLifetime)` — same + configures optional services via callback + +## Files Modified + +### `src/Kista.Manager/RepositoryContextBuilderExtensions.cs` + +- Fixed duplicate `TryAdd` (was registering `managerType` twice) +- Removed unused `repositoryInterface` variable + +### `src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs` + +- Added `EntityManagerBuilderExtensions.WithEasyCaching()` extension method + +## What Stays Unchanged + +- All `With*Caching()` methods on `RepositoryContextBuilder` (global config) +- All existing cache extension methods on `IServiceCollection` +- All existing tests (add new tests, don't break old) +- `WithManagement(Action?, ServiceLifetime)` overload signature +- `AddOperationErrorFactory` on `IServiceCollection` (not obsolete) +- `MemoryCacheExtensions`, `FusionCache` and `DistributedCache` projects (not implemented yet) + +## Migration Path + +- Obsolete methods (`AddEntityValidator`, `AddEntityCacheKeyGenerator`, etc.) remain obsolete — their replacements live on `EntityManagerBuilder` +- Consider extracting internal helpers from obsolete `ServiceCollectionExtensions` methods to avoid duplication with `EntityManagerBuilder` diff --git a/docs/repository-implementations/in-memory.md b/docs/repository-implementations/in-memory.md index 327deec4..bd9d71f5 100644 --- a/docs/repository-implementations/in-memory.md +++ b/docs/repository-implementations/in-memory.md @@ -42,6 +42,24 @@ builder.Services.AddRepositoryContext() .WithInitialData(new[] { new MyEntity { /* ... */ } })); ``` +### Bulk vs Per-Entity + +`.UseInMemory()` registers the **open generic** `InMemoryRepository<>` so that `IRepository`, `IRepository`, and any other entity type resolve to `InMemoryRepository` automatically — no per-entity class is needed: + +```csharp +builder.Services.AddRepositoryContext() + .UseInMemory(); // IRepository, IRepository, etc. all work +``` + +For entities that need custom query methods or overrides, create a concrete repository class and register it with `AddRepository()`. The concrete type overrides the open generic for that entity only: + +```csharp +builder.Services.AddRepositoryContext() + .UseInMemory() + .AddRepository(); // PersonRepository used for Person + // InMemoryRepository used for everything else +``` + ### Pre-seeding Data You can seed initial data using the `WithInitialData` method: diff --git a/src/Kista.DynamicLinq/BoundedCache.cs b/src/Kista.DynamicLinq/BoundedCache.cs new file mode 100644 index 00000000..533ccbe1 --- /dev/null +++ b/src/Kista.DynamicLinq/BoundedCache.cs @@ -0,0 +1,161 @@ +// Copyright 2023-2026 Antonello Provenzano +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Kista { + /// + /// A bounded, thread-safe LRU cache base class used by + /// and . + /// + /// The type of value stored in the cache. + public abstract class BoundedCache { + private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly Dictionary> _map; + private readonly LinkedList _order; + private readonly int _maxCapacity; + private long _hits; + private long _misses; + + /// + /// Initializes a new instance with the specified maximum capacity. + /// + /// The maximum number of entries before LRU eviction begins. + /// Thrown when is less than 1. + protected BoundedCache(int maxCapacity) { + if (maxCapacity < 1) + throw new ArgumentOutOfRangeException(nameof(maxCapacity), "MaxCapacity must be at least 1."); + + _maxCapacity = maxCapacity; + _map = new Dictionary>(maxCapacity, StringComparer.Ordinal); + _order = new LinkedList(); + Statistics = new InternalStatistics(this); + } + + /// + /// Initializes a new instance with the default maximum capacity of 1024 entries. + /// + protected BoundedCache() + : this(1024) { + } + + /// + /// Initializes a new instance with the specified configuration options. + /// + /// The configuration options that determine the cache capacity. + /// Thrown when is null. + protected BoundedCache(BoundedFilterCacheOptions options) + : this(options?.MaxCapacity ?? throw new ArgumentNullException(nameof(options))) { + } + + /// + /// Represents an entry in the cache with a string key and a value. + /// + protected record CacheEntry(string Key, TValue Value); + + private sealed class InternalStatistics(BoundedCache cache) : IFilterCacheStatistics { + public long Hits => Volatile.Read(ref cache._hits); + public long Misses => Volatile.Read(ref cache._misses); + public int CurrentSize => cache._map.Count; + public int MaxCapacity => cache._maxCapacity; + + public double HitRate { + get { + var total = Hits + Misses; + return total == 0 ? 0 : (double)Hits / total; + } + } + + public void Reset() { + Volatile.Write(ref cache._hits, 0); + Volatile.Write(ref cache._misses, 0); + } + } + + /// + /// Gets the statistics for this cache instance. + /// + public IFilterCacheStatistics Statistics { get; } + + /// + /// Attempts to retrieve a value from the cache by key, promoting it to the + /// most-recently-used position on a hit. + /// + /// The cache key. + /// When returned, the cached value if found; otherwise default. + /// true if the key was found; otherwise false. + protected bool TryGetCore(string key, out TValue? value) { + ArgumentNullException.ThrowIfNull(key); + + _semaphore.Wait(); + try { + if (_map.TryGetValue(key, out var node)) { + _order.Remove(node); + _order.AddFirst(node); + Interlocked.Increment(ref _hits); + value = node.Value.Value; + return true; + } + + Interlocked.Increment(ref _misses); + value = default; + return false; + } finally { + _semaphore.Release(); + } + } + + /// + /// Stores a value in the cache. If the key already exists, the value is updated + /// and promoted to the most-recently-used position. If the cache is at capacity, + /// the least recently used entry is evicted. + /// + /// The cache key. + /// The value to store. + protected void SetCore(string key, TValue value) { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + + _semaphore.Wait(); + try { + if (_map.TryGetValue(key, out var existingNode)) { + _order.Remove(existingNode); + } else if (_map.Count >= _maxCapacity) { + var lruNode = _order.Last; + if (lruNode != null) { + _order.RemoveLast(); + _map.Remove(lruNode.Value.Key); + } + } + + var entry = new CacheEntry(key, value); + var newNode = _order.AddFirst(entry); + _map[key] = newNode; + } finally { + _semaphore.Release(); + } + } + + /// + /// Removes all entries from the cache. Does not reset the counters. + /// + public void Clear() { + _semaphore.Wait(); + try { + _map.Clear(); + _order.Clear(); + } finally { + _semaphore.Release(); + } + } + } +} diff --git a/src/Kista.DynamicLinq/BoundedExpressionCache.cs b/src/Kista.DynamicLinq/BoundedExpressionCache.cs index 40fa4d38..3c292201 100644 --- a/src/Kista.DynamicLinq/BoundedExpressionCache.cs +++ b/src/Kista.DynamicLinq/BoundedExpressionCache.cs @@ -33,18 +33,9 @@ namespace Kista { /// expression tree can be used directly with filtering methods. /// /// - /// The implementation uses a for O(1) key lookup - /// and a to maintain access order for LRU eviction. - /// All public operations are protected by a (initialized - /// as a binary semaphore) to ensure thread safety under concurrent access. The semaphore - /// uses spin-waiting before falling back to kernel-level waiting, reducing - /// context-switch overhead under moderate contention typical of high-throughput - /// query paths. - /// - /// - /// Statistics are tracked using reads and writes on the hit - /// and miss counters, providing lock-free access to - /// properties without impacting cache throughput. + /// The implementation is backed by which provides the + /// LRU eviction mechanism and thread safety using a + /// for O(1) key lookup and a to maintain access order. /// /// /// @@ -67,14 +58,7 @@ namespace Kista { /// /// /// - public sealed class BoundedExpressionCache : IExpressionCache { - private readonly SemaphoreSlim _semaphore = new(1, 1); - private readonly Dictionary> _map; - private readonly LinkedList _order; - private readonly int _maxCapacity; - private long _hits; - private long _misses; - + public sealed class BoundedExpressionCache : BoundedCache, IExpressionCache { /// /// Initializes a new instance of the class /// with the specified maximum capacity. @@ -86,22 +70,14 @@ public sealed class BoundedExpressionCache : IExpressionCache { /// /// Thrown when is less than 1. /// - public BoundedExpressionCache(int maxCapacity) { - if (maxCapacity < 1) - throw new ArgumentOutOfRangeException(nameof(maxCapacity), "MaxCapacity must be at least 1."); - - _maxCapacity = maxCapacity; - _map = new Dictionary>(maxCapacity, StringComparer.Ordinal); - _order = new LinkedList(); - Statistics = new InternalStatistics(this); + public BoundedExpressionCache(int maxCapacity) : base(maxCapacity) { } /// /// Initializes a new instance of the class /// with the default maximum capacity of 1024 entries. /// - public BoundedExpressionCache() - : this(1024) { + public BoundedExpressionCache() : base() { } /// @@ -114,111 +90,17 @@ public BoundedExpressionCache() /// /// Thrown when is null. /// - public BoundedExpressionCache(BoundedFilterCacheOptions options) - : this(options?.MaxCapacity ?? throw new ArgumentNullException(nameof(options))) { - } - - private sealed record CacheEntry(string Key, LambdaExpression Expression); - - private sealed class InternalStatistics(BoundedExpressionCache cache) : IFilterCacheStatistics { - /// - public long Hits => Volatile.Read(ref cache._hits); - /// - public long Misses => Volatile.Read(ref cache._misses); - /// - public int CurrentSize => cache._map.Count; - /// - public int MaxCapacity => cache._maxCapacity; - - /// - public double HitRate { - get { - var total = Hits + Misses; - return total == 0 ? 0 : (double)Hits / total; - } - } - - /// - public void Reset() { - Volatile.Write(ref cache._hits, 0); - Volatile.Write(ref cache._misses, 0); - } + public BoundedExpressionCache(BoundedFilterCacheOptions options) : base(options) { } /// - public IFilterCacheStatistics Statistics { get; } - - /// - /// - /// On a cache hit, the accessed entry is promoted to the most-recently-used - /// position in the LRU ordering. This ensures that frequently used expressions - /// are not evicted. - /// public bool TryGet(string key, out LambdaExpression? expression) { - ArgumentNullException.ThrowIfNull(key); - - _semaphore.Wait(); - try { - if (_map.TryGetValue(key, out var node)) { - _order.Remove(node); - _order.AddFirst(node); - Interlocked.Increment(ref _hits); - expression = node.Value.Expression; - return true; - } - - Interlocked.Increment(ref _misses); - expression = null; - return false; - } finally { - _semaphore.Release(); - } + return TryGetCore(key, out expression); } /// - /// - /// If the key already exists, the stored expression is updated and the entry - /// is promoted to the most-recently-used position. If the cache is at capacity - /// and the key is new, the least recently used entry is evicted before insertion. - /// public void Set(string key, LambdaExpression expression) { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(expression); - - _semaphore.Wait(); - try { - if (_map.TryGetValue(key, out var existingNode)) { - _order.Remove(existingNode); - } else if (_map.Count >= _maxCapacity) { - var lruNode = _order.Last; - if (lruNode != null) { - _order.RemoveLast(); - _map.Remove(lruNode.Value.Key); - } - } - - var entry = new CacheEntry(key, expression); - var newNode = _order.AddFirst(entry); - _map[key] = newNode; - } finally { - _semaphore.Release(); - } - } - - /// - /// - /// This method removes all cached expressions but does not reset the - /// counters. Call - /// separately if you need to clear the statistics as well. - /// - public void Clear() { - _semaphore.Wait(); - try { - _map.Clear(); - _order.Clear(); - } finally { - _semaphore.Release(); - } + SetCore(key, expression); } } } diff --git a/src/Kista.DynamicLinq/BoundedFilterCache.cs b/src/Kista.DynamicLinq/BoundedFilterCache.cs index 7d4b7628..80d01f11 100644 --- a/src/Kista.DynamicLinq/BoundedFilterCache.cs +++ b/src/Kista.DynamicLinq/BoundedFilterCache.cs @@ -80,18 +80,9 @@ public int MaxCapacity { /// of queries per minute with identical filter shapes. /// /// - /// The cache uses a combination of a for O(1) - /// key lookup and a to track access order for LRU eviction. - /// All operations are protected by a (initialized as a - /// binary semaphore) to ensure thread safety under concurrent access. The semaphore - /// uses spin-waiting before falling back to kernel-level waiting, reducing - /// context-switch overhead under moderate contention typical of high-throughput - /// query paths. - /// - /// - /// Statistics are tracked using reads and writes on the hit - /// and miss counters, providing lock-free access to - /// properties without impacting cache throughput. + /// The implementation is backed by which provides the + /// LRU eviction mechanism and thread safety using a + /// for O(1) key lookup and a to maintain access order. /// /// /// @@ -114,14 +105,7 @@ public int MaxCapacity { /// /// /// - public sealed class BoundedFilterCache : IFilterCache { - private readonly SemaphoreSlim _semaphore = new(1, 1); - private readonly Dictionary> _map; - private readonly LinkedList _order; - private readonly int _maxCapacity; - private long _hits; - private long _misses; - + public sealed class BoundedFilterCache : BoundedCache, IFilterCache { /// /// Initializes a new instance of the class /// with the specified maximum capacity. @@ -133,22 +117,14 @@ public sealed class BoundedFilterCache : IFilterCache { /// /// Thrown when is less than 1. /// - public BoundedFilterCache(int maxCapacity) { - if (maxCapacity < 1) - throw new ArgumentOutOfRangeException(nameof(maxCapacity), "MaxCapacity must be at least 1."); - - _maxCapacity = maxCapacity; - _map = new Dictionary>(maxCapacity, StringComparer.Ordinal); - _order = new LinkedList(); - Statistics = new InternalStatistics(this); + public BoundedFilterCache(int maxCapacity) : base(maxCapacity) { } /// /// Initializes a new instance of the class /// with the default maximum capacity of 1024 entries. /// - public BoundedFilterCache() - : this(new BoundedFilterCacheOptions().MaxCapacity) { + public BoundedFilterCache() : base() { } /// @@ -161,111 +137,17 @@ public BoundedFilterCache() /// /// Thrown when is null. /// - public BoundedFilterCache(BoundedFilterCacheOptions options) - : this(options?.MaxCapacity ?? throw new ArgumentNullException(nameof(options))) { - } - - private sealed record CacheEntry(string Expression, Delegate Lambda); - - private sealed class InternalStatistics(BoundedFilterCache cache) : IFilterCacheStatistics { - /// - public long Hits => Volatile.Read(ref cache._hits); - /// - public long Misses => Volatile.Read(ref cache._misses); - /// - public int CurrentSize => cache._map.Count; - /// - public int MaxCapacity => cache._maxCapacity; - - /// - public double HitRate { - get { - var total = Hits + Misses; - return total == 0 ? 0 : (double)Hits / total; - } - } - - /// - public void Reset() { - Volatile.Write(ref cache._hits, 0); - Volatile.Write(ref cache._misses, 0); - } + public BoundedFilterCache(BoundedFilterCacheOptions options) : base(options) { } /// - public IFilterCacheStatistics Statistics { get; } - - /// - /// - /// On a cache hit, the accessed entry is promoted to the most-recently-used - /// position in the LRU ordering. This ensures that frequently used expressions - /// are not evicted. - /// - public bool TryGet(string expression, out Delegate? lambda) { - ArgumentNullException.ThrowIfNull(expression); - - _semaphore.Wait(); - try { - if (_map.TryGetValue(expression, out var node)) { - _order.Remove(node); - _order.AddFirst(node); - Interlocked.Increment(ref _hits); - lambda = node.Value.Lambda; - return true; - } - - Interlocked.Increment(ref _misses); - lambda = null; - return false; - } finally { - _semaphore.Release(); - } + public bool TryGet(string expression, out Delegate? labda) { + return TryGetCore(expression, out labda); } /// - /// - /// If the key already exists, the stored delegate is updated and the entry - /// is promoted to the most-recently-used position. If the cache is at capacity - /// and the key is new, the least recently used entry is evicted before insertion. - /// public void Set(string expression, Delegate lambda) { - ArgumentNullException.ThrowIfNull(expression); - ArgumentNullException.ThrowIfNull(lambda); - - _semaphore.Wait(); - try { - if (_map.TryGetValue(expression, out var existingNode)) { - _order.Remove(existingNode); - } else if (_map.Count >= _maxCapacity) { - var lruNode = _order.Last; - if (lruNode != null) { - _order.RemoveLast(); - _map.Remove(lruNode.Value.Expression); - } - } - - var entry = new CacheEntry(expression, lambda); - var newNode = _order.AddFirst(entry); - _map[expression] = newNode; - } finally { - _semaphore.Release(); - } - } - - /// - /// - /// This method removes all cached delegates but does not reset the - /// counters. Call - /// separately if you need to clear the statistics as well. - /// - public void Clear() { - _semaphore.Wait(); - try { - _map.Clear(); - _order.Clear(); - } finally { - _semaphore.Release(); - } + SetCore(expression, lambda); } } } diff --git a/src/Kista.DynamicLinq/RepositoryExtensions.cs b/src/Kista.DynamicLinq/RepositoryExtensions.cs index 9a39ec3e..9e9d0c3a 100644 --- a/src/Kista.DynamicLinq/RepositoryExtensions.cs +++ b/src/Kista.DynamicLinq/RepositoryExtensions.cs @@ -45,6 +45,7 @@ public static class RepositoryExtensions { /// Returns an instance of that matches the given expression, /// otherwise null if no entity is found. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask FindFirstAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.FindFirstAsync(new DynamicLinqFilter(paramName, expression), cancellationToken); @@ -74,6 +75,7 @@ public static class RepositoryExtensions { /// Returns an instance of that matches the given expression, /// otherwise null if no entity is found. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask FindFirstAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.FindFirstAsync(new DynamicLinqFilter(paramName, expression), cancellationToken); @@ -98,6 +100,7 @@ public static class RepositoryExtensions { /// Returns an instance of that matches the given expression, /// otherwise null if no entity is found. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask FindFirstAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.FindFirstAsync(new DynamicLinqFilter(expression), cancellationToken); @@ -124,6 +127,7 @@ public static class RepositoryExtensions { /// Returns an instance of that matches the given expression, /// otherwise null if no entity is found. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask FindFirstAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.FindFirstAsync(new DynamicLinqFilter(expression), cancellationToken); @@ -156,6 +160,7 @@ public static class RepositoryExtensions { /// Returns a list of that match the /// given expression. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask> FindAllAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.FindAllAsync(new DynamicLinqFilter(paramName, expression), cancellationToken); @@ -186,6 +191,7 @@ public static ValueTask> FindAllAsync(this IRepository that match the /// given expression. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask> FindAllAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.FindAllAsync(new DynamicLinqFilter(paramName, expression), cancellationToken); @@ -211,6 +217,7 @@ public static ValueTask> FindAllAsync(this IReposi /// Returns a list of that match the /// given expression. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask> FindAllAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.FindAllAsync(new DynamicLinqFilter(expression), cancellationToken); @@ -238,6 +245,7 @@ public static ValueTask> FindAllAsync(this IRepository that match the /// given expression. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask> FindAllAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.FindAllAsync(new DynamicLinqFilter(expression), cancellationToken); @@ -270,6 +278,7 @@ public static ValueTask> FindAllAsync(this IReposi /// /// Returns the number of entities that match the given expression. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask CountAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.AsFilterable().CountAsync(new DynamicLinqFilter(paramName, expression), cancellationToken); @@ -300,6 +309,7 @@ public static ValueTask CountAsync(this IRepository repo /// /// Returns the number of entities that match the given expression. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask CountAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.AsFilterable().CountAsync(new DynamicLinqFilter(paramName, expression), cancellationToken); @@ -325,6 +335,7 @@ public static ValueTask CountAsync(this IRepository /// Returns the number of entities that match the given expression. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask CountAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.AsFilterable().CountAsync(new DynamicLinqFilter(expression), cancellationToken); @@ -352,6 +363,7 @@ public static ValueTask CountAsync(this IRepository repo /// /// Returns the number of entities that match the given expression. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask CountAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.AsFilterable().CountAsync(new DynamicLinqFilter(expression), cancellationToken); @@ -387,6 +399,7 @@ public static ValueTask CountAsync(this IRepositorytrue if the repository contains at least one entity /// that matches the given expression, otherwise false. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask ExistsAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.ExistsAsync(new DynamicLinqFilter(paramName, expression), cancellationToken); @@ -418,6 +431,7 @@ public static ValueTask ExistsAsync(this IRepositorytrue if the repository contains at least one entity /// that matches the given expression, otherwise false. /// + [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)] public static ValueTask ExistsAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default) where TEntity : class => repository.ExistsAsync(DynamicLinqFilter.DefaultParameterName, expression, cancellationToken); diff --git a/src/Kista.Manager.DynamicLinq/EntityManagerExtensions.cs b/src/Kista.Manager.DynamicLinq/EntityManagerExtensions.cs index 48dd443d..6838de6d 100644 --- a/src/Kista.Manager.DynamicLinq/EntityManagerExtensions.cs +++ b/src/Kista.Manager.DynamicLinq/EntityManagerExtensions.cs @@ -42,6 +42,7 @@ public static class EntityManagerExtensions { /// containing the first entity that matches the given expression, /// or a failure result if no entity was found or an error occurred. /// + [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)] public static ValueTask> FindFirstAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null) where TEntity : class => manager.FindFirstAsync(new Query(new DynamicLinqFilter(expression)), cancellationToken); @@ -70,6 +71,7 @@ public static ValueTask> FindFirstAsync(this E /// containing the first entity that matches the given expression, /// or a failure result if no entity was found or an error occurred. /// + [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)] public static ValueTask> FindFirstAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null) where TEntity : class where TKey : notnull @@ -95,6 +97,7 @@ public static ValueTask> FindFirstAsync( /// /// Returns a list of entities that matches the given expression. /// + [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)] public static ValueTask> FindAllAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null) where TEntity : class => manager.FindAllAsync(new Query(new DynamicLinqFilter(expression)), cancellationToken); @@ -121,6 +124,7 @@ public static ValueTask> FindAllAsync(this EntityManager /// /// Returns a list of entities that matches the given expression. /// + [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)] public static ValueTask> FindAllAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null) where TEntity : class where TKey : notnull @@ -146,6 +150,7 @@ public static ValueTask> FindAllAsync(this EntityM /// /// Returns the number of entities that matches the given expression. /// + [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)] public static ValueTask CountAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null) where TEntity : class => manager.CountAsync(new DynamicLinqFilter(expression), cancellationToken); @@ -172,6 +177,7 @@ public static ValueTask CountAsync(this EntityManager ma /// /// Returns the number of entities that matches the given expression. /// + [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)] public static ValueTask CountAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null) where TEntity : class where TKey : notnull diff --git a/src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs b/src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs index 292611f1..31479910 100644 --- a/src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs +++ b/src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs @@ -17,6 +17,53 @@ using Microsoft.Extensions.Options; namespace Kista.Caching { + /// + /// Extension methods for configuring EasyCaching on an . + /// + public static class EntityManagerBuilderExtensions { + /// + /// Enables EasyCaching-based entity caching for the entity type + /// being configured by the . + /// + /// The entity manager builder. + /// + /// An optional delegate to configure the EasyCaching options. + /// + /// + /// The service lifetime for the cache registration (default: Singleton). + /// + /// The builder for chaining. + public static EntityManagerBuilder WithEasyCaching( + this EntityManagerBuilder builder, + Action? configure = null, + ServiceLifetime lifetime = ServiceLifetime.Singleton) { + + var entityType = builder.EntityType; + var options = new EasyCachingOptions(); + configure?.Invoke(options); + + var cacheType = typeof(EntityEasyCache<>).MakeGenericType(entityType); + var cacheInterface = typeof(IEntityCache<>).MakeGenericType(entityType); + + builder.Services.TryAdd(new ServiceDescriptor(cacheInterface, cacheType, lifetime)); + builder.Services.TryAdd(new ServiceDescriptor(cacheType, cacheType, lifetime)); + + if (options.DefaultExpiration.HasValue || !string.IsNullOrEmpty(options.CacheKeyPrefix)) { + var entityOptionsType = typeof(EntityCacheOptions<>).MakeGenericType(entityType); + var expiration = options.DefaultExpiration; + var prefix = options.CacheKeyPrefix; + + builder.Services.AddSingleton(typeof(IConfigureOptions<>).MakeGenericType(entityOptionsType), sp => { + return Activator.CreateInstance( + typeof(ConfiguredEntityCacheOptions<>).MakeGenericType(entityType), + expiration, prefix)!; + }); + } + + return builder; + } + } + /// /// Options for configuring EasyCaching-based entity caching. /// diff --git a/src/Kista.Manager/EntityManagerBuilder.cs b/src/Kista.Manager/EntityManagerBuilder.cs new file mode 100644 index 00000000..eb449345 --- /dev/null +++ b/src/Kista.Manager/EntityManagerBuilder.cs @@ -0,0 +1,138 @@ +// Copyright 2023-2026 Antonello Provenzano +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Kista.Caching; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Kista { + /// + /// A fluent builder for configuring entity-specific services + /// (validators, cache key generators, error factories, caching) + /// for a single entity type. + /// + /// + /// + /// Created by + /// and scoped to the entity and key types of the repository being configured. + /// + /// + public class EntityManagerBuilder { + private readonly RepositoryBuilder _repoBuilder; + private readonly ServiceLifetime _lifetime; + + /// + /// Constructs the builder with the given repository builder and lifetime. + /// + /// + /// + internal EntityManagerBuilder(RepositoryBuilder repoBuilder, ServiceLifetime lifetime) { + _repoBuilder = repoBuilder; + _lifetime = lifetime; + } + + /// + /// Gets the underlying service collection for direct registration. + /// + public IServiceCollection Services => _repoBuilder.Services; + + /// + /// Gets the entity type managed by the repository. + /// + public Type EntityType => _repoBuilder.EntityType; + + /// + /// Gets the entity key type managed by the repository. + /// + public Type EntityKeyType => _repoBuilder.EntityKeyType; + + /// + /// Registers a validator type by scanning its implemented + /// and + /// interfaces, filtering for those matching the current entity and key types. + /// + /// The type of the validator to register. + /// This builder for chaining. + public EntityManagerBuilder WithValidator() + where TValidator : class { + var validatorType = typeof(TValidator); + + if (!validatorType.IsClass || validatorType.IsAbstract) + throw new ArgumentException($"The type {validatorType} is not a concrete class", nameof(TValidator)); + + var interfaceTypes = validatorType.GetInterfaces().Where(x => x.IsGenericType); + foreach (var interfaceType in interfaceTypes) { + + var genericDef = interfaceType.GetGenericTypeDefinition(); + + if (genericDef == typeof(IEntityValidator<>)) { + var entityType = interfaceType.GetGenericArguments()[0]; + if (entityType == EntityType) { + var compareType = typeof(IEntityValidator<>).MakeGenericType(entityType); + Services.TryAdd(new ServiceDescriptor(compareType, validatorType, _lifetime)); + } + } else if (genericDef == typeof(IEntityValidator<,>)) { + var args = interfaceType.GetGenericArguments(); + if (args[0] == EntityType) { + var compareType = typeof(IEntityValidator<,>).MakeGenericType(args[0], args[1]); + Services.TryAdd(new ServiceDescriptor(compareType, validatorType, _lifetime)); + } + } + } + + Services.Add(new ServiceDescriptor(validatorType, validatorType, _lifetime)); + return this; + } + + /// + /// Registers a cache key generator type by scanning its implemented + /// interfaces, + /// filtering for those matching the current entity type. + /// + /// The type of the key generator to register. + /// This builder for chaining. + public EntityManagerBuilder WithCacheKeyGenerator() + where TGenerator : class { + var generatorType = typeof(TGenerator); + + if (!generatorType.IsClass || generatorType.IsAbstract) + throw new ArgumentException($"The type {generatorType} is not a concrete class", nameof(TGenerator)); + + var entityTypes = generatorType.GetInterfaces() + .Where(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEntityCacheKeyGenerator<>)) + .Select(x => x.GetGenericArguments()[0]); + + foreach (var entityType in entityTypes.Where(entityType => entityType == EntityType)) { + var compareType = typeof(IEntityCacheKeyGenerator<>).MakeGenericType(entityType); + Services.TryAdd(new ServiceDescriptor(compareType, generatorType, _lifetime)); + } + + Services.Add(new ServiceDescriptor(generatorType, generatorType, _lifetime)); + return this; + } + + /// + /// Registers an operation error factory for the current entity type. + /// + /// + /// The type of the to register. + /// + /// This builder for chaining. + public EntityManagerBuilder WithOperationErrorFactory() + where TFactory : class, IOperationErrorFactory { + Services.AddOperationErrorFactory(EntityType, typeof(TFactory)); + return this; + } + } +} diff --git a/src/Kista.Manager/RepositoryBuilderExtensions.cs b/src/Kista.Manager/RepositoryBuilderExtensions.cs new file mode 100644 index 00000000..c1bd4992 --- /dev/null +++ b/src/Kista.Manager/RepositoryBuilderExtensions.cs @@ -0,0 +1,79 @@ +// Copyright 2023-2026 Antonello Provenzano +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Kista { + /// + /// Extension methods for configuring entity management on a . + /// + public static class RepositoryBuilderExtensions { + /// + /// Registers an for the entity type + /// of this repository. + /// + /// The repository builder. + /// + /// The service lifetime of the entity manager (default: Scoped). + /// + /// The repository builder for chaining. + public static RepositoryBuilder WithManagement( + this RepositoryBuilder builder, + ServiceLifetime lifetime = ServiceLifetime.Scoped) { + RegisterEntityManager(builder, lifetime); + return builder; + } + + /// + /// Registers an for the entity type + /// of this repository, and configures entity-specific services via the + /// provided callback. + /// + /// The repository builder. + /// + /// A delegate that configures entity-specific services + /// (validators, cache key generators, error factories, caching). + /// + /// + /// The service lifetime of the entity manager (default: Scoped). + /// + /// The repository builder for chaining. + public static RepositoryBuilder WithManagement( + this RepositoryBuilder builder, + Action configure, + ServiceLifetime lifetime = ServiceLifetime.Scoped) { + var mgmtBuilder = new EntityManagerBuilder(builder, lifetime); + configure(mgmtBuilder); + RegisterEntityManager(builder, lifetime); + return builder; + } + + /// + /// Registers the entity manager for the repository's entity type. + /// + private static void RegisterEntityManager(RepositoryBuilder builder, ServiceLifetime lifetime) { + var entityType = builder.EntityType; + var keyType = builder.EntityKeyType; + + if (keyType == typeof(object)) { + var managerType = typeof(EntityManager<>).MakeGenericType(entityType); + builder.Services.TryAdd(new ServiceDescriptor(managerType, managerType, lifetime)); + } else { + var managerType = typeof(EntityManager<,>).MakeGenericType(entityType, keyType); + builder.Services.TryAdd(new ServiceDescriptor(managerType, managerType, lifetime)); + } + } + } +} diff --git a/src/Kista.Manager/RepositoryContextBuilderExtensions.cs b/src/Kista.Manager/RepositoryContextBuilderExtensions.cs index 564620b8..f193b035 100644 --- a/src/Kista.Manager/RepositoryContextBuilderExtensions.cs +++ b/src/Kista.Manager/RepositoryContextBuilderExtensions.cs @@ -46,9 +46,6 @@ public static RepositoryContextBuilder WithManagement( foreach (var entityType in builder.RegisteredEntityTypes) { var managerType = typeof(EntityManager<>).MakeGenericType(entityType); builder.Services.TryAdd(new ServiceDescriptor(managerType, managerType, lifetime)); - - var repositoryInterface = typeof(IRepository<>).MakeGenericType(entityType); - builder.Services.TryAdd(new ServiceDescriptor(managerType, managerType, lifetime)); } } diff --git a/src/Kista/RepositoryExtensions.cs b/src/Kista/RepositoryExtensions.cs index 0dd5ae81..b9173914 100644 --- a/src/Kista/RepositoryExtensions.cs +++ b/src/Kista/RepositoryExtensions.cs @@ -685,6 +685,7 @@ public static PageResult GetPage(this IRepositorytrue if any entity exists in the repository /// that matches the given filter, otherwise false. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask ExistsAsync(this IRepository repository, Expression> filter, CancellationToken cancellationToken = default) where TEntity : class => repository.ExistsAsync(new ExpressionQueryFilter(filter), cancellationToken); @@ -763,6 +764,7 @@ public static ValueTask ExistsAsync(this IRepository /// Thrown when the repository does not support querying or filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static bool Exists(this IRepository repository, IQueryFilter filter) where TEntity : class => repository.ExistsAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -789,6 +791,7 @@ public static bool Exists(this IRepository reposit /// otherwise it returns false. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static bool Exists(this IRepository repository, Expression> filter) where TEntity : class => Exists(repository, new ExpressionQueryFilter(filter)); @@ -817,6 +820,7 @@ public static bool Exists(this IRepository reposit /// /// Thrown when the repository does not support querying or filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask ExistsAsync(this IRepository repository, IQueryFilter filter, CancellationToken cancellationToken = default) where TEntity : class => ExistsAsync(repository, filter, cancellationToken); @@ -842,6 +846,7 @@ public static ValueTask ExistsAsync(this IRepository rep /// Returns true if any entity exists in the repository /// that matches the given filter, otherwise false. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask ExistsAsync(this IRepository repository, Expression> filter, CancellationToken cancellationToken = default) where TEntity : class => ExistsAsync(repository, filter, cancellationToken); @@ -872,6 +877,7 @@ public static ValueTask ExistsAsync(this IRepository rep /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static bool Exists(this IRepository repository, IQueryFilter filter) where TEntity : class => repository.ExistsAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -902,6 +908,7 @@ public static bool Exists(this IRepository repository, IQueryF /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static bool Exists(this IRepository repository, Expression> filter) where TEntity : class => repository.ExistsAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1038,6 +1045,7 @@ public static ValueTask CountAllAsync(this IRepository /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static long Count(this IRepository repository, Expression> filter) where TEntity : class => repository.CountAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1071,6 +1079,7 @@ public static long Count(this IRepository reposito /// /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static long Count(this IRepository repository, IQueryFilter filter) where TEntity : class => repository.CountAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1101,6 +1110,7 @@ public static long Count(this IRepository reposito /// /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static long Count(this IRepository repository, IQueryFilter filter) where TEntity : class => repository.CountAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1121,6 +1131,7 @@ public static long Count(this IRepository repository, IQueryFi /// Returns the number of entities in the repository. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static long CountAll(this IRepository repository) where TEntity : class => repository.CountAllAsync().ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1147,6 +1158,7 @@ public static long CountAll(this IRepository repos /// /// Thrown when the repository does not support querying or filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask CountAsync(this IRepository repository, Expression> filter, CancellationToken cancellationToken = default) where TEntity : class => CountAsync(repository, filter, cancellationToken); @@ -1170,6 +1182,7 @@ public static ValueTask CountAsync(this IRepository repo /// /// Thrown when the repository does not support querying or filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask CountAllAsync(this IRepository repository, CancellationToken cancellationToken = default) where TEntity : class => CountAllAsync(repository, cancellationToken); @@ -1198,6 +1211,7 @@ public static ValueTask CountAllAsync(this IRepository r /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static long Count(this IRepository repository, Expression> filter) where TEntity : class => repository.CountAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1223,6 +1237,7 @@ public static long Count(this IRepository repository, Expressi /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static long CountAll(this IRepository repository) where TEntity : class => repository.CountAllAsync().ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1353,6 +1368,7 @@ public static long CountAll(this IRepository repository) /// matched the given filter, or null if no entity matches /// the given filter. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask FindFirstAsync(this IRepository repository, IQueryFilter filter, CancellationToken cancellationToken = default) where TEntity : class => repository.FindFirstAsync(new Query(filter), cancellationToken); @@ -1381,6 +1397,7 @@ public static long CountAll(this IRepository repository) /// /// Thrown when the repository does not support filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static TEntity? FindFirst(this IRepository repository, IQueryFilter filter) where TEntity : class => repository.FindFirstAsync(new Query(filter)).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1412,6 +1429,7 @@ public static long CountAll(this IRepository repository) /// /// Thrown when the repository does not support filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask FindFirstAsync(this IRepository repository, Expression> filter, CancellationToken cancellationToken = default) where TEntity : class => repository.FindFirstAsync(Query.Where(filter), cancellationToken); @@ -1436,6 +1454,7 @@ public static long CountAll(this IRepository repository) /// is the first entity in the repository, or null if the /// repository is empty. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask FindFirstAsync(this IRepository repository, CancellationToken cancellationToken = default) where TEntity : class => repository.AsFilterable().FindFirstAsync(Query.Empty, cancellationToken); @@ -1471,6 +1490,7 @@ public static long CountAll(this IRepository repository) /// /// Thrown when the repository does not support querying. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static TEntity? FindFirst(this IRepository repository, IQuery query) where TEntity : class => repository.FindFirstAsync(query).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1506,6 +1526,7 @@ public static long CountAll(this IRepository repository) /// /// Thrown when the repository does not support filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static TEntity? FindFirst(this IRepository repository, Expression> filter) where TEntity : class => repository.FindFirstAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1535,6 +1556,7 @@ public static long CountAll(this IRepository repository) /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static TEntity? FindFirst(this IRepository repository) where TEntity : class => repository.FindFirstAsync().ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1562,6 +1584,7 @@ public static long CountAll(this IRepository repository) /// /// Thrown when the repository does not support querying. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask FindFirstAsync(this IRepository repository, IQuery query, CancellationToken cancellationToken = default) where TEntity : class => FindFirstAsync(repository, query, cancellationToken); @@ -1586,6 +1609,7 @@ public static long CountAll(this IRepository repository) /// Returns an instance of that matched the given /// filter, or null if no entity matches. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask FindFirstAsync(this IRepository repository, IQueryFilter filter, CancellationToken cancellationToken = default) where TEntity : class => FindFirstAsync(repository, filter, cancellationToken); @@ -1613,6 +1637,7 @@ public static long CountAll(this IRepository repository) /// /// Thrown when the repository does not support filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask FindFirstAsync(this IRepository repository, Expression> filter, CancellationToken cancellationToken = default) where TEntity : class => FindFirstAsync(repository, filter, cancellationToken); @@ -1634,6 +1659,7 @@ public static long CountAll(this IRepository repository) /// Returns an instance of that is the first /// entity in the repository, or null if the repository is empty. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask FindFirstAsync(this IRepository repository, CancellationToken cancellationToken = default) where TEntity : class => FindFirstAsync(repository, cancellationToken); @@ -1663,6 +1689,7 @@ public static long CountAll(this IRepository repository) /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static TEntity? FindFirst(this IRepository repository, IQuery query) where TEntity : class => repository.FindFirstAsync(query).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1692,6 +1719,7 @@ public static long CountAll(this IRepository repository) /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static TEntity? FindFirst(this IRepository repository, Expression> filter) where TEntity : class => repository.FindFirstAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1718,6 +1746,7 @@ public static long CountAll(this IRepository repository) /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static TEntity? FindFirst(this IRepository repository) where TEntity : class => repository.FindFirstAsync().ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1790,6 +1819,7 @@ public static ValueTask> FindAllAsync(this IReposi /// Returns a list of from /// the repository that match the given filter. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask> FindAllAsync(this IRepository repository, IQueryFilter filter, CancellationToken cancellationToken = default) where TEntity : class => repository.FindAllAsync(new Query(filter), cancellationToken); @@ -1817,6 +1847,7 @@ public static ValueTask> FindAllAsync(this IReposi /// Returns a list of from /// the repository that match the given filter. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask> FindAllAsync(this IRepository repository, Expression> filter, CancellationToken cancellationToken = default) where TEntity : class => repository.FindAllAsync(Query.Where(filter), cancellationToken); @@ -1843,6 +1874,7 @@ public static ValueTask> FindAllAsync(this IReposi /// /// Thrown when the repository does not support querying or filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask> FindAllAsync(this IRepository repository, CancellationToken cancellationToken = default) where TEntity : class => repository.FindAllAsync(Query.Empty, cancellationToken); @@ -1866,6 +1898,7 @@ public static ValueTask> FindAllAsync(this IReposi /// /// Thrown when the repository does not support querying or filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static IList FindAll(this IRepository repository) where TEntity : class => repository.FindAll(QueryFilter.Empty); @@ -1890,6 +1923,7 @@ public static IList FindAll(this IRepository from /// the repository that match the given filter. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static IList FindAll(this IRepository repository, IQueryFilter filter) where TEntity : class => repository.FindAllAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1925,6 +1959,7 @@ public static IList FindAll(this IRepository /// Thrown when the repository does not support querying or filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static IList FindAll(this IRepository repository, IQuery query) where TEntity : class => repository.FindAllAsync(query).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1957,6 +1992,7 @@ public static IList FindAll(this IRepository /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static IList FindAll(this IRepository repository, Expression> filter) where TEntity : class => repository.FindAllAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -1986,6 +2022,7 @@ public static IList FindAll(this IRepository /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static IList FindAll(this IRepository repository, IQueryFilter filter) where TEntity : class => repository.FindAllAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -2013,6 +2050,7 @@ public static IList FindAll(this IRepository reposito /// /// Thrown when the repository does not support querying or filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask> FindAllAsync(this IRepository repository, IQuery query, CancellationToken cancellationToken = default) where TEntity : class => FindAllAsync(repository, query, cancellationToken); @@ -2037,6 +2075,7 @@ public static ValueTask> FindAllAsync(this IRepository from /// the repository that match the given filter. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask> FindAllAsync(this IRepository repository, IQueryFilter filter, CancellationToken cancellationToken = default) where TEntity : class => FindAllAsync(repository, filter, cancellationToken); @@ -2061,6 +2100,7 @@ public static ValueTask> FindAllAsync(this IRepository from /// the repository that match the given filter. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask> FindAllAsync(this IRepository repository, Expression> filter, CancellationToken cancellationToken = default) where TEntity : class => FindAllAsync(repository, filter, cancellationToken); @@ -2085,6 +2125,7 @@ public static ValueTask> FindAllAsync(this IRepository /// Thrown when the repository does not support querying or filtering. /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static ValueTask> FindAllAsync(this IRepository repository, CancellationToken cancellationToken = default) where TEntity : class => FindAllAsync(repository, cancellationToken); @@ -2114,6 +2155,7 @@ public static ValueTask> FindAllAsync(this IRepository /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static IList FindAll(this IRepository repository, IQuery query) where TEntity : class => repository.FindAllAsync(query).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -2143,6 +2185,7 @@ public static IList FindAll(this IRepository reposito /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static IList FindAll(this IRepository repository, Expression> filter) where TEntity : class => repository.FindAllAsync(filter).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -2169,6 +2212,7 @@ public static IList FindAll(this IRepository reposito /// in async contexts to avoid potential deadlocks. /// /// + [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)] public static IList FindAll(this IRepository repository) where TEntity : class => repository.FindAllAsync().ConfigureAwait(false).GetAwaiter().GetResult(); diff --git a/src/Kista/Repository_T2.cs b/src/Kista/Repository_T2.cs index adab7fc8..bc5e53c2 100644 --- a/src/Kista/Repository_T2.cs +++ b/src/Kista/Repository_T2.cs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Linq.Expressions; + using System; namespace Kista { @@ -42,7 +44,9 @@ namespace Kista { /// FindFirstAsync(IQuery, CancellationToken) and /// FindAllAsync(IQuery, CancellationToken) methods that /// unpack the query, apply sorting/filtering/pagination through the engine - /// hatch, and surface the result. Engine-specific async execution can be + /// hatch, and surface the result. For convenience, each method also has a + /// Expression<Func<TEntity, bool>>-based overload that + /// automatically wraps the predicate in the appropriate filter or query. Engine-specific async execution can be /// customised by overriding and /// . /// @@ -349,6 +353,27 @@ protected virtual ValueTask ExistsAsync(IQueryFilter filter, CancellationT throw new NotSupportedException("Filtering requires IQueryable support or a subclass override."); } + /// + /// Checks if an entity exists in the repository that matches the given + /// predicate expression. + /// + /// + /// The predicate expression used to check the existence of any matching entity. + /// + /// + /// A token used to cancel the operation. + /// + /// + /// Returns true if any entity exists in the repository that matches + /// the given predicate, otherwise false. + /// + /// + /// Thrown when is false and the subclass + /// has not overridden this method. + /// + protected virtual ValueTask ExistsAsync(Expression> predicate, CancellationToken cancellationToken = default) + => ExistsAsync(new ExpressionQueryFilter(predicate), cancellationToken); + /// /// Counts the number of items in the repository matching the given /// filtering conditions. @@ -383,6 +408,26 @@ protected virtual ValueTask CountAsync(IQueryFilter filter, CancellationTo throw new NotSupportedException("Filtering requires IQueryable support or a subclass override."); } + /// + /// Counts the number of items in the repository that match the given + /// predicate expression. + /// + /// + /// The predicate expression used to count the matching entities. + /// + /// + /// A token used to cancel the operation. + /// + /// + /// Returns the total count of items matching the given predicate. + /// + /// + /// Thrown when is false and the subclass + /// has not overridden this method. + /// + protected virtual ValueTask CountAsync(Expression> predicate, CancellationToken cancellationToken = default) + => CountAsync(new ExpressionQueryFilter(predicate), cancellationToken); + /// /// Finds the first item in the repository that matches the given query. /// @@ -413,6 +458,27 @@ protected virtual ValueTask CountAsync(IQueryFilter filter, CancellationTo throw new NotSupportedException("Querying requires IQueryable support or a subclass override."); } + /// + /// Finds the first item in the repository that matches the given + /// predicate expression. + /// + /// + /// The predicate expression used to identify the item to return. + /// + /// + /// A token used to cancel the operation. + /// + /// + /// Returns the first item in the repository that matches the given predicate, + /// or null if none of the items matches the condition. + /// + /// + /// Thrown when is false and the subclass + /// has not overridden this method. + /// + protected virtual ValueTask FindFirstAsync(Expression> predicate, CancellationToken cancellationToken = default) + => FindFirstAsync(Kista.Query.Where(predicate), cancellationToken); + /// /// Finds all the items in the repository that match the given filtering condition. /// @@ -443,6 +509,27 @@ protected virtual ValueTask> FindAllAsync(IQuery query, Cancellat throw new NotSupportedException("Querying requires IQueryable support or a subclass override."); } + /// + /// Finds all the items in the repository that match the given + /// predicate expression. + /// + /// + /// The predicate expression used to identify the items to return. + /// + /// + /// A token used to cancel the operation. + /// + /// + /// Returns a list of items in the repository that match the given predicate, + /// or an empty list if none of the items matches the condition. + /// + /// + /// Thrown when is false and the subclass + /// has not overridden this method. + /// + protected virtual ValueTask> FindAllAsync(Expression> predicate, CancellationToken cancellationToken = default) + => FindAllAsync(Kista.Query.Where(predicate), cancellationToken); + /// public abstract ValueTask AddAsync(TEntity entity, CancellationToken cancellationToken = default); diff --git a/test/Kista.Context.XUnit/Unit/CacheTestFixtures.cs b/test/Kista.Context.XUnit/Unit/CacheTestFixtures.cs new file mode 100644 index 00000000..f9678d3d --- /dev/null +++ b/test/Kista.Context.XUnit/Unit/CacheTestFixtures.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace Kista; + +public class CachingTestEntity { + [Key] + public string Id { get; set; } = Guid.NewGuid().ToString(); +} + +public class CachingTestRepository : IRepository { + public ValueTask AddAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask UpdateAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); + public ValueTask RemoveAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); + public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((CachingTestEntity?)null); + public object? GetEntityKey(CachingTestEntity entity) => (object?)entity.Id; + public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); +} + +public class SecondCachingEntity { + [Key] + public string Id { get; set; } = Guid.NewGuid().ToString(); +} + +public class SecondCachingRepository : IRepository { + public ValueTask AddAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask UpdateAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); + public ValueTask RemoveAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); + public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((SecondCachingEntity?)null); + public object? GetEntityKey(SecondCachingEntity entity) => (object?)entity.Id; + public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); +} diff --git a/test/Kista.Context.XUnit/Unit/DistributedCacheExtensionTests.cs b/test/Kista.Context.XUnit/Unit/DistributedCacheExtensionTests.cs index a4a08e25..7375a4d7 100644 --- a/test/Kista.Context.XUnit/Unit/DistributedCacheExtensionTests.cs +++ b/test/Kista.Context.XUnit/Unit/DistributedCacheExtensionTests.cs @@ -1,5 +1,3 @@ -using System.ComponentModel.DataAnnotations; - using Kista.Caching; using Microsoft.Extensions.DependencyInjection; @@ -137,35 +135,4 @@ public void WithDistributedCaching_ResolvesEntityCache_WhenProviderBuilt() { Assert.NotNull(provider.GetService>()); } - public class SecondCachingEntity { - [Key] - public string Id { get; set; } = Guid.NewGuid().ToString(); - } - - public class SecondCachingRepository : IRepository { - public ValueTask AddAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask UpdateAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((SecondCachingEntity?)null); - public object? GetEntityKey(SecondCachingEntity entity) => (object?)entity.Id; - public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - } - - public class CachingTestEntity { - [Key] - public string Id { get; set; } = Guid.NewGuid().ToString(); - } - - public class CachingTestRepository : IRepository { - public ValueTask AddAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask UpdateAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((CachingTestEntity?)null); - public object? GetEntityKey(CachingTestEntity entity) => (object?)entity.Id; - public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - } } diff --git a/test/Kista.Context.XUnit/Unit/EasyCachingExtensionTests.cs b/test/Kista.Context.XUnit/Unit/EasyCachingExtensionTests.cs index bcbe9bd4..482a2818 100644 --- a/test/Kista.Context.XUnit/Unit/EasyCachingExtensionTests.cs +++ b/test/Kista.Context.XUnit/Unit/EasyCachingExtensionTests.cs @@ -1,5 +1,3 @@ -using System.ComponentModel.DataAnnotations; - using Kista.Caching; using Microsoft.Extensions.DependencyInjection; @@ -74,35 +72,4 @@ public void WithEasyCaching_WithOptionsAppliesToAllEntityTypes() { Assert.NotNull(optionsForSecond); } - public class SecondCachingEntity { - [Key] - public string Id { get; set; } = Guid.NewGuid().ToString(); - } - - public class SecondCachingRepository : IRepository { - public ValueTask AddAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask UpdateAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((SecondCachingEntity?)null); - public object? GetEntityKey(SecondCachingEntity entity) => (object?)entity.Id; - public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - } - - public class CachingTestEntity { - [Key] - public string Id { get; set; } = Guid.NewGuid().ToString(); - } - - public class CachingTestRepository : IRepository { - public ValueTask AddAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask UpdateAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((CachingTestEntity?)null); - public object? GetEntityKey(CachingTestEntity entity) => (object?)entity.Id; - public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - } } diff --git a/test/Kista.Context.XUnit/Unit/EntityManagerBuilderTests.cs b/test/Kista.Context.XUnit/Unit/EntityManagerBuilderTests.cs new file mode 100644 index 00000000..db9920c0 --- /dev/null +++ b/test/Kista.Context.XUnit/Unit/EntityManagerBuilderTests.cs @@ -0,0 +1,184 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.CompilerServices; +using Deveel; +using Kista.Caching; + +using Microsoft.Extensions.DependencyInjection; + +namespace Kista; + +[Trait("Category", "Unit")] +[Trait("Layer", "Application")] +[Trait("Feature", "EntityManager")] +public class EntityManagerBuilderTests { + [Fact] + public void WithManagement_RegistersEntityManager() { + var services = new ServiceCollection(); + services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement()); + + var provider = services.BuildServiceProvider(); + var manager = provider.GetService>(); + + Assert.NotNull(manager); + } + + [Fact] + public void WithManagement_WithoutCallback_ReturnsRepositoryBuilder() { + var services = new ServiceCollection(); + var builder = services.AddRepositoryContext(); + var repoBuilder = builder.AddRepository(); + var result = repoBuilder.WithManagement(); + + Assert.Same(repoBuilder, result); + } + + [Fact] + public void WithManagement_WithCallback_ReturnsRepositoryBuilder() { + var services = new ServiceCollection(); + var builder = services.AddRepositoryContext(); + var repoBuilder = builder.AddRepository(); + var result = repoBuilder.WithManagement(mgmt => { }); + + Assert.Same(repoBuilder, result); + } + + [Fact] + public void WithManagement_RegistersValidator() { + var services = new ServiceCollection(); + services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt + .WithValidator())); + + var provider = services.BuildServiceProvider(); + var validator = provider.GetService>(); + + Assert.NotNull(validator); + Assert.IsType(validator); + } + + [Fact] + public void WithManagement_RegistersCacheKeyGenerator() { + var services = new ServiceCollection(); + services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt + .WithCacheKeyGenerator())); + + var provider = services.BuildServiceProvider(); + var generator = provider.GetService>(); + + Assert.NotNull(generator); + Assert.IsType(generator); + } + + [Fact] + public void WithManagement_RegistersOperationErrorFactory() { + var services = new ServiceCollection(); + services.AddRepositoryContext() + .AddRepository(repo => repo + .WithManagement(mgmt => mgmt + .WithOperationErrorFactory())); + + var provider = services.BuildServiceProvider(); + var factory = provider.GetService>(); + + Assert.NotNull(factory); + } + + [Fact] + public void GlobalWithManagement_StillWorks() { + var services = new ServiceCollection(); + services.AddRepositoryContext() + .AddRepository(_ => { }) + .WithManagement(); + + var provider = services.BuildServiceProvider(); + var manager = provider.GetService>(); + + Assert.NotNull(manager); + } + + [Fact] + public void GlobalWithManagement_Callback_StillWorks() { + var services = new ServiceCollection(); + services.AddRepositoryContext() + .AddRepository(_ => { }) + .WithManagement(opts => { }); + + var provider = services.BuildServiceProvider(); + var manager = provider.GetService>(); + + Assert.NotNull(manager); + } + + #region Test Types + + public class TestPersonEntity { + [Key] + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string? Name { get; set; } + } + + public class TestPersonRepository : IRepository { + private readonly List _entities = new(); + + public ValueTask AddAsync(TestPersonEntity entity, CancellationToken cancellationToken = default) { + _entities.Add(entity); + return ValueTask.CompletedTask; + } + + public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) { + _entities.AddRange(entities); + return ValueTask.CompletedTask; + } + + public ValueTask UpdateAsync(TestPersonEntity entity, CancellationToken cancellationToken = default) { + var idx = _entities.FindIndex(e => e.Id == entity.Id); + if (idx < 0) return new ValueTask(false); + _entities[idx] = entity; + return new ValueTask(true); + } + + public ValueTask RemoveAsync(TestPersonEntity entity, CancellationToken cancellationToken = default) { + return new ValueTask(_entities.Remove(entity)); + } + + public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) { + foreach (var e in entities) _entities.Remove(e); + return ValueTask.CompletedTask; + } + + public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) { + return new ValueTask(_entities.FirstOrDefault(e => e.Id == (string)key)); + } + + public object? GetEntityKey(TestPersonEntity entity) => entity.Id; + + public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) { + var items = _entities.Skip(request.Offset).Take(request.Size).ToList(); + return new ValueTask>(new PageResult(request, _entities.Count, items)); + } + } + + public class TestPersonValidator : IEntityValidator { + public async IAsyncEnumerable ValidateAsync( + EntityManager manager, + TestPersonEntity entity, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { + yield break; + } + } + + public class TestPersonKeyGenerator : IEntityCacheKeyGenerator { + public string GenerateKey(object key) => $"person:{key}"; + public string[] GenerateAllKeys(TestPersonEntity entity) => [$"person:{entity.Id}"]; + } + + public class TestPersonErrorFactory : OperationErrorFactory { + } + + #endregion +} diff --git a/test/Kista.Context.XUnit/Unit/FusionCacheExtensionTests.cs b/test/Kista.Context.XUnit/Unit/FusionCacheExtensionTests.cs index 4d7ed5c1..5f93adda 100644 --- a/test/Kista.Context.XUnit/Unit/FusionCacheExtensionTests.cs +++ b/test/Kista.Context.XUnit/Unit/FusionCacheExtensionTests.cs @@ -1,5 +1,3 @@ -using System.ComponentModel.DataAnnotations; - using Kista.Caching; using Microsoft.Extensions.Caching.Memory; @@ -168,35 +166,4 @@ public void WithFusionCaching_ResolvesEntityCache_WhenProviderBuilt() { Assert.NotNull(provider.GetService>()); } - public class SecondCachingEntity { - [Key] - public string Id { get; set; } = Guid.NewGuid().ToString(); - } - - public class SecondCachingRepository : IRepository { - public ValueTask AddAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask UpdateAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((SecondCachingEntity?)null); - public object? GetEntityKey(SecondCachingEntity entity) => (object?)entity.Id; - public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - } - - public class CachingTestEntity { - [Key] - public string Id { get; set; } = Guid.NewGuid().ToString(); - } - - public class CachingTestRepository : IRepository { - public ValueTask AddAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask UpdateAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((CachingTestEntity?)null); - public object? GetEntityKey(CachingTestEntity entity) => (object?)entity.Id; - public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - } } diff --git a/test/Kista.Context.XUnit/Unit/MemoryCacheExtensionTests.cs b/test/Kista.Context.XUnit/Unit/MemoryCacheExtensionTests.cs index 2d44e614..bb6f7977 100644 --- a/test/Kista.Context.XUnit/Unit/MemoryCacheExtensionTests.cs +++ b/test/Kista.Context.XUnit/Unit/MemoryCacheExtensionTests.cs @@ -1,5 +1,3 @@ -using System.ComponentModel.DataAnnotations; - using Kista.Caching; using Microsoft.Extensions.DependencyInjection; @@ -141,35 +139,4 @@ public void WithMemoryCaching_ResolvesEntityCache_WhenProviderBuilt() { Assert.NotNull(provider.GetService>()); } - public class SecondCachingEntity { - [Key] - public string Id { get; set; } = Guid.NewGuid().ToString(); - } - - public class SecondCachingRepository : IRepository { - public ValueTask AddAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask UpdateAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveAsync(SecondCachingEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((SecondCachingEntity?)null); - public object? GetEntityKey(SecondCachingEntity entity) => (object?)entity.Id; - public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - } - - public class CachingTestEntity { - [Key] - public string Id { get; set; } = Guid.NewGuid().ToString(); - } - - public class CachingTestRepository : IRepository { - public ValueTask AddAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask UpdateAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveAsync(CachingTestEntity entity, CancellationToken cancellationToken = default) => new(false); - public ValueTask RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask FindAsync(object key, CancellationToken cancellationToken = default) => new((CachingTestEntity?)null); - public object? GetEntityKey(CachingTestEntity entity) => (object?)entity.Id; - public ValueTask> GetPageAsync(PageRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - } } diff --git a/test/Kista.DynamicLinq.XUnit/Unit/FilterableRepositoryTests.cs b/test/Kista.DynamicLinq.XUnit/Unit/FilterableRepositoryTests.cs index 077a2eb1..34b51096 100644 --- a/test/Kista.DynamicLinq.XUnit/Unit/FilterableRepositoryTests.cs +++ b/test/Kista.DynamicLinq.XUnit/Unit/FilterableRepositoryTests.cs @@ -1,4 +1,6 @@ -namespace Kista; +#pragma warning disable CS0618 + +namespace Kista; [Trait("Category", "Unit")] [Trait("Layer", "Infrastructure")] diff --git a/test/Kista.Testing.TUnit/Fixtures/IPerson.cs b/test/Kista.Testing.TUnit/Fixtures/IPerson.cs deleted file mode 100644 index c71cad16..00000000 --- a/test/Kista.Testing.TUnit/Fixtures/IPerson.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Kista { - public interface IPerson : IHaveTimeStamp where TKey : notnull { - TKey? Id { get; set; } - - string FirstName { get; set; } - - string LastName { get; set; } - - string? Email { get; set; } - - DateTime? DateOfBirth { get; set; } - - string? PhoneNumber { get; set; } - - IEnumerable Relationships { get; } - } - - public interface IPerson : IPerson { - } -} diff --git a/test/Kista.Testing.TUnit/Fixtures/IRelationship.cs b/test/Kista.Testing.TUnit/Fixtures/IRelationship.cs deleted file mode 100644 index 35163b42..00000000 --- a/test/Kista.Testing.TUnit/Fixtures/IRelationship.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Kista { - public interface IRelationship { - string Type { get; set; } - - string FullName { get; set; } - } -} diff --git a/test/Kista.Testing.TUnit/Fixtures/TestTime.cs b/test/Kista.Testing.TUnit/Fixtures/TestTime.cs deleted file mode 100644 index ad80464f..00000000 --- a/test/Kista.Testing.TUnit/Fixtures/TestTime.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Kista { - public readonly struct TestTime : ISystemTime { - public TestTime() { - var now = DateTimeOffset.UtcNow; - UtcNow = now; - Now = now.ToLocalTime(); - } - - public DateTimeOffset UtcNow { get; } - - public DateTimeOffset Now { get; } - } -} diff --git a/test/Kista.Testing.TUnit/Kista.Testing.TUnit.csproj b/test/Kista.Testing.TUnit/Kista.Testing.TUnit.csproj index 78eab91a..8ed1d075 100644 --- a/test/Kista.Testing.TUnit/Kista.Testing.TUnit.csproj +++ b/test/Kista.Testing.TUnit/Kista.Testing.TUnit.csproj @@ -12,6 +12,12 @@ + + + + + +