PostgreSQL age() 函数: different/unexpected 在不同月份登陆时的结果

PostgreSQL age() function: different/unexpected results when landing in dfferent month

今天,我在 PostgreSQL 9.6 中遇到无法解释的结果,而 运行 这个查询:

SELECT age('2018-06-30','2018-05-19') AS one,
       age('2018-07-01','2018-05-20') AS two; 

两列的预期结果:1 mon 11 days。但是,仅在 2018-05-19 到 2018-06-30 期间,我得到了我的期望,而对于 2018-05-20 到 2018-07-01,我会多一天:1 mon 12 days

我不明白为什么会这样,据我了解,2018-05-20 2018-07-01 之间只是 1 mon 11 days 的间隔,这里的 Postgres 结果是错误的。

我找不到任何关于 PostgreSQL-age(timestamp,timestamp) 函数究竟如何工作的深入信息。但是,我假设该函数执行类似的操作:从开始日期开始,以月份为单位向前移动,直到到达结束月份。从那里,转到结束日期的那一天。总结月和日。

所以,根据我的理解,这就是我的情况下应该发生的事情(抱歉,这里太冗长了,但我觉得这是必要的):

从 2018-05-19 开始。前进一个月。登陆时间为 2018-06-19。向前走 N 天,直到您到达 2018-06-30:

1 day: 20
2 days: 21
3 days: 22
4 days: 23
5 days: 24
6 days: 25
7 days: 26
8 days: 27
9 days: 28
10 days: 29
11 days: 30

= 1 month 11 days.

2018-05-20和2018-07-01之间的时间应该差不多:

从 2018-05-20 开始。前进一个月。登陆时间为 2018-06-20。向前走 N 天,直到您到达 2018-07-01:

1 day: 21
2 days: 22
3 days: 23
4 days: 24
5 days: 25
6 days: 26
7 days: 27
8 days: 28
9 days: 29
10 days: 30
11 days: 1

= 1 month 11 days.

这是我的错误还是 PostgreSQL 的错误? 是否有替代方案 functions/algorithms 以我 described/expect 的方式工作?

上述意外行为不是因为 age() 。但是由于允许计算的区间数据类型。下面 link 包含必要的解释。

Odd month arithmetic

在你的第一个中,因为连续两次你没有看到意外。但事实并非如此。这往往高于奇数月算术行为

age是通过src/backend/utils/adt/timestamp.c中的timestamptz_age函数计算出来的。评论说:

/* timestamptz_age()
 * Calculate time difference while retaining year/month fields.
 * Note that this does not result in an accurate absolute time span
 *  since year and month are out of context once the arithmetic
 *  is done.
 */

代码首先将参数转换为struct pg_tm变量tm1tm2struct pg_tm类似于C库的struct tm,但有额外的时区字段),然后计算每个字段的差异 tm

age('2018-07-01','2018-05-20') 的情况下,该差异的相关字段如下所示:

tm_mday = -19
tm_mon  =   2
tm_year =   0

现在调整负字段。对于 tm_mday,代码如下所示:

while (tm->tm_mday < 0)
{
    if (dt1 < dt2)
    {
        tm->tm_mday += day_tab[isleap(tm1->tm_year)][tm1->tm_mon - 1];
        tm->tm_mon--;
    }
    else
    {
        tm->tm_mday += day_tab[isleap(tm2->tm_year)][tm2->tm_mon - 1];
        tm->tm_mon--;
    }
}

由于 dt1 > dt2,采用了 else 分支,代码添加了五月的天数 (31) 并将月份减去 1,以

结束
tm_mday = 12
tm_mon  =  1
tm_year =  0

这就是你得到的结果。

现在乍一看好像tm2->tm_mon月份选的不对,取左边参数的前一个月比较好:

day_tab[isleap(tm1->tm_year)][(tm1->tm_mon + 10) % 12]

但我不能说这个选择是否在所有情况下都更好,而且无论如何评论都会保护该功能,所以我不愿将其称为错误。

您可能想通过黑客邮件列表处理它。

对于那些感兴趣的人:我想我已经找到了解决这个问题的方法,使用一个函数给我想要的结果。它根据我自己的测试工作,即使是闰年,但不幸的是,我不能保证它总是有效。它也似乎有点老套。

CREATE OR REPLACE FUNCTION age_forward ("endDate" date,"startDate" date) 
     RETURNS interval AS $$

     /*

     Basic approach: actually do a culculation like this:
     SELECT age('2018-07-01','2018-06-01') + ((30 - 20) + 1||' days')::interval;

     So, basically:
     (1) truncate start and end to month level, so always FIRST of month
     (2) add one month to the start month
     (3) calculate the days
     (4) add the days as string and build the interval

     The crucial part is 3: calculate the days

     We do it like this:
     - get the number of days for the month in question. The month in question is the month BEFORE the end month. For our example it is JUNE
     - subtract the start date day number from the number of days (here 20)
     - add the end date day number (here 1)

     */

     SELECT CASE 

        /* First step: Check if the startDate day number is lower or equal the endDate day number.
           If this is the case: Do vanilla age(). Works perfectly here
        */

        WHEN (date_part('day', "startDate" )::integer) <= date_part('day', "endDate" )::integer

        THEN age("endDate","startDate")

        /* Special case to treat here: startDate day number is greater than endDate day number. Do the algorithm described above */

        ELSE  age
               (

                  date_trunc('month', "endDate"::date), /* Go just till month level, always using '1' as day */

                  date_trunc('month', "startDate"::date)
                  + '1 mons'::interval
                  /* Add one month so that interval to look for will become actually shorter for now. */
                ) 
              + 
                (
                   (

                     /* Calculate the last day of the month previous to the end month. See   */
                     (date_part('day',(date_trunc('month', (date_trunc('month', "endDate"::date) - '1 mons'::interval)  ) + interval '1 month' - interval '1 day')::date))::integer

                     - 

                     /* endDate day number subtracted */
                     date_part('day', "startDate" )::integer
                   )

                   /* endDate day number added */
                   + date_part('day', "endDate" )::integer||' days'

                )::interval
        END

$$ LANGUAGE sql;

这不是一个完整的答案,但对于评论来说太长了。

间隔 mon 的问题在于它是一个移动目标。

例如,如果您 运行 这个:

SELECT
    '2018-01-30'::date+interval'1 month' AS toomuch,    --  02-28
    '2018-01-19'::date+interval'1 month' AS jan,        --  02-19
    '2018-02-19'::date+interval'1 month' AS feb,        --  03-19
    '2018-03-19'::date+interval'1 month' AS mar,        --  … and so on…
    '2018-04-19'::date+interval'1 month' AS apr,
    '2018-05-19'::date+interval'1 month' AS may,
    '2018-06-19'::date+interval'1 month' AS jun
;   

你每次都会得到第19个(当然第一个除外)。也就是说mon区间明显变了

查看第一种情况,然后查看 toomuch 列,您还可以看到,这不仅仅是添加月份数字的情况,因为结果需要裁剪到月底。

因此,如果您 运行 原始问题的扩展版本:

SELECT
    age('2018-06-30','2018-05-19') AS one,
    '2018-06-30'::date-'2018-05-19'::date AS oneminus,
    age('2018-07-01','2018-05-20') AS two,
    '2018-07-01'::date-'2018-05-20'::date AS twominus
;

您可以看到,虽然两种情况的差异是 42 天,但 1 mon 部分似乎使用了一个月的两个不同版本。

也就是说,间隔真的是一样的,只要你选择不同的月份定义即可。

我还没有完全弄清楚它是如何在第一个实例中使用 31 天的月份,在第二个实例中使用 30 天的月份,但它显然与开始日期或结束日期有关。