This is an automated email from the ASF dual-hosted git repository.

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new aee3290eb0 [#7271] feat(server): Add table statistics REST API (#7829)
aee3290eb0 is described below

commit aee3290eb05dcf74a8bd8cd0c3b2a4d6759f2ddf
Author: roryqi <[email protected]>
AuthorDate: Tue Aug 19 10:50:49 2025 +0800

    [#7271] feat(server): Add table statistics REST API (#7829)
    
    ### What changes were proposed in this pull request?
    
    Add table statistics REST API
    
    ### Why are the changes needed?
    
    Fix: #7271
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Add UT.
---
 .../java/org/apache/gravitino/stats/Statistic.java |   5 +-
 .../dto/requests/StatisticsDropRequest.java        |  64 ++++
 .../dto/requests/StatisticsUpdateRequest.java      |  75 ++++
 .../dto/responses/StatisticListResponse.java       |  61 +++
 .../apache/gravitino/dto/stats/StatisticDTO.java   | 186 ++++++++++
 .../apache/gravitino/dto/util/DTOConverters.java   |  26 ++
 .../gravitino/dto/stats/TestStatisticDTO.java      | 182 +++++++++
 .../apache/gravitino/stats/StatisticManager.java   |  16 +-
 .../apache/gravitino/server/GravitinoServer.java   |   2 +
 .../server/web/rest/ExceptionHandlers.java         |  34 ++
 .../gravitino/server/web/rest/OperationType.java   |   3 +-
 .../server/web/rest/StatisticOperations.java       | 203 ++++++++++
 .../server/web/rest/TestStatisticOperations.java   | 409 +++++++++++++++++++++
 13 files changed, 1260 insertions(+), 6 deletions(-)

diff --git a/api/src/main/java/org/apache/gravitino/stats/Statistic.java 
b/api/src/main/java/org/apache/gravitino/stats/Statistic.java
index ad10cb5b4c..e54c82a0cb 100644
--- a/api/src/main/java/org/apache/gravitino/stats/Statistic.java
+++ b/api/src/main/java/org/apache/gravitino/stats/Statistic.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.stats;
 
 import java.util.Optional;
+import org.apache.gravitino.Auditable;
 import org.apache.gravitino.annotation.Evolving;
 
 /**
@@ -27,10 +28,10 @@ import org.apache.gravitino.annotation.Evolving;
  * statistics, fileset statistics, etc.
  */
 @Evolving
-public interface Statistic {
+public interface Statistic extends Auditable {
 
   /** The prefix for custom statistics. Custom statistics are user-defined 
statistics. */
-  String CUSTOM_PREFIX = "custom.";
+  String CUSTOM_PREFIX = "custom-";
 
   /**
    * Get the name of the statistic.
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/StatisticsDropRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/StatisticsDropRequest.java
new file mode 100644
index 0000000000..7cbacbc5fb
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/StatisticsDropRequest.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.dto.requests;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.base.Preconditions;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import lombok.extern.jackson.Jacksonized;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.rest.RESTRequest;
+
+/** Represents a request to drop statistics for specified names. */
+@Getter
+@EqualsAndHashCode
+@ToString
+@Builder
+@Jacksonized
+public class StatisticsDropRequest implements RESTRequest {
+  @JsonProperty("names")
+  String[] names;
+
+  /**
+   * Creates a new StatisticsDropRequest with the specified names.
+   *
+   * @param names The names of the statistics to drop.
+   */
+  public StatisticsDropRequest(String[] names) {
+    this.names = names;
+  }
+
+  /** Default constructor for deserialization. */
+  public StatisticsDropRequest() {
+    this(null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    Preconditions.checkArgument(
+        names != null && names.length > 0, "\"names\" must not be null or 
empty");
+    for (String name : names) {
+      Preconditions.checkArgument(
+          StringUtils.isNotBlank(name), "Each name must be a non-empty 
string");
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/StatisticsUpdateRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/StatisticsUpdateRequest.java
new file mode 100644
index 0000000000..6cb94f71af
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/StatisticsUpdateRequest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.dto.requests;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.google.common.base.Preconditions;
+import java.util.Map;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import lombok.extern.jackson.Jacksonized;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.rest.RESTRequest;
+import org.apache.gravitino.stats.StatisticValue;
+
+/** Represents a request to update statistics. */
+@Getter
+@EqualsAndHashCode
+@ToString
+@Builder
+@Jacksonized
+public class StatisticsUpdateRequest implements RESTRequest {
+
+  @JsonProperty("updates")
+  @JsonSerialize(contentUsing = JsonUtils.StatisticValueSerializer.class)
+  @JsonDeserialize(contentUsing = JsonUtils.StatisticValueDeserializer.class)
+  Map<String, StatisticValue<?>> updates;
+
+  /**
+   * Creates a new StatisticsUpdateRequest with the specified updates.
+   *
+   * @param updates The statistics to update.
+   */
+  public StatisticsUpdateRequest(Map<String, StatisticValue<?>> updates) {
+    this.updates = updates;
+  }
+
+  /** Default constructor for deserialization. */
+  public StatisticsUpdateRequest() {
+    this(null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    Preconditions.checkArgument(
+        updates != null && !updates.isEmpty(), "\"updates\" must not be null 
or empty");
+    updates.forEach(
+        (name, value) -> {
+          Preconditions.checkArgument(
+              StringUtils.isNotBlank(name), "statistic \"name\" must not be 
null or empty");
+          Preconditions.checkArgument(
+              value != null, "statistic \"value\" for '%s' must not be null", 
name);
+        });
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/StatisticListResponse.java
 
b/common/src/main/java/org/apache/gravitino/dto/responses/StatisticListResponse.java
new file mode 100644
index 0000000000..dcdf1050c1
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/responses/StatisticListResponse.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.dto.responses;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.base.Preconditions;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.apache.gravitino.dto.stats.StatisticDTO;
+
+/** Represents a response containing a list of statistics. */
+@Getter
+@ToString
+@EqualsAndHashCode(callSuper = true)
+public class StatisticListResponse extends BaseResponse {
+
+  @JsonProperty("statistics")
+  private StatisticDTO[] statistics;
+
+  /**
+   * Constructor for StatisticsListResponse.
+   *
+   * @param statistics Array of StatisticDTO objects representing the 
statistics.
+   */
+  public StatisticListResponse(StatisticDTO[] statistics) {
+    super(0);
+    this.statistics = statistics;
+  }
+
+  /** Default constructor for StatisticsListResponse (used by Jackson 
deserializer). */
+  public StatisticListResponse() {
+    this(null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    Preconditions.checkArgument(statistics != null, "\"statistics\" must not 
be null");
+
+    for (StatisticDTO statistic : statistics) {
+      Preconditions.checkArgument(statistic != null, "\"statistic\" must not 
be null");
+      statistic.validate();
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/stats/StatisticDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/stats/StatisticDTO.java
new file mode 100644
index 0000000000..8742428329
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/dto/stats/StatisticDTO.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.dto.stats;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.google.common.base.Preconditions;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.stats.Statistic;
+import org.apache.gravitino.stats.StatisticValue;
+
+/** Data Transfer Object (DTO) for representing a statistic. */
+@EqualsAndHashCode
+@ToString
+public class StatisticDTO implements Statistic {
+
+  @JsonProperty("name")
+  private String name;
+
+  @Nullable
+  @JsonInclude
+  @JsonProperty("value")
+  @JsonSerialize(using = JsonUtils.StatisticValueSerializer.class)
+  @JsonDeserialize(using = JsonUtils.StatisticValueDeserializer.class)
+  private StatisticValue<?> value;
+
+  @JsonProperty("reserved")
+  private boolean reserved;
+
+  @JsonProperty("modifiable")
+  private boolean modifiable;
+
+  @JsonProperty("audit")
+  private AuditDTO audit;
+
+  @Override
+  public String name() {
+    return name;
+  }
+
+  @Override
+  public Optional<StatisticValue<?>> value() {
+    return Optional.ofNullable(value);
+  }
+
+  @Override
+  public boolean reserved() {
+    return reserved;
+  }
+
+  @Override
+  public boolean modifiable() {
+    return modifiable;
+  }
+
+  /**
+   * Validates the StatisticDTO.
+   *
+   * @throws IllegalArgumentException if any of the required fields are 
invalid.
+   */
+  public void validate() {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(name), "\"name\" is required and cannot be 
empty");
+
+    Preconditions.checkArgument(
+        audit != null, "\"audit\" information is required and cannot be null");
+  }
+
+  @Override
+  public AuditDTO auditInfo() {
+    return audit;
+  }
+
+  /**
+   * Creates a new builder for constructing instances of {@link StatisticDTO}.
+   *
+   * @return a new instance of {@link Builder}
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Builder class for constructing instances of {@link StatisticDTO}. */
+  public static class Builder {
+    private String name;
+    private StatisticValue<?> value;
+    private boolean reserved;
+    private boolean modifiable;
+    private AuditDTO audit;
+
+    /**
+     * Sets the name of the statistic.
+     *
+     * @param name the name of the statistic
+     * @return the builder instance
+     */
+    public Builder withName(String name) {
+      this.name = name;
+      return this;
+    }
+
+    /**
+     * Sets the value of the statistic.
+     *
+     * @param value the value of the statistic
+     * @return the builder instance
+     */
+    public Builder withValue(Optional<StatisticValue<?>> value) {
+      value.ifPresent(v -> this.value = v);
+      return this;
+    }
+
+    /**
+     * Sets whether the statistic is reserved.
+     *
+     * @param reserved true if the statistic is reserved, false otherwise
+     * @return the builder instance
+     */
+    public Builder withReserved(boolean reserved) {
+      this.reserved = reserved;
+      return this;
+    }
+
+    /**
+     * Sets whether the statistic is modifiable.
+     *
+     * @param modifiable true if the statistic is modifiable, false otherwise
+     * @return the builder instance
+     */
+    public Builder withModifiable(boolean modifiable) {
+      this.modifiable = modifiable;
+      return this;
+    }
+
+    /**
+     * Sets the audit information for the statistic.
+     *
+     * @param audit the audit information
+     * @return the builder instance
+     */
+    public Builder withAudit(AuditDTO audit) {
+      this.audit = audit;
+      return this;
+    }
+
+    /**
+     * Builds an instance of {@link StatisticDTO} with the provided values.
+     *
+     * @return a new instance of {@link StatisticDTO}
+     */
+    public StatisticDTO build() {
+      StatisticDTO statisticDTO = new StatisticDTO();
+      statisticDTO.name = this.name;
+      statisticDTO.value = this.value;
+      statisticDTO.reserved = this.reserved;
+      statisticDTO.modifiable = this.modifiable;
+      statisticDTO.audit = this.audit;
+      statisticDTO.validate();
+      return statisticDTO;
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java 
b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
index 9ef4026431..fac66a4e99 100644
--- a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
+++ b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
@@ -83,6 +83,7 @@ import 
org.apache.gravitino.dto.rel.partitions.IdentityPartitionDTO;
 import org.apache.gravitino.dto.rel.partitions.ListPartitionDTO;
 import org.apache.gravitino.dto.rel.partitions.PartitionDTO;
 import org.apache.gravitino.dto.rel.partitions.RangePartitionDTO;
+import org.apache.gravitino.dto.stats.StatisticDTO;
 import org.apache.gravitino.dto.tag.MetadataObjectDTO;
 import org.apache.gravitino.dto.tag.TagDTO;
 import org.apache.gravitino.file.FileInfo;
@@ -117,6 +118,7 @@ import org.apache.gravitino.rel.partitions.Partition;
 import org.apache.gravitino.rel.partitions.Partitions;
 import org.apache.gravitino.rel.partitions.RangePartition;
 import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.stats.Statistic;
 import org.apache.gravitino.tag.Tag;
 
 /** Utility class for converting between DTOs and domain objects. */
@@ -841,6 +843,30 @@ public class DTOConverters {
     return 
Arrays.stream(groups).map(DTOConverters::toDTO).toArray(GroupDTO[]::new);
   }
 
+  /**
+   * Converts an array of statistics to an array of StatisticDTOs.
+   *
+   * @param statistics The statistics to be converted.
+   * @return The array of StatisticDTOs.
+   */
+  public static StatisticDTO[] toDTOs(Statistic[] statistics) {
+    if (ArrayUtils.isEmpty(statistics)) {
+      return new StatisticDTO[0];
+    }
+
+    return Arrays.stream(statistics)
+        .map(
+            statistic ->
+                StatisticDTO.builder()
+                    .withName(statistic.name())
+                    .withValue(statistic.value())
+                    .withModifiable(statistic.modifiable())
+                    .withReserved(statistic.reserved())
+                    .withAudit(toDTO(statistic.auditInfo()))
+                    .build())
+        .toArray(StatisticDTO[]::new);
+  }
+
   /**
    * Converts a DistributionDTO to a Distribution.
    *
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/stats/TestStatisticDTO.java 
b/common/src/test/java/org/apache/gravitino/dto/stats/TestStatisticDTO.java
new file mode 100644
index 0000000000..dbc940e020
--- /dev/null
+++ b/common/src/test/java/org/apache/gravitino/dto/stats/TestStatisticDTO.java
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.dto.stats;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.stats.StatisticValue;
+import org.apache.gravitino.stats.StatisticValues;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestStatisticDTO {
+  @Test
+  public void testStatisticSerDe() throws JsonProcessingException {
+    // case 1: StatisticDTO with long value
+    StatisticDTO statisticDTO =
+        StatisticDTO.builder()
+            .withName("statistic_test")
+            .withValue(Optional.of(StatisticValues.longValue(100L)))
+            .withReserved(false)
+            .withModifiable(false)
+            .withAudit(
+                AuditDTO.builder()
+                    .withCreator("test_user")
+                    .withCreateTime(Instant.now())
+                    .withLastModifier("test_user")
+                    .withLastModifiedTime(Instant.now())
+                    .build())
+            .build();
+
+    String serJson = JsonUtils.objectMapper().writeValueAsString(statisticDTO);
+    StatisticDTO deserStatisticDTO =
+        JsonUtils.objectMapper().readValue(serJson, StatisticDTO.class);
+
+    Assertions.assertEquals(statisticDTO, deserStatisticDTO);
+
+    // case 2: StatisticDTO with string value
+    statisticDTO =
+        StatisticDTO.builder()
+            .withName("statistic_test")
+            .withValue(Optional.of(StatisticValues.stringValue("test_value")))
+            .withReserved(true)
+            .withModifiable(true)
+            .withAudit(
+                AuditDTO.builder()
+                    .withCreator("test_user")
+                    .withCreateTime(Instant.now())
+                    .withLastModifier("test_user")
+                    .withLastModifiedTime(Instant.now())
+                    .build())
+            .build();
+    serJson = JsonUtils.objectMapper().writeValueAsString(statisticDTO);
+    deserStatisticDTO = JsonUtils.objectMapper().readValue(serJson, 
StatisticDTO.class);
+    Assertions.assertEquals(statisticDTO, deserStatisticDTO);
+
+    // case 3: StatisticDTO with null value
+    statisticDTO =
+        StatisticDTO.builder()
+            .withName("statistic_test")
+            .withReserved(false)
+            .withModifiable(true)
+            .withAudit(
+                AuditDTO.builder()
+                    .withCreator("test_user")
+                    .withCreateTime(Instant.now())
+                    .withLastModifier("test_user")
+                    .withLastModifiedTime(Instant.now())
+                    .build())
+            .build();
+    serJson = JsonUtils.objectMapper().writeValueAsString(statisticDTO);
+    Assertions.assertTrue(serJson.contains("null"));
+    deserStatisticDTO = JsonUtils.objectMapper().readValue(serJson, 
StatisticDTO.class);
+    Assertions.assertEquals(statisticDTO, deserStatisticDTO);
+
+    // case 4: StatisticDTO with list value
+    statisticDTO =
+        StatisticDTO.builder()
+            .withName("statistic_test")
+            .withValue(
+                Optional.of(
+                    StatisticValues.listValue(
+                        Lists.newArrayList(
+                            StatisticValues.stringValue("value1"),
+                            StatisticValues.stringValue("value2")))))
+            .withReserved(false)
+            .withModifiable(true)
+            .withAudit(
+                AuditDTO.builder()
+                    .withCreator("test_user")
+                    .withCreateTime(Instant.now())
+                    .withLastModifier("test_user")
+                    .withLastModifiedTime(Instant.now())
+                    .build())
+            .build();
+    serJson = JsonUtils.objectMapper().writeValueAsString(statisticDTO);
+    deserStatisticDTO = JsonUtils.objectMapper().readValue(serJson, 
StatisticDTO.class);
+    Assertions.assertEquals(statisticDTO, deserStatisticDTO);
+
+    // case 5: StatisticDTO with double value
+    statisticDTO =
+        StatisticDTO.builder()
+            .withName("statistic_test")
+            .withValue(Optional.of(StatisticValues.doubleValue(99.99)))
+            .withReserved(true)
+            .withModifiable(false)
+            .withAudit(
+                AuditDTO.builder()
+                    .withCreator("test_user")
+                    .withCreateTime(Instant.now())
+                    .withLastModifier("test_user")
+                    .withLastModifiedTime(Instant.now())
+                    .build())
+            .build();
+    serJson = JsonUtils.objectMapper().writeValueAsString(statisticDTO);
+    deserStatisticDTO = JsonUtils.objectMapper().readValue(serJson, 
StatisticDTO.class);
+    Assertions.assertEquals(statisticDTO, deserStatisticDTO);
+
+    // case 6: StatisticDTO with boolean value
+    statisticDTO =
+        StatisticDTO.builder()
+            .withName("statistic_test")
+            .withValue(Optional.of(StatisticValues.booleanValue(true)))
+            .withReserved(false)
+            .withModifiable(true)
+            .withAudit(
+                AuditDTO.builder()
+                    .withCreator("test_user")
+                    .withCreateTime(Instant.now())
+                    .withLastModifier("test_user")
+                    .withLastModifiedTime(Instant.now())
+                    .build())
+            .build();
+    serJson = JsonUtils.objectMapper().writeValueAsString(statisticDTO);
+    deserStatisticDTO = JsonUtils.objectMapper().readValue(serJson, 
StatisticDTO.class);
+    Assertions.assertEquals(statisticDTO, deserStatisticDTO);
+
+    // case 7: StatisticDTO with complex object value
+    Map<String, StatisticValue<?>> map = Maps.newHashMap();
+    map.put("key1", StatisticValues.stringValue("value1"));
+    map.put("key2", StatisticValues.longValue(200L));
+    StatisticDTO complexStatisticDTO =
+        StatisticDTO.builder()
+            .withName("complex_statistic")
+            .withValue(Optional.of(StatisticValues.objectValue(map)))
+            .withReserved(false)
+            .withModifiable(true)
+            .withAudit(
+                AuditDTO.builder()
+                    .withCreator("test_user")
+                    .withCreateTime(Instant.now())
+                    .withLastModifier("test_user")
+                    .withLastModifiedTime(Instant.now())
+                    .build())
+            .build();
+    serJson = JsonUtils.objectMapper().writeValueAsString(complexStatisticDTO);
+    StatisticDTO deserComplexStatisticDTO =
+        JsonUtils.objectMapper().readValue(serJson, StatisticDTO.class);
+    Assertions.assertEquals(complexStatisticDTO, deserComplexStatisticDTO);
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/stats/StatisticManager.java 
b/core/src/main/java/org/apache/gravitino/stats/StatisticManager.java
index c1fc9bc892..b62527dca6 100644
--- a/core/src/main/java/org/apache/gravitino/stats/StatisticManager.java
+++ b/core/src/main/java/org/apache/gravitino/stats/StatisticManager.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.stats;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Lists;
 import java.io.IOException;
 import java.time.Instant;
@@ -26,6 +27,7 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.stream.Collectors;
 import org.apache.commons.lang3.tuple.Pair;
+import org.apache.gravitino.Audit;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.MetadataObject;
@@ -73,7 +75,7 @@ public class StatisticManager {
                       entity -> {
                         String name = entity.name();
                         StatisticValue<?> value = entity.value();
-                        return new CustomStatistic(name, value);
+                        return new CustomStatistic(name, value, 
entity.auditInfo());
                       })
                   .collect(Collectors.toList()));
     } catch (NoSuchEntityException nse) {
@@ -180,14 +182,17 @@ public class StatisticManager {
     }
   }
 
-  private static class CustomStatistic implements Statistic {
+  @VisibleForTesting
+  public static class CustomStatistic implements Statistic {
 
     private final String name;
     private final StatisticValue<?> value;
+    private final Audit auditInfo;
 
-    CustomStatistic(String name, StatisticValue<?> value) {
+    public CustomStatistic(String name, StatisticValue<?> value, Audit 
auditInfo) {
       this.name = name;
       this.value = value;
+      this.auditInfo = auditInfo;
     }
 
     @Override
@@ -209,5 +214,10 @@ public class StatisticManager {
     public boolean modifiable() {
       return true;
     }
+
+    @Override
+    public Audit auditInfo() {
+      return auditInfo;
+    }
   }
 }
diff --git 
a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java 
b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
index 3063a12278..48e22e0490 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -56,6 +56,7 @@ import 
org.apache.gravitino.server.web.mapper.JsonMappingExceptionMapper;
 import org.apache.gravitino.server.web.mapper.JsonParseExceptionMapper;
 import org.apache.gravitino.server.web.mapper.JsonProcessingExceptionMapper;
 import org.apache.gravitino.server.web.ui.WebUIFilter;
+import org.apache.gravitino.stats.StatisticManager;
 import org.apache.gravitino.tag.TagDispatcher;
 import org.glassfish.hk2.api.InterceptionService;
 import org.glassfish.hk2.utilities.binding.AbstractBinder;
@@ -147,6 +148,7 @@ public class GravitinoServer extends ResourceConfig {
             
bind(gravitinoEnv.modelDispatcher()).to(ModelDispatcher.class).ranked(1);
             bind(lineageService).to(LineageDispatcher.class).ranked(1);
             
bind(gravitinoEnv.jobOperationDispatcher()).to(JobOperationDispatcher.class).ranked(1);
+            
bind(gravitinoEnv.statisticManager()).to(StatisticManager.class).ranked(1);
           }
         });
     register(JsonProcessingExceptionMapper.class);
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
index 2e933c10c9..7b5ed17336 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
@@ -188,6 +188,11 @@ public class ExceptionHandlers {
     return RolePermissionOperationHandler.INSTANCE.handle(type, name, parent, 
e);
   }
 
+  public static Response handleStatisticException(
+      OperationType type, String name, String parent, Exception e) {
+    return StatisticExceptionHandler.INSTANCE.handle(type, name, parent, e);
+  }
+
   private static class PartitionExceptionHandler extends BaseExceptionHandler {
 
     private static final ExceptionHandler INSTANCE = new 
PartitionExceptionHandler();
@@ -906,6 +911,35 @@ public class ExceptionHandlers {
     }
   }
 
+  private static class StatisticExceptionHandler extends BaseExceptionHandler {
+
+    private static final ExceptionHandler INSTANCE = new 
StatisticExceptionHandler();
+
+    private static String getStatisticErrorMsg(
+        String name, String operation, String parent, String reason) {
+      return String.format(
+          "Failed to operate statistic(s)%s operation [%s] on parent [%s], 
reason [%s]",
+          name, operation, parent, reason);
+    }
+
+    @Override
+    public Response handle(OperationType op, String name, String object, 
Exception e) {
+      String formatted = StringUtil.isBlank(name) ? "" : " [" + name + "]";
+      String errorMsg = getStatisticErrorMsg(formatted, op.name(), object, 
getErrorMsg(e));
+      LOG.warn(errorMsg, e);
+
+      if (e instanceof IllegalArgumentException) {
+        return Utils.illegalArguments(errorMsg, e);
+
+      } else if (e instanceof NotFoundException) {
+        return Utils.notFound(errorMsg, e);
+
+      } else {
+        return super.handle(op, name, object, e);
+      }
+    }
+  }
+
   @VisibleForTesting
   static class BaseExceptionHandler extends ExceptionHandler {
 
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/OperationType.java 
b/server/src/main/java/org/apache/gravitino/server/web/rest/OperationType.java
index 7b9025eacb..04f8e26de7 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/OperationType.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/OperationType.java
@@ -39,5 +39,6 @@ public enum OperationType {
   LIST_VERSIONS, // An operation to list versions of a model
   LINK, // An operation to link a version to a model
   RUN, // An operation to run a job
-  CANCEL // An operation to cancel a job
+  CANCEL, // An operation to cancel a job
+  UPDATE
 }
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/StatisticOperations.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/StatisticOperations.java
new file mode 100644
index 0000000000..2ddcfc2e50
--- /dev/null
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/StatisticOperations.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.server.web.rest;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.Timed;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import javax.inject.Inject;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.dto.requests.StatisticsDropRequest;
+import org.apache.gravitino.dto.requests.StatisticsUpdateRequest;
+import org.apache.gravitino.dto.responses.BaseResponse;
+import org.apache.gravitino.dto.responses.DropResponse;
+import org.apache.gravitino.dto.responses.StatisticListResponse;
+import org.apache.gravitino.dto.util.DTOConverters;
+import org.apache.gravitino.exceptions.IllegalStatisticNameException;
+import org.apache.gravitino.metrics.MetricNames;
+import org.apache.gravitino.server.web.Utils;
+import org.apache.gravitino.stats.Statistic;
+import org.apache.gravitino.stats.StatisticManager;
+import org.apache.gravitino.stats.StatisticValue;
+import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Path("/metalakes/{metalake}/objects/{type}/{fullName}/statistics")
+@Consumes(MediaType.APPLICATION_JSON)
+@Produces(MediaType.APPLICATION_JSON)
+public class StatisticOperations {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(StatisticOperations.class);
+
+  @Context private HttpServletRequest httpRequest;
+
+  private final StatisticManager statisticManager;
+
+  @Inject
+  public StatisticOperations(StatisticManager statisticManager) {
+    this.statisticManager = statisticManager;
+  }
+
+  @GET
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "list-stats." + MetricNames.HTTP_PROCESS_DURATION, absolute = 
true)
+  @ResponseMetered(name = "list-stats", absolute = true)
+  public Response listStatistics(
+      @PathParam("metalake") String metalake,
+      @PathParam("type") String type,
+      @PathParam("fullName") String fullName) {
+    LOG.info(
+        "Received list statistics request for object full name: {} type: {} in 
the metalake {}",
+        fullName,
+        type,
+        metalake);
+    try {
+
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            MetadataObject object =
+                MetadataObjects.parse(
+                    fullName, 
MetadataObject.Type.valueOf(type.toUpperCase(Locale.ROOT)));
+            if (object.type() != MetadataObject.Type.TABLE) {
+              throw new UnsupportedOperationException(
+                  "Listing statistics is only supported for tables now.");
+            }
+
+            MetadataObjectUtil.checkMetadataObject(metalake, object);
+
+            List<Statistic> statistics = 
statisticManager.listStatistics(metalake, object);
+            return Utils.ok(
+                new StatisticListResponse(
+                    DTOConverters.toDTOs(statistics.toArray(new 
Statistic[0]))));
+          });
+    } catch (Exception e) {
+      return ExceptionHandlers.handleStatisticException(OperationType.LIST, 
fullName, metalake, e);
+    }
+  }
+
+  @PUT
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "update-stats." + MetricNames.HTTP_PROCESS_DURATION, absolute 
= true)
+  @ResponseMetered(name = "update-stats", absolute = true)
+  public Response updateStatistics(
+      @PathParam("metalake") String metalake,
+      @PathParam("type") String type,
+      @PathParam("fullName") String fullName,
+      StatisticsUpdateRequest request) {
+    try {
+      LOG.info(
+          "Received update statistics request for object full name: {} type: 
{} in the metalake {}",
+          fullName,
+          type,
+          metalake);
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            request.validate();
+            MetadataObject object =
+                MetadataObjects.parse(
+                    fullName, 
MetadataObject.Type.valueOf(type.toUpperCase(Locale.ROOT)));
+            if (object.type() != MetadataObject.Type.TABLE) {
+              throw new UnsupportedOperationException(
+                  "Update statistics is only supported for tables now.");
+            }
+
+            Map<String, StatisticValue<?>> statisticMaps = Maps.newHashMap();
+            for (Map.Entry<String, StatisticValue<?>> entry : 
request.getUpdates().entrySet()) {
+              // Current we only support custom statistics
+              if (!entry.getKey().startsWith(Statistic.CUSTOM_PREFIX)) {
+                throw new IllegalStatisticNameException(
+                    "Statistic name must start with %s , but got: %s",
+                    Statistic.CUSTOM_PREFIX, entry.getKey());
+              }
+
+              statisticMaps.put(entry.getKey(), entry.getValue());
+            }
+
+            MetadataObjectUtil.checkMetadataObject(metalake, object);
+
+            statisticManager.updateStatistics(metalake, object, statisticMaps);
+            return Utils.ok(new BaseResponse(0));
+          });
+    } catch (Exception e) {
+      return ExceptionHandlers.handleStatisticException(
+          OperationType.UPDATE, 
StringUtils.join(request.getUpdates().keySet(), ","), fullName, e);
+    }
+  }
+
+  @POST
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "drop-stats." + MetricNames.HTTP_PROCESS_DURATION, absolute = 
true)
+  @ResponseMetered(name = "drop-stats", absolute = true)
+  public Response dropStatistics(
+      @PathParam("metalake") String metalake,
+      @PathParam("type") String type,
+      @PathParam("fullName") String fullName,
+      StatisticsDropRequest request) {
+    try {
+      LOG.info(
+          "Received drop statistics request for object full name: {} type: {} 
in the metalake {}",
+          fullName,
+          type,
+          metalake);
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            request.validate();
+
+            MetadataObject object =
+                MetadataObjects.parse(
+                    fullName, 
MetadataObject.Type.valueOf(type.toUpperCase(Locale.ROOT)));
+            if (object.type() != MetadataObject.Type.TABLE) {
+              throw new UnsupportedOperationException(
+                  "Dropping statistics is only supported for tables now.");
+            }
+
+            MetadataObjectUtil.checkMetadataObject(metalake, object);
+
+            boolean dropped =
+                statisticManager.dropStatistics(
+                    metalake, object, Lists.newArrayList(request.getNames()));
+            return Utils.ok(new DropResponse(dropped));
+          });
+    } catch (Exception e) {
+      return ExceptionHandlers.handleStatisticException(
+          OperationType.DROP, StringUtils.join(request.getNames(), ","), 
fullName, e);
+    }
+  }
+}
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestStatisticOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestStatisticOperations.java
new file mode 100644
index 0000000000..1a55b10282
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestStatisticOperations.java
@@ -0,0 +1,409 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.server.web.rest;
+
+import static javax.ws.rs.client.Entity.entity;
+import static org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
+import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
+import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.dto.requests.StatisticsDropRequest;
+import org.apache.gravitino.dto.requests.StatisticsUpdateRequest;
+import org.apache.gravitino.dto.responses.BaseResponse;
+import org.apache.gravitino.dto.responses.DropResponse;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.dto.responses.StatisticListResponse;
+import org.apache.gravitino.dto.stats.StatisticDTO;
+import org.apache.gravitino.dto.util.DTOConverters;
+import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
+import org.apache.gravitino.lock.LockManager;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.stats.Statistic;
+import org.apache.gravitino.stats.StatisticManager;
+import org.apache.gravitino.stats.StatisticValue;
+import org.apache.gravitino.stats.StatisticValues;
+import org.glassfish.jersey.internal.inject.AbstractBinder;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.glassfish.jersey.test.TestProperties;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class TestStatisticOperations extends JerseyTest {
+
+  private static class MockServletRequestFactory extends 
ServletRequestFactoryBase {
+    @Override
+    public HttpServletRequest get() {
+      HttpServletRequest request = mock(HttpServletRequest.class);
+      when(request.getRemoteUser()).thenReturn(null);
+      return request;
+    }
+  }
+
+  private static TableDispatcher tableDispatcher = mock(TableDispatcher.class);
+  private StatisticManager manager = mock(StatisticManager.class);
+
+  private final String metalake = "metalake1";
+
+  private final String catalog = "catalog1";
+
+  private final String schema = "schema1";
+
+  private final String table = "table1";
+
+  @BeforeAll
+  public static void setup() throws IllegalAccessException {
+    Config config = mock(Config.class);
+    Mockito.doReturn(100000L).when(config).get(TREE_LOCK_MAX_NODE_IN_MEMORY);
+    Mockito.doReturn(1000L).when(config).get(TREE_LOCK_MIN_NODE_IN_MEMORY);
+    Mockito.doReturn(36000L).when(config).get(TREE_LOCK_CLEAN_INTERVAL);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", new 
LockManager(config), true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "tableDispatcher", 
tableDispatcher, true);
+  }
+
+  @Override
+  protected Application configure() {
+    try {
+      forceSet(
+          TestProperties.CONTAINER_PORT, 
String.valueOf(RESTUtils.findAvailablePort(2000, 3000)));
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    ResourceConfig resourceConfig = new ResourceConfig();
+    resourceConfig.register(StatisticOperations.class);
+    resourceConfig.register(
+        new AbstractBinder() {
+          @Override
+          protected void configure() {
+            bind(manager).to(StatisticManager.class).ranked(2);
+            
bindFactory(MockServletRequestFactory.class).to(HttpServletRequest.class);
+          }
+        });
+
+    return resourceConfig;
+  }
+
+  @Test
+  public void testListTableStatistics() {
+
+    AuditInfo auditInfo =
+        AuditInfo.builder()
+            .withCreateTime(Instant.now())
+            .withCreator("test")
+            .withLastModifiedTime(Instant.now())
+            .withLastModifier("test")
+            .build();
+
+    Statistic stat1 =
+        new StatisticManager.CustomStatistic(
+            "test", StatisticValues.stringValue("test"), auditInfo);
+
+    Statistic stat2 =
+        new StatisticManager.CustomStatistic("test2", 
StatisticValues.longValue(1L), auditInfo);
+
+    when(manager.listStatistics(any(), 
any())).thenReturn(Lists.newArrayList(stat1, stat2));
+    when(tableDispatcher.tableExists(any())).thenReturn(true);
+
+    MetadataObject tableObject =
+        MetadataObjects.parse(
+            String.format("%s.%s.%s", catalog, schema, table), 
MetadataObject.Type.TABLE);
+    Response resp =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp.getMediaType());
+
+    StatisticListResponse listResp = 
resp.readEntity(StatisticListResponse.class);
+    listResp.validate();
+    Assertions.assertEquals(0, listResp.getCode());
+
+    StatisticDTO[] statisticDTOS = listResp.getStatistics();
+    Assertions.assertEquals(2, statisticDTOS.length);
+    Assertions.assertEquals(stat1.name(), statisticDTOS[0].name());
+    Assertions.assertEquals(DTOConverters.toDTO(auditInfo), 
statisticDTOS[0].auditInfo());
+    Assertions.assertEquals(stat1.value().get(), 
statisticDTOS[0].value().get());
+    Assertions.assertEquals(stat2.name(), statisticDTOS[1].name());
+    Assertions.assertEquals(stat2.value().get(), 
statisticDTOS[1].value().get());
+    Assertions.assertEquals(DTOConverters.toDTO(auditInfo), 
statisticDTOS[1].auditInfo());
+
+    // Test throw NoSuchMetadataObjectException
+    when(tableDispatcher.tableExists(any())).thenReturn(false);
+
+    Response resp1 =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
resp1.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp1.getMediaType());
+
+    ErrorResponse errorResp = resp1.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, 
errorResp.getCode());
+    Assertions.assertEquals(
+        NoSuchMetadataObjectException.class.getSimpleName(), 
errorResp.getType());
+
+    // Test throw RuntimeException
+    when(tableDispatcher.tableExists(any())).thenReturn(true);
+    doThrow(new RuntimeException("mock 
error")).when(manager).listStatistics(any(), any());
+    Response resp2 =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(
+        Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), 
resp2.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp2.getMediaType());
+
+    ErrorResponse errorResp2 = resp2.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, 
errorResp2.getCode());
+    Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResp2.getType());
+  }
+
+  @Test
+  public void testUpdateTableStatistics() {
+    Map<String, StatisticValue<?>> statsMap = Maps.newHashMap();
+    statsMap.put(Statistic.CUSTOM_PREFIX + "test1", 
StatisticValues.stringValue("test"));
+    statsMap.put(Statistic.CUSTOM_PREFIX + "test2", 
StatisticValues.longValue(1L));
+
+    StatisticsUpdateRequest req = new StatisticsUpdateRequest(statsMap);
+    MetadataObject tableObject =
+        MetadataObjects.parse(
+            String.format("%s.%s.%s", catalog, schema, table), 
MetadataObject.Type.TABLE);
+
+    when(tableDispatcher.tableExists(any())).thenReturn(true);
+
+    Response resp =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .put(entity(req, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp.getMediaType());
+
+    BaseResponse updateResp = resp.readEntity(BaseResponse.class);
+    Assertions.assertEquals(0, updateResp.getCode());
+
+    // Test throw NoSuchMetadataObjectException
+    when(tableDispatcher.tableExists(any())).thenReturn(false);
+    Response resp1 =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .put(entity(req, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
resp1.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp1.getMediaType());
+
+    ErrorResponse errorResp = resp1.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, 
errorResp.getCode());
+    Assertions.assertEquals(
+        NoSuchMetadataObjectException.class.getSimpleName(), 
errorResp.getType());
+
+    // Test throw RuntimeException
+    when(tableDispatcher.tableExists(any())).thenReturn(true);
+    doThrow(new RuntimeException("mock 
error")).when(manager).updateStatistics(any(), any(), any());
+    Response resp2 =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .put(entity(req, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(
+        Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), 
resp2.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp2.getMediaType());
+
+    ErrorResponse errorResp2 = resp2.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, 
errorResp2.getCode());
+    Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResp2.getType());
+
+    // Test throw IllegalStatisticNameException
+    statsMap.put("test1", StatisticValues.longValue(1L));
+
+    req = new StatisticsUpdateRequest(statsMap);
+    Response resp3 =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .put(entity(req, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(
+        Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), 
resp3.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp3.getMediaType());
+
+    ErrorResponse errorResp3 = resp3.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, 
errorResp3.getCode());
+    Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResp3.getType());
+  }
+
+  @Test
+  public void testDropTableStatistics() {
+    StatisticsDropRequest req = new StatisticsDropRequest(new String[] 
{"test1", "test2"});
+    when(manager.dropStatistics(any(), any(), any())).thenReturn(true);
+    when(tableDispatcher.tableExists(any())).thenReturn(true);
+    MetadataObject tableObject =
+        MetadataObjects.parse(
+            String.format("%s.%s.%s", catalog, schema, table), 
MetadataObject.Type.TABLE);
+
+    Response resp =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(entity(req, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp.getMediaType());
+
+    DropResponse dropResp = resp.readEntity(DropResponse.class);
+    Assertions.assertEquals(0, dropResp.getCode());
+    Assertions.assertTrue(dropResp.dropped());
+
+    // Test throw NoSuchMetadataObjectException
+    when(tableDispatcher.tableExists(any())).thenReturn(false);
+    Response resp1 =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(entity(req, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
resp1.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp1.getMediaType());
+
+    ErrorResponse errorResp = resp1.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, 
errorResp.getCode());
+    Assertions.assertEquals(
+        NoSuchMetadataObjectException.class.getSimpleName(), 
errorResp.getType());
+
+    // Test throw RuntimeException
+    when(tableDispatcher.tableExists(any())).thenReturn(true);
+    doThrow(new RuntimeException("mock 
error")).when(manager).dropStatistics(any(), any(), any());
+    Response resp2 =
+        target(
+                "/metalakes/"
+                    + metalake
+                    + "/objects/"
+                    + tableObject.type()
+                    + "/"
+                    + tableObject.fullName()
+                    + "/statistics")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(entity(req, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(
+        Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), 
resp2.getStatus());
+    Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, 
resp2.getMediaType());
+
+    ErrorResponse errorResp2 = resp2.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, 
errorResp2.getCode());
+    Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResp2.getType());
+  }
+}

Reply via email to