====== Crontab ====== This article provides an implementation of a ''[[https://en.wikipedia.org/wiki/Cron#Overview|crontab]]'' file's time/date specification expression in T-SQL. [[https://github.com/atifaziz/NCrontab/|NCrontab]] is a great (and better!) choice for this if you can use a .NET library, but if you can't do so inside SQL Server (maybe your DBAs don't like CLR functions, maybe you're [[https://docs.microsoft.com/en-gb/azure/sql-database/sql-database-transact-sql-information#transact-sql-syntax-not-supported-in-azure-sql-database|in Azure]]) then this may be of use. It has two component functions: * ''tvf_GetNextOccurrence'' takes a [[https://github.com/atifaziz/NCrontab/wiki/Crontab-Expression|crontab expression]] and returns a corresponding list of ''DATETIME'' values; * ''tvf_CrontabFieldValues'' is a helper function that parses individual date/time component fields into corresponding value lists. ===== tvf_GetNextOccurrence ===== Returns a list of ''DATETIME'' values corresponding to a ''crontab'' expression. Parameters: * ''@after'' -- lower bound for values returned; * ''@crontabExpr'' -- the ''crontab'' expression; * ''@limit'' -- the maximum number of occurrences to return. Passing these parameters into ''tvf_GetNextOccurrence'' returns a list of the first ''@limit'' dates & times indicated by ''@crontabExpr'' after ''@after''. /* * Return the next few start date/times indicated by a crontab expression * (c) Richard Swinbank 2019-2020 * http://richardswinbank.net */ CREATE FUNCTION [dbo].[tvf_GetNextOccurrence] ( @after DATETIME , @crontabExpr VARCHAR(255) , @limit INT = 1 ) RETURNS TABLE AS RETURN WITH fields AS ( -- split incoming expression into its five fields SELECT SUBSTRING( @crontabExpr , LAG(N, 1, 0) OVER (ORDER BY N) + 1 , N - LAG(N, 1, 0) OVER (ORDER BY N) - 1 ) AS FieldValue , CASE ROW_NUMBER() OVER (ORDER BY N) WHEN 1 THEN 'MINUTES' WHEN 2 THEN 'HOURS' WHEN 3 THEN 'DAYS' WHEN 4 THEN 'MONTHS' WHEN 5 THEN 'DAYS-OF-WEEK' END AS FieldName FROM dbo.TinyIntegers n WHERE SUBSTRING(REPLACE(@crontabExpr, ' ', 'x') + 'x', N, 1) = 'x' -- really we're looking for spaces, but turn them into 'x' because T-SQL doesn't always play nicely with them AND LEN(@crontabExpr) - LEN(REPLACE(@crontabExpr, ' ', '')) = 4 -- refuse to handle badly-formatted expressions ), [values] AS ( -- expand the list of fields into a list of corresponding field values SELECT f.FieldName , tvf.N AS [Value] FROM fields f CROSS APPLY tvf_CrontabFieldValues (FieldValue, FieldName) tvf ) SELECT TOP (@limit) validated.[ScheduledDateTime] AS NextOccurrence FROM ( SELECT [Value] AS [Minute] -- list of minutes expanded from @crontabExpr FROM [values] WHERE FieldName = 'MINUTES' ) m CROSS JOIN ( SELECT [Value] AS [Hour] -- list of hours expanded from @crontabExpr FROM [values] WHERE FieldName = 'HOURS' ) h CROSS JOIN ( SELECT [Value] AS [DayOfMonth] -- list of days-of-month expanded from @crontabExpr FROM [values] WHERE FieldName = 'DAYS' ) d CROSS JOIN ( SELECT [Value] AS [Month] -- list of months expanded from @crontabExpr FROM [values] WHERE FieldName = 'MONTHS' ) mth CROSS JOIN ( SELECT [Value] AS [DayOfWeek] -- list of days-of-week expanded from @crontabExpr FROM [values] WHERE FieldName = 'DAYS-OF-WEEK' ) w CROSS APPLY ( SELECT DATEADD(SECOND, -(DATEPART(SECOND, @after)), DATEADD(MILLISECOND, -(DATEPART(MILLISECOND, @after)), @after)) AS StartMinute ) cutoff CROSS APPLY ( -- Field value lists are CROSS JOINed above to generate all matching date patterns. -- Here we translate the patterns into dates, but use the 1st of the month instead -- of the day from the pattern (because we don't know at this point if the month -- has a day of that number; e.g. there is no February 30th). -- generate a date during this year SELECT DATETIMEFROMPARTS( DATEPART(YEAR, StartMinute) , mth.[Month] , 1 , h.[Hour] , m.[Minute] , 0, 0 ) AS [ScheduledDateTime] , d.[DayOfMonth] UNION ALL -- generate a date during next year (in case we're nearly there) SELECT DATETIMEFROMPARTS( DATEPART(YEAR, StartMinute) + 1 , mth.[Month] , 1 , h.[Hour] , m.[Minute] , 0, 0 ) AS [ScheduledDateTime] , d.[DayOfMonth] ) proto CROSS APPLY ( -- determine whether the each month has a day of the number from the pattern, and -- if so build it (this protects us from invalid DATETIMEFROMPARTS construction) SELECT CASE WHEN DATEPART(MONTH, proto.[ScheduledDateTime] - 1 + proto.[DayOfMonth]) = DATEPART(MONTH, proto.[ScheduledDateTime]) THEN proto.[ScheduledDateTime] - 1 + proto.[DayOfMonth] END AS [ScheduledDateTime] ) validated WHERE DATEPART(WEEKDAY, validated.[ScheduledDateTime]) = w.[DayOfWeek] + 1 -- crontab weekdays from 0 -- note that where day-of-month & weekday are both specified, this returns only dates matching *both* criteria AND validated.[ScheduledDateTime] >= StartMinute -- (also eliminates NULLs introduced by [validated]) ORDER BY NextOccurrence ===== tvf_CrontabFieldValues ===== {{tag> tsql}} Helper function for ''tvf_GetNextOccurrence''. Could do a better job at excluding badly-formatted fields -- this implementation accepts anything that simply contains no invalid characters. For example, ''1*'' in the ''HOURS'' field is accepted but expands (counterintuitively) to ''10-23'' (''1'', followed by ''*'' expanded to ''0-23''). /* * Translate a single crontab field into a corresponding list of values * (c) Richard Swinbank 2019-2020 * http://richardswinbank.net */ CREATE FUNCTION [dbo].[tvf_CrontabFieldValues] ( @fieldValue NVARCHAR(255) , @crontabField VARCHAR(25) ) RETURNS TABLE AS RETURN WITH ranges AS ( -- translate value ranges into min/max pairs SELECT TRY_CAST(IIF(CHARINDEX('-', rm.[Range]) > 0, LEFT(rm.[Range], CHARINDEX('-', rm.[Range]) - 1), rm.[Range]) AS INT) AS MinValue , TRY_CAST(IIF(CHARINDEX('-', rm.[Range]) > 0, RIGHT(rm.[Range], LEN(rm.[Range]) - CHARINDEX('-', rm.[Range])), rm.[Range]) AS INT) AS MaxValue , TRY_CAST([Modulo] AS INT) AS [Modulo] FROM ( -- split the "every nth iteration" value off (if any) SELECT IIF(CHARINDEX('/', c.FieldPart) > 0, LEFT(c.FieldPart, CHARINDEX('/', c.FieldPart) - 1), c.FieldPart) AS [Range] , IIF(CHARINDEX('/', c.FieldPart) > 0, RIGHT(c.FieldPart, LEN(c.FieldPart) - CHARINDEX('/', c.FieldPart)), NULL) AS [Modulo] FROM ( -- parse a comma-separated list into separate components SELECT REPLACE( SUBSTRING( @fieldValue , LAG(N, 1, 0) OVER (ORDER BY N) + 1 , N - LAG(N, 1, 0) OVER (ORDER BY N) - 1 ) , '*', '0-59') AS FieldPart FROM dbo.TinyIntegers WHERE SUBSTRING(@fieldValue + ',', N, 1) = ',' ) c ) rm ) SELECT DISTINCT n.N FROM ranges r INNER JOIN ( -- generate a list of all possible integers (0-59 is the largest range) SELECT TOP 60 N - 1 AS N FROM dbo.TinyIntegers ) n ON n.N BETWEEN r.MinValue AND r.MaxValue WHERE (r.[Modulo] IS NULL OR n.N % r.Modulo = 0) -- run every r.[Modulo] iterations AND n.N BETWEEN IIF(@crontabField IN ('DAYS', 'MONTHS'), 1, 0) AND CASE @crontabField WHEN 'MINUTES' THEN 59 WHEN 'HOURS' THEN 23 WHEN 'DAYS' THEN 31 WHEN 'MONTHS' THEN 12 WHEN 'DAYS-OF-WEEK' THEN 6 END -- run during intervals AND @fieldValue NOT LIKE '%[^0-9,\-/*]%' ESCAPE '\' -- refuse badly-formatted fields (mostly...) AND @fieldValue NOT LIKE '[\-/,]%' ESCAPE '\' AND @fieldValue NOT LIKE '%[\-/,]' ESCAPE '\' ===== What is dbo.TinyIntegers? ===== The code here makes use of a table called ''dbo.TinyIntegers''. This is a **small** [[numbers_table|numbers table]]: * column [N] is of type TINYINT * it contains integers from **1** to 255 (no zero!) CREATE TABLE dbo.TinyIntegers ( N TINYINT PRIMARY KEY )