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

jmclean 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 4ffe56ccd7 [#6481] improve(CLI): Refactor table output format (#6483)
4ffe56ccd7 is described below

commit 4ffe56ccd770e6614e1aab68311dd22f3e308479
Author: Lord of Abyss <103809695+abyss-l...@users.noreply.github.com>
AuthorDate: Fri Feb 21 17:15:20 2025 +0800

    [#6481] improve(CLI): Refactor table output format (#6483)
    
    ### What changes were proposed in this pull request?
    
    Refactor table output format, make  it easier to test and scale.
    
    ### Why are the changes needed?
    
    Fix: #6481
    
    ### Does this PR introduce _any_ user-facing change?
    
    No
    
    ### How was this patch tested?
    
    table format test
    
    ```bash
    gcli metalake list -i  --output table
    
    +-------------------+
    |     Metalake      |
    +-------------------+
    | demo              |
    | cli_demo          |
    | demo_metalake     |
    | test_cli_metalake |
    | tyy               |
    | demo3             |
    +-------------------+
    
    gcli metalake details -i --output table  -m demo_metalake
    
    +---------------+-------------+
    |   Metalake    |   Comment   |
    +---------------+-------------+
    | demo_metalake | new comment |
    +---------------+-------------+
    
    gcli catalog  list -i --output table  -m demo_metalake
    +-------------------+
    |      Catalog      |
    +-------------------+
    | File              |
    | Hive_catalog      |
    | Iceberg_catalog   |
    | Mysql_catalog     |
    | Test_hive_catalog |
    +-------------------+
    
    gcli catalog  details --name Hive_catalog  -i --output table  -m 
demo_metalake
    +--------------+------------+----------+-------------+
    |   Catalog    |    Type    | Provider |   Comment   |
    +--------------+------------+----------+-------------+
    | Hive_catalog | RELATIONAL | hive     | new comment |
    +--------------+------------+----------+-------------+
    ```
    
    plainformat test
    ```bash
    gcli metalake list -i
    # demo
    # cli_demo
    # demo_metalake
    # test_cli_metalake
    # demo3
    
    gcli metalake details -i  -m demo_metalake
    # demo_metalake, new comment
    
    gcli catalog  list -i   -m demo_metalake
    # File
    # Hive_catalog
    # Iceberg_catalog
    # Mysql_catalog
    # Test_hive_catalog
    
    gcli catalog  details --name Hive_catalog  -i  -m demo_metalake
    # Hive_catalog, RELATIONAL, hive, new comment
    ```
---
 .../gravitino/cli/commands/CatalogDetails.java     |   2 +-
 .../org/apache/gravitino/cli/commands/Command.java |  23 +-
 .../gravitino/cli/commands/ListCatalogs.java       |   6 +-
 .../gravitino/cli/commands/ListMetalakes.java      |   6 +-
 .../gravitino/cli/commands/MetalakeDetails.java    |   2 +-
 .../gravitino/cli/outputs/BaseOutputFormat.java    |  95 +++
 .../org/apache/gravitino/cli/outputs/Column.java   | 245 ++++++++
 .../org/apache/gravitino/cli/outputs/LineUtil.java | 103 ++++
 .../gravitino/cli/outputs/OutputConstant.java      |  62 ++
 .../apache/gravitino/cli/outputs/OutputFormat.java |  29 +-
 .../apache/gravitino/cli/outputs/PlainFormat.java  | 124 ++--
 .../apache/gravitino/cli/outputs/TableFormat.java  | 642 ++++++++++++++++-----
 .../cli/integration/test/TableFormatOutputIT.java  |  10 +-
 .../gravitino/cli/output/TestPlainFormat.java      | 171 ++++++
 .../gravitino/cli/output/TestTableFormat.java      | 348 +++++++++++
 15 files changed, 1652 insertions(+), 216 deletions(-)

diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CatalogDetails.java
 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CatalogDetails.java
index fac504a008..81365062c2 100644
--- 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CatalogDetails.java
+++ 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CatalogDetails.java
@@ -52,7 +52,7 @@ public class CatalogDetails extends Command {
     try {
       GravitinoClient client = buildClient(metalake);
       result = client.loadCatalog(catalog);
-      output(result);
+      printResults(result);
     } catch (NoSuchMetalakeException err) {
       exitWithError(ErrorMessages.UNKNOWN_METALAKE);
     } catch (NoSuchCatalogException err) {
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java
index 6ecef9278b..f789e2336b 100644
--- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java
+++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java
@@ -29,6 +29,7 @@ import org.apache.gravitino.cli.GravitinoConfig;
 import org.apache.gravitino.cli.KerberosData;
 import org.apache.gravitino.cli.Main;
 import org.apache.gravitino.cli.OAuthData;
+import org.apache.gravitino.cli.outputs.BaseOutputFormat;
 import org.apache.gravitino.cli.outputs.PlainFormat;
 import org.apache.gravitino.cli.outputs.TableFormat;
 import org.apache.gravitino.client.DefaultOAuth2TokenProvider;
@@ -89,16 +90,26 @@ public abstract class Command {
       return;
     }
 
-    System.out.print(message);
+    printResults(message);
   }
 
   /**
-   * Prints out an a results of a command.
+   * Outputs the entity result to the console.
+   *
+   * @param entity The entity to output.
+   * @param <T> The type of entity.
+   */
+  public <T> void printResults(T entity) {
+    output(entity);
+  }
+
+  /**
+   * Prints out the string result of a command.
    *
    * @param results The results to display.
    */
   public void printResults(String results) {
-    System.out.print(results);
+    BaseOutputFormat.output(results, System.out);
   }
 
   /**
@@ -226,14 +237,14 @@ public abstract class Command {
    */
   protected <T> void output(T entity) {
     if (outputFormat == null) {
-      PlainFormat.output(entity);
+      PlainFormat.output(entity, context);
       return;
     }
 
     if (outputFormat.equals(OUTPUT_FORMAT_TABLE)) {
-      TableFormat.output(entity);
+      TableFormat.output(entity, context);
     } else if (outputFormat.equals(OUTPUT_FORMAT_PLAIN)) {
-      PlainFormat.output(entity);
+      PlainFormat.output(entity, context);
     } else {
       throw new IllegalArgumentException("Unsupported output format");
     }
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListCatalogs.java 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListCatalogs.java
index ad8d171fec..6dc8b4cea3 100644
--- 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListCatalogs.java
+++ 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListCatalogs.java
@@ -48,7 +48,11 @@ public class ListCatalogs extends Command {
     try {
       GravitinoClient client = buildClient(metalake);
       catalogs = client.listCatalogsInfo();
-      output(catalogs);
+      if (catalogs.length == 0) {
+        printInformation("No catalogs exist.");
+        return;
+      }
+      printResults(catalogs);
     } catch (NoSuchMetalakeException err) {
       exitWithError(ErrorMessages.UNKNOWN_METALAKE);
     } catch (Exception exp) {
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListMetalakes.java
 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListMetalakes.java
index a97e89bca2..0a030a3478 100644
--- 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListMetalakes.java
+++ 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListMetalakes.java
@@ -42,7 +42,11 @@ public class ListMetalakes extends Command {
     try {
       GravitinoAdminClient client = buildAdminClient();
       metalakes = client.listMetalakes();
-      output(metalakes);
+      if (metalakes.length == 0) {
+        printInformation("No metalakes exist.");
+        return;
+      }
+      printResults(metalakes);
     } catch (Exception exp) {
       exitWithError(exp.getMessage());
     }
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/MetalakeDetails.java
 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/MetalakeDetails.java
index cc2bf6ce4b..b427052c0c 100644
--- 
a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/MetalakeDetails.java
+++ 
b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/MetalakeDetails.java
@@ -46,7 +46,7 @@ public class MetalakeDetails extends Command {
     try {
       GravitinoClient client = buildClient(metalake);
       Metalake metalakeEntity = client.loadMetalake(metalake);
-      output(metalakeEntity);
+      printResults(metalakeEntity);
     } catch (NoSuchMetalakeException err) {
       exitWithError(ErrorMessages.UNKNOWN_METALAKE);
     } catch (Exception exp) {
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/BaseOutputFormat.java
 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/BaseOutputFormat.java
new file mode 100644
index 0000000000..a233fe8751
--- /dev/null
+++ 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/BaseOutputFormat.java
@@ -0,0 +1,95 @@
+/*
+ * 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.cli.outputs;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import org.apache.gravitino.cli.CommandContext;
+
+/**
+ * Abstract base implementation of {@link OutputFormat} interface providing 
common functionality for
+ * various output format implementations.
+ */
+public abstract class BaseOutputFormat<T> implements OutputFormat<T> {
+  protected CommandContext context;
+
+  /**
+   * Creates a new {@link BaseOutputFormat} with specified configuration.
+   *
+   * @param context the command context, must not be null;
+   */
+  public BaseOutputFormat(CommandContext context) {
+    this.context = context;
+  }
+
+  /**
+   * Outputs a message to the specified OutputStream. This method handles both 
system streams
+   * ({@code System.out}, {@code System.err}) and regular output streams 
differently: - For system
+   * streams: Preserves the stream open after writing - For other streams: 
Automatically closes the
+   * stream after writing
+   *
+   * @param message the message to output, must not be null
+   * @param os the output stream to write to, must not be null If this is 
{@code System.out} or
+   *     {@code System.err}, the stream will not be closed
+   * @throws IllegalArgumentException if either message or os is null
+   * @throws UncheckedIOException if an I/O error occurs during writing
+   */
+  public static void output(String message, OutputStream os) {
+    if (message == null || os == null) {
+      throw new IllegalArgumentException(
+          "Message and OutputStream cannot be null, message: " + message + ", 
os: " + os);
+    }
+    boolean isSystemStream = (os == System.out || os == System.err);
+
+    try {
+      PrintStream printStream =
+          new PrintStream(
+              isSystemStream ? os : new BufferedOutputStream(os),
+              true,
+              StandardCharsets.UTF_8.name());
+
+      try {
+        printStream.println(message);
+        printStream.flush();
+      } finally {
+        if (!isSystemStream) {
+          printStream.close();
+        }
+      }
+    } catch (IOException e) {
+      throw new UncheckedIOException("Failed to write message to output 
stream", e);
+    }
+  }
+
+  /**
+   * {@inheritDoc} This implementation checks the quiet flag and handles null 
output gracefully. If
+   * quiet mode is enabled, no output is produced.
+   */
+  @Override
+  public void output(T entity) {
+    String outputMessage = getOutput(entity);
+    String output = outputMessage == null ? "" : outputMessage;
+    output(output, System.out);
+  }
+}
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/Column.java 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/Column.java
new file mode 100644
index 0000000000..25c92a9d7b
--- /dev/null
+++ b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/Column.java
@@ -0,0 +1,245 @@
+/*
+ * 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.cli.outputs;
+
+import com.google.common.collect.Lists;
+import java.util.List;
+import org.apache.gravitino.cli.CommandContext;
+
+/**
+ * Represents a column in a formatted table output. Manages column properties 
including header,
+ * alignment, and content cells. Handles width calculations.
+ */
+public class Column {
+  public static final char ELLIPSIS = '…';
+  private final String header;
+  private final HorizontalAlign headerAlign;
+  private final HorizontalAlign dataAlign;
+  private final CommandContext context;
+
+  private int maxWidth;
+  private List<String> cellContents;
+
+  /**
+   * Creates a new {@code Column} instance with the specified header and 
default alignment.
+   *
+   * @param context the command context.
+   * @param header the header of the column.
+   */
+  public Column(CommandContext context, String header) {
+    this(context, header, HorizontalAlign.CENTER, HorizontalAlign.LEFT);
+  }
+
+  /**
+   * Creates a new {@code Column} instance with the specified header and 
alignment.
+   *
+   * @param context the command context.
+   * @param header the header of the column.
+   * @param headerAlign the alignment of the header.
+   * @param dataAlign the alignment of the data in the column.
+   */
+  public Column(
+      CommandContext context,
+      String header,
+      HorizontalAlign headerAlign,
+      HorizontalAlign dataAlign) {
+    this.context = context;
+    this.header = LineUtil.capitalize(header);
+    this.headerAlign = headerAlign;
+    this.dataAlign = dataAlign;
+
+    this.cellContents = Lists.newArrayList();
+    this.maxWidth = LineUtil.getDisplayWidth(header);
+  }
+
+  /**
+   * Specifies the horizontal text alignment within table elements such as 
cells and headers. This
+   * enum provides options for standard left-to-right text positioning.
+   */
+  public enum HorizontalAlign {
+    LEFT,
+    CENTER,
+    RIGHT
+  }
+
+  /**
+   * Returns the header of the column.
+   *
+   * @return the header of the column.
+   */
+  public String getHeader() {
+    return header;
+  }
+
+  /**
+   * Returns the alignment of the header.
+   *
+   * @return the alignment of the header.
+   */
+  public HorizontalAlign getHeaderAlign() {
+    return headerAlign;
+  }
+
+  /**
+   * Returns the alignment of the data in the column.
+   *
+   * @return the alignment of the data in the column.
+   */
+  public HorizontalAlign getDataAlign() {
+    return dataAlign;
+  }
+
+  /**
+   * Returns the maximum width of the column.
+   *
+   * @return the maximum width of the column.
+   */
+  public int getMaxWidth() {
+    return maxWidth;
+  }
+
+  /**
+   * Returns the command context.
+   *
+   * @return the {@link CommandContext} instance.
+   */
+  public CommandContext getContext() {
+    return context;
+  }
+
+  /**
+   * Returns a copy of this column.
+   *
+   * @return a copy of this column.
+   */
+  public Column copy() {
+    return new Column(context, header, headerAlign, dataAlign);
+  }
+
+  /**
+   * Adds a cell to the column and updates the maximum width of the column.
+   *
+   * @param cell the cell to add to the column.
+   * @return this column instance, for chaining.
+   */
+  public Column addCell(String cell) {
+    if (cell == null) {
+      cell = "null";
+    }
+
+    maxWidth = Math.max(maxWidth, LineUtil.getDisplayWidth(cell));
+    cellContents.add(cell);
+    return this;
+  }
+
+  /**
+   * Adds a cell to the column and updates the maximum width of the column.
+   *
+   * @param cell the cell to add to the column.
+   * @return this column instance, for chaining.
+   */
+  public Column addCell(Object cell) {
+    return addCell(cell == null ? "null" : cell.toString());
+  }
+
+  /**
+   * Adds a cell to the column and updates the maximum width of the column.
+   *
+   * @param cell the cell to add to the column.
+   * @return this column instance, for chaining.
+   */
+  public Column addCell(char cell) {
+    return addCell(String.valueOf(cell));
+  }
+
+  /**
+   * Adds a cell to the column and updates the maximum width of the column.
+   *
+   * @param cell the cell to add to the column.
+   * @return this column instance, for chaining.
+   */
+  public Column addCell(int cell) {
+    return addCell(String.valueOf(cell));
+  }
+
+  /**
+   * Adds a cell to the column and updates the maximum width of the column.
+   *
+   * @param cell the cell to add to the column.
+   * @return this column instance, for chaining.
+   */
+  public Column addCell(double cell) {
+    return addCell(String.valueOf(cell));
+  }
+
+  /**
+   * Adds a cell to the column and updates the maximum width of the column.
+   *
+   * @param cell the cell to add to the column.
+   * @return this column instance, for chaining.
+   */
+  public Column addCell(boolean cell) {
+    return addCell(String.valueOf(cell));
+  }
+
+  /**
+   * Returns a limited version of this column, with a maximum of {@code limit} 
cells.
+   *
+   * @param limit the maximum number of cells to include in the limited column.
+   * @return a limited version of this column, with a maximum of {@code limit} 
cells.
+   */
+  public Column getLimitedColumn(int limit) {
+    if (cellContents.size() <= limit) {
+      return this;
+    }
+
+    Column newColumn = copy();
+    newColumn.cellContents = cellContents.subList(0, Math.min(limit, 
cellContents.size()));
+    newColumn.reCalculateMaxWidth();
+    newColumn.addCell(ELLIPSIS);
+
+    return newColumn;
+  }
+
+  /**
+   * Returns the cell at the specified index.
+   *
+   * @param index the index of the cell to return.
+   * @return the cell at the specified index.
+   */
+  public String getCell(int index) {
+    return cellContents.get(index);
+  }
+
+  /**
+   * Returns the number of cells in the column.
+   *
+   * @return the number of cells in the column.
+   */
+  public int getCellCount() {
+    return cellContents.size();
+  }
+
+  private void reCalculateMaxWidth() {
+    for (String cell : cellContents) {
+      maxWidth = Math.max(maxWidth, LineUtil.getDisplayWidth(cell));
+    }
+  }
+}
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/LineUtil.java 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/LineUtil.java
new file mode 100644
index 0000000000..7aadfe5e52
--- /dev/null
+++ b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/LineUtil.java
@@ -0,0 +1,103 @@
+/*
+ * 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.cli.outputs;
+
+import com.google.common.base.Preconditions;
+import java.util.regex.Pattern;
+
+public class LineUtil {
+  // This expression is primarily used to match characters that have a display 
width of
+  // 2, such as characters from Korean, Chinese
+  private static final Pattern FULL_WIDTH_PATTERN =
+      Pattern.compile(
+          
"[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]");
+
+  /**
+   * Get the display width of a string.
+   *
+   * @param str the string to measure.
+   * @return the display width of the string.
+   */
+  public static int getDisplayWidth(String str) {
+    int width = 0;
+    for (int i = 0; i < str.length(); i++) {
+      width += getCharWidth(str.charAt(i));
+    }
+
+    return width;
+  }
+
+  private static int getCharWidth(char ch) {
+    String s = String.valueOf(ch);
+    if (FULL_WIDTH_PATTERN.matcher(s).find()) {
+      return 2;
+    }
+
+    return 1;
+  }
+
+  /**
+   * Get the space string of the specified length.
+   *
+   * @param n the length of the space string to get.
+   * @return the space string of the specified length.
+   */
+  public static String getSpaces(int n) {
+    Preconditions.checkArgument(n >= 0, "n must be non-negative");
+    StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < n; i++) {
+      sb.append(' ');
+    }
+    return sb.toString();
+  }
+
+  /**
+   * Capitalize the first letter of a string.
+   *
+   * @param str the string to capitalize.
+   * @return the capitalized string.
+   */
+  public static String capitalize(String str) {
+    int strLen = str.length();
+    if (strLen == 0) {
+      return str;
+    } else {
+      int firstCodepoint = str.codePointAt(0);
+      int newCodePoint = Character.toTitleCase(firstCodepoint);
+      if (firstCodepoint == newCodePoint) {
+        return str;
+      } else {
+        int[] newCodePoints = new int[strLen];
+        int outOffset = 0;
+        newCodePoints[outOffset++] = newCodePoint;
+
+        int codePoint;
+        for (int inOffset = Character.charCount(firstCodepoint);
+            inOffset < strLen;
+            inOffset += Character.charCount(codePoint)) {
+          codePoint = str.codePointAt(inOffset);
+          newCodePoints[outOffset++] = codePoint;
+        }
+
+        return new String(newCodePoints, 0, outOffset);
+      }
+    }
+  }
+}
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/OutputConstant.java
 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/OutputConstant.java
new file mode 100644
index 0000000000..756984485e
--- /dev/null
+++ 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/OutputConstant.java
@@ -0,0 +1,62 @@
+/*
+ * 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.cli.outputs;
+
+import com.google.common.collect.ImmutableList;
+
+public class OutputConstant {
+  public static final ImmutableList<Character> BASIC_ASCII =
+      ImmutableList.of(
+          '+', '-', '+', '+', '|', '|', '|', '+', '-', '+', '+', '|', '|', 
'|', '+', '-', '+', '+',
+          '+', '-', '+', '+', '|', '|', '|', '+', '-', '+', '+');
+
+  // ===== Table Upper Border Indices =====
+  public static final int TABLE_UPPER_BORDER_LEFT_IDX = 0;
+  public static final int TABLE_UPPER_BORDER_MIDDLE_IDX = 1;
+  public static final int TABLE_UPPER_BORDER_COLUMN_SEPARATOR_IDX = 2;
+  public static final int TABLE_UPPER_BORDER_RIGHT_IDX = 3;
+
+  // ===== Data Line Indices =====
+  public static final int DATA_LINE_LEFT_IDX = 4;
+  public static final int DATA_LINE_COLUMN_SEPARATOR_IDX = 5;
+  public static final int DATA_LINE_RIGHT_IDX = 6;
+
+  // ===== Data Row Border Indices =====
+  public static final int DATA_ROW_BORDER_LEFT_IDX = 14;
+  public static final int DATA_ROW_BORDER_MIDDLE_IDX = 15;
+  public static final int DATA_ROW_BORDER_COLUMN_SEPARATOR_IDX = 16;
+  public static final int DATA_ROW_BORDER_RIGHT_IDX = 17;
+
+  // ===== Table Bottom Border Indices =====
+  public static final int TABLE_BOTTOM_BORDER_LEFT_IDX = 25;
+  public static final int TABLE_BOTTOM_BORDER_MIDDLE_IDX = 26;
+  public static final int TABLE_BOTTOM_BORDER_COLUMN_SEPARATOR_IDX = 27;
+  public static final int TABLE_BOTTOM_BORDER_RIGHT_IDX = 28;
+
+  // ===== Header Bottom Border Indices =====
+  public static final int HEADER_BOTTOM_BORDER_LEFT_IDX = 18;
+  public static final int HEADER_BOTTOM_BORDER_MIDDLE_IDX = 19;
+  public static final int HEADER_BOTTOM_BORDER_COLUMN_SEPARATOR_IDX = 20;
+  public static final int HEADER_BOTTOM_BORDER_RIGHT_IDX = 21;
+
+  private OutputConstant() {
+    // private constructor to prevent instantiation
+  }
+}
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/OutputFormat.java 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/OutputFormat.java
index 8e6ab31162..fe0d7b7c9e 100644
--- 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/OutputFormat.java
+++ 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/OutputFormat.java
@@ -18,7 +18,32 @@
  */
 package org.apache.gravitino.cli.outputs;
 
-/** Output format interface for the CLI results. */
+import com.google.common.base.Joiner;
+
+/**
+ * Defines formatting behavior for command-line interface output. 
Implementations of this interface
+ * handle the conversion of entities to their string representation in 
specific output formats.
+ */
 public interface OutputFormat<T> {
-  void output(T object);
+  /** Joiner for creating comma-separated output strings, ignoring null values 
*/
+  Joiner COMMA_JOINER = Joiner.on(", ").skipNulls();
+  /** Joiner for creating line-separated output strings, ignoring null values 
*/
+  Joiner NEWLINE_JOINER = Joiner.on(System.lineSeparator()).skipNulls();
+
+  /**
+   * Displays the entity in the specified output format. This method handles 
the actual output
+   * operation
+   *
+   * @param entity The entity to be formatted and output
+   */
+  void output(T entity);
+
+  /**
+   * Returns entity's string representation. This method only handles the 
formatting without
+   * performing any I/O operations.
+   *
+   * @param entity The entity to be formatted
+   * @return The formatted string representation of the entity
+   */
+  String getOutput(T entity);
 }
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/PlainFormat.java 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/PlainFormat.java
index 66e616c4f7..f61578ffc6 100644
--- 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/PlainFormat.java
+++ 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/PlainFormat.java
@@ -23,68 +23,114 @@ import java.util.List;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.Metalake;
+import org.apache.gravitino.cli.CommandContext;
 
 /** Plain format to print a pretty string to standard out. */
-public class PlainFormat {
-  public static void output(Object object) {
-    if (object instanceof Metalake) {
-      new MetalakePlainFormat().output((Metalake) object);
-    } else if (object instanceof Metalake[]) {
-      new MetalakesPlainFormat().output((Metalake[]) object);
-    } else if (object instanceof Catalog) {
-      new CatalogPlainFormat().output((Catalog) object);
-    } else if (object instanceof Catalog[]) {
-      new CatalogsPlainFormat().output((Catalog[]) object);
+public abstract class PlainFormat<T> extends BaseOutputFormat<T> {
+
+  /**
+   * Routes the object to its appropriate formatter and outputs the formatted 
result. Creates a new
+   * formatter instance for the given object type and delegates the formatting.
+   *
+   * @param entity The object to format
+   * @param context The command context
+   * @throws IllegalArgumentException if the object type is not supported
+   */
+  public static void output(Object entity, CommandContext context) {
+    if (entity instanceof Metalake) {
+      new MetalakePlainFormat(context).output((Metalake) entity);
+    } else if (entity instanceof Metalake[]) {
+      new MetalakeListPlainFormat(context).output((Metalake[]) entity);
+    } else if (entity instanceof Catalog) {
+      new CatalogPlainFormat(context).output((Catalog) entity);
+    } else if (entity instanceof Catalog[]) {
+      new CatalogListPlainFormat(context).output((Catalog[]) entity);
     } else {
       throw new IllegalArgumentException("Unsupported object type");
     }
   }
 
-  static final class MetalakePlainFormat implements OutputFormat<Metalake> {
+  /**
+   * Creates a new {@link PlainFormat} with the specified output properties.
+   *
+   * @param context The command context.
+   */
+  public PlainFormat(CommandContext context) {
+    super(context);
+  }
+
+  /**
+   * Formats a single {@link Metalake} instance as a comma-separated string. 
Output format: name,
+   * comment
+   */
+  static final class MetalakePlainFormat extends PlainFormat<Metalake> {
+
+    public MetalakePlainFormat(CommandContext context) {
+      super(context);
+    }
+
+    /** {@inheritDoc} */
     @Override
-    public void output(Metalake metalake) {
-      System.out.println(metalake.name() + "," + metalake.comment());
+    public String getOutput(Metalake metalake) {
+      return COMMA_JOINER.join(metalake.name(), metalake.comment());
     }
   }
 
-  static final class MetalakesPlainFormat implements OutputFormat<Metalake[]> {
+  /**
+   * Formats an array of Metalakes, outputting one name per line. Returns null 
if the array is empty
+   * or null.
+   */
+  static final class MetalakeListPlainFormat extends PlainFormat<Metalake[]> {
+
+    public MetalakeListPlainFormat(CommandContext context) {
+      super(context);
+    }
+
+    /** {@inheritDoc} */
     @Override
-    public void output(Metalake[] metalakes) {
-      if (metalakes.length == 0) {
-        System.out.println("No metalakes exist.");
-      } else {
-        List<String> metalakeNames =
-            
Arrays.stream(metalakes).map(Metalake::name).collect(Collectors.toList());
-        String all = String.join(System.lineSeparator(), metalakeNames);
-        System.out.println(all);
-      }
+    public String getOutput(Metalake[] metalakes) {
+      List<String> metalakeNames =
+          
Arrays.stream(metalakes).map(Metalake::name).collect(Collectors.toList());
+      return NEWLINE_JOINER.join(metalakeNames);
     }
   }
 
-  static final class CatalogPlainFormat implements OutputFormat<Catalog> {
+  /**
+   * Formats a single {@link Catalog} instance as a comma-separated string. 
Output format: name,
+   * type, provider, comment
+   */
+  static final class CatalogPlainFormat extends PlainFormat<Catalog> {
+    public CatalogPlainFormat(CommandContext context) {
+      super(context);
+    }
+
+    /** {@inheritDoc} */
     @Override
-    public void output(Catalog catalog) {
-      System.out.println(
-          catalog.name()
-              + ","
-              + catalog.type()
-              + ","
-              + catalog.provider()
-              + ","
-              + catalog.comment());
+    public String getOutput(Catalog catalog) {
+      return COMMA_JOINER.join(
+          catalog.name(), catalog.type(), catalog.provider(), 
catalog.comment());
     }
   }
 
-  static final class CatalogsPlainFormat implements OutputFormat<Catalog[]> {
+  /**
+   * Formats an array of Catalogs, outputting one name per line. Returns null 
if the array is empty
+   * or null.
+   */
+  static final class CatalogListPlainFormat extends PlainFormat<Catalog[]> {
+    public CatalogListPlainFormat(CommandContext context) {
+      super(context);
+    }
+
+    /** {@inheritDoc} */
     @Override
-    public void output(Catalog[] catalogs) {
-      if (catalogs.length == 0) {
-        System.out.println("No catalogs exist.");
+    public String getOutput(Catalog[] catalogs) {
+      if (catalogs == null || catalogs.length == 0) {
+        output("No catalogs exist.", System.out);
+        return null;
       } else {
         List<String> catalogNames =
             
Arrays.stream(catalogs).map(Catalog::name).collect(Collectors.toList());
-        String all = String.join(System.lineSeparator(), catalogNames);
-        System.out.println(all);
+        return NEWLINE_JOINER.join(catalogNames);
       }
     }
   }
diff --git 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/TableFormat.java 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/TableFormat.java
index a3c9975652..6d08f73edf 100644
--- 
a/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/TableFormat.java
+++ 
b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/TableFormat.java
@@ -18,204 +18,526 @@
  */
 package org.apache.gravitino.cli.outputs;
 
-import java.util.ArrayList;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.DATA_LINE_COLUMN_SEPARATOR_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.DATA_LINE_LEFT_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.DATA_LINE_RIGHT_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.DATA_ROW_BORDER_COLUMN_SEPARATOR_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.DATA_ROW_BORDER_LEFT_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.DATA_ROW_BORDER_MIDDLE_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.DATA_ROW_BORDER_RIGHT_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.HEADER_BOTTOM_BORDER_COLUMN_SEPARATOR_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.HEADER_BOTTOM_BORDER_LEFT_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.HEADER_BOTTOM_BORDER_MIDDLE_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.HEADER_BOTTOM_BORDER_RIGHT_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.TABLE_BOTTOM_BORDER_COLUMN_SEPARATOR_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.TABLE_BOTTOM_BORDER_LEFT_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.TABLE_BOTTOM_BORDER_MIDDLE_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.TABLE_BOTTOM_BORDER_RIGHT_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.TABLE_UPPER_BORDER_COLUMN_SEPARATOR_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.TABLE_UPPER_BORDER_LEFT_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.TABLE_UPPER_BORDER_MIDDLE_IDX;
+import static 
org.apache.gravitino.cli.outputs.OutputConstant.TABLE_UPPER_BORDER_RIGHT_IDX;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
-import java.util.regex.Pattern;
+import java.util.Objects;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.Metalake;
+import org.apache.gravitino.cli.CommandContext;
+
+/**
+ * Abstract base class for formatting entity information into ASCII-art 
tables. Provides
+ * comprehensive table rendering with features including: - Header and footer 
rows - Column
+ * alignments and padding - Border styles and row separators - Content 
overflow handling - Row
+ * numbers - Data limiting and sorting
+ */
+public abstract class TableFormat<T> extends BaseOutputFormat<T> {
+  public static final int PADDING = 1;
 
-/** Table format to print a pretty table to standard out. */
-public class TableFormat {
-  public static void output(Object object) {
-    if (object instanceof Metalake) {
-      new MetalakeTableFormat().output((Metalake) object);
-    } else if (object instanceof Metalake[]) {
-      new MetalakesTableFormat().output((Metalake[]) object);
-    } else if (object instanceof Catalog) {
-      new CatalogTableFormat().output((Catalog) object);
-    } else if (object instanceof Catalog[]) {
-      new CatalogsTableFormat().output((Catalog[]) object);
+  /**
+   * Routes the entity object to its appropriate table formatter. Creates a 
new formatter instance
+   * based on the object's type.
+   *
+   * @param entity The object to format.
+   * @param context the command context.
+   * @throws IllegalArgumentException if the object type is not supported
+   */
+  public static void output(Object entity, CommandContext context) {
+    if (entity instanceof Metalake) {
+      new MetalakeTableFormat(context).output((Metalake) entity);
+    } else if (entity instanceof Metalake[]) {
+      new MetalakeListTableFormat(context).output((Metalake[]) entity);
+    } else if (entity instanceof Catalog) {
+      new CatalogTableFormat(context).output((Catalog) entity);
+    } else if (entity instanceof Catalog[]) {
+      new CatalogListTableFormat(context).output((Catalog[]) entity);
     } else {
       throw new IllegalArgumentException("Unsupported object type");
     }
   }
 
-  static final class MetalakeTableFormat implements OutputFormat<Metalake> {
-    @Override
-    public void output(Metalake metalake) {
-      List<String> headers = Arrays.asList("metalake", "comment");
-      List<List<String>> rows = new ArrayList<>();
-      rows.add(Arrays.asList(metalake.name(), metalake.comment()));
-      TableFormatImpl tableFormat = new TableFormatImpl();
-      tableFormat.print(headers, rows);
-    }
+  /**
+   * Creates a new {@link TableFormat} with the specified properties.
+   *
+   * @param context the command context.
+   */
+  public TableFormat(CommandContext context) {
+    super(context);
+    // TODO: add other options for TableFormat
   }
 
-  static final class MetalakesTableFormat implements OutputFormat<Metalake[]> {
-    @Override
-    public void output(Metalake[] metalakes) {
-      if (metalakes.length == 0) {
-        System.out.println("No metalakes exist.");
-      } else {
-        List<String> headers = Collections.singletonList("metalake");
-        List<List<String>> rows = new ArrayList<>();
-        for (int i = 0; i < metalakes.length; i++) {
-          rows.add(Arrays.asList(metalakes[i].name()));
-        }
-        TableFormatImpl tableFormat = new TableFormatImpl();
-        tableFormat.print(headers, rows);
-      }
+  /**
+   * Get the formatted output string for the given columns.
+   *
+   * @param columns the columns to print.
+   * @return the table formatted output string.
+   */
+  public String getTableFormat(Column... columns) {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+
+    String[] headers =
+        Arrays.stream(columns)
+            .map(Column::getHeader)
+            .filter(Objects::nonNull)
+            .toArray(String[]::new);
+
+    List<Character> borders = OutputConstant.BASIC_ASCII;
+
+    if (headers.length != columns.length) {
+      throw new IllegalArgumentException("Headers must be provided for all 
columns");
     }
+
+    try (OutputStreamWriter osw = new OutputStreamWriter(baos, 
StandardCharsets.UTF_8)) {
+      writeUpperBorder(osw, borders, System.lineSeparator(), columns);
+      writeHeader(osw, borders, System.lineSeparator(), columns);
+      writeHeaderBorder(osw, borders, System.lineSeparator(), columns);
+      writeData(osw, borders, columns, System.lineSeparator());
+      writeBottomBorder(osw, borders, System.lineSeparator(), columns);
+
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    return new String(baos.toByteArray(), StandardCharsets.UTF_8);
   }
 
-  static final class CatalogTableFormat implements OutputFormat<Catalog> {
-    @Override
-    public void output(Catalog catalog) {
-      List<String> headers = Arrays.asList("catalog", "type", "provider", 
"comment");
-      List<List<String>> rows = new ArrayList<>();
-      rows.add(
-          Arrays.asList(
-              catalog.name(),
-              catalog.type().toString(),
-              catalog.provider(),
-              catalog.comment() + ""));
-      TableFormatImpl tableFormat = new TableFormatImpl();
-      tableFormat.print(headers, rows);
+  /**
+   * Writes the top border of the table using specified border characters.
+   *
+   * @param writer the writer for output
+   * @param borders the collection of border characters for rendering
+   * @param lineSeparator the system-specific line separator
+   * @param columns the array of columns defining the table structure
+   * @throws IOException if an error occurs while writing to the output
+   */
+  private static void writeUpperBorder(
+      OutputStreamWriter writer, List<Character> borders, String 
lineSeparator, Column[] columns)
+      throws IOException {
+    writeHorizontalLine(
+        writer,
+        borders.get(TABLE_UPPER_BORDER_LEFT_IDX),
+        borders.get(TABLE_UPPER_BORDER_MIDDLE_IDX),
+        borders.get(TABLE_UPPER_BORDER_COLUMN_SEPARATOR_IDX),
+        borders.get(TABLE_UPPER_BORDER_RIGHT_IDX),
+        lineSeparator,
+        columns);
+  }
+
+  /**
+   * Writes the bottom border that separates the header from the table content.
+   *
+   * @param writer the writer for output
+   * @param borders the collection of border characters for rendering
+   * @param lineSeparator the system-specific line separator
+   * @param columns the array of columns defining the table structure
+   * @throws IOException if an error occurs while writing to the output
+   */
+  private static void writeHeaderBorder(
+      OutputStreamWriter writer, List<Character> borders, String 
lineSeparator, Column[] columns)
+      throws IOException {
+    writeHorizontalLine(
+        writer,
+        borders.get(HEADER_BOTTOM_BORDER_LEFT_IDX),
+        borders.get(HEADER_BOTTOM_BORDER_MIDDLE_IDX),
+        borders.get(HEADER_BOTTOM_BORDER_COLUMN_SEPARATOR_IDX),
+        borders.get(HEADER_BOTTOM_BORDER_RIGHT_IDX),
+        lineSeparator,
+        columns);
+  }
+
+  /**
+   * Writes the separator line between data rows.
+   *
+   * @param writer the writer for output
+   * @param borders the collection of border characters for rendering
+   * @param lineSeparator the system-specific line separator
+   * @param columns the array of columns defining the table structure
+   * @throws IOException if an error occurs while writing to the output
+   */
+  private static void writeRowSeparator(
+      OutputStreamWriter writer, List<Character> borders, String 
lineSeparator, Column[] columns)
+      throws IOException {
+    writeHorizontalLine(
+        writer,
+        borders.get(DATA_ROW_BORDER_LEFT_IDX),
+        borders.get(DATA_ROW_BORDER_MIDDLE_IDX),
+        borders.get(DATA_ROW_BORDER_COLUMN_SEPARATOR_IDX),
+        borders.get(DATA_ROW_BORDER_RIGHT_IDX),
+        lineSeparator,
+        columns);
+  }
+
+  /**
+   * Writes the bottom border that closes the table.
+   *
+   * @param writer the writer for output
+   * @param borders the collection of border characters for rendering
+   * @param lineSeparator the system-specific line separator
+   * @param columns the array of columns defining the table structure
+   * @throws IOException if an error occurs while writing to the output
+   */
+  private static void writeBottomBorder(
+      OutputStreamWriter writer, List<Character> borders, String 
lineSeparator, Column[] columns)
+      throws IOException {
+    writeHorizontalLine(
+        writer,
+        borders.get(TABLE_BOTTOM_BORDER_LEFT_IDX),
+        borders.get(TABLE_BOTTOM_BORDER_MIDDLE_IDX),
+        borders.get(TABLE_BOTTOM_BORDER_COLUMN_SEPARATOR_IDX),
+        borders.get(TABLE_BOTTOM_BORDER_RIGHT_IDX),
+        lineSeparator,
+        columns);
+  }
+
+  /**
+   * Writes the data rows of the table.
+   *
+   * <p>For each row of data:
+   *
+   * <ul>
+   *   <li>Writes the data line with appropriate borders and alignment
+   *   <li>If not the last row and row boundaries are enabled in the style, 
writes a separator line
+   *       between rows
+   * </ul>
+   *
+   * @param writer the writer for output
+   * @param borders the collection of border characters for rendering
+   * @param columns the array of columns containing the data to write
+   * @param lineSeparator the system-specific line separator
+   * @throws IOException if an error occurs while writing to the output
+   */
+  private void writeData(
+      OutputStreamWriter writer, List<Character> borders, Column[] columns, 
String lineSeparator)
+      throws IOException {
+    int dataSize = columns[0].getCellCount();
+    Column.HorizontalAlign[] dataAligns =
+        
Arrays.stream(columns).map(Column::getDataAlign).toArray(Column.HorizontalAlign[]::new);
+
+    for (int i = 0; i < dataSize; i++) {
+      String[] data = getData(columns, i);
+      writeRow(
+          writer,
+          borders.get(DATA_LINE_LEFT_IDX),
+          borders.get(DATA_LINE_COLUMN_SEPARATOR_IDX),
+          borders.get(DATA_LINE_RIGHT_IDX),
+          data,
+          columns,
+          dataAligns,
+          lineSeparator);
     }
   }
 
-  static final class CatalogsTableFormat implements OutputFormat<Catalog[]> {
-    @Override
-    public void output(Catalog[] catalogs) {
-      if (catalogs.length == 0) {
-        System.out.println("No catalogs exist.");
-      } else {
-        List<String> headers = Collections.singletonList("catalog");
-        List<List<String>> rows = new ArrayList<>();
-        for (int i = 0; i < catalogs.length; i++) {
-          rows.add(Arrays.asList(catalogs[i].name()));
-        }
-        TableFormatImpl tableFormat = new TableFormatImpl();
-        tableFormat.print(headers, rows);
+  /**
+   * Writes a horizontal line in the table using specified border characters. 
The line consists of
+   * repeated middle characters for each column width, separated by column 
separators and bounded by
+   * left/right borders.
+   *
+   * @param osw The output stream writer for writing the line.
+   * @param left The character used for the left border.
+   * @param middle The character to repeat for creating the line.
+   * @param columnSeparator The character used between columns.
+   * @param right The character used for the right border.
+   * @param lineSeparator The line separator to append.
+   * @param columns Array of columns containing width information.
+   * @throws IOException If an error occurs while writing to the output stream.
+   */
+  private static void writeHorizontalLine(
+      OutputStreamWriter osw,
+      Character left,
+      Character middle,
+      Character columnSeparator,
+      Character right,
+      String lineSeparator,
+      Column[] columns)
+      throws IOException {
+
+    Integer[] colWidths =
+        Arrays.stream(columns).map(s -> s.getMaxWidth() + 2 * 
PADDING).toArray(Integer[]::new);
+
+    if (left != null) {
+      osw.write(left);
+    }
+
+    for (int col = 0; col < colWidths.length; col++) {
+      writeRepeated(osw, middle, colWidths[col]);
+      if (columnSeparator != null && col != colWidths.length - 1) {
+        osw.write(columnSeparator);
       }
     }
+
+    if (right != null) {
+      osw.write(right);
+    }
+
+    if (lineSeparator != null) {
+      osw.write(System.lineSeparator());
+    }
+  }
+
+  /**
+   * Renders the header row of a formatted table, applying specified 
alignments and borders. This
+   * method processes the column definitions to extract headers and their 
alignment, then delegates
+   * the actual writing to writeDataLine.
+   *
+   * @param osw The output writer for writing the formatted header
+   * @param borders A list containing border characters in the following 
order: [4]: left border
+   *     character [5]: middle border character [6]: right border character
+   * @param lineSeparator Platform-specific line separator (e.g., \n on Unix, 
\r\n on Windows)
+   * @param columns Array of Column objects defining the structure of each 
table column, including
+   *     header text and alignment preferences
+   * @throws IOException If any error occurs during writing to the output 
stream
+   */
+  private static void writeHeader(
+      OutputStreamWriter osw, List<Character> borders, String lineSeparator, 
Column[] columns)
+      throws IOException {
+    Column.HorizontalAlign[] dataAligns =
+        
Arrays.stream(columns).map(Column::getHeaderAlign).toArray(Column.HorizontalAlign[]::new);
+
+    String[] headers =
+        Arrays.stream(columns)
+            .map(Column::getHeader)
+            .filter(Objects::nonNull)
+            .toArray(String[]::new);
+
+    writeRow(
+        osw,
+        borders.get(4),
+        borders.get(5),
+        borders.get(6),
+        headers,
+        columns,
+        dataAligns,
+        lineSeparator);
   }
 
-  static final class TableFormatImpl {
-    private int[] maxElementLengths;
-    // This expression is primarily used to match characters that have a 
display width of
-    // 2, such as characters from Korean, Chinese
-    private static final Pattern FULL_WIDTH_PATTERN =
-        Pattern.compile(
-            
"[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]");
-    private int[][] elementOutputWidths;
-    private static final String horizontalDelimiter = "-";
-    private static final String verticalDelimiter = "|";
-    private static final String crossDelimiter = "+";
-    private static final String indent = " ";
+  /**
+   * Write the data to the output stream.
+   *
+   * @param osw the output stream writer.
+   * @param left the left border character.
+   * @param columnSeparator the column separator character.
+   * @param right the right border character.
+   * @param data the data to write.
+   * @param columns the columns to write.
+   * @param lineSeparator the line separator.
+   */
+  private static void writeRow(
+      OutputStreamWriter osw,
+      Character left,
+      Character columnSeparator,
+      Character right,
+      String[] data,
+      Column[] columns,
+      Column.HorizontalAlign[] dataAligns,
+      String lineSeparator)
+      throws IOException {
+
+    int maxWidth;
+    Column.HorizontalAlign dataAlign;
 
-    public void debug() {
-      System.out.println();
-      Arrays.stream(maxElementLengths).forEach(e -> System.out.print(e + " "));
+    if (left != null) {
+      osw.write(left);
     }
 
-    public void print(List<String> headers, List<List<String>> rows) {
-      if (rows.size() > 0 && headers.size() != rows.get(0).size()) {
-        throw new IllegalArgumentException("Number of columns is not equal.");
-      }
-      maxElementLengths = new int[headers.size()];
-      elementOutputWidths = new int[rows.size()][headers.size()];
-      updateMaxLengthsFromList(headers);
-      updateMaxLengthsFromNestedList(rows);
-      printLine();
-      System.out.println();
-      for (int i = 0; i < headers.size(); ++i) {
-        System.out.printf(
-            verticalDelimiter + indent + "%-" + maxElementLengths[i] + "s" + 
indent,
-            headers.get(i));
+    for (int i = 0; i < data.length; i++) {
+      maxWidth = columns[i].getMaxWidth();
+      dataAlign = dataAligns[i];
+      writeJustified(osw, data[i], dataAlign, maxWidth, PADDING);
+      if (i < data.length - 1) {
+        osw.write(columnSeparator);
       }
-      System.out.println(verticalDelimiter);
-      printLine();
-      System.out.println();
-
-      // print rows
-      for (int i = 0; i < rows.size(); ++i) {
-        List<String> columns = rows.get(i);
-        for (int j = 0; j < columns.size(); ++j) {
-          String column = columns.get(j);
-          // Handle cases where the width and number of characters are 
inconsistent
-          if (elementOutputWidths[i][j] != column.length()) {
-            if (elementOutputWidths[i][j] > maxElementLengths[j]) {
-              System.out.printf(
-                  verticalDelimiter + indent + "%-" + column.length() + "s" + 
indent, column);
-            } else {
-              int paddingLength =
-                  maxElementLengths[j] - (elementOutputWidths[i][j] - 
column.length());
-              System.out.printf(
-                  verticalDelimiter + indent + "%-" + paddingLength + "s" + 
indent, column);
-            }
-          } else {
-            System.out.printf(
-                verticalDelimiter + indent + "%-" + maxElementLengths[j] + "s" 
+ indent, column);
-          }
-        }
-        System.out.println(verticalDelimiter);
-      }
-      printLine();
-      // add one more line
-      System.out.println("");
     }
 
-    private void updateMaxLengthsFromList(List<String> elements) {
-      String s;
-      for (int i = 0; i < elements.size(); ++i) {
-        s = elements.get(i);
-        if (getOutputWidth(s) > maxElementLengths[i]) maxElementLengths[i] = 
getOutputWidth(s);
-      }
+    if (right != null) {
+      osw.write(right);
     }
 
-    private void updateMaxLengthsFromNestedList(List<List<String>> elements) {
-      int rowIdx = 0;
-      for (List<String> row : elements) {
-        String s;
-        for (int i = 0; i < row.size(); ++i) {
-          s = row.get(i);
-          int consoleWidth = getOutputWidth(s);
-          elementOutputWidths[rowIdx][i] = consoleWidth;
-          if (consoleWidth > maxElementLengths[i]) maxElementLengths[i] = 
consoleWidth;
-        }
-        rowIdx++;
-      }
+    osw.write(lineSeparator);
+  }
+
+  /**
+   * Retrieves data from all columns for a specific row index. Creates an 
array of cell values by
+   * extracting the data at the given row index from each column.
+   *
+   * @param columns Array of columns to extract data from.
+   * @param rowIndex Zero-based index of the row to retrieve.
+   * @return Array of cell values for the specified row.
+   * @throws IndexOutOfBoundsException if rowIndex is invalid for any column.
+   */
+  private static String[] getData(Column[] columns, int rowIndex) {
+    return Arrays.stream(columns).map(c -> 
c.getCell(rowIndex)).toArray(String[]::new);
+  }
+
+  /**
+   * Justifies the given string according to the specified alignment and 
maximum length then writes
+   * it to the output stream.
+   *
+   * @param osw the output stream writer.
+   * @param str the string to justify.
+   * @param align the horizontal alignment.
+   * @param maxLength the maximum length.
+   * @param minPadding the minimum padding.
+   * @throws IOException if an I/O error occurs.
+   */
+  private static void writeJustified(
+      OutputStreamWriter osw,
+      String str,
+      Column.HorizontalAlign align,
+      int maxLength,
+      int minPadding)
+      throws IOException {
+
+    osw.write(LineUtil.getSpaces(minPadding));
+    if (str.length() < maxLength) {
+      int leftPadding =
+          align == Column.HorizontalAlign.LEFT
+              ? 0
+              : align == Column.HorizontalAlign.CENTER
+                  ? (maxLength - LineUtil.getDisplayWidth(str)) / 2
+                  : maxLength - LineUtil.getDisplayWidth(str);
+
+      writeRepeated(osw, ' ', leftPadding);
+      osw.write(str);
+      writeRepeated(osw, ' ', maxLength - LineUtil.getDisplayWidth(str) - 
leftPadding);
+    } else {
+      osw.write(str);
     }
+    osw.write(LineUtil.getSpaces(minPadding));
+  }
 
-    private int getOutputWidth(String s) {
-      int width = 0;
-      for (int i = 0; i < s.length(); i++) {
-        width += getCharWidth(s.charAt(i));
-      }
+  /**
+   * Writes a character repeatedly to the output stream a specified number of 
times. Used for
+   * creating horizontal lines and padding in the table.
+   *
+   * @param osw Output stream to write to.
+   * @param c Character to repeat.
+   * @param num Number of times to repeat the character (must be non-negative).
+   * @throws IOException If an I/O error occurs during writing.
+   * @throws IllegalArgumentException if num is negative.
+   */
+  private static void writeRepeated(OutputStreamWriter osw, char c, int num) 
throws IOException {
+    for (int i = 0; i < num; i++) {
+      osw.append(c);
+    }
+  }
+
+  /**
+   * Formats a metalake into a table string representation. Creates a 
two-column table with headers
+   * "METALAKE" and "COMMENT", containing the metalake's name and comment 
respectively.
+   */
+  static final class MetalakeTableFormat extends TableFormat<Metalake> {
 
-      return width;
+    /**
+     * Creates a new {@link TableFormat} with the specified properties.
+     *
+     * @param context the command context.
+     */
+    public MetalakeTableFormat(CommandContext context) {
+      super(context);
     }
 
-    private static int getCharWidth(char ch) {
-      String s = String.valueOf(ch);
-      if (FULL_WIDTH_PATTERN.matcher(s).find()) {
-        return 2;
-      }
+    /** {@inheritDoc} */
+    @Override
+    public String getOutput(Metalake metalake) {
+      Column columnName = new Column(context, "metalake");
+      Column columnComment = new Column(context, "comment");
+
+      columnName.addCell(metalake.name());
+      columnComment.addCell(metalake.comment());
 
-      return 1;
+      return getTableFormat(columnName, columnComment);
     }
+  }
 
-    private void printLine() {
-      System.out.print(crossDelimiter);
-      for (int i = 0; i < maxElementLengths.length; ++i) {
-        for (int j = 0; j < maxElementLengths[i] + indent.length() * 2; ++j) {
-          System.out.print(horizontalDelimiter);
-        }
-        System.out.print(crossDelimiter);
-      }
+  /**
+   * Formats an array of Metalakes into a single-column table display. Lists 
all metalake names in a
+   * vertical format.
+   */
+  static final class MetalakeListTableFormat extends TableFormat<Metalake[]> {
+
+    public MetalakeListTableFormat(CommandContext context) {
+      super(context);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String getOutput(Metalake[] metalakes) {
+
+      Column columnName = new Column(context, "metalake");
+      Arrays.stream(metalakes).forEach(metalake -> 
columnName.addCell(metalake.name()));
+
+      return getTableFormat(columnName);
+    }
+  }
+
+  /**
+   * Formats a single Catalog instance into a four-column table display. 
Displays catalog details
+   * including name, type, provider, and comment information.
+   */
+  static final class CatalogTableFormat extends TableFormat<Catalog> {
+
+    public CatalogTableFormat(CommandContext context) {
+      super(context);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String getOutput(Catalog catalog) {
+      Column columnName = new Column(context, "catalog");
+      Column columnType = new Column(context, "type");
+      Column columnProvider = new Column(context, "provider");
+      Column columnComment = new Column(context, "comment");
+
+      columnName.addCell(catalog.name());
+      columnType.addCell(catalog.type().name());
+      columnProvider.addCell(catalog.provider());
+      columnComment.addCell(catalog.comment());
+
+      return getTableFormat(columnName, columnType, columnProvider, 
columnComment);
+    }
+  }
+
+  /**
+   * Formats an array of Catalogs into a single-column table display. Lists 
all catalog names in a
+   * vertical format.
+   */
+  static final class CatalogListTableFormat extends TableFormat<Catalog[]> {
+
+    public CatalogListTableFormat(CommandContext context) {
+      super(context);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String getOutput(Catalog[] catalogs) {
+      Column columnName = new Column(context, "catalog");
+      Arrays.stream(catalogs).forEach(metalake -> 
columnName.addCell(metalake.name()));
+
+      return getTableFormat(columnName);
     }
   }
 }
diff --git 
a/clients/cli/src/test/java/org/apache/gravitino/cli/integration/test/TableFormatOutputIT.java
 
b/clients/cli/src/test/java/org/apache/gravitino/cli/integration/test/TableFormatOutputIT.java
index f23d0284fb..bca9e960ff 100644
--- 
a/clients/cli/src/test/java/org/apache/gravitino/cli/integration/test/TableFormatOutputIT.java
+++ 
b/clients/cli/src/test/java/org/apache/gravitino/cli/integration/test/TableFormatOutputIT.java
@@ -106,7 +106,7 @@ public class TableFormatOutputIT extends BaseIT {
     String output = new String(outputStream.toByteArray(), 
StandardCharsets.UTF_8).trim();
     assertEquals(
         "+-------------+\n"
-            + "| metalake    |\n"
+            + "|  Metalake   |\n"
             + "+-------------+\n"
             + "| my_metalake |\n"
             + "+-------------+",
@@ -138,7 +138,7 @@ public class TableFormatOutputIT extends BaseIT {
     String output = new String(outputStream.toByteArray(), 
StandardCharsets.UTF_8).trim();
     assertEquals(
         "+-------------+-------------+\n"
-            + "| metalake    | comment     |\n"
+            + "|  Metalake   |   Comment   |\n"
             + "+-------------+-------------+\n"
             + "| my_metalake | my metalake |\n"
             + "+-------------+-------------+",
@@ -170,7 +170,7 @@ public class TableFormatOutputIT extends BaseIT {
     String output = new String(outputStream.toByteArray(), 
StandardCharsets.UTF_8).trim();
     assertEquals(
         "+-----------+\n"
-            + "| catalog   |\n"
+            + "|  Catalog  |\n"
             + "+-----------+\n"
             + "| postgres  |\n"
             + "| postgres2 |\n"
@@ -205,7 +205,7 @@ public class TableFormatOutputIT extends BaseIT {
     String output = new String(outputStream.toByteArray(), 
StandardCharsets.UTF_8).trim();
     assertEquals(
         "+----------+------------+-----------------+---------+\n"
-            + "| catalog  | type       | provider        | comment |\n"
+            + "| Catalog  |    Type    |    Provider     | Comment |\n"
             + "+----------+------------+-----------------+---------+\n"
             + "| postgres | RELATIONAL | jdbc-postgresql | null    |\n"
             + "+----------+------------+-----------------+---------+",
@@ -237,7 +237,7 @@ public class TableFormatOutputIT extends BaseIT {
     String output = new String(outputStream.toByteArray(), 
StandardCharsets.UTF_8).trim();
     assertEquals(
         "+-----------+------------+-----------------+-------------------+\n"
-            + "| catalog   | type       | provider        | comment           
|\n"
+            + "|  Catalog  |    Type    |    Provider     |      Comment      
|\n"
             + 
"+-----------+------------+-----------------+-------------------+\n"
             + "| postgres2 | RELATIONAL | jdbc-postgresql | catalog, 用于测试 |\n"
             + 
"+-----------+------------+-----------------+-------------------+",
diff --git 
a/clients/cli/src/test/java/org/apache/gravitino/cli/output/TestPlainFormat.java
 
b/clients/cli/src/test/java/org/apache/gravitino/cli/output/TestPlainFormat.java
new file mode 100644
index 0000000000..300a12e9d5
--- /dev/null
+++ 
b/clients/cli/src/test/java/org/apache/gravitino/cli/output/TestPlainFormat.java
@@ -0,0 +1,171 @@
+/*
+ * 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.cli.output;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import org.apache.gravitino.Audit;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.Metalake;
+import org.apache.gravitino.Schema;
+import org.apache.gravitino.cli.CommandContext;
+import org.apache.gravitino.cli.outputs.PlainFormat;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TestPlainFormat {
+
+  private final ByteArrayOutputStream outContent = new ByteArrayOutputStream();
+  private final ByteArrayOutputStream errContent = new ByteArrayOutputStream();
+  private final PrintStream originalOut = System.out;
+  private final PrintStream originalErr = System.err;
+
+  @BeforeEach
+  void setUp() {
+    System.setOut(new PrintStream(outContent));
+    System.setErr(new PrintStream(errContent));
+  }
+
+  @AfterEach
+  public void restoreStreams() {
+    System.setOut(originalOut);
+    System.setErr(originalErr);
+  }
+
+  @Test
+  void testMetalakeDetailsWithPlainFormat() {
+    CommandContext mockContext = getMockContext();
+    Metalake mockMetalake = getMockMetalake();
+
+    PlainFormat.output(mockMetalake, mockContext);
+    String output = new String(outContent.toByteArray(), 
StandardCharsets.UTF_8).trim();
+    Assertions.assertEquals("demo_metalake, This is a demo metalake", output);
+  }
+
+  @Test
+  void testListMetalakeWithPlainFormat() {
+    CommandContext mockContext = getMockContext();
+    Metalake mockMetalake1 = getMockMetalake("metalake1", "This is a 
metalake");
+    Metalake mockMetalake2 = getMockMetalake("metalake2", "This is another 
metalake");
+
+    PlainFormat.output(new Metalake[] {mockMetalake1, mockMetalake2}, 
mockContext);
+    String output = new String(outContent.toByteArray(), 
StandardCharsets.UTF_8).trim();
+    Assertions.assertEquals("metalake1\n" + "metalake2", output);
+  }
+
+  @Test
+  void testCatalogDetailsWithPlainFormat() {
+    CommandContext mockContext = getMockContext();
+    Catalog mockCatalog = getMockCatalog();
+
+    PlainFormat.output(mockCatalog, mockContext);
+    String output = new String(outContent.toByteArray(), 
StandardCharsets.UTF_8).trim();
+    Assertions.assertEquals(
+        "demo_catalog, RELATIONAL, demo_provider, This is a demo catalog", 
output);
+  }
+
+  @Test
+  void testListCatalogWithPlainFormat() {
+    CommandContext mockContext = getMockContext();
+    Catalog mockCatalog1 =
+        getMockCatalog("catalog1", Catalog.Type.FILESET, "provider1", "This is 
a catalog");
+    Catalog mockCatalog2 =
+        getMockCatalog("catalog2", Catalog.Type.RELATIONAL, "provider2", "This 
is another catalog");
+
+    PlainFormat.output(new Catalog[] {mockCatalog1, mockCatalog2}, 
mockContext);
+    String output = new String(outContent.toByteArray(), 
StandardCharsets.UTF_8).trim();
+    Assertions.assertEquals("catalog1\n" + "catalog2", output);
+  }
+
+  @Test
+  void testOutputWithUnsupportType() {
+    CommandContext mockContext = getMockContext();
+    Object mockObject = new Object();
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> {
+          PlainFormat.output(mockObject, mockContext);
+        });
+  }
+
+  private CommandContext getMockContext() {
+    CommandContext mockContext = mock(CommandContext.class);
+
+    return mockContext;
+  }
+
+  private Metalake getMockMetalake() {
+    return getMockMetalake("demo_metalake", "This is a demo metalake");
+  }
+
+  private Metalake getMockMetalake(String name, String comment) {
+    Metalake mockMetalake = mock(Metalake.class);
+    when(mockMetalake.name()).thenReturn(name);
+    when(mockMetalake.comment()).thenReturn(comment);
+
+    return mockMetalake;
+  }
+
+  private Catalog getMockCatalog() {
+    return getMockCatalog(
+        "demo_catalog", Catalog.Type.RELATIONAL, "demo_provider", "This is a 
demo catalog");
+  }
+
+  private Catalog getMockCatalog(String name, Catalog.Type type, String 
provider, String comment) {
+    Catalog mockCatalog = mock(Catalog.class);
+    when(mockCatalog.name()).thenReturn(name);
+    when(mockCatalog.type()).thenReturn(type);
+    when(mockCatalog.provider()).thenReturn(provider);
+    when(mockCatalog.comment()).thenReturn(comment);
+
+    return mockCatalog;
+  }
+
+  private Schema getMockSchema() {
+    return getMockSchema("demo_schema", "This is a demo schema");
+  }
+
+  private Schema getMockSchema(String name, String comment) {
+    Schema mockSchema = mock(Schema.class);
+    when(mockSchema.name()).thenReturn(name);
+    when(mockSchema.comment()).thenReturn(comment);
+
+    return mockSchema;
+  }
+
+  private Audit getMockAudit() {
+    Audit mockAudit = mock(Audit.class);
+    when(mockAudit.creator()).thenReturn("demo_user");
+    
when(mockAudit.createTime()).thenReturn(Instant.ofEpochMilli(1611111111111L));
+    when(mockAudit.lastModifier()).thenReturn("demo_user");
+    
when(mockAudit.lastModifiedTime()).thenReturn(Instant.ofEpochMilli(1611111111111L));
+
+    return mockAudit;
+  }
+}
diff --git 
a/clients/cli/src/test/java/org/apache/gravitino/cli/output/TestTableFormat.java
 
b/clients/cli/src/test/java/org/apache/gravitino/cli/output/TestTableFormat.java
new file mode 100644
index 0000000000..64d5ea4987
--- /dev/null
+++ 
b/clients/cli/src/test/java/org/apache/gravitino/cli/output/TestTableFormat.java
@@ -0,0 +1,348 @@
+/*
+ * 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.cli.output;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.Metalake;
+import org.apache.gravitino.cli.CommandContext;
+import org.apache.gravitino.cli.outputs.Column;
+import org.apache.gravitino.cli.outputs.TableFormat;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TestTableFormat {
+  private final ByteArrayOutputStream outContent = new ByteArrayOutputStream();
+  private final ByteArrayOutputStream errContent = new ByteArrayOutputStream();
+  private final PrintStream originalOut = System.out;
+  private final PrintStream originalErr = System.err;
+
+  @BeforeEach
+  void setUp() {
+    System.setOut(new PrintStream(outContent));
+    System.setErr(new PrintStream(errContent));
+  }
+
+  @AfterEach
+  public void restoreStreams() {
+    System.setOut(originalOut);
+    System.setErr(originalErr);
+  }
+
+  @Test
+  void testCreateDefaultTableFormat() {
+    CommandContext mockContext = getMockContext();
+
+    Column columnA = new Column(mockContext, "metalake");
+    Column columnB = new Column(mockContext, "comment");
+
+    columnA.addCell("cell1").addCell("cell2").addCell("cell3");
+    columnB.addCell("cell4").addCell("cell5").addCell("cell6");
+
+    TableFormat<String> tableFormat =
+        new TableFormat<String>(mockContext) {
+          @Override
+          public String getOutput(String entity) {
+            return null;
+          }
+        };
+
+    String outputString = tableFormat.getTableFormat(columnA, columnB).trim();
+    Assertions.assertEquals(
+        "+----------+---------+\n"
+            + "| Metalake | Comment |\n"
+            + "+----------+---------+\n"
+            + "| cell1    | cell4   |\n"
+            + "| cell2    | cell5   |\n"
+            + "| cell3    | cell6   |\n"
+            + "+----------+---------+",
+        outputString);
+  }
+
+  @Test
+  void testTitleWithLeftAlign() {
+    CommandContext mockContext = getMockContext();
+
+    Column columnA =
+        new Column(
+            mockContext, "metalake", Column.HorizontalAlign.LEFT, 
Column.HorizontalAlign.CENTER);
+    Column columnB =
+        new Column(
+            mockContext, "comment", Column.HorizontalAlign.LEFT, 
Column.HorizontalAlign.CENTER);
+
+    columnA.addCell("cell1").addCell("cell2").addCell("cell3").addCell("very 
long cell");
+    columnB.addCell("cell4").addCell("cell5").addCell("cell6").addCell("very 
long cell");
+
+    TableFormat<String> tableFormat =
+        new TableFormat<String>(mockContext) {
+          @Override
+          public String getOutput(String entity) {
+            return null;
+          }
+        };
+
+    String outputString = tableFormat.getTableFormat(columnA, columnB).trim();
+    Assertions.assertEquals(
+        "+----------------+----------------+\n"
+            + "| Metalake       | Comment        |\n"
+            + "+----------------+----------------+\n"
+            + "|     cell1      |     cell4      |\n"
+            + "|     cell2      |     cell5      |\n"
+            + "|     cell3      |     cell6      |\n"
+            + "| very long cell | very long cell |\n"
+            + "+----------------+----------------+",
+        outputString);
+  }
+
+  @Test
+  void testTitleWithRightAlign() {
+    CommandContext mockContext = getMockContext();
+
+    Column columnA =
+        new Column(
+            mockContext, "metalake", Column.HorizontalAlign.RIGHT, 
Column.HorizontalAlign.CENTER);
+    Column columnB =
+        new Column(
+            mockContext, "comment", Column.HorizontalAlign.RIGHT, 
Column.HorizontalAlign.CENTER);
+
+    columnA.addCell("cell1").addCell("cell2").addCell("cell3").addCell("very 
long cell");
+    columnB.addCell("cell4").addCell("cell5").addCell("cell6").addCell("very 
long cell");
+
+    TableFormat<String> tableFormat =
+        new TableFormat<String>(mockContext) {
+          @Override
+          public String getOutput(String entity) {
+            return null;
+          }
+        };
+
+    String outputString = tableFormat.getTableFormat(columnA, columnB).trim();
+    Assertions.assertEquals(
+        "+----------------+----------------+\n"
+            + "|       Metalake |        Comment |\n"
+            + "+----------------+----------------+\n"
+            + "|     cell1      |     cell4      |\n"
+            + "|     cell2      |     cell5      |\n"
+            + "|     cell3      |     cell6      |\n"
+            + "| very long cell | very long cell |\n"
+            + "+----------------+----------------+",
+        outputString);
+  }
+
+  @Test
+  void testDataWithCenterAlign() {
+    CommandContext mockContext = getMockContext();
+
+    Column columnA =
+        new Column(
+            mockContext, "metalake", Column.HorizontalAlign.CENTER, 
Column.HorizontalAlign.CENTER);
+    Column columnB =
+        new Column(
+            mockContext, "comment", Column.HorizontalAlign.CENTER, 
Column.HorizontalAlign.CENTER);
+
+    columnA.addCell("cell1").addCell("cell2").addCell("cell3").addCell("very 
long cell");
+    columnB.addCell("cell4").addCell("cell5").addCell("cell6").addCell("very 
long cell");
+
+    TableFormat<String> tableFormat =
+        new TableFormat<String>(mockContext) {
+          @Override
+          public String getOutput(String entity) {
+            return null;
+          }
+        };
+
+    String outputString = tableFormat.getTableFormat(columnA, columnB).trim();
+    Assertions.assertEquals(
+        "+----------------+----------------+\n"
+            + "|    Metalake    |    Comment     |\n"
+            + "+----------------+----------------+\n"
+            + "|     cell1      |     cell4      |\n"
+            + "|     cell2      |     cell5      |\n"
+            + "|     cell3      |     cell6      |\n"
+            + "| very long cell | very long cell |\n"
+            + "+----------------+----------------+",
+        outputString);
+  }
+
+  @Test
+  void testDataWithRightAlign() {
+    CommandContext mockContext = getMockContext();
+
+    Column columnA =
+        new Column(
+            mockContext, "metalake", Column.HorizontalAlign.CENTER, 
Column.HorizontalAlign.RIGHT);
+    Column columnB =
+        new Column(
+            mockContext, "comment", Column.HorizontalAlign.CENTER, 
Column.HorizontalAlign.RIGHT);
+
+    columnA.addCell("cell1").addCell("cell2").addCell("cell3").addCell("very 
long cell");
+    columnB.addCell("cell4").addCell("cell5").addCell("cell6").addCell("very 
long cell");
+
+    TableFormat<String> tableFormat =
+        new TableFormat<String>(mockContext) {
+          @Override
+          public String getOutput(String entity) {
+            return null;
+          }
+        };
+
+    String outputString = tableFormat.getTableFormat(columnA, columnB).trim();
+    Assertions.assertEquals(
+        "+----------------+----------------+\n"
+            + "|    Metalake    |    Comment     |\n"
+            + "+----------------+----------------+\n"
+            + "|          cell1 |          cell4 |\n"
+            + "|          cell2 |          cell5 |\n"
+            + "|          cell3 |          cell6 |\n"
+            + "| very long cell | very long cell |\n"
+            + "+----------------+----------------+",
+        outputString);
+  }
+
+  @Test
+  void testMetalakeDetailsWithTableFormat() {
+    CommandContext mockContext = getMockContext();
+
+    Metalake mockMetalake = getMockMetalake();
+    TableFormat.output(mockMetalake, mockContext);
+
+    String output = new String(outContent.toByteArray(), 
StandardCharsets.UTF_8).trim();
+    Assertions.assertEquals(
+        "+---------------+-------------------------+\n"
+            + "|   Metalake    |         Comment         |\n"
+            + "+---------------+-------------------------+\n"
+            + "| demo_metalake | This is a demo metalake |\n"
+            + "+---------------+-------------------------+",
+        output);
+  }
+
+  @Test
+  void testListMetalakeWithTableFormat() {
+    CommandContext mockContext = getMockContext();
+    Metalake mockMetalake1 = getMockMetalake("metalake1", "This is a 
metalake");
+    Metalake mockMetalake2 = getMockMetalake("metalake2", "This is another 
metalake");
+
+    TableFormat.output(new Metalake[] {mockMetalake1, mockMetalake2}, 
mockContext);
+    String output = new String(outContent.toByteArray(), 
StandardCharsets.UTF_8).trim();
+    Assertions.assertEquals(
+        "+-----------+\n"
+            + "| Metalake  |\n"
+            + "+-----------+\n"
+            + "| metalake1 |\n"
+            + "| metalake2 |\n"
+            + "+-----------+",
+        output);
+  }
+
+  @Test
+  void testCatalogDetailsWithTableFormat() {
+    CommandContext mockContext = getMockContext();
+    Catalog mockCatalog = getMockCatalog();
+
+    TableFormat.output(mockCatalog, mockContext);
+    String output = new String(outContent.toByteArray(), 
StandardCharsets.UTF_8).trim();
+    Assertions.assertEquals(
+        
"+--------------+------------+---------------+------------------------+\n"
+            + "|   Catalog    |    Type    |   Provider    |        Comment    
     |\n"
+            + 
"+--------------+------------+---------------+------------------------+\n"
+            + "| demo_catalog | RELATIONAL | demo_provider | This is a demo 
catalog |\n"
+            + 
"+--------------+------------+---------------+------------------------+",
+        output);
+  }
+
+  @Test
+  void testListCatalogWithTableFormat() {
+    CommandContext mockContext = getMockContext();
+    Catalog mockCatalog1 =
+        getMockCatalog("catalog1", Catalog.Type.FILESET, "provider1", "This is 
a catalog");
+    Catalog mockCatalog2 =
+        getMockCatalog("catalog2", Catalog.Type.RELATIONAL, "provider2", "This 
is another catalog");
+
+    TableFormat.output(new Catalog[] {mockCatalog1, mockCatalog2}, 
mockContext);
+    String output = new String(outContent.toByteArray(), 
StandardCharsets.UTF_8).trim();
+    Assertions.assertEquals(
+        "+----------+\n"
+            + "| Catalog  |\n"
+            + "+----------+\n"
+            + "| catalog1 |\n"
+            + "| catalog2 |\n"
+            + "+----------+",
+        output);
+  }
+
+  @Test
+  void testOutputWithUnsupportType() {
+    CommandContext mockContext = getMockContext();
+    Object mockObject = new Object();
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> {
+          TableFormat.output(mockObject, mockContext);
+        });
+  }
+
+  private void addRepeatedCells(Column column, int count) {
+    for (int i = 0; i < count; i++) {
+      column.addCell(column.getHeader() + "-" + (i + 1));
+    }
+  }
+
+  private CommandContext getMockContext() {
+    CommandContext mockContext = mock(CommandContext.class);
+
+    return mockContext;
+  }
+
+  private Metalake getMockMetalake() {
+    return getMockMetalake("demo_metalake", "This is a demo metalake");
+  }
+
+  private Metalake getMockMetalake(String name, String comment) {
+    Metalake mockMetalake = mock(Metalake.class);
+    when(mockMetalake.name()).thenReturn(name);
+    when(mockMetalake.comment()).thenReturn(comment);
+
+    return mockMetalake;
+  }
+
+  private Catalog getMockCatalog() {
+    return getMockCatalog(
+        "demo_catalog", Catalog.Type.RELATIONAL, "demo_provider", "This is a 
demo catalog");
+  }
+
+  private Catalog getMockCatalog(String name, Catalog.Type type, String 
provider, String comment) {
+    Catalog mockCatalog = mock(Catalog.class);
+    when(mockCatalog.name()).thenReturn(name);
+    when(mockCatalog.type()).thenReturn(type);
+    when(mockCatalog.provider()).thenReturn(provider);
+    when(mockCatalog.comment()).thenReturn(comment);
+
+    return mockCatalog;
+  }
+}

Reply via email to