diff --git a/src/main/java/net/sf/jsqlparser/util/TemporalExpressionInfo.java b/src/main/java/net/sf/jsqlparser/util/TemporalExpressionInfo.java new file mode 100644 index 0000000000..a14ffc1422 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/util/TemporalExpressionInfo.java @@ -0,0 +1,190 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.util; + +import java.math.BigInteger; +import java.util.Locale; +import java.util.Optional; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.TimeKeyExpression; +import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.schema.Column; + +/** + * Read-only metadata for current date/time expressions in PostgreSQL and MySQL. The parser's + * existing TimeKeyExpression, Function and Column nodes are retained. Results are snapshots; + * inspect the expression again after editing its AST. This is not a database range validator. + */ +public final class TemporalExpressionInfo { + + public enum Kind { + CURRENT_DATE, CURRENT_TIME, CURRENT_TIMESTAMP, LOCAL_TIME, LOCAL_TIMESTAMP + } + + private final Kind kind; + private final String name; + private final Integer precision; + private final boolean parentheses; + + private TemporalExpressionInfo(Kind kind, String name, Integer precision, boolean parentheses) { + this.kind = kind; + this.name = name; + this.precision = precision; + this.parentheses = parentheses; + } + + public Kind getKind() { + return kind; + } + + /** Returns the original keyword or function name, without argument parentheses. */ + public String getName() { + return name; + } + + /** + * Returns the explicitly requested fractional precision, or null when omitted. Zero is distinct + * from omission. Database defaults, range checks and precision clamping are not applied. + */ + public Integer getPrecision() { + return precision; + } + + public boolean hasParentheses() { + return parentheses; + } + + /** + * Recognizes standard current-time spellings and MySQL NOW/CURDATE/CURTIME aliases. MySQL + * LOCALTIME and LOCALTIMESTAMP normalize to CURRENT_TIMESTAMP; PostgreSQL retains their local + * time/time-stamp distinction. Quoted/qualified names, ordinary functions, unsupported dialects + * and expressions outside the supported call shapes return an empty result. + */ + public static Optional from(Expression source, Dialect dialect) { + if (dialect != Dialect.POSTGRESQL && dialect != Dialect.MYSQL) { + return Optional.empty(); + } + Expression expression = unwrap(source); + if (expression instanceof TimeKeyExpression) { + return fromKeyword(((TimeKeyExpression) expression).getStringValue(), dialect); + } + if (expression instanceof Column) { + Column column = (Column) expression; + if (column.getTable() != null && column.getTable().getName() != null) { + return Optional.empty(); + } + return create(column.getColumnName(), null, false, dialect); + } + if (expression instanceof Function) { + return fromFunction((Function) expression, dialect); + } + return Optional.empty(); + } + + private static Expression unwrap(Expression source) { + Expression expression = source; + while (expression instanceof ParenthesedExpressionList + && ((ParenthesedExpressionList) expression).size() == 1) { + expression = ((ParenthesedExpressionList) expression).get(0); + } + return expression; + } + + private static Optional fromKeyword(String name, Dialect dialect) { + boolean parentheses = name != null && name.endsWith("()"); + String keyword = parentheses ? name.substring(0, name.length() - 2) : name; + return create(keyword, null, parentheses, dialect); + } + + private static Optional fromFunction(Function function, + Dialect dialect) { + if (!isPlainCall(function)) { + return Optional.empty(); + } + if (function.getParameters() == null || function.getParameters().isEmpty()) { + return create(function.getName(), null, true, dialect); + } + if (function.getParameters().size() != 1 + || !(function.getParameters().get(0) instanceof LongValue)) { + return Optional.empty(); + } + BigInteger value = ((LongValue) function.getParameters().get(0)).getBigIntegerValue(); + if (value.signum() < 0 || value.bitLength() >= Integer.SIZE) { + return Optional.empty(); + } + return create(function.getName(), value.intValue(), true, dialect); + } + + private static Optional create(String name, Integer precision, + boolean parentheses, Dialect dialect) { + if (name == null) { + return Optional.empty(); + } + String normalized = name.toUpperCase(Locale.ROOT); + Kind kind = kind(normalized, dialect); + if (kind == null || !validCallShape(normalized, kind, precision, parentheses, dialect)) { + return Optional.empty(); + } + return Optional.of(new TemporalExpressionInfo(kind, name, precision, parentheses)); + } + + private static boolean isPlainCall(Function function) { + return function.getClass() == Function.class + && function.getMultipartName() != null && function.getMultipartName().size() == 1 + && !function.isEscaped() && !function.isAllColumns() && !function.isDistinct() + && !function.isUnique() && function.getNamedParameters() == null + && function.getChainedParameters() == null && function.getAttribute() == null + && function.getHavingClause() == null && function.getOrderByElements() == null + && function.getNullHandling() == null && function.getLimit() == null + && function.getKeep() == null && function.getOnOverflowTruncate() == null + && function.getExtraKeyword() == null && function.getKeywordArguments() == null; + } + + private static Kind kind(String name, Dialect dialect) { + switch (name) { + case "CURRENT_DATE": + return Kind.CURRENT_DATE; + case "CURRENT_TIME": + return Kind.CURRENT_TIME; + case "CURRENT_TIMESTAMP": + case "NOW": + return Kind.CURRENT_TIMESTAMP; + case "LOCALTIME": + return dialect == Dialect.POSTGRESQL ? Kind.LOCAL_TIME : Kind.CURRENT_TIMESTAMP; + case "LOCALTIMESTAMP": + return dialect == Dialect.POSTGRESQL ? Kind.LOCAL_TIMESTAMP + : Kind.CURRENT_TIMESTAMP; + case "CURDATE": + return dialect == Dialect.MYSQL ? Kind.CURRENT_DATE : null; + case "CURTIME": + return dialect == Dialect.MYSQL ? Kind.CURRENT_TIME : null; + default: + return null; + } + } + + private static boolean validCallShape(String name, Kind kind, Integer precision, + boolean parentheses, Dialect dialect) { + if (("NOW".equals(name) || "CURDATE".equals(name) || "CURTIME".equals(name)) + && !parentheses) { + return false; + } + if (kind == Kind.CURRENT_DATE && precision != null) { + return false; + } + if (dialect == Dialect.POSTGRESQL) { + return "NOW".equals(name) ? precision == null : parentheses == (precision != null); + } + return true; + } +} diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 2e70489dec..155b532ace 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1212,3 +1212,37 @@ sequences, types or functions as tables. Statement visitors visit comment literals, and custom SQL deparsers can replace the explicit relation or literal. Feature analysis reports a schema modification. Validation exposes a separate ``commentOn...`` capability for each additional target kind. + +Current date/time expression metadata +===================================== + +``TemporalExpressionInfo.from(expression, dialect)`` provides a common read-only +view of MySQL and PostgreSQL current-date/time expressions. It recognizes the +existing ``TimeKeyExpression``, ``Function`` and ``Column`` representations without +replacing nodes or changing their SQL rendering: + +.. code-block:: java + + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse( + "SELECT CURRENT_TIMESTAMP(6)", p -> p.withDialect(Dialect.POSTGRESQL)); + TemporalExpressionInfo info = TemporalExpressionInfo.from( + select.getSelectItem(0).getExpression(), Dialect.POSTGRESQL).orElseThrow(); + // info.getKind() == TemporalExpressionInfo.Kind.CURRENT_TIMESTAMP + // info.getPrecision() == 6 + +Import ``TemporalExpressionInfo`` from ``net.sf.jsqlparser.util``. Precision is +``null`` when omitted, and explicit zero is preserved. ``getName()`` retains the +original keyword or function name; ``hasParentheses()`` distinguishes bare and +call forms. Results are snapshots: call ``from`` again after editing an AST. + +The dialect is significant. MySQL ``LOCALTIME`` and ``LOCALTIMESTAMP`` are aliases +of ``CURRENT_TIMESTAMP``; PostgreSQL exposes ``LOCAL_TIME`` and +``LOCAL_TIMESTAMP`` separately. MySQL ``NOW``, ``CURDATE`` and ``CURTIME`` aliases +are recognized in their function-call forms. PostgreSQL ``now()`` is recognized +without precision arguments. Quoted or qualified identifiers, unrelated +expressions and unsupported dialects return ``Optional.empty()``. + +This API identifies expression metadata rather than performing database +validation. Precision reports the requested value, without applying defaults, +server range checks or clamping. For example, PostgreSQL accepts precision 7 with +a warning and clamps it to 6, whereas MySQL rejects it. diff --git a/src/test/java/net/sf/jsqlparser/util/TemporalExpressionInfoTest.java b/src/test/java/net/sf/jsqlparser/util/TemporalExpressionInfoTest.java new file mode 100644 index 0000000000..28395849be --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/TemporalExpressionInfoTest.java @@ -0,0 +1,149 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.TimeKeyExpression; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.util.TemporalExpressionInfo.Kind; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvFileSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class TemporalExpressionInfoTest { + + // Accepted forms from an execution matrix on MySQL 8.4.11 and PostgreSQL 18.6. + @ParameterizedTest + @CsvFileSource(resources = "temporal-expression-cases.tsv", delimiter = '\t') + void recognizesServerAcceptedFormsWithoutChangingTheirAst(Dialect dialect, String expression, + Kind kind, Integer precision) throws JSQLParserException { + PlainSelect select = parse(expression, dialect); + Expression node = select.getSelectItem(0).getExpression(); + String original = select.toString(); + TemporalExpressionInfo info = TemporalExpressionInfo.from(node, dialect).orElseThrow(); + assertEquals(kind, info.getKind()); + assertEquals(precision, info.getPrecision()); + assertEquals(expression.contains("("), info.hasParentheses()); + assertEquals(expression.split("\\(")[0], info.getName()); + assertEquals(original, select.toString()); + + StringBuilder visitor = new StringBuilder(); + select.accept(new StatementDeParser(visitor)); + for (String sql : List.of(select.toString(), visitor.toString())) { + PlainSelect reparsed = (PlainSelect) CCJSqlParserUtil.parse(sql, + p -> p.withDialect(dialect)); + Expression again = reparsed.getSelectItem(0).getExpression(); + assertEquals(node.getClass(), again.getClass()); + TemporalExpressionInfo after = + TemporalExpressionInfo.from(again, dialect).orElseThrow(); + assertEquals(kind, after.getKind()); + assertEquals(precision, after.getPrecision()); + assertEquals(info.hasParentheses(), after.hasParentheses()); + } + } + + @Test + void preservesLegacyNodeClassesAndNormalizesTheirMetadata() throws JSQLParserException { + assertEquals(TimeKeyExpression.class, + node("CURRENT_TIMESTAMP", Dialect.POSTGRESQL).getClass()); + assertEquals(Function.class, + node("CURRENT_TIMESTAMP(6)", Dialect.POSTGRESQL).getClass()); + assertEquals(Column.class, node("LOCALTIME", Dialect.POSTGRESQL).getClass()); + assertEquals(Kind.LOCAL_TIME, info("LOCALTIME", Dialect.POSTGRESQL).getKind()); + assertEquals(Kind.CURRENT_TIMESTAMP, info("LOCALTIME", Dialect.MYSQL).getKind()); + } + + @ParameterizedTest + @EnumSource(value = Dialect.class, names = {"MYSQL", "POSTGRESQL"}) + void distinguishesOmittedAndZeroPrecisionAndUnwrapsGrouping(Dialect dialect) + throws JSQLParserException { + assertNull(info("CURRENT_TIMESTAMP", dialect).getPrecision()); + assertEquals(0, info("CURRENT_TIMESTAMP(0)", dialect).getPrecision()); + assertEquals(6, info("((current_timestamp(6)))", dialect).getPrecision()); + assertEquals("current_timestamp", info("((current_timestamp(6)))", dialect).getName()); + } + + @ParameterizedTest + @ValueSource(strings = {"\"LOCALTIME\"", "t.localtime", "t.current_timestamp", + "app.now()", "\"now\"()", "'CURRENT_TIMESTAMP'", "CURRENT_TIMEZONE", + "CURRENT_TIMESTAMP + INTERVAL '1' HOUR", "COALESCE(CURRENT_TIMESTAMP, NULL)", + "CURRENT_TIMESTAMP()", "CURRENT_DATE(1)", "CURRENT_TIMESTAMP('6')", + "CURRENT_TIMESTAMP(1, 2)", "CURRENT_TIMESTAMP(-1)", "NOW(6)", "CURDATE()"}) + void doesNotClassifyUnrelatedOrUnsupportedPostgreSqlExpressions(String expression) + throws JSQLParserException { + assertTrue(TemporalExpressionInfo.from(node(expression, Dialect.POSTGRESQL), + Dialect.POSTGRESQL).isEmpty(), expression); + } + + @ParameterizedTest + @ValueSource(strings = {"`LOCALTIME`", "\"LOCALTIME\"", "t.localtime", "app.now()", + "'NOW()'", "CURRENT_DATE(6)", "CURRENT_TIMESTAMP(1 + 1)", + "CURRENT_TIMESTAMP(1.5)", "CURRENT_TIMESTAMP(?)", "CURDATE(1)"}) + void doesNotClassifyUnrelatedOrUnsupportedMySqlExpressions(String expression) + throws JSQLParserException { + assertTrue(TemporalExpressionInfo.from(node(expression, Dialect.MYSQL), + Dialect.MYSQL).isEmpty(), expression); + } + + @Test + void recognizesRequestedPrecisionWithoutApplyingDatabaseRangeRules() + throws JSQLParserException { + // PostgreSQL accepts 7 with a warning and clamps to 6; MySQL rejects 7. Metadata retains + // the requested value, rather than pretending to perform execution-time validation. + assertEquals(7, info("CURRENT_TIMESTAMP(7)", Dialect.POSTGRESQL).getPrecision()); + assertEquals(7, info("CURRENT_TIMESTAMP(7)", Dialect.MYSQL).getPrecision()); + } + + @Test + void returnsSnapshotsAndIgnoresFunctionModifiers() { + Function function = new Function("CURRENT_TIMESTAMP", new LongValue(6)); + TemporalExpressionInfo before = TemporalExpressionInfo.from(function, Dialect.MYSQL) + .orElseThrow(); + ((LongValue) function.getParameters().get(0)).setValue(0); + assertEquals(6, before.getPrecision()); + assertEquals(0, TemporalExpressionInfo.from(function, Dialect.MYSQL) + .orElseThrow().getPrecision()); + function.setDistinct(true); + assertTrue(TemporalExpressionInfo.from(function, Dialect.MYSQL).isEmpty()); + assertTrue(TemporalExpressionInfo.from(new Column("NOW"), Dialect.POSTGRESQL).isEmpty()); + assertTrue(TemporalExpressionInfo.from(new Column("CURTIME"), Dialect.MYSQL).isEmpty()); + assertTrue(TemporalExpressionInfo.from(null, Dialect.MYSQL).isEmpty()); + assertTrue(TemporalExpressionInfo.from(function, Dialect.ORACLE).isEmpty()); + } + + private static TemporalExpressionInfo info(String expression, Dialect dialect) + throws JSQLParserException { + return TemporalExpressionInfo.from(node(expression, dialect), dialect).orElseThrow(); + } + + private static Expression node(String expression, Dialect dialect) throws JSQLParserException { + return parse(expression, dialect).getSelectItem(0).getExpression(); + } + + private static PlainSelect parse(String expression, Dialect dialect) + throws JSQLParserException { + return (PlainSelect) CCJSqlParserUtil.parse("SELECT " + expression, + p -> p.withDialect(dialect)); + } +} diff --git a/src/test/resources/net/sf/jsqlparser/util/temporal-expression-cases.tsv b/src/test/resources/net/sf/jsqlparser/util/temporal-expression-cases.tsv new file mode 100644 index 0000000000..6c00fb1f65 --- /dev/null +++ b/src/test/resources/net/sf/jsqlparser/util/temporal-expression-cases.tsv @@ -0,0 +1,43 @@ +MYSQL CURRENT_DATE CURRENT_DATE +MYSQL CURRENT_DATE() CURRENT_DATE +MYSQL CURRENT_TIME CURRENT_TIME +MYSQL CURRENT_TIME() CURRENT_TIME +MYSQL CURRENT_TIME(0) CURRENT_TIME 0 +MYSQL CURRENT_TIME(6) CURRENT_TIME 6 +MYSQL CURRENT_TIMESTAMP CURRENT_TIMESTAMP +MYSQL CURRENT_TIMESTAMP() CURRENT_TIMESTAMP +MYSQL CURRENT_TIMESTAMP(0) CURRENT_TIMESTAMP 0 +MYSQL CURRENT_TIMESTAMP(6) CURRENT_TIMESTAMP 6 +MYSQL LOCALTIME CURRENT_TIMESTAMP +MYSQL LOCALTIME() CURRENT_TIMESTAMP +MYSQL LOCALTIME(0) CURRENT_TIMESTAMP 0 +MYSQL LOCALTIME(6) CURRENT_TIMESTAMP 6 +MYSQL LOCALTIMESTAMP CURRENT_TIMESTAMP +MYSQL LOCALTIMESTAMP() CURRENT_TIMESTAMP +MYSQL LOCALTIMESTAMP(0) CURRENT_TIMESTAMP 0 +MYSQL LOCALTIMESTAMP(6) CURRENT_TIMESTAMP 6 +MYSQL NOW() CURRENT_TIMESTAMP +MYSQL NOW(0) CURRENT_TIMESTAMP 0 +MYSQL NOW(6) CURRENT_TIMESTAMP 6 +MYSQL CURDATE() CURRENT_DATE +MYSQL CURTIME() CURRENT_TIME +MYSQL CURTIME(0) CURRENT_TIME 0 +MYSQL CURTIME(6) CURRENT_TIME 6 +POSTGRESQL CURRENT_DATE CURRENT_DATE +POSTGRESQL CURRENT_TIME CURRENT_TIME +POSTGRESQL CURRENT_TIME(0) CURRENT_TIME 0 +POSTGRESQL CURRENT_TIME(6) CURRENT_TIME 6 +POSTGRESQL CURRENT_TIME(7) CURRENT_TIME 7 +POSTGRESQL CURRENT_TIMESTAMP CURRENT_TIMESTAMP +POSTGRESQL CURRENT_TIMESTAMP(0) CURRENT_TIMESTAMP 0 +POSTGRESQL CURRENT_TIMESTAMP(6) CURRENT_TIMESTAMP 6 +POSTGRESQL CURRENT_TIMESTAMP(7) CURRENT_TIMESTAMP 7 +POSTGRESQL LOCALTIME LOCAL_TIME +POSTGRESQL LOCALTIME(0) LOCAL_TIME 0 +POSTGRESQL LOCALTIME(6) LOCAL_TIME 6 +POSTGRESQL LOCALTIME(7) LOCAL_TIME 7 +POSTGRESQL LOCALTIMESTAMP LOCAL_TIMESTAMP +POSTGRESQL LOCALTIMESTAMP(0) LOCAL_TIMESTAMP 0 +POSTGRESQL LOCALTIMESTAMP(6) LOCAL_TIMESTAMP 6 +POSTGRESQL LOCALTIMESTAMP(7) LOCAL_TIMESTAMP 7 +POSTGRESQL NOW() CURRENT_TIMESTAMP