From b66ff8afe69e52916688d69f8e9d734f103ca2e3 Mon Sep 17 00:00:00 2001 From: Henrik Gedionsen Date: Thu, 16 Jul 2026 10:46:51 +0200 Subject: [PATCH 1/7] Avoid some string allocations --- .../ElementOnlyOutputExpansionStrategy.cs | 5 +- .../Services/ApiMediaQueryService.cs | 46 ++++++++++++------- .../Strategies/ThrottlingCondition.cs | 2 +- .../DeliveryApi/ApiDocumentUrlService.cs | 2 +- .../DeliveryApi/ApiPublishedContentCache.cs | 2 +- .../Extensions/DictionaryExtensions.cs | 13 +++++- .../DocumentUrlAliasServiceExtensions.cs | 3 +- .../Extensions/UdiGetterExtensions.cs | 2 +- src/Umbraco.Core/Extensions/UriExtensions.cs | 12 ++--- src/Umbraco.Core/Routing/AliasUrlProvider.cs | 4 +- .../Routing/ContentFinderByKeyPath.cs | 2 +- .../Routing/DefaultUrlProvider.cs | 6 +-- src/Umbraco.Core/Semver/SemVersion.cs | 6 +-- src/Umbraco.Core/StringUdi.cs | 2 +- .../Templates/HtmlLocalLinkParser.cs | 6 +-- src/Umbraco.Core/UdiParser.cs | 6 +-- src/Umbraco.Core/UriUtilityCore.cs | 4 +- .../DeliveryApi/ApiRichTextParserBase.cs | 2 +- .../Migrations/Upgrade/UmbracoPlan.cs | 6 +-- .../ModelsBuilder/Building/TextBuilder.cs | 38 +++++++-------- .../Persistence/NPocoSqlExtensions.cs | 2 +- .../Implement/ContentRepositoryBase.cs | 2 +- .../Repositories/Implement/ListExtensions.cs | 2 +- .../SqlSyntax/SqlSyntaxProviderBase.cs | 14 +++++- ...ckEditorPropertyNotificationHandlerBase.cs | 2 +- .../RteBlockRenderingValueConverter.cs | 2 +- .../AspNetCoreHostingEnvironment.cs | 5 +- .../Filters/ModelBindingExceptionAttribute.cs | 2 +- 28 files changed, 115 insertions(+), 85 deletions(-) diff --git a/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs b/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs index 7c3493513986..9812f2de4f04 100644 --- a/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs +++ b/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs @@ -212,13 +212,14 @@ protected sealed class Node public static Node Parse(string value) { // verify that there are as many start brackets as there are end brackets - if (value.CountOccurrences("[") != value.CountOccurrences("]")) + ReadOnlySpan valueAsSpan = value.AsSpan(); + if (valueAsSpan.Count('[') != valueAsSpan.Count(']')) { throw new ArgumentException("Value did not contain an equal number of start and end brackets"); } // verify that the value does not start with a start bracket - if (value.StartsWith("[")) + if (value.StartsWith('[')) { throw new ArgumentException("Value cannot start with a bracket"); } diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs index 1265ee42b383..d52a45e2eff3 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs @@ -63,14 +63,21 @@ public Attempt, ApiMediaQueryOperationStatus> ExecuteQuery(stri private IPublishedMediaCache GetRequiredPublishedMediaCache() => _publishedMediaCache; - private IPublishedContent? TryGetByPath(string path, IPublishedMediaCache mediaCache) + private IPublishedContent? TryGetByPath(ReadOnlySpan path, IPublishedMediaCache mediaCache) { - var segments = path.Split(Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries); + MemoryExtensions.SpanSplitEnumerator segments = path.Split(Constants.CharArrays.ForwardSlash); IEnumerable currentChildren = GetRootContent(mediaCache); IPublishedContent? resolvedMedia = null; - foreach (var segment in segments) + foreach (Range segmentRange in segments) { + ReadOnlySpan segmentSpan = path[segmentRange]; + if (segmentSpan.IsEmpty) + { + continue; + } + + string segment = new string(segmentSpan); resolvedMedia = currentChildren.FirstOrDefault(c => segment.InvariantEquals(c.Name)); if (resolvedMedia is null) { @@ -93,15 +100,15 @@ private IPublishedMediaCache GetRequiredPublishedMediaCache() return null; } - var childrenOf = fetch.TrimStart(childrenOfParameter); - if (childrenOf.IsNullOrWhiteSpace()) + ReadOnlySpan childrenOf = fetch.AsSpan().TrimStart(childrenOfParameter); + if (childrenOf.IsEmpty) { // this mirrors the current behavior of the Content Delivery API :-) - return Array.Empty(); + return []; } IPublishedMediaCache mediaCache = GetRequiredPublishedMediaCache(); - if (childrenOf.Trim(Constants.CharArrays.ForwardSlash).Length == 0) + if (childrenOf.Trim(Constants.CharArrays.ForwardSlash).IsEmpty) { return GetRootContent(mediaCache); } @@ -110,28 +117,31 @@ private IPublishedMediaCache GetRequiredPublishedMediaCache() ? mediaCache.GetById(parentKey) : TryGetByPath(childrenOf, mediaCache); - return parent?.Children(_mediaNavigationQueryService, _publishedMediaStatusFilteringService) ?? Array.Empty(); + return parent?.Children(_mediaNavigationQueryService, _publishedMediaStatusFilteringService) ?? []; } private IEnumerable? ApplyFilters(IEnumerable source, IEnumerable filters) { + Span partRanges = stackalloc Range[3]; foreach (var filter in filters) { - var parts = filter.Split(':'); - if (parts.Length != 2) + ReadOnlySpan filterAsSpan = filter.AsSpan(); + int partsFound = filterAsSpan.Split(partRanges, ':'); + if (partsFound != 2) { // invalid filter _logger.LogInformation("An invalid filter option was encountered. Please ensure that supplied filter options are two-part, separated by ':'."); return null; } - switch (parts[0]) + string secondPart = new string(filterAsSpan[partRanges[1]]); + switch (filterAsSpan[partRanges[0]]) { case "mediaType": - source = source.Where(c => c.ContentType.Alias == parts[1]); + source = source.Where(c => c.ContentType.Alias == secondPart); break; case "name": - source = source.Where(c => c.Name.InvariantContains(parts[1])); + source = source.Where(c => c.Name.InvariantContains(secondPart)); break; default: // unknown filter @@ -145,10 +155,12 @@ private IPublishedMediaCache GetRequiredPublishedMediaCache() private IEnumerable? ApplySorts(IEnumerable source, IEnumerable sorts) { + Span partRanges = stackalloc Range[3]; foreach (var sort in sorts) { - var parts = sort.Split(':'); - if (parts.Length != 2) + ReadOnlySpan sortAsSpan = sort.AsSpan(); + int partsFound = sortAsSpan.Split(partRanges, ':'); + if (partsFound != 2) { // invalid sort _logger.LogInformation("An invalid sort option was encountered. Please ensure that the supplied sort options are two-part, separated by ':'."); @@ -156,7 +168,7 @@ private IPublishedMediaCache GetRequiredPublishedMediaCache() } Func keySelector; - switch (parts[0]) + switch (sortAsSpan[partRanges[0]]) { case "createDate": keySelector = content => content.CreateDate; @@ -176,7 +188,7 @@ private IPublishedMediaCache GetRequiredPublishedMediaCache() return null; } - source = parts[1].StartsWith("asc") + source = sortAsSpan[partRanges[1]].StartsWith("asc") ? source.OrderBy(keySelector) : source.OrderByDescending(keySelector); } diff --git a/src/Umbraco.Cms.Persistence.SqlServer/FaultHandling/Strategies/ThrottlingCondition.cs b/src/Umbraco.Cms.Persistence.SqlServer/FaultHandling/Strategies/ThrottlingCondition.cs index 52e48b7aef6b..25fcaf70e389 100644 --- a/src/Umbraco.Cms.Persistence.SqlServer/FaultHandling/Strategies/ThrottlingCondition.cs +++ b/src/Umbraco.Cms.Persistence.SqlServer/FaultHandling/Strategies/ThrottlingCondition.cs @@ -258,7 +258,7 @@ public static ThrottlingCondition FromError(SqlError? error) { Match match = _sqlErrorCodeRegEx.Match(error.Message); - if (match.Success && int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int reasonCode)) + if (match.Success && int.TryParse(match.Groups[1].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out int reasonCode)) { return FromReasonCode(reasonCode); } diff --git a/src/Umbraco.Core/DeliveryApi/ApiDocumentUrlService.cs b/src/Umbraco.Core/DeliveryApi/ApiDocumentUrlService.cs index b1175f6ca85f..c7c4400c3261 100644 --- a/src/Umbraco.Core/DeliveryApi/ApiDocumentUrlService.cs +++ b/src/Umbraco.Core/DeliveryApi/ApiDocumentUrlService.cs @@ -26,7 +26,7 @@ public ApiDocumentUrlService(IDocumentUrlService documentUrlService) { var index = route.IndexOf('/'); - if (index > -1 && int.TryParse(route.Substring(0, index), out var nodeId)) + if (index > -1 && int.TryParse(route.AsSpan(0, index), out var nodeId)) { documentStartNodeId = nodeId; route = route.Substring(index); diff --git a/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs b/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs index 11a94403e402..28bc00d1224a 100644 --- a/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs +++ b/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs @@ -71,7 +71,7 @@ public ApiPublishedContentCache( // e.g. "1234/second-root-url-segment". in V15+, IDocumentUrlService won't resolve this anymore; it will // however resolve "1234/" correctly, so to remain backwards compatible, we need to perform this extra step. var verifyUrlSegment = false; - if (documentKey is null && route.TrimEnd('/').CountOccurrences("/") is 1) + if (documentKey is null && route.AsSpan().TrimEnd('/').Count('/') is 1) { documentKey = _apiDocumentUrlService.GetDocumentKeyByRoute( route[..(route.IndexOf('/') + 1)], diff --git a/src/Umbraco.Core/Extensions/DictionaryExtensions.cs b/src/Umbraco.Core/Extensions/DictionaryExtensions.cs index ffc5c73a56e2..ec2bbfec507a 100644 --- a/src/Umbraco.Core/Extensions/DictionaryExtensions.cs +++ b/src/Umbraco.Core/Extensions/DictionaryExtensions.cs @@ -268,18 +268,27 @@ public static string ToQueryString(this IDictionary d) } var builder = new StringBuilder(); + bool first = true; foreach (KeyValuePair i in d) { + if (first) + { + first = false; + } + else + { + builder.Append('&'); + } + builder.Append(WebUtility.UrlEncode(i.Key)); builder.Append('='); if (i.Value != null) { builder.Append(WebUtility.UrlEncode(i.Value.ToString())); } - builder.Append('&'); } - return builder.ToString().TrimEnd(Constants.CharArrays.Ampersand); + return builder.ToString(); } /// The get entry ignore case. diff --git a/src/Umbraco.Core/Extensions/DocumentUrlAliasServiceExtensions.cs b/src/Umbraco.Core/Extensions/DocumentUrlAliasServiceExtensions.cs index cc752394effc..ac0aea4a993b 100644 --- a/src/Umbraco.Core/Extensions/DocumentUrlAliasServiceExtensions.cs +++ b/src/Umbraco.Core/Extensions/DocumentUrlAliasServiceExtensions.cs @@ -16,7 +16,6 @@ internal static class DocumentUrlAliasServiceExtensions public static string NormalizeAlias(this IDocumentUrlAliasService service, string alias) => alias .Trim() - .TrimStart('/') - .TrimEnd('/') + .Trim('/') .ToLowerInvariant(); } diff --git a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs index 763dde84107f..d6d12fe0d5a4 100644 --- a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs +++ b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs @@ -406,7 +406,7 @@ public static GuidUdi GetUdi(this IWebhook entity) /// private static StringUdi GetUdiFromPath(string entityType, string path) { - string id = path.TrimStart(Constants.CharArrays.ForwardSlash).Replace("\\", "/"); + string id = path.TrimStart(Constants.CharArrays.ForwardSlash).Replace('\\', '/'); return new StringUdi(entityType, id).EnsureClosed(); } diff --git a/src/Umbraco.Core/Extensions/UriExtensions.cs b/src/Umbraco.Core/Extensions/UriExtensions.cs index a2145f3e554f..c68b073d9e1a 100644 --- a/src/Umbraco.Core/Extensions/UriExtensions.cs +++ b/src/Umbraco.Core/Extensions/UriExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; using System.Net; using System.Web; using Umbraco.Cms.Core; @@ -113,7 +112,7 @@ public static Uri EndPathWithSlash(this Uri uri) var path = uri.GetSafeAbsolutePath(); if (uri.IsAbsoluteUri) { - if (path != "/" && path.EndsWith("/") == false) + if (path != "/" && path.EndsWith('/') == false) { uri = new Uri(uri.GetLeftPart(UriPartial.Authority) + path + "/" + uri.Query); } @@ -121,7 +120,7 @@ public static Uri EndPathWithSlash(this Uri uri) return uri; } - if (path != "/" && path.EndsWith("/") == false) + if (path != "/" && path.EndsWith('/') == false) { uri = new Uri(path + "/" + uri.Query, UriKind.Relative); } @@ -142,15 +141,16 @@ public static Uri TrimPathEndSlash(this Uri uri) { if (path != "/") { - uri = new Uri(uri.GetLeftPart(UriPartial.Authority) + path.TrimEnd(Constants.CharArrays.ForwardSlash) + - uri.Query); + uri = new Uri( + $"{uri.GetLeftPart(UriPartial.Authority)}{path.AsSpan().TrimEnd(Constants.CharArrays.ForwardSlash)}{uri.Query}" + ); } } else { if (path != "/") { - uri = new Uri(path.TrimEnd(Constants.CharArrays.ForwardSlash) + uri.Query, UriKind.Relative); + uri = new Uri($"{path.AsSpan().TrimEnd(Constants.CharArrays.ForwardSlash)}{uri.Query}", UriKind.Relative); } } diff --git a/src/Umbraco.Core/Routing/AliasUrlProvider.cs b/src/Umbraco.Core/Routing/AliasUrlProvider.cs index 1313a54bb1be..bcb6459d9735 100644 --- a/src/Umbraco.Core/Routing/AliasUrlProvider.cs +++ b/src/Umbraco.Core/Routing/AliasUrlProvider.cs @@ -184,9 +184,9 @@ public IEnumerable GetOtherUrls(int id, Uri current) #region Utilities - private string CombinePaths(string path1, string path2) + private static string CombinePaths(string path1, string path2) { - var path = path1.TrimEnd(Constants.CharArrays.ForwardSlash) + path2; + var path = $"{path1.AsSpan().TrimEnd(Constants.CharArrays.ForwardSlash)}{path2}"; return path == "/" ? path : path.TrimEnd(Constants.CharArrays.ForwardSlash); } diff --git a/src/Umbraco.Core/Routing/ContentFinderByKeyPath.cs b/src/Umbraco.Core/Routing/ContentFinderByKeyPath.cs index c782d962c2f4..588f6a2b1bc3 100644 --- a/src/Umbraco.Core/Routing/ContentFinderByKeyPath.cs +++ b/src/Umbraco.Core/Routing/ContentFinderByKeyPath.cs @@ -65,7 +65,7 @@ public Task TryFindContent(IPublishedRequestBuilder frequest) return LogAndReturnFailure(); } - var noSlashPath = path.Substring(1); + ReadOnlySpan noSlashPath = path.AsSpan(1); if (Guid.TryParse(noSlashPath, out var nodeKey) == false) { diff --git a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs index e68d0af15909..7eeeee4126bf 100644 --- a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs @@ -224,7 +224,7 @@ public virtual IEnumerable GetOtherUrls(int id, Uri current) : DomainUtilities.DomainForNode( umbracoContext.Domains, _siteDomainMapper, - int.Parse(route[..pos], CultureInfo.InvariantCulture), + int.Parse(route.AsSpan(0, pos), CultureInfo.InvariantCulture), current, culture); @@ -313,9 +313,9 @@ private Uri AssembleUrl(DomainAndUri? domainUri, string path, Uri current, UrlMo return _uriUtility.UriFromUmbraco(uri, _requestSettings); } - private string CombinePaths(string path1, string path2) + private static string CombinePaths(string path1, string path2) { - var path = path1.TrimEnd(Constants.CharArrays.ForwardSlash) + path2; + var path = $"{path1.AsSpan().TrimEnd(Constants.CharArrays.ForwardSlash)}{path2}"; return path == "/" ? path : path.TrimEnd(Constants.CharArrays.ForwardSlash); } diff --git a/src/Umbraco.Core/Semver/SemVersion.cs b/src/Umbraco.Core/Semver/SemVersion.cs index 3c61d7d58fed..71c17d8a678b 100644 --- a/src/Umbraco.Core/Semver/SemVersion.cs +++ b/src/Umbraco.Core/Semver/SemVersion.cs @@ -122,7 +122,7 @@ public static SemVersion Parse(string version, bool strict = false) #if NETSTANDARD var major = int.Parse(match.Groups["major"].Value); #else - var major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture); + var major = int.Parse(match.Groups["major"].ValueSpan, CultureInfo.InvariantCulture); #endif Group minorMatch = match.Groups["minor"]; @@ -132,7 +132,7 @@ public static SemVersion Parse(string version, bool strict = false) #if NETSTANDARD minor = int.Parse(minorMatch.Value); #else - minor = int.Parse(minorMatch.Value, CultureInfo.InvariantCulture); + minor = int.Parse(minorMatch.ValueSpan, CultureInfo.InvariantCulture); #endif } else if (strict) @@ -147,7 +147,7 @@ public static SemVersion Parse(string version, bool strict = false) #if NETSTANDARD patch = int.Parse(patchMatch.Value); #else - patch = int.Parse(patchMatch.Value, CultureInfo.InvariantCulture); + patch = int.Parse(patchMatch.ValueSpan, CultureInfo.InvariantCulture); #endif } else if (strict) diff --git a/src/Umbraco.Core/StringUdi.cs b/src/Umbraco.Core/StringUdi.cs index fec7e139b8cc..8409ac56e3c2 100644 --- a/src/Umbraco.Core/StringUdi.cs +++ b/src/Umbraco.Core/StringUdi.cs @@ -23,7 +23,7 @@ public StringUdi(string entityType, string id) /// The uri value of the udi. public StringUdi(Uri uriValue) : base(uriValue) => - Id = Uri.UnescapeDataString(uriValue.AbsolutePath.TrimStart(Constants.CharArrays.ForwardSlash)); + Id = Uri.UnescapeDataString(uriValue.AbsolutePath.AsSpan().TrimStart(Constants.CharArrays.ForwardSlash)); /// /// The string part of the identifier. diff --git a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs index 8fb467e82875..11a6d6a58951 100644 --- a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs +++ b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs @@ -148,10 +148,10 @@ private string ReplaceLink(string text, string tagHref, string newLink, string? { return _linkPattern.Replace(text, match => { - if (match.Groups["culture"].Value.Equals(culture, StringComparison.OrdinalIgnoreCase) + if (match.Groups["culture"].ValueSpan.Equals(culture, StringComparison.OrdinalIgnoreCase) && match.Groups["href"].Value == tagHref) { - return match.Groups[1].Value + newLink + match.Groups["closequote"].Value; + return $"{match.Groups[1].ValueSpan}{newLink}{match.Groups["closequote"].ValueSpan}"; } return match.Value; }); @@ -171,7 +171,7 @@ private IEnumerable FindLocalLinkIds(string text) MatchCollection localLinkTagMatches = _localLinkTagPattern.Matches(text); foreach (Match linkTag in localLinkTagMatches) { - if (Guid.TryParse(linkTag.Groups["guid"].Value, out Guid guid) is false) + if (Guid.TryParse(linkTag.Groups["guid"].ValueSpan, out Guid guid) is false) { continue; } diff --git a/src/Umbraco.Core/UdiParser.cs b/src/Umbraco.Core/UdiParser.cs index 2524a4c87cb4..70a797eb26d9 100644 --- a/src/Umbraco.Core/UdiParser.cs +++ b/src/Umbraco.Core/UdiParser.cs @@ -170,11 +170,11 @@ private static bool ParseInternal(string? s, bool tryParse, bool knownTypes, [Ma throw new FormatException($"Unknown entity type \"{entityType}\"."); } - var path = uri.AbsolutePath.TrimStart('/'); + ReadOnlySpan path = uri.AbsolutePath.AsSpan().TrimStart('/'); if (udiType == UdiType.GuidUdi) { - if (path == string.Empty) + if (path.IsEmpty) { udi = GetRootUdi(uri.Host); return true; @@ -196,7 +196,7 @@ private static bool ParseInternal(string? s, bool tryParse, bool knownTypes, [Ma if (udiType == UdiType.StringUdi) { - udi = path == string.Empty ? GetRootUdi(uri.Host) : new StringUdi(uri.Host, Uri.UnescapeDataString(path)); + udi = path.IsEmpty ? GetRootUdi(uri.Host) : new StringUdi(uri.Host, Uri.UnescapeDataString(path)); return true; } diff --git a/src/Umbraco.Core/UriUtilityCore.cs b/src/Umbraco.Core/UriUtilityCore.cs index 7051b7057873..e300b534e30f 100644 --- a/src/Umbraco.Core/UriUtilityCore.cs +++ b/src/Umbraco.Core/UriUtilityCore.cs @@ -61,7 +61,7 @@ public static string TrimPathEndSlash(string uri) ReadOnlySpan uriSpan = uri.AsSpan(); var pos = IndexOfPathEnd(uriSpan); - var path = (pos > 0 ? uriSpan[..pos] : uriSpan).ToString(); + ReadOnlySpan path = pos > 0 ? uriSpan[..pos] : uriSpan; path = path.TrimEnd(Constants.CharArrays.ForwardSlash); if (pos > 0) @@ -69,7 +69,7 @@ public static string TrimPathEndSlash(string uri) return string.Concat(path, uriSpan[pos..]); } - return path; + return new string(path); } private static int IndexOfPathEnd(ReadOnlySpan uri) diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParserBase.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParserBase.cs index cf0e9aba8448..ff2e5c815bc9 100644 --- a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParserBase.cs +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParserBase.cs @@ -43,7 +43,7 @@ private ReplaceStatus ReplaceLocalLink(IPublishedContentCache contentCache, IPub return ReplaceStatus.NoMatch; } - if (Guid.TryParse(match.Groups["guid"].Value, out Guid guid) is false) + if (Guid.TryParse(match.Groups["guid"].ValueSpan, out Guid guid) is false) { return ReplaceStatus.NoMatch; } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index d65c05660c3c..f06185141d41 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -224,9 +224,9 @@ protected virtual void DefinePlan() if (match.Success) { trackedVersion = new SemVersion( - int.Parse(match.Groups[1].Value), - int.Parse(match.Groups[2].Value), - int.Parse(match.Groups[3].Value)); + int.Parse(match.Groups[1].ValueSpan), + int.Parse(match.Groups[2].ValueSpan), + int.Parse(match.Groups[3].ValueSpan)); } if (string.Equals(transition.TargetState, state, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs index 567155a745e1..0c4144779af9 100644 --- a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs +++ b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs @@ -75,7 +75,7 @@ public void Generate(StringBuilder sb, TypeModel typeModel) sb.AppendFormat("using {0};\n", t); } - sb.Append("\n"); + sb.Append('\n'); sb.AppendFormat("namespace {0}\n", GetModelsNamespace()); sb.Append("{\n"); @@ -135,7 +135,7 @@ public void WriteClrType(StringBuilder sb, Type type) { var p = s.IndexOf('`'); WriteNonGenericClrType(sb, s[..p]); - sb.Append("<"); + sb.Append('<'); Type[] args = type.GetGenericArguments(); for (var i = 0; i < args.Length; i++) { @@ -242,7 +242,7 @@ private void WriteContentType(StringBuilder sb, TypeModel type) { if (more) { - sb.Append("\n"); + sb.Append('\n'); } more = true; @@ -369,7 +369,7 @@ private void WriteContentTypeProperties(StringBuilder sb, TypeModel type) private void WriteMixinProperty(StringBuilder sb, PropertyModel property, string mixinClrName) { - sb.Append("\n"); + sb.Append('\n'); // Adds xml summary to each property containing // property name and property description @@ -421,7 +421,7 @@ private void WriteProperty(StringBuilder sb, PropertyModel property, string? mix { var mixinStatic = mixinClrName != null; - sb.Append("\n"); + sb.Append('\n'); if (property.Errors != null) { @@ -444,12 +444,12 @@ private void WriteProperty(StringBuilder sb, PropertyModel property, string? mix { sb.Append("\t\t * "); sb.Append(s); - sb.Append("\n"); + sb.Append('\n'); } } sb.Append("\t\t *\n"); - sb.Append("\n"); + sb.Append('\n'); } // Adds xml summary to each property containing @@ -507,9 +507,9 @@ private void WriteProperty(StringBuilder sb, PropertyModel property, string? mix property.ClrName); if (property.ModelClrType != typeof(object)) { - sb.Append("<"); + sb.Append('<'); WriteClrType(sb, property.ClrTypeName); - sb.Append(">"); + sb.Append('>'); } sb.AppendFormat( @@ -519,7 +519,7 @@ private void WriteProperty(StringBuilder sb, PropertyModel property, string? mix if (property.Errors != null) { - sb.Append("\n"); + sb.Append('\n'); sb.Append("\t\t *\n"); sb.Append("\t\t */\n"); } @@ -532,7 +532,7 @@ private void WriteProperty(StringBuilder sb, PropertyModel property, string? mix var mixinStaticGetterName = MixinStaticGetterName(property.ClrName); // if (type.StaticMixinMethods.Contains(mixinStaticGetterName)) return; - sb.Append("\n"); + sb.Append('\n'); if (!string.IsNullOrWhiteSpace(property.Name)) { @@ -553,9 +553,9 @@ private void WriteProperty(StringBuilder sb, PropertyModel property, string? mix mixinClrName); if (property.ModelClrType != typeof(object)) { - sb.Append("<"); + sb.Append('<'); WriteClrType(sb, property.ClrTypeName); - sb.Append(">"); + sb.Append('>'); } sb.AppendFormat( @@ -586,12 +586,12 @@ private void WriteInterfaceProperty(StringBuilder sb, PropertyModel property) { sb.Append("\t\t * "); sb.Append(s); - sb.Append("\n"); + sb.Append('\n'); } } sb.Append("\t\t *\n"); - sb.Append("\n"); + sb.Append('\n'); } if (!string.IsNullOrWhiteSpace(property.Name)) @@ -613,7 +613,7 @@ private void WriteInterfaceProperty(StringBuilder sb, PropertyModel property) if (property.Errors != null) { - sb.Append("\n"); + sb.Append('\n'); sb.Append("\t\t *\n"); sb.Append("\t\t */\n"); } @@ -631,7 +631,7 @@ internal void WriteClrType(StringBuilder sb, string type) if (p >= 0) { WriteNonGenericClrType(sb, type[..p]); - sb.Append("<"); + sb.Append('<'); var argsString = type[(p + 1)..^1]; // Extract content between '<' and the final '>' IReadOnlyList args = SplitGenericArguments(argsString); @@ -646,7 +646,7 @@ internal void WriteClrType(StringBuilder sb, string type) WriteClrType(sb, args[i]); } - sb.Append(">"); + sb.Append('>'); } else { @@ -720,7 +720,7 @@ private static string XmlCommentString(string s) => private void WriteNonGenericClrType(StringBuilder sb, string s) { // map model types - s = Regex.Replace(s, @"\{(.*)\}\[\*\]", m => ModelsMap[m.Groups[1].Value + "[]"]); + s = Regex.Replace(s, @"\{(.*)\}\[\*\]", m => ModelsMap[$"{m.Groups[1].ValueSpan}[]"]); // takes care eg of "System.Int32" vs. "int" if (_typesMap.TryGetValue(s, out var typeName)) diff --git a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs index b9ddae8ea309..7ec6d97ed0dc 100644 --- a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs +++ b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs @@ -1641,7 +1641,7 @@ internal static string GetAliasedField(this Sql sql, string field) // then GetAliased for "[umbracoNode].[nodeId]" returns "[umbracoNode__nodeId]" MatchCollection matches = sql.SqlContext.SqlSyntax.AliasRegex.Matches(sql.SQL); - Match? match = matches.Cast().FirstOrDefault(m => m.Groups[1].Value.InvariantEquals(field)); + Match? match = matches.FirstOrDefault(m => m.Groups[1].ValueSpan.Equals(field, StringComparison.InvariantCultureIgnoreCase)); return match == null ? field : match.Groups[2].Value; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs index d3b09be46934..3643c8c6d803 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs @@ -1100,7 +1100,7 @@ private string GetAliasedField(string field, Sql sql) // so... if query contains "[umbracoNode].[nodeId] AS [umbracoNode__nodeId]" // then GetAliased for "[umbracoNode].[nodeId]" returns "[umbracoNode__nodeId]" MatchCollection matches = SqlContext.SqlSyntax.AliasRegex.Matches(sql.SQL); - Match? match = matches.Cast().FirstOrDefault(m => m.Groups[1].Value.InvariantEquals(field)); + Match? match = matches.FirstOrDefault(m => m.Groups[1].ValueSpan.Equals(field, StringComparison.InvariantCultureIgnoreCase)); return match == null ? field : match.Groups[2].Value; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ListExtensions.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ListExtensions.cs index 9bbaf1112a53..3d7ce95a44d3 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ListExtensions.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ListExtensions.cs @@ -218,7 +218,7 @@ internal StructuredName(string? name) { Match match = matches[0]; Text = match.Groups[1].Value; - int number = int.TryParse(match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out number) + int number = int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : 0; Suffix = (uint?)number; diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs index fc52a9e5f14d..8dd9ac1a7e0a 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs @@ -644,12 +644,22 @@ public virtual string Format(ForeignKeyDefinition foreignKey) public virtual string Format(IEnumerable columns) { var sb = new StringBuilder(); + bool first = true; foreach (ColumnDefinition column in columns) { - sb.Append(Format(column) + ",\n"); + if (first) + { + first = false; + } + else + { + sb.Append(",\n"); + } + + sb.Append(Format(column)); } - return sb.ToString().TrimEnd(",\n"); + return sb.ToString(); } /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyNotificationHandlerBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyNotificationHandlerBase.cs index e40f2857089d..627f8a22af9a 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyNotificationHandlerBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyNotificationHandlerBase.cs @@ -64,7 +64,7 @@ internal string ReplaceBlockEditorKeys(string rawJson, Func? createG return match.Value; } - var oldKey = Guid.Parse(match.Value); + var oldKey = Guid.Parse(match.ValueSpan); if (oldToNewKeys.ContainsKey(oldKey) == false) { oldToNewKeys[oldKey] = createGuid(oldKey); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs index 16f29b00ab51..b5f356247bf6 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs @@ -342,7 +342,7 @@ private string RenderRichTextBlockModel(string source, RichTextBlockModel? richT var blocksByKey = richTextBlockModel.ToDictionary(block => block.ContentKey); string RenderBlock(Match match) => - Guid.TryParse(match.Groups["key"].Value, out Guid key) && blocksByKey.TryGetValue(key, out RichTextBlockItem? richTextBlockItem) + Guid.TryParse(match.Groups["key"].ValueSpan, out Guid key) && blocksByKey.TryGetValue(key, out RichTextBlockItem? richTextBlockItem) ? _partialViewBlockEngine.ExecuteAsync(richTextBlockItem).GetAwaiter().GetResult() : string.Empty; diff --git a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs index 4c0600be41d3..6bbfd69d5d45 100644 --- a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs +++ b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs @@ -165,7 +165,7 @@ public string LocalTempPath /// public string ToAbsolute(string virtualPath) { - if (!virtualPath.StartsWith("~/") && !virtualPath.StartsWith("/") && _urlProviderMode != UrlMode.Absolute) + if (!virtualPath.StartsWith("~/") && !virtualPath.StartsWith('/') && _urlProviderMode != UrlMode.Absolute) { throw new InvalidOperationException( $"The value {virtualPath} for parameter {nameof(virtualPath)} must start with ~/ or /"); @@ -177,8 +177,7 @@ public string ToAbsolute(string virtualPath) return virtualPath; } - var fullPath = ApplicationVirtualPath.EnsureEndsWith('/') + - virtualPath.TrimStart(Core.Constants.CharArrays.TildeForwardSlash); + var fullPath = $"{ApplicationVirtualPath.EnsureEndsWith('/')}{virtualPath.AsSpan().TrimStart(Core.Constants.CharArrays.TildeForwardSlash)}"; return fullPath; } diff --git a/src/Umbraco.Web.Common/Filters/ModelBindingExceptionAttribute.cs b/src/Umbraco.Web.Common/Filters/ModelBindingExceptionAttribute.cs index 165944a8b5f5..8111c8ce8509 100644 --- a/src/Umbraco.Web.Common/Filters/ModelBindingExceptionAttribute.cs +++ b/src/Umbraco.Web.Common/Filters/ModelBindingExceptionAttribute.cs @@ -92,7 +92,7 @@ private static bool IsMessageAboutTheSameModelType(string exceptionMessage) if (matches.Count >= 2) { - return string.Equals(matches[0].Value, matches[1].Value, StringComparison.InvariantCulture); + return matches[0].ValueSpan.Equals(matches[1].ValueSpan, StringComparison.InvariantCulture); } return false; From 6ba214e439f36cf409e64ef743e8b6cd9da84c2b Mon Sep 17 00:00:00 2001 From: Henrik Gedionsen Date: Thu, 16 Jul 2026 12:42:59 +0200 Subject: [PATCH 2/7] One less string allocation --- .../Querying/Filters/ContainsFilterBase.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContainsFilterBase.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContainsFilterBase.cs index 389246534cc2..d2f699362c2a 100644 --- a/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContainsFilterBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContainsFilterBase.cs @@ -42,7 +42,7 @@ public FilterOption BuildFilterOption(string filter) return DefaultFilterOption(); } - FilterOperation? filterOperation = ParseFilterOperation(operatorGroup.Value); + FilterOperation? filterOperation = ParseFilterOperation(operatorGroup.ValueSpan); if (filterOperation.HasValue is false) { return DefaultFilterOption(); @@ -64,7 +64,7 @@ FilterOption DefaultFilterOption() }; } - private FilterOperation? ParseFilterOperation(string filterOperation) + private static FilterOperation? ParseFilterOperation(ReadOnlySpan filterOperation) => filterOperation switch { ":" => FilterOperation.Is, From 339ea41462f88a443e4862dd91f6ae54ee28c720 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 16 Jul 2026 14:09:08 +0200 Subject: [PATCH 3/7] Add tests for modified extension methods where existing coverage was sparse. --- .../Extensions/DictionaryExtensionsTests.cs | 64 +++++++++++++++++++ .../DocumentUrlAliasServiceExtensionsTests.cs | 24 +++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/DictionaryExtensionsTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/DocumentUrlAliasServiceExtensionsTests.cs diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/DictionaryExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/DictionaryExtensionsTests.cs new file mode 100644 index 000000000000..73308b3133b5 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/DictionaryExtensionsTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions; + +[TestFixture] +public class DictionaryExtensionsTests +{ + [Test] + public void ToQueryString_Empty_Dictionary_Returns_Empty_String() + { + var dictionary = new Dictionary(); + + Assert.AreEqual(string.Empty, dictionary.ToQueryString()); + } + + [Test] + public void ToQueryString_Single_Entry_Has_No_Separator() + { + var dictionary = new Dictionary { ["firstname"] = "shannon" }; + + Assert.AreEqual("firstname=shannon", dictionary.ToQueryString()); + } + + [Test] + public void ToQueryString_Multiple_Entries_Are_Ampersand_Separated_Without_Trailing_Ampersand() + { + var dictionary = new Dictionary + { + ["firstname"] = "shannon", + ["lastname"] = "deminick", + }; + + var result = dictionary.ToQueryString(); + + Assert.Multiple(() => + { + // Dictionary enumeration order is not contractually guaranteed, so assert on the set of pairs. + Assert.That(result.Split('&'), Is.EquivalentTo(new[] { "firstname=shannon", "lastname=deminick" })); + Assert.That(result, Does.Not.StartWith("&")); + Assert.That(result, Does.Not.EndWith("&")); + }); + } + + [Test] + public void ToQueryString_Null_Value_Renders_Key_With_Empty_Value() + { + var dictionary = new Dictionary { ["key"] = null }; + + Assert.AreEqual("key=", dictionary.ToQueryString()); + } + + [Test] + public void ToQueryString_Encodes_Ampersand_In_Value_And_Does_Not_Trim_It() + { + var dictionary = new Dictionary { ["key"] = "a&b" }; + + // The '&' inside the value must be URL-encoded (never treated as a separator or trimmed). + Assert.AreEqual("key=a%26b", dictionary.ToQueryString()); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/DocumentUrlAliasServiceExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/DocumentUrlAliasServiceExtensionsTests.cs new file mode 100644 index 000000000000..e496392382aa --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/DocumentUrlAliasServiceExtensionsTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Extensions; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions; + +[TestFixture] +public class DocumentUrlAliasServiceExtensionsTests +{ + private static readonly IDocumentUrlAliasService _service = Mock.Of(); + + [TestCase("some-alias", "some-alias")] + [TestCase(" some-alias ", "some-alias")] + [TestCase("/some-alias/", "some-alias")] + [TestCase("//some-alias//", "some-alias")] + [TestCase("Some-Mixed-Case", "some-mixed-case")] + [TestCase(" /Some/Nested-Alias/ ", "some/nested-alias")] + public void NormalizeAlias_Trims_Whitespace_And_Slashes_And_Lowercases(string alias, string expected) + => Assert.AreEqual(expected, _service.NormalizeAlias(alias)); +} From 6f48570b1dd47c4c72d9d7f48ba101f95760f08d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 16 Jul 2026 14:23:19 +0200 Subject: [PATCH 4/7] =?UTF-8?q?Add=20(failing)=20tests=20for=20functionali?= =?UTF-8?q?ty=20in=20ApiMediaQueryService.cs=E2=80=8E.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/ApiMediaQueryServiceTests.cs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryServiceTests.cs diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryServiceTests.cs new file mode 100644 index 000000000000..ae94e3337268 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryServiceTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Delivery.Services; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services.Navigation; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Delivery.Services; + +[TestFixture] +public class ApiMediaQueryServiceTests +{ + private Mock _mediaCache = null!; + private Mock _navigationQueryService = null!; + private Mock _statusFilteringService = null!; + + [SetUp] + public void SetUp() + { + _mediaCache = new Mock(); + _navigationQueryService = new Mock(); + _statusFilteringService = new Mock(); + } + + private ApiMediaQueryService CreateSut() => new( + _mediaCache.Object, + NullLogger.Instance, + _navigationQueryService.Object, + _statusFilteringService.Object); + + private IPublishedContent SetUpParentWithSingleChild(Guid parentKey, Guid childKey) + { + var child = new Mock(); + child.Setup(x => x.Key).Returns(childKey); + + IEnumerable childKeys = new[] { childKey }; + _navigationQueryService.Setup(x => x.TryGetChildrenKeys(parentKey, out childKeys)).Returns(true); + _statusFilteringService + .Setup(x => x.FilterAvailable(It.IsAny>(), It.IsAny())) + .Returns(new[] { child.Object }); + + var parent = new Mock(); + parent.Setup(x => x.Key).Returns(parentKey); + return parent.Object; + } + + // Only the "children:" prefix may be removed from the selector. The media key here deliberately begins + // with 'e' — a character that also appears in "children:" — so the test fails if prefix removal ever + // regresses into character-set trimming that eats into the key itself. + [Test] + public void Can_Resolve_ChildrenOf_By_Key_When_Key_Starts_With_A_Prefix_Character() + { + var parentKey = new Guid("eeeeeeee-1111-1111-1111-111111111111"); + var childKey = new Guid("aaaaaaaa-2222-2222-2222-222222222222"); + + IPublishedContent parent = SetUpParentWithSingleChild(parentKey, childKey); + _mediaCache.Setup(x => x.GetById(parentKey)).Returns(parent); + + var attempt = CreateSut().ExecuteQuery( + $"children:{parentKey}", + [], + [], + skip: 0, + take: 100); + + Assert.Multiple(() => + { + Assert.That(attempt.Success, Is.True); + Assert.That(attempt.Result.Total, Is.EqualTo(1)); + Assert.That(attempt.Result.Items, Is.EqualTo(new[] { childKey })); + }); + } + + // Same invariant for path-based resolution: the leading path segment "news" begins with characters that + // also appear in the "children:" prefix. Removing the prefix must leave the segment intact ("news"), so + // the node stays resolvable by path — a character-set trim would reduce it to "ws" and lose the node. + [Test] + public void Can_Resolve_ChildrenOf_By_Path_When_Segment_Starts_With_A_Prefix_Character() + { + var newsKey = new Guid("cccccccc-1111-1111-1111-111111111111"); + var childKey = new Guid("aaaaaaaa-2222-2222-2222-222222222222"); + + var newsNode = new Mock(); + newsNode.Setup(x => x.Key).Returns(newsKey); + newsNode.Setup(x => x.Name).Returns("news"); + + IEnumerable rootKeys = new[] { newsKey }; + _navigationQueryService.Setup(x => x.TryGetRootKeys(out rootKeys)).Returns(true); + _mediaCache.Setup(x => x.GetById(false, newsKey)).Returns(newsNode.Object); + + IEnumerable childKeys = new[] { childKey }; + _navigationQueryService.Setup(x => x.TryGetChildrenKeys(newsKey, out childKeys)).Returns(true); + + var child = new Mock(); + child.Setup(x => x.Key).Returns(childKey); + _statusFilteringService + .Setup(x => x.FilterAvailable(It.IsAny>(), It.IsAny())) + .Returns(new[] { child.Object }); + + var attempt = CreateSut().ExecuteQuery( + "children:news", + [], + [], + skip: 0, + take: 100); + + Assert.Multiple(() => + { + Assert.That(attempt.Success, Is.True); + Assert.That(attempt.Result.Total, Is.EqualTo(1)); + Assert.That(attempt.Result.Items, Is.EqualTo(new[] { childKey })); + }); + } +} From e1968d97e3a2c2c76281e49929de61442cada466 Mon Sep 17 00:00:00 2001 From: Henrik Gedionsen Date: Thu, 16 Jul 2026 17:38:12 +0200 Subject: [PATCH 5/7] Make string trimming act like umbraco TrimStart & use the cheaper char overload when trimming elsewhere --- .../OpenApi/SubTypesSelector.cs | 2 +- .../Content/ByRouteContentApiController.cs | 2 +- .../Services/ApiMediaQueryService.cs | 9 +++++++-- .../Services/RequestStartItemProvider.cs | 2 +- .../Security/BackOfficeApplicationManager.cs | 2 +- .../Services/SqliteSyntaxProvider.cs | 2 +- .../Extensions/StringExtensions.Manipulation.cs | 2 +- src/Umbraco.Core/Extensions/UdiGetterExtensions.cs | 2 +- src/Umbraco.Core/Extensions/UriExtensions.cs | 10 +++++----- src/Umbraco.Core/GuidUdi.cs | 2 +- src/Umbraco.Core/IO/IOHelper.cs | 13 ++++++------- src/Umbraco.Core/IO/PhysicalFileSystem.cs | 10 +++++----- src/Umbraco.Core/Media/UploadAutoFillProperties.cs | 2 +- src/Umbraco.Core/Routing/AliasUrlProvider.cs | 4 ++-- src/Umbraco.Core/Routing/DefaultUrlProvider.cs | 4 ++-- src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs | 4 ++-- .../Routing/PublishedUrlInfoProvider.cs | 2 +- src/Umbraco.Core/Routing/UriUtility.cs | 2 +- src/Umbraco.Core/Routing/UrlProviderExtensions.cs | 2 +- .../ContentTypeEditing/MediaTypeEditingService.cs | 2 +- src/Umbraco.Core/StringUdi.cs | 2 +- src/Umbraco.Core/UdiRange.cs | 2 +- src/Umbraco.Core/UriUtilityCore.cs | 2 +- .../Routing/RedirectTracker.cs | 4 ++-- .../Extensions/FormCollectionExtensions.cs | 2 +- 25 files changed, 48 insertions(+), 44 deletions(-) diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/SubTypesSelector.cs b/src/Umbraco.Cms.Api.Common/OpenApi/SubTypesSelector.cs index 18b3c036df0f..0c76b41d1a43 100644 --- a/src/Umbraco.Cms.Api.Common/OpenApi/SubTypesSelector.cs +++ b/src/Umbraco.Cms.Api.Common/OpenApi/SubTypesSelector.cs @@ -47,7 +47,7 @@ public IEnumerable SubTypes(Type type) { // Split the path into segments var segments = _httpContextAccessor.HttpContext.Request.Path.Value![swaggerPath.Length..] - .TrimStart(Constants.CharArrays.ForwardSlash) + .TrimStart('/') .Split(Constants.CharArrays.ForwardSlash); // Extract the document name from the path diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs index fccddc9779c7..d244f4bd297d 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs @@ -89,7 +89,7 @@ private async Task HandleRequest(string path) return null; } - if (Guid.TryParse(path.AsSpan(PreviewContentRequestPathPrefix.Length).TrimEnd("/"), out Guid contentId) is false) + if (Guid.TryParse(path.AsSpan(PreviewContentRequestPathPrefix.Length).TrimEnd('/'), out Guid contentId) is false) { return null; } diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs index d52a45e2eff3..79fdadd792b0 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs @@ -100,7 +100,12 @@ private IPublishedMediaCache GetRequiredPublishedMediaCache() return null; } - ReadOnlySpan childrenOf = fetch.AsSpan().TrimStart(childrenOfParameter); + ReadOnlySpan childrenOf = fetch.AsSpan(); + if (fetch.StartsWith(childrenOfParameter, StringComparison.InvariantCultureIgnoreCase)) + { + childrenOf = childrenOf[childrenOfParameter.Length..]; + } + if (childrenOf.IsEmpty) { // this mirrors the current behavior of the Content Delivery API :-) @@ -108,7 +113,7 @@ private IPublishedMediaCache GetRequiredPublishedMediaCache() } IPublishedMediaCache mediaCache = GetRequiredPublishedMediaCache(); - if (childrenOf.Trim(Constants.CharArrays.ForwardSlash).IsEmpty) + if (childrenOf.Trim('/').IsEmpty) { return GetRootContent(mediaCache); } diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestStartItemProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestStartItemProvider.cs index 87e27b535baf..516638b70829 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/RequestStartItemProvider.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestStartItemProvider.cs @@ -41,7 +41,7 @@ public RequestStartItemProvider( return _requestedStartContent; } - var headerValue = RequestedStartItem()?.Trim(Constants.CharArrays.ForwardSlash); + var headerValue = RequestedStartItem()?.Trim('/'); if (headerValue.IsNullOrWhiteSpace()) { return null; diff --git a/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs b/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs index 6ab521cdc879..83bf79787b8f 100644 --- a/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs +++ b/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs @@ -324,5 +324,5 @@ internal OpenIddictApplicationDescriptor DeveloperOpenIddictApplicationDescripto return descriptor; } - private static Uri CallbackUrlFor(Uri url, string relativePath) => new Uri($"{url.GetLeftPart(UriPartial.Authority)}/{relativePath.TrimStart(Constants.CharArrays.ForwardSlash)}"); + private static Uri CallbackUrlFor(Uri url, string relativePath) => new Uri($"{url.GetLeftPart(UriPartial.Authority)}/{relativePath.AsSpan().TrimStart('/')}"); } diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs index 4cec5e62cb98..8374ee127b0d 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs +++ b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs @@ -320,7 +320,7 @@ public override Sql SelectTop(Sql sql, int top) var sb = new StringBuilder(); foreach (ColumnDefinition column in columns) { - sb.AppendLine(", " + Format(column)); + sb.Append(", ").AppendLine(Format(column)); } return sb.ToString().TrimStart(','); diff --git a/src/Umbraco.Core/Extensions/StringExtensions.Manipulation.cs b/src/Umbraco.Core/Extensions/StringExtensions.Manipulation.cs index 30a0c1dae330..a884b27ce8ce 100644 --- a/src/Umbraco.Core/Extensions/StringExtensions.Manipulation.cs +++ b/src/Umbraco.Core/Extensions/StringExtensions.Manipulation.cs @@ -194,7 +194,7 @@ public static string AppendQueryStringToUrl(this string url, params string[] que for (var i = 0; i < queryStrings.Length; i++) { queryStrings[i] = queryStrings[i].TrimStart(Constants.CharArrays.QuestionMarkAmpersand) - .TrimEnd(Constants.CharArrays.Ampersand); + .TrimEnd('&'); } var nonEmpty = queryStrings.Where(x => !x.IsNullOrWhiteSpace()).ToArray(); diff --git a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs index d6d12fe0d5a4..ebe1b950fba4 100644 --- a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs +++ b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs @@ -406,7 +406,7 @@ public static GuidUdi GetUdi(this IWebhook entity) /// private static StringUdi GetUdiFromPath(string entityType, string path) { - string id = path.TrimStart(Constants.CharArrays.ForwardSlash).Replace('\\', '/'); + string id = path.TrimStart('/').Replace('\\', '/'); return new StringUdi(entityType, id).EnsureClosed(); } diff --git a/src/Umbraco.Core/Extensions/UriExtensions.cs b/src/Umbraco.Core/Extensions/UriExtensions.cs index c68b073d9e1a..ac316e74f4e2 100644 --- a/src/Umbraco.Core/Extensions/UriExtensions.cs +++ b/src/Umbraco.Core/Extensions/UriExtensions.cs @@ -21,7 +21,7 @@ public static class UriExtensions /// Everything else remains unchanged, except for the fragment which is removed. public static Uri Rewrite(this Uri uri, string path) { - if (path.StartsWith("/", StringComparison.Ordinal) == false) + if (path.StartsWith('/') == false) { throw new ArgumentException("Path must start with a slash.", "path"); } @@ -41,12 +41,12 @@ public static Uri Rewrite(this Uri uri, string path) /// Everything else remains unchanged, except for the fragment which is removed. public static Uri Rewrite(this Uri uri, string path, string query) { - if (path.StartsWith("/", StringComparison.Ordinal) == false) + if (path.StartsWith('/') == false) { throw new ArgumentException("Path must start with a slash.", "path"); } - if (query.Length > 0 && query.StartsWith("?", StringComparison.Ordinal) == false) + if (query.Length > 0 && query.StartsWith('?') == false) { throw new ArgumentException("Query must start with a question mark.", "query"); } @@ -142,7 +142,7 @@ public static Uri TrimPathEndSlash(this Uri uri) if (path != "/") { uri = new Uri( - $"{uri.GetLeftPart(UriPartial.Authority)}{path.AsSpan().TrimEnd(Constants.CharArrays.ForwardSlash)}{uri.Query}" + $"{uri.GetLeftPart(UriPartial.Authority)}{path.AsSpan().TrimEnd('/')}{uri.Query}" ); } } @@ -150,7 +150,7 @@ public static Uri TrimPathEndSlash(this Uri uri) { if (path != "/") { - uri = new Uri($"{path.AsSpan().TrimEnd(Constants.CharArrays.ForwardSlash)}{uri.Query}", UriKind.Relative); + uri = new Uri($"{path.AsSpan().TrimEnd('/')}{uri.Query}", UriKind.Relative); } } diff --git a/src/Umbraco.Core/GuidUdi.cs b/src/Umbraco.Core/GuidUdi.cs index 679a1939c959..42c667150f8c 100644 --- a/src/Umbraco.Core/GuidUdi.cs +++ b/src/Umbraco.Core/GuidUdi.cs @@ -25,7 +25,7 @@ public GuidUdi(string entityType, Guid guid) public GuidUdi(Uri uriValue) : base(uriValue) { - if (Guid.TryParse(uriValue.AbsolutePath.TrimStart(Constants.CharArrays.ForwardSlash), out Guid guid) == false) + if (Guid.TryParse(uriValue.AbsolutePath.AsSpan().TrimStart('/'), out Guid guid) == false) { throw new FormatException("URI \"" + uriValue + "\" is not a GUID entity ID."); } diff --git a/src/Umbraco.Core/IO/IOHelper.cs b/src/Umbraco.Core/IO/IOHelper.cs index b3f4d21e904b..37fb7370d803 100644 --- a/src/Umbraco.Core/IO/IOHelper.cs +++ b/src/Umbraco.Core/IO/IOHelper.cs @@ -29,15 +29,14 @@ public string FindFile(string virtualPath) { var retval = virtualPath; - if (virtualPath.StartsWith("~")) + if (virtualPath.StartsWith('~')) { retval = virtualPath.Replace("~", _hostingEnvironment.ApplicationVirtualPath); } - if (virtualPath.StartsWith("/") && !PathStartsWith(virtualPath, _hostingEnvironment.ApplicationVirtualPath)) + if (virtualPath.StartsWith('/') && !PathStartsWith(virtualPath, _hostingEnvironment.ApplicationVirtualPath)) { - retval = _hostingEnvironment.ApplicationVirtualPath + "/" + - virtualPath.TrimStart(Constants.CharArrays.ForwardSlash); + retval = $"{_hostingEnvironment.ApplicationVirtualPath}/{virtualPath.AsSpan().TrimStart('/')}"; } return retval; @@ -71,9 +70,9 @@ public string MapPath(string path) if (_hostingEnvironment.IsHosted) { var result = !string.IsNullOrEmpty(path) && - (path.StartsWith("~") || PathStartsWith(path, _hostingEnvironment.ApplicationVirtualPath)) + (path.StartsWith('~') || PathStartsWith(path, _hostingEnvironment.ApplicationVirtualPath)) ? _hostingEnvironment.MapPathWebRoot(path) - : _hostingEnvironment.MapPathWebRoot("~/" + path.TrimStart(Constants.CharArrays.ForwardSlash)); + : _hostingEnvironment.MapPathWebRoot($"~/{path.AsSpan().TrimStart('/')}"); if (result != null) { @@ -149,7 +148,7 @@ public bool VerifyEditPath(string filePath, IEnumerable validDirs) public bool VerifyFileExtension(string filePath, IEnumerable validFileExtensions) { var ext = Path.GetExtension(filePath); - return ext != null && validFileExtensions.Contains(ext.TrimStart(Constants.CharArrays.Period)); + return ext != null && validFileExtensions.Contains(ext.TrimStart('.')); } /// diff --git a/src/Umbraco.Core/IO/PhysicalFileSystem.cs b/src/Umbraco.Core/IO/PhysicalFileSystem.cs index 26d087b822b5..679dfcdfc48e 100644 --- a/src/Umbraco.Core/IO/PhysicalFileSystem.cs +++ b/src/Umbraco.Core/IO/PhysicalFileSystem.cs @@ -77,7 +77,7 @@ public PhysicalFileSystem(IIOHelper ioHelper, IHostingEnvironment hostingEnviron _rootPath = EnsureDirectorySeparatorChar(rootPath).TrimEnd(Path.DirectorySeparatorChar); _rootPathFwd = EnsureUrlSeparatorChar(_rootPath); - _rootUrl = EnsureUrlSeparatorChar(rootUrl).TrimEnd(Constants.CharArrays.ForwardSlash); + _rootUrl = EnsureUrlSeparatorChar(rootUrl).TrimEnd('/'); } /// @@ -300,18 +300,18 @@ public string GetRelativePath(string fullPathOrUrl) // or on unix systems "/var/wwwroot/test/Meia/1234/img.jpg" if (_ioHelper.PathStartsWith(path, _rootPathFwd, '/')) { - return path.Substring(_rootPathFwd.Length).TrimStart(Constants.CharArrays.ForwardSlash); + return path.Substring(_rootPathFwd.Length).TrimStart('/'); } // if it starts with the root URL, strip it and trim the starting slash to make it relative // eg "/Media/1234/img.jpg" => "1234/img.jpg" if (_ioHelper.PathStartsWith(path, _rootUrl, '/')) { - return path.Substring(_rootUrl.Length).TrimStart(Constants.CharArrays.ForwardSlash); + return path.Substring(_rootUrl.Length).TrimStart('/'); } // unchanged - what else? - return path.TrimStart(Constants.CharArrays.ForwardSlash); + return path.TrimStart('/'); } /// @@ -370,7 +370,7 @@ public string GetFullPath(string path) /// All separators are forward-slashes. public string GetUrl(string? path) { - path = EnsureUrlSeparatorChar(path ?? string.Empty).Trim(Constants.CharArrays.ForwardSlash); + path = EnsureUrlSeparatorChar(path ?? string.Empty).Trim('/'); return _rootUrl + "/" + path; } diff --git a/src/Umbraco.Core/Media/UploadAutoFillProperties.cs b/src/Umbraco.Core/Media/UploadAutoFillProperties.cs index 5c657bcb3917..9ca27cb89ef7 100644 --- a/src/Umbraco.Core/Media/UploadAutoFillProperties.cs +++ b/src/Umbraco.Core/Media/UploadAutoFillProperties.cs @@ -130,7 +130,7 @@ private static void SetProperties(IContentBase content, ImagingAutoFillUploadFie private void SetProperties(IContentBase content, ImagingAutoFillUploadField autoFillConfig, string filepath, Stream filestream, string? culture, string? segment) { - var extension = (Path.GetExtension(filepath) ?? string.Empty).TrimStart(Constants.CharArrays.Period); + var extension = (Path.GetExtension(filepath) ?? string.Empty).TrimStart('.'); Size? size = _imageDimensionExtractor.IsSupportedImageFormat(extension) ? _imageDimensionExtractor.GetDimensions(filestream) ?? new Size(Constants.Conventions.Media.DefaultSize, Constants.Conventions.Media.DefaultSize) diff --git a/src/Umbraco.Core/Routing/AliasUrlProvider.cs b/src/Umbraco.Core/Routing/AliasUrlProvider.cs index bcb6459d9735..ffcea68c9087 100644 --- a/src/Umbraco.Core/Routing/AliasUrlProvider.cs +++ b/src/Umbraco.Core/Routing/AliasUrlProvider.cs @@ -186,8 +186,8 @@ public IEnumerable GetOtherUrls(int id, Uri current) private static string CombinePaths(string path1, string path2) { - var path = $"{path1.AsSpan().TrimEnd(Constants.CharArrays.ForwardSlash)}{path2}"; - return path == "/" ? path : path.TrimEnd(Constants.CharArrays.ForwardSlash); + var path = $"{path1.AsSpan().TrimEnd('/')}{path2}"; + return path == "/" ? path : path.TrimEnd('/'); } #endregion diff --git a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs index 7eeeee4126bf..f3e84f7ea8b0 100644 --- a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs @@ -315,8 +315,8 @@ private Uri AssembleUrl(DomainAndUri? domainUri, string path, Uri current, UrlMo private static string CombinePaths(string path1, string path2) { - var path = $"{path1.AsSpan().TrimEnd(Constants.CharArrays.ForwardSlash)}{path2}"; - return path == "/" ? path : path.TrimEnd(Constants.CharArrays.ForwardSlash); + var path = $"{path1.AsSpan().TrimEnd('/')}{path2}"; + return path == "/" ? path : path.TrimEnd('/'); } #endregion diff --git a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs index 9146c825092b..bb95863abb7a 100644 --- a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs @@ -315,7 +315,7 @@ private Uri AssembleUrl(DomainAndUri? domainUri, string path, Uri current, UrlMo private string CombinePaths(string path1, string path2) { - var path = path1.TrimEnd(Constants.CharArrays.ForwardSlash) + path2; - return path == "/" ? path : path.TrimEnd(Constants.CharArrays.ForwardSlash); + var path = $"{path1.AsSpan().TrimEnd('/')}{path2}"; + return path == "/" ? path : path.TrimEnd('/'); } } diff --git a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs index bff3ae9b01a4..d522bdb899dd 100644 --- a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs @@ -177,7 +177,7 @@ private async Task> GetCulturesForUrlLookupAsync(IContent co private async Task> VerifyCollisionAsync(IContent content, string url, string culture) { - var uri = new Uri(url.TrimEnd(Constants.CharArrays.ForwardSlash), UriKind.RelativeOrAbsolute); + var uri = new Uri(url.TrimEnd('/'), UriKind.RelativeOrAbsolute); if (uri.IsAbsoluteUri is false) { uri = uri.MakeAbsolute(_umbracoContextAccessor.GetRequiredUmbracoContext().CleanedUmbracoUrl); diff --git a/src/Umbraco.Core/Routing/UriUtility.cs b/src/Umbraco.Core/Routing/UriUtility.cs index 34459e34eaae..b109d63d7346 100644 --- a/src/Umbraco.Core/Routing/UriUtility.cs +++ b/src/Umbraco.Core/Routing/UriUtility.cs @@ -143,7 +143,7 @@ public Uri UriToUmbraco(Uri uri) if (path != "/") { - path = path.TrimEnd(Constants.CharArrays.ForwardSlash); + path = path.TrimEnd('/'); // perform fallback to root if the path was all slashes (i.e. https://some.where//////) if (path == string.Empty) diff --git a/src/Umbraco.Core/Routing/UrlProviderExtensions.cs b/src/Umbraco.Core/Routing/UrlProviderExtensions.cs index 19abad7a64ec..5948e23df4d9 100644 --- a/src/Umbraco.Core/Routing/UrlProviderExtensions.cs +++ b/src/Umbraco.Core/Routing/UrlProviderExtensions.cs @@ -226,7 +226,7 @@ private static UrlInfo HandleCouldNotGetUrl(IContent content, string culture, IC IPublishedContentStatusFilteringService publishedContentStatusFilteringService) { // test for collisions on the 'main' URL - var uri = new Uri(url.TrimEnd(Constants.CharArrays.ForwardSlash), UriKind.RelativeOrAbsolute); + var uri = new Uri(url.TrimEnd('/'), UriKind.RelativeOrAbsolute); if (uri.IsAbsoluteUri == false) { uri = uri.MakeAbsolute(umbracoContext.CleanedUmbracoUrl); diff --git a/src/Umbraco.Core/Services/ContentTypeEditing/MediaTypeEditingService.cs b/src/Umbraco.Core/Services/ContentTypeEditing/MediaTypeEditingService.cs index eb9af06e0ff8..e9b2a6fdf4a2 100644 --- a/src/Umbraco.Core/Services/ContentTypeEditing/MediaTypeEditingService.cs +++ b/src/Umbraco.Core/Services/ContentTypeEditing/MediaTypeEditingService.cs @@ -111,7 +111,7 @@ public async Task> GetMediaTypesForFileExtensionAsync(str /// public async Task> GetMediaTypesForFileExtensionWithMatchInfoAsync(string fileExtension, int skip, int take) { - fileExtension = fileExtension.TrimStart(Constants.CharArrays.Period).ToLowerInvariant(); + fileExtension = fileExtension.TrimStart('.').ToLowerInvariant(); IMediaType[] candidateMediaTypes = _mediaTypeService.GetAll().Where(mt => mt.CompositionPropertyTypes.Any(pt => pt.Alias == Constants.Conventions.Media.File)).ToArray(); var results = new List(); diff --git a/src/Umbraco.Core/StringUdi.cs b/src/Umbraco.Core/StringUdi.cs index 8409ac56e3c2..c802909904c8 100644 --- a/src/Umbraco.Core/StringUdi.cs +++ b/src/Umbraco.Core/StringUdi.cs @@ -23,7 +23,7 @@ public StringUdi(string entityType, string id) /// The uri value of the udi. public StringUdi(Uri uriValue) : base(uriValue) => - Id = Uri.UnescapeDataString(uriValue.AbsolutePath.AsSpan().TrimStart(Constants.CharArrays.ForwardSlash)); + Id = Uri.UnescapeDataString(uriValue.AbsolutePath.AsSpan().TrimStart('/')); /// /// The string part of the identifier. diff --git a/src/Umbraco.Core/UdiRange.cs b/src/Umbraco.Core/UdiRange.cs index 787c885b1aeb..43ac26f8f6f6 100644 --- a/src/Umbraco.Core/UdiRange.cs +++ b/src/Umbraco.Core/UdiRange.cs @@ -111,7 +111,7 @@ public static UdiRange Parse(string value) // Only specify selector if query string is not empty return string.IsNullOrEmpty(uri.Query) ? new UdiRange(udi) - : new UdiRange(udi, uri.Query.TrimStart(Constants.CharArrays.QuestionMark)); + : new UdiRange(udi, uri.Query.TrimStart('?')); } /// diff --git a/src/Umbraco.Core/UriUtilityCore.cs b/src/Umbraco.Core/UriUtilityCore.cs index e300b534e30f..8e811c77aa6e 100644 --- a/src/Umbraco.Core/UriUtilityCore.cs +++ b/src/Umbraco.Core/UriUtilityCore.cs @@ -62,7 +62,7 @@ public static string TrimPathEndSlash(string uri) var pos = IndexOfPathEnd(uriSpan); ReadOnlySpan path = pos > 0 ? uriSpan[..pos] : uriSpan; - path = path.TrimEnd(Constants.CharArrays.ForwardSlash); + path = path.TrimEnd('/'); if (pos > 0) { diff --git a/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs b/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs index ab76589a908b..54795f7ad825 100644 --- a/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs +++ b/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs @@ -114,7 +114,7 @@ public void StoreOldRoute( { try { - var route = _publishedUrlProvider.GetUrl(publishedContent.Key, UrlMode.Relative, culture).TrimEnd(Constants.CharArrays.ForwardSlash); + var route = _publishedUrlProvider.GetUrl(publishedContent.Key, UrlMode.Relative, culture).TrimEnd('/'); if (IsValidRoute(route) && HasPublishedUrlSegment(publishedContent.Key, culture)) { StoreRoute(oldRoutes, publishedContent, culture, route, domainRootId.Value); @@ -227,7 +227,7 @@ private int GetNodeIdWithAssignedDomain(IPublishedContent entityContent) => .FirstOrDefault(x => _domainCache.HasAssigned(x, includeWildcards: true)); private string GetUrl(Guid contentKey, string languageIsoCode) => - _publishedUrlProvider.GetUrl(contentKey, UrlMode.Relative, languageIsoCode).TrimEnd(Constants.CharArrays.ForwardSlash); + _publishedUrlProvider.GetUrl(contentKey, UrlMode.Relative, languageIsoCode).TrimEnd('/'); /// /// Strips the domain's path prefix from a relative URL so that the route stored for redirect diff --git a/src/Umbraco.Web.Common/Extensions/FormCollectionExtensions.cs b/src/Umbraco.Web.Common/Extensions/FormCollectionExtensions.cs index dbf0d1714306..5c6a3c65c1e5 100644 --- a/src/Umbraco.Web.Common/Extensions/FormCollectionExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/FormCollectionExtensions.cs @@ -32,7 +32,7 @@ public static string ToQueryString(this FormCollection? items, params string[] k builder.Append($"{key}={value}&"); } - return builder.ToString().TrimEnd(Constants.CharArrays.Ampersand); + return builder.ToString().TrimEnd('&'); } /// From 39815e3183ace2f4afdd5ad69b01f45ab5b9acf1 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 17 Jul 2026 07:08:54 +0200 Subject: [PATCH 6/7] Simplify children-of prefix stripping in ApiMediaQueryService by removing redundant re-check and the Ordinal/InvariantCulture inconsistency. --- .../Services/ApiMediaQueryService.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs index 79fdadd792b0..42b01d1c44c5 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs @@ -100,12 +100,8 @@ private IPublishedMediaCache GetRequiredPublishedMediaCache() return null; } - ReadOnlySpan childrenOf = fetch.AsSpan(); - if (fetch.StartsWith(childrenOfParameter, StringComparison.InvariantCultureIgnoreCase)) - { - childrenOf = childrenOf[childrenOfParameter.Length..]; - } - + // The guard above guarantees fetch starts with the prefix, so we can strip it unconditionally. + ReadOnlySpan childrenOf = fetch.AsSpan(childrenOfParameter.Length); if (childrenOf.IsEmpty) { // this mirrors the current behavior of the Content Delivery API :-) From fa007e0d7d2d39b845a9d68e81754327a65cdd8b Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 17 Jul 2026 07:09:31 +0200 Subject: [PATCH 7/7] Align use of static and instance methods between URL providers. --- src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs index bb95863abb7a..c4f8d497f4a3 100644 --- a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs @@ -313,7 +313,7 @@ private Uri AssembleUrl(DomainAndUri? domainUri, string path, Uri current, UrlMo return _uriUtility.UriFromUmbraco(uri, _requestSettings); } - private string CombinePaths(string path1, string path2) + private static string CombinePaths(string path1, string path2) { var path = $"{path1.AsSpan().TrimEnd('/')}{path2}"; return path == "/" ? path : path.TrimEnd('/');