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

clintropolis pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 7d1eed5655f feat: better validation for catalog types, more permissive 
parsing for native type strings (#19769)
7d1eed5655f is described below

commit 7d1eed5655f9fc4c773b8b065f9c9cc417f09685
Author: Clint Wylie <[email protected]>
AuthorDate: Wed Jul 29 23:17:03 2026 -0700

    feat: better validation for catalog types, more permissive parsing for 
native type strings (#19769)
---
 .../org/apache/druid/catalog/CatalogException.java | 15 +++++
 .../apache/druid/catalog/http/CatalogResource.java | 13 ++--
 .../org/apache/druid/catalog/http/TableEditor.java |  5 +-
 .../server/http/catalog/CatalogResourceTest.java   |  8 +++
 .../org/apache/druid/segment/column/Types.java     | 18 +++---
 .../apache/druid/math/expr/ExpressionTypeTest.java | 16 +++--
 .../druid/segment/column/ColumnTypeTest.java       | 16 +++--
 .../ClusteredValueGroupsBaseTableMetadata.java     | 14 +++++
 .../org/apache/druid/catalog/model/TableDefn.java  | 20 ++++++
 .../druid/catalog/model/table/DatasourceDefn.java  | 10 ---
 .../catalog/model/table/ExternalTableDefn.java     |  7 ---
 .../ClusteredValueGroupsBaseTableMetadataTest.java | 34 +++++++++-
 .../catalog/model/table/DatasourceTableTest.java   | 36 ++++++++++-
 .../catalog/model/table/ExternalTableTest.java     | 24 +++++++
 .../druid/sql/calcite/external/Externals.java      | 10 ++-
 .../druid/sql/calcite/IngestTableFunctionTest.java | 73 ++++++++++++++++++++++
 16 files changed, 273 insertions(+), 46 deletions(-)

diff --git 
a/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/CatalogException.java
 
b/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/CatalogException.java
index 3b71af7af0c..ddf2142dfe3 100644
--- 
a/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/CatalogException.java
+++ 
b/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/CatalogException.java
@@ -20,6 +20,8 @@
 package org.apache.druid.catalog;
 
 import com.google.common.collect.ImmutableMap;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.StringUtils;
 
 import javax.ws.rs.core.Response;
@@ -86,6 +88,19 @@ public class CatalogException extends Exception
     );
   }
 
+  /**
+   * Converts a table validation failure into a bad request: {@link IAE} (the 
traditional catalog validation
+   * exception) and invalid-input {@link DruidException}s become bad requests; 
a {@link DruidException} of any other
+   * category is a genuine server error, and is rethrown rather than being 
misreported as a problem with the request.
+   */
+  public static CatalogException validationError(RuntimeException e)
+  {
+    if (e instanceof DruidException && ((DruidException) e).getCategory() != 
DruidException.Category.INVALID_INPUT) {
+      throw e;
+    }
+    return badRequest(e.getMessage());
+  }
+
   public Response toResponse()
   {
     return Response
diff --git 
a/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/http/CatalogResource.java
 
b/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/http/CatalogResource.java
index a766b86caec..f96f042e722 100644
--- 
a/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/http/CatalogResource.java
+++ 
b/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/http/CatalogResource.java
@@ -31,6 +31,7 @@ import org.apache.druid.catalog.model.TableMetadata;
 import org.apache.druid.catalog.model.TableSpec;
 import org.apache.druid.catalog.storage.CatalogStorage;
 import org.apache.druid.common.utils.IdUtils;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.Pair;
 import org.apache.druid.java.util.common.StringUtils;
@@ -140,8 +141,8 @@ public class CatalogResource
       try {
         catalog.validate(table);
       }
-      catch (IAE e) {
-        throw CatalogException.badRequest(e.getMessage());
+      catch (IAE | DruidException e) {
+        throw CatalogException.validationError(e);
       }
 
       long newVersion;
@@ -271,6 +272,10 @@ public class CatalogResource
     catch (CatalogException e) {
       return e.toResponse();
     }
+    catch (IAE | DruidException e) {
+      // Edits merge and re-validate the table spec, so validation failures 
surface here.
+      return CatalogException.validationError(e).toResponse();
+    }
   }
 
   // ---------------------------------------------------------------------
@@ -524,8 +529,8 @@ public class CatalogResource
     try {
       spec.validate();
     }
-    catch (IAE e) {
-      throw CatalogException.badRequest(e.getMessage());
+    catch (IAE | DruidException e) {
+      throw CatalogException.validationError(e);
     }
 
     if (!schema.accepts(spec.type())) {
diff --git 
a/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/http/TableEditor.java
 
b/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/http/TableEditor.java
index f7a92d61f40..b3a762b0568 100644
--- 
a/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/http/TableEditor.java
+++ 
b/extensions-core/druid-catalog/src/main/java/org/apache/druid/catalog/http/TableEditor.java
@@ -36,6 +36,7 @@ import org.apache.druid.catalog.model.TableMetadata;
 import org.apache.druid.catalog.model.TableSpec;
 import org.apache.druid.catalog.model.table.DatasourceDefn;
 import org.apache.druid.catalog.storage.CatalogStorage;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.utils.CollectionUtils;
 
@@ -312,8 +313,8 @@ public class TableEditor
     try {
       defn.validateColumns(revised);
     }
-    catch (IAE e) {
-      throw CatalogException.badRequest(e.getMessage());
+    catch (IAE | DruidException e) {
+      throw CatalogException.validationError(e);
     }
     return existingSpec.withColumns(revised);
   }
diff --git 
a/extensions-core/druid-catalog/src/test/java/org/apache/druid/server/http/catalog/CatalogResourceTest.java
 
b/extensions-core/druid-catalog/src/test/java/org/apache/druid/server/http/catalog/CatalogResourceTest.java
index 066d5a44cad..8f99eec43f5 100644
--- 
a/extensions-core/druid-catalog/src/test/java/org/apache/druid/server/http/catalog/CatalogResourceTest.java
+++ 
b/extensions-core/druid-catalog/src/test/java/org/apache/druid/server/http/catalog/CatalogResourceTest.java
@@ -146,6 +146,14 @@ public class CatalogResourceTest
     resp = resource.postTable(TableId.DRUID_SCHEMA, tableName, dsSpec, 0, 
false, postBy(CatalogTests.WRITER_USER));
     assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
resp.getStatus());
 
+    // Invalid column type: table-level validation failures (which raise 
DruidException rather than IAE) must also
+    // surface as a bad request, not an internal error.
+    TableSpec badTypeSpec = TableBuilder.datasource("badType", "P1D")
+        .column("foo", "FOO")
+        .buildSpec();
+    resp = resource.postTable(TableId.DRUID_SCHEMA, "badType", badTypeSpec, 0, 
false, postBy(CatalogTests.SUPER_USER));
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
resp.getStatus());
+
     // Inline input source
     TableSpec inputSpec = TableBuilder.external("inline")
         .inputSource(toMap(new InlineInputSource("a,b,1\nc,d,2\n")))
diff --git 
a/processing/src/main/java/org/apache/druid/segment/column/Types.java 
b/processing/src/main/java/org/apache/druid/segment/column/Types.java
index 831f5c76ce9..469a7e39d0a 100644
--- a/processing/src/main/java/org/apache/druid/segment/column/Types.java
+++ b/processing/src/main/java/org/apache/druid/segment/column/Types.java
@@ -19,7 +19,6 @@
 
 package org.apache.druid.segment.column;
 
-import com.google.common.base.Preconditions;
 import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.StringUtils;
 
@@ -39,7 +38,8 @@ public class Types
     if (typeString == null) {
       return null;
     }
-    switch (StringUtils.toUpperCase(typeString)) {
+    final String upperTypeString = StringUtils.toUpperCase(typeString);
+    switch (upperTypeString) {
       case "STRING":
         return typeFactory.ofString();
       case "LONG":
@@ -57,14 +57,16 @@ public class Types
       case "COMPLEX":
         return typeFactory.ofComplex(null);
       default:
-        // we do not convert to uppercase here, because complex type name must 
be preserved in original casing
-        // array could be converted, but are not for no particular reason 
other than less spooky magic
-        if (typeString.startsWith(ARRAY_PREFIX)) {
+        // Prefix matching is case-insensitive, consistent with the scalar 
handling above, but the type parameter is
+        // taken from the original string: complex type names are 
case-sensitive registry keys which must be preserved
+        // in their original casing.
+        if (upperTypeString.startsWith(ARRAY_PREFIX) && 
upperTypeString.endsWith(">")) {
           T elementType = fromString(typeFactory, 
typeString.substring(ARRAY_PREFIX.length(), typeString.length() - 1));
-          Preconditions.checkNotNull(elementType, "Array element type must not 
be null");
-          return typeFactory.ofArray(elementType);
+          if (elementType != null) {
+            return typeFactory.ofArray(elementType);
+          }
         }
-        if (typeString.startsWith(COMPLEX_PREFIX)) {
+        if (upperTypeString.startsWith(COMPLEX_PREFIX) && 
upperTypeString.endsWith(">")) {
           return 
typeFactory.ofComplex(typeString.substring(COMPLEX_PREFIX.length(), 
typeString.length() - 1));
         }
     }
diff --git 
a/processing/src/test/java/org/apache/druid/math/expr/ExpressionTypeTest.java 
b/processing/src/test/java/org/apache/druid/math/expr/ExpressionTypeTest.java
index 52c8994c779..c73e6973432 100644
--- 
a/processing/src/test/java/org/apache/druid/math/expr/ExpressionTypeTest.java
+++ 
b/processing/src/test/java/org/apache/druid/math/expr/ExpressionTypeTest.java
@@ -67,12 +67,18 @@ public class ExpressionTypeTest
     Assertions.assertEquals(ExpressionType.STRING_ARRAY, 
MAPPER.readValue("\"string_array\"", ExpressionType.class));
     Assertions.assertEquals(ExpressionType.LONG_ARRAY, 
MAPPER.readValue("\"long_array\"", ExpressionType.class));
     Assertions.assertEquals(ExpressionType.DOUBLE_ARRAY, 
MAPPER.readValue("\"double_array\"", ExpressionType.class));
-    // ARRAY<*> and COMPLEX<*> patterns must match exactly ...
-    Assertions.assertNotEquals(ExpressionType.STRING_ARRAY, 
MAPPER.readValue("\"array<string>\"", ExpressionType.class));
-    Assertions.assertNotEquals(ExpressionType.LONG_ARRAY, 
MAPPER.readValue("\"array<LONG>\"", ExpressionType.class));
-    Assertions.assertNotEquals(SOME_COMPLEX, 
MAPPER.readValue("\"COMPLEX<FOO>\"", ExpressionType.class));
-    // this works though because array recursively calls on element type...
+    // the ARRAY<*> and COMPLEX<*> prefixes match case-insensitively, like the 
scalar type names ...
+    Assertions.assertEquals(ExpressionType.STRING_ARRAY, 
MAPPER.readValue("\"array<string>\"", ExpressionType.class));
+    Assertions.assertEquals(ExpressionType.LONG_ARRAY, 
MAPPER.readValue("\"array<LONG>\"", ExpressionType.class));
     Assertions.assertEquals(ExpressionType.DOUBLE_ARRAY, 
MAPPER.readValue("\"ARRAY<double>\"", ExpressionType.class));
+    Assertions.assertEquals(SOME_COMPLEX, MAPPER.readValue("\"complex<foo>\"", 
ExpressionType.class));
+    // ... but the complex type name inside the brackets is a case-sensitive 
registry key, preserved as written
+    Assertions.assertNotEquals(SOME_COMPLEX, 
MAPPER.readValue("\"COMPLEX<FOO>\"", ExpressionType.class));
+    // a parameterized type missing its closing bracket is malformed, not a 
truncated type parameter
+    Assertions.assertNull(MAPPER.readValue("\"COMPLEX<foo\"", 
ExpressionType.class));
+    // an unrecognized array element type makes the whole array type 
unrecognized, rather than an error
+    Assertions.assertNull(MAPPER.readValue("\"ARRAY<FOO>\"", 
ExpressionType.class));
+    Assertions.assertNull(MAPPER.readValue("\"ARRAY<ARRAY<FOO>>\"", 
ExpressionType.class));
   }
 
   @Test
diff --git 
a/processing/src/test/java/org/apache/druid/segment/column/ColumnTypeTest.java 
b/processing/src/test/java/org/apache/druid/segment/column/ColumnTypeTest.java
index 88e167614fc..6302ea96c74 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/column/ColumnTypeTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/column/ColumnTypeTest.java
@@ -69,12 +69,18 @@ public class ColumnTypeTest
     Assertions.assertEquals(ColumnType.STRING_ARRAY, 
MAPPER.readValue("\"string_array\"", ColumnType.class));
     Assertions.assertEquals(ColumnType.LONG_ARRAY, 
MAPPER.readValue("\"long_array\"", ColumnType.class));
     Assertions.assertEquals(ColumnType.DOUBLE_ARRAY, 
MAPPER.readValue("\"double_array\"", ColumnType.class));
-    // ARRAY<*> and COMPLEX<*> patterns must match exactly ...
-    Assertions.assertNotEquals(ColumnType.STRING_ARRAY, 
MAPPER.readValue("\"array<string>\"", ColumnType.class));
-    Assertions.assertNotEquals(ColumnType.LONG_ARRAY, 
MAPPER.readValue("\"array<LONG>\"", ColumnType.class));
-    Assertions.assertNotEquals(SOME_COMPLEX, 
MAPPER.readValue("\"COMPLEX<FOO>\"", ColumnType.class));
-    // this works though because array recursively calls on element type...
+    // the ARRAY<*> and COMPLEX<*> prefixes match case-insensitively, like the 
scalar type names ...
+    Assertions.assertEquals(ColumnType.STRING_ARRAY, 
MAPPER.readValue("\"array<string>\"", ColumnType.class));
+    Assertions.assertEquals(ColumnType.LONG_ARRAY, 
MAPPER.readValue("\"array<LONG>\"", ColumnType.class));
     Assertions.assertEquals(ColumnType.DOUBLE_ARRAY, 
MAPPER.readValue("\"ARRAY<double>\"", ColumnType.class));
+    Assertions.assertEquals(SOME_COMPLEX, MAPPER.readValue("\"complex<foo>\"", 
ColumnType.class));
+    // ... but the complex type name inside the brackets is a case-sensitive 
registry key, preserved as written
+    Assertions.assertNotEquals(SOME_COMPLEX, 
MAPPER.readValue("\"COMPLEX<FOO>\"", ColumnType.class));
+    // a parameterized type missing its closing bracket is malformed, not a 
truncated type parameter
+    Assertions.assertNull(MAPPER.readValue("\"COMPLEX<foo\"", 
ColumnType.class));
+    // an unrecognized array element type makes the whole array type 
unrecognized, rather than an error
+    Assertions.assertNull(MAPPER.readValue("\"ARRAY<FOO>\"", 
ColumnType.class));
+    Assertions.assertNull(MAPPER.readValue("\"ARRAY<ARRAY<FOO>>\"", 
ColumnType.class));
   }
 
   @Test
diff --git 
a/server/src/main/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadata.java
 
b/server/src/main/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadata.java
index f0a3321285d..feaf647e402 100644
--- 
a/server/src/main/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadata.java
+++ 
b/server/src/main/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadata.java
@@ -183,6 +183,20 @@ public class ClusteredValueGroupsBaseTableMetadata 
implements DatasourceBaseTabl
   {
     ColumnType druidType = Columns.druidType(column);
     if (druidType == null) {
+      // A column declared without a type defaults to STRING (mirroring 
Columns.convertSignature), but a declared
+      // type that does not parse must be rejected rather than silently 
defaulted: the declared type is the physical
+      // segment schema here.
+      if (column.dataType() != null) {
+        throw InvalidInput.exception(
+            "column [%s] has an unrecognized type [%s]; declare a SQL type 
(such as [%s]) or a Druid type string"
+            + " (such as [%s] or [%s])",
+            column.name(),
+            column.dataType(),
+            Columns.SQL_BIGINT,
+            ColumnType.LONG_ARRAY.asTypeString(),
+            ColumnType.NESTED_DATA.asTypeString()
+        );
+      }
       druidType = ColumnType.STRING;
     }
     if (customSchema != null) {
diff --git a/server/src/main/java/org/apache/druid/catalog/model/TableDefn.java 
b/server/src/main/java/org/apache/druid/catalog/model/TableDefn.java
index 0e7a26a89ec..f986210aa82 100644
--- a/server/src/main/java/org/apache/druid/catalog/model/TableDefn.java
+++ b/server/src/main/java/org/apache/druid/catalog/model/TableDefn.java
@@ -22,7 +22,9 @@ package org.apache.druid.catalog.model;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.base.Strings;
 import org.apache.druid.catalog.model.ModelProperties.PropertyDefn;
+import org.apache.druid.error.InvalidInput;
 import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.segment.column.ColumnType;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -106,9 +108,27 @@ public class TableDefn extends ObjectDefn
   /**
    * Table-specific validation of a column spec. Override for table definitions
    * that need table-specific validation rules.
+   * <p>
+   * A column declared without a type is legal (the type is resolved from the 
physical schema, the ingestion query, or
+   * the input format), but a declared type must parse to a Druid type, 
otherwise we would silently substitute STRING
+   * for the unrecognized declaration. This runs at catalog write time only: 
reads never validate, so tables stored
+   * before this rule keep resolving (though editing them surfaces the invalid 
type). {@link Columns#druidType} maps
+   * {@code __time} to LONG regardless of the declared type, which {@link 
ColumnSpec#validate} already restricts to
+   * LONG or untyped.
    */
   protected void validateColumn(ColumnSpec colSpec)
   {
+    if (colSpec.dataType() != null && Columns.druidType(colSpec) == null) {
+      throw InvalidInput.exception(
+          "Column [%s] has an unrecognized type [%s]; declare a SQL type (such 
as [%s]) or a Druid type string"
+          + " (such as [%s] or [%s])",
+          colSpec.name(),
+          colSpec.dataType(),
+          Columns.SQL_BIGINT,
+          ColumnType.LONG_ARRAY.asTypeString(),
+          ColumnType.NESTED_DATA.asTypeString()
+      );
+    }
   }
 
   /**
diff --git 
a/server/src/main/java/org/apache/druid/catalog/model/table/DatasourceDefn.java 
b/server/src/main/java/org/apache/druid/catalog/model/table/DatasourceDefn.java
index ef3a41dba46..89982a94883 100644
--- 
a/server/src/main/java/org/apache/druid/catalog/model/table/DatasourceDefn.java
+++ 
b/server/src/main/java/org/apache/druid/catalog/model/table/DatasourceDefn.java
@@ -21,7 +21,6 @@ package org.apache.druid.catalog.model.table;
 
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
-import org.apache.druid.catalog.model.ColumnSpec;
 import org.apache.druid.catalog.model.Columns;
 import org.apache.druid.catalog.model.DatasourceBaseTableMetadata;
 import org.apache.druid.catalog.model.DatasourceProjectionMetadata;
@@ -126,15 +125,6 @@ public class DatasourceDefn extends TableDefn
     }
   }
 
-  @Override
-  protected void validateColumn(ColumnSpec spec)
-  {
-    super.validateColumn(spec);
-    if (Columns.isTimeColumn(spec.name()) && spec.dataType() != null) {
-      // Validate type in next PR
-    }
-  }
-
   /**
    * Check if {@link TableSpec#type()} is {@link DatasourceDefn#TABLE_TYPE}
    */
diff --git 
a/server/src/main/java/org/apache/druid/catalog/model/table/ExternalTableDefn.java
 
b/server/src/main/java/org/apache/druid/catalog/model/table/ExternalTableDefn.java
index 015f7195c2a..c967f3e1a9c 100644
--- 
a/server/src/main/java/org/apache/druid/catalog/model/table/ExternalTableDefn.java
+++ 
b/server/src/main/java/org/apache/druid/catalog/model/table/ExternalTableDefn.java
@@ -21,7 +21,6 @@ package org.apache.druid.catalog.model.table;
 
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.google.common.annotations.VisibleForTesting;
-import org.apache.druid.catalog.model.ColumnSpec;
 import org.apache.druid.catalog.model.ModelProperties.ObjectPropertyDefn;
 import org.apache.druid.catalog.model.ModelProperties.PropertyDefn;
 import org.apache.druid.catalog.model.ResolvedTable;
@@ -284,12 +283,6 @@ public class ExternalTableDefn extends TableDefn
     return new ResolvedExternalTable(table).resolve(registry).tableFn();
   }
 
-  @Override
-  protected void validateColumn(ColumnSpec colSpec)
-  {
-    // Validate type in next PR
-  }
-
   /**
    * Return the {@link ExternalTableSpec} for a catalog entry for a
    * fully-defined table. This form exists for completeness, since ingestion 
never
diff --git 
a/server/src/test/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadataTest.java
 
b/server/src/test/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadataTest.java
index e7891dd9c2a..a31a8e4dad8 100644
--- 
a/server/src/test/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadataTest.java
+++ 
b/server/src/test/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadataTest.java
@@ -421,7 +421,10 @@ public class ClusteredValueGroupsBaseTableMetadataTest 
extends InitializedNullHa
         new ColumnSpec("tags", Columns.SQL_VARCHAR_ARRAY, null),
         new ColumnSpec("vals", Columns.SQL_BIGINT_ARRAY, null),
         new ColumnSpec("ratios", Columns.SQL_FLOAT_ARRAY, null),
-        new ColumnSpec("attrs", ColumnType.NESTED_DATA.asTypeString(), null)
+        new ColumnSpec("attrs", ColumnType.NESTED_DATA.asTypeString(), null),
+        // type prefixes match case-insensitively; these must not silently 
fall back to STRING
+        new ColumnSpec("attrs2", "complex<json>", null),
+        new ColumnSpec("vals2", "array<long>", null)
     );
     // Declared types are retained in the ingestion schema rather than left to 
inference: arrays cast an auto column
     // to the declared type (an all-null batch has no values to infer from; 
FLOAT ARRAY is stored as DOUBLE ARRAY by
@@ -434,7 +437,9 @@ public class ClusteredValueGroupsBaseTableMetadataTest 
extends InitializedNullHa
                                                        new 
AutoTypeColumnSchema("tags", ColumnType.STRING_ARRAY, null),
                                                        new 
AutoTypeColumnSchema("vals", ColumnType.LONG_ARRAY, null),
                                                        new 
AutoTypeColumnSchema("ratios", ColumnType.DOUBLE_ARRAY, null),
-                                                       new 
NestedDataColumnSchema("attrs", NestedDataColumnSchema.DEFAULT_FORMAT_VERSION)
+                                                       new 
NestedDataColumnSchema("attrs", NestedDataColumnSchema.DEFAULT_FORMAT_VERSION),
+                                                       new 
NestedDataColumnSchema("attrs2", NestedDataColumnSchema.DEFAULT_FORMAT_VERSION),
+                                                       new 
AutoTypeColumnSchema("vals2", ColumnType.LONG_ARRAY, null)
                                                    )
                                                    .clusteringColumns("tenant")
                                                    .build(),
@@ -459,6 +464,31 @@ public class ClusteredValueGroupsBaseTableMetadataTest 
extends InitializedNullHa
     Assert.assertTrue(e.getMessage().contains("column [unique_things] has 
unsupported type [COMPLEX<hyperUnique>]"));
   }
 
+  @Test
+  public void testCreateSpecUnparseableTypeFails()
+  {
+    // A column declared WITHOUT a type defaults to STRING, but a declared 
type that does not parse must be rejected
+    // rather than silently defaulted: the declared type is the physical 
segment schema here. (A malformed
+    // parameterized type such as a missing closing bracket parses to null.)
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant"),
+        null,
+        null
+    );
+    for (String badType : new String[]{"COMPLEX<json", "ARRAY<LONG", 
"ARRAY<FOO>", "FOO"}) {
+      final List<ColumnSpec> columns = Arrays.asList(
+          new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+          new ColumnSpec(Columns.TIME_COLUMN, null, null),
+          new ColumnSpec("busted", badType, null)
+      );
+      final DruidException e = Assert.assertThrows(DruidException.class, () -> 
metadata.createSpec(columns));
+      Assert.assertTrue(
+          "expected unrecognized-type error for [" + badType + "] but got: " + 
e.getMessage(),
+          e.getMessage().contains("column [busted] has an unrecognized type [" 
+ badType + "]")
+      );
+    }
+  }
+
   @Test
   public void testCreateSpecClusteringColumnsNotLeadingPrefixFails()
   {
diff --git 
a/server/src/test/java/org/apache/druid/catalog/model/table/DatasourceTableTest.java
 
b/server/src/test/java/org/apache/druid/catalog/model/table/DatasourceTableTest.java
index 287e9477c89..ba32f61de7f 100644
--- 
a/server/src/test/java/org/apache/druid/catalog/model/table/DatasourceTableTest.java
+++ 
b/server/src/test/java/org/apache/druid/catalog/model/table/DatasourceTableTest.java
@@ -43,6 +43,7 @@ import org.apache.druid.math.expr.ExprMacroTable;
 import org.apache.druid.segment.VirtualColumns;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
+import org.apache.druid.testing.InitializedNullHandlingTest;
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.experimental.categories.Category;
@@ -66,7 +67,7 @@ import static org.junit.Assert.assertTrue;
  * Test of validation and serialization of the catalog table definitions.
  */
 @Category(CatalogTest.class)
-public class DatasourceTableTest
+public class DatasourceTableTest extends InitializedNullHandlingTest
 {
   private static final Logger LOG = new Logger(DatasourceTableTest.class);
 
@@ -447,6 +448,39 @@ public class DatasourceTableTest
           .buildSpec();
       expectValidationFails(spec);
     }
+
+    // A declared type must parse to a Druid type; no type at all remains 
legal (covered above).
+    {
+      TableSpec spec = builder.copy()
+          .column("foo", "FOO")
+          .buildSpec();
+      DruidException e = assertThrows(DruidException.class, () -> 
registry.resolve(spec).validate());
+      assertTrue(e.getMessage().contains("Column [foo] has an unrecognized 
type [FOO]"));
+    }
+    {
+      // A parameterized type missing its closing bracket is malformed, and 
must not silently pass as undeclared.
+      TableSpec spec = builder.copy()
+          .column("foo", "COMPLEX<json")
+          .buildSpec();
+      DruidException e = assertThrows(DruidException.class, () -> 
registry.resolve(spec).validate());
+      assertTrue(e.getMessage().contains("Column [foo] has an unrecognized 
type [COMPLEX<json]"));
+    }
+    {
+      // An unrecognized array element type is an invalid input, not an 
internal error.
+      TableSpec spec = builder.copy()
+          .column("foo", "ARRAY<FOO>")
+          .buildSpec();
+      DruidException e = assertThrows(DruidException.class, () -> 
registry.resolve(spec).validate());
+      assertTrue(e.getMessage().contains("Column [foo] has an unrecognized 
type [ARRAY<FOO>]"));
+    }
+    {
+      // Parse-level validation only: any well-formed complex type is accepted 
at the logical layer, whether or not
+      // its serde is registered on this server.
+      TableSpec spec = builder.copy()
+          .column("foo", "COMPLEX<thetaSketch>")
+          .buildSpec();
+      expectValidationSucceeds(spec);
+    }
   }
 
   @Test
diff --git 
a/server/src/test/java/org/apache/druid/catalog/model/table/ExternalTableTest.java
 
b/server/src/test/java/org/apache/druid/catalog/model/table/ExternalTableTest.java
index acbea354703..20048d02cef 100644
--- 
a/server/src/test/java/org/apache/druid/catalog/model/table/ExternalTableTest.java
+++ 
b/server/src/test/java/org/apache/druid/catalog/model/table/ExternalTableTest.java
@@ -30,6 +30,7 @@ import org.apache.druid.data.input.impl.HttpInputSourceConfig;
 import org.apache.druid.data.input.impl.InlineInputSource;
 import org.apache.druid.data.input.impl.JsonInputFormat;
 import org.apache.druid.data.input.impl.LocalInputSource;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.logger.Logger;
 import org.apache.druid.metadata.DefaultPasswordProvider;
@@ -43,6 +44,7 @@ import java.util.Collections;
 import java.util.Map;
 
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
 
 public class ExternalTableTest extends BaseExternTableTest
 {
@@ -132,6 +134,28 @@ public class ExternalTableTest extends BaseExternTableTest
     resolved.validate();
   }
 
+  @Test
+  public void testValidateUnrecognizedColumnType()
+  {
+    // A declared column type must parse to a Druid type: an unrecognized type 
would otherwise silently become
+    // STRING when the columns are converted to the input row signature.
+    CsvInputFormat format = new CsvInputFormat(
+        Collections.singletonList("a"), ";", false, false, 0, null);
+    for (String badType : new String[]{"FOO", "COMPLEX<json"}) {
+      TableMetadata table = TableBuilder.external("foo")
+          .inputSource(toMap(new InlineInputSource("a\n")))
+          .inputFormat(formatToMap(format))
+          .column("a", badType)
+          .build();
+      ResolvedTable resolved = registry.resolve(table.spec());
+      DruidException e = assertThrows(DruidException.class, () -> 
resolved.validate());
+      assertTrue(
+          "expected unrecognized-type error for [" + badType + "] but got: " + 
e.getMessage(),
+          e.getMessage().contains("Column [a] has an unrecognized type [" + 
badType + "]")
+      );
+    }
+  }
+
   /**
    * Test case for multiple of the {@code ext.md} examples. To use this, 
enable the
    * test, run it, then copy the JSON from the console. The examples pull out 
bits
diff --git 
a/sql/src/main/java/org/apache/druid/sql/calcite/external/Externals.java 
b/sql/src/main/java/org/apache/druid/sql/calcite/external/Externals.java
index 598bdf97375..53908dbc470 100644
--- a/sql/src/main/java/org/apache/druid/sql/calcite/external/Externals.java
+++ b/sql/src/main/java/org/apache/druid/sql/calcite/external/Externals.java
@@ -273,8 +273,14 @@ public class Externals
       throw unsupportedType(name, dataType);
     }
     String simpleName = typeNameIdentifier.getSimple();
-    if (StringUtils.toLowerCase(simpleName).startsWith(("complex<"))) {
-      return simpleName;
+    if (StringUtils.toLowerCase(simpleName).startsWith("complex<")) {
+      // Parse and validate rather than passing the raw string downstream, 
where a malformed type string would
+      // silently resolve to a different type; return the canonical form.
+      final ColumnType complexType = ColumnType.fromString(simpleName);
+      if (complexType == null) {
+        throw unsupportedType(name, dataType);
+      }
+      return complexType.asTypeString();
     }
     SqlTypeName type = SqlTypeName.get(simpleName);
     if (type == null) {
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/IngestTableFunctionTest.java 
b/sql/src/test/java/org/apache/druid/sql/calcite/IngestTableFunctionTest.java
index 1828984d78c..47b220f7177 100644
--- 
a/sql/src/test/java/org/apache/druid/sql/calcite/IngestTableFunctionTest.java
+++ 
b/sql/src/test/java/org/apache/druid/sql/calcite/IngestTableFunctionTest.java
@@ -34,6 +34,7 @@ import org.apache.druid.data.input.impl.HttpInputSourceConfig;
 import org.apache.druid.data.input.impl.JsonInputFormat;
 import org.apache.druid.data.input.impl.LocalInputSource;
 import org.apache.druid.data.input.impl.systemfield.SystemFields;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.initialization.DruidModule;
 import org.apache.druid.java.util.common.ISE;
 import org.apache.druid.java.util.common.StringUtils;
@@ -458,6 +459,78 @@ public class IngestTableFunctionTest extends 
CalciteIngestionDmlTest
         .verify();
   }
 
+  /**
+   * The COMPLEX type prefix in an EXTEND clause matches case-insensitively 
(like all other type names) and is
+   * normalized to the canonical form; the complex type name inside the 
brackets keeps its casing.
+   */
+  @Test
+  public void testHttpJsonLowercaseComplexTypePrefix()
+  {
+    final ExternalDataSource httpDataSource = new ExternalDataSource(
+        new HttpInputSource(
+            Collections.singletonList(toURI("http://foo.com/bar.json";)),
+            "bob",
+            new DefaultPasswordProvider("secret"),
+            SystemFields.none(),
+            null,
+            new HttpInputSourceConfig(null, null)
+        ),
+        new JsonInputFormat(null, null, null, null, null),
+        RowSignature.builder()
+                    .add("x", ColumnType.STRING)
+                    .add("z", ColumnType.NESTED_DATA)
+                    .build()
+        );
+    testIngestionQuery()
+        .sql("INSERT INTO dst SELECT *\n" +
+             "FROM TABLE(http(userName => 'bob',\n" +
+            "                 password => 'secret',\n" +
+             "                uris => ARRAY['http://foo.com/bar.json'],\n" +
+             "                format => 'json'))\n" +
+             "     EXTEND (x VARCHAR, z TYPE('complex<json>'))\n" +
+             "PARTITIONED BY ALL TIME")
+        .authentication(CalciteTests.SUPER_USER_AUTH_RESULT)
+        .expectTarget("dst", httpDataSource.getSignature())
+        .expectResources(dataSourceWrite("dst"), 
Externals.EXTERNAL_RESOURCE_ACTION)
+        .expectQuery(
+            newScanQueryBuilder()
+                .dataSource(httpDataSource)
+                .intervals(querySegmentSpec(Filtration.eternity()))
+                .columns("x", "z")
+                .columnTypes(ColumnType.STRING, ColumnType.ofComplex("json"))
+                
.context(CalciteIngestionDmlTest.PARTITIONED_BY_ALL_TIME_QUERY_CONTEXT)
+                .build()
+         )
+        .verify();
+  }
+
+  /**
+   * A malformed complex type in an EXTEND clause (missing the closing 
bracket) is rejected rather than silently
+   * resolving to a different type.
+   */
+  @Test
+  public void testHttpJsonMalformedComplexTypeRejected()
+  {
+    testIngestionQuery()
+        .sql("INSERT INTO dst SELECT *\n" +
+             "FROM TABLE(http(userName => 'bob',\n" +
+            "                 password => 'secret',\n" +
+             "                uris => ARRAY['http://foo.com/bar.json'],\n" +
+             "                format => 'json'))\n" +
+             "     EXTEND (x VARCHAR, z TYPE('complex<json'))\n" +
+             "PARTITIONED BY ALL TIME")
+        .authentication(CalciteTests.SUPER_USER_AUTH_RESULT)
+        .expectValidationError(
+            CoreMatchers.allOf(
+                CoreMatchers.instanceOf(DruidException.class),
+                ThrowableMessageMatcher.hasMessage(
+                    CoreMatchers.containsString("Column [z] has an unsupported 
type")
+                )
+            )
+        )
+        .verify();
+  }
+
   /**
    * Basic use of an inline input source via EXTERN
    */


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to