Skip to content

Commit 81df701

Browse files
refactor: rework JsonPatch collection-exclusion filter to match FieldFilterService 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>
1 parent f12cfba commit 81df701

5 files changed

Lines changed: 318 additions & 25 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright (c) 2004-2022, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.jsonpatch;
31+
32+
import com.fasterxml.jackson.core.JsonGenerator;
33+
import com.fasterxml.jackson.databind.SerializerProvider;
34+
import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
35+
import com.fasterxml.jackson.databind.ser.PropertyWriter;
36+
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
37+
import java.util.Set;
38+
39+
/**
40+
* PropertyFilter that omits properties named in {@code excluded} without invoking their getter, via
41+
* {@link PropertyWriter#serializeAsOmittedField}. Mirrors {@code
42+
* org.hisp.dhis.fieldfiltering.FieldFilterSimpleBeanPropertyFilter}'s shape, simplified to a flat
43+
* excluded-name set (no path-context tracking needed here).
44+
*
45+
* @author Jason Pickering
46+
*/
47+
public class JsonPatchExcludedPropertyFilter extends SimpleBeanPropertyFilter {
48+
private final Set<String> excluded;
49+
50+
public JsonPatchExcludedPropertyFilter(Set<String> excluded) {
51+
this.excluded = excluded;
52+
}
53+
54+
@Override
55+
protected boolean include(final BeanPropertyWriter writer) {
56+
return true;
57+
}
58+
59+
@Override
60+
protected boolean include(final PropertyWriter writer) {
61+
return true;
62+
}
63+
64+
@Override
65+
public void serializeAsField(
66+
Object pojo, JsonGenerator jgen, SerializerProvider provider, PropertyWriter writer)
67+
throws Exception {
68+
if (excluded.contains(writer.getName())) {
69+
if (!jgen.canOmitFields()) {
70+
writer.serializeAsOmittedField(pojo, jgen, provider);
71+
}
72+
} else {
73+
writer.serializeAsField(pojo, jgen, provider);
74+
}
75+
}
76+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright (c) 2004-2022, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.jsonpatch;
31+
32+
import com.fasterxml.jackson.annotation.JsonFilter;
33+
34+
/**
35+
* Bound per patched entity type ({@code realClass}), not {@code Object.class} -- see {@link
36+
* JsonPatchManager}'s {@code patchMapperCache} field for why a global binding is wrong: {@code
37+
* JsonPatchExcludedPropertyFilter} matches by property name only, so binding this mixin to {@code
38+
* Object.class} would apply the exclusion filter to every nested object in the graph too (e.g.
39+
* {@code org.hisp.dhis.user.sharing.Sharing.users}, which collides by name with {@code
40+
* UserRole.users}) and silently strip unrelated data. {@code JsonPatchManager} builds one
41+
* mixin-bound {@code ObjectMapper} copy per distinct {@code realClass}, lazily, and caches it.
42+
*
43+
* <p>Solves the same underlying problem as {@code org.hisp.dhis.fieldfiltering.FieldFilterMixin}
44+
* (skip a getter during Jackson serialization based on per-call criteria, without invoking it) for
45+
* the {@code ?fields=} GET path -- but that mixin's filter is path-aware, which is what makes its
46+
* own {@code Object.class}-wide binding safe; this one is not, so it must stay scoped per-class.
47+
*
48+
* @author Jason Pickering
49+
*/
50+
@JsonFilter(JsonPatchFilterMixin.ID)
51+
public interface JsonPatchFilterMixin {
52+
/** Single source of truth for the filter id -- see {@link JsonPatchManager}'s usage. */
53+
String ID = "json-patch-collection-filter";
54+
}

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/jsonpatch/JsonPatchManager.java

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,16 @@
3232
import static org.hisp.dhis.schema.DefaultSchemaService.safeInvoke;
3333
import static org.hisp.dhis.util.JsonUtils.jsonToObject;
3434

35-
import com.fasterxml.jackson.annotation.JsonFilter;
3635
import com.fasterxml.jackson.databind.JsonNode;
3736
import com.fasterxml.jackson.databind.ObjectMapper;
3837
import com.fasterxml.jackson.databind.node.ArrayNode;
3938
import com.fasterxml.jackson.databind.node.ObjectNode;
40-
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
4139
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
4240
import java.util.Collection;
4341
import java.util.HashSet;
42+
import java.util.Map;
4443
import java.util.Set;
44+
import java.util.concurrent.ConcurrentHashMap;
4545
import java.util.stream.Collectors;
4646
import org.hisp.dhis.common.BaseIdentifiableObject;
4747
import org.hisp.dhis.common.EmbeddedObject;
@@ -65,10 +65,32 @@
6565
*/
6666
@Service
6767
public class JsonPatchManager {
68-
private static final String JSON_PATCH_FILTER_ID = "jsonPatchFilter";
6968

7069
private final ObjectMapper jsonMapper;
7170

71+
/**
72+
* Cache of per-entity-type {@code ObjectMapper} copies with {@link JsonPatchFilterMixin} bound to
73+
* that type only -- NOT {@code Object.class}. Binding the mixin globally would apply the
74+
* exclusion filter to every nested object in the serialized graph too: {@code Sharing} (present
75+
* on every {@code IdentifiableObject} via {@code BaseIdentifiableObject.getSharing()}) has its
76+
* own {@code @JsonProperty} field literally named {@code users}, so a global binding would
77+
* silently strip {@code sharing.users}/{@code sharing.userGroups} whenever a same-named top-level
78+
* collection (e.g. {@code UserRole.users}) is excluded -- confirmed as a real bug during review,
79+
* not a hypothetical. Scoping to {@code realClass} makes that specific cross-type collision
80+
* impossible: only {@code realClass} carries {@code @JsonFilter}, so a differently-typed nested
81+
* object (e.g. {@code Sharing}) is never affected by another type's exclusions. (A
82+
* self-referential {@code realClass}, e.g. a nested {@code OrganisationUnit} under another {@code
83+
* OrganisationUnit}, does re-carry the filter -- but the excluded set only ever names {@code
84+
* realClass}'s own non-owner collections, so re-applying it at any depth is safe by the same
85+
* owner/non-owner invariant that makes dropping them at the root safe.) Built lazily, once per
86+
* distinct {@code realClass} ever patched, then reused -- mirrors {@code
87+
* org.hisp.dhis.fieldfiltering.FieldFilterSimpleBeanPropertyFilter}'s own {@code
88+
* ALWAYS_EXPAND_CACHE} pattern for the same "compute once per Class" idiom. Per-call code (see
89+
* {@link #toJsonNode}) only attaches a fresh {@link SimpleFilterProvider}; it does not rebuild
90+
* the cached entry on every patch.
91+
*/
92+
private final Map<Class<?>, ObjectMapper> patchMapperCache = new ConcurrentHashMap<>();
93+
7294
private final SchemaService schemaService;
7395

7496
public JsonPatchManager(ObjectMapper jsonMapper, SchemaService schemaService) {
@@ -87,7 +109,9 @@ public JsonPatchManager(ObjectMapper jsonMapper, SchemaService schemaService) {
87109
* (for example {@code OrganisationUnit.leaf}) are also omitted when unreferenced, because their
88110
* getters can force-initialize inverse lazy collections. Skipping avoids initializing lazy
89111
* Hibernate collections such as {@code UserRole.members} during scalar PATCH /userRoles (slow
90-
* PATCH /userRoles).
112+
* PATCH /userRoles). A patch path that explicitly targets an otherwise-excludable property (e.g.
113+
* {@code /users}) removes it from the excluded set, so that property still falls back to full
114+
* (slower, but correct) hydration -- it is never silently dropped.
91115
*
92116
* @param patch JsonPatch object with the operations it should apply.
93117
* @param object Jackson Object to apply the patch to.
@@ -165,24 +189,26 @@ private static boolean isReferencedByPatch(Property property, Set<String> patche
165189
&& patchedPaths.contains(property.getCollectionName()));
166190
}
167191

192+
/**
193+
* Builds the JSON tree for {@code object}, skipping getters for properties in {@code excluded}.
194+
* Looks up (or lazily builds) {@code realClass}'s cached mapper from {@link #patchMapperCache};
195+
* only attaches a fresh, per-call {@link SimpleFilterProvider} -- the expensive
196+
* mixin-binding/{@code copy()} happens at most once per distinct {@code realClass}, not on every
197+
* patch.
198+
*/
168199
private JsonNode toJsonNode(Object object, Class<?> realClass, Set<String> excluded) {
169200
if (excluded.isEmpty()) {
170201
return jsonMapper.valueToTree(object);
171202
}
172203

173-
// Per-call mapper copy with a mixin bound to realClass only, so nested
174-
// objects serialize unchanged and excluded getters (lazy collections) are
175-
// never invoked.
176204
ObjectMapper patchMapper =
177-
jsonMapper
178-
.copy()
179-
.addMixIn(realClass, JsonPatchFilterMixin.class)
180-
.setFilterProvider(
181-
new SimpleFilterProvider()
182-
.addFilter(
183-
JSON_PATCH_FILTER_ID,
184-
SimpleBeanPropertyFilter.serializeAllExcept(excluded)));
185-
return patchMapper.valueToTree(object);
205+
patchMapperCache.computeIfAbsent(
206+
realClass, cls -> jsonMapper.copy().addMixIn(cls, JsonPatchFilterMixin.class));
207+
208+
SimpleFilterProvider filterProvider =
209+
new SimpleFilterProvider()
210+
.addFilter(JsonPatchFilterMixin.ID, new JsonPatchExcludedPropertyFilter(excluded));
211+
return patchMapper.copy().setFilterProvider(filterProvider).valueToTree(object);
186212
}
187213

188214
private <T> void handleCollectionUpdates(
@@ -248,8 +274,4 @@ private void validatePatchPath(JsonPatch patch, Schema schema) throws JsonPatchE
248274
}
249275
}
250276
}
251-
252-
/** Mixin that attaches the per-call json-patch property filter to the root entity class only. */
253-
@JsonFilter(JSON_PATCH_FILTER_ID)
254-
private static final class JsonPatchFilterMixin {}
255277
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright (c) 2004-2022, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.jsonpatch;
31+
32+
import static org.junit.jupiter.api.Assertions.assertFalse;
33+
import static org.junit.jupiter.api.Assertions.assertTrue;
34+
35+
import com.fasterxml.jackson.databind.JsonNode;
36+
import com.fasterxml.jackson.databind.ObjectMapper;
37+
import com.fasterxml.jackson.databind.module.SimpleModule;
38+
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
39+
import java.util.List;
40+
import java.util.Set;
41+
import org.junit.jupiter.api.Test;
42+
43+
/**
44+
* @author Jason Pickering
45+
*/
46+
class JsonPatchExcludedPropertyFilterTest {
47+
48+
/** Getter throws if invoked, so the test fails loudly if the filter ever calls it. */
49+
static class SampleBean {
50+
public String getId() {
51+
return "abc";
52+
}
53+
54+
public List<String> getUsers() {
55+
throw new UnsupportedOperationException("users getter must not be invoked when excluded");
56+
}
57+
}
58+
59+
@Test
60+
void excludedPropertyGetterIsNeverInvokedAndOmittedFromOutput() {
61+
ObjectMapper mapper = new ObjectMapper();
62+
SimpleModule module = new SimpleModule();
63+
module.setMixInAnnotation(Object.class, JsonPatchFilterMixin.class);
64+
mapper.registerModule(module);
65+
66+
SimpleFilterProvider filterProvider =
67+
new SimpleFilterProvider()
68+
.addFilter(
69+
"json-patch-collection-filter",
70+
new JsonPatchExcludedPropertyFilter(Set.of("users")));
71+
72+
JsonNode node = mapper.copy().setFilterProvider(filterProvider).valueToTree(new SampleBean());
73+
74+
assertTrue(node.has("id"), "non-excluded property must still be serialized");
75+
assertFalse(node.has("users"), "excluded property must not appear in output");
76+
}
77+
78+
@Test
79+
void nonExcludedPropertiesAreUnaffected() {
80+
ObjectMapper mapper = new ObjectMapper();
81+
SimpleModule module = new SimpleModule();
82+
module.setMixInAnnotation(Object.class, JsonPatchFilterMixin.class);
83+
mapper.registerModule(module);
84+
85+
SimpleFilterProvider filterProvider =
86+
new SimpleFilterProvider()
87+
.addFilter(
88+
"json-patch-collection-filter", new JsonPatchExcludedPropertyFilter(Set.of()));
89+
90+
JsonNode node = mapper.copy().setFilterProvider(filterProvider).valueToTree(new NoUsersBean());
91+
92+
assertTrue(node.has("id"));
93+
}
94+
95+
static class NoUsersBean {
96+
public String getId() {
97+
return "abc";
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)