Gabriel39 commented on code in PR #65992: URL: https://github.com/apache/doris/pull/65992#discussion_r3644273461
########## regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_position_dv.groovy: ########## @@ -0,0 +1,242 @@ +// 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. + +suite("test_iceberg_partition_evolution_position_dv", + "p0,external,iceberg,external_docker,external_docker_iceberg") { + String enabled = context.config.otherConfigs.get("enableIcebergTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable iceberg test") + return + } + + 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") + String catalogName = "test_iceberg_partition_evolution_position_dv" + String dbName = "iceberg_partition_evolution_position_dv_db" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def latestSnapshotId = { String tableName -> + return spark_iceberg(""" + select snapshot_id + from demo.${dbName}.${tableName}.snapshots + order by committed_at desc + limit 1 + """)[0][0].toString() + } + + 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', + 'meta.cache.iceberg.table.ttl-second'='0' + ) + """ + + try { + ["parquet", "orc"].each { String format -> + [2, 3].each { int formatVersion -> + String deleteKind = formatVersion == 2 ? "position" : "dv" + String tableName = "${deleteKind}_${format}_evolved" + + spark_iceberg_multi """ + create database if not exists demo.${dbName}; + drop table if exists demo.${dbName}.${tableName}; + create table demo.${dbName}.${tableName} ( + id int, + category string, + event_time timestamp, + payload struct<metric:int, label:string> + ) using iceberg + partitioned by (category, days(event_time)) + tblproperties ( + 'format-version'='${formatVersion}', + 'write.format.default'='${format}', + 'write.delete.mode'='merge-on-read', + 'write.update.mode'='merge-on-read', + 'write.merge.mode'='merge-on-read', + 'write.distribution-mode'='none' + ); + insert into demo.${dbName}.${tableName} + select /*+ coalesce(1) */ id, category, event_time, payload from values + (1, 'A', timestamp '2026-01-01 01:00:00', + named_struct('metric', 10, 'label', 'base-a')), + (2, 'A', timestamp '2026-01-01 02:00:00', + named_struct('metric', 20, 'label', 'base-delete')), + (3, 'B', timestamp '2026-01-02 01:00:00', + named_struct('metric', 30, 'label', 'base-b')), + (4, 'C', timestamp '2026-01-03 01:00:00', + named_struct('metric', 40, 'label', 'base-c')) + as t(id, category, event_time, payload); + """ + String baseSnapshot = latestSnapshotId(tableName) + sql """ + alter table `${catalogName}`.`${dbName}`.`${tableName}` + create tag ${tableName}_base as of version ${baseSnapshot} + """ + + // Scenario PE-D01: delete against the original spec before any evolution. + spark_iceberg """ + delete from demo.${dbName}.${tableName} where id = 2 + """ + String firstDeleteSnapshot = latestSnapshotId(tableName) + + // Scenario PE-D02: ADD partition field and complex child, then delete a row written + // with the new spec. Old/new delete files must remain associated with their specs. + spark_iceberg_multi """ + alter table demo.${dbName}.${tableName} add partition field bucket(8, id); + alter table demo.${dbName}.${tableName} add column payload.extra string; + insert into demo.${dbName}.${tableName} + select /*+ coalesce(1) */ id, category, event_time, payload from values + (5, 'A', timestamp '2026-02-01 01:00:00', + named_struct('metric', 50, 'label', 'add-a', 'extra', 'new-child')), + (6, 'A', timestamp '2026-02-01 02:00:00', + named_struct('metric', 60, 'label', 'add-delete', 'extra', 'new-child')) + as t(id, category, event_time, payload); + delete from demo.${dbName}.${tableName} where id = 6; + """ + String addedDeleteSnapshot = latestSnapshotId(tableName) + + // Scenario PE-D03: REPLACE temporal transform and rename/promote nested fields. + spark_iceberg_multi """ + alter table demo.${dbName}.${tableName} + replace partition field days(event_time) with months(event_time); + alter table demo.${dbName}.${tableName} + rename column payload.label to renamed_label; + alter table demo.${dbName}.${tableName} + alter column payload.metric type bigint; + insert into demo.${dbName}.${tableName} values + (7, 'A', timestamp '2026-03-01 01:00:00', + named_struct('metric', 7000000000, + 'renamed_label', 'replace-a', 'extra', 'renamed-child')), + (8, 'A', timestamp '2026-03-01 02:00:00', + named_struct('metric', 80, + 'renamed_label', 'replace-delete', 'extra', 'renamed-child')); + """ + + // Scenario PE-D04: DROP identity field before the final delete. The matching row + // is in a spec without category, while older files still expose category partitions. + spark_iceberg_multi """ + alter table demo.${dbName}.${tableName} drop partition field category; + delete from demo.${dbName}.${tableName} where id = 8; + drop table if exists demo.${dbName}.${tableName}_dimension; + create table demo.${dbName}.${tableName}_dimension (category string) + using iceberg tblproperties ('format-version'='2'); + insert into demo.${dbName}.${tableName}_dimension values ('A'); + """ + String finalSnapshot = latestSnapshotId(tableName) + sql """ + alter table `${catalogName}`.`${dbName}`.`${tableName}` + create tag ${tableName}_final as of version ${finalSnapshot} + """ + + sql """switch ${catalogName}""" + sql """use ${dbName}""" + sql """refresh table ${tableName}""" + + // Scenario PE-D05: static partition filters apply every delete exactly once. + List<List<String>> expectedCurrent = [["1"], ["5"], ["7"]] + assertEquals(expectedCurrent, stringRows(""" + select id from ${tableName} where category = 'A' order by id + """)) + assertEquals([["7", "7000000000"]], stringRows(""" + select id, payload.metric from ${tableName} + where payload.metric > 5000000000 order by id + """)) + + // Scenario PE-D06: runtime filter on the dropped identity partition column returns + // the same delete-aware rows with partition pruning enabled and disabled. + String rfQuery = """ + select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ f.id + from ${tableName} f + join ${tableName}_dimension d on f.category = d.category + order by f.id + """ + sql """set runtime_filter_wait_infinitely=true""" Review Comment: 已在 profile 前显式设置 enable_runtime_filter_prune=false,并要求 RuntimeFilterPartitionPrunedRangeNum 与 PartitionsPrunedByRuntimeFilter 的合计大于 0。 ########## regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_runtime_filter.groovy: ########## @@ -0,0 +1,238 @@ +// 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 org.apache.doris.regression.action.ProfileAction + +suite("test_iceberg_partition_evolution_runtime_filter", Review Comment: 已将四个 profile 依赖 suite 标为 nonConcurrent,并在文档中说明其他独立 suite 仍可并发执行。 ########## regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_filter_refs.groovy: ########## @@ -0,0 +1,273 @@ +// 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. + +suite("test_iceberg_partition_evolution_filter_refs", + "p0,external,iceberg,external_docker,external_docker_iceberg") { + String enabled = context.config.otherConfigs.get("enableIcebergTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable iceberg test") + return + } + + 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") + String catalogName = "test_iceberg_partition_evolution_filter_refs" + String dbName = "iceberg_partition_evolution_filter_refs_db" + String identityTable = "identity_bucket_truncate_timeline" + String temporalTable = "temporal_transform_timeline" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def latestSnapshotId = { String tableName -> + List<List<Object>> rows = spark_iceberg """ + select snapshot_id + from demo.${dbName}.${tableName}.snapshots + order by committed_at desc + limit 1 + """ + assertEquals(1, rows.size()) + return rows[0][0].toString() + } + + 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', + 'meta.cache.iceberg.table.ttl-second'='0', + 'meta.cache.iceberg.schema.ttl-second'='0' + ) + """ + + try { + spark_iceberg_multi """ + create database if not exists demo.${dbName}; + drop table if exists demo.${dbName}.${identityTable}; + create table demo.${dbName}.${identityTable} ( + id int, + category string, + code string, + event_time timestamp, + payload struct<metric:int, label:string> + ) using iceberg + partitioned by (category, days(event_time)) + tblproperties ( + 'format-version'='2', + 'write.format.default'='parquet' + ); + insert into demo.${dbName}.${identityTable} values + (1, 'A', 'aa-1', timestamp '2026-01-01 01:00:00', + named_struct('metric', 10, 'label', 'base-a')), + (2, 'B', 'bb-1', timestamp '2026-01-02 01:00:00', + named_struct('metric', 20, 'label', 'base-b')); + """ + String identityBase = latestSnapshotId(identityTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${identityTable}` + create tag identity_base as of version ${identityBase} + """ + sql """ + alter table `${catalogName}`.`${dbName}`.`${identityTable}` + create branch identity_base_branch as of version ${identityBase} + """ + + // Scenario PE-I01: ADD bucket partition field and add a complex-type child in the same + // timeline. Filters must evaluate old files whose spec has no bucket field. + spark_iceberg_multi """ + alter table demo.${dbName}.${identityTable} add partition field bucket(8, id); + alter table demo.${dbName}.${identityTable} add column payload.extra string; + insert into demo.${dbName}.${identityTable} values + (3, 'A', 'aa-2', timestamp '2026-02-01 01:00:00', + named_struct('metric', 30, 'label', 'bucket-a', 'extra', 'add-child')), + (4, 'C', 'cc-1', timestamp '2026-02-02 01:00:00', + named_struct('metric', 40, 'label', 'bucket-c', 'extra', 'add-child')); + """ + String identityAdded = latestSnapshotId(identityTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${identityTable}` + create tag identity_added as of version ${identityAdded} + """ + + // Scenario PE-I02: REPLACE bucket with truncate while renaming/promoting nested children. + // Predicates on both old and new partition source columns must scan every applicable spec. + spark_iceberg_multi """ + alter table demo.${dbName}.${identityTable} + replace partition field bucket(8, id) with truncate(2, code); + alter table demo.${dbName}.${identityTable} + rename column payload.label to renamed_label; + alter table demo.${dbName}.${identityTable} + alter column payload.metric type bigint; + insert into demo.${dbName}.${identityTable} values + (5, 'A', 'aa-3', timestamp '2026-03-01 01:00:00', + named_struct('metric', 5000000000, 'renamed_label', 'truncate-a', + 'extra', 'renamed-child')), + (6, 'D', 'dd-1', timestamp '2026-03-02 01:00:00', + named_struct('metric', 60, 'renamed_label', 'truncate-d', + 'extra', 'renamed-child')); + """ + String identityReplaced = latestSnapshotId(identityTable) + + // Scenario PE-I03: DROP identity and temporal fields, then drop/re-add a nested name. + // New unpartitioned-by-category files and old identity-partitioned files coexist. + spark_iceberg_multi """ + alter table demo.${dbName}.${identityTable} drop partition field category; + alter table demo.${dbName}.${identityTable} drop partition field days(event_time); + alter table demo.${dbName}.${identityTable} drop column payload.extra; + alter table demo.${dbName}.${identityTable} add column payload.extra bigint; + insert into demo.${dbName}.${identityTable} values + (7, 'A', 'aa-4', timestamp '2026-04-01 01:00:00', + named_struct('metric', 70, 'renamed_label', 'dropped-partition', + 'extra', 7000)), + (8, 'E', 'ee-1', timestamp '2026-04-02 01:00:00', + named_struct('metric', 80, 'renamed_label', 'dropped-partition', + 'extra', 8000)); + """ + String identityDropped = latestSnapshotId(identityTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${identityTable}` + create tag identity_dropped as of version ${identityDropped} + """ + + spark_iceberg_multi """ + drop table if exists demo.${dbName}.${temporalTable}; + create table demo.${dbName}.${temporalTable} ( + id int, + event_time timestamp, + payload string + ) using iceberg + partitioned by (years(event_time)) + tblproperties ( + 'format-version'='2', + 'write.format.default'='orc' + ); + insert into demo.${dbName}.${temporalTable} values + (11, timestamp '2024-01-01 01:00:00', 'year-2024'), + (12, timestamp '2025-01-01 01:00:00', 'year-2025'); + """ + String temporalYear = latestSnapshotId(temporalTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${temporalTable}` + create tag temporal_year as of version ${temporalYear} + """ + + // Scenario PE-T01: REPLACE year -> month -> day -> hour across ORC files. + // Range and equality filters validate every temporal transform boundary. + spark_iceberg_multi """ + alter table demo.${dbName}.${temporalTable} + replace partition field years(event_time) with months(event_time); + insert into demo.${dbName}.${temporalTable} values + (13, timestamp '2026-02-01 01:00:00', 'month-feb'), + (14, timestamp '2026-03-01 01:00:00', 'month-mar'); + alter table demo.${dbName}.${temporalTable} + replace partition field months(event_time) with days(event_time); + insert into demo.${dbName}.${temporalTable} values + (15, timestamp '2026-04-03 01:00:00', 'day-03'), + (16, timestamp '2026-04-04 01:00:00', 'day-04'); + alter table demo.${dbName}.${temporalTable} + replace partition field days(event_time) with hours(event_time); + insert into demo.${dbName}.${temporalTable} values + (17, timestamp '2026-05-01 08:00:00', 'hour-08'), + (18, timestamp '2026-05-01 09:00:00', 'hour-09'); + """ + String temporalHour = latestSnapshotId(temporalTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${temporalTable}` + create tag temporal_hour as of version ${temporalHour} + """ + + sql """switch ${catalogName}""" + sql """use ${dbName}""" + sql """refresh catalog ${catalogName}""" + + // Scenario PE-F01: equality/range/IN/NULL-safe source-column filters span four specs. + assertEquals([["1"], ["3"], ["5"], ["7"]], stringRows(""" Review Comment: 已将稳定结果断言全部改为 qt/order_qt 风格,并提交 regression runner 生成的对应 .out 基线。 ########## regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_runtime_filter.groovy: ########## @@ -0,0 +1,238 @@ +// 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 org.apache.doris.regression.action.ProfileAction + +suite("test_iceberg_partition_evolution_runtime_filter", + "p0,external,iceberg,external_docker,external_docker_iceberg") { + String enabled = context.config.otherConfigs.get("enableIcebergTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable iceberg test") + return + } + + 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") + String catalogName = "test_iceberg_partition_evolution_runtime_filter" + String dbName = "iceberg_partition_evolution_runtime_filter_db" + String factTable = "evolved_fact" + String dimensionTable = "rf_dimension" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def latestSnapshotId = { + return spark_iceberg(""" + select snapshot_id + from demo.${dbName}.${factTable}.snapshots + order by committed_at desc + limit 1 + """)[0][0].toString() + } + def profileAction = new ProfileAction(context) + def profileCounterValues = { String profileText, String counterName -> + def values = [] + def matcher = profileText =~ ("(?m)^\\s*(?:-\\s*)?" + + java.util.regex.Pattern.quote(counterName) + ":\\s+([^\\n]+)") + while (matcher.find()) { + String valueText = matcher.group(1).toString() + def exact = valueText =~ /\(([0-9,]+)\)/ + def number = valueText =~ /([0-9,]+)/ + String rawValue = exact.find() ? exact.group(1) : (number.find() ? number.group(1) : null) + if (rawValue != null) { + values.add(Long.parseLong(rawValue.replace(",", ""))) + } + } + return values + } + def assertRuntimeFilterPruned = { String queryBody -> + String token = UUID.randomUUID().toString() + List<List<String>> rows = stringRows(""" + select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ + '${token}', f.id + ${queryBody} + order by f.id + """) + // Scanner counters can arrive after the profile list first reports COMPLETE. + String profile = profileAction.getProfileBySql( + token, + ["RuntimeFilterPartitionPrunedRangeNum"], + 30000L, + 500L) + long fileRangesPruned = profileCounterValues( + profile, "RuntimeFilterPartitionPrunedRangeNum").sum(0L) + long partitionsPruned = profileCounterValues( + profile, "PartitionsPrunedByRuntimeFilter").sum(0L) + assertTrue(fileRangesPruned + partitionsPruned > 0L, + "Runtime filter did not prune any evolved Iceberg partition/file range; " + + profile.take(2000).replaceAll("\\s+", " ")) + return rows.collect { row -> [row[1]] } + } + + 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', + 'meta.cache.iceberg.table.ttl-second'='0' + ) + """ + + try { + spark_iceberg_multi """ + create database if not exists demo.${dbName}; + drop table if exists demo.${dbName}.${factTable}; + create table demo.${dbName}.${factTable} ( + id int, + category string, + event_time timestamp, + payload struct<metric:int> + ) using iceberg + partitioned by (category, days(event_time)) + tblproperties ('format-version'='2'); + insert into demo.${dbName}.${factTable} values + (1, 'A', timestamp '2026-01-01 01:00:00', named_struct('metric', 10)), + (2, 'B', timestamp '2026-01-02 01:00:00', named_struct('metric', 20)), + (3, 'C', timestamp '2026-01-03 01:00:00', named_struct('metric', 30)); + """ + String baseSnapshot = latestSnapshotId() + sql """ + alter table `${catalogName}`.`${dbName}`.`${factTable}` + create tag rf_base as of version ${baseSnapshot} + """ + + // Scenario PE-RF01: add a partition field and evolve a nested payload between data files. + spark_iceberg_multi """ + alter table demo.${dbName}.${factTable} add partition field bucket(8, id); + alter table demo.${dbName}.${factTable} add column payload.label string; + insert into demo.${dbName}.${factTable} values + (4, 'A', timestamp '2026-02-01 01:00:00', + named_struct('metric', 40, 'label', 'add-spec')), + (5, 'D', timestamp '2026-02-02 01:00:00', + named_struct('metric', 50, 'label', 'add-spec')); + """ + String addedSnapshot = latestSnapshotId() + + // Scenario PE-RF02: replace a temporal transform. Runtime filters on event_time must + // translate against the transform of each file's own spec. + spark_iceberg_multi """ + alter table demo.${dbName}.${factTable} + replace partition field days(event_time) with months(event_time); + insert into demo.${dbName}.${factTable} values + (6, 'A', timestamp '2026-03-01 01:00:00', + named_struct('metric', 60, 'label', 'replace-spec')), + (7, 'E', timestamp '2026-03-02 01:00:00', + named_struct('metric', 70, 'label', 'replace-spec')); + """ + + // Scenario PE-RF03: drop the identity field. New files have no category partition value; + // runtime pruning may only discard older ranges and must still scan matching new rows. + spark_iceberg_multi """ + alter table demo.${dbName}.${factTable} drop partition field category; + insert into demo.${dbName}.${factTable} values + (8, 'A', timestamp '2026-04-01 01:00:00', + named_struct('metric', 80, 'label', 'drop-spec')), + (9, 'F', timestamp '2026-04-02 01:00:00', + named_struct('metric', 90, 'label', 'drop-spec')); + drop table if exists demo.${dbName}.${dimensionTable}; + create table demo.${dbName}.${dimensionTable} ( + category string, + lower_time timestamp, + upper_time timestamp + ) using iceberg + tblproperties ('format-version'='2'); + insert into demo.${dbName}.${dimensionTable} values + ('A', timestamp '2026-03-01 00:00:00', timestamp '2026-05-01 00:00:00'); + """ + String droppedSnapshot = latestSnapshotId() + sql """ + alter table `${catalogName}`.`${dbName}`.`${factTable}` + create tag rf_dropped as of version ${droppedSnapshot} + """ + + sql """switch ${catalogName}""" + sql """use ${dbName}""" + sql """refresh catalog ${catalogName}""" + sql """set enable_profile=true""" + sql """set profile_level=2""" + // Small fixture tables have no column statistics. Keep the generated RF so this suite + // validates scanner-side partition pruning instead of optimizer selectivity heuristics. + sql """set enable_runtime_filter_prune=false""" + sql """set runtime_filter_wait_infinitely=true""" + sql """set runtime_filter_mode=GLOBAL""" + sql """set parallel_pipeline_task_num=1""" + sql """set disable_join_reorder=true""" + + String currentCategoryJoin = """ + from ${factTable} f + join ${dimensionTable} d on f.category = d.category + """ + String currentTemporalJoin = """ + from ${factTable} f + join ${dimensionTable} d + on f.event_time >= d.lower_time and f.event_time < d.upper_time + """ + + // Scenario PE-RF04: result parity with RF disabled protects correctness. + sql """set enable_runtime_filter_partition_prune=false""" + assertEquals([["1"], ["4"], ["6"], ["8"]], stringRows(""" + select f.id ${currentCategoryJoin} order by f.id + """)) + assertEquals([["6"], ["7"], ["8"], ["9"]], stringRows(""" + select f.id ${currentTemporalJoin} order by f.id + """)) + + // Scenario PE-RF05: current multi-spec scan must both return the same rows and show + // physical pruning in the profile. + sql """set enable_runtime_filter_partition_prune=true""" + assertEquals([["1"], ["4"], ["6"], ["8"]], Review Comment: 已拆分可归因路径:category drop 与新增 identity region 均独立 profile 且要求正计数;bucket source 与 temporal 明确仅做结果契约,覆盖文档不再声称这些 transform 可物理 RF pruning。 ########## regression-test/suites/external_table_p0/paimon/test_paimon_partition_pk_delete_refs.groovy: ########## @@ -0,0 +1,218 @@ +// 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. + +suite("test_paimon_partition_pk_delete_refs", + "p0,external,paimon,external_docker,external_docker_paimon") { + String enabled = context.config.otherConfigs.get("enablePaimonTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable paimon test") + return + } + + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String catalogName = "test_paimon_partition_pk_delete_refs" + String dbName = "paimon_partition_pk_delete_refs_db" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def latestSnapshotId = { String tableName -> + return spark_paimon(""" + select snapshot_id + from paimon.${dbName}.`${tableName}\$snapshots` + order by snapshot_id desc + limit 1 + """)[0][0].toString() + } + def createTag = { String tableName, String tagName -> + spark_paimon """ + call paimon.sys.create_tag( + table => '${dbName}.${tableName}', + tag => '${tagName}' + ) + """ + } + + sql """drop catalog if exists ${catalogName}""" + sql """ + create catalog ${catalogName} properties ( + 'type'='paimon', + 'warehouse'='s3://warehouse/wh', + 's3.endpoint'='http://${externalEnvIp}:${minioPort}', + 's3.access_key'='admin', + 's3.secret_key'='password', + 's3.path.style.access'='true', + 'meta.cache.paimon.table.ttl-second'='0' + ) + """ + + try { + ["parquet", "orc"].each { String format -> + String tableName = "partition_pk_dv_${format}" + String dimensionTable = "${tableName}_dimension" + spark_paimon_multi """ + create database if not exists paimon.${dbName}; + drop table if exists paimon.${dbName}.${tableName}; + create table paimon.${dbName}.${tableName} ( + id int not null, + part string not null, + old_name string, + note string, + payload struct<metric:int, label:string> + ) using paimon + partitioned by (part) + tblproperties ( + 'bucket'='1', + 'primary-key'='part,id', + 'file.format'='${format}', + 'deletion-vectors.enabled'='true' + ); + insert into paimon.${dbName}.${tableName} values + (1, 'p1', 'alpha', 'old-note-1', + named_struct('metric', 10, 'label', 'base-1')), + (2, 'p1', 'beta', 'old-note-2', + named_struct('metric', 20, 'label', 'base-delete')), + (3, 'p2', 'gamma', 'old-note-3', + named_struct('metric', 30, 'label', 'base-p2')); + """ + String baseSnapshot = latestSnapshotId(tableName) + createTag(tableName, "${tableName}_base") + + // Scenario PM-D01: add/rename fields, upsert one PK and delete another inside p1. + spark_paimon_multi """ + alter table paimon.${dbName}.${tableName} add column payload.extra string; + alter table paimon.${dbName}.${tableName} rename column old_name to full_name; + insert into paimon.${dbName}.${tableName} + (id, part, full_name, note, payload) values + (1, 'p1', 'alpha-updated', 'new-note-1', + named_struct('metric', 11, 'label', 'updated-1', 'extra', 'extra-1')), + (4, 'p1', 'delta', 'delete-later', + named_struct('metric', 40, 'label', 'insert-4', 'extra', 'extra-4')); + delete from paimon.${dbName}.${tableName} + where part = 'p1' and id = 2; + """ + String firstDeleteSnapshot = latestSnapshotId(tableName) + createTag(tableName, "${tableName}_first_delete") + + // Scenario PM-D02: nested rename/type promotion and drop/re-add combine with another + // delete, insert and full compaction while the partition key stays fixed. + spark_paimon_multi """ + alter table paimon.${dbName}.${tableName} + rename column payload.label to renamed_label; + alter table paimon.${dbName}.${tableName} + alter column payload.metric type bigint; + alter table paimon.${dbName}.${tableName} drop column note; + alter table paimon.${dbName}.${tableName} add column note bigint; + delete from paimon.${dbName}.${tableName} + where part = 'p1' and id = 4; + insert into paimon.${dbName}.${tableName} + (id, part, full_name, payload, note) values + (5, 'p1', 'epsilon', + named_struct('metric', 5000000000, + 'renamed_label', 'insert-5', 'extra', 'extra-5'), + 5000); + call paimon.sys.compact( + table => '${dbName}.${tableName}', + compact_strategy => 'full' + ); + drop table if exists paimon.${dbName}.${dimensionTable}; + create table paimon.${dbName}.${dimensionTable} (part string) + using paimon tblproperties ('file.format'='parquet'); + insert into paimon.${dbName}.${dimensionTable} values ('p1'); + """ + String finalSnapshot = latestSnapshotId(tableName) + createTag(tableName, "${tableName}_final") + + sql """switch ${catalogName}""" + sql """use ${dbName}""" + sql """refresh table ${tableName}""" + + // Scenario PM-D03: static partition filters apply PK upserts, deletes and DV state. + List<List<String>> expectedCurrent = [["1", "alpha-updated"], ["5", "epsilon"]] + assertEquals(expectedCurrent, stringRows(""" + select id, full_name from ${tableName} + where part = 'p1' order by id + """)) + assertEquals([["5", "5000000000", "5000"]], stringRows(""" + select id, payload.metric, note from ${tableName} + where part = 'p1' and payload.metric > 1000000000 + order by id + """)) + + // Scenario PM-D04: runtime-filter pruning on the partition key remains delete-aware. + String rfQuery = """ + select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ + f.id, f.full_name + from ${tableName} f + join ${dimensionTable} d on f.part = d.part + order by f.id + """ + sql """set runtime_filter_wait_infinitely=true""" Review Comment: 已显式设置 enable_runtime_filter_prune=false,并以 profile 的正 pruning counter 证明 Paimon PK 分区 RF 生效。 ########## regression-test/suites/external_table_p0/paimon/test_paimon_partition_pk_delete_refs.groovy: ########## @@ -0,0 +1,218 @@ +// 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. + +suite("test_paimon_partition_pk_delete_refs", + "p0,external,paimon,external_docker,external_docker_paimon") { + String enabled = context.config.otherConfigs.get("enablePaimonTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable paimon test") + return + } + + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String catalogName = "test_paimon_partition_pk_delete_refs" + String dbName = "paimon_partition_pk_delete_refs_db" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def latestSnapshotId = { String tableName -> + return spark_paimon(""" + select snapshot_id + from paimon.${dbName}.`${tableName}\$snapshots` + order by snapshot_id desc + limit 1 + """)[0][0].toString() + } + def createTag = { String tableName, String tagName -> + spark_paimon """ + call paimon.sys.create_tag( + table => '${dbName}.${tableName}', + tag => '${tagName}' + ) + """ + } + + sql """drop catalog if exists ${catalogName}""" + sql """ + create catalog ${catalogName} properties ( + 'type'='paimon', + 'warehouse'='s3://warehouse/wh', + 's3.endpoint'='http://${externalEnvIp}:${minioPort}', + 's3.access_key'='admin', + 's3.secret_key'='password', + 's3.path.style.access'='true', + 'meta.cache.paimon.table.ttl-second'='0' + ) + """ + + try { + ["parquet", "orc"].each { String format -> + String tableName = "partition_pk_dv_${format}" + String dimensionTable = "${tableName}_dimension" + spark_paimon_multi """ + create database if not exists paimon.${dbName}; + drop table if exists paimon.${dbName}.${tableName}; + create table paimon.${dbName}.${tableName} ( + id int not null, + part string not null, + old_name string, + note string, + payload struct<metric:int, label:string> + ) using paimon + partitioned by (part) + tblproperties ( + 'bucket'='1', + 'primary-key'='part,id', + 'file.format'='${format}', + 'deletion-vectors.enabled'='true' + ); + insert into paimon.${dbName}.${tableName} values + (1, 'p1', 'alpha', 'old-note-1', + named_struct('metric', 10, 'label', 'base-1')), + (2, 'p1', 'beta', 'old-note-2', + named_struct('metric', 20, 'label', 'base-delete')), + (3, 'p2', 'gamma', 'old-note-3', + named_struct('metric', 30, 'label', 'base-p2')); + """ + String baseSnapshot = latestSnapshotId(tableName) + createTag(tableName, "${tableName}_base") + + // Scenario PM-D01: add/rename fields, upsert one PK and delete another inside p1. + spark_paimon_multi """ + alter table paimon.${dbName}.${tableName} add column payload.extra string; + alter table paimon.${dbName}.${tableName} rename column old_name to full_name; + insert into paimon.${dbName}.${tableName} + (id, part, full_name, note, payload) values + (1, 'p1', 'alpha-updated', 'new-note-1', + named_struct('metric', 11, 'label', 'updated-1', 'extra', 'extra-1')), + (4, 'p1', 'delta', 'delete-later', + named_struct('metric', 40, 'label', 'insert-4', 'extra', 'extra-4')); + delete from paimon.${dbName}.${tableName} + where part = 'p1' and id = 2; + """ + String firstDeleteSnapshot = latestSnapshotId(tableName) + createTag(tableName, "${tableName}_first_delete") + + // Scenario PM-D02: nested rename/type promotion and drop/re-add combine with another + // delete, insert and full compaction while the partition key stays fixed. + spark_paimon_multi """ + alter table paimon.${dbName}.${tableName} + rename column payload.label to renamed_label; + alter table paimon.${dbName}.${tableName} + alter column payload.metric type bigint; + alter table paimon.${dbName}.${tableName} drop column note; Review Comment: 已新增 payload.extra、payload.renamed_label 和 drop/re-add note 的 current/history 投影;旧存活 row 1 对新 BIGINT note 的值明确断言为 NULL。 ########## regression-test/suites/external_table_p0/iceberg_paimon_schema_time_travel_coverage.md: ########## @@ -11,6 +11,47 @@ The tests use explicit old/new field projections and negative bindings in addition to row-shape assertions. This prevents a rename with unchanged values from passing accidentally. +The partition-evolution extension also distinguishes result correctness from +physical pruning. Static predicates are validated across files written by +different partition specs, while runtime-filter cases require both equal +results with pruning disabled/enabled and a positive pruning counter. + +## Partition evolution matrix + +### Iceberg + +| Partition operation / transform | Static filter | Runtime filter | Snapshot/tag/branch | Position delete | Equality delete | Deletion vector | Complex schema in same timeline | Format / reader | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Add identity field | Covered | Covered | Covered | Covered | Covered | Covered | Covered | Parquet/ORC, V1/V2 | +| Drop identity field | Covered | Covered | Covered | Covered | Covered | Covered | Covered | Parquet/ORC, V1/V2 | +| Add/drop bucket | Covered | Covered by source-column join | Covered | Covered | Covered | Covered | Covered | Parquet/ORC, V1/V2 | +| Replace bucket with truncate | Covered | Result contract; transform is not RF-prunable | Covered | Covered by delete timeline | Covered by delete timeline | Covered by delete timeline | Covered | Parquet/ORC, V1/V2 | +| Year → month → day → hour | Covered | Covered for supported temporal transforms | Covered | Day → month covered | Day → month covered | Day → month covered | Covered | Parquet/ORC, V1/V2 | Review Comment: 已新增 test_iceberg_partition_evolution_format_scanner.groovy,在 Parquet/ORC × scanner V1/V2 下运行完整 identity/bucket-to-truncate/drop 与 year-to-month-to-day-to-hour 的 current/tag timeline;文档也同步收窄 RF 与并发声明。 ########## regression-test/suites/external_table_p0/iceberg_paimon_schema_time_travel_coverage.md: ########## @@ -11,6 +11,47 @@ The tests use explicit old/new field projections and negative bindings in addition to row-shape assertions. This prevents a rename with unchanged values from passing accidentally. +The partition-evolution extension also distinguishes result correctness from +physical pruning. Static predicates are validated across files written by +different partition specs, while runtime-filter cases require both equal +results with pruning disabled/enabled and a positive pruning counter. + +## Partition evolution matrix + +### Iceberg + +| Partition operation / transform | Static filter | Runtime filter | Snapshot/tag/branch | Position delete | Equality delete | Deletion vector | Complex schema in same timeline | Format / reader | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Add identity field | Covered | Covered | Covered | Covered | Covered | Covered | Covered | Parquet/ORC, V1/V2 | +| Drop identity field | Covered | Covered | Covered | Covered | Covered | Covered | Covered | Parquet/ORC, V1/V2 | +| Add/drop bucket | Covered | Covered by source-column join | Covered | Covered | Covered | Covered | Covered | Parquet/ORC, V1/V2 | +| Replace bucket with truncate | Covered | Result contract; transform is not RF-prunable | Covered | Covered by delete timeline | Covered by delete timeline | Covered by delete timeline | Covered | Parquet/ORC, V1/V2 | Review Comment: 已在 equality delete 和 position/DV timeline 中加入 bucket(8,id) 到 truncate(2,code) 的 replacement,并新增完整 format/reader timeline。 ########## regression-test/suites/external_table_p0/paimon/test_paimon_partition_pk_delete_refs.groovy: ########## @@ -0,0 +1,218 @@ +// 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. + +suite("test_paimon_partition_pk_delete_refs", + "p0,external,paimon,external_docker,external_docker_paimon") { + String enabled = context.config.otherConfigs.get("enablePaimonTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable paimon test") + return + } + + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String catalogName = "test_paimon_partition_pk_delete_refs" + String dbName = "paimon_partition_pk_delete_refs_db" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def latestSnapshotId = { String tableName -> + return spark_paimon(""" + select snapshot_id + from paimon.${dbName}.`${tableName}\$snapshots` + order by snapshot_id desc + limit 1 + """)[0][0].toString() + } + def createTag = { String tableName, String tagName -> + spark_paimon """ + call paimon.sys.create_tag( + table => '${dbName}.${tableName}', + tag => '${tagName}' + ) + """ + } + + sql """drop catalog if exists ${catalogName}""" + sql """ + create catalog ${catalogName} properties ( + 'type'='paimon', + 'warehouse'='s3://warehouse/wh', + 's3.endpoint'='http://${externalEnvIp}:${minioPort}', + 's3.access_key'='admin', + 's3.secret_key'='password', + 's3.path.style.access'='true', + 'meta.cache.paimon.table.ttl-second'='0' + ) + """ + + try { + ["parquet", "orc"].each { String format -> + String tableName = "partition_pk_dv_${format}" + String dimensionTable = "${tableName}_dimension" + spark_paimon_multi """ + create database if not exists paimon.${dbName}; + drop table if exists paimon.${dbName}.${tableName}; + create table paimon.${dbName}.${tableName} ( + id int not null, + part string not null, + old_name string, + note string, + payload struct<metric:int, label:string> + ) using paimon + partitioned by (part) + tblproperties ( + 'bucket'='1', + 'primary-key'='part,id', + 'file.format'='${format}', + 'deletion-vectors.enabled'='true' + ); + insert into paimon.${dbName}.${tableName} values + (1, 'p1', 'alpha', 'old-note-1', + named_struct('metric', 10, 'label', 'base-1')), + (2, 'p1', 'beta', 'old-note-2', + named_struct('metric', 20, 'label', 'base-delete')), + (3, 'p2', 'gamma', 'old-note-3', + named_struct('metric', 30, 'label', 'base-p2')); + """ + String baseSnapshot = latestSnapshotId(tableName) + createTag(tableName, "${tableName}_base") + + // Scenario PM-D01: add/rename fields, upsert one PK and delete another inside p1. + spark_paimon_multi """ + alter table paimon.${dbName}.${tableName} add column payload.extra string; + alter table paimon.${dbName}.${tableName} rename column old_name to full_name; + insert into paimon.${dbName}.${tableName} + (id, part, full_name, note, payload) values + (1, 'p1', 'alpha-updated', 'new-note-1', + named_struct('metric', 11, 'label', 'updated-1', 'extra', 'extra-1')), + (4, 'p1', 'delta', 'delete-later', + named_struct('metric', 40, 'label', 'insert-4', 'extra', 'extra-4')); + delete from paimon.${dbName}.${tableName} + where part = 'p1' and id = 2; + """ + String firstDeleteSnapshot = latestSnapshotId(tableName) + createTag(tableName, "${tableName}_first_delete") + + // Scenario PM-D02: nested rename/type promotion and drop/re-add combine with another + // delete, insert and full compaction while the partition key stays fixed. + spark_paimon_multi """ + alter table paimon.${dbName}.${tableName} + rename column payload.label to renamed_label; + alter table paimon.${dbName}.${tableName} + alter column payload.metric type bigint; + alter table paimon.${dbName}.${tableName} drop column note; + alter table paimon.${dbName}.${tableName} add column note bigint; + delete from paimon.${dbName}.${tableName} + where part = 'p1' and id = 4; + insert into paimon.${dbName}.${tableName} + (id, part, full_name, payload, note) values + (5, 'p1', 'epsilon', + named_struct('metric', 5000000000, + 'renamed_label', 'insert-5', 'extra', 'extra-5'), + 5000); + call paimon.sys.compact( + table => '${dbName}.${tableName}', + compact_strategy => 'full' + ); + drop table if exists paimon.${dbName}.${dimensionTable}; + create table paimon.${dbName}.${dimensionTable} (part string) + using paimon tblproperties ('file.format'='parquet'); + insert into paimon.${dbName}.${dimensionTable} values ('p1'); + """ + String finalSnapshot = latestSnapshotId(tableName) + createTag(tableName, "${tableName}_final") + + sql """switch ${catalogName}""" + sql """use ${dbName}""" + sql """refresh table ${tableName}""" + + // Scenario PM-D03: static partition filters apply PK upserts, deletes and DV state. + List<List<String>> expectedCurrent = [["1", "alpha-updated"], ["5", "epsilon"]] + assertEquals(expectedCurrent, stringRows(""" + select id, full_name from ${tableName} + where part = 'p1' order by id + """)) + assertEquals([["5", "5000000000", "5000"]], stringRows(""" + select id, payload.metric, note from ${tableName} + where part = 'p1' and payload.metric > 1000000000 + order by id + """)) + + // Scenario PM-D04: runtime-filter pruning on the partition key remains delete-aware. + String rfQuery = """ + select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ + f.id, f.full_name + from ${tableName} f + join ${dimensionTable} d on f.part = d.part + order by f.id + """ + sql """set runtime_filter_wait_infinitely=true""" + sql """set disable_join_reorder=true""" + sql """set enable_runtime_filter_partition_prune=false""" + assertEquals(expectedCurrent, stringRows(rfQuery)) + sql """set enable_runtime_filter_partition_prune=true""" + assertEquals(expectedCurrent, stringRows(rfQuery)) + + // Scenario PM-D05: numeric snapshots and tags preserve the matching schema/delete set. + assertEquals([["1", "alpha"], ["2", "beta"]], stringRows(""" + select id, old_name + from ${tableName} for version as of ${baseSnapshot} + where part = 'p1' order by id + """)) + assertEquals([["1", "alpha"], ["2", "beta"]], stringRows(""" + select id, old_name + from ${tableName}@tag(${tableName}_base) + where part = 'p1' order by id + """)) + assertEquals([["1", "alpha-updated"], ["4", "delta"]], stringRows(""" + select id, full_name + from ${tableName} for version as of ${firstDeleteSnapshot} + where part = 'p1' order by id + """)) + assertEquals(expectedCurrent, stringRows(""" + select id, full_name + from ${tableName}@tag(${tableName}_final) + where part = 'p1' order by id + """)) + + // Scenario PM-D06: JNI/native readers agree after partitioned PK deletes and compaction. + sql """set enable_paimon_cpp_reader=false""" + sql """set force_jni_scanner=true""" + List<List<String>> jniRows = stringRows(""" + select id, part, full_name from ${tableName} + where part in ('p1', 'p2') order by part, id + """) + sql """set force_jni_scanner=false""" Review Comment: 已用 explain 中 paimonNativeReadSplits 与 SplitStat 精确验证 JNI/native 路径;通过 $files.deleteRowCount 证明物理 DV,并在两种 reader 下比较 current evolved fields 与 historical ref;sibling schema suite 也同步补齐。 ########## regression-test/suites/external_table_p0/paimon/test_paimon_partition_schema_filter_refs.groovy: ########## @@ -0,0 +1,290 @@ +// 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 org.apache.doris.regression.action.ProfileAction + +suite("test_paimon_partition_schema_filter_refs", + "p0,external,paimon,external_docker,external_docker_paimon") { + String enabled = context.config.otherConfigs.get("enablePaimonTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable paimon test") + return + } + + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String catalogName = "test_paimon_partition_schema_filter_refs" + String dbName = "paimon_partition_schema_filter_refs_db" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def latestSnapshotId = { String tableName -> + return spark_paimon(""" + select snapshot_id + from paimon.${dbName}.`${tableName}\$snapshots` + order by snapshot_id desc + limit 1 + """)[0][0].toString() + } + def createTag = { String tableName, String tagName -> + spark_paimon """ + call paimon.sys.create_tag( + table => '${dbName}.${tableName}', + tag => '${tagName}' + ) + """ + } + def profileAction = new ProfileAction(context) + def profileCounterValues = { String profileText, String counterName -> + def values = [] + def matcher = profileText =~ ("(?m)^\\s*(?:-\\s*)?" + + java.util.regex.Pattern.quote(counterName) + ":\\s+([^\\n]+)") + while (matcher.find()) { + def number = matcher.group(1).toString() =~ /([0-9,]+)/ + if (number.find()) { + values.add(Long.parseLong(number.group(1).replace(",", ""))) + } + } + return values + } + def assertRuntimeFilterPruned = { String tableName, String dimensionTable, + List<List<String>> expectedRows -> + String token = UUID.randomUUID().toString() + assertEquals(expectedRows, stringRows(""" + select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ + '${token}', f.id + from ${tableName} f join ${dimensionTable} d on f.part = d.part + order by f.id + """).collect { row -> [row[1]] }) + // Scanner counters can arrive after the profile list first reports COMPLETE. + String profile = profileAction.getProfileBySql( + token, + ["RuntimeFilterPartitionPrunedRangeNum"], + 30000L, + 500L) + long fileRangesPruned = profileCounterValues( + profile, "RuntimeFilterPartitionPrunedRangeNum").sum(0L) + long partitionsPruned = profileCounterValues( + profile, "PartitionsPrunedByRuntimeFilter").sum(0L) + assertTrue(fileRangesPruned + partitionsPruned > 0L, + "Runtime filter did not prune any Paimon partition/file range; " + + profile.take(2000).replaceAll("\\s+", " ")) + } + + sql """drop catalog if exists ${catalogName}""" + sql """ + create catalog ${catalogName} properties ( + 'type'='paimon', + 'warehouse'='s3://warehouse/wh', + 's3.endpoint'='http://${externalEnvIp}:${minioPort}', + 's3.access_key'='admin', + 's3.secret_key'='password', + 's3.path.style.access'='true', + 'meta.cache.paimon.table.ttl-second'='0' + ) + """ + + try { + ["parquet", "orc"].each { String format -> + String tableName = "partition_schema_${format}" + String dimensionTable = "${tableName}_dimension" + spark_paimon_multi """ + create database if not exists paimon.${dbName}; + drop table if exists paimon.${dbName}.${tableName}; + create table paimon.${dbName}.${tableName} ( + id int, + part string, + payload struct<metric:int, label:string>, + attrs map<string, struct<code:int>>, + events array<struct<score:int>> + ) using paimon + partitioned by (part) + tblproperties ('file.format'='${format}'); + insert into paimon.${dbName}.${tableName} values + (1, 'p1', named_struct('metric', 10, 'label', 'base-1'), + map('k', named_struct('code', 100)), + array(named_struct('score', 1000))), + (2, 'p2', named_struct('metric', 20, 'label', 'base-2'), + map('k', named_struct('code', 200)), + array(named_struct('score', 2000))); + """ + String baseSnapshot = latestSnapshotId(tableName) + createTag(tableName, "${tableName}_base") + spark_paimon """ + call paimon.sys.create_branch( + '${dbName}.${tableName}', + '${tableName}_base_branch', + '${tableName}_base' + ) + """ + + // Scenario PM-PE01: Paimon keeps a fixed partition key while STRUCT, MAP-value + // STRUCT and ARRAY-element STRUCT children are added. + spark_paimon_multi """ + alter table paimon.${dbName}.${tableName} add column payload.extra string; + alter table paimon.${dbName}.${tableName} add column attrs.value.extra int; + alter table paimon.${dbName}.${tableName} add column events.element.extra int; + insert into paimon.${dbName}.${tableName} values + (3, 'p1', + named_struct('metric', 30, 'label', 'add-3', 'extra', 'payload-extra'), + map('k', named_struct('code', 300, 'extra', 301)), + array(named_struct('score', 3000, 'extra', 3001))), + (4, 'p3', + named_struct('metric', 40, 'label', 'add-4', 'extra', 'payload-extra'), + map('k', named_struct('code', 400, 'extra', 401)), + array(named_struct('score', 4000, 'extra', 4001))); + """ + String addedSnapshot = latestSnapshotId(tableName) + + // Scenario PM-PE02: rename and promote complex children without changing partition + // identity. Old snapshot/tag/branch schemas must remain independently readable. + spark_paimon_multi """ + alter table paimon.${dbName}.${tableName} + rename column payload.label to renamed_label; + alter table paimon.${dbName}.${tableName} + rename column attrs.value.code to renamed_code; Review Comment: 已新增 current/history 的 STRUCT、MAP、ARRAY 演进字段投影及 wrong-snapshot 负向绑定,并在 JNI/native 两种 reader 下验证。 ########## regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_filter_refs.groovy: ########## @@ -0,0 +1,273 @@ +// 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. + +suite("test_iceberg_partition_evolution_filter_refs", + "p0,external,iceberg,external_docker,external_docker_iceberg") { + String enabled = context.config.otherConfigs.get("enableIcebergTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable iceberg test") + return + } + + 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") + String catalogName = "test_iceberg_partition_evolution_filter_refs" + String dbName = "iceberg_partition_evolution_filter_refs_db" + String identityTable = "identity_bucket_truncate_timeline" + String temporalTable = "temporal_transform_timeline" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def latestSnapshotId = { String tableName -> + List<List<Object>> rows = spark_iceberg """ + select snapshot_id + from demo.${dbName}.${tableName}.snapshots + order by committed_at desc + limit 1 + """ + assertEquals(1, rows.size()) + return rows[0][0].toString() + } + + 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', + 'meta.cache.iceberg.table.ttl-second'='0', + 'meta.cache.iceberg.schema.ttl-second'='0' + ) + """ + + try { + spark_iceberg_multi """ + create database if not exists demo.${dbName}; + drop table if exists demo.${dbName}.${identityTable}; + create table demo.${dbName}.${identityTable} ( + id int, + category string, + code string, + event_time timestamp, + payload struct<metric:int, label:string> + ) using iceberg + partitioned by (category, days(event_time)) + tblproperties ( + 'format-version'='2', + 'write.format.default'='parquet' + ); + insert into demo.${dbName}.${identityTable} values + (1, 'A', 'aa-1', timestamp '2026-01-01 01:00:00', + named_struct('metric', 10, 'label', 'base-a')), + (2, 'B', 'bb-1', timestamp '2026-01-02 01:00:00', + named_struct('metric', 20, 'label', 'base-b')); + """ + String identityBase = latestSnapshotId(identityTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${identityTable}` + create tag identity_base as of version ${identityBase} + """ + sql """ + alter table `${catalogName}`.`${dbName}`.`${identityTable}` + create branch identity_base_branch as of version ${identityBase} + """ + + // Scenario PE-I01: ADD bucket partition field and add a complex-type child in the same + // timeline. Filters must evaluate old files whose spec has no bucket field. + spark_iceberg_multi """ + alter table demo.${dbName}.${identityTable} add partition field bucket(8, id); + alter table demo.${dbName}.${identityTable} add column payload.extra string; + insert into demo.${dbName}.${identityTable} values + (3, 'A', 'aa-2', timestamp '2026-02-01 01:00:00', + named_struct('metric', 30, 'label', 'bucket-a', 'extra', 'add-child')), + (4, 'C', 'cc-1', timestamp '2026-02-02 01:00:00', + named_struct('metric', 40, 'label', 'bucket-c', 'extra', 'add-child')); + """ + String identityAdded = latestSnapshotId(identityTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${identityTable}` + create tag identity_added as of version ${identityAdded} + """ + + // Scenario PE-I02: REPLACE bucket with truncate while renaming/promoting nested children. + // Predicates on both old and new partition source columns must scan every applicable spec. + spark_iceberg_multi """ + alter table demo.${dbName}.${identityTable} + replace partition field bucket(8, id) with truncate(2, code); + alter table demo.${dbName}.${identityTable} + rename column payload.label to renamed_label; + alter table demo.${dbName}.${identityTable} + alter column payload.metric type bigint; + insert into demo.${dbName}.${identityTable} values + (5, 'A', 'aa-3', timestamp '2026-03-01 01:00:00', + named_struct('metric', 5000000000, 'renamed_label', 'truncate-a', + 'extra', 'renamed-child')), + (6, 'D', 'dd-1', timestamp '2026-03-02 01:00:00', + named_struct('metric', 60, 'renamed_label', 'truncate-d', + 'extra', 'renamed-child')); + """ + String identityReplaced = latestSnapshotId(identityTable) + + // Scenario PE-I03: DROP identity and temporal fields, then drop/re-add a nested name. + // New unpartitioned-by-category files and old identity-partitioned files coexist. + spark_iceberg_multi """ + alter table demo.${dbName}.${identityTable} drop partition field category; + alter table demo.${dbName}.${identityTable} drop partition field days(event_time); + alter table demo.${dbName}.${identityTable} drop column payload.extra; + alter table demo.${dbName}.${identityTable} add column payload.extra bigint; + insert into demo.${dbName}.${identityTable} values + (7, 'A', 'aa-4', timestamp '2026-04-01 01:00:00', + named_struct('metric', 70, 'renamed_label', 'dropped-partition', + 'extra', 7000)), + (8, 'E', 'ee-1', timestamp '2026-04-02 01:00:00', + named_struct('metric', 80, 'renamed_label', 'dropped-partition', + 'extra', 8000)); + """ + String identityDropped = latestSnapshotId(identityTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${identityTable}` + create tag identity_dropped as of version ${identityDropped} + """ + + spark_iceberg_multi """ + drop table if exists demo.${dbName}.${temporalTable}; + create table demo.${dbName}.${temporalTable} ( + id int, + event_time timestamp, + payload string + ) using iceberg + partitioned by (years(event_time)) + tblproperties ( + 'format-version'='2', + 'write.format.default'='orc' + ); + insert into demo.${dbName}.${temporalTable} values + (11, timestamp '2024-01-01 01:00:00', 'year-2024'), + (12, timestamp '2025-01-01 01:00:00', 'year-2025'); + """ + String temporalYear = latestSnapshotId(temporalTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${temporalTable}` + create tag temporal_year as of version ${temporalYear} + """ + + // Scenario PE-T01: REPLACE year -> month -> day -> hour across ORC files. + // Range and equality filters validate every temporal transform boundary. + spark_iceberg_multi """ + alter table demo.${dbName}.${temporalTable} + replace partition field years(event_time) with months(event_time); + insert into demo.${dbName}.${temporalTable} values + (13, timestamp '2026-02-01 01:00:00', 'month-feb'), + (14, timestamp '2026-03-01 01:00:00', 'month-mar'); + alter table demo.${dbName}.${temporalTable} + replace partition field months(event_time) with days(event_time); + insert into demo.${dbName}.${temporalTable} values + (15, timestamp '2026-04-03 01:00:00', 'day-03'), + (16, timestamp '2026-04-04 01:00:00', 'day-04'); + alter table demo.${dbName}.${temporalTable} + replace partition field days(event_time) with hours(event_time); + insert into demo.${dbName}.${temporalTable} values + (17, timestamp '2026-05-01 08:00:00', 'hour-08'), + (18, timestamp '2026-05-01 09:00:00', 'hour-09'); + """ + String temporalHour = latestSnapshotId(temporalTable) + sql """ + alter table `${catalogName}`.`${dbName}`.`${temporalTable}` + create tag temporal_hour as of version ${temporalHour} + """ + + sql """switch ${catalogName}""" + sql """use ${dbName}""" + sql """refresh catalog ${catalogName}""" + + // Scenario PE-F01: equality/range/IN/NULL-safe source-column filters span four specs. + assertEquals([["1"], ["3"], ["5"], ["7"]], stringRows(""" + select id from ${identityTable} where category = 'A' order by id + """)) + assertEquals([["1"], ["3"], ["5"], ["7"]], stringRows(""" + select id from ${identityTable} + where code in ('aa-1', 'aa-2', 'aa-3', 'aa-4') + order by id + """)) + assertEquals([["5"], ["6"], ["7"], ["8"]], stringRows(""" + select id from ${identityTable} + where event_time >= timestamp '2026-03-01 00:00:00' + order by id + """)) + assertEquals([["7", "7000"], ["8", "8000"]], stringRows(""" + select id, payload.extra from ${identityTable} + where payload.extra is not null + order by id + """)) + + // Scenario PE-R01: numeric snapshot, tag and branch retain their own data/spec timeline. + List<List<String>> identityBaseRows = [["1", "A"], ["2", "B"]] + assertEquals(identityBaseRows, stringRows(""" + select id, category + from ${identityTable} for version as of ${identityBase} + where category in ('A', 'B') + order by id + """)) + assertEquals(identityBaseRows, stringRows(""" + select id, category from ${identityTable}@tag(identity_base) + where category in ('A', 'B') order by id + """)) + assertEquals(identityBaseRows, stringRows(""" + select id, category from ${identityTable}@branch(identity_base_branch) + where category in ('A', 'B') order by id + """)) + assertEquals([["1"], ["3"]], stringRows(""" + select id from ${identityTable} for version as of ${identityAdded} + where category = 'A' order by id + """)) + assertEquals([["1"], ["3"], ["5"]], stringRows(""" + select id from ${identityTable} for version as of ${identityReplaced} + where category = 'A' order by id + """)) + assertEquals([["1"], ["3"], ["5"], ["7"]], stringRows(""" + select id from ${identityTable}@tag(identity_dropped) + where category = 'A' order by id + """)) + + // Scenario PE-F02/PE-R02: temporal filters use the spec selected by numeric/tag refs. + assertEquals([["11"], ["12"]], stringRows(""" + select id from ${temporalTable}@tag(temporal_year) + where event_time < timestamp '2026-01-01 00:00:00' + order by id + """)) + assertEquals([["17"], ["18"]], stringRows(""" Review Comment: 已增加一个 current predicate 跨越 year/month/day/hour 四种 spec;新的 format/scanner suite 也在 Parquet/ORC 下覆盖同一完整跨度。 ########## regression-test/suites/external_table_p0/paimon/test_paimon_partition_mutation_atomicity.groovy: ########## @@ -0,0 +1,157 @@ +// 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. + +suite("test_paimon_partition_mutation_atomicity", + "p0,external,paimon,external_docker,external_docker_paimon") { + String enabled = context.config.otherConfigs.get("enablePaimonTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable paimon test") + return + } + + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String catalogName = "test_paimon_partition_mutation_atomicity" + String dbName = "paimon_partition_mutation_atomicity_db" + String tableName = "partition_contract" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def schemaCount = { + return spark_paimon(""" + select count(*) from paimon.${dbName}.`${tableName}\$schemas` + """)[0][0].toString().toInteger() + } + def assertSparkRejected = { String statement, String operation -> + String error = null + try { + spark_paimon(statement) + } catch (Exception e) { + error = e.getMessage() + } + assertNotNull(error, "Paimon must reject partition-key ${operation}") + assertTrue(error.toLowerCase().contains("partition"), Review Comment: 已将 rename、type change、drop 三类 Paimon partition mutation 收紧为实际稳定错误信息,并继续校验 schema count、current data 与历史 tag 的原子性。 ########## regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_position_dv.groovy: ########## @@ -0,0 +1,242 @@ +// 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. + +suite("test_iceberg_partition_evolution_position_dv", + "p0,external,iceberg,external_docker,external_docker_iceberg") { + String enabled = context.config.otherConfigs.get("enableIcebergTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable iceberg test") + return + } + + 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") + String catalogName = "test_iceberg_partition_evolution_position_dv" + String dbName = "iceberg_partition_evolution_position_dv_db" + + def stringRows = { String query -> + sql(query).collect { row -> + row.collect { value -> value == null ? null : value.toString() } + } + } + def latestSnapshotId = { String tableName -> + return spark_iceberg(""" + select snapshot_id + from demo.${dbName}.${tableName}.snapshots + order by committed_at desc + limit 1 + """)[0][0].toString() + } + + 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', + 'meta.cache.iceberg.table.ttl-second'='0' + ) + """ + + try { + ["parquet", "orc"].each { String format -> + [2, 3].each { int formatVersion -> + String deleteKind = formatVersion == 2 ? "position" : "dv" + String tableName = "${deleteKind}_${format}_evolved" + + spark_iceberg_multi """ + create database if not exists demo.${dbName}; + drop table if exists demo.${dbName}.${tableName}; + create table demo.${dbName}.${tableName} ( + id int, + category string, + event_time timestamp, + payload struct<metric:int, label:string> + ) using iceberg + partitioned by (category, days(event_time)) + tblproperties ( + 'format-version'='${formatVersion}', + 'write.format.default'='${format}', + 'write.delete.mode'='merge-on-read', + 'write.update.mode'='merge-on-read', + 'write.merge.mode'='merge-on-read', + 'write.distribution-mode'='none' + ); + insert into demo.${dbName}.${tableName} + select /*+ coalesce(1) */ id, category, event_time, payload from values + (1, 'A', timestamp '2026-01-01 01:00:00', + named_struct('metric', 10, 'label', 'base-a')), + (2, 'A', timestamp '2026-01-01 02:00:00', + named_struct('metric', 20, 'label', 'base-delete')), + (3, 'B', timestamp '2026-01-02 01:00:00', + named_struct('metric', 30, 'label', 'base-b')), + (4, 'C', timestamp '2026-01-03 01:00:00', + named_struct('metric', 40, 'label', 'base-c')) + as t(id, category, event_time, payload); + """ + String baseSnapshot = latestSnapshotId(tableName) + sql """ + alter table `${catalogName}`.`${dbName}`.`${tableName}` + create tag ${tableName}_base as of version ${baseSnapshot} + """ + + // Scenario PE-D01: delete against the original spec before any evolution. + spark_iceberg """ + delete from demo.${dbName}.${tableName} where id = 2 + """ + String firstDeleteSnapshot = latestSnapshotId(tableName) + + // Scenario PE-D02: ADD partition field and complex child, then delete a row written + // with the new spec. Old/new delete files must remain associated with their specs. + spark_iceberg_multi """ + alter table demo.${dbName}.${tableName} add partition field bucket(8, id); + alter table demo.${dbName}.${tableName} add column payload.extra string; + insert into demo.${dbName}.${tableName} + select /*+ coalesce(1) */ id, category, event_time, payload from values + (5, 'A', timestamp '2026-02-01 01:00:00', + named_struct('metric', 50, 'label', 'add-a', 'extra', 'new-child')), + (6, 'A', timestamp '2026-02-01 02:00:00', + named_struct('metric', 60, 'label', 'add-delete', 'extra', 'new-child')) + as t(id, category, event_time, payload); + delete from demo.${dbName}.${tableName} where id = 6; + """ + String addedDeleteSnapshot = latestSnapshotId(tableName) + + // Scenario PE-D03: REPLACE temporal transform and rename/promote nested fields. + spark_iceberg_multi """ + alter table demo.${dbName}.${tableName} + replace partition field days(event_time) with months(event_time); + alter table demo.${dbName}.${tableName} + rename column payload.label to renamed_label; + alter table demo.${dbName}.${tableName} + alter column payload.metric type bigint; + insert into demo.${dbName}.${tableName} values + (7, 'A', timestamp '2026-03-01 01:00:00', + named_struct('metric', 7000000000, + 'renamed_label', 'replace-a', 'extra', 'renamed-child')), + (8, 'A', timestamp '2026-03-01 02:00:00', + named_struct('metric', 80, + 'renamed_label', 'replace-delete', 'extra', 'renamed-child')); + """ + + // Scenario PE-D04: DROP identity field before the final delete. The matching row + // is in a spec without category, while older files still expose category partitions. + spark_iceberg_multi """ + alter table demo.${dbName}.${tableName} drop partition field category; Review Comment: 已补充 drop category 后在同一新 spec 写入 victim/survivor,再删除 victim,并分别断言当前 data/delete 的 spec_id 一致。 -- 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]
