// 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.Linq; using System.Text; using Microsoft.AspNetCore.Routing.DecisionTree; using Microsoft.AspNetCore.Routing.Tree; namespace Microsoft.AspNetCore.Routing.Internal { // A decision tree that matches link generation entries based on route data. [DebuggerDisplay("{DebuggerDisplayString,nq}")] public class LinkGenerationDecisionTree { private readonly DecisionTreeNode _root; public LinkGenerationDecisionTree(IReadOnlyList entries) { _root = DecisionTreeBuilder.GenerateTree( entries, new OutboundMatchClassifier()); } public IList GetMatches(RouteValueDictionary values, RouteValueDictionary ambientValues) { // Perf: Avoid allocation for List if there aren't any Matches or Criteria if (_root.Matches.Count > 0 || _root.Criteria.Count > 0) { var results = new List(); Walk(results, values, ambientValues, _root, isFallbackPath: false); results.Sort(OutboundMatchResultComparer.Instance); return results; } return null; } // We need to recursively walk the decision tree based on the provided route data // (context.Values + context.AmbientValues) to find all entries that match. This process is // virtually identical to action selection. // // Each entry has a collection of 'required link values' that must be satisfied. These are // key-value pairs that make up the decision tree. // // A 'require link value' is considered satisfied IF: // 1. The value in context.Values matches the required value OR // 2. There is no value in context.Values and the value in context.AmbientValues matches OR // 3. The required value is 'null' and there is no value in context.Values. // // Ex: // entry requires { area = null, controller = Store, action = Buy } // context.Values = { controller = Store, action = Buy } // context.AmbientValues = { area = Help, controller = AboutStore, action = HowToBuyThings } // // In this case the entry is a match. The 'controller' and 'action' are both supplied by context.Values, // and the 'area' is satisfied because there's NOT a value in context.Values. It's OK to ignore ambient // values in link generation. // // If another entry existed like { area = Help, controller = Store, action = Buy }, this would also // match. // // The decision tree uses a tree data structure to execute these rules across all candidates at once. private void Walk( List results, RouteValueDictionary values, RouteValueDictionary ambientValues, DecisionTreeNode node, bool isFallbackPath) { // Any entries in node.Matches have had all their required values satisfied, so add them // to the results. for (var i = 0; i < node.Matches.Count; i++) { results.Add(new OutboundMatchResult(node.Matches[i], isFallbackPath)); } for (var i = 0; i < node.Criteria.Count; i++) { var criterion = node.Criteria[i]; var key = criterion.Key; object value; if (values.TryGetValue(key, out value)) { DecisionTreeNode branch; if (criterion.Branches.TryGetValue(value ?? string.Empty, out branch)) { Walk(results, values, ambientValues, branch, isFallbackPath); } } else { // If a value wasn't explicitly supplied, match BOTH the ambient value and the empty value // if an ambient value was supplied. The path explored with the empty value is considered // the fallback path. DecisionTreeNode branch; if (ambientValues.TryGetValue(key, out value) && !criterion.Branches.Comparer.Equals(value, string.Empty)) { if (criterion.Branches.TryGetValue(value, out branch)) { Walk(results, values, ambientValues, branch, isFallbackPath); } } if (criterion.Branches.TryGetValue(string.Empty, out branch)) { Walk(results, values, ambientValues, branch, isFallbackPath: true); } } } } private class OutboundMatchClassifier : IClassifier { public OutboundMatchClassifier() { ValueComparer = new RouteValueEqualityComparer(); } public IEqualityComparer ValueComparer { get; private set; } public IDictionary GetCriteria(OutboundMatch item) { var results = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kvp in item.Entry.RequiredLinkValues) { results.Add(kvp.Key, new DecisionCriterionValue(kvp.Value ?? string.Empty)); } return results; } } private class OutboundMatchResultComparer : IComparer { public static readonly OutboundMatchResultComparer Instance = new OutboundMatchResultComparer(); public int Compare(OutboundMatchResult x, OutboundMatchResult y) { // For this comparison lower is better. if (x.Match.Entry.Order != y.Match.Entry.Order) { return x.Match.Entry.Order.CompareTo(y.Match.Entry.Order); } if (x.Match.Entry.Precedence != y.Match.Entry.Precedence) { // Reversed because higher is better return y.Match.Entry.Precedence.CompareTo(x.Match.Entry.Precedence); } if (x.IsFallbackMatch != y.IsFallbackMatch) { // A fallback match is worse than a non-fallback return x.IsFallbackMatch.CompareTo(y.IsFallbackMatch); } return StringComparer.Ordinal.Compare( x.Match.Entry.RouteTemplate.TemplateText, y.Match.Entry.RouteTemplate.TemplateText); } } // Example output: // // => action: Buy => controller: Store => version: V1(Matches: Store/Buy/V1) // => action: Buy => controller: Store => version: V2(Matches: Store/Buy/V2) // => action: Buy => controller: Store => area: Admin(Matches: Admin/Store/Buy) // => action: Buy => controller: Products(Matches: Products/Buy) // => action: Cart => controller: Store(Matches: Store/Cart) internal string DebuggerDisplayString { get { var sb = new StringBuilder(); var branchStack = new Stack(); branchStack.Push(string.Empty); FlattenTree(branchStack, sb, _root); return sb.ToString(); } } private void FlattenTree(Stack branchStack, StringBuilder sb, DecisionTreeNode node) { // leaf node if (node.Criteria.Count == 0) { var matchesSb = new StringBuilder(); foreach (var branch in branchStack) { matchesSb.Insert(0, branch); } sb.Append(matchesSb.ToString()); sb.Append(" (Matches: "); sb.Append(string.Join(", ", node.Matches.Select(m => m.Entry.RouteTemplate.TemplateText))); sb.AppendLine(")"); } foreach (var criterion in node.Criteria) { foreach (var branch in criterion.Branches) { branchStack.Push($" => {criterion.Key}: {branch.Key}"); FlattenTree(branchStack, sb, branch.Value); branchStack.Pop(); } } } } }