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

jrmccluskey 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 a26ecfd4857 [#39723] Implement model for Iceberg side input cache 
(#39724)
a26ecfd4857 is described below

commit a26ecfd4857264ea121d0d41929b441d05400862
Author: Jack McCluskey <[email protected]>
AuthorDate: Thu Aug 20 12:40:22 2026 -0400

    [#39723] Implement model for Iceberg side input cache (#39724)
    
    * [#39723] Implement model for Iceberg side input cache
    
    * Move FileIO and EncryptionManager, address comments
---
 .../beam/sdk/io/iceberg/SerializableTableSpec.java | 382 +++++++++++++++++++++
 .../apache/beam/sdk/io/iceberg/SideInputTable.java | 368 ++++++++++++++++++++
 .../sdk/io/iceberg/SerializableTableSpecTest.java  | 348 +++++++++++++++++++
 .../beam/sdk/io/iceberg/SideInputTableTest.java    | 239 +++++++++++++
 4 files changed, 1337 insertions(+)

diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpec.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpec.java
new file mode 100644
index 00000000000..c6ee4a97699
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpec.java
@@ -0,0 +1,382 @@
+/*
+ * 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 com.google.auto.value.AutoValue;
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.beam.sdk.schemas.AutoValueSchema;
+import org.apache.beam.sdk.schemas.NoSuchSchemaException;
+import org.apache.beam.sdk.schemas.SchemaCoder;
+import org.apache.beam.sdk.schemas.SchemaRegistry;
+import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
+import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
+import org.apache.beam.sdk.schemas.annotations.SchemaIgnore;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.EncryptedKeyParser;
+import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.PartitionSpecParser;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SchemaParser;
+import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.SortOrderParser;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.encryption.EncryptedKey;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.FileIOParser;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A serializable, lightweight representation of an Iceberg {@link Table}'s 
declarative metadata.
+ *
+ * <p>Captures the table's schemas, partition specs, sort orders, location, 
properties, identifier,
+ * encrypted keys, and serialized {@link FileIO} configuration. Suitable for 
broadcasting across
+ * worker nodes via Beam's side-input mechanism.
+ */
+@DefaultSchema(AutoValueSchema.class)
+@AutoValue
+public abstract class SerializableTableSpec implements Serializable {
+
+  @SchemaFieldNumber("0")
+  public abstract String getTableIdentifierString();
+
+  @SchemaFieldNumber("1")
+  public abstract String getName();
+
+  @SchemaFieldNumber("2")
+  public abstract String getLocation();
+
+  @SchemaFieldNumber("3")
+  public abstract int getSchemaId();
+
+  @SchemaFieldNumber("4")
+  public abstract Map<Integer, String> getSchemasJson();
+
+  @SchemaFieldNumber("5")
+  public abstract int getSpecId();
+
+  @SchemaFieldNumber("6")
+  public abstract Map<Integer, String> getPartitionSpecsJson();
+
+  @SchemaFieldNumber("7")
+  public abstract int getOrderId();
+
+  @SchemaFieldNumber("8")
+  public abstract Map<Integer, String> getSortOrdersJson();
+
+  @SchemaFieldNumber("9")
+  public abstract Map<String, String> getProperties();
+
+  @SchemaFieldNumber("10")
+  public abstract String getFileIoJson();
+
+  @SchemaFieldNumber("11")
+  public abstract List<String> getEncryptedKeyJsons();
+
+  private transient volatile @MonotonicNonNull Map<Integer, Schema> 
cachedSchemas;
+  private transient volatile @MonotonicNonNull Map<Integer, PartitionSpec> 
cachedPartitionSpecs;
+  private transient volatile @MonotonicNonNull Map<Integer, SortOrder> 
cachedSortOrders;
+  private transient volatile @MonotonicNonNull TableIdentifier 
cachedTableIdentifier;
+  private transient volatile @MonotonicNonNull FileIO cachedFileIO;
+  private transient volatile @MonotonicNonNull List<EncryptedKey> 
cachedEncryptedKeys;
+
+  private static volatile @MonotonicNonNull SchemaCoder<SerializableTableSpec> 
cachedCoder;
+
+  @SchemaIgnore
+  public Map<Integer, Schema> getSchemas() {
+    Map<Integer, Schema> local = cachedSchemas;
+    if (local == null) {
+      synchronized (this) {
+        local = cachedSchemas;
+        if (local == null) {
+          ImmutableMap.Builder<Integer, Schema> builder = 
ImmutableMap.builder();
+          for (Map.Entry<Integer, String> entry : getSchemasJson().entrySet()) 
{
+            builder.put(entry.getKey(), 
SchemaParser.fromJson(entry.getValue()));
+          }
+          cachedSchemas = local = builder.build();
+        }
+      }
+    }
+    return local;
+  }
+
+  @SchemaIgnore
+  public Schema getSchema() {
+    Schema schema = getSchemas().get(getSchemaId());
+    if (schema == null) {
+      throw new IllegalStateException(
+          "Schema with id " + getSchemaId() + " not found in schemas map");
+    }
+    return schema;
+  }
+
+  @SchemaIgnore
+  public @Nullable Schema getSchema(int schemaId) {
+    return getSchemas().get(schemaId);
+  }
+
+  @SchemaIgnore
+  public Map<Integer, PartitionSpec> getPartitionSpecs() {
+    Map<Integer, PartitionSpec> local = cachedPartitionSpecs;
+    if (local == null) {
+      synchronized (this) {
+        local = cachedPartitionSpecs;
+        if (local == null) {
+          ImmutableMap.Builder<Integer, PartitionSpec> builder = 
ImmutableMap.builder();
+          for (Map.Entry<Integer, String> entry : 
getPartitionSpecsJson().entrySet()) {
+            builder.put(
+                entry.getKey(), PartitionSpecParser.fromJson(getSchema(), 
entry.getValue()));
+          }
+          cachedPartitionSpecs = local = builder.build();
+        }
+      }
+    }
+    return local;
+  }
+
+  @SchemaIgnore
+  public PartitionSpec getPartitionSpec() {
+    PartitionSpec spec = getPartitionSpecs().get(getSpecId());
+    if (spec == null) {
+      throw new IllegalStateException(
+          "PartitionSpec with id " + getSpecId() + " not found in 
partitionSpecs map");
+    }
+    return spec;
+  }
+
+  @SchemaIgnore
+  public @Nullable PartitionSpec getPartitionSpec(int specId) {
+    return getPartitionSpecs().get(specId);
+  }
+
+  @SchemaIgnore
+  public Map<Integer, SortOrder> getSortOrders() {
+    Map<Integer, SortOrder> local = cachedSortOrders;
+    if (local == null) {
+      synchronized (this) {
+        local = cachedSortOrders;
+        if (local == null) {
+          ImmutableMap.Builder<Integer, SortOrder> builder = 
ImmutableMap.builder();
+          for (Map.Entry<Integer, String> entry : 
getSortOrdersJson().entrySet()) {
+            builder.put(entry.getKey(), SortOrderParser.fromJson(getSchema(), 
entry.getValue()));
+          }
+          cachedSortOrders = local = builder.build();
+        }
+      }
+    }
+    return local;
+  }
+
+  @SchemaIgnore
+  public SortOrder getSortOrder() {
+    SortOrder order = getSortOrders().get(getOrderId());
+    if (order == null) {
+      throw new IllegalStateException(
+          "SortOrder with id " + getOrderId() + " not found in sortOrders 
map");
+    }
+    return order;
+  }
+
+  @SchemaIgnore
+  public @Nullable SortOrder getSortOrder(int orderId) {
+    return getSortOrders().get(orderId);
+  }
+
+  @SchemaIgnore
+  public TableIdentifier getTableIdentifier() {
+    TableIdentifier local = cachedTableIdentifier;
+    if (local == null) {
+      synchronized (this) {
+        local = cachedTableIdentifier;
+        if (local == null) {
+          cachedTableIdentifier =
+              local = 
IcebergUtils.parseTableIdentifier(getTableIdentifierString());
+        }
+      }
+    }
+    return local;
+  }
+
+  @SchemaIgnore
+  public FileIO getFileIO() {
+    FileIO local = cachedFileIO;
+    if (local == null) {
+      synchronized (this) {
+        local = cachedFileIO;
+        if (local == null) {
+          cachedFileIO = local = FileIOParser.fromJson(getFileIoJson());
+        }
+      }
+    }
+    return local;
+  }
+
+  @SchemaIgnore
+  public List<EncryptedKey> getEncryptedKeys() {
+    List<EncryptedKey> local = cachedEncryptedKeys;
+    if (local == null) {
+      synchronized (this) {
+        local = cachedEncryptedKeys;
+        if (local == null) {
+          cachedEncryptedKeys =
+              local =
+                  getEncryptedKeyJsons().stream()
+                      .map(EncryptedKeyParser::fromJson)
+                      .collect(Collectors.toList());
+        }
+      }
+    }
+    return local;
+  }
+
+  public static Builder builder() {
+    return new AutoValue_SerializableTableSpec.Builder();
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setTableIdentifierString(String 
tableIdentifierString);
+
+    public abstract Builder setName(String name);
+
+    public abstract Builder setLocation(String location);
+
+    public abstract Builder setSchemaId(int schemaId);
+
+    public abstract Builder setSchemasJson(Map<Integer, String> schemasJson);
+
+    public abstract Builder setSpecId(int specId);
+
+    public abstract Builder setPartitionSpecsJson(Map<Integer, String> 
partitionSpecsJson);
+
+    public abstract Builder setOrderId(int orderId);
+
+    public abstract Builder setSortOrdersJson(Map<Integer, String> 
sortOrdersJson);
+
+    public abstract Builder setProperties(Map<String, String> properties);
+
+    public abstract Builder setFileIoJson(String fileIoJson);
+
+    public abstract Builder setEncryptedKeyJsons(List<String> 
encryptedKeyJsons);
+
+    @SchemaIgnore
+    public Builder setFileIO(FileIO fileIO) {
+      return setFileIoJson(FileIOParser.toJson(fileIO));
+    }
+
+    public abstract SerializableTableSpec build();
+  }
+
+  /**
+   * Constructs a {@link SerializableTableSpec} from a {@link Table}, using 
{@link Table#name()} as
+   * the table identifier string.
+   *
+   * <p>Note: When possible, prefer {@link #fromTable(TableIdentifier, Table)} 
to avoid catalog name
+   * prefix ambiguities in {@link Table#name()}.
+   */
+  public static SerializableTableSpec fromTable(Table table) {
+    return fromTable(table.name(), table);
+  }
+
+  /**
+   * Constructs a {@link SerializableTableSpec} from a {@link TableIdentifier} 
and a {@link Table}.
+   */
+  public static SerializableTableSpec fromTable(TableIdentifier 
tableIdentifier, Table table) {
+    return fromTable(IcebergUtils.tableIdentifierToString(tableIdentifier), 
table);
+  }
+
+  /**
+   * Constructs a {@link SerializableTableSpec} from an explicit table 
identifier string and a
+   * {@link Table}.
+   */
+  public static SerializableTableSpec fromTable(String tableIdentifierString, 
Table table) {
+    if (!(table instanceof HasTableOperations)) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Table %s of class %s does not implement HasTableOperations",
+              table.name(), table.getClass().getName()));
+    }
+
+    TableMetadata metadata = ((HasTableOperations) 
table).operations().current();
+    List<String> encryptedKeyJsons = Collections.emptyList();
+    if (metadata != null && metadata.encryptionKeys() != null) {
+      encryptedKeyJsons =
+          metadata.encryptionKeys().stream()
+              .map(key -> EncryptedKeyParser.toJson(key, false))
+              .collect(Collectors.toList());
+    }
+
+    ImmutableMap.Builder<Integer, String> schemasJson = ImmutableMap.builder();
+    for (Map.Entry<Integer, Schema> entry : table.schemas().entrySet()) {
+      schemasJson.put(entry.getKey(), SchemaParser.toJson(entry.getValue()));
+    }
+
+    ImmutableMap.Builder<Integer, String> specsJson = ImmutableMap.builder();
+    for (Map.Entry<Integer, PartitionSpec> entry : table.specs().entrySet()) {
+      specsJson.put(entry.getKey(), 
PartitionSpecParser.toJson(entry.getValue()));
+    }
+
+    ImmutableMap.Builder<Integer, String> sortOrdersJson = 
ImmutableMap.builder();
+    for (Map.Entry<Integer, SortOrder> entry : table.sortOrders().entrySet()) {
+      sortOrdersJson.put(entry.getKey(), 
SortOrderParser.toJson(entry.getValue()));
+    }
+
+    return builder()
+        .setTableIdentifierString(tableIdentifierString)
+        .setName(table.name())
+        .setLocation(table.location())
+        .setSchemaId(table.schema().schemaId())
+        .setSchemasJson(schemasJson.build())
+        .setSpecId(table.spec().specId())
+        .setPartitionSpecsJson(specsJson.build())
+        .setOrderId(table.sortOrder().orderId())
+        .setSortOrdersJson(sortOrdersJson.build())
+        .setProperties(table.properties())
+        .setFileIoJson(FileIOParser.toJson(table.io()))
+        .setEncryptedKeyJsons(encryptedKeyJsons)
+        .build();
+  }
+
+  /** Returns the cached {@link SchemaCoder} for {@link 
SerializableTableSpec}. */
+  public static SchemaCoder<SerializableTableSpec> getCoder() {
+    if (cachedCoder == null) {
+      synchronized (SerializableTableSpec.class) {
+        if (cachedCoder == null) {
+          try {
+            cachedCoder =
+                
SchemaRegistry.createDefault().getSchemaCoder(SerializableTableSpec.class);
+          } catch (NoSuchSchemaException e) {
+            throw new RuntimeException(e);
+          }
+        }
+      }
+    }
+    return checkStateNotNull(cachedCoder);
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SideInputTable.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SideInputTable.java
new file mode 100644
index 00000000000..aa51571c4cb
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SideInputTable.java
@@ -0,0 +1,368 @@
+/*
+ * 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.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.beam.sdk.annotations.Internal;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.MoreObjects;
+import org.apache.iceberg.AppendFiles;
+import org.apache.iceberg.DeleteFiles;
+import org.apache.iceberg.ExpireSnapshots;
+import org.apache.iceberg.HistoryEntry;
+import org.apache.iceberg.IncrementalAppendScan;
+import org.apache.iceberg.IncrementalChangelogScan;
+import org.apache.iceberg.LocationProviders;
+import org.apache.iceberg.ManageSnapshots;
+import org.apache.iceberg.OverwriteFiles;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.PartitionStatisticsFile;
+import org.apache.iceberg.ReplacePartitions;
+import org.apache.iceberg.ReplaceSortOrder;
+import org.apache.iceberg.RewriteFiles;
+import org.apache.iceberg.RewriteManifests;
+import org.apache.iceberg.RowDelta;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.SnapshotRef;
+import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.StatisticsFile;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.TableScan;
+import org.apache.iceberg.Transaction;
+import org.apache.iceberg.UpdateLocation;
+import org.apache.iceberg.UpdatePartitionSpec;
+import org.apache.iceberg.UpdateProperties;
+import org.apache.iceberg.UpdateSchema;
+import org.apache.iceberg.UpdateStatistics;
+import org.apache.iceberg.encryption.EncryptionManager;
+import org.apache.iceberg.encryption.EncryptionUtil;
+import org.apache.iceberg.encryption.KeyManagementClient;
+import org.apache.iceberg.encryption.PlaintextEncryptionManager;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.LocationProvider;
+
+/**
+ * A lightweight adapter that implements {@link Table} backed by a {@link 
SerializableTableSpec}.
+ *
+ * <p>Delegates declarative metadata (schemas, partition specs, sort orders, 
properties) and {@link
+ * FileIO} to the broadcasted {@link SerializableTableSpec}, and reconstructs 
the {@link
+ * EncryptionManager} from catalog properties or falls back to {@link 
PlaintextEncryptionManager}.
+ *
+ * <p>All non-metadata or mutating operations (e.g. {@code refresh()}, {@code 
currentSnapshot()},
+ * {@code newAppend()}, {@code updateSchema()}) throw {@link 
UnsupportedOperationException}. Table
+ * commits are handled centrally in {@link AppendFilesToTables}.
+ */
+@Internal
+@SuppressWarnings("nullness")
+public class SideInputTable implements Table {
+
+  private final SerializableTableSpec spec;
+  private final EncryptionManager encryptionManager;
+  private final LocationProvider locationProvider;
+
+  public SideInputTable(SerializableTableSpec spec) {
+    this(spec, Collections.emptyMap());
+  }
+
+  public SideInputTable(SerializableTableSpec spec, Map<String, String> 
catalogProperties) {
+    this.spec = checkNotNull(spec, "spec must not be null");
+    checkNotNull(catalogProperties, "catalogProperties must not be null");
+    this.locationProvider =
+        LocationProviders.locationsFor(spec.getLocation(), 
spec.getProperties());
+
+    Map<String, String> properties = spec.getProperties();
+    if (!properties.containsKey(TableProperties.ENCRYPTION_TABLE_KEY)) {
+      this.encryptionManager = PlaintextEncryptionManager.instance();
+    } else {
+      KeyManagementClient kmsClient = 
EncryptionUtil.createKmsClient(catalogProperties);
+      this.encryptionManager =
+          EncryptionUtil.createEncryptionManager(spec.getEncryptedKeys(), 
properties, kmsClient);
+    }
+  }
+
+  public SideInputTable(SerializableTableSpec spec, EncryptionManager 
encryptionManager) {
+    this.spec = checkNotNull(spec, "spec must not be null");
+    this.encryptionManager = checkNotNull(encryptionManager, 
"encryptionManager must not be null");
+    this.locationProvider =
+        LocationProviders.locationsFor(spec.getLocation(), 
spec.getProperties());
+  }
+
+  public SerializableTableSpec getTableSpec() {
+    return spec;
+  }
+
+  @Override
+  public String name() {
+    return spec.getName();
+  }
+
+  @Override
+  public String location() {
+    return spec.getLocation();
+  }
+
+  @Override
+  public Schema schema() {
+    return spec.getSchema();
+  }
+
+  @Override
+  public Map<Integer, Schema> schemas() {
+    return spec.getSchemas();
+  }
+
+  @Override
+  public PartitionSpec spec() {
+    return spec.getPartitionSpec();
+  }
+
+  @Override
+  public Map<Integer, PartitionSpec> specs() {
+    return spec.getPartitionSpecs();
+  }
+
+  @Override
+  public SortOrder sortOrder() {
+    return spec.getSortOrder();
+  }
+
+  @Override
+  public Map<Integer, SortOrder> sortOrders() {
+    return spec.getSortOrders();
+  }
+
+  @Override
+  public Map<String, String> properties() {
+    return spec.getProperties();
+  }
+
+  @Override
+  public LocationProvider locationProvider() {
+    return locationProvider;
+  }
+
+  @Override
+  public FileIO io() {
+    return spec.getFileIO();
+  }
+
+  @Override
+  public EncryptionManager encryption() {
+    return encryptionManager;
+  }
+
+  @Override
+  public void refresh() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
refresh.");
+  }
+
+  @Override
+  public Snapshot currentSnapshot() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
snapshots.");
+  }
+
+  @Override
+  public Snapshot snapshot(long snapshotId) {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
snapshots.");
+  }
+
+  @Override
+  public Iterable<Snapshot> snapshots() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
snapshots.");
+  }
+
+  @Override
+  public List<HistoryEntry> history() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
snapshots.");
+  }
+
+  @Override
+  public Map<String, SnapshotRef> refs() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
snapshot refs.");
+  }
+
+  @Override
+  public List<StatisticsFile> statisticsFiles() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
statisticsFiles.");
+  }
+
+  @Override
+  public List<PartitionStatisticsFile> partitionStatisticsFiles() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
partitionStatisticsFiles.");
+  }
+
+  @Override
+  public TableScan newScan() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
scans directly.");
+  }
+
+  @Override
+  public IncrementalAppendScan newIncrementalAppendScan() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
scans directly.");
+  }
+
+  @Override
+  public IncrementalChangelogScan newIncrementalChangelogScan() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
scans directly.");
+  }
+
+  @Override
+  public UpdateSchema updateSchema() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public UpdatePartitionSpec updateSpec() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public UpdateProperties updateProperties() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public ReplaceSortOrder replaceSortOrder() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public UpdateLocation updateLocation() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public AppendFiles newAppend() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public AppendFiles newFastAppend() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public RewriteFiles newRewrite() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public RewriteManifests rewriteManifests() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public OverwriteFiles newOverwrite() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public RowDelta newRowDelta() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public ReplacePartitions newReplacePartitions() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public DeleteFiles newDelete() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public UpdateStatistics updateStatistics() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public ExpireSnapshots expireSnapshots() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public ManageSnapshots manageSnapshots() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public Transaction newTransaction() {
+    throw new UnsupportedOperationException(
+        "SideInputTable is a read-only metadata adapter and does not support 
table mutations.");
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SideInputTable)) {
+      return false;
+    }
+    SideInputTable that = (SideInputTable) o;
+    return Objects.equals(spec, that.spec)
+        && Objects.equals(encryptionManager, that.encryptionManager);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(spec, encryptionManager);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("spec", spec)
+        .add("encryptionManager", encryptionManager)
+        .toString();
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpecTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpecTest.java
new file mode 100644
index 00000000000..87a843db7a2
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpecTest.java
@@ -0,0 +1,348 @@
+/*
+ * 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.optional;
+import static org.apache.iceberg.types.Types.NestedField.required;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.apache.beam.sdk.schemas.SchemaCoder;
+import org.apache.beam.sdk.util.CoderUtils;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.NullOrder;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SortDirection;
+import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.encryption.EncryptedKey;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.types.Types;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class SerializableTableSpecTest {
+
+  @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+  private Catalog catalog;
+  private String warehouseLocation;
+
+  private static final Schema COMPLEX_SCHEMA =
+      new Schema(
+          required(1, "id", Types.LongType.get()),
+          optional(2, "name", Types.StringType.get()),
+          optional(3, "timestamp_val", Types.TimestampType.withZone()),
+          optional(4, "amount", Types.DecimalType.of(10, 2)),
+          optional(
+              5,
+              "nested_struct",
+              Types.StructType.of(
+                  required(6, "nested_id", Types.IntegerType.get()),
+                  optional(7, "nested_desc", Types.StringType.get()))),
+          optional(8, "string_list", Types.ListType.ofOptional(9, 
Types.StringType.get())),
+          optional(
+              10,
+              "str_int_map",
+              Types.MapType.ofOptional(11, 12, Types.StringType.get(), 
Types.IntegerType.get())));
+
+  @Before
+  public void setUp() throws Exception {
+    warehouseLocation = "file:" + tempFolder.newFolder().getAbsolutePath();
+    catalog =
+        CatalogUtil.loadCatalog(
+            CatalogUtil.ICEBERG_CATALOG_HADOOP,
+            "hadoop",
+            ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION, 
warehouseLocation),
+            new Configuration());
+  }
+
+  @Test
+  public void testFromTableThrowsWhenNotImplementingHasTableOperations() {
+    Table mockTable = mock(Table.class);
+    when(mockTable.name()).thenReturn("mock_table");
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> SerializableTableSpec.fromTable(TableIdentifier.of("default", 
"mock"), mockTable));
+  }
+
+  @Test
+  public void testFromTableAndGettersUnpartitioned() {
+    TableIdentifier tableId = TableIdentifier.of("default", 
"unpartitioned_table");
+    Table table = catalog.createTable(tableId, TestFixtures.SCHEMA);
+
+    SerializableTableSpec spec = SerializableTableSpec.fromTable(tableId, 
table);
+
+    assertEquals(IcebergUtils.tableIdentifierToString(tableId), 
spec.getTableIdentifierString());
+    assertEquals(table.name(), spec.getName());
+    assertEquals(table.location(), spec.getLocation());
+    assertEquals(table.schema().schemaId(), spec.getSchemaId());
+    assertEquals(table.spec().specId(), spec.getSpecId());
+    assertEquals(table.sortOrder().orderId(), spec.getOrderId());
+    assertEquals(table.schema().asStruct(), spec.getSchema().asStruct());
+    assertEquals(table.schemas().size(), spec.getSchemas().size());
+    assertEquals(table.spec(), spec.getPartitionSpec());
+    assertEquals(table.specs().size(), spec.getPartitionSpecs().size());
+    assertTrue(spec.getPartitionSpec().isUnpartitioned());
+    assertEquals(table.sortOrder(), spec.getSortOrder());
+    assertEquals(table.sortOrders().size(), spec.getSortOrders().size());
+    assertEquals(tableId, spec.getTableIdentifier());
+    assertNotNull(spec.getFileIO());
+    assertEquals(table.io().getClass().getName(), 
spec.getFileIO().getClass().getName());
+    assertNotNull(spec.getEncryptedKeyJsons());
+    assertNotNull(spec.getEncryptedKeys());
+    assertTrue(spec.getEncryptedKeys().isEmpty());
+  }
+
+  @Test
+  public void testFromTableAndGettersPartitionedWithSortOrder() {
+    TableIdentifier tableId = TableIdentifier.of("default", 
"partitioned_table");
+    PartitionSpec partitionSpec =
+        
PartitionSpec.builderFor(COMPLEX_SCHEMA).day("timestamp_val").identity("name").build();
+    SortOrder sortOrder =
+        SortOrder.builderFor(COMPLEX_SCHEMA)
+            .sortBy("id", SortDirection.ASC, NullOrder.NULLS_FIRST)
+            .sortBy("name", SortDirection.DESC, NullOrder.NULLS_LAST)
+            .build();
+    Map<String, String> properties =
+        ImmutableMap.of("write.format.default", "parquet", "custom.property", 
"test-val");
+
+    Table table =
+        catalog
+            .buildTable(tableId, COMPLEX_SCHEMA)
+            .withPartitionSpec(partitionSpec)
+            .withSortOrder(sortOrder)
+            .withProperties(properties)
+            .create();
+
+    SerializableTableSpec spec = SerializableTableSpec.fromTable(table);
+
+    assertEquals(table.name(), spec.getTableIdentifierString());
+    assertEquals(table.name(), spec.getName());
+    assertEquals(table.location(), spec.getLocation());
+    assertEquals(table.schema().schemaId(), spec.getSchemaId());
+    assertEquals(partitionSpec.specId(), spec.getSpecId());
+    assertEquals(sortOrder.orderId(), spec.getOrderId());
+    assertEquals(table.schema().asStruct(), spec.getSchema().asStruct());
+    assertEquals(table.schemas().keySet(), spec.getSchemas().keySet());
+    assertEquals(partitionSpec, spec.getPartitionSpec());
+    assertEquals(table.specs().keySet(), spec.getPartitionSpecs().keySet());
+    assertEquals(sortOrder, spec.getSortOrder());
+    assertEquals(table.sortOrders().keySet(), spec.getSortOrders().keySet());
+    assertEquals(
+        properties.get("write.format.default"), 
spec.getProperties().get("write.format.default"));
+    assertEquals(properties.get("custom.property"), 
spec.getProperties().get("custom.property"));
+    assertNotNull(spec.getFileIO());
+    assertNotNull(spec.getEncryptedKeys());
+  }
+
+  @Test
+  public void testDottedNestedNamespaceIdentifier() {
+    TableIdentifier tableId = TableIdentifier.of("my", "nested", "catalog", 
"deep_table");
+    Table table = catalog.createTable(tableId, TestFixtures.SCHEMA);
+
+    SerializableTableSpec spec = SerializableTableSpec.fromTable(tableId, 
table);
+
+    assertEquals("my.nested.catalog.deep_table", 
spec.getTableIdentifierString());
+    assertEquals(tableId, spec.getTableIdentifier());
+  }
+
+  @Test
+  public void testBuilderAndToBuilderWithEmptyProperties() {
+    TableIdentifier tableId = TableIdentifier.of("default", 
"empty_prop_table");
+    Table table = catalog.createTable(tableId, TestFixtures.SCHEMA);
+
+    SerializableTableSpec spec =
+        SerializableTableSpec.fromTable(tableId, table)
+            .toBuilder()
+            .setProperties(Collections.emptyMap())
+            .build();
+
+    assertTrue(spec.getProperties().isEmpty());
+    assertEquals(tableId, spec.getTableIdentifier());
+    assertNotNull(spec.getFileIO());
+  }
+
+  @Test
+  public void testJavaSerializationRoundtrip() throws Exception {
+    TableIdentifier tableId = TableIdentifier.of("default", "ser_table");
+    PartitionSpec partitionSpec =
+        PartitionSpec.builderFor(COMPLEX_SCHEMA).bucket("name", 16).build();
+    Table table =
+        catalog.buildTable(tableId, 
COMPLEX_SCHEMA).withPartitionSpec(partitionSpec).create();
+
+    SerializableTableSpec original = SerializableTableSpec.fromTable(tableId, 
table);
+
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+      oos.writeObject(original);
+    }
+
+    SerializableTableSpec deserialized;
+    try (ObjectInputStream ois =
+        new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
+      deserialized = (SerializableTableSpec) ois.readObject();
+    }
+
+    assertNotNull(deserialized);
+    assertEquals(original, deserialized);
+    assertEquals(original.getTableIdentifierString(), 
deserialized.getTableIdentifierString());
+    assertEquals(original.getSchemaId(), deserialized.getSchemaId());
+    assertEquals(original.getSpecId(), deserialized.getSpecId());
+    assertEquals(original.getOrderId(), deserialized.getOrderId());
+    assertEquals(original.getSchema().asStruct(), 
deserialized.getSchema().asStruct());
+    assertEquals(original.getSchemas().keySet(), 
deserialized.getSchemas().keySet());
+    assertEquals(original.getPartitionSpec(), deserialized.getPartitionSpec());
+    assertEquals(original.getPartitionSpecs().keySet(), 
deserialized.getPartitionSpecs().keySet());
+    assertEquals(original.getSortOrder(), deserialized.getSortOrder());
+    assertEquals(original.getSortOrders().keySet(), 
deserialized.getSortOrders().keySet());
+    assertEquals(original.getEncryptedKeyJsons(), 
deserialized.getEncryptedKeyJsons());
+    assertEquals(original.getEncryptedKeys(), deserialized.getEncryptedKeys());
+    assertNotNull(deserialized.getFileIO());
+  }
+
+  @Test
+  public void testBeamSchemaCoderRoundtrip() throws Exception {
+    TableIdentifier tableId = TableIdentifier.of("default", "coder_table");
+    PartitionSpec partitionSpec =
+        PartitionSpec.builderFor(COMPLEX_SCHEMA).hour("timestamp_val").build();
+    SortOrder sortOrder =
+        SortOrder.builderFor(COMPLEX_SCHEMA)
+            .sortBy("id", SortDirection.DESC, NullOrder.NULLS_LAST)
+            .build();
+    Table table =
+        catalog
+            .buildTable(tableId, COMPLEX_SCHEMA)
+            .withPartitionSpec(partitionSpec)
+            .withSortOrder(sortOrder)
+            .withProperties(ImmutableMap.of("k1", "v1"))
+            .create();
+
+    SerializableTableSpec original = SerializableTableSpec.fromTable(tableId, 
table);
+    SchemaCoder<SerializableTableSpec> coder = 
SerializableTableSpec.getCoder();
+
+    SerializableTableSpec decoded = CoderUtils.clone(coder, original);
+
+    assertNotNull(decoded);
+    assertEquals(original, decoded);
+    assertEquals(original.getTableIdentifierString(), 
decoded.getTableIdentifierString());
+    assertEquals(original.getName(), decoded.getName());
+    assertEquals(original.getLocation(), decoded.getLocation());
+    assertEquals(original.getSchemaId(), decoded.getSchemaId());
+    assertEquals(original.getSchemasJson(), decoded.getSchemasJson());
+    assertEquals(original.getSpecId(), decoded.getSpecId());
+    assertEquals(original.getPartitionSpecsJson(), 
decoded.getPartitionSpecsJson());
+    assertEquals(original.getOrderId(), decoded.getOrderId());
+    assertEquals(original.getSortOrdersJson(), decoded.getSortOrdersJson());
+    assertEquals(original.getSchema().asStruct(), 
decoded.getSchema().asStruct());
+    assertEquals(original.getPartitionSpec(), decoded.getPartitionSpec());
+    assertEquals(original.getSortOrder(), decoded.getSortOrder());
+    assertEquals(original.getProperties(), decoded.getProperties());
+    assertEquals(original.getFileIoJson(), decoded.getFileIoJson());
+    assertEquals(original.getEncryptedKeyJsons(), 
decoded.getEncryptedKeyJsons());
+    assertNotNull(decoded.getFileIO());
+  }
+
+  @Test
+  @SuppressWarnings("ReferenceEquality")
+  public void testConcurrentGetterInitializationThreadSafety() throws 
Exception {
+    TableIdentifier tableId = TableIdentifier.of("default", 
"concurrent_table");
+    PartitionSpec partitionSpec =
+        PartitionSpec.builderFor(COMPLEX_SCHEMA).bucket("name", 8).build();
+    Table table =
+        catalog.buildTable(tableId, 
COMPLEX_SCHEMA).withPartitionSpec(partitionSpec).create();
+
+    SerializableTableSpec spec = SerializableTableSpec.fromTable(tableId, 
table);
+
+    int numThreads = 16;
+    ExecutorService executor = Executors.newFixedThreadPool(numThreads);
+    CountDownLatch startLatch = new CountDownLatch(1);
+    List<Future<Void>> futures = new ArrayList<>();
+
+    try {
+      for (int i = 0; i < numThreads; i++) {
+        futures.add(
+            executor.submit(
+                () -> {
+                  startLatch.await();
+                  Schema schema = spec.getSchema();
+                  Map<Integer, Schema> schemas = spec.getSchemas();
+                  PartitionSpec ps = spec.getPartitionSpec();
+                  Map<Integer, PartitionSpec> specs = spec.getPartitionSpecs();
+                  SortOrder so = spec.getSortOrder();
+                  Map<Integer, SortOrder> orders = spec.getSortOrders();
+                  TableIdentifier ti = spec.getTableIdentifier();
+                  FileIO io = spec.getFileIO();
+                  List<EncryptedKey> keys = spec.getEncryptedKeys();
+
+                  if (schema == null
+                      || schemas == null
+                      || ps == null
+                      || specs == null
+                      || so == null
+                      || orders == null
+                      || ti == null
+                      || io == null
+                      || keys == null) {
+                    throw new IllegalStateException("Getter returned null");
+                  }
+                  if (schemas != spec.getSchemas()
+                      || specs != spec.getPartitionSpecs()
+                      || orders != spec.getSortOrders()
+                      || io != spec.getFileIO()
+                      || keys != spec.getEncryptedKeys()) {
+                    throw new IllegalStateException("Getter returned 
non-identical instance");
+                  }
+                  return null;
+                }));
+      }
+
+      startLatch.countDown();
+      for (Future<Void> future : futures) {
+        future.get(10, TimeUnit.SECONDS);
+      }
+    } finally {
+      executor.shutdown();
+    }
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SideInputTableTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SideInputTableTest.java
new file mode 100644
index 00000000000..663c818b587
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SideInputTableTest.java
@@ -0,0 +1,239 @@
+/*
+ * 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.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Map;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.NullOrder;
+import org.apache.iceberg.PartitionKey;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.SortDirection;
+import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.encryption.PlaintextEncryptionManager;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class SideInputTableTest {
+
+  @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+  private Catalog catalog;
+  private String warehouseLocation;
+
+  @Before
+  public void setUp() throws Exception {
+    warehouseLocation = "file:" + tempFolder.newFolder().getAbsolutePath();
+    catalog =
+        CatalogUtil.loadCatalog(
+            CatalogUtil.ICEBERG_CATALOG_HADOOP,
+            "hadoop",
+            ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION, 
warehouseLocation),
+            new Configuration());
+  }
+
+  @Test
+  public void testConstructorNullChecks() {
+    assertThrows(NullPointerException.class, () -> new SideInputTable(null));
+    TableIdentifier tableId = TableIdentifier.of("default", 
"null_check_table");
+    Table realTable = catalog.createTable(tableId, TestFixtures.SCHEMA);
+    SerializableTableSpec spec = SerializableTableSpec.fromTable(tableId, 
realTable);
+    assertThrows(
+        NullPointerException.class, () -> new SideInputTable(spec, 
(Map<String, String>) null));
+  }
+
+  @Test
+  public void testMetadataDelegation() {
+    TableIdentifier tableId = TableIdentifier.of("default", 
"side_input_test_table");
+    PartitionSpec partitionSpec =
+        PartitionSpec.builderFor(TestFixtures.SCHEMA).identity("data").build();
+    SortOrder sortOrder =
+        SortOrder.builderFor(TestFixtures.SCHEMA)
+            .sortBy("id", SortDirection.ASC, NullOrder.NULLS_FIRST)
+            .build();
+    Map<String, String> properties =
+        ImmutableMap.of("write.format.default", "parquet", "user.key", 
"user.val");
+
+    Table realTable =
+        catalog
+            .buildTable(tableId, TestFixtures.SCHEMA)
+            .withPartitionSpec(partitionSpec)
+            .withSortOrder(sortOrder)
+            .withProperties(properties)
+            .create();
+
+    SerializableTableSpec spec = SerializableTableSpec.fromTable(tableId, 
realTable);
+    SideInputTable sideInputTable = new SideInputTable(spec, 
ImmutableMap.of());
+
+    assertEquals(realTable.name(), sideInputTable.name());
+    assertEquals(realTable.location(), sideInputTable.location());
+    assertEquals(realTable.schema().asStruct(), 
sideInputTable.schema().asStruct());
+    assertEquals(realTable.schemas().keySet(), 
sideInputTable.schemas().keySet());
+    assertEquals(realTable.spec(), sideInputTable.spec());
+    assertEquals(realTable.specs().keySet(), sideInputTable.specs().keySet());
+    assertEquals(realTable.sortOrder(), sideInputTable.sortOrder());
+    assertEquals(realTable.sortOrders().keySet(), 
sideInputTable.sortOrders().keySet());
+    assertEquals(
+        realTable.properties().get("user.key"), 
sideInputTable.properties().get("user.key"));
+    assertNotNull(sideInputTable.io());
+    assertEquals(realTable.io().getClass().getName(), 
sideInputTable.io().getClass().getName());
+    assertNotNull(sideInputTable.locationProvider());
+    assertNotNull(sideInputTable.encryption());
+    assertTrue(sideInputTable.encryption() instanceof 
PlaintextEncryptionManager);
+    assertEquals(spec, sideInputTable.getTableSpec());
+    assertTrue(sideInputTable.specs().containsKey(spec.getSpecId()));
+  }
+
+  @Test
+  public void testWritingPartitionedWithRecordWriter() throws Exception {
+    TableIdentifier tableId = TableIdentifier.of("default", 
"partitioned_writer_table");
+    PartitionSpec partitionSpec =
+        PartitionSpec.builderFor(TestFixtures.SCHEMA).identity("data").build();
+    Table realTable =
+        catalog.buildTable(tableId, 
TestFixtures.SCHEMA).withPartitionSpec(partitionSpec).create();
+
+    SerializableTableSpec spec = SerializableTableSpec.fromTable(tableId, 
realTable);
+    SideInputTable sideInputTable = new SideInputTable(spec);
+
+    PartitionKey partitionKey = new PartitionKey(sideInputTable.spec(), 
sideInputTable.schema());
+    Record record = GenericRecord.create(sideInputTable.schema());
+    record.setField("id", 42L);
+    record.setField("data", "test_partition_value");
+    partitionKey.partition(record);
+
+    RecordWriter writer =
+        new RecordWriter(
+            sideInputTable, FileFormat.PARQUET, "test_file_001", partitionKey, 
ImmutableMap.of());
+
+    writer.write(record);
+    writer.close();
+
+    assertNotNull(writer.getDataFile());
+    assertNotNull(writer.getDataFile().path());
+    assertEquals(1, writer.getDataFile().recordCount());
+    assertEquals(FileFormat.PARQUET, writer.getDataFile().format());
+  }
+
+  @Test
+  public void testWritingUnpartitionedWithRecordWriter() throws Exception {
+    TableIdentifier tableId = TableIdentifier.of("default", 
"unpartitioned_writer_table");
+    Table realTable = catalog.createTable(tableId, TestFixtures.SCHEMA);
+
+    SerializableTableSpec spec = SerializableTableSpec.fromTable(tableId, 
realTable);
+    SideInputTable sideInputTable = new SideInputTable(spec);
+
+    PartitionKey partitionKey = new PartitionKey(sideInputTable.spec(), 
sideInputTable.schema());
+    Record record = GenericRecord.create(sideInputTable.schema());
+    record.setField("id", 99L);
+    record.setField("data", "unpartitioned_data");
+    partitionKey.partition(record);
+
+    RecordWriter writer =
+        new RecordWriter(
+            sideInputTable,
+            FileFormat.PARQUET,
+            "test_unpartitioned_file_001",
+            partitionKey,
+            ImmutableMap.of());
+
+    writer.write(record);
+    writer.close();
+
+    assertNotNull(writer.getDataFile());
+    assertNotNull(writer.getDataFile().path());
+    assertEquals(1, writer.getDataFile().recordCount());
+    assertEquals(FileFormat.PARQUET, writer.getDataFile().format());
+  }
+
+  @Test
+  public void testUnsupportedAndNoOpOperationsThrowExceptions() {
+    TableIdentifier tableId = TableIdentifier.of("default", 
"mutations_test_table");
+    Table realTable = catalog.createTable(tableId, TestFixtures.SCHEMA);
+    SerializableTableSpec spec = SerializableTableSpec.fromTable(tableId, 
realTable);
+    SideInputTable sideInputTable = new SideInputTable(spec);
+
+    // Refresh & Snapshot operations must throw UnsupportedOperationException
+    assertThrows(UnsupportedOperationException.class, sideInputTable::refresh);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::currentSnapshot);
+    assertThrows(UnsupportedOperationException.class, () -> 
sideInputTable.snapshot(12345L));
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::snapshots);
+    assertThrows(UnsupportedOperationException.class, sideInputTable::history);
+    assertThrows(UnsupportedOperationException.class, sideInputTable::refs);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::statisticsFiles);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::partitionStatisticsFiles);
+
+    // Scans & Mutations
+    assertThrows(UnsupportedOperationException.class, sideInputTable::newScan);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newIncrementalAppendScan);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newIncrementalChangelogScan);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::updateSchema);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::updateSpec);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::updateProperties);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::replaceSortOrder);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::updateLocation);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newAppend);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newFastAppend);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newRewrite);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::rewriteManifests);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newOverwrite);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newRowDelta);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newReplacePartitions);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newDelete);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::updateStatistics);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::expireSnapshots);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::manageSnapshots);
+    assertThrows(UnsupportedOperationException.class, 
sideInputTable::newTransaction);
+  }
+
+  @Test
+  public void testEqualsHashCodeAndToString() {
+    TableIdentifier tableId1 = TableIdentifier.of("default", "t1");
+    TableIdentifier tableId2 = TableIdentifier.of("default", "t2");
+    Table realTable1 = catalog.createTable(tableId1, TestFixtures.SCHEMA);
+    Table realTable2 = catalog.createTable(tableId2, TestFixtures.SCHEMA);
+
+    SerializableTableSpec spec1 = SerializableTableSpec.fromTable(tableId1, 
realTable1);
+    SerializableTableSpec spec2 = SerializableTableSpec.fromTable(tableId2, 
realTable2);
+
+    SideInputTable table1a = new SideInputTable(spec1);
+    SideInputTable table1b = new SideInputTable(spec1);
+    SideInputTable table2 = new SideInputTable(spec2);
+
+    assertEquals(table1a, table1b);
+    assertEquals(table1a.hashCode(), table1b.hashCode());
+    assertNotEquals(table1a, table2);
+    assertNotNull(table1a.toString());
+    assertTrue(table1a.toString().contains("SideInputTable"));
+  }
+}

Reply via email to