claudevdm commented on code in PR #40062: URL: https://github.com/apache/beam/pull/40062#discussion_r3983877045
########## sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaDelta.java: ########## @@ -0,0 +1,610 @@ +/* + * 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.beam.sdk.io.iceberg; + +import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull; +import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * What {@code unionByNameWith(fileSchema)} would change on a table, without changing it. Computed + * by diffing the union result against the table schema by field id: existing fields keep their ids + * and additions get fresh ones, so the diff is exact and independent of column order. + * + * <p>The union ignores table columns absent from the file, but every row of such a file reads null + * in them, so a required column absent from the file is also a relaxation. The commit side stages + * those explicitly via {@link #absentRequiredPaths()}. + */ +final class SchemaDelta { + + enum Kind { + FIELD_ADDITION(SchemaEvolutionOption.ALLOW_FIELD_ADDITION), + FIELD_RELAXATION(SchemaEvolutionOption.ALLOW_FIELD_RELAXATION), + TYPE_PROMOTION(SchemaEvolutionOption.ALLOW_TYPE_PROMOTION), + /** The union is impossible (for example string vs int); never allowed. */ + CONFLICT(null); + + final @Nullable SchemaEvolutionOption option; + + Kind(@Nullable SchemaEvolutionOption option) { + this.option = option; + } + + boolean allowedBy(SchemaEvolutionConfig config) { + return option != null && config.allows(option); + } + } + + private static final class Change { + final Kind kind; + + /** Unquoted column path for the config lookup; empty for conflicts without a field. */ + final String path; + + final String description; + + /** A relaxation because the column is absent from the file, not declared optional. */ + final boolean absent; + + Change(Kind kind, String path, String description) { + this(kind, path, description, false); + } + + Change(Kind kind, String path, String description, boolean absent) { + this.kind = kind; + this.path = path; + this.description = description; + this.absent = absent; + } + + boolean allowedBy(SchemaEvolutionConfig config) { + if (kind == Kind.FIELD_RELAXATION && forbiddingPin(config) != null) { + return false; + } + return kind.allowedBy(config); + } + + /** + * The pin that forbids relaxing this path: the path itself, or a pinned column beneath it. A + * null ancestor nulls the pinned leaf, so relaxing the ancestor only manufactures files that + * fail the pin check at registration. + */ + private @Nullable String forbiddingPin(SchemaEvolutionConfig config) { + if (config.isPinned(path)) { + return path; + } + for (String pin : config.getRequiredColumns()) { + if (pin.startsWith(path + ".")) { + return pin; + } + } + return null; + } + + String disallowedReason(SchemaEvolutionConfig config) { + @Nullable String pin = kind == Kind.FIELD_RELAXATION ? forbiddingPin(config) : null; + if (pin != null) { + if (pin.equals(path)) { + return description + " (pinned as required)"; + } + return description + " (ancestor of pinned column " + pin + ")"; + } + return description + " (needs " + kind.option + ")"; + } + } + + private final List<Change> changes; + + private SchemaDelta(List<Change> changes) { + this.changes = Collections.unmodifiableList(changes); + } + + static SchemaDelta classify(Table table, Schema fileSchema) { + Schema before = table.schema(); + if (before.sameSchema(fileSchema)) { + return new SchemaDelta(Collections.emptyList()); + } + + List<Change> nameConflicts = new ArrayList<>(); + findInvalidNames(fileSchema.asStruct(), "", nameConflicts); + findCaseCollisions(before.asStruct(), fileSchema.asStruct(), "", nameConflicts); + if (!nameConflicts.isEmpty()) { + return new SchemaDelta(nameConflicts); + } + + List<Change> absent = new ArrayList<>(); + findAbsentRequired(before.asStruct(), fileSchema.asStruct(), "", absent); + Schema merged; + try { + // The absent-path relaxations are applied here too, so anything Iceberg refuses (an + // identifier field, say) is classified as this file's conflict instead of surfacing + // mid-transaction under a cross-schema message. + UpdateSchema update = table.updateSchema().unionByNameWith(fileSchema); + for (Change change : absent) { + update = update.makeColumnOptional(change.path); + } + merged = update.apply(); + } catch (ValidationException | IllegalArgumentException e) { + // SchemaUpdate reports type conflicts through both exception types + return conflict(e.getClass().getSimpleName() + ": " + AddFiles.errorMessage(e)); + } + Map<String, Change> absentByPath = new HashMap<>(); + for (Change change : absent) { + absentByPath.put(change.path, change); + } + return diff(before, merged, absentByPath); + } + + /** + * File column names no table can absorb, checked at every level including structs the table does Review Comment: Iceberg accepts a literal a.b column through unionByNameWith. But icebergs addColumn rejects dots as "ambiguous name". So upstream laready treats creating them as user error that unionByNameWith can bypass. Empty names are rejected by iceberg at the top level. Empty nested names are silently added but I think that is not a valid use case and missing validation in iceberg? Case collisions are legal in iceberg file format, but it breaks reader implementations. Users can work around this with a column alias. So I think these are protecting the user against configuring something that might blow up without their knowledge. -- 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]
