Skip to content

Add NotRouteConstraint for logical negation in route constraints #62756

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

sami-daniel
Copy link

@sami-daniel sami-daniel commented Jul 16, 2025

Add NotRouteConstraint for logical negation in route constraints

Implements a new route constraint that provides logical negation capabilities
for existing route constraints. This enables developers to create routes that
match when specific constraints do NOT match.

Key Features:

  • Single constraint negation: not(int) matches non-integers
  • Multiple constraint negation: not(int;bool) matches neither integers nor booleans
  • Nested negation support: not(not(int)) for double negation (equivalent to int)
  • Parameterized constraints: not(min(18)) for values under 18 or non-numeric
  • File extension negation: not(file) for values without file extensions
  • String pattern negation: not(alpha) for non-alphabetic values
    and other patterns

Examples:

  • /users/{id:not(int)} - matches /users/john but not /users/123
  • /files/{name:not(file)} - matches /files/readme but not /files/doc.txt
  • /ages/{age:not(min(18))} - matches /ages/16 but not /ages/25
  • /values/{val:not(int;bool)} - matches /values/text but not /values/123 or /values/true

Implementation:

  • Added NotRouteConstraint class with XML documentation
  • Supports both IRouteConstraint and IParameterLiteralNodeMatchingPolicy
  • Added to RouteOptions default constraint map with 'not' token

Testing:

  • Tests for single/multiple constraints, nested negation, edge cases
  • Integration tests with HttpContext and service provider scenarios
  • Validation of argument null exceptions and error handling
  • Performance tests for literal matching optimization

This enables more flexible route patterns by allowing inverse constraint logic.

It can also be used to escape internal implementation details when
building route templates.

Fixes #62278

@github-actions github-actions bot added the needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically label Jul 16, 2025
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Jul 16, 2025
@martincostello martincostello added area-routing and removed needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically labels Jul 16, 2025
@sami-daniel
Copy link
Author

I am not sure if those who failed are related with the changes.

@captainsafia
Copy link
Member

@sami-daniel These failures appear to be flakes in our build and not related to your change. I can kick the build here.

@davidfowl
Copy link
Member

davidfowl commented Jul 18, 2025

This is a hot path. Are we adding allocations?

Why do we need this logic in the default selector instead of one you can opt into?

Copy link
Member

@captainsafia captainsafia left a comment

Choose a reason for hiding this comment

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

Thanks for taking the time to work on this change, @sami-daniel!

I left some notes on the current implementation but have two big picture thoughts:

  • Given the nature of the change, we'll likely have to document this as a breaking behavioral change in routing given it changes the contract the router makes.
  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Cc: @javiercn @JamesNK for additional routing expertise

@@ -60,6 +60,8 @@ private static void ProcessFinalCandidates(
Endpoint? endpoint = null;
RouteValueDictionary? values = null;
int? foundScore = null;
var candidatesWithSameScore = new List<CandidateState>();
Copy link
Member

Choose a reason for hiding this comment

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

Can we initialize this the first time two candidates with the same score are detected?

// Assert 2
Assert.Same(endpoint2, httpContext.GetEndpoint());
}

Copy link
Member

Choose a reason for hiding this comment

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

Can we add a test for the case where the match is still ambiguous if there are specificity constraints that match?

{
// Strong typed constraints that are very restrictive and has
// the highest specificity
"guid" => 100,
Copy link
Member

Choose a reason for hiding this comment

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

Using .Equals with a StringComparison to ignore casing might be more consistent than calling ToLowerInvariant for every constraint.

@davidfowl
Copy link
Member

davidfowl commented Jul 18, 2025

I’d follow up here by looking for benchmarks, for scenarios where this new code should not impact simple scenarios. At a glance, it does not look pay for play.

@javiercn
Copy link
Member

Thanks for taking the time to work on this change, @sami-daniel!

I left some notes on the current implementation but have two big picture thoughts:

  • Given the nature of the change, we'll likely have to document this as a breaking behavioral change in routing given it changes the contract the router makes.
  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Cc: @javiercn @JamesNK for additional routing expertise

Few things.

I don't believe that the endpoint selector is the right place to make this change. Factoring it on the route precedence would make more sense.

That said, I think this change is problematic for several reasons:

  • It impacts how routing behaves, which is not an ASP.NET Core only concern (precedence needs to be accounted for in the Blazor router).
  • Constraints allow general code execution to participate in the matching process, ergo it's really hard if not impossible to correctly set a precedence, especially when custom user code is involved.
  • Finally, I don't see general applicability for having to distinguish between two routes based uniquely on the constraints. It feels that the change adds significant overhead and complexity just to cover a "niche" scenario.
  • In many cases, you can disambiguate in different ways by negating one of the constraints in one route. That to me feels like a more solid approach that keeps the routing behavior deterministic and doesn't involve having to "guess" the priorities that constraints impose.
  • If we would want to take it further, we could introduce a pseudo-constraint to negate any existing constraint applied to a route like :not(:int) to address these scenarios.

I think before we even consider a change in this area, we need to discuss whether this is even a change that we want to make at all, and that should be something we discuss on an issue rather than on a PR.

@sami-daniel
Copy link
Author

This is a hot path. Are we adding allocations?

Why do we need this logic in the default selector instead of one you can opt into?

Yes, we are adding allocations, and worse. We are copying the candidateState unnecessarily to the list when we could use it by reference. I will change that.

@sami-daniel
Copy link
Author

  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Thanks for taking the time to work on this change, @sami-daniel!

I left some notes on the current implementation but have two big picture thoughts:

  • Given the nature of the change, we'll likely have to document this as a breaking behavioral change in routing given it changes the contract the router makes.
  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Cc: @javiercn @JamesNK for additional routing expertise

Placing these changes in the DefaultEndpointSelector was a mistake, as it's not actually the source of the problem. The issue lies with its caller — the DFAMatcher. The DefaultEndpointSelector is just the default implementation used by the engine when no custom endpoint selector is defined. In contrast, the DFAMatcher cannot be replaced or customized with its own selector, so it makes more sense for the changes to be applied there. The real problem is that the DFAMatcher does not consider the weight of constraints, as described on the pr — it only checks whether they are valid.

I confess that I did not notice or pay attention to the use of RoutePrecedence in the code. From what I saw, it is used in the DFAMatcher builder, and in some other places that I am not sure are related. I am not sure if it makes sense to make changes to RoutePrecedence, since it is not the direct cause of the problem. I would need to study a bit more to see where it applies...

@sami-daniel
Copy link
Author

sami-daniel commented Jul 18, 2025

Thanks for taking the time to work on this change, @sami-daniel!
I left some notes on the current implementation but have two big picture thoughts:

  • Given the nature of the change, we'll likely have to document this as a breaking behavioral change in routing given it changes the contract the router makes.
  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Cc: @javiercn @JamesNK for additional routing expertise

Few things.

I don't believe that the endpoint selector is the right place to make this change. Factoring it on the route precedence would make more sense.

That said, I think this change is problematic for several reasons:

  • It impacts how routing behaves, which is not an ASP.NET Core only concern (precedence needs to be accounted for in the Blazor router).
  • Constraints allow general code execution to participate in the matching process, ergo it's really hard if not impossible to correctly set a precedence, especially when custom user code is involved.
  • Finally, I don't see general applicability for having to distinguish between two routes based uniquely on the constraints. It feels that the change adds significant overhead and complexity just to cover a "niche" scenario.
  • In many cases, you can disambiguate in different ways by negating one of the constraints in one route. That to me feels like a more solid approach that keeps the routing behavior deterministic and doesn't involve having to "guess" the priorities that constraints impose.
  • If we would want to take it further, we could introduce a pseudo-constraint to negate any existing constraint applied to a route like :not(:int) to address these scenarios.

I think before we even consider a change in this area, we need to discuss whether this is even a change that we want to make at all, and that should be something we discuss on an issue rather than on a PR.

Yes, those are great points to consider. It may really not be worth pursuing. The DFA/DFAMatcher is already a solid algorithm that performs its task well. As you mentioned, introducing additional overhead to this hot path might not be a good idea — even if it’s just to eliminate ambiguity and align more closely with how framework users think.

From what I can tell, making this change without affecting a critical path in the router is nearly impossible, since its a hot path. It seems like a very costly tradeoff. On top of that, as you pointed out, there are other cases that would also need to be handled. This could potentially require an entirely new pattern matching layer to cover all scenarios, adding even more overhead.

Maybe your approach to negating a constraint is much more interesting and much cheaper. The control of this would be in the user's hands, not altering the current behavior of the Router and not adding any overhead. Such a constraint would pave the way for a new range of other possible routes to be created with similar templates. I really would like to do this 😁😁.

@sami-daniel
Copy link
Author

As @javiercn said, perhaps it would be better, instead of adding overhead to DFAMatcher or the endpoint selector itself with a very niche change with little gain and a lot of tradeoff, to abstract this and leave control to the user. A new constraint that negates (:not(:int)), :not(:guid))) other constraints could be interesting to increase the number of possible templates and provide more flexibility with minimal cost. From another point of view, perhaps an exhaustive regex could handle a :not(:guid)), but I think the intention is to make things easier and not more complicated. I think it's worth pursuing this path. It seems like a complex challenge, but interesting to consider. I would like to work on it. If you have any ideas 😎

@dotnet-policy-service dotnet-policy-service bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Jul 26, 2025
@Copilot Copilot AI review requested due to automatic review settings August 20, 2025 13:20
Copy link
Contributor

@Copilot 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 enhances the routing system's endpoint selection by implementing constraint specificity analysis to resolve ambiguities between routes with different constraint levels. When multiple endpoints have the same route pattern but different constraints (e.g., {name:required} vs {id:guid:required}), the system can now select the more specific constraint rather than throwing an ambiguous match exception.

Key Changes

  • Constraint specificity analysis in DefaultEndpointSelector to resolve routing ambiguities
  • Introduction of NotRouteConstraint for logical negation of route constraints
  • Specificity scoring system with weights for different constraint types (strong-typed > ranged > length > string patterns > file/nonfile > unknown > required)

Reviewed Changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
DefaultEndpointSelector.cs Implements constraint specificity analysis logic and scoring system to resolve endpoint ambiguities
NotRouteConstraint.cs New constraint class that provides logical negation functionality for route constraints
NotRouteConstraintTests.cs Comprehensive test suite for the new NotRouteConstraint functionality
DfaMatcherTest.cs Added tests for the enhanced endpoint selection with constraint specificity
RouteOptions.cs Minor comment addition
PublicAPI.Unshipped.txt API surface changes for the new NotRouteConstraint

@@ -141,6 +141,7 @@ private static IDictionary<string, Type> GetDefaultConstraintMap()
AddConstraint<FileNameRouteConstraint>(defaults, "file");
AddConstraint<NonFileNameRouteConstraint>(defaults, "nonfile");

// Non
Copy link
Preview

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

The comment '// Non' is incomplete and unclear. It should either be completed with a meaningful description or removed entirely.

Suggested change
// Non

Copilot uses AI. Check for mistakes.

var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest);

// Assert
Assert.False(result); // hould fall back to default map and int constraint should match
Copy link
Preview

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

There's a typo in the comment: 'hould' should be 'Should'.

Suggested change
Assert.False(result); // hould fall back to default map and int constraint should match
Assert.False(result); // Should fall back to default map and int constraint should match

Copilot uses AI. Check for mistakes.

// FIXME: I'm not sure if this is a good thing to do because
// it requires weak spreading between the ConstraintMap and
// RouteOptions. It doesn't seem appropriate to create two
// identical variables for this...
Copy link
Preview

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

The FIXME comment indicates uncertainty about the design approach. This should be resolved before merging - either the approach should be validated and the comment removed, or an alternative implementation should be considered.

Suggested change
// identical variables for this...

Copilot uses AI. Check for mistakes.

Copy link
Author

Choose a reason for hiding this comment

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

I agree Mr. copilot 👍

@@ -85,15 +88,27 @@ private static void ProcessFinalCandidates(
}
else if (foundScore == state.Score)
Copy link
Preview

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

Missing newline before the opening curly brace. According to the coding guidelines, there should be a newline before the opening brace of code blocks.

Copilot uses AI. Check for mistakes.


// If we have multiple candidates with the same score, try to resolve using
// constraint specificity rules
if (candidatesWithSameScore.Count > 1)
Copy link
Preview

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

Missing newline before the opening curly brace. According to the coding guidelines, there should be a newline before the opening brace of code blocks.

Copilot uses AI. Check for mistakes.

if (candidatesWithSameScore.Count > 1)
{
var mostSpecific = SelectMostSpecificEndpoint(candidatesWithSameScore);
if (mostSpecific.HasValue)
Copy link
Preview

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

Missing newline before the opening curly brace. According to the coding guidelines, there should be a newline before the opening brace of code blocks.

Copilot uses AI. Check for mistakes.

endpoint = mostSpecific.Value.Endpoint;
values = mostSpecific.Value.Values;
}
else
Copy link
Preview

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

Missing newline before the opening curly brace. According to the coding guidelines, there should be a newline before the opening brace of code blocks.

Copilot uses AI. Check for mistakes.

@javiercn
Copy link
Member

As @javiercn said, perhaps it would be better, instead of adding overhead to DFAMatcher or the endpoint selector itself with a very niche change with little gain and a lot of tradeoff, to abstract this and leave control to the user. A new constraint that negates (:not(:int)), :not(:guid))) other constraints could be interesting to increase the number of possible templates and provide more flexibility with minimal cost. From another point of view, perhaps an exhaustive regex could handle a :not(:guid)), but I think the intention is to make things easier and not more complicated. I think it's worth pursuing this path. It seems like a complex challenge, but interesting to consider. I would like to work on it. If you have any ideas 😎

The other point about this is that there is already a mechanism to disambiguate, which is Order.
I don't think we want any sort of mechanism with constraint "priorities", that's what Order is for.
If we accept that, we might choose to use an analyzer instead to detect potentially ambiguous routes and suggest applying order. I think that's a reasonable compromise and doesn't involve changing the runtime. It won't cover 100% of the cases, but It'll likely cover the vast majority of them.

@sami-daniel sami-daniel changed the title Enhance endpoint selection by resolving ambiguities through constraint specificity analysis Add NotRouteConstraint for logical negation in route constraints Aug 20, 2025
@sami-daniel
Copy link
Author

As @javiercn said, perhaps it would be better, instead of adding overhead to DFAMatcher or the endpoint selector itself with a very niche change with little gain and a lot of tradeoff, to abstract this and leave control to the user. A new constraint that negates (:not(:int)), :not(:guid))) other constraints could be interesting to increase the number of possible templates and provide more flexibility with minimal cost. From another point of view, perhaps an exhaustive regex could handle a :not(:guid)), but I think the intention is to make things easier and not more complicated. I think it's worth pursuing this path. It seems like a complex challenge, but interesting to consider. I would like to work on it. If you have any ideas 😎

The other point about this is that there is already a mechanism to disambiguate, which is Order. I don't think we want any sort of mechanism with constraint "priorities", that's what Order is for. If we accept that, we might choose to use an analyzer instead to detect potentially ambiguous routes and suggest applying order. I think that's a reasonable compromise and doesn't involve changing the runtime. It won't cover 100% of the cases, but It'll likely cover the vast majority of them.

As @javiercn said, perhaps it would be better, instead of adding overhead to DFAMatcher or the endpoint selector itself with a very niche change with little gain and a lot of tradeoff, to abstract this and leave control to the user. A new constraint that negates (:not(:int)), :not(:guid))) other constraints could be interesting to increase the number of possible templates and provide more flexibility with minimal cost. From another point of view, perhaps an exhaustive regex could handle a :not(:guid)), but I think the intention is to make things easier and not more complicated. I think it's worth pursuing this path. It seems like a complex challenge, but interesting to consider. I would like to work on it. If you have any ideas 😎

The other point about this is that there is already a mechanism to disambiguate, which is Order. I don't think we want any sort of mechanism with constraint "priorities", that's what Order is for. If we accept that, we might choose to use an analyzer instead to detect potentially ambiguous routes and suggest applying order. I think that's a reasonable compromise and doesn't involve changing the runtime. It won't cover 100% of the cases, but It'll likely cover the vast majority of them.

It makes sense. I talked a bit about this in referenced issue, and it does. I'm not familiar with Order, but I found something very similar, which is a route precedence mechanism. It basically does that, haha. It calculates the precedence of routes selected by the DFA Build or something like that, so it would be reinventing the wheel. I ended up creating NotRouteConstraint because I thought it was more viable, mainly because it can resolve mysterious AmbiguousMatchExceptions without apparent cause and doesn't add much overhead natively. The fact is that the routing mechanism works very well, is stable, and is optimized to the max, so it might not be feasible to change it just to fix a simple issue. The route precedence mechanism works very well, and I don't think we can consider it a "bug," but rather a limitation. Regarding the Analyzer, if I'm not mistaken, it already has an alert that warns about similar templates, but I don't think it usually goes into much detail, only considering the template literal.

Implements a new route constraint that provides logical negation capabilities
for existing route constraints. This enables developers to create routes that
match when specific constraints do NOT match.

Key Features:
- Single constraint negation: not(int) matches non-integers
- Multiple constraint negation: not(int;bool) matches neither integers nor booleans
- Nested negation support: not(not(int)) for double negation (equivalent to int)
- Parameterized constraints: not(min(18)) for values under 18 or non-numeric
- File extension negation: not(file) for values without file extensions
- String pattern negation: not(alpha) for non-alphabetic values
and other patterns

Examples:
- /users/{id:not(int)} - matches /users/john but not /users/123
- /files/{name:not(file)} - matches /files/readme but not /files/doc.txt
- /ages/{age:not(min(18))} - matches /ages/16 but not /ages/25
- /values/{val:not(int;bool)} - matches /values/text but not /values/123 or /values/true

Implementation:
- Added NotRouteConstraint class with XML documentation
- Supports both IRouteConstraint and IParameterLiteralNodeMatchingPolicy
- Added to RouteOptions default constraint map with 'not' token

Testing:
- Tests for single/multiple constraints, nested negation, edge cases
- Integration tests with HttpContext and service provider scenarios
- Validation of argument null exceptions and error handling
- Performance tests for literal matching optimization

This enables more flexible route patterns by allowing inverse constraint logic.

It can also be used to escape internal implementation details when
building route templates.
@sami-daniel
Copy link
Author

Okay, this new implementation has a few issues that I've identified so far, but I'm not sure how to proceed. The first one I've been able to report is whether it's prudent/necessary to recreate a new ConstraintMap variable identical to the value in RouteOptions. The code uses this manually created variable as a fallback in case we can't resolve RouteOptions and obtain the ConstraintMap, but I'm not sure if that's appropriate.

Another point I'm not sure how to proceed with is how we should proceed with RegexRouteConstraint. I've seen that they're handled specifically for some reason, I imagine to avoid browser support issues or some security problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-routing community-contribution Indicates that the PR has been added by a community member pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun
Projects
None yet
Development

Successfully merging this pull request may close these issues.

AmbiguousMatchException thrown when using required route constraint
5 participants