OIiveirra commented on code in PR #69032: URL: https://github.com/apache/doris/pull/69032#discussion_r4551060138
########## regression-test/suites/external_table_p0/iceberg/test_iceberg_spatial_v3.groovy: ########## @@ -0,0 +1,2029 @@ +// 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. + +import com.amazonaws.auth.AWSStaticCredentialsProvider +import com.amazonaws.auth.BasicAWSCredentials +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration +import com.amazonaws.services.s3.AmazonS3ClientBuilder +import com.amazonaws.services.s3.model.ListObjectsV2Request + +suite("test_iceberg_spatial_v3", "p0,external,iceberg,external_docker,external_docker_iceberg,nonConcurrent") { + String enabled = context.config.otherConfigs.get("enableIcebergTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("Iceberg test is disabled") + return + } + + String catalogName = "test_iceberg_spatial_v3" + String dbName = "iceberg_spatial_v3_db" + String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port") + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + + def executeCommand = { String command, int timeoutSeconds = 300 -> + StringBuilder stdout = new StringBuilder() + StringBuilder stderr = new StringBuilder() + def process = new ProcessBuilder("/bin/bash", "-c", command).start() + process.consumeProcessOutput(stdout, stderr) + process.waitForOrKill(timeoutSeconds * 1000) + assertEquals(0, process.exitValue(), + "Command failed\nstdout:\n${stdout}\nstderr:\n${stderr}") + return stdout.toString() + } + + String dockerCommand = context.config.otherConfigs.get("externalDockerCommand") ?: "docker" + String sparkContainer = context.config.otherConfigs.get("icebergSparkContainer") + if (sparkContainer == null || sparkContainer.isEmpty()) { + String containers = executeCommand( + "${dockerCommand} ps --format '{{.ID}}\t{{.Names}}'", 30) + def matches = [] + containers.readLines().each { String line -> + String containerId = line.split(/\t/, 2)[0] + String probe = "${dockerCommand} exec ${containerId} bash -lc " + + "'test -f /mnt/SUCCESS && command -v spark-sql >/dev/null'" + try { + executeCommand(probe, 30) + matches.add(containerId) + } catch (Throwable ignored) { + // Only the Spark service has the Iceberg API used to create this V3 schema. + } + } + assertEquals(1, matches.size(), "Expected exactly one usable Spark Iceberg container") + sparkContainer = matches[0] + } + + String javaSource = ''' +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.UUID; +import java.util.Map; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.SupportsNamespaces; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.types.Types; +import org.apache.hadoop.fs.Path; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.example.data.simple.SimpleGroupFactory; +import org.apache.parquet.hadoop.ParquetWriter; +import org.apache.parquet.hadoop.example.ExampleParquetWriter; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.MessageTypeParser; + +public class CreateIcebergSpatialV3Table { + private static void appendParquetRow(Table table, MessageType message, Group row) throws Exception { + String localFile = "/tmp/spatial-fixture-" + UUID.randomUUID() + ".parquet"; + try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(new Path(localFile)).withType(message).build()) { + writer.write(row); + } + OutputFile output = table.io().newOutputFile(table.location() + + "/data/spatial-fixture-" + UUID.randomUUID() + ".parquet"); + long size = Files.size(Paths.get(localFile)); + try (OutputStream stream = output.create()) { + Files.copy(Paths.get(localFile), stream); + } + Files.delete(Paths.get(localFile)); + table.newAppend().appendFile(DataFiles.builder(table.spec()) + .withPath(output.location()).withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(size).withRecordCount(1).build()).commit(); + } + + private static String parquetField(Types.NestedField field) { + String head = (field.isRequired() ? "required " : "optional "); + String tail = " = " + field.fieldId(); + switch (field.type().typeId()) { + case INTEGER: return head + "int32 " + field.name() + tail + "; "; + case STRING: return head + "binary " + field.name() + " (UTF8)" + tail + "; "; + case GEOMETRY: + case GEOGRAPHY: return head + "binary " + field.name() + tail + "; "; + case LIST: return head + "group " + field.name() + " (LIST)" + tail + + " { repeated group list { " + parquetField(field.type().asListType().fields().get(0)) + "} } "; + case MAP: return head + "group " + field.name() + " (MAP)" + tail + + " { repeated group key_value { " + parquetField(field.type().asMapType().fields().get(0)) + + parquetField(field.type().asMapType().fields().get(1)) + "} } "; + case STRUCT: + StringBuilder structure = new StringBuilder(head + "group " + field.name() + tail + " { "); + for (Types.NestedField child : field.type().asStructType().fields()) { + structure.append(parquetField(child)); + } + return structure.append("} ").toString(); + default: throw new IllegalArgumentException("Unexpected fixture type: " + field.type()); + } + } + + private static Binary binary(String hex) { + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16); + } + return Binary.fromConstantByteArray(bytes); + } + + private static void createRequiredNestedTargets(Catalog catalog, Namespace namespace, + Map<String, String> properties) { + for (boolean geography : new boolean[] {false, true}) { + org.apache.iceberg.types.Type spatial = geography + ? Types.GeographyType.crs84() : Types.GeometryType.crs84(); + TableIdentifier name = TableIdentifier.of(namespace, + "spatial_required_" + (geography ? "geography" : "geometry")); + if (catalog.tableExists(name)) { + catalog.dropTable(name); + } + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "payload", Types.StructType.of( + Types.NestedField.optional(3, "items", Types.ListType.ofRequired(4, spatial)))), + Types.NestedField.optional(5, "entries", Types.MapType.ofOptional( + 6, 7, Types.StringType.get(), Types.ListType.ofRequired(8, spatial)))); + catalog.createTable(name, schema, PartitionSpec.unpartitioned(), properties); + } + } + + private static void createNestedSource(Catalog catalog, Namespace namespace, + Map<String, String> properties) throws Exception { + java.util.List<Types.NestedField> fields = new java.util.ArrayList<>(); + fields.add(Types.NestedField.required(1, "id", Types.IntegerType.get())); + int nextId = 2; + for (boolean geography : new boolean[] {false, true}) { + String prefix = geography ? "gg_" : "g_"; + org.apache.iceberg.types.Type geo = geography ? Types.GeographyType.crs84() : Types.GeometryType.crs84(); + fields.add(Types.NestedField.optional(nextId++, prefix + "array", + Types.ListType.ofOptional(nextId++, geo))); + fields.add(Types.NestedField.optional(nextId++, prefix + "map", + Types.MapType.ofOptional(nextId++, nextId++, Types.StringType.get(), geo))); + fields.add(Types.NestedField.optional(nextId++, prefix + "struct", Types.StructType.of( + Types.NestedField.optional(nextId++, "shape", geo), + Types.NestedField.optional(nextId++, "label", Types.StringType.get())))); + fields.add(Types.NestedField.optional(nextId++, prefix + "deep", + Types.MapType.ofOptional(nextId++, nextId++, Types.StringType.get(), + Types.ListType.ofOptional(nextId++, Types.StructType.of( + Types.NestedField.optional(nextId++, "shape", geo)))))); + } + TableIdentifier name = TableIdentifier.of(namespace, "spatial_nested_source"); + if (catalog.tableExists(name)) { + catalog.dropTable(name); + } + Table table = catalog.createTable(name, new Schema(fields), PartitionSpec.unpartitioned(), properties); + StringBuilder schema = new StringBuilder("message nested_spatial { "); + for (Types.NestedField field : table.schema().columns()) { + schema.append(parquetField(field)); + } + MessageType message = MessageTypeParser.parseMessageType(schema.append("}").toString()); + String[] payloads = {"0101000000000000000000F03F0000000000000040", + "00000000013FF00000000000004000000000000000", + "01b90b0000000000000000f03f000000000000004000000000000008400000000000001040", + "010200000000000000"}; + for (int id = 1; id <= 6; id++) { + Group row = new SimpleGroupFactory(message).newGroup().append("id", id); + for (String prefix : new String[] {"g_", "gg_"}) { + if (id == 5) { + continue; // SQL NULL parents. + } + Group array = row.addGroup(prefix + "array"); + Group map = row.addGroup(prefix + "map"); + Group structure = row.addGroup(prefix + "struct").append("label", "row" + id); + Group deep = row.addGroup(prefix + "deep"); + if (id == 6) { + continue; // Empty containers and a present struct with a NULL spatial field. + } + Binary point = binary(payloads[id - 1]); + array.addGroup("list").append("element", point); + array.addGroup("list"); + map.addGroup("key_value").append("key", "shape").append("value", point); + map.addGroup("key_value").append("key", "absent"); + structure.append("shape", point); + Group list = deep.addGroup("key_value").append("key", "shapes").addGroup("value"); + list.addGroup("list").addGroup("element").append("shape", point); + list.addGroup("list").addGroup("element"); + list.addGroup("list"); + } + appendParquetRow(table, message, row); + } + } + + private static void createOrcWriteTargets(Catalog catalog, Namespace namespace, + Map<String, String> properties) { + Map<String, String> orcProperties = new HashMap<>(properties); + orcProperties.put("write.format.default", "orc"); + orcProperties.put("write-format", "orc"); + for (boolean geography : new boolean[] {false, true}) { + for (boolean nested : new boolean[] {false, true}) { + TableIdentifier name = TableIdentifier.of(namespace, "spatial_existing_orc_" + + (geography ? "geog" : "geom") + (nested ? "_nested" : "")); + if (catalog.tableExists(name)) { + catalog.dropTable(name); + } + org.apache.iceberg.types.Type type = geography + ? Types.GeographyType.crs84() : Types.GeometryType.crs84(); + catalog.createTable(name, new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "shape", nested + ? Types.ListType.ofOptional(3, type) : type)), + PartitionSpec.unpartitioned(), orcProperties); + } + } + } + + private static void createEmbeddedNullCrsSources(Catalog catalog, Namespace namespace, + Map<String, String> properties) throws Exception { + for (boolean geography : new boolean[] {false, true}) { + String suffix = geography ? "geog" : "geom"; + // Geography's ')' survives the SDK parser. A C-string truncation would turn + // its serialized prefix into the valid, different geography(custom) type. + String crs = (geography ? "custom)" : "custom:") + (char) 0 + "zone"; + org.apache.iceberg.types.Type type = geography + ? Types.GeographyType.of(crs, org.apache.iceberg.types.EdgeAlgorithm.SPHERICAL) + : Types.GeometryType.of(crs); + Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "shape", type)); + for (boolean copy : new boolean[] {false, true}) { + TableIdentifier name = TableIdentifier.of(namespace, + "spatial_nul_crs_" + suffix + (copy ? "_copy" : "")); + if (catalog.tableExists(name)) { + catalog.dropTable(name); + } + Table table = catalog.createTable(name, schema, PartitionSpec.unpartitioned(), properties); + if (!copy) { + MessageType message = MessageTypeParser.parseMessageType( + "message spatial_nul_crs { required int32 id = 1; optional binary shape = 2; }"); + appendParquetRow(table, message, new SimpleGroupFactory(message).newGroup().append("id", 1) + .append("shape", binary("0101000000000000000000F03F0000000000000040"))); + } + } + } + } + + public static void main(String[] args) throws Exception { + Map<String, String> properties = new HashMap<>(); + properties.put("type", "rest"); + properties.put("uri", "http://rest:8181"); + properties.put("warehouse", "s3://warehouse/wh/"); + properties.put("io-impl", "org.apache.iceberg.aws.s3.S3FileIO"); + properties.put("s3.endpoint", "http://minio:9000"); + properties.put("s3.path-style-access", "true"); + properties.put("s3.region", "us-east-1"); + Catalog catalog = CatalogUtil.buildIcebergCatalog("demo", properties, null); + SupportsNamespaces namespaceCatalog = (SupportsNamespaces) catalog; + Namespace namespace = Namespace.of(args[0]); + if (!namespaceCatalog.namespaceExists(namespace)) { + namespaceCatalog.createNamespace(namespace); + } + TableIdentifier identifier = TableIdentifier.of(namespace, args[1]); + if (catalog.tableExists(identifier)) { + catalog.dropTable(identifier); + } + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "geom", Types.GeometryType.crs84()), + Types.NestedField.optional(3, "geog", Types.GeographyType.crs84())); + Map<String, String> tableProperties = new HashMap<>(); + tableProperties.put("format-version", "3"); + tableProperties.put("write.format.default", "parquet"); + catalog.createTable(identifier, schema, PartitionSpec.unpartitioned(), tableProperties); + TableIdentifier nested = TableIdentifier.of(namespace, "nested_spatial_values"); + if (catalog.tableExists(nested)) { + catalog.dropTable(nested); + } + Table nestedTable = catalog.createTable(nested, new Schema( + Types.NestedField.optional(1, "shapes", + Types.ListType.ofOptional(2, Types.GeometryType.crs84()))), + PartitionSpec.unpartitioned(), tableProperties); + nestedTable.updateProperties().set("write.merge.mode", "merge-on-read").commit(); + TableIdentifier projected = TableIdentifier.of(namespace, "projected_values"); + if (catalog.tableExists(projected)) { + catalog.dropTable(projected); + } + Table projectedTable = catalog.createTable(projected, new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "geom", Types.GeometryType.of("EPSG:3857")), + Types.NestedField.optional(3, "geog", Types.GeographyType.of("OGC:CRS84", + org.apache.iceberg.types.EdgeAlgorithm.VINCENTY))), + PartitionSpec.unpartitioned(), tableProperties); + // A table property resolves the Iceberg CRS reference; it is unavailable to the BE file writer. + Map<String, String> referencedProperties = new HashMap<>(tableProperties); + referencedProperties.put("my_crs", "{\\"type\\":\\"GeographicCRS\\",\\"name\\":\\"WGS 84\\",\\"datum\\":{\\"type\\":\\"GeodeticReferenceFrame\\",\\"name\\":\\"World Geodetic System 1984\\",\\"ellipsoid\\":{\\"name\\":\\"WGS 84\\",\\"semi_major_axis\\":6378137,\\"inverse_flattening\\":298.257223563}},\\"coordinate_system\\":{\\"subtype\\":\\"ellipsoidal\\",\\"axis\\":[{\\"name\\":\\"Geodetic longitude\\",\\"abbreviation\\":\\"Lon\\",\\"direction\\":\\"east\\",\\"unit\\":\\"degree\\"},{\\"name\\":\\"Geodetic latitude\\",\\"abbreviation\\":\\"Lat\\",\\"direction\\":\\"north\\",\\"unit\\":\\"degree\\"}]}}"); + TableIdentifier referenced = TableIdentifier.of(namespace, "referenced_crs_values"); + if (catalog.tableExists(referenced)) { + catalog.dropTable(referenced); + } + Table referencedTable = catalog.createTable(referenced, new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "geom", Types.GeometryType.of("projjson:my_crs")), + Types.NestedField.optional(3, "geog", Types.GeographyType.of("projjson:my_crs", + org.apache.iceberg.types.EdgeAlgorithm.SPHERICAL))), + PartitionSpec.unpartitioned(), referencedProperties); + for (boolean isGeography : new boolean[] {false, true}) { + String column = isGeography ? "geog" : "geom"; + Schema copySchema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, column, isGeography + ? Types.GeographyType.of("projjson:my_crs", + org.apache.iceberg.types.EdgeAlgorithm.SPHERICAL) + : Types.GeometryType.of("projjson:my_crs"))); + for (boolean partitioned : new boolean[] {false, true}) { + String name = (isGeography ? "referenced_geography_copy" : "referenced_geometry_copy") + + (partitioned ? "_partitioned" : ""); + TableIdentifier copy = TableIdentifier.of(namespace, name); + if (catalog.tableExists(copy)) { + catalog.dropTable(copy); + } + Table copyTable = catalog.createTable(copy, copySchema, partitioned + ? PartitionSpec.builderFor(copySchema).identity("id").build() + : PartitionSpec.unpartitioned(), referencedProperties); + System.out.println("SPATIAL_DATA_LOCATION\\t" + name + "\\t" + + copyTable.properties().getOrDefault("write.data.path", copyTable.location() + "/data")); + } + } + // Iceberg 1.10 exposes spatial schema types but its generic Parquet writer does not implement them. + // Store standard WKB BYTE_ARRAY values with field ids; the Iceberg schema carries CRS/algorithm. + String parquetSchema = "message spatial { required int32 id = " + + projectedTable.schema().findField("id").fieldId() + "; optional binary geom = " + + projectedTable.schema().findField("geom").fieldId() + "; optional binary geog = " + + projectedTable.schema().findField("geog").fieldId() + "; }"; + MessageType message = MessageTypeParser.parseMessageType(parquetSchema); + Group row = new SimpleGroupFactory(message).newGroup().append("id", 1); + ByteBuffer point = ByteBuffer.allocate(21).order(ByteOrder.LITTLE_ENDIAN); + point.put((byte) 1).putInt(1).putDouble(1000).putDouble(2000).flip(); + row.append("geom", Binary.fromConstantByteBuffer(point)); + ByteBuffer geography = ByteBuffer.allocate(21).order(ByteOrder.LITTLE_ENDIAN); + geography.put((byte) 1).putInt(1).putDouble(1).putDouble(2).flip(); + row.append("geog", Binary.fromConstantByteBuffer(geography)); + appendParquetRow(projectedTable, message, row); + Group referencedRow = new SimpleGroupFactory(message).newGroup().append("id", 1); + referencedRow.append("geom", row.getBinary("geog", 0)); + referencedRow.append("geog", row.getBinary("geog", 0)); + appendParquetRow(referencedTable, message, referencedRow); + MessageType nestedMessage = MessageTypeParser.parseMessageType( + "message spatial_nested { optional group shapes (LIST) = 1 { " + + "repeated group list { optional binary element = 2; } } }"); + Group nestedRow = new SimpleGroupFactory(nestedMessage).newGroup(); + nestedRow.addGroup("shapes").addGroup("list").append("element", row.getBinary("geog", 0)); + appendParquetRow(nestedTable, nestedMessage, nestedRow); + createNestedSource(catalog, namespace, tableProperties); + createRequiredNestedTargets(catalog, namespace, tableProperties); + createOrcWriteTargets(catalog, namespace, tableProperties); + createEmbeddedNullCrsSources(catalog, namespace, tableProperties); + } +} +''' + String encodedJavaSource = javaSource.getBytes("UTF-8").encodeBase64().toString() + String fixtureOutput = executeCommand("${dockerCommand} exec ${sparkContainer} bash -lc '" + + "task_java_dir=\$(mktemp -d /tmp/iceberg-spatial-v3.XXXXXX) && " + + "echo ${encodedJavaSource} | base64 -d >\"\$task_java_dir/CreateIcebergSpatialV3Table.java\" && " + + "javac -cp \"/opt/spark/jars/*\" \"\$task_java_dir/CreateIcebergSpatialV3Table.java\" && " + + "java -cp \"\$task_java_dir:/opt/spark/jars/*\" " + + "CreateIcebergSpatialV3Table ${dbName} geometry_values'") + + def dataLocations = [:] + fixtureOutput.readLines().findAll { it.startsWith("SPATIAL_DATA_LOCATION\t") }.each { line -> + def fields = line.split("\t", 3) + dataLocations[fields[1]] = fields[2] + } + def minioClient = AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(new EndpointConfiguration("http://${externalEnvIp}:${minioPort}", "us-east-1")) + .withPathStyleAccessEnabled(true) + .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials("admin", "password"))) + .build() + def listDataObjects = { String table -> + String location = dataLocations[table] + assertNotNull(location, "Missing data location for ${table}") + def uri = new URI(location) + def objects = new TreeMap<String, Long>() + String token = null + while (true) { + def listing = minioClient.listObjectsV2(new ListObjectsV2Request() + .withBucketName(uri.host).withPrefix(uri.path.substring(1).replaceAll('/+$', '') + '/') + .withContinuationToken(token)) + listing.objectSummaries.each { objects[it.key] = it.size } + if (!listing.isTruncated()) { + break + } + token = listing.nextContinuationToken + } + return objects + } + + def createCatalog = { + sql """drop catalog if exists ${catalogName}""" + sql """ + create catalog ${catalogName} properties ( + 'type' = 'iceberg', + 'iceberg.catalog.type' = 'rest', + 'uri' = 'http://${externalEnvIp}:${restPort}', + 's3.access_key' = 'admin', + 's3.secret_key' = 'password', + 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}', + 's3.region' = 'us-east-1' + ) + """ + sql """switch ${catalogName}""" + sql """use ${dbName}""" + } + createCatalog() + test { + sql """create table spatial_invalid_geometry_crs (geom geometry('')) + engine=iceberg properties ('format-version' = '3')""" + exception "Spatial CRS must not be empty" + } + test { + sql """create table spatial_invalid_geography_crs (geog geography('', 'spherical')) + engine=iceberg properties ('format-version' = '3')""" + exception "Spatial CRS must not be empty" + } + test { + sql """create table spatial_invalid_algorithm (geog geography('OGC:CRS84', 'unknown')) + engine=iceberg properties ('format-version' = '3')""" + exception "Unsupported Geography algorithm" + } + sql """ + insert into geometry_values values + (1, ST_GeometryFromWKBTyped('0101000000000000000000F03F0000000000000040'), + ST_GeogFromWKB('0101000000000000000000F03F0000000000000040')), + (2, NULL, NULL), + (3, ST_GeometryFromWKBTyped('010100000000000000000008400000000000001040'), NULL), + (4, NULL, ST_GeogFromWKB('010100000000000000000008400000000000001040')) + """ + // Doris writes OPTIONAL GEOMETRY/GEOGRAPHY annotations; count each leaf separately + // to exercise definition-level COUNT pushdown, including SQL NULL rows. + for (boolean pushdown : [false, true]) { + "qt_optional_geometry_count_${pushdown}"("""select /*+ SET_VAR( + enable_push_down_no_group_agg=${pushdown}) */ + count(geom) from geometry_values""") + "qt_optional_geography_count_${pushdown}"("""select /*+ SET_VAR( + enable_push_down_no_group_agg=${pushdown}) */ + count(geog) from geometry_values""") + } + qt_spatial_schema """desc geometry_values""" + // These factories do not implement scalar spatial values. Fail in FE, including + // analytic DISTINCT, which is rewritten to multi_distinct_count after binding. + for (String column : ["geom", "geog"]) { + for (String query : [ + "select ${column}, count(*) from geometry_values group by ${column}", + "select id, ${column}, count(*) from geometry_values group by id, ${column}", + "select ${column}, count(*) from geometry_values group by grouping sets ((${column}), ())", + "select distinct ${column} from geometry_values", + "select distinct id, ${column} from geometry_values"]) { + test { + sql query + exception "GROUP BY and DISTINCT do not support spatial keys" + } + } + for (String expression : ["min(${column})", "max(${column})", + "min(${column}) over ()", "max(${column}) over ()", + "count(distinct ${column})", "count(distinct id, ${column})"]) { + test { + sql "select ${expression} from geometry_values" + exception "does not support spatial arguments" + } + } + // Compare only implemented types; MAP_CONTAINS_KEY must not inspect spatial values. + for (String expression : [ + "map_contains_value(map(id, ${column}), ${column})", + "map_contains_value(map(id, ${column}), NULL)", + "map_contains_value(map(id, id), ${column})", + "map_contains_value(map(id, array(${column})), array(${column}))", + "map_contains_key(map(${column}, id), ${column})", + "map_contains_key(map(${column}, id), NULL)", + "map_contains_key(map(id, ${column}), ${column})", + "map_contains_entry(map(id, ${column}), id, ${column})", + "map_contains_entry(map(id, ${column}), id, NULL)", + "map_contains_entry(map(id, id), id, ${column})", + "map_contains_entry(map(id, id), ${column}, id)", + "map_contains_entry(map(${column}, id), NULL, id)", + "map_contains_entry(map(id, array(${column})), id, array(${column}))"]) { + test { + sql """select ${expression} from geometry_values""" + exception "does not support spatial comparison" Review Comment: Fixed in b58b2e21698. The lookup and MAP aggregate/combinator cases that construct a MAP with a spatial key now expect the inner `map does not support spatial keys` error. Cases with a valid MAP and an invalid lookup still expect the comparison error. The Groovy suite compiles and all 46 covered expressions match their expected messages in the FE analyzer. ########## fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorSpatialWriteNormalizer.java: ########## @@ -0,0 +1,747 @@ +// 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.datasource.connector.converter; + +import org.apache.doris.catalog.Column; +import org.apache.doris.nereids.CTEContext; +import org.apache.doris.nereids.analyzer.UnboundFunction; +import org.apache.doris.nereids.properties.OrderKey; +import org.apache.doris.nereids.rules.analysis.ExpressionAnalyzer; +import org.apache.doris.nereids.rules.rewrite.PushProjectThroughUnion; +import org.apache.doris.nereids.trees.expressions.Alias; +import org.apache.doris.nereids.trees.expressions.CTEId; +import org.apache.doris.nereids.trees.expressions.Cast; +import org.apache.doris.nereids.trees.expressions.ExprId; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.NamedExpression; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; +import org.apache.doris.nereids.trees.expressions.functions.scalar.Array; +import org.apache.doris.nereids.trees.expressions.functions.scalar.Cardinality; +import org.apache.doris.nereids.trees.expressions.functions.scalar.CreateMap; +import org.apache.doris.nereids.trees.expressions.functions.scalar.CreateNamedStruct; +import org.apache.doris.nereids.trees.expressions.functions.scalar.CreateStruct; +import org.apache.doris.nereids.trees.expressions.literal.ArrayLiteral; +import org.apache.doris.nereids.trees.expressions.literal.Literal; +import org.apache.doris.nereids.trees.expressions.literal.MapLiteral; +import org.apache.doris.nereids.trees.expressions.literal.NullLiteral; +import org.apache.doris.nereids.trees.expressions.literal.StringLikeLiteral; +import org.apache.doris.nereids.trees.expressions.literal.StructLiteral; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.algebra.SetOperation.Qualifier; +import org.apache.doris.nereids.trees.plans.logical.LogicalCTE; +import org.apache.doris.nereids.trees.plans.logical.LogicalCTEAnchor; +import org.apache.doris.nereids.trees.plans.logical.LogicalCTEConsumer; +import org.apache.doris.nereids.trees.plans.logical.LogicalCTEProducer; +import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; +import org.apache.doris.nereids.trees.plans.logical.LogicalLimit; +import org.apache.doris.nereids.trees.plans.logical.LogicalOneRowRelation; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.nereids.trees.plans.logical.LogicalProject; +import org.apache.doris.nereids.trees.plans.logical.LogicalSort; +import org.apache.doris.nereids.trees.plans.logical.LogicalSubQueryAlias; +import org.apache.doris.nereids.trees.plans.logical.LogicalUnion; +import org.apache.doris.nereids.trees.plans.logical.ProjectProcessor; +import org.apache.doris.nereids.types.ArrayType; +import org.apache.doris.nereids.types.DataType; +import org.apache.doris.nereids.types.GeographyType; +import org.apache.doris.nereids.types.GeometryType; +import org.apache.doris.nereids.types.MapType; +import org.apache.doris.nereids.types.StructType; +import org.apache.doris.nereids.util.ExpressionUtils; +import org.apache.doris.nereids.util.TypeCoercionUtils; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.IntStream; + +/** Restores proven NULL producer values before spatial sink coercion. */ +public final class ConnectorSpatialWriteNormalizer { + private ConnectorSpatialWriteNormalizer() { + } + + public static LogicalPlan normalize(List<Column> targets, LogicalPlan source, CTEContext cteContext) { + return normalize(targets, source, IntStream.range(0, targets.size()).boxed() + .collect(ImmutableList.toImmutableList()), cteContext); + } + + /** Aligns data targets with source positions when a row-level sink also carries routing columns. */ + public static LogicalPlan normalize(List<Column> targets, LogicalPlan source, + List<Integer> sourceOrdinals, CTEContext cteContext) { + if (targets.stream().noneMatch(column -> column.getType().typeContainsSpatialType())) { + return source; + } + if (source instanceof LogicalUnion && ((LogicalUnion) source).getQualifier() == Qualifier.ALL) { + return normalizeUnionAll(targets, (LogicalUnion) source, sourceOrdinals, cteContext); + } + if (source instanceof LogicalSubQueryAlias) { + LogicalPlan child = (LogicalPlan) source.child(0); + LogicalPlan normalized = normalize(targets, child, sourceOrdinals, cteContext); + return normalized == child ? source : (LogicalPlan) source.withChildren(ImmutableList.of(normalized)); + } + if (source instanceof LogicalProject && isForwardingProject((LogicalProject<?>) source)) { + Plan input = source.child(0); + while (input instanceof LogicalSubQueryAlias) { + input = input.child(0); + } + Plan boundary = input; + while (boundary instanceof LogicalSubQueryAlias + || (boundary instanceof LogicalProject && isForwardingProject((LogicalProject<?>) boundary))) { + boundary = boundary.child(0); + } + if (boundary instanceof LogicalUnion && ((LogicalUnion) boundary).getQualifier() == Qualifier.ALL) { + // Reuse the existing safe forwarding/cast projection rule, including its ExprId handling. + Optional<Plan> pushed = ProjectProcessor.tryProcessProject( + ((LogicalProject<?>) source).getProjects(), input); + // Forwarding aliases can also repeat or prune outputs without moving calculations. + if (!pushed.isPresent() && input instanceof LogicalUnion + && ((LogicalProject<?>) source).getProjects().stream() + .allMatch(project -> project instanceof Slot + || (project instanceof Alias && project.child(0) instanceof Slot))) { + pushed = Optional.of(PushProjectThroughUnion.doPushProject( + ((LogicalProject<?>) source).getProjects(), (LogicalUnion) input)); + } + if (pushed.isPresent()) { + return normalize(targets, (LogicalPlan) pushed.get(), sourceOrdinals, cteContext); + } + } + } + Resolver resolver = new Resolver(source, cteContext); + List<NamedExpression> outputs = new ArrayList<>(source.getOutput()); + List<LogicalPlan> wrappers = new ArrayList<>(); + Set<ExprId> sortInputs = new HashSet<>(); + LogicalPlan producerInput = source; + while (producerInput instanceof LogicalSort || producerInput instanceof LogicalLimit) { + wrappers.add(producerInput); + for (Expression expression : producerInput.getExpressions()) { + // A direct CARDINALITY(map_slot) depends on the entry count, which retagging preserves. + if (!isMapCardinality(expression)) { + for (Slot input : expression.getInputSlots()) { + sortInputs.add(input.getExprId()); + } + } + } + producerInput = (LogicalPlan) producerInput.child(0); + } + LogicalProject<?> producer = producerInput instanceof LogicalProject + && !((LogicalProject<?>) producerInput).isDistinct() ? (LogicalProject<?>) producerInput : null; + List<NamedExpression> producerOutputs = null; + Map<ExprId, Slot> replacedSlots = new HashMap<>(); + boolean changed = false; + for (int i = 0; i < targets.size(); i++) { + Column target = targets.get(i); + if (!target.getType().typeContainsSpatialType()) { + continue; + } + int sourceOrdinal = sourceOrdinals.get(i); + Slot slot = source.getOutput().get(sourceOrdinal); + Expression value = resolver.resolve(slot, source); + DataType targetType = DataType.fromCatalogType(target.getType()); + boolean literal = isLiteralConstruction(value) || canRetargetSpatialNulls(value, targetType); + // SELECT/UPDATE reach the bound sink with CAST(ARRAY(NULL) AS ARRAY<INT>). + // Reuse MERGE's NULL-only proof before validation, without lifting arbitrary producers. + if (literal) { + value = normalizeSpatialNullsBeforeBinding(value, targetType); + } + if (!literal && producer != null && !sortInputs.contains(slot.getExprId()) + && sourceOrdinals.indexOf(sourceOrdinal) == sourceOrdinals.lastIndexOf(sourceOrdinal)) { + // Retag a constructor where it is computed, so its dynamic ordinary siblings + // still use the same inputs and are evaluated only once. Do not follow slots + // through joins, other producers, or a DISTINCT projection. + NamedExpression project = producer.getProjects().get(sourceOrdinal); + if (project instanceof Alias && canRetargetSpatialNulls(project.child(0), targetType, true)) { + Expression normalized = normalizeSpatialNullsBeforeBinding(project.child(0), targetType, true); + if (!normalized.getDataType().equals(slot.getDataType())) { + value = normalized; + if (producerOutputs == null) { + producerOutputs = new ArrayList<>(producer.getProjects()); + } + Alias replacement = new Alias(normalized, slot.getName()); + producerOutputs.set(sourceOrdinal, replacement); + Slot replacementSlot = replacement.toSlot(); + outputs.set(sourceOrdinal, replacementSlot); + replacedSlots.put(slot.getExprId(), replacementSlot); + } + } + } + ConnectorSpatialWriteValidator.validate(target, value); + // Never lift arbitrary producer expressions above their input scope, or + // replace outer-join NULL padding with a non-NULL container constant. + if (literal) { + Expression normalized = normalizeSpatialNulls(value, targetType); + if (!normalized.getDataType().equals(slot.getDataType())) { + // SELECT v,v can feed an ordinary BIGINT and a spatial NULL. Sharing + // v's ExprId would let project merging replace the ordinary cast's input. + outputs.set(sourceOrdinal, new Alias(normalized, slot.getName())); + changed = true; + } + } + } + LogicalPlan normalizedSource = source; + if (producerOutputs != null) { + normalizedSource = producer.withProjects(producerOutputs); + for (int i = wrappers.size() - 1; i >= 0; i--) { + LogicalPlan wrapper = wrappers.get(i); + if (wrapper instanceof LogicalSort) { + LogicalSort<?> sort = (LogicalSort<?>) wrapper; + List<OrderKey> keys = sort.getOrderKeys().stream().map(key -> { + Expression expr = key.getExpr(); + if (isMapCardinality(expr)) { + Slot input = replacedSlots.get(((Slot) expr.child(0)).getExprId()); + if (input != null) { + return key.withExpression(new Cardinality(input)); + } + } + return key; + }).collect(ImmutableList.toImmutableList()); + normalizedSource = sort.withOrderKeysAndChild(keys, normalizedSource); + } else { + normalizedSource = (LogicalPlan) wrapper.withChildren(ImmutableList.of(normalizedSource)); + } + } + } + return changed ? new LogicalProject<>(outputs, normalizedSource) : normalizedSource; + } + + private static boolean isMapCardinality(Expression expression) { + return expression instanceof Cardinality && expression.child(0) instanceof Slot + && expression.child(0).getDataType() instanceof MapType; + } + + private static boolean isForwardingProject(LogicalProject<?> project) { + return !project.isDistinct() && project.getProjects().stream().allMatch(output -> output instanceof Slot + || (output instanceof Alias && ExpressionUtils.getExpressionCoveredByCast(output.child(0)) + instanceof Slot)); + } + + private static LogicalPlan normalizeUnionAll(List<Column> targets, LogicalUnion source, + List<Integer> sourceOrdinals, CTEContext context) { + List<Plan> children = new ArrayList<>(); + List<List<SlotReference>> childOutputs = new ArrayList<>(); + boolean changed = !source.getConstantExprsList().isEmpty(); + for (int i = 0; i < source.arity(); i++) { + LogicalPlan child = (LogicalPlan) source.child(i); + List<SlotReference> outputs = source.getRegularChildOutput(i); + LogicalPlan input = child.getOutput().equals(outputs) + ? child : new LogicalProject<>(ImmutableList.<NamedExpression>copyOf(outputs), child); + LogicalPlan normalized = normalize(targets, input, sourceOrdinals, context); + children.add(normalized); + childOutputs.add(normalized.getOutput().stream().map(SlotReference.class::cast) + .collect(ImmutableList.toImmutableList())); + changed |= normalized != child; + } + // Constant rows use the same proof and CAST validation as ordinary branches. + // Keep them as separate rows; never replace an entire UNION output with one constant. + for (List<NamedExpression> row : source.getConstantExprsList()) { + LogicalPlan normalized = normalize(targets, + new LogicalOneRowRelation(StatementScopeIdGenerator.newRelationId(), row), + sourceOrdinals, context); + children.add(normalized); + childOutputs.add(normalized.getOutput().stream().map(SlotReference.class::cast) + .collect(ImmutableList.toImmutableList())); + } + if (!changed) { + return source; + } + List<NamedExpression> outputs = new ArrayList<>(); + for (int i = 0; i < source.getOutput().size(); i++) { + SlotReference slot = (SlotReference) source.getOutput().get(i); + DataType type = childOutputs.get(0).get(i).getDataType(); Review Comment: Fixed in b58b2e21698. After spatial NULL retagging, UNION ALL computes the set-operation common type for each output and applies the required casts to every branch, including constant rows. The regression covers both regular-branch orders and a constant branch. It reproduces the type mismatch on the previous implementation and passes with the fix. ########## be/src/format_v2/table/iceberg_reader.h: ########## @@ -74,6 +74,7 @@ class IcebergTableReader : public format::TableReader { protected: // Iceberg UUID uses the same 16-byte STRING/VARBINARY carrier in data, defaults and deletes. bool preserve_binary_uuid() const override { return true; } + bool parquet_spatial_annotations_as_bytes() const override { return false; } Review Comment: Fixed in b58b2e21698. Iceberg uses a raw-byte Parquet read type for spatially annotated leaves mapped to table BINARY fields. Fields declared GEOMETRY or GEOGRAPHY keep typed decoding. The actual-file test covers scalar and nested fields matched by ID, both annotations, opaque TRIANGLE WKB and NULLs. The Iceberg and Parquet BE test suites pass. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
