diff --git a/.dockerignore b/.dockerignore
index 50d68018327..400575544c0 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,7 @@
**/*.log
**/target
!gremlin-server/target/apache-tinkerpop-gremlin-server-*
+!gremlin-server/target/gremlin-server-*-tests.jar
!gremlin-console/target/apache-tinkerpop-gremlin-console-*
*.iml
.idea
diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index e312807286e..de5df857c03 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -27,6 +27,7 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima
* Added typed numeric wrappers and `preciseNumbers` connection option to `gremlin-javascript` for explicit control over numeric type serialization and deserialization.
* Added `NextN(n)` to `Traversal` in `gremlin-go` for batched result iteration, providing API parity with `next(n)` in the Java, Python, and .NET GLVs, and updated the Go translators in `gremlin-core` and `gremlin-javascript` to emit `NextN(n)` for the batched form.
+* Added Provider Defined Types (PDT) support — graph providers can define custom types via `@ProviderDefined` annotation that serialize/deserialize seamlessly across all GLVs without driver-side configuration. Replaces TP3 custom type mechanism.
* Added Gremlator, a single page web application, that translates Gremlin into various programming languages like Javascript and Python.
* Removed `uuid` dependency from `gremlin-javascript` in favor of the built-in `globalThis.crypto.randomUUID()`.
* Added streaming HTTP response support to `gremlin-driver` for incremental result deserialization over GraphBinary.
diff --git a/docker/gremlin-test-server/Dockerfile b/docker/gremlin-test-server/Dockerfile
index 8ac853c1d1a..3740499dcc7 100644
--- a/docker/gremlin-test-server/Dockerfile
+++ b/docker/gremlin-test-server/Dockerfile
@@ -24,6 +24,7 @@ USER root
RUN mkdir -p /opt
WORKDIR /opt
COPY gremlin-server/src/test /opt/test/
+COPY gremlin-server/target/gremlin-server-*-tests.jar /opt/gremlin-server/lib/
COPY docker/gremlin-server/docker-entrypoint.sh docker/gremlin-server/*.yaml docker/gremlin-server/*.conf /opt/
RUN chmod 755 /opt/docker-entrypoint.sh
diff --git a/docs/src/dev/provider/index.asciidoc b/docs/src/dev/provider/index.asciidoc
index 7654807a90f..7de043bc9da 100644
--- a/docs/src/dev/provider/index.asciidoc
+++ b/docs/src/dev/provider/index.asciidoc
@@ -1334,6 +1334,213 @@ can be used as a reference on how these files can be used and its
link:https://github.com/apache/tinkerpop/blob/x.y.z/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/structure/io/Model.java[model]
shows the Java representation of those files.
+[[provider-defined-types]]
+=== Provider Defined Types (PDT)
+
+Provider Defined Types allow graph providers to expose custom types that drivers can serialize and deserialize without
+manual configuration on the client side. A provider annotates a class (or registers an adapter for a class it doesn't
+own), and the type flows through the wire protocol automatically. Clients receive PDT values as structured objects they
+can use directly or hydrate into language-native types.
+
+==== Basic Usage
+
+Annotate a class with `@ProviderDefined` from the `org.apache.tinkerpop.gremlin.structure.io.pdt` package:
+
+[source,java]
+----
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
+
+@ProviderDefined(name = "mygraph:Point")
+public class Point {
+ public double x;
+ public double y;
+
+ public Point(double x, double y) {
+ this.x = x;
+ this.y = y;
+ }
+}
+----
+
+The `name` attribute is a unique identifier for the type. It is strongly recommended to namespace type names using
+your graph's identifier as a prefix (e.g. `"mygraph:Point"`). This avoids collisions when clients interact with
+multiple providers and makes the origin of a type immediately clear. By default, all fields are included. Use
+`includedFields` or `excludedFields` to control which fields are serialized:
+
+[source,java]
+----
+@ProviderDefined(name = "mygraph:Point", includedFields = {"x", "y"})
+public class Point { ... }
+
+// or exclude specific fields
+@ProviderDefined(name = "mygraph:Person", excludedFields = {"internalId"})
+public class Person { ... }
+----
+
+NOTE: For annotation-based round-trip hydration (see <>), an annotated class must expose a no-arg
+constructor and the mapped fields must be directly settable (e.g. public fields). Classes that cannot meet these
+requirements — for example those with immutable `final` fields or no default constructor — should instead use a
+`ProviderDefinedTypeAdapter` (see <>), which gives full control over construction.
+
+==== Nested Types
+
+PDT supports nested custom types. Each nested type must also be annotated:
+
+[source,java]
+----
+@ProviderDefined(name = "mygraph:Address")
+public class Address {
+ public String street;
+ public String city;
+}
+
+@ProviderDefined(name = "mygraph:Person")
+public class Person {
+ public String name;
+ public Address address;
+}
+----
+
+When serialized, the `address` field is itself encoded as a PDT value.
+
+[[adapter-for-types-you-don-t-own]]
+==== Adapter for Types You Don't Own
+
+For classes you cannot annotate (e.g. `java.awt.Color`), implement `ProviderDefinedTypeAdapter`:
+
+[source,java]
+----
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter;
+
+public class ColorAdapter implements ProviderDefinedTypeAdapter {
+
+ @Override
+ public String typeName() { return "mygraph:Color"; }
+
+ @Override
+ public Class targetClass() { return java.awt.Color.class; }
+
+ @Override
+ public Map toProperties(java.awt.Color color) {
+ return Map.of("r", color.getRed(), "g", color.getGreen(),
+ "b", color.getBlue(), "a", color.getAlpha());
+ }
+
+ @Override
+ public java.awt.Color fromProperties(Map fields) {
+ return new java.awt.Color((int) fields.get("r"), (int) fields.get("g"),
+ (int) fields.get("b"), (int) fields.get("a"));
+ }
+}
+----
+
+[[round-trip-support]]
+==== Round-Trip Support (Dehydration and Hydration)
+
+There is an important distinction between *dehydration* (serializing a type for sending) and *hydration* (deserializing
+a received PDT back into a language-native type).
+
+*Dehydration* is handled automatically for `@ProviderDefined`-annotated classes and adapter-registered types. When a
+user passes an annotated object into a Gremlin traversal or script, TinkerPop converts it to a PDT on the wire
+without any extra configuration.
+
+*Hydration* — reconstructing an incoming PDT back into the original typed object — requires the driver to know which
+class corresponds to a given PDT name. Without this mapping, the driver will return a generic `ProviderDefinedType`
+object. To enable automatic round-trip hydration, providers must expose a pre-configured `ProviderDefinedTypeRegistry`
+to users. How that registry is populated differs by language:
+
+===== Java
+
+Register annotated classes explicitly with the registry. `register(Class>...)` inspects the `@ProviderDefined`
+annotation to derive the type name and field mapping automatically:
+
+[source,java]
+----
+ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.build();
+registry.register(Point.class, Address.class, Person.class);
+----
+
+Adapter types (for classes you don't own) are discovered automatically via `ServiceLoader` when using
+`ProviderDefinedTypeRegistry.build()`. Register them by adding a file at:
+
+----
+META-INF/services/org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter
+----
+
+with the fully qualified class name of each adapter:
+
+----
+com.example.graph.ColorAdapter
+----
+
+===== Python
+
+Hydration is fully automatic for `@provider_defined`-decorated classes. The decorator registers the class at
+definition time (import time), so any annotated type round-trips without any additional setup.
+
+===== .NET
+
+`[ProviderDefined]`-annotated types are discovered automatically. Calling `ProviderDefinedTypeRegistry.Build()`
+scans all loaded assemblies for `[ProviderDefined]`-annotated types and registers them for hydration. No extra
+configuration is needed — providers simply annotate their types and users call `Build()` to create the registry.
+
+===== JavaScript
+
+Register hydration adapters explicitly on a `ProviderDefinedTypeRegistry` instance, then pass it to the connection:
+
+[source,javascript]
+----
+const registry = new ProviderDefinedTypeRegistry();
+registry.register('mygraph:Point', {
+ serialize: (obj) => ({ x: obj.x, y: obj.y }),
+ deserialize: (props) => new Point(props.x, props.y)
+}, Point);
+----
+
+===== Go
+
+Register types on a `PDTRegistry` instance. Go supports either reflection-based registration (using `pdt` struct
+tags) or explicit function registration:
+
+[source,go]
+----
+registry := NewPDTRegistry()
+registry.RegisterType("mygraph:Point", reflect.TypeOf(Point{}))
+----
+
+===== Provider Factory Pattern
+
+Regardless of language, the recommended pattern is for providers to expose a factory method that returns a
+pre-configured `ProviderDefinedTypeRegistry`. This shields end users from needing to know which types exist or how
+the registry is populated:
+
+[source,java]
+----
+// In the provider's client library
+public class MyGraphTypeRegistry {
+ public static ProviderDefinedTypeRegistry build() {
+ ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.build(); // discovers ServiceLoader adapters
+ registry.register(Point.class, Address.class, Person.class); // registers annotated types
+ return registry;
+ }
+}
+----
+
+End users configure their connection in one line:
+
+[source,java]
+----
+DriverRemoteConnection conn = DriverRemoteConnection.using(cluster);
+conn.setPdtRegistry(MyGraphTypeRegistry.build());
+GraphTraversalSource g = traversal().with(conn);
+----
+
+With this in place, `Point` objects round-trip transparently in both directions — the annotation handles outbound
+serialization and the registry handles inbound reconstruction.
+
+For driver users consuming PDTs, see the <> reference documentation for
+each language driver.
+
[[gremlin-plugins]]
== Gremlin Plugins
diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc
index 521a7017bf7..2583e93ebf1 100644
--- a/docs/src/reference/gremlin-variants.asciidoc
+++ b/docs/src/reference/gremlin-variants.asciidoc
@@ -270,6 +270,7 @@ can be passed to the `NewClient` or `NewDriverRemoteConnection` functions as con
More details can be found in provider docs
link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_graph_driver_provider_requirements[here].|true
|RequestInterceptors |Functions that modify HTTP requests before sending. Used for authentication and custom headers. |empty
+|PDTRegistry |A `*PDTRegistry` for hydrating and dehydrating <>. |`nil`
|=========================================================
[[gremlin-go-strategies]]
@@ -615,6 +616,48 @@ go run basic_gremlin.go
go run modern_traversals.go
----
+[[gremlin-go-pdt]]
+=== Provider Defined Types
+
+Provider Defined Types (PDTs) allow graph providers to expose custom types through the driver. PDT values arrive as
+`*ProviderDefinedType` structs containing a `Name` and `Properties` map without any configuration.
+Consult your graph provider's documentation for the list of PDTs they support.
+
+[source,go]
+----
+results, err := g.V().Has("location").Values("location").ToList()
+pdt := results[0].GetInterface().(*gremlingo.ProviderDefinedType)
+fmt.Println(pdt.Name) // "x:Point"
+fmt.Println(pdt.Properties) // map[x:1.0 y:2.0]
+----
+
+Working with raw `*ProviderDefinedType` values is always available. Using a `PDTRegistry` is an optional
+convenience that automates conversion between PDT values and application types on both the request and response paths.
+
+Using a `PDTRegistry` for hydration and dehydration:
+
+[source,go]
+----
+registry := gremlingo.NewPDTRegistry()
+registry.RegisterFuncsWithType("x:Point", reflect.TypeOf(Point{}),
+ // hydrate: convert incoming PDT properties map to a Go type
+ func(props map[string]interface{}) (interface{}, error) {
+ return &Point{X: props["x"].(float64), Y: props["y"].(float64)}, nil
+ },
+ // dehydrate: convert a Go type to a PDT properties map for sending
+ func(obj interface{}) (map[string]interface{}, error) {
+ p := obj.(*Point)
+ return map[string]interface{}{"x": p.X, "y": p.Y}, nil
+ },
+)
+
+remote, _ := gremlingo.NewDriverRemoteConnection("http://localhost:8182/gremlin",
+ func(settings *gremlingo.DriverRemoteConnectionSettings) {
+ settings.PDTRegistry = registry
+ })
+g := gremlingo.Traversal_().With(remote)
+----
+
[[gremlin-groovy]]
== Gremlin-Groovy
@@ -1504,6 +1547,72 @@ java -cp target/run-examples-shaded.jar examples.BasicGremlin
java -cp target/run-examples-shaded.jar examples.ModernTraversals
----
+[[gremlin-java-pdt]]
+=== Provider Defined Types
+
+Provider Defined Types (PDTs) allow graph providers to expose custom types through the driver. PDT values arrive as
+`ProviderDefinedType` objects containing a name and properties map without any configuration.
+Consult your graph provider's documentation for the list of PDTs they support.
+
+Receiving a raw PDT:
+
+[source,java]
+----
+ProviderDefinedType pdt = (ProviderDefinedType) g.V().has("location").values("location").next();
+String typeName = pdt.getName(); // "x:Point"
+Map props = pdt.getProperties(); // {x: 1.0, y: 2.0}
+----
+
+Working with raw `ProviderDefinedType` objects is always available. The following two approaches are optional
+conveniences that automate conversion between PDT values and application types on both the request and response paths.
+
+Using a `ProviderDefinedTypeRegistry` for hydration and dehydration:
+----
+public class PointAdapter implements ProviderDefinedTypeAdapter {
+ @Override public String typeName() { return "x:Point"; }
+ @Override public Class targetClass() { return Point.class; }
+ @Override public Map toProperties(Point p) { return Map.of("x", p.getX(), "y", p.getY()); }
+ @Override public Point fromProperties(Map m) { return new Point((double) m.get("x"), (double) m.get("y")); }
+}
+----
+
+Register adapters via ServiceLoader by adding the fully qualified class name to
+`META-INF/services/org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter`. The driver discovers
+adapters on the classpath and automatically hydrates/dehydrates.
+
+For simpler cases where you own the type, annotate it directly to avoid writing an adapter:
+
+Annotation-based conversion with `@ProviderDefined`:
+
+[source,java]
+----
+// includedFields: only serialize the listed fields
+@ProviderDefined(name = "x:Point", includedFields = {"x", "y"})
+public class Point {
+ private final double x;
+ private final double y;
+ private final String internalId; // not serialized
+ // constructor, getters...
+}
+
+// excludedFields: serialize all fields except those listed
+@ProviderDefined(name = "x:Timestamped", excludedFields = {"createdAt"})
+public class Timestamped {
+ private final String value;
+ private final long createdAt; // not serialized
+ // constructor, getters...
+}
+
+// send: Point is automatically dehydrated
+g.inject(new Point(1.0, 2.0, "internal")).iterate();
+
+// receive: PDT is automatically hydrated back to Point
+Point p = (Point) g.V().has("location").values("location").next();
+----
+
+Classes annotated with `@ProviderDefined` are automatically dehydrated when passed as traversal arguments and
+hydrated on deserialization without explicit registry configuration.
+
[[gremlin-javascript]]
== Gremlin-JavaScript
@@ -1682,6 +1791,7 @@ can be passed in the constructor of a new `Client` or `DriverRemoteConnection` :
|options.writer |GraphBinaryWriter |The writer to use for serializing requests. |GraphBinaryWriter
|options.enableUserAgentOnConnect |Boolean |Determines if a user agent header will be sent with requests. |true
|options.agent |Agent |A custom `node:http` or `node:https` Agent for connection pooling or proxy configuration. |undefined
+|options.pdtRegistry |ProviderDefinedTypeRegistry |A registry for hydrating and dehydrating <>. |undefined
|=========================================================
[[gremlin-javascript-logging]]
@@ -2101,6 +2211,42 @@ node basic-gremlin.js
node modern-traversals.js
----
+[[gremlin-javascript-pdt]]
+=== Provider Defined Types
+
+Provider Defined Types (PDTs) allow graph providers to expose custom types through the driver. PDT values arrive as
+`ProviderDefinedType` objects containing a `name` and `properties` map without any configuration.
+Consult your graph provider's documentation for the list of PDTs they support.
+
+Receiving a raw PDT:
+
+[source,javascript]
+----
+const results = await g.V().has('location').values('location').toList();
+const pdt = results[0];
+console.log(pdt.name); // "x:Point"
+console.log(pdt.properties); // { x: 1.0, y: 2.0 }
+----
+
+Working with raw `ProviderDefinedType` objects is always available. Using a `ProviderDefinedTypeRegistry` is an
+optional convenience that automates conversion between PDT values and application types on both the request and
+response paths.
+
+[source,javascript]
+----
+const { ProviderDefinedTypeRegistry } = require('gremlin');
+
+const registry = new ProviderDefinedTypeRegistry();
+registry.register('x:Point', {
+ serialize: (point) => ({ x: point.x, y: point.y }),
+ deserialize: (props) => new Point(props.x, props.y)
+}, Point);
+
+const g = traversal().with_(new DriverRemoteConnection('http://localhost:8182/gremlin', {
+ pdtRegistry: registry
+}));
+----
+
anchor:gremlin-DotNet[]
[[gremlin-dotnet]]
== Gremlin.Net
@@ -2241,6 +2387,7 @@ The following options can be passed to the `GremlinClient` constructor:
|connectionSettings |The `ConnectionSettings` for the HTTP connection. |default `ConnectionSettings`
|loggerFactory |An `ILoggerFactory` for logging. |`NullLoggerFactory`
|interceptors |A list of `Func` that modify HTTP requests before sending. |_none_
+|pdtRegistry |A `ProviderDefinedTypeRegistry` for hydrating and dehydrating <>. |`null`
|=========================================================
[[gremlin-dotnet-logging]]
@@ -2548,6 +2695,82 @@ dotnet run --project Connections
dotnet run --project ModernTraversals
----
+[[gremlin-dotnet-pdt]]
+=== Provider Defined Types
+
+Provider Defined Types (PDTs) allow graph providers to expose custom types through the driver. PDT values arrive as
+`ProviderDefinedType` objects containing a `Name` and `Properties` dictionary without any configuration.
+Consult your graph provider's documentation for the list of PDTs they support.
+
+Receiving a raw PDT:
+
+[source,csharp]
+----
+var pdt = (ProviderDefinedType) g.V().Has("location").Values
*/
public
Builder add(final Class
type, final TypeSerializer
serializer) {
- if (serializer.getDataType() == DataType.CUSTOM) {
- throw new IllegalArgumentException("DataType can not be CUSTOM, use addCustomType() method instead");
- }
-
if (serializer.getDataType() == DataType.UNSPECIFIED_NULL) {
throw new IllegalArgumentException("Adding a serializer for a UNSPECIFIED_NULL is not permitted");
}
- if (serializer instanceof CustomTypeSerializer) {
- throw new IllegalArgumentException(
- "CustomTypeSerializer implementations are reserved for customtypes");
- }
-
- list.add(new RegistryEntry<>(type, serializer));
- return this;
- }
-
- /**
- * Adds a serializer for a custom type.
- */
- public
Builder addCustomType(final Class
type, final CustomTypeSerializer
serializer) {
- if (serializer == null) {
- throw new NullPointerException("serializer can not be null");
- }
-
- if (serializer.getDataType() != DataType.CUSTOM) {
- throw new IllegalArgumentException("Custom serializer must use CUSTOM data type");
- }
-
- if (serializer.getTypeName() == null) {
- throw new NullPointerException("serializer custom type name can not be null");
- }
-
list.add(new RegistryEntry<>(type, serializer));
return this;
}
@@ -185,21 +157,6 @@ public Builder withFallbackResolver(final Function, TypeSerializer>>
return this;
}
- /**
- * Add {@link CustomTypeSerializer} by way of an {@link IoRegistry}. The registry entries should be bound to
- * {@link GraphBinaryIo}.
- */
- public Builder addRegistry(final IoRegistry registry) {
- if (null == registry) throw new IllegalArgumentException("The registry cannot be null");
-
- final List> classSerializers = registry.find(GraphBinaryIo.class, CustomTypeSerializer.class);
- for (Pair cs : classSerializers) {
- addCustomType(cs.getValue0(), cs.getValue1());
- }
-
- return this;
- }
-
/**
* Creates a new {@link TypeSerializerRegistry} instance based on the serializers added.
*/
@@ -225,15 +182,6 @@ public DataType getDataType() {
return typeSerializer.getDataType();
}
- public String getCustomTypeName() {
- if (getDataType() != DataType.CUSTOM) {
- return null;
- }
-
- final CustomTypeSerializer customTypeSerializer = (CustomTypeSerializer) typeSerializer;
- return customTypeSerializer.getTypeName();
- }
-
public TypeSerializer
getTypeSerializer() {
private final Map, TypeSerializer>> serializers = new HashMap<>();
private final Map, TypeSerializer>> serializersByInterface = new LinkedHashMap<>();
private final Map> serializersByDataType = new HashMap<>();
- private final Map serializersByCustomTypeName = new HashMap<>();
private Function, TypeSerializer>> fallbackResolver;
/**
@@ -291,9 +238,7 @@ private void put(final RegistryEntry entry) {
serializersByInterface.put(type, serializer);
}
- if (serializer.getDataType() == DataType.CUSTOM) {
- serializersByCustomTypeName.put(entry.getCustomTypeName(), (CustomTypeSerializer) serializer);
- } else if (serializer.getDataType() != null) {
+ if (serializer.getDataType() != null) {
serializersByDataType.put(serializer.getDataType(), serializer);
}
}
@@ -333,7 +278,15 @@ public
TypeSerializer
getSerializer(final Class
type) throws IOExce
serializer = fallbackResolver.apply(type);
}
- validateInstance(serializer, type.getTypeName());
+ if (null == serializer && type.isAnnotationPresent(ProviderDefined.class)) {
+ serializer = serializersByDataType.get(DataType.COMPOSITE_PDT);
+ }
+
+ if (serializer == null) {
+ throw new IOException(String.format(
+ "Serializer not found for type %s. If this is a provider-defined type, annotate the class with @ProviderDefined.",
+ type.getTypeName()));
+ }
// Store the lookup match to avoid looking it up in the future
serializersByImplementation.put(type, serializer);
@@ -342,26 +295,9 @@ public
TypeSerializer
getSerializer(final Class
type) throws IOExce
}
public
TypeSerializer
getSerializer(final DataType dataType) throws IOException {
- if (dataType == DataType.CUSTOM) {
- throw new IllegalArgumentException("Custom type serializers can not be retrieved using this method");
- }
-
return validateInstance(serializersByDataType.get(dataType), dataType.toString());
}
- /**
- * Gets the serializer for a given custom type name.
- */
- public
CustomTypeSerializer
getSerializerForCustomType(final String name) throws IOException {
- final CustomTypeSerializer serializer = serializersByCustomTypeName.get(name);
-
- if (serializer == null) {
- throw new IOException(String.format("Serializer for custom type '%s' not found", name));
- }
-
- return serializer;
- }
-
private static TypeSerializer validateInstance(final TypeSerializer serializer, final String typeName) throws IOException {
if (serializer == null) {
throw new IOException(String.format("Serializer for type %s not found", typeName));
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/ProviderDefinedTypeSerializer.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/ProviderDefinedTypeSerializer.java
new file mode 100644
index 00000000000..d45b4cf2a94
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/ProviderDefinedTypeSerializer.java
@@ -0,0 +1,56 @@
+/*
+ * 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 org.apache.tinkerpop.gremlin.structure.io.binary.types;
+
+import org.apache.tinkerpop.gremlin.structure.io.Buffer;
+import org.apache.tinkerpop.gremlin.structure.io.binary.DataType;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryReader;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryWriter;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+
+import java.io.IOException;
+import java.util.Map;
+
+public class ProviderDefinedTypeSerializer extends SimpleTypeSerializer {
+
+ public ProviderDefinedTypeSerializer() {
+ super(DataType.COMPOSITE_PDT);
+ }
+
+ @Override
+ protected ProviderDefinedType readValue(final Buffer buffer, final GraphBinaryReader context) throws IOException {
+ final String name = context.read(buffer);
+ if (name == null || name.isEmpty())
+ throw new IOException("ProviderDefinedType name cannot be null or empty");
+ final Map, ?> properties = context.read(buffer);
+ for (final Object key : properties.keySet()) {
+ if (!(key instanceof String))
+ throw new IOException("ProviderDefinedType properties map must have String keys, found: " + key.getClass().getName());
+ }
+ @SuppressWarnings("unchecked")
+ final Map typedProperties = (Map) (Map, ?>) properties;
+ return new ProviderDefinedType(name, typedProperties);
+ }
+
+ @Override
+ protected void writeValue(final ProviderDefinedType value, final Buffer buffer, final GraphBinaryWriter context) throws IOException {
+ context.write(value.getName(), buffer);
+ context.write(value.getProperties(), buffer);
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java
index 3da5c4e367f..6877b67c692 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java
@@ -21,6 +21,7 @@
import org.apache.tinkerpop.gremlin.structure.Graph;
import org.apache.tinkerpop.gremlin.structure.io.IoRegistry;
import org.apache.tinkerpop.gremlin.structure.io.Mapper;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
import org.apache.tinkerpop.shaded.jackson.annotation.JsonTypeInfo;
import org.apache.tinkerpop.shaded.jackson.core.JsonFactory;
import org.apache.tinkerpop.shaded.jackson.core.JsonGenerator;
@@ -71,6 +72,7 @@ public class GraphSONMapper implements Mapper {
private final GraphSONVersion version;
private final TypeInfo typeInfo;
private final StreamReadConstraints streamReadConstraints;
+ private final ProviderDefinedTypeRegistry pdtRegistry;
private GraphSONMapper(final Builder builder) {
this.customModules = builder.customModules;
@@ -79,6 +81,7 @@ private GraphSONMapper(final Builder builder) {
this.version = builder.version;
this.streamReadConstraints = builder.streamReadConstraintsBuilder.build();
this.typeInfo = builder.typeInfo;
+ this.pdtRegistry = builder.pdtRegistry;
}
@Override
@@ -89,6 +92,9 @@ public ObjectMapper createMapper() {
}
final GraphSONModule graphSONModule = version.getBuilder().create(normalize, typeInfo);
+ if (pdtRegistry != null && graphSONModule instanceof GraphSONModule.GraphSONModuleV4) {
+ ((GraphSONModule.GraphSONModuleV4) graphSONModule).setPdtRegistry(pdtRegistry);
+ }
om.registerModule(graphSONModule);
customModules.forEach(om::registerModule);
@@ -186,6 +192,7 @@ public static Builder build(final GraphSONMapper mapper) {
builder.loadCustomModules = mapper.loadCustomSerializers;
builder.normalize = mapper.normalize;
builder.typeInfo = mapper.typeInfo;
+ builder.pdtRegistry = mapper.pdtRegistry;
builder.streamReadConstraintsBuilder = mapper.streamReadConstraints.rebuild();
return builder;
@@ -217,6 +224,7 @@ public static class Builder implements Mapper.Builder {
private StreamReadConstraints.Builder streamReadConstraintsBuilder = StreamReadConstraints.builder()
.maxNumberLength(DEFAULT_MAX_NUMBER_LENGTH);
private TypeInfo typeInfo = null;
+ private ProviderDefinedTypeRegistry pdtRegistry = null;
private Builder() {
}
@@ -301,6 +309,15 @@ public Builder typeInfo(final TypeInfo typeInfo) {
return this;
}
+ /**
+ * Set the {@link ProviderDefinedTypeRegistry} to enable automatic hydration of
+ * {@link org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType} values during deserialization.
+ */
+ public Builder pdtRegistry(final ProviderDefinedTypeRegistry pdtRegistry) {
+ this.pdtRegistry = pdtRegistry;
+ return this;
+ }
+
public Builder maxNumberLength(final int maxNumLength) {
this.streamReadConstraintsBuilder.maxNumberLength(maxNumLength);
return this;
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
index 6e98642d7ba..5cdb5cb1c32 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
@@ -81,6 +81,8 @@
import org.apache.tinkerpop.gremlin.structure.T;
import org.apache.tinkerpop.gremlin.structure.Vertex;
import org.apache.tinkerpop.gremlin.structure.VertexProperty;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
import org.apache.tinkerpop.gremlin.structure.util.star.DirectionalStarGraph;
import org.apache.tinkerpop.gremlin.structure.util.star.StarGraphGraphSONSerializerV1;
import org.apache.tinkerpop.gremlin.structure.util.star.StarGraphGraphSONSerializerV2;
@@ -156,12 +158,15 @@ static final class GraphSONModuleV4 extends GraphSONModule {
put(Path.class, "Path");
put(VertexProperty.class, "VertexProperty");
put(Tree.class, "Tree");
+ put(ProviderDefinedType.class, "CompositePdt");
Stream.of(
Direction.class,
Merge.class,
T.class).forEach(e -> put(e, e.getSimpleName()));
}});
+ private final PdtGraphSONSerializersV4.ProviderDefinedTypeJacksonDeserializer pdtDeserializer;
+
/**
* Constructs a new object.
*/
@@ -178,6 +183,7 @@ protected GraphSONModuleV4(final boolean normalize, final TypeInfo typeInfo) {
addSerializer(Path.class, new GraphSONSerializersV4.PathJacksonSerializer());
addSerializer(DirectionalStarGraph.class, new StarGraphGraphSONSerializerV4(normalize));
addSerializer(Tree.class, new GraphSONSerializersV4.TreeJacksonSerializer());
+ addSerializer(ProviderDefinedType.class, new PdtGraphSONSerializersV4.ProviderDefinedTypeJacksonSerializer());
// java.util - use the standard jackson serializers for collections when types aren't embedded
if (typeInfo != TypeInfo.NO_TYPES) {
@@ -208,6 +214,8 @@ protected GraphSONModuleV4(final boolean normalize, final TypeInfo typeInfo) {
addDeserializer(Path.class, new GraphSONSerializersV4.PathJacksonDeserializer());
addDeserializer(VertexProperty.class, new GraphSONSerializersV4.VertexPropertyJacksonDeserializer());
addDeserializer(Tree.class, new GraphSONSerializersV4.TreeJacksonDeserializer());
+ pdtDeserializer = new PdtGraphSONSerializersV4.ProviderDefinedTypeJacksonDeserializer();
+ addDeserializer(ProviderDefinedType.class, pdtDeserializer);
// java.util - use the standard jackson serializers for collections when types aren't embedded
if (typeInfo != TypeInfo.NO_TYPES) {
@@ -232,6 +240,10 @@ public static Builder build() {
return new Builder();
}
+ void setPdtRegistry(final ProviderDefinedTypeRegistry registry) {
+ pdtDeserializer.setRegistry(registry);
+ }
+
@Override
public Map getTypeDefinitions() {
return TYPE_DEFINITIONS;
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4.java
new file mode 100644
index 00000000000..fb4cd848a36
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4.java
@@ -0,0 +1,116 @@
+/*
+ * 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 org.apache.tinkerpop.gremlin.structure.io.graphson;
+
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
+import org.apache.tinkerpop.shaded.jackson.core.JsonGenerator;
+import org.apache.tinkerpop.shaded.jackson.core.JsonParser;
+import org.apache.tinkerpop.shaded.jackson.core.JsonToken;
+import org.apache.tinkerpop.shaded.jackson.databind.DeserializationContext;
+import org.apache.tinkerpop.shaded.jackson.databind.SerializerProvider;
+import org.apache.tinkerpop.shaded.jackson.databind.deser.std.StdDeserializer;
+import org.apache.tinkerpop.shaded.jackson.databind.ser.std.StdScalarSerializer;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * GraphSON V4 serializers for {@link ProviderDefinedType}.
+ */
+final class PdtGraphSONSerializersV4 {
+
+ private PdtGraphSONSerializersV4() {
+ }
+
+ final static class ProviderDefinedTypeJacksonSerializer extends StdScalarSerializer {
+
+ public ProviderDefinedTypeJacksonSerializer() {
+ super(ProviderDefinedType.class);
+ }
+
+ @Override
+ public void serialize(final ProviderDefinedType pdt, final JsonGenerator jsonGenerator,
+ final SerializerProvider serializerProvider) throws IOException {
+ jsonGenerator.writeStartObject();
+ jsonGenerator.writeStringField("type", pdt.getName());
+ jsonGenerator.writeFieldName("fields");
+ jsonGenerator.writeStartObject();
+ for (final Map.Entry entry : pdt.getProperties().entrySet()) {
+ jsonGenerator.writeFieldName(entry.getKey());
+ jsonGenerator.writeObject(entry.getValue());
+ }
+ jsonGenerator.writeEndObject();
+ jsonGenerator.writeEndObject();
+ }
+ }
+
+ static class ProviderDefinedTypeJacksonDeserializer extends StdDeserializer {
+
+ private ProviderDefinedTypeRegistry registry;
+
+ public ProviderDefinedTypeJacksonDeserializer() {
+ super(ProviderDefinedType.class);
+ }
+
+ void setRegistry(final ProviderDefinedTypeRegistry registry) {
+ this.registry = registry;
+ }
+
+ @Override
+ public ProviderDefinedType deserialize(final JsonParser jsonParser, final DeserializationContext deserializationContext)
+ throws IOException {
+ String typeName = null;
+ Map fields = new LinkedHashMap<>();
+
+ while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
+ final String fieldName = jsonParser.getCurrentName();
+ if ("type".equals(fieldName)) {
+ jsonParser.nextToken();
+ typeName = jsonParser.getText();
+ } else if ("fields".equals(fieldName)) {
+ jsonParser.nextToken();
+ while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
+ final String key = jsonParser.getCurrentName();
+ jsonParser.nextToken();
+ final Object value = deserializationContext.readValue(jsonParser, Object.class);
+ fields.put(key, value);
+ }
+ }
+ }
+
+ final ProviderDefinedType pdt = new ProviderDefinedType(typeName, fields);
+ if (registry != null) {
+ final Object hydrated = registry.hydrate(pdt);
+ if (hydrated instanceof ProviderDefinedType)
+ return (ProviderDefinedType) hydrated;
+ // Store hydrated object back as a single-entry PDT so the typed result is accessible.
+ // This preserves the return type contract while enabling hydration.
+ return pdt.withHydrated(hydrated);
+ }
+ return pdt;
+ }
+
+ @Override
+ public boolean isCachable() {
+ return true;
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefined.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefined.java
new file mode 100644
index 00000000000..51611923760
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefined.java
@@ -0,0 +1,37 @@
+/*
+ * 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 org.apache.tinkerpop.gremlin.structure.io.pdt;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a class as a provider-defined type for serialization via {@link ProviderDefinedType}.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface ProviderDefined {
+ String name() default "";
+ String[] includedFields() default {};
+ String[] excludedFields() default {};
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedType.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedType.java
new file mode 100644
index 00000000000..3a67209db91
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedType.java
@@ -0,0 +1,179 @@
+/*
+ * 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 org.apache.tinkerpop.gremlin.structure.io.pdt;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * An immutable representation of a provider-defined type consisting of a name and a map of properties.
+ */
+public final class ProviderDefinedType {
+
+ private static final ConcurrentHashMap, FieldCache> FIELD_CACHE = new ConcurrentHashMap<>();
+
+ private final String name;
+ private final Map properties;
+ private Object hydrated;
+
+ public ProviderDefinedType(final String name, final Map properties) {
+ if (name == null || name.isEmpty())
+ throw new IllegalArgumentException("name cannot be null or empty");
+ if (properties == null)
+ throw new IllegalArgumentException("properties cannot be null");
+ this.name = name;
+ this.properties = Collections.unmodifiableMap(new LinkedHashMap<>(properties));
+ }
+
+ /**
+ * Creates a {@code ProviderDefinedType} from an object annotated with {@link ProviderDefined}.
+ */
+ public static ProviderDefinedType from(final Object obj) {
+ if (obj == null)
+ throw new IllegalArgumentException("obj cannot be null");
+
+ final Class> clazz = obj.getClass();
+ final FieldCache cache = FIELD_CACHE.computeIfAbsent(clazz, ProviderDefinedType::buildCache);
+
+ final Map props = new LinkedHashMap<>();
+ for (final Field field : cache.fields) {
+ try {
+ props.put(field.getName(), field.get(obj));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to read field '" + field.getName() + "' from " + clazz.getName(), e);
+ }
+ }
+
+ return new ProviderDefinedType(cache.name, props);
+ }
+
+ /**
+ * Package-private access to the resolved type name for a {@link ProviderDefined}-annotated class.
+ * Validates the annotation and field configuration via the shared field cache.
+ */
+ static String resolveTypeName(final Class> clazz) {
+ return FIELD_CACHE.computeIfAbsent(clazz, ProviderDefinedType::buildCache).name;
+ }
+
+ /**
+ * Package-private access to the resolved serializable fields for a {@link ProviderDefined}-annotated class.
+ */
+ static Field[] resolveFields(final Class> clazz) {
+ return FIELD_CACHE.computeIfAbsent(clazz, ProviderDefinedType::buildCache).fields;
+ }
+
+ private static FieldCache buildCache(final Class> clazz) {
+ final ProviderDefined annotation = clazz.getAnnotation(ProviderDefined.class);
+ if (annotation == null)
+ throw new IllegalArgumentException(clazz.getName() + " is not annotated with @ProviderDefined");
+
+ final String typeName = annotation.name().isEmpty() ? clazz.getSimpleName() : annotation.name();
+ final String[] included = annotation.includedFields();
+ final String[] excluded = annotation.excludedFields();
+
+ if (included.length > 0 && excluded.length > 0)
+ throw new IllegalArgumentException("@ProviderDefined cannot specify both includedFields and excludedFields");
+
+ final Set includedSet = included.length > 0 ? new HashSet<>(Arrays.asList(included)) : null;
+ final Set excludedSet = excluded.length > 0 ? new HashSet<>(Arrays.asList(excluded)) : Collections.emptySet();
+
+ final Field[] allFields = getAllFields(clazz).toArray(new Field[0]);
+ final Field[] filtered = Arrays.stream(allFields)
+ .filter(f -> !f.isSynthetic())
+ .filter(f -> {
+ if (includedSet != null) return includedSet.contains(f.getName());
+ return !excludedSet.contains(f.getName());
+ })
+ .peek(f -> f.setAccessible(true))
+ .toArray(Field[]::new);
+
+ return new FieldCache(typeName, filtered);
+ }
+
+ private static List getAllFields(final Class> clazz) {
+ final List fields = new ArrayList<>();
+ Class> current = clazz;
+ while (current != null && current != Object.class) {
+ fields.addAll(Arrays.asList(current.getDeclaredFields()));
+ current = current.getSuperclass();
+ }
+ return fields;
+ }
+
+ private static class FieldCache {
+ final String name;
+ final Field[] fields;
+
+ FieldCache(final String name, final Field[] fields) {
+ this.name = name;
+ this.fields = fields;
+ }
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Map getProperties() {
+ return properties;
+ }
+
+ /**
+ * Returns a copy of this PDT with the hydrated object attached.
+ */
+ public ProviderDefinedType withHydrated(final Object hydrated) {
+ final ProviderDefinedType copy = new ProviderDefinedType(this.name, this.properties);
+ copy.hydrated = hydrated;
+ return copy;
+ }
+
+ /**
+ * Returns the hydrated object if this PDT was hydrated by a {@link ProviderDefinedTypeRegistry}, or {@code null}.
+ */
+ public Object getHydrated() {
+ return hydrated;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) return true;
+ if (!(o instanceof ProviderDefinedType)) return false;
+ final ProviderDefinedType that = (ProviderDefinedType) o;
+ return name.equals(that.name) && properties.equals(that.properties);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, properties);
+ }
+
+ @Override
+ public String toString() {
+ return "pdt[" + name + "]" + properties;
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeAdapter.java
similarity index 68%
rename from gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
rename to gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeAdapter.java
index a54580cab0c..86880a0584e 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeAdapter.java
@@ -16,17 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.tinkerpop.gremlin.structure.io.binary.types;
+package org.apache.tinkerpop.gremlin.structure.io.pdt;
-import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializer;
+import java.util.Map;
/**
- * Represents a serializer for a custom (provider specific) serializer.
- * @param
+ * Adapter for converting between a typed object and a {@link ProviderDefinedType} property map.
*/
-public interface CustomTypeSerializer extends TypeSerializer {
- /**
- * Gets the custom type name.
- */
- String getTypeName();
+public interface ProviderDefinedTypeAdapter {
+ String typeName();
+ Class targetClass();
+ Map toProperties(T obj);
+ T fromProperties(Map properties);
}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistry.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistry.java
new file mode 100644
index 00000000000..10d63bc92f9
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistry.java
@@ -0,0 +1,214 @@
+/*
+ * 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 org.apache.tinkerpop.gremlin.structure.io.pdt;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Registry for {@link ProviderDefinedTypeAdapter} instances that supports hydration of
+ * {@link ProviderDefinedType} values into typed objects.
+ */
+public final class ProviderDefinedTypeRegistry {
+
+ private static final Logger logger = LoggerFactory.getLogger(ProviderDefinedTypeRegistry.class);
+
+ private final Map> adaptersByName = new ConcurrentHashMap<>();
+ private final Map, ProviderDefinedTypeAdapter>> adaptersByClass = new ConcurrentHashMap<>();
+
+ private ProviderDefinedTypeRegistry() {}
+
+ /**
+ * Creates a registry populated via {@link ServiceLoader} discovery.
+ */
+ @SuppressWarnings("rawtypes")
+ public static ProviderDefinedTypeRegistry build() {
+ final ProviderDefinedTypeRegistry registry = new ProviderDefinedTypeRegistry();
+ for (final ProviderDefinedTypeAdapter adapter : ServiceLoader.load(ProviderDefinedTypeAdapter.class)) {
+ registry.register(adapter);
+ }
+ return registry;
+ }
+
+ /**
+ * Creates an empty registry for manual registration.
+ */
+ public static ProviderDefinedTypeRegistry empty() {
+ return new ProviderDefinedTypeRegistry();
+ }
+
+ public void register(final ProviderDefinedTypeAdapter> adapter) {
+ adaptersByName.put(adapter.typeName(), adapter);
+ adaptersByClass.put(adapter.targetClass(), adapter);
+ }
+
+ /**
+ * Registers one or more classes annotated with {@link ProviderDefined} for automatic round-trip hydration.
+ * An adapter is synthesized from the annotation metadata using reflection.
+ *
+ * @throws IllegalArgumentException if any class is not annotated with {@link ProviderDefined}
+ */
+ public void register(final Class>... annotatedClasses) {
+ for (final Class> clazz : annotatedClasses) {
+ register(AnnotatedTypeAdapter.of(clazz));
+ }
+ }
+
+ public Optional> getAdapterByName(final String name) {
+ return Optional.ofNullable(adaptersByName.get(name));
+ }
+
+ public Optional> getAdapterByClass(final Class> clazz) {
+ return Optional.ofNullable(adaptersByClass.get(clazz));
+ }
+
+ /**
+ * Attempts to hydrate a {@link ProviderDefinedType} into a typed object using a registered adapter.
+ * Recursively hydrates nested PDT values in the properties map (including those inside Lists, Sets,
+ * and Maps) before calling the adapter.
+ * Returns the original PDT if no adapter is found or if the adapter throws an exception.
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public Object hydrate(final ProviderDefinedType pdt) {
+ final ProviderDefinedTypeAdapter adapter = adaptersByName.get(pdt.getName());
+ if (adapter == null)
+ return pdt;
+
+ // recursively hydrate nested PDTs in the properties map
+ final Map hydrated = new LinkedHashMap<>();
+ for (final Map.Entry entry : pdt.getProperties().entrySet()) {
+ hydrated.put(entry.getKey(), hydrateValue(entry.getValue()));
+ }
+
+ try {
+ return adapter.fromProperties(hydrated);
+ } catch (final Exception e) {
+ logger.warn("Failed to hydrate ProviderDefinedType '{}', returning raw PDT: {}",
+ pdt.getName(), e.getMessage());
+ return pdt;
+ }
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private Object hydrateValue(final Object value) {
+ if (value instanceof ProviderDefinedType)
+ return hydrate((ProviderDefinedType) value);
+ if (value instanceof List) {
+ final List result = new ArrayList<>();
+ for (final Object item : (List>) value)
+ result.add(hydrateValue(item));
+ return result;
+ }
+ if (value instanceof Set) {
+ final Set result = new LinkedHashSet<>();
+ for (final Object item : (Set>) value)
+ result.add(hydrateValue(item));
+ return result;
+ }
+ if (value instanceof Map) {
+ final Map result = new LinkedHashMap<>();
+ for (final Map.Entry, ?> entry : ((Map, ?>) value).entrySet())
+ result.put(entry.getKey(), hydrateValue(entry.getValue()));
+ return result;
+ }
+ return value;
+ }
+
+ /**
+ * A reflective adapter synthesized from a {@link ProviderDefined}-annotated class.
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static final class AnnotatedTypeAdapter implements ProviderDefinedTypeAdapter {
+ private final String typeName;
+ private final Class targetClass;
+ private final Field[] fields;
+
+ private AnnotatedTypeAdapter(final String typeName, final Class targetClass, final Field[] fields) {
+ this.typeName = typeName;
+ this.targetClass = targetClass;
+ this.fields = fields;
+ }
+
+ static AnnotatedTypeAdapter of(final Class clazz) {
+ if (!clazz.isAnnotationPresent(ProviderDefined.class))
+ throw new IllegalArgumentException(clazz.getName() + " is not annotated with @ProviderDefined");
+ try {
+ clazz.getDeclaredConstructor();
+ } catch (final NoSuchMethodException e) {
+ throw new IllegalArgumentException(clazz.getName() +
+ " must have a no-arg constructor for annotation-based hydration");
+ }
+ // reuse ProviderDefinedType's validated, cached field/name resolution
+ return new AnnotatedTypeAdapter<>(
+ ProviderDefinedType.resolveTypeName(clazz),
+ clazz,
+ ProviderDefinedType.resolveFields(clazz));
+ }
+
+ @Override public String typeName() { return typeName; }
+ @Override public Class targetClass() { return targetClass; }
+
+ @Override
+ public Map toProperties(final T obj) {
+ return ProviderDefinedType.from(obj).getProperties();
+ }
+
+ @Override
+ public T fromProperties(final Map properties) {
+ try {
+ final java.lang.reflect.Constructor ctor = targetClass.getDeclaredConstructor();
+ ctor.setAccessible(true);
+ final T obj = ctor.newInstance();
+ for (final Field field : fields) {
+ final Object value = properties.get(field.getName());
+ if (value != null)
+ field.set(obj, coerce(value, field.getType()));
+ }
+ return obj;
+ } catch (final ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to hydrate " + targetClass.getName() + ": " + e, e);
+ }
+ }
+
+ private static Object coerce(final Object value, final Class> targetType) {
+ if (targetType.isInstance(value)) return value;
+ if (value instanceof Number) {
+ final Number n = (Number) value;
+ if (targetType == int.class || targetType == Integer.class) return n.intValue();
+ if (targetType == long.class || targetType == Long.class) return n.longValue();
+ if (targetType == double.class || targetType == Double.class) return n.doubleValue();
+ if (targetType == float.class || targetType == Float.class) return n.floatValue();
+ if (targetType == short.class || targetType == Short.class) return n.shortValue();
+ if (targetType == byte.class || targetType == Byte.class) return n.byteValue();
+ }
+ return value;
+ }
+ }
+}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/grammar/GeneralLiteralVisitorTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/grammar/GeneralLiteralVisitorTest.java
index 71f4acce192..953565679bf 100644
--- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/grammar/GeneralLiteralVisitorTest.java
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/grammar/GeneralLiteralVisitorTest.java
@@ -24,6 +24,7 @@
import org.apache.tinkerpop.gremlin.structure.Direction;
import org.apache.tinkerpop.gremlin.structure.T;
import org.apache.tinkerpop.gremlin.structure.VertexProperty;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Test;
@@ -1049,4 +1050,44 @@ public void shouldFailOnInvalidBase64() {
}
}
}
+
+ public static class ValidPdtLiteralTest {
+ @Test
+ public void shouldParsePdtLiteral() {
+ final GremlinLexer lexer = new GremlinLexer(CharStreams.fromString("PDT(\"MyType\",[\"x\":1,\"y\":\"hello\"])"));
+ final GremlinParser parser = new GremlinParser(new CommonTokenStream(lexer));
+ final GremlinParser.PdtLiteralContext ctx = parser.pdtLiteral();
+ final Object result = new GenericLiteralVisitor(new GremlinAntlrToJava()).visitPdtLiteral(ctx);
+ assertThat(result, instanceOf(ProviderDefinedType.class));
+ final ProviderDefinedType pdt = (ProviderDefinedType) result;
+ assertEquals("MyType", pdt.getName());
+ assertEquals(1, pdt.getProperties().get("x"));
+ assertEquals("hello", pdt.getProperties().get("y"));
+ }
+
+ @Test
+ public void shouldParsePdtLiteralWithEmptyMap() {
+ final GremlinLexer lexer = new GremlinLexer(CharStreams.fromString("PDT(\"Empty\",[:])"));
+ final GremlinParser parser = new GremlinParser(new CommonTokenStream(lexer));
+ final GremlinParser.PdtLiteralContext ctx = parser.pdtLiteral();
+ final Object result = new GenericLiteralVisitor(new GremlinAntlrToJava()).visitPdtLiteral(ctx);
+ assertThat(result, instanceOf(ProviderDefinedType.class));
+ final ProviderDefinedType pdt = (ProviderDefinedType) result;
+ assertEquals("Empty", pdt.getName());
+ assertTrue(pdt.getProperties().isEmpty());
+ }
+
+ @Test
+ public void shouldRejectNonStringMapKey() {
+ final GremlinLexer lexer = new GremlinLexer(CharStreams.fromString("PDT(\"Bad\",[1:\"value\"])"));
+ final GremlinParser parser = new GremlinParser(new CommonTokenStream(lexer));
+ final GremlinParser.PdtLiteralContext ctx = parser.pdtLiteral();
+ try {
+ new GenericLiteralVisitor(new GremlinAntlrToJava()).visitPdtLiteral(ctx);
+ fail("Expected IllegalArgumentException for non-String map key");
+ } catch (final IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("PDT properties map must have String keys, found: java.lang.Integer"));
+ }
+ }
+ }
}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/translator/GremlinTranslatorTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/translator/GremlinTranslatorTest.java
index 8e13823450b..42ee0aaaab4 100644
--- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/translator/GremlinTranslatorTest.java
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/translator/GremlinTranslatorTest.java
@@ -1480,6 +1480,15 @@ public static Collection data() {
"g.inject(ByteBuffer.wrap(Base64.getDecoder().decode(\"AQID\")))",
"g.inject(Buffer.from(\"AQID\",'base64'))",
"g.inject(base64.b64decode('AQID'))"},
+ {"g.inject(PDT(\"Point\",[\"x\":1,\"y\":2]))",
+ null,
+ "g.inject(providerdefinedtype0)",
+ "g.Inject(new ProviderDefinedType(\"Point\", new Dictionary {{ \"x\", 1 }, { \"y\", 2 }}))",
+ "g.Inject(&gremlingo.ProviderDefinedType{Name: \"Point\", Properties: map[interface{}]interface{}{\"x\": 1, \"y\": 2 }})",
+ "g.inject(new ProviderDefinedType(\"Point\", [\"x\":1, \"y\":2]))",
+ "g.inject(new ProviderDefinedType(\"Point\", new LinkedHashMap() {{ put(\"x\", 1); put(\"y\", 2); }}))",
+ "g.inject(new ProviderDefinedType(\"Point\", new Map([[\"x\", 1], [\"y\", 2]])))",
+ "g.inject(ProviderDefinedType('Point', { 'x': 1, 'y': 2 }))"},
});
}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLangTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLangTest.java
index 57649b697d2..e181393de76 100644
--- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLangTest.java
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLangTest.java
@@ -28,6 +28,7 @@
import org.apache.tinkerpop.gremlin.structure.VertexProperty;
import org.apache.tinkerpop.gremlin.structure.util.detached.DetachedVertex;
import org.apache.tinkerpop.gremlin.structure.util.empty.EmptyGraph;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
import org.apache.tinkerpop.gremlin.structure.util.reference.ReferenceEdge;
import org.apache.tinkerpop.gremlin.structure.util.reference.ReferenceVertex;
import org.apache.tinkerpop.gremlin.util.DatetimeHelper;
@@ -140,6 +141,19 @@ public static Iterable generateTestParameters() {
{g.inject(new byte[]{1, 2, 3}), "g.inject(Binary(\"AQID\"))"},
{g.inject(new byte[]{}), "g.inject(Binary(\"\"))"},
{g.inject(new byte[]{0}), "g.inject(Binary(\"AA==\"))"},
+ // PDT
+ {g.inject(new ProviderDefinedType("MyType", asMap("x", 1, "y", "hello"))),
+ "g.inject(PDT(\"MyType\",[\"x\":1,\"y\":\"hello\"]))"},
+ {g.inject(new ProviderDefinedType("Empty", Collections.emptyMap())),
+ "g.inject(PDT(\"Empty\",[:]))"},
+ // PDT with special characters in name
+ {g.inject(new ProviderDefinedType("say\"hello\"", asMap("v", 1))),
+ "g.inject(PDT(\"say\\\"hello\\\"\",[\"v\":1]))"},
+ {g.inject(new ProviderDefinedType("back\\slash", asMap("v", 1))),
+ "g.inject(PDT(\"back\\\\slash\",[\"v\":1]))"},
+ // Nested PDT
+ {g.inject(new ProviderDefinedType("Outer", asMap("inner", new ProviderDefinedType("Inner", asMap("v", 1))))),
+ "g.inject(PDT(\"Outer\",[\"inner\":PDT(\"Inner\",[\"v\":1])]))"},
});
}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryWriterPdtTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryWriterPdtTest.java
new file mode 100644
index 00000000000..863e2559da9
--- /dev/null
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryWriterPdtTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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 org.apache.tinkerpop.gremlin.structure.io.binary;
+
+import org.apache.tinkerpop.gremlin.structure.io.Buffer;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class GraphBinaryWriterPdtTest {
+
+ private static final GraphBinaryReader reader = new GraphBinaryReader();
+ private static final GraphBinaryWriter writer = new GraphBinaryWriter();
+
+ @ProviderDefined
+ static class TestPoint {
+ int x;
+ int y;
+
+ TestPoint(int x, int y) {
+ this.x = x;
+ this.y = y;
+ }
+ }
+
+ static class UnannotatedType {
+ int value = 1;
+ }
+
+ @Test
+ public void shouldAutoConvertAnnotatedObjectToPdt() throws IOException {
+ final Buffer buffer = HeapBuffer.allocate(1024);
+ writer.write(new TestPoint(1, 2), buffer);
+ buffer.readerIndex(0);
+
+ final ProviderDefinedType result = reader.read(buffer);
+ assertEquals("TestPoint", result.getName());
+ assertEquals(1, result.getProperties().get("x"));
+ assertEquals(2, result.getProperties().get("y"));
+ }
+
+ @Test
+ public void shouldThrowActionableMessageForUnannotatedType() {
+ final Buffer buffer = HeapBuffer.allocate(1024);
+ final IOException ex = assertThrows(IOException.class, () -> writer.write(new UnannotatedType(), buffer));
+ assertTrue(ex.getMessage().contains("@ProviderDefined"));
+ assertTrue(ex.getMessage().contains("UnannotatedType"));
+ }
+
+ @Test
+ public void shouldNotDoubleWrapProviderDefinedType() throws IOException {
+ final Map props = new LinkedHashMap<>();
+ props.put("x", 1);
+ props.put("y", 2);
+ final ProviderDefinedType pdt = new ProviderDefinedType("TestPoint", props);
+
+ final Buffer buffer = HeapBuffer.allocate(1024);
+ writer.write(pdt, buffer);
+ buffer.readerIndex(0);
+
+ final ProviderDefinedType result = reader.read(buffer);
+ assertEquals(pdt, result);
+ }
+}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/binary/HeapBuffer.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/binary/HeapBuffer.java
new file mode 100644
index 00000000000..e7f090cd1b2
--- /dev/null
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/binary/HeapBuffer.java
@@ -0,0 +1,230 @@
+/*
+ * 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 org.apache.tinkerpop.gremlin.structure.io.binary;
+
+import org.apache.tinkerpop.gremlin.structure.io.Buffer;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+
+/**
+ * A simple heap-based {@link Buffer} implementation for unit testing in gremlin-core.
+ */
+public class HeapBuffer implements Buffer {
+ private byte[] data;
+ private int readerIndex;
+ private int writerIndex;
+ private int markedWriterIndex;
+
+ public HeapBuffer(final int initialCapacity) {
+ this.data = new byte[initialCapacity];
+ }
+
+ public static HeapBuffer allocate(final int capacity) {
+ return new HeapBuffer(capacity);
+ }
+
+ private void ensureCapacity(final int needed) {
+ if (writerIndex + needed > data.length) {
+ data = Arrays.copyOf(data, Math.max(data.length * 2, writerIndex + needed));
+ }
+ }
+
+ @Override public int readableBytes() { return writerIndex - readerIndex; }
+ @Override public int readerIndex() { return readerIndex; }
+ @Override public Buffer readerIndex(final int readerIndex) { this.readerIndex = readerIndex; return this; }
+ @Override public int writerIndex() { return writerIndex; }
+ @Override public Buffer writerIndex(final int writerIndex) { this.writerIndex = writerIndex; return this; }
+ @Override public Buffer markWriterIndex() { this.markedWriterIndex = writerIndex; return this; }
+ @Override public Buffer resetWriterIndex() { this.writerIndex = markedWriterIndex; return this; }
+ @Override public int capacity() { return data.length; }
+ @Override public boolean isDirect() { return false; }
+
+ @Override
+ public boolean readBoolean() { return readByte() != 0; }
+
+ @Override
+ public byte readByte() { return data[readerIndex++]; }
+
+ @Override
+ public short readShort() {
+ short v = (short) ((data[readerIndex] & 0xFF) << 8 | (data[readerIndex + 1] & 0xFF));
+ readerIndex += 2;
+ return v;
+ }
+
+ @Override
+ public int readInt() {
+ int v = (data[readerIndex] & 0xFF) << 24 | (data[readerIndex + 1] & 0xFF) << 16 |
+ (data[readerIndex + 2] & 0xFF) << 8 | (data[readerIndex + 3] & 0xFF);
+ readerIndex += 4;
+ return v;
+ }
+
+ @Override
+ public long readLong() {
+ long v = ((long)(data[readerIndex] & 0xFF) << 56) | ((long)(data[readerIndex+1] & 0xFF) << 48) |
+ ((long)(data[readerIndex+2] & 0xFF) << 40) | ((long)(data[readerIndex+3] & 0xFF) << 32) |
+ ((long)(data[readerIndex+4] & 0xFF) << 24) | ((long)(data[readerIndex+5] & 0xFF) << 16) |
+ ((long)(data[readerIndex+6] & 0xFF) << 8) | ((long)(data[readerIndex+7] & 0xFF));
+ readerIndex += 8;
+ return v;
+ }
+
+ @Override
+ public float readFloat() { return Float.intBitsToFloat(readInt()); }
+
+ @Override
+ public double readDouble() { return Double.longBitsToDouble(readLong()); }
+
+ @Override
+ public Buffer readBytes(final byte[] destination) {
+ System.arraycopy(data, readerIndex, destination, 0, destination.length);
+ readerIndex += destination.length;
+ return this;
+ }
+
+ @Override
+ public Buffer readBytes(final byte[] destination, final int dstIndex, final int length) {
+ System.arraycopy(data, readerIndex, destination, dstIndex, length);
+ readerIndex += length;
+ return this;
+ }
+
+ @Override
+ public Buffer readBytes(final ByteBuffer dst) {
+ int len = dst.remaining();
+ dst.put(data, readerIndex, len);
+ readerIndex += len;
+ return this;
+ }
+
+ @Override
+ public Buffer readBytes(final OutputStream out, final int length) throws IOException {
+ out.write(data, readerIndex, length);
+ readerIndex += length;
+ return this;
+ }
+
+ @Override
+ public Buffer writeBoolean(final boolean value) { return writeByte(value ? 1 : 0); }
+
+ @Override
+ public Buffer writeByte(final int value) {
+ ensureCapacity(1);
+ data[writerIndex++] = (byte) value;
+ return this;
+ }
+
+ @Override
+ public Buffer writeShort(final int value) {
+ ensureCapacity(2);
+ data[writerIndex++] = (byte) (value >>> 8);
+ data[writerIndex++] = (byte) value;
+ return this;
+ }
+
+ @Override
+ public Buffer writeInt(final int value) {
+ ensureCapacity(4);
+ data[writerIndex++] = (byte) (value >>> 24);
+ data[writerIndex++] = (byte) (value >>> 16);
+ data[writerIndex++] = (byte) (value >>> 8);
+ data[writerIndex++] = (byte) value;
+ return this;
+ }
+
+ @Override
+ public Buffer writeLong(final long value) {
+ ensureCapacity(8);
+ data[writerIndex++] = (byte) (value >>> 56);
+ data[writerIndex++] = (byte) (value >>> 48);
+ data[writerIndex++] = (byte) (value >>> 40);
+ data[writerIndex++] = (byte) (value >>> 32);
+ data[writerIndex++] = (byte) (value >>> 24);
+ data[writerIndex++] = (byte) (value >>> 16);
+ data[writerIndex++] = (byte) (value >>> 8);
+ data[writerIndex++] = (byte) value;
+ return this;
+ }
+
+ @Override
+ public Buffer writeFloat(final float value) { return writeInt(Float.floatToIntBits(value)); }
+
+ @Override
+ public Buffer writeDouble(final double value) { return writeLong(Double.doubleToLongBits(value)); }
+
+ @Override
+ public Buffer writeBytes(final byte[] src) {
+ ensureCapacity(src.length);
+ System.arraycopy(src, 0, data, writerIndex, src.length);
+ writerIndex += src.length;
+ return this;
+ }
+
+ @Override
+ public Buffer writeBytes(final ByteBuffer src) {
+ int len = src.remaining();
+ ensureCapacity(len);
+ src.get(data, writerIndex, len);
+ writerIndex += len;
+ return this;
+ }
+
+ @Override
+ public Buffer writeBytes(final byte[] src, final int srcIndex, final int length) {
+ ensureCapacity(length);
+ System.arraycopy(src, srcIndex, data, writerIndex, length);
+ writerIndex += length;
+ return this;
+ }
+
+ @Override public boolean release() { return true; }
+ @Override public Buffer retain() { return this; }
+ @Override public int referenceCount() { return 1; }
+ @Override public int nioBufferCount() { return 1; }
+
+ @Override
+ public ByteBuffer[] nioBuffers() {
+ return new ByteBuffer[] { nioBuffer() };
+ }
+
+ @Override
+ public ByteBuffer[] nioBuffers(final int index, final int length) {
+ return new ByteBuffer[] { nioBuffer(index, length) };
+ }
+
+ @Override
+ public ByteBuffer nioBuffer() {
+ return ByteBuffer.wrap(data, readerIndex, readableBytes()).slice();
+ }
+
+ @Override
+ public ByteBuffer nioBuffer(final int index, final int length) {
+ return ByteBuffer.wrap(data, index, length).slice();
+ }
+
+ @Override
+ public Buffer getBytes(final int index, final byte[] dst) {
+ System.arraycopy(data, index, dst, 0, dst.length);
+ return this;
+ }
+}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/ProviderDefinedTypeSerializerTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/ProviderDefinedTypeSerializerTest.java
new file mode 100644
index 00000000000..76f373609d9
--- /dev/null
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/ProviderDefinedTypeSerializerTest.java
@@ -0,0 +1,229 @@
+/*
+ * 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 org.apache.tinkerpop.gremlin.structure.io.binary.types;
+
+import org.apache.tinkerpop.gremlin.structure.io.Buffer;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryReader;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryWriter;
+import org.apache.tinkerpop.gremlin.structure.io.binary.HeapBuffer;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class ProviderDefinedTypeSerializerTest {
+
+ private static final GraphBinaryReader reader = new GraphBinaryReader();
+ private static final GraphBinaryWriter writer = new GraphBinaryWriter();
+
+ private Buffer writeAndRead(final Object value) throws IOException {
+ final Buffer buffer = HeapBuffer.allocate(1024);
+ writer.write(value, buffer);
+ buffer.readerIndex(0);
+ return buffer;
+ }
+
+ @Test
+ public void shouldRoundTripSimplePdt() throws IOException {
+ final Map props = new LinkedHashMap<>();
+ props.put("x", 1);
+ props.put("y", "hello");
+ final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Point", props);
+
+ final Buffer buffer = writeAndRead(pdt);
+ final ProviderDefinedType result = reader.read(buffer);
+
+ assertEquals(pdt, result);
+ }
+
+ @Test
+ public void shouldRoundTripPdtWithNullPropertyValue() throws IOException {
+ final Map props = new LinkedHashMap<>();
+ props.put("name", "test");
+ props.put("value", null);
+ final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Nullable", props);
+
+ final Buffer buffer = writeAndRead(pdt);
+ final ProviderDefinedType result = reader.read(buffer);
+
+ assertEquals(pdt, result);
+ }
+
+ @Test
+ public void shouldRoundTripNestedPdt() throws IOException {
+ final Map innerProps = new LinkedHashMap<>();
+ innerProps.put("street", "123 Main");
+ final ProviderDefinedType inner = new ProviderDefinedType("com.example.Address", innerProps);
+
+ final Map outerProps = new LinkedHashMap<>();
+ outerProps.put("name", "Alice");
+ outerProps.put("address", inner);
+ final ProviderDefinedType outer = new ProviderDefinedType("com.example.Person", outerProps);
+
+ final Buffer buffer = writeAndRead(outer);
+ final ProviderDefinedType result = reader.read(buffer);
+
+ assertEquals(outer, result);
+ }
+
+ @Test
+ public void shouldRoundTripPdtInsideList() throws IOException {
+ final Map props = Collections.singletonMap("id", 42);
+ final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Item", props);
+ final List list = Arrays.asList(pdt, "other");
+
+ final Buffer buffer = writeAndRead(list);
+ final List result = reader.read(buffer);
+
+ assertEquals(list, result);
+ }
+
+ @Test
+ public void shouldRoundTripPdtInsideMapValue() throws IOException {
+ final Map props = Collections.singletonMap("val", 99L);
+ final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Wrapper", props);
+ final Map map = new HashMap<>();
+ map.put("key", pdt);
+
+ final Buffer buffer = writeAndRead(map);
+ final Map result = reader.read(buffer);
+
+ assertEquals(map, result);
+ }
+
+ @Test(expected = IOException.class)
+ public void shouldThrowOnEmptyNameDuringRead() throws IOException {
+ // Manually write a PDT with empty name to trigger the validation
+ final Buffer buffer = HeapBuffer.allocate(256);
+ // Write type code for COMPOSITE_PDT
+ buffer.writeByte(0xF0);
+ // Write value_flag = 0 (not null)
+ buffer.writeByte(0x00);
+ // Write a fully-qualified empty string: type_code STRING (0x03), value_flag 0, length 0
+ buffer.writeByte(0x03);
+ buffer.writeByte(0x00);
+ buffer.writeInt(0);
+ // Write a fully-qualified map: type_code MAP (0x0A), value_flag 0, length 0
+ buffer.writeByte(0x0A);
+ buffer.writeByte(0x00);
+ buffer.writeInt(0);
+
+ buffer.readerIndex(0);
+ reader.read(buffer);
+ }
+
+ @Test(expected = IOException.class)
+ public void shouldThrowOnNonStringKeyInPropertiesMap() throws IOException {
+ final Buffer buffer = HeapBuffer.allocate(256);
+ // Write type code for COMPOSITE_PDT (0xF0), value_flag 0
+ buffer.writeByte(0xF0);
+ buffer.writeByte(0x00);
+ // Write fully-qualified String name: type STRING (0x03), flag 0, length 4, "test"
+ buffer.writeByte(0x03);
+ buffer.writeByte(0x00);
+ buffer.writeInt(4);
+ buffer.writeBytes(new byte[]{'t', 'e', 's', 't'});
+ // Write fully-qualified Map: type MAP (0x0A), flag 0, length 1 (one entry)
+ buffer.writeByte(0x0A);
+ buffer.writeByte(0x00);
+ buffer.writeInt(1);
+ // Key: INT type (0x01), flag 0, value 42
+ buffer.writeByte(0x01);
+ buffer.writeByte(0x00);
+ buffer.writeInt(42);
+ // Value: STRING type (0x03), flag 0, length 3, "val"
+ buffer.writeByte(0x03);
+ buffer.writeByte(0x00);
+ buffer.writeInt(3);
+ buffer.writeBytes(new byte[]{'v', 'a', 'l'});
+
+ buffer.readerIndex(0);
+ reader.read(buffer);
+ }
+
+ @Test
+ public void shouldHandleNullPdt() throws IOException {
+ final Buffer buffer = HeapBuffer.allocate(64);
+ writer.write(null, buffer);
+ buffer.readerIndex(0);
+ final Object result = reader.read(buffer);
+ assertNull(result);
+ }
+
+ @Test
+ public void shouldAutoHydrateWhenRegistryConfigured() throws IOException {
+ final ProviderDefinedTypeRegistry pdtRegistry = ProviderDefinedTypeRegistry.empty();
+ pdtRegistry.register(new ProviderDefinedTypeAdapter