Given 2 dates (StartDate and EndDate), how to do i generate quarterly periods in Pl/SQL.
Example:
Start Date: 01-JAN-2009
End Date: 31-DEC-2009
Expected Output:
StartDate EndDate
01-JAN-2009 31-MAR-2009
01-APR-2009 30-JUN-2009
01-JUL-2009 30-SEP-2009
01-OCT-2009 31-DEC-2009
SELECT ADD_MONTHS( TRUNC(PARAM.start_date, 'Q'), 3*(LEVEL-1) ) AS qstart
, ADD_MONTHS( TRUNC(PARAM.start_date, 'Q'), 3*(LEVEL) ) -1 AS qend
FROM ( SELECT TO_DATE('&start_date') AS start_date
, TO_DATE('&end_date') AS end_date
FROM DUAL
) PARAM
CONNECT BY ADD_MONTHS( TRUNC(PARAM.start_date, 'Q'), 3*(LEVEL) ) -1
<= PARAM.end_date
Rules for params, you may need to adjust the query to suit your purposes:
If start_date is not exact quarter start it effectively uses the quarter contain start date.
If end_date is not exact quarter end then we end on the quarter that ended BEFORE end_date (not the one containing end date).
Here's one way that you can do it with PL/SQL
declare
startDate Date := '01-JAN-2009';
endDate Date := '31-DEC-2009';
totalQuarters number := 0;
begin
totalQuarters := round(months_between(endDate, startDate),0)/3;
dbms_output.put_line ('total quarters: ' || totalQuarters);
for i in 1..totalQuarters loop
dbms_output.put_line('start date: '|| startDate || ' end date:' || add_months(startDate -1,3));
startDate := add_months(startDate,3) ;
end loop;
end;
Related
Is the following the correct way to get a distinct list of days for a date range (min and max of a date field) I intend to create a sql view out of this:
with range as (
select min(date) start_date,
max(date) end_date
from table
)
select start_date + level - 1 AS "DATE",
extract(month from start_date + level - 1) AS "MONTH",
extract(year from start_date + level - 1) AS "YEAR"
from range
connect by level <= (
trunc(end_date) - trunc(start_date) + 1
);
Do you really need to create a DATE table when you can generate one on the fly if needed
ALTER SESSION SET NLS_DATE_FORMAT = 'DD-MON-YYYY';
CREATE OR REPLACE TYPE nt_date IS TABLE OF DATE;
/
CREATE OR REPLACE FUNCTION generate_dates_pipelined(
p_from IN DATE,
p_to IN DATE
)
RETURN nt_date PIPELINED DETERMINISTIC
IS
v_start DATE := TRUNC(LEAST(p_from, p_to));
v_end DATE := TRUNC(GREATEST(p_from, p_to));
BEGIN
LOOP
PIPE ROW (v_start);
EXIT WHEN v_start >= v_end;
v_start := v_start + INTERVAL '1'DAY;
END LOOP;
RETURN;
END generate_dates_pipelined;
/
SELECT
c.COLUMN_VALUE
FROM
TABLE(generate_dates_pipelined(DATE '2022-07-01',
DATE '2022-07-31')) c
I have this common function which counts business days between two dates.
BUS_DAY := TRUNC(TO_DATE(P_START_DATE, D_FORMAT));
DATES_DIFF := TRUNC(TO_DATE(P_END_DATE, D_FORMAT)) - BUS_DAY;
SELECT MAX(RNUM) INTO T_DAYS
FROM (
SELECT ROWNUM RNUM
FROM ALL_OBJECTS
)
WHERE ROWNUM <= DATES_DIFF
AND TO_CHAR(BUS_DAY + RNUM, 'DY') NOT IN ('SAT', 'SUN');
My problem is it gives an incorrect day of the week for dates.
For example, today is Oct 7 2020 WEDNESDAY, but the function reads this date as a MONDAY, so it gives incorrect number of business days T_T
Anyone have the same issue or have any idea why oracle is reading dates incorrectly?
You can calculate the value without having to use a row generator and independent of the NLS_DATE_LANGUAGE.
Adapted from my answer here (which is the same problem but also ignoring holidays):
Get the number of days between the Mondays of both weeks (using TRUNC( datevalue, 'IW' ) as an NLS_LANGUAGE independent method of finding the Monday of the week) and multiply by 5/7 to give the week days of the full weeks; then
Add the day of the week (Monday = 1, Tuesday = 2, etc., to a maximum of 5 to ignore weekends) to count the part week for the end date; and
Subtract the day of the week of the start date to remove the counted values beforehand.
Like this:
SELECT ( TRUNC( end_date, 'IW' ) - TRUNC( start_date, 'IW' ) ) * 5 / 7
+ LEAST( end_date - TRUNC( end_date, 'IW' ) + 1, 5 )
- LEAST( start_date - TRUNC( start_date, 'IW' ) + 1, 5 )
AS WeekDaysDifference
FROM your_table
If you are just calculating for a single value in a function then you can avoid a context-switch to SQL and do it all in PL/SQL:
CREATE FUNCTION count_weekdays_between(
p_start_date IN DATE,
p_end_date IN DATE
) RETURN NUMBER DETERMINISTIC
IS
BEGIN
RETURN ( TRUNC( p_end_date, 'IW' ) - TRUNC( p_start_date, 'IW' ) ) * 5 / 7
+ LEAST( p_end_date - TRUNC( p_end_date, 'IW' ) + 1, 5 )
- LEAST( p_start_date - TRUNC( p_start_date, 'IW' ) + 1, 5 );
END;
/
and:
SELECT count_weekdays_between( DATE '2020-09-29', DATE '2020-10-07' )
AS num_week_days
FROM DUAL;
Outputs: 6
db<>fiddle here
Using SELECT ... FROM ALL_OBJECTS is a really ugly workaround.
What about this proposal?
DECLARE
BUS_DAY DATE := TRUNC(SYSDATE - 10);
DATES_DIFF INTEGER := TRUNC(SYSDATE - BUS_DAY);
T_DAYS INTEGER;
BEGIN
SELECT SUM(1)
INTO T_DAYS
FROM dual
WHERE TO_CHAR(BUS_DAY + LEVEL, 'DY', 'NLS_DATE_LANGUAGE = American') NOT IN ('SAT', 'SUN')
CONNECT BY LEVEL <= DATES_DIFF;
DBMS_OUTPUT.PUT_LINE ( 'T_DAYS = ' || T_DAYS );
END;
Looking just at why you see Monday, and ignoring whether this is a good approach - which #MTO has covered - then if p_start_date and p_end_date are dates, using to_date() on them is a bug, as #a_horse_with_no_name said in a comment.
If your NLS settings have YY or RR and d_format is using YYYY then today's date would end up as 0020-10-07, which was a Monday.
As a demo:
declare
P_START_DATE date := date '2020-10-07';
D_FORMAT varchar2(11) := 'DD-MON-YYYY';
BUS_DAY date;
begin
dbms_output.put_line(P_START_DATE || ' => ' || to_char(P_START_DATE, 'SYYYY-MM-DD Day'));
BUS_DAY := TRUNC(TO_DATE(P_START_DATE, D_FORMAT));
dbms_output.put_line(BUS_DAY || ' => ' || to_char(BUS_DAY, 'SYYYY-MM-DD Day'));
end;
/
07-OCT-20 => 2020-10-07 Wednesday
07-OCT-20 => 0020-10-07 Monday
When you do:
BUS_DAY := TRUNC(TO_DATE(P_START_DATE, D_FORMAT));
it's really:
BUS_DAY := TRUNC(TO_DATE(TO_CHAR(P_START_DATE), D_FORMAT));
and that implicit TO_CHAR(P_START_DATE) is using your NLS settings, so it's something like:
BUS_DAY := TRUNC(TO_DATE(TO_CHAR(P_START_DATE, 'DD-MON-RR'), D_FORMAT));
You end up with that intermediate string value as '07-OCT-20'. If you convert that back to a date with a YYYY year component in the format mask then the year is seen as 0020, not 2020:
select to_char(to_date('07-OCT-20', 'DD-MON-YYYY'), 'DD-MON-YYYY') from dual;
07-OCT-0020
You don't need to convert to and from a string, and you're already truncating to set any time part to midnight, so you only need that part:
BUS_DAY := TRUNC(P_START_DATE);
db<>fiddle
Some clients use their own display preferences rather than NLS settings, so you may be seeing the date as 07-Oct-2020 when you query, while the NLS setting has YY or RR. You can query nls_session_parameters to check.
I need to find the 'n'th business day in oracle plsql function which should exclude weekends and custom list of holidays. I got something like this however unable to fit the custom holiday logic in here,
create or replace function add_n_working_days (
start_date date, working_days pls_integer
) return date as
end_date date := start_date;
counter pls_integer := 0;
begin
if working_days = 0 then
end_date := start_date;
elsif to_char(start_date, 'fmdy') in ('sat', 'sun') then
end_date := next_day(start_date, 'monday');
end if;
while (counter < working_days) loop
end_date := end_date + 1;
if to_char(end_date, 'fmdy') not in ('sat', 'sun') then
counter := counter + 1;
end if;
end loop;
return end_date;
end add_n_working_days;
/
I have the custom holiday list in a different table which can be extracted using the sql
select holidays from holiday_table
I tried adding elsif condition with a sub query but that is not supported
if to_char(end_date, 'fmdy') not in ('sat', 'sun') then
counter := counter + 1;
elsif to_char(end_date, 'YYYYMMDD') in (select holidays from holiday_table) then
counter := counter + 1;
end if;
I will try with counting the number of weekend days with a similar loop:
WHILE (v_copy > 0) LOOP
end_date := end_date + 1;
IF to_char(end_date, 'fmdy') IN ('sat', 'sun') THEN
end_date := end_date + 1;
ELSE
v_copy := v_copy - 1;
END IF;
END LOOP;
And then after the looping process you can count the number of holidays in the table which are after the start_date and end_date and not in ('sat', 'sun').
SELECT COUNT(*) INTO v_custom_cnt
FROM holiday_table
WHERE to_char(holidays, 'fmdy') not in ('sat', 'sun') AND
holidays BETWEEN start_date AND end_date;
Now add this number to your end_date and you will get the date of the nth business day. Of course before returning check if the new end_date is in ('sat', 'sun').
end_date := end_date + v_custom_cnt;
IF to_char(holidays, 'fmdy') in ('sat', 'sun') THEN
end_date := next_day(end_date, 'monday');
END IF;
Finally the end_date is the date of the nth working day.
CREATE OR REPLACE FUNCTION add_n_working_days (
start_date DATE,
working_days PLS_INTEGER
) RETURN DATE AS
end_date DATE := start_date;
counter PLS_INTEGER := 0;
v_copy PLS_INTEGER := working_days;
v_custom_cnt INTEGER := 0;
BEGIN
IF working_days = 0 THEN
end_date := start_date;
ELSIF to_char(start_date, 'fmdy') IN ('sat', 'sun') THEN
end_date := next_day(start_date, 'monday');
END IF;
WHILE (v_copy > 0) LOOP
end_date := end_date + 1;
IF to_char(end_date, 'fmdy') IN ('sat', 'sun') THEN
end_date := end_date + 1;
ELSE
v_copy := v_copy - 1;
END IF;
END LOOP;
SELECT COUNT(*) INTO v_custom_cnt
FROM holiday_table
WHERE to_char(end_date, 'fmdy') NOT IN ('sat', 'sun')
AND holidays BETWEEN start_date AND end_date;
end_date := end_date + v_custom_cnt;
IF to_char(end_date, 'fmdy') IN ('sat', 'sun') THEN
end_date := next_day(start_date, 'monday');
END IF;
RETURN end_date;
END add_n_working_days;
It is not 100% tested
Different approach: create a table that only lists business day and take nth value:
CREATE OR REPLACE FUNCTION add_n_working_days (
start_date DATE, working_days PLS_INTEGER
) RETURN DATE AS
l_end_date DATE := start_date;
l_counter pls_integer := 0;
BEGIN
SELECT
business_day
INTO l_end_date
FROM
(
WITH
dates AS
(SELECT start_date + level - 1 as dt FROM dual CONNECT BY level < 100)
,weekdates AS
(SELECT dt as weekday FROM dates WHERE TO_CHAR(dt,'fmdy') NOT IN ('sat','sun'))
,business_days AS
(
SELECT weekday as business_day FROM weekdates
MINUS
SELECT holiday FROM so_holidays
)
SELECT business_day, ROW_NUMBER() OVER (ORDER BY 1) as rn from business_days
)
WHERE rn = working_days + 1;
RETURN l_end_date;
END add_n_working_days;
SQL is set orientated so start thinking in terms of sets NOT iteration (no loops). Assuming you can designate a maximum number of days to be tested you can get what you want with a single simple query.
create or replace function add_n_working_days (
start_date date, working_days pls_integer
) return date as
working_date_ot date;
begin
with date_list as (select trunc(start_date) + level - 1 tdate
from dual
connect by level <= 5*working_days
)
select tdate
into working_date_ot
from ( select tdate, row_number() over(order by tdate) date_num
from date_list
where to_char(tdate,'fmdy') not in ('sat','sun')
and not exists
(select null
from holiday_dates
where trunc(holiday_date) = tdate
)
)
where date_num = working_days;
return working_date_ot;
end add_n_working_days;
How it works:
The function assumes the desired date exists within 5 times the number of days requested. So if 10 business hence are requested the resulting date will be within 50 days (that would be a whole lot of holidays if not).
The date_list CTE builds a list of potential dates out to the identified limit.
The sub select in the main filters the generated list eliminating Sat and Sun. It then probes the holiday table for the remaining dates and eliminating any any date in the table.
Remaining dates are numbered based on ascending value.
The main outer select then chooses the date number matching the working_days specified.
No Looping required. Just my .02 cents worth.
I have Holiday table that contains all holidays in it.
How can I get next work day from given date in one SQL query and not use FOR Loop like below?
DECLARE
givendate DATE := TO_DATE('2019-07-01', 'YYYY-MM-DD');
out NUMBER := 1;
BEGIN
WHILE out != 0 LOOP
SELECT
COUNT(*)
INTO out
FROM
holiday h
WHERE
trunc(h.holiday_date) = trunc(givendate);
IF out != 0 THEN
givendate := givendate + 1;
END IF;
END LOOP;
DBMS_OUTPUT.put_line(givendate);
END;
SELECT MIN(hd)
FROM (
select TO_DATE('2019-07-01', 'YYYY-MM-DD') as hd from dual
union
select trunc(h.holiday_date + 1)
FROM
holiday h
WHERE
trunc(h.holiday_date) >= TO_DATE('2019-07-01', 'YYYY-MM-DD')
) t left join holiday h2 on trunc(h2.holiday_date) = t.hd
WHERE h2.holiday_date IS NULL;
You may want to use a hierarchical query:
select nvl(max(holiday_date)+1, trunc(sysdate))
from holiday
connect by holiday_date = prior holiday_date + 1
start with holiday_date = trunc(sysdate)
It works like that:
if sysdate is a holiday, it builds a chain of continuous holidays and then returns max holiday_date + 1 as thenext business date
if sysdate is not a holiday, it returns sysdate (nvl() does that when max returns null)
So just replace trunc(sysdate) with your given_date and it should work just as your piece of code.
Can someone just tell me why this doesn't work. As far as I know, I've put the string into the variable and wanted to test it but it didn't seem to work and brought up errors.
SET SERVEROUTPUT ON;
DECLARE
DATETODAY DATE := SYSDATE;
DAYT VARCHAR2(10);
BEGIN
SELECT TO_CHAR(SYSDATE,'DAY') INTO DAYT FROM DUAL;
DBMS_OUTPUT.PUT_LINE('The date today is '||DATETODAY ||' and it is ' ||DAYT);
IF DAYT = 'SATURDAY' OR DAYT = 'SUNDAY' THEN
: DBMS_OUTPUT.PUT_LINE('Today is '||DAYT||' and its is a weekend');
ELSE
: DBMS_OUTPUT.PUT_LINE('Today is '||DAYT||' and its a week day');
END IF;
END;
/
SELECT TO_CHAR (SYSDATE, 'day') DayName,
TO_CHAR (SYSDATE, 'd') DayOfWeek,
TO_CHAR (SYSDATE, 'dd') DayOfMonth,
TO_CHAR (SYSDATE, 'ddd') DayOfYear
FROM DUAL;
DayName is bound to the language of your db. Better use DayOfWeek.
Anyway you should run the example-sql provided to compare your output. Perhaps a UPPER() could also help you.
TO_DATE( date_value, 'DAY' ) returns a fixed-length string (not a variable-length); this means that it is right-padded with space characters:
SQL Fiddle
Query 1:
SELECT TO_CHAR( DATE '2018-05-05', 'DAY' ) AS day,
DUMP( TO_CHAR( DATE '2018-05-05', 'DAY' ) ) As dump
FROM DUAL
Results:
| DAY | DUMP |
|-----------|-----------------------------------------|
| SATURDAY | Typ=1 Len=9: 83,65,84,85,82,68,65,89,32 |
Shows that the final character has ASCII code 32 - a space.
So your code should be:
SET SERVEROUTPUT ON;
DECLARE
DATETODAY DATE := SYSDATE;
DAYT VARCHAR2(10);
BEGIN
DAYT := TO_CHAR( DATETODAY ,'DAY');
DBMS_OUTPUT.PUT_LINE('The date today is '||DATETODAY ||' and it is ' ||DAYT);
IF DAYT = 'SATURDAY ' OR DAYT = 'SUNDAY ' THEN
DBMS_OUTPUT.PUT_LINE('Today is '||DAYT||' and its is a weekend');
ELSE
DBMS_OUTPUT.PUT_LINE('Today is '||DAYT||' and its a week day');
END IF;
END;
/
Which (for my NLS_DATE_FORMAT setting of YYYY-MM-DD HH24:MI:SS) outputs:
The date today is 2018-05-04 14:32:25 and it is FRIDAY
Today is FRIDAY and its a week day
Changing the initial assignment to DATETODAY DATE := DATE '2018-05-05'; then the output is:
The date today is 2018-05-05 00:00:00 and it is SATURDAY
Today is SATURDAY and its is a weekend
However, you could also write it as:
DECLARE
DATETODAY DATE := SYSDATE;
DAYT VARCHAR2(10);
BEGIN
DAYT := TO_CHAR( DATETODAY ,'DAY');
DBMS_OUTPUT.PUT_LINE('The date today is '||DATETODAY ||' and it is ' ||DAYT);
IF DATETODAY - TRUNC( DATETODAY, 'IW' ) >= 5 THEN
DBMS_OUTPUT.PUT_LINE('Today is '||DAYT||' and its is a weekend');
ELSE
DBMS_OUTPUT.PUT_LINE('Today is '||DAYT||' and its a week day');
END IF;
END;
/
As TRUNC( DATETODAY, 'IW' ) will truncate the date back to the start of the ISO week (always midnight on Monday) and this is independent of any NLS_DATE_LANGUAGE or NLS_TERRITORY settings that affect the TO_CHAR function.