Skip to content

fix: global naming strategy not applied to Java records (#7656) - #7657

Merged
wenshao merged 3 commits into
alibaba:mainfrom
1919chichi:fix/record-snake-case-naming-strategy
Aug 2, 2026
Merged

fix: global naming strategy not applied to Java records (#7656)#7657
wenshao merged 3 commits into
alibaba:mainfrom
1919chichi:fix/record-snake-case-naming-strategy

Conversation

@1919chichi

@1919chichi 1919chichi commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #7656

When serializing Java records, ObjectWriterCreator was calling method.getName() directly to get the field name, bypassing BeanUtils.getterName(method, kotlin, namingStrategy). This meant that both the global naming strategy (set via JSONFactory.getDefaultObjectWriterProvider().setNamingStrategy(...)) and the per-type @JSONType(naming = ...) annotation were silently ignored for record components.

Root cause (ObjectWriterCreator.java):

// Before — records bypassed the naming strategy entirely
if (record) {
    fieldName = method.getName();
} else {
    fieldName = BeanUtils.getterName(method, beanInfo.kotlin, beanInfo.namingStrategy);
    ...
}

// After — records go through the same naming pipeline as regular classes
fieldName = BeanUtils.getterName(method, beanInfo.kotlin, beanInfo.namingStrategy);
if (!record) {
    // NAME_COMPATIBLE_WITH_FILED logic (not applicable to records)
    ...
}

Test plan

  • JSONTypeNamingSnake#testRecord — verifies @JSONType(naming = SnakeCase) applies to records
  • JSONTypeNamingSnake#testRecordGlobalNaming — verifies global setNamingStrategy(SnakeCase) applies to records
  • ./mvnw -pl core clean test — all tests pass
  • ./mvnw -pl core -Dfastjson2.creator=reflect clean test — reflect mode tests pass
  • ./mvnw -pl core validate — Checkstyle passes

@CLAassistant

CLAassistant commented Jun 13, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Deserialization round-trip is broken for records with naming strategies

This PR fixes only the writer side. The reader side (ObjectReaderCreator line ~194) assigns fieldName = paramName from the canonical constructor parameter names with no naming-strategy transformation. After this PR, JSON.toJSONString(new RecordDto("840")) produces {"currency_code":"840"}, but JSON.parseObject("{\"currency_code\":\"840\"}", RecordDto.class) fails to match the key currency_code against the raw parameter name currencyCode — the value is silently dropped.

The existing test only asserts serialization, not round-trip. The reader-side createFieldReaders(provider, objectClass, objectType, owner, parameters, paramNames) overload needs to apply beanInfo.namingStrategy to parameter names, similar to how the field-based overload applies it via BeanUtils.fieldName(fieldName, namingStrategy).

— qwen3.7-max via Qwen Code /review

Comment thread core/src/main/java/com/alibaba/fastjson2/writer/ObjectWriterCreator.java Outdated
Comment thread core/src/test/java/com/alibaba/fastjson2/annotation/JSONTypeNamingSnake.java Outdated
@artpaym

artpaym commented Jun 16, 2026

Copy link
Copy Markdown

@wenshao @1919chichi As it seems related to the changes, please also ensure code gen code also gets the same fix (if needed).

@1919chichi

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! Here's a summary of the changes made to address all three points:

1. is/get prefix stripping (Writer side)
Restored the if (record) / else branch and replaced BeanUtils.getterName() with the existing BeanUtils.fieldName(method.getName(), namingStrategy) for records. This applies the naming strategy without any prefix stripping, so a field like isActive correctly serializes to is_active instead of active.

2. Deserialization round-trip (Reader side)
Fixed ObjectReaderCreator: after fetching record field names via BeanUtils.getRecordFieldNames(), the naming strategy is now applied to each parameter name so that incoming JSON keys (e.g. currency_code) hash-match correctly during constructor binding. Added round-trip deserialization assertions to the tests to cover this.

3. JDK 11 compilation failure (Tests)
Moved all record-based tests to the test-jdk17 module (compiled with source 17). Replaced the local record and var with a class-level static record and explicit types. Also added a BooleanRecordDto(boolean isActive) test case to explicitly verify the is prefix is preserved.


import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.PropertyNamingStrategy;
import org.junit.jupiter.api.Tag;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Missing import causes compilation failure

The import com.alibaba.fastjson2.PropertyNamingStrategy was removed, but the class is still referenced at lines 27, 33, and 55 (PropertyNamingStrategy.SnakeCase). Since this file is in package com.alibaba.fastjson2.annotation and PropertyNamingStrategy lives in com.alibaba.fastjson2, the explicit import is required.

This causes core module test compilation to fail:

JSONTypeNamingSnake.java:[27,24] cannot find symbol
JSONTypeNamingSnake.java:[33,24] cannot find symbol
JSONTypeNamingSnake.java:[55,24] cannot find symbol
Suggested change
import org.junit.jupiter.api.Tag;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.PropertyNamingStrategy;
import org.junit.jupiter.api.Tag;

— qwen3.7-max via Qwen Code /review

}

@Test
public void testRecordGlobalNaming() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] testRecordGlobalNaming only tests serialization — deserialization with global naming strategy is untested

The reader-side fix in ObjectReaderCreator.java:1282-1286 applies naming strategy to record constructor parameter names when beanInfo.namingStrategy is non-null. For global strategies, this value comes from ObjectReaderProvider via the BeanInfo constructor. This entire code path has zero test coverage.

A regression in reader-side global naming support for records would go undetected.

Suggested change
public void testRecordGlobalNaming() {
@Test
public void testRecordGlobalNaming() {
ObjectWriterProvider writerProvider = JSONFactory.getDefaultObjectWriterProvider();
ObjectReaderProvider readerProvider = JSONFactory.getDefaultObjectReaderProvider();
PropertyNamingStrategy prevWriter = writerProvider.getNamingStrategy();
PropertyNamingStrategy prevReader = readerProvider.getNamingStrategy();
try {
writerProvider.setNamingStrategy(PropertyNamingStrategy.SnakeCase);
readerProvider.setNamingStrategy(PropertyNamingStrategy.SnakeCase);
assertEquals("{\"currency_code\":\"840\"}", JSON.toJSONString(new GlobalRecordDto("840")));
assertEquals(new GlobalRecordDto("840"), JSON.parseObject("{\"currency_code\":\"840\"}", GlobalRecordDto.class));
} finally {
writerProvider.setNamingStrategy(prevWriter);
readerProvider.setNamingStrategy(prevReader);
}
}

— qwen3.7-max via Qwen Code /review

if (fieldInfo.fieldName == null || fieldInfo.fieldName.isEmpty()) {
if (record) {
fieldName = method.getName();
fieldName = BeanUtils.fieldName(method.getName(), beanInfo.namingStrategy);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing null guard for namingStrategy — inconsistent with reader and sibling code

The record branch unconditionally calls BeanUtils.fieldName(method.getName(), beanInfo.namingStrategy). While BeanUtils.fieldName() handles null by defaulting to "CamelCase" (which is a no-op for standard lowercase-starting record names), this is inconsistent with:

  • Reader (ObjectReaderCreator.java:1282): if (beanInfo.namingStrategy != null && parameterNames != null)
  • Writer field path (line 243): if (beanInfo.namingStrategy != null)

Adding a null guard makes the code robust against future changes to BeanUtils.fieldName()'s null-handling and keeps the three paths consistent.

Suggested change
fieldName = BeanUtils.fieldName(method.getName(), beanInfo.namingStrategy);
fieldName = beanInfo.namingStrategy != null
? BeanUtils.fieldName(method.getName(), beanInfo.namingStrategy)
: method.getName();

— qwen3.7-max via Qwen Code /review

@1919chichi

Copy link
Copy Markdown
Contributor Author

Updated the PR to address the latest review comments:

  • Restored the missing PropertyNamingStrategy import in JSONTypeNamingSnake.
  • Kept record writer field-name handling on the raw accessor name and added an explicit namingStrategy != null guard, so record components such as isActive are not treated as JavaBean getters.
  • Extended RecordNamingStrategyTest#testRecordGlobalNaming to cover global naming strategy deserialization via ObjectReaderProvider as well as serialization via ObjectWriterProvider.
  • Restored the accidentally removed lombok.var imports in the issue3601 tests, which caused the same kind of test compilation failure.

Validation run locally:

  • ./mvnw -pl core -Dtest=JSONTypeNamingSnake test -q
  • ../mvnw test -Dtest=RecordNamingStrategyTest -q from test-jdk17/
  • git diff --check

@wenshao wenshao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running.

The implementation correctly integrates naming strategy support for Java records with proper reader/writer symmetry. @JSONField annotations are correctly resolved via constructor parameter annotations with proper precedence over naming strategy. Test coverage is adequate — serialization, deserialization, boolean prefix handling, and global naming strategy are all verified.

— qwen3.7-max via Qwen Code /review

@artpaym

artpaym commented Jul 8, 2026

Copy link
Copy Markdown

Hi. What's the status of this PR?

@wenshao

wenshao commented Jul 10, 2026

Copy link
Copy Markdown
Member

CI is all green, but the following issues need to be addressed before merging:

1. Reader-side missing naming strategy support (round-trip broken)

ObjectReaderCreator ~L194 does not apply naming strategy transformation to record canonical constructor parameter names. Serialization produces currency_code, but deserialization fails to match against currencyCode — the value is silently dropped.

Apply beanInfo.namingStrategy to parameter names (same as the field-based overload), and add a deserialization assertion in testRecordGlobalNaming to verify round-trip.

2. is/get prefix incorrectly stripped

Routing records through BeanUtils.getterName() strips isActive to active. Records need a separate path: apply naming strategy only, skip JavaBean prefix detection.

3. namingStrategy null guard

The writer record branch calls BeanUtils.fieldName() without a null check on namingStrategy, inconsistent with the reader side and neighboring code paths.


Once these three are fixed, this is good to merge. Thanks for the contribution!

中文版本

CI 已经全绿,但以下几个问题需要解决后才能合入:

1. Reader 侧缺少 naming strategy 支持(round-trip 断掉)

ObjectReaderCreator ~L194 对 record canonical constructor 参数名没有做 naming strategy 转换。序列化产出 currency_code,反序列化时无法匹配 currencyCode,值被静默丢弃。

需要像 field-based overload 那样对参数名应用 beanInfo.namingStrategy,并在 testRecordGlobalNaming 中补充反序列化断言验证 round-trip。

2. is/get 前缀误剥离

Record 直接走 BeanUtils.getterName() 会把 isActive 剥成 active。Record 应走独立路径:只做 naming strategy 转换,跳过 JavaBean 前缀检测。

3. namingStrategy null guard

Writer record 分支调用 BeanUtils.fieldName() 前缺少 null 判断,与 reader 侧及相邻代码路径不一致。

修好这三点就可以合了,感谢贡献!

1919chichi and others added 3 commits July 10, 2026 23:52
When a record's field name was resolved, `method.getName()` was called
directly instead of `BeanUtils.getterName(…, namingStrategy)`, causing
the global (and @jsontype) naming strategy to be silently ignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Writer: use BeanUtils.fieldName() for records to avoid stripping is/get
  prefixes that are part of the field name in Java records
- Reader: apply namingStrategy to record parameterNames so deserialized
  JSON keys (e.g. currency_code) match the strategy-applied names
- Tests: move record tests to test-jdk17 module (Java 16+ required),
  replace local record and var with class-level record and explicit types,
  add round-trip deserialization assertions and boolean prefix test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@1919chichi
1919chichi force-pushed the fix/record-snake-case-naming-strategy branch from 33b0511 to db66664 Compare July 10, 2026 15:52
@1919chichi

Copy link
Copy Markdown
Contributor Author

CI is all green, but the following issues need to be addressed before merging:

1. Reader-side missing naming strategy support (round-trip broken)

ObjectReaderCreator ~L194 does not apply naming strategy transformation to record canonical constructor parameter names. Serialization produces currency_code, but deserialization fails to match against currencyCode — the value is silently dropped.

Apply beanInfo.namingStrategy to parameter names (same as the field-based overload), and add a deserialization assertion in testRecordGlobalNaming to verify round-trip.

2. is/get prefix incorrectly stripped

Routing records through BeanUtils.getterName() strips isActive to active. Records need a separate path: apply naming strategy only, skip JavaBean prefix detection.

3. namingStrategy null guard

The writer record branch calls BeanUtils.fieldName() without a null check on namingStrategy, inconsistent with the reader side and neighboring code paths.

Once these three are fixed, this is good to merge. Thanks for the contribution!

中文版本

Hi All three issues have been addressed in db6666405 ("fix: address record naming review comments"), on top of the reader-side fix already landed in dc90fd47a. Details below.

1. Reader-side missing naming strategy support (round-trip broken)

Fixed in ObjectReaderCreator#createObjectReader (core/src/main/java/com/alibaba/fastjson2/reader/ObjectReaderCreator.java:1280-1287):

if (record && parameterNames == null) {
    parameterNames = BeanUtils.getRecordFieldNames(objectClass);
    if (beanInfo.namingStrategy != null && parameterNames != null) {
        for (int i = 0; i < parameterNames.length; i++) {
            parameterNames[i] = BeanUtils.fieldName(parameterNames[i], beanInfo.namingStrategy);
        }
    }
}

The record canonical-constructor parameter names now go through the same naming-strategy transform as the writer side, so a value serialized to currency_code deserializes correctly.

Covered by testRecordGlobalNaming in test-jdk17/src/test/java/com/alibaba/fastjson2/RecordNamingStrategyTest.java:31-46, which asserts both directions:

assertEquals("{\"currency_code\":\"840\"}", JSON.toJSONString(new GlobalRecordDto("840")));
assertEquals(new GlobalRecordDto("840"), JSON.parseObject("{\"currency_code\":\"840\"}", GlobalRecordDto.class));

2. is/get prefix incorrectly stripped

Fixed in ObjectWriterCreator#getFieldName (core/src/main/java/com/alibaba/fastjson2/writer/ObjectWriterCreator.java:756-759). Records no longer route through BeanUtils.getterName() (which does JavaBean prefix detection); they apply the naming strategy directly to the raw accessor name:

if (record) {
    fieldName = beanInfo.namingStrategy != null
            ? BeanUtils.fieldName(method.getName(), beanInfo.namingStrategy)
            : method.getName();
} else {
    fieldName = BeanUtils.getterName(method, beanInfo.kotlin, beanInfo.namingStrategy);
    ...

Added regression test testBooleanFieldNotStripped (RecordNamingStrategyTest.java:25-29) covering exactly the isActive case from the review:

public record BooleanRecordDto(boolean isActive) {}
...
assertEquals("{\"is_active\":true}", JSON.toJSONString(new BooleanRecordDto(true)));
assertEquals(new BooleanRecordDto(true), JSON.parseObject("{\"is_active\":true}", BooleanRecordDto.class));

3. namingStrategy null guard

Fixed at the same location (ObjectWriterCreator.java:757), shown in the snippet above — the record branch now short-circuits to method.getName() when namingStrategy is null, matching the reader side (ObjectReaderCreator.java:1282) and the writer field-based path (ObjectWriterCreator.java:243).


All three tests pass locally:

$ mvn -f test-jdk17/pom.xml -am test -Dtest=RecordNamingStrategyTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

Let me know if anything still looks off.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.8-max-preview via Qwen Code /review

@wenshao
wenshao merged commit d1029c7 into alibaba:main Aug 2, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] global SnakeCase naming strategy is not applied to Java records

4 participants