diff --git a/.dockerignore b/.dockerignore
index 4e566f8f812..bdfad38b343 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-*
!gremlin-tools/gremlin-socket-server/target/gremlin-socket-server-*
!gremlin-tools/gremlin-socket-server/target/libs
diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index 837007596fe..4971f792173 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -29,6 +29,7 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima
* Fixed `ByteBuf` leak in `GraphBinaryMessageSerializerV4` when serialization throws an `IOException`.
* 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.
* Added explicit transaction support to all non-Java GLVs (gremlin-python, gremlin-go, gremlin-javascript, gremlin-dotnet).
* Changed default transaction close behavior from commit to rollback across all GLVs to align with embedded graph defaults.
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 f1c1bc64466..45c4cd8a91e 100644
--- a/docs/src/dev/provider/index.asciidoc
+++ b/docs/src/dev/provider/index.asciidoc
@@ -1358,6 +1358,225 @@ 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 must not be null or empty — all GLVs reject an
+empty name when a type is defined or a `ProviderDefinedType` is constructed. 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.
+
+The serialized field set becomes a map whose keys are always strings. In statically typed GLVs (Java, .NET,
+Go, and TypeScript) this is enforced by the type system; in Python the keys are validated at runtime and a
+`TypeError` is raised for any non-string key.
+
+==== 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 toFields(java.awt.Color color) {
+ return Map.of("r", color.getRed(), "g", color.getGreen(),
+ "b", color.getBlue(), "a", color.getAlpha());
+ }
+
+ @Override
+ public java.awt.Color fromFields(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.create();
+registry.register(Point.class, Address.class, Person.class);
+----
+
+Adapter types (for classes you don't own) are discovered automatically via `ServiceLoader` when using
+`ProviderDefinedTypeRegistry.create()`. 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
+----
+
+NOTE: Annotation-based hydration reflectively constructs and populates the annotated class
+(`Constructor.setAccessible(true)` and `Field.set()` in `AnnotatedTypeAdapter`). Under JPMS, the provider's module
+must therefore `opens` its PDT package to `org.apache.tinkerpop.gremlin.core` (e.g.
+`opens com.example.graph.types to org.apache.tinkerpop.gremlin.core;` in `module-info.java`, or the equivalent
+`--add-opens` JVM flag), otherwise the JVM throws `InaccessibleObjectException` at hydration time. Providers using a
+`ProviderDefinedTypeAdapter` with explicit `fromFields` logic do not rely on reflection and are unaffected.
+
+===== 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.Create()`
+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 `Create()` 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: (fields) => new Point(fields.x, fields.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 create() {
+ ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.create(); // 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.create());
+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 27924fa3600..7a8405b2924 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]]
@@ -655,6 +656,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 `Fields` 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.Fields) // 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 fields map to a Go type
+ func(fields map[string]interface{}) (interface{}, error) {
+ return &Point{X: fields["x"].(float64), Y: fields["y"].(float64)}, nil
+ },
+ // dehydrate: convert a Go type to a PDT fields 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
@@ -1544,6 +1587,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 fields 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 fields = pdt.getFields(); // {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 toFields(Point p) { return Map.of("x", p.getX(), "y", p.getY()); }
+ @Override public Point fromFields(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
@@ -1722,6 +1831,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]]
@@ -2171,6 +2281,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 `fields` 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.fields); // { 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: (fields) => new Point(fields.x, fields.y)
+}, Point);
+
+const g = traversal().with_(new DriverRemoteConnection('http://localhost:8182/gremlin', {
+ pdtRegistry: registry
+}));
+----
+
anchor:gremlin-DotNet[]
[[gremlin-dotnet]]
== Gremlin.Net
@@ -2311,6 +2457,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]]
@@ -2652,6 +2799,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 `Fields` 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..a833d2d46ae
--- /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, ?> fields = context.read(buffer);
+ for (final Object key : fields.keySet()) {
+ if (!(key instanceof String))
+ throw new IOException("ProviderDefinedType fields map must have String keys, found: " + key.getClass().getName());
+ }
+ @SuppressWarnings("unchecked")
+ final Map typedFields = (Map) (Map, ?>) fields;
+ return new ProviderDefinedType(name, typedFields);
+ }
+
+ @Override
+ protected void writeValue(final ProviderDefinedType value, final Buffer buffer, final GraphBinaryWriter context) throws IOException {
+ context.write(value.getName(), buffer);
+ context.write(value.getFields(), 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..de116f54aab
--- /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.getFields().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..165a32967e2
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedType.java
@@ -0,0 +1,186 @@
+/*
+ * 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 fields.
+ */
+public final class ProviderDefinedType {
+
+ private static final ConcurrentHashMap, FieldCache> FIELD_CACHE = new ConcurrentHashMap<>();
+
+ private final String name;
+ private final Map fields;
+ private Object hydrated;
+
+ public ProviderDefinedType(final String name, final Map fields) {
+ if (name == null || name.isEmpty())
+ throw new IllegalArgumentException("name cannot be null or empty");
+ if (fields == null)
+ throw new IllegalArgumentException("fields cannot be null");
+ this.name = name;
+ this.fields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
+ }
+
+ /**
+ * 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 fields = new LinkedHashMap<>();
+ for (final Field field : cache.fields) {
+ try {
+ fields.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, fields);
+ }
+
+ /**
+ * 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 getFields() {
+ return fields;
+ }
+
+ /**
+ * 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.fields);
+ 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;
+ }
+
+ /**
+ * Equality is based solely on {@code name} and {@code fields} (the serialized wire form).
+ * The {@code hydrated} field is intentionally excluded — it is a transient, derived view
+ * cached by the deserializer via {@link #withHydrated(Object)} and is not part of the
+ * type's logical identity.
+ */
+ @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) && fields.equals(that.fields);
+ }
+
+ /** See {@link #equals(Object)} for rationale on field inclusion. */
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, fields);
+ }
+
+ @Override
+ public String toString() {
+ return "pdt[" + name + "]" + fields;
+ }
+}
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 69%
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..701fba0d497 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} field map.
*/
-public interface CustomTypeSerializer extends TypeSerializer {
- /**
- * Gets the custom type name.
- */
- String getTypeName();
+public interface ProviderDefinedTypeAdapter {
+ String typeName();
+ Class targetClass();
+ Map toFields(T obj);
+ T fromFields(Map fields);
}
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..dec03e53835
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistry.java
@@ -0,0 +1,224 @@
+/*
+ * 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 create() {
+ 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 fields map (including those inside Lists, Sets,
+ * and Maps) regardless of whether the outer type itself has a registered adapter — so a registered
+ * inner type is hydrated even when nested inside an unregistered outer PDT.
+ * Returns the original PDT (with nested values hydrated) if no adapter is found for the outer type,
+ * or if the adapter throws an exception.
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public Object hydrate(final ProviderDefinedType pdt) {
+ // recursively hydrate nested PDTs in the fields map, whether or not the outer has an adapter
+ boolean nestedChanged = false;
+ final Map hydrated = new LinkedHashMap<>();
+ for (final Map.Entry entry : pdt.getFields().entrySet()) {
+ final Object original = entry.getValue();
+ final Object value = hydrateValue(original);
+ if (value != original) nestedChanged = true;
+ hydrated.put(entry.getKey(), value);
+ }
+
+ final ProviderDefinedTypeAdapter adapter = adaptersByName.get(pdt.getName());
+ if (adapter == null) {
+ // No adapter for the outer type: return it raw, but with any registered nested types hydrated.
+ // Preserve identity when nothing nested was hydrated.
+ return nestedChanged ? new ProviderDefinedType(pdt.getName(), hydrated) : pdt;
+ }
+
+ try {
+ return adapter.fromFields(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 toFields(final T obj) {
+ return ProviderDefinedType.from(obj).getFields();
+ }
+
+ @Override
+ public T fromFields(final Map fieldMap) {
+ 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 = fieldMap.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..3adc7b63bb4 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.getFields().get("x"));
+ assertEquals("hello", pdt.getFields().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.getFields().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 fields 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..43c785a8455 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\", Fields: 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..3a62dcd9ec1 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,10 @@
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.ProviderDefined;
+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.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 +144,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])]))"},
});
}
@@ -436,6 +453,80 @@ public void shouldSerializeUnicodeKey() {
}
}
+ public static class AdapterPrecedenceTests {
+
+ /**
+ * A type annotated with @ProviderDefined that also has an explicit adapter registered.
+ * The adapter should take precedence over the annotation.
+ */
+ @ProviderDefined(name = "AnnotationName")
+ private static class DualType {
+ public int value = 42;
+
+ private DualType() {}
+ DualType(final int value) { this.value = value; }
+ }
+
+ @Test
+ public void shouldUseAdapterOverAnnotation() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ registry.register(new ProviderDefinedTypeAdapter() {
+ @Override public String typeName() { return "AdapterName"; }
+ @Override public Class targetClass() { return DualType.class; }
+ @Override public Map toFields(final DualType obj) {
+ return Collections.singletonMap("v", obj.value);
+ }
+ @Override public DualType fromFields(final Map fields) {
+ return new DualType((int) fields.get("v"));
+ }
+ });
+
+ final GraphTraversalSource g2 = traversal().with(EmptyGraph.instance());
+ g2.getGremlinLang().setPdtRegistry(registry);
+ final String gremlin = g2.inject(new DualType(7)).asAdmin().getGremlinLang().getGremlin();
+
+ // adapter produces "AdapterName" with key "v", not annotation's "AnnotationName" with key "value"
+ assertTrue(gremlin, gremlin.contains("PDT(\"AdapterName\""));
+ assertTrue(gremlin, gremlin.contains("\"v\":7"));
+ assertFalse(gremlin, gremlin.contains("AnnotationName"));
+ }
+
+ private static class TestPoint {
+ final int x;
+ final int y;
+ TestPoint(int x, int y) { this.x = x; this.y = y; }
+ }
+
+ @Test
+ public void shouldDehydrateRegisteredTypeNestedInsideUnregisteredOuterPdt() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ registry.register(new ProviderDefinedTypeAdapter() {
+ @Override public String typeName() { return "Point"; }
+ @Override public Class targetClass() { return TestPoint.class; }
+ @Override public Map toFields(final TestPoint obj) {
+ final Map m = new HashMap<>();
+ m.put("x", obj.x);
+ m.put("y", obj.y);
+ return m;
+ }
+ @Override public TestPoint fromFields(final Map fields) {
+ return new TestPoint((int) fields.get("x"), (int) fields.get("y"));
+ }
+ });
+
+ // Outer is a raw ProviderDefinedType whose "location" field value is a registered domain object
+ final Map outerFields = new LinkedHashMap<>();
+ outerFields.put("location", new TestPoint(3, 7));
+ final ProviderDefinedType outerPdt = new ProviderDefinedType("Container", outerFields);
+
+ final GraphTraversalSource g2 = traversal().with(EmptyGraph.instance());
+ g2.getGremlinLang().setPdtRegistry(registry);
+ final String gremlin = g2.inject(outerPdt).asAdmin().getGremlinLang().getGremlin();
+
+ assertEquals("g.inject(PDT(\"Container\",[\"location\":PDT(\"Point\",[\"x\":3,\"y\":7])]))", gremlin);
+ }
+ }
+
public static class UnsupportedTypeTests {
/**
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4Test.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4Test.java
new file mode 100644
index 00000000000..20bf1f72386
--- /dev/null
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4Test.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.graphson;
+
+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.apache.tinkerpop.shaded.jackson.databind.JsonNode;
+import org.apache.tinkerpop.shaded.jackson.databind.ObjectMapper;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for {@link PdtGraphSONSerializersV4}.
+ */
+public class PdtGraphSONSerializersV4Test extends AbstractGraphSONTest {
+
+ private ObjectMapper mapper;
+ private ObjectMapper plainMapper;
+
+ @Before
+ public void setUp() {
+ mapper = GraphSONMapper.build()
+ .version(GraphSONVersion.V4_0)
+ .addCustomModule(GraphSONXModuleV4.build())
+ .typeInfo(TypeInfo.PARTIAL_TYPES)
+ .create().createMapper();
+ plainMapper = new ObjectMapper();
+ }
+
+ @Test
+ public void shouldSerializeSimplePdt() throws Exception {
+ final Map pdtFields = new LinkedHashMap<>();
+ pdtFields.put("x", 1);
+ pdtFields.put("y", 2);
+ final ProviderDefinedType pdt = new ProviderDefinedType("Point", pdtFields);
+
+ final String json = mapper.writeValueAsString(pdt);
+ final JsonNode node = plainMapper.readTree(json);
+
+ assertEquals("g:CompositePdt", node.get("@type").asText());
+ final JsonNode value = node.get("@value");
+ assertEquals("Point", value.get("type").asText());
+
+ final JsonNode fields = value.get("fields");
+ assertEquals("g:Int32", fields.get("x").get("@type").asText());
+ assertEquals(1, fields.get("x").get("@value").asInt());
+ assertEquals("g:Int32", fields.get("y").get("@type").asText());
+ assertEquals(2, fields.get("y").get("@value").asInt());
+ }
+
+ @Test
+ public void shouldDeserializeValidJson() throws Exception {
+ final String json = "{\"@type\":\"g:CompositePdt\",\"@value\":{\"type\":\"Point\",\"fields\":{\"x\":{\"@type\":\"g:Int32\",\"@value\":1},\"y\":{\"@type\":\"g:Int32\",\"@value\":2}}}}";
+ final ProviderDefinedType pdt = mapper.readValue(json, ProviderDefinedType.class);
+
+ assertEquals("Point", pdt.getName());
+ assertEquals(2, pdt.getFields().size());
+ assertEquals(1, pdt.getFields().get("x"));
+ assertEquals(2, pdt.getFields().get("y"));
+ }
+
+ @Test
+ public void shouldRoundTrip() throws Exception {
+ final Map fields = new LinkedHashMap<>();
+ fields.put("x", 1);
+ fields.put("y", 2);
+ final ProviderDefinedType original = new ProviderDefinedType("Point", fields);
+
+ final ProviderDefinedType result = serializeDeserialize(mapper, original, ProviderDefinedType.class);
+
+ assertEquals(original.getName(), result.getName());
+ assertEquals(original.getFields(), result.getFields());
+ }
+
+ @Test
+ public void shouldSerializeNestedPdt() throws Exception {
+ final Map innerFields = new LinkedHashMap<>();
+ innerFields.put("x", 10);
+ innerFields.put("y", 20);
+ final ProviderDefinedType inner = new ProviderDefinedType("Point", innerFields);
+
+ final Map outerFields = new LinkedHashMap<>();
+ outerFields.put("name", "origin");
+ outerFields.put("location", inner);
+ final ProviderDefinedType outer = new ProviderDefinedType("NamedPoint", outerFields);
+
+ final String json = mapper.writeValueAsString(outer);
+ final JsonNode node = plainMapper.readTree(json);
+
+ assertEquals("g:CompositePdt", node.get("@type").asText());
+ final JsonNode fields = node.get("@value").get("fields");
+ final JsonNode locationNode = fields.get("location");
+ assertEquals("g:CompositePdt", locationNode.get("@type").asText());
+ assertEquals("Point", locationNode.get("@value").get("type").asText());
+
+ // round-trip nested
+ final ProviderDefinedType result = serializeDeserialize(mapper, outer, ProviderDefinedType.class);
+ assertEquals("NamedPoint", result.getName());
+ assertTrue(result.getFields().get("location") instanceof ProviderDefinedType);
+ final ProviderDefinedType nestedResult = (ProviderDefinedType) result.getFields().get("location");
+ assertEquals("Point", nestedResult.getName());
+ assertEquals(10, nestedResult.getFields().get("x"));
+ assertEquals(20, nestedResult.getFields().get("y"));
+ }
+
+ @Test
+ public void shouldHandleNullFieldValues() throws Exception {
+ final Map fields = new LinkedHashMap<>();
+ fields.put("name", "test");
+ fields.put("value", null);
+ final ProviderDefinedType pdt = new ProviderDefinedType("NullableType", fields);
+
+ final ProviderDefinedType result = serializeDeserialize(mapper, pdt, ProviderDefinedType.class);
+
+ assertEquals("NullableType", result.getName());
+ assertEquals("test", result.getFields().get("name"));
+ assertNull(result.getFields().get("value"));
+ assertTrue(result.getFields().containsKey("value"));
+ }
+
+ // --- Hydration tests ---
+
+ static class Point {
+ final int x;
+ final int y;
+ Point(int x, int y) { this.x = x; this.y = y; }
+ }
+
+ static class PointAdapter implements ProviderDefinedTypeAdapter {
+ @Override public String typeName() { return "Point"; }
+ @Override public Class targetClass() { return Point.class; }
+ @Override public Map toFields(Point obj) {
+ final Map m = new HashMap<>();
+ m.put("x", obj.x);
+ m.put("y", obj.y);
+ return m;
+ }
+ @Override public Point fromFields(Map fields) {
+ return new Point((int) fields.get("x"), (int) fields.get("y"));
+ }
+ }
+
+ @Test
+ public void shouldHydrateWhenRegistryConfigured() throws Exception {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ registry.register(new PointAdapter());
+
+ final ObjectMapper hydratingMapper = GraphSONMapper.build()
+ .version(GraphSONVersion.V4_0)
+ .addCustomModule(GraphSONXModuleV4.build())
+ .typeInfo(TypeInfo.PARTIAL_TYPES)
+ .pdtRegistry(registry)
+ .create().createMapper();
+
+ final Map fields = new LinkedHashMap<>();
+ fields.put("x", 3);
+ fields.put("y", 7);
+ final ProviderDefinedType pdt = new ProviderDefinedType("Point", fields);
+
+ final ProviderDefinedType result = serializeDeserialize(hydratingMapper, pdt, ProviderDefinedType.class);
+
+ assertNotNull(result.getHydrated());
+ assertTrue(result.getHydrated() instanceof Point);
+ assertEquals(3, ((Point) result.getHydrated()).x);
+ assertEquals(7, ((Point) result.getHydrated()).y);
+ }
+
+ @Test
+ public void shouldNotHydrateWhenNoRegistryConfigured() throws Exception {
+ final Map fields = new LinkedHashMap<>();
+ fields.put("x", 1);
+ fields.put("y", 2);
+ final ProviderDefinedType pdt = new ProviderDefinedType("Point", fields);
+
+ final ProviderDefinedType result = serializeDeserialize(mapper, pdt, ProviderDefinedType.class);
+
+ assertNull(result.getHydrated());
+ assertEquals("Point", result.getName());
+ assertEquals(1, result.getFields().get("x"));
+ }
+
+ @Test
+ public void shouldReturnRawPdtWhenTypeNotRegistered() throws Exception {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ // No adapter registered for "Unknown"
+
+ final ObjectMapper hydratingMapper = GraphSONMapper.build()
+ .version(GraphSONVersion.V4_0)
+ .addCustomModule(GraphSONXModuleV4.build())
+ .typeInfo(TypeInfo.PARTIAL_TYPES)
+ .pdtRegistry(registry)
+ .create().createMapper();
+
+ final Map fields = new LinkedHashMap<>();
+ fields.put("a", 1);
+ final ProviderDefinedType pdt = new ProviderDefinedType("Unknown", fields);
+
+ final ProviderDefinedType result = serializeDeserialize(hydratingMapper, pdt, ProviderDefinedType.class);
+
+ assertNull(result.getHydrated());
+ assertEquals("Unknown", result.getName());
+ assertEquals(1, result.getFields().get("a"));
+ }
+}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistryTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistryTest.java
new file mode 100644
index 00000000000..b710a2d67c9
--- /dev/null
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistryTest.java
@@ -0,0 +1,416 @@
+/*
+ * 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.junit.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class ProviderDefinedTypeRegistryTest {
+
+ // Simple test type
+ static class Point {
+ final int x;
+ final int y;
+ Point(int x, int y) { this.x = x; this.y = y; }
+ }
+
+ static class PointAdapter implements ProviderDefinedTypeAdapter {
+ @Override public String typeName() { return "Point"; }
+ @Override public Class targetClass() { return Point.class; }
+ @Override public Map toFields(Point obj) {
+ final Map m = new HashMap<>();
+ m.put("x", obj.x);
+ m.put("y", obj.y);
+ return m;
+ }
+ @Override public Point fromFields(Map fields) {
+ return new Point((int) fields.get("x"), (int) fields.get("y"));
+ }
+ }
+
+ // Nested test type
+ static class Line {
+ final Point start;
+ final Point end;
+ Line(Point start, Point end) { this.start = start; this.end = end; }
+ }
+
+ static class LineAdapter implements ProviderDefinedTypeAdapter {
+ @Override public String typeName() { return "Line"; }
+ @Override public Class targetClass() { return Line.class; }
+ @Override public Map toFields(Line obj) {
+ final Map m = new HashMap<>();
+ m.put("start", obj.start);
+ m.put("end", obj.end);
+ return m;
+ }
+ @Override public Line fromFields(Map fields) {
+ return new Line((Point) fields.get("start"), (Point) fields.get("end"));
+ }
+ }
+
+ // Adapter that always throws
+ static class FailingAdapter implements ProviderDefinedTypeAdapter {
+ @Override public String typeName() { return "Failing"; }
+ @Override public Class targetClass() { return Point.class; }
+ @Override public Map toFields(Point obj) { return new HashMap<>(); }
+ @Override public Point fromFields(Map fields) {
+ throw new RuntimeException("intentional failure");
+ }
+ }
+
+ @Test
+ public void shouldHydrateSimplePdt() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ registry.register(new PointAdapter());
+
+ final Map fields = new HashMap<>();
+ fields.put("x", 3);
+ fields.put("y", 7);
+ final ProviderDefinedType pdt = new ProviderDefinedType("Point", fields);
+
+ final Object result = registry.hydrate(pdt);
+ assertTrue(result instanceof Point);
+ assertEquals(3, ((Point) result).x);
+ assertEquals(7, ((Point) result).y);
+ }
+
+ @Test
+ public void shouldReturnRawPdtWhenNoAdapterRegistered() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+
+ final Map fields = new HashMap<>();
+ fields.put("x", 1);
+ final ProviderDefinedType pdt = new ProviderDefinedType("Unknown", fields);
+
+ final Object result = registry.hydrate(pdt);
+ assertSame(pdt, result);
+ }
+
+ @Test
+ public void shouldHydrateNestedPdts() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ registry.register(new PointAdapter());
+ registry.register(new LineAdapter());
+
+ final Map startFields = new HashMap<>();
+ startFields.put("x", 0);
+ startFields.put("y", 0);
+ final Map endFields = new HashMap<>();
+ endFields.put("x", 5);
+ endFields.put("y", 5);
+
+ final Map lineFields = new HashMap<>();
+ lineFields.put("start", new ProviderDefinedType("Point", startFields));
+ lineFields.put("end", new ProviderDefinedType("Point", endFields));
+ final ProviderDefinedType linePdt = new ProviderDefinedType("Line", lineFields);
+
+ final Object result = registry.hydrate(linePdt);
+ assertTrue(result instanceof Line);
+ final Line line = (Line) result;
+ assertEquals(0, line.start.x);
+ assertEquals(0, line.start.y);
+ assertEquals(5, line.end.x);
+ assertEquals(5, line.end.y);
+ }
+
+ @Test
+ public void shouldPartiallyHydrateWhenInnerAdapterMissing() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ registry.register(new LineAdapter());
+ // Point adapter NOT registered
+
+ final Map startFields = new HashMap<>();
+ startFields.put("x", 1);
+ startFields.put("y", 2);
+ final ProviderDefinedType startPdt = new ProviderDefinedType("Point", startFields);
+
+ final Map endFields = new HashMap<>();
+ endFields.put("x", 3);
+ endFields.put("y", 4);
+ final ProviderDefinedType endPdt = new ProviderDefinedType("Point", endFields);
+
+ final Map lineFields = new HashMap<>();
+ lineFields.put("start", startPdt);
+ lineFields.put("end", endPdt);
+ final ProviderDefinedType linePdt = new ProviderDefinedType("Line", lineFields);
+
+ // Line adapter will receive ProviderDefinedType values for start/end since Point is not registered.
+ // The LineAdapter.fromFields casts to Point which will throw ClassCastException,
+ // so hydrate should fall back to returning the raw PDT.
+ final Object result = registry.hydrate(linePdt);
+ assertSame(linePdt, result);
+ }
+
+ @Test
+ public void shouldFallBackWhenAdapterThrows() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ registry.register(new FailingAdapter());
+
+ final Map fields = new HashMap<>();
+ fields.put("x", 1);
+ final ProviderDefinedType pdt = new ProviderDefinedType("Failing", fields);
+
+ // should not throw, should return raw PDT
+ final Object result = registry.hydrate(pdt);
+ assertSame(pdt, result);
+ }
+
+ @Test
+ public void shouldLookUpAdapterByClass() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ final PointAdapter adapter = new PointAdapter();
+ registry.register(adapter);
+
+ final Optional> found = registry.getAdapterByClass(Point.class);
+ assertTrue(found.isPresent());
+ assertEquals("Point", found.get().typeName());
+ }
+
+ // Collection test type
+ static class Polygon {
+ final List vertices;
+ Polygon(List vertices) { this.vertices = vertices; }
+ }
+
+ static class PolygonAdapter implements ProviderDefinedTypeAdapter {
+ @Override public String typeName() { return "Polygon"; }
+ @Override public Class targetClass() { return Polygon.class; }
+ @Override public Map toFields(Polygon obj) {
+ final Map m = new HashMap<>();
+ m.put("vertices", obj.vertices);
+ return m;
+ }
+ @SuppressWarnings("unchecked")
+ @Override public Polygon fromFields(Map fields) {
+ return new Polygon((List) fields.get("vertices"));
+ }
+ }
+
+ @Test
+ public void shouldHydratePdtsInsideList() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ registry.register(new PointAdapter());
+ registry.register(new PolygonAdapter());
+
+ final Map p1 = new HashMap<>();
+ p1.put("x", 1); p1.put("y", 2);
+ final Map p2 = new HashMap<>();
+ p2.put("x", 3); p2.put("y", 4);
+
+ final Map polyFields = new HashMap<>();
+ polyFields.put("vertices", Arrays.asList(
+ new ProviderDefinedType("Point", p1),
+ new ProviderDefinedType("Point", p2)));
+ final ProviderDefinedType polyPdt = new ProviderDefinedType("Polygon", polyFields);
+
+ final Object result = registry.hydrate(polyPdt);
+ assertTrue(result instanceof Polygon);
+ final Polygon polygon = (Polygon) result;
+ assertEquals(2, polygon.vertices.size());
+ assertEquals(1, polygon.vertices.get(0).x);
+ assertEquals(2, polygon.vertices.get(0).y);
+ assertEquals(3, polygon.vertices.get(1).x);
+ assertEquals(4, polygon.vertices.get(1).y);
+ }
+
+ @Test
+ public void shouldHydratePdtsInsideMapValues() {
+ final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+ registry.register(new PointAdapter());
+
+ // A simple adapter that receives a map of named points
+ registry.register(new ProviderDefinedTypeAdapter