9 Commits

Author SHA1 Message Date
4bb1b24f92 docs: mark Phase 2 complete in design document
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 00:12:08 +00:00
1bc741b470 refactor(core): delegate GetPagedOfType/s to QueryOperationService
ContentService now delegates all paged type queries to the new
QueryOperationService, completing Phase 2 delegation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 00:03:12 +00:00
dc44bebfcc refactor(core): delegate GetByLevel to QueryOperationService
ContentService.GetByLevel now delegates to QueryOperationService,
continuing Phase 2 query operation extraction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 23:59:40 +00:00
fb20c480e3 refactor(core): delegate Count methods to QueryOperationService
ContentService now delegates Count, CountPublished, CountChildren,
CountDescendants to the new QueryOperationService.

This is Task 5 of Phase 2 - the first delegation task that converts
ContentService from direct repository calls to delegating to the
specialized query operation service.

All tests pass. Baseline tests confirm facade and direct service
return identical results. Full ContentService test suite passes
(excluding pre-existing benchmark regressions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 23:46:59 +00:00
ff4bdb2509 refactor(core): add QueryOperationService to ContentService facade
Injects IContentQueryOperationService for future delegation.
Includes lazy resolution support for obsolete constructors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 23:20:35 +00:00
31dfe07aa7 feat(core): register IContentQueryOperationService in DI container
Adds unique registration for ContentQueryOperationService matching
the Phase 1 pattern for IContentCrudService. Also updates the
ContentService factory to inject the new service as the 19th parameter.

Note: Build will fail until Task 4 adds the 19-parameter constructor
to ContentService.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 23:10:09 +00:00
cf8394b6fd feat(core): add ContentQueryOperationService implementation
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 <noreply@anthropic.com>
2025-12-22 23:03:36 +00:00
36d1fcc8ac feat(core): add IContentQueryOperationService interface for Phase 2
Extracts query operations (Count, GetByLevel, GetPagedOfType/s) into
focused interface following Phase 1 patterns.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 22:51:41 +00:00
d78238b247 docs: mark Phase 1 complete in design document
Phase 1 (CRUD Service) successfully implemented:
- ContentServiceBase abstract class created
- IContentCrudService interface defined (21 methods)
- ContentCrudService implementation complete (~750 lines)
- ContentService facade updated to delegate (reduced from 3823 to 3497 lines)
- Benchmark regression enforcement with 20% threshold
- All 16 integration tests + 8 unit tests passing
- Git tag: phase-1-crud-extraction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-21 03:51:01 +00:00
8 changed files with 725 additions and 105 deletions

View File

@@ -14,6 +14,7 @@
| 1.3 | Added detailed test strategy (15 tests for coverage gaps) | | 1.3 | Added detailed test strategy (15 tests for coverage gaps) |
| 1.4 | Added phase gates with test execution commands and regression protocol | | 1.4 | Added phase gates with test execution commands and regression protocol |
| 1.5 | Added performance benchmarks (33 tests for baseline comparison) | | 1.5 | Added performance benchmarks (33 tests for baseline comparison) |
| 1.6 | Phase 2 complete - QueryOperationService extracted |
## Overview ## Overview
@@ -386,22 +387,29 @@ public class ContentServicesComposer : IComposer
Each phase MUST run tests before and after to verify no regressions. Each phase MUST run tests before and after to verify no regressions.
| Phase | Service | Tests to Run | Gate | | Phase | Service | Tests to Run | Gate | Status |
|-------|---------|--------------|------| |-------|---------|--------------|------|--------|
| 0 | Write tests | `ContentServiceRefactoringTests` | All 15 pass | | 0 | Write tests | `ContentServiceRefactoringTests` | All 15 pass | ✅ Complete |
| 1 | CRUD Service | All ContentService*Tests | All pass | | 1 | CRUD Service | All ContentService*Tests | All pass | ✅ Complete |
| 2 | Query Service | All ContentService*Tests | All pass | | 2 | Query Service | All ContentService*Tests | All pass | ✅ Complete |
| 3 | Version Service | All ContentService*Tests | All pass | | 3 | Version Service | All ContentService*Tests | All pass | Pending |
| 4 | Move Service | All ContentService*Tests + Sort/MoveToRecycleBin tests | All pass | | 4 | Move Service | All ContentService*Tests + Sort/MoveToRecycleBin tests | All pass | Pending |
| 5 | Publish Operation Service | All ContentService*Tests + Notification ordering tests | All pass | | 5 | Publish Operation Service | All ContentService*Tests + Notification ordering tests | All pass | Pending |
| 6 | Permission Manager | All ContentService*Tests + Permission tests | All pass | | 6 | Permission Manager | All ContentService*Tests + Permission tests | All pass | Pending |
| 7 | Blueprint Manager | All ContentService*Tests | All pass | | 7 | Blueprint Manager | All ContentService*Tests | All pass | Pending |
| 8 | Facade | **Full test suite** | All pass | | 8 | Facade | **Full test suite** | All pass | Pending |
### Phase Details ### Phase Details
1. **Phase 0: Write Tests** - Create `ContentServiceRefactoringTests.cs` with all 15 tests 1. **Phase 0: Write Tests** - Created `ContentServiceRefactoringTests.cs` with 16 tests (15 original + 1 DI test)
2. **Phase 1: CRUD Service** - Establish patterns, base class 2. **Phase 1: CRUD Service** ✅ - Complete! Created:
- `ContentServiceBase.cs` - Abstract base class with shared infrastructure
- `ContentServiceConstants.cs` - Shared constants
- `IContentCrudService.cs` - Interface (21 methods)
- `ContentCrudService.cs` - Implementation (~750 lines)
- Updated `ContentService.cs` to delegate CRUD operations (reduced from 3823 to 3497 lines)
- Benchmark regression enforcement (20% threshold, CI-configurable)
- Git tag: `phase-1-crud-extraction`
3. **Phase 2: Query Service** - Read-only operations, low risk 3. **Phase 2: Query Service** - Read-only operations, low risk
4. **Phase 3: Version Service** - Straightforward extraction 4. **Phase 3: Version Service** - Straightforward extraction
5. **Phase 4: Move Service** - Depends on CRUD; Sort and MoveToRecycleBin tests critical 5. **Phase 4: Move Service** - Depends on CRUD; Sort and MoveToRecycleBin tests critical

View File

@@ -299,6 +299,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IContentPermissionService, ContentPermissionService>(); Services.AddUnique<IContentPermissionService, ContentPermissionService>();
Services.AddUnique<IDictionaryPermissionService, DictionaryPermissionService>(); Services.AddUnique<IDictionaryPermissionService, DictionaryPermissionService>();
Services.AddUnique<IContentCrudService, ContentCrudService>(); Services.AddUnique<IContentCrudService, ContentCrudService>();
Services.AddUnique<IContentQueryOperationService, ContentQueryOperationService>();
Services.AddUnique<IContentService>(sp => Services.AddUnique<IContentService>(sp =>
new ContentService( new ContentService(
sp.GetRequiredService<ICoreScopeProvider>(), sp.GetRequiredService<ICoreScopeProvider>(),
@@ -318,7 +319,8 @@ namespace Umbraco.Cms.Core.DependencyInjection
sp.GetRequiredService<IIdKeyMap>(), sp.GetRequiredService<IIdKeyMap>(),
sp.GetRequiredService<IOptionsMonitor<ContentSettings>>(), sp.GetRequiredService<IOptionsMonitor<ContentSettings>>(),
sp.GetRequiredService<IRelationService>(), sp.GetRequiredService<IRelationService>(),
sp.GetRequiredService<IContentCrudService>())); sp.GetRequiredService<IContentCrudService>(),
sp.GetRequiredService<IContentQueryOperationService>()));
Services.AddUnique<IContentBlueprintEditingService, ContentBlueprintEditingService>(); Services.AddUnique<IContentBlueprintEditingService, ContentBlueprintEditingService>();
Services.AddUnique<IContentEditingService, ContentEditingService>(); Services.AddUnique<IContentEditingService, ContentEditingService>();
Services.AddUnique<IContentPublishingService, ContentPublishingService>(); Services.AddUnique<IContentPublishingService, ContentPublishingService>();

View File

@@ -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;
/// <summary>
/// Implements content query operations (counting, filtering by type/level).
/// </summary>
public class ContentQueryOperationService : ContentServiceBase, IContentQueryOperationService
{
/// <summary>
/// Default ordering for paged queries.
/// </summary>
private static readonly Ordering DefaultSortOrdering = Ordering.By("sortOrder");
/// <summary>
/// Logger for this service (for debugging, performance monitoring, or error tracking).
/// </summary>
private readonly ILogger<ContentQueryOperationService> _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<ContentQueryOperationService>();
}
#region Count Operations
/// <inheritdoc />
public int Count(string? contentTypeAlias = null)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
scope.ReadLock(Constants.Locks.ContentTree);
return DocumentRepository.Count(contentTypeAlias);
}
/// <inheritdoc />
public int CountPublished(string? contentTypeAlias = null)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
scope.ReadLock(Constants.Locks.ContentTree);
return DocumentRepository.CountPublished(contentTypeAlias);
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
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
/// <inheritdoc />
/// <remarks>
/// 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.
/// </remarks>
public IEnumerable<IContent> GetByLevel(int level)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
scope.ReadLock(Constants.Locks.ContentTree);
IQuery<IContent>? query = Query<IContent>().Where(x => x.Level == level && x.Trashed == false);
return DocumentRepository.Get(query);
}
#endregion
#region Paged Type Queries
/// <inheritdoc />
public IEnumerable<IContent> GetPagedOfType(
int contentTypeId,
long pageIndex,
int pageSize,
out long totalRecords,
IQuery<IContent>? 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<IContent>()?.Where(x => x.ContentTypeId == contentTypeId),
pageIndex,
pageSize,
out totalRecords,
filter,
ordering);
}
/// <inheritdoc />
public IEnumerable<IContent> GetPagedOfTypes(
int[] contentTypeIds,
long pageIndex,
int pageSize,
out long totalRecords,
IQuery<IContent>? 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<int> 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<IContent>()?.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId)),
pageIndex,
pageSize,
out totalRecords,
filter,
ordering);
}
#endregion
}

View File

@@ -51,6 +51,18 @@ public class ContentService : RepositoryService, IContentService
// Property for convenient access (deferred resolution for both paths) // Property for convenient access (deferred resolution for both paths)
private IContentCrudService CrudService => _crudServiceLazy.Value; private IContentCrudService CrudService => _crudServiceLazy.Value;
// Query operation service fields (for Phase 2 extracted query operations)
private readonly IContentQueryOperationService? _queryOperationService;
private readonly Lazy<IContentQueryOperationService>? _queryOperationServiceLazy;
/// <summary>
/// Gets the query operation service.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the service was not properly initialized.</exception>
private IContentQueryOperationService QueryOperationService =>
_queryOperationService ?? _queryOperationServiceLazy?.Value
?? throw new InvalidOperationException("QueryOperationService not initialized. Ensure the service is properly injected via constructor.");
#region Constructors #region Constructors
[Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor] [Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor]
@@ -72,7 +84,8 @@ public class ContentService : RepositoryService, IContentService
IIdKeyMap idKeyMap, IIdKeyMap idKeyMap,
IOptionsMonitor<ContentSettings> optionsMonitor, IOptionsMonitor<ContentSettings> optionsMonitor,
IRelationService relationService, IRelationService relationService,
IContentCrudService crudService) // NEW PARAMETER - direct injection IContentCrudService crudService,
IContentQueryOperationService queryOperationService) // NEW PARAMETER - Phase 2 query operations
: base(provider, loggerFactory, eventMessagesFactory) : base(provider, loggerFactory, eventMessagesFactory)
{ {
_documentRepository = documentRepository; _documentRepository = documentRepository;
@@ -97,6 +110,11 @@ public class ContentService : RepositoryService, IContentService
ArgumentNullException.ThrowIfNull(crudService); ArgumentNullException.ThrowIfNull(crudService);
// Wrap in Lazy for consistent access pattern (already resolved, so returns immediately) // Wrap in Lazy for consistent access pattern (already resolved, so returns immediately)
_crudServiceLazy = new Lazy<IContentCrudService>(() => crudService); _crudServiceLazy = new Lazy<IContentCrudService>(() => crudService);
// Phase 2: Query operation service (direct injection)
ArgumentNullException.ThrowIfNull(queryOperationService);
_queryOperationService = queryOperationService;
_queryOperationServiceLazy = null; // Not needed when directly injected
} }
[Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")] [Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")]
@@ -148,6 +166,11 @@ public class ContentService : RepositoryService, IContentService
_crudServiceLazy = new Lazy<IContentCrudService>(() => _crudServiceLazy = new Lazy<IContentCrudService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentCrudService>(), StaticServiceProvider.Instance.GetRequiredService<IContentCrudService>(),
LazyThreadSafetyMode.ExecutionAndPublication); LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 2: Lazy resolution of IContentQueryOperationService
_queryOperationServiceLazy = new Lazy<IContentQueryOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentQueryOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
} }
[Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")] [Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")]
@@ -198,6 +221,11 @@ public class ContentService : RepositoryService, IContentService
_crudServiceLazy = new Lazy<IContentCrudService>(() => _crudServiceLazy = new Lazy<IContentCrudService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentCrudService>(), StaticServiceProvider.Instance.GetRequiredService<IContentCrudService>(),
LazyThreadSafetyMode.ExecutionAndPublication); LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 2: Lazy resolution of IContentQueryOperationService
_queryOperationServiceLazy = new Lazy<IContentQueryOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentQueryOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
} }
#endregion #endregion
@@ -274,40 +302,16 @@ public class ContentService : RepositoryService, IContentService
#region Count #region Count
public int CountPublished(string? contentTypeAlias = null) public int CountPublished(string? contentTypeAlias = null)
{ => QueryOperationService.CountPublished(contentTypeAlias);
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.CountPublished(contentTypeAlias);
}
}
public int Count(string? contentTypeAlias = null) public int Count(string? contentTypeAlias = null)
{ => QueryOperationService.Count(contentTypeAlias);
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.Count(contentTypeAlias);
}
}
public int CountChildren(int parentId, string? contentTypeAlias = null) public int CountChildren(int parentId, string? contentTypeAlias = null)
{ => QueryOperationService.CountChildren(parentId, contentTypeAlias);
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) public int CountDescendants(int parentId, string? contentTypeAlias = null)
{ => QueryOperationService.CountDescendants(parentId, contentTypeAlias);
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.CountDescendants(parentId, contentTypeAlias);
}
}
#endregion #endregion
@@ -545,63 +549,11 @@ public class ContentService : RepositoryService, IContentService
out long totalRecords, out long totalRecords,
IQuery<IContent>? filter = null, IQuery<IContent>? filter = null,
Ordering? ordering = null) Ordering? ordering = null)
{ => QueryOperationService.GetPagedOfType(contentTypeId, pageIndex, pageSize, out totalRecords, filter, ordering);
if (pageIndex < 0)
{
throw new ArgumentOutOfRangeException(nameof(pageIndex));
}
if (pageSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
ordering ??= Ordering.By("sortOrder");
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetPage(
Query<IContent>()?.Where(x => x.ContentTypeId == contentTypeId),
pageIndex,
pageSize,
out totalRecords,
filter,
ordering);
}
}
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<IContent> GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery<IContent>? filter, Ordering? ordering = null) public IEnumerable<IContent> GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery<IContent>? filter, Ordering? ordering = null)
{ => QueryOperationService.GetPagedOfTypes(contentTypeIds, pageIndex, pageSize, out totalRecords, filter, ordering);
if (pageIndex < 0)
{
throw new ArgumentOutOfRangeException(nameof(pageIndex));
}
if (pageSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
ordering ??= Ordering.By("sortOrder");
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
// Need to use a List here because the expression tree cannot convert the array when used in Contains.
// See ExpressionTests.Sql_In().
List<int> contentTypeIdsAsList = [.. contentTypeIds];
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetPage(
Query<IContent>()?.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId)),
pageIndex,
pageSize,
out totalRecords,
filter,
ordering);
}
}
/// <summary> /// <summary>
/// Gets a collection of <see cref="IContent" /> objects by Level /// Gets a collection of <see cref="IContent" /> objects by Level
@@ -610,14 +562,7 @@ public class ContentService : RepositoryService, IContentService
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns> /// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
/// <remarks>Contrary to most methods, this method filters out trashed content items.</remarks> /// <remarks>Contrary to most methods, this method filters out trashed content items.</remarks>
public IEnumerable<IContent> GetByLevel(int level) public IEnumerable<IContent> GetByLevel(int level)
{ => QueryOperationService.GetByLevel(level);
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
IQuery<IContent>? query = Query<IContent>().Where(x => x.Level == level && x.Trashed == false);
return _documentRepository.Get(query);
}
}
/// <summary> /// <summary>
/// Gets a specific version of an <see cref="IContent" /> item. /// Gets a specific version of an <see cref="IContent" /> item.

View File

@@ -0,0 +1,123 @@
// src/Umbraco.Core/Services/IContentQueryOperationService.cs
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Service for content query operations (counting, filtering by type/level).
/// </summary>
/// <remarks>
/// <para>
/// <strong>Implementation Note:</strong> Do not implement this interface directly.
/// Instead, inherit from <see cref="ContentServiceBase"/> which provides required
/// infrastructure (scoping, repository access, auditing). Direct implementation
/// without this base class will result in missing functionality.
/// </para>
/// <para>
/// This interface is part of the ContentService refactoring initiative (Phase 2).
/// It extracts query operations into a focused, testable service.
/// </para>
/// <para>
/// <strong>Versioning Policy:</strong> This interface follows additive-only changes.
/// New methods may be added with default implementations. Existing methods will not
/// be removed or have signatures changed without a 2 major version deprecation period.
/// </para>
/// <para>
/// <strong>Version History:</strong>
/// <list type="bullet">
/// <item><description>v1.0 (Phase 2): Initial interface with Count, GetByLevel, GetPagedOfType operations</description></item>
/// </list>
/// </para>
/// </remarks>
/// <since>1.0</since>
public interface IContentQueryOperationService : IService
{
#region Count Operations
/// <summary>
/// Counts content items, optionally filtered by content type.
/// </summary>
/// <param name="contentTypeAlias">Optional content type alias to filter by. If the alias doesn't exist, returns 0.</param>
/// <returns>The count of matching content items (includes trashed items).</returns>
int Count(string? contentTypeAlias = null);
/// <summary>
/// Counts published content items, optionally filtered by content type.
/// </summary>
/// <param name="contentTypeAlias">Optional content type alias to filter by. If the alias doesn't exist, returns 0.</param>
/// <returns>The count of matching published content items.</returns>
int CountPublished(string? contentTypeAlias = null);
/// <summary>
/// Counts children of a parent, optionally filtered by content type.
/// </summary>
/// <param name="parentId">The parent content id. If the parent doesn't exist, returns 0.</param>
/// <param name="contentTypeAlias">Optional content type alias to filter by. If the alias doesn't exist, returns 0.</param>
/// <returns>The count of matching child content items.</returns>
int CountChildren(int parentId, string? contentTypeAlias = null);
/// <summary>
/// Counts descendants of an ancestor, optionally filtered by content type.
/// </summary>
/// <param name="parentId">The ancestor content id. If the ancestor doesn't exist, returns 0.</param>
/// <param name="contentTypeAlias">Optional content type alias to filter by. If the alias doesn't exist, returns 0.</param>
/// <returns>The count of matching descendant content items.</returns>
int CountDescendants(int parentId, string? contentTypeAlias = null);
#endregion
#region Hierarchy Queries
/// <summary>
/// Gets content items at a specific tree level.
/// </summary>
/// <param name="level">The tree level (1 = root children, 2 = grandchildren, etc.).</param>
/// <returns>Content items at the specified level, excluding trashed items.</returns>
IEnumerable<IContent> GetByLevel(int level);
#endregion
#region Paged Type Queries
/// <summary>
/// Gets paged content items of a specific content type.
/// </summary>
/// <param name="contentTypeId">The content type id. If the content type doesn't exist, returns empty results with totalRecords = 0.</param>
/// <param name="pageIndex">Zero-based page index.</param>
/// <param name="pageSize">Page size.</param>
/// <param name="totalRecords">Output: total number of matching records.</param>
/// <param name="filter">Optional filter query.</param>
/// <param name="ordering">Optional ordering (defaults to sortOrder).</param>
/// <returns>Paged content items.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when pageIndex is negative or pageSize is less than or equal to zero.</exception>
IEnumerable<IContent> GetPagedOfType(
int contentTypeId,
long pageIndex,
int pageSize,
out long totalRecords,
IQuery<IContent>? filter = null,
Ordering? ordering = null);
/// <summary>
/// Gets paged content items of multiple content types.
/// </summary>
/// <param name="contentTypeIds">The content type ids. If empty or containing non-existent IDs, returns empty results with totalRecords = 0.</param>
/// <param name="pageIndex">Zero-based page index.</param>
/// <param name="pageSize">Page size.</param>
/// <param name="totalRecords">Output: total number of matching records.</param>
/// <param name="filter">Optional filter query.</param>
/// <param name="ordering">Optional ordering (defaults to sortOrder).</param>
/// <returns>Paged content items.</returns>
/// <exception cref="ArgumentNullException">Thrown when contentTypeIds is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when pageIndex is negative or pageSize is less than or equal to zero.</exception>
IEnumerable<IContent> GetPagedOfTypes(
int[] contentTypeIds,
long pageIndex,
int pageSize,
out long totalRecords,
IQuery<IContent>? filter = null,
Ordering? ordering = null);
#endregion
}

View File

@@ -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;
/// <summary>
/// Integration tests for ContentQueryOperationService.
/// </summary>
[TestFixture]
[UmbracoTest(
Database = UmbracoTestOptions.Database.NewSchemaPerTest,
WithApplication = true)]
public class ContentQueryOperationServiceTests : UmbracoIntegrationTestWithContent
{
private IContentQueryOperationService QueryService => GetRequiredService<IContentQueryOperationService>();
[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<int>(), 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));
}
}

View File

@@ -673,6 +673,137 @@ internal sealed class ContentServiceRefactoringTests : UmbracoIntegrationTestWit
#endregion #endregion
#region Phase 2 - Count Method Delegation Tests
/// <summary>
/// Phase 2 Test: Verifies Count() via facade returns same result as direct service call.
/// </summary>
[Test]
public void Count_ViaFacade_ReturnsEquivalentResultToDirectService()
{
// Arrange
var queryService = GetRequiredService<IContentQueryOperationService>();
// Act
var facadeCount = ContentService.Count();
var directCount = queryService.Count();
// Assert
Assert.That(facadeCount, Is.EqualTo(directCount));
}
/// <summary>
/// Phase 2 Test: Verifies CountPublished() via facade returns same result as direct service call.
/// </summary>
[Test]
public void CountPublished_ViaFacade_ReturnsEquivalentResultToDirectService()
{
// Arrange
var queryService = GetRequiredService<IContentQueryOperationService>();
ContentService.Publish(Textpage, new[] { "*" });
// Act
var facadeCount = ContentService.CountPublished();
var directCount = queryService.CountPublished();
// Assert
Assert.That(facadeCount, Is.EqualTo(directCount));
}
/// <summary>
/// Phase 2 Test: Verifies CountChildren() via facade returns same result as direct service call.
/// </summary>
[Test]
public void CountChildren_ViaFacade_ReturnsEquivalentResultToDirectService()
{
// Arrange
var queryService = GetRequiredService<IContentQueryOperationService>();
var parentId = Textpage.Id;
// Act
var facadeCount = ContentService.CountChildren(parentId);
var directCount = queryService.CountChildren(parentId);
// Assert
Assert.That(facadeCount, Is.EqualTo(directCount));
}
/// <summary>
/// Phase 2 Test: Verifies CountDescendants() via facade returns same result as direct service call.
/// </summary>
[Test]
public void CountDescendants_ViaFacade_ReturnsEquivalentResultToDirectService()
{
// Arrange
var queryService = GetRequiredService<IContentQueryOperationService>();
var parentId = Textpage.Id;
// Act
var facadeCount = ContentService.CountDescendants(parentId);
var directCount = queryService.CountDescendants(parentId);
// Assert
Assert.That(facadeCount, Is.EqualTo(directCount));
}
/// <summary>
/// Phase 2 Test: Verifies GetByLevel() via facade returns same result as direct service call.
/// </summary>
[Test]
public void GetByLevel_ViaFacade_ReturnsEquivalentResultToDirectService()
{
// Arrange
var queryService = GetRequiredService<IContentQueryOperationService>();
// Act
var facadeItems = ContentService.GetByLevel(1).ToList();
var directItems = queryService.GetByLevel(1).ToList();
// Assert
Assert.That(facadeItems.Count, Is.EqualTo(directItems.Count));
Assert.That(facadeItems.Select(x => x.Id), Is.EquivalentTo(directItems.Select(x => x.Id)));
}
/// <summary>
/// Phase 2 Test: Verifies GetPagedOfType() via facade returns same result as direct service call.
/// </summary>
[Test]
public void GetPagedOfType_ViaFacade_ReturnsEquivalentResultToDirectService()
{
// Arrange
var queryService = GetRequiredService<IContentQueryOperationService>();
var contentTypeId = ContentType.Id;
// Act
var facadeItems = ContentService.GetPagedOfType(contentTypeId, 0, 10, out var facadeTotal).ToList();
var directItems = queryService.GetPagedOfType(contentTypeId, 0, 10, out var directTotal).ToList();
// Assert
Assert.That(facadeTotal, Is.EqualTo(directTotal));
Assert.That(facadeItems.Select(x => x.Id), Is.EquivalentTo(directItems.Select(x => x.Id)));
}
/// <summary>
/// Phase 2 Test: Verifies GetPagedOfTypes() via facade returns same result as direct service call.
/// </summary>
[Test]
public void GetPagedOfTypes_ViaFacade_ReturnsEquivalentResultToDirectService()
{
// Arrange
var queryService = GetRequiredService<IContentQueryOperationService>();
var contentTypeIds = new[] { ContentType.Id };
// Act
var facadeItems = ContentService.GetPagedOfTypes(contentTypeIds, 0, 10, out var facadeTotal, null).ToList();
var directItems = queryService.GetPagedOfTypes(contentTypeIds, 0, 10, out var directTotal).ToList();
// Assert
Assert.That(facadeTotal, Is.EqualTo(directTotal));
Assert.That(facadeItems.Select(x => x.Id), Is.EquivalentTo(directItems.Select(x => x.Id)));
}
#endregion
/// <summary> /// <summary>
/// Notification handler that tracks the order of notifications for test verification. /// Notification handler that tracks the order of notifications for test verification.
/// </summary> /// </summary>

View File

@@ -0,0 +1,30 @@
// tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentQueryOperationServiceInterfaceTests.cs
using NUnit.Framework;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services;
[TestFixture]
public class ContentQueryOperationServiceInterfaceTests
{
[Test]
public void IContentQueryOperationService_Interface_Exists()
{
// Arrange & Act
var interfaceType = typeof(IContentQueryOperationService);
// Assert
Assert.That(interfaceType, Is.Not.Null);
Assert.That(interfaceType.IsInterface, Is.True);
}
[Test]
public void IContentQueryOperationService_Extends_IService()
{
// Arrange
var interfaceType = typeof(IContentQueryOperationService);
// Act & Assert
Assert.That(typeof(IService).IsAssignableFrom(interfaceType), Is.True);
}
}