Skip to content

Commit 258d34e

Browse files
authored
Use coventional routes for link generation (#9037)
Use coventional routes for link generation This change enables using conventional routes for link generation when using MVC conventional routes. This change makes MVC link generation behaviour highly compatible with 2.1. The way that this works is that we create endpoints for **MATCHING** using the denormalized conventional route, but we tell those endpoints to suppress link generation. For link generation we generate a non-matching endpoints per-route with the same order value. I added the concept of *required value any* to link generation. This is needed because for an endpoint to participate in link generation using RouteValuesAddress it needs to have some required values. These details are a little fiddly, but I think it's worth doing this feature completely.
1 parent 896537b commit 258d34e

26 files changed

+1121
-387
lines changed

src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,7 @@ namespace Microsoft.AspNetCore.Routing.Patterns
671671
public sealed partial class RoutePattern
672672
{
673673
internal RoutePattern() { }
674+
public static readonly object RequiredValueAny;
674675
public System.Collections.Generic.IReadOnlyDictionary<string, object> Defaults { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
675676
public decimal InboundPrecedence { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
676677
public decimal OutboundPrecedence { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }

src/Http/Routing/src/Internal/LinkGenerationDecisionTree.cs

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Linq;
88
using System.Text;
99
using Microsoft.AspNetCore.Routing.DecisionTree;
10+
using Microsoft.AspNetCore.Routing.Patterns;
1011
using Microsoft.AspNetCore.Routing.Tree;
1112

1213
namespace Microsoft.AspNetCore.Routing.Internal
@@ -21,38 +22,57 @@ public class LinkGenerationDecisionTree
2122
private static readonly RouteValueDictionary EmptyAmbientValues = new RouteValueDictionary();
2223

2324
private readonly DecisionTreeNode<OutboundMatch> _root;
24-
private readonly Dictionary<string, HashSet<object>> _knownValues;
25+
private readonly List<OutboundMatch> _conventionalEntries;
2526

2627
public LinkGenerationDecisionTree(IReadOnlyList<OutboundMatch> entries)
2728
{
28-
_root = DecisionTreeBuilder<OutboundMatch>.GenerateTree(
29-
entries,
30-
new OutboundMatchClassifier());
29+
// We split up the entries into:
30+
// 1. attribute routes - these go into the tree
31+
// 2. conventional routes - these are a list
32+
var attributedEntries = new List<OutboundMatch>();
33+
_conventionalEntries = new List<OutboundMatch>();
3134

32-
_knownValues = new Dictionary<string, HashSet<object>>(StringComparer.OrdinalIgnoreCase);
35+
// Anything with a RoutePattern.RequiredValueAny as a RequiredValue is a conventional route.
36+
// This is because RequiredValueAny acts as a wildcard, whereas an attribute route entry
37+
// is denormalized to contain an exact set of required values.
38+
//
39+
// We will only see conventional routes show up here for endpoint routing.
3340
for (var i = 0; i < entries.Count; i++)
3441
{
42+
var isAttributeRoute = true;
3543
var entry = entries[i];
3644
foreach (var kvp in entry.Entry.RequiredLinkValues)
3745
{
38-
if (!_knownValues.TryGetValue(kvp.Key, out var values))
46+
if (RoutePattern.IsRequiredValueAny(kvp.Value))
3947
{
40-
values = new HashSet<object>(RouteValueEqualityComparer.Default);
41-
_knownValues.Add(kvp.Key, values);
48+
isAttributeRoute = false;
49+
break;
4250
}
51+
}
4352

44-
values.Add(kvp.Value ?? string.Empty);
53+
if (isAttributeRoute)
54+
{
55+
attributedEntries.Add(entry);
56+
}
57+
else
58+
{
59+
_conventionalEntries.Add(entry);
4560
}
4661
}
62+
63+
_root = DecisionTreeBuilder<OutboundMatch>.GenerateTree(
64+
attributedEntries,
65+
new OutboundMatchClassifier());
4766
}
4867

4968
public IList<OutboundMatchResult> GetMatches(RouteValueDictionary values, RouteValueDictionary ambientValues)
5069
{
5170
// Perf: Avoid allocation for List if there aren't any Matches or Criteria
52-
if (_root.Matches.Count > 0 || _root.Criteria.Count > 0)
71+
if (_root.Matches.Count > 0 || _root.Criteria.Count > 0 || _conventionalEntries.Count > 0)
5372
{
5473
var results = new List<OutboundMatchResult>();
5574
Walk(results, values, ambientValues ?? EmptyAmbientValues, _root, isFallbackPath: false);
75+
ProcessConventionalEntries(results, values, ambientValues ?? EmptyAmbientValues);
5676
results.Sort(OutboundMatchResultComparer.Instance);
5777
return results;
5878
}
@@ -110,19 +130,6 @@ private void Walk(
110130
{
111131
Walk(results, values, ambientValues, branch, isFallbackPath);
112132
}
113-
else
114-
{
115-
// If an explicitly specified value doesn't match any branch, then speculatively walk the
116-
// "null" path if the value doesn't match any known value.
117-
//
118-
// This can happen when linking from a page <-> action. We want to be
119-
// able to use "page" and "action" as normal route parameters.
120-
var knownValues = _knownValues[key];
121-
if (!knownValues.Contains(value ?? string.Empty) && criterion.Branches.TryGetValue(string.Empty, out branch))
122-
{
123-
Walk(results, values, ambientValues, branch, isFallbackPath: true);
124-
}
125-
}
126133
}
127134
else
128135
{
@@ -147,6 +154,17 @@ private void Walk(
147154
}
148155
}
149156

157+
private void ProcessConventionalEntries(
158+
List<OutboundMatchResult> results,
159+
RouteValueDictionary values,
160+
RouteValueDictionary ambientvalues)
161+
{
162+
for (var i = 0; i < _conventionalEntries.Count; i++)
163+
{
164+
results.Add(new OutboundMatchResult(_conventionalEntries[i], isFallbackMatch: false));
165+
}
166+
}
167+
150168
private class OutboundMatchClassifier : IClassifier<OutboundMatch>
151169
{
152170
public IEqualityComparer<object> ValueComparer => RouteValueEqualityComparer.Default;

src/Http/Routing/src/Patterns/DefaultRoutePatternTransformer.cs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -43,8 +43,9 @@ private RoutePattern SubstituteRequiredValuesCore(RoutePattern original, RouteVa
4343
{
4444
// There are three possible cases here:
4545
// 1. Required value is null-ish
46-
// 2. Required value corresponds to a parameter
47-
// 3. Required value corresponds to a matching default value
46+
// 2. Required value is *any*
47+
// 3. Required value corresponds to a parameter
48+
// 4. Required value corresponds to a matching default value
4849
//
4950
// If none of these are true then we can reject this substitution.
5051
RoutePatternParameterPart parameter;
@@ -76,9 +77,28 @@ private RoutePattern SubstituteRequiredValuesCore(RoutePattern original, RouteVa
7677
// Ex: {controller=Home}/{action=Index}/{id?} - with required values: { area = "", ... }
7778
continue;
7879
}
80+
else if (RoutePattern.IsRequiredValueAny(kvp.Value))
81+
{
82+
// 2. Required value is *any* - this is allowed for a parameter with a default, but not
83+
// a non-parameter default.
84+
if (original.GetParameter(kvp.Key) == null &&
85+
original.Defaults.TryGetValue(kvp.Key, out var defaultValue) &&
86+
!RouteValueEqualityComparer.Default.Equals(string.Empty, defaultValue))
87+
{
88+
// Fail: this route as a non-parameter default that is stricter than *any*.
89+
//
90+
// Ex: Admin/{controller=Home}/{action=Index}/{id?} defaults: { area = "Admin" } - with required values: { area = *any* }
91+
return null;
92+
}
93+
94+
// Success: (for this parameter at least)
95+
//
96+
// Ex: {controller=Home}/{action=Index}/{id?} - with required values: { controller = *any*, ... }
97+
continue;
98+
}
7999
else if ((parameter = original.GetParameter(kvp.Key)) != null)
80100
{
81-
// 2. Required value corresponds to a parameter - check to make sure that this value matches
101+
// 3. Required value corresponds to a parameter - check to make sure that this value matches
82102
// any IRouteConstraint implementations.
83103
if (!MatchesConstraints(original, parameter, kvp.Key, requiredValues))
84104
{
@@ -96,7 +116,7 @@ private RoutePattern SubstituteRequiredValuesCore(RoutePattern original, RouteVa
96116
else if (original.Defaults.TryGetValue(kvp.Key, out var defaultValue) &&
97117
RouteValueEqualityComparer.Default.Equals(kvp.Value, defaultValue))
98118
{
99-
// 3. Required value corresponds to a matching default value - check to make sure that this value matches
119+
// 4. Required value corresponds to a matching default value - check to make sure that this value matches
100120
// any IRouteConstraint implementations. It's unlikely that this would happen in practice but it doesn't
101121
// hurt for us to check.
102122
if (!MatchesConstraints(original, parameter: null, kvp.Key, requiredValues))
@@ -142,7 +162,10 @@ private RoutePattern SubstituteRequiredValuesCore(RoutePattern original, RouteVa
142162
// We only need to handle the case where the required value maps to a parameter. That's the only
143163
// case where we allow a default and a required value to disagree, and we already validated the
144164
// other cases.
145-
if (parameter != null &&
165+
//
166+
// If the required value is *any* then don't remove the default.
167+
if (parameter != null &&
168+
!RoutePattern.IsRequiredValueAny(kvp.Value) &&
146169
original.Defaults.TryGetValue(kvp.Key, out var defaultValue) &&
147170
!RouteValueEqualityComparer.Default.Equals(kvp.Value, defaultValue))
148171
{

src/Http/Routing/src/Patterns/RoutePattern.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ namespace Microsoft.AspNetCore.Routing.Patterns
1717
[DebuggerDisplay("{DebuggerToString()}")]
1818
public sealed class RoutePattern
1919
{
20+
/// <summary>
21+
/// A marker object that can be used in <see cref="RequiredValues"/> to designate that
22+
/// any non-null or non-empty value is required.
23+
/// </summary>
24+
/// <remarks>
25+
/// <see cref="RequiredValueAny"/> is only use in routing is in <see cref="RoutePattern.RequiredValues"/>.
26+
/// <see cref="RequiredValueAny"/> is not valid as a route value, and will convert to the null/empty string.
27+
/// </remarks>
28+
public static readonly object RequiredValueAny = new RequiredValueAnySentinal();
29+
30+
internal static bool IsRequiredValueAny(object value)
31+
{
32+
return object.ReferenceEquals(RequiredValueAny, value);
33+
}
34+
2035
private const string SeparatorString = "/";
2136

2237
internal RoutePattern(
@@ -140,5 +155,11 @@ internal string DebuggerToString()
140155
{
141156
return RawText ?? string.Join(SeparatorString, PathSegments.Select(s => s.DebuggerToString()));
142157
}
158+
159+
[DebuggerDisplay("{DebuggerToString(),nq}")]
160+
private class RequiredValueAnySentinal
161+
{
162+
private string DebuggerToString() => "*any*";
163+
}
143164
}
144165
}

src/Http/Routing/src/Template/TemplateBinder.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ public TemplateValuesResult GetValues(RouteValueDictionary ambientValues, RouteV
178178
throw new InvalidOperationException($"Unable to find required value '{key}' on route pattern.");
179179
}
180180

181-
if (!RoutePartsEqual(ambientValue, _pattern.RequiredValues[key]))
181+
if (!RoutePartsEqual(ambientValue, _pattern.RequiredValues[key]) &&
182+
!RoutePattern.IsRequiredValueAny(_pattern.RequiredValues[key]))
182183
{
183184
copyAmbientValues = false;
184185
break;
@@ -261,10 +262,11 @@ public TemplateValuesResult GetValues(RouteValueDictionary ambientValues, RouteV
261262
//
262263
// OR in plain English... when linking from a page in an area to an action in the same area, it should
263264
// be possible to use the area as an ambient value.
264-
if (!copyAmbientValues && _pattern.RequiredValues.TryGetValue(key, out var requiredValue))
265+
if (!copyAmbientValues && !hasExplicitValue && _pattern.RequiredValues.TryGetValue(key, out var requiredValue))
265266
{
266267
hasAmbientValue = ambientValues != null && ambientValues.TryGetValue(key, out ambientValue);
267-
if (hasAmbientValue && RoutePartsEqual(requiredValue, ambientValue))
268+
if (hasAmbientValue &&
269+
(RoutePartsEqual(requiredValue, ambientValue) || RoutePattern.IsRequiredValueAny(requiredValue)))
268270
{
269271
// Treat this an an explicit value to *force it*.
270272
slots[i] = new KeyValuePair<string, object>(key, ambientValue);

src/Http/Routing/test/UnitTests/Internal/LinkGenerationDecisionTreeTest.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -583,9 +583,7 @@ public void GetMatches_LinkToControllerFromPage_WithPageValue()
583583
var matches = tree.GetMatches(context.Values, context.AmbientValues).Select(m => m.Match).ToList();
584584

585585
// Assert
586-
Assert.Collection(
587-
matches,
588-
m => { Assert.Same(entry1, m); });
586+
Assert.Empty(matches);
589587
}
590588

591589
[Fact]
@@ -689,9 +687,7 @@ public void GetMatches_LinkToPageFromController_WithActionValue()
689687
var matches = tree.GetMatches(context.Values, context.AmbientValues).Select(m => m.Match).ToList();
690688

691689
// Assert
692-
Assert.Collection(
693-
matches,
694-
m => { Assert.Same(entry2, m); });
690+
Assert.Empty(matches);
695691
}
696692

697693
[Fact]

0 commit comments

Comments
 (0)