home | O'Reilly's CD bookshelfs | FreeBSD | Linux | Cisco | Cisco Exam  


14.3 Conversion Function Examples

This section shows how you can use the conversion functions we've described in actual PL/SQL examples.

14.3.1 FM: Suppressing Blanks and Zeros

PL/SQL offers the FM element as a modifier to a format mask. FM (fill mode) controls the suppression of padded blanks and leading zeros in values returned by the TO_CHAR function.

By default, the following format mask results in both padded blanks and leading zeros (there are five spaces between the month name and the day number):

TO_CHAR (SYSDATE, 'Month DD, YYYY') ==> 'April     05, 1994'

With the FM modifier at the beginning of the format mask, however, both the extra blank and the leading zeros disappear:

TO_CHAR (SYSDATE, 'FMMonth DD, YYYY') ==> April 5, 1994'

The modifier can be specified in upper-, lower-, or mixed-case; the effect is the same.

The FM modifier is a toggle, and can appear more than once in a format model. Each time it appears in the format, it changes the effect of the modifier. By default (that is, if FM is not specified anywhere in a format mask), blanks are not suppressed and leading zeros are included in the result value. So the first time that FM appears in the format it indicates that blanks and leading zeros are suppressed for any following elements. The second time that FM appears in the format, it indicates that blanks and leading zeros are not suppressed for any following elements, and so on.

In the following example I suppress the padded blank at the end of the month name, but preserve the leading zero on the day number with a second specification of FM:

TO_CHAR (SYSDATE, 'fmMonth FMDD, YYYY') ==> April 05, 1994'

If you do not use FM in your mask, a converted date value is always right-padded with blanks to a fixed length (that length is dependent on the different format elements you use). When you do use FM, on the other hand, the length of your return value may vary depending on the actual values returned by the different format elements.

When you do not use FM to convert a number to a character string, the resulting value is always left-padded with blanks so that the number is right-justified to the length specified by the format (or declaration of the variable). When you do use FM, the left-padded blanks are suppressed and the resulting value is left-justified.

Here are some examples of the impact of FM on numbers converted with TO_CHAR:

TO_CHAR (8889.77, 'L9999D99')   ==> '      $8889.77'
TO_CHAR (8889.77, 'fmL9999D99') ==> '$8889.77'

The FM modifier can also be used in the format model of a call to the TO_DATE function to fill a string with blanks or zeros to match the format model. This variation of FM is explored in the discussion of FX.

14.3.2 FX: Matching Formats Exactly

PL/SQL offers the FX element as a modifier to a format mask. FX (format exact) specifies that an exact match must be performed for a character argument and date format mask in a call to the TO_DATE function.

If FX is not specified, the TO_DATE function does not require that the character string match the format precisely. It makes the following allowances:

  • Extra blanks in the character string are ignored. Blanks are not significant data in any of the parts of a date value, except to delimit separate parts of the date and time:

    TO_DATE ('Jan         15       1994', 'MON DD YYYY') ==> 15-JAN-1994
  • Numeric values, such as the day number or the year, do not have to include leading zeros to fill out the mask. As long as the numbers are in the right place in the string (as determined, usually, by the delimiter characters in the string), TO_DATE can convert the numeric values properly:

    TO_DATE ('1-1-4', 'DD-MM-YYYY') ==> 01-JAN-0004
    TO_DATE ('7/16/94', 'MM/DD/YY') ==> 14-JUL-1994
  • Punctuation and literals in the string to be converted can simply match the length and position of punctuation and quoted text in the format. In the following example, my format mask specifies hyphen ( - ) delimiters and the text "WhatIsaynotdo" between the day and the year. The string that I pass it, on the other hand, uses caret ( ^ ) delimiters and "the year of" for the embedded text. Because both of the two literals inside quotes have the same number of characters, TO_DATE has no problem making the match.

TO_DATE ('JANUARY^1^ the year of 94', 'Month-dd-"WhatIsaynotdo"yy')
==> 01-JAN-1994

This kind of flexibility is great -- until you want to actually restrict a user or even a batch process from entering data in a nonstandard format. In some cases, it simply is not a reflection of everything being OK when a date string has a pound sign ( # ) instead of a hyphen ( - ) between the day and month numbers. For these situations, you can use the FX modifier to enforce an exact match between string and format model.

With FX, there is no flexibility for interpretation of the string. It cannot have extra blanks if none are found in the model. Its numeric values must include leading zeros if the format model specifies additional digits. And the punctuation and literals must exactly match the punctuation and quoted text of the format mask (except for case, which is always ignored). In all of the following examples, PL/SQL raises one of the following errors:

ORA-01861: literal does not match format string
ORA-01862: wrong number of digits for this format item

TO_DATE ('Jan 15 1994', 'fxMON DD YYYY')
TO_DATE ('1-1-4', 'fxDD-MM-YYYY')
TO_DATE ('7/16/94', 'FXMM/DD/YY')
TO_DATE ('JANUARY^1^ the year of 94', 'FXMonth-dd-"WhatIsaynotdo"yy')

The FX modifier can be specified in upper-, lower-, or mixed-case; the effect is the same.

The FX modifier is a toggle, and can appear more than once in a format model. Each time it appears in the format, it changes the effect of the modifier. By default (that is, if FX is not specified anywhere in a format mask), an exact match is not required in any part of the string (as described above). So the first time that FX appears in the format it turns on exact matching for any following elements. The second time that FX appears in the format it indicates that an exact match is not required for any following elements, and so on.

In the following example I specify FX three times. As a result, an exact match is required for the day number and the year number, but not the month number:

TO_DATE ('07-1-1994', 'FXDD-FXMM-FXYYYY') ==> 07-JUL-1994

This next attempt at date conversion will raise ORA-01862 because the year number is not fully specified:

TO_DATE ('07-1-94', 'FXDD-FXMM-FXYYYY') -- Invalid string for format!

You saw in the previous section how the FM modifier would strip leading blanks and zeros from the output of a call to TO_CHAR. You can also use FM in the format model of a call to the TO_DATE function to fill a string with blanks or zeros. This action matches the format model (the opposite of the suppression action). You can, in other words, use FM to guarantee that a format exact match required by FX will succeed. The following call to TO_DATE will return a date because the fm at the beginning of the format mask turns on fill mode for the entire string, thus changing the 1 to 01 and 94 to 1994:

TO_DATE ('07-1-94', 'FXfmDD-FXMM-FXYYYY')

You can also include multiple references to both FM and FX in the same format string, to toggle both or either of these modifiers.

14.3.3 RR: Changing Millenia

We are coming up fast on the end of the 20th century. How many of your programs will still work when the clock ticks over midnight on December 31, 1999? Many of your Oracle-based applications should be well protected since you have been able to take advantage of a true date datatype. In other words, you haven't had to write any special programs to manually manipulate dates, thereby leaving yourself vulnerable. On the other hand, most everyone has been using a two-digit year in their date format masks, either inherited from the default DD-MON-YY or with common substitutes like MM/DD/YY.

The two-digit year format elements could give you problems when the century and millenium are close to changing. The YY format element always defaults to the current century. So when it is November 1999 and your user enters 1/1/1 or 1-JAN-1, they will enter into the database the date of January 1, 1901 -- not January 1, 2001, as they might have been thinking.

What's an IS manager to do? One solution is to go into all your screens and change or add trigger logic so that if the user enters a year number less than ten (or whatever you decide the cutoff to be), then the next century will be assumed. That will work, but it surely must be a most undesirable prospect.

Fortunately, Oracle7 provides a new format element to take care of this problem: the RR format model. With RR you can enter dates from the 21st century before the year 2000 and you can enter dates from the 20th century after the year 2000 (like the birthdays of employees and customers). Here is how RR works:

If the current year is in the first half of the century (years through 49) then:

  • If you enter a date in the first half of the century, RR returns the current century.

  • On the other hand, if you enter a date in the latter half of the century, RR returns the previous century.

  • If the current year is in the latter half of the century (years 50 through 99) then:

    • If you enter a date in the first half of the century, RR returns the next century.

    • If you enter a date in the latter half of the century, RR returns the current century.

Here are some examples of the impact of RR. Notice that the same year numbers are returned for Year 88 and Year 18, even though SYSDATE returns a current date in the 20th and 21st centuries, respectively:

SELECT TO_CHAR (SYSDATE, 'MM/DD/YYYY') "Current Date",
       TO_CHAR (TO_DATE ('14-OCT-88', 'DD-MON-RR'), 'YYYY') "Year 88",
       TO_CHAR (TO_DATE ('14-OCT-18', 'DD-MON-RR'), 'YYYY') "Year 18"
  FROM dual;

Current Date  Year 88  Year 18
------------  -------  -------
  11/14/1994     1988     2018


SELECT TO_CHAR (SYSDATE, 'MM/DD/YYYY') "Current Date",
       TO_CHAR (TO_DATE ('10/14/88', 'MM/DD/RR'), 'YYYY') "Year 88",
       TO_CHAR (TO_DATE ('10/14/18', 'MM/DD/RR'), 'YYYY') "Year 18"
  FROM dual;

Current Date  Year 88  Year 18
------------  -------  -------
  11/14/2001     1988     2018

Of course, if you use the RR format after the year 2000 and want to enter a date that falls in the latter half of the 21st century, you will need to add special logic. Masks with the RR format model will always convert such two-digit years into the previous century.

There are a number of ways you can activate the RR logic in your current applications. The cleanest and simplest way is to change the default format mask for dates in your database instance(s). You can do this by changing the NLS_DATE_FORMAT initialization parameter as follows:

NLS_DATE_FORMAT = 'MM/DD/RR'

or:

NLS_DATE_FORMAT = 'DD-MON-RR'

depending on what the previous format was. Then, if you have not hardcoded the date format mask anywhere else in your screens or reports, you are done. Bring down and restart the database and then your application will allow users to enter dates in the 21st century. If you do have date format masks in the format property for an Oracle Forms item or in an Oracle Reports query or field, you will need to change those modules to reflect the new approach embodied by RR.

14.3.4 Using TO_CHAR to Create a Date Range

At times, users want information about activity on a specific date. In other situations, however, their interest lies in a range of dates. The user might enter the two dates and then expect to view all data that falls between them.

Suppose, for example, that in an Oracle Forms application the user enters 12/4/93 in the start date field and 4/8/96 in the end date field. The query that Oracle Forms executes against the database would need to have logic in it as follows:

hire_date BETWEEN '04-DEC-93' AND '08-APR-96'

or, more generally:

hire_date BETWEEN :criteria.start_date AND :criteria.end_date

where criteria is the name of the block containing the start_date and end_date fields. The colons (:) in front of the field names indicate to PL/SQL that these are bind variables from the host environment.

Sometimes this general logic can be passed directly to the SQL layer. In other situations, programmers must use the Pre-Query trigger or the SET_BLOCK_PROPERTY built-in to alter the SQL statement directly. In this case, they will need to create a string date range from the input dates.

Rather than write the application-specific code to handle this each time, you can build a generic utility, using TO_CHAR and TO_DATE conversion functions.

I offer below the date_range function. Its specification is as follows:

FUNCTION date_range
   (start_date_in IN DATE,
    end_date_in IN DATE,
    check_time_in IN VARCHAR2 := 'NOTIME')
RETURN VARCHAR2

The arguments to date_range are:

start_date_in

The starting date in the range. If NULL then use the min_start_date. If that is NULL, range has form `<= end_date'.

end_date_in

The end date in the range. If NULL then use the max_end_date. If that is NULL, range has form `>= start_date'.

check_time_in

Flag indicating whether or not to include the time component of the dates in the range check. If TIME then use the time component of the dates as part of the comparison. If NOTIME then strip off the time.

If the start date is NULL or the end date is NULL, the string returned by date_range uses the <= and >= operators rather than the BETWEEN operator.

Here are some examples of the output from date_range. Note that date_range places two contiguous quote marks around each date because these are actually string literals. At runtime these two quotes are resolved into a single quote by PL/SQL:

date_range ('04-DEC-93', '08-APR-96')
==>
   BETWEEN TO_DATE (''04-DEC-93'') AND TO_DATE (''08-APR-96'')

date_range ('04-DEC-93', NULL)
==>
   >= TO_DATE (''04-DEC-93'')

date_range (NULL, '04-DEC-93')
==>
   <= TO_DATE (''04-DEC-93'') 

If you do want to include the time component, then date_range will generate a string in this form:

date_range ('04-DEC-93', '08-APR-96', 'time')
==>
   BETWEEN TO_DATE (''04-DEC-93'', ''HHMMYYYY HHMISS'') AND
           TO_DATE (''08-APR-96'', ''HHMMYYYY HHMISS'')

Let's take a look at how you might use date_range in query processing in Oracle Forms. The Pre-Query trigger modifies the Default Where clause of a base table block. In Pre-Query, an assignment actually results in the addition of a WHERE clause in the SQL query for that block. Let's look at a simple example first. If Pre-Query contains a statement like this:

:customer.contact_date := '12-JAN-95';

then the query executed by the forms tool to fill the block contains a WHERE clause that looks like this:

WHERE <other clauses> AND (contact_date = '12-JAN-95')

So a simple assignment results in a straightforward comparison/restriction in the WHERE clause. You can also place complex SQL statements in the WHERE clause by appending a pound sign ( # ) at the beginning of the assignment string. When the forms tools detect the # , they know to substitute completely the equals sign ( = ) comparison with the text following the symbol. So if you place a statement like this in Pre-Query:

:customer.customer_id :=
   '# IN (SELECT customer_id FROM invoice WHERE invoice_total > ' ||
   TO_CHAR (:invoice.amount);

then the query executed by the forms tool to fill the block contains a WHERE clause that looks like this:

WHERE <other-where-clauses> AND
      customer_id IN (SELECT customer_id
                         FROM invoice WHERE invoice_total > 1000)

This very useful feature is documented in the Advanced Oracle Forms Techniques manual from Oracle Corporation. The syntax of a # assignment in Pre-Query for a date range would be like this:

:employee.hire_date :=
   '# BETWEEN ' || TO_CHAR (:criteria.start_date) || ' AND ' ||
   TO_CHAR (:criteria.end_date);

If I were to place literal dates inside this string assignment, then I would need to put two single quotes together in the string wherever I needed one single quote to appear in the actual value placed in that field, as follows:

:employee.hire_date := '# BETWEEN ''01-JAN-93'' AND ''01-DEC-94''';

Now if I apply the date_range function to this Pre-Query context, I have one of the following:

:customer.contact_date :=
   '# ' || date_range (:criteria.start_date, :criteria.end_date);

or:

:customer.contact_date :=
   '# ' || date_range (:criteria.start_date, :criteria.end_date, 'TIME');

In the first call to date_range, I rely on the default value of the check_time_in parameter of NOTIME to ignore the time component of the dates. In the second call, I explicitly request that the time component be included.

The SET_BLOCK_PROPERTY built-in in Oracle Forms offers another method of modifying the DEFAULT WHERE clause of a base table block. It allows you to directly pass a string of SQL syntax, which then replaces the DEFAULT WHERE clause specified at design time in the form. It is a much more structured approach than using the # syntax. In the following two calls to the built-in, I call date_range to generate a date range and attach that date range syntax to the contact_date column. In the second example I apply TRUNC to the contact date so that the time at which the contact_date was entered does not become a factor in the range check:

SET_BLOCK_PROPERTY
   ('customer', DEFAULT_WHERE,
    'contact_date ' ||
    date_range (:criteria.start_date, :criteria.end_date, 'TIME'));

or:

SET_BLOCK_PROPERTY
   ('customer', DEFAULT_WHERE,
    'TRUNC (contact_date) ' ||
    date_range (:criteria.start_date, :criteria.end_date));

Here is the code for the date_range function:

/* Filename on companion disk: daternge.sf */
FUNCTION date_range
   (start_date_in IN DATE,
    end_date_in IN DATE,
    check_time_in IN VARCHAR2 := 'NOTIME')
RETURN VARCHAR2
IS
   /* String versions of parameters to place in return value */
   start_date_int VARCHAR2(30);
   end_date_int VARCHAR2(30);

   /* Date mask for date<->character conversions. */
   mask_int VARCHAR2(15) := 'MMDDYYYY';

   /* Version of date mask which fits right into date range string */
   mask_string VARCHAR2(30) := NULL;

   /* The return value for the function. */
   return_value VARCHAR2(1000) := NULL;
BEGIN
   /*
   || Finalize the date mask. If user wants to use time, add that to
   || the mask. Then set the string version by embedding the mask
   || in single quotes and with a trailing paranthesis.
   */
   IF UPPER (check_time_in) = 'TIME'
   THEN
      mask_int := mask_int || ' HHMISS';
   END IF;
   /*
   || Convert mask. Example:
   ||       If mask is: MMDDYYYY HHMISS
   ||       then mask string is: ', 'MMDDYYYY HHMISS')
   */
   mask_string := ''', ''' || mask_int || ''')';

   /* Now convert the dates to character strings using format mask */
   start_date_int := TO_CHAR (start_date_in, mask_int);
   end_date_int := TO_CHAR (end_date_in, mask_int);

   /* If both start and end are NULL, then return NULL. */
   IF start_date_int IS NULL AND end_date_int IS NULL
   THEN
      return_value := NULL;

   /* If no start point then return "<=" format. */
   ELSIF start_date_int IS NULL
   THEN
      return_value := '<= TO_DATE (''' || end_date_int || mask_string;

   /* If no end point then return ">=" format. */
   ELSIF end_date_int IS NULL
   THEN
      return_value := '>= TO_DATE (''' || start_date_int || mask_string;

   /* Have start and end. A true range, so just put it together. */
   ELSE
      return_value :=
        'BETWEEN TO_DATE (''' || start_date_int || mask_string ||
           ' AND TO_DATE (''' || end_date_int || mask_string;
   END IF;

   RETURN return_value;

END;




14.3.5 Building a Date Manager

The Oracle Server offers the ability to set a default date format for each instance of a database with the NLS_DATE_FORMAT initialization parameter.[ 2 ] Oracle provides a ruthlessly efficient gatekeeper for its RDBMS: there is no way you will ever be able to enter an invalid date into the database. And there are lots of functions that enable you to perform arithmetic on dates once they are in the database. There are, however, obstacles to entering dates efficiently:

[2] This is a big improvement over earlier versions, in which the default date format was hardcoded to be DD-MON-YY. In the Oracle RDBMS Version 6, for example, if your company happened to use something else, you would have to make extensive use of the TO_DATE and TO_CHAR functions (and set the mask on all date fields in your forms applications, as well).

  • Oracle tools do not make it easy for users to enter dates. Oracle Forms, for example, insists that when a user enters a date, it must conform to a single date format mask -- either the system-wide default, or an override for that particular item.

  • Users cannot enter partial date information and have the rest defaulted by the system. Shouldn't users be able to enter a date in any manner they please? That same date mask demands that you enter every part of the date, even if it is always going to be in the current month or year. It seems to me that users should be able to enter the smallest number of characters needed to specify their date, and let the application figure out the rest.

It is possible with PL/SQL to build a "date manager" that satisfies the above moral imperatives for our users. The rest of this section explores the implementation of a function, dm_convert, which converts a string entered by the user into an actual Oracle date value. It liberates the user from having to know the default/enforced format in the Oracle7 database, because the input can conform to any of a wide variety of formats. The function determines which format applies, and returns the date.

14.3.5.1 The dm_convert function date masks

Table 14.4 shows the different date masks which dm_convert supports. The user can enter a string conforming to any of these masks and dm_convert will return a date value. It will not issue such errors as:

ORA-01861 literal does not match format string
ORA-01858 a non-numeric character found where a digit was expected

In addition to supporting many different date formats, dm_convert also offers conversion of "shortcut" entries. Suppose the user wishes to enter the Monday (first day) of this week or the last day of the month. Rather than requiring that users figure out the dates for those days, dm_convert allows them to simply enter a shortcut like "ew" for "end of week." Table 14.5 shows the shortcuts supported by dm_convert. You can easily add your own!

Here are some examples of the way dm_convert changes a string to a date (assuming today's date is December 15, 1994):

dm_convert ('12')    ==> 12-DEC-1994
dm_convert ('3/15')  ==> 15-MAR-1994
dm_convert ('em')    ==> 31-DEC-1994  

Table 14.4: Different Masks Supported by dm_convert

Date Mask

User Input

Result (Assuming SYSDATE = 15-DEC-93)

DD

12

12-DEC-1993

MM/DD

12/1

01-DEC-1993

MM/DD/YY

2/5/92

05-FEB-1992

MM/DD/YYYY

4/12/1990

12-APR-1990

DD-MON

3-Jan

03-JAN-1993

DD-MON-YY

19-NOV-92

19-NOV-1992

DD-MON-YYYY

19-NOV-1992

19-NOV-1992

MON

JAN

01-JAN-1993

MON-DD

MAR-02

02-MAR-1990

MON-DD-YY

MAR-13-90

13-MAR-1990

MON-DD-YYYY

MAR-13-1990

13-MAR-1990

Mon-YYYY

MAR-1990

01-MAR-1990


Table 14.5: Shortcut Entries for dm_convert

Abbreviation

String

Result (Assuming SYSDATE = 14-MAY-93)

Beginning of Week

BW or bw

Monday in the current week:

14-MAY-93

End of Week

EW or ew

Friday in the current week:

18-MAY-93

Beginning of Month

BM or bm

First day in current month:

01-MAY-93

End of Month

EM or em

Last day in current month:

30-MAY-93

14.3.5.2 Using exception handlers to find the right format

You might be wondering how Steven gets around raising those nasty Oracle errors relating to invalid date formats when he tries to convert the string to a date. The answer is that I use both the procedurality and the exception handling of PL/SQL. In native SQL, I can use TO_DATE to convert a string to a date, but if the format doesn't match the string, I will get an error and the SQL statement will fail. In fact, this is exactly what Oracle Forms does when a user enters a value in a date item.

In PL/SQL, I can use the EXCEPTION clause in my program to trap a conversion failure and handle that failure. Usually when you get such a failure you raise an error, as shown in the following trap conversion failure example:

FUNCTION convert_date (string_in IN VARCHAR2) RETURN DATE IS
BEGIN
   RETURN TO_DATE (string_in);
EXCEPTION
   WHEN OTHERS
   THEN
      DBMS_OUTPUT.PUT_LINE
         (' Invalid format for date conversion of ' || string_in);
END;

Clearly, this is not the behavior I want in dm_convert. In dm_convert, a conversion failure does not result from a user's error in entry. It is simply the first step in a search for the right date format mask. The behavior I need to create in dm_convert is the following:

  1. Try to convert the string with a particular date mask.

  2. If it fails, then try to convert the string with a different date mask.

  3. Continue to do this until the string converts without an error or I run out of date masks.

I can use nested exception handlers, as shown in the following example, to implement this multiple-pass technique:

FUNCTION dm_convert (string_in IN VARCHAR2) RETURN DATE
IS
   my_date DATE;
BEGIN
   BEGIN
      my_date := TO_DATE (string_in, 'MM/DD');
   EXCEPTION
      WHEN OTHERS
      THEN
         BEGIN
            my_date := TO_DATE (string_in, 'MM/DD/YY');
         EXCEPTION
            WHEN OTHERS
            THEN
               BEGIN
                  my_date := TO_DATE (string_in, 'MM/DD/YYYY');

   .. and so on for all the formats...

               END;
         END;
   END;
END;

Here, the dm_convert function uses nested anonymous blocks, each with its own exception section, to trap a date conversion failure and pass it on to the next format. The sequence and variety of masks used dictate the range of valid user input and the precedence with which it is parsed. One problem you might notice with this approach is with indentation. When I use my indentation guidelines for these nested blocks and exception handlers, I quickly run out of room on my page! As a result, in the final version of dm_convert, you will see that I pointedly give up trying to properly indent the exception sections of the function. Instead, I structure the exception sections like a CASE statement:

/* Filename on companion disk: dmcnvrt.sf */
FUNCTION dm_convert (value_in IN VARCHAR2)     RETURN DATE
/*
|| Summary: Validate and convert date input of most any format.
||    dm_convert stands for "date manager conversion". Accepts
||    a character string and returns a fully-parsed and validated
||    date. If the string does not specify a valid date, the function
||    returns NULL.
*/
IS
   /* Internal, upper-cased version of date string */
   value_int VARCHAR2(100) := UPPER (value_in);

   /* The value returned by the function */
   return_value DATE := NULL;

   /* Transfer SYSDATE to local variable to avoid repetitive calls */
   today DATE := SYSDATE;
BEGIN
   /*
   || Handle short-cut logic before checking for specific date formats.
   || Supported short-cuts include:
   ||    EW - end of week
   ||    BW - beginning of week
   ||    EM - end of month
   ||    BM - beginning of month
   ||
   || Add shortcuts for quarters specific to your site.
   */
   IF value_int = 'EW'
   THEN
      /* End of week in this case is Friday of the week */
      return_value := NEXT_DAY (today, 'FRIDAY');

   ELSIF value_int = 'BW'
   THEN
      /* Beginning of week in this case is Monday of the week */
      return_value := NEXT_DAY (today, 'MONDAY') - 7;

   ELSIF value_int = 'BM'
   THEN
       return_value := TRUNC (today, 'MONTH');

   ELSIF value_int = 'EM'
   THEN
      return_value := LAST_DAY (today);

   ELSIF value_int IS NOT NULL
   THEN
      /* No known short-cut. The user must have entered a date string for
      || conversion. Now attempt to convert the value using a sequence
      || of calls to TO_DATE. If one attempt fails, pass it to the next
      || TO_DATE and format mask within a (very) nested exception section.
      */
      BEGIN    return_value := TO_DATE (value_int, 'DD');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'MM/DD');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'MM/DD/YY');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'MM/DD/YYYY');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'DD-MON');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'DD-MON-YY');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'DD-MON-YYYY');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'MON');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'MON-DD');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'MON-DD-YY');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'MON-DD-YYYY');
      EXCEPTION WHEN OTHERS THEN
      BEGIN return_value := TO_DATE (value_int, 'MON-YYYY');
      EXCEPTION WHEN OTHERS THEN return_value := NULL;
      END; END; END; END; END; END;
      END; END; END; END; END; END;
   END IF;

   /* Whether NULL or a real date, return the value */
   RETURN (return_value);

END;

In the rest of this section I offer alternative implementations of dm_convert. These do not require nesting of exception handling sections and also avoid hardcoding the format masks into the function.

14.3.5.3 Table-driven date format masks

One drawback to the dm_convert procedure as implemented in the previous section is that everything is hardcoded. Sure, I offer lots of acceptable formats, but they still are all coded explicitly in the procedure. What if a new format needs to be added? In addition, the order of precedence of those formats in validating the date input is hardcoded. If a person enters a 1 and the system date is 12-FEB-95, then dm_convert will change the entry into 01-FEB-95. Suppose, however, that the user really wanted to enter 01-JAN-95 by entering a 1 and suppose further that such defaulted entry is a requirement of your application?

Generally, I like to avoid hardcoding any literal values (like the specific formats and their order of execution) in my routines. Any changes (additions or deletions from the supported date formats, or a request in a specific application to move a format up in the batting order) necessitate a change to the program itself, and then the recompilation of all affected modules. I can avoid this scenario by making the date formats and the order in which they are used data-driven. Here's what I would need to do:

  1. Create a table to store the date formats:

    CREATE TABLE dm_date_mask
       (date_mask VARCHAR2(30), date_mask_seq NUMBER(5,2));
  2. Build a screen in your choice of tools to enter and maintain records in this table. Note that users should be able to change the date_mask_seq. This sequence number determines the order in which the mask is applied to the date input.

  3. Rewrite dm_convert to use the data in this table instead of the hardcoded sequence of masks. This new version of dm_convert, shown in the following example, replaces the nested exception sections with a simple loop that reads through the contents of the table and attempts to convert the entry with the next format in the table.

/* Filename on companion disk: dmcntab.sf */
FUNCTION dm_convert (value_in IN VARCHAR2) RETURN DATE

/* I will only comment the new sections in the function */

IS
   value_int VARCHAR2(100) := UPPER (value_in);
   return_value DATE := NULL;
   today DATE := SYSDATE;

   /* Now set up a cursor to go through the table of formats */
   CURSOR mask_cur IS
      SELECT date_mask FROM dm_date_mask
       ORDER BY date_mask_seq;
   mask_rec mask_cur%ROWTYPE;
BEGIN
   /* Convert short-cut entry. Same as before. */
   IF value_int = 'EW'
   THEN return_value := NEXT_DAY (today, 'FRIDAY');
   ELSIF value_int = 'BW'
   THEN return_value := NEXT_DAY (today, 'MONDAY') - 7;
   ELSIF value_int = 'BM'
   THEN return_value := TRUNC (today, 'MONTH');
   ELSIF value_int = 'EM'
   THEN return_value := LAST_DAY (today);
   ELSIF value_int IS NOT NULL
   THEN
      /*
      || Open the cursor and loop through the date masks until one
      || of them is used successfully in a TO_DATE conversion.
      */
      OPEN mask_cur;
      LOOP
         /* Fetch a record. Exit loop if there aren't any more masks */
         FETCH mask_cur INTO mask_rec;
         EXIT WHEN mask_cur%NOTFOUND;
         /*
         || Still need separate PL/SQL block in the function to trap
         || a conversion failure, but I only need ONE!
         */
         BEGIN
            /* Try to convert the date */
            return_value := TO_DATE (value_int, mask_rec.date_mask);
            /*
            || If I made it this far, I have converted the string, so
            || EXIT the loop.
            */
            EXIT;
         EXCEPTION
            /*
            || Conversion failure. Reset value to make sure it is still
            || NULL and then keep going -- back to the loop!
            */
            WHEN OTHERS
            THEN
               return_value := NULL;
         END;
      END LOOP;
      CLOSE mask_cur;
   END IF;

   /* Return the converted date */
   RETURN return_value;
END;

This new version of dm_convert not only looks more elegant than the first version -- it is considerably more flexible in its approach. If you no longer want to accept Month YYYY DD as a date format, simply pull up the maintenance screen, delete that entry from the table, or execute a DELETE from the dm_date_mask table directly in SQL*Plus. Poof ! The users will no longer be able to enter January 1995 11 for a date value.

There is, of course, a downside to this solution, and it is a familiar one. The price of making your code more flexible (for the developer) is almost always to make your code work less efficiently. Now, instead of executing a series of inline TO_DATE conversion functions, we fetch a series of records from a table. The dm_date_mask table could well be sitting on a remote server. This network traffic could well make the application performance unacceptable to precisely the people you are trying to help by providing flexible date formats. What's a developer to do? One answer is to transfer the table into a memory structure at the start of each user session, as we describe in the next section.

14.3.5.4 Stashing date masks in memory

Say you moved the date masks to a table and that any changes made to the dm_date_mask table will not happen very often. For all intents and purposes, the date masks are constant for a particular user session. So it is not really necessary to read the masks from the table every time you need to validate a date entry. Instead, we can transfer the masks from the table into a memory structure at the start of the user session. With this approach you add a little more time to the startup execution of the form, but then speed up the validation and conversion for each date item.

You have a number of options for storing these date masks in memory:

  • GLOBAL variables in Oracle Forms . These variables persist across forms and menus in an application and so are good candidates to store information which should last for an entire user session.

  • A record group in Oracle Forms. This structure takes less memory than GLOBAL variables (which use a fixed 255 bytes regardless of the actual data stored in the variable) and can be manipulated more naturally with Oracle Forms built-ins. However, you cannot currently pass a record group from one form to another (except with a call to RUN_PRODUCT, which is not always appropriate). You might use the record group if the user performs date processing primarily within a single form. Or you might just be looking for an excuse to practice building and manipulating record groups.

  • Other tool-specific memory structures.

  • A PL/SQL table in PL/SQL Version 2. Oracle's version of a simple array structure is perfect for this application; unfortunately, PL/SQL tables are available only in the RDBMS (through stored procedures and triggers) and SQL*Plus scripts.

I provide the code for PL/SQL table-based alternatives in the following sections. The technique based on Oracle Forms GLOBAL variables may be found in the dmcnvrt.doc and dmcnvrt.fp files on the companion disk. In both cases, there are two steps involved in using the memory-resident, data-driven approach:

  1. Initialize the data structure with the various format masks.

  2. Revamp dm_convert to rely on that data structure.

Notice that the specification of the call to dm_convert does not change with any of these new implementations. I am changing the engine under the hood without making any alterations to the body style, dashboard, or steering controls. None of the triggers that call dm_convert would have to be modified if you did change the storage implementation for the date masks.

14.3.5.5 Storing and accessing date masks in a PL/SQL table

You can also store the date masks in a PL/SQL table (PL/SQL tables are explained fully in Chapter 10, PL/SQL Tables ). The PL/SQL table structure, available only with PL/SQL Version 2, is similar to both a database table and a single-dimensional array. There are two steps to using date masks from the PL/SQL table: Initialize the date masks in the PL/SQL table structure, and revamp dm_convert to rely on the PL/SQL table. These are described below:

  1. Initialize the date masks in the PL/SQL table structure. The best way to do this in PL/SQL tables is to build a package (see Chapter 16, Packages , for more information about this structure). This package contains the code to initialize the masks in the PL/SQL table by transferring them from the database table, to perform the date conversions, and to clean up the table when done.

    A package is made up of two parts: the specification and the body. The specification for the date manager (dm) package is:

    /* Filename on companion disk: dmcnvrt.spp */
    PACKAGE dm
    IS
       /* The replacement for dm_convert */
       FUNCTION convert (value_in IN VARCHAR2) RETURN DATE;
    
       /* Clean-up program */
       PROCEDURE erase;
    
    END dm;

    The body of the dm package shown in the following example contains all the code behind the specification. In addition to the definitions of the convert and erase modules, the body also contains an initialization section following the last BEGIN in the body. This section is run automatically the first (and only the first) time the convert or erase modules are called. This initialization code populates the PL/SQL table of masks.

    /* Filename on companion disk: dmcnvrt.spp */
    PACKAGE BODY dm
    IS
       /*
       || Declare the structure of the PL/SQL table which will hold
       || the masks. Then declare the table itself.
       */
       TYPE mask_tabtype IS TABLE OF VARCHAR2 (30) INDEX BY BINARY_INTEGER;
       mask_table mask_tabtype;
    
       /* Also declare an empty table for use in erasing the masks */
       empty_table mask_tabtype;
    
       /* Package variable which keeps track of number of rows in table */
       mask_count NUMBER;
    
       /* Replacement for dm_convert */
       FUNCTION convert (value_in IN VARCHAR2) RETURN DATE
       IS
          value_int VARCHAR2(100) := UPPER (value_in);
          return_value DATE := NULL;
          today DATE := SYSDATE;
    
          /* Loop index for the scan through the masks */
          mask_index INTEGER := 1;
    
          /* Boolean to terminate loop if date was converted */
          date_converted BOOLEAN := FALSE;
       BEGIN
          /* Same old shortcuts! */
          IF value_int = 'EW'
          THEN return_value := NEXT_DAY (today, 'FRIDAY');
          ELSIF value_int = 'BW'
          THEN return_value := NEXT_DAY (today, 'MONDAY') - 7;
          ELSIF value_int = 'BM'
          THEN return_value := TRUNC (today, 'MONTH');
          ELSIF value_int = 'EM'
          THEN return_value := LAST_DAY (today);
    
          /* Now convert from masks in table */
          ELSIF value_int IS NOT NULL
          THEN
             /* Loop through the rows in the table... */
             WHILE mask_index <= mask_count AND
                   NOT date_converted
             LOOP
                BEGIN
                   /* Try to convert string using mask in table row */
                   return_value :=
                      TO_DATE (value_int, mask_table (mask_index));
                   date_converted := TRUE;
                EXCEPTION
                   WHEN OTHERS THEN
                      return_value := NULL;
                      mask_index:= mask_index+ 1;
                END;
             END LOOP;
          END IF;
          RETURN (return_value);
       END convert;
    
       PROCEDURE erase IS
       BEGIN
          /*
          || Clear the table of any defined rows by assigning the empty
          || table to the mask table.
          */
          mask_table := empty_table;
       END;
    
    BEGIN
       /* ------------ Initialization Section of Package ------------*/
    
       /* Transfer values from RDBMS table to PL/SQL table */
       mask_count := 0;
       FOR mask_rec IN (SELECT date_mask
                          FROM dm_date_mask
                         ORDER BY date_mask_seq)
       LOOP
          mask_count := mask_count + 1;
          mask_table (mask_count) := mask_rec.date_mask;
       END LOOP;
    
    END dm;
  2. Revamp dm_convert to rely on the PL/SQL table. The structure of the convert program in the dm package is very similar to that of the dm_convert program, which relied on global variables in Oracle Forms. In both cases, I replace the nested exception sections with a PL/SQL loop.

    To call the conversion program in the dm package, you would use dot notation, as follows:

    my_date := dm.convert ('12');

    But dm.convert is not the same thing as dm_convert, which is what all my triggers are already calling. In order to make the conversion to PL/SQL table-based format masks completely transparent to my Oracle Forms applications, I will need to build another layer of code over the package. I do this by creating a stored procedure as follows:[ 3 ]

    [3] I could also create this function in a PL/SQL library and attach it to all of my forms.

    CREATE OR REPLACE FUNCTION dm_convert (string_in IN VARCHAR2)
       RETURN DATE
    IS
    BEGIN
       RETURN dm.convert (string_in);
    END;

Now I can call dm_convert just as I did in the good old days:

my_date := dm_convert ('12');














 


Previous: 14.2 Conversion Function Descriptions Oracle PL/SQL Programming, 2nd Edition Next: IV. Modular Code
14.2 Conversion Function Descriptions Book Index IV. Modular Code

The Oracle Library Navigation

Copyright (c) 2000 O'Reilly & Associates. All rights reserved.

Library Home Oracle PL/SQL Programming, 2nd. Ed. Guide to Oracle 8i Features Oracle Built-in Packages Advanced PL/SQL Programming with Packages Oracle Web Applications Oracle PL/SQL Language Pocket Reference Oracle PL/SQL Built-ins Pocket Reference