diff --git a/babel/src/test/java/org/apache/calcite/test/BabelTest.java b/babel/src/test/java/org/apache/calcite/test/BabelTest.java index 09da7555085..769975c4a89 100644 --- a/babel/src/test/java/org/apache/calcite/test/BabelTest.java +++ b/babel/src/test/java/org/apache/calcite/test/BabelTest.java @@ -490,4 +490,48 @@ private void checkSqlResult(String funLibrary, String query, String result) { .query(query) .returns(result); } + + /** Test case for + * [CALCITE-7337] + * Add age function (enabled in PostgreSQL library). */ + @Test void testAgeFunction() { + checkSqlResult("postgresql", + "SELECT AGE(timestamp '2023-12-25', timestamp '2020-01-01') FROM (VALUES (1)) t", + "EXPR$0=3 years 11 mons 24 days\n"); + + checkSqlResult("postgresql", + "SELECT AGE(timestamp '2023-01-01', timestamp '2023-01-01') FROM (VALUES (1)) t", + "EXPR$0=00:00:00\n"); + + checkSqlResult("postgresql", + "SELECT AGE(timestamp '2020-01-01', timestamp '2023-12-25') FROM (VALUES (1)) t", + "EXPR$0=-3 years -11 mons -24 days\n"); + + checkSqlResult("postgresql", + "SELECT AGE(timestamp '2023-02-01', timestamp '2023-01-31') FROM (VALUES (1)) t", + "EXPR$0=1 day\n"); + + checkSqlResult("postgresql", + "SELECT AGE(timestamp '2023-12-26 14:30:00', timestamp '2023-12-25 14:30:00') FROM (VALUES (1)) t", + "EXPR$0=1 day\n"); + + checkSqlResult("postgresql", + "SELECT AGE(timestamp '2023-12-25 00:00:00', timestamp '2020-01-01 23:59:59') FROM (VALUES (1)) t", + "EXPR$0=3 years 11 mons 23 days 00:00:01\n"); + + checkSqlResult("postgresql", + "SELECT AGE(timestamp '2023-12-25 00:00:00.101', timestamp '2020-01-01 23:59:59.202') FROM (VALUES (1)) t", + "EXPR$0=3 years 11 mons 23 days 00:00:00.899\n"); + + checkSqlResult("postgresql", + "SELECT AGE(timestamp '2023-12-25 12:00:00.500', timestamp '2023-12-25 12:00:00.000') FROM (VALUES (1)) t", + "EXPR$0=00:00:00.5\n"); + + CalciteAssert.that() + .with(CalciteConnectionProperty.PARSER_FACTORY, + SqlBabelParserImpl.class.getName() + "#FACTORY") + .with(CalciteConnectionProperty.FUN, "postgresql") + .query("SELECT AGE(timestamp '2023-12-25') FROM (VALUES (1)) t") + .runs(); + } } diff --git a/core/src/main/java/org/apache/calcite/adapter/enumerable/RexImpTable.java b/core/src/main/java/org/apache/calcite/adapter/enumerable/RexImpTable.java index ca8f93feb9f..2e1a12cd397 100644 --- a/core/src/main/java/org/apache/calcite/adapter/enumerable/RexImpTable.java +++ b/core/src/main/java/org/apache/calcite/adapter/enumerable/RexImpTable.java @@ -134,6 +134,7 @@ import static org.apache.calcite.sql.fun.SqlInternalOperators.THROW_UNLESS; import static org.apache.calcite.sql.fun.SqlLibraryOperators.ACOSD; import static org.apache.calcite.sql.fun.SqlLibraryOperators.ACOSH; +import static org.apache.calcite.sql.fun.SqlLibraryOperators.AGE; import static org.apache.calcite.sql.fun.SqlLibraryOperators.ARRAY; import static org.apache.calcite.sql.fun.SqlLibraryOperators.ARRAYS_OVERLAP; import static org.apache.calcite.sql.fun.SqlLibraryOperators.ARRAYS_ZIP; @@ -1027,6 +1028,8 @@ void populate2() { define(FORMAT_TIME, datetimeFormatImpl); define(FORMAT_TIMESTAMP, datetimeFormatImpl); + defineMethod(AGE, BuiltInMethod.AGE.method, NullPolicy.STRICT); + // Boolean operators define(IS_NULL, new IsNullImplementor()); define(IS_NOT_NULL, new IsNotNullImplementor()); diff --git a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java index dc4c6c25878..5db95851590 100644 --- a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java +++ b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java @@ -60,6 +60,7 @@ import org.apache.commons.codec.binary.Hex; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.codec.language.Soundex; +import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.math3.util.CombinatoricsUtils; import org.apache.commons.text.StringEscapeUtils; import org.apache.commons.text.similarity.LevenshteinDistance; @@ -99,11 +100,13 @@ import java.text.Normalizer; import java.text.ParsePosition; import java.text.SimpleDateFormat; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; +import java.time.Period; import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; @@ -7491,4 +7494,112 @@ private enum PartToExtract { AUTHORITY, USERINFO; } + + /** SQL {@code AGE(timestamp1, timestamp2)} function. */ + private static String age(long timestamp1, long timestamp2) { + // Convert timestamps to ZonedDateTime objects using UTC to avoid timezone issues + Instant instant1 = Instant.ofEpochMilli(timestamp1); + Instant instant2 = Instant.ofEpochMilli(timestamp2); + + ZonedDateTime dateTime1 = ZonedDateTime.ofInstant(instant1, ZoneOffset.UTC); + ZonedDateTime dateTime2 = ZonedDateTime.ofInstant(instant2, ZoneOffset.UTC); + + // Check if the original timestamps are in the correct order + boolean isNegative = timestamp1 < timestamp2; + + // Ensure dateTime1 is later than dateTime2 for consistent calculation + if (dateTime1.isBefore(dateTime2)) { + ZonedDateTime temp = dateTime1; + dateTime1 = dateTime2; + dateTime2 = temp; + } + + // Calculate period (years, months, days) + Period period = Period.between(dateTime2.toLocalDate(), dateTime1.toLocalDate()); + + // Calculate duration (hours, minutes, seconds, milliseconds) + Duration duration = Duration.between(dateTime2, dateTime1); + + // Adjust for possible day overflow when time part is negative + if (dateTime1.toLocalTime().isBefore(dateTime2.toLocalTime())) { + period = period.minusDays(1); + duration = duration.plusDays(1); + } + + // Extract components + int years = period.getYears(); + int months = period.getMonths(); + int days = period.getDays(); + + long hours = duration.toHours() % 24; + long minutes = duration.toMinutes() % 60; + long seconds = duration.getSeconds() % 60; + long millis = duration.toMillis() % 1000; + + // Apply negative sign if needed + if (isNegative) { + years = -years; + months = -months; + days = -days; + } + + StringBuilder sb = new StringBuilder(); + if (years != 0) { + sb = + Math.abs(years) > 1 ? sb.append(years).append(" years ") + : sb.append(years).append(" year "); + } + if (months != 0) { + sb = + Math.abs(months) > 1 ? sb.append(months).append(" mons ") + : sb.append(months).append(" mon "); + } + if (days != 0) { + sb = + Math.abs(days) > 1 ? sb.append(days).append(" days ") + : sb.append(days).append(" day "); + } + + + // Add negative sign if needed for time part + if (isNegative && (hours != 0 || minutes != 0 || seconds != 0)) { + sb.append("-"); + } + if (millis != 0) { + String millisString = BigDecimal.valueOf(millis) + .divide(BigDecimal.valueOf(1000)) + .stripTrailingZeros() + .toPlainString().substring(2); + sb.append( + String.format(Locale.ROOT, "%02d:%02d:%02d.%s", hours, minutes, seconds, + millisString)); + } else if (ObjectUtils.isNotEmpty(sb) + && hours == 0 && minutes == 0 && seconds == 0 && millis == 0) { + return sb.toString().trim(); + } else { + sb.append(String.format(Locale.ROOT, "%02d:%02d:%02d", hours, minutes, seconds)); + } + return sb.toString().trim(); + } + + /** SQL {@code AGE(timestamp1, timestamp2)} function. Supports 1 or 2 timestamp arguments. */ + public static String age(long... timestamps) { + if (timestamps.length == 0) { + throw new IllegalArgumentException("AGE function requires at least one timestamp argument"); + } + + if (timestamps.length == 1) { + // Single parameter version: calculate age relative to current time + long timestamp = timestamps[0]; + // Use the actual current timestamp (including time component) in UTC + long currentTimestamp = Instant.now().toEpochMilli(); + // Call the two-parameter version with current timestamp and input timestamp + return age(currentTimestamp, timestamp); + } else if (timestamps.length == 2) { + // Two parameter version: calculate age between two timestamps + return age(timestamps[0], timestamps[1]); + } else { + throw new IllegalArgumentException("AGE function supports only 1 or 2 timestamp arguments"); + } + } } diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlLibraryOperators.java b/core/src/main/java/org/apache/calcite/sql/fun/SqlLibraryOperators.java index f8f4e9273b8..ded908db1eb 100644 --- a/core/src/main/java/org/apache/calcite/sql/fun/SqlLibraryOperators.java +++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlLibraryOperators.java @@ -2774,4 +2774,21 @@ private static RelDataType deriveTypeMapFromEntries(SqlOperatorBinding opBinding public static final SqlFunction RANDOM = SqlStdOperatorTable.RAND .withName("RANDOM") .withOperandTypeChecker(OperandTypes.NILADIC); + + /** + * AGE function for PostgreSQL. + * Returns a human-readable VARCHAR describing the interval between + * one or two timestamps (for example, + * "3 years 11 mons 24 days 0 hours 0 mins 0.0 secs"). + * + * @see PostgreSQL AGE + */ + @LibraryOperator(libraries = {POSTGRESQL}, exceptLibraries = {REDSHIFT}) + public static final SqlBasicFunction AGE = + SqlBasicFunction.create("AGE", + ReturnTypes.VARCHAR_NULLABLE, + OperandTypes.or( + OperandTypes.family(SqlTypeFamily.TIMESTAMP), + OperandTypes.family(SqlTypeFamily.TIMESTAMP, SqlTypeFamily.TIMESTAMP)), + SqlFunctionCategory.TIMEDATE); } diff --git a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java index 38344096404..8b1e3d73395 100644 --- a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java +++ b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java @@ -980,7 +980,8 @@ public enum BuiltInMethod { ImmutableBitSet.class), FUNCTIONAL_DEPENDENCY_DETERMINANTS(FunctionalDependency.class, "determinants", ImmutableBitSet.class), - FUNCTIONAL_DEPENDENCY_GET_FDS(FunctionalDependency.class, "getFDs"); + FUNCTIONAL_DEPENDENCY_GET_FDS(FunctionalDependency.class, "getFDs"), + AGE(SqlFunctions.class, "age", long[].class); @SuppressWarnings("ImmutableEnumChecker") public final Method method; diff --git a/site/_docs/reference.md b/site/_docs/reference.md index 617ce1a83be..1c5f8d38286 100644 --- a/site/_docs/reference.md +++ b/site/_docs/reference.md @@ -3075,6 +3075,7 @@ In the following: | b | TO_CODE_POINTS(string) | Converts *string* to an array of integers that represent code points or extended ASCII character values | o p r h | TO_DATE(string, format) | Converts *string* to a date using the format *format* | o p r | TO_TIMESTAMP(string, format) | Converts *string* to a timestamp using the format *format* +| p | AGE(timestamp1 [, timestamp2 ]) | Returns a formatted string representing the difference between timestamps (for example, "3 years 11 mons 24 days 0 hours 0 mins 0.0 secs"), not an interval type. With one argument, returns the difference between the current timestamp at midnight UTC and the specified timestamp. With two arguments, returns the difference between *timestamp1* and *timestamp2* | b o p r s | TRANSLATE(expr, fromString, toString) | Returns *expr* with all occurrences of each character in *fromString* replaced by its corresponding character in *toString*. Characters in *expr* that are not in *fromString* are not replaced | b | TRUNC(numeric1 [, integer2 ]) | Truncates *numeric1* to optionally *integer2* (if not specified 0) places right to the decimal point | q | TRY_CAST(value AS type) | Converts *value* to *type*, returning NULL if conversion fails diff --git a/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java b/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java index 90839a1daa7..c2202d85bd9 100644 --- a/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java +++ b/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java @@ -6948,6 +6948,114 @@ void checkRegexpExtract(SqlOperatorFixture f0, FunctionAlias functionAlias) { f.checkNull("json_storage_size(cast(null as varchar))"); } + /** Test case for + * [CALCITE-7337] + * Add age function (enabled in PostgreSQL library). */ + @Test void testAgePg() { + final SqlOperatorFixture f0 = fixture(); + f0.checkFails("^age(timestamp '2023-12-25', timestamp '2020-01-01')^", + "No match found for function signature AGE\\(, \\)", false); + + final SqlOperatorFixture f = f0.withLibrary(SqlLibrary.POSTGRESQL); + + // Test illegal timestamp argument + f.checkFails("age(^timestamp 'abc'^, timestamp '2023-12-25')", + "Illegal TIMESTAMP literal 'abc': not in format 'yyyy-MM-dd HH:mm:ss'", + false); + f.checkFails("age(timestamp '2023-12-25', ^timestamp 'invalid-date'^)", + "Illegal TIMESTAMP literal 'invalid-date': not in format 'yyyy-MM-dd HH:mm:ss'", + false); + f.checkFails("age(^timestamp '2023-12-25 25:61:61'^, timestamp '2023-12-25')", + "Illegal TIMESTAMP literal '2023-12-25 25:61:61': not in format 'yyyy-MM-dd HH:mm:ss'", + false); + f.checkFails("age(^timestamp '2023-02-30'^, timestamp '2023-12-25')", + "Illegal TIMESTAMP literal '2023-02-30': not in format 'yyyy-MM-dd HH:mm:ss'", + false); + f.checkFails("age(^timestamp ''^)", + "Illegal TIMESTAMP literal '': not in format 'yyyy-MM-dd HH:mm:ss'", + false); + f.checkFails("age(^timestamp '2023-13-25 12:00:00'^)", + "Illegal TIMESTAMP literal '2023-13-25 12:00:00': not in format 'yyyy-MM-dd HH:mm:ss'", + false); + + // Test two timestamp arguments + f.checkScalar("age(timestamp '2023-12-25', timestamp '2020-01-01')", + "3 years 11 mons 24 days", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '2023-01-01', timestamp '2023-01-01')", + "00:00:00", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '2020-01-01', timestamp '2023-12-25')", + "-3 years -11 mons -24 days", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '2023-02-01', timestamp '2023-01-31')", + "1 day", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '2023-12-26 14:30:00', timestamp '2023-12-25 14:30:00')", + "1 day", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '2023-12-25 00:00:00', timestamp '2020-01-01 23:59:59')", + "3 years 11 mons 23 days 00:00:01", + "VARCHAR NOT NULL"); + + // Test single timestamp argument (relative to current time) + f.checkType("age(timestamp '2023-12-25')", "VARCHAR NOT NULL"); + + // NULL value tests + f.checkNull("age(null, timestamp '2023-12-25')"); + f.checkNull("age(timestamp '2023-12-25', null)"); + f.checkNull("age(null, null)"); + f.checkNull("age(null)"); + + // Boundary date tests (Unix epoch time) + f.checkScalar("age(timestamp '1970-01-01', timestamp '1970-01-01')", + "00:00:00", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '1970-01-02', timestamp '1970-01-01')", + "1 day", + "VARCHAR NOT NULL"); + + // Time boundary tests (start and end of day) + f.checkScalar("age(timestamp '2023-12-25 23:59:59', timestamp '2023-12-25 00:00:00')", + "23:59:59", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '2023-12-26 00:00:00', timestamp '2023-12-25 23:59:59')", + "00:00:01", + "VARCHAR NOT NULL"); + + // Leap year tests + f.checkScalar("age(timestamp '2024-02-29', timestamp '2023-02-28')", + "1 year 1 day", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '2024-03-01', timestamp '2023-02-28')", + "1 year 2 days", + "VARCHAR NOT NULL"); + + // Month boundary tests (across months) + f.checkScalar("age(timestamp '2023-03-01', timestamp '2023-02-01')", + "1 mon", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '2023-03-31', timestamp '2023-02-28')", + "1 mon 3 days", + "VARCHAR NOT NULL"); + + // Year boundary tests + f.checkScalar("age(timestamp '2024-01-01', timestamp '2023-01-01')", + "1 year", + "VARCHAR NOT NULL"); + f.checkScalar("age(timestamp '2024-01-01', timestamp '2023-12-31')", + "1 day", + "VARCHAR NOT NULL"); + + // Actual execution test for single parameter version + f.checkType("age(timestamp '1970-01-01')", "VARCHAR NOT NULL"); + + // Millisecond precision tests + f.checkScalar("age(timestamp '2023-12-25 12:00:00.500', timestamp '2023-12-25 12:00:00.000')", + "00:00:00.5", + "VARCHAR NOT NULL"); + } + @Test void testJsonType() { final SqlOperatorFixture f = fixture(); f.setFor(SqlLibraryOperators.JSON_TYPE, VmName.EXPAND);