介绍 (Introduction)
There are plenty of scripts to compute the date of various holidays given the year. Let’s look at the problem in reverse: Given a date, determine if it is a given holiday or not.
有很多脚本可以计算给定年份中各个假期的日期。 让我们反过来看问题:给定日期,确定是否是给定的假期。
日期表 (Date Tables)
Many dimensional models make use of date tables — often called DimDate. They’re pretty handy! A well-designed date table saves you from coding up things like computing fiscal vs calendar intervals, converting between formats or selecting parts of a date. When building a date table, we will likely need to flag some days as holidays.
许多维度模型都使用日期表(通常称为DimDate)。 他们很方便! 精心设计的日期表可避免您编写诸如计算财务间隔与日历间隔,在格式之间进行转换或选择日期的一部分之类的事情。 建立日期表时,我们可能需要将某些日子标记为假期。
For this article we’ll work with a simple date table. It looks like this:
对于本文,我们将使用一个简单的日期表。 看起来像这样:
CREATE TABLE [dbo].[DimDate](
[DimDateID] [int] NOT NULL PRIMARY KEY,
[DateValue] [date] NOT NULL UNIQUE,
[Day] AS DAY(DateValue),
[Week] AS DATEPART(WEEK, DateValue),
[Month] AS MONTH(DateValue),
[Quarter] AS DATEPART(QUARTER, DateValue),
[Year] AS YEAR(DateValue),
[DayOfWeek] AS DATEPART(WEEKDAY, DateValue),
[IsCanadianHoliday] [bit] NOT NULL DEFAULT ((0)),
[IsUSHoliday] [bit] NOT NULL DEFAULT ((0)),
)
Notice that I use several computed columns. This makes my life easier (at least for this article!). If you prefer, you can compute and persist the values when you create the table, of course.
注意,我使用了几个计算列。 这使我的生活更加轻松(至少对于本文而言!)。 当然,如果愿意,您可以在创建表时计算并保留这些值。
To populate this table, I’m using an in-line tally table approach:
为了填充此表,我使用了一个在线理货表方法:
DECLARE @StartDate date = '20000101',
@EndDate date = '20501231';
WITH N10(n) AS (SELECT 1 FROM (VALUES
(0),(1),(2),(3),(4),(5),(6),(7),(8),(9)) v(n))
, N100(n) AS (SELECT 1 FROM N10, N10 n)
, N10000(n) AS (SELECT 1 FROM N100, N100 n)
, N100000(n) AS (SELECT 1 FROM N10, N10000 n)
, N AS (SELECT TOP (DATEDIFF(DAY, @startdate, @enddate) + 1)
n = ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) - 1
FROM N100000)
INSERT INTO [dbo].[DimDate](DimDateID, DateValue)
SELECT CAST(CONVERT(CHAR(8),InsertDate, 112) AS INT)
, InsertDate
FROM N
CROSS APPLY (SELECT DATEADD(DAY, n, @Startdate)) d(InsertDate);
If you haven’t used tally tables like this before, they are well-worth learning. The basic idea is to start with some set (I chose the integers from 0 to 9), then take the Cartesian product of that set and then do it again and again until you get at least as many items as you need. Note that to do that, I’m using the old-style join syntax rather than explicitly writing CROSS JOIN. That keeps each CTE on a single line, which I think improves readability. The final CTE, which I just call “N” (named after the double-struck symbol which is often used to denote the set of natural numbers), produces just the right number of integers required, starting at 0. In other implementations, you might see code where people have used WHILE loops or even (shudder!) cursors to do this sort of thing. Using a tally table is easy and set-based and rocket fast.
如果您以前没有使用过这样的计数表,那么它们是值得学习的。 基本思想是从某个集合开始(我选择了0到9之间的整数),然后取该集合的笛卡尔积,然后一次又一次地进行,直到获得至少所需数量的项为止。 请注意,要做到这一点,我使用的是老式的联接语法,而不是显式地编写CROSS JOIN。 这样可使每个CTE保持一行,我认为这提高了可读性。 最终的CTE,我仅称其为“ N”(以经常被用来表示自然数的双折符号命名),它产生所需的正确数量的整数,从0开始。在其他实现中,您需要可能会在人们使用WHILE循环或什至(颤抖!)游标执行此类操作的地方看到代码。 使用理货台非常容易,而且可以固定设置,而且火箭速度很快。
You might be curious what kind of execution plan the database engine generates for this. It’s a little long, so I’ve split it up to make it easier to see:
您可能想知道数据库引擎为此生成什么样的执行计划。 它有点长,所以我将其拆分以使其更易于查看:
Are you worried about all those red X’s? You needn’t be. You get those whenever the query includes a join without a predicate. Hovering over one of them, I see the message:
您担心所有这些红色的X吗? 不用了 只要查询包含不带谓词的联接,就可以得到这些。 将鼠标悬停在其中之一上,会看到以下消息:
In this case, though, we actually want a cross join operation, so we can safely ignore those warnings.
但是,在这种情况下,我们实际上需要进行交叉联接操作,因此我们可以放心地忽略这些警告。
Notice also that I use CROSS APPLY as an expression evaluator. This keeps the code a little DRY-er. In OOP-speak, I’ve encapsulated what varies.
还要注意,我使用CROSS APPLY作为表达式评估器。 这使代码有点干。 用面向对象的方式讲,我封装了各种内容。
Now that I’ve got a basic date table, let’s see about updating those holiday columns. I’ll do it with an UPDATE command here, though it is possible to make them computed also — it just gets a little messy since there are so many different holidays with different calculations. Let’s start with Thanksgiving. In Canada, Thanksgiving Day is the second Monday in October. In the US, it’s the fourth Thursday in November. I’ve seen some interesting approaches to solving the problem. One (for US Thanksgiving) looks like this:
现在,我已经有了一个基本的日期表,让我们看看有关更新那些假期列的信息。 我可以在这里用UPDATE命令来完成它,尽管也可以对它们进行计算-因为有些假期有很多不同的计算方式,所以会变得有些混乱。 让我们从感恩节开始。 在加拿大,感恩节是10月的第二个星期一。 在美国,这是11月的第四个星期四。 我已经看到了一些解决问题的有趣方法。 一个(用于美国感恩节)如下所示:
UPDATE [dbo].[DimDate]
SET IsUSHoliday = 1
FROM [dbo].[DimDate] AS c1
WHERE [Month] = 11
AND [DayOfWeek] = 5
AND ( SELECT count(0)
FROM [dbo].[DimDate] AS c2
WHERE [Month] = 11
AND [DayOfWeek] = 5
AND c2.[Year] = c1.[Year]
AND c2.[DateValue] < c1.[DateValue] ) = 3;
Does it work? Well, yes, it does. It counts the number of Thursdays before the date being updated and ensures that there are three. Thinking about it another way though, the earliest Thursday in any month would be the 1st. The 3rd Thursday after that, the 22nd, would then be the earliest possible date for US Thanksgiving. The latest date would of course be 6 days after that (7 days after that is too much (why?)) which is the 28th. That means I can simplify this update to:
它行得通吗? 好吧,是的。 它计算更新日期之前的星期四的数量,并确保有三个。 但是,换种方式考虑,任何一个月的最早星期四都是第一天。 之后,第22个星期三是美国感恩节的最早日期。 最晚的日期当然是在那之后的6天(那之后的7天太多了(为什么?)),即28日。 这意味着我可以将此更新简化为:
UPDATE [dbo].[DimDate]
SET IsUSHoliday = 1
FROM [dbo].[DimDate] AS c1
WHERE c1.[Month] = 11
AND c1.[DayOfWeek] = 5
AND c1.[Day] between 22 and 28
Of course that’s just one pass through the table. The previous example may require two logical passes (although the optimizer may do it better than that), because of the sub query. I use the same technique for Canadian Thanksgiving:
当然,那只是桌子的一关。 由于子查询,前面的示例可能需要两次逻辑传递(尽管优化器可能会做得更好)。 我对加拿大的感恩节使用相同的技术:
UPDATE [dbo].[DimDate]
SET IsCanadianHoliday = 1
FROM [dbo].[DimDate] AS c1
WHERE c1.[Month] = 10
AND c1.[DayOfWeek] = 2
AND c1.[Day] between 8 and 14
另一种方法 (Another approach)
If you’re not crazy about (or if you go crazy computing) the first and last possible days for a holiday, here’s another easy way, at least for holidays that are defined using some week number in the month. The way these work (Thanksgiving is an excellent example) is that they are relative to a specific week number. For Canadian and U.S. Thanksgivings, that would be week numbers 2 and 4, respectively. Now, given a day number in any month, the week number is simply:
如果您不对假期的头几天和最后一天不满意(或者如果您对计算感到疯狂),则这是另一种简便的方法,至少对于使用月份中某个星期数定义的假期而言。 这些工作的方式(感恩节是一个很好的例子)是它们与特定的星期数有关。 对于加拿大和美国的感恩节,分别是第2周和第4周。 现在,给定任意月份的天数,周数就是:
![](https://www.sqlshack.com/wp-content/uploads/2016/11/screenshot.1478001677.png)
Where “d” is the day number in the month. (We’re using integer division here). We have to subtract 1 from the day number before dividing by 7 since days are numbered from 1, not 0. Then, we have to add 1 at the end to get the week number for the same basic reason: weeks are numbered from 1. To see that this works, take the minimum and maximum days for Canadian Thanksgiving that we figured out in the last section:
其中“ d”是月份中的天数。 (我们在这里使用整数除法)。 我们必须从天数中减去1,然后再除以7,因为天数是从1而不是0编号的。然后,出于相同的基本原因,必须在末尾加1才能得到星期数:周数从1开始。为确保此方法有效,请采用上一节中得出的加拿大感恩节的最短和最长天数:
Again, this is integer division here. It is easy to see that day numbers less than 8 return a week number of 1 and days greater than 14 yields a week number of 3 or more. Now, we could simply rework the expression in the WHERE clause like this:
同样,这里是整数除法。 不难发现,少于8的天数返回1的星期数,大于14的天数返回3或更多的星期数。 现在,我们可以像下面这样简单地重做WHERE子句中的表达式:
WHERE c1.[Month] = 10
AND c1.[DayOfWeek] = 2
AND (c1.[Day] – 1) / 7 + 1 = 2
But why not be smarter, since we’ll likely need this for other holidays? Let’s add a new computed column instead:
但是,为什么不变得更聪明,因为在其他假期我们可能会需要它呢? 让我们添加一个新的计算列:
[MonthWeek] AS (DAY(DateValue) – 1) / 7 + 1
Then, the WHERE clause reduces to:
然后,WHERE子句简化为:
WHERE c1.[Month] = 10
AND c1.[DayOfWeek] = 2
AND c1.MonthWeek = 2
Easy and an explicit implementation of the holiday’s specification.
轻松轻松地实现假期规范。
复活节? (Easter Sunday?)
What about Easter Sunday? That is one wild formula! However, I’ll use CROSS APPLY once again, to avoid repeating myself. This algorithm uses the one from the US Navy.
复活节星期天呢? 那是一个疯狂的公式! 但是,我将再次使用CROSS APPLY,以避免重复自己。 该算法使用了美国海军的算法。
UPDATE dimdate
SET IsCanadianHoliday = 1
, IsUSHoliday = 1
FROM [dbo].[DimDate] AS dimdate
CROSS APPLY (SELECT dimdate.year AS y) _y
CROSS APPLY (SELECT y / 100 as c, y - 19 * (y / 19) AS n) _nc
CROSS APPLY (SELECT (c - 17) / 25 AS k) _k
CROSS APPLY (SELECT c - c/4 - (c - k)/3 + 19 *n + 15 AS i1) _i1
CROSS APPLY (SELECT i1 - 30 * (i1 / 30) AS i2) _i2
CROSS APPLY (SELECT i2 - (i2 / 28) * (1 - (i2 / 28) * (29 / (i2 + 1)) *
((21 - n) / 11)) AS i) _i
CROSS APPLY (SELECT y + y / 4 + i + 2 - c + c / 4 AS j1) _j1
CROSS APPLY (SELECT j1 - 7*(j1 / 7) AS j) _j
CROSS APPLY (SELECT i - j AS el) _el
CROSS APPLY (SELECT 3 + (el + 40) / 44 AS m) _m
CROSS APPLY (SELECT el + 28 - 31*(m / 4) AS d) _d
CROSS APPLY (SELECT DATEFROMPARTS(y, m, d) AS EasterSunday) _Easter
WHERE dimdate.DateValue = EasterSunday
Maybe you’re wondering how this performs. Take a look at the execution plan:
也许您想知道这是如何执行的。 看一下执行计划:
The SQL compiler has collapsed all those CROSS APPLYs into two compute scalar operations. The plan is a straight line. You can’t get much better than that!
SQL编译器已将所有这些CROSS APPLY折叠为两个计算标量运算。 该计划是一条直线。 你不能比这更好!
A minor point here is that the subquery aliases in the CROSS APPLY clauses are not used in this example. (Of course, you could use them, but that would reduce the readability, in the writer’s opinion.) When I’m not going to use an alias, I usually prefix it with an underscore. It’s a habit I picked up from years of programming in Python. In fact, if there is just one CROSS APPLY, a single underscore is all you need, if you’re not going to use the alias.
这里的一个小问题是,在此示例中未使用CROSS APPLY子句中的子查询别名。 (当然,您可以使用它们,但在作者看来,这会降低可读性。)当我不打算使用别名时,通常会在其下划线加一个下划线。 这是我从多年的Python编程中学到的习惯。 实际上,如果您不打算使用别名,那么只要有一个 CROSS APPLY,就只需要一个下划线即可。
摘要 (Summary)
Computing the dates of holidays is never my favorite thing to do. That’s why I like to push the work into a date table that’s built once and referenced by all. Also, remember the simple methods used for computing holidays that fall on certain weekdays. Just figure out the earliest and latest days of the month these can be and build that into your script. Alternatively, for holidays dependent on week numbers, use the simple method to compute the week number.
计算假期日期从来都不是我最喜欢的事情。 这就是为什么我喜欢将工作推送到一个仅建立一次并被所有人引用的日期表中的原因。 另外,请记住用于计算特定工作日内假期的简单方法。 只需弄清楚这些月的最早和最近几天,然后将其构建到脚本中即可。 另外,对于取决于周数的假期,请使用简单的方法来计算周数。
We’ve also shown how to use an inline tally table. These are very handy. Some DBAs like to put this into a view or function or even persist it to the database. However, you decide to use then, use them! You might even like to search your code for WHILE loops or CURSORs and see if you can eliminate them with tally tables.
我们还展示了如何使用内联计数表。 这些非常方便。 一些DBA喜欢将其放入视图或函数中,甚至将其持久化到数据库中。 但是,您决定使用,然后使用它们! 您甚至可能希望在代码中搜索WHILE循环或CURSOR,并查看是否可以使用计数表消除它们。
Finally, we showed how you can use CROSS APPLY to encapsulate expressions. This is a fantastic and somewhat underutilized technique for factoring code and making it easier to read and maintain. Always keep in mind that your code will be read – and probably altered – more times than it is written.
最后,我们展示了如何使用CROSS APPLY封装表达式。 这是一种出色的技术,但未充分利用,因此可以分解代码并使之易于阅读和维护。 始终牢记,您的代码将被读取(并且可能会被更改)比编写的次数更多。
翻译自: https://www.sqlshack.com/how-to-check-if-a-date-is-a-holiday-in-sql-server/