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

mxm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 66a1417a76 Flink: Backport Support Lookup Join using full in-memory 
lookup cache (#18766)
66a1417a76 is described below

commit 66a1417a7674d677aee6c6d24cc6486ec33b2192
Author: GuoYu <[email protected]>
AuthorDate: Wed Nov 4 18:49:55 2026 +0800

    Flink: Backport Support Lookup Join using full in-memory lookup cache 
(#18766)
---
 .../iceberg/flink/FlinkDynamicTableFactory.java    |   2 +
 .../iceberg/flink/source/IcebergTableSource.java   |  60 +++-
 .../lookup/IcebergFullCachingLookupFunction.java   | 223 +++++++++++++
 .../flink/source/lookup/IcebergLookupOptions.java  |  41 +++
 .../flink/source/lookup/IcebergLookupReader.java   | 124 +++++++
 .../flink/source/lookup/InMemoryLookupCache.java   |  69 ++++
 .../iceberg/flink/source/TestFlinkSourceSql.java   |   2 +-
 .../flink/source/TestIcebergLookupJoinSql.java     | 369 +++++++++++++++++++++
 .../iceberg/flink/source/TestIcebergSourceSql.java |   2 +-
 .../iceberg/flink/source/TestSourceSqlBase.java}   |  54 +--
 .../apache/iceberg/flink/source/TestSqlBase.java   |  99 +-----
 .../flink/source/lookup/TestIcebergLookup.java     | 318 ++++++++++++++++++
 .../iceberg/flink/FlinkDynamicTableFactory.java    |   2 +
 .../iceberg/flink/source/IcebergTableSource.java   |  60 +++-
 .../lookup/IcebergFullCachingLookupFunction.java   | 223 +++++++++++++
 .../flink/source/lookup/IcebergLookupOptions.java  |  41 +++
 .../flink/source/lookup/IcebergLookupReader.java   | 124 +++++++
 .../flink/source/lookup/InMemoryLookupCache.java   |  69 ++++
 .../iceberg/flink/source/TestFlinkSourceSql.java   |   2 +-
 .../flink/source/TestIcebergLookupJoinSql.java     | 369 +++++++++++++++++++++
 .../iceberg/flink/source/TestIcebergSourceSql.java |   2 +-
 .../{TestSqlBase.java => TestSourceSqlBase.java}   |  54 +--
 .../apache/iceberg/flink/source/TestSqlBase.java   |  97 +-----
 .../flink/source/lookup/TestIcebergLookup.java     | 318 ++++++++++++++++++
 gradle.properties                                  |   2 +-
 25 files changed, 2419 insertions(+), 307 deletions(-)

diff --git 
a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java
 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java
index 6306ee7a0a..0c7244dc23 100644
--- 
a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java
+++ 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java
@@ -39,6 +39,7 @@ import 
org.apache.flink.table.factories.DynamicTableSourceFactory;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.flink.source.IcebergTableSource;
+import org.apache.iceberg.flink.source.lookup.IcebergLookupOptions;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.apache.iceberg.relocated.com.google.common.collect.Sets;
@@ -132,6 +133,7 @@ public class FlinkDynamicTableFactory
     options.add(FlinkCreateTableOptions.CATALOG_TABLE);
     options.add(FlinkCreateTableOptions.USE_DYNAMIC_ICEBERG_SINK);
     options.add(FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL);
+    options.add(IcebergLookupOptions.FULL_CACHE_EAGER_LOAD);
     return options;
   }
 
diff --git 
a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java
 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java
index c65a423489..7cd93f53e0 100644
--- 
a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java
+++ 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java
@@ -34,20 +34,28 @@ import org.apache.flink.table.connector.ChangelogMode;
 import org.apache.flink.table.connector.ProviderContext;
 import org.apache.flink.table.connector.source.DataStreamScanProvider;
 import org.apache.flink.table.connector.source.DynamicTableSource;
+import org.apache.flink.table.connector.source.LookupTableSource;
 import org.apache.flink.table.connector.source.ScanTableSource;
 import 
org.apache.flink.table.connector.source.abilities.SupportsFilterPushDown;
 import org.apache.flink.table.connector.source.abilities.SupportsLimitPushDown;
 import 
org.apache.flink.table.connector.source.abilities.SupportsProjectionPushDown;
 import 
org.apache.flink.table.connector.source.abilities.SupportsSourceWatermark;
+import org.apache.flink.table.connector.source.lookup.LookupFunctionProvider;
+import org.apache.flink.table.connector.source.lookup.LookupOptions;
 import org.apache.flink.table.data.RowData;
 import org.apache.flink.table.expressions.ResolvedExpression;
 import org.apache.flink.table.factories.FactoryUtil;
+import org.apache.flink.table.functions.LookupFunction;
+import org.apache.flink.table.types.logical.RowType;
 import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.flink.FlinkConfParser;
 import org.apache.iceberg.flink.FlinkConfigOptions;
 import org.apache.iceberg.flink.FlinkFilters;
 import org.apache.iceberg.flink.FlinkReadOptions;
 import org.apache.iceberg.flink.TableLoader;
 import org.apache.iceberg.flink.source.assigner.SplitAssignerType;
+import org.apache.iceberg.flink.source.lookup.IcebergFullCachingLookupFunction;
+import org.apache.iceberg.flink.source.lookup.IcebergLookupOptions;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
@@ -60,7 +68,8 @@ public class IcebergTableSource
         SupportsProjectionPushDown,
         SupportsFilterPushDown,
         SupportsLimitPushDown,
-        SupportsSourceWatermark {
+        SupportsSourceWatermark,
+        LookupTableSource {
 
   private int[] projectedFields;
   private Long limit;
@@ -71,6 +80,7 @@ public class IcebergTableSource
   private final Map<String, String> properties;
   private final boolean isLimitPushDown;
   private final ReadableConfig readableConfig;
+  private final FlinkConfParser flinkConfParser;
 
   private IcebergTableSource(IcebergTableSource toCopy) {
     this.loader = toCopy.loader;
@@ -81,6 +91,7 @@ public class IcebergTableSource
     this.limit = toCopy.limit;
     this.filters = toCopy.filters;
     this.readableConfig = toCopy.readableConfig;
+    this.flinkConfParser = toCopy.flinkConfParser;
   }
 
   public IcebergTableSource(
@@ -108,6 +119,7 @@ public class IcebergTableSource
     this.limit = limit;
     this.filters = filters;
     this.readableConfig = readableConfig;
+    this.flinkConfParser = new FlinkConfParser(properties, readableConfig);
   }
 
   @Override
@@ -236,4 +248,50 @@ public class IcebergTableSource
   public String asSummaryString() {
     return "Iceberg table source";
   }
+
+  @Override
+  public LookupRuntimeProvider getLookupRuntimeProvider(LookupContext context) 
{
+    int[][] lookupKeys = context.getKeys();
+    int[] lookupKeyIndexes = new int[lookupKeys.length];
+    for (int i = 0; i < lookupKeys.length; i++) {
+      Preconditions.checkArgument(
+          lookupKeys[i].length == 1, "Iceberg lookup source doesn't support 
nested lookup key.");
+      lookupKeyIndexes[i] = lookupKeys[i][0];
+    }
+
+    ResolvedSchema projected = getProjectedSchema();
+    RowType projectedRowType = (RowType) 
projected.toPhysicalRowDataType().getLogicalType();
+
+    LookupOptions.LookupCacheType requestedCacheType =
+        flinkConfParser
+            .enumConfParser(LookupOptions.LookupCacheType.class)
+            .option(LookupOptions.CACHE_TYPE.key())
+            .parseOptional();
+    Preconditions.checkArgument(
+        requestedCacheType == null || requestedCacheType == 
LookupOptions.LookupCacheType.FULL,
+        "Iceberg lookup join only supports %s=FULL, but it is set to '%s'. 
NONE and PARTIAL are "
+            + "not supported, because an Iceberg table cannot be 
point-looked-up.",
+        LookupOptions.CACHE_TYPE.key(),
+        requestedCacheType);
+
+    boolean eagerLoad =
+        flinkConfParser
+            .booleanConf()
+            .option(IcebergLookupOptions.FULL_CACHE_EAGER_LOAD.key())
+            
.defaultValue(IcebergLookupOptions.FULL_CACHE_EAGER_LOAD.defaultValue())
+            .parse();
+
+    boolean caseSensitive =
+        flinkConfParser
+            .booleanConf()
+            .option(FlinkReadOptions.CASE_SENSITIVE)
+            .flinkConfig(FlinkReadOptions.CASE_SENSITIVE_OPTION)
+            
.defaultValue(FlinkReadOptions.CASE_SENSITIVE_OPTION.defaultValue())
+            .parse();
+
+    LookupFunction lookupFn =
+        new IcebergFullCachingLookupFunction(
+            loader, projectedRowType, lookupKeyIndexes, filters, 
caseSensitive, eagerLoad);
+    return LookupFunctionProvider.of(lookupFn);
+  }
 }
diff --git 
a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergFullCachingLookupFunction.java
 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergFullCachingLookupFunction.java
new file mode 100644
index 0000000000..d7e78913e3
--- /dev/null
+++ 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergFullCachingLookupFunction.java
@@ -0,0 +1,223 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.metrics.Counter;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.binary.BinaryRowData;
+import org.apache.flink.table.functions.FunctionContext;
+import org.apache.flink.table.functions.LookupFunction;
+import org.apache.flink.table.runtime.typeutils.RowDataSerializer;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.flink.FlinkRowData;
+import org.apache.iceberg.flink.FlinkSchemaUtil;
+import org.apache.iceberg.flink.TableLoader;
+import org.apache.iceberg.io.CloseableIterable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A lookup function that serves every lookup from an in-memory cache of the 
rows of an Iceberg
+ * table, after projection and pushed-down filters are applied.
+ *
+ * <p>The cache is loaded from the current snapshot either when the function 
is opened or on the
+ * first lookup, depending on {@link 
IcebergLookupOptions#FULL_CACHE_EAGER_LOAD}. It is never
+ * refreshed, so later changes to the table are not visible.
+ */
+@Internal
+public class IcebergFullCachingLookupFunction extends LookupFunction {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IcebergFullCachingLookupFunction.class);
+
+  private static final String METRIC_GROUP = "cache";
+  private static final long UNINITIALIZED = -1L;
+
+  private final TableLoader tableLoader;
+  private final RowType projectedRowType;
+  private final int[] lookupKeyIndexes;
+  private final List<Expression> pushedFilters;
+  private final boolean caseSensitive;
+  private final boolean eagerLoad;
+
+  private transient Table table;
+  private transient IcebergLookupReader reader;
+  private transient RowDataSerializer rowSerializer;
+  private transient RowDataSerializer keySerializer;
+  private transient RowData.FieldGetter[] keyGetters;
+  private transient InMemoryLookupCache cache;
+
+  private transient Counter missCounter;
+  private transient volatile long currentSnapshotId;
+  private transient volatile long cachedRecords;
+  private transient volatile long latestLoadTimeMs;
+
+  public IcebergFullCachingLookupFunction(
+      TableLoader tableLoader,
+      RowType projectedRowType,
+      int[] lookupKeyIndexes,
+      List<Expression> pushedFilters,
+      boolean caseSensitive,
+      boolean eagerLoad) {
+    this.tableLoader = tableLoader;
+    this.projectedRowType = projectedRowType;
+    this.lookupKeyIndexes = lookupKeyIndexes;
+    this.pushedFilters = pushedFilters;
+    this.caseSensitive = caseSensitive;
+    this.eagerLoad = eagerLoad;
+  }
+
+  @Override
+  public void open(FunctionContext context) throws Exception {
+    super.open(context);
+    LOG.info(
+        "IcebergFullCachingLookupFunction opening, projected fields={}, 
lookupKeyIndexes={}",
+        projectedRowType.getFieldNames(),
+        Arrays.toString(lookupKeyIndexes));
+
+    MetricGroup metricGroup = context.getMetricGroup().addGroup(METRIC_GROUP);
+    this.missCounter = metricGroup.counter("missCount");
+    this.currentSnapshotId = IcebergLookupReader.CURRENT_SNAPSHOT;
+    this.cachedRecords = 0L;
+    this.latestLoadTimeMs = UNINITIALIZED;
+    metricGroup.gauge("snapshotId", () -> currentSnapshotId);
+    metricGroup.gauge("numCachedRecords", () -> cachedRecords);
+    metricGroup.gauge("latestLoadTime", () -> latestLoadTimeMs);
+
+    tableLoader.open();
+    this.table = tableLoader.loadTable();
+
+    Schema icebergProjection =
+        FlinkSchemaUtil.convert(table.schema(), 
FlinkSchemaUtil.toResolvedSchema(projectedRowType));
+
+    createAccessors();
+
+    String nameMapping = 
table.properties().get(TableProperties.DEFAULT_NAME_MAPPING);
+    this.reader =
+        new IcebergLookupReader(
+            table, icebergProjection, pushedFilters, caseSensitive, 
nameMapping);
+
+    if (eagerLoad) {
+      loadCache();
+    }
+  }
+
+  /**
+   * Returns the cached rows for the key. The returned collection and its rows 
are shared with the
+   * cache, so they must not be modified.
+   */
+  @Override
+  public Collection<RowData> lookup(RowData keyRow) throws IOException {
+    if (cache == null) {
+      loadCache();
+    }
+
+    List<RowData> hit = cache.get(keySerializer.toBinaryRow(keyRow));
+    if (hit == null) {
+      missCounter.inc();
+      return Collections.emptyList();
+    }
+
+    return hit;
+  }
+
+  @Override
+  public void close() throws Exception {
+    try {
+      tableLoader.close();
+    } finally {
+      if (cache != null) {
+        cache.close();
+      }
+
+      super.close();
+    }
+  }
+
+  private void loadCache() throws IOException {
+    table.refresh();
+    Snapshot snapshot = table.currentSnapshot();
+    long snapshotId =
+        snapshot == null ? IcebergLookupReader.CURRENT_SNAPSHOT : 
snapshot.snapshotId();
+
+    LOG.info(
+        "IcebergFullCachingLookupFunction loading started, snapshot={}, 
committedAt={}, projected fields={}, pushedFilters={}",
+        snapshot == null ? "none" : snapshotId,
+        snapshot == null ? "n/a" : snapshot.timestampMillis(),
+        projectedRowType.getFieldNames(),
+        pushedFilters);
+
+    InMemoryLookupCache loaded = new InMemoryLookupCache();
+    long rowCnt = 0L;
+    long start = System.currentTimeMillis();
+    try (CloseableIterable<RowData> rows = reader.read(snapshotId)) {
+      for (RowData row : rows) {
+        loaded.add(extractKey(row), rowSerializer.toBinaryRow(row).copy());
+        rowCnt++;
+      }
+    } finally {
+      this.latestLoadTimeMs = System.currentTimeMillis() - start;
+    }
+
+    this.cache = loaded;
+    this.currentSnapshotId = snapshotId;
+    this.cachedRecords = rowCnt;
+
+    LOG.info(
+        "IcebergFullCachingLookupFunction loading finished, snapshot={}, 
rows={}, cost={} ms",
+        snapshot == null ? "none" : snapshotId,
+        rowCnt,
+        latestLoadTimeMs);
+  }
+
+  private BinaryRowData extractKey(RowData row) {
+    GenericRowData key = new GenericRowData(keyGetters.length);
+    for (int i = 0; i < keyGetters.length; i++) {
+      key.setField(i, keyGetters[i].getFieldOrNull(row));
+    }
+
+    return keySerializer.toBinaryRow(key).copy();
+  }
+
+  private void createAccessors() {
+    LogicalType[] keyTypes = new LogicalType[lookupKeyIndexes.length];
+    this.keyGetters = new RowData.FieldGetter[lookupKeyIndexes.length];
+    for (int i = 0; i < lookupKeyIndexes.length; i++) {
+      int projectedIndex = lookupKeyIndexes[i];
+      keyTypes[i] = projectedRowType.getTypeAt(projectedIndex);
+      this.keyGetters[i] = FlinkRowData.createFieldGetter(keyTypes[i], 
projectedIndex);
+    }
+
+    this.rowSerializer = new RowDataSerializer(projectedRowType);
+    this.keySerializer = new RowDataSerializer(keyTypes);
+  }
+}
diff --git 
a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupOptions.java
 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupOptions.java
new file mode 100644
index 0000000000..355fd5387f
--- /dev/null
+++ 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupOptions.java
@@ -0,0 +1,41 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.configuration.ConfigOption;
+import org.apache.flink.configuration.ConfigOptions;
+
+/** Lookup join options for the Iceberg table source. */
+@Internal
+public class IcebergLookupOptions {
+
+  public static final ConfigOption<Boolean> FULL_CACHE_EAGER_LOAD =
+      ConfigOptions.key("lookup.full-cache.eager-load")
+          .booleanType()
+          .defaultValue(true)
+          .withDescription(
+              "Whether to load the full lookup cache when the lookup function 
is opened, instead "
+                  + "of on the first lookup. Eager loading narrows the window 
in which the subtasks "
+                  + "of the join can end up on different snapshots of the 
dimension table, and "
+                  + "fails the job at startup if the table cannot be read, at 
the cost of a longer "
+                  + "deployment.");
+
+  private IcebergLookupOptions() {}
+}
diff --git 
a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupReader.java
 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupReader.java
new file mode 100644
index 0000000000..d669178557
--- /dev/null
+++ 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupReader.java
@@ -0,0 +1,124 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import java.io.IOException;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.table.data.RowData;
+import org.apache.iceberg.CombinedScanTask;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableScan;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.flink.source.DataIterator;
+import org.apache.iceberg.flink.source.FileScanTaskReader;
+import org.apache.iceberg.flink.source.RowDataFileScanTaskReader;
+import org.apache.iceberg.io.CloseableGroup;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.CloseableIterator;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.util.SnapshotUtil;
+
+/** Reads all rows of an Iceberg table to build a lookup cache. */
+@Internal
+class IcebergLookupReader {
+
+  static final long CURRENT_SNAPSHOT = -1L;
+
+  private final Table table;
+  private final Schema projectedSchema;
+  private final List<Expression> baseFilters;
+  private final Expression filter;
+  private final boolean caseSensitive;
+  private final String nameMapping;
+
+  IcebergLookupReader(
+      Table table,
+      Schema projectedSchema,
+      List<Expression> baseFilters,
+      boolean caseSensitive,
+      @Nullable String nameMapping) {
+    Preconditions.checkNotNull(baseFilters, "Base filters should not be null");
+
+    this.table = table;
+    this.projectedSchema = projectedSchema;
+    this.baseFilters = baseFilters;
+    this.caseSensitive = caseSensitive;
+    this.nameMapping = nameMapping;
+
+    Expression combinedFilter = Expressions.alwaysTrue();
+    for (Expression baseFilter : this.baseFilters) {
+      combinedFilter = Expressions.and(combinedFilter, baseFilter);
+    }
+
+    this.filter = combinedFilter;
+  }
+
+  CloseableIterable<RowData> read(long snapshotId) {
+    Schema tableSchema =
+        snapshotId == CURRENT_SNAPSHOT ? table.schema() : 
SnapshotUtil.schemaFor(table, snapshotId);
+
+    RowDataFileScanTaskReader fileReader =
+        new RowDataFileScanTaskReader(
+            tableSchema, projectedSchema, nameMapping, caseSensitive, 
baseFilters);
+
+    TableScan scan =
+        
table.newScan().caseSensitive(caseSensitive).project(projectedSchema).filter(filter);
+    if (snapshotId != CURRENT_SNAPSHOT) {
+      scan = scan.useSnapshot(snapshotId);
+    }
+
+    return new LookupIterable(scan.planTasks(), fileReader);
+  }
+
+  private class LookupIterable extends CloseableGroup implements 
CloseableIterable<RowData> {
+    private final CloseableIterable<CombinedScanTask> tasks;
+    private final FileScanTaskReader<RowData> fileReader;
+
+    private LookupIterable(
+        CloseableIterable<CombinedScanTask> tasks, FileScanTaskReader<RowData> 
fileReader) {
+      this.tasks = tasks;
+      this.fileReader = fileReader;
+    }
+
+    @Override
+    public CloseableIterator<RowData> iterator() {
+      CloseableIterator<RowData> rows =
+          CloseableIterable.concat(Iterables.transform(tasks, 
this::taskRows)).iterator();
+      addCloseable(rows);
+      return rows;
+    }
+
+    @Override
+    public void close() throws IOException {
+      tasks.close();
+      super.close();
+    }
+
+    private CloseableIterable<RowData> taskRows(CombinedScanTask task) {
+      DataIterator<RowData> rows =
+          new DataIterator<>(fileReader, task, table.io(), table.encryption());
+      return CloseableIterable.combine(() -> rows, rows);
+    }
+  }
+}
diff --git 
a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/InMemoryLookupCache.java
 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/InMemoryLookupCache.java
new file mode 100644
index 0000000000..b6e1a4ebc2
--- /dev/null
+++ 
b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/lookup/InMemoryLookupCache.java
@@ -0,0 +1,69 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import java.io.Closeable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.binary.BinaryRowData;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+
+class InMemoryLookupCache implements Closeable {
+
+  private final Map<BinaryRowData, List<RowData>> cache;
+
+  InMemoryLookupCache() {
+    this.cache = Maps.newHashMap();
+  }
+
+  @Nullable
+  List<RowData> get(BinaryRowData key) {
+    return cache.get(key);
+  }
+
+  void add(BinaryRowData key, BinaryRowData row) {
+    cache.compute(key, (k, existing) -> append(existing, row));
+  }
+
+  private static List<RowData> append(@Nullable List<RowData> existing, 
RowData row) {
+    if (existing == null) {
+      return Collections.singletonList(row);
+    }
+
+    if (existing instanceof ArrayList) {
+      existing.add(row);
+      return existing;
+    }
+
+    List<RowData> rows = Lists.newArrayListWithCapacity(2);
+    rows.add(existing.get(0));
+    rows.add(row);
+    return rows;
+  }
+
+  @Override
+  public void close() {
+    cache.clear();
+  }
+}
diff --git 
a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkSourceSql.java
 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkSourceSql.java
index 2dc5bc5c65..4a3d585e9d 100644
--- 
a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkSourceSql.java
+++ 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkSourceSql.java
@@ -37,7 +37,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 /** Use the FlinkSource */
-public class TestFlinkSourceSql extends TestSqlBase {
+public class TestFlinkSourceSql extends TestSourceSqlBase {
   @BeforeEach
   @Override
   public void before() throws IOException {
diff --git 
a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergLookupJoinSql.java
 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergLookupJoinSql.java
new file mode 100644
index 0000000000..40c13af6d4
--- /dev/null
+++ 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergLookupJoinSql.java
@@ -0,0 +1,369 @@
+/*
+ * 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.iceberg.flink.source;
+
+import static org.apache.iceberg.types.Types.NestedField.required;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.api.TableEnvironment;
+import org.apache.flink.types.Row;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericAppenderHelper;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.flink.FlinkConfigOptions;
+import org.apache.iceberg.flink.TestFixtures;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Tests SQL lookup join with Iceberg table source. */
+class TestIcebergLookupJoinSql extends TestSqlBase {
+  private static final Schema DIM_SCHEMA =
+      new Schema(
+          required(1, "user_id", Types.LongType.get()),
+          required(2, "name", Types.StringType.get()),
+          required(3, "city", Types.StringType.get()));
+
+  @BeforeEach
+  @Override
+  public void before() throws IOException {
+    setUpTableEnv(getStreamingTableEnv());
+  }
+
+  @Test
+  void lookupJoinReturnsMatchedAndUnmatchedRows() throws Exception {
+    createDefaultDimTable();
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders", 5);
+
+    String joinSql = lookupJoinSql("o.order_id, o.user_id, u.name, u.city", 
"orders");
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(
+            Row.of(1L, 1L, "alice", "beijing"),
+            Row.of(2L, 2L, "bob", "shanghai"),
+            Row.of(3L, 3L, "carol", "guangzhou"),
+            Row.of(4L, 4L, null, null),
+            Row.of(5L, 5L, null, null));
+  }
+
+  @Test
+  void lookupJoinWithFilterConditionInJoin() throws Exception {
+    Table dimTable =
+        CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, 
DIM_SCHEMA);
+    GenericAppenderHelper helper =
+        new GenericAppenderHelper(dimTable, FileFormat.PARQUET, 
temporaryFolder);
+    helper.appendToTable(
+        helper.writeFile(
+            Lists.newArrayList(
+                dimRecord(1L, "alice", "beijing"), dimRecord(3L, "carol", 
"beijing"))));
+    DataFile shanghaiFile = helper.writeFile(Lists.newArrayList(dimRecord(2L, 
"bob", "shanghai")));
+    helper.appendToTable(shanghaiFile);
+
+    // The filter must be pushed to the dimension table scan, which skips this 
file by its
+    // column stats. Otherwise loading the cache fails on the missing file.
+    dimTable.io().deleteFile(shanghaiFile.location());
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_with_filter", 4);
+
+    String joinSql =
+        lookupJoinSql(
+            "o.order_id, o.user_id, u.name, u.city",
+            "orders_with_filter",
+            null,
+            "u.city = 'beijing'");
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(
+            Row.of(1L, 1L, "alice", "beijing"),
+            Row.of(2L, 2L, null, null),
+            Row.of(3L, 3L, "carol", "beijing"),
+            Row.of(4L, 4L, null, null));
+  }
+
+  @Test
+  void lookupJoinWithDuplicateKeysInDimTable() throws Exception {
+    // The dimension table has two rows sharing the same join key, so every 
probe row
+    // matching that key must be joined with both of them.
+    createDimTable(
+        dimRecord(1L, "alice", "beijing"),
+        dimRecord(1L, "alice-2", "shanghai"),
+        dimRecord(2L, "bob", "shanghai"));
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_duplicate_keys", 3);
+
+    String joinSql =
+        lookupJoinSql("o.order_id, o.user_id, u.name, u.city", 
"orders_duplicate_keys");
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(
+            Row.of(1L, 1L, "alice", "beijing"),
+            Row.of(1L, 1L, "alice-2", "shanghai"),
+            Row.of(2L, 2L, "bob", "shanghai"),
+            Row.of(3L, 3L, null, null));
+  }
+
+  @Test
+  void lookupJoinWithOptionsInTableDdl() throws Exception {
+    createDefaultDimTable();
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_ddl", 3);
+
+    SqlHelpers.sql(
+        streamEnv,
+        "CREATE TABLE default_catalog.default_database.dim_users_ddl (\n"
+            + "  user_id BIGINT,\n"
+            + "  name    STRING,\n"
+            + "  city    STRING\n"
+            + ") WITH (\n"
+            + "  'connector' = 'iceberg',\n"
+            + "  'catalog-name' = 'iceberg_catalog',\n"
+            + "  'catalog-type' = 'hadoop',\n"
+            + "  'warehouse' = '%s',\n"
+            + "  'catalog-database' = '%s',\n"
+            + "  'catalog-table' = '%s',\n"
+            + "  'lookup.cache' = 'FULL'\n"
+            + ")",
+        CATALOG_EXTENSION.warehouse(),
+        TestFixtures.DATABASE,
+        TestFixtures.TABLE);
+
+    String joinSql =
+        "SELECT o.order_id, o.user_id, u.name, u.city\n"
+            + "FROM orders_ddl AS o\n"
+            + "LEFT JOIN default_catalog.default_database.dim_users_ddl\n"
+            + "  FOR SYSTEM_TIME AS OF o.proc_time AS u\n"
+            + "  ON o.user_id = u.user_id";
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(
+            Row.of(1L, 1L, "alice", "beijing"),
+            Row.of(2L, 2L, "bob", "shanghai"),
+            Row.of(3L, 3L, "carol", "guangzhou"));
+  }
+
+  @Test
+  void lookupJoinAcceptsFullCacheType() throws Exception {
+    createDimTable(dimRecord(1L, "alice", "beijing"));
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_full_cache_options", 1);
+
+    String joinSql =
+        lookupJoinSql(
+            "o.order_id, u.name",
+            "orders_full_cache_options",
+            "OPTIONS('lookup.cache'='FULL')",
+            null);
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql)).containsExactly(Row.of(1L, 
"alice"));
+  }
+
+  @Test
+  void lookupJoinRejectsUnsupportedCacheType() throws Exception {
+    createDimTable(dimRecord(1L, "alice", "beijing"));
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_unsupported_cache", 1);
+
+    String joinSql =
+        lookupJoinSql(
+            "o.order_id, u.name",
+            "orders_unsupported_cache",
+            "OPTIONS('lookup.cache'='PARTIAL')",
+            null);
+
+    assertThatThrownBy(() -> SqlHelpers.sql(streamEnv, joinSql))
+        .as("An Iceberg table cannot be point-looked-up, so PARTIAL must be 
rejected")
+        .hasStackTraceContaining("only supports lookup.cache=FULL");
+  }
+
+  @Test
+  void lookupJoinLoadsCacheEagerlyByDefault() throws Exception {
+    String dataFileLocation = createEmptyProbeAndUnreadableDimTable();
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+
+    assertThatThrownBy(
+            () -> SqlHelpers.sql(streamEnv, lookupJoinSql("o.user_id, u.name", 
"probe_empty")))
+        .as("The cache is loaded when the lookup function is opened, so the 
job fails at startup")
+        .rootCause()
+        .hasMessageContaining(dataFileLocation);
+  }
+
+  @Test
+  void lookupJoinLoadsCacheLazilyWhenEagerLoadDisabled() throws Exception {
+    createEmptyProbeAndUnreadableDimTable();
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+
+    assertThat(
+            SqlHelpers.sql(
+                streamEnv,
+                lookupJoinSql(
+                    "o.user_id, u.name",
+                    "probe_empty",
+                    "OPTIONS('lookup.full-cache.eager-load'='false')",
+                    null)))
+        .as("The cache is loaded on the first lookup, and no probe row is ever 
looked up")
+        .isEmpty();
+  }
+
+  @Test
+  void lookupJoinWithReorderedDimColumns() throws Exception {
+    createDimTable(dimRecord(1L, "alice", "beijing"), dimRecord(2L, "bob", 
"shanghai"));
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_reordered", 2);
+
+    String joinSql = lookupJoinSql("o.order_id, u.city, u.name", 
"orders_reordered");
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(Row.of(1L, "beijing", "alice"), Row.of(2L, 
"shanghai", "bob"));
+  }
+
+  private static String lookupJoinSql(String selectClause, String probeTable) {
+    return lookupJoinSql(selectClause, probeTable, null, null);
+  }
+
+  private static String lookupJoinSql(
+      String selectClause, String probeTable, String options, String 
extraCondition) {
+    return String.format(
+        "SELECT %s\n"
+            + "FROM %s AS o\n"
+            + "LEFT JOIN iceberg_catalog.`%s`.`%s`\n"
+            + "%s"
+            + "  FOR SYSTEM_TIME AS OF o.proc_time AS u\n"
+            + "  ON o.user_id = u.user_id%s",
+        selectClause,
+        probeTable,
+        TestFixtures.DATABASE,
+        TestFixtures.TABLE,
+        options == null ? "" : "  /*+ " + options + " */\n",
+        extraCondition == null ? "" : " AND " + extraCondition);
+  }
+
+  private void createDefaultDimTable() throws IOException {
+    createDimTable(
+        dimRecord(1L, "alice", "beijing"),
+        dimRecord(2L, "bob", "shanghai"),
+        dimRecord(3L, "carol", "guangzhou"));
+  }
+
+  private void createDimTable(Record... records) throws IOException {
+    Table dimTable =
+        CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, 
DIM_SCHEMA);
+    GenericAppenderHelper helper =
+        new GenericAppenderHelper(dimTable, FileFormat.PARQUET, 
temporaryFolder);
+    DataFile dataFile = helper.writeFile(Lists.newArrayList(records));
+    helper.appendToTable(dataFile);
+  }
+
+  private static void createOrdersTable(
+      TableEnvironment tableEnvironment, String tableName, int numberOfRows) {
+    SqlHelpers.sql(
+        tableEnvironment,
+        "CREATE TEMPORARY TABLE %s (\n"
+            + "  order_id BIGINT,\n"
+            + "  user_id  BIGINT,\n"
+            + "  proc_time AS PROCTIME()\n"
+            + ") WITH (\n"
+            + "  'connector' = 'datagen',\n"
+            + "  'number-of-rows' = '%d',\n"
+            + "  'fields.order_id.kind' = 'sequence',\n"
+            + "  'fields.order_id.start' = '1',\n"
+            + "  'fields.order_id.end' = '%d',\n"
+            + "  'fields.user_id.kind' = 'sequence',\n"
+            + "  'fields.user_id.start' = '1',\n"
+            + "  'fields.user_id.end' = '%d'\n"
+            + ")",
+        tableName,
+        numberOfRows,
+        numberOfRows,
+        numberOfRows);
+  }
+
+  private static Record dimRecord(long userId, String name, String city) {
+    Record record = GenericRecord.create(DIM_SCHEMA);
+    record.setField("user_id", userId);
+    record.setField("name", name);
+    record.setField("city", city);
+    return record;
+  }
+
+  private static void setUpTableEnv(TableEnvironment tableEnvironment) {
+    Configuration tableConf = tableEnvironment.getConfig().getConfiguration();
+    tableConf.set(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_FLIP27_SOURCE, 
true);
+    
tableConf.set(FlinkConfigOptions.TABLE_EXEC_ICEBERG_INFER_SOURCE_PARALLELISM, 
false);
+
+    
tableEnvironment.getConfig().set("table.exec.resource.default-parallelism", 
"1");
+    SqlHelpers.sql(
+        tableEnvironment,
+        "create catalog iceberg_catalog with ('type'='iceberg', 
'catalog-type'='hadoop', 'warehouse'='%s')",
+        CATALOG_EXTENSION.warehouse());
+    SqlHelpers.sql(tableEnvironment, "use catalog iceberg_catalog");
+  }
+
+  private String createEmptyProbeAndUnreadableDimTable() throws IOException {
+    Table dimTable =
+        CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, 
DIM_SCHEMA);
+    GenericAppenderHelper helper =
+        new GenericAppenderHelper(dimTable, FileFormat.PARQUET, 
temporaryFolder);
+    DataFile dataFile = helper.writeFile(Lists.newArrayList(dimRecord(1L, 
"alice", "beijing")));
+    helper.appendToTable(dataFile);
+
+    CATALOG_EXTENSION
+        .catalog()
+        .createTable(TableIdentifier.of(TestFixtures.DATABASE, "probe_empty"), 
DIM_SCHEMA);
+
+    SqlHelpers.sql(
+        getStreamingTableEnv(),
+        "CREATE TEMPORARY TABLE probe_empty (\n"
+            + "  user_id BIGINT,\n"
+            + "  name    STRING,\n"
+            + "  city    STRING,\n"
+            + "  proc_time AS PROCTIME()\n"
+            + ") WITH (\n"
+            + "  'connector' = 'iceberg',\n"
+            + "  'catalog-name' = 'iceberg_catalog',\n"
+            + "  'catalog-type' = 'hadoop',\n"
+            + "  'warehouse' = '%s',\n"
+            + "  'catalog-database' = '%s',\n"
+            + "  'catalog-table' = 'probe_empty'\n"
+            + ")",
+        CATALOG_EXTENSION.warehouse(),
+        TestFixtures.DATABASE);
+
+    dimTable.io().deleteFile(dataFile.location());
+    return dataFile.location();
+  }
+}
diff --git 
a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceSql.java
 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceSql.java
index 6cfc18868a..7d52662245 100644
--- 
a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceSql.java
+++ 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceSql.java
@@ -46,7 +46,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 /** Use the IcebergSource (FLIP-27) */
-public class TestIcebergSourceSql extends TestSqlBase {
+public class TestIcebergSourceSql extends TestSourceSqlBase {
   private static final Schema SCHEMA_TS =
       new Schema(
           required(1, "t1", Types.TimestampType.withoutZone()),
diff --git 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestSourceSqlBase.java
similarity index 74%
copy from 
flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
copy to 
flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestSourceSqlBase.java
index fe4c327310..61ae30bbe1 100644
--- 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
+++ 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestSourceSqlBase.java
@@ -18,17 +18,11 @@
  */
 package org.apache.iceberg.flink.source;
 
-import static org.apache.iceberg.flink.TestFixtures.DATABASE;
 import static org.assertj.core.api.Assertions.assertThat;
 
-import java.io.IOException;
-import java.nio.file.Path;
 import java.util.List;
 import java.util.Map;
 import org.apache.flink.configuration.Configuration;
-import org.apache.flink.table.api.EnvironmentSettings;
-import org.apache.flink.table.api.TableEnvironment;
-import org.apache.flink.test.junit5.MiniClusterExtension;
 import org.apache.flink.types.Row;
 import org.apache.iceberg.DataFile;
 import org.apache.iceberg.FileFormat;
@@ -38,60 +32,14 @@ import org.apache.iceberg.data.GenericAppenderHelper;
 import org.apache.iceberg.data.RandomGenericData;
 import org.apache.iceberg.data.Record;
 import org.apache.iceberg.flink.FlinkConfigOptions;
-import org.apache.iceberg.flink.HadoopCatalogExtension;
-import org.apache.iceberg.flink.MiniFlinkClusterExtension;
 import org.apache.iceberg.flink.TableLoader;
 import org.apache.iceberg.flink.TestFixtures;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
 import org.apache.iceberg.relocated.com.google.common.collect.Maps;
-import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.RegisterExtension;
-import org.junit.jupiter.api.io.TempDir;
 
 /** Test other more advanced usage of SQL. They don't need to run for every 
file format. */
-public abstract class TestSqlBase {
-  @RegisterExtension
-  public static MiniClusterExtension miniClusterExtension =
-      MiniFlinkClusterExtension.createWithClassloaderCheckDisabled();
-
-  @RegisterExtension
-  public static final HadoopCatalogExtension CATALOG_EXTENSION =
-      new HadoopCatalogExtension(DATABASE, TestFixtures.TABLE);
-
-  @TempDir protected Path temporaryFolder;
-
-  private volatile TableEnvironment tEnv;
-
-  private volatile TableEnvironment streamingTEnv;
-
-  protected TableEnvironment getTableEnv() {
-    if (tEnv == null) {
-      synchronized (this) {
-        if (tEnv == null) {
-          this.tEnv =
-              
TableEnvironment.create(EnvironmentSettings.newInstance().inBatchMode().build());
-        }
-      }
-    }
-    return tEnv;
-  }
-
-  protected TableEnvironment getStreamingTableEnv() {
-    if (streamingTEnv == null) {
-      synchronized (this) {
-        if (streamingTEnv == null) {
-          this.streamingTEnv =
-              
TableEnvironment.create(EnvironmentSettings.newInstance().inStreamingMode().build());
-        }
-      }
-    }
-
-    return streamingTEnv;
-  }
-
-  @BeforeEach
-  public abstract void before() throws IOException;
+public abstract class TestSourceSqlBase extends TestSqlBase {
 
   @Test
   public void testResiduals() throws Exception {
diff --git 
a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
index dd63154fe0..1ad17af2f3 100644
--- 
a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
+++ 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
@@ -19,37 +19,20 @@
 package org.apache.iceberg.flink.source;
 
 import static org.apache.iceberg.flink.TestFixtures.DATABASE;
-import static org.assertj.core.api.Assertions.assertThat;
 
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.List;
-import java.util.Map;
-import org.apache.flink.configuration.Configuration;
 import org.apache.flink.table.api.EnvironmentSettings;
 import org.apache.flink.table.api.TableEnvironment;
 import org.apache.flink.test.junit5.MiniClusterExtension;
-import org.apache.flink.types.Row;
-import org.apache.iceberg.DataFile;
-import org.apache.iceberg.FileFormat;
-import org.apache.iceberg.Table;
-import org.apache.iceberg.TestHelpers;
-import org.apache.iceberg.data.GenericAppenderHelper;
-import org.apache.iceberg.data.RandomGenericData;
-import org.apache.iceberg.data.Record;
-import org.apache.iceberg.flink.FlinkConfigOptions;
 import org.apache.iceberg.flink.HadoopCatalogExtension;
 import org.apache.iceberg.flink.MiniFlinkClusterExtension;
-import org.apache.iceberg.flink.TableLoader;
 import org.apache.iceberg.flink.TestFixtures;
-import org.apache.iceberg.relocated.com.google.common.collect.Lists;
-import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 import org.junit.jupiter.api.io.TempDir;
 
-/** Test other more advanced usage of SQL. They don't need to run for every 
file format. */
+/** Base class for SQL tests, providing a mini cluster, a Hadoop catalog and 
table environments. */
 public abstract class TestSqlBase {
   @RegisterExtension
   public static MiniClusterExtension miniClusterExtension =
@@ -92,84 +75,4 @@ public abstract class TestSqlBase {
 
   @BeforeEach
   public abstract void before() throws IOException;
-
-  @Test
-  public void testResiduals() throws Exception {
-    Table table =
-        CATALOG_EXTENSION
-            .catalog()
-            .createTable(TestFixtures.TABLE_IDENTIFIER, TestFixtures.SCHEMA, 
TestFixtures.SPEC);
-
-    List<Record> writeRecords = 
RandomGenericData.generate(TestFixtures.SCHEMA, 2, 0L);
-    writeRecords.get(0).set(1, 123L);
-    writeRecords.get(0).set(2, "2020-03-20");
-    writeRecords.get(1).set(1, 456L);
-    writeRecords.get(1).set(2, "2020-03-20");
-
-    GenericAppenderHelper helper =
-        new GenericAppenderHelper(table, FileFormat.PARQUET, temporaryFolder);
-
-    List<Record> expectedRecords = Lists.newArrayList();
-    expectedRecords.add(writeRecords.get(0));
-
-    DataFile dataFile1 = helper.writeFile(TestHelpers.Row.of("2020-03-20", 0), 
writeRecords);
-    DataFile dataFile2 =
-        helper.writeFile(
-            TestHelpers.Row.of("2020-03-21", 0),
-            RandomGenericData.generate(TestFixtures.SCHEMA, 2, 0L));
-    helper.appendToTable(dataFile1, dataFile2);
-
-    org.apache.iceberg.flink.TestHelpers.assertRecords(
-        run(Maps.newHashMap(), "where dt='2020-03-20' and id=123", "*"),
-        expectedRecords,
-        TestFixtures.SCHEMA);
-  }
-
-  @Test
-  public void testExposeLocality() throws Exception {
-    Table table =
-        CATALOG_EXTENSION
-            .catalog()
-            .createTable(TestFixtures.TABLE_IDENTIFIER, TestFixtures.SCHEMA, 
TestFixtures.SPEC);
-
-    TableLoader tableLoader = TableLoader.fromHadoopTable(table.location());
-    List<Record> expectedRecords = 
RandomGenericData.generate(TestFixtures.SCHEMA, 10, 0L);
-    expectedRecords.forEach(expectedRecord -> expectedRecord.set(2, 
"2020-03-20"));
-
-    GenericAppenderHelper helper =
-        new GenericAppenderHelper(table, FileFormat.PARQUET, temporaryFolder);
-    DataFile dataFile = helper.writeFile(TestHelpers.Row.of("2020-03-20", 0), 
expectedRecords);
-    helper.appendToTable(dataFile);
-
-    // test sql api
-    Configuration tableConf = getTableEnv().getConfig().getConfiguration();
-    tableConf.setBoolean(
-        
FlinkConfigOptions.TABLE_EXEC_ICEBERG_EXPOSE_SPLIT_LOCALITY_INFO.key(), false);
-
-    List<Row> results = SqlHelpers.sql(getTableEnv(), "select * from t");
-    org.apache.iceberg.flink.TestHelpers.assertRecords(
-        results, expectedRecords, TestFixtures.SCHEMA);
-
-    // test table api
-    tableConf.setBoolean(
-        
FlinkConfigOptions.TABLE_EXEC_ICEBERG_EXPOSE_SPLIT_LOCALITY_INFO.key(), true);
-    FlinkSource.Builder builder = 
FlinkSource.forRowData().tableLoader(tableLoader).table(table);
-
-    // When running with CI or local, `localityEnabled` will be false even if 
this configuration is
-    // enabled
-    assertThat(SourceUtil.isLocalityEnabled(table, tableConf, true))
-        .as("Expose split locality info should be false.")
-        .isFalse();
-
-    results = run(Maps.newHashMap(), "where dt='2020-03-20'", "*");
-    org.apache.iceberg.flink.TestHelpers.assertRecords(
-        results, expectedRecords, TestFixtures.SCHEMA);
-  }
-
-  protected List<Row> run(
-      Map<String, String> options, String sqlFilter, String... 
sqlSelectedFields) {
-    String select = String.join(",", sqlSelectedFields);
-    String optionStr = SqlHelpers.sqlOptionsToString(options);
-    return SqlHelpers.sql(getTableEnv(), "select %s from t %s %s", select, 
optionStr, sqlFilter);
-  }
 }
diff --git 
a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/lookup/TestIcebergLookup.java
 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/lookup/TestIcebergLookup.java
new file mode 100644
index 0000000000..d13d89d17a
--- /dev/null
+++ 
b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/lookup/TestIcebergLookup.java
@@ -0,0 +1,318 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import static org.apache.iceberg.types.Types.NestedField.optional;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.functions.FunctionContext;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.data.GenericAppenderHelper;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.flink.FlinkSchemaUtil;
+import org.apache.iceberg.flink.HadoopCatalogExtension;
+import org.apache.iceberg.flink.TestFixtures;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+
+class TestIcebergLookup {
+  private static final Schema SCHEMA =
+      new Schema(
+          optional(1, "id", Types.LongType.get()),
+          optional(2, "data", Types.StringType.get()),
+          optional(3, "category", Types.StringType.get()));
+
+  private static final String[] PROJECTED_COLUMNS = {"id", "data", "category"};
+  private static final RowType ROW_TYPE = FlinkSchemaUtil.convert(SCHEMA);
+  private static final int[] ID_KEY_INDEXES = {0};
+  private static final int[] ID_AND_CATEGORY_KEY_INDEXES = {0, 2};
+
+  @TempDir private Path temporaryFolder;
+
+  @RegisterExtension
+  protected static final HadoopCatalogExtension CATALOG_EXTENSION =
+      new HadoopCatalogExtension(TestFixtures.DATABASE, TestFixtures.TABLE);
+
+  private IcebergFullCachingLookupFunction lookupFunction;
+
+  @AfterEach
+  void after() throws Exception {
+    if (lookupFunction != null) {
+      lookupFunction.close();
+    }
+  }
+
+  @Test
+  void lookupReaderReadsWithBaseFilters() throws Exception {
+    Table table = createTableWithRecords();
+    IcebergLookupReader reader =
+        lookupReader(table, ImmutableList.of(Expressions.equal("category", 
"B")), false);
+
+    List<List<Object>> rows = Lists.newArrayList();
+    try (CloseableIterable<RowData> rowData = 
reader.read(IcebergLookupReader.CURRENT_SNAPSHOT)) {
+      for (RowData row : rowData) {
+        rows.add(
+            ImmutableList.of(
+                row.getLong(0), row.getString(1).toString(), 
row.getString(2).toString()));
+      }
+    }
+
+    assertThat(rows).containsExactly(ImmutableList.of(2L, "bob", "B"));
+  }
+
+  @Test
+  void lookupReaderRespectsCaseSensitivity() throws Exception {
+    Table table = createTableWithRecords();
+    List<Expression> filters = ImmutableList.of(Expressions.equal("CATEGORY", 
"B"));
+
+    IcebergLookupReader caseSensitiveReader = lookupReader(table, filters, 
true);
+    assertThatThrownBy(() -> readIds(caseSensitiveReader, 
IcebergLookupReader.CURRENT_SNAPSHOT))
+        .as("Case sensitive lookup should reject a filter that doesn't match 
the column case")
+        .isInstanceOf(ValidationException.class)
+        .hasMessageContaining("CATEGORY");
+
+    assertThat(readIds(lookupReader(table, filters, false), 
IcebergLookupReader.CURRENT_SNAPSHOT))
+        .containsExactly(2L);
+  }
+
+  @Test
+  void lookupReaderReadsPinnedSnapshot() throws Exception {
+    Table table = createTableWithRecords();
+    long pinnedSnapshot = table.currentSnapshot().snapshotId();
+
+    appendRecords(table, ImmutableList.of(record(6L, "frank", "D")));
+
+    IcebergLookupReader reader = lookupReader(table, ImmutableList.of(), 
false);
+
+    assertThat(readIds(reader, pinnedSnapshot))
+        .as("Pinned snapshot should not contain the appended row")
+        .containsExactlyInAnyOrder(1L, 2L, 3L);
+    assertThat(readIds(reader, IcebergLookupReader.CURRENT_SNAPSHOT))
+        .as("Current snapshot should contain the appended row")
+        .containsExactlyInAnyOrder(1L, 2L, 3L, 6L);
+  }
+
+  @Test
+  void lookupFunctionReturnsRowsFromCache() throws Exception {
+    Table table = createTableWithRecords();
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, false);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 1L, "alice", "A"));
+
+    appendRecords(table, ImmutableList.of(record(6L, "frank", "D")));
+
+    assertThat(lookupFunction.lookup(keyRow(6L))).isEmpty();
+    assertThat(lookupFunction.lookup(keyRow(404L))).isEmpty();
+    assertThat(lookupFunction.lookup(keyRow(1L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 1L, "alice", "A"));
+  }
+
+  @Test
+  void lookupFunctionWithMultiColumnKey() throws Exception {
+    Table table = createTableWithRecords();
+
+    lookupFunction = newLookupFunction(ID_AND_CATEGORY_KEY_INDEXES, false);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L, "A")))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 1L, "alice", "A"));
+
+    assertThat(lookupFunction.lookup(keyRow(1L, "B"))).isEmpty();
+
+    appendRecords(table, ImmutableList.of(record(3L, "carol-2", "A")));
+
+    assertThat(lookupFunction.lookup(keyRow(3L, "A")))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 3L, "carol", "A"));
+  }
+
+  @Test
+  void lookupFunctionLoadsCacheLazily() throws Exception {
+    Table table = createTableWithRecords();
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, false);
+    lookupFunction.open(new FunctionContext(null));
+
+    // Nothing is loaded yet, so the first lookup sees the row appended after 
opening.
+    appendRecords(table, ImmutableList.of(record(6L, "frank", "D")));
+
+    assertThat(lookupFunction.lookup(keyRow(6L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 6L, "frank", "D"));
+  }
+
+  @Test
+  void lookupFunctionLoadsCacheEagerly() throws Exception {
+    Table table = createTableWithRecords();
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, true);
+    lookupFunction.open(new FunctionContext(null));
+
+    appendRecords(table, ImmutableList.of(record(6L, "frank", "D")));
+
+    assertThat(lookupFunction.lookup(keyRow(6L))).isEmpty();
+    assertThat(lookupFunction.lookup(keyRow(1L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 1L, "alice", "A"));
+  }
+
+  @Test
+  void lookupFunctionHandlesEmptyTable() throws Exception {
+    CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, 
SCHEMA);
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, true);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L))).isEmpty();
+  }
+
+  @Test
+  void lookupFunctionHandlesMultipleRowsPerKey() throws Exception {
+    Table table = 
CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, SCHEMA);
+    appendRecords(
+        table,
+        ImmutableList.of(
+            record(1L, "alice", "A"), record(1L, "alice-2", "A"), record(2L, 
"bob", "B")));
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, false);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L)))
+        .hasSize(2)
+        .allSatisfy(row -> assertThat(row.getLong(0)).isEqualTo(1L));
+    assertThat(lookupFunction.lookup(keyRow(2L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 2L, "bob", "B"));
+  }
+
+  @Test
+  void lookupFunctionAppliesPushedFilters() throws Exception {
+    createTableWithRecords();
+
+    lookupFunction =
+        new IcebergFullCachingLookupFunction(
+            CATALOG_EXTENSION.tableLoader().clone(),
+            ROW_TYPE,
+            ID_KEY_INDEXES,
+            ImmutableList.of(Expressions.equal("category", "B")),
+            true,
+            false);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L))).isEmpty();
+    assertThat(lookupFunction.lookup(keyRow(2L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 2L, "bob", "B"));
+  }
+
+  private IcebergFullCachingLookupFunction newLookupFunction(
+      int[] lookupKeyIndexes, boolean eagerLoad) {
+    return new IcebergFullCachingLookupFunction(
+        CATALOG_EXTENSION.tableLoader().clone(),
+        ROW_TYPE,
+        lookupKeyIndexes,
+        ImmutableList.of(),
+        false,
+        eagerLoad);
+  }
+
+  private static IcebergLookupReader lookupReader(
+      Table table, List<Expression> filters, boolean caseSensitive) {
+    return new IcebergLookupReader(
+        table, SCHEMA.select(PROJECTED_COLUMNS), filters, caseSensitive, null);
+  }
+
+  private static List<Long> readIds(IcebergLookupReader reader, long 
snapshotId)
+      throws IOException {
+    List<Long> ids = Lists.newArrayList();
+    try (CloseableIterable<RowData> rows = reader.read(snapshotId)) {
+      for (RowData row : rows) {
+        ids.add(row.isNullAt(0) ? null : row.getLong(0));
+      }
+    }
+
+    return ids;
+  }
+
+  private Table createTableWithRecords() throws Exception {
+    Table table = 
CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, SCHEMA);
+    appendRecords(
+        table,
+        ImmutableList.of(
+            record(1L, "alice", "A"), record(2L, "bob", "B"), record(3L, 
"carol", "A")));
+    return table;
+  }
+
+  private void appendRecords(Table table, List<Record> records) throws 
Exception {
+    new GenericAppenderHelper(table, FileFormat.PARQUET, 
temporaryFolder).appendToTable(records);
+  }
+
+  private static GenericRecord record(Long id, String data, String category) {
+    GenericRecord record = GenericRecord.create(SCHEMA);
+    record.setField("id", id);
+    record.setField("data", data);
+    record.setField("category", category);
+    return record;
+  }
+
+  private static RowData keyRow(Long id) {
+    GenericRowData row = new GenericRowData(1);
+    row.setField(0, id);
+    return row;
+  }
+
+  private static RowData keyRow(Long id, String category) {
+    GenericRowData row = new GenericRowData(2);
+    row.setField(0, id);
+    row.setField(1, StringData.fromString(category));
+    return row;
+  }
+
+  private static void assertRow(RowData row, long id, String data, String 
category) {
+    assertThat(row.getLong(0)).isEqualTo(id);
+    assertThat(row.getString(1)).hasToString(data);
+    assertThat(row.getString(2)).hasToString(category);
+  }
+}
diff --git 
a/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java
 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java
index 6306ee7a0a..0c7244dc23 100644
--- 
a/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java
+++ 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java
@@ -39,6 +39,7 @@ import 
org.apache.flink.table.factories.DynamicTableSourceFactory;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.flink.source.IcebergTableSource;
+import org.apache.iceberg.flink.source.lookup.IcebergLookupOptions;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.apache.iceberg.relocated.com.google.common.collect.Sets;
@@ -132,6 +133,7 @@ public class FlinkDynamicTableFactory
     options.add(FlinkCreateTableOptions.CATALOG_TABLE);
     options.add(FlinkCreateTableOptions.USE_DYNAMIC_ICEBERG_SINK);
     options.add(FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL);
+    options.add(IcebergLookupOptions.FULL_CACHE_EAGER_LOAD);
     return options;
   }
 
diff --git 
a/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java
 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java
index 40df25b59f..759b316667 100644
--- 
a/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java
+++ 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java
@@ -33,22 +33,30 @@ import org.apache.flink.table.connector.ChangelogMode;
 import org.apache.flink.table.connector.ProviderContext;
 import org.apache.flink.table.connector.source.DataStreamScanProvider;
 import org.apache.flink.table.connector.source.DynamicTableSource;
+import org.apache.flink.table.connector.source.LookupTableSource;
 import org.apache.flink.table.connector.source.ScanTableSource;
 import 
org.apache.flink.table.connector.source.abilities.SupportsFilterPushDown;
 import org.apache.flink.table.connector.source.abilities.SupportsLimitPushDown;
 import 
org.apache.flink.table.connector.source.abilities.SupportsProjectionPushDown;
 import 
org.apache.flink.table.connector.source.abilities.SupportsSourceWatermark;
+import org.apache.flink.table.connector.source.lookup.LookupFunctionProvider;
+import org.apache.flink.table.connector.source.lookup.LookupOptions;
 import org.apache.flink.table.data.RowData;
 import org.apache.flink.table.expressions.ResolvedExpression;
 import org.apache.flink.table.factories.FactoryUtil;
+import org.apache.flink.table.functions.LookupFunction;
 import org.apache.flink.table.legacy.api.TableSchema;
 import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.RowType;
 import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.flink.FlinkConfParser;
 import org.apache.iceberg.flink.FlinkConfigOptions;
 import org.apache.iceberg.flink.FlinkFilters;
 import org.apache.iceberg.flink.FlinkReadOptions;
 import org.apache.iceberg.flink.TableLoader;
 import org.apache.iceberg.flink.source.assigner.SplitAssignerType;
+import org.apache.iceberg.flink.source.lookup.IcebergFullCachingLookupFunction;
+import org.apache.iceberg.flink.source.lookup.IcebergLookupOptions;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
@@ -61,7 +69,8 @@ public class IcebergTableSource
         SupportsProjectionPushDown,
         SupportsFilterPushDown,
         SupportsLimitPushDown,
-        SupportsSourceWatermark {
+        SupportsSourceWatermark,
+        LookupTableSource {
 
   private int[] projectedFields;
   private Long limit;
@@ -72,6 +81,7 @@ public class IcebergTableSource
   private final Map<String, String> properties;
   private final boolean isLimitPushDown;
   private final ReadableConfig readableConfig;
+  private final FlinkConfParser flinkConfParser;
 
   private IcebergTableSource(IcebergTableSource toCopy) {
     this.loader = toCopy.loader;
@@ -82,6 +92,7 @@ public class IcebergTableSource
     this.limit = toCopy.limit;
     this.filters = toCopy.filters;
     this.readableConfig = toCopy.readableConfig;
+    this.flinkConfParser = toCopy.flinkConfParser;
   }
 
   public IcebergTableSource(
@@ -109,6 +120,7 @@ public class IcebergTableSource
     this.limit = limit;
     this.filters = filters;
     this.readableConfig = readableConfig;
+    this.flinkConfParser = new FlinkConfParser(properties, readableConfig);
   }
 
   @Override
@@ -237,4 +249,50 @@ public class IcebergTableSource
   public String asSummaryString() {
     return "Iceberg table source";
   }
+
+  @Override
+  public LookupRuntimeProvider getLookupRuntimeProvider(LookupContext context) 
{
+    int[][] lookupKeys = context.getKeys();
+    int[] lookupKeyIndexes = new int[lookupKeys.length];
+    for (int i = 0; i < lookupKeys.length; i++) {
+      Preconditions.checkArgument(
+          lookupKeys[i].length == 1, "Iceberg lookup source doesn't support 
nested lookup key.");
+      lookupKeyIndexes[i] = lookupKeys[i][0];
+    }
+
+    ResolvedSchema projected = getProjectedSchema();
+    RowType projectedRowType = (RowType) 
projected.toPhysicalRowDataType().getLogicalType();
+
+    LookupOptions.LookupCacheType requestedCacheType =
+        flinkConfParser
+            .enumConfParser(LookupOptions.LookupCacheType.class)
+            .option(LookupOptions.CACHE_TYPE.key())
+            .parseOptional();
+    Preconditions.checkArgument(
+        requestedCacheType == null || requestedCacheType == 
LookupOptions.LookupCacheType.FULL,
+        "Iceberg lookup join only supports %s=FULL, but it is set to '%s'. 
NONE and PARTIAL are "
+            + "not supported, because an Iceberg table cannot be 
point-looked-up.",
+        LookupOptions.CACHE_TYPE.key(),
+        requestedCacheType);
+
+    boolean eagerLoad =
+        flinkConfParser
+            .booleanConf()
+            .option(IcebergLookupOptions.FULL_CACHE_EAGER_LOAD.key())
+            
.defaultValue(IcebergLookupOptions.FULL_CACHE_EAGER_LOAD.defaultValue())
+            .parse();
+
+    boolean caseSensitive =
+        flinkConfParser
+            .booleanConf()
+            .option(FlinkReadOptions.CASE_SENSITIVE)
+            .flinkConfig(FlinkReadOptions.CASE_SENSITIVE_OPTION)
+            
.defaultValue(FlinkReadOptions.CASE_SENSITIVE_OPTION.defaultValue())
+            .parse();
+
+    LookupFunction lookupFn =
+        new IcebergFullCachingLookupFunction(
+            loader, projectedRowType, lookupKeyIndexes, filters, 
caseSensitive, eagerLoad);
+    return LookupFunctionProvider.of(lookupFn);
+  }
 }
diff --git 
a/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergFullCachingLookupFunction.java
 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergFullCachingLookupFunction.java
new file mode 100644
index 0000000000..d7e78913e3
--- /dev/null
+++ 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergFullCachingLookupFunction.java
@@ -0,0 +1,223 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.metrics.Counter;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.binary.BinaryRowData;
+import org.apache.flink.table.functions.FunctionContext;
+import org.apache.flink.table.functions.LookupFunction;
+import org.apache.flink.table.runtime.typeutils.RowDataSerializer;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.flink.FlinkRowData;
+import org.apache.iceberg.flink.FlinkSchemaUtil;
+import org.apache.iceberg.flink.TableLoader;
+import org.apache.iceberg.io.CloseableIterable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A lookup function that serves every lookup from an in-memory cache of the 
rows of an Iceberg
+ * table, after projection and pushed-down filters are applied.
+ *
+ * <p>The cache is loaded from the current snapshot either when the function 
is opened or on the
+ * first lookup, depending on {@link 
IcebergLookupOptions#FULL_CACHE_EAGER_LOAD}. It is never
+ * refreshed, so later changes to the table are not visible.
+ */
+@Internal
+public class IcebergFullCachingLookupFunction extends LookupFunction {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IcebergFullCachingLookupFunction.class);
+
+  private static final String METRIC_GROUP = "cache";
+  private static final long UNINITIALIZED = -1L;
+
+  private final TableLoader tableLoader;
+  private final RowType projectedRowType;
+  private final int[] lookupKeyIndexes;
+  private final List<Expression> pushedFilters;
+  private final boolean caseSensitive;
+  private final boolean eagerLoad;
+
+  private transient Table table;
+  private transient IcebergLookupReader reader;
+  private transient RowDataSerializer rowSerializer;
+  private transient RowDataSerializer keySerializer;
+  private transient RowData.FieldGetter[] keyGetters;
+  private transient InMemoryLookupCache cache;
+
+  private transient Counter missCounter;
+  private transient volatile long currentSnapshotId;
+  private transient volatile long cachedRecords;
+  private transient volatile long latestLoadTimeMs;
+
+  public IcebergFullCachingLookupFunction(
+      TableLoader tableLoader,
+      RowType projectedRowType,
+      int[] lookupKeyIndexes,
+      List<Expression> pushedFilters,
+      boolean caseSensitive,
+      boolean eagerLoad) {
+    this.tableLoader = tableLoader;
+    this.projectedRowType = projectedRowType;
+    this.lookupKeyIndexes = lookupKeyIndexes;
+    this.pushedFilters = pushedFilters;
+    this.caseSensitive = caseSensitive;
+    this.eagerLoad = eagerLoad;
+  }
+
+  @Override
+  public void open(FunctionContext context) throws Exception {
+    super.open(context);
+    LOG.info(
+        "IcebergFullCachingLookupFunction opening, projected fields={}, 
lookupKeyIndexes={}",
+        projectedRowType.getFieldNames(),
+        Arrays.toString(lookupKeyIndexes));
+
+    MetricGroup metricGroup = context.getMetricGroup().addGroup(METRIC_GROUP);
+    this.missCounter = metricGroup.counter("missCount");
+    this.currentSnapshotId = IcebergLookupReader.CURRENT_SNAPSHOT;
+    this.cachedRecords = 0L;
+    this.latestLoadTimeMs = UNINITIALIZED;
+    metricGroup.gauge("snapshotId", () -> currentSnapshotId);
+    metricGroup.gauge("numCachedRecords", () -> cachedRecords);
+    metricGroup.gauge("latestLoadTime", () -> latestLoadTimeMs);
+
+    tableLoader.open();
+    this.table = tableLoader.loadTable();
+
+    Schema icebergProjection =
+        FlinkSchemaUtil.convert(table.schema(), 
FlinkSchemaUtil.toResolvedSchema(projectedRowType));
+
+    createAccessors();
+
+    String nameMapping = 
table.properties().get(TableProperties.DEFAULT_NAME_MAPPING);
+    this.reader =
+        new IcebergLookupReader(
+            table, icebergProjection, pushedFilters, caseSensitive, 
nameMapping);
+
+    if (eagerLoad) {
+      loadCache();
+    }
+  }
+
+  /**
+   * Returns the cached rows for the key. The returned collection and its rows 
are shared with the
+   * cache, so they must not be modified.
+   */
+  @Override
+  public Collection<RowData> lookup(RowData keyRow) throws IOException {
+    if (cache == null) {
+      loadCache();
+    }
+
+    List<RowData> hit = cache.get(keySerializer.toBinaryRow(keyRow));
+    if (hit == null) {
+      missCounter.inc();
+      return Collections.emptyList();
+    }
+
+    return hit;
+  }
+
+  @Override
+  public void close() throws Exception {
+    try {
+      tableLoader.close();
+    } finally {
+      if (cache != null) {
+        cache.close();
+      }
+
+      super.close();
+    }
+  }
+
+  private void loadCache() throws IOException {
+    table.refresh();
+    Snapshot snapshot = table.currentSnapshot();
+    long snapshotId =
+        snapshot == null ? IcebergLookupReader.CURRENT_SNAPSHOT : 
snapshot.snapshotId();
+
+    LOG.info(
+        "IcebergFullCachingLookupFunction loading started, snapshot={}, 
committedAt={}, projected fields={}, pushedFilters={}",
+        snapshot == null ? "none" : snapshotId,
+        snapshot == null ? "n/a" : snapshot.timestampMillis(),
+        projectedRowType.getFieldNames(),
+        pushedFilters);
+
+    InMemoryLookupCache loaded = new InMemoryLookupCache();
+    long rowCnt = 0L;
+    long start = System.currentTimeMillis();
+    try (CloseableIterable<RowData> rows = reader.read(snapshotId)) {
+      for (RowData row : rows) {
+        loaded.add(extractKey(row), rowSerializer.toBinaryRow(row).copy());
+        rowCnt++;
+      }
+    } finally {
+      this.latestLoadTimeMs = System.currentTimeMillis() - start;
+    }
+
+    this.cache = loaded;
+    this.currentSnapshotId = snapshotId;
+    this.cachedRecords = rowCnt;
+
+    LOG.info(
+        "IcebergFullCachingLookupFunction loading finished, snapshot={}, 
rows={}, cost={} ms",
+        snapshot == null ? "none" : snapshotId,
+        rowCnt,
+        latestLoadTimeMs);
+  }
+
+  private BinaryRowData extractKey(RowData row) {
+    GenericRowData key = new GenericRowData(keyGetters.length);
+    for (int i = 0; i < keyGetters.length; i++) {
+      key.setField(i, keyGetters[i].getFieldOrNull(row));
+    }
+
+    return keySerializer.toBinaryRow(key).copy();
+  }
+
+  private void createAccessors() {
+    LogicalType[] keyTypes = new LogicalType[lookupKeyIndexes.length];
+    this.keyGetters = new RowData.FieldGetter[lookupKeyIndexes.length];
+    for (int i = 0; i < lookupKeyIndexes.length; i++) {
+      int projectedIndex = lookupKeyIndexes[i];
+      keyTypes[i] = projectedRowType.getTypeAt(projectedIndex);
+      this.keyGetters[i] = FlinkRowData.createFieldGetter(keyTypes[i], 
projectedIndex);
+    }
+
+    this.rowSerializer = new RowDataSerializer(projectedRowType);
+    this.keySerializer = new RowDataSerializer(keyTypes);
+  }
+}
diff --git 
a/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupOptions.java
 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupOptions.java
new file mode 100644
index 0000000000..355fd5387f
--- /dev/null
+++ 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupOptions.java
@@ -0,0 +1,41 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.configuration.ConfigOption;
+import org.apache.flink.configuration.ConfigOptions;
+
+/** Lookup join options for the Iceberg table source. */
+@Internal
+public class IcebergLookupOptions {
+
+  public static final ConfigOption<Boolean> FULL_CACHE_EAGER_LOAD =
+      ConfigOptions.key("lookup.full-cache.eager-load")
+          .booleanType()
+          .defaultValue(true)
+          .withDescription(
+              "Whether to load the full lookup cache when the lookup function 
is opened, instead "
+                  + "of on the first lookup. Eager loading narrows the window 
in which the subtasks "
+                  + "of the join can end up on different snapshots of the 
dimension table, and "
+                  + "fails the job at startup if the table cannot be read, at 
the cost of a longer "
+                  + "deployment.");
+
+  private IcebergLookupOptions() {}
+}
diff --git 
a/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupReader.java
 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupReader.java
new file mode 100644
index 0000000000..d669178557
--- /dev/null
+++ 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/IcebergLookupReader.java
@@ -0,0 +1,124 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import java.io.IOException;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.table.data.RowData;
+import org.apache.iceberg.CombinedScanTask;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableScan;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.flink.source.DataIterator;
+import org.apache.iceberg.flink.source.FileScanTaskReader;
+import org.apache.iceberg.flink.source.RowDataFileScanTaskReader;
+import org.apache.iceberg.io.CloseableGroup;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.CloseableIterator;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.util.SnapshotUtil;
+
+/** Reads all rows of an Iceberg table to build a lookup cache. */
+@Internal
+class IcebergLookupReader {
+
+  static final long CURRENT_SNAPSHOT = -1L;
+
+  private final Table table;
+  private final Schema projectedSchema;
+  private final List<Expression> baseFilters;
+  private final Expression filter;
+  private final boolean caseSensitive;
+  private final String nameMapping;
+
+  IcebergLookupReader(
+      Table table,
+      Schema projectedSchema,
+      List<Expression> baseFilters,
+      boolean caseSensitive,
+      @Nullable String nameMapping) {
+    Preconditions.checkNotNull(baseFilters, "Base filters should not be null");
+
+    this.table = table;
+    this.projectedSchema = projectedSchema;
+    this.baseFilters = baseFilters;
+    this.caseSensitive = caseSensitive;
+    this.nameMapping = nameMapping;
+
+    Expression combinedFilter = Expressions.alwaysTrue();
+    for (Expression baseFilter : this.baseFilters) {
+      combinedFilter = Expressions.and(combinedFilter, baseFilter);
+    }
+
+    this.filter = combinedFilter;
+  }
+
+  CloseableIterable<RowData> read(long snapshotId) {
+    Schema tableSchema =
+        snapshotId == CURRENT_SNAPSHOT ? table.schema() : 
SnapshotUtil.schemaFor(table, snapshotId);
+
+    RowDataFileScanTaskReader fileReader =
+        new RowDataFileScanTaskReader(
+            tableSchema, projectedSchema, nameMapping, caseSensitive, 
baseFilters);
+
+    TableScan scan =
+        
table.newScan().caseSensitive(caseSensitive).project(projectedSchema).filter(filter);
+    if (snapshotId != CURRENT_SNAPSHOT) {
+      scan = scan.useSnapshot(snapshotId);
+    }
+
+    return new LookupIterable(scan.planTasks(), fileReader);
+  }
+
+  private class LookupIterable extends CloseableGroup implements 
CloseableIterable<RowData> {
+    private final CloseableIterable<CombinedScanTask> tasks;
+    private final FileScanTaskReader<RowData> fileReader;
+
+    private LookupIterable(
+        CloseableIterable<CombinedScanTask> tasks, FileScanTaskReader<RowData> 
fileReader) {
+      this.tasks = tasks;
+      this.fileReader = fileReader;
+    }
+
+    @Override
+    public CloseableIterator<RowData> iterator() {
+      CloseableIterator<RowData> rows =
+          CloseableIterable.concat(Iterables.transform(tasks, 
this::taskRows)).iterator();
+      addCloseable(rows);
+      return rows;
+    }
+
+    @Override
+    public void close() throws IOException {
+      tasks.close();
+      super.close();
+    }
+
+    private CloseableIterable<RowData> taskRows(CombinedScanTask task) {
+      DataIterator<RowData> rows =
+          new DataIterator<>(fileReader, task, table.io(), table.encryption());
+      return CloseableIterable.combine(() -> rows, rows);
+    }
+  }
+}
diff --git 
a/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/InMemoryLookupCache.java
 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/InMemoryLookupCache.java
new file mode 100644
index 0000000000..b6e1a4ebc2
--- /dev/null
+++ 
b/flink/v2.2/flink/src/main/java/org/apache/iceberg/flink/source/lookup/InMemoryLookupCache.java
@@ -0,0 +1,69 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import java.io.Closeable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.binary.BinaryRowData;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+
+class InMemoryLookupCache implements Closeable {
+
+  private final Map<BinaryRowData, List<RowData>> cache;
+
+  InMemoryLookupCache() {
+    this.cache = Maps.newHashMap();
+  }
+
+  @Nullable
+  List<RowData> get(BinaryRowData key) {
+    return cache.get(key);
+  }
+
+  void add(BinaryRowData key, BinaryRowData row) {
+    cache.compute(key, (k, existing) -> append(existing, row));
+  }
+
+  private static List<RowData> append(@Nullable List<RowData> existing, 
RowData row) {
+    if (existing == null) {
+      return Collections.singletonList(row);
+    }
+
+    if (existing instanceof ArrayList) {
+      existing.add(row);
+      return existing;
+    }
+
+    List<RowData> rows = Lists.newArrayListWithCapacity(2);
+    rows.add(existing.get(0));
+    rows.add(row);
+    return rows;
+  }
+
+  @Override
+  public void close() {
+    cache.clear();
+  }
+}
diff --git 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkSourceSql.java
 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkSourceSql.java
index 2dc5bc5c65..4a3d585e9d 100644
--- 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkSourceSql.java
+++ 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkSourceSql.java
@@ -37,7 +37,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 /** Use the FlinkSource */
-public class TestFlinkSourceSql extends TestSqlBase {
+public class TestFlinkSourceSql extends TestSourceSqlBase {
   @BeforeEach
   @Override
   public void before() throws IOException {
diff --git 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergLookupJoinSql.java
 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergLookupJoinSql.java
new file mode 100644
index 0000000000..40c13af6d4
--- /dev/null
+++ 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergLookupJoinSql.java
@@ -0,0 +1,369 @@
+/*
+ * 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.iceberg.flink.source;
+
+import static org.apache.iceberg.types.Types.NestedField.required;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.api.TableEnvironment;
+import org.apache.flink.types.Row;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericAppenderHelper;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.flink.FlinkConfigOptions;
+import org.apache.iceberg.flink.TestFixtures;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Tests SQL lookup join with Iceberg table source. */
+class TestIcebergLookupJoinSql extends TestSqlBase {
+  private static final Schema DIM_SCHEMA =
+      new Schema(
+          required(1, "user_id", Types.LongType.get()),
+          required(2, "name", Types.StringType.get()),
+          required(3, "city", Types.StringType.get()));
+
+  @BeforeEach
+  @Override
+  public void before() throws IOException {
+    setUpTableEnv(getStreamingTableEnv());
+  }
+
+  @Test
+  void lookupJoinReturnsMatchedAndUnmatchedRows() throws Exception {
+    createDefaultDimTable();
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders", 5);
+
+    String joinSql = lookupJoinSql("o.order_id, o.user_id, u.name, u.city", 
"orders");
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(
+            Row.of(1L, 1L, "alice", "beijing"),
+            Row.of(2L, 2L, "bob", "shanghai"),
+            Row.of(3L, 3L, "carol", "guangzhou"),
+            Row.of(4L, 4L, null, null),
+            Row.of(5L, 5L, null, null));
+  }
+
+  @Test
+  void lookupJoinWithFilterConditionInJoin() throws Exception {
+    Table dimTable =
+        CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, 
DIM_SCHEMA);
+    GenericAppenderHelper helper =
+        new GenericAppenderHelper(dimTable, FileFormat.PARQUET, 
temporaryFolder);
+    helper.appendToTable(
+        helper.writeFile(
+            Lists.newArrayList(
+                dimRecord(1L, "alice", "beijing"), dimRecord(3L, "carol", 
"beijing"))));
+    DataFile shanghaiFile = helper.writeFile(Lists.newArrayList(dimRecord(2L, 
"bob", "shanghai")));
+    helper.appendToTable(shanghaiFile);
+
+    // The filter must be pushed to the dimension table scan, which skips this 
file by its
+    // column stats. Otherwise loading the cache fails on the missing file.
+    dimTable.io().deleteFile(shanghaiFile.location());
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_with_filter", 4);
+
+    String joinSql =
+        lookupJoinSql(
+            "o.order_id, o.user_id, u.name, u.city",
+            "orders_with_filter",
+            null,
+            "u.city = 'beijing'");
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(
+            Row.of(1L, 1L, "alice", "beijing"),
+            Row.of(2L, 2L, null, null),
+            Row.of(3L, 3L, "carol", "beijing"),
+            Row.of(4L, 4L, null, null));
+  }
+
+  @Test
+  void lookupJoinWithDuplicateKeysInDimTable() throws Exception {
+    // The dimension table has two rows sharing the same join key, so every 
probe row
+    // matching that key must be joined with both of them.
+    createDimTable(
+        dimRecord(1L, "alice", "beijing"),
+        dimRecord(1L, "alice-2", "shanghai"),
+        dimRecord(2L, "bob", "shanghai"));
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_duplicate_keys", 3);
+
+    String joinSql =
+        lookupJoinSql("o.order_id, o.user_id, u.name, u.city", 
"orders_duplicate_keys");
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(
+            Row.of(1L, 1L, "alice", "beijing"),
+            Row.of(1L, 1L, "alice-2", "shanghai"),
+            Row.of(2L, 2L, "bob", "shanghai"),
+            Row.of(3L, 3L, null, null));
+  }
+
+  @Test
+  void lookupJoinWithOptionsInTableDdl() throws Exception {
+    createDefaultDimTable();
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_ddl", 3);
+
+    SqlHelpers.sql(
+        streamEnv,
+        "CREATE TABLE default_catalog.default_database.dim_users_ddl (\n"
+            + "  user_id BIGINT,\n"
+            + "  name    STRING,\n"
+            + "  city    STRING\n"
+            + ") WITH (\n"
+            + "  'connector' = 'iceberg',\n"
+            + "  'catalog-name' = 'iceberg_catalog',\n"
+            + "  'catalog-type' = 'hadoop',\n"
+            + "  'warehouse' = '%s',\n"
+            + "  'catalog-database' = '%s',\n"
+            + "  'catalog-table' = '%s',\n"
+            + "  'lookup.cache' = 'FULL'\n"
+            + ")",
+        CATALOG_EXTENSION.warehouse(),
+        TestFixtures.DATABASE,
+        TestFixtures.TABLE);
+
+    String joinSql =
+        "SELECT o.order_id, o.user_id, u.name, u.city\n"
+            + "FROM orders_ddl AS o\n"
+            + "LEFT JOIN default_catalog.default_database.dim_users_ddl\n"
+            + "  FOR SYSTEM_TIME AS OF o.proc_time AS u\n"
+            + "  ON o.user_id = u.user_id";
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(
+            Row.of(1L, 1L, "alice", "beijing"),
+            Row.of(2L, 2L, "bob", "shanghai"),
+            Row.of(3L, 3L, "carol", "guangzhou"));
+  }
+
+  @Test
+  void lookupJoinAcceptsFullCacheType() throws Exception {
+    createDimTable(dimRecord(1L, "alice", "beijing"));
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_full_cache_options", 1);
+
+    String joinSql =
+        lookupJoinSql(
+            "o.order_id, u.name",
+            "orders_full_cache_options",
+            "OPTIONS('lookup.cache'='FULL')",
+            null);
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql)).containsExactly(Row.of(1L, 
"alice"));
+  }
+
+  @Test
+  void lookupJoinRejectsUnsupportedCacheType() throws Exception {
+    createDimTable(dimRecord(1L, "alice", "beijing"));
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_unsupported_cache", 1);
+
+    String joinSql =
+        lookupJoinSql(
+            "o.order_id, u.name",
+            "orders_unsupported_cache",
+            "OPTIONS('lookup.cache'='PARTIAL')",
+            null);
+
+    assertThatThrownBy(() -> SqlHelpers.sql(streamEnv, joinSql))
+        .as("An Iceberg table cannot be point-looked-up, so PARTIAL must be 
rejected")
+        .hasStackTraceContaining("only supports lookup.cache=FULL");
+  }
+
+  @Test
+  void lookupJoinLoadsCacheEagerlyByDefault() throws Exception {
+    String dataFileLocation = createEmptyProbeAndUnreadableDimTable();
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+
+    assertThatThrownBy(
+            () -> SqlHelpers.sql(streamEnv, lookupJoinSql("o.user_id, u.name", 
"probe_empty")))
+        .as("The cache is loaded when the lookup function is opened, so the 
job fails at startup")
+        .rootCause()
+        .hasMessageContaining(dataFileLocation);
+  }
+
+  @Test
+  void lookupJoinLoadsCacheLazilyWhenEagerLoadDisabled() throws Exception {
+    createEmptyProbeAndUnreadableDimTable();
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+
+    assertThat(
+            SqlHelpers.sql(
+                streamEnv,
+                lookupJoinSql(
+                    "o.user_id, u.name",
+                    "probe_empty",
+                    "OPTIONS('lookup.full-cache.eager-load'='false')",
+                    null)))
+        .as("The cache is loaded on the first lookup, and no probe row is ever 
looked up")
+        .isEmpty();
+  }
+
+  @Test
+  void lookupJoinWithReorderedDimColumns() throws Exception {
+    createDimTable(dimRecord(1L, "alice", "beijing"), dimRecord(2L, "bob", 
"shanghai"));
+
+    TableEnvironment streamEnv = getStreamingTableEnv();
+    createOrdersTable(streamEnv, "orders_reordered", 2);
+
+    String joinSql = lookupJoinSql("o.order_id, u.city, u.name", 
"orders_reordered");
+
+    assertThat(SqlHelpers.sql(streamEnv, joinSql))
+        .containsExactlyInAnyOrder(Row.of(1L, "beijing", "alice"), Row.of(2L, 
"shanghai", "bob"));
+  }
+
+  private static String lookupJoinSql(String selectClause, String probeTable) {
+    return lookupJoinSql(selectClause, probeTable, null, null);
+  }
+
+  private static String lookupJoinSql(
+      String selectClause, String probeTable, String options, String 
extraCondition) {
+    return String.format(
+        "SELECT %s\n"
+            + "FROM %s AS o\n"
+            + "LEFT JOIN iceberg_catalog.`%s`.`%s`\n"
+            + "%s"
+            + "  FOR SYSTEM_TIME AS OF o.proc_time AS u\n"
+            + "  ON o.user_id = u.user_id%s",
+        selectClause,
+        probeTable,
+        TestFixtures.DATABASE,
+        TestFixtures.TABLE,
+        options == null ? "" : "  /*+ " + options + " */\n",
+        extraCondition == null ? "" : " AND " + extraCondition);
+  }
+
+  private void createDefaultDimTable() throws IOException {
+    createDimTable(
+        dimRecord(1L, "alice", "beijing"),
+        dimRecord(2L, "bob", "shanghai"),
+        dimRecord(3L, "carol", "guangzhou"));
+  }
+
+  private void createDimTable(Record... records) throws IOException {
+    Table dimTable =
+        CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, 
DIM_SCHEMA);
+    GenericAppenderHelper helper =
+        new GenericAppenderHelper(dimTable, FileFormat.PARQUET, 
temporaryFolder);
+    DataFile dataFile = helper.writeFile(Lists.newArrayList(records));
+    helper.appendToTable(dataFile);
+  }
+
+  private static void createOrdersTable(
+      TableEnvironment tableEnvironment, String tableName, int numberOfRows) {
+    SqlHelpers.sql(
+        tableEnvironment,
+        "CREATE TEMPORARY TABLE %s (\n"
+            + "  order_id BIGINT,\n"
+            + "  user_id  BIGINT,\n"
+            + "  proc_time AS PROCTIME()\n"
+            + ") WITH (\n"
+            + "  'connector' = 'datagen',\n"
+            + "  'number-of-rows' = '%d',\n"
+            + "  'fields.order_id.kind' = 'sequence',\n"
+            + "  'fields.order_id.start' = '1',\n"
+            + "  'fields.order_id.end' = '%d',\n"
+            + "  'fields.user_id.kind' = 'sequence',\n"
+            + "  'fields.user_id.start' = '1',\n"
+            + "  'fields.user_id.end' = '%d'\n"
+            + ")",
+        tableName,
+        numberOfRows,
+        numberOfRows,
+        numberOfRows);
+  }
+
+  private static Record dimRecord(long userId, String name, String city) {
+    Record record = GenericRecord.create(DIM_SCHEMA);
+    record.setField("user_id", userId);
+    record.setField("name", name);
+    record.setField("city", city);
+    return record;
+  }
+
+  private static void setUpTableEnv(TableEnvironment tableEnvironment) {
+    Configuration tableConf = tableEnvironment.getConfig().getConfiguration();
+    tableConf.set(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_FLIP27_SOURCE, 
true);
+    
tableConf.set(FlinkConfigOptions.TABLE_EXEC_ICEBERG_INFER_SOURCE_PARALLELISM, 
false);
+
+    
tableEnvironment.getConfig().set("table.exec.resource.default-parallelism", 
"1");
+    SqlHelpers.sql(
+        tableEnvironment,
+        "create catalog iceberg_catalog with ('type'='iceberg', 
'catalog-type'='hadoop', 'warehouse'='%s')",
+        CATALOG_EXTENSION.warehouse());
+    SqlHelpers.sql(tableEnvironment, "use catalog iceberg_catalog");
+  }
+
+  private String createEmptyProbeAndUnreadableDimTable() throws IOException {
+    Table dimTable =
+        CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, 
DIM_SCHEMA);
+    GenericAppenderHelper helper =
+        new GenericAppenderHelper(dimTable, FileFormat.PARQUET, 
temporaryFolder);
+    DataFile dataFile = helper.writeFile(Lists.newArrayList(dimRecord(1L, 
"alice", "beijing")));
+    helper.appendToTable(dataFile);
+
+    CATALOG_EXTENSION
+        .catalog()
+        .createTable(TableIdentifier.of(TestFixtures.DATABASE, "probe_empty"), 
DIM_SCHEMA);
+
+    SqlHelpers.sql(
+        getStreamingTableEnv(),
+        "CREATE TEMPORARY TABLE probe_empty (\n"
+            + "  user_id BIGINT,\n"
+            + "  name    STRING,\n"
+            + "  city    STRING,\n"
+            + "  proc_time AS PROCTIME()\n"
+            + ") WITH (\n"
+            + "  'connector' = 'iceberg',\n"
+            + "  'catalog-name' = 'iceberg_catalog',\n"
+            + "  'catalog-type' = 'hadoop',\n"
+            + "  'warehouse' = '%s',\n"
+            + "  'catalog-database' = '%s',\n"
+            + "  'catalog-table' = 'probe_empty'\n"
+            + ")",
+        CATALOG_EXTENSION.warehouse(),
+        TestFixtures.DATABASE);
+
+    dimTable.io().deleteFile(dataFile.location());
+    return dataFile.location();
+  }
+}
diff --git 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceSql.java
 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceSql.java
index 6cfc18868a..7d52662245 100644
--- 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceSql.java
+++ 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceSql.java
@@ -46,7 +46,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 /** Use the IcebergSource (FLIP-27) */
-public class TestIcebergSourceSql extends TestSqlBase {
+public class TestIcebergSourceSql extends TestSourceSqlBase {
   private static final Schema SCHEMA_TS =
       new Schema(
           required(1, "t1", Types.TimestampType.withoutZone()),
diff --git 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSourceSqlBase.java
similarity index 74%
copy from 
flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
copy to 
flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSourceSqlBase.java
index fe4c327310..61ae30bbe1 100644
--- 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
+++ 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSourceSqlBase.java
@@ -18,17 +18,11 @@
  */
 package org.apache.iceberg.flink.source;
 
-import static org.apache.iceberg.flink.TestFixtures.DATABASE;
 import static org.assertj.core.api.Assertions.assertThat;
 
-import java.io.IOException;
-import java.nio.file.Path;
 import java.util.List;
 import java.util.Map;
 import org.apache.flink.configuration.Configuration;
-import org.apache.flink.table.api.EnvironmentSettings;
-import org.apache.flink.table.api.TableEnvironment;
-import org.apache.flink.test.junit5.MiniClusterExtension;
 import org.apache.flink.types.Row;
 import org.apache.iceberg.DataFile;
 import org.apache.iceberg.FileFormat;
@@ -38,60 +32,14 @@ import org.apache.iceberg.data.GenericAppenderHelper;
 import org.apache.iceberg.data.RandomGenericData;
 import org.apache.iceberg.data.Record;
 import org.apache.iceberg.flink.FlinkConfigOptions;
-import org.apache.iceberg.flink.HadoopCatalogExtension;
-import org.apache.iceberg.flink.MiniFlinkClusterExtension;
 import org.apache.iceberg.flink.TableLoader;
 import org.apache.iceberg.flink.TestFixtures;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
 import org.apache.iceberg.relocated.com.google.common.collect.Maps;
-import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.RegisterExtension;
-import org.junit.jupiter.api.io.TempDir;
 
 /** Test other more advanced usage of SQL. They don't need to run for every 
file format. */
-public abstract class TestSqlBase {
-  @RegisterExtension
-  public static MiniClusterExtension miniClusterExtension =
-      MiniFlinkClusterExtension.createWithClassloaderCheckDisabled();
-
-  @RegisterExtension
-  public static final HadoopCatalogExtension CATALOG_EXTENSION =
-      new HadoopCatalogExtension(DATABASE, TestFixtures.TABLE);
-
-  @TempDir protected Path temporaryFolder;
-
-  private volatile TableEnvironment tEnv;
-
-  private volatile TableEnvironment streamingTEnv;
-
-  protected TableEnvironment getTableEnv() {
-    if (tEnv == null) {
-      synchronized (this) {
-        if (tEnv == null) {
-          this.tEnv =
-              
TableEnvironment.create(EnvironmentSettings.newInstance().inBatchMode().build());
-        }
-      }
-    }
-    return tEnv;
-  }
-
-  protected TableEnvironment getStreamingTableEnv() {
-    if (streamingTEnv == null) {
-      synchronized (this) {
-        if (streamingTEnv == null) {
-          this.streamingTEnv =
-              
TableEnvironment.create(EnvironmentSettings.newInstance().inStreamingMode().build());
-        }
-      }
-    }
-
-    return streamingTEnv;
-  }
-
-  @BeforeEach
-  public abstract void before() throws IOException;
+public abstract class TestSourceSqlBase extends TestSqlBase {
 
   @Test
   public void testResiduals() throws Exception {
diff --git 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
index fe4c327310..1ad17af2f3 100644
--- 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
+++ 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/TestSqlBase.java
@@ -19,37 +19,20 @@
 package org.apache.iceberg.flink.source;
 
 import static org.apache.iceberg.flink.TestFixtures.DATABASE;
-import static org.assertj.core.api.Assertions.assertThat;
 
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.List;
-import java.util.Map;
-import org.apache.flink.configuration.Configuration;
 import org.apache.flink.table.api.EnvironmentSettings;
 import org.apache.flink.table.api.TableEnvironment;
 import org.apache.flink.test.junit5.MiniClusterExtension;
-import org.apache.flink.types.Row;
-import org.apache.iceberg.DataFile;
-import org.apache.iceberg.FileFormat;
-import org.apache.iceberg.Table;
-import org.apache.iceberg.TestHelpers;
-import org.apache.iceberg.data.GenericAppenderHelper;
-import org.apache.iceberg.data.RandomGenericData;
-import org.apache.iceberg.data.Record;
-import org.apache.iceberg.flink.FlinkConfigOptions;
 import org.apache.iceberg.flink.HadoopCatalogExtension;
 import org.apache.iceberg.flink.MiniFlinkClusterExtension;
-import org.apache.iceberg.flink.TableLoader;
 import org.apache.iceberg.flink.TestFixtures;
-import org.apache.iceberg.relocated.com.google.common.collect.Lists;
-import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 import org.junit.jupiter.api.io.TempDir;
 
-/** Test other more advanced usage of SQL. They don't need to run for every 
file format. */
+/** Base class for SQL tests, providing a mini cluster, a Hadoop catalog and 
table environments. */
 public abstract class TestSqlBase {
   @RegisterExtension
   public static MiniClusterExtension miniClusterExtension =
@@ -92,82 +75,4 @@ public abstract class TestSqlBase {
 
   @BeforeEach
   public abstract void before() throws IOException;
-
-  @Test
-  public void testResiduals() throws Exception {
-    Table table =
-        CATALOG_EXTENSION
-            .catalog()
-            .createTable(TestFixtures.TABLE_IDENTIFIER, TestFixtures.SCHEMA, 
TestFixtures.SPEC);
-
-    List<Record> writeRecords = 
RandomGenericData.generate(TestFixtures.SCHEMA, 2, 0L);
-    writeRecords.get(0).set(1, 123L);
-    writeRecords.get(0).set(2, "2020-03-20");
-    writeRecords.get(1).set(1, 456L);
-    writeRecords.get(1).set(2, "2020-03-20");
-
-    GenericAppenderHelper helper =
-        new GenericAppenderHelper(table, FileFormat.PARQUET, temporaryFolder);
-
-    List<Record> expectedRecords = Lists.newArrayList();
-    expectedRecords.add(writeRecords.get(0));
-
-    DataFile dataFile1 = helper.writeFile(TestHelpers.Row.of("2020-03-20", 0), 
writeRecords);
-    DataFile dataFile2 =
-        helper.writeFile(
-            TestHelpers.Row.of("2020-03-21", 0),
-            RandomGenericData.generate(TestFixtures.SCHEMA, 2, 0L));
-    helper.appendToTable(dataFile1, dataFile2);
-
-    org.apache.iceberg.flink.TestHelpers.assertRecords(
-        run(Maps.newHashMap(), "where dt='2020-03-20' and id=123", "*"),
-        expectedRecords,
-        TestFixtures.SCHEMA);
-  }
-
-  @Test
-  public void testExposeLocality() throws Exception {
-    Table table =
-        CATALOG_EXTENSION
-            .catalog()
-            .createTable(TestFixtures.TABLE_IDENTIFIER, TestFixtures.SCHEMA, 
TestFixtures.SPEC);
-
-    TableLoader tableLoader = TableLoader.fromHadoopTable(table.location());
-    List<Record> expectedRecords = 
RandomGenericData.generate(TestFixtures.SCHEMA, 10, 0L);
-    expectedRecords.forEach(expectedRecord -> expectedRecord.set(2, 
"2020-03-20"));
-
-    GenericAppenderHelper helper =
-        new GenericAppenderHelper(table, FileFormat.PARQUET, temporaryFolder);
-    DataFile dataFile = helper.writeFile(TestHelpers.Row.of("2020-03-20", 0), 
expectedRecords);
-    helper.appendToTable(dataFile);
-
-    // test sql api
-    Configuration tableConf = getTableEnv().getConfig().getConfiguration();
-    
tableConf.set(FlinkConfigOptions.TABLE_EXEC_ICEBERG_EXPOSE_SPLIT_LOCALITY_INFO, 
false);
-
-    List<Row> results = SqlHelpers.sql(getTableEnv(), "select * from t");
-    org.apache.iceberg.flink.TestHelpers.assertRecords(
-        results, expectedRecords, TestFixtures.SCHEMA);
-
-    // test table api
-    
tableConf.set(FlinkConfigOptions.TABLE_EXEC_ICEBERG_EXPOSE_SPLIT_LOCALITY_INFO, 
true);
-    FlinkSource.Builder builder = 
FlinkSource.forRowData().tableLoader(tableLoader).table(table);
-
-    // When running with CI or local, `localityEnabled` will be false even if 
this configuration is
-    // enabled
-    assertThat(SourceUtil.isLocalityEnabled(table, tableConf, true))
-        .as("Expose split locality info should be false.")
-        .isFalse();
-
-    results = run(Maps.newHashMap(), "where dt='2020-03-20'", "*");
-    org.apache.iceberg.flink.TestHelpers.assertRecords(
-        results, expectedRecords, TestFixtures.SCHEMA);
-  }
-
-  protected List<Row> run(
-      Map<String, String> options, String sqlFilter, String... 
sqlSelectedFields) {
-    String select = String.join(",", sqlSelectedFields);
-    String optionStr = SqlHelpers.sqlOptionsToString(options);
-    return SqlHelpers.sql(getTableEnv(), "select %s from t %s %s", select, 
optionStr, sqlFilter);
-  }
 }
diff --git 
a/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/lookup/TestIcebergLookup.java
 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/lookup/TestIcebergLookup.java
new file mode 100644
index 0000000000..d13d89d17a
--- /dev/null
+++ 
b/flink/v2.2/flink/src/test/java/org/apache/iceberg/flink/source/lookup/TestIcebergLookup.java
@@ -0,0 +1,318 @@
+/*
+ * 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.iceberg.flink.source.lookup;
+
+import static org.apache.iceberg.types.Types.NestedField.optional;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.functions.FunctionContext;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.data.GenericAppenderHelper;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.flink.FlinkSchemaUtil;
+import org.apache.iceberg.flink.HadoopCatalogExtension;
+import org.apache.iceberg.flink.TestFixtures;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+
+class TestIcebergLookup {
+  private static final Schema SCHEMA =
+      new Schema(
+          optional(1, "id", Types.LongType.get()),
+          optional(2, "data", Types.StringType.get()),
+          optional(3, "category", Types.StringType.get()));
+
+  private static final String[] PROJECTED_COLUMNS = {"id", "data", "category"};
+  private static final RowType ROW_TYPE = FlinkSchemaUtil.convert(SCHEMA);
+  private static final int[] ID_KEY_INDEXES = {0};
+  private static final int[] ID_AND_CATEGORY_KEY_INDEXES = {0, 2};
+
+  @TempDir private Path temporaryFolder;
+
+  @RegisterExtension
+  protected static final HadoopCatalogExtension CATALOG_EXTENSION =
+      new HadoopCatalogExtension(TestFixtures.DATABASE, TestFixtures.TABLE);
+
+  private IcebergFullCachingLookupFunction lookupFunction;
+
+  @AfterEach
+  void after() throws Exception {
+    if (lookupFunction != null) {
+      lookupFunction.close();
+    }
+  }
+
+  @Test
+  void lookupReaderReadsWithBaseFilters() throws Exception {
+    Table table = createTableWithRecords();
+    IcebergLookupReader reader =
+        lookupReader(table, ImmutableList.of(Expressions.equal("category", 
"B")), false);
+
+    List<List<Object>> rows = Lists.newArrayList();
+    try (CloseableIterable<RowData> rowData = 
reader.read(IcebergLookupReader.CURRENT_SNAPSHOT)) {
+      for (RowData row : rowData) {
+        rows.add(
+            ImmutableList.of(
+                row.getLong(0), row.getString(1).toString(), 
row.getString(2).toString()));
+      }
+    }
+
+    assertThat(rows).containsExactly(ImmutableList.of(2L, "bob", "B"));
+  }
+
+  @Test
+  void lookupReaderRespectsCaseSensitivity() throws Exception {
+    Table table = createTableWithRecords();
+    List<Expression> filters = ImmutableList.of(Expressions.equal("CATEGORY", 
"B"));
+
+    IcebergLookupReader caseSensitiveReader = lookupReader(table, filters, 
true);
+    assertThatThrownBy(() -> readIds(caseSensitiveReader, 
IcebergLookupReader.CURRENT_SNAPSHOT))
+        .as("Case sensitive lookup should reject a filter that doesn't match 
the column case")
+        .isInstanceOf(ValidationException.class)
+        .hasMessageContaining("CATEGORY");
+
+    assertThat(readIds(lookupReader(table, filters, false), 
IcebergLookupReader.CURRENT_SNAPSHOT))
+        .containsExactly(2L);
+  }
+
+  @Test
+  void lookupReaderReadsPinnedSnapshot() throws Exception {
+    Table table = createTableWithRecords();
+    long pinnedSnapshot = table.currentSnapshot().snapshotId();
+
+    appendRecords(table, ImmutableList.of(record(6L, "frank", "D")));
+
+    IcebergLookupReader reader = lookupReader(table, ImmutableList.of(), 
false);
+
+    assertThat(readIds(reader, pinnedSnapshot))
+        .as("Pinned snapshot should not contain the appended row")
+        .containsExactlyInAnyOrder(1L, 2L, 3L);
+    assertThat(readIds(reader, IcebergLookupReader.CURRENT_SNAPSHOT))
+        .as("Current snapshot should contain the appended row")
+        .containsExactlyInAnyOrder(1L, 2L, 3L, 6L);
+  }
+
+  @Test
+  void lookupFunctionReturnsRowsFromCache() throws Exception {
+    Table table = createTableWithRecords();
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, false);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 1L, "alice", "A"));
+
+    appendRecords(table, ImmutableList.of(record(6L, "frank", "D")));
+
+    assertThat(lookupFunction.lookup(keyRow(6L))).isEmpty();
+    assertThat(lookupFunction.lookup(keyRow(404L))).isEmpty();
+    assertThat(lookupFunction.lookup(keyRow(1L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 1L, "alice", "A"));
+  }
+
+  @Test
+  void lookupFunctionWithMultiColumnKey() throws Exception {
+    Table table = createTableWithRecords();
+
+    lookupFunction = newLookupFunction(ID_AND_CATEGORY_KEY_INDEXES, false);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L, "A")))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 1L, "alice", "A"));
+
+    assertThat(lookupFunction.lookup(keyRow(1L, "B"))).isEmpty();
+
+    appendRecords(table, ImmutableList.of(record(3L, "carol-2", "A")));
+
+    assertThat(lookupFunction.lookup(keyRow(3L, "A")))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 3L, "carol", "A"));
+  }
+
+  @Test
+  void lookupFunctionLoadsCacheLazily() throws Exception {
+    Table table = createTableWithRecords();
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, false);
+    lookupFunction.open(new FunctionContext(null));
+
+    // Nothing is loaded yet, so the first lookup sees the row appended after 
opening.
+    appendRecords(table, ImmutableList.of(record(6L, "frank", "D")));
+
+    assertThat(lookupFunction.lookup(keyRow(6L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 6L, "frank", "D"));
+  }
+
+  @Test
+  void lookupFunctionLoadsCacheEagerly() throws Exception {
+    Table table = createTableWithRecords();
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, true);
+    lookupFunction.open(new FunctionContext(null));
+
+    appendRecords(table, ImmutableList.of(record(6L, "frank", "D")));
+
+    assertThat(lookupFunction.lookup(keyRow(6L))).isEmpty();
+    assertThat(lookupFunction.lookup(keyRow(1L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 1L, "alice", "A"));
+  }
+
+  @Test
+  void lookupFunctionHandlesEmptyTable() throws Exception {
+    CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, 
SCHEMA);
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, true);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L))).isEmpty();
+  }
+
+  @Test
+  void lookupFunctionHandlesMultipleRowsPerKey() throws Exception {
+    Table table = 
CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, SCHEMA);
+    appendRecords(
+        table,
+        ImmutableList.of(
+            record(1L, "alice", "A"), record(1L, "alice-2", "A"), record(2L, 
"bob", "B")));
+
+    lookupFunction = newLookupFunction(ID_KEY_INDEXES, false);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L)))
+        .hasSize(2)
+        .allSatisfy(row -> assertThat(row.getLong(0)).isEqualTo(1L));
+    assertThat(lookupFunction.lookup(keyRow(2L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 2L, "bob", "B"));
+  }
+
+  @Test
+  void lookupFunctionAppliesPushedFilters() throws Exception {
+    createTableWithRecords();
+
+    lookupFunction =
+        new IcebergFullCachingLookupFunction(
+            CATALOG_EXTENSION.tableLoader().clone(),
+            ROW_TYPE,
+            ID_KEY_INDEXES,
+            ImmutableList.of(Expressions.equal("category", "B")),
+            true,
+            false);
+    lookupFunction.open(new FunctionContext(null));
+
+    assertThat(lookupFunction.lookup(keyRow(1L))).isEmpty();
+    assertThat(lookupFunction.lookup(keyRow(2L)))
+        .singleElement()
+        .satisfies(row -> assertRow(row, 2L, "bob", "B"));
+  }
+
+  private IcebergFullCachingLookupFunction newLookupFunction(
+      int[] lookupKeyIndexes, boolean eagerLoad) {
+    return new IcebergFullCachingLookupFunction(
+        CATALOG_EXTENSION.tableLoader().clone(),
+        ROW_TYPE,
+        lookupKeyIndexes,
+        ImmutableList.of(),
+        false,
+        eagerLoad);
+  }
+
+  private static IcebergLookupReader lookupReader(
+      Table table, List<Expression> filters, boolean caseSensitive) {
+    return new IcebergLookupReader(
+        table, SCHEMA.select(PROJECTED_COLUMNS), filters, caseSensitive, null);
+  }
+
+  private static List<Long> readIds(IcebergLookupReader reader, long 
snapshotId)
+      throws IOException {
+    List<Long> ids = Lists.newArrayList();
+    try (CloseableIterable<RowData> rows = reader.read(snapshotId)) {
+      for (RowData row : rows) {
+        ids.add(row.isNullAt(0) ? null : row.getLong(0));
+      }
+    }
+
+    return ids;
+  }
+
+  private Table createTableWithRecords() throws Exception {
+    Table table = 
CATALOG_EXTENSION.catalog().createTable(TestFixtures.TABLE_IDENTIFIER, SCHEMA);
+    appendRecords(
+        table,
+        ImmutableList.of(
+            record(1L, "alice", "A"), record(2L, "bob", "B"), record(3L, 
"carol", "A")));
+    return table;
+  }
+
+  private void appendRecords(Table table, List<Record> records) throws 
Exception {
+    new GenericAppenderHelper(table, FileFormat.PARQUET, 
temporaryFolder).appendToTable(records);
+  }
+
+  private static GenericRecord record(Long id, String data, String category) {
+    GenericRecord record = GenericRecord.create(SCHEMA);
+    record.setField("id", id);
+    record.setField("data", data);
+    record.setField("category", category);
+    return record;
+  }
+
+  private static RowData keyRow(Long id) {
+    GenericRowData row = new GenericRowData(1);
+    row.setField(0, id);
+    return row;
+  }
+
+  private static RowData keyRow(Long id, String category) {
+    GenericRowData row = new GenericRowData(2);
+    row.setField(0, id);
+    row.setField(1, StringData.fromString(category));
+    return row;
+  }
+
+  private static void assertRow(RowData row, long id, String data, String 
category) {
+    assertThat(row.getLong(0)).isEqualTo(id);
+    assertThat(row.getString(1)).hasToString(data);
+    assertThat(row.getString(2)).hasToString(category);
+  }
+}
diff --git a/gradle.properties b/gradle.properties
index 874981cfc3..f225d438a4 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -16,7 +16,7 @@
 jmhOutputPath=build/reports/jmh/human-readable-output.txt
 jmhJsonOutputPath=build/reports/jmh/results.json
 jmhIncludeRegex=.*
-systemProp.defaultFlinkVersions=2.3
+systemProp.defaultFlinkVersions=1.20,2.2,2.3
 systemProp.knownFlinkVersions=1.20,2.2,2.3
 systemProp.defaultSparkVersions=4.2
 systemProp.knownSparkVersions=3.5,4.0,4.1,4.2

Reply via email to