diff --git a/CHANGELOG.md b/CHANGELOG.md index 9425d0c..829b4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/), +## [Unreleased] + +**Added** + +- feat: add `runDQL()` methods to `DgraphClient` and `DgraphAsyncClient` for direct DQL query + execution +- feat: add `allocateUIDs()` method to `DgraphClient` and `DgraphAsyncClient` for UID allocation +- feat: add `allocateTimestamps()` method to `DgraphClient` and `DgraphAsyncClient` for timestamp + allocation +- feat: add `allocateNamespaces()` method to `DgraphClient` and `DgraphAsyncClient` for namespace + allocation +- feat: add `createNamespace()` method to `DgraphClient` and `DgraphAsyncClient` for namespace + creation +- feat: add `dropNamespace()` method to `DgraphClient` and `DgraphAsyncClient` for namespace + deletion +- feat: add `listNamespaces()` method to `DgraphClient` and `DgraphAsyncClient` for listing all + namespaces +- feat: add namespace parameter support in connection strings and ClientOptions +- feat: add `withNamespace()` method to `ClientOptions` for programmatic namespace configuration + ## [24.2.0] - 2025-04-21 **Added** diff --git a/README.md b/README.md index e918041..815c8c7 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ Valid connection string args: | apikey | \ | a Dgraph Cloud API Key | | bearertoken | \ | an access token | | sslmode | disable \| require \| verify-ca | TLS option, the default is `disable`. If `verify-ca` is set, the TLS certificate configured in the Dgraph cluster must be from a valid certificate authority. | +| namespace | \ | a previously created integer-based namespace, username and password must be supplied | Note that using `sslmode=require` disables certificate validation and significantly reduces the security of TLS. This mode should only be used in non-production (e.g., testing or development) @@ -191,6 +192,7 @@ Some example connection strings: | dgraph://sally:supersecret@dg.example.com:443?sslmode=verify-ca | Connect to remote server, use ACL and require TLS and a valid certificate from a CA | | dgraph://foo-bar.grpc.us-west-2.aws.cloud.dgraph.io:443?sslmode=verify-ca&apikey=\ | Connect to a Dgraph Cloud cluster | | dgraph://foo-bar.grpc.hypermode.com?sslmode=verify-ca&bearertoken=\ | Connect to a Dgraph cluster protected by a secure gateway | +| dgraph://sally:supersecret@dg.example.com:443?namespace=2 | Connect to a ACL enabled Dgraph cluster in namespace 2 | Using the `DgraphClient.open` function with a connection string: diff --git a/build.gradle b/build.gradle index 553c4b8..4bf3120 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ apply plugin: 'idea' apply plugin: 'signing' group = 'io.dgraph' -version = '24.2.0' +version = '25.0.0' base { archivesName = 'dgraph4j' diff --git a/src/main/java/io/dgraph/DgraphAsyncClient.java b/src/main/java/io/dgraph/DgraphAsyncClient.java index c31b9d5..dee0caf 100644 --- a/src/main/java/io/dgraph/DgraphAsyncClient.java +++ b/src/main/java/io/dgraph/DgraphAsyncClient.java @@ -8,7 +8,18 @@ import static java.util.Arrays.asList; import com.google.protobuf.InvalidProtocolBufferException; +import io.dgraph.DgraphProto.AllocateIDsRequest; +import io.dgraph.DgraphProto.AllocateIDsResponse; +import io.dgraph.DgraphProto.CreateNamespaceRequest; +import io.dgraph.DgraphProto.CreateNamespaceResponse; +import io.dgraph.DgraphProto.DropNamespaceRequest; +import io.dgraph.DgraphProto.DropNamespaceResponse; +import io.dgraph.DgraphProto.LeaseType; +import io.dgraph.DgraphProto.ListNamespacesRequest; +import io.dgraph.DgraphProto.ListNamespacesResponse; import io.dgraph.DgraphProto.Payload; +import io.dgraph.DgraphProto.Response; +import io.dgraph.DgraphProto.RunDQLRequest; import io.dgraph.DgraphProto.TxnContext; import io.dgraph.DgraphProto.Version; import io.grpc.Channel; @@ -19,6 +30,7 @@ import io.grpc.StatusRuntimeException; import io.grpc.stub.MetadataUtils; import java.util.List; +import java.util.Map; import java.util.concurrent.*; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; @@ -41,6 +53,7 @@ public class DgraphAsyncClient { private final Executor executor; private final ReadWriteLock jwtLock; private DgraphProto.Jwt jwt; + private long currentNamespace = 0L; // Default namespace /** * Creates a new client for interacting with a Dgraph store. @@ -101,6 +114,7 @@ public CompletableFuture loginIntoNamespace( Lock wlock = jwtLock.writeLock(); wlock.lock(); try { + this.currentNamespace = namespace; // Track the current namespace final DgraphGrpc.DgraphStub client = anyClient(); final DgraphProto.LoginRequest loginRequest = DgraphProto.LoginRequest.newBuilder() @@ -173,14 +187,19 @@ protected DgraphGrpc.DgraphStub getStubWithJwt(DgraphGrpc.DgraphStub stub) { Lock readLock = jwtLock.readLock(); readLock.lock(); try { + Metadata metadata = new Metadata(); + + // Add JWT token if available if (jwt != null && !jwt.getAccessJwt().isEmpty()) { - Metadata metadata = new Metadata(); metadata.put( Metadata.Key.of("accessJwt", Metadata.ASCII_STRING_MARSHALLER), jwt.getAccessJwt()); - return stub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata)); } - return stub; + // Add namespace metadata (required for v25 methods like runDQL) + metadata.put( + Metadata.Key.of("namespace", Metadata.ASCII_STRING_MARSHALLER), + String.valueOf(currentNamespace)); + return stub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata)); } finally { readLock.unlock(); } @@ -293,6 +312,169 @@ public CompletableFuture checkVersion() { }); } + /** + * runDQL executes a DQL query or mutation. + * + * @param dqlQuery the DQL query string to execute + * @param vars variables to substitute in the query + * @param readOnly whether this is a read-only query + * @param bestEffort whether to use best effort for read queries + * @param respFormat response format (JSON or RDF) + * @return A CompletableFuture containing the Response from the query + */ + public CompletableFuture runDQL( + String dqlQuery, + Map vars, + boolean readOnly, + boolean bestEffort, + DgraphProto.Request.RespFormat respFormat) { + final DgraphGrpc.DgraphStub stub = anyClient(); + final RunDQLRequest.Builder requestBuilder = RunDQLRequest.newBuilder() + .setDqlQuery(dqlQuery) + .setReadOnly(readOnly) + .setBestEffort(bestEffort) + .setRespFormat(respFormat); + + if (vars != null) { + requestBuilder.putAllVars(vars); + } + + final RunDQLRequest request = requestBuilder.build(); + + return runWithRetries( + "runDQL", + () -> { + StreamObserverBridge observerBridge = new StreamObserverBridge<>(); + DgraphGrpc.DgraphStub localStub = getStubWithJwt(stub); + localStub.runDQL(request, observerBridge); + return observerBridge.getDelegate(); + }); + } + + /** + * allocateUIDs allocates a given number of Node UIDs in the Graph and returns a start and end UIDs, + * end excluded. The UIDs in the range [start, end) can then be used by the client in the mutations + * going forward. + * + * @param howMany number of UIDs to allocate + * @return A CompletableFuture containing the AllocateIDsResponse with start and end UIDs + */ + public CompletableFuture allocateUIDs(long howMany) { + return allocateIDs(howMany, LeaseType.UID); + } + + /** + * allocateTimestamps gets a sequence of timestamps allocated from Dgraph. These timestamps can be + * used in bulk loader and similar applications. + * + * @param howMany number of timestamps to allocate + * @return A CompletableFuture containing the AllocateIDsResponse with start and end timestamps + */ + public CompletableFuture allocateTimestamps(long howMany) { + return allocateIDs(howMany, LeaseType.TS); + } + + /** + * allocateNamespaces allocates a given number of namespaces in the Graph and returns a start and end + * namespaces, end excluded. The namespaces in the range [start, end) can then be used by the client. + * + * @param howMany number of namespaces to allocate + * @return A CompletableFuture containing the AllocateIDsResponse with start and end namespaces + */ + public CompletableFuture allocateNamespaces(long howMany) { + return allocateIDs(howMany, LeaseType.NS); + } + + /** + * Helper method to allocate IDs of different types (UIDs, timestamps, namespaces). + * + * @param howMany number of IDs to allocate + * @param leaseType type of lease (UID, TS, or NS) + * @return A CompletableFuture containing the AllocateIDsResponse + */ + private CompletableFuture allocateIDs(long howMany, LeaseType leaseType) { + if (howMany <= 0) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new IllegalArgumentException("howMany must be greater than 0")); + return future; + } + + final DgraphGrpc.DgraphStub stub = anyClient(); + final AllocateIDsRequest request = AllocateIDsRequest.newBuilder() + .setHowMany(howMany) + .setLeaseType(leaseType) + .build(); + + return runWithRetries( + "allocateIDs", + () -> { + StreamObserverBridge observerBridge = new StreamObserverBridge<>(); + DgraphGrpc.DgraphStub localStub = getStubWithJwt(stub); + localStub.allocateIDs(request, observerBridge); + return observerBridge.getDelegate(); + }); + } + + /** + * createNamespace creates a new namespace and returns its ID. + * + * @return A CompletableFuture containing the CreateNamespaceResponse with the new namespace ID + */ + public CompletableFuture createNamespace() { + final DgraphGrpc.DgraphStub stub = anyClient(); + final CreateNamespaceRequest request = CreateNamespaceRequest.newBuilder().build(); + + return runWithRetries( + "createNamespace", + () -> { + StreamObserverBridge observerBridge = new StreamObserverBridge<>(); + DgraphGrpc.DgraphStub localStub = getStubWithJwt(stub); + localStub.createNamespace(request, observerBridge); + return observerBridge.getDelegate(); + }); + } + + /** + * dropNamespace drops the specified namespace. If the namespace does not exist, the request will still succeed. + * + * @param namespace the ID of the namespace to drop + * @return A CompletableFuture containing the DropNamespaceResponse + */ + public CompletableFuture dropNamespace(long namespace) { + final DgraphGrpc.DgraphStub stub = anyClient(); + final DropNamespaceRequest request = DropNamespaceRequest.newBuilder() + .setNamespace(namespace) + .build(); + + return runWithRetries( + "dropNamespace", + () -> { + StreamObserverBridge observerBridge = new StreamObserverBridge<>(); + DgraphGrpc.DgraphStub localStub = getStubWithJwt(stub); + localStub.dropNamespace(request, observerBridge); + return observerBridge.getDelegate(); + }); + } + + /** + * listNamespaces lists all namespaces. + * + * @return A CompletableFuture containing the ListNamespacesResponse with all namespaces + */ + public CompletableFuture listNamespaces() { + final DgraphGrpc.DgraphStub stub = anyClient(); + final ListNamespacesRequest request = ListNamespacesRequest.newBuilder().build(); + + return runWithRetries( + "listNamespaces", + () -> { + StreamObserverBridge observerBridge = new StreamObserverBridge<>(); + DgraphGrpc.DgraphStub localStub = getStubWithJwt(stub); + localStub.listNamespaces(request, observerBridge); + return observerBridge.getDelegate(); + }); + } + private DgraphGrpc.DgraphStub anyClient() { int index = ThreadLocalRandom.current().nextInt(stubs.size()); DgraphGrpc.DgraphStub rawStub = stubs.get(index); diff --git a/src/main/java/io/dgraph/DgraphClient.java b/src/main/java/io/dgraph/DgraphClient.java index 440c44c..4966462 100644 --- a/src/main/java/io/dgraph/DgraphClient.java +++ b/src/main/java/io/dgraph/DgraphClient.java @@ -5,7 +5,12 @@ package io.dgraph; +import io.dgraph.DgraphProto.AllocateIDsResponse; +import io.dgraph.DgraphProto.CreateNamespaceResponse; +import io.dgraph.DgraphProto.DropNamespaceResponse; +import io.dgraph.DgraphProto.ListNamespacesResponse; import io.dgraph.DgraphProto.Operation; +import io.dgraph.DgraphProto.Response; import io.dgraph.DgraphProto.TxnContext; import io.dgraph.DgraphProto.Version; import io.grpc.ManagedChannelBuilder; @@ -61,6 +66,7 @@ public static class ClientOptions { private String username; private String password; private String authorizationToken; + private Long namespace; private final String host; private final int port; @@ -118,6 +124,17 @@ public ClientOptions withBearerToken(String token) { return this; } + /** + * Sets the namespace to login into when using ACL credentials. + * + * @param namespace The namespace ID to login into. + * @return This ClientOptions instance for chaining. + */ + public ClientOptions withNamespace(long namespace) { + this.namespace = namespace; + return this; + } + /** * Configures the client to use plaintext communication (no encryption). * @@ -156,6 +173,7 @@ public DgraphGrpc.DgraphStub createStub() { newOptions.username = this.username; newOptions.password = this.password; newOptions.authorizationToken = this.authorizationToken; + newOptions.namespace = this.namespace; return newOptions; } @@ -181,8 +199,15 @@ protected DgraphGrpc.DgraphStub createStub() { * Creates a new DgraphClient with the configured options. * * @return A new DgraphClient instance. + * @throws IllegalArgumentException if namespace is specified without username/password */ public DgraphClient build() { + // Validate namespace usage + if (namespace != null && (username == null || password == null)) { + throw new IllegalArgumentException( + "Namespace can only be specified when username and password are provided"); + } + DgraphGrpc.DgraphStub stub = createStub(); if (authorizationToken != null) { @@ -196,7 +221,11 @@ public DgraphClient build() { DgraphClient client = new DgraphClient(stub); if (username != null && password != null) { - client.login(username, password); + if (namespace != null) { + client.loginIntoNamespace(username, password, namespace); + } else { + client.login(username, password); + } } return client; @@ -330,6 +359,15 @@ public static DgraphClient open(String connectionString) options.withBearerToken(params.get("bearertoken")); } + if (params.containsKey("namespace")) { + try { + long namespace = Long.parseLong(params.get("namespace")); + options.withNamespace(namespace); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid namespace: must be a valid integer", e); + } + } + return options.build(); } @@ -520,6 +558,129 @@ public void loginIntoNamespace(String userid, String password, long namespace) { asyncClient.loginIntoNamespace(userid, password, namespace).join(); } + /** + * runDQL executes a DQL query or mutation. + * + * @param dqlQuery the DQL query string to execute + * @param vars variables to substitute in the query + * @param readOnly whether this is a read-only query + * @param bestEffort whether to use best effort for read queries + * @param respFormat response format (JSON or RDF) + * @return the Response from the query + */ + public Response runDQL( + String dqlQuery, + Map vars, + boolean readOnly, + boolean bestEffort, + DgraphProto.Request.RespFormat respFormat) { + return ExceptionUtil.withExceptionUnwrapped( + () -> { + return asyncClient.runDQL(dqlQuery, vars, readOnly, bestEffort, respFormat).join(); + }); + } + + /** + * runDQL executes a DQL query or mutation with default parameters. + * + * @param dqlQuery the DQL query string to execute + * @return the Response from the query + */ + public Response runDQL(String dqlQuery) { + return runDQL(dqlQuery, null, false, false, DgraphProto.Request.RespFormat.JSON); + } + + /** + * runDQL executes a DQL query or mutation with variables. + * + * @param dqlQuery the DQL query string to execute + * @param vars variables to substitute in the query + * @return the Response from the query + */ + public Response runDQL(String dqlQuery, Map vars) { + return runDQL(dqlQuery, vars, false, false, DgraphProto.Request.RespFormat.JSON); + } + + /** + * allocateUIDs allocates a given number of Node UIDs in the Graph and returns a start and end UIDs, + * end excluded. The UIDs in the range [start, end) can then be used by the client in the mutations + * going forward. + * + * @param howMany number of UIDs to allocate + * @return the AllocateIDsResponse with start and end UIDs + */ + public AllocateIDsResponse allocateUIDs(long howMany) { + return ExceptionUtil.withExceptionUnwrapped( + () -> { + return asyncClient.allocateUIDs(howMany).join(); + }); + } + + /** + * allocateTimestamps gets a sequence of timestamps allocated from Dgraph. These timestamps can be + * used in bulk loader and similar applications. + * + * @param howMany number of timestamps to allocate + * @return the AllocateIDsResponse with start and end timestamps + */ + public AllocateIDsResponse allocateTimestamps(long howMany) { + return ExceptionUtil.withExceptionUnwrapped( + () -> { + return asyncClient.allocateTimestamps(howMany).join(); + }); + } + + /** + * allocateNamespaces allocates a given number of namespaces in the Graph and returns a start and end + * namespaces, end excluded. The namespaces in the range [start, end) can then be used by the client. + * + * @param howMany number of namespaces to allocate + * @return the AllocateIDsResponse with start and end namespaces + */ + public AllocateIDsResponse allocateNamespaces(long howMany) { + return ExceptionUtil.withExceptionUnwrapped( + () -> { + return asyncClient.allocateNamespaces(howMany).join(); + }); + } + + /** + * createNamespace creates a new namespace and returns its ID. + * + * @return the CreateNamespaceResponse with the new namespace ID + */ + public CreateNamespaceResponse createNamespace() { + return ExceptionUtil.withExceptionUnwrapped( + () -> { + return asyncClient.createNamespace().join(); + }); + } + + /** + * dropNamespace drops the specified namespace. If the namespace does not exist, the request will still succeed. + * + * @param namespace the ID of the namespace to drop + * @return the DropNamespaceResponse + */ + public DropNamespaceResponse dropNamespace(long namespace) { + return ExceptionUtil.withExceptionUnwrapped( + () -> { + return asyncClient.dropNamespace(namespace).join(); + }); + } + + /** + * listNamespaces lists all namespaces. + * + * @return the ListNamespacesResponse with all namespaces + */ + public ListNamespacesResponse listNamespaces() { + return ExceptionUtil.withExceptionUnwrapped( + () -> { + return asyncClient.listNamespaces().join(); + }); + } + /** Calls %{@link io.grpc.ManagedChannel#shutdown} on all connections for this client */ public void shutdown() { asyncClient.shutdown().join(); diff --git a/src/main/java/io/dgraph/ExceptionUtil.java b/src/main/java/io/dgraph/ExceptionUtil.java index 841fc83..e3f68e2 100644 --- a/src/main/java/io/dgraph/ExceptionUtil.java +++ b/src/main/java/io/dgraph/ExceptionUtil.java @@ -45,8 +45,12 @@ public static boolean isJwtExpired(Throwable e) { if (cause instanceof StatusRuntimeException) { StatusRuntimeException runtimeException = (StatusRuntimeException) cause; - return runtimeException.getStatus().getCode().equals(Status.Code.UNAUTHENTICATED) - && runtimeException.getMessage().contains("Token is expired"); + Status.Code code = runtimeException.getStatus().getCode(); + String message = runtimeException.getMessage(); + + // Check for JWT expiration in both UNAUTHENTICATED and UNKNOWN status codes + return (code.equals(Status.Code.UNAUTHENTICATED) || code.equals(Status.Code.UNKNOWN)) + && message != null && message.contains("Token is expired"); } return false; } diff --git a/src/main/proto/api.proto b/src/main/proto/api.proto index 78df31b..4949b7f 100644 --- a/src/main/proto/api.proto +++ b/src/main/proto/api.proto @@ -12,7 +12,7 @@ syntax = "proto3"; package api; -option go_package = "github.com/dgraph-io/dgo/v240/protos/api"; +option go_package = "github.com/dgraph-io/dgo/v250/protos/api"; option java_package = "io.dgraph"; option java_outer_classname = "DgraphProto"; @@ -24,6 +24,16 @@ service Dgraph { rpc Alter (Operation) returns (Payload) {} rpc CommitOrAbort (TxnContext) returns (TxnContext) {} rpc CheckVersion(Check) returns (Version) {} + + rpc RunDQL(RunDQLRequest) returns (Response) {} + rpc AllocateIDs(AllocateIDsRequest) returns (AllocateIDsResponse) {} + + rpc UpdateExtSnapshotStreamingState(UpdateExtSnapshotStreamingStateRequest) returns (UpdateExtSnapshotStreamingStateResponse) {} + rpc StreamExtSnapshot(stream StreamExtSnapshotRequest) returns (StreamExtSnapshotResponse) {} + + rpc CreateNamespace(CreateNamespaceRequest) returns (CreateNamespaceResponse) {} + rpc DropNamespace(DropNamespaceRequest) returns (DropNamespaceResponse) {} + rpc ListNamespaces(ListNamespacesRequest) returns (ListNamespacesResponse) {} } message Request { @@ -192,4 +202,73 @@ message Jwt { string refresh_jwt = 2; } +message RunDQLRequest { + string dql_query = 1; + map vars = 2; + bool read_only = 3; + bool best_effort = 4; + Request.RespFormat resp_format = 5; +} + +enum LeaseType { + NS = 0; + UID = 1; + TS = 2; +} + +message AllocateIDsRequest { + uint64 how_many = 1; + LeaseType lease_type = 2; +} + +message AllocateIDsResponse { + uint64 start = 1; + uint64 end = 2; // inclusive +} + +message CreateNamespaceRequest {} + +message CreateNamespaceResponse { + uint64 namespace = 1; +} + +message DropNamespaceRequest { + uint64 namespace = 1; +} + +message DropNamespaceResponse {} + +message ListNamespacesRequest {} + +message ListNamespacesResponse { + map namespaces = 1; +} + +message Namespace { + uint64 id = 1; +} + +message UpdateExtSnapshotStreamingStateRequest { + bool start = 1; + bool finish = 2; + bool drop_data = 3; +} + +message UpdateExtSnapshotStreamingStateResponse { + repeated uint32 groups = 1; +} + +message StreamExtSnapshotRequest { + uint32 group_id = 1; + bool forward = 2; + StreamPacket pkt = 3; +} + +message StreamExtSnapshotResponse {} + +message StreamPacket { + bytes data = 1; + bool done = 2; +} + // vim: noexpandtab sw=2 ts=2 diff --git a/src/test/java/io/dgraph/DgraphClientOptionsTest.java b/src/test/java/io/dgraph/DgraphClientOptionsTest.java new file mode 100644 index 0000000..df02fff --- /dev/null +++ b/src/test/java/io/dgraph/DgraphClientOptionsTest.java @@ -0,0 +1,283 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.dgraph; + +import static org.testng.Assert.*; + +import java.net.MalformedURLException; +import javax.net.ssl.SSLException; +import org.testng.annotations.Test; + +/** + * Comprehensive tests for DgraphClient.ClientOptions functionality. + * These tests focus on validation and parsing without requiring server connections. + */ +public class DgraphClientOptionsTest { + + // ========== Basic ClientOptions Tests ========== + + @Test + public void testForAddress() { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080); + assertNotNull(options); + } + + @Test + public void testWithACLCredentials() { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withACLCredentials("username", "password"); + assertNotNull(options); + } + + @Test + public void testWithDgraphApiKey() { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withDgraphApiKey("test-api-key"); + assertNotNull(options); + } + + @Test + public void testWithBearerToken() { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withBearerToken("test-bearer-token"); + assertNotNull(options); + } + + @Test + public void testWithTLSSkipVerify() throws SSLException { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withTLSSkipVerify(); + assertNotNull(options); + } + + // ========== Namespace Tests ========== + + @Test + public void testWithNamespace() { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withACLCredentials("username", "password") + .withNamespace(123); + assertNotNull(options); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testNamespaceWithoutCredentials() { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withNamespace(123); + + // Should throw exception when namespace is set without username/password + options.build(); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testNamespaceWithOnlyUsername() { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withACLCredentials("username", null) + .withNamespace(123); + + // Should throw exception when namespace is set without password + options.build(); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testNamespaceWithApiKey() { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withDgraphApiKey("test-key") + .withNamespace(123); + + // Should throw exception when namespace is set with API key (no ACL credentials) + options.build(); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testNamespaceWithBearerToken() { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withBearerToken("test-token") + .withNamespace(123); + + // Should throw exception when namespace is set with bearer token (no ACL credentials) + options.build(); + } + + // ========== Connection String Parsing Tests ========== + + @Test + public void testOpenBasicConnectionString() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://localhost:9080"; + DgraphClient.open(connectionString); + } catch (Exception e) { + // Connection failures are expected, but parsing errors would indicate a problem + assertFalse(e instanceof IllegalArgumentException, + "Basic connection string parsing failed: " + e.getMessage()); + } + } + + @Test + public void testOpenWithCredentials() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://username:password@localhost:9080"; + DgraphClient.open(connectionString); + } catch (Exception e) { + assertFalse(e instanceof IllegalArgumentException, + "Credentials parsing failed: " + e.getMessage()); + } + } + + @Test + public void testOpenWithSSLMode() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://localhost:9080?sslmode=disable"; + DgraphClient.open(connectionString); + } catch (Exception e) { + assertFalse(e instanceof IllegalArgumentException, + "SSL mode parsing failed: " + e.getMessage()); + } + } + + @Test + public void testOpenWithApiKey() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://localhost:9080?apikey=test-key"; + DgraphClient.open(connectionString); + } catch (Exception e) { + assertFalse(e instanceof IllegalArgumentException, + "API key parsing failed: " + e.getMessage()); + } + } + + @Test + public void testOpenWithBearerToken() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://localhost:9080?bearertoken=test-token"; + DgraphClient.open(connectionString); + } catch (Exception e) { + assertFalse(e instanceof IllegalArgumentException, + "Bearer token parsing failed: " + e.getMessage()); + } + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testOpenWithBothApiKeyAndBearerToken() throws MalformedURLException, SSLException { + String connectionString = "dgraph://localhost:9080?apikey=test-key&bearertoken=test-token"; + + // Should throw exception when both apikey and bearertoken are provided + DgraphClient.open(connectionString); + } + + // ========== Namespace Connection String Tests ========== + + @Test + public void testOpenWithNamespaceParameter() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://username:password@localhost:9080?namespace=123"; + DgraphClient.open(connectionString); + } catch (Exception e) { + assertFalse(e instanceof IllegalArgumentException, + "Namespace parsing failed: " + e.getMessage()); + } + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testOpenWithNamespaceButNoCredentials() throws MalformedURLException, SSLException { + String connectionString = "dgraph://localhost:9080?namespace=123"; + + // Should throw exception when namespace is in connection string but no credentials + DgraphClient.open(connectionString); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testOpenWithInvalidNamespace() throws MalformedURLException, SSLException { + String connectionString = "dgraph://username:password@localhost:9080?namespace=invalid"; + + // Should throw exception when namespace is not a valid integer + DgraphClient.open(connectionString); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testOpenWithNamespaceAndApiKey() throws MalformedURLException, SSLException { + String connectionString = "dgraph://localhost:9080?apikey=test&namespace=123"; + + // Should throw exception when namespace is used with apikey (no username/password) + DgraphClient.open(connectionString); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testOpenWithNamespaceAndBearerToken() throws MalformedURLException, SSLException { + String connectionString = "dgraph://localhost:9080?bearertoken=test&namespace=123"; + + // Should throw exception when namespace is used with bearertoken (no username/password) + DgraphClient.open(connectionString); + } + + @Test + public void testOpenWithMultipleParameters() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://username:password@localhost:9080?sslmode=disable&namespace=456"; + DgraphClient.open(connectionString); + } catch (Exception e) { + assertFalse(e instanceof IllegalArgumentException, + "Multiple parameters parsing failed: " + e.getMessage()); + } + } + + @Test + public void testOpenWithZeroNamespace() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://username:password@localhost:9080?namespace=0"; + DgraphClient.open(connectionString); + } catch (Exception e) { + assertFalse(e instanceof IllegalArgumentException, + "Zero namespace parsing failed: " + e.getMessage()); + } + } + + @Test + public void testOpenWithNegativeNamespace() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://username:password@localhost:9080?namespace=-1"; + DgraphClient.open(connectionString); + } catch (Exception e) { + assertFalse(e instanceof IllegalArgumentException, + "Negative namespace parsing failed: " + e.getMessage()); + } + } + + // ========== Edge Case Tests ========== + + @Test + public void testOpenWithEmptyParameters() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://localhost:9080?"; + DgraphClient.open(connectionString); + } catch (Exception e) { + assertFalse(e instanceof IllegalArgumentException, + "Empty parameters parsing failed: " + e.getMessage()); + } + } + + @Test + public void testOpenWithMultipleQuestionMarks() throws MalformedURLException, SSLException { + try { + String connectionString = "dgraph://username:password@localhost:9080?sslmode=disable?namespace=123"; + DgraphClient.open(connectionString); + } catch (Exception e) { + // This should fail parsing, but not with IllegalArgumentException for namespace + // The URL parsing itself might fail, which is expected + } + } + + @Test + public void testTLSOptionsPreservesNamespace() throws SSLException { + DgraphClient.ClientOptions options = DgraphClient.ClientOptions.forAddress("localhost", 9080) + .withACLCredentials("username", "password") + .withNamespace(123); + + DgraphClient.ClientOptions tlsOptions = options.withTLSSkipVerify(); + assertNotNull(tlsOptions); + // The namespace should be preserved in the new options object + } +} diff --git a/src/test/java/io/dgraph/DgraphV25Test.java b/src/test/java/io/dgraph/DgraphV25Test.java new file mode 100644 index 0000000..90d04d3 --- /dev/null +++ b/src/test/java/io/dgraph/DgraphV25Test.java @@ -0,0 +1,394 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.dgraph; + +import static org.testng.Assert.*; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.dgraph.DgraphProto.AllocateIDsResponse; +import io.dgraph.DgraphProto.CreateNamespaceResponse; +import io.dgraph.DgraphProto.DropNamespaceResponse; +import io.dgraph.DgraphProto.ListNamespacesResponse; +import io.dgraph.DgraphProto.Operation; +import io.dgraph.DgraphProto.Response; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +/** + * Tests for Dgraph v25 features including runDQL, allocation methods, and namespace management. + */ +public class DgraphV25Test extends DgraphIntegrationTest { + + @BeforeMethod + public void beforeMethod() { + dgraphClient.alter(Operation.newBuilder().setDropAll(true).build()); + } + + @Test + public void testRunDQLQuery() { + try { + // Set schema + Operation op = Operation.newBuilder().setSchema("name: string @index(exact) .").build(); + dgraphClient.alter(op); + + // Add data using runDQL + String mutationDQL = "{\n" + + " set {\n" + + " _:alice \"Alice\" .\n" + + " _:greg \"Greg\" .\n" + + " _:alice _:greg .\n" + + " }\n" + + "}"; + + Response mutationResponse = dgraphClient.runDQL(mutationDQL); + assertNotNull(mutationResponse); + + // Query data using runDQL + String queryDQL = "{\n" + + " people(func: has(name)) {\n" + + " uid\n" + + " name\n" + + " follows {\n" + + " name\n" + + " }\n" + + " }\n" + + "}"; + + Response queryResponse = dgraphClient.runDQL(queryDQL); + assertNotNull(queryResponse); + assertNotNull(queryResponse.getJson()); + + // Parse and verify the response + JsonObject jsonResponse = JsonParser.parseString(queryResponse.getJson().toStringUtf8()).getAsJsonObject(); + assertTrue(jsonResponse.has("people")); + assertTrue(jsonResponse.getAsJsonArray("people").size() > 0); + } catch (RuntimeException e) { + // Print detailed error information to understand what's happening + System.out.println("Error details:"); + System.out.println(" Type: " + e.getClass().getName()); + System.out.println(" Message: " + e.getMessage()); + + Throwable cause = e.getCause(); + while (cause != null) { + System.out.println(" Cause type: " + cause.getClass().getName()); + System.out.println(" Cause message: " + cause.getMessage()); + cause = cause.getCause(); + } + + // Check if this is an "unimplemented" error, which means the server doesn't support runDQL + String errorChain = e.toString(); + if (e.getCause() != null) { + errorChain += " " + e.getCause().toString(); + if (e.getCause().getCause() != null) { + errorChain += " " + e.getCause().getCause().toString(); + } + } + + if (errorChain.contains("UNIMPLEMENTED") || errorChain.contains("unknown method")) { + System.out.println("SKIPPING: runDQL method not supported by this Dgraph server version"); + return; // Skip this test + } + throw e; // Re-throw if it's a different error + } + } + + @Test + public void testRunDQLWithAllParameters() { + // Set schema + Operation op = Operation.newBuilder().setSchema("name: string @index(exact) .").build(); + dgraphClient.alter(op); + + // Add data first + String mutationDQL = "{\n" + + " set {\n" + + " _:alice \"Alice\" .\n" + + " }\n" + + "}"; + dgraphClient.runDQL(mutationDQL); + + // Query with all parameters + String queryDQL = "query { me(func: eq(name, \"Alice\")) { name } }"; + Response response = dgraphClient.runDQL( + queryDQL, + null, // no variables + true, // read-only + false, // not best effort + DgraphProto.Request.RespFormat.JSON + ); + + assertNotNull(response); + JsonObject data = JsonParser.parseString(response.getJson().toStringUtf8()).getAsJsonObject(); + assertTrue(data.has("me")); + assertEquals("Alice", data.getAsJsonArray("me").get(0).getAsJsonObject().get("name").getAsString()); + } + + @Test + public void testRunDQLWithRDFFormat() { + // Set schema + Operation op = Operation.newBuilder().setSchema("name: string @index(exact) .").build(); + dgraphClient.alter(op); + + // Add data first + String mutationDQL = "{\n" + + " set {\n" + + " _:alice \"Alice\" .\n" + + " }\n" + + "}"; + Response mutationResponse = dgraphClient.runDQL(mutationDQL); + + // Get the UID from mutation response + String aliceUid = mutationResponse.getUidsMap().values().iterator().next(); + + // Query with RDF format + String queryDQL = String.format("{ me(func: uid(%s)) { name } }", aliceUid); + Response response = dgraphClient.runDQL( + queryDQL, + null, + true, + false, + DgraphProto.Request.RespFormat.RDF + ); + + assertNotNull(response); + String rdfResult = response.getRdf().toStringUtf8(); + assertTrue(rdfResult.contains("<" + aliceUid + "> \"Alice\"")); + } + + @Test + public void testAllocateUIDs() { + long howMany = 100; + AllocateIDsResponse response = dgraphClient.allocateUIDs(howMany); + + assertNotNull(response); + assertTrue(response.getStart() > 0); + assertTrue(response.getEnd() > response.getStart()); + // Note: end is inclusive in the response, so we add 1 to get the actual count + assertEquals(response.getEnd() - response.getStart() + 1, howMany); + + // Test allocating again gives different range + AllocateIDsResponse response2 = dgraphClient.allocateUIDs(howMany); + assertNotEquals(response.getStart(), response2.getStart()); + assertTrue(response2.getStart() >= response.getEnd()); // Should be non-overlapping + } + + @Test + public void testAllocateTimestamps() { + long howMany = 50; + AllocateIDsResponse response = dgraphClient.allocateTimestamps(howMany); + + assertNotNull(response); + assertTrue(response.getStart() > 0); + assertTrue(response.getEnd() > response.getStart()); + // Note: end is inclusive in the response, so we add 1 to get the actual count + assertEquals(response.getEnd() - response.getStart() + 1, howMany); + + // Test allocating again gives different range + AllocateIDsResponse response2 = dgraphClient.allocateTimestamps(howMany); + assertNotEquals(response.getStart(), response2.getStart()); + assertTrue(response2.getStart() >= response.getEnd()); // Should be non-overlapping + } + + @Test + public void testAllocateNamespaces() { + long howMany = 10; + AllocateIDsResponse response = dgraphClient.allocateNamespaces(howMany); + + assertNotNull(response); + assertTrue(response.getStart() > 0); + assertTrue(response.getEnd() > response.getStart()); + // Note: end is inclusive in the response, so we add 1 to get the actual count + assertEquals(response.getEnd() - response.getStart() + 1, howMany); + + // Test allocating again gives different range + AllocateIDsResponse response2 = dgraphClient.allocateNamespaces(howMany); + assertNotEquals(response.getStart(), response2.getStart()); + assertTrue(response2.getStart() >= response.getEnd()); // Should be non-overlapping + } + + @Test + public void testAllocateUIDsDifferentSizes() { + // Test small allocation + AllocateIDsResponse response1 = dgraphClient.allocateUIDs(1); + assertEquals(response1.getEnd() - response1.getStart() + 1, 1); + + // Test larger allocation + AllocateIDsResponse response2 = dgraphClient.allocateUIDs(1000); + assertEquals(response2.getEnd() - response2.getStart() + 1, 1000); + + // Ensure ranges don't overlap + assertTrue(response2.getStart() >= response1.getEnd()); + } + + @Test(expectedExceptions = RuntimeException.class) + public void testAllocateUIDsZeroItems() { + dgraphClient.allocateUIDs(0); + } + + @Test(expectedExceptions = RuntimeException.class) + public void testAllocateUIDsNegativeItems() { + dgraphClient.allocateUIDs(-1); + } + + @Test + public void testAllocationMethodsAreIndependent() { + // Allocate from each type + AllocateIDsResponse uidResponse = dgraphClient.allocateUIDs(100); + AllocateIDsResponse tsResponse = dgraphClient.allocateTimestamps(100); + AllocateIDsResponse nsResponse = dgraphClient.allocateNamespaces(100); + + // All should return valid ranges + assertEquals(uidResponse.getEnd() - uidResponse.getStart() + 1, 100); + assertEquals(tsResponse.getEnd() - tsResponse.getStart() + 1, 100); + assertEquals(nsResponse.getEnd() - nsResponse.getStart() + 1, 100); + + // All should be positive and valid + assertTrue(uidResponse.getStart() > 0); + assertTrue(tsResponse.getStart() > 0); + assertTrue(nsResponse.getStart() > 0); + } + + @Test + public void testCreateNamespace() { + CreateNamespaceResponse response = dgraphClient.createNamespace(); + + assertNotNull(response); + assertTrue(response.getNamespace() > 0); + + // Test creating another namespace gives different ID + CreateNamespaceResponse response2 = dgraphClient.createNamespace(); + assertNotEquals(response.getNamespace(), response2.getNamespace()); + } + + @Test + public void testListNamespaces() { + // Create a namespace first + CreateNamespaceResponse createResponse = dgraphClient.createNamespace(); + long namespaceId = createResponse.getNamespace(); + + // List namespaces + ListNamespacesResponse listResponse = dgraphClient.listNamespaces(); + + assertNotNull(listResponse); + assertTrue(listResponse.getNamespacesMap().containsKey(namespaceId)); + + // Verify the namespace object + DgraphProto.Namespace namespace = listResponse.getNamespacesMap().get(namespaceId); + assertEquals(namespace.getId(), namespaceId); + } + + @Test + public void testDropNamespace() { + // Create a namespace + CreateNamespaceResponse createResponse = dgraphClient.createNamespace(); + long namespaceId = createResponse.getNamespace(); + + // Verify it exists in the list + ListNamespacesResponse listBefore = dgraphClient.listNamespaces(); + assertTrue(listBefore.getNamespacesMap().containsKey(namespaceId)); + + // Only drop if it's not namespace 0 (system namespace cannot be deleted) + if (namespaceId != 0) { + // Drop the namespace + DropNamespaceResponse dropResponse = dgraphClient.dropNamespace(namespaceId); + assertNotNull(dropResponse); + + // Verify it's no longer in the list + ListNamespacesResponse listAfter = dgraphClient.listNamespaces(); + assertFalse(listAfter.getNamespacesMap().containsKey(namespaceId)); + } + } + + @Test + public void testCreateAndDropMultipleNamespaces() { + try { + System.out.println("=== Starting testCreateAndDropMultipleNamespaces ==="); + + // Create multiple namespaces + long[] namespaceIds = new long[3]; + for (int i = 0; i < 3; i++) { + System.out.println("Creating namespace " + (i + 1) + "/3"); + CreateNamespaceResponse response = dgraphClient.createNamespace(); + namespaceIds[i] = response.getNamespace(); + System.out.println("Created namespace with ID: " + namespaceIds[i]); + } + + // Verify all are in the list + System.out.println("Listing namespaces to verify creation..."); + ListNamespacesResponse listResponse = dgraphClient.listNamespaces(); + System.out.println("Found " + listResponse.getNamespacesMap().size() + " namespaces total"); + for (long namespaceId : namespaceIds) { + boolean exists = listResponse.getNamespacesMap().containsKey(namespaceId); + System.out.println("Namespace " + namespaceId + " exists: " + exists); + assertTrue(exists); + } + + // Drop all namespaces (except namespace 0 which cannot be deleted) + System.out.println("Dropping namespaces..."); + for (long namespaceId : namespaceIds) { + if (namespaceId != 0) { + System.out.println("Dropping namespace: " + namespaceId); + dgraphClient.dropNamespace(namespaceId); + System.out.println("Successfully dropped namespace: " + namespaceId); + } else { + System.out.println("Skipping namespace 0 (cannot be deleted)"); + } + } + + // Verify droppable namespaces are no longer in the list + System.out.println("Verifying namespaces were dropped..."); + ListNamespacesResponse listAfter = dgraphClient.listNamespaces(); + System.out.println("Found " + listAfter.getNamespacesMap().size() + " namespaces after dropping"); + for (long namespaceId : namespaceIds) { + if (namespaceId != 0) { + boolean exists = listAfter.getNamespacesMap().containsKey(namespaceId); + System.out.println("Namespace " + namespaceId + " exists after drop: " + exists); + assertFalse(exists); + } else { + // Namespace 0 should still exist + boolean exists = listAfter.getNamespacesMap().containsKey(namespaceId); + System.out.println("Namespace 0 exists after drop: " + exists); + assertTrue(exists); + } + } + System.out.println("=== testCreateAndDropMultipleNamespaces completed successfully ==="); + } catch (RuntimeException e) { + System.out.println("=== ERROR in testCreateAndDropMultipleNamespaces ==="); + System.out.println("Error type: " + e.getClass().getName()); + System.out.println("Error message: " + e.getMessage()); + + Throwable cause = e.getCause(); + while (cause != null) { + System.out.println("Cause type: " + cause.getClass().getName()); + System.out.println("Cause message: " + cause.getMessage()); + cause = cause.getCause(); + } + + throw e; // Re-throw to fail the test + } + } + + @Test + public void testCannotDropNamespaceZero() { + // Namespace 0 is the system namespace and cannot be deleted + try { + dgraphClient.dropNamespace(0); + fail("Expected exception when trying to drop namespace 0"); + } catch (RuntimeException e) { + // Expected - namespace 0 cannot be deleted + assertTrue(e.getMessage().contains("cannot be deleted") || + e.getCause().getMessage().contains("cannot be deleted")); + } + } + + @Test + public void testDropNonExistentNamespace() { + // Dropping a non-existent namespace should still succeed (idempotent operation) + long nonExistentId = 999999L; + DropNamespaceResponse response = dgraphClient.dropNamespace(nonExistentId); + assertNotNull(response); + } +}