diff --git a/CLAUDE.md b/CLAUDE.md index 51b039f4ffa4..017f4839a158 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -435,6 +435,14 @@ When a PR changes Management API controllers or models, the `OpenApi.json` file The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing". +### SQL Server 2100-parameter limit + +Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production. + +Batch with `IEnumerable.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't. + +Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit". + ### Known Limitations 1. **Circular Dependencies**: Avoided via `Lazy` or event notifications diff --git a/src/Umbraco.Core/CLAUDE.md b/src/Umbraco.Core/CLAUDE.md index da1b406ed9e9..61f75ce0a114 100644 --- a/src/Umbraco.Core/CLAUDE.md +++ b/src/Umbraco.Core/CLAUDE.md @@ -305,6 +305,8 @@ public class MyEntityCacheRefresher : CacheRefresherBase - `Attempt.Succeed(value)` / `Attempt.Fail()` - `Attempt` - typed result with status +> Writing or reviewing a query with a `WHERE IN` on a runtime-sized collection? See "Avoiding the SQL Server 2100-parameter limit" in `/src/Umbraco.Infrastructure/CLAUDE.md` — that's where the full helper list (`Constants.Sql.MaxParameterCount`, `InGroupsOf`, NPoco's `FetchByGroups`) and the decision rules live. + ### Configuration Configuration models in `/Configuration/Models`: diff --git a/src/Umbraco.Infrastructure/CLAUDE.md b/src/Umbraco.Infrastructure/CLAUDE.md index 84102b6df1cf..68c3aebac149 100644 --- a/src/Umbraco.Infrastructure/CLAUDE.md +++ b/src/Umbraco.Infrastructure/CLAUDE.md @@ -384,6 +384,57 @@ using (ICoreScope scope = ScopeProvider.CreateCoreScope()) 3. **Lazy loading outside scope** - NPoco relationships must load within scope 4. **Large migrations** - Split into multiple steps if > 1000 lines 5. **Repository logic in services** - Keep repos thin, logic in services +6. **Unbatched `WHERE IN` on user-sized collections** - See "Avoiding the SQL Server 2100-parameter limit" below + +### Avoiding the SQL Server 2100-parameter limit + +SQL Server caps a single statement at 2100 parameters. When an `IN` clause is built from a collection sized by user data, that cap can be hit — and the symptom is a runtime `SqlException` (error 8003) on customer installs that nobody hit in dev. + +**The constant and helpers**: +- `Constants.Sql.MaxParameterCount = 2000` (in `Umbraco.Core`, `Constants-Sql.cs`) — the ceiling we target (2100 minus headroom for joined predicates already in the SQL). +- `IEnumerable.InGroupsOf(groupSize)` (in `Umbraco.Core`, `Extensions/EnumerableExtensions.cs`) — extension method to batch a collection. +- `Database.FetchByGroups(source, groupSize, sqlFactory)` (in `Umbraco.Infrastructure`, `Persistence/NPocoDatabaseExtensions.cs`) — NPoco helper that batches a fetch. + +**The safe patterns** (use one of these any time the collection size is user-driven): + +```csharp +// Pattern 1: batch a DeleteMany / Execute / Fetch by looping. +foreach (IEnumerable group in ids.InGroupsOf(Constants.Sql.MaxParameterCount)) +{ + Database.DeleteMany().Where(x => group.Contains(x.Id)).Execute(); +} + +// Pattern 2: batched fetch with NPoco helper. +List dtos = Database.FetchByGroups( + ids, + Constants.Sql.MaxParameterCount, + batch => Sql().Select().From().WhereIn(x => x.Id, batch)); + +// Pattern 3: reserve headroom for other parameters in the same statement. +foreach (IEnumerable group in entityIds.InGroupsOf(Constants.Sql.MaxParameterCount - userGroupIds.Length)) +{ + // statement uses entityIds + userGroupIds, so subtract the other predicate's parameter count from the budget +} +``` + +**Decision rule when writing or reviewing a `WHERE IN`-style query**: + +Look at what drives the size of the collection feeding the `IN`. Ask: *could this realistically exceed 2000 on a large install?* Risky drivers — batch any query backed by these: +- All content / media / member nodes (or descendants of a deep tree). +- A product of two scaling dimensions, e.g. `documents × languages`, `properties × versions`, `relations × endpoints`. +- Configuration-tunable batch sizes (`CacheSettings.DocumentSeedBatchSize`, `NuCacheSettings.SqlPageSize`, etc.). The default may be safe but the customer can raise it. +- Anything that scans property data, version history, relations, or audit logs across many nodes. + +Safe drivers — don't bother batching: +- Languages / content types / member groups / user groups — bounded by install configuration, typically <100. +- "Per single content item" collections — properties on one document, versions of one document, tokens for one external login. +- IDs supplied directly by a user action through the UI (picker selections, bulk actions on a page of results). + +If you're not sure, batch — the cost is one loop and an `IEnumerable` allocation per batch; the cost of being wrong is a SqlException on a customer's biggest site. + +**For new public APIs** that take an `IEnumerable`/`IEnumerable` and feed it into a query, batch internally even if no current caller is large — package authors and future callers will not know about the 2000-limit ceiling. + +**Don't** rely on `if (ids.Length > MaxParameterCount) throw` as a substitute for batching. Throwing only moves the problem; the caller has no obvious way to recover and will most likely just fail in production. --- diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RedirectUrlRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RedirectUrlRepository.cs index 11abbfa34b57..a1874d54b1f7 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RedirectUrlRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RedirectUrlRepository.cs @@ -249,14 +249,24 @@ protected override int PerformCount(IQuery? query) => protected override IEnumerable PerformGetAll(params Guid[]? ids) { - if (ids?.Length > Constants.Sql.MaxParameterCount) + if (ids is null || ids.Length == 0) { - throw new NotSupportedException( - $"This repository does not support more than {Constants.Sql.MaxParameterCount} ids."); + return Database.Fetch(GetBaseQuery(false)) + .WhereNotNull() + .Select(Map) + .WhereNotNull(); + } + + // Batch the WhereIn fetch so we never exceed SQL Server's 2100 parameter limit. + // EntityRepositoryBase.GetMany already groups IDs, but we keep the batching here as + // a defensive measure for safety and consistency at the repository boundary. + var dtos = new List(ids.Length); + foreach (IEnumerable group in ids.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Sql sql = GetBaseQuery(false).WhereIn(x => x.Id, group); + dtos.AddRange(Database.Fetch(sql)); } - Sql sql = GetBaseQuery(false).WhereIn(x => x.Id, ids); - List dtos = Database.Fetch(sql); return dtos.WhereNotNull().Select(Map).WhereNotNull(); } diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs index c1a66a30e9a3..233765c30d14 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs @@ -207,21 +207,28 @@ private void TruncateContent() /// public async Task> GetContentSourcesAsync(IEnumerable keys, bool preview = false) { - Sql? sql = SqlContentSourcesSelect() - .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document)) - .WhereIn(x => x.UniqueId, keys) - .Append(SqlOrderByLevelIdSortOrder(SqlContext)); - - List dtos = await Database.FetchAsync(sql); + // Batch the WHERE IN to stay within SQL Server's parameter limit. + // The configurable document seed batch size is applied upstream; this method only enforces MaxParameterCount. + Guid[] keysArray = keys as Guid[] ?? keys.ToArray(); + var dtos = new List(keysArray.Length); + foreach (IEnumerable group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Sql? sql = SqlContentSourcesSelect() + .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document)) + .WhereIn(x => x.UniqueId, group) + .Append(SqlOrderByLevelIdSortOrder(SqlContext)); + + dtos.AddRange(await Database.FetchAsync(sql)); + } - dtos = dtos + var filtered = dtos .Where(x => x is not null) .Where(x => preview || ((x.PubDataRaw is not null || x.PubData is not null) && (!x.Published || x.PubName is not null))) .ToList(); IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document); - return dtos + return filtered .Select(x => CreateContentNodeKit(x, serializer, preview)) .OfType(); } @@ -379,20 +386,27 @@ private class MediaKeyDto /// public async Task> GetMediaSourcesAsync(IEnumerable keys) { - Sql? sql = SqlMediaSourcesSelect() - .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media)) - .WhereIn(x => x.UniqueId, keys) - .Append(SqlOrderByLevelIdSortOrder(SqlContext)); + // Batch the WHERE IN by Constants.Sql.MaxParameterCount so callers configuring + // CacheSettings.MediaSeedBatchSize above that limit do not hit SQL Server's 2100 parameter limit. + Guid[] keysArray = keys as Guid[] ?? keys.ToArray(); + var dtos = new List(keysArray.Length); + foreach (IEnumerable group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Sql? sql = SqlMediaSourcesSelect() + .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media)) + .WhereIn(x => x.UniqueId, group) + .Append(SqlOrderByLevelIdSortOrder(SqlContext)); - List dtos = await Database.FetchAsync(sql); + dtos.AddRange(await Database.FetchAsync(sql)); + } - dtos = dtos + var filtered = dtos .Where(x => x is not null) .ToList(); IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media); - return dtos + return filtered .Select(x => CreateMediaNodeKit(x, serializer)); } @@ -578,107 +592,135 @@ private List GetPagedContentNodeIds(Guid objectType, IReadOnlyCollection private List GetDocumentMetadataForNodes(List nodeIds) { - // Query content metadata with both edit and published version info + // Query content metadata with both edit and published version info. // Uses nested join pattern to ensure we only get the published ContentVersion - // (where a DocumentVersionDto with Published=true exists) - Sql sql = Sql() - .Select( - x => x.NodeId, - x => x.UniqueId, - x => x.Text, - x => x.Path, - x => x.Level, - x => x.ParentId, - x => x.SortOrder, - x => x.CreateDate, - x => Alias(x.UserId, "CreatorId")) - .AndSelect(x => x.ContentTypeId) - .AndSelect(x => x.Published) - .AndSelect( - x => Alias(x.Id, "EditVersionId"), - x => Alias(x.Text, "EditName"), - x => Alias(x.VersionDate, "EditVersionDate"), - x => Alias(x.UserId, "EditWriterId")) - .AndSelect( - "pcv", - x => Alias(x.Id, "PublishedVersionId"), - x => Alias(x.Text, "PublishedName"), - x => Alias(x.VersionDate, "PublishedVersionDate"), - x => Alias(x.UserId, "PublishedWriterId")) - .From() - .InnerJoin().On((n, c) => n.NodeId == c.NodeId) - .InnerJoin().On((n, d) => n.NodeId == d.NodeId) - .InnerJoin().On((n, cv) => n.NodeId == cv.NodeId && cv.Current) + // (where a DocumentVersionDto with Published=true exists). + // Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works. + var results = new List(nodeIds.Count); + foreach (IEnumerable group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Sql sql = Sql() + .Select( + x => x.NodeId, + x => x.UniqueId, + x => x.Text, + x => x.Path, + x => x.Level, + x => x.ParentId, + x => x.SortOrder, + x => x.CreateDate, + x => Alias(x.UserId, "CreatorId")) + .AndSelect(x => x.ContentTypeId) + .AndSelect(x => x.Published) + .AndSelect( + x => Alias(x.Id, "EditVersionId"), + x => Alias(x.Text, "EditName"), + x => Alias(x.VersionDate, "EditVersionDate"), + x => Alias(x.UserId, "EditWriterId")) + .AndSelect( + "pcv", + x => Alias(x.Id, "PublishedVersionId"), + x => Alias(x.Text, "PublishedName"), + x => Alias(x.VersionDate, "PublishedVersionDate"), + x => Alias(x.UserId, "PublishedWriterId")) + .From() + .InnerJoin().On((n, c) => n.NodeId == c.NodeId) + .InnerJoin().On((n, d) => n.NodeId == d.NodeId) + .InnerJoin().On((n, cv) => n.NodeId == cv.NodeId && cv.Current) - // Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true - // This ensures pcv only includes rows where there's a published DocumentVersion - .LeftJoin( - j => j.InnerJoin("pdv") - .On( - (left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"), - "pcv") + // Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true. + // This ensures pcv only includes rows where there's a published DocumentVersion. + .LeftJoin( + j => j.InnerJoin("pdv") + .On( + (left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"), + "pcv") - .On((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv") - .WhereIn(x => x.NodeId, nodeIds); + .On((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv") + .WhereIn(x => x.NodeId, group); - return Database.Fetch(sql); + results.AddRange(Database.Fetch(sql)); + } + + return results; } /// /// Gets property data for the specified node IDs using efficient JOIN on nodeId. /// This avoids the expensive WHERE IN on versionId that causes index scans. + /// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works. /// private List GetPropertyDataForNodes(List nodeIds) { - // JOIN through nodeId → versionId path for efficient query plan - Sql sql = Sql() - .Select( - x => x.VersionId, - x => x.LanguageId, - x => x.Segment, - x => x.IntegerValue, - x => x.DecimalValue, - x => x.DateValue, - x => x.VarcharValue, - x => x.TextValue) - .AndSelect(x => Alias(x.Alias, "PropertyAlias")) - .From() - .InnerJoin().On((pd, pt) => pd.PropertyTypeId == pt.Id) - .InnerJoin().On((pd, cv) => pd.VersionId == cv.Id) - .WhereIn(x => x.NodeId, nodeIds); - - return Database.Fetch(sql); + var results = new List(); + foreach (IEnumerable group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + // JOIN through nodeId → versionId path for efficient query plan. + Sql sql = Sql() + .Select( + x => x.VersionId, + x => x.LanguageId, + x => x.Segment, + x => x.IntegerValue, + x => x.DecimalValue, + x => x.DateValue, + x => x.VarcharValue, + x => x.TextValue) + .AndSelect(x => Alias(x.Alias, "PropertyAlias")) + .From() + .InnerJoin().On((pd, pt) => pd.PropertyTypeId == pt.Id) + .InnerJoin().On((pd, cv) => pd.VersionId == cv.Id) + .WhereIn(x => x.NodeId, group); + + results.AddRange(Database.Fetch(sql)); + } + + return results; } /// /// Gets culture variation data for the specified node IDs. + /// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works. /// private List GetCultureDataForNodes(List nodeIds) { - Sql sql = Sql() - .Select(x => x.VersionId, x => x.Name, x => x.UpdateDate) - .AndSelect(x => Alias(x.IsoCode, "IsoCode")) - .From() - .InnerJoin().On((cv, l) => cv.LanguageId == l.Id) - .InnerJoin().On((ccv, cv) => ccv.VersionId == cv.Id) - .WhereIn(x => x.NodeId, nodeIds); + var results = new List(); + foreach (IEnumerable group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Sql sql = Sql() + .Select(x => x.VersionId, x => x.Name, x => x.UpdateDate) + .AndSelect(x => Alias(x.IsoCode, "IsoCode")) + .From() + .InnerJoin().On((cv, l) => cv.LanguageId == l.Id) + .InnerJoin().On((ccv, cv) => ccv.VersionId == cv.Id) + .WhereIn(x => x.NodeId, group); + + results.AddRange(Database.Fetch(sql)); + } - return Database.Fetch(sql); + return results; } /// /// Gets document culture variation data (edited status per culture) for the specified node IDs. + /// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works. /// private List GetDocumentCultureDataForNodes(List nodeIds) { - Sql sql = Sql() - .Select(x => x.NodeId, x => x.Edited) - .AndSelect(x => Alias(x.IsoCode, "IsoCode")) - .From() - .InnerJoin().On((dcv, l) => dcv.LanguageId == l.Id) - .WhereIn(x => x.NodeId, nodeIds); + var results = new List(); + foreach (IEnumerable group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Sql sql = Sql() + .Select(x => x.NodeId, x => x.Edited) + .AndSelect(x => Alias(x.IsoCode, "IsoCode")) + .From() + .InnerJoin().On((dcv, l) => dcv.LanguageId == l.Id) + .WhereIn(x => x.NodeId, group); + + results.AddRange(Database.Fetch(sql)); + } - return Database.Fetch(sql); + return results; } /// @@ -1207,31 +1249,38 @@ private HashSet GetAllCompositionsRecursive(int contentTypeId, Dictionary /// Gets content metadata for the specified node IDs using efficient JOIN. Used for media and members. + /// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works. /// private List GetContentMetadataForNodes(List nodeIds) { - Sql sql = Sql() - .Select( - x => x.NodeId, - x => x.UniqueId, - x => x.Text, - x => x.Path, - x => x.Level, - x => x.ParentId, - x => x.SortOrder, - x => x.CreateDate, - x => Alias(x.UserId, "CreatorId")) - .AndSelect(x => x.ContentTypeId) - .AndSelect( - x => Alias(x.Id, "VersionId"), - x => Alias(x.VersionDate, "VersionDate"), - x => Alias(x.UserId, "WriterId")) - .From() - .InnerJoin().On((n, c) => n.NodeId == c.NodeId) - .InnerJoin().On((n, cv) => n.NodeId == cv.NodeId && cv.Current) - .WhereIn(x => x.NodeId, nodeIds); + var results = new List(nodeIds.Count); + foreach (IEnumerable group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Sql sql = Sql() + .Select( + x => x.NodeId, + x => x.UniqueId, + x => x.Text, + x => x.Path, + x => x.Level, + x => x.ParentId, + x => x.SortOrder, + x => x.CreateDate, + x => Alias(x.UserId, "CreatorId")) + .AndSelect(x => x.ContentTypeId) + .AndSelect( + x => Alias(x.Id, "VersionId"), + x => Alias(x.VersionDate, "VersionDate"), + x => Alias(x.UserId, "WriterId")) + .From() + .InnerJoin().On((n, c) => n.NodeId == c.NodeId) + .InnerJoin().On((n, cv) => n.NodeId == cv.NodeId && cv.Current) + .WhereIn(x => x.NodeId, group); - return Database.Fetch(sql); + results.AddRange(Database.Fetch(sql)); + } + + return results; } /// diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/RedirectUrlRepositoryTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/RedirectUrlRepositoryTests.cs index 4b694f1b9c44..370b6e31441a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/RedirectUrlRepositoryTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/RedirectUrlRepositoryTests.cs @@ -287,6 +287,85 @@ public void Can_Get_All_Urls_Filtered_By_Root_Content_Id() } } + [Test] + public void GetMany_With_Ids_Returns_Matching_Redirects() + { + // The "happy path" — a handful of ids, well under the SQL parameter limit. + // Verifies that batching in PerformGetAll doesn't break the common case. + Guid id1, id2; + using (var scope = ScopeProvider.CreateScope()) + { + var repo = CreateRepository(ScopeProvider); + + var rurl1 = new RedirectUrl { ContentKey = _textpage.Key, Url = "a" }; + repo.Save(rurl1); + id1 = rurl1.Key; + + var rurl2 = new RedirectUrl + { + ContentKey = _subpage.Key, + Url = "b", + CreateDateUtc = rurl1.CreateDateUtc.AddSeconds(1) + }; + repo.Save(rurl2); + id2 = rurl2.Key; + + // Third redirect we deliberately don't pass to GetMany — it should not be returned. + var rurl3 = new RedirectUrl + { + ContentKey = _otherpage.Key, + Url = "c", + CreateDateUtc = rurl1.CreateDateUtc.AddSeconds(2) + }; + repo.Save(rurl3); + + scope.Complete(); + } + + using (var scope = ScopeProvider.CreateScope()) + { + var repo = CreateRepository(ScopeProvider); + var rurls = repo.GetMany(id1, id2).ToArray(); + scope.Complete(); + + Assert.That(rurls, Has.Length.EqualTo(2)); + Assert.That(rurls.Select(r => r.Url), Is.EquivalentTo(new[] { "a", "b" })); + } + } + + [Test] + public void GetMany_With_No_Ids_Returns_All_Redirects() + { + // When PerformGetAll is invoked with no ids (the FullDataSet cache-policy path), the repository + // should return every row rather than fall through to a `WHERE id IN ()` that returns nothing. + using (var scope = ScopeProvider.CreateScope()) + { + var repo = CreateRepository(ScopeProvider); + + var rurl1 = new RedirectUrl { ContentKey = _textpage.Key, Url = "x" }; + repo.Save(rurl1); + + var rurl2 = new RedirectUrl + { + ContentKey = _subpage.Key, + Url = "y", + CreateDateUtc = rurl1.CreateDateUtc.AddSeconds(1) + }; + repo.Save(rurl2); + + scope.Complete(); + } + + using (var scope = ScopeProvider.CreateScope()) + { + var repo = CreateRepository(ScopeProvider); + var rurls = repo.GetMany().ToArray(); + scope.Complete(); + + Assert.That(rurls.Select(r => r.Url), Is.SupersetOf(new[] { "x", "y" })); + } + } + [Test] public void Can_Search_Urls() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DatabaseCacheRepositoryTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DatabaseCacheRepositoryTests.cs new file mode 100644 index 000000000000..3c9eb3123dd0 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DatabaseCacheRepositoryTests.cs @@ -0,0 +1,127 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.HybridCache.Persistence; +using Umbraco.Cms.Infrastructure.HybridCache.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.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class DatabaseCacheRepositoryTests : UmbracoIntegrationTestWithContent +{ + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.Services.AddUnique(); + } + + private IDatabaseCacheRepository DatabaseCacheRepository => GetRequiredService(); + + private IContentPublishingService ContentPublishingService => GetRequiredService(); + + private IMediaTypeService MediaTypeService => GetRequiredService(); + + private IMediaService MediaService => GetRequiredService(); + + private IMediaType MediaType { get; set; } + + private IMedia MediaItem1 { get; set; } + + private IMedia MediaItem2 { get; set; } + + private IMedia MediaItem3 { get; set; } + + public override void CreateTestData() + { + base.CreateTestData(); + + // Add a few media items so the media-side methods have something to read. + MediaType = MediaTypeService.Get("image")!; + MediaItem1 = new MediaBuilder().WithName("Image 1").WithMediaType(MediaType).Build(); + MediaItem2 = new MediaBuilder().WithName("Image 2").WithMediaType(MediaType).Build(); + MediaItem3 = new MediaBuilder().WithName("Image 3").WithMediaType(MediaType).Build(); + MediaService.Save(MediaItem1); + MediaService.Save(MediaItem2); + MediaService.Save(MediaItem3); + } + + [Test] + public async Task GetContentSourcesAsync_With_Keys_Returns_Matching_Sources() + { + // Arrange — publish the root so we exercise the published branch of the filter, and leave + // the subpages in draft. Base fixture gives us 4 non-trashed documents. + await ContentPublishingService.PublishAsync(Textpage.Key, [new()], Constants.Security.SuperUserKey); + + var requestedKeys = new[] { Textpage.Key, Subpage.Key, Subpage2.Key }; + + // Act — preview = true so draft-only subpages are also returned. + using var scope = ScopeProvider.CreateScope(autoComplete: true); + var sources = (await DatabaseCacheRepository.GetContentSourcesAsync(requestedKeys, preview: true)).ToList(); + + // Assert — only the three requested nodes come back, ignoring Subpage3 and the trashed node. + var returnedKeys = sources.Select(s => s.Key).ToHashSet(); + Assert.That(returnedKeys, Is.EquivalentTo(requestedKeys)); + } + + [Test] + public async Task GetContentSourcesAsync_With_Empty_Keys_Returns_Nothing() + { + // The batched loop iterates zero groups when the input is empty — no rows should be + // returned, and the database must not be hit with a malformed `WHERE id IN ()`. + using var scope = ScopeProvider.CreateScope(autoComplete: true); + var sources = await DatabaseCacheRepository.GetContentSourcesAsync(Array.Empty(), preview: true); + + Assert.That(sources, Is.Empty); + } + + [Test] + public async Task GetMediaSourcesAsync_With_Keys_Returns_Matching_Sources() + { + var requestedKeys = new[] { MediaItem1.Key, MediaItem2.Key }; + + using var scope = ScopeProvider.CreateScope(autoComplete: true); + var sources = (await DatabaseCacheRepository.GetMediaSourcesAsync(requestedKeys)).ToList(); + + var returnedKeys = sources.Select(s => s.Key).ToHashSet(); + Assert.That(returnedKeys, Is.EquivalentTo(requestedKeys)); + } + + [Test] + public async Task GetMediaSourcesAsync_With_Empty_Keys_Returns_Nothing() + { + using var scope = ScopeProvider.CreateScope(autoComplete: true); + var sources = await DatabaseCacheRepository.GetMediaSourcesAsync(Array.Empty()); + + Assert.That(sources, Is.Empty); + } + + [Test] + public void GetContentByContentTypeKey_With_Keys_Returns_Matching_Nodes() + { + // Exercises the batched ForNodes helpers (GetContentMetadataForNodes, + // GetPropertyDataForNodes, etc.) used by the rebuild/document-type lookup pipeline. + using var scope = ScopeProvider.CreateScope(autoComplete: true); + var nodes = DatabaseCacheRepository + .GetContentByContentTypeKey([ContentType.Key], ContentCacheDataSerializerEntityType.Document) + .ToList(); + + // The 4 non-trashed documents created by the fixture must all surface. + var returnedKeys = nodes.Select(n => n.Key).ToHashSet(); + Assert.That(returnedKeys, Does.Contain(Textpage.Key)); + Assert.That(returnedKeys, Does.Contain(Subpage.Key)); + Assert.That(returnedKeys, Does.Contain(Subpage2.Key)); + Assert.That(returnedKeys, Does.Contain(Subpage3.Key)); + } +}