Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions src/main/java/net/sf/jsqlparser/util/TemporalExpressionInfo.java
Original file line number Diff line number Diff line change
@@ -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<TemporalExpressionInfo> 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<TemporalExpressionInfo> 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<TemporalExpressionInfo> 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<TemporalExpressionInfo> 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;
}
}
34 changes: 34 additions & 0 deletions src/site/sphinx/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
149 changes: 149 additions & 0 deletions src/test/java/net/sf/jsqlparser/util/TemporalExpressionInfoTest.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading
Loading