DO NOT MERGE: fix(permissions): pattern-switch resolvePermissionType and stop it throwing on a typeless contentlet (#34154) - #36982
Conversation
…rowing on a typeless contentlet (#34154) resolvePermissionType was an if/else-if chain of thirteen instanceof tests, and the first condition alone cast the same reference three times: if (permissionable instanceof Host || (permissionable instanceof Contentlet && ((Contentlet) permissionable).getStructure() != null && ((Contentlet) permissionable).getStructure().getVelocityVarName() != null && ((Contentlet) permissionable).getStructure().getVelocityVarName().equals("Host"))) It is now a pattern switch: the type test binds the value once, and the extra conditions move into `when` guards. The nested chain for the Identifier fallback moves to its own method, and `case null` replaces the `if (perm != null)` wrapper. Behaviour change, deliberate: a contentlet with no resolvable content type no longer throws. Contentlet.getStructure() returns null when getContentType() is null. The first branch checked for that; the FILEASSET branch right after it did not, and read getStructure().getStructureType() directly. So a typeless contentlet failed the first guard correctly and then NPE'd on the second. Verified by running the original condition verbatim against such a contentlet: NullPointerException: Cannot invoke "Structure.getStructureType()" because the return value of "Contentlet.getStructure()" is null The null check is now in one shared helper, so both call sites get it. Two branches of the original chain are gone, as provably redundant rather than as a judgement call. They mapped a FILEASSET contentlet, and an Event, to Contentlet.class.getCanonicalName() - which is exactly what Contentlet.getPermissionType() already returns (Contentlet.java:1186), and no Contentlet subclass overrides it. Both inputs now reach `default` and produce the same string. The FILEASSET branch was also the one that could throw, so it was spending a crash on a value that was already correct. Case order is load bearing and is documented in the javadoc: Host extends Contentlet, so it has to match first, and the Host-content-type check has to precede the IHTMLPage one. Ordering the other way is not a silent bug - the compiler rejects a dominated case - but only for unguarded patterns, so the comment earns its place. resolvePermissionType widened from private to package-private @VisibleForTesting. It had no direct coverage before. Tests: 11 new unit tests in PermissionBitFactoryImplResolvePermissionTypeTest, covering every branch of the chain plus both trailing overrides. Assets are mocked because instantiating a real Contentlet runs a static initialiser that reaches CDI. Where the expected answer is "the asset's own declared type" the mock declares a sentinel, so a fall-through to `default` is distinguishable from a branch that happened to produce the same value - without that, the two redundancy tests would have asserted a string they themselves stubbed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @fabrizzio-dotCMS's task in 5m 34s —— View job Code ReviewReviewed the pattern- Verified facts
Test coverage — the suite exercises every branch including the deliberately-changed typeless-contentlet path, both dropped-redundant branches (with a sentinel New IssuesNo issues found. The refactor is behavior-preserving except for the one documented, tested change (typeless contentlet resolves to its declared type instead of throwing). The redundant-branch removals and dead- One process note: the title is prefixed • Branch: |
… fallback (#34154) The switch in permissionTypeOfBackingInode held its subject as a Permissionable, but InodeFactory.getInode can only ever hand back an Inode. Widening the local was the only reason five cases compiled at all: Contentlet, Folder and ContentType are classes with no kinship to Inode, so no object could ever be both, and the Permissionable interface they share does not change that — Java has single class inheritance. Those branches were dead in the original if / else if chain too; the pattern switch is what made them visible. Typing the local as Inode is the part that lasts: the compiler now rejects a case that cannot match, instead of leaving it to an IDE inspection. The IHTMLPage branch stays as a defence — it is an interface, so a future Inode subclass could implement it — with a comment saying why it is currently unreachable. The null case folds into the default arm, which already returns the same value. Also reads the content type off Contentlet#getContentType() rather than the deprecated getStructure(), which merely wraps it in a StructureTransformer, and compares base types by enum identity instead of by int. The pinning test follows the accessor the code actually reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Type (#34154) The nested chain that resolves an Identifier through the inode it points at had no coverage at all: reaching it means going through the static InodeFactory.getInode, so every claim about those branches came from reading the code rather than running it. Seven tests intercept that lookup with mockStatic and pin each arm. Two are worth calling out. The IHTMLPage arm is the branch kept as a defence rather than deleted, and no Inode implements that interface today — the mock is built with extraInterfaces to produce the combination at all, which is precisely the shape a future subclass would take. The null arm pins that `case null, default` is load bearing: a switch over patterns throws on a null selector unless a case null says otherwise, and getInode returns null when the lookup fails. The Template and Structure arms are unreachable in production, because InodeFactory throws for those row types instead of returning them. Their tests say so, and pin the mapping for the day that changes. Verified the coverage is not vacuous: removing the IHTMLPage arm fails exactly one test, and the sentinel in the failure message shows the resolver fell through to default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
resolvePermissionTypedecides the key an asset inherits permissions under — whatpermission_referencerows are stored and looked up against. It was anif / else ifchain ofthirteen
instanceoftests, and the first condition alone cast the same reference three times:It is now a pattern
switch: the type test binds the value once, the extra conditions move intowhenguards, the nested
Identifierfallback chain moves to its own method, andcase nullreplaces theif (perm != null)wrapper around it.The behaviour that deliberately changed
A contentlet with no resolvable content type no longer throws.
Contentlet.getStructure()returnsnullwhengetContentType()is null. The first branch checked forthat. The FILEASSET branch right after it did not, and read
getStructure().getStructureType()directly — so a typeless contentlet failed the first guard correctly and then NPE'd on the second.
Verified rather than argued: the original condition, copied verbatim, run against such a contentlet:
The null check now lives in two shared helpers, so both places that read the content type get it.
Two branches removed as provably redundant
Not a judgement call — a checkable fact. Both branches mapped their input to
Contentlet.class.getCanonicalName():EventThat is exactly what
Contentlet.getPermissionType()already returns (Contentlet.java:1186), andno
Contentletsubclass overrides it (verified across the tree). So both branches assigned the valuethe variable already held. They now fall through to
defaultand produce the same string.The FILEASSET branch was also the one that could throw — so it was spending a crash to compute a value
that was already correct.
Case order is load bearing
Documented in the javadoc, because a reviewer will ask:
Host extends Contentlet, so it has to bematched first, and the Host-content-type check has to precede the
IHTMLPageone.Worth noting that getting this wrong is not a silent bug — the compiler rejects a dominated case
(
error: this case label is dominated by a preceding case label). But that protection only applies tounguarded patterns, and several of these cases are guarded, so the comment earns its place.
The
Identifierfallback: five cases that could never matchThe nested chain that moved out to
permissionTypeOfBackingInodeheld its subject as aPermissionable, butInodeFactory.getInodecan only ever hand back anInode(
InodeFactory.java:107). Widening the local was the only reason five of its cases compiled at all:Contentlet, twice — once guarded onHTMLPAGEContentletimplementsPermissionabledirectly; it is not anInodeFolderInodeFactorythrowsDotStateExceptionfor afolderrow before it returns anything (InodeFactory.java:147)ContentTypeInodeJava has single class inheritance, so an object that is both an
Inodeand aFoldercannot exist —sharing the
Permissionableinterface does not change that. These were dead in the originalif / else iftoo; the patternswitchis what made them visible, and an IDE inspection is whatreported them.
Typing the local as
Inodeis the part that lasts, because it moves the guarantee from the IDE to thecompiler:
IHTMLPageis kept as a defensive branch, with a comment saying why it is unreachable today: it is aninterface, so a future
Inodesubclass could implement it — which is also why the compiler stillaccepts it while rejecting the four class patterns above.
case nullfolds into thedefaultarm,which already returned the same value.
Reading the content type off the modern accessor
Both shared helpers now call
Contentlet.getContentType()rather thangetStructure(), which is@Deprecatedand does nothing but wrap that same call in aStructureTransformer(
Contentlet.java:590). Identical null semantics, one hop and one allocation less. Base types arecompared by enum identity (
baseType == contentType.baseType()) instead of through theintbehindthem.
Testing
11 new unit tests, all green —
PermissionBitFactoryImplResolvePermissionTypeTest. The method hadno direct coverage before this PR;
resolvePermissionTypewidened fromprivateto package-private@VisibleForTestingso it could get some.The mocks stub
getContentType()— the accessor the code actually reads — with a mockedContentTypedeclaringvariable()andbaseType().Coverage is one test per branch of the chain plus both trailing overrides (drawn
Template→TemplateLayout,NavResult→ enclosing type), and the typeless-contentlet case that used to throw.Two deliberate choices in the test design:
Contentletruns a static initialiserthat reaches CDI (
OSIndexAPIImpl), which no plain unit test has. Mocking costs nothing here — everyassertion is about which branch the resolver takes, not about how the asset was built.
than the real value. Without that, the two redundancy tests above would have asserted a string they
themselves stubbed. Seeing the sentinel come back proves the resolver reached
defaultinstead ofmatching a branch that happened to produce the same value.
Integration tests — stated plainly
Ran
PermissionBitFactoryImplTest,PermissionAPITest,PermissionAPIIntegrationTest,PermissionBitFactoryImplGetPermittedIdsTest,PermissionBitAPIImplFilterCollectionTest:47 run, 4 errors.
All four appear to be artifacts of running these classes standalone via
-Dit.test=rather than intheir suite, and none of them reach the changed code:
NullPointerExceptioninDwrUtil.getLoggedInUser— DWRWebContextis nullRoleAjax.saveRolePermission→DwrUtil.getLoggedInUser; it never reachesresolvePermissionType. That web context is only set up by the full suite.issue11850— FK violationfk_structure_hostdeleting a test SiteConfirmed by baseline. The same two classes were run against unmodified
main(this branch's sourcefile reverted, the new test set aside) and the identical four tests fail:
issue11850,issue560,issue781,test_templateLayout_parentPermissionableIsHost. Same set, same causes. They arepre-existing and independent of this change — not "probably", measured.
Note that
test_templateLayout_parentPermissionableIsHostsounds like it would exercise theTemplateLayoutoverride this PR touches. It does not get that far: it dies inDwrUtil.getLoggedInUserbefore any permission type is resolved, on
mainand on this branch alike. That override is covered bythe two new unit tests instead.
Breaking Changes
None to any signature. One behavioural change, described above: a typeless contentlet resolves to its
declared type instead of throwing a
NullPointerException.Context
Part of the Devoxx Belgium 2025 Lunch and Learn groundwork (#34154). This is the pattern-matching example
for that talk, and it was picked because it demonstrates the three things at once on real code: the type
pattern removing repeated casts,
whenguards carrying the conditions that used to be&&-chained ontothe
instanceof, and a latent defect that the old shape hid and the restructure surfaced.Note for the talk, and for anyone reading this expecting a Java 25 feature: pattern
switch, recordpatterns and
whenguards are all final since Java 21. They are new to dotCMS only because thiscodebase went from bytecode 11 straight to 25 in a single commit.
This PR fixes: #34154
🤖 Generated with Claude Code