【问题标题】:How do you do date math that ignores the year?你如何做忽略年份的日期数学?
【发布时间】:2013-02-16 15:26:39
【问题描述】:

我正在尝试选择在接下来的 14 天内有周年纪念日的日期。如何根据不包括年份的日期进行选择?我尝试过类似以下的方法。

SELECT * FROM events
WHERE EXTRACT(month FROM "date") = 3
AND EXTRACT(day FROM "date") < EXTRACT(day FROM "date") + 14

这个问题是几个月换行。
我宁愿做这样的事情,但我不知道如何忽略这一年。

SELECT * FROM events
WHERE (date > '2013-03-01' AND date < '2013-04-01')

如何在 Postgres 中完成这种日期数学运算?

【问题讨论】:

  • 关于闰年有一些棘手的细节(见我的回答),“接下来的 14 天”通常最终涵盖 15 天(包括今天)。您需要准确定义您想要的内容。
  • @JNK:哇,谢谢 :) 300 份您自己辛苦赚来的 XP 是一笔巨额的赏金。不应该是来自community 之类的吗?
  • @ErwinBrandstetter 一点也不,这是一个很棒的答案!
  • @JNK:嗯,我确实花了我周末的大部分时间。再次感谢。

标签: sql postgresql date datetime indexing


【解决方案1】:

如果您不关心解释和细节,请使用下面的“黑魔法版”

到目前为止,其他答案中的所有查询都使用not sargable 的条件进行操作 - 它们不能使用索引,并且必须为基表中的每一行计算一个表达式以查找匹配的行。与小桌子无关紧要。大桌子很重要(很多)。

给定以下简单表格:

CREATE TABLE event (
  event_id   serial PRIMARY KEY
, event_date date
);

查询

下面的版本1.和2.可以使用简单的索引形式:

CREATE INDEX event_event_date_idx ON event(event_date);

但以下所有解决方案都没有索引会更快

1。简单版

SELECT *
FROM  (
   SELECT ((current_date + d) - interval '1 year' * y)::date AS event_date
   FROM       generate_series( 0,  14) d
   CROSS JOIN generate_series(13, 113) y
   ) x
JOIN  event USING (event_date);

子查询x 从两个generate_series() 调用中的CROSS JOIN 计算给定年份范围内的所有可能日期。选择是通过最终的简单连接完成的。

2。进阶版

WITH val AS (
   SELECT extract(year FROM age(current_date + 14, min(event_date)))::int AS max_y
        , extract(year FROM age(current_date,      max(event_date)))::int AS min_y
   FROM   event
   )
SELECT e.*
FROM  (
   SELECT ((current_date + d.d) - interval '1 year' * y.y)::date AS event_date
   FROM   generate_series(0, 14) d
        ,(SELECT generate_series(min_y, max_y) AS y FROM val) y
   ) x
JOIN  event e USING (event_date);

自动从表中推断出年份范围 - 从而最大限度地减少生成的年份。
如果存在差距,您可以更进一步,提取现有年份的列表。

有效性共同取决于日期的分布。几年每行都有很多行,这使得这个解决方案更有用。多年来,每行很少,因此它的用处不大。

Simple SQL Fiddle 一起玩。

3。黑魔法版

2016 年更新以删除“生成的列”,这将阻止 H.O.T.更新;更简单、更快捷的功能。
2018 年更新以使用 IMMUTABLE 表达式计算 MMDD,以允许函数内联。

创建一个简单的 SQL 函数,根据模式 'MMDD' 计算 integer

CREATE FUNCTION f_mmdd(date) RETURNS int LANGUAGE sql IMMUTABLE AS
'SELECT (EXTRACT(month FROM $1) * 100 + EXTRACT(day FROM $1))::int';

一开始我有to_char(time, 'MMDD'),但切换到上面的表达式,这在 Postgres 9.6 和 10 的新测试中被证明是最快的:

db小提琴here

它允许function inlining,因为EXTRACT (xyz FROM date) 在内部使用IMMUTABLE 函数date_part(text, date) 实现。它必须是 IMMUTABLE 才能在以下基本多列表达式索引中使用:

CREATE INDEX event_mmdd_event_date_idx ON event(f_mmdd(event_date), event_date);

多列有多种原因:
可以帮助ORDER BY 或从给定年份中进行选择。阅读here。索引几乎没有额外成本。 date 适合 4 个字节,否则会因数据对齐而丢失填充。阅读here
此外,由于两个索引列都引用同一个表列,所以 H.O.T. 更新没有缺点。阅读here

一个 PL/pgSQL 表函数来统治它们

分叉到两个查询之一以涵盖年初:

CREATE OR REPLACE FUNCTION f_anniversary(date = current_date, int = 14)
  RETURNS SETOF event AS
$func$
DECLARE
   d  int := f_mmdd($1);
   d1 int := f_mmdd($1 + $2 - 1);  -- fix off-by-1 from upper bound
BEGIN
   IF d1 > d THEN
      RETURN QUERY
      SELECT *
      FROM   event e
      WHERE  f_mmdd(e.event_date) BETWEEN d AND d1
      ORDER  BY f_mmdd(e.event_date), e.event_date;

   ELSE  -- wrap around end of year
      RETURN QUERY
      SELECT *
      FROM   event e
      WHERE  f_mmdd(e.event_date) >= d OR
             f_mmdd(e.event_date) <= d1
      ORDER  BY (f_mmdd(e.event_date) >= d) DESC, f_mmdd(e.event_date), event_date;
      -- chronological across turn of the year
   END IF;
END
$func$  LANGUAGE plpgsql;

调用使用默认值:从“今天”开始的 14 天:

SELECT * FROM f_anniversary();

从“2014-08-23”开始的 7 天通话:

SELECT * FROM f_anniversary(date '2014-08-23', 7);

SQL Fiddle 比较EXPLAIN ANALYZE

2 月 29 日

在处理纪念日或“生日”时,需要定义闰年特殊情况“2 月 29 日”的处理方式。

在测试日期范围时,通常会自动包含Feb 29,即使当前年份不是闰年。当涵盖这一天时,日期范围追溯延长 1。
另一方面,如果当前年份是闰年,并且您想要查找 15 天,那么如果您的数据来自非闰年,您最终可能会在闰年获得 14 天的结果。

假设,鲍勃出生于 2 月 29 日:
我的查询 1. 和 2. 仅包括闰年的 2 月 29 日。鲍勃每 4 年才过一次生日。
我的查询 3. 在范围内包括 2 月 29 日。 Bob 每年都会过生日。

没有神奇的解决方案。您必须为每种情况定义您想要的内容。

测试

为了证实我的观点,我对所有提供的解决方案进行了广泛的测试。我将每个查询调整到给定的表,并在没有ORDER BY 的情况下产生相同的结果。

好消息:所有这些都是正确并产生相同的结果 - 除了 Gordon 的查询有语法错误,以及 @wildplasser 的查询在年份结束时失败(易于修复) .

插入 108000 行,其中包含 20 世纪的随机日期,这类似于在世人(13 岁或以上)的表格。

INSERT INTO  event (event_date)
SELECT '2000-1-1'::date - (random() * 36525)::int
FROM   generate_series (1, 108000);

删除 ~ 8 % 以创建一些死元组并使表格更加“真实”。

DELETE FROM event WHERE random() < 0.08;
ANALYZE event;

我的测试用例有 99289 行,4012 次点击。

C - Catcall

WITH anniversaries as (
   SELECT event_id, event_date
         ,(event_date + (n || ' years')::interval)::date anniversary
   FROM   event, generate_series(13, 113) n
   )
SELECT event_id, event_date -- count(*)   --
FROM   anniversaries
WHERE  anniversary BETWEEN current_date AND current_date + interval '14' day;

C1 - Catcall 的想法重写

除了小的优化之外,主要的区别是只添加确切的年数 date_trunc('year', age(current_date + 14, event_date)) 来获得今年的周年纪念日,这完全避免了对 CTE 的需要:

SELECT event_id, event_date
FROM   event
WHERE (event_date + date_trunc('year', age(current_date + 14, event_date)))::date
       BETWEEN current_date AND current_date + 14;

D - Daniel

SELECT *   -- count(*)   -- 
FROM   event
WHERE  extract(month FROM age(current_date + 14, event_date))  = 0
AND    extract(day   FROM age(current_date + 14, event_date)) <= 14;

E1 - 欧文 1

参见上面的“1. 简单版”。

E2 - 欧文 2

参见上面的“2. 高级版”。

E3 - 欧文 3

见上文“3.黑魔法版”。

G - Gordon

SELECT * -- count(*)   
FROM  (SELECT *, to_char(event_date, 'MM-DD') AS mmdd FROM event) e
WHERE  to_date(to_char(now(), 'YYYY') || '-'
                 || (CASE WHEN mmdd = '02-29' THEN '02-28' ELSE mmdd END)
              ,'YYYY-MM-DD') BETWEEN date(now()) and date(now()) + 14;

H - a_horse_with_no_name

WITH upcoming as (
   SELECT event_id, event_date
         ,CASE 
            WHEN date_trunc('year', age(event_date)) = age(event_date)
                 THEN current_date
            ELSE cast(event_date + ((extract(year FROM age(event_date)) + 1)
                      * interval '1' year) AS date) 
          END AS next_event
   FROM event
   )
SELECT event_id, event_date
FROM   upcoming
WHERE  next_event - current_date  <= 14;

W - wildplasser

CREATE OR REPLACE FUNCTION this_years_birthday(_dut date) RETURNS date AS
$func$
DECLARE
    ret date;
BEGIN
    ret :=
    date_trunc( 'year' , current_timestamp)
        + (date_trunc( 'day' , _dut)
         - date_trunc( 'year' , _dut));
    RETURN ret;
END
$func$ LANGUAGE plpgsql;

简化为与其他所有返回相同:

SELECT *
FROM   event e
WHERE  this_years_birthday( e.event_date::date )
        BETWEEN current_date
        AND     current_date + '2weeks'::interval;

W1 - wildplasser 的查询重写

以上内容存在许多低效的细节(超出了这篇已经相当大的帖子的范围)。重写后的版本要快得多

CREATE OR REPLACE FUNCTION this_years_birthday(_dut INOUT date) AS
$func$
SELECT (date_trunc('year', now()) + ($1 - date_trunc('year', $1)))::date
$func$ LANGUAGE sql;

SELECT *
FROM   event e
WHERE  this_years_birthday(e.event_date)
        BETWEEN current_date
        AND    (current_date + 14);

测试结果

我在 PostgreSQL 9.1.7 上使用临时表运行了这个测试。 使用EXPLAIN ANALYZE 收​​集结果,最好的 5 个。

结果

无索引 C:总运行时间:76714.723 毫秒 C1:总运行时间:307.987 ms -- ! D:总运行时间:325.549 毫秒 E1:总运行时间:253.671 ms -- ! E2:总运行时间:484.698 ms -- min() & max() 没有索引很昂贵 E3:总运行时间:213.805 ms -- ! G:总运行时间:984.788 毫秒 H:总运行时间:977.297 毫秒 W:总运行时间:2668.092 毫秒 W1:总运行时间:596.849 ms -- ! 有索引 E1:总运行时间:37.939 ms --!! E2:总运行时间:38.097 ms --!! 在表达式上有索引 E3:总运行时间:11.837 ms --!!

所有其他查询无论是否使用索引都执行相同的操作,因为它们使用 non-sargable 表达式。

结论

  • 到目前为止,@Daniel 的查询是最快的。

  • @wildplassers(重写)方法的性能也可以接受。

  • @Catcall 的版本类似于我的反向方法。表越大,性能很快就会失控。
    不过,重写后的版本表现相当不错。我使用的表达式类似于@wildplasser 的this_years_birthday() 函数的更简单版本。

  • 我的“简单版本”更快即使没有索引,因为它需要的计算更少。

  • 有了索引,“高级版”的速度与“简单版”差不多,因为有了索引,min()max() 变得非常便宜。两者都比其他不能使用索引的要快得多。

  • 我的“黑魔法版”无论有无索引都最快。而且调用起来非常简单。
    更新后的版本(在基准测试之后)要快一些。

  • 在现实生活中的表格中,索引将使更大不同。列越多,表越大,顺序扫描成本越高,而索引大小保持不变。

【讨论】:

  • 很酷。 generate_series(13, 113) 在“简单版”中到底在做什么?这些数字从何而来?
  • @a_horse_with_no_name:谢谢!由于我的测试表可以追溯到 20 世纪,因此这是current_date - interval '1y' * y.y) 的近似系列。 “高级”版本会自动计算。
  • 非常感谢 Erwin 的广泛回答。
  • @ErwinBrandstetter 令人难以置信的广泛答案。 +1 你应该得到比 +1 更多的东西。 :)
【解决方案2】:

我相信以下测试适用于所有情况,假设有一个名为 anniv_date 的列:

select * from events
where extract(month from age(current_date+interval '14 days', anniv_date))=0
  and extract(day from age(current_date+interval '14 days', anniv_date)) <= 14

作为跨越一年(以及一个月)如何工作的示例,假设周年日期是 2009-01-04,运行测试的日期是 2012-12-29

我们想考虑2012-12-292013-01-12 之间的任何日期(14 天)

age('2013-01-12'::date, '2009-01-04'::date)4 years 8 days

extract(month...) from this is 0 and extract(days...) is 8, which is less than 14 so it match.

【讨论】:

  • +1 这是迄今为止最好的答案。 SQLlfiddle demo。还证明您可以将整数添加到日期。我想我想出了更好的东西。
【解决方案3】:

这个怎么样?

select *
from events e
where to_char(e."date", 'MM-DD') between to_char(now(), 'MM-DD') and 
                                         to_char(date(now())+14, 'MM-DD')

您可以将比较作为字符串进行。

为了将年末考虑在内,我们将转换回日期:

select *
from events e
where to_date(to_char(now(), 'YYYY')||'-'||to_char(e."date", 'MM-DD'), 'YYYY-MM-DD')
           between date(now()) and date(now())+14

您确实需要在 2 月 29 日稍作调整。我可能会建议:

select *
from (select e.*,
             to_char(e."date", 'MM-DD') as MMDD
      from events
     ) e
where to_date(to_char(now(), 'YYYY')||'-'||(case when MMDD = '02-29' then '02-28' else MMDD), 'YYYY-MM-DD')
           between date(now()) and date(now())+14

【讨论】:

  • 没有意识到你可以做这样的字符串比较。这在大多数情况下都有效。但它在年度包装中失败了。你的日期是这样的:SELECT to_char(date('1999-01-01'), 'MM-DD') between to_char(date('2012-12-22'), 'MM-DD') and to_char(date('2013-01-02'), 'MM-DD');
  • 我误解了还是修改后的答案在 1999-01-01 仍然失败?简单地将周年日期调整为当年会将 1999-01-01 变成 2013-01-01,这已经是过去的时间,因此无法通过第二个示例的 BETWEEN 检查。
  • @pilcrow 。 . .我想你误会了。日期是 1 月 1 日的东西在接下来的两周内没有周年纪念日(在我写这篇文章的时候)。您将不得不等到一年中的最后两周。请注意,当前年放在事件日期。对于超过一年的跨度,“+14”将处理它。
  • @GordonLinoff,您计算的是“本日历年的周年纪念日”,而不是“未来的下一个周年纪念日”。例如,今年,您的查询会将 $Whatever-01-01 调整为 2013-01-01。如果您在今年的最后一天运行查询,您的 where 子句将变为:WHERE '2013-01-01'::date BETWEEN '2013-12-31'::date AND '2014-01-14'::date。也就是说,它会失败,即使周年纪念日就在第二天。 (顺便说一下,to_date() 会为您调整假闰日,而不需要 CASE 逻辑。例如,2013-02-29 => 2013-03-01。)
【解决方案4】:

为方便起见,我创建了两个函数来生成当年的(预期或过去的)生日和即将到来的生日。

CREATE OR REPLACE FUNCTION this_years_birthday( _dut DATE) RETURNS DATE AS
$func$

DECLARE
        ret DATE;
BEGIN
        ret =
        date_trunc( 'year' , current_timestamp)
        + (date_trunc( 'day' , _dut)
          - date_trunc( 'year' , _dut)
          )
        ;
        RETURN ret;
END;
$func$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION next_birthday( _dut DATE) RETURNS DATE AS
$func$

DECLARE
        ret DATE;
BEGIN
        ret =
        date_trunc( 'year' , current_timestamp)
        + (date_trunc( 'day' , _dut)
          - date_trunc( 'year' , _dut)
          )
        ;
        IF (ret < date_trunc( 'day' , current_timestamp))
           THEN ret = ret + '1year'::interval; END IF;
        RETURN ret;
END;
$func$ LANGUAGE plpgsql;

      --
      -- call the function
      --
SELECT date_trunc( 'day' , t.topic_date) AS the_date
        , this_years_birthday( t.topic_date::date ) AS the_day
        , next_birthday( t.topic_date::date ) AS next_day
FROM topic t
WHERE this_years_birthday( t.topic_date::date )
        BETWEEN  current_date
        AND  current_date + '2weeks':: interval
        ;

注意:演员表是必需的,因为我只有可用的时间戳。

【讨论】:

  • 抱歉,我忽略了您对我的测试用例的查询。乍一看,我只看到了辅助函数。现在时间不多了。我稍后会添加它。
  • 我的猜测是,一旦你“分解”它(像宏一样写出来),这些功能将与任何其他解决方案相媲美。如果需要 GROUP BY,我的会赢(给定“宏化/爆炸函数”)顺便说一句:我不喜欢速度。我喜欢正确性。
  • 现已测试。你原来的表现很差,重写的版本还不错。看看吧。
  • 啊,谢谢。我也尝试将其放入纯 SQL,但失败和/或停止。顺便说一句:我意识到它有一个年份换行问题,所以我添加了一个 next_birthday() 函数。我的猜测是,在可能的情况下,纯 SQL 可能会被合并到计划中。
【解决方案5】:

这也应该在年底处理环绕:

with upcoming as (
  select name, 
         event_date,
         case 
           when date_trunc('year', age(event_date)) = age(event_date) then current_date
           else cast(event_date + ((extract(year from age(event_date)) + 1) * interval '1' year) as date) 
         end as next_event
  from events
)
select name, 
       next_event, 
       next_event - current_date as days_until_next
from upcoming
order by next_event - current_date 

您可以对表达式 next_event - current_date 进行过滤以应用“未来 14 天”

case ... 仅在您将“今天”的事件也视为“即将发生”的事件时才需要。否则,可以简化为 case 语句的 else 部分。

请注意,我将列"date"“重命名”为event_date。主要是因为保留字不应该用作标识符,还因为date 是一个糟糕的列名。它不会告诉你它存储了什么。

【讨论】:

    【解决方案6】:

    您可以生成一个虚拟的周年纪念表,并从中进行选择。

    with anniversaries as (
      select event_date, 
             (event_date + (n || ' years')::interval)::date anniversary
      from events, generate_series(1,10) n
    )
    select event_date, anniversary
    from anniversaries
    where anniversary between current_date and current_date + interval '14' day
    order by event_date, anniversary
    

    generate_series(1,10) 的调用具有为每个 event_date 生成 10 周年纪念日的效果。我不会在生产中使用文字值 10 。相反,我要么计算在子查询中使用的正确年数,要么使用像 100 这样的大字面值。

    您需要调整 WHERE 子句以适合您的应用程序。

    如果虚拟表存在性能问题(当“事件”中有很多行时),请将公用表表达式替换为具有相同结构的基表。将周年纪念日存储在基表中会使它们的值显而易见(尤其是对于例如 2 月 29 日的周年纪念日),并且对此类表的查询可以使用索引。仅使用上面的 SELECT 语句查询包含 50 万行的周年纪念 在我的桌面上需要 25 毫秒。

    【讨论】:

    • 您可能对我发布的这种方法的修订版感兴趣。
    • 好文章。你已经小睡了。
    • @ErwinBrandstetter:我昨天在 Linkedin 上联系了你。另外,我写的 WHERE 子句是 sargable。
    • 我还没有使用 Linkedin。这么多社交网络,呃。我可能很快就会看看,因为人们一直在烦我。至于 WHERE 子句:如果 anniversary 是表列,where anniversary between current_date and current_date + interval '14' day would 是可搜索的,但这里它基于 CTE 中的表达式,这里 不是 .至少,我无法让 Postgres 9.1.7 使用任何索引,而这正是我所期望的。如果您能提供相反的证据,我很乐意向您学习。
    • @ErwinBrandstetter:其他 Erwin Brandstetter LinkedIn 建议我可能知道的人可能在想,“这到底是谁?” 我理解 sargable意思是“如果碰巧存在一个可以使用索引的表达式”,而不是“实际使用索引的表达式”。我的 WHERE 子句中的表达式可以使用索引,但 CTE 上没有索引。
    【解决方案7】:

    我找到了办法。

    SELECT EXTRACT(DAYS FROM age('1999-04-10', '2003-05-12')), 
           EXTRACT(MONTHS FROM age('1999-04-10', '2003-05-12'));
     date_part | date_part 
    -----------+-----------
            -2 |        -1
    

    然后我可以检查月份是否为 0,天数是否小于 14。

    如果您有更优雅的解决方案,请发布。我将把这个问题留待一会儿。

    【讨论】:

    • 这个解决方案在年份换行的情况下会失败,所以我肯定还在寻找一些东西。
    【解决方案8】:

    我不使用 postgresql,所以我用谷歌搜索了它的日期函数,发现:http://www.postgresql.org/docs/current/static/functions-datetime.html

    如果我没看错的话,寻找未来 14 天内的事件就像这样简单:

     where mydatefield >= current_date
     and mydatefield < current_date + integer '14'
    

    当然,我可能没有正确阅读它。

    【讨论】:

    • 通常就是这么简单。我想在不考虑年份部分的情况下做这个数学运算,所以它不会起作用。
    • 如果您的需求很简单,比如未来 14 天会发生什么,那么解决方案同样简单。如果您的要求是别的,我没有在您的问题中看到它。
    • mydatefield 不是活动日期。它与事件的日期有同一天和同一个月,但在过去的 N 年,N 可以是任何东西。或者它是每年发生的事件的第一个日期,如果更清楚的话。
    猜你喜欢
    • 2010-10-10
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-07-04
    • 2011-12-24
    • 2019-03-09
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多