diff --git a/src/Umbraco.Core/Models/ContentBaseExtensions.cs b/src/Umbraco.Core/Models/ContentBaseExtensions.cs index 09aeee2f7dc1..0953242c2520 100644 --- a/src/Umbraco.Core/Models/ContentBaseExtensions.cs +++ b/src/Umbraco.Core/Models/ContentBaseExtensions.cs @@ -11,7 +11,7 @@ public static class ContentBaseExtensions private static DefaultUrlSegmentProvider? _defaultUrlSegmentProvider; /// - /// Gets the URL segment for a specified content and culture. + /// Gets a single URL segment for a specified content and culture. /// /// The content. /// @@ -19,29 +19,73 @@ public static class ContentBaseExtensions /// The culture. /// Whether to get the published or draft. /// The URL segment. + /// + /// If more than one URL segment provider is available, the first one that returns a non-null value will be returned. + /// public static string? GetUrlSegment(this IContentBase content, IShortStringHelper shortStringHelper, IEnumerable urlSegmentProviders, string? culture = null, bool published = true) { - if (content == null) - { - throw new ArgumentNullException(nameof(content)); - } + var urlSegment = GetUrlSegments(content, urlSegmentProviders, culture, published).FirstOrDefault(); + + // Ensure we have at least the segment from the default URL provider returned. + urlSegment ??= GetDefaultUrlSegment(shortStringHelper, content, culture, published); - if (urlSegmentProviders == null) + return urlSegment; + } + + /// + /// Gets all URL segments for a specified content and culture. + /// + /// The content. + /// + /// + /// The culture. + /// Whether to get the published or draft. + /// The collection of URL segments. + public static IEnumerable GetUrlSegments(this IContentBase content, IShortStringHelper shortStringHelper, IEnumerable urlSegmentProviders, string? culture = null, bool published = true) + { + var urlSegments = GetUrlSegments(content, urlSegmentProviders, culture, published).Distinct().ToList(); + + // Ensure we have at least the segment from the default URL provider returned. + if (urlSegments.Count == 0) { - throw new ArgumentNullException(nameof(urlSegmentProviders)); + var defaultUrlSegment = GetDefaultUrlSegment(shortStringHelper, content, culture, published); + if (defaultUrlSegment is not null) + { + urlSegments.Add(defaultUrlSegment); + } } - var url = urlSegmentProviders.Select(p => p.GetUrlSegment(content, published, culture)).FirstOrDefault(u => u != null); - if (url == null) + return urlSegments; + } + + private static IEnumerable GetUrlSegments( + IContentBase content, + IEnumerable urlSegmentProviders, + string? culture, + bool published) + { + foreach (IUrlSegmentProvider urlSegmentProvider in urlSegmentProviders) { - if (_defaultUrlSegmentProvider == null) + var segment = urlSegmentProvider.GetUrlSegment(content, published, culture); + if (string.IsNullOrEmpty(segment) == false) { - _defaultUrlSegmentProvider = new DefaultUrlSegmentProvider(shortStringHelper); - } + yield return segment; - url = _defaultUrlSegmentProvider.GetUrlSegment(content, culture); // be safe + if (urlSegmentProvider.AllowAdditionalSegments is false) + { + yield break; + } + } } + } - return url; + private static string? GetDefaultUrlSegment( + IShortStringHelper shortStringHelper, + IContentBase content, + string? culture, + bool published) + { + _defaultUrlSegmentProvider ??= new DefaultUrlSegmentProvider(shortStringHelper); + return _defaultUrlSegmentProvider.GetUrlSegment(content, published, culture); } } diff --git a/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs b/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs index 81451d3223af..a16e7423aa26 100644 --- a/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs +++ b/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs @@ -1,9 +1,32 @@ namespace Umbraco.Cms.Core.Models; +/// +/// Represents a URL segment for a published document. +/// public class PublishedDocumentUrlSegment { + /// + /// Gets or sets the document key. + /// public required Guid DocumentKey { get; set; } + + /// + /// Gets or sets the language Id. + /// public required int LanguageId { get; set; } + + /// + /// Gets or sets the URL segment string. + /// public required string UrlSegment { get; set; } + + /// + /// Gets or sets a value indicating whether the URL segment is for a draft. + /// public required bool IsDraft { get; set; } + + /// + /// Gets or sets a value indicating whether the URL segment is the primary one (first resolved from the collection of URL providers). + /// + public required bool IsPrimary { get; set; } } diff --git a/src/Umbraco.Core/Services/DocumentUrlService.cs b/src/Umbraco.Core/Services/DocumentUrlService.cs index 09c2e434eaf6..c73b2c41e6db 100644 --- a/src/Umbraco.Core/Services/DocumentUrlService.cs +++ b/src/Umbraco.Core/Services/DocumentUrlService.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging; @@ -15,6 +16,9 @@ namespace Umbraco.Cms.Core.Services; +/// +/// Implements operations for handling document URLs. +/// public class DocumentUrlService : IDocumentUrlService { private const string RebuildKey = "UmbracoUrlGeneration"; @@ -34,9 +38,39 @@ public class DocumentUrlService : IDocumentUrlService private readonly IPublishStatusQueryService _publishStatusQueryService; private readonly IDomainCacheService _domainCacheService; - private readonly ConcurrentDictionary _cache = new(); + private readonly ConcurrentDictionary _cache = new(); private bool _isInitialized; + /// + /// Model used to cache a single published document along with all it's URL segments. + /// + private class PublishedDocumentUrlSegments + { + public required Guid DocumentKey { get; set; } + + public required int LanguageId { get; set; } + + public required IList UrlSegments { get; set; } + + public required bool IsDraft { get; set; } + + public class UrlSegment + { + public UrlSegment(string segment, bool isPrimary) + { + Segment = segment; + IsPrimary = isPrimary; + } + + public string Segment { get; } + + public bool IsPrimary { get; } + } + } + + /// + /// Initializes a new instance of the class. + /// public DocumentUrlService( ILogger logger, IDocumentUrlRepository documentUrlRepository, @@ -69,6 +103,7 @@ public DocumentUrlService( _domainCacheService = domainCacheService; } + /// public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken) { if (forceEmpty) @@ -79,9 +114,9 @@ public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken } using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - if (await ShouldRebuildUrlsAsync()) + if (ShouldRebuildUrls()) { - _logger.LogInformation("Rebuilding all urls."); + _logger.LogInformation("Rebuilding all URLs."); await RebuildAllUrlsAsync(); } @@ -89,7 +124,7 @@ public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken IEnumerable languages = await _languageService.GetAllAsync(); var languageIdToIsoCode = languages.ToDictionary(x => x.Id, x => x.IsoCode); - foreach (PublishedDocumentUrlSegment publishedDocumentUrlSegment in publishedDocumentUrlSegments) + foreach (PublishedDocumentUrlSegments publishedDocumentUrlSegment in ConvertToCacheModel(publishedDocumentUrlSegments)) { if (cancellationToken.IsCancellationRequested) { @@ -101,39 +136,77 @@ public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken UpdateCache(_coreScopeProvider.Context!, publishedDocumentUrlSegment, isoCode); } } + _isInitialized = true; scope.Complete(); } - private void UpdateCache(IScopeContext scopeContext, PublishedDocumentUrlSegment publishedDocumentUrlSegment, string isoCode) + private bool ShouldRebuildUrls() { - var cacheKey = CreateCacheKey(publishedDocumentUrlSegment.DocumentKey, isoCode, publishedDocumentUrlSegment.IsDraft); + var persistedValue = GetPersistedRebuildValue(); + var currentValue = GetCurrentRebuildValue(); - scopeContext.Enlist("UpdateCache_" + cacheKey, () => - { - _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegment? existingValue); + return string.Equals(persistedValue, currentValue) is false; + } - if (existingValue is null) + private string? GetPersistedRebuildValue() => _keyValueService.GetValue(RebuildKey); + + private string GetCurrentRebuildValue() => string.Join("|", _urlSegmentProviderCollection.Select(x => x.GetType().Name)); + + /// + public async Task RebuildAllUrlsAsync() + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.ReadLock(Constants.Locks.ContentTree); + + IEnumerable documents = _documentRepository.GetMany(Array.Empty()); + + await CreateOrUpdateUrlSegmentsAsync(documents); + + _keyValueService.SetValue(RebuildKey, GetCurrentRebuildValue()); + + scope.Complete(); + } + + private static IEnumerable ConvertToCacheModel(IEnumerable publishedDocumentUrlSegments) + { + var cacheModels = new List(); + foreach (PublishedDocumentUrlSegment model in publishedDocumentUrlSegments) + { + PublishedDocumentUrlSegments? existingCacheModel = GetModelFromCache(cacheModels, model); + if (existingCacheModel is null) { - if (_cache.TryAdd(cacheKey, publishedDocumentUrlSegment) is false) + cacheModels.Add(new PublishedDocumentUrlSegments { - _logger.LogError("Could not add the document url cache."); - return false; - } + DocumentKey = model.DocumentKey, + LanguageId = model.LanguageId, + UrlSegments = [new PublishedDocumentUrlSegments.UrlSegment(model.UrlSegment, model.IsPrimary)], + IsDraft = model.IsDraft, + }); } else { - if (_cache.TryUpdate(cacheKey, publishedDocumentUrlSegment, existingValue) is false) - { - _logger.LogError("Could not update the document url cache."); - return false; - } + existingCacheModel.UrlSegments = GetUpdatedUrlSegments(existingCacheModel.UrlSegments, model.UrlSegment, model.IsPrimary); } + } - return true; - }); + return cacheModels; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static PublishedDocumentUrlSegments? GetModelFromCache(List cacheModels, PublishedDocumentUrlSegment model) + => cacheModels + .SingleOrDefault(x => x.DocumentKey == model.DocumentKey && x.LanguageId == model.LanguageId && x.IsDraft == model.IsDraft); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static IList GetUpdatedUrlSegments(IList urlSegments, string segment, bool isPrimary) + { + if (urlSegments.FirstOrDefault(x => x.Segment == segment) is null) + { + urlSegments.Add(new PublishedDocumentUrlSegments.UrlSegment(segment, isPrimary)); + } + + return urlSegments; } private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, string isoCode, bool isDraft) @@ -152,40 +225,58 @@ private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, strin }); } - public async Task RebuildAllUrlsAsync() + private void UpdateCache(IScopeContext scopeContext, PublishedDocumentUrlSegments publishedDocumentUrlSegments, string isoCode) { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - scope.ReadLock(Constants.Locks.ContentTree); - - IEnumerable documents = _documentRepository.GetMany(Array.Empty()); + var cacheKey = CreateCacheKey(publishedDocumentUrlSegments.DocumentKey, isoCode, publishedDocumentUrlSegments.IsDraft); - await CreateOrUpdateUrlSegmentsAsync(documents); + scopeContext.Enlist("UpdateCache_" + cacheKey, () => + { + _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegments? existingValue); - _keyValueService.SetValue(RebuildKey, GetCurrentRebuildValue()); + if (existingValue is null) + { + if (_cache.TryAdd(cacheKey, publishedDocumentUrlSegments) is false) + { + _logger.LogError("Could not add to the document url cache."); + return false; + } + } + else + { + if (_cache.TryUpdate(cacheKey, publishedDocumentUrlSegments, existingValue) is false) + { + _logger.LogError("Could not update the document url cache."); + return false; + } + } - scope.Complete(); + return true; + }); } - private Task ShouldRebuildUrlsAsync() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string CreateCacheKey(Guid documentKey, string culture, bool isDraft) => $"{documentKey}|{culture}|{isDraft}".ToLowerInvariant(); + + /// + public string? GetUrlSegment(Guid documentKey, string culture, bool isDraft) { - var persistedValue = GetPersistedRebuildValue(); - var currentValue = GetCurrentRebuildValue(); + ThrowIfNotInitialized(); + var cacheKey = CreateCacheKey(documentKey, culture, isDraft); - return Task.FromResult(string.Equals(persistedValue, currentValue) is false); - } + _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegments? urlSegment); - private string GetCurrentRebuildValue() => string.Join("|", _urlSegmentProviderCollection.Select(x => x.GetType().Name)); - - private string? GetPersistedRebuildValue() => _keyValueService.GetValue(RebuildKey); + return urlSegment?.UrlSegments.FirstOrDefault(x => x.IsPrimary)?.Segment; + } - public string? GetUrlSegment(Guid documentKey, string culture, bool isDraft) + /// + public IEnumerable GetUrlSegments(Guid documentKey, string culture, bool isDraft) { ThrowIfNotInitialized(); var cacheKey = CreateCacheKey(documentKey, culture, isDraft); - _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegment? urlSegment); + _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegments? urlSegments); - return urlSegment?.UrlSegment; + return urlSegments?.UrlSegments.Select(x => x.Segment) ?? Enumerable.Empty(); } private void ThrowIfNotInitialized() @@ -196,10 +287,35 @@ private void ThrowIfNotInitialized() } } + /// + public async Task CreateOrUpdateUrlSegmentsAsync(Guid key) + { + IContent? content = _contentService.GetById(key); + + if (content is not null) + { + await CreateOrUpdateUrlSegmentsAsync(content.Yield()); + } + } + + /// + public async Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key) + { + var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result; + IContent item = _contentService.GetById(id)!; + IEnumerable descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _); + + await CreateOrUpdateUrlSegmentsAsync(new List(descendants) + { + item, + }); + } + + /// public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documentsEnumerable) { IEnumerable documents = documentsEnumerable as IContent[] ?? documentsEnumerable.ToArray(); - if(documents.Any() is false) + if (documents.Any() is false) { return; } @@ -209,7 +325,7 @@ public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents var toSave = new List(); IEnumerable languages = await _languageService.GetAllAsync(); - var languageDictionary = languages.ToDictionary(x=>x.IsoCode); + var languageDictionary = languages.ToDictionary(x => x.IsoCode); foreach (IContent document in documents) { @@ -224,7 +340,7 @@ public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents } } - if(toSave.Any()) + if (toSave.Count > 0) { _documentUrlRepository.Save(toSave); } @@ -234,9 +350,9 @@ public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents private void HandleCaching(IScopeContext scopeContext, IContent document, string? culture, ILanguage language, List toSave) { - IEnumerable<(PublishedDocumentUrlSegment model, bool shouldCache)> modelsAndStatus = GenerateModels(document, culture, language); + IEnumerable<(PublishedDocumentUrlSegments model, bool shouldCache)> modelsAndStatus = GenerateModels(document, culture, language); - foreach ((PublishedDocumentUrlSegment model, bool shouldCache) in modelsAndStatus) + foreach ((PublishedDocumentUrlSegments model, bool shouldCache) in modelsAndStatus) { if (shouldCache is false) { @@ -244,69 +360,91 @@ private void HandleCaching(IScopeContext scopeContext, IContent document, string } else { - toSave.Add(model); + toSave.AddRange(ConvertToPersistedModel(model)); UpdateCache(scopeContext, model, language.IsoCode); } } } - private IEnumerable<(PublishedDocumentUrlSegment model, bool shouldCache)> GenerateModels(IContent document, string? culture, ILanguage language) + private IEnumerable<(PublishedDocumentUrlSegments model, bool shouldCache)> GenerateModels(IContent document, string? culture, ILanguage language) { if (document.Trashed is false && (IsInvariantAndPublished(document) || IsVariantAndPublishedForCulture(document, culture))) { - var publishedUrlSegment = - document.GetUrlSegment(_shortStringHelper, _urlSegmentProviderCollection, culture); - if (publishedUrlSegment.IsNullOrWhiteSpace()) + string[] publishedUrlSegments = document.GetUrlSegments(_shortStringHelper, _urlSegmentProviderCollection, culture).ToArray(); + if (publishedUrlSegments.Length == 0) { - _logger.LogWarning("No published url segment found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}"); + _logger.LogWarning("No published URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}"); } else { - yield return (new PublishedDocumentUrlSegment() + yield return (new PublishedDocumentUrlSegments { DocumentKey = document.Key, LanguageId = language.Id, - UrlSegment = publishedUrlSegment, - IsDraft = false + UrlSegments = publishedUrlSegments + .Select((x, i) => new PublishedDocumentUrlSegments.UrlSegment(x, i == 0)) + .ToList(), + IsDraft = false, }, true); } } else { - yield return (new PublishedDocumentUrlSegment() + yield return (new PublishedDocumentUrlSegments { DocumentKey = document.Key, LanguageId = language.Id, - UrlSegment = string.Empty, - IsDraft = false + UrlSegments = [], + IsDraft = false, }, false); } - var draftUrlSegment = document.GetUrlSegment(_shortStringHelper, _urlSegmentProviderCollection, culture, false); + string[] draftUrlSegments = document.GetUrlSegments(_shortStringHelper, _urlSegmentProviderCollection, culture, false).ToArray(); - if(draftUrlSegment.IsNullOrWhiteSpace()) + if (draftUrlSegments.Any() is false) { - _logger.LogWarning("No draft url segment found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}"); + _logger.LogWarning("No draft URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}"); } else { - yield return (new PublishedDocumentUrlSegment() + yield return (new PublishedDocumentUrlSegments { - DocumentKey = document.Key, LanguageId = language.Id, UrlSegment = draftUrlSegment, IsDraft = true + DocumentKey = document.Key, + LanguageId = language.Id, + UrlSegments = draftUrlSegments + .Select((x, i) => new PublishedDocumentUrlSegments.UrlSegment(x, i == 0)) + .ToList(), + IsDraft = true, }, document.Trashed is false); } } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsVariantAndPublishedForCulture(IContent document, string? culture) => - document.PublishCultureInfos?.Values.Any(x => x.Culture == culture) ?? false; - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsInvariantAndPublished(IContent document) => document.ContentType.VariesByCulture() is false // Is Invariant && document.Published; // Is Published + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsVariantAndPublishedForCulture(IContent document, string? culture) => + document.PublishCultureInfos?.Values.Any(x => x.Culture == culture) ?? false; + + private IEnumerable ConvertToPersistedModel(PublishedDocumentUrlSegments model) + { + foreach (PublishedDocumentUrlSegments.UrlSegment urlSegment in model.UrlSegments) + { + yield return new PublishedDocumentUrlSegment + { + DocumentKey = model.DocumentKey, + LanguageId = model.LanguageId, + UrlSegment = urlSegment.Segment, + IsDraft = model.IsDraft, + IsPrimary = urlSegment.IsPrimary, + }; + } + } + + /// public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumerable) { using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); @@ -327,6 +465,7 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera scope.Complete(); } + /// public Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft) { var urlSegments = route.Split(Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries); @@ -361,7 +500,7 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera // Otherwise we have to find the child with that segment anc follow that foreach (var urlSegment in urlSegments) { - //Get the children of the runnerKey and find the child (if any) with the correct url segment + // Get the children of the runnerKey and find the child (if any) with the correct url segment IEnumerable childKeys = GetChildKeys(runnerKey.Value); runnerKey = GetChildWithUrlSegment(childKeys, urlSegment, culture, isDraft); @@ -371,7 +510,8 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera { break; } - //if part of the path is unpublished, we need to break + + // If part of the path is unpublished, we need to break if (isDraft is false && IsContentPublished(runnerKey.Value, culture) is false) { return null; @@ -380,8 +520,9 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera return runnerKey; } + // If there is no parts, it means it is a root (and no assigned domain) - if(urlSegments.Length == 0) + if (urlSegments.Length == 0) { // // if we do not hide the top level and no domain was found, it mean there is no content. // // TODO we can remove this to keep consistency with the old routing, but it seems incorrect to allow that. @@ -435,13 +576,106 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera return runnerKey; } + private Guid? GetStartNodeKey(int? documentStartNodeId) + { + if (documentStartNodeId is null) + { + return null; + } + + Attempt attempt = _idKeyMap.GetKeyForId(documentStartNodeId.Value, UmbracoObjectTypes.Document); + return attempt.Success ? attempt.Result : null; + } + private bool IsContentPublished(Guid contentKey, string culture) => _publishStatusQueryService.IsDocumentPublished(contentKey, culture); + /// + /// Gets the children based on the latest published version of the content. (No aware of things in this scope). + /// + /// The key of the document to get children from. + /// The keys of all the children of the document. + private IEnumerable GetChildKeys(Guid documentKey) + { + if (_documentNavigationQueryService.TryGetChildrenKeys(documentKey, out IEnumerable childrenKeys)) + { + return childrenKeys; + } + + return []; + } + + private Guid? GetChildWithUrlSegment(IEnumerable childKeys, string urlSegment, string culture, bool isDraft) + { + foreach (Guid childKey in childKeys) + { + IEnumerable childUrlSegments = GetUrlSegments(childKey, culture, isDraft); + + if (childUrlSegments.Contains(urlSegment)) + { + return childKey; + } + } + + return null; + } + + private Guid? GetTopMostRootKey(bool isDraft, string culture) => GetRootKeys(isDraft, culture).Cast().FirstOrDefault(); + + private IEnumerable GetRootKeys(bool isDraft, string culture) + { + if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeys)) + { + foreach (Guid rootKey in rootKeys) + { + if (isDraft || IsContentPublished(rootKey, culture)) + { + yield return rootKey; + } + } + } + } + + private IEnumerable GetKeysInRoot(bool considerFirstLevelAsRoot, bool isDraft, string culture) + { + if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeysEnumerable) is false) + { + yield break; + } + + IEnumerable rootKeys = rootKeysEnumerable as Guid[] ?? rootKeysEnumerable.ToArray(); + + if (considerFirstLevelAsRoot) + { + foreach (Guid rootKey in rootKeys) + { + if (isDraft is false && IsContentPublished(rootKey, culture) is false) + { + continue; + } + + IEnumerable childKeys = GetChildKeys(rootKey); + + foreach (Guid childKey in childKeys) + { + yield return childKey; + } + } + } + else + { + foreach (Guid rootKey in rootKeys) + { + yield return rootKey; + } + } + } + + /// public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDraft) { Attempt documentIdAttempt = _idKeyMap.GetIdForKey(documentKey, UmbracoObjectTypes.Document); - if(documentIdAttempt.Success is false) + if (documentIdAttempt.Success is false) { return "#"; } @@ -451,19 +685,19 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra return "#"; } - if(isDraft is false && string.IsNullOrWhiteSpace(culture) is false && _publishStatusQueryService.IsDocumentPublished(documentKey, culture) is false) + if (isDraft is false && string.IsNullOrWhiteSpace(culture) is false && _publishStatusQueryService.IsDocumentPublished(documentKey, culture) is false) { return "#"; } - var cultureOrDefault = string.IsNullOrWhiteSpace(culture) is false ? culture : _languageService.GetDefaultIsoCodeAsync().GetAwaiter().GetResult(); + string cultureOrDefault = GetCultureOrDefault(culture); Guid[] ancestorsOrSelfKeysArray = ancestorsOrSelfKeys as Guid[] ?? ancestorsOrSelfKeys.ToArray(); ILookup ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray.ToLookup(x => x, ancestorKey => { Attempt idAttempt = _idKeyMap.GetIdForKey(ancestorKey, UmbracoObjectTypes.Document); - if(idAttempt.Success is false) + if (idAttempt.Success is false) { return null; } @@ -472,9 +706,7 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra // If no culture is specified, we assume invariant and return the first domain. // This is also only used to later to specify the node id in the route, so it does not matter what culture it is. - return string.IsNullOrEmpty(culture) - ? domains.FirstOrDefault() - : domains.FirstOrDefault(x => x.Culture?.Equals(culture, StringComparison.InvariantCultureIgnoreCase) ?? false); + return GetDomainForCultureOrInvariant(domains, culture); }); var urlSegments = new List(); @@ -483,16 +715,16 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra foreach (Guid ancestorOrSelfKey in ancestorsOrSelfKeysArray) { - var domains = ancestorOrSelfKeyToDomains[ancestorOrSelfKey].WhereNotNull(); + IEnumerable domains = ancestorOrSelfKeyToDomains[ancestorOrSelfKey].WhereNotNull(); if (domains.Any()) { foundDomain = domains.First();// What todo here that is better? break; } - if (_cache.TryGetValue(CreateCacheKey(ancestorOrSelfKey, cultureOrDefault, isDraft), out PublishedDocumentUrlSegment? publishedDocumentUrlSegment)) + if (TryGetPrimaryUrlSegment(ancestorOrSelfKey, cultureOrDefault, isDraft, out string? segment)) { - urlSegments.Add(publishedDocumentUrlSegment.UrlSegment); + urlSegments.Add(segment); } if (foundDomain is not null) @@ -501,8 +733,7 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra } } - var leftToRight = _globalSettings.ForceCombineUrlPathLeftToRight - || CultureInfo.GetCultureInfo(cultureOrDefault).TextInfo.IsRightToLeft is false; + bool leftToRight = ArePathsLeftToRight(cultureOrDefault); if (leftToRight) { urlSegments.Reverse(); @@ -510,7 +741,7 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra if (foundDomain is not null) { - //we found a domain, and not to construct the route in the funny legacy way + // We found a domain, and not to construct the route in the funny legacy way return foundDomain.ContentId + "/" + string.Join("/", urlSegments); } @@ -518,13 +749,69 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra return GetFullUrl(isRootFirstItem, urlSegments, null, leftToRight); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private string GetCultureOrDefault(string? culture) + => string.IsNullOrWhiteSpace(culture) is false + ? culture + : _languageService.GetDefaultIsoCodeAsync().GetAwaiter().GetResult(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool ArePathsLeftToRight(string cultureOrDefault) + => _globalSettings.ForceCombineUrlPathLeftToRight || + CultureInfo.GetCultureInfo(cultureOrDefault).TextInfo.IsRightToLeft is false; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Domain? GetDomainForCultureOrInvariant(IEnumerable domains, string? culture) + => string.IsNullOrEmpty(culture) + ? domains.FirstOrDefault() + : domains.FirstOrDefault(x => x.Culture?.Equals(culture, StringComparison.InvariantCultureIgnoreCase) ?? false); + + private string GetFullUrl(bool isRootFirstItem, List segments, Domain? foundDomain, bool leftToRight) + { + var urlSegments = new List(segments); + + if (foundDomain is not null) + { + return foundDomain.Name.EnsureEndsWith("/") + string.Join('/', urlSegments); + } + + var hideTopLevel = HideTopLevel(_globalSettings.HideTopLevelNodeFromPath, isRootFirstItem, urlSegments); + if (leftToRight) + { + return '/' + string.Join('/', urlSegments.Skip(hideTopLevel ? 1 : 0)); + } + + if (hideTopLevel) + { + urlSegments.RemoveAt(urlSegments.Count - 1); + } + + return '/' + string.Join('/', urlSegments); + } + + private bool HideTopLevel(bool hideTopLevelNodeFromPath, bool isRootFirstItem, List urlSegments) + { + if (hideTopLevelNodeFromPath is false) + { + return false; + } + + if (isRootFirstItem is false && urlSegments.Count == 1) + { + return false; + } + + return true; + } + + /// public bool HasAny() { ThrowIfNotInitialized(); return _cache.Any(); } - + /// [Obsolete("This method is obsolete and will be removed in future versions. Use IPublishedUrlInfoProvider.GetAllAsync instead.")] public async Task> ListUrlsAsync(Guid contentKey) { @@ -532,7 +819,7 @@ public async Task> ListUrlsAsync(Guid contentKey) Attempt documentIdAttempt = _idKeyMap.GetIdForKey(contentKey, UmbracoObjectTypes.Document); - if(documentIdAttempt.Success is false) + if (documentIdAttempt.Success is false) { return result; } @@ -544,30 +831,34 @@ public async Task> ListUrlsAsync(Guid contentKey) var cultures = languages.ToDictionary(x=>x.IsoCode); Guid[] ancestorsOrSelfKeysArray = ancestorsOrSelfKeys as Guid[] ?? ancestorsOrSelfKeys.ToArray(); - Dictionary>> ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray.ToDictionary(x => x, async ancestorKey => - { - Attempt idAttempt = _idKeyMap.GetIdForKey(ancestorKey, UmbracoObjectTypes.Document); + Dictionary>> ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray + .ToDictionary( + x => x, + ancestorKey => + { + Attempt idAttempt = _idKeyMap.GetIdForKey(ancestorKey, UmbracoObjectTypes.Document); - if(idAttempt.Success is false) - { - return null; - } - IEnumerable domains = _domainCacheService.GetAssigned(idAttempt.Result, false); - return domains.ToLookup(x => x.Culture!); - })!; + if (idAttempt.Success is false) + { + return Task.FromResult((ILookup)null!); + } + + IEnumerable domains = _domainCacheService.GetAssigned(idAttempt.Result, false); + return Task.FromResult(domains.ToLookup(x => x.Culture!)); + })!; foreach ((string culture, ILanguage language) in cultures) { var urlSegments = new List(); - List foundDomains = new List(); + var foundDomains = new List(); var hasUrlInCulture = true; foreach (Guid ancestorOrSelfKey in ancestorsOrSelfKeysArray) { - var domainLookup = await ancestorOrSelfKeyToDomains[ancestorOrSelfKey]; + ILookup domainLookup = await ancestorOrSelfKeyToDomains[ancestorOrSelfKey]; if (domainLookup.Any()) { - var domains = domainLookup[culture]; + IEnumerable domains = domainLookup[culture]; foreach (Domain domain in domains) { Attempt domainKeyAttempt = @@ -587,11 +878,9 @@ public async Task> ListUrlsAsync(Guid contentKey) } } - if (_cache.TryGetValue( - CreateCacheKey(ancestorOrSelfKey, culture, false), - out PublishedDocumentUrlSegment? publishedDocumentUrlSegment)) + if (TryGetPrimaryUrlSegment(ancestorOrSelfKey, culture, false, out string? segment)) { - urlSegments.Add(publishedDocumentUrlSegment.UrlSegment); + urlSegments.Add(segment); } else { @@ -599,7 +888,7 @@ public async Task> ListUrlsAsync(Guid contentKey) } } - //If we did not find a domain and this is not the default language, then the content is not routable + // If we did not find a domain and this is not the default language, then the content is not routable if (foundDomains.Any() is false && language.IsDefault is false) { continue; @@ -631,8 +920,7 @@ public async Task> ListUrlsAsync(Guid contentKey) result.Add(new UrlInfo( text: foundUrl, isUrl: hasUrlInCulture, - culture: culture - )); + culture: culture)); } else { @@ -644,190 +932,37 @@ public async Task> ListUrlsAsync(Guid contentKey) result.Add(new UrlInfo( text: foundUrl, isUrl: hasUrlInCulture, - culture: culture - )); + culture: culture)); } else { result.Add(new UrlInfo( text: "Conflict: Other page has the same url", isUrl: false, - culture: culture - )); + culture: culture)); } } - - - } } return result; } - - private string GetFullUrl(bool isRootFirstItem, List segments, Domain? foundDomain, bool leftToRight) - { - var urlSegments = new List(segments); - - if (foundDomain is not null) - { - return foundDomain.Name.EnsureEndsWith("/") + string.Join('/', urlSegments); - } - - var hideTopLevel = HideTopLevel(_globalSettings.HideTopLevelNodeFromPath, isRootFirstItem, urlSegments); - if (leftToRight) - { - return '/' + string.Join('/', urlSegments.Skip(hideTopLevel ? 1 : 0)); - } - - if (hideTopLevel) - { - urlSegments.RemoveAt(urlSegments.Count - 1); - } - - return '/' + string.Join('/', urlSegments); - } - - private bool HideTopLevel(bool hideTopLevelNodeFromPath, bool isRootFirstItem, List urlSegments) + private bool TryGetPrimaryUrlSegment(Guid documentKey, string culture, bool isDraft, [NotNullWhen(true)] out string? segment) { - if (hideTopLevelNodeFromPath is false) + if (_cache.TryGetValue( + CreateCacheKey(documentKey, culture, isDraft), + out PublishedDocumentUrlSegments? publishedDocumentUrlSegments)) { - return false; - } - - if(isRootFirstItem is false && urlSegments.Count == 1) - { - return false; - } - - return true; - } - - public async Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key) - { - var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result; - IContent item = _contentService.GetById(id)!; - IEnumerable descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _); - - await CreateOrUpdateUrlSegmentsAsync(new List(descendants) - { - item - }); - } - - public async Task CreateOrUpdateUrlSegmentsAsync(Guid key) - { - IContent? content = _contentService.GetById(key); - - if (content is not null) - { - await CreateOrUpdateUrlSegmentsAsync(content.Yield()); - } - } - - private IEnumerable GetKeysInRoot(bool considerFirstLevelAsRoot, bool isDraft, string culture) - { - if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeysEnumerable) is false) - { - yield break; - } - - IEnumerable rootKeys = rootKeysEnumerable as Guid[] ?? rootKeysEnumerable.ToArray(); - - if (considerFirstLevelAsRoot) - { - foreach (Guid rootKey in rootKeys) + PublishedDocumentUrlSegments.UrlSegment? primaryUrlSegment = publishedDocumentUrlSegments.UrlSegments.FirstOrDefault(x => x.IsPrimary); + if (primaryUrlSegment is not null) { - if (isDraft is false && IsContentPublished(rootKey, culture) is false) - { - continue; - } - - IEnumerable childKeys = GetChildKeys(rootKey); - - foreach (Guid childKey in childKeys) - { - yield return childKey; - } - } - } - else - { - foreach (Guid rootKey in rootKeys) - { - yield return rootKey; + segment = primaryUrlSegment.Segment; + return true; } } + segment = null; + return false; } - - private Guid? GetChildWithUrlSegment(IEnumerable childKeys, string urlSegment, string culture, bool isDraft) - { - foreach (Guid childKey in childKeys) - { - var childUrlSegment = GetUrlSegment(childKey, culture, isDraft); - - if (string.Equals(childUrlSegment, urlSegment)) - { - return childKey; - } - } - - return null; - } - - /// - /// Gets the children based on the latest published version of the content. (No aware of things in this scope). - /// - /// The key of the document to get children from. - /// The keys of all the children of the document. - private IEnumerable GetChildKeys(Guid documentKey) - { - if(_documentNavigationQueryService.TryGetChildrenKeys(documentKey, out IEnumerable childrenKeys)) - { - return childrenKeys; - } - - return Enumerable.Empty(); - } - - private IEnumerable GetRootKeys(bool isDraft, string culture) - { - if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeys)) - { - foreach (Guid rootKey in rootKeys) - { - if (isDraft || IsContentPublished(rootKey, culture)) - { - yield return rootKey; - } - } - } - } - - - /// - /// Gets the top most root key. - /// - /// The top most root key. - private Guid? GetTopMostRootKey(bool isDraft, string culture) - { - return GetRootKeys(isDraft, culture).Cast().FirstOrDefault(); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string CreateCacheKey(Guid documentKey, string culture, bool isDraft) => $"{documentKey}|{culture}|{isDraft}".ToLowerInvariant(); - - private Guid? GetStartNodeKey(int? documentStartNodeId) - { - if (documentStartNodeId is null) - { - return null; - } - - Attempt attempt = _idKeyMap.GetKeyForId(documentStartNodeId.Value, UmbracoObjectTypes.Document); - return attempt.Success ? attempt.Result : null; - } - } diff --git a/src/Umbraco.Core/Services/IDocumentUrlService.cs b/src/Umbraco.Core/Services/IDocumentUrlService.cs index 3aa0811cd457..052980f7f4bb 100644 --- a/src/Umbraco.Core/Services/IDocumentUrlService.cs +++ b/src/Umbraco.Core/Services/IDocumentUrlService.cs @@ -1,38 +1,97 @@ -using Umbraco.Cms.Core.Media.EmbedProviders; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Routing; namespace Umbraco.Cms.Core.Services; +/// +/// Defines operations for handling document URLs. +/// public interface IDocumentUrlService { /// /// Initializes the service and ensure the content in the database is correct with the current configuration. /// - /// - /// - /// + /// Forces an early return when we know there are no routes (i.e. on install). + /// The cancellation token. Task InitAsync(bool forceEmpty, CancellationToken cancellationToken); + /// + /// Rebuilds all document URLs. + /// Task RebuildAllUrlsAsync(); + /// - /// Gets the Url from a document key, culture and segment. Preview urls are returned if isPreview is true. + /// Gets a single URL segment from a document key and culture. Preview urls are returned if isDraft is true. /// /// The key of the document. /// The culture code. /// Whether to get the url of the draft or published document. - /// The url of the document. + /// A URL segment for the document. + /// If more than one segment is available, the first retrieved and indicated as primary will be returned. string? GetUrlSegment(Guid documentKey, string culture, bool isDraft); + /// + /// Gets the URL segments from a document key and culture. Preview urls are returned if isDraft is true. + /// + /// The key of the document. + /// The culture code. + /// Whether to get the url of the draft or published document. + /// The URL segments for the document. + IEnumerable GetUrlSegments(Guid documentKey, string culture, bool isDraft) + => throw new NotImplementedException(); + + /// + /// Creates or updates the URL segments for a single document. + /// + /// The document key. + Task CreateOrUpdateUrlSegmentsAsync(Guid key); + + /// + /// Creates or updates the URL segments for a document and it's descendants. + /// + /// The document key. + Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key); + + /// + /// Creates or updates the URL segments for a collection of documents. + /// + /// The document collection. Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents); + /// + /// Deletes all URLs from the cache for a collection of document keys. + /// + /// The collection of document keys. Task DeleteUrlsFromCacheAsync(IEnumerable documentKeys); + /// + /// Gets a document key by route. + /// + /// The route. + /// The culture code. + /// The document start node Id. + /// Whether to get the url of the draft or published document. + /// The document key, or null if not found. Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft); + + /// + /// Gets all the URLs for a given content key. + /// + /// The content key. + [Obsolete("This method is obsolete and will be removed in future versions. Use IPublishedUrlInfoProvider.GetAllAsync instead. Scheduled for removal in Umbraco 17.")] Task> ListUrlsAsync(Guid contentKey); - Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key); - Task CreateOrUpdateUrlSegmentsAsync(Guid key); + + /// + /// Gets the legacy route format for a document key and culture. + /// + /// The key of the document. + /// The culture code. + /// Whether to get the url of the draft or published document. + /// string GetLegacyRouteFormat(Guid key, string? culture, bool isDraft); + /// + /// Gets a value indicating whether any URLs have been cached. + /// bool HasAny(); } diff --git a/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs b/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs index 6e9f1db32607..075b52b4eea1 100644 --- a/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs +++ b/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs @@ -8,6 +8,16 @@ namespace Umbraco.Cms.Core.Strings; /// Url segments should comply with IETF RFCs regarding content, encoding, etc. public interface IUrlSegmentProvider { + /// + /// Gets a value indicating whether the URL segment provider allows additional segments after providing one. + /// + /// + /// If set to true, when more than one URL segment provider is available, futher providers after this one in the collection will be called + /// even if the current provider provides a segment. + /// If false, the provider will terminate the chain of URL segment providers if it provides a segment. + /// + bool AllowAdditionalSegments => false; + /// /// Gets the URL segment for a specified content and culture. /// @@ -20,6 +30,18 @@ public interface IUrlSegmentProvider /// URL per culture. /// string? GetUrlSegment(IContentBase content, string? culture = null); + + /// + /// Gets the URL segment for a specified content, published status and and culture. + /// + /// The content. + /// The culture. + /// The URL segment. + /// + /// This is for when Umbraco is capable of managing more than one URL + /// per content, in 1-to-1 multilingual configurations. Then there would be one + /// URL per culture. + /// string? GetUrlSegment(IContentBase content, bool published, string? culture = null) => GetUrlSegment(content, culture); // TODO: For the 301 tracking, we need to add another extended interface to this so that diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 599b2fb9d3b6..fe14f785d8b9 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -111,5 +111,8 @@ protected virtual void DefinePlan() // To 15.3.0 To("{7B11F01E-EE33-4B0B-81A1-F78F834CA45B}"); + + // To 15.4.0 + To("{A9E72794-4036-4563-B543-1717C73B8879}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_3_0/AddNameAndDescriptionToWebhooks.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_3_0/AddNameAndDescriptionToWebhooks.cs index eee8d5e26cd4..a8cdd96f2e64 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_3_0/AddNameAndDescriptionToWebhooks.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_3_0/AddNameAndDescriptionToWebhooks.cs @@ -28,7 +28,7 @@ protected override void Migrate() } else { - Logger.LogWarning($"Table {Constants.DatabaseSchema.Tables.Webhook} does not exist so the addition of the name and description by columnss in migration {nameof(AddNameAndDescriptionToWebhooks)} cannot be completed."); + Logger.LogWarning($"Table {Constants.DatabaseSchema.Tables.Webhook} does not exist so the addition of the name and description by columns in migration {nameof(AddNameAndDescriptionToWebhooks)} cannot be completed."); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_4_0/UpdateDocumentUrlToPersistMultipleSegmentsPerDocument.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_4_0/UpdateDocumentUrlToPersistMultipleSegmentsPerDocument.cs new file mode 100644 index 000000000000..092e7f81bad4 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_4_0/UpdateDocumentUrlToPersistMultipleSegmentsPerDocument.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_4_0; + +/// +/// Migration to make necessary schema updates to support multiple segments per document. +/// +public class UpdateDocumentUrlToPersistMultipleSegmentsPerDocument : MigrationBase +{ + /// + /// Initializes a new instance of the class. + /// + public UpdateDocumentUrlToPersistMultipleSegmentsPerDocument(IMigrationContext context) + : base(context) + { + } + + /// + protected override void Migrate() + { + Logger.LogDebug("Schema updates to {TableName} for support of multiple segments per document", Constants.DatabaseSchema.Tables.DocumentUrl); + + if (TableExists(Constants.DatabaseSchema.Tables.DocumentUrl)) + { + ExtendUniqueIndexAcrossSegmentField(); + AddAndPopulateIsPrimaryColumn(); + } + else + { + throw new InvalidOperationException( + $"Table {Constants.DatabaseSchema.Tables.DocumentUrl} does not exist so the migration {nameof(UpdateDocumentUrlToPersistMultipleSegmentsPerDocument)} could not be completed."); + } + } + + private void ExtendUniqueIndexAcrossSegmentField() + { + Logger.LogDebug("Extending the unique index on {TableName} to include the urlSegment column", Constants.DatabaseSchema.Tables.DocumentUrl); + + var indexName = "IX_" + Constants.DatabaseSchema.Tables.DocumentUrl; + if (IndexExists(indexName)) + { + DeleteIndex(indexName); + } + + CreateIndex(indexName); + } + + private void AddAndPopulateIsPrimaryColumn() + { + const string IsPrimaryColumnName = "isPrimary"; + + Logger.LogDebug("Adding the {Column} column {TableName} to include the urlSegment column", IsPrimaryColumnName, Constants.DatabaseSchema.Tables.DocumentUrl); + + var columns = Context.SqlContext.SqlSyntax.GetColumnsInSchema(Context.Database).ToList(); + if (columns + .SingleOrDefault(x => x.TableName == Constants.DatabaseSchema.Tables.DocumentUrl && x.ColumnName == IsPrimaryColumnName) is null) + { + AddColumn(Constants.DatabaseSchema.Tables.DocumentUrl, IsPrimaryColumnName); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs index 4a006f9307b0..133df789c77a 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs @@ -16,7 +16,7 @@ public class DocumentUrlDto [PrimaryKeyColumn(Clustered = false, AutoIncrement = true)] public int NodeId { get; set; } - [Index(IndexTypes.UniqueClustered, ForColumns = "uniqueId, languageId, isDraft", Name = "IX_" + TableName)] + [Index(IndexTypes.UniqueClustered, ForColumns = "uniqueId, languageId, isDraft, urlSegment", Name = "IX_" + TableName)] [Column("uniqueId")] [ForeignKey(typeof(NodeDto), Column = "uniqueId")] public Guid UniqueId { get; set; } @@ -28,14 +28,11 @@ public class DocumentUrlDto [ForeignKey(typeof(LanguageDto))] public int LanguageId { get; set; } - // - // [Column("segment")] - // [NullSetting(NullSetting = NullSettings.Null)] - // [Length(PropertyDataDto.SegmentLength)] - // public string Segment { get; set; } = string.Empty; - [Column("urlSegment")] [NullSetting(NullSetting = NullSettings.NotNull)] public string UrlSegment { get; set; } = string.Empty; + [Column("isPrimary")] + [Constraint(Default = 1)] + public bool IsPrimary { get; set; } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs index ded011440da0..07a36f49eedc 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs @@ -1,18 +1,11 @@ -using Microsoft.Extensions.Logging; using NPoco; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Entities; -using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; -using Umbraco.Cms.Core.Security; using Umbraco.Cms.Infrastructure.Persistence.Dtos; -using Umbraco.Cms.Infrastructure.Persistence.Factories; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; - namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; public class DocumentUrlRepository : IDocumentUrlRepository @@ -36,14 +29,15 @@ private IUmbracoDatabase Database public void Save(IEnumerable publishedDocumentUrlSegments) { - //TODO avoid this is called as first thing on first restart after install + // TODO: avoid this is called as first thing on first restart after install IEnumerable documentKeys = publishedDocumentUrlSegments.Select(x => x.DocumentKey).Distinct(); - Dictionary<(Guid UniqueId, int LanguageId, bool isDraft), DocumentUrlDto> dtoDictionary = publishedDocumentUrlSegments.Select(BuildDto).ToDictionary(x=> (x.UniqueId, x.LanguageId, x.IsDraft)); + Dictionary<(Guid UniqueId, int LanguageId, bool isDraft, string urlSegment), DocumentUrlDto> dtoDictionary = publishedDocumentUrlSegments + .Select(BuildDto) + .ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft, x.UrlSegment)); - var toUpdate = new List(); var toDelete = new List(); - var toInsert = dtoDictionary.Values.ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft)); + var toInsert = dtoDictionary.Values.ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft, x.UrlSegment)); foreach (IEnumerable group in documentKeys.InGroupsOf(Constants.Sql.MaxParameterCount)) { @@ -57,18 +51,12 @@ public void Save(IEnumerable publishedDocumentUrlSe foreach (DocumentUrlDto existing in existingUrlsInBatch) { - - if (dtoDictionary.TryGetValue((existing.UniqueId, existing.LanguageId, existing.IsDraft), out DocumentUrlDto? found)) + if (dtoDictionary.TryGetValue((existing.UniqueId, existing.LanguageId, existing.IsDraft, existing.UrlSegment), out DocumentUrlDto? found)) { found.NodeId = existing.NodeId; - // Only update if the url segment is different - if (found.UrlSegment != existing.UrlSegment) - { - toUpdate.Add(found); - } - // if we found it, we know we should not insert it as a new - toInsert.Remove((found.UniqueId, found.LanguageId, found.IsDraft)); + // If we found it, we know we should not insert it as a new record. + toInsert.Remove((found.UniqueId, found.LanguageId, found.IsDraft, found.UrlSegment)); } else { @@ -83,14 +71,6 @@ public void Save(IEnumerable publishedDocumentUrlSe Database.DeleteMany().Where(x => toDelete.Contains(x.NodeId)).Execute(); } - if (toUpdate.Any()) - { - foreach (DocumentUrlDto updated in toUpdate) - { - Database.Update(updated); - } - } - Database.InsertBulk(toInsert.Values); } @@ -115,7 +95,8 @@ private PublishedDocumentUrlSegment BuildModel(DocumentUrlDto dto) => UrlSegment = dto.UrlSegment, DocumentKey = dto.UniqueId, LanguageId = dto.LanguageId, - IsDraft = dto.IsDraft + IsDraft = dto.IsDraft, + IsPrimary = dto.IsPrimary }; private DocumentUrlDto BuildDto(PublishedDocumentUrlSegment model) @@ -126,6 +107,7 @@ private DocumentUrlDto BuildDto(PublishedDocumentUrlSegment model) UniqueId = model.DocumentKey, LanguageId = model.LanguageId, IsDraft = model.IsDraft, + IsPrimary = model.IsPrimary, }; } } diff --git a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml index 7c6ea3140b7f..918b3ea4fed7 100644 --- a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml +++ b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml @@ -1,6 +1,20 @@  + + CP0001 + T:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.DocumentUrlServiceTest + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + + + CP0001 + T:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.DocumentUrlServiceTest_HideTopLevel_False + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + CP0001 T:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors.BlockEditorBackwardsCompatibilityTests diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs similarity index 57% rename from tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs rename to tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs index cfc775c88ed1..3f015e5a940c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs @@ -3,6 +3,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; @@ -13,9 +14,13 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; [TestFixture] [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Mock)] -public class DocumentUrlServiceTest : UmbracoIntegrationTestWithContent +public class DocumentUrlServiceTests : UmbracoIntegrationTestWithContent { + private const string SubSubPage2Key = "48AE405E-5142-4EBE-929F-55EB616F51F2"; + private const string SubSubPage3Key = "AACF2979-3F53-4184-B071-BA34D3338497"; + protected IDocumentUrlService DocumentUrlService => GetRequiredService(); + protected ILanguageService LanguageService => GetRequiredService(); protected override void CustomTestSetup(IUmbracoBuilder builder) @@ -24,8 +29,60 @@ protected override void CustomTestSetup(IUmbracoBuilder builder) builder.AddNotificationHandler(); builder.Services.AddNotificationAsyncHandler(); + + builder.UrlSegmentProviders().Insert(); + builder.UrlSegmentProviders().Insert(); + } + + private abstract class CustomUrlSegmentProviderBase + { + private readonly IUrlSegmentProvider _defaultProvider; + + public CustomUrlSegmentProviderBase(IShortStringHelper stringHelper) => _defaultProvider = new DefaultUrlSegmentProvider(stringHelper); + + protected string? GetUrlSegment(IContentBase content, string? culture, params Guid[] pageKeys) + { + if (pageKeys.Contains(content.Key) is false) + { + return null; + } + + var segment = _defaultProvider.GetUrlSegment(content, culture); + return segment is not null ? segment + "-custom" : null; + } } + /// + /// A test implementation of that provides a custom URL segment for a specific page + /// and allows for additional providers to provide segments too. + /// + private class CustomUrlSegmentProvider1 : CustomUrlSegmentProviderBase, IUrlSegmentProvider + { + public CustomUrlSegmentProvider1(IShortStringHelper stringHelper) + : base(stringHelper) + { + } + + public bool AllowAdditionalSegments => true; + + public string? GetUrlSegment(IContentBase content, string? culture = null) + => GetUrlSegment(content, culture, Guid.Parse(SubPageKey), Guid.Parse(SubSubPage3Key)); + } + + /// + /// A test implementation of that provides a custom URL segment for a specific page + /// and terminates, not allowing additional providers to provide segments too. + /// + private class CustomUrlSegmentProvider2 : CustomUrlSegmentProviderBase, IUrlSegmentProvider + { + public CustomUrlSegmentProvider2(IShortStringHelper stringHelper) + : base(stringHelper) + { + } + + public string? GetUrlSegment(IContentBase content, string? culture = null) + => GetUrlSegment(content, culture, Guid.Parse(SubPage2Key), Guid.Parse(SubSubPage2Key)); + } public override void Setup() { @@ -52,7 +109,7 @@ public override void Setup() // } [Test] - public async Task Trashed_documents_do_not_have_a_url_segment() + public async Task GetUrlSegment_For_Deleted_Document_Does_Not_Have_Url_Segment() { var isoCode = (await LanguageService.GetDefaultLanguageAsync()).IsoCode; @@ -64,7 +121,7 @@ public async Task Trashed_documents_do_not_have_a_url_segment() //TODO test with the urlsegment property value! [Test] - public async Task Deleted_documents_do_not_have_a_url_segment__Parent_deleted() + public async Task GetUrlSegment_For_Document_With_Parent_Deleted_Does_Not_Have_Url_Segment() { ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); @@ -78,7 +135,7 @@ public async Task Deleted_documents_do_not_have_a_url_segment__Parent_deleted() } [Test] - public async Task Deleted_documents_do_not_have_a_url_segment() + public async Task GetUrlSegment_For_Published_Then_Deleted_Document_Does_Not_Have_Url_Segment() { ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); @@ -91,16 +148,19 @@ public async Task Deleted_documents_do_not_have_a_url_segment() Assert.IsNull(actual); } - [Test] [TestCase("/", "en-US", true, ExpectedResult = TextpageKey)] [TestCase("/text-page-1", "en-US", true, ExpectedResult = SubPageKey)] - [TestCase("/text-page-2", "en-US", true, ExpectedResult = SubPage2Key)] + [TestCase("/text-page-1-custom", "en-US", true, ExpectedResult = SubPageKey)] // Uses the segment registered by the custom IIUrlSegmentProvider that allows for more than one segment per document. + [TestCase("/text-page-2", "en-US", true, ExpectedResult = null)] + [TestCase("/text-page-2-custom", "en-US", true, ExpectedResult = SubPage2Key)] // Uses the segment registered by the custom IIUrlSegmentProvider that does not allow for more than one segment per document. [TestCase("/text-page-3", "en-US", true, ExpectedResult = SubPage3Key)] [TestCase("/", "en-US", false, ExpectedResult = TextpageKey)] [TestCase("/text-page-1", "en-US", false, ExpectedResult = SubPageKey)] - [TestCase("/text-page-2", "en-US", false, ExpectedResult = SubPage2Key)] + [TestCase("/text-page-1-custom", "en-US", false, ExpectedResult = SubPageKey)] // Uses the segment registered by the custom IIUrlSegmentProvider that allows for more than one segment per document. + [TestCase("/text-page-2", "en-US", false, ExpectedResult = null)] + [TestCase("/text-page-2-custom", "en-US", false, ExpectedResult = SubPage2Key)] // Uses the segment registered by the custom IIUrlSegmentProvider that does not allow for more than one segment per document. [TestCase("/text-page-3", "en-US", false, ExpectedResult = SubPage3Key)] - public string? Expected_Routes(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Returns_Expected_Route(string route, string isoCode, bool loadDraft) { if (loadDraft is false) { @@ -111,16 +171,16 @@ public async Task Deleted_documents_do_not_have_a_url_segment() } [Test] - public void No_Published_Route_when_not_published() + public void GetDocumentKeyByRoute_UnPublished_Documents_Have_No_Published_Route() { Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, true)); Assert.IsNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false)); } [Test] - public void Unpublished_Pages_Are_not_available() + public void GetDocumentKeyByRoute_Published_Then_Unpublished_Documents_Have_No_Published_Route() { - //Arrange + // Arrange ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); Assert.Multiple(() => @@ -131,7 +191,7 @@ public void Unpublished_Pages_Are_not_available() Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false)); }); - //Act + // Act ContentService.Unpublish(Textpage ); Assert.Multiple(() => @@ -144,18 +204,32 @@ public void Unpublished_Pages_Are_not_available() Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, true)); Assert.IsNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false)); }); - } - - [Test] [TestCase("/text-page-1/sub-page-1", "en-US", true, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")] [TestCase("/text-page-1/sub-page-1", "en-US", false, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")] - public string? Expected_Routes_with_subpages(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Returns_Expected_Route_For_SubPage(string route, string isoCode, bool loadDraft) + => ExecuteSubPageTest("DF49F477-12F2-4E33-8563-91A7CC1DCDBB", "Sub Page 1", route, isoCode, loadDraft); + + [TestCase("/text-page-1/sub-page-2-custom", "en-US", true, ExpectedResult = SubSubPage2Key)] + [TestCase("/text-page-1/sub-page-2-custom", "en-US", false, ExpectedResult = SubSubPage2Key)] + [TestCase("/text-page-1/sub-page-2", "en-US", true, ExpectedResult = null)] + [TestCase("/text-page-1/sub-page-2", "en-US", false, ExpectedResult = null)] + public string? GetDocumentKeyByRoute_Returns_Expected_Route_For_SubPage_With_Terminating_Custom_Url_Provider(string route, string isoCode, bool loadDraft) + => ExecuteSubPageTest(SubSubPage2Key, "Sub Page 2", route, isoCode, loadDraft); + + [TestCase("/text-page-1/sub-page-3-custom", "en-US", true, ExpectedResult = SubSubPage3Key)] + [TestCase("/text-page-1/sub-page-3-custom", "en-US", false, ExpectedResult = SubSubPage3Key)] + [TestCase("/text-page-1/sub-page-3", "en-US", true, ExpectedResult = SubSubPage3Key)] + [TestCase("/text-page-1/sub-page-3", "en-US", false, ExpectedResult = SubSubPage3Key)] + public string? GetDocumentKeyByRoute_Returns_Expected_Route_For_SubPage_With_Non_Terminating_Custom_Url_Provider(string route, string isoCode, bool loadDraft) + => ExecuteSubPageTest(SubSubPage3Key, "Sub Page 3", route, isoCode, loadDraft); + + private string? ExecuteSubPageTest(string documentKey, string documentName, string route, string isoCode, bool loadDraft) { // Create a subpage - var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, "Sub Page 1", Subpage.Id); - subsubpage.Key = new Guid("DF49F477-12F2-4E33-8563-91A7CC1DCDBB"); + var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, documentName, Subpage.Id); + subsubpage.Key = Guid.Parse(documentKey); var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); ContentService.Save(subsubpage, -1, contentSchedule); @@ -164,13 +238,12 @@ public void Unpublished_Pages_Are_not_available() ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); } - return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); + return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); } - [Test] [TestCase("/second-root", "en-US", true, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")] [TestCase("/second-root", "en-US", false, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")] - public string? Second_root_cannot_hide_url(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Second_Root_Does_Not_Hide_Url(string route, string isoCode, bool loadDraft) { // Create a second root var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); @@ -187,10 +260,9 @@ public void Unpublished_Pages_Are_not_available() return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); } - [Test] [TestCase("/child-of-second-root", "en-US", true, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")] [TestCase("/child-of-second-root", "en-US", false, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")] - public string? Child_of_second_root_do_not_have_parents_url_as_prefix(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Child_Of_Second_Root_Does_Not_Have_Parents_Url_As_Prefix(string route, string isoCode, bool loadDraft) { // Create a second root var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); @@ -205,7 +277,6 @@ public void Unpublished_Pages_Are_not_available() // Publish both the main root and the second root with descendants if (loadDraft is false) { - ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); ContentService.PublishBranch(secondRoot, PublishBranchFilter.IncludeUnpublished, ["*"]); } @@ -213,6 +284,16 @@ public void Unpublished_Pages_Are_not_available() return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); } + [TestCase(TextpageKey, "en-US", ExpectedResult = "/")] + [TestCase(SubPageKey, "en-US", ExpectedResult = "/text-page-1-custom")] // Has non-terminating custom URL segment provider. + [TestCase(SubPage2Key, "en-US", ExpectedResult = "/text-page-2-custom")] // Has terminating custom URL segment provider. + [TestCase(SubPage3Key, "en-US", ExpectedResult = "/text-page-3")] + public string? GetLegacyRouteFormat_Returns_Expected_Route(string documentKey, string culture) + { + ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + return DocumentUrlService.GetLegacyRouteFormat(Guid.Parse(documentKey), culture, false); + } + //TODO test cases: // - Find the root, when a domain is set diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests_HideTopLevel_False.cs similarity index 90% rename from tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs rename to tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests_HideTopLevel_False.cs index 5efc52f77877..5158e421c19e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests_HideTopLevel_False.cs @@ -2,12 +2,10 @@ using NUnit.Framework; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Handlers; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; -using Umbraco.Cms.Tests.Common.Attributes; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; @@ -17,7 +15,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; [TestFixture] [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Console)] -public class DocumentUrlServiceTest_HideTopLevel_False : UmbracoIntegrationTestWithContent +public class DocumentUrlServiceTests_HideTopLevel_False : UmbracoIntegrationTestWithContent { protected IDocumentUrlService DocumentUrlService => GetRequiredService(); protected ILanguageService LanguageService => GetRequiredService(); @@ -28,7 +26,6 @@ protected override void CustomTestSetup(IUmbracoBuilder builder) builder.Services.AddUnique(); builder.AddNotificationHandler(); - } public override void Setup() @@ -46,7 +43,7 @@ public override void Setup() [TestCase("/textpage/text-page-1", "en-US", false, ExpectedResult = SubPageKey)] [TestCase("/textpage/text-page-2", "en-US", false, ExpectedResult = SubPage2Key)] [TestCase("/textpage/text-page-3", "en-US", false, ExpectedResult = SubPage3Key)] - public string? Expected_Routes(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Returns_Expected_Route(string route, string isoCode, bool loadDraft) { if (loadDraft is false) { @@ -60,7 +57,7 @@ public override void Setup() [Test] [TestCase("/textpage/text-page-1/sub-page-1", "en-US", true, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")] [TestCase("/textpage/text-page-1/sub-page-1", "en-US", false, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")] - public string? Expected_Routes_with_subpages(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Returns_Expected_Route_For_SubPage(string route, string isoCode, bool loadDraft) { // Create a subpage var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, "Sub Page 1", Subpage.Id); @@ -79,7 +76,7 @@ public override void Setup() [Test] [TestCase("/second-root", "en-US", true, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")] [TestCase("/second-root", "en-US", false, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")] - public string? Second_root_cannot_hide_url(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Second_Root_Does_Not_Hide_Url(string route, string isoCode, bool loadDraft) { // Create a second root var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); @@ -99,7 +96,7 @@ public override void Setup() [Test] [TestCase("/second-root/child-of-second-root", "en-US", true, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")] [TestCase("/second-root/child-of-second-root", "en-US", false, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")] - public string? Child_of_second_root_do_not_have_parents_url_as_prefix(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Child_Of_Second_Root_Does_Not_Have_Parents_Url_As_Prefix(string route, string isoCode, bool loadDraft) { // Create a second root var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);