diff --git a/be/src/exprs/function/function_utility.cpp b/be/src/exprs/function/function_utility.cpp index 340f05ab0c4fd7..ab3c57c27cb1bf 100644 --- a/be/src/exprs/function/function_utility.cpp +++ b/be/src/exprs/function/function_utility.cpp @@ -20,6 +20,8 @@ // IWYU pragma: no_include #include #include // IWYU pragma: keep +#include +#include #include #include #include @@ -138,11 +140,109 @@ class FunctionVersion : public IFunction { } }; +class FunctionHumanReadableSeconds : public IFunction { +public: + static constexpr auto name = "human_readable_seconds"; + + static FunctionPtr create() { return std::make_shared(); } + + String get_name() const override { return name; } + + size_t get_number_of_arguments() const override { return 1; } + + DataTypePtr get_return_type_impl(const DataTypes& /*arguments*/) const override { + return std::make_shared(); + } + + Status execute_impl(FunctionContext* /*context*/, Block& block, const ColumnNumbers& arguments, + uint32_t result, size_t input_rows_count) const override { + const auto& argument_column = block.get_by_position(arguments[0]).column; + const auto* data_column = check_and_get_column(*argument_column); + if (data_column == nullptr) { + return Status::InvalidArgument("Illegal column {} of first argument of function {}", + argument_column->get_name(), name); + } + + auto result_column = ColumnString::create(); + result_column->reserve(input_rows_count); + std::string buffer; + for (size_t i = 0; i < input_rows_count; ++i) { + double value = data_column->get_element(i); + if (std::isnan(value) || std::isinf(value)) { + return Status::InvalidArgument("Invalid argument value {} for function {}", value, + name); + } + buffer.clear(); + to_human_readable(value, buffer); + result_column->insert_data(buffer.data(), buffer.size()); + } + + block.replace_by_position(result, std::move(result_column)); + return Status::OK(); + } + +private: + static void append_unit(std::string& out, int64_t value, const char* singular, + const char* plural) { + if (!out.empty()) { + out += ", "; + } + out += std::to_string(value); + out += ' '; + out += (value == 1 ? singular : plural); + } + + static void to_human_readable(double seconds, std::string& out) { + // Match Presto/Trino: round to whole seconds and ignore the sign. + // Saturate at int64_t max for very large finite inputs. This both matches the + // FE constant-folding path (Java Math.round saturates to Long.MAX_VALUE) and + // avoids std::llround's domain error when the rounded value exceeds int64_t. + double abs_seconds = std::fabs(seconds); + int64_t remain; + if (abs_seconds >= static_cast(std::numeric_limits::max())) { + remain = std::numeric_limits::max(); + } else { + remain = std::llround(abs_seconds); + } + + constexpr int64_t WEEK = 7 * 24 * 60 * 60; + constexpr int64_t DAY = 24 * 60 * 60; + constexpr int64_t HOUR = 60 * 60; + constexpr int64_t MINUTE = 60; + + const int64_t weeks = remain / WEEK; + remain %= WEEK; + const int64_t days = remain / DAY; + remain %= DAY; + const int64_t hours = remain / HOUR; + remain %= HOUR; + const int64_t minutes = remain / MINUTE; + const int64_t secs = remain % MINUTE; + + if (weeks > 0) { + append_unit(out, weeks, "week", "weeks"); + } + if (days > 0) { + append_unit(out, days, "day", "days"); + } + if (hours > 0) { + append_unit(out, hours, "hour", "hours"); + } + if (minutes > 0) { + append_unit(out, minutes, "minute", "minutes"); + } + if (secs > 0 || out.empty()) { + append_unit(out, secs, "second", "seconds"); + } + } +}; + const std::string FunctionVersion::version = "5.7.99"; void register_function_utility(SimpleFunctionFactory& factory) { factory.register_function(); factory.register_function(); + factory.register_function(); } } // namespace doris diff --git a/be/test/exprs/function/function_human_readable_seconds_test.cpp b/be/test/exprs/function/function_human_readable_seconds_test.cpp new file mode 100644 index 00000000000000..9960749dd41313 --- /dev/null +++ b/be/test/exprs/function/function_human_readable_seconds_test.cpp @@ -0,0 +1,56 @@ +// 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. + +#include + +#include "core/data_type/data_type_string.h" +#include "core/types.h" +#include "exprs/function/function_test_util.h" + +namespace doris { +using namespace ut_type; + +TEST(FunctionHumanReadableSecondsTest, one_arg) { + std::string func_name = "human_readable_seconds"; + + InputTypeSet input_types = {PrimitiveType::TYPE_DOUBLE}; + + // Presto/Trino semantics: round to whole seconds, ignore the sign, no milliseconds. + DataSet data_set = { + {{96.0}, std::string("1 minute, 36 seconds")}, + {{3762.0}, std::string("1 hour, 2 minutes, 42 seconds")}, + {{56363463.0}, std::string("93 weeks, 1 day, 8 hours, 31 minutes, 3 seconds")}, + {{0.0}, std::string("0 seconds")}, + {{0.4}, std::string("0 seconds")}, + {{0.9}, std::string("1 second")}, + {{1.2}, std::string("1 second")}, + {{1.5}, std::string("2 seconds")}, + {{61.0}, std::string("1 minute, 1 second")}, + {{3600.0}, std::string("1 hour")}, + {{604800.0}, std::string("1 week")}, + {{475.33}, std::string("7 minutes, 55 seconds")}, + // negative input uses absolute value (no leading '-') + {{-96.0}, std::string("1 minute, 36 seconds")}, + {{-0.5}, std::string("1 second")}, + {{1e20}, std::string("15250284452471 weeks, 3 days, 15 hours, 30 minutes, 7 seconds")}, + {{-1e20}, std::string("15250284452471 weeks, 3 days, 15 hours, 30 minutes, 7 seconds")}, + {{Null()}, Null()}}; + + check_function_all_arg_comb(func_name, input_types, data_set); +} + +} // namespace doris diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/BuiltinScalarFunctions.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/BuiltinScalarFunctions.java index 0883b93e5a8342..49628dd1c569d4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/BuiltinScalarFunctions.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/BuiltinScalarFunctions.java @@ -252,6 +252,7 @@ import org.apache.doris.nereids.trees.expressions.functions.scalar.HoursAdd; import org.apache.doris.nereids.trees.expressions.functions.scalar.HoursDiff; import org.apache.doris.nereids.trees.expressions.functions.scalar.HoursSub; +import org.apache.doris.nereids.trees.expressions.functions.scalar.HumanReadableSeconds; import org.apache.doris.nereids.trees.expressions.functions.scalar.If; import org.apache.doris.nereids.trees.expressions.functions.scalar.Ignore; import org.apache.doris.nereids.trees.expressions.functions.scalar.Initcap; @@ -823,6 +824,7 @@ public class BuiltinScalarFunctions implements FunctionHelper { scalar(HoursAdd.class, "hours_add"), scalar(HoursDiff.class, "hours_diff"), scalar(HoursSub.class, "hours_sub"), + scalar(HumanReadableSeconds.class, "human_readable_seconds"), scalar(If.class, "if"), scalar(Ignore.class, "ignore"), scalar(Initcap.class, "initcap"), diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/executable/DateTimeExtractAndTransform.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/executable/DateTimeExtractAndTransform.java index acbdfc255047e3..e46d1df1054293 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/executable/DateTimeExtractAndTransform.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/executable/DateTimeExtractAndTransform.java @@ -1340,6 +1340,61 @@ public static Expression secToTime(DoubleLiteral sec) { return new TimeV2Literal(sec.getValue() * 1000000); } + /** + * human_readable_seconds function for constant folding + */ + @ExecFunction(name = "human_readable_seconds") + public static Expression humanReadableSeconds(DoubleLiteral sec) { + return new VarcharLiteral(formatHumanReadableSeconds(sec.getValue())); + } + + private static String formatHumanReadableSeconds(double seconds) { + if (Double.isNaN(seconds) || Double.isInfinite(seconds)) { + throw new AnalysisException("Invalid argument value " + seconds + + " for function human_readable_seconds"); + } + + long remain = Math.round(Math.abs(seconds)); + final long week = 7L * 24 * 60 * 60; + final long day = 24L * 60 * 60; + final long hour = 60L * 60; + final long minute = 60L; + + long weeks = remain / week; + remain %= week; + long days = remain / day; + remain %= day; + long hours = remain / hour; + remain %= hour; + long minutes = remain / minute; + long secs = remain % minute; + + StringBuilder result = new StringBuilder(); + if (weeks > 0) { + appendUnit(result, weeks, "week", "weeks"); + } + if (days > 0) { + appendUnit(result, days, "day", "days"); + } + if (hours > 0) { + appendUnit(result, hours, "hour", "hours"); + } + if (minutes > 0) { + appendUnit(result, minutes, "minute", "minutes"); + } + if (secs > 0 || result.length() == 0) { + appendUnit(result, secs, "second", "seconds"); + } + return result.toString(); + } + + private static void appendUnit(StringBuilder out, long value, String singular, String plural) { + if (out.length() > 0) { + out.append(", "); + } + out.append(value).append(' ').append(value == 1 ? singular : plural); + } + /** * get_format function for constant folding */ diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/HumanReadableSeconds.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/HumanReadableSeconds.java new file mode 100644 index 00000000000000..e73b96ed4a202b --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/HumanReadableSeconds.java @@ -0,0 +1,68 @@ +// 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.doris.nereids.trees.expressions.functions.scalar; + +import org.apache.doris.catalog.FunctionSignature; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature; +import org.apache.doris.nereids.trees.expressions.functions.PropagateNullable; +import org.apache.doris.nereids.trees.expressions.shape.UnaryExpression; +import org.apache.doris.nereids.trees.expressions.visitor.ExpressionVisitor; +import org.apache.doris.nereids.types.DoubleType; +import org.apache.doris.nereids.types.VarcharType; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +import java.util.List; + +/** + * ScalarFunction 'human_readable_seconds'. + */ +public class HumanReadableSeconds extends ScalarFunction + implements UnaryExpression, ExplicitlyCastableSignature, PropagateNullable { + + public static final List SIGNATURES = ImmutableList.of( + FunctionSignature.ret(VarcharType.SYSTEM_DEFAULT).args(DoubleType.INSTANCE) + ); + + public HumanReadableSeconds(Expression arg) { + super("human_readable_seconds", arg); + } + + /** constructor for withChildren and reuse signature */ + private HumanReadableSeconds(ScalarFunctionParams functionParams) { + super(functionParams); + } + + @Override + public HumanReadableSeconds withChildren(List children) { + Preconditions.checkArgument(children.size() == 1); + return new HumanReadableSeconds(getFunctionParams(children)); + } + + @Override + public List getSignatures() { + return SIGNATURES; + } + + @Override + public R accept(ExpressionVisitor visitor, C context) { + return visitor.visitHumanReadableSeconds(this, context); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/visitor/ScalarFunctionVisitor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/visitor/ScalarFunctionVisitor.java index ce9deb2776dab1..1d3c2058f6b3b4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/visitor/ScalarFunctionVisitor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/visitor/ScalarFunctionVisitor.java @@ -271,6 +271,7 @@ import org.apache.doris.nereids.trees.expressions.functions.scalar.HoursAdd; import org.apache.doris.nereids.trees.expressions.functions.scalar.HoursDiff; import org.apache.doris.nereids.trees.expressions.functions.scalar.HoursSub; +import org.apache.doris.nereids.trees.expressions.functions.scalar.HumanReadableSeconds; import org.apache.doris.nereids.trees.expressions.functions.scalar.If; import org.apache.doris.nereids.trees.expressions.functions.scalar.Ignore; import org.apache.doris.nereids.trees.expressions.functions.scalar.Initcap; @@ -1292,6 +1293,10 @@ default R visitHoursSub(HoursSub hoursSub, C context) { return visitScalarFunction(hoursSub, context); } + default R visitHumanReadableSeconds(HumanReadableSeconds humanReadableSeconds, C context) { + return visitScalarFunction(humanReadableSeconds, context); + } + default R visitHourMinute(HourMinute hourMinute, C context) { return visitScalarFunction(hourMinute, context); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/executable/DateTimeExtractAndTransformTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/executable/DateTimeExtractAndTransformTest.java index ee3604f83dee51..d1bebdf2cc7eaf 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/executable/DateTimeExtractAndTransformTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/executable/DateTimeExtractAndTransformTest.java @@ -21,9 +21,11 @@ import org.apache.doris.nereids.trees.expressions.literal.BigIntLiteral; import org.apache.doris.nereids.trees.expressions.literal.DateTimeV2Literal; import org.apache.doris.nereids.trees.expressions.literal.DecimalV3Literal; +import org.apache.doris.nereids.trees.expressions.literal.DoubleLiteral; import org.apache.doris.nereids.trees.expressions.literal.SmallIntLiteral; import org.apache.doris.nereids.trees.expressions.literal.StringLiteral; import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral; +import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral; import org.apache.doris.nereids.types.DateTimeV2Type; import org.junit.jupiter.api.Assertions; @@ -166,4 +168,41 @@ public void testFromUnixTimeOutOfRangeThrows() { Assertions.assertThrows(AnalysisException.class, () -> DateTimeExtractAndTransform.fromUnixTime(dec)); } + + @Test + public void testHumanReadableSeconds() { + // Presto/Trino round to whole seconds, drop the sign, and never emit milliseconds. + Assertions.assertEquals(new VarcharLiteral("1 minute, 36 seconds"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(96.0))); + Assertions.assertEquals(new VarcharLiteral("1 hour, 2 minutes, 42 seconds"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(3762.0))); + Assertions.assertEquals(new VarcharLiteral("93 weeks, 1 day, 8 hours, 31 minutes, 3 seconds"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(56363463.0))); + Assertions.assertEquals(new VarcharLiteral("0 seconds"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(0.0))); + // fractional input is rounded, not rendered as milliseconds + Assertions.assertEquals(new VarcharLiteral("7 minutes, 55 seconds"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(475.33))); + Assertions.assertEquals(new VarcharLiteral("1 second"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(0.9))); + // negative input uses absolute value (no leading '-') + Assertions.assertEquals(new VarcharLiteral("1 minute, 36 seconds"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(-96.0))); + Assertions.assertEquals(new VarcharLiteral("1 second"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(-0.5))); + // very large finite inputs saturate at Long.MAX_VALUE (Math.round), and BE matches + Assertions.assertEquals( + new VarcharLiteral("15250284452471 weeks, 3 days, 15 hours, 30 minutes, 7 seconds"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(1e20))); + Assertions.assertEquals( + new VarcharLiteral("15250284452471 weeks, 3 days, 15 hours, 30 minutes, 7 seconds"), + DateTimeExtractAndTransform.humanReadableSeconds(new DoubleLiteral(-1e20))); + // NaN / Infinity are rejected + Assertions.assertThrows(AnalysisException.class, + () -> DateTimeExtractAndTransform.humanReadableSeconds( + new DoubleLiteral(Double.NaN))); + Assertions.assertThrows(AnalysisException.class, + () -> DateTimeExtractAndTransform.humanReadableSeconds( + new DoubleLiteral(Double.POSITIVE_INFINITY))); + } } diff --git a/regression-test/data/external_table_p0/dialect_compatible/sql/presto/scalar/timestamp/TestHumanReadableSeconds.out b/regression-test/data/external_table_p0/dialect_compatible/sql/presto/scalar/timestamp/TestHumanReadableSeconds.out index 420e711e026da7..2009c68c5c8439 100644 --- a/regression-test/data/external_table_p0/dialect_compatible/sql/presto/scalar/timestamp/TestHumanReadableSeconds.out +++ b/regression-test/data/external_table_p0/dialect_compatible/sql/presto/scalar/timestamp/TestHumanReadableSeconds.out @@ -9,5 +9,16 @@ 0 -- !TestHumanReadableSeconds_4 -- +6 days, 4 hours, 42 minutes, 14 seconds + +-- !TestHumanReadableSeconds_5 -- +6 days, 4 hours, 42 minutes, 13 seconds + +-- !TestHumanReadableSeconds_6 -- 0 +-- !TestHumanReadableSeconds_7 -- +6 days, 4 hours, 42 minutes, 14 seconds + +-- !TestHumanReadableSeconds_8 -- +6 days, 4 hours, 42 minutes, 13 seconds diff --git a/regression-test/data/query_p0/sql_functions/datetime_functions/test_human_readable_seconds.out b/regression-test/data/query_p0/sql_functions/datetime_functions/test_human_readable_seconds.out new file mode 100644 index 00000000000000..a6d5ebcd33a5a8 --- /dev/null +++ b/regression-test/data/query_p0/sql_functions/datetime_functions/test_human_readable_seconds.out @@ -0,0 +1,72 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !empty_nullable -- + +-- !empty_not_nullable -- + +-- !all_null -- +\N +\N +\N + +-- !nullable -- +1 minute, 36 seconds +1 hour, 2 minutes, 42 seconds +93 weeks, 1 day, 8 hours, 31 minutes, 3 seconds +0 seconds +1 minute, 36 seconds +1 second +1 second +1 minute, 1 second +1 week +\N + +-- !not_nullable -- +1 minute, 36 seconds +1 hour, 2 minutes, 42 seconds +93 weeks, 1 day, 8 hours, 31 minutes, 3 seconds +0 seconds +1 minute, 36 seconds +1 second +1 second +1 minute, 1 second +1 week +1 hour + +-- !nullable_no_null -- +1 minute, 36 seconds +1 hour, 2 minutes, 42 seconds +93 weeks, 1 day, 8 hours, 31 minutes, 3 seconds +0 seconds +1 minute, 36 seconds +1 second +1 second +1 minute, 1 second +1 week +1 hour + +-- !const_nullable -- +\N +\N +\N +\N +\N +\N +\N +\N +\N +\N + +-- !const_not_nullable -- +1 hour, 2 minutes, 42 seconds +1 hour, 2 minutes, 42 seconds +1 hour, 2 minutes, 42 seconds +1 hour, 2 minutes, 42 seconds +1 hour, 2 minutes, 42 seconds +1 hour, 2 minutes, 42 seconds +1 hour, 2 minutes, 42 seconds +1 hour, 2 minutes, 42 seconds +1 hour, 2 minutes, 42 seconds +1 hour, 2 minutes, 42 seconds + +-- !const_nullable_no_null -- +1 hour, 2 minutes, 42 seconds diff --git a/regression-test/suites/external_table_p0/dialect_compatible/sql/presto/scalar/timestamp/TestHumanReadableSeconds.sql b/regression-test/suites/external_table_p0/dialect_compatible/sql/presto/scalar/timestamp/TestHumanReadableSeconds.sql index 04dae5ee4e027f..b219a71145943a 100644 --- a/regression-test/suites/external_table_p0/dialect_compatible/sql/presto/scalar/timestamp/TestHumanReadableSeconds.sql +++ b/regression-test/suites/external_table_p0/dialect_compatible/sql/presto/scalar/timestamp/TestHumanReadableSeconds.sql @@ -3,6 +3,12 @@ set enable_fallback_to_original_planner=false; set debug_skip_fold_constant=false; -- SELECT human_readable_seconds(535333.9513888889); # error: errCode = 2, detailMessage = Can not found function 'human_readable_seconds' -- SELECT human_readable_seconds(535333.2513888889); # error: errCode = 2, detailMessage = Can not found function 'human_readable_seconds' +SELECT human_readable_seconds(535333.9513888889); +SELECT human_readable_seconds(535333.2513888889); + set debug_skip_fold_constant=true; -- SELECT human_readable_seconds(535333.9513888889); # error: errCode = 2, detailMessage = Can not found function 'human_readable_seconds' -- SELECT human_readable_seconds(535333.2513888889) # error: errCode = 2, detailMessage = Can not found function 'human_readable_seconds' + +SELECT human_readable_seconds(535333.9513888889); +SELECT human_readable_seconds(535333.2513888889); \ No newline at end of file diff --git a/regression-test/suites/query_p0/sql_functions/datetime_functions/test_human_readable_seconds.groovy b/regression-test/suites/query_p0/sql_functions/datetime_functions/test_human_readable_seconds.groovy new file mode 100644 index 00000000000000..545c86f0710894 --- /dev/null +++ b/regression-test/suites/query_p0/sql_functions/datetime_functions/test_human_readable_seconds.groovy @@ -0,0 +1,186 @@ +// 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. + +// human_readable_seconds follows Presto/Trino semantics: +// - rounds to whole seconds with round(abs(x)) (no fractional / millisecond output) +// - the sign is dropped (absolute value) +// - emits only weeks / days / hours / minutes / seconds +// - NaN / Infinity are rejected +// This suite checks three things: +// 1. vectorized BE execution on nullable / not-nullable / const columns (qt_ + .out), +// 2. FE constant folding produces exactly the same string as BE execution, +// 3. NaN / Infinity raise an error on both the FE-fold and BE-exec paths. +suite("test_human_readable_seconds") { + // ==================================================================== + // Part 1: vectorized BE execution + nullable / const column handling + // ==================================================================== + sql "drop table if exists test_human_readable_seconds" + sql """ + create table test_human_readable_seconds ( + k0 int, + a double not null, + b double null + ) + distributed by hash(k0) + properties + ( + "replication_num" = "1" + ); + """ + + qt_empty_nullable "select human_readable_seconds(b) from test_human_readable_seconds order by k0" + qt_empty_not_nullable "select human_readable_seconds(a) from test_human_readable_seconds order by k0" + + sql "insert into test_human_readable_seconds values (1, 1, null), (2, 1, null), (3, 1, null)" + qt_all_null "select human_readable_seconds(b) from test_human_readable_seconds order by k0" + + sql "truncate table test_human_readable_seconds" + sql """ + insert into test_human_readable_seconds values + (1, 96, 96), + (2, 3762, 3762), + (3, 56363463, 56363463), + (4, 0, 0), + (5, -96, -96), + (6, 0.9, 0.9), + (7, 1.2, 1.2), + (8, 61, 61), + (9, 604800, 604800), + (10, 3600, null); + """ + + qt_nullable "select human_readable_seconds(b) from test_human_readable_seconds order by k0" + qt_not_nullable "select human_readable_seconds(a) from test_human_readable_seconds order by k0" + qt_nullable_no_null "select human_readable_seconds(nullable(a)) from test_human_readable_seconds order by k0" + qt_const_nullable "select human_readable_seconds(NULL) from test_human_readable_seconds order by k0" + qt_const_not_nullable "select human_readable_seconds(3762) from test_human_readable_seconds order by k0" + qt_const_nullable_no_null "select human_readable_seconds(nullable(3762))" + + // ==================================================================== + // Part 2: golden correctness (BE execution) + FE/BE folding consistency. + // + // The list mixes integer and fractional literals on purpose: integer + // literals (e.g. 96, 3600, 604800) are coerced to DOUBLE by the function + // signature, so they still fold through humanReadableSeconds(DoubleLiteral). + // ==================================================================== + def goldenCases = [ + // zero / sub-second rounding + [0, "0 seconds"], + [0.4, "0 seconds"], + [0.5, "1 second"], + [0.9, "1 second"], + [1, "1 second"], + [1.5, "2 seconds"], + // minute boundary: round-half-up lands exactly on 60 + [59, "59 seconds"], + [59.4, "59 seconds"], + [59.5, "1 minute"], + [60, "1 minute"], + [60.4, "1 minute"], + [60.5, "1 minute, 1 second"], + [61, "1 minute, 1 second"], + [119, "1 minute, 59 seconds"], + [120, "2 minutes"], + // hour boundary + [3599, "59 minutes, 59 seconds"], + [3599.5, "1 hour"], + [3600, "1 hour"], + [3601, "1 hour, 1 second"], + [3660, "1 hour, 1 minute"], + [7199, "1 hour, 59 minutes, 59 seconds"], + [7200, "2 hours"], + // day boundary + [86399, "23 hours, 59 minutes, 59 seconds"], + [86400, "1 day"], + [90061, "1 day, 1 hour, 1 minute, 1 second"], + [172800, "2 days"], + // week boundary + [604799, "6 days, 23 hours, 59 minutes, 59 seconds"], + [604800, "1 week"], + [691200, "1 week, 1 day"], + [1209600, "2 weeks"], + // Presto/Trino documentation examples + [96, "1 minute, 36 seconds"], + [3762, "1 hour, 2 minutes, 42 seconds"], + [56363463, "93 weeks, 1 day, 8 hours, 31 minutes, 3 seconds"], + // negatives use absolute value (no leading '-') + [-0.5, "1 second"], + [-59.5, "1 minute"], + [-96, "1 minute, 36 seconds"], + [-3762, "1 hour, 2 minutes, 42 seconds"], + // large values + [1000000, "1 week, 4 days, 13 hours, 46 minutes, 40 seconds"], + [535333.9513888889, "6 days, 4 hours, 42 minutes, 14 seconds"], + [535333.2513888889, "6 days, 4 hours, 42 minutes, 13 seconds"], + [1234567890, "2041 weeks, 1 day, 23 hours, 31 minutes, 30 seconds"], + // out-of-range finite DOUBLE values saturate at int64_t max on both + // FE (Java Math.round -> Long.MAX_VALUE) and BE (explicit clamp). + // Passed as strings so they reach SQL as 1e20 / -1e20, not BigDecimal "1E+20". + ["1e20", "15250284452471 weeks, 3 days, 15 hours, 30 minutes, 7 seconds"], + ["-1e20", "15250284452471 weeks, 3 days, 15 hours, 30 minutes, 7 seconds"] + ] + + // Correctness: force BE to evaluate each constant and compare against the + // hand-verified Presto/Trino result. enable_sql_cache is disabled so a + // repeated query text cannot return a stale cached row. + sql "set enable_sql_cache=false" + sql "set debug_skip_fold_constant=true" + goldenCases.each { c -> + def actual = (sql "select human_readable_seconds(${c[0]})")[0][0] as String + assertEquals("human_readable_seconds(${c[0]})", c[1] as String, actual) + } + sql "set debug_skip_fold_constant=false" + + // ==================================================================== + // Part 3: FE constant folding must match BE execution for every golden + // value above plus a broad sweep around each unit boundary. testFoldConst + // runs the same query with folding on and off and asserts identical rows + // (it also disables enable_sql_cache internally), which is exactly the + // FE/BE consistency check and is cheap to extend to many more values. + // ==================================================================== + def consistencyValues = goldenCases.collect { it[0] } + for (int v = 0; v <= 130; v += 5) { consistencyValues.add(v) } // around minute + for (int v = 3590; v <= 3610; v += 2) { consistencyValues.add(v) } // around hour + for (int v = 86390; v <= 86410; v += 2) { consistencyValues.add(v) } // around day + for (int v = 604790; v <= 604810; v += 2) { consistencyValues.add(v) } // around week + [7199.5, -1, -60, -3600, -86400, 123456789, 535334].each { consistencyValues.add(it) } + + consistencyValues.each { v -> + testFoldConst("select human_readable_seconds(${v})") + } + + // ==================================================================== + // Part 4: NaN / Infinity are rejected on both the BE-exec and FE-fold paths. + // Both implementations raise an "Invalid argument value ..." error. + // ==================================================================== + ['true', 'false'].each { skip -> + sql "set debug_skip_fold_constant=${skip}" + test { + sql "select human_readable_seconds(cast('nan' as double))" + exception "Invalid argument value" + } + test { + sql "select human_readable_seconds(cast('inf' as double))" + exception "Invalid argument value" + } + test { + sql "select human_readable_seconds(-cast('inf' as double))" + exception "Invalid argument value" + } + } + sql "set debug_skip_fold_constant=false" +}