Skip to content

CASSJAVA-92: Local DC provided for nodetool clientstats #2036

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: 4.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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 com.datastax.oss.driver.api.core.loadbalancing;

import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;

/** Load balancing policy taking into account local datacenter of the application. */
public interface LocalDcAwareLoadBalancingPolicy extends LoadBalancingPolicy {

/** Returns the local datacenter name, if known; empty otherwise. */
@Nullable
String getLocalDatacenter();

/** Returns JSON string containing all properties that impact C* node connectivity. */
@NonNull
String getStartupConfiguration();
Comment on lines +30 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment below about String vs DriverBaggage. Could hoist this up to LoadBalancingPolicy with an empty default as well.

}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oooooh, I like this very much... being explicit about whether an LBP cares about this (and building that into the type system) seems desirable.

Note that we're not saying any specific LBP will take action in a particular way based on this information. Presumably all we can say of an LBP that implements this interface is that it cares about a "local" LBP in some way. That's what we're communicating back to the server... but it's worth noting that this information might be used in different ways by different load balancers.

Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,28 @@

import com.datastax.dse.driver.api.core.config.DseDriverOption;
import com.datastax.oss.driver.api.core.config.DriverExecutionProfile;
import com.datastax.oss.driver.api.core.loadbalancing.LoadBalancingPolicy;
import com.datastax.oss.driver.api.core.loadbalancing.LocalDcAwareLoadBalancingPolicy;
import com.datastax.oss.driver.api.core.session.Session;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.datastax.oss.driver.internal.core.util.Strings;
import com.datastax.oss.driver.shaded.guava.common.base.Joiner;
import com.datastax.oss.driver.shaded.guava.common.collect.Maps;
import com.datastax.oss.protocol.internal.request.Startup;
import com.datastax.oss.protocol.internal.util.collection.NullAllowingImmutableMap;
import edu.umd.cs.findbugs.annotations.Nullable;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import net.jcip.annotations.Immutable;

@Immutable
public class StartupOptionsBuilder {

public static final String DRIVER_NAME_KEY = "DRIVER_NAME";
public static final String DRIVER_VERSION_KEY = "DRIVER_VERSION";
public static final String DRIVER_BAGGAGE = "DRIVER_BAGGAGE";
public static final String APPLICATION_NAME_KEY = "APPLICATION_NAME";
public static final String APPLICATION_VERSION_KEY = "APPLICATION_VERSION";
public static final String CLIENT_ID_KEY = "CLIENT_ID";
Expand Down Expand Up @@ -119,6 +127,8 @@ public Map<String, String> build() {
if (applicationVersion != null) {
builder.put(APPLICATION_VERSION_KEY, applicationVersion);
}
// do not cache local DC as it can change within LBP implementation
driverBaggage().ifPresent(s -> builder.put(DRIVER_BAGGAGE, s));

return builder.build();
}
Expand All @@ -142,4 +152,28 @@ protected String getDriverName() {
protected String getDriverVersion() {
return Session.OSS_DRIVER_COORDINATES.getVersion().toString();
}

private Optional<String> driverBaggage() {
Joiner joiner = Joiner.on(": ");
Map<String, Optional<String>> lbpToBag =
Maps.transformValues(context.getLoadBalancingPolicies(), this::getDriverBaggage);
return Optional.of(
"{"
+ lbpToBag.entrySet().stream()
.filter(e -> e.getValue().isPresent())
.map(
entry ->
joiner.join(Strings.doubleQuote(entry.getKey()), entry.getValue().get()))
.collect(Collectors.joining(", "))
+ "}");
Comment on lines +160 to +168
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not depend on Jackson to do the serialization?

I was thinking you could add a DriverBaggage class with String toJson() and static DriverBaggage put(String key, DriverBaggage value), and in StartupOptionsBuilder here add all the load balancing policies' baggage. Then we wouldn't have the implicit "String that's actually JSON" type.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I completely agree with @aratno on this one. I'd actually go a bit further; I'd argue it's the responsibility of individual LBPs to return a collection of name/value pairs as a Map with absolutely no notion of JSON formatting. It's then the responsibility of StartupOptionsBuilder (or any tool that wants to format these values in any way, either as JSON or something else) to put them into the appropriate format for their use case.

As a proof-of-concept the following implementation passes the test you added to DseStartupOptionsBuilderTest @lukasz-antoniak . I'm not saying this has to be the impl... I'm just providing it as a concrete example of what I'm talking about (and I think what @aratno is talking about as well, although I don't want to speak for him):

diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/loadbalancing/LocalDcAwareLoadBalancingPolicy.java b/core/src/main/java/com/datastax/oss/driver/api/core/loadbalancing/LocalDcAwareLoadBalancingPolicy.java
index d5604b4bf..55ed157e6 100644
--- a/core/src/main/java/com/datastax/oss/driver/api/core/loadbalancing/LocalDcAwareLoadBalancingPolicy.java
+++ b/core/src/main/java/com/datastax/oss/driver/api/core/loadbalancing/LocalDcAwareLoadBalancingPolicy.java
@@ -20,6 +20,8 @@ package com.datastax.oss.driver.api.core.loadbalancing;
 import edu.umd.cs.findbugs.annotations.NonNull;
 import edu.umd.cs.findbugs.annotations.Nullable;
 
+import java.util.Map;
+
 /** Load balancing policy taking into account local datacenter of the application. */
 public interface LocalDcAwareLoadBalancingPolicy extends LoadBalancingPolicy {
 
@@ -29,5 +31,5 @@ public interface LocalDcAwareLoadBalancingPolicy extends LoadBalancingPolicy {
 
   /** Returns JSON string containing all properties that impact C* node connectivity. */
   @NonNull
-  String getStartupConfiguration();
+  Map<String, ?> getStartupConfiguration();
 }
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java
index f64472761..55412fcf5 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java
@@ -23,16 +23,21 @@ import com.datastax.oss.driver.api.core.loadbalancing.LoadBalancingPolicy;
 import com.datastax.oss.driver.api.core.loadbalancing.LocalDcAwareLoadBalancingPolicy;
 import com.datastax.oss.driver.api.core.session.Session;
 import com.datastax.oss.driver.api.core.uuid.Uuids;
+import com.datastax.oss.driver.internal.core.type.codec.extras.json.JsonCodec;
 import com.datastax.oss.driver.internal.core.util.Strings;
 import com.datastax.oss.driver.shaded.guava.common.base.Joiner;
+import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap;
 import com.datastax.oss.driver.shaded.guava.common.collect.Maps;
 import com.datastax.oss.protocol.internal.request.Startup;
 import com.datastax.oss.protocol.internal.util.collection.NullAllowingImmutableMap;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import edu.umd.cs.findbugs.annotations.Nullable;
 import java.util.Map;
 import java.util.Optional;
 import java.util.UUID;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
 import net.jcip.annotations.Immutable;
 
 @Immutable
@@ -154,21 +159,24 @@ public class StartupOptionsBuilder {
   }
 
   private Optional<String> driverBaggage() {
-    Joiner joiner = Joiner.on(": ");
-    Map<String, Optional<String>> lbpToBag =
-        Maps.transformValues(context.getLoadBalancingPolicies(), this::getDriverBaggage);
-    return Optional.of(
-        "{"
-            + lbpToBag.entrySet().stream()
-                .filter(e -> e.getValue().isPresent())
-                .map(
-                    entry ->
-                        joiner.join(Strings.doubleQuote(entry.getKey()), entry.getValue().get()))
-                .collect(Collectors.joining(", "))
-            + "}");
+    ImmutableMap.Builder builder = new ImmutableMap.Builder();
+    for (Map.Entry<String,LoadBalancingPolicy> entry : context.getLoadBalancingPolicies().entrySet()) {
+      this.getDriverBaggage(entry.getValue()).ifPresent(baggage -> {
+        builder.put(entry.getKey(), baggage);
+      });
+    }
+    ObjectMapper mapper = new ObjectMapper();
+    try {
+
+      return Optional.of(mapper.writeValueAsString(builder.build()));
+    }
+    catch (Exception e) {
+      e.printStackTrace();
+      return Optional.empty();
+    }
   }
 
-  private Optional<String> getDriverBaggage(LoadBalancingPolicy loadBalancingPolicy) {
+  private Optional<Map<String,?>> getDriverBaggage(LoadBalancingPolicy loadBalancingPolicy) {
     if (loadBalancingPolicy instanceof LocalDcAwareLoadBalancingPolicy) {
       LocalDcAwareLoadBalancingPolicy dcAwareLbp =
           (LocalDcAwareLoadBalancingPolicy) loadBalancingPolicy;
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/BasicLoadBalancingPolicy.java b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/BasicLoadBalancingPolicy.java
index 777fa66ce..e62c724db 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/BasicLoadBalancingPolicy.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/BasicLoadBalancingPolicy.java
@@ -46,9 +46,12 @@ import com.datastax.oss.driver.internal.core.util.collection.CompositeQueryPlan;
 import com.datastax.oss.driver.internal.core.util.collection.LazyQueryPlan;
 import com.datastax.oss.driver.internal.core.util.collection.QueryPlan;
 import com.datastax.oss.driver.internal.core.util.collection.SimpleQueryPlan;
+import com.datastax.oss.driver.shaded.guava.common.base.Joiner;
 import com.datastax.oss.driver.shaded.guava.common.base.Predicates;
+import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap;
 import com.datastax.oss.driver.shaded.guava.common.collect.Lists;
 import com.datastax.oss.driver.shaded.guava.common.collect.Sets;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import edu.umd.cs.findbugs.annotations.NonNull;
 import edu.umd.cs.findbugs.annotations.Nullable;
 import java.nio.ByteBuffer;
@@ -65,6 +68,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.IntUnaryOperator;
 import java.util.stream.Collectors;
 import net.jcip.annotations.ThreadSafe;
+import org.apache.tinkerpop.shaded.kryo.util.ObjectMap;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -165,43 +169,20 @@ public class BasicLoadBalancingPolicy implements LocalDcAwareLoadBalancingPolicy
 
   @NonNull
   @Override
-  public String getStartupConfiguration() {
-    StringBuilder builder = new StringBuilder();
-    builder
-        .append("{")
-        .append(Strings.doubleQuote(BasicLoadBalancingPolicy.class.getSimpleName()))
-        .append(":")
-        .append("{")
-        .append(Strings.doubleQuote("localDc"))
-        .append(":")
-        .append(Strings.doubleQuoteNullable(localDc));
+  public Map<String, ?> getStartupConfiguration() {
+
+    ImmutableMap.Builder builder = new ImmutableMap.Builder<>();
+    builder.put("localDc", localDc);
     if (!preferredRemoteDcs.isEmpty()) {
-      builder
-          .append(",")
-          .append(Strings.doubleQuote("preferredRemoteDcs"))
-          .append(":[")
-          .append(
-              preferredRemoteDcs.stream()
-                  .map(Strings::doubleQuote)
-                  .collect(Collectors.joining(", ")))
-          .append("]");
+      builder.put("preferredRemoteDcs", preferredRemoteDcs);
     }
     if (allowDcFailoverForLocalCl) {
-      builder
-          .append(",")
-          .append(Strings.doubleQuote("allowDcFailoverForLocalCl"))
-          .append(":")
-          .append(allowDcFailoverForLocalCl);
+      builder.put("allowDcFailoverForLocalCl", allowDcFailoverForLocalCl);
     }
     if (maxNodesPerRemoteDc > 0) {
-      builder
-          .append(",")
-          .append(Strings.doubleQuote("maxNodesPerRemoteDc"))
-          .append(":")
-          .append(maxNodesPerRemoteDc);
+      builder.put("maxNodesPerRemoteDc", maxNodesPerRemoteDc);
     }
-    builder.append("}}");
-    return builder.toString();
+    return ImmutableMap.of(BasicLoadBalancingPolicy.class.getSimpleName(), builder.build());
   }
 
   /** @return The nodes currently considered as live. */
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/DefaultLoadBalancingPolicy.java b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/DefaultLoadBalancingPolicy.java
index 533239a6d..146b4b41c 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/DefaultLoadBalancingPolicy.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/DefaultLoadBalancingPolicy.java
@@ -34,6 +34,7 @@ import com.datastax.oss.driver.internal.core.util.ArrayUtils;
 import com.datastax.oss.driver.internal.core.util.collection.QueryPlan;
 import com.datastax.oss.driver.internal.core.util.collection.SimpleQueryPlan;
 import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting;
+import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap;
 import com.datastax.oss.driver.shaded.guava.common.collect.MapMaker;
 import edu.umd.cs.findbugs.annotations.NonNull;
 import edu.umd.cs.findbugs.annotations.Nullable;
@@ -353,12 +354,8 @@ public class DefaultLoadBalancingPolicy extends BasicLoadBalancingPolicy impleme
 
   @NonNull
   @Override
-  public String getStartupConfiguration() {
-    String result = super.getStartupConfiguration();
-    result =
-        result.replaceFirst(
-            BasicLoadBalancingPolicy.class.getSimpleName(),
-            DefaultLoadBalancingPolicy.class.getSimpleName());
-    return result;
+  public Map<String, ?> getStartupConfiguration() {
+    Map<String, ?> parent = super.getStartupConfiguration();
+    return ImmutableMap.of(DefaultLoadBalancingPolicy.class.getSimpleName(), parent.get(BasicLoadBalancingPolicy.class.getSimpleName()));
   }
 }
diff --git a/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java b/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java
index 15e4bcbfc..99175265b 100644
--- a/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java
+++ b/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java
@@ -339,9 +339,9 @@ public class DseStartupOptionsBuilderTest {
     assertThat(startup.options)
         .containsEntry(
             StartupOptionsBuilder.DRIVER_BAGGAGE,
-            "{\"oltp\": {\"DefaultLoadBalancingPolicy\":{"
+            "{\"oltp\":{\"DefaultLoadBalancingPolicy\":{"
                 + "\"localDc\":\"dc1\","
-                + "\"preferredRemoteDcs\":[\"dc2\", \"dc3\"],"
+                + "\"preferredRemoteDcs\":[\"dc2\",\"dc3\"],"
                 + "\"allowDcFailoverForLocalCl\":true,"
                 + "\"maxNodesPerRemoteDc\":2}}}");
   }

There are some changes to the test code itself but they're only minor whitespace changes; the substance is still very much intact. Regardless this demonstrates the key aspect of the change; LBPs provide metadata as Maps and StartupOptionsBuilder handles the JSON conversion by passing everything off to Jackson.

}

private Optional<String> getDriverBaggage(LoadBalancingPolicy loadBalancingPolicy) {
if (loadBalancingPolicy instanceof LocalDcAwareLoadBalancingPolicy) {
LocalDcAwareLoadBalancingPolicy dcAwareLbp =
(LocalDcAwareLoadBalancingPolicy) loadBalancingPolicy;
return Optional.of(dcAwareLbp.getStartupConfiguration());
}
return Optional.empty();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DefaultDriverContext already defines lazy instantiation for (and access to) the startup options map for a given run. Rather than splitting the logic for determining the contents of a STARTUP message between DefaultDriverContext and this class the majority of the logic in this class should be consolidated into the existing DefaultDriverContext methods.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have noticed that and though that other entries of the STARTUP options are added here. The justification of the logic would be that all "dedicated" properties for STARTUP message are lazily instantiated where you pointed out, whereas all properties taken from other components (e.g. compression) are automatically injected in build() method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, as I look at this again you're right, there's something of a bifurcation here already. The entries returned by DefaultDriverContext.buildStartupOptions() are more static key/value pairs, mostly (exclusively?) pairs that were used by Insights. Nearly all of those should be removed as part of CASSJAVA-73; driver name and version will stay but the rest should disappear.

So how should we format this data? That question is still under discussion in CASSJAVA-92... we probably need to settle on what the data should look like and then adjust this impl accordingly.

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import com.datastax.oss.driver.api.core.context.DriverContext;
import com.datastax.oss.driver.api.core.cql.Statement;
import com.datastax.oss.driver.api.core.loadbalancing.LoadBalancingPolicy;
import com.datastax.oss.driver.api.core.loadbalancing.LocalDcAwareLoadBalancingPolicy;
import com.datastax.oss.driver.api.core.loadbalancing.NodeDistance;
import com.datastax.oss.driver.api.core.loadbalancing.NodeDistanceEvaluator;
import com.datastax.oss.driver.api.core.metadata.Node;
Expand All @@ -40,6 +41,7 @@
import com.datastax.oss.driver.internal.core.loadbalancing.nodeset.NodeSet;
import com.datastax.oss.driver.internal.core.loadbalancing.nodeset.SingleDcNodeSet;
import com.datastax.oss.driver.internal.core.util.ArrayUtils;
import com.datastax.oss.driver.internal.core.util.Strings;
import com.datastax.oss.driver.internal.core.util.collection.CompositeQueryPlan;
import com.datastax.oss.driver.internal.core.util.collection.LazyQueryPlan;
import com.datastax.oss.driver.internal.core.util.collection.QueryPlan;
Expand All @@ -61,6 +63,7 @@
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.IntUnaryOperator;
import java.util.stream.Collectors;
import net.jcip.annotations.ThreadSafe;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -99,7 +102,7 @@
* DefaultLoadBalancingPolicy}</b>.
*/
@ThreadSafe
public class BasicLoadBalancingPolicy implements LoadBalancingPolicy {
public class BasicLoadBalancingPolicy implements LocalDcAwareLoadBalancingPolicy {

private static final Logger LOG = LoggerFactory.getLogger(BasicLoadBalancingPolicy.class);

Expand Down Expand Up @@ -155,10 +158,52 @@ public BasicLoadBalancingPolicy(@NonNull DriverContext context, @NonNull String
* Before initialization, this method always returns null.
*/
@Nullable
protected String getLocalDatacenter() {
@Override
public String getLocalDatacenter() {
return localDc;
}

@NonNull
@Override
public String getStartupConfiguration() {
StringBuilder builder = new StringBuilder();
builder
.append("{")
.append(Strings.doubleQuote(BasicLoadBalancingPolicy.class.getSimpleName()))
.append(":")
.append("{")
.append(Strings.doubleQuote("localDc"))
.append(":")
.append(Strings.doubleQuoteNullable(localDc));
Comment on lines +170 to +177
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above about using Jackson for JSON encoding

if (!preferredRemoteDcs.isEmpty()) {
builder
.append(",")
.append(Strings.doubleQuote("preferredRemoteDcs"))
.append(":[")
.append(
preferredRemoteDcs.stream()
.map(Strings::doubleQuote)
.collect(Collectors.joining(", ")))
.append("]");
}
if (allowDcFailoverForLocalCl) {
builder
.append(",")
.append(Strings.doubleQuote("allowDcFailoverForLocalCl"))
.append(":")
.append(allowDcFailoverForLocalCl);
}
if (maxNodesPerRemoteDc > 0) {
builder
.append(",")
.append(Strings.doubleQuote("maxNodesPerRemoteDc"))
.append(":")
.append(maxNodesPerRemoteDc);
}
builder.append("}}");
return builder.toString();
}

/** @return The nodes currently considered as live. */
protected NodeSet getLiveNodes() {
return liveNodes;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,4 +350,15 @@ private boolean hasSufficientResponses(long now) {
return this.oldest - threshold >= 0;
}
}

@NonNull
@Override
public String getStartupConfiguration() {
String result = super.getStartupConfiguration();
result =
result.replaceFirst(
BasicLoadBalancingPolicy.class.getSimpleName(),
DefaultLoadBalancingPolicy.class.getSimpleName());
return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ public static String doubleQuote(String value) {
return quote(value, '"');
}

/**
* Double quote the given string; double quotes are escaped. If the given string is null, this
* method returns ({@code null}).
*
* @param value The value to double quote.
* @return The double quoted string.
*/
public static String doubleQuoteNullable(String value) {
if (value == null) return null;
return quote(value, '"');
}

/**
* Unquote the given string if it is double quoted; double quotes are unescaped. If the given
* string is not double quoted, it is returned without any modification.
Expand Down
Loading