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
今回の環境は下記のとおりです。
- PostgreSQL 9.6
- Scala 2.12.8
- flyway-core 6.0.7
- scala-parser-combinators 1.1.2