Skip to content

Commit 4ce4717

Browse files
authored
Add custom type serializer API for gremlin-go (#3335)
Adds support for serializing custom types in gremlin-go, bringing it to feature parity with the Java driver and completing the custom type support that currently only includes deserialization. This implements the official GraphBinary specification for custom types, enabling full round-trip support for custom graph database types.
1 parent a112cae commit 4ce4717

4 files changed

Lines changed: 190 additions & 0 deletions

File tree

CHANGELOG.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ This release also includes changes from <<release-3-7-6, 3.7.6>>.
3636
* Expose serialization functions for alternative transport protocols in gremlin-go
3737
* Improved Gremlint formatting to keep the first argument for a step on the same line if line breaks were required to meet max line length.
3838
* Improved Gremlint formatting to do greedy argument packing when possible so that more arguments can appear on a single line.
39+
* Add custom type writer and serializer to gremlin-go
3940
4041
[[release-3-8-0]]
4142
=== TinkerPop 3.8.0 (Release Date: November 12, 2025)

gremlin-go/driver/graphBinary.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,57 @@ func bindingWriter(value interface{}, buffer *bytes.Buffer, typeSerializer *grap
681681
return buffer.Bytes(), nil
682682
}
683683

684+
// customTypeWriter handles serialization of custom types registered via RegisterCustomTypeWriter.
685+
// Format: {type_code}{type_name}{custom_payload}
686+
// where:
687+
// - type_code = 0x00 (customType) - already written in write() before invoking this function
688+
// - type_name = string with length prefix - written by this function
689+
// - custom_payload = everything else - written by the user's CustomTypeWriter function
690+
//
691+
// The custom_payload typically includes {value_flag}{value}, but custom types may include
692+
// additional metadata (e.g., JanusGraph's customTypeInfo) before the value_flag.
693+
func customTypeWriter(value interface{}, buffer *bytes.Buffer, typeSerializer *graphBinaryTypeSerializer) ([]byte, error) {
694+
// Look up the custom type info
695+
valType := reflect.TypeOf(value)
696+
customTypeWriterLock.RLock()
697+
typeInfo, exists := customSerializers[valType]
698+
customTypeWriterLock.RUnlock()
699+
700+
if !exists || customSerializers == nil {
701+
return nil, newError(err0407GetSerializerToWriteUnknownTypeError, valType.Name())
702+
}
703+
704+
// Write the custom type name as a String (length prefix + UTF-8 bytes)
705+
typeName := typeInfo.TypeName
706+
typeNameBytes := []byte(typeName)
707+
if err := binary.Write(buffer, binary.BigEndian, int32(len(typeNameBytes))); err != nil {
708+
return nil, err
709+
}
710+
if _, err := buffer.Write(typeNameBytes); err != nil {
711+
return nil, err
712+
}
713+
714+
// Call the custom writer to serialize the value
715+
if err := typeInfo.Writer(value, buffer, typeSerializer); err != nil {
716+
return nil, err
717+
}
718+
719+
return buffer.Bytes(), nil
720+
}
721+
684722
func (serializer *graphBinaryTypeSerializer) getType(val interface{}) (dataType, error) {
723+
// Check if this is a registered custom type
724+
valType := reflect.TypeOf(val)
725+
customTypeWriterLock.RLock()
726+
var isCustomType bool
727+
if customSerializers != nil {
728+
_, isCustomType = customSerializers[valType]
729+
}
730+
customTypeWriterLock.RUnlock()
731+
if isCustomType {
732+
return customType, nil
733+
}
734+
685735
switch val.(type) {
686736
case *Bytecode, Bytecode, *GraphTraversal:
687737
return bytecodeType, nil
@@ -816,6 +866,13 @@ func (serializer *graphBinaryTypeSerializer) write(valueObject interface{}, buff
816866
return nil, err
817867
}
818868
buffer.Write(dataType.getCodeBytes())
869+
if dataType == customType {
870+
// Custom type format typically: {type_code=0x00}{type_name}{custom_writer_output}
871+
// The type_name immediately follows type_code with NO value_flag in between.
872+
// writeType would insert an extra value_flag byte that shifts the type_name
873+
// string, causing the server to compute the wrong string length → PROCESSING_ERROR.
874+
return writer(valueObject, buffer, serializer)
875+
}
819876
return serializer.writeType(valueObject, buffer, writer)
820877
}
821878

gremlin-go/driver/serializer.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ type GraphBinarySerializer struct {
4646
// CustomTypeReader user provided function to deserialize custom types
4747
type CustomTypeReader func(data *[]byte, i *int) (interface{}, error)
4848

49+
// CustomTypeWriter user provided function to serialize custom types
50+
type CustomTypeWriter func(value interface{}, buffer *bytes.Buffer, serializer *graphBinaryTypeSerializer) error
51+
52+
// CustomTypeInfo holds metadata for a registered custom type
53+
type CustomTypeInfo struct {
54+
TypeName string
55+
Writer CustomTypeWriter
56+
}
57+
4958
type writer func(interface{}, *bytes.Buffer, *graphBinaryTypeSerializer) ([]byte, error)
5059
type reader func(data *[]byte, i *int) (interface{}, error)
5160

@@ -56,6 +65,10 @@ var serializers map[dataType]writer
5665
var customTypeReaderLock = sync.RWMutex{}
5766
var customDeserializers map[string]CustomTypeReader
5867

68+
// customTypeWriterLock used to synchronize access to the customSerializers map
69+
var customTypeWriterLock = sync.RWMutex{}
70+
var customSerializers map[reflect.Type]CustomTypeInfo
71+
5972
func init() {
6073
initSerializers()
6174
initDeserializers()
@@ -266,6 +279,7 @@ func (gs GraphBinarySerializer) DeserializeMessage(message []byte) (Response, er
266279

267280
func initSerializers() {
268281
serializers = map[dataType]writer{
282+
customType: customTypeWriter,
269283
bytecodeType: bytecodeWriter,
270284
stringType: stringWriter,
271285
bigDecimalType: bigDecimalWriter,
@@ -392,3 +406,26 @@ func UnregisterCustomTypeReader(customTypeName string) {
392406
defer customTypeReaderLock.Unlock()
393407
delete(customDeserializers, customTypeName)
394408
}
409+
410+
// RegisterCustomTypeWriter registers a writer (serializer) for a custom type.
411+
// The valueType should be the reflect.Type of the custom type (e.g., reflect.TypeOf((*MyType)(nil)))
412+
// The typeName is the GraphBinary custom type name (e.g., "janusgraph.RelationIdentifier")
413+
// The writer function should serialize the value into the buffer in GraphBinary custom type format.
414+
func RegisterCustomTypeWriter(valueType reflect.Type, typeName string, writer CustomTypeWriter) {
415+
customTypeWriterLock.Lock()
416+
defer customTypeWriterLock.Unlock()
417+
if customSerializers == nil {
418+
customSerializers = make(map[reflect.Type]CustomTypeInfo)
419+
}
420+
customSerializers[valueType] = CustomTypeInfo{
421+
TypeName: typeName,
422+
Writer: writer,
423+
}
424+
}
425+
426+
// UnregisterCustomTypeWriter unregisters a writer (serializer) for a custom type
427+
func UnregisterCustomTypeWriter(valueType reflect.Type) {
428+
customTypeWriterLock.Lock()
429+
defer customTypeWriterLock.Unlock()
430+
delete(customSerializers, valueType)
431+
}

gremlin-go/driver/serializer_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ under the License.
2020
package gremlingo
2121

2222
import (
23+
"bytes"
24+
"encoding/binary"
2325
"errors"
2426
"fmt"
27+
"reflect"
2528
"testing"
2629

2730
"github.com/google/uuid"
@@ -78,6 +81,45 @@ func TestSerializer(t *testing.T) {
7881
assert.Equal(t, map[string]interface{}{}, response.ResponseResult.Meta)
7982
assert.NotNil(t, response.ResponseResult.Data)
8083
})
84+
85+
t.Run("test serialized request message w/ custom type", func(t *testing.T) {
86+
customType := reflect.TypeOf((*TestCustomType)(nil))
87+
typeName := "test.CustomType"
88+
89+
// Register the custom type writer
90+
RegisterCustomTypeWriter(customType, typeName, testCustomTypeWriter)
91+
defer UnregisterCustomTypeWriter(customType)
92+
93+
testValue := &TestCustomType{
94+
ID: 12345,
95+
Value: "test value",
96+
}
97+
98+
var u, _ = uuid.Parse("41d2e28a-20a4-4ab0-b379-d810dede3786")
99+
testRequest := request{
100+
requestID: u,
101+
op: "eval",
102+
processor: "",
103+
args: map[string]interface{}{"gremlin": "g.V().count()", "customArg": testValue},
104+
}
105+
106+
serializer := newGraphBinarySerializer(newLogHandler(&defaultLogger{}, Error, language.English))
107+
serialized, err := serializer.SerializeMessage(&testRequest)
108+
109+
assert.Nil(t, err)
110+
assert.NotNil(t, serialized)
111+
112+
// Verify the serialized data contains the custom type name bytes
113+
typeNameBytes := []byte(typeName)
114+
found := false
115+
for i := 0; i <= len(serialized)-len(typeNameBytes); i++ {
116+
if bytes.Equal(serialized[i:i+len(typeNameBytes)], typeNameBytes) {
117+
found = true
118+
break
119+
}
120+
}
121+
assert.True(t, found, "Expected serialized data to contain custom type name")
122+
})
81123
}
82124

83125
func TestSerializerFailures(t *testing.T) {
@@ -106,6 +148,59 @@ func TestSerializerFailures(t *testing.T) {
106148
assert.NotNil(t, err)
107149
assert.True(t, isSameErrorCode(newError(err0409GetSerializerToReadUnknownCustomTypeError), err))
108150
})
151+
152+
t.Run("test unregistered custom type writer failure", func(t *testing.T) {
153+
type UnregisteredType struct {
154+
Value string
155+
}
156+
157+
testValue := &UnregisteredType{Value: "test"}
158+
159+
var u, _ = uuid.Parse("41d2e28a-20a4-4ab0-b379-d810dede3786")
160+
testRequest := request{
161+
requestID: u,
162+
op: "eval",
163+
processor: "",
164+
args: map[string]interface{}{"gremlin": "g.V().count()", "unregistered": testValue},
165+
}
166+
167+
serializer := newGraphBinarySerializer(newLogHandler(&defaultLogger{}, Error, language.English))
168+
serialized, err := serializer.SerializeMessage(&testRequest)
169+
170+
assert.Nil(t, serialized)
171+
assert.NotNil(t, err)
172+
assert.True(t, isSameErrorCode(newError(err0407GetSerializerToWriteUnknownTypeError), err))
173+
})
174+
}
175+
176+
// TestCustomType is a test custom type for writer tests
177+
type TestCustomType struct {
178+
ID int64
179+
Value string
180+
}
181+
182+
// testCustomTypeWriter is a writer for the test custom type
183+
var testCustomTypeWriter = func(value interface{}, buffer *bytes.Buffer, _ *graphBinaryTypeSerializer) error {
184+
customValue, ok := value.(*TestCustomType)
185+
if !ok {
186+
return errors.New("expected *TestCustomType")
187+
}
188+
189+
// Write ID as int64
190+
if err := binary.Write(buffer, binary.BigEndian, customValue.ID); err != nil {
191+
return err
192+
}
193+
194+
// Write Value as string (length-prefixed)
195+
valueBytes := []byte(customValue.Value)
196+
if err := binary.Write(buffer, binary.BigEndian, int32(len(valueBytes))); err != nil {
197+
return err
198+
}
199+
if _, err := buffer.Write(valueBytes); err != nil {
200+
return err
201+
}
202+
203+
return nil
109204
}
110205

111206
// exampleJanusgraphRelationIdentifierReader this implementation is not complete and is used only for the purposes of testing custom readers

0 commit comments

Comments
 (0)