DateFormatは前方一致だったのか

yyyy/MMという日付書式に、2010/06/15っていう文字列日付を入れてもパースできちゃうっていうのはどうゆう仕様なんだろうか。

JavaのDateFormat - 何言語でも話したいPGの開発日記


気になったので調べてみました。

メソッドは pos によって指定されたインデックスを開始位置としてテキストの解析を試みます。解析が完了すると、pos のインデックスは、使用された最後の文字 (解析では、文字列の最後までのすべての文字を使用する必要はありません) のあとのインデックスに更新され、解析された日付が返されます。更新された pos は、このメソッドの次の呼び出しの開始点を示すのに使用できます。エラーが発生した場合は、pos のインデックスは変更されず、エラーが発生した文字のインデックスに pos のエラーインデックスが設定され、null が返されます。

DateFormat#parseObject (Java Platform SE 6)


DateFormatの基底クラスにあたるFormatクラスは、区切り文字で区切られてる連続した文字列の集まりをパースできるように作られているらしく、そのため解析を完了したらその後に続く文字列は無視する仕様みたいです。

正規表現でのチェックや、日付に変換してから文字列に戻して変換前の文字列になるかっていうチェックでも問題ない気がしますが、コストを重視するなら文字列長のチェックだけ行うのが良いと思いました。また、parseメソッドにParsePositionっていうのを渡してあげると、チェックNGのときにParseExceptionが発生しないようです。これもコスト的なメリットになりそうです。

せっかくなので書いてみました

import java.text.DateFormat;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;

public class DateValidator {
  public static boolean isValid(String date, String format) {

    if(date.length() != format.length()) {
      return false;
    }
    
    DateFormat dateFormat = createStrictDateFormat(format);
    ParsePosition pos = new ParsePosition(0);
    return dateFormat.parse(date, pos) != null;
  }
  
  protected static DateFormat createStrictDateFormat(String format) {
    DateFormat dateFormat = new SimpleDateFormat(format);
    dateFormat.setLenient(false);
    return dateFormat;
  }
}

テスト

import static org.junit.Assert.*;

import org.junit.Test;


public class DateValidatorTest {

  @Test
  public void testIsValid() {
    assertFalse(DateValidator.isValid("2010/06/30", "yyyy/MM"));
    assertFalse(DateValidator.isValid("2010/6/31", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("2010/06/00", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("2010/06/30<script>...</script>", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("2010/06/31", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("2010/6/3", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("13:57", "hh:mm"));
    assertTrue(DateValidator.isValid("2010", "yyyy"));
    assertTrue(DateValidator.isValid("2010/06", "yyyy/MM"));
    assertTrue(DateValidator.isValid("2010/06/30", "yyyy/MM/dd"));
    assertTrue(DateValidator.isValid("2010年06月30日", "yyyy年MM月dd日"));
  }

}

あ、書いてから気がつきました。DateFormatはスレッドセーフでないから毎回newしなければいけないと思ってましたが、ThreadLocalクラスを使えば、同一スレッド内で使いまわすことができてもう少し節約できそうです。今度やってみよう。

追記

元記事の方からコメントを頂いて気がつきました。

SimpleDateFormatは各フィールドの桁数はチェックしてくれないのですね。

書式 yyyy/MM
日付文字列 2010/0000006/

とか

書式 yyyy/MM/dd
日付文字列 1/2/3

とかもエラーにならずにパースしてくれます。setLenient(false)しててもエラーになりません。なんてこったい。

さらに、前方一致での解析というのが手伝って

書式 yyyy/MM/dd
日付文字列 1/2/3あいうえお

という場合でもエラーにならないため「書式文字列の長さと日付文字列の長さを比較する」という方法が通用しなくなってしまいました。なんてこったい。

「SimpleDateFormatはバリデーションには使えない。やっぱり正規表現しかないのか」と悲しみに暮れていたら、ParsePositionが役に立ってくれました。ParsePostionはパースした文字数を保持しているので、それと書式文字列の長さを比較したらいい感じになりました。

修正版DateValidator

import java.text.DateFormat;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;

public class DateValidator {
  public static boolean isValid(String date, String format) {

    if(date.length() != format.length()) {
      return false;
    }
    
    DateFormat dateFormat = createStrictDateFormat(format);
    ParsePosition pos = new ParsePosition(0);
    return dateFormat.parse(date, pos) != null &&
      pos.getIndex() == format.length();  // <-- 解析した長さと書式の長さを比較
  }
  
  protected static DateFormat createStrictDateFormat(String format) {
    DateFormat dateFormat = new SimpleDateFormat(format);
    dateFormat.setLenient(false);
    return dateFormat;
  }
}

テスト

import static org.junit.Assert.*;

import org.junit.Test;


public class DateValidatorTest {

  @Test
  public void testIsValid() {
    assertFalse(DateValidator.isValid("2010/06/30", "yyyy/MM"));
    assertFalse(DateValidator.isValid("2010/6/", "yyyy/MM")); // <- 書式と日付の長さが一致するケース(7文字)
    assertFalse(DateValidator.isValid("1/2/3あいうえお", "yyyy/MM/dd")); // <- 書式と日付の長さが一致するケース(10文字)
    assertFalse(DateValidator.isValid("2010/6/31", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("2010/06/00", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("2010/06/30<script>...</script>", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("2010/06/31", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("2010/6/3", "yyyy/MM/dd"));
    assertFalse(DateValidator.isValid("13:57", "hh:mm"));
    assertTrue(DateValidator.isValid("2010", "yyyy"));
    assertTrue(DateValidator.isValid("2010/06", "yyyy/MM"));
    assertTrue(DateValidator.isValid("2010/06/30", "yyyy/MM/dd"));
    assertTrue(DateValidator.isValid("2010年06月30日", "yyyy年MM月dd日"));
  }
}

できたにはできたけど、なんだか無理やり感が出てきました。やっぱりparse()したものをformat()で元に戻して、元の文字列と一致するか調べるのがわかりやすくて良いのだろうなあ。