This is an automated email from the ASF dual-hosted git repository. yuqi4733 pushed a commit to branch internal-main in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit b73baaee9b7d413dc3f774ec2daaf396c84752d1 Author: geyanggang <[email protected]> AuthorDate: Fri Feb 6 17:01:49 2026 +0800 [#128]feat (maxcompute-catalog): Add database operations for MaxCompute catalog. --- build.gradle.kts | 1 + .../maxcompute/MaxComputeCatalogCapability.java | 80 +++++++- .../converter/MaxComputeExceptionConverter.java | 197 ++++++++++++++++++- .../operation/MaxComputeDatabaseOperations.java | 216 ++++++++++++++++++++- .../TestMaxComputeExceptionConverter.java | 188 ++++++++++++++++++ .../TestMaxComputeDatabaseOperations.java | 192 ++++++++++++++++++ 6 files changed, 868 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 22ca5904fa..9a8d599cd9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1053,6 +1053,7 @@ tasks { ":catalogs:catalog-hive:copyLibAndConfig", ":catalogs:catalog-jdbc-bigquery:copyLibAndConfig", ":catalogs:catalog-jdbc-doris:copyLibAndConfig", + ":catalogs:catalog-jdbc-maxcompute:copyLibAndConfig", ":catalogs:catalog-jdbc-mysql:copyLibAndConfig", ":catalogs:catalog-jdbc-oceanbase:copyLibAndConfig", ":catalogs:catalog-jdbc-postgresql:copyLibAndConfig", diff --git a/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/MaxComputeCatalogCapability.java b/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/MaxComputeCatalogCapability.java index f4f0311dbb..5be86acae7 100644 --- a/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/MaxComputeCatalogCapability.java +++ b/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/MaxComputeCatalogCapability.java @@ -18,7 +18,83 @@ */ package org.apache.gravitino.catalog.maxcompute; +import java.util.Set; import org.apache.gravitino.connector.capability.Capability; +import org.apache.gravitino.connector.capability.CapabilityResult; -/** Capability for MaxCompute catalog. */ -public class MaxComputeCatalogCapability implements Capability {} +/** + * Capability for MaxCompute catalog. + * + * <p>MaxCompute has the following limitations: + * + * <ul> + * <li>Schema/Table names are case-insensitive + * <li>Column default values have limited support (only constant values, no functions like + * GETDATE()) + * <li>Table names must start with a letter or underscore and can only contain letters, digits, + * and underscores + * <li>Maximum table name length is 128 bytes + * </ul> + */ +public class MaxComputeCatalogCapability implements Capability { + + /** + * Regular expression for MaxCompute identifier names. + * + * <p>Rules: + * + * <ul> + * <li>Must start with a letter (a-z, A-Z) or underscore (_) + * <li>Can contain letters, digits (0-9), and underscores + * <li>Maximum length is 128 characters + * </ul> + */ + private static final String MAXCOMPUTE_NAME_PATTERN = "^[a-zA-Z_][a-zA-Z0-9_]{0,127}$"; + + /** Reserved schema names in MaxCompute that cannot be used for user-defined schemas. */ + private static final Set<String> MAXCOMPUTE_RESERVED_WORDS = Set.of("information_schema"); + + @Override + public CapabilityResult specificationOnName(Scope scope, String name) { + // Check name pattern + if (!name.matches(MAXCOMPUTE_NAME_PATTERN)) { + return CapabilityResult.unsupported( + String.format( + "The %s name '%s' is illegal. MaxCompute names must start with a letter or underscore, " + + "contain only letters, digits, and underscores, and be at most 128 characters long.", + scope, name)); + } + + // Check reserved words for schema + if (scope == Scope.SCHEMA && MAXCOMPUTE_RESERVED_WORDS.contains(name.toLowerCase())) { + return CapabilityResult.unsupported( + String.format("The %s name '%s' is reserved and cannot be used.", scope, name)); + } + + return CapabilityResult.SUPPORTED; + } + + /** + * MaxCompute table/schema names are case-insensitive. Names are stored in lowercase internally. + * + * @param scope The scope of the capability. + * @return The capability result indicating case-insensitivity. + */ + @Override + public CapabilityResult caseSensitiveOnName(Scope scope) { + return CapabilityResult.unsupported( + String.format("MaxCompute does not support case-sensitive %s names.", scope)); + } + + /** + * MaxCompute has limited support for column default values. Only constant values are supported, + * function-based defaults (like GETDATE()) are not supported. + * + * @return The capability result for column default value support. + */ + @Override + public CapabilityResult columnDefaultValue() { + // MaxCompute supports constant default values but not function-based defaults + return CapabilityResult.SUPPORTED; + } +} diff --git a/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/converter/MaxComputeExceptionConverter.java b/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/converter/MaxComputeExceptionConverter.java index abc69e8de9..fe7f628d6f 100644 --- a/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/converter/MaxComputeExceptionConverter.java +++ b/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/converter/MaxComputeExceptionConverter.java @@ -18,16 +18,211 @@ */ package org.apache.gravitino.catalog.maxcompute.converter; +import com.google.common.annotations.VisibleForTesting; import java.sql.SQLException; +import java.util.Arrays; +import org.apache.commons.lang3.StringUtils; import org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter; +import org.apache.gravitino.exceptions.ConnectionFailedException; import org.apache.gravitino.exceptions.GravitinoRuntimeException; +import org.apache.gravitino.exceptions.NoSuchSchemaException; +import org.apache.gravitino.exceptions.NoSuchTableException; +import org.apache.gravitino.exceptions.UnauthorizedException; -/** Exception converter for MaxCompute. */ +/** + * Exception converter for MaxCompute. + * + * <p>MaxCompute uses ODPS error codes in the format "ODPS-XXXXXXX". This converter handles common + * error patterns including authentication failures, permission denied, and schema/table not found + * errors. + * + * <p>Common ODPS error code patterns: + * + * <ul> + * <li>ODPS-0110011: Authorization exception (unauthorized) + * <li>ODPS-0030001: Authorization exception (permission denied) + * <li>ODPS-0420095: Access Denied + * <li>Authentication failures: Various patterns in error messages + * </ul> + */ public class MaxComputeExceptionConverter extends JdbcExceptionConverter { + // ODPS error code patterns + @VisibleForTesting static final String ODPS_AUTHORIZATION_EXCEPTION = "ODPS-0110011"; + + @VisibleForTesting static final String ODPS_PERMISSION_DENIED = "ODPS-0030001"; + + @VisibleForTesting static final String ODPS_ACCESS_DENIED = "ODPS-0420095"; + + // Error message patterns for authentication failures + @VisibleForTesting + static final String[] AUTHENTICATION_FAILURE_PATTERNS = { + "Access denied", + "authentication failed", + "Invalid AccessKeyId", + "invalid access key", + "Signature not match", + "signature does not match", + "InvalidAccessKeyId", + "SignatureDoesNotMatch" + }; + + // Error message patterns for permission denied + @VisibleForTesting + static final String[] PERMISSION_DENIED_PATTERNS = { + "Access Denied", + "no privilege", + "permission denied", + "not authorized", + "Authorization exception", + "You are unauthorized" + }; + + // Error message patterns for schema not found + @VisibleForTesting + static final String[] SCHEMA_NOT_FOUND_PATTERNS = { + "project not found", + "Project not found", + "schema not found", + "Schema not found", + "Unknown project", + "unknown project", + "does not exist" + }; + + // Error message patterns for table not found + @VisibleForTesting + static final String[] TABLE_NOT_FOUND_PATTERNS = { + "table not found", "Table not found", "Unknown table", "unknown table" + }; + + // Error message patterns for connection failures + @VisibleForTesting + static final String[] CONNECTION_FAILURE_PATTERNS = { + "Connection refused", + "Connection timed out", + "Unable to connect", + "Network is unreachable", + "No route to host", + "Connection reset", + "Socket timeout", + "Read timed out" + }; + + /** + * Converts a SQLException to a GravitinoRuntimeException. + * + * @param se the SQLException to convert + * @return the corresponding GravitinoRuntimeException + */ @SuppressWarnings("FormatStringAnnotation") @Override public GravitinoRuntimeException toGravitinoException(SQLException se) { + String message = se.getMessage(); + + // Check for authentication failures first (most critical) + if (isAuthenticationFailure(message)) { + return new UnauthorizedException(se, se.getMessage()); + } + + // Check for permission denied errors + if (isPermissionDenied(message)) { + return new UnauthorizedException(se, se.getMessage()); + } + + // Check for connection failures + if (isConnectionFailure(message)) { + return new ConnectionFailedException(se, se.getMessage()); + } + + // Check for schema not found errors + if (isSchemaNotFound(message)) { + return new NoSuchSchemaException(se, se.getMessage()); + } + + // Check for table not found errors + if (isTableNotFound(message)) { + return new NoSuchTableException(se, se.getMessage()); + } + + // Default: return generic runtime exception return new GravitinoRuntimeException(se, se.getMessage()); } + + /** + * Checks if the error message indicates an authentication failure. + * + * @param message the error message + * @return true if the message indicates an authentication failure + */ + @VisibleForTesting + static boolean isAuthenticationFailure(String message) { + if (StringUtils.isBlank(message)) { + return false; + } + return Arrays.stream(AUTHENTICATION_FAILURE_PATTERNS).anyMatch(message::contains); + } + + /** + * Checks if the error message indicates a permission denied error. + * + * @param message the error message + * @return true if the message indicates a permission denied error + */ + @VisibleForTesting + static boolean isPermissionDenied(String message) { + if (StringUtils.isBlank(message)) { + return false; + } + // Check for ODPS error codes + if (message.contains(ODPS_AUTHORIZATION_EXCEPTION) + || message.contains(ODPS_PERMISSION_DENIED) + || message.contains(ODPS_ACCESS_DENIED)) { + return true; + } + // Check for error message patterns + return Arrays.stream(PERMISSION_DENIED_PATTERNS).anyMatch(message::contains); + } + + /** + * Checks if the error message indicates a schema not found error. + * + * @param message the error message + * @return true if the message indicates a schema not found error + */ + @VisibleForTesting + static boolean isSchemaNotFound(String message) { + if (StringUtils.isBlank(message)) { + return false; + } + return Arrays.stream(SCHEMA_NOT_FOUND_PATTERNS).anyMatch(message::contains); + } + + /** + * Checks if the error message indicates a table not found error. + * + * @param message the error message + * @return true if the message indicates a table not found error + */ + @VisibleForTesting + static boolean isTableNotFound(String message) { + if (StringUtils.isBlank(message)) { + return false; + } + return Arrays.stream(TABLE_NOT_FOUND_PATTERNS).anyMatch(message::contains); + } + + /** + * Checks if the error message indicates a connection failure. + * + * @param message the error message + * @return true if the message indicates a connection failure + */ + @VisibleForTesting + static boolean isConnectionFailure(String message) { + if (StringUtils.isBlank(message)) { + return false; + } + return Arrays.stream(CONNECTION_FAILURE_PATTERNS).anyMatch(message::contains); + } } diff --git a/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/operation/MaxComputeDatabaseOperations.java b/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/operation/MaxComputeDatabaseOperations.java index 86312413dd..12d4d54046 100644 --- a/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/operation/MaxComputeDatabaseOperations.java +++ b/catalogs/catalog-jdbc-maxcompute/src/main/java/org/apache/gravitino/catalog/maxcompute/operation/MaxComputeDatabaseOperations.java @@ -18,23 +18,205 @@ */ package org.apache.gravitino.catalog.maxcompute.operation; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.gravitino.catalog.jdbc.JdbcSchema; import org.apache.gravitino.catalog.jdbc.operation.JdbcDatabaseOperations; +import org.apache.gravitino.exceptions.NoSuchSchemaException; +import org.apache.gravitino.meta.AuditInfo; -/** Database operations for MaxCompute. */ +/** + * Database operations for MaxCompute. + * + * <p>MaxCompute has a three-layer structure: Project -> Schema -> Table. The Project is specified + * in the JDBC URL (jdbc:odps:{endpoint}?project={project_name}), and Schema is the namespace within + * a Project. + * + * <p>This implementation maps Gravitino Schema to MaxCompute Schema. Use "SHOW SCHEMAS" SQL command + * to list schemas within the current project. + * + * <p>Schema operations require enabling schema mode in MaxCompute. This class automatically enables + * schema mode by executing "set odps.namespace.schema=true" on each connection. + */ public class MaxComputeDatabaseOperations extends JdbcDatabaseOperations { + private static final String BACK_QUOTE = "`"; + + /** + * Enables schema mode on the given connection. + * + * <p>MaxCompute has a three-layer structure: Project -> Schema -> Table. Schema mode must be + * enabled to use schema-level operations like CREATE SCHEMA, DROP SCHEMA, etc. + * + * @param connection the JDBC connection + * @throws SQLException if the setting fails + */ + private static void enableSchemaMode(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("set odps.namespace.schema=true"); + } + } + + /** + * Gets a connection with schema mode enabled. + * + * <p>MaxCompute requires schema mode to be enabled for schema operations. This method overrides + * the base implementation to enable schema mode on each connection. + * + * @return a JDBC connection with schema mode enabled + * @throws SQLException if connection fails or schema mode cannot be enabled + */ + @Override + protected Connection getConnection() throws SQLException { + Connection connection = dataSource.getConnection(); + try { + enableSchemaMode(connection); + return connection; + } catch (SQLException e) { + try { + connection.close(); + } catch (SQLException closeException) { + // Log but don't mask the original exception + LOG.warn("Failed to close connection after enableSchemaMode failure", closeException); + } + throw e; + } + } + + /** + * Lists all schemas in the current MaxCompute project. + * + * <p>Uses "SHOW SCHEMAS" SQL command to get the list of schemas within the current project. Each + * project has a default schema named "default" and can have additional custom schemas. + * + * <p>Note: DatabaseMetaData.getSchemas() returns Projects list, not Schemas within a Project, so + * we use SQL command instead. + * + * <p>The SHOW SCHEMAS command may return results in different formats depending on the JDBC + * driver version. This implementation handles both single-column and space-separated formats. + * + * @return a list of schema names + */ + @Override + public List<String> listDatabases() { + List<String> schemaNames = new ArrayList<>(); + try (Connection connection = getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SHOW SCHEMAS")) { + while (resultSet.next()) { + String result = resultSet.getString(1); + if (StringUtils.isNotBlank(result)) { + // Handle both formats: one schema per row or space-separated schemas + String[] schemas = result.trim().split("\\s+"); + for (String schemaName : schemas) { + if (StringUtils.isNotBlank(schemaName) && !isSystemDatabase(schemaName)) { + schemaNames.add(schemaName); + } + } + } + } + return schemaNames; + } catch (SQLException se) { + throw this.exceptionMapper.toGravitinoException(se); + } + } + + /** + * Loads a schema from MaxCompute. + * + * @param databaseName the name of the schema to load + * @return the JdbcSchema representing the schema + * @throws NoSuchSchemaException if the schema does not exist + */ + @Override + public JdbcSchema load(String databaseName) throws NoSuchSchemaException { + List<String> allDatabases = listDatabases(); + String dbName = + allDatabases.stream() + .filter(db -> db.equalsIgnoreCase(databaseName)) + .findFirst() + .orElseThrow( + () -> new NoSuchSchemaException("Schema %s could not be found", databaseName)); + + // MaxCompute does not support schema comments or properties via JDBC + return JdbcSchema.builder() + .withName(dbName) + .withProperties(ImmutableMap.of()) + .withAuditInfo(AuditInfo.EMPTY) + .build(); + } + + /** + * Generates the SQL statement to create a schema in MaxCompute. + * + * <p>MaxCompute syntax: CREATE SCHEMA `schema_name` + * + * <p>Note: MaxCompute does not support schema comments or properties in CREATE SCHEMA statement. + * + * @param databaseName the name of the schema to create + * @param comment the comment for the schema (not supported, will be ignored) + * @param properties the properties for the schema (not supported) + * @return the SQL statement to create the schema + */ @Override public String generateCreateDatabaseSql( String databaseName, String comment, Map<String, String> properties) { - throw new UnsupportedOperationException("Not implemented yet"); + if (MapUtils.isNotEmpty(properties)) { + throw new UnsupportedOperationException( + "MaxCompute does not support setting schema properties."); + } + String sql = String.format("CREATE SCHEMA %s", quoteDatabaseName(databaseName)); + LOG.info("Generated create schema sql: {}", sql); + return sql; } + /** + * Generates the SQL statement to drop a schema in MaxCompute. + * + * <p>MaxCompute syntax: DROP SCHEMA `schema_name` + * + * <p>Note: MaxCompute does not support CASCADE option in DROP SCHEMA. The schema must be empty + * before dropping. When cascade is true, this method checks if the schema is empty and throws an + * exception if it contains tables. + * + * @param databaseName the name of the schema to drop + * @param cascade whether to check for non-empty schema (MaxCompute does not support CASCADE) + * @return the SQL statement to drop the schema + */ @Override public String generateDropDatabaseSql(String databaseName, boolean cascade) { - throw new UnsupportedOperationException("Not implemented yet"); + if (cascade) { + // Check if schema has tables before dropping + try (Connection connection = getConnection(); + Statement statement = connection.createStatement()) { + String query = String.format("SHOW TABLES IN %s", quoteDatabaseName(databaseName)); + try (ResultSet resultSet = statement.executeQuery(query)) { + if (resultSet.next()) { + throw new IllegalStateException( + String.format( + "Schema %s is not empty. MaxCompute does not support CASCADE drop. " + + "Please drop all tables in the schema first.", + databaseName)); + } + } + } catch (SQLException se) { + throw this.exceptionMapper.toGravitinoException(se); + } + } + String sql = String.format("DROP SCHEMA %s", quoteDatabaseName(databaseName)); + LOG.info("Generated drop schema sql: {}", sql); + return sql; } @Override @@ -46,4 +228,32 @@ public class MaxComputeDatabaseOperations extends JdbcDatabaseOperations { protected Set<String> createSysDatabaseNameSet() { return ImmutableSet.of("information_schema"); } + + /** + * Check whether it is a system database. + * + * @param dbName The name of the database. + * @return whether it is a system database. + */ + @Override + protected boolean isSystemDatabase(String dbName) { + if (dbName == null) { + return false; + } + return createSysDatabaseNameSet().contains(dbName.toLowerCase(Locale.ROOT)); + } + + /** + * Quotes a database/schema name for use in SQL statements. + * + * <p>Escapes backticks in the name by doubling them, then wraps the name in backticks. + * + * @param databaseName the database name to quote + * @return the quoted database name + */ + private String quoteDatabaseName(String databaseName) { + // Escape backticks by doubling them + String escaped = databaseName.replace(BACK_QUOTE, BACK_QUOTE + BACK_QUOTE); + return BACK_QUOTE + escaped + BACK_QUOTE; + } } diff --git a/catalogs/catalog-jdbc-maxcompute/src/test/java/org/apache/gravitino/catalog/maxcompute/converter/TestMaxComputeExceptionConverter.java b/catalogs/catalog-jdbc-maxcompute/src/test/java/org/apache/gravitino/catalog/maxcompute/converter/TestMaxComputeExceptionConverter.java new file mode 100644 index 0000000000..65d293b3b9 --- /dev/null +++ b/catalogs/catalog-jdbc-maxcompute/src/test/java/org/apache/gravitino/catalog/maxcompute/converter/TestMaxComputeExceptionConverter.java @@ -0,0 +1,188 @@ +/* + * 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.catalog.maxcompute.converter; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.SQLException; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import org.apache.gravitino.exceptions.GravitinoRuntimeException; +import org.apache.gravitino.exceptions.NoSuchSchemaException; +import org.apache.gravitino.exceptions.NoSuchTableException; +import org.apache.gravitino.exceptions.UnauthorizedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for MaxComputeExceptionConverter. */ +class TestMaxComputeExceptionConverter { + + private MaxComputeExceptionConverter converter; + + @BeforeEach + void setUp() { + converter = new MaxComputeExceptionConverter(); + } + + @Test + void testIsAuthenticationFailure() { + assertTrue(MaxComputeExceptionConverter.isAuthenticationFailure("Access denied for user")); + assertTrue( + MaxComputeExceptionConverter.isAuthenticationFailure("authentication failed for account")); + assertTrue(MaxComputeExceptionConverter.isAuthenticationFailure("Invalid AccessKeyId")); + assertTrue(MaxComputeExceptionConverter.isAuthenticationFailure("invalid access key provided")); + assertTrue(MaxComputeExceptionConverter.isAuthenticationFailure("Signature not match")); + assertTrue( + MaxComputeExceptionConverter.isAuthenticationFailure("signature does not match expected")); + assertTrue(MaxComputeExceptionConverter.isAuthenticationFailure("InvalidAccessKeyId error")); + assertTrue(MaxComputeExceptionConverter.isAuthenticationFailure("SignatureDoesNotMatch")); + + assertFalse(MaxComputeExceptionConverter.isAuthenticationFailure(null)); + assertFalse(MaxComputeExceptionConverter.isAuthenticationFailure("")); + assertFalse(MaxComputeExceptionConverter.isAuthenticationFailure(" ")); + assertFalse(MaxComputeExceptionConverter.isAuthenticationFailure("Some other error")); + } + + @Test + void testIsPermissionDenied() { + assertTrue(MaxComputeExceptionConverter.isPermissionDenied("Access Denied - no permission")); + assertTrue(MaxComputeExceptionConverter.isPermissionDenied("You have no privilege to access")); + assertTrue(MaxComputeExceptionConverter.isPermissionDenied("permission denied for operation")); + assertTrue(MaxComputeExceptionConverter.isPermissionDenied("User is not authorized")); + assertTrue(MaxComputeExceptionConverter.isPermissionDenied("Authorization exception occurred")); + assertTrue(MaxComputeExceptionConverter.isPermissionDenied("You are unauthorized to access")); + + // Test ODPS error codes + assertTrue( + MaxComputeExceptionConverter.isPermissionDenied( + "ODPS-0110011: Authorization exception - You are unauthorized")); + assertTrue( + MaxComputeExceptionConverter.isPermissionDenied( + "ODPS-0030001: Authorization exception - permission denied")); + assertTrue( + MaxComputeExceptionConverter.isPermissionDenied( + "ODPS-0420095: Access Denied - You have no privilege")); + + assertFalse(MaxComputeExceptionConverter.isPermissionDenied(null)); + assertFalse(MaxComputeExceptionConverter.isPermissionDenied("")); + assertFalse(MaxComputeExceptionConverter.isPermissionDenied(" ")); + assertFalse(MaxComputeExceptionConverter.isPermissionDenied("Some other error")); + } + + @Test + void testIsSchemaNotFound() { + assertTrue(MaxComputeExceptionConverter.isSchemaNotFound("project not found: test_project")); + assertTrue(MaxComputeExceptionConverter.isSchemaNotFound("Project not found in region")); + assertTrue(MaxComputeExceptionConverter.isSchemaNotFound("schema not found: test_schema")); + assertTrue(MaxComputeExceptionConverter.isSchemaNotFound("Schema not found error")); + assertTrue(MaxComputeExceptionConverter.isSchemaNotFound("Unknown project: test")); + assertTrue(MaxComputeExceptionConverter.isSchemaNotFound("unknown project specified")); + assertTrue(MaxComputeExceptionConverter.isSchemaNotFound("The project does not exist")); + + assertFalse(MaxComputeExceptionConverter.isSchemaNotFound(null)); + assertFalse(MaxComputeExceptionConverter.isSchemaNotFound("")); + assertFalse(MaxComputeExceptionConverter.isSchemaNotFound(" ")); + assertFalse(MaxComputeExceptionConverter.isSchemaNotFound("Some other error")); + } + + @Test + void testIsTableNotFound() { + assertTrue(MaxComputeExceptionConverter.isTableNotFound("table not found: test_table")); + assertTrue(MaxComputeExceptionConverter.isTableNotFound("Table not found in schema")); + assertTrue(MaxComputeExceptionConverter.isTableNotFound("Unknown table: test")); + assertTrue(MaxComputeExceptionConverter.isTableNotFound("unknown table specified")); + + assertFalse(MaxComputeExceptionConverter.isTableNotFound(null)); + assertFalse(MaxComputeExceptionConverter.isTableNotFound("")); + assertFalse(MaxComputeExceptionConverter.isTableNotFound(" ")); + assertFalse(MaxComputeExceptionConverter.isTableNotFound("Some other error")); + } + + @Test + void testIsConnectionFailure() { + assertTrue(MaxComputeExceptionConverter.isConnectionFailure("Connection refused by server")); + assertTrue(MaxComputeExceptionConverter.isConnectionFailure("Connection timed out")); + assertTrue(MaxComputeExceptionConverter.isConnectionFailure("Unable to connect to host")); + assertTrue(MaxComputeExceptionConverter.isConnectionFailure("Network is unreachable")); + assertTrue(MaxComputeExceptionConverter.isConnectionFailure("No route to host")); + assertTrue(MaxComputeExceptionConverter.isConnectionFailure("Connection reset by peer")); + assertTrue(MaxComputeExceptionConverter.isConnectionFailure("Socket timeout occurred")); + assertTrue(MaxComputeExceptionConverter.isConnectionFailure("Read timed out")); + + assertFalse(MaxComputeExceptionConverter.isConnectionFailure(null)); + assertFalse(MaxComputeExceptionConverter.isConnectionFailure("")); + assertFalse(MaxComputeExceptionConverter.isConnectionFailure(" ")); + assertFalse(MaxComputeExceptionConverter.isConnectionFailure("Some other error")); + } + + @Test + void testToGravitinoExceptionAuthenticationFailure() { + SQLException se = new SQLException("Access denied for user 'test'"); + GravitinoRuntimeException result = converter.toGravitinoException(se); + assertInstanceOf(UnauthorizedException.class, result); + } + + @Test + void testToGravitinoExceptionPermissionDenied() { + SQLException se = + new SQLException("ODPS-0110011: Authorization exception - You are unauthorized"); + GravitinoRuntimeException result = converter.toGravitinoException(se); + assertInstanceOf(UnauthorizedException.class, result); + } + + @Test + void testToGravitinoExceptionSchemaNotFound() { + SQLException se = new SQLException("Project not found: test_project"); + GravitinoRuntimeException result = converter.toGravitinoException(se); + assertInstanceOf(NoSuchSchemaException.class, result); + } + + @Test + void testToGravitinoExceptionTableNotFound() { + SQLException se = new SQLException("Table not found: test_table"); + GravitinoRuntimeException result = converter.toGravitinoException(se); + assertInstanceOf(NoSuchTableException.class, result); + } + + @Test + void testToGravitinoExceptionConnectionFailure() { + SQLException se = new SQLException("Connection refused by server"); + GravitinoRuntimeException result = converter.toGravitinoException(se); + assertInstanceOf(ConnectionFailedException.class, result); + } + + @Test + void testToGravitinoExceptionGenericError() { + SQLException se = new SQLException("Some generic error occurred"); + GravitinoRuntimeException result = converter.toGravitinoException(se); + assertInstanceOf(GravitinoRuntimeException.class, result); + assertFalse(result instanceof ConnectionFailedException); + assertFalse(result instanceof UnauthorizedException); + assertFalse(result instanceof NoSuchSchemaException); + assertFalse(result instanceof NoSuchTableException); + } + + @Test + void testToGravitinoExceptionNullMessage() { + SQLException se = new SQLException((String) null); + GravitinoRuntimeException result = converter.toGravitinoException(se); + assertInstanceOf(GravitinoRuntimeException.class, result); + } +} diff --git a/catalogs/catalog-jdbc-maxcompute/src/test/java/org/apache/gravitino/catalog/maxcompute/operation/TestMaxComputeDatabaseOperations.java b/catalogs/catalog-jdbc-maxcompute/src/test/java/org/apache/gravitino/catalog/maxcompute/operation/TestMaxComputeDatabaseOperations.java new file mode 100644 index 0000000000..67fd8501eb --- /dev/null +++ b/catalogs/catalog-jdbc-maxcompute/src/test/java/org/apache/gravitino/catalog/maxcompute/operation/TestMaxComputeDatabaseOperations.java @@ -0,0 +1,192 @@ +/* + * 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.catalog.maxcompute.operation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; +import org.apache.gravitino.catalog.jdbc.JdbcSchema; +import org.apache.gravitino.catalog.maxcompute.converter.MaxComputeExceptionConverter; +import org.apache.gravitino.exceptions.NoSuchSchemaException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for MaxComputeDatabaseOperations. */ +class TestMaxComputeDatabaseOperations { + + private MaxComputeDatabaseOperations operations; + private DataSource mockDataSource; + private Connection mockConnection; + private ResultSet mockResultSet; + + @BeforeEach + void setUp() throws SQLException { + operations = new MaxComputeDatabaseOperations(); + mockDataSource = mock(DataSource.class); + mockConnection = mock(Connection.class); + mockResultSet = mock(ResultSet.class); + + Map<String, String> conf = new HashMap<>(); + operations.initialize(mockDataSource, new MaxComputeExceptionConverter(), conf); + } + + @Test + void testCreate() throws SQLException { + when(mockDataSource.getConnection()).thenReturn(mockConnection); + java.sql.Statement mockStatement = mock(java.sql.Statement.class); + when(mockConnection.createStatement()).thenReturn(mockStatement); + when(mockStatement.executeUpdate("CREATE SCHEMA test_schema")).thenReturn(0); + + // Should not throw exception + Map<String, String> properties = new HashMap<>(); + operations.create("test_schema", null, properties); + } + + @Test + void testDelete() throws SQLException { + when(mockDataSource.getConnection()).thenReturn(mockConnection); + java.sql.Statement mockStatement = mock(java.sql.Statement.class); + when(mockConnection.createStatement()).thenReturn(mockStatement); + when(mockStatement.executeUpdate("DROP SCHEMA test_schema")).thenReturn(0); + + // Should not throw exception + boolean result = operations.delete("test_schema", false); + assertTrue(result); + } + + @Test + void testGenerateCreateDatabaseSql() { + Map<String, String> properties = new HashMap<>(); + String sql = operations.generateCreateDatabaseSql("test_schema", "comment", properties); + assertEquals("CREATE SCHEMA test_schema", sql); + } + + @Test + void testGenerateCreateDatabaseSqlWithPropertiesThrowsException() { + Map<String, String> properties = new HashMap<>(); + properties.put("some_property", "value"); + assertThrows( + UnsupportedOperationException.class, + () -> operations.generateCreateDatabaseSql("test_schema", "comment", properties)); + } + + @Test + void testGenerateDropDatabaseSql() throws SQLException { + // Mock for cascade check - empty schema + when(mockDataSource.getConnection()).thenReturn(mockConnection); + + String sql = operations.generateDropDatabaseSql("test_schema", false); + assertEquals("DROP SCHEMA test_schema", sql); + } + + @Test + void testSupportSchemaComment() { + assertFalse(operations.supportSchemaComment()); + } + + @Test + void testCreateSysDatabaseNameSet() { + assertTrue(operations.createSysDatabaseNameSet().contains("information_schema")); + assertEquals(1, operations.createSysDatabaseNameSet().size()); + } + + @Test + void testIsSystemDatabase() { + assertTrue(operations.isSystemDatabase("information_schema")); + assertTrue(operations.isSystemDatabase("INFORMATION_SCHEMA")); + assertFalse(operations.isSystemDatabase("my_project")); + assertFalse(operations.isSystemDatabase(null)); + } + + @Test + void testListDatabases() throws SQLException { + java.sql.Statement mockStatement = mock(java.sql.Statement.class); + when(mockDataSource.getConnection()).thenReturn(mockConnection); + when(mockConnection.createStatement()).thenReturn(mockStatement); + when(mockStatement.executeQuery("SHOW SCHEMAS")).thenReturn(mockResultSet); + // Simulate space-separated format: "default information_schema test_schema" + when(mockResultSet.next()).thenReturn(true, false); + when(mockResultSet.getString(1)).thenReturn("default information_schema test_schema"); + + List<String> databases = operations.listDatabases(); + + assertEquals(2, databases.size()); + assertTrue(databases.contains("default")); + assertTrue(databases.contains("test_schema")); + assertFalse(databases.contains("information_schema")); + } + + @Test + void testListDatabasesMultipleRows() throws SQLException { + java.sql.Statement mockStatement = mock(java.sql.Statement.class); + when(mockDataSource.getConnection()).thenReturn(mockConnection); + when(mockConnection.createStatement()).thenReturn(mockStatement); + when(mockStatement.executeQuery("SHOW SCHEMAS")).thenReturn(mockResultSet); + // Simulate one schema per row format + when(mockResultSet.next()).thenReturn(true, true, true, false); + when(mockResultSet.getString(1)).thenReturn("default", "information_schema", "test_schema"); + + List<String> databases = operations.listDatabases(); + + assertEquals(2, databases.size()); + assertTrue(databases.contains("default")); + assertTrue(databases.contains("test_schema")); + assertFalse(databases.contains("information_schema")); + } + + @Test + void testLoad() throws SQLException { + java.sql.Statement mockStatement = mock(java.sql.Statement.class); + when(mockDataSource.getConnection()).thenReturn(mockConnection); + when(mockConnection.createStatement()).thenReturn(mockStatement); + when(mockStatement.executeQuery("SHOW SCHEMAS")).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(true, false); + when(mockResultSet.getString(1)).thenReturn("test_schema"); + + JdbcSchema schema = operations.load("test_schema"); + + assertNotNull(schema); + assertEquals("test_schema", schema.name()); + assertNotNull(schema.properties()); + assertTrue(schema.properties().isEmpty()); + } + + @Test + void testLoadThrowsNoSuchSchemaException() throws SQLException { + java.sql.Statement mockStatement = mock(java.sql.Statement.class); + when(mockDataSource.getConnection()).thenReturn(mockConnection); + when(mockConnection.createStatement()).thenReturn(mockStatement); + when(mockStatement.executeQuery("SHOW SCHEMAS")).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + assertThrows(NoSuchSchemaException.class, () -> operations.load("non_existent_schema")); + } +}
