Calculate calendar days by month for date ranges - sql

I have a table with date ranges:
CREATE TABLE REQUEST (
REQUEST_ID NUMBER(*,0) NOT NULL ENABLE,
EMPLOYEE_ID NUMBER(*,0) NOT NULL ENABLE,
START_DATE DATE NOT NULL ENABLE,
END_DATE DATE NOT NULL ENABLE,
CONSTRAINT REQUEST_PK PRIMARY KEY (REQUEST_ID)
);
The're also a couple of constraints (omitted for brevity) that ensure they're valid (end date cannot be less then start date) and force time to be 00:00:00. (Runnable fiddle with sample data).
Is there a way to split my data set by year/month into a result set based on the date ranges? The rules are:
A request splits in as many rows as months its range covers.
Each month has a column stating how many calendar days the range has in such month.
For example, a request with [2020-12-28, 2021-02-10] would produce three rows:
request_id year month days
========== ==== ===== ====
1 2020 12 4
1 2021 1 31
1 2021 2 10
I've been playing with CONNECT BY but I've been unable to adapt it to my use case. Is that the right tool?

A more direct way to do the computation is to use connect by to generate just the needed months (not every day in every interval) - and then to do the day computation directly, rather than by counting. Something like this:
Adding data for testing:
insert into request (request_id, employee_id, start_date, end_date)
select 1, 1001, date '2020-12-28', date '2021-02-10' from dual union all
select 2, 4002, date '2021-02-10', date '2021-02-20' from dual union all
select 3, 6004, date '2020-12-15', date '2021-03-31' from dual
;
commit;
Query and output:
with
prep (request_id, start_date, end_date, mth) as (
select request_id, start_date, end_date,
add_months(trunc(start_date, 'mm'), level - 1)
from request
connect by level <= months_between(trunc(end_date, 'mm'),
trunc(start_date, 'mm')) + 1
and prior request_id = request_id
and prior sys_guid() is not null
)
select request_id, extract(year from mth) as year_,
extract(month from mth) as month_,
least(last_day(mth), end_date) - greatest(mth, start_date) + 1 as days
from prep
order by request_id, mth -- if needed
;
REQUEST_ID YEAR_ MONTH_ DAYS
---------- ---------- ---------- ----------
1 2020 12 4
1 2021 1 31
1 2021 2 10
2 2021 2 11
3 2020 12 17
3 2021 1 31
3 2021 2 28
3 2021 3 31

For example this one:
WITH t AS (
SELECT DATE '2020-12-28' +LEVEL-1 AS ts
FROM dual
CONNECT BY DATE '2020-12-28' +LEVEL-1 <= DATE '2021-02-10')
SELECT
EXTRACT(YEAR FROM TRUNC(ts, 'Month')) AS YEAR,
EXTRACT(MONTH FROM TRUNC(ts, 'Month')) AS MONTH,
COUNT(ts) AS DAYS
FROM t
GROUP BY TRUNC(ts, 'Month')
ORDER BY YEAR, MONTH;

Related

How to get first date and last date of all twelve months for given year in Oracle PLSQL?

If i give input year like '2021' i need result as below
Month Start Date End Date
1 1/1/2021 31/01/2021
2 1/2/2021 28/01/2021
.
.
.
.
.
.
.
.
.
12 1/12/2021 31/12/2021
Basically, it is about the row generator technique; there are plenty of them, pick any you want. (Have a look at OraFAQ).
For example:
SQL> with mon as
2 (select add_months(trunc(to_date(&par_year, 'yyyy'), 'yyyy'), level - 1) val
3 from dual
4 connect by level <= 12
5 )
6 select to_char(val, 'mm') mon,
7 val start_date,
8 last_day(val) end_date
9 from mon
10 order by 1;
Enter value for par_year: 2021
MO START_DATE END_DATE
-- ---------- ----------
01 01/01/2021 31/01/2021
02 01/02/2021 28/02/2021
03 01/03/2021 31/03/2021
04 01/04/2021 30/04/2021
05 01/05/2021 31/05/2021
06 01/06/2021 30/06/2021
07 01/07/2021 31/07/2021
08 01/08/2021 31/08/2021
09 01/09/2021 30/09/2021
10 01/10/2021 31/10/2021
11 01/11/2021 30/11/2021
12 01/12/2021 31/12/2021
12 rows selected.
SQL>
You could also use directly the model clause for that purpose.
SELECT n
, TO_DATE(&the_year||lpad(f, 2, '0'), 'YYYYMM') start_dt
, last_day(TO_DATE(&the_year||lpad(f, 2, '0'), 'YYYYMM')) end_dt
FROM DUAL
MODEL
DIMENSION by (1 as n)
MEASURES (1 as f)
RULES (
f[FOR n FROM 1 TO 12 INCREMENT 1 ] = cv(n)
)
;
The advantage of the model clause is if you later want to get the every other month, you just need to change the increment from 1 to 2. Or if you are looking for the quarter months of the year (January, April, Jully, October), you just need to change increment from 1 to 3, and so on...
Just replace 2021 in the query for your year
with months (m) as (
select 1 from dual union all
select m + 1 from months where m < 12
)
select
to_date('2021' || '-' || to_char(m) || '-01', 'YYYY-MM-DD') as first_day,
last_day(to_date('2021' || '-' || to_char(m) || '-01', 'YYYY-MM-DD')) as last_day
from months
You can try on this db<>fiddle
Try below query
WITH cte_date as(
SELECT
LEVEL Month_No,
to_date(to_char(LEVEL||'-2021'),'MM-YYYY') Start_Date FROM dual
CONNECT BY LEVEL <=12
)
SELECT Month_No, Start_Date, LAST_DAY(Start_Date) End_Date
FROM cte_date;
I'm a fan of recursive CTEs because they are part of Standard SQL. I would phrase this as:
with months (month, startdate) as (
select 1 as month, date '2021-01-01'
from dual
union all
select month + 1, add_months(startdate, 1)
from months
where month < 12
)
select month, startdate, last_day(startdate) as enddate
from months;
If you need an input year, there are different ways to accomplish it. But a simple way is to change the second line to:
select 1 as month, to_date(:year || '0101', 'YYYYMMDD')

Need SQL to generate multiple rows from subquery

I have a query that selects a month and year from a complex heirachy of tables. To simplify this question, we can use this query:
select 2 as month, 2018 as year from dual;
I need SQL that will use that query as a subquery to output 3 row: that month as well as with the preceeding 2 months (including years). So the output needed for that specific case would be:
Month Year
2 2018
1 2018
12 2017
I have no idea how to proceed. Ideas anyone?
If you have the month/year already in a DATE column, it would be one less conversion, but if they are separate columns like you have provided, you can use a query like the one below.
Query
WITH sample_data AS (SELECT 2 AS month, 2018 AS year FROM DUAL)
SELECT TO_CHAR (ADD_MONTHS (TO_DATE (month || '-' || year, 'MM-YYYY'), ((LEVEL * -1) + 1)), 'MM')
AS month,
TO_CHAR (ADD_MONTHS (TO_DATE (month || '-' || year, 'MM-YYYY'), ((LEVEL * -1) + 1)), 'YYYY')
AS year
FROM sample_data
CONNECT BY LEVEL <= 3;
Result
MONTH YEAR
________ _______
02 2018
01 2018
12 2017
For Oracle 12+:
select *
from
(select 2 as month, 2018 as year
from dual) complex_query
,lateral(
select
extract(month from add_months(dt, 1-level)) as month,
extract(year from add_months(dt, 1-level)) as year
from (
select
to_date(complex_query.year||'-'||complex_query.month||'-01', 'yyyy-mm-dd') as dt
from dual
)
connect by level<=3
);
For previous versions:
select
complex_query.*
,extract(year from (to_date(year||'-'||month,'yyyy-mm')-delta)) year_2
,extract(month from (to_date(year||'-'||month,'yyyy-mm')-delta)) month_2
from
(select 2 as month, 2018 as year
from dual) complex_query
,(select
NUMTOYMINTERVAL(level-1,'month') delta
from dual
connect by level<=3
) v
Results:
MONTH YEAR YEAR_2 MONTH_2
---------- ---------- ---------- ----------
2 2018 2018 1
2 2018 2017 12
2 2018 2017 11
Would union work?
with primary_month as (
select 2 as month, 2018 as year from dual
)
select (case when month = 1 then 13 else month end) - 1 as month , case when month = 1 then year-1 else year end as year from primary_month
union
select month, year from primary_month
union
select month + 1 as month, year from primary_month
;
This example won't handle boundary cases like if the primary month is January, since there is no month 0, but I'm not sure if that's what you're going for and it depends on the real query and schema (e.g. are you using timestamp columns?)

Extracting date field

I need to pull the month field from the first table and I would like the results to look like the second table.
I know to use EXTRACT(MONTH FROM BEGIN_DATE) to get the month but I'm unsure how to show all of the date fields. Any ideas? Using Oracle SQL.
Here is one way to do this. Note that I changed the column name from Month to mth ("month" is an Oracle keyword, it shouldn't be used as a table or column name). I also show the full month description (that is, including the year), so that there is no confusion when you have the same month (September for example) for the same person_id more than once. You can change that to show just the month by adjusting the format model in TO_CHAR, but that doesn't make much sense to me.
The test data is in the WITH clause which is NOT part of the SQL query; remove it, and use the actual table name in the SELECT query (instead of the made-up name I created in the WITH clause).
with
inputs ( person_id, begin_date, end_date ) as (
select 123, date '2017-11-01', date '2199-12-31' from dual union all
select 654, date '2017-09-01', date '2017-10-31' from dual union all
select 789, date '2017-12-01', date '2199-12-31' from dual
)
select person_id,
to_char(add_months(trunc(begin_date, 'mm'), level - 1), 'mm-yyyy') as mth,
case when add_months(trunc(begin_date, 'mm'), level - 1) <= end_date
then 'Y' else 'N' end as yn
from inputs
connect by add_months(trunc(begin_date, 'mm'), level - 1) <= sysdate
and prior person_id = person_id
and prior sys_guid() is not null
order by person_id, level
;
PERSON_ID MTH YN
---------- ------- --
123 11-2017 Y
123 12-2017 Y
123 01-2018 Y
654 09-2017 Y
654 10-2017 Y
654 11-2017 N
654 12-2017 N
654 01-2018 N
789 12-2017 Y
789 01-2018 Y
10 rows selected
One more thing - in the sample data, your begin_date is always the beginning of a month. If this is guaranteed, then you could simplify the code - there would be no need for trunc( ..., 'mm') - but I would still not change it; you don't know if this special property of the column will continue into the future, and if it ever changes, you will be glad the code addresses it already.
I think I finally understood what you want; it is SYSDATE you're running the query on. Based on it, you want to get all months that are between BEGIN_DATE and SYSDATE, for each PERSON_ID.
If that's so, here you go:
SQL> with test (person_id, begin_date) as
2 (select 123, date '2017-11-01' from dual union
3 select 654, date '2017-09-01' from dual
4 )
5 select person_id,
6 to_char(add_months(trunc(begin_date, 'mm'), column_value - 1), 'mm') mon
7 from test,
8 table(cast(multiset
9 (select level lvl from dual
10 connect by level <= round(months_Between (sysdate, begin_date)) + 1)
11 as sys.odcinumberlist))
12 order by 1, 2;
PERSON_ID MO
---------- --
123 01
123 11
123 12
654 01
654 09
654 10
654 11
654 12
8 rows selected.
SQL>
A variation on mathguy's answer:
SELECT
person_id
, TO_CHAR(ADD_MONTHS(begin_date, LEVEL-1), 'mm') m
, TO_CHAR(ADD_MONTHS(begin_date, LEVEL-1), 'yyyy') y
, CASE WHEN ADD_MONTHS(begin_date, LEVEL-1) BETWEEN begin_date and end_date THEN 'Y' ELSE 'N' END AS yn
FROM
person_table
CONNECT BY
LEVEL <= MONTHS_BETWEEN(TRUNC(SYSDATE, 'MON'), TRUNC(begin_date, 'MON')) + 1
AND prior person_id = person_id
AND prior sys_guid() IS NOT NULL
ORDER BY
person_id, y, m;
SQLFiddle for reference.

Oracle date as fraction of month

I would like to get a table of months between two dates with a fraction of each month that the two dates cover.
For example with a start date of 15/01/2017 and end date of 01/03/2017 it would output:
01/2017 : 0.5483..
02/2017 : 1
03/2017: 0.0322..
where for January and March the calculations are 17/31 and 1/31 respectively. I currently have the query:
WITH dates_between as (SELECT ADD_MONTHS(TRUNC(TO_DATE(:givenStartDate,'dd/mm/yyyy'), 'MON'), ROWNUM - 1) date_out
FROM DUAL
CONNECT BY ADD_MONTHS(TRUNC(TO_DATE(:givenStartDate,'dd/mm/yyyy'), 'MON'), ROWNUM - 1)
<= TRUNC(TO_DATE(:givenEndDate,'dd/mm/yyyy'), 'MON')
)
select * from dates_between
This outputs each month between two dates and formats it to the start of the month. I just need another column to give me the fraction the start and end dates cover. I'm not sure of a way to do this without it getting messy.
The months_between() function "calculates the fractional portion of the result based on a 31-day month". That means that if your range starts or ends in a month that doesn't have 31 days, the fraction you get might not be quite what you expect:
select months_between(date '2017-04-02', date '2017-04-01') as calc from dual
CALC
----------
.0322580645
... which is 1/31, not 1/30. To get 0.0333... instead you'd need to calculate the number of days in each month, at least for the first and last month. This uses a recursive CTE (11gR2+) to get the months, using a couple of date ranges provided by another CTE as a demo to show the difference (you can use a hierarchical query too of course):
with ranges (id, start_date, end_date) as (
select 1, date '2017-01-15', date '2017-03-01' from dual
union all select 2, date '2017-01-31', date '2017-03-01' from dual
union all select 3, date '2017-02-28', date '2017-04-01' from dual
),
months (id, month_start, month_days, range_start, range_end) as (
select id,
trunc(start_date, 'MM'),
extract(day from last_day(start_date)),
start_date,
end_date
from ranges
union all
select id,
month_start + interval '1' month,
extract(day from last_day(month_start + interval '1' month)),
range_start,
range_end
from months
where month_start < range_end
)
select id,
to_char(month_start, 'YYYY-MM-DD') as month_start,
month_days,
case when month_start = trunc(range_start, 'MM')
then month_days - extract(day from range_start) + 1
when month_start = trunc(range_end, 'MM')
then extract(day from range_end)
else month_days end as range_days,
(case when month_start = trunc(range_start, 'MM')
then month_days - extract(day from range_start) + 1
when month_start = trunc(range_end, 'MM')
then extract(day from range_end)
else month_days end) / month_days as fraction
from months
order by id, month_start;
which gets:
ID MONTH_STAR MONTH_DAYS RANGE_DAYS FRACTION
------ ---------- ---------- ---------- --------
1 2017-01-01 31 17 0.5483
1 2017-02-01 28 28 1
1 2017-03-01 31 1 0.0322
2 2017-01-01 31 1 0.0322
2 2017-02-01 28 28 1
2 2017-03-01 31 1 0.0322
3 2017-02-01 28 1 0.0357
3 2017-03-01 31 31 1
3 2017-04-01 30 1 0.0333
The first CTE ranges is just the demo data. The second, recursive, CTE months generates the start and number of days in each month, while keeping track of the original range dates too. The final query just calculates the fractions based on the number of days in the month in the range against the number of days in that month overall.
The month_days and range_days are only shown in the output so you can see what the calculation is based on, you can obviously omit those from your actual result, and format the month start date however you want.
With your original single pair of bind variables the equivalent would be:
with months (month_start, month_days, range_start, range_end) as (
select trunc(to_date(:givenstartdate, 'DD/MM/YYYY'), 'MM'),
extract(day from last_day(to_date(:givenstartdate, 'DD/MM/YYYY'))),
to_date(:givenstartdate, 'DD/MM/YYYY'),
to_date(:givenenddate, 'DD/MM/YYYY')
from dual
union all
select month_start + interval '1' month,
extract(day from last_day(month_start + interval '1' month)),
range_start,
range_end
from months
where month_start < range_end
)
select to_char(month_start, 'MM/YYYY') as month,
(case when month_start = trunc(range_start, 'MM')
then month_days - extract(day from range_start) + 1
when month_start = trunc(range_end, 'MM')
then extract(day from range_end)
else month_days end) / month_days as fraction
from months
order by month_start;
MONTH FRACTION
------- --------
01/2017 0.5483
02/2017 1
03/2017 0.0322
Here's how I would do it (n.b. I have expanded your dates_between to work against multiple rows, purely for demonstration purposes. If you're only working with a single set of parameters, you wouldn't need to do that):
WITH params AS (SELECT 1 ID, '15/01/2017' givenstartdate, '01/03/2017' givenenddate FROM dual UNION ALL
SELECT 2 ID, '15/01/2017' givenstartdate, '23/01/2017' givenenddate FROM dual UNION ALL
SELECT 3 ID, '01/01/2017' givenstartdate, '07/04/2017' givenenddate FROM dual),
dates_between AS (SELECT ID,
to_date(givenstartdate, 'dd/mm/yyyy') givenstartdate,
to_date(givenenddate, 'dd/mm/yyyy') givenenddate,
add_months(trunc(to_date(givenstartdate, 'dd/mm/yyyy'), 'MON'), LEVEL - 1) start_of_month,
last_day(add_months(trunc(to_date(givenstartdate, 'dd/mm/yyyy'), 'MON'), LEVEL - 1)) end_of_month
FROM params
CONNECT BY add_months(trunc(to_date(givenstartdate, 'dd/mm/yyyy'), 'MON'), LEVEL - 1) <=
trunc(to_date(givenenddate, 'dd/mm/yyyy'), 'MON')
AND PRIOR ID = ID
AND PRIOR sys_guid() IS NOT NULL)
SELECT ID,
givenstartdate,
givenenddate,
start_of_month date_out,
end_of_month,
months_between(LEAST(givenenddate, end_of_month) + 1, GREATEST(start_of_month, givenstartdate))
FROM dates_between;
ID GIVENSTARTDATE GIVENENDDATE DATE_OUT END_OF_MONTH DIFF
1 15/01/2017 01/03/2017 01/01/2017 31/01/2017 0.54838709
1 15/01/2017 01/03/2017 01/02/2017 28/02/2017 1
1 15/01/2017 01/03/2017 01/03/2017 31/03/2017 0.03225806
2 15/01/2017 23/01/2017 01/01/2017 31/01/2017 0.29032258
3 01/01/2017 07/04/2017 01/01/2017 31/01/2017 1
3 01/01/2017 07/04/2017 01/02/2017 28/02/2017 1
3 01/01/2017 07/04/2017 01/03/2017 31/03/2017 1
3 01/01/2017 07/04/2017 01/04/2017 30/04/2017 0.22580645
N.B. You may need to add a case statement to decide whether you want to add 1 or not to the diff calculation, based on your requirements.
Try this
For first month, I have calculated remaining days / total days and for last month, I subtracted it by 1 to get days passed / total days.
DBFiddle Demo
WITH tbl AS
(SELECT date '2017-01-15' AS givenStartDate
,date '2017-03-01' AS givenEndDate
FROM dual
)
SELECT ADD_MONTHS(TRUNC(givenStartDate, 'MON'), ROWNUM - 1) AS date_out ,
CASE
WHEN
rownum - 1 = 0
THEN months_between(last_day(givenStartDate), givenStartDate)
WHEN ADD_MONTHS(TRUNC(givenStartDate, 'MON'), ROWNUM - 1) = TRUNC(givenEndDate, 'MON')
THEN 1 - (months_between(last_day(givenEndDate), givenEndDate))
ELSE 1
END AS perc
FROM tbl
CONNECT BY ADD_MONTHS(TRUNC(givenStartDate, 'MON'), ROWNUM - 1)
<= TRUNC(givenEndDate, 'MON');
Output
+-----------+-------------------------------------------+
| DATE_OUT | PERC |
+-----------+-------------------------------------------+
| 01-JAN-17 | .5161290322580645161290322580645161290323 |
| 01-FEB-17 | 1 |
| 01-MAR-17 | .0322580645161290322580645161290322580645 |
+-----------+-------------------------------------------+

Number of days in a month

I have a monthly amount that I need to spread equally over the number of days in the month. The data looks like this:
Month Value
----------- ---------------
01-Jan-2012 100000
01-Feb-2012 121002
01-Mar-2012 123123
01-Apr-2012 118239
I have to spread the Jan amount over 31 days, the Feb amount over 29 days and the March amount over 31 days.
How can I use PL/SQL to find out how many days there are in the month given in the month column?
SELECT CAST(to_char(LAST_DAY(date_column),'dd') AS INT)
FROM table1
Don't use to_char() and stuff when doing arithmetics with dates.
Strings are strings and dates are dates. Please respect the data types and use this instead:
1+trunc(last_day(date_column))-trunc(date_column,'MM')
Indeed, this is correct. It computes the difference between the value of the last day of the month and the value of the first (which is obviously always 1 and therefore we need to add this 1 again).
You must not forget to use the trunc() function if your date columns contains time, because last_day() preserves the time component.
SELECT EXTRACT(DAY FROM LAST_DAY(SYSDATE)) num_of_days FROM dual;
/
SELECT SYSDATE, TO_CHAR(LAST_DAY(SYSDATE), 'DD') num_of_days FROM dual
/
-- Days left in a month --
SELECT SYSDATE, LAST_DAY(SYSDATE) "Last", LAST_DAY(SYSDATE) - SYSDATE "Days left"
FROM DUAL
/
You can add a month and subtract the two dates
SQL> ed
Wrote file afiedt.buf
1 with x as (
2 select date '2012-01-01' dt from dual union all
3 select date '2012-02-01' from dual union all
4 select date '2012-03-01' from dual union all
5 select date '2012-01-31' from dual
6 )
7 select dt, add_months(trunc(dt,'MM'),1) - trunc(dt,'MM')
8* from x
SQL> /
DT ADD_MONTHS(TRUNC(DT,'MM'),1)-TRUNC(DT,'MM')
--------- -------------------------------------------
01-JAN-12 31
01-FEB-12 29
01-MAR-12 31
31-JAN-12 31
select add_months(my_date, 1)-my_date from dual;
SELECT TO_CHAR(LAST_DAY(SYSDATE), 'fmday-mon-rr dd') as LastDayOfMonth
FROM dual;
Use the following Oracle query:
select to_number(to_char(last_day(sysdate),'dd')) TotalDays from dual
Date_Parameter='01-Oct-2017'
select to_number(to_char(last_day('Date_Parameter'),'dd')) TotalDays from dual