TimestampDataType.java

/*
 *
 * The DbUnit Database Testing Framework
 * Copyright (C)2002-2004, DbUnit.org
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 *
 */

package org.dbunit.dataset.datatype;

import java.math.BigInteger;
import java.sql.ParameterMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.Calendar;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.dbunit.dataset.ITable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * {@link DataType} mapping the SQL TIMESTAMP type to {@link java.sql.Timestamp}.
 *
 * @author Manuel Laflamme
 * @author Last changed by: $Author$
 * @version $Revision$ $Date$
 * @since 1.0 (Feb 19, 2002)
 */
public class TimestampDataType extends AbstractDataType
{
    private static final BigInteger ONE_BILLION = new BigInteger("1000000000");
    private static final Pattern TIMEZONE_REGEX =
            Pattern.compile("(.*)(?:\\W([+-][0-2][0-9][0-5][0-9]))");

    /**
     * Logger for this class
     */
    private static final Logger logger =
            LoggerFactory.getLogger(TimestampDataType.class);

    TimestampDataType()
    {
        super("TIMESTAMP", Types.TIMESTAMP, Timestamp.class, false);
    }

    ////////////////////////////////////////////////////////////////////////////
    // DataType class

    @Override
    public Object typeCast(final Object value) throws TypeCastException
    {
        logger.debug("typeCast(value={}) - start", value);

        if (value == null || value == ITable.NO_VALUE)
        {
            return null;
        }

        if (value instanceof java.sql.Timestamp)
        {
            return value;
        }

        if (value instanceof java.util.Date)
        {
            final java.util.Date date = (java.util.Date) value;
            return new java.sql.Timestamp(date.getTime());
        }

        if (value instanceof Long)
        {
            final Long date = (Long) value;
            return new java.sql.Timestamp(date);
        }

        if (value instanceof String)
        {
            String stringValue = value.toString();

            if (isExtendedSyntax(stringValue))
            {
                // Relative date.
                try
                {
                    final LocalDateTime datetime =
                            RELATIVE_DATE_TIME_PARSER.parse(stringValue);
                    return java.sql.Timestamp.valueOf(datetime);
                } catch (IllegalArgumentException | DateTimeParseException e)
                {
                    throw new TypeCastException(value, this, e);
                }
            }

            String zoneValue = null;

            if (couldHaveTimezoneSuffix(stringValue))
            {
                final Matcher tzMatcher = TIMEZONE_REGEX.matcher(stringValue);
                if (tzMatcher.matches() && tzMatcher.group(2) != null)
                {
                    stringValue = tzMatcher.group(1);
                    zoneValue = tzMatcher.group(2);
                }
            }

            Timestamp ts = null;
            if (stringValue.length() == 10)
            {
                try
                {
                    final long time =
                            java.sql.Date.valueOf(stringValue).getTime();
                    ts = new java.sql.Timestamp(time);
                } catch (final IllegalArgumentException e)
                {
                    // Was not a java.sql.Date, let Timestamp handle this value
                }
            }
            if (ts == null)
            {
                try
                {
                    ts = java.sql.Timestamp.valueOf(stringValue);
                } catch (final IllegalArgumentException e)
                {
                    throw new TypeCastException(value, this, e);
                }
            }

            // Apply zone if any
            if (zoneValue != null)
            {
                final long tsTime = ts.getTime();

                final TimeZone localTZ = java.util.TimeZone.getDefault();
                final int offset = localTZ.getOffset(tsTime);
                final BigInteger localTZOffset = BigInteger.valueOf(offset);
                BigInteger time = BigInteger
                        .valueOf(Math.floorDiv(tsTime, 1000L) * 1000L)
                        .add(localTZOffset).multiply(ONE_BILLION)
                        .add(BigInteger.valueOf(ts.getNanos()));
                final int hours = Integer.parseInt(zoneValue.substring(1, 3));
                final int minutes = Integer.parseInt(zoneValue.substring(3, 5));
                final BigInteger offsetAsSeconds =
                        BigInteger.valueOf((hours * 3600) + (minutes * 60));
                final BigInteger offsetAsNanos =
                        offsetAsSeconds.multiply(BigInteger.valueOf(1000))
                                .multiply(ONE_BILLION);
                if (zoneValue.charAt(0) == '+')
                {
                    time = time.subtract(offsetAsNanos);
                } else
                {
                    time = time.add(offsetAsNanos);
                }
                final BigInteger[] components =
                        time.divideAndRemainder(ONE_BILLION);
                BigInteger millis = components[0];
                BigInteger nanos = components[1];
                if (nanos.signum() < 0)
                {
                    // BigInteger division truncates toward zero, so a
                    // negative time produces a negative remainder here;
                    // normalize to floor-mod so setNanos below never sees
                    // a negative value.
                    millis = millis.subtract(BigInteger.ONE);
                    nanos = nanos.add(ONE_BILLION);
                }
                ts = new Timestamp(millis.longValue());
                ts.setNanos(nanos.intValue());
            }

            return ts;
        }

        throw new TypeCastException(value, this);
    }

    /**
     * Cheaply rules out strings that {@link #TIMEZONE_REGEX} can never match, so
     * the majority of plain timestamp values never pay for constructing a
     * {@link Matcher} and running the greedy {@code (.*)}-leading pattern.
     * <p>
     * The regex requires its final five characters to be a sign character
     * ({@code +} or {@code -}) followed by four digits. A string that is too
     * short, or whose character at that position is not a sign, provably cannot
     * match, so the full match can be skipped.
     * @param value The candidate string.
     * @return {@code true} if {@code value} is long enough and has a sign
     *         character at the position the regex requires, meaning a full match
     *         attempt is worthwhile.
     */
    private static boolean couldHaveTimezoneSuffix(final String value)
    {
        final int length = value.length();
        if (length < 6)
        {
            return false;
        }
        final char signChar = value.charAt(length - 5);
        return signChar == '+' || signChar == '-';
    }

    @Override
    public boolean isDateTime()
    {
        logger.debug("isDateTime() - start");

        return true;
    }

    @Override
    public Object getSqlValue(final int column, final ResultSet resultSet)
            throws SQLException, TypeCastException
    {
        logger.debug("getSqlValue(column={}, resultSet={}) - start", column,
                resultSet);

        final Timestamp rawValue = resultSet.getTimestamp(column);
        final Timestamp value = resultSet.wasNull() ? null : rawValue;
        logger.debug("getSqlValue: column={}, value={}", column, value);
        return value;
    }

    @Override
    public void setSqlValue(final Object value, final int column,
            final PreparedStatement statement)
            throws SQLException, TypeCastException
    {
        if (logger.isDebugEnabled())
        {
            logger.debug(
                    "setSqlValue(value={}, column={}, statement={}) - start",
                    value, column, statement);
        }
        final Timestamp ts = (Timestamp) typeCast(value);
        if (value instanceof String)
        {
            final String stringValue = (String) value;
            setSqlValueFromString(stringValue, column, statement, ts);
        } else
        {
            statement.setTimestamp(column, ts);
        }
    }

    private void setSqlValueFromString(final String value, final int column,
            final PreparedStatement statement, final Timestamp ts)
            throws SQLException
    {
        final Matcher timezoneMatcher = TIMEZONE_REGEX.matcher(value);
        if (timezoneMatcher.matches() && timezoneMatcher.group(2) != null)
        {
            final Calendar cal = makeCalendar(timezoneMatcher);
            if (isTimestampWithTimeZoneColumn(column, statement))
            {
                final ZoneId zoneId = cal.getTimeZone().toZoneId();
                final OffsetDateTime offsetDateTime =
                        OffsetDateTime.ofInstant(ts.toInstant(), zoneId);
                statement.setObject(column, offsetDateTime);
            } else
            {
                statement.setTimestamp(column, ts, cal);
            }
        } else
        {
            statement.setTimestamp(column, ts);
        }
    }

    /**
     * Determines whether a prepared-statement parameter targets a column of
     * SQL type {@code TIMESTAMP WITH TIME ZONE}.
     * <p>
     * {@link PreparedStatement#setTimestamp(int, Timestamp, Calendar)} only
     * changes the wall-clock digits sent to the database. Some drivers write
     * those digits into a time-zone-aware column tagged with the JVM's
     * default time zone rather than the given calendar's zone, which
     * silently discards the dataset's own time zone. Columns of this type
     * must instead be written with
     * {@link PreparedStatement#setObject(int, Object)} using an
     * {@link OffsetDateTime}, which drivers supporting this SQL type map
     * unambiguously.
     * @param column The one-based parameter index.
     * @param statement The statement the parameter belongs to.
     * @return {@code true} if the parameter's declared SQL type is
     *         {@link Types#TIMESTAMP_WITH_TIMEZONE}, {@code false} if it is
     *         not, or if that could not be determined.
     */
    private boolean isTimestampWithTimeZoneColumn(final int column,
            final PreparedStatement statement)
    {
        try
        {
            final ParameterMetaData parameterMetaData =
                    statement.getParameterMetaData();
            final int parameterType =
                    parameterMetaData.getParameterType(column);
            return parameterType == Types.TIMESTAMP_WITH_TIMEZONE;
        } catch (final Exception e)
        {
            // Driver support for ParameterMetaData is inconsistent; treat any
            // failure as "unknown" and fall back to the safe, existing
            // behavior instead of failing the whole operation.
            logger.debug(
                    "Could not determine parameter type for column {}, assuming no timezone.",
                    column, e);
            return false;
        }
    }

    private Calendar makeCalendar(final Matcher timezoneMatcher)
    {
        final String zoneValue = timezoneMatcher.group(2);
        final String sign = zoneValue.substring(0, 1);
        final int hours = Integer.parseInt(zoneValue.substring(1, 3));
        final int minutes = Integer.parseInt(zoneValue.substring(3, 5));
        final String timezoneId =
                String.format("GMT%s%02d:%02d", sign, hours, minutes);
        final TimeZone timeZone = TimeZone.getTimeZone(timezoneId);
        final Calendar cal = Calendar.getInstance(timeZone);
        return cal;
    }
}