This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 08f3a2fca2 [Cherry-pick to branch-1.3] [#14536] fix(iceberg-rest):
honor use-snapshot-schema and plan dropped partition sources (#14552) (#14647)
08f3a2fca2 is described below
commit 08f3a2fca24e0b03a6bbd0cac3c507790b9de6fa
Author: MaSai <[email protected]>
AuthorDate: Thu Nov 5 14:07:33 2026 +0800
[Cherry-pick to branch-1.3] [#14536] fix(iceberg-rest): honor
use-snapshot-schema and plan dropped partition sources (#14552) (#14647)
## Purpose
Cherry-pick of #14552 onto `branch-1.3`, replacing bot PR #14646 (closed
with conflicts).
## Changes
- Honor `use-snapshot-schema` and plan tables whose partition sources
were dropped.
- branch-1.3 adaptations from review follow-ups on #14552:
- `rewriteFilterNames` NOT polarity
- `canProjectPartition` for incompatible partition projection
- restore `equalityFieldIds` after `FileMetadata.copy` (Iceberg 1.11)
- include `schemaId` in `ScanPlanCacheKey`
## Closes
Supersedes #14646
Co-authored-by: Cursor <[email protected]>
---
.../iceberg/service/CatalogWrapperForREST.java | 68 +-
.../iceberg/service/IcebergScanPlanHelper.java | 822 +++++++++++++++++++++
.../iceberg/service/cache/ScanPlanCacheKey.java | 21 +-
.../iceberg/service/TestIcebergScanPlanHelper.java | 732 ++++++++++++++++++
.../iceberg/service/TestIcebergScanPlanning.java | 183 ++++-
.../iceberg/service/rest/TestScanPlanCache.java | 35 +
6 files changed, 1848 insertions(+), 13 deletions(-)
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index 723ab46a45..d96c2f9cba 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -55,6 +55,7 @@ import org.apache.iceberg.FileScanTask;
import org.apache.iceberg.HasTableOperations;
import org.apache.iceberg.IncrementalAppendScan;
import org.apache.iceberg.Scan;
+import org.apache.iceberg.Schema;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableMetadata;
import org.apache.iceberg.TableProperties;
@@ -65,6 +66,7 @@ import org.apache.iceberg.exceptions.BadRequestException;
import org.apache.iceberg.exceptions.ServiceFailureException;
import org.apache.iceberg.exceptions.ServiceUnavailableException;
import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.expressions.Expression;
import org.apache.iceberg.io.CloseableIterable;
import org.apache.iceberg.rest.CatalogHandlers;
import org.apache.iceberg.rest.PlanStatus;
@@ -76,6 +78,7 @@ import
org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
import org.apache.iceberg.rest.responses.PlanTableScanResponse;
+import org.apache.iceberg.util.SnapshotUtil;
/** Process Iceberg REST specific operations, like credential vending. */
public class CatalogWrapperForREST extends IcebergCatalogWrapper {
@@ -486,10 +489,28 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
return cachedResponse.get();
}
+ Schema residualFrom = null;
+ Schema residualTo = null;
+ Expression extraResidual = null;
List<FileScanTask> fileScanTasks = new ArrayList<>();
-
try (CloseableIterable<FileScanTask> scanTasks =
createFilePlanScanTasks(table, tableIdentifier, scanRequest)) {
+ // Recompute whether names were rewritten so residuals can be mapped
back to the request
+ // schema. createFilePlanScanTasks applies the same rewrite rules.
+ if (scanRequest.snapshotId() != null &&
!scanRequest.useSnapshotSchema()) {
+ long snapshotId = scanRequest.snapshotId();
+ Optional<String> branch = IcebergScanPlanHelper.branchAt(table,
snapshotId);
+ if (!branch.isPresent()) {
+ residualFrom = SnapshotUtil.schemaFor(table, snapshotId);
+ residualTo = table.schema();
+ // Keep the complete client filter as a residual. Extracting only
+ // post-snapshot predicates by replacing others with always-true
+ // collapses OR/NOT (e.g. id=1 OR new_col=2 → true).
+ if (scanRequest.filter() != null) {
+ extraResidual = scanRequest.filter();
+ }
+ }
+ }
for (FileScanTask fileScanTask : scanTasks) {
fileScanTasks.add(fileScanTask);
}
@@ -497,6 +518,14 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
LOG.error("Failed to close scan task iterator for table: {}",
tableIdentifier, e);
throw new RuntimeException("Failed to plan scan tasks: " +
e.getMessage(), e);
}
+ fileScanTasks =
+ IcebergScanPlanHelper.prepareTasksForResponse(
+ fileScanTasks,
+ table.specs(),
+ residualTo,
+ residualFrom,
+ extraResidual,
+ scanRequest.caseSensitive());
if (fileScanTasks.isEmpty()) {
LOG.info(
@@ -555,7 +584,9 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
return PlanTableScanResponse.builder()
.withPlanStatus(PlanStatus.COMPLETED)
.withFileScanTasks(fileScanTasks)
- .withSpecsById(table.specs())
+ .withSpecsById(
+ IcebergScanPlanHelper.sanitizeSpecsForSerialization(
+ table.specs(), fileScanTasks, table.schemas().values()))
.build();
}
@@ -565,6 +596,12 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
* <p>If both start and end snapshot IDs are provided, uses
IncrementalAppendScan. Otherwise, uses
* regular TableScan.
*
+ * <p>With {@code use-snapshot-schema} false (the REST default, sent for
branch scans) and a
+ * {@code snapshot-id}, the request is written against the current table
schema. A branch that
+ * points at the snapshot is scanned with {@code useRef}, which binds to the
current schema like a
+ * client-side branch scan. If no branch points there any more, the snapshot
is scanned and the
+ * request's column names are mapped to the snapshot schema by field id.
+ *
* @param table The table to scan
* @param tableIdentifier The table identifier for logging
* @param scanRequest The scan request parameters
@@ -596,12 +633,28 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
return incrementalScan.planFiles();
} else {
TableScan tableScan = table.newScan();
+ PlanTableScanRequest requestToApply = scanRequest;
// Snapshot ID 0 has no special meaning in Iceberg, so we only apply if
not null
if (scanRequest.snapshotId() != null) {
- tableScan = tableScan.useSnapshot(scanRequest.snapshotId());
- LOG.debug("Applied snapshot filter: snapshot-id={}",
scanRequest.snapshotId());
+ long snapshotId = scanRequest.snapshotId();
+ Optional<String> branch =
+ scanRequest.useSnapshotSchema()
+ ? Optional.empty()
+ : IcebergScanPlanHelper.branchAt(table, snapshotId);
+ if (branch.isPresent()) {
+ tableScan = tableScan.useRef(branch.get());
+ LOG.debug("Applied branch {} for snapshot-id={}", branch.get(),
snapshotId);
+ } else {
+ tableScan = tableScan.useSnapshot(snapshotId);
+ if (!scanRequest.useSnapshotSchema()) {
+ requestToApply =
+ IcebergScanPlanHelper.rewriteForSnapshot(
+ scanRequest, table.schema(), SnapshotUtil.schemaFor(table,
snapshotId));
+ }
+ LOG.debug("Applied snapshot filter: snapshot-id={}", snapshotId);
+ }
}
- tableScan = applyScanRequest(tableScan, scanRequest);
+ tableScan = applyScanRequest(tableScan, requestToApply);
return tableScan.planFiles();
}
}
@@ -621,8 +674,9 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
private <T extends Scan> T applyScanFilter(T scan, PlanTableScanRequest
scanRequest) {
if (scanRequest.filter() != null) {
try {
- scan = (T) scan.filter(scanRequest.filter());
- LOG.debug("Applied filter expression: {}", scanRequest.filter());
+ Expression filter = scanRequest.filter();
+ scan = (T) scan.filter(filter);
+ LOG.debug("Applied filter expression: {}", filter);
} catch (Exception e) {
LOG.error("Failed to apply filter expression: {}", e.getMessage(), e);
throw new IllegalArgumentException("Invalid filter expression: " +
e.getMessage(), e);
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergScanPlanHelper.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergScanPlanHelper.java
new file mode 100644
index 0000000000..5843729938
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergScanPlanHelper.java
@@ -0,0 +1,822 @@
+/*
+ * 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.gravitino.iceberg.service;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.apache.iceberg.BaseFileScanTask;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileContent;
+import org.apache.iceberg.FileMetadata;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.PartitionData;
+import org.apache.iceberg.PartitionField;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.PartitionSpecParser;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SchemaParser;
+import org.apache.iceberg.SnapshotRef;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.expressions.And;
+import org.apache.iceberg.expressions.BoundPredicate;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.ExpressionVisitors;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.expressions.Literal;
+import org.apache.iceberg.expressions.Not;
+import org.apache.iceberg.expressions.Or;
+import org.apache.iceberg.expressions.ResidualEvaluator;
+import org.apache.iceberg.expressions.UnboundPredicate;
+import org.apache.iceberg.expressions.UnboundTerm;
+import org.apache.iceberg.expressions.UnboundTransform;
+import org.apache.iceberg.rest.requests.PlanTableScanRequest;
+import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types;
+
+/**
+ * Helpers for Iceberg REST server-side scan planning: honour {@code
use-snapshot-schema} and avoid
+ * failing when historical partition specs reference dropped columns.
+ */
+final class IcebergScanPlanHelper {
+
+ private IcebergScanPlanHelper() {}
+
+ /**
+ * Returns a branch that points at the snapshot, preferring {@code main}.
+ *
+ * <p>Iceberg's REST client sends {@code use-snapshot-schema=false} for a
branch scan, and {@code
+ * TableScan#useRef} on a branch reads its snapshot with the current table
schema, which is what
+ * that flag asks for.
+ */
+ static Optional<String> branchAt(Table table, long snapshotId) {
+ SnapshotRef main = table.refs().get(SnapshotRef.MAIN_BRANCH);
+ if (main != null && main.snapshotId() == snapshotId) {
+ return Optional.of(SnapshotRef.MAIN_BRANCH);
+ }
+ return table.refs().entrySet().stream()
+ .filter(entry -> entry.getValue().isBranch() &&
entry.getValue().snapshotId() == snapshotId)
+ .map(Map.Entry::getKey)
+ .sorted()
+ .findFirst();
+ }
+
+ /**
+ * Rewrites filter/select/stats field names from the current table schema to
the snapshot schema
+ * by field id, so a {@code useSnapshot} scan can serve a request written
against the current
+ * schema. Names are resolved on the current schema first, so a name that
only the snapshot schema
+ * has, or that a later column reuses, never binds to the snapshot's field.
Columns added after
+ * the snapshot are dropped from select/stats; filters on them are rewritten
conservatively for
+ * planning with polarity-aware placeholders (never under-prune) and the
full original filter is
+ * reattached as a residual by the caller.
+ */
+ static PlanTableScanRequest rewriteForSnapshot(
+ PlanTableScanRequest request, Schema current, Schema snapshot) {
+ PlanTableScanRequest.Builder builder =
+ PlanTableScanRequest.builder()
+ .withSnapshotId(request.snapshotId())
+ .withCaseSensitive(request.caseSensitive())
+ .withUseSnapshotSchema(request.useSnapshotSchema())
+ .withStartSnapshotId(request.startSnapshotId())
+ .withEndSnapshotId(request.endSnapshotId())
+ .withMinRowsRequested(request.minRowsRequested());
+ if (request.filter() != null) {
+ builder.withFilter(
+ rewriteFilterNames(request.filter(), current, snapshot,
request.caseSensitive()));
+ }
+ if (request.select() != null && !request.select().isEmpty()) {
+ List<String> select =
+ rewriteColumnNames(request.select(), current, snapshot,
request.caseSensitive());
+ if (!select.isEmpty()) {
+ builder.withSelect(select);
+ }
+ }
+ if (request.statsFields() != null && !request.statsFields().isEmpty()) {
+ List<String> statsFields =
+ rewriteColumnNames(request.statsFields(), current, snapshot,
request.caseSensitive());
+ if (!statsFields.isEmpty()) {
+ builder.withStatsFields(statsFields);
+ }
+ }
+ return builder.build();
+ }
+
+ /**
+ * Remaps residual filters that were bound/named against {@code from} onto
{@code to} by field id,
+ * so a response for {@code use-snapshot-schema=false} keeps the request
schema's identities.
+ *
+ * <p>Handles both bound residuals (field id from the scan schema) and
unbound ones (names
+ * resolved on {@code from}).
+ */
+ @VisibleForTesting
+ static Expression remapExpressionNames(
+ Expression expression, Schema from, Schema to, boolean caseSensitive) {
+ if (expression == null) {
+ return null;
+ }
+ return ExpressionVisitors.visit(
+ expression,
+ new ExpressionVisitors.ExpressionVisitor<Expression>() {
+ @Override
+ public Expression alwaysTrue() {
+ return Expressions.alwaysTrue();
+ }
+
+ @Override
+ public Expression alwaysFalse() {
+ return Expressions.alwaysFalse();
+ }
+
+ @Override
+ public Expression not(Expression result) {
+ return Expressions.not(result);
+ }
+
+ @Override
+ public Expression and(Expression left, Expression right) {
+ return Expressions.and(left, right);
+ }
+
+ @Override
+ public Expression or(Expression left, Expression right) {
+ return Expressions.or(left, right);
+ }
+
+ @Override
+ public <T> Expression predicate(BoundPredicate<T> pred) {
+ String toName = to.findColumnName(pred.ref().fieldId());
+ if (toName == null) {
+ return Expressions.alwaysTrue();
+ }
+ return unboundFromBound(pred, toName);
+ }
+
+ @Override
+ public <T> Expression predicate(UnboundPredicate<T> pred) {
+ String fromName = pred.ref().name();
+ String toName = renameByFieldId(fromName, from, to, caseSensitive);
+ if (toName == null) {
+ // Field was dropped from the request schema; residual cannot
name it.
+ return Expressions.alwaysTrue();
+ }
+ if (toName.equals(fromName)) {
+ return pred;
+ }
+ return unboundWithName(pred, toName);
+ }
+ });
+ }
+
+ /**
+ * Remaps residuals (bound or unbound) onto {@code to} by field id. Bound
residuals from a
+ * snapshot scan carry snapshot names; unbound ones do as well after a name
rewrite.
+ */
+ @VisibleForTesting
+ static Expression remapResidualToSchema(Expression residual, Schema to,
boolean caseSensitive) {
+ if (residual == null) {
+ return null;
+ }
+ return ExpressionVisitors.visit(
+ residual,
+ new ExpressionVisitors.BoundExpressionVisitor<Expression>() {
+ @Override
+ public Expression alwaysTrue() {
+ return Expressions.alwaysTrue();
+ }
+
+ @Override
+ public Expression alwaysFalse() {
+ return Expressions.alwaysFalse();
+ }
+
+ @Override
+ public Expression not(Expression result) {
+ return Expressions.not(result);
+ }
+
+ @Override
+ public Expression and(Expression left, Expression right) {
+ return Expressions.and(left, right);
+ }
+
+ @Override
+ public Expression or(Expression left, Expression right) {
+ return Expressions.or(left, right);
+ }
+
+ @Override
+ public <T> Expression predicate(BoundPredicate<T> pred) {
+ String toName = to.findColumnName(pred.ref().fieldId());
+ if (toName == null) {
+ return Expressions.alwaysTrue();
+ }
+ return unboundFromBound(pred, toName);
+ }
+
+ @Override
+ public <T> Expression predicate(UnboundPredicate<T> pred) {
+ // Unbound residual still carries the scan-schema name; resolve
via {@code to} only when
+ // the name already matches a current field, otherwise keep as-is
(tests use unbound
+ // remaps through {@link #remapExpressionNames}).
+ Types.NestedField field =
+ caseSensitive
+ ? to.findField(pred.ref().name())
+ : to.caseInsensitiveFindField(pred.ref().name());
+ if (field != null) {
+ return pred;
+ }
+ return pred;
+ }
+ });
+ }
+
+ /**
+ * Prepares planned tasks for the REST response: remap residuals onto the
request schema and
+ * rewrite tasks whose partition spec still has {@code unknown} onto a
client-parseable spec from
+ * {@code tableSpecs} (prefer unpartitioned, else any spec without {@code
unknown}) so Iceberg
+ * clients can parse them with their local {@code table.specs()} map.
Associated delete files are
+ * rewritten to the same target spec.
+ */
+ static List<FileScanTask> prepareTasksForResponse(
+ List<FileScanTask> fileScanTasks,
+ Map<Integer, PartitionSpec> tableSpecs,
+ @Nullable Schema residualTargetSchema,
+ @Nullable Schema residualSourceSchema,
+ @Nullable Expression extraResidual,
+ boolean caseSensitive) {
+ if (fileScanTasks.isEmpty()) {
+ return fileScanTasks;
+ }
+ PartitionSpec compatibleSpec = findClientCompatibleSpec(tableSpecs);
+ List<FileScanTask> prepared =
Lists.newArrayListWithCapacity(fileScanTasks.size());
+ for (FileScanTask task : fileScanTasks) {
+ Expression residual = task.residual();
+ if (residualTargetSchema != null && residual != null) {
+ if (residualSourceSchema != null) {
+ residual =
+ remapExpressionNames(
+ residual, residualSourceSchema, residualTargetSchema,
caseSensitive);
+ } else {
+ residual = remapResidualToSchema(residual, residualTargetSchema,
caseSensitive);
+ }
+ }
+ if (extraResidual != null) {
+ residual = residual == null ? extraResidual :
Expressions.and(residual, extraResidual);
+ }
+ PartitionSpec spec = tableSpecs.get(task.file().specId());
+ boolean needsSpecRewrite =
+ spec != null && partitionTypeHasUnknown(spec.partitionType()) &&
compatibleSpec != null;
+ if (needsSpecRewrite) {
+ if (!canProjectPartition(task.spec(), compatibleSpec)) {
+ // Inventing NULL for missing target fields makes residuals evaluate
wrong.
+ throw new IllegalArgumentException(
+ String.format(
+ "Cannot rewrite file %s from spec %d to client-compatible
spec %d: "
+ + "target partition fields are missing from the source
file",
+ task.file().location(), task.spec().specId(),
compatibleSpec.specId()));
+ }
+ prepared.add(
+ withCompatibleSpec(
+ task, compatibleSpec, residual, residualTargetSchema,
caseSensitive));
+ } else if (residual != null && residual != task.residual()) {
+ prepared.add(withResidual(task, residual, residualTargetSchema,
caseSensitive));
+ } else {
+ prepared.add(task);
+ }
+ }
+ return prepared;
+ }
+
+ /**
+ * Rebinds partition specs whose {@link PartitionSpec#partitionType()}
contains {@code unknown}
+ * (dropped source columns) so file-scan-task serialization can write
partition values. Source
+ * types are restored from historical table schemas (and identity partition
data when present);
+ * transform result types and a binary guess are never used. Specs that
cannot be restored are
+ * left unchanged so empty plans are not made worse.
+ */
+ @VisibleForTesting
+ static Map<Integer, PartitionSpec> sanitizeSpecsForSerialization(
+ Map<Integer, PartitionSpec> specsById,
+ List<FileScanTask> fileScanTasks,
+ Collection<Schema> historicalSchemas) {
+ Map<Integer, Types.StructType> partitionTypesBySpecId = Maps.newHashMap();
+ for (FileScanTask task : fileScanTasks) {
+ StructLike partition = task.file().partition();
+ if (partition instanceof PartitionData) {
+ partitionTypesBySpecId.putIfAbsent(
+ task.file().specId(), ((PartitionData)
partition).getPartitionType());
+ }
+ }
+
+ Map<Integer, Type> typesBySourceId =
indexTypesByFieldId(historicalSchemas);
+
+ Map<Integer, PartitionSpec> sanitized = Maps.newHashMap();
+ boolean changed = false;
+ for (Map.Entry<Integer, PartitionSpec> entry : specsById.entrySet()) {
+ PartitionSpec spec = entry.getValue();
+ if (!partitionTypeHasUnknown(spec.partitionType())) {
+ sanitized.put(entry.getKey(), spec);
+ continue;
+ }
+ Schema extended =
+ extendSchemaForDroppedSources(
+ spec, partitionTypesBySpecId.get(entry.getKey()),
typesBySourceId);
+ if (extended == spec.schema()) {
+ // Could not restore every dropped source; leave the broken spec alone.
+ sanitized.put(entry.getKey(), spec);
+ continue;
+ }
+ // ignoreMissingFields covers any residual gaps if a type is still
missing.
+ sanitized.put(entry.getKey(), spec.toUnbound().bind(extended, true));
+ changed = true;
+ }
+ return changed ? Collections.unmodifiableMap(sanitized) : specsById;
+ }
+
+ /**
+ * Rewrites filter names onto the snapshot schema for planning.
+ *
+ * <p>Columns added after the snapshot are replaced conservatively with
polarity awareness: under
+ * a positive context they become {@code true} (never under-prune); under
{@code NOT} they become
+ * {@code false} so {@code NOT(old AND new)} stays weak instead of
collapsing to {@code NOT(old)}.
+ * The caller still ANDs the full original filter onto the response residual.
+ */
+ @VisibleForTesting
+ static Expression rewriteFilterNames(
+ Expression filter, Schema current, Schema snapshot, boolean
caseSensitive) {
+ return rewriteFilterNames(filter, current, snapshot, caseSensitive, true);
+ }
+
+ private static Expression rewriteFilterNames(
+ Expression filter, Schema current, Schema snapshot, boolean
caseSensitive, boolean positive) {
+ switch (filter.op()) {
+ case TRUE:
+ return Expressions.alwaysTrue();
+ case FALSE:
+ return Expressions.alwaysFalse();
+ case NOT:
+ Expression child =
+ rewriteFilterNames(((Not) filter).child(), current, snapshot,
caseSensitive, !positive);
+ if (child.op() == Expression.Operation.TRUE) {
+ return Expressions.alwaysFalse();
+ }
+ if (child.op() == Expression.Operation.FALSE) {
+ return Expressions.alwaysTrue();
+ }
+ return Expressions.not(child);
+ case AND:
+ {
+ And and = (And) filter;
+ Expression left =
+ rewriteFilterNames(and.left(), current, snapshot, caseSensitive,
positive);
+ Expression right =
+ rewriteFilterNames(and.right(), current, snapshot,
caseSensitive, positive);
+ if (left.op() == Expression.Operation.FALSE || right.op() ==
Expression.Operation.FALSE) {
+ return Expressions.alwaysFalse();
+ }
+ if (left.op() == Expression.Operation.TRUE) {
+ return right;
+ }
+ if (right.op() == Expression.Operation.TRUE) {
+ return left;
+ }
+ return Expressions.and(left, right);
+ }
+ case OR:
+ {
+ Or or = (Or) filter;
+ Expression left =
+ rewriteFilterNames(or.left(), current, snapshot, caseSensitive,
positive);
+ Expression right =
+ rewriteFilterNames(or.right(), current, snapshot, caseSensitive,
positive);
+ if (left.op() == Expression.Operation.TRUE || right.op() ==
Expression.Operation.TRUE) {
+ return Expressions.alwaysTrue();
+ }
+ if (left.op() == Expression.Operation.FALSE) {
+ return right;
+ }
+ if (right.op() == Expression.Operation.FALSE) {
+ return left;
+ }
+ return Expressions.or(left, right);
+ }
+ default:
+ if (!(filter instanceof UnboundPredicate)) {
+ throw new IllegalArgumentException("Unsupported filter expression: "
+ filter);
+ }
+ return rewriteFilterPredicate(
+ (UnboundPredicate<?>) filter, current, snapshot, caseSensitive,
positive);
+ }
+ }
+
+ private static <T> Expression rewriteFilterPredicate(
+ UnboundPredicate<T> pred,
+ Schema current,
+ Schema snapshot,
+ boolean caseSensitive,
+ boolean positive) {
+ String name = pred.ref().name();
+ String snapshotName = toSnapshotName(name, current, snapshot,
caseSensitive);
+ if (snapshotName == null) {
+ // Post-snapshot column: weaken the planning filter (over-include).
Polarity flips under NOT.
+ return positive ? Expressions.alwaysTrue() : Expressions.alwaysFalse();
+ }
+ if (snapshotName.equals(name)) {
+ return pred;
+ }
+ return unboundWithName(pred, snapshotName);
+ }
+
+ @VisibleForTesting
+ static List<String> rewriteColumnNames(
+ List<String> columns, Schema current, Schema snapshot, boolean
caseSensitive) {
+ List<String> rewritten = Lists.newArrayListWithCapacity(columns.size());
+ for (String column : columns) {
+ if ("*".equals(column)) {
+ // Iceberg schema selection treats "*" as project-all, not a field
name.
+ rewritten.add("*");
+ continue;
+ }
+ String snapshotName = toSnapshotName(column, current, snapshot,
caseSensitive);
+ if (snapshotName != null) {
+ rewritten.add(snapshotName);
+ }
+ }
+ return rewritten;
+ }
+
+ /**
+ * Returns the snapshot schema's name for a current-schema column, or {@code
null} if the column
+ * was added after the snapshot.
+ *
+ * @throws IllegalArgumentException if the current table schema has no such
column
+ */
+ @Nullable
+ private static String toSnapshotName(
+ String name, Schema current, Schema snapshot, boolean caseSensitive) {
+ Types.NestedField field =
+ caseSensitive ? current.findField(name) :
current.caseInsensitiveFindField(name);
+ if (field == null) {
+ throw new IllegalArgumentException(
+ String.format("Cannot find field '%s' in the current table schema",
name));
+ }
+ return snapshot.findColumnName(field.fieldId());
+ }
+
+ @Nullable
+ private static String renameByFieldId(
+ String fromName, Schema from, Schema to, boolean caseSensitive) {
+ Types.NestedField field =
+ caseSensitive ? from.findField(fromName) :
from.caseInsensitiveFindField(fromName);
+ if (field == null) {
+ return null;
+ }
+ return to.findColumnName(field.fieldId());
+ }
+
+ private static boolean partitionTypeHasUnknown(Types.StructType
partitionType) {
+ for (Types.NestedField field : partitionType.fields()) {
+ if (field.type().typeId() == Type.TypeID.UNKNOWN) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Map<Integer, Type> indexTypesByFieldId(Collection<Schema>
schemas) {
+ Map<Integer, Type> types = Maps.newHashMap();
+ if (schemas == null) {
+ return types;
+ }
+ for (Schema schema : schemas) {
+ for (Types.NestedField field :
TypeUtil.indexById(schema.asStruct()).values()) {
+ types.putIfAbsent(field.fieldId(), field.type());
+ }
+ }
+ return types;
+ }
+
+ private static Schema extendSchemaForDroppedSources(
+ PartitionSpec spec, Types.StructType partitionDataType, Map<Integer,
Type> typesBySourceId) {
+ Schema schema = spec.schema();
+ List<Types.NestedField> extras = Lists.newArrayList();
+ Set<Integer> addedSourceIds = Sets.newHashSet();
+ List<Types.NestedField> partitionFields =
+ partitionDataType == null ? Collections.emptyList() :
partitionDataType.fields();
+ int partitionPos = 0;
+ for (PartitionField field : spec.fields()) {
+ Types.NestedField source = schema.findField(field.sourceId());
+ Type partitionFieldType =
+ partitionPos < partitionFields.size() ?
partitionFields.get(partitionPos).type() : null;
+ partitionPos++;
+ if (source != null || !addedSourceIds.add(field.sourceId())) {
+ continue;
+ }
+ Type restored = typesBySourceId.get(field.sourceId());
+ if (restored == null
+ && field.transform().isIdentity()
+ && partitionFieldType != null
+ && partitionFieldType.typeId() != Type.TypeID.UNKNOWN) {
+ // Identity partition values share the source type; non-identity
transform outputs do not.
+ restored = partitionFieldType;
+ }
+ if (restored == null) {
+ // Cannot restore this source; signal the caller by returning the
original schema.
+ return schema;
+ }
+ extras.add(
+ Types.NestedField.optional(
+ field.sourceId(), "_dropped_source_" + field.sourceId(),
restored));
+ }
+ if (extras.isEmpty()) {
+ return schema;
+ }
+ List<Types.NestedField> all = Lists.newArrayList(schema.columns());
+ all.addAll(extras);
+ return new Schema(all);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static <T> Expression unboundWithName(UnboundPredicate<T> pred,
String name) {
+ UnboundTerm<T> term = renameTerm(pred.term(), name);
+ if (pred.literals() == null) {
+ return Expressions.predicate(pred.op(), term);
+ }
+ List<T> values =
pred.literals().stream().map(Literal::value).collect(Collectors.toList());
+ return Expressions.predicate(pred.op(), term, values);
+ }
+
+ private static <T> Expression unboundFromBound(BoundPredicate<T> pred,
String name) {
+ if (pred.isUnaryPredicate()) {
+ return Expressions.predicate(pred.op(), name);
+ }
+ if (pred.isLiteralPredicate()) {
+ return Expressions.predicate(pred.op(), name,
pred.asLiteralPredicate().literal().value());
+ }
+ if (pred.isSetPredicate()) {
+ return Expressions.predicate(pred.op(), name,
pred.asSetPredicate().literalSet());
+ }
+ // Fallback: preserve always-true rather than drop an unrecognised shape.
+ return Expressions.alwaysTrue();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static <T> UnboundTerm<T> renameTerm(UnboundTerm<T> term, String
name) {
+ if (term instanceof UnboundTransform) {
+ return Expressions.transform(name, ((UnboundTransform<?, T>)
term).transform());
+ }
+ return (UnboundTerm<T>) Expressions.ref(name);
+ }
+
+ /**
+ * Prefers an unpartitioned parseable spec; otherwise any parseable spec
(typically the current
+ * default after partition evolution). Iceberg REST clients resolve file
{@code spec-id} against
+ * their local {@code table.specs()}, so the target id must already exist
there without {@code
+ * unknown}.
+ */
+ @Nullable
+ private static PartitionSpec findClientCompatibleSpec(Map<Integer,
PartitionSpec> specsById) {
+ PartitionSpec unpartitioned = null;
+ PartitionSpec anyCompatible = null;
+ int bestId = Integer.MIN_VALUE;
+ for (PartitionSpec spec : specsById.values()) {
+ if (partitionTypeHasUnknown(spec.partitionType())) {
+ continue;
+ }
+ if (spec.isUnpartitioned()) {
+ unpartitioned = spec;
+ }
+ if (spec.specId() >= bestId) {
+ bestId = spec.specId();
+ anyCompatible = spec;
+ }
+ }
+ return unpartitioned != null ? unpartitioned : anyCompatible;
+ }
+
+ private static FileScanTask withResidual(
+ FileScanTask task,
+ Expression residual,
+ @Nullable Schema residualSchema,
+ boolean caseSensitive) {
+ PartitionSpec fileSpec = task.spec();
+ PartitionSpec evalSpec =
+ residualSchema == null ? fileSpec :
fileSpec.toUnbound().bind(residualSchema, true);
+ ResidualEvaluator residuals =
+ ResidualEvaluator.of(
+ evalSpec, residual == null ? Expressions.alwaysTrue() : residual,
caseSensitive);
+ Schema schemaJson = residualSchema != null ? residualSchema :
task.schema();
+ return new BaseFileScanTask(
+ task.file(),
+ task.deletes().toArray(new DeleteFile[0]),
+ SchemaParser.toJson(schemaJson),
+ PartitionSpecParser.toJson(fileSpec),
+ residuals);
+ }
+
+ private static FileScanTask withCompatibleSpec(
+ FileScanTask task,
+ PartitionSpec targetSpec,
+ Expression residual,
+ @Nullable Schema residualSchema,
+ boolean caseSensitive) {
+ // Rebuild under a client-parseable spec id so Iceberg clients can parse
the task (and its
+ // deletes) with their local table.specs() map, which still has
UnknownType for dropped
+ // partition sources on the historical spec id.
+ DataFile rewritten = rewriteDataFile(task.file(), task.spec(), targetSpec);
+ DeleteFile[] deletes = rewriteDeletes(task.deletes(), task.spec(),
targetSpec);
+ Schema evalSchema = residualSchema != null ? residualSchema :
targetSpec.schema();
+ PartitionSpec evalSpec = targetSpec.toUnbound().bind(evalSchema, true);
+ ResidualEvaluator residuals =
+ ResidualEvaluator.of(
+ evalSpec, residual == null ? Expressions.alwaysTrue() : residual,
caseSensitive);
+ return new BaseFileScanTask(
+ rewritten,
+ deletes,
+ SchemaParser.toJson(evalSchema),
+ PartitionSpecParser.toJson(targetSpec),
+ residuals);
+ }
+
+ private static DataFile rewriteDataFile(
+ DataFile file, PartitionSpec fromSpec, PartitionSpec targetSpec) {
+ if (targetSpec.isUnpartitioned()) {
+ return DataFiles.builder(targetSpec).copy(file).build();
+ }
+ if (fromSpec.specId() == targetSpec.specId()) {
+ return DataFiles.builder(targetSpec).copy(file).build();
+ }
+ DataFiles.Builder builder =
+ DataFiles.builder(targetSpec)
+ .withPath(file.location())
+ .withFormat(file.format())
+ .withFileSizeInBytes(file.fileSizeInBytes())
+ .withRecordCount(file.recordCount());
+ if (file.splitOffsets() != null) {
+ builder.withSplitOffsets(file.splitOffsets());
+ }
+ if (file.sortOrderId() != null) {
+ builder.withSortOrderId(file.sortOrderId());
+ }
+ if (file.keyMetadata() != null) {
+ builder.withEncryptionKeyMetadata(file.keyMetadata());
+ }
+ StructLike projected = projectPartition(file.partition(), fromSpec,
targetSpec);
+ if (projected == null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Cannot project data file %s partition from spec %d onto spec
%d",
+ file.location(), fromSpec.specId(), targetSpec.specId()));
+ }
+ builder.withPartition(projected);
+ return builder.build();
+ }
+
+ private static DeleteFile[] rewriteDeletes(
+ List<DeleteFile> deletes, PartitionSpec fromSpec, PartitionSpec
targetSpec) {
+ if (deletes == null || deletes.isEmpty()) {
+ return new DeleteFile[0];
+ }
+ DeleteFile[] rewritten = new DeleteFile[deletes.size()];
+ for (int i = 0; i < deletes.size(); i++) {
+ rewritten[i] = rewriteDeleteFile(deletes.get(i), fromSpec, targetSpec);
+ }
+ return rewritten;
+ }
+
+ private static DeleteFile rewriteDeleteFile(
+ DeleteFile delete, PartitionSpec fromSpec, PartitionSpec targetSpec) {
+ if (targetSpec.isUnpartitioned() || fromSpec.specId() ==
targetSpec.specId()) {
+ // Iceberg 1.11 FileMetadata.copy does not copy equalityFieldIds;
restore them explicitly.
+ FileMetadata.Builder builder =
FileMetadata.deleteFileBuilder(targetSpec).copy(delete);
+ restoreEqualityFieldIds(builder, delete);
+ return builder.build();
+ }
+ FileMetadata.Builder builder = FileMetadata.deleteFileBuilder(targetSpec);
+ switch (delete.content()) {
+ case POSITION_DELETES:
+ builder.ofPositionDeletes();
+ break;
+ case EQUALITY_DELETES:
+ restoreEqualityFieldIds(builder, delete);
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported delete content: " +
delete.content());
+ }
+ builder
+ .withPath(delete.location())
+ .withFormat(delete.format())
+ .withFileSizeInBytes(delete.fileSizeInBytes())
+ .withRecordCount(delete.recordCount());
+ if (delete.referencedDataFile() != null) {
+ builder.withReferencedDataFile(delete.referencedDataFile());
+ }
+ if (delete.contentOffset() != null) {
+ builder.withContentOffset(delete.contentOffset());
+ }
+ if (delete.contentSizeInBytes() != null) {
+ builder.withContentSizeInBytes(delete.contentSizeInBytes());
+ }
+ StructLike projected = projectPartition(delete.partition(), fromSpec,
targetSpec);
+ if (projected == null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Cannot project delete file %s partition from spec %d onto spec
%d",
+ delete.location(), fromSpec.specId(), targetSpec.specId()));
+ }
+ builder.withPartition(projected);
+ return builder.build();
+ }
+
+ private static void restoreEqualityFieldIds(FileMetadata.Builder builder,
DeleteFile delete) {
+ if (delete.content() != FileContent.EQUALITY_DELETES) {
+ return;
+ }
+ List<Integer> equalityFieldIds = delete.equalityFieldIds();
+ int[] eqIds =
+ equalityFieldIds == null
+ ? new int[0]
+ : equalityFieldIds.stream().mapToInt(Integer::intValue).toArray();
+ builder.ofEqualityDeletes(eqIds);
+ }
+
+ /**
+ * True when every field of {@code toSpec} can be copied from {@code
fromSpec} by matching source
+ * id and transform. Unpartitioned targets are always projectable.
+ */
+ private static boolean canProjectPartition(PartitionSpec fromSpec,
PartitionSpec toSpec) {
+ if (toSpec.isUnpartitioned()) {
+ return true;
+ }
+ List<PartitionField> fromFields = fromSpec.fields();
+ for (PartitionField toField : toSpec.fields()) {
+ boolean found = false;
+ for (PartitionField fromField : fromFields) {
+ if (fromField.sourceId() == toField.sourceId()
+ &&
fromField.transform().toString().equals(toField.transform().toString())) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Nullable
+ private static StructLike projectPartition(
+ StructLike partition, PartitionSpec fromSpec, PartitionSpec toSpec) {
+ if (toSpec.isUnpartitioned() || partition == null) {
+ return null;
+ }
+ if (!canProjectPartition(fromSpec, toSpec)) {
+ return null;
+ }
+ PartitionData out = new PartitionData(toSpec.partitionType());
+ List<PartitionField> fromFields = fromSpec.fields();
+ List<PartitionField> toFields = toSpec.fields();
+ for (int toPos = 0; toPos < toFields.size(); toPos++) {
+ PartitionField toField = toFields.get(toPos);
+ for (int i = 0; i < fromFields.size(); i++) {
+ PartitionField fromField = fromFields.get(i);
+ if (fromField.sourceId() == toField.sourceId()
+ &&
fromField.transform().toString().equals(toField.transform().toString())) {
+ out.set(toPos, partition.get(i, Object.class));
+ break;
+ }
+ }
+ }
+ return out;
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cache/ScanPlanCacheKey.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cache/ScanPlanCacheKey.java
index 8823d1aaad..24fdfa3090 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cache/ScanPlanCacheKey.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cache/ScanPlanCacheKey.java
@@ -37,6 +37,7 @@ public class ScanPlanCacheKey {
private final String statsFieldsStr;
private final boolean caseSensitive;
private final boolean useSnapshotSchema;
+ private final int schemaId;
private ScanPlanCacheKey(
TableIdentifier tableIdentifier,
@@ -47,7 +48,8 @@ public class ScanPlanCacheKey {
String select,
String statsFields,
boolean caseSensitive,
- boolean useSnapshotSchema) {
+ boolean useSnapshotSchema,
+ int schemaId) {
this.tableIdentifier = tableIdentifier;
this.snapshotId = snapshotId;
this.startSnapshotId = startSnapshotId;
@@ -57,6 +59,7 @@ public class ScanPlanCacheKey {
this.statsFieldsStr = statsFields;
this.caseSensitive = caseSensitive;
this.useSnapshotSchema = useSnapshotSchema;
+ this.schemaId = schemaId;
}
/**
@@ -94,6 +97,9 @@ public class ScanPlanCacheKey {
statsFieldsStr =
scanRequest.statsFields().stream().sorted().collect(Collectors.joining(","));
}
+ // Current-schema planning binds names by field id; a schema-only
evolution must miss cache.
+ int schemaId = table.schema().schemaId();
+
return new ScanPlanCacheKey(
tableIdentifier,
snapshotId,
@@ -103,7 +109,8 @@ public class ScanPlanCacheKey {
selectStr,
statsFieldsStr,
scanRequest.caseSensitive(),
- scanRequest.useSnapshotSchema());
+ scanRequest.useSnapshotSchema(),
+ schemaId);
}
public TableIdentifier getTableIdentifier() {
@@ -125,6 +132,7 @@ public class ScanPlanCacheKey {
ScanPlanCacheKey that = (ScanPlanCacheKey) o;
return caseSensitive == that.caseSensitive
&& useSnapshotSchema == that.useSnapshotSchema
+ && schemaId == that.schemaId
&& Objects.equal(tableIdentifier, that.tableIdentifier)
&& Objects.equal(snapshotId, that.snapshotId)
&& Objects.equal(startSnapshotId, that.startSnapshotId)
@@ -147,14 +155,16 @@ public class ScanPlanCacheKey {
selectStr,
statsFieldsStr,
caseSensitive,
- useSnapshotSchema);
+ useSnapshotSchema,
+ schemaId);
}
@Override
public String toString() {
return String.format(
"ScanPlanCacheKey{table=%s, snapshotId=%s, startSnapshotId=%s,
endSnapshotId=%s, "
- + "filter=%s, select=%s, statsFields=%s, caseSensitive=%s,
useSnapshotSchema=%s}",
+ + "filter=%s, select=%s, statsFields=%s, caseSensitive=%s,
useSnapshotSchema=%s, "
+ + "schemaId=%s}",
tableIdentifier,
snapshotId,
startSnapshotId,
@@ -163,6 +173,7 @@ public class ScanPlanCacheKey {
selectStr.isEmpty() ? "null" : selectStr,
statsFieldsStr.isEmpty() ? "null" : statsFieldsStr,
caseSensitive,
- useSnapshotSchema);
+ useSnapshotSchema,
+ schemaId);
}
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergScanPlanHelper.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergScanPlanHelper.java
new file mode 100644
index 0000000000..61ba354586
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergScanPlanHelper.java
@@ -0,0 +1,732 @@
+/*
+ * 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.gravitino.iceberg.service;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.apache.iceberg.ContentFileParser;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.FileMetadata;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.PartitionData;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.PartitionSpecParser;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SchemaParser;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.expressions.Evaluator;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.ExpressionParser;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.expressions.ResidualEvaluator;
+import org.apache.iceberg.rest.requests.PlanTableScanRequest;
+import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.JsonUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIcebergScanPlanHelper {
+
+ // Field 2 was "l" at the snapshot and is "l2" now; field 3, added later,
reuses the name "l".
+ private static final Schema SNAPSHOT_SCHEMA =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "l", Types.LongType.get()));
+ private static final Schema CURRENT_SCHEMA =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "l2", Types.LongType.get()),
+ Types.NestedField.optional(3, "l", Types.LongType.get()));
+
+ @Test
+ void testRewriteForSnapshotMapsCurrentNames() {
+ PlanTableScanRequest rewritten =
+ IcebergScanPlanHelper.rewriteForSnapshot(
+ PlanTableScanRequest.builder()
+ .withSnapshotId(7L)
+ .withFilter(Expressions.equal("l2", 10L))
+ .withSelect(ImmutableList.of("id", "l2", "l"))
+ .withStatsFields(ImmutableList.of("l2", "l"))
+ .build(),
+ CURRENT_SCHEMA,
+ SNAPSHOT_SCHEMA);
+
+ Assertions.assertEquals(Expressions.equal("l", 10L).toString(),
rewritten.filter().toString());
+ Assertions.assertEquals(ImmutableList.of("id", "l"), rewritten.select());
+ Assertions.assertEquals(ImmutableList.of("l"), rewritten.statsFields());
+ }
+
+ @Test
+ void testRewriteForSnapshotResolvesCaseInsensitively() {
+ PlanTableScanRequest rewritten =
+ IcebergScanPlanHelper.rewriteForSnapshot(
+ PlanTableScanRequest.builder()
+ .withSnapshotId(7L)
+ .withCaseSensitive(false)
+ .withFilter(Expressions.equal("L2", 10L))
+ .build(),
+ CURRENT_SCHEMA,
+ SNAPSHOT_SCHEMA);
+
+ Assertions.assertEquals(Expressions.equal("l", 10L).toString(),
rewritten.filter().toString());
+ }
+
+ @Test
+ void testRewriteForSnapshotKeepsStarSelect() {
+ PlanTableScanRequest rewritten =
+ IcebergScanPlanHelper.rewriteForSnapshot(
+ PlanTableScanRequest.builder()
+ .withSnapshotId(7L)
+ .withSelect(ImmutableList.of("*"))
+ .build(),
+ CURRENT_SCHEMA,
+ SNAPSHOT_SCHEMA);
+
+ Assertions.assertEquals(ImmutableList.of("*"), rewritten.select());
+ }
+
+ @Test
+ void testRewriteFilterNames() {
+ Assertions.assertEquals(
+ Expressions.equal("l", 10L).toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.equal("l2", 10L), CURRENT_SCHEMA, SNAPSHOT_SCHEMA,
true)
+ .toString());
+ Assertions.assertEquals(
+ Expressions.equal("id", 1L).toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.equal("id", 1L), CURRENT_SCHEMA, SNAPSHOT_SCHEMA,
true)
+ .toString());
+ Assertions.assertEquals(
+ Expressions.and(Expressions.in("l", 1L, 2L),
Expressions.not(Expressions.isNull("l")))
+ .toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.and(
+ Expressions.in("l2", 1L, 2L),
Expressions.not(Expressions.isNull("l2"))),
+ CURRENT_SCHEMA,
+ SNAPSHOT_SCHEMA,
+ true)
+ .toString());
+ Assertions.assertEquals(
+ Expressions.equal(Expressions.bucket("l", 4), 1).toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.equal(Expressions.bucket("l2", 4), 1),
+ CURRENT_SCHEMA,
+ SNAPSHOT_SCHEMA,
+ true)
+ .toString());
+
+ // Column added after the snapshot: do not prune; callers AND the full
original filter.
+ Assertions.assertEquals(
+ Expressions.alwaysTrue().toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.isNull("l"), CURRENT_SCHEMA, SNAPSHOT_SCHEMA, true)
+ .toString());
+ Assertions.assertEquals(
+ Expressions.alwaysTrue().toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.equal("l", 10L), CURRENT_SCHEMA, SNAPSHOT_SCHEMA,
true)
+ .toString());
+
+ // Mixed OR: post-snapshot leaf becomes always-true so planning
over-includes; residual keeps
+ // the complete original filter.
+ Assertions.assertEquals(
+ Expressions.alwaysTrue().toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.or(Expressions.equal("id", 1L),
Expressions.equal("l", 2L)),
+ CURRENT_SCHEMA,
+ SNAPSHOT_SCHEMA,
+ true)
+ .toString());
+
+ // NOT of a post-snapshot predicate must not become always-false (would
prune every file).
+ Assertions.assertEquals(
+ Expressions.alwaysTrue().toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.not(Expressions.equal("l", 1L)), CURRENT_SCHEMA,
SNAPSHOT_SCHEMA, true)
+ .toString());
+ Assertions.assertEquals(
+ Expressions.not(Expressions.equal("id", 1L)).toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.not(Expressions.equal("id", 1L)), CURRENT_SCHEMA,
SNAPSHOT_SCHEMA, true)
+ .toString());
+
+ // NOT over mixed AND/OR must stay weak (never collapse to NOT(old) and
under-prune).
+ Assertions.assertEquals(
+ Expressions.alwaysTrue().toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.not(
+ Expressions.and(Expressions.equal("id", 1L),
Expressions.notNull("l"))),
+ CURRENT_SCHEMA,
+ SNAPSHOT_SCHEMA,
+ true)
+ .toString());
+ // Under NOT, a post-snapshot OR leaf becomes false, so planning keeps
NOT(old) — correct for
+ // historical rows where the new column is null/absent.
+ Assertions.assertEquals(
+ Expressions.not(Expressions.equal("id", 1L)).toString(),
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.not(
+ Expressions.or(Expressions.equal("id", 1L),
Expressions.equal("l", 2L))),
+ CURRENT_SCHEMA,
+ SNAPSHOT_SCHEMA,
+ true)
+ .toString());
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.equal("nope", 1L), CURRENT_SCHEMA,
SNAPSHOT_SCHEMA, true));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.equal("L2", 10L), CURRENT_SCHEMA, SNAPSHOT_SCHEMA,
true));
+ }
+
+ @Test
+ void testRemapResidualKeepsRequestFieldIdentities() {
+ Expression residualOnSnapshot =
+ IcebergScanPlanHelper.rewriteFilterNames(
+ Expressions.equal("l2", 10L), CURRENT_SCHEMA, SNAPSHOT_SCHEMA,
true);
+ Assertions.assertEquals(Expressions.equal("l", 10L).toString(),
residualOnSnapshot.toString());
+
+ Expression residualForClient =
+ IcebergScanPlanHelper.remapExpressionNames(
+ residualOnSnapshot, SNAPSHOT_SCHEMA, CURRENT_SCHEMA, true);
+ Assertions.assertEquals(Expressions.equal("l2", 10L).toString(),
residualForClient.toString());
+
+ // With name reuse, evaluating the remapped residual against the request
row is true; the
+ // snapshot-named residual would bind to the wrong field and evaluate
false.
+ Types.StructType rowType = CURRENT_SCHEMA.asStruct();
+ StructLike row = new ArrayStruct(1L, 10L, null);
+ Assertions.assertTrue(new Evaluator(rowType, residualForClient).eval(row));
+ Assertions.assertFalse(new Evaluator(rowType,
residualOnSnapshot).eval(row));
+ }
+
+ @Test
+ void testRewriteColumnNamesDropsColumnsAddedLater() {
+ Assertions.assertEquals(
+ ImmutableList.of("id", "l"),
+ IcebergScanPlanHelper.rewriteColumnNames(
+ ImmutableList.of("id", "l2", "l"), CURRENT_SCHEMA,
SNAPSHOT_SCHEMA, true));
+ }
+
+ @Test
+ void testSanitizeSpecsForDroppedPartitionSource() {
+ Schema originalSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "s", Types.StringType.get()),
+ Types.NestedField.optional(3, "n", Types.IntegerType.get()));
+ PartitionSpec originalSpec =
+
PartitionSpec.builderFor(originalSchema).withSpecId(0).identity("s").build();
+
+ Schema evolvedSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(3, "n", Types.IntegerType.get()));
+ // allowMissingFields=true mirrors a table whose current schema dropped
the partition source
+ // while the historical spec is still retained in specs-by-id.
+ PartitionSpec staleSpec = originalSpec.toUnbound().bind(evolvedSchema,
true);
+ Assertions.assertTrue(
+ staleSpec.partitionType().fields().stream()
+ .anyMatch(f -> f.type().typeId() == Type.TypeID.UNKNOWN));
+
+ Types.StructType partitionDataType =
+ Types.StructType.of(Types.NestedField.optional(1000, "s",
Types.StringType.get()));
+ PartitionData partitionData = new PartitionData(partitionDataType);
+ partitionData.set(0, "a");
+
+ DataFile dataFile = mock(DataFile.class);
+ doReturn(0).when(dataFile).specId();
+ doReturn(partitionData).when(dataFile).partition();
+
+ FileScanTask task = mock(FileScanTask.class);
+ doReturn(dataFile).when(task).file();
+
+ Map<Integer, PartitionSpec> sanitized =
+ IcebergScanPlanHelper.sanitizeSpecsForSerialization(
+ ImmutableMap.of(0, staleSpec),
+ Collections.singletonList(task),
+ ImmutableList.of(originalSchema, evolvedSchema));
+
+ Assertions.assertFalse(
+ sanitized.get(0).partitionType().fields().stream()
+ .anyMatch(f -> f.type().typeId() == Type.TypeID.UNKNOWN));
+ Assertions.assertEquals(
+ Type.TypeID.STRING,
sanitized.get(0).partitionType().fields().get(0).type().typeId());
+ }
+
+ @Test
+ void testSanitizeSpecsDedupesMultipleTransformsOfOneDroppedSource() {
+ Schema originalSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "s", Types.StringType.get()));
+ PartitionSpec originalSpec =
+
PartitionSpec.builderFor(originalSchema).withSpecId(0).identity("s").bucket("s",
8).build();
+ Schema evolved = new Schema(Types.NestedField.optional(1, "id",
Types.LongType.get()));
+ PartitionSpec stale = originalSpec.toUnbound().bind(evolved, true);
+
+ Map<Integer, PartitionSpec> sanitized =
+ IcebergScanPlanHelper.sanitizeSpecsForSerialization(
+ ImmutableMap.of(0, stale),
+ Collections.emptyList(),
+ ImmutableList.of(originalSchema, evolved));
+
+ Assertions.assertDoesNotThrow(() -> sanitized.get(0).schema());
+ Assertions.assertEquals(
+ 1,
+ sanitized.get(0).schema().columns().stream()
+ .filter(f -> f.name().startsWith("_dropped_source_"))
+ .count());
+ }
+
+ @Test
+ void testSanitizeSpecsDoesNotUseTransformResultTypeAsSource() {
+ Schema originalSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "s", Types.StringType.get()),
+ Types.NestedField.optional(3, "ts",
Types.TimestampType.withoutZone()));
+ PartitionSpec originalSpec =
+
PartitionSpec.builderFor(originalSchema).withSpecId(0).identity("s").hour("ts").build();
+ Schema evolved = new Schema(Types.NestedField.optional(1, "id",
Types.LongType.get()));
+ PartitionSpec stale = originalSpec.toUnbound().bind(evolved, true);
+
+ // Partition data carries transform results (string + int), which must not
become source types.
+ Types.StructType partitionDataType =
+ Types.StructType.of(
+ Types.NestedField.optional(1000, "s", Types.StringType.get()),
+ Types.NestedField.optional(1001, "ts_hour",
Types.IntegerType.get()));
+ PartitionData partitionData = new PartitionData(partitionDataType);
+ partitionData.set(0, "a");
+ partitionData.set(1, 1);
+ DataFile dataFile = mock(DataFile.class);
+ doReturn(0).when(dataFile).specId();
+ doReturn(partitionData).when(dataFile).partition();
+ FileScanTask task = mock(FileScanTask.class);
+ doReturn(dataFile).when(task).file();
+
+ Map<Integer, PartitionSpec> sanitized =
+ IcebergScanPlanHelper.sanitizeSpecsForSerialization(
+ ImmutableMap.of(0, stale),
+ Collections.singletonList(task),
+ ImmutableList.of(originalSchema, evolved));
+
+ Assertions.assertEquals(
+ Type.TypeID.STRING,
sanitized.get(0).schema().findField(2).type().typeId());
+ Assertions.assertEquals(
+ Type.TypeID.TIMESTAMP,
sanitized.get(0).schema().findField(3).type().typeId());
+ }
+
+ @Test
+ void testPrepareTasksRewritesUnknownSpecForClientRoundTrip() {
+ Schema originalSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "s", Types.StringType.get()));
+ PartitionSpec originalSpec =
+
PartitionSpec.builderFor(originalSchema).withSpecId(0).identity("s").build();
+ Schema evolved = new Schema(Types.NestedField.optional(1, "id",
Types.LongType.get()));
+ PartitionSpec unpartitioned =
PartitionSpec.builderFor(evolved).withSpecId(1).build();
+ PartitionSpec stale = originalSpec.toUnbound().bind(evolved, true);
+
+ PartitionData partitionData =
+ new PartitionData(
+ Types.StructType.of(Types.NestedField.optional(1000, "s",
Types.StringType.get())));
+ partitionData.set(0, "a");
+ DataFile dataFile =
+ DataFiles.builder(originalSpec)
+ .withPath("file:///tmp/f.parquet")
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(10)
+ .withRecordCount(1)
+ .withPartition(partitionData)
+ .build();
+
+ // Build a task against the stale (client-visible) spec id.
+ org.apache.iceberg.BaseFileScanTask task =
+ new org.apache.iceberg.BaseFileScanTask(
+
DataFiles.builder(stale).copy(dataFile).withPartition(partitionData).build(),
+ null,
+ SchemaParser.toJson(evolved),
+ PartitionSpecParser.toJson(stale),
+ ResidualEvaluator.of(stale, Expressions.equal("id", 1L), true));
+
+ Map<Integer, PartitionSpec> clientSpecs = ImmutableMap.of(0, stale, 1,
unpartitioned);
+ List<FileScanTask> prepared =
+ IcebergScanPlanHelper.prepareTasksForResponse(
+ Collections.singletonList(task), clientSpecs, evolved, null, null,
true);
+
+ Assertions.assertEquals(1, prepared.get(0).file().specId());
+
Assertions.assertTrue(clientSpecs.get(prepared.get(0).file().specId()).isUnpartitioned());
+
+ // Serialize with sanitized specs (server), parse with the client's
original broken specs.
+ Map<Integer, PartitionSpec> sanitized =
+ IcebergScanPlanHelper.sanitizeSpecsForSerialization(
+ clientSpecs, prepared, ImmutableList.of(originalSchema, evolved));
+ String json =
+ JsonUtil.generate(
+ gen -> ContentFileParser.toJson(prepared.get(0).file(),
sanitized.get(1), gen), false);
+ JsonNode node = JsonUtil.parse(json, n -> n);
+ DataFile parsed = (DataFile) ContentFileParser.fromJson(node, clientSpecs);
+ Assertions.assertEquals("file:///tmp/f.parquet", parsed.location());
+ }
+
+ @Test
+ void testPrepareTasksRewritesToCurrentSpecWhenNeverUnpartitioned() {
+ // Evolution (s, id) → (id) never creates an unpartitioned spec; old files
keep spec 0.
+ Schema originalSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "s", Types.StringType.get()));
+ PartitionSpec originalSpec =
+
PartitionSpec.builderFor(originalSchema).withSpecId(0).identity("s").identity("id").build();
+ Schema evolved =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "s", Types.StringType.get()));
+ PartitionSpec currentSpec =
+ PartitionSpec.builderFor(evolved).withSpecId(1).identity("id").build();
+ Schema droppedS = new Schema(Types.NestedField.optional(1, "id",
Types.LongType.get()));
+ PartitionSpec stale = originalSpec.toUnbound().bind(droppedS, true);
+ PartitionSpec currentOnDropped = currentSpec.toUnbound().bind(droppedS,
true);
+
+ PartitionData partitionData =
+ new PartitionData(
+ Types.StructType.of(
+ Types.NestedField.optional(1000, "s", Types.StringType.get()),
+ Types.NestedField.optional(1001, "id", Types.LongType.get())));
+ partitionData.set(0, "a");
+ partitionData.set(1, 7L);
+ DataFile dataFile =
+ DataFiles.builder(originalSpec)
+ .withPath("file:///tmp/sid.parquet")
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(10)
+ .withRecordCount(1)
+ .withPartition(partitionData)
+ .build();
+
+ org.apache.iceberg.BaseFileScanTask task =
+ new org.apache.iceberg.BaseFileScanTask(
+
DataFiles.builder(stale).copy(dataFile).withPartition(partitionData).build(),
+ null,
+ SchemaParser.toJson(droppedS),
+ PartitionSpecParser.toJson(stale),
+ ResidualEvaluator.of(stale, Expressions.alwaysTrue(), true));
+
+ Map<Integer, PartitionSpec> clientSpecs = ImmutableMap.of(0, stale, 1,
currentOnDropped);
+ List<FileScanTask> prepared =
+ IcebergScanPlanHelper.prepareTasksForResponse(
+ Collections.singletonList(task), clientSpecs, droppedS, null,
null, true);
+
+ Assertions.assertEquals(1, prepared.get(0).file().specId());
+ Assertions.assertFalse(clientSpecs.get(1).isUnpartitioned());
+
+ String json =
+ JsonUtil.generate(
+ gen -> ContentFileParser.toJson(prepared.get(0).file(),
clientSpecs.get(1), gen),
+ false);
+ DataFile parsed =
+ (DataFile) ContentFileParser.fromJson(JsonUtil.parse(json, n -> n),
clientSpecs);
+ Assertions.assertEquals("file:///tmp/sid.parquet", parsed.location());
+ Assertions.assertEquals(1, parsed.specId());
+ }
+
+ @Test
+ void testPrepareTasksFailsWhenTargetPartitionValuesMissing() {
+ // Evolution (s) → (id) with s dropped: old files have no id partition
value and no
+ // unpartitioned spec. Rewriting must not invent NULL for id.
+ Schema originalSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "s", Types.StringType.get()));
+ PartitionSpec originalSpec =
+
PartitionSpec.builderFor(originalSchema).withSpecId(0).identity("s").build();
+ Schema evolved = new Schema(Types.NestedField.optional(1, "id",
Types.LongType.get()));
+ PartitionSpec currentSpec =
+ PartitionSpec.builderFor(evolved).withSpecId(1).identity("id").build();
+ PartitionSpec stale = originalSpec.toUnbound().bind(evolved, true);
+ PartitionSpec currentOnDropped = currentSpec.toUnbound().bind(evolved,
true);
+
+ PartitionData partitionData =
+ new PartitionData(
+ Types.StructType.of(Types.NestedField.optional(1000, "s",
Types.StringType.get())));
+ partitionData.set(0, "a");
+ DataFile dataFile =
+ DataFiles.builder(originalSpec)
+ .withPath("file:///tmp/missing-id.parquet")
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(10)
+ .withRecordCount(1)
+ .withPartition(partitionData)
+ .build();
+
+ org.apache.iceberg.BaseFileScanTask task =
+ new org.apache.iceberg.BaseFileScanTask(
+
DataFiles.builder(stale).copy(dataFile).withPartition(partitionData).build(),
+ null,
+ SchemaParser.toJson(evolved),
+ PartitionSpecParser.toJson(stale),
+ ResidualEvaluator.of(stale, Expressions.equal("id", 1L), true));
+
+ Map<Integer, PartitionSpec> clientSpecs = ImmutableMap.of(0, stale, 1,
currentOnDropped);
+ IllegalArgumentException thrown =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ IcebergScanPlanHelper.prepareTasksForResponse(
+ Collections.singletonList(task), clientSpecs, evolved,
null, null, true));
+ Assertions.assertTrue(
+ thrown.getMessage().contains("missing from the source file"),
thrown.getMessage());
+ }
+
+ @Test
+ void testPrepareTasksRewritesDeleteFilesWithCompatibleSpec() {
+ Schema originalSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "s", Types.StringType.get()));
+ PartitionSpec originalSpec =
+
PartitionSpec.builderFor(originalSchema).withSpecId(0).identity("s").build();
+ Schema evolved = new Schema(Types.NestedField.optional(1, "id",
Types.LongType.get()));
+ PartitionSpec unpartitioned =
PartitionSpec.builderFor(evolved).withSpecId(1).build();
+ PartitionSpec stale = originalSpec.toUnbound().bind(evolved, true);
+
+ PartitionData partitionData =
+ new PartitionData(
+ Types.StructType.of(Types.NestedField.optional(1000, "s",
Types.StringType.get())));
+ partitionData.set(0, "a");
+ DataFile dataFile =
+ DataFiles.builder(originalSpec)
+ .withPath("file:///tmp/data.parquet")
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(10)
+ .withRecordCount(1)
+ .withPartition(partitionData)
+ .build();
+ DeleteFile deleteFile =
+ FileMetadata.deleteFileBuilder(originalSpec)
+ .ofPositionDeletes()
+ .withPath("file:///tmp/data.parquet.pos-deletes")
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(4)
+ .withRecordCount(1)
+ .withPartition(partitionData)
+ .build();
+
+ org.apache.iceberg.BaseFileScanTask task =
+ new org.apache.iceberg.BaseFileScanTask(
+
DataFiles.builder(stale).copy(dataFile).withPartition(partitionData).build(),
+ new DeleteFile[] {
+ FileMetadata.deleteFileBuilder(stale)
+ .copy(deleteFile)
+ .withPartition(partitionData)
+ .build()
+ },
+ SchemaParser.toJson(evolved),
+ PartitionSpecParser.toJson(stale),
+ ResidualEvaluator.of(stale, Expressions.alwaysTrue(), true));
+
+ Map<Integer, PartitionSpec> clientSpecs = ImmutableMap.of(0, stale, 1,
unpartitioned);
+ List<FileScanTask> prepared =
+ IcebergScanPlanHelper.prepareTasksForResponse(
+ Collections.singletonList(task), clientSpecs, evolved, null, null,
true);
+
+ Assertions.assertEquals(1, prepared.get(0).file().specId());
+ Assertions.assertEquals(1, prepared.get(0).deletes().size());
+ Assertions.assertEquals(1, prepared.get(0).deletes().get(0).specId());
+
+ String deleteJson =
+ JsonUtil.generate(
+ gen -> ContentFileParser.toJson(prepared.get(0).deletes().get(0),
unpartitioned, gen),
+ false);
+ DeleteFile parsedDelete =
+ (DeleteFile) ContentFileParser.fromJson(JsonUtil.parse(deleteJson, n
-> n), clientSpecs);
+ Assertions.assertEquals("file:///tmp/data.parquet.pos-deletes",
parsedDelete.location());
+ Assertions.assertEquals(1, parsedDelete.specId());
+ }
+
+ @Test
+ void testPrepareTasksPreservesEqualityFieldIdsOnRewrite() {
+ Schema originalSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "s", Types.StringType.get()));
+ PartitionSpec originalSpec =
+
PartitionSpec.builderFor(originalSchema).withSpecId(0).identity("s").build();
+ Schema evolved = new Schema(Types.NestedField.optional(1, "id",
Types.LongType.get()));
+ PartitionSpec unpartitioned =
PartitionSpec.builderFor(evolved).withSpecId(1).build();
+ PartitionSpec stale = originalSpec.toUnbound().bind(evolved, true);
+
+ PartitionData partitionData =
+ new PartitionData(
+ Types.StructType.of(Types.NestedField.optional(1000, "s",
Types.StringType.get())));
+ partitionData.set(0, "a");
+ DataFile dataFile =
+ DataFiles.builder(originalSpec)
+ .withPath("file:///tmp/eq-data.parquet")
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(10)
+ .withRecordCount(1)
+ .withPartition(partitionData)
+ .build();
+ DeleteFile equalityDelete =
+ FileMetadata.deleteFileBuilder(originalSpec)
+ .ofEqualityDeletes(1)
+ .withPath("file:///tmp/eq-data.parquet.eq-deletes")
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(4)
+ .withRecordCount(1)
+ .withPartition(partitionData)
+ .build();
+
+ org.apache.iceberg.BaseFileScanTask task =
+ new org.apache.iceberg.BaseFileScanTask(
+
DataFiles.builder(stale).copy(dataFile).withPartition(partitionData).build(),
+ new DeleteFile[] {
+ FileMetadata.deleteFileBuilder(stale)
+ .copy(equalityDelete)
+ .ofEqualityDeletes(1)
+ .withPartition(partitionData)
+ .build()
+ },
+ SchemaParser.toJson(evolved),
+ PartitionSpecParser.toJson(stale),
+ ResidualEvaluator.of(stale, Expressions.alwaysTrue(), true));
+
+ Map<Integer, PartitionSpec> clientSpecs = ImmutableMap.of(0, stale, 1,
unpartitioned);
+ List<FileScanTask> prepared =
+ IcebergScanPlanHelper.prepareTasksForResponse(
+ Collections.singletonList(task), clientSpecs, evolved, null, null,
true);
+
+ DeleteFile rewritten = prepared.get(0).deletes().get(0);
+ Assertions.assertEquals(1, rewritten.specId());
+ Assertions.assertEquals(ImmutableList.of(1), rewritten.equalityFieldIds());
+
+ String deleteJson =
+ JsonUtil.generate(gen -> ContentFileParser.toJson(rewritten,
unpartitioned, gen), false);
+ DeleteFile parsedDelete =
+ (DeleteFile) ContentFileParser.fromJson(JsonUtil.parse(deleteJson, n
-> n), clientSpecs);
+ Assertions.assertEquals(ImmutableList.of(1),
parsedDelete.equalityFieldIds());
+ }
+
+ @Test
+ void testPrepareTasksResidualEvaluatorUsesRequestSchema() {
+ // Partitioned by id; snapshot schema names field 2 "l", request schema
renamed it to "l2".
+ Schema snapshotSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "l", Types.LongType.get()));
+ Schema requestSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "l2", Types.LongType.get()));
+ PartitionSpec snapshotSpec =
+
PartitionSpec.builderFor(snapshotSchema).withSpecId(0).identity("id").build();
+ PartitionSpec requestSpec = snapshotSpec.toUnbound().bind(requestSchema,
true);
+
+ PartitionData partitionData =
+ new PartitionData(
+ Types.StructType.of(Types.NestedField.optional(1000, "id",
Types.LongType.get())));
+ partitionData.set(0, 1L);
+ DataFile dataFile =
+ DataFiles.builder(snapshotSpec)
+ .withPath("file:///tmp/part.parquet")
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(10)
+ .withRecordCount(1)
+ .withPartition(partitionData)
+ .build();
+
+ Expression snapshotResidual = Expressions.equal("l", 10L);
+ org.apache.iceberg.BaseFileScanTask task =
+ new org.apache.iceberg.BaseFileScanTask(
+ dataFile,
+ null,
+ SchemaParser.toJson(snapshotSchema),
+ PartitionSpecParser.toJson(snapshotSpec),
+ ResidualEvaluator.of(snapshotSpec, snapshotResidual, true));
+
+ List<FileScanTask> prepared =
+ IcebergScanPlanHelper.prepareTasksForResponse(
+ Collections.singletonList(task),
+ ImmutableMap.of(0, requestSpec),
+ requestSchema,
+ snapshotSchema,
+ null,
+ true);
+
+ Assertions.assertEquals(
+ Expressions.equal("l2", 10L).toString(),
prepared.get(0).residual().toString());
+ // ResidualEvaluator must bind remapped names against the request schema,
not the snapshot.
+ Types.StructType rowType = requestSchema.asStruct();
+ Assertions.assertTrue(
+ new Evaluator(rowType, prepared.get(0).residual()).eval(new
ArrayStruct(1L, 10L)));
+ }
+
+ @Test
+ void testRemapExpressionRoundTripThroughParser() {
+ Expression remapped =
+ IcebergScanPlanHelper.remapExpressionNames(
+ Expressions.equal("l", 10L), SNAPSHOT_SCHEMA, CURRENT_SCHEMA,
true);
+ String json = ExpressionParser.toJson(remapped);
+ Expression parsed = ExpressionParser.fromJson(json);
+ Assertions.assertEquals(Expressions.equal("l2", 10L).toString(),
parsed.toString());
+ }
+
+ private static final class ArrayStruct implements StructLike {
+ private final Object[] values;
+
+ private ArrayStruct(Object... values) {
+ this.values = values;
+ }
+
+ @Override
+ public int size() {
+ return values.length;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public <T> T get(int pos, Class<T> javaClass) {
+ return (T) values[pos];
+ }
+
+ @Override
+ public <T> void set(int pos, T value) {
+ values[pos] = value;
+ }
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergScanPlanning.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergScanPlanning.java
index 84b11e5ec1..4f0d1d034f 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergScanPlanning.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergScanPlanning.java
@@ -20,13 +20,18 @@ package org.apache.gravitino.iceberg.service;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.stream.Collectors;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.iceberg.DataFile;
import org.apache.iceberg.DataFiles;
import org.apache.iceberg.DeleteFile;
import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.Metrics;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Table;
@@ -38,16 +43,20 @@ import org.apache.iceberg.expressions.Expression;
import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.rest.requests.PlanTableScanRequest;
import org.apache.iceberg.rest.responses.PlanTableScanResponse;
+import org.apache.iceberg.types.Conversions;
import org.apache.iceberg.types.Types;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
import org.mockito.Mockito;
/** Tests server-side scan planning against the REST spec's request semantics.
*/
public class TestIcebergScanPlanning {
private static final TableIdentifier IDENT =
TableIdentifier.of(Namespace.of("db"), "t");
+ private static final List<String> F1 = ImmutableList.of("/data/f1.parquet");
+ private static final List<String> NONE = ImmutableList.of();
private CatalogWrapperForREST wrapper;
private Table table;
@@ -130,6 +139,101 @@ public class TestIcebergScanPlanning {
Assertions.assertEquals(Expression.Operation.TRUE,
alsoAccepted.filter().op());
}
+ @Test
+ public void testBranchScanBindsToTheCurrentSchema() {
+ EvolvedTable evolved = evolvedTable("branch_scan");
+ // Branch "b" still points at s1, so this is the request Iceberg's client
sends for a branch.
+ evolved.table.manageSnapshots().createBranch("b", evolved.s1).commit();
+
+ Assertions.assertEquals(F1, planBranch(evolved, Expressions.equal("l2",
10L)));
+ Assertions.assertEquals(NONE, planBranch(evolved, Expressions.equal("l2",
99L)));
+ // "l" is the column added after s1. It has no values in f1, so f1 is not
pruned on it,
+ // while binding to the snapshot's "l" (field 2, bounds [10, 10]) would
prune f1.
+ Assertions.assertEquals(F1, planBranch(evolved, Expressions.equal("l",
99L)));
+ assertInvalidScan(() -> planBranch(evolved, Expressions.equal("a", 5L)),
"'a'");
+
+ Assertions.assertEquals(
+ F1,
+ planFiles(
+ evolved.ident,
+ branchRequest(evolved).withSelect(ImmutableList.of("id", "l2",
"l", "a2"))));
+ Assertions.assertEquals(
+ F1,
+ planFiles(
+ evolved.ident,
+ branchRequest(evolved)
+ .withCaseSensitive(false)
+ .withFilter(Expressions.equal("L2", 10L))));
+ }
+
+ @Test
+ public void testSnapshotNoBranchPointsAtBindsToTheCurrentSchema() {
+ EvolvedTable evolved = evolvedTable("moved_branch");
+ // The branch moved past s1 after the client read it, so no branch points
at s1 any more.
+ evolved.table.manageSnapshots().createBranch("b", evolved.s1).commit();
+ evolved
+ .table
+ .newFastAppend()
+ .appendFile(file("/data/f3.parquet", ImmutableMap.of(2, 30L)))
+ .toBranch("b")
+ .commit();
+ evolved.table.refresh();
+
+ Assertions.assertEquals(F1, planBranch(evolved, Expressions.equal("l2",
10L)));
+ Assertions.assertEquals(NONE, planBranch(evolved, Expressions.equal("l2",
99L)));
+ // Same current-schema semantics as when a branch still pointed here: "l"
was added after s1,
+ // so old files are not pruned on it (they read as null under the current
schema).
+ Assertions.assertEquals(F1, planBranch(evolved, Expressions.equal("l",
99L)));
+ assertInvalidScan(() -> planBranch(evolved, Expressions.equal("a", 5L)),
"'a'");
+
+ // Residual must keep current-schema names (l2), not the snapshot name (l)
that now means a
+ // different field id after rename+reuse. The complete client filter is
ANDed on as an
+ // extra residual (may duplicate the remapped scan residual; that is
intentional).
+ PlanTableScanResponse residualResponse =
+ wrapper.planTableScan(
+ evolved.ident,
branchRequest(evolved).withFilter(Expressions.equal("l2", 10L)).build());
+ Assertions.assertEquals(1, residualResponse.fileScanTasks().size());
+ String residual =
residualResponse.fileScanTasks().get(0).residual().toString();
+ Assertions.assertTrue(residual.contains("l2"), residual);
+ Assertions.assertFalse(residual.contains("name=\"l\""), residual);
+
+ Assertions.assertEquals(
+ F1,
+ planFiles(
+ evolved.ident,
+ branchRequest(evolved)
+ .withSelect(ImmutableList.of("id", "l2", "l", "a2"))
+ .withStatsFields(ImmutableList.of("l2", "l"))));
+ Assertions.assertEquals(
+ F1,
+ planFiles(
+ evolved.ident,
+ branchRequest(evolved)
+ .withCaseSensitive(false)
+ .withFilter(Expressions.equal("L2", 10L))));
+ Assertions.assertEquals(
+ F1, planFiles(evolved.ident,
branchRequest(evolved).withSelect(ImmutableList.of("*"))));
+ }
+
+ @Test
+ public void testTimeTravelBindsToTheSnapshotSchema() {
+ EvolvedTable evolved = evolvedTable("time_travel");
+ PlanTableScanRequest.Builder request =
+
PlanTableScanRequest.builder().withSnapshotId(evolved.s1).withUseSnapshotSchema(true);
+
+ Assertions.assertEquals(
+ F1, planFiles(evolved.ident, request.withFilter(Expressions.equal("l",
10L))));
+ assertInvalidScan(
+ () ->
+ planFiles(
+ evolved.ident,
+ PlanTableScanRequest.builder()
+ .withSnapshotId(evolved.s1)
+ .withUseSnapshotSchema(true)
+ .withFilter(Expressions.equal("l2", 10L))),
+ "'l2'");
+ }
+
@Test
public void testDeletionVectorsSharingAFileAreRefused() {
DeleteFile dv1 = deletionVector("/data/dv.puffin", 4L, "/data/f1.parquet");
@@ -174,10 +278,87 @@ public class TestIcebergScanPlanning {
}
private List<String> planFiles(PlanTableScanRequest.Builder request) {
- PlanTableScanResponse response = wrapper.planTableScan(IDENT,
request.build());
+ return planFiles(IDENT, request);
+ }
+
+ private List<String> planFiles(TableIdentifier ident,
PlanTableScanRequest.Builder request) {
+ PlanTableScanResponse response = wrapper.planTableScan(ident,
request.build());
return response.fileScanTasks().stream()
.map(task -> task.file().location())
.sorted()
.collect(Collectors.toList());
}
+
+ private List<String> planBranch(EvolvedTable evolved, Expression filter) {
+ return planFiles(evolved.ident, branchRequest(evolved).withFilter(filter));
+ }
+
+ private static PlanTableScanRequest.Builder branchRequest(EvolvedTable
evolved) {
+ return
PlanTableScanRequest.builder().withSnapshotId(evolved.s1).withUseSnapshotSchema(false);
+ }
+
+ private static void assertInvalidScan(Executable plan, String field) {
+ IllegalArgumentException e =
Assertions.assertThrows(IllegalArgumentException.class, plan);
+ Assertions.assertTrue(e.getMessage().contains(field), e.getMessage());
+ }
+
+ /**
+ * Creates a table whose snapshot s1 holds f1 with l=10 and a=5. Main then
moves on to f2, and the
+ * schema evolves: l is renamed to l2, a new column reuses the name l, and a
is renamed to a2.
+ */
+ private EvolvedTable evolvedTable(String name) {
+ TableIdentifier ident = TableIdentifier.of(Namespace.of("db"), name);
+ Table evolved =
+ wrapper
+ .getCatalog()
+ .createTable(
+ ident,
+ new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "l", Types.LongType.get()),
+ Types.NestedField.optional(3, "a", Types.LongType.get())),
+ PartitionSpec.unpartitioned());
+ evolved
+ .newFastAppend()
+ .appendFile(file("/data/f1.parquet", ImmutableMap.of(2, 10L, 3, 5L)))
+ .commit();
+ evolved.refresh();
+ long s1 = evolved.currentSnapshot().snapshotId();
+ evolved.newFastAppend().appendFile(file("/data/f2.parquet",
ImmutableMap.of(2, 20L))).commit();
+ // Rename first so the next update can reuse the freed name "l".
+ evolved.updateSchema().renameColumn("l", "l2").renameColumn("a",
"a2").commit();
+ evolved.updateSchema().addColumn("l", Types.LongType.get()).commit();
+ evolved.refresh();
+ return new EvolvedTable(ident, evolved, s1);
+ }
+
+ private static DataFile file(String path, Map<Integer, Long> values) {
+ Map<Integer, ByteBuffer> bounds = new HashMap<>();
+ Map<Integer, Long> valueCounts = new HashMap<>();
+ Map<Integer, Long> nullCounts = new HashMap<>();
+ values.forEach(
+ (fieldId, value) -> {
+ bounds.put(fieldId, Conversions.toByteBuffer(Types.LongType.get(),
value));
+ valueCounts.put(fieldId, 1L);
+ nullCounts.put(fieldId, 0L);
+ });
+ return DataFiles.builder(PartitionSpec.unpartitioned())
+ .withPath(path)
+ .withFileSizeInBytes(10)
+ .withRecordCount(1)
+ .withMetrics(new Metrics(1L, null, valueCounts, nullCounts, null,
bounds, bounds))
+ .build();
+ }
+
+ private static final class EvolvedTable {
+ private final TableIdentifier ident;
+ private final Table table;
+ private final long s1;
+
+ private EvolvedTable(TableIdentifier ident, Table table, long s1) {
+ this.ident = ident;
+ this.table = table;
+ this.s1 = s1;
+ }
+ }
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestScanPlanCache.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestScanPlanCache.java
index 8fb5afec21..39e028d570 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestScanPlanCache.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestScanPlanCache.java
@@ -28,6 +28,7 @@ import java.util.Optional;
import org.apache.gravitino.iceberg.service.cache.LocalScanPlanCache;
import org.apache.gravitino.iceberg.service.cache.ScanPlanCache;
import org.apache.gravitino.iceberg.service.cache.ScanPlanCacheKey;
+import org.apache.iceberg.Schema;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.Table;
import org.apache.iceberg.catalog.Namespace;
@@ -37,6 +38,7 @@ import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.rest.PlanStatus;
import org.apache.iceberg.rest.requests.PlanTableScanRequest;
import org.apache.iceberg.rest.responses.PlanTableScanResponse;
+import org.apache.iceberg.types.Types;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
@@ -61,6 +63,8 @@ public class TestScanPlanCache {
mockTable = mock(Table.class);
when(mockTable.name()).thenReturn("test_table");
when(mockTable.currentSnapshot()).thenReturn(mockSnapshot);
+ when(mockTable.schema())
+ .thenReturn(new Schema(0, Types.NestedField.optional(1, "id",
Types.LongType.get())));
}
@AfterEach
@@ -263,6 +267,37 @@ public class TestScanPlanCache {
cached1.get(), cached2.get(), "Different requests should have
different cache entries");
}
+ @Test
+ public void testCacheMissOnSchemaIdChange() {
+ PlanTableScanRequest scanRequest =
PlanTableScanRequest.builder().withSnapshotId(1L).build();
+ PlanTableScanResponse response =
+ PlanTableScanResponse.builder()
+ .withPlanStatus(PlanStatus.COMPLETED)
+ .withFileScanTasks(Collections.emptyList())
+ .build();
+
+ scanPlanCache.put(ScanPlanCacheKey.create(tableIdentifier, mockTable,
scanRequest), response);
+ Assertions.assertTrue(
+ scanPlanCache
+ .get(ScanPlanCacheKey.create(tableIdentifier, mockTable,
scanRequest))
+ .isPresent());
+
+ Table evolvedTable = mock(Table.class);
+ when(evolvedTable.currentSnapshot()).thenReturn(mockSnapshot);
+ when(evolvedTable.schema())
+ .thenReturn(
+ new Schema(
+ 1,
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "a", Types.LongType.get())));
+
+ Assertions.assertFalse(
+ scanPlanCache
+ .get(ScanPlanCacheKey.create(tableIdentifier, evolvedTable,
scanRequest))
+ .isPresent(),
+ "Schema-only evolution must miss the scan-plan cache");
+ }
+
@Test
public void testExpressionIsEquivalentTo() {