Holidays
Nobody agrees on holidays. There are the obvious international differences – the Fourth of July doesn’t have the same appeal to the UK that it does to the US, and most Americans have no idea what Bank holidays are, and why the banks deserve them. This can be a real problem in certain industries. The ocean freight and maritime industries publish a list of all the non-working holidays for every seaport on Earth. Air freight is not so bothered by this, but strong dock worker unions mean that if you show up in a port on the wrong day, you can pay extra docking fees.
Holidays fall into two types. Some can be computed from a rule and some are simply declared by an authority. The declared holidays are usually civic ones and they might change. The computed ones that involve lunar and/or solar cycles are mostly religious. You also have holidays that appear only once, such as a “National Day of Mourning” for the death of an important political figure.
But even the familiar holidays are harder to compute than you think. Every programmer is aware that Easter has a pretty elaborate formula. But nobody asks “Which Easter?” we are to compute. Orthodox and Catholic (which is used by Protestants) dates do not agree. For example, in 2008 the Catholic Easter was on March 10 and the Greek Orthodox Easter on April 14; but by 2010 they will both be on March 22. You can get a side by side comparison at Side-by-side Easter calendar reference for the 21st century.
Some holidays are longer than others. The obvious example is the Weekends. That usually means Saturdays and Sundays in Western countries. In the Islamic world, it means Fridays. And probably in my lifetime, we will probably see the four day work week in some countries. Then there is Hanukkah and Eid ul-Fitr, which last for several days.
In short, holidays are just too complicated and arbitrary to put in a function, VIEW or a query. I wish that there was even an ISO Standard for just classifying types of holidays. What this means is that you cannot help but use a Calendar table for holidays and you have to do most of the work for yourself. We will get to that in another section.
Leap Seconds and Trivia
Be grateful that most commercial work is done at the level of days and perhaps time of day, and not to the accuracy of decimal fractions of seconds. That leads to the choice of atomic versus the astronomical clocks, if you have to co-ordinate events at the nanosecond level. Scientific work has to cope with leap-seconds. These are positive or negative corrections defined by the International Earth Rotation and Reference System Service to keep the atomic clocks within 0.9 seconds of the planet’s actual rotation. They announced on 2008 July 4 that the world’s atomic clocks will be adding another second at the end of this year. I hope this does not spoil your New Years Eve plans.
Calendar Tables
The simplest Calendar is just a single column with the dates for a particular range. Since it is all key, we know that It is as normalized as it can get. Since there are 365.2422 days per year, a 100 year calendar is pretty small and would fit into main storage in most modern machines. It is also of pretty limited use without other data that we want to look up.
Since we already talked about holidays, the first impulse is to add a second column with a flag for “work day” or “holiday” to the table, something like this skeleton.
1 2 3 4 5 |
CREATE TABLE Calendar (cal_date DATE NOT NULL PRIMARY KEY, business_day_flg INTEGER DEFAULT 1 NOT NULL CHECK (business_day_flg IN (0,1)), ...); |
The most common calculations you need are the number of business days between two dates or the nearest business day to a given single date. You can do this with the integer flag and earlier SQLs with a skeleton like this:
1 2 3 4 |
SELECT SUM(C.business_day_flg) AS business_day_cnt FROM Calendar AS C, Foobar AS F WHERE C.cal_date BETWEEN F.foo_start_date AND F.foo_end_date AND ..; |
This interferes with the ability to get other data out of Foobar without adding GROUP BY
clauses. Today you can use the windowed aggregates functions – that is the new ones with an OVER()
clause.
1 2 3 4 5 6 |
SELECT F.*, -- use real column names in production code SUM(C.business_day_flg) OVER (ORDER BY C.cal_date) AS business_day_cnt FROM Calendar AS C, Foobar AS F WHERE C.cal_date BETWEEN F.foo_start_date AND F.foo_end_date AND ..; |
This is still not as good as we can make it. Let me take time out to discuss the Julian date, which is often misused. Julian date is technically a count of days used by astronomers for observations (http://en.wikipedia.org/wiki/Julian_date). It is a large integer number and has some rules for computing it. Only astronomers care about it, and the rest of us get it confused with a Julian-IZED date,
The Julianized date is a count of days from a base day, used in business. You see it as the “day within year” number on the bottom of the pages of a desk calendar. If your time frame is a calendar year, it will be between 1 and 365 or 366 days. It is called the ordinal day format (“yyyy-ddd”) in ISO-8601. But you can Julianize any time frame you wish such as the entire range covered by your Calendar table. Instead of a flag, you simply repeat the Julianized number for holidays and use integer math to get the counts. Here is a skeleton.
1 2 3 4 5 6 7 8 9 10 11 |
CREATE TABLE Calendar (cal_date DATE NOT NULL PRIMARY KEY, julian_business_nbr INTEGER NOT NULL, ...); INSERT INTO Calendar VALUES ('2007-04-05', 42); INSERT INTO Calendar VALUES ('2007-04-06', 43); -- Good Friday INSERT INTO Calendar VALUES ('2007-04-07', 43); INSERT INTO Calendar VALUES ('2007-04-08', 43); -- Easter Sunday INSERT INTO Calendar VALUES ('2007-04-09', 44); INSERT INTO Calendar VALUES ('2007-04-10', 45); -- Tuesday, back to work |
To compute the business days from ‘2007-04-05’, Thursday before Easter in 2007 to ‘2007-04-10’, the next Tuesday:
1 2 3 4 |
SELECT (C2.julian_business_nbr - C1.julian_business_nbr -1) AS business_day_cnt FROM Calendar AS C1, Calendar AS C2 WHERE C1.cal_date = '2007-04-05' AND C2.cal_date = '2007-04-10'; |
This saves having to scan all of the rows within the range to get a sum. The -1 is to avoid counting the current day as one of the business days, but you might have a different rule in your company.
Report Range Tables
A common application is have special reporting periods, perhaps based on the fiscal calendar or business defined events (“Annual Going out Of Business Sale!” was one of my favorites).
1 2 3 4 5 6 7 |
CREATE TABLE ReportRanges (report_name VARCHAR(30) NOT NULL PRIMARY KEY, report_start_date DATE DEFAULT CURRENT_TIMESTAMP NOT NULL, report_end_date DATE NOT NULL, CONSTRAINT date_ordering CHECK (report_start_date <= report_end_date), etc); |
These reports can overlap; a fiscal quarter will be contained in the range of its fiscal year. There can be gaps between them; we have to wait a year between each “Annual Going out Of Business Sale!” and there might be long stretches of time without any special sales. But we want to know their ranges so that the table is fairly constant once it is created.
The basic skeleton for use with these tables is
1 2 3 4 5 |
SELECT R.report_name, << summary computations >> FROM ReportRanges AS R, [Events] AS E WHERE E.event_date BETWEEN R.report_start_date AND report_end_date AND R.report_name IN (<<report name list>>) GROUP BY R.report_name; |
The <<report name list>> would probably be events nested inside each other, like fiscal quarters inside a fiscal year. While this is a useful and portable programming trick, you need to consider replacing it with the newer OLAP extensions to the GROUP BY
clause such as ROLLUP
and CUBE.
History Tables
Another form of calendar table records the history of events. You will see history tables used in Data Warehouses and queries dealing with things that have duration. If you go to the French Quarter in New Orleans, there is a lamp post with a historical plaque on it which gives the years of the Spanish domination, French domination, American domination, Confederate domination and finally, the second American domination. The last entry has a blank space to be filled in later and empty room on the plaque beneath that line.
Now look at the NULL
-able end_date in the skeleton that follows. It is useful because SQL does not have a token for “Eternity” to show that something is not yet finished like New Orleans. Using a dummy date in the distant future for “Eternity” has portability problems. And it can mess up temporal math if you don’t catch it in CASE
expressions. There should be, at most, one NULL
for each item in this inventory history to model the current price. It is also possible that we know a future ending data for an item – look at the expiration dates on food.
To make this easier to see, let me use a history table for price changes. So a basic history table looks like this skeleton:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
CREATE TABLE PriceHistory (ean CHAR(13) NOT NULL -- industry standard barcode REFERENCES Inventory(ean), price_prev_date DATE NOT NULL, price_start_date DATE DEFAULT CURRENT_TIMESTAMP NOT NULL, price_end_date DATE, -- NULL means current price item_price DECIMAL (12,4) NOT NULL CHECK (item_price > 0.0000), etc. -- now we add constraints --uniqueness PRIMARY KEY (ean, price_start_date), UNIQUE (ean, price_end_date), UNIQUE (ean, prev_end_date), --ordering of events CHECK (price_start_date <= price_end_date), -- Optionally, CHECK (price_start_date < price_end_date CHECK (prev_end_date <= price_start_date), -- Optionally, CHECK (price_start_date = price_prev_date + INTERVAL '1' DAY), -- prevents gaps -- self-referencing constraint CONSTRAINT ean_prev_end_date FOREIGN KEY (ean, prev_end_date) REFERENCES PriceHistory (ean, price_end_date) ); |
The first three uniqueness constraints are fairly obvious. The EAN
and the start of a price have to be unique and not NULL
-able so we have a natural primary key. The ending dates, current and previous, might have NULL
s, so we need to use a UNIQUE
constraint.
The next constraints give an ordering to each event, namely, price_prev_date
is on or before price_start_date
, which is on or before price_end_date
. The reason for not putting this into a single BETWEEN
predicate is that each constraint will have a name in production code that will show up in error messages so we want to be exact.
The self-referencing constraint is a trick from
It is also a good idea to have a VIEW with the current data:
1 2 3 4 5 6 7 |
CREATE VIEW CurrentPrices (..) AS SELECT .. FROM PriceHistory WHERE price_end_date IS NULL OR price_end_date >= CURRENT_TIMESTAMP; |
You use a BETWEEN
predicate to get the appropriate price for a given order on a particular date.
1 2 3 4 5 |
SELECT .. FROM PriceHistory AS H, Orders AS O WHERE O.sales_date BETWEEN H.price_start_date AND COALESCE (price_end_date, CURRENT_TIMESTAMP); -- or other known data as needed |
You can also enforce the “one NULL
per item” with a trigger. If your SQL has CREATE ASSERTION
or a full implementation of the CHECK()
constraints in SQL-92, you can do it with declarative code. But there is a sneaky trick that uses the WITH CHECK OPTION
on a VIEW
to fake it; here is the skeleton:
1 2 3 4 5 6 7 8 9 10 11 |
CREATE VIEW Prices (..) AS ( SELECT ... FROM PriceHistory AS P1 WHERE NOT EXISTS (SELECT * FROM PriceHistory AS P2 GROUP BY ean HAVING COUNT(*) - COUNT(price_end_date) > 1) ) WITH CHECK OPTION |
This forces you to update through the VIEW so that the WITH CHECK OPTION is always used. However, if you go directly to the base table, it can be circumvented. Now your problem is to write a stored procedure that will update the table and insert a new row. You can do this with a single MERGE statement, or with a short block of procedural code:
1 2 3 4 5 6 7 8 9 10 11 |
CREATE PROCEDURE UpdateItemPrice @in_ean CHAR(13), @new_item_price DECIMAL (12,4) AS -- LANGUAGE SQL BEGIN ATOMIC UPDATE PriceHistory SET price_end_date = CURRENT_TIMESTAMP WHERE ean = @in_ean; INSERT INTO PriceHistory (ean, price_prev_date, price_start_date, price_end_date, item_price) VALUES (@in_ean, CURRENT_TIMESTAMP, DATEADD(DAY,1,CURRENT_TIMESTAMP), NULL, @new_item_price); END |
This will make the price change go into effect tomorrow.
In his next article, Joe gets distracted from temporal data by reading the Manga Guide to Databases, and decides, instead, to write about relational division.
Load comments