2021年2月10日水曜日

制約の違反に対応したエラー・メッセージを定義する

オランダ在住のRoel Hartmanさん(ODTUGのボード・メンバーです)が彼のブログに以下の記事を書いていました。

Stop using validations for checking constraints!

記事の内容は、データベースの表に設定している制約に違反したときに発生するエラー・メッセージを画面に表示する際には、Oracle APEXが提供している(アプリケーション定義に含まれる)エラー処理ファンクションを活用しましょう、というものです。関連するドキュメントの記載としては、パッケージAPEX_ERRORの説明になります。

彼の記事の中で面白いなと思ったのは、出力するメッセージの取得にAPEX_LANG.MESSAGEを使用しているところです。このファンクションは元々はアプリケーションの翻訳に使用するものです。翻訳で使用する方法については、以前にこのような記事を書きました。もちろん、アプリケーションの翻訳をしなくても、キーとなる値を指定して、メッセージとする文字列を取得することができます。

実際に実装して効果を確認してみましょう。


確認用アプリケーションの作成


Oracle APEXのワークスペースにサインインし、SQLワークショップからユーティリティクイックSQLを開きます。

以下のクイックSQLのモデルを使って、表AET_EMPとAET_EMP_ATTRIBUTESを作成します。表AET_EMP_ATTRIBUTESには、各種の制約を付加しておきます。これらの制約に違反したときに表示されるエラー・メッセージを、Oracle Databaseが返すメッセージから、利用者向けに分かりやすいメッセージに変更することが本記事のゴールです。

# prefix: aet
# prefixPKwithTname: true
# semantics: default
# language: ja
emp /insert 5
  emp_id /pk /values 1,2,3,4,5
  emp_name

emp_attributes
  emp_attr_id /pk 
  emp_id /fk emp
  national_id num /unique
  attribute_name vc20 /nn
  attribute_value vc80
  gender vc1 /check M,F 
  age num /between 0 and 120

制約としては、主キー、外部キー、一意、NULL不可、チェック、範囲指定を設定しています。必要なディレクティブはモデルに含んでいるので、クイックSQLでSQLの生成SQLスクリプトの保存レビューおよび実行即時実行とボタンをクリックしていけば、今回使用する表が作成されます。

続いてアプリケーション・ビルダーより、空のアプリケーションを作成します。アプリケーションの作成より新規アプリケーションを選び、アプリケーションの名前だけを指定して、アプリケーションの作成を実行します。ここではアプリケーションの名前を、エラー処理の確認としています。

空のアプリケーションが作成されたら、確認に使用するフォームと対話グリッドのページを作成します。最初にフォームのページを作成します。アプリケーション・ビルダーの画面より、ページの作成を開始します。

コンポーネントフォームを選択します。

フォーム付きレポートを選択します。

レポート・タイプ対話モード・レポートフォーム・ページ・モードモーダル・ダイアログとします。それ以外は任意の値を設定できます。に進みます。

ナビゲーションのプリファレンスとして新規ナビゲーション・メニュー・エントリの作成を選択します。に進みます。


データ・ソースローカル・データベースソース・タイプとし、表/ビューの名前としてAET_EMP_ATTRIBUTES(表)を選択します。に進みます。


主キー制約でのエラーも発生させるため、主キー型としてデータベースで管理(ROWID)を選択します。作成を実行します。


以上でレポートとフォームの2つのページが作成されます。続いて、対話グリッドのページを作成します。フォームの作成と同様に、ページの作成を開始し、フォームの選択に続いて対話グリッドを選びます。


ページ名は任意ですが、ここではグリッド編集とします。に進みます。


ナビゲーションのプリファレンスとして新規ナビゲーション・メニュー・エントリの作成を選択します。に進みます。


データ・ソースとしてローカル・データベース編集が有効ONソース・タイプとし、表/ビューの名前としてAET_EMP_ATTRIBUTES(表)を選択します。こちらも主キー制約のエラーを発生させるため、あえて主キー列ROWIDを選択します。作成を実行します。


対話グリッドが作成されます。作成されたアプリケーションを実行し、表の制約に違反したデータを投入し、エラーの表示を確認してみましょう。

すでに作成済みの行を重複させて保存を実行すると、ORA-00001: 一意制約(DBCLOUD_TEST.AET_EMP_ATTRIBU_EMP_ATTR_ID_PK)に反しています、が発生しました。


Ageに400を指定して作成を実行すると、ORA-02290: チェック制約(DBCLOUD_TEST.AET_EMP_ATTRIBUTES_AGE_BET)に違反しました、が発生しました。



以上で確認用アプリケーションは完成です。

エラー処理ファンクションの設定


マニュアルに記載されている例を元に、エラー処理ファンクションを定義します。コードに含まれているコメントが日本語マニュアルでも翻訳されていないので、処理内容がわかるように日本語に翻訳しました。


create or replace function apex_error_handling_example (
p_error in apex_error.t_error )
return apex_error.t_error_result
is
l_result apex_error.t_error_result;
l_reference_id number;
l_constraint_name varchar2(255);
begin
l_result := apex_error.init_error_result (
p_error => p_error );
-- APEXの内部エラー、例えばSQL文の不正であったり、PL/SQLコードが実行できない、などは
-- エラーを報告する文字列にセキュリティ上、開示すべきではない情報が含まれる場合があります。
-- このようなセキュリティ上の問題の発生を避けるため、このようなエラーのテキストをより一般的な
-- エラー・メッセージに書き換え、元々のメッセージを別途ログとして保存し、問題の調査に利用できる
-- ようにします。
if p_error.is_internal_error then
-- 一般的なエラー(ページやアプリケーションのアクセス不許可といった
-- 認可やセッション、セッション・ステートに関するエラー)を除いた
-- すべてのエラーをマスクします。
if not p_error.is_common_runtime_error then
-- 自律トランザクションによるエラーのロギングを実施します。
-- reference# としてl_reference_idを返します。
-- l_reference_id := log_error (
-- p_error => p_error );
--
-- セキュリティ上の問題を含む全てのメッセージを一般的なエラー・メッセージに
-- 置き換えます。
l_result.message := 'An unexpected internal application error has occurred. '||
'Please get in contact with XXX and provide '||
'reference# '||to_char(l_reference_id, '999G999G999G990')||
' for further investigation.';
l_result.additional_info := null;
end if;
else
-- エラーはいつでもインラインで表示します。
-- 注意: マニュアルで表形式フォームを作成した場合(apex_item/htmldb_itemをSQL文
-- にて使用)は、"エラー・ページ"を選択し、入力データを失わないようにできます。
l_result.display_location := case
when l_result.display_location = apex_error.c_on_error_page then apex_error.c_inline_in_notification
else l_result.display_location
end;
-- 注意: もし、ORAエラーのメッセージをわかりやすくしたいだけであれば、
-- 名前が APEX.ERROR.ORA-番号 というパターンのテキスト・メッセージとして登録できます。
-- カスタム・コードを実装する必要はありません。
--
-- もし、以下の様な制約に関する違反の場合は
--
-- -) ORA-00001: unique constraint violated
-- -) ORA-02091: transaction rolled back (-> can hide a deferred constraint)
-- -) ORA-02290: check constraint violated
-- -) ORA-02291: integrity constraint violated - parent key not found
-- -) ORA-02292: integrity constraint violated - child record found
--
-- 違反した制約名から、より分かりやすいエラー・メッセージに置き換えます。
-- メッセージが見つからない場合は、元々のORAエラーのメッセージを使います。
if p_error.ora_sqlcode in (-1, -2091, -2290, -2291, -2292) then
l_constraint_name := apex_error.extract_constraint_name (
p_error => p_error );
-- ORIGINAL CODE IN EXAMPLE --
-- begin
-- select message
-- into l_result.message
-- from constraint_lookup
-- where constraint_name = l_constraint_name;
-- exception when no_data_found then null; -- not every constraint has to be in our lookup table
-- end;
-- REPLACED WITH THE CODE FROM ROEL HARTMAN'S ARTICLE
-- 制約に対応したメッセージが見つからないときは、TODO として、
-- テキスト・メッセージの更新を促します。
l_result.message := apex_lang.message(l_constraint_name);
if l_result.message = l_constraint_name then -- no message registered
apex_lang.create_message(
p_application_id => v('APP_ID')
, p_name => l_constraint_name
, p_language => 'ja'
, p_message_text => 'TODO'
);
commit;
end if;
-- END OF CUSTOMIZED CODE.
end if;
-- ORAエラーが発生した場合、例えば raise_application_error(-20xxx, '...')
-- が表トリガーやAPEXのプロセスとして呼び出されたPL/SQLパッケージで発生した場合、
-- 対応するメッセージが見つけられないときは、すべてのエラー・スタックの代わりに
-- 実際のエラーのテキストを表示するようにします。
if p_error.ora_sqlcode is not null and l_result.message = p_error.message then
l_result.message := apex_error.get_first_ora_error_text (
p_error => p_error );
end if;
-- 関連するアイテムや表形式フォームの列が設定されていない場合は、
-- apex_error.auto_set_associated_item を使用して、影響を受けた領域を
-- ORAエラーの種類や制約の名前、列の名前を元に、自動的に推定します。
if l_result.page_item_name is null and l_result.column_alias is null then
apex_error.auto_set_associated_item (
p_error => p_error,
p_error_result => l_result );
end if;
end if;
return l_result;
end apex_error_handling_example;


元の記事ではapex_lang.create_messageの引数であるp_languageにapex_util.get_preference('FSP_LANGUAGE_PREFERENCE')を与えていますが、これはアプリケーション言語の導出元アプリケーション・プリファレンス(FSP_LANGUAGE_PREFERENCEを使用)になっていて初めて有効なので、アプリケーションのプライマリ言語になっているときはNULLを返します。日本では大抵、プライマリ言語として日本語(ja)を設定しているため、単にjaを指定する様に変更しました。


定義したファンクションを、アプリケーション定義のエラー処理ファンクションとして設定します。


アプリケーションを実行して、再度、制約に関するエラーを発生させます。最初は制約の名前が表示されます。


再度、エラーを発生させると、次はTODOと表示されます。


テキスト・メッセージの登録


アプリケーション・ビルダーを開いて、共有コンポーネントグローバリゼーションに含まれるテキスト・メッセージを開きます。

エラーの原因となった制約名にTODOというテキストが登録されていることが確認できます。

このTODOを適切なメッセージに置き換えます。今回は制約AET_EMP_ATTRIBU_EMP_ATTR_ID_PKにたいして、従業員属性のIDとなる値は一意である必要があります。とテキストを設定します。変更の適用を行います。

再度、同じ操作を行うと、エラー・メッセージが変更されていることが確認できます。

フォームを使って同じデータを作成しようとしても、表示されるエラー・メッセージは同じです。


以上でエラー処理ファンクションを使った、エラー・メッセージの変更方法の紹介は完了です。

他にも設定した制約があります。演習を行うつもりで、メッセージを設定してみてください。


発生したエラーの確認方法


ひとつ追加で説明すると、Oracle APEXのアプリケーションで発生したエラーはすべて記録されます。

右上の管理者アイコンよりメニューを開き、アクティビティのモニターを呼び出します。


左下にあるアプリケーション・エラーを開きます。


いつ、誰が、どのアプリケーションの、どのページで、どのようなエラーが発生したのか、などの情報が記録されているので、利用者からの報告が無くても、作成した機能の不具合の有無を確認することが可能です。


データベースのスキーマに定義した制約に対応したエラー・メッセージが定義済みであれば、アプリケーションを開発する人にとっては、相当な負担の軽減になるかと思います。是非、活用の検討をしてみてください。

確認のために作成したアプリケーションのエクスポートをこちらに置いておきます。
https://github.com/ujnak/apexapps/blob/master/exports/apexerrormessage.sql

テキスト・メッセージはエクスポートに含まれていません。これは仕様です。