Skip to content

Commit fd1f288

Browse files
fix: detect @PropertyTransformer on read-only derived properties [DHIS2-21872] (#24520)
* fix: detect @PropertyTransformer on read-only derived properties [DHIS2-21872] PropertyPropertyIntrospector.updatePropertyTypes() only read the @PropertyTransformer annotation inside the isWritable() branch. A property whose getter is annotated but has no matching setter (e.g. UserRole.getUsers(), computed from the separately-named `members` field) is read-only, so its annotation was silently dropped and Property.hasPropertyTransformer() returned false at runtime even though the annotation is present in source. Move the annotation read outside the writability gate, since whether a property is serialized via a custom transformer is orthogonal to whether it can be written to. Found while live-validating DHIS2-21856/PR #24519: with that fix alone, GET /api/userRoles/{id}?fields=users[*] against an 83,385-member role still took 139.7s / ~583k queries because hasPropertyTransformer() never saw UserRole.users' annotation. With this fix applied, the same request drops to 5.76s. * fix: apply @Property annotation regardless of writability too [DHIS2-21872] Per review feedback from @jbee: initFromPropertyAnnotation() was also gated behind isWritable(), but most of @Property's attributes (type override, required/persisted/owner flags, persistedAs aliasing) describe the property itself and apply equally to read-only properties or aliases, not just writable ones. Snapshot writability before calling initFromPropertyAnnotation(), since that call can itself flip it via access = READ_ONLY/WRITE_ONLY, and range/min-max defaults should still be judged against the pre-annotation writability (matches existing behavior for e.g. Attribute.getObjectTypes(), which has a real setter overridden to read-only via the annotation). Added regression coverage: a read-only property now picks up persistedAs() (previously silently ignored), and a writable property whose annotation overrides it to read-only still gets range/min-max defaults applied (unchanged behavior).
1 parent 0cea25c commit fd1f288

2 files changed

Lines changed: 144 additions & 3 deletions

File tree

dhis-2/dhis-services/dhis-service-schema/src/main/java/org/hisp/dhis/schema/introspection/PropertyPropertyIntrospector.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,29 @@ private static void updatePropertyTypes(Property property) {
8888
property.setItemPropertyType(getPropertyType(property.getItemKlass()));
8989
}
9090

91-
if (property.isWritable()) {
92-
initFromPropertyAnnotation(property);
93-
initFromPropertyTransformerAnnotation(property);
91+
// Snapshot before initFromPropertyAnnotation, which may itself flip writability (via
92+
// access = READ_ONLY/WRITE_ONLY) — range/min-max only make sense relative to whether the
93+
// property was actually settable prior to that annotation-driven override.
94+
boolean writable = property.isWritable();
95+
96+
// Independent of writability: most of @Property's attributes (type override, required/
97+
// persisted/owner flags, persistedAs aliasing) describe the property itself, not whether it
98+
// can be written to, so they apply equally to read-only properties and aliases (DHIS2-21872).
99+
initFromPropertyAnnotation(property);
100+
101+
if (writable) {
94102
initFromPropertyRangeAnnotation(property);
95103
ensureMinMaxDefaults(property);
96104
} else {
97105
property.setMin(null);
98106
property.setMax(null);
99107
}
108+
109+
// Independent of writability: a read-only, computed getter (e.g. UserRole.getUsers(), derived
110+
// from a differently-named backing field with its own setter) can still carry
111+
// @PropertyTransformer, since it describes the serialized shape, not whether the property can
112+
// be written to (DHIS2-21872).
113+
initFromPropertyTransformerAnnotation(property);
100114
}
101115

102116
private static void ensureMinMaxDefaults(Property property) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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.schema.introspection;
31+
32+
import static org.junit.jupiter.api.Assertions.assertEquals;
33+
import static org.junit.jupiter.api.Assertions.assertFalse;
34+
import static org.junit.jupiter.api.Assertions.assertNotNull;
35+
import static org.junit.jupiter.api.Assertions.assertTrue;
36+
37+
import com.fasterxml.jackson.annotation.JsonProperty;
38+
import java.util.HashMap;
39+
import java.util.Map;
40+
import org.hisp.dhis.schema.Property;
41+
import org.hisp.dhis.schema.annotation.Property.Access;
42+
import org.hisp.dhis.user.UserRole;
43+
import org.junit.jupiter.api.Test;
44+
45+
/**
46+
* Uses the real {@link JacksonPropertyIntrospector} pipeline (not a hand-built {@link Property}) to
47+
* reproduce the exact reflection-based state that {@link PropertyPropertyIntrospector} sees in
48+
* production, since a hand-built {@code Property} with {@code setPropertyTransformer(...)} called
49+
* directly would bypass the bug entirely.
50+
*/
51+
class PropertyPropertyIntrospectorTest {
52+
53+
@Test
54+
void detectsPropertyTransformerOnReadOnlyDerivedGetter() {
55+
Map<String, Property> properties = new HashMap<>();
56+
new JacksonPropertyIntrospector().introspect(UserRole.class, properties);
57+
58+
Property users = properties.get("users");
59+
60+
// UserRole.getUsers() is computed from the "members" field and has no matching setUsers(),
61+
// so it must be read-only.
62+
assertFalse(users.isWritable());
63+
64+
new PropertyPropertyIntrospector().introspect(UserRole.class, properties);
65+
66+
// ... yet it still carries @PropertyTransformer(UserPropertyTransformer.class) (DHIS2-21872).
67+
assertTrue(users.hasPropertyTransformer());
68+
}
69+
70+
@Test
71+
void detectsPropertyAnnotationOnReadOnlyProperty() {
72+
Map<String, Property> properties = new HashMap<>();
73+
new JacksonPropertyIntrospector().introspect(ReadOnlyAliasedFixture.class, properties);
74+
75+
Property displayAlias = properties.get("displayAlias");
76+
77+
// No setDisplayAlias(...), so this is read-only.
78+
assertFalse(displayAlias.isWritable());
79+
80+
new PropertyPropertyIntrospector().introspect(ReadOnlyAliasedFixture.class, properties);
81+
82+
// ... yet persistedAs() still aliases the field name, since it describes the property, not
83+
// whether it can be written to (DHIS2-21872).
84+
assertEquals("internalName", displayAlias.getFieldName());
85+
}
86+
87+
@Test
88+
void rangeDefaultsUnaffectedWhenPropertyAnnotationOverridesWritableProperty() {
89+
Map<String, Property> properties = new HashMap<>();
90+
new JacksonPropertyIntrospector().introspect(ReadOnlyOverrideFixture.class, properties);
91+
92+
Property computed = properties.get("computed");
93+
94+
// Has a real setter, so writable prior to the @Property(access = READ_ONLY) override below.
95+
assertTrue(computed.isWritable());
96+
97+
new PropertyPropertyIntrospector().introspect(ReadOnlyOverrideFixture.class, properties);
98+
99+
// The annotation flips writability for API purposes ...
100+
assertFalse(computed.isWritable());
101+
// ... but range/min-max defaults are still applied, since that decision is snapshotted from
102+
// before the annotation-driven override, matching pre-existing behavior for properties like
103+
// Attribute.getObjectTypes() (real setter + @Property(access = READ_ONLY)).
104+
assertNotNull(computed.getMin());
105+
assertNotNull(computed.getMax());
106+
}
107+
108+
private static class ReadOnlyAliasedFixture {
109+
@JsonProperty
110+
@org.hisp.dhis.schema.annotation.Property(persistedAs = "internalName")
111+
public String getDisplayAlias() {
112+
return "computed";
113+
}
114+
}
115+
116+
private static class ReadOnlyOverrideFixture {
117+
@JsonProperty
118+
@org.hisp.dhis.schema.annotation.Property(access = Access.READ_ONLY)
119+
public String getComputed() {
120+
return "computed";
121+
}
122+
123+
public void setComputed(String value) {
124+
// real setter; @Property(access = READ_ONLY) overrides this for API purposes only
125+
}
126+
}
127+
}

0 commit comments

Comments
 (0)