Создание Excel файла из селекта с параметрами при помощи чистого PL/SQL, как альтернатива Oracle*Reports

от автора

Прежде всего не судите строго за обилие англицизмов(так, по-моему, это называется), я просто давно вне русского программного сообщества и не знаю, чем заменяют эти слова.

Цели, которых я хотел достичь

  • Excel, как результат селекта, текст которого процедура узнает только в runtime
  • Селект перед выпонением видоизменяется в соответствии с параметрами, которые получает процедура
  • Процедуре передаются параметры файла, который будет создан
  • Возможность получения результирующего файла в форматах Excel Workbook,CSV,HTML,XML

Хранимая PL/SQL процедура получает в параметрах

  • текст селекта
  • параметры файла Excel
  • параметры выполнения

и создает полноценный Excel с несколькими таблицами(sheets).
Я знаю, что есть Crystal Reports и Oracle BI Publisher.
Но, во-первых, это крупные продукты(с большими ценами…), а Publisher, насколько я знаю не работает как отдельный модуль без Oracle Business Intelligence Enterprise Edition. И кроме того, речь шла о довольно узкой задаче создания файла без layout.

В конце, я написал один PL/SQL пакет, который находится в database и может быть вызван из любой аппликации. В ходе написания я столкнулся со многими ограничениями и хочу рассказать о том, как их поборол.

Для тех — кто сомневается, я этого, конечно не мог знать заранее, но за несколько лет, что пакет работает в большой компании, у меня не было проблем свести RDF любой сложности, с многими триггерами/формулами, в один селект, хвала Ораклу. Наоборот, так как селект — стринговый параметр и его можно построить динамически, это дает большую гибкость. В параметрах можно задать даже имя таблицы.

Очень часто в аппликациях, написанных в Oracle Forms/Reports для создания файла Excel используют Oracle*Reports, потому что там есть возможность использовать параметры и видоизменять селект до его выполнения. Потом в триггер на уровне строки вывода пишут вывод в файл. Получается csv файл. Ну что же, можно и так, конечно.
Если вместе с Excel нужно создать pdf, то никуда не денешься, пользуйся Reports и не жалуйся как тебя достала эта программа. Но ведь часто нужен только Excel и городить для этого RDF как-то не хочется.

Итак, к делу.

Параметры файла

Тут все просто,

 <DIR_NAME> </DIR_NAME>  <FILE_NAME> </FILE_NAME>  <OUT_TYPE> </OUT_TYPE>  <LIMIT_ROWS> </LIMIT_ROWS>  <LIMIT_LEN> </LIMIT_LEN>  <EXCEL_TITLE> </EXCEL_TITLE>  <SUBTITLE></SUBTITLE>  <SUBTITLE2></SUBTITLE2>  <DIRECTION> ltr/rtl </DIRECTION>  <CHARSET> </CHARSET>  <LITERAL_PARAMS> Y/N</LITERAL_PARAMS>  <DIVIDE_BY>FILES|SHEETS</DIVIDE_BY>  <PARAM_TITLE> </PARAM_TITLE>  <PAR_NAME_HEAD> </PAR_NAME_HEAD>  <PAR_VALUE_HEAD> </PAR_VALUE_HEAD>  <NOT_FOUND_MSG> </NOT_FOUND_MSG>  <LONG_OUT>Y/N</LONG_OUT>  <MULTI_VALUE_DELIMITER> </MULTI_VALUE_DELIMITER>  <CURR_DATE_PROMPT> </CURR_DATE_PROMPT>  <DEFAULT_DATE_MASK> </DEFAULT_DATE_MASK>  <OUTPUT_DATE_FORMAT></OUTPUT_DATE_FORMAT>  <CURRENT_SHEET></CURRENT_SHEET>  <TOTAL_SHEETS></TOTAL_SHEETS> 

Примерно такой набор. Думаю, тут все понятно. Несколько слов:

LIMIT_ROWS, LIMIT_LEN позволяют делить результирующий файл в процессе создания по мере достижения предельных значений на несколько Excel корректных.

LITERAL_PARAMS говорит о том, как использовать параметры выполнения — вставлять значения или выполнять селект в dbms_sql с dbms_sql.bind_variable.

OUT_TYPE задает формат: Excel Workbook,CSV,HTML,XML

Как обеспечить динамичность селекта с параметрами, получаемыми в runtime

Параметры выполнения

Параметры передаем так:

  <PARAMS>     <PARAM>       <NAME>      </NAME>       <DATATYPE> [ALPHANUMERIC|CHAR|DATE|NUMBER|AS_IS] </DATATYPE>       <FORMAT_MASK>[Date format]</FORMAT_MASK>       <PROMPT>    </PROMPT>       <LABEL>     </LABEL>       <VALUE>     </VALUE>     </PARAM>               ..   </PARAMS>  

Язык предвыполнения

Нужен некий язык, на котором можно написать инструкции, что делать в зависимости от значений параметров выполнения.

  • Получаем параметры выполнения.
  • Компилируем текст селекта.
  • Подаем его для выполнения следующему шагу.

Вот язык, который в конце покрывал все мои потребности

В тексте селекта это выглядит как комментарий(hint)

/*!<HINT> [{]operand1[}] [ [{]operand2[}][{]operand3[}] ] ; !*/ 

Первое слово — это hint, определяющий команду
VAR CHAR | NUMBER | AS_IS | DATE [date format] {PL/SQL expression};
IF_CONTINUE {PL/SQL expression}
IF_EXECUTE {PL/SQL expression}
EXPR {PL/SQL expression};
IIF_EXPR {boolean expression} {String if true} {String if false};
IS_NOT_NULL {Bind variable} {String if Bind variable is not null};
IS_NULL {Bind variable} {String if Bind variable is null};
BOTTOM_SUM {Total bottom title} B C …Z;
ROW_SUM {Total column title} B C D… Z;
BEFORE {PL/SQL block};
TITLE {Title};
ALIAS {column_name} {alias};
AFTER {PL/SQL block};

Шаг компиляции заключается в том, что я нахожу в тексте команду, если один из операндов требует выполнения — выполняю это как select (expression) from dual или как PL/SQL блок в execute immediate и заменяю всю команду на результат выполнения.

Например

/*! VAR :Max_salary_dep number {select department_id                                   from (select ee.department_id,                                                sum(ee.salary)                                           from employee ee                    /*! IS_NOT_NULL :emp_id {where ee.employee_id = :emp_id}; !*/                                          group  by ee.department_id                                          order by sum(ee.salary) desc)                                  where rownum = 1} !*/    /*! VAR :Debug_print char    'Y' ; !*/   select e.first_name "First Name",          e.last_name,          d.name "Department  name",          j.function,e.hire_date,e.salary,e.commission  /*! IS_NOT_NULL :loc_id {,l.regional_group}; !*/    from department d,employee e,job j  /*! IS_NOT_NULL :loc_id {,loc l}; !*/   where e.department_id=d.department_id     and e.job_id=j.job_id  /*! IS_NOT_NULL :loc_id { and l.loc_id=d.loc_id}; !*/  /*! IS_NOT_NULL :hire_date { and hire_date >= :hire_date}; !*/  /*! IS_NOT_NULL :function { and j.function=upper(:function)}; !*/  /*! IIF_EXPR {:dep_id is not null} {and d.department_id = :dep_id}                                  {and d.department_id = :Max_salary_dep}; !*/  /*! IS_NOT_NULL :emp_id {and employee_id = :emp_id}; !*/  /*! ROW_SUM {Total row} F G; !*/  /*! BOTTOM_SUM Total F G /*! IS_NOT_NULL :loc_id I ; !*/  /*! IS_NULL :loc_id H; !*/ ; !*/     

В зависимости от передаваемых значений можно получить всякие селекты

 select e.first_name "First Name",          e.last_name,          d.name "Department name",          j.function,e.hire_date,e.salary,e.commission    from department d,employee e,job j   where e.department_id=d.department_id     and e.job_id=j.job_id and d.department_id = 20   

select e.first_name "First Name",          e.last_name,          d.name "Department  name",          j.function,e.hire_date,e.salary,e.commission   ,l.regional_group    from department d,employee e,job j   ,loc l   where e.department_id=d.department_id     and e.job_id=j.job_id   and l.loc_id=d.loc_id   and hire_date >= to_date('1985-09-08','yyyy-mm-dd')   and j.function=upper('SALESPERSON')  and d.department_id = 30  

или

 select e.first_name "First Name",          e.last_name,          d.name "Department name",          j.function,e.hire_date,e.salary,e.commission    from department d,employee e,job j   where e.department_id=d.department_id     and e.job_id=j.job_id   and hire_date >= to_date('1985-09-08','yyyy-mm-dd')   and j.function=upper('SALESPERSON')  and d.department_id = 30   

ну и так далее…

Я это описал для того, чтобы вы поверили, что эти приемы позволяют писать действительно эффективные селекты.
Никаких " and (:param1 is null or table_field=:param1)"

Парсинг и выполнение

Ради этого раздела я затеялся писать эту статью. Здесь я напишу об опыте, который приобрел, и который наверняка не нужен тому, кто не ходил на границах допустимого в Оракле. Например, все знают, что максимальная длина текстового поля в таблице — 4000, но многие ли знают, что предел для конкатенации строкового поля в селекте в оракле тоже 4000 байтов.

Все знают

А может я не прав, может, это только я не знал.

Получили селект после предкомпиляции с параметрами выполнения. Он у нас в переменной l_Stmt.
Что мы хотим сделать?
Выполнить парсинг и получить список полей с datatype.
Для этого применяем

dbms_sql.parse

    l_CursorId := dbms_sql.open_cursor;     begin       dbms_sql.parse(l_CursorId, substr('select * from (' || l_Stmt || ')', 1, 32765),1);     exception       when others then         v_Msg := '--After parse: ' || sqlerrm;         put_str_to_output(substr('select * from (' || l_Stmt || ')',1,32765));         raise ParsingException;     end;  

dbms_sql.describe_columns

    begin       dbms_sql.describe_columns(l_CursorId, l_ColumnCnt, l_LogColumnTblInit);     exception       when others then         v_Msg := '--After describe_columns: ' || sqlerrm;         put_str_to_output(substr('select * from (' || l_Stmt || ')', 1, 32765));         raise ParsingException;     end; 

Мы получили самое главное — список полей селекта в PL/SQL таблице l_LogColumnTblInit.
Это для нас выполнил великий пакет DBMS_SQL. Теперь мы можем организовать цикл по полям селекта.
Тот, кто пишет на Java(в том числе и я теперешний) посмеется над такой победой, там это всегда было — перебор полей в PreparedStatement.
Сейчас, зная Java, я бы написал бы, может, по другому, но принципиальные вещи не изменились бы.
Кстати, здесь я встретил ограничение на размер селекта 32К, не сразу, в ходе эксплуатации, когда начали писать серьезные селекты. И тут меня снова порадовал Оракл. Оказывается, длинный селект можно разбить на порции 256 байт, зарузить в PL/SQL таблицу l_LongSelectStmt dbms_sql.varchar2s и передать в overload версию dbms_sql.parse.

        begin           dbms_sql.parse(l_CursorId                         ,l_LongSelectStmt                         ,1                         ,l_LongSelectStmt.count                         ,false                         ,1);         exception           when others then             v_Msg := '--After parse long 2: ' || sqlerrm;             raise ParsingException;         end; 

Теперь пришло время подумать о форматах вывода.
Допустим, наш селект выглядит так:

  select a,b       from table1   where ... 

Для вывода в формате CSV нужно написать

   select a||chr(9)||b            from(  select a,b                         from table1                      where ...                    ) 

Для вывода в формате HTML нужно написать

   select '<tr><td>'||a||'</td><td>'||b||'</td></tr>'            from(   select a,b                         from table1                      where ...                    ) 

Для создания самого красивого, но и самого сложного формата Excel Workbook, мне пришлось поэкспериментировать с Excel. Excel Workbook — это не бинарный, а текстовый файл, его можно посмотреть и понять, как там все устроено.
Там есть CSS, определения Workbook,Worksheet, заголовков таблиц. Не буду углубляться, это не очень сложно понять, если вы встречали раньше HTML.
В Excel Workbook строка вывода будет выглядеть примерно так

 select '<Row> <Cell ss:StyleID="s24"><Data ss:Type="String">'||a|| '</Data></Cell>'||                          '<Cell ss:StyleID="s25"><Data ss:Type="DateTime">'||b||'</Data></Cell>'||                          '<Cell ss:StyleID="s24"><Data ss:Type="String">'||c||'</Data></Cell></Row>'   from ( select a,b,c                 from table2           ) 

Здесь, как вы видите, нам может пригодиться знание типов данных из виртуальной таблицы, полученной в dbms_sql.describe_columns.
Если сравнивать типы вывода, то можно сказать следующее:
CSV — маленький по размеру(это плюс), некрасивый, нет возможности нескольких таблиц(spreadshhets)
HTML — средний по размеру, достаточно сексуальный, нет возможности нескольких таблиц
Excel Workbook — большой файл, красивый, есть возможность создания нескольких таблиц

Алгоритм работы

Цикл по полям

Двигаясь по таблице выходных полей, заворачиваем очередное поле в соответствующие формату тэги или просто добавляем табуляцию(CSV). Теперь вы поняли, как я ударился об эти 4000 байтов. Мне пришлось проверять перед слиянием строк длину результата и, если она была больше 4000, то начинал новое поле вывода, примерно так:

 select '<Row> <Cell ss:StyleID="s24"><Data ss:Type="String">'||a|| '</Data></Cell>'||                          '<Cell ss:StyleID="s25"><Data ss:Type="DateTime">'||b||'</Data></Cell>' а1,              '<Cell ss:StyleID="s24"><Data ss:Type="String">'||c||'</Data></Cell></Row>' а2   from ( select a,b,c                 from table2           ) 

Когда селект построен, выполняем его. Если селект большой, то его надо загрузить в dbms_sql.varchar2s таблицу и выполнить в dbms_sql. Если ваш DBA сказал, что он не потерпит литералы и требует, чтобы параметры были bind variable, то тоже нужно использовать dbms_sql с dbms_sql.bind_variable.
Иначе, если ваш селект поместился в 32К вашей varchar2 переменной l_Stmt можете открыть ref cursor:

      begin         open l_RefCursor for l_Stmt;       exception         when others then           v_Msg := '--After open: ' || sqlerrm;           raise ParsingException;       end; 

Цикл по курсору

Делаем fetch и пишем в utl_file. Следим за количеством строк и за величиной выводимого файла, если нужно завершаем его и начинаем следующий.

Ну вот, наверно и все по большому счету.
Наверное теперь можно показать результат:
image

Если кому интересно, я могу рассказать, как я завернул этот пакет в другой, который зиповал файл, если он был большой, посылал его по мейлу как ссылку или как attachment, но главное, это определения параметров и типовой экран ввода.

ссылка на оригинал статьи http://habrahabr.ru/post/263323/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *