René Nyffenegger's collection of things on the web
René Nyffenegger on Oracle - Most wanted - Feedback -
 

April 14, 2007: printf in PL/SQL

There are a few things in PL/SQL that I am really missing. One of these things is printf. I am not aware of any built-in functionality that remotely resembles the elegance of printf. Ok, lpad and rpad and to_char come to mind. But these functions are, IMHO, tedious to use, especially when it comes to handling null values.
Therefore, I have hacked up my own printf. I stress that it is a hack since there would probably be many features or improvements worth implementing, but that's for another day... So, if you come up with a better printf, please let me know, so I can put it on my website.
Anyway, there are a few differences between my printf and the standard c printf. For one, my printf handles only the format specifiers %d (numbers) and %s (strings). Also, the (optional) width (w) and precision (p) part in %w.pd behave differently. In c's printf, w denotes the total width of the resulting string. In my implementation, w denotes how many characters (digits) will be printed left of the dot. So, the resulting string of %7.3d will always be 10 digits wide, the sign inclusively. However, if I use %+7.3d, the resulting string is eleven characters wide. The behaviour of my printf is probably best demonstrated in this test case.
Since printf has a variable number of arguments, I create a nested table type to mimic this feature:
create or replace type arg_list as table of varchar2(4000)
/
Now, sprintf, which returns the formatted string:
create or replace function sprintf(format in varchar2, parms in arg_list) return varchar2 is
  ret          varchar2(4000);
 
  cur_pos      number := 0;
  cur_format   varchar2(4000);
  len_format   number := length(format);
 
  left_aligned boolean;
  print_sign   boolean;
 
  cur_param    number := 0;
begin

  loop
    -- Iterating over each character in the format.
    -- cur_pos points to the character 'being examined'.
    cur_pos := cur_pos + 1;

    exit when cur_pos > len_format;
      -- The iteration is over when cur_pos is past the last character.

    if substr(format, cur_pos, 1) = '%' then
      -- A % sign is recognized.
     
      -- I assume the default: left aligned, sign (+) not printed
      left_aligned := false;
      print_sign   := false;
      
      -- Advance cur_pos so that it points to the character
      -- right of the %
      cur_pos := cur_pos + 1;

      -- 
      if substr(format, cur_pos, 1) = '%' then
         -- If % is immediately followed by another %, a literal
         -- % is wanted:
         ret := ret || '%';

         -- No need to further process the format (it is none)
         goto percent;
      end if;

      if substr(format, cur_pos, 1) = '-' then
         -- Current format will be left aligned
         left_aligned := true;
         cur_pos      := cur_pos + 1;
      end if;

      if substr(format, cur_pos, 1) = '+' then
         -- Print plus sign explicitely (only number, %d)
         print_sign := true;
         cur_pos    := cur_pos + 1;
      end if;

      -- Now, reading the rest until 'd' or 's' and
      -- store it in cur_format.
      cur_format := '';

      -- cur_param points to the corresponding entry
      -- in parms
      cur_param  := cur_param + 1;

      loop

        -- Make sure, iteration doesn't loop forever
        -- (for example if incorrect format is given)
        exit when cur_pos > len_format;

        if substr(format, cur_pos, 1) = 'd' then

          declare
            -- some 'local' variables, only used for %d
            chars_left_dot number;
            chars_rite_dot number;
            chars_total    number;
            dot_pos        number;
            to_char_format varchar2(50);
            buf            varchar2(50);
            num_left_dot   char(1) := '9';
          begin

          if cur_format is null then
             -- Format is: %d (maybe %-d, or %+d which SHOULD be
             -- handled, but isn't)
             ret := ret || to_char(parms(cur_param));
             -- current format specification finished, exit the loop
             exit;
          end if;

          -- does the current format contain a dot?
          -- dot_pos will be the position of the dot
          -- if it contains one, or will be 0 otherwise.
          dot_pos := instr(cur_format, '.');

          if substr(cur_format, 1, 1) = '0' then
            -- Is the current format something like %0...d?
            num_left_dot := '0';
          end if;

          -- determine how many digits (chars) are to be printed left
          -- and right of the dot.
          if dot_pos = 0 then
             -- If no dot, there won't be any characters rigth of the dot
             -- (and no dot will be printed, either)
             chars_rite_dot := 0;
             chars_left_dot := to_number(cur_format);
             chars_total    := chars_left_dot;
          else
             chars_rite_dot := to_number(substr(cur_format,    dot_pos + 1));
             chars_left_dot := to_number(substr(cur_format, 1, dot_pos - 1));
             chars_total    := chars_left_dot + chars_rite_dot + 1;
          end if;

          if parms(cur_param) is null then
             --  null h
             ret := ret || lpad(' ', chars_total);
             exit;
          end if;

          to_char_format := lpad('9', chars_left_dot-1, '9') || num_left_dot;
          
          if dot_pos != 0 then 
             -- There will be a dot
             to_char_format := to_char_format || '.' || lpad('9', chars_rite_dot, '9');
          end if;

          if print_sign then
             to_char_format := 'S' || to_char_format;
             -- The explicit printing of the sign widens the output one character
             chars_total := chars_total + 1;
          end if;

          buf := to_char(to_number(parms(cur_param)), to_char_format);

          if not print_sign then
            if substr(buf, 1, 1) = '-' or substr(buf, 1, 1) != ' ' then
              -- print a bunch of ! if the number doesn't fit the length
              buf := lpad('!', chars_total, '!');
            else
              buf := substr(buf, 2);
            end if;
          end if;

          if left_aligned then
            buf := rpad(trim(buf), chars_total);
          else
            buf := lpad(trim(buf), chars_total);
          end if;

          ret := ret || buf;

          exit;
          end;

        elsif substr(format, cur_pos, 1) = 's' then

          if cur_format is null then
            ret := ret || parms(cur_param);
            exit;
          end if;

          if left_aligned then
            ret := ret || rpad(nvl(parms(cur_param), ' '), to_number(cur_format));
          else
            ret := ret || lpad(nvl(parms(cur_param), ' '), to_number(cur_format));
          end if;

          exit;

        end if;

       cur_format := cur_format || substr(format, cur_pos, 1);

       cur_pos := cur_pos + 1;
      end loop;

    else
      ret := ret || substr(format, cur_pos, 1);
    end if;

    <<PERCENT>> null;
 
  end loop;

  return ret;

end sprintf;
/
The test case for sprintf:
set serveroutput on size 100000 format wrapped

declare
  procedure cmp(test_no in number, format in varchar2, args in arg_list, expected in varchar2) is
    gotten varchar2(4000);
  begin
    gotten := sprintf(format, args);

    if gotten != expected then

       dbms_output.put_line('test ' || test_no || ' failed');
       dbms_output.put_line('    gotten is: ' || gotten);
       dbms_output.put_line('  expected is: ' || expected);

    end if;

  end cmp;

begin
  dbms_output.put_line('sprintf test');

  -- Right aligning a string:
  cmp( 1,  'String: %20s', arg_list('hello world'), 'String:          hello world');

  -- Left aligning a string                                  12345678901234567890
  cmp( 2, 'String: %-20s', arg_list('hello world'), 'String: hello world         ');

  -- A left and a right aligned string                       ... 1234567890 1234567890 ...
  cmp( 3, '... %10s %-10s ...', arg_list('hello', 'world'), '...      hello world      ...');

  -- Strings and null                                                   ... 12345 12345 12345 12345 ...
  cmp( 4, '... %5s %5s %-5s %-5s ...', arg_list('a', null, 'b', null), '...     a       b           ...');

  -- Numbers
  cmp( 5, 'Numbers: %d,%d,%d'  , arg_list(  42,    0, -42), 'Numbers: 42,0,-42');
  cmp( 6, 'Numbers: %d,%d,%d'  , arg_list(  42, null, -42), 'Numbers: 42,,-42' );

  -- Right aligning numbers:                                             12345 12345 12345
  cmp( 7, 'Numbers: %5d,%5d,%5d'  , arg_list(  42,    0, -42), 'Numbers:    42,    0,  -42');
  cmp( 8, 'Numbers: %5d,%5d,%5d'  , arg_list(  42, null, -42), 'Numbers:    42,     ,  -42');

  -- Left aligning numbers:                                                 12345 12345 12345
  cmp( 9, 'Numbers: %-5d,%-5d,%-5d'  , arg_list(  42, null, -42), 'Numbers: 42   ,     ,-42  ');

  -- Fractions                                 12.12345
  cmp(10, '1/3: %2.5d',  arg_list( 1/3), '1/3:   .33333');
  cmp(11, '1/3: %02.5d', arg_list( 1/3), '1/3:  0.33333');
  cmp(12, '1/3: %2.5d',  arg_list(-1/3), '1/3:  -.33333');
  cmp(13, '1/3: %02.5d', arg_list(-1/3), '1/3: -0.33333');

  -- Fractions with signs                       S12.12345
  cmp(14, '1/3: %+2.5d',  arg_list( 1/3), '1/3:   +.33333');
  cmp(15, '1/3: %+02.5d', arg_list( 1/3), '1/3:  +0.33333');
  cmp(16, '1/3: %+2.5d',  arg_list(-1/3), '1/3:   -.33333');
  cmp(17, '1/3: %+02.5d', arg_list(-1/3), '1/3:  -0.33333');

  -- Recognizition of the %
  cmp(18, '%d %% of %d is: %d', arg_list(7, 68, 68/100*7), '7 % of 68 is: 4.76');

  -- Number doesn't fit the length
  cmp(19, '... %4d ...', arg_list(12345), '... !!!! ...');

end;
/
Here's the printf function:
create or replace procedure printf (format in varchar2, parms in arg_list) is
begin
  dbms_output.put_line(sprintf(format, parms));
end;
/

More on Oracle

This is an on Oracle article. The most current articles of this series can be found here.

Warning: require(): open_basedir restriction in effect. File(/var/www/virtual/adp-gmbh.ch/forum/comment.inc) is not within the allowed path(s): (/home/httpd/vhosts/renenyffenegger.ch/:/tmp/) in /home/httpd/vhosts/renenyffenegger.ch/adp-gmbh.ch/blog/2007/04/14.php on line 523

Warning: require(/var/www/virtual/adp-gmbh.ch/forum/comment.inc): failed to open stream: Operation not permitted in /home/httpd/vhosts/renenyffenegger.ch/adp-gmbh.ch/blog/2007/04/14.php on line 523

Fatal error: require(): Failed opening required '/var/www/virtual/adp-gmbh.ch/forum/comment.inc' (include_path='.:/home/httpd/vhosts/renenyffenegger.ch') in /home/httpd/vhosts/renenyffenegger.ch/adp-gmbh.ch/blog/2007/04/14.php on line 523