Copilot commented on code in PR #12234:
URL: https://github.com/apache/gravitino/pull/12234#discussion_r3671970828


##########
scripts/postgresql/schema-2.0.0-postgresql.sql:
##########
@@ -523,21 +525,25 @@ CREATE TABLE IF NOT EXISTS tag_relation_meta (
     tag_id BIGINT NOT NULL,
     metadata_object_id BIGINT NOT NULL,
     metadata_object_type VARCHAR(64) NOT NULL,
+    tag_value VARCHAR(256) DEFAULT NULL,
+    value_order INT NOT NULL DEFAULT 0,
     audit_info TEXT NOT NULL,
     current_version INT NOT NULL DEFAULT 1,
     last_version INT NOT NULL DEFAULT 1,
     deleted_at BIGINT NOT NULL DEFAULT 0,
-    PRIMARY KEY (id),
-    UNIQUE (tag_id, metadata_object_id, metadata_object_type, deleted_at)
+    PRIMARY KEY (id)
 );

Review Comment:
   The previous uniqueness constraint preventing duplicate active tag-object 
relations was removed, but no replacement constraint/index was added for the 
new multi-valued model. This can allow duplicate active rows for the same 
(tag_id, metadata_object_id, metadata_object_type, tag_value), and even 
multiple NULL tag_value rows (valueless assignment), especially under 
concurrent updates—leading to inconsistent reads and unbounded relation growth. 
Consider adding a uniqueness guarantee such as: (1) a unique constraint/index 
on (tag_id, metadata_object_id, metadata_object_type, tag_value, deleted_at) 
for valued rows, and (2) a separate mechanism to ensure at most one active NULL 
tag_value row per tag/object (e.g., partial unique index in PostgreSQL, or a 
generated/coalesced key / sentinel approach in MySQL/H2). The same issue 
appears in the MySQL and H2 schema diffs where the prior UNIQUE KEY was removed.



##########
core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TagMetadataObjectRelBaseSQLProvider.java:
##########
@@ -130,6 +160,30 @@ public String 
batchDeleteTagMetadataObjectRelsByTagIdsAndMetadataObject(
         + "</script>";
   }
 
+  public String 
batchDeleteTagMetadataObjectRelsByTagIdsAndValuesAndMetadataObject(
+      @Param("metadataObjectId") Long metadataObjectId,
+      @Param("metadataObjectType") String metadataObjectType,
+      @Param("tagRels") List<TagMetadataObjectRelPO> tagRelPOs) {
+    return "<script>"
+        + "UPDATE "
+        + TagMetadataObjectRelMapper.TAG_METADATA_OBJECT_RELATION_TABLE_NAME
+        + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
+        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+        + " WHERE metadata_object_id = #{metadataObjectId}"
+        + " AND metadata_object_type = #{metadataObjectType} AND deleted_at = 
0"
+        + " AND ("
+        + "<foreach item='item' collection='tagRels' separator=' OR '>"
+        + "(tag_id = #{item.tagId}"
+        + "<choose>"
+        + "<when test='item.tagValue == null'> AND tag_value IS NULL</when>"
+        + "<otherwise> AND tag_value = #{item.tagValue}</otherwise>"
+        + "</choose>"

Review Comment:
   Building the WHERE clause as a long 'OR' chain can become a query-planning 
and execution bottleneck as the number of removals grows (and can hit SQL 
length limits). A more scalable approach is to use a set-based form: e.g., 
`(tag_id, tag_value) IN (...)` for non-null values plus a separate `tag_value 
IS NULL` branch, or (DB-specific) joining against a derived table/VALUES list 
of (tag_id, tag_value) pairs. This typically produces better plans and avoids 
very large OR expressions.



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/TagMetaService.java:
##########
@@ -291,80 +311,188 @@ public List<GenericEntity> 
listAssociatedMetadataObjectsForTag(NameIdentifier ta
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "associateTagsWithMetadataObject")
-  public List<TagEntity> associateTagsWithMetadataObject(
+  public List<AssignedTagEntity> associateTagsWithMetadataObject(
       NameIdentifier objectIdent,
       Entity.EntityType objectType,
       NameIdentifier[] tagsToAdd,
       NameIdentifier[] tagsToRemove)
       throws NoSuchEntityException, EntityAlreadyExistsException, IOException {
+    return associateTagValuesWithMetadataObject(
+        objectIdent,
+        objectType,
+        toValuelessTagValues(tagsToAdd),
+        toValuelessTagValues(tagsToRemove),
+        true /* failOnDuplicateValuelessAssignment */);
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "associateTagValuesWithMetadataObject")
+  public List<AssignedTagEntity> associateTagValuesWithMetadataObject(
+      NameIdentifier objectIdent,
+      Entity.EntityType objectType,
+      TagValue[] tagsToAdd,
+      TagValue[] tagsToRemove)
+      throws NoSuchEntityException, EntityAlreadyExistsException, IOException {
+    return associateTagValuesWithMetadataObject(
+        objectIdent,
+        objectType,
+        tagsToAdd,
+        tagsToRemove,
+        false /* failOnDuplicateValuelessAssignment */);
+  }
+
+  private List<AssignedTagEntity> associateTagValuesWithMetadataObject(
+      NameIdentifier objectIdent,
+      Entity.EntityType objectType,
+      TagValue[] tagsToAdd,
+      TagValue[] tagsToRemove,
+      boolean failOnDuplicateValuelessAssignment)
+      throws NoSuchEntityException, EntityAlreadyExistsException, IOException {
     MetadataObject metadataObject = 
NameIdentifierUtil.toMetadataObject(objectIdent, objectType);
     String metalake = objectIdent.namespace().level(0);
 
     try {
       Long metadataObjectId = EntityIdService.getEntityId(objectIdent, 
objectType);
-
-      // Fetch all the tags need to associate with the metadata object.
-      List<String> tagNamesToAdd =
-          
Arrays.stream(tagsToAdd).map(NameIdentifier::name).collect(Collectors.toList());
-      List<TagPO> tagPOsToAdd =
-          tagNamesToAdd.isEmpty()
+      List<TagValue> tagValuesToAdd = new 
ArrayList<>(Arrays.asList(nullToEmpty(tagsToAdd)));
+      List<TagValue> tagValuesToRemove = new 
ArrayList<>(Arrays.asList(nullToEmpty(tagsToRemove)));
+      Set<TagValue> commonTagValues = new LinkedHashSet<>(tagValuesToAdd);
+      commonTagValues.retainAll(tagValuesToRemove);
+      tagValuesToAdd.removeAll(commonTagValues);
+      tagValuesToRemove.removeAll(commonTagValues);
+
+      List<String> tagNamesToUpdate = tagNamesToUpdate(tagValuesToAdd, 
tagValuesToRemove);
+      List<TagPO> tagPOsToUpdate =
+          tagNamesToUpdate.isEmpty()
               ? Collections.emptyList()
-              : getTagPOsByMetalakeAndNames(metalake, tagNamesToAdd);
+              : getTagPOsByMetalakeAndNames(metalake, tagNamesToUpdate);
+      Map<String, TagPO> tagPOsByName = tagPOsByName(tagPOsToUpdate);
 
-      // Fetch all the tags need to remove from the metadata object.
-      List<String> tagNamesToRemove =
-          
Arrays.stream(tagsToRemove).map(NameIdentifier::name).collect(Collectors.toList());
-      List<TagPO> tagPOsToRemove =
-          tagNamesToRemove.isEmpty()
-              ? Collections.emptyList()
-              : getTagPOsByMetalakeAndNames(metalake, tagNamesToRemove);
+      List<TagPO> currentTagPOs =
+          SessionUtils.getWithoutCommit(
+              TagMetadataObjectRelMapper.class,
+              mapper ->
+                  mapper.listTagPOsByMetadataObjectIdAndType(
+                      metadataObjectId, metadataObject.type().toString()));
+      Map<Long, Set<Optional<String>>> activeValuesByTagId = new 
LinkedHashMap<>();
+      Map<Long, Integer> maxValueOrderByTagId = new LinkedHashMap<>();
+      for (TagPO currentTagPO : currentTagPOs) {
+        trackExistingAssignment(currentTagPO, activeValuesByTagId, 
maxValueOrderByTagId);
+      }
+
+      List<Long> tagIdsToRemove = new ArrayList<>();
+      List<TagMetadataObjectRelPO> tagRelsToRemove = new ArrayList<>();
+      for (TagValue tagValueToRemove : tagValuesToRemove) {
+        TagPO tagPO = tagPOsByName.get(tagValueToRemove.name());
+        if (tagPO == null) {
+          continue;
+        }
+
+        if (tagValueToRemove.value().isPresent()) {
+          activeValuesByTagId
+              .computeIfAbsent(tagPO.getTagId(), ignored -> new 
LinkedHashSet<>())
+              .remove(tagValueToRemove.value());
+          tagRelsToRemove.add(
+              tagRelForValue(tagPO, metadataObjectId, metadataObject, 
tagValueToRemove));
+        } else {
+          activeValuesByTagId.remove(tagPO.getTagId());
+          maxValueOrderByTagId.remove(tagPO.getTagId());
+          tagIdsToRemove.add(tagPO.getTagId());
+        }
+      }
+
+      List<TagMetadataObjectRelPO> tagRelsToAdd = new ArrayList<>();
+      for (TagValue tagValueToAdd : tagValuesToAdd) {
+        TagPO tagPO = tagPOsByName.get(tagValueToAdd.name());
+        if (tagPO == null) {
+          continue;
+        }
+
+        validateAllowedValue(tagPO, tagValueToAdd);
+        Set<Optional<String>> activeValues =
+            activeValuesByTagId.computeIfAbsent(tagPO.getTagId(), ignored -> 
new LinkedHashSet<>());
+        Optional<String> value = tagValueToAdd.value();
+        if (activeValues.contains(value)) {
+          if (failOnDuplicateValuelessAssignment && !value.isPresent()) {
+            throw new EntityAlreadyExistsException(
+                "Tag %s is already associated to metadata object %s",
+                tagValueToAdd.name(), metadataObject);
+          }
+          continue;
+        }
+
+        if (value.isPresent()) {
+          if (activeValues.remove(Optional.empty())) {
+            tagRelsToRemove.add(
+                tagRelForValue(
+                    tagPO,
+                    metadataObjectId,
+                    metadataObject,
+                    TagValue.noValue(tagValueToAdd.name())));
+          }
+        } else {
+          Preconditions.checkArgument(
+              activeValues.isEmpty(),
+              "Cannot add tag with no value %s while valued assignments are 
active",
+              tagValueToAdd.name());

Review Comment:
   This error message is hard to action because it doesn’t identify which 
metadata object is being updated and the phrasing is a bit ambiguous. Consider 
including the metadata object identifier/type (or `metadataObject`) and 
clarifying that the tag already has valued assignments, e.g., 'Cannot add a 
valueless assignment for tag X on metadata object Y because valued assignments 
exist'.



##########
core/src/main/java/org/apache/gravitino/meta/AssignedTagEntity.java:
##########
@@ -0,0 +1,133 @@
+/*
+ * 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.meta;
+
+import com.google.common.base.Preconditions;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.apache.gravitino.Audit;
+import org.apache.gravitino.Auditable;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.Field;
+import org.apache.gravitino.HasIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagAssignment;
+import org.apache.gravitino.tag.TagValueConstraint;
+
+/** A tag entity in a metadata-object relation context, with assignment values 
from the relation. */
+public final class AssignedTagEntity implements Tag, Entity, Auditable, 
HasIdentifier {
+
+  private final TagEntity tagEntity;
+  private final TagAssignment assignment;
+
+  private AssignedTagEntity(TagEntity tagEntity, TagAssignment assignment) {
+    this.tagEntity = tagEntity;
+    this.assignment = assignment;
+  }
+
+  /**
+   * Creates an assigned tag entity.
+   *
+   * @param tagEntity The tag definition entity.
+   * @param assignmentValues The assignment values from the relation row. 
Empty means no value.
+   * @return The assigned tag entity.
+   */
+  public static AssignedTagEntity of(TagEntity tagEntity, String[] 
assignmentValues) {
+    Preconditions.checkArgument(tagEntity != null, "tagEntity must not be 
null");
+    String[] values = assignmentValues == null ? new String[0] : 
assignmentValues.clone();
+    TagAssignment assignment =
+        values.length == 0 ? TagAssignment.noValue() : 
TagAssignment.ofValues(values);
+    return new AssignedTagEntity(tagEntity, assignment);
+  }
+
+  @Override
+  public Map<Field, Object> fields() {
+    return tagEntity.fields();
+  }

Review Comment:
   `AssignedTagEntity` is introduced explicitly to carry relation-context 
assignment values, but `fields()` currently delegates to `TagEntity.fields()` 
and does not expose assignment values through the `Entity` field map. If any 
generic serialization / projection / logging relies on `Entity.fields()` 
(rather than `Tag.assignment()`), assignment values may be silently dropped. 
Consider adding an `ASSIGNMENT` (or similar) field to 
`AssignedTagEntity.fields()` that reflects the assignment state/values, or 
clearly documenting that assignment values are only accessible via 
`Tag.assignment()` and not via `Entity.fields()`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to