Freitag, 5. Oktober 2018

"ORA-01839: date not valid for month specified" when adding interval

Problem

You try to add month or years to a date using interval year to month data type. But

Cause

You probably are trying from a day in a month that does not exist in the resulting month, e. g. 30th of January plus one month -> 30th of February 😓. The problem is, that adding year month interval is like just changing the month: 30 : 1 + 0 : 1 => 30 : 2 i. e. 30th of February. There is no logic to handle this within year month interval data type.

Solution

You need to employ the add_months function for that. It can be a bit tricky if you have got interval data like you can easily extract from range partion by date of the data dictionary. I wrote a function the takes a date and the function string to create an interval (as it is stored in the data dictionary) NUMTOYMINTERVAL(1, 'MONTH') and returns according date. Be aware that it uses calls to a logger. You either need to install it as well or remove those calls.
/** Date calculations are not straight foreward such that adding a
 *  year-month interval to a date will probably result in a wrong result.
 *  Actually, you would be save until day 28, because each and every month
 *  has at least 28 days. Beyond that ...
 *  Examples with  + NUMTOYMINTERVAL('1', 'MONTH')
 *
 *  Finding           Expectation     Result
 *  ok          1999-01-15  1999-02-15      1999-02-15
 *  nok         1999-02-28  last of March   1999-03-28
 *  ok          1999-02-28  1999-03-28      1999-03-28
 *  nok         1999-01-29  1999-02-28      ORA-01839: date not valid for
 *                                          month specified
 *
 *
 *  @function ADD_MONTH_YEAR_INTERVAL
 *  @param in date I_DATE Base date on that the operation of the other
 *                        parameter is executed.
 *  @param in varchar2 I_NUMTOYMINTERVAL_STRING NumToYMInterval operation
 *                                              string, e. g.
 *                                              NUMTOYMINTERVAL('1', 'MONTH')
 *  @return date Result of the NumToYMInterval operation on the base date.
 *  @throws PKG_EXCEPTIONS.CG_ERR_N_UNSUPP_OPERATION
 */
function ADD_MONTH_YEAR_INTERVAL(
    I_DATE date,
    I_NUMTOYMINTERVAL_OPERATION varchar2
)
    return date
as
    /* local constants */
    CL_ROUTINE_NAME constant USER_OBJECTS.OBJECT_NAME%type :=
      'ADD_MONTH_YEAR_INTERVAL';
    CL_PARSER_PATTERN constant varchar2(1000 char) :=
      '^\s*NUMTOYMINTERVAL\s*\(\s*(-?\s*\d+(\.\d+)?)\s*,\s*''(MONTH|YEAR)''\s*\)\s*$';
    CL_SCALE_RESULT_PATTERN constant char(2 char) := '\3';
    CL_VALUE_RESULT_PATTERN constant char(2 char) := '\1';
    CL_STATEMENT constant char(1000 char) :=
      'select add_months(:1, :2) from dual';

    /* local variables */
    VL_SCALE varchar2(1000 char);
    VL_VALUE integer;
    VL_RESULT_DATE date;
begin
    PKG_PLNSQL_LOGGER.DEBUG(
        MESSAGE => 'I_DATE: ' ||
          to_char(I_DATE, PKG_UTILITIES.CG_DATETIME_FORMAT),
        PACKAGE_NAME => CG_PACKAGE_NAME,
        FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
    );
    PKG_PLNSQL_LOGGER.DEBUG(
        MESSAGE => 'I_NUMTOYMINTERVAL_OPERATION: ' ||
          I_NUMTOYMINTERVAL_OPERATION,
        PACKAGE_NAME => CG_PACKAGE_NAME,
        FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
    );
    PKG_PLNSQL_LOGGER.DEBUG(
        MESSAGE => 'CL_PARSER_PATTERN: ' ||
          CL_PARSER_PATTERN,
        PACKAGE_NAME => CG_PACKAGE_NAME,
        FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
    );
    PKG_PLNSQL_LOGGER.DEBUG(
        MESSAGE => 'CL_SCALE_RESULT_PATTERN: ' ||
          CL_SCALE_RESULT_PATTERN,
        PACKAGE_NAME => CG_PACKAGE_NAME,
        FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
    );
    PKG_PLNSQL_LOGGER.DEBUG(
        MESSAGE => 'CL_VALUE_RESULT_PATTERN: ' ||
          CL_VALUE_RESULT_PATTERN,
        PACKAGE_NAME => CG_PACKAGE_NAME,
        FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
    );
    PKG_PLNSQL_LOGGER.DEBUG(
        MESSAGE => 'CL_STATEMENT: ' ||
          CL_STATEMENT,
        PACKAGE_NAME => CG_PACKAGE_NAME,
        FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
    );

    VL_SCALE := upper(
        regexp_replace(
            I_NUMTOYMINTERVAL_OPERATION,
            CL_PARSER_PATTERN,
            CL_SCALE_RESULT_PATTERN,
            1, -- needed to get to the matching parameter
            0, -- needed to get to the matching parameter
            'i'
        )
    );
    PKG_PLNSQL_LOGGER.DEBUG(
        MESSAGE => 'VL_SCALE: ' ||
          VL_SCALE,
        PACKAGE_NAME => CG_PACKAGE_NAME,
        FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
    );
    VL_VALUE := regexp_replace(
        I_NUMTOYMINTERVAL_OPERATION,
        CL_PARSER_PATTERN,
        CL_VALUE_RESULT_PATTERN,
        1, -- needed to get to the matching parameter
        0, -- needed to get to the matching parameter
        'i'
    );
    PKG_PLNSQL_LOGGER.DEBUG(
        MESSAGE => 'VL_VALUE: ' ||
          VL_VALUE,
        PACKAGE_NAME => CG_PACKAGE_NAME,
        FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
    );
    if VL_SCALE is null or VL_VALUE is null then
        PKG_PLNSQL_LOGGER.INFO(
            MESSAGE => 'I_NUMTOYMINTERVAL_OPERATION: ' ||
              I_NUMTOYMINTERVAL_OPERATION,
            PACKAGE_NAME => CG_PACKAGE_NAME,
            FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
        );
        PKG_PLNSQL_LOGGER.INFO(
            MESSAGE => 'CL_PARSER_PATTERN: ' ||
              CL_PARSER_PATTERN,
            PACKAGE_NAME => CG_PACKAGE_NAME,
            FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
        );
        PKG_PLNSQL_LOGGER.INFO(
            MESSAGE => 'CL_SCALE_RESULT_PATTERN: ' ||
              CL_SCALE_RESULT_PATTERN,
            PACKAGE_NAME => CG_PACKAGE_NAME,
            FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
        );
        PKG_PLNSQL_LOGGER.INFO(
            MESSAGE => 'CL_VALUE_RESULT_PATTERN: ' ||
              CL_VALUE_RESULT_PATTERN,
            PACKAGE_NAME => CG_PACKAGE_NAME,
            FUNCTION_PROCEDURE_NAME => CL_ROUTINE_NAME
        );
        raise_application_error(
            PKG_EXCEPTIONS.CG_ERR_N_UNSUPP_OPERATION,
            PKG_EXCEPTIONS.CG_ERR_D_UNSUPP_OPERATION
        );
    elsif VL_SCALE = 'YEAR' then
        -- There is no add_year function so we have to base on months.
        VL_VALUE := VL_VALUE * 12;
    end if;
    execute immediate CL_STATEMENT
      into VL_RESULT_DATE
      using I_DATE, VL_VALUE;

    return VL_RESULT_DATE;
end ADD_MONTH_YEAR_INTERVAL;

BTW

You are save to use the day second interval data type for this kind of calculations.