Create date from year, month and day - sql

Does Oracle have a builtin function to create a date from its individual components (year, month and day) that just returns null on missing data?
I'm aware of TO_DATE() but I need to compose a string first and neither the || operator nor the CONCAT() function make it easy to handle missing data:
-- my_year NUMBER(4,0) NULL
SELECT TO_DATE(my_year || '-01-01', 'YYYY-MM-DD') AS my_date
FROM my_table;
Whenever my_year is NULL we end up with TO_DATE('-01-01', 'YYYY-MM-DD') and:
ORA-01841: (full) year must be between -4713 and +9999, and not be 0

For your example, you can use case:
select (case when my_year is not null and my_year <> 0 and
my_year between -4713 and 9999
then TO_DATE(my_year || '-01-01', 'YYYY-MM-DD')
end)
Unfortunately, Oracle does not have a method of doing the conversion, if possible, and otherwise returning NULL. SQL Server recently introduced try_convert() for this purpose.
One option is to write your own function with an exception handler for the failed conversion. The exception handler would simply return NULL for a bad format.

You can't use year zero with to_date('0000-01-01', 'YYYY-MM-DD'), but oddly you can with a date literal date '0000-01-01'. On its own that becomes year -1, but you can use it calculations; and you can add an interval too which can be based on your numeric value, e.g.:
SELECT DATE '0000-01-01' + NUMTOYMINTERVAL(my_year, 'YEAR') AS my_date
FROM my_table;
The numtoyminterval function returns null if the argument is null, and adding that to a fixed date also gives you null:
alter session set nls_date_format = 'SYYYY-MM-DD';
select date '0000-01-01' + numtoyminterval(null, 'YEAR') from dual;
DATE'0000-01-01'+NUMTOYMINTERVAL(NULL,'YEAR')
---------------------------------------------
select date '0000-01-01' + numtoyminterval(2015, 'YEAR') from dual;
DATE'0000-01-01'+NUMTOYMINTERVAL(2015,'YEAR')
---------------------------------------------
2015-01-01
select date '0000-01-01' + numtoyminterval(9999, 'YEAR') from dual;
DATE'0000-01-01'+NUMTOYMINTERVAL(9999,'YEAR')
---------------------------------------------
9999-01-01
select date '0000-01-01' + numtoyminterval(-4712, 'YEAR') from dual;
DATE'0000-01-01'+NUMTOYMINTERVAL(-4712,'YEAR')
----------------------------------------------
-4712-01-01
It isn't foolproof; it will still error if you try to go before -4713:
select date '0000-01-01' + numtoyminterval(-4713, 'YEAR') from dual;
SQL Error: ORA-01841: (full) year must be between -4713 and +9999, and not be 0
...
Though you can avoid that with a check constraint on the column. And because of the silent translation of year 0 to -1, you get the same answer if your my_year value is 0 or -1:
select date '0000-01-01' + numtoyminterval(0, 'YEAR') from dual;
DATE'0000-01-01'+NUMTOYMINTERVAL(0,'YEAR')
------------------------------------------
-0001-01-01
select date '0000-01-01' + numtoyminterval(-1, 'YEAR') from dual;
DATE'0000-01-01'+NUMTOYMINTERVAL(-1,'YEAR')
-------------------------------------------
-0001-01-01
Gordon Linoff's case approach is more robust, but this might work if you're only really dealing with 'sane' positive years. And if not, it's mildly interesting...

I've eventually composed a user-defined function to encapsulate the logic. It returns a date from its individual components or NULL if the date is not valid:
CREATE OR REPLACE FUNCTION TRY_TO_DATE (
V_YEAR IN NUMBER,
V_MONTH IN NUMBER,
V_DAY IN NUMBER
) RETURN DATE DETERMINISTIC IS
BEGIN
RETURN TO_DATE(LPAD(V_YEAR, 4, '0') || LPAD(V_MONTH, 2, '0') || LPAD(V_DAY, 2, '0'), 'YYYY-MM-DD');
EXCEPTION
WHEN OTHERS THEN RETURN NULL;
END TRY_TO_DATE;
/
Fiddle

where my_year > 0
This will just return rows where it's possible to create a date. Ignoring rows with null or with a value of 0. If you have some values not in the range -4713 and +9999, you should of course exclude those in the where clause too.

Related

More on extracting year from date - Oracle

I have a question about selecting year from a date. This is in Oracle database 12c.
Given that SELECT trunc(SYSDATE) FROM DUAL; returns 02/06/2020
These work proper and return current year of 2020 -
SELECT EXTRACT(YEAR FROM trunc(SYSDATE)) FROM DUAL;
SELECT TO_CHAR(trunc(SYSDATE,'YYYY')) FROM DUAL;
These do not work and give error -
SELECT EXTRACT(YEAR FROM '02/06/2019') FROM DUAL;
Gives error: ORA-30076: invalid extract field for extract source
SELECT TO_CHAR('02/06/2019','YYYY') FROM DUAL;
Gives error: ORA-01722: invalid number
The same format is being passed with sysdate and hard coded date of 02/06/2019. Why is it that one works and the other does not?
I know I could just select 2019 from dual but that is not the point or use case here.
You can't extract year from a string (which '02/06/2019' is). First convert it to date:
SQL> SELECT EXTRACT(YEAR FROM to_date('02/06/2019', 'dd/mm/yyyy')) year FROM DUAL;
YEAR
----------
2019
SQL>
Or, if you know that last 4 digits are valid year, then
SQL> select substr('02/06/2019', -4) year from dual;
YEAR
----
2019
SQL>
It comes down to the data type being passed. sysdate by default is a DATE field. A hard date like '02/06/2020' by default is considered a string.
To get around that, just cast the string as a date. All good.
SELECT TO_CHAR(cast('6-feb-2019' as date),'YYYY') FROM DUAL;

Find date in a specific week

I would like to select an item where the date is in a specific week in the year.
For example :
SELECT item
FROM table
WHERE my_date in week 44
Where week 44 is from monday 31/10 to sunday 06/11.
I know to get the week number with
to_number(to_char(to_date(my_date,'MM/DD/YYYY'),'IW'))
How can I do that ?
Thanks.
Try:
SELECT item
FROM table
WHERE to_char( my_date, 'IW ) = '44'
See this link for details:
https://docs.oracle.com/cd/B19306_01/server.102/b14200/sql_elements004.htm#i34924
IW ---> Week of year (1-52 or 1-53) based on the ISO standard.
Working with ISO Weeks is not trivial because week 1 can start in previous year and first days in January may be counted as week 52 or 53.
So, providing just week number without a year can be ambiguous (for week number 52, 53, 1).
The best function I found in order to get the first day of an ISO-Week is
NEXT_DAY(TO_DATE( yearNo || '0104', 'YYYYMMDD' ) - INTERVAL '7' DAY, 'MONDAY') + ( weekNo - 1 ) * 7
So, for your need it would be
SELECT item
FROM table
WHERE my_date
between NEXT_DAY(TO_DATE( yearNo || '0104', 'YYYYMMDD' ) - INTERVAL '7' DAY, 'MONDAY') + ( weekNo - 1 ) * 7
AND 6 + (NEXT_DAY(TO_DATE( yearNo || '0104', 'YYYYMMDD' ) - INTERVAL '7' DAY, 'MONDAY') + ( weekNo - 1 ) * 7)
Actually the "full error proven" way would be this one:
FUNCTION ISOWeekDate(weekNo INTEGER, yearNo INTEGER) RETURN DATE DETERMINISTIC IS
res DATE;
BEGIN
IF weekNo > 53 OR weekNo < 1 THEN
RAISE VALUE_ERROR;
END IF;
res := NEXT_DAY(TO_DATE( yearNo || '0104', 'YYYYMMDD' ) - INTERVAL '7' DAY, 'MONDAY') + ( weekNo - 1 ) * 7;
IF TO_CHAR(res, 'fmIYYY') = yearNo THEN
RETURN res;
ELSE
RAISE VALUE_ERROR;
END IF;
END ISOWeekDate;
If the data type of the my_date column is, in fact, VARCHAR2 (which it shouldn't be, but it often is), then you do need to convert it to date first, and then back to char (string) in a different format. Then, if 44 is not necessarily a number but it may be the result of a calculation, or a subquery, or a value in another table (and if it is of NUMBER data type), then you need to do exactly what you were doing. What was the problem with it?
select item
from your_table
where to_number(to_char(to_date(my_date,'MM/DD/YYYY'),'IW')) = 44;
Perhaps you want to know if this can be done more efficiently? So, for example, can you instead write the where condition as
my_date between (something) and (something else)
which would allow the use of an index on my_date? The answer is NO, if my_date is in VARCHAR2 format - you would still have to wrap it within to_date() so you would lose the index.
But, you may build an index function on to_date(my_date) which perhaps would help you in other queries as well. So then the question of making your present query more efficient is meaningful. Now you run into the complication of ISO week not being well defined (you are not also saying which YEAR), so the assumption is that you want the date to be in ISO week 44 of whatever year it is in. If the date is a pure date (time component equal to 00:00:00), you could do this:
where to_date(my_date, 'MM/DD/YYYY') between
trunc(sysdate, 'iw') + 7 * (44 - to_number(to_char(sysdate, 'iw'))) and
trunc(sysdate, 'iw') + 7 * (44 - to_number(to_char(sysdate, 'iw'))) + 6
There are a lot of computation on the right-hand side, but they are done just once for all the rows in your table; they will take essentially no time at all.

How to convert a sysdate month value to number in oracle?

Im trying to return the CARDS of my CARD table that will expire in the next month. But the problem is that the table has two columns to represent the card date. The columns are EXPIREDAY and EXPIREMONTH ,both are numbers. So when i do that query i get an error:
select * from CARD WHERE EXPIREDAY <= sysdate - interval '2' DAY;
//Oracle error: ORA-00932: inconsistent datatypes: expected NUMBER got DATE
Is there a way to convert the sysdate - interval '2' DAY as Number data type?
Thanks!
If you want to compare the values as strings you can use this to convert the SYSDATE
SELECT TO_CHAR(sysdate, 'MM') || TO_CHAR(sysdate, 'DD') MONTH_NUM FROM DUAL
-- gives you "0922"
and this for your numeric columns which will pad with leading zeros if you only have a single digit
SELECT TO_CHAR(9, 'FM00') || TO_CHAR(22, 'FM00') MONTH_NUM FROM DUAL
-- also gives you "0922"
If you have control over the table schema it would be best practise to store both the DAY and MONTH values in a single numeric field, so that 9-SEP would be stored in this column as the numeric value 0922 where the month is first so that the natural ordering is used.
A simple and not necessarily very efficient approach is to convert the day and month values into an actual date, using to_date(), and then compare that with your target date range:
select * from card
where to_date(lpad(expireday, 2, '0')
||'/'|| lpad(expiremonth, 2, '0'), 'DD/MM')
between sysdate and add_months(sysdate, 1);
Which appears to work. But this will have problems if the dates span the end of the year. Because your table doesn't specify the year, you either have to work one out, or allow to_date to default it to the current year. And if you let it default then it won't work. For example, if you have values for December and January in your table, and run this query in December, then the January dates will be seen as January 2014, and won't be counted as being in the next month. So you'll need to do more to pick the right year.
This treats any month numbers before the current one as being next year, which may be good enough for you as you only have a one-month window:
select * from card
where to_date(lpad(expireday, 2, '0')
||'/'|| lpad(expiremonth, 2, '0')
||'/'|| (extract(year from sysdate) +
case when expiremonth < extract(month from sysdate) then 1 else 0 end),
'DD/MM/YYYY')
between sysdate and add_months(sysdate, 1);
SQL Fiddle using a date range from December to January.
And you can see the ways the two columns are being combined to form a date in this Fiddle.
As so often, the moral is... store things as the right data type. Store dates as dates, not as string or numbers.
Im trying to return the CARDS of my CARD table that will expire in the next month. But the problem is that the table has two columns to represent the card date.
Assuming:
you are using floating months (say: from 23 dec. to 23 jan.) and
your table somehow only contains one (floating ?) year of data
Why can't you use simple arithmetics? Like that:
-- some constant definitions for testing purpose
with cst as (
select EXTRACT(DAY from TO_DATE('23/12','DD/MM')) as theDay,
EXTRACT(MONTH from TO_DATE('23/12','DD/MM')) as theMonth
from dual)
-- the actual query
select card.* from card,cst
where (expiremonth = theMonth AND expireday > theDay)
or (expiremonth = 1+MOD(theMonth,12) AND expireday <= theDay);
-- ^^^^^^^^^^^^^^^^^^
-- map [01 .. 12] to [02 .. 12, 01] (i.e.: next month)
This will simply select all "pseudo-dates" from tomorrow to the end of the month, as well as any one before (and including) the current day# next month.
See this example.
For something a little bit more generic, but probably more efficient than converting all your values TO_DATE, you might want to try that:
-- the calendar is the key part of the query (see below)
with calendar as (
select extract(month from sysdate + level) as theMonth,
extract(day from sysdate + level) as theDay
from DUAL connect by ROWNUM <= 8)
-- ^
-- adjust to the right number of days you are looking for
select card.* from card join calendar
on expiremonth = theMonth and expireDay = theDay
The idea here is to simply build a calendar with all the upcoming days and then join your data table on that calendar. See an example here.
Try using to_char(sysdate - interval '2' DAY,'ddmmyyyy') to convert to character type. The date format('ddmmyyyy') will depend of the value of expiredate

PL/SQL newbie: Functions with SELECT and SYSDATE

I'm new to SQL and was hoping for some help turning this into a function:
SELECT
SYSDATE "Today's Date",
NEXT_DAY(trunc(SYSDATE, 'MONTH')-1, 'Tuesday')"First Tuesday this Month",
NEXT_DAY (LAST_DAY(SYSDATE)+1,'TUESDAY') "First Tuesday of Next Month"
FROM DUAL;
Here's my attempt at turning the above into a function (as you can see it didn't go so hot)...
CREATE OR REPLACE FUNCTION first_tuesday
RETURN DATE
AS
date1 DATE;
date2 DATE;
date3 DATE;
BEGIN
date1 := SELECT SYSDATE;
date2 := SELECT NEXT_DAY(trunc(SYSDATE, 'MONTH')-1, 'Tuesday');
date3 := SELECT NEXT_DAY (LAST_DAY(SYSDATE)+1,'TUESDAY');
RETURN 'todays date:' || date1,
'first tuesday this month:' || date2,
'first tuesday next month:' || date3;
END;
/
SELECT first_tuesday FROM DUAL;
What am I doing wrong?
You're really trying to return three values there. I'd suggest breaking it out into separate functions, but leave "today's date" just as a call to sysdate.
You don't need to issue a SELECT query to populate a variable, and in this case you can just return an expression.
CREATE OR REPLACE FUNCTION first_tuesday
RETURN DATE
AS
BEGIN
RETURN NEXT_DAY(trunc(SYSDATE, 'MONTH')-1, 'Tuesday')
END;
/
Something to look out for here is that within the execution of a SQL statement, SYSDATE always has the same value, but that's not the case in PL/SQL. So when sysdate is evaluated in PL/SQL that is called from a SQL statement, you can get a different value in each context. So if you call:
select sysdate col1,
some_function col2
from dual;
... and during it's lengthy execution some_function calculates sysdate, it could be using a slightly different (later) value than the parent SQL statement.
The way to avoid this is to pass sysdate into the function:
CREATE OR REPLACE FUNCTION first_tuesday (sysdate_in date)
RETURN DATE
AS
BEGIN
RETURN NEXT_DAY(trunc(sysdate_in, 'MONTH')-1, 'Tuesday')
END;
/
So then you might ...
Select ...
From invoices
Where invoice_date < first_tuesday(sysdate)
That's a trivial example just to demonstrate the principle, and it really only matters for functions called from SQL.
To be honest, most practitioners in your situation would probably skip the function and just:
Select ...
From invoices
Where invoice_date < NEXT_DAY(trunc(sysdate, 'MONTH')-1, 'Tuesday')
Edit: by the way, you probably want to use:
NEXT_DAY (LAST_DAY(SYSDATE),'TUESDAY')
... not ...
NEXT_DAY (LAST_DAY(SYSDATE)+1,'TUESDAY')

to_date ora-01847 day of month must be between 1 and last day of month

I am trying to execute the below query but getting this error:
to_date ora-01847 day of month must be between 1 and last day of month
query is to check overlaping of date and time in two different tables
table1 (emp_num1 number,start_date1 date,start_time1 varchar2,end_date1 date,end_time2 varchar2)
table2(emp_num2 number,start_date2 date,start_time2 varchar2,end_date2 date,end_time2 varchar2)
select *
from table1 t1
,table2 t2
where t1.emp_num 1 = t2.emp_num2
and to_timestamp(to_char(start_date1,'DD-MON-YYYY')||' '||NVL(start_time1,'00:00'),'DD-MON-YYYY HH24:MI')
between
to_timestamp(to_char(start_date2,'DD-MON-YYYY')||' '||NVL(start_time2,'00:00'),'DD-MON-YYYY HH24:MI')
and
to_timestamp(to_char(end_date2,'DD-MON-YYYY')||' '||NVL(end_time2,'00:00'),'DD-MON-YYYY HH24:MI')
or
to_timestamp(to_char(end_date1,'DD-MON-YYYY')||' '||NVL(end_time1,'00:00'),'DD-MON-YYYY HH24:MI')
between
to_timestamp(to_char(start_date2,'DD-MON-YYYY')||' '||NVL(start_time2,'00:00'),'DD-MON-YYYY HH24:MI')
and
to_timestamp(to_char(end_date2,'DD-MON-YYYY')||' '||NVL(end_time2,'00:00'),'DD-MON-YYYY HH24:MI')
the above query resulting the error:
to_date ora-01847 day of month must be between 1 and last day of month
I tried running query
select to_timestamp(to_char(start_date1,'DD-MON-YYYY')||' '||NVL(start_time1,'00:00'),'DD-MON-YYYY HH24:MI') from table1
no error is encountered.
To reproduce the error:
SELECT NVL(SYSDATE,'00:00')
FROM DUAL
ORA-01847: day of month must be between 1 and last day of month
The NVL() description says:
The arguments expr1 and expr2 can have any data type. If their data
types are different, then Oracle Database implicitly converts one to
the other. If they cannot be converted implicitly, then the database
returns an error. The implicit conversion is implemented as follows:
If expr1 is character data, then Oracle Database converts expr2 to the data type of expr1 before comparing them and returns VARCHAR2 in
the character set of expr1.
If expr1 is numeric, then Oracle Database determines which argument has the highest numeric precedence, implicitly converts the other
argument to that data type, and returns that data type.
So we can simplify the reproduce case:
SELECT TO_DATE('00:00')
FROM DUAL
Since you don't provide a format, it's assuming NLS_DATE_FORMAT, and thus the error: '00' is not a valid day.
(I don't really know what you're trying to do but you can try using pure date functions.)
Your formatting is need to be fixed. Here's your initial setup:
SELECT '07-MAR-2013' start_date -- char
, NULL start_time -- char
FROM dual
/
Final formatting - NO NVL required. But if you must then add NVL(start_time, '00:00'):
SELECT to_timestamp(start_date||' '||start_time, 'DD-MON-YYYY HH24:MI:SS') t_stamp
FROM
(
SELECT '07-MAR-2013' start_date -- char
, NVL(NULL, '00:00') start_time -- char - NVL is optional
FROM dual
)
/
3/7/2013 12:00:00.000000000 AM
You will get the same result if you remove start_time compl. Generally to compare dates you should use TRUNC to remove time portion.
SELECT to_timestamp('07-MAR-2013', 'DD-MON-YYYY HH24:MI:SS') start_date_only
FROM dual
/
3/7/2013 12:00:00.000000000 AM
SELECT trunc(to_timestamp('07-MAR-2013', 'DD-MON-YYYY HH24:MI:SS')) start_date_only FROM dual
/
3/7/2013