Repository: GoogleCloudPlatform/pgadapter
Pull request #917 (2023-07-03 19:08:16)

feat: create options builder
Add a builder for the OptionsMetadata class, to simplify building and running PGAdapter as an in-process proxy. Instead of having to set command line arguments in the Java application, users can just use a normal Builder class to set options. This also significantly simplifies setting structured options like SessionPoolOptions.

#918 shows how this builder can be used to create an in-process PGAdapter instance.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/917?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) Report
> Merging [#917](https://app.codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/917?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (86f3685) into [postgresql-dialect](https://app.codecov.io/gh/GoogleCloudPlatform/pgadapter/commit/5088c64e76027466493fa0f43be4d67495226292?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (5088c64) will **increase** coverage by `0.04%`.
> The diff coverage is `88.34%`.

> :exclamation: Current head 86f3685 differs from pull request most recent head f28360d. Consider uploading reports for the commit f28360d to get more accurate results

```diff
@@                   Coverage Diff                    @@
##             postgresql-dialect     #917      +/-   ##
========================================================
+ Coverage                 90.14%   90.19%   +0.04%     
- Complexity                 2542     2555      +13     
========================================================
  Files                       141      141              
  Lines                      8393     8495     +102     
  Branches                   1237     1259      +22     
========================================================
+ Hits                       7566     7662      +96     
+ Misses                      570      565       -5     
- Partials                    257      268      +11     
```

| Flag | Coverage Δ | |
|---|---|---|
| all_tests | `90.19% <88.34%> (+0.04%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/917?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) | Coverage Δ | |
|---|---|---|
| [...gle/cloud/spanner/pgadapter/ConnectionHandler.java](https://app.codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/917?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL0Nvbm5lY3Rpb25IYW5kbGVyLmphdmE=) | `87.90% <57.14%> (-0.55%)` | :arrow_down: |
| [...ud/spanner/pgadapter/metadata/OptionsMetadata.java](https://app.codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/917?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL21ldGFkYXRhL09wdGlvbnNNZXRhZGF0YS5qYXZh) | `84.30% <90.62%> (+4.23%)` | :arrow_up: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform)


Patch:
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java b/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
index 655d3e72..36385570 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
@@ -200,6 +200,14 @@ public class ConnectionHandler extends Thread {
     if (credentials != null) {
       connectionOptionsBuilder =
           ConnectionOptionsHelper.setCredentials(connectionOptionsBuilder, credentials);
+    } else if (options.getCredentials() != null) {
+      connectionOptionsBuilder =
+          ConnectionOptionsHelper.setCredentials(
+              connectionOptionsBuilder, options.getCredentials());
+    }
+    if (options.getSessionPoolOptions() != null) {
+      connectionOptionsBuilder =
+          connectionOptionsBuilder.setSessionPoolOptions(options.getSessionPoolOptions());
     }
     ConnectionOptions connectionOptions = connectionOptionsBuilder.build();
     Connection spannerConnection = connectionOptions.getConnection();
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/metadata/OptionsMetadata.java b/src/main/java/com/google/cloud/spanner/pgadapter/metadata/OptionsMetadata.java
index 14a5ad0d..36109ce9 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/metadata/OptionsMetadata.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/metadata/OptionsMetadata.java
@@ -14,16 +14,21 @@
 
 package com.google.cloud.spanner.pgadapter.metadata;
 
+import com.google.auth.Credentials;
 import com.google.auth.oauth2.GoogleCredentials;
+import com.google.cloud.NoCredentials;
 import com.google.cloud.spanner.DatabaseId;
 import com.google.cloud.spanner.ErrorCode;
 import com.google.cloud.spanner.InstanceId;
+import com.google.cloud.spanner.SessionPoolOptions;
 import com.google.cloud.spanner.SpannerExceptionFactory;
 import com.google.cloud.spanner.SpannerOptions;
+import com.google.cloud.spanner.pgadapter.ProxyServer;
 import com.google.cloud.spanner.pgadapter.Server;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.spanner.v1.DatabaseName;
 import java.io.File;
 import java.io.IOException;
@@ -31,6 +36,7 @@ import java.util.HashMap;
 import java.util.Map;
 import java.util.logging.Level;
 import java.util.logging.Logger;
+import javax.annotation.Nullable;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.CommandLineParser;
 import org.apache.commons.cli.DefaultParser;
@@ -42,6 +48,337 @@ import org.json.simple.JSONObject;
 /** Metadata extractor for CLI. */
 public class OptionsMetadata {
 
+  /**
+   * Builder class for creating an instance of {@link OptionsMetadata}.
+   *
+   * <p>A default instance of this Builder will create a PGAdapter proxy server that:
+   *
+   * <ol>
+   *   <li>Accepts connections to any Cloud Spanner database. The database name must be given as a
+   *       fully qualified database name in the PostgreSQL connection URL.
+   *   <li>Accepts connections on a dynamically assigned port. You can get the port that was
+   *       assigned to the server after the server has started by calling {@link
+   *       ProxyServer#getLocalPort()}.
+   *   <li>Uses the default credentials in the current runtime environment.
+   *   <li>Listens for Unix domain socket connections on '/tmp'.
+   * </ol>
+   */
+  public static class Builder {
+    private String project;
+    private String instance;
+    private String database;
+    private SessionPoolOptions sessionPoolOptions;
+    private Integer numChannels;
+    private String databaseRole;
+    private DdlTransactionMode ddlTransactionMode;
+    private String credentialsFile;
+    private Credentials credentials;
+    private boolean requireAuthentication;
+    private boolean skipLocalhostCheck;
+    private SslMode sslMode;
+    private int port;
+    private String unixDomainSocketDirectory;
+    private boolean debugMode;
+    private String endpoint;
+    private boolean usePlainText;
+
+    Builder() {}
+
+    /**
+     * (Optional) The Google Cloud project ID that PGAdapter should connect to.
+     *
+     * <p>It is not required to set a project ID for PGAdapter, but you are required to use a fully
+     * qualified database name in the form
+     * `projects/my-project/instances/my-instance/databases/my-database` in any connection URL if
+     * you do not set a project ID here.
+     */
+    public Builder setProject(String project) {
+      this.project = project;
+      return this;
+    }
+
+    /**
+     * (Optional) The Google Cloud Spanner instance ID that PGAdapter should connect to.
+     *
+     * <p>It is not required to set an instance ID for PGAdapter, but you are required to use a
+     * fully qualified database name in the form
+     * `projects/my-project/instances/my-instance/databases/my-database` in any connection URL if
+     * you do not set an instance ID here.
+     */
+    public Builder setInstance(String instance) {
+      this.instance = instance;
+      return this;
+    }
+
+    /**
+     * (Optional) The Google Cloud Spanner database ID that PGAdapter should connect to. You can
+     * only set this property if you also set the project and instance ID that PGAdapter should use.
+     *
+     * <p><b>Note:</b>Setting a project, instance and database ID will lock PGAdapter to only this
+     * specific database. Any database information in a PostgreSQL connection URL that PGAdapter
+     * receives will be ignored.
+     *
+     * <p>It is not required to set a database ID for PGAdapter. You must include a database name in
+     * the PostgreSQL connection URL if you do not set a database name on this builder. The database
+     * name in the connection URL can be:
+     *
+     * <ul>
+     *   <li>A database ID: This is only supported if you have set a project and instance ID on this
+     *       builder. The database ID must be a valid database ID on the selected Cloud Spanner
+     *       instance.
+     *   <li>A fully qualified database name in the form
+     *       `projects/my-project/instances/my-instance/databases/my-database`. This database can be
+     *       on any Cloud Spanner instance, and is not restricted to any project and instance ID
+     *       that might have been set on this builder.
+     * </ul>
+     */
+    public Builder setDatabase(String database) {
+      this.database = database;
+      return this;
+    }
+
+    /** (Optional) The {@link SessionPoolOptions} to use for connections to Cloud Spanner. */
+    public Builder setSessionPoolOptions(SessionPoolOptions sessionPoolOptions) {
+      this.sessionPoolOptions = Preconditions.checkNotNull(sessionPoolOptions);
+      return this;
+    }
+
+    /** (Optional) The number of gRPC channels to use for connections to Cloud Spanner. */
+    public Builder setNumChannels(int numChannels) {
+      this.numChannels = numChannels;
+      return this;
+    }
+
+    /** (Optional) The database role to use for connections to Cloud Spanner. */
+    public Builder setDatabaseRole(String databaseRole) {
+      this.databaseRole = databaseRole;
+      return this;
+    }
+
+    /**
+     * (Optional) Sets the default DDL transaction mode. Cloud Spanner does not support DDL
+     * statements in transactions, but some tools and frameworks will assume that it does. This
+     * option enables you to specify the behavior that PGAdapter should use when it encounters a DDL
+     * statement in a read/write transaction.
+     *
+     * <p>See also <a
+     * href="https://github.com/GoogleCloudPlatform/pgadapter/blob/postgresql-dialect/docs/ddl.md">DDL
+     * Options</a> for more information.
+     */
+    public Builder setDdlTransactionMode(DdlTransactionMode ddlTransactionMode) {
+      this.ddlTransactionMode = Preconditions.checkNotNull(ddlTransactionMode);
+      return this;
+    }
+
+    /**
+     * (Optional) The credentials file (user credentials or service account key file) that PGAdapter
+     * should use to connect to Cloud Spanner.
+     *
+     * <p><b>Note:</b>Specifying a credentials file is only necessary if you do not want PGAdapter
+     * to use the default credentials of the runtime environment.
+     *
+     * <p>You can only specify either a credentials file using this method or a {@link Credentials}
+     * instance using {@link #setCredentials(Credentials)}.
+     */
+    public Builder setCredentialsFile(String credentialsFile) {
+      Preconditions.checkState(
+          credentials == null, "Cannot set both credentials and credentialsFile");
+      this.credentialsFile = Preconditions.checkNotNull(credentialsFile);
+      return this;
+    }
+
+    /**
+     * (Optional) The credentials that PGAdapter should use to connect to Cloud Spanner.
+     *
+     * <p><b>Note:</b>Specifying a credentials file is only necessary if you do not want PGAdapter
+     * to use the default credentials of the runtime environment.
+     *
+     * <p>You can only specify either a credentials instance using this method or a credentials file
+     * using {@link #setCredentialsFile(String)}.
+     */
+    public Builder setCredentials(Credentials credentials) {
+      Preconditions.checkState(
+          this.credentialsFile == null, "Cannot set both credentials and credentialsFile");
+      this.credentials = Preconditions.checkNotNull(credentials);
+      return this;
+    }
+
+    /**
+     * Require PostgreSQL clients that connect to PGAdapter to authenticate. The PostgreSQL client
+     * should provide the serialized credentials that PGAdapter should use to connect to Cloud
+     * Spanner as a password.
+     *
+     * <p>See <a
+     * href="https://github.com/GoogleCloudPlatform/pgadapter/blob/postgresql-dialect/docs/authentication.md">authentication.md</a>
+     * for more information.
+     */
+    public Builder setRequireAuthentication() {
+      this.requireAuthentication = true;
+      return this;
+    }
+
+    /**
+     * PGAdapter by default only allows connections from localhost. Call this method to disable this
+     * check. You should only allow connections from private networks, unless you are also using SSL
+     * and require clients to authenticate.
+     */
+    public Builder setDisableLocalhostCheck() {
+      this.skipLocalhostCheck = true;
+      return this;
+    }
+
+    /**
+     * PGAdapter by default does not support SSL connections. Call this method to allow or require
+     * SSL connections.
+     *
+     * <p>See <a
+     * href="https://github.com/GoogleCloudPlatform/pgadapter/blob/postgresql-dialect/docs/ssl.md">SSL
+     * options</a> for more information on setting up SSL for PGAdapter.
+     */
+    public Builder setSslMode(SslMode sslMode) {
+      this.sslMode = Preconditions.checkNotNull(sslMode);
+      return this;
+    }
+
+    /**
+     * (Optional) Sets the port that PGAdapter should use to listen for incoming connections. The
+     * default is 0, which dynamically assigns an available port to PGAdapter. The port that was
+     * assigned can be retrieved by calling {@link ProxyServer#getLocalPort()} after the server has
+     * started.
+     */
+    public Builder setPort(int port) {
+      Preconditions.checkArgument(port >= 0, "Port must be >= 0");
+      this.port = port;
+      return this;
+    }
+
+    /**
+     * (Optional) The directory to use for Unix domain socket connections. The default is '/tmp'.
+     */
+    public Builder setUnixDomainSocketDirectory(String directory) {
+      this.unixDomainSocketDirectory = Preconditions.checkNotNull(directory);
+      return this;
+    }
+
+    /**
+     * Disables the use of Unix domain sockets. This is necessary on systems that do not support
+     * Unix domain sockets, or if you do not intend to use Unix domain sockets to connect to
+     * PGAdapter.
+     */
+    public Builder disableUnixDomainSockets() {
+      this.unixDomainSocketDirectory = "";
+      return this;
+    }
+
+    Builder enableDebugMode() {
+      this.debugMode = true;
+      return this;
+    }
+
+    Builder setEndpoint(String endpoint) {
+      this.endpoint = endpoint;
+      return this;
+    }
+
+    Builder setUsePlainText() {
+      this.usePlainText = true;
+      return this;
+    }
+
+    public OptionsMetadata build() {
+      if (Strings.isNullOrEmpty(project) && !Strings.isNullOrEmpty(instance)) {
+        throw SpannerExceptionFactory.newSpannerException(
+            ErrorCode.INVALID_ARGUMENT,
+            "You must also specify a project if you specify an instance that PGAdapter should connect to.");
+      }
+      if (Strings.isNullOrEmpty(instance) && !Strings.isNullOrEmpty(database)) {
+        throw SpannerExceptionFactory.newSpannerException(
+            ErrorCode.INVALID_ARGUMENT,
+            "You must also specify an instance if you specify a database that PGAdapter should connect to.");
+      }
+      if (!(Strings.isNullOrEmpty(credentialsFile)
+              && (credentials == null || NoCredentials.getInstance().equals(credentials)))
+          && requireAuthentication) {
+        throw SpannerExceptionFactory.newSpannerException(
+            ErrorCode.INVALID_ARGUMENT,
+            "Cannot set both a credentialsFile and requireAuthentication. If you set requireAuthentication, the PostgreSQL client that connects to PGAdapter must supply the credentials in the password message.");
+      }
+      return new OptionsMetadata(this);
+    }
+
+    private String[] toCommandLineArguments() {
+      ImmutableList.Builder<String> args = ImmutableList.builder();
+      if (!Strings.isNullOrEmpty(project)) {
+        addOption(args, OPTION_PROJECT_ID, project);
+      }
+      if (!Strings.isNullOrEmpty(instance)) {
+        addOption(args, OPTION_INSTANCE_ID, instance);
+      }
+      if (!Strings.isNullOrEmpty(database)) {
+        addOption(args, OPTION_DATABASE_NAME, database);
+      }
+      if (!Strings.isNullOrEmpty(credentialsFile)) {
+        addOption(args, OPTION_CREDENTIALS_FILE, credentialsFile);
+      }
+      if (requireAuthentication) {
+        addOption(args, OPTION_AUTHENTICATE);
+      }
+      if (skipLocalhostCheck) {
+        addOption(args, OPTION_DISABLE_LOCALHOST_CHECK);
+      }
+      if (sslMode != null) {
+        addLongOption(args, OPTION_SSL, sslMode.name());
+      }
+      if (ddlTransactionMode != null) {
+        addLongOption(args, OPTION_DDL_TRANSACTION_MODE, ddlTransactionMode.name());
+      }
+      if (unixDomainSocketDirectory != null) {
+        addLongOption(args, OPTION_SOCKET_DIR, unixDomainSocketDirectory);
+      }
+      if (endpoint != null) {
+        addOption(args, OPTION_SPANNER_ENDPOINT, endpoint);
+      }
+      if (usePlainText || numChannels != null || databaseRole != null) {
+        StringBuilder jdbcOptionBuilder = new StringBuilder();
+        if (usePlainText) {
+          jdbcOptionBuilder.append("usePlainText=true;");
+        }
+        if (numChannels != null) {
+          jdbcOptionBuilder.append("numChannels=").append(numChannels).append(";");
+        }
+        if (databaseRole != null) {
+          jdbcOptionBuilder.append("databaseRole=").append(databaseRole).append(";");
+        }
+        addOption(args, OPTION_JDBC_PROPERTIES, jdbcOptionBuilder.toString());
+      }
+      if (debugMode) {
+        addOption(args, OPTION_INTERNAL_DEBUG_MODE);
+        addOption(args, OPTION_SKIP_INTERNAL_DEBUG_MODE_WARNING);
+      }
+      addOption(args, OPTION_SERVER_PORT, String.valueOf(port));
+      return args.build().toArray(new String[0]);
+    }
+  }
+
+  private static void addOption(ImmutableList.Builder<String> builder, String option) {
+    builder.add("-" + option);
+  }
+
+  private static void addOption(
+      ImmutableList.Builder<String> builder, String option, String value) {
+    builder.add("-" + option, value);
+  }
+
+  private static void addLongOption(
+      ImmutableList.Builder<String> builder, String option, String value) {
+    builder.add("-" + option + "=" + value);
+  }
+
+  /** Creates a {@link Builder} for an {@link OptionsMetadata} instance. */
+  public static Builder newBuilder() {
+    return new Builder();
+  }
+
   /** Returns true if the current JVM is Java 8. */
   public static boolean isJava8() {
     return System.getProperty("java.version").startsWith("1.8");
@@ -130,6 +467,8 @@ public class OptionsMetadata {
 
   private final String osName;
   private final CommandLine commandLine;
+  private final Credentials credentials;
+  private final SessionPoolOptions sessionPoolOptions;
   private final CommandMetadataParser commandMetadataParser;
   private final String defaultConnectionUrl;
   private final int proxyPort;
@@ -151,13 +490,37 @@ public class OptionsMetadata {
   private final String serverVersion;
   private final boolean debugMode;
 
+  /**
+   * Creates a new instance of {@link OptionsMetadata} from the given arguments.
+   *
+   * <p>It is recommended to use {@link #newBuilder()} to create an options {@link Builder} instead
+   * of calling this method directly.
+   */
   public OptionsMetadata(String[] args) {
     this(System.getProperty("os.name", ""), args);
   }
 
+  private OptionsMetadata(Builder builder) {
+    this(
+        System.getProperty("os.name", ""),
+        builder.toCommandLineArguments(),
+        builder.credentials,
+        builder.sessionPoolOptions);
+  }
+
   OptionsMetadata(String osName, String[] args) {
+    this(osName, args, null, null);
+  }
+
+  OptionsMetadata(
+      String osName,
+      String[] args,
+      @Nullable Credentials credentials,
+      @Nullable SessionPoolOptions sessionPoolOptions) {
     this.osName = osName;
     this.commandLine = buildOptions(args);
+    this.credentials = credentials;
+    this.sessionPoolOptions = sessionPoolOptions;
     this.commandMetadataParser = new CommandMetadataParser();
     if (this.commandLine.hasOption(OPTION_AUTHENTICATE)
         && this.commandLine.hasOption(OPTION_CREDENTIALS_FILE)
@@ -210,6 +573,11 @@ public class OptionsMetadata {
     this.debugMode = commandLine.hasOption(OPTION_INTERNAL_DEBUG_MODE);
   }
 
+  /**
+   * @deprecated Use {@link #newBuilder()} to create an options builder, and then call {@link
+   *     Builder#build()} instead of using this constructor.
+   */
+  @Deprecated
   public OptionsMetadata(
       String defaultConnectionUrl,
       int proxyPort,
@@ -231,6 +599,7 @@ public class OptionsMetadata {
         commandMetadata);
   }
 
+  @VisibleForTesting
   OptionsMetadata(
       String osName,
       String defaultConnectionUrl,
@@ -243,6 +612,8 @@ public class OptionsMetadata {
       JSONObject commandMetadata) {
     this.osName = osName;
     this.commandLine = null;
+    this.credentials = null;
+    this.sessionPoolOptions = null;
     this.commandMetadataParser = new CommandMetadataParser();
     this.defaultConnectionUrl = defaultConnectionUrl;
     this.proxyPort = proxyPort;
@@ -347,12 +718,32 @@ public class OptionsMetadata {
   }
 
   /**
-   * Get credential file path from either command line or application default. If neither throw
-   * error.
+   * Returns the {@link Credentials} instance that has been set for this {@link OptionsMetadata}.
+   * This overrides both any credentials file and any default credentials in the current runtime
+   * environment.
+   */
+  @Nullable
+  public Credentials getCredentials() {
+    return this.credentials;
+  }
+
+  /** Returns the {@link SessionPoolOptions} that has been set for this {@link OptionsMetadata}. */
+  @Nullable
+  public SessionPoolOptions getSessionPoolOptions() {
+    return this.sessionPoolOptions;
+  }
+
+  /**
+   * Get credential file path from either command line or application default. If neither are set,
+   * then throw an error.
    *
    * @return The absolute path of the credentials file.
    */
   public String buildCredentialsFile() {
+    // Skip if a com.google.auth.Credentials instance has been set.
+    if (credentials != null) {
+      return null;
+    }
     if (!commandLine.hasOption(OPTION_CREDENTIALS_FILE)) {
       try {
         // This will throw an IOException if no default credentials are available.


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/AbstractMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/AbstractMockServerTest.java
index 8d2e4928..b14f201e 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/AbstractMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/AbstractMockServerTest.java
@@ -19,6 +19,7 @@ import static com.google.cloud.spanner.pgadapter.statements.PgCatalog.PgNamespac
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
+import com.google.cloud.NoCredentials;
 import com.google.cloud.spanner.Dialect;
 import com.google.cloud.spanner.ErrorCode;
 import com.google.cloud.spanner.MockSpannerServiceImpl;
@@ -29,7 +30,7 @@ import com.google.cloud.spanner.Statement;
 import com.google.cloud.spanner.admin.database.v1.MockDatabaseAdminImpl;
 import com.google.cloud.spanner.admin.instance.v1.MockInstanceAdminImpl;
 import com.google.cloud.spanner.connection.SpannerPool;
-import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata;
+import com.google.cloud.spanner.pgadapter.metadata.TestOptionsMetadataBuilder;
 import com.google.cloud.spanner.pgadapter.statements.PgCatalog.PgAttrdef;
 import com.google.cloud.spanner.pgadapter.statements.PgCatalog.PgAttribute;
 import com.google.cloud.spanner.pgadapter.statements.PgCatalog.PgClass;
@@ -72,9 +73,9 @@ import java.nio.charset.StandardCharsets;
 import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.Base64;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
 import java.util.logging.Logger;
 import java.util.stream.Collectors;
 import org.junit.AfterClass;
@@ -936,13 +937,14 @@ public abstract class AbstractMockServerTest {
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
     doStartMockSpannerAndPgAdapterServers(
-        createMockSpannerThatReturnsOneQueryPartition(), "d", Collections.emptyList());
+        createMockSpannerThatReturnsOneQueryPartition(), "d", configurator -> {});
   }
 
   protected static void doStartMockSpannerAndPgAdapterServers(
-      String defaultDatabase, Iterable<String> extraPGAdapterOptions) throws Exception {
+      String defaultDatabase, Consumer<TestOptionsMetadataBuilder> optionsConfigurator)
+      throws Exception {
     doStartMockSpannerAndPgAdapterServers(
-        createMockSpannerThatReturnsOneQueryPartition(), defaultDatabase, extraPGAdapterOptions);
+        createMockSpannerThatReturnsOneQueryPartition(), defaultDatabase, optionsConfigurator);
   }
 
   public static PGobject createJdbcPgJsonbObject(String value) throws SQLException {
@@ -955,7 +957,7 @@ public abstract class AbstractMockServerTest {
   protected static void doStartMockSpannerAndPgAdapterServers(
       MockSpannerServiceImpl mockSpannerService,
       String defaultDatabase,
-      Iterable<String> extraPGAdapterOptions)
+      Consumer<TestOptionsMetadataBuilder> optionsConfigurator)
       throws Exception {
     mockSpanner = mockSpannerService;
     mockSpanner.setAbortProbability(0.0D); // We don't want any unpredictable aborted transactions.
@@ -1023,25 +1025,18 @@ public abstract class AbstractMockServerTest {
             .build()
             .start();
 
-    ImmutableList.Builder<String> argsListBuilder =
-        ImmutableList.<String>builder().add("-p", "p", "-i", "i");
+    TestOptionsMetadataBuilder builder = new TestOptionsMetadataBuilder();
+    builder.setProject("p").setInstance("i");
     if (defaultDatabase != null) {
-      argsListBuilder.add("-d", defaultDatabase);
+      builder.setDatabase("d");
     }
-    argsListBuilder.add(
-        "-internal_debug",
-        "-skip_internal_debug_warning",
-        "-c",
-        "", // empty credentials file, as we are using a plain text connection.
-        "-s",
-        "0", // port 0 to let the OS pick an available port
-        "-e",
-        String.format("localhost:%d", spannerServer.getPort()),
-        "-r",
-        "usePlainText=true;");
-    argsListBuilder.addAll(extraPGAdapterOptions);
-    String[] args = argsListBuilder.build().toArray(new String[0]);
-    pgServer = new ProxyServer(new OptionsMetadata(args));
+    builder
+        .enableDebugMode()
+        .setUsePlainText()
+        .setEndpoint(String.format("localhost:%d", spannerServer.getPort()))
+        .setCredentials(NoCredentials.getInstance());
+    optionsConfigurator.accept(builder);
+    pgServer = new ProxyServer(builder.build());
     pgServer.startServer();
   }
 
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/AuthMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/AuthMockServerTest.java
index 0106cebb..7a887813 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/AuthMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/AuthMockServerTest.java
@@ -21,8 +21,8 @@ import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.Mockito.mock;
 
+import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.Builder;
 import com.google.cloud.spanner.pgadapter.wireprotocol.PasswordMessage;
-import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.security.KeyPair;
@@ -59,7 +59,7 @@ public class AuthMockServerTest extends AbstractMockServerTest {
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
     // Start PGAdapter with authentication mode enabled.
-    doStartMockSpannerAndPgAdapterServers("d", ImmutableList.of("-a"));
+    doStartMockSpannerAndPgAdapterServers("d", Builder::setRequireAuthentication);
   }
 
   private String createUrl() {
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/CopyOutMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/CopyOutMockServerTest.java
index 7cbe34b0..e43e2cd7 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/CopyOutMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/CopyOutMockServerTest.java
@@ -54,7 +54,6 @@ import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.ResultSet;
 import java.sql.SQLException;
-import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Scanner;
@@ -87,7 +86,7 @@ public class CopyOutMockServerTest extends AbstractMockServerTest {
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
     doStartMockSpannerAndPgAdapterServers(
-        createMockSpannerServiceWithQueryPartitions(), "d", Collections.emptyList());
+        createMockSpannerServiceWithQueryPartitions(), "d", builder -> {});
   }
 
   private static MockSpannerServiceImpl createMockSpannerServiceWithQueryPartitions() {
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/CursorMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/CursorMockServerTest.java
index c9cb003e..07ba17ce 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/CursorMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/CursorMockServerTest.java
@@ -23,7 +23,6 @@ import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
 import com.google.cloud.spanner.RandomResultSetGenerator;
 import com.google.cloud.spanner.Statement;
 import com.google.cloud.spanner.pgadapter.error.SQLState;
-import com.google.common.collect.ImmutableList;
 import com.google.spanner.v1.BeginTransactionRequest;
 import com.google.spanner.v1.ExecuteSqlRequest;
 import java.sql.Connection;
@@ -52,7 +51,7 @@ public class CursorMockServerTest extends AbstractMockServerTest {
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
     // Start PGAdapter without a default database.
-    doStartMockSpannerAndPgAdapterServers(null, ImmutableList.of());
+    doStartMockSpannerAndPgAdapterServers(null, builder -> {});
   }
 
   @After
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeAutocommitExplicitTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeAutocommitExplicitTest.java
index a594d1fc..f8c1e6fa 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeAutocommitExplicitTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeAutocommitExplicitTest.java
@@ -21,6 +21,7 @@ import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.DdlTransactionMode;
 import com.google.protobuf.AbstractMessage;
 import com.google.spanner.admin.database.v1.UpdateDatabaseDdlRequest;
 import com.google.spanner.v1.BatchCreateSessionsRequest;
@@ -33,7 +34,6 @@ import java.sql.DriverManager;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
-import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
 import org.junit.BeforeClass;
@@ -47,7 +47,8 @@ public class DdlTransactionModeAutocommitExplicitTest
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
     doStartMockSpannerAndPgAdapterServers(
-        "d", Collections.singleton("-ddl=AutocommitExplicitTransaction"));
+        "d",
+        builder -> builder.setDdlTransactionMode(DdlTransactionMode.AutocommitExplicitTransaction));
   }
 
   @Test
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeAutocommitImplicitTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeAutocommitImplicitTest.java
index 459133bd..4915721c 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeAutocommitImplicitTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeAutocommitImplicitTest.java
@@ -21,6 +21,7 @@ import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.DdlTransactionMode;
 import com.google.spanner.admin.database.v1.UpdateDatabaseDdlRequest;
 import com.google.spanner.v1.BeginTransactionRequest;
 import com.google.spanner.v1.CommitRequest;
@@ -32,7 +33,6 @@ import java.sql.DriverManager;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
-import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
 import org.junit.BeforeClass;
@@ -45,7 +45,8 @@ public class DdlTransactionModeAutocommitImplicitTest extends DdlTransactionMode
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
     doStartMockSpannerAndPgAdapterServers(
-        "d", Collections.singleton("-ddl=AutocommitImplicitTransaction"));
+        "d",
+        builder -> builder.setDdlTransactionMode(DdlTransactionMode.AutocommitImplicitTransaction));
   }
 
   @Test
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeBatchTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeBatchTest.java
index 02d59eb6..82b9f2e9 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeBatchTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeBatchTest.java
@@ -20,6 +20,7 @@ import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.DdlTransactionMode;
 import com.google.spanner.v1.BeginTransactionRequest;
 import com.google.spanner.v1.CommitRequest;
 import com.google.spanner.v1.RollbackRequest;
@@ -27,7 +28,6 @@ import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.SQLException;
 import java.sql.Statement;
-import java.util.Collections;
 import org.junit.BeforeClass;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -38,7 +38,8 @@ public class DdlTransactionModeBatchTest extends DdlTransactionModeNoneTest {
 
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
-    doStartMockSpannerAndPgAdapterServers("d", Collections.singleton("-ddl=Batch"));
+    doStartMockSpannerAndPgAdapterServers(
+        "d", builder -> builder.setDdlTransactionMode(DdlTransactionMode.Batch));
   }
 
   @Override
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeNoneTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeNoneTest.java
index 9152f6cf..fb49512a 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeNoneTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/DdlTransactionModeNoneTest.java
@@ -19,13 +19,13 @@ import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.DdlTransactionMode;
 import com.google.spanner.admin.database.v1.UpdateDatabaseDdlRequest;
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
 import org.junit.BeforeClass;
@@ -57,7 +57,8 @@ public class DdlTransactionModeNoneTest extends AbstractMockServerTest {
 
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
-    doStartMockSpannerAndPgAdapterServers("d", Collections.singleton("-ddl=Single"));
+    doStartMockSpannerAndPgAdapterServers(
+        "d", builder -> builder.setDdlTransactionMode(DdlTransactionMode.Single));
   }
 
   protected String createUrl() {
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/DomainSocketsTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/DomainSocketsTest.java
index 75b6c060..0ca13f19 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/DomainSocketsTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/DomainSocketsTest.java
@@ -20,7 +20,6 @@ import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assume.assumeFalse;
 
-import com.google.common.collect.ImmutableList;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -28,7 +27,6 @@ import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.ResultSet;
 import java.sql.SQLException;
-import java.util.Collections;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -65,7 +63,7 @@ public class DomainSocketsTest extends AbstractMockServerTest {
 
   @Test
   public void testDefaultDomainSocketFile() throws Exception {
-    doStartMockSpannerAndPgAdapterServers(null, Collections.emptyList());
+    doStartMockSpannerAndPgAdapterServers(null, builder -> {});
 
     String sql = "SELECT 1";
 
@@ -82,7 +80,8 @@ public class DomainSocketsTest extends AbstractMockServerTest {
 
   @Test
   public void testCustomDomainSocketFile() throws Exception {
-    doStartMockSpannerAndPgAdapterServers(null, ImmutableList.of("-dir", "./"));
+    doStartMockSpannerAndPgAdapterServers(
+        null, builder -> builder.setUnixDomainSocketDirectory("./"));
 
     String sql = "SELECT 1";
 
@@ -99,7 +98,8 @@ public class DomainSocketsTest extends AbstractMockServerTest {
 
   @Test
   public void testConnectionFailsForIncorrectDomainSocketFile() throws Exception {
-    doStartMockSpannerAndPgAdapterServers(null, ImmutableList.of("-dir", "./sockets"));
+    doStartMockSpannerAndPgAdapterServers(
+        null, builder -> builder.setUnixDomainSocketDirectory("./sockets"));
 
     assertThrows(SQLException.class, () -> DriverManager.getConnection(createUrl("./.s.PGSQL.%d")));
 
@@ -118,7 +118,8 @@ public class DomainSocketsTest extends AbstractMockServerTest {
     assumeFalse(
         "Skip test if the test container allows creating files in the root directory", canCreate);
 
-    doStartMockSpannerAndPgAdapterServers(null, ImmutableList.of("-dir", "/"));
+    doStartMockSpannerAndPgAdapterServers(
+        null, builder -> builder.setUnixDomainSocketDirectory("/"));
 
     // Verify that we cannot connect to the invalid (not permitted) domain socket.
     assertThrows(SQLException.class, () -> DriverManager.getConnection(createUrl("/.s.PGSQL.%d")));
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/EmulatedPsqlMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/EmulatedPsqlMockServerTest.java
index 86d53ee7..31d593e7 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/EmulatedPsqlMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/EmulatedPsqlMockServerTest.java
@@ -79,7 +79,7 @@ public class EmulatedPsqlMockServerTest extends AbstractMockServerTest {
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
     // Start PGAdapter without a default database.
-    doStartMockSpannerAndPgAdapterServers(null, ImmutableList.of());
+    doStartMockSpannerAndPgAdapterServers(null, builder -> {});
 
     mockSpanner.putStatementResults(
         StatementResult.update(Statement.of(INSERT1), 1L),
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcBatchMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcBatchMockServerTest.java
index 4523e827..098fb911 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcBatchMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcBatchMockServerTest.java
@@ -32,7 +32,6 @@ import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
 import org.junit.BeforeClass;
@@ -52,7 +51,7 @@ public class JdbcBatchMockServerTest extends AbstractMockServerTest {
 
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
-    doStartMockSpannerAndPgAdapterServers(null, Collections.emptyList());
+    doStartMockSpannerAndPgAdapterServers(null, builder -> {});
   }
 
   @Parameter public String preferQueryMode;
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcSimpleModeMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcSimpleModeMockServerTest.java
index 7aaf8104..ded38fe6 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcSimpleModeMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcSimpleModeMockServerTest.java
@@ -44,7 +44,6 @@ import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.OffsetDateTime;
 import java.time.ZoneOffset;
-import java.util.Collections;
 import java.util.List;
 import java.util.TimeZone;
 import java.util.stream.Collectors;
@@ -70,7 +69,7 @@ public class JdbcSimpleModeMockServerTest extends AbstractMockServerTest {
 
   @BeforeClass
   public static void startMockSpannerAndPgAdapterServers() throws Exception {
-    doStartMockSpannerAndPgAdapterServers(null, Collections.emptyList());
+    doStartMockSpannerAndPgAdapterServers(null, builder -> {});
   }
 
   @Parameter public boolean useDomainSocket;
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/PsqlMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/PsqlMockServerTest.java
index cdf27b55..53b03af0 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/PsqlMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/PsqlMockServerTest.java
@@ -25,6 +25,7 @@ import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
 import com.google.cloud.spanner.Statement;
 import com.google.cloud.spanner.Statement.Builder;
 import com.google.cloud.spanner.ValueBinder;
+import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.DdlTransactionMode;
 import com.google.cloud.spanner.pgadapter.statements.CopyToStatement;
 import com.google.cloud.spanner.pgadapter.wireprotocol.SSLMessage;
 import com.google.common.collect.ImmutableList;
@@ -63,7 +64,7 @@ public class PsqlMockServerTest extends AbstractMockServerTest {
     doStartMockSpannerAndPgAdapterServers(
         createMockSpannerThatReturnsOneQueryPartition(),
         "d",
-        ImmutableList.of("-ddl=AutocommitExplicitTransaction"));
+        builder -> builder.setDdlTransactionMode(DdlTransactionMode.AutocommitExplicitTransaction));
   }
 
   private static boolean isPsqlAvailable() {
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/SSLMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/SSLMockServerTest.java
index 06211db4..19320769 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/SSLMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/SSLMockServerTest.java
@@ -20,8 +20,8 @@ import static org.junit.Assert.assertTrue;
 import static org.junit.Assume.assumeTrue;
 
 import com.google.cloud.spanner.MockSpannerServiceImpl;
+import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.SslMode;
 import com.google.cloud.spanner.pgadapter.wireprotocol.SSLMessage;
-import com.google.common.collect.ImmutableList;
 import java.io.BufferedReader;
 import java.io.File;
 import java.io.InputStreamReader;
@@ -52,7 +52,7 @@ public class SSLMockServerTest extends AbstractMockServerTest {
     System.setProperty("javax.net.ssl.keyStorePassword", "password");
 
     doStartMockSpannerAndPgAdapterServers(
-        new MockSpannerServiceImpl(), "d", ImmutableList.of("-ssl", "require"));
+        new MockSpannerServiceImpl(), "d", builder -> builder.setSslMode(SslMode.Require));
   }
 
   @AfterClass
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/SessionPoolSettingsTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/SessionPoolSettingsTest.java
new file mode 100644
index 00000000..50b06a9a
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/SessionPoolSettingsTest.java
@@ -0,0 +1,73 @@
+// Copyright 2023 Google LLC
+//
+// Licensed 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.google.cloud.spanner.pgadapter;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.cloud.spanner.SessionPoolOptions;
+import com.google.spanner.v1.BatchCreateSessionsRequest;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class SessionPoolSettingsTest extends AbstractMockServerTest {
+  @BeforeClass
+  public static void loadPgJdbcDriver() throws Exception {
+    // Make sure the PG JDBC driver is loaded.
+    Class.forName("org.postgresql.Driver");
+  }
+
+  @BeforeClass
+  public static void startMockSpannerAndPgAdapterServers() throws Exception {
+    doStartMockSpannerAndPgAdapterServers(
+        null,
+        builder ->
+            builder
+                .setSessionPoolOptions(
+                    SessionPoolOptions.newBuilder().setMinSessions(20).setMaxSessions(100).build())
+                .setNumChannels(5));
+  }
+
+  private String createUrl() {
+    return String.format("jdbc:postgresql://localhost:%d/my-db", pgServer.getLocalPort());
+  }
+
+  @Test
+  public void testSessionPoolInitialization() throws SQLException {
+    String sql = "SELECT 1";
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      try (ResultSet resultSet = connection.createStatement().executeQuery(sql)) {
+        assertTrue(resultSet.next());
+        assertEquals(1L, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+    }
+    // Check that the session pool options are honored.
+    assertEquals(5, mockSpanner.countRequestsOfType(BatchCreateSessionsRequest.class));
+    for (BatchCreateSessionsRequest createSessionsRequest :
+        mockSpanner.getRequestsOfType(BatchCreateSessionsRequest.class)) {
+      assertEquals(4, createSessionsRequest.getSessionCount());
+    }
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/metadata/OptionsMetadataTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/metadata/OptionsMetadataTest.java
index 47b8b440..e4d66383 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/metadata/OptionsMetadataTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/metadata/OptionsMetadataTest.java
@@ -18,11 +18,19 @@ import static com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.parseS
 import static com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.toServerVersionNum;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import com.google.auth.oauth2.AccessToken;
+import com.google.auth.oauth2.OAuth2Credentials;
+import com.google.cloud.NoCredentials;
+import com.google.cloud.spanner.DatabaseId;
 import com.google.cloud.spanner.ErrorCode;
+import com.google.cloud.spanner.InstanceId;
+import com.google.cloud.spanner.SessionPoolOptions;
 import com.google.cloud.spanner.SpannerException;
+import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.DdlTransactionMode;
 import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata.SslMode;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
@@ -276,4 +284,216 @@ public class OptionsMetadataTest {
     assertTrue(SslMode.Enable.isSslEnabled());
     assertTrue(SslMode.Require.isSslEnabled());
   }
+
+  @Test
+  public void testBuilder() {
+    assertFalse(
+        OptionsMetadata.newBuilder()
+            .setProject("my-project")
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .hasDefaultInstanceId());
+    assertEquals(
+        InstanceId.of("my-project", "my-instance"),
+        OptionsMetadata.newBuilder()
+            .setProject("my-project")
+            .setInstance("my-instance")
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getDefaultInstanceId());
+    assertEquals(
+        DatabaseId.of("my-project", "my-instance", "my-database"),
+        OptionsMetadata.newBuilder()
+            .setProject("my-project")
+            .setInstance("my-instance")
+            .setDatabase("my-database")
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getDefaultDatabaseId());
+    assertEquals(
+        "/path/to/credentials.json",
+        OptionsMetadata.newBuilder()
+            .setCredentialsFile("/path/to/credentials.json")
+            .build()
+            .buildCredentialsFile());
+    assertNull(OptionsMetadata.newBuilder().build().getCredentials());
+    assertEquals(
+        NoCredentials.getInstance(),
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getCredentials());
+    assertNull(
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getSessionPoolOptions());
+    assertEquals(
+        SessionPoolOptions.newBuilder().setMinSessions(500).setMaxSessions(1000).build(),
+        OptionsMetadata.newBuilder()
+            .setSessionPoolOptions(
+                SessionPoolOptions.newBuilder().setMinSessions(500).setMaxSessions(1000).build())
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getSessionPoolOptions());
+    assertNull(
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getPropertyMap()
+            .get("numChannels"));
+    assertEquals(
+        "4",
+        OptionsMetadata.newBuilder()
+            .setNumChannels(4)
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getPropertyMap()
+            .get("numChannels"));
+    assertEquals(
+        "16",
+        OptionsMetadata.newBuilder()
+            .setNumChannels(16)
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getPropertyMap()
+            .get("numChannels"));
+    assertNull(
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getPropertyMap()
+            .get("databaseRole"));
+    assertEquals(
+        "my-role",
+        OptionsMetadata.newBuilder()
+            .setDatabaseRole("my-role")
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getPropertyMap()
+            .get("databaseRole"));
+    assertNull(
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getPropertyMap()
+            .get("usePlainText"));
+    assertEquals(
+        "true",
+        OptionsMetadata.newBuilder()
+            .setUsePlainText()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getPropertyMap()
+            .get("usePlainText"));
+    assertEquals(
+        DdlTransactionMode.Batch,
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getDdlTransactionMode());
+    assertEquals(
+        DdlTransactionMode.AutocommitImplicitTransaction,
+        OptionsMetadata.newBuilder()
+            .setDdlTransactionMode(DdlTransactionMode.AutocommitImplicitTransaction)
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getDdlTransactionMode());
+    assertFalse(
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .shouldAuthenticate());
+    assertTrue(
+        OptionsMetadata.newBuilder()
+            .setRequireAuthentication()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .shouldAuthenticate());
+    assertFalse(
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .disableLocalhostCheck());
+    assertTrue(
+        OptionsMetadata.newBuilder()
+            .setDisableLocalhostCheck()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .disableLocalhostCheck());
+    assertEquals(
+        SslMode.Disable,
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getSslMode());
+    assertEquals(
+        SslMode.Require,
+        OptionsMetadata.newBuilder()
+            .setSslMode(SslMode.Require)
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getSslMode());
+    assertEquals(
+        0,
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getProxyPort());
+    assertEquals(
+        9999,
+        OptionsMetadata.newBuilder()
+            .setPort(9999)
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getProxyPort());
+    boolean isWindows =
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .isWindows();
+    assertEquals(
+        isWindows ? "" : "/tmp/.s.PGSQL.9999",
+        OptionsMetadata.newBuilder()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getSocketFile(9999));
+    if (!isWindows) {
+      assertEquals(
+          "/var/pg/.s.PGSQL.5432",
+          OptionsMetadata.newBuilder()
+              .setUnixDomainSocketDirectory("/var/pg")
+              .setCredentials(NoCredentials.getInstance())
+              .build()
+              .getSocketFile(5432));
+    }
+    assertEquals(
+        "",
+        OptionsMetadata.newBuilder()
+            .disableUnixDomainSockets()
+            .setCredentials(NoCredentials.getInstance())
+            .build()
+            .getSocketFile(5432));
+
+    assertThrows(
+        SpannerException.class,
+        () -> OptionsMetadata.newBuilder().setInstance("my-instance").build());
+    assertThrows(
+        SpannerException.class,
+        () -> OptionsMetadata.newBuilder().setDatabase("my-database").build());
+    assertThrows(
+        SpannerException.class,
+        () ->
+            OptionsMetadata.newBuilder()
+                .setCredentialsFile("/path/to/credentials.json")
+                .setRequireAuthentication()
+                .build());
+    assertThrows(
+        SpannerException.class,
+        () ->
+            OptionsMetadata.newBuilder()
+                .setCredentials(OAuth2Credentials.create(AccessToken.newBuilder().build()))
+                .setRequireAuthentication()
+                .build());
+  }
 }
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/metadata/TestOptionsMetadataBuilder.java b/src/test/java/com/google/cloud/spanner/pgadapter/metadata/TestOptionsMetadataBuilder.java
new file mode 100644
index 00000000..843c890f
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/metadata/TestOptionsMetadataBuilder.java
@@ -0,0 +1,35 @@
+// Copyright 2023 Google LLC
+//
+// Licensed 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.google.cloud.spanner.pgadapter.metadata;
+
+public class TestOptionsMetadataBuilder extends OptionsMetadata.Builder {
+
+  @Override
+  public TestOptionsMetadataBuilder enableDebugMode() {
+    super.enableDebugMode();
+    return this;
+  }
+
+  @Override
+  public TestOptionsMetadataBuilder setEndpoint(String endpoint) {
+    super.setEndpoint(endpoint);
+    return this;
+  }
+
+  public TestOptionsMetadataBuilder setUsePlainText() {
+    super.setUsePlainText();
+    return this;
+  }
+}