Skip to content

Commit 9f459b0

Browse files
Enhance JsonViewDescriptorDeserializer and JsonViewDescriptorSerializer to support nested dot-path fields for improved JSON serialization and deserialization
1 parent d9370dd commit 9f459b0

2 files changed

Lines changed: 245 additions & 14 deletions

File tree

platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,24 +76,115 @@ public Object deserialize(JsonParser jp, DeserializationContext ctxt) {
7676

7777
private Object parseNode(Class<?> type, JsonNode node, ViewDescriptor descriptor) {
7878
Object object = ObjectOperations.newInstance(type);
79+
80+
// Group fields by their root prefix (part before the first dot).
81+
// Fields without a dot are stored under their own name as key.
82+
Map<String, List<Field>> groups = new LinkedHashMap<>();
7983
for (Field field : Viewers.getFields(descriptor)) {
84+
String name = field.getName();
85+
String root = name.contains(".") ? name.substring(0, name.indexOf('.')) : name;
86+
groups.computeIfAbsent(root, k -> new ArrayList<>()).add(field);
87+
}
88+
89+
for (Map.Entry<String, List<Field>> entry : groups.entrySet()) {
90+
List<Field> groupFields = entry.getValue();
91+
boolean isPathGroup = groupFields.size() > 1 || groupFields.getFirst().getName().contains(".");
92+
93+
if (isPathGroup) {
94+
// Navigate the JsonNode by root key and recursively set nested values
95+
JsonNode groupNode = node.get(entry.getKey());
96+
if (groupNode != null) {
97+
deserializePathGroup(object, groupFields, entry.getKey(), groupNode);
98+
}
99+
} else {
100+
Field field = groupFields.getFirst();
101+
PropertyInfo fieldInfo = field.getPropertyInfo();
102+
if (fieldInfo == null || fieldInfo.isAnnotationPresent(JsonIgnore.class)) {
103+
continue;
104+
}
105+
JsonNode fieldNode = node.get(field.getName());
106+
if (fieldNode == null) {
107+
continue;
108+
}
109+
if (fieldInfo.isCollection()) {
110+
processCollectionField(object, field, fieldInfo, fieldNode, type);
111+
} else {
112+
setSimpleField(object, field.getName(), fieldInfo, fieldNode);
113+
}
114+
}
115+
}
116+
return object;
117+
}
118+
119+
/**
120+
* Recursively traverses a group of dot-path fields and sets their values on the target object.
121+
* <p>
122+
* For example, given fields {@code category.id}, {@code category.name}, {@code category.type.name}
123+
* and a {@code groupNode} that is the {@code "category"} JSON object, this method:
124+
* <ul>
125+
* <li>Sets {@code object.category.id} from {@code groupNode.id}</li>
126+
* <li>Sets {@code object.category.name} from {@code groupNode.name}</li>
127+
* <li>Recurses into {@code groupNode.type} for {@code category.type.name}</li>
128+
* </ul>
129+
*
130+
* @param object the root bean being populated
131+
* @param fields fields whose names start with the current prefix (full original dot-path names)
132+
* @param prefix the dot-path prefix consumed so far (e.g. {@code "category"})
133+
* @param currentNode the JsonNode corresponding to {@code prefix}
134+
*/
135+
private void deserializePathGroup(Object object, List<Field> fields, String prefix, JsonNode currentNode) {
136+
// Separate leaf fields from those that need another level of nesting.
137+
// We strip the current prefix (e.g. "category") using its length to correctly
138+
// handle out-of-order fields and deep paths regardless of dot position.
139+
Map<String, List<Field>> subGroups = new LinkedHashMap<>();
140+
List<Field> leafFields = new ArrayList<>();
141+
142+
for (Field field : fields) {
143+
String fullName = field.getName();
144+
// Strip the current prefix: "category.type.name" with prefix "category" → "type.name"
145+
String remainder = fullName.length() > prefix.length() + 1
146+
? fullName.substring(prefix.length() + 1)
147+
: fullName;
148+
if (remainder.contains(".")) {
149+
// Still nested — group by the next segment (e.g. "type" from "type.name")
150+
String nextRoot = remainder.substring(0, remainder.indexOf('.'));
151+
subGroups.computeIfAbsent(nextRoot, k -> new ArrayList<>()).add(field);
152+
} else {
153+
// Leaf at this level (e.g. "id", "name")
154+
leafFields.add(field);
155+
}
156+
}
157+
158+
// Set leaf values using the full dot-path on the root object
159+
for (Field field : leafFields) {
160+
String fullName = field.getName(); // e.g. "category.id"
161+
// The JSON key is the last segment of the full path
162+
String subName = fullName.contains(".") ? fullName.substring(fullName.lastIndexOf('.') + 1) : fullName;
80163
PropertyInfo fieldInfo = field.getPropertyInfo();
81-
if (fieldInfo.isAnnotationPresent(JsonIgnore.class)) {
164+
if (fieldInfo == null || fieldInfo.isAnnotationPresent(JsonIgnore.class)) {
82165
continue;
83166
}
84-
85-
JsonNode fieldNode = node.get(field.getName());
86-
if (fieldNode == null) {
167+
JsonNode leafNode = currentNode.get(subName);
168+
if (leafNode == null) {
87169
continue;
88170
}
171+
Object fieldValue = getNodeValue(fieldInfo, leafNode);
172+
try {
173+
// invokeSetMethod supports dot-notation and auto-creates null intermediate objects
174+
ObjectOperations.invokeSetMethod(object, fullName, fieldValue);
175+
} catch (ReflectionException e) {
176+
LOGGER.warn("Cannot parse json path field " + fullName + " = " + fieldValue + ": " + e.getMessage());
177+
}
178+
}
89179

90-
if (fieldInfo.isCollection()) {
91-
processCollectionField(object, field, fieldInfo, fieldNode, type);
92-
} else {
93-
setSimpleField(object, field.getName(), fieldInfo, fieldNode);
180+
// Recurse into sub-groups, passing the extended prefix and the matching sub-node
181+
for (Map.Entry<String, List<Field>> sub : subGroups.entrySet()) {
182+
String nextRoot = sub.getKey();
183+
JsonNode subNode = currentNode.get(nextRoot);
184+
if (subNode != null) {
185+
deserializePathGroup(object, sub.getValue(), prefix + "." + nextRoot, subNode);
94186
}
95187
}
96-
return object;
97188
}
98189

99190
private void processCollectionField(Object object, Field field, PropertyInfo fieldInfo,

platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorSerializer.java

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@
4242
import java.text.DateFormat;
4343
import java.text.SimpleDateFormat;
4444
import java.time.temporal.TemporalAccessor;
45+
import java.util.ArrayList;
4546
import java.util.Collection;
4647
import java.util.Date;
48+
import java.util.LinkedHashMap;
49+
import java.util.List;
4750
import java.util.Map;
4851
import java.util.concurrent.ConcurrentHashMap;
4952

@@ -81,20 +84,157 @@ public void serialize(Object value, JsonGenerator gen, SerializationContext prov
8184
if (id != null) {
8285
writeField(gen, "id", id);
8386
}
84-
// writeField(gen, "name", value.toString());
8587

88+
// Group fields by their root prefix (part before the first dot).
89+
// Fields without a dot are stored under their own name as key.
90+
Map<String, List<Field>> groups = new LinkedHashMap<>();
8691
for (Field field : Viewers.getFields(viewDescriptor)) {
87-
PropertyInfo fieldInfo = field.getPropertyInfo();
88-
if (field.isCollection() && fieldInfo != null) {
89-
serializeCollectionField(field, fieldInfo, value, gen, provider);
92+
String name = field.getName();
93+
String root = name.contains(".") ? name.substring(0, name.indexOf('.')) : name;
94+
groups.computeIfAbsent(root, k -> new ArrayList<>()).add(field);
95+
}
96+
97+
for (Map.Entry<String, List<Field>> entry : groups.entrySet()) {
98+
List<Field> groupFields = entry.getValue();
99+
boolean isPathGroup = groupFields.size() > 1 || groupFields.get(0).getName().contains(".");
100+
101+
if (isPathGroup) {
102+
// All fields in this group are path-based; render them as a nested object
103+
serializePathGroup(entry.getKey(), groupFields, value, gen, provider);
90104
} else {
91-
serializeSimpleField(field, fieldInfo, value, gen);
105+
Field field = groupFields.get(0);
106+
PropertyInfo fieldInfo = field.getPropertyInfo();
107+
if (field.isCollection() && fieldInfo != null) {
108+
serializeCollectionField(field, fieldInfo, value, gen, provider);
109+
} else {
110+
serializeSimpleField(field, fieldInfo, value, gen);
111+
}
92112
}
93113
}
94114

95115
gen.writeEndObject();
96116
}
97117

118+
/**
119+
* Writes a group of dot-path fields as a nested JSON object, recursively.
120+
* Supports arbitrarily deep paths, e.g.:
121+
* <pre>
122+
* category.id, category.name, category.type.name →
123+
* "category": { "id": 1, "name": "...", "type": { "name": "..." } }
124+
* </pre>
125+
*
126+
* @param rootName the JSON key to write (current nesting level)
127+
* @param fields the fields whose names begin with rootName (full original paths)
128+
* @param value the root bean being serialized
129+
* @param pathSoFar the dot-path prefix already consumed (used to read values from the root bean)
130+
* @param gen the JSON generator
131+
*/
132+
private void serializePathGroup(String rootName, List<Field> fields, Object value,
133+
String pathSoFar, JsonGenerator gen) {
134+
// Split fields into: leaf fields (no more dots after stripping rootName)
135+
// and sub-groups (need another nesting level).
136+
Map<String, List<Field>> subGroups = new LinkedHashMap<>();
137+
List<Field> leafFields = new ArrayList<>();
138+
139+
for (Field field : fields) {
140+
if (!field.isVisible()) {
141+
continue;
142+
}
143+
String fullName = field.getName();
144+
// Strip the current root prefix to get the remainder
145+
String remainder = fullName.contains(".") ? fullName.substring(fullName.indexOf('.') + 1) : fullName;
146+
if (remainder.contains(".")) {
147+
// Still nested — group by the next segment
148+
String nextRoot = remainder.substring(0, remainder.indexOf('.'));
149+
subGroups.computeIfAbsent(nextRoot, k -> new ArrayList<>()).add(field);
150+
} else {
151+
leafFields.add(field);
152+
}
153+
}
154+
155+
// Only open the object if there is something to write
156+
boolean hasLeafValues = leafFields.stream().anyMatch(f -> {
157+
try {
158+
return ObjectOperations.invokeGetMethod(value, f.getName()) != null;
159+
} catch (Exception e) {
160+
return false;
161+
}
162+
});
163+
boolean hasSubGroups = !subGroups.isEmpty();
164+
165+
if (!hasLeafValues && !hasSubGroups) {
166+
return;
167+
}
168+
169+
gen.writeObjectPropertyStart(rootName);
170+
171+
// Write leaf fields
172+
for (Field field : leafFields) {
173+
String fullName = field.getName();
174+
String subName = fullName.contains(".") ? fullName.substring(fullName.lastIndexOf('.') + 1) : fullName;
175+
try {
176+
Object fieldValue = ObjectOperations.invokeGetMethod(value, fullName);
177+
if (fieldValue != null) {
178+
writeField(gen, subName, fieldValue);
179+
}
180+
} catch (Exception e) {
181+
LOGGER.warn("Cannot write path field " + fullName + " to json: " + e.getMessage());
182+
}
183+
}
184+
185+
// Recurse into sub-groups
186+
for (Map.Entry<String, List<Field>> sub : subGroups.entrySet()) {
187+
// Re-root the field names so the next level sees them starting from sub.getKey()
188+
String nextRoot = sub.getKey();
189+
List<Field> subFields = sub.getValue().stream()
190+
.map(f -> {
191+
// Trim one prefix level from the field name for the recursive call
192+
String trimmed = f.getName().substring(f.getName().indexOf('.') + 1);
193+
return new PathAliasField(f, trimmed);
194+
})
195+
.collect(java.util.stream.Collectors.toList());
196+
serializePathGroup(nextRoot, subFields, value, pathSoFar, gen);
197+
}
198+
199+
gen.writeEndObject();
200+
}
201+
202+
/** Overload used by the top-level serialize() method — starts pathSoFar as empty. */
203+
private void serializePathGroup(String rootName, List<Field> fields, Object value,
204+
JsonGenerator gen, SerializationContext provider) {
205+
serializePathGroup(rootName, fields, value, "", gen);
206+
}
207+
208+
/**
209+
* A lightweight wrapper around a {@link Field} that overrides only {@link #getName()}
210+
* so that recursive calls see the trimmed path rather than the full original path.
211+
*/
212+
private static class PathAliasField extends Field {
213+
private final Field delegate;
214+
private final String aliasName;
215+
216+
PathAliasField(Field delegate, String aliasName) {
217+
super(aliasName);
218+
this.delegate = delegate;
219+
this.aliasName = aliasName;
220+
}
221+
222+
@Override
223+
public String getName() {
224+
return aliasName;
225+
}
226+
227+
@Override
228+
public boolean isVisible() {
229+
return delegate.isVisible();
230+
}
231+
232+
@Override
233+
public java.util.Map<String, Object> getParams() {
234+
return delegate.getParams();
235+
}
236+
}
237+
98238
private void serializeCollectionField(Field field, PropertyInfo fieldInfo, Object value,
99239
JsonGenerator gen, SerializationContext provider) {
100240
Collection<?> collection = null;

0 commit comments

Comments
 (0)