diff --git a/src/Umbraco.Core/Persistence/Repositories/ITemplateRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ITemplateRepository.cs index 5c5881ef7a62..1f4f28094bb5 100644 --- a/src/Umbraco.Core/Persistence/Repositories/ITemplateRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/ITemplateRepository.cs @@ -6,6 +6,8 @@ public interface ITemplateRepository : IReadWriteQueryRepository { ITemplate? Get(string? alias); + ITemplate? Get(Guid key) => throw new NotImplementedException(); + IEnumerable GetAll(params string[] aliases); IEnumerable GetChildren(int masterTemplateId); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DataTypeRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DataTypeRepository.cs index c8cc19b0c714..0a3d688335cc 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DataTypeRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DataTypeRepository.cs @@ -30,6 +30,7 @@ internal sealed class DataTypeRepository : EntityRepositoryBase, private readonly PropertyEditorCollection _editors; private readonly IConfigurationEditorJsonSerializer _serializer; private readonly IDataValueEditorFactory _dataValueEditorFactory; + private readonly DataTypeByGuidReadRepository _dataTypeByGuidReadRepository; public DataTypeRepository( IScopeAccessor scopeAccessor, @@ -53,11 +54,34 @@ public DataTypeRepository( _serializer = serializer; _dataValueEditorFactory = dataValueEditorFactory; _dataTypeLogger = loggerFactory.CreateLogger(); + _dataTypeByGuidReadRepository = new DataTypeByGuidReadRepository( + this, + scopeAccessor, + cache, + loggerFactory.CreateLogger(), + repositoryCacheVersionService, + cacheSyncService); } private Guid NodeObjectTypeId => Constants.ObjectTypes.DataType; - public IDataType? Get(Guid key) => GetMany().FirstOrDefault(x => x.Key == key); + public IDataType? Get(Guid key) => _dataTypeByGuidReadRepository.Get(key); + + public override void Save(IDataType entity) + { + base.Save(entity); + + // Also populate the GUID cache so subsequent lookups by GUID don't hit the database. + _dataTypeByGuidReadRepository.PopulateCacheByKey(entity); + } + + public override void Delete(IDataType entity) + { + base.Delete(entity); + + // Also clear the GUID cache so subsequent lookups by GUID don't return stale data. + _dataTypeByGuidReadRepository.ClearCacheByKey(entity.Key); + } public IEnumerable> Move(IDataType toMove, EntityContainer? container) { @@ -224,7 +248,18 @@ public IReadOnlyDictionary> FindListViewUsages(int id) #region Overrides of RepositoryBase - protected override IDataType? PerformGet(int id) => GetMany(id).FirstOrDefault(); + protected override IDataType? PerformGet(int id) + { + IDataType? dataType = GetMany(id).FirstOrDefault(); + + if (dataType != null) + { + // Also populate the GUID cache so subsequent lookups by GUID don't hit the database. + _dataTypeByGuidReadRepository.PopulateCacheByKey(dataType); + } + + return dataType; + } private string? EnsureUniqueNodeName(string? nodeName, int id = 0) { @@ -273,12 +308,17 @@ protected override IEnumerable PerformGetAll(params int[]? ids) } List? dtos = Database.Fetch(dataTypeSql); - return dtos.Select(x => DataTypeFactory.BuildEntity( + IDataType[] dataTypes = dtos.Select(x => DataTypeFactory.BuildEntity( x, _editors, _dataTypeLogger, _serializer, _dataValueEditorFactory)).ToArray(); + + // Also populate the GUID cache so subsequent lookups by GUID don't hit the database. + _dataTypeByGuidReadRepository.PopulateCacheByKey(dataTypes); + + return dataTypes; } protected override IEnumerable PerformGetByQuery(IQuery query) @@ -476,4 +516,162 @@ protected override void PersistDeletedItem(IDataType entity) } #endregion + + #region Read Repository implementation for Guid keys + + /// + /// Populates the int-keyed cache with the given entity. + /// This allows entities retrieved by GUID to also be cached for int ID lookups. + /// + private void PopulateCacheById(IDataType entity) + { + if (entity.HasIdentity) + { + var cacheKey = GetCacheKey(entity.Id); + IsolatedCache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true); + } + } + + /// + /// Populates the int-keyed cache with the given entities. + /// This allows entities retrieved by GUID to also be cached for int ID lookups. + /// + private void PopulateCacheById(IEnumerable entities) + { + foreach (IDataType entity in entities) + { + PopulateCacheById(entity); + } + } + + private static string GetCacheKey(int id) => RepositoryCacheKeys.GetKey() + id; + + // reading repository purely for looking up by GUID + private sealed class DataTypeByGuidReadRepository : EntityRepositoryBase + { + private readonly DataTypeRepository _outerRepo; + + public DataTypeByGuidReadRepository( + DataTypeRepository outerRepo, + IScopeAccessor scopeAccessor, + AppCaches cache, + ILogger logger, + IRepositoryCacheVersionService repositoryCacheVersionService, + ICacheSyncService cacheSyncService) + : base( + scopeAccessor, + cache, + logger, + repositoryCacheVersionService, + cacheSyncService) => + _outerRepo = outerRepo; + + protected override IDataType? PerformGet(Guid id) + { + Sql sql = _outerRepo.GetBaseQuery(false) + .Where(x => x.UniqueId == id); + + DataTypeDto? dto = Database.FirstOrDefault(sql); + + if (dto == null) + { + return null; + } + + IDataType dataType = DataTypeFactory.BuildEntity( + dto, + _outerRepo._editors, + _outerRepo._dataTypeLogger, + _outerRepo._serializer, + _outerRepo._dataValueEditorFactory); + + // Also populate the int-keyed cache so subsequent lookups by int ID don't hit the database + _outerRepo.PopulateCacheById(dataType); + + return dataType; + } + + protected override IEnumerable PerformGetAll(params Guid[]? ids) + { + Sql sql = _outerRepo.GetBaseQuery(false); + if (ids?.Length > 0) + { + sql.WhereIn(x => x.UniqueId, ids); + } + else + { + sql.Where(x => x.NodeObjectType == _outerRepo.NodeObjectTypeId); + } + + List? dtos = Database.Fetch(sql); + IDataType[] dataTypes = dtos.Select(x => DataTypeFactory.BuildEntity( + x, + _outerRepo._editors, + _outerRepo._dataTypeLogger, + _outerRepo._serializer, + _outerRepo._dataValueEditorFactory)).ToArray(); + + // Also populate the int-keyed cache so subsequent lookups by int ID don't hit the database + _outerRepo.PopulateCacheById(dataTypes); + + return dataTypes; + } + + protected override IEnumerable PerformGetByQuery(IQuery query) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override IEnumerable GetDeleteClauses() => + throw new InvalidOperationException("This method won't be implemented."); + + protected override void PersistNewItem(IDataType entity) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override void PersistUpdatedItem(IDataType entity) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override Sql GetBaseQuery(bool isCount) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override string GetBaseWhereClause() => + throw new InvalidOperationException("This method won't be implemented."); + + /// + /// Populates the GUID-keyed cache with the given entity. + /// This allows entities retrieved by int ID to also be cached for GUID lookups. + /// + public void PopulateCacheByKey(IDataType entity) + { + if (entity.HasIdentity) + { + var cacheKey = GetCacheKey(entity.Key); + IsolatedCache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true); + } + } + + /// + /// Populates the GUID-keyed cache with the given entities. + /// This allows entities retrieved by int ID to also be cached for GUID lookups. + /// + public void PopulateCacheByKey(IEnumerable entities) + { + foreach (IDataType entity in entities) + { + PopulateCacheByKey(entity); + } + } + + /// + /// Clears the GUID-keyed cache entry for the given key. + /// This ensures deleted entities are not returned from the cache. + /// + public void ClearCacheByKey(Guid key) + { + var cacheKey = GetCacheKey(key); + IsolatedCache.Clear(cacheKey); + } + + private static string GetCacheKey(Guid key) => RepositoryCacheKeys.GetKey() + key; + } + + #endregion } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TemplateRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TemplateRepository.cs index f88c04827bb1..71fbe9f679ad 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TemplateRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TemplateRepository.cs @@ -28,10 +28,13 @@ internal sealed class TemplateRepository : EntityRepositoryBase, private readonly IFileSystem? _viewsFileSystem; private readonly IViewHelper _viewHelper; private readonly IOptionsMonitor _runtimeSettings; + private readonly TemplateByGuidReadRepository _templateByGuidReadRepository; + public TemplateRepository( IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, + ILoggerFactory loggerFactory, FileSystems fileSystems, IShortStringHelper shortStringHelper, IViewHelper viewHelper, @@ -49,6 +52,60 @@ public TemplateRepository( _viewsFileSystem = fileSystems.MvcViewsFileSystem; _viewHelper = viewHelper; _runtimeSettings = runtimeSettings; + _templateByGuidReadRepository = new TemplateByGuidReadRepository( + this, + scopeAccessor, + cache, + loggerFactory.CreateLogger(), + repositoryCacheVersionService, + cacheSyncService); + } + + [Obsolete("Use constructor with ILoggerFactory parameter. Scheduled for removal in Umbraco 18.")] + public TemplateRepository( + IScopeAccessor scopeAccessor, + AppCaches cache, + ILogger logger, + FileSystems fileSystems, + IShortStringHelper shortStringHelper, + IViewHelper viewHelper, + IOptionsMonitor runtimeSettings, + IRepositoryCacheVersionService repositoryCacheVersionService, + ICacheSyncService cacheSyncService) + : this( + scopeAccessor, + cache, + logger, + Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance, + fileSystems, + shortStringHelper, + viewHelper, + runtimeSettings, + repositoryCacheVersionService, + cacheSyncService) + { + } + + public ITemplate? Get(Guid key) => _templateByGuidReadRepository.Get(key); + + public override void Save(ITemplate entity) + { + base.Save(entity); + + // Force population of the full dataset cache so subsequent lookups don't hit the database. + // TemplateRepository uses FullDataSetRepositoryCachePolicy which caches all templates together. + GetMany(); + + // Also populate the GUID cache so subsequent lookups by GUID don't hit the database. + _templateByGuidReadRepository.PopulateCacheByKey(entity); + } + + public override void Delete(ITemplate entity) + { + base.Delete(entity); + + // Also clear the GUID cache so subsequent lookups by GUID don't return stale data. + _templateByGuidReadRepository.ClearCacheByKey(entity.Key); } public Stream GetFileContentStream(string filepath) @@ -311,9 +368,19 @@ private string EnsureUniqueAlias(ITemplate template, int attempts) #region Overrides of RepositoryBase - protected override ITemplate? PerformGet(int id) => + protected override ITemplate? PerformGet(int id) + { //use the underlying GetAll which will force cache all templates - GetMany().FirstOrDefault(x => x.Id == id); + ITemplate? template = GetMany().FirstOrDefault(x => x.Id == id); + + if (template != null) + { + // Also populate the GUID cache so subsequent lookups by GUID don't hit the database. + _templateByGuidReadRepository.PopulateCacheByKey(template); + } + + return template; + } protected override IEnumerable PerformGetAll(params int[]? ids) { @@ -342,7 +409,12 @@ protected override IEnumerable PerformGetAll(params int[]? ids) : dtos.Select(x => new EntitySlim { Id = x.NodeId, ParentId = x.NodeDto.ParentId, Name = x.Alias })) .ToArray(); - return dtos.Select(d => MapFromDto(d, childIds)); + ITemplate[] templates = dtos.Select(d => MapFromDto(d, childIds)).ToArray(); + + // Also populate the GUID cache so subsequent lookups by GUID don't hit the database. + _templateByGuidReadRepository.PopulateCacheByKey(templates); + + return templates; } protected override IEnumerable PerformGetByQuery(IQuery query) @@ -657,4 +729,106 @@ private static void AddChildren(ITemplate[]? all, List descendants, s } #endregion + + #region Read Repository implementation for Guid keys + + // Reading repository purely for looking up by GUID. + // This leverages the outer repository's GetMany() which uses FullDataSetRepositoryCachePolicy + // to cache all templates together, ensuring efficient lookups by both ID and GUID. + private sealed class TemplateByGuidReadRepository : EntityRepositoryBase + { + private readonly TemplateRepository _outerRepo; + + public TemplateByGuidReadRepository( + TemplateRepository outerRepo, + IScopeAccessor scopeAccessor, + AppCaches cache, + ILogger logger, + IRepositoryCacheVersionService repositoryCacheVersionService, + ICacheSyncService cacheSyncService) + : base( + scopeAccessor, + cache, + logger, + repositoryCacheVersionService, + cacheSyncService) => + _outerRepo = outerRepo; + + protected override ITemplate? PerformGet(Guid id) + { + // Use the outer repository's GetMany() which benefits from FullDataSetRepositoryCachePolicy. + // This ensures all templates are cached together for efficient lookups. + return _outerRepo.GetMany().FirstOrDefault(x => x.Key == id); + } + + protected override IEnumerable PerformGetAll(params Guid[]? ids) + { + // Use the outer repository's GetMany() which benefits from FullDataSetRepositoryCachePolicy. + IEnumerable all = _outerRepo.GetMany(); + + if (ids?.Length > 0) + { + return all.Where(x => ids.Contains(x.Key)).ToArray(); + } + + return all; + } + + protected override IEnumerable PerformGetByQuery(IQuery query) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override IEnumerable GetDeleteClauses() => + throw new InvalidOperationException("This method won't be implemented."); + + protected override void PersistNewItem(ITemplate entity) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override void PersistUpdatedItem(ITemplate entity) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override Sql GetBaseQuery(bool isCount) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override string GetBaseWhereClause() => + throw new InvalidOperationException("This method won't be implemented."); + + /// + /// Populates the GUID-keyed cache with the given entity. + /// This allows entities retrieved by int ID to also be cached for GUID lookups. + /// + public void PopulateCacheByKey(ITemplate entity) + { + if (entity.HasIdentity) + { + var cacheKey = GetCacheKey(entity.Key); + IsolatedCache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true); + } + } + + /// + /// Populates the GUID-keyed cache with the given entities. + /// This allows entities retrieved by int ID to also be cached for GUID lookups. + /// + public void PopulateCacheByKey(IEnumerable entities) + { + foreach (ITemplate entity in entities) + { + PopulateCacheByKey(entity); + } + } + + /// + /// Clears the GUID-keyed cache entry for the given key. + /// This ensures deleted entities are not returned from the cache. + /// + public void ClearCacheByKey(Guid key) + { + var cacheKey = GetCacheKey(key); + IsolatedCache.Clear(cacheKey); + } + + private static string GetCacheKey(Guid key) => RepositoryCacheKeys.GetKey() + key; + } + + #endregion } diff --git a/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs index 071e4290d1ee..1561e1558e60 100644 --- a/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs @@ -136,7 +136,7 @@ public override DataType Build() var name = _name ?? Guid.NewGuid().ToString(); var level = _level ?? 0; var path = _path ?? $"-1,{id}"; - var creatorId = _creatorId ?? 1; + var creatorId = _creatorId ?? Constants.Security.SuperUserId; var databaseType = _databaseType ?? ValueStorageType.Ntext; var sortOrder = _sortOrder ?? 0; var serializer = new SystemTextConfigurationEditorJsonSerializer(new DefaultJsonSerializerEncoderFactory()); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ContentTypeRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ContentTypeRepositoryTest.cs index 0dc81e346850..997961f4f417 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ContentTypeRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ContentTypeRepositoryTest.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; -using Umbraco.Cms.Api.Management.Mapping.Permissions; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; @@ -39,8 +38,6 @@ internal sealed class ContentTypeRepositoryTest : UmbracoIntegrationTest private FileSystems FileSystems => GetRequiredService(); - private IUmbracoMapper Mapper => GetRequiredService(); - private IContentTypeService ContentTypeService => GetRequiredService(); private IDocumentTypeContainerRepository DocumentTypeContainerRepository => @@ -57,6 +54,10 @@ internal sealed class ContentTypeRepositoryTest : UmbracoIntegrationTest private IUserGroupRepository UserGroupRepository => GetRequiredService(); + private ITemplateRepository TemplateRepository => GetRequiredService(); + + private ILanguageRepository LanguageRepository => GetRequiredService(); + private ContentTypeRepository ContentTypeRepository => (ContentTypeRepository)GetRequiredService(); @@ -75,6 +76,171 @@ public void CreateTestData() // TODO: Add test to verify SetDefaultTemplates updates both AllowedTemplates and DefaultTemplate(id). + [Test] + public void Retrieval_By_Id_After_Retrieval_By_Id_Is_Cached() + { + var realCache = new AppCaches( + new ObjectCacheAppCache(), + new DictionaryAppCache(), + new IsolatedCaches(t => new ObjectCacheAppCache())); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + var contentType = _simpleContentType; + database.EnableSqlCount = true; + + // Clear the isolated cache for IContentType so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by Id should hit the database. + repository.Get(contentType.Id); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache. + repository.Get(contentType.Id); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Key_After_Retrieval_By_Key_Is_Cached() + { + var realCache = new AppCaches( + new ObjectCacheAppCache(), + new DictionaryAppCache(), + new IsolatedCaches(t => new ObjectCacheAppCache())); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + var contentType = _simpleContentType; + database.EnableSqlCount = true; + + // Clear the isolated cache for IContentType so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by key should hit the database. + repository.Get(contentType.Key); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache. + repository.Get(contentType.Key); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Key_After_Retrieval_By_Id_Is_Cached() + { + var realCache = new AppCaches( + new ObjectCacheAppCache(), + new DictionaryAppCache(), + new IsolatedCaches(t => new ObjectCacheAppCache())); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + var contentType = _simpleContentType; + database.EnableSqlCount = true; + + // Clear the isolated cache for IContentType so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by ID should hit the database. + repository.Get(contentType.Id); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache, since the cache by Id and Key was populated on retrieval. + repository.Get(contentType.Id); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(contentType.Key); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Id_After_Retrieval_By_Key_Is_Cached() + { + var realCache = new AppCaches( + new ObjectCacheAppCache(), + new DictionaryAppCache(), + new IsolatedCaches(t => new ObjectCacheAppCache())); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + var contentType = _simpleContentType; + database.EnableSqlCount = true; + + // Clear the isolated cache for IContentType so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by key should hit the database. + repository.Get(contentType.Key); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache, since the cache by Id and Key was populated on retrieval. + repository.Get(contentType.Key); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(contentType.Id); + Assert.AreEqual(0, database.SqlCount); + } + + private ContentTypeRepository CreateRepository(IScopeAccessor scopeAccessor, AppCaches? appCaches = null) + { + appCaches ??= AppCaches; + + var commonRepository = + new ContentTypeCommonRepository(scopeAccessor, TemplateRepository, appCaches, ShortStringHelper); + + return new ContentTypeRepository( + scopeAccessor, + appCaches, + LoggerFactory.CreateLogger(), + commonRepository, + LanguageRepository, + ShortStringHelper, + Mock.Of(), + IdKeyMap, + Mock.Of()); + } + [Test] public void Maps_Templates_Correctly() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeRepositoryTest.cs new file mode 100644 index 000000000000..f3e82e7b084a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeRepositoryTest.cs @@ -0,0 +1,266 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Scoping; +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.Infrastructure.Persistence.Repositories; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class DataTypeRepositoryTest : UmbracoIntegrationTest +{ + private IConfigurationEditorJsonSerializer ConfigurationEditorJsonSerializer => + GetRequiredService(); + + private IDataValueEditorFactory DataValueEditorFactory => + GetRequiredService(); + + [Test] + public void Retrieval_By_Id_After_Retrieval_By_Id_Is_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var dataType = CreateDataType(repository); + + database.EnableSqlCount = true; + + // Clear the isolated cache for IDataType so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by Id should hit the database. + repository.Get(dataType.Id); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache. + repository.Get(dataType.Id); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Key_After_Retrieval_By_Key_Is_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var dataType = CreateDataType(repository); + + database.EnableSqlCount = true; + + // Clear the isolated cache for IDataType so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by key should hit the database. + repository.Get(dataType.Key); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache. + repository.Get(dataType.Key); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrievals_By_Id_And_Key_After_Save_Are_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var dataType = CreateDataType(repository); + + database.EnableSqlCount = true; + + // Initial and subsequent requests should use the cache, since the cache by Id and Key was populated on save. + repository.Get(dataType.Id); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(dataType.Id); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(dataType.Key); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(dataType.Key); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Key_After_Retrieval_By_Id_Is_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var dataType = CreateDataType(repository); + + database.EnableSqlCount = true; + + // Clear the isolated cache for IDataType so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by ID should hit the database. + repository.Get(dataType.Id); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache, since the cache by Id and Key was populated on retrieval. + repository.Get(dataType.Id); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(dataType.Key); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Id_After_Retrieval_By_Key_Is_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var dataType = CreateDataType(repository); + + database.EnableSqlCount = true; + + // Clear the isolated cache for IDataType so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by key should hit the database. + repository.Get(dataType.Key); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache, since the cache by Id and Key was populated on retrieval. + repository.Get(dataType.Key); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(dataType.Id); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Id_After_Deletion_Returns_Null() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + + using var scope = provider.CreateScope(); + var repository = CreateRepository((IScopeAccessor)provider, realCache); + + var dataType = CreateDataType(repository); + var retrievedDataType = repository.Get(dataType.Key); + Assert.IsNotNull(retrievedDataType); + + repository.Delete(dataType); + + retrievedDataType = repository.Get(dataType.Key); + Assert.IsNull(retrievedDataType); + } + + private static AppCaches CreateAppCaches() => + new( + new ObjectCacheAppCache(), + new DictionaryAppCache(), + new IsolatedCaches(t => new ObjectCacheAppCache())); + + private DataTypeRepository CreateRepository(IScopeAccessor scopeAccessor, AppCaches? appCaches = null) + { + appCaches ??= AppCaches; + + var editors = new PropertyEditorCollection(new DataEditorCollection(() => Enumerable.Empty())); + return new DataTypeRepository( + scopeAccessor, + appCaches, + editors, + LoggerFactory.CreateLogger(), + LoggerFactory, + ConfigurationEditorJsonSerializer, + Mock.Of(), + Mock.Of(), + DataValueEditorFactory); + } + + private static DataType CreateDataType(DataTypeRepository repository) + { + var dataTypeBuilder = new DataTypeBuilder(); + var dataType = dataTypeBuilder + .WithId(0) + .WithName("Test Data Type") + .AddEditor() + .WithAlias(Cms.Core.Constants.PropertyEditors.Aliases.TextBox) + .Done() + .Build(); + + repository.Save(dataType); + return dataType; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs index de1d2bf3af05..74373d9fd7cc 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs @@ -1,9 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -24,10 +21,10 @@ using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Cms.Infrastructure.Serialization; using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Implementations; using Umbraco.Cms.Tests.Integration.Testing; -using Umbraco.Extensions; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Persistence.Repositories; @@ -62,8 +59,8 @@ public void TearDown() private IOptionsMonitor RuntimeSettings => GetRequiredService>(); - private ITemplateRepository CreateRepository(IScopeProvider provider) => - new TemplateRepository((IScopeAccessor)provider, AppCaches.Disabled, LoggerFactory.CreateLogger(), FileSystems, ShortStringHelper, ViewHelper, RuntimeSettings, Mock.Of(), Mock.Of()); + private ITemplateRepository CreateRepository(IScopeProvider provider, AppCaches? appCaches = null) => + new TemplateRepository((IScopeAccessor)provider, appCaches ?? AppCaches.Disabled, LoggerFactory.CreateLogger(), LoggerFactory, FileSystems, ShortStringHelper, ViewHelper, RuntimeSettings, Mock.Of(), Mock.Of()); [Test] public void Can_Instantiate_Repository() @@ -80,6 +77,224 @@ public void Can_Instantiate_Repository() } } + [Test] + public void Retrieval_By_Id_After_Retrieval_By_Id_Is_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var template = CreateTemplate(repository); + + database.EnableSqlCount = true; + + // Clear the isolated cache for ITemplate so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by Id should hit the database. + repository.Get(template.Id); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache. + repository.Get(template.Id); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Key_After_Retrieval_By_Key_Is_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var template = CreateTemplate(repository); + + database.EnableSqlCount = true; + + // Clear the isolated cache for ITemplate so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by key should hit the database. + repository.Get(template.Key); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache. + repository.Get(template.Key); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrievals_By_Id_And_Key_After_Save_Are_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var template = CreateTemplate(repository); + + database.EnableSqlCount = true; + + // Initial and subsequent requests should use the cache, since the cache by Id and Key was populated on save. + repository.Get(template.Id); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(template.Id); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(template.Key); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(template.Key); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Key_After_Retrieval_By_Id_Is_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var template = CreateTemplate(repository); + + database.EnableSqlCount = true; + + // Clear the isolated cache for ITemplate so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by ID should hit the database. + repository.Get(template.Id); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache, since the cache by Id and Key was populated on retrieval. + repository.Get(template.Id); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(template.Key); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Id_After_Retrieval_By_Key_Is_Cached() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider, realCache); + + var database = scopeAccessor.AmbientScope.Database; + + database.EnableSqlCount = false; + + var template = CreateTemplate(repository); + + database.EnableSqlCount = true; + + // Clear the isolated cache for ITemplate so the next retrieval hits the database + realCache.IsolatedCaches.ClearCache(); + + // Initial request by key should hit the database. + repository.Get(template.Key); + Assert.Greater(database.SqlCount, 0); + + // Reset counter. + database.EnableSqlCount = false; + database.EnableSqlCount = true; + + // Subsequent requests should use the cache, since the cache by Id and Key was populated on retrieval. + repository.Get(template.Key); + Assert.AreEqual(0, database.SqlCount); + + repository.Get(template.Id); + Assert.AreEqual(0, database.SqlCount); + } + + [Test] + public void Retrieval_By_Id_After_Deletion_Returns_Null() + { + var realCache = CreateAppCaches(); + + var provider = ScopeProvider; + + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider, realCache); + + var template = CreateTemplate(repository); + var retrievedTemplate = repository.Get(template.Key); + Assert.IsNotNull(retrievedTemplate); + + repository.Delete(template); + + retrievedTemplate = repository.Get(template.Key); + Assert.IsNull(retrievedTemplate); + } + + private static AppCaches CreateAppCaches() => + new( + new ObjectCacheAppCache(), + new DictionaryAppCache(), + new IsolatedCaches(t => new ObjectCacheAppCache())); + + private static ITemplate CreateTemplate(ITemplateRepository repository) + { + var templateBuilder = new TemplateBuilder(); + var template = templateBuilder + .WithId(0) + .WithAlias("testTemplate") + .WithName("Test Template") + .Build(); + + repository.Save(template); + return template; + } + [Test] public void Can_Perform_Add_View() {