This is an automated email from the ASF dual-hosted git repository.

chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git


The following commit(s) were added to refs/heads/main by this push:
     new 0e4ac5917 feat(cpp): support struct property accessors (#3751)
0e4ac5917 is described below

commit 0e4ac5917898e4fbd253a5d638a21ff15d500d67
Author: Shawn Yang <[email protected]>
AuthorDate: Fri Jun 12 16:36:21 2026 +0800

    feat(cpp): support struct property accessors (#3751)
    
    ## Why?
    
    C++ struct serialization only accepted data-member field entries, which
    made PIMPL-style or private-storage types awkward to serialize when
    their stable public surface is an accessor pair.
    
    ## What does this PR do?
    
    - Adds `FORY_PROPERTY` field entries for accessor-backed C++ struct
    fields, including same-name getter/setter, custom getter/setter, and
    optional `fory::F(...)` metadata forms.
    - Introduces compile-time property descriptors plus shared field
    type/get/set helpers so struct serialization, compatible struct reads,
    row encoding, and type metadata can handle both member pointers and
    accessor properties.
    - Keeps direct-member primitive fast paths limited to direct fields
    while routing accessor-backed fields through their setter path.
    - Adds C++ coverage for property metadata, PIMPL property round trips,
    configured accessor metadata, and compatible nested named structs.
    - Documents accessor properties in the C++ serialization and schema
    metadata guides.
    
    ## Related issues
    
    Closes #3747
    
    ## AI Contribution Checklist
    
    
    
    - [ ] Substantial AI assistance was used in this PR: `yes` / `no`
    - [ ] If `yes`, I included a completed [AI Contribution
    
Checklist](https://github.com/apache/fory/blob/main/AI_POLICY.md#9-contributor-checklist-for-ai-assisted-prs)
    in this PR description and the required `AI Usage Disclosure`.
    - [ ] If `yes`, my PR description includes the required `ai_review`
    summary and screenshot evidence of the final clean AI review results
    from both fresh reviewers on the current PR diff or current HEAD after
    the latest code changes.
    
    
    
    ## Does this PR introduce any user-facing change?
    
    Adds the C++ `FORY_PROPERTY` macro for accessor-backed struct fields.
    This does not change the binary protocol.
    
    - [x] Does this PR introduce any public API change?
    - [ ] Does this PR introduce any binary protocol compatibility change?
    
    ## Benchmark
    
    Not run; this PR changes C++ struct metadata/accessor support and does
    not include benchmark changes.
---
 cpp/fory/encoder/row_encode_trait.h              |  13 +-
 cpp/fory/meta/field.h                            |  36 ++++
 cpp/fory/meta/field_info.h                       | 114 ++++++++--
 cpp/fory/meta/field_info_test.cc                 |  49 +++++
 cpp/fory/meta/preprocessor.h                     |   6 +
 cpp/fory/serialization/struct_compatible_test.cc |  71 +++++++
 cpp/fory/serialization/struct_serializer.h       | 260 +++++++++--------------
 cpp/fory/serialization/struct_test.cc            |  79 +++++++
 cpp/fory/serialization/type_resolver.h           |  10 +-
 docs/benchmarks/cpp/README.md                    |  52 ++---
 docs/benchmarks/cpp/throughput.png               | Bin 103838 -> 102327 bytes
 docs/guide/cpp/basic-serialization.md            |  61 +++++-
 docs/guide/cpp/schema-metadata.md                |  17 +-
 13 files changed, 551 insertions(+), 217 deletions(-)

diff --git a/cpp/fory/encoder/row_encode_trait.h 
b/cpp/fory/encoder/row_encode_trait.h
index ebe034f15..1b039412e 100644
--- a/cpp/fory/encoder/row_encode_trait.h
+++ b/cpp/fory/encoder/row_encode_trait.h
@@ -19,6 +19,7 @@
 
 #pragma once
 
+#include "fory/meta/field.h"
 #include "fory/meta/field_info.h"
 #include "fory/meta/type_traits.h"
 #include "fory/row/writer.h"
@@ -191,8 +192,8 @@ private:
   using FieldInfo = decltype(fory_field_info(std::declval<T>()));
 
   template <size_t I> static FieldPtr get_field() {
-    using FieldType = meta::RemoveMemberPointerCVRefT<
-        std::tuple_element_t<I, decltype(FieldInfo::ptrs())>>;
+    using FieldEntry = std::tuple_element_t<I, decltype(FieldInfo::ptrs())>;
+    using FieldType = field_value_type_t<T, FieldEntry>;
     return field(details::string_view_to_string(FieldInfo::Names[I]),
                  RowEncodeTrait<FieldType>::Type());
   }
@@ -204,10 +205,10 @@ private:
 
   template <size_t I, typename V>
   static void write_field(V &&visitor, const T &value, RowWriter &writer) {
-    using FieldType = meta::RemoveMemberPointerCVRefT<
-        std::tuple_element_t<I, decltype(FieldInfo::ptrs())>>;
-    RowEncodeTrait<FieldType>::write(std::forward<V>(visitor),
-                                     value.*std::get<I>(FieldInfo::ptrs_ref()),
+    const auto field_entry = std::get<I>(FieldInfo::ptrs_ref());
+    using FieldType = field_value_type_t<T, decltype(field_entry)>;
+    auto &&field_value = field_value_get(value, field_entry);
+    RowEncodeTrait<FieldType>::write(std::forward<V>(visitor), field_value,
                                      writer, I);
   }
 
diff --git a/cpp/fory/meta/field.h b/cpp/fory/meta/field.h
index faad1c2e7..e66934dc7 100644
--- a/cpp/fory/meta/field.h
+++ b/cpp/fory/meta/field.h
@@ -21,12 +21,14 @@
 
 #include "fory/meta/field_info.h"
 #include "fory/type/type.h"
+#include "fory/util/macros.h"
 #include <cstdint>
 #include <memory>
 #include <optional>
 #include <string_view>
 #include <tuple>
 #include <type_traits>
+#include <utility>
 
 namespace fory {
 
@@ -198,4 +200,38 @@ template <typename T> struct field_dynamic_value {
 template <typename T>
 inline constexpr int field_dynamic_value_v = field_dynamic_value<T>::value;
 
+template <typename StructT, typename Entry>
+using field_value_type_t = unwrap_field_t<meta::FieldRawTypeT<
+    std::remove_cv_t<std::remove_reference_t<StructT>>, Entry>>;
+
+template <typename StructT, typename Entry>
+FORY_ALWAYS_INLINE decltype(auto) field_value_get(StructT &&obj, Entry entry) {
+  using RawFieldType =
+      meta::FieldRawTypeT<std::remove_cv_t<std::remove_reference_t<StructT>>,
+                          Entry>;
+  if constexpr (meta::IsPropertyDescriptorV<Entry>) {
+    return meta::RemoveCVRefT<Entry>::get(std::forward<StructT>(obj));
+  } else if constexpr (is_fory_field_v<RawFieldType>) {
+    return (std::forward<StructT>(obj).*entry).value;
+  } else {
+    return (std::forward<StructT>(obj).*entry);
+  }
+}
+
+template <typename StructT, typename Entry, typename Value>
+FORY_ALWAYS_INLINE void field_value_set(StructT &&obj, Entry entry,
+                                        Value &&value) {
+  using RawFieldType =
+      meta::FieldRawTypeT<std::remove_cv_t<std::remove_reference_t<StructT>>,
+                          Entry>;
+  if constexpr (meta::IsPropertyDescriptorV<Entry>) {
+    meta::RemoveCVRefT<Entry>::set(std::forward<StructT>(obj),
+                                   std::forward<Value>(value));
+  } else if constexpr (is_fory_field_v<RawFieldType>) {
+    (std::forward<StructT>(obj).*entry).value = std::forward<Value>(value);
+  } else {
+    (std::forward<StructT>(obj).*entry) = std::forward<Value>(value);
+  }
+}
+
 } // namespace fory
diff --git a/cpp/fory/meta/field_info.h b/cpp/fory/meta/field_info.h
index fb3d8285e..0da5d22b0 100644
--- a/cpp/fory/meta/field_info.h
+++ b/cpp/fory/meta/field_info.h
@@ -320,6 +320,31 @@ constexpr auto make_field_entry(const char *name, 
FieldMeta meta) {
 
 namespace meta {
 
+template <typename T, typename = void>
+struct IsPropertyDescriptor : std::false_type {};
+
+template <typename T>
+struct IsPropertyDescriptor<
+    T, std::void_t<decltype(T::is_fory_property_descriptor)>>
+    : std::bool_constant<T::is_fory_property_descriptor> {};
+
+template <typename T>
+inline constexpr bool IsPropertyDescriptorV =
+    IsPropertyDescriptor<RemoveCVRefT<T>>::value;
+
+template <typename StructT, typename Entry, bool = 
IsPropertyDescriptorV<Entry>>
+struct FieldRawType {
+  using Type = RemoveMemberPointerCVRefT<Entry>;
+};
+
+template <typename StructT, typename Entry>
+struct FieldRawType<StructT, Entry, true> {
+  using Type = typename RemoveCVRefT<Entry>::template ForyFieldType<StructT>;
+};
+
+template <typename StructT, typename Entry>
+using FieldRawTypeT = typename FieldRawType<StructT, 
RemoveCVRefT<Entry>>::Type;
+
 template <typename T> struct Identity {
   using Type = T;
 };
@@ -366,12 +391,16 @@ constexpr const T &unwrap_tuple(const TupleWrapper<T> 
&value) {
 // it must be able to be executed in compile-time
 template <typename FieldInfo, size_t... I>
 constexpr bool is_valid_field_info_impl(std::index_sequence<I...>) {
-  if constexpr (sizeof...(I) == 0) {
-    return true;
-  } else {
-    constexpr auto ptrs = FieldInfo::ptrs();
-    return IsUnique<std::get<I>(ptrs)...>::value;
+  (void)std::index_sequence<I...>{};
+  constexpr auto names = FieldInfo::Names;
+  for (size_t i = 0; i < FieldInfo::Size; ++i) {
+    for (size_t j = i + 1; j < FieldInfo::Size; ++j) {
+      if (names[i] == names[j]) {
+        return false;
+      }
+    }
   }
+  return true;
 }
 
 } // namespace details
@@ -390,7 +419,8 @@ struct HasForyStructInfo
 // it includes:
 // - number of fields: typed size_t
 // - field names: typed `std::string_view`
-// - field member points: typed `decltype(a) T::*` for any member `T::a`
+// - field access entries: direct fields use typed `decltype(a) T::*` member
+//   pointers; properties use empty compile-time descriptor objects.
 template <typename T>
 constexpr auto fory_field_info([[maybe_unused]] const T &value) noexcept {
   if constexpr (details::HasMemberStructInfo<T>::value) {
@@ -507,20 +537,51 @@ constexpr auto concat_tuples_from_tuple(const Tuple 
&tuple) {
 
 #define FORY_BASE_TYPE(arg) FORY_PP_TUPLE_SECOND(arg)
 
+#define FORY_PROPERTY(...)                                                     
\
+  FORY_PP_CONCAT(FORY_PROPERTY_, FORY_PP_NARG(__VA_ARGS__))(__VA_ARGS__)
+
+#define FORY_PROPERTY_1(name) (FORY_PROPERTY_TAG, name, name, name, 
::fory::F())
+#define FORY_PROPERTY_2(name, meta) (FORY_PROPERTY_TAG, name, name, name, meta)
+#define FORY_PROPERTY_3(name, getter, setter)                                  
\
+  (FORY_PROPERTY_TAG, name, getter, setter, ::fory::F())
+#define FORY_PROPERTY_4(name, getter, setter, meta)                            
\
+  (FORY_PROPERTY_TAG, name, getter, setter, meta)
+
+#define FORY_PP_IS_PROPERTY_TAG(x)                                             
\
+  FORY_PP_CHECK(FORY_PP_CONCAT(FORY_PP_IS_PROPERTY_TAG_PROBE_, x))
+#define FORY_PP_IS_PROPERTY_TAG_PROBE_FORY_PROPERTY_TAG FORY_PP_PROBE()
+
+#define FORY_PP_IS_PROPERTY(arg)                                               
\
+  FORY_PP_IS_PROPERTY_IMPL(FORY_PP_IS_PAREN(arg), arg)
+#define FORY_PP_IS_PROPERTY_IMPL(is_paren, arg)                                
\
+  FORY_PP_CONCAT(FORY_PP_IS_PROPERTY_IMPL_, is_paren)(arg)
+#define FORY_PP_IS_PROPERTY_IMPL_0(arg) 0
+#define FORY_PP_IS_PROPERTY_IMPL_1(arg)                                        
\
+  FORY_PP_IS_PROPERTY_TAG(FORY_PP_TUPLE_FIRST(arg))
+
+#define FORY_PROPERTY_ARG_NAME(arg) FORY_PP_TUPLE_SECOND(arg)
+#define FORY_PROPERTY_ARG_GETTER(arg) FORY_PP_TUPLE_THIRD(arg)
+#define FORY_PROPERTY_ARG_SETTER(arg) FORY_PP_TUPLE_FOURTH(arg)
+#define FORY_PROPERTY_ARG_META(arg) FORY_PP_TUPLE_FIFTH(arg)
+
 #define FORY_PP_IS_CONFIG(arg)                                                 
\
   FORY_PP_IS_CONFIG_IMPL(FORY_PP_IS_PAREN(arg), arg)
 #define FORY_PP_IS_CONFIG_IMPL(is_paren, arg)                                  
\
   FORY_PP_CONCAT(FORY_PP_IS_CONFIG_IMPL_, is_paren)(arg)
 #define FORY_PP_IS_CONFIG_IMPL_0(arg) 0
-#define FORY_PP_IS_CONFIG_IMPL_1(arg) FORY_PP_NOT(FORY_PP_IS_BASE(arg))
+#define FORY_PP_IS_CONFIG_IMPL_1(arg)                                          
\
+  FORY_PP_IF(FORY_PP_IS_BASE(arg))                                             
\
+  (0, FORY_PP_IF(FORY_PP_IS_PROPERTY(arg))(0, 1))
 
 #define FORY_FIELD_ARG_NAME(arg)                                               
\
-  FORY_PP_IF(FORY_PP_IS_CONFIG(arg))                                           
\
-  (FORY_PP_TUPLE_FIRST(arg), arg)
+  FORY_PP_IF(FORY_PP_IS_PROPERTY(arg))                                         
\
+  (FORY_PROPERTY_ARG_NAME(arg),                                                
\
+   FORY_PP_IF(FORY_PP_IS_CONFIG(arg))(FORY_PP_TUPLE_FIRST(arg), arg))
 
 #define FORY_FIELD_ARG_META(arg)                                               
\
-  FORY_PP_IF(FORY_PP_IS_CONFIG(arg))                                           
\
-  (FORY_PP_TUPLE_SECOND(arg), ::fory::F())
+  FORY_PP_IF(FORY_PP_IS_PROPERTY(arg))                                         
\
+  (FORY_PROPERTY_ARG_META(arg),                                                
\
+   FORY_PP_IF(FORY_PP_IS_CONFIG(arg))(FORY_PP_TUPLE_SECOND(arg), ::fory::F()))
 
 #define FORY_FIELD_INFO_NAMES_FIELD(field) #field,
 #define FORY_FIELD_INFO_NAMES_ARG(arg) FORY_FIELD_INFO_NAMES_FIELD(arg)
@@ -529,11 +590,35 @@ constexpr auto concat_tuples_from_tuple(const Tuple 
&tuple) {
   (FORY_PP_EMPTY(), FORY_FIELD_INFO_NAMES_ARG(FORY_FIELD_ARG_NAME(arg)))
 
 #define FORY_FIELD_INFO_PTRS_FIELD(type, field) &type::field,
-#define FORY_FIELD_INFO_PTRS_ARG(type, field)                                  
\
-  FORY_FIELD_INFO_PTRS_FIELD(type, field)
+#define FORY_PROPERTY_DESCRIPTOR_NAME(arg)                                     
\
+  FORY_PP_CONCAT(ForyPropertyDescriptor_, FORY_PROPERTY_ARG_NAME(arg))
+#define FORY_FIELD_INFO_PROPERTY_DECL(type, arg)                               
\
+  struct FORY_PROPERTY_DESCRIPTOR_NAME(arg) {                                  
\
+    static constexpr bool is_fory_property_descriptor = true;                  
\
+    using ForyStructType = type;                                               
\
+    template <typename T>                                                      
\
+    using ForyFieldType = ::fory::meta::RemoveCVRefT<                          
\
+        decltype(std::declval<const T &>().FORY_PROPERTY_ARG_GETTER(arg)())>;  
\
+    template <typename Obj> static decltype(auto) get(Obj &&obj) {             
\
+      return std::forward<Obj>(obj).FORY_PROPERTY_ARG_GETTER(arg)();           
\
+    }                                                                          
\
+    template <typename Obj, typename Value>                                    
\
+    static void set(Obj &&obj, Value &&value) {                                
\
+      (void)std::forward<Obj>(obj).FORY_PROPERTY_ARG_SETTER(arg)(              
\
+          std::forward<Value>(value));                                         
\
+    }                                                                          
\
+  };
+#define FORY_FIELD_INFO_PROPERTY_DECL_FUNC(type, arg)                          
\
+  FORY_PP_IF(FORY_PP_IS_PROPERTY(arg))                                         
\
+  (FORY_FIELD_INFO_PROPERTY_DECL(type, arg), FORY_PP_EMPTY())
+#define FORY_FIELD_INFO_PTRS_PROPERTY(arg) 
FORY_PROPERTY_DESCRIPTOR_NAME(arg){},
+#define FORY_FIELD_INFO_PTRS_ARG(type, arg)                                    
\
+  FORY_PP_IF(FORY_PP_IS_PROPERTY(arg))                                         
\
+  (FORY_FIELD_INFO_PTRS_PROPERTY(arg),                                         
\
+   FORY_FIELD_INFO_PTRS_FIELD(type, FORY_FIELD_ARG_NAME(arg)))
 #define FORY_FIELD_INFO_PTRS_FUNC(type, arg)                                   
\
   FORY_PP_IF(FORY_PP_IS_BASE(arg))                                             
\
-  (FORY_PP_EMPTY(), FORY_FIELD_INFO_PTRS_ARG(type, FORY_FIELD_ARG_NAME(arg)))
+  (FORY_PP_EMPTY(), FORY_FIELD_INFO_PTRS_ARG(type, arg))
 
 #define FORY_FIELD_INFO_CONFIG_ENTRY(arg)                                      
\
   ::fory::detail::make_field_entry(                                            
\
@@ -612,6 +697,7 @@ constexpr auto concat_tuples_from_tuple(const Tuple &tuple) 
{
       return fory::meta::concat_tuples_from_tuple(                             
\
           std::tuple{FORY_PP_FOREACH(FORY_BASE_PTRS_ARG, __VA_ARGS__)});       
\
     }                                                                          
\
+    FORY_PP_FOREACH_1(FORY_FIELD_INFO_PROPERTY_DECL_FUNC, type, __VA_ARGS__)   
\
     using FieldPtrsType = decltype(std::tuple{                                 
\
         FORY_PP_FOREACH_1(FORY_FIELD_INFO_PTRS_FUNC, type, __VA_ARGS__)});     
\
     static constexpr FieldPtrsType FieldPtrs() {                               
\
diff --git a/cpp/fory/meta/field_info_test.cc b/cpp/fory/meta/field_info_test.cc
index a873ed00e..a09166f2c 100644
--- a/cpp/fory/meta/field_info_test.cc
+++ b/cpp/fory/meta/field_info_test.cc
@@ -19,7 +19,10 @@
 
 #include "gtest/gtest.h"
 
+#include "fory/meta/field.h"
 #include "fory/meta/field_info.h"
+#include <cstdint>
+#include <type_traits>
 
 namespace fory {
 
@@ -68,6 +71,52 @@ TEST(FieldInfo, Hidden) {
   static_assert(std::get<0>(decltype(info)::ptrs()) == &B::a);
 }
 
+struct C {
+  const int32_t &value() const { return value_; }
+  int32_t &value() { return value_; }
+  C &value(int32_t v) {
+    value_ = v;
+    return *this;
+  }
+
+  const int32_t &get_count() const { return count_; }
+  C &set_count(int32_t v) {
+    count_ = v;
+    return *this;
+  }
+
+  int32_t value_ = 0;
+  int32_t count_ = 0;
+
+  FORY_STRUCT(C, FORY_PROPERTY(value),
+              FORY_PROPERTY(count, get_count, set_count, fory::F(7).varint()));
+};
+
+TEST(FieldInfo, Property) {
+  C c;
+  constexpr auto info = meta::fory_field_info(c);
+
+  static_assert(info.Size == 2);
+  static_assert(info.Names[0] == "value");
+  static_assert(info.Names[1] == "count");
+
+  using ValueEntry = decltype(std::get<0>(decltype(info)::ptrs()));
+  using CountEntry = decltype(std::get<1>(decltype(info)::ptrs()));
+  static_assert(meta::IsPropertyDescriptorV<ValueEntry>);
+  static_assert(meta::IsPropertyDescriptorV<CountEntry>);
+  static_assert(std::is_same_v<meta::FieldRawTypeT<C, ValueEntry>, int32_t>);
+  static_assert(std::is_same_v<meta::FieldRawTypeT<C, CountEntry>, int32_t>);
+  static_assert(std::get<1>(decltype(info)::entries).meta.id_ == 7);
+  static_assert(std::get<1>(decltype(info)::entries).meta.encoding_ ==
+                Encoding::Varint);
+
+  auto entries = decltype(info)::ptrs();
+  field_value_set(c, std::get<0>(entries), 11);
+  field_value_set(c, std::get<1>(entries), 22);
+  EXPECT_EQ(field_value_get(c, std::get<0>(entries)), 11);
+  EXPECT_EQ(field_value_get(c, std::get<1>(entries)), 22);
+}
+
 } // namespace test
 
 } // namespace fory
diff --git a/cpp/fory/meta/preprocessor.h b/cpp/fory/meta/preprocessor.h
index 5ebe684ec..64c40fbb6 100644
--- a/cpp/fory/meta/preprocessor.h
+++ b/cpp/fory/meta/preprocessor.h
@@ -97,6 +97,12 @@
 #define FORY_PP_TUPLE_FIRST_IMPL(a, ...) a
 #define FORY_PP_TUPLE_SECOND(tuple) FORY_PP_TUPLE_SECOND_IMPL tuple
 #define FORY_PP_TUPLE_SECOND_IMPL(a, b, ...) b
+#define FORY_PP_TUPLE_THIRD(tuple) FORY_PP_TUPLE_THIRD_IMPL tuple
+#define FORY_PP_TUPLE_THIRD_IMPL(a, b, c, ...) c
+#define FORY_PP_TUPLE_FOURTH(tuple) FORY_PP_TUPLE_FOURTH_IMPL tuple
+#define FORY_PP_TUPLE_FOURTH_IMPL(a, b, c, d, ...) d
+#define FORY_PP_TUPLE_FIFTH(tuple) FORY_PP_TUPLE_FIFTH_IMPL tuple
+#define FORY_PP_TUPLE_FIFTH_IMPL(a, b, c, d, e, ...) e
 
 #define FORY_PP_INVOKE(X, ...) X(__VA_ARGS__)
 
diff --git a/cpp/fory/serialization/struct_compatible_test.cc 
b/cpp/fory/serialization/struct_compatible_test.cc
index da3450eae..b062191ca 100644
--- a/cpp/fory/serialization/struct_compatible_test.cc
+++ b/cpp/fory/serialization/struct_compatible_test.cc
@@ -348,6 +348,52 @@ struct OptionalIntField {
   FORY_STRUCT(OptionalIntField, (value, fory::F(1)));
 };
 
+struct PimplCompatibleAImpl {
+  int32_t a = 0;
+};
+
+class PimplCompatibleA {
+public:
+  PimplCompatibleA() : impl_(std::make_unique<PimplCompatibleAImpl>()) {}
+  explicit PimplCompatibleA(int32_t a) : PimplCompatibleA() { impl_->a = a; }
+  PimplCompatibleA(const PimplCompatibleA &other)
+      : PimplCompatibleA(other.a()) {}
+  PimplCompatibleA &operator=(const PimplCompatibleA &other) {
+    a(other.a());
+    return *this;
+  }
+  PimplCompatibleA(PimplCompatibleA &&) noexcept = default;
+  PimplCompatibleA &operator=(PimplCompatibleA &&) noexcept = default;
+
+  const int32_t &a() const { return impl_->a; }
+  int32_t &a() { return impl_->a; }
+  PimplCompatibleA &a(int32_t value) {
+    impl_->a = value;
+    return *this;
+  }
+
+private:
+  std::unique_ptr<PimplCompatibleAImpl> impl_;
+
+public:
+  FORY_STRUCT(PimplCompatibleA, FORY_PROPERTY(a));
+};
+
+struct DirectCompatibleA {
+  int32_t a = 0;
+  FORY_STRUCT(DirectCompatibleA, a);
+};
+
+struct PimplCompatibleB {
+  PimplCompatibleA a;
+  FORY_STRUCT(PimplCompatibleB, a);
+};
+
+struct DirectCompatibleB {
+  DirectCompatibleA a;
+  FORY_STRUCT(DirectCompatibleB, a);
+};
+
 // ============================================================================
 // TESTS
 // ============================================================================
@@ -685,6 +731,31 @@ TEST(SchemaEvolutionTest, 
ChangedSchemaReadsExactUnsignedFields) {
   EXPECT_EQ(decoded.value().count, value.count);
 }
 
+TEST(SchemaEvolutionTest, PimplPropertyNestedNamedStruct) {
+  auto writer = Fory::builder().compatible(true).xlang(true).build();
+  auto reader = Fory::builder().compatible(true).xlang(true).build();
+
+  ASSERT_TRUE(writer.register_struct<PimplCompatibleA>("pimpl", "A").ok());
+  ASSERT_TRUE(writer.register_struct<PimplCompatibleB>("pimpl", "B").ok());
+  ASSERT_TRUE(reader.register_struct<DirectCompatibleA>("pimpl", "A").ok());
+  ASSERT_TRUE(reader.register_struct<DirectCompatibleB>("pimpl", "B").ok());
+
+  auto type_info = writer.type_resolver().get_type_info<PimplCompatibleA>();
+  ASSERT_TRUE(type_info.ok()) << type_info.error().to_string();
+  EXPECT_EQ(type_info.value()->type_id,
+            static_cast<uint32_t>(TypeId::NAMED_COMPATIBLE_STRUCT));
+
+  PimplCompatibleB original;
+  original.a.a(2468);
+  auto bytes = writer.serialize(original);
+  ASSERT_TRUE(bytes.ok()) << bytes.error().to_string();
+
+  auto decoded = reader.deserialize<DirectCompatibleB>(bytes.value().data(),
+                                                       bytes.value().size());
+  ASSERT_TRUE(decoded.ok()) << decoded.error().to_string();
+  EXPECT_EQ(decoded.value().a.a, 2468);
+}
+
 TEST(SchemaEvolutionTest, ScalarBoolString) {
   auto decoded =
       convert_field<ScalarStringField, ScalarBoolField>({"true"}, 1010);
diff --git a/cpp/fory/serialization/struct_serializer.h 
b/cpp/fory/serialization/struct_serializer.h
index d8a6e2dd2..2954c850e 100644
--- a/cpp/fory/serialization/struct_serializer.h
+++ b/cpp/fory/serialization/struct_serializer.h
@@ -1242,13 +1242,21 @@ template <typename T> struct CompileTimeFieldHelpers {
   static inline constexpr auto ptrs = FieldDescriptor::ptrs();
   using FieldPtrs = decltype(ptrs);
 
+  template <size_t Index>
+  using EntryType = std::tuple_element_t<Index, FieldPtrs>;
+  template <size_t Index>
+  using RawFieldTypeFor = meta::FieldRawTypeT<T, EntryType<Index>>;
+  template <size_t Index>
+  using ValueType = unwrap_field_t<RawFieldTypeFor<Index>>;
+  template <size_t Index> static constexpr bool is_direct_field() {
+    return !meta::IsPropertyDescriptorV<EntryType<Index>>;
+  }
+
   template <size_t Index> static constexpr uint32_t field_type_id() {
     if constexpr (FieldCount == 0) {
       return 0;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
-      using FieldType = unwrap_field_t<RawFieldType>;
+      using FieldType = ValueType<Index>;
 
       if constexpr (::fory::detail::has_field_config_v<T>) {
         constexpr uint32_t effective_tid =
@@ -1283,8 +1291,7 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return false;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
+      using RawFieldType = RawFieldTypeFor<Index>;
 
       if constexpr (is_fory_field_v<RawFieldType>) {
         return RawFieldType::is_nullable;
@@ -1312,8 +1319,7 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return -1;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
+      using RawFieldType = RawFieldTypeFor<Index>;
 
       if constexpr (::fory::detail::has_field_config_v<T>) {
         constexpr int16_t config_id =
@@ -1351,8 +1357,7 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return false;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
+      using RawFieldType = RawFieldTypeFor<Index>;
 
       if constexpr (is_fory_field_v<RawFieldType>) {
         return RawFieldType::track_ref;
@@ -1379,8 +1384,7 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return -1; // AUTO
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
+      using RawFieldType = RawFieldTypeFor<Index>;
 
       if constexpr (is_fory_field_v<RawFieldType>) {
         return RawFieldType::dynamic_value;
@@ -1408,9 +1412,7 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return false;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
-      using FieldType = unwrap_field_t<RawFieldType>;
+      using FieldType = ValueType<Index>;
 
       constexpr TypeId field_type_id = Serializer<FieldType>::type_id;
       constexpr bool is_struct = is_struct_type(field_type_id);
@@ -1442,9 +1444,7 @@ template <typename T> struct CompileTimeFieldHelpers {
 
   /// get the underlying field type.
   template <size_t Index> struct UnwrappedFieldTypeHelper {
-    using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-    using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
-    using type = unwrap_field_t<RawFieldType>;
+    using type = ValueType<Index>;
   };
   template <size_t Index>
   using UnwrappedFieldType = typename UnwrappedFieldTypeHelper<Index>::type;
@@ -1456,9 +1456,7 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return false;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
-      using FieldType = unwrap_field_t<RawFieldType>;
+      using FieldType = ValueType<Index>;
       // Check the unwrapped type
       return is_nullable_v<FieldType>;
     }
@@ -1472,9 +1470,10 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return false;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
-      using FieldType = unwrap_field_t<RawFieldType>;
+      if constexpr (!is_direct_field<Index>()) {
+        return false;
+      }
+      using FieldType = ValueType<Index>;
 
       if constexpr (is_configurable_int_v<FieldType>) {
         return configurable_int_is_fixed<FieldType, T, Index>();
@@ -1498,9 +1497,10 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return false;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
-      using FieldType = unwrap_field_t<RawFieldType>;
+      if constexpr (!is_direct_field<Index>()) {
+        return false;
+      }
+      using FieldType = ValueType<Index>;
 
       if constexpr (is_configurable_int_v<FieldType>) {
         return configurable_int_is_varint<FieldType, T, Index>();
@@ -1518,9 +1518,10 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return 0;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
-      using FieldType = unwrap_field_t<RawFieldType>;
+      if constexpr (!is_direct_field<Index>()) {
+        return 0;
+      }
+      using FieldType = ValueType<Index>;
       if constexpr (std::is_same_v<FieldType, bool> ||
                     std::is_same_v<FieldType, int8_t> ||
                     std::is_same_v<FieldType, uint8_t>) {
@@ -1547,9 +1548,10 @@ template <typename T> struct CompileTimeFieldHelpers {
     if constexpr (FieldCount == 0) {
       return 0;
     } else {
-      using PtrT = std::tuple_element_t<Index, FieldPtrs>;
-      using RawFieldType = meta::RemoveMemberPointerCVRefT<PtrT>;
-      using FieldType = unwrap_field_t<RawFieldType>;
+      if constexpr (!is_direct_field<Index>()) {
+        return 0;
+      }
+      using FieldType = ValueType<Index>;
 
       if constexpr (is_configurable_int_v<FieldType>) {
         return configurable_int_max_varint_bytes<FieldType, T, Index>();
@@ -1565,6 +1567,16 @@ template <typename T> struct CompileTimeFieldHelpers {
   }
 
   /// Create arrays of field encoding info at compile time
+  template <size_t... Indices>
+  static constexpr std::array<bool, FieldCount>
+  make_direct_field_array(std::index_sequence<Indices...>) {
+    if constexpr (FieldCount == 0) {
+      return {};
+    } else {
+      return {is_direct_field<Indices>()...};
+    }
+  }
+
   template <size_t... Indices>
   static constexpr std::array<bool, FieldCount>
   make_field_is_fixed_array(std::index_sequence<Indices...>) {
@@ -1607,6 +1619,8 @@ template <typename T> struct CompileTimeFieldHelpers {
 
   /// Arrays storing encoding info for each field (indexed by original field
   /// index)
+  static inline constexpr std::array<bool, FieldCount> direct_fields =
+      make_direct_field_array(std::make_index_sequence<FieldCount>{});
   static inline constexpr std::array<bool, FieldCount> field_is_fixed =
       make_field_is_fixed_array(std::make_index_sequence<FieldCount>{});
   static inline constexpr std::array<bool, FieldCount> field_is_varint =
@@ -1848,7 +1862,7 @@ template <typename T> struct CompileTimeFieldHelpers {
     } else {
       uint32_t tid = type_ids[index];
       bool nullable = nullable_flags[index];
-      if (is_primitive_type_id(tid)) {
+      if (direct_fields[index] && is_primitive_type_id(tid)) {
         return nullable ? 1 : 0;
       }
       return 2;
@@ -1969,8 +1983,8 @@ template <typename T> struct CompileTimeFieldHelpers {
       return true;
     } else {
       for (size_t i = 0; i < FieldCount; ++i) {
-        if (!is_primitive_type_id(type_ids[i]) || nullable_flags[i] ||
-            nullable_type_flags[i]) {
+        if (!direct_fields[i] || !is_primitive_type_id(type_ids[i]) ||
+            nullable_flags[i] || nullable_type_flags[i]) {
           return false;
         }
       }
@@ -2045,7 +2059,8 @@ template <typename T> struct CompileTimeFieldHelpers {
       size_t count = 0;
       for (size_t i = 0; i < FieldCount; ++i) {
         size_t original_idx = sorted_indices[i];
-        if (is_primitive_type_id(type_ids[original_idx]) &&
+        if (direct_fields[original_idx] &&
+            is_primitive_type_id(type_ids[original_idx]) &&
             !nullable_flags[original_idx] &&
             !nullable_type_flags[original_idx]) {
           ++count;
@@ -2313,10 +2328,10 @@ FORY_ALWAYS_INLINE void write_single_fixed_field(const 
T &obj, Buffer &buffer,
   const auto field_info = fory_field_info(obj);
   const auto field_ptr =
       std::get<original_index>(decltype(field_info)::ptrs_ref());
+  static_assert(Helpers::template is_direct_field<original_index>());
   using RawFieldType =
       typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
   using FieldType = unwrap_field_t<RawFieldType>;
-  // get the actual field value.
   const FieldType &field_value = [&]() -> const FieldType & {
     if constexpr (is_fory_field_v<RawFieldType>) {
       return (obj.*field_ptr).value;
@@ -2355,10 +2370,10 @@ FORY_ALWAYS_INLINE void write_single_varint_field(const 
T &obj, Buffer &buffer,
   const auto field_info = fory_field_info(obj);
   const auto field_ptr =
       std::get<original_index>(decltype(field_info)::ptrs_ref());
+  static_assert(Helpers::template is_direct_field<original_index>());
   using RawFieldType =
       typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
   using FieldType = unwrap_field_t<RawFieldType>;
-  // get the actual field value.
   const FieldType &field_value = [&]() -> const FieldType & {
     if constexpr (is_fory_field_v<RawFieldType>) {
       return (obj.*field_ptr).value;
@@ -2398,10 +2413,10 @@ write_single_remaining_field(const T &obj, Buffer 
&buffer, uint32_t &offset) {
   const auto field_info = fory_field_info(obj);
   const auto field_ptr =
       std::get<original_index>(decltype(field_info)::ptrs_ref());
+  static_assert(Helpers::template is_direct_field<original_index>());
   using RawFieldType =
       typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
   using FieldType = unwrap_field_t<RawFieldType>;
-  // get the actual field value.
   const FieldType &field_value = [&]() -> const FieldType & {
     if constexpr (is_fory_field_v<RawFieldType>) {
       return (obj.*field_ptr).value;
@@ -2481,18 +2496,19 @@ template <typename T, size_t Index, typename FieldPtrs>
 void write_single_field(const T &obj, WriteContext &ctx,
                         const FieldPtrs &field_ptrs, bool has_generics) {
   using Helpers = CompileTimeFieldHelpers<T>;
-  const auto field_ptr = std::get<Index>(field_ptrs);
-  using RawFieldType =
-      typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
+  const auto field_entry = std::get<Index>(field_ptrs);
+  using RawFieldType = meta::FieldRawTypeT<T, decltype(field_entry)>;
   using FieldType = unwrap_field_t<RawFieldType>;
 
-  // get the actual field value.
-  const auto &raw_field_ref = obj.*field_ptr;
-  const FieldType &field_value = [&]() -> const FieldType & {
-    if constexpr (is_fory_field_v<RawFieldType>) {
-      return raw_field_ref.value;
+  auto &&field_value = [&]() -> decltype(auto) {
+    if constexpr (Helpers::template is_direct_field<Index>()) {
+      if constexpr (is_fory_field_v<RawFieldType>) {
+        return (obj.*field_entry).value;
+      } else {
+        return (obj.*field_entry);
+      }
     } else {
-      return raw_field_ref;
+      return field_value_get(obj, field_entry);
     }
   }();
 
@@ -2956,9 +2972,8 @@ void read_single_field_by_index(T &obj, ReadContext &ctx) 
{
   using Helpers = CompileTimeFieldHelpers<T>;
   const auto field_info = fory_field_info(obj);
   const auto &field_ptrs = decltype(field_info)::ptrs_ref();
-  const auto field_ptr = std::get<Index>(field_ptrs);
-  using RawFieldType =
-      typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
+  const auto field_entry = std::get<Index>(field_ptrs);
+  using RawFieldType = meta::FieldRawTypeT<T, decltype(field_entry)>;
   using FieldType = unwrap_field_t<RawFieldType>;
 
   // In non-compatible mode, no type info for fields except for polymorphic
@@ -3004,11 +3019,7 @@ void read_single_field_by_index(T &obj, ReadContext 
&ctx) {
   if constexpr (configured_node_has_override<T, Index>()) {
     FieldType result = read_configured_value<FieldType, T, Index, 0>(
         ctx, field_ref_mode, read_type);
-    if constexpr (is_fory_field_v<RawFieldType>) {
-      (obj.*field_ptr).value = std::move(result);
-    } else {
-      obj.*field_ptr = std::move(result);
-    }
+    field_value_set(obj, field_entry, std::move(result));
     return;
   }
 
@@ -3024,12 +3035,7 @@ void read_single_field_by_index(T &obj, ReadContext 
&ctx) {
     } else {
       value = read_primitive_field_direct<FieldType>(ctx, ctx.error());
     }
-    // Assign to field.
-    if constexpr (is_fory_field_v<RawFieldType>) {
-      (obj.*field_ptr).value = value;
-    } else {
-      obj.*field_ptr = value;
-    }
+    field_value_set(obj, field_entry, value);
   } else {
     // Special handling for std::optional<uint32_t/uint64_t> with encoding
     // config
@@ -3053,11 +3059,7 @@ void read_single_field_by_index(T &obj, ReadContext 
&ctx) {
         return;
       }
       if (flag == NULL_FLAG) {
-        if constexpr (is_fory_field_v<RawFieldType>) {
-          (obj.*field_ptr).value = std::nullopt;
-        } else {
-          obj.*field_ptr = std::nullopt;
-        }
+        field_value_set(obj, field_entry, std::nullopt);
         return;
       }
       // Read the value with encoding-aware reading
@@ -3078,11 +3080,7 @@ void read_single_field_by_index(T &obj, ReadContext 
&ctx) {
           value = ctx.read_uint64(ctx.error());
         }
       }
-      if constexpr (is_fory_field_v<RawFieldType>) {
-        (obj.*field_ptr).value = std::optional<InnerType>(value);
-      } else {
-        obj.*field_ptr = std::optional<InnerType>(value);
-      }
+      field_value_set(obj, field_entry, std::optional<InnerType>(value));
     } else if constexpr (is_encoded_optional_int) {
       constexpr auto enc =
           ::fory::detail::GetFieldConfigEntry<T, Index>::encoding;
@@ -3091,11 +3089,7 @@ void read_single_field_by_index(T &obj, ReadContext 
&ctx) {
         return;
       }
       if (flag == NULL_FLAG) {
-        if constexpr (is_fory_field_v<RawFieldType>) {
-          (obj.*field_ptr).value = std::nullopt;
-        } else {
-          obj.*field_ptr = std::nullopt;
-        }
+        field_value_set(obj, field_entry, std::nullopt);
         return;
       }
       using InnerType = typename 
std::remove_reference_t<FieldType>::value_type;
@@ -3117,20 +3111,12 @@ void read_single_field_by_index(T &obj, ReadContext 
&ctx) {
           value = static_cast<InnerType>(ctx.read_var_int64(ctx.error()));
         }
       }
-      if constexpr (is_fory_field_v<RawFieldType>) {
-        (obj.*field_ptr).value = std::optional<InnerType>(value);
-      } else {
-        obj.*field_ptr = std::optional<InnerType>(value);
-      }
+      field_value_set(obj, field_entry, std::optional<InnerType>(value));
     } else {
       // Assign to field.
       FieldType result =
           Serializer<FieldType>::read(ctx, field_ref_mode, read_type);
-      if constexpr (is_fory_field_v<RawFieldType>) {
-        (obj.*field_ptr).value = std::move(result);
-      } else {
-        obj.*field_ptr = std::move(result);
-      }
+      field_value_set(obj, field_entry, std::move(result));
     }
   }
 }
@@ -3144,9 +3130,8 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
   using Helpers = CompileTimeFieldHelpers<T>;
   const auto field_info = fory_field_info(obj);
   const auto &field_ptrs = decltype(field_info)::ptrs_ref();
-  const auto field_ptr = std::get<Index>(field_ptrs);
-  using RawFieldType =
-      typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
+  const auto field_entry = std::get<Index>(field_ptrs);
+  using RawFieldType = meta::FieldRawTypeT<T, decltype(field_entry)>;
   using FieldType = unwrap_field_t<RawFieldType>;
   const RefMode remote_ref_mode = remote_field_type.ref_mode;
   const uint32_t remote_type_id = remote_field_type.type_id;
@@ -3206,11 +3191,7 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
       if (FORY_PREDICT_FALSE(ctx.has_error())) {
         return;
       }
-      if constexpr (is_fory_field_v<RawFieldType>) {
-        (obj.*field_ptr).value = std::move(result);
-      } else {
-        obj.*field_ptr = std::move(result);
-      }
+      field_value_set(obj, field_entry, std::move(result));
       return;
     }
   }
@@ -3229,11 +3210,7 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
             return;
           }
           if (!has_value) {
-            if constexpr (is_fory_field_v<RawFieldType>) {
-              (obj.*field_ptr).value = std::nullopt;
-            } else {
-              obj.*field_ptr = std::nullopt;
-            }
+            field_value_set(obj, field_entry, std::nullopt);
             return;
           }
         }
@@ -3242,11 +3219,8 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
         if (FORY_PREDICT_FALSE(ctx.has_error())) {
           return;
         }
-        if constexpr (is_fory_field_v<RawFieldType>) {
-          (obj.*field_ptr).value = std::optional<InnerType>(std::move(value));
-        } else {
-          obj.*field_ptr = std::optional<InnerType>(std::move(value));
-        }
+        field_value_set(obj, field_entry,
+                        std::optional<InnerType>(std::move(value)));
         return;
       }
     }
@@ -3258,13 +3232,9 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
   if constexpr (is_raw_prim && is_primitive_field) {
     if (remote_ref_mode == RefMode::None) {
       // Remote is non-nullable, no ref flag
-      if constexpr (is_fory_field_v<RawFieldType>) {
-        (obj.*field_ptr).value = read_primitive_by_type_id<FieldType>(
-            ctx, remote_type_id, ctx.error());
-      } else {
-        obj.*field_ptr = read_primitive_by_type_id<FieldType>(
-            ctx, remote_type_id, ctx.error());
-      }
+      FieldType value = read_primitive_by_type_id<FieldType>(
+          ctx, remote_type_id, ctx.error());
+      field_value_set(obj, field_entry, std::move(value));
       return;
     } else {
       // Remote is nullable, has ref flag
@@ -3279,13 +3249,9 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
         return;
       }
       // NOT_NULL_VALUE_FLAG or REF_VALUE_FLAG - read the value
-      if constexpr (is_fory_field_v<RawFieldType>) {
-        (obj.*field_ptr).value = read_primitive_by_type_id<FieldType>(
-            ctx, remote_type_id, ctx.error());
-      } else {
-        obj.*field_ptr = read_primitive_by_type_id<FieldType>(
-            ctx, remote_type_id, ctx.error());
-      }
+      FieldType value = read_primitive_by_type_id<FieldType>(
+          ctx, remote_type_id, ctx.error());
+      field_value_set(obj, field_entry, std::move(value));
       return;
     }
   }
@@ -3304,11 +3270,7 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
         if (FORY_PREDICT_FALSE(ctx.has_error())) {
           return;
         }
-        if constexpr (is_fory_field_v<RawFieldType>) {
-          (obj.*field_ptr).value = std::optional<InnerType>(value);
-        } else {
-          obj.*field_ptr = std::optional<InnerType>(value);
-        }
+        field_value_set(obj, field_entry, std::optional<InnerType>(value));
         return;
       } else {
         // Remote is nullable, has ref flag
@@ -3318,11 +3280,7 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
         }
         if (flag == NULL_FLAG) {
           // Null value - set optional to nullopt
-          if constexpr (is_fory_field_v<RawFieldType>) {
-            (obj.*field_ptr).value = std::nullopt;
-          } else {
-            obj.*field_ptr = std::nullopt;
-          }
+          field_value_set(obj, field_entry, std::nullopt);
           return;
         }
         // NOT_NULL_VALUE_FLAG or REF_VALUE_FLAG - read the value
@@ -3331,11 +3289,7 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
         if (FORY_PREDICT_FALSE(ctx.has_error())) {
           return;
         }
-        if constexpr (is_fory_field_v<RawFieldType>) {
-          (obj.*field_ptr).value = std::optional<InnerType>(value);
-        } else {
-          obj.*field_ptr = std::optional<InnerType>(value);
-        }
+        field_value_set(obj, field_entry, std::optional<InnerType>(value));
         return;
       }
     }
@@ -3358,11 +3312,7 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
         constexpr int8_t child = configured_node_child<T, Index, 0, 0>();
         FieldType result = read_configured_list_data_as_array_field<
             FieldType, T, Index, 0, child>(ctx, remote_element_type.type_id);
-        if constexpr (is_fory_field_v<RawFieldType>) {
-          (obj.*field_ptr).value = std::move(result);
-        } else {
-          obj.*field_ptr = std::move(result);
-        }
+        field_value_set(obj, field_entry, std::move(result));
         return;
       }
     } else if constexpr (configured_as_list) {
@@ -3370,11 +3320,7 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
       if (primitive_array_element_type_id(remote_type_id, element_type_id)) {
         FieldType result = read_configured_array_data_as_list_field<FieldType>(
             ctx, remote_ref_mode);
-        if constexpr (is_fory_field_v<RawFieldType>) {
-          (obj.*field_ptr).value = std::move(result);
-        } else {
-          obj.*field_ptr = std::move(result);
-        }
+        field_value_set(obj, field_entry, std::move(result));
         return;
       }
     }
@@ -3383,22 +3329,14 @@ void read_single_field_by_index_compatible(T &obj, 
ReadContext &ctx,
   if constexpr (configured_node_has_override<T, Index>()) {
     FieldType result = read_configured_value<FieldType, T, Index, 0>(
         ctx, remote_ref_mode, read_type);
-    if constexpr (is_fory_field_v<RawFieldType>) {
-      (obj.*field_ptr).value = std::move(result);
-    } else {
-      obj.*field_ptr = std::move(result);
-    }
+    field_value_set(obj, field_entry, std::move(result));
     return;
   }
 
   // For non-primitive types, use the standard serializer path
   FieldType result =
       Serializer<FieldType>::read(ctx, remote_ref_mode, read_type);
-  if constexpr (is_fory_field_v<RawFieldType>) {
-    (obj.*field_ptr).value = std::move(result);
-  } else {
-    obj.*field_ptr = std::move(result);
-  }
+  field_value_set(obj, field_entry, std::move(result));
 }
 
 template <typename T, size_t MatchedId>
@@ -3417,13 +3355,12 @@ template <typename T, size_t Index>
 constexpr bool can_read_exact_primitive_with_offset() {
   using Helpers = CompileTimeFieldHelpers<T>;
   constexpr size_t original_index = Helpers::sorted_indices[Index];
-  using FieldPtr =
-      typename std::tuple_element<original_index,
-                                  typename Helpers::FieldPtrs>::type;
-  using RawFieldType = typename meta::RemoveMemberPointerCVRefT<FieldPtr>;
+  using RawFieldType =
+      typename Helpers::template RawFieldTypeFor<original_index>;
   using FieldType = unwrap_field_t<RawFieldType>;
   constexpr TypeId field_type_id = Serializer<FieldType>::type_id;
-  return is_raw_primitive_v<FieldType> && is_primitive_type_id(field_type_id) 
&&
+  return Helpers::template is_direct_field<original_index>() &&
+         is_raw_primitive_v<FieldType> && is_primitive_type_id(field_type_id) 
&&
          !is_nullable_v<FieldType> &&
          !Helpers::template field_nullable<original_index>() &&
          !configured_node_has_override<T, original_index>();
@@ -3437,6 +3374,7 @@ FORY_ALWAYS_INLINE void read_single_exact_primitive_at(T 
&obj, ReadContext &ctx,
   const auto field_info = fory_field_info(obj);
   const auto field_ptr =
       std::get<original_index>(decltype(field_info)::ptrs_ref());
+  static_assert(Helpers::template is_direct_field<original_index>());
   using RawFieldType =
       typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
   using FieldType = unwrap_field_t<RawFieldType>;
@@ -3848,12 +3786,12 @@ FORY_ALWAYS_INLINE void read_single_fixed_field(T &obj, 
Buffer &buffer,
   const auto field_info = fory_field_info(obj);
   const auto field_ptr =
       std::get<original_index>(decltype(field_info)::ptrs_ref());
+  static_assert(Helpers::template is_direct_field<original_index>());
   using RawFieldType =
       typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
   using FieldType = unwrap_field_t<RawFieldType>;
   FieldType result =
       read_fixed_primitive_at<FieldType>(buffer, base_offset + field_offset);
-  // Assign to field.
   if constexpr (is_fory_field_v<RawFieldType>) {
     (obj.*field_ptr).value = result;
   } else {
@@ -4084,6 +4022,7 @@ FORY_ALWAYS_INLINE void read_single_varint_field(T &obj, 
Buffer &buffer,
   const auto field_info = fory_field_info(obj);
   const auto field_ptr =
       std::get<original_index>(decltype(field_info)::ptrs_ref());
+  static_assert(Helpers::template is_direct_field<original_index>());
   using RawFieldType =
       typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
   using FieldType = unwrap_field_t<RawFieldType>;
@@ -4096,7 +4035,6 @@ FORY_ALWAYS_INLINE void read_single_varint_field(T &obj, 
Buffer &buffer,
     result = read_varint_at<FieldType>(buffer, offset);
   }
 
-  // Assign to field.
   if constexpr (is_fory_field_v<RawFieldType>) {
     (obj.*field_ptr).value = result;
   } else {
diff --git a/cpp/fory/serialization/struct_test.cc 
b/cpp/fory/serialization/struct_test.cc
index 970cf7154..0d2ba527d 100644
--- a/cpp/fory/serialization/struct_test.cc
+++ b/cpp/fory/serialization/struct_test.cc
@@ -37,6 +37,7 @@
 #include <climits>
 #include <limits>
 #include <map>
+#include <memory>
 #include <optional>
 #include <string>
 #include <vector>
@@ -165,6 +166,74 @@ public:
   FORY_STRUCT(PrivateFieldsStruct, id_, name_, scores_);
 };
 
+struct PimplPropertyImpl {
+  int32_t a = 0;
+};
+
+class PimplPropertyStruct {
+public:
+  PimplPropertyStruct() : impl_(std::make_unique<PimplPropertyImpl>()) {}
+  explicit PimplPropertyStruct(int32_t a) : PimplPropertyStruct() {
+    impl_->a = a;
+  }
+  PimplPropertyStruct(const PimplPropertyStruct &other)
+      : PimplPropertyStruct(other.a()) {}
+  PimplPropertyStruct &operator=(const PimplPropertyStruct &other) {
+    a(other.a());
+    return *this;
+  }
+  PimplPropertyStruct(PimplPropertyStruct &&) noexcept = default;
+  PimplPropertyStruct &operator=(PimplPropertyStruct &&) noexcept = default;
+
+  const int32_t &a() const { return impl_->a; }
+  int32_t &a() { return impl_->a; }
+  PimplPropertyStruct &a(int32_t value) {
+    impl_->a = value;
+    return *this;
+  }
+
+  bool operator==(const PimplPropertyStruct &other) const {
+    return a() == other.a();
+  }
+
+private:
+  std::unique_ptr<PimplPropertyImpl> impl_;
+
+public:
+  FORY_STRUCT(PimplPropertyStruct, FORY_PROPERTY(a));
+};
+
+struct PimplConfiguredPropertyImpl {
+  int32_t value = 0;
+};
+
+class PimplConfiguredPropertyStruct {
+public:
+  PimplConfiguredPropertyStruct()
+      : impl_(std::make_unique<PimplConfiguredPropertyImpl>()) {}
+  explicit PimplConfiguredPropertyStruct(int32_t value)
+      : PimplConfiguredPropertyStruct() {
+    impl_->value = value;
+  }
+
+  const int32_t &get_value() const { return impl_->value; }
+  PimplConfiguredPropertyStruct &set_value(int32_t value) {
+    impl_->value = value;
+    return *this;
+  }
+
+  bool operator==(const PimplConfiguredPropertyStruct &other) const {
+    return get_value() == other.get_value();
+  }
+
+private:
+  std::unique_ptr<PimplConfiguredPropertyImpl> impl_;
+
+public:
+  FORY_STRUCT(PimplConfiguredPropertyStruct,
+              FORY_PROPERTY(value, get_value, set_value, fory::F(1).varint()));
+};
+
 // All primitives
 struct AllPrimitivesStruct {
   bool bool_val;
@@ -536,6 +605,8 @@ inline void register_all_test_types(Fory &fory) {
   fory.register_struct<NumericTaggedOrderStruct>(type_id++);
   fory.register_struct<TaggedGroupedOrderStruct>(type_id++);
   fory.register_struct<PrivateFieldsStruct>(type_id++);
+  fory.register_struct<PimplPropertyStruct>(type_id++);
+  fory.register_struct<PimplConfiguredPropertyStruct>(type_id++);
   fory.register_struct<AllPrimitivesStruct>(type_id++);
   fory.register_struct<StringTestStruct>(type_id++);
   fory.register_struct<Point2D>(type_id++);
@@ -629,6 +700,14 @@ TEST(StructComprehensiveTest, PrivateFieldsStruct) {
   test_roundtrip(PrivateFieldsStruct{42, "secret", {1, 2, 3}});
 }
 
+TEST(StructComprehensiveTest, PimplPropertyStruct) {
+  test_roundtrip(PimplPropertyStruct{12345});
+}
+
+TEST(StructComprehensiveTest, PimplConfiguredPropertyStruct) {
+  test_roundtrip(PimplConfiguredPropertyStruct{-12345});
+}
+
 TEST(StructComprehensiveTest, AllPrimitivesZero) {
   test_roundtrip(AllPrimitivesStruct{false, 0, 0, 0, 0, 0, 0, 0, 0, 0.0f, 
0.0});
 }
diff --git a/cpp/fory/serialization/type_resolver.h 
b/cpp/fory/serialization/type_resolver.h
index 85774d168..5e6ec218b 100644
--- a/cpp/fory/serialization/type_resolver.h
+++ b/cpp/fory/serialization/type_resolver.h
@@ -1175,9 +1175,8 @@ template <typename T, size_t Index> struct 
FieldInfoBuilder {
         ::fory::to_snake_case<max_snake_len>(original_name);
     std::string field_name(snake_buffer.data(), snake_len);
 
-    const auto field_ptr = std::get<Index>(field_ptrs);
-    using RawFieldType =
-        typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
+    const auto field_entry = std::get<Index>(field_ptrs);
+    using RawFieldType = meta::FieldRawTypeT<T, decltype(field_entry)>;
     using ActualFieldType =
         std::remove_cv_t<std::remove_reference_t<RawFieldType>>;
     using UnwrappedFieldType = fory::unwrap_field_t<ActualFieldType>;
@@ -1222,9 +1221,8 @@ template <typename T, size_t Index> struct 
FieldInfoBuilder {
         ::fory::to_snake_case<max_snake_len>(original_name);
     std::string field_name(snake_buffer.data(), snake_len);
 
-    const auto field_ptr = std::get<Index>(field_ptrs);
-    using RawFieldType =
-        typename meta::RemoveMemberPointerCVRefT<decltype(field_ptr)>;
+    const auto field_entry = std::get<Index>(field_ptrs);
+    using RawFieldType = meta::FieldRawTypeT<T, decltype(field_entry)>;
     using ActualFieldType =
         std::remove_cv_t<std::remove_reference_t<RawFieldType>>;
     using UnwrappedFieldType = fory::unwrap_field_t<ActualFieldType>;
diff --git a/docs/benchmarks/cpp/README.md b/docs/benchmarks/cpp/README.md
index f8fde1a79..8b201fb77 100644
--- a/docs/benchmarks/cpp/README.md
+++ b/docs/benchmarks/cpp/README.md
@@ -1,6 +1,6 @@
 # C++ Benchmark Performance Report
 
-_Generated on 2026-05-08 17:54:45_
+_Generated on 2026-06-12 16:14:04_
 
 ## How to Generate This Report
 
@@ -27,7 +27,7 @@ The plot shows throughput (ops/sec); higher is better.
 | CPU Cores (Physical)       | 12                        |
 | CPU Cores (Logical)        | 12                        |
 | Total RAM (GB)             | 48.0                      |
-| Benchmark Date             | 2026-05-08T16:29:28+08:00 |
+| Benchmark Date             | 2026-06-12T16:13:27+08:00 |
 | CPU Cores (from benchmark) | 12                        |
 
 ## Benchmark Results
@@ -36,35 +36,35 @@ The plot shows throughput (ops/sec); higher is better.
 
 | Datatype          | Operation   | fory (ns) | protobuf (ns) | msgpack (ns) | 
Fastest |
 | ----------------- | ----------- | --------- | ------------- | ------------ | 
------- |
-| NumericStruct     | Serialize   | 24.9      | 48.2          | 91.0         | 
fory    |
-| NumericStruct     | Deserialize | 26.6      | 33.0          | 1194.5       | 
fory    |
-| Sample            | Serialize   | 62.3      | 97.3          | 314.6        | 
fory    |
-| Sample            | Deserialize | 371.1     | 689.0         | 2649.9       | 
fory    |
-| MediaContent      | Serialize   | 115.0     | 857.2         | 311.7        | 
fory    |
-| MediaContent      | Deserialize | 406.5     | 1193.1        | 3311.1       | 
fory    |
-| NumericStructList | Serialize   | 81.7      | 495.0         | 485.6        | 
fory    |
-| NumericStructList | Deserialize | 180.9     | 410.6         | 5733.1       | 
fory    |
-| SampleList        | Serialize   | 284.9     | 5004.9        | 1579.6       | 
fory    |
-| SampleList        | Deserialize | 1928.7    | 5118.1        | 13396.8      | 
fory    |
-| MediaContentList  | Serialize   | 464.8     | 4861.1        | 1671.1       | 
fory    |
-| MediaContentList  | Deserialize | 2099.8    | 6610.3        | 13963.4      | 
fory    |
+| NumericStruct     | Serialize   | 25.7      | 48.2          | 85.4         | 
fory    |
+| NumericStruct     | Deserialize | 25.1      | 31.8          | 887.2        | 
fory    |
+| Sample            | Serialize   | 60.6      | 96.4          | 361.6        | 
fory    |
+| Sample            | Deserialize | 176.7     | 397.0         | 2031.6       | 
fory    |
+| MediaContent      | Serialize   | 113.5     | 471.0         | 290.4        | 
fory    |
+| MediaContent      | Deserialize | 247.3     | 641.9         | 2015.4       | 
fory    |
+| NumericStructList | Serialize   | 83.3      | 372.9         | 446.9        | 
fory    |
+| NumericStructList | Deserialize | 158.1     | 268.2         | 4342.4       | 
fory    |
+| SampleList        | Serialize   | 258.8     | 2829.7        | 2602.3       | 
fory    |
+| SampleList        | Deserialize | 1001.7    | 2794.4        | 12220.7      | 
fory    |
+| MediaContentList  | Serialize   | 504.2     | 2589.8        | 1549.5       | 
fory    |
+| MediaContentList  | Deserialize | 1258.6    | 3620.3        | 10263.4      | 
fory    |
 
 ### Throughput Results (ops/sec)
 
 | Datatype          | Operation   | fory TPS   | protobuf TPS | msgpack TPS | 
Fastest |
 | ----------------- | ----------- | ---------- | ------------ | ----------- | 
------- |
-| NumericStruct     | Serialize   | 40,087,668 | 20,733,305   | 10,989,907  | 
fory    |
-| NumericStruct     | Deserialize | 37,606,127 | 30,296,744   | 837,189     | 
fory    |
-| Sample            | Serialize   | 16,041,299 | 10,277,207   | 3,178,983   | 
fory    |
-| Sample            | Deserialize | 2,694,434  | 1,451,449    | 377,373     | 
fory    |
-| MediaContent      | Serialize   | 8,698,574  | 1,166,539    | 3,208,626   | 
fory    |
-| MediaContent      | Deserialize | 2,460,094  | 838,185      | 302,013     | 
fory    |
-| NumericStructList | Serialize   | 12,240,275 | 2,020,102    | 2,059,276   | 
fory    |
-| NumericStructList | Deserialize | 5,527,333  | 2,435,246    | 174,427     | 
fory    |
-| SampleList        | Serialize   | 3,510,210  | 199,804      | 633,061     | 
fory    |
-| SampleList        | Deserialize | 518,490    | 195,386      | 74,645      | 
fory    |
-| MediaContentList  | Serialize   | 2,151,560  | 205,715      | 598,396     | 
fory    |
-| MediaContentList  | Deserialize | 476,241    | 151,280      | 71,616      | 
fory    |
+| NumericStruct     | Serialize   | 38,845,461 | 20,734,963   | 11,707,994  | 
fory    |
+| NumericStruct     | Deserialize | 39,872,217 | 31,443,829   | 1,127,092   | 
fory    |
+| Sample            | Serialize   | 16,496,488 | 10,372,657   | 2,765,312   | 
fory    |
+| Sample            | Deserialize | 5,660,852  | 2,518,926    | 492,232     | 
fory    |
+| MediaContent      | Serialize   | 8,808,084  | 2,122,926    | 3,443,519   | 
fory    |
+| MediaContent      | Deserialize | 4,043,028  | 1,557,819    | 496,175     | 
fory    |
+| NumericStructList | Serialize   | 11,999,598 | 2,681,661    | 2,237,536   | 
fory    |
+| NumericStructList | Deserialize | 6,323,730  | 3,728,133    | 230,285     | 
fory    |
+| SampleList        | Serialize   | 3,864,068  | 353,391      | 384,276     | 
fory    |
+| SampleList        | Deserialize | 998,326    | 357,854      | 81,828      | 
fory    |
+| MediaContentList  | Serialize   | 1,983,502  | 386,135      | 645,372     | 
fory    |
+| MediaContentList  | Deserialize | 794,544    | 276,221      | 97,434      | 
fory    |
 
 ### Serialized Data Sizes (bytes)
 
diff --git a/docs/benchmarks/cpp/throughput.png 
b/docs/benchmarks/cpp/throughput.png
index 4ae6119fa..b2d6bfffb 100644
Binary files a/docs/benchmarks/cpp/throughput.png and 
b/docs/benchmarks/cpp/throughput.png differ
diff --git a/docs/guide/cpp/basic-serialization.md 
b/docs/guide/cpp/basic-serialization.md
index 3a1647b3d..b4f9c5893 100644
--- a/docs/guide/cpp/basic-serialization.md
+++ b/docs/guide/cpp/basic-serialization.md
@@ -210,6 +210,63 @@ public:
 };
 ```
 
+### Accessor Properties
+
+Use `FORY_PROPERTY` when the serialized field is exposed through accessor
+methods instead of a data member. This keeps the type registered as a normal
+struct type:
+
+```cpp
+struct AccountImpl {
+  int32_t id = 0;
+};
+
+class Account {
+public:
+  explicit Account(AccountImpl *impl) : impl_(impl) {}
+
+  const int32_t &id() const { return impl_->id; }
+  Account &id(int32_t value) {
+    impl_->id = value;
+    return *this;
+  }
+
+private:
+  AccountImpl *impl_ = nullptr;
+
+public:
+  FORY_STRUCT(Account, FORY_PROPERTY(id));
+};
+```
+
+`FORY_PROPERTY(id)` calls `obj.id()` to read the field and `obj.id(value)` to
+write it. The field type is inferred from the const getter return type with
+cv-qualifiers and references removed, so `const int32_t &` is treated as
+`int32_t`.
+
+Use the three-argument form when the getter and setter have different names:
+
+```cpp
+class User {
+public:
+  const int32_t &get_id() const;
+  void set_id(int32_t value);
+
+  FORY_STRUCT(User, FORY_PROPERTY(id, get_id, set_id));
+};
+```
+
+Field metadata can be attached as the final argument:
+
+```cpp
+FORY_STRUCT(Account, FORY_PROPERTY(id, fory::F().varint()));
+FORY_STRUCT(User, FORY_PROPERTY(id, get_id, set_id, fory::F(1).varint()));
+```
+
+When `FORY_STRUCT` is declared at namespace scope, the accessor methods must be
+public. For private PIMPL accessors or private data members, place
+`FORY_STRUCT` inside the class in a `public:` section.
+
 The macro:
 
 1. Generates compile-time field metadata
@@ -228,7 +285,7 @@ The macro:
 ## External / Third-Party Types
 
 When you cannot modify a third-party type, use `FORY_STRUCT` at namespace
-scope. This only works with **public** fields.
+scope. This only works with public data members or public accessor methods.
 
 ```cpp
 namespace thirdparty {
@@ -244,7 +301,7 @@ FORY_STRUCT(Foo, id, name);
 **Limitations:**
 
 - Must be declared at namespace scope in the same namespace as the type
-- Only public fields are supported
+- Only public data members or accessor methods are supported
 
 ## Inherited Fields
 
diff --git a/docs/guide/cpp/schema-metadata.md 
b/docs/guide/cpp/schema-metadata.md
index 9d1c55542..fe455dd60 100644
--- a/docs/guide/cpp/schema-metadata.md
+++ b/docs/guide/cpp/schema-metadata.md
@@ -20,8 +20,8 @@ license: |
 ---
 
 Field configuration is embedded directly in `FORY_STRUCT`. A field entry may be
-bare, or it may be a tuple containing the member name and a `fory::F(...)`
-builder:
+bare, it may be a tuple containing the member name and a `fory::F(...)`
+builder, or it may be a configured accessor property:
 
 ```cpp
 #include "fory/serialization/fory.h"
@@ -35,6 +35,18 @@ struct DataV2 {
 FORY_STRUCT(DataV2, id, (timestamp, fory::F().tagged()), version);
 ```
 
+Accessor properties use the same field metadata as data members:
+
+```cpp
+class Counter {
+public:
+  const uint32_t &value() const;
+  Counter &value(uint32_t value);
+
+  FORY_STRUCT(Counter, FORY_PROPERTY(value, fory::F().varint()));
+};
+```
+
 The configuration is compile-time metadata. It does not allocate codec objects
 or add virtual dispatch on the serialization path.
 
@@ -44,6 +56,7 @@ or add virtual dispatch on the serialization path.
 
 ```cpp
 FORY_STRUCT(DataV2, id, (timestamp, fory::F().tagged()), version);
+FORY_STRUCT(Counter, FORY_PROPERTY(value, fory::F().varint()));
 ```
 
 `fory::F(id)` uses explicit id-based field identity. IDs must be


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to