diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs index c5bfe4fe9e..46cb65d5e4 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs @@ -1,12 +1,25 @@ -using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; namespace Umbraco.Cms.Infrastructure.HybridCache.Factories; +/// +/// Defines a factory to create and from a or . +/// internal interface IPublishedContentFactory { + /// + /// Converts a to an if document type. + /// IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview); + + /// + /// Converts a to an of media type. + /// IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode); + /// + /// Converts a to an . + /// IPublishedMember ToPublishedMember(IMember member); } diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs index 57863dc986..5f33705716 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs @@ -1,29 +1,61 @@ -using Umbraco.Cms.Core.Models; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.HybridCache.Factories; +/// +/// Defines a factory to create and from a or . +/// internal sealed class PublishedContentFactory : IPublishedContentFactory { private readonly IElementsCache _elementsCache; private readonly IVariationContextAccessor _variationContextAccessor; private readonly IPublishedContentTypeCache _publishedContentTypeCache; + private readonly ILogger _logger; + private readonly AppCaches _appCaches; - + /// + /// Initializes a new instance of the class. + /// public PublishedContentFactory( IElementsCache elementsCache, IVariationContextAccessor variationContextAccessor, - IPublishedContentTypeCache publishedContentTypeCache) + IPublishedContentTypeCache publishedContentTypeCache, + ILogger logger, + AppCaches appCaches) { _elementsCache = elementsCache; _variationContextAccessor = variationContextAccessor; _publishedContentTypeCache = publishedContentTypeCache; + _logger = logger; + _appCaches = appCaches; } + /// public IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview) { - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId); + var cacheKey = $"{nameof(PublishedContentFactory)}DocumentCache_{contentCacheNode.Id}_{preview}"; + IPublishedContent? publishedContent = _appCaches.RequestCache.GetCacheItem(cacheKey); + if (publishedContent is not null) + { + _logger.LogDebug( + "Using cached IPublishedContent for document {ContentCacheNodeName} ({ContentCacheNodeId}).", + contentCacheNode.Data?.Name ?? "No Name", + contentCacheNode.Id); + return publishedContent; + } + + _logger.LogDebug( + "Creating IPublishedContent for document {ContentCacheNodeName} ({ContentCacheNodeId}).", + contentCacheNode.Data?.Name ?? "No Name", + contentCacheNode.Id); + + IPublishedContentType contentType = + _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId); var contentNode = new ContentNode( contentCacheNode.Id, contentCacheNode.Key, @@ -34,19 +66,42 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory preview ? contentCacheNode.Data : null, preview ? null : contentCacheNode.Data); - IPublishedContent? model = GetModel(contentNode, preview); + publishedContent = GetModel(contentNode, preview); if (preview) { - return model ?? GetPublishedContentAsDraft(model); + publishedContent ??= GetPublishedContentAsDraft(publishedContent); } - return model; + if (publishedContent is not null) + { + _appCaches.RequestCache.Set(cacheKey, publishedContent); + } + + return publishedContent; } + /// public IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode) { - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Media, contentCacheNode.ContentTypeId); + var cacheKey = $"{nameof(PublishedContentFactory)}MediaCache_{contentCacheNode.Id}"; + IPublishedContent? publishedContent = _appCaches.RequestCache.GetCacheItem(cacheKey); + if (publishedContent is not null) + { + _logger.LogDebug( + "Using cached IPublishedContent for media {ContentCacheNodeName} ({ContentCacheNodeId}).", + contentCacheNode.Data?.Name ?? "No Name", + contentCacheNode.Id); + return publishedContent; + } + + _logger.LogDebug( + "Creating IPublishedContent for media {ContentCacheNodeName} ({ContentCacheNodeId}).", + contentCacheNode.Data?.Name ?? "No Name", + contentCacheNode.Id); + + IPublishedContentType contentType = + _publishedContentTypeCache.Get(PublishedItemType.Media, contentCacheNode.ContentTypeId); var contentNode = new ContentNode( contentCacheNode.Id, contentCacheNode.Key, @@ -57,14 +112,40 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory null, contentCacheNode.Data); - return GetModel(contentNode, false); + publishedContent = GetModel(contentNode, false); + + if (publishedContent is not null) + { + _appCaches.RequestCache.Set(cacheKey, publishedContent); + } + + return publishedContent; } + /// public IPublishedMember ToPublishedMember(IMember member) { - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Member, member.ContentTypeId); + string cacheKey = $"{nameof(PublishedContentFactory)}MemberCache_{member.Id}"; + IPublishedMember? publishedMember = _appCaches.RequestCache.GetCacheItem(cacheKey); + if (publishedMember is not null) + { + _logger.LogDebug( + "Using cached IPublishedMember for member {MemberName} ({MemberId}).", + member.Username, + member.Id); - // Members are only "mapped" never cached, so these default values are a bit wierd, but they are not used. + return publishedMember; + } + + _logger.LogDebug( + "Creating IPublishedMember for member {MemberName} ({MemberId}).", + member.Username, + member.Id); + + IPublishedContentType contentType = + _publishedContentTypeCache.Get(PublishedItemType.Member, member.ContentTypeId); + + // Members are only "mapped" never cached, so these default values are a bit weird, but they are not used. var contentData = new ContentData( member.Name, null, @@ -85,7 +166,11 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory contentType, null, contentData); - return new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor); + publishedMember = new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor); + + _appCaches.RequestCache.Set(cacheKey, publishedMember); + + return publishedMember; } private static Dictionary GetPropertyValues(IPublishedContentType contentType, IMember member) @@ -134,7 +219,6 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory _variationContextAccessor); } - private static IPublishedContent? GetPublishedContentAsDraft(IPublishedContent? content) => content == null ? null : // an object in the cache is either an IPublishedContentOrMedia, @@ -149,7 +233,7 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory content = wrapped.Unwrap(); } - if (!(content is PublishedContent inner)) + if (content is not PublishedContent inner) { throw new InvalidOperationException("Innermost content is not PublishedContent."); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs new file mode 100644 index 0000000000..3d7be44d69 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs @@ -0,0 +1,147 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.HybridCache; +using Umbraco.Cms.Infrastructure.HybridCache.Factories; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class PublishedContentFactoryTests : UmbracoIntegrationTestWithContent +{ + private IPublishedContentFactory PublishedContentFactory => GetRequiredService(); + + private IPublishedValueFallback PublishedValueFallback => GetRequiredService(); + + private IMediaService MediaService => GetRequiredService(); + + private IMediaTypeService MediaTypeService => GetRequiredService(); + + private IMemberService MemberService => GetRequiredService(); + + private IMemberTypeService MemberTypeService => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + var requestCache = new DictionaryAppCache(); + var appCaches = new AppCaches( + NoAppCache.Instance, + requestCache, + new IsolatedCaches(type => NoAppCache.Instance)); + builder.Services.AddUnique(appCaches); + } + + [Test] + public void Can_Create_Published_Content_For_Document() + { + var contentCacheNode = new ContentCacheNode + { + Id = Textpage.Id, + Key = Textpage.Key, + ContentTypeId = Textpage.ContentType.Id, + CreateDate = Textpage.CreateDate, + CreatorId = Textpage.CreatorId, + SortOrder = Textpage.SortOrder, + Data = new ContentData( + Textpage.Name, + "text-page", + Textpage.VersionId, + Textpage.UpdateDate, + Textpage.WriterId, + Textpage.TemplateId, + true, + new Dictionary + { + { + "title", new[] + { + new PropertyData + { + Value = "Test title", + Culture = string.Empty, + Segment = string.Empty, + }, + } + }, + }, + null), + }; + var result = PublishedContentFactory.ToIPublishedContent(contentCacheNode, false); + Assert.IsNotNull(result); + Assert.AreEqual(Textpage.Id, result.Id); + Assert.AreEqual(Textpage.Name, result.Name); + Assert.AreEqual("Test title", result.Properties.Single(x => x.Alias == "title").Value(PublishedValueFallback)); + + // Verify that requesting the same content again returns the same instance (from request cache). + var result2 = PublishedContentFactory.ToIPublishedContent(contentCacheNode, false); + Assert.AreSame(result, result2); + } + + [Test] + public async Task Can_Create_Published_Content_For_Media() + { + var mediaType = new MediaTypeBuilder().Build(); + mediaType.AllowedAsRoot = true; + await MediaTypeService.CreateAsync(mediaType, Constants.Security.SuperUserKey); + + var media = new MediaBuilder() + .WithMediaType(mediaType) + .WithName("Media 1") + .Build(); + MediaService.Save(media); + + var contentCacheNode = new ContentCacheNode + { + Id = media.Id, + Key = media.Key, + ContentTypeId = media.ContentType.Id, + Data = new ContentData( + media.Name, + null, + 0, + media.UpdateDate, + media.WriterId, + null, + false, + new Dictionary(), + null), + }; + var result = PublishedContentFactory.ToIPublishedMedia(contentCacheNode); + Assert.IsNotNull(result); + Assert.AreEqual(media.Id, result.Id); + Assert.AreEqual(media.Name, result.Name); + + // Verify that requesting the same content again returns the same instance (from request cache). + var result2 = PublishedContentFactory.ToIPublishedMedia(contentCacheNode); + Assert.AreSame(result, result2); + } + + [Test] + public async Task Can_Create_Published_Member_For_Member() + { + var memberType = new MemberTypeBuilder().Build(); + await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); + + var member = new MemberBuilder() + .WithMemberType(memberType) + .WithName("Member 1") + .Build(); + MemberService.Save(member); + + var result = PublishedContentFactory.ToPublishedMember(member); + Assert.IsNotNull(result); + Assert.AreEqual(member.Id, result.Id); + Assert.AreEqual(member.Name, result.Name); + + // Verify that requesting the same content again returns the same instance (from request cache). + var result2 = PublishedContentFactory.ToPublishedMember(member); + Assert.AreSame(result, result2); + } +}