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

shuwenwei pushed a commit to branch fix-copy-to-duplicate-tag-column
in repository https://gitbox.apache.org/repos/asf/iotdb.git

commit 5f8c9232c9bae09f23a2c2e03d07d0b729061ccb
Author: shuwenwei <[email protected]>
AuthorDate: Thu Aug 6 10:48:42 2026 +0800

    Fix silent deduplication of duplicate tag columns in COPY TO statement
    
    Previously, TAGS(device, device) in a COPY TO statement was silently
    deduplicated to [device] because the identifiers were added to a
    LinkedHashSet, so no error was reported and the file was generated with
    fewer tag columns than the user specified. The duplicate information was
    lost before CopyToTsFileOptions.check() could validate it.
    
    Also, duplicate option clauses (e.g. (tags(a), tags(b)) or (time x, time y))
    were silently overwritten by the last one in the CopyToOptions.Builder.
    
    Fix both cases at parse time in AstBuilder:
    - Detect duplicate tag columns in the TAGS clause and report
      "Duplicate tag column in TAGS clause: %s".
    - Track already-seen option keys and report
      "Duplicate option in COPY TO statement: %s".
    
    Add ITs covering both scenarios.
---
 .../query/recent/copyto/IoTDBCopyToTsFileIT.java   | 32 ++++++++++++++++++++++
 .../iotdb/db/i18n/DataNodeQueryMessages.java       |  4 +++
 .../iotdb/db/i18n/DataNodeQueryMessages.java       |  4 +++
 .../plan/relational/sql/parser/AstBuilder.java     | 31 ++++++++++++++++++++-
 4 files changed, 70 insertions(+), 1 deletion(-)

diff --git 
a/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/copyto/IoTDBCopyToTsFileIT.java
 
b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/copyto/IoTDBCopyToTsFileIT.java
index 04bed8b91a1..8e7cf274051 100644
--- 
a/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/copyto/IoTDBCopyToTsFileIT.java
+++ 
b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/copyto/IoTDBCopyToTsFileIT.java
@@ -276,6 +276,38 @@ public class IoTDBCopyToTsFileIT {
     }
   }
 
+  @Test
+  public void testDuplicateTagColumns()
+      throws IoTDBConnectionException, StatementExecutionException, 
IOException {
+    try (ITableSession session =
+        EnvFactory.getEnv().getTableSessionConnectionWithDB(DATABASE_NAME)) {
+      try {
+        session.executeQueryStatement(
+            "copy table1(time,tag1,tag2,s1) to 'dup.tsfile' with 
(tags(tag1,tag1), memory_threshold 1000000)");
+        Assert.fail("Should report duplicate tag column error");
+      } catch (StatementExecutionException e) {
+        Assert.assertTrue(
+            e.getMessage(), e.getMessage().contains("Duplicate tag column in 
TAGS clause: tag1"));
+      }
+    }
+  }
+
+  @Test
+  public void testDuplicateOption()
+      throws IoTDBConnectionException, StatementExecutionException, 
IOException {
+    try (ITableSession session =
+        EnvFactory.getEnv().getTableSessionConnectionWithDB(DATABASE_NAME)) {
+      try {
+        session.executeQueryStatement(
+            "copy table1(time,tag1,tag2,s1) to 'dup_option.tsfile' with 
(tags(tag1), tags(tag2), memory_threshold 1000000)");
+        Assert.fail("Should report duplicate option error");
+      } catch (StatementExecutionException e) {
+        Assert.assertTrue(
+            e.getMessage(), e.getMessage().contains("Duplicate option in COPY 
TO statement: TAGS"));
+      }
+    }
+  }
+
   @Test
   public void testCopyWithSpecifiedTag()
       throws IoTDBConnectionException, StatementExecutionException, 
IOException {
diff --git 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
index 40e7ed020c3..0e9e8158ac7 100644
--- 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
@@ -2424,6 +2424,10 @@ public final class DataNodeQueryMessages {
       "Note that the syntax for 'set configuration' in the tree model is not 
exactly the same as that in the table model.";
   public static final String UNSUPPORTED_COPY_TO_FORMAT_S_SUPPORTED_FORMATS_S =
       "Unsupported COPY TO format '%s'. Supported formats: %s";
+  public static final String 
EXCEPTION_DUPLICATE_TAG_COLUMN_IN_TAGS_CLAUSE_ARG_61FD5422 =
+      "Duplicate tag column in TAGS clause: %s";
+  public static final String 
EXCEPTION_DUPLICATE_OPTION_IN_COPY_TO_STATEMENT_ARG_99CFE09F =
+      "Duplicate option in COPY TO statement: %s";
   public static final String 
SIMULTANEOUS_SETTING_OF_MONTHLY_AND_NON_MONTHLY_INTERVALS_IS_NOT_SUPPORTED =
       "Simultaneous setting of monthly and non-monthly intervals is not 
supported.";
   public static final String 
DON_T_NEED_TO_SPECIFY_TIME_COLUMN_WHILE_EITHER_TIME_BOUND_OR_FILL_GROUP_PARAMETER_IS_NOT
 =
diff --git 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
index f236a57f6a4..1ab922fa121 100644
--- 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
@@ -2799,6 +2799,10 @@ public final class DataNodeQueryMessages {
   public static final String UNSUPPORTED_COPY_TO_FORMAT_S_SUPPORTED_FORMATS_S =
 
       "不支持 COPY_TO 格式 '%s'。支持的格式:%s";
+  public static final String 
EXCEPTION_DUPLICATE_TAG_COLUMN_IN_TAGS_CLAUSE_ARG_61FD5422 =
+      "TAGS 子句中存在重复的 TAG 列:%s";
+  public static final String 
EXCEPTION_DUPLICATE_OPTION_IN_COPY_TO_STATEMENT_ARG_99CFE09F =
+      "COPY TO 语句中存在重复的选项:%s";
   public static final String 
SIMULTANEOUS_SETTING_OF_MONTHLY_AND_NON_MONTHLY_INTERVALS_IS_NOT_SUPPORTED =
       "不支持同时设置月级和非月级时间间隔。";
   public static final String 
DON_T_NEED_TO_SPECIFY_TIME_COLUMN_WHILE_EITHER_TIME_BOUND_OR_FILL_GROUP_PARAMETER_IS_NOT
 =
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
index 7db81826ab6..87e1b417e44 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
@@ -2270,8 +2270,17 @@ public class AstBuilder extends 
RelationalSqlBaseVisitor<Node> {
     String targetFileName = parseStringLiteral(ctx.fileName.getText());
     CopyToOptions.Builder copyToOptionsBuilder = new CopyToOptions.Builder();
     if (optionsContext != null) {
+      Set<String> optionKeys = new HashSet<>();
       for (RelationalSqlParser.CopyToStatementOptionContext context :
           optionsContext.copyToStatementOption()) {
+        String optionKey = getCopyToOptionKey(context);
+        if (!optionKeys.add(optionKey)) {
+          throw new SemanticException(
+              String.format(
+                  DataNodeQueryMessages
+                      
.EXCEPTION_DUPLICATE_OPTION_IN_COPY_TO_STATEMENT_ARG_99CFE09F,
+                  optionKey));
+        }
         addCopyToOption(copyToOptionsBuilder, context);
       }
     }
@@ -2296,6 +2305,20 @@ public class AstBuilder extends 
RelationalSqlBaseVisitor<Node> {
     return new CopyTo(queryNode, targetFileName, copyToOptionsBuilder.build());
   }
 
+  private String 
getCopyToOptionKey(RelationalSqlParser.CopyToStatementOptionContext context) {
+    if (context.FORMAT() != null) {
+      return "FORMAT";
+    } else if (context.TABLE() != null) {
+      return "TABLE";
+    } else if (context.TIME() != null) {
+      return "TIME";
+    } else if (context.TAGS() != null) {
+      return "TAGS";
+    } else {
+      return "MEMORY_THRESHOLD";
+    }
+  }
+
   private void addCopyToOption(
       CopyToOptions.Builder builder, 
RelationalSqlParser.CopyToStatementOptionContext context) {
     if (context.FORMAT() != null) {
@@ -2325,7 +2348,13 @@ public class AstBuilder extends 
RelationalSqlBaseVisitor<Node> {
           context.identifierList().identifier();
       Set<String> targetTagColumns = new 
LinkedHashSet<>(identifierList.size());
       for (RelationalSqlParser.IdentifierContext identifierContext : 
identifierList) {
-        targetTagColumns.add(((Identifier) 
visit(identifierContext)).getValue());
+        String tagColumnName = ((Identifier) 
visit(identifierContext)).getValue();
+        if (!targetTagColumns.add(tagColumnName)) {
+          throw new SemanticException(
+              String.format(
+                  
DataNodeQueryMessages.EXCEPTION_DUPLICATE_TAG_COLUMN_IN_TAGS_CLAUSE_ARG_61FD5422,
+                  tagColumnName));
+        }
       }
       builder.withTargetTagColumns(targetTagColumns);
     } else if (context.MEMORY_THRESHOLD() != null) {

Reply via email to