Repository: googleapis/java-datastore
Pull request #1365 (2024-03-13 14:29:39)

feat: implement query profile


Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index daa58f7e..3a7c9a45 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -29,5 +29,3 @@ updates:
       prefix: "deps"
     open-pull-requests-limit: 10
     target-branch: "V3-experimental"
-    labels:
-      - "V3 dependencies"
diff --git a/google-cloud-datastore/clirr-ignored-differences.xml b/google-cloud-datastore/clirr-ignored-differences.xml
index 34395cc0..1620fd75 100644
--- a/google-cloud-datastore/clirr-ignored-differences.xml
+++ b/google-cloud-datastore/clirr-ignored-differences.xml
@@ -1,16 +1,58 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!-- see http://mojo.codehaus.org/clirr-maven-plugin/examples/ignored-differences.html -->
 <differences>
-    <!-- renaming internal only class-->
+    <!--  added a default method to an interface-->
     <difference>
-        <className>com/google/cloud/datastore/ReadOption$QueryAndReadOptions</className>
-        <method>*</method>
-        <differenceType>8001</differenceType>
+        <className>com/google/cloud/datastore/Datastore</className>
+        <method>com.google.cloud.datastore.QueryResults run(com.google.cloud.datastore.Query, com.google.cloud.datastore.models.ExplainOptions, com.google.cloud.datastore.ReadOption[])</method>
+        <differenceType>7012</differenceType>
     </difference>
     <difference>
-        <className>com/google/cloud/datastore/execution/request/AggregationQueryRequestProtoPreparer</className>
-        <method>*QueryAndReadOptions*</method>
-        <to>*QueryConfig*</to>
+        <className>com/google/cloud/datastore/Datastore</className>
+        <method>com.google.cloud.datastore.AggregationResults runAggregation(com.google.cloud.datastore.AggregationQuery, com.google.cloud.datastore.models.ExplainOptions, com.google.cloud.datastore.ReadOption[])</method>
+        <differenceType>7012</differenceType>
+    </difference>
+    <difference>
+        <className>com/google/cloud/datastore/DatastoreReader</className>
+        <method> com.google.cloud.datastore.AggregationResults runAggregation(com.google.cloud.datastore.AggregationQuery, com.google.cloud.datastore.models.ExplainOptions)</method>
+        <differenceType>7012</differenceType>
+    </difference>
+    <difference>
+        <className>com/google/cloud/datastore/QueryResults</className>
+        <method>java.util.Optional getExplainMetrics()</method>
+        <differenceType>7012</differenceType>
+    </difference>
+    <difference>
+        <className>com/google/cloud/datastore/Transaction</className>
+        <method>com.google.cloud.datastore.QueryResults run(com.google.cloud.datastore.Query, com.google.cloud.datastore.models.ExplainOptions)</method>
+        <differenceType>7012</differenceType>
+    </difference>
+
+    <!-- changed parmeters on internal only class-->
+    <difference>
+        <className>com/google/cloud/datastore/ReadOption$QueryConfig</className>
+        <method>com.google.cloud.datastore.ReadOption$QueryConfig create(com.google.cloud.datastore.Query, java.util.List)</method>
+        <to>*com.google.datastore.v1.ExplainOptions*</to>
         <differenceType>7005</differenceType>
     </difference>
+    <difference>
+        <className>com/google/cloud/datastore/ReadOption$QueryConfig</className>
+        <method>com.google.cloud.datastore.ReadOption$QueryConfig create(com.google.cloud.datastore.Query)</method>
+        <differenceType>7004</differenceType>
+    </difference>
+    <difference>
+        <className>com/google/cloud/datastore/execution/AggregationQueryExecutor</className>
+        <method>com.google.cloud.datastore.AggregationResults execute(com.google.cloud.datastore.AggregationQuery, com.google.cloud.datastore.ReadOption[])</method>
+        <differenceType>7004</differenceType>
+    </difference>
+    <difference>
+        <className>com/google/cloud/datastore/execution/AggregationQueryExecutor</className>
+        <method>java.lang.Object execute(com.google.cloud.datastore.Query, com.google.cloud.datastore.ReadOption[])</method>
+        <differenceType>7004</differenceType>
+    </difference>
+    <difference>
+        <className>com/google/cloud/datastore/execution/QueryExecutor</className>
+        <method>java.lang.Object execute(com.google.cloud.datastore.Query, com.google.cloud.datastore.ReadOption[])</method>
+        <differenceType>7004</differenceType>
+    </difference>
 </differences>
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/AggregationResults.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/AggregationResults.java
index d85e8f8a..8e40ab8b 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/AggregationResults.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/AggregationResults.java
@@ -17,11 +17,14 @@ package com.google.cloud.datastore;
 
 import static com.google.api.client.util.Preconditions.checkNotNull;
 
+import com.google.api.core.BetaApi;
 import com.google.api.core.InternalApi;
 import com.google.cloud.Timestamp;
+import com.google.cloud.datastore.models.ExplainMetrics;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 
 /**
  * The result of an {@link AggregationQuery} query submission. Contains a List&lt;{@link
@@ -34,11 +37,22 @@ public class AggregationResults implements Iterable<AggregationResult> {
   private final List<AggregationResult> aggregationResults;
   private final Timestamp readTime;
 
-  public AggregationResults(List<AggregationResult> aggregationResults, Timestamp readTime) {
+  private final ExplainMetrics explainMetrics;
+
+  @BetaApi
+  public AggregationResults(
+      List<AggregationResult> aggregationResults,
+      Timestamp readTime,
+      ExplainMetrics explainMetrics) {
     checkNotNull(aggregationResults, "Aggregation results cannot be null");
     checkNotNull(readTime, "readTime cannot be null");
     this.aggregationResults = aggregationResults;
     this.readTime = readTime;
+    this.explainMetrics = explainMetrics;
+  }
+
+  public AggregationResults(List<AggregationResult> aggregationResults, Timestamp readTime) {
+    this(aggregationResults, readTime, null);
   }
 
   /** Returns {@link Iterator} for underlying List&lt;{@link AggregationResult}&gt;. */
@@ -51,6 +65,14 @@ public class AggregationResults implements Iterable<AggregationResult> {
     return this.aggregationResults.size();
   }
 
+  /**
+   * Returns {@code ExplainMetrics} if {@code ExplainOptions} were enabled. Otherwise, returns null.
+   */
+  @BetaApi
+  public Optional<ExplainMetrics> getExplainMetrics() {
+    return Optional.ofNullable(this.explainMetrics);
+  }
+
   @InternalApi
   public AggregationResult get(int index) {
     return this.aggregationResults.get(index);
@@ -70,11 +92,13 @@ public class AggregationResults implements Iterable<AggregationResult> {
       return false;
     }
     AggregationResults that = (AggregationResults) o;
-    return Objects.equals(aggregationResults, that.aggregationResults);
+    return Objects.equals(aggregationResults, that.aggregationResults)
+        && Objects.equals(readTime, that.readTime)
+        && Objects.equals(explainMetrics, that.explainMetrics);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(aggregationResults);
+    return Objects.hash(aggregationResults, readTime, explainMetrics);
   }
 }
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Datastore.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Datastore.java
index d78bea9a..5bd8384a 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Datastore.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Datastore.java
@@ -16,7 +16,9 @@
 
 package com.google.cloud.datastore;
 
+import com.google.api.core.BetaApi;
 import com.google.cloud.Service;
+import com.google.cloud.datastore.models.ExplainOptions;
 import com.google.datastore.v1.TransactionOptions;
 import java.util.Iterator;
 import java.util.List;
@@ -462,6 +464,28 @@ public interface Datastore extends Service<DatastoreOptions>, DatastoreReaderWri
    */
   <T> QueryResults<T> run(Query<T> query, ReadOption... options);
 
+  /**
+   * Submits a {@link Query} with specified {@link com.google.cloud.datastore.models.ExplainOptions}
+   * and returns its result. {@link ReadOption}s can be specified if desired.
+   *
+   * <p>Example of running a query to find all entities of one kind.
+   *
+   * <pre>{@code
+   * String kind = "my_kind";
+   * StructuredQuery<Entity> query = Query.newEntityQueryBuilder()
+   *     .setKind(kind)
+   *     .build();
+   * QueryResults<Entity> results = datastore.run(query, ExplainOptions.newBuilder().setAnalyze(true).build());
+   * }</pre>
+   *
+   * @throws DatastoreException upon failure
+   */
+  @BetaApi
+  default <T> QueryResults<T> run(
+      Query<T> query, ExplainOptions explainOptions, ReadOption... options) {
+    throw new UnsupportedOperationException("Not implemented.");
+  }
+
   /**
    * Submits a {@link AggregationQuery} and returns {@link AggregationResults}. {@link ReadOption}s
    * can be specified if desired.
@@ -508,4 +532,33 @@ public interface Datastore extends Service<DatastoreOptions>, DatastoreReaderWri
   default AggregationResults runAggregation(AggregationQuery query, ReadOption... options) {
     throw new UnsupportedOperationException("Not implemented.");
   }
+
+  /**
+   * Submits a {@link AggregationQuery} with specified {@link
+   * com.google.cloud.datastore.models.ExplainOptions} and returns {@link AggregationResults}.
+   * {@link ReadOption}s can be specified if desired.
+   *
+   * <p>Example of running an {@link AggregationQuery} to find the count of entities of one kind.
+   *
+   * <p>{@link StructuredQuery} example:
+   *
+   * <pre>{@code
+   * EntityQuery selectAllQuery = Query.newEntityQueryBuilder()
+   *    .setKind("Task")
+   *    .build();
+   * AggregationQuery aggregationQuery = Query.newAggregationQueryBuilder()
+   *    .addAggregation(count().as("total_count"))
+   *    .over(selectAllQuery)
+   *    .build();
+   * AggregationResults aggregationResults = datastore.runAggregation(aggregationQuery, ExplainOptions.newBuilder().setAnalyze(true).build());
+   * }</pre>
+   *
+   * @throws DatastoreException upon failure
+   * @return {@link AggregationResults}
+   */
+  @BetaApi
+  default AggregationResults runAggregation(
+      AggregationQuery query, ExplainOptions explainOptions, ReadOption... options) {
+    throw new UnsupportedOperationException("Not implemented.");
+  }
 }
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreImpl.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreImpl.java
index a1b337c0..a3bfb379 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreImpl.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreImpl.java
@@ -16,6 +16,7 @@
 
 package com.google.cloud.datastore;
 
+import com.google.api.core.BetaApi;
 import com.google.api.gax.retrying.RetrySettings;
 import com.google.cloud.BaseService;
 import com.google.cloud.ExceptionHandler;
@@ -30,6 +31,7 @@ import com.google.common.collect.AbstractIterator;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
+import com.google.datastore.v1.ExplainOptions;
 import com.google.datastore.v1.ReadOptions;
 import com.google.datastore.v1.ReserveIdsRequest;
 import com.google.datastore.v1.TransactionOptions;
@@ -182,28 +184,54 @@ final class DatastoreImpl extends BaseService<DatastoreOptions> implements Datas
 
   @Override
   public <T> QueryResults<T> run(Query<T> query) {
-    return run(Optional.empty(), query);
+    return run(Optional.empty(), query, null);
   }
 
   @Override
   public <T> QueryResults<T> run(Query<T> query, ReadOption... options) {
-    return run(toReadOptionsPb(options), query);
+    return run(toReadOptionsPb(options), query, null);
+  }
+
+  @Override
+  @BetaApi
+  public <T> QueryResults<T> run(
+      Query<T> query,
+      com.google.cloud.datastore.models.ExplainOptions explainOptions,
+      ReadOption... options) {
+    return run(toReadOptionsPb(options), query, explainOptions.toPb());
   }
 
   @SuppressWarnings("unchecked")
-  <T> QueryResults<T> run(Optional<ReadOptions> readOptionsPb, Query<T> query) {
+  <T> QueryResults<T> run(
+      Optional<ReadOptions> readOptionsPb, Query<T> query, ExplainOptions explainOptions) {
     return new QueryResultsImpl<T>(
-        this, readOptionsPb, (RecordQuery<T>) query, query.getNamespace());
+        this, readOptionsPb, (RecordQuery<T>) query, query.getNamespace(), explainOptions);
   }
 
   @Override
   public AggregationResults runAggregation(AggregationQuery query) {
-    return aggregationQueryExecutor.execute(query);
+    return aggregationQueryExecutor.execute(query, null);
   }
 
   @Override
   public AggregationResults runAggregation(AggregationQuery query, ReadOption... options) {
-    return aggregationQueryExecutor.execute(query, options);
+    return aggregationQueryExecutor.execute(query, null, options);
+  }
+
+  @Override
+  @BetaApi
+  public AggregationResults runAggregation(
+      AggregationQuery query, com.google.cloud.datastore.models.ExplainOptions explainOptions) {
+    return aggregationQueryExecutor.execute(query, explainOptions);
+  }
+
+  @Override
+  @BetaApi
+  public AggregationResults runAggregation(
+      AggregationQuery query,
+      com.google.cloud.datastore.models.ExplainOptions explainOptions,
+      ReadOption... options) {
+    return aggregationQueryExecutor.execute(query, explainOptions, options);
   }
 
   com.google.datastore.v1.RunQueryResponse runQuery(
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreReader.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreReader.java
index 2a3071f3..8aef7f5c 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreReader.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreReader.java
@@ -16,6 +16,8 @@
 
 package com.google.cloud.datastore;
 
+import com.google.api.core.BetaApi;
+import com.google.cloud.datastore.models.ExplainOptions;
 import java.util.Iterator;
 import java.util.List;
 
@@ -62,4 +64,15 @@ public interface DatastoreReader {
   default AggregationResults runAggregation(AggregationQuery query) {
     throw new UnsupportedOperationException("Not implemented.");
   }
+
+  /**
+   * Submits a {@link AggregationQuery} with a specified {@link
+   * com.google.cloud.datastore.models.ExplainOptions} and returns {@link AggregationResults}.
+   *
+   * @throws DatastoreException upon failure
+   */
+  @BetaApi
+  default AggregationResults runAggregation(AggregationQuery query, ExplainOptions explainOptions) {
+    throw new UnsupportedOperationException("Not implemented.");
+  }
 }
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Query.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Query.java
index 89b8d880..88305197 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Query.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Query.java
@@ -16,6 +16,7 @@
 
 package com.google.cloud.datastore;
 
+import com.google.api.core.InternalApi;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.MoreObjects.ToStringHelper;
 import com.google.common.collect.Maps;
@@ -117,6 +118,11 @@ public abstract class Query<V> implements Serializable {
       return resultClass;
     }
 
+    @InternalApi
+    public com.google.datastore.v1.EntityResult.ResultType getQueryType() {
+      return this.queryType;
+    }
+
     @Override
     public int hashCode() {
       return resultClass.hashCode();
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/QueryResults.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/QueryResults.java
index 3f73824d..50433a6a 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/QueryResults.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/QueryResults.java
@@ -16,8 +16,11 @@
 
 package com.google.cloud.datastore;
 
+import com.google.api.core.BetaApi;
+import com.google.cloud.datastore.models.ExplainMetrics;
 import com.google.datastore.v1.QueryResultBatch;
 import java.util.Iterator;
+import java.util.Optional;
 
 /**
  * The result of a Google Cloud Datastore query submission. When the result is not typed it is
@@ -70,4 +73,9 @@ public interface QueryResults<V> extends Iterator<V> {
 
   /** Returns MoreResults state of the query after the current batch. */
   QueryResultBatch.MoreResultsType getMoreResults();
+
+  @BetaApi
+  default Optional<ExplainMetrics> getExplainMetrics() {
+    throw new UnsupportedOperationException("Not implemented.");
+  }
 }
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/QueryResultsImpl.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/QueryResultsImpl.java
index 8fc731ac..ad511298 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/QueryResultsImpl.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/QueryResultsImpl.java
@@ -16,9 +16,12 @@
 
 package com.google.cloud.datastore;
 
+import com.google.api.core.BetaApi;
 import com.google.cloud.datastore.Query.ResultType;
+import com.google.cloud.datastore.models.ExplainMetrics;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.AbstractIterator;
+import com.google.datastore.v1.ExplainOptions;
 import com.google.datastore.v1.QueryResultBatch.MoreResultsType;
 import com.google.datastore.v1.ReadOptions;
 import com.google.protobuf.ByteString;
@@ -40,16 +43,20 @@ class QueryResultsImpl<T> extends AbstractIterator<T> implements QueryResults<T>
   private Iterator<com.google.datastore.v1.EntityResult> entityResultPbIter;
   private ByteString cursor;
   private MoreResultsType moreResults;
+  private final ExplainOptions explainOptions;
+  private ExplainMetrics explainMetrics;
 
   QueryResultsImpl(
       DatastoreImpl datastore,
       Optional<ReadOptions> readOptionsPb,
       RecordQuery<T> query,
-      String namespace) {
+      String namespace,
+      ExplainOptions explainOptions) {
     this.datastore = datastore;
     this.readOptionsPb = readOptionsPb;
     this.query = query;
     queryResultType = query.getType();
+    this.explainOptions = explainOptions;
     com.google.datastore.v1.PartitionId.Builder pbBuilder =
         com.google.datastore.v1.PartitionId.newBuilder();
     pbBuilder.setProjectId(datastore.getOptions().getProjectId());
@@ -75,6 +82,9 @@ class QueryResultsImpl<T> extends AbstractIterator<T> implements QueryResults<T>
     requestPb.setPartitionId(partitionIdPb);
     requestPb.setProjectId(datastore.getOptions().getProjectId());
     requestPb.setDatabaseId(datastore.getOptions().getDatabaseId());
+    if (explainOptions != null) {
+      requestPb.setExplainOptions(explainOptions);
+    }
     query.populatePb(requestPb);
     runQueryResponsePb = datastore.runQuery(requestPb.build());
     mostRecentQueryPb = requestPb.getQuery();
@@ -86,9 +96,20 @@ class QueryResultsImpl<T> extends AbstractIterator<T> implements QueryResults<T>
       // projection entity can represent all type of results
       actualResultType = ResultType.PROJECTION_ENTITY;
     }
+    boolean isExplain =
+        explainOptions != null
+            && actualResultType.getQueryType() == null
+            && !explainOptions.getAnalyze();
     Preconditions.checkState(
-        queryResultType.isAssignableFrom(actualResultType),
-        "Unexpected result type " + actualResultType + " vs " + queryResultType);
+        queryResultType.isAssignableFrom(actualResultType) || isExplain,
+        "Unexpected result type or explain options set "
+            + actualResultType
+            + " vs "
+            + queryResultType
+            + ", explain options = false");
+    if (runQueryResponsePb.hasExplainMetrics()) {
+      this.explainMetrics = new ExplainMetrics(runQueryResponsePb.getExplainMetrics());
+    }
   }
 
   @Override
@@ -127,4 +148,10 @@ class QueryResultsImpl<T> extends AbstractIterator<T> implements QueryResults<T>
   public MoreResultsType getMoreResults() {
     return moreResults;
   }
+
+  @Override
+  @BetaApi
+  public Optional<ExplainMetrics> getExplainMetrics() {
+    return Optional.ofNullable(this.explainMetrics);
+  }
 }
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/ReadOption.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/ReadOption.java
index c249e45a..f7d6d82c 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/ReadOption.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/ReadOption.java
@@ -20,6 +20,7 @@ import com.google.api.core.BetaApi;
 import com.google.api.core.InternalApi;
 import com.google.cloud.Timestamp;
 import com.google.common.collect.ImmutableMap;
+import com.google.datastore.v1.ExplainOptions;
 import com.google.protobuf.ByteString;
 import java.io.Serializable;
 import java.util.Collections;
@@ -146,32 +147,38 @@ public abstract class ReadOption implements Serializable {
 
     Q query;
     List<ReadOption> readOptions;
+    ExplainOptions explainOptions;
 
-    private QueryConfig(Q query, List<ReadOption> readOptions) {
+    private QueryConfig(Q query, ExplainOptions explainOptions, List<ReadOption> readOptions) {
       this.query = query;
+      this.explainOptions = explainOptions;
       this.readOptions = readOptions;
     }
 
-    private QueryConfig(Q query) {
-      this.query = query;
-      this.readOptions = Collections.emptyList();
+    private QueryConfig(Q query, ExplainOptions explainOptions) {
+      this(query, explainOptions, Collections.emptyList());
     }
 
     public Q getQuery() {
       return query;
     }
 
+    public ExplainOptions getExplainOptions() {
+      return this.explainOptions;
+    }
+
     public List<ReadOption> getReadOptions() {
       return readOptions;
     }
 
-    public static <Q extends Query<?>> QueryConfig<Q> create(Q query) {
-      return new QueryConfig<>(query);
+    public static <Q extends Query<?>> QueryConfig<Q> create(
+        Q query, ExplainOptions explainOptions) {
+      return new QueryConfig<>(query, explainOptions);
     }
 
     public static <Q extends Query<?>> QueryConfig<Q> create(
-        Q query, List<ReadOption> readOptions) {
-      return new QueryConfig<>(query, readOptions);
+        Q query, ExplainOptions explainOptions, List<ReadOption> readOptions) {
+      return new QueryConfig<>(query, explainOptions, readOptions);
     }
   }
 }
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Transaction.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Transaction.java
index 69c18d75..7b6a67a2 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Transaction.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/Transaction.java
@@ -16,6 +16,8 @@
 
 package com.google.cloud.datastore;
 
+import com.google.api.core.BetaApi;
+import com.google.cloud.datastore.models.ExplainOptions;
 import com.google.protobuf.ByteString;
 import java.util.Iterator;
 import java.util.List;
@@ -176,6 +178,11 @@ public interface Transaction extends DatastoreBatchWriter, DatastoreReaderWriter
   @Override
   <T> QueryResults<T> run(Query<T> query);
 
+  @BetaApi
+  default <T> QueryResults<T> run(Query<T> query, ExplainOptions explainOptions) {
+    throw new UnsupportedOperationException("Not implemented.");
+  }
+
   /**
    * Datastore add operation. This method will also allocate id for any entity with an incomplete
    * key. As opposed to {@link #add(FullEntity)} and {@link #add(FullEntity...)}, this method will
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/TransactionImpl.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/TransactionImpl.java
index 3b5e5e4e..f08a908e 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/TransactionImpl.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/TransactionImpl.java
@@ -18,6 +18,8 @@ package com.google.cloud.datastore;
 
 import static com.google.cloud.datastore.ReadOption.transactionId;
 
+import com.google.api.core.BetaApi;
+import com.google.cloud.datastore.models.ExplainOptions;
 import com.google.common.collect.ImmutableList;
 import com.google.datastore.v1.ReadOptions;
 import com.google.datastore.v1.TransactionOptions;
@@ -102,7 +104,16 @@ final class TransactionImpl extends BaseDatastoreBatchWriter implements Transact
     validateActive();
     Optional<ReadOptions> readOptions =
         this.readOptionProtoPreparer.prepare(ImmutableList.of(transactionId(transactionId)));
-    return datastore.run(readOptions, query);
+    return datastore.run(readOptions, query, null);
+  }
+
+  @Override
+  @BetaApi
+  public <T> QueryResults<T> run(Query<T> query, ExplainOptions explainOptions) {
+    validateActive();
+    Optional<ReadOptions> readOptions =
+        this.readOptionProtoPreparer.prepare(ImmutableList.of(transactionId(transactionId)));
+    return datastore.run(readOptions, query, explainOptions.toPb());
   }
 
   @Override
@@ -110,6 +121,12 @@ final class TransactionImpl extends BaseDatastoreBatchWriter implements Transact
     return datastore.runAggregation(query, transactionId(transactionId));
   }
 
+  @Override
+  @BetaApi
+  public AggregationResults runAggregation(AggregationQuery query, ExplainOptions explainOptions) {
+    return datastore.runAggregation(query, explainOptions, transactionId(transactionId));
+  }
+
   @Override
   public Transaction.Response commit() {
     validateActive();
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/AggregationQueryExecutor.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/AggregationQueryExecutor.java
index 5a1fdd2c..bedcf34e 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/AggregationQueryExecutor.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/AggregationQueryExecutor.java
@@ -23,6 +23,7 @@ import com.google.cloud.datastore.ReadOption;
 import com.google.cloud.datastore.ReadOption.QueryConfig;
 import com.google.cloud.datastore.execution.request.AggregationQueryRequestProtoPreparer;
 import com.google.cloud.datastore.execution.response.AggregationQueryResponseTransformer;
+import com.google.cloud.datastore.models.ExplainOptions;
 import com.google.cloud.datastore.spi.v1.DatastoreRpc;
 import com.google.datastore.v1.RunAggregationQueryRequest;
 import com.google.datastore.v1.RunAggregationQueryResponse;
@@ -47,20 +48,24 @@ public class AggregationQueryExecutor
   }
 
   @Override
-  public AggregationResults execute(AggregationQuery query, ReadOption... readOptions) {
+  public AggregationResults execute(
+      AggregationQuery query, ExplainOptions explainOptions, ReadOption... readOptions) {
     RunAggregationQueryRequest runAggregationQueryRequest =
-        getRunAggregationQueryRequest(query, readOptions);
+        getRunAggregationQueryRequest(
+            query, explainOptions == null ? null : explainOptions.toPb(), readOptions);
     RunAggregationQueryResponse runAggregationQueryResponse =
         this.datastoreRpc.runAggregationQuery(runAggregationQueryRequest);
     return this.responseTransformer.transform(runAggregationQueryResponse);
   }
 
   private RunAggregationQueryRequest getRunAggregationQueryRequest(
-      AggregationQuery query, ReadOption... readOptions) {
+      AggregationQuery query,
+      com.google.datastore.v1.ExplainOptions explainOptions,
+      ReadOption... readOptions) {
     QueryConfig<AggregationQuery> queryConfig =
         readOptions == null
-            ? QueryConfig.create(query)
-            : QueryConfig.create(query, Arrays.asList(readOptions));
+            ? QueryConfig.create(query, explainOptions)
+            : QueryConfig.create(query, explainOptions, Arrays.asList(readOptions));
     return this.protoPreparer.prepare(queryConfig);
   }
 }
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/QueryExecutor.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/QueryExecutor.java
index 856c64a0..60c75e0f 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/QueryExecutor.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/QueryExecutor.java
@@ -18,6 +18,7 @@ package com.google.cloud.datastore.execution;
 import com.google.api.core.InternalApi;
 import com.google.cloud.datastore.Query;
 import com.google.cloud.datastore.ReadOption;
+import com.google.cloud.datastore.models.ExplainOptions;
 
 /**
  * An internal functional interface whose implementation has the responsibility to execute a {@link
@@ -34,7 +35,9 @@ public interface QueryExecutor<INPUT extends Query<OUTPUT>, OUTPUT> {
 
   /**
    * @param query A {@link Query} to execute.
+   * @param explainOptions {@link com.google.cloud.datastore.models.ExplainOptions}s to be used when
+   *     executing {@link Query}.
    * @param readOptions Optional {@link ReadOption}s to be used when executing {@link Query}.
    */
-  OUTPUT execute(INPUT query, ReadOption... readOptions);
+  OUTPUT execute(INPUT query, ExplainOptions explainOptions, ReadOption... readOptions);
 }
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/request/AggregationQueryRequestProtoPreparer.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/request/AggregationQueryRequestProtoPreparer.java
index 475a47b5..a89ebb11 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/request/AggregationQueryRequestProtoPreparer.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/request/AggregationQueryRequestProtoPreparer.java
@@ -67,7 +67,9 @@ public class AggregationQueryRequestProtoPreparer
     } else {
       aggregationQueryRequestBuilder.setAggregationQuery(getAggregationQuery(aggregationQuery));
     }
-
+    if (queryConfig.getExplainOptions() != null) {
+      aggregationQueryRequestBuilder.setExplainOptions(queryConfig.getExplainOptions());
+    }
     Optional<ReadOptions> readOptionsPb = readOptionProtoPreparer.prepare(readOptions);
     readOptionsPb.ifPresent(aggregationQueryRequestBuilder::setReadOptions);
     return aggregationQueryRequestBuilder.build();
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/response/AggregationQueryResponseTransformer.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/response/AggregationQueryResponseTransformer.java
index 8c99fcd4..71f7453c 100644
--- a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/response/AggregationQueryResponseTransformer.java
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/execution/response/AggregationQueryResponseTransformer.java
@@ -19,6 +19,7 @@ import com.google.api.core.InternalApi;
 import com.google.cloud.Timestamp;
 import com.google.cloud.datastore.AggregationResult;
 import com.google.cloud.datastore.AggregationResults;
+import com.google.cloud.datastore.models.ExplainMetrics;
 import com.google.datastore.v1.RunAggregationQueryResponse;
 import com.google.datastore.v1.Value;
 import java.util.AbstractMap.SimpleEntry;
@@ -40,7 +41,11 @@ public class AggregationQueryResponseTransformer
         response.getBatch().getAggregationResultsList().stream()
             .map(aggregationResult -> new AggregationResult(transformValues(aggregationResult)))
             .collect(Collectors.toCollection(LinkedList::new));
-    return new AggregationResults(aggregationResults, readTime);
+    ExplainMetrics explainMetrics = null;
+    if (response.hasExplainMetrics()) {
+      explainMetrics = new ExplainMetrics(response.getExplainMetrics());
+    }
+    return new AggregationResults(aggregationResults, readTime, explainMetrics);
   }
 
   private Map<String, com.google.cloud.datastore.Value<?>> transformValues(
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/ExecutionStats.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/ExecutionStats.java
new file mode 100644
index 00000000..52184a01
--- /dev/null
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/ExecutionStats.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2024 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
+ *
+ *     https://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.datastore.models;
+
+import com.google.api.core.BetaApi;
+import com.google.api.core.InternalApi;
+import com.google.cloud.Structs;
+import com.google.common.base.Objects;
+import java.util.Map;
+import org.threeten.bp.Duration;
+
+/** Model class for {@link com.google.datastore.v1.ExecutionStats} */
+@BetaApi
+public class ExecutionStats {
+  private final long resultsReturned;
+  private final Duration executionDuration;
+  private final long readOperations;
+  private final Map<String, Object> debugStats;
+
+  @InternalApi
+  public ExecutionStats(com.google.datastore.v1.ExecutionStats proto) {
+    this.resultsReturned = proto.getResultsReturned();
+    this.executionDuration = Duration.ofNanos(proto.getExecutionDuration().getNanos());
+    this.readOperations = proto.getReadOperations();
+    this.debugStats = Structs.asMap(proto.getDebugStats());
+  }
+
+  /**
+   * Returns the total number of results returned, including documents, projections, aggregation
+   * results, keys.
+   */
+  public long getResultsReturned() {
+    return resultsReturned;
+  }
+
+  /** Returns the debugging statistics from the execution of the query. */
+  public Map<String, Object> getDebugStats() {
+    return debugStats;
+  }
+
+  /** Returns the total time to execute the query in the backend. */
+  public Duration getExecutionDuration() {
+    return executionDuration;
+  }
+
+  /** Returns the total billable read operations. */
+  public long getReadOperations() {
+    return readOperations;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof ExecutionStats)) {
+      return false;
+    }
+    ExecutionStats that = (ExecutionStats) o;
+
+    return Objects.equal(resultsReturned, that.resultsReturned)
+        && Objects.equal(executionDuration, that.executionDuration)
+        && Objects.equal(readOperations, that.readOperations)
+        && Objects.equal(debugStats, that.debugStats);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(resultsReturned, executionDuration, readOperations, debugStats);
+  }
+}
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/ExplainMetrics.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/ExplainMetrics.java
new file mode 100644
index 00000000..75fd94d6
--- /dev/null
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/ExplainMetrics.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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
+ *
+ *     https://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.datastore.models;
+
+import com.google.api.core.BetaApi;
+import com.google.api.core.InternalApi;
+import com.google.common.base.Objects;
+import java.util.Optional;
+
+/** Model class for {@link com.google.datastore.v1.ExplainMetrics}. */
+@BetaApi
+public class ExplainMetrics {
+  private final PlanSummary planSummary;
+  private ExecutionStats executionStats;
+
+  @InternalApi
+  public ExplainMetrics(com.google.datastore.v1.ExplainMetrics proto) {
+    if (proto.hasExecutionStats()) {
+      this.executionStats = new ExecutionStats(proto.getExecutionStats());
+    }
+    this.planSummary = new PlanSummary(proto.getPlanSummary());
+  }
+
+  /** Returns the planning phase information for the query. */
+  public PlanSummary getPlanSummary() {
+    return planSummary;
+  }
+
+  /**
+   * Returns the aggregated stats from the execution of the query, if present. Only present when
+   * 'analyze' is set to true for {@link ExplainOptions}.
+   */
+  public Optional<ExecutionStats> getExecutionStats() {
+    return Optional.ofNullable(executionStats);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof ExplainMetrics)) return false;
+    ExplainMetrics that = (ExplainMetrics) o;
+    return Objects.equal(planSummary, that.planSummary)
+        && Objects.equal(executionStats, that.executionStats);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(planSummary, executionStats);
+  }
+}
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/ExplainOptions.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/ExplainOptions.java
new file mode 100644
index 00000000..f339a851
--- /dev/null
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/ExplainOptions.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 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
+ *
+ *     https://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.datastore.models;
+
+import com.google.api.core.BetaApi;
+import com.google.api.core.InternalApi;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Objects;
+
+/**
+ * Model class for {@link com.google.datastore.v1.ExplainOptions}. Contains the explain options for
+ * the query. Analyze is set to 'false' by default.
+ */
+@BetaApi
+public class ExplainOptions {
+  private final com.google.datastore.v1.ExplainOptions proto;
+
+  private ExplainOptions(com.google.datastore.v1.ExplainOptions proto) {
+    this.proto = proto;
+  }
+
+  public static Builder newBuilder() {
+    return new Builder();
+  }
+
+  /**
+   * Returns whether analyze is set to true or false. When false (the default), the query will be
+   * planned, returning only metrics from the planning stages. When true, the query will be planned
+   * and executed, returning the full query results along with both planning and execution stage
+   * metrics.
+   */
+  public boolean shouldAnalyze() {
+    return proto.getAnalyze();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof ExplainOptions)) return false;
+    ExplainOptions that = (ExplainOptions) o;
+    return proto.equals(that.proto);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(proto);
+  }
+
+  @InternalApi
+  @VisibleForTesting
+  public com.google.datastore.v1.ExplainOptions toPb() {
+    return this.proto;
+  }
+
+  public static class Builder {
+    private boolean analyze = false;
+
+    /*
+     * Set 'analyze' to true or false for the explain options.
+     * When false (the default), the query will be planned, returning only metrics from the planning stages.
+     * When true, the query will be planned and executed, returning the full query results along with both planning and execution stage metrics.
+     */
+    public Builder setAnalyze(boolean analyze) {
+      this.analyze = analyze;
+      return this;
+    }
+
+    public ExplainOptions build() {
+      return new ExplainOptions(
+          com.google.datastore.v1.ExplainOptions.newBuilder().setAnalyze(this.analyze).build());
+    }
+  }
+}
diff --git a/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/PlanSummary.java b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/PlanSummary.java
new file mode 100644
index 00000000..ea9e87bd
--- /dev/null
+++ b/google-cloud-datastore/src/main/java/com/google/cloud/datastore/models/PlanSummary.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 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
+ *
+ *       https://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.datastore.models;
+
+import com.google.api.core.BetaApi;
+import com.google.api.core.InternalApi;
+import com.google.cloud.Structs;
+import com.google.common.base.Objects;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/** Model class for {@link com.google.datastore.v1.PlanSummary} */
+@BetaApi
+public class PlanSummary {
+  private final List<Map<String, Object>> indexesUsed;
+
+  @InternalApi
+  public PlanSummary(com.google.datastore.v1.PlanSummary proto) {
+    this.indexesUsed =
+        proto.getIndexesUsedList().stream().map(Structs::asMap).collect(Collectors.toList());
+  }
+
+  /** Returns the indexes selected for the query. */
+  public List<Map<String, Object>> getIndexesUsed() {
+    return indexesUsed;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof PlanSummary)) return false;
+    PlanSummary planSummary = (PlanSummary) o;
+    return Objects.equal(indexesUsed, planSummary.indexesUsed);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(indexesUsed);
+  }
+}


Test patch:
diff --git a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/AggregationQueryExecutorTest.java b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/AggregationQueryExecutorTest.java
index f9f23261..46752ba8 100644
--- a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/AggregationQueryExecutorTest.java
+++ b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/AggregationQueryExecutorTest.java
@@ -88,7 +88,7 @@ public class AggregationQueryExecutorTest {
 
     replay(mockRpc);
 
-    AggregationResults aggregationResults = queryExecutor.execute(aggregationQuery);
+    AggregationResults aggregationResults = queryExecutor.execute(aggregationQuery, null);
 
     verify(mockRpc);
     assertThat(aggregationResults)
@@ -101,7 +101,8 @@ public class AggregationQueryExecutorTest {
                     new AggregationResult(
                         ImmutableMap.of(
                             "count", LongValue.of(509), "property_2", LongValue.of(100)))),
-                Timestamp.fromProto(runAggregationQueryResponse.getBatch().getReadTime())));
+                Timestamp.fromProto(runAggregationQueryResponse.getBatch().getReadTime()),
+                null));
   }
 
   @Test
@@ -127,7 +128,7 @@ public class AggregationQueryExecutorTest {
     replay(mockRpc);
 
     AggregationResults aggregationResults =
-        queryExecutor.execute(aggregationQuery, eventualConsistency());
+        queryExecutor.execute(aggregationQuery, null, eventualConsistency());
 
     verify(mockRpc);
     assertThat(aggregationResults)
@@ -140,7 +141,8 @@ public class AggregationQueryExecutorTest {
                     new AggregationResult(
                         ImmutableMap.of(
                             "count", LongValue.of(509), "property_2", LongValue.of(100)))),
-                Timestamp.fromProto(runAggregationQueryResponse.getBatch().getReadTime())));
+                Timestamp.fromProto(runAggregationQueryResponse.getBatch().getReadTime()),
+                null));
   }
 
   private RunAggregationQueryResponse placeholderAggregationQueryResponse() {
diff --git a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/request/AggregationQueryRequestProtoPreparerTest.java b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/request/AggregationQueryRequestProtoPreparerTest.java
index 0f22828d..4aec8f7b 100644
--- a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/request/AggregationQueryRequestProtoPreparerTest.java
+++ b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/request/AggregationQueryRequestProtoPreparerTest.java
@@ -40,6 +40,7 @@ import com.google.cloud.datastore.Query;
 import com.google.cloud.datastore.ReadOption;
 import com.google.cloud.datastore.ReadOption.QueryConfig;
 import com.google.common.collect.ImmutableMap;
+import com.google.datastore.v1.ExplainOptions;
 import com.google.datastore.v1.RunAggregationQueryRequest;
 import java.util.HashMap;
 import org.junit.Test;
@@ -91,13 +92,14 @@ public class AggregationQueryRequestProtoPreparerTest {
   @Test
   public void shouldPrepareAggregationQueryRequestWithGivenStructuredQuery() {
     RunAggregationQueryRequest runAggregationQueryRequest =
-        protoPreparer.prepare(QueryConfig.create(AGGREGATION_OVER_STRUCTURED_QUERY));
+        protoPreparer.prepare(QueryConfig.create(AGGREGATION_OVER_STRUCTURED_QUERY, null));
 
     assertThat(runAggregationQueryRequest.getProjectId()).isEqualTo(PROJECT_ID);
     assertThat(runAggregationQueryRequest.getDatabaseId()).isEqualTo(DATABASE_ID);
 
     assertThat(runAggregationQueryRequest.getPartitionId().getProjectId()).isEqualTo(PROJECT_ID);
     assertThat(runAggregationQueryRequest.getPartitionId().getNamespaceId()).isEqualTo(NAMESPACE);
+    assertThat(runAggregationQueryRequest.hasExplainOptions()).isFalse();
 
     com.google.datastore.v1.AggregationQuery aggregationQueryProto =
         runAggregationQueryRequest.getAggregationQuery();
@@ -114,7 +116,7 @@ public class AggregationQueryRequestProtoPreparerTest {
   @Test
   public void shouldPrepareAggregationQueryRequestWithGivenGqlQuery() {
     RunAggregationQueryRequest runAggregationQueryRequest =
-        protoPreparer.prepare(QueryConfig.create(AGGREGATION_OVER_GQL_QUERY));
+        protoPreparer.prepare(QueryConfig.create(AGGREGATION_OVER_GQL_QUERY, null));
 
     assertThat(runAggregationQueryRequest.getProjectId()).isEqualTo(PROJECT_ID);
     assertThat(runAggregationQueryRequest.getDatabaseId()).isEqualTo(DATABASE_ID);
@@ -122,6 +124,7 @@ public class AggregationQueryRequestProtoPreparerTest {
     assertThat(runAggregationQueryRequest.getPartitionId().getProjectId()).isEqualTo(PROJECT_ID);
     assertThat(runAggregationQueryRequest.getPartitionId().getDatabaseId()).isEqualTo(DATABASE_ID);
     assertThat(runAggregationQueryRequest.getPartitionId().getNamespaceId()).isEqualTo(NAMESPACE);
+    assertThat(runAggregationQueryRequest.hasExplainOptions()).isFalse();
 
     com.google.datastore.v1.GqlQuery gqlQueryProto = runAggregationQueryRequest.getGqlQuery();
 
@@ -172,17 +175,42 @@ public class AggregationQueryRequestProtoPreparerTest {
         Query.newAggregationQueryBuilder().over(COMPLETED_TASK_GQL_QUERY).build();
 
     RunAggregationQueryRequest runAggregationQueryFromStructuredQuery =
-        protoPreparer.prepare(QueryConfig.create(structuredQueryWithoutNamespace));
+        protoPreparer.prepare(QueryConfig.create(structuredQueryWithoutNamespace, null));
     RunAggregationQueryRequest runAggregationQueryFromGqlQuery =
-        protoPreparer.prepare(QueryConfig.create(gqlQueryWithoutNamespace));
+        protoPreparer.prepare(QueryConfig.create(gqlQueryWithoutNamespace, null));
 
     assertThat(runAggregationQueryFromStructuredQuery.getPartitionId().getNamespaceId())
         .isEqualTo(NAMESPACE);
     assertThat(runAggregationQueryFromGqlQuery.getPartitionId().getNamespaceId())
         .isEqualTo(NAMESPACE);
+    assertThat(runAggregationQueryFromStructuredQuery.hasExplainOptions()).isEqualTo(false);
+  }
+
+  @Test
+  public void shouldPrepareAggregationQueryWithDifferentModes() {
+    AggregationQuery structuredQueryWithoutNamespace =
+        Query.newAggregationQueryBuilder()
+            .addAggregation(count().as("total"))
+            .over(COMPLETED_TASK_STRUCTURED_QUERY)
+            .build();
+
+    ExplainOptions explainOptions = ExplainOptions.newBuilder().build();
+    RunAggregationQueryRequest runAggregationQueryFromStructuredQuery =
+        protoPreparer.prepare(QueryConfig.create(structuredQueryWithoutNamespace, explainOptions));
+    assertThat(runAggregationQueryFromStructuredQuery.getPartitionId().getNamespaceId())
+        .isEqualTo(NAMESPACE);
+    assertThat(runAggregationQueryFromStructuredQuery.getExplainOptions())
+        .isEqualTo(explainOptions);
+
+    RunAggregationQueryRequest runAggregationQueryFromStructuredQuery2 =
+        protoPreparer.prepare(QueryConfig.create(structuredQueryWithoutNamespace, explainOptions));
+    assertThat(runAggregationQueryFromStructuredQuery2.getPartitionId().getNamespaceId())
+        .isEqualTo(NAMESPACE);
+    assertThat(runAggregationQueryFromStructuredQuery2.getExplainOptions())
+        .isEqualTo(explainOptions);
   }
 
   private RunAggregationQueryRequest prepareQuery(AggregationQuery query, ReadOption readOption) {
-    return protoPreparer.prepare(QueryConfig.create(query, singletonList(readOption)));
+    return protoPreparer.prepare(QueryConfig.create(query, null, singletonList(readOption)));
   }
 }
diff --git a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/response/AggregationQueryResponseTransformerTest.java b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/response/AggregationQueryResponseTransformerTest.java
index 7ba57223..9263543d 100644
--- a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/response/AggregationQueryResponseTransformerTest.java
+++ b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/execution/response/AggregationQueryResponseTransformerTest.java
@@ -22,10 +22,15 @@ import static com.google.common.truth.Truth.assertThat;
 import com.google.cloud.Timestamp;
 import com.google.cloud.datastore.AggregationResult;
 import com.google.cloud.datastore.AggregationResults;
+import com.google.cloud.datastore.models.ExplainMetrics;
 import com.google.common.collect.ImmutableMap;
 import com.google.datastore.v1.AggregationResultBatch;
+import com.google.datastore.v1.ExecutionStats;
+import com.google.datastore.v1.PlanSummary;
 import com.google.datastore.v1.RunAggregationQueryResponse;
 import com.google.datastore.v1.Value;
+import com.google.protobuf.Duration;
+import com.google.protobuf.Struct;
 import java.util.AbstractMap.SimpleEntry;
 import java.util.HashMap;
 import java.util.Map;
@@ -76,6 +81,81 @@ public class AggregationQueryResponseTransformerTest {
     assertThat(aggregationResults.get(0)).isEqualTo(new AggregationResult(toDomainValues(result1)));
     assertThat(aggregationResults.get(1)).isEqualTo(new AggregationResult(toDomainValues(result2)));
     assertThat(aggregationResults.getReadTime()).isEqualTo(readTime);
+    assertThat(aggregationResults.getExplainMetrics().isPresent()).isFalse();
+  }
+
+  @Test
+  public void shouldTransformAggregationQueryResponseWithIntValuesWithStats() {
+    Map<String, com.google.datastore.v1.Value> result1 =
+        new HashMap<>(
+            ImmutableMap.of(
+                "count", intValue(209),
+                "property_2", intValue(100)));
+
+    Map<String, com.google.datastore.v1.Value> result2 =
+        new HashMap<>(
+            ImmutableMap.of(
+                "count", intValue(509),
+                "property_2", intValue((100))));
+    Timestamp readTime = Timestamp.now();
+
+    AggregationResultBatch resultBatch =
+        AggregationResultBatch.newBuilder()
+            .addAggregationResults(
+                com.google.datastore.v1.AggregationResult.newBuilder()
+                    .putAllAggregateProperties(result1)
+                    .build())
+            .addAggregationResults(
+                com.google.datastore.v1.AggregationResult.newBuilder()
+                    .putAllAggregateProperties(result2)
+                    .build())
+            .setReadTime(readTime.toProto())
+            .build();
+
+    ExecutionStats executionStats =
+        ExecutionStats.newBuilder()
+            .setDebugStats(
+                Struct.newBuilder()
+                    .putFields(
+                        "field",
+                        com.google.protobuf.Value.newBuilder().setStringValue("val").build())
+                    .build())
+            .setExecutionDuration(Duration.newBuilder().setSeconds(1).build())
+            .setReadOperations(1)
+            .setResultsReturned(2)
+            .build();
+
+    PlanSummary planSummary =
+        PlanSummary.newBuilder()
+            .addIndexesUsed(
+                Struct.newBuilder()
+                    .putFields(
+                        "field2",
+                        com.google.protobuf.Value.newBuilder().setStringValue("val2").build())
+                    .build())
+            .build();
+
+    com.google.datastore.v1.ExplainMetrics explainMetrics =
+        com.google.datastore.v1.ExplainMetrics.newBuilder()
+            .setExecutionStats(executionStats)
+            .setPlanSummary(planSummary)
+            .build();
+
+    RunAggregationQueryResponse runAggregationQueryResponse =
+        RunAggregationQueryResponse.newBuilder()
+            .setBatch(resultBatch)
+            .setExplainMetrics(explainMetrics)
+            .build();
+
+    AggregationResults aggregationResults =
+        responseTransformer.transform(runAggregationQueryResponse);
+
+    assertThat(aggregationResults.size()).isEqualTo(2);
+    assertThat(aggregationResults.get(0)).isEqualTo(new AggregationResult(toDomainValues(result1)));
+    assertThat(aggregationResults.get(1)).isEqualTo(new AggregationResult(toDomainValues(result2)));
+    assertThat(aggregationResults.getReadTime()).isEqualTo(readTime);
+    assertThat(aggregationResults.getExplainMetrics().get())
+        .isEqualTo(new ExplainMetrics(explainMetrics));
   }
 
   @Test
diff --git a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/it/ITDatastoreConceptsTest.java b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/it/ITDatastoreConceptsTest.java
index b8ebd277..77006577 100644
--- a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/it/ITDatastoreConceptsTest.java
+++ b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/it/ITDatastoreConceptsTest.java
@@ -566,7 +566,7 @@ public class ITDatastoreConceptsTest {
   }
 
   @Test
-  public void testInequalityInvalid() {
+  public void testInequalityValid() {
     Query<Entity> query =
         Query.newEntityQueryBuilder()
             .setKind(TASK_CONCEPTS)
@@ -574,7 +574,7 @@ public class ITDatastoreConceptsTest {
                 CompositeFilter.and(
                     PropertyFilter.gt("created", startDate), PropertyFilter.gt("priority", 3)))
             .build();
-    assertInvalidQuery(query);
+    assertValidQuery(query);
   }
 
   @Test
diff --git a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/it/ITDatastoreTest.java b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/it/ITDatastoreTest.java
index 7c68ffe3..35e1e72b 100644
--- a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/it/ITDatastoreTest.java
+++ b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/it/ITDatastoreTest.java
@@ -31,6 +31,7 @@ import static org.junit.Assert.fail;
 import com.google.cloud.Timestamp;
 import com.google.cloud.Tuple;
 import com.google.cloud.datastore.AggregationQuery;
+import com.google.cloud.datastore.AggregationResults;
 import com.google.cloud.datastore.Batch;
 import com.google.cloud.datastore.BooleanValue;
 import com.google.cloud.datastore.Cursor;
@@ -66,9 +67,15 @@ import com.google.cloud.datastore.StructuredQuery.PropertyFilter;
 import com.google.cloud.datastore.TimestampValue;
 import com.google.cloud.datastore.Transaction;
 import com.google.cloud.datastore.ValueType;
+import com.google.cloud.datastore.models.ExecutionStats;
+import com.google.cloud.datastore.models.ExplainMetrics;
+import com.google.cloud.datastore.models.ExplainOptions;
+import com.google.cloud.datastore.models.PlanSummary;
 import com.google.cloud.datastore.testing.RemoteDatastoreHelper;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Range;
+import com.google.common.truth.Truth;
 import com.google.datastore.v1.TransactionOptions;
 import com.google.datastore.v1.TransactionOptions.ReadOnly;
 import java.util.ArrayList;
@@ -77,6 +84,7 @@ import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -91,6 +99,7 @@ import org.junit.Test;
 import org.junit.rules.Timeout;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
+import org.threeten.bp.Duration;
 
 @RunWith(Parameterized.class)
 public class ITDatastoreTest {
@@ -358,6 +367,82 @@ public class ITDatastoreTest {
     assertFalse(results4.hasNext());
   }
 
+  @Test
+  public void testQueryProfile() {
+    Key key = Key.newBuilder(KEY1, KIND2, 2).build();
+    Entity entity3 =
+        Entity.newBuilder(ENTITY1)
+            .setKey(key)
+            .remove("str")
+            .set("name", "Dan")
+            .setNull("null")
+            .set("age", 19)
+            .build();
+    datastore.put(entity3);
+
+    // age == 19 || age == 20
+    CompositeFilter orFilter =
+        CompositeFilter.or(PropertyFilter.eq("age", 19), PropertyFilter.eq("age", 20));
+    StructuredQuery<Entity> simpleOrQuery =
+        Query.newEntityQueryBuilder()
+            .setNamespace(NAMESPACE)
+            .setKind(KIND2)
+            .setFilter(orFilter)
+            .build();
+    QueryResults<Entity> results =
+        datastore.run(simpleOrQuery, ExplainOptions.newBuilder().setAnalyze(true).build());
+    Truth.assertThat(results.hasNext()).isTrue();
+    Truth.assertThat(results.getExplainMetrics().isPresent()).isTrue();
+    assertPlanSummary(results.getExplainMetrics().get().getPlanSummary());
+    assertExecutionStats(results.getExplainMetrics().get().getExecutionStats().get(), 2, 2, "2");
+
+    QueryResults<Entity> results2 =
+        datastore.run(simpleOrQuery, ExplainOptions.newBuilder().build());
+    Truth.assertThat(results2.hasNext()).isFalse();
+    assertPlanSummary(results2.getExplainMetrics().get().getPlanSummary());
+    Truth.assertThat(results2.getExplainMetrics().get().getExecutionStats().isPresent()).isFalse();
+
+    QueryResults<Entity> results3 = datastore.run(simpleOrQuery);
+    Truth.assertThat(results3.hasNext()).isTrue();
+    Truth.assertThat(results3.getExplainMetrics().isPresent()).isFalse();
+
+    QueryResults<Entity> results4 =
+        datastore.run(simpleOrQuery, ExplainOptions.newBuilder().setAnalyze(false).build());
+    Truth.assertThat(results4.hasNext()).isFalse();
+    assertPlanSummary(results4.getExplainMetrics().get().getPlanSummary());
+    Truth.assertThat(results4.getExplainMetrics().get().getExecutionStats().isPresent()).isFalse();
+
+    AggregationQuery aggregationQuery =
+        Query.newAggregationQueryBuilder().over(simpleOrQuery).addAggregation(count()).build();
+    AggregationResults resultsAggregation =
+        datastore.runAggregation(
+            aggregationQuery, ExplainOptions.newBuilder().setAnalyze(true).build());
+
+    Truth.assertThat(resultsAggregation.size() > 0).isTrue();
+
+    assertPlanSummary(resultsAggregation.getExplainMetrics().get().getPlanSummary());
+    assertExecutionStats(
+        resultsAggregation.getExplainMetrics().get().getExecutionStats().get(), 1, 1, "2");
+
+    AggregationQuery aggregationQuery2 =
+        Query.newAggregationQueryBuilder().over(simpleOrQuery).addAggregation(count()).build();
+    AggregationResults resultsAggregation2 =
+        datastore.runAggregation(aggregationQuery2, ExplainOptions.newBuilder().build());
+
+    Truth.assertThat(resultsAggregation2.size() > 0).isFalse();
+
+    assertPlanSummary(resultsAggregation2.getExplainMetrics().get().getPlanSummary());
+    Truth.assertThat(resultsAggregation2.getExplainMetrics().get().getExecutionStats().isPresent())
+        .isFalse();
+
+    AggregationQuery aggregationQuery3 =
+        Query.newAggregationQueryBuilder().over(simpleOrQuery).addAggregation(count()).build();
+    AggregationResults resultsAggregation3 = datastore.runAggregation(aggregationQuery3);
+
+    Truth.assertThat(resultsAggregation3.size() > 0).isTrue();
+    Truth.assertThat(resultsAggregation3.getExplainMetrics().isPresent()).isFalse();
+  }
+
   @Test
   public void testNewTransactionCommit() {
     Transaction transaction = datastore.newTransaction();
@@ -468,6 +553,100 @@ public class ITDatastoreTest {
     assertThat(onlyOneTransactionIsSuccessful).isTrue();
   }
 
+  @Test
+  public void testTransactionExplainOptionsAnalyze() {
+    StructuredQuery<Entity> query =
+        Query.newEntityQueryBuilder()
+            .setKind(KIND2)
+            .setFilter(PropertyFilter.hasAncestor(KEY2))
+            .setNamespace(NAMESPACE)
+            .build();
+    Transaction baseTransaction = datastore.newTransaction();
+    QueryResults<Entity> baseResults =
+        baseTransaction.run(query, ExplainOptions.newBuilder().setAnalyze(true).build());
+    assertTrue(baseResults.hasNext());
+    assertEquals(ENTITY2, baseResults.next());
+    assertFalse(baseResults.hasNext());
+
+    ExplainMetrics explainMetrics = baseResults.getExplainMetrics().get();
+    assertPlanSummary(explainMetrics.getPlanSummary());
+    assertExecutionStats(explainMetrics.getExecutionStats().get(), 1, 1, "1");
+
+    baseTransaction.add(ENTITY3);
+    baseTransaction.commit();
+    assertEquals(ENTITY3, datastore.get(KEY3));
+
+    AggregationQuery aggregationQuery =
+        Query.newAggregationQueryBuilder().addAggregation(count()).over(query).build();
+
+    Transaction aggregationTransaction = datastore.newTransaction();
+    AggregationResults results =
+        aggregationTransaction.runAggregation(
+            aggregationQuery, ExplainOptions.newBuilder().setAnalyze(true).build());
+    assertTrue(results.size() > 0);
+
+    assertPlanSummary(results.getExplainMetrics().get().getPlanSummary());
+    assertExecutionStats(results.getExplainMetrics().get().getExecutionStats().get(), 1, 1, "1");
+    aggregationTransaction.commit();
+  }
+
+  @Test
+  public void testTransactionExplainOptions() {
+    StructuredQuery<Entity> query =
+        Query.newEntityQueryBuilder()
+            .setKind(KIND2)
+            .setFilter(PropertyFilter.hasAncestor(KEY2))
+            .setNamespace(NAMESPACE)
+            .build();
+    Transaction baseTransaction = datastore.newTransaction();
+    QueryResults<Entity> baseResults =
+        baseTransaction.run(query, ExplainOptions.newBuilder().build());
+    assertFalse(baseResults.hasNext());
+
+    ExplainMetrics explainMetrics = baseResults.getExplainMetrics().get();
+    assertPlanSummary(explainMetrics.getPlanSummary());
+    Truth.assertThat(explainMetrics.getExecutionStats().isPresent()).isFalse();
+
+    AggregationQuery aggregationQuery =
+        Query.newAggregationQueryBuilder().addAggregation(count()).over(query).build();
+
+    Transaction aggregationTransaction = datastore.newTransaction();
+    AggregationResults results =
+        aggregationTransaction.runAggregation(
+            aggregationQuery, ExplainOptions.newBuilder().build());
+    assertFalse(results.size() > 0);
+
+    assertPlanSummary(results.getExplainMetrics().get().getPlanSummary());
+    assertThat(results.getExplainMetrics().get().getExecutionStats().isPresent()).isFalse();
+  }
+
+  private void assertPlanSummary(PlanSummary planSummary) {
+    List<Map<String, Object>> indexesUsed = planSummary.getIndexesUsed();
+    indexesUsed.forEach(
+        each -> Truth.assertThat(each.keySet()).containsAtLeast("properties", "query_scope"));
+  }
+
+  private void assertExecutionStats(
+      ExecutionStats executionStats,
+      long expectedReadOps,
+      long expectedResultsReturned,
+      String expectedIndexEntriesScanned) {
+    Map<String, Object> debugStats = executionStats.getDebugStats();
+    Truth.assertThat(debugStats.keySet())
+        .containsAtLeast("billing_details", "documents_scanned", "index_entries_scanned");
+    Truth.assertThat(debugStats.get("index_entries_scanned"))
+        .isEqualTo(expectedIndexEntriesScanned);
+
+    Duration executionDuration = executionStats.getExecutionDuration();
+    Truth.assertThat(executionDuration).isIn(Range.greaterThan(Duration.ofMillis(0)));
+
+    long readOperations = executionStats.getReadOperations();
+    Truth.assertThat(readOperations).isEqualTo(expectedReadOps);
+
+    long resultsReturned = executionStats.getResultsReturned();
+    Truth.assertThat(resultsReturned).isEqualTo(expectedResultsReturned);
+  }
+
   @Test
   public void testNewTransactionRollback() {
     Transaction transaction = datastore.newTransaction();
@@ -1646,10 +1825,13 @@ public class ITDatastoreTest {
           getOnlyElement(datastore.runAggregation(countAggregationQuery)).getLong("total_count");
       assertThat(latestCount).isEqualTo(3L);
 
-      Long oldCount =
-          getOnlyElement(datastore.runAggregation(countAggregationQuery, ReadOption.readTime(now)))
-              .getLong("total_count");
+      ExplainOptions explainOptions = ExplainOptions.newBuilder().setAnalyze(true).build();
+      AggregationResults results =
+          datastore.runAggregation(countAggregationQuery, explainOptions, ReadOption.readTime(now));
+      Long oldCount = getOnlyElement(results).getLong("total_count");
       assertThat(oldCount).isEqualTo(2L);
+      assertPlanSummary(results.getExplainMetrics().get().getPlanSummary());
+      assertExecutionStats(results.getExplainMetrics().get().getExecutionStats().get(), 1, 1, "2");
     } finally {
       datastore.delete(entity1.getKey(), entity2.getKey(), entity3.getKey());
     }
diff --git a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/models/ExecutionStatsTest.java b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/models/ExecutionStatsTest.java
new file mode 100644
index 00000000..4706e3b8
--- /dev/null
+++ b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/models/ExecutionStatsTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2024 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.datastore.models;
+
+import com.google.cloud.Structs;
+import com.google.common.testing.EqualsTester;
+import com.google.common.truth.Truth;
+import com.google.protobuf.Duration;
+import com.google.protobuf.Struct;
+import com.google.protobuf.Value;
+import org.junit.Test;
+
+public class ExecutionStatsTest {
+  private final Struct struct =
+      Struct.newBuilder()
+          .putFields("key", Value.newBuilder().setStringValue("val").build())
+          .build();
+  private final Duration duration = Duration.newBuilder().setSeconds(23).build();
+  private final com.google.datastore.v1.ExecutionStats proto =
+      com.google.datastore.v1.ExecutionStats.newBuilder()
+          .setDebugStats(struct)
+          .setExecutionDuration(duration)
+          .setReadOperations(2)
+          .setResultsReturned(3)
+          .build();
+  ExecutionStats executionStats = new ExecutionStats(proto);
+
+  @Test
+  public void testModel() {
+    Truth.assertThat(executionStats.getDebugStats()).isEqualTo(Structs.asMap(struct));
+    Truth.assertThat(executionStats.getExecutionDuration())
+        .isEqualTo(org.threeten.bp.Duration.ofNanos(duration.getNanos()));
+    Truth.assertThat(executionStats.getReadOperations()).isEqualTo(2);
+    Truth.assertThat(executionStats.getResultsReturned()).isEqualTo(3);
+  }
+
+  @Test
+  public void testEqualsAndHashcode() {
+    com.google.datastore.v1.ExecutionStats proto2 =
+        com.google.datastore.v1.ExecutionStats.newBuilder()
+            .setDebugStats(struct)
+            .setExecutionDuration(duration)
+            .setReadOperations(6)
+            .setResultsReturned(7)
+            .build();
+
+    ExecutionStats executionStats1 = new ExecutionStats(proto);
+    ExecutionStats executionStats2 = new ExecutionStats(proto2);
+    ExecutionStats executionStats3 = new ExecutionStats(proto2);
+
+    Truth.assertThat(executionStats1).isNotEqualTo(executionStats2);
+    Truth.assertThat(executionStats2).isEqualTo(executionStats3);
+
+    EqualsTester equalsTester = new EqualsTester();
+    equalsTester.addEqualityGroup(executionStats1, executionStats1).testEquals();
+
+    Truth.assertThat(executionStats1.hashCode()).isNotEqualTo(executionStats2.hashCode());
+    Truth.assertThat(executionStats2.hashCode()).isEqualTo(executionStats3.hashCode());
+    Truth.assertThat(executionStats1.hashCode()).isEqualTo(executionStats1.hashCode());
+  }
+}
diff --git a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/models/ExplainOptionsTest.java b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/models/ExplainOptionsTest.java
new file mode 100644
index 00000000..f8fb86ab
--- /dev/null
+++ b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/models/ExplainOptionsTest.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2024 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.datastore.models;
+
+import com.google.common.testing.EqualsTester;
+import com.google.common.truth.Truth;
+import org.junit.Test;
+
+public class ExplainOptionsTest {
+  @Test
+  public void testModel() {
+    com.google.datastore.v1.ExplainOptions builtProto =
+        com.google.datastore.v1.ExplainOptions.newBuilder().setAnalyze(true).build();
+
+    com.google.datastore.v1.ExplainOptions defaultProto =
+        com.google.datastore.v1.ExplainOptions.newBuilder().build();
+
+    ExplainOptions explainOptionsAnalyzeTrue = ExplainOptions.newBuilder().setAnalyze(true).build();
+    ExplainOptions explainOptionsAnalyzeFalse =
+        ExplainOptions.newBuilder().setAnalyze(false).build();
+    ExplainOptions explainOptionsDefault = ExplainOptions.newBuilder().build();
+
+    Truth.assertThat(explainOptionsAnalyzeTrue.shouldAnalyze()).isTrue();
+    Truth.assertThat(explainOptionsAnalyzeTrue.toPb()).isEqualTo(builtProto);
+
+    Truth.assertThat(explainOptionsAnalyzeFalse.shouldAnalyze()).isFalse();
+    Truth.assertThat(explainOptionsAnalyzeFalse.toPb()).isEqualTo(defaultProto);
+
+    Truth.assertThat(explainOptionsDefault.shouldAnalyze()).isFalse();
+    Truth.assertThat(explainOptionsDefault.toPb()).isNotEqualTo(builtProto);
+    Truth.assertThat(explainOptionsDefault.toPb()).isEqualTo(defaultProto);
+    Truth.assertThat(explainOptionsDefault).isEqualTo(explainOptionsAnalyzeFalse);
+  }
+
+  @Test
+  public void testEqualsAndHashcode() {
+    ExplainOptions explainOptions = ExplainOptions.newBuilder().build();
+    ExplainOptions explainOptions2 = ExplainOptions.newBuilder().setAnalyze(true).build();
+
+    Truth.assertThat(explainOptions).isNotEqualTo(explainOptions2);
+    EqualsTester equalsTester = new EqualsTester();
+    equalsTester.addEqualityGroup(explainOptions, explainOptions).testEquals();
+
+    Truth.assertThat(explainOptions.hashCode()).isNotEqualTo(explainOptions2.hashCode());
+    Truth.assertThat(explainOptions.hashCode()).isEqualTo(explainOptions.hashCode());
+  }
+}
diff --git a/google-cloud-datastore/src/test/java/com/google/cloud/datastore/models/PlanSummaryTest.java b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/models/PlanSummaryTest.java
new file mode 100644
index 00000000..fe8c0f0d
--- /dev/null
+++ b/google-cloud-datastore/src/test/java/com/google/cloud/datastore/models/PlanSummaryTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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.datastore.models;
+
+import com.google.cloud.Structs;
+import com.google.common.collect.Lists;
+import com.google.common.testing.EqualsTester;
+import com.google.common.truth.Truth;
+import com.google.protobuf.Struct;
+import com.google.protobuf.Value;
+import org.junit.Test;
+
+public class PlanSummaryTest {
+  private final Struct struct1 =
+      Struct.newBuilder()
+          .putFields("key", Value.newBuilder().setStringValue("val").build())
+          .build();
+  private final Struct struct2 =
+      Struct.newBuilder()
+          .putFields("key2", Value.newBuilder().setStringValue("val2").build())
+          .putFields("key3", Value.newBuilder().setStringValue("val3").build())
+          .build();
+  private final com.google.datastore.v1.PlanSummary proto =
+      com.google.datastore.v1.PlanSummary.newBuilder()
+          .addIndexesUsed(struct1)
+          .addIndexesUsed(struct2)
+          .build();
+  private final PlanSummary planSummary = new PlanSummary(proto);
+
+  @Test
+  public void testModel() {
+    Truth.assertThat(planSummary.getIndexesUsed())
+        .isEqualTo(Lists.newArrayList(Structs.asMap(struct1), Structs.asMap(struct2)));
+    Truth.assertThat(planSummary.getIndexesUsed().get(0).get("key")).isEqualTo("val");
+  }
+
+  @Test
+  public void testEqualsAndHashcode() {
+    com.google.datastore.v1.PlanSummary proto2 =
+        com.google.datastore.v1.PlanSummary.newBuilder().addIndexesUsed(struct1).build();
+    PlanSummary planSummary2 = new PlanSummary(proto2);
+    EqualsTester equalsTester = new EqualsTester();
+    equalsTester.addEqualityGroup(planSummary, planSummary).testEquals();
+
+    Truth.assertThat(planSummary).isNotEqualTo(planSummary2);
+
+    Truth.assertThat(planSummary.hashCode()).isEqualTo(planSummary.hashCode());
+    Truth.assertThat(planSummary.hashCode()).isNotEqualTo(planSummary2.hashCode());
+  }
+}