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

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new cbf0198fd5 [#12233] refactor(core): Support relation targets (#12234)
cbf0198fd5 is described below

commit cbf0198fd5b2e72b4c063c3b3d6b7555f20897b8
Author: roryqi <[email protected]>
AuthorDate: Tue Aug 4 09:24:57 2026 +0800

    [#12233] refactor(core): Support relation targets (#12234)
    
    ### What changes were proposed in this pull request?
    
    Introduce object-based relation query and update requests for
    relation-edge metadata.
    
    This PR adds `RelationQuery`, `RelationUpdate`, and `RelationEdgeTarget`
    as the canonical generic relation APIs. Existing primitive
    `SupportsRelationOperations` methods are kept as compatibility adapters,
    while relation-value reads and value-carrying updates now evolve through
    the request/update objects instead of tag-specific or value-specific
    overloads.
    
    `RelationalEntityStore` delegates the object APIs, validates relation
    target entity types before backend writes, and invalidates relation
    caches after backend writes using the same derived target type.
    
    This is the first split from the original tag assignment value storage
    PR. The schema and tag storage implementation are kept in
    `tag-assignment-values-storage-next` for the follow-up PR.
    
    ### Why are the changes needed?
    
    Tag assignment values need relation-level metadata, but the store
    contract should stay relation-generic instead of adding tag-specific
    methods.
    
    Fix: #12233
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This is internal core/store infrastructure only.
    
    ### How was this patch tested?
    
    `./gradlew :core:spotlessApply`
    
    `./gradlew :core:test --tests
    org.apache.gravitino.TestSupportsRelationOperations --tests
    org.apache.gravitino.storage.relational.TestRelationalEntityStore
    -PskipITs -PskipDockerTests=false`
---
 .../org/apache/gravitino/RelationEdgeTarget.java   |  81 +++++++++
 .../java/org/apache/gravitino/RelationQuery.java   | 116 +++++++++++++
 .../java/org/apache/gravitino/RelationUpdate.java  | 134 +++++++++++++++
 .../gravitino/SupportsRelationOperations.java      |  87 +++++++++-
 .../storage/relational/RelationalEntityStore.java  | 116 +++++++++++--
 .../gravitino/TestSupportsRelationOperations.java  | 186 +++++++++++++++++++++
 .../relational/TestRelationalEntityStore.java      | 120 ++++++++++++-
 7 files changed, 814 insertions(+), 26 deletions(-)

diff --git a/core/src/main/java/org/apache/gravitino/RelationEdgeTarget.java 
b/core/src/main/java/org/apache/gravitino/RelationEdgeTarget.java
new file mode 100644
index 0000000000..e654670c2b
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/RelationEdgeTarget.java
@@ -0,0 +1,81 @@
+/*
+ * 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;
+
+import com.google.common.base.Preconditions;
+import java.util.Optional;
+import javax.annotation.Nullable;
+
+/**
+ * Represents the immutable target endpoint of a relation edge in a relation 
update. The source
+ * endpoint is supplied by {@link RelationUpdate}.
+ *
+ * <p>The optional relation value is metadata stored on the edge itself rather 
than on either
+ * endpoint entity. For example, a tag assignment value belongs to the 
tag-to-metadata-object
+ * relation, not to the tag definition or metadata object.
+ */
+public final class RelationEdgeTarget {
+
+  private final NameIdentifier nameIdentifier;
+  private final Entity.EntityType entityType;
+  @Nullable private final String relationValue;
+
+  private RelationEdgeTarget(
+      NameIdentifier nameIdentifier, Entity.EntityType entityType, @Nullable 
String relationValue) {
+    this.nameIdentifier = nameIdentifier;
+    this.entityType = entityType;
+    this.relationValue = relationValue;
+  }
+
+  /**
+   * Creates a relation edge target.
+   *
+   * @param nameIdentifier The target entity identifier.
+   * @param entityType The target entity type.
+   * @param relationValue Optional string value carried by the relation edge.
+   * @return A relation edge target.
+   */
+  public static RelationEdgeTarget of(
+      NameIdentifier nameIdentifier, Entity.EntityType entityType, @Nullable 
String relationValue) {
+    Preconditions.checkArgument(nameIdentifier != null, "nameIdentifier must 
not be null");
+    Preconditions.checkArgument(entityType != null, "entityType must not be 
null");
+    return new RelationEdgeTarget(nameIdentifier, entityType, relationValue);
+  }
+
+  /**
+   * @return The target entity identifier.
+   */
+  public NameIdentifier nameIdentifier() {
+    return nameIdentifier;
+  }
+
+  /**
+   * @return The target entity type.
+   */
+  public Entity.EntityType entityType() {
+    return entityType;
+  }
+
+  /**
+   * @return The optional string value carried by the relation edge.
+   */
+  public Optional<String> relationValue() {
+    return Optional.ofNullable(relationValue);
+  }
+}
diff --git a/core/src/main/java/org/apache/gravitino/RelationQuery.java 
b/core/src/main/java/org/apache/gravitino/RelationQuery.java
new file mode 100644
index 0000000000..2947a2ab76
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/RelationQuery.java
@@ -0,0 +1,116 @@
+/*
+ * 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;
+
+import com.google.common.base.Preconditions;
+import java.util.Optional;
+import javax.annotation.Nullable;
+
+/**
+ * Describes a relation lookup from an anchor entity. The anchor may be either 
endpoint of the
+ * relation edge, and the returned entities come from the opposite endpoint.
+ *
+ * <p>For reverse lookups, callers still use the same relation type. For 
example, querying all
+ * metadata objects that have a specific policy uses {@link
+ * SupportsRelationOperations.Type#POLICY_METADATA_OBJECT_REL} with the policy 
as the anchor entity,
+ * and querying all metadata objects that have a specific tag uses {@link
+ * SupportsRelationOperations.Type#TAG_METADATA_OBJECT_REL} with the tag as 
the anchor entity.
+ *
+ * <p>The optional relation value is an exact string value carried by the 
relation edge, such as a
+ * tag assignment value. A null value means the query should not filter by 
relation value.
+ */
+public final class RelationQuery {
+
+  private final SupportsRelationOperations.Type relationType;
+  private final NameIdentifier anchorIdentifier;
+  private final Entity.EntityType anchorEntityType;
+  private final boolean allFields;
+  @Nullable private final String relationValue;
+
+  private RelationQuery(
+      SupportsRelationOperations.Type relationType,
+      NameIdentifier anchorIdentifier,
+      Entity.EntityType anchorEntityType,
+      boolean allFields,
+      @Nullable String relationValue) {
+    this.relationType = relationType;
+    this.anchorIdentifier = anchorIdentifier;
+    this.anchorEntityType = anchorEntityType;
+    this.allFields = allFields;
+    this.relationValue = relationValue;
+  }
+
+  /**
+   * Creates a relation query.
+   *
+   * @param relationType The type of relation.
+   * @param anchorIdentifier The anchor entity identifier.
+   * @param anchorEntityType The entity type that {@code anchorIdentifier} 
represents.
+   * @param allFields Whether to fetch all fields.
+   * @param relationValue Optional exact string value carried by the relation 
edge.
+   * @return A relation query.
+   */
+  public static RelationQuery of(
+      SupportsRelationOperations.Type relationType,
+      NameIdentifier anchorIdentifier,
+      Entity.EntityType anchorEntityType,
+      boolean allFields,
+      @Nullable String relationValue) {
+    Preconditions.checkArgument(relationType != null, "relationType must not 
be null");
+    Preconditions.checkArgument(anchorIdentifier != null, "anchorIdentifier 
must not be null");
+    Preconditions.checkArgument(anchorEntityType != null, "anchorEntityType 
must not be null");
+    return new RelationQuery(
+        relationType, anchorIdentifier, anchorEntityType, allFields, 
relationValue);
+  }
+
+  /**
+   * @return The type of relation.
+   */
+  public SupportsRelationOperations.Type relationType() {
+    return relationType;
+  }
+
+  /**
+   * @return The anchor entity identifier.
+   */
+  public NameIdentifier anchorIdentifier() {
+    return anchorIdentifier;
+  }
+
+  /**
+   * @return The entity type that {@link #anchorIdentifier()} represents.
+   */
+  public Entity.EntityType anchorEntityType() {
+    return anchorEntityType;
+  }
+
+  /**
+   * @return Whether to fetch all fields.
+   */
+  public boolean allFields() {
+    return allFields;
+  }
+
+  /**
+   * @return The optional exact string value carried by the relation edge.
+   */
+  public Optional<String> relationValue() {
+    return Optional.ofNullable(relationValue);
+  }
+}
diff --git a/core/src/main/java/org/apache/gravitino/RelationUpdate.java 
b/core/src/main/java/org/apache/gravitino/RelationUpdate.java
new file mode 100644
index 0000000000..5a847cb62e
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/RelationUpdate.java
@@ -0,0 +1,134 @@
+/*
+ * 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;
+
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+
+/**
+ * Describes an immutable relation update from a source entity to target 
endpoints. Target endpoint
+ * identity and relation-edge attributes are carried by immutable {@link 
RelationEdgeTarget}
+ * instances.
+ *
+ * <p>The target endpoint arrays are copied when the update is created and 
when the targets are
+ * returned. The copy protects the array container from external mutation; the 
elements themselves
+ * are immutable.
+ */
+public final class RelationUpdate {
+
+  private static final RelationEdgeTarget[] EMPTY_TARGETS = new 
RelationEdgeTarget[0];
+
+  private final SupportsRelationOperations.Type relationType;
+  private final NameIdentifier sourceIdentifier;
+  private final Entity.EntityType sourceEntityType;
+  private final RelationEdgeTarget[] targetsToAdd;
+  private final RelationEdgeTarget[] targetsToRemove;
+
+  private RelationUpdate(
+      SupportsRelationOperations.Type relationType,
+      NameIdentifier sourceIdentifier,
+      Entity.EntityType sourceEntityType,
+      RelationEdgeTarget[] targetsToAdd,
+      RelationEdgeTarget[] targetsToRemove) {
+    this.relationType = relationType;
+    this.sourceIdentifier = sourceIdentifier;
+    this.sourceEntityType = sourceEntityType;
+    this.targetsToAdd = copyTargets(targetsToAdd, "targetsToAdd");
+    this.targetsToRemove = copyTargets(targetsToRemove, "targetsToRemove");
+  }
+
+  /**
+   * Creates a relation update.
+   *
+   * @param relationType The type of relation.
+   * @param sourceIdentifier The identifier of the source entity whose 
relations are being updated.
+   * @param sourceEntityType The source entity type.
+   * @param targetsToAdd Target endpoints to associate with the source entity.
+   * @param targetsToRemove Target endpoints to disassociate from the source 
entity.
+   * @return A relation update.
+   */
+  public static RelationUpdate of(
+      SupportsRelationOperations.Type relationType,
+      NameIdentifier sourceIdentifier,
+      Entity.EntityType sourceEntityType,
+      RelationEdgeTarget[] targetsToAdd,
+      RelationEdgeTarget[] targetsToRemove) {
+    Preconditions.checkArgument(relationType != null, "relationType must not 
be null");
+    Preconditions.checkArgument(sourceIdentifier != null, "sourceIdentifier 
must not be null");
+    Preconditions.checkArgument(sourceEntityType != null, "sourceEntityType 
must not be null");
+    return new RelationUpdate(
+        relationType, sourceIdentifier, sourceEntityType, targetsToAdd, 
targetsToRemove);
+  }
+
+  /**
+   * @return The type of relation.
+   */
+  public SupportsRelationOperations.Type relationType() {
+    return relationType;
+  }
+
+  /**
+   * @return The source entity identifier.
+   */
+  public NameIdentifier sourceIdentifier() {
+    return sourceIdentifier;
+  }
+
+  /**
+   * @return The source entity type.
+   */
+  public Entity.EntityType sourceEntityType() {
+    return sourceEntityType;
+  }
+
+  /**
+   * @return A copy of the target endpoint array to associate with the source 
entity.
+   */
+  public RelationEdgeTarget[] targetsToAdd() {
+    return targetsToAdd.clone();
+  }
+
+  /**
+   * @return A copy of the target endpoint array to disassociate from the 
source entity.
+   */
+  public RelationEdgeTarget[] targetsToRemove() {
+    return targetsToRemove.clone();
+  }
+
+  /**
+   * @return Whether any target endpoint carries relation values.
+   */
+  public boolean hasRelationValues() {
+    return Arrays.stream(targetsToAdd).anyMatch(target -> 
target.relationValue().isPresent())
+        || Arrays.stream(targetsToRemove).anyMatch(target -> 
target.relationValue().isPresent());
+  }
+
+  private static RelationEdgeTarget[] copyTargets(
+      RelationEdgeTarget[] targets, String parameterName) {
+    if (targets == null) {
+      return EMPTY_TARGETS;
+    }
+
+    for (RelationEdgeTarget target : targets) {
+      Preconditions.checkArgument(target != null, "%s must not contain null", 
parameterName);
+    }
+
+    return targets.clone();
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/SupportsRelationOperations.java 
b/core/src/main/java/org/apache/gravitino/SupportsRelationOperations.java
index 25c4fc60b1..ca1ba1c230 100644
--- a/core/src/main/java/org/apache/gravitino/SupportsRelationOperations.java
+++ b/core/src/main/java/org/apache/gravitino/SupportsRelationOperations.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino;
 
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.List;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 
@@ -60,7 +61,8 @@ public interface SupportsRelationOperations {
   }
 
   /**
-   * List the entities according to a given entity in a specific relation.
+   * List the entities according to a given entity in a specific relation. 
Compatibility adapter for
+   * {@link #listEntitiesByRelation(RelationQuery)}.
    *
    * @param <E> the type of entities returned.
    * @param relType The type of relation.
@@ -88,9 +90,27 @@ public interface SupportsRelationOperations {
    *  The end entity type is determined by the relType parameter and start 
entity.
    * </pre>
    */
-  <E extends Entity & HasIdentifier> List<E> listEntitiesByRelation(
+  default <E extends Entity & HasIdentifier> List<E> listEntitiesByRelation(
       Type relType, NameIdentifier nameIdentifier, Entity.EntityType 
identType, boolean allFields)
-      throws IOException;
+      throws IOException {
+    return listEntitiesByRelation(
+        RelationQuery.of(relType, nameIdentifier, identType, allFields, null));
+  }
+
+  /**
+   * Canonical relation query API. Implementations should add future 
relation-edge query attributes
+   * to {@link RelationQuery} instead of introducing new relation-specific 
overloads.
+   *
+   * @param <E> The type of entities returned.
+   * @param query The relation query.
+   * @return The list of entities.
+   * @throws IOException When occurs storage issues, it will throw IOException.
+   */
+  default <E extends Entity & HasIdentifier> List<E> 
listEntitiesByRelation(RelationQuery query)
+      throws IOException {
+    throw new UnsupportedOperationException(
+        "listEntitiesByRelation with RelationQuery is not supported by this 
implementation");
+  }
 
   /**
    * Retrieves the relations for a batch of source entities in a single call.
@@ -194,8 +214,15 @@ public interface SupportsRelationOperations {
   }
 
   /**
-   * Updates the relations for a given entity by adding a set of new relations 
and removing another
-   * set of relations.
+   * Updates the relations for a given source entity by adding a set of new 
relation targets and
+   * removing another set of relation targets. Compatibility adapter for {@link
+   * #updateEntityRelations(RelationUpdate)}.
+   *
+   * <p>For {@link Type#POLICY_METADATA_OBJECT_REL} and {@link 
Type#TAG_METADATA_OBJECT_REL}, this
+   * update adapter treats the source entity as the metadata object and the 
destination identifiers
+   * as policies or tags. Reverse traversal is done by {@link
+   * #listEntitiesByRelation(RelationQuery)} using the same relation type with 
the policy or tag as
+   * the query anchor.
    *
    * @param <E> The type of the entity returned in the list, which represents 
the final state of
    *     related entities.
@@ -217,7 +244,55 @@ public interface SupportsRelationOperations {
       NameIdentifier[] destEntitiesToAdd,
       NameIdentifier[] destEntitiesToRemove)
       throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
+    return updateEntityRelations(
+        RelationUpdate.of(
+            relType,
+            srcEntityIdent,
+            srcEntityType,
+            toRelationEdgeTargets(relType, srcEntityType, destEntitiesToAdd),
+            toRelationEdgeTargets(relType, srcEntityType, 
destEntitiesToRemove)));
+  }
+
+  /**
+   * Canonical relation update API. Implementations should add future 
relation-edge update
+   * attributes to {@link RelationUpdate} or {@link RelationEdgeTarget} 
instead of introducing new
+   * relation-specific overloads.
+   *
+   * @param <E> The type of the entity returned in the list, which represents 
the final state of
+   *     related entities.
+   * @param update The relation update.
+   * @return A list of entities that are related to the given entity after the 
update.
+   * @throws IOException If a storage-related error occurs.
+   * @throws NoSuchEntityException If any of the specified entities does not 
exist.
+   * @throws EntityAlreadyExistsException If a relation to be added already 
exists.
+   */
+  default <E extends Entity & HasIdentifier> List<E> 
updateEntityRelations(RelationUpdate update)
+      throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
     throw new UnsupportedOperationException(
-        "updateEntityRelations is not supported by this implementation");
+        "updateEntityRelations with RelationUpdate is not supported by this 
implementation");
+  }
+
+  private static RelationEdgeTarget[] toRelationEdgeTargets(
+      Type relType, Entity.EntityType srcEntityType, NameIdentifier[] 
nameIdentifiers) {
+    if (nameIdentifiers == null) {
+      return null;
+    }
+
+    Entity.EntityType targetEntityType = relationUpdateTargetType(relType, 
srcEntityType);
+    return Arrays.stream(nameIdentifiers)
+        .map(nameIdentifier -> RelationEdgeTarget.of(nameIdentifier, 
targetEntityType, null))
+        .toArray(RelationEdgeTarget[]::new);
+  }
+
+  private static Entity.EntityType relationUpdateTargetType(
+      Type relType, Entity.EntityType srcEntityType) {
+    switch (relType) {
+      case POLICY_METADATA_OBJECT_REL:
+        return Entity.EntityType.POLICY;
+      case TAG_METADATA_OBJECT_REL:
+        return Entity.EntityType.TAG;
+      default:
+        return srcEntityType;
+    }
   }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
index f14f41c9a1..cd303eb0a9 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
@@ -21,9 +21,11 @@ package org.apache.gravitino.storage.relational;
 import static org.apache.gravitino.Configs.ENTITY_RELATIONAL_STORE;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
@@ -41,6 +43,9 @@ import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.RelationEdgeTarget;
+import org.apache.gravitino.RelationQuery;
+import org.apache.gravitino.RelationUpdate;
 import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.SupportsExternalIdOperations;
 import org.apache.gravitino.SupportsRelationOperations;
@@ -471,22 +476,55 @@ public class RelationalEntityStore
       NameIdentifier[] destEntitiesToAdd,
       NameIdentifier[] destEntitiesToRemove)
       throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
+    return updateEntityRelations(
+        RelationUpdate.of(
+            relType,
+            srcEntityIdent,
+            srcEntityType,
+            toRelationEdgeTargets(relType, srcEntityType, destEntitiesToAdd),
+            toRelationEdgeTargets(relType, srcEntityType, 
destEntitiesToRemove)));
+  }
 
-    // Invalidate after the backend write, not before. Invalidating before 
creates a window where
-    // a concurrent read can repopulate the cache with stale pre-commit data.
-    List<E> result =
-        backend.updateEntityRelations(
-            relType, srcEntityIdent, srcEntityType, destEntitiesToAdd, 
destEntitiesToRemove);
-
-    cache.invalidate(srcEntityIdent, srcEntityType, relType);
-    for (NameIdentifier destToAdd : destEntitiesToAdd) {
-      cache.invalidate(destToAdd, srcEntityType, relType);
+  @Override
+  public <E extends Entity & HasIdentifier> List<E> 
listEntitiesByRelation(RelationQuery query)
+      throws IOException {
+    if (query.relationValue().isPresent()) {
+      return backend.listEntitiesByRelation(query);
     }
 
-    for (NameIdentifier destToRemove : destEntitiesToRemove) {
-      cache.invalidate(destToRemove, srcEntityType, relType);
+    return listEntitiesByRelation(
+        query.relationType(),
+        query.anchorIdentifier(),
+        query.anchorEntityType(),
+        query.allFields());
+  }
+
+  @Override
+  public <E extends Entity & HasIdentifier> List<E> 
updateEntityRelations(RelationUpdate update)
+      throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
+    validateRelationTargetTypes(update);
+
+    RelationEdgeTarget[] targetsToAdd = update.targetsToAdd();
+    RelationEdgeTarget[] targetsToRemove = update.targetsToRemove();
+    List<E> result;
+    if (update.hasRelationValues()) {
+      result = backend.updateEntityRelations(update);
+    } else {
+      result =
+          backend.updateEntityRelations(
+              update.relationType(),
+              update.sourceIdentifier(),
+              update.sourceEntityType(),
+              toNameIdentifiers(targetsToAdd),
+              toNameIdentifiers(targetsToRemove));
     }
 
+    Entity.EntityType targetEntityType =
+        relationUpdateTargetType(update.relationType(), 
update.sourceEntityType());
+    cache.invalidate(update.sourceIdentifier(), update.sourceEntityType(), 
update.relationType());
+    invalidateRelationTargetCache(update.relationType(), targetEntityType, 
targetsToAdd);
+    invalidateRelationTargetCache(update.relationType(), targetEntityType, 
targetsToRemove);
+
     return result;
   }
 
@@ -503,6 +541,62 @@ public class RelationalEntityStore
     backend.batchPut(entities, overwritten);
   }
 
+  private void invalidateRelationTargetCache(
+      Type relType, Entity.EntityType targetEntityType, RelationEdgeTarget[] 
relationTargets) {
+    for (RelationEdgeTarget relationTarget : relationTargets) {
+      cache.invalidate(relationTarget.nameIdentifier(), targetEntityType, 
relType);
+    }
+  }
+
+  private static void validateRelationTargetTypes(RelationUpdate update) {
+    Entity.EntityType targetEntityType =
+        relationUpdateTargetType(update.relationType(), 
update.sourceEntityType());
+    validateRelationTargetTypes(update.relationType(), targetEntityType, 
update.targetsToAdd());
+    validateRelationTargetTypes(update.relationType(), targetEntityType, 
update.targetsToRemove());
+  }
+
+  private static void validateRelationTargetTypes(
+      Type relType, Entity.EntityType targetEntityType, RelationEdgeTarget[] 
relationTargets) {
+    for (RelationEdgeTarget relationTarget : relationTargets) {
+      Preconditions.checkArgument(
+          relationTarget.entityType() == targetEntityType,
+          "Relation target type %s does not match expected destination type %s 
for relation type %s",
+          relationTarget.entityType(),
+          targetEntityType,
+          relType);
+    }
+  }
+
+  private static RelationEdgeTarget[] toRelationEdgeTargets(
+      Type relType, Entity.EntityType srcEntityType, NameIdentifier[] 
nameIdentifiers) {
+    if (nameIdentifiers == null) {
+      return new RelationEdgeTarget[0];
+    }
+
+    Entity.EntityType targetEntityType = relationUpdateTargetType(relType, 
srcEntityType);
+    return Arrays.stream(nameIdentifiers)
+        .map(nameIdentifier -> RelationEdgeTarget.of(nameIdentifier, 
targetEntityType, null))
+        .toArray(RelationEdgeTarget[]::new);
+  }
+
+  private static NameIdentifier[] toNameIdentifiers(RelationEdgeTarget[] 
relationTargets) {
+    return Arrays.stream(relationTargets)
+        .map(RelationEdgeTarget::nameIdentifier)
+        .toArray(NameIdentifier[]::new);
+  }
+
+  private static Entity.EntityType relationUpdateTargetType(
+      Type relType, Entity.EntityType srcEntityType) {
+    switch (relType) {
+      case POLICY_METADATA_OBJECT_REL:
+        return Entity.EntityType.POLICY;
+      case TAG_METADATA_OBJECT_REL:
+        return Entity.EntityType.TAG;
+      default:
+        return srcEntityType;
+    }
+  }
+
   private <E extends Entity & HasIdentifier> 
Optional<List<RelationalEntity<?>>> getCachedRelations(
       SupportsRelationOperations.Type relType,
       NameIdentifier nameIdentifier,
diff --git 
a/core/src/test/java/org/apache/gravitino/TestSupportsRelationOperations.java 
b/core/src/test/java/org/apache/gravitino/TestSupportsRelationOperations.java
new file mode 100644
index 0000000000..85e621a222
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/TestSupportsRelationOperations.java
@@ -0,0 +1,186 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestSupportsRelationOperations {
+
+  @Test
+  public void testRelationOperationsDoesNotExposeTagSpecificMethods() {
+    Set<String> methodNames =
+        Arrays.stream(SupportsRelationOperations.class.getDeclaredMethods())
+            .map(Method::getName)
+            .collect(Collectors.toSet());
+
+    Assertions.assertFalse(methodNames.contains("listMetadataObjectsForTag"));
+    Assertions.assertFalse(methodNames.contains("updateTagRelations"));
+  }
+
+  @Test
+  public void testRelationEdgeTargetCarriesOptionalRelationValue() {
+    NameIdentifier targetIdent = NameIdentifier.of("metalake", "tag");
+
+    RelationEdgeTarget targetWithValue =
+        RelationEdgeTarget.of(targetIdent, Entity.EntityType.TAG, "finance");
+    Assertions.assertEquals(Entity.EntityType.TAG, 
targetWithValue.entityType());
+    Assertions.assertEquals(targetIdent, targetWithValue.nameIdentifier());
+    Assertions.assertTrue(targetWithValue.relationValue().isPresent());
+    Assertions.assertEquals("finance", targetWithValue.relationValue().get());
+
+    RelationEdgeTarget targetWithNoValue =
+        RelationEdgeTarget.of(targetIdent, Entity.EntityType.TAG, null);
+    Assertions.assertEquals(Entity.EntityType.TAG, 
targetWithNoValue.entityType());
+    Assertions.assertEquals(targetIdent, targetWithNoValue.nameIdentifier());
+    Assertions.assertFalse(targetWithNoValue.relationValue().isPresent());
+  }
+
+  @Test
+  public void testPrimitiveListDelegatesToRelationQuery() throws IOException {
+    RecordingRelationOperations relationOperations = new 
RecordingRelationOperations();
+    NameIdentifier tagIdent = NameIdentifier.of("metalake", "tag");
+
+    relationOperations.listEntitiesByRelation(
+        SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+        tagIdent,
+        Entity.EntityType.TAG,
+        false);
+
+    RelationQuery query = relationOperations.relationQuery;
+    Assertions.assertEquals(
+        SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL, 
query.relationType());
+    Assertions.assertEquals(tagIdent, query.anchorIdentifier());
+    Assertions.assertEquals(Entity.EntityType.TAG, query.anchorEntityType());
+    Assertions.assertFalse(query.allFields());
+    Assertions.assertFalse(query.relationValue().isPresent());
+  }
+
+  @Test
+  public void testPrimitiveUpdateDelegatesToRelationUpdate()
+      throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
+    RecordingRelationOperations relationOperations = new 
RecordingRelationOperations();
+    NameIdentifier srcIdent = NameIdentifier.of("metalake", "catalog", 
"schema", "table");
+    NameIdentifier targetIdent = NameIdentifier.of("metalake", "tag");
+    NameIdentifier[] targetsToAdd = new NameIdentifier[] {targetIdent};
+    NameIdentifier[] targetsToRemove = new NameIdentifier[0];
+
+    relationOperations.updateEntityRelations(
+        SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+        srcIdent,
+        Entity.EntityType.TABLE,
+        targetsToAdd,
+        targetsToRemove);
+
+    RelationUpdate update = relationOperations.relationUpdate;
+    Assertions.assertEquals(
+        SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL, 
update.relationType());
+    Assertions.assertEquals(srcIdent, update.sourceIdentifier());
+    Assertions.assertEquals(Entity.EntityType.TABLE, 
update.sourceEntityType());
+    Assertions.assertEquals(1, update.targetsToAdd().length);
+    Assertions.assertEquals(targetIdent, 
update.targetsToAdd()[0].nameIdentifier());
+    Assertions.assertEquals(Entity.EntityType.TAG, 
update.targetsToAdd()[0].entityType());
+    
Assertions.assertFalse(update.targetsToAdd()[0].relationValue().isPresent());
+    Assertions.assertEquals(0, update.targetsToRemove().length);
+  }
+
+  @Test
+  public void testRelationUpdateCopiesTargetArrays() {
+    NameIdentifier srcIdent = NameIdentifier.of("metalake", "catalog", 
"schema", "table");
+    RelationEdgeTarget originalTarget =
+        RelationEdgeTarget.of(NameIdentifier.of("metalake", "tag"), 
Entity.EntityType.TAG, "dev");
+    RelationEdgeTarget replacementTarget =
+        RelationEdgeTarget.of(NameIdentifier.of("metalake", "tag2"), 
Entity.EntityType.TAG, "prod");
+    RelationEdgeTarget[] targetsToAdd = new RelationEdgeTarget[] 
{originalTarget};
+    RelationEdgeTarget[] targetsToRemove = new RelationEdgeTarget[] 
{originalTarget};
+
+    RelationUpdate update =
+        RelationUpdate.of(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            srcIdent,
+            Entity.EntityType.TABLE,
+            targetsToAdd,
+            targetsToRemove);
+
+    targetsToAdd[0] = replacementTarget;
+    targetsToRemove[0] = replacementTarget;
+    Assertions.assertSame(originalTarget, update.targetsToAdd()[0]);
+    Assertions.assertSame(originalTarget, update.targetsToRemove()[0]);
+
+    RelationEdgeTarget[] returnedTargetsToAdd = update.targetsToAdd();
+    RelationEdgeTarget[] returnedTargetsToRemove = update.targetsToRemove();
+    returnedTargetsToAdd[0] = replacementTarget;
+    returnedTargetsToRemove[0] = replacementTarget;
+    Assertions.assertSame(originalTarget, update.targetsToAdd()[0]);
+    Assertions.assertSame(originalTarget, update.targetsToRemove()[0]);
+  }
+
+  private static class RecordingRelationOperations implements 
SupportsRelationOperations {
+    private RelationQuery relationQuery;
+    private RelationUpdate relationUpdate;
+
+    @Override
+    public <E extends Entity & HasIdentifier> List<E> 
listEntitiesByRelation(RelationQuery query)
+        throws IOException {
+      this.relationQuery = query;
+      return List.of();
+    }
+
+    @Override
+    public List<RelationalEntity<?>> batchListEntitiesByRelation(
+        Type relType, List<NameIdentifier> nameIdentifiers, Entity.EntityType 
identType)
+        throws IOException {
+      return List.of();
+    }
+
+    @Override
+    public <E extends Entity & HasIdentifier> E getEntityByRelation(
+        Type relType,
+        NameIdentifier srcIdentifier,
+        Entity.EntityType srcType,
+        NameIdentifier destEntityIdent)
+        throws IOException, NoSuchEntityException {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void insertRelation(
+        Type relType,
+        NameIdentifier srcIdentifier,
+        Entity.EntityType srcType,
+        NameIdentifier dstIdentifier,
+        Entity.EntityType dstType,
+        boolean override)
+        throws IOException {}
+
+    @Override
+    public <E extends Entity & HasIdentifier> List<E> 
updateEntityRelations(RelationUpdate update)
+        throws IOException, NoSuchEntityException, 
EntityAlreadyExistsException {
+      this.relationUpdate = update;
+      return List.of();
+    }
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
index 042c29681b..e9451c5895 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
@@ -30,6 +30,9 @@ import org.apache.gravitino.Configs;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.RelationEdgeTarget;
+import org.apache.gravitino.RelationQuery;
+import org.apache.gravitino.RelationUpdate;
 import org.apache.gravitino.SupportsRelationOperations;
 import org.apache.gravitino.cache.NoOpsCache;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
@@ -178,22 +181,22 @@ public class TestRelationalEntityStore {
               Mockito.verify(cache, Mockito.never())
                   .invalidate(
                       destToAdd,
-                      Entity.EntityType.TABLE,
+                      Entity.EntityType.TAG,
                       SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
               Mockito.verify(cache, Mockito.never())
                   .invalidate(
                       destToRemove,
-                      Entity.EntityType.TABLE,
+                      Entity.EntityType.TAG,
                       SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
               return List.of();
             })
         .when(backend)
         .updateEntityRelations(
-            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
-            src,
-            Entity.EntityType.TABLE,
-            destEntitiesToAdd,
-            destEntitiesToRemove);
+            eq(SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL),
+            eq(src),
+            eq(Entity.EntityType.TABLE),
+            any(NameIdentifier[].class),
+            any(NameIdentifier[].class));
 
     store.updateEntityRelations(
         SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
@@ -206,11 +209,93 @@ public class TestRelationalEntityStore {
     inOrder
         .verify(backend)
         .updateEntityRelations(
+            eq(SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL),
+            eq(src),
+            eq(Entity.EntityType.TABLE),
+            any(NameIdentifier[].class),
+            any(NameIdentifier[].class));
+    inOrder
+        .verify(cache)
+        .invalidate(
+            src, Entity.EntityType.TABLE, 
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+    inOrder
+        .verify(cache)
+        .invalidate(
+            destToAdd,
+            Entity.EntityType.TAG,
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+    inOrder
+        .verify(cache)
+        .invalidate(
+            destToRemove,
+            Entity.EntityType.TAG,
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+  }
+
+  @Test
+  void testListEntitiesByRelationWithRelationValueDelegatesToBackend() throws 
IOException {
+    NameIdentifier tag = NameIdentifier.of("metalake", "tag1");
+    RelationQuery query =
+        RelationQuery.of(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            tag,
+            Entity.EntityType.TAG,
+            true,
+            "dev");
+
+    store.listEntitiesByRelation(query);
+
+    Mockito.verify(backend).listEntitiesByRelation(query);
+  }
+
+  @Test
+  void testUpdateRelationWithValuesInvalidatesCacheAfterBackendUpdate()
+      throws IOException, NoSuchEntityException, EntityAlreadyExistsException,
+          IllegalAccessException {
+    NameIdentifier src = NameIdentifier.of("metalake", "catalog", "schema", 
"table1");
+    NameIdentifier destToAdd = NameIdentifier.of("metalake", "tag1");
+    NameIdentifier destToRemove = NameIdentifier.of("metalake", "tag2");
+    RelationEdgeTarget[] destEntitiesToAdd =
+        new RelationEdgeTarget[] {RelationEdgeTarget.of(destToAdd, 
Entity.EntityType.TAG, "dev")};
+    RelationEdgeTarget[] destEntitiesToRemove =
+        new RelationEdgeTarget[] {
+          RelationEdgeTarget.of(destToRemove, Entity.EntityType.TAG, "prod")
+        };
+    RelationUpdate update =
+        RelationUpdate.of(
             SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
             src,
             Entity.EntityType.TABLE,
             destEntitiesToAdd,
             destEntitiesToRemove);
+    NoOpsCache cache = (NoOpsCache) FieldUtils.readField(store, "cache", true);
+
+    Mockito.doAnswer(
+            invocation -> {
+              Mockito.verify(cache, Mockito.never())
+                  .invalidate(
+                      src,
+                      Entity.EntityType.TABLE,
+                      SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+              Mockito.verify(cache, Mockito.never())
+                  .invalidate(
+                      destToAdd,
+                      Entity.EntityType.TAG,
+                      SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+              Mockito.verify(cache, Mockito.never())
+                  .invalidate(
+                      destToRemove,
+                      Entity.EntityType.TAG,
+                      SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+              return List.of();
+            })
+        .when(backend)
+        .updateEntityRelations(update);
+
+    store.updateEntityRelations(update);
+
+    InOrder inOrder = Mockito.inOrder(backend, cache);
+    inOrder.verify(backend).updateEntityRelations(update);
     inOrder
         .verify(cache)
         .invalidate(
@@ -219,13 +304,30 @@ public class TestRelationalEntityStore {
         .verify(cache)
         .invalidate(
             destToAdd,
-            Entity.EntityType.TABLE,
+            Entity.EntityType.TAG,
             SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
     inOrder
         .verify(cache)
         .invalidate(
             destToRemove,
-            Entity.EntityType.TABLE,
+            Entity.EntityType.TAG,
             SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
   }
+
+  @Test
+  void testUpdateRelationRejectsMismatchedTargetTypeBeforeBackendUpdate() {
+    NameIdentifier src = NameIdentifier.of("metalake", "catalog", "schema", 
"table1");
+    NameIdentifier tag = NameIdentifier.of("metalake", "tag1");
+    RelationUpdate update =
+        RelationUpdate.of(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            src,
+            Entity.EntityType.TABLE,
+            new RelationEdgeTarget[] {RelationEdgeTarget.of(tag, 
Entity.EntityType.TABLE, "dev")},
+            new RelationEdgeTarget[0]);
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
store.updateEntityRelations(update));
+    Mockito.verifyNoInteractions(backend);
+  }
 }

Reply via email to