I've data like below
Id
date
time
type
1
01-01-2022
08:00
in
1
01-01-2022
11:30
out
1
01-01-2022
11:35
out
1
01-01-2022
12:45
in
1
01-01-2022
17:30
out
1
01-01-2022
01:00
out
expected output :
Id
start
end
totaltime
1
08:00
11:35
03:35:00
1
12:45
17:30
04:45:00
where date is of DATE and time is of VARCHAR type columns. I want to calculate, all in/out duration for an Id. I am not able to think logic how get duration for all in/out duration for this.
Can anyone suggest any ideas ?
It's a typical gaps-and-islands type of question, and one option to resolve would be conditionally using SUM() OVER () analytic function such as
WITH t1 AS
(
SELECT t.*,
TO_TIMESTAMP(TO_CHAR("date",'yyyy-mm-dd ')||time,'yyyy-mm-dd hh24:mi:ss') AS dt
FROM t --> your table
), t2 AS
(
SELECT t1.*,
SUM(CASE WHEN type = 'in' THEN 1 ELSE 0 END) OVER (PARTITION BY id ORDER BY dt) AS rn_in,
SUM(CASE WHEN type = 'out' THEN 1 ELSE 0 END) OVER (PARTITION BY id ORDER BY dt) AS rn_out,
CASE WHEN type = 'in' THEN dt END AS "in",
CASE WHEN type = 'out' THEN dt END AS "out"
FROM t1
)
SELECT id, MIN("in") AS "start", MAX("out") AS "end", MAX("out")-MIN("in") AS "totaltime"
FROM t2
WHERE rn_in >= 1
GROUP BY id, rn_in
ORDER BY "start"
where "date" is considered to be a date type column, not an ordinary string as commented.
Demo
In Oracle, a DATE data type is a binary data type consisting of 7-bytes representing: century, year-of-century, month, day, hour, minute and second and it ALWAYS has those components. Given that, there is no point in having separate date and time columns and you can combine your two columns into one and have the sample data:
CREATE TABLE table_name (Id, datetime, type) AS
SELECT 1, DATE '2022-01-01' + INTERVAL '08:00' HOUR TO MINUTE, 'in' FROM DUAL UNION ALL
SELECT 1, DATE '2022-01-01' + INTERVAL '11:30' HOUR TO MINUTE, 'out' FROM DUAL UNION ALL
SELECT 1, DATE '2022-01-01' + INTERVAL '11:35' HOUR TO MINUTE, 'out' FROM DUAL UNION ALL
SELECT 1, DATE '2022-01-01' + INTERVAL '12:45' HOUR TO MINUTE, 'in' FROM DUAL UNION ALL
SELECT 1, DATE '2022-01-01' + INTERVAL '17:30' HOUR TO MINUTE, 'out' FROM DUAL UNION ALL
SELECT 1, DATE '2022-01-01' + INTERVAL '01:00' HOUR TO MINUTE, 'out' FROM DUAL;
Note: Another option to create the sample data is to use TO_DATE('2022-01-01 12:45', 'YYYY-MM-DD HH24:MI').
Then, from Oracle 12, you can use MATCH_RECOGNIZE to perform row-by-row operations on the data:
SELECT m.*,
(end_dt - start_dt) DAY TO SECOND AS duration
FROM table_name
MATCH_RECOGNIZE (
PARTITION BY id
ORDER BY datetime
MEASURES
FIRST(ins.datetime) AS start_dt,
LAST(outs.datetime) AS end_dt
PATTERN (ins+ outs+)
DEFINE
ins AS type = 'in',
outs AS type = 'out'
) m
Which outputs:
ID
START_DT
END_DT
DURATION
1
2022-01-01 08:00:00
2022-01-01 11:35:00
+00 03:35:00.000000
1
2022-01-01 12:45:00
2022-01-01 17:30:00
+00 04:45:00.000000
If you do keep separate date and time columns (you should not) then you can combine them into a single column before using MATCH_RECOGNIZE:
SELECT m.*,
(end_dt - start_dt) DAY TO SECOND AS duration
FROM (
SELECT id,
TO_DATE(TO_CHAR("DATE", 'YYYY-MM-DD') || time, 'YYYY-MM-DDHH24:MI') AS datetime,
type
FROM table_name
)
MATCH_RECOGNIZE (
PARTITION BY id
ORDER BY datetime
MEASURES
FIRST(ins.datetime) AS start_dt,
LAST(outs.datetime) AS end_dt
PATTERN (ins+ outs+)
DEFINE
ins AS type = 'in',
outs AS type = 'out'
) m
db<>fiddle here
By one comment I assumed that every 'in' record got its matching 'out' record on the same day, so I solved it by getting the next 'in' on a day and finding max 'out' in between:
with t1 as
(select ID, "date" , "time" as "start", LEAD("time",1,'24:00') over(partition by id, "date" order by "time") as "next"
from test
where "type" = 'in'),
t3 as
(select ID, "start", (select max("time") from test t2
where t1.id = t2.id and t1."date" = t2."date"
and t2."type" = 'out' and t2."time" between t1."start"
and t1."next" ) as "end"
from t1)
select ID, "start", "end", (case when "end" is null then null else (TO_DSINTERVAL('0 '||"end"||':00') - TO_DSINTERVAL('0 '||"start"||':00')) end) as totaltime
from t3
Related
I have a table that needs to be split on the basis of datetime
Input Table
ID| Start | End
--------------------------------------------
A | 2019-03-04 23:18:04| 2019-03-04 23:21:25
--------------------------------------------
A | 2019-03-04 23:45:05| 2019-03-05 00:15:14
--------------------------------------------
Required Output
ID| Start | End
--------------------------------------------
A | 2019-03-04 23:18:04| 2019-03-04 23:21:25
--------------------------------------------
A | 2019-03-04 23:45:05| 2019-03-04 23:59:59
--------------------------------------------
A | 2019-03-05 00:00:00| 2019-03-05 00:15:14
--------------------------------------------
Thanks!!
Try this below code. This will only work if the start and end date fall in two consecutive day. Not if the start and end date difference is more than 1 day.
MSSQL:
SELECT ID,[Start],[End]
FROM Input_Table A
WHERE DATEDIFF(DD,[Start],[End]) = 0
UNION ALL
SELECT ID,[Start], CAST(CAST(CAST([Start] AS DATE) AS VARCHAR(MAX)) +' 23:59:59' AS DATETIME)
FROM Input_Table A
WHERE DATEDIFF(DD,[Start],[End]) > 0
UNION ALL
SELECT ID,CAST(CAST([End] AS DATE) AS DATETIME),[End]
FROM Input_Table A
WHERE DATEDIFF(DD,[Start],[End]) > 0
ORDER BY 1,2,3
PostgreSQL:
SELECT ID,
TO_TIMESTAMP(startDate,'YYYY-MM-DD HH24:MI:SS'),
TO_TIMESTAMP(endDate, 'YYYY-MM-DD HH24:MI:SS')
FROM mytemp A
WHERE DATE_PART('day', endDate::date) -
DATE_PART('day',startDate::date) = 0
UNION ALL
SELECT ID,
TO_TIMESTAMP(startDate,'YYYY-MM-DD HH24:MI:SS'),
TO_TIMESTAMP(CONCAT(CAST(CAST (startDate AS DATE) AS VARCHAR) ,
' 23:59:59') , 'YYYY-MM-DD HH24:MI:SS')
FROM mytemp A
WHERE DATE_PART('day', endDate::date) -
DATE_PART('day',startDate::date) > 0
UNION ALL
SELECT ID,
TO_TIMESTAMP(CAST(CAST (endDate AS DATE) AS VARCHAR) ,
'YYYY-MM-DD HH24:MI:SS') ,
TO_TIMESTAMP(endDate,'YYYY-MM-DD HH24:MI:SS')
FROM mytemp A
WHERE DATE_PART('day', endDate::date) -
DATE_PART('day',startDate::date) > 0;
PostgreSQL Demo Here
demo:db<>fiddle
This works even when range crosses more than one day
WITH cte AS (
SELECT
id,
start_time,
end_time,
gs,
lag(gs) over (PARTITION BY id ORDER BY gs) -- 2
FROM
a
LEFT JOIN LATERAL
generate_series(start_time::date + 1, end_time::date, interval '1 day') gs --1
ON TRUE
)
SELECT -- 3
id,
COALESCE(lag, start_time) AS start_time,
gs - interval '1 second'
FROM
cte
WHERE gs IS NOT NULL
UNION
SELECT DISTINCT ON (id) -- 4
id,
CASE WHEN start_time::date = end_time::date THEN start_time ELSE end_time::date END, -- 5
end_time
FROM
cte
CTE: the generate_series function generates one row per day new day. So, there is no value if there is no date change
CTE: the lag() window function allows to move the current date value into the next row (the current end is the next start)
With this data set you can calculate the new start and end values. If there is no gs value: There is no date change. This ignored at this point. For all cases with date changes: If there is no lag value, it is the beginning (so it cannot got a previous value). In this case, the normal start_time is taken, otherwise it is a new day which takes the date break time. The end_time is taken with the last second of the day (interval - '1 second')
The second part: Because of the date breaks there is always one additional record which need to be unioned. The last record is from the beginning of the end_time (so cast to date). The CASE clause combines this step with the case of no date change which has been ignored so far. So if start_time and end_time are at the same date, here the original start_time is taken.
Unfortunately, Redshift doesn't have a convenient way to generate a series of numbers. If you table is big enough, you can use it to generate numbers. "Big enough" means that the number of rows is greater than the longest span. Perhaps another table would work, if not this one.
Once you have that, you can use this logic:
with n as (
select row_number() over () - 1 as n
from t
)
select t.id,
greatest(t.s, date_trunc('day', t.s) + n.n * interval '1 day') as s,
least(t.e, date_trunc('day', t.s) + (n.n + 1) * interval '1 day' - interval '1 second') as e
from t join
n
on t.e >= date_trunc('day', t.s) + n.n * interval '1 day';
Here is a db<>fiddle. It uses an old version of Postgres, but not quite old enough for Redshift.
Simulate loop for interval generation using recursive CTE, i.e. take range from start to midnight in seed row, take another day in subsequent rows etc.
with recursive input as (
select 'A' as id, timestamp '2019-03-04 23:18:04' as s, timestamp '2019-03-04 23:21:25' as e union
select 'A' as id, timestamp '2019-03-04 23:45:05' as s, timestamp '2019-03-05 00:15:14' as e union
select 'B' as id, timestamp '2019-03-06 23:45:05' as s, timestamp '2019-03-08 00:15:14' as e union
select 'C' as id, timestamp '2019-03-10 23:45:05' as s, timestamp '2019-03-15 00:15:14' as e
), generate_id as (
select row_number() over () as unique_id, * from input
), rec (unique_id, id, s, e) as (
select unique_id, id, s, least(e, s::date::timestamp + interval '1 day')
from generate_id seed
union
select remaining.unique_id, remaining.id, previous.e, least(remaining.e, previous.e::date::timestamp + interval '1 day')
from rec as previous
join generate_id remaining on previous.unique_id = remaining.unique_id and previous.e < remaining.e
)
select id, s, e from rec
order by id,s,e
Note:
your id column appears not to be unique, so I added custom unique_id column. If id was unique, CTE generate_id was unnecessary. Uniqueness is unavoidable for recursive query to work.
close-open range is better for representation of such data, rather than close-close range. So end time in my query returns 00:00:00, not 23:59:59. If it's not suitable for you, modify query as an exercise.
UPDATE: query works on Postgres. OP originally tagged question postgres, then changed tag to redshift.
I have a few fields in a database that look like this:
trip_id
start_date
end_date
start_station_name
end_station_name
I need to write a query that shows all the stations with no activity on a particular day in the year 2015. I wrote the following query but it's not giving the right output:
select
start_station_name,
extract(date from start_date) as dt,
count(*)
from
trips_table
where
(
start_date >= timestamp('2015-01-01')
and
start_date < timestamp('2016-01-01')
)
group by
start_station_name,
dt
order by
count(*)
Can someone help come up with the right query? Thanks in advance!
Below is for BigQuery Standard SQL
It assumes start_date and end_date are of DATE type
It also assumes that all days in between start_date and end_date are "dedicated" to station in start_station_name field, which most likely not what is expected but question is missing details here thus such an assumption
#standardSQL
WITH days AS (
SELECT day
FROM UNNEST(GENERATE_DATE_ARRAY('2015-01-01', '2015-12-31')) AS day
),
stations AS (
SELECT DISTINCT start_station_name AS station
FROM `trips_table`
)
SELECT s.*
FROM (SELECT * FROM stations CROSS JOIN days) AS s
LEFT JOIN (SELECT * FROM `trips_table`,
UNNEST(GENERATE_DATE_ARRAY(start_date, end_date)) AS day) AS a
ON s.day = a.day AND s.station = a.start_station_name
WHERE a.day IS NULL
You can test/play it with below simple/dummy data
#standardSQL
WITH `trips_table` AS (
SELECT 1 AS trip_id, DATE '2015-01-01' AS start_date, DATE '2015-12-01' AS end_date, '111' AS start_station_name UNION ALL
SELECT 2, DATE '2015-12-10', DATE '2015-12-31', '111'
),
days AS (
SELECT day
FROM UNNEST(GENERATE_DATE_ARRAY('2015-01-01', '2015-12-31')) AS day
),
stations AS (
SELECT DISTINCT start_station_name AS station
FROM `trips_table`
)
SELECT s.*
FROM (SELECT * FROM stations CROSS JOIN days) AS s
LEFT JOIN (SELECT * FROM `trips_table`,
UNNEST(GENERATE_DATE_ARRAY(start_date, end_date)) AS day) AS a
ON s.day = a.day AND s.station = a.start_station_name
WHERE a.day IS NULL
ORDER BY station, day
the output is like below
station day
111 2015-12-02
111 2015-12-03
111 2015-12-04
111 2015-12-05
111 2015-12-06
111 2015-12-07
111 2015-12-08
111 2015-12-09
Use recursion for this purpose: try this SQL SERVER
WITH sample AS (
SELECT CAST('2015-01-01' AS DATETIME) AS dt
UNION ALL
SELECT DATEADD(dd, 1, dt)
FROM sample s
WHERE DATEADD(dd, 1, dt) < CAST('2016-01-01' AS DATETIME)
)
SELECT * FROM sample
Where CAST(sample.dt as date) NOT IN (
SELECT CAST(start_date as date)
FROM tablename
WHERE start_date >= '2015-01-01 00:00:00'
AND start_date < '2016-01-01 00:00:00'
)
Option(maxrecursion 0)
If you want the station data with it then you can use left join as :
WITH sample AS (
SELECT CAST('2015-01-01' AS DATETIME) AS dt
UNION ALL
SELECT DATEADD(dd, 1, dt)
FROM sample s
WHERE DATEADD(dd, 1, dt) < CAST('2016-01-01' AS DATETIME)
)
SELECT * FROM sample
left join tablename
on CAST(sample.dt as date) = CAST(tablename.start_date as date)
where sample.dt>= '2015-01-01 00:00:00' and sample.dt< '2016-01-01 00:00:00' )
Option(maxrecursion 0)
For mysql, see this fiddle. I think this would help you....
SQL Fiddle Demo
Here in dummy data, you can see there are two columns Task_Completion and Time_stamp.there is a java scheduler that runs whenever the task is finish, for example, scheduler run on 15-FEB-2016 5 times and on 17-FEB only time So I wanted a query that calculates the start_time and end_time from the given column time_stamp
Task_Completion Time_stamp
true 15-FEB-16 11.37.56.013000000 AM
true 15-FEB-16 11.42.55.593000000 AM
true 15-FEB-16 11.47.48.970000000 AM
true 15-FEB-16 12.21.57.587000000 PM
true 15-FEB-16 12.26.55.767000000 PM
true 17-FEB-16 10.24.03.320000000 PM
true 18-FEB-16 10.19.04.333000000 PM
So the output must be like
start_time end_time
15-FEB-16 11.37.56.013000000 AM 15-FEB-16 11.47.48.970000000 AM
15-FEB-16 12.21.57.587000000 PM 15-FEB-16 12.26.55.767000000 PM
17-FEB-16 10.21.33.320000000 PM 17-FEB-16 10.26.33.320000000 PM
18-FEB-16 10.16.33.333000000 PM 18-FEB-16 10.21.33.333000000 PM
There are two conditions
condition 1. if there is a 0-5min gap in time_stamp for example on 15-Feb then start_time will be 11.37.56.013000000 and end_time will be 11.47.48.970000000 but if there is not, then again check is there any schedule runs on that day like on 15-Feb at 12.21.57.587000000(start_time) and 12.26.55.767000000(end_time)
condition2. if the schedule runs only once in a day then avg that time_stamp and add/sub 2.30 min from that time (i.e.) no need to check again
For Example
17-Feb 10.21.33.320000000(start_time) and 10.26.33.320000000(end_time) same as 18-Feb
So my logic was, to do a union of both condition1 and condition2 and this is I what end up with ...
--------------------condition2-------------------
select count(*), min(time_stamp) - interval '150' second start_date, max(time_stamp) + interval '150' second end_date
from serverstatus
group by to_char(time_stamp,'dd-mon-yy')
having count(*)=1
UNION ALL
------------condition1 query--------------------------
I need Condition1 Query
SELECT start_time - CASE num_grp_per_day
WHEN 1
THEN INTERVAL '150' SECOND
ELSE INTERVAL '0' SECOND
END AS start_time,
end_time + CASE num_grp_per_day
WHEN 1
THEN INTERVAL '150' SECOND
ELSE INTERVAL '0' SECOND
END AS end_time
FROM (
SELECT DISTINCT
MIN( time_stamp ) OVER ( PARTITION BY grp ) AS start_time,
MAX( time_stamp ) OVER ( PARTITION BY grp ) AS end_time,
COUNT( DISTINCT grp ) OVER ( PARTITION BY TRUNC( time_stamp ) ) AS num_grp_per_day
FROM (
SELECT time_stamp,
SUM( diff ) OVER ( ORDER BY time_stamp ) AS grp
FROM (
SELECT time_stamp,
CASE
WHEN time_stamp - LAG( time_stamp ) OVER ( ORDER BY time_stamp )
<= INTERVAL '5' MINUTE
THEN 0
ELSE 1
END AS diff
FROM your_table
)
)
)
ORDER BY start_time;
or, if you just want to expand all zero-width groups to a 5-minute interval (and not just when there is one per day) then:
SELECT MIN( time_stamp ) - CASE COUNT(*)
WHEN 1
THEN INTERVAL '150' SECOND
ELSE INTERVAL '0' SECOND
END AS start_time,
MAX( time_stamp ) + CASE COUNT(*)
WHEN 1
THEN INTERVAL '150' SECOND
ELSE INTERVAL '0' SECOND
END AS end_time
FROM (
SELECT time_stamp,
SUM( diff ) OVER ( ORDER BY time_stamp ) AS grp
FROM (
SELECT time_stamp,
CASE
WHEN time_stamp - LAG( time_stamp ) OVER ( ORDER BY time_stamp )
<= INTERVAL '5' MINUTE
THEN 0
ELSE 1
END AS diff
FROM your_table
)
)
GROUP BY grp;
I have a query which I want to order by year and then month. I have tryed order by to_date( depdate, 'mm' ) and TO_CHAR(depdate, 'YYYY/MM'). Here is an sqlfiddle to the table i am querying and the query itself sqlfiddle
You want to sort by the date value, not by the character string representation. That means that you also want to group by the date value. trunc(<<date column>>, 'mm') truncates a date to midnight on the first of the month. So something like this
SELECT to_char(trunc(DEPDATE,'MM'), 'Mon-YYYY') AS MONTH,
SUM(AMOUNTROOM) AS ROOMTOTAL,
SUM(AMOUNTEXTRAS) AS EXTRATOTAL,
SUM(AMOUNTEXTRAS + AMOUNTROOM) AS OATOTAL
FROM checkins
WHERE checkinstatus = 'D' AND depdate > TO_DATE('2013-12-01', 'yyyy/mm/dd')
AND depdate <= TO_DATE('2014-04-10', 'yyyy/mm/dd')
GROUP BY trunc(depdate,'mm')
ORDER BY trunc(depdate,'mm');
should be what you're looking for. See the updated fiddle
Check out this query. If it is a date field, just plain order by would work for you. You need not use TO_CHAR to convert to string and then sort:
WITH TAB AS
(
SELECT SYSDATE DATEVAL FROM DUAL
UNION
SELECT SYSDATE + 100 DATEVAL FROM DUAL
UNION
SELECT SYSDATE -500 DATEVAL FROM DUAL
UNION
SELECT SYSDATE + 30 DATEVAL FROM DUAL
UNION
SELECT SYSDATE -30 DATEVAL FROM DUAL
) SELECT * FROM TAB
ORDER BY DATEVAL DESC
See the results of below queries:
>> SELECT ADD_MONTHS(TO_DATE('30-MAR-11','DD-MON-RR'),-4) FROM DUAL;
30-NOV-10
>> SELECT ADD_MONTHS(TO_DATE('30-NOV-10','DD-MON-RR'),4) FROM DUAL;
31-MAR-11
How can I get '30-MAR-11' when adding 4 months to some date?
Please help.
There is another question here about Oracle and Java
It states that
From the Oracle reference on add_months http://download-west.oracle.com/docs/cd/B19306_01/server.102/b14200/functions004.htm
If date is the last day of the month or if the resulting month has fewer days than the day component of date, then the result is the last day of the resulting month. Otherwise, the result has the same day component as date.
So I guess you have to manually check stating day and ending day to change the behaviour of the function. Or maybe by adding days instead of months. (But I didn't find a add_day function in the ref)
As a workaround, I might possibly use this algorithm:
Calculate the target date TargetDate1 using ADD_MONTHS.
Alternatively calculate the target date TargetDate2 like this:
1) apply ADD_MONTHS to the first of the source date's month;
2) add the difference of days between the source date and the beginning of the same month.
Select the LEAST between the TargetDate1 and TargetDate2.
So in the end, the target date will contain a different day component if the source date's day component is greater than the number of day in the target month. In this case the target date will be the last day of the corresponding month.
I'm not really sure about my knowledge of Oracle's SQL syntax, but basically the implementation might look like this:
SELECT
LEAST(
ADD_MONTHS(SourceDate, Months),
ADD_MONTHS(TRUNC(SourceDate, 'MONTH'), Months)
+ (SourceDate - TRUNC(SourceDate, 'MONTH'))
) AS TargetDate
FROM (
SELECT
TO_DATE('30-NOV-10', 'DD-MON-RR') AS SourceDate,
4 AS Months
FROM DUAL
)
Here is a detailed illustration of how the method works:
SourceDate = '30-NOV-10'
Months = 4
TargetDate1 = ADD_MONTHS('30-NOV-10', 4) = '31-MAR-11' /* unacceptable */
TargetDate2 = ADD_MONTHS('01-NOV-10', 4) + (30 - 1)
= '01-MAR-11' + 29 = '30-MAR-11' /* acceptable */
TargetDate = LEAST('31-MAR-11', '30-MAR-11') = '30-MAR-11'
And here are some more examples to show different cases:
SourceDate | Months | TargetDate1 | TargetDate2 | TargetDate
-----------+--------+-------------+-------------+-----------
29-NOV-10 | 4 | 29-MAR-11 | 29-MAR-11 | 29-MAR-11
30-MAR-11 | -4 | 30-NOV-10 | 30-NOV-10 | 30-NOV-10
31-MAR-11 | -4 | 30-NOV-10 | 01-DEC-10 | 30-NOV-10
30-NOV-10 | 3 | 28-FEB-11 | 02-MAR-11 | 28-FEB-11
You can use interval arithmetic to get the result you want
SQL> select date '2011-03-30' - interval '4' month
2 from dual;
DATE'2011
---------
30-NOV-10
SQL> ed
Wrote file afiedt.buf
1 select date '2010-11-30' + interval '4' month
2* from dual
SQL> /
DATE'2010
---------
30-MAR-11
Be aware, however, that there are pitfalls to interval arithmetic if you're working with days that don't exist in every month
SQL> ed
Wrote file afiedt.buf
1 select date '2011-03-31' + interval '1' month
2* from dual
SQL> /
select date '2011-03-31' + interval '1' month
*
ERROR at line 1:
ORA-01839: date not valid for month specified
How about something like this:
SELECT
LEAST(
ADD_MONTHS(TO_DATE('30-MAR-11','DD-MON-RR'),-4),
ADD_MONTHS(TO_DATE('30-MAR-11','DD-MON-RR')-1,-4)+1
)
FROM
DUAL
;
Result: 30-NOV-10
SELECT
LEAST(
ADD_MONTHS(TO_DATE('30-NOV-10','DD-MON-RR'),4),
ADD_MONTHS(TO_DATE('30-NOV-10','DD-MON-RR')-1,4)+1
)
FROM
DUAL
;
Result: 30-MAR-11
the add_months function returns a date plus n months.
Since 30th November is the last date of the month, adding 4 months will result in a date that's the end of 4 months. This is expected behavior. If the dates are not bound to change, a workaround is to subtract a day after the new date has been returned
SQL> SELECT ADD_MONTHS(TO_DATE('30-NOV-10','DD-MON-RR'),4) -1 from dual;
ADD_MONTH
---------
30-MAR-11
SELECT TO_DATE('30-NOV-10','DD-MON-RR') +
(
ADD_MONTHS(TRUNC(TO_DATE('30-NOV-10','DD-MON-RR'),'MM'),4) -
TRUNC(TO_DATE('30-NOV-10','DD-MON-RR'),'MM')
) RESULT
FROM DUAL;
This section in paranthesis:
ADD_MONTHS(TRUNC(TO_DATE('30-NOV-10','DD-MON-RR'),'MM'),4) - TRUNC(TO_DATE('30-NOV-10','DD-MON-RR'),'MM')
gives you number of days between the date you entered and 4 months later. So, adding this number of days to the date you given gives the exact date after 4 months.
Ref: http://www.dba-oracle.com/t_test_data_date_generation_sql.htm
Simple solution:
ADD_MONTHS(date - 1, x) + 1
Here is the trick:
select add_months(to_date('20160228', 'YYYYMMDD')-1, 1)+1 from dual;
Enjoy!
We have come to simpler (in our understanding) solution to this problem - take the least day number from original and add_month result dates, as this:
TRUNC(ADD_MONTHS(input_date,1),'MM') + LEAST(TO_CHAR(input_date, 'DD'), TO_CHAR(ADD_MONTHS(input_date,1), 'DD')) - 1
Some other examples here do not work on every date, below our test results:
WITH DATES as (
SELECT TO_DATE('2020-01-31', 'YYYY-MM-DD HH24:MI:SS') as input_date,
'2020-02-29' as expected_date
FROM dual
UNION ALL
SELECT TO_DATE('2020-02-28', 'YYYY-MM-DD HH24:MI:SS'),
'2020-03-28'
FROM dual
UNION ALL
SELECT TO_DATE('2020-09-30', 'YYYY-MM-DD HH24:MI:SS'),
'2020-10-30'
FROM dual
UNION ALL
SELECT TO_DATE('2020-09-01', 'YYYY-MM-DD HH24:MI:SS'),
'2020-10-01'
FROM dual
UNION ALL
SELECT TO_DATE('2019-01-30', 'YYYY-MM-DD HH24:MI:SS'),
'2019-02-28'
FROM dual
UNION ALL
SELECT TO_DATE('2020-02-29', 'YYYY-MM-DD HH24:MI:SS'),
'2020-03-29'
FROM dual
UNION ALL
SELECT TO_DATE('2020-09-29', 'YYYY-MM-DD HH24:MI:SS'),
'2020-10-29'
FROM dual
UNION ALL
SELECT TO_DATE('2020-03-01', 'YYYY-MM-DD HH24:MI:SS'),
'2020-04-01'
FROM dual
),
methods as (
SELECT
input_date,
expected_date,
ADD_MONTHS(input_date,1) as standard_way,
add_months(input_date-1, 1)+1 as wrong_way,
TO_DATE(LEAST(TO_CHAR(input_date, 'DD'), TO_CHAR(ADD_MONTHS(input_date,1), 'DD')) || '-' || TO_CHAR(ADD_MONTHS(input_date,1), 'MM-YYYY'), 'DD-MM-YYYY') as good_way,
TRUNC(ADD_MONTHS(input_date,1),'MM') + LEAST(TO_CHAR(input_date, 'DD'), TO_CHAR(ADD_MONTHS(input_date,1), 'DD')) - 1 as better_way
FROM
DATES
)
SELECT
input_date,
expected_date,
standard_way,
CASE WHEN TO_CHAR(standard_way,'YYYY-MM-DD') = expected_date THEN 'OK' ELSE 'NOK' END as standard_way_ok,
wrong_way,
CASE WHEN TO_CHAR(wrong_way,'YYYY-MM-DD') = expected_date THEN 'OK' ELSE 'NOK' END as wrong_way_ok,
good_way,
CASE WHEN TO_CHAR(good_way,'YYYY-MM-DD') = expected_date THEN 'OK' ELSE 'NOK' END as good_way_ok,
better_way,
CASE WHEN TO_CHAR(better_way,'YYYY-MM-DD') = expected_date THEN 'OK' ELSE 'NOK' END as better_way_ok
FROM
methods
;
CREATE OR REPLACE FUNCTION My_Add_Month(
STARTDATE DATE,
MONTHS_TO_ADD NUMBER
)
RETURN DATE
IS
MY_ADD_MONTH_RESULT DATE;
BEGIN
SELECT ORACLES_ADD_MONTH_RESULT + NET_DAYS_TO_ADJUST INTO MY_ADD_MONTH_RESULT FROM
(
SELECT T.*,CASE WHEN SUBSTRACT_DAYS > ADD_DAYS THEN ADD_DAYS - SUBSTRACT_DAYS ELSE 0 END AS NET_DAYS_TO_ADJUST FROM
(
SELECT T.*,EXTRACT(DAY FROM ORACLES_ADD_MONTH_RESULT) AS SUBSTRACT_DAYS FROM
(
SELECT ADD_MONTHS(STARTDATE,MONTHS_TO_ADD) AS ORACLES_ADD_MONTH_RESULT,EXTRACT(DAY FROM STARTDATE) AS ADD_DAYS FROM DUAL
)T
)T
)T;
RETURN TRUNC(MY_ADD_MONTH_RESULT);
END My_Add_Month;
/
--test & verification of logic & function both
SELECT T.*,ORACLES_ADD_MONTH_RESULT + NET_DAYS_TO_ADJUST AS MY_ADD_MONTH_RESULT,
My_Add_Month(STARTDATE,MONTHS_TO_ADD) MY_ADD_MONTH_FUNCTION_RESULT
FROM
(
SELECT T.*,CASE WHEN SUBSTRACT_DAYS > ADD_DAYS THEN ADD_DAYS - SUBSTRACT_DAYS ELSE 0 END AS NET_DAYS_TO_ADJUST FROM
(
SELECT T.*,EXTRACT(DAY FROM ORACLES_ADD_MONTH_RESULT) AS SUBSTRACT_DAYS FROM
(
SELECT T.*,ADD_MONTHS(STARTDATE,MONTHS_TO_ADD) AS ORACLES_ADD_MONTH_RESULT,EXTRACT(DAY FROM STARTDATE) AS ADD_DAYS FROM
(
SELECT TO_DATE('28/02/2014','DD/MM/YYYY') AS STARTDATE, 1 AS MONTHS_TO_ADD FROM DUAL
)T
)T
)T
)T;
Query-result
STARTDATE 2/28/2014
MONTHS_TO_ADD 1
ORACLES_ADD_MONTH_RESULT 3/31/2014
ADD_DAYS 28
SUBSTRACT_DAYS 31
NET_DAYS_TO_ADJUST -3
MY_ADD_MONTH_RESULT 3/28/2014
MY_ADD_MONTH_FUNCTION_RESULT 3/28/2014