diff --git a/src/Http/Routing/src/DataSourceDependentCache.cs b/src/Http/Routing/src/DataSourceDependentCache.cs index 2f08da69cb..f31807e171 100644 --- a/src/Http/Routing/src/DataSourceDependentCache.cs +++ b/src/Http/Routing/src/DataSourceDependentCache.cs @@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Routing { + // FYI: This class is also linked into MVC. If you make changes to the API you will + // also need to change MVC's usage. internal sealed class DataSourceDependentCache : IDisposable where T : class { private readonly EndpointDataSource _dataSource; diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs new file mode 100644 index 0000000000..7bee7d79a5 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs @@ -0,0 +1,180 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + // Common infrastructure for things that look up actions by route values. + // + // The ActionSelectionTable stores a mapping of route-values -> items for each known set of + // of route-values. We actually build two of these mappings, one for case-sensitive (fast path) and one for + // case-insensitive (slow path). + // + // This is necessary because MVC routing/action-selection is always case-insensitive. So we're going to build + // a case-sensitive dictionary that will behave like the a case-insensitive dictionary when you hit one of the + // canonical entries. When you don't hit a case-sensitive match it will try the case-insensitive dictionary + // so you still get correct behaviors. + // + // The difference here is because while MVC is case-insensitive, doing a case-sensitive comparison is much + // faster. We also expect that most of the URLs we process are canonically-cased because they were generated + // by Url.Action or another routing api. + // + // This means that for a set of actions like: + // { controller = "Home", action = "Index" } -> HomeController::Index1() + // { controller = "Home", action = "index" } -> HomeController::Index2() + // + // Both of these actions match "Index" case-insensitively, but there exist two known canonical casings, + // so we will create an entry for "Index" and an entry for "index". Both of these entries match **both** + // actions. + internal class ActionSelectionTable + { + private ActionSelectionTable( + int version, + string[] routeKeys, + Dictionary> ordinalEntries, + Dictionary> ordinalIgnoreCaseEntries) + { + Version = version; + RouteKeys = routeKeys; + OrdinalEntries = ordinalEntries; + OrdinalIgnoreCaseEntries = ordinalIgnoreCaseEntries; + } + + public int Version { get; } + + private string[] RouteKeys { get; } + + private Dictionary> OrdinalEntries { get; } + + private Dictionary> OrdinalIgnoreCaseEntries { get; } + + public static ActionSelectionTable Create(ActionDescriptorCollection actions) + { + return CreateCore( + + // We need to store the version so the cache can be invalidated if the actions change. + version: actions.Version, + + // For action selection, ignore attribute routed actions + items: actions.Items.Where(a => a.AttributeRouteInfo == null), + + getRouteKeys: a => a.RouteValues.Keys, + getRouteValue: (a, key) => + { + a.RouteValues.TryGetValue(key, out var value); + return value ?? string.Empty; + }); + } + + public static ActionSelectionTable Create(IEnumerable endpoints) + { + return CreateCore( + + // we don't use version for endpoints + version: 0, + + // Only include RouteEndpoints and only those that aren't suppressed. + items: endpoints.OfType().Where(e => + { + return e.Metadata.GetMetadata().SuppressMatching != true; + }), + + getRouteKeys: e => e.RoutePattern.RequiredValues.Keys, + getRouteValue: (e, key) => + { + e.RoutePattern.RequiredValues.TryGetValue(key, out var value); + return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + }); + } + + private static ActionSelectionTable CreateCore( + int version, + IEnumerable items, + Func> getRouteKeys, + Func getRouteValue) + { + // We need to build two maps for all of the route values. + var ordinalEntries = new Dictionary>(StringArrayComparer.Ordinal); + var ordinalIgnoreCaseEntries = new Dictionary>(StringArrayComparer.OrdinalIgnoreCase); + + // We need to hold on to an ordered set of keys for the route values. We'll use these later to + // extract the set of route values from an incoming request to compare against our maps of known + // route values. + var routeKeys = new SortedSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in items) + { + foreach (var key in getRouteKeys(item)) + { + routeKeys.Add(key); + } + } + + foreach (var item in items) + { + // This is a conventionally routed action - so we need to extract the route values associated + // with this action (in order) so we can store them in our dictionaries. + var index = 0; + var routeValues = new string[routeKeys.Count]; + foreach (var key in routeKeys) + { + var value = getRouteValue(item, key); + routeValues[index++] = value; + } + + if (!ordinalIgnoreCaseEntries.TryGetValue(routeValues, out var entries)) + { + entries = new List(); + ordinalIgnoreCaseEntries.Add(routeValues, entries); + } + + entries.Add(item); + + // We also want to add the same (as in reference equality) list of actions to the ordinal entries. + // We'll keep updating `entries` to include all of the actions in the same equivalence class - + // meaning, all conventionally routed actions for which the route values are equal ignoring case. + // + // `entries` will appear in `OrdinalIgnoreCaseEntries` exactly once and in `OrdinalEntries` once + // for each variation of casing that we've seen. + if (!ordinalEntries.ContainsKey(routeValues)) + { + ordinalEntries.Add(routeValues, entries); + } + } + + return new ActionSelectionTable(version, routeKeys.ToArray(), ordinalEntries, ordinalIgnoreCaseEntries); + } + + public IReadOnlyList Select(RouteValueDictionary values) + { + // Select works based on a string[] of the route values in a pre-calculated order. This code extracts + // those values in the correct order. + var routeKeys = RouteKeys; + var routeValues = new string[routeKeys.Length]; + for (var i = 0; i < routeKeys.Length; i++) + { + values.TryGetValue(routeKeys[i], out var value); + routeValues[i] = value as string ?? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + } + + // Now look up, first case-sensitive, then case-insensitive. + if (OrdinalEntries.TryGetValue(routeValues, out var matches) || + OrdinalIgnoreCaseEntries.TryGetValue(routeValues, out matches)) + { + Debug.Assert(matches != null); + Debug.Assert(matches.Count >= 0); + return matches; + } + + return Array.Empty(); + } + } +} diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelector.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelector.cs index 834862f568..2912f9ec85 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelector.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelector.cs @@ -3,15 +3,11 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; using System.Linq; using System.Threading; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; @@ -22,13 +18,11 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure /// internal class ActionSelector : IActionSelector { - private static readonly IReadOnlyList EmptyActions = Array.Empty(); - private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; private readonly ActionConstraintCache _actionConstraintCache; private readonly ILogger _logger; - private Cache _cache; + private ActionSelectionTable _cache; /// /// Creates a new . @@ -49,7 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure _actionConstraintCache = actionConstraintCache; } - private Cache Current + private ActionSelectionTable Current { get { @@ -61,7 +55,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure return cache; } - cache = new Cache(actions); + cache = ActionSelectionTable.Create(actions); Volatile.Write(ref _cache, cache); return cache; } @@ -76,28 +70,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure var cache = Current; - // The Cache works based on a string[] of the route values in a pre-calculated order. This code extracts - // those values in the correct order. - var keys = cache.RouteKeys; - var values = new string[keys.Length]; - for (var i = 0; i < keys.Length; i++) + var matches = cache.Select(context.RouteData.Values); + if (matches.Count > 0) { - context.RouteData.Values.TryGetValue(keys[i], out var value); - if (value != null) - { - values[i] = value as string ?? Convert.ToString(value, CultureInfo.InvariantCulture); - } - } - - if (cache.OrdinalEntries.TryGetValue(values, out var matchingRouteValues) || - cache.OrdinalIgnoreCaseEntries.TryGetValue(values, out matchingRouteValues)) - { - Debug.Assert(matchingRouteValues != null); - return matchingRouteValues; + return matches; } _logger.NoActionsMatched(context.RouteData.Values); - return EmptyActions; + return matches; } public ActionDescriptor SelectBestCandidate(RouteContext context, IReadOnlyList candidates) @@ -286,175 +266,5 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure return EvaluateActionConstraintsCore(context, actionsWithoutConstraint, order); } } - - // The action selector cache stores a mapping of route-values -> action descriptors for each known set of - // of route-values. We actually build two of these mappings, one for case-sensitive (fast path) and one for - // case-insensitive (slow path). - // - // This is necessary because MVC routing/action-selection is always case-insensitive. So we're going to build - // a case-sensitive dictionary that will behave like the a case-insensitive dictionary when you hit one of the - // canonical entries. When you don't hit a case-sensitive match it will try the case-insensitive dictionary - // so you still get correct behaviors. - // - // The difference here is because while MVC is case-insensitive, doing a case-sensitive comparison is much - // faster. We also expect that most of the URLs we process are canonically-cased because they were generated - // by Url.Action or another routing api. - // - // This means that for a set of actions like: - // { controller = "Home", action = "Index" } -> HomeController::Index1() - // { controller = "Home", action = "index" } -> HomeController::Index2() - // - // Both of these actions match "Index" case-insensitively, but there exist two known canonical casings, - // so we will create an entry for "Index" and an entry for "index". Both of these entries match **both** - // actions. - private class Cache - { - public Cache(ActionDescriptorCollection actions) - { - // We need to store the version so the cache can be invalidated if the actions change. - Version = actions.Version; - - // We need to build two maps for all of the route values. - OrdinalEntries = new Dictionary>(StringArrayComparer.Ordinal); - OrdinalIgnoreCaseEntries = new Dictionary>(StringArrayComparer.OrdinalIgnoreCase); - - // We need to first identify of the keys that action selection will look at (in route data). - // We want to only consider conventionally routed actions here. - var routeKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - for (var i = 0; i < actions.Items.Count; i++) - { - var action = actions.Items[i]; - if (action.AttributeRouteInfo == null) - { - // This is a conventionally routed action - so make sure we include its keys in the set of - // known route value keys. - foreach (var kvp in action.RouteValues) - { - routeKeys.Add(kvp.Key); - } - } - } - - // We need to hold on to an ordered set of keys for the route values. We'll use these later to - // extract the set of route values from an incoming request to compare against our maps of known - // route values. - RouteKeys = routeKeys.ToArray(); - - for (var i = 0; i < actions.Items.Count; i++) - { - var action = actions.Items[i]; - if (action.AttributeRouteInfo != null) - { - // This only handles conventional routing. Ignore attribute routed actions. - continue; - } - - // This is a conventionally routed action - so we need to extract the route values associated - // with this action (in order) so we can store them in our dictionaries. - var routeValues = new string[RouteKeys.Length]; - for (var j = 0; j < RouteKeys.Length; j++) - { - action.RouteValues.TryGetValue(RouteKeys[j], out routeValues[j]); - } - - if (!OrdinalIgnoreCaseEntries.TryGetValue(routeValues, out var entries)) - { - entries = new List(); - OrdinalIgnoreCaseEntries.Add(routeValues, entries); - } - - entries.Add(action); - - // We also want to add the same (as in reference equality) list of actions to the ordinal entries. - // We'll keep updating `entries` to include all of the actions in the same equivalence class - - // meaning, all conventionally routed actions for which the route values are equal ignoring case. - // - // `entries` will appear in `OrdinalIgnoreCaseEntries` exactly once and in `OrdinalEntries` once - // for each variation of casing that we've seen. - if (!OrdinalEntries.ContainsKey(routeValues)) - { - OrdinalEntries.Add(routeValues, entries); - } - } - } - - public int Version { get; } - - public string[] RouteKeys { get; } - - public Dictionary> OrdinalEntries { get; } - - public Dictionary> OrdinalIgnoreCaseEntries { get; } - } - - private class StringArrayComparer : IEqualityComparer - { - public static readonly StringArrayComparer Ordinal = new StringArrayComparer(StringComparer.Ordinal); - - public static readonly StringArrayComparer OrdinalIgnoreCase = new StringArrayComparer(StringComparer.OrdinalIgnoreCase); - - private readonly StringComparer _valueComparer; - - private StringArrayComparer(StringComparer valueComparer) - { - _valueComparer = valueComparer; - } - - public bool Equals(string[] x, string[] y) - { - if (object.ReferenceEquals(x, y)) - { - return true; - } - - if (x == null ^ y == null) - { - return false; - } - - if (x.Length != y.Length) - { - return false; - } - - for (var i = 0; i < x.Length; i++) - { - if (string.IsNullOrEmpty(x[i]) && string.IsNullOrEmpty(y[i])) - { - continue; - } - - if (!_valueComparer.Equals(x[i], y[i])) - { - return false; - } - } - - return true; - } - - public int GetHashCode(string[] obj) - { - if (obj == null) - { - return 0; - } - - var hash = new HashCodeCombiner(); - for (var i = 0; i < obj.Length; i++) - { - var o = obj[i]; - - // Route values define null and "" to be equivalent. - if (string.IsNullOrEmpty(o)) - { - o = null; - } - hash.Add(o, _valueComparer); - } - - return hash.CombinedHash; - } - } } } diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/StringArrayComparer.cs b/src/Mvc/Mvc.Core/src/Infrastructure/StringArrayComparer.cs new file mode 100644 index 0000000000..a0454f71ce --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Infrastructure/StringArrayComparer.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + internal class StringArrayComparer : IEqualityComparer + { + public static readonly StringArrayComparer Ordinal = new StringArrayComparer(StringComparer.Ordinal); + + public static readonly StringArrayComparer OrdinalIgnoreCase = new StringArrayComparer(StringComparer.OrdinalIgnoreCase); + + private readonly StringComparer _valueComparer; + + private StringArrayComparer(StringComparer valueComparer) + { + _valueComparer = valueComparer; + } + + public bool Equals(string[] x, string[] y) + { + if (object.ReferenceEquals(x, y)) + { + return true; + } + + if (x == null ^ y == null) + { + return false; + } + + if (x.Length != y.Length) + { + return false; + } + + for (var i = 0; i < x.Length; i++) + { + if (string.IsNullOrEmpty(x[i]) && string.IsNullOrEmpty(y[i])) + { + continue; + } + + if (!_valueComparer.Equals(x[i], y[i])) + { + return false; + } + } + + return true; + } + + public int GetHashCode(string[] obj) + { + if (obj == null) + { + return 0; + } + + var hash = new HashCodeCombiner(); + for (var i = 0; i < obj.Length; i++) + { + var o = obj[i]; + + // Route values define null and "" to be equivalent. + if (string.IsNullOrEmpty(o)) + { + o = null; + } + hash.Add(o, _valueComparer); + } + + return hash.CombinedHash; + } + } +} diff --git a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj index 1077fe4166..da787d60ca 100644 --- a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj @@ -27,6 +27,10 @@ Microsoft.AspNetCore.Mvc.RouteAttribute + + + + diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/ActionSelectionTableTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/ActionSelectionTableTest.cs new file mode 100644 index 0000000000..4e961370d9 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/Infrastructure/ActionSelectionTableTest.cs @@ -0,0 +1,606 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + // The ActionSelectionTable has different code paths for ActionDescriptor and + // RouteEndpoint for creating a table. We're trying to test both code paths + // for creation, but selection works the same for both cases. + public class ActionSelectionTableTest + { + [Fact] + public void Select_SingleMatch() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "About" } + }, + }, + }; + + var table = CreateTableWithActionDescriptors(actions); + var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Collection(matches, (a) => Assert.Same(actions[0], a)); + } + + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void Select_ActionDescriptor_SingleMatch_UsesInvariantCulture() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" }, + { "date", "10/31/2018 07:37:38 -07:00" }, + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "About" } + }, + }, + }; + + var table = CreateTableWithActionDescriptors(actions); + var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); + values.Add( + "date", + new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7))); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Collection(matches, (a) => Assert.Same(actions[0], a)); + } + + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void Select_Endpoint_SingleMatch_UsesInvariantCulture() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" }, + { "date", "10/31/2018 07:37:38 -07:00" }, + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "About" } + }, + }, + }; + + var table = CreateTableWithEndpoints(actions); + var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); + values.Add( + "date", + new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7))); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Collection(matches, (e) => Assert.Same(actions[0], e.Metadata.GetMetadata())); + } + + [Fact] + public void Select_ActionDescriptor_MultipleMatches() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + }; + + var table = CreateTableWithActionDescriptors(actions); + var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Equal(actions.ToArray(), matches.ToArray()); + } + + [Fact] + public void Select_Endpoint_MultipleMatches() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + }; + + var table = CreateTableWithEndpoints(actions); + var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Equal(actions.ToArray(), matches.Select(e => e.Metadata.GetMetadata()).ToArray()); + } + + [Fact] + public void Select_NoMatch() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "About" } + }, + }, + }; + + var table = CreateTableWithActionDescriptors(actions); + var values = new RouteValueDictionary(new { controller = "Foo", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Empty(matches); + } + + [Fact] + public void Select_ActionDescriptors_NoMatch_ExcludesAttributeRoutedActions() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + AttributeRouteInfo = new AttributeRouteInfo() + { + Template = "/Home", + } + }, + }; + + var table = CreateTableWithActionDescriptors(actions); + var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Empty(matches); + } + + [Fact] + public void Select_Endpoint_Match_IncludesAttributeRoutedActions() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + AttributeRouteInfo = new AttributeRouteInfo() + { + Template = "/Home", + } + }, + }; + + var table = CreateTableWithEndpoints(actions); + var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Single(matches); + } + + [Fact] + public void Select_Endpoint_NoMatch_ExcludesMatchingSuppressedAction() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + EndpointMetadata = new List() + { + new SuppressMatchingMetadata(), + }, + }, + }; + + var table = CreateTableWithEndpoints(actions); + var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Empty(matches); + } + + // In this context `CaseSensitiveMatch` means that the input route values exactly match one of the action + // descriptor's route values in terms of casing. This is important because we optimize for this case + // in the implementation. + [Fact] + public void Select_Match_CaseSensitiveMatch_IncludesAllCaseInsensitiveMatches() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() // This won't match the request + { + DisplayName = "A3", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "About" } + }, + }, + }; + + var expected = actions.Take(2).ToArray(); + + var table = CreateTableWithActionDescriptors(actions); + var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Equal(expected, matches); + } + + // In this context `CaseInsensitiveMatch` means that the input route values do not match any action + // descriptor's route values in terms of casing. This is important because we optimize for the case + // where the casing matches - the non-matching-casing path is handled a bit differently. + [Fact] + public void Select_Match_CaseInsensitiveMatch_IncludesAllCaseInsensitiveMatches() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() // This won't match the request + { + DisplayName = "A3", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "About" } + }, + }, + }; + + var expected = actions.Take(2).ToArray(); + + var table = CreateTableWithActionDescriptors(actions); + var values = new RouteValueDictionary(new { controller = "HOME", action = "iNDex", }); + + // Act + var matches = table.Select(values); + + // Assert + Assert.Equal(expected, matches); + } + + [Fact] + public void Select_Match_CaseSensitiveMatch_MatchesOnEmptyString() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "area", null }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var table = CreateTableWithActionDescriptors(actions); + + // Example: In conventional route, one could set non-inline defaults + // new { area = "", controller = "Home", action = "Index" } + var values = new RouteValueDictionary(new { area = "", controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + var action = Assert.Single(matches); + Assert.Same(actions[0], action); + } + + [Fact] + public void Select_Match_CaseInsensitiveMatch_MatchesOnEmptyString() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "area", null }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var table = CreateTableWithActionDescriptors(actions); + + // Example: In conventional route, one could set non-inline defaults + // new { area = "", controller = "Home", action = "Index" } + var values = new RouteValueDictionary(new { area = "", controller = "HoMe", action = "InDeX", }); + + // Act + var matches = table.Select(values); + + // Assert + var action = Assert.Single(matches); + Assert.Same(actions[0], action); + } + + [Fact] + public void Select_Match_MatchesOnNull() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "area", null }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var table = CreateTableWithActionDescriptors(actions); + + // Example: In conventional route, one could set non-inline defaults + // new { area = (string)null, controller = "Foo", action = "Index" } + var values = new RouteValueDictionary(new { area = (string)null, controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + var action = Assert.Single(matches); + Assert.Same(actions[0], action); + } + + [Fact] + public void Select_Match_ActionDescriptorWithEmptyRouteValues_MatchesOnEmptyString() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "foo", "" }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var table = CreateTableWithActionDescriptors(actions); + + var values = new RouteValueDictionary(new { foo = "", controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + var action = Assert.Single(matches); + Assert.Same(actions[0], action); + } + + [Fact] + public void Select_Match_ActionDescriptorWithEmptyRouteValues_MatchesOnNull() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "foo", "" }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var table = CreateTableWithActionDescriptors(actions); + + var values = new RouteValueDictionary(new { foo = (string)null, controller = "Home", action = "Index", }); + + // Act + var matches = table.Select(values); + + // Assert + var action = Assert.Single(matches); + Assert.Same(actions[0], action); + } + + private static ActionSelectionTable CreateTableWithActionDescriptors(IReadOnlyList actions) + { + return ActionSelectionTable.Create(new ActionDescriptorCollection(actions, 0)); + } + + private static ActionSelectionTable CreateTableWithEndpoints(IReadOnlyList actions) + { + + + var endpoints = actions.Select(a => + { + var metadata = new List(a.EndpointMetadata ?? Array.Empty()); + metadata.Add(a); + return new RouteEndpoint( + requestDelegate: context => Task.CompletedTask, + routePattern: RoutePatternFactory.Parse("/", defaults: a.RouteValues, parameterPolicies: null, requiredValues: a.RouteValues), + order: 0, + metadata: new EndpointMetadataCollection(metadata), + a.DisplayName); + }); + + return ActionSelectionTable.Create(endpoints); + } + } +}