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

shuwenwei pushed a commit to branch flink-iotdb-table-connector
in repository https://gitbox.apache.org/repos/asf/iotdb-extras.git


The following commit(s) were added to refs/heads/flink-iotdb-table-connector by 
this push:
     new 7b194bc  feat: support lookup
7b194bc is described below

commit 7b194bcecf4e435211be0291cc91680062296aca
Author: shuwenwei <[email protected]>
AuthorDate: Mon Sep 21 17:41:16 2026 +0800

    feat: support lookup
---
 .../iotdb/relational/flink/cfg/IoTDBOptions.java   |  36 ++++++
 .../source/lookup/IoTDBAsyncLookupFunction.java    | 106 ++++++++++++++++
 .../flink/source/lookup/IoTDBLookupFunction.java   |  70 +++++++++++
 .../flink/source/lookup/IoTDBLookupReader.java     | 138 +++++++++++++++++++++
 .../source/lookup/IoTDBRuntimeLiteralUtils.java    | 108 ++++++++++++++++
 .../lookup/IoTDBAsyncLookupFunctionTest.java       |  54 ++++++++
 .../flink/source/lookup/IoTDBLookupReaderTest.java | 120 ++++++++++++++++++
 .../table/IoTDBRelationalDynamicTableFactory.java  |  14 +--
 .../table/IoTDBRelationalDynamicTableSource.java   |  38 +++++-
 .../flink/IoTDBRelationalLocalQueryManual.java     | 107 ++++++++++++----
 10 files changed, 760 insertions(+), 31 deletions(-)

diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/cfg/IoTDBOptions.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/cfg/IoTDBOptions.java
index fcd21e5..0bc6cf9 100644
--- 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/cfg/IoTDBOptions.java
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/cfg/IoTDBOptions.java
@@ -65,6 +65,12 @@ public class IoTDBOptions implements Serializable {
   public static final ConfigOption<String> ATTRIBUTE_COLUMNS =
       ConfigOptions.key("attribute-columns").stringType().defaultValue("");
 
+  public static final ConfigOption<Boolean> LOOKUP_ASYNC =
+      ConfigOptions.key("lookup.async").booleanType().defaultValue(false);
+
+  public static final ConfigOption<Integer> LOOKUP_THREAD_SIZE =
+      ConfigOptions.key("lookup.thread-size").intType().defaultValue(5);
+
   private final List<String> nodeUrls;
   private final String username;
   private final String password;
@@ -74,6 +80,8 @@ public class IoTDBOptions implements Serializable {
   private final String timeColumn;
   private final List<String> tagColumns;
   private final List<String> attributeColumns;
+  private final boolean lookupAsync;
+  private final int lookupThreadSize;
 
   private IoTDBOptions(Builder builder) {
     this.nodeUrls = builder.nodeUrls;
@@ -85,6 +93,8 @@ public class IoTDBOptions implements Serializable {
     this.timeColumn = builder.timeColumn;
     this.tagColumns = builder.tagColumns;
     this.attributeColumns = builder.attributeColumns;
+    this.lookupAsync = builder.lookupAsync;
+    this.lookupThreadSize = builder.lookupThreadSize;
   }
 
   /**
@@ -150,6 +160,20 @@ public class IoTDBOptions implements Serializable {
     return attributeColumns;
   }
 
+  /**
+   * @return whether an asynchronous lookup function is used for lookup joins.
+   */
+  public boolean isLookupAsync() {
+    return lookupAsync;
+  }
+
+  /**
+   * @return the number of concurrent lookup query threads, also the lookup 
session pool size.
+   */
+  public int getLookupThreadSize() {
+    return lookupThreadSize;
+  }
+
   /**
    * @return a new builder
    */
@@ -169,6 +193,8 @@ public class IoTDBOptions implements Serializable {
     private String timeColumn;
     private List<String> tagColumns = Collections.emptyList();
     private List<String> attributeColumns = Collections.emptyList();
+    private boolean lookupAsync = false;
+    private int lookupThreadSize = 5;
 
     public Builder withNodeUrls(List<String> nodeUrls) {
       this.nodeUrls = nodeUrls;
@@ -215,6 +241,16 @@ public class IoTDBOptions implements Serializable {
       return this;
     }
 
+    public Builder withLookupAsync(boolean lookupAsync) {
+      this.lookupAsync = lookupAsync;
+      return this;
+    }
+
+    public Builder withLookupThreadSize(int lookupThreadSize) {
+      this.lookupThreadSize = lookupThreadSize;
+      return this;
+    }
+
     /**
      * @return the built options
      */
diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBAsyncLookupFunction.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBAsyncLookupFunction.java
new file mode 100644
index 0000000..5b7e223
--- /dev/null
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBAsyncLookupFunction.java
@@ -0,0 +1,106 @@
+/*
+ * 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.iotdb.relational.flink.source.lookup;
+
+import org.apache.iotdb.relational.flink.cfg.IoTDBOptions;
+
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.functions.AsyncLookupFunction;
+import org.apache.flink.table.functions.FunctionContext;
+import org.apache.flink.table.types.DataType;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Asynchronous lookup function that queries an IoTDB table-model table by the 
join keys.
+ *
+ * <p>{@code ITableSession} only offers blocking queries, so the blocking 
{@link IoTDBLookupReader}
+ * is executed on a bounded thread pool and results are delivered through 
{@link CompletableFuture}.
+ */
+public class IoTDBAsyncLookupFunction extends AsyncLookupFunction {
+
+  private static final long serialVersionUID = 1L;
+
+  private final IoTDBOptions options;
+  private final DataType rowDataType;
+  private final int[] keyIndices;
+  private final int threadSize;
+
+  private transient ExecutorService executor;
+  private transient IoTDBLookupReader reader;
+
+  public IoTDBAsyncLookupFunction(
+      IoTDBOptions options, DataType rowDataType, int[] keyIndices, int 
threadSize) {
+    this.options = options;
+    this.rowDataType = rowDataType;
+    this.keyIndices = keyIndices.clone();
+    this.threadSize = threadSize;
+  }
+
+  @Override
+  public void open(FunctionContext context) {
+    reader = new IoTDBLookupReader(options, rowDataType, keyIndices);
+    reader.open();
+    executor = Executors.newFixedThreadPool(Math.max(1, threadSize), 
createThreadFactory());
+  }
+
+  @Override
+  public CompletableFuture<Collection<RowData>> asyncLookup(RowData keyRow) {
+    if (keyRow == null) {
+      return CompletableFuture.completedFuture(Collections.emptyList());
+    }
+    return CompletableFuture.<Collection<RowData>>supplyAsync(
+        () -> {
+          try {
+            return reader.get(keyRow);
+          } catch (IOException e) {
+            throw new CompletionException(e);
+          }
+        },
+        executor);
+  }
+
+  @Override
+  public void close() {
+    if (reader != null) {
+      reader.close();
+    }
+    if (executor != null) {
+      executor.shutdown();
+    }
+  }
+
+  private static ThreadFactory createThreadFactory() {
+    AtomicInteger counter = new AtomicInteger();
+    return runnable -> {
+      Thread thread = new Thread(runnable, "iotdb-lookup-" + 
counter.incrementAndGet());
+      thread.setDaemon(true);
+      return thread;
+    };
+  }
+}
diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBLookupFunction.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBLookupFunction.java
new file mode 100644
index 0000000..da07a69
--- /dev/null
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBLookupFunction.java
@@ -0,0 +1,70 @@
+/*
+ * 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.iotdb.relational.flink.source.lookup;
+
+import org.apache.iotdb.relational.flink.cfg.IoTDBOptions;
+
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.functions.FunctionContext;
+import org.apache.flink.table.functions.LookupFunction;
+import org.apache.flink.table.types.DataType;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+
+/** Synchronous lookup function that queries an IoTDB table-model table by the 
join keys. */
+public class IoTDBLookupFunction extends LookupFunction {
+
+  private static final long serialVersionUID = 1L;
+
+  private final IoTDBOptions options;
+  private final DataType rowDataType;
+  private final int[] keyIndices;
+
+  private transient IoTDBLookupReader reader;
+
+  public IoTDBLookupFunction(IoTDBOptions options, DataType rowDataType, int[] 
keyIndices) {
+    this.options = options;
+    this.rowDataType = rowDataType;
+    this.keyIndices = keyIndices.clone();
+  }
+
+  @Override
+  public void open(FunctionContext context) {
+    reader = new IoTDBLookupReader(options, rowDataType, keyIndices);
+    reader.open();
+  }
+
+  @Override
+  public Collection<RowData> lookup(RowData keyRow) throws IOException {
+    if (keyRow == null) {
+      return Collections.emptyList();
+    }
+    return reader.get(keyRow);
+  }
+
+  @Override
+  public void close() {
+    if (reader != null) {
+      reader.close();
+    }
+  }
+}
diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBLookupReader.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBLookupReader.java
new file mode 100644
index 0000000..cd4e6ad
--- /dev/null
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBLookupReader.java
@@ -0,0 +1,138 @@
+/*
+ * 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.iotdb.relational.flink.source.lookup;
+
+import org.apache.iotdb.isession.ITableSession;
+import org.apache.iotdb.isession.SessionDataSet;
+import org.apache.iotdb.isession.pool.ITableSessionPool;
+import org.apache.iotdb.relational.flink.cfg.IoTDBOptions;
+import 
org.apache.iotdb.relational.flink.source.deserializer.RowDataDeserializationSchema;
+import org.apache.iotdb.relational.flink.utils.IoTDBUtils;
+import org.apache.iotdb.session.pool.TableSessionPoolBuilder;
+
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.DataType;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Blocking lookup reader for an IoTDB table-model table.
+ *
+ * <p>It builds a point query from the lookup keys and converts the result 
rows back to {@link
+ * RowData}. The instance is not thread-safe until {@link #open()} has been 
called; afterwards the
+ * underlying session pool is safe to use from multiple threads.
+ */
+public class IoTDBLookupReader implements AutoCloseable {
+
+  private final IoTDBOptions options;
+  private final DataType rowDataType;
+  private final int[] keyIndices;
+  private final List<String> fieldNames;
+  private final List<DataType> fieldTypes;
+  private final RowDataDeserializationSchema deserializer;
+
+  private volatile ITableSessionPool sessionPool;
+
+  public IoTDBLookupReader(IoTDBOptions options, DataType rowDataType, int[] 
keyIndices) {
+    this.options = options;
+    this.rowDataType = rowDataType;
+    this.keyIndices = keyIndices.clone();
+    this.fieldNames = DataType.getFieldNames(rowDataType);
+    this.fieldTypes = DataType.getFieldDataTypes(rowDataType);
+    this.deserializer = new RowDataDeserializationSchema(rowDataType);
+  }
+
+  /** Opens the IoTDB session pool. The database is taken from the connector 
options. */
+  public synchronized void open() {
+    if (sessionPool != null) {
+      return;
+    }
+    TableSessionPoolBuilder builder =
+        new TableSessionPoolBuilder()
+            .nodeUrls(options.getNodeUrls())
+            .user(options.getUsername())
+            .password(options.getPassword());
+    if (options.getDatabase() != null) {
+      builder.database(options.getDatabase());
+    }
+    sessionPool = builder.build();
+  }
+
+  /**
+   * Runs the lookup query for the given key row and returns the matching rows.
+   *
+   * @param keyRow row whose fields are the lookup keys, in {@link 
#keyIndices} order
+   * @return matching rows, or an empty list when a key is {@code null}
+   */
+  public List<RowData> get(RowData keyRow) throws IOException {
+    String sql = buildLookupSql(keyRow);
+    if (sql == null) {
+      return Collections.emptyList();
+    }
+
+    ITableSessionPool pool = sessionPool;
+    if (pool == null) {
+      throw new IOException("IoTDB lookup reader is not open.");
+    }
+
+    try (ITableSession session = pool.getSession();
+        SessionDataSet dataSet = session.executeQueryStatement(sql)) {
+      List<RowData> rows = new ArrayList<>();
+      SessionDataSet.DataIterator iterator = dataSet.iterator();
+      while (iterator.next()) {
+        rows.add(deserializer.deserialize(iterator));
+      }
+      return rows;
+    } catch (IOException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new IOException("Failed to execute IoTDB lookup query: " + sql, e);
+    }
+  }
+
+  /** Builds the lookup SQL, or {@code null} if a key value cannot be 
represented. */
+  String buildLookupSql(RowData keyRow) {
+    List<String> predicates = new ArrayList<>(keyIndices.length);
+    for (int position = 0; position < keyIndices.length; position++) {
+      int fieldIndex = keyIndices[position];
+      String literal =
+          IoTDBRuntimeLiteralUtils.render(keyRow, position, 
fieldTypes.get(fieldIndex));
+      if (literal == null) {
+        return null;
+      }
+      predicates.add(IoTDBUtils.quoteIdentifier(fieldNames.get(fieldIndex)) + 
" = " + literal);
+    }
+    return IoTDBUtils.buildSelectQuery(options.getTable(), rowDataType, 
predicates, -1);
+  }
+
+  @Override
+  public synchronized void close() {
+    if (sessionPool != null) {
+      try {
+        sessionPool.close();
+      } finally {
+        sessionPool = null;
+      }
+    }
+  }
+}
diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBRuntimeLiteralUtils.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBRuntimeLiteralUtils.java
new file mode 100644
index 0000000..9993c91
--- /dev/null
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/main/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBRuntimeLiteralUtils.java
@@ -0,0 +1,108 @@
+/*
+ * 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.iotdb.relational.flink.source.lookup;
+
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.LogicalTypeRoot;
+import org.apache.flink.table.types.logical.TimestampType;
+
+import java.time.LocalDate;
+
+/**
+ * Renders a runtime {@link RowData} field as an IoTDB SQL literal, used to 
build lookup queries.
+ *
+ * <p>Returns {@code null} when the value is {@code null} or cannot be 
represented in IoTDB SQL.
+ */
+public final class IoTDBRuntimeLiteralUtils {
+
+  private IoTDBRuntimeLiteralUtils() {}
+
+  public static String render(RowData row, int position, DataType dataType) {
+    if (row == null || row.isNullAt(position) || dataType == null) {
+      return null;
+    }
+
+    LogicalTypeRoot typeRoot = dataType.getLogicalType().getTypeRoot();
+    switch (typeRoot) {
+      case BOOLEAN:
+        return Boolean.toString(row.getBoolean(position));
+      case TINYINT:
+        return Byte.toString(row.getByte(position));
+      case SMALLINT:
+        return Short.toString(row.getShort(position));
+      case INTEGER:
+        return Integer.toString(row.getInt(position));
+      case BIGINT:
+        return Long.toString(row.getLong(position));
+      case FLOAT:
+        float floatValue = row.getFloat(position);
+        return isFinite(floatValue) ? Float.toString(floatValue) : null;
+      case DOUBLE:
+        double doubleValue = row.getDouble(position);
+        return isFinite(doubleValue) ? Double.toString(doubleValue) : null;
+      case CHAR:
+      case VARCHAR:
+        return quoteString(row.getString(position).toString());
+      case BINARY:
+      case VARBINARY:
+        byte[] bytes = row.getBinary(position);
+        return bytes == null ? null : formatBinary(bytes);
+      case DATE:
+        return "CAST('" + LocalDate.ofEpochDay(row.getInt(position)) + "' AS 
DATE)";
+      case TIMESTAMP_WITHOUT_TIME_ZONE:
+        return "CAST('"
+            + getTimestamp(row, position, dataType).toLocalDateTime()
+            + "' AS TIMESTAMP)";
+      case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+        return "CAST('" + getTimestamp(row, position, dataType).toInstant() + 
"' AS TIMESTAMP)";
+      default:
+        return null;
+    }
+  }
+
+  private static TimestampData getTimestamp(RowData row, int position, 
DataType dataType) {
+    int precision = ((TimestampType) dataType.getLogicalType()).getPrecision();
+    return row.getTimestamp(position, precision);
+  }
+
+  private static boolean isFinite(float value) {
+    return !Float.isNaN(value) && !Float.isInfinite(value);
+  }
+
+  private static boolean isFinite(double value) {
+    return !Double.isNaN(value) && !Double.isInfinite(value);
+  }
+
+  private static String quoteString(String value) {
+    return "'" + value.replace("'", "''") + "'";
+  }
+
+  private static String formatBinary(byte[] value) {
+    StringBuilder builder = new StringBuilder(value.length * 2 + 3);
+    builder.append("X'");
+    for (byte b : value) {
+      builder.append(String.format("%02X", b));
+    }
+    builder.append("'");
+    return builder.toString();
+  }
+}
diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/test/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBAsyncLookupFunctionTest.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/test/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBAsyncLookupFunctionTest.java
new file mode 100644
index 0000000..2adc756
--- /dev/null
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/test/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBAsyncLookupFunctionTest.java
@@ -0,0 +1,54 @@
+/*
+ * 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.iotdb.relational.flink.source.lookup;
+
+import org.apache.iotdb.relational.flink.cfg.IoTDBOptions;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.DataType;
+import org.junit.Test;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import static org.junit.Assert.assertTrue;
+
+/** Unit tests for {@link IoTDBAsyncLookupFunction}. */
+public class IoTDBAsyncLookupFunctionTest {
+
+  private static final IoTDBOptions OPTIONS =
+      IoTDBOptions.builder()
+          .withNodeUrls(Collections.singletonList("127.0.0.1:6667"))
+          .withDatabase("test")
+          .withTable("sensor")
+          .build();
+
+  @Test
+  public void testNullKeyReturnsEmptyResult() throws Exception {
+    DataType rowType = DataTypes.ROW(DataTypes.FIELD("device_id", 
DataTypes.STRING()));
+    IoTDBAsyncLookupFunction function =
+        new IoTDBAsyncLookupFunction(OPTIONS, rowType, new int[] {0}, 2);
+
+    Collection<RowData> result = function.asyncLookup(null).get();
+
+    assertTrue(result.isEmpty());
+  }
+}
diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/test/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBLookupReaderTest.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/test/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBLookupReaderTest.java
new file mode 100644
index 0000000..f2b6134
--- /dev/null
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-base/src/test/java/org/apache/iotdb/relational/flink/source/lookup/IoTDBLookupReaderTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.iotdb.relational.flink.source.lookup;
+
+import org.apache.iotdb.relational.flink.cfg.IoTDBOptions;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.types.DataType;
+import org.junit.Test;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+/** Unit tests for the lookup SQL rendering of {@link IoTDBLookupReader}. */
+public class IoTDBLookupReaderTest {
+
+  private static final IoTDBOptions OPTIONS =
+      IoTDBOptions.builder()
+          .withNodeUrls(Collections.singletonList("127.0.0.1:6667"))
+          .withDatabase("test")
+          .withTable("sensor")
+          .build();
+
+  @Test
+  public void testStringAndTimestampKeys() {
+    DataType rowType =
+        DataTypes.ROW(
+            DataTypes.FIELD("time", DataTypes.TIMESTAMP(3)),
+            DataTypes.FIELD("device_id", DataTypes.STRING()),
+            DataTypes.FIELD("temperature", DataTypes.DOUBLE()));
+    IoTDBLookupReader reader = new IoTDBLookupReader(OPTIONS, rowType, new 
int[] {1, 0});
+
+    GenericRowData keyRow =
+        GenericRowData.of(
+            StringData.fromString("d1"),
+            TimestampData.fromLocalDateTime(LocalDateTime.of(2024, 1, 1, 12, 
30)));
+
+    assertEquals(
+        "SELECT \"time\", \"device_id\", \"temperature\" FROM \"sensor\" "
+            + "WHERE \"device_id\" = 'd1' AND \"time\" = 
CAST('2024-01-01T12:30' AS TIMESTAMP)",
+        reader.buildLookupSql(keyRow));
+  }
+
+  @Test
+  public void testStringKeyIsEscaped() {
+    DataType rowType = DataTypes.ROW(DataTypes.FIELD("device_id", 
DataTypes.STRING()));
+    IoTDBLookupReader reader = new IoTDBLookupReader(OPTIONS, rowType, new 
int[] {0});
+
+    GenericRowData keyRow = 
GenericRowData.of(StringData.fromString("O'Brien"));
+
+    assertEquals(
+        "SELECT \"device_id\" FROM \"sensor\" WHERE \"device_id\" = 
'O''Brien'",
+        reader.buildLookupSql(keyRow));
+  }
+
+  @Test
+  public void testDateKey() {
+    DataType rowType = DataTypes.ROW(DataTypes.FIELD("day", DataTypes.DATE()));
+    IoTDBLookupReader reader = new IoTDBLookupReader(OPTIONS, rowType, new 
int[] {0});
+    int epochDay = 19000;
+
+    assertEquals(
+        "SELECT \"day\" FROM \"sensor\" "
+            + "WHERE \"day\" = CAST('"
+            + LocalDate.ofEpochDay(epochDay)
+            + "' AS DATE)",
+        reader.buildLookupSql(GenericRowData.of(epochDay)));
+  }
+
+  @Test
+  public void testCompositeNumericKey() {
+    DataType rowType =
+        DataTypes.ROW(
+            DataTypes.FIELD("code", DataTypes.INT()), DataTypes.FIELD("value", 
DataTypes.BIGINT()));
+    IoTDBLookupReader reader = new IoTDBLookupReader(OPTIONS, rowType, new 
int[] {0, 1});
+
+    assertEquals(
+        "SELECT \"code\", \"value\" FROM \"sensor\" WHERE \"code\" = 1 AND 
\"value\" = 2",
+        reader.buildLookupSql(GenericRowData.of(1, 2L)));
+  }
+
+  @Test
+  public void testNullKeyProducesNoQuery() {
+    DataType rowType =
+        DataTypes.ROW(
+            DataTypes.FIELD("time", DataTypes.TIMESTAMP(3)),
+            DataTypes.FIELD("device_id", DataTypes.STRING()));
+    IoTDBLookupReader reader = new IoTDBLookupReader(OPTIONS, rowType, new 
int[] {1, 0});
+
+    GenericRowData keyRow =
+        GenericRowData.of(
+            null, TimestampData.fromLocalDateTime(LocalDateTime.of(2024, 1, 1, 
12, 30)));
+
+    assertNull(reader.buildLookupSql(keyRow));
+  }
+}
diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/main/java/org/apache/iotdb/relational/flink/table/IoTDBRelationalDynamicTableFactory.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/main/java/org/apache/iotdb/relational/flink/table/IoTDBRelationalDynamicTableFactory.java
index 5c45b73..e755082 100644
--- 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/main/java/org/apache/iotdb/relational/flink/table/IoTDBRelationalDynamicTableFactory.java
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/main/java/org/apache/iotdb/relational/flink/table/IoTDBRelationalDynamicTableFactory.java
@@ -69,10 +69,7 @@ public class IoTDBRelationalDynamicTableFactory
   @Override
   public Set<ConfigOption<?>> requiredOptions() {
     return new HashSet<>(
-        Arrays.asList(
-            IoTDBOptions.NODE_URLS,
-            IoTDBOptions.DATABASE,
-            IoTDBOptions.TABLE));
+        Arrays.asList(IoTDBOptions.NODE_URLS, IoTDBOptions.DATABASE, 
IoTDBOptions.TABLE));
   }
 
   @Override
@@ -83,7 +80,9 @@ public class IoTDBRelationalDynamicTableFactory
             IoTDBOptions.PASSWORD,
             IoTDBOptions.TIME_COLUMN,
             IoTDBOptions.TAG_COLUMNS,
-            IoTDBOptions.ATTRIBUTE_COLUMNS));
+            IoTDBOptions.ATTRIBUTE_COLUMNS,
+            IoTDBOptions.LOOKUP_ASYNC,
+            IoTDBOptions.LOOKUP_THREAD_SIZE));
   }
 
   private static IoTDBOptions toOptions(ReadableConfig config) {
@@ -95,8 +94,9 @@ public class IoTDBRelationalDynamicTableFactory
         .withTable(config.get(IoTDBOptions.TABLE))
         .withTimeColumn(config.get(IoTDBOptions.TIME_COLUMN))
         .withTagColumns(parseColumnNames(config.get(IoTDBOptions.TAG_COLUMNS)))
-        .withAttributeColumns(
-            parseColumnNames(config.get(IoTDBOptions.ATTRIBUTE_COLUMNS)))
+        
.withAttributeColumns(parseColumnNames(config.get(IoTDBOptions.ATTRIBUTE_COLUMNS)))
+        .withLookupAsync(config.get(IoTDBOptions.LOOKUP_ASYNC))
+        .withLookupThreadSize(config.get(IoTDBOptions.LOOKUP_THREAD_SIZE))
         .build();
   }
 
diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/main/java/org/apache/iotdb/relational/flink/table/IoTDBRelationalDynamicTableSource.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/main/java/org/apache/iotdb/relational/flink/table/IoTDBRelationalDynamicTableSource.java
index f9d43c9..7adcb7e 100644
--- 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/main/java/org/apache/iotdb/relational/flink/table/IoTDBRelationalDynamicTableSource.java
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/main/java/org/apache/iotdb/relational/flink/table/IoTDBRelationalDynamicTableSource.java
@@ -22,20 +22,26 @@ package org.apache.iotdb.relational.flink.table;
 import org.apache.iotdb.relational.flink.cfg.IoTDBOptions;
 import org.apache.iotdb.relational.flink.source.IoTDBSource;
 import 
org.apache.iotdb.relational.flink.source.deserializer.RowDataDeserializationSchema;
+import 
org.apache.iotdb.relational.flink.source.lookup.IoTDBAsyncLookupFunction;
+import org.apache.iotdb.relational.flink.source.lookup.IoTDBLookupFunction;
 import org.apache.iotdb.relational.flink.source.pushdown.AggregateSpec;
 import 
org.apache.iotdb.relational.flink.source.pushdown.IoTDBAggregatePushDownUtils;
 import 
org.apache.iotdb.relational.flink.source.pushdown.IoTDBExpressionVisitor;
 import org.apache.iotdb.relational.flink.utils.IoTDBUtils;
 
+import org.apache.flink.table.api.TableException;
 import org.apache.flink.table.catalog.ResolvedSchema;
 import org.apache.flink.table.connector.ChangelogMode;
 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.SourceProvider;
 import 
org.apache.flink.table.connector.source.abilities.SupportsAggregatePushDown;
 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.lookup.AsyncLookupFunctionProvider;
+import org.apache.flink.table.connector.source.lookup.LookupFunctionProvider;
 import org.apache.flink.table.expressions.AggregateExpression;
 import org.apache.flink.table.expressions.ResolvedExpression;
 import org.apache.flink.table.types.DataType;
@@ -47,11 +53,12 @@ import java.util.List;
 /**
  * Dynamic table source of the IoTDB relational (table model) Flink connector.
  *
- * <p>Only scan reads are exposed for now. Projection pushdown is supported 
for top-level fields.
- * Lookup reads are intentionally disabled until their runtime behavior is 
implemented.
+ * <p>Scan reads are fully implemented. Lookup reads are declared through 
{@link LookupTableSource}
+ * but their runtime behavior is still a stub.
  */
 public class IoTDBRelationalDynamicTableSource
     implements ScanTableSource,
+        LookupTableSource,
         SupportsFilterPushDown,
         SupportsLimitPushDown,
         SupportsProjectionPushDown,
@@ -87,6 +94,33 @@ public class IoTDBRelationalDynamicTableSource
             aggregateSpec));
   }
 
+  @Override
+  public LookupRuntimeProvider getLookupRuntimeProvider(LookupContext 
lookupContext) {
+    // Lookup key indices refer to the scan's current row type, i.e. after any 
projection that has
+    // already been pushed into this source.
+    DataType lookupRowDataType = physicalRowDataType;
+    List<String> fieldNames = DataType.getFieldNames(lookupRowDataType);
+    int[][] keys = lookupContext.getKeys();
+    int[] keyIndices = new int[keys.length];
+    for (int i = 0; i < keys.length; i++) {
+      if (keys[i] == null || keys[i].length != 1) {
+        throw new TableException("IoTDB lookup supports only top-level lookup 
keys.");
+      }
+      int keyIndex = keys[i][0];
+      if (keyIndex < 0 || keyIndex >= fieldNames.size()) {
+        throw new TableException("Invalid IoTDB lookup key index: " + 
keyIndex);
+      }
+      keyIndices[i] = keyIndex;
+    }
+    if (options.isLookupAsync()) {
+      return AsyncLookupFunctionProvider.of(
+          new IoTDBAsyncLookupFunction(
+              options, lookupRowDataType, keyIndices, 
options.getLookupThreadSize()));
+    }
+    return LookupFunctionProvider.of(
+        new IoTDBLookupFunction(options, lookupRowDataType, keyIndices));
+  }
+
   @Override
   public boolean supportsNestedProjection() {
     return false;
diff --git 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/test/java/org/apache/iotdb/relational/flink/IoTDBRelationalLocalQueryManual.java
 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/test/java/org/apache/iotdb/relational/flink/IoTDBRelationalLocalQueryManual.java
index 02b4b5e..68c6712 100644
--- 
a/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/test/java/org/apache/iotdb/relational/flink/IoTDBRelationalLocalQueryManual.java
+++ 
b/connectors/flink-iotdb-table-connector/flink-iotdb-table-connector-flink1/src/test/java/org/apache/iotdb/relational/flink/IoTDBRelationalLocalQueryManual.java
@@ -19,9 +19,11 @@
 
 package org.apache.iotdb.relational.flink;
 
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
 import org.apache.flink.table.api.EnvironmentSettings;
 import org.apache.flink.table.api.TableEnvironment;
 import org.apache.flink.table.api.TableResult;
+import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
 import org.apache.flink.types.Row;
 import org.apache.flink.util.CloseableIterator;
 
@@ -30,7 +32,7 @@ import org.apache.flink.util.CloseableIterator;
  * committed as a production test.
  *
  * <p>The flow is write first and then query: rows are inserted through the 
connector and read back
- * with a SELECT.
+ * with a SELECT, followed by a lookup join (both sync and async) executed as 
Flink SQL.
  *
  * <p>Run with system properties such as:
  *
@@ -59,24 +61,7 @@ public class IoTDBRelationalLocalQueryManual {
     tableEnvironment.executeSql("DROP TABLE IF EXISTS iotdb_table");
 
     // Replace these columns with the actual columns and categories in the 
local IoTDB table.
-    String ddl =
-        String.format(
-            "CREATE TABLE iotdb_table (\n"
-                + "  `time` TIMESTAMP(3),\n"
-                + "  `device_id` STRING,\n"
-                + "  `temperature` DOUBLE\n"
-                + ") WITH (\n"
-                + "  'connector' = 'iotdb-relational',\n"
-                + "  'nodeUrls' = '%s',\n"
-                + "  'user' = '%s',\n"
-                + "  'password' = '%s',\n"
-                + "  'database' = '%s',\n"
-                + "  'table' = '%s',\n"
-                + "  'time-column' = 'time',\n"
-                + "  'tag-columns' = 'device_id'\n"
-                + ")",
-            nodeUrls, user, password, database, table);
-
+    String ddl = buildDdl("iotdb_table", nodeUrls, user, password, database, 
table, null);
     System.out.println("DDL:\n" + ddl);
     tableEnvironment.executeSql(ddl);
 
@@ -96,9 +81,87 @@ public class IoTDBRelationalLocalQueryManual {
     run(
         tableEnvironment,
         "SELECT device_id, temperature FROM iotdb_table WHERE temperature > 0 
LIMIT 1");
-    run(
-        tableEnvironment,
-        "SELECT temperature FROM iotdb_table WHERE temperature + 1 > 21 LIMIT 
5");
+    run(tableEnvironment, "SELECT temperature FROM iotdb_table WHERE 
temperature + 1 > 21 LIMIT 5");
+
+    // 3) Lookup: run a temporal join through SQL for both sync and async 
lookup.
+    verifyLookupJoin(nodeUrls, user, password, database, table, false);
+    verifyLookupJoin(nodeUrls, user, password, database, table, true);
+  }
+
+  private static void verifyLookupJoin(
+      String nodeUrls, String user, String password, String database, String 
table, boolean async)
+      throws Exception {
+    StreamExecutionEnvironment environment = 
StreamExecutionEnvironment.getExecutionEnvironment();
+    environment.setParallelism(1);
+    StreamTableEnvironment tableEnvironment = 
StreamTableEnvironment.create(environment);
+
+    String flinkTable = "iotdb_lookup_" + (async ? "async" : "sync");
+    tableEnvironment.executeSql("DROP TABLE IF EXISTS " + flinkTable);
+    tableEnvironment.executeSql(
+        buildDdl(flinkTable, nodeUrls, user, password, database, table, 
async));
+
+    tableEnvironment.executeSql(
+        "CREATE TEMPORARY VIEW probe AS "
+            + "SELECT device_id, PROCTIME() AS proc_time FROM "
+            + flinkTable);
+
+    System.out.println("\n=== LOOKUP " + (async ? "ASYNC" : "SYNC") + " ===");
+    String sql =
+        "SELECT p.device_id, d.temperature FROM probe AS p "
+            + "JOIN "
+            + flinkTable
+            + " FOR SYSTEM_TIME AS OF p.proc_time AS d "
+            + "ON p.device_id = d.device_id";
+    System.out.println(sql);
+
+    TableResult result = tableEnvironment.executeSql(sql);
+    try (CloseableIterator<Row> iterator = result.collect()) {
+      while (iterator.hasNext()) {
+        System.out.println(iterator.next());
+      }
+    }
+  }
+
+  private static String buildDdl(
+      String flinkTable,
+      String nodeUrls,
+      String user,
+      String password,
+      String database,
+      String table,
+      Boolean async) {
+    StringBuilder ddl =
+        new StringBuilder()
+            .append("CREATE TABLE ")
+            .append(flinkTable)
+            .append(" (\n")
+            .append("  `time` TIMESTAMP(3),\n")
+            .append("  `device_id` STRING,\n")
+            .append("  `temperature` DOUBLE\n")
+            .append(") WITH (\n")
+            .append("  'connector' = 'iotdb-relational',\n")
+            .append("  'nodeUrls' = '")
+            .append(nodeUrls)
+            .append("',\n")
+            .append("  'user' = '")
+            .append(user)
+            .append("',\n")
+            .append("  'password' = '")
+            .append(password)
+            .append("',\n")
+            .append("  'database' = '")
+            .append(database)
+            .append("',\n")
+            .append("  'table' = '")
+            .append(table)
+            .append("',\n")
+            .append("  'time-column' = 'time',\n")
+            .append("  'tag-columns' = 'device_id'");
+    if (async != null) {
+      ddl.append(",\n  'lookup.async' = '").append(async).append("'");
+    }
+    ddl.append("\n)");
+    return ddl.toString();
   }
 
   private static void execute(TableEnvironment tableEnvironment, String sql) 
throws Exception {

Reply via email to