Skip to content

FEATURE: Query Results and validations.#105

Draft
CesarD wants to merge 1 commit intomainfrom
feat/query_results
Draft

FEATURE: Query Results and validations.#105
CesarD wants to merge 1 commit intomainfrom
feat/query_results

Conversation

@CesarD
Copy link
Copy Markdown
Collaborator

@CesarD CesarD commented May 8, 2026

Description

Incorporate query validations and result pattern to better handle their outcome.

Motivation and Context

Just like with the Commands, it's been more evident that the handling of the queries sometimes requires some validations, such as:

  • Unknown/extra query params (like analytics, etc) => They can be silently ignored
  • Invalid sort field names => They need to be rejected with 400 Bad Request
  • Out-of-range Limit/Offset => They need to be rejected with 400 Bad Request
  • Wrong type for known filter = They need to be rejected with 400 Bad Request
  • Missing optional parameters => Apply defaults
    For all this, the inclusion of some default validators and a refactor of the current base Query classes to better integrate some mechanisms (like MappedFields for filtering and sorting) and the way to handle their results is needed. Hence.

How Has This Been Tested?

The same test suite has been used to ensure that nothing has been broken and also the same tests have been improved and adapted for the new results being returned

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a QueryResult<T> result pattern (similar in spirit to the existing command result pattern) and adds default query validation (paging, sorting, filter-type validation) so queries can return ValidationProblem (400) or NotFound (404) consistently from API endpoints.

Changes:

  • Add QueryResult<T> union types and refactor query base classes to return QueryResult<...> (including paged and by-id queries).
  • Add FluentValidation validators + a MediatR pipeline behavior intended to short-circuit queries on validation failures.
  • Split mediator HTTP mapping helpers into command/query-specific extensions and update endpoints + application tests to the new query result pattern.

Reviewed changes

Copilot reviewed 35 out of 35 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Infrastructure/Context/Extensions/FilterExtensions.cs Exposes helper methods used by new query validators for filter datatype checks.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs Adds paging + sort-field validation rules for paged queries.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs Adds sort-field validation and filter value type validation based on mapped fields.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryResult.cs Introduces QueryResult<T> (Success / NotFound / ValidationFailure).
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryPagedBase.cs Refactors paged queries to return QueryResult<Page<T>> and centralizes paging parsing via IPagedQuery.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryByIdBase.cs Refactors by-id query base to return a non-null IRequest<T> (now typically QueryResult<T>).
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs Refactors base query typing and adds mapped-field hooks for filtering/sorting in entity-backed queries.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryExtensions.cs Updates EF query execution helpers to return QueryResult<T> and support the new query base types.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs Adds DI registration helper intended to wire query validation pipeline + default validators.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Contracts/IPagedQuery.cs Adds shared parsing helpers and max limit for paged queries.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Behaviors/QueryValidationBehavior.cs Adds MediatR pipeline behavior that aggregates FluentValidation errors into QueryResult.ValidationFailure.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs Adds query-to-HTTP mapping extensions for QueryResult<T> including ValidationProblem in some overloads.
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorExtensions.cs Removes the old combined command/query mediator helpers (superseded by new split extensions).
src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorCommandExtensions.cs Adds command-only mediator helpers (extracted from the removed combined file).
src/Content/Backend/Solution/Monaco.Template.Backend.ArchitectureTests/ApplicationTests.cs Narrows validator discovery to the application assembly for architecture constraints.
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductPage.cs Updates product page query to entity-backed paged base + mapped fields + QueryResult<Page<ProductDto>>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductById.cs Updates product by-id query to return QueryResult<ProductDto> and expands includes.
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/Extensions/ProductExtensions.cs Makes mapping extensions non-nullable and removes mapped-field helper (now on query types).
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/DownloadProductPicture.cs Updates download query to return QueryResult<FileDownloadDto> and map NotFound explicitly.
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryList.cs Updates list query to entity-backed QueryBase<,> + mapped fields + QueryResult<List<CountryDto>>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryById.cs Updates by-id query to return QueryResult<CountryDto>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/Extensions/CountryExtensions.cs Makes mapping extensions non-nullable and removes mapped-field helper (now on query types).
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyPage.cs Updates company page query to entity-backed paged base + mapped fields + QueryResult<Page<CompanyDto>>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs Updates company by-id query to return QueryResult<CompanyDto>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/Extensions/CompanyExtensions.cs Makes mapping extensions non-nullable; keeps mapped-field helper for companies.
src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductPageTests.cs Updates tests to assert QueryResult.Success<Page<ProductDto>>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductByIdTests.cs Updates tests to assert Success<ProductDto> / NotFound<ProductDto>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/DownloadProductPictureTests.cs Updates tests to assert Success<FileDownloadDto> / NotFound<FileDownloadDto>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryListTests.cs Updates tests to assert Success<List<CountryDto>>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs Updates tests to assert Success<CountryDto> / NotFound<CountryDto>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyPageTests.cs Updates tests to assert Success<Page<CompanyDto>>.
src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyByIdTests.cs Updates tests to assert Success<CompanyDto> / NotFound<CompanyDto>.
src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs Updates endpoints to new query result-to-HTTP mapping (including ValidationProblem for list).
src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs Updates endpoints to new query result-to-HTTP mapping (including ValidationProblem for list).
src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs Updates endpoints to new query result-to-HTTP mapping (including ValidationProblem for page).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +50 to +71
private static void RegisterDefaultValidators(IServiceCollection services, Type queryType)
{
var validatorInterface = typeof(IValidator<>).MakeGenericType(queryType);
var baseType = queryType.BaseType;

while (baseType is not null && baseType != typeof(object))
{
if (baseType.IsGenericType)
{
var found = true;
switch (baseType.GetGenericTypeDefinition())
{
case var t when t == typeof(QueryPagedBase<,>):
services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments));
services.AddScoped(validatorInterface, typeof(QueryBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments));
break;
case var t when t == typeof(QueryPagedBase<>):
services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<>).MakeGenericType(baseType.GenericTypeArguments));
break;
case var t when t == typeof(QueryBase<,>):
services.AddScoped(validatorInterface, typeof(QueryBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments));
break;
Comment on lines +10 to +47
public static class QueryBehaviorExtensions
{
extension(IServiceCollection services)
{
/// <summary>
/// Scans the assembly for query types returning <see cref="QueryResult{T}"/>, registers
/// <see cref="QueryValidationBehavior{TQuery,TResult}"/> for each, and automatically registers
/// the appropriate default base validators (<see cref="QueryPagedBaseValidator{T}"/> or
/// <see cref="QueryPagedBaseValidator{T,TEntity}"/>).
/// Specific validators already registered in DI will also be picked up by the behavior.
/// </summary>
public IServiceCollection RegisterQueryValidationBehaviors(Assembly assembly)
{
var queryTypes = assembly.GetTypes()
.Where(t => !t.IsAbstract &&
t.GetInterfaces().Any(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IRequest<>) &&
i.GenericTypeArguments[0].IsGenericType &&
i.GenericTypeArguments[0].GetGenericTypeDefinition() == typeof(QueryResult<>)));

foreach (var queryType in queryTypes)
{
var resultType = queryType.GetInterfaces()
.First(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IRequest<>))
.GenericTypeArguments[0] // QueryResult<T>
.GenericTypeArguments[0]; // T

// Register the behavior
services.AddScoped(typeof(IPipelineBehavior<,>).MakeGenericType(queryType, typeof(QueryResult<>).MakeGenericType(resultType)),
typeof(QueryValidationBehavior<,>).MakeGenericType(queryType, resultType));

// Register default base validators
RegisterDefaultValidators(services, queryType);
}

return services;
}
Comment on lines +21 to +30
RuleFor(x => x.QueryParams)
.Must(p => !TryGetInt(p, nameof(Pager.Offset), out var v) ||
v >= 0)
.WithMessage($"{nameof(Pager.Offset)} must be greater than or equal to 0.");

RuleFor(x => x.QueryParams)
.Must(p => !TryGetInt(p, nameof(Pager.Limit), out var v) ||
v is > 0 and <= IPagedQuery.MaxLimit)
.WithMessage($"{nameof(Pager.Limit)} must be between 1 and {IPagedQuery.MaxLimit}.");
}
Comment on lines +111 to +120
private static Results<Ok<TResult>, NotFound> OkOrNotFound<TResult>(QueryResult<TResult> result) =>
ResponseOrNotFound(result, TypedResults.Ok);

private static Results<TResponse, NotFound> ResponseOrNotFound<TResult, TResponse>(QueryResult<TResult> result, Func<TResult, TResponse> func) where TResponse : IResult =>
result switch
{
Success<TResult> success => func(success.Result),
Common.Application.Queries.NotFound<TResult> => TypedResults.NotFound(),
_ => throw new InvalidOperationException($"Unexpected query result type '{result.GetType().FullName ?? "null"}' returned.")
};
.SingleOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
return item.Map(true);

return QueryResult<CompanyDto>.SuccessOrNotFound(item?.Map());
@CesarD CesarD marked this pull request as draft May 8, 2026 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants