Skip to content

feat: validate the whole query in IsValid(out errors), fix contains on non-string fields (#332) - #346

Merged
alirezanet merged 5 commits into
masterfrom
fix/332-isvalid-errors-ordering
Sep 17, 2026
Merged

alirezanet merged 5 commits into
masterfrom
fix/332-isvalid-errors-ordering

Conversation

@alirezanet

@alirezanet alirezanet commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Fixes #332

The bug

2.18.0 added IsValid(out List<string> validationErrors) for IGridifyFiltering, but nothing equivalent for IGridifyOrdering or IGridifyQuery. IGridifyQuery inherits from both, so with no IGridifyQuery overload to bind to, the error collecting call on a GridifyQuery resolved to the filtering extension and validated Filter only.

Two overloads of the same method, on the same object, disagreed — verified on master:

var mapper = new GridifyMapper<Person>()
   .AddMap("Name", x => x.Name)
   .AddMap("Age", x => x.Age);

var q = new GridifyQuery(1, 100, "Name=Ivan", "ageee");

q.IsValid(out var errors, mapper);  // True,  errors == []   <-- the bug
q.IsValid(mapper);                  // False

That is the shape that bites: code using the error-reporting overload to build a validation response waves a bad OrderBy straight through, and the failure resurfaces later as a runtime error.

The fix

Two new overloads, mirroring the existing filtering one:

public static bool IsValid<T>(this IGridifyOrdering ordering, out List<string> validationErrors, IGridifyMapper<T>? mapper = null)
public static bool IsValid<T>(this IGridifyQuery gridifyQuery, out List<string> validationErrors, IGridifyMapper<T>? mapper = null)
  • Ordering reports an unmapped field as Field 'x' is not mapped, the same wording the filtering validation already uses, and captures the message of the GridifyOrderingException that a malformed ordering throws ("Age bogus" → Ordering validation error: Invalid keyword. expected 'desc' or 'asc').
  • Query validates both parts and concatenates their errors. It deliberately does not short-circuit, so the caller gets every error instead of only the ones from Filter. The boolean IGridifyQuery overload keeps its &&, since it only has to produce the verdict.
  • IGridifyOrdering.IsValid(mapper) now delegates to the new overload and discards the errors, exactly as the filtering one already did.

After: False with ["Field 'ageee' is not mapped"], agreeing with the boolean overload.

One detail worth knowing

SyntaxTree.ParseOrderings is a lazy iterator that throws part way through enumeration, so the foreach has to stay inside the try — orders.Any(...) outside it would let the exception escape. Errors collected from fields before the throw are kept, which IsValid_WithErrors_OnOrdering_WithErrorsBeforeAMalformedOrdering_KeepsBoth pins.

Tests

Gridify.Tests 464, full solution 743, all six TFMs build.

The one that matters is a theory asserting the two overloads never disagree, across good, bad and null combinations of Filter and OrderBy — that property is the bug. Plus a theory covering ordering shapes (desc, multiple fields, the ? / ! null-check suffixes, empty, whitespace, null, unmapped), the malformed-ordering message, error concatenation from both parts, and the auto-generated mapper path.

Three further validation gaps closed

Cases where IsValid reported a query valid and the builder then threw. Each was found by running the verdict against real ApplyFiltering / ApplyOrdering calls, and each is now checked the way the builder checks it.

before now
AllowNullSearch off on the mapper, on globally, Rank=null valid, then ArgumentException invalid
AllowNullSearch on the mapper, off globally, Rank=null invalid, but ran fine valid
OrderBy = "Age?" on an int valid, then GridifyOrderingException invalid
OrderBy = "Name?" on a string valid, then GridifyOrderingException invalid
Filter = "Age=*5" on an int valid, then ArgumentException valid, and it runs (builder fixed)
  • AllowNullSearch was read from GridifyGlobalConfiguration while BaseQueryBuilder.BuildQuery reads mapper.Configuration, so a mapper built with a different value validated under the wrong rule — wrong in both directions. One line.
  • The ? / ! ordering suffixes order by the member's HasValue, which LinqSortingQueryBuilder.GetOrderExpression can only build for a Nullable<> member — note a reference type does not qualify either, so Name? on a string throws too. Ordering validation previously only checked that the field was mapped.
  • Like / NotLike called string.Contains on the member itself, so Age=*5 threw while the sibling Age^3 worked. Fixed in the builder, not in validation — see below.

One existing theory case asserted "IntProperty?, StringProperty!" was valid — it never was at execution time, so it now covers nullable members instead.

The contains operator is fixed in the builder, not in validation

Like and NotLike called string.Contains on the member, so Age=*5 on an int threw ArgumentException — while Age^3 and Age$5 worked, because StartsWith and EndsWith have a ToString() fallback. Like was simply missing it.

An earlier revision of this PR made validation reject the operator on non-string fields. That was the wrong side: validation exists to predict what the builder does, so making it stricter than the builder hides the gap instead of closing it. Like and NotLike now fall back to ToString() exactly as the two cases immediately below them do, and the validation rejection is gone.

source.ApplyFiltering("IntProperty=*31");   // matches 315, previously threw
source.ApplyFiltering("IntProperty!*31");   // matches 25

The risk with a ToString() fallback is a provider that cannot translate it — that would be worse than the exception it replaced. So it is pinned by tests in every builder, not asserted in prose:

builder Id=*5 becomes test
SQL Server CONVERT(varchar(11), [u].[Id]) LIKE @p Issue332ContainsOnNonStringTests
PostgreSQL u."Id"::text LIKE @p Issue332ContainsOnNonStringPostgreSqlTests
Elasticsearch {"wildcard":{"MyInt":{"value":"*5*"}}} ApplyFiltering_WhenTextOperatorIsUsedOnNonStringField_ShouldMatchItsText
LINQ member.ToString().Contains("5") ApplyFiltering_WithContainsOperatorOnANonStringField_ComparesItsText

The EF tests use ToQueryString(), so they need no database and run in CI. Each covers ^ and $ alongside =* / !*, since all four produce the identical SQL shape and should not drift apart.

Elasticsearch already behaved this way for all four operators, so this change makes the LINQ builder agree with it rather than introducing a divergence.

Also verified unchanged for string members, for the case-insensitive /i suffix, and with EntityFrameworkCompatibilityLayer on. A nullable member with no value has empty text and simply does not match, rather than throwing.

Two limitations remain, both shared with StartsWith / EndsWith and neither new:

  • the value is still converted to the member's type before the operator runs, so When=*2024 on a DateTime cannot match and is still reported invalid;
  • the /i suffix only lowercases a string member, so it has no effect on the text of a non-string one.

Still not validated

None of these can be checked without either an IQueryable in hand or guessing at another subsystem's rules, so they are left alone and listed for separate issues:

  • Ordering by a member that is mapped but not orderable — a collection, a dictionary, a Select(...) projection. Validates true, throws InvalidOperationException.
  • Values behind nested-collection and indexed maps: ExtractPropertyType returns null for those bodies, so the value is never checked at all.
  • A convertor returning the string "null", which the builder nullifies but validation does not model.
  • Elasticsearch: ElasticsearchQueryBuilder emits the raw token for most operators, so its behaviour differs from the LINQ builder this validation mirrors.

Notes

  • Purely additive to the public API; no existing signature or behaviour changes. A call that previously bound to the filtering overload on an IGridifyQuery-typed value now binds to the query overload and so validates more — which is the point of the issue, but it does mean a query with a bad OrderBy that used to report valid will now report invalid.
  • Docs: the IsValid guide gains the OrderBy example, the per-part overloads, and a note that the error collecting overload covers both parts.
  • No version bump, following feat(mapper): add case-insensitive per-map override #336.

An unmapped field is reported once

Naively concatenating the two lists reported the identical message twice when the same field was unmapped in both parts, and ordering on the same unmapped field twice did the same. The filtering side already reported one error per distinct field, so ordering now matches it: the ordering overload skips a message it has already collected, and the query overload appends only what the filter did not already report. Distinct fields are still reported separately.

Filter / OrderBy before now
nope=1 / nope 2 identical errors 1
- / nope, nope 2 identical errors 1
- / nope, nope desc 2 identical errors 1
aaa=1 / bbb 2 errors 2 errors

2.18.0 added IsValid(out List<string> validationErrors) for IGridifyFiltering,
but nothing equivalent for IGridifyOrdering or IGridifyQuery. Since
IGridifyQuery inherits from both, the error collecting call on a GridifyQuery
bound to the IGridifyFiltering extension and validated Filter only, so the two
overloads disagreed on the same object:

  var q = new GridifyQuery(1, 100, "Name=Ivan", "ageee");
  q.IsValid(out var errors, mapper);  // true, errors empty
  q.IsValid(mapper);                  // false

Adds the two missing overloads:

- IGridifyOrdering.IsValid(out validationErrors, mapper) reports unmapped
  ordering fields as "Field 'x' is not mapped", matching the wording the
  filtering validation already uses, and captures the message of the
  GridifyOrderingException that a malformed ordering throws
- IGridifyQuery.IsValid(out validationErrors, mapper) validates both parts and
  concatenates their errors

Both parts are validated even when the first already failed, so the caller gets
every error instead of only the ones from Filter. The boolean IGridifyQuery
overload keeps its short circuit, since it only has to produce the verdict.

ParseOrderings is a lazy iterator that throws part way through enumeration, so
the foreach stays inside the try and errors collected before the throw are kept.
@what-the-diff

what-the-diff Bot commented Sep 17, 2026

Copy link
Copy Markdown

PR Summary

  • Documentation Update
    The instructions for gridifyQuery.md have been updated to include a verification process for OrderBy. This means it now explains how to display error reports.

  • Introduction of a New Function Overload
    A new overload for IsValid in GridifyExtensions.cs has been introduced. This version not only verifies Filter and OrderBy, but also consolidates the errors from both checks.

  • Enhancements to the IsValid Method
    The IsValid method for IGridifyOrdering has been improved. It now reports specialized errors for fields that are not mapped and logs exceptions related to disorderly orderings.

  • Unit Test Expansion
    Expanded unit tests in GridifyQueryValidationTests.cs are now available to verify the updated verification process for both Filter and OrderBy. This ensures consistent behavior across multiple uses.

An unmapped field used in both Filter and OrderBy produced the identical
message twice, and so did ordering on the same unmapped field more than once.
One unmapped field is one problem, and the filtering side already reported it
once per distinct field, so ordering now matches:

- the ordering overload skips a message it has already collected
- the query overload appends only the ordering errors the filter did not
  already report

Distinct unmapped fields are still reported separately.
#332)

Three cases where IsValid reported a query as valid and the query builder then
threw. Each was found by running the verdict against ApplyFiltering or
ApplyOrdering rather than reading the code, and each is checked the way the
builder checks it.

AllowNullSearch was read from GridifyGlobalConfiguration while
BaseQueryBuilder.BuildQuery reads mapper.Configuration, so a mapper built with
a different value validated under the wrong rule, in both directions: with the
global on and the mapper off, "Rank=null" validated but threw, and with the
global off and the mapper on it failed validation although it ran fine.

The '?' and '!' ordering suffixes order by the member's HasValue, which
LinqSortingQueryBuilder can only build for a Nullable<> member. Ordering was
only checked for being mapped, so "Age?" on an int, and "Name?" on a string,
validated and then threw GridifyOrderingException.

Like and NotLike call string.Contains on the member itself. StartsWith and
EndsWith fall back to ToString for a non-string member but these two do not, so
"Age=*5" validated and then threw ArgumentException. Note the alternative fix
is in the query builder, giving Like the same ToString fallback its siblings
have, which would make such filters work rather than be rejected.

One theory case asserted "IntProperty?, StringProperty!" was valid, which it
never was at execution time; it now covers the nullable members instead.

Still not validated, since none can be checked without either an IQueryable or
guessing at another subsystem: ordering by a non-orderable member such as a
collection or dictionary, values behind nested-collection and indexed maps
whose property type cannot be extracted, and the Elasticsearch builder, which
emits the raw token for most operators.
Like and NotLike called string.Contains on the member itself, so "Age=*5" on an
int threw ArgumentException, while StartsWith and EndsWith had a ToString
fallback and "Age^3" worked. The previous commit made validation reject the
contains operator on a non-string field to match. That was the wrong side to
fix: validation exists to predict the builder, so making it stricter than the
builder hides a builder gap rather than closing it.

Like and NotLike now fall back to ToString exactly as their siblings do, which
is the mirror of the two cases immediately below them, and the validation
rejection added in the previous commit is removed.

"Age=*5" and "Rank!*1" now build and run. A nullable member with no value has
an empty text and simply does not match, rather than throwing. Verified
unchanged for string members, for '^' and '$', for the case insensitive '/i'
suffix, and with EntityFrameworkCompatibilityLayer on.

Two limitations stay, both shared with StartsWith and EndsWith and neither new:
the value is still converted to the member's type before the operator runs, so
"When=*2024" on a DateTime cannot match and is still reported invalid; and the
case insensitive suffix only lowercases a string member, so it has no effect on
the text of a non-string one. The Elasticsearch builder has its own handling of
this operator and is untouched.
The builder change in the previous commit was verified against real SQL
translation and against the Elasticsearch query DSL, but only by scratch probes
that left nothing behind. These make those checks permanent, so a later change
cannot silently undo them.

EF Core, both providers, through ToQueryString() and so without a database:
the contains operator on a non-string column reaches the provider as a cast,
CONVERT(varchar(11), [u].[Id]) LIKE on SQL Server and u."Id"::text LIKE on
PostgreSQL. Each test also covers starts with and ends with, which produce the
identical shape, so the four operators cannot drift apart. A fallback the
provider could not translate would have been worse than the exception it
replaced, which is why this is worth pinning rather than asserting in prose.

Elasticsearch already matched a non-string field's text for all four operators,
which is what the LINQ builder now does too, so the equivalent test there
records the agreement between the two.

Also tightens the SQL assertions: '!*' produces NOT LIKE rather than LIKE, and
the first version of these tests passed only because the assertion was loose
enough not to notice.
@alirezanet alirezanet changed the title feat(validation): report OrderBy errors from IsValid(out errors) (#332) feat: validate the whole query in IsValid(out errors), fix contains on non-string fields (#332) Sep 17, 2026
@alirezanet
alirezanet merged commit a7333b8 into master Sep 17, 2026
7 checks passed
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.

Implement IsValid() overloads with list of errors for IGridifyQuery and IGridifyOrdering

1 participant