Repository navigation
Content Types: Also check descendants for property alias collisions when listing available compositions - #23529
Conversation
…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.
|
Claude finished @lauraneto's task in 9m 51s —— View job PR ReviewTarget: Extracts a shared
Critical
Root cause. return ContentTypeComposition
.SelectMany(x => x.CompositionPropertyTypes) // recursive
.Union(PropertyTypes);So for child
Concrete repro:
Why the removed exemption was still load-bearing. The PR description argues the exemption "no longer protects anything reachable" because the Suggested fix. In 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 Missing test. There is no test for "can re-save Suggestions
Request ChangesThe 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
left a comment
There was a problem hiding this comment.
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.
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
ContentTypeEditingHelperandContentTypeServiceExtensions? 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).
|
Claude finished @lauraneto's task in 6m 34s —— View job PR ReviewTarget: Fixes the composition picker showing candidates as selectable when selecting them would cause a descendant property alias collision. Extracts a shared
Suggestions
Approved with Suggestions for improvementThe critical re-save regression identified in the previous round has been addressed by the Good to go, but please carefully consider the importance of the suggestions. |
|
@AndyButland Ups, I had totally missed the findings from Claude. |
AndyButland
left a comment
There was a problem hiding this comment.
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.
|
cc510f9
into
v17/bugfix/23103-compositions-locked-by-inheritance
…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>



Summary
This PR targets
v17/bugfix/23103-compositions-locked-by-inheritance(#23433) directly, notv17/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 isAllowed. 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.ContentTypeEditingHelperso the listing and the save-time validation read from the same source of truth.GetAvailableCompositeContentTypesnow also excludes candidates that would introduce a descendant property alias collision.ValidateCompositionsonly re-validated newly-added composition keys, skipping already-persisted ones entirely whenever nothing new was being added. Tracing that back, it was a workaround (2fbdc291c9e, pre-dating Content Types: Allow editing compositions on document types that are inherited from (closes #23103) #23433) forisUsingincorrectly treating a tree-inheritance child as a composition user - a bug Content Types: Allow editing compositions on document types that are inherited from (closes #23103) #23433 already fixed at the root by excludingx.ParentId == sourceId. With that fixed, the exemption no longer protects anything reachable, so every requested composition key is now re-validated on every save, persisted or new. This is new to this PR, not a change to anything Content Types: Allow editing compositions on document types that are inherited from (closes #23103) #23433 introduced.ValidateDescendantPropertyAliases(added by Content Types: Allow editing compositions on document types that are inherited from (closes #23103) #23433) had the same "only check new aliases" shape, apparently to avoid retroactively rejecting configurations that pre-dated the check. Turns out that scenario was never reachable either:IContentTypeService.InternalSaveAsynccallsValidateLockedunconditionally on every save, independently walking the full transitive composition graph and throwingInvalidCompositionExceptionon the same collision - predating this check entirely and with no persisted/new distinction of its own. Removed the exemption here too. Unlike the point above, this does adjust behaviour Content Types: Allow editing compositions on document types that are inherited from (closes #23103) #23433 introduced, flagging explicitly for Andy.IContentTypeService).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, andMemberTypeEditingServiceTestssuites 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:
GetComposedOfis 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.cmsContentType2ContentTypeedge'sparentContentTypeIdagainst the child's currentumbracoNode.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 (Allowedcount 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.