This is an automated email from the ASF dual-hosted git repository.

claudevdm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 2c21f02ce61 AddFiles: Parse partition keys in filepath for requirement 
check (#40691)
2c21f02ce61 is described below

commit 2c21f02ce6154dc01093433af682ddc78c1f7e49
Author: claudevdm <[email protected]>
AuthorDate: Fri Oct 23 17:24:23 2026 -0400

    AddFiles: Parse partition keys in filepath for requirement check (#40691)
    
    * initial
    
    * revert-doc
    
    * trigger tests
    
    * comments
---
 .../IO_Iceberg_Integration_Tests.json              |   2 +-
 .../org/apache/beam/sdk/io/iceberg/AddFiles.java   |  49 +++++++---
 .../sdk/io/iceberg/CollectDistinctSchemas.java     |  94 +++++++++++++-----
 .../apache/beam/sdk/io/iceberg/PartitionPaths.java |  91 ++++++++++++++++++
 .../beam/sdk/io/iceberg/ReadFooterSchema.java      |  25 ++++-
 .../apache/beam/sdk/io/iceberg/SchemaDelta.java    |  40 ++++++--
 .../org/apache/beam/sdk/io/iceberg/SchemaPlan.java |   5 +-
 .../org/apache/beam/sdk/io/iceberg/AddFilesIT.java |  98 +++++++++++++++++++
 .../apache/beam/sdk/io/iceberg/AddFilesTest.java   | 107 +++++++++++++++++++++
 .../sdk/io/iceberg/CollectDistinctSchemasTest.java |  27 ++++++
 .../beam/sdk/io/iceberg/CommitSchemaUnionTest.java |  57 +++++++++++
 .../beam/sdk/io/iceberg/PartitionPathsTest.java    | 101 +++++++++++++++++++
 .../beam/sdk/io/iceberg/ReadFooterSchemaTest.java  |  58 +++++++++++
 .../beam/sdk/io/iceberg/SchemaDeltaTest.java       |  12 +++
 14 files changed, 721 insertions(+), 45 deletions(-)

diff --git a/.github/trigger_files/IO_Iceberg_Integration_Tests.json 
b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
index b73af5e61a4..7ab7bcd9a9c 100644
--- a/.github/trigger_files/IO_Iceberg_Integration_Tests.json
+++ b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
@@ -1,4 +1,4 @@
 {
     "comment": "Modify this file in a trivial way to cause this test suite to 
run.",
-    "modification": 1
+    "modification": 2
 }
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
index c72479aa39b..00cf77af1dc 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
@@ -211,7 +211,8 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
 
   /**
    * @param locationPrefix when set on a partitioned table, the partition is 
read from the path
-   *     after this prefix instead of from the file's column stats
+   *     after this prefix instead of from the file's column stats. A table 
partitioned by {@code
+   *     dt} then accepts files under {@code dt=2024-01-15/} that have no 
{@code dt} column
    * @param partitionFields partition spec applied when the table is created 
by this transform
    * @param sortFields sort order applied when the table is created by this 
transform
    * @param tableProps table properties applied when the table is created by 
this transform
@@ -343,7 +344,7 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
   private PCollection<List<CollectDistinctSchemas.SchemaGroup>> 
distinctSchemas(
       PCollection<String> windowed) {
     return windowed
-        .apply("ReadFooterSchema", ParDo.of(new ReadFooterSchema(evolution)))
+        .apply("ReadFooterSchema", ParDo.of(new ReadFooterSchema(evolution, 
locationPrefix)))
         .setCoder(CollectDistinctSchemas.groupCoder())
         .apply(
             "CollectDistinctSchemas",
@@ -811,11 +812,26 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
       } catch (Exception e) {
         return Verdict.error(UNREADABLE_SCHEMA_ERROR + errorMessage(e));
       }
-      @Nullable String uncovered = uncoveredReason(fileSchema);
+      Set<Integer> identityPartitionColumnsFromPath = 
identityPartitionColumnsFromPath(filePath);
+      @Nullable String uncovered = uncoveredReason(fileSchema, 
identityPartitionColumnsFromPath);
       if (uncovered != null) {
         return Verdict.error(uncovered);
       }
-      return checkPins(filePath, fileSchema, parquetFooter);
+      return checkPins(filePath, fileSchema, parquetFooter, 
identityPartitionColumnsFromPath);
+    }
+
+    /**
+     * Identity partition columns this file may lack because its path gives 
them a value: {@code dt}
+     * for a file under {@code dt=2024-01-15/} when the table is partitioned 
by {@code dt}. Empty
+     * without a prefix.
+     */
+    private Set<Integer> identityPartitionColumnsFromPath(String filePath) {
+      Table table = checkStateNotNull(this.table);
+      if (Strings.isNullOrEmpty(prefix) || table.spec().isUnpartitioned()) {
+        return Collections.emptySet();
+      }
+      return PartitionPaths.identityColumnsFilledByKeys(
+          table.spec(), PartitionPaths.partitionKeysInPath(filePath, prefix));
     }
 
     /**
@@ -823,9 +839,10 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
      * covers every file. If not, refresh once per bundle (a commit may have 
landed since the table
      * was cached) and report the remaining delta. Never changes the schema.
      */
-    private @Nullable String uncoveredReason(org.apache.iceberg.Schema 
fileSchema) {
+    private @Nullable String uncoveredReason(
+        org.apache.iceberg.Schema fileSchema, Set<Integer> 
identityPartitionColumnsFromPath) {
       Table table = checkStateNotNull(this.table);
-      SchemaDelta delta = SchemaDelta.classify(table, fileSchema);
+      SchemaDelta delta = SchemaDelta.classify(table, fileSchema, 
identityPartitionColumnsFromPath);
       if (delta.isEmpty()) {
         return null;
       }
@@ -836,7 +853,7 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
           table.refresh();
         }
       }
-      delta = SchemaDelta.classify(table, fileSchema);
+      delta = SchemaDelta.classify(table, fileSchema, 
identityPartitionColumnsFromPath);
       if (delta.isEmpty()) {
         return null;
       }
@@ -857,14 +874,23 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
      * goes on, so a pin the footer does count nulls for still fails the file.
      */
     private Verdict checkPins(
-        String filePath, org.apache.iceberg.Schema fileSchema, ParquetMetadata 
footer) {
+        String filePath,
+        org.apache.iceberg.Schema fileSchema,
+        ParquetMetadata footer,
+        Set<Integer> identityPartitionColumnsFromPath) {
       Table table = checkStateNotNull(this.table);
       List<String> unproven = new ArrayList<>();
       for (String pinned : evolution.getRequiredColumns()) {
-        if (table.schema().findField(pinned) == null) {
+        org.apache.iceberg.types.Types.@Nullable NestedField tableField =
+            table.schema().findField(pinned);
+        if (tableField == null) {
           continue;
         }
         if (fileSchema.findField(pinned) == null) {
+          if (identityPartitionColumnsFromPath.contains(tableField.fieldId())) 
{
+            // the path gives it a value, e.g. dt=2024-01-15, so it is never 
null
+            continue;
+          }
           return Verdict.error(PINNED_COLUMN_ERROR + pinned + " is absent from 
the file");
         }
         @Nullable Long nulls = FileSchemas.nullCount(footer, fileSchema, 
pinned);
@@ -969,10 +995,7 @@ public class AddFiles extends 
PTransform<PCollection<String>, PCollectionRowTupl
       if (checkStateNotNull(table).spec().isUnpartitioned()) {
         return "";
       }
-      String partitionPath = 
filePath.substring(checkStateNotNull(prefix).length());
-      int lastSlashIndex = partitionPath.lastIndexOf('/');
-
-      return lastSlashIndex > 0 ? partitionPath.substring(0, lastSlashIndex) : 
"";
+      return PartitionPaths.relative(filePath, checkStateNotNull(prefix));
     }
 
     /**
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CollectDistinctSchemas.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CollectDistinctSchemas.java
index 5c36967cfeb..8e11bd1ed3e 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CollectDistinctSchemas.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CollectDistinctSchemas.java
@@ -22,6 +22,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -47,9 +48,10 @@ import org.checkerframework.checker.nullness.qual.Nullable;
 /**
  * One output entry per distinct schema: its file count and the columns EVERY 
file carrying it
  * proved free of nulls; one file with a null in "name" forces "name" to 
relax, however many clean
- * files sit next to it. Entries come out most common first (ties broken by 
the JSON text) because
- * the commit side applies schemas in that order and the most common schema 
should win a conflict.
- * Schemas are compared as strings, so inputs must already be canonical.
+ * files sit next to it. Partition keys in path are kept the same way: {@code 
dt} stays only if
+ * every file sits under a {@code dt=...} folder. Entries come out most common 
first (ties broken by
+ * the JSON text) because the commit side applies schemas in that order and 
the most common schema
+ * should win a conflict. Schemas are compared as strings, so inputs must 
already be canonical.
  */
 class CollectDistinctSchemas
     extends Combine.CombineFn<
@@ -61,10 +63,12 @@ class CollectDistinctSchemas
   static final class Group {
     long files;
     TreeSet<String> nullFreeColumns;
+    TreeSet<String> partitionKeysInPath;
 
-    Group(long files, TreeSet<String> nullFreeColumns) {
+    Group(long files, TreeSet<String> nullFreeColumns, TreeSet<String> 
partitionKeysInPath) {
       this.files = files;
       this.nullFreeColumns = nullFreeColumns;
+      this.partitionKeysInPath = partitionKeysInPath;
     }
 
     @Override
@@ -73,12 +77,14 @@ class CollectDistinctSchemas
         return false;
       }
       Group that = (Group) other;
-      return files == that.files && 
nullFreeColumns.equals(that.nullFreeColumns);
+      return files == that.files
+          && nullFreeColumns.equals(that.nullFreeColumns)
+          && partitionKeysInPath.equals(that.partitionKeysInPath);
     }
 
     @Override
     public int hashCode() {
-      return Objects.hash(files, nullFreeColumns);
+      return Objects.hash(files, nullFreeColumns, partitionKeysInPath);
     }
   }
 
@@ -92,7 +98,20 @@ class CollectDistinctSchemas
     private static @MonotonicNonNull SchemaCoder<SchemaGroup> coder;
 
     static SchemaGroup of(String schemaJson, long files, List<String> 
nullFreeColumns) {
-      return new AutoValue_CollectDistinctSchemas_SchemaGroup(schemaJson, 
files, nullFreeColumns);
+      return of(schemaJson, files, nullFreeColumns, Collections.emptyList());
+    }
+
+    static SchemaGroup of(
+        String schemaJson,
+        long files,
+        List<String> nullFreeColumns,
+        List<String> partitionKeysInPath) {
+      return new AutoValue_CollectDistinctSchemas_SchemaGroup(
+          schemaJson, files, nullFreeColumns, partitionKeysInPath);
+    }
+
+    SchemaGroup withPartitionKeysInPath(List<String> partitionKeysInPath) {
+      return of(getSchemaJson(), getFiles(), getNullFreeColumns(), 
partitionKeysInPath);
     }
 
     static SchemaCoder<SchemaGroup> getCoder() {
@@ -115,11 +134,21 @@ class CollectDistinctSchemas
     @SchemaFieldNumber("2")
     abstract List<String> getNullFreeColumns();
 
+    /**
+     * Folder keys every file of the group has a value for, e.g. {@code [dt]} 
when every file sits
+     * under a {@code dt=...} folder. A table partitioned by {@code dt} then 
needs no {@code dt}
+     * column in these files.
+     */
+    @SchemaFieldNumber("3")
+    abstract List<String> getPartitionKeysInPath();
+
     @Override
     public final String toString() {
       return getFiles()
           + " file(s), null-free in "
           + getNullFreeColumns()
+          + ", partition keys in path "
+          + getPartitionKeysInPath()
           + ", schema "
           + getSchemaJson();
     }
@@ -132,7 +161,12 @@ class CollectDistinctSchemas
 
   @Override
   public Map<String, Group> addInput(Map<String, Group> accumulator, 
SchemaGroup file) {
-    add(accumulator, file.getSchemaJson(), file.getFiles(), 
file.getNullFreeColumns());
+    add(
+        accumulator,
+        file.getSchemaJson(),
+        file.getFiles(),
+        file.getNullFreeColumns(),
+        file.getPartitionKeysInPath());
     return accumulator;
   }
 
@@ -141,7 +175,8 @@ class CollectDistinctSchemas
     Map<String, Group> merged = createAccumulator();
     for (Map<String, Group> accumulator : accumulators) {
       for (Map.Entry<String, Group> entry : accumulator.entrySet()) {
-        add(merged, entry.getKey(), entry.getValue().files, 
entry.getValue().nullFreeColumns);
+        Group group = entry.getValue();
+        add(merged, entry.getKey(), group.files, group.nullFreeColumns, 
group.partitionKeysInPath);
       }
     }
     return merged;
@@ -155,7 +190,8 @@ class CollectDistinctSchemas
           SchemaGroup.of(
               entry.getKey(),
               entry.getValue().files,
-              new ArrayList<>(entry.getValue().nullFreeColumns)));
+              new ArrayList<>(entry.getValue().nullFreeColumns),
+              new ArrayList<>(entry.getValue().partitionKeysInPath)));
     }
     schemas.sort(
         (a, b) -> {
@@ -200,12 +236,15 @@ class CollectDistinctSchemas
     public void encode(Group value, OutputStream out) throws IOException {
       VarLongCoder.of().encode(value.files, out);
       COLUMNS_CODER.encode(new ArrayList<>(value.nullFreeColumns), out);
+      COLUMNS_CODER.encode(new ArrayList<>(value.partitionKeysInPath), out);
     }
 
     @Override
     public Group decode(InputStream in) throws IOException {
       long files = VarLongCoder.of().decode(in);
-      return new Group(files, new TreeSet<>(COLUMNS_CODER.decode(in)));
+      TreeSet<String> nullFreeColumns = new 
TreeSet<>(COLUMNS_CODER.decode(in));
+      TreeSet<String> partitionKeysInPath = new 
TreeSet<>(COLUMNS_CODER.decode(in));
+      return new Group(files, nullFreeColumns, partitionKeysInPath);
     }
   }
 
@@ -213,23 +252,34 @@ class CollectDistinctSchemas
       Map<String, Group> accumulator,
       String schemaJson,
       long files,
-      Iterable<String> nullFreeColumns) {
+      Iterable<String> nullFreeColumns,
+      Iterable<String> partitionKeysInPath) {
     Group existing = accumulator.get(schemaJson);
     if (existing == null) {
-      TreeSet<String> copy = new TreeSet<>();
-      for (String column : nullFreeColumns) {
-        copy.add(column);
-      }
-      accumulator.put(schemaJson, new Group(files, copy));
+      accumulator.put(
+          schemaJson, new Group(files, copy(nullFreeColumns), 
copy(partitionKeysInPath)));
       return;
     }
     existing.files += files;
-    TreeSet<String> stillNullFree = new TreeSet<>();
-    for (String column : nullFreeColumns) {
-      if (existing.nullFreeColumns.contains(column)) {
-        stillNullFree.add(column);
+    existing.nullFreeColumns = intersect(existing.nullFreeColumns, 
nullFreeColumns);
+    existing.partitionKeysInPath = intersect(existing.partitionKeysInPath, 
partitionKeysInPath);
+  }
+
+  private static TreeSet<String> copy(Iterable<String> values) {
+    TreeSet<String> copy = new TreeSet<>();
+    for (String value : values) {
+      copy.add(value);
+    }
+    return copy;
+  }
+
+  private static TreeSet<String> intersect(TreeSet<String> kept, 
Iterable<String> values) {
+    TreeSet<String> stillKept = new TreeSet<>();
+    for (String value : values) {
+      if (kept.contains(value)) {
+        stillKept.add(value);
       }
     }
-    existing.nullFreeColumns = stillNullFree;
+    return stillKept;
   }
 }
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/PartitionPaths.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/PartitionPaths.java
new file mode 100644
index 00000000000..76dcf612f20
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/PartitionPaths.java
@@ -0,0 +1,91 @@
+/*
+ * 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 java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.TreeSet;
+import org.apache.iceberg.PartitionField;
+import org.apache.iceberg.PartitionSpec;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Partition values in a file's path. With prefix {@code gs://lake/orders/}, 
the file {@code
+ * gs://lake/orders/dt=2024-01-15/f.parquet} has {@code dt = 2024-01-15}. If 
the table is
+ * partitioned by {@code dt} as is (not by a transform such as {@code 
bucket}), readers take {@code
+ * dt} from that value, so the file needs no {@code dt} column.
+ */
+final class PartitionPaths {
+  /**
+   * Hive writes {@code dt=__HIVE_DEFAULT_PARTITION__} when dt is null; 
Iceberg reads it as null.
+   */
+  static final String HIVE_NULL = "__HIVE_DEFAULT_PARTITION__";
+
+  private PartitionPaths() {}
+
+  /**
+   * With prefix {@code gs://lake/orders/}: {@code 
gs://lake/orders/dt=1/region=EU/f.parquet} gives
+   * {@code dt=1/region=EU}, and {@code gs://lake/orders/f.parquet} gives "".
+   */
+  static String relative(String filePath, String prefix) {
+    String partitionPath = filePath.substring(prefix.length());
+    int lastSlashIndex = partitionPath.lastIndexOf('/');
+    return lastSlashIndex > 0 ? partitionPath.substring(0, lastSlashIndex) : 
"";
+  }
+
+  /**
+   * The partition keys the file's folders give a value: {@code 
dt=1/region=EU/f.parquet} gives
+   * {@code [dt, region]}, {@code 
dt=__HIVE_DEFAULT_PARTITION__/region=EU/f.parquet} gives {@code
+   * [region]}, and {@code 2024/f.parquet} gives none. Also none without a 
prefix or for a file
+   * outside it. Needs no table, unlike Iceberg's parser, so the pre-pass can 
use it.
+   */
+  static Set<String> partitionKeysInPath(String filePath, @Nullable String 
prefix) {
+    Set<String> keys = new TreeSet<>();
+    if (prefix == null || prefix.isEmpty() || !filePath.startsWith(prefix)) {
+      return keys;
+    }
+    String relative = relative(filePath, prefix);
+    if (relative.isEmpty()) {
+      return keys;
+    }
+    for (String folder : relative.split("/", -1)) {
+      String[] parts = folder.split("=", 2);
+      if (parts.length == 2 && !parts[0].isEmpty() && 
!HIVE_NULL.equals(parts[1])) {
+        keys.add(parts[0]);
+      }
+    }
+    return keys;
+  }
+
+  /**
+   * Ids of the columns that identity partitions named by these keys fill: 
{@code dt} for key {@code
+   * dt} in a table partitioned by {@code dt}. Other transforms fill nothing: 
a file under {@code
+   * id_bucket=3/} in a table partitioned by {@code bucket(id, 4)} is in 
bucket 3, which does not
+   * say what its {@code id} is.
+   */
+  static Set<Integer> identityColumnsFilledByKeys(PartitionSpec spec, 
Collection<String> keys) {
+    Set<Integer> ids = new HashSet<>();
+    for (PartitionField field : spec.fields()) {
+      if (field.transform().isIdentity() && keys.contains(field.name())) {
+        ids.add(field.sourceId());
+      }
+    }
+    return ids;
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java
index f111ed4b855..37310b8d7f7 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java
@@ -20,7 +20,9 @@ package org.apache.beam.sdk.io.iceberg;
 import static org.apache.beam.sdk.metrics.Metrics.counter;
 import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.List;
 import java.util.concurrent.Callable;
 import org.apache.beam.sdk.metrics.Counter;
 import org.apache.beam.sdk.transforms.DoFn;
@@ -61,16 +63,31 @@ class ReadFooterSchema extends DoFn<String, 
CollectDistinctSchemas.SchemaGroup>
   static final String UNCHECKED_FORMAT_KEY = "unchecked";
 
   private final SchemaEvolutionConfig config;
+  private final @Nullable String locationPrefix;
   private final int threadPoolSize;
   private final int maxInFlightTasks;
   private transient @MonotonicNonNull BoundedAsyncTasks<ReadResult> tasks;
 
   ReadFooterSchema(SchemaEvolutionConfig config) {
-    this(config, DEFAULT_THREAD_POOL_SIZE, DEFAULT_MAX_IN_FLIGHT_TASKS);
+    this(config, null);
+  }
+
+  /** {@code locationPrefix}: where the {@code key=value} folders start, e.g. 
gs://lake/orders/. */
+  ReadFooterSchema(SchemaEvolutionConfig config, @Nullable String 
locationPrefix) {
+    this(config, locationPrefix, DEFAULT_THREAD_POOL_SIZE, 
DEFAULT_MAX_IN_FLIGHT_TASKS);
   }
 
   ReadFooterSchema(SchemaEvolutionConfig config, int threadPoolSize, int 
maxInFlightTasks) {
+    this(config, null, threadPoolSize, maxInFlightTasks);
+  }
+
+  ReadFooterSchema(
+      SchemaEvolutionConfig config,
+      @Nullable String locationPrefix,
+      int threadPoolSize,
+      int maxInFlightTasks) {
     this.config = config;
+    this.locationPrefix = locationPrefix;
     this.threadPoolSize = threadPoolSize;
     this.maxInFlightTasks = maxInFlightTasks;
   }
@@ -186,7 +203,11 @@ class ReadFooterSchema extends DoFn<String, 
CollectDistinctSchemas.SchemaGroup>
         }
       }
       try {
-        return new ReadResult(FileSchemas.schemaGroup(footer), false, 
timestamp, window, paneInfo);
+        List<String> partitionKeysInPath =
+            new ArrayList<>(PartitionPaths.partitionKeysInPath(filePath, 
locationPrefix));
+        CollectDistinctSchemas.SchemaGroup group =
+            
FileSchemas.schemaGroup(footer).withPartitionKeysInPath(partitionKeysInPath);
+        return new ReadResult(group, false, timestamp, window, paneInfo);
       } catch (Exception e) {
         return unconvertibleSchema(filePath, e, timestamp, window, paneInfo);
       }
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaDelta.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaDelta.java
index 743558e68f3..cd599b74680 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaDelta.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaDelta.java
@@ -60,7 +60,12 @@ final class SchemaDelta {
    * (dotted, empty, case-colliding) come back as conflicts without attempting 
the union.
    */
   static SchemaDelta classify(Table table, Schema fileSchema) {
-    return classify(table, table.schema(), fileSchema);
+    return classify(table, table.schema(), fileSchema, Collections.emptySet());
+  }
+
+  /** As {@link #classify(Table, Schema, Schema, Set)}, against the table's 
current schema. */
+  static SchemaDelta classify(Table table, Schema fileSchema, Set<Integer> 
filledOutsideFile) {
+    return classify(table, table.schema(), fileSchema, filledOutsideFile);
   }
 
   /**
@@ -68,6 +73,17 @@ final class SchemaDelta {
    * is applied to the table's current metadata, which the caller keeps equal 
to the snapshot.
    */
   static SchemaDelta classify(Table table, Schema before, Schema fileSchema) {
+    return classify(table, before, fileSchema, Collections.emptySet());
+  }
+
+  /**
+   * {@code filledOutsideFile}: ids of required table columns whose values 
come from somewhere other
+   * than the file and are never null, so the file may lack them without the 
column becoming
+   * optional. For example {@code dt} for a file under {@code dt=2024-01-15/} 
in a table partitioned
+   * by {@code dt}.
+   */
+  static SchemaDelta classify(
+      Table table, Schema before, Schema fileSchema, Set<Integer> 
filledOutsideFile) {
     if (before.sameSchema(fileSchema)) {
       return new SchemaDelta(Collections.emptyList());
     }
@@ -81,7 +97,7 @@ final class SchemaDelta {
     }
 
     List<SchemaChange> absent = new ArrayList<>();
-    findAbsentRequired(before.asStruct(), fileSchema.asStruct(), "", absent);
+    findAbsentRequired(before.asStruct(), fileSchema.asStruct(), "", 
filledOutsideFile, absent);
     Schema merged;
     try {
       // The absent-path relaxations are applied here too, so anything Iceberg 
refuses (an
@@ -114,12 +130,13 @@ final class SchemaDelta {
       Types.StructType tableStruct,
       Types.StructType fileStruct,
       String prefix,
+      Set<Integer> filledOutsideFile,
       List<SchemaChange> changes) {
     for (Types.NestedField field : tableStruct.fields()) {
       String rawPath = prefix + field.name();
       Types.NestedField fileField = fileStruct.field(field.name());
       if (fileField == null) {
-        if (field.isRequired()) {
+        if (field.isRequired() && 
!filledOutsideFile.contains(field.fieldId())) {
           changes.add(
               new SchemaChange(
                   SchemaChange.Kind.FIELD_RELAXATION,
@@ -132,25 +149,36 @@ final class SchemaDelta {
         }
         continue;
       }
-      findAbsentRequiredInType(field.type(), fileField.type(), rawPath, 
changes);
+      findAbsentRequiredInType(field.type(), fileField.type(), rawPath, 
filledOutsideFile, changes);
     }
   }
 
   private static void findAbsentRequiredInType(
-      Type tableType, Type fileType, String rawPath, List<SchemaChange> 
changes) {
+      Type tableType,
+      Type fileType,
+      String rawPath,
+      Set<Integer> filledOutsideFile,
+      List<SchemaChange> changes) {
     if (tableType.isStructType() && fileType.isStructType()) {
-      findAbsentRequired(tableType.asStructType(), fileType.asStructType(), 
rawPath + ".", changes);
+      findAbsentRequired(
+          tableType.asStructType(),
+          fileType.asStructType(),
+          rawPath + ".",
+          filledOutsideFile,
+          changes);
     } else if (tableType.isListType() && fileType.isListType()) {
       findAbsentRequiredInType(
           tableType.asListType().elementType(),
           fileType.asListType().elementType(),
           rawPath + ".element",
+          filledOutsideFile,
           changes);
     } else if (tableType.isMapType() && fileType.isMapType()) {
       findAbsentRequiredInType(
           tableType.asMapType().valueType(),
           fileType.asMapType().valueType(),
           rawPath + ".value",
+          filledOutsideFile,
           changes);
     }
   }
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaPlan.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaPlan.java
index e9e72199c82..0c298e79542 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaPlan.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaPlan.java
@@ -346,7 +346,10 @@ abstract class SchemaPlan {
       Schema fileSchema =
           FileSchemas.markRequired(
               SchemaParser.fromJson(group.getSchemaJson()), 
group.getNullFreeColumns());
-      SchemaDelta delta = SchemaDelta.classify(table, base, fileSchema);
+      Set<Integer> identityPartitionColumnsFromPath =
+          PartitionPaths.identityColumnsFilledByKeys(table.spec(), 
group.getPartitionKeysInPath());
+      SchemaDelta delta =
+          SchemaDelta.classify(table, base, fileSchema, 
identityPartitionColumnsFromPath);
       if (delta.isEmpty()) {
         continue;
       }
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java
index 276ea4b0ee9..7595483d58a 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java
@@ -778,6 +778,104 @@ public class AddFilesIT {
     checkRecordsInDestinationTable(/* alsoCheckWithBigQueryIO= */ true);
   }
 
+  /**
+   * Files under dt=2024-01-15/ and dt=2024-01-16/ with no dt column, into a 
table with a required
+   * dt: dt stays required, and Beam and BigQuery read it from the folder 
names.
+   */
+  @Test
+  public void testBatchImportOfRequiredPartitionColumnFromThePath() throws 
IOException {
+    org.apache.iceberg.Schema withDt =
+        new org.apache.iceberg.Schema(
+            org.apache.iceberg.types.Types.NestedField.required(
+                1, "id", org.apache.iceberg.types.Types.LongType.get()),
+            org.apache.iceberg.types.Types.NestedField.required(
+                2, "name", org.apache.iceberg.types.Types.StringType.get()),
+            org.apache.iceberg.types.Types.NestedField.required(
+                3, "age", org.apache.iceberg.types.Types.IntegerType.get()),
+            org.apache.iceberg.types.Types.NestedField.required(
+                4, "dt", org.apache.iceberg.types.Types.StringType.get()));
+    catalog.createTable(
+        destTableId, withDt, 
PartitionSpec.builderFor(withDt).identity("dt").build());
+
+    String parquetDir = format("gs://%s/%s/", DATA_BUCKET, dirName);
+    String tempDir = format("gs://%s/%s-tmp/", DATA_BUCKET, dirName);
+    writeParquet(
+        TEST_ROWS.subList(0, 10), ROW_SCHEMA, parquetDir + "dt=2024-01-15/", 
tempDir + "a/");
+    writeParquet(
+        TEST_ROWS.subList(10, 20), ROW_SCHEMA, parquetDir + "dt=2024-01-16/", 
tempDir + "b/");
+    GcsUtil gcsUtil = 
TestPipeline.testingPipelineOptions().as(GcsOptions.class).getGcsUtil();
+    List<String> files = new ArrayList<>();
+    for (StorageObject object : gcsUtil.listObjects(DATA_BUCKET, dirName + 
"/", null).getItems()) {
+      files.add(format("gs://%s/%s", object.getBucket(), object.getName()));
+    }
+    assertEquals(20, files.size());
+
+    SchemaEvolutionConfig evolution =
+        SchemaEvolutionConfig.builder()
+            .setOptions(EnumSet.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION))
+            .build();
+    Pipeline p = Pipeline.create();
+    PCollectionRowTuple tuple =
+        p.apply(Create.of(files))
+            .apply(
+                new AddFiles(
+                    
IcebergCatalogConfig.builder().setCatalogProperties(LAKEHOUSE_PROPS).build(),
+                    namespace + "." + destTableName,
+                    parquetDir,
+                    null,
+                    null,
+                    TABLE_PROPS,
+                    null,
+                    null,
+                    evolution));
+    PAssert.that(tuple.get("errors")).empty();
+    p.run().waitUntilFinish();
+
+    Table destTable = catalog.loadTable(destTableId);
+    assertTrue("dt stays required", 
destTable.schema().findField("dt").isRequired());
+
+    List<String> expected = new ArrayList<>();
+    for (Row row : TEST_ROWS) {
+      String dt = checkStateNotNull(row.getInt64("id")) < 10 ? "2024-01-15" : 
"2024-01-16";
+      expected.add(canonicalRecord(row) + "|" + dt);
+    }
+    Pipeline s = Pipeline.create();
+    PCollection<String> icebergRows =
+        s.apply(
+                Managed.read(Managed.ICEBERG)
+                    .withConfig(
+                        ImmutableMap.of(
+                            "table",
+                            destTableId.toString(),
+                            "catalog_properties",
+                            LAKEHOUSE_PROPS)))
+            .getSinglePCollection()
+            .apply(
+                "canonicalize iceberg rows", 
MapElements.into(strings()).via(AddFilesIT::withDt));
+    PAssert.that(icebergRows).containsInAnyOrder(expected);
+    PCollection<String> bqRows =
+        s.apply(
+                "read with BigQueryIO",
+                Managed.read(Managed.BIGQUERY)
+                    .withConfig(
+                        ImmutableMap.of(
+                            "table",
+                            format(
+                                "%s.%s.%s.%s",
+                                PROJECT,
+                                LakehouseTestCatalog.CATALOG_ID,
+                                destTableId.namespace(),
+                                destTableId.name()))))
+            .getSinglePCollection()
+            .apply("canonicalize bq rows", 
MapElements.into(strings()).via(AddFilesIT::withDt));
+    PAssert.that(bqRows).containsInAnyOrder(expected);
+    s.run().waitUntilFinish();
+  }
+
+  private static String withDt(Row row) {
+    return canonicalRecord(row) + "|" + String.valueOf((Object) 
row.getValue("dt"));
+  }
+
   private static void writeParquet(List<Row> rows, Schema schema, String dir, 
String tempDir) {
     Pipeline q = Pipeline.create();
     org.apache.avro.Schema avroSchema = AvroUtils.toAvroSchema(schema);
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
index 578d18c539d..47aa8290f72 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
@@ -96,6 +96,7 @@ import org.apache.iceberg.Table;
 import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.IcebergGenerics;
 import org.apache.iceberg.data.Record;
 import org.apache.iceberg.data.parquet.GenericParquetReaders;
 import org.apache.iceberg.data.parquet.GenericParquetWriter;
@@ -1086,6 +1087,112 @@ public class AddFilesTest {
     return GenericRecord.create(icebergSchema).copy("id", id, "name", name, 
"age", age);
   }
 
+  // ---- partition column read from the path (dt=.../)
+
+  private static final Schema WITH_DT =
+      new Schema(
+          Types.NestedField.required(1, "id", Types.IntegerType.get()),
+          Types.NestedField.required(2, "name", Types.StringType.get()),
+          Types.NestedField.required(3, "age", Types.IntegerType.get()),
+          Types.NestedField.required(4, "dt", Types.StringType.get()));
+
+  private void createTablePartitionedByDt() {
+    catalog.createTable(tableId, WITH_DT, 
PartitionSpec.builderFor(WITH_DT).identity("dt").build());
+  }
+
+  /** Writes a file without a dt column to root/folder/, e.g. 
root/dt=2024-01-15/. */
+  private String writeUnder(String folder, int id) throws IOException {
+    new File(root + folder).mkdirs();
+    String file = root + folder + "/data" + id + ".parquet";
+    DataWriter<Record> writer = createWriter(file);
+    writer.write(record(id, "n" + id, id));
+    writer.close();
+    return file;
+  }
+
+  private AddFiles addFilesUnderRoot(SchemaEvolutionConfig config) {
+    return new AddFiles(
+        catalogConfig, tableId.toString(), root, null, null, null, null, null, 
config);
+  }
+
+  /** Files under dt=2024-01-15/ and dt=2024-01-16/ with no dt column; dt 
reads back from them. */
+  @Test
+  public void testRequiredPartitionColumnFromThePathNeedsNoRelaxation() throws 
Exception {
+    createTablePartitionedByDt();
+    String first = writeUnder("dt=2024-01-15", 1);
+    String second = writeUnder("dt=2024-01-16", 2);
+
+    PCollectionRowTuple output =
+        pipeline
+            .apply("Create Input", Create.of(first, second))
+            .apply(addFilesUnderRoot(ADDITIONS));
+    PAssert.that(output.get("errors")).empty();
+
+    pipeline.run().waitUntilFinish();
+
+    Table table = catalog.loadTable(tableId);
+    assertTrue("dt stays required", 
table.schema().findField("dt").isRequired());
+    List<String> rows = new ArrayList<>();
+    try (CloseableIterable<Record> records = 
IcebergGenerics.read(table).build()) {
+      for (Record record : records) {
+        rows.add(record.getField("id") + " " + record.getField("dt"));
+      }
+    }
+    Collections.sort(rows);
+    assertEquals(Arrays.asList("1 2024-01-15", "2 2024-01-16"), rows);
+  }
+
+  @Test
+  public void testPinnedPartitionColumnFromThePathIsSatisfied() throws 
Exception {
+    createTablePartitionedByDt();
+    String file = writeUnder("dt=2024-01-15", 1);
+    SchemaEvolutionConfig pinned =
+        SchemaEvolutionConfig.builder()
+            .setOptions(EnumSet.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION))
+            .setRequiredColumns(Collections.singleton("dt"))
+            .build();
+
+    PCollectionRowTuple output =
+        pipeline.apply("Create Input", 
Create.of(file)).apply(addFilesUnderRoot(pinned));
+    PAssert.that(output.get("errors")).empty();
+
+    pipeline.run().waitUntilFinish();
+
+    assertEquals(1, 
Iterables.size(catalog.loadTable(tableId).newScan().planFiles()));
+  }
+
+  /** A file directly under the prefix has no dt=... folder, so it alone is 
refused. */
+  @Test
+  public void testFileWithoutAPathValueForARequiredPartitionColumnIsRefused() 
throws Exception {
+    createTablePartitionedByDt();
+    String inFolder = writeUnder("dt=2024-01-15", 1);
+    String stray = root + "stray.parquet";
+    DataWriter<Record> writer = createWriter(stray);
+    writer.write(record(2, "n2", 2));
+    writer.close();
+    SchemaEvolutionConfig routed =
+        SchemaEvolutionConfig.builder()
+            .setOptions(EnumSet.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION))
+            .setIncompatibleSchemaHandling(
+                
SchemaEvolutionConfig.IncompatibleSchemaHandling.ROUTE_TO_ERRORS)
+            .build();
+
+    PCollectionRowTuple output =
+        pipeline.apply("Create Input", Create.of(inFolder, 
stray)).apply(addFilesUnderRoot(routed));
+    PAssert.that(output.get("errors"))
+        .satisfies(
+            rows -> {
+              assertEquals(stray, 
Iterables.getOnlyElement(rows).getString("file"));
+              return null;
+            });
+
+    pipeline.run().waitUntilFinish();
+
+    Table table = catalog.loadTable(tableId);
+    assertTrue("dt stays required", 
table.schema().findField("dt").isRequired());
+    assertEquals(1, Iterables.size(table.newScan().planFiles()));
+  }
+
   // ---- AddFiles with schema evolution
 
   private AddFiles addFiles(@Nullable SchemaEvolutionConfig config) {
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CollectDistinctSchemasTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CollectDistinctSchemasTest.java
index 7b4a22b2618..76f650758f6 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CollectDistinctSchemasTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CollectDistinctSchemasTest.java
@@ -130,6 +130,29 @@ public class CollectDistinctSchemasTest {
         out);
   }
 
+  /** One file under dt=.../region=EU/, one under dt=.../ only: the group 
keeps [dt]. */
+  @Test
+  public void testPartitionKeysInPathIntersect() {
+    List<SchemaGroup> out =
+        combine(
+            withPartitionKeysInPath(ID_NAME, Arrays.asList("dt", "region")),
+            withPartitionKeysInPath(ID_NAME, Arrays.asList("dt")),
+            withPartitionKeysInPath(NAME_ID, NONE));
+    assertEquals(
+        Arrays.asList(
+            SchemaGroup.of(ID_NAME, 2, NONE, Arrays.asList("dt")),
+            SchemaGroup.of(NAME_ID, 1, NONE, NONE)),
+        out);
+  }
+
+  @Test
+  public void testAccumulatorCoderRoundTripWithPartitionKeysInPath() throws 
Exception {
+    Coder<Map<String, Group>> coder = fn.getAccumulatorCoder(null, null);
+    Map<String, Group> accumulator =
+        fn.addInput(fn.createAccumulator(), withPartitionKeysInPath(ID_NAME, 
Arrays.asList("dt")));
+    CoderProperties.coderDecodeEncodeEqual(coder, accumulator);
+  }
+
   @Test
   public void testEmptyInput() {
     assertEquals(Arrays.asList(), combine());
@@ -182,6 +205,10 @@ public class CollectDistinctSchemasTest {
     return SchemaGroup.of(json, files, proven);
   }
 
+  private static SchemaGroup withPartitionKeysInPath(String json, List<String> 
keys) {
+    return SchemaGroup.of(json, 1, NONE, keys);
+  }
+
   private static String json(Schema schema) {
     return SchemaParser.toJson(schema);
   }
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnionTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnionTest.java
index 0c5f379a110..ce226f37f9f 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnionTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnionTest.java
@@ -41,6 +41,7 @@ import 
org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.IncompatibleSchemaHa
 import org.apache.beam.sdk.util.FastNanoClockAndSleeper;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.SchemaParser;
 import org.apache.iceberg.Table;
@@ -117,6 +118,24 @@ public class CommitSchemaUnionTest {
     return CollectDistinctSchemas.SchemaGroup.of(json(schema), count, 
nullFreeColumns);
   }
 
+  /** {@code count} files, each under a folder per key, e.g. dt=2024-01-15/ 
for key dt. */
+  private static CollectDistinctSchemas.SchemaGroup filesUnder(
+      Schema schema, long count, String... partitionKeysInPath) {
+    return CollectDistinctSchemas.SchemaGroup.of(
+        json(schema), count, Collections.emptyList(), 
Arrays.asList(partitionKeysInPath));
+  }
+
+  private static final Schema PARTITIONED =
+      new Schema(
+          required(1, "id", Types.LongType.get()), required(2, "dt", 
Types.StringType.get()));
+
+  private TableIdentifier partitionedByDt() {
+    TableIdentifier id = missing();
+    catalog.createTable(
+        id, PARTITIONED, 
PartitionSpec.builderFor(PARTITIONED).identity("dt").build());
+    return id;
+  }
+
   private long commit(
       SchemaEvolutionConfig config,
       IncompatibleSchemaHandling handling,
@@ -744,6 +763,44 @@ public class CommitSchemaUnionTest {
 
   // ---- create path
 
+  /** Files under dt=.../ folders, with no dt column: required dt stays 
required. */
+  @Test
+  public void testRequiredPartitionColumnSuppliedByThePathIsNotRelaxed() {
+    TableIdentifier id = partitionedByDt();
+    Schema file = new Schema(required(1, "id", Types.LongType.get()));
+    int before = catalog.loadTable(id).schema().schemaId();
+
+    long schemaId =
+        commitTo(
+            id,
+            ADDITION_ONLY,
+            IncompatibleSchemaHandling.FAIL_PIPELINE,
+            NO_CREATION,
+            filesUnder(file, 3, "dt"));
+
+    assertEquals(before, schemaId);
+    assertTrue(catalog.loadTable(id).schema().findField("dt").isRequired());
+  }
+
+  /** Files with no dt=... folder would read dt as null, so dt must still 
become optional. */
+  @Test
+  public void 
testRequiredPartitionColumnWithoutAPathValueStillNeedsRelaxation() {
+    TableIdentifier id = partitionedByDt();
+    Schema file = new Schema(required(1, "id", Types.LongType.get()));
+
+    assertThrows(
+        IncompatibleSchemaException.class,
+        () ->
+            commitTo(
+                id,
+                ADDITION_ONLY,
+                IncompatibleSchemaHandling.FAIL_PIPELINE,
+                NO_CREATION,
+                filesUnder(file, 3)));
+    commitTo(id, ALL, IncompatibleSchemaHandling.FAIL_PIPELINE, NO_CREATION, 
filesUnder(file, 3));
+    assertTrue(catalog.loadTable(id).schema().findField("dt").isOptional());
+  }
+
   private TableIdentifier missing() {
     return TableIdentifier.of("default", testName.getMethodName() + "_new");
   }
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/PartitionPathsTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/PartitionPathsTest.java
new file mode 100644
index 00000000000..e5883ee9fbe
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/PartitionPathsTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.iceberg.types.Types.NestedField.required;
+import static org.junit.Assert.assertEquals;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.types.Types;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class PartitionPathsTest {
+  private static final String PREFIX = "gs://lake/orders/";
+
+  private static final Schema SCHEMA =
+      new Schema(
+          required(1, "id", Types.LongType.get()),
+          required(2, "dt", Types.StringType.get()),
+          required(3, "region", Types.StringType.get()));
+
+  @Test
+  public void testRelativeIsTheFoldersAfterThePrefix() {
+    assertEquals(
+        "dt=2024-01-15/region=EU",
+        PartitionPaths.relative(PREFIX + "dt=2024-01-15/region=EU/f.parquet", 
PREFIX));
+    assertEquals("", PartitionPaths.relative(PREFIX + "f.parquet", PREFIX));
+  }
+
+  @Test
+  public void testPartitionKeysInPath() {
+    assertEquals(
+        set("dt", "region"),
+        PartitionPaths.partitionKeysInPath(PREFIX + 
"dt=2024-01-15/region=EU/f.parquet", PREFIX));
+    assertEquals(
+        "a value may hold '='",
+        set("dt"),
+        PartitionPaths.partitionKeysInPath(PREFIX + "dt=a=b/f.parquet", 
PREFIX));
+    assertEquals(
+        "plain folders and empty keys give nothing",
+        set("dt"),
+        PartitionPaths.partitionKeysInPath(PREFIX + "2024/=x/dt=1/f.parquet", 
PREFIX));
+  }
+
+  @Test
+  public void testHiveNullGivesNoValue() {
+    assertEquals(
+        set("region"),
+        PartitionPaths.partitionKeysInPath(
+            PREFIX + "dt=__HIVE_DEFAULT_PARTITION__/region=EU/f.parquet", 
PREFIX));
+  }
+
+  @Test
+  public void testNoKeysWithoutAPrefixOrOutsideIt() {
+    assertEquals(set(), PartitionPaths.partitionKeysInPath(PREFIX + 
"dt=1/f.parquet", null));
+    assertEquals(set(), PartitionPaths.partitionKeysInPath(PREFIX + 
"dt=1/f.parquet", ""));
+    assertEquals(set(), 
PartitionPaths.partitionKeysInPath("gs://other/dt=1/f.parquet", PREFIX));
+    assertEquals(set(), PartitionPaths.partitionKeysInPath(PREFIX + 
"f.parquet", PREFIX));
+  }
+
+  @Test
+  public void testIdentityColumnsFilledByKeysIgnoresOtherTransforms() {
+    PartitionSpec spec =
+        PartitionSpec.builderFor(SCHEMA).identity("dt").bucket("region", 
4).build();
+    assertEquals(
+        set(2),
+        PartitionPaths.identityColumnsFilledByKeys(
+            spec, Arrays.asList("dt", "region_bucket", "unknown")));
+    assertEquals(set(), PartitionPaths.identityColumnsFilledByKeys(spec, 
Collections.emptyList()));
+    assertEquals(
+        set(),
+        PartitionPaths.identityColumnsFilledByKeys(
+            PartitionSpec.unpartitioned(), Arrays.asList("dt")));
+  }
+
+  @SafeVarargs
+  private static <T> HashSet<T> set(T... values) {
+    return new HashSet<>(Arrays.asList(values));
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java
index a6c178a520f..ea5a6e76868 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java
@@ -28,6 +28,7 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import org.apache.avro.SchemaBuilder;
 import org.apache.beam.sdk.PipelineResult;
@@ -183,6 +184,43 @@ public class ReadFooterSchemaTest {
     pipeline.run();
   }
 
+  @Test
+  public void testRecordsPartitionKeysInPath() throws IOException {
+    String prefix = temporaryFolder.getRoot().getAbsolutePath() + "/";
+    new File(prefix + "dt=2024-01-15/region=EU").mkdirs();
+    String file =
+        writeParquet(
+            "dt=2024-01-15/region=EU/f.parquet", FLAT_SCHEMA, 
record(FLAT_SCHEMA, "id", 1));
+
+    assertPartitionKeysInPath(runWithPrefix(prefix, file), Arrays.asList("dt", 
"region"));
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testHiveNullFolderGivesNoPartitionKey() throws IOException {
+    String prefix = temporaryFolder.getRoot().getAbsolutePath() + "/";
+    new File(prefix + "dt=__HIVE_DEFAULT_PARTITION__").mkdirs();
+    String file =
+        writeParquet(
+            "dt=__HIVE_DEFAULT_PARTITION__/f.parquet", FLAT_SCHEMA, 
record(FLAT_SCHEMA, "id", 1));
+
+    assertPartitionKeysInPath(runWithPrefix(prefix, file), 
Collections.emptyList());
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testNoPartitionKeysWithoutAPrefix() throws IOException {
+    new File(temporaryFolder.getRoot(), "dt=2024-01-15").mkdirs();
+    String file =
+        writeParquet("dt=2024-01-15/f.parquet", FLAT_SCHEMA, 
record(FLAT_SCHEMA, "id", 1));
+
+    assertPartitionKeysInPath(run(file), Collections.emptyList());
+
+    pipeline.run();
+  }
+
   /**
    * The schema is emitted exactly as declared; which columns proved null-free 
travels beside it.
    */
@@ -309,6 +347,26 @@ public class ReadFooterSchemaTest {
         .setCoder(CollectDistinctSchemas.groupCoder());
   }
 
+  private PCollection<CollectDistinctSchemas.SchemaGroup> runWithPrefix(
+      String prefix, String... paths) {
+    return pipeline
+        .apply(Create.of(Arrays.asList(paths)))
+        .apply(ParDo.of(new ReadFooterSchema(SchemaEvolutionConfig.disabled(), 
prefix)))
+        .setCoder(CollectDistinctSchemas.groupCoder());
+  }
+
+  private static void assertPartitionKeysInPath(
+      PCollection<CollectDistinctSchemas.SchemaGroup> out, List<String> 
expected) {
+    PAssert.that(out)
+        .satisfies(
+            groups -> {
+              for (CollectDistinctSchemas.SchemaGroup group : groups) {
+                assertEquals(expected, group.getPartitionKeysInPath());
+              }
+              return null;
+            });
+  }
+
   /** Asserts the emitted declared schemas equal the canonical forms of {@code 
expected}. */
   private static void assertSchemas(
       PCollection<CollectDistinctSchemas.SchemaGroup> out, Schema... expected) 
{
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaDeltaTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaDeltaTest.java
index d3d4172f0f9..0832f618ff0 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaDeltaTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaDeltaTest.java
@@ -27,6 +27,7 @@ import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashSet;
 import org.apache.beam.sdk.io.iceberg.SchemaChange.Kind;
@@ -128,6 +129,17 @@ public class SchemaDeltaTest {
         delta.disallowedReason(pinned("id")));
   }
 
+  /** As for a file under id=7/ in a table partitioned by id: required id is 
not made optional. */
+  @Test
+  public void testAbsentRequiredColumnSuppliedByThePathIsNoChange() {
+    Schema file = new Schema(optional(1, "name", Types.StringType.get()));
+    assertEquals(Arrays.asList("id"), classify(file).absentRequiredPaths());
+    Table created = checkStateNotNull(table);
+    int id = created.schema().findField("id").fieldId();
+
+    assertTrue(SchemaDelta.classify(created, file, 
Collections.singleton(id)).isEmpty());
+  }
+
   @Test
   public void testAbsentNestedRequiredChildIsRelaxation() {
     Schema file =

Reply via email to