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 d60de394b feat(go): add go struct field tag support (#3082)
d60de394b is described below
commit d60de394b010bf972e315cdf77f91233df954174
Author: Shawn Yang <[email protected]>
AuthorDate: Wed Dec 24 23:16:52 2025 +0800
feat(go): add go struct field tag support (#3082)
## Why?
## What does this PR do?
Closes #3005
#2982
## Does this PR introduce any user-facing change?
- [ ] Does this PR introduce any public API change?
- [ ] Does this PR introduce any binary protocol compatibility change?
## Benchmark
---
cpp/fory/serialization/type_resolver.cc | 45 +-
cpp/fory/serialization/type_resolver.h | 41 +-
go/fory/codegen/decoder.go | 11 +-
go/fory/codegen/encoder.go | 11 +-
go/fory/codegen/generator.go | 23 +-
go/fory/codegen/utils.go | 145 -----
go/fory/errors.go | 18 +
go/fory/struct.go | 260 +++++++--
go/fory/tag.go | 208 +++++++
go/fory/tag_test.go | 613 +++++++++++++++++++++
go/fory/tests/structs_fory_gen.go | 154 ++++--
go/fory/type_def.go | 153 +++--
.../org/apache/fory/resolver/XtypeResolver.java | 5 +-
.../apache/fory/serializer/ObjectSerializer.java | 108 +++-
.../org/apache/fory/type/DescriptorGrouper.java | 38 +-
.../test/java/org/apache/fory/XlangTestBase.java | 32 --
python/pyfory/struct.py | 119 ++--
python/pyfory/tests/xlang_test_main.py | 239 ++++++++
rust/fory-derive/src/object/util.rs | 46 +-
19 files changed, 1874 insertions(+), 395 deletions(-)
diff --git a/cpp/fory/serialization/type_resolver.cc
b/cpp/fory/serialization/type_resolver.cc
index f93a0e06f..900eac39e 100644
--- a/cpp/fory/serialization/type_resolver.cc
+++ b/cpp/fory/serialization/type_resolver.cc
@@ -928,15 +928,37 @@ std::string ToSnakeCase(const std::string &name) {
} // anonymous namespace
-int32_t TypeMeta::compute_struct_version(const TypeMeta &meta) {
- // Build fingerprint string as in Rust's compute_struct_version_hash:
- // snake_case(field_name),effective_type_id,nullable; ...
+std::string TypeMeta::compute_struct_fingerprint(
+ const std::vector<FieldInfo> &field_infos) {
+ // Computes the fingerprint string for a struct type used in schema
+ // versioning.
+ //
+ // Fingerprint Format:
+ // Each field contributes: <field_name>,<type_id>,<ref>,<nullable>;
+ // Fields are sorted lexicographically by field name (not by type
category).
+ //
+ // Field Components:
+ // - field_name: snake_case field name (C++ doesn't support field tag IDs
+ // yet)
+ // - type_id: Fory TypeId as decimal string (e.g., "4" for INT32)
+ // - ref: "1" if reference tracking enabled, "0" otherwise (always "0" in
+ // C++)
+ // - nullable: "1" if null flag is written, "0" otherwise
+ //
+ // Example fingerprint: "age,4,0,0;name,12,0,1;"
+
+ // Copy fields and sort lexicographically by snake_case name for fingerprint
+ std::vector<FieldInfo> sorted_fields = field_infos;
+ std::sort(sorted_fields.begin(), sorted_fields.end(),
+ [](const FieldInfo &a, const FieldInfo &b) {
+ return ToSnakeCase(a.field_name) < ToSnakeCase(b.field_name);
+ });
+
std::string fingerprint;
- const auto &fields = meta.field_infos;
// Reserve a rough estimate to avoid reallocations
- fingerprint.reserve(fields.size() * 24);
+ fingerprint.reserve(sorted_fields.size() * 24);
- for (const auto &fi : fields) {
+ for (const auto &fi : sorted_fields) {
std::string snake = ToSnakeCase(fi.field_name);
fingerprint.append(snake);
fingerprint.push_back(',');
@@ -951,14 +973,23 @@ int32_t TypeMeta::compute_struct_version(const TypeMeta
&meta) {
}
fingerprint.append(std::to_string(effective_type_id));
fingerprint.push_back(',');
+ // ref flag: currently always 0 in C++ (no ref tracking support yet)
+ fingerprint.push_back('0');
+ fingerprint.push_back(',');
fingerprint.append(fi.field_type.nullable ? "1;" : "0;");
}
+ return fingerprint;
+}
+
+int32_t TypeMeta::compute_struct_version(const TypeMeta &meta) {
+ std::string fingerprint = compute_struct_fingerprint(meta.field_infos);
+
int64_t hash_out[2] = {0, 0};
MurmurHash3_x64_128(reinterpret_cast<const uint8_t *>(fingerprint.data()),
static_cast<int>(fingerprint.size()), 47, hash_out);
- // Rust uses the low 64 bits and then keeps low 32 bits as i32.
+ // Use the low 64 bits and then keep low 32 bits as i32.
uint64_t low = static_cast<uint64_t>(hash_out[0]);
uint32_t version = static_cast<uint32_t>(low & 0xFFFF'FFFFu);
#ifdef FORY_DEBUG
diff --git a/cpp/fory/serialization/type_resolver.h
b/cpp/fory/serialization/type_resolver.h
index a4a605735..3e40189ab 100644
--- a/cpp/fory/serialization/type_resolver.h
+++ b/cpp/fory/serialization/type_resolver.h
@@ -215,23 +215,36 @@ public:
const std::string &get_type_name() const { return type_name; }
const std::string &get_namespace() const { return namespace_str; }
- /// Compute struct version hash from field metadata.
+ /// Computes the fingerprint string for a struct type used in schema
+ /// versioning.
+ ///
+ /// Fingerprint Format:
+ /// Each field contributes: `<field_name>,<type_id>,<ref>,<nullable>;`
+ /// Fields are sorted lexicographically by field name (not by type
+ /// category).
+ ///
+ /// Field Components:
+ /// - field_name: snake_case field name (C++ doesn't support field tag IDs
+ /// yet)
+ /// - type_id: Fory TypeId as decimal string (e.g., "4" for INT32)
+ /// - ref: "1" if reference tracking enabled, "0" otherwise (always "0" in
+ /// C++)
+ /// - nullable: "1" if null flag is written, "0" otherwise
///
- /// This mirrors Rust's
- /// `compute_struct_version_hash` in
- /// rust/fory-derive/src/object/util.rs:
+ /// Example fingerprint: "age,4,0,0;name,12,0,1;"
+ ///
+ /// This format is consistent across Go, Java, Rust, and C++ implementations.
+ static std::string
+ compute_struct_fingerprint(const std::vector<FieldInfo> &field_infos);
+
+ /// Compute struct version hash from field metadata.
///
- /// - Build a fingerprint string using
- /// `snake_case(field_name),effective_type_id,nullable;`
- /// for each field in the sorted order defined by the
- /// xlang spec (primitive, nullable primitive, internal,
- /// list, set, map, other).
- /// - Hash the fingerprint with MurmurHash3_x64_128 using
- /// seed 47, then take the low 32 bits as signed i32.
+ /// Uses compute_struct_fingerprint to build the fingerprint string,
+ /// then hashes it with MurmurHash3_x64_128 using seed 47, and takes
+ /// the low 32 bits as signed i32.
///
- /// Java's ObjectSerializer.computeStructHash uses the same
- /// algorithm, so this function provides the cross-language
- /// struct version ID used by class version checking.
+ /// This provides the cross-language struct version ID used by class
+ /// version checking, consistent with Go, Java, and Rust implementations.
static int32_t compute_struct_version(const TypeMeta &meta);
private:
diff --git a/go/fory/codegen/decoder.go b/go/fory/codegen/decoder.go
index 2d708ee6d..0f0960408 100644
--- a/go/fory/codegen/decoder.go
+++ b/go/fory/codegen/decoder.go
@@ -27,20 +27,18 @@ import (
// generateReadTyped generates the strongly-typed ReadData method
func generateReadTyped(buf *bytes.Buffer, s *StructInfo) error {
- hash := computeStructHash(s)
-
fmt.Fprintf(buf, "// ReadTyped provides strongly-typed deserialization
with no reflection overhead\n")
- fmt.Fprintf(buf, "func (g %s_ForyGenSerializer) ReadTyped(ctx
*fory.ReadContext, v *%s) error {\n", s.Name, s.Name)
+ fmt.Fprintf(buf, "func (g *%s_ForyGenSerializer) ReadTyped(ctx
*fory.ReadContext, v *%s) error {\n", s.Name, s.Name)
fmt.Fprintf(buf, "\tbuf := ctx.Buffer()\n")
fmt.Fprintf(buf, "\terr := ctx.Err() // Get error pointer for deferred
error checking\n\n")
// ReadData and verify struct hash
fmt.Fprintf(buf, "\t// ReadData and verify struct hash\n")
- fmt.Fprintf(buf, "\tif got := buf.ReadInt32(err); got != %d {\n", hash)
+ fmt.Fprintf(buf, "\tif got := buf.ReadInt32(err); got != g.structHash
{\n")
fmt.Fprintf(buf, "\t\tif ctx.HasError() {\n")
fmt.Fprintf(buf, "\t\t\treturn ctx.TakeError()\n")
fmt.Fprintf(buf, "\t\t}\n")
- fmt.Fprintf(buf, "\t\treturn fory.HashMismatchError(got, %d,
\"%s\")\n", hash, s.Name)
+ fmt.Fprintf(buf, "\t\treturn fory.HashMismatchError(got, g.structHash,
\"%s\")\n", s.Name)
fmt.Fprintf(buf, "\t}\n\n")
// ReadData fields in sorted order
@@ -65,7 +63,8 @@ func generateReadTyped(buf *bytes.Buffer, s *StructInfo)
error {
func generateReadInterface(buf *bytes.Buffer, s *StructInfo) error {
// Generate ReadData method (reflect.Value-based API)
fmt.Fprintf(buf, "// ReadData provides reflect.Value interface
compatibility (implements fory.Serializer)\n")
- fmt.Fprintf(buf, "func (g %s_ForyGenSerializer) ReadData(ctx
*fory.ReadContext, type_ reflect.Type, value reflect.Value) {\n", s.Name)
+ fmt.Fprintf(buf, "func (g *%s_ForyGenSerializer) ReadData(ctx
*fory.ReadContext, type_ reflect.Type, value reflect.Value) {\n", s.Name)
+ fmt.Fprintf(buf, "\tg.initHash(ctx.TypeResolver())\n")
fmt.Fprintf(buf, "\t// Convert reflect.Value to concrete type and
delegate to typed method\n")
fmt.Fprintf(buf, "\tvar v *%s\n", s.Name)
fmt.Fprintf(buf, "\tif value.Kind() == reflect.Ptr {\n")
diff --git a/go/fory/codegen/encoder.go b/go/fory/codegen/encoder.go
index b90e230b5..17f5b6b42 100644
--- a/go/fory/codegen/encoder.go
+++ b/go/fory/codegen/encoder.go
@@ -27,15 +27,13 @@ import (
// generateWriteTyped generates the strongly-typed WriteData method
func generateWriteTyped(buf *bytes.Buffer, s *StructInfo) error {
- hash := computeStructHash(s)
-
fmt.Fprintf(buf, "// WriteTyped provides strongly-typed serialization
with no reflection overhead\n")
- fmt.Fprintf(buf, "func (g %s_ForyGenSerializer) WriteTyped(ctx
*fory.WriteContext, v *%s) error {\n", s.Name, s.Name)
+ fmt.Fprintf(buf, "func (g *%s_ForyGenSerializer) WriteTyped(ctx
*fory.WriteContext, v *%s) error {\n", s.Name, s.Name)
fmt.Fprintf(buf, "\tbuf := ctx.Buffer()\n")
// WriteData struct hash
- fmt.Fprintf(buf, "\t// WriteData precomputed struct hash for
compatibility checking\n")
- fmt.Fprintf(buf, "\tbuf.WriteInt32(%d) // hash of %s structure\n\n",
hash, s.Name)
+ fmt.Fprintf(buf, "\t// WriteData struct hash for compatibility
checking\n")
+ fmt.Fprintf(buf, "\tbuf.WriteInt32(g.structHash)\n\n")
// WriteData fields in sorted order
fmt.Fprintf(buf, "\t// WriteData fields in sorted order\n")
@@ -54,7 +52,8 @@ func generateWriteTyped(buf *bytes.Buffer, s *StructInfo)
error {
func generateWriteInterface(buf *bytes.Buffer, s *StructInfo) error {
// Generate WriteData method (reflect.Value-based API)
fmt.Fprintf(buf, "// WriteData provides reflect.Value interface
compatibility (implements fory.Serializer)\n")
- fmt.Fprintf(buf, "func (g %s_ForyGenSerializer) WriteData(ctx
*fory.WriteContext, value reflect.Value) {\n", s.Name)
+ fmt.Fprintf(buf, "func (g *%s_ForyGenSerializer) WriteData(ctx
*fory.WriteContext, value reflect.Value) {\n", s.Name)
+ fmt.Fprintf(buf, "\tg.initHash(ctx.TypeResolver())\n")
fmt.Fprintf(buf, "\t// Convert reflect.Value to concrete type and
delegate to typed method\n")
fmt.Fprintf(buf, "\tvar v *%s\n", s.Name)
fmt.Fprintf(buf, "\tif value.Kind() == reflect.Ptr {\n")
diff --git a/go/fory/codegen/generator.go b/go/fory/codegen/generator.go
index f289a40c1..0d7cdfd45 100644
--- a/go/fory/codegen/generator.go
+++ b/go/fory/codegen/generator.go
@@ -390,12 +390,21 @@ func cleanupGeneratedFiles(opts *GeneratorOptions) error {
// generateStructSerializer generates a complete serializer for a struct
func generateStructSerializer(buf *bytes.Buffer, s *StructInfo) error {
- // Generate struct serializer type
- fmt.Fprintf(buf, "type %s_ForyGenSerializer struct {}\n\n", s.Name)
+ // Generate struct serializer type with lazy hash computation
+ fmt.Fprintf(buf, "type %s_ForyGenSerializer struct {\n", s.Name)
+ fmt.Fprintf(buf, "\tstructHash int32\n")
+ fmt.Fprintf(buf, "}\n\n")
// Generate factory function
fmt.Fprintf(buf, "func NewSerializerFor_%s() fory.Serializer {\n",
s.Name)
- fmt.Fprintf(buf, "\treturn %s_ForyGenSerializer{}\n", s.Name)
+ fmt.Fprintf(buf, "\treturn &%s_ForyGenSerializer{}\n", s.Name)
+ fmt.Fprintf(buf, "}\n\n")
+
+ // Generate hash initialization method
+ fmt.Fprintf(buf, "func (g *%s_ForyGenSerializer) initHash(resolver
*fory.TypeResolver) {\n", s.Name)
+ fmt.Fprintf(buf, "\tif g.structHash == 0 {\n")
+ fmt.Fprintf(buf, "\t\tg.structHash =
fory.GetStructHash(reflect.TypeOf(%s{}), resolver)\n", s.Name)
+ fmt.Fprintf(buf, "\t}\n")
fmt.Fprintf(buf, "}\n\n")
// Generate Write method (entry point with ref/type handling)
@@ -438,7 +447,8 @@ func generateStructSerializer(buf *bytes.Buffer, s
*StructInfo) error {
// generateWriteMethod generates the Write method that handles ref/type flags
func generateWriteMethod(buf *bytes.Buffer, s *StructInfo) error {
fmt.Fprintf(buf, "// Write is the entry point for serialization with
ref/type handling\n")
- fmt.Fprintf(buf, "func (g %s_ForyGenSerializer) Write(ctx
*fory.WriteContext, refMode fory.RefMode, writeType bool, hasGenerics bool,
value reflect.Value) {\n", s.Name)
+ fmt.Fprintf(buf, "func (g *%s_ForyGenSerializer) Write(ctx
*fory.WriteContext, refMode fory.RefMode, writeType bool, hasGenerics bool,
value reflect.Value) {\n", s.Name)
+ fmt.Fprintf(buf, "\tg.initHash(ctx.TypeResolver())\n")
fmt.Fprintf(buf, "\t_ = hasGenerics // not used for struct
serializers\n")
fmt.Fprintf(buf, "\tswitch refMode {\n")
fmt.Fprintf(buf, "\tcase fory.RefModeTracking:\n")
@@ -472,7 +482,8 @@ func generateWriteMethod(buf *bytes.Buffer, s *StructInfo)
error {
// generateReadMethod generates the Read method that handles ref/type flags
func generateReadMethod(buf *bytes.Buffer, s *StructInfo) error {
fmt.Fprintf(buf, "// Read is the entry point for deserialization with
ref/type handling\n")
- fmt.Fprintf(buf, "func (g %s_ForyGenSerializer) Read(ctx
*fory.ReadContext, refMode fory.RefMode, readType bool, hasGenerics bool, value
reflect.Value) {\n", s.Name)
+ fmt.Fprintf(buf, "func (g *%s_ForyGenSerializer) Read(ctx
*fory.ReadContext, refMode fory.RefMode, readType bool, hasGenerics bool, value
reflect.Value) {\n", s.Name)
+ fmt.Fprintf(buf, "\tg.initHash(ctx.TypeResolver())\n")
fmt.Fprintf(buf, "\t_ = hasGenerics // not used for struct
serializers\n")
fmt.Fprintf(buf, "\terr := ctx.Err() // Get error pointer for deferred
error checking\n")
fmt.Fprintf(buf, "\tswitch refMode {\n")
@@ -506,7 +517,7 @@ func generateReadMethod(buf *bytes.Buffer, s *StructInfo)
error {
// generateReadWithTypeInfoMethod generates the ReadWithTypeInfo method
func generateReadWithTypeInfoMethod(buf *bytes.Buffer, s *StructInfo) error {
fmt.Fprintf(buf, "// ReadWithTypeInfo deserializes with pre-read type
information\n")
- fmt.Fprintf(buf, "func (g %s_ForyGenSerializer) ReadWithTypeInfo(ctx
*fory.ReadContext, refMode fory.RefMode, typeInfo *fory.TypeInfo, value
reflect.Value) {\n", s.Name)
+ fmt.Fprintf(buf, "func (g *%s_ForyGenSerializer) ReadWithTypeInfo(ctx
*fory.ReadContext, refMode fory.RefMode, typeInfo *fory.TypeInfo, value
reflect.Value) {\n", s.Name)
fmt.Fprintf(buf, "\tg.Read(ctx, refMode, false, false, value)\n")
fmt.Fprintf(buf, "}\n\n")
return nil
diff --git a/go/fory/codegen/utils.go b/go/fory/codegen/utils.go
index 937467408..d8cdf2e55 100644
--- a/go/fory/codegen/utils.go
+++ b/go/fory/codegen/utils.go
@@ -18,14 +18,11 @@
package codegen
import (
- "fmt"
"go/types"
"sort"
- "strings"
"unicode"
"github.com/apache/fory/go/fory"
- "github.com/spaolacci/murmur3"
)
// FieldInfo contains metadata about a struct field
@@ -397,148 +394,6 @@ func getFieldGroup(field *FieldInfo) int {
return groupOther
}
-// computeStructHash computes a hash for struct schema compatibility
-// This implementation follows the new xlang serialization spec:
-// 1. Sort fields by fields sort algorithm (already done in s.Fields)
-// 2. Build string: snake_case(field_name),$type_id,$nullable;
-// 3. For "other fields", use TypeId::UNKNOWN
-// 4. Convert to UTF8 bytes
-// 5. Compute murmurhash3_x64_128, use first 32 bits
-func computeStructHash(s *StructInfo) int32 {
- var hashString strings.Builder
-
- // Iterate through sorted fields
- for _, field := range s.Fields {
- // Append snake_case field name
- hashString.WriteString(field.SnakeName)
- hashString.WriteString(",")
-
- // Append type_id
- typeID := getTypeIDForHash(field)
- hashString.WriteString(fmt.Sprintf("%d", typeID))
- hashString.WriteString(",")
-
- // Append nullable (1 if nullable, 0 otherwise)
- // nullable is determined by field type (matching reflection's
nullable() function)
- nullable := 0
- if isNullableType(field.Type) {
- nullable = 1
- }
- hashString.WriteString(fmt.Sprintf("%d", nullable))
- hashString.WriteString(";")
- }
-
- // Convert to UTF8 bytes
- hashBytes := []byte(hashString.String())
-
- // Compute murmurhash3_x64_128 with seed 47, and use first 32 bits
- // This matches the reflection implementation
- h1, _ := murmur3.Sum128WithSeed(hashBytes, 47)
- hash := int32(h1 & 0xFFFFFFFF)
-
- if hash == 0 {
- panic(fmt.Errorf("hash for type %v is 0", s.Name))
- }
-
- return hash
-}
-
-// isNullableType checks if a type should have nullable=1 in hash computation
-// This matches Java's behavior where primitives have nullable=0 and objects
have nullable=1
-func isNullableType(t types.Type) bool {
- // Check basic types - only primitives return false
- if basic, ok := t.(*types.Basic); ok {
- switch basic.Kind() {
- case types.Bool,
- types.Int8, types.Int16, types.Int32, types.Int64,
- types.Uint8, types.Uint16, types.Uint32, types.Uint64,
- types.Float32, types.Float64,
- types.Int, types.Uint:
- // These are primitive types - not nullable
- return false
- case types.String:
- // String is an object type in Java - nullable
- return true
- }
- }
-
- // All other types (pointers, slices, maps, interfaces, arrays,
structs) are nullable
- return true
-}
-
-// getTypeIDForHash returns the TypeId for hash calculation according to new
spec
-// For "other fields" (groupOther), returns UNKNOWN (63)
-func getTypeIDForHash(field *FieldInfo) int16 {
- // Determine field group
- group := getFieldGroup(field)
-
- // For "other fields", use UNKNOWN
- if group == groupOther {
- return fory.UNKNOWN
- }
-
- // For struct fields declared with concrete slice types,
- // use typeID = LIST uniformly for hash calculation to align
cross-language behavior
- // This matches the reflection implementation
- if field.TypeID == "LIST" {
- return fory.LIST
- }
-
- // Map field TypeID string to Fory TypeId value
- switch field.TypeID {
- case "BOOL":
- return fory.BOOL
- case "INT8":
- return fory.INT8
- case "INT16":
- return fory.INT16
- case "INT32":
- return fory.INT32
- case "INT64":
- return fory.INT64
- case "UINT8":
- return fory.UINT8
- case "UINT16":
- return fory.UINT16
- case "UINT32":
- return fory.UINT32
- case "UINT64":
- return fory.UINT64
- case "FLOAT32":
- return fory.FLOAT
- case "FLOAT64":
- return fory.DOUBLE
- case "STRING":
- return fory.STRING
- case "TIMESTAMP":
- return fory.TIMESTAMP
- case "LOCAL_DATE":
- return fory.LOCAL_DATE
- case "NAMED_STRUCT":
- return fory.NAMED_STRUCT
- case "STRUCT":
- return fory.STRUCT
- case "SET":
- return fory.SET
- case "MAP":
- return fory.MAP
- case "BINARY":
- return fory.BINARY
- case "ENUM":
- return fory.ENUM
- case "NAMED_ENUM":
- return fory.NAMED_ENUM
- case "EXT":
- return fory.EXT
- case "NAMED_EXT":
- return fory.NAMED_EXT
- case "INTERFACE":
- return fory.UNKNOWN // interface{} treated as UNKNOWN
- default:
- return fory.UNKNOWN
- }
-}
-
// getStructNames extracts struct names from StructInfo slice
func getStructNames(structs []*StructInfo) []string {
names := make([]string, len(structs))
diff --git a/go/fory/errors.go b/go/fory/errors.go
index e1d549944..9f8cde95a 100644
--- a/go/fory/errors.go
+++ b/go/fory/errors.go
@@ -44,6 +44,8 @@ const (
ErrKindInvalidRefId
// ErrKindHashMismatch indicates struct hash mismatch
ErrKindHashMismatch
+ // ErrKindInvalidTag indicates invalid fory struct tag configuration
+ ErrKindInvalidTag
)
// Error is a lightweight error type optimized for hot path performance.
@@ -200,6 +202,22 @@ func InvalidRefIdError(refId int32) Error {
}
}
+// InvalidTagError creates an invalid fory struct tag error
+func InvalidTagError(msg string) Error {
+ return Error{
+ kind: ErrKindInvalidTag,
+ message: msg,
+ }
+}
+
+// InvalidTagErrorf creates a formatted invalid fory struct tag error
+func InvalidTagErrorf(format string, args ...interface{}) Error {
+ return Error{
+ kind: ErrKindInvalidTag,
+ message: fmt.Sprintf(format, args...),
+ }
+}
+
// WrapError wraps a standard error into a fory Error
func WrapError(err error, kind ErrorKind) Error {
if err == nil {
diff --git a/go/fory/struct.go b/go/fory/struct.go
index aa506c777..849a86ca6 100644
--- a/go/fory/struct.go
+++ b/go/fory/struct.go
@@ -53,6 +53,12 @@ type FieldInfo struct {
RefMode RefMode // ref mode for serializer.Write/Read
WriteType bool // whether to write type info (true for struct
fields in compatible mode)
HasGenerics bool // whether element types are known from TypeDef
(for container fields)
+
+ // Tag-based configuration (from fory struct tags)
+ TagID int // -1 = use field name, >=0 = use tag ID
+ HasForyTag bool // Whether field has explicit fory tag
+ TagRefSet bool // Whether ref was explicitly set via fory tag
+ TagRef bool // The ref value from fory tag (only valid if TagRefSet
is true)
}
// fieldHasNonPrimitiveSerializer returns true if the field has a serializer
with a non-primitive type ID.
@@ -995,6 +1001,7 @@ func (s *structSerializer)
initFieldsFromTypeResolver(typeResolver *TypeResolver
var serializers []Serializer
var typeIds []TypeId
var nullables []bool
+ var tagIDs []int
for i := 0; i < type_.NumField(); i++ {
field := type_.Field(i)
@@ -1003,6 +1010,12 @@ func (s *structSerializer)
initFieldsFromTypeResolver(typeResolver *TypeResolver
continue // skip unexported fields
}
+ // Parse fory struct tag and check for ignore
+ foryTag := ParseForyTag(field)
+ if foryTag.Ignore {
+ continue // skip ignored fields
+ }
+
fieldType := field.Type
var fieldSerializer Serializer
@@ -1035,9 +1048,20 @@ func (s *structSerializer)
initFieldsFromTypeResolver(typeResolver *TypeResolver
if isUserDefinedType(int16(internalId)) || internalId == ENUM
|| internalId == NAMED_ENUM {
nullableFlag = true
}
- // Pre-compute RefMode based on trackRef and nullable flag
+ // Override nullable flag if explicitly set in fory tag
+ if foryTag.NullableSet {
+ nullableFlag = foryTag.Nullable
+ }
+
+ // Calculate ref tracking - use tag override if explicitly set
+ trackRef := typeResolver.TrackRef()
+ if foryTag.RefSet {
+ trackRef = foryTag.Ref
+ }
+
+ // Pre-compute RefMode based on (possibly overridden) trackRef
and nullable
refMode := RefModeNone
- if typeResolver.TrackRef() && nullableFlag {
+ if trackRef && nullableFlag {
refMode = RefModeTracking
} else if nullableFlag {
refMode = RefModeNullOnly
@@ -1069,16 +1093,21 @@ func (s *structSerializer)
initFieldsFromTypeResolver(typeResolver *TypeResolver
RefMode: refMode,
WriteType: writeType,
HasGenerics: isCollectionType(fieldTypeId), //
Container fields have declared element types
+ TagID: foryTag.ID,
+ HasForyTag: foryTag.HasTag,
+ TagRefSet: foryTag.RefSet,
+ TagRef: foryTag.Ref,
}
fields = append(fields, fieldInfo)
fieldNames = append(fieldNames, fieldInfo.Name)
serializers = append(serializers, fieldSerializer)
typeIds = append(typeIds, fieldTypeId)
nullables = append(nullables, nullableFlag)
+ tagIDs = append(tagIDs, foryTag.ID)
}
- // Sort fields according to specification using nullable info for
consistent ordering
- serializers, fieldNames = sortFields(typeResolver, fieldNames,
serializers, typeIds, nullables)
+ // Sort fields according to specification using nullable info and tag
IDs for consistent ordering
+ serializers, fieldNames = sortFields(typeResolver, fieldNames,
serializers, typeIds, nullables, tagIDs)
order := make(map[string]int, len(fieldNames))
for idx, name := range fieldNames {
order[name] = idx
@@ -1195,16 +1224,35 @@ func (s *structSerializer)
initFieldsFromDefsWithResolver(typeResolver *TypeReso
return nil
}
- // Build map from field names to struct field indices
+ // Build maps from field names and tag IDs to struct field indices
fieldNameToIndex := make(map[string]int)
fieldNameToOffset := make(map[string]uintptr)
fieldNameToType := make(map[string]reflect.Type)
+ fieldTagIDToIndex := make(map[int]int) // tag ID -> struct
field index
+ fieldTagIDToOffset := make(map[int]uintptr) // tag ID -> field offset
+ fieldTagIDToType := make(map[int]reflect.Type) // tag ID -> field type
+ fieldTagIDToName := make(map[int]string) // tag ID -> snake_case
field name
for i := 0; i < type_.NumField(); i++ {
field := type_.Field(i)
+
+ // Parse fory tag and skip ignored fields
+ foryTag := ParseForyTag(field)
+ if foryTag.Ignore {
+ continue
+ }
+
name := SnakeCase(field.Name)
fieldNameToIndex[name] = i
fieldNameToOffset[name] = field.Offset
fieldNameToType[name] = field.Type
+
+ // Also index by tag ID if present
+ if foryTag.ID >= 0 {
+ fieldTagIDToIndex[foryTag.ID] = i
+ fieldTagIDToOffset[foryTag.ID] = field.Offset
+ fieldTagIDToType[foryTag.ID] = field.Type
+ fieldTagIDToName[foryTag.ID] = name
+ }
}
var fields []*FieldInfo
@@ -1237,12 +1285,43 @@ func (s *structSerializer)
initFieldsFromDefsWithResolver(typeResolver *TypeReso
isStructLikeField := isStructFieldType(def.fieldType)
// Try to find corresponding local field
+ // First try to match by tag ID (if remote def uses tag ID)
+ // Then fall back to matching by field name
fieldIndex := -1
var offset uintptr
var fieldType reflect.Type
+ var localFieldName string
+ var localType reflect.Type
+ var exists bool
+
+ if def.tagID >= 0 {
+ // Try to match by tag ID
+ if idx, ok := fieldTagIDToIndex[def.tagID]; ok {
+ exists = true
+ fieldIndex = idx // Will be overwritten if
types are compatible
+ localType = fieldTagIDToType[def.tagID]
+ offset = fieldTagIDToOffset[def.tagID]
+ localFieldName = fieldTagIDToName[def.tagID]
+ _ = fieldIndex // Use to avoid compiler
warning, will be set properly below
+ }
+ }
+
+ // Fall back to name-based matching if tag ID match failed
+ if !exists && def.name != "" {
+ if idx, ok := fieldNameToIndex[def.name]; ok {
+ exists = true
+ localType = fieldNameToType[def.name]
+ offset = fieldNameToOffset[def.name]
+ localFieldName = def.name
+ _ = idx // Will be set properly below
+ }
+ }
- if idx, exists := fieldNameToIndex[def.name]; exists {
- localType := fieldNameToType[def.name]
+ if exists {
+ idx := fieldNameToIndex[localFieldName]
+ if def.tagID >= 0 {
+ idx = fieldTagIDToIndex[def.tagID]
+ }
// Check if types are compatible
// For primitive types: skip if types don't match
// For struct-like types: allow read even if TypeDef
lookup failed,
@@ -1306,7 +1385,7 @@ func (s *structSerializer)
initFieldsFromDefsWithResolver(typeResolver *TypeReso
if shouldRead {
fieldIndex = idx
- offset = fieldNameToOffset[def.name]
+ // offset was already set above when matching
by tag ID or field name
// For struct-like fields with failed type
lookup, get the serializer for the local type
if typeLookupFailed && isStructLikeField &&
fieldSerializer == nil {
fieldSerializer, _ =
typeResolver.getSerializerByType(localType, true)
@@ -1398,8 +1477,14 @@ func (s *structSerializer)
initFieldsFromDefsWithResolver(typeResolver *TypeReso
}
}
+ // Determine field name: use local field name if matched,
otherwise use def.name
+ fieldName := def.name
+ if localFieldName != "" {
+ fieldName = localFieldName
+ }
+
fieldInfo := &FieldInfo{
- Name: def.name,
+ Name: fieldName,
Offset: offset,
Type: fieldType,
StaticId: staticId,
@@ -1411,6 +1496,8 @@ func (s *structSerializer)
initFieldsFromDefsWithResolver(typeResolver *TypeReso
RefMode: refMode,
WriteType: writeType,
HasGenerics: isCollectionType(fieldTypeId), //
Container fields have declared element types
+ TagID: def.tagID,
+ HasForyTag: def.tagID >= 0,
}
fields = append(fields, fieldInfo)
}
@@ -1518,13 +1605,91 @@ func isStructFieldType(ft FieldType) bool {
return false
}
-func (s *structSerializer) computeHash() int32 {
- var sb strings.Builder
+// FieldFingerprintInfo contains the information needed to compute a field's
fingerprint.
+type FieldFingerprintInfo struct {
+ // FieldID is the tag ID if configured (>= 0), or -1 to use field name
+ FieldID int
+ // FieldName is the snake_case field name (used when FieldID < 0)
+ FieldName string
+ // TypeID is the Fory type ID for the field
+ TypeID TypeId
+ // Ref is true if reference tracking is enabled for this field
+ Ref bool
+ // Nullable is true if null flag is written for this field
+ Nullable bool
+}
- for _, field := range s.fields {
- sb.WriteString(SnakeCase(field.Name))
+// ComputeStructFingerprint computes the fingerprint string for a struct type.
+//
+// Fingerprint Format:
+//
+// Each field contributes: "<field_id_or_name>,<type_id>,<ref>,<nullable>;"
+// Fields are sorted by field_id_or_name (lexicographically as strings)
+//
+// Field Components:
+// - field_id_or_name: Tag ID as string if configured (e.g., "0", "1"),
otherwise snake_case field name
+// - type_id: Fory TypeId as decimal string (e.g., "4" for INT32)
+// - ref: "1" if reference tracking enabled, "0" otherwise
+// - nullable: "1" if null flag is written, "0" otherwise
+//
+// Example fingerprints:
+// - With tag IDs: "0,4,0,0;1,4,0,1;2,9,0,1;"
+// - With field names: "age,4,0,0;name,9,0,1;"
+//
+// The fingerprint is used to compute a hash for struct schema versioning.
+// Different nullable/ref settings will produce different fingerprints,
+// ensuring schema compatibility is properly validated.
+func ComputeStructFingerprint(fields []FieldFingerprintInfo) string {
+ // Sort fields by their identifier (field ID or name)
+ type fieldWithKey struct {
+ field FieldFingerprintInfo
+ sortKey string
+ }
+ fieldsWithKeys := make([]fieldWithKey, 0, len(fields))
+ for _, field := range fields {
+ var sortKey string
+ if field.FieldID >= 0 {
+ sortKey = fmt.Sprintf("%d", field.FieldID)
+ } else {
+ sortKey = field.FieldName
+ }
+ fieldsWithKeys = append(fieldsWithKeys, fieldWithKey{field:
field, sortKey: sortKey})
+ }
+
+ sort.Slice(fieldsWithKeys, func(i, j int) bool {
+ return fieldsWithKeys[i].sortKey < fieldsWithKeys[j].sortKey
+ })
+
+ var sb strings.Builder
+ for _, fw := range fieldsWithKeys {
+ // Field identifier
+ sb.WriteString(fw.sortKey)
+ sb.WriteString(",")
+ // Type ID
+ sb.WriteString(fmt.Sprintf("%d", fw.field.TypeID))
sb.WriteString(",")
+ // Ref flag
+ if fw.field.Ref {
+ sb.WriteString("1")
+ } else {
+ sb.WriteString("0")
+ }
+ sb.WriteString(",")
+ // Nullable flag
+ if fw.field.Nullable {
+ sb.WriteString("1")
+ } else {
+ sb.WriteString("0")
+ }
+ sb.WriteString(";")
+ }
+ return sb.String()
+}
+func (s *structSerializer) computeHash() int32 {
+ // Build FieldFingerprintInfo for each field
+ fields := make([]FieldFingerprintInfo, 0, len(s.fields))
+ for _, field := range s.fields {
var typeId TypeId
isEnumField := false
if field.Serializer == nil {
@@ -1534,17 +1699,14 @@ func (s *structSerializer) computeHash() int32 {
// Check if this is an enum serializer (directly or
wrapped in ptrToValueSerializer)
if _, ok := field.Serializer.(*enumSerializer); ok {
isEnumField = true
- // Java uses UNKNOWN (0) for enum types in
fingerprint computation
typeId = UNKNOWN
} else if ptrSer, ok :=
field.Serializer.(*ptrToValueSerializer); ok {
if _, ok :=
ptrSer.valueSerializer.(*enumSerializer); ok {
isEnumField = true
- // Java uses UNKNOWN (0) for enum types
in fingerprint computation
typeId = UNKNOWN
}
}
// For fixed-size arrays with primitive elements, use
primitive array type IDs
- // This matches Python's int8_array, int16_array, etc.
types
if field.Type.Kind() == reflect.Array {
elemKind := field.Type.Elem().Kind()
switch elemKind {
@@ -1564,26 +1726,29 @@ func (s *structSerializer) computeHash() int32 {
typeId = LIST
}
} else if field.Type.Kind() == reflect.Slice {
- // Slices use LIST type ID (maps to Python
List[T])
typeId = LIST
}
}
- sb.WriteString(fmt.Sprintf("%d", typeId))
- sb.WriteString(",")
- // For cross-language hash compatibility, nullable=0 only for
primitive non-pointer types
- // This matches Java's behavior where isPrimitive() returns
true only for int, long, boolean, etc.
- // Go strings and other non-primitive types should have
nullable=1
- // Enum types are always nullable (like Java enums which are
objects)
- nullableFlag := "1"
- if isNonNullablePrimitiveKind(field.Type.Kind()) &&
!field.Referencable && !isEnumField {
- nullableFlag = "0"
+ // Determine nullable flag based on field type
+ // Use the same logic as codegen for consistency
+ nullable := true
+ if isNonNullablePrimitiveKind(field.Type.Kind()) &&
!isEnumField {
+ nullable = false
}
- sb.WriteString(nullableFlag)
- sb.WriteString(";")
+
+ fields = append(fields, FieldFingerprintInfo{
+ FieldID: field.TagID,
+ FieldName: SnakeCase(field.Name),
+ TypeID: typeId,
+ // Ref is based on explicit tag annotation only, NOT
runtime ref_tracking config
+ // This allows fingerprint to be computed at compile
time for C++/Rust
+ Ref: field.TagRefSet && field.TagRef,
+ Nullable: nullable,
+ })
}
- hashString := sb.String()
+ hashString := ComputeStructFingerprint(fields)
data := []byte(hashString)
h1, _ := murmur3.Sum128WithSeed(data, 47)
hash := int32(h1 & 0xFFFFFFFF)
@@ -1598,6 +1763,16 @@ func (s *structSerializer) computeHash() int32 {
return hash
}
+// GetStructHash returns the struct hash for a given type using the provided
TypeResolver.
+// This is used by codegen serializers to get the hash at runtime.
+func GetStructHash(type_ reflect.Type, resolver *TypeResolver) int32 {
+ ser := newStructSerializer(type_, "", nil)
+ if err := ser.initialize(resolver); err != nil {
+ panic(fmt.Errorf("failed to initialize struct serializer for
hash computation: %v", err))
+ }
+ return ser.structHash
+}
+
// Field sorting helpers
type triple struct {
@@ -1605,17 +1780,30 @@ type triple struct {
serializer Serializer
name string
nullable bool
+ tagID int // -1 = use field name, >=0 = use tag ID for sorting
+}
+
+// getFieldSortKey returns the sort key for a field.
+// If tagID >= 0, returns the tag ID as string (for tag-based sorting).
+// Otherwise returns the snake_case field name.
+func (t triple) getSortKey() string {
+ if t.tagID >= 0 {
+ return fmt.Sprintf("%d", t.tagID)
+ }
+ return SnakeCase(t.name)
}
// sortFields sorts fields with nullable information to match Java's field
ordering.
// Java separates primitive types (int, long) from boxed types (Integer, Long).
// In Go, this corresponds to non-pointer primitives vs pointer-to-primitive.
+// When tagIDs are provided (>= 0), fields are sorted by tag ID instead of
field name.
func sortFields(
typeResolver *TypeResolver,
fieldNames []string,
serializers []Serializer,
typeIds []TypeId,
nullables []bool,
+ tagIDs []int,
) ([]Serializer, []string) {
var (
typeTriples []triple
@@ -1625,11 +1813,15 @@ func sortFields(
for i, name := range fieldNames {
ser := serializers[i]
+ tagID := TagIDUseFieldName // default: use field name
+ if tagIDs != nil && i < len(tagIDs) {
+ tagID = tagIDs[i]
+ }
if ser == nil {
- others = append(others, triple{UNKNOWN, nil, name,
nullables[i]})
+ others = append(others, triple{UNKNOWN, nil, name,
nullables[i], tagID})
continue
}
- typeTriples = append(typeTriples, triple{typeIds[i], ser, name,
nullables[i]})
+ typeTriples = append(typeTriples, triple{typeIds[i], ser, name,
nullables[i], tagID})
}
// Java orders: primitives, boxed, finals, others, collections, maps
// primitives = non-nullable primitive types (int, long, etc.)
@@ -1674,7 +1866,7 @@ func sortFields(
if szI != szJ {
return szI > szJ
}
- return SnakeCase(ai.name) < SnakeCase(aj.name)
+ return ai.getSortKey() < aj.getSortKey()
})
}
sortPrimitiveSlice(primitives)
@@ -1684,12 +1876,12 @@ func sortFields(
if s[i].typeID != s[j].typeID {
return s[i].typeID < s[j].typeID
}
- return SnakeCase(s[i].name) < SnakeCase(s[j].name)
+ return s[i].getSortKey() < s[j].getSortKey()
})
}
sortTuple := func(s []triple) {
sort.Slice(s, func(i, j int) bool {
- return SnakeCase(s[i].name) < SnakeCase(s[j].name)
+ return s[i].getSortKey() < s[j].getSortKey()
})
}
sortByTypeIDThenName(otherInternalTypeFields)
diff --git a/go/fory/tag.go b/go/fory/tag.go
new file mode 100644
index 000000000..d07154374
--- /dev/null
+++ b/go/fory/tag.go
@@ -0,0 +1,208 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package fory
+
+import (
+ "reflect"
+ "strconv"
+ "strings"
+)
+
+const (
+ // TagIDUseFieldName indicates field name should be used instead of tag
ID
+ TagIDUseFieldName = -1
+)
+
+// ForyTag represents parsed fory struct tag options.
+//
+// Tag format: `fory:"id=N,nullable=bool,ref=bool,ignore=bool"` or `fory:"-"`
+//
+// Options:
+// - id: Field tag ID. -1 (default) uses field name, >=0 uses numeric tag ID
for compact encoding
+// - nullable: Whether to write null flag. Default false (skip null flag for
non-nullable fields)
+// - ref: Whether to enable reference tracking. Default false (skip ref
tracking overhead)
+// - ignore: Whether to skip this field during serialization. Default false
+//
+// Examples:
+//
+// type Example struct {
+// Name string `fory:"id=0"` // Use tag
ID 0
+// Age int `fory:"id=1,nullable=false"` // Explicit
nullable=false
+// Email *string `fory:"id=2,nullable=true,ref=false"` // Nullable
pointer, no ref tracking
+// Parent *Node `fory:"id=3,ref=true,nullable=true"` // With
reference tracking
+// Secret string `fory:"ignore"` // Skip this
field
+// Hidden string `fory:"-"` // Skip this
field (shorthand)
+// }
+type ForyTag struct {
+ ID int // Field tag ID (-1 = use field name, >=0 = use tag ID)
+ Nullable bool // Whether to write null flag (default: false)
+ Ref bool // Whether to enable reference tracking (default: false)
+ Ignore bool // Whether to ignore this field during serialization
(default: false)
+ HasTag bool // Whether field has fory tag at all
+
+ // Track which options were explicitly set (for override logic)
+ NullableSet bool
+ RefSet bool
+ IgnoreSet bool
+}
+
+// ParseForyTag parses a fory struct tag from reflect.StructField.Tag.
+//
+// Tag format: `fory:"id=N,nullable=bool,ref=bool,ignore=bool"` or `fory:"-"`
+//
+// Supported syntaxes:
+// - Key-value: `nullable=true`, `ref=false`, `ignore=true`, `id=0`
+// - Standalone flags: `nullable`, `ref`, `ignore` (equivalent to =true)
+// - Shorthand: `-` (equivalent to `ignore=true`)
+func ParseForyTag(field reflect.StructField) ForyTag {
+ tag := ForyTag{
+ ID: TagIDUseFieldName,
+ Nullable: false,
+ Ref: false,
+ Ignore: false,
+ HasTag: false,
+ }
+
+ tagValue, ok := field.Tag.Lookup("fory")
+ if !ok {
+ return tag
+ }
+
+ tag.HasTag = true
+
+ // Handle "-" shorthand for ignore
+ if tagValue == "-" {
+ tag.Ignore = true
+ tag.IgnoreSet = true
+ return tag
+ }
+
+ // Parse comma-separated options
+ parts := strings.Split(tagValue, ",")
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+
+ // Handle key=value pairs and standalone flags
+ if idx := strings.Index(part, "="); idx >= 0 {
+ key := strings.TrimSpace(part[:idx])
+ value := strings.TrimSpace(part[idx+1:])
+
+ switch key {
+ case "id":
+ if id, err := strconv.Atoi(value); err == nil {
+ tag.ID = id
+ }
+ case "nullable":
+ tag.Nullable = parseBool(value)
+ tag.NullableSet = true
+ case "ref":
+ tag.Ref = parseBool(value)
+ tag.RefSet = true
+ case "ignore":
+ tag.Ignore = parseBool(value)
+ tag.IgnoreSet = true
+ }
+ } else {
+ // Handle standalone flags (presence means true)
+ switch part {
+ case "nullable":
+ tag.Nullable = true
+ tag.NullableSet = true
+ case "ref":
+ tag.Ref = true
+ tag.RefSet = true
+ case "ignore":
+ tag.Ignore = true
+ tag.IgnoreSet = true
+ }
+ }
+ }
+
+ return tag
+}
+
+// parseBool parses a boolean value from string.
+// Accepts: "true", "1", "yes" as true; everything else as false.
+func parseBool(s string) bool {
+ s = strings.ToLower(strings.TrimSpace(s))
+ return s == "true" || s == "1" || s == "yes"
+}
+
+// ValidateForyTags validates all fory tags in a struct type.
+// Returns an error if validation fails.
+//
+// Validation rules:
+// - Tag ID must be >= -1
+// - Tag IDs must be unique within a struct (except -1)
+// - Ignored fields are not validated for ID uniqueness
+func ValidateForyTags(t reflect.Type) error {
+ if t.Kind() == reflect.Ptr {
+ t = t.Elem()
+ }
+ if t.Kind() != reflect.Struct {
+ return nil
+ }
+
+ tagIDs := make(map[int]string) // id -> field name
+
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+ tag := ParseForyTag(field)
+
+ // Skip ignored fields for ID uniqueness validation
+ if tag.Ignore {
+ continue
+ }
+
+ // Validate tag ID range
+ if tag.ID < TagIDUseFieldName {
+ return InvalidTagErrorf("invalid fory tag id=%d on
field %s: id must be >= -1",
+ tag.ID, field.Name)
+ }
+
+ // Check for duplicate tag IDs (except -1 which means use field
name)
+ if tag.ID >= 0 {
+ if existing, ok := tagIDs[tag.ID]; ok {
+ return InvalidTagErrorf("duplicate fory tag
id=%d on fields %s and %s",
+ tag.ID, existing, field.Name)
+ }
+ tagIDs[tag.ID] = field.Name
+ }
+ }
+
+ return nil
+}
+
+// ShouldIncludeField returns true if the field should be serialized.
+// A field is excluded if:
+// - It's unexported (starts with lowercase)
+// - It has `fory:"-"` tag
+// - It has `fory:"ignore"` or `fory:"ignore=true"` tag
+func ShouldIncludeField(field reflect.StructField) bool {
+ // Skip unexported fields
+ if field.PkgPath != "" {
+ return false
+ }
+
+ // Check for ignore tag
+ tag := ParseForyTag(field)
+ return !tag.Ignore
+}
diff --git a/go/fory/tag_test.go b/go/fory/tag_test.go
new file mode 100644
index 000000000..3c7f52e2b
--- /dev/null
+++ b/go/fory/tag_test.go
@@ -0,0 +1,613 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package fory
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseForyTag(t *testing.T) {
+ type TestStruct struct {
+ Field1 string `fory:"id=0"`
+ Field2 int
`fory:"id=1,nullable=false,ref=false,ignore=false"`
+ Field3 *string `fory:"id=2,nullable=true,ref=true"`
+ Field4 string `fory:"nullable,ref"`
+ Field5 string `fory:"ignore"`
+ Field6 string `fory:"ignore=true"`
+ Field7 string `fory:"ignore=false"`
+ Field8 string `fory:"-"`
+ Field9 string
+ Field10 string `fory:"id=3,ignore=false"`
+ Field11 string `fory:"id=-1"`
+ Field12 string `fory:"nullable=true,ref=false"`
+ }
+
+ typ := reflect.TypeOf(TestStruct{})
+
+ // Test Field1: id=0
+ tag1 := ParseForyTag(typ.Field(0))
+ require.True(t, tag1.HasTag)
+ require.Equal(t, 0, tag1.ID)
+ require.False(t, tag1.Nullable)
+ require.False(t, tag1.Ref)
+ require.False(t, tag1.Ignore)
+ require.False(t, tag1.NullableSet)
+ require.False(t, tag1.RefSet)
+ require.False(t, tag1.IgnoreSet)
+
+ // Test Field2: all explicit false values
+ tag2 := ParseForyTag(typ.Field(1))
+ require.Equal(t, 1, tag2.ID)
+ require.False(t, tag2.Nullable)
+ require.False(t, tag2.Ref)
+ require.False(t, tag2.Ignore)
+ require.True(t, tag2.NullableSet)
+ require.True(t, tag2.RefSet)
+ require.True(t, tag2.IgnoreSet)
+
+ // Test Field3: explicit true values
+ tag3 := ParseForyTag(typ.Field(2))
+ require.Equal(t, 2, tag3.ID)
+ require.True(t, tag3.Nullable)
+ require.True(t, tag3.Ref)
+ require.False(t, tag3.Ignore)
+
+ // Test Field4: standalone flags (presence = true)
+ tag4 := ParseForyTag(typ.Field(3))
+ require.Equal(t, TagIDUseFieldName, tag4.ID)
+ require.True(t, tag4.Nullable)
+ require.True(t, tag4.Ref)
+ require.True(t, tag4.NullableSet)
+ require.True(t, tag4.RefSet)
+
+ // Test Field5: standalone ignore
+ tag5 := ParseForyTag(typ.Field(4))
+ require.True(t, tag5.Ignore)
+ require.True(t, tag5.IgnoreSet)
+
+ // Test Field6: explicit ignore=true
+ tag6 := ParseForyTag(typ.Field(5))
+ require.True(t, tag6.Ignore)
+ require.True(t, tag6.IgnoreSet)
+
+ // Test Field7: explicit ignore=false
+ tag7 := ParseForyTag(typ.Field(6))
+ require.False(t, tag7.Ignore)
+ require.True(t, tag7.IgnoreSet)
+
+ // Test Field8: "-" shorthand
+ tag8 := ParseForyTag(typ.Field(7))
+ require.True(t, tag8.Ignore)
+ require.True(t, tag8.IgnoreSet)
+
+ // Test Field9: no tag
+ tag9 := ParseForyTag(typ.Field(8))
+ require.False(t, tag9.HasTag)
+ require.False(t, tag9.Ignore)
+ require.Equal(t, TagIDUseFieldName, tag9.ID)
+
+ // Test Field10: has ID but not ignored
+ tag10 := ParseForyTag(typ.Field(9))
+ require.Equal(t, 3, tag10.ID)
+ require.False(t, tag10.Ignore)
+ require.True(t, tag10.IgnoreSet)
+
+ // Test Field11: explicit id=-1 (use field name)
+ tag11 := ParseForyTag(typ.Field(10))
+ require.Equal(t, TagIDUseFieldName, tag11.ID)
+ require.True(t, tag11.HasTag)
+
+ // Test Field12: nullable=true,ref=false
+ tag12 := ParseForyTag(typ.Field(11))
+ require.True(t, tag12.Nullable)
+ require.False(t, tag12.Ref)
+ require.True(t, tag12.NullableSet)
+ require.True(t, tag12.RefSet)
+}
+
+func TestShouldIncludeField(t *testing.T) {
+ type TestStruct struct {
+ Included1 string `fory:"id=0"`
+ Included2 string `fory:"ignore=false"`
+ Ignored1 string `fory:"ignore"`
+ Ignored2 string `fory:"ignore=true"`
+ Ignored3 string `fory:"-"`
+ NoTag string
+ }
+
+ typ := reflect.TypeOf(TestStruct{})
+
+ require.True(t, ShouldIncludeField(typ.Field(0))) // Included1
+ require.True(t, ShouldIncludeField(typ.Field(1))) // Included2
(ignore=false)
+ require.False(t, ShouldIncludeField(typ.Field(2))) // Ignored1
+ require.False(t, ShouldIncludeField(typ.Field(3))) // Ignored2
+ require.False(t, ShouldIncludeField(typ.Field(4))) // Ignored3
+ require.True(t, ShouldIncludeField(typ.Field(5))) // NoTag (default:
include)
+}
+
+func TestValidateForyTags(t *testing.T) {
+ // Test valid struct
+ type ValidStruct struct {
+ Field1 string `fory:"id=0"`
+ Field2 string `fory:"id=1"`
+ Field3 string `fory:"id=-1"`
+ Field4 string // No tag
+ }
+ err := ValidateForyTags(reflect.TypeOf(ValidStruct{}))
+ require.NoError(t, err)
+
+ // Test duplicate tag IDs
+ type DuplicateIDs struct {
+ Field1 string `fory:"id=0"`
+ Field2 string `fory:"id=0"`
+ }
+ err = ValidateForyTags(reflect.TypeOf(DuplicateIDs{}))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "duplicate")
+ foryErr, ok := err.(Error)
+ require.True(t, ok, "error should be fory.Error type")
+ require.Equal(t, ErrKindInvalidTag, foryErr.Kind())
+
+ // Test invalid ID (< -1)
+ type InvalidID struct {
+ Field1 string `fory:"id=-2"`
+ }
+ err = ValidateForyTags(reflect.TypeOf(InvalidID{}))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid")
+ foryErr, ok = err.(Error)
+ require.True(t, ok, "error should be fory.Error type")
+ require.Equal(t, ErrKindInvalidTag, foryErr.Kind())
+
+ // Test that ignored fields don't count for ID uniqueness
+ type IgnoredFields struct {
+ Field1 string `fory:"id=0"`
+ Field2 string `fory:"id=0,ignore"` // Same ID but ignored
+ Field3 string `fory:"id=1"`
+ }
+ err = ValidateForyTags(reflect.TypeOf(IgnoredFields{}))
+ require.NoError(t, err)
+}
+
+func TestParseForyTagEdgeCases(t *testing.T) {
+ // Test whitespace handling
+ type WhitespaceStruct struct {
+ Field1 string `fory:" id = 0 , nullable = true "`
+ }
+ typ := reflect.TypeOf(WhitespaceStruct{})
+ tag := ParseForyTag(typ.Field(0))
+ require.Equal(t, 0, tag.ID)
+ require.True(t, tag.Nullable)
+
+ // Test empty tag value
+ type EmptyTagStruct struct {
+ Field1 string `fory:""`
+ }
+ typ2 := reflect.TypeOf(EmptyTagStruct{})
+ tag2 := ParseForyTag(typ2.Field(0))
+ require.True(t, tag2.HasTag)
+ require.Equal(t, TagIDUseFieldName, tag2.ID)
+
+ // Test boolean values
+ type BoolValuesStruct struct {
+ Field1 string `fory:"nullable=1"`
+ Field2 string `fory:"nullable=yes"`
+ Field3 string `fory:"nullable=TRUE"`
+ Field4 string `fory:"nullable=no"`
+ }
+ typ3 := reflect.TypeOf(BoolValuesStruct{})
+
+ tag3 := ParseForyTag(typ3.Field(0))
+ require.True(t, tag3.Nullable) // "1" -> true
+
+ tag4 := ParseForyTag(typ3.Field(1))
+ require.True(t, tag4.Nullable) // "yes" -> true
+
+ tag5 := ParseForyTag(typ3.Field(2))
+ require.True(t, tag5.Nullable) // "TRUE" -> true
+
+ tag6 := ParseForyTag(typ3.Field(3))
+ require.False(t, tag6.Nullable) // "no" -> false
+}
+
+// Test struct with tags for serialization tests
+type PersonWithTags struct {
+ Name string `fory:"id=0"`
+ Age int32 `fory:"id=1"`
+ Email string `fory:"id=2,nullable=true"`
+ Secret string `fory:"-"`
+}
+
+// Test struct without tags
+type PersonWithoutTags struct {
+ Name string
+ Age int32
+ Email string
+}
+
+func TestSerializationWithTags(t *testing.T) {
+ fory := NewFory(WithRefTracking(false), WithCompatible(true))
+ err := fory.RegisterByName(PersonWithTags{}, "test.PersonWithTags")
+ require.NoError(t, err)
+
+ person := PersonWithTags{
+ Name: "John",
+ Age: 30,
+ Email: "[email protected]",
+ Secret: "should-be-ignored",
+ }
+
+ // Serialize
+ data, err := fory.Marshal(person)
+ require.NoError(t, err)
+
+ // Deserialize
+ var result PersonWithTags
+ err = fory.Unmarshal(data, &result)
+ require.NoError(t, err)
+
+ require.Equal(t, person.Name, result.Name)
+ require.Equal(t, person.Age, result.Age)
+ require.Equal(t, person.Email, result.Email)
+ require.Empty(t, result.Secret) // Secret should be empty (not
serialized)
+}
+
+func TestSerializationWithoutTags(t *testing.T) {
+ fory := NewFory(WithRefTracking(false), WithCompatible(true))
+ err := fory.RegisterByName(PersonWithoutTags{},
"test.PersonWithoutTags")
+ require.NoError(t, err)
+
+ person := PersonWithoutTags{
+ Name: "Jane",
+ Age: 25,
+ Email: "[email protected]",
+ }
+
+ // Serialize
+ data, err := fory.Marshal(person)
+ require.NoError(t, err)
+
+ // Deserialize
+ var result PersonWithoutTags
+ err = fory.Unmarshal(data, &result)
+ require.NoError(t, err)
+
+ require.Equal(t, person.Name, result.Name)
+ require.Equal(t, person.Age, result.Age)
+ require.Equal(t, person.Email, result.Email)
+}
+
+func TestTagIDReducesPayloadSize(t *testing.T) {
+ fory1 := NewFory(WithRefTracking(false), WithCompatible(true))
+ err := fory1.RegisterByName(PersonWithTags{}, "test.PersonWithTags")
+ require.NoError(t, err)
+
+ fory2 := NewFory(WithRefTracking(false), WithCompatible(true))
+ err = fory2.RegisterByName(PersonWithoutTags{},
"test.PersonWithoutTags")
+ require.NoError(t, err)
+
+ // Create comparable data
+ personWithTags := PersonWithTags{
+ Name: "John",
+ Age: 30,
+ Email: "[email protected]",
+ }
+ personWithoutTags := PersonWithoutTags{
+ Name: "John",
+ Age: 30,
+ Email: "[email protected]",
+ }
+
+ // Serialize both
+ dataWithTags, err := fory1.Marshal(personWithTags)
+ require.NoError(t, err)
+
+ dataWithoutTags, err := fory2.Marshal(personWithoutTags)
+ require.NoError(t, err)
+
+ // Tag IDs should produce smaller or equal payload
+ // (Tag IDs use compact integer encoding vs field name strings)
+ t.Logf("With tags: %d bytes, Without tags: %d bytes",
len(dataWithTags), len(dataWithoutTags))
+ require.LessOrEqual(t, len(dataWithTags), len(dataWithoutTags),
+ "Tag IDs should produce smaller or equal payload size")
+}
+
+// Test struct with numeric fields and tag IDs for compact encoding
+type NumericStructWithTags struct {
+ FieldA *int32 `fory:"id=0,nullable=false"`
+ FieldB *int32 `fory:"id=1,nullable=false"`
+ FieldC *int32 `fory:"id=2,nullable=false"`
+ FieldD *int32 `fory:"id=3,nullable=false"`
+ FieldE *int32 `fory:"id=4,nullable=false"`
+}
+
+type NumericStructWithoutTags struct {
+ FieldA *int32
+ FieldB *int32
+ FieldC *int32
+ FieldD *int32
+ FieldE *int32
+}
+
+func TestNumericStructTagIDReducesSize(t *testing.T) {
+ fory1 := NewFory(WithRefTracking(false), WithCompatible(true))
+ err := fory1.RegisterByName(NumericStructWithTags{},
"test.NumericStructWithTags")
+ require.NoError(t, err)
+
+ fory2 := NewFory(WithRefTracking(false), WithCompatible(true))
+ err = fory2.RegisterByName(NumericStructWithoutTags{},
"test.NumericStructWithoutTags")
+ require.NoError(t, err)
+
+ // Create small int32 values
+ v1, v2, v3, v4, v5 := int32(1), int32(2), int32(3), int32(4), int32(5)
+
+ objWithTags := NumericStructWithTags{
+ FieldA: &v1,
+ FieldB: &v2,
+ FieldC: &v3,
+ FieldD: &v4,
+ FieldE: &v5,
+ }
+ objWithoutTags := NumericStructWithoutTags{
+ FieldA: &v1,
+ FieldB: &v2,
+ FieldC: &v3,
+ FieldD: &v4,
+ FieldE: &v5,
+ }
+
+ // Serialize both
+ dataWithTags, err := fory1.Marshal(objWithTags)
+ require.NoError(t, err)
+
+ dataWithoutTags, err := fory2.Marshal(objWithoutTags)
+ require.NoError(t, err)
+
+ t.Logf("Numeric with tags: %d bytes, without tags: %d bytes, saved: %d
bytes",
+ len(dataWithTags), len(dataWithoutTags),
len(dataWithoutTags)-len(dataWithTags))
+
+ // Tag IDs + nullable=false should produce significantly smaller payload
+ require.Less(t, len(dataWithTags), len(dataWithoutTags),
+ "Tag IDs with nullable=false should produce smaller payload")
+
+ // Deserialize and verify
+ var result NumericStructWithTags
+ err = fory1.Unmarshal(dataWithTags, &result)
+ require.NoError(t, err)
+
+ require.Equal(t, *objWithTags.FieldA, *result.FieldA)
+ require.Equal(t, *objWithTags.FieldB, *result.FieldB)
+ require.Equal(t, *objWithTags.FieldC, *result.FieldC)
+ require.Equal(t, *objWithTags.FieldD, *result.FieldD)
+ require.Equal(t, *objWithTags.FieldE, *result.FieldE)
+}
+
+// Test structs at package level for consistent naming
+type TestStructNoNull struct {
+ A *int32 `fory:"id=0,nullable=false,ref=false"`
+ B *int32 `fory:"id=1,nullable=false,ref=false"`
+ C *int32 `fory:"id=2,nullable=false,ref=false"`
+ D *int32 `fory:"id=3,nullable=false,ref=false"`
+ E *int32 `fory:"id=4,nullable=false,ref=false"`
+}
+
+type TestStructDefalt struct {
+ A *int32 `fory:"id=0"`
+ B *int32 `fory:"id=1"`
+ C *int32 `fory:"id=2"`
+ D *int32 `fory:"id=3"`
+ E *int32 `fory:"id=4"`
+}
+
+func TestNullableRefFlagsRespected(t *testing.T) {
+ // Debug: verify tag parsing
+ typ1 := reflect.TypeOf(TestStructNoNull{})
+ for i := 0; i < typ1.NumField(); i++ {
+ field := typ1.Field(i)
+ tag := ParseForyTag(field)
+ t.Logf("Field %s: ID=%d, Nullable=%v (set=%v), Ref=%v (set=%v)",
+ field.Name, tag.ID, tag.Nullable, tag.NullableSet,
tag.Ref, tag.RefSet)
+ }
+
+ fory1 := NewFory(WithRefTracking(false), WithCompatible(true))
+ err := fory1.RegisterByName(TestStructNoNull{}, "test.TestStructNoNull")
+ require.NoError(t, err)
+
+ fory2 := NewFory(WithRefTracking(false), WithCompatible(true))
+ err = fory2.RegisterByName(TestStructDefalt{}, "test.TestStructDefalt")
+ require.NoError(t, err)
+
+ v1, v2, v3, v4, v5 := int32(1), int32(2), int32(3), int32(4), int32(5)
+
+ objNoFlags := TestStructNoNull{A: &v1, B: &v2, C: &v3, D: &v4, E: &v5}
+ objDefault := TestStructDefalt{A: &v1, B: &v2, C: &v3, D: &v4, E: &v5}
+
+ dataNoFlags, err := fory1.Marshal(objNoFlags)
+ require.NoError(t, err)
+
+ dataDefault, err := fory2.Marshal(objDefault)
+ require.NoError(t, err)
+
+ // With nullable=false, we should save 5 bytes (1 null flag per field)
+ t.Logf("No nullable/ref flags: %d bytes, Default flags: %d bytes,
saved: %d bytes",
+ len(dataNoFlags), len(dataDefault),
len(dataDefault)-len(dataNoFlags))
+
+ require.Less(t, len(dataNoFlags), len(dataDefault),
+ "nullable=false,ref=false should produce smaller payload by
skipping null flags")
+
+ // Verify deserialization works
+ var result TestStructNoNull
+ err = fory1.Unmarshal(dataNoFlags, &result)
+ require.NoError(t, err)
+ require.Equal(t, *objNoFlags.A, *result.A)
+ require.Equal(t, *objNoFlags.B, *result.B)
+ require.Equal(t, *objNoFlags.C, *result.C)
+ require.Equal(t, *objNoFlags.D, *result.D)
+ require.Equal(t, *objNoFlags.E, *result.E)
+}
+
+func TestTypeDefEncodingSizeWithTagIDs(t *testing.T) {
+ fory1 := NewFory(WithRefTracking(false), WithCompatible(true))
+ err := fory1.RegisterByName(NumericStructWithTags{},
"test.NumericStructWithTags")
+ require.NoError(t, err)
+
+ fory2 := NewFory(WithRefTracking(false), WithCompatible(true))
+ err = fory2.RegisterByName(NumericStructWithoutTags{},
"test.NumericStructWithoutTags")
+ require.NoError(t, err)
+
+ // Build TypeDef for struct with tags
+ typeDefWithTags, err := buildTypeDef(fory1,
reflect.ValueOf(NumericStructWithTags{}))
+ require.NoError(t, err)
+
+ // Build TypeDef for struct without tags
+ typeDefWithoutTags, err := buildTypeDef(fory2,
reflect.ValueOf(NumericStructWithoutTags{}))
+ require.NoError(t, err)
+
+ // Encode TypeDef with tags
+ bufferWithTags := NewByteBuffer(make([]byte, 0, 256))
+ writeErr := &Error{}
+ typeDefWithTags.writeTypeDef(bufferWithTags, writeErr)
+ require.False(t, writeErr.HasError())
+ sizeWithTags := bufferWithTags.WriterIndex()
+
+ // Encode TypeDef without tags
+ bufferWithoutTags := NewByteBuffer(make([]byte, 0, 256))
+ typeDefWithoutTags.writeTypeDef(bufferWithoutTags, writeErr)
+ require.False(t, writeErr.HasError())
+ sizeWithoutTags := bufferWithoutTags.WriterIndex()
+
+ t.Logf("TypeDef with tags: %d bytes, without tags: %d bytes, saved: %d
bytes",
+ sizeWithTags, sizeWithoutTags, sizeWithoutTags-sizeWithTags)
+
+ // TypeDef with tag IDs should be smaller (tag IDs use 1 byte vs field
names)
+ require.Less(t, sizeWithTags, sizeWithoutTags,
+ "TypeDef with tag IDs should be smaller than with field names")
+}
+
+// Test struct with large tag IDs (>15 requires varint encoding)
+type StructWithLargeTagIDs struct {
+ Field1 string `fory:"id=0"`
+ Field2 string `fory:"id=15"`
+ Field3 string `fory:"id=100"`
+ Field4 string `fory:"id=1000"`
+}
+
+func TestLargeTagIDs(t *testing.T) {
+ fory := NewFory(WithRefTracking(false), WithCompatible(true))
+ err := fory.RegisterByName(StructWithLargeTagIDs{},
"test.StructWithLargeTagIDs")
+ require.NoError(t, err)
+
+ obj := StructWithLargeTagIDs{
+ Field1: "value1",
+ Field2: "value2",
+ Field3: "value3",
+ Field4: "value4",
+ }
+
+ // Serialize
+ data, err := fory.Marshal(obj)
+ require.NoError(t, err)
+
+ // Deserialize
+ var result StructWithLargeTagIDs
+ err = fory.Unmarshal(data, &result)
+ require.NoError(t, err)
+
+ require.Equal(t, obj.Field1, result.Field1)
+ require.Equal(t, obj.Field2, result.Field2)
+ require.Equal(t, obj.Field3, result.Field3)
+ require.Equal(t, obj.Field4, result.Field4)
+}
+
+// Test struct with mixed tag and no-tag fields
+type MixedTagStruct struct {
+ TaggedField string `fory:"id=0"`
+ UntaggedField string
+ AnotherTagged int32 `fory:"id=1,nullable=false"`
+}
+
+func TestMixedTagFields(t *testing.T) {
+ fory := NewFory(WithRefTracking(false), WithCompatible(true))
+ err := fory.RegisterByName(MixedTagStruct{}, "test.MixedTagStruct")
+ require.NoError(t, err)
+
+ obj := MixedTagStruct{
+ TaggedField: "tagged",
+ UntaggedField: "untagged",
+ AnotherTagged: 42,
+ }
+
+ // Serialize
+ data, err := fory.Marshal(obj)
+ require.NoError(t, err)
+
+ // Deserialize
+ var result MixedTagStruct
+ err = fory.Unmarshal(data, &result)
+ require.NoError(t, err)
+
+ require.Equal(t, obj.TaggedField, result.TaggedField)
+ require.Equal(t, obj.UntaggedField, result.UntaggedField)
+ require.Equal(t, obj.AnotherTagged, result.AnotherTagged)
+}
+
+// Test nested struct with tags
+type InnerWithTags struct {
+ Value string `fory:"id=0"`
+ Count int32 `fory:"id=1"`
+}
+
+type OuterWithTags struct {
+ Name string `fory:"id=0"`
+ Inner InnerWithTags `fory:"id=1"`
+ Items []string `fory:"id=2"`
+}
+
+func TestNestedStructWithTags(t *testing.T) {
+ fory := NewFory(WithRefTracking(false), WithCompatible(true))
+ err := fory.RegisterByName(InnerWithTags{}, "test.InnerWithTags")
+ require.NoError(t, err)
+ err = fory.RegisterByName(OuterWithTags{}, "test.OuterWithTags")
+ require.NoError(t, err)
+
+ obj := OuterWithTags{
+ Name: "outer",
+ Inner: InnerWithTags{
+ Value: "inner-value",
+ Count: 10,
+ },
+ Items: []string{"a", "b", "c"},
+ }
+
+ // Serialize
+ data, err := fory.Marshal(obj)
+ require.NoError(t, err)
+
+ // Deserialize
+ var result OuterWithTags
+ err = fory.Unmarshal(data, &result)
+ require.NoError(t, err)
+
+ require.Equal(t, obj.Name, result.Name)
+ require.Equal(t, obj.Inner.Value, result.Inner.Value)
+ require.Equal(t, obj.Inner.Count, result.Inner.Count)
+ require.Equal(t, obj.Items, result.Items)
+}
diff --git a/go/fory/tests/structs_fory_gen.go
b/go/fory/tests/structs_fory_gen.go
index 553748487..ae3faa5c8 100644
--- a/go/fory/tests/structs_fory_gen.go
+++ b/go/fory/tests/structs_fory_gen.go
@@ -1,6 +1,6 @@
// Code generated by forygen. DO NOT EDIT.
// source: ./structs.go
-// generated at: 2025-12-23T11:00:56+08:00
+// generated at: 2025-12-24T18:00:19+08:00
package fory
@@ -17,14 +17,23 @@ func init() {
fory.RegisterSerializerFactory((*ValidationDemo)(nil),
NewSerializerFor_ValidationDemo)
}
-type DynamicSliceDemo_ForyGenSerializer struct{}
+type DynamicSliceDemo_ForyGenSerializer struct {
+ structHash int32
+}
func NewSerializerFor_DynamicSliceDemo() fory.Serializer {
- return DynamicSliceDemo_ForyGenSerializer{}
+ return &DynamicSliceDemo_ForyGenSerializer{}
+}
+
+func (g *DynamicSliceDemo_ForyGenSerializer) initHash(resolver
*fory.TypeResolver) {
+ if g.structHash == 0 {
+ g.structHash =
fory.GetStructHash(reflect.TypeOf(DynamicSliceDemo{}), resolver)
+ }
}
// Write is the entry point for serialization with ref/type handling
-func (g DynamicSliceDemo_ForyGenSerializer) Write(ctx *fory.WriteContext,
refMode fory.RefMode, writeType bool, hasGenerics bool, value reflect.Value) {
+func (g *DynamicSliceDemo_ForyGenSerializer) Write(ctx *fory.WriteContext,
refMode fory.RefMode, writeType bool, hasGenerics bool, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
_ = hasGenerics // not used for struct serializers
switch refMode {
case fory.RefModeTracking:
@@ -54,10 +63,10 @@ func (g DynamicSliceDemo_ForyGenSerializer) Write(ctx
*fory.WriteContext, refMod
}
// WriteTyped provides strongly-typed serialization with no reflection overhead
-func (g DynamicSliceDemo_ForyGenSerializer) WriteTyped(ctx *fory.WriteContext,
v *DynamicSliceDemo) error {
+func (g *DynamicSliceDemo_ForyGenSerializer) WriteTyped(ctx
*fory.WriteContext, v *DynamicSliceDemo) error {
buf := ctx.Buffer()
- // WriteData precomputed struct hash for compatibility checking
- buf.WriteInt32(659991945) // hash of DynamicSliceDemo structure
+ // WriteData struct hash for compatibility checking
+ buf.WriteInt32(g.structHash)
// WriteData fields in sorted order
// Field: DynamicSlice ([]interface{})
@@ -81,7 +90,8 @@ func (g DynamicSliceDemo_ForyGenSerializer) WriteTyped(ctx
*fory.WriteContext, v
}
// WriteData provides reflect.Value interface compatibility (implements
fory.Serializer)
-func (g DynamicSliceDemo_ForyGenSerializer) WriteData(ctx *fory.WriteContext,
value reflect.Value) {
+func (g *DynamicSliceDemo_ForyGenSerializer) WriteData(ctx *fory.WriteContext,
value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
// Convert reflect.Value to concrete type and delegate to typed method
var v *DynamicSliceDemo
if value.Kind() == reflect.Ptr {
@@ -98,7 +108,8 @@ func (g DynamicSliceDemo_ForyGenSerializer) WriteData(ctx
*fory.WriteContext, va
}
// Read is the entry point for deserialization with ref/type handling
-func (g DynamicSliceDemo_ForyGenSerializer) Read(ctx *fory.ReadContext,
refMode fory.RefMode, readType bool, hasGenerics bool, value reflect.Value) {
+func (g *DynamicSliceDemo_ForyGenSerializer) Read(ctx *fory.ReadContext,
refMode fory.RefMode, readType bool, hasGenerics bool, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
_ = hasGenerics // not used for struct serializers
err := ctx.Err() // Get error pointer for deferred error checking
switch refMode {
@@ -128,16 +139,16 @@ func (g DynamicSliceDemo_ForyGenSerializer) Read(ctx
*fory.ReadContext, refMode
}
// ReadTyped provides strongly-typed deserialization with no reflection
overhead
-func (g DynamicSliceDemo_ForyGenSerializer) ReadTyped(ctx *fory.ReadContext, v
*DynamicSliceDemo) error {
+func (g *DynamicSliceDemo_ForyGenSerializer) ReadTyped(ctx *fory.ReadContext,
v *DynamicSliceDemo) error {
buf := ctx.Buffer()
err := ctx.Err() // Get error pointer for deferred error checking
// ReadData and verify struct hash
- if got := buf.ReadInt32(err); got != 659991945 {
+ if got := buf.ReadInt32(err); got != g.structHash {
if ctx.HasError() {
return ctx.TakeError()
}
- return fory.HashMismatchError(got, 659991945,
"DynamicSliceDemo")
+ return fory.HashMismatchError(got, g.structHash,
"DynamicSliceDemo")
}
// ReadData fields in same order as write
@@ -168,7 +179,8 @@ func (g DynamicSliceDemo_ForyGenSerializer) ReadTyped(ctx
*fory.ReadContext, v *
}
// ReadData provides reflect.Value interface compatibility (implements
fory.Serializer)
-func (g DynamicSliceDemo_ForyGenSerializer) ReadData(ctx *fory.ReadContext,
type_ reflect.Type, value reflect.Value) {
+func (g *DynamicSliceDemo_ForyGenSerializer) ReadData(ctx *fory.ReadContext,
type_ reflect.Type, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
// Convert reflect.Value to concrete type and delegate to typed method
var v *DynamicSliceDemo
if value.Kind() == reflect.Ptr {
@@ -188,18 +200,27 @@ func (g DynamicSliceDemo_ForyGenSerializer) ReadData(ctx
*fory.ReadContext, type
}
// ReadWithTypeInfo deserializes with pre-read type information
-func (g DynamicSliceDemo_ForyGenSerializer) ReadWithTypeInfo(ctx
*fory.ReadContext, refMode fory.RefMode, typeInfo *fory.TypeInfo, value
reflect.Value) {
+func (g *DynamicSliceDemo_ForyGenSerializer) ReadWithTypeInfo(ctx
*fory.ReadContext, refMode fory.RefMode, typeInfo *fory.TypeInfo, value
reflect.Value) {
g.Read(ctx, refMode, false, false, value)
}
-type MapDemo_ForyGenSerializer struct{}
+type MapDemo_ForyGenSerializer struct {
+ structHash int32
+}
func NewSerializerFor_MapDemo() fory.Serializer {
- return MapDemo_ForyGenSerializer{}
+ return &MapDemo_ForyGenSerializer{}
+}
+
+func (g *MapDemo_ForyGenSerializer) initHash(resolver *fory.TypeResolver) {
+ if g.structHash == 0 {
+ g.structHash = fory.GetStructHash(reflect.TypeOf(MapDemo{}),
resolver)
+ }
}
// Write is the entry point for serialization with ref/type handling
-func (g MapDemo_ForyGenSerializer) Write(ctx *fory.WriteContext, refMode
fory.RefMode, writeType bool, hasGenerics bool, value reflect.Value) {
+func (g *MapDemo_ForyGenSerializer) Write(ctx *fory.WriteContext, refMode
fory.RefMode, writeType bool, hasGenerics bool, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
_ = hasGenerics // not used for struct serializers
switch refMode {
case fory.RefModeTracking:
@@ -229,10 +250,10 @@ func (g MapDemo_ForyGenSerializer) Write(ctx
*fory.WriteContext, refMode fory.Re
}
// WriteTyped provides strongly-typed serialization with no reflection overhead
-func (g MapDemo_ForyGenSerializer) WriteTyped(ctx *fory.WriteContext, v
*MapDemo) error {
+func (g *MapDemo_ForyGenSerializer) WriteTyped(ctx *fory.WriteContext, v
*MapDemo) error {
buf := ctx.Buffer()
- // WriteData precomputed struct hash for compatibility checking
- buf.WriteInt32(-1565547955) // hash of MapDemo structure
+ // WriteData struct hash for compatibility checking
+ buf.WriteInt32(g.structHash)
// WriteData fields in sorted order
// Field: IntMap (map[int]int)
@@ -365,7 +386,8 @@ func (g MapDemo_ForyGenSerializer) WriteTyped(ctx
*fory.WriteContext, v *MapDemo
}
// WriteData provides reflect.Value interface compatibility (implements
fory.Serializer)
-func (g MapDemo_ForyGenSerializer) WriteData(ctx *fory.WriteContext, value
reflect.Value) {
+func (g *MapDemo_ForyGenSerializer) WriteData(ctx *fory.WriteContext, value
reflect.Value) {
+ g.initHash(ctx.TypeResolver())
// Convert reflect.Value to concrete type and delegate to typed method
var v *MapDemo
if value.Kind() == reflect.Ptr {
@@ -382,7 +404,8 @@ func (g MapDemo_ForyGenSerializer) WriteData(ctx
*fory.WriteContext, value refle
}
// Read is the entry point for deserialization with ref/type handling
-func (g MapDemo_ForyGenSerializer) Read(ctx *fory.ReadContext, refMode
fory.RefMode, readType bool, hasGenerics bool, value reflect.Value) {
+func (g *MapDemo_ForyGenSerializer) Read(ctx *fory.ReadContext, refMode
fory.RefMode, readType bool, hasGenerics bool, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
_ = hasGenerics // not used for struct serializers
err := ctx.Err() // Get error pointer for deferred error checking
switch refMode {
@@ -412,16 +435,16 @@ func (g MapDemo_ForyGenSerializer) Read(ctx
*fory.ReadContext, refMode fory.RefM
}
// ReadTyped provides strongly-typed deserialization with no reflection
overhead
-func (g MapDemo_ForyGenSerializer) ReadTyped(ctx *fory.ReadContext, v
*MapDemo) error {
+func (g *MapDemo_ForyGenSerializer) ReadTyped(ctx *fory.ReadContext, v
*MapDemo) error {
buf := ctx.Buffer()
err := ctx.Err() // Get error pointer for deferred error checking
// ReadData and verify struct hash
- if got := buf.ReadInt32(err); got != -1565547955 {
+ if got := buf.ReadInt32(err); got != g.structHash {
if ctx.HasError() {
return ctx.TakeError()
}
- return fory.HashMismatchError(got, -1565547955, "MapDemo")
+ return fory.HashMismatchError(got, g.structHash, "MapDemo")
}
// ReadData fields in same order as write
@@ -542,7 +565,8 @@ func (g MapDemo_ForyGenSerializer) ReadTyped(ctx
*fory.ReadContext, v *MapDemo)
}
// ReadData provides reflect.Value interface compatibility (implements
fory.Serializer)
-func (g MapDemo_ForyGenSerializer) ReadData(ctx *fory.ReadContext, type_
reflect.Type, value reflect.Value) {
+func (g *MapDemo_ForyGenSerializer) ReadData(ctx *fory.ReadContext, type_
reflect.Type, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
// Convert reflect.Value to concrete type and delegate to typed method
var v *MapDemo
if value.Kind() == reflect.Ptr {
@@ -562,18 +586,27 @@ func (g MapDemo_ForyGenSerializer) ReadData(ctx
*fory.ReadContext, type_ reflect
}
// ReadWithTypeInfo deserializes with pre-read type information
-func (g MapDemo_ForyGenSerializer) ReadWithTypeInfo(ctx *fory.ReadContext,
refMode fory.RefMode, typeInfo *fory.TypeInfo, value reflect.Value) {
+func (g *MapDemo_ForyGenSerializer) ReadWithTypeInfo(ctx *fory.ReadContext,
refMode fory.RefMode, typeInfo *fory.TypeInfo, value reflect.Value) {
g.Read(ctx, refMode, false, false, value)
}
-type SliceDemo_ForyGenSerializer struct{}
+type SliceDemo_ForyGenSerializer struct {
+ structHash int32
+}
func NewSerializerFor_SliceDemo() fory.Serializer {
- return SliceDemo_ForyGenSerializer{}
+ return &SliceDemo_ForyGenSerializer{}
+}
+
+func (g *SliceDemo_ForyGenSerializer) initHash(resolver *fory.TypeResolver) {
+ if g.structHash == 0 {
+ g.structHash = fory.GetStructHash(reflect.TypeOf(SliceDemo{}),
resolver)
+ }
}
// Write is the entry point for serialization with ref/type handling
-func (g SliceDemo_ForyGenSerializer) Write(ctx *fory.WriteContext, refMode
fory.RefMode, writeType bool, hasGenerics bool, value reflect.Value) {
+func (g *SliceDemo_ForyGenSerializer) Write(ctx *fory.WriteContext, refMode
fory.RefMode, writeType bool, hasGenerics bool, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
_ = hasGenerics // not used for struct serializers
switch refMode {
case fory.RefModeTracking:
@@ -603,10 +636,10 @@ func (g SliceDemo_ForyGenSerializer) Write(ctx
*fory.WriteContext, refMode fory.
}
// WriteTyped provides strongly-typed serialization with no reflection overhead
-func (g SliceDemo_ForyGenSerializer) WriteTyped(ctx *fory.WriteContext, v
*SliceDemo) error {
+func (g *SliceDemo_ForyGenSerializer) WriteTyped(ctx *fory.WriteContext, v
*SliceDemo) error {
buf := ctx.Buffer()
- // WriteData precomputed struct hash for compatibility checking
- buf.WriteInt32(-1393614469) // hash of SliceDemo structure
+ // WriteData struct hash for compatibility checking
+ buf.WriteInt32(g.structHash)
// WriteData fields in sorted order
// Field: BoolSlice ([]bool)
@@ -644,7 +677,8 @@ func (g SliceDemo_ForyGenSerializer) WriteTyped(ctx
*fory.WriteContext, v *Slice
}
// WriteData provides reflect.Value interface compatibility (implements
fory.Serializer)
-func (g SliceDemo_ForyGenSerializer) WriteData(ctx *fory.WriteContext, value
reflect.Value) {
+func (g *SliceDemo_ForyGenSerializer) WriteData(ctx *fory.WriteContext, value
reflect.Value) {
+ g.initHash(ctx.TypeResolver())
// Convert reflect.Value to concrete type and delegate to typed method
var v *SliceDemo
if value.Kind() == reflect.Ptr {
@@ -661,7 +695,8 @@ func (g SliceDemo_ForyGenSerializer) WriteData(ctx
*fory.WriteContext, value ref
}
// Read is the entry point for deserialization with ref/type handling
-func (g SliceDemo_ForyGenSerializer) Read(ctx *fory.ReadContext, refMode
fory.RefMode, readType bool, hasGenerics bool, value reflect.Value) {
+func (g *SliceDemo_ForyGenSerializer) Read(ctx *fory.ReadContext, refMode
fory.RefMode, readType bool, hasGenerics bool, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
_ = hasGenerics // not used for struct serializers
err := ctx.Err() // Get error pointer for deferred error checking
switch refMode {
@@ -691,16 +726,16 @@ func (g SliceDemo_ForyGenSerializer) Read(ctx
*fory.ReadContext, refMode fory.Re
}
// ReadTyped provides strongly-typed deserialization with no reflection
overhead
-func (g SliceDemo_ForyGenSerializer) ReadTyped(ctx *fory.ReadContext, v
*SliceDemo) error {
+func (g *SliceDemo_ForyGenSerializer) ReadTyped(ctx *fory.ReadContext, v
*SliceDemo) error {
buf := ctx.Buffer()
err := ctx.Err() // Get error pointer for deferred error checking
// ReadData and verify struct hash
- if got := buf.ReadInt32(err); got != -1393614469 {
+ if got := buf.ReadInt32(err); got != g.structHash {
if ctx.HasError() {
return ctx.TakeError()
}
- return fory.HashMismatchError(got, -1393614469, "SliceDemo")
+ return fory.HashMismatchError(got, g.structHash, "SliceDemo")
}
// ReadData fields in same order as write
@@ -774,7 +809,8 @@ func (g SliceDemo_ForyGenSerializer) ReadTyped(ctx
*fory.ReadContext, v *SliceDe
}
// ReadData provides reflect.Value interface compatibility (implements
fory.Serializer)
-func (g SliceDemo_ForyGenSerializer) ReadData(ctx *fory.ReadContext, type_
reflect.Type, value reflect.Value) {
+func (g *SliceDemo_ForyGenSerializer) ReadData(ctx *fory.ReadContext, type_
reflect.Type, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
// Convert reflect.Value to concrete type and delegate to typed method
var v *SliceDemo
if value.Kind() == reflect.Ptr {
@@ -794,18 +830,27 @@ func (g SliceDemo_ForyGenSerializer) ReadData(ctx
*fory.ReadContext, type_ refle
}
// ReadWithTypeInfo deserializes with pre-read type information
-func (g SliceDemo_ForyGenSerializer) ReadWithTypeInfo(ctx *fory.ReadContext,
refMode fory.RefMode, typeInfo *fory.TypeInfo, value reflect.Value) {
+func (g *SliceDemo_ForyGenSerializer) ReadWithTypeInfo(ctx *fory.ReadContext,
refMode fory.RefMode, typeInfo *fory.TypeInfo, value reflect.Value) {
g.Read(ctx, refMode, false, false, value)
}
-type ValidationDemo_ForyGenSerializer struct{}
+type ValidationDemo_ForyGenSerializer struct {
+ structHash int32
+}
func NewSerializerFor_ValidationDemo() fory.Serializer {
- return ValidationDemo_ForyGenSerializer{}
+ return &ValidationDemo_ForyGenSerializer{}
+}
+
+func (g *ValidationDemo_ForyGenSerializer) initHash(resolver
*fory.TypeResolver) {
+ if g.structHash == 0 {
+ g.structHash =
fory.GetStructHash(reflect.TypeOf(ValidationDemo{}), resolver)
+ }
}
// Write is the entry point for serialization with ref/type handling
-func (g ValidationDemo_ForyGenSerializer) Write(ctx *fory.WriteContext,
refMode fory.RefMode, writeType bool, hasGenerics bool, value reflect.Value) {
+func (g *ValidationDemo_ForyGenSerializer) Write(ctx *fory.WriteContext,
refMode fory.RefMode, writeType bool, hasGenerics bool, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
_ = hasGenerics // not used for struct serializers
switch refMode {
case fory.RefModeTracking:
@@ -835,10 +880,10 @@ func (g ValidationDemo_ForyGenSerializer) Write(ctx
*fory.WriteContext, refMode
}
// WriteTyped provides strongly-typed serialization with no reflection overhead
-func (g ValidationDemo_ForyGenSerializer) WriteTyped(ctx *fory.WriteContext, v
*ValidationDemo) error {
+func (g *ValidationDemo_ForyGenSerializer) WriteTyped(ctx *fory.WriteContext,
v *ValidationDemo) error {
buf := ctx.Buffer()
- // WriteData precomputed struct hash for compatibility checking
- buf.WriteInt32(728169998) // hash of ValidationDemo structure
+ // WriteData struct hash for compatibility checking
+ buf.WriteInt32(g.structHash)
// WriteData fields in sorted order
// Field: D (float64)
@@ -858,7 +903,8 @@ func (g ValidationDemo_ForyGenSerializer) WriteTyped(ctx
*fory.WriteContext, v *
}
// WriteData provides reflect.Value interface compatibility (implements
fory.Serializer)
-func (g ValidationDemo_ForyGenSerializer) WriteData(ctx *fory.WriteContext,
value reflect.Value) {
+func (g *ValidationDemo_ForyGenSerializer) WriteData(ctx *fory.WriteContext,
value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
// Convert reflect.Value to concrete type and delegate to typed method
var v *ValidationDemo
if value.Kind() == reflect.Ptr {
@@ -875,7 +921,8 @@ func (g ValidationDemo_ForyGenSerializer) WriteData(ctx
*fory.WriteContext, valu
}
// Read is the entry point for deserialization with ref/type handling
-func (g ValidationDemo_ForyGenSerializer) Read(ctx *fory.ReadContext, refMode
fory.RefMode, readType bool, hasGenerics bool, value reflect.Value) {
+func (g *ValidationDemo_ForyGenSerializer) Read(ctx *fory.ReadContext, refMode
fory.RefMode, readType bool, hasGenerics bool, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
_ = hasGenerics // not used for struct serializers
err := ctx.Err() // Get error pointer for deferred error checking
switch refMode {
@@ -905,16 +952,16 @@ func (g ValidationDemo_ForyGenSerializer) Read(ctx
*fory.ReadContext, refMode fo
}
// ReadTyped provides strongly-typed deserialization with no reflection
overhead
-func (g ValidationDemo_ForyGenSerializer) ReadTyped(ctx *fory.ReadContext, v
*ValidationDemo) error {
+func (g *ValidationDemo_ForyGenSerializer) ReadTyped(ctx *fory.ReadContext, v
*ValidationDemo) error {
buf := ctx.Buffer()
err := ctx.Err() // Get error pointer for deferred error checking
// ReadData and verify struct hash
- if got := buf.ReadInt32(err); got != 728169998 {
+ if got := buf.ReadInt32(err); got != g.structHash {
if ctx.HasError() {
return ctx.TakeError()
}
- return fory.HashMismatchError(got, 728169998, "ValidationDemo")
+ return fory.HashMismatchError(got, g.structHash,
"ValidationDemo")
}
// ReadData fields in same order as write
@@ -940,7 +987,8 @@ func (g ValidationDemo_ForyGenSerializer) ReadTyped(ctx
*fory.ReadContext, v *Va
}
// ReadData provides reflect.Value interface compatibility (implements
fory.Serializer)
-func (g ValidationDemo_ForyGenSerializer) ReadData(ctx *fory.ReadContext,
type_ reflect.Type, value reflect.Value) {
+func (g *ValidationDemo_ForyGenSerializer) ReadData(ctx *fory.ReadContext,
type_ reflect.Type, value reflect.Value) {
+ g.initHash(ctx.TypeResolver())
// Convert reflect.Value to concrete type and delegate to typed method
var v *ValidationDemo
if value.Kind() == reflect.Ptr {
@@ -960,7 +1008,7 @@ func (g ValidationDemo_ForyGenSerializer) ReadData(ctx
*fory.ReadContext, type_
}
// ReadWithTypeInfo deserializes with pre-read type information
-func (g ValidationDemo_ForyGenSerializer) ReadWithTypeInfo(ctx
*fory.ReadContext, refMode fory.RefMode, typeInfo *fory.TypeInfo, value
reflect.Value) {
+func (g *ValidationDemo_ForyGenSerializer) ReadWithTypeInfo(ctx
*fory.ReadContext, refMode fory.RefMode, typeInfo *fory.TypeInfo, value
reflect.Value) {
g.Read(ctx, refMode, false, false, value)
}
diff --git a/go/fory/type_def.go b/go/fory/type_def.go
index 1509388d6..cff3443e0 100644
--- a/go/fory/type_def.go
+++ b/go/fory/type_def.go
@@ -365,6 +365,7 @@ type FieldDef struct {
nullable bool
trackingRef bool
fieldType FieldType
+ tagID int // -1 = use field name, >=0 = use tag ID
}
// String returns a string representation of FieldDef for debugging
@@ -375,6 +376,10 @@ func (fd FieldDef) String() string {
} else {
fieldTypeStr = "nil"
}
+ if fd.tagID >= 0 {
+ return fmt.Sprintf("FieldDef{tagID=%d, nullable=%v,
trackingRef=%v, fieldType=%s}",
+ fd.tagID, fd.nullable, fd.trackingRef, fieldTypeStr)
+ }
return fmt.Sprintf("FieldDef{name=%s, nullable=%v, trackingRef=%v,
fieldType=%s}",
fd.name, fd.nullable, fd.trackingRef, fieldTypeStr)
}
@@ -405,9 +410,19 @@ func buildFieldDefs(fory *Fory, value reflect.Value)
([]FieldDef, error) {
type_ := value.Type()
for i := 0; i < type_.NumField(); i++ {
field := type_.Field(i)
- fieldValue := value.Field(i)
- var fieldInfo FieldDef
+ // Skip unexported fields
+ if field.PkgPath != "" {
+ continue
+ }
+
+ // Parse fory struct tag and check for ignore
+ foryTag := ParseForyTag(field)
+ if foryTag.Ignore {
+ continue // skip ignored fields
+ }
+
+ fieldValue := value.Field(i)
fieldName := SnakeCase(field.Name)
nameEncoding :=
fory.typeResolver.typeNameEncoder.ComputeEncodingWith(fieldName,
fieldNameEncodings)
@@ -423,24 +438,36 @@ func buildFieldDefs(fory *Fory, value reflect.Value)
([]FieldDef, error) {
if isUserDefinedType(int16(internalId)) || internalId == ENUM
|| internalId == NAMED_ENUM {
nullableFlag = true
}
+ // Override nullable flag if explicitly set in fory tag
+ if foryTag.NullableSet {
+ nullableFlag = foryTag.Nullable
+ }
+
+ // Calculate ref tracking - use tag override if explicitly set
+ trackingRef := fory.config.TrackRef
+ if foryTag.RefSet {
+ trackingRef = foryTag.Ref
+ }
- fieldInfo = FieldDef{
+ fieldInfo := FieldDef{
name: fieldName,
nameEncoding: nameEncoding,
nullable: nullableFlag,
- trackingRef: fory.config.TrackRef,
+ trackingRef: trackingRef,
fieldType: ft,
+ tagID: foryTag.ID,
}
fieldDefs = append(fieldDefs, fieldInfo)
}
// Sort field definitions
if len(fieldDefs) > 1 {
- // Extract serializers, names, typeIds and nullable info for
sorting
+ // Extract serializers, names, typeIds, nullable info and
tagIDs for sorting
serializers := make([]Serializer, len(fieldDefs))
fieldNames := make([]string, len(fieldDefs))
typeIds := make([]TypeId, len(fieldDefs))
nullables := make([]bool, len(fieldDefs))
+ tagIDs := make([]int, len(fieldDefs))
for i, fieldDef := range fieldDefs {
serializer, err := getFieldTypeSerializer(fory,
fieldDef.fieldType)
if err != nil {
@@ -452,11 +479,12 @@ func buildFieldDefs(fory *Fory, value reflect.Value)
([]FieldDef, error) {
fieldNames[i] = fieldDef.name
typeIds[i] = fieldDef.fieldType.TypeId()
nullables[i] = fieldDef.nullable
+ tagIDs[i] = fieldDef.tagID
}
// Use sortFields to match Java's field ordering
- // (primitives before boxed/nullable primitives)
- _, sortedNames := sortFields(fory.typeResolver, fieldNames,
serializers, typeIds, nullables)
+ // (primitives before boxed/nullable primitives, sorted by tag
ID if available)
+ _, sortedNames := sortFields(fory.typeResolver, fieldNames,
serializers, typeIds, nullables, tagIDs)
// Rebuild fieldInfos in the sorted order
nameToFieldInfo := make(map[string]FieldDef)
@@ -895,12 +923,20 @@ const (
FieldNameSizeThreshold = 15
)
-// Encoding `UTF8/ALL_TO_LOWER_SPECIAL/LOWER_UPPER_DIGIT_SPECIAL/TAG_ID` for
fieldName
+// Field name encoding flags (2 bits in header)
+const (
+ FieldNameEncodingUTF8 = 0 // UTF-8 encoding
+ FieldNameEncodingAllToLowerSpecial = 1 // ALL_TO_LOWER_SPECIAL encoding
+ FieldNameEncodingLowerUpperDigit = 2 // LOWER_UPPER_DIGIT_SPECIAL
encoding
+ FieldNameEncodingTagID = 3 // Use tag ID instead of field
name
+)
+
+// Encoding `UTF8/ALL_TO_LOWER_SPECIAL/LOWER_UPPER_DIGIT_SPECIAL` for fieldName
+// Note: TAG_ID (0b11) is a special encoding that uses tag ID instead of field
name
var fieldNameEncodings = []meta.Encoding{
meta.UTF_8,
meta.ALL_TO_LOWER_SPECIAL,
meta.LOWER_UPPER_DIGIT_SPECIAL,
- // todo: add support for TAG_ID encoding
}
func getFieldNameEncodingIndex(encoding meta.Encoding) int {
@@ -1125,29 +1161,50 @@ func writeFieldDef(typeResolver *TypeResolver, buffer
*ByteBuffer, field FieldDe
if field.nullable {
header |= 0b10
}
- // store index of encoding in the 2 highest bits
- encodingFlag := byte(getFieldNameEncodingIndex(field.nameEncoding))
- header |= encodingFlag << 6
- metaString, err :=
typeResolver.typeNameEncoder.EncodeWithEncoding(field.name, field.nameEncoding)
- if err != nil {
- return err
- }
- nameLen := len(metaString.GetEncodedBytes())
- if nameLen < FieldNameSizeThreshold {
- header |= uint8((nameLen-1)&0x0F) << 2 // 1-based encoding
+
+ if field.tagID >= 0 {
+ // Use TAG_ID encoding (encoding flag = 3)
+ header |= FieldNameEncodingTagID << 6
+ // For tag ID, we encode the tag ID value in the size bits (4
bits)
+ // If tagID < 15, encode directly in header; otherwise use
varint
+ if field.tagID < FieldNameSizeThreshold {
+ header |= uint8(field.tagID&0x0F) << 2
+ } else {
+ header |= 0x0F << 2 // Max value, actual tag ID will
follow
+ }
+ buffer.PutUint8(offset, header)
+
+ // Write extra varint for large tag IDs
+ if field.tagID >= FieldNameSizeThreshold {
+ buffer.WriteVaruint32(uint32(field.tagID -
FieldNameSizeThreshold))
+ }
+
+ // Write field type
+ field.fieldType.write(buffer)
} else {
- header |= 0x0F << 2 // Max value, actual length will follow
- buffer.WriteVaruint32(uint32(nameLen - FieldNameSizeThreshold))
- }
- buffer.PutUint8(offset, header)
+ // Use field name encoding
+ encodingFlag :=
byte(getFieldNameEncodingIndex(field.nameEncoding))
+ header |= encodingFlag << 6
+ metaString, err :=
typeResolver.typeNameEncoder.EncodeWithEncoding(field.name, field.nameEncoding)
+ if err != nil {
+ return err
+ }
+ nameLen := len(metaString.GetEncodedBytes())
+ if nameLen < FieldNameSizeThreshold {
+ header |= uint8((nameLen-1)&0x0F) << 2 // 1-based
encoding
+ } else {
+ header |= 0x0F << 2 // Max value, actual length will
follow
+ buffer.WriteVaruint32(uint32(nameLen -
FieldNameSizeThreshold))
+ }
+ buffer.PutUint8(offset, header)
- // WriteData field type
- field.fieldType.write(buffer)
+ // Write field type
+ field.fieldType.write(buffer)
- // todo: support tag id
- // write field name
- if _, err := buffer.Write(metaString.GetEncodedBytes()); err != nil {
- return err
+ // Write field name
+ if _, err := buffer.Write(metaString.GetEncodedBytes()); err !=
nil {
+ return err
+ }
}
return nil
}
@@ -1361,24 +1418,51 @@ func readFieldDef(typeResolver *TypeResolver, buffer
*ByteBuffer) (FieldDef, err
}
// Resolve the header
- nameEncodingFlag := (headerByte >> 6) & 0b11
- nameEncoding := fieldNameEncodings[nameEncodingFlag]
- nameLen := int((headerByte >> 2) & 0x0F)
+ nameEncodingFlag := int((headerByte >> 6) & 0b11)
+ sizeBits := int((headerByte >> 2) & 0x0F)
refTracking := (headerByte & 0b1) != 0
isNullable := (headerByte & 0b10) != 0
+
+ // Check if using TAG_ID encoding
+ if nameEncodingFlag == FieldNameEncodingTagID {
+ // Read tag ID
+ tagID := sizeBits
+ if sizeBits == 0x0F {
+ tagID = FieldNameSizeThreshold +
int(buffer.ReadVaruint32(&bufErr))
+ }
+
+ // Read field type
+ ft, err := readFieldType(buffer, &bufErr)
+ if err != nil {
+ return FieldDef{}, err
+ }
+
+ return FieldDef{
+ name: "", // No field name when using tag ID
+ nameEncoding: meta.UTF_8,
+ fieldType: ft,
+ nullable: isNullable,
+ trackingRef: refTracking,
+ tagID: tagID,
+ }, nil
+ }
+
+ // Use field name encoding
+ nameEncoding := fieldNameEncodings[nameEncodingFlag]
+ nameLen := sizeBits
if nameLen == 0x0F {
nameLen = FieldNameSizeThreshold +
int(buffer.ReadVaruint32(&bufErr))
} else {
nameLen++ // Adjust for 1-based encoding
}
- // reading field type
+ // Read field type
ft, err := readFieldType(buffer, &bufErr)
if err != nil {
return FieldDef{}, err
}
- // Reading field name based on encoding
+ // Read field name based on encoding
nameBytes := buffer.ReadBinary(nameLen, &bufErr)
fieldName, err := typeResolver.typeNameDecoder.Decode(nameBytes,
nameEncoding)
if err != nil {
@@ -1391,5 +1475,6 @@ func readFieldDef(typeResolver *TypeResolver, buffer
*ByteBuffer) (FieldDef, err
fieldType: ft,
nullable: isNullable,
trackingRef: refTracking,
+ tagID: TagIDUseFieldName, // -1 indicates using field
name
}, nil
}
diff --git
a/java/fory-core/src/main/java/org/apache/fory/resolver/XtypeResolver.java
b/java/fory-core/src/main/java/org/apache/fory/resolver/XtypeResolver.java
index 94e1a2774..08abaa201 100644
--- a/java/fory-core/src/main/java/org/apache/fory/resolver/XtypeResolver.java
+++ b/java/fory-core/src/main/java/org/apache/fory/resolver/XtypeResolver.java
@@ -942,12 +942,13 @@ public class XtypeResolver extends TypeResolver {
int xtypeId = getXtypeId(o1.getRawType());
int xtypeId2 = getXtypeId(o2.getRawType());
if (xtypeId == xtypeId2) {
- return o1.getSnakeCaseName().compareTo(o2.getSnakeCaseName());
+ return DescriptorGrouper.getFieldSortKey(o1)
+ .compareTo(DescriptorGrouper.getFieldSortKey(o2));
} else {
return xtypeId - xtypeId2;
}
})
-
.setOtherDescriptorComparator(Comparator.comparing(Descriptor::getSnakeCaseName))
+
.setOtherDescriptorComparator(Comparator.comparing(DescriptorGrouper::getFieldSortKey))
.sort();
}
diff --git
a/java/fory-core/src/main/java/org/apache/fory/serializer/ObjectSerializer.java
b/java/fory-core/src/main/java/org/apache/fory/serializer/ObjectSerializer.java
index eeec93946..c12c18410 100644
---
a/java/fory-core/src/main/java/org/apache/fory/serializer/ObjectSerializer.java
+++
b/java/fory-core/src/main/java/org/apache/fory/serializer/ObjectSerializer.java
@@ -20,11 +20,13 @@
package org.apache.fory.serializer;
import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.fory.Fory;
+import org.apache.fory.annotation.ForyField;
import org.apache.fory.collection.Tuple2;
import org.apache.fory.collection.Tuple3;
import org.apache.fory.exception.ForyException;
@@ -43,7 +45,6 @@ import org.apache.fory.type.DescriptorGrouper;
import org.apache.fory.type.Generics;
import org.apache.fory.type.Types;
import org.apache.fory.util.MurmurHash3;
-import org.apache.fory.util.StringUtils;
import org.apache.fory.util.record.RecordInfo;
import org.apache.fory.util.record.RecordUtils;
@@ -356,23 +357,8 @@ public final class ObjectSerializer<T> extends
AbstractObjectSerializer<T> {
}
public static int computeStructHash(Fory fory, DescriptorGrouper grouper) {
- StringBuilder builder = new StringBuilder();
List<Descriptor> sorted = grouper.getSortedDescriptors();
- for (Descriptor descriptor : sorted) {
- Class<?> rawType = descriptor.getTypeRef().getRawType();
- int typeId = getTypeId(fory, rawType);
- String underscore =
StringUtils.lowerCamelToLowerUnderscore(descriptor.getName());
- char nullable = rawType.isPrimitive() ? '0' : '1';
- builder
- .append(underscore)
- .append(',')
- .append(typeId)
- .append(',')
- .append(nullable)
- .append(';');
- }
-
- String fingerprint = builder.toString();
+ String fingerprint = computeStructFingerprint(fory, sorted);
byte[] bytes = fingerprint.getBytes(StandardCharsets.UTF_8);
long hashLong = MurmurHash3.murmurhash3_x64_128(bytes, 0, bytes.length,
47)[0];
int hash = (int) (hashLong & 0xffffffffL);
@@ -390,6 +376,94 @@ public final class ObjectSerializer<T> extends
AbstractObjectSerializer<T> {
return hash;
}
+ /**
+ * Computes the fingerprint string for a struct type used in schema
versioning.
+ *
+ * <p><b>Fingerprint Format:</b>
+ *
+ * <p>Each field contributes: {@code
<field_id_or_name>,<type_id>,<ref>,<nullable>;}
+ *
+ * <p>Fields are sorted by their identifier (field ID or name)
lexicographically as strings.
+ *
+ * <p><b>Field Components:</b>
+ *
+ * <ul>
+ * <li><b>field_id_or_name</b>: Tag ID as string if configured via {@link
ForyField#id()} (e.g.,
+ * "0", "1"), otherwise snake_case field name
+ * <li><b>type_id</b>: Fory TypeId as decimal string (e.g., "4" for INT32)
+ * <li><b>ref</b>: "1" if reference tracking enabled, "0" otherwise
+ * <li><b>nullable</b>: "1" if null flag is written, "0" otherwise
+ * </ul>
+ *
+ * <p><b>Example fingerprints:</b>
+ *
+ * <ul>
+ * <li>With tag IDs: "0,4,0,0;1,4,0,1;2,9,0,1;"
+ * <li>With field names: "age,4,0,0;name,9,0,1;"
+ * </ul>
+ *
+ * <p>The fingerprint is used to compute a hash for struct schema
versioning. Different
+ * nullable/ref settings will produce different fingerprints, ensuring
schema compatibility is
+ * properly validated.
+ *
+ * @param fory the Fory instance for type resolution
+ * @param descriptors the sorted list of field descriptors
+ * @return the fingerprint string
+ */
+ public static String computeStructFingerprint(Fory fory, List<Descriptor>
descriptors) {
+ // Build fingerprint info for each field
+ List<String[]> fieldInfos = new ArrayList<>(descriptors.size());
+ for (Descriptor descriptor : descriptors) {
+ Class<?> rawType = descriptor.getTypeRef().getRawType();
+ int typeId = getTypeId(fory, rawType);
+
+ // Get field identifier: tag ID if configured, otherwise snake_case name
+ String fieldIdentifier;
+ ForyField foryField = descriptor.getForyField();
+ if (foryField != null && foryField.id() >= 0) {
+ fieldIdentifier = String.valueOf(foryField.id());
+ } else {
+ fieldIdentifier = descriptor.getSnakeCaseName();
+ }
+
+ // Get ref flag from @ForyField annotation only (compile-time info)
+ // If annotation is absent or ref not explicitly set to true, ref is 0
+ // This allows fingerprint to be computed at compile time for C++/Rust
+ char ref = (foryField != null && foryField.ref()) ? '1' : '0';
+
+ // Get nullable flag: primitives are non-nullable, others depend on
descriptor
+ char nullable;
+ if (rawType.isPrimitive()) {
+ nullable = '0';
+ } else {
+ nullable = descriptor.isNullable() ? '1' : '0';
+ }
+
+ fieldInfos.add(
+ new String[] {
+ fieldIdentifier, String.valueOf(typeId), String.valueOf(ref),
String.valueOf(nullable)
+ });
+ }
+
+ // Sort by field identifier (lexicographically as strings)
+ fieldInfos.sort((a, b) -> a[0].compareTo(b[0]));
+
+ // Build fingerprint string
+ StringBuilder builder = new StringBuilder();
+ for (String[] info : fieldInfos) {
+ builder
+ .append(info[0])
+ .append(',')
+ .append(info[1])
+ .append(',')
+ .append(info[2])
+ .append(',')
+ .append(info[3])
+ .append(';');
+ }
+ return builder.toString();
+ }
+
private static int getTypeId(Fory fory, Class<?> cls) {
TypeResolver resolver = fory._getTypeResolver();
if (resolver.isSet(cls)) {
diff --git
a/java/fory-core/src/main/java/org/apache/fory/type/DescriptorGrouper.java
b/java/fory-core/src/main/java/org/apache/fory/type/DescriptorGrouper.java
index 95213b893..1b6435ffe 100644
--- a/java/fory-core/src/main/java/org/apache/fory/type/DescriptorGrouper.java
+++ b/java/fory-core/src/main/java/org/apache/fory/type/DescriptorGrouper.java
@@ -29,6 +29,7 @@ import java.util.List;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.function.Predicate;
+import org.apache.fory.annotation.ForyField;
import org.apache.fory.util.Preconditions;
import org.apache.fory.util.record.RecordUtils;
@@ -42,16 +43,40 @@ import org.apache.fory.util.record.RecordUtils;
* <li>other fields
*/
public class DescriptorGrouper {
+
+ /**
+ * Gets the sort key for a field descriptor.
+ *
+ * <p>If the field has a {@link ForyField} annotation with id >= 0, returns
the id as a string.
+ * Otherwise, returns the snake_case field name. This ensures fields are
sorted by tag ID when
+ * configured, matching the fingerprint computation order.
+ *
+ * @param descriptor the field descriptor
+ * @return the sort key (tag ID as string or snake_case name)
+ */
+ public static String getFieldSortKey(Descriptor descriptor) {
+ ForyField foryField = descriptor.getForyField();
+ if (foryField != null && foryField.id() >= 0) {
+ return String.valueOf(foryField.id());
+ }
+ return descriptor.getSnakeCaseName();
+ }
+
static final Comparator<Descriptor> COMPARATOR_BY_PRIMITIVE_TYPE_ID =
(d1, d2) -> {
int c =
Types.getPrimitiveTypeId(TypeUtils.unwrap(d2.getRawType()))
- Types.getPrimitiveTypeId(TypeUtils.unwrap(d1.getRawType()));
if (c == 0) {
- c = d1.getSnakeCaseName().compareTo(d2.getSnakeCaseName());
+ c = getFieldSortKey(d1).compareTo(getFieldSortKey(d2));
if (c == 0) {
// Field name duplicate in super/child classes.
c = d1.getDeclaringClass().compareTo(d2.getDeclaringClass());
+ if (c == 0) {
+ // Final tie-breaker: use actual field name to distinguish
fields with same tag ID.
+ // This ensures TreeSet never treats different fields as
duplicates.
+ c = d1.getName().compareTo(d2.getName());
+ }
}
}
return c;
@@ -115,11 +140,11 @@ public class DescriptorGrouper {
return false;
}
- /** Comparator based on field type, name and declaring class. */
+ /** Comparator based on field type, name/id and declaring class. */
public static final Comparator<Descriptor> COMPARATOR_BY_TYPE_AND_NAME =
(d1, d2) -> {
// sort by type so that we can hit class info cache more possibly.
- // sort by field name to fix order if type is same.
+ // sort by field id/name to fix order if type is same.
int c =
d1
// Use type name instead of generic type so that fields with
type ref
@@ -128,10 +153,15 @@ public class DescriptorGrouper {
.getTypeName()
.compareTo(d2.getTypeName());
if (c == 0) {
- c = d1.getName().compareTo(d2.getName());
+ c = getFieldSortKey(d1).compareTo(getFieldSortKey(d2));
if (c == 0) {
// Field name duplicate in super/child classes.
c = d1.getDeclaringClass().compareTo(d2.getDeclaringClass());
+ if (c == 0) {
+ // Final tie-breaker: use actual field name to distinguish
fields with same tag ID.
+ // This ensures TreeSet never treats different fields as
duplicates.
+ c = d1.getName().compareTo(d2.getName());
+ }
}
}
return c;
diff --git a/java/fory-core/src/test/java/org/apache/fory/XlangTestBase.java
b/java/fory-core/src/test/java/org/apache/fory/XlangTestBase.java
index 516a9ac9e..b8801bfa3 100644
--- a/java/fory-core/src/test/java/org/apache/fory/XlangTestBase.java
+++ b/java/fory-core/src/test/java/org/apache/fory/XlangTestBase.java
@@ -1365,30 +1365,12 @@ public abstract class XlangTestBase extends
ForyTestBase {
buffer = MemoryBuffer.newHeapBuffer(64);
fory1.serialize(buffer, obj1);
-
- // Debug: print Java output bytes
byte[] javaBytes = buffer.getBytes(0, buffer.writerIndex());
- System.out.println("Java OneStringFieldStruct output " + javaBytes.length
+ " bytes:");
- for (int i = 0; i < javaBytes.length; i++) {
- if (i > 0 && i % 16 == 0) System.out.println();
- System.out.printf("%02x ", javaBytes[i] & 0xFF);
- }
- System.out.println();
-
String caseName2 = "test_schema_evolution_compatible_reverse";
ExecutionContext ctx2 = prepareExecution(caseName2, javaBytes);
runPeer(ctx2);
MemoryBuffer buffer3 = readBuffer(ctx2.dataFile());
- // Debug: print Go output bytes
- byte[] goBytes = buffer3.getBytes(0, buffer3.size());
- System.out.println("Go output " + goBytes.length + " bytes:");
- for (int i = 0; i < goBytes.length; i++) {
- if (i > 0 && i % 16 == 0) System.out.println();
- System.out.printf("%02x ", goBytes[i] & 0xFF);
- }
- System.out.println();
-
TwoStringFieldStruct result2 = (TwoStringFieldStruct)
fory2.deserialize(buffer3);
Assert.assertEquals(result2.f1, "only_one");
// Go uses empty string for missing fields (Go string can't be null)
@@ -1548,13 +1530,6 @@ public abstract class XlangTestBase extends ForyTestBase
{
// Debug: print Java output bytes
byte[] javaBytes = buffer.getBytes(0, buffer.writerIndex());
- System.out.println("Java OneEnumFieldStruct output " + javaBytes.length +
" bytes:");
- for (int i = 0; i < javaBytes.length; i++) {
- if (i > 0 && i % 16 == 0) System.out.println();
- System.out.printf("%02x ", javaBytes[i] & 0xFF);
- }
- System.out.println();
-
String caseName2 = "test_enum_schema_evolution_compatible_reverse";
ExecutionContext ctx2 = prepareExecution(caseName2, javaBytes);
runPeer(ctx2);
@@ -1562,13 +1537,6 @@ public abstract class XlangTestBase extends ForyTestBase
{
MemoryBuffer buffer3 = readBuffer(ctx2.dataFile());
// Debug: print Go output bytes
byte[] goBytes = buffer3.getBytes(0, buffer3.size());
- System.out.println("Go output " + goBytes.length + " bytes:");
- for (int i = 0; i < goBytes.length; i++) {
- if (i > 0 && i % 16 == 0) System.out.println();
- System.out.printf("%02x ", goBytes[i] & 0xFF);
- }
- System.out.println();
-
TwoEnumFieldStruct result2 = (TwoEnumFieldStruct)
fory2.deserialize(buffer3);
Assert.assertEquals(result2.f1, TestEnum.VALUE_C);
// Go uses zero value for missing enum fields (first enum value)
diff --git a/python/pyfory/struct.py b/python/pyfory/struct.py
index 3d3dcf8f7..12c2b7ba5 100644
--- a/python/pyfory/struct.py
+++ b/python/pyfory/struct.py
@@ -485,11 +485,19 @@ class DataClassSerializer(Serializer):
serializer_var = f"serializer{index}"
serializer = self._serializers[index]
context[serializer_var] = serializer
+ is_nullable = self._nullable_fields.get(field_name, False)
+ # For nullable fields, use safe access with None default to handle
+ # schema evolution cases where the field might not exist on the
object
if not self._has_slots:
- stmts.append(f"{field_value} = {value_dict}['{field_name}']")
+ if is_nullable:
+ stmts.append(f"{field_value} =
{value_dict}.get('{field_name}')")
+ else:
+ stmts.append(f"{field_value} =
{value_dict}['{field_name}']")
else:
- stmts.append(f"{field_value} = {value}.{field_name}")
- is_nullable = self._nullable_fields.get(field_name, False)
+ if is_nullable:
+ stmts.append(f"{field_value} = getattr({value},
'{field_name}', None)")
+ else:
+ stmts.append(f"{field_value} = {value}.{field_name}")
if is_nullable:
if isinstance(serializer, StringSerializer):
stmts.extend(
@@ -841,41 +849,86 @@ def group_fields(type_resolver, field_names, serializers,
nullable_map=None):
return (boxed_types, nullable_boxed_types, internal_types,
collection_types, set_types, map_types, other_types)
+def compute_struct_fingerprint(type_resolver, field_names, serializers,
nullable_map=None):
+ """
+ Computes the fingerprint string for a struct type used in schema
versioning.
+
+ Fingerprint Format:
+ Each field contributes: <field_name>,<type_id>,<ref>,<nullable>;
+ Fields are sorted lexicographically by field name (not by type
category).
+
+ Field Components:
+ - field_name: snake_case field name (Python doesn't support field tag
IDs yet)
+ - type_id: Fory TypeId as decimal string (e.g., "4" for INT32)
+ - ref: "1" if field has explicit ref annotation, "0" otherwise
+ (always "0" in Python since field annotations are not supported)
+ - nullable: "1" if null flag is written, "0" otherwise
+
+ Example fingerprint: "age,4,0,0;name,12,0,1;"
+
+ This format is consistent across Go, Java, Rust, C++, and Python
implementations.
+ The ref flag is based on compile-time annotations only, NOT runtime
ref_tracking config.
+ """
+ if nullable_map is None:
+ nullable_map = {}
+
+ # Build field info list: (field_name, type_id, nullable)
+ field_infos = []
+ for i, field_name in enumerate(field_names):
+ serializer = serializers[i]
+ if serializer is None:
+ # For dynamic/polymorphic fields (like Any/Object), use UNKNOWN
type_id
+ # These fields are included in fingerprint with type_id=0,
nullable=1
+ type_id = TypeId.UNKNOWN
+ nullable_flag = "1"
+ else:
+ type_id = type_resolver.get_typeinfo(serializer.type_).type_id &
0xFF
+ is_nullable = nullable_map.get(field_name, False)
+
+ # Determine nullable flag based on type category (matching Java
behavior)
+ if is_primitive_type(type_id) and not is_nullable:
+ nullable_flag = "0"
+ elif is_polymorphic_type(type_id) or type_id in {TypeId.ENUM,
TypeId.NAMED_ENUM}:
+ # For polymorphic/enum types, use UNKNOWN type_id
+ type_id = TypeId.UNKNOWN
+ nullable_flag = "1"
+ else:
+ nullable_flag = "1"
+
+ field_infos.append((field_name, type_id, nullable_flag))
+
+ # Sort fields lexicographically by field name for fingerprint computation
+ # This matches Java/Go/Rust/C++ behavior
+ field_infos.sort(key=lambda x: x[0])
+
+ # Build fingerprint string
+ # Format: <field_name>,<type_id>,<ref>,<nullable>;
+ hash_parts = []
+ for field_name, type_id, nullable_flag in field_infos:
+ # ref flag: always "0" in Python since field annotations are not
supported
+ # In Java/Go, ref is "1" only if explicitly annotated with
@ForyField(ref=true) or fory:"trackRef"
+ ref_flag = "0"
+
hash_parts.append(f"{field_name},{type_id},{ref_flag},{nullable_flag};")
+
+ return "".join(hash_parts)
+
+
def compute_struct_meta(type_resolver, field_names, serializers,
nullable_map=None):
+ """
+ Computes struct metadata including version hash, sorted field names, and
serializers.
+
+ Uses compute_struct_fingerprint to build the fingerprint string, then
hashes it
+ with MurmurHash3 using seed 47, and takes the low 32 bits as signed int32.
+
+ This provides the cross-language struct version ID used by class version
checking,
+ consistent with Go, Java, Rust, and C++ implementations.
+ """
(boxed_types, nullable_boxed_types, internal_types, collection_types,
set_types, map_types, other_types) = group_fields(
type_resolver, field_names, serializers, nullable_map
)
- # Build hash string
- hash_parts = []
-
- # boxed_types => non-nullable
- for field in boxed_types:
- type_id = field[0]
- field_name = field[2] # already snake_case
- nullable_flag = "0"
- hash_parts.append(f"{field_name},{type_id},{nullable_flag};")
-
- # All other groups => nullable
- for group in (
- nullable_boxed_types,
- internal_types,
- collection_types,
- set_types,
- map_types,
- ):
- for field in group:
- type_id = field[0]
- field_name = field[2]
- nullable_flag = "1"
- hash_parts.append(f"{field_name},{type_id},{nullable_flag};")
- for field in other_types:
- type_id = TypeId.UNKNOWN
- field_name = field[2]
- nullable_flag = "1"
- hash_parts.append(f"{field_name},{type_id},{nullable_flag};")
-
- hash_str = "".join(hash_parts)
+ # Compute fingerprint string using the new format
+ hash_str = compute_struct_fingerprint(type_resolver, field_names,
serializers, nullable_map)
hash_bytes = hash_str.encode("utf-8")
# Handle empty hash_bytes (no fields or all fields are unknown/dynamic)
diff --git a/python/pyfory/tests/xlang_test_main.py
b/python/pyfory/tests/xlang_test_main.py
index d2782c552..e1355aab2 100644
--- a/python/pyfory/tests/xlang_test_main.py
+++ b/python/pyfory/tests/xlang_test_main.py
@@ -140,6 +140,39 @@ class EmptyWrapper:
pass
+@dataclass
+class EmptyStruct:
+ pass
+
+
+@dataclass
+class OneStringFieldStruct:
+ f1: Optional[str] = None
+
+
+@dataclass
+class TwoStringFieldStruct:
+ f1: Optional[str] = None
+ f2: Optional[str] = None
+
+
+class TestEnum(enum.Enum):
+ VALUE_A = 0
+ VALUE_B = 1
+ VALUE_C = 2
+
+
+@dataclass
+class OneEnumFieldStruct:
+ f1: TestEnum = None
+
+
+@dataclass
+class TwoEnumFieldStruct:
+ f1: TestEnum = None
+ f2: TestEnum = None
+
+
# ============================================================================
# Test Functions - Each function handles read -> verify -> write back
# ============================================================================
@@ -448,6 +481,212 @@ def test_polymorphic_map():
f.write(new_buffer.get_bytes(0, new_buffer.writer_index))
+def test_one_string_field_schema():
+ """Test one string field struct with schema consistent mode."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ fory = pyfory.Fory(xlang=True, compatible=False)
+ fory.register_type(OneStringFieldStruct, type_id=200)
+
+ expected = OneStringFieldStruct(f1="hello")
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized: {obj}")
+ assert obj == expected, f"Mismatch: {obj} != {expected}"
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
+def test_one_string_field_compatible():
+ """Test one string field struct with compatible mode."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ fory = pyfory.Fory(xlang=True, compatible=True)
+ fory.register_type(OneStringFieldStruct, type_id=200)
+
+ expected = OneStringFieldStruct(f1="hello")
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized: {obj}")
+ assert obj == expected, f"Mismatch: {obj} != {expected}"
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
+def test_two_string_field_compatible():
+ """Test two string field struct with compatible mode."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ fory = pyfory.Fory(xlang=True, compatible=True)
+ fory.register_type(TwoStringFieldStruct, type_id=201)
+
+ expected = TwoStringFieldStruct(f1="first", f2="second")
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized: {obj}")
+ assert obj == expected, f"Mismatch: {obj} != {expected}"
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
+def test_schema_evolution_compatible():
+ """Test schema evolution: deserialize TwoStringFieldStruct as
EmptyStruct."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ # Deserialize TwoStringFieldStruct as EmptyStruct (should skip all fields)
+ fory = pyfory.Fory(xlang=True, compatible=True)
+ fory.register_type(EmptyStruct, type_id=200)
+
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized as EmptyStruct: {obj}")
+ assert isinstance(obj, EmptyStruct), f"Expected EmptyStruct, got
{type(obj)}"
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
+def test_schema_evolution_compatible_reverse():
+ """Test schema evolution: deserialize OneStringFieldStruct as
TwoStringFieldStruct."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ # Deserialize OneStringFieldStruct as TwoStringFieldStruct
+ fory = pyfory.Fory(xlang=True, compatible=True)
+ fory.register_type(TwoStringFieldStruct, type_id=200)
+
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized as TwoStringFieldStruct: {obj}")
+ assert isinstance(obj, TwoStringFieldStruct), f"Expected
TwoStringFieldStruct, got {type(obj)}"
+ assert obj.f1 == "only_one", f"Expected f1='only_one', got f1='{obj.f1}'"
+ # f2 should be None (missing field)
+ assert obj.f2 is None or obj.f2 == "", f"Expected f2=None or empty, got
f2='{obj.f2}'"
+
+ # Set f2 to empty string for serialization (match Go behavior)
+ if obj.f2 is None:
+ obj.f2 = ""
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
+def test_one_enum_field_schema():
+ """Test one enum field struct with schema consistent mode."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ fory = pyfory.Fory(xlang=True, compatible=False)
+ fory.register_type(TestEnum, type_id=210)
+ fory.register_type(OneEnumFieldStruct, type_id=211)
+
+ expected = OneEnumFieldStruct(f1=TestEnum.VALUE_B)
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized: {obj}")
+ assert obj == expected, f"Mismatch: {obj} != {expected}"
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
+def test_one_enum_field_compatible():
+ """Test one enum field struct with compatible mode."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ fory = pyfory.Fory(xlang=True, compatible=True)
+ fory.register_type(TestEnum, type_id=210)
+ fory.register_type(OneEnumFieldStruct, type_id=211)
+
+ expected = OneEnumFieldStruct(f1=TestEnum.VALUE_A)
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized: {obj}")
+ assert obj == expected, f"Mismatch: {obj} != {expected}"
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
+def test_two_enum_field_compatible():
+ """Test two enum field struct with compatible mode."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ fory = pyfory.Fory(xlang=True, compatible=True)
+ fory.register_type(TestEnum, type_id=210)
+ fory.register_type(TwoEnumFieldStruct, type_id=212)
+
+ expected = TwoEnumFieldStruct(f1=TestEnum.VALUE_A, f2=TestEnum.VALUE_C)
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized: {obj}")
+ assert obj == expected, f"Mismatch: {obj} != {expected}"
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
+def test_enum_schema_evolution_compatible():
+ """Test enum schema evolution: deserialize TwoEnumFieldStruct as
EmptyStruct."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ # Deserialize TwoEnumFieldStruct as EmptyStruct (should skip all fields)
+ fory = pyfory.Fory(xlang=True, compatible=True)
+ fory.register_type(TestEnum, type_id=210)
+ fory.register_type(EmptyStruct, type_id=211)
+
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized as EmptyStruct: {obj}")
+ assert isinstance(obj, EmptyStruct), f"Expected EmptyStruct, got
{type(obj)}"
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
+def test_enum_schema_evolution_compatible_reverse():
+ """Test enum schema evolution: deserialize OneEnumFieldStruct as
TwoEnumFieldStruct."""
+ data_file = get_data_file()
+ with open(data_file, "rb") as f:
+ data_bytes = f.read()
+
+ # Deserialize OneEnumFieldStruct as TwoEnumFieldStruct
+ fory = pyfory.Fory(xlang=True, compatible=True)
+ fory.register_type(TestEnum, type_id=210)
+ fory.register_type(TwoEnumFieldStruct, type_id=211)
+
+ obj = fory.deserialize(data_bytes)
+ debug_print(f"Deserialized as TwoEnumFieldStruct: {obj}")
+ assert isinstance(obj, TwoEnumFieldStruct), f"Expected TwoEnumFieldStruct,
got {type(obj)}"
+ assert obj.f1 == TestEnum.VALUE_C, f"Expected f1=VALUE_C, got f1={obj.f1}"
+ # f2 should be None (missing field due to schema evolution)
+ f2_value = getattr(obj, "f2", None)
+ assert f2_value is None, f"Expected f2=None, got f2={f2_value}"
+
+ new_bytes = fory.serialize(obj)
+ with open(data_file, "wb") as f:
+ f.write(new_bytes)
+
+
if __name__ == "__main__":
"""
This file is executed by PythonXlangTest.java and other cross-language
tests.
diff --git a/rust/fory-derive/src/object/util.rs
b/rust/fory-derive/src/object/util.rs
index 0e9add7a3..1389dfeb9 100644
--- a/rust/fory-derive/src/object/util.rs
+++ b/rust/fory-derive/src/object/util.rs
@@ -994,7 +994,24 @@ fn to_snake_case(name: &str) -> String {
result
}
-pub(crate) fn compute_struct_version_hash(fields: &[&Field]) -> i32 {
+/// Computes the fingerprint string for a struct type used in schema
versioning.
+///
+/// **Fingerprint Format:**
+///
+/// Each field contributes: `<field_name>,<type_id>,<ref>,<nullable>;`
+///
+/// Fields are sorted by field name (snake_case) lexicographically.
+///
+/// **Field Components:**
+/// - `field_name`: snake_case field name (Rust doesn't support field tag IDs
yet)
+/// - `type_id`: Fory TypeId as decimal string (e.g., "4" for INT32)
+/// - `ref`: "1" if reference tracking enabled, "0" otherwise (currently
always "0" in Rust)
+/// - `nullable`: "1" if null flag is written, "0" otherwise
+///
+/// **Example fingerprint:** `"age,4,0,0;name,12,0,1;"`
+///
+/// This format is consistent across Go, Java, Rust, and C++ implementations.
+pub(crate) fn compute_struct_fingerprint(fields: &[&Field]) -> String {
let mut field_info_map: HashMap<String, (u32, bool)> =
HashMap::with_capacity(fields.len());
for field in fields {
let name = field.ident.as_ref().unwrap().to_string();
@@ -1006,11 +1023,19 @@ pub(crate) fn compute_struct_version_hash(fields:
&[&Field]) -> i32 {
field_info_map.insert(name, (type_id, nullable));
}
+ // Sort field names lexicographically for fingerprint computation
+ // This matches Java/Go behavior where fingerprint fields are sorted by
name,
+ // not by the type-category-based ordering used for serialization
+ let mut sorted_names: Vec<String> =
field_info_map.keys().cloned().collect();
+ sorted_names.sort();
+
let mut fingerprint = String::new();
- for name in get_sorted_field_names(fields).iter() {
+ for name in sorted_names.iter() {
let (type_id, nullable) = field_info_map
.get(name)
.expect("Field metadata missing during struct hash computation");
+ // Format: <field_name>,<type_id>,<ref>,<nullable>;
+ // Since Rust doesn't support field tag IDs yet, use snake_case field
name
fingerprint.push_str(&to_snake_case(name));
fingerprint.push(',');
let effective_type_id = if *type_id == TypeId::UNKNOWN as u32 {
@@ -1020,9 +1045,26 @@ pub(crate) fn compute_struct_version_hash(fields:
&[&Field]) -> i32 {
};
fingerprint.push_str(&effective_type_id.to_string());
fingerprint.push(',');
+ // ref flag: currently always 0 in Rust (no ref tracking support yet)
+ fingerprint.push('0');
+ fingerprint.push(',');
fingerprint.push_str(if *nullable { "1;" } else { "0;" });
}
+ fingerprint
+}
+
+/// Computes the struct version hash from field metadata.
+///
+/// Uses `compute_struct_fingerprint` to build the fingerprint string,
+/// then hashes it with MurmurHash3_x64_128 using seed 47, and takes
+/// the low 32 bits as signed i32.
+///
+/// This provides the cross-language struct version ID used by class
+/// version checking, consistent with Go, Java, and C++ implementations.
+pub(crate) fn compute_struct_version_hash(fields: &[&Field]) -> i32 {
+ let fingerprint = compute_struct_fingerprint(fields);
+
let seed: u64 = 47;
let (hash, _) =
fory_core::meta::murmurhash3_x64_128(fingerprint.as_bytes(), seed);
let version = (hash & 0xFFFF_FFFF) as u32;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]