ご質問・お見積り等お気軽にご相談ください
お問い合わせ

PHP・PostgreSQLのIntervalの罠

PHP・PostgreSQLのIntervalの罠

こんにちはSGKです。

最近勤務シフトのシステムを作成するお仕事がありまして、「Interval」使えるかな?
とレビューしてみたのが始まり、結果として「使えないなこりゃ」となったのでその事を書きたいと思います。

Interval とは?

ここでいう “Interval” とは時間の間隔を表すデータの型、もしくはクラスです。
PostgreSQL は Interval型、PHPでは DateInterval クラスとして用意されており、主に DateInterval について書いていきます。

加えて CakePHP では DateInterval を継承した ChronosInterval があり、こちらについても少しだけ触れていきたいと思います。

DateInterval の使用例

日時を扱う処理と相性が良く、「2023年11月29日 + 15日」等の処理を行う場合、「2023年11月29日」は日時の DateTime クラス、「15日」は DateInterval クラスというようなケースで使用します。

$dt = new \DateTime('2023-11-29');
$di = new \DateInterval('P15D');
echo $dt->add( $di )->format('Y-m-d'); // 2023-12-14

ただし上記のような例は冗長で、わざわざ DateInterval を使う必要は無く DateTime の modify メソッドを使えば簡単に書けたりします。

$dt = new \DateTime('2023-11-29');
echo $dt->modify( '15days' )->format('Y-m-d'); // 2023-12-14

DateInterval の有用な使用例

では、どのようなケースが有用な使用法かというと DateTime 同士を diff で差分計算した場合に返される値が DateInterval となっており、以下のようなケースで使用されることが多いです。

$dt1 = new \DateTime('2023-11-29');
$dt2 = new \DateTime('2023-12-14');
$di = $dt1->diff( $dt2 );
echo $di->format('差は %d日 もしくは %a日、%%a は DateTime で diff した場合のみ使用可能。');

echo $dt1->add( $di )->format('Y-m-d');

注意点としてDateInterval の format メソッドでは使い慣れた date関数や DateTime のものとは違い、% を先頭において続けて指定子を置くと、それを値に置き換える方式になっています。

罠1:マイナスの値を保持できるが、、

で。いよいよ罠のご登場です。「1」とか言っているのでひとつじゃないです無念。

DateInterval クラスの実体として日時の各部位がプロパティとして独立してあります。

y:年、m:月、d:日、h:時、i:分、s:秒、f:マイクロ秒 となっています。

これに加えて invert:負の数は1・それ以外は0 がありますが、、、
コレがひとクセありで、実は「$di->d = -15;」のように各プロパティにマイナスを設定できるにもかかわらず invert があるんです。

必要です?invert?まぁ一応以下のように マイナス15日にinvert=1をセットしたDateIntervalをDateTime::add した場合、プラスされるのでマイナス値がinvert(反転)しているので想定通りではありますが。

$dt = new \DateTime('2023-11-29');
$di = new \DateInterval('P0D');
$di->d = -15;
$di->invert = 1;
print $di->d."日(invert:".$di->invert.")\n";
echo $dt->add( $di )->format('Y-m-d'); // 2023-12-14

ちなみにですがこのプロパティにマイナスの値をセットする場合は、直接代入するしかないようなので「プロパティ直接代入」が想定されていない動作なのかもしれません。public のくせに。

加えて以下のように初期値にマイナスの値をセットすることも許容されていません。例外になります。
※ちなみにDateIntervalの初期値はISO 8601の継続時間の形式で入力します。

$di = new \DateInterval('P-15D');

罠2:1年は365日じゃないし、1日は24時間じゃない

これはphpとかDateIntervalが悪いんじゃない、この世界の問題です。

1年は365日じゃない、大体4年に1回うるう年があって366日になる。
そしてIntervalを検証して色々調べて思い起こした、「1日は24時間じゃない」という真実、実はうるう秒があるんです。

地球の回転が1年ぐらいで実際の24時間から1秒ずれるから、どこかで1日=24時間±1秒するという奴です。

例えば最近では 2017年1月1日の午前8時は8時59分60秒が存在し、その1秒後が9時となっているため、この日は1日=24時間1秒とされています。

で、以下のように2017年1月1日に24時間を足すと、、

$dt = new \DateTime('2017-01-01');
$di = new \DateInterval('PT24H');
echo $dt->add( $di )->format('Y-m-d');

アレ、「2017-01-02」が返ってきます。ん~これは、、ちなみにPostgreSQLで以下のクエリを実行すると、、

SELECT '2017-01-01'::timestamp + 'PT24H'::interval

「2017-01-02 00:00:00」が返ってきます。
おんや?幻想だったようです閏秒を実装しているシステムは少ないのかも。

まぁとにかくです。このうるうが理由と思いますがDateInterval、PostgreSQLのIntervalも同様に、年・月・日・時分秒は独立した値として制御されており、日から月・年の繰り上げはもちろんですが、時間から日への繰り上げ、1日を24時間として扱うような処理は避けられているようです。

ちなみに CakePHP の ChronosInterval はこの禁忌を犯しているコードを書いている部分があり、信用できません。

罠3:DateInterval 同士を比較できない

PHPではオブジェクトのインスタンス同士を「==」で等価比較した場合、同じプロパティがあり同じ値であれば等価とします。

で、す、が!DateIntervalのインスタンス同士は比較できません!マニュアルにも書いてあります。

できません!はいコレ↓↓↓

$dt1 = new \DateTime('2023-11-29');
$dt2 = new \DateTime('2023-11-29');
var_dump($dt1 == $dt2); // bool(true)

$di1 = new \DateInterval('P15D');
$di2 = new \DateInterval('P15D');
var_dump($di1 == $di2); // bool(false) そして exception!

DateTimeはできますDateIntervalはできません! 例外ドーンッ!!

普通はしませんそんな比較、書かなきゃ良いしなんならプロパティ同士を比較するコードに直して書けば良いんです。

で、す、が! CakePHP で、データベースの Interval から DateInterval にマップしたコードが保存する所で動かないんです。何でか?CakePHP がやっていたんですこの比較を! orz…

※なんか CakePHP の悪口みたいになっていしまいましたが、私ならびに弊社はCakePHPを愛しています。 正しく理解した上で運用しているという事でご理解ください。

罠4:-99999年 = FALSE?

多いな罠、最後です。おまけです。

以下を実行してみてください。

$di = new \DateInterval('P0D');
$di->y = -99999;
var_dump( $di->y );

bool(false)」?って出力されます。謎です。
「-99998」、「-100000」も int なのですが -99999年 だけ false になります。

PHPでマンモスの事とかを扱う場合は注意です。ないか。。
まぁ以下のようにDateTime::addではちゃんと動作するので。余計謎ですが。。

$dt = new \DateTime('2023-11-29');
$di = new \DateInterval('P0D');
$di->y = -99999;
echo $dt->add( $di )->format('Y-m-d'); // -97976-11-29

Intervalは止めた

とにかく扱いにくいDateInterval、加えてCakePHPとも相性が悪く、すんなりDBのInterval型からDateIntervalクラスへの変換ができない。

じゃあどうしたか?昔からよくある方法ですが int を使うことにしました。
基本、勤務シフトでIntervalとして扱うのは時間の部分だけです。

ただし時間の表現として23時を超えて24時以降の値を表現したいので、Intervalが適任かと思ったのですが、使えないとなれば int で「下2桁が分」、「それ以上が時間」とします。

時と分を分割する場合は演算で以下のようにすれば良いわけです。

$t = 2634;
printf('%02d:%02d', floor($t/100), $t%100);

データベース上でも、ちょっと無理やりですが以下のように演算して INTERVAL にキャストして TIMESTAMP と足すことができます。

SELECT '2017-11-29 12:25:00'::TIMESTAMP + ('PT' || (2634 / 100)::INT || 'H' || (2634 % 100) || 'M')::INTERVAL

後書

ん~今回はDateIntervalの使用の範囲を超えた使い方をしようとしたのが問題だと思いますが、-99999年 が FALSE なのはもしかして新発見だったんじゃないですか?

あ、そうだ! DateInterval は f プロパティ:マイクロ秒の扱いも変なんだよね、、う~ん、もうやめよ。

この記事を書いた人
SGK
SGK
プログラムを担当しています。 古墳が好きです。猫が好きです。お祭りが好きです。普通の人です。 休日はイオンか大須に行きます。大須にも古墳があります。古墳はコンビニの数より多いです。