From cf8394b6fdde6bcf8d69562b9a370aa687c8c8c1 Mon Sep 17 00:00:00 2001 From: yv01p Date: Mon, 22 Dec 2025 23:03:36 +0000 Subject: [PATCH] feat(core): add ContentQueryOperationService implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements IContentQueryOperationService with Count, GetByLevel, and GetPagedOfType operations. Follows Phase 1 patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/ContentQueryOperationService.cs | 169 ++++++++++++++ .../ContentQueryOperationServiceTests.cs | 212 ++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 src/Umbraco.Core/Services/ContentQueryOperationService.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentQueryOperationServiceTests.cs diff --git a/src/Umbraco.Core/Services/ContentQueryOperationService.cs b/src/Umbraco.Core/Services/ContentQueryOperationService.cs new file mode 100644 index 0000000000..6b66e65121 --- /dev/null +++ b/src/Umbraco.Core/Services/ContentQueryOperationService.cs @@ -0,0 +1,169 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Implements content query operations (counting, filtering by type/level). +/// +public class ContentQueryOperationService : ContentServiceBase, IContentQueryOperationService +{ + /// + /// Default ordering for paged queries. + /// + private static readonly Ordering DefaultSortOrdering = Ordering.By("sortOrder"); + + /// + /// Logger for this service (for debugging, performance monitoring, or error tracking). + /// + private readonly ILogger _logger; + + public ContentQueryOperationService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IDocumentRepository documentRepository, + IAuditService auditService, + IUserIdKeyResolver userIdKeyResolver) + : base(provider, loggerFactory, eventMessagesFactory, documentRepository, auditService, userIdKeyResolver) + { + _logger = loggerFactory.CreateLogger(); + } + + #region Count Operations + + /// + public int Count(string? contentTypeAlias = null) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + scope.ReadLock(Constants.Locks.ContentTree); + return DocumentRepository.Count(contentTypeAlias); + } + + /// + public int CountPublished(string? contentTypeAlias = null) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + scope.ReadLock(Constants.Locks.ContentTree); + return DocumentRepository.CountPublished(contentTypeAlias); + } + + /// + public int CountChildren(int parentId, string? contentTypeAlias = null) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + scope.ReadLock(Constants.Locks.ContentTree); + return DocumentRepository.CountChildren(parentId, contentTypeAlias); + } + + /// + public int CountDescendants(int parentId, string? contentTypeAlias = null) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + scope.ReadLock(Constants.Locks.ContentTree); + return DocumentRepository.CountDescendants(parentId, contentTypeAlias); + } + + #endregion + + #region Hierarchy Queries + + /// + /// + /// The returned enumerable may be lazily evaluated. Callers should materialize + /// results (e.g., call ToList()) if they need to access them after the scope is disposed. + /// This is consistent with the existing ContentService.GetByLevel implementation. + /// + public IEnumerable GetByLevel(int level) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + scope.ReadLock(Constants.Locks.ContentTree); + IQuery? query = Query().Where(x => x.Level == level && x.Trashed == false); + return DocumentRepository.Get(query); + } + + #endregion + + #region Paged Type Queries + + /// + public IEnumerable GetPagedOfType( + int contentTypeId, + long pageIndex, + int pageSize, + out long totalRecords, + IQuery? filter = null, + Ordering? ordering = null) + { + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + ordering ??= DefaultSortOrdering; + + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + scope.ReadLock(Constants.Locks.ContentTree); + + // Note: filter=null is valid and means no additional filtering beyond the content type + return DocumentRepository.GetPage( + Query()?.Where(x => x.ContentTypeId == contentTypeId), + pageIndex, + pageSize, + out totalRecords, + filter, + ordering); + } + + /// + public IEnumerable GetPagedOfTypes( + int[] contentTypeIds, + long pageIndex, + int pageSize, + out long totalRecords, + IQuery? filter = null, + Ordering? ordering = null) + { + ArgumentNullException.ThrowIfNull(contentTypeIds); + + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + ordering ??= DefaultSortOrdering; + + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + // Expression trees require a List for Contains() - array not supported. + // This O(n) copy is unavoidable but contentTypeIds is typically small. + List contentTypeIdsAsList = [.. contentTypeIds]; + + scope.ReadLock(Constants.Locks.ContentTree); + + // Note: filter=null is valid and means no additional filtering beyond the content types + return DocumentRepository.GetPage( + Query()?.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId)), + pageIndex, + pageSize, + out totalRecords, + filter, + ordering); + } + + #endregion +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentQueryOperationServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentQueryOperationServiceTests.cs new file mode 100644 index 0000000000..4367595bb7 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentQueryOperationServiceTests.cs @@ -0,0 +1,212 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +/// +/// Integration tests for ContentQueryOperationService. +/// +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + WithApplication = true)] +public class ContentQueryOperationServiceTests : UmbracoIntegrationTestWithContent +{ + private IContentQueryOperationService QueryService => GetRequiredService(); + + [Test] + public void Count_WithNoFilter_ReturnsAllContentCount() + { + // Arrange - base class creates Textpage, Subpage, Subpage2, Subpage3, Trashed + + // Act + var count = QueryService.Count(); + + // Assert - should return 5 (all items including Trashed) + Assert.That(count, Is.EqualTo(5)); + } + + [Test] + public void Count_WithNonExistentContentTypeAlias_ReturnsZero() + { + // Arrange + var nonExistentAlias = "nonexistent-content-type-alias"; + + // Act + var count = QueryService.Count(nonExistentAlias); + + // Assert + Assert.That(count, Is.EqualTo(0)); + } + + [Test] + public void Count_WithContentTypeAlias_ReturnsFilteredCount() + { + // Arrange + var alias = ContentType.Alias; + + // Act + var count = QueryService.Count(alias); + + // Assert - all 5 content items use the same content type + Assert.That(count, Is.EqualTo(5)); + } + + [Test] + public void CountChildren_ReturnsChildCount() + { + // Arrange - Textpage has children: Subpage, Subpage2, Subpage3 + var parentId = Textpage.Id; + + // Act + var count = QueryService.CountChildren(parentId); + + // Assert + Assert.That(count, Is.EqualTo(3)); + } + + [Test] + public void GetByLevel_ReturnsContentAtLevel() + { + // Arrange - level 1 is root content + + // Act + var items = QueryService.GetByLevel(1); + + // Assert + Assert.That(items, Is.Not.Null); + Assert.That(items.All(x => x.Level == 1), Is.True); + } + + [Test] + public void GetPagedOfType_ReturnsPaginatedResults() + { + // Arrange + var contentTypeId = ContentType.Id; + + // Act + var items = QueryService.GetPagedOfType(contentTypeId, 0, 10, out var total); + + // Assert + Assert.That(items, Is.Not.Null); + Assert.That(total, Is.EqualTo(5)); // All 5 content items are of this type + } + + [Test] + public void GetPagedOfTypes_WithEmptyArray_ReturnsEmpty() + { + // Act + var items = QueryService.GetPagedOfTypes(Array.Empty(), 0, 10, out var total); + + // Assert + Assert.That(items, Is.Empty); + Assert.That(total, Is.EqualTo(0)); + } + + [Test] + public void GetPagedOfTypes_WithNonExistentContentTypeIds_ReturnsEmpty() + { + // Arrange + var nonExistentIds = new[] { 999999, 999998 }; + + // Act + var items = QueryService.GetPagedOfTypes(nonExistentIds, 0, 10, out var total); + + // Assert + Assert.That(items, Is.Empty); + Assert.That(total, Is.EqualTo(0)); + } + + [Test] + public void CountChildren_WithNonExistentParentId_ReturnsZero() + { + // Arrange + var nonExistentParentId = 999999; + + // Act + var count = QueryService.CountChildren(nonExistentParentId); + + // Assert + Assert.That(count, Is.EqualTo(0)); + } + + [Test] + public void GetByLevel_WithLevelZero_ReturnsEmpty() + { + // Arrange - level 0 doesn't exist (content starts at level 1) + + // Act + var items = QueryService.GetByLevel(0); + + // Assert + Assert.That(items, Is.Empty); + } + + [Test] + public void GetByLevel_WithNegativeLevel_ReturnsEmpty() + { + // Arrange + + // Act + var items = QueryService.GetByLevel(-1); + + // Assert + Assert.That(items, Is.Empty); + } + + [Test] + public void GetPagedOfType_WithNonExistentContentTypeId_ReturnsEmpty() + { + // Arrange + var nonExistentId = 999999; + + // Act + var items = QueryService.GetPagedOfType(nonExistentId, 0, 10, out var total); + + // Assert + Assert.That(items, Is.Empty); + Assert.That(total, Is.EqualTo(0)); + } + + [Test] + public void CountDescendants_ReturnsDescendantCount() + { + // Arrange - Textpage has descendants: Subpage, Subpage2, Subpage3 + var ancestorId = Textpage.Id; + + // Act + var count = QueryService.CountDescendants(ancestorId); + + // Assert + Assert.That(count, Is.EqualTo(3)); + } + + [Test] + public void CountDescendants_WithNonExistentAncestorId_ReturnsZero() + { + // Arrange + var nonExistentId = 999999; + + // Act + var count = QueryService.CountDescendants(nonExistentId); + + // Assert + Assert.That(count, Is.EqualTo(0)); + } + + [Test] + public void CountPublished_WithNoPublishedContent_ReturnsZero() + { + // Arrange - base class creates content but doesn't publish + + // Act + var count = QueryService.CountPublished(); + + // Assert + Assert.That(count, Is.EqualTo(0)); + } +}