2022年3月18日金曜日

APEXで日時データを扱う際の注意点について

 APEXで日時データを扱っていると、以下のエラーが発生することがあります。

ORA-01830: 日付書式の変換で不要なデータが含まれています

Oracle APEXのアプリケーションを作成する際の日時データの扱いについて、注意する点を思いつく範囲で紹介します。おおむね書式マスクの設定に関連します。数値にも書式マスクはありますが、一般的には日時データで問題になることが多いと思います。

Oracleのドキュメントとしては、以下の部分に該当します。

SQL言語リファレンス リリース19c
    2 Oracle SQL の基本要素
        日時書式モデル


確認用アプリケーションを作成する


最初にテストに使用するデータを準備します。

DATE型の列MY_DATETIMESTAMP型の列MY_TIMESTAMPなどを持つ表MY_TESTを作成します。SQLワークショップSQLコマンドより実行します。

create table my_test(
my_level number
, my_date date
, my_timestamp timestamp
, my_local_date timestamp with local time zone
, my_global_date timestamp with time zone
);


現在日時の前後15日をテスト用のデータとして、表MY_TESTに投入します。

insert into my_test
select
level my_level
, (sysdate - (7 - level)) my_date
, (localtimestamp - (7 - level)) my_timestamp
, ((systimestamp - numtodsinterval((7 - level),'day'))) my_local_date
, ((systimestamp - numtodsinterval((7 - level),'day'))) my_global_date
from dual
connect by level <= 15;


テスト・データの準備ができたので、アプリケーション作成ウィザードを起動し、テストに使用するアプリケーションを作成します。アプリケーションの名前日付書式の変換とします。

作成するアプリケーションには、クラシック・レポートフォームのページ、および、対話グリッドのページを作成します。

ページの追加をクリックします。


ページの追加で(作成するのはクラシック・レポートですが)対話モード・レポートを選択します。


ページ名レポートとフォームとします。表またはビュークラシック・レポートを選択し、表またはビューとして先ほど作成したMY_TESTを選択します。編集のためのページも作成するため、フォームを含めるチェックを入れます。

ページの追加をクリックします。


再度ページの追加をクリックし、今度は対話グリッドを選択します。


ページ名グリッドとします。表またはビュー編集を許可を選択し、表またはビューとして対話モード・レポートと同様にMY_TESTを選択します。

ページの追加をクリックします。


アプリケーションにクラシック・レポートとフォーム、および、対話グリッドのページが追加されました。

アプリケーションの作成を実行します。


アプリケーションが作成されたらアプリケーション・プロパティの編集をクリックし、アプリケーション定義の画面を開きます。


グローバリゼーション自動タイムゾーンONに変更し、変更の適用をクリックします。


簡易URLONアプリケーションの別名に日本語が含まれていると、自動タイムゾーンをONにすると404 Not Foundのエラーが発生します。


定義を開いて、アプリケーションの別名TESTDATEMASKに変更します。

変更の適用をクリックします。


以上でテスト用のアプリケーションは完成です。

アプリケーションを実行し、作成されたアプリケーションを確認します。

レポートとフォームのページは以下のようになります。


対話グリッドのページです。


このアプリケーションを使用して、日時データの扱いの注意点ついて確認します。


一覧する日付の範囲を指定する



クラシック・レポートで一覧する行の表示条件として、開始日と終了日を設定できるようにします。

ページ・デザイナにて、クラシック・レポートが実装されているレポートとフォームのページを開きます。


リージョンMy Testにページ・アイテムP2_FROMP2_TOを作成します。

リージョンMy Testの上でコンテキスト・メニューを表示させ、ページ・アイテムの作成を実行します。


作成したページ・アイテムの識別名前P2_FROMタイプ日付ピッカーを選択します。ラベル開始日とします。


もうひとつページ・アイテムを作成します。識別名前P2_TOタイプ日付ピッカーラベル終了日です。レイアウト新規行の開始OFFにし、作成したページ・アイテムを横一列で配置します。


一覧する行を絞り込む条件を追加します。リージョンMy TestソースWHERE句として以下を記述します。

MY_DATE between :P2_FROM and :P2_TO


リージョンMy Testに送信ボタンを作成します。

リージョンMy Testの上でコンテキスト・メニューを表示させ、ボタンの作成を実行します。


識別ボタン名B_SUBMITラベル送信とします。動作アクションはデフォルトのページの送信のまま、変更はしません。ページ・アイテムP2_FROMおよびP2_TOの値がHTTPのPOSTでサーバーに送信され、その値が適用されて、レポートとフォームのページ全体が再表示されます。


アプリケーションを実行し、開始日終了日に日付を設定して送信を実行します。


MY_DATEの値が開始日終了日の間にある行が一覧されます。


推奨1:書式マスクを個別に設定したページ・アイテムをSQLに含めるときは、TO_DATE関数などを使用して変換する。



アプリケーション定義グローバリゼーションに設定されているデフォルトの日付書式タイムスタンプ書式と異なる書式をページ・アイテムに適用するために、ページ・アイテム書式マスクを設定します。ソースSQLに現れるページ・アイテムにはデフォルトの書式が適用されるため、ページ・アイテムの書式マスクとは異なります。結果としてORA-1830が発生します。

そのため、書式マスクを個別に設定したページ・アイテムをSQLに含めるときは、TO_DATE、TO_TIMESTAMP、TO_TIMESTAMP_TZ関数などにページ・アイテムと同じ書式マスクを指定して日時データ型に変換する必要があります。

エラーの状況を確認してみます。

レポートのソースWHERE句にはページ・アイテムP2_FROMおよびP2_TOがバインド変数として使用されています。Oracle APEXのアイテムが保持しているデータは、必ず文字列です。

開始2022/03/09終了2022/03/16と設定されているとします。


ソースの条件句は以下のように解釈されます。

MY_DATE between '2022/03/09' and '2022/03/16'

開始と終了の日付である文字列は列MY_DATEがDATE型なので、NLSセッション・パラメータNLS_DATE_FORMATに従ってDATE型に変換されます。

NLSセッション・パラメータのNLS_DATE_FORMATは、アプリケーション定義グローバリゼーションアプリケーション日付書式として設定されています。


デフォルトではDSと設定されているため、アプリケーションのプライマリ言語に依存して日付書式が決まります。日本語(ja)のアプリケーションではRRRR/MM/DDになります。

ソースとなるSQLまたはPL/SQLのコードにて、暗黙の変換が実施される場合はアプリケーション定義の設定が参照されます。

ページ・アイテムに、アプリケーション日付書式とは異なる書式マスクを設定してみます。

ページ・アイテムP2_FROM書式マスクとしてDD/MM/RRRRを設定します。


ページを実行すると、ORA-1830が発生します。アプリケーション日付書式とページ・アイテムの書式マスクの設定が異なるためです。


例外としてはRRRR/MM/DDとRRRR-MM-DDといった、書式マスクの違いが区切り文字だけであれば(書式に厳密な定義であるFXをつけない限り)ORA-1830は発生しません。

SELECT文の条件句を以下のように変更すると、エラーの発生を回避できます。

MY_DATE between to_date(:P2_FROM,'DD/MM/RRRR') and :P2_TO


ページ・アイテムの書式マスクとソースのSQLをセットで変更しているため、ORA-1830は発生しません。



推奨2:グローバリゼーションに設定している書式は注意して変更する。


グローバリゼーション日付書式などはアプリケーション全体に影響します。レポートの表示だけではなく、ページ・アイテム書式マスクが設定されていて、ソースSQLでページ・アイテムが暗黙で日時データに変換されていると、グローバリゼーションの日付書式が変更されることによって、ORA-1830が発生する可能性があります。

推奨1に従ってTO_DATE関数などをソースのSQLに使用していると、エラーの発生を避けることができます。

エラーの状況を確認してみます。

アプリケーション日付書式DS HH24:MI(またはRRRR/MM/DD HH24:MI)と設定します。


ページ・アイテムP2_FROM書式マスクRRRR/MM/DD HH24:MIと設定します。


リージョンMy TestソースWHERE句に以下を記述します。

MY_DATE between :P2_FROM and :P2_TO


この場合、アプリケーションの日付書式とページ・アイテムP2_FROMの設定は一致しているため、エラーは発生しません。


時刻表示をデフォルトから外すために、アプリケーションの日付書式DSに変更します。


結果として、ORA-1830が発生します。


エラーが発生するページ(今回の例ではレポートとフォームのページ)は一切変更していないので(特に他の人がアプリケーションの日付書式を変更した場合)、原因を見つけにくいエラーになります。


推奨3:アプリケーション日付書式に時間を含めるときは、タイムスタンプ書式にも時間を含める。



ソースSQLの検索条件の列がDATE型でない場合、ページ・アイテムにはDATE型の書式(日付書式)、ソースのSQLでの暗黙変換では列の型に合わせた書式(例えばタイムスタンプ書式)が適用されます。そのため書式が異なるとORA-1830が発生します。

エラーの状況を確認してみます。

ページ・アイテムタイプ日付ピッカーソースタイプNULLの場合、ページ・アイテム書式マスクが未指定であれば、アプリケーション日付書式が適用されます。


フォームのページ・アイテムであれば、以下のようにデータ型が指定されるので、この限りではありません。


今回使用しているアプリケーションに含まれるページ・アイテムP2_FROMP2_TOといったページ・アイテムはデータ型が指定されていないので、アプリケーション日付書式が適用されます。

グローバリゼーションアプリケーション日付書式としてDS HH24:MIタイムスタンプ書式としてDSを設定します。

意識してこういう設定はしないと思います。一般にオラクルのDATE型は時刻を含むため、TIMESTAMP型はあまり使われません。そのため、DATE型の日付書式だけを変更し、タイムスタンプ書式はデフォルトを変更していないことはあり得ます。


範囲指定に時刻を含めるため、ページ・アイテムP2_FROMP2_TO設定時間の表示ONにし、外観書式マスクRRRR/MM/DD HH24:MIにします。


本題から外れますが、日付ピッカーで時間の表示をONにすると、以下の問題が発生するので要注意です。
  1. 書式マスクを空白にできないため、デフォルトの日付書式が適用されない。
  2. APEX 21.2では書式マスクDSが正しく認識されない。
  3. 書式マスクTSは正しく認識されない。
書式マスクTSはそれほど問題ないとは思いますが、アプリケーションを多言語対応する際には書式マスクDSが使えないのは問題となるでしょう。

この設定でソースWHERE句として列MY_DATEではなくMY_TIMESTAMPが対象になっていると、0RA-1830が発生します。

MY_TIMESTAMP between :P2_FROM and :P2_TO

これは列MY_TIMESTAMPがTIMESTAMP型であるため、ページ・アイテムP2_FROMおよびP2_TOにNLS_TIMESTAMP_FORMAT(アプリケーションのタイムスタンプ書式)が適用されるためです。


デフォルトのタイムスタンプ書式を合わせるか、または、WHERE句にTO_TIMESTAMP関数を使う必要があります。


推奨4:DATE型のデータと比較する際にはTRUNC関数の利用を検討する。



特に意識せずDATE型のデータにアプリケーション日付書式が適用されていると、オラクルのDATE型が時間を含んでいることを忘れてしまうことがあります。結果として、範囲指定などが意図しない結果になります。

状況を確認してみます。

アプリケーション日付書式DSとします。


ページ・アイテムP2_FROMP2_TO設定時間の表示OFFにします。時間の表示OFFに切り替えても外観書式マスクの設定は残るため、書式マスクの設定も削除します。


レポートの検索条件が以下で、P2_FROM2022/03/09P2_TO2022/03/16を指定したケースを考えます。

MY_DATE between :P2_FROM and :P2_TO

検索結果に終了日である2022/03/16は含まれません。


TRUNC関数を使うようにWHERE句を変更します。

trunc(MY_DATE) between :P2_FROM and :P2_TO

検索結果に終了日である2022/03/16が含まれます。


グローバリゼーションアプリケーション日付書式DS HH24:MIに変更して実際のデータを確認してみます。


終了日として指定されている日付は厳密には2022/03/16 00:00なので、2022/03/16 02:15はそれより後の時刻になるため検索結果には含まれません。TRUNC関数を適用すると2022/03/16 02:15の時刻の部分は丸められて2022/03/16 00:00となるため、検索結果に含まれます。


推奨5:フォームのページ・アイテムに日時データがある場合、変更しなくても書式マスクが適用された日時で更新される。



日付のみが表示される書式マスクがページ・アイテムに適用されていると、フォームを保存したときに時刻の部分が00:00に更新されます。

状況を確認します。

レポートに含まれる日時データの列の書式マスクDS HH24:MIに設定します。


それぞれの時刻データを確認します。00:00ではない時刻になっているはずです。

任意の一行を編集するため、フォームを開きます。


レベルだけを変更し、日時データは変更しません。変更の適用を行います。


更新されたデータを確認すると、時刻の部分がすべて00:00になっていることが分かります。


この動作は対話グリッドでも同様です。

グリッドのページを開いて任意の一行のLevel値を変更し、保存を実行します。


対話モード・レポートに戻って変更された行を確認すると、時刻の部分がすべて00:00になっていることが分かります。


DATE型のデータの表示を日付に限定しているにもかかわらず、アプリケーションの内部で時刻まで扱うようなコーディングをしていなければ問題は起こらないように思います。


推奨6:アプリケーションを多言語に対応させる場合、書式DSとRRRR/MM/DDといった書式を混在させない。



書式DSはアプリケーションのプライマリ言語の設定によって変わります。例えば日本語(ja)ではRRRR/MM/DDですが英語(en)ではDD/MM/RRRRです。ページ・アイテムの書式マスクにRRRR/MM/DDといった設定がされていると、プライマリ言語を変更したときにORA-18xxのエラーが発生することがあります。

エラーの状況を確認してみます。

グローバリゼーションアプリケーションのプライマリ言語日本語(ja)で、アプリケーション日付書式DSとします。


ページ・アイテム書式マスクとしてRRRR-MM-DDを設定します。ページ・アイテムの表示は2022-03-09といった形式になります。区切り文字の変更であればデフォルトのアプリケーション日付書式DS(日本語なのでRRRR/MM/DD)でエラーなく変換が行われます。


アプリケーションのプライマリ言語英語(en)に変更します。


書式マスクはDD/MM/RRRRとなるため、エラーが発生します。発生するエラーはORA-1843: not a valid monthになっていますが、原因は日時データとNLS_DATE_FORMATとの書式の不一致です。


日時フォーマットを多言語に対応させるには、DS(およびDL)といった書式マスクを使用する必要があります。そのため、エラーを発生させないようにページ・アイテムには個別に書式マスクを設定しない、書式マスクを設定した場合はTO_DATE関数を使って暗黙の変換はさせないといったことを行う必要があります。

APEX 21.2の日付ピッカーで時間の表示をONにすると、書式マスクDSが使えないようです。そのため、暗黙の変換を避けるような実装が必要です。


推奨7:日時データのページ・アイテムでタイムゾーンを扱わない。



アプリケーションのタイムスタンプ・タイムゾーン書式に含まれるTZRは、ページ・アイテムについては効果がありません。

状況を確認してみます。

グローバリゼーションアプリケーションのタイムスタンプ・タイムゾーン書式RRRR/MM/DD HH24:MI:SS TZRに設定します。


(APEX 21.2ではタイムスタンプ書式にDSを使用すると、ページ・アイテムに時刻が適切に表示されません。)

レポートを表示すると、TIMESTAMP WITH TIME ZONE型のデータにタイムゾーンが表示されていることが確認できます。


任意の一行を編集フォームで開きます。ページ・アイテムにはタイムゾーンは表示されません。


日時データは変更せずに(Levelだけを変更する)、変更の適用をします。レポートを確認すると、元々のデータが 2022/03/13 02:15:00 +00:00だったのが 2022/03/13 02:15:00 +09:00となっていることが確認できます。元々のタイムゾーンの代わりにアプリケーションのセッション・タイムゾーンが適用されたためです。


結果として、9時間、時間が進んでいます。TIMESTAMP WITH TIME ZONE型を使う場合には注意が必要です。

対話グリッドの場合、TIMESTAMP WITH TIME ZONE型の列にタイムゾーンが表示されていれば、タイムゾーンも含めて問題なく編集できます。


現時点で思いつく、Oracle APEXのアプリケーションで日時データを扱う際の注意点は以上になります。

ちなみに、デバッグ・ログより、NLS関連の設定はページ処理の一番最初に行われていることが分かります。



推奨8:ソースとなるSELECT文の列にTO_CHAR関数は使用しない。


今回のサンプルではリージョンMy Testのソースには表を指定しています。ソースタイプSQL問合せに切り替えると、以下のSELECT文がSQL問合せに現れます。

select ROWID,
MY_LEVEL,
MY_DATE,
MY_TIMESTAMP,
MY_LOCAL_DATE,
MY_GLOBAL_DATE
from MY_TEST
where MY_DATE between :P2_FROM and :P2_TO


日時データ型の書式を指定するために、TO_CHAR関数を以下のように使用すると、レポートとしては、その列が文字列型と認識されます。

select ROWID,
MY_LEVEL,
to_char(MY_DATE,'RRRR/MM/DD') MY_DATE,
MY_TIMESTAMP,
MY_LOCAL_DATE,
MY_GLOBAL_DATE
from MY_TEST
where MY_DATE between :P2_FROM and :P2_TO

特に対話モード・レポートにおいて、日時データ型に適用できるフィルタ条件が変わるため注意が必要です。

列が日時データ型として認識されている場合のフィルタ条件です。


TO_CHAR関数を適用した場合は、列の表示はまったく同じに見えても文字列型として扱われます。


文字列型として認識されると、ページ・アイテムの書式マスクおよびデフォルトの日付書式は適用されません。


推奨9:デフォルトの指定にTO_CHAR関数および文字列は使わない


Oracle APEX 21.2で発生する事象で、Oracle APEX 21.1では発生しません。とはいえ、最初からデフォルトにTO_CHARおよび文字列を使っていなければ、両方のバージョンで安全です。

日付を保持するページ・アイテムのデフォルトを、現在の日付やその前後の日付に設定する場合は多いと思います。TO_CHARを使って書式を指定すると、結果は文字列になります。デフォルトが文字列の場合、ページ・アイテムには文字列がそのまま設定されます。

TO_CHARで指定している書式とページ・アイテムの書式マスクまたはアプリケーション定義アプリケーション日付書式が一致している場合は問題は発生しませんが、ページ・アイテムの書式マスクまたはアプリケーション日付書式が変更されるとエラーが発生します。

特にアプリケーション日付書式を変更している場合、ページ自体は変更していないし、また、利用者側から見ても違いはないため、原因が分かりにくいエラーになります。

ページ・アイテムのデフォルトとしてタイプPL/SQL式として

to_char(sysdate,'YYYY/MM/DD')

が設定されています。


デフォルトを確認するために、新規作成でフォームを開くと2022/03/25のように値が設定されます。to_char(sysdate,'YYYY/MM/DD')の結果が設定されています。


グローバリゼーションアプリケーション日付書式を、DSからDD/MM/RRRRへ変更します。


再度、フォームを開いて確認します。デフォルトは変わらず2022/03/25です。そのため、この日付でそのまま保存しようとするとORA-18xxのエラーが発生します。


開発者が意図してYYYY/MM/DDの書式にしているので、それをそのままページ・アイテムに設定しているAPEX 21.2の動作の方が理にかなっています。

デフォルトの値をソースデータ型に一致させると、このようなエラーは発生しません。


現在の日時をデフォルトにする場合であれば、DATE型はSYSDATEまたはCURRENT_DATE、TIMESTAMP型はSYSTIMESTAMP(タイムゾーン有り)、CURRENT_TIMESTAMP(タイムゾーン有り)、LOCALTIMESTAMP(タイムゾーン無し)などを使うことになります。

以上になります。

今回テストに使ったアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/testdatemask.sql

Oracle APEXのアプリケーション作成の参考になれば幸いです。