This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch branch-domain-dev
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-domain-dev by this push:
new db66a5d63b [#13884] feat(core): Add the domain manager (#14522)
db66a5d63b is described below
commit db66a5d63bd831c365e596c5f0b4f91cc18817d6
Author: Jerry Shao <[email protected]>
AuthorDate: Tue Nov 3 20:48:52 2026 +0800
[#13884] feat(core): Add the domain manager (#14522)
### What changes were proposed in this pull request?
Add `DomainDispatcher` and `DomainManager` for creating, listing,
loading, altering and deleting domains; managing sub-domains; and
publishing or unpublishing domains.
Hierarchy and status checks run through the entity store against the
locked domain state. Register the manager in `GravitinoEnv` and support
DOMAIN existence checks in `MetadataObjectUtil`.
### Why are the changes needed?
Provide the server-side domain operations for #13884 while keeping the
hierarchy one level deep and published sub-domains under published
parents.
Part of #13884. Backing-tag creation and binding, authorization, hooks,
events and draft visibility filtering follow in separate PRs. Creation
paths include TODOs for the backing-tag work.
### Does this PR introduce _any_ user-facing change?
No REST or client API changes. This adds internal server operations.
### How was this patch tested?
- H2-backed manager tests cover domain operations, hierarchy and status
rules, moves and no-op cases.
- Mock-store tests cover exception mapping and the checks passed to
locked store operations.
- `./gradlew compileJava compileTestJava spotlessCheck -PskipITs`
- `./gradlew :core:javadoc` (run separately)
- Related tests through `:core:coreUnitTest` and `:core:coreH2Test`.
MySQL and PostgreSQL tests run in CI.
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../java/org/apache/gravitino/GravitinoEnv.java | 28 ++
.../apache/gravitino/domain/DomainDispatcher.java | 241 ++++++++++
.../org/apache/gravitino/domain/DomainManager.java | 517 ++++++++++++++++++++
.../apache/gravitino/utils/MetadataObjectUtil.java | 10 +
.../TestGravitinoEnvMetadataComponents.java | 6 +
.../apache/gravitino/domain/TestDomainManager.java | 521 +++++++++++++++++++++
.../domain/TestDomainManagerWithMockStore.java | 520 ++++++++++++++++++++
7 files changed, 1843 insertions(+)
diff --git a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
index 3d17fb2b3e..bf2d0c04bf 100644
--- a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
+++ b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
@@ -60,6 +60,8 @@ import org.apache.gravitino.catalog.ViewDispatcher;
import org.apache.gravitino.catalog.ViewNormalizeDispatcher;
import org.apache.gravitino.catalog.ViewOperationDispatcher;
import org.apache.gravitino.credential.CredentialOperationDispatcher;
+import org.apache.gravitino.domain.DomainDispatcher;
+import org.apache.gravitino.domain.DomainManager;
import org.apache.gravitino.encryption.kms.KmsClientRegistry;
import org.apache.gravitino.hook.AccessControlHookDispatcher;
import org.apache.gravitino.hook.CatalogHookDispatcher;
@@ -185,6 +187,9 @@ public class GravitinoEnv {
private PolicyDispatcher policyDispatcher;
private PolicyDispatcher internalPolicyDispatcher;
+ private DomainDispatcher domainDispatcher;
+ private DomainDispatcher internalDomainDispatcher;
+
private AccessControlDispatcher accessControlDispatcher;
private AccessControlDispatcher internalAccessControlDispatcher;
@@ -701,6 +706,25 @@ public class GravitinoEnv {
return internalPolicyDispatcher;
}
+ /**
+ * Get the DomainDispatcher associated with the Gravitino environment.
+ *
+ * @return The DomainDispatcher instance.
+ */
+ public DomainDispatcher domainDispatcher() {
+ return domainDispatcher;
+ }
+
+ /**
+ * Get the internal DomainDispatcher associated with the Gravitino
environment. The internal
+ * dispatcher is the domain manager itself.
+ *
+ * @return The internal DomainDispatcher instance.
+ */
+ public DomainDispatcher internalDomainDispatcher() {
+ return internalDomainDispatcher;
+ }
+
/**
* Get the Owner dispatcher associated with the Gravitino environment.
*
@@ -935,6 +959,7 @@ public class GravitinoEnv {
this.internalTagDispatcher = new TagManager(idGenerator, entityStore);
this.internalPolicyDispatcher = new PolicyManager(idGenerator,
entityStore);
+ this.internalDomainDispatcher = new DomainManager(idGenerator,
entityStore);
return new MetadataOperations(
filesetOperationDispatcher,
@@ -1185,6 +1210,9 @@ public class GravitinoEnv {
PolicyHookDispatcher policyHookDispatcher = new
PolicyHookDispatcher(internalPolicyDispatcher);
this.policyDispatcher = new PolicyEventDispatcher(eventBus,
policyHookDispatcher);
+
+ // Domain hooks and events are added with domain authorization and
auditing.
+ this.domainDispatcher = internalDomainDispatcher;
}
private static final class MetadataOperations {
diff --git
a/core/src/main/java/org/apache/gravitino/domain/DomainDispatcher.java
b/core/src/main/java/org/apache/gravitino/domain/DomainDispatcher.java
new file mode 100644
index 0000000000..3e75233f96
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/domain/DomainDispatcher.java
@@ -0,0 +1,241 @@
+/*
+ * 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.domain;
+
+import java.util.Arrays;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.gravitino.annotation.Evolving;
+import org.apache.gravitino.exceptions.DomainAlreadyExistsException;
+import org.apache.gravitino.exceptions.NoSuchDomainException;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NonEmptyDomainException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.apache.gravitino.meta.DomainEntity;
+
+/**
+ * The domain operations of the server. Domains are one level deep: a domain
is either top-level or
+ * a sub-domain of one top-level domain. Creating a sub-domain, attaching a
domain and moving it
+ * check that:
+ *
+ * <ul>
+ * <li>a domain is not its own parent;
+ * <li>the parent is top-level, so a sub-domain cannot have sub-domains;
+ * <li>the attached domain has no sub-domains, so it does not bring a second
level with it;
+ * <li>parent and child are in the same metalake;
+ * <li>a domain has at most one parent, so attaching a sub-domain moves it;
+ * <li>a published domain is attached only under a published parent.
+ * </ul>
+ *
+ * <p>The first three keep the hierarchy one level deep, so it cannot have a
cycle.
+ *
+ * <p>A new domain is a draft. A sub-domain can be published only under a
published parent, and a
+ * domain can be unpublished only when none of its sub-domains is published.
+ *
+ * <p>The rules are checked against the domains as they are under the row
locks of the change, so a
+ * concurrent change cannot break them. A rule that does not hold fails the
operation with an {@link
+ * IllegalArgumentException}.
+ */
+@Evolving
+public interface DomainDispatcher {
+
+ /**
+ * Lists the names of the domains in a metalake, including sub-domains.
+ *
+ * @param metalake The name of the metalake.
+ * @return The names of the domains.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ */
+ default String[] listDomains(String metalake) throws NoSuchMetalakeException
{
+ return
Arrays.stream(listDomainInfos(metalake)).map(DomainEntity::name).toArray(String[]::new);
+ }
+
+ /**
+ * Lists the domains in a metalake, including sub-domains.
+ *
+ * @param metalake The name of the metalake.
+ * @return The domains.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ */
+ DomainEntity[] listDomainInfos(String metalake) throws
NoSuchMetalakeException;
+
+ /**
+ * Gets a domain.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @return The domain.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NoSuchDomainException If the domain does not exist.
+ */
+ DomainEntity getDomain(String metalake, String name) throws
NoSuchDomainException;
+
+ // TODO: The next PR of #13884 adds the explicit backing tag to createDomain
and
+ // createSubdomain, which every create request must name, and adds
updateBackingTag.
+
+ /**
+ * Creates a top-level domain in draft status.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @param comment The comment of the domain, or null.
+ * @param properties The properties of the domain, or null.
+ * @return The created domain.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws DomainAlreadyExistsException If a domain with the same name
exists.
+ */
+ DomainEntity createDomain(
+ String metalake,
+ String name,
+ @Nullable String comment,
+ @Nullable Map<String, String> properties)
+ throws DomainAlreadyExistsException;
+
+ /**
+ * Creates a sub-domain in draft status under a top-level domain.
+ *
+ * <p>If the metalake is deleted after the initial existence check, creation
reports the parent
+ * domain as not found.
+ *
+ * @param metalake The name of the metalake.
+ * @param parent The name of the parent domain.
+ * @param name The name of the sub-domain.
+ * @param comment The comment of the sub-domain, or null.
+ * @param properties The properties of the sub-domain, or null.
+ * @return The created sub-domain.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NoSuchDomainException If the parent does not exist.
+ * @throws DomainAlreadyExistsException If a domain with the same name
exists.
+ * @throws IllegalArgumentException If the parent is the sub-domain itself
or a sub-domain.
+ */
+ DomainEntity createSubdomain(
+ String metalake,
+ String parent,
+ String name,
+ @Nullable String comment,
+ @Nullable Map<String, String> properties)
+ throws NoSuchDomainException, DomainAlreadyExistsException,
IllegalArgumentException;
+
+ /**
+ * Alters a domain. Its status and parent are not changed by this method.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @param changes The changes to apply.
+ * @return The altered domain.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NoSuchDomainException If the domain does not exist.
+ * @throws DomainAlreadyExistsException If the domain is renamed to a name
in use.
+ * @throws IllegalArgumentException If a change cannot be applied.
+ */
+ DomainEntity alterDomain(String metalake, String name, DomainChange...
changes)
+ throws NoSuchDomainException, DomainAlreadyExistsException,
IllegalArgumentException;
+
+ /**
+ * Deletes a domain, with its parent edge.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @return True if the domain is deleted, false if it does not exist.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NonEmptyDomainException If the domain has sub-domains.
+ */
+ boolean deleteDomain(String metalake, String name) throws
NonEmptyDomainException;
+
+ /**
+ * Publishes a domain. Publishing a published domain changes nothing.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @return The published domain.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NoSuchDomainException If the domain does not exist.
+ * @throws IllegalArgumentException If the domain is a sub-domain of a draft
parent.
+ * @throws OptimisticLockException If the parent keeps changing while the
status is changed.
+ */
+ DomainEntity publishDomain(String metalake, String name)
+ throws NoSuchDomainException, IllegalArgumentException;
+
+ /**
+ * Unpublishes a domain. Unpublishing a draft domain changes nothing.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @return The unpublished domain.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NoSuchDomainException If the domain does not exist.
+ * @throws IllegalArgumentException If the domain has published sub-domains.
+ * @throws OptimisticLockException If the parent keeps changing while the
status is changed.
+ */
+ DomainEntity unpublishDomain(String metalake, String name)
+ throws NoSuchDomainException, IllegalArgumentException;
+
+ /**
+ * Lists the names of the sub-domains of a domain.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @return The names of the sub-domains.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NoSuchDomainException If the domain does not exist.
+ */
+ default String[] listSubdomains(String metalake, String name) throws
NoSuchDomainException {
+ return Arrays.stream(listSubdomainInfos(metalake, name))
+ .map(DomainEntity::name)
+ .toArray(String[]::new);
+ }
+
+ /**
+ * Lists the sub-domains of a domain.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @return The sub-domains.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NoSuchDomainException If the domain does not exist.
+ */
+ DomainEntity[] listSubdomainInfos(String metalake, String name) throws
NoSuchDomainException;
+
+ /**
+ * Attaches a domain under a parent, or moves a sub-domain to another
parent. Attaching a domain
+ * to its current parent changes nothing.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @param parent The name of the new parent.
+ * @return The attached domain.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NoSuchDomainException If the domain or the parent does not exist.
+ * @throws IllegalArgumentException If the domain cannot be attached under
the parent.
+ */
+ DomainEntity attachDomain(String metalake, String name, String parent)
+ throws NoSuchDomainException, IllegalArgumentException;
+
+ /**
+ * Detaches a domain from its current parent, making it top-level. Detaching
a top-level domain
+ * changes nothing.
+ *
+ * @param metalake The name of the metalake.
+ * @param name The name of the domain.
+ * @return The detached domain.
+ * @throws NoSuchMetalakeException If the metalake does not exist.
+ * @throws NoSuchDomainException If the domain does not exist.
+ */
+ DomainEntity detachDomain(String metalake, String name) throws
NoSuchDomainException;
+}
diff --git a/core/src/main/java/org/apache/gravitino/domain/DomainManager.java
b/core/src/main/java/org/apache/gravitino/domain/DomainManager.java
new file mode 100644
index 0000000000..2fc4e0ef7e
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/domain/DomainManager.java
@@ -0,0 +1,517 @@
+/*
+ * 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.domain;
+
+import static org.apache.gravitino.metalake.MetalakeManager.checkMetalake;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.apache.gravitino.Audit;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.SupportsDomainOperations;
+import org.apache.gravitino.SupportsDomainOperations.LockedDomainState;
+import org.apache.gravitino.exceptions.DomainAlreadyExistsException;
+import org.apache.gravitino.exceptions.NoSuchDomainException;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NonEmptyDomainException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.DomainEntity;
+import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.gravitino.utils.RenameUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The domain manager. It keeps the domain hierarchy one level deep and the
status of each domain
+ * consistent with its parent and sub-domains (see {@link DomainDispatcher}).
+ *
+ * <p>The rules that involve other domains are checked by the entity store's
domain operations,
+ * against the domains as they are under the row locks of the change, so that
a concurrent change
+ * cannot break them. Name, comment and property changes involve no other
domain and use the store's
+ * update, as other entities do.
+ */
+public class DomainManager implements DomainDispatcher {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(DomainManager.class);
+
+ private final IdGenerator idGenerator;
+ private final EntityStore entityStore;
+
+ /**
+ * Creates a domain manager.
+ *
+ * @param idGenerator The ID generator of new domains.
+ * @param entityStore The entity store, which must support the domain
operations.
+ */
+ public DomainManager(IdGenerator idGenerator, EntityStore entityStore) {
+ this.idGenerator = idGenerator;
+ this.entityStore = entityStore;
+ }
+
+ @Override
+ public DomainEntity[] listDomainInfos(String metalake) {
+ checkMetalake(NameIdentifierUtil.ofMetalake(metalake), entityStore);
+ try {
+ return entityStore
+ .list(NamespaceUtil.ofDomain(metalake), DomainEntity.class,
Entity.EntityType.DOMAIN)
+ .toArray(new DomainEntity[0]);
+ } catch (IOException ioe) {
+ LOG.error("Failed to list domains under metalake {}", metalake, ioe);
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ @Override
+ public DomainEntity getDomain(String metalake, String name) throws
NoSuchDomainException {
+ checkMetalake(NameIdentifierUtil.ofMetalake(metalake), entityStore);
+ try {
+ return entityStore.get(
+ NameIdentifierUtil.ofDomain(metalake, name),
+ Entity.EntityType.DOMAIN,
+ DomainEntity.class);
+ } catch (NoSuchEntityException e) {
+ throw noSuchDomain(e, metalake, name);
+ } catch (IOException ioe) {
+ LOG.error("Failed to get domain {} under metalake {}", name, metalake,
ioe);
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ @Override
+ public DomainEntity createDomain(
+ String metalake,
+ String name,
+ @Nullable String comment,
+ @Nullable Map<String, String> properties)
+ throws DomainAlreadyExistsException {
+ return insertDomain(metalake, null, name, comment, properties);
+ }
+
+ @Override
+ public DomainEntity createSubdomain(
+ String metalake,
+ String parent,
+ String name,
+ @Nullable String comment,
+ @Nullable Map<String, String> properties)
+ throws NoSuchDomainException, DomainAlreadyExistsException,
IllegalArgumentException {
+ checkParentName(name, parent);
+ return insertDomain(metalake, parent, name, comment, properties);
+ }
+
+ @Override
+ public DomainEntity alterDomain(String metalake, String name,
DomainChange... changes)
+ throws NoSuchDomainException, DomainAlreadyExistsException,
IllegalArgumentException {
+ checkMetalake(NameIdentifierUtil.ofMetalake(metalake), entityStore);
+ try {
+ return entityStore.update(
+ NameIdentifierUtil.ofDomain(metalake, name),
+ DomainEntity.class,
+ Entity.EntityType.DOMAIN,
+ domain -> applyChanges(domain, changes));
+ } catch (NoSuchEntityException e) {
+ throw noSuchDomain(e, metalake, name);
+ } catch (EntityAlreadyExistsException e) {
+ String newName =
+ RenameUtils.getLastRenameName(
+ name,
+ changes,
+ DomainChange.RenameDomain.class,
+ DomainChange.RenameDomain::getNewName);
+ throw new DomainAlreadyExistsException(
+ e, "Domain %s under metalake %s already exists", newName, metalake);
+ } catch (OptimisticLockException ole) {
+ LOG.warn(
+ "Failed to alter domain {} under metalake {} because of a concurrent
operation",
+ name,
+ metalake,
+ ole);
+ throw ole;
+ } catch (IOException ioe) {
+ LOG.error("Failed to alter domain {} under metalake {}", name, metalake,
ioe);
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ @Override
+ public boolean deleteDomain(String metalake, String name) throws
NonEmptyDomainException {
+ checkMetalake(NameIdentifierUtil.ofMetalake(metalake), entityStore);
+ try {
+ // In one transaction, the store refuses a domain with sub-domains, and
removes the domain
+ // with its own parent edge, its owner and the privileges on it. Its
backing tag stays.
+ return entityStore.delete(
+ NameIdentifierUtil.ofDomain(metalake, name),
Entity.EntityType.DOMAIN);
+ } catch (NonEmptyEntityException e) {
+ throw new NonEmptyDomainException(
+ e, "Domain %s under metalake %s has sub-domains, remove them first",
name, metalake);
+ } catch (OptimisticLockException ole) {
+ LOG.warn(
+ "Failed to delete domain {} under metalake {} because of a
concurrent operation",
+ name,
+ metalake,
+ ole);
+ throw ole;
+ } catch (IOException ioe) {
+ LOG.error("Failed to delete domain {} under metalake {}", name,
metalake, ioe);
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ @Override
+ public DomainEntity publishDomain(String metalake, String name)
+ throws NoSuchDomainException, IllegalArgumentException {
+ return updateStatus(metalake, name, Domain.Status.PUBLISHED);
+ }
+
+ @Override
+ public DomainEntity unpublishDomain(String metalake, String name)
+ throws NoSuchDomainException, IllegalArgumentException {
+ return updateStatus(metalake, name, Domain.Status.DRAFT);
+ }
+
+ @Override
+ public DomainEntity[] listSubdomainInfos(String metalake, String name)
+ throws NoSuchDomainException {
+ checkMetalake(NameIdentifierUtil.ofMetalake(metalake), entityStore);
+ try {
+ return domainOperations()
+ .listSubDomains(NameIdentifierUtil.ofDomain(metalake, name))
+ .toArray(new DomainEntity[0]);
+ } catch (NoSuchEntityException e) {
+ throw noSuchDomain(e, metalake, name);
+ } catch (IOException ioe) {
+ LOG.error("Failed to list sub-domains of domain {} under metalake {}",
name, metalake, ioe);
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ @Override
+ public DomainEntity attachDomain(String metalake, String name, String parent)
+ throws NoSuchDomainException, IllegalArgumentException {
+ checkParentName(name, parent);
+ checkMetalake(NameIdentifierUtil.ofMetalake(metalake), entityStore);
+ try {
+ return domainOperations()
+ .setDomainParentAndGet(
+ NameIdentifierUtil.ofDomain(metalake, name),
+ NameIdentifierUtil.ofDomain(metalake, parent),
+ newAuditInfo(),
+ DomainManager::checkAttach);
+ } catch (NoSuchEntityException e) {
+ throw new NoSuchDomainException(
+ e,
+ "Domain %s or parent %s under metalake %s does not exist: %s",
+ name,
+ parent,
+ metalake,
+ e.getMessage());
+ } catch (IOException ioe) {
+ LOG.error("Failed to attach domain {} to {} under metalake {}", name,
parent, metalake, ioe);
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ @Override
+ public DomainEntity detachDomain(String metalake, String name) throws
NoSuchDomainException {
+ checkMetalake(NameIdentifierUtil.ofMetalake(metalake), entityStore);
+ try {
+ // Detaching breaks no rule of the hierarchy or the status, so nothing
is checked.
+ return domainOperations()
+ .removeDomainParentAndGet(NameIdentifierUtil.ofDomain(metalake,
name), state -> {});
+ } catch (NoSuchEntityException e) {
+ throw noSuchDomain(e, metalake, name);
+ } catch (IOException ioe) {
+ LOG.error("Failed to detach domain {} under metalake {}", name,
metalake, ioe);
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ /**
+ * Checks a new sub-domain against its locked parent: the parent must be
top-level, so that a
+ * sub-domain has no sub-domains. A new domain has no sub-domains of its own
and is a draft, so
+ * the other rules of an attach hold for it.
+ *
+ * @param state The locked state of the insert.
+ */
+ @VisibleForTesting
+ static void checkNewDomain(LockedDomainState state) {
+ state.parent().ifPresent(parent -> checkParentIsTopLevel(state.domain(),
parent));
+ }
+
+ /**
+ * Checks an attach or a move against the locked domains. Attaching a domain
to its current parent
+ * changes nothing and is not checked.
+ *
+ * @param state The locked state of the attach.
+ */
+ @VisibleForTesting
+ static void checkAttach(LockedDomainState state) {
+ DomainEntity domain = state.domain();
+ DomainEntity parent =
+ state.parent().orElseThrow(() -> new IllegalStateException("An attach
needs a parent"));
+ // Two different names can find the same row, such as names that differ
only in trailing
+ // spaces on MySQL. The store refuses that before it locks; this checks
the locked rows again,
+ // so the rule does not depend on one store implementation.
+ Preconditions.checkArgument(
+ !Objects.equals(domain.id(), parent.id()),
+ "Domain %s cannot be its own parent",
+ domain.name());
+ if (domain.parent().filter(parent.name()::equals).isPresent()) {
+ return;
+ }
+
+ // A sub-domain cannot be a parent, or the hierarchy would be two levels
deep.
+ checkParentIsTopLevel(domain, parent);
+ // A domain with sub-domains cannot become a sub-domain, for the same
reason.
+ Preconditions.checkArgument(
+ state.subDomains().isEmpty(),
+ "Domain %s has sub-domains %s and cannot be attached to another
domain",
+ domain.name(),
+ names(state.subDomains()));
+ // A published sub-domain needs a published parent, as when it is
published.
+ Preconditions.checkArgument(
+ domain.status() != Domain.Status.PUBLISHED || parent.status() ==
Domain.Status.PUBLISHED,
+ "Published domain %s cannot be attached to draft domain %s, publish %s
first",
+ domain.name(),
+ parent.name(),
+ parent.name());
+ }
+
+ /**
+ * Checks a publish against the locked domains: a sub-domain needs a
published parent.
+ *
+ * @param state The locked state of the status change.
+ */
+ @VisibleForTesting
+ static void checkPublish(LockedDomainState state) {
+ if (state.domain().status() == Domain.Status.PUBLISHED) {
+ return;
+ }
+ state
+ .parent()
+ .ifPresent(
+ parent ->
+ Preconditions.checkArgument(
+ parent.status() == Domain.Status.PUBLISHED,
+ "Domain %s cannot be published while its parent %s is a
draft",
+ state.domain().name(),
+ parent.name()));
+ }
+
+ /**
+ * Checks an unpublish against the locked domains: no sub-domain may be
published.
+ *
+ * @param state The locked state of the status change.
+ */
+ @VisibleForTesting
+ static void checkUnpublish(LockedDomainState state) {
+ if (state.domain().status() == Domain.Status.DRAFT) {
+ return;
+ }
+ List<DomainEntity> publishedSubDomains =
+ state.subDomains().stream()
+ .filter(subDomain -> subDomain.status() == Domain.Status.PUBLISHED)
+ .collect(Collectors.toList());
+ Preconditions.checkArgument(
+ publishedSubDomains.isEmpty(),
+ "Domain %s cannot be unpublished while its sub-domains %s are
published",
+ state.domain().name(),
+ names(publishedSubDomains));
+ }
+
+ private DomainEntity insertDomain(
+ String metalake,
+ @Nullable String parent,
+ String name,
+ @Nullable String comment,
+ @Nullable Map<String, String> properties) {
+ checkMetalake(NameIdentifierUtil.ofMetalake(metalake), entityStore);
+ // TODO: The next PR of #13884 sets the backing tag here: it creates a new
tag or binds an
+ // existing (tag, value), in the same transaction as the domain, under the
binding rules.
+ DomainEntity domain =
+ DomainEntity.builder()
+ .withId(idGenerator.nextId())
+ .withName(name)
+ .withNamespace(NamespaceUtil.ofDomain(metalake))
+ .withComment(comment)
+ .withProperties(properties == null ? Collections.emptyMap() :
properties)
+ .withStatus(Domain.Status.DRAFT)
+ .withParent(parent)
+ .withAuditInfo(
+ AuditInfo.builder()
+
.withCreator(PrincipalUtils.getCurrentPrincipal().getName())
+ .withCreateTime(Instant.now())
+ .build())
+ .build();
+
+ try {
+ // Return the parent's name as stored, read under its lock, rather than
the requested one:
+ // on MySQL, a name with trailing spaces finds the same parent row.
+ String[] storedParent = new String[] {parent};
+ domainOperations()
+ .insertDomain(
+ domain,
+ state -> {
+ checkNewDomain(state);
+ state.parent().ifPresent(lockedParent -> storedParent[0] =
lockedParent.name());
+ });
+ return Objects.equals(storedParent[0], parent)
+ ? domain
+ : domain.toBuilder().withParent(storedParent[0]).build();
+ } catch (EntityAlreadyExistsException e) {
+ throw new DomainAlreadyExistsException(
+ e, "Domain %s under metalake %s already exists", name, metalake);
+ } catch (NoSuchEntityException e) {
+ if (parent == null) {
+ throw new NoSuchMetalakeException(e, "Metalake %s does not exist",
metalake);
+ }
+ // A missing metalake is reported by the pre-check. If it is deleted
concurrently after
+ // that check, the requested parent domain is also gone; report it as
not found.
+ throw new NoSuchDomainException(
+ e,
+ "Parent domain %s under metalake %s does not exist: %s",
+ parent,
+ metalake,
+ e.getMessage());
+ } catch (IOException ioe) {
+ LOG.error("Failed to create domain {} under metalake {}", name,
metalake, ioe);
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ private DomainEntity updateStatus(String metalake, String name,
Domain.Status status) {
+ checkMetalake(NameIdentifierUtil.ofMetalake(metalake), entityStore);
+ try {
+ return domainOperations()
+ .updateDomainStatusAndGet(
+ NameIdentifierUtil.ofDomain(metalake, name),
+ status,
+ newAuditInfo(),
+ status == Domain.Status.PUBLISHED
+ ? DomainManager::checkPublish
+ : DomainManager::checkUnpublish);
+ } catch (NoSuchEntityException e) {
+ throw noSuchDomain(e, metalake, name);
+ } catch (IOException ioe) {
+ LOG.error(
+ "Failed to change the status of domain {} under metalake {} to {}",
+ name,
+ metalake,
+ status,
+ ioe);
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ private SupportsDomainOperations domainOperations() {
+ return entityStore.domainOperations();
+ }
+
+ private static DomainEntity applyChanges(DomainEntity domain,
DomainChange... changes) {
+ String newName = domain.name();
+ String newComment = domain.comment();
+ Map<String, String> newProperties =
+ domain.properties() == null ? new HashMap<>() : new
HashMap<>(domain.properties());
+
+ for (DomainChange change : changes) {
+ if (change instanceof DomainChange.RenameDomain) {
+ newName = ((DomainChange.RenameDomain) change).getNewName();
+ } else if (change instanceof DomainChange.UpdateDomainComment) {
+ newComment = ((DomainChange.UpdateDomainComment)
change).getNewComment();
+ } else if (change instanceof DomainChange.SetProperty) {
+ DomainChange.SetProperty setProperty = (DomainChange.SetProperty)
change;
+ newProperties.put(setProperty.getProperty(), setProperty.getValue());
+ } else if (change instanceof DomainChange.RemoveProperty) {
+ newProperties.remove(((DomainChange.RemoveProperty)
change).getProperty());
+ } else {
+ throw new IllegalArgumentException("Unsupported domain change: " +
change);
+ }
+ }
+
+ Audit audit = domain.auditInfo();
+ return domain.toBuilder()
+ .withName(newName)
+ .withComment(newComment)
+ .withProperties(newProperties)
+ .withAuditInfo(
+ AuditInfo.builder()
+ .withCreator(audit.creator())
+ .withCreateTime(audit.createTime())
+
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
+ .withLastModifiedTime(Instant.now())
+ .build())
+ .build();
+ }
+
+ // The audit info of a change: a new parent edge takes its creator, a
changed row its modifier.
+ private static AuditInfo newAuditInfo() {
+ String user = PrincipalUtils.getCurrentPrincipal().getName();
+ Instant now = Instant.now();
+ return AuditInfo.builder()
+ .withCreator(user)
+ .withCreateTime(now)
+ .withLastModifier(user)
+ .withLastModifiedTime(now)
+ .build();
+ }
+
+ // A domain cannot be its own parent. Parent and child are always in the
same metalake, since
+ // both are looked up in the given one.
+ private static void checkParentName(String name, @Nullable String parent) {
+ Preconditions.checkArgument(parent != null, "The parent domain name must
not be null");
+ Preconditions.checkArgument(
+ !Objects.equals(name, parent), "Domain %s cannot be its own parent",
name);
+ }
+
+ private static void checkParentIsTopLevel(DomainEntity domain, DomainEntity
parent) {
+ Preconditions.checkArgument(
+ !parent.parent().isPresent(),
+ "Domain %s cannot be a sub-domain of %s, which is itself a sub-domain
of %s",
+ domain.name(),
+ parent.name(),
+ parent.parent().orElse(null));
+ }
+
+ private static List<String> names(List<DomainEntity> domains) {
+ return
domains.stream().map(DomainEntity::name).collect(Collectors.toList());
+ }
+
+ private static NoSuchDomainException noSuchDomain(
+ NoSuchEntityException e, String metalake, String name) {
+ return new NoSuchDomainException(
+ e, "Domain %s under metalake %s does not exist", name, metalake);
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java
b/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java
index 0413866c69..65968cc218 100644
--- a/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java
+++ b/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java
@@ -37,6 +37,7 @@ import org.apache.gravitino.MetadataObjects;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.authorization.AuthorizationUtils;
import org.apache.gravitino.exceptions.IllegalMetadataObjectException;
+import org.apache.gravitino.exceptions.NoSuchDomainException;
import org.apache.gravitino.exceptions.NoSuchJobException;
import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
@@ -350,6 +351,15 @@ public class MetadataObjectUtil {
}
break;
+ case DOMAIN:
+ NameIdentifierUtil.checkDomain(identifier);
+ try {
+ env.internalDomainDispatcher().getDomain(metalake,
object.fullName());
+ } catch (NoSuchDomainException e) {
+ throw exceptionToThrowSupplier.get();
+ }
+ break;
+
default:
throw new IllegalArgumentException(
String.format("Doesn't support the type %s", object.type()));
diff --git
a/core/src/test/java/org/apache/gravitino/TestGravitinoEnvMetadataComponents.java
b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvMetadataComponents.java
index 9d2e182905..9416976216 100644
---
a/core/src/test/java/org/apache/gravitino/TestGravitinoEnvMetadataComponents.java
+++
b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvMetadataComponents.java
@@ -56,6 +56,7 @@ import org.apache.gravitino.catalog.TopicNormalizeDispatcher;
import org.apache.gravitino.catalog.TopicOperationDispatcher;
import org.apache.gravitino.catalog.ViewNormalizeDispatcher;
import org.apache.gravitino.catalog.ViewOperationDispatcher;
+import org.apache.gravitino.domain.DomainManager;
import org.apache.gravitino.hook.FilesetHookDispatcher;
import org.apache.gravitino.hook.FunctionHookDispatcher;
import org.apache.gravitino.hook.ModelHookDispatcher;
@@ -156,6 +157,7 @@ class TestGravitinoEnvMetadataComponents {
assertNotNull(env.secretPropertyOperationDispatcher());
assertNotNull(env.internalTagDispatcher());
assertNotNull(env.internalPolicyDispatcher());
+ assertInstanceOf(DomainManager.class, env.internalDomainDispatcher());
assertInstanceOf(StatisticManager.class,
env.internalStatisticDispatcher());
assertNotNull(env.lockManager());
assertNotNull(env.metricsSystem());
@@ -175,6 +177,7 @@ class TestGravitinoEnvMetadataComponents {
assertNull(env.viewDispatcher());
assertNull(env.tagDispatcher());
assertNull(env.policyDispatcher());
+ assertNull(env.domainDispatcher());
assertNull(env.statisticDispatcher());
assertNull(env.internalAccessControlDispatcher());
assertNull(env.internalOwnerDispatcher());
@@ -238,6 +241,9 @@ class TestGravitinoEnvMetadataComponents {
env.initializeFullComponents(config);
+ assertInstanceOf(DomainManager.class, env.internalDomainDispatcher());
+ assertSame(env.internalDomainDispatcher(), env.domainDispatcher());
+
assertDispatcherChain(
env.filesetDispatcher(),
FilesetEventDispatcher.class,
diff --git
a/core/src/test/java/org/apache/gravitino/domain/TestDomainManager.java
b/core/src/test/java/org/apache/gravitino/domain/TestDomainManager.java
new file mode 100644
index 0000000000..7a0463ef82
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/domain/TestDomainManager.java
@@ -0,0 +1,521 @@
+/*
+ * 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.domain;
+
+import static
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
+import static
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_REPLAY_MAX_ENTRIES;
+import static
org.apache.gravitino.Configs.DEFAULT_ENTITY_CHANGE_LOG_REPLAY_WINDOW_SECS;
+import static org.apache.gravitino.Configs.DEFAULT_ENTITY_RELATIONAL_STORE;
+import static
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS;
+import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_BATCH_SIZE;
+import static
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS;
+import static
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_REPLAY_MAX_ENTRIES;
+import static
org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_REPLAY_WINDOW_SECS;
+import static org.apache.gravitino.Configs.ENTITY_CHANGE_LOG_RETENTION_SECS;
+import static
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER;
+import static
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS;
+import static
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_MAX_IDLE_CONNECTIONS;
+import static org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL;
+import static
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS;
+import static org.apache.gravitino.Configs.ENTITY_RELATIONAL_STORE;
+import static org.apache.gravitino.Configs.ENTITY_STORE;
+import static org.apache.gravitino.Configs.RELATIONAL_ENTITY_STORE;
+import static org.apache.gravitino.Configs.STORE_DELETE_AFTER_TIME;
+import static org.apache.gravitino.Configs.STORE_TRANSACTION_MAX_SKEW_TIME;
+import static org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
+import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
+import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
+import static org.apache.gravitino.Configs.VERSION_RETENTION_COUNT;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.io.File;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.EntityStoreFactory;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.SupportsDomainOperations.LockedDomainState;
+import org.apache.gravitino.exceptions.DomainAlreadyExistsException;
+import org.apache.gravitino.exceptions.NoSuchDomainException;
+import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NonEmptyDomainException;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.meta.DomainEntity;
+import org.apache.gravitino.meta.SchemaVersion;
+import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class TestDomainManager {
+
+ private static final String METALAKE = "metalake_for_domain_test";
+ private static final String JDBC_STORE_PATH =
+ "/tmp/gravitino_jdbc_entityStore_" +
UUID.randomUUID().toString().replace("-", "");
+ private static final String DB_DIR = JDBC_STORE_PATH + "/testdb";
+
+ private static EntityStore entityStore;
+ private static DomainManager domainManager;
+
+ @BeforeAll
+ public static void setUp() throws IllegalAccessException, IOException {
+ IdGenerator idGenerator = new RandomIdGenerator();
+ Config config = mockConfig();
+ entityStore = EntityStoreFactory.createEntityStore(config);
+ entityStore.initialize(config);
+ domainManager = new DomainManager(idGenerator, entityStore);
+
+ FieldUtils.writeField(
+ GravitinoEnv.getInstance(), "internalDomainDispatcher", domainManager,
true);
+
+ BaseMetalake metalake =
+ BaseMetalake.builder()
+ .withId(idGenerator.nextId())
+ .withName(METALAKE)
+ .withVersion(SchemaVersion.V_0_1)
+ .withComment("Test metalake")
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .build();
+ entityStore.put(metalake, false /* overwritten */);
+ }
+
+ @AfterAll
+ public static void tearDown() throws IOException, IllegalAccessException {
+ FieldUtils.writeField(GravitinoEnv.getInstance(),
"internalDomainDispatcher", null, true);
+ if (entityStore != null) {
+ entityStore.close();
+ entityStore = null;
+ }
+ FileUtils.deleteDirectory(new File(JDBC_STORE_PATH));
+ }
+
+ @AfterEach
+ public void cleanUp() {
+ DomainEntity[] domains = domainManager.listDomainInfos(METALAKE);
+ // Sub-domains first, since a domain with sub-domains cannot be deleted.
+ Arrays.stream(domains)
+ .filter(domain -> domain.parent().isPresent())
+ .forEach(domain -> domainManager.deleteDomain(METALAKE,
domain.name()));
+ Arrays.stream(domains)
+ .filter(domain -> !domain.parent().isPresent())
+ .forEach(domain -> domainManager.deleteDomain(METALAKE,
domain.name()));
+ }
+
+ @Test
+ public void testCreateGetAndListDomains() {
+ Map<String, String> properties = ImmutableMap.of("k1", "v1");
+ DomainEntity finance = domainManager.createDomain(METALAKE, "Finance",
"comment", properties);
+ Assertions.assertEquals("Finance", finance.name());
+ Assertions.assertEquals(NamespaceUtil.ofDomain(METALAKE),
finance.namespace());
+ Assertions.assertEquals("comment", finance.comment());
+ Assertions.assertEquals(properties, finance.properties());
+ Assertions.assertEquals(Domain.Status.DRAFT, finance.status());
+ Assertions.assertFalse(finance.parent().isPresent());
+ Assertions.assertFalse(finance.backingTag().isPresent());
+ Assertions.assertNotNull(finance.auditInfo().creator());
+ Assertions.assertEquals(finance, domainManager.getDomain(METALAKE,
"Finance"));
+
+ DomainEntity markets = domainManager.createDomain(METALAKE, "Markets",
null, null);
+ Assertions.assertNull(markets.comment());
+ Assertions.assertTrue(markets.properties().isEmpty());
+
+ Assertions.assertEquals(
+ ImmutableSet.of("Finance", "Markets"),
+ ImmutableSet.copyOf(domainManager.listDomains(METALAKE)));
+ Assertions.assertEquals(
+ ImmutableSet.of(finance, markets),
+ ImmutableSet.copyOf(domainManager.listDomainInfos(METALAKE)));
+
+ Assertions.assertThrows(
+ DomainAlreadyExistsException.class,
+ () -> domainManager.createDomain(METALAKE, "Finance", null, null));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () -> domainManager.getDomain(METALAKE,
"missing"));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class,
+ () -> domainManager.createDomain("missing_metalake", "Finance", null,
null));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class, () ->
domainManager.listDomains("missing_metalake"));
+ }
+
+ @Test
+ public void testCreateSubdomain() {
+ domainManager.createDomain(METALAKE, "Finance", null, null);
+ DomainEntity trading =
+ domainManager.createSubdomain(METALAKE, "Finance", "Trading",
"comment", null);
+ Assertions.assertEquals("Finance", trading.parent().get());
+ Assertions.assertEquals(Domain.Status.DRAFT, trading.status());
+ Assertions.assertEquals(trading, domainManager.getDomain(METALAKE,
"Trading"));
+ Assertions.assertArrayEquals(
+ new String[] {"Trading"}, domainManager.listSubdomains(METALAKE,
"Finance"));
+ Assertions.assertArrayEquals(
+ new DomainEntity[] {trading},
domainManager.listSubdomainInfos(METALAKE, "Finance"));
+ Assertions.assertEquals(0, domainManager.listSubdomains(METALAKE,
"Trading").length);
+
+ // A sub-domain cannot have sub-domains.
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.createSubdomain(METALAKE, "Trading", "Desk",
null, null));
+ Assertions.assertTrue(e.getMessage().contains("itself a sub-domain"),
e.getMessage());
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () -> domainManager.getDomain(METALAKE,
"Desk"));
+
+ Assertions.assertThrows(
+ NoSuchDomainException.class,
+ () -> domainManager.createSubdomain(METALAKE, "missing", "Desk", null,
null));
+ // A domain cannot be its own parent.
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.createSubdomain(METALAKE, "Finance", "Finance",
null, null));
+ Assertions.assertThrows(
+ DomainAlreadyExistsException.class,
+ () -> domainManager.createSubdomain(METALAKE, "Finance", "Trading",
null, null));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () ->
domainManager.listSubdomains(METALAKE, "missing"));
+ }
+
+ @Test
+ public void testAlterDomain() {
+ domainManager.createDomain(METALAKE, "Finance", "comment",
ImmutableMap.of("k1", "v1"));
+ domainManager.createSubdomain(METALAKE, "Finance", "Trading", null, null);
+ domainManager.createDomain(METALAKE, "Markets", null, null);
+
+ DomainEntity altered =
+ domainManager.alterDomain(
+ METALAKE,
+ "Finance",
+ DomainChange.rename("Finance2"),
+ DomainChange.updateComment("new comment"),
+ DomainChange.setProperty("k2", "v2"),
+ DomainChange.removeProperty("k1"));
+ Assertions.assertEquals("Finance2", altered.name());
+ Assertions.assertEquals("new comment", altered.comment());
+ Assertions.assertEquals(ImmutableMap.of("k2", "v2"), altered.properties());
+ Assertions.assertEquals(Domain.Status.DRAFT, altered.status());
+ Assertions.assertNotNull(altered.auditInfo().lastModifier());
+ Assertions.assertEquals(altered, domainManager.getDomain(METALAKE,
"Finance2"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () -> domainManager.getDomain(METALAKE,
"Finance"));
+ // The sub-domain follows the renamed parent, and a sub-domain keeps its
parent when altered.
+ Assertions.assertEquals(
+ "Finance2", domainManager.getDomain(METALAKE,
"Trading").parent().get());
+ Assertions.assertEquals(
+ "Finance2",
+ domainManager
+ .alterDomain(METALAKE, "Trading", DomainChange.updateComment("c"))
+ .parent()
+ .get());
+
+ Assertions.assertThrows(
+ DomainAlreadyExistsException.class,
+ () -> domainManager.alterDomain(METALAKE, "Finance2",
DomainChange.rename("Markets")));
+ Assertions.assertThrows(
+ NoSuchDomainException.class,
+ () -> domainManager.alterDomain(METALAKE, "missing",
DomainChange.updateComment("c")));
+ }
+
+ @Test
+ public void testDeleteDomain() {
+ domainManager.createDomain(METALAKE, "Finance", null, null);
+ domainManager.createSubdomain(METALAKE, "Finance", "Trading", null, null);
+
+ Assertions.assertThrows(
+ NonEmptyDomainException.class, () ->
domainManager.deleteDomain(METALAKE, "Finance"));
+ Assertions.assertNotNull(domainManager.getDomain(METALAKE, "Finance"));
+
+ Assertions.assertTrue(domainManager.deleteDomain(METALAKE, "Trading"));
+ Assertions.assertEquals(0, domainManager.listSubdomains(METALAKE,
"Finance").length);
+ Assertions.assertTrue(domainManager.deleteDomain(METALAKE, "Finance"));
+ Assertions.assertFalse(domainManager.deleteDomain(METALAKE, "Finance"));
+ Assertions.assertEquals(0, domainManager.listDomains(METALAKE).length);
+ }
+
+ @Test
+ public void testPublishAndUnpublish() {
+ domainManager.createDomain(METALAKE, "Finance", null, null);
+ domainManager.createSubdomain(METALAKE, "Finance", "Trading", null, null);
+
+ // A sub-domain needs a published parent.
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
domainManager.publishDomain(METALAKE, "Trading"));
+ Assertions.assertEquals(
+ Domain.Status.DRAFT, domainManager.getDomain(METALAKE,
"Trading").status());
+
+ DomainEntity finance = domainManager.publishDomain(METALAKE, "Finance");
+ Assertions.assertEquals(Domain.Status.PUBLISHED, finance.status());
+ Assertions.assertNotNull(finance.auditInfo().lastModifier());
+ // Publishing a published domain changes nothing.
+ Assertions.assertEquals(finance, domainManager.publishDomain(METALAKE,
"Finance"));
+ DomainEntity trading = domainManager.publishDomain(METALAKE, "Trading");
+ Assertions.assertEquals(Domain.Status.PUBLISHED, trading.status());
+ Assertions.assertEquals("Finance", trading.parent().get());
+
+ // A domain with published sub-domains cannot be unpublished.
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.unpublishDomain(METALAKE, "Finance"));
+ Assertions.assertTrue(e.getMessage().contains("[Trading]"),
e.getMessage());
+
+ Assertions.assertEquals(
+ Domain.Status.DRAFT, domainManager.unpublishDomain(METALAKE,
"Trading").status());
+ Assertions.assertEquals(
+ Domain.Status.DRAFT, domainManager.unpublishDomain(METALAKE,
"Finance").status());
+ // Unpublishing a draft domain changes nothing.
+ Assertions.assertEquals(
+ Domain.Status.DRAFT, domainManager.unpublishDomain(METALAKE,
"Finance").status());
+
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () ->
domainManager.publishDomain(METALAKE, "missing"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () ->
domainManager.unpublishDomain(METALAKE, "missing"));
+ }
+
+ @Test
+ public void testAttachMoveAndDetach() {
+ domainManager.createDomain(METALAKE, "Finance", null, null);
+ domainManager.createDomain(METALAKE, "Markets", null, null);
+ domainManager.createDomain(METALAKE, "Trading", null, null);
+
+ DomainEntity attached = domainManager.attachDomain(METALAKE, "Trading",
"Finance");
+ Assertions.assertEquals("Finance", attached.parent().get());
+ Assertions.assertArrayEquals(
+ new String[] {"Trading"}, domainManager.listSubdomains(METALAKE,
"Finance"));
+ // Attaching a domain to its current parent changes nothing.
+ Assertions.assertEquals(attached, domainManager.attachDomain(METALAKE,
"Trading", "Finance"));
+
+ // A domain has one parent, so attaching a sub-domain moves it.
+ DomainEntity moved = domainManager.attachDomain(METALAKE, "Trading",
"Markets");
+ Assertions.assertEquals("Markets", moved.parent().get());
+ Assertions.assertEquals(0, domainManager.listSubdomains(METALAKE,
"Finance").length);
+ Assertions.assertArrayEquals(
+ new String[] {"Trading"}, domainManager.listSubdomains(METALAKE,
"Markets"));
+
+ // A domain cannot be its own parent.
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.attachDomain(METALAKE, "Finance", "Finance"));
+ // The parent must be top-level.
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.attachDomain(METALAKE, "Finance", "Trading"));
+ // A domain with sub-domains cannot be attached.
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.attachDomain(METALAKE, "Markets", "Finance"));
+ Assertions.assertTrue(e.getMessage().contains("[Trading]"),
e.getMessage());
+ Assertions.assertFalse(domainManager.getDomain(METALAKE,
"Markets").parent().isPresent());
+ // A sub-domain cannot be moved under another sub-domain.
+ domainManager.createSubdomain(METALAKE, "Finance", "Desk", null, null);
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.attachDomain(METALAKE, "Trading", "Desk"));
+ Assertions.assertEquals("Markets", domainManager.getDomain(METALAKE,
"Trading").parent().get());
+
+ Assertions.assertThrows(
+ NoSuchDomainException.class,
+ () -> domainManager.attachDomain(METALAKE, "Trading", "missing"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class,
+ () -> domainManager.attachDomain(METALAKE, "missing", "Finance"));
+
+ DomainEntity detached = domainManager.detachDomain(METALAKE, "Trading");
+ Assertions.assertFalse(detached.parent().isPresent());
+ Assertions.assertEquals(0, domainManager.listSubdomains(METALAKE,
"Markets").length);
+ // Detaching a top-level domain changes nothing.
+ Assertions.assertEquals(detached, domainManager.detachDomain(METALAKE,
"Trading"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () ->
domainManager.detachDomain(METALAKE, "missing"));
+ }
+
+ @Test
+ public void testAttachChecksStatus() {
+ domainManager.createDomain(METALAKE, "Finance", null, null);
+ domainManager.createDomain(METALAKE, "Trading", null, null);
+ domainManager.publishDomain(METALAKE, "Trading");
+
+ // A published domain needs a published parent.
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.attachDomain(METALAKE, "Trading", "Finance"));
+ Assertions.assertFalse(domainManager.getDomain(METALAKE,
"Trading").parent().isPresent());
+
+ domainManager.publishDomain(METALAKE, "Finance");
+ Assertions.assertEquals(
+ "Finance", domainManager.attachDomain(METALAKE, "Trading",
"Finance").parent().get());
+
+ // A draft domain can be attached under a draft or a published parent.
+ domainManager.createDomain(METALAKE, "Draft", null, null);
+ domainManager.createDomain(METALAKE, "Desk", null, null);
+ Assertions.assertEquals(
+ "Draft", domainManager.attachDomain(METALAKE, "Desk",
"Draft").parent().get());
+ Assertions.assertEquals(
+ "Finance", domainManager.attachDomain(METALAKE, "Desk",
"Finance").parent().get());
+
+ // A published sub-domain cannot be moved under a draft parent.
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.attachDomain(METALAKE, "Trading", "Draft"));
+ Assertions.assertEquals("Finance", domainManager.getDomain(METALAKE,
"Trading").parent().get());
+ Assertions.assertEquals(0, domainManager.listSubdomains(METALAKE,
"Draft").length);
+ }
+
+ @Test
+ public void testChecksSkipUnchangedState() {
+ DomainEntity draftParent = domain("Finance", null, Domain.Status.DRAFT);
+ DomainEntity publishedChild = domain("Trading", "Finance",
Domain.Status.PUBLISHED);
+ DomainEntity grandParent = domain("Markets", "Other", Domain.Status.DRAFT);
+
+ // Attaching a domain to its current parent is not checked.
+ DomainManager.checkAttach(
+ new LockedDomainState(publishedChild, draftParent,
Collections.emptyList()));
+ // A change of parent is.
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ DomainManager.checkAttach(
+ new LockedDomainState(
+ domain("Trading", null, Domain.Status.PUBLISHED),
+ draftParent,
+ Collections.emptyList())));
+ // So is a move from another parent.
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ DomainManager.checkAttach(
+ new LockedDomainState(
+ domain("Trading", "Markets", Domain.Status.PUBLISHED),
+ draftParent,
+ Collections.emptyList())));
+ // Two names that find the same row are the domain itself.
+ DomainEntity finance = domain("Finance", null, Domain.Status.DRAFT);
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ DomainManager.checkAttach(
+ new LockedDomainState(finance, finance,
Collections.emptyList())));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ DomainManager.checkNewDomain(
+ new LockedDomainState(
+ domain("Desk", "Markets", Domain.Status.DRAFT),
+ grandParent,
+ Collections.emptyList())));
+
+ // Publishing a published domain and unpublishing a draft one are not
checked.
+ DomainManager.checkPublish(
+ new LockedDomainState(publishedChild, draftParent,
Collections.emptyList()));
+ List<DomainEntity> publishedSubDomains =
Collections.singletonList(publishedChild);
+ DomainManager.checkUnpublish(new LockedDomainState(draftParent, null,
publishedSubDomains));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ DomainManager.checkUnpublish(
+ new LockedDomainState(
+ domain("Finance", null, Domain.Status.PUBLISHED), null,
publishedSubDomains)));
+ }
+
+ @Test
+ public void testCheckDomainMetadataObject() {
+ domainManager.createDomain(METALAKE, "Finance", null, null);
+ MetadataObjectUtil.checkMetadataObject(
+ METALAKE, MetadataObjects.of(null, "Finance",
MetadataObject.Type.DOMAIN));
+ Assertions.assertThrows(
+ NoSuchMetadataObjectException.class,
+ () ->
+ MetadataObjectUtil.checkMetadataObject(
+ METALAKE, MetadataObjects.of(null, "missing",
MetadataObject.Type.DOMAIN)));
+ }
+
+ private static DomainEntity domain(String name, String parent, Domain.Status
status) {
+ return DomainEntity.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName(name)
+ .withNamespace(NamespaceUtil.ofDomain(METALAKE))
+ .withProperties(Collections.emptyMap())
+ .withStatus(status)
+ .withParent(parent)
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .build();
+ }
+
+ private static Config mockConfig() {
+ Config config = Mockito.mock(Config.class);
+
+ Mockito.when(config.get(ENTITY_STORE)).thenReturn(RELATIONAL_ENTITY_STORE);
+
Mockito.when(config.get(ENTITY_RELATIONAL_STORE)).thenReturn(DEFAULT_ENTITY_RELATIONAL_STORE);
+ Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_URL))
+ .thenReturn(
+ String.format(
+
"jdbc:h2:file:%s;DB_CLOSE_DELAY=-1;MODE=MYSQL;LOCK_TIMEOUT=30000", DB_DIR));
+
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER)).thenReturn("org.h2.Driver");
+
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS)).thenReturn(100);
+
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_MAX_IDLE_CONNECTIONS)).thenReturn(10);
+
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS)).thenReturn(1000L);
+
Mockito.when(config.get(STORE_TRANSACTION_MAX_SKEW_TIME)).thenReturn(1000L);
+ Mockito.when(config.get(STORE_DELETE_AFTER_TIME)).thenReturn(20 * 60 *
1000L);
+
Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+ Mockito.when(config.get(ENTITY_CHANGE_LOG_POLL_BATCH_SIZE))
+ .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_POLL_BATCH_SIZE);
+ Mockito.when(config.get(ENTITY_CHANGE_LOG_REPLAY_WINDOW_SECS))
+ .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_REPLAY_WINDOW_SECS);
+ Mockito.when(config.get(ENTITY_CHANGE_LOG_REPLAY_MAX_ENTRIES))
+ .thenReturn(DEFAULT_ENTITY_CHANGE_LOG_REPLAY_MAX_ENTRIES);
+ Mockito.when(config.get(ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24 *
60 * 60L);
+
Mockito.when(config.get(ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60
* 60L);
+ Mockito.when(config.get(VERSION_RETENTION_COUNT)).thenReturn(1L);
+ Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(true);
+ Mockito.when(config.get(Configs.CACHE_MAX_ENTRIES)).thenReturn(10_000);
+
Mockito.when(config.get(Configs.CACHE_EXPIRATION_TIME)).thenReturn(3_600_000L);
+ Mockito.when(config.get(Configs.CACHE_WEIGHER_ENABLED)).thenReturn(true);
+ Mockito.when(config.get(Configs.CACHE_STATS_ENABLED)).thenReturn(false);
+
Mockito.when(config.get(Configs.CACHE_IMPLEMENTATION)).thenReturn("caffeine");
+ Mockito.when(config.get(Configs.CACHE_LOCK_SEGMENTS)).thenReturn(16);
+
+ Mockito.doReturn(100000L).when(config).get(TREE_LOCK_MAX_NODE_IN_MEMORY);
+ Mockito.doReturn(1000L).when(config).get(TREE_LOCK_MIN_NODE_IN_MEMORY);
+ Mockito.doReturn(36000L).when(config).get(TREE_LOCK_CLEAN_INTERVAL);
+ return config;
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/domain/TestDomainManagerWithMockStore.java
b/core/src/test/java/org/apache/gravitino/domain/TestDomainManagerWithMockStore.java
new file mode 100644
index 0000000000..56e8a179bd
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/domain/TestDomainManagerWithMockStore.java
@@ -0,0 +1,520 @@
+/*
+ * 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.domain;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.SupportsDomainOperations;
+import org.apache.gravitino.SupportsDomainOperations.LockedDomainState;
+import org.apache.gravitino.exceptions.DomainAlreadyExistsException;
+import org.apache.gravitino.exceptions.NoSuchDomainException;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NonEmptyDomainException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.meta.DomainEntity;
+import org.apache.gravitino.meta.SchemaVersion;
+import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.mockito.invocation.InvocationOnMock;
+
+/**
+ * Tests {@link DomainManager} against a mocked entity store: how store
exceptions map to domain
+ * exceptions, and which check each store operation gets.
+ */
+public class TestDomainManagerWithMockStore {
+
+ private static final String METALAKE = "metalake";
+ private static final NameIdentifier FINANCE =
NameIdentifierUtil.ofDomain(METALAKE, "Finance");
+ private static final NameIdentifier TRADING =
NameIdentifierUtil.ofDomain(METALAKE, "Trading");
+
+ private EntityStore entityStore;
+ private SupportsDomainOperations domainOperations;
+ private DomainManager domainManager;
+
+ @BeforeEach
+ public void setUp() throws IOException {
+ entityStore = Mockito.mock(EntityStore.class);
+ domainOperations = Mockito.mock(SupportsDomainOperations.class);
+ Mockito.when(entityStore.domainOperations()).thenReturn(domainOperations);
+ BaseMetalake metalake =
+ BaseMetalake.builder()
+ .withId(1L)
+ .withName(METALAKE)
+ .withVersion(SchemaVersion.V_0_1)
+ .withAuditInfo(auditInfo())
+ .build();
+ Mockito.when(
+ entityStore.get(
+ NameIdentifierUtil.ofMetalake(METALAKE),
+ Entity.EntityType.METALAKE,
+ BaseMetalake.class))
+ .thenReturn(metalake);
+
+ IdGenerator idGenerator = Mockito.mock(IdGenerator.class);
+ Mockito.when(idGenerator.nextId()).thenReturn(100L);
+ domainManager = new DomainManager(idGenerator, entityStore);
+ }
+
+ @Test
+ public void testMissingMetalake() throws IOException {
+ Mockito.when(
+ entityStore.get(
+ NameIdentifierUtil.ofMetalake("missing"),
+ Entity.EntityType.METALAKE,
+ BaseMetalake.class))
+ .thenThrow(new NoSuchEntityException("missing"));
+
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class, () ->
domainManager.listDomainInfos("missing"));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class, () ->
domainManager.getDomain("missing", "Finance"));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class,
+ () -> domainManager.createDomain("missing", "Finance", null, null));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class,
+ () -> domainManager.createSubdomain("missing", "Finance", "Trading",
null, null));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class,
+ () -> domainManager.alterDomain("missing", "Finance",
DomainChange.updateComment("c")));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class, () ->
domainManager.deleteDomain("missing", "Finance"));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class, () ->
domainManager.publishDomain("missing", "Finance"));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class, () ->
domainManager.unpublishDomain("missing", "Finance"));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class,
+ () -> domainManager.listSubdomainInfos("missing", "Finance"));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class,
+ () -> domainManager.attachDomain("missing", "Trading", "Finance"));
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class, () ->
domainManager.detachDomain("missing", "Trading"));
+ Mockito.verifyNoInteractions(domainOperations);
+ Mockito.verify(entityStore, Mockito.never()).update(any(), any(), any(),
any());
+ Mockito.verify(entityStore, Mockito.never()).delete(any(), any());
+ }
+
+ @Test
+ public void testListAndGetDomain() throws IOException {
+ Mockito.when(
+ entityStore.list(
+ NamespaceUtil.ofDomain(METALAKE), DomainEntity.class,
Entity.EntityType.DOMAIN))
+ .thenThrow(new IOException("list failed"));
+ assertIOExceptionWrapped(() -> domainManager.listDomainInfos(METALAKE));
+
+ Mockito.when(entityStore.get(FINANCE, Entity.EntityType.DOMAIN,
DomainEntity.class))
+ .thenThrow(new NoSuchEntityException("missing"));
+ Mockito.when(entityStore.get(TRADING, Entity.EntityType.DOMAIN,
DomainEntity.class))
+ .thenThrow(new IOException("get failed"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () -> domainManager.getDomain(METALAKE,
"Finance"));
+ assertIOExceptionWrapped(() -> domainManager.getDomain(METALAKE,
"Trading"));
+ }
+
+ @Test
+ public void testCreateDomain() throws IOException {
+ ArgumentCaptor<DomainEntity> domainCaptor =
ArgumentCaptor.forClass(DomainEntity.class);
+ ArgumentCaptor<Consumer<LockedDomainState>> checkCaptor = checkCaptor();
+ DomainEntity created = domainManager.createDomain(METALAKE, "Finance",
"comment", null);
+ Mockito.verify(domainOperations).insertDomain(domainCaptor.capture(),
checkCaptor.capture());
+
+ DomainEntity inserted = domainCaptor.getValue();
+ Assertions.assertSame(inserted, created);
+ Assertions.assertEquals(100L, inserted.id());
+ Assertions.assertEquals(FINANCE, inserted.nameIdentifier());
+ Assertions.assertEquals("comment", inserted.comment());
+ Assertions.assertTrue(inserted.properties().isEmpty());
+ Assertions.assertEquals(Domain.Status.DRAFT, inserted.status());
+ Assertions.assertFalse(inserted.parent().isPresent());
+ assertIsNewDomainCheck(checkCaptor.getValue());
+
+ Mockito.doThrow(new EntityAlreadyExistsException("exists"))
+ .when(domainOperations)
+ .insertDomain(any(), any());
+ Assertions.assertThrows(
+ DomainAlreadyExistsException.class,
+ () -> domainManager.createDomain(METALAKE, "Finance", null, null));
+
+ // The metalake was dropped after it was checked.
+ Mockito.doThrow(new NoSuchEntityException("missing"))
+ .when(domainOperations)
+ .insertDomain(any(), any());
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class,
+ () -> domainManager.createDomain(METALAKE, "Finance", null, null));
+
+ Mockito.doThrow(new IOException("insert failed"))
+ .when(domainOperations)
+ .insertDomain(any(), any());
+ assertIOExceptionWrapped(() -> domainManager.createDomain(METALAKE,
"Finance", null, null));
+ }
+
+ @Test
+ public void testCreateSubdomain() throws IOException {
+ ArgumentCaptor<DomainEntity> domainCaptor =
ArgumentCaptor.forClass(DomainEntity.class);
+ ArgumentCaptor<Consumer<LockedDomainState>> checkCaptor = checkCaptor();
+ domainManager.createSubdomain(METALAKE, "Finance", "Trading", null, null);
+ Mockito.verify(domainOperations).insertDomain(domainCaptor.capture(),
checkCaptor.capture());
+ Assertions.assertEquals(TRADING, domainCaptor.getValue().nameIdentifier());
+ Assertions.assertEquals("Finance", domainCaptor.getValue().parent().get());
+ assertIsNewDomainCheck(checkCaptor.getValue());
+
+ // The sub-domain is returned with the parent's stored name, which the
check reads under the
+ // parent's lock, even when the request names it differently.
+ Mockito.doAnswer(
+ invocation -> {
+ DomainEntity newDomain = invocation.getArgument(0);
+ Consumer<LockedDomainState> check = invocation.getArgument(1);
+ DomainEntity storedParent = domain("Finance", null,
Domain.Status.DRAFT);
+ check.accept(state(newDomain, storedParent));
+ return null;
+ })
+ .when(domainOperations)
+ .insertDomain(any(), any());
+ DomainEntity desk = domainManager.createSubdomain(METALAKE, "Finance ",
"Desk", null, null);
+ Assertions.assertEquals("Finance", desk.parent().get());
+ Assertions.assertEquals(NameIdentifierUtil.ofDomain(METALAKE, "Desk"),
desk.nameIdentifier());
+
+ // A check that throws reaches the caller unchanged.
+ Mockito.doAnswer(TestDomainManagerWithMockStore::runInsertCheck)
+ .when(domainOperations)
+ .insertDomain(any(), any());
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.createSubdomain(METALAKE, "Trading", "Desk", null,
null));
+
+ Mockito.doThrow(new NoSuchEntityException("missing"))
+ .when(domainOperations)
+ .insertDomain(any(), any());
+ Assertions.assertThrows(
+ NoSuchDomainException.class,
+ () -> domainManager.createSubdomain(METALAKE, "missing", "Trading",
null, null));
+
+ Mockito.doThrow(new EntityAlreadyExistsException("exists"))
+ .when(domainOperations)
+ .insertDomain(any(), any());
+ Assertions.assertThrows(
+ DomainAlreadyExistsException.class,
+ () -> domainManager.createSubdomain(METALAKE, "Finance", "Trading",
null, null));
+
+ Mockito.doThrow(new IOException("insert failed"))
+ .when(domainOperations)
+ .insertDomain(any(), any());
+ assertIOExceptionWrapped(
+ () -> domainManager.createSubdomain(METALAKE, "Finance", "Trading",
null, null));
+
+ // A domain as its own parent, and a null parent, are refused before the
store is called.
+ Mockito.clearInvocations(domainOperations);
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.createSubdomain(METALAKE, "Finance", "Finance",
null, null));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.createSubdomain(METALAKE, null, "Trading", null,
null));
+ Mockito.verifyNoInteractions(domainOperations);
+ }
+
+ @Test
+ public void testAlterDomain() throws IOException {
+ Mockito.when(entityStore.update(eq(FINANCE), eq(DomainEntity.class),
any(), any()))
+ .thenThrow(new NoSuchEntityException("missing"))
+ .thenThrow(new EntityAlreadyExistsException("exists"))
+ .thenThrow(new IOException("update failed"));
+
+ Assertions.assertThrows(
+ NoSuchDomainException.class,
+ () -> domainManager.alterDomain(METALAKE, "Finance",
DomainChange.updateComment("c")));
+ DomainAlreadyExistsException e =
+ Assertions.assertThrows(
+ DomainAlreadyExistsException.class,
+ () ->
+ domainManager.alterDomain(
+ METALAKE,
+ "Finance",
+ DomainChange.rename("Markets"),
+ DomainChange.rename("Trading")));
+ // The message names the last new name.
+ Assertions.assertTrue(e.getMessage().contains("Domain Trading"),
e.getMessage());
+ assertIOExceptionWrapped(
+ () -> domainManager.alterDomain(METALAKE, "Finance",
DomainChange.updateComment("c")));
+ }
+
+ @Test
+ public void testAlterDomainWithNullProperties() throws IOException {
+ DomainEntity stored =
+ domain("Finance", null,
Domain.Status.DRAFT).toBuilder().withProperties(null).build();
+ Mockito.when(entityStore.update(eq(FINANCE), eq(DomainEntity.class),
any(), any()))
+ .thenAnswer(
+ invocation -> {
+ Function<DomainEntity, DomainEntity> updater =
invocation.getArgument(3);
+ return updater.apply(stored);
+ });
+
+ DomainEntity updated =
+ domainManager.alterDomain(METALAKE, "Finance",
DomainChange.updateComment("new comment"));
+ Assertions.assertEquals("new comment", updated.comment());
+ Assertions.assertTrue(updated.properties().isEmpty());
+ Assertions.assertEquals(
+ Map.of("key", "value"),
+ domainManager
+ .alterDomain(METALAKE, "Finance", DomainChange.setProperty("key",
"value"))
+ .properties());
+ Assertions.assertTrue(
+ domainManager
+ .alterDomain(METALAKE, "Finance",
DomainChange.removeProperty("key"))
+ .properties()
+ .isEmpty());
+ Assertions.assertNull(stored.properties());
+ }
+
+ @Test
+ public void testAlterAndDeletePropagateOptimisticLockException() throws
IOException {
+ OptimisticLockException conflict = new OptimisticLockException("Concurrent
operation");
+ Mockito.when(entityStore.update(eq(FINANCE), eq(DomainEntity.class),
any(), any()))
+ .thenThrow(conflict);
+ Mockito.when(entityStore.delete(FINANCE,
Entity.EntityType.DOMAIN)).thenThrow(conflict);
+
+ Assertions.assertSame(
+ conflict,
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () -> domainManager.alterDomain(METALAKE, "Finance",
DomainChange.updateComment("c"))));
+ Assertions.assertSame(
+ conflict,
+ Assertions.assertThrows(
+ OptimisticLockException.class, () ->
domainManager.deleteDomain(METALAKE, "Finance")));
+ }
+
+ @Test
+ public void testDeleteDomain() throws IOException {
+ Mockito.when(entityStore.delete(FINANCE, Entity.EntityType.DOMAIN))
+ .thenReturn(true)
+ .thenReturn(false)
+ .thenThrow(new NonEmptyEntityException("has sub-domains"))
+ .thenThrow(new IOException("delete failed"));
+
+ Assertions.assertTrue(domainManager.deleteDomain(METALAKE, "Finance"));
+ Assertions.assertFalse(domainManager.deleteDomain(METALAKE, "Finance"));
+ Assertions.assertThrows(
+ NonEmptyDomainException.class, () ->
domainManager.deleteDomain(METALAKE, "Finance"));
+ assertIOExceptionWrapped(() -> domainManager.deleteDomain(METALAKE,
"Finance"));
+ }
+
+ @Test
+ public void testPublishAndUnpublish() throws IOException {
+ ArgumentCaptor<Consumer<LockedDomainState>> publishCheck = checkCaptor();
+ domainManager.publishDomain(METALAKE, "Trading");
+ Mockito.verify(domainOperations)
+ .updateDomainStatusAndGet(
+ eq(TRADING), eq(Domain.Status.PUBLISHED), any(),
publishCheck.capture());
+ assertIsPublishCheck(publishCheck.getValue());
+
+ ArgumentCaptor<Consumer<LockedDomainState>> unpublishCheck = checkCaptor();
+ ArgumentCaptor<AuditInfo> auditCaptor =
ArgumentCaptor.forClass(AuditInfo.class);
+ domainManager.unpublishDomain(METALAKE, "Finance");
+ Mockito.verify(domainOperations)
+ .updateDomainStatusAndGet(
+ eq(FINANCE), eq(Domain.Status.DRAFT), auditCaptor.capture(),
unpublishCheck.capture());
+ assertIsUnpublishCheck(unpublishCheck.getValue());
+ Assertions.assertNotNull(auditCaptor.getValue().lastModifier());
+ Assertions.assertNotNull(auditCaptor.getValue().lastModifiedTime());
+
+ Mockito.when(domainOperations.updateDomainStatusAndGet(any(), any(),
any(), any()))
+ .thenThrow(new NoSuchEntityException("missing"))
+ .thenThrow(new NoSuchEntityException("missing"))
+ .thenThrow(new IOException("update failed"))
+ .thenThrow(new IOException("update failed"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () ->
domainManager.publishDomain(METALAKE, "Finance"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () ->
domainManager.unpublishDomain(METALAKE, "Finance"));
+ assertIOExceptionWrapped(() -> domainManager.publishDomain(METALAKE,
"Finance"));
+ assertIOExceptionWrapped(() -> domainManager.unpublishDomain(METALAKE,
"Finance"));
+ }
+
+ @Test
+ public void testListSubdomains() throws IOException {
+ DomainEntity trading = domain("Trading", "Finance", Domain.Status.DRAFT);
+ Mockito.when(domainOperations.listSubDomains(FINANCE))
+ .thenReturn(Collections.singletonList(trading))
+ .thenThrow(new NoSuchEntityException("missing"))
+ .thenThrow(new IOException("list failed"));
+
+ Assertions.assertArrayEquals(
+ new DomainEntity[] {trading},
domainManager.listSubdomainInfos(METALAKE, "Finance"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () ->
domainManager.listSubdomainInfos(METALAKE, "Finance"));
+ assertIOExceptionWrapped(() -> domainManager.listSubdomainInfos(METALAKE,
"Finance"));
+ }
+
+ @Test
+ public void testAttachDomain() throws IOException {
+ ArgumentCaptor<Consumer<LockedDomainState>> checkCaptor = checkCaptor();
+ ArgumentCaptor<AuditInfo> auditCaptor =
ArgumentCaptor.forClass(AuditInfo.class);
+ domainManager.attachDomain(METALAKE, "Trading", "Finance");
+ Mockito.verify(domainOperations)
+ .setDomainParentAndGet(
+ eq(TRADING), eq(FINANCE), auditCaptor.capture(),
checkCaptor.capture());
+ assertIsAttachCheck(checkCaptor.getValue());
+ // A new edge takes the creator, a moved one the modifier, so both are set.
+ Assertions.assertNotNull(auditCaptor.getValue().creator());
+ Assertions.assertNotNull(auditCaptor.getValue().lastModifier());
+
+ Mockito.when(domainOperations.setDomainParentAndGet(any(), any(), any(),
any()))
+ .thenThrow(new NoSuchEntityException("missing"))
+ .thenThrow(new IOException("update failed"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class,
+ () -> domainManager.attachDomain(METALAKE, "Trading", "Finance"));
+ assertIOExceptionWrapped(() -> domainManager.attachDomain(METALAKE,
"Trading", "Finance"));
+
+ // A domain as its own parent, and a null parent, are refused before the
store is called.
+ Mockito.clearInvocations(domainOperations);
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.attachDomain(METALAKE, "Finance", "Finance"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> domainManager.attachDomain(METALAKE, "Trading", null));
+ Mockito.verifyNoInteractions(domainOperations);
+ }
+
+ @Test
+ public void testDetachDomain() throws IOException {
+ DomainEntity trading = domain("Trading", null, Domain.Status.DRAFT);
+ Mockito.when(domainOperations.removeDomainParentAndGet(eq(TRADING), any()))
+ .thenReturn(trading)
+ .thenThrow(new NoSuchEntityException("missing"))
+ .thenThrow(new IOException("update failed"));
+
+ Assertions.assertSame(trading, domainManager.detachDomain(METALAKE,
"Trading"));
+ Assertions.assertThrows(
+ NoSuchDomainException.class, () ->
domainManager.detachDomain(METALAKE, "Trading"));
+ assertIOExceptionWrapped(() -> domainManager.detachDomain(METALAKE,
"Trading"));
+ }
+
+ // The insert check refuses a parent that is itself a sub-domain.
+ private static void assertIsNewDomainCheck(Consumer<LockedDomainState>
check) {
+ DomainEntity desk = domain("Desk", "Trading", Domain.Status.DRAFT);
+ check.accept(state(desk, domain("Trading", null, Domain.Status.DRAFT)));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> check.accept(state(desk, domain("Trading", "Finance",
Domain.Status.DRAFT))));
+ }
+
+ // The publish check refuses a draft parent, which the unpublish check would
accept.
+ private static void assertIsPublishCheck(Consumer<LockedDomainState> check) {
+ DomainEntity trading = domain("Trading", "Finance", Domain.Status.DRAFT);
+ check.accept(state(trading, domain("Finance", null,
Domain.Status.PUBLISHED)));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> check.accept(state(trading, domain("Finance", null,
Domain.Status.DRAFT))));
+ }
+
+ // The unpublish check refuses published sub-domains, which the publish
check would accept.
+ private static void assertIsUnpublishCheck(Consumer<LockedDomainState>
check) {
+ DomainEntity finance = domain("Finance", null, Domain.Status.PUBLISHED);
+ check.accept(
+ new LockedDomainState(
+ finance, null, List.of(domain("Trading", "Finance",
Domain.Status.DRAFT))));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ check.accept(
+ new LockedDomainState(
+ finance,
+ null,
+ List.of(domain("Trading", "Finance",
Domain.Status.PUBLISHED)))));
+ }
+
+ // The attach check refuses a domain with sub-domains, which the insert
check would accept.
+ private static void assertIsAttachCheck(Consumer<LockedDomainState> check) {
+ DomainEntity trading = domain("Trading", null, Domain.Status.DRAFT);
+ DomainEntity finance = domain("Finance", null, Domain.Status.DRAFT);
+ check.accept(state(trading, finance));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ check.accept(
+ new LockedDomainState(
+ trading, finance, List.of(domain("Desk", "Trading",
Domain.Status.DRAFT)))));
+ }
+
+ private static Object runInsertCheck(InvocationOnMock invocation) {
+ DomainEntity domain = invocation.getArgument(0);
+ Consumer<LockedDomainState> check = invocation.getArgument(1);
+ // The parent of the new domain is itself a sub-domain.
+ check.accept(state(domain, domain(domain.parent().get(), "Finance",
Domain.Status.PUBLISHED)));
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ArgumentCaptor<Consumer<LockedDomainState>> checkCaptor() {
+ return ArgumentCaptor.forClass(Consumer.class);
+ }
+
+ private static void assertIOExceptionWrapped(Runnable call) {
+ RuntimeException e = Assertions.assertThrows(RuntimeException.class,
call::run);
+ Assertions.assertInstanceOf(IOException.class, e.getCause());
+ }
+
+ private static LockedDomainState state(DomainEntity domain, DomainEntity
parent) {
+ return new LockedDomainState(domain, parent, Collections.emptyList());
+ }
+
+ private static DomainEntity domain(String name, String parent, Domain.Status
status) {
+ return DomainEntity.builder()
+ .withId((long) name.hashCode())
+ .withName(name)
+ .withNamespace(NamespaceUtil.ofDomain(METALAKE))
+ .withProperties(Collections.emptyMap())
+ .withStatus(status)
+ .withParent(parent)
+ .withAuditInfo(auditInfo())
+ .build();
+ }
+
+ private static AuditInfo auditInfo() {
+ return
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+ }
+}