// 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; namespace Microsoft.AspNetCore.Mvc.Internal { /// /// This is a container for prefix values. It normalizes all the values into dotted-form and then stores /// them in a sorted array. All queries for prefixes are also normalized to dotted-form, and searches /// for ContainsPrefix are done with a binary search. /// public class PrefixContainer { private static readonly char[] Delimiters = new char[] { '[', '.' }; private readonly ICollection _originalValues; private readonly string[] _sortedValues; public PrefixContainer(ICollection values) { if (values == null) { throw new ArgumentNullException(nameof(values)); } _originalValues = values; if (_originalValues.Count == 0) { _sortedValues = Array.Empty(); } else { _sortedValues = new string[_originalValues.Count]; _originalValues.CopyTo(_sortedValues, 0); Array.Sort(_sortedValues, StringComparer.OrdinalIgnoreCase); } } public bool ContainsPrefix(string prefix) { if (prefix == null) { throw new ArgumentNullException(nameof(prefix)); } if (_sortedValues.Length == 0) { return false; } if (prefix.Length == 0) { return true; // Empty prefix matches all elements. } return BinarySearch(prefix) > -1; } // Given "foo.bar", "foo.hello", "something.other", foo[abc].baz and asking for prefix "foo" will return: // - "bar"/"foo.bar" // - "hello"/"foo.hello" // - "abc"/"foo[abc]" public IDictionary GetKeysFromPrefix(string prefix) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var entry in _originalValues) { if (entry != null) { if (entry.Length == prefix.Length) { // No key in this entry continue; } if (prefix.Length == 0) { GetKeyFromEmptyPrefix(entry, result); } else if (entry.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { GetKeyFromNonEmptyPrefix(prefix, entry, result); } } } return result; } private static void GetKeyFromEmptyPrefix(string entry, IDictionary results) { string key; string fullName; var delimiterPosition = entry.IndexOfAny(Delimiters, 0); if (delimiterPosition == 0 && entry[0] == '[') { // Handle an entry such as "[key]". var bracketPosition = entry.IndexOf(']', 1); if (bracketPosition == -1) { // Malformed for dictionary. return; } key = entry.Substring(1, bracketPosition - 1); fullName = entry.Substring(0, bracketPosition + 1); } else { // Handle an entry such as "key", "key.property" and "key[index]". key = delimiterPosition == -1 ? entry : entry.Substring(0, delimiterPosition); fullName = key; } if (!results.ContainsKey(key)) { results.Add(key, fullName); } } private static void GetKeyFromNonEmptyPrefix(string prefix, string entry, IDictionary results) { string key; string fullName; var keyPosition = prefix.Length + 1; switch (entry[prefix.Length]) { case '.': // Handle an entry such as "prefix.key", "prefix.key.property" and "prefix.key[index]". var delimiterPosition = entry.IndexOfAny(Delimiters, keyPosition); if (delimiterPosition == -1) { // Neither '.' nor '[' found later in the name. Use rest of the string. key = entry.Substring(keyPosition); fullName = entry; } else { key = entry.Substring(keyPosition, delimiterPosition - keyPosition); fullName = entry.Substring(0, delimiterPosition); } break; case '[': // Handle an entry such as "prefix[key]". var bracketPosition = entry.IndexOf(']', keyPosition); if (bracketPosition == -1) { // Malformed for dictionary return; } key = entry.Substring(keyPosition, bracketPosition - keyPosition); fullName = entry.Substring(0, bracketPosition + 1); break; default: // Ignore an entry such as "prefixA". return; } if (!results.ContainsKey(key)) { results.Add(key, fullName); } } // This is tightly coupled to the definition at ModelStateDictionary.StartsWithPrefix private int BinarySearch(string prefix) { var start = 0; var end = _sortedValues.Length - 1; while (start <= end) { var pivot = start + ((end - start) / 2); var candidate = _sortedValues[pivot]; var compare = string.Compare( prefix, 0, candidate, 0, prefix.Length, StringComparison.OrdinalIgnoreCase); if (compare == 0) { Debug.Assert(candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); // Okay, now we have a candidate that starts with the prefix. If the candidate is longer than // the prefix, we need to look at the next character and see if it's a delimiter. if (candidate.Length == prefix.Length) { // Exact match return pivot; } var c = candidate[prefix.Length]; if (c == '.' || c == '[') { // Match, followed by delimiter return pivot; } // Okay, so the candidate has some extra text. We need to keep searching. // // Can often assume the candidate string is greater than the prefix e.g. that works for // prefix: product // candidate: productId // most of the time because "product", "product.id", etc. will sort earlier than "productId". But, // the assumption isn't correct if "product[0]" is also in _sortedValues because that value will // sort later than "productId". // // Fall back to brute force and cover all the cases. return LinearSearch(prefix, start, end); } if (compare > 0) { start = pivot + 1; } else { end = pivot - 1; } } return ~start; } private int LinearSearch(string prefix, int start, int end) { for (; start <= end; start++) { var candidate = _sortedValues[start]; var compare = string.Compare( prefix, 0, candidate, 0, prefix.Length, StringComparison.OrdinalIgnoreCase); if (compare == 0) { Debug.Assert(candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); // Okay, now we have a candidate that starts with the prefix. If the candidate is longer than // the prefix, we need to look at the next character and see if it's a delimiter. if (candidate.Length == prefix.Length) { // Exact match return start; } var c = candidate[prefix.Length]; if (c == '.' || c == '[') { // Match, followed by delimiter return start; } // Keep checking until we've passed all StartsWith() matches. } if (compare < 0) { // Prefix is less than the candidate. No potential matches left. break; } } return ~start; } } }