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

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


The following commit(s) were added to refs/heads/master by this push:
     new 0280aa7b51a Implement FFT table function for table model
0280aa7b51a is described below

commit 0280aa7b51a0c3c34401edd19165ee26f5f7358c
Author: DaZuiZui <[email protected]>
AuthorDate: Wed Aug 26 15:46:11 2026 +0800

    Implement FFT table function for table model
---
 .../it/db/it/IoTDBFFTTableFunctionIT.java          | 293 ++++++++
 .../relational/analyzer/StatementAnalyzer.java     |  65 +-
 .../plan/relational/planner/RelationPlanner.java   |   3 +
 .../distribute/TableDistributedPlanGenerator.java  |  23 +-
 .../relational/analyzer/TableFunctionTest.java     | 174 +++++
 .../apache/iotdb/commons/i18n/QueryMessages.java   |  39 +
 .../apache/iotdb/commons/i18n/QueryMessages.java   |  36 +
 .../function/TableBuiltinTableFunction.java        |   4 +
 .../builtin/relational/tvf/FFTTableFunction.java   | 804 +++++++++++++++++++++
 .../builtin/relational/tvf/fft/DoubleFFT_1D.java   | 159 ++++
 .../builtin/relational/tvf/fft/FloatFFT_1D.java    | 160 ++++
 .../relational/tvf/FFTTableFunctionTest.java       | 518 +++++++++++++
 .../udf/builtin/relational/tvf/fft/FFT1DTest.java  | 109 +++
 13 files changed, 2381 insertions(+), 6 deletions(-)

diff --git 
a/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBFFTTableFunctionIT.java
 
b/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBFFTTableFunctionIT.java
new file mode 100644
index 00000000000..874f5bf9076
--- /dev/null
+++ 
b/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBFFTTableFunctionIT.java
@@ -0,0 +1,293 @@
+/*
+ * 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.it.db.it;
+
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.TableClusterIT;
+import org.apache.iotdb.itbase.category.TableLocalStandaloneIT;
+
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import static org.apache.iotdb.db.it.utils.TestUtils.tableAssertTestFail;
+import static org.apache.iotdb.db.it.utils.TestUtils.tableResultSetEqualTest;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+@RunWith(IoTDBTestRunner.class)
+@Category({TableLocalStandaloneIT.class, TableClusterIT.class})
+public class IoTDBFFTTableFunctionIT {
+
+  private static final String DATABASE_NAME = "test_fft";
+  private static final double DELTA = 1e-9;
+  private static final String[] SQLS =
+      new String[] {
+        "CREATE DATABASE " + DATABASE_NAME,
+        "USE " + DATABASE_NAME,
+        "CREATE TABLE signal(device_id STRING TAG, temperature DOUBLE FIELD, 
speed INT32 FIELD, note STRING FIELD)",
+        "INSERT INTO signal(time, device_id, temperature, speed, note) VALUES 
(0, 'd1', 1.0, 1, 'ok')",
+        "INSERT INTO signal(time, device_id, temperature, speed, note) VALUES 
(1000, 'd1', 0.0, 2, 'ok')",
+        "INSERT INTO signal(time, device_id, temperature, speed, note) VALUES 
(2000, 'd1', 0.0, 3, 'ok')",
+        "INSERT INTO signal(time, device_id, temperature, speed, note) VALUES 
(3000, 'd1', 0.0, 4, 'ok')",
+        "INSERT INTO signal(time, device_id, temperature, speed, note) VALUES 
(0, 'd2', 2.0, 4, 'ok')",
+        "INSERT INTO signal(time, device_id, temperature, speed, note) VALUES 
(1000, 'd2', 0.0, 3, 'ok')",
+        "INSERT INTO signal(time, device_id, temperature, speed, note) VALUES 
(2000, 'd2', 0.0, 2, 'ok')",
+        "INSERT INTO signal(time, device_id, temperature, speed, note) VALUES 
(3000, 'd2', 0.0, 1, 'ok')",
+        "CREATE TABLE single_row(device_id STRING TAG, value DOUBLE FIELD)",
+        "INSERT INTO single_row(time, device_id, value) VALUES (0, 'd1', 1.0)",
+        "CREATE TABLE no_numeric(device_id STRING TAG, note STRING FIELD)",
+        "INSERT INTO no_numeric(time, device_id, note) VALUES (0, 'd1', 'ok')",
+        "CREATE TABLE with_null(device_id STRING TAG, value DOUBLE FIELD)",
+        "INSERT INTO with_null(time, device_id, value) VALUES (0, 'd1', 1.0)",
+        "INSERT INTO with_null(time, device_id, value) VALUES (1000, 'd1', 
null)",
+        "CREATE TABLE irregular(device_id STRING TAG, value DOUBLE FIELD)",
+        "INSERT INTO irregular(time, device_id, value) VALUES (0, 'd1', 1.0)",
+        "INSERT INTO irregular(time, device_id, value) VALUES (1000, 'd1', 
2.0)",
+        "INSERT INTO irregular(time, device_id, value) VALUES (2500, 'd1', 
3.0)",
+        "CREATE TABLE custom_time_signal(event_time TIMESTAMP TIME, value 
DOUBLE FIELD)",
+        "INSERT INTO custom_time_signal(event_time, value) VALUES (0, 1.0)",
+        "INSERT INTO custom_time_signal(event_time, value) VALUES (1000, 0.0)",
+        "INSERT INTO custom_time_signal(event_time, value) VALUES (2000, 0.0)",
+        "INSERT INTO custom_time_signal(event_time, value) VALUES (3000, 0.0)",
+        "FLUSH"
+      };
+
+  @BeforeClass
+  public static void setUp() throws Exception {
+    EnvFactory.getEnv().initClusterEnvironment();
+    insertData();
+  }
+
+  @AfterClass
+  public static void tearDown() throws Exception {
+    EnvFactory.getEnv().cleanClusterEnvironment();
+  }
+
+  private static void insertData() {
+    String currentSql = null;
+    try (Connection connection = EnvFactory.getEnv().getTableConnection();
+        Statement statement = connection.createStatement()) {
+      for (String sql : SQLS) {
+        currentSql = sql;
+        statement.execute(sql);
+      }
+    } catch (Exception e) {
+      throw new AssertionError("insertData failed while executing [" + 
currentSql + "].", e);
+    }
+  }
+
+  @Test
+  public void testFFTWithPartitionAndMultipleColumns() {
+    String[] expectedHeader =
+        new String[] {
+          "device_id",
+          "frequency_index",
+          "frequency",
+          "temperature_real",
+          "temperature_imag",
+          "speed_real",
+          "speed_imag"
+        };
+    assertFftRows(
+        "SELECT * FROM FFT(DATA => signal PARTITION BY device_id ORDER BY 
time, "
+            + "SAMPLE_INTERVAL => 1s, N => 4) ORDER BY device_id, 
frequency_index",
+        expectedHeader,
+        new Object[][] {
+          {"d1", 0L, 0.0, 1.0, 0.0, 10.0, 0.0},
+          {"d1", 1L, 0.25, 1.0, 0.0, -2.0, 2.0},
+          {"d1", 2L, -0.5, 1.0, 0.0, -2.0, 0.0},
+          {"d1", 3L, -0.25, 1.0, 0.0, -2.0, -2.0},
+          {"d2", 0L, 0.0, 2.0, 0.0, 10.0, 0.0},
+          {"d2", 1L, 0.25, 2.0, 0.0, 2.0, -2.0},
+          {"d2", 2L, -0.5, 2.0, 0.0, 2.0, 0.0},
+          {"d2", 3L, -0.25, 2.0, 0.0, 2.0, 2.0}
+        });
+  }
+
+  @Test
+  public void testFFTDefaultNAndInferredSampleInterval() {
+    String[] expectedHeader =
+        new String[] {"frequency_index", "frequency", "temperature_real", 
"temperature_imag"};
+    String[] retArray =
+        new String[] {"0,0.0,1.0,0.0,", "1,0.25,1.0,0.0,", "2,-0.5,1.0,0.0,", 
"3,-0.25,1.0,0.0,"};
+
+    tableResultSetEqualTest(
+        "SELECT frequency_index, frequency, temperature_real, temperature_imag 
"
+            + "FROM FFT(DATA => (SELECT time, temperature FROM signal WHERE 
device_id='d1') "
+            + "ORDER BY time) ORDER BY frequency_index",
+        expectedHeader,
+        retArray,
+        DATABASE_NAME);
+  }
+
+  @Test
+  public void testFFTWithSpecifiedTimeColumn() {
+    tableResultSetEqualTest(
+        "SELECT frequency_index, frequency, value_real, value_imag "
+            + "FROM FFT(DATA => custom_time_signal ORDER BY event_time, "
+            + "TIMECOL => 'event_time', SAMPLE_INTERVAL => 1s, N => 4) "
+            + "ORDER BY frequency_index",
+        new String[] {"frequency_index", "frequency", "value_real", 
"value_imag"},
+        new String[] {"0,0.0,1.0,0.0,", "1,0.25,1.0,0.0,", "2,-0.5,1.0,0.0,", 
"3,-0.25,1.0,0.0,"},
+        DATABASE_NAME);
+  }
+
+  @Test
+  public void testFFTTruncateAndZeroPad() {
+    tableResultSetEqualTest(
+        "SELECT frequency_index, frequency, speed_real, speed_imag "
+            + "FROM FFT(DATA => (SELECT time, speed FROM signal WHERE 
device_id='d1') "
+            + "ORDER BY time, SAMPLE_INTERVAL => 1s, N => 2) ORDER BY 
frequency_index",
+        new String[] {"frequency_index", "frequency", "speed_real", 
"speed_imag"},
+        new String[] {"0,0.0,3.0,0.0,", "1,-0.5,-1.0,0.0,"},
+        DATABASE_NAME);
+
+    tableResultSetEqualTest(
+        "SELECT frequency_index, frequency, temperature_real, temperature_imag 
"
+            + "FROM FFT(DATA => (SELECT time, temperature FROM signal WHERE 
device_id='d1') "
+            + "ORDER BY time, SAMPLE_INTERVAL => 1s, N => 8) ORDER BY 
frequency_index",
+        new String[] {"frequency_index", "frequency", "temperature_real", 
"temperature_imag"},
+        new String[] {
+          "0,0.0,1.0,0.0,",
+          "1,0.125,1.0,0.0,",
+          "2,0.25,1.0,0.0,",
+          "3,0.375,1.0,0.0,",
+          "4,-0.5,1.0,0.0,",
+          "5,-0.375,1.0,0.0,",
+          "6,-0.25,1.0,0.0,",
+          "7,-0.125,1.0,0.0,"
+        },
+        DATABASE_NAME);
+  }
+
+  @Test
+  public void testFFTNorm() {
+    tableResultSetEqualTest(
+        "SELECT frequency_index, temperature_real, temperature_imag "
+            + "FROM FFT(DATA => (SELECT time, temperature FROM signal WHERE 
device_id='d1') "
+            + "ORDER BY time, SAMPLE_INTERVAL => 1s, N => 4, NORM => 
'forward') "
+            + "ORDER BY frequency_index",
+        new String[] {"frequency_index", "temperature_real", 
"temperature_imag"},
+        new String[] {"0,0.25,0.0,", "1,0.25,0.0,", "2,0.25,0.0,", 
"3,0.25,0.0,"},
+        DATABASE_NAME);
+
+    tableResultSetEqualTest(
+        "SELECT frequency_index, temperature_real, temperature_imag "
+            + "FROM FFT(DATA => (SELECT time, temperature FROM signal WHERE 
device_id='d1') "
+            + "ORDER BY time, SAMPLE_INTERVAL => 1s, N => 4, NORM => 'ortho') "
+            + "ORDER BY frequency_index",
+        new String[] {"frequency_index", "temperature_real", 
"temperature_imag"},
+        new String[] {"0,0.5,0.0,", "1,0.5,0.0,", "2,0.5,0.0,", "3,0.5,0.0,"},
+        DATABASE_NAME);
+  }
+
+  @Test
+  public void testFFTIrregularTimeUsesInferredOrExplicitSampleInterval() {
+    String[] expectedHeader =
+        new String[] {"device_id", "frequency_index", "frequency", 
"value_real", "value_imag"};
+
+    assertFftRows(
+        "SELECT * FROM FFT(DATA => irregular PARTITION BY device_id ORDER BY 
time) "
+            + "ORDER BY frequency_index",
+        expectedHeader,
+        new Object[][] {
+          {"d1", 0L, 0.0, 6.0, 0.0},
+          {"d1", 1L, 0.26666666666666666, -1.5, 0.8660254037844386},
+          {"d1", 2L, -0.26666666666666666, -1.5, -0.8660254037844386}
+        });
+
+    assertFftRows(
+        "SELECT * FROM FFT(DATA => irregular PARTITION BY device_id ORDER BY 
time, "
+            + "SAMPLE_INTERVAL => 1s) ORDER BY frequency_index",
+        expectedHeader,
+        new Object[][] {
+          {"d1", 0L, 0.0, 6.0, 0.0},
+          {"d1", 1L, 0.3333333333333333, -1.5, 0.8660254037844386},
+          {"d1", 2L, -0.3333333333333333, -1.5, -0.8660254037844386}
+        });
+  }
+
+  @Test
+  public void testFFTFailures() {
+    tableAssertTestFail(
+        "SELECT * FROM FFT(DATA => signal PARTITION BY device_id, 
SAMPLE_INTERVAL => 1s)",
+        "701: Table argument with set semantics requires an ORDER BY clause.",
+        DATABASE_NAME);
+    tableAssertTestFail(
+        "SELECT * FROM FFT(DATA => single_row PARTITION BY device_id ORDER BY 
time)",
+        "701: FFT requires at least two rows to infer SAMPLE_INTERVAL.",
+        DATABASE_NAME);
+    tableAssertTestFail(
+        "SELECT * FROM FFT(DATA => no_numeric PARTITION BY device_id ORDER BY 
time, SAMPLE_INTERVAL => 1s)",
+        "701: No numeric columns found for FFT calculation.",
+        DATABASE_NAME);
+    tableAssertTestFail(
+        "SELECT * FROM FFT(DATA => with_null PARTITION BY device_id ORDER BY 
time, SAMPLE_INTERVAL => 1s)",
+        "701: FFT does not support null values in column [value].",
+        DATABASE_NAME);
+    tableAssertTestFail(
+        "SELECT * FROM FFT(DATA => signal PARTITION BY device_id ORDER BY 
time, N => 65537)",
+        "701: FFT transform length N must not exceed 65536.",
+        DATABASE_NAME);
+  }
+
+  private static void assertFftRows(String sql, String[] expectedHeader, 
Object[][] expectedRows) {
+    try (Connection connection = EnvFactory.getEnv().getTableConnection();
+        Statement statement = connection.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      try (ResultSet resultSet = statement.executeQuery(sql)) {
+        assertHeader(resultSet.getMetaData(), expectedHeader);
+
+        int rowIndex = 0;
+        while (resultSet.next()) {
+          Object[] expectedRow = expectedRows[rowIndex];
+          assertEquals(expectedRow[0], resultSet.getString(1));
+          assertEquals(expectedRow[1], resultSet.getLong(2));
+          for (int columnIndex = 2; columnIndex < expectedRow.length; 
columnIndex++) {
+            assertEquals(
+                (double) expectedRow[columnIndex], 
resultSet.getDouble(columnIndex + 1), DELTA);
+          }
+          rowIndex++;
+        }
+        assertEquals(expectedRows.length, rowIndex);
+      }
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  private static void assertHeader(ResultSetMetaData metaData, String[] 
expectedHeader)
+      throws SQLException {
+    assertEquals(expectedHeader.length, metaData.getColumnCount());
+    for (int i = 1; i <= metaData.getColumnCount(); i++) {
+      assertEquals(expectedHeader[i - 1], metaData.getColumnName(i));
+    }
+  }
+}
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
index 741116c844c..9a54519953d 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
@@ -121,6 +121,7 @@ import 
org.apache.iotdb.commons.queryengine.utils.cte.CteDataStore;
 import org.apache.iotdb.commons.schema.table.TsTable;
 import org.apache.iotdb.commons.schema.table.column.TsTableColumnCategory;
 import org.apache.iotdb.commons.schema.table.column.TsTableColumnSchema;
+import org.apache.iotdb.commons.udf.builtin.relational.tvf.FFTTableFunction;
 import org.apache.iotdb.commons.udf.builtin.relational.tvf.M4TableFunction;
 import org.apache.iotdb.commons.udf.utils.UDFDataTypeTransformer;
 import org.apache.iotdb.db.i18n.DataNodeQueryMessages;
@@ -5775,7 +5776,7 @@ public class StatementAnalyzer {
         Scope argumentScope = analysis.getScope(argument.getRelation());
         if (argument.isPassThroughColumns()) {
           argumentScope.getRelationType().getAllFields().forEach(fields::add);
-        } else if 
(!TableBuiltinTableFunction.M4.getFunctionName().equalsIgnoreCase(functionName)
+        } else if (!isPartitionColumnsProvidedByProperSchema(functionName)
             && argument.getPartitionBy().isPresent()) {
           argument.getPartitionBy().get().stream()
               .map(expression -> validateAndGetInputField(expression, 
argumentScope))
@@ -5915,9 +5916,71 @@ public class StatementAnalyzer {
         }
       }
       tryAppendM4ModeArgument(functionName, arguments, 
parameterSpecifications, passedArguments);
+      tryAppendFFTInternalArguments(
+          functionName, arguments, parameterSpecifications, passedArguments);
       return new ArgumentsAnalysis(passedArguments.buildOrThrow(), 
tableArgumentAnalyses.build());
     }
 
+    private boolean isPartitionColumnsProvidedByProperSchema(String 
functionName) {
+      return 
TableBuiltinTableFunction.M4.getFunctionName().equalsIgnoreCase(functionName)
+          || 
TableBuiltinTableFunction.FFT.getFunctionName().equalsIgnoreCase(functionName);
+    }
+
+    private void tryAppendFFTInternalArguments(
+        String functionName,
+        List<TableFunctionArgument> arguments,
+        List<ParameterSpecification> parameterSpecifications,
+        ImmutableMap.Builder<String, Argument> passedArguments) {
+      if 
(!TableBuiltinTableFunction.FFT.getFunctionName().equalsIgnoreCase(functionName))
 {
+        return;
+      }
+
+      Optional<TableFunctionArgument> sampleIntervalArgument =
+          findOptionalTableFunctionArgument(
+              arguments, parameterSpecifications, 
FFTTableFunction.SAMPLE_INTERVAL_PARAMETER_NAME);
+      if (sampleIntervalArgument.isPresent()
+          && !(sampleIntervalArgument.get().getValue() instanceof 
TimeDurationLiteral)) {
+        throw new 
SemanticException(QueryMessages.FFT_SAMPLE_INTERVAL_MUST_BE_DURATION_LITERAL);
+      }
+
+      Optional<TableFunctionArgument> nArgument =
+          findOptionalTableFunctionArgument(
+              arguments, parameterSpecifications, 
FFTTableFunction.N_PARAMETER_NAME);
+      if (nArgument.isPresent() && nArgument.get().getValue() instanceof 
TimeDurationLiteral) {
+        throw new 
SemanticException(QueryMessages.FFT_N_MUST_BE_POSITIVE_INTEGER);
+      }
+
+      validateFFTOrderBySortOrder(arguments, parameterSpecifications);
+      passedArguments.put(
+          FFTTableFunction.SAMPLE_INTERVAL_SPECIFIED_PARAMETER_NAME,
+          new ScalarArgument(
+              org.apache.iotdb.udf.api.type.Type.BOOLEAN, 
sampleIntervalArgument.isPresent()));
+    }
+
+    private void validateFFTOrderBySortOrder(
+        List<TableFunctionArgument> arguments,
+        List<ParameterSpecification> parameterSpecifications) {
+      Optional<TableFunctionArgument> dataArgument =
+          findOptionalTableFunctionArgument(
+              arguments, parameterSpecifications, 
FFTTableFunction.DATA_PARAMETER_NAME);
+      if (!dataArgument.isPresent()
+          || !(dataArgument.get().getValue() instanceof 
TableFunctionTableArgument)) {
+        return;
+      }
+
+      Optional<OrderBy> orderBy =
+          ((TableFunctionTableArgument) 
dataArgument.get().getValue()).getOrderBy();
+      if (!orderBy.isPresent()) {
+        return;
+      }
+
+      for (SortItem sortItem : orderBy.get().getSortItems()) {
+        if (sortItem.getOrdering() != SortItem.Ordering.ASCENDING) {
+          throw new 
SemanticException(QueryMessages.FFT_ORDER_BY_MUST_SORT_ASCENDING);
+        }
+      }
+    }
+
     private void tryAppendM4ModeArgument(
         String functionName,
         List<TableFunctionArgument> arguments,
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/RelationPlanner.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/RelationPlanner.java
index 117d5db4e5c..7fc367b3785 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/RelationPlanner.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/RelationPlanner.java
@@ -1575,6 +1575,9 @@ public class RelationPlanner implements 
AstVisitor<RelationPlan, Void> {
       } else if (!TableBuiltinTableFunction.M4
               .getFunctionName()
               .equalsIgnoreCase(functionAnalysis.getFunctionName())
+          && !TableBuiltinTableFunction.FFT
+              .getFunctionName()
+              .equalsIgnoreCase(functionAnalysis.getFunctionName())
           && tableArgument.getPartitionBy().isPresent()) {
         tableArgument.getPartitionBy().get().stream()
             // the original symbols for partitioning columns, not coerced
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanGenerator.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanGenerator.java
index 25845758a08..60a9cd2cba2 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanGenerator.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanGenerator.java
@@ -32,6 +32,7 @@ import 
org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
 import 
org.apache.iotdb.commons.queryengine.plan.planner.plan.node.process.SingleChildProcessNode;
 import 
org.apache.iotdb.commons.queryengine.plan.relational.function.BoundSignature;
 import 
org.apache.iotdb.commons.queryengine.plan.relational.function.FunctionId;
+import 
org.apache.iotdb.commons.queryengine.plan.relational.function.TableBuiltinTableFunction;
 import 
org.apache.iotdb.commons.queryengine.plan.relational.metadata.ColumnSchema;
 import 
org.apache.iotdb.commons.queryengine.plan.relational.metadata.QualifiedObjectName;
 import 
org.apache.iotdb.commons.queryengine.plan.relational.metadata.ResolvedFunction;
@@ -1906,22 +1907,34 @@ public class TableDistributedPlanGenerator
     if (node.getChildren().isEmpty()) {
       return Collections.singletonList(node);
     }
-    boolean canSplitPushDown = node.isRowSemantic() || (node.getChild() 
instanceof GroupNode);
+    boolean canSplitPushDown = canSplitTableFunctionProcessor(node);
     List<PlanNode> childrenNodes = node.getChild().accept(this, context);
     if (childrenNodes.size() == 1) {
       node.setChild(childrenNodes.get(0));
       return Collections.singletonList(node);
     } else if (!canSplitPushDown) {
-      CollectNode collectNode =
-          new CollectNode(queryId.genPlanNodeId(), 
node.getChildren().get(0).getOutputSymbols());
-      childrenNodes.forEach(collectNode::addChild);
-      node.setChild(collectNode);
+      OrderingScheme childOrdering = 
nodeOrderingMap.get(childrenNodes.get(0).getPlanNodeId());
+      node.setChild(mergeChildrenViaCollectOrMergeSort(childOrdering, 
childrenNodes));
       return Collections.singletonList(node);
     } else {
       return splitForEachChild(node, childrenNodes);
     }
   }
 
+  private boolean canSplitTableFunctionProcessor(TableFunctionProcessorNode 
node) {
+    if (node.isRowSemantic()) {
+      return true;
+    }
+    if (!isPartitionedGroup(node.getChild())) {
+      return false;
+    }
+    return 
!TableBuiltinTableFunction.FFT.getFunctionName().equalsIgnoreCase(node.getName());
+  }
+
+  private boolean isPartitionedGroup(PlanNode node) {
+    return node instanceof GroupNode && ((GroupNode) 
node).getPartitionKeyCount() > 0;
+  }
+
   private void buildRegionNodeMap(
       AggregationTableScanNode originalAggTableScanNode,
       List<List<TRegionReplicaSet>> regionReplicaSetsList,
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/TableFunctionTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/TableFunctionTest.java
index 9d09ff057b3..637113026f4 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/TableFunctionTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/TableFunctionTest.java
@@ -22,6 +22,7 @@ package 
org.apache.iotdb.db.queryengine.plan.relational.analyzer;
 import org.apache.iotdb.commons.exception.SemanticException;
 import 
org.apache.iotdb.commons.queryengine.plan.relational.function.tvf.ForecastTableFunction;
 import 
org.apache.iotdb.commons.queryengine.plan.relational.planner.node.JoinNode;
+import org.apache.iotdb.commons.udf.builtin.relational.tvf.FFTTableFunction;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.LogicalQueryPlan;
 import org.apache.iotdb.db.queryengine.plan.relational.planner.PlanTester;
 import 
org.apache.iotdb.db.queryengine.plan.relational.planner.assertions.PlanMatchPattern;
@@ -482,6 +483,179 @@ public class TableFunctionTest {
     }
   }
 
+  @Test
+  public void testFFTFunction() {
+    PlanTester planTester = new PlanTester();
+    String sql =
+        "SELECT * FROM FFT("
+            + "DATA => table1 PARTITION BY tag1 ORDER BY time, "
+            + "SAMPLE_INTERVAL => 1s, "
+            + "N => 4, "
+            + "NORM => 'ortho')";
+    LogicalQueryPlan logicalQueryPlan = planTester.createPlan(sql);
+    PlanMatchPattern tableScan =
+        tableScan(
+            "testdb.table1",
+            ImmutableMap.<String, String>builder()
+                .put("time", "time")
+                .put("tag1_0", "tag1")
+                .put("s1", "s1")
+                .put("s2", "s2")
+                .put("s3", "s3")
+                .buildOrThrow());
+
+    Consumer<TableFunctionProcessorMatcher.Builder> tableFunctionMatcher =
+        builder ->
+            builder
+                .name("fft")
+                .properOutputs(
+                    "tag1",
+                    "frequency_index",
+                    "frequency",
+                    "s1_real",
+                    "s1_imag",
+                    "s2_real",
+                    "s2_imag",
+                    "s3_real",
+                    "s3_imag")
+                .requiredSymbols("time", "tag1_0", "s1", "s2", "s3")
+                .handle(
+                    new MapTableFunctionHandle.Builder()
+                        
.addProperty(FFTTableFunction.SAMPLE_INTERVAL_PARAMETER_NAME, 1000L)
+                        .addProperty(
+                            
FFTTableFunction.SAMPLE_INTERVAL_SPECIFIED_PARAMETER_NAME, true)
+                        .addProperty(FFTTableFunction.N_PARAMETER_NAME, 4L)
+                        .addProperty(FFTTableFunction.NORM_PARAMETER_NAME, 
"ortho")
+                        .addProperty("__FFT_PARTITION_TYPES", "STRING")
+                        .addProperty("__FFT_VALUE_TYPES", "INT64,INT64,DOUBLE")
+                        .addProperty("__FFT_VALUE_NAMES", "czE=,czI=,czM=")
+                        .build());
+
+    assertPlan(
+        logicalQueryPlan, anyTree(tableFunctionProcessor(tableFunctionMatcher, 
sort(tableScan))));
+    assertPlan(
+        planTester.getFragmentPlan(0),
+        output(
+            tableFunctionProcessor(
+                tableFunctionMatcher, mergeSort(exchange(), exchange(), 
exchange()))));
+    assertPlan(planTester.getFragmentPlan(1), sort(tableScan));
+    assertPlan(planTester.getFragmentPlan(2), sort(tableScan));
+    assertPlan(planTester.getFragmentPlan(3), sort(tableScan));
+  }
+
+  @Test
+  public void testFFTDefaultArguments() {
+    PlanTester planTester = new PlanTester();
+    String sql = "SELECT * FROM TABLE(FFT(DATA => TABLE(table1) ORDER BY 
time))";
+    LogicalQueryPlan logicalQueryPlan = planTester.createPlan(sql);
+    PlanMatchPattern tableScan =
+        tableScan(
+            "testdb.table1",
+            ImmutableMap.<String, String>builder()
+                .put("time", "time")
+                .put("s1", "s1")
+                .put("s2", "s2")
+                .put("s3", "s3")
+                .buildOrThrow());
+
+    Consumer<TableFunctionProcessorMatcher.Builder> tableFunctionMatcher =
+        builder ->
+            builder
+                .name("fft")
+                .properOutputs(
+                    "frequency_index",
+                    "frequency",
+                    "s1_real",
+                    "s1_imag",
+                    "s2_real",
+                    "s2_imag",
+                    "s3_real",
+                    "s3_imag")
+                .requiredSymbols("time", "s1", "s2", "s3")
+                .handle(
+                    new MapTableFunctionHandle.Builder()
+                        .addProperty(
+                            FFTTableFunction.SAMPLE_INTERVAL_PARAMETER_NAME, 
Long.MIN_VALUE)
+                        .addProperty(
+                            
FFTTableFunction.SAMPLE_INTERVAL_SPECIFIED_PARAMETER_NAME, false)
+                        .addProperty(FFTTableFunction.N_PARAMETER_NAME, -1L)
+                        .addProperty(FFTTableFunction.NORM_PARAMETER_NAME, 
"backward")
+                        .addProperty("__FFT_PARTITION_TYPES", "")
+                        .addProperty("__FFT_VALUE_TYPES", "INT64,INT64,DOUBLE")
+                        .addProperty("__FFT_VALUE_NAMES", "czE=,czI=,czM=")
+                        .build());
+
+    assertPlan(
+        logicalQueryPlan, anyTree(tableFunctionProcessor(tableFunctionMatcher, 
sort(tableScan))));
+    assertPlan(
+        planTester.getFragmentPlan(0),
+        output(
+            tableFunctionProcessor(
+                tableFunctionMatcher, mergeSort(exchange(), exchange(), 
exchange()))));
+    assertPlan(planTester.getFragmentPlan(1), sort(tableScan));
+    assertPlan(planTester.getFragmentPlan(2), sort(tableScan));
+    assertPlan(planTester.getFragmentPlan(3), sort(tableScan));
+  }
+
+  @Test
+  public void testFFTWithSpecifiedTimeColumn() {
+    PlanTester planTester = new PlanTester();
+    String sql =
+        "SELECT * FROM FFT("
+            + "DATA => (SELECT time AS event_time, tag1, s1 FROM table1) "
+            + "PARTITION BY tag1 ORDER BY event_time, "
+            + "TIMECOL => 'event_time', "
+            + "SAMPLE_INTERVAL => 1s, "
+            + "N => 4)";
+
+    planTester.createPlan(sql);
+  }
+
+  @Test
+  public void testFFTPositionalArgumentsKeepExistingOrder() {
+    PlanTester planTester = new PlanTester();
+    String sql = "SELECT * FROM TABLE(FFT(TABLE(table1) ORDER BY time, 1s, 4, 
'ortho'))";
+
+    planTester.createPlan(sql);
+  }
+
+  @Test
+  public void testFFTRejectsInvalidArguments() {
+    assertAnalyzeFails(
+        "SELECT * FROM FFT(DATA => table1 PARTITION BY tag1, SAMPLE_INTERVAL 
=> 1ms)",
+        "Table argument with set semantics requires an ORDER BY clause.");
+    assertAnalyzeFails(
+        "SELECT * FROM FFT(DATA => table1 PARTITION BY tag1 ORDER BY time 
DESC, SAMPLE_INTERVAL => 1ms)",
+        "The ORDER BY clause of the DATA argument must sort the time column in 
ascending order.");
+    assertAnalyzeFails(
+        "SELECT * FROM FFT(DATA => table1 PARTITION BY tag1 ORDER BY s1, 
SAMPLE_INTERVAL => 1ms)",
+        "The ORDER BY clause of the DATA argument must contain exactly the 
time column specified by the TIMECOL argument.");
+    assertAnalyzeFails(
+        "SELECT * FROM FFT(DATA => table1 PARTITION BY tag1 ORDER BY time, 
SAMPLE_INTERVAL => 1)",
+        "The SAMPLE_INTERVAL argument of FFT must be a duration literal.");
+    assertAnalyzeFails(
+        "SELECT * FROM FFT(DATA => table1 PARTITION BY tag1 ORDER BY time, N 
=> 0)",
+        "Invalid scalar argument N, should be a positive value");
+    assertAnalyzeFails(
+        "SELECT * FROM FFT(DATA => table1 PARTITION BY tag1 ORDER BY time, N 
=> 65537)",
+        "FFT transform length N must not exceed 65536.");
+    assertAnalyzeFails(
+        "SELECT * FROM FFT(DATA => table1 PARTITION BY tag1 ORDER BY time, 
NORM => 'bad')",
+        "Invalid NORM value for FFT. Supported values are backward, forward 
and ortho.");
+    assertAnalyzeFails(
+        "SELECT * FROM FFT(DATA => (SELECT time, tag1 FROM table1) PARTITION 
BY tag1 ORDER BY time)",
+        "No numeric columns found for FFT calculation.");
+  }
+
+  private void assertAnalyzeFails(String sql, String message) {
+    try {
+      analyzeSQL(sql, TEST_MATADATA, QUERY_CONTEXT);
+      fail();
+    } catch (SemanticException e) {
+      assertEquals(message, e.getMessage());
+    }
+  }
+
   @Test
   public void testM4TimeWindowMode() {
     PlanTester planTester = new PlanTester();
diff --git 
a/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/QueryMessages.java
 
b/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/QueryMessages.java
index fdee28c8cad..e84ebc6d9a9 100644
--- 
a/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/QueryMessages.java
+++ 
b/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/QueryMessages.java
@@ -554,4 +554,43 @@ public final class QueryMessages {
   public static final String EXCEPTION_MISSING_REQUIRED_ARGUMENT_ARG_1308D0DC 
= "Missing required argument: %s";
   public static final String EXCEPTION_UNKNOWN_ARGUMENT_ARG_F40E9394 = 
"Unknown argument: %s";
 
+  // ======================== FFT table function ========================
+
+  public static final String FFT_DATA_REQUIRES_ORDER_BY =
+      "Table argument with set semantics requires an ORDER BY clause.";
+  public static final String FFT_REQUIRES_NAMED_NUMERIC_COLUMNS =
+      "FFT requires named numeric input columns.";
+  public static final String FFT_NO_NUMERIC_COLUMNS =
+      "No numeric columns found for FFT calculation.";
+  public static final String FFT_ORDER_BY_MUST_CONTAIN_TIMECOL =
+      "The ORDER BY clause of the DATA argument must contain exactly the time 
column specified by the TIMECOL argument.";
+  public static final String FFT_TRANSFORM_LENGTH_EXCEEDS_LIMIT =
+      "FFT transform length N must not exceed %d.";
+  public static final String FFT_SPECTRUM_BUFFER_TOO_LARGE =
+      "FFT spectrum buffer is too large. Reduce N or the number of numeric 
columns.";
+  public static final String FFT_INVALID_NORM =
+      "Invalid NORM value for FFT. Supported values are backward, forward and 
ortho.";
+  public static final String FFT_UNSUPPORTED_PARTITION_TYPE =
+      "Unsupported FFT partition type: %s";
+  public static final String FFT_UNSUPPORTED_VALUE_TYPE = "Unsupported FFT 
value type: %s";
+  public static final String FFT_TIME_MUST_BE_STRICTLY_ASCENDING =
+      "The time column of FFT input must be strictly ascending within each 
partition.";
+  public static final String FFT_NULL_VALUE_NOT_SUPPORTED =
+      "FFT does not support null values in column [%s].";
+  public static final String FFT_NEEDS_TWO_ROWS_FOR_INTERVAL =
+      "FFT requires at least two rows to infer SAMPLE_INTERVAL.";
+  public static final String FFT_SAMPLE_INTERVAL_MUST_BE_POSITIVE =
+      "FFT SAMPLE_INTERVAL must be positive.";
+  public static final String FFT_SAMPLE_INTERVAL_MUST_BE_DURATION_LITERAL =
+      "The SAMPLE_INTERVAL argument of FFT must be a duration literal.";
+  public static final String FFT_N_MUST_BE_POSITIVE_INTEGER =
+      "The N argument of FFT must be a positive integer.";
+  public static final String FFT_ORDER_BY_MUST_SORT_ASCENDING =
+      "The ORDER BY clause of the DATA argument must sort the time column in 
ascending order.";
+  public static final String 
EXCEPTION_FFT_LENGTH_MUST_BE_A_POSITIVE_INT_SIZED_VALUE_A000D3BB =
+      "FFT length must be a positive int-sized value.";
+  public static final String
+      EXCEPTION_INPUT_ARRAY_LENGTH_MUST_BE_AT_LEAST_2_FFT_LENGTH_31DF6A25 =
+          "Input array length must be at least 2 * FFT length.";
+
 }
diff --git 
a/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/QueryMessages.java
 
b/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/QueryMessages.java
index 6fa67c0a65d..03aab191920 100644
--- 
a/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/QueryMessages.java
+++ 
b/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/QueryMessages.java
@@ -554,4 +554,40 @@ public final class QueryMessages {
   public static final String EXCEPTION_MISSING_REQUIRED_ARGUMENT_ARG_1308D0DC 
= "缺少必要参数:%s";
   public static final String EXCEPTION_UNKNOWN_ARGUMENT_ARG_F40E9394 = 
"未知参数:%s";
 
+  // ======================== FFT table function ========================
+
+  public static final String FFT_DATA_REQUIRES_ORDER_BY =
+      "具有 set 语义的表参数需要 ORDER BY 子句。";
+  public static final String FFT_REQUIRES_NAMED_NUMERIC_COLUMNS =
+      "FFT 要求数值输入列必须有名称。";
+  public static final String FFT_NO_NUMERIC_COLUMNS = "未找到用于 FFT 计算的数值列。";
+  public static final String FFT_ORDER_BY_MUST_CONTAIN_TIMECOL =
+      "DATA 参数的 ORDER BY 子句必须恰好包含 TIMECOL 参数指定的时间列。";
+  public static final String FFT_TRANSFORM_LENGTH_EXCEEDS_LIMIT =
+      "FFT 变换长度 N 不能超过 %d。";
+  public static final String FFT_SPECTRUM_BUFFER_TOO_LARGE =
+      "FFT 频谱缓冲区过大。请减小 N 或数值列的数量。";
+  public static final String FFT_INVALID_NORM =
+      "FFT 的 NORM 值无效。支持 backward、forward 和 ortho。";
+  public static final String FFT_UNSUPPORTED_PARTITION_TYPE = "不支持的 FFT 
分区类型:%s";
+  public static final String FFT_UNSUPPORTED_VALUE_TYPE = "不支持的 FFT 数值类型:%s";
+  public static final String FFT_TIME_MUST_BE_STRICTLY_ASCENDING =
+      "FFT 输入的时间列在每个分区内必须严格递增。";
+  public static final String FFT_NULL_VALUE_NOT_SUPPORTED =
+      "FFT 不支持列 [%s] 中的 null 值。";
+  public static final String FFT_NEEDS_TWO_ROWS_FOR_INTERVAL =
+      "FFT 至少需要两行数据才能推断 SAMPLE_INTERVAL。";
+  public static final String FFT_SAMPLE_INTERVAL_MUST_BE_POSITIVE =
+      "FFT 的 SAMPLE_INTERVAL 必须为正数。";
+  public static final String FFT_SAMPLE_INTERVAL_MUST_BE_DURATION_LITERAL =
+      "FFT 的 SAMPLE_INTERVAL 参数必须是 duration 字面量。";
+  public static final String FFT_N_MUST_BE_POSITIVE_INTEGER = "FFT 的 N 
参数必须是正整数。";
+  public static final String FFT_ORDER_BY_MUST_SORT_ASCENDING =
+      "DATA 参数的 ORDER BY 子句必须按时间列升序排序。";
+  public static final String 
EXCEPTION_FFT_LENGTH_MUST_BE_A_POSITIVE_INT_SIZED_VALUE_A000D3BB =
+      "FFT 长度必须是正数且不能超过 int 范围。";
+  public static final String
+      EXCEPTION_INPUT_ARRAY_LENGTH_MUST_BE_AT_LEAST_2_FFT_LENGTH_31DF6A25 =
+          "输入数组长度必须至少为 FFT 长度的 2 倍。";
+
 }
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/queryengine/plan/relational/function/TableBuiltinTableFunction.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/queryengine/plan/relational/function/TableBuiltinTableFunction.java
index eb969a2c6ae..decaf8dc466 100644
--- 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/queryengine/plan/relational/function/TableBuiltinTableFunction.java
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/queryengine/plan/relational/function/TableBuiltinTableFunction.java
@@ -25,6 +25,7 @@ import 
org.apache.iotdb.commons.queryengine.plan.relational.function.tvf.Forecas
 import 
org.apache.iotdb.commons.queryengine.plan.relational.function.tvf.PatternMatchTableFunction;
 import 
org.apache.iotdb.commons.udf.builtin.relational.tvf.CapacityTableFunction;
 import 
org.apache.iotdb.commons.udf.builtin.relational.tvf.CumulateTableFunction;
+import org.apache.iotdb.commons.udf.builtin.relational.tvf.FFTTableFunction;
 import org.apache.iotdb.commons.udf.builtin.relational.tvf.HOPTableFunction;
 import org.apache.iotdb.commons.udf.builtin.relational.tvf.M4TableFunction;
 import 
org.apache.iotdb.commons.udf.builtin.relational.tvf.SessionTableFunction;
@@ -45,6 +46,7 @@ public enum TableBuiltinTableFunction {
   VARIATION("variation"),
   CAPACITY("capacity"),
   M4("m4"),
+  FFT("fft"),
   FORECAST("forecast"),
   PATTERN_MATCH("pattern_match"),
   CLASSIFY("classify");
@@ -91,6 +93,8 @@ public enum TableBuiltinTableFunction {
         return new CapacityTableFunction();
       case "m4":
         return new M4TableFunction();
+      case "fft":
+        return new FFTTableFunction();
       case "forecast":
         return new ForecastTableFunction();
       case "classify":
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/FFTTableFunction.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/FFTTableFunction.java
new file mode 100644
index 00000000000..ebdd089c172
--- /dev/null
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/FFTTableFunction.java
@@ -0,0 +1,804 @@
+/*
+ * 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.commons.udf.builtin.relational.tvf;
+
+import org.apache.iotdb.commons.exception.SemanticException;
+import org.apache.iotdb.commons.i18n.QueryMessages;
+import org.apache.iotdb.commons.queryengine.utils.TimestampPrecisionUtils;
+import org.apache.iotdb.commons.udf.builtin.relational.tvf.fft.DoubleFFT_1D;
+import org.apache.iotdb.commons.udf.builtin.relational.tvf.fft.FloatFFT_1D;
+import org.apache.iotdb.udf.api.exception.UDFException;
+import org.apache.iotdb.udf.api.relational.TableFunction;
+import org.apache.iotdb.udf.api.relational.access.Record;
+import org.apache.iotdb.udf.api.relational.table.MapTableFunctionHandle;
+import org.apache.iotdb.udf.api.relational.table.TableFunctionAnalysis;
+import org.apache.iotdb.udf.api.relational.table.TableFunctionHandle;
+import 
org.apache.iotdb.udf.api.relational.table.TableFunctionProcessorProvider;
+import org.apache.iotdb.udf.api.relational.table.argument.Argument;
+import org.apache.iotdb.udf.api.relational.table.argument.DescribedSchema;
+import org.apache.iotdb.udf.api.relational.table.argument.ScalarArgument;
+import org.apache.iotdb.udf.api.relational.table.argument.TableArgument;
+import 
org.apache.iotdb.udf.api.relational.table.processor.TableFunctionDataProcessor;
+import 
org.apache.iotdb.udf.api.relational.table.specification.ParameterSpecification;
+import 
org.apache.iotdb.udf.api.relational.table.specification.ScalarParameterSpecification;
+import 
org.apache.iotdb.udf.api.relational.table.specification.TableParameterSpecification;
+import org.apache.iotdb.udf.api.type.Type;
+
+import org.apache.tsfile.block.column.ColumnBuilder;
+import org.apache.tsfile.utils.Binary;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import static 
org.apache.iotdb.commons.udf.builtin.relational.tvf.WindowTVFUtils.findColumnIndex;
+import static 
org.apache.iotdb.udf.api.relational.table.argument.ScalarArgumentChecker.POSITIVE_LONG_CHECKER;
+
+public class FFTTableFunction implements TableFunction {
+
+  public static final String DATA_PARAMETER_NAME = "DATA";
+  public static final String TIMECOL_PARAMETER_NAME = "TIMECOL";
+  public static final String SAMPLE_INTERVAL_PARAMETER_NAME = 
"SAMPLE_INTERVAL";
+  public static final String N_PARAMETER_NAME = "N";
+  public static final String NORM_PARAMETER_NAME = "NORM";
+  public static final String SAMPLE_INTERVAL_SPECIFIED_PARAMETER_NAME =
+      "__FFT_SAMPLE_INTERVAL_SPECIFIED";
+
+  private static final String DEFAULT_TIME_COLUMN_NAME = "time";
+  private static final String OUTPUT_FREQUENCY_INDEX_COLUMN = 
"frequency_index";
+  private static final String OUTPUT_FREQUENCY_COLUMN = "frequency";
+  private static final String PARTITION_TYPES_PROPERTY = 
"__FFT_PARTITION_TYPES";
+  private static final String VALUE_TYPES_PROPERTY = "__FFT_VALUE_TYPES";
+  private static final String VALUE_NAMES_PROPERTY = "__FFT_VALUE_NAMES";
+  private static final long UNSPECIFIED_SAMPLE_INTERVAL = Long.MIN_VALUE;
+  private static final long UNSPECIFIED_N = -1L;
+  private static final long MAX_TRANSFORM_LENGTH = 65_536L;
+  private static final long MAX_SPECTRUM_VALUES = 16_777_216L;
+  private static final String NORM_BACKWARD = "backward";
+  private static final String NORM_FORWARD = "forward";
+  private static final String NORM_ORTHO = "ortho";
+  private static final Set<Type> SUPPORTED_PARTITION_TYPES =
+      new HashSet<>(
+          Arrays.asList(
+              Type.BOOLEAN,
+              Type.INT32,
+              Type.INT64,
+              Type.FLOAT,
+              Type.DOUBLE,
+              Type.TEXT,
+              Type.TIMESTAMP,
+              Type.DATE,
+              Type.BLOB,
+              Type.STRING));
+  private static final Set<Type> SUPPORTED_VALUE_TYPES =
+      new HashSet<>(Arrays.asList(Type.INT32, Type.INT64, Type.FLOAT, 
Type.DOUBLE));
+
+  @Override
+  public List<ParameterSpecification> getArgumentsSpecifications() {
+    return Arrays.asList(
+        
TableParameterSpecification.builder().name(DATA_PARAMETER_NAME).setSemantics().build(),
+        ScalarParameterSpecification.builder()
+            .name(SAMPLE_INTERVAL_PARAMETER_NAME)
+            .type(Type.INT64)
+            .defaultValue(UNSPECIFIED_SAMPLE_INTERVAL)
+            .addChecker(POSITIVE_LONG_CHECKER)
+            .build(),
+        ScalarParameterSpecification.builder()
+            .name(N_PARAMETER_NAME)
+            .type(Type.INT64)
+            .defaultValue(UNSPECIFIED_N)
+            .addChecker(POSITIVE_LONG_CHECKER)
+            .build(),
+        ScalarParameterSpecification.builder()
+            .name(NORM_PARAMETER_NAME)
+            .type(Type.STRING)
+            .defaultValue(NORM_BACKWARD)
+            .build(),
+        ScalarParameterSpecification.builder()
+            .name(TIMECOL_PARAMETER_NAME)
+            .type(Type.STRING)
+            .defaultValue(DEFAULT_TIME_COLUMN_NAME)
+            .build());
+  }
+
+  @Override
+  public TableFunctionAnalysis analyze(Map<String, Argument> arguments) throws 
UDFException {
+    TableArgument tableArgument = (TableArgument) 
arguments.get(DATA_PARAMETER_NAME);
+    if (tableArgument.getOrderBy().isEmpty()) {
+      throw new SemanticException(QueryMessages.FFT_DATA_REQUIRES_ORDER_BY);
+    }
+
+    String timeColumn =
+        (String) ((ScalarArgument) 
arguments.get(TIMECOL_PARAMETER_NAME)).getValue();
+    int timeColumnIndex =
+        findColumnIndex(tableArgument, timeColumn, 
Collections.singleton(Type.TIMESTAMP));
+    validateOrderBy(tableArgument, timeColumn);
+
+    List<Integer> partitionIndexes = getPartitionIndexes(tableArgument);
+    Set<Integer> excludedIndexes = new HashSet<>(partitionIndexes);
+    excludedIndexes.add(timeColumnIndex);
+
+    List<Integer> valueIndexes = new ArrayList<>();
+    List<String> valueNames = new ArrayList<>();
+    List<Type> valueTypes = new ArrayList<>();
+    List<Type> partitionTypes = new ArrayList<>();
+    DescribedSchema.Builder schemaBuilder = new DescribedSchema.Builder();
+
+    for (int partitionIndex : partitionIndexes) {
+      Type type = tableArgument.getFieldTypes().get(partitionIndex);
+      partitionTypes.add(type);
+      
schemaBuilder.addField(tableArgument.getFieldNames().get(partitionIndex).get(), 
type);
+    }
+    schemaBuilder
+        .addField(OUTPUT_FREQUENCY_INDEX_COLUMN, Type.INT64)
+        .addField(OUTPUT_FREQUENCY_COLUMN, Type.DOUBLE);
+
+    for (int i = 0; i < tableArgument.getFieldTypes().size(); i++) {
+      if (excludedIndexes.contains(i)) {
+        continue;
+      }
+      Type type = tableArgument.getFieldTypes().get(i);
+      if (!SUPPORTED_VALUE_TYPES.contains(type)) {
+        continue;
+      }
+      String columnName =
+          tableArgument
+              .getFieldNames()
+              .get(i)
+              .orElseThrow(
+                  () -> new 
SemanticException(QueryMessages.FFT_REQUIRES_NAMED_NUMERIC_COLUMNS));
+      valueIndexes.add(i);
+      valueNames.add(columnName);
+      valueTypes.add(type);
+      schemaBuilder.addField(columnName + "_real", Type.DOUBLE);
+      schemaBuilder.addField(columnName + "_imag", Type.DOUBLE);
+    }
+
+    if (valueIndexes.isEmpty()) {
+      throw new SemanticException(QueryMessages.FFT_NO_NUMERIC_COLUMNS);
+    }
+
+    long transformLength = (long) ((ScalarArgument) 
arguments.get(N_PARAMETER_NAME)).getValue();
+    validateTransformLength(transformLength, valueIndexes.size());
+    String norm =
+        ((String) ((ScalarArgument) 
arguments.get(NORM_PARAMETER_NAME)).getValue())
+            .toLowerCase(Locale.ROOT);
+    validateNorm(norm);
+
+    MapTableFunctionHandle handle =
+        new MapTableFunctionHandle.Builder()
+            .addProperty(
+                SAMPLE_INTERVAL_PARAMETER_NAME,
+                ((ScalarArgument) 
arguments.get(SAMPLE_INTERVAL_PARAMETER_NAME)).getValue())
+            .addProperty(
+                SAMPLE_INTERVAL_SPECIFIED_PARAMETER_NAME,
+                (boolean)
+                    ((ScalarArgument) 
arguments.get(SAMPLE_INTERVAL_SPECIFIED_PARAMETER_NAME))
+                        .getValue())
+            .addProperty(N_PARAMETER_NAME, transformLength)
+            .addProperty(NORM_PARAMETER_NAME, norm)
+            .addProperty(PARTITION_TYPES_PROPERTY, joinTypes(partitionTypes))
+            .addProperty(VALUE_TYPES_PROPERTY, joinTypes(valueTypes))
+            .addProperty(VALUE_NAMES_PROPERTY, encodeStrings(valueNames))
+            .build();
+
+    List<Integer> requiredColumns = new ArrayList<>();
+    requiredColumns.add(timeColumnIndex);
+    requiredColumns.addAll(partitionIndexes);
+    requiredColumns.addAll(valueIndexes);
+
+    return TableFunctionAnalysis.builder()
+        .properColumnSchema(schemaBuilder.build())
+        .requireRecordSnapshot(false)
+        .requiredColumns(DATA_PARAMETER_NAME, requiredColumns)
+        .handle(handle)
+        .build();
+  }
+
+  @Override
+  public TableFunctionHandle createTableFunctionHandle() {
+    return new MapTableFunctionHandle();
+  }
+
+  @Override
+  public TableFunctionProcessorProvider getProcessorProvider(
+      TableFunctionHandle tableFunctionHandle) {
+    MapTableFunctionHandle handle = (MapTableFunctionHandle) 
tableFunctionHandle;
+    boolean sampleIntervalSpecified =
+        (boolean) handle.getProperty(SAMPLE_INTERVAL_SPECIFIED_PARAMETER_NAME);
+    long sampleInterval = (long) 
handle.getProperty(SAMPLE_INTERVAL_PARAMETER_NAME);
+    long transformLength = (long) handle.getProperty(N_PARAMETER_NAME);
+    String norm = (String) handle.getProperty(NORM_PARAMETER_NAME);
+    Type[] partitionTypes = parseTypes((String) 
handle.getProperty(PARTITION_TYPES_PROPERTY));
+    Type[] valueTypes = parseTypes((String) 
handle.getProperty(VALUE_TYPES_PROPERTY));
+    String[] valueNames = decodeStrings((String) 
handle.getProperty(VALUE_NAMES_PROPERTY));
+
+    return new TableFunctionProcessorProvider() {
+      @Override
+      public TableFunctionDataProcessor getDataProcessor() {
+        return new FFTDataProcessor(
+            sampleIntervalSpecified,
+            sampleInterval,
+            transformLength,
+            norm,
+            createColumns(partitionTypes, 1),
+            createNumericColumns(valueTypes, valueNames, partitionTypes.length 
+ 1));
+      }
+    };
+  }
+
+  private static void validateOrderBy(TableArgument tableArgument, String 
timeColumn) {
+    if (tableArgument.getOrderBy().size() != 1
+        || !tableArgument.getOrderBy().get(0).equalsIgnoreCase(timeColumn)) {
+      throw new 
SemanticException(QueryMessages.FFT_ORDER_BY_MUST_CONTAIN_TIMECOL);
+    }
+  }
+
+  private static List<Integer> getPartitionIndexes(TableArgument tableArgument)
+      throws UDFException {
+    List<Integer> indexes = new ArrayList<>();
+    for (String partitionColumn : tableArgument.getPartitionBy()) {
+      indexes.add(findColumnIndex(tableArgument, partitionColumn, 
SUPPORTED_PARTITION_TYPES));
+    }
+    return indexes;
+  }
+
+  public static void validateTransformLength(long transformLength, int 
valueColumnCount) {
+    if (transformLength == UNSPECIFIED_N) {
+      return;
+    }
+    if (transformLength > MAX_TRANSFORM_LENGTH) {
+      throw new SemanticException(
+          String.format(QueryMessages.FFT_TRANSFORM_LENGTH_EXCEEDS_LIMIT, 
MAX_TRANSFORM_LENGTH));
+    }
+    long spectrumValues;
+    try {
+      spectrumValues =
+          Math.multiplyExact(Math.multiplyExact(transformLength, 2L), 
valueColumnCount);
+    } catch (ArithmeticException e) {
+      throw new SemanticException(QueryMessages.FFT_SPECTRUM_BUFFER_TOO_LARGE);
+    }
+    if (spectrumValues > MAX_SPECTRUM_VALUES) {
+      throw new SemanticException(QueryMessages.FFT_SPECTRUM_BUFFER_TOO_LARGE);
+    }
+  }
+
+  private static void validateNorm(String norm) {
+    if (!NORM_BACKWARD.equals(norm) && !NORM_FORWARD.equals(norm) && 
!NORM_ORTHO.equals(norm)) {
+      throw new SemanticException(QueryMessages.FFT_INVALID_NORM);
+    }
+  }
+
+  private static String joinTypes(List<Type> types) {
+    StringBuilder builder = new StringBuilder();
+    for (int i = 0; i < types.size(); i++) {
+      if (i > 0) {
+        builder.append(',');
+      }
+      builder.append(types.get(i).name());
+    }
+    return builder.toString();
+  }
+
+  private static Type[] parseTypes(String value) {
+    if (value.isEmpty()) {
+      return new Type[0];
+    }
+    String[] values = value.split(",");
+    Type[] types = new Type[values.length];
+    for (int i = 0; i < values.length; i++) {
+      types[i] = Type.valueOf(values[i]);
+    }
+    return types;
+  }
+
+  private static String encodeStrings(List<String> values) {
+    StringBuilder builder = new StringBuilder();
+    for (int i = 0; i < values.size(); i++) {
+      if (i > 0) {
+        builder.append(',');
+      }
+      builder.append(
+          
Base64.getEncoder().encodeToString(values.get(i).getBytes(StandardCharsets.UTF_8)));
+    }
+    return builder.toString();
+  }
+
+  private static String[] decodeStrings(String value) {
+    if (value.isEmpty()) {
+      return new String[0];
+    }
+    String[] encodedValues = value.split(",");
+    String[] decodedValues = new String[encodedValues.length];
+    for (int i = 0; i < encodedValues.length; i++) {
+      decodedValues[i] =
+          new String(Base64.getDecoder().decode(encodedValues[i]), 
StandardCharsets.UTF_8);
+    }
+    return decodedValues;
+  }
+
+  private static ValueColumn[] createColumns(Type[] types, int 
firstInputIndex) {
+    ValueColumn[] columns = new ValueColumn[types.length];
+    for (int i = 0; i < types.length; i++) {
+      columns[i] = new ValueColumn(firstInputIndex + i, 
ValueOperator.fromType(types[i]));
+    }
+    return columns;
+  }
+
+  private static NumericColumn[] createNumericColumns(
+      Type[] types, String[] names, int firstInputIndex) {
+    NumericColumn[] columns = new NumericColumn[types.length];
+    for (int i = 0; i < types.length; i++) {
+      columns[i] =
+          new NumericColumn(firstInputIndex + i, names[i], 
NumericOperator.fromType(types[i]));
+    }
+    return columns;
+  }
+
+  private enum ValueOperator {
+    BOOLEAN(Type.BOOLEAN) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getBoolean(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeBoolean((Boolean) value);
+      }
+    },
+    INT32(Type.INT32) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getInt(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeInt((Integer) value);
+      }
+    },
+    INT64(Type.INT64) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getLong(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeLong((Long) value);
+      }
+    },
+    FLOAT(Type.FLOAT) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getFloat(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeFloat((Float) value);
+      }
+    },
+    DOUBLE(Type.DOUBLE) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getDouble(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeDouble((Double) value);
+      }
+    },
+    TEXT(Type.TEXT) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getBinary(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeBinary((Binary) value);
+      }
+    },
+    BLOB(Type.BLOB) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getBinary(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeBinary((Binary) value);
+      }
+    },
+    TIMESTAMP(Type.TIMESTAMP) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getLong(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeLong((Long) value);
+      }
+    },
+    DATE(Type.DATE) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getLocalDate(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeObject(value);
+      }
+    },
+    STRING(Type.STRING) {
+      @Override
+      Object read(Record record, int index) {
+        return record.getBinary(index);
+      }
+
+      @Override
+      void write(ColumnBuilder builder, Object value) {
+        builder.writeBinary((Binary) value);
+      }
+    };
+
+    private final Type type;
+
+    ValueOperator(Type type) {
+      this.type = type;
+    }
+
+    abstract Object read(Record record, int index);
+
+    abstract void write(ColumnBuilder builder, Object value);
+
+    static ValueOperator fromType(Type type) {
+      for (ValueOperator valueOperator : values()) {
+        if (valueOperator.type == type) {
+          return valueOperator;
+        }
+      }
+      throw new IllegalArgumentException(
+          String.format(QueryMessages.FFT_UNSUPPORTED_PARTITION_TYPE, type));
+    }
+  }
+
+  private enum NumericOperator {
+    INT32(Type.INT32, false) {
+      @Override
+      Number read(Record record, int index) {
+        return record.getInt(index);
+      }
+    },
+    INT64(Type.INT64, false) {
+      @Override
+      Number read(Record record, int index) {
+        return record.getLong(index);
+      }
+    },
+    FLOAT(Type.FLOAT, true) {
+      @Override
+      Number read(Record record, int index) {
+        return record.getFloat(index);
+      }
+    },
+    DOUBLE(Type.DOUBLE, false) {
+      @Override
+      Number read(Record record, int index) {
+        return record.getDouble(index);
+      }
+    };
+
+    private final Type type;
+    private final boolean floatFft;
+
+    NumericOperator(Type type, boolean floatFft) {
+      this.type = type;
+      this.floatFft = floatFft;
+    }
+
+    abstract Number read(Record record, int index);
+
+    boolean usesFloatFft() {
+      return floatFft;
+    }
+
+    static NumericOperator fromType(Type type) {
+      for (NumericOperator numericOperator : values()) {
+        if (numericOperator.type == type) {
+          return numericOperator;
+        }
+      }
+      throw new IllegalArgumentException(
+          String.format(QueryMessages.FFT_UNSUPPORTED_VALUE_TYPE, type));
+    }
+  }
+
+  private static class ValueColumn {
+    private final int inputIndex;
+    private final ValueOperator valueOperator;
+
+    private ValueColumn(int inputIndex, ValueOperator valueOperator) {
+      this.inputIndex = inputIndex;
+      this.valueOperator = valueOperator;
+    }
+
+    private Object read(Record record) {
+      return valueOperator.read(record, inputIndex);
+    }
+
+    private void write(ColumnBuilder builder, Object value) {
+      valueOperator.write(builder, value);
+    }
+  }
+
+  private static class NumericColumn {
+    private final int inputIndex;
+    private final String name;
+    private final NumericOperator numericOperator;
+
+    private NumericColumn(int inputIndex, String name, NumericOperator 
numericOperator) {
+      this.inputIndex = inputIndex;
+      this.name = name;
+      this.numericOperator = numericOperator;
+    }
+
+    private Number read(Record record) {
+      return numericOperator.read(record, inputIndex);
+    }
+
+    private boolean usesFloatFft() {
+      return numericOperator.usesFloatFft();
+    }
+  }
+
+  private static class Spectrum {
+    private final double[] doubleValues;
+    private final float[] floatValues;
+
+    private Spectrum(double[] doubleValues, float[] floatValues) {
+      this.doubleValues = doubleValues;
+      this.floatValues = floatValues;
+    }
+
+    private static Spectrum fromDouble(double[] values) {
+      return new Spectrum(values, null);
+    }
+
+    private static Spectrum fromFloat(float[] values) {
+      return new Spectrum(null, values);
+    }
+
+    private double real(int frequencyIndex, double scaleFactor) {
+      int index = 2 * frequencyIndex;
+      return (doubleValues == null ? floatValues[index] : doubleValues[index]) 
* scaleFactor;
+    }
+
+    private double imaginary(int frequencyIndex, double scaleFactor) {
+      int index = 2 * frequencyIndex + 1;
+      return (doubleValues == null ? floatValues[index] : doubleValues[index]) 
* scaleFactor;
+    }
+  }
+
+  private static class FFTDataProcessor implements TableFunctionDataProcessor {
+    private final boolean sampleIntervalSpecified;
+    private final long sampleInterval;
+    private final long specifiedTransformLength;
+    private final String norm;
+    private final ValueColumn[] partitionColumns;
+    private final NumericColumn[] valueColumns;
+    private final Object[] partitionValues;
+    private final boolean[] partitionValueIsNull;
+    private final List<Number[]> rows = new ArrayList<>();
+    private long inputRowCount;
+    private long firstTime;
+    private long previousTime;
+    private boolean initialized;
+
+    private FFTDataProcessor(
+        boolean sampleIntervalSpecified,
+        long sampleInterval,
+        long specifiedTransformLength,
+        String norm,
+        ValueColumn[] partitionColumns,
+        NumericColumn[] valueColumns) {
+      this.sampleIntervalSpecified = sampleIntervalSpecified;
+      this.sampleInterval = sampleInterval;
+      this.specifiedTransformLength = specifiedTransformLength;
+      this.norm = norm;
+      this.partitionColumns = partitionColumns;
+      this.valueColumns = valueColumns;
+      this.partitionValues = new Object[partitionColumns.length];
+      this.partitionValueIsNull = new boolean[partitionColumns.length];
+    }
+
+    @Override
+    public void process(
+        Record input,
+        List<ColumnBuilder> properColumnBuilders,
+        ColumnBuilder passThroughIndexBuilder) {
+      long currentTime = input.getLong(0);
+      if (!initialized) {
+        capturePartitionValues(input);
+        firstTime = currentTime;
+        initialized = true;
+      } else if (currentTime <= previousTime) {
+        throw new 
SemanticException(QueryMessages.FFT_TIME_MUST_BE_STRICTLY_ASCENDING);
+      }
+      previousTime = currentTime;
+
+      if (specifiedTransformLength == UNSPECIFIED_N) {
+        validateTransformLength(inputRowCount + 1, valueColumns.length);
+      }
+
+      boolean shouldCacheRow =
+          specifiedTransformLength == UNSPECIFIED_N || rows.size() < 
specifiedTransformLength;
+      Number[] row = shouldCacheRow ? new Number[valueColumns.length] : null;
+      for (int i = 0; i < valueColumns.length; i++) {
+        NumericColumn valueColumn = valueColumns[i];
+        if (input.isNull(valueColumn.inputIndex)) {
+          throw new SemanticException(
+              String.format(QueryMessages.FFT_NULL_VALUE_NOT_SUPPORTED, 
valueColumn.name));
+        }
+        if (shouldCacheRow) {
+          row[i] = valueColumn.read(input);
+        }
+      }
+      inputRowCount++;
+      if (shouldCacheRow) {
+        rows.add(row);
+      }
+    }
+
+    @Override
+    public void finish(
+        List<ColumnBuilder> properColumnBuilders, ColumnBuilder 
passThroughIndexBuilder) {
+      if (inputRowCount == 0) {
+        return;
+      }
+
+      int transformLength = getTransformLength();
+      double sampleIntervalSeconds = getSampleIntervalSeconds();
+      double scaleFactor = getScaleFactor(transformLength);
+      Spectrum[] spectra = new Spectrum[valueColumns.length];
+
+      int copiedRows = Math.min(rows.size(), transformLength);
+      DoubleFFT_1D doubleFft = null;
+      FloatFFT_1D floatFft = null;
+      for (int columnIndex = 0; columnIndex < valueColumns.length; 
columnIndex++) {
+        if (valueColumns[columnIndex].usesFloatFft()) {
+          float[] spectrum = new float[2 * transformLength];
+          for (int rowIndex = 0; rowIndex < copiedRows; rowIndex++) {
+            spectrum[2 * rowIndex] = 
rows.get(rowIndex)[columnIndex].floatValue();
+          }
+          if (floatFft == null) {
+            floatFft = new FloatFFT_1D(transformLength);
+          }
+          floatFft.complexForward(spectrum);
+          spectra[columnIndex] = Spectrum.fromFloat(spectrum);
+        } else {
+          double[] spectrum = new double[2 * transformLength];
+          for (int rowIndex = 0; rowIndex < copiedRows; rowIndex++) {
+            spectrum[2 * rowIndex] = 
rows.get(rowIndex)[columnIndex].doubleValue();
+          }
+          if (doubleFft == null) {
+            doubleFft = new DoubleFFT_1D(transformLength);
+          }
+          doubleFft.complexForward(spectrum);
+          spectra[columnIndex] = Spectrum.fromDouble(spectrum);
+        }
+      }
+
+      for (int frequencyIndex = 0; frequencyIndex < transformLength; 
frequencyIndex++) {
+        int outputColumnIndex = 0;
+        for (int partitionIndex = 0; partitionIndex < partitionColumns.length; 
partitionIndex++) {
+          if (partitionValueIsNull[partitionIndex]) {
+            properColumnBuilders.get(outputColumnIndex++).appendNull();
+          } else {
+            partitionColumns[partitionIndex].write(
+                properColumnBuilders.get(outputColumnIndex++), 
partitionValues[partitionIndex]);
+          }
+        }
+        
properColumnBuilders.get(outputColumnIndex++).writeLong(frequencyIndex);
+        properColumnBuilders
+            .get(outputColumnIndex++)
+            .writeDouble(
+                calculateFrequency(frequencyIndex, transformLength, 
sampleIntervalSeconds));
+        for (int columnIndex = 0; columnIndex < valueColumns.length; 
columnIndex++) {
+          properColumnBuilders
+              .get(outputColumnIndex++)
+              .writeDouble(spectra[columnIndex].real(frequencyIndex, 
scaleFactor));
+          properColumnBuilders
+              .get(outputColumnIndex++)
+              .writeDouble(spectra[columnIndex].imaginary(frequencyIndex, 
scaleFactor));
+        }
+      }
+    }
+
+    private void capturePartitionValues(Record input) {
+      for (int i = 0; i < partitionColumns.length; i++) {
+        if (input.isNull(partitionColumns[i].inputIndex)) {
+          partitionValueIsNull[i] = true;
+        } else {
+          partitionValues[i] = partitionColumns[i].read(input);
+        }
+      }
+    }
+
+    private int getTransformLength() {
+      long transformLength =
+          specifiedTransformLength == UNSPECIFIED_N ? inputRowCount : 
specifiedTransformLength;
+      validateTransformLength(transformLength, valueColumns.length);
+      return (int) transformLength;
+    }
+
+    private double getSampleIntervalSeconds() {
+      double interval;
+      if (sampleIntervalSpecified) {
+        interval = sampleInterval;
+      } else {
+        if (inputRowCount < 2) {
+          throw new 
SemanticException(QueryMessages.FFT_NEEDS_TWO_ROWS_FOR_INTERVAL);
+        }
+        // Convert before subtracting so nanosecond timestamps spanning more 
than Long.MAX_VALUE
+        // do not overflow as longs.
+        interval = ((double) previousTime - (double) firstTime) / 
(inputRowCount - 1);
+      }
+      double intervalSeconds =
+          interval * TimestampPrecisionUtils.currPrecision.toNanos(1L) / 
1_000_000_000.0;
+      if (intervalSeconds <= 0) {
+        throw new 
SemanticException(QueryMessages.FFT_SAMPLE_INTERVAL_MUST_BE_POSITIVE);
+      }
+      return intervalSeconds;
+    }
+
+    private double getScaleFactor(int transformLength) {
+      if (NORM_FORWARD.equals(norm)) {
+        return 1.0 / transformLength;
+      }
+      if (NORM_ORTHO.equals(norm)) {
+        return 1.0 / Math.sqrt(transformLength);
+      }
+      return 1.0;
+    }
+
+    private double calculateFrequency(
+        int frequencyIndex, int transformLength, double sampleIntervalSeconds) 
{
+      int positiveFrequencyCount = (transformLength + 1) / 2;
+      int signedIndex =
+          frequencyIndex < positiveFrequencyCount
+              ? frequencyIndex
+              : frequencyIndex - transformLength;
+      return signedIndex / (transformLength * sampleIntervalSeconds);
+    }
+  }
+}
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/fft/DoubleFFT_1D.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/fft/DoubleFFT_1D.java
new file mode 100644
index 00000000000..e0d6b89f1ba
--- /dev/null
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/fft/DoubleFFT_1D.java
@@ -0,0 +1,159 @@
+/*
+ * 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.commons.udf.builtin.relational.tvf.fft;
+
+import org.apache.iotdb.commons.i18n.QueryMessages;
+
+/** Computes an in-place 1D forward DFT for interleaved complex double data. */
+public final class DoubleFFT_1D {
+
+  private final int length;
+
+  public DoubleFFT_1D(long length) {
+    if (length < 1 || length > Integer.MAX_VALUE) {
+      throw new IllegalArgumentException(
+          
QueryMessages.EXCEPTION_FFT_LENGTH_MUST_BE_A_POSITIVE_INT_SIZED_VALUE_A000D3BB);
+    }
+    this.length = (int) length;
+  }
+
+  public void complexForward(double[] values) {
+    if (values.length < 2 * length) {
+      throw new IllegalArgumentException(
+          
QueryMessages.EXCEPTION_INPUT_ARRAY_LENGTH_MUST_BE_AT_LEAST_2_FFT_LENGTH_31DF6A25);
+    }
+    if (length == 1) {
+      return;
+    }
+    if (isPowerOfTwo(length)) {
+      transform(values, length, false);
+    } else {
+      bluesteinForward(values);
+    }
+  }
+
+  private void bluesteinForward(double[] values) {
+    int convolutionLength = nextPowerOfTwo(2 * length - 1);
+    double[] a = new double[2 * convolutionLength];
+    double[] b = new double[2 * convolutionLength];
+
+    for (int i = 0; i < length; i++) {
+      double angle = Math.PI * i * (double) i / length;
+      double cos = Math.cos(angle);
+      double sin = Math.sin(angle);
+      double real = values[2 * i];
+      double imaginary = values[2 * i + 1];
+
+      a[2 * i] = real * cos + imaginary * sin;
+      a[2 * i + 1] = imaginary * cos - real * sin;
+      b[2 * i] = cos;
+      b[2 * i + 1] = sin;
+      if (i > 0) {
+        b[2 * (convolutionLength - i)] = cos;
+        b[2 * (convolutionLength - i) + 1] = sin;
+      }
+    }
+
+    transform(a, convolutionLength, false);
+    transform(b, convolutionLength, false);
+    for (int i = 0; i < convolutionLength; i++) {
+      int offset = 2 * i;
+      double real = a[offset] * b[offset] - a[offset + 1] * b[offset + 1];
+      double imaginary = a[offset] * b[offset + 1] + a[offset + 1] * b[offset];
+      a[offset] = real;
+      a[offset + 1] = imaginary;
+    }
+    transform(a, convolutionLength, true);
+
+    for (int i = 0; i < length; i++) {
+      double angle = Math.PI * i * (double) i / length;
+      double cos = Math.cos(angle);
+      double sin = Math.sin(angle);
+      double real = a[2 * i];
+      double imaginary = a[2 * i + 1];
+      values[2 * i] = real * cos + imaginary * sin;
+      values[2 * i + 1] = imaginary * cos - real * sin;
+    }
+  }
+
+  private static void transform(double[] values, int size, boolean inverse) {
+    for (int i = 1, j = 0; i < size; i++) {
+      int bit = size >>> 1;
+      while ((j & bit) != 0) {
+        j ^= bit;
+        bit >>>= 1;
+      }
+      j ^= bit;
+      if (i < j) {
+        swap(values, 2 * i, 2 * j);
+        swap(values, 2 * i + 1, 2 * j + 1);
+      }
+    }
+
+    for (int step = 2; step <= size; step <<= 1) {
+      double angle = (inverse ? 2.0 : -2.0) * Math.PI / step;
+      double stepReal = Math.cos(angle);
+      double stepImaginary = Math.sin(angle);
+      int halfStep = step >>> 1;
+      for (int block = 0; block < size; block += step) {
+        double factorReal = 1.0;
+        double factorImaginary = 0.0;
+        for (int j = 0; j < halfStep; j++) {
+          int even = 2 * (block + j);
+          int odd = 2 * (block + j + halfStep);
+          double oddReal = values[odd] * factorReal - values[odd + 1] * 
factorImaginary;
+          double oddImaginary = values[odd] * factorImaginary + values[odd + 
1] * factorReal;
+          double evenReal = values[even];
+          double evenImaginary = values[even + 1];
+
+          values[even] = evenReal + oddReal;
+          values[even + 1] = evenImaginary + oddImaginary;
+          values[odd] = evenReal - oddReal;
+          values[odd + 1] = evenImaginary - oddImaginary;
+
+          double nextFactorReal = factorReal * stepReal - factorImaginary * 
stepImaginary;
+          factorImaginary = factorReal * stepImaginary + factorImaginary * 
stepReal;
+          factorReal = nextFactorReal;
+        }
+      }
+    }
+
+    if (inverse) {
+      for (int i = 0; i < 2 * size; i++) {
+        values[i] /= size;
+      }
+    }
+  }
+
+  private static boolean isPowerOfTwo(int value) {
+    return (value & (value - 1)) == 0;
+  }
+
+  private static int nextPowerOfTwo(int value) {
+    int highestOneBit = Integer.highestOneBit(value);
+    return value == highestOneBit ? value : highestOneBit << 1;
+  }
+
+  private static void swap(double[] values, int left, int right) {
+    double tmp = values[left];
+    values[left] = values[right];
+    values[right] = tmp;
+  }
+}
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/fft/FloatFFT_1D.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/fft/FloatFFT_1D.java
new file mode 100644
index 00000000000..7828197f0b9
--- /dev/null
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/fft/FloatFFT_1D.java
@@ -0,0 +1,160 @@
+/*
+ * 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.commons.udf.builtin.relational.tvf.fft;
+
+import org.apache.iotdb.commons.i18n.QueryMessages;
+
+/** Computes an in-place 1D forward DFT for interleaved complex float data. */
+public final class FloatFFT_1D {
+
+  private final int length;
+
+  public FloatFFT_1D(long length) {
+    if (length < 1 || length > Integer.MAX_VALUE) {
+      throw new IllegalArgumentException(
+          
QueryMessages.EXCEPTION_FFT_LENGTH_MUST_BE_A_POSITIVE_INT_SIZED_VALUE_A000D3BB);
+    }
+    this.length = (int) length;
+  }
+
+  public void complexForward(float[] values) {
+    if (values.length < 2 * length) {
+      throw new IllegalArgumentException(
+          
QueryMessages.EXCEPTION_INPUT_ARRAY_LENGTH_MUST_BE_AT_LEAST_2_FFT_LENGTH_31DF6A25);
+    }
+    if (length == 1) {
+      return;
+    }
+    if (isPowerOfTwo(length)) {
+      transform(values, length, false);
+    } else {
+      bluesteinForward(values);
+    }
+  }
+
+  private void bluesteinForward(float[] values) {
+    int convolutionLength = nextPowerOfTwo(2 * length - 1);
+    float[] a = new float[2 * convolutionLength];
+    float[] b = new float[2 * convolutionLength];
+
+    for (int i = 0; i < length; i++) {
+      double angle = Math.PI * i * (double) i / length;
+      float cos = (float) Math.cos(angle);
+      float sin = (float) Math.sin(angle);
+      float real = values[2 * i];
+      float imaginary = values[2 * i + 1];
+
+      a[2 * i] = real * cos + imaginary * sin;
+      a[2 * i + 1] = imaginary * cos - real * sin;
+      b[2 * i] = cos;
+      b[2 * i + 1] = sin;
+      if (i > 0) {
+        b[2 * (convolutionLength - i)] = cos;
+        b[2 * (convolutionLength - i) + 1] = sin;
+      }
+    }
+
+    transform(a, convolutionLength, false);
+    transform(b, convolutionLength, false);
+    for (int i = 0; i < convolutionLength; i++) {
+      int offset = 2 * i;
+      float real = a[offset] * b[offset] - a[offset + 1] * b[offset + 1];
+      float imaginary = a[offset] * b[offset + 1] + a[offset + 1] * b[offset];
+      a[offset] = real;
+      a[offset + 1] = imaginary;
+    }
+    transform(a, convolutionLength, true);
+
+    for (int i = 0; i < length; i++) {
+      double angle = Math.PI * i * (double) i / length;
+      float cos = (float) Math.cos(angle);
+      float sin = (float) Math.sin(angle);
+      float real = a[2 * i];
+      float imaginary = a[2 * i + 1];
+      values[2 * i] = real * cos + imaginary * sin;
+      values[2 * i + 1] = imaginary * cos - real * sin;
+    }
+  }
+
+  private static void transform(float[] values, int size, boolean inverse) {
+    for (int i = 1, j = 0; i < size; i++) {
+      int bit = size >>> 1;
+      while ((j & bit) != 0) {
+        j ^= bit;
+        bit >>>= 1;
+      }
+      j ^= bit;
+      if (i < j) {
+        swap(values, 2 * i, 2 * j);
+        swap(values, 2 * i + 1, 2 * j + 1);
+      }
+    }
+
+    for (int step = 2; step <= size; step <<= 1) {
+      double angle = (inverse ? 2.0 : -2.0) * Math.PI / step;
+      double stepReal = Math.cos(angle);
+      double stepImaginary = Math.sin(angle);
+      int halfStep = step >>> 1;
+      for (int block = 0; block < size; block += step) {
+        double factorReal = 1.0;
+        double factorImaginary = 0.0;
+        for (int j = 0; j < halfStep; j++) {
+          int even = 2 * (block + j);
+          int odd = 2 * (block + j + halfStep);
+          float oddReal = (float) (values[odd] * factorReal - values[odd + 1] 
* factorImaginary);
+          float oddImaginary =
+              (float) (values[odd] * factorImaginary + values[odd + 1] * 
factorReal);
+          float evenReal = values[even];
+          float evenImaginary = values[even + 1];
+
+          values[even] = evenReal + oddReal;
+          values[even + 1] = evenImaginary + oddImaginary;
+          values[odd] = evenReal - oddReal;
+          values[odd + 1] = evenImaginary - oddImaginary;
+
+          double nextFactorReal = factorReal * stepReal - factorImaginary * 
stepImaginary;
+          factorImaginary = factorReal * stepImaginary + factorImaginary * 
stepReal;
+          factorReal = nextFactorReal;
+        }
+      }
+    }
+
+    if (inverse) {
+      for (int i = 0; i < 2 * size; i++) {
+        values[i] /= size;
+      }
+    }
+  }
+
+  private static boolean isPowerOfTwo(int value) {
+    return (value & (value - 1)) == 0;
+  }
+
+  private static int nextPowerOfTwo(int value) {
+    int highestOneBit = Integer.highestOneBit(value);
+    return value == highestOneBit ? value : highestOneBit << 1;
+  }
+
+  private static void swap(float[] values, int left, int right) {
+    float tmp = values[left];
+    values[left] = values[right];
+    values[right] = tmp;
+  }
+}
diff --git 
a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/FFTTableFunctionTest.java
 
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/FFTTableFunctionTest.java
new file mode 100644
index 00000000000..6dcef223c1d
--- /dev/null
+++ 
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/FFTTableFunctionTest.java
@@ -0,0 +1,518 @@
+/*
+ * 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.commons.udf.builtin.relational.tvf;
+
+import org.apache.iotdb.commons.exception.SemanticException;
+import org.apache.iotdb.commons.queryengine.utils.TimestampPrecisionUtils;
+import org.apache.iotdb.udf.api.exception.UDFException;
+import org.apache.iotdb.udf.api.relational.access.Record;
+import org.apache.iotdb.udf.api.relational.table.TableFunctionAnalysis;
+import org.apache.iotdb.udf.api.relational.table.TableFunctionHandle;
+import org.apache.iotdb.udf.api.relational.table.argument.Argument;
+import org.apache.iotdb.udf.api.relational.table.argument.ScalarArgument;
+import org.apache.iotdb.udf.api.relational.table.argument.TableArgument;
+import 
org.apache.iotdb.udf.api.relational.table.processor.TableFunctionDataProcessor;
+import org.apache.iotdb.udf.api.type.Type;
+
+import org.apache.tsfile.block.column.Column;
+import org.apache.tsfile.block.column.ColumnBuilder;
+import org.apache.tsfile.read.common.block.column.DoubleColumnBuilder;
+import org.apache.tsfile.read.common.block.column.LongColumnBuilder;
+import org.apache.tsfile.utils.Binary;
+import org.junit.Test;
+
+import java.io.File;
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+public class FFTTableFunctionTest {
+
+  private static final double DELTA = 1e-9;
+
+  private final FFTTableFunction function = new FFTTableFunction();
+
+  @Test
+  public void testWritesFullSpectrumAndZeroPadsToSpecifiedN() throws 
UDFException {
+    TableFunctionDataProcessor processor = createProcessor(true, 4L);
+    processor.process(record(0L, 1.0), Collections.emptyList(), null);
+
+    List<ColumnBuilder> builders = createOutputBuilders(4);
+    processor.finish(builders, null);
+
+    double intervalSeconds = TimestampPrecisionUtils.currPrecision.toNanos(1L) 
/ 1_000_000_000.0;
+    assertLongColumn(builders.get(0).build(), 0L, 1L, 2L, 3L);
+    assertDoubleColumn(
+        builders.get(1).build(),
+        0.0,
+        1.0 / (4.0 * intervalSeconds),
+        -2.0 / (4.0 * intervalSeconds),
+        -1.0 / (4.0 * intervalSeconds));
+    assertDoubleColumn(builders.get(2).build(), 1.0, 1.0, 1.0, 1.0);
+    assertDoubleColumn(builders.get(3).build(), 0.0, 0.0, 0.0, 0.0);
+  }
+
+  @Test
+  public void testTruncatesInputRowsToSpecifiedN() throws UDFException {
+    TableFunctionDataProcessor processor = createProcessor(true, 2L);
+    processor.process(record(0L, 1.0), Collections.emptyList(), null);
+    processor.process(record(1L, 2.0), Collections.emptyList(), null);
+    processor.process(record(2L, 100.0), Collections.emptyList(), null);
+    processor.process(record(3L, 200.0), Collections.emptyList(), null);
+
+    List<ColumnBuilder> builders = createOutputBuilders(2);
+    processor.finish(builders, null);
+
+    assertLongColumn(builders.get(0).build(), 0L, 1L);
+    assertDoubleColumn(builders.get(2).build(), 3.0, -1.0);
+    assertDoubleColumn(builders.get(3).build(), 0.0, 0.0);
+  }
+
+  @Test
+  public void testSupportsNonPowerOfTwoTransformLength() throws UDFException {
+    TableFunctionDataProcessor processor = createProcessor(true, 3L);
+    processor.process(record(0L, 1.0), Collections.emptyList(), null);
+    processor.process(record(1L, 2.0), Collections.emptyList(), null);
+    processor.process(record(2L, 3.0), Collections.emptyList(), null);
+
+    List<ColumnBuilder> builders = createOutputBuilders(3);
+    processor.finish(builders, null);
+
+    double imaginaryComponent = Math.sqrt(3.0) / 2.0;
+    assertLongColumn(builders.get(0).build(), 0L, 1L, 2L);
+    assertDoubleColumn(builders.get(2).build(), 6.0, -1.5, -1.5);
+    assertDoubleColumn(builders.get(3).build(), 0.0, imaginaryComponent, 
-imaginaryComponent);
+  }
+
+  @Test
+  public void testSupportsFloatInputWithFloatFft() throws UDFException {
+    TableFunctionDataProcessor processor = createProcessor(Type.FLOAT, true, 
3L);
+    processor.process(record(0L, 1.0f), Collections.emptyList(), null);
+    processor.process(record(1L, 2.0f), Collections.emptyList(), null);
+    processor.process(record(2L, 3.0f), Collections.emptyList(), null);
+
+    List<ColumnBuilder> builders = createOutputBuilders(3);
+    processor.finish(builders, null);
+
+    double imaginaryComponent = Math.sqrt(3.0) / 2.0;
+    assertLongColumn(builders.get(0).build(), 0L, 1L, 2L);
+    assertDoubleColumnWithDelta(builders.get(2).build(), 1e-6, 6.0, -1.5, 
-1.5);
+    assertDoubleColumnWithDelta(
+        builders.get(3).build(), 1e-6, 0.0, imaginaryComponent, 
-imaginaryComponent);
+  }
+
+  @Test
+  public void testRejectsInvalidRowsEvenWhenBeyondTruncatedN() throws 
UDFException {
+    TableFunctionDataProcessor processor = createProcessor(true, 2L);
+    processor.process(record(0L, 1.0), Collections.emptyList(), null);
+    processor.process(record(1L, 2.0), Collections.emptyList(), null);
+
+    assertSemanticException(
+        () -> processor.process(nullValueRecord(2L), Collections.emptyList(), 
null),
+        "FFT does not support null values in column [value].");
+  }
+
+  @Test
+  public void testInfersSampleIntervalFromFullInputRangeBeyondSpecifiedN() 
throws UDFException {
+    TableFunctionDataProcessor processor = createProcessor(false, 2L);
+    processor.process(record(0L, 1.0), Collections.emptyList(), null);
+    processor.process(record(1L, 2.0), Collections.emptyList(), null);
+    processor.process(record(3L, 3.0), Collections.emptyList(), null);
+
+    List<ColumnBuilder> builders = createOutputBuilders(2);
+    processor.finish(builders, null);
+
+    double intervalSeconds =
+        1.5 * TimestampPrecisionUtils.currPrecision.toNanos(1L) / 
1_000_000_000.0;
+    assertLongColumn(builders.get(0).build(), 0L, 1L);
+    assertDoubleColumn(builders.get(1).build(), 0.0, -1.0 / (2.0 * 
intervalSeconds));
+    assertDoubleColumn(builders.get(2).build(), 3.0, -1.0);
+    assertDoubleColumn(builders.get(3).build(), 0.0, 0.0);
+  }
+
+  @Test
+  public void testRejectsDuplicateTime() throws UDFException {
+    TableFunctionDataProcessor processor = createProcessor(false);
+    processor.process(record(1L, 1.0), Collections.emptyList(), null);
+
+    assertSemanticException(
+        () -> processor.process(record(1L, 2.0), Collections.emptyList(), 
null),
+        "The time column of FFT input must be strictly ascending within each 
partition.");
+  }
+
+  @Test
+  public void testRejectsOutOfOrderTime() throws UDFException {
+    TableFunctionDataProcessor processor = createProcessor(false);
+    processor.process(record(2L, 1.0), Collections.emptyList(), null);
+
+    assertSemanticException(
+        () -> processor.process(record(1L, 2.0), Collections.emptyList(), 
null),
+        "The time column of FFT input must be strictly ascending within each 
partition.");
+  }
+
+  @Test
+  public void testRejectsSingleRowWithoutSampleInterval() throws UDFException {
+    TableFunctionDataProcessor processor = createProcessor(false);
+    processor.process(record(1L, 1.0), Collections.emptyList(), null);
+
+    assertSemanticException(
+        () -> processor.finish(Collections.emptyList(), null),
+        "FFT requires at least two rows to infer SAMPLE_INTERVAL.");
+  }
+
+  @Test
+  public void testInfersSampleIntervalFromPartitionTimeRange() throws 
UDFException {
+    TableFunctionDataProcessor processor = createProcessor(false);
+    processor.process(record(0L, 1.0), Collections.emptyList(), null);
+    processor.process(record(1L, 2.0), Collections.emptyList(), null);
+    processor.process(record(3L, 3.0), Collections.emptyList(), null);
+
+    List<ColumnBuilder> builders = createOutputBuilders(3);
+    processor.finish(builders, null);
+
+    double intervalSeconds =
+        1.5 * TimestampPrecisionUtils.currPrecision.toNanos(1L) / 
1_000_000_000.0;
+    assertLongColumn(builders.get(0).build(), 0L, 1L, 2L);
+    assertDoubleColumn(
+        builders.get(1).build(),
+        0.0,
+        1.0 / (3.0 * intervalSeconds),
+        -1.0 / (3.0 * intervalSeconds));
+  }
+
+  @Test
+  public void testInfersIntervalWithoutNanosecondTimestampOverflow() throws 
UDFException {
+    TableFunctionDataProcessor processor = createProcessor(false, 2L);
+    processor.process(record(-6_000_000_000_000_000_000L, 1.0), 
Collections.emptyList(), null);
+    processor.process(record(6_000_000_000_000_000_000L, 2.0), 
Collections.emptyList(), null);
+
+    List<ColumnBuilder> builders = createOutputBuilders(2);
+    processor.finish(builders, null);
+
+    double intervalSeconds =
+        12.0e18 * TimestampPrecisionUtils.currPrecision.toNanos(1L) / 
1_000_000_000.0;
+    assertDoubleColumn(builders.get(1).build(), 0.0, -1.0 / (2.0 * 
intervalSeconds));
+  }
+
+  @Test
+  public void testUsesExplicitSampleIntervalWithoutGapValidation() throws 
UDFException {
+    TableFunctionDataProcessor processor = createProcessor(true);
+    processor.process(record(0L, 1.0), Collections.emptyList(), null);
+    processor.process(record(2L, 2.0), Collections.emptyList(), null);
+
+    List<ColumnBuilder> builders = createOutputBuilders(2);
+    processor.finish(builders, null);
+
+    double intervalSeconds = TimestampPrecisionUtils.currPrecision.toNanos(1L) 
/ 1_000_000_000.0;
+    assertLongColumn(builders.get(0).build(), 0L, 1L);
+    assertDoubleColumn(builders.get(1).build(), 0.0, -1.0 / (2.0 * 
intervalSeconds));
+  }
+
+  @Test
+  public void testRejectsDefaultTransformLengthAboveLimit() throws 
UDFException {
+    TableFunctionDataProcessor processor = createProcessor(true);
+    for (long time = 0; time < 65_536L; time++) {
+      processor.process(record(time, 1.0), Collections.emptyList(), null);
+    }
+
+    assertSemanticException(
+        () -> processor.process(record(65_536L, 1.0), Collections.emptyList(), 
null),
+        "FFT transform length N must not exceed 65536.");
+  }
+
+  @Test
+  public void testRejectsDefaultSpectrumBufferAboveLimit() {
+    assertSemanticException(
+        () -> FFTTableFunction.validateTransformLength(65_536L, 129),
+        "FFT spectrum buffer is too large. Reduce N or the number of numeric 
columns.");
+  }
+
+  @Test
+  public void testAnalyzeUsesSpecifiedTimeColumn() throws UDFException {
+    Map<String, Argument> arguments = createArguments("event_time", 
"event_time");
+
+    TableFunctionAnalysis analysis = function.analyze(arguments);
+
+    assertEquals(
+        Arrays.asList(0, 1),
+        
analysis.getRequiredColumns().get(FFTTableFunction.DATA_PARAMETER_NAME));
+    assertEquals(
+        "value_real", 
analysis.getProperColumnSchema().get().getFields().get(2).getName().get());
+    assertEquals(
+        "value_imag", 
analysis.getProperColumnSchema().get().getFields().get(3).getName().get());
+  }
+
+  @Test
+  public void testAnalyzePreservesValueColumnNamesContainingComma() throws 
UDFException {
+    Map<String, Argument> arguments = createArguments("time", "time", 
"value,with,comma");
+
+    TableFunctionAnalysis analysis = function.analyze(arguments);
+
+    assertEquals(
+        "value,with,comma_real",
+        
analysis.getProperColumnSchema().get().getFields().get(2).getName().get());
+    assertEquals(
+        "value,with,comma_imag",
+        
analysis.getProperColumnSchema().get().getFields().get(3).getName().get());
+
+    TableFunctionHandle handle = function.createTableFunctionHandle();
+    handle.deserialize(analysis.getTableFunctionHandle().serialize());
+    TableFunctionDataProcessor processor = 
function.getProcessorProvider(handle).getDataProcessor();
+    processor.process(record(0L, 1.0), Collections.emptyList(), null);
+    assertSemanticException(
+        () -> processor.process(nullValueRecord(1L), Collections.emptyList(), 
null),
+        "FFT does not support null values in column [value,with,comma].");
+  }
+
+  @Test
+  public void testAnalyzeRejectsOrderByDifferentFromSpecifiedTimeColumn() {
+    assertSemanticException(
+        () -> {
+          try {
+            function.analyze(createArguments("event_time", "time"));
+          } catch (UDFException e) {
+            throw new AssertionError(e);
+          }
+        },
+        "The ORDER BY clause of the DATA argument must contain exactly the 
time column specified by the TIMECOL argument.");
+  }
+
+  private TableFunctionDataProcessor createProcessor(boolean 
sampleIntervalSpecified)
+      throws UDFException {
+    return createProcessor(sampleIntervalSpecified, -1L);
+  }
+
+  private TableFunctionDataProcessor createProcessor(
+      boolean sampleIntervalSpecified, long transformLength) throws 
UDFException {
+    return createProcessor(Type.DOUBLE, sampleIntervalSpecified, 
transformLength);
+  }
+
+  private TableFunctionDataProcessor createProcessor(
+      Type valueType, boolean sampleIntervalSpecified, long transformLength) 
throws UDFException {
+    Map<String, Argument> arguments = new HashMap<>();
+    arguments.put(
+        FFTTableFunction.DATA_PARAMETER_NAME,
+        new TableArgument(
+            Arrays.asList(Optional.of("time"), Optional.of("value")),
+            Arrays.asList(Type.TIMESTAMP, valueType),
+            Collections.emptyList(),
+            Collections.singletonList("time"),
+            false));
+    arguments.put(FFTTableFunction.TIMECOL_PARAMETER_NAME, new 
ScalarArgument(Type.STRING, "time"));
+    arguments.put(
+        FFTTableFunction.SAMPLE_INTERVAL_PARAMETER_NAME,
+        new ScalarArgument(Type.INT64, sampleIntervalSpecified ? 1L : 
Long.MIN_VALUE));
+    arguments.put(
+        FFTTableFunction.SAMPLE_INTERVAL_SPECIFIED_PARAMETER_NAME,
+        new ScalarArgument(Type.BOOLEAN, sampleIntervalSpecified));
+    arguments.put(
+        FFTTableFunction.N_PARAMETER_NAME, new ScalarArgument(Type.INT64, 
transformLength));
+    arguments.put(
+        FFTTableFunction.NORM_PARAMETER_NAME, new ScalarArgument(Type.STRING, 
"backward"));
+
+    return function
+        
.getProcessorProvider(function.analyze(arguments).getTableFunctionHandle())
+        .getDataProcessor();
+  }
+
+  private Map<String, Argument> createArguments(String timeColumn, String 
orderByColumn) {
+    return createArguments(timeColumn, orderByColumn, "value");
+  }
+
+  private Map<String, Argument> createArguments(
+      String timeColumn, String orderByColumn, String valueColumnName) {
+    Map<String, Argument> arguments = new HashMap<>();
+    arguments.put(
+        FFTTableFunction.DATA_PARAMETER_NAME,
+        new TableArgument(
+            Arrays.asList(Optional.of(timeColumn), 
Optional.of(valueColumnName)),
+            Arrays.asList(Type.TIMESTAMP, Type.DOUBLE),
+            Collections.emptyList(),
+            Collections.singletonList(orderByColumn),
+            false));
+    arguments.put(
+        FFTTableFunction.TIMECOL_PARAMETER_NAME, new 
ScalarArgument(Type.STRING, timeColumn));
+    arguments.put(
+        FFTTableFunction.SAMPLE_INTERVAL_PARAMETER_NAME, new 
ScalarArgument(Type.INT64, 1L));
+    arguments.put(
+        FFTTableFunction.SAMPLE_INTERVAL_SPECIFIED_PARAMETER_NAME,
+        new ScalarArgument(Type.BOOLEAN, true));
+    arguments.put(FFTTableFunction.N_PARAMETER_NAME, new 
ScalarArgument(Type.INT64, -1L));
+    arguments.put(
+        FFTTableFunction.NORM_PARAMETER_NAME, new ScalarArgument(Type.STRING, 
"backward"));
+    return arguments;
+  }
+
+  private Record record(long time, double value) {
+    return new SimpleRecord(time, value);
+  }
+
+  private Record record(long time, float value) {
+    return new SimpleRecord(time, value);
+  }
+
+  private Record nullValueRecord(long time) {
+    return new SimpleRecord(time, null);
+  }
+
+  private List<ColumnBuilder> createOutputBuilders(int expectedPositionCount) {
+    return Arrays.asList(
+        new LongColumnBuilder(null, expectedPositionCount),
+        new DoubleColumnBuilder(null, expectedPositionCount),
+        new DoubleColumnBuilder(null, expectedPositionCount),
+        new DoubleColumnBuilder(null, expectedPositionCount));
+  }
+
+  private void assertLongColumn(Column column, long... expected) {
+    assertEquals(expected.length, column.getPositionCount());
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(expected[i], column.getLong(i));
+    }
+  }
+
+  private void assertDoubleColumn(Column column, double... expected) {
+    assertDoubleColumnWithDelta(column, DELTA, expected);
+  }
+
+  private void assertDoubleColumnWithDelta(Column column, double delta, 
double... expected) {
+    assertEquals(expected.length, column.getPositionCount());
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(expected[i], column.getDouble(i), delta);
+    }
+  }
+
+  private void assertSemanticException(Runnable runnable, String message) {
+    try {
+      runnable.run();
+      fail();
+    } catch (SemanticException e) {
+      assertEquals(message, e.getMessage());
+    }
+  }
+
+  private static class SimpleRecord implements Record {
+    private final long time;
+    private final Number value;
+
+    private SimpleRecord(long time, Number value) {
+      this.time = time;
+      this.value = value;
+    }
+
+    @Override
+    public int getInt(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public long getLong(int columnIndex) {
+      if (columnIndex == 0) {
+        return time;
+      }
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public float getFloat(int columnIndex) {
+      if (columnIndex == 1 && value instanceof Float) {
+        return value.floatValue();
+      }
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public double getDouble(int columnIndex) {
+      if (columnIndex == 1 && value instanceof Double) {
+        return value.doubleValue();
+      }
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean getBoolean(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Binary getBinary(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String getString(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public LocalDate getLocalDate(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Object getObject(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Optional<File> getObjectFile(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public long objectLength(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Binary readObject(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Binary readObject(int columnIndex, long offset, int length) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Type getDataType(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean isNull(int columnIndex) {
+      if (columnIndex == 1) {
+        return value == null;
+      }
+      return false;
+    }
+
+    @Override
+    public int size() {
+      return 2;
+    }
+  }
+}
diff --git 
a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/fft/FFT1DTest.java
 
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/fft/FFT1DTest.java
new file mode 100644
index 00000000000..86f9d6c11fa
--- /dev/null
+++ 
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/udf/builtin/relational/tvf/fft/FFT1DTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.commons.udf.builtin.relational.tvf.fft;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertArrayEquals;
+
+public class FFT1DTest {
+
+  @Test
+  public void testDoubleComplexForwardPowerOfTwoLength() {
+    double[] values = {1.0, 0.5, -2.0, 1.0, 0.0, -1.5, 3.0, 2.0};
+    double[] expected = directDft(values);
+
+    new DoubleFFT_1D(4).complexForward(values);
+
+    assertArrayEquals(expected, values, 1e-9);
+  }
+
+  @Test
+  public void testDoubleComplexForwardNonPowerOfTwoLength() {
+    double[] values = {1.0, 0.0, 2.0, -0.5, -1.0, 1.5, 0.0, 0.25, 3.0, -2.0};
+    double[] expected = directDft(values);
+
+    new DoubleFFT_1D(5).complexForward(values);
+
+    assertArrayEquals(expected, values, 1e-9);
+  }
+
+  @Test
+  public void testFloatComplexForwardPowerOfTwoLength() {
+    float[] values = {1.0f, 0.5f, -2.0f, 1.0f, 0.0f, -1.5f, 3.0f, 2.0f};
+    float[] expected = directDft(values);
+
+    new FloatFFT_1D(4).complexForward(values);
+
+    assertArrayEquals(expected, values, 1e-5f);
+  }
+
+  @Test
+  public void testFloatComplexForwardNonPowerOfTwoLength() {
+    float[] values = {1.0f, 0.0f, 2.0f, -0.5f, -1.0f, 1.5f, 0.0f, 0.25f, 3.0f, 
-2.0f};
+    float[] expected = directDft(values);
+
+    new FloatFFT_1D(5).complexForward(values);
+
+    assertArrayEquals(expected, values, 1e-5f);
+  }
+
+  private static double[] directDft(double[] values) {
+    int length = values.length / 2;
+    double[] result = new double[values.length];
+    for (int frequencyIndex = 0; frequencyIndex < length; frequencyIndex++) {
+      double real = 0.0;
+      double imaginary = 0.0;
+      for (int timeIndex = 0; timeIndex < length; timeIndex++) {
+        double angle = -2.0 * Math.PI * frequencyIndex * timeIndex / length;
+        double cos = Math.cos(angle);
+        double sin = Math.sin(angle);
+        double inputReal = values[2 * timeIndex];
+        double inputImaginary = values[2 * timeIndex + 1];
+        real += inputReal * cos - inputImaginary * sin;
+        imaginary += inputReal * sin + inputImaginary * cos;
+      }
+      result[2 * frequencyIndex] = real;
+      result[2 * frequencyIndex + 1] = imaginary;
+    }
+    return result;
+  }
+
+  private static float[] directDft(float[] values) {
+    int length = values.length / 2;
+    float[] result = new float[values.length];
+    for (int frequencyIndex = 0; frequencyIndex < length; frequencyIndex++) {
+      double real = 0.0;
+      double imaginary = 0.0;
+      for (int timeIndex = 0; timeIndex < length; timeIndex++) {
+        double angle = -2.0 * Math.PI * frequencyIndex * timeIndex / length;
+        double cos = Math.cos(angle);
+        double sin = Math.sin(angle);
+        double inputReal = values[2 * timeIndex];
+        double inputImaginary = values[2 * timeIndex + 1];
+        real += inputReal * cos - inputImaginary * sin;
+        imaginary += inputReal * sin + inputImaginary * cos;
+      }
+      result[2 * frequencyIndex] = (float) real;
+      result[2 * frequencyIndex + 1] = (float) imaginary;
+    }
+    return result;
+  }
+}

Reply via email to