Oracle Databaseで2つの日付の間に日、週、または月を生成する方法 (2021/06/01)
Oracle Databaseで2つの日付の間に日、週、または月を生成する方法 (2021/06/01)
投稿者:Chris Saxon | Developer Advocate
Oracle Databaseで行を生成するには多くのコツがあります。最も簡単なのは、レベル別に接続する方法です。
select level rn from dual connect by level <= 3; RN 1 2 3
これを使って、2つの日付の間のすべての日数を取り出すことができます。
- 最後の日付から最初の日付を引いて日数を得る
- この数の行を生成する (終了日を出力に含める場合は 1 つ追加)
- 現在の行番号(マイナス1)を開始日に加える
6月8日から14日までの各日の行を作成するには、次のようにします。
select date'2021-06-08' + level - 1 dt from dual connect by level <= ( date'2021-06-14' - date'2021-06-08' + 1 ); DT 08-Jun-2021 09-Jun-2021 10-Jun-2021 11-Jun-2021 12-Jun-2021 13-Jun-2021 14-Jun-2021
簡単でしょう?
しかし、これは2つの日付の間のすべての日を求める場合にのみ機能します。タイムスタンプを扱っている場合や、各行の間に異なる期間を設定したい場合(例:週、月、年)は、このクエリを調整する必要があります。
この記事では、以下の方法を学びます。
- Get the days between
timestamps
- Generate rows from the oldest to the newest date in a table
- Return every Monday in a year
- Fetch every second Friday in a year
- Get the months between two dates
- Show the first and last day of each month
- Return a row per quarter
- Generate a row per year in a period
- Make reusable date generation functions
NOTE 日付時間の値の表示は、クライアントのNLSの設定によって決まります。この記事では、これらの設定を使用して日付とタイムスタンプの値をフォーマットします。
alter session set nls_date_format = 'DD-Mon-YYYY';
alter session set nls_timestamp_format = 'DD-Mon-YYYY hh24:mi:ss';
常に同じ方法で日付を表示するには、to_charを使ってフォーマットを設定します。
ビデオ形式のコンテンツをご希望の場合は、このトピックを詳しく説明したAsk TOM Office Hoursセッションの録画をご覧ください。
収録で使用したスクリプトは、Live SQLで入手できます。これらはこの記事の例と似ています。
タイムスタンプ値間の日数を取得する方法
ある日付の値から別の日付の値を引くと、その結果は数字になります。その単位は「日数」です。
しかし、どちらかの値がタイムスタンプであれば、その差は(日から秒までの)間隔になります。ここから日数を求めるにはextractを使います。
with time_between as ( select timestamp'2021-06-14 06:00:00' - timestamp'2021-06-08 14:00:00' t from dual ) select t, extract ( day from t ) dys from time_between; T DYS +05 16:00:00.000000 5
この例では、別の潜在的な問題が浮き彫りになっています。
開始時刻が終了時刻よりも遅いため、日付間の時間は1日の一部となります。完全な日を追加するだけでは、1日足りなくなってしまいます。この値を使って行を作成すると、抽出された日に1日追加しても、結果には6月8日~13日しか表示されません。
with time_between as ( select timestamp'2021-06-14 06:00:00' - timestamp'2021-06-08 14:00:00' t from dual ) select timestamp'2021-06-08 14:00:00' + numtodsinterval ( level - 1, 'day' ) dt from time_between connect by level <= extract ( day from t ) + 1; DT 08-Jun-2021 14:00:00 09-Jun-2021 14:00:00 10-Jun-2021 14:00:00 11-Jun-2021 14:00:00 12-Jun-2021 14:00:00 13-Jun-2021 14:00:00 6 rows selected.
Oracle Database の日付には必ず時刻が含まれているため、どのような入力値でもこの問題が発生します。レベルで接続する方法では、小数点以下の数字は無視されます。そのため、差分を切り上げる必要があります。
週、月、年など、他の期間を扱う場合は、この単位に切り上げる必要があります。
どのような単位を使用する場合でも、1つ違いのエラーを避けるために、最終値を含めるべきか除外すべきかを再確認してください。
日ごとに行を作成する場合、最初に値をtrunc()することで、1日単位の問題を回避することができます。これにより時刻が取り除かれ、真夜中の時刻を持つ日付が返されます。入力がタイムスタンプであっても、結果は常に日付になります。つまり、extractの代わりに入力値をtruncすることで、範囲内のすべての日を取得することができます。
with time_between as ( select trunc ( timestamp'2021-06-14 06:00:00' ) - trunc ( timestamp'2021-06-08 14:00:00' ) t from dual ) select * from time_between; T 6
これは、テーブルの列を使って日付範囲を生成したい場合に特に便利です。たとえば、テーブルの2つの行の間にあるすべての日を表示する場合などです。
テーブルの古い日と新しい日の間の日数を取得する方法
ここまでは、開始日と終了日はユーザーが定義するものと仮定してきました。例えば、1月の毎日、過去4週間、エンドユーザーが指定した2つの日付の間を表示するなどです。
しかし、テーブルの最初と最後の挿入日を開始点と終了点として使用したい場合もあります。そのためには、これらの値を取得し、それを使って行ジェネレータを動かします。
この例では、Customer Orders スキーマを使用して、orders テーブルの最も古い日付と最も新しい日付を見つけます。
select min ( order_datetime ) start_date, max ( order_datetime ) end_date from co.orders; START_DATE END_DATE 04-Feb-2018 13:20:22 12-Apr-2019 21:41:06
レベルによる接続のトリックは、1行のテーブルでも機能します。そして、上記のクエリは1行を返します。つまり、これをデータソースとして使用し、行を作成することができます。
with ranges as ( select min ( order_datetime ) start_date, max ( order_datetime ) end_date from co.orders ) select start_date + level - 1 dt from ranges connect by level <= ( trunc ( end_date ) - trunc ( start_date ) + 1 ); DT 04-Feb-2018 05-Feb-2018 ... 11-Apr-2019 12-Apr-2019 433 rows selected.
任意のクエリを使用して日付の範囲を定義することができます。そのため、このメソッドを使用して、テーブルのサブセットの日付を返すことができます。たとえば、特定の店舗や顧客の最初の注文から最後の注文までのすべての日数を表示することができます。
with stor as ( select store_id, order_datetime from co.orders o where store_id = 23 ), rws as ( select min ( trunc ( order_datetime ) ) mn_dt, max ( trunc ( order_datetime ) ) mx_dt from stor ) select dt, count ( order_datetime ) num_orders from ( select mn_dt + level - 1 as dt from rws connect by level <= mx_dt - mn_dt + 1 ) left join stor on dt <= order_datetime and dt + 1 > order_datetime group by dt order by dt; DT NUM_ORDERS 22-Mar-2019 1 23-Mar-2019 0 ... 11-Apr-2019 3 12-Apr-2019 1 22 rows selected.
ここまでは、1日ごとに行を作成してきました。しかし、1週間ごとに行を作成するなど、異なる時間単位を使用したい場合はどうすればよいでしょうか。
ある年の毎週月曜日を表示する方法
ある年の毎週月曜日を表示する必要があるとします。ひとつの方法として、以下のようなものがあります。
- その年のすべての日の行を生成する。
- 必要な日だけを表示するように出力をフィルタリングする。
to_char関数には、日付から曜日を抽出するために使用できるさまざまな書式マスクがあります。この例では、dayを使用して完全な曜日名を取得しています。
with rws as ( select date'2020-12-31' + level dt from dual connect by level <= ( date'2022-01-01' - date'2021-01-01' ) ) select dt from rws where to_char ( dt, 'fmday', 'nls_date_language = English' ) = 'monday'; DT 04-Jan-2021 11-Jan-2021 ... 20-Dec-2021 27-Dec-2021 52 rows selected.
nls_date_languageを指定することは良い習慣です。これにより、セッションの言語設定に関わらず、曜日の変換に常に英語の名前が使われるようになります。
しかし、このクエリには問題があります。必要な行数の約7倍の行が作成されるのです。これは、高速なSQLの重要な原則を破っています。
早めにフィルタリングしましょう
必要な行だけを選択することで、データの転送を最小限に抑えることができます。処理するデータが少なければ少ないほど、クエリは速くなります。
実際には、この違いを実感することはほとんどないでしょう。しかし、必要なデータだけを取得することは、良い習慣です。出力では各週の行を生成するだけにしたほうがいいでしょう。
1年のうちの月曜日の数は52と53の間で変化します。これは、1月1日がどの曜日であるか、うるう年であるかどうかによります。
では、何行必要なのでしょうか?
この式は、1年のうちの月曜日の数を返します。
31st December - first Monday |
+ 1 week |
7 days |
Oracle Databaseには1週間という時間単位はありません。これをシミュレートするには、7日間を使用します。
その年の最初の月曜日を求めるには、次のようにします。
- next_dayを使うと、入力された曜日の後の最初の日付が返されます。入力された日付が要求された日であれば、次の週を返します。つまり、その年の最初の月曜日を求めるには、前年の12月31日を次のように渡します。
- 入力された年の1月7日のISO週の開始日を検索します。
これらはどちらも2021年の最初の月曜日を見つけることができます。
select next_day ( date'2020-12-31', 'Monday' ) nxt_day, trunc ( date'2021-01-07', 'iw' ) trnc from dual; NXT_DAY TRNC 04-Jan-2021 04-Jan-2021
next_dayを使う方が直感的です。コードを見る誰もがあなたの意図を理解できるでしょう。
しかし、これには大きな欠点があります。
それはNLS_LANGUAGEを使って日にちを決めることです。これは、セッションで変更可能な設定です。曜日名に使用する言語との間に不一致があると、エラーが発生します。
alter session set nls_language = Spanish; select next_day ( date'2020-12-31', 'Monday' ) nxt_day from dual; ORA-01846: día de la semana no válido
残念ながら、next_dayには、使用するNLS_LANGUAGEを指定するパラメータがありません。そのため、予期せぬエラーを避けるためには、コードの中でこのパラメータを確認し、設定しなければなりません。
ISO週の開始日を使用することで、この問題を回避できます。これは常に月曜日です。そのため、誰かがNLSの設定を変更したとしても、クエリは常に同じ結果を返します。
どの方法で最初の月曜日を見つけるにしても、上の式で 1 週間を 7 日に置き換えると、次のようになります。
alter session set nls_language = English; with rws as ( select next_day ( date'2021-01-01' - 1, 'Monday' ) + ( level - 1 ) * 7 as dt from dual connect by level <= ( ( date'2021-12-31' - next_day ( date'2021-01-01' - 1, 'Monday' ) + 7 ) / 7 ) ) select dt from rws; DT 04-Jan-2021 11-Jan-2021 ... 20-Dec-2021 27-Dec-2021 52 rows selected.
これにより、NLSの変更からコードを保護することができます。しかし、特に異なる曜日を返したい場合には、理解するのが少し難しくなります。
隔週の金曜日を取得する方法
毎週月曜日を得るための解決策を、任意の曜日に一般化し、任意の週数で増加させることができます。例えば、2021年の隔週の金曜日です。
next_dayを使って曜日を取得している場合、解決方法は簡単です。入力値から1日分を引いて、次の金曜日を返します。
ISO weekメソッドで入力値の前後の最初の金曜日を求める場合は、より厄介です。この場合の式は
date + 6 - mod ( ( date + 6 - day of week number ) - start of ISO week, 7 )
日数は0~6で、0が月曜、6が日曜となっています。
隔週で取得するには、日にちを14で正規化し、2週間ごとの行を取得します。
next_dayとtruncを使って2021年の隔週の金曜日(4日目)を選択する例を示します。
with input as ( select date'2021-01-01' start_date, date'2021-12-31' end_date, 4 day_of_week from dual ), rws as ( select next_day ( start_date - 1, 'Friday' ) + ( level - 1 ) * 14 as nxt_day, start_date + 6 - mod ( ( start_date + 6 - day_of_week ) - trunc ( start_date , 'iw' ), 7 ) + ( level - 1 ) * 14 as trnc from input connect by level <= ( ( end_date - next_day ( start_date - 1, 'Friday' ) + 14 ) / 14 ) ) select * from rws; NXT_DAY TRNC 01-Jan-2021 01-Jan-2021 15-Jan-2021 15-Jan-2021 ... 17-Dec-2021 17-Dec-2021 31-Dec-2021 31-Dec-2021 27 rows selected.
ISO weekメソッドは、NLSの設定に関わらず同じ結果を返しますが、next_dayよりも理解しづらいです。また、このメソッドは、月曜日が週の最初の日であると仮定して値をゼロにすることを呼び出した人に依存しています。
どのような方法で開始日を見つけるにしても、この日から週への正規化テクニックを使って、他の日ベースの間隔を作ることができます。たとえば、4週間ごとや10日ごとなどです。
しかし、暦月の場合はどうでしょうか。これは28日から31日の間で変化します。これを正しく行うには、方法を変える必要があります。
2 つの日付の間の月を選択する方法
Oracle Databaseには、以下の月の算術関数があります。
- add_months
- months_between
その名のとおり、これらの関数は月を基本単位として使用します。したがって、生成する行の数を調べるには、開始日と終了日の間のmonths_betweenを使用します。次に add_months で各行の月を増やします。
これにより、2021年の各月の行が作成されます。
select add_months ( date'2021-01-01', level - 1 ) as dt from dual connect by level <= months_between ( date'2021-12-31', date'2021-01-01' ) + 1; DT 01-Jan-2021 01-Feb-2021 ... 01-Nov-2021 01-Dec-2021 12 rows selected.
Add_monthsは、入力の月日を基準にしています。返される日付は、これと同じ月の同じ日になります。注意点としては、入力日が月末の場合、出力も月末になります。
Months_betweenも同様の動作をします。2つの日付が同じ月の同じ日、またはその月の最終日であれば、結果は整数となります。それ以外の場合は、31日の月を基準にして、月の端数を含みます。
ほとんどの場合、これはあなたが望むものです。しかし、毎月28日にプロセスを実行したい場合は注意が必要です。
1月から始めた場合、すべての行が28日になります。
select add_months ( date'2021-01-28', level - 1 ) as dt from dual connect by level <= months_between ( date'2021-03-01', date'2021-01-01' ) + 1; DT 28-Jan-2021 28-Feb-2021 28-Mar-2021
しかし、(うるう年ではない)2月から始めれば、他の行はすべて月末に移動します。
select add_months ( date'2021-02-28', level - 1 ) as dt from dual connect by level <= months_between ( date'2021-04-01', date'2021-02-01' ) + 1; DT 28-Feb-2021 31-Mar-2021 30-Apr-2021
すべての日付が毎月28日になるようにするには、代わりに月単位の間隔を追加します。
select date'2021-02-28' + numtoyminterval ( level - 1, 'month' ) as dt from dual connect by level <= months_between ( date'2021-04-01', date'2021-02-01' ) + 1; DT 28-Feb-2021 28-Mar-2021 28-Apr-2021
これは、月の間隔を追加すると、入力と同じ月の日が返されるからです。
では、なぜ最初に add_months を使ったのでしょうか。
開始日が29日以降の場合、最終的に結果は存在しない日付(例:2月30日)にマッピングされ、例外が発生します。
select date'2021-01-30' + interval '1' month as dt from dual; ORA-01839: date not valid for month specified
そのため、月を追加する際には、月の28日以降の開始日をどのように処理するかを慎重に検討してください。この問題を回避するには、各月に2つの列を表示する方法があります。1つは開始日、もう1つは終了日を表します。
各月の初日と最終日を含む月ごとの行を取得する方法
月ごとの行のソリューションを拡張して、月の開始日と終了日を示す2つの列を持つようにしたい場合があります。
最初の行の開始日は入力日で、他の行は月の1日とします。終了日は、その月の最終日を表示します。ただし、最終行は最初の行と同じ月の日を表示します。
例えば、以下のようになります。
Start Date | End Date |
15 Jan 2021 | 31 Jan 2021 |
1 Feb 2021 | 28 Feb 2021 |
1 Mar 2021 | 31 Mar 2021 |
1 Apr 2021 | 15 Apr 2021 |
行を作成するには、上記の月の作成方法を使用します。次に、以下のルールで列の値を設定します。
- 開始日:最初の行では、入力された日付を返し、それ以外の場合は、その月の1日を返します。
- 終了日:次の行の月の開始日を取得し、そこから1日を引きます。最後の行では、計算された日付を返します。
これらをSQLで行うのは簡単です。
- 開始日:結果に行番号を割り当てる。これが1であれば、入力された日付を返します。そうでなければ、mm形式のtruncを使って月の最初を返します。
- 終了日: leadを使って次の行の月を調べます。開始日と同様に、trunc ( ... , 'mm' ) を使用して、その月の最初の日を取得します。これを今月の最終日にして、1を差し引きます。最終行の日付を取得するには、leadの3番目のパラメータに現在の日付を渡します。これは、結果セットの最後を超える値を取得しようとしたときに lead が返す値です。
これで得られるのは
with mths as ( select level as rn, add_months ( date'2021-01-15', level - 1 ) as dt from dual connect by level <= months_between ( date'2021-04-15', date'2021-01-15' ) + 1 ) select case rn when 1 then dt else trunc ( dt, 'mm' ) end start_date, lead ( trunc ( dt, 'mm' ) - 1, 1, dt ) over ( order by dt ) end_date from mths; START_DATE END_DATE 15-Jan-2021 31-Jan-2021 01-Feb-2021 28-Feb-2021 01-Mar-2021 31-Mar-2021 01-Apr-2021 15-Apr-2021
日の作成方法と同様に、月の作成を拡張して、四半期や年など、月をベースにした他の期間を作成することができます。
1年のうち四半期ごとに行を取得する方法
四半期は3ヶ月単位です。週と同様に、Oracle Databaseではこれらに定義された期間はありません。したがって、四半期ごとに行を取得するには、以下の方法で必要な行数を求めます。
- 開始日と終了日の間の月数を求める
- これを3で割ります。
- 必要に応じて終了日を含む/含まないように、ビジネスルールに従って結果を丸めます。
このようにして得られた各行に3ヶ月を加えると、次のようになります。
select add_months ( date'2021-01-01', ( level - 1 ) * 3 ) as dt from dual connect by level <= ( months_between ( date'2021-06-01', date'2021-01-01' ) + 3 ) / 3; DT 01-Jan-2021 01-Apr-2021
日付範囲内の年を返す方法
年ごとに行を作成する手順は、四半期ごとに説明したものと同様です。
- 開始日と終了日の間の月数を求めます。これを12で割って年に換算します。
- 各行ごとに、開始日に1年を加えます。これを行うには、行番号から1を引いた数に、12ヶ月または1年の間隔を掛けます。
select add_months ( date'2021-01-01', ( level - 1 ) * 12 ) as yr_add_months, date'2021-01-01' + numtoyminterval ( level - 1, 'year' ) yr_interval from dual connect by level <= ( months_between ( date'2023-01-01', date'2021-01-01' ) + 12 ) / 12; YR_ADD_MONTHS YR_INTERVAL 01-Jan-2021 01-Jan-2021 01-Jan-2022 01-Jan-2022 01-Jan-2023 01-Jan-2023
年の間隔を追加することは、月の間隔を追加するのと同じ曜日のロジックを持っています。つまり、入力と出力が同じ数字になるのです。
これが、見つけにくい罠、うるう年につながります。2020年2月29日に「1」年の間隔を加えると、2021年2月29日という無効な日付になります。月の生成と同様に、どのように処理したいかをよく確認し、徹底的にテストしてください。
この時点で、2つの日付の間の期間ごとに1行を生成する、似ているようで微妙に異なるいくつかのクエリがあります。
これらを再利用可能なテンプレートにすることができればいいのですが。朗報は、19.6からSQLマクロを使ってできるようになったことです。
SQLマクロで再利用可能な日付生成関数を作る方法
SQLマクロは、(スカラまたはテーブル)SQL式のテキストを返します。解析時に、データベースはこのテキストを抽出し、最終的なSQL文の一部にします。その際、データベースは戻り値の文字列の中からパラメータ名を探します。見つかったものは、実際のパラメータのテキストに置き換えられます。
たとえば、日にちジェネレータのマクロを作成するには、次のような関数を作成します。
- 開始日と終了日のパラメータを受け取る
- テーブルのSQLマクロである文字列を返す
Giving
create or replace function generate_days ( start_date date, end_date date, day_increment integer default 1 ) return clob sql_macro as stmt clob; begin stmt := 'select start_date + ( level - 1 ) * day_increment as dt from dual connect by level <= ( ( ( end_date - start_date ) + day_increment ) / day_increment )'; return stmt; end generate_days;
これは、日付リテラルやバインド変数、関数で呼び出すことができます。
例えば、以下のようになります。
select * from generate_days ( date'2021-06-01', date'2021-06-30' ); DT 01-Jun-2021 02-Jun-2021 ... 29-Jun-2021 30-Jun-2021 30 rows selected. var start_day varchar2(10) exec :start_day := '2021-01-01'; select * from generate_days ( to_date ( :start_day, 'yyyy-mm-dd' ), sysdate, 7 ); DT 01-Jan-2021 08-Jan-2021 ... 14-May-2021 21-May-2021 21 rows selected.
最終的なクエリを確認したい場合は、dbms_utility.get_sql_textにコールを渡すことで取得できます。
declare l_clob clob; begin dbms_utility.expand_sql_text ( input_sql_text => q'!select * from generate_days ( to_date ( :start_day, 'yyyy-mm-dd' ), sysdate, 7 )!', output_sql_text => l_clob ); dbms_output.put_line(l_clob); end; / SELECT "A1"."DT" "DT" FROM ( SELECT "A2"."DT" "DT" FROM ( SELECT TO_DATE ( :B1, 'yyyy-mm-dd' ) + ( LEVEL - 1 ) * 7 "DT" FROM "SYS"."DUAL" "A3" CONNECT BY LEVEL <= ( SYSDATE - TO_DATE ( :B2, 'yyyy-mm-dd' ) + 7 ) / 7 ) "A2" ) "A1"
7、バインド変数、およびsysdateが最後のSQL文の一部であることに注意してください。データベースは、マクロを解決する際に変数や関数を評価しません。それらはそのままクエリの一部になります。
日、月、年を生成するサンプルAPIがLive SQLで公開されています。
とはいえ、日を生成するクエリを定期的に書くのであれば、日付テーブルの作成を真剣に検討してください。このテーブルには、必要なカレンダーの日付ごとに1行が格納されます。
これには、生成する方法に比べていくつかの利点があります。
- 古いバージョンのOracle Databaseであっても動作します。
- オプティマイザがフェッチしている行数を推定しやすくなり、より良いプランと高速なクエリが可能になります。
- ビジネスに特化した期間を定義するためのカラムを追加できます。例えば、会計年度や、週末、祝日、臨時休業などの非業務日などです。
まとめ
connect by level メソッドは、簡単に行を生成できる方法です。これにより、日付範囲の各日、週、月、または年を返すことができます。
各期間の行を作成するロジックは微妙に異なります。月末の日付を扱うときには、特に注意が必要です。また、ひとつ違いのエラーにも注意しなければなりません。結果に終了日を含めるべきか、除外すべきか。
また、日付の変換や計算が NLS の変更に影響されないようにする必要があります。これを行わないと、異なるサーバからコードを呼び出しても、異なる結果が得られる可能性があります。
これらの問題を標準的な方法で処理するために、Oracle Database 19c からは SQL マクロを使用して日付生成 API を作成することができます。しかし、多くの場合、アプリケーション用の日付テーブルを作成する方が良いでしょう。
SQLを学びたい方は、Databases for Developers:Foundationsに参加しましょう。この無料オンラインクラスでは、SQLの基礎を学ぶことができます。
これらのクエリを試すための本格的なデータベースが必要な場合は、Oracle Cloud Free TierのAlways Free Autonomous Databaseにお申し込みください。
コメント
コメントを投稿