refactor: rework JsonPatch collection-exclusion filter to match FieldFilterService pattern [DHIS2-21852]#24541
Conversation
06884f3 to
81df701
Compare
Local performance + Glowroot verificationRan Gatling results — 132/132 requests OK, 0 KO:
Large-role PATCH latency is in the same band as the empty-role control (actually slightly faster here, just run-order noise) — the membership-size-independence the fix is meant to guarantee. Glowroot trace on a single large-role PATCH ( The one query that does join Net: the actual PATCH logic this PR touches never hydrates |
…FilterService pattern [DHIS2-21852] Addresses review feedback on #24489 (Jan Bernitt): the per-call `.addMixIn(realClass, ...)` + inline mixin + filter-provider wiring in JsonPatchManager.apply() was rebuilt from scratch on every call and didn't follow any existing convention. Reworks it to mirror the codebase's established pattern for the same underlying problem -- skipping a getter during Jackson serialization based on per-call criteria, without invoking it -- already used for the `?fields=` GET path (org.hisp.dhis.fieldfiltering.FieldFilterService/FieldFilterMixin). - New `JsonPatchFilterMixin` (`@JsonFilter`) and `JsonPatchExcludedPropertyFilter` (`SimpleBeanPropertyFilter`), shaped like `FieldFilterMixin`/`FieldFilterSimpleBeanPropertyFilter`. - `JsonPatchManager` caches one mixin-bound `ObjectMapper` copy per entity type (`ConcurrentHashMap<Class<?>, ObjectMapper>`, built lazily via `computeIfAbsent`) instead of rebuilding the mixin binding on every `apply()` call. `findExcludableNonOwnerCollections` (the exclusion rule itself) is unchanged -- it was already schema-driven and generic. - The mixin is scoped per-`realClass`, not bound to `Object.class` globally: `FieldFilterMixin`'s own filter is path-aware, so a global binding is safe for it, but `JsonPatchExcludedPropertyFilter` matches by property name only. A global binding was tried first and found, in review, to silently strip `Sharing.users`/`Sharing.userGroups` (present on every `IdentifiableObject`) whenever a same-named top-level collection was excluded, e.g. on a scalar UserRole PATCH. Scoping per-class makes that cross-type collision impossible. Regression test: `testUserRoleSharingUsersSurviveScalarPatch`. - Answers Jan's second question (should an explicit patch to an excluded property throw?): no -- `findExcludableNonOwnerCollections` already un-excludes a property referenced by a patch path, so it falls back to full (slower, correct) hydration rather than being silently dropped. Replaced the previous `testUserRoleUsersPathPatchDoesNotThrow` (asserted only `assertNotNull`, would pass either way) with `testUserRoleUsersPathPatchFallsBackToFullHydration`, which asserts `Hibernate.isInitialized(...)` actually flips to `true`, mutation-tested against the fallback logic. No functional change to PATCH behavior beyond fixing the Object.class regression introduced and caught within this same review cycle (never released). All existing JsonPatchManagerTest coverage (14 tests) plus the new JsonPatchExcludedPropertyFilterTest (2 tests) pass; UserControllerTest (52 tests, dhis-test-web-api) unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
81df701 to
975d100
Compare
|
|
FF-merged into parent PR #24489 ( This stacked refactor is now part of the main DHIS2-21852 branch. Closing as incorporated. |



Summary
Addresses Jan's review comment on #24489. Targets that PR's branch, not
master-- this is a review-feedback follow-up, not a new independent change.Jan's complaint: the collection-exclusion mechanism in
JsonPatchManager.apply()wired its own bespoke Jackson filter (mixin + filter provider, both rebuilt per call) rather than following any existing convention -- "way too complicated and entangled to be flexible or extendable."This reworks the wiring to mirror the codebase's existing, established pattern for the same problem (skip a getter during Jackson serialization based on per-call criteria, without invoking it):
org.hisp.dhis.fieldfiltering.FieldFilterService/FieldFilterMixin, already used for?fields=.JsonPatchFilterMixin(@JsonFilter) andJsonPatchExcludedPropertyFilter(SimpleBeanPropertyFilter), shaped likeFieldFilterMixin/FieldFilterSimpleBeanPropertyFilter.JsonPatchManagercaches one mixin-boundObjectMappercopy per entity type (ConcurrentHashMap<Class<?>, ObjectMapper>, built lazily viacomputeIfAbsent) instead of rebuilding the binding on everyapply()call.findExcludableNonOwnerCollections(the actual exclusion rule) is unchanged -- it was already schema-driven and generic, notUserRole-specific.realClass, not bound toObject.classglobally.FieldFilterMixin's own filter is path-aware, so a global binding is safe for it; ours matches by property name only. A global binding was tried first here and found, in review, to silently stripSharing.users/Sharing.userGroups(present on everyIdentifiableObject) whenever a same-named top-level collection was excluded -- e.g. a scalarUserRolePATCH would wipesharing.users. Caught before merge, never released. Scoping per-class makes that cross-type collision structurally impossible. Regression test:testUserRoleSharingUsersSurviveScalarPatch.findExcludableNonOwnerCollectionsalready un-excludes a property referenced by a patch path, so it falls back to full (slower, correct) hydration rather than being silently dropped. Replaced the previoustestUserRoleUsersPathPatchDoesNotThrow(asserted onlyassertNotNull, would pass either way) withtestUserRoleUsersPathPatchFallsBackToFullHydration, which assertsHibernate.isInitialized(...)actually flips totrue, mutation-tested against the fallback logic.No functional change to PATCH behavior beyond fixing the
Object.classregression introduced and caught within this same review cycle.Separately (not in this PR): David asked whether
PUThas the same issue -- confirmed it doesn't (AbstractCrudController.putJsonObjectparses the request body into a fresh transient object, never round-trips the persisted entity through Jackson). A dedicated PUT perf test to prove that under real load is tracked as a follow-up.Test plan
mvn spotless:applycleanJsonPatchExcludedPropertyFilterTest(new, unit): 2/2 passingJsonPatchManagerTest(dhis-test-integration): 14/14 passing (13 original +testUserRoleSharingUsersSurviveScalarPatch)UserControllerTest(dhis-test-web-api): 52/52 passing, unaffecteddhis-test-performancemodule:mvn test-compilepasses (GatlingUserRolesPerformanceTestuntouched)Object.classcollision bug, the fallback-not-silently-dropped behavior, and the filter-omission mechanism itself were each confirmed to fail for the right reason when deliberately broken, then revertedAI Assisted
🤖 Generated with Claude Code