[feature](http) Add structured type metadata to table schema API (#66670)

### What problem does this PR solve?

Issue Number: close #66675

Related PR: #xxx

Problem Summary:

The table schema HTTP API previously returned only the top-level
primitive type name through the `type` field. This is insufficient for
complex types such as ARRAY, MAP, and STRUCT because clients cannot
reliably reconstruct nested type definitions, nullability, or scalar
attributes.

This PR extends the schema response with two additive fields while
preserving all existing fields:

- `type_sql`: the complete SQL representation of the column type.
- `type_desc`: a recursively structured type description.

`type_desc` contains:

- Common attributes: `kind` and `sql`.
- Decimal attributes: `precision` and `scale`.
- CHAR/VARCHAR attribute: `length`.
- DATETIMEV2/TIMEV2/TIMESTAMPTZ attribute: `scale`.
- ARRAY attributes: `contains_null` and `element`.
- MAP attributes: `key_contains_null`, `value_contains_null`, `key`, and
`value`.
- STRUCT attributes: ordered `fields`, including field name,
nullability, and recursive type metadata.

The new metadata is returned for both base table schema columns and
materialized index columns.

The existing `type`, `precision`, `scale`, and other response fields
remain unchanged for backward compatibility.

### Release note

None

### Check List (For Author)

- Test <!-- At least one of them must be included. -->
    - [ ] Regression test
    - [x] Unit Test
    - [ ] Manual test (add detailed scripts or steps below)
    - [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
        - [ ] Previous test can cover this change.
        - [ ] No code files have been changed.
        - [ ] Other reason <!-- Add your reason?  -->

- Behavior changed:
    - [ ] No.
- [x] Yes. The table schema API response includes the additive
`type_sql` and `type_desc` fields. Existing fields and their values are
preserved.

- Does this need documentation?
    - [x] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->

### Check List (For Reviewer who merge this PR)

- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->

---------

Signed-off-by: StandingMan <jmtangcs@gmail.com>
This commit is contained in:
Alan Tang authored and Your Name committed 2026-11-03 03:13:57 +00:00
1 parent fae4af2e16
commit 97dee66c2b
6 files changed
+793 -9

No files matched your search

@@ -36,10 +36,12 @@ import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.httpv2.entity.ResponseEntityBuilder;
import org.apache.doris.httpv2.rest.response.GsonSchemaResponse;
import org.apache.doris.httpv2.rest.response.SchemaTypeDesc;
import org.apache.doris.mysql.privilege.PrivPredicate;
import org.apache.doris.persist.gson.GsonUtils;
import org.apache.doris.qe.ConnectContext;
import com.google.common.annotations.VisibleForTesting;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import jakarta.servlet.http.HttpServletRequest;
@@ -70,8 +72,9 @@ public class TableSchemaAction extends RestBaseController {
* @param column the column to build info for
* @return map containing column information
*/
private Map<String, String> buildColumnInfo(Column column) {
Map<String, String> columnInfo = new HashMap<>(2);
@VisibleForTesting
static Map<String, Object> buildColumnInfo(Column column) {
Map<String, Object> columnInfo = new HashMap<>();
Type colType = column.getOriginType();
PrimitiveType primitiveType = colType.getPrimitiveType();
@@ -83,6 +86,11 @@ public class TableSchemaAction extends RestBaseController {
columnInfo.put("column_uid", String.valueOf(column.getUniqueId()));
columnInfo.put("type", primitiveType.toString());
SchemaTypeDesc typeDesc = SchemaTypeDesc.fromType(colType);
if (typeDesc.getSql() != null) {
columnInfo.put("type_sql", typeDesc.getSql());
}
columnInfo.put("type_desc", typeDesc);
columnInfo.put("comment", column.getComment());
columnInfo.put("name", column.getDisplayName());
@@ -131,9 +139,9 @@ public class TableSchemaAction extends RestBaseController {
try {
try {
List<Column> columns = table.getBaseSchema();
List<Map<String, String>> propList = new ArrayList(columns.size());
List<Map<String, Object>> propList = new ArrayList<>(columns.size());
for (Column column : columns) {
Map<String, String> baseInfo = buildColumnInfo(column);
Map<String, Object> baseInfo = buildColumnInfo(column);
propList.add(baseInfo);
}
resultMap.put("status", 200);
@@ -160,10 +168,10 @@ public class TableSchemaAction extends RestBaseController {
// Get schema columns for this materialized index
List<Column> indexColumns = indexMeta.getSchema();
List<Map<String, String>> indexColumnList = new ArrayList<>();
List<Map<String, Object>> indexColumnList = new ArrayList<>();
for (Column column : indexColumns) {
Map<String, String> columnInfo = buildColumnInfo(column);
Map<String, Object> columnInfo = buildColumnInfo(column);
indexColumnList.add(columnInfo);
}
@@ -0,0 +1,234 @@
// 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.doris.httpv2.rest.response;
import org.apache.doris.catalog.AggStateType;
import org.apache.doris.catalog.ArrayType;
import org.apache.doris.catalog.MapType;
import org.apache.doris.catalog.PrimitiveType;
import org.apache.doris.catalog.ScalarType;
import org.apache.doris.catalog.StructField;
import org.apache.doris.catalog.StructType;
import org.apache.doris.catalog.Type;
import org.apache.doris.catalog.VariantField;
import org.apache.doris.catalog.VariantType;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* Recursively structured type description for table schema HTTP APIs.
*
* <p>Only attributes that apply to a type are serialized. Complex types point to child
* {@code SchemaTypeDesc} instances, allowing clients to traverse arbitrarily nested ARRAY, MAP,
* STRUCT, VARIANT, and AGG_STATE definitions. JSON property names use snake case to match the
* surrounding schema API.</p>
*/
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class SchemaTypeDesc {
// Doris primitive type name. Decimal V3 storage widths remain distinguishable, for example
// DECIMAL32 and DECIMAL64.
private final String kind;
// Complete SQL representation intended for display and compatibility fallback. Unsupported
// types have no valid SQL representation, so this field is null for them. Clients should prefer
// the structured fields below when interpreting a type.
private final String sql;
// Scalar attributes. Null means that the attribute does not apply to this type.
private Integer precision;
private Integer scale;
private Integer length;
// ARRAY attributes. Doris ARRAY elements are always nullable, so containsNull is always true
// and is emitted for symmetry with MAP. element is the recursively described item type.
private Boolean containsNull;
private SchemaTypeDesc element;
// MAP attributes. Nullability and type metadata are reported independently for keys and values.
private Boolean keyContainsNull;
private Boolean valueContainsNull;
private SchemaTypeDesc key;
private SchemaTypeDesc value;
// STRUCT attributes in declaration order.
private List<StructFieldDesc> fields;
// VARIANT predefined fields in declaration order. Dynamic fields discovered at runtime are not
// part of the catalog type and therefore are not included.
private List<VariantFieldDesc> predefinedFields;
// AGG_STATE attributes. Each subtype keeps its nullability next to its recursive type metadata.
private String functionName;
private Boolean resultIsNullable;
private List<AggStateSubTypeDesc> subTypes;
private SchemaTypeDesc(Type type) {
this.kind = type.getPrimitiveType().toString();
this.sql = type.isSupported() ? type.toSql() : null;
}
/**
* Converts a Doris catalog type into its public recursive schema representation.
*
* <p>The primitive name is present on every node. The SQL form is present only for supported
* types. Type-specific metadata is added only on nodes where it is meaningful, so Jackson can
* omit unrelated fields from the response.</p>
*
* @param type catalog type to expose through the schema API
* @return recursively structured description of {@code type}
*/
public static SchemaTypeDesc fromType(Type type) {
SchemaTypeDesc result = new SchemaTypeDesc(type);
PrimitiveType primitiveType = type.getPrimitiveType();
switch (primitiveType) {
case ARRAY:
ArrayType arrayType = (ArrayType) type;
result.containsNull = arrayType.getContainsNull();
result.element = fromType(arrayType.getItemType());
break;
case MAP:
MapType mapType = (MapType) type;
result.keyContainsNull = mapType.getIsKeyContainsNull();
result.valueContainsNull = mapType.getIsValueContainsNull();
result.key = fromType(mapType.getKeyType());
result.value = fromType(mapType.getValueType());
break;
case STRUCT:
StructType structType = (StructType) type;
result.fields = structType.getFields().stream()
.map(StructFieldDesc::fromField)
.collect(Collectors.toList());
break;
case VARIANT:
VariantType variantType = (VariantType) type;
result.predefinedFields = variantType.getPredefinedFields().stream()
.map(VariantFieldDesc::fromField)
.collect(Collectors.toList());
break;
case AGG_STATE:
AggStateType aggStateType = (AggStateType) type;
result.functionName = aggStateType.getFunctionName();
result.resultIsNullable = aggStateType.getResultIsNullable();
result.subTypes = AggStateSubTypeDesc.fromType(aggStateType);
break;
case DECIMALV2:
case DECIMAL32:
case DECIMAL64:
case DECIMAL128:
case DECIMAL256:
ScalarType decimalType = (ScalarType) type;
result.precision = decimalType.getPrecision();
result.scale = decimalType.getScalarScale();
break;
case CHAR:
case VARCHAR:
case VARBINARY:
result.length = ((ScalarType) type).getLength();
break;
case DATETIMEV2:
case TIMEV2:
case TIMESTAMPTZ:
result.scale = ((ScalarType) type).getScalarScale();
break;
default:
break;
}
return result;
}
/**
* One named child of a STRUCT type.
*
* <p>Field order is preserved by the containing list. {@code containsNull} describes the
* nested field itself, while {@code type} recursively describes its value type.</p>
*/
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public static class StructFieldDesc {
private final String name;
private final boolean containsNull;
private final String comment;
private final SchemaTypeDesc type;
private StructFieldDesc(StructField field) {
this.name = field.getName();
this.containsNull = field.getContainsNull();
this.comment = field.isCommentSpecified() ? field.getComment() : null;
this.type = SchemaTypeDesc.fromType(field.getType());
}
private static StructFieldDesc fromField(StructField field) {
return new StructFieldDesc(field);
}
}
/** One predefined field pattern of a VARIANT type. */
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public static class VariantFieldDesc {
private final String pattern;
private final String patternType;
private final String comment;
private final SchemaTypeDesc type;
private VariantFieldDesc(VariantField field) {
this.pattern = field.getPattern();
this.patternType = field.getPatternType().toString();
this.comment = field.getComment();
this.type = SchemaTypeDesc.fromType(field.getType());
}
private static VariantFieldDesc fromField(VariantField field) {
return new VariantFieldDesc(field);
}
}
/** One recursively described input type of an AGG_STATE type. */
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public static class AggStateSubTypeDesc {
private final boolean containsNull;
private final SchemaTypeDesc type;
private AggStateSubTypeDesc(Type type, boolean containsNull) {
this.containsNull = containsNull;
this.type = SchemaTypeDesc.fromType(type);
}
private static List<AggStateSubTypeDesc> fromType(AggStateType type) {
List<AggStateSubTypeDesc> subTypes = new ArrayList<>(type.getSubTypes().size());
for (int i = 0; i < type.getSubTypes().size(); i++) {
subTypes.add(new AggStateSubTypeDesc(
type.getSubTypes().get(i), type.getSubTypeNullables().get(i)));
}
return subTypes;
}
}
}
@@ -47,5 +47,17 @@ public class TableSchemaActionTest extends DorisHttpTestCase {
JSONArray propArray = (JSONArray) ((JSONObject) object.get("data")).get("properties");
// k1, k2
Assert.assertEquals(2, propArray.size());
JSONObject column = (JSONObject) propArray.get(0);
Assert.assertEquals("BIGINT", column.get("type"));
Assert.assertEquals("bigint", column.get("type_sql"));
Assert.assertEquals("BIGINT", ((JSONObject) column.get("type_desc")).get("kind"));
JSONObject materializedIndexes =
(JSONObject) ((JSONObject) object.get("data")).get("materialized_indexes");
JSONObject baseIndex = (JSONObject) materializedIndexes.get("testIndex");
JSONArray indexColumns = (JSONArray) baseIndex.get("columns");
Assert.assertEquals("BIGINT",
((JSONObject) ((JSONObject) indexColumns.get(0)).get("type_desc")).get("kind"));
}
}
@@ -0,0 +1,172 @@
// 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.doris.httpv2.rest;
import org.apache.doris.catalog.AggStateType;
import org.apache.doris.catalog.ArrayType;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.MapType;
import org.apache.doris.catalog.ScalarType;
import org.apache.doris.catalog.StructField;
import org.apache.doris.catalog.StructType;
import org.apache.doris.catalog.Type;
import org.apache.doris.catalog.VariantField;
import org.apache.doris.catalog.VariantType;
import org.apache.doris.httpv2.rest.response.SchemaTypeDesc;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Map;
public class TableSchemaActionColumnInfoTest {
@Test
public void testArrayDecimalColumnInfo() {
Column column = new Column("array_decimal",
new ArrayType(ScalarType.createDecimalV3Type(18, 4)), false, null, true, null,
"nested decimal");
Map<String, Object> columnInfo = TableSchemaAction.buildColumnInfo(column);
Assertions.assertEquals("array_decimal", columnInfo.get("name"));
Assertions.assertEquals("ARRAY", columnInfo.get("type"));
Assertions.assertEquals("array<decimalv3(18,4)>", columnInfo.get("type_sql"));
Assertions.assertEquals("Yes", columnInfo.get("is_nullable"));
Assertions.assertEquals("No", columnInfo.get("is_key"));
Assertions.assertEquals("nested decimal", columnInfo.get("comment"));
Assertions.assertFalse(columnInfo.containsKey("precision"));
Assertions.assertFalse(columnInfo.containsKey("scale"));
SchemaTypeDesc typeDesc = (SchemaTypeDesc) columnInfo.get("type_desc");
Assertions.assertEquals("ARRAY", typeDesc.getKind());
Assertions.assertTrue(typeDesc.getContainsNull());
Assertions.assertEquals("DECIMAL64", typeDesc.getElement().getKind());
Assertions.assertEquals(Integer.valueOf(18), typeDesc.getElement().getPrecision());
Assertions.assertEquals(Integer.valueOf(4), typeDesc.getElement().getScale());
JsonNode json = new ObjectMapper().valueToTree(columnInfo);
Assertions.assertEquals("ARRAY", json.path("type").asText());
assertBooleanField(json.path("type_desc"), "contains_null", true);
Assertions.assertEquals(4, json.path("type_desc").path("element").path("scale").asInt());
}
@Test
public void testScalarDecimalKeepsLegacyAttributes() {
Column column = new Column("amount", ScalarType.createDecimalV3Type(18, 4));
Map<String, Object> columnInfo = TableSchemaAction.buildColumnInfo(column);
Assertions.assertEquals("DECIMAL64", columnInfo.get("type"));
Assertions.assertEquals("18", columnInfo.get("precision"));
Assertions.assertEquals("4", columnInfo.get("scale"));
SchemaTypeDesc typeDesc = (SchemaTypeDesc) columnInfo.get("type_desc");
Assertions.assertEquals(Integer.valueOf(18), typeDesc.getPrecision());
Assertions.assertEquals(Integer.valueOf(4), typeDesc.getScale());
}
@Test
public void testVarbinaryColumnInfoContainsStructuredLength() {
Map<String, Object> columnInfo = TableSchemaAction.buildColumnInfo(
new Column("payload", ScalarType.createVarbinaryType(64)));
Assertions.assertEquals("VARBINARY", columnInfo.get("type"));
Assertions.assertEquals("varbinary(64)", columnInfo.get("type_sql"));
JsonNode json = new ObjectMapper().valueToTree(columnInfo);
Assertions.assertEquals("VARBINARY", json.path("type_desc").path("kind").asText());
Assertions.assertEquals("varbinary(64)", json.path("type_desc").path("sql").asText());
Assertions.assertEquals(64, json.path("type_desc").path("length").asInt());
}
@Test
public void testVariantAndAggStateColumnInfoContainsStructuredChildren() {
VariantType variantType = new VariantType(Lists.newArrayList(
new VariantField("event_id", Type.BIGINT, "event identifier")));
JsonNode variantJson = new ObjectMapper().valueToTree(TableSchemaAction.buildColumnInfo(
new Column("payload", variantType)));
JsonNode variantField = variantJson.path("type_desc").path("predefined_fields").path(0);
Assertions.assertEquals("event_id", variantField.path("pattern").asText());
Assertions.assertEquals("BIGINT", variantField.path("type").path("kind").asText());
AggStateType aggStateType = new AggStateType("sum", true,
Lists.newArrayList(Type.BIGINT), Lists.newArrayList(false));
JsonNode aggStateJson = new ObjectMapper().valueToTree(TableSchemaAction.buildColumnInfo(
new Column("sum_state", aggStateType)));
JsonNode typeDesc = aggStateJson.path("type_desc");
Assertions.assertEquals("sum", typeDesc.path("function_name").asText());
assertBooleanField(typeDesc, "result_is_nullable", true);
assertBooleanField(typeDesc.path("sub_types").path(0), "contains_null", false);
Assertions.assertEquals("BIGINT", typeDesc.path("sub_types").path(0).path("type").path("kind").asText());
}
@Test
public void testUnsupportedColumnInfoOmitsSql() {
Map<String, Object> columnInfo = TableSchemaAction.buildColumnInfo(
new Column("geometry", Type.UNSUPPORTED));
Assertions.assertEquals("UNSUPPORTED_TYPE", columnInfo.get("type"));
Assertions.assertFalse(columnInfo.containsKey("type_sql"));
SchemaTypeDesc typeDesc = (SchemaTypeDesc) columnInfo.get("type_desc");
Assertions.assertEquals("UNSUPPORTED_TYPE", typeDesc.getKind());
Assertions.assertNull(typeDesc.getSql());
JsonNode json = new ObjectMapper().valueToTree(columnInfo);
Assertions.assertFalse(json.has("type_sql"));
Assertions.assertEquals("UNSUPPORTED_TYPE", json.path("type_desc").path("kind").asText());
Assertions.assertFalse(json.path("type_desc").has("sql"));
}
@Test
public void testStructAndMapColumnInfo() {
MapType mapType = new MapType(Type.STRING,
new ArrayType(ScalarType.createDecimalV3Type(9, 2)), false, true);
StructType structType = new StructType(Lists.newArrayList(
new StructField("attributes", mapType, "map attributes", false),
new StructField("tags", new ArrayType(Type.STRING))));
Map<String, Object> columnInfo = TableSchemaAction.buildColumnInfo(
new Column("detail", structType));
SchemaTypeDesc structDesc = (SchemaTypeDesc) columnInfo.get("type_desc");
Assertions.assertEquals("STRUCT", structDesc.getKind());
Assertions.assertEquals(2, structDesc.getFields().size());
Assertions.assertFalse(structDesc.getFields().get(0).isContainsNull());
Assertions.assertEquals("map attributes", structDesc.getFields().get(0).getComment());
Assertions.assertNull(structDesc.getFields().get(1).getComment());
SchemaTypeDesc mapDesc = structDesc.getFields().get(0).getType();
Assertions.assertEquals("MAP", mapDesc.getKind());
Assertions.assertFalse(mapDesc.getKeyContainsNull());
Assertions.assertTrue(mapDesc.getValueContainsNull());
Assertions.assertEquals("STRING", mapDesc.getKey().getKind());
Assertions.assertEquals("DECIMAL32", mapDesc.getValue().getElement().getKind());
Assertions.assertEquals(Integer.valueOf(2), mapDesc.getValue().getElement().getScale());
}
private void assertBooleanField(JsonNode node, String fieldName, boolean expected) {
Assertions.assertTrue(node.has(fieldName), "Missing field: " + fieldName);
JsonNode value = node.get(fieldName);
Assertions.assertTrue(value.isBoolean(), "Field is not boolean: " + fieldName);
Assertions.assertEquals(expected, value.booleanValue(),
"Unexpected value for field: " + fieldName);
}
}
@@ -0,0 +1,306 @@
// 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.doris.httpv2.rest.response;
import org.apache.doris.catalog.AggStateType;
import org.apache.doris.catalog.ArrayType;
import org.apache.doris.catalog.MapType;
import org.apache.doris.catalog.PatternType;
import org.apache.doris.catalog.ScalarType;
import org.apache.doris.catalog.StructField;
import org.apache.doris.catalog.StructType;
import org.apache.doris.catalog.Type;
import org.apache.doris.catalog.VariantField;
import org.apache.doris.catalog.VariantType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class SchemaTypeDescTest {
@Test
public void testNestedType() {
ScalarType decimalType = ScalarType.createDecimalV3Type(18, 4);
StructType structType = new StructType(Lists.newArrayList(
new StructField("price", decimalType, "", false)));
SchemaTypeDesc typeDesc = SchemaTypeDesc.fromType(new ArrayType(structType));
Assertions.assertEquals("ARRAY", typeDesc.getKind());
Assertions.assertTrue(typeDesc.getContainsNull());
Assertions.assertEquals("STRUCT", typeDesc.getElement().getKind());
Assertions.assertEquals(1, typeDesc.getElement().getFields().size());
SchemaTypeDesc.StructFieldDesc field = typeDesc.getElement().getFields().get(0);
Assertions.assertEquals("price", field.getName());
Assertions.assertFalse(field.isContainsNull());
Assertions.assertEquals("DECIMAL64", field.getType().getKind());
Assertions.assertEquals(Integer.valueOf(18), field.getType().getPrecision());
Assertions.assertEquals(Integer.valueOf(4), field.getType().getScale());
}
@Test
public void testMapType() {
MapType mapType = new MapType(Type.STRING,
new ArrayType(ScalarType.createDecimalV3Type(9, 2)), false, true);
SchemaTypeDesc typeDesc = SchemaTypeDesc.fromType(mapType);
Assertions.assertEquals("MAP", typeDesc.getKind());
Assertions.assertFalse(typeDesc.getKeyContainsNull());
Assertions.assertTrue(typeDesc.getValueContainsNull());
Assertions.assertEquals("STRING", typeDesc.getKey().getKind());
Assertions.assertEquals("ARRAY", typeDesc.getValue().getKind());
Assertions.assertEquals(Integer.valueOf(9), typeDesc.getValue().getElement().getPrecision());
Assertions.assertEquals(Integer.valueOf(2), typeDesc.getValue().getElement().getScale());
}
@Test
public void testDeeplyNestedArrayMapStructType() {
StructType leafStruct = new StructType(Lists.newArrayList(
new StructField("id", Type.BIGINT, "", false),
new StructField("amounts",
new ArrayType(ScalarType.createDecimalV3Type(18, 4)), "", true)));
MapType mapType = new MapType(ScalarType.createVarcharType(16), leafStruct, false, true);
SchemaTypeDesc typeDesc = SchemaTypeDesc.fromType(new ArrayType(mapType));
Assertions.assertEquals("ARRAY", typeDesc.getKind());
Assertions.assertTrue(typeDesc.getContainsNull());
SchemaTypeDesc mapDesc = typeDesc.getElement();
Assertions.assertEquals("MAP", mapDesc.getKind());
Assertions.assertFalse(mapDesc.getKeyContainsNull());
Assertions.assertTrue(mapDesc.getValueContainsNull());
Assertions.assertEquals(Integer.valueOf(16), mapDesc.getKey().getLength());
SchemaTypeDesc structDesc = mapDesc.getValue();
Assertions.assertEquals("STRUCT", structDesc.getKind());
Assertions.assertEquals(2, structDesc.getFields().size());
Assertions.assertFalse(structDesc.getFields().get(0).isContainsNull());
Assertions.assertEquals("BIGINT", structDesc.getFields().get(0).getType().getKind());
Assertions.assertTrue(structDesc.getFields().get(1).isContainsNull());
SchemaTypeDesc amountsDesc = structDesc.getFields().get(1).getType();
Assertions.assertEquals("ARRAY", amountsDesc.getKind());
Assertions.assertTrue(amountsDesc.getContainsNull());
Assertions.assertEquals("DECIMAL64", amountsDesc.getElement().getKind());
Assertions.assertEquals(Integer.valueOf(18), amountsDesc.getElement().getPrecision());
Assertions.assertEquals(Integer.valueOf(4), amountsDesc.getElement().getScale());
}
@Test
public void testNestedArraysPreserveElementChain() {
Type nestedArrays = new ArrayType(new ArrayType(new ArrayType(Type.INT)));
SchemaTypeDesc outer = SchemaTypeDesc.fromType(nestedArrays);
Assertions.assertEquals("ARRAY", outer.getKind());
Assertions.assertEquals("ARRAY", outer.getElement().getKind());
Assertions.assertEquals("ARRAY", outer.getElement().getElement().getKind());
Assertions.assertEquals("INT", outer.getElement().getElement().getElement().getKind());
}
@Test
public void testDeeplyNestedJsonSerialization() {
StructType leafStruct = new StructType(Lists.newArrayList(
new StructField("created_at", ScalarType.createDatetimeV2Type(6), "", false)));
Type nestedType = new StructType(Lists.newArrayList(
new StructField("events",
new ArrayType(new MapType(Type.STRING, leafStruct, false, true)),
"", true)));
JsonNode json = new ObjectMapper().valueToTree(SchemaTypeDesc.fromType(nestedType));
JsonNode eventsField = json.path("fields").path(0);
Assertions.assertEquals("events", eventsField.path("name").asText());
assertBooleanField(eventsField, "contains_null", true);
JsonNode map = eventsField.path("type").path("element");
assertBooleanField(map, "key_contains_null", false);
assertBooleanField(map, "value_contains_null", true);
Assertions.assertEquals("STRING", map.path("key").path("kind").asText());
JsonNode createdAt = map.path("value").path("fields").path(0);
Assertions.assertEquals("created_at", createdAt.path("name").asText());
assertBooleanField(createdAt, "contains_null", false);
Assertions.assertEquals("DATETIMEV2", createdAt.path("type").path("kind").asText());
Assertions.assertEquals(6, createdAt.path("type").path("scale").asInt());
Assertions.assertFalse(createdAt.path("type").has("fields"));
Assertions.assertFalse(createdAt.path("type").has("element"));
}
@Test
public void testStructFieldComments() {
StructType structType = new StructType(Lists.newArrayList(
new StructField("documented", Type.INT, "unit price", true),
new StructField("undocumented", Type.INT),
new StructField("empty_comment", Type.INT, "", true, true)));
JsonNode fields = new ObjectMapper().valueToTree(
SchemaTypeDesc.fromType(structType)).path("fields");
Assertions.assertEquals("unit price", fields.path(0).path("comment").asText());
Assertions.assertFalse(fields.path(1).has("comment"));
Assertions.assertTrue(fields.path(2).has("comment"));
Assertions.assertEquals("", fields.path(2).path("comment").asText());
}
@Test
public void testScalarAttributes() {
SchemaTypeDesc character = SchemaTypeDesc.fromType(ScalarType.createCharType(16));
SchemaTypeDesc varchar = SchemaTypeDesc.fromType(ScalarType.createVarcharType(32));
SchemaTypeDesc varbinary = SchemaTypeDesc.fromType(ScalarType.createVarbinaryType(64));
SchemaTypeDesc datetime = SchemaTypeDesc.fromType(ScalarType.createDatetimeV2Type(3));
SchemaTypeDesc time = SchemaTypeDesc.fromType(ScalarType.createTimeV2Type(5));
SchemaTypeDesc timestamp = SchemaTypeDesc.fromType(ScalarType.createTimeStampTzType(6));
Assertions.assertEquals(Integer.valueOf(16), character.getLength());
Assertions.assertEquals(Integer.valueOf(32), varchar.getLength());
Assertions.assertEquals(Integer.valueOf(64), varbinary.getLength());
Assertions.assertEquals(Integer.valueOf(3), datetime.getScale());
Assertions.assertEquals(Integer.valueOf(5), time.getScale());
Assertions.assertEquals(Integer.valueOf(6), timestamp.getScale());
Assertions.assertNull(SchemaTypeDesc.fromType(Type.STRING).getLength());
}
@Test
public void testDecimalStorageKinds() {
assertDecimalType(ScalarType.createDecimalV3Type(9, 1), "DECIMAL32", 9, 1);
assertDecimalType(ScalarType.createDecimalV3Type(18, 2), "DECIMAL64", 18, 2);
assertDecimalType(ScalarType.createDecimalV3Type(38, 3), "DECIMAL128", 38, 3);
}
@Test
public void testJsonUsesSnakeCaseAndOmitsNullFields() {
SchemaTypeDesc typeDesc = SchemaTypeDesc.fromType(
new ArrayType(ScalarType.createDecimalV3Type(18, 4)));
JsonNode json = new ObjectMapper().valueToTree(typeDesc);
assertBooleanField(json, "contains_null", true);
Assertions.assertFalse(json.has("containsNull"));
Assertions.assertFalse(json.has("precision"));
Assertions.assertEquals("array<decimalv3(18,4)>", json.path("sql").asText());
Assertions.assertEquals("decimalv3(18,4)", json.path("element").path("sql").asText());
Assertions.assertEquals(18, json.path("element").path("precision").asInt());
Assertions.assertEquals(4, json.path("element").path("scale").asInt());
}
@Test
public void testVarbinaryJsonContainsLength() {
JsonNode json = new ObjectMapper().valueToTree(
SchemaTypeDesc.fromType(ScalarType.createVarbinaryType(64)));
Assertions.assertEquals("VARBINARY", json.path("kind").asText());
Assertions.assertEquals("varbinary(64)", json.path("sql").asText());
Assertions.assertEquals(64, json.path("length").asInt());
Assertions.assertEquals(3, json.size());
}
@Test
public void testVariantPredefinedFieldsAreStructuredRecursively() {
VariantType variantType = new VariantType(Lists.newArrayList(
new VariantField("amount", ScalarType.createDecimalV3Type(18, 4),
"monetary value", PatternType.MATCH_NAME),
new VariantField("tags_*", new ArrayType(ScalarType.createVarcharType(32)),
"", PatternType.MATCH_NAME_GLOB)));
JsonNode json = new ObjectMapper().valueToTree(SchemaTypeDesc.fromType(variantType));
JsonNode amount = json.path("predefined_fields").path(0);
Assertions.assertEquals("amount", amount.path("pattern").asText());
Assertions.assertEquals("MATCH_NAME", amount.path("pattern_type").asText());
Assertions.assertEquals("monetary value", amount.path("comment").asText());
Assertions.assertEquals("DECIMAL64", amount.path("type").path("kind").asText());
Assertions.assertEquals(18, amount.path("type").path("precision").asInt());
Assertions.assertEquals(4, amount.path("type").path("scale").asInt());
JsonNode tags = json.path("predefined_fields").path(1);
Assertions.assertEquals("tags_*", tags.path("pattern").asText());
Assertions.assertEquals("MATCH_NAME_GLOB", tags.path("pattern_type").asText());
Assertions.assertEquals("ARRAY", tags.path("type").path("kind").asText());
Assertions.assertEquals(32, tags.path("type").path("element").path("length").asInt());
}
@Test
public void testAggStateSubTypesAreStructuredRecursively() {
AggStateType aggStateType = new AggStateType("weighted_sum", false,
Lists.newArrayList(Type.INT,
new ArrayType(ScalarType.createDecimalV3Type(18, 4))),
Lists.newArrayList(true, false));
JsonNode json = new ObjectMapper().valueToTree(SchemaTypeDesc.fromType(aggStateType));
Assertions.assertEquals("AGG_STATE", json.path("kind").asText());
Assertions.assertEquals("weighted_sum", json.path("function_name").asText());
assertBooleanField(json, "result_is_nullable", false);
JsonNode integer = json.path("sub_types").path(0);
assertBooleanField(integer, "contains_null", true);
Assertions.assertEquals("INT", integer.path("type").path("kind").asText());
JsonNode amounts = json.path("sub_types").path(1);
assertBooleanField(amounts, "contains_null", false);
Assertions.assertEquals("ARRAY", amounts.path("type").path("kind").asText());
Assertions.assertEquals(18, amounts.path("type").path("element").path("precision").asInt());
Assertions.assertEquals(4, amounts.path("type").path("element").path("scale").asInt());
}
@Test
public void testPrimitiveJsonOnlyContainsKindAndSql() {
JsonNode json = new ObjectMapper().valueToTree(SchemaTypeDesc.fromType(Type.BIGINT));
Assertions.assertEquals(2, json.size());
Assertions.assertEquals("BIGINT", json.path("kind").asText());
Assertions.assertEquals("bigint", json.path("sql").asText());
}
@Test
public void testUnsupportedTypeOmitsSql() {
SchemaTypeDesc unsupported = SchemaTypeDesc.fromType(Type.UNSUPPORTED);
JsonNode json = new ObjectMapper().valueToTree(unsupported);
Assertions.assertEquals("UNSUPPORTED_TYPE", unsupported.getKind());
Assertions.assertNull(unsupported.getSql());
Assertions.assertEquals(1, json.size());
Assertions.assertEquals("UNSUPPORTED_TYPE", json.path("kind").asText());
Assertions.assertFalse(json.has("sql"));
}
@Test
public void testComplexTypeContainingUnsupportedTypeOmitsSql() {
SchemaTypeDesc array = SchemaTypeDesc.fromType(new ArrayType(Type.UNSUPPORTED));
JsonNode json = new ObjectMapper().valueToTree(array);
Assertions.assertNull(array.getSql());
Assertions.assertNull(array.getElement().getSql());
Assertions.assertFalse(json.has("sql"));
Assertions.assertFalse(json.path("element").has("sql"));
Assertions.assertEquals("UNSUPPORTED_TYPE", json.path("element").path("kind").asText());
}
private void assertBooleanField(JsonNode node, String fieldName, boolean expected) {
Assertions.assertTrue(node.has(fieldName), "Missing field: " + fieldName);
JsonNode value = node.get(fieldName);
Assertions.assertTrue(value.isBoolean(), "Field is not boolean: " + fieldName);
Assertions.assertEquals(expected, value.booleanValue(),
"Unexpected value for field: " + fieldName);
}
private void assertDecimalType(ScalarType type, String kind, int precision, int scale) {
SchemaTypeDesc typeDesc = SchemaTypeDesc.fromType(type);
Assertions.assertEquals(kind, typeDesc.getKind());
Assertions.assertEquals(Integer.valueOf(precision), typeDesc.getPrecision());
Assertions.assertEquals(Integer.valueOf(scale), typeDesc.getScale());
}
}
@@ -33,7 +33,13 @@ suite("test_schema_api") {
`c2` date NOT NULL COMMENT "date columns",
`c3` VARCHAR(20) COMMENT "nullable columns",
`c4` VARCHAR COMMENT "varchar columns",
`c5` BIGINT DEFAULT "0" COMMENT "test columns"
`c5` BIGINT DEFAULT "0" COMMENT "test columns",
`c6` ARRAY<DECIMAL(18, 4)> COMMENT "array column",
`c7` MAP<VARCHAR(16), BIGINT> COMMENT "map column",
`c8` STRUCT<
price:DECIMAL(18, 4) COMMENT 'unit price',
tags:ARRAY<VARCHAR(32)>
> COMMENT "struct column"
)
UNIQUE KEY(`id`)
DISTRIBUTED BY HASH(`id`) BUCKETS 8
@@ -62,7 +68,54 @@ suite("test_schema_api") {
assertEquals(result.msg, "success")
// parsing
def resultList = result.data.properties
assertTrue(resultList.size() == 6)
assertEquals(9, resultList.size())
def columns = resultList.collectEntries { [(it.name): it] }
def arrayColumn = columns.c6
assertEquals("ARRAY", arrayColumn.type)
assertEquals("array<decimalv3(18,4)>", arrayColumn.type_sql)
def arrayDesc = arrayColumn.type_desc
assertEquals("ARRAY", arrayDesc.kind)
assertTrue(arrayDesc.containsKey("contains_null"))
assertFalse(arrayDesc.containsKey("containsNull"))
assertTrue(arrayDesc.contains_null)
assertEquals("DECIMAL64", arrayDesc.element.kind)
assertEquals(18, arrayDesc.element.precision)
assertEquals(4, arrayDesc.element.scale)
def mapColumn = columns.c7
assertEquals("MAP", mapColumn.type)
assertEquals("map<varchar(16),bigint>", mapColumn.type_sql)
def mapDesc = mapColumn.type_desc
assertEquals("MAP", mapDesc.kind)
assertTrue(mapDesc.containsKey("key_contains_null"))
assertTrue(mapDesc.containsKey("value_contains_null"))
assertFalse(mapDesc.containsKey("keyContainsNull"))
assertFalse(mapDesc.containsKey("valueContainsNull"))
assertTrue(mapDesc.key_contains_null)
assertTrue(mapDesc.value_contains_null)
assertEquals("VARCHAR", mapDesc.key.kind)
assertEquals(16, mapDesc.key.length)
assertEquals("BIGINT", mapDesc.value.kind)
def structColumn = columns.c8
assertEquals("STRUCT", structColumn.type)
def structDesc = structColumn.type_desc
assertEquals("STRUCT", structDesc.kind)
def structFields = structDesc.fields.collectEntries { [(it.name): it] }
assertTrue(structFields.price.containsKey("contains_null"))
assertFalse(structFields.price.containsKey("containsNull"))
assertTrue(structFields.price.contains_null)
assertEquals("unit price", structFields.price.comment)
assertEquals("DECIMAL64", structFields.price.type.kind)
assertEquals(18, structFields.price.type.precision)
assertEquals(4, structFields.price.type.scale)
assertEquals("ARRAY", structFields.tags.type.kind)
assertTrue(structFields.tags.type.containsKey("contains_null"))
assertFalse(structFields.tags.type.containsKey("containsNull"))
assertEquals("VARCHAR", structFields.tags.type.element.kind)
assertEquals(32, structFields.tags.type.element.length)
// not exist catalog
def url2 = String.format("http://%s/api/%s/%s/%s/_schema", context.config.feHttpAddress, "notexistctl", thisDb, tbName)
@@ -71,4 +124,3 @@ suite("test_schema_api") {
assertTrue(result2.data.contains("Unknown catalog"))
}