Java Tips | 日付・時間:日付重複判定

Java Java
スポンサーリンク

日付重複判定ユーティリティは何のために必要か

業務システムでは、期間がかぶってはいけない場面がたくさんあります。
割引キャンペーン期間が重複してはいけない。
同じ部屋の予約が同じ時間帯に二重に入ってはいけない。
社員の所属期間や契約期間が矛盾してはいけない。

こういうときに必要になるのが「日付重複判定」のユーティリティです。
期間同士が重なっているかどうかを、毎回 if 文で書くのではなく、
小さなメソッドにまとめておくことで、バグを減らしつつコードを読みやすくできます。


まずは「期間」をどう表現するかを決める

LocalDate で期間を表す基本形

日付の期間は、ふつう「開始日」と「終了日」のペアで表します。
まずは LocalDate を使ったシンプルな期間クラスを作ってみます。

import java.time.LocalDate;

public class DateRange {

    private final LocalDate startInclusive;
    private final LocalDate endInclusive;

    public DateRange(LocalDate startInclusive, LocalDate endInclusive) {
        if (endInclusive.isBefore(startInclusive)) {
            throw new IllegalArgumentException("終了日は開始日以降である必要があります");
        }
        this.startInclusive = startInclusive;
        this.endInclusive = endInclusive;
    }

    public LocalDate getStartInclusive() {
        return startInclusive;
    }

    public LocalDate getEndInclusive() {
        return endInclusive;
    }
}
Java

ここで重要なのは、コンストラクタで「終了日が開始日より前なら例外」にしていることです。
このチェックをサボると、「おかしな期間」がシステムの中に紛れ込み、
重複判定の前にロジックが破綻してしまいます。


二つの期間が重なっているかどうかの考え方

直感的なイメージから式に落とす

二つの期間 A と B が重なっているかどうかを、図でイメージしてみます。

A: [Astart ───── Aend]
B: [Bstart ───── Bend]

このように、どこか一部でも重なっていれば「重複あり」です。
逆に、次のどちらかなら「重複なし」です。

A が完全に B より前にある
A が完全に B より後ろにある

これを式にすると、こうなります。

重複していない条件
Aend < Bstart または Bend < Astart

つまり、重複している条件はその逆で、

Aend >= Bstart かつ Bend >= Astart

となります。

これを LocalDate と DateRange に落とし込むと、次のようなユーティリティになります。

public class DateRangeOverlap {

    public static boolean overlaps(DateRange a, DateRange b) {
        LocalDate aStart = a.getStartInclusive();
        LocalDate aEnd   = a.getEndInclusive();
        LocalDate bStart = b.getStartInclusive();
        LocalDate bEnd   = b.getEndInclusive();

        boolean aEndsBeforeBStarts = aEnd.isBefore(bStart);
        boolean bEndsBeforeAStarts = bEnd.isBefore(aStart);

        return !(aEndsBeforeBStarts || bEndsBeforeAStarts);
    }
}
Java

ここで深掘りしたいのは、「重複していない条件を先に考えて、その否定を取る」という発想です。
重複しているパターンを全部列挙しようとすると混乱しますが、
「完全に前」「完全に後ろ」の二つだけを考えればよいので、頭がスッキリします。


境界を含むかどうかの扱い(同じ日を共有する場合)

終了日と開始日が同じときは重複とみなすか

例えば、次の二つの期間を考えます。

A: 2025-03-01 〜 2025-03-10
B: 2025-03-10 〜 2025-03-20

このとき、「3月10日を共有しているので重複」とみなすか、
「A は 10 日まで、B は 10 日からで“隙間なしだが重複なし”」とみなすかは、業務ルール次第です。

先ほどの DateRange は「開始も終了も含む(閉区間)」として設計しているので、
上の A と B は「重複あり」と判定されます。

もし「終了日と開始日が同じだけなら重複なし」としたい場合は、
半開区間(開始含む・終了含まない)で期間を表現する方が自然です。

public class DateRangeHalfOpen {

    private final LocalDate startInclusive;
    private final LocalDate endExclusive;

    public DateRangeHalfOpen(LocalDate startInclusive, LocalDate endExclusive) {
        if (!endExclusive.isAfter(startInclusive)) {
            throw new IllegalArgumentException("終了日は開始日より後である必要があります");
        }
        this.startInclusive = startInclusive;
        this.endExclusive = endExclusive;
    }

    public LocalDate getStartInclusive() {
        return startInclusive;
    }

    public LocalDate getEndExclusive() {
        return endExclusive;
    }
}
Java

この場合の重複判定は、次のように書けます。

public class DateRangeHalfOpenOverlap {

    public static boolean overlaps(DateRangeHalfOpen a, DateRangeHalfOpen b) {
        LocalDate aStart = a.getStartInclusive();
        LocalDate aEnd   = a.getEndExclusive();
        LocalDate bStart = b.getStartInclusive();
        LocalDate bEnd   = b.getEndExclusive();

        boolean aEndsOnOrBeforeBStarts = !aEnd.isAfter(bStart); // aEnd <= bStart
        boolean bEndsOnOrBeforeAStarts = !bEnd.isAfter(aStart); // bEnd <= aStart

        return !(aEndsOnOrBeforeBStarts || bEndsOnOrBeforeAStarts);
    }
}
Java

ここでのポイントは、「期間の意味(閉区間か半開区間か)をクラスで固定する」ことです。
業務としてどちらの解釈にするかをチームで決め、そのルールをユーティリティに閉じ込めておくと、
境界のバグを大きく減らせます。


実例で重複判定を確認してみる

いくつかのパターンをコードで試す

先ほどの閉区間版 DateRange と overlaps を使って、いくつかのパターンを試してみます。

public class OverlapExample {

    public static void main(String[] args) {
        DateRange a = new DateRange(
                LocalDate.of(2025, 3, 1),
                LocalDate.of(2025, 3, 10)
        );

        DateRange b = new DateRange(
                LocalDate.of(2025, 3, 5),
                LocalDate.of(2025, 3, 15)
        );

        DateRange c = new DateRange(
                LocalDate.of(2025, 3, 11),
                LocalDate.of(2025, 3, 20)
        );

        DateRange d = new DateRange(
                LocalDate.of(2025, 3, 10),
                LocalDate.of(2025, 3, 10)
        );

        System.out.println(DateRangeOverlap.overlaps(a, b)); // true(がっつり重なっている)
        System.out.println(DateRangeOverlap.overlaps(a, c)); // false(完全に後ろ)
        System.out.println(DateRangeOverlap.overlaps(a, d)); // true(境界の1日を共有)
    }
}
Java

こうやって具体的な日付で試してみると、
自分が想定している「重複」の定義と、実装が一致しているかを確認しやすくなります。


複数の期間同士の重複チェック

新しい期間が既存のどれかと重なっていないか

実務では、「新しく登録しようとしている期間が、既存の期間と重なっていないか」をチェックしたいことが多いです。
例えば、会議室予約やキャンペーン設定などです。

import java.util.List;

public class DateRangeValidator {

    public static boolean overlapsAny(DateRange target, List<DateRange> existing) {
        for (DateRange r : existing) {
            if (DateRangeOverlap.overlaps(target, r)) {
                return true;
            }
        }
        return false;
    }
}
Java

使い方のイメージはこうです。

List<DateRange> existing = List.of(
        new DateRange(LocalDate.of(2025, 3, 1), LocalDate.of(2025, 3, 10)),
        new DateRange(LocalDate.of(2025, 3, 20), LocalDate.of(2025, 3, 25))
);

DateRange newRange = new DateRange(LocalDate.of(2025, 3, 8), LocalDate.of(2025, 3, 12));

if (DateRangeValidator.overlapsAny(newRange, existing)) {
    System.out.println("既存の期間と重複しています");
} else {
    System.out.println("重複なしで登録できます");
}
Java

ここで大事なのは、「重複判定のロジックを一か所に集約している」ことです。
画面やサービス層のあちこちで期間比較の if 文を書き散らすのではなく、
必ず overlaps / overlapsAny を通すようにしておくと、
仕様変更やバグ修正がとても楽になります。


LocalDateTime / Instant の重複判定とタイムゾーン

日時の重複判定も考え方は同じ

LocalDateTime や Instant でも、基本の考え方は同じです。
開始と終了を持つ Range クラスを作り、
「完全に前」「完全に後ろ」の否定で重複を定義します。

import java.time.LocalDateTime;

public class DateTimeRange {

    private final LocalDateTime startInclusive;
    private final LocalDateTime endExclusive; // 半開区間にしておくと扱いやすい

    public DateTimeRange(LocalDateTime startInclusive, LocalDateTime endExclusive) {
        if (!endExclusive.isAfter(startInclusive)) {
            throw new IllegalArgumentException("終了日時は開始日時より後である必要があります");
        }
        this.startInclusive = startInclusive;
        this.endExclusive = endExclusive;
    }

    public boolean overlaps(DateTimeRange other) {
        boolean thisEndsOnOrBeforeOtherStarts = !this.endExclusive.isAfter(other.startInclusive);
        boolean otherEndsOnOrBeforeThisStarts = !other.endExclusive.isAfter(this.startInclusive);
        return !(thisEndsOnOrBeforeOtherStarts || otherEndsOnOrBeforeThisStarts);
    }
}
Java

ただし、ここで必ず意識してほしいのが「タイムゾーン」です。
LocalDateTime はタイムゾーンを持たないので、
「同じタイムゾーンで解釈される前提」で使う必要があります。

タイムゾーンをまたいで“瞬間”として重複判定したい場合は、
ZonedDateTime や Instant を使い、
すべて Instant に変換してから比較する方が安全です。


セキュリティ・運用の観点から見た日付重複判定

重複バグはそのまま業務トラブルになる

日付重複判定を間違えると、直接ビジネスに影響します。

会議室やリソースの二重予約。
キャンペーンが意図せず重なり、割引が二重に適用される。
契約期間が重なってしまい、どの契約を適用すべきか分からなくなる。

これらは、クレームや損失、契約トラブルにつながります。
だからこそ、「期間の表現」「重複の定義」「境界の扱い」を、
ユーティリティとしてきちんと設計しておくことが重要です。

期間の逆転や異常値を必ず弾く

開始 > 終了 の期間をそのまま受け入れてしまうと、
重複判定以前にデータが破綻します。

コンストラクタやファクトリメソッドで
「終了が開始より前なら例外」
というチェックを必ず入れておくことで、
設定ミスやバグを早期に検出できます。

これは、セキュリティというより「堅牢性」の話ですが、
結果として不正なデータや攻撃的な入力に対する防御線にもなります。


まとめ:日付重複判定ユーティリティで身につけてほしい感覚

日付重複判定は、「二つの期間がどこか一部でも重なっているか?」を調べるだけの処理です。
しかし、その裏には次のような大事なポイントが隠れています。

期間は「開始」と「終了」のペアとしてクラスで表現する。
終了が開始より前の期間はコンストラクタで弾く。
重複していない条件(完全に前・完全に後ろ)を先に考え、その否定で重複を定義する。
閉区間か半開区間かをプロジェクトとして決め、そのルールをクラスに閉じ込める。
LocalDate / LocalDateTime / Instant を、何を比較したいかに応じて使い分ける。

もしあなたのコードの中に、
開始と終了を直接 compareTo でゴチャゴチャ比較している if 文があれば、
それを一度「期間クラス+重複判定ユーティリティ」に置き換えられないか眺めてみてください。

それだけで、コードの意図がはっきりし、
二重予約や期間の矛盾といった“業務トラブルの種”を、かなりの確率で潰せるようになります。

タイトルとURLをコピーしました