Skip to content

Content Types: Also check descendants for property alias collisions when listing available compositions - #23529

Merged
lauraneto merged 9 commits into
v17/bugfix/23103-compositions-locked-by-inheritancefrom
v17/bugfix/23103-compositions-locked-by-inheritance-2
Aug 19, 2026
Merged

lauraneto merged 9 commits into
v17/bugfix/23103-compositions-locked-by-inheritancefrom
v17/bugfix/23103-compositions-locked-by-inheritance-2

Conversation

@lauraneto

@lauraneto lauraneto commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR targets v17/bugfix/23103-compositions-locked-by-inheritance (#23433) directly, not v17/dev - it's an addition on top of that work, for Andy to review in isolation before it merges into his branch.

The available-compositions listing (GetAvailableCompositeContentTypes) only accounted for ancestor-lock and same-type conflicts when deciding whether a candidate composition is Allowed. It didn't check whether selecting a candidate would push a property alias down onto a descendant that already defines it - even though #23433 added exactly that check at save time (ValidateDescendantPropertyAliases). A candidate could show as allowed in the picker and then be rejected on save.

Testing

Open the composition picker on a content type that has a descendant (via inheritance or composition) already defining a given property alias. Before this change, a composition that would introduce that same alias showed as selectable in the picker, then failed with a validation error on save. After this change, that composition shows as not allowed directly in the picker, consistent with what save-time validation already enforced.

The full ContentTypeEditingServiceTests, MediaTypeEditingServiceTests, and MemberTypeEditingServiceTests suites pass (220/220) on top of #23433's changes.

Possible follow-ups (not addressed here)

While testing this, ran into two pre-existing gaps, unrelated to and not fixed by this PR or #23433. Flagging for visibility, not proposing to act on them now:

  • GetComposedOf is direct-only, not transitive. The composition-references endpoints backing it only look one level down. A type several inheritance hops below the direct composer - a grandchild, not just a child - is still affected by changes to that composition (it inherits the same properties transitively), but never shows up in the results. This gap can only be reached through inheritance depth, not composition depth: a type that already composes or inherits something can never itself be composed by another type, so composition chains can't form in the first place.
  • Moving a content type in the tree can silently reclassify an inheritance edge into a composition edge. Composition vs. inheritance isn't a stored flag - it's derived by comparing a cmsContentType2ContentType edge's parentContentTypeId against the child's current umbracoNode.parentId (match = inheritance, mismatch = composition). The tree "Move" command changes the child's tree position but never reconciles the edge, so moving an inherited-from parent away can flip that edge's meaning with no explicit conversion step or validation. Confirmed this lets a moved type bypass the "a composition can't itself be composed" rule (Allowed count for the abandoned parent drops to 0, unearned). Verified this cannot be escalated into an actual composition cycle - closing any cycle needs a fresh edge landing on a type that already has some inheritance/composition relationship, which is independently blocked either way.

…hen listing available compositions

Available-compositions listing only checked ancestor-lock and same-type conflicts, missing the descendant-alias-collision check that save-time validation already enforced. A candidate could show as allowed in the picker and then be rejected on save.

Extracted the shared alias-resolution logic (own aliases, descendant aliases) into ContentTypeEditingHelper so both the listing and the save-time validation use the same source of truth.
Covers the descendant-collision check in the available-compositions listing (available but not allowed), that an already-in-use composition stays locked for the parent, and that a pre-existing collision on one composition doesn't exempt an unrelated candidate sharing the same alias. Also updates existing status-code assertions from DuplicatePropertyTypeAlias to InvalidComposition now that composition-caused collisions are split from own-property-caused ones.
…added ones

ValidateCompositions only checked genuinely new composition keys against
GetAvailableCompositeContentTypes, letting an already-persisted composition
ride along unchecked even if it had since become structurally invalid (e.g.
now colliding with a descendant's property alias). We initially assumed this
exemption was deliberately protecting a legitimate scenario, but tracing it
back to when it was introduced (2fbdc29, before #23433) showed it was
only ever working around isUsing incorrectly treating an inheritance child
as a composition user. isUsing now correctly excludes inheritance children,
so the exemption is no longer needed and every requested composition key -
persisted or new - is validated on every save.

Removed test coverage that only demonstrated the now-removed exemption via
composition states unreachable through the editing service or the tree Move
command (only reachable by bypassing validation directly through
IContentTypeService, e.g. legacy data or raw API/import usage).
…opertyAliases

Same reasoning as the ValidateCompositions change: this exemption assumed a
content type could legitimately have an own-property alias colliding with a
descendant's, from before this check existed, and that resaving it shouldn't
retroactively reject that. Tracing the deeper save path shows that scenario
was never actually reachable - IContentTypeService.InternalSaveAsync calls
ValidateLocked on every create/update, unconditionally, which already walks
the full transitive composition/inheritance graph and throws
InvalidCompositionException on any such collision. It has no persisted/new
distinction and predates this check entirely.

Keeping the exemption only made the failure mode worse: if it ever let a
collision through, the save wouldn't succeed anyway, it would just fail
later as an unhandled exception from ValidateLocked instead of a clean
DuplicatePropertyTypeAlias status here.

Also removes ContentTypeEditingHelper.GetAllPropertyAliases, which was only
used to compute the now-removed persisted/new split and has no other caller.
@lauraneto
lauraneto marked this pull request as ready for review August 4, 2026 12:54
@claude

claude Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Claude finished @lauraneto's task in 9m 51s —— View job


PR Review

Target: origin/v17/bugfix/23103-compositions-locked-by-inheritance · Based on commit: 5bbafd01

Extracts a shared GetAllDescendantPropertyAliases helper and wires it into both GetAvailableCompositeContentTypes (listing) and the simplified ValidateDescendantPropertyAliases (save-time), so the picker and the validator share the same source of truth. Also removes a grandfather exemption from ValidateCompositions and a "new-only" gate from ValidateDescendantPropertyAliases.

  • Other changes: Error status for composition-induced alias collision changes from ContentTypeOperationStatus.DuplicatePropertyTypeAlias to ContentTypeOperationStatus.InvalidComposition; consumers checking the specific status code are affected.

Critical

ContentTypeEditingServiceBase.cs:449 + ContentTypeServiceExtensions.cs:96–104 — Re-saving a content type that already has composition C fails with InvalidComposition whenever it has an inheriting child.

Root cause. GetAllDescendantPropertyAliases collects each descendant's CompositionPropertyTypes, which is computed recursively (ContentTypeCompositionBase.cs:120):

return ContentTypeComposition
    .SelectMany(x => x.CompositionPropertyTypes) // recursive
    .Union(PropertyTypes);

So for child D inheriting P which composes C:

  • D.CompositionPropertyTypes includes C's properties (via P's composition)
  • GetAllDescendantPropertyAliases(P) therefore includes C's aliases in descendantPropertyAliases
  • GetAvailableCompositeContentTypes adds those to filterPropertyTypes, then filters out any candidate whose own PropertyTypes overlap — so C is marked Allowed = false
  • ValidateCompositions sees C is not in allowedCompositionKeys and returns InvalidComposition

Concrete repro:

  1. Create P with composition C (property alias "shared")
  2. Create D inheriting P
  3. Open P in backoffice, change nothing, hit Save → InvalidComposition

Why the removed exemption was still load-bearing. The PR description argues the exemption "no longer protects anything reachable" because the isUsing bug it was originally shielding was fixed by the x.ParentId != sourceId guard. That reasoning is correct for the isUsing path. But removing the exemption is no longer safe once GetAvailableCompositeContentTypes has been extended with the descendant alias check — that new check introduces a new false-positive path that the exemption previously protected.

Suggested fix. In ValidateCompositions, apply the descendant-alias check only to newly-added compositions (those not already in contentType.ContentTypeComposition). Already-persisted compositions have already passed validation; their aliases are already propagated to descendants and don't constitute new collisions.

Guid[] compositionKeys = KeysForCompositionTypes(model, CompositionType.Composition);

// Only validate newly-added compositions against descendants; persisted ones already propagate
// their aliases onto descendants legitimately.
HashSet<Guid> alreadyPersistedKeys = contentType?.ContentTypeComposition
    .Select(c => c.Key)
    .ToHashSet() ?? [];
Guid[] keysToValidate = compositionKeys
    .Where(k => !alreadyPersistedKeys.Contains(k))
    .ToArray();

if (keysToValidate.Length == 0)
{
    return ContentTypeOperationStatus.Success;
}

Guid[] allowedCompositionKeys = _contentTypeService.GetAvailableCompositeContentTypes(
        contentType, allContentTypeCompositions, isElement: model.IsElement)
    .Results
    .Where(x => x.Allowed)
    .Select(x => x.Composition.Key)
    .ToArray();

return allowedCompositionKeys.ContainsAll(keysToValidate) is false
    ? ContentTypeOperationStatus.InvalidComposition
    : ContentTypeOperationStatus.Success;

This preserves the intent of both removals (the isUsing exemption stays gone, the new-only gate in ValidateDescendantPropertyAliases stays gone) while closing the specific re-save regression.

Missing test. There is no test for "can re-save P with existing composition C when D inherits P." Adding one would have caught this before merge.


Suggestions

  • ContentTypeEditingServiceBase.cs:506 — descendantPropertyAliases.InvariantContains(alias) resolves to IEnumerable<string>.Contains(value, StringComparer.InvariantCultureIgnoreCase), which iterates the HashSet regardless of its internal structure. Since GetAllDescendantPropertyAliases already lowercases every alias on insertion, descendantPropertyAliases.Contains(alias.ToLowerInvariant()) would use the hash bucket (O(1) vs O(n)).

Request Changes

The re-save regression is a blocker: any content type that (a) has an existing composition and (b) has an inheriting child will fail on every save after this PR lands.

@AndyButland AndyButland left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update @lauraneto. I like the idea. Much better to prevent the selection of a composition that won't be allowed than to reject it once selected on save.

I found a problem though with the logic: any document type that has at least one own property and at least one child can no longer be saved.

Image

So that should be addressed.

I see Can_Reapply_Compositions_For_Content_Type_With_Children uses a property-less composition and property-less parent, so nothing collides, and hence this passes. So we should have a test that re-saves a parent with properties and descendants (which should fail before a fix).

Root cause looks to be that CompositionPropertyTypes is transitive, so a descendant's effective set always contains everything the source pushes down - so the source ends up colliding with itself. Subtracting source.CompositionPropertyTypes from the descendant set looks to be what's needed to fix.

The same self-collision breaks the composition path too: a type with a composition carrying properties plus any descendant now fails to re-save with InvalidComposition.

Other points:

  • Consider unit tests for ContentTypeEditingHelper and ContentTypeServiceExtensions? These could be fast and in-memory, so cheap to run once written.

I've not tested the main fix yet, but will do so once this save issue is resolved.

…s collisions

GetAllDescendantPropertyAliases collected a descendant's full effective
aliases, including whatever it inherits from the type being validated
itself. Re-validating a type's own already-effective compositions or
properties against its descendants then always found its own aliases
reflected back as a false collision, rejecting a plain resave of any
composed-and-inherited-from content type - flagged by review on this PR.

Fixed at the source: subtract the type's own current CompositionPropertyTypes
from the descendant alias set before comparing. ValidateCompositions and
ValidateDescendantPropertyAliases keep checking every requested key on every
save with no persisted/new split, but now only flag aliases reaching a
descendant through some other, genuinely separate path.
Every caller of GetAllDescendantPropertyAliases already compares
case-insensitively (InvariantContains, StringComparer.InvariantCultureIgnoreCase),
so lowercasing aliases inside the method was redundant and just obscured
the original casing. The set now uses an InvariantCultureIgnoreCase comparer
and preserves original casing; comparisons stay case-insensitive everywhere
they already were.
Fast, in-memory unit tests for ContentTypeEditingHelper and
ContentTypeServiceExtensions, per review request: GetAllDescendantPropertyAliases
excluding a source's own composition/property contribution, and
GetAvailableCompositeContentTypes keeping an already-selected composition
Allowed when it carries properties and the source is inherited from.
- ShortStringHelper: static readonly field instead of a get-only property
  that constructed a new DefaultShortStringHelper on every access (CA1822).
- AddPropertyType: narrowed the parameter to ContentType, the concrete type
  actually passed everywhere (CA1859).
- Removed a stray double blank line (SA1507).
@lauraneto
lauraneto marked this pull request as draft August 6, 2026 08:50
@lauraneto
lauraneto marked this pull request as ready for review August 6, 2026 08:50
@claude

claude Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Claude finished @lauraneto's task in 6m 34s —— View job


PR Review

Target: origin/v17/bugfix/23103-compositions-locked-by-inheritance · Based on commit: e385227c

Fixes the composition picker showing candidates as selectable when selecting them would cause a descendant property alias collision. Extracts a shared GetAllDescendantPropertyAliases helper (excluding the source's own contribution to prevent self-collision) used by both GetAvailableCompositeContentTypes (picker) and ValidateDescendantPropertyAliases (save-time). Also removes the "only validate new compositions" early exit from ValidateCompositions now that the self-collision issue in the helper makes that safe.

  • Other changes: Error status for composition-induced descendant alias collision changes from ContentTypeOperationStatus.DuplicatePropertyTypeAlias to ContentTypeOperationStatus.InvalidComposition; consumers (including the management API) already handle both separately with distinct 400 titles ("Invalid composition" vs "Duplicate property type alias"), but any client-side code keying on the specific status string will see a different response.

Suggestions

  • ContentTypeEditingServiceBase.cs:506 — descendantPropertyAliases.InvariantContains(alias) dispatches through the IEnumerable<string> overload (compare.Contains(compareTo, StringComparer.InvariantCultureIgnoreCase)) which iterates the set, O(n). Since the HashSet is already constructed with StringComparer.InvariantCultureIgnoreCase, calling the instance method descendantPropertyAliases.Contains(alias) uses the hash bucket, O(1). With a small property set this won't matter, but the fix is one word:
    var collidesWithDescendants = model.Properties.Select(p => p.Alias).Any(descendantPropertyAliases.Contains);

Approved with Suggestions for improvement

The critical re-save regression identified in the previous round has been addressed by the ExceptWith(source.CompositionPropertyTypes) call in GetAllDescendantPropertyAliases, and the two new integration tests (Can_Reapply_Composition_With_Property_When_It_Has_An_Inheriting_Child, Can_Resave_Own_Property_When_Content_Type_Has_An_Inheriting_Child) pin the fix. The helper logic is correct: composition-pushed aliases that a descendant receives through the source are correctly excluded from the descendant collision set, preventing false positives on re-save while still catching genuinely independent descendant conflicts.

Good to go, but please carefully consider the importance of the suggestions.

@lauraneto
lauraneto requested a review from AndyButland August 6, 2026 15:09
@lauraneto

Copy link
Copy Markdown
Contributor Author

@AndyButland Ups, I had totally missed the findings from Claude.
I have applied the changes and added a few more tests now.

@AndyButland AndyButland left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All looks good to me now @lauraneto. Just one minor naming suggestion. Please go ahead and merge once you've considered that.

I've confirmed the bug I found in the previous review is resolved, and that the improvement provided by this PR is in place - when I try to select compositions, and types that have a property that would clash with one of the current type's descendants, are not available for selection.

Comment thread src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingHelper.cs Outdated
@sonarqubecloud

Copy link
Copy Markdown

@lauraneto
lauraneto merged commit cc510f9 into v17/bugfix/23103-compositions-locked-by-inheritance Aug 19, 2026
23 checks passed
@lauraneto
lauraneto deleted the v17/bugfix/23103-compositions-locked-by-inheritance-2 branch August 19, 2026 14:11
AndyButland added a commit that referenced this pull request Aug 24, 2026
…inherited from (closes #23103) (#23433)

* Allow editing compositions on content types that are inherited from.

* Addressed code review feedback.

* Refresh descendant published types when a composition is added.

* Addressed Sonarqube and Copilot feedback.

* Remove confusing comment.

* Content Types: Also check descendants for property alias collisions when listing available compositions (#23529)

* Content Types: Also check descendants for property alias collisions when listing available compositions

Available-compositions listing only checked ancestor-lock and same-type conflicts, missing the descendant-alias-collision check that save-time validation already enforced. A candidate could show as allowed in the picker and then be rejected on save.

Extracted the shared alias-resolution logic (own aliases, descendant aliases) into ContentTypeEditingHelper so both the listing and the save-time validation use the same source of truth.

* Content Types: Add coverage for descendant property alias collisions

Covers the descendant-collision check in the available-compositions listing (available but not allowed), that an already-in-use composition stays locked for the parent, and that a pre-existing collision on one composition doesn't exempt an unrelated candidate sharing the same alias. Also updates existing status-code assertions from DuplicatePropertyTypeAlias to InvalidComposition now that composition-caused collisions are split from own-property-caused ones.

* Content Types: Re-validate every composition on save, not just newly added ones

ValidateCompositions only checked genuinely new composition keys against
GetAvailableCompositeContentTypes, letting an already-persisted composition
ride along unchecked even if it had since become structurally invalid (e.g.
now colliding with a descendant's property alias). We initially assumed this
exemption was deliberately protecting a legitimate scenario, but tracing it
back to when it was introduced (2fbdc29, before #23433) showed it was
only ever working around isUsing incorrectly treating an inheritance child
as a composition user. isUsing now correctly excludes inheritance children,
so the exemption is no longer needed and every requested composition key -
persisted or new - is validated on every save.

Removed test coverage that only demonstrated the now-removed exemption via
composition states unreachable through the editing service or the tree Move
command (only reachable by bypassing validation directly through
IContentTypeService, e.g. legacy data or raw API/import usage).

* Content Types: Drop the grandfather exemption in ValidateDescendantPropertyAliases

Same reasoning as the ValidateCompositions change: this exemption assumed a
content type could legitimately have an own-property alias colliding with a
descendant's, from before this check existed, and that resaving it shouldn't
retroactively reject that. Tracing the deeper save path shows that scenario
was never actually reachable - IContentTypeService.InternalSaveAsync calls
ValidateLocked on every create/update, unconditionally, which already walks
the full transitive composition/inheritance graph and throws
InvalidCompositionException on any such collision. It has no persisted/new
distinction and predates this check entirely.

Keeping the exemption only made the failure mode worse: if it ever let a
collision through, the save wouldn't succeed anyway, it would just fail
later as an unhandled exception from ValidateLocked instead of a clean
DuplicatePropertyTypeAlias status here.

Also removes ContentTypeEditingHelper.GetAllPropertyAliases, which was only
used to compute the now-removed persisted/new split and has no other caller.

* Content Types: Exclude source's own contribution from descendant alias collisions

GetAllDescendantPropertyAliases collected a descendant's full effective
aliases, including whatever it inherits from the type being validated
itself. Re-validating a type's own already-effective compositions or
properties against its descendants then always found its own aliases
reflected back as a false collision, rejecting a plain resave of any
composed-and-inherited-from content type - flagged by review on this PR.

Fixed at the source: subtract the type's own current CompositionPropertyTypes
from the descendant alias set before comparing. ValidateCompositions and
ValidateDescendantPropertyAliases keep checking every requested key on every
save with no persisted/new split, but now only flag aliases reaching a
descendant through some other, genuinely separate path.

* Content Types: Stop lowercasing descendant aliases

Every caller of GetAllDescendantPropertyAliases already compares
case-insensitively (InvariantContains, StringComparer.InvariantCultureIgnoreCase),
so lowercasing aliases inside the method was redundant and just obscured
the original casing. The set now uses an InvariantCultureIgnoreCase comparer
and preserves original casing; comparisons stay case-insensitive everywhere
they already were.

* Content Types: Add unit tests for the self-collision fix

Fast, in-memory unit tests for ContentTypeEditingHelper and
ContentTypeServiceExtensions, per review request: GetAllDescendantPropertyAliases
excluding a source's own composition/property contribution, and
GetAvailableCompositeContentTypes keeping an already-selected composition
Allowed when it carries properties and the source is inherited from.

* Content Types: Address SonarQube findings on the new test files

- ShortStringHelper: static readonly field instead of a get-only property
  that constructed a new DefaultShortStringHelper on every access (CA1822).
- AddPropertyType: narrowed the parameter to ContentType, the concrete type
  actually passed everywhere (CA1859).
- Removed a stray double blank line (SA1507).

* Rename GetAllDescendantPropertyAliases to GetPropertyAliasesReservedByDescendants

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants