Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>.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<T>` or event notifications
Expand Down
2 changes: 2 additions & 0 deletions src/Umbraco.Core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ public class MyEntityCacheRefresher : CacheRefresherBase<MyEntityCacheRefresher>
- `Attempt.Succeed(value)` / `Attempt.Fail<T>()`
- `Attempt<Content, ContentEditingOperationStatus>` - 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`:
Expand Down
51 changes: 51 additions & 0 deletions src/Umbraco.Infrastructure/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>.InGroupsOf(groupSize)` (in `Umbraco.Core`, `Extensions/EnumerableExtensions.cs`) — extension method to batch a collection.
- `Database.FetchByGroups<TResult, TSource>(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<int> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Database.DeleteMany<FooDto>().Where(x => group.Contains(x.Id)).Execute();
}

// Pattern 2: batched fetch with NPoco helper.
List<FooDto> dtos = Database.FetchByGroups<FooDto, int>(
ids,
Constants.Sql.MaxParameterCount,
batch => Sql().Select<FooDto>().From<FooDto>().WhereIn<FooDto>(x => x.Id, batch));

// Pattern 3: reserve headroom for other parameters in the same statement.
foreach (IEnumerable<int> 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<T>` 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<int>`/`IEnumerable<Guid>` 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.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,14 +249,24 @@ protected override int PerformCount(IQuery<IRedirectUrl>? query) =>

protected override IEnumerable<IRedirectUrl> 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<RedirectUrlDto>(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<RedirectUrlDto>(ids.Length);
foreach (IEnumerable<Guid> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, group);
dtos.AddRange(Database.Fetch<RedirectUrlDto>(sql));
}

Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, ids);
List<RedirectUrlDto> dtos = Database.Fetch<RedirectUrlDto>(sql);
return dtos.WhereNotNull().Select(Map).WhereNotNull();
}

Expand Down
Loading
Loading