営業日カレンダーモデルの実装

これは、FOLIO Advent Calendar 2022 の11日目の記事です。

営業日カレンダーモデル

zenn.dev

この記事で提案されている営業日カレンダーモデルの parser を実装してみました。

*趣味で作っただけなので業務利用は行ってません

営業日カレンダーモデルとは

営業日が2つ以上存在するドメインで利用できるデータモデルです。

あるA市場の営業日からn日後の日付にもっとも近いB市場の営業日からn日後の日付、のような複雑な日付を表現することができます。詳しくは元記事を参照して下さい。

環境

sacla と scala-parser-combinators を利用します。

scalaVersion := "2.13.8"
libraryDependencies += "org.scala-lang.modules" %% "scala-parser-combinators" % "2.1.1"

元の規則の営業日カレンダーモデルの実装

オリジナルのEBNF

binop    ::= '+'
           | '-'
castop   ::= '_'
           | '^'
cal      ::= 'jp'
           | 'us'
           | 'c'
           | 'jp & us'
           | 'jp | us'
expression
         ::= ( ( "T" | expression ) cast_op ) cal
           | ( "(" expression ")" )
           | expression binop num

まずはこの EBNF の規則に従って lexer を実装してみます。以下が実装例です。

object BusinessCalendarParser extends JavaTokenParsers with RegexParsers {
  private def binop = "+" | "-"
  private def castop = "_" | "^"
  private def cal: Parser[String] = "jp&us" | "jp|us" | "jp" | "us" | "c"
  private def integer = wholeNumber
  private def T: Parser[String] = """[0-9]{4}/[0-9]{2}/[0-9]{2}""".r ^^ { _.toString } //
  private def expr: Parser[Any] = ((T | expr) ~ castop ~cal) | ("(" ~ expr ~ ")") | (expr ~ binop ~ integer)

  def apply(input: String): Either[String, Any] = parseAll(expr, input) match {
    case Success(c, n) => Right(c)
    case Failure(e, _) => Left("FAILURE: " + e.toString)
    case Error(e, _) => Left("ERROR: " + e.toString _)
  }
}

この実装では入力によっては簡単にスタックオーバーフローになってしまいます。

scala-parser-combinators は LL(1)の構文しか解析できず、元の定義が LL(1)文法ではないからです。

例えば (2022/12/11_jp&us+1) を入力するとスタックーオーバーフローになります。

新しい規則の営業日カレンダーモデルの実装

元の定義は LL(1)ではないので、実装するには元の定義を変更する必要があります。

以下が修正した新しい規則です。

新規則のEBNF

 binop    ::= '+'
            | '-'
 castop   ::= '_'
            | '^'
 cal      ::= 'jp'
            | 'us'
            | 'c'
            | 'jp&us'
            | 'jp|us'
 expr     ::= term cast_op cal [ binop num ]
 term     ::= "(" expr ")" | "T"

また元記事だと表記ゆれがあったので jp & us -> jp&us, jp | us -> jp|us としています。記事の最後の例題には半角スペースが無かったのでそちらに合わせました。

この規則だとバックトラックが必要になる例を回避できます。

新規則のBNFに基づいた実装

以下が新規則の BNF に基づいた lexer、parser の実装です。(parser の実装も一緒に行っています)

以下が修正した EBNF の定義通りの lexer を実装です。

object BusinessCalendarParser extends JavaTokenParsers with RegexParsers {
  private def binop = "+" | "-"
  private def castop = "_" | "^"
  private def cal: Parser[String] = "jp&us" | "jp|us" | "jp" | "us" | "c"
  private def integer = wholeNumber
  private def T: Parser[String] = """[0-9]{4}/[0-9]{2}/[0-9]{2}""".r ^^ { _.toString }
  private def expr: Parser[Any] = term ~! castop ~! cal ~! (binop ~! integer).?
  private def term: Parser[Any] = ("(" ~! expr ~! ")") | T

  def apply(input: String): Either[String, Any] = parseAll(expr, input) match {
    case Success(c, n) => Right(c)
    case Failure(e, _) => Left("FAILURE: " + e.toString)
    case Error(e, _) => Left("ERROR: " + e.toString _)
  }
}

case classへの変換

規則に従ってパースができることがわかったのでパース結果を扱いやすいように case class に変換します。

まずは Token を定義します。

object Token {
  sealed trait BinOp
  case object Plus extends BinOp
  case object Minus extends BinOp

  sealed trait CastOp
  case object Hat extends CastOp
  case object UnderBar extends CastOp

  sealed trait Cal
  case object JP extends Cal
  case object EN extends Cal
  case object Center extends Cal //cはCenterだと思っていたらCalendarのcだった。。。
  case object JPAndEN extends Cal
  case object JPOrEn extends Cal

  case class Num(int: Int)

  sealed trait Expr
  case class BusinessDayCalendar(
      expr: Expr,
      castOp: CastOp,
      cal: Cal,
      maybeBinOp: Option[BinOp],
      maybeInt: Option[Num]
  ) extends Expr
  
    case class Calendar(localDate: LocalDate) extends Expr
  object Calendar {
    def fromString(date: String): Calendar = Calendar(
      LocalDate.parse(
        date,
        DateTimeFormatter
          .ofPattern("uuuu/MM/dd")
          .withResolverStyle(ResolverStyle.STRICT)
      )
    )

以下が parser の実装です。

import businesscalendarparser.Token._
object BusinessCalendarParser extends JavaTokenParsers with RegexParsers {
  private def binop: Parser[BinOp] = ("+" | "-") ^^ {
    case "+" => Plus
    case "-" => Minus
  }

  private def castop: Parser[CastOp] = ("_" | "^") ^^ {
    case "_" => UnderBar
    case "^" => Hat
  }

  private def cal: Parser[Cal] = ("jp&us" | "jp|us" | "jp" | "us" | "c") ^^ {
    case "jp&us" => JPAndEN
    case "jp|us" => JPOrEn
    case "jp"    => JP
    case "us"    => EN
    case "c"     => Center
  }

  private def integer = wholeNumber ^^ { s => Num(s.toInt) }

  private def T: Parser[Calendar] =
    """[0-9]{4}/(0[1-9]|1[0-2])/(0[1-9]|[12][0-9]|3[01])""".r.filter(
      Calendar.canCreateCalendarInstance
    ) ^^ Calendar.fromString

  private def expr: Parser[BusinessDayCalendar] =
    term ~! castop ~! cal ~! (binop ~! integer).? ^^ {
      case (t: Expr) ~ (cas: CastOp) ~ (cl: Cal) ~ Some(
            (b: BinOp) ~ (i: Num)
          ) =>
        BusinessDayCalendar(t, cas, cl, Some(b), Some(i))
      case (t: Expr) ~ (cas: CastOp) ~ (cl: Cal) ~ None =>
        BusinessDayCalendar(t, cas, cl, None, None)
    }

  private def term: Parser[Expr] = ("(" ~>! expr <~! ")") | T

  def apply(input: String): Either[String, BusinessDayCalendar] =
    parseAll(expr, input) match {
      case Success(c, n) => Right(c)
      case Failure(e, _) => Left("FAILURE: " + e.toString)
      case Error(e, _)   => Left("ERROR: " + e.toString)
   

この実装で元記事で紹介されていた"((2020/12/26_c+1)jp+1)_us-1"のパースできることがわかります。

以下が該当のテストです。

  it should "((2020/12/26_c+1)^jp+1)_us-1" in {
      val bcp = BusinessCalendarParser("((2020/12/26_c+1)^jp+1)_us-1")
      bcp shouldBe a [Right[_, _]]
      bcp.right.get shouldBe BusinessDayCalendar(BusinessDayCalendar(BusinessDayCalendar(Calendar(LocalDate.of(2020, 12, 26)), UnderBar, Center, Some(Plus), Some(Num(1))), Hat, JP, Some(Plus), Some(Num(1))), UnderBar, EN, Some(Minus), Some(Num(1)))
  }  

((2020/12/26_c+1)^jp+1)_us-1 が((2020/12/26 の翌日)から翌日以降の JPX 営業日の翌日の営業日)から翌日以前の EN 営業日の前日の営業日) と表現できているのがわかります。

オリジナルのBNFでできて新規則のBNFでできないことはあるか?

  • あります。互換性が無い規則になっています。

具体的に言うと "(" "T" cast_op cal ")"は元の規則では評価可能ですが、変更した新しい規則では評価できません。

  // "(" "T" cast_op cal ")" は評価できない
  it should "left (2020/12/26^jp)" in {
      val bcp = BusinessCalendarParser("(2020/12/26^jp)")
      bcp shouldBe a [Left[_, _]]
      bcp.left.get shouldBe "ERROR: '^' expected but end of source found" 
  }

ただ "T" cast_op cal をわざわざ "(" "T" cast_op cal ")" と書く必要はないので実用上問題はないです。

評価機の実装

lexer と parser は作りましたが、実際に評価して日付を出力しないとなんとも面白くないです。

かなり適当な実装ですがインタプリタも作ってみました。

ダサいのでコードを載せるのは勘弁..。

デモ

では実際にT=2020/12/26としたときに、上のEBNFに従って書いた ((T_c+1)jp+1)_us-1 が何日になるかみてみましょう。

元記事の上記の演習を解いてみます。

sbt:BusinessDayModelBot> run "((2020/12/26_c+1)^jp+1)_us-1"
[info] running BusinessDayModelBot.Main ((2020/12/26_c+1)^jp+1)_us-1
input: ((2020/12/26_c+1)^jp+1)_us-1
token: Right(BusinessDayCalendar(BusinessDayCalendar(BusinessDayCalendar(Calendar(2020-12-26),UnderBar,Center,Some(Plus),Some(Num(1))),Hat,JP,Some(Plus),Some(Num(1))),UnderBar,EN,Some(Minus),Some(Num(1))))
result: Some(2020-12-24)

12/24 になっているので正しく解答できています。

まとめ

  • ”複数の営業日カレンダーを扱う業務のための日付モデル”のlexer 、 parser 、interpreter を実装しました
  • 既存の定義では parser-combinators で扱いにくいので修正した新たな規則を作りました
  • 演習課題が解けることを確認しました

reference