Candy, Vitamin or Painkiller

He's half man and half machine rides the metal monster

PostgreSQLの識別子のtruncateをFlywayのCallbackとParser Combinatorで防ぐ

PostgreSQLにおける識別子、たとえばテーブル名や外部キー制約名などは最長で63バイトに制限されています。*1

各種識別子の名前が63バイトを超えることは現実的にあまりないように思えますが、複合ユニーク制約や外部キー制約は命名ルールによってはしばしば超えてしまいそうです。*2

では63バイト以上の識別子を指定すると何が起こるでしょうか。

test=# CREATE TABLE two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onions_on_a_sesameseed_bun (
test(#   two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onions_on_a_sesameseed_bun_id INT NOT NULL,
test(#   price INT NOT NULL
test(# );
NOTICE:  identifier "two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onions_on_a_sesameseed_bun" will be truncated to "two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onion"
NOTICE:  identifier "two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onions_on_a_sesameseed_bun_id" will be truncated to "two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onion"

test=# CREATE UNIQUE INDEX unq_two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onions_on_a_sesameseed_bun ON two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onion USING btree (price);
NOTICE:  identifier "uniq_two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onions_on_a_sesameseed_bun" will be truncated to "uniq_two_all_beef_patties_special_sauce_lettuce_cheese_pickles_"

NOTICE として出力されていることから分かるように、これらの識別子はエラーにはなりません。識別子は63バイトにtruncateされて成功してしまいます。

これらを成功とさせず、識別子名のバイト数の超過によるtruncateを事前に防ぎたい、が今回のお題です。

FlywayのCallbackでEventをフックする

識別子名の超過は、各開発者が自身のローカル環境にDB migrationを行うタイミングで気づけるのが良さそうです。
私はmigrationに Flyway を利用しているため、このCallbackを利用して各migrationをフックし、識別子のバリデーションを行おうと思います。

手始めに、migrationをフックし、適用しようとしているSQLのパスを出力させてみます。

import java.nio.file.{Files, Path, Paths}
import org.flywaydb.core.api.callback.{Callback, Context, Event}

class IdentifierValidator extends Callback {

  override def handle(event: Event, context: Context): Unit =
    if (event == Event.BEFORE_EACH_MIGRATE) {
      val filePath = Paths.get(context.getMigrationInfo.getPhysicalLocation)
      println(s"about to migrate: $filePath")
    }

  override def supports(event: Event, context: Context): Boolean =
    event == Event.BEFORE_EACH_MIGRATE

  override def canHandleInTransaction(event: Event, context: Context): Boolean =
    event == Event.BEFORE_EACH_MIGRATE
}

flywayのCalbackを利用するには Callback インタフェースを実装します。
今回は各migrationを事前にフックしたいので、 BEFORE_EACH_MIGRATION イベントにのみ処理を適用するようにしています。各イベントの詳細は FlywayのJavaDoc を参照してください。

このCallbackの実装をflywayに認識させるために、flywayのオプションに実装したCallbackのFQDNを指定します。
環境変数として渡すなら下記のようなイメージです。

FLYWAY_OPTIONS="-Dflyway.callbacks=IdentifierValidator -Dflyway.url=...

migrationを実行すると、各migrationファイルのパスがコンソールに出力されることが分かると思います。

識別子をパースする

各migrationをフックすることができたので、続いて識別子名のvalidationを実装します。
今回、validateの対象にしたい構文は下記の3つです。

CREATE INDEX target ...;

CREATE UNIQUE INDEX target ...;

ALTER TABLE ONLY xxx
    ADD CONSTRAINT target ...;

識別子を厳密に抽出するならちゃんとしたSQLパーサーを利用するのが良さそうですが、今回は雑に1行ごとに Parser Combinator でパースを行うようにしてみました。*3
入門には Scalaスケーラブルプログラミング(コップ本) の33章がおすすめです。

このような形でパーサーを実装します。

import scala.util.parsing.combinator.RegexParsers

trait IdentifierParser extends RegexParsers {

  def parseIdentifier(line: String): Option[String] =
    parse(parser, line.toLowerCase).map(Some.apply).getOrElse(None)

  // create [unique] index 
  private def index: Parser[String] = ("create" ~ opt("unique") ~ "index") ^^ (_.toString)

  // add constraint
  private def constraint: Parser[String] = "add" ~ "constraint" ^^ (_.toString)

  // ↑の後に来る字句をパース
  private def parser: Parser[String] =
    (index | constraint) ~> """\w+""".r ^^ (_.toString)
}

parseIdentifier(line) に1行を渡すと、ターゲットとなる識別子がSomeで返されます。ポイントは下記あたりでしょうか。

  • ("create" ~ opt("unique") ~ "index") は EBNFでいう create [ unique ] index に相当する
  • a ~> b: aの結果を捨ててbを抽出。 (index | constraint) ~> """\w+""".r で indexあるいはconstraintに続く字句を抽出

識別子をvalidateする

材料が揃ったので、組み合わせてvalidationをしてみます。先に作ったIdentifierValidator#handleを下のように改修します。

class IdentifierValidator extends Callback with IdentifierParser {

  override def handle(event: Event, context: Context): Unit =
    if (event == Event.BEFORE_EACH_MIGRATE) {
      val filePath = Paths.get(context.getMigrationInfo.getPhysicalLocation)

      if (Files.isRegularFile(filePath)) {

        val sqlLines = Files.readAllLines(filePath).asScala
        val tooLongIdentifiers = 
          sqlLines
            .map(parseIdentifier)
            .collect { case Some(ientifier) if ientifier.length > 63 => ientifier }

        if (tooLongIdentifiers.nonEmpty) {
          val msg = s"too long identifiers:\n${tooLongIdentifiers.mkString("\n")}"
          throw new FlywayException(msg)
        }
      }
    }
}

63バイト以上の識別子が含まれると下記のようにFlywayExceptionが発生し、migrationを失敗させられるようになりました。

[error] (flywayMigrate) org.flywaydb.core.api.FlywayException: too long identifiers:
[error] two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onions_on_a_sesameseed_bun
[error] two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onions_on_a_sesameseed_bun_id
[error] uniq_two_all_beef_patties_special_sauce_lettuce_cheese_pickles_onions_on_a_sesameseed_bun_id

今回の環境は下記のとおりです。

*1:PostgreSQL 10.5文書 4.1.1. 識別子とキーワード

*2:たとえば外部キー制約の場合、 fk$参照先テーブル名$参照元テーブル名_$キー名 など

*3:この記事を書くきっかけになった状況ではもう少し複雑なパースを行っていたのですが、説明のために単純にしています。このくらい単純ならもっと手軽に正規表現でも良さそうです。