From 8bfb6eb8d5a0fbb42c20a1e9fbd1d91b5c114357 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 14 Jul 2014 15:41:56 -0700 Subject: [PATCH] implement a decision-tree-based action selector --- .../DefaultActionSelector.cs | 24 +- .../DecisionTree/DecisionCriterion.cs | 16 + .../DecisionTree/DecisionCriterionValue.cs | 27 ++ .../DecisionCriterionValueEqualityComparer.cs | 34 +++ .../DecisionTree/DecisionTreeBuilder.cs | 234 ++++++++++++++ .../Internal/DecisionTree/DecisionTreeNode.cs | 14 + .../Internal/DecisionTree/IClassifier.cs | 14 + .../Internal/DecisionTree/ItemDescriptor.cs | 16 + .../DefaultActionSelectorSelectAsyncValues.cs | 8 +- .../Microsoft.AspNet.Mvc.Core.kproj | 21 +- .../RouteDataActionConstraint.cs | 4 - .../RouteKeyHandling.cs | 5 - .../Routing/ActionSelectorDecisionTree.cs | 191 ++++++++++++ .../ActionSelectorDecisionTreeProvider.cs | 50 +++ .../Routing/IActionSelectionDecisionTree.cs | 27 ++ .../IActionSelectorDecisionTreeProvider.cs | 20 ++ .../Routing/RouteValueEqualityComparer.cs | 53 ++++ src/Microsoft.AspNet.Mvc/MvcServices.cs | 4 + .../ActionAttributeTests.cs | 10 +- ...iscoveryConventionsActionSelectionTests.cs | 10 +- .../DefaultActionSelectorTests.cs | 5 +- .../DecisionTree/DecisionTreeBuilderTest.cs | 286 ++++++++++++++++++ .../Microsoft.AspNet.Mvc.Core.Test.kproj | 3 +- .../ModelBindingWebSite.kproj | 1 - 24 files changed, 1040 insertions(+), 37 deletions(-) create mode 100644 src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterion.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterionValue.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterionValueEqualityComparer.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionTreeBuilder.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionTreeNode.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/IClassifier.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/ItemDescriptor.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/ActionSelectorDecisionTree.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/ActionSelectorDecisionTreeProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/IActionSelectionDecisionTree.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/IActionSelectorDecisionTreeProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/RouteValueEqualityComparer.cs create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/Internal/DecisionTree/DecisionTreeBuilderTest.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/DefaultActionSelector.cs b/src/Microsoft.AspNet.Mvc.Core/DefaultActionSelector.cs index 6bf5963976..f762dcacdf 100644 --- a/src/Microsoft.AspNet.Mvc.Core/DefaultActionSelector.cs +++ b/src/Microsoft.AspNet.Mvc.Core/DefaultActionSelector.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.AspNet.Mvc.Core; using Microsoft.AspNet.Mvc.Logging; using Microsoft.AspNet.Mvc.ModelBinding; +using Microsoft.AspNet.Mvc.Routing; using Microsoft.AspNet.Routing; using Microsoft.Framework.Logging; @@ -16,14 +17,18 @@ namespace Microsoft.AspNet.Mvc public class DefaultActionSelector : IActionSelector { private readonly IActionDescriptorsCollectionProvider _actionDescriptorsCollectionProvider; + private readonly IActionSelectorDecisionTreeProvider _decisionTreeProvider; private readonly IActionBindingContextProvider _bindingProvider; private ILogger _logger; - public DefaultActionSelector(IActionDescriptorsCollectionProvider actionDescriptorsCollectionProvider, - IActionBindingContextProvider bindingProvider, - [NotNull] ILoggerFactory loggerFactory) + public DefaultActionSelector( + [NotNull] IActionDescriptorsCollectionProvider actionDescriptorsCollectionProvider, + [NotNull] IActionSelectorDecisionTreeProvider decisionTreeProvider, + [NotNull] IActionBindingContextProvider bindingProvider, + [NotNull] ILoggerFactory loggerFactory) { _actionDescriptorsCollectionProvider = actionDescriptorsCollectionProvider; + _decisionTreeProvider = decisionTreeProvider; _bindingProvider = bindingProvider; _logger = loggerFactory.Create(); } @@ -32,11 +37,8 @@ namespace Microsoft.AspNet.Mvc { using (_logger.BeginScope("DefaultActionSelector.SelectAsync")) { - var allDescriptors = GetActions(); - - var matchingRouteConstraints = - allDescriptors.Where(ad => - MatchRouteConstraints(ad, context)).ToList(); + var tree = _decisionTreeProvider.DecisionTree; + var matchingRouteConstraints = tree.Select(context.RouteData.Values); var matchingRouteAndMethodConstraints = matchingRouteConstraints.Where(ad => @@ -103,12 +105,6 @@ namespace Microsoft.AspNet.Mvc } } - private bool MatchRouteConstraints(ActionDescriptor descriptor, RouteContext context) - { - return descriptor.RouteConstraints == null || - descriptor.RouteConstraints.All(c => c.Accept(context)); - } - private bool MatchMethodConstraints(ActionDescriptor descriptor, RouteContext context) { return descriptor.MethodConstraints == null || diff --git a/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterion.cs b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterion.cs new file mode 100644 index 0000000000..af0d0a9b14 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterion.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.Internal.DecisionTree +{ + public class DecisionCriterion + { + public string Key { get; set; } + + public Dictionary> Branches { get; set; } + + public DecisionTreeNode Fallback { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterionValue.cs b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterionValue.cs new file mode 100644 index 0000000000..ab14107c2b --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterionValue.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Mvc.Internal.DecisionTree +{ + public struct DecisionCriterionValue + { + private readonly bool _isCatchAll; + private readonly object _value; + + public DecisionCriterionValue(object value, bool isCatchAll) + { + _value = value; + _isCatchAll = isCatchAll; + } + + public bool IsCatchAll + { + get { return _isCatchAll; } + } + + public object Value + { + get { return _value; } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterionValueEqualityComparer.cs b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterionValueEqualityComparer.cs new file mode 100644 index 0000000000..4e1e1a71c7 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionCriterionValueEqualityComparer.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.Internal.DecisionTree +{ + public class DecisionCriterionValueEqualityComparer : IEqualityComparer + { + public DecisionCriterionValueEqualityComparer(IEqualityComparer innerComparer) + { + InnerComparer = innerComparer; + } + + public IEqualityComparer InnerComparer { get; private set; } + + public bool Equals(DecisionCriterionValue x, DecisionCriterionValue y) + { + return x.IsCatchAll == y.IsCatchAll || InnerComparer.Equals(x.Value, y.Value); + } + + public int GetHashCode(DecisionCriterionValue obj) + { + if (obj.IsCatchAll) + { + return 0; + } + else + { + return InnerComparer.GetHashCode(obj.Value); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionTreeBuilder.cs b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionTreeBuilder.cs new file mode 100644 index 0000000000..9eb1c01442 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionTreeBuilder.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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; + +namespace Microsoft.AspNet.Mvc.Internal.DecisionTree +{ + // This code generates a minimal tree of decision criteria that map known categorical data + // (key-value-pairs) to a set of inputs. Action Selection is the best example of how this + // can be used, so the comments here will describe the process from the point-of-view, + // though the decision tree is generally applicable to like-problems. + // + // Care has been taken here to keep the performance of building the data-structure at a + // reasonable level, as this has an impact on startup cost for action selection. Additionally + // we want to hold on to the minimal amount of memory needed once we've built the tree. + // + // Ex: + // Given actions like the following, create a decision tree that will help action + // selection work efficiently. + // + // Given any set of route data it should be possible to traverse the tree using the + // presence our route data keys (like action), and whether or not they match any of + // the known values for that route data key, to find the set of actions that match + // the route data. + // + // Actions: + // + // { controller = "Home", action = "Index" } + // { controller = "Products", action = "Index" } + // { controller = "Products", action = "Buy" } + // { area = "Admin", controller = "Users", action = "AddUser" } + // + // The generated tree looks like this (json-like-notation): + // + // { + // action : { + // "AddUser" : { + // controller : { + // "Users" : { + // area : { + // "Admin" : match { area = "Admin", controller = "Users", action = "AddUser" } + // } + // } + // } + // }, + // "Buy" : { + // controller : { + // "Products" : { + // area : { + // null : match { controller = "Products", action = "Buy" } + // } + // } + // } + // }, + // "Index" : { + // controller : { + // "Home" : { + // area : { + // null : match { controller = "Home", action = "Index" } + // } + // } + // "Products" : { + // area : { + // "null" : match { controller = "Products", action = "Index" } + // } + // } + // } + // } + // } + // } + public static class DecisionTreeBuilder + { + public static DecisionTreeNode GenerateTree(IReadOnlyList items, IClassifier classifier) + { + var itemDescriptors = new List>(); + for (var i = 0; i < items.Count; i++) + { + itemDescriptors.Add(new ItemDescriptor() + { + Criteria = classifier.GetCriteria(items[i]), + Index = i, + Item = items[i], + }); + } + + var comparer = new DecisionCriterionValueEqualityComparer(classifier.ValueComparer); + return GenerateNode( + new TreeBuilderContext(), + comparer, + itemDescriptors); + } + + private static DecisionTreeNode GenerateNode( + TreeBuilderContext context, + DecisionCriterionValueEqualityComparer comparer, + IList> items) + { + // The extreme use of generics here is intended to reduce the number of intermediate + // allocations of wrapper classes. Performance testing found that building these trees allocates + // significant memory that we can avoid and that it has a real impact on startup. + var criteria = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Matches are items that have no remaining criteria - at this point in the tree + // they are considered accepted. + var matches = new List(); + + // For each item in the working set, we want to map it to it's possible criteria-branch + // pairings, then reduce that tree to the minimal set. + foreach (var item in items) + { + var unsatisfiedCriteria = 0; + + foreach (var kvp in item.Criteria) + { + // context.CurrentCriteria is the logical 'stack' of criteria that we've already processed + // on this branch of the tree. + if (context.CurrentCriteria.Contains(kvp.Key)) + { + continue; + } + + unsatisfiedCriteria++; + + Criterion criterion; + if (!criteria.TryGetValue(kvp.Key, out criterion)) + { + criterion = new Criterion(comparer); + criteria.Add(kvp.Key, criterion); + } + + List> branch; + if (!criterion.TryGetValue(kvp.Value, out branch)) + { + branch = new List>(); + criterion.Add(kvp.Value, branch); + } + + branch.Add(item); + } + + // If all of the criteria on item are satisfied by the 'stack' then this item is a match. + if (unsatisfiedCriteria == 0) + { + matches.Add(item.Item); + } + } + + // Iterate criteria in order of branchiness to determine which one to explore next. If a criterion + // has no 'new' matches under it then we can just eliminate that part of the tree. + var reducedCriteria = new List>(); + foreach (var criterion in criteria.OrderByDescending(c => c.Value.Count)) + { + var reducedBranches = new Dictionary>(comparer.InnerComparer); + DecisionTreeNode fallback = null; + + foreach (var branch in criterion.Value) + { + var reducedItems = new List>(); + foreach (var item in branch.Value) + { + if (context.MatchedItems.Add(item)) + { + reducedItems.Add(item); + } + } + + if (reducedItems.Count > 0) + { + var childContext = new TreeBuilderContext(context); + childContext.CurrentCriteria.Add(criterion.Key); + + var newBranch = GenerateNode(childContext, comparer, branch.Value); + if (branch.Key.IsCatchAll) + { + fallback = newBranch; + } + else + { + reducedBranches.Add(branch.Key.Value, newBranch); + } + } + } + + if (reducedBranches.Count > 0 || fallback != null) + { + var newCriterion = new DecisionCriterion() + { + Key = criterion.Key, + Branches = reducedBranches, + Fallback = fallback, + }; + + reducedCriteria.Add(newCriterion); + } + } + + return new DecisionTreeNode() + { + Criteria = reducedCriteria.ToList(), + Matches = matches, + }; + } + + private class TreeBuilderContext + { + public TreeBuilderContext() + { + CurrentCriteria = new HashSet(StringComparer.OrdinalIgnoreCase); + MatchedItems = new HashSet>(); + } + + public TreeBuilderContext(TreeBuilderContext other) + { + CurrentCriteria = new HashSet(other.CurrentCriteria, StringComparer.OrdinalIgnoreCase); + MatchedItems = new HashSet>(); + } + + public HashSet CurrentCriteria { get; private set; } + + public HashSet> MatchedItems { get; private set; } + } + + // Subclass just to give a logical name to a mess of generics + private class Criterion : Dictionary>> + { + public Criterion(DecisionCriterionValueEqualityComparer comparer) + : base(comparer) + { + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionTreeNode.cs b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionTreeNode.cs new file mode 100644 index 0000000000..3defcf0570 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/DecisionTreeNode.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.Internal.DecisionTree +{ + public class DecisionTreeNode + { + public List Matches { get; set; } + + public List> Criteria { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/IClassifier.cs b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/IClassifier.cs new file mode 100644 index 0000000000..58ff1141ac --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/IClassifier.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.Internal.DecisionTree +{ + public interface IClassifier + { + IDictionary GetCriteria(TItem item); + + IEqualityComparer ValueComparer { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/ItemDescriptor.cs b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/ItemDescriptor.cs new file mode 100644 index 0000000000..9c2a4df784 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Internal/DecisionTree/ItemDescriptor.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.Internal.DecisionTree +{ + public class ItemDescriptor + { + public IDictionary Criteria { get; set; } + + public int Index { get; set; } + + public TItem Item { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Logging/DefaultActionSelectorSelectAsyncValues.cs b/src/Microsoft.AspNet.Mvc.Core/Logging/DefaultActionSelectorSelectAsyncValues.cs index 72dcc1bb6f..4699f7c459 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Logging/DefaultActionSelectorSelectAsyncValues.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Logging/DefaultActionSelectorSelectAsyncValues.cs @@ -25,22 +25,22 @@ namespace Microsoft.AspNet.Mvc.Logging /// /// The list of actions that matched all their route constraints, if any. /// - public IList ActionsMatchingRouteConstraints { get; set; } + public IReadOnlyList ActionsMatchingRouteConstraints { get; set; } /// /// The list of actions that matched all their route and method constraints, if any. /// - public IList ActionsMatchingRouteAndMethodConstraints { get; set; } + public IReadOnlyList ActionsMatchingRouteAndMethodConstraints { get; set; } /// /// The list of actions that matched all their route, method, and dynamic constraints, if any. /// - public IList ActionsMatchingRouteAndMethodAndDynamicConstraints { get; set; } + public IReadOnlyList ActionsMatchingRouteAndMethodAndDynamicConstraints { get; set; } /// /// The actions that matched with at least one constraint. /// - public IList ActionsMatchingWithConstraints { get; set; } + public IReadOnlyList ActionsMatchingWithConstraints { get; set; } /// /// The selected action. diff --git a/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj b/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj index 74a333ce67..c68b0ee58d 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj +++ b/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj @@ -32,6 +32,8 @@ + + @@ -62,6 +64,18 @@ + + + + + + + + + + + + @@ -249,13 +263,18 @@ + + + + + @@ -287,4 +306,4 @@ - + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/RouteDataActionConstraint.cs b/src/Microsoft.AspNet.Mvc.Core/RouteDataActionConstraint.cs index bbe1e3f5ce..7f5d099fa3 100644 --- a/src/Microsoft.AspNet.Mvc.Core/RouteDataActionConstraint.cs +++ b/src/Microsoft.AspNet.Mvc.Core/RouteDataActionConstraint.cs @@ -43,7 +43,6 @@ namespace Microsoft.AspNet.Mvc { switch (keyHandling) { - case RouteKeyHandling.AcceptAlways: case RouteKeyHandling.CatchAll: case RouteKeyHandling.DenyKey: case RouteKeyHandling.RequireKey: @@ -81,9 +80,6 @@ namespace Microsoft.AspNet.Mvc object value; switch (KeyHandling) { - case RouteKeyHandling.AcceptAlways: - return true; - case RouteKeyHandling.CatchAll: return routeValues.ContainsKey(RouteKey); diff --git a/src/Microsoft.AspNet.Mvc.Core/RouteKeyHandling.cs b/src/Microsoft.AspNet.Mvc.Core/RouteKeyHandling.cs index 31c774b737..b92229a754 100644 --- a/src/Microsoft.AspNet.Mvc.Core/RouteKeyHandling.cs +++ b/src/Microsoft.AspNet.Mvc.Core/RouteKeyHandling.cs @@ -21,10 +21,5 @@ namespace Microsoft.AspNet.Mvc /// Requires that the key will be in the route values, but ignore the content. /// CatchAll, - - /// - /// Always accept. - /// - AcceptAlways, } } diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/ActionSelectorDecisionTree.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/ActionSelectorDecisionTree.cs new file mode 100644 index 0000000000..fb1eced959 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/ActionSelectorDecisionTree.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.ComponentModel; +using System.Diagnostics.Contracts; +using Microsoft.AspNet.Mvc.Internal.DecisionTree; + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + public class ActionSelectionDecisionTree : IActionSelectionDecisionTree + { + private readonly DecisionTreeNode _root; + + /// + /// Creates a new . + /// + /// The . + public ActionSelectionDecisionTree(ActionDescriptorsCollection actions) + { + Version = actions.Version; + + _root = DecisionTreeBuilder.GenerateTree( + actions.Items, + new ActionDescriptorClassifier()); + } + + /// + public int Version { get; private set; } + + /// + public IReadOnlyList Select(IDictionary routeValues) + { + var results = new List(); + Walk(results, routeValues, _root); + + // If we have a match that isn't using catch-all, then it's considered better than matches with catch all + // so filter those out. + var hasNonCatchAll = false; + + // The common case for MVC has no catch-alls, so avoid allocating. + List filtered = null; + + foreach (var action in results) + { + var actionHasCatchAll = false; + if (action.RouteConstraints != null) + { + foreach (var constraint in action.RouteConstraints) + { + if (constraint.KeyHandling == RouteKeyHandling.CatchAll) + { + actionHasCatchAll = true; + break; + } + } + } + + if (hasNonCatchAll && actionHasCatchAll) + { + // Do nothing - we've already found a better match. + } + else if (actionHasCatchAll) + { + if (filtered == null) + { + filtered = new List(); + } + + filtered.Add(action); + } + else if (hasNonCatchAll) + { + Contract.Assert(filtered != null); + filtered.Add(action); + } + else + { + // This is the first non-catch-all we've found. + hasNonCatchAll = true; + + if (filtered == null) + { + filtered = new List(); + } + else + { + filtered.Clear(); + } + + filtered.Add(action); + } + } + + return filtered ?? results; + } + + private void Walk( + List results, + IDictionary routeValues, + DecisionTreeNode node) + { + for (var i = 0; i < node.Matches.Count; i++) + { + results.Add(node.Matches[i]); + } + + for (var i = 0; i < node.Criteria.Count; i++) + { + var criterion = node.Criteria[i]; + var key = criterion.Key; + + object value; + var hasValue = routeValues.TryGetValue(key, out value); + + DecisionTreeNode branch; + if (criterion.Branches.TryGetValue(value ?? string.Empty, out branch)) + { + Walk(results, routeValues, branch); + } + + // If there's a fallback node we always need to process it when we have a value. We'll prioritize + // non-fallback matches later in the process. + if (hasValue && criterion.Fallback != null) + { + Walk(results, routeValues, criterion.Fallback); + } + } + } + + private class ActionDescriptorClassifier : IClassifier + { + public ActionDescriptorClassifier() + { + ValueComparer = new RouteValueEqualityComparer(); + } + + public IEqualityComparer ValueComparer { get; private set; } + + public IDictionary GetCriteria(ActionDescriptor item) + { + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (item.RouteConstraints != null) + { + foreach (var constraint in item.RouteConstraints) + { + DecisionCriterionValue value; + if (constraint.KeyHandling == RouteKeyHandling.CatchAll) + { + value = new DecisionCriterionValue(value: null, isCatchAll: true); + } + else if (constraint.KeyHandling == RouteKeyHandling.DenyKey) + { + // null and string.Empty are equivalent for route values, so just treat nulls as + // string.Empty. + value = new DecisionCriterionValue(value: string.Empty, isCatchAll: false); + } + else if (constraint.KeyHandling == RouteKeyHandling.RequireKey) + { + value = new DecisionCriterionValue(value: constraint.RouteValue, isCatchAll: false); + } + else + { + // We'd already have failed before getting here. The RouteDataActionConstraint constructor + // would throw. +#if NET45 + throw new InvalidEnumArgumentException( + "item", + (int)constraint.KeyHandling, + typeof(RouteKeyHandling)); +#else + throw new ArgumentOutOfRangeException("item"); +#endif + } + + // Workaround for Javier's cool bug. + if (!results.ContainsKey(constraint.RouteKey)) + { + results.Add(constraint.RouteKey, value); + } + } + } + + return results; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/ActionSelectorDecisionTreeProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/ActionSelectorDecisionTreeProvider.cs new file mode 100644 index 0000000000..653a0e0d11 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/ActionSelectorDecisionTreeProvider.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Mvc.Core; + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + public class ActionSelectorDecisionTreeProvider : IActionSelectorDecisionTreeProvider + { + private readonly IActionDescriptorsCollectionProvider _actionDescriptorsCollectionProvider; + private ActionSelectionDecisionTree _decisionTree; + + /// + /// Creates a new . + /// + /// + /// The . + /// + public ActionSelectorDecisionTreeProvider( + IActionDescriptorsCollectionProvider actionDescriptorsCollectionProvider) + { + _actionDescriptorsCollectionProvider = actionDescriptorsCollectionProvider; + } + + /// + public IActionSelectionDecisionTree DecisionTree + { + get + { + var descriptors = _actionDescriptorsCollectionProvider.ActionDescriptors; + if (descriptors == null) + { + throw new InvalidOperationException( + Resources.FormatPropertyOfTypeCannotBeNull( + "ActionDescriptors", + _actionDescriptorsCollectionProvider.GetType())); + } + + if (_decisionTree == null || descriptors.Version != _decisionTree.Version) + { + _decisionTree = new ActionSelectionDecisionTree(descriptors); + } + + return _decisionTree; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/IActionSelectionDecisionTree.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/IActionSelectionDecisionTree.cs new file mode 100644 index 0000000000..d941f58da8 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/IActionSelectionDecisionTree.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + /// A data structure that retrieves a list of matches based on the values + /// supplied for the current request by . + /// + public interface IActionSelectionDecisionTree + { + /// + /// Gets the version. The same as the value of . + /// + int Version { get; } + + /// + /// Retrieves a set of based on the route values supplied by + /// / + /// + /// The route values for the current request. + /// A set of matching the route values. + IReadOnlyList Select(IDictionary routeValues); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/IActionSelectorDecisionTreeProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/IActionSelectorDecisionTreeProvider.cs new file mode 100644 index 0000000000..caf5e4a351 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/IActionSelectorDecisionTreeProvider.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + /// Stores an for the current value of + /// . + /// + public interface IActionSelectorDecisionTreeProvider + { + /// + /// Gets the . + /// + IActionSelectionDecisionTree DecisionTree + { + get; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/RouteValueEqualityComparer.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/RouteValueEqualityComparer.cs new file mode 100644 index 0000000000..44232f4d05 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/RouteValueEqualityComparer.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Globalization; + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + /// An implementation that compares objects as-if + /// they were route value strings. + /// + /// + /// Values that are are not strings are converted to strings using + /// Convert.ToString(x, CultureInfo.InvariantCulture). null values are converted + /// to the empty string. + /// + /// strings are compared using . + /// + public class RouteValueEqualityComparer : IEqualityComparer + { + /// + public new bool Equals(object x, object y) + { + var stringX = x as string ?? Convert.ToString(x, CultureInfo.InvariantCulture); + var stringY = y as string ?? Convert.ToString(y, CultureInfo.InvariantCulture); + + if (string.IsNullOrEmpty(stringX) && string.IsNullOrEmpty(stringY)) + { + return true; + } + else + { + return string.Equals(stringX, stringY, StringComparison.OrdinalIgnoreCase); + } + } + + /// + public int GetHashCode(object obj) + { + var stringObj = obj as string ?? Convert.ToString(obj, CultureInfo.InvariantCulture); + if (string.IsNullOrEmpty(stringObj)) + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(string.Empty); + } + else + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(stringObj); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc/MvcServices.cs b/src/Microsoft.AspNet.Mvc/MvcServices.cs index de8a7580cc..a0b9485a79 100644 --- a/src/Microsoft.AspNet.Mvc/MvcServices.cs +++ b/src/Microsoft.AspNet.Mvc/MvcServices.cs @@ -9,6 +9,7 @@ using Microsoft.AspNet.Mvc.OptionDescriptors; using Microsoft.AspNet.Mvc.Razor; using Microsoft.AspNet.Mvc.Razor.Compilation; using Microsoft.AspNet.Mvc.Rendering; +using Microsoft.AspNet.Mvc.Routing; using Microsoft.AspNet.Security; using Microsoft.Framework.ConfigurationModel; using Microsoft.Framework.DependencyInjection; @@ -32,7 +33,10 @@ namespace Microsoft.AspNet.Mvc yield return describe.Transient(); yield return describe.Singleton(); + + yield return describe.Singleton(); yield return describe.Scoped(); + yield return describe.Transient(); yield return describe.Transient(); yield return describe.Transient(); diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs index 5cda802b73..7b72b435e1 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNet.Http; +using Microsoft.AspNet.Mvc.Routing; using Microsoft.AspNet.Routing; using Microsoft.Framework.DependencyInjection; using Microsoft.Framework.DependencyInjection.NestedProviders; @@ -195,12 +196,15 @@ namespace Microsoft.AspNet.Mvc descriptorProvider); var actionCollectionDescriptorProvider = new DefaultActionDescriptorsCollectionProvider(serviceContainer); + var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionCollectionDescriptorProvider); var bindingProvider = new Mock(); - var defaultActionSelector = new DefaultActionSelector(actionCollectionDescriptorProvider, - bindingProvider.Object, - NullLoggerFactory.Instance); + var defaultActionSelector = new DefaultActionSelector( + actionCollectionDescriptorProvider, + decisionTreeProvider, + bindingProvider.Object, + NullLoggerFactory.Instance); return await defaultActionSelector.SelectAsync(context); } diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionDiscoveryConventionsActionSelectionTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionDiscoveryConventionsActionSelectionTests.cs index aff4c35210..9ca5e78431 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionDiscoveryConventionsActionSelectionTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionDiscoveryConventionsActionSelectionTests.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNet.Http; +using Microsoft.AspNet.Mvc.Routing; using Microsoft.AspNet.Routing; using Microsoft.Framework.DependencyInjection; using Microsoft.Framework.DependencyInjection.NestedProviders; @@ -174,12 +175,15 @@ namespace Microsoft.AspNet.Mvc descriptorProvider); var actionCollectionDescriptorProvider = new DefaultActionDescriptorsCollectionProvider(serviceContainer); + var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionCollectionDescriptorProvider); var bindingProvider = new Mock(); - var defaultActionSelector = new DefaultActionSelector(actionCollectionDescriptorProvider, - bindingProvider.Object, - NullLoggerFactory.Instance); + var defaultActionSelector = new DefaultActionSelector( + actionCollectionDescriptorProvider, + decisionTreeProvider, + bindingProvider.Object, + NullLoggerFactory.Instance); return await defaultActionSelector.SelectAsync(context); } diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTests.cs index 6dfca7f57a..9f72ec2bdd 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNet.Http; +using Microsoft.AspNet.Mvc.Routing; using Microsoft.AspNet.Routing; using Microsoft.AspNet.Mvc.Logging; using Microsoft.Framework.Logging; @@ -215,12 +216,14 @@ namespace Microsoft.AspNet.Mvc actionProvider .Setup(p => p.ActionDescriptors).Returns(new ActionDescriptorsCollection(actions, 0)); + var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionProvider.Object); + var bindingProvider = new Mock(MockBehavior.Strict); bindingProvider .Setup(bp => bp.GetActionBindingContextAsync(It.IsAny())) .Returns(Task.FromResult(null)); - return new DefaultActionSelector(actionProvider.Object, bindingProvider.Object, loggerFactory); + return new DefaultActionSelector(actionProvider.Object, decisionTreeProvider, bindingProvider.Object, loggerFactory); } private static VirtualPathContext CreateContext(object routeValues) diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Internal/DecisionTree/DecisionTreeBuilderTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Internal/DecisionTree/DecisionTreeBuilderTest.cs new file mode 100644 index 0000000000..e0525fccef --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Internal/DecisionTree/DecisionTreeBuilderTest.cs @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc.Routing; +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Internal.DecisionTree +{ + public class DecisionTreeBuilderTest + { + [Fact] + public void BuildTree_Empty() + { + // Arrange + var items = new List(); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Criteria); + Assert.Empty(tree.Matches); + } + + [Fact] + public void BuildTree_TrivialMatch() + { + // Arrange + var items = new List(); + + var item = new Item(); + items.Add(item); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Criteria); + Assert.Same(item, Assert.Single(tree.Matches)); + } + + [Fact] + public void BuildTree_WithMultipleCriteria() + { + // Arrange + var items = new List(); + + var item = new Item(); + item.Criteria.Add("area", new DecisionCriterionValue(value: "Admin", isCatchAll: false)); + item.Criteria.Add("controller", new DecisionCriterionValue(value: "Users", isCatchAll: false)); + item.Criteria.Add("action", new DecisionCriterionValue(value: "AddUser", isCatchAll: false)); + items.Add(item); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var area = Assert.Single(tree.Criteria); + Assert.Equal("area", area.Key); + Assert.Null(area.Fallback); + + var admin = Assert.Single(area.Branches); + Assert.Equal("Admin", admin.Key); + Assert.Empty(admin.Value.Matches); + + var controller = Assert.Single(admin.Value.Criteria); + Assert.Equal("controller", controller.Key); + Assert.Null(controller.Fallback); + + var users = Assert.Single(controller.Branches); + Assert.Equal("Users", users.Key); + Assert.Empty(users.Value.Matches); + + var action = Assert.Single(users.Value.Criteria); + Assert.Equal("action", action.Key); + Assert.Null(action.Fallback); + + var addUser = Assert.Single(action.Branches); + Assert.Equal("AddUser", addUser.Key); + Assert.Empty(addUser.Value.Criteria); + Assert.Same(item, Assert.Single(addUser.Value.Matches)); + } + + [Fact] + public void BuildTree_WithMultipleItems() + { + // Arrange + var items = new List(); + + var item1 = new Item(); + item1.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item1.Criteria.Add("action", new DecisionCriterionValue(value: "Buy", isCatchAll: false)); + items.Add(item1); + + var item2 = new Item(); + item2.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item2.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item2); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var action = Assert.Single(tree.Criteria); + Assert.Equal("action", action.Key); + Assert.Null(action.Fallback); + + var buy = action.Branches["Buy"]; + Assert.Empty(buy.Matches); + + var controller = Assert.Single(buy.Criteria); + Assert.Equal("controller", controller.Key); + Assert.Null(controller.Fallback); + + var store = Assert.Single(controller.Branches); + Assert.Equal("Store", store.Key); + Assert.Empty(store.Value.Criteria); + Assert.Same(item1, Assert.Single(store.Value.Matches)); + + var checkout = action.Branches["Checkout"]; + Assert.Empty(checkout.Matches); + + controller = Assert.Single(checkout.Criteria); + Assert.Equal("controller", controller.Key); + Assert.Null(controller.Fallback); + + store = Assert.Single(controller.Branches); + Assert.Equal("Store", store.Key); + Assert.Empty(store.Value.Criteria); + Assert.Same(item2, Assert.Single(store.Value.Matches)); + } + + [Fact] + public void BuildTree_WithInteriorMatch() + { + // Arrange + var items = new List(); + + var item1 = new Item(); + item1.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item1.Criteria.Add("action", new DecisionCriterionValue(value: "Buy", isCatchAll: false)); + items.Add(item1); + + var item2 = new Item(); + item2.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item2.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item2); + + var item3 = new Item(); + item3.Criteria.Add("action", new DecisionCriterionValue(value: "Buy", isCatchAll: false)); + items.Add(item3); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var action = Assert.Single(tree.Criteria); + Assert.Equal("action", action.Key); + Assert.Null(action.Fallback); + + var buy = action.Branches["Buy"]; + Assert.Same(item3, Assert.Single(buy.Matches)); + } + + [Fact] + public void BuildTree_WithCatchAll() + { + // Arrange + var items = new List(); + + var item1 = new Item(); + item1.Criteria.Add("country", new DecisionCriterionValue(value: "CA", isCatchAll: false)); + item1.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item1.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item1); + + var item2 = new Item(); + item2.Criteria.Add("country", new DecisionCriterionValue(value: "US", isCatchAll: false)); + item2.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item2.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item2); + + var item3 = new Item(); + item3.Criteria.Add("country", new DecisionCriterionValue(value: null, isCatchAll: true)); + item3.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item3.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item3); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var country = Assert.Single(tree.Criteria); + Assert.Equal("country", country.Key); + + var fallback = country.Fallback; + Assert.NotNull(fallback); + + var controller = Assert.Single(fallback.Criteria); + Assert.Equal("controller", controller.Key); + Assert.Null(controller.Fallback); + + var store = Assert.Single(controller.Branches); + Assert.Equal("Store", store.Key); + Assert.Empty(store.Value.Matches); + + var action = Assert.Single(store.Value.Criteria); + Assert.Equal("action", action.Key); + Assert.Null(action.Fallback); + + var checkout = Assert.Single(action.Branches); + Assert.Equal("Checkout", checkout.Key); + Assert.Empty(checkout.Value.Criteria); + Assert.Same(item3, Assert.Single(checkout.Value.Matches)); + } + + [Fact] + public void BuildTree_WithDivergentCriteria() + { + // Arrange + var items = new List(); + + var item1 = new Item(); + item1.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item1.Criteria.Add("action", new DecisionCriterionValue(value: "Buy", isCatchAll: false)); + items.Add(item1); + + var item2 = new Item(); + item2.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item2.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item2); + + var item3 = new Item(); + item3.Criteria.Add("stub", new DecisionCriterionValue(value: "Bleh", isCatchAll: false)); + items.Add(item3); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var action = tree.Criteria[0]; + Assert.Equal("action", action.Key); + + var stub = tree.Criteria[1]; + Assert.Equal("stub", stub.Key); + } + + private class Item + { + public Item() + { + Criteria = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public Dictionary Criteria { get; private set; } + } + + private class ItemClassifier : IClassifier + { + public IEqualityComparer ValueComparer + { + get + { + return new RouteValueEqualityComparer(); + } + } + + public IDictionary GetCriteria(Item item) + { + return item.Criteria; + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj index 8c21609ea7..7f051786f8 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj @@ -36,6 +36,7 @@ + @@ -119,4 +120,4 @@ - \ No newline at end of file + diff --git a/test/WebSites/ModelBindingWebSite/ModelBindingWebSite.kproj b/test/WebSites/ModelBindingWebSite/ModelBindingWebSite.kproj index 113f6d6c2f..f098cef95a 100644 --- a/test/WebSites/ModelBindingWebSite/ModelBindingWebSite.kproj +++ b/test/WebSites/ModelBindingWebSite/ModelBindingWebSite.kproj @@ -29,7 +29,6 @@ -