From 176eeff8d94e945248c20a57b9c62387822da0fc Mon Sep 17 00:00:00 2001 From: pancx Date: Sun, 16 Feb 2025 00:05:52 +0800 Subject: [PATCH] [#5746] improve(CLI): Support table format output for Audit command Support table format output for Audit command. --- .../apache/gravitino/cli/CommandContext.java | 13 + .../gravitino/cli/commands/AuditCommand.java | 13 +- .../cli/commands/CatalogDetails.java | 2 +- .../gravitino/cli/commands/Command.java | 31 +- .../gravitino/cli/commands/ListCatalogs.java | 2 +- .../gravitino/cli/commands/ListMetalakes.java | 2 +- .../gravitino/cli/commands/ListSchema.java | 20 +- .../cli/commands/MetalakeDetails.java | 2 +- .../gravitino/cli/commands/SchemaDetails.java | 2 +- .../cli/outputs/BaseOutputFormat.java | 99 +++ .../apache/gravitino/cli/outputs/Column.java | 245 ++++++ .../gravitino/cli/outputs/Constant.java | 62 ++ .../gravitino/cli/outputs/LineUtil.java | 70 ++ .../gravitino/cli/outputs/OutputFormat.java | 29 +- .../gravitino/cli/outputs/PlainFormat.java | 248 +++++- .../gravitino/cli/outputs/TableFormat.java | 766 ++++++++++++++---- .../integration/test/TableFormatOutputIT.java | 10 +- .../gravitino/cli/output/TestPlainFormat.java | 204 +++++ .../gravitino/cli/output/TestTableFormat.java | 456 +++++++++++ 19 files changed, 2044 insertions(+), 232 deletions(-) create mode 100644 clients/cli/src/main/java/org/apache/gravitino/cli/outputs/BaseOutputFormat.java create mode 100644 clients/cli/src/main/java/org/apache/gravitino/cli/outputs/Column.java create mode 100644 clients/cli/src/main/java/org/apache/gravitino/cli/outputs/Constant.java create mode 100644 clients/cli/src/main/java/org/apache/gravitino/cli/outputs/LineUtil.java create mode 100644 clients/cli/src/test/java/org/apache/gravitino/cli/output/TestPlainFormat.java create mode 100644 clients/cli/src/test/java/org/apache/gravitino/cli/output/TestTableFormat.java diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/CommandContext.java b/clients/cli/src/main/java/org/apache/gravitino/cli/CommandContext.java index b684ede48f0..94543b19861 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/CommandContext.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/CommandContext.java @@ -31,6 +31,8 @@ public class CommandContext { private final String url; private final boolean quiet; private final CommandLine line; + // TODO make it final + private int outputLimit; private String ignoreEnv; private boolean ignoreSet = false; @@ -55,6 +57,8 @@ public CommandContext(CommandLine line) { this.url = getUrl(); this.ignoreVersions = getIgnore(); + // TODO add limit option to CLI + this.outputLimit = -1; } /** @@ -102,6 +106,15 @@ public boolean quiet() { return quiet; } + /** + * Returns the output limit. + * + * @return The output limit. + */ + public int outputLimit() { + return outputLimit; + } + /** * Retrieves the Gravitino URL from the command line options or the GRAVITINO_URL environment * variable or the Gravitino config file. diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/AuditCommand.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/AuditCommand.java index 951ad33cc0a..2490e405f37 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/AuditCommand.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/AuditCommand.java @@ -39,17 +39,6 @@ public void handle() {} * @param audit from a class that implements the Auditable interface. */ public void displayAuditInfo(Audit audit) { - String auditInfo = - "creator,create_time,modified,modified_time" - + System.lineSeparator() - + audit.creator() - + "," - + audit.createTime() - + "," - + audit.lastModifier() - + "," - + audit.lastModifiedTime(); - - printResults(auditInfo); + printResults(audit); } } 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 fac504a0083..81365062c25 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 void handle() { 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 6ecef9278b5..d1e2b4deee7 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.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 void printInformation(String message) { 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 The type of entity. + */ + public 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); } /** @@ -218,22 +229,16 @@ protected Builder constructClient(Builder return builder; } - /** - * Outputs the entity to the console. - * - * @param entity The entity to output. - * @param The type of entity. - */ - protected void output(T entity) { + private 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 ad8d171fec6..70301a131a0 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,7 @@ public void handle() { try { GravitinoClient client = buildClient(metalake); catalogs = client.listCatalogsInfo(); - output(catalogs); + 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 a97e89bca2e..ef2214e05ec 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,7 @@ public void handle() { try { GravitinoAdminClient client = buildAdminClient(); metalakes = client.listMetalakes(); - output(metalakes); + printResults(metalakes); } catch (Exception exp) { exitWithError(exp.getMessage()); } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListSchema.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListSchema.java index bbc881ce06e..ba987486b68 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListSchema.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/ListSchema.java @@ -19,7 +19,10 @@ package org.apache.gravitino.cli.commands; -import com.google.common.base.Joiner; +import com.google.common.collect.Lists; +import java.util.List; +import org.apache.gravitino.Schema; +import org.apache.gravitino.SupportsSchemas; import org.apache.gravitino.cli.CommandContext; import org.apache.gravitino.cli.ErrorMessages; import org.apache.gravitino.client.GravitinoClient; @@ -48,10 +51,13 @@ public ListSchema(CommandContext context, String metalake, String catalog) { /** List all schema names in a schema. */ @Override public void handle() { - String[] schemas = new String[0]; + List schemas = Lists.newArrayList(); try { GravitinoClient client = buildClient(metalake); - schemas = client.loadCatalog(catalog).asSchemas().listSchemas(); + SupportsSchemas gObject = client.loadCatalog(catalog).asSchemas(); + for (String schemaName : gObject.listSchemas()) { + schemas.add(gObject.loadSchema(schemaName)); + } } catch (NoSuchMetalakeException err) { exitWithError(ErrorMessages.UNKNOWN_METALAKE); } catch (NoSuchCatalogException err) { @@ -60,8 +66,10 @@ public void handle() { exitWithError(exp.getMessage()); } - String all = schemas.length == 0 ? "No schemas exist." : Joiner.on(",").join(schemas); - - printInformation(all); + if (schemas.isEmpty()) { + printInformation("No schemas exist."); + } else { + printResults(schemas.toArray(new Schema[0])); + } } } 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 cc2bf6ce4b4..b427052c0c2 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 void handle() { 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/commands/SchemaDetails.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SchemaDetails.java index b9c530aea5e..560fca20dfd 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SchemaDetails.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SchemaDetails.java @@ -68,7 +68,7 @@ public void handle() { } if (result != null) { - printInformation(result.name() + "," + result.comment()); + printResults(result); } } } 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 00000000000..da60d7bfe8b --- /dev/null +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/BaseOutputFormat.java @@ -0,0 +1,99 @@ +/* + * 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.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. This class handles basic output operations and provides + * configurable behavior for quiet mode, output limiting. + */ +public abstract class BaseOutputFormat implements OutputFormat { + protected int limit; + protected CommandContext context; + + /** + * Creates a new {@link BaseOutputFormat} with specified configuration. + * + * @param context the command context, must not be null; + */ + public BaseOutputFormat(CommandContext context) { + Preconditions.checkNotNull(context, "CommandContext cannot be null"); + this.context = context; + this.limit = context.outputLimit(); + } + + /** + * 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"); + } + 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 00000000000..70b5ee7f34d --- /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 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 = 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/Constant.java b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/Constant.java new file mode 100644 index 00000000000..65c034965d1 --- /dev/null +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/Constant.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 Constant { + public static final ImmutableList 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 Constant() { + // private constructor to prevent instantiation + } +} 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 00000000000..60fc05206f4 --- /dev/null +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/outputs/LineUtil.java @@ -0,0 +1,70 @@ +/* + * 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(); + } +} 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 8e6ab311628..fe0d7b7c9e0 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 { - 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 66e616c4f78..9f6aa70e71f 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 @@ -21,71 +21,247 @@ import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; +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.rel.Table; -/** 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); +/** + * Formats entity into plain text representation for command-line output. Supports formatting of + * single objects and arrays of Metalake, Catalog, Schema, and Table objects. Each supported type + * has its own specialized formatter as an inner class. + */ +public abstract class PlainFormat extends BaseOutputFormat { + + /** + * 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 MetalakesPlainFormat(context).output((Metalake[]) entity); + } else if (entity instanceof Catalog) { + new CatalogPlainFormat(context).output((Catalog) entity); + } else if (entity instanceof Catalog[]) { + new CatalogsPlainFormat(context).output((Catalog[]) entity); + } else if (entity instanceof Schema) { + new SchemaPlainFormat(context).output((Schema) entity); + } else if (entity instanceof Schema[]) { + new SchemasPlainFormat(context).output((Schema[]) entity); + } else if (entity instanceof Table) { + new TablePlainFormat(context).output((Table) entity); + } else if (entity instanceof Table[]) { + new TablesPlainFormat(context).output((Table[]) entity); + } else if (entity instanceof Audit) { + new AuditPlainFormat(context).output((Audit) entity); } else { throw new IllegalArgumentException("Unsupported object type"); } } - static final class MetalakePlainFormat implements OutputFormat { + /** + * 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 { + + 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 { + /** + * Formats an array of Metalakes, outputting one name per line. Returns null if the array is empty + * or null. + */ + static final class MetalakesPlainFormat extends PlainFormat { + + public MetalakesPlainFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ @Override - public void output(Metalake[] metalakes) { - if (metalakes.length == 0) { - System.out.println("No metalakes exist."); + public String getOutput(Metalake[] metalakes) { + if (metalakes == null || metalakes.length == 0) { + return null; } else { List metalakeNames = Arrays.stream(metalakes).map(Metalake::name).collect(Collectors.toList()); - String all = String.join(System.lineSeparator(), metalakeNames); - System.out.println(all); + return NEWLINE_JOINER.join(metalakeNames); } } } - static final class CatalogPlainFormat implements OutputFormat { + /** + * Formats a single {@link Catalog} instance as a comma-separated string. Output format: name, + * type, provider, comment + */ + static final class CatalogPlainFormat extends PlainFormat { + 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 { + /** + * Formats an array of Catalogs, outputting one name per line. Returns null if the array is empty + * or null. + */ + static final class CatalogsPlainFormat extends PlainFormat { + public CatalogsPlainFormat(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 exists.", System.err); + return null; } else { List 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); } } } + + /** + * Formats a single {@link Schema} instance as a comma-separated string. Output format: name, + * comment + */ + static final class SchemaPlainFormat extends PlainFormat { + public SchemaPlainFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ + @Override + public String getOutput(Schema schema) { + return COMMA_JOINER.join(schema.name(), schema.comment()); + } + } + + /** + * Formats an array of Schemas, outputting one name per line. Returns null if the array is empty + * or null. + */ + static final class SchemasPlainFormat extends PlainFormat { + public SchemasPlainFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ + @Override + public String getOutput(Schema[] schemas) { + if (schemas == null || schemas.length == 0) { + return null; + } else { + List schemaNames = + Arrays.stream(schemas).map(Schema::name).collect(Collectors.toList()); + return NEWLINE_JOINER.join(schemaNames); + } + } + } + + /** + * Formats a single Table instance with detailed column information. Output format: table_name + * column1_name, column1_type, column1_comment column2_name, column2_type, column2_comment ... + * table_comment + */ + static final class TablePlainFormat extends PlainFormat { + public TablePlainFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ + @Override + public String getOutput(Table table) { + StringBuilder output = new StringBuilder(table.name() + System.lineSeparator()); + List columnOutput = + Arrays.stream(table.columns()) + .map( + column -> + COMMA_JOINER.join( + column.name(), column.dataType().simpleString(), column.comment())) + .collect(Collectors.toList()); + output.append(NEWLINE_JOINER.join(columnOutput)); + output.append(System.lineSeparator()); + output.append(table.comment()); + return output.toString(); + } + } + + /** + * Formats an array of Tables, outputting one name per line. Returns null if the array is empty or + * null. + */ + static final class TablesPlainFormat extends PlainFormat { + public TablesPlainFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ + @Override + public String getOutput(Table[] tables) { + if (tables == null || tables.length == 0) { + return null; + } else { + List tableNames = + Arrays.stream(tables).map(Table::name).collect(Collectors.toList()); + return NEWLINE_JOINER.join(tableNames); + } + } + } + + /** + * Formats an instance of {@link Audit} , outputting the audit information. Output format: + * creator, create_time, modified, modified_time + */ + static final class AuditPlainFormat extends PlainFormat { + public AuditPlainFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ + @Override + public String getOutput(Audit audit) { + String header = COMMA_JOINER.join("creator", "create_time", "modified", "modified_time"); + String auditInfo = + COMMA_JOINER.join( + audit.creator(), audit.createTime(), audit.lastModifier(), audit.lastModifiedTime()); + + return NEWLINE_JOINER.join(header, auditInfo); + } + } } 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 a3c99756524..418eca471cd 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,664 @@ */ package org.apache.gravitino.cli.outputs; -import java.util.ArrayList; +import static org.apache.gravitino.cli.outputs.Constant.DATA_LINE_COLUMN_SEPARATOR_IDX; +import static org.apache.gravitino.cli.outputs.Constant.DATA_LINE_LEFT_IDX; +import static org.apache.gravitino.cli.outputs.Constant.DATA_LINE_RIGHT_IDX; +import static org.apache.gravitino.cli.outputs.Constant.DATA_ROW_BORDER_COLUMN_SEPARATOR_IDX; +import static org.apache.gravitino.cli.outputs.Constant.DATA_ROW_BORDER_LEFT_IDX; +import static org.apache.gravitino.cli.outputs.Constant.DATA_ROW_BORDER_MIDDLE_IDX; +import static org.apache.gravitino.cli.outputs.Constant.DATA_ROW_BORDER_RIGHT_IDX; +import static org.apache.gravitino.cli.outputs.Constant.HEADER_BOTTOM_BORDER_COLUMN_SEPARATOR_IDX; +import static org.apache.gravitino.cli.outputs.Constant.HEADER_BOTTOM_BORDER_LEFT_IDX; +import static org.apache.gravitino.cli.outputs.Constant.HEADER_BOTTOM_BORDER_MIDDLE_IDX; +import static org.apache.gravitino.cli.outputs.Constant.HEADER_BOTTOM_BORDER_RIGHT_IDX; +import static org.apache.gravitino.cli.outputs.Constant.TABLE_BOTTOM_BORDER_COLUMN_SEPARATOR_IDX; +import static org.apache.gravitino.cli.outputs.Constant.TABLE_BOTTOM_BORDER_LEFT_IDX; +import static org.apache.gravitino.cli.outputs.Constant.TABLE_BOTTOM_BORDER_MIDDLE_IDX; +import static org.apache.gravitino.cli.outputs.Constant.TABLE_BOTTOM_BORDER_RIGHT_IDX; +import static org.apache.gravitino.cli.outputs.Constant.TABLE_UPPER_BORDER_COLUMN_SEPARATOR_IDX; +import static org.apache.gravitino.cli.outputs.Constant.TABLE_UPPER_BORDER_LEFT_IDX; +import static org.apache.gravitino.cli.outputs.Constant.TABLE_UPPER_BORDER_MIDDLE_IDX; +import static org.apache.gravitino.cli.outputs.Constant.TABLE_UPPER_BORDER_RIGHT_IDX; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +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.Audit; import org.apache.gravitino.Catalog; import org.apache.gravitino.Metalake; +import org.apache.gravitino.Schema; +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 extends BaseOutputFormat { + public static final ImmutableList BASIC_ASCII = + ImmutableList.of( + '+', '-', '+', '+', '|', '|', '|', '+', '-', '+', '+', '|', '|', '|', '+', '-', '+', '+', + '+', '-', '+', '+', '|', '|', '|', '+', '-', '+', '+'); + + 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 MetalakesTableFormat(context).output((Metalake[]) entity); + } else if (entity instanceof Catalog) { + new CatalogTableFormat(context).output((Catalog) entity); + } else if (entity instanceof Catalog[]) { + new CatalogsTableFormat(context).output((Catalog[]) entity); + } else if (entity instanceof Schema) { + new SchemaTableFormat(context).output((Schema) entity); + } else if (entity instanceof Schema[]) { + new SchemasTableFormat(context).output((Schema[]) entity); + } else if (entity instanceof Audit) { + new AuditTableFormat(context).output((Audit) entity); } else { throw new IllegalArgumentException("Unsupported object type"); } } - static final class MetalakeTableFormat implements OutputFormat { + /** + * 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 + } + + /** + * 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) { + checkColumns(columns); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + String[] headers = + Arrays.stream(columns) + .map(Column::getHeader) + .filter(Objects::nonNull) + .toArray(String[]::new); + + List borders = BASIC_ASCII; + checkHeaders(headers, columns); + + if (headers.length != columns.length) { + throw new IllegalArgumentException("Headers must be provided for all columns"); + } + + if (limit != -1) { + columns = getLimitedColumns(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); + } + + private void checkColumns(Column... columns) { + Preconditions.checkArgument(columns.length > 0, "At least one column must be provided"); + int cellCount = columns[0].getCellCount(); + for (Column column : columns) { + Preconditions.checkArgument( + column.getCellCount() == cellCount, "All columns must have the same cell count"); + } + } + + private void checkHeaders(String[] headers, Column[] columns) { + Preconditions.checkArgument( + headers.length == columns.length, "Headers must be provided for all columns"); + for (String header : headers) { + Preconditions.checkArgument(header != null, "Headers must not be null"); + } + } + + /** + * Limits the number of rows in the table columns based on a predefined limit. If the current cell + * count is below the limit, returns the original columns unchanged. Otherwise, creates new + * columns with data truncated to the limit. + * + * @param columns The array of columns to potentially limit + * @return A new array of columns with limited rows, or the original array if no limiting is + * needed + * @throws IllegalArgumentException If the columns array is null or empty + */ + private Column[] getLimitedColumns(Column[] columns) { + if (columns[0].getCellCount() < limit) { + return columns; + } + + Column[] limitedColumns = new Column[columns.length]; + for (int i = 0; i < columns.length; i++) { + limitedColumns[i] = columns[i].getLimitedColumn(limit); + } + + return limitedColumns; + } + + /** + * 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 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 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 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 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. + * + *

For each row of data: + * + *

    + *
  • Writes the data line with appropriate borders and alignment + *
  • If not the last row and row boundaries are enabled in the style, writes a separator line + * between rows + *
+ * + * @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 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); + } + } + + /** + * 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 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); + } + + /** + * 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; + + if (left != null) { + osw.write(left); + } + + 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); + } + } + + if (right != null) { + osw.write(right); + } + + 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)); + } + + /** + * 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 { + + /** + * Creates a new {@link TableFormat} with the specified properties. + * + * @param context the command context. + */ + public MetalakeTableFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ @Override - public void output(Metalake metalake) { - List headers = Arrays.asList("metalake", "comment"); - List> rows = new ArrayList<>(); - rows.add(Arrays.asList(metalake.name(), metalake.comment())); - TableFormatImpl tableFormat = new TableFormatImpl(); - tableFormat.print(headers, rows); + public String getOutput(Metalake metalake) { + Column columnA = new Column(context, "METALAKE"); + Column columnB = new Column(context, "COMMENT"); + + columnA.addCell(metalake.name()); + columnB.addCell(metalake.comment()); + + return getTableFormat(columnA, columnB); } } - static final class MetalakesTableFormat implements OutputFormat { + /** + * Formats an array of Metalakes into a single-column table display. Lists all metalake names in a + * vertical format. + */ + static final class MetalakesTableFormat extends TableFormat { + + public MetalakesTableFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ @Override - public void output(Metalake[] metalakes) { + public String getOutput(Metalake[] metalakes) { if (metalakes.length == 0) { - System.out.println("No metalakes exist."); + output("No metalakes exist.", System.err); + return null; } else { - List headers = Collections.singletonList("metalake"); - List> 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); + Column columnA = new Column(context, "METALAKE"); + Arrays.stream(metalakes).forEach(metalake -> columnA.addCell(metalake.name())); + + return getTableFormat(columnA); } } } - static final class CatalogTableFormat implements OutputFormat { + /** + * 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 { + + public CatalogTableFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ @Override - public void output(Catalog catalog) { - List headers = Arrays.asList("catalog", "type", "provider", "comment"); - List> rows = new ArrayList<>(); - rows.add( - Arrays.asList( - catalog.name(), - catalog.type().toString(), - catalog.provider(), - catalog.comment() + "")); - TableFormatImpl tableFormat = new TableFormatImpl(); - tableFormat.print(headers, rows); + public String getOutput(Catalog catalog) { + Column columnA = new Column(context, "CATALOG"); + Column columnB = new Column(context, "TYPE"); + Column columnC = new Column(context, "PROVIDER"); + Column columnD = new Column(context, "COMMENT"); + + columnA.addCell(catalog.name()); + columnB.addCell(catalog.type().name()); + columnC.addCell(catalog.provider()); + columnD.addCell(catalog.comment()); + + return getTableFormat(columnA, columnB, columnC, columnD); } } - static final class CatalogsTableFormat implements OutputFormat { + /** + * Formats an array of Catalogs into a single-column table display. Lists all catalog names in a + * vertical format. + */ + static final class CatalogsTableFormat extends TableFormat { + + public CatalogsTableFormat(CommandContext context) { + super(context); + } + + /** {@inheritDoc} */ @Override - public void output(Catalog[] catalogs) { + public String getOutput(Catalog[] catalogs) { if (catalogs.length == 0) { - System.out.println("No catalogs exist."); + output("No metalakes exist.", System.err); + return null; } else { - List headers = Collections.singletonList("catalog"); - List> 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); + Column columnA = new Column(context, "CATALOG"); + Arrays.stream(catalogs).forEach(metalake -> columnA.addCell(metalake.name())); + + return getTableFormat(columnA); } } } - 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 = " "; - - public void debug() { - System.out.println(); - Arrays.stream(maxElementLengths).forEach(e -> System.out.print(e + " ")); + /** + * Formats a single {@link Schema} instance into a two-column table display. Displays catalog + * details including name and comment information. + */ + static final class SchemaTableFormat extends TableFormat { + public SchemaTableFormat(CommandContext context) { + super(context); } - public void print(List headers, List> 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)); - } - System.out.println(verticalDelimiter); - printLine(); - System.out.println(); - - // print rows - for (int i = 0; i < rows.size(); ++i) { - List 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(""); - } + /** {@inheritDoc} */ + @Override + public String getOutput(Schema schema) { + Column columnA = new Column(context, "SCHEMA"); + Column columnB = new Column(context, "COMMENT"); - private void updateMaxLengthsFromList(List elements) { - String s; - for (int i = 0; i < elements.size(); ++i) { - s = elements.get(i); - if (getOutputWidth(s) > maxElementLengths[i]) maxElementLengths[i] = getOutputWidth(s); - } + columnA.addCell(schema.name()); + columnB.addCell(schema.comment()); + + return getTableFormat(columnA, columnB); } + } - private void updateMaxLengthsFromNestedList(List> elements) { - int rowIdx = 0; - for (List 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++; - } + /** + * Formats an array of Schemas into a single-column table display. Lists all schema names in a + * vertical format. + */ + static final class SchemasTableFormat extends TableFormat { + public SchemasTableFormat(CommandContext context) { + super(context); } - private int getOutputWidth(String s) { - int width = 0; - for (int i = 0; i < s.length(); i++) { - width += getCharWidth(s.charAt(i)); + /** {@inheritDoc} */ + @Override + public String getOutput(Schema[] schemas) { + if (schemas.length == 0) { + output("No schemas exist.", System.err); + return null; + } else { + Column column = new Column(context, "SCHEMA"); + Arrays.stream(schemas).forEach(schema -> column.addCell(schema.name())); + + return getTableFormat(column); } + } + } - return width; + static final class AuditTableFormat extends TableFormat { + public AuditTableFormat(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(Audit audit) { + Column columnA = new Column(context, "creator"); + Column columnB = new Column(context, "create_time"); + Column columnC = new Column(context, "modified"); + Column columnD = new Column(context, "modify_time"); - return 1; - } + columnA.addCell(audit.creator()); + columnB.addCell(audit.createTime()); + columnC.addCell(audit.lastModifier()); + columnD.addCell(audit.lastModifiedTime()); - 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); - } + return getTableFormat(columnA, columnB, columnC, columnD); } } } 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 f23d0284fb2..c6f5d9e4c05 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 void testMetalakeListCommand() { 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 void testMetalakeDetailsCommand() { 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 void testCatalogListCommand() { 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 void testCatalogDetailsCommand() { 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 void testCatalogDetailsCommandFullCornerCharacter() { 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 00000000000..e38609ffc89 --- /dev/null +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/output/TestPlainFormat.java @@ -0,0 +1,204 @@ +/* + * 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 testSchemaDetailsWithPlainFormat() { + CommandContext mockContext = getMockContext(); + Schema mockSchema = getMockSchema(); + PlainFormat.output(mockSchema, mockContext); + String output = new String(outContent.toByteArray(), StandardCharsets.UTF_8).trim(); + Assertions.assertEquals("demo_schema, This is a demo schema", output); + } + + @Test + void testListSchemaWithPlainFormat() { + CommandContext mockContext = getMockContext(); + Schema mockSchema1 = getMockSchema("schema1", "This is a schema"); + Schema mockSchema2 = getMockSchema("schema2", "This is another schema"); + + PlainFormat.output(new Schema[] {mockSchema1, mockSchema2}, mockContext); + String output = new String(outContent.toByteArray(), StandardCharsets.UTF_8).trim(); + Assertions.assertEquals("schema1\n" + "schema2", output); + } + + @Test + void testAuditWithPlainFormat() { + CommandContext mockContext = getMockContext(); + Audit mockAudit = getMockAudit(); + PlainFormat.output(mockAudit, mockContext); + String output = new String(outContent.toByteArray(), StandardCharsets.UTF_8).trim(); + Assertions.assertEquals( + "creator, create_time, modified, modified_time\n" + + "demo_user, 2021-01-20T02:51:51.111Z, demo_user, 2021-01-20T02:51:51.111Z", + 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); + when(mockContext.outputLimit()).thenReturn(-1); + + 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 00000000000..599d9eb4897 --- /dev/null +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/output/TestTableFormat.java @@ -0,0 +1,456 @@ +/* + * 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.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 tableFormat = + new TableFormat(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 tableFormat = + new TableFormat(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 tableFormat = + new TableFormat(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 tableFormat = + new TableFormat(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 tableFormat = + new TableFormat(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 testTableOutputWithLimit() { + CommandContext mockContext = mock(CommandContext.class); + when(mockContext.outputLimit()).thenReturn(5); + + Column columnA = new Column(mockContext, "METALAKE"); + Column columnB = new Column(mockContext, "comment"); + + addRepeatedCells(columnA, 10); + addRepeatedCells(columnB, 10); + + TableFormat tableFormat = + new TableFormat(mockContext) { + @Override + public String getOutput(String entity) { + return null; + } + }; + + String outputString = tableFormat.getTableFormat(columnA, columnB).trim(); + Assertions.assertEquals( + "+------------+-----------+\n" + + "| METALAKE | comment |\n" + + "+------------+-----------+\n" + + "| METALAKE-1 | comment-1 |\n" + + "| METALAKE-2 | comment-2 |\n" + + "| METALAKE-3 | comment-3 |\n" + + "| METALAKE-4 | comment-4 |\n" + + "| METALAKE-5 | comment-5 |\n" + + "| … | … |\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 testSchemaDetailsWithTableFormat() { + CommandContext mockContext = getMockContext(); + Schema mockSchema = getMockSchema(); + TableFormat.output(mockSchema, mockContext); + String output = new String(outContent.toByteArray(), StandardCharsets.UTF_8).trim(); + Assertions.assertEquals( + "+-------------+-----------------------+\n" + + "| SCHEMA | COMMENT |\n" + + "+-------------+-----------------------+\n" + + "| demo_schema | This is a demo schema |\n" + + "+-------------+-----------------------+", + output); + } + + @Test + void testListSchemaWithTableFormat() { + CommandContext mockContext = getMockContext(); + Schema mockSchema1 = getMockSchema("schema1", "This is a schema"); + Schema mockSchema2 = getMockSchema("schema2", "This is another schema"); + + TableFormat.output(new Schema[] {mockSchema1, mockSchema2}, mockContext); + String output = new String(outContent.toByteArray(), StandardCharsets.UTF_8).trim(); + Assertions.assertEquals( + "+---------+\n" + + "| SCHEMA |\n" + + "+---------+\n" + + "| schema1 |\n" + + "| schema2 |\n" + + "+---------+", + output); + } + + @Test + void testAuditWithTableFormat() { + CommandContext mockContext = getMockContext(); + Audit mockAudit = getMockAudit(); + TableFormat.output(mockAudit, mockContext); + String output = new String(outContent.toByteArray(), StandardCharsets.UTF_8).trim(); + Assertions.assertEquals( + "+-----------+--------------------------+-----------+--------------------------+\n" + + "| creator | create_time | modified | modify_time |\n" + + "+-----------+--------------------------+-----------+--------------------------+\n" + + "| demo_user | 2021-01-20T02:51:51.111Z | demo_user | 2021-01-20T02:51:51.111Z |\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); + when(mockContext.outputLimit()).thenReturn(-1); + + 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; + } +}