Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ This release also includes changes from <<release-3-7-6, 3.7.6>>.
* Expose serialization functions for alternative transport protocols in gremlin-go
* 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.
* Improved Gremlint formatting to do greedy argument packing when possible so that more arguments can appear on a single line.
* Add custom type writer and serializer to gremlin-go

[[release-3-8-0]]
=== TinkerPop 3.8.0 (Release Date: November 12, 2025)
Expand Down
57 changes: 57 additions & 0 deletions gremlin-go/driver/graphBinary.go
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,57 @@ func bindingWriter(value interface{}, buffer *bytes.Buffer, typeSerializer *grap
return buffer.Bytes(), nil
}

// customTypeWriter handles serialization of custom types registered via RegisterCustomTypeWriter.
// Format: {type_code}{type_name}{custom_payload}
// where:
// - type_code = 0x00 (customType) - already written in write() before invoking this function
// - type_name = string with length prefix - written by this function
// - custom_payload = everything else - written by the user's CustomTypeWriter function
//
// The custom_payload typically includes {value_flag}{value}, but custom types may include
// additional metadata (e.g., JanusGraph's customTypeInfo) before the value_flag.
func customTypeWriter(value interface{}, buffer *bytes.Buffer, typeSerializer *graphBinaryTypeSerializer) ([]byte, error) {
// Look up the custom type info
valType := reflect.TypeOf(value)
customTypeWriterLock.RLock()
typeInfo, exists := customSerializers[valType]
customTypeWriterLock.RUnlock()

if !exists || customSerializers == nil {
return nil, newError(err0407GetSerializerToWriteUnknownTypeError, valType.Name())
}

// Write the custom type name as a String (length prefix + UTF-8 bytes)
typeName := typeInfo.TypeName
typeNameBytes := []byte(typeName)
if err := binary.Write(buffer, binary.BigEndian, int32(len(typeNameBytes))); err != nil {
return nil, err
}
if _, err := buffer.Write(typeNameBytes); err != nil {
return nil, err
}

Comment thread
xiazcy marked this conversation as resolved.
// Call the custom writer to serialize the value
if err := typeInfo.Writer(value, buffer, typeSerializer); err != nil {
return nil, err
}

return buffer.Bytes(), nil
}

func (serializer *graphBinaryTypeSerializer) getType(val interface{}) (dataType, error) {
// Check if this is a registered custom type
valType := reflect.TypeOf(val)
customTypeWriterLock.RLock()
var isCustomType bool
if customSerializers != nil {
_, isCustomType = customSerializers[valType]
}
customTypeWriterLock.RUnlock()
if isCustomType {
return customType, nil
}

switch val.(type) {
case *Bytecode, Bytecode, *GraphTraversal:
return bytecodeType, nil
Expand Down Expand Up @@ -816,6 +866,13 @@ func (serializer *graphBinaryTypeSerializer) write(valueObject interface{}, buff
return nil, err
}
buffer.Write(dataType.getCodeBytes())
if dataType == customType {
// Custom type format typically: {type_code=0x00}{type_name}{custom_writer_output}
// The type_name immediately follows type_code with NO value_flag in between.
// writeType would insert an extra value_flag byte that shifts the type_name
// string, causing the server to compute the wrong string length → PROCESSING_ERROR.
return writer(valueObject, buffer, serializer)
}
return serializer.writeType(valueObject, buffer, writer)
}

Expand Down
37 changes: 37 additions & 0 deletions gremlin-go/driver/serializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ type GraphBinarySerializer struct {
// CustomTypeReader user provided function to deserialize custom types
type CustomTypeReader func(data *[]byte, i *int) (interface{}, error)

// CustomTypeWriter user provided function to serialize custom types
type CustomTypeWriter func(value interface{}, buffer *bytes.Buffer, serializer *graphBinaryTypeSerializer) error

// CustomTypeInfo holds metadata for a registered custom type
type CustomTypeInfo struct {
TypeName string
Writer CustomTypeWriter
}

type writer func(interface{}, *bytes.Buffer, *graphBinaryTypeSerializer) ([]byte, error)
type reader func(data *[]byte, i *int) (interface{}, error)

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

// customTypeWriterLock used to synchronize access to the customSerializers map
var customTypeWriterLock = sync.RWMutex{}
var customSerializers map[reflect.Type]CustomTypeInfo

func init() {
initSerializers()
initDeserializers()
Expand Down Expand Up @@ -266,6 +279,7 @@ func (gs GraphBinarySerializer) DeserializeMessage(message []byte) (Response, er

func initSerializers() {
serializers = map[dataType]writer{
customType: customTypeWriter,
bytecodeType: bytecodeWriter,
stringType: stringWriter,
bigDecimalType: bigDecimalWriter,
Expand Down Expand Up @@ -392,3 +406,26 @@ func UnregisterCustomTypeReader(customTypeName string) {
defer customTypeReaderLock.Unlock()
delete(customDeserializers, customTypeName)
}

// RegisterCustomTypeWriter registers a writer (serializer) for a custom type.
// The valueType should be the reflect.Type of the custom type (e.g., reflect.TypeOf((*MyType)(nil)))
// The typeName is the GraphBinary custom type name (e.g., "janusgraph.RelationIdentifier")
// The writer function should serialize the value into the buffer in GraphBinary custom type format.
func RegisterCustomTypeWriter(valueType reflect.Type, typeName string, writer CustomTypeWriter) {
customTypeWriterLock.Lock()
defer customTypeWriterLock.Unlock()
if customSerializers == nil {
customSerializers = make(map[reflect.Type]CustomTypeInfo)
}
customSerializers[valueType] = CustomTypeInfo{
TypeName: typeName,
Writer: writer,
}
}

// UnregisterCustomTypeWriter unregisters a writer (serializer) for a custom type
func UnregisterCustomTypeWriter(valueType reflect.Type) {
customTypeWriterLock.Lock()
defer customTypeWriterLock.Unlock()
delete(customSerializers, valueType)
}
95 changes: 95 additions & 0 deletions gremlin-go/driver/serializer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ under the License.
package gremlingo

import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"reflect"
"testing"

"github.com/google/uuid"
Expand Down Expand Up @@ -78,6 +81,45 @@ func TestSerializer(t *testing.T) {
assert.Equal(t, map[string]interface{}{}, response.ResponseResult.Meta)
assert.NotNil(t, response.ResponseResult.Data)
})

t.Run("test serialized request message w/ custom type", func(t *testing.T) {
customType := reflect.TypeOf((*TestCustomType)(nil))
typeName := "test.CustomType"

// Register the custom type writer
RegisterCustomTypeWriter(customType, typeName, testCustomTypeWriter)
defer UnregisterCustomTypeWriter(customType)

testValue := &TestCustomType{
ID: 12345,
Value: "test value",
}

var u, _ = uuid.Parse("41d2e28a-20a4-4ab0-b379-d810dede3786")
testRequest := request{
requestID: u,
op: "eval",
processor: "",
args: map[string]interface{}{"gremlin": "g.V().count()", "customArg": testValue},
}

serializer := newGraphBinarySerializer(newLogHandler(&defaultLogger{}, Error, language.English))
serialized, err := serializer.SerializeMessage(&testRequest)

assert.Nil(t, err)
assert.NotNil(t, serialized)

// Verify the serialized data contains the custom type name bytes
typeNameBytes := []byte(typeName)
found := false
for i := 0; i <= len(serialized)-len(typeNameBytes); i++ {
if bytes.Equal(serialized[i:i+len(typeNameBytes)], typeNameBytes) {
found = true
break
}
}
assert.True(t, found, "Expected serialized data to contain custom type name")
})
}

func TestSerializerFailures(t *testing.T) {
Expand Down Expand Up @@ -106,6 +148,59 @@ func TestSerializerFailures(t *testing.T) {
assert.NotNil(t, err)
assert.True(t, isSameErrorCode(newError(err0409GetSerializerToReadUnknownCustomTypeError), err))
})

t.Run("test unregistered custom type writer failure", func(t *testing.T) {
type UnregisteredType struct {
Value string
}

testValue := &UnregisteredType{Value: "test"}

var u, _ = uuid.Parse("41d2e28a-20a4-4ab0-b379-d810dede3786")
testRequest := request{
requestID: u,
op: "eval",
processor: "",
args: map[string]interface{}{"gremlin": "g.V().count()", "unregistered": testValue},
}

serializer := newGraphBinarySerializer(newLogHandler(&defaultLogger{}, Error, language.English))
serialized, err := serializer.SerializeMessage(&testRequest)

assert.Nil(t, serialized)
assert.NotNil(t, err)
assert.True(t, isSameErrorCode(newError(err0407GetSerializerToWriteUnknownTypeError), err))
})
}

// TestCustomType is a test custom type for writer tests
type TestCustomType struct {
ID int64
Value string
}

// testCustomTypeWriter is a writer for the test custom type
var testCustomTypeWriter = func(value interface{}, buffer *bytes.Buffer, _ *graphBinaryTypeSerializer) error {
customValue, ok := value.(*TestCustomType)
if !ok {
return errors.New("expected *TestCustomType")
}

// Write ID as int64
if err := binary.Write(buffer, binary.BigEndian, customValue.ID); err != nil {
return err
}

// Write Value as string (length-prefixed)
valueBytes := []byte(customValue.Value)
if err := binary.Write(buffer, binary.BigEndian, int32(len(valueBytes))); err != nil {
return err
}
if _, err := buffer.Write(valueBytes); err != nil {
return err
}

return nil
}

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