From d76e0e459a9e9293df1d10728a2b3c4b79d4a2cb Mon Sep 17 00:00:00 2001 From: mlx93 Date: Mon, 24 Nov 2025 20:47:43 -0600 Subject: [PATCH] feat(mssql_compat): Add DATEDIFF extension for SQL Server compatibility Implements datediff(datepart, start_date, end_date) function per PRD1a/PRD1b specs: - Supports day, week, month, quarter, year dateparts with aliases - Hybrid calculation model: full calendar units + contextual fractions - Returns NUMERIC with 3 decimal precision (banker's rounding) - Handles DATE, TIMESTAMP, and TIMESTAMPTZ input types - Calendar alignment detection for whole-number returns - Negative span handling (start > end returns negative) - Case-insensitive datepart parsing - IMMUTABLE STRICT PARALLEL SAFE function attributes Files added: - contrib/mssql_compat/mssql_compat.c - C implementation (~750 lines) - contrib/mssql_compat/mssql_compat--1.0.sql - SQL function declarations - contrib/mssql_compat/mssql_compat.control - Extension metadata - contrib/mssql_compat/Makefile - PGXS build config - contrib/mssql_compat/meson.build - Meson build config - contrib/mssql_compat/sql/mssql_compat.sql - Regression tests - contrib/mssql_compat/expected/mssql_compat.out - Expected output - contrib/mssql_compat/sql/datediff_comprehensive_tests.sql - 50+ test cases All regression tests pass. --- contrib/meson.build | 1 + contrib/mssql_compat/Makefile | 21 + .../mssql_compat/expected/mssql_compat.out | 519 ++++++++++++ contrib/mssql_compat/meson.build | 36 + contrib/mssql_compat/mssql_compat--1.0.sql | 55 ++ contrib/mssql_compat/mssql_compat.c | 751 ++++++++++++++++++ contrib/mssql_compat/mssql_compat.control | 7 + contrib/mssql_compat/results/mssql_compat.out | 519 ++++++++++++ .../sql/datediff_comprehensive_tests.sql | 598 ++++++++++++++ contrib/mssql_compat/sql/mssql_compat.sql | 173 ++++ 10 files changed, 2680 insertions(+) create mode 100644 contrib/mssql_compat/Makefile create mode 100644 contrib/mssql_compat/expected/mssql_compat.out create mode 100644 contrib/mssql_compat/meson.build create mode 100644 contrib/mssql_compat/mssql_compat--1.0.sql create mode 100644 contrib/mssql_compat/mssql_compat.c create mode 100644 contrib/mssql_compat/mssql_compat.control create mode 100644 contrib/mssql_compat/results/mssql_compat.out create mode 100644 contrib/mssql_compat/sql/datediff_comprehensive_tests.sql create mode 100644 contrib/mssql_compat/sql/mssql_compat.sql diff --git a/contrib/meson.build b/contrib/meson.build index ed30ee7d639..6a48df8daf8 100644 --- a/contrib/meson.build +++ b/contrib/meson.build @@ -40,6 +40,7 @@ subdir('jsonb_plpython') subdir('lo') subdir('ltree') subdir('ltree_plpython') +subdir('mssql_compat') subdir('oid2name') subdir('pageinspect') subdir('passwordcheck') diff --git a/contrib/mssql_compat/Makefile b/contrib/mssql_compat/Makefile new file mode 100644 index 00000000000..7dbb3409703 --- /dev/null +++ b/contrib/mssql_compat/Makefile @@ -0,0 +1,21 @@ +# contrib/mssql_compat/Makefile + +MODULES = mssql_compat + +EXTENSION = mssql_compat +DATA = mssql_compat--1.0.sql +PGFILEDESC = "mssql_compat - SQL Server compatible date functions" + +REGRESS = mssql_compat + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = contrib/mssql_compat +top_builddir = ../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif + diff --git a/contrib/mssql_compat/expected/mssql_compat.out b/contrib/mssql_compat/expected/mssql_compat.out new file mode 100644 index 00000000000..3fd45c1ecc6 --- /dev/null +++ b/contrib/mssql_compat/expected/mssql_compat.out @@ -0,0 +1,519 @@ +-- +-- Test cases for mssql_compat extension +-- Covers PRD1a unit tests (UT-01 to UT-15) and edge cases (EC-01 to EC-06) +-- +CREATE EXTENSION mssql_compat; +-- +-- Basic Day Calculations (UT-01, UT-02) +-- +SELECT 'UT-01: Day difference basic' AS test; + test +----------------------------- + UT-01: Day difference basic +(1 row) + +SELECT datediff('day', '2024-01-01'::date, '2024-01-15'::date); + datediff +---------- + 14 +(1 row) + +SELECT 'UT-02: Day difference negative' AS test; + test +-------------------------------- + UT-02: Day difference negative +(1 row) + +SELECT datediff('day', '2024-01-15'::date, '2024-01-01'::date); + datediff +---------- + -14 +(1 row) + +-- +-- Week Calculations (UT-03, UT-04) +-- +SELECT 'UT-03: Week exact' AS test; + test +------------------- + UT-03: Week exact +(1 row) + +SELECT datediff('week', '2024-01-01'::date, '2024-01-08'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'UT-04: Week partial' AS test; + test +--------------------- + UT-04: Week partial +(1 row) + +SELECT datediff('week', '2024-01-01'::date, '2024-01-10'::date); + datediff +---------- + 1.286 +(1 row) + +-- +-- Month Calculations (UT-05, UT-06, UT-07) +-- +SELECT 'UT-05: Month aligned' AS test; + test +---------------------- + UT-05: Month aligned +(1 row) + +SELECT datediff('month', '2024-01-15'::date, '2024-02-15'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'UT-06: Month partial' AS test; + test +---------------------- + UT-06: Month partial +(1 row) + +SELECT datediff('month', '2024-01-15'::date, '2024-02-20'::date); + datediff +---------- + 1.172 +(1 row) + +SELECT 'UT-07: Month end-of-month alignment' AS test; + test +------------------------------------- + UT-07: Month end-of-month alignment +(1 row) + +SELECT datediff('month', '2024-01-31'::date, '2024-02-29'::date); + datediff +---------- + 1.000 +(1 row) + +-- +-- Quarter Calculations (UT-08, UT-09) +-- +SELECT 'UT-08: Quarter aligned' AS test; + test +------------------------ + UT-08: Quarter aligned +(1 row) + +SELECT datediff('quarter', '2024-01-01'::date, '2024-04-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'UT-09: Quarter partial' AS test; + test +------------------------ + UT-09: Quarter partial +(1 row) + +SELECT datediff('quarter', '2024-01-15'::date, '2024-05-20'::date); + datediff +---------- + 1.385 +(1 row) + +-- +-- Year Calculations (UT-10, UT-11) +-- +SELECT 'UT-10: Year aligned' AS test; + test +--------------------- + UT-10: Year aligned +(1 row) + +SELECT datediff('year', '2024-03-15'::date, '2025-03-15'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'UT-11: Year partial leap year' AS test; + test +------------------------------- + UT-11: Year partial leap year +(1 row) + +SELECT datediff('year', '2024-01-01'::date, '2024-07-01'::date); + datediff +---------- + 0.497 +(1 row) + +-- +-- NULL Handling (UT-12, UT-13) - STRICT functions return NULL for NULL inputs +-- +SELECT 'UT-12: NULL start date' AS test; + test +------------------------ + UT-12: NULL start date +(1 row) + +SELECT datediff('day', NULL::date, '2024-01-15'::date); + datediff +---------- + +(1 row) + +SELECT 'UT-13: NULL end date' AS test; + test +---------------------- + UT-13: NULL end date +(1 row) + +SELECT datediff('day', '2024-01-01'::date, NULL::date); + datediff +---------- + +(1 row) + +-- +-- Invalid Datepart (UT-14) +-- +SELECT 'UT-14: Invalid datepart' AS test; + test +------------------------- + UT-14: Invalid datepart +(1 row) + +SELECT datediff('hour', '2024-01-01'::date, '2024-01-02'::date); +ERROR: Invalid datepart: 'hour' +HINT: Valid options: year, quarter, month, week, day +-- +-- Case Insensitivity (UT-15) +-- +SELECT 'UT-15: Case insensitive datepart' AS test; + test +---------------------------------- + UT-15: Case insensitive datepart +(1 row) + +SELECT datediff('MONTH', '2024-01-01'::date, '2024-02-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT datediff('Month', '2024-01-01'::date, '2024-02-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT datediff('month', '2024-01-01'::date, '2024-02-01'::date); + datediff +---------- + 1.000 +(1 row) + +-- +-- Edge Cases (EC-01 to EC-06) +-- +SELECT 'EC-01: Same date' AS test; + test +------------------ + EC-01: Same date +(1 row) + +SELECT datediff('day', '2024-01-01'::date, '2024-01-01'::date); + datediff +---------- + 0 +(1 row) + +SELECT 'EC-02: Leap year February 29' AS test; + test +------------------------------ + EC-02: Leap year February 29 +(1 row) + +SELECT datediff('day', '2024-02-28'::date, '2024-03-01'::date); + datediff +---------- + 2 +(1 row) + +SELECT 'EC-03: Non-leap year February' AS test; + test +------------------------------- + EC-03: Non-leap year February +(1 row) + +SELECT datediff('day', '2023-02-28'::date, '2023-03-01'::date); + datediff +---------- + 1 +(1 row) + +SELECT 'EC-04: Year boundary' AS test; + test +---------------------- + EC-04: Year boundary +(1 row) + +SELECT datediff('year', '2024-12-31'::date, '2025-01-01'::date); + datediff +---------- + 0.003 +(1 row) + +SELECT 'EC-05: Multi-year span' AS test; + test +------------------------ + EC-05: Multi-year span +(1 row) + +SELECT datediff('year', '2020-01-01'::date, '2025-01-01'::date); + datediff +---------- + 5.000 +(1 row) + +SELECT 'EC-06: Century boundary' AS test; + test +------------------------- + EC-06: Century boundary +(1 row) + +SELECT datediff('day', '1999-12-31'::date, '2000-01-01'::date); + datediff +---------- + 1 +(1 row) + +-- +-- Alias Tests (from PRD1b lines 224-230) +-- +SELECT 'Alias: yy for year' AS test; + test +-------------------- + Alias: yy for year +(1 row) + +SELECT datediff('yy', '2024-01-01'::date, '2025-01-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: yyyy for year' AS test; + test +---------------------- + Alias: yyyy for year +(1 row) + +SELECT datediff('yyyy', '2024-01-01'::date, '2025-01-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: mm for month' AS test; + test +--------------------- + Alias: mm for month +(1 row) + +SELECT datediff('mm', '2024-01-15'::date, '2024-02-15'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: qq for quarter' AS test; + test +----------------------- + Alias: qq for quarter +(1 row) + +SELECT datediff('qq', '2024-01-01'::date, '2024-04-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: wk for week' AS test; + test +-------------------- + Alias: wk for week +(1 row) + +SELECT datediff('wk', '2024-01-01'::date, '2024-01-08'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: dd for day' AS test; + test +------------------- + Alias: dd for day +(1 row) + +SELECT datediff('dd', '2024-01-01'::date, '2024-01-15'::date); + datediff +---------- + 14 +(1 row) + +-- +-- Timestamp Tests +-- +SELECT 'Timestamp: basic day diff' AS test; + test +--------------------------- + Timestamp: basic day diff +(1 row) + +SELECT datediff('day', '2024-01-01 10:30:00'::timestamp, '2024-01-15 14:45:00'::timestamp); + datediff +---------- + 14 +(1 row) + +SELECT 'Timestamp: month diff' AS test; + test +----------------------- + Timestamp: month diff +(1 row) + +SELECT datediff('month', '2024-01-15 08:00:00'::timestamp, '2024-02-20 16:00:00'::timestamp); + datediff +---------- + 1.172 +(1 row) + +-- +-- Timestamptz Tests +-- +SELECT 'Timestamptz: basic day diff' AS test; + test +----------------------------- + Timestamptz: basic day diff +(1 row) + +SELECT datediff('day', '2024-01-01 10:30:00+00'::timestamptz, '2024-01-15 14:45:00+00'::timestamptz); + datediff +---------- + 14 +(1 row) + +-- +-- Additional Month Calculation Tests +-- +SELECT 'Month: Jan 25 to Mar 10 (PRD walkthrough example)' AS test; + test +--------------------------------------------------- + Month: Jan 25 to Mar 10 (PRD walkthrough example) +(1 row) + +SELECT datediff('month', '2024-01-25'::date, '2024-03-10'::date); + datediff +---------- + 1.483 +(1 row) + +SELECT 'Month: subscription proration example' AS test; + test +--------------------------------------- + Month: subscription proration example +(1 row) + +SELECT datediff('month', '2024-01-15'::date, '2024-02-20'::date); + datediff +---------- + 1.172 +(1 row) + +-- +-- Additional Quarter Calculation Tests +-- +SELECT 'Quarter: PRD walkthrough example' AS test; + test +---------------------------------- + Quarter: PRD walkthrough example +(1 row) + +SELECT datediff('quarter', '2024-01-15'::date, '2024-05-20'::date); + datediff +---------- + 1.385 +(1 row) + +-- +-- Additional Year Calculation Tests +-- +SELECT 'Year: PRD walkthrough example' AS test; + test +------------------------------- + Year: PRD walkthrough example +(1 row) + +SELECT datediff('year', '2024-03-15'::date, '2025-06-20'::date); + datediff +---------- + 1.266 +(1 row) + +SELECT 'Year: exact 5-year tenure' AS test; + test +--------------------------- + Year: exact 5-year tenure +(1 row) + +SELECT datediff('year', '2020-03-15'::date, '2025-03-15'::date); + datediff +---------- + 5.000 +(1 row) + +SELECT 'Year: leap year partial (182 days / 366)' AS test; + test +------------------------------------------ + Year: leap year partial (182 days / 366) +(1 row) + +SELECT datediff('year', '2024-01-01'::date, '2024-07-01'::date); + datediff +---------- + 0.497 +(1 row) + +-- +-- Week Calculation Additional Tests +-- +SELECT 'Week: exact 2 weeks' AS test; + test +--------------------- + Week: exact 2 weeks +(1 row) + +SELECT datediff('week', '2024-01-01'::date, '2024-01-15'::date); + datediff +---------- + 2.000 +(1 row) + +SELECT 'Week: PRD example 9 days' AS test; + test +-------------------------- + Week: PRD example 9 days +(1 row) + +SELECT datediff('week', '2024-01-01'::date, '2024-01-10'::date); + datediff +---------- + 1.286 +(1 row) + +DROP EXTENSION mssql_compat; diff --git a/contrib/mssql_compat/meson.build b/contrib/mssql_compat/meson.build new file mode 100644 index 00000000000..d1a412ed879 --- /dev/null +++ b/contrib/mssql_compat/meson.build @@ -0,0 +1,36 @@ +# Copyright (c) 2022-2025, PostgreSQL Global Development Group + +mssql_compat_sources = files( + 'mssql_compat.c', +) + +if host_system == 'windows' + mssql_compat_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'mssql_compat', + '--FILEDESC', 'mssql_compat - SQL Server compatible date functions',]) +endif + +mssql_compat = shared_module('mssql_compat', + mssql_compat_sources, + kwargs: contrib_mod_args, +) + +contrib_targets += mssql_compat + +install_data( + 'mssql_compat--1.0.sql', + 'mssql_compat.control', + kwargs: contrib_data_args, +) + +tests += { + 'name': 'mssql_compat', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'mssql_compat', + ], + }, +} + diff --git a/contrib/mssql_compat/mssql_compat--1.0.sql b/contrib/mssql_compat/mssql_compat--1.0.sql new file mode 100644 index 00000000000..8b950cb4cc4 --- /dev/null +++ b/contrib/mssql_compat/mssql_compat--1.0.sql @@ -0,0 +1,55 @@ +/* contrib/mssql_compat/mssql_compat--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION mssql_compat" to load this file. \quit + +-- +-- datediff(datepart, start_date, end_date) - SQL Server compatible date difference +-- +-- Returns the difference between two dates in the specified datepart unit. +-- Supports: year, quarter, month, week, day (and aliases) +-- +-- Unlike SQL Server's boundary-crossing semantics, this implementation provides +-- mathematically accurate results using a hybrid calculation model: full calendar +-- units plus contextual fractions based on actual period lengths. +-- + +-- Date version +CREATE FUNCTION datediff( + datepart TEXT, + start_date DATE, + end_date DATE +) +RETURNS NUMERIC +AS 'MODULE_PATHNAME', 'datediff_date' +LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE; + +COMMENT ON FUNCTION datediff(TEXT, DATE, DATE) IS +'Calculate the difference between two dates in the specified unit (year, quarter, month, week, day)'; + +-- Timestamp version +CREATE FUNCTION datediff( + datepart TEXT, + start_ts TIMESTAMP, + end_ts TIMESTAMP +) +RETURNS NUMERIC +AS 'MODULE_PATHNAME', 'datediff_timestamp' +LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE; + +COMMENT ON FUNCTION datediff(TEXT, TIMESTAMP, TIMESTAMP) IS +'Calculate the difference between two timestamps in the specified unit (year, quarter, month, week, day)'; + +-- Timestamptz version +CREATE FUNCTION datediff( + datepart TEXT, + start_tstz TIMESTAMPTZ, + end_tstz TIMESTAMPTZ +) +RETURNS NUMERIC +AS 'MODULE_PATHNAME', 'datediff_timestamptz' +LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE; + +COMMENT ON FUNCTION datediff(TEXT, TIMESTAMPTZ, TIMESTAMPTZ) IS +'Calculate the difference between two timestamps with timezone in the specified unit (year, quarter, month, week, day)'; + diff --git a/contrib/mssql_compat/mssql_compat.c b/contrib/mssql_compat/mssql_compat.c new file mode 100644 index 00000000000..1db22f37982 --- /dev/null +++ b/contrib/mssql_compat/mssql_compat.c @@ -0,0 +1,751 @@ +/*------------------------------------------------------------------------- + * + * mssql_compat.c + * SQL Server compatible datediff function for PostgreSQL. + * + * This extension provides datediff(datepart, start_date, end_date) which + * calculates the difference between two dates using a hybrid calculation + * model: full calendar units plus contextual fractions based on actual + * period lengths. + * + * Copyright (c) 2024, PostgreSQL Global Development Group + * + * contrib/mssql_compat/mssql_compat.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#include + +#include "datatype/timestamp.h" +#include "fmgr.h" +#include "utils/builtins.h" +#include "utils/date.h" +#include "utils/datetime.h" +#include "utils/numeric.h" +#include "utils/timestamp.h" + +PG_MODULE_MAGIC_EXT( + .name = "mssql_compat", + .version = PG_VERSION +); + +/* + * Datepart enumeration for routing calculation logic + */ +typedef enum +{ + DATEPART_DAY, + DATEPART_WEEK, + DATEPART_MONTH, + DATEPART_QUARTER, + DATEPART_YEAR, + DATEPART_INVALID +} DatepartType; + +/* + * parse_datepart - convert datepart string to enum + * + * Performs case-insensitive comparison and handles aliases. + * Returns DATEPART_INVALID for unrecognized input. + */ +static DatepartType +parse_datepart(const char *datepart_str) +{ + char lower[32]; + int i; + + /* Convert to lowercase for comparison */ + for (i = 0; datepart_str[i] && i < 31; i++) + lower[i] = tolower((unsigned char) datepart_str[i]); + lower[i] = '\0'; + + /* Match canonical names and aliases per PRD1b lines 224-230 */ + if (strcmp(lower, "year") == 0 || + strcmp(lower, "yy") == 0 || + strcmp(lower, "yyyy") == 0 || + strcmp(lower, "y") == 0 || + strcmp(lower, "years") == 0) + return DATEPART_YEAR; + + if (strcmp(lower, "quarter") == 0 || + strcmp(lower, "qq") == 0 || + strcmp(lower, "q") == 0 || + strcmp(lower, "quarters") == 0) + return DATEPART_QUARTER; + + if (strcmp(lower, "month") == 0 || + strcmp(lower, "mm") == 0 || + strcmp(lower, "m") == 0 || + strcmp(lower, "months") == 0) + return DATEPART_MONTH; + + if (strcmp(lower, "week") == 0 || + strcmp(lower, "wk") == 0 || + strcmp(lower, "ww") == 0 || + strcmp(lower, "w") == 0 || + strcmp(lower, "weeks") == 0) + return DATEPART_WEEK; + + if (strcmp(lower, "day") == 0 || + strcmp(lower, "dd") == 0 || + strcmp(lower, "d") == 0 || + strcmp(lower, "days") == 0) + return DATEPART_DAY; + + return DATEPART_INVALID; +} + +/* + * days_in_month_helper - get days in a specific month + * + * Uses PostgreSQL's day_tab array (per PRD1b lines 235-252) + * month is 1-based (1=January, 12=December) + */ +static int +days_in_month_helper(int year, int month) +{ + return day_tab[isleap(year) ? 1 : 0][month - 1]; +} + +/* + * is_end_of_month - check if day is the last day of its month + */ +static bool +is_end_of_month(int year, int month, int day) +{ + return day == days_in_month_helper(year, month); +} + +/* + * days_in_quarter - get total days in a specific quarter + * + * Quarter is 1-4. + * Q1: Jan+Feb+Mar, Q2: Apr+May+Jun, Q3: Jul+Aug+Sep, Q4: Oct+Nov+Dec + */ +static int +days_in_quarter(int year, int quarter) +{ + int first_month = (quarter - 1) * 3 + 1; + int days = 0; + int i; + + for (i = 0; i < 3; i++) + days += days_in_month_helper(year, first_month + i); + + return days; +} + +/* + * day_of_quarter - get day position within a quarter (1-92) + */ +static int +day_of_quarter(int year, int month, int day) +{ + int quarter = (month - 1) / 3 + 1; + int first_month = (quarter - 1) * 3 + 1; + int days = 0; + int m; + + /* Sum days in complete months before this month within the quarter */ + for (m = first_month; m < month; m++) + days += days_in_month_helper(year, m); + + return days + day; +} + +/* + * bankers_round - round to 3 decimal places using HALF_EVEN (banker's rounding) + * + * Per PRD1a FR-7: "Decimal results SHALL be rounded to exactly 3 decimal places + * using HALF_EVEN (banker's) rounding" + */ +static double +bankers_round(double value) +{ + double scaled = value * 1000.0; + double integer_part; + double frac = modf(scaled, &integer_part); + int64 int_val = (int64) integer_part; + + /* + * Banker's rounding: round half to even + * If fraction is exactly 0.5, round to nearest even number + */ + if (fabs(frac) == 0.5) + { + /* Round to even */ + if (int_val % 2 == 0) + scaled = integer_part; /* Already even, truncate */ + else + scaled = integer_part + (value >= 0 ? 1.0 : -1.0); /* Round away */ + } + else + { + /* Standard rounding */ + scaled = round(scaled); + } + + return scaled / 1000.0; +} + +/* + * make_numeric_result - convert double to NUMERIC with 3 decimal places + * + * Uses string conversion approach as per PRD1b lines 167-174 + */ +static Datum +make_numeric_result(double value) +{ + char result_str[32]; + Datum result; + + snprintf(result_str, sizeof(result_str), "%.3f", value); + result = DirectFunctionCall3(numeric_in, + CStringGetDatum(result_str), + ObjectIdGetDatum(InvalidOid), + Int32GetDatum(-1)); + return result; +} + +/* + * compute_diff_day - calculate day difference + * + * Simple subtraction, returns whole number as NUMERIC. + */ +static Datum +compute_diff_day(int start_y, int start_m, int start_d, + int end_y, int end_m, int end_d) +{ + int start_jd = date2j(start_y, start_m, start_d); + int end_jd = date2j(end_y, end_m, end_d); + int64 diff = (int64) end_jd - (int64) start_jd; + + return NumericGetDatum(int64_to_numeric(diff)); +} + +/* + * compute_diff_week - calculate week difference + * + * Total days / 7, rounded to 3 decimal places. + */ +static Datum +compute_diff_week(int start_y, int start_m, int start_d, + int end_y, int end_m, int end_d) +{ + int start_jd = date2j(start_y, start_m, start_d); + int end_jd = date2j(end_y, end_m, end_d); + int64 days = (int64) end_jd - (int64) start_jd; + double weeks = (double) days / 7.0; + + return make_numeric_result(bankers_round(weeks)); +} + +/* + * compute_diff_month - calculate month difference using hybrid model + * + * Per PRD1a FR-3/FR-4: + * - Aligned dates (same day-of-month or both end-of-month) return whole numbers + * - Non-aligned: full months + (remaining days / days in partial period) + */ +static Datum +compute_diff_month(int start_y, int start_m, int start_d, + int end_y, int end_m, int end_d) +{ + bool negated = false; + int full_months; + int remaining_days; + int partial_period_days; + double result; + bool start_eom; + bool end_eom; + bool aligned; + int anniversary_y, anniversary_m, anniversary_d; + int anniversary_jd, end_jd; + + /* Handle negative spans by swapping and negating result (FR-6) */ + if (start_y > end_y || + (start_y == end_y && start_m > end_m) || + (start_y == end_y && start_m == end_m && start_d > end_d)) + { + int tmp_y = start_y, tmp_m = start_m, tmp_d = start_d; + + start_y = end_y; + start_m = end_m; + start_d = end_d; + end_y = tmp_y; + end_m = tmp_m; + end_d = tmp_d; + negated = true; + } + + /* Check for calendar alignment (FR-4) */ + start_eom = is_end_of_month(start_y, start_m, start_d); + end_eom = is_end_of_month(end_y, end_m, end_d); + aligned = (start_d == end_d) || (start_eom && end_eom); + + /* Calculate full months */ + full_months = (end_y - start_y) * 12 + (end_m - start_m); + + if (aligned) + { + /* Aligned dates return whole numbers */ + result = (double) full_months; + } + else + { + /* + * Find the last "anniversary" before or on end_date. + * Anniversary is the same day-of-month as start_d, or end-of-month + * if start was end-of-month. + */ + if (end_d < start_d) + full_months--; + + if (full_months < 0) + full_months = 0; + + /* Calculate anniversary date */ + anniversary_y = start_y + (start_m + full_months - 1) / 12; + anniversary_m = ((start_m - 1 + full_months) % 12) + 1; + + /* + * Handle case where start_d doesn't exist in anniversary month + * (e.g., Jan 31 -> Feb has no 31st) + */ + if (start_d > days_in_month_helper(anniversary_y, anniversary_m)) + anniversary_d = days_in_month_helper(anniversary_y, anniversary_m); + else + anniversary_d = start_d; + + /* Calculate remaining days after anniversary */ + anniversary_jd = date2j(anniversary_y, anniversary_m, anniversary_d); + end_jd = date2j(end_y, end_m, end_d); + remaining_days = end_jd - anniversary_jd; + + /* + * Calculate partial period length (days from anniversary to next + * anniversary) + */ + { + int next_anniversary_y = anniversary_y + (anniversary_m) / 12; + int next_anniversary_m = (anniversary_m % 12) + 1; + int next_anniversary_d; + int next_anniversary_jd; + + if (start_d > days_in_month_helper(next_anniversary_y, next_anniversary_m)) + next_anniversary_d = days_in_month_helper(next_anniversary_y, next_anniversary_m); + else + next_anniversary_d = start_d; + + next_anniversary_jd = date2j(next_anniversary_y, next_anniversary_m, next_anniversary_d); + partial_period_days = next_anniversary_jd - anniversary_jd; + } + + if (partial_period_days <= 0) + partial_period_days = 1; /* Safety guard */ + + result = (double) full_months + (double) remaining_days / (double) partial_period_days; + } + + if (negated) + result = -result; + + return make_numeric_result(bankers_round(result)); +} + +/* + * compute_diff_quarter - calculate quarter difference using hybrid model + * + * Similar to month but with quarter-based periods. + */ +static Datum +compute_diff_quarter(int start_y, int start_m, int start_d, + int end_y, int end_m, int end_d) +{ + bool negated = false; + int start_quarter, end_quarter; + int start_day_of_qtr, end_day_of_qtr; + int full_quarters; + int remaining_days; + int partial_period_days; + double result; + + /* Handle negative spans */ + if (start_y > end_y || + (start_y == end_y && start_m > end_m) || + (start_y == end_y && start_m == end_m && start_d > end_d)) + { + int tmp_y = start_y, tmp_m = start_m, tmp_d = start_d; + + start_y = end_y; + start_m = end_m; + start_d = end_d; + end_y = tmp_y; + end_m = tmp_m; + end_d = tmp_d; + negated = true; + } + + start_quarter = (start_m - 1) / 3 + 1; + end_quarter = (end_m - 1) / 3 + 1; + start_day_of_qtr = day_of_quarter(start_y, start_m, start_d); + end_day_of_qtr = day_of_quarter(end_y, end_m, end_d); + + /* Calculate full quarters */ + full_quarters = (end_y - start_y) * 4 + (end_quarter - start_quarter); + + /* Check alignment: same day-of-quarter position */ + if (start_day_of_qtr == end_day_of_qtr) + { + result = (double) full_quarters; + } + else + { + /* + * Non-aligned: find anniversary (same position in quarter), calculate + * remaining days + */ + int anniversary_y, anniversary_quarter, anniversary_m, anniversary_d; + int anniversary_jd, end_jd; + /* Adjust full_quarters if end is before anniversary position */ + if (end_day_of_qtr < start_day_of_qtr) + full_quarters--; + + if (full_quarters < 0) + full_quarters = 0; + + /* Calculate anniversary date */ + anniversary_quarter = start_quarter + full_quarters; + anniversary_y = start_y + (anniversary_quarter - 1) / 4; + anniversary_quarter = ((anniversary_quarter - 1) % 4) + 1; + + /* Convert day-of-quarter back to month and day */ + { + int first_month = (anniversary_quarter - 1) * 3 + 1; + int days_remaining = start_day_of_qtr; + int m; + bool found = false; + + anniversary_m = first_month; + anniversary_d = 1; /* Default initialization */ + for (m = first_month; m <= first_month + 2 && days_remaining > 0; m++) + { + int days_in_m = days_in_month_helper(anniversary_y, m); + + if (days_remaining <= days_in_m) + { + anniversary_m = m; + anniversary_d = days_remaining; + found = true; + break; + } + days_remaining -= days_in_m; + } + + /* Handle overflow (day position exceeds quarter length) */ + if (!found) + { + anniversary_m = first_month + 2; + anniversary_d = days_in_month_helper(anniversary_y, anniversary_m); + } + } + + /* Ensure anniversary_d is valid */ + if (anniversary_d > days_in_month_helper(anniversary_y, anniversary_m)) + anniversary_d = days_in_month_helper(anniversary_y, anniversary_m); + + anniversary_jd = date2j(anniversary_y, anniversary_m, anniversary_d); + end_jd = date2j(end_y, end_m, end_d); + remaining_days = end_jd - anniversary_jd; + + /* Partial period is the quarter containing the anniversary */ + partial_period_days = days_in_quarter(anniversary_y, anniversary_quarter); + + if (partial_period_days <= 0) + partial_period_days = 1; + + result = (double) full_quarters + (double) remaining_days / (double) partial_period_days; + } + + if (negated) + result = -result; + + return make_numeric_result(bankers_round(result)); +} + +/* + * compute_diff_year - calculate year difference using hybrid model + * + * Similar to month but with year-based periods. + */ +static Datum +compute_diff_year(int start_y, int start_m, int start_d, + int end_y, int end_m, int end_d) +{ + bool negated = false; + int full_years; + int remaining_days; + int partial_period_days; + double result; + bool aligned; + int anniversary_y, anniversary_m, anniversary_d; + int anniversary_jd, end_jd; + + /* Handle negative spans */ + if (start_y > end_y || + (start_y == end_y && start_m > end_m) || + (start_y == end_y && start_m == end_m && start_d > end_d)) + { + int tmp_y = start_y, tmp_m = start_m, tmp_d = start_d; + + start_y = end_y; + start_m = end_m; + start_d = end_d; + end_y = tmp_y; + end_m = tmp_m; + end_d = tmp_d; + negated = true; + } + + /* Check alignment: same month and day, or Feb 29 -> Feb 28 in non-leap */ + aligned = (start_m == end_m && start_d == end_d); + + /* Special case: Feb 29 in leap year aligns with Feb 28 in non-leap */ + if (!aligned && start_m == 2 && start_d == 29 && end_m == 2 && end_d == 28) + { + if (!isleap(end_y)) + aligned = true; + } + if (!aligned && start_m == 2 && start_d == 28 && end_m == 2 && end_d == 29) + { + if (!isleap(start_y)) + aligned = true; + } + + /* Calculate full years */ + full_years = end_y - start_y; + if (end_m < start_m || (end_m == start_m && end_d < start_d)) + full_years--; + + if (full_years < 0) + full_years = 0; + + if (aligned && full_years > 0) + { + result = (double) full_years; + } + else if (aligned && full_years == 0 && end_y > start_y) + { + /* Exact one year */ + result = 1.0; + } + else if (start_y == end_y && start_m == end_m && start_d == end_d) + { + /* Same date */ + result = 0.0; + } + else + { + /* Non-aligned: calculate fractional part */ + anniversary_y = start_y + full_years; + anniversary_m = start_m; + + /* Handle Feb 29 when anniversary year is not a leap year */ + if (start_m == 2 && start_d == 29 && !isleap(anniversary_y)) + anniversary_d = 28; + else if (start_d > days_in_month_helper(anniversary_y, anniversary_m)) + anniversary_d = days_in_month_helper(anniversary_y, anniversary_m); + else + anniversary_d = start_d; + + anniversary_jd = date2j(anniversary_y, anniversary_m, anniversary_d); + end_jd = date2j(end_y, end_m, end_d); + remaining_days = end_jd - anniversary_jd; + + /* + * Partial period: days from anniversary to next anniversary The + * period uses the year that contains the partial span + */ + { + int next_anniversary_y = anniversary_y + 1; + int next_anniversary_m = anniversary_m; + int next_anniversary_d; + int next_anniversary_jd; + + if (start_m == 2 && start_d == 29 && !isleap(next_anniversary_y)) + next_anniversary_d = 28; + else if (start_d > days_in_month_helper(next_anniversary_y, next_anniversary_m)) + next_anniversary_d = days_in_month_helper(next_anniversary_y, next_anniversary_m); + else + next_anniversary_d = start_d; + + next_anniversary_jd = date2j(next_anniversary_y, next_anniversary_m, next_anniversary_d); + partial_period_days = next_anniversary_jd - anniversary_jd; + } + + if (partial_period_days <= 0) + partial_period_days = 1; + + result = (double) full_years + (double) remaining_days / (double) partial_period_days; + } + + if (negated) + result = -result; + + return make_numeric_result(bankers_round(result)); +} + +/* + * datediff_internal - core calculation dispatcher + * + * Takes year, month, day for both dates and computes the difference + * based on the specified datepart. + */ +static Datum +datediff_internal(const char *datepart_str, + int start_y, int start_m, int start_d, + int end_y, int end_m, int end_d) +{ + DatepartType datepart = parse_datepart(datepart_str); + + /* Validate datepart (FR-2) */ + if (datepart == DATEPART_INVALID) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("Invalid datepart: '%s'", datepart_str), + errhint("Valid options: year, quarter, month, week, day"))); + } + + /* Dispatch to appropriate calculator */ + switch (datepart) + { + case DATEPART_DAY: + return compute_diff_day(start_y, start_m, start_d, + end_y, end_m, end_d); + case DATEPART_WEEK: + return compute_diff_week(start_y, start_m, start_d, + end_y, end_m, end_d); + case DATEPART_MONTH: + return compute_diff_month(start_y, start_m, start_d, + end_y, end_m, end_d); + case DATEPART_QUARTER: + return compute_diff_quarter(start_y, start_m, start_d, + end_y, end_m, end_d); + case DATEPART_YEAR: + return compute_diff_year(start_y, start_m, start_d, + end_y, end_m, end_d); + default: + /* Should not reach here */ + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("Unexpected datepart type"))); + return (Datum) 0; /* Keep compiler happy */ + } +} + +/*------------------------------------------------------------------------- + * Public Entry Points + *------------------------------------------------------------------------- + */ + +PG_FUNCTION_INFO_V1(datediff_date); + +/* + * datediff_date - DATE version of datediff + */ +Datum +datediff_date(PG_FUNCTION_ARGS) +{ + text *datepart_text = PG_GETARG_TEXT_PP(0); + DateADT start_date = PG_GETARG_DATEADT(1); + DateADT end_date = PG_GETARG_DATEADT(2); + char *datepart_str; + int start_y, start_m, start_d; + int end_y, end_m, end_d; + + datepart_str = text_to_cstring(datepart_text); + + /* Convert dates to year/month/day using j2date */ + j2date(start_date + POSTGRES_EPOCH_JDATE, &start_y, &start_m, &start_d); + j2date(end_date + POSTGRES_EPOCH_JDATE, &end_y, &end_m, &end_d); + + return datediff_internal(datepart_str, + start_y, start_m, start_d, + end_y, end_m, end_d); +} + +PG_FUNCTION_INFO_V1(datediff_timestamp); + +/* + * datediff_timestamp - TIMESTAMP version of datediff + * + * Ignores time component, uses only date portion. + */ +Datum +datediff_timestamp(PG_FUNCTION_ARGS) +{ + text *datepart_text = PG_GETARG_TEXT_PP(0); + Timestamp start_ts = PG_GETARG_TIMESTAMP(1); + Timestamp end_ts = PG_GETARG_TIMESTAMP(2); + char *datepart_str; + struct pg_tm start_tm, end_tm; + fsec_t fsec; + + datepart_str = text_to_cstring(datepart_text); + + /* Decompose timestamps to get date components */ + if (timestamp2tm(start_ts, NULL, &start_tm, &fsec, NULL, NULL) != 0) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("timestamp out of range"))); + + if (timestamp2tm(end_ts, NULL, &end_tm, &fsec, NULL, NULL) != 0) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("timestamp out of range"))); + + return datediff_internal(datepart_str, + start_tm.tm_year, start_tm.tm_mon, start_tm.tm_mday, + end_tm.tm_year, end_tm.tm_mon, end_tm.tm_mday); +} + +PG_FUNCTION_INFO_V1(datediff_timestamptz); + +/* + * datediff_timestamptz - TIMESTAMPTZ version of datediff + * + * Converts to local time then uses date portion. + */ +Datum +datediff_timestamptz(PG_FUNCTION_ARGS) +{ + text *datepart_text = PG_GETARG_TEXT_PP(0); + TimestampTz start_tstz = PG_GETARG_TIMESTAMPTZ(1); + TimestampTz end_tstz = PG_GETARG_TIMESTAMPTZ(2); + char *datepart_str; + struct pg_tm start_tm, end_tm; + fsec_t fsec; + int tz; + + datepart_str = text_to_cstring(datepart_text); + + /* Decompose timestamps with timezone to get date components */ + if (timestamp2tm(start_tstz, &tz, &start_tm, &fsec, NULL, NULL) != 0) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("timestamp out of range"))); + + if (timestamp2tm(end_tstz, &tz, &end_tm, &fsec, NULL, NULL) != 0) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("timestamp out of range"))); + + return datediff_internal(datepart_str, + start_tm.tm_year, start_tm.tm_mon, start_tm.tm_mday, + end_tm.tm_year, end_tm.tm_mon, end_tm.tm_mday); +} + diff --git a/contrib/mssql_compat/mssql_compat.control b/contrib/mssql_compat/mssql_compat.control new file mode 100644 index 00000000000..cf669a4eaee --- /dev/null +++ b/contrib/mssql_compat/mssql_compat.control @@ -0,0 +1,7 @@ +# mssql_compat extension +comment = 'SQL Server compatible datediff function' +default_version = '1.0' +module_pathname = '$libdir/mssql_compat' +relocatable = true +trusted = true + diff --git a/contrib/mssql_compat/results/mssql_compat.out b/contrib/mssql_compat/results/mssql_compat.out new file mode 100644 index 00000000000..3fd45c1ecc6 --- /dev/null +++ b/contrib/mssql_compat/results/mssql_compat.out @@ -0,0 +1,519 @@ +-- +-- Test cases for mssql_compat extension +-- Covers PRD1a unit tests (UT-01 to UT-15) and edge cases (EC-01 to EC-06) +-- +CREATE EXTENSION mssql_compat; +-- +-- Basic Day Calculations (UT-01, UT-02) +-- +SELECT 'UT-01: Day difference basic' AS test; + test +----------------------------- + UT-01: Day difference basic +(1 row) + +SELECT datediff('day', '2024-01-01'::date, '2024-01-15'::date); + datediff +---------- + 14 +(1 row) + +SELECT 'UT-02: Day difference negative' AS test; + test +-------------------------------- + UT-02: Day difference negative +(1 row) + +SELECT datediff('day', '2024-01-15'::date, '2024-01-01'::date); + datediff +---------- + -14 +(1 row) + +-- +-- Week Calculations (UT-03, UT-04) +-- +SELECT 'UT-03: Week exact' AS test; + test +------------------- + UT-03: Week exact +(1 row) + +SELECT datediff('week', '2024-01-01'::date, '2024-01-08'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'UT-04: Week partial' AS test; + test +--------------------- + UT-04: Week partial +(1 row) + +SELECT datediff('week', '2024-01-01'::date, '2024-01-10'::date); + datediff +---------- + 1.286 +(1 row) + +-- +-- Month Calculations (UT-05, UT-06, UT-07) +-- +SELECT 'UT-05: Month aligned' AS test; + test +---------------------- + UT-05: Month aligned +(1 row) + +SELECT datediff('month', '2024-01-15'::date, '2024-02-15'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'UT-06: Month partial' AS test; + test +---------------------- + UT-06: Month partial +(1 row) + +SELECT datediff('month', '2024-01-15'::date, '2024-02-20'::date); + datediff +---------- + 1.172 +(1 row) + +SELECT 'UT-07: Month end-of-month alignment' AS test; + test +------------------------------------- + UT-07: Month end-of-month alignment +(1 row) + +SELECT datediff('month', '2024-01-31'::date, '2024-02-29'::date); + datediff +---------- + 1.000 +(1 row) + +-- +-- Quarter Calculations (UT-08, UT-09) +-- +SELECT 'UT-08: Quarter aligned' AS test; + test +------------------------ + UT-08: Quarter aligned +(1 row) + +SELECT datediff('quarter', '2024-01-01'::date, '2024-04-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'UT-09: Quarter partial' AS test; + test +------------------------ + UT-09: Quarter partial +(1 row) + +SELECT datediff('quarter', '2024-01-15'::date, '2024-05-20'::date); + datediff +---------- + 1.385 +(1 row) + +-- +-- Year Calculations (UT-10, UT-11) +-- +SELECT 'UT-10: Year aligned' AS test; + test +--------------------- + UT-10: Year aligned +(1 row) + +SELECT datediff('year', '2024-03-15'::date, '2025-03-15'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'UT-11: Year partial leap year' AS test; + test +------------------------------- + UT-11: Year partial leap year +(1 row) + +SELECT datediff('year', '2024-01-01'::date, '2024-07-01'::date); + datediff +---------- + 0.497 +(1 row) + +-- +-- NULL Handling (UT-12, UT-13) - STRICT functions return NULL for NULL inputs +-- +SELECT 'UT-12: NULL start date' AS test; + test +------------------------ + UT-12: NULL start date +(1 row) + +SELECT datediff('day', NULL::date, '2024-01-15'::date); + datediff +---------- + +(1 row) + +SELECT 'UT-13: NULL end date' AS test; + test +---------------------- + UT-13: NULL end date +(1 row) + +SELECT datediff('day', '2024-01-01'::date, NULL::date); + datediff +---------- + +(1 row) + +-- +-- Invalid Datepart (UT-14) +-- +SELECT 'UT-14: Invalid datepart' AS test; + test +------------------------- + UT-14: Invalid datepart +(1 row) + +SELECT datediff('hour', '2024-01-01'::date, '2024-01-02'::date); +ERROR: Invalid datepart: 'hour' +HINT: Valid options: year, quarter, month, week, day +-- +-- Case Insensitivity (UT-15) +-- +SELECT 'UT-15: Case insensitive datepart' AS test; + test +---------------------------------- + UT-15: Case insensitive datepart +(1 row) + +SELECT datediff('MONTH', '2024-01-01'::date, '2024-02-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT datediff('Month', '2024-01-01'::date, '2024-02-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT datediff('month', '2024-01-01'::date, '2024-02-01'::date); + datediff +---------- + 1.000 +(1 row) + +-- +-- Edge Cases (EC-01 to EC-06) +-- +SELECT 'EC-01: Same date' AS test; + test +------------------ + EC-01: Same date +(1 row) + +SELECT datediff('day', '2024-01-01'::date, '2024-01-01'::date); + datediff +---------- + 0 +(1 row) + +SELECT 'EC-02: Leap year February 29' AS test; + test +------------------------------ + EC-02: Leap year February 29 +(1 row) + +SELECT datediff('day', '2024-02-28'::date, '2024-03-01'::date); + datediff +---------- + 2 +(1 row) + +SELECT 'EC-03: Non-leap year February' AS test; + test +------------------------------- + EC-03: Non-leap year February +(1 row) + +SELECT datediff('day', '2023-02-28'::date, '2023-03-01'::date); + datediff +---------- + 1 +(1 row) + +SELECT 'EC-04: Year boundary' AS test; + test +---------------------- + EC-04: Year boundary +(1 row) + +SELECT datediff('year', '2024-12-31'::date, '2025-01-01'::date); + datediff +---------- + 0.003 +(1 row) + +SELECT 'EC-05: Multi-year span' AS test; + test +------------------------ + EC-05: Multi-year span +(1 row) + +SELECT datediff('year', '2020-01-01'::date, '2025-01-01'::date); + datediff +---------- + 5.000 +(1 row) + +SELECT 'EC-06: Century boundary' AS test; + test +------------------------- + EC-06: Century boundary +(1 row) + +SELECT datediff('day', '1999-12-31'::date, '2000-01-01'::date); + datediff +---------- + 1 +(1 row) + +-- +-- Alias Tests (from PRD1b lines 224-230) +-- +SELECT 'Alias: yy for year' AS test; + test +-------------------- + Alias: yy for year +(1 row) + +SELECT datediff('yy', '2024-01-01'::date, '2025-01-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: yyyy for year' AS test; + test +---------------------- + Alias: yyyy for year +(1 row) + +SELECT datediff('yyyy', '2024-01-01'::date, '2025-01-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: mm for month' AS test; + test +--------------------- + Alias: mm for month +(1 row) + +SELECT datediff('mm', '2024-01-15'::date, '2024-02-15'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: qq for quarter' AS test; + test +----------------------- + Alias: qq for quarter +(1 row) + +SELECT datediff('qq', '2024-01-01'::date, '2024-04-01'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: wk for week' AS test; + test +-------------------- + Alias: wk for week +(1 row) + +SELECT datediff('wk', '2024-01-01'::date, '2024-01-08'::date); + datediff +---------- + 1.000 +(1 row) + +SELECT 'Alias: dd for day' AS test; + test +------------------- + Alias: dd for day +(1 row) + +SELECT datediff('dd', '2024-01-01'::date, '2024-01-15'::date); + datediff +---------- + 14 +(1 row) + +-- +-- Timestamp Tests +-- +SELECT 'Timestamp: basic day diff' AS test; + test +--------------------------- + Timestamp: basic day diff +(1 row) + +SELECT datediff('day', '2024-01-01 10:30:00'::timestamp, '2024-01-15 14:45:00'::timestamp); + datediff +---------- + 14 +(1 row) + +SELECT 'Timestamp: month diff' AS test; + test +----------------------- + Timestamp: month diff +(1 row) + +SELECT datediff('month', '2024-01-15 08:00:00'::timestamp, '2024-02-20 16:00:00'::timestamp); + datediff +---------- + 1.172 +(1 row) + +-- +-- Timestamptz Tests +-- +SELECT 'Timestamptz: basic day diff' AS test; + test +----------------------------- + Timestamptz: basic day diff +(1 row) + +SELECT datediff('day', '2024-01-01 10:30:00+00'::timestamptz, '2024-01-15 14:45:00+00'::timestamptz); + datediff +---------- + 14 +(1 row) + +-- +-- Additional Month Calculation Tests +-- +SELECT 'Month: Jan 25 to Mar 10 (PRD walkthrough example)' AS test; + test +--------------------------------------------------- + Month: Jan 25 to Mar 10 (PRD walkthrough example) +(1 row) + +SELECT datediff('month', '2024-01-25'::date, '2024-03-10'::date); + datediff +---------- + 1.483 +(1 row) + +SELECT 'Month: subscription proration example' AS test; + test +--------------------------------------- + Month: subscription proration example +(1 row) + +SELECT datediff('month', '2024-01-15'::date, '2024-02-20'::date); + datediff +---------- + 1.172 +(1 row) + +-- +-- Additional Quarter Calculation Tests +-- +SELECT 'Quarter: PRD walkthrough example' AS test; + test +---------------------------------- + Quarter: PRD walkthrough example +(1 row) + +SELECT datediff('quarter', '2024-01-15'::date, '2024-05-20'::date); + datediff +---------- + 1.385 +(1 row) + +-- +-- Additional Year Calculation Tests +-- +SELECT 'Year: PRD walkthrough example' AS test; + test +------------------------------- + Year: PRD walkthrough example +(1 row) + +SELECT datediff('year', '2024-03-15'::date, '2025-06-20'::date); + datediff +---------- + 1.266 +(1 row) + +SELECT 'Year: exact 5-year tenure' AS test; + test +--------------------------- + Year: exact 5-year tenure +(1 row) + +SELECT datediff('year', '2020-03-15'::date, '2025-03-15'::date); + datediff +---------- + 5.000 +(1 row) + +SELECT 'Year: leap year partial (182 days / 366)' AS test; + test +------------------------------------------ + Year: leap year partial (182 days / 366) +(1 row) + +SELECT datediff('year', '2024-01-01'::date, '2024-07-01'::date); + datediff +---------- + 0.497 +(1 row) + +-- +-- Week Calculation Additional Tests +-- +SELECT 'Week: exact 2 weeks' AS test; + test +--------------------- + Week: exact 2 weeks +(1 row) + +SELECT datediff('week', '2024-01-01'::date, '2024-01-15'::date); + datediff +---------- + 2.000 +(1 row) + +SELECT 'Week: PRD example 9 days' AS test; + test +-------------------------- + Week: PRD example 9 days +(1 row) + +SELECT datediff('week', '2024-01-01'::date, '2024-01-10'::date); + datediff +---------- + 1.286 +(1 row) + +DROP EXTENSION mssql_compat; diff --git a/contrib/mssql_compat/sql/datediff_comprehensive_tests.sql b/contrib/mssql_compat/sql/datediff_comprehensive_tests.sql new file mode 100644 index 00000000000..a763d958a62 --- /dev/null +++ b/contrib/mssql_compat/sql/datediff_comprehensive_tests.sql @@ -0,0 +1,598 @@ +-- +-- Comprehensive DATEDIFF Function Tests +-- 50+ tests covering all permutations and edge cases +-- + +-- Setup: Create extension +DROP EXTENSION IF EXISTS mssql_compat CASCADE; +CREATE EXTENSION mssql_compat; + +-- ============================================================================ +-- SECTION 1: Test Data Setup +-- ============================================================================ + +DROP TABLE IF EXISTS date_test_data; +CREATE TABLE date_test_data ( + id SERIAL PRIMARY KEY, + description TEXT, + start_date DATE, + end_date DATE, + start_ts TIMESTAMP, + end_ts TIMESTAMP, + start_tstz TIMESTAMPTZ, + end_tstz TIMESTAMPTZ +); + +-- Insert comprehensive test data +INSERT INTO date_test_data (description, start_date, end_date, start_ts, end_ts, start_tstz, end_tstz) VALUES +-- Basic date ranges +('Same day', '2024-06-15', '2024-06-15', '2024-06-15 10:00:00', '2024-06-15 18:00:00', '2024-06-15 10:00:00+00', '2024-06-15 18:00:00+00'), +('One day apart', '2024-06-15', '2024-06-16', '2024-06-15 00:00:00', '2024-06-16 00:00:00', '2024-06-15 00:00:00+00', '2024-06-16 00:00:00+00'), +('One week apart', '2024-06-01', '2024-06-08', '2024-06-01 12:00:00', '2024-06-08 12:00:00', '2024-06-01 12:00:00+00', '2024-06-08 12:00:00+00'), +('One month apart (same day)', '2024-05-15', '2024-06-15', '2024-05-15 08:30:00', '2024-06-15 08:30:00', '2024-05-15 08:30:00+00', '2024-06-15 08:30:00+00'), +('One quarter apart', '2024-01-01', '2024-04-01', '2024-01-01 00:00:00', '2024-04-01 00:00:00', '2024-01-01 00:00:00+00', '2024-04-01 00:00:00+00'), +('One year apart', '2023-06-15', '2024-06-15', '2023-06-15 15:45:00', '2024-06-15 15:45:00', '2023-06-15 15:45:00+00', '2024-06-15 15:45:00+00'), + +-- Leap year scenarios +('Leap year Feb 28 to Mar 1', '2024-02-28', '2024-03-01', '2024-02-28 00:00:00', '2024-03-01 00:00:00', '2024-02-28 00:00:00+00', '2024-03-01 00:00:00+00'), +('Leap year Feb 29 exists', '2024-02-29', '2024-03-01', '2024-02-29 00:00:00', '2024-03-01 00:00:00', '2024-02-29 00:00:00+00', '2024-03-01 00:00:00+00'), +('Non-leap year Feb 28 to Mar 1', '2023-02-28', '2023-03-01', '2023-02-28 00:00:00', '2023-03-01 00:00:00', '2023-02-28 00:00:00+00', '2023-03-01 00:00:00+00'), +('Leap year full year', '2024-01-01', '2025-01-01', '2024-01-01 00:00:00', '2025-01-01 00:00:00', '2024-01-01 00:00:00+00', '2025-01-01 00:00:00+00'), + +-- End of month scenarios +('Jan 31 to Feb 28 (non-leap)', '2023-01-31', '2023-02-28', '2023-01-31 00:00:00', '2023-02-28 00:00:00', '2023-01-31 00:00:00+00', '2023-02-28 00:00:00+00'), +('Jan 31 to Feb 29 (leap)', '2024-01-31', '2024-02-29', '2024-01-31 00:00:00', '2024-02-29 00:00:00', '2024-01-31 00:00:00+00', '2024-02-29 00:00:00+00'), +('Mar 31 to Apr 30', '2024-03-31', '2024-04-30', '2024-03-31 00:00:00', '2024-04-30 00:00:00', '2024-03-31 00:00:00+00', '2024-04-30 00:00:00+00'), +('Month end to month end chain', '2024-01-31', '2024-05-31', '2024-01-31 00:00:00', '2024-05-31 00:00:00', '2024-01-31 00:00:00+00', '2024-05-31 00:00:00+00'), + +-- Negative spans (start > end) +('Negative: 2 weeks back', '2024-06-22', '2024-06-08', '2024-06-22 00:00:00', '2024-06-08 00:00:00', '2024-06-22 00:00:00+00', '2024-06-08 00:00:00+00'), +('Negative: 3 months back', '2024-09-15', '2024-06-15', '2024-09-15 00:00:00', '2024-06-15 00:00:00', '2024-09-15 00:00:00+00', '2024-06-15 00:00:00+00'), +('Negative: 2 years back', '2026-01-01', '2024-01-01', '2026-01-01 00:00:00', '2024-01-01 00:00:00', '2026-01-01 00:00:00+00', '2024-01-01 00:00:00+00'), + +-- Year boundary crossings +('Cross year boundary', '2024-12-31', '2025-01-01', '2024-12-31 23:59:59', '2025-01-01 00:00:01', '2024-12-31 23:59:59+00', '2025-01-01 00:00:01+00'), +('Cross multiple years', '2020-06-15', '2024-06-15', '2020-06-15 00:00:00', '2024-06-15 00:00:00', '2020-06-15 00:00:00+00', '2024-06-15 00:00:00+00'), +('Century boundary', '1999-12-31', '2000-01-01', '1999-12-31 00:00:00', '2000-01-01 00:00:00', '1999-12-31 00:00:00+00', '2000-01-01 00:00:00+00'), + +-- Partial periods +('Partial month mid-month', '2024-01-15', '2024-02-20', '2024-01-15 00:00:00', '2024-02-20 00:00:00', '2024-01-15 00:00:00+00', '2024-02-20 00:00:00+00'), +('Partial quarter', '2024-01-15', '2024-05-20', '2024-01-15 00:00:00', '2024-05-20 00:00:00', '2024-01-15 00:00:00+00', '2024-05-20 00:00:00+00'), +('Partial year', '2024-03-15', '2025-06-20', '2024-03-15 00:00:00', '2025-06-20 00:00:00', '2024-03-15 00:00:00+00', '2025-06-20 00:00:00+00'), + +-- Employee tenure scenarios +('Employee 90-day probation', '2024-01-15', '2024-04-14', '2024-01-15 09:00:00', '2024-04-14 17:00:00', '2024-01-15 09:00:00+00', '2024-04-14 17:00:00+00'), +('Employee 5-year anniversary', '2019-03-01', '2024-03-01', '2019-03-01 00:00:00', '2024-03-01 00:00:00', '2019-03-01 00:00:00+00', '2024-03-01 00:00:00+00'), +('Employee 10-year tenure', '2014-06-15', '2024-06-15', '2014-06-15 08:00:00', '2024-06-15 08:00:00', '2014-06-15 08:00:00+00', '2024-06-15 08:00:00+00'), + +-- Billing/subscription scenarios +('Monthly subscription', '2024-01-01', '2024-01-31', '2024-01-01 00:00:00', '2024-01-31 23:59:59', '2024-01-01 00:00:00+00', '2024-01-31 23:59:59+00'), +('Quarterly billing', '2024-01-01', '2024-03-31', '2024-01-01 00:00:00', '2024-03-31 00:00:00', '2024-01-01 00:00:00+00', '2024-03-31 00:00:00+00'), +('Annual subscription', '2023-07-15', '2024-07-15', '2023-07-15 00:00:00', '2024-07-15 00:00:00', '2023-07-15 00:00:00+00', '2024-07-15 00:00:00+00'), +('Prorated mid-month cancel', '2024-03-01', '2024-03-18', '2024-03-01 00:00:00', '2024-03-18 00:00:00', '2024-03-01 00:00:00+00', '2024-03-18 00:00:00+00'), + +-- Large spans +('Decade span', '2010-01-01', '2020-01-01', '2010-01-01 00:00:00', '2020-01-01 00:00:00', '2010-01-01 00:00:00+00', '2020-01-01 00:00:00+00'), +('25 years span', '1999-06-15', '2024-06-15', '1999-06-15 00:00:00', '2024-06-15 00:00:00', '1999-06-15 00:00:00+00', '2024-06-15 00:00:00+00'); + +-- ============================================================================ +-- SECTION 2: DAY Datepart Tests (Tests 1-8) +-- ============================================================================ + +SELECT '=== DAY DATEPART TESTS ===' AS section; + +-- Test 1: Basic day difference +SELECT 'Test 1: Basic day difference' AS test_name, + datediff('day', '2024-01-01', '2024-01-15') AS result, + 14 AS expected, + CASE WHEN datediff('day', '2024-01-01', '2024-01-15') = 14 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 2: Day difference using 'dd' alias +SELECT 'Test 2: Day alias dd' AS test_name, + datediff('dd', '2024-01-01', '2024-01-15') AS result, + 14 AS expected, + CASE WHEN datediff('dd', '2024-01-01', '2024-01-15') = 14 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 3: Day difference using 'd' alias +SELECT 'Test 3: Day alias d' AS test_name, + datediff('d', '2024-03-01', '2024-03-31') AS result, + 30 AS expected, + CASE WHEN datediff('d', '2024-03-01', '2024-03-31') = 30 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 4: Day difference using 'days' alias +SELECT 'Test 4: Day alias days' AS test_name, + datediff('days', '2024-06-01', '2024-06-30') AS result, + 29 AS expected, + CASE WHEN datediff('days', '2024-06-01', '2024-06-30') = 29 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 5: Negative day difference +SELECT 'Test 5: Negative day difference' AS test_name, + datediff('day', '2024-01-15', '2024-01-01') AS result, + -14 AS expected, + CASE WHEN datediff('day', '2024-01-15', '2024-01-01') = -14 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 6: Same day returns 0 +SELECT 'Test 6: Same day returns 0' AS test_name, + datediff('day', '2024-06-15', '2024-06-15') AS result, + 0 AS expected, + CASE WHEN datediff('day', '2024-06-15', '2024-06-15') = 0 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 7: Leap year February (28th to Mar 1st) +SELECT 'Test 7: Leap year Feb 28 to Mar 1' AS test_name, + datediff('day', '2024-02-28', '2024-03-01') AS result, + 2 AS expected, + CASE WHEN datediff('day', '2024-02-28', '2024-03-01') = 2 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 8: Non-leap year February +SELECT 'Test 8: Non-leap year Feb 28 to Mar 1' AS test_name, + datediff('day', '2023-02-28', '2023-03-01') AS result, + 1 AS expected, + CASE WHEN datediff('day', '2023-02-28', '2023-03-01') = 1 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- ============================================================================ +-- SECTION 3: WEEK Datepart Tests (Tests 9-15) +-- ============================================================================ + +SELECT '=== WEEK DATEPART TESTS ===' AS section; + +-- Test 9: Exact 1 week +SELECT 'Test 9: Exact 1 week' AS test_name, + datediff('week', '2024-01-01', '2024-01-08') AS result, + 1.000 AS expected, + CASE WHEN datediff('week', '2024-01-01', '2024-01-08') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 10: Exact 2 weeks +SELECT 'Test 10: Exact 2 weeks' AS test_name, + datediff('week', '2024-01-01', '2024-01-15') AS result, + 2.000 AS expected, + CASE WHEN datediff('week', '2024-01-01', '2024-01-15') = 2.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 11: Partial week (9 days = 1.286 weeks) +SELECT 'Test 11: Partial week 9 days' AS test_name, + datediff('week', '2024-01-01', '2024-01-10') AS result, + 1.286 AS expected, + CASE WHEN datediff('week', '2024-01-01', '2024-01-10') = 1.286 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 12: Week alias 'wk' +SELECT 'Test 12: Week alias wk' AS test_name, + datediff('wk', '2024-01-01', '2024-01-08') AS result, + 1.000 AS expected, + CASE WHEN datediff('wk', '2024-01-01', '2024-01-08') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 13: Week alias 'ww' +SELECT 'Test 13: Week alias ww' AS test_name, + datediff('ww', '2024-01-01', '2024-01-22') AS result, + 3.000 AS expected, + CASE WHEN datediff('ww', '2024-01-01', '2024-01-22') = 3.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 14: Week alias 'weeks' +SELECT 'Test 14: Week alias weeks' AS test_name, + datediff('weeks', '2024-02-01', '2024-02-29') AS result, + 4.000 AS expected, + CASE WHEN datediff('weeks', '2024-02-01', '2024-02-29') = 4.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 15: Negative weeks +SELECT 'Test 15: Negative weeks' AS test_name, + datediff('week', '2024-01-15', '2024-01-01') AS result, + -2.000 AS expected, + CASE WHEN datediff('week', '2024-01-15', '2024-01-01') = -2.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- ============================================================================ +-- SECTION 4: MONTH Datepart Tests (Tests 16-25) +-- ============================================================================ + +SELECT '=== MONTH DATEPART TESTS ===' AS section; + +-- Test 16: Aligned month (same day-of-month) +SELECT 'Test 16: Aligned month same day' AS test_name, + datediff('month', '2024-01-15', '2024-02-15') AS result, + 1.000 AS expected, + CASE WHEN datediff('month', '2024-01-15', '2024-02-15') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 17: Partial month +SELECT 'Test 17: Partial month' AS test_name, + datediff('month', '2024-01-15', '2024-02-20') AS result, + 1.172 AS expected, + CASE WHEN datediff('month', '2024-01-15', '2024-02-20') = 1.172 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 18: End-of-month alignment (Jan 31 -> Feb 29) +SELECT 'Test 18: End-of-month alignment' AS test_name, + datediff('month', '2024-01-31', '2024-02-29') AS result, + 1.000 AS expected, + CASE WHEN datediff('month', '2024-01-31', '2024-02-29') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 19: Month alias 'mm' +SELECT 'Test 19: Month alias mm' AS test_name, + datediff('mm', '2024-01-01', '2024-02-01') AS result, + 1.000 AS expected, + CASE WHEN datediff('mm', '2024-01-01', '2024-02-01') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 20: Month alias 'm' +SELECT 'Test 20: Month alias m' AS test_name, + datediff('m', '2024-03-15', '2024-06-15') AS result, + 3.000 AS expected, + CASE WHEN datediff('m', '2024-03-15', '2024-06-15') = 3.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 21: Month alias 'months' +SELECT 'Test 21: Month alias months' AS test_name, + datediff('months', '2024-01-01', '2024-07-01') AS result, + 6.000 AS expected, + CASE WHEN datediff('months', '2024-01-01', '2024-07-01') = 6.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 22: Multiple months with partial +SELECT 'Test 22: Multiple months partial' AS test_name, + datediff('month', '2024-01-25', '2024-03-10') AS result, + 1.483 AS expected, + CASE WHEN datediff('month', '2024-01-25', '2024-03-10') = 1.483 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 23: Negative months +SELECT 'Test 23: Negative months' AS test_name, + datediff('month', '2024-06-15', '2024-03-15') AS result, + -3.000 AS expected, + CASE WHEN datediff('month', '2024-06-15', '2024-03-15') = -3.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 24: Month spanning year boundary +SELECT 'Test 24: Month across year boundary' AS test_name, + datediff('month', '2024-11-15', '2025-02-15') AS result, + 3.000 AS expected, + CASE WHEN datediff('month', '2024-11-15', '2025-02-15') = 3.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 25: Less than one month +SELECT 'Test 25: Less than one month' AS test_name, + datediff('month', '2024-01-01', '2024-01-15') AS result, + 0.452 AS expected, -- 14 days / 31 days in January + CASE WHEN datediff('month', '2024-01-01', '2024-01-15') BETWEEN 0.450 AND 0.460 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- ============================================================================ +-- SECTION 5: QUARTER Datepart Tests (Tests 26-33) +-- ============================================================================ + +SELECT '=== QUARTER DATEPART TESTS ===' AS section; + +-- Test 26: Exact quarter aligned +SELECT 'Test 26: Exact quarter aligned' AS test_name, + datediff('quarter', '2024-01-01', '2024-04-01') AS result, + 1.000 AS expected, + CASE WHEN datediff('quarter', '2024-01-01', '2024-04-01') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 27: Partial quarter +SELECT 'Test 27: Partial quarter' AS test_name, + datediff('quarter', '2024-01-15', '2024-05-20') AS result, + 1.385 AS expected, + CASE WHEN datediff('quarter', '2024-01-15', '2024-05-20') = 1.385 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 28: Quarter alias 'qq' +SELECT 'Test 28: Quarter alias qq' AS test_name, + datediff('qq', '2024-01-01', '2024-07-01') AS result, + 2.000 AS expected, + CASE WHEN datediff('qq', '2024-01-01', '2024-07-01') = 2.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 29: Quarter alias 'q' +SELECT 'Test 29: Quarter alias q' AS test_name, + datediff('q', '2024-01-01', '2024-10-01') AS result, + 3.000 AS expected, + CASE WHEN datediff('q', '2024-01-01', '2024-10-01') = 3.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 30: Quarter alias 'quarters' +SELECT 'Test 30: Quarter alias quarters' AS test_name, + datediff('quarters', '2024-01-01', '2025-01-01') AS result, + 4.000 AS expected, + CASE WHEN datediff('quarters', '2024-01-01', '2025-01-01') = 4.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 31: Negative quarters +SELECT 'Test 31: Negative quarters' AS test_name, + datediff('quarter', '2024-10-01', '2024-04-01') AS result, + -2.000 AS expected, + CASE WHEN datediff('quarter', '2024-10-01', '2024-04-01') = -2.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 32: Less than one quarter +SELECT 'Test 32: Less than one quarter' AS test_name, + datediff('quarter', '2024-01-01', '2024-02-15') AS result, + 0.495 AS expected, -- ~45 days / 91 days + CASE WHEN datediff('quarter', '2024-01-01', '2024-02-15') BETWEEN 0.490 AND 0.500 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 33: Quarter across year boundary +SELECT 'Test 33: Quarter across year boundary' AS test_name, + datediff('quarter', '2024-10-01', '2025-04-01') AS result, + 2.000 AS expected, + CASE WHEN datediff('quarter', '2024-10-01', '2025-04-01') = 2.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- ============================================================================ +-- SECTION 6: YEAR Datepart Tests (Tests 34-42) +-- ============================================================================ + +SELECT '=== YEAR DATEPART TESTS ===' AS section; + +-- Test 34: Exact year aligned +SELECT 'Test 34: Exact year aligned' AS test_name, + datediff('year', '2024-03-15', '2025-03-15') AS result, + 1.000 AS expected, + CASE WHEN datediff('year', '2024-03-15', '2025-03-15') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 35: Partial year in leap year +SELECT 'Test 35: Partial year leap year' AS test_name, + datediff('year', '2024-01-01', '2024-07-01') AS result, + 0.497 AS expected, -- 182 days / 366 + CASE WHEN datediff('year', '2024-01-01', '2024-07-01') BETWEEN 0.495 AND 0.500 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 36: Year alias 'yy' +SELECT 'Test 36: Year alias yy' AS test_name, + datediff('yy', '2020-01-01', '2025-01-01') AS result, + 5.000 AS expected, + CASE WHEN datediff('yy', '2020-01-01', '2025-01-01') = 5.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 37: Year alias 'yyyy' +SELECT 'Test 37: Year alias yyyy' AS test_name, + datediff('yyyy', '2024-06-15', '2027-06-15') AS result, + 3.000 AS expected, + CASE WHEN datediff('yyyy', '2024-06-15', '2027-06-15') = 3.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 38: Year alias 'y' +SELECT 'Test 38: Year alias y' AS test_name, + datediff('y', '2024-01-01', '2024-12-31') AS result, + 0.997 AS expected, -- 365 days / 366 + CASE WHEN datediff('y', '2024-01-01', '2024-12-31') BETWEEN 0.995 AND 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 39: Year alias 'years' +SELECT 'Test 39: Year alias years' AS test_name, + datediff('years', '2014-06-15', '2024-06-15') AS result, + 10.000 AS expected, + CASE WHEN datediff('years', '2014-06-15', '2024-06-15') = 10.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 40: Year boundary crossing (1 day) +SELECT 'Test 40: Year boundary 1 day' AS test_name, + datediff('year', '2024-12-31', '2025-01-01') AS result, + 0.003 AS expected, -- 1 day / 365 + CASE WHEN datediff('year', '2024-12-31', '2025-01-01') BETWEEN 0.001 AND 0.005 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 41: Negative years +SELECT 'Test 41: Negative years' AS test_name, + datediff('year', '2025-06-15', '2020-06-15') AS result, + -5.000 AS expected, + CASE WHEN datediff('year', '2025-06-15', '2020-06-15') = -5.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 42: Feb 29 leap year to Feb 28 non-leap (aligned) +SELECT 'Test 42: Feb 29 to Feb 28 alignment' AS test_name, + datediff('year', '2024-02-29', '2025-02-28') AS result, + 1.000 AS expected, + CASE WHEN datediff('year', '2024-02-29', '2025-02-28') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- ============================================================================ +-- SECTION 7: Case Insensitivity Tests (Tests 43-45) +-- ============================================================================ + +SELECT '=== CASE INSENSITIVITY TESTS ===' AS section; + +-- Test 43: UPPERCASE datepart +SELECT 'Test 43: UPPERCASE MONTH' AS test_name, + datediff('MONTH', '2024-01-01', '2024-02-01') AS result, + 1.000 AS expected, + CASE WHEN datediff('MONTH', '2024-01-01', '2024-02-01') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 44: Mixed case datepart +SELECT 'Test 44: Mixed case Quarter' AS test_name, + datediff('QuArTeR', '2024-01-01', '2024-04-01') AS result, + 1.000 AS expected, + CASE WHEN datediff('QuArTeR', '2024-01-01', '2024-04-01') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 45: Mixed case alias +SELECT 'Test 45: Mixed case alias YY' AS test_name, + datediff('Yy', '2024-01-01', '2025-01-01') AS result, + 1.000 AS expected, + CASE WHEN datediff('Yy', '2024-01-01', '2025-01-01') = 1.000 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- ============================================================================ +-- SECTION 8: TIMESTAMP and TIMESTAMPTZ Tests (Tests 46-48) +-- ============================================================================ + +SELECT '=== TIMESTAMP TESTS ===' AS section; + +-- Test 46: Timestamp day difference +SELECT 'Test 46: Timestamp day diff' AS test_name, + datediff('day', '2024-01-01 10:30:00'::timestamp, '2024-01-15 14:45:00'::timestamp) AS result, + 14 AS expected, + CASE WHEN datediff('day', '2024-01-01 10:30:00'::timestamp, '2024-01-15 14:45:00'::timestamp) = 14 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 47: Timestamp month difference +SELECT 'Test 47: Timestamp month diff' AS test_name, + datediff('month', '2024-01-15 08:00:00'::timestamp, '2024-02-20 16:00:00'::timestamp) AS result, + 1.172 AS expected, + CASE WHEN datediff('month', '2024-01-15 08:00:00'::timestamp, '2024-02-20 16:00:00'::timestamp) = 1.172 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- Test 48: Timestamptz day difference +SELECT 'Test 48: Timestamptz day diff' AS test_name, + datediff('day', '2024-01-01 10:30:00+00'::timestamptz, '2024-01-15 14:45:00+00'::timestamptz) AS result, + 14 AS expected, + CASE WHEN datediff('day', '2024-01-01 10:30:00+00'::timestamptz, '2024-01-15 14:45:00+00'::timestamptz) = 14 THEN 'PASS' ELSE 'FAIL' END AS status; + +-- ============================================================================ +-- SECTION 9: Error Handling Tests (Tests 49-50) +-- ============================================================================ + +SELECT '=== ERROR HANDLING TESTS ===' AS section; + +-- Test 49: Invalid datepart should error +SELECT 'Test 49: Invalid datepart error' AS test_name; +DO $$ +BEGIN + PERFORM datediff('hour', '2024-01-01'::date, '2024-01-02'::date); + RAISE NOTICE 'FAIL: No error raised for invalid datepart'; +EXCEPTION WHEN invalid_parameter_value THEN + RAISE NOTICE 'PASS: Correctly raised error for invalid datepart'; +END $$; + +-- Test 50: NULL handling (should return NULL) +SELECT 'Test 50: NULL handling' AS test_name, + datediff('day', NULL::date, '2024-01-15'::date) IS NULL AS null_start_returns_null, + datediff('day', '2024-01-01'::date, NULL::date) IS NULL AS null_end_returns_null, + CASE + WHEN datediff('day', NULL::date, '2024-01-15'::date) IS NULL + AND datediff('day', '2024-01-01'::date, NULL::date) IS NULL + THEN 'PASS' + ELSE 'FAIL' + END AS status; + +-- ============================================================================ +-- SECTION 10: Table-Based Tests +-- ============================================================================ + +SELECT '=== TABLE-BASED TESTS ===' AS section; + +-- Test all dateparts against table data +SELECT + description, + datediff('day', start_date, end_date) AS days, + datediff('week', start_date, end_date) AS weeks, + datediff('month', start_date, end_date) AS months, + datediff('quarter', start_date, end_date) AS quarters, + datediff('year', start_date, end_date) AS years +FROM date_test_data +ORDER BY id; + +-- Test with timestamp columns +SELECT + description, + datediff('day', start_ts, end_ts) AS ts_days, + datediff('month', start_ts, end_ts) AS ts_months +FROM date_test_data +WHERE start_ts IS NOT NULL +ORDER BY id +LIMIT 10; + +-- Test with timestamptz columns +SELECT + description, + datediff('day', start_tstz, end_tstz) AS tstz_days, + datediff('year', start_tstz, end_tstz) AS tstz_years +FROM date_test_data +WHERE start_tstz IS NOT NULL +ORDER BY id +LIMIT 10; + +-- ============================================================================ +-- SECTION 11: Aggregation and Analytics Tests +-- ============================================================================ + +SELECT '=== AGGREGATION TESTS ===' AS section; + +-- Average tenure calculations +SELECT + 'Average differences across test data' AS metric, + ROUND(AVG(datediff('day', start_date, end_date)), 2) AS avg_days, + ROUND(AVG(datediff('month', start_date, end_date)), 2) AS avg_months, + ROUND(AVG(datediff('year', start_date, end_date)), 2) AS avg_years +FROM date_test_data +WHERE start_date <= end_date; + +-- Group by ranges +WITH date_diffs AS ( + SELECT + id, + description, + datediff('day', start_date, end_date) AS day_diff + FROM date_test_data + WHERE start_date <= end_date +) +SELECT + CASE + WHEN day_diff < 7 THEN 'Less than 1 week' + WHEN day_diff < 30 THEN '1 week to 1 month' + WHEN day_diff < 90 THEN '1 to 3 months' + WHEN day_diff < 365 THEN '3 months to 1 year' + ELSE 'Over 1 year' + END AS duration_bucket, + COUNT(*) AS count +FROM date_diffs +GROUP BY 1 +ORDER BY MIN(day_diff); + +-- ============================================================================ +-- SECTION 12: Real-World Scenario Tests +-- ============================================================================ + +SELECT '=== REAL-WORLD SCENARIO TESTS ===' AS section; + +-- Invoice aging report simulation +WITH invoices AS ( + SELECT + generate_series(1, 10) AS invoice_id, + '2024-01-01'::date + (random() * 180)::int AS due_date +) +SELECT + invoice_id, + due_date, + CURRENT_DATE AS today, + datediff('day', due_date, CURRENT_DATE) AS days_overdue, + CASE + WHEN datediff('day', due_date, CURRENT_DATE) > 90 THEN 'Critical' + WHEN datediff('day', due_date, CURRENT_DATE) > 60 THEN 'Warning' + WHEN datediff('day', due_date, CURRENT_DATE) > 30 THEN 'Attention' + WHEN datediff('day', due_date, CURRENT_DATE) > 0 THEN 'Overdue' + ELSE 'Current' + END AS aging_bucket +FROM invoices +ORDER BY days_overdue DESC; + +-- Subscription proration simulation +WITH subscriptions AS ( + SELECT + 1 AS sub_id, '2024-01-15'::date AS start_date, '2024-02-20'::date AS cancel_date, 29.99 AS monthly_rate + UNION ALL SELECT + 2, '2024-03-01', '2024-03-18', 49.99 + UNION ALL SELECT + 3, '2024-06-15', '2024-09-15', 99.99 +) +SELECT + sub_id, + start_date, + cancel_date, + monthly_rate, + datediff('month', start_date, cancel_date) AS months_used, + ROUND((datediff('month', start_date, cancel_date) * monthly_rate)::numeric, 2) AS prorated_charge +FROM subscriptions; + +-- Employee tenure report +WITH employees AS ( + SELECT 'Alice' AS name, '2019-03-15'::date AS hire_date + UNION ALL SELECT 'Bob', '2021-06-01' + UNION ALL SELECT 'Carol', '2023-01-10' + UNION ALL SELECT 'David', '2024-06-15' +) +SELECT + name, + hire_date, + datediff('year', hire_date, CURRENT_DATE) AS years_tenure, + datediff('month', hire_date, CURRENT_DATE) AS months_tenure, + datediff('day', hire_date, CURRENT_DATE) AS days_tenure, + CASE + WHEN datediff('year', hire_date, CURRENT_DATE) >= 5 THEN 'Senior' + WHEN datediff('year', hire_date, CURRENT_DATE) >= 2 THEN 'Mid-level' + WHEN datediff('year', hire_date, CURRENT_DATE) >= 1 THEN 'Junior' + ELSE 'Probation' + END AS tenure_level +FROM employees +ORDER BY hire_date; + +-- ============================================================================ +-- SUMMARY: Test Results +-- ============================================================================ + +SELECT '=== TEST SUMMARY ===' AS section; + +-- Count passing tests from table data +SELECT + 'Table data validation' AS category, + COUNT(*) FILTER (WHERE datediff('day', start_date, end_date) IS NOT NULL) AS day_tests, + COUNT(*) FILTER (WHERE datediff('week', start_date, end_date) IS NOT NULL) AS week_tests, + COUNT(*) FILTER (WHERE datediff('month', start_date, end_date) IS NOT NULL) AS month_tests, + COUNT(*) FILTER (WHERE datediff('quarter', start_date, end_date) IS NOT NULL) AS quarter_tests, + COUNT(*) FILTER (WHERE datediff('year', start_date, end_date) IS NOT NULL) AS year_tests +FROM date_test_data; + +-- Cleanup +DROP TABLE IF EXISTS date_test_data; +-- Note: Keeping extension for further manual testing +-- DROP EXTENSION IF EXISTS mssql_compat; + +SELECT 'All comprehensive tests completed!' AS final_status; + diff --git a/contrib/mssql_compat/sql/mssql_compat.sql b/contrib/mssql_compat/sql/mssql_compat.sql new file mode 100644 index 00000000000..f625868b3f7 --- /dev/null +++ b/contrib/mssql_compat/sql/mssql_compat.sql @@ -0,0 +1,173 @@ +-- +-- Test cases for mssql_compat extension +-- Covers PRD1a unit tests (UT-01 to UT-15) and edge cases (EC-01 to EC-06) +-- + +CREATE EXTENSION mssql_compat; + +-- +-- Basic Day Calculations (UT-01, UT-02) +-- +SELECT 'UT-01: Day difference basic' AS test; +SELECT datediff('day', '2024-01-01'::date, '2024-01-15'::date); + +SELECT 'UT-02: Day difference negative' AS test; +SELECT datediff('day', '2024-01-15'::date, '2024-01-01'::date); + +-- +-- Week Calculations (UT-03, UT-04) +-- +SELECT 'UT-03: Week exact' AS test; +SELECT datediff('week', '2024-01-01'::date, '2024-01-08'::date); + +SELECT 'UT-04: Week partial' AS test; +SELECT datediff('week', '2024-01-01'::date, '2024-01-10'::date); + +-- +-- Month Calculations (UT-05, UT-06, UT-07) +-- +SELECT 'UT-05: Month aligned' AS test; +SELECT datediff('month', '2024-01-15'::date, '2024-02-15'::date); + +SELECT 'UT-06: Month partial' AS test; +SELECT datediff('month', '2024-01-15'::date, '2024-02-20'::date); + +SELECT 'UT-07: Month end-of-month alignment' AS test; +SELECT datediff('month', '2024-01-31'::date, '2024-02-29'::date); + +-- +-- Quarter Calculations (UT-08, UT-09) +-- +SELECT 'UT-08: Quarter aligned' AS test; +SELECT datediff('quarter', '2024-01-01'::date, '2024-04-01'::date); + +SELECT 'UT-09: Quarter partial' AS test; +SELECT datediff('quarter', '2024-01-15'::date, '2024-05-20'::date); + +-- +-- Year Calculations (UT-10, UT-11) +-- +SELECT 'UT-10: Year aligned' AS test; +SELECT datediff('year', '2024-03-15'::date, '2025-03-15'::date); + +SELECT 'UT-11: Year partial leap year' AS test; +SELECT datediff('year', '2024-01-01'::date, '2024-07-01'::date); + +-- +-- NULL Handling (UT-12, UT-13) - STRICT functions return NULL for NULL inputs +-- +SELECT 'UT-12: NULL start date' AS test; +SELECT datediff('day', NULL::date, '2024-01-15'::date); + +SELECT 'UT-13: NULL end date' AS test; +SELECT datediff('day', '2024-01-01'::date, NULL::date); + +-- +-- Invalid Datepart (UT-14) +-- +SELECT 'UT-14: Invalid datepart' AS test; +SELECT datediff('hour', '2024-01-01'::date, '2024-01-02'::date); + +-- +-- Case Insensitivity (UT-15) +-- +SELECT 'UT-15: Case insensitive datepart' AS test; +SELECT datediff('MONTH', '2024-01-01'::date, '2024-02-01'::date); +SELECT datediff('Month', '2024-01-01'::date, '2024-02-01'::date); +SELECT datediff('month', '2024-01-01'::date, '2024-02-01'::date); + +-- +-- Edge Cases (EC-01 to EC-06) +-- +SELECT 'EC-01: Same date' AS test; +SELECT datediff('day', '2024-01-01'::date, '2024-01-01'::date); + +SELECT 'EC-02: Leap year February 29' AS test; +SELECT datediff('day', '2024-02-28'::date, '2024-03-01'::date); + +SELECT 'EC-03: Non-leap year February' AS test; +SELECT datediff('day', '2023-02-28'::date, '2023-03-01'::date); + +SELECT 'EC-04: Year boundary' AS test; +SELECT datediff('year', '2024-12-31'::date, '2025-01-01'::date); + +SELECT 'EC-05: Multi-year span' AS test; +SELECT datediff('year', '2020-01-01'::date, '2025-01-01'::date); + +SELECT 'EC-06: Century boundary' AS test; +SELECT datediff('day', '1999-12-31'::date, '2000-01-01'::date); + +-- +-- Alias Tests (from PRD1b lines 224-230) +-- +SELECT 'Alias: yy for year' AS test; +SELECT datediff('yy', '2024-01-01'::date, '2025-01-01'::date); + +SELECT 'Alias: yyyy for year' AS test; +SELECT datediff('yyyy', '2024-01-01'::date, '2025-01-01'::date); + +SELECT 'Alias: mm for month' AS test; +SELECT datediff('mm', '2024-01-15'::date, '2024-02-15'::date); + +SELECT 'Alias: qq for quarter' AS test; +SELECT datediff('qq', '2024-01-01'::date, '2024-04-01'::date); + +SELECT 'Alias: wk for week' AS test; +SELECT datediff('wk', '2024-01-01'::date, '2024-01-08'::date); + +SELECT 'Alias: dd for day' AS test; +SELECT datediff('dd', '2024-01-01'::date, '2024-01-15'::date); + +-- +-- Timestamp Tests +-- +SELECT 'Timestamp: basic day diff' AS test; +SELECT datediff('day', '2024-01-01 10:30:00'::timestamp, '2024-01-15 14:45:00'::timestamp); + +SELECT 'Timestamp: month diff' AS test; +SELECT datediff('month', '2024-01-15 08:00:00'::timestamp, '2024-02-20 16:00:00'::timestamp); + +-- +-- Timestamptz Tests +-- +SELECT 'Timestamptz: basic day diff' AS test; +SELECT datediff('day', '2024-01-01 10:30:00+00'::timestamptz, '2024-01-15 14:45:00+00'::timestamptz); + +-- +-- Additional Month Calculation Tests +-- +SELECT 'Month: Jan 25 to Mar 10 (PRD walkthrough example)' AS test; +SELECT datediff('month', '2024-01-25'::date, '2024-03-10'::date); + +SELECT 'Month: subscription proration example' AS test; +SELECT datediff('month', '2024-01-15'::date, '2024-02-20'::date); + +-- +-- Additional Quarter Calculation Tests +-- +SELECT 'Quarter: PRD walkthrough example' AS test; +SELECT datediff('quarter', '2024-01-15'::date, '2024-05-20'::date); + +-- +-- Additional Year Calculation Tests +-- +SELECT 'Year: PRD walkthrough example' AS test; +SELECT datediff('year', '2024-03-15'::date, '2025-06-20'::date); + +SELECT 'Year: exact 5-year tenure' AS test; +SELECT datediff('year', '2020-03-15'::date, '2025-03-15'::date); + +SELECT 'Year: leap year partial (182 days / 366)' AS test; +SELECT datediff('year', '2024-01-01'::date, '2024-07-01'::date); + +-- +-- Week Calculation Additional Tests +-- +SELECT 'Week: exact 2 weeks' AS test; +SELECT datediff('week', '2024-01-01'::date, '2024-01-15'::date); + +SELECT 'Week: PRD example 9 days' AS test; +SELECT datediff('week', '2024-01-01'::date, '2024-01-10'::date); + +DROP EXTENSION mssql_compat; + -- 2.52.0