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

lvyanquan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git


The following commit(s) were added to refs/heads/master by this push:
     new ff26ee22f [FLINK-40312] [runtime] Support EXTRACT, date-part 
functions, and interval arithmetic in YAML Transform (#4495)
ff26ee22f is described below

commit ff26ee22f7a89aafdb926d23f207c489d930b623
Author: haruki <[email protected]>
AuthorDate: Mon Aug 10 12:56:18 2026 +0800

    [FLINK-40312] [runtime] Support EXTRACT, date-part functions, and interval 
arithmetic in YAML Transform (#4495)
    
    Co-authored-by: 春栖 <[email protected]>
---
 docs/content.zh/docs/core-concept/transform.md     |   5 +
 docs/content/docs/core-concept/transform.md        |   5 +
 .../src/test/resources/specs/temporal.yaml         |  44 +++++++
 .../runtime/functions/impl/TemporalFunctions.java  |  94 ++++++++++++++
 .../flink/cdc/runtime/parser/JaninoCompiler.java   | 126 +++++++++++++++++++
 .../parser/metadata/TransformSqlOperatorTable.java |  11 ++
 .../functions/impl/TemporalFunctionsTest.java      | 138 +++++++++++++++++++++
 .../cdc/runtime/parser/TransformParserTest.java    |  97 ++++++++++++++-
 8 files changed, 516 insertions(+), 4 deletions(-)

diff --git a/docs/content.zh/docs/core-concept/transform.md 
b/docs/content.zh/docs/core-concept/transform.md
index 726769254..d6daab547 100644
--- a/docs/content.zh/docs/core-concept/transform.md
+++ b/docs/content.zh/docs/core-concept/transform.md
@@ -216,6 +216,11 @@ Flink CDC 使用 [Calcite](https://calcite.apache.org/) 
来解析表达式并且
 | CURRENT_DATE                                         | currentDate()         
                               | 返回本地时区的当前 SQL 日期。                              
                                                                                
                                                                                
                                                       |
 | CURRENT_TIMESTAMP                                    | currentTimestamp()    
                               | 返回本地时区的当前 SQL 时间戳,返回类型为 TIMESTAMP_LTZ(3)。      
                                                                                
                                                                                
                                                       |
 | NOW()                                                | now()                 
                               | 返回本地时区的当前 SQL 时间戳,是 CURRENT_TIMESTAMP 的同义词。    
                                                                                
                                                                                
                                                       |
+| EXTRACT(timeintervalunit FROM temporal)              | 
extract(timeintervalunit, temporal)                  | 从时间值中提取指定部分。支持 
YEAR、QUARTER、MONTH、WEEK、DAY、DOY、DOW、HOUR、MINUTE 和 SECOND。                       
                                                                                
                                                                                
    |
+| YEAR / QUARTER / MONTH / WEEK(temporal)              | 
extract(timeintervalunit, temporal)                  | 从时间值中提取年、季度、月或 ISO 周。    
                                                                                
                                                                                
                                                                                
 |
+| DAYOFYEAR / DAYOFMONTH / DAYOFWEEK(temporal)         | 
extract(timeintervalunit, temporal)                  | 
从时间值中提取一年中的第几天、一月中的第几天或一周中的第几天。DAYOFWEEK 以星期日为 1。                               
                                                                                
                                                                                
     |
+| HOUR / MINUTE / SECOND(temporal)                     | 
extract(timeintervalunit, temporal)                  | 从时间值中提取小时、分钟或秒。          
                                                                                
                                                                                
                                                                                
  |
+| temporal +/- INTERVAL 'value' unit                   | temporalPlusMonths / 
temporalPlusMillis              | 对 DATE、TIME、TIMESTAMP 和 TIMESTAMP_LTZ 
值加减年-月或日-时间隔字面量。支持 `INTERVAL + temporal`,以及 `INTERVAL '1-2' YEAR TO MONTH` 和 
`INTERVAL '1 02:03:04' DAY TO SECOND` 等复合间隔。                                    
                                                                |
 | DATE_FORMAT(timestamp, string)                       | dateFormat(timestamp, 
string)                        | 将时间戳转换为指定日期格式字符串的值。格式字符串与 Java 的 
SimpleDateFormat 兼容。                                                            
                                                                                
                                                                     |
 | DATE_FORMAT(date, string)                            | dateFormat(date, 
string)                             | 将给定日期转换为指定格式字符串的值。格式字符串与 Java 的 
SimpleDateFormat 兼容。                                                            
                                                                                
                                                                      |
 | DATE_FORMAT(time, string)                            | dateFormat(time, 
string)                             | 将给定时间转换为指定格式字符串的值。格式字符串与 Java 的 
SimpleDateFormat 兼容。                                                            
                                                                                
                                                                      |
diff --git a/docs/content/docs/core-concept/transform.md 
b/docs/content/docs/core-concept/transform.md
index f60f4d7c7..28bda0e34 100644
--- a/docs/content/docs/core-concept/transform.md
+++ b/docs/content/docs/core-concept/transform.md
@@ -217,6 +217,11 @@ Logical functions follow SQL three-valued logic for 
nullable BOOLEAN values. `AN
 | CURRENT_DATE                                         | currentDate()         
                               | Returns the current SQL date in the local time 
zone.                                                                           
                                                                                
                                                                                
                                                                                
              [...]
 | CURRENT_TIMESTAMP                                    | currentTimestamp()    
                               | Returns the current SQL timestamp in the local 
time zone, the return type is TIMESTAMP_LTZ(3).                                 
                                                                                
                                                                                
                                                                                
              [...]
 | NOW()                                                | now()                 
                               | Returns the current SQL timestamp in the local 
time zone, this is a synonym of CURRENT_TIMESTAMP.                              
                                                                                
                                                                                
                                                                                
              [...]
+| EXTRACT(timeintervalunit FROM temporal)              | 
extract(timeintervalunit, temporal)                  | Extracts a time interval 
unit from a temporal value. Supported units are YEAR, QUARTER, MONTH, WEEK, 
DAY, DOY, DOW, HOUR, MINUTE, and SECOND.                                        
                                                                                
                                                                                
                                        [...]
+| YEAR / QUARTER / MONTH / WEEK(temporal)              | 
extract(timeintervalunit, temporal)                  | Extracts the year, 
quarter, month, or ISO week from a temporal value.                              
                                                                                
                                                                                
                                                                                
                                          [...]
+| DAYOFYEAR / DAYOFMONTH / DAYOFWEEK(temporal)         | 
extract(timeintervalunit, temporal)                  | Extracts the day of 
year, day of month, or day of week from a temporal value. DAYOFWEEK uses Sunday 
as 1.                                                                           
                                                                                
                                                                                
                                         [...]
+| HOUR / MINUTE / SECOND(temporal)                     | 
extract(timeintervalunit, temporal)                  | Extracts the hour, 
minute, or second from a temporal value.                                        
                                                                                
                                                                                
                                                                                
                                          [...]
+| temporal +/- INTERVAL 'value' unit                   | temporalPlusMonths / 
temporalPlusMillis              | Adds or subtracts a year-month or day-time 
interval literal for DATE, TIME, TIMESTAMP, and TIMESTAMP_LTZ values. `INTERVAL 
+ temporal` and compound intervals such as `INTERVAL '1-2' YEAR TO MONTH` and 
`INTERVAL '1 02:03:04' DAY TO SECOND` are supported.                            
                                                                                
                    [...]
 | DATE_FORMAT(timestamp, string)                       | dateFormat(timestamp, 
string)                        | Converts timestamp to a value of string in the 
format specified by the format string. The format string is compatible with 
Java's SimpleDateFormat.                                                        
                                                                                
                                                                                
                  [...]
 | DATE_FORMAT(date, string)                            | dateFormat(date, 
string)                             | Converts given date to a value of string 
in the format specified by the format string. The format string is compatible 
with Java's SimpleDateFormat.                                                   
                                                                                
                                                                                
                      [...]
 | DATE_FORMAT(time, string)                            | dateFormat(time, 
string)                             | Converts given time to a value of string 
in the format specified by the format string. The format string is compatible 
with Java's SimpleDateFormat.                                                   
                                                                                
                                                                                
                      [...]
diff --git a/flink-cdc-composer/src/test/resources/specs/temporal.yaml 
b/flink-cdc-composer/src/test/resources/specs/temporal.yaml
index 3e6125296..8420ccaf7 100644
--- a/flink-cdc-composer/src/test/resources/specs/temporal.yaml
+++ b/flink-cdc-composer/src/test/resources/specs/temporal.yaml
@@ -276,3 +276,47 @@
     DataChangeEvent{tableId=foo.bar.baz, before=[1970-01-26, 1970-01-27, 
1970-01-28, 1970-01-26, 1970-01-27, 1970-01-28, 2001-01-18, 2015-08-09, 
2015-08-09], after=[], op=DELETE, meta=()}
     DataChangeEvent{tableId=foo.bar.baz, before=[], after=[null, null, null, 
null, null, null, null, 2015-08-09, 2015-08-09], op=INSERT, meta=()}
     DataChangeEvent{tableId=foo.bar.baz, before=[null, null, null, null, null, 
null, null, 2015-08-09, 2015-08-09], after=[], op=DELETE, meta=()}
+- do: EXTRACT and Date Part Functions
+  projection: |-
+    id_
+    YEAR(date_) AS year_
+    QUARTER(date_) AS quarter_
+    MONTH(timestamp_9_) AS month_
+    WEEK(date_) AS week_
+    DAYOFYEAR(date_) AS day_of_year_
+    DAYOFMONTH(date_) AS day_of_month_
+    DAYOFWEEK(date_) AS day_of_week_
+    HOUR(time_0_) AS hour_
+    MINUTE(time_0_) AS minute_
+    SECOND(time_0_) AS second_
+    EXTRACT(DAY FROM timestamp_0_) AS extract_day_
+    EXTRACT(HOUR FROM timestamp_ltz_0_) AS extract_ltz_hour_
+    EXTRACT(YEAR FROM TO_TIMESTAMP_LTZ('2023-12-31 16:30:00.000')) AS 
extract_ltz_year_
+  filter: YEAR(date_) >= 2000 OR date_ IS NULL
+  time-zone: Asia/Shanghai
+  expect: |-
+    CreateTableEvent{tableId=foo.bar.baz, schema=columns={`id_` BIGINT NOT 
NULL 'Identifier',`year_` BIGINT,`quarter_` BIGINT,`month_` BIGINT,`week_` 
BIGINT,`day_of_year_` BIGINT,`day_of_month_` BIGINT,`day_of_week_` 
BIGINT,`hour_` BIGINT,`minute_` BIGINT,`second_` BIGINT,`extract_day_` 
BIGINT,`extract_ltz_hour_` BIGINT,`extract_ltz_year_` BIGINT}, primaryKeys=, 
options=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[], after=[1, 2000, 4, 1, 52, 
366, 31, 1, 19, 43, 17, 2, 18, 2024], op=INSERT, meta=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[1, 2000, 4, 1, 52, 366, 31, 
1, 19, 43, 17, 2, 18, 2024], after=[-1, 2001, 1, 1, 1, 1, 1, 2, 12, 34, 45, 9, 
16, 2024], op=UPDATE, meta=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[-1, 2001, 1, 1, 1, 1, 1, 2, 
12, 34, 45, 9, 16, 2024], after=[], op=DELETE, meta=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[], after=[0, null, null, 
null, null, null, null, null, null, null, null, null, null, 2024], op=INSERT, 
meta=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[0, null, null, null, null, 
null, null, null, null, null, null, null, null, 2024], after=[], op=DELETE, 
meta=()}
+- do: Interval Literal Arithmetic
+  projection: |-
+    id_
+    date_ + INTERVAL '1' DAY AS date_plus_day_
+    date_ - INTERVAL '1' MONTH AS date_minus_month_
+    time_0_ + INTERVAL '6' HOUR AS time_plus_hour_
+    timestamp_0_ + INTERVAL '1 02:03:04' DAY TO SECOND AS 
timestamp_plus_day_time_
+    timestamp_0_ - INTERVAL '1-2' YEAR TO MONTH AS timestamp_minus_year_month_
+    INTERVAL '3' DAY + timestamp_ltz_0_ AS ltz_plus_day_
+    timestamp_ltz_0_ + INTERVAL '1' MONTH AS ltz_plus_month_
+    timestamp_0_ + INTERVAL '1' DAY - INTERVAL '2' HOUR AS nested_interval_
+  filter: timestamp_0_ + INTERVAL '1' DAY > timestamp_0_ OR timestamp_0_ IS 
NULL
+  expect: |-
+    CreateTableEvent{tableId=foo.bar.baz, schema=columns={`id_` BIGINT NOT 
NULL 'Identifier',`date_plus_day_` DATE,`date_minus_month_` 
DATE,`time_plus_hour_` TIME(0),`timestamp_plus_day_time_` 
TIMESTAMP(0),`timestamp_minus_year_month_` TIMESTAMP(0),`ltz_plus_day_` 
TIMESTAMP_LTZ(0),`ltz_plus_month_` TIMESTAMP_LTZ(0),`nested_interval_` 
TIMESTAMP(0)}, primaryKeys=, options=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[], after=[1, 2001-01-01, 
2000-11-30, 01:43:17, 1970-01-03T12:20:40.789123456, 
1968-11-02T10:17:36.789123456, 1970-01-05T10:17:36.789123456, 
1970-02-02T10:17:36.789123456, 1970-01-03T08:17:36.789123456], op=INSERT, 
meta=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[1, 2001-01-01, 2000-11-30, 
01:43:17, 1970-01-03T12:20:40.789123456, 1968-11-02T10:17:36.789123456, 
1970-01-05T10:17:36.789123456, 1970-02-02T10:17:36.789123456, 
1970-01-03T08:17:36.789123456], after=[-1, 2001-01-02, 2000-12-01, 18:34:45, 
1970-01-10T11:00:40.789723456, 1968-11-09T08:57:36.789723456, 
1970-01-12T08:57:36.789723456, 1970-02-09T08:57:36.789723456, 
1970-01-10T06:57:36.789723456], op=UPDATE, meta=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[-1, 2001-01-02, 2000-12-01, 
18:34:45, 1970-01-10T11:00:40.789723456, 1968-11-09T08:57:36.789723456, 
1970-01-12T08:57:36.789723456, 1970-02-09T08:57:36.789723456, 
1970-01-10T06:57:36.789723456], after=[], op=DELETE, meta=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[], after=[0, null, null, 
null, null, null, null, null, null], op=INSERT, meta=()}
+    DataChangeEvent{tableId=foo.bar.baz, before=[0, null, null, null, null, 
null, null, null, null], after=[], op=DELETE, meta=()}
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/functions/impl/TemporalFunctions.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/functions/impl/TemporalFunctions.java
index de31a81fc..1deeae0c0 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/functions/impl/TemporalFunctions.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/functions/impl/TemporalFunctions.java
@@ -32,6 +32,11 @@ import java.time.LocalTime;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoField;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.IsoFields;
+import java.time.temporal.TemporalAccessor;
+import java.time.temporal.TemporalField;
 import java.util.TimeZone;
 
 /** Temporal built-in functions. */
@@ -39,6 +44,7 @@ public class TemporalFunctions {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(TemporalFunctions.class);
 
+    private static final long MILLIS_PER_DAY = 24L * 60 * 60 * 1000;
     private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
     private static final String DEFAULT_DATETIME_FORMAT = "yyyy-MM-dd 
HH:mm:ss";
 
@@ -71,6 +77,94 @@ public class TemporalFunctions {
         return localtimestamp(epochTime, timezone).toLocalDate();
     }
 
+    public static Long extract(String unit, TemporalAccessor temporal, String 
timezone) {
+        if (temporal == null) {
+            return null;
+        }
+        if (temporal instanceof Instant) {
+            temporal = ((Instant) temporal).atZone(ZoneId.of(timezone));
+        }
+        switch (unit) {
+            case "YEAR":
+                return getTemporalField(temporal, ChronoField.YEAR, unit);
+            case "QUARTER":
+                return getTemporalField(temporal, IsoFields.QUARTER_OF_YEAR, 
unit);
+            case "MONTH":
+                return getTemporalField(temporal, ChronoField.MONTH_OF_YEAR, 
unit);
+            case "WEEK":
+                return getTemporalField(temporal, 
IsoFields.WEEK_OF_WEEK_BASED_YEAR, unit);
+            case "DAY":
+                return getTemporalField(temporal, ChronoField.DAY_OF_MONTH, 
unit);
+            case "DOY":
+                return getTemporalField(temporal, ChronoField.DAY_OF_YEAR, 
unit);
+            case "DOW":
+                // SQL DOW starts with Sunday as 1, while ISO starts with 
Monday as 1.
+                return getTemporalField(temporal, ChronoField.DAY_OF_WEEK, 
unit) % 7 + 1;
+            case "HOUR":
+                return getTemporalField(temporal, ChronoField.HOUR_OF_DAY, 
unit);
+            case "MINUTE":
+                return getTemporalField(temporal, ChronoField.MINUTE_OF_HOUR, 
unit);
+            case "SECOND":
+                return getTemporalField(temporal, 
ChronoField.SECOND_OF_MINUTE, unit);
+            default:
+                throw new IllegalArgumentException("Unsupported EXTRACT unit: 
" + unit);
+        }
+    }
+
+    private static long getTemporalField(
+            TemporalAccessor temporal, TemporalField field, String unit) {
+        if (temporal instanceof LocalDate
+                && (field == ChronoField.HOUR_OF_DAY
+                        || field == ChronoField.MINUTE_OF_HOUR
+                        || field == ChronoField.SECOND_OF_MINUTE)) {
+            return 0L;
+        }
+        if (!temporal.isSupported(field)) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "EXTRACT unit %s cannot be applied to %s",
+                            unit, temporal.getClass().getSimpleName()));
+        }
+        return temporal.getLong(field);
+    }
+
+    public static LocalDate temporalPlusMonths(LocalDate temporal, long 
months) {
+        return temporal == null ? null : temporal.plusMonths(months);
+    }
+
+    public static LocalTime temporalPlusMonths(LocalTime temporal, long 
months) {
+        // Year-month intervals do not affect TIME values.
+        return temporal;
+    }
+
+    public static LocalDateTime temporalPlusMonths(LocalDateTime temporal, 
long months) {
+        return temporal == null ? null : temporal.plusMonths(months);
+    }
+
+    public static Instant temporalPlusMonths(Instant temporal, long months) {
+        // TIMESTAMP_LTZ month arithmetic follows UTC calendar semantics.
+        return temporal == null
+                ? null
+                : 
temporal.atZone(ZoneId.of("UTC")).plusMonths(months).toInstant();
+    }
+
+    public static LocalDate temporalPlusMillis(LocalDate temporal, long 
millis) {
+        // DATE arithmetic uses only the whole-day portion of a day-time 
interval.
+        return temporal == null ? null : temporal.plusDays(millis / 
MILLIS_PER_DAY);
+    }
+
+    public static LocalTime temporalPlusMillis(LocalTime temporal, long 
millis) {
+        return temporal == null ? null : temporal.plus(millis, 
ChronoUnit.MILLIS);
+    }
+
+    public static LocalDateTime temporalPlusMillis(LocalDateTime temporal, 
long millis) {
+        return temporal == null ? null : temporal.plus(millis, 
ChronoUnit.MILLIS);
+    }
+
+    public static Instant temporalPlusMillis(Instant temporal, long millis) {
+        return temporal == null ? null : temporal.plusMillis(millis);
+    }
+
     public static String fromUnixtime(Integer seconds, String timezone) {
         if (seconds == null) {
             return null;
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/JaninoCompiler.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/JaninoCompiler.java
index 53e81a5dc..6f8d0e464 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/JaninoCompiler.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/JaninoCompiler.java
@@ -35,11 +35,15 @@ import org.apache.calcite.sql.SqlBasicTypeNameSpec;
 import org.apache.calcite.sql.SqlCharStringLiteral;
 import org.apache.calcite.sql.SqlDataTypeSpec;
 import org.apache.calcite.sql.SqlIdentifier;
+import org.apache.calcite.sql.SqlIntervalLiteral;
+import org.apache.calcite.sql.SqlIntervalQualifier;
+import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.sql.SqlLiteral;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlNodeList;
 import org.apache.calcite.sql.SqlNumericLiteral;
 import org.apache.calcite.sql.fun.SqlCase;
+import org.apache.calcite.sql.parser.SqlParserUtil;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.util.NlsString;
 import org.codehaus.commons.compiler.CompileException;
@@ -54,6 +58,7 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
@@ -64,6 +69,24 @@ import java.util.stream.Collectors;
  */
 public class JaninoCompiler {
 
+    private static final Map<String, String> DATE_PART_FUNCTION_UNITS =
+            Map.ofEntries(
+                    Map.entry("YEAR", "YEAR"),
+                    Map.entry("QUARTER", "QUARTER"),
+                    Map.entry("MONTH", "MONTH"),
+                    Map.entry("WEEK", "WEEK"),
+                    Map.entry("DAYOFYEAR", "DOY"),
+                    Map.entry("DAYOFMONTH", "DAY"),
+                    Map.entry("DAYOFWEEK", "DOW"),
+                    Map.entry("HOUR", "HOUR"),
+                    Map.entry("MINUTE", "MINUTE"),
+                    Map.entry("SECOND", "SECOND"));
+
+    private static final Set<String> SUPPORTED_EXTRACT_UNITS =
+            Set.of(
+                    "YEAR", "QUARTER", "MONTH", "WEEK", "DAY", "DOY", "DOW", 
"HOUR", "MINUTE",
+                    "SECOND");
+
     private static final List<SqlTypeName> SQL_TYPE_NAME_IGNORE = 
Arrays.asList(SqlTypeName.SYMBOL);
     private static final List<String> TIMEZONE_FREE_TEMPORAL_FUNCTIONS =
             Arrays.asList("CURRENT_TIMESTAMP", "NOW");
@@ -200,6 +223,17 @@ public class JaninoCompiler {
     }
 
     private static Java.Rvalue translateSqlBasicCall(Context context, 
SqlBasicCall sqlBasicCall) {
+        String functionName = 
sqlBasicCall.getOperator().getName().toUpperCase();
+        if (isIntervalArithmetic(sqlBasicCall)) {
+            return generateIntervalArithmeticOperation(context, sqlBasicCall);
+        }
+        if (sqlBasicCall.getKind() == SqlKind.EXTRACT) {
+            return generateExtractOperation(context, sqlBasicCall);
+        }
+        if (DATE_PART_FUNCTION_UNITS.containsKey(functionName)) {
+            return generateDatePartFunctionOperation(context, sqlBasicCall, 
functionName);
+        }
+
         List<SqlNode> operandList = sqlBasicCall.getOperandList();
         List<Java.Rvalue> atoms = new ArrayList<>();
         for (SqlNode sqlNode : operandList) {
@@ -219,6 +253,98 @@ public class JaninoCompiler {
         return sqlBasicCallToJaninoRvalue(context, sqlBasicCall, 
atoms.toArray(new Java.Rvalue[0]));
     }
 
+    private static boolean isIntervalArithmetic(SqlBasicCall sqlBasicCall) {
+        if (sqlBasicCall.getKind() != SqlKind.PLUS && sqlBasicCall.getKind() 
!= SqlKind.MINUS) {
+            return false;
+        }
+        return sqlBasicCall.getOperandList().stream()
+                .anyMatch(operand -> operand instanceof SqlIntervalLiteral);
+    }
+
+    private static Java.Rvalue generateIntervalArithmeticOperation(
+            Context context, SqlBasicCall sqlBasicCall) {
+        List<SqlNode> operands = sqlBasicCall.getOperandList();
+        if (operands.size() != 2) {
+            throw new ParseException("Unrecognized interval arithmetic: " + 
sqlBasicCall);
+        }
+
+        boolean intervalOnLeft = operands.get(0) instanceof SqlIntervalLiteral;
+        boolean intervalOnRight = operands.get(1) instanceof 
SqlIntervalLiteral;
+        if (intervalOnLeft == intervalOnRight
+                || (intervalOnLeft && sqlBasicCall.getKind() == 
SqlKind.MINUS)) {
+            throw new ParseException("Unsupported interval arithmetic: " + 
sqlBasicCall);
+        }
+
+        SqlIntervalLiteral intervalLiteral =
+                (SqlIntervalLiteral) operands.get(intervalOnLeft ? 0 : 1);
+        SqlNode temporalOperand = operands.get(intervalOnLeft ? 1 : 0);
+        Java.Rvalue temporal = translateSqlNodeToJaninoRvalue(context, 
temporalOperand);
+        if (temporal == null) {
+            throw new ParseException("Unrecognized temporal expression: " + 
temporalOperand);
+        }
+
+        SqlIntervalLiteral.IntervalValue intervalValue =
+                
intervalLiteral.getValueAs(SqlIntervalLiteral.IntervalValue.class);
+        boolean yearMonth = intervalValue.getIntervalQualifier().isYearMonth();
+        long amount =
+                yearMonth
+                        ? SqlParserUtil.intervalToMonths(intervalValue)
+                        : SqlParserUtil.intervalToMillis(intervalValue);
+        if (sqlBasicCall.getKind() == SqlKind.MINUS) {
+            amount = Math.negateExact(amount);
+        }
+
+        return generateFunctionOperation(
+                yearMonth ? "temporalPlusMonths" : "temporalPlusMillis",
+                new Java.Rvalue[] {
+                    temporal,
+                    new Java.AmbiguousName(
+                            Location.NOWHERE, new String[] 
{Long.toString(amount) + "L"})
+                });
+    }
+
+    private static Java.Rvalue generateDatePartFunctionOperation(
+            Context context, SqlBasicCall sqlBasicCall, String functionName) {
+        if (sqlBasicCall.getOperandList().size() != 1) {
+            throw new ParseException("Unrecognized expression: " + 
sqlBasicCall);
+        }
+        return generateExtractOperation(
+                context,
+                DATE_PART_FUNCTION_UNITS.get(functionName),
+                sqlBasicCall.getOperandList().get(0));
+    }
+
+    private static Java.Rvalue generateExtractOperation(
+            Context context, SqlBasicCall sqlBasicCall) {
+        List<SqlNode> operands = sqlBasicCall.getOperandList();
+        if (operands.size() != 2 || !(operands.get(0) instanceof 
SqlIntervalQualifier)) {
+            throw new ParseException("Unrecognized expression: " + 
sqlBasicCall);
+        }
+        SqlIntervalQualifier qualifier = (SqlIntervalQualifier) 
operands.get(0);
+        if (!qualifier.isSingleDatetimeField()) {
+            throw new ParseException("Unsupported EXTRACT unit: " + qualifier);
+        }
+        return generateExtractOperation(context, 
qualifier.getStartUnit().name(), operands.get(1));
+    }
+
+    private static Java.Rvalue generateExtractOperation(
+            Context context, String unit, SqlNode temporalOperand) {
+        if (!SUPPORTED_EXTRACT_UNITS.contains(unit)) {
+            throw new ParseException("Unsupported EXTRACT unit: " + unit);
+        }
+        Java.Rvalue temporal = translateSqlNodeToJaninoRvalue(context, 
temporalOperand);
+        if (temporal == null) {
+            throw new ParseException("Unrecognized temporal expression: " + 
temporalOperand);
+        }
+        return generateFunctionOperation(
+                "extract",
+                new Java.Rvalue[] {
+                    new Java.AmbiguousName(Location.NOWHERE, new String[] 
{"\"" + unit + "\""}),
+                    temporal,
+                    new Java.AmbiguousName(Location.NOWHERE, new String[] 
{DEFAULT_TIME_ZONE})
+                });
+    }
+
     private static Java.Rvalue translateSqlCase(Context context, SqlCase 
sqlCase) {
         SqlNodeList whenOperands = sqlCase.getWhenOperands();
         SqlNodeList thenOperands = sqlCase.getThenOperands();
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/metadata/TransformSqlOperatorTable.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/metadata/TransformSqlOperatorTable.java
index 4850815cc..096e3fa2b 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/metadata/TransformSqlOperatorTable.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/metadata/TransformSqlOperatorTable.java
@@ -443,6 +443,17 @@ public class TransformSqlOperatorTable extends 
ReflectiveSqlOperatorTable {
     // ------------------
     // Temporal Functions
     // ------------------
+    public static final SqlFunction EXTRACT = SqlStdOperatorTable.EXTRACT;
+    public static final SqlFunction YEAR = SqlStdOperatorTable.YEAR;
+    public static final SqlFunction QUARTER = SqlStdOperatorTable.QUARTER;
+    public static final SqlFunction MONTH = SqlStdOperatorTable.MONTH;
+    public static final SqlFunction WEEK = SqlStdOperatorTable.WEEK;
+    public static final SqlFunction DAYOFYEAR = SqlStdOperatorTable.DAYOFYEAR;
+    public static final SqlFunction DAYOFMONTH = 
SqlStdOperatorTable.DAYOFMONTH;
+    public static final SqlFunction DAYOFWEEK = SqlStdOperatorTable.DAYOFWEEK;
+    public static final SqlFunction HOUR = SqlStdOperatorTable.HOUR;
+    public static final SqlFunction MINUTE = SqlStdOperatorTable.MINUTE;
+    public static final SqlFunction SECOND = SqlStdOperatorTable.SECOND;
     public static final SqlFunction LOCALTIME = SqlStdOperatorTable.LOCALTIME;
     public static final SqlFunction LOCALTIMESTAMP =
             new BuiltInTimestampFunction("LOCALTIMESTAMP", 
SqlTypeName.TIMESTAMP, 3);
diff --git 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/functions/impl/TemporalFunctionsTest.java
 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/functions/impl/TemporalFunctionsTest.java
new file mode 100644
index 000000000..ad6d29bf9
--- /dev/null
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/functions/impl/TemporalFunctionsTest.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.runtime.functions.impl;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link TemporalFunctions}. */
+class TemporalFunctionsTest {
+
+    @Test
+    void testExtractDateParts() {
+        LocalDate date = LocalDate.of(2024, 2, 29);
+
+        assertThat(TemporalFunctions.extract("YEAR", date, 
"UTC")).isEqualTo(2024L);
+        assertThat(TemporalFunctions.extract("QUARTER", date, 
"UTC")).isEqualTo(1L);
+        assertThat(TemporalFunctions.extract("MONTH", date, 
"UTC")).isEqualTo(2L);
+        assertThat(TemporalFunctions.extract("WEEK", date, 
"UTC")).isEqualTo(9L);
+        assertThat(TemporalFunctions.extract("DAY", date, 
"UTC")).isEqualTo(29L);
+        assertThat(TemporalFunctions.extract("DOY", date, 
"UTC")).isEqualTo(60L);
+        assertThat(TemporalFunctions.extract("DOW", date, 
"UTC")).isEqualTo(5L);
+        assertThat(TemporalFunctions.extract("HOUR", date, "UTC")).isZero();
+        assertThat(TemporalFunctions.extract("MINUTE", date, "UTC")).isZero();
+        assertThat(TemporalFunctions.extract("SECOND", date, "UTC")).isZero();
+    }
+
+    @Test
+    void testExtractTimeParts() {
+        LocalTime time = LocalTime.of(23, 58, 57);
+
+        assertThat(TemporalFunctions.extract("HOUR", time, 
"UTC")).isEqualTo(23L);
+        assertThat(TemporalFunctions.extract("MINUTE", time, 
"UTC")).isEqualTo(58L);
+        assertThat(TemporalFunctions.extract("SECOND", time, 
"UTC")).isEqualTo(57L);
+    }
+
+    @Test
+    void testExtractTimestampParts() {
+        LocalDateTime timestamp = LocalDateTime.of(2024, 12, 31, 23, 58, 57);
+
+        assertThat(TemporalFunctions.extract("YEAR", timestamp, 
"UTC")).isEqualTo(2024L);
+        assertThat(TemporalFunctions.extract("WEEK", timestamp, 
"UTC")).isEqualTo(1L);
+        assertThat(TemporalFunctions.extract("HOUR", timestamp, 
"UTC")).isEqualTo(23L);
+    }
+
+    @Test
+    void testExtractTimestampLtzUsesPipelineTimeZone() {
+        Instant timestamp = Instant.parse("2023-12-31T16:30:00Z");
+
+        assertThat(TemporalFunctions.extract("YEAR", timestamp, 
"UTC")).isEqualTo(2023L);
+        assertThat(TemporalFunctions.extract("YEAR", timestamp, 
"Asia/Shanghai")).isEqualTo(2024L);
+        assertThat(TemporalFunctions.extract("HOUR", timestamp, 
"Asia/Shanghai")).isEqualTo(0L);
+    }
+
+    @Test
+    void testExtractTimestampTzUsesValueTimeZone() {
+        assertThat(
+                        TemporalFunctions.extract(
+                                "HOUR",
+                                Instant.parse("2024-01-01T00:00:00Z")
+                                        .atZone(ZoneId.of("Asia/Shanghai")),
+                                "UTC"))
+                .isEqualTo(8L);
+    }
+
+    @Test
+    void testExtractNullAndUnsupportedParts() {
+        assertThat(TemporalFunctions.extract("YEAR", null, "UTC")).isNull();
+        assertThatThrownBy(() -> TemporalFunctions.extract("YEAR", 
LocalTime.NOON, "UTC"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("EXTRACT unit YEAR cannot be applied to 
LocalTime");
+        assertThatThrownBy(
+                        () -> TemporalFunctions.extract("MILLISECOND", 
LocalDateTime.now(), "UTC"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Unsupported EXTRACT unit: MILLISECOND");
+    }
+
+    @Test
+    void testTemporalPlusMonths() {
+        assertThat(TemporalFunctions.temporalPlusMonths(LocalDate.of(2024, 1, 
31), 1))
+                .isEqualTo(LocalDate.of(2024, 2, 29));
+        assertThat(
+                        TemporalFunctions.temporalPlusMonths(
+                                LocalDateTime.of(2024, 1, 31, 12, 34, 56), 13))
+                .isEqualTo(LocalDateTime.of(2025, 2, 28, 12, 34, 56));
+        
assertThat(TemporalFunctions.temporalPlusMonths(Instant.parse("2024-01-31T12:34:56Z"),
 -1))
+                .isEqualTo(Instant.parse("2023-12-31T12:34:56Z"));
+        assertThat(TemporalFunctions.temporalPlusMonths(LocalTime.NOON, 12))
+                .isEqualTo(LocalTime.NOON);
+        assertThat(TemporalFunctions.temporalPlusMonths((LocalDate) null, 
1)).isNull();
+    }
+
+    @Test
+    void testTemporalPlusMillis() {
+        assertThat(
+                        TemporalFunctions.temporalPlusMillis(
+                                LocalDate.of(2024, 2, 28), 24L * 60 * 60 * 
1000))
+                .isEqualTo(LocalDate.of(2024, 2, 29));
+        assertThat(
+                        TemporalFunctions.temporalPlusMillis(
+                                LocalDate.of(2024, 2, 28), 12L * 60 * 60 * 
1000))
+                .isEqualTo(LocalDate.of(2024, 2, 28));
+        assertThat(TemporalFunctions.temporalPlusMillis(LocalTime.of(23, 30), 
2L * 60 * 60 * 1000))
+                .isEqualTo(LocalTime.of(1, 30));
+        assertThat(
+                        TemporalFunctions.temporalPlusMillis(
+                                LocalDateTime.of(2024, 2, 28, 23, 30), 2L * 60 
* 60 * 1000))
+                .isEqualTo(LocalDateTime.of(2024, 2, 29, 1, 30));
+        assertThat(
+                        TemporalFunctions.temporalPlusMillis(
+                                Instant.parse("2024-02-28T23:30:00Z"), 2L * 60 
* 60 * 1000))
+                .isEqualTo(Instant.parse("2024-02-29T01:30:00Z"));
+        assertThat(TemporalFunctions.temporalPlusMillis((Instant) null, 
1)).isNull();
+    }
+}
diff --git 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/parser/TransformParserTest.java
 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/parser/TransformParserTest.java
index c46525cd7..ab9ed2467 100644
--- 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/parser/TransformParserTest.java
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/parser/TransformParserTest.java
@@ -59,6 +59,72 @@ class TransformParserTest {
                     .primaryKey("id")
                     .build();
 
+    @Test
+    void testGenerateDatePartFunctionProjectionColumns() {
+        List<ProjectionColumn> projectionColumns =
+                TransformParser.generateProjectionColumns(
+                        "YEAR(date_col) AS year_col, "
+                                + "HOUR(time_col) AS hour_col, "
+                                + "EXTRACT(DAY FROM timestamp_col) AS day_col, 
"
+                                + "EXTRACT(HOUR FROM timestamp_ltz_col) AS 
ltz_hour_col",
+                        Arrays.asList(
+                                Column.physicalColumn("date_col", 
DataTypes.DATE()),
+                                Column.physicalColumn("time_col", 
DataTypes.TIME()),
+                                Column.physicalColumn("timestamp_col", 
DataTypes.TIMESTAMP()),
+                                Column.physicalColumn(
+                                        "timestamp_ltz_col", 
DataTypes.TIMESTAMP_LTZ())),
+                        Collections.emptyList(),
+                        new SupportedMetadataColumn[0]);
+
+        Assertions.assertThat(projectionColumns)
+                .extracting(ProjectionColumn::getDataType)
+                .containsExactly(
+                        DataTypes.BIGINT(),
+                        DataTypes.BIGINT(),
+                        DataTypes.BIGINT(),
+                        DataTypes.BIGINT());
+        Assertions.assertThat(projectionColumns)
+                .extracting(ProjectionColumn::getScriptExpression)
+                .containsExactly(
+                        "extract(\"YEAR\", $0, __time_zone__)",
+                        "extract(\"HOUR\", $0, __time_zone__)",
+                        "extract(\"DAY\", $0, __time_zone__)",
+                        "extract(\"HOUR\", $0, __time_zone__)");
+    }
+
+    @Test
+    void testGenerateIntervalArithmeticProjectionColumns() {
+        List<ProjectionColumn> projectionColumns =
+                TransformParser.generateProjectionColumns(
+                        "date_col + INTERVAL '1' DAY AS date_col, "
+                                + "time_col + INTERVAL '2:03' HOUR TO MINUTE 
AS time_col, "
+                                + "timestamp_col - INTERVAL '1-2' YEAR TO 
MONTH AS timestamp_col, "
+                                + "INTERVAL '3' DAY + timestamp_ltz_col AS 
timestamp_ltz_col",
+                        Arrays.asList(
+                                Column.physicalColumn("date_col", 
DataTypes.DATE()),
+                                Column.physicalColumn("time_col", 
DataTypes.TIME()),
+                                Column.physicalColumn("timestamp_col", 
DataTypes.TIMESTAMP()),
+                                Column.physicalColumn(
+                                        "timestamp_ltz_col", 
DataTypes.TIMESTAMP_LTZ())),
+                        Collections.emptyList(),
+                        new SupportedMetadataColumn[0]);
+
+        Assertions.assertThat(projectionColumns)
+                .extracting(ProjectionColumn::getDataType)
+                .containsExactly(
+                        DataTypes.DATE(),
+                        DataTypes.TIME(),
+                        DataTypes.TIMESTAMP(3),
+                        DataTypes.TIMESTAMP_LTZ(3));
+        Assertions.assertThat(projectionColumns)
+                .extracting(ProjectionColumn::getScriptExpression)
+                .containsExactly(
+                        "temporalPlusMillis($0, 86400000L)",
+                        "temporalPlusMillis($0, 7380000L)",
+                        "temporalPlusMonths($0, -14L)",
+                        "temporalPlusMillis($0, 259200000L)");
+    }
+
     @Test
     void testCalciteParser() {
         SqlSelect parse =
@@ -302,10 +368,18 @@ class TransformParserTest {
         testFilterExpression(
                 "UNIX_TIMESTAMP('1970-01-01 08:00:01.001 +0800', 'yyyy-MM-dd 
HH:mm:ss.SSS X')",
                 "unixTimestamp(\"1970-01-01 08:00:01.001 +0800\", \"yyyy-MM-dd 
HH:mm:ss.SSS X\", __epoch_time__, __time_zone__)");
-        testFilterExpression("YEAR(dt)", "year(dt)");
-        testFilterExpression("QUARTER(dt)", "quarter(dt)");
-        testFilterExpression("MONTH(dt)", "month(dt)");
-        testFilterExpression("WEEK(dt)", "week(dt)");
+        testFilterExpression("YEAR(dt)", "extract(\"YEAR\", dt, 
__time_zone__)");
+        testFilterExpression("QUARTER(dt)", "extract(\"QUARTER\", dt, 
__time_zone__)");
+        testFilterExpression("MONTH(dt)", "extract(\"MONTH\", dt, 
__time_zone__)");
+        testFilterExpression("WEEK(dt)", "extract(\"WEEK\", dt, 
__time_zone__)");
+        testFilterExpression("DAYOFYEAR(dt)", "extract(\"DOY\", dt, 
__time_zone__)");
+        testFilterExpression("DAYOFMONTH(dt)", "extract(\"DAY\", dt, 
__time_zone__)");
+        testFilterExpression("DAYOFWEEK(dt)", "extract(\"DOW\", dt, 
__time_zone__)");
+        testFilterExpression("HOUR(dt)", "extract(\"HOUR\", dt, 
__time_zone__)");
+        testFilterExpression("MINUTE(dt)", "extract(\"MINUTE\", dt, 
__time_zone__)");
+        testFilterExpression("SECOND(dt)", "extract(\"SECOND\", dt, 
__time_zone__)");
+        testFilterExpression("EXTRACT(YEAR FROM dt)", "extract(\"YEAR\", dt, 
__time_zone__)");
+        testFilterExpression("EXTRACT(DOY FROM dt)", "extract(\"DOY\", dt, 
__time_zone__)");
         testFilterExpression(
                 "DATE_FORMAT(dt,'yyyy-MM-dd')", "dateFormat(dt, 
\"yyyy-MM-dd\", __time_zone__)");
         testFilterExpression("TO_DATE(dt, 'yyyy-MM-dd')", "toDate(dt, 
\"yyyy-MM-dd\")");
@@ -412,6 +486,11 @@ class TransformParserTest {
         testFilterExpression("id * 2", "id * 2");
         testFilterExpression("id / 2", "id / 2");
         testFilterExpression("id % 2", "id % 2");
+        testFilterExpression("dt + INTERVAL '1' DAY", "temporalPlusMillis(dt, 
86400000L)");
+        testFilterExpression("dt - INTERVAL '1-2' YEAR TO MONTH", 
"temporalPlusMonths(dt, -14L)");
+        testFilterExpression(
+                "INTERVAL '2:03' HOUR TO MINUTE + dt", "temporalPlusMillis(dt, 
7380000L)");
+        testFilterExpression("dt + INTERVAL '-1' SECOND", 
"temporalPlusMillis(dt, -1000L)");
         testFilterExpression("a < b", "lessThan(a, b)");
         testFilterExpression("a <= b", "lessThanOrEqual(a, b)");
         testFilterExpression("a > b", "greaterThan(a, b)");
@@ -661,6 +740,16 @@ class TransformParserTest {
 
     @Test
     public void testTranslateFilterToJaninoExpressionError() {
+        Assertions.assertThatThrownBy(
+                        () ->
+                                
TransformParser.translateFilterExpressionToJaninoExpression(
+                                        "INTERVAL '1' DAY - dt",
+                                        Collections.emptyList(),
+                                        Collections.emptyList(),
+                                        new SupportedMetadataColumn[0],
+                                        Collections.emptyMap()))
+                .isExactlyInstanceOf(ParseException.class)
+                .hasMessageStartingWith("Unsupported interval arithmetic:");
         Assertions.assertThatThrownBy(
                         () -> {
                             
TransformParser.translateFilterExpressionToJaninoExpression(

Reply via email to