Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions macros/create_udfs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% macro create_udfs() %}

create schema if not exists {{target.schema}};

{{ create_udf_business_days() }};
{{ create_udf_business_hours() }};

{% endmacro %}
30 changes: 29 additions & 1 deletion macros/macros.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,32 @@ This macro only seeks to add or update the tags which are specified in dbt. It w
If you need this behaviour, it usually comes naturally as dbt drops and recreates tables/views for most materializations.
If you are using the incremental materialization, be aware of this limitation.

{% enddocs %}
{% enddocs %}

{% docs create_udfs %}

Create_udfs is a macro that does exactly as it says, calls the individiual
UDFs that are nested in macros/udfs and makes them available to use in
your dbt transformations within Snowflake.

In order to facilitate the use of UDFs within your project, you must add
the following to your dbt_project.yml file;

on-run-start:
- '{{ create_udfs() }}'

Users must have the proper permissions to create UDFs, orchestrated
through the following command as an owner/administrator:
GRANT USAGE ON LANGUAGE PLPYTHONU TO <user>

Why/motivation:
While many complex transformations are possible with just SQL, creating
UDFs allows a project to begin encorporating python packages
to make certain transformations incredibly easy and efficient.
Additionally, leveraging UDFs via dbt macros allows for:
- the ability to version control,
- the ability to read transformations from your project without needing
to interact with Snowflake
- to maintain separate dev/prod versions of the UDFs.

{% enddocs %}
5 changes: 4 additions & 1 deletion macros/macros.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ macros:
description: '{{ doc("apply_meta_as_tags") }}'
arguments:
- name: results
description: The on-run-end context object
description: The on-run-end context object

- name: create_udfs
description: '{{ doc("create_udfs") }}'
28 changes: 28 additions & 0 deletions macros/udfs/business_days.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% macro create_udf_business_days() %}

create or replace function {{target.schema}}.udf_business_days(date1 TIMESTAMP_NTZ, date2 TIMESTAMP_NTZ, country STRING)
returns int
language python
runtime_version = 3.8
packages = ('numpy','holidays')
handler = 'business_days_py'
as

$$
import numpy as np
import holidays as holidays

def business_days_py(start_date, end_date, country='US'):
Comment thread
cris-seaton marked this conversation as resolved.
Outdated

# calculate business days between two dates
start_date = start_date.date()
end_date = end_date.date()
years = [*range(1990,2030)]

holiday_list = list(holidays.country_holidays(country, years=years))

return np.busday_count(start_date, end_date, holidays=holiday_list)

$$

{% endmacro %}
37 changes: 37 additions & 0 deletions macros/udfs/business_days.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
version: 2

macros:
- name: create_udf_business_days
description: >
This macro creates a UDF within the target schema that can be used to
calculate business days (excluding holidays) between two date fields
in a Snowflake table. The macro mearly creates the UDF within Snowflake,
and is called by the create_udfs macro. You must ensure that create_udfs
is called on-run-start in dbt_project.yml.

To utilize the UDF within your dbt project, proper syntax is:

select
*,
udf_business_days(start_date, end_date, country)
as business_days_elapsed
from sample_table

Notes:
- The date fields passed MUST be type == date.
- The country field is optional as it is defaulted to 'US'. If looking to
use a different country, must use the ISO 3166-1 alpha-2 country code
as defined here:
https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes
- This UDF only considers holidays between Jan 1 1990 and Dec 31 2030, as
is currently hardcoded. This can be changed in the macro source code.
- If increased granularity is desired for the region, you must modify the
macro to apply regional (state or province) holidays as described as
defined here:
holiday_list = holidays.country_holidays(country, subdiv='PR')
- The holidays python package does not include the end date in its
calculations of the duration. To account for this, you can add a day to
your end date to make the calculation inclusive.

docs:
show: false
61 changes: 61 additions & 0 deletions macros/udfs/business_hours.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{% macro create_udf_business_hours() %}

create or replace function {{target.schema}}.udf_business_hours(start_datetime TIMESTAMP_NTZ, end_datetime TIMESTAMP_NTZ, country STRING)
returns number
language python
runtime_version = 3.8
packages = ('numpy','holidays')
handler = 'business_hours_py'
as

$$
from datetime import datetime
from datetime import timedelta
import holidays
import numpy as np

def business_hours_py(start_datetime, end_datetime, country='US'):

# Year range for holidays spanning the years below
years = [*range(1990,2030)]

# Hard coded business hours, opening and closing times
workhours_per_day = 8
opening_hour = 9
closing_hour = 17

# Create open and closing datetimes to establish day 0 and day n durations
first_day_closed = start_datetime.replace(hour=closing_hour, minute=0, second=0)
last_day_opened = end_datetime.replace(hour=opening_hour, minute=0, second=0)

holiday_list = list(holidays.country_holidays(country, years=years))
days = np.busday_count(
start_datetime.date(),
end_datetime.date(),
holidays = holiday_list)

# Calculate hours for full business days (excluding first day and last day)
complete_days_hours = (days - 2) * workhours_per_day

# Jump forward to the next non-holiday weekday
while start_datetime.date() in holiday_list or start_datetime.weekday() in [5,6]:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After your review, going to talk to Peter about practicality on omitting weekdays. we could make it a boolean tag in the call, (i.e.

udf_business_hours(start_datetime, end_datetime, country, omit_weekends[Yes|No]) but it will complicate the code with more conditional if statements for the while loop L48 and the if statement L57

start_datetime = start_datetime.replace(
hour = opening_hour, minute = 0, second = 0
) + timedelta(days = 1)
Comment thread
cris-seaton marked this conversation as resolved.
Outdated

# Jump back to the last non-holiday weekday
while end_datetime.date() in holiday_list or end_datetime.weekday() in [5,6]:
end_datetime = end_datetime.replace(
hour = closing_hour, minute = 0, second = 0
) - timedelta(days = 1)

if start_datetime.date() == end_datetime.date():
return round((end_datetime - start_datetime).seconds/60/60,2)
else:
duration_day_zero = round((first_day_closed - start_datetime ).seconds/60/60, 2)
duration_day_n = round((end_datetime - last_day_opened).seconds/60/60, 2)
return round(duration_day_zero + duration_day_n + complete_days_hours, 2)

$$

{% endmacro %}
37 changes: 37 additions & 0 deletions macros/udfs/business_hours.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
version: 2

macros:
- name: create_udf_business_hours
description: >
This macro creates a UDF within the target schema that can be used to
calculate business hours (excluding holidays) between two date fields
in a Snowflake table. The macro mearly creates the UDF within Snowflake,
and is called by the create_udfs macro. You must ensure that create_udfs
is called on-run-start in dbt_project.yml.

To utilize the UDF within your dbt project, proper syntax is:

select
*,
udf_business_days(start_datetime, end_datetime, country)
as business_hours_elapsed
from sample_table

Notes:
- The date fields passed MUST be type == datetime (i.e. timestamp_ntz).
- The country field is optional as it is defaulted to 'US'. If looking to
use a different country, must use the ISO 3166-1 alpha-2 country code
as defined here:
https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes
- This UDF only considers holidays between Jan 1 1990 and Dec 31 2030, as
is currently hardcoded. This can be changed in the macro source code.
- If increased granularity is desired for the region, you must modify the
macro to apply regional (state or province) holidays as described as
defined here:
holiday_list = holidays.country_holidays(country, subdiv='PR')
- The holidays python package does not include the end date in its
calculations of the duration. To account for this, you can add a day to
your end date to make the calculation inclusive.

docs:
show: false