Skip to content

Commit e83b06c

Browse files
[fix](iceberg) Let MODIFY COLUMN restate Iceberg BINARY and FIXED columns as VARBINARY (#69285)
### What problem does this PR solve? Issue Number: None Related PR: #66032, #68786 Problem Summary: **In short.** This ports to master the rule branch-4.1 got in #66032 (`IcebergMetadataOps.resolvePrimitiveTypeForModify`): when `ALTER TABLE ... MODIFY COLUMN` restates the type an Iceberg column is read as, the Iceberg type stays as it is. On master, every MODIFY COLUMN of an Iceberg BINARY or FIXED column, or of such a STRUCT field, ARRAY element or MAP value, fails with `Unsupported type for Iceberg: VARBINARY`, so their nullability, comment and position cannot be changed at all. After this PR they can. **Background.** Since #68786, Doris always reads an Iceberg BINARY column as `VARBINARY` and a FIXED(n) column as `VARBINARY(n)`. MODIFY COLUMN carries the whole column definition, so a statement that only means to change the nullability, comment or position of such a column restates that type, for example `MODIFY COLUMN payload VARBINARY NULL FIRST`. The nested form (`MODIFY COLUMN s.a ...`, `arr.element`, `m.value`) works the same way. The Iceberg connector turns the requested type into an Iceberg type before it looks at the table (`IcebergSchemaBuilder.buildColumnType`), and then diffs it against the current schema. `VARBINARY` has no Iceberg type there: CREATE TABLE and ADD COLUMN reject it on purpose, as branch-4.x does, because a Doris `VARBINARY(n)` holds up to n bytes and an Iceberg FIXED(n) exactly n. **The problem, and what it cost.** The type build fails before the connector ever looks at the current column, so restating the type fails too: | Statement (current Iceberg type) | master | this PR | |---|---|---| | `MODIFY COLUMN payload VARBINARY NULL FIRST` (BINARY) | `Unsupported type for Iceberg: VARBINARY` | succeeds, stays BINARY | | `MODIFY COLUMN digest VARBINARY(16) NULL COMMENT 'digest'` (required FIXED(16)) | same error | succeeds, stays FIXED(16) | | `MODIFY COLUMN s.a VARBINARY COMMENT 'bytes'` (STRUCT field BINARY) | same error | succeeds, stays BINARY | | `MODIFY COLUMN arr.element VARBINARY(4) NULL` (required FIXED(4) element) | same error | succeeds, stays FIXED(4) | | `MODIFY COLUMN m.value VARBINARY NULL` (required BINARY value) | same error | succeeds, stays BINARY | | `MODIFY COLUMN payload VARBINARY(16)` (BINARY: not how it is read) | `Unsupported type for Iceberg: VARBINARY` | unchanged | So a column that users read and write as VARBINARY could not be moved, commented, or made nullable from Doris; only a comment-only `MODIFY COLUMN ... COMMENT '...'` worked, because it carries no type. On branch-4.1 and branch-4.2, `IcebergMetadataOps.resolvePrimitiveTypeForModify` keeps the current Iceberg type in the first five cases and rejects the last one (`Type varbinary(16) is not supported for Iceberg column payload`). **How this PR fixes it.** When the requested type does not build and it is a `VARBINARY`, `IcebergConnectorMetadata` looks up the current type of the target field (the same best-effort lookup it already did to rewrite the nested error message) and keeps it if that type is read as exactly the requested `VARBINARY`, length included (`IcebergTypeMapping.isReadAs`). The schema update then sees an unchanged type and applies only the nullability, comment and position the statement asks for. This is the same check the Paimon connector already makes in `requestedColumnType`. A `VARBINARY` that restates nothing (another length, a column or field of another type) still fails with `Unsupported type for Iceberg: VARBINARY`, as ADD COLUMN does; any other scalar that does not build still fails without loading the table, and a complex type still gets the nested error message as before. A STRUCT, ARRAY or MAP type that contains `VARBINARY` is rejected by Doris's DDL type check (`STRUCT unsupported sub-type: varbinary(...)`) before it reaches the connector, on branch-4.x as well, so it is unchanged. **Results.** - **Regression test**: new `external_table_p0/iceberg/test_iceberg_modify_binary_column`. The table is created through the REST catalog, since neither Doris nor Spark SQL can declare a FIXED column. The suite runs the five statements above, checks with `DESC` (comments shown) that the types are unchanged while the position and comments changed, checks that the required column, ARRAY element and MAP value reject NULL before and accept it after, and compares the rows with what Spark reads. It fails at its first MODIFY with the connector before this PR and passes with it. Seven related suites pass as well: `test_iceberg_schema_change_complex_types`, `test_iceberg_nested_schema_evolution_ddl`, `test_iceberg_nested_schema_evolution_spark_doris_interop`, `iceberg_schema_change_ddl`, `test_iceberg_varbinary`, `test_iceberg_uuid_binary_compatibility` and `test_iceberg_struct_schema_evolution`. - **FE UT**: new `IcebergConnectorMetadataBinaryModifyTest` drives `IcebergConnectorMetadata` into an in-memory Iceberg catalog. Its four restating cases fail before this PR with `Unsupported type for Iceberg: VARBINARY`; the fifth checks that a VARBINARY that restates nothing still fails and leaves the schema unchanged. Turning off the length comparison, the primitive check, the null check, or reading the current type at the top-level column instead of the nested path each turns a case red. All 1615 tests of fe-connector-iceberg pass. Co-authored-by: Gabriel <[email protected]>
1 parent 351d938 commit e83b06c

6 files changed

Lines changed: 398 additions & 18 deletions

File tree

‎fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java‎

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,11 +1392,11 @@ public void modifyColumn(ConnectorSession session, ConnectorTableHandle handle,
13921392
try {
13931393
icebergType = IcebergSchemaBuilder.buildColumnType(column.getType());
13941394
} catch (DorisConnectorException buildError) {
1395-
// A nested narrowing to an iceberg-unrepresentable type (e.g. ARRAY<INT> -> ARRAY<SMALLINT>) throws a
1396-
// generic "Unsupported type for Iceberg: SMALLINT" here. Restore the legacy parity message ("Cannot
1397-
// change int to smallint in nested types") by validating the requested nested type against the
1398-
// CURRENT type — legacy validated in Doris type space, where the narrow target still exists.
1399-
throw upgradeNestedModifyError(iceHandle, ConnectorColumnPath.of(column.getName()), column, buildError);
1395+
// VARBINARY, how iceberg BINARY / FIXED is read, has no iceberg type of its own, and a nested narrowing
1396+
// to an iceberg-unrepresentable type (e.g. ARRAY<INT> -> ARRAY<SMALLINT>) throws a generic
1397+
// "Unsupported type for Iceberg: SMALLINT" here. Both are resolved against the CURRENT type.
1398+
icebergType = keepRestatedBinaryType(iceHandle, ConnectorColumnPath.of(column.getName()), column,
1399+
buildError);
14001400
}
14011401
// Carry the neutral source type so a complex-type diff can read each STRUCT field's commentSpecified.
14021402
IcebergColumnChange change = new IcebergColumnChange(column.getName(), icebergType,
@@ -1415,6 +1415,25 @@ public void modifyColumn(ConnectorSession session, ConnectorTableHandle handle,
14151415
}
14161416
}
14171417

1418+
/**
1419+
* Keeps the CURRENT iceberg type of a {@code MODIFY COLUMN} target that the requested VARBINARY restates. Doris
1420+
* reads iceberg BINARY and FIXED(n) as VARBINARY and VARBINARY(n), which have no iceberg type of their own, so
1421+
* restating how such a column or field is read is the only way to change its nullability, comment or
1422+
* position (branch-4.x {@code IcebergMetadataOps.resolvePrimitiveTypeForModify} keeps the iceberg type the same
1423+
* way). Any other requested type that did not build gets {@link #upgradeNestedModifyError}.
1424+
*/
1425+
private Type keepRestatedBinaryType(IcebergTableHandle handle, ConnectorColumnPath path,
1426+
ConnectorColumn column, DorisConnectorException buildError) {
1427+
if ("VARBINARY".equals(column.getType().getTypeName().toUpperCase(Locale.ROOT))) {
1428+
Types.NestedField current = loadModifyTarget(handle, path);
1429+
if (current != null && current.type().isPrimitiveType()
1430+
&& IcebergTypeMapping.isReadAs(current.type().asPrimitiveType(), column.getType())) {
1431+
return current.type();
1432+
}
1433+
}
1434+
throw upgradeNestedModifyError(handle, path, column, buildError);
1435+
}
1436+
14181437
/**
14191438
* Upgrades the generic "Unsupported type for Iceberg" error from a failed complex-type build into the legacy
14201439
* "Cannot change &lt;old&gt; to &lt;new&gt; in nested types" message, by walking the requested nested type
@@ -1426,21 +1445,31 @@ private DorisConnectorException upgradeNestedModifyError(IcebergTableHandle hand
14261445
if (!isComplexType(column.getType())) {
14271446
return buildError;
14281447
}
1429-
try {
1430-
Types.NestedField current = executeAuthenticated(() ->
1431-
catalogOps.withTable(handle.getDbName(), handle.getTableName(),
1432-
table -> IcebergNestedColumnEvolution.findFieldForErrorUpgrade(table.schema(), path)));
1433-
if (current != null && !current.type().isPrimitiveType()) {
1448+
Types.NestedField current = loadModifyTarget(handle, path);
1449+
if (current != null && !current.type().isPrimitiveType()) {
1450+
try {
14341451
IcebergComplexTypeDiff.validateNestedModifyRepresentable(current.type(), column.getType());
1452+
} catch (DorisConnectorException parityError) {
1453+
return parityError;
14351454
}
1436-
} catch (DorisConnectorException parityError) {
1437-
return parityError;
1438-
} catch (Exception ignored) {
1439-
// load failed / column missing -> keep the original build error
14401455
}
14411456
return buildError;
14421457
}
14431458

1459+
/**
1460+
* The current field at {@code path}, read only to resolve a requested type that did not build: null when the
1461+
* table does not load or has no such field, which keeps the original build error.
1462+
*/
1463+
private Types.NestedField loadModifyTarget(IcebergTableHandle handle, ConnectorColumnPath path) {
1464+
try {
1465+
return executeAuthenticated(() -> catalogOps.withTable(handle.getDbName(), handle.getTableName(),
1466+
table -> IcebergNestedColumnEvolution.findModifyTarget(table.schema(), path)));
1467+
} catch (Exception ignored) {
1468+
// load failed -> keep the original build error
1469+
return null;
1470+
}
1471+
}
1472+
14441473
/** Reorders columns, mirroring legacy {@code IcebergMetadataOps.reorderColumns}. */
14451474
@Override
14461475
public void reorderColumns(ConnectorSession session, ConnectorTableHandle handle, List<String> newOrder) {
@@ -1580,8 +1609,9 @@ public void modifyNestedColumn(ConnectorSession session, ConnectorTableHandle ha
15801609
try {
15811610
icebergType = IcebergSchemaBuilder.buildNestedFieldType(column.getType());
15821611
} catch (DorisConnectorException buildError) {
1583-
// Preserve the complete target identity so error parity cannot bind a same-named top-level field.
1584-
throw upgradeNestedModifyError(iceHandle, path, column, buildError);
1612+
// Preserve the complete target identity so the current type cannot be read from a same-named
1613+
// top-level field.
1614+
icebergType = keepRestatedBinaryType(iceHandle, path, column, buildError);
15851615
}
15861616
// Carry the neutral source type so the nested complex-type diff can read each STRUCT field's
15871617
// commentSpecified (an omitted COMMENT on a sub-field must keep its current doc, not clear it).

‎fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolution.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,11 +496,12 @@ static NestedField findTopLevelField(Schema schema, String columnName) {
496496
return schema.asStruct().caseInsensitiveField(columnName);
497497
}
498498

499-
static NestedField findFieldForErrorUpgrade(Schema schema, ConnectorColumnPath columnPath) {
499+
static NestedField findModifyTarget(Schema schema, ConnectorColumnPath columnPath) {
500500
try {
501501
return resolveColumnPath(schema, columnPath, "modify").getField();
502502
} catch (DorisConnectorException ignored) {
503-
// Error-message upgrading is best-effort and must not replace the original type-build failure.
503+
// Building a modify against the current type is best-effort and must not replace the original
504+
// type-build failure.
504505
return null;
505506
}
506507
}

‎fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTypeMapping.java‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,17 @@ private static ConnectorType fromPrimitive(Type.PrimitiveType primitive,
171171
}
172172
}
173173

174+
/**
175+
* Whether the primitive {@code icebergType} is read as {@code type}, so a {@code MODIFY COLUMN} stating
176+
* {@code type} restates it rather than changing it. The read mapping cannot always be inverted by
177+
* {@link #toIcebergPrimitive}: iceberg BINARY and FIXED(n) are read as VARBINARY and VARBINARY(n), which
178+
* have no iceberg type of their own.
179+
*/
180+
static boolean isReadAs(Type.PrimitiveType icebergType, ConnectorType type) {
181+
// Neither legacy mapping option changes how a primitive type is read.
182+
return type.equals(fromPrimitive(icebergType, true, true));
183+
}
184+
174185
/**
175186
* Maps a SCALAR {@link ConnectorType} (a Doris column type carried across the SPI by name) to an
176187
* Iceberg {@link Type} for CREATE TABLE. The inverse of {@link #fromPrimitive}, this is a string-driven
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.apache.doris.connector.iceberg;
19+
20+
import org.apache.doris.connector.iceberg.IcebergCatalogOps.CatalogBackedIcebergCatalogOps;
21+
import org.apache.doris.connector.spi.ConnectorColumn;
22+
import org.apache.doris.connector.spi.ConnectorType;
23+
import org.apache.doris.connector.spi.DorisConnectorException;
24+
import org.apache.doris.connector.spi.ddl.ConnectorColumnPath;
25+
import org.apache.doris.connector.spi.ddl.ConnectorColumnPosition;
26+
27+
import org.apache.iceberg.PartitionSpec;
28+
import org.apache.iceberg.Schema;
29+
import org.apache.iceberg.inmemory.InMemoryCatalog;
30+
import org.apache.iceberg.types.Types;
31+
import org.junit.jupiter.api.AfterEach;
32+
import org.junit.jupiter.api.Assertions;
33+
import org.junit.jupiter.api.BeforeEach;
34+
import org.junit.jupiter.api.Test;
35+
36+
import java.util.Arrays;
37+
import java.util.Collections;
38+
import java.util.HashMap;
39+
import java.util.Map;
40+
41+
/**
42+
* {@code MODIFY COLUMN} of iceberg BINARY / FIXED columns and nested fields, driven through
43+
* {@link IcebergConnectorMetadata} into a REAL {@link InMemoryCatalog}. Doris reads BINARY as {@code VARBINARY} and
44+
* FIXED(n) as {@code VARBINARY(n)}, and iceberg has no type to build for VARBINARY, so a modify that restates how
45+
* such a column or field is read (to change its nullability, comment or position) must keep the current iceberg
46+
* type rather than fail with "Unsupported type for Iceberg: VARBINARY". A VARBINARY that restates nothing still
47+
* fails.
48+
*/
49+
public class IcebergConnectorMetadataBinaryModifyTest {
50+
51+
private static final IcebergTableHandle HANDLE = new IcebergTableHandle("db1", "t1");
52+
private static final String UNSUPPORTED = "Unsupported type for Iceberg: VARBINARY";
53+
54+
private InMemoryCatalog catalog;
55+
private CatalogBackedIcebergCatalogOps ops;
56+
private IcebergConnectorMetadata metadata;
57+
58+
@BeforeEach
59+
public void setUp() {
60+
catalog = new InMemoryCatalog();
61+
catalog.initialize("test", Collections.emptyMap());
62+
ops = new CatalogBackedIcebergCatalogOps(catalog);
63+
ops.createDatabase("db1", Collections.emptyMap());
64+
Schema schema = new Schema(
65+
Types.NestedField.optional(1, "id", Types.LongType.get()),
66+
Types.NestedField.optional(2, "payload", Types.BinaryType.get()),
67+
Types.NestedField.required(3, "digest", Types.FixedType.ofLength(16), "sha"),
68+
Types.NestedField.optional(4, "s", Types.StructType.of(
69+
Types.NestedField.optional(5, "a", Types.BinaryType.get(), "raw"),
70+
Types.NestedField.optional(6, "b", Types.IntegerType.get()))),
71+
Types.NestedField.optional(7, "arr", Types.ListType.ofRequired(8, Types.FixedType.ofLength(4))),
72+
Types.NestedField.optional(9, "m", Types.MapType.ofRequired(10, 11,
73+
Types.StringType.get(), Types.BinaryType.get())));
74+
ops.createTable("db1", "t1", schema, PartitionSpec.unpartitioned(), null,
75+
IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap()));
76+
Map<String, String> props = new HashMap<>();
77+
props.put(IcebergCatalogProperties.ICEBERG_CATALOG_TYPE, IcebergCatalogProperties.TYPE_REST);
78+
metadata = new IcebergConnectorMetadata(ops, IcebergCatalogProperties.of(props),
79+
new RecordingConnectorContext());
80+
}
81+
82+
@AfterEach
83+
public void tearDown() throws Exception {
84+
catalog.close();
85+
}
86+
87+
private Schema reload() {
88+
return ops.loadTable("db1", "t1").schema();
89+
}
90+
91+
/** The neutral type fe-core sends for a Doris {@code VARBINARY}, which is how iceberg BINARY is read. */
92+
private static ConnectorType varbinary() {
93+
return ConnectorType.of("VARBINARY");
94+
}
95+
96+
/** The neutral type fe-core sends for a Doris {@code VARBINARY(n)}, which is how iceberg FIXED(n) is read. */
97+
private static ConnectorType varbinary(int length) {
98+
return ConnectorType.of("VARBINARY", length, 0);
99+
}
100+
101+
/** A nullable column with its nullability stated and its comment omitted. */
102+
private static ConnectorColumn column(String name, ConnectorType type) {
103+
return new ConnectorColumn(name, type, "", true, null, false).withSpecified(true, false);
104+
}
105+
106+
private static ConnectorColumnPath path(String... parts) {
107+
return ConnectorColumnPath.of(Arrays.asList(parts));
108+
}
109+
110+
@Test
111+
public void testRestatingBinaryColumnKeepsBinaryAndMovesIt() {
112+
// MODIFY COLUMN payload VARBINARY NULL FIRST
113+
metadata.modifyColumn(null, HANDLE, column("payload", varbinary()), ConnectorColumnPosition.FIRST);
114+
115+
Schema schema = reload();
116+
Assertions.assertEquals(Types.BinaryType.get(), schema.findField("payload").type());
117+
Assertions.assertEquals("payload", schema.columns().get(0).name());
118+
}
119+
120+
@Test
121+
public void testRestatingFixedColumnKeepsFixedAndWidensNullability() {
122+
// MODIFY COLUMN digest VARBINARY(16) NULL COMMENT 'digest'
123+
ConnectorColumn digest = new ConnectorColumn("digest", varbinary(16), "digest", true, null, false)
124+
.withSpecified(true, true);
125+
metadata.modifyColumn(null, HANDLE, digest, null);
126+
127+
Types.NestedField field = reload().findField("digest");
128+
Assertions.assertEquals(Types.FixedType.ofLength(16), field.type());
129+
Assertions.assertTrue(field.isOptional());
130+
Assertions.assertEquals("digest", field.doc());
131+
}
132+
133+
@Test
134+
public void testRestatingBinaryStructFieldChangesItsComment() {
135+
// MODIFY COLUMN s.a VARBINARY COMMENT 'bytes'
136+
ConnectorColumn a = new ConnectorColumn("a", varbinary(), "bytes", true, null, false)
137+
.withSpecified(false, true);
138+
metadata.modifyNestedColumn(null, HANDLE, path("s", "a"), a, null);
139+
140+
Types.NestedField field = reload().findField("s.a");
141+
Assertions.assertEquals(Types.BinaryType.get(), field.type());
142+
Assertions.assertEquals("bytes", field.doc());
143+
}
144+
145+
@Test
146+
public void testRestatingBinaryElementAndValueWidensThem() {
147+
// MODIFY COLUMN arr.element VARBINARY(4) NULL; MODIFY COLUMN m.value VARBINARY NULL
148+
metadata.modifyNestedColumn(null, HANDLE, path("arr", "element"), column("element", varbinary(4)), null);
149+
metadata.modifyNestedColumn(null, HANDLE, path("m", "value"), column("value", varbinary()), null);
150+
151+
Schema schema = reload();
152+
Types.ListType arr = schema.findField("arr").type().asListType();
153+
Assertions.assertEquals(Types.FixedType.ofLength(4), arr.elementType());
154+
Assertions.assertTrue(arr.isElementOptional());
155+
Types.MapType m = schema.findField("m").type().asMapType();
156+
Assertions.assertEquals(Types.BinaryType.get(), m.valueType());
157+
Assertions.assertTrue(m.isValueOptional());
158+
}
159+
160+
@Test
161+
public void testVarbinaryThatRestatesNothingStillFails() {
162+
Schema before = reload();
163+
// A VARBINARY is not how the current column or field is read: a length other than FIXED's, a length for an
164+
// unbounded BINARY, a column or field of another type, or no such column.
165+
assertFails(() -> metadata.modifyColumn(null, HANDLE, column("payload", varbinary(16)), null));
166+
assertFails(() -> metadata.modifyColumn(null, HANDLE, column("digest", varbinary(8)), null));
167+
assertFails(() -> metadata.modifyColumn(null, HANDLE, column("id", varbinary()), null));
168+
assertFails(() -> metadata.modifyColumn(null, HANDLE, column("s", varbinary()), null));
169+
assertFails(() -> metadata.modifyColumn(null, HANDLE, column("missing", varbinary()), null));
170+
assertFails(() -> metadata.modifyNestedColumn(null, HANDLE, path("s", "a"), column("a", varbinary(3)), null));
171+
assertFails(() -> metadata.modifyNestedColumn(null, HANDLE, path("s", "b"), column("b", varbinary()), null));
172+
Assertions.assertTrue(before.sameSchema(reload()), "a failed modify must not change the schema");
173+
}
174+
175+
private static void assertFails(org.junit.jupiter.api.function.Executable modify) {
176+
DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, modify);
177+
Assertions.assertEquals(UNSUPPORTED, ex.getMessage());
178+
}
179+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- This file is automatically generated. You should know what you did if you want to edit this
2+
-- !desc_before --
3+
id int Yes true \N
4+
payload varbinary(2147483647) Yes true \N
5+
digest varbinary(16) Yes true \N sha
6+
s struct<a:varbinary(2147483647) comment "raw",b:int> Yes true \N
7+
arr array<varbinary(4)> Yes true \N
8+
m map<text,varbinary(2147483647)> Yes true \N
9+
10+
-- !desc_after --
11+
payload varbinary(2147483647) Yes true \N
12+
id int Yes true \N
13+
digest varbinary(16) Yes true \N digest
14+
s struct<a:varbinary(2147483647) comment "bytes",b:int> Yes true \N
15+
arr array<varbinary(4)> Yes true \N
16+
m map<text,varbinary(2147483647)> Yes true \N
17+
18+
-- !rows --
19+
1 0102 00112233445566778899AABBCCDDEEFF AB 1 01020304 02
20+
2 FF \N \N 2 \N \N
21+

0 commit comments

Comments
 (0)