fix: honor serialize false in JSONCompiled writers#7661
Conversation
97d0996 to
21df00e
Compare
|
Updated the commit author to the GitHub-linked noreply identity so the CLA assistant can associate the commit with my account. |
| Process process = new ProcessBuilder(command) | ||
| .redirectErrorStream(true) | ||
| .start(); | ||
| byte[] output = process.getInputStream().readAllBytes(); |
There was a problem hiding this comment.
[Critical] InputStream.readAllBytes() is a JDK 9+ API. The codegen module compiles with -source 8 -target 8 (both jdk1.8 and jdk9+ profiles in codegen/pom.xml). On JDK 8 (which is in the CI matrix), this will fail to compile with "cannot find symbol".
| byte[] output = process.getInputStream().readAllBytes(); | |
| ByteArrayOutputStream baos = new ByteArrayOutputStream(); | |
| byte[] buf = new byte[4096]; | |
| int n; | |
| while ((n = process.getInputStream().read(buf)) != -1) { | |
| baos.write(buf, 0, n); | |
| } | |
| return new ProcessResult(process.waitFor(), baos.toString(StandardCharsets.UTF_8.name())); |
— qwen3.7-max via Qwen Code /review
| FieldInfo writerFieldInfo = new FieldInfo(); | ||
|
|
||
| String name = field.getSimpleName().toString(); | ||
| JSONField[] annotations = field.getAnnotationsByType(JSONField.class); |
There was a problem hiding this comment.
[Suggestion] Follow-up: the method-processing loop (lines 149-189) also never reads @JSONField from getter/setter methods. If a user places @JSONField(serialize = false) on a getter (common JavaBeans pattern for private fields), the generated writer will still serialize the field — the same class of bug, on a different annotation target.
Consider adding method.getAnnotationsByType(JSONField.class) processing in the method loop to mirror the field-level fix:
JSONField[] methodAnnotations = method.getAnnotationsByType(JSONField.class);
for (JSONField annotation : methodAnnotations) {
FieldInfo readerFI = new FieldInfo();
FieldInfo writerFI = new FieldInfo();
CodeGenUtils.getFieldInfo(readerFI, annotation, false);
CodeGenUtils.getFieldInfo(writerFI, annotation, true);
if (readerFI.ignore) attr.reader = false;
if (writerFI.ignore) attr.writer = false;
}— qwen3.7-max via Qwen Code /review
|
|
||
| public class Issue7660Test { | ||
| @TempDir | ||
| public Path tempDir; |
There was a problem hiding this comment.
[Suggestion] Consider adding a test for @JSONField(deserialize = false) as well. The reader-side code path was modified (attr.reader = !readerFieldInfo.ignore, supportSet() gating on reader), but only the writer path is tested. A deserialization test would protect against regressions in the new reader-side filtering logic.
— qwen3.7-max via Qwen Code /review
|
|
||
| info.getAttributeByField(name, field); | ||
| AttributeInfo attr = info.getAttributeByField(name, field); | ||
| attr.reader = !readerFieldInfo.ignore; |
There was a problem hiding this comment.
[Critical] Field-level unconditional overwrite of attr.reader/attr.writer
The field processing loop unconditionally assigns attr.reader = !readerFieldInfo.ignore and attr.writer = !writerFieldInfo.ignore. In inheritance scenarios where getTypeHierarchy() visits child classes before parent classes, a child's method-level annotation (e.g., @JSONField(serialize = false) on a getter) can set attr.writer = false, but then the parent's unannotated field overwrites it back to true, silently losing the child's disable decision.
The method loop (lines 206-211) correctly uses a one-way disable pattern (if (ignore) attr.reader = false). The field loop should do the same.
| attr.reader = !readerFieldInfo.ignore; | |
| AttributeInfo attr = info.getAttributeByField(name, field); | |
| if (readerFieldInfo.ignore) { | |
| attr.reader = false; | |
| } | |
| if (writerFieldInfo.ignore) { | |
| attr.writer = false; | |
| } |
— qwen3.7-max via Qwen Code /review
| " public static class Person {", | ||
| " @JSONField(deserialize = false)", | ||
| " public String internalCode;", | ||
| " public String name;", |
There was a problem hiding this comment.
[Suggestion] Consider adding a round-trip test that verifies both directions work correctly together on the same @JSONCompiled bean. For example, a bean with @JSONField(serialize = false) on a field should exclude it from toJSONString output while still populating it from parseObject. This catches asymmetric bugs where the reader/writer attribute list split inadvertently affects the wrong direction.
— qwen3.7-max via Qwen Code /review
|
Addressed the latest review feedback in d45d5fa. What changed:
Verification:
|
| name = writerFieldInfo.fieldName; | ||
| } | ||
|
|
||
| AttributeInfo attr = info.getAttributeByMethod(name, type, getter, setter); |
There was a problem hiding this comment.
[Suggestion] Dead code: the else if (writerFieldInfo.fieldName != null) branch is unreachable. CodeGenUtils.getFieldInfo sets fieldName from JSONField.name() regardless of the serialize parameter, so readerFieldInfo.fieldName and writerFieldInfo.fieldName are always identical. When readerFieldInfo.fieldName is null, writerFieldInfo.fieldName is guaranteed null too. The field loop (line 140) correctly omits this redundant check, making the two code paths inconsistent.
| AttributeInfo attr = info.getAttributeByMethod(name, type, getter, setter); | |
| if (readerFieldInfo.fieldName != null) { | |
| name = readerFieldInfo.fieldName; | |
| } |
— qwen3.7-max via Qwen Code /review
|
Updated in This follow-up removes the redundant method-level Validation: git diff --check
./mvnw -Penable-codegen -pl codegen -Dtest=Issue7660Test test -DskipITs -Dcheckstyle.skip -Drat.skip=true
./mvnw -Penable-codegen -pl codegen test -DskipITs -Dcheckstyle.skip -Drat.skip=trueThe full codegen module test run passed 10 tests with 0 failures/errors. |
| for (JSONField annotation : annotations) { | ||
| CodeGenUtils.getFieldInfo(fieldInfo, annotation, false); | ||
| CodeGenUtils.getFieldInfo(readerFieldInfo, annotation, false); | ||
| CodeGenUtils.getFieldInfo(writerFieldInfo, annotation, true); |
There was a problem hiding this comment.
[Critical] serialize=false is bypassed when deserializeFeatures includes FieldBased
The new call getFieldInfo(writerFieldInfo, annotation, true) reuses CodeGenUtils.getFieldInfo, which contains reader-specific reset logic (lines 542–547) that sets fieldInfo.ignore = false when JSONReader.Feature.FieldBased is present — regardless of the serialize parameter. This silently overrides the ignore=true that serialize=false just set.
Verified empirically: @JSONField(serialize = false, deserializeFeatures = {JSONReader.Feature.FieldBased}) leaks the field into JSON output. This is a realistic combination — FieldBased for private-field deserialization + serialize=false to prevent leakage.
| CodeGenUtils.getFieldInfo(writerFieldInfo, annotation, true); |
Fix should be in CodeGenUtils.getFieldInfo — gate the FieldBased override on !serialize:
if (!serialize && fieldInfo.ignore && feature == JSONReader.Feature.FieldBased) {
fieldInfo.ignore = false;
}— glm-5.2 via Qwen Code /review
| } | ||
|
|
||
| AttributeInfo attr = info.getAttributeByMethod(name, type, getter, setter); | ||
| if (annotations.length != 0) { |
There was a problem hiding this comment.
[Suggestion] Redundant if (annotations.length != 0) guard
The method-loop block wraps the reader/writer ignore mapping in this guard, but the structurally identical field-loop block (lines 145–150) does not. The guard is provably dead: when annotations is empty, the for loop never executes, so both ignore flags remain false and the inner if branches never fire regardless.
| if (annotations.length != 0) { | |
| AttributeInfo attr = info.getAttributeByMethod(name, type, getter, setter); | |
| if (readerFieldInfo.ignore) { | |
| attr.reader = false; | |
| } | |
| if (writerFieldInfo.ignore) { | |
| attr.writer = false; | |
| } |
— glm-5.2 via Qwen Code /review
| continue; | ||
| } | ||
|
|
||
| FieldInfo readerFieldInfo = new FieldInfo(); |
There was a problem hiding this comment.
[Suggestion] Duplicated annotation-processing logic
The reader/writer FieldInfo construction, annotation iteration, name override, and ignore-to-flag mapping is duplicated verbatim between the field loop (lines 130–150) and this method loop (lines 193–211). The copies have already drifted (the redundant guard above exists in only one). Consider extracting a helper to keep them in sync.
— glm-5.2 via Qwen Code /review
| } | ||
|
|
||
| @Test | ||
| public void jsonCompiledHonorsDeserializeFalse() throws Exception { |
There was a problem hiding this comment.
[Suggestion] Missing test coverage for combined flags
Two gaps in the otherwise thorough test suite:
-
No test exercises
@JSONField(serialize = false, deserialize = false)simultaneously — the only case where bothattr.readerandattr.writerflip tofalse, dropping the attribute from both lists. -
The
deserialize = falsetest (this method) only checks that deserialization is blocked. It never verifies the field still serializes correctly (the mirror of theserialize=falseround-trip test at line 228).
— glm-5.2 via Qwen Code /review
83f638d to
4dac8fd
Compare
|
Follow-up status on the latest head |
| } | ||
|
|
||
| AttributeInfo attr = info.getAttributeByMethod(name, type, getter, setter); | ||
| if (readerFieldInfo.ignore) { |
There was a problem hiding this comment.
[Critical] Split-attribute bypass: @JSONField(name, serialize=false) can leak supposedly-hidden fields
When a field has @JSONField(name = "alias", serialize = false) and a getter exists for the same Java property, the field loop creates an AttributeInfo under "alias" with writer=false, but this method loop creates a separate AttributeInfo under the Java property name (e.g., "internalCode") with writer=true (default). getWriterAttributes() then includes the second entry, leaking the supposedly-hidden value into serialized output.
The same vulnerability exists for deserialize=false: a field with @JSONField(name = "alias", deserialize = false) plus a setter creates two entries, and the generated reader accepts the value under the Java property name.
Consider propagating the field-level disable to the method-resolved attribute when the names diverge, or unifying disable tracking by Java property name rather than JSON name.
— qwen3.7-max via Qwen Code /review
|
|
||
| public boolean supportSet() { | ||
| return field != null || setMethod != null; | ||
| return reader && (field != null || setMethod != null); |
There was a problem hiding this comment.
[Critical] Record canonical constructor arity mismatch with @JSONField(deserialize=false)
For records, field != null (set by the field loop for record components), so the new reader guard changes supportSet() behavior. When @JSONField(deserialize = false) is on a record component, reader becomes false, the component is excluded from getReaderAttributes(), and genCreateRecordInstance / genReadRecordObject generate a constructor call with fewer arguments than the canonical constructor requires.
Example: record Person(@JSONField(deserialize=false) String secret, String name) would generate new Person((String) null) instead of new Person(null, null) — a compile error.
Fix: for records, the constructor call sites need ALL components regardless of the reader filter. Components with reader=false should contribute a default value but not be read from JSON.
— qwen3.7-max via Qwen Code /review
Suggestions — commit
|
| File | Issue | Suggested fix |
|---|---|---|
AttributeInfo.java:44 |
New supportGet() includes getter-only computed properties in generated writer (behavior change). Previously excluded since supportSet() required field || setMethod. |
Document this behavior change in the PR description. If backward compat is needed, gate supportGet() to also require a backing field. |
AttributeInfo.java:19-20 |
reader/writer boolean names are terse; supportSet() gated by reader, supportGet() gated by writer — perspective mismatch. |
Add Javadoc or rename to deserializable/serializable. |
Issue7660Test.java |
No test for @JSONField(name, serialize=false) combination (triggers split-attribute bypass). No record test with @JSONField(deserialize=false). |
Add tests covering both scenarios to prevent regressions. |
— qwen3.7-max via Qwen Code /review
|
The latest review exposed two correctness gaps in the current design: an aliased field can be split from its accessor and bypass serialize=false, and filtering record components can break canonical-constructor arity. Both need a redesign of attribute identity and record construction rather than another narrow patch. I am closing this version instead of expanding an already broad change without enough confidence. I will only revisit it as a smaller change after separate reproductions and a narrower design. |
Summary
@JSONField(serialize = false)from generated writersRoot Cause
The annotation processor built both generated readers and writers from
StructInfo#getReaderAttributes(). Field annotations were analyzed with deserialize semantics only, soserialize = falsedid not remove the field from the generated writer.Tests
./mvnw -Penable-codegen -pl codegen -am -Dtest=Issue7660Test -Dsurefire.failIfNoSpecifiedTests=false test./mvnw -Penable-codegen -pl codegen,codegen-test -am -Dsurefire.failIfNoSpecifiedTests=false test./mvnw -Penable-codegen -pl codegen,codegen-test -am validateFixes #7660