You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by ad...@apache.org on 2026/04/30 20:07:36 UTC
(polaris) branch main updated: Reject invalid entity names in the REST layer (#4282)
This is an automated email from the ASF dual-hosted git repository.
adutra pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push:
new b18c048add Reject invalid entity names in the REST layer (#4282)
b18c048add is described below
commit b18c048add63c3f0b29cbd17e23d259d9fbb5788
Author: Alexandre Dutra <ad...@apache.org>
AuthorDate: Thu Apr 30 22:07:32 2026 +0200
Reject invalid entity names in the REST layer (#4282)
Disallow slash, empty, and leading/trailing whitespace in names submitted to the create-namespace, create/register/rename-table, create/rename-view, and create-generic-table endpoints.
Policies already enforce a stricter pattern via Open-API generated bean validation, so they are left untouched.
---
CHANGELOG.md | 1 +
.../it/test/PolarisRestCatalogIntegrationBase.java | 179 +++++++++++++++++++++
.../generic/GenericTableCatalogAdapter.java | 11 +-
.../catalog/iceberg/IcebergCatalogAdapter.java | 7 +
.../catalog/validation/EntityNameValidator.java | 63 ++++++++
.../iceberg/AbstractIcebergCatalogTest.java | 5 +
.../validation/EntityNameValidatorTest.java | 138 ++++++++++++++++
7 files changed, 400 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0b539f42c..90228f4347 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
### Breaking changes
- The ExternalCatalogFactory interface has been renamed to FederatedCatalogFactory. Its createCatalog() and createGenericCatalog() method signatures have been extended to include a `catalogProperties` parameter of type `Map<String, String>` for passing through proxy and timeout settings to federated catalog HTTP clients.
- The `ConnectionCredentials.of()` method now throws an exception when more than one expiration timestamp property is present in the credentials map. Only a single expiration timestamp is allowed per credentials bundle.
+- Entity names (namespaces, tables, views, generic tables) submitted to the REST layer are now rejected with HTTP 400 if they are empty, contain a `/`, or have leading/trailing whitespace. Clients that were previously able to create such entities must rename them before upgrading.
### New Features
- Added `envFrom` support in Helm chart.
diff --git a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
index febc444c49..e12740a6fe 100644
--- a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
+++ b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
@@ -121,6 +121,8 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
/**
* Import the full core Iceberg catalog tests by hitting the REST service via the RESTCatalog
@@ -434,6 +436,11 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
return true;
}
+ @Override
+ protected boolean supportsNamesWithSlashes() {
+ return false;
+ }
+
@Override
protected boolean overridesRequestedLocation() {
return true;
@@ -2412,4 +2419,176 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
assertThat(nsResponse.namespaces()).hasSize(5);
assertThat(nsResponse.nextPageToken()).isNull();
}
+
+ @ParameterizedTest
+ @ValueSource(strings = {"bad/name", " leading", "trailing "})
+ public void testCreateNamespaceRejectsInvalidName(String badName) {
+ try (Response res =
+ catalogApi
+ .request("v1/{cat}/namespaces", Map.of("cat", currentCatalogName))
+ .post(Entity.json(Map.of("namespace", List.of(badName))))) {
+ assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ assertThat(res.readEntity(String.class)).contains("Entity name");
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"bad/name", " leading", "trailing "})
+ public void testCreateTableRejectsInvalidName(String badName) {
+ Namespace ns = Namespace.of("ns_create_table_bad");
+ restCatalog.createNamespace(ns);
+ try {
+ String nsEncoded = RESTUtil.encodeNamespace(ns);
+ try (Response res =
+ catalogApi
+ .request(
+ "v1/{cat}/namespaces/{ns}/tables",
+ Map.of("cat", currentCatalogName, "ns", nsEncoded))
+ .post(Entity.json(Map.of("name", badName, "schema", SCHEMA)))) {
+ assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ assertThat(res.readEntity(String.class)).contains("Entity name");
+ }
+ } finally {
+ catalogApi.purge(currentCatalogName, ns);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"bad/name", " leading", "trailing "})
+ public void testRegisterTableRejectsInvalidName(String badName) {
+ Namespace ns = Namespace.of("ns_register_bad");
+ restCatalog.createNamespace(ns);
+ try {
+ String nsEncoded = RESTUtil.encodeNamespace(ns);
+ try (Response res =
+ catalogApi
+ .request(
+ "v1/{cat}/namespaces/{ns}/register",
+ Map.of("cat", currentCatalogName, "ns", nsEncoded))
+ .post(
+ Entity.json(
+ Map.of("name", badName, "metadata-location", "file:/tmp/nowhere.json")))) {
+ assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ assertThat(res.readEntity(String.class)).contains("Entity name");
+ }
+ } finally {
+ catalogApi.purge(currentCatalogName, ns);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"bad/name", " leading", "trailing "})
+ public void testCreateViewRejectsInvalidName(String badName) {
+ Namespace ns = Namespace.of("ns_create_view_bad");
+ restCatalog.createNamespace(ns);
+ try {
+ String nsEncoded = RESTUtil.encodeNamespace(ns);
+ Map<String, Object> viewVersion =
+ Map.of(
+ "version-id",
+ 1,
+ "timestamp-ms",
+ 0,
+ "schema-id",
+ 0,
+ "summary",
+ Map.of(),
+ "representations",
+ List.of(Map.of("type", "sql", "sql", VIEW_QUERY, "dialect", "spark")),
+ "default-namespace",
+ List.of("ns_create_view_bad"));
+ try (Response res =
+ catalogApi
+ .request(
+ "v1/{cat}/namespaces/{ns}/views",
+ Map.of("cat", currentCatalogName, "ns", nsEncoded))
+ .post(
+ Entity.json(
+ Map.of("name", badName, "schema", SCHEMA, "view-version", viewVersion)))) {
+ assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ assertThat(res.readEntity(String.class)).contains("Entity name");
+ }
+ } finally {
+ catalogApi.purge(currentCatalogName, ns);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"bad/name", " leading", "trailing "})
+ public void testRenameTableRejectsInvalidDestinationName(String badName) {
+ Namespace ns = Namespace.of("ns_rename_table_bad");
+ restCatalog.createNamespace(ns);
+ try {
+ restCatalog.buildTable(TableIdentifier.of(ns, "src_tbl"), SCHEMA).create();
+ try (Response res =
+ catalogApi
+ .request("v1/{cat}/tables/rename", Map.of("cat", currentCatalogName))
+ .post(
+ Entity.json(
+ Map.of(
+ "source", Map.of("namespace", List.of(ns.level(0)), "name", "src_tbl"),
+ "destination",
+ Map.of("namespace", List.of(ns.level(0)), "name", badName))))) {
+ assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ assertThat(res.readEntity(String.class)).contains("Entity name");
+ }
+ } finally {
+ catalogApi.purge(currentCatalogName, ns);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"bad/name", " leading", "trailing "})
+ public void testRenameViewRejectsInvalidDestinationName(String badName) {
+ Namespace ns = Namespace.of("ns_rename_view_bad");
+ restCatalog.createNamespace(ns);
+ try {
+ restCatalog
+ .buildView(TableIdentifier.of(ns, "src_view"))
+ .withSchema(SCHEMA)
+ .withDefaultNamespace(ns)
+ .withQuery("spark", VIEW_QUERY)
+ .create();
+ try (Response res =
+ catalogApi
+ .request("v1/{cat}/views/rename", Map.of("cat", currentCatalogName))
+ .post(
+ Entity.json(
+ Map.of(
+ "source", Map.of("namespace", List.of(ns.level(0)), "name", "src_view"),
+ "destination",
+ Map.of("namespace", List.of(ns.level(0)), "name", badName))))) {
+ assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ assertThat(res.readEntity(String.class)).contains("Entity name");
+ }
+ } finally {
+ catalogApi.purge(currentCatalogName, ns);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"bad/name", " leading", "trailing "})
+ public void testCreateGenericTableRejectsInvalidName(String badName) {
+ Namespace ns = Namespace.of("ns_generic_bad");
+ restCatalog.createNamespace(ns);
+ try {
+ String nsEncoded = RESTUtil.encodeNamespace(ns);
+ try (Response res =
+ genericTableApi
+ .request(
+ "polaris/v1/{cat}/namespaces/{ns}/generic-tables",
+ Map.of("cat", currentCatalogName, "ns", nsEncoded))
+ .post(
+ Entity.json(
+ CreateGenericTableRequest.builder()
+ .setName(badName)
+ .setFormat("format")
+ .build()))) {
+ assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ assertThat(res.readEntity(String.class)).contains("Entity name");
+ }
+ } finally {
+ genericTableApi.purge(currentCatalogName, ns);
+ }
+ }
}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java
index 31188771c4..8e65652071 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java
@@ -32,6 +32,7 @@ import org.apache.polaris.core.rest.NamespaceUtils;
import org.apache.polaris.service.catalog.CatalogPrefixParser;
import org.apache.polaris.service.catalog.api.PolarisCatalogGenericTableApiService;
import org.apache.polaris.service.catalog.common.CatalogAdapter;
+import org.apache.polaris.service.catalog.validation.EntityNameValidator;
import org.apache.polaris.service.config.ReservedProperties;
import org.apache.polaris.service.types.CreateGenericTableRequest;
import org.apache.polaris.service.types.ListGenericTablesResponse;
@@ -74,13 +75,15 @@ public class GenericTableCatalogAdapter
String polarisGenericTableAccessDelegation,
RealmContext realmContext,
SecurityContext securityContext) {
+ TableIdentifier identifier =
+ TableIdentifier.of(
+ NamespaceUtils.splitNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR),
+ createGenericTableRequest.getName());
+ EntityNameValidator.validateIdentifier(identifier);
GenericTableCatalogHandler handler = newHandler(securityContext, prefix);
LoadGenericTableResponse response =
handler.createGenericTable(
- TableIdentifier.of(
- NamespaceUtils.splitNamespace(
- namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR),
- createGenericTableRequest.getName()),
+ identifier,
createGenericTableRequest.getFormat(),
createGenericTableRequest.getBaseLocation(),
createGenericTableRequest.getDoc(),
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
index 780a85054f..98b1e46265 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
@@ -55,6 +55,7 @@ import org.apache.polaris.service.catalog.CatalogPrefixParser;
import org.apache.polaris.service.catalog.api.IcebergRestCatalogApiService;
import org.apache.polaris.service.catalog.api.IcebergRestConfigurationApiService;
import org.apache.polaris.service.catalog.common.CatalogAdapter;
+import org.apache.polaris.service.catalog.validation.EntityNameValidator;
import org.apache.polaris.service.config.ReservedProperties;
import org.apache.polaris.service.http.IcebergHttpUtil;
import org.apache.polaris.service.http.IfNoneMatch;
@@ -131,6 +132,7 @@ public class IcebergCatalogAdapter
RealmContext realmContext,
SecurityContext securityContext) {
validateIcebergProperties(realmConfig, createNamespaceRequest.properties());
+ EntityNameValidator.validateNamespace(createNamespaceRequest.namespace());
return withCatalog(
securityContext,
prefix,
@@ -267,6 +269,7 @@ public class IcebergCatalogAdapter
parseAccessDelegationModes(accessDelegationMode);
Namespace ns =
NamespaceUtils.splitNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR);
+ EntityNameValidator.validateIdentifier(TableIdentifier.of(ns, createTableRequest.name()));
return withCatalog(
securityContext,
prefix,
@@ -412,6 +415,7 @@ public class IcebergCatalogAdapter
SecurityContext securityContext) {
Namespace ns =
NamespaceUtils.splitNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR);
+ EntityNameValidator.validateIdentifier(TableIdentifier.of(ns, registerTableRequest.name()));
return withCatalog(
securityContext,
prefix,
@@ -429,6 +433,7 @@ public class IcebergCatalogAdapter
RenameTableRequest renameTableRequest,
RealmContext realmContext,
SecurityContext securityContext) {
+ EntityNameValidator.validateIdentifier(renameTableRequest.destination());
return withCatalog(
securityContext,
prefix,
@@ -489,6 +494,7 @@ public class IcebergCatalogAdapter
reservedProperties.removeReservedProperties(createViewRequest.properties()));
Namespace ns =
NamespaceUtils.splitNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR);
+ EntityNameValidator.validateIdentifier(TableIdentifier.of(ns, revisedRequest.name()));
return withCatalog(
securityContext,
prefix,
@@ -587,6 +593,7 @@ public class IcebergCatalogAdapter
RenameTableRequest renameTableRequest,
RealmContext realmContext,
SecurityContext securityContext) {
+ EntityNameValidator.validateIdentifier(renameTableRequest.destination());
return withCatalog(
securityContext,
prefix,
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/validation/EntityNameValidator.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/validation/EntityNameValidator.java
new file mode 100644
index 0000000000..a86397327a
--- /dev/null
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/validation/EntityNameValidator.java
@@ -0,0 +1,63 @@
+/*
+ * 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.polaris.service.catalog.validation;
+
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+
+/**
+ * Validates entity names provided by clients at the REST layer (tables, views, namespaces, generic
+ * tables, ...). A valid name:
+ *
+ * <ul>
+ * <li>is not null or empty;
+ * <li>does not contain a forward slash ({@code /});
+ * <li>does not start or end with whitespace.
+ * </ul>
+ */
+public final class EntityNameValidator {
+
+ private EntityNameValidator() {}
+
+ /** Validates a single entity name (table, view, namespace level, ...). */
+ public static void validateName(String name) {
+ if (name == null || name.isEmpty()) {
+ throw new IllegalArgumentException("Entity name must not be empty");
+ }
+ if (name.indexOf('/') >= 0) {
+ throw new IllegalArgumentException("Entity name must not contain '/': " + name);
+ }
+ if (!name.equals(name.strip())) {
+ throw new IllegalArgumentException(
+ "Entity name must not have leading or trailing whitespace: " + name);
+ }
+ }
+
+ /** Validates each level of a namespace. */
+ public static void validateNamespace(Namespace namespace) {
+ for (String level : namespace.levels()) {
+ validateName(level);
+ }
+ }
+
+ public static void validateIdentifier(TableIdentifier identifier) {
+ validateNamespace(identifier.namespace());
+ validateName(identifier.name());
+ }
+}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
index 7bb7cb1ca1..ac99fc3de0 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
@@ -396,6 +396,11 @@ public abstract class AbstractIcebergCatalogTest extends CatalogTests<IcebergCat
+ identifier.name();
}
+ @Override
+ protected boolean supportsNamesWithSlashes() {
+ return false;
+ }
+
protected boolean supportsNotifications() {
return true;
}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/validation/EntityNameValidatorTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/validation/EntityNameValidatorTest.java
new file mode 100644
index 0000000000..85f8c6e60e
--- /dev/null
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/validation/EntityNameValidatorTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.polaris.service.catalog.validation;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class EntityNameValidatorTest {
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "simple",
+ "with space",
+ "under_score",
+ "hy-phen",
+ "with.dot",
+ "café",
+ "日本語",
+ "a+b",
+ "percent%20encoded",
+ })
+ void validateNameAccepts(String name) {
+ assertThatCode(() -> EntityNameValidator.validateName(name)).doesNotThrowAnyException();
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ void validateNameRejectsNullOrEmpty(String name) {
+ assertThatThrownBy(() -> EntityNameValidator.validateName(name))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("empty");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"/", "a/b", "/leading", "trailing/", "a/b/c"})
+ void validateNameRejectsSlash(String name) {
+ assertThatThrownBy(() -> EntityNameValidator.validateName(name))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("'/'");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {" lead", "trail ", " both ", "\ttab", "line\n", " ", "\t", "\n"})
+ void validateNameRejectsSurroundingWhitespace(String name) {
+ assertThatThrownBy(() -> EntityNameValidator.validateName(name))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("whitespace");
+ }
+
+ @Test
+ void validateNamespaceAcceptsEmpty() {
+ assertThatCode(() -> EntityNameValidator.validateNamespace(Namespace.empty()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void validateNamespaceAcceptsMultiLevel() {
+ assertThatCode(
+ () -> EntityNameValidator.validateNamespace(Namespace.of("ns1", "ns2", "with space")))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void validateNamespaceRejectsLevelWithSlash() {
+ assertThatThrownBy(
+ () -> EntityNameValidator.validateNamespace(Namespace.of("ns1", "bad/level")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("'/'");
+ }
+
+ @Test
+ void validateNamespaceRejectsEmptyLevel() {
+ assertThatThrownBy(() -> EntityNameValidator.validateNamespace(Namespace.of("ns1", "")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("empty");
+ }
+
+ @Test
+ void validateNamespaceRejectsLevelWithSurroundingWhitespace() {
+ assertThatThrownBy(() -> EntityNameValidator.validateNamespace(Namespace.of("ns1", " bad ")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("whitespace");
+ }
+
+ @Test
+ void validateIdentifierAccepts() {
+ assertThatCode(
+ () ->
+ EntityNameValidator.validateIdentifier(
+ TableIdentifier.of(Namespace.of("ns1", "ns2"), "table")))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void validateIdentifierRejectsBadNamespaceLevel() {
+ assertThatThrownBy(
+ () ->
+ EntityNameValidator.validateIdentifier(
+ TableIdentifier.of(Namespace.of("ns1", "bad/level"), "table")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("'/'");
+ }
+
+ @Test
+ void validateIdentifierRejectsBadName() {
+ assertThatThrownBy(
+ () ->
+ EntityNameValidator.validateIdentifier(
+ TableIdentifier.of(Namespace.of("ns1"), " bad ")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("whitespace");
+ }
+}