Wikipedia日本語版のダンプデータを手元で復元する(できてない)

TL;DR

  • 少し用事があって多数の日本語テキストを含むデータベースが欲しかった
  • 公開データベースといえば Wikipedia だろうと思って、手順どおりにダンプデータの復元を試みた
  • MySQLインスタンスmediawiki:latest をつなぐだけのはずだった
  • インポートがいつまで経っても終わらなくて諦めた
    • 試しに24時間放置したら25万ページくらいまでは進んだ

Wikipediaのダンプファイルのダウンロード

MediaWikiのセットアップウィザードはMySQLSSLで接続できない

  • PlanetScaleAWS RDSインスタンスを作成すると、最初はSSL接続が必須になっている(たぶん)
  • しかし、MediaWikiのセットアップウィザードにはSSL接続を指定するところがなくて、どうしようもない
  • includes/libs/rdbms/database/DatabaseMysqli.php の L150 周辺を編集して $flags |= MYSQLI_CLIENT_SSL; とすれば一応進められる
  • セットアップした後は LocalSettings.php$wgDBssl = true; を指定できることになっている

ダンプファイルのインポート

とにかく遅い。履歴をスキップするとかのコツがありそう。

MySQLの実行環境 処理速度(ページ/秒)
Docker Desktop 10
RDS(db.t3.micro) 0.8

begin/endと名付けられたtypeglobを見た

こういう書き方ができるのを知らなかった。

DBIx/QueryLog.pm#L82-L85

*enable  = *begin = \&import; 
*disable = *end   = \&unimport; 
  • コードリファレンスをbegin enable end disableと名付けたtypeglobへ保存している
  • レキシカルスコープでパッケージを有効化、無効化するのにenable/disableを使っていて分かりやすい

ただ、beginとendのtypeglobが分からなくて、もやもやする。

BEGINやENDブロックは分かるけど、ブロックとコードリファレンスは等価なんだっけ??

The Perl Playgroundでいくつか標準モジュールを眺めたけど、そういうシンボルは存在しない。本体の実装読まないと分からないのかもしれない。

use Benchmark;
use Carp;
use Time::HiRes;
use Math::BigInt;

foreach my $m (qw{Benchmark Carp Time::HiRes Math::BigInt}) {
  foreach my $entry (qw{BEGIN END DESTROY begin end import} ) {
    print $entry . "\n";
    print join("\n", map { "  $_  -- " . *{$m . "::" . $entry}{$_} } 
      qw{
        CODE
        IO
        GLOB
        FORMAT
        NAME
        PACKAGE
      }
    ) . "\n";
  }
}
BEGIN
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7367af8)
  FORMAT  -- 
  NAME  -- BEGIN
  PACKAGE  -- Benchmark
END
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7345740)
  FORMAT  -- 
  NAME  -- END
  PACKAGE  -- Benchmark
DESTROY
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b73458d8)
  FORMAT  -- 
  NAME  -- DESTROY
  PACKAGE  -- Benchmark
begin
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b73671f8)
  FORMAT  -- 
  NAME  -- begin
  PACKAGE  -- Benchmark
end
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7367210)
  FORMAT  -- 
  NAME  -- end
  PACKAGE  -- Benchmark
import
  CODE  -- CODE(0x55a7b7437350)
  IO  -- 
  GLOB  -- GLOB(0x55a7b7452cc8)
  FORMAT  -- 
  NAME  -- import
  PACKAGE  -- Benchmark
BEGIN
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b73816d0)
  FORMAT  -- 
  NAME  -- BEGIN
  PACKAGE  -- Carp
END
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b73672b8)
  FORMAT  -- 
  NAME  -- END
  PACKAGE  -- Carp
DESTROY
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7367228)
  FORMAT  -- 
  NAME  -- DESTROY
  PACKAGE  -- Carp
begin
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b73672d0)
  FORMAT  -- 
  NAME  -- begin
  PACKAGE  -- Carp
end
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7367378)
  FORMAT  -- 
  NAME  -- end
  PACKAGE  -- Carp
import
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7367ac8)
  FORMAT  -- 
  NAME  -- import
  PACKAGE  -- Carp
BEGIN
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b74375f0)
  FORMAT  -- 
  NAME  -- BEGIN
  PACKAGE  -- Time::HiRes
END
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7367348)
  FORMAT  -- 
  NAME  -- END
  PACKAGE  -- Time::HiRes
DESTROY
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7367360)
  FORMAT  -- 
  NAME  -- DESTROY
  PACKAGE  -- Time::HiRes
begin
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7437200)
  FORMAT  -- 
  NAME  -- begin
  PACKAGE  -- Time::HiRes
end
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b75481a8)
  FORMAT  -- 
  NAME  -- end
  PACKAGE  -- Time::HiRes
import
  CODE  -- CODE(0x55a7b7452908)
  IO  -- 
  GLOB  -- GLOB(0x55a7b7452da0)
  FORMAT  -- 
  NAME  -- import
  PACKAGE  -- Time::HiRes
BEGIN
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b74370b0)
  FORMAT  -- 
  NAME  -- BEGIN
  PACKAGE  -- Math::BigInt
END
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7367708)
  FORMAT  -- 
  NAME  -- END
  PACKAGE  -- Math::BigInt
DESTROY
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b74f5588)
  FORMAT  -- 
  NAME  -- DESTROY
  PACKAGE  -- Math::BigInt
begin
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b74f5180)
  FORMAT  -- 
  NAME  -- begin
  PACKAGE  -- Math::BigInt
end
  CODE  -- 
  IO  -- 
  GLOB  -- GLOB(0x55a7b7408ab8)
  FORMAT  -- 
  NAME  -- end
  PACKAGE  -- Math::BigInt
import
  CODE  -- CODE(0x55a7b772ba98)
  IO  -- 
  GLOB  -- GLOB(0x55a7b77503e0)
  FORMAT  -- 
  NAME  -- import
  PACKAGE  -- Math::BigInt

MavenやGradleの依存ライブラリ解決にかかる時間を少し短縮できるかもしれない

目的

  • 未公開記事の発掘
  • ニッチな話題だった

2015年からGCSにMaven Centralのコピーを公開するようになったそうです。知らなかった。

どれくらい違いが出るのか検証してみる。

検証

対象のホストは以下。

  • Maven Central(repo.apache.maven.org)
    • Maven Central の公式ホスト
  • GCS-Asia(maven-central-asia.storage-download.googleapis.com)
    • GCS の Asia 地域向けホスト
  • GCS-EU(maven-central-eu.storage-download.googleapis.com)
    • GCS の EU 地域向けホスト
  • GCS-US(maven-central.storage-download.googleapis.com)
    • GCS の US 地域向けホスト

計測は curl で行う。それぞれのホストに対して5回。

$ for m in repo.apache.maven.org maven-central-asia.storage-download.googleapis.com maven-central-eu.storage-download.googleapis.com maven-central.storage-download.googleapis.com; do
for i in {0..5}; do
curl -s -k -w "${m},${i},%{time_namelookup},%{time_connect},%{time_appconnect},%{time_pretransfer},%{time_redirect},%{time_starttransfer},%{time_total}\n" -o /dev/null https://${m}/maven2/org/springframework/spring-core/5.3.8/spring-core-5.3.8.jar;
done
done
repo.apache.maven.org,0,0.037991,0.163917,0.422735,0.422971,0.000000,0.546282,0.546399
repo.apache.maven.org,1,0.004278,0.121880,0.364024,0.364144,0.000000,0.481385,0.481420
repo.apache.maven.org,2,0.001639,0.119328,0.361785,0.361857,0.000000,0.486429,0.486477
repo.apache.maven.org,3,0.002099,0.124872,0.380066,0.380149,0.000000,0.507271,0.507390
repo.apache.maven.org,4,0.001770,0.125946,0.380944,0.381223,0.000000,0.506575,0.506630
repo.apache.maven.org,5,0.001851,0.124397,0.376576,0.376872,0.000000,0.502826,0.502898
maven-central-asia.storage-download.googleapis.com,0,0.001884,0.009613,0.055196,0.055288,0.000000,0.068453,0.224391
maven-central-asia.storage-download.googleapis.com,1,0.002481,0.013766,0.068994,0.069050,0.000000,0.088661,0.270275
maven-central-asia.storage-download.googleapis.com,2,0.003084,0.012668,0.063887,0.064081,0.000000,0.072885,0.235593
maven-central-asia.storage-download.googleapis.com,3,0.001820,0.009232,0.054758,0.054809,0.000000,0.062724,0.227797
maven-central-asia.storage-download.googleapis.com,4,0.004236,0.021772,0.079667,0.079779,0.000000,0.092243,0.263077
maven-central-asia.storage-download.googleapis.com,5,0.002611,0.044696,0.101837,0.101898,0.000000,0.118384,0.305925
maven-central-eu.storage-download.googleapis.com,0,0.001900,0.011311,0.066934,0.067014,0.000000,0.082776,0.257083
maven-central-eu.storage-download.googleapis.com,1,0.001837,0.023069,0.078694,0.078742,0.000000,0.096553,0.287659
maven-central-eu.storage-download.googleapis.com,2,0.002092,0.018734,0.327978,0.328036,0.000000,0.345902,0.521407
maven-central-eu.storage-download.googleapis.com,3,0.002118,0.019215,0.070883,0.071256,0.000000,0.083224,0.271967
maven-central-eu.storage-download.googleapis.com,4,0.004461,0.018585,0.074322,0.074431,0.000000,0.088315,0.252058
maven-central-eu.storage-download.googleapis.com,5,0.001913,0.015818,0.065436,0.065493,0.000000,0.076168,0.245924
maven-central.storage-download.googleapis.com,0,0.002025,0.016342,0.068950,0.069062,0.000000,0.082023,0.293154
maven-central.storage-download.googleapis.com,1,0.001882,0.017747,0.074114,0.074177,0.000000,0.085885,0.264096
maven-central.storage-download.googleapis.com,2,0.002242,0.016268,0.071127,0.071346,0.000000,0.086131,0.259000
maven-central.storage-download.googleapis.com,3,0.002341,0.016438,0.069612,0.069663,0.000000,0.085434,0.279309
maven-central.storage-download.googleapis.com,4,0.014452,0.028634,0.077995,0.078046,0.000000,0.088248,0.257237
maven-central.storage-download.googleapis.com,5,0.002168,0.016135,0.066451,0.066529,0.000000,0.077767,0.278174

集計結果。値の単位は秒で、5回の単純平均。最小値に(*)を付けてみた。

Maven Central は fastly を使ってても速さが出てないようだ。キャッシュが効いてるのかどうかはわからない。 合計時間のオーダーはあまり変わらない(250msと500ms)から、データ転送は速そう。

ホスト GCS-US GCS-Asia GCS-EU Maven Central
time_namelookup 0.004185 0.002686(*) 0.002386 0.008271
time_connect 0.018594 0.018624 0.017788(*) 0.130056
time_appconnect 0.071374 0.070723(*) 0.114041 0.381021
time_pretransfer 0.071470 0.070817(*) 0.114162 0.381202
time_starttransfer 0.084248 0.083891(*) 0.128823 0.505128
time_redirect 0 0 0 0
time_total 0.271828 0.254509(*) 0.306016 0.505202

設定の仕方

Maven

${HOME}/.m2/settings.xmlあるいは%USERPROFILE%\.m2\settings.xmlへ次のように記述する。

つまり、 settings 要素の子要素を追加すればよい。

<settings>
    <!-- 何かしらの設定 -->
    <mirrors>
        <mirror>
            <id>google-maven-central</id>
            <name>GCS Maven Central mirror Asia Pacific</name>
            <url>https://maven-central-asia.storage-download.googleapis.com/maven2/</url>
            <mirrorOf>central</mirrorOf>
        </mirror>
    </mirrors>
    <!-- 何かしらの設定 -->
</settings>

Gradle

${HOME}/.gradle/init.gradleあるいは%USERPROFILE%\.gradle\init.gradleを作成して次のように記述する。

allprojects {
    buildscript {
        repositories {
            mavenLocal()
            maven { url "https://maven-central-asia.storage-download.googleapis.com/maven2/" }
        }
    }

    repositories {
        mavenLocal()
        maven { url "https://maven-central-asia.storage-download.googleapis.com/maven2/" }
    }
}

代表値を固定する代わりに指定した範囲から確率的に取り出したサンプルを確かめる

目的

  • 未公開記事の発掘
  • 製品コードの特性をテストが知っているのは役割分担を間違えてるような気がする
  • 値域や事後条件をアノテーションで書けるといいのにな

メリットや使いどころ

パラメタライズドテスト

  • テスト条件と期待値の組を複数用意して、同じテストメソッドを繰り返し呼び出す手法です
  • 状態や入力値によって期待値の変化するロジックを効率的にチェックできます
  • JUnit5 では拡張ライブラリの junit-jupiter-params を利用します
  • JUnit4 では組み込みクラスの Parameterized テストランナーを利用します

ドメインの固有型

  • たとえば、業務日付を表現するのに組み込みの Date クラスではなく BusinessDate クラスを作成する設計手法です
  • コンパイル時や実行時にプロパティ(特性)を保護することができます
    • クラスの事前条件や事後条件に記述する
  • プログラミング言語レベルの設計の自由度が低下する場合があります
    • 固有型そのままでは一般的なライブラリの API が利用できないとか
  • 記述が冗長化する場合があります
    • 組み込み型との相互変換処理を提供するとか
    • システムAの業務日付とシステムBの業務日付を別々の固有型にするとか
      • 共通する部分を基底クラスに導出して派生クラスにするみたいなシステム都合の対応をするとドメインから乖離してしまう

具体例

サンプルプロジェクト を作ってみました。

  • /todo に REST エンドポイントを公開するだけの Spring Boot な Java アプリです
    • Controller > Service > Repository の 3 階層になっています
  • TodoServiceTest はプリミティブなユニットテストです
    • jqwik API で書き直したプロパティベーステストが TodoServicePropertyTest です

TodoServiceTest

TodoServiceTest には次のようなテストを記述しています。

  • 指定した ID で取得するデータが存在する場合のテスト
  • 指定した ID で取得するデータが存在しない場合のテスト
  • 永続化の成功を確認するテスト

複数のパラメータを使用するので、パラメタライズドテストのための @ParameterizedTest を使っています。

しかし、@ValueSource にあまり多くの値を記述すると読みにくくなるため、代表的な値しか記述していません。

@CsvFileSource を使うと、パラメーターをクラスパスに配置した CSV ファイルから読み取るようにできます。

TodoServicePropertyTest

TodoServicePropertyTest に記述しているテストは基本的に TodoServiceTest と同じです。

パラメタライズドテストを次のような API で置き換えています。

  • プロパティベーステスト @Property
    • 引数に指定した @ForAll は入力値を生成します
    • 生成した入力値から所定の回数だけ、ランダムに取り出してテストを実行します
  • 具体例ベーステスト @Example
    • プロパティベーステストと違って 1 度しか実行しません
    • API の使い方など、代表的な例を記述するのに向いていると思います
  • データ駆動テスト @FromData
    • @Data を指定したメソッドの返り値そのものを入力としてテストを実行します
    • テストメソッドの引数は 8 個まで対応していました

ユニットテストの目的

ユニットテストの目的は、プログラムの構成部品が設計したとおりに動作することを確かめることです。

  • 設計したとおりに動作する とは何?
    • 仕様 は図表や自然言語で記述する
    • つまり解釈による曖昧性がある
    • 仕様 をプログラムで実現可能な表現に翻訳したのが 設計
    • 設計 には暗黙的な前提条件や状態、手段を記述する
    • ユニットテストの対象は 仕様 ではなく 設計 なので、仕様 に記述されていない要素も確かめないといけない
  • 設計したとおりに動作することを確かめる とは何?
    • 設計 に記述された要素を実現できているか確かめること
      • (あるなら)前提条件を満たしているか確かめる
      • (変更しているなら)意図した状態に変更できているか確かめる
      • (あるなら)入力に対する出力が正しいか確かめる
      • (あるなら)事後条件を満たしているか確かめる

設計 に記述してあることだけを確かめるわけではないので注意が必要です。

  • 設計 には 仕様 から導出された正常系だけしか記述されていない場合が多い
    • 記述が不足している場合もある
    • そもそも記述できない場合もある
      • 主に量の都合
  • 設計 に記述されてないから確認しなくていい?
    • No !!!
    • 設計 をインプットにテストを設計する
  • テストを設計する とは何?
    • プログラムの構成部品を状態遷移機械に見立てて、入力と次状態を整理すること
    • 入力を構成する要素:前提条件、事前状態、入力
    • 次状態を構成する要素:事後条件、事後状態、出力

ユニットテストに可能な全ての入力を与えるのは大変なので工夫します。

  • 同値分割
    • 値の範囲を分割して代表値を選ぶ
  • 境界値分析
    • 分割した範囲の境目を選ぶ
    • 内、境界、外

しかし、工夫した結果の正しさを厳密に保証する手法は存在しないため、設計とは異なる観点のレビューや確認(元の要素からの過不足など)が必要になります。

プロパティベーステストの目的

  • プロパティベーステストは QuickCheck という Haskell の実装から成立した手法
  • ランダムな入力値生成による失敗パスの発見を目指すところがファジングと似ている

この辺の説明がシンプルで分かりやすいと思います。

後から読み解くのが難しいパラメタライズドテストやデータ駆動テストができるまで

テスト対象をステートレスにするといいことがある、というより、ステートフルだと困ることがある。 問題はテスト対象がステートフルな場合。

シナリオ

  • 最初は例示ベースで状態をテストコードに埋め込んでいる
  • 隠れていた事前状態が見つかったりして、だんだん例示の部分が巨大化してくる
  • 例示の部分をフィクスチャーとして別の関数に分離したくなる
  • フィクスチャーに分離した関数は冗長な記述が多くなりがちで、JSONYAMLXMLなどデシリアライズの容易な形式でテストコードの外にファイルとして置きたくなる
    • この辺で、事前状態と事後状態が離ればなれになることが多い
  • 後から読み解くのが難しいテストはこのように生まれている

考察

フィクスチャーが肥大化するのは、例示だけで表現しきれない組み合わせが発生しているからだと思う。 なので、フィクスチャーを外部ファイルにしたくなるタイミングで踏みとどまるべきだ。 比較的見通しのよいデシジョンテーブルを整備して、デシジョンテーブルからフィクスチャーを生成するアプローチへ切り替えた方がいい。 なお、デシジョンテーブルにすると、それぞれの因子の値域を定義したくなるので、それはそれで深入りしそうな場面でもある。

Get Started With JHipster6

目的

  • 未公開記事の発掘
  • JHipsterの現行バージョンはv7.9.3です Release Notes
  • 少なくとも2年以上前に書いていたらしい

目的

JHipster is 何

  • Angular と Bootstrap と Spring Boot をいい感じに統合する Web アプリケーションフレームワークです
    • 2019-05-02 に公開した JHipster 6 では Java 11 と Spring Boot 2.1.x に対応しました
      • 最新バージョンは 6.10.1 (2020-07-05)
    • 今は React や Quarkus や Micronaut にも対応しつつある
  • プロジェクトジェネレーター、ソースコードジェネレーターとして使います
    • Angular な部分は yeoman で生成してるみたい
    • Spring Boot な部分はどうしてるんだろう
  • 公式ページ
  • GitHub リポジトリ

JHipster のインストール

  • Java 11 をインストールする
    • SDKMAN! - sdk intall java 11.0.8.hs-adpt
    • Scoop - scoop install adoptopenjdk-lts-hotspot
  • Git をインストールする
    • Scoop - scoop install git
  • Node.js(LTS) をインストールする
    • Scoop - scoop install nodejs-lts
  • JHipster をインストールする
    • NPM - npm install -g generator-jhipster

プロジェクトの作成

jhipster コマンドは今のディレクトリにソースコードを生成します。

プロジェクトのディレクトリを作成、移動してから jhipster コマンドを実行します。対話的にいろいろと入力します。

質問 回答
type of application Monolithic application
WebFlux ? No
base name jhipsterblog
package name org.bitbucket.yujiorama.jhipsterblog
type of authentication JWT authentication
type of database SQL
production database PostgreSQL
development database H2
Spring cache Ehcache
Hibernate 2nd level cache Yes
building the backend Maven
Client framework Angular
Bootswatch theme Default JHipster
internationalization support Yes
native language Japanese
additional language English
testing framework Protractor
other generators No
MINGW64 ~/.go/src/bitbucket.org/yujiorama/jhipster-blog  (master +)
$ jhipster

INFO! Congratulations, JHipster execution is complete!
MINGW64 ~/.go/src/bitbucket.org/yujiorama/jhipster-blog  (master +)
$

もろもろ完了したらすでに git add した状態になっているので最初のコミットを作成します。

git commit -m init

環境変数 JAVA_HOMEJava 11 を指定しているのを確認します。

$ echo $JAVA_HOME
C:/Users/user/scoop/apps/adoptopenjdk-lts-hotspot/current

Java 11 に対応するため pom.xml を編集します。

pom.xml

@@ -21,8 +21,8 @@

     <properties>
         <!-- Build properties -->
         <maven.version>3.3.9</maven.version>
-        <java.version>1.8</java.version>
+        <java.version>11</java.version>
         <node.version>v12.16.1</node.version>
         <npm.version>6.14.5</npm.version>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

@@ -418,8 +418,7 @@
                     <artifactId>maven-compiler-plugin</artifactId>
                     <version>${maven-compiler-plugin.version}</version>
                     <configuration>
-                        <source>${java.version}</source>
-                        <target>${java.version}</target>
+                        <release>${java.version}</release>
                         <annotationProcessorPaths>
                             <path>
                                 <groupId>org.springframework.boot</groupId>

依存ライブラリを最新化するため pom.xml を編集します。

./mvnw versions:update-properties

pom.xml

@@ -43,7 +43,7 @@
         <jhipster-dependencies.version>3.9.0</jhipster-dependencies.version>
         <!-- The spring-boot version should match the one managed by
         https://mvnrepository.com/artifact/io.github.jhipster/jhipster-dependencies/${jhipster-dependencies.version} -->
-        <spring-boot.version>2.2.7.RELEASE</spring-boot.version>
+        <spring-boot.version>2.3.3.RELEASE</spring-boot.version>
         <!-- The hibernate version should match the one managed by
         https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-dependencies/${spring-boot.version} -->
         <hibernate.version>5.4.15.Final</hibernate.version>
@@ -52,8 +52,8 @@
         <javassist.version>3.24.0-GA</javassist.version>
         <!-- The liquibase version should match the one managed by
         https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-dependencies/${spring-boot.version} -->
-        <liquibase.version>3.9.0</liquibase.version>
-        <liquibase-hibernate5.version>3.8</liquibase-hibernate5.version>
+        <liquibase.version>4.0.0</liquibase.version>
+        <liquibase-hibernate5.version>4.0.0</liquibase-hibernate5.version>
         <h2.version>1.4.200</h2.version>
         <validation-api.version>2.0.1.Final</validation-api.version>
         <jaxb-runtime.version>2.3.3</jaxb-runtime.version>
@@ -65,18 +65,18 @@
         <maven-javadoc-plugin.version>3.2.0</maven-javadoc-plugin.version>
         <maven-eclipse-plugin.version>2.10</maven-eclipse-plugin.version>
         <maven-enforcer-plugin.version>3.0.0-M3</maven-enforcer-plugin.version>
-        <maven-failsafe-plugin.version>3.0.0-M4</maven-failsafe-plugin.version>
+        <maven-failsafe-plugin.version>3.0.0-M5</maven-failsafe-plugin.version>
         <maven-idea-plugin.version>2.2.1</maven-idea-plugin.version>
-        <maven-resources-plugin.version>3.1.0</maven-resources-plugin.version>
-        <maven-surefire-plugin.version>3.0.0-M4</maven-surefire-plugin.version>
-        <maven-war-plugin.version>3.2.3</maven-war-plugin.version>
+        <maven-resources-plugin.version>3.2.0</maven-resources-plugin.version>
+        <maven-surefire-plugin.version>3.0.0-M5</maven-surefire-plugin.version>
+        <maven-war-plugin.version>3.3.1</maven-war-plugin.version>
         <maven-checkstyle.version>3.1.1</maven-checkstyle.version>
-        <checkstyle.version>8.32</checkstyle.version>
-        <spring-nohttp-checkstyle.version>0.0.4.RELEASE</spring-nohttp-checkstyle.version>
+        <checkstyle.version>8.35</checkstyle.version>
+        <spring-nohttp-checkstyle.version>0.0.5.RELEASE</spring-nohttp-checkstyle.version>
         <frontend-maven-plugin.version>1.10.0</frontend-maven-plugin.version>
-        <git-commit-id-plugin.version>4.0.0</git-commit-id-plugin.version>
+        <git-commit-id-plugin.version>4.0.2</git-commit-id-plugin.version>
         <jacoco-maven-plugin.version>0.8.5</jacoco-maven-plugin.version>
-        <jib-maven-plugin.version>2.4.0</jib-maven-plugin.version>
+        <jib-maven-plugin.version>2.5.2</jib-maven-plugin.version>
         <lifecycle-mapping.version>1.0.0</lifecycle-mapping.version>
         <properties-maven-plugin.version>1.0.0</properties-maven-plugin.version>
         <sonar-maven-plugin.version>3.7.0.1746</sonar-maven-plugin.version>

NoHttpCheck モジュールのプロパティ名を修正するため checkstyle.xml を編集します。

checkstyle.xml

@@ -9,7 +9,7 @@
     <property name="fileExtensions" value=""/>
     <!-- For detailed checkstyle configuration, see https://github.com/spring-io/nohttp/tree/master/nohttp-checkstyle -->
     <module name="io.spring.nohttp.checkstyle.check.NoHttpCheck">
-        <property name="whitelist" value="http://maven.apache.org/POM/4.0.0&#10;
+        <property name="allowlist" value="http://maven.apache.org/POM/4.0.0&#10;
             http://www.w3.org/2001/XMLSchema-instance&#10;http://maven.apache.org/maven-v4_0_0.xsd"/>
     </module>
     <!-- Allow suppression with comments

テストが成功するのを確認します。

./mvnw verify

アプリを起動します。

./mvnw

別のプロンプトで Protractor による e2e テストが成功するのを確認します。

npm run e2e

ブラウザで http://localhost:8080 にアクセスしていろいろ確認します。

  • ユーザー名 admin パスワード admin でログインできる
  • 管理メニューからいろいろ見れる
    • ユーザー管理ページ http://localhost:8080/admin/user-management
    • メトリクス監視ページ http://localhost:8080/admin/metrics
      • JVM メトリクス(ヒープ、GC、スレッド、CPU)
      • アプリケーションメトリクス(HTTP リクエスト統計、エンドポイント統計、キャッシュ統計、データベース統計)
    • ヘルスチェック http://localhost:8080/admin/health
      • データベース
      • ディスク
      • アプリケーション
    • 設定値一覧 http://localhost:8080/admin/configuration
    • 監査ログ http://localhost:8080/admin/audits
      • ユーザー認証の成否、日時など
    • ログレベル設定 http://localhost:8080/admin/logs
    • Swagger Editor http://localhost:8080/admin/docs
    • H2 Database Web コンソール http://localhost:8080/h2-console/

エンティティの生成

エンティティを追加するために必要なリソース

JHipster はこれらのリソースを自動生成します。 エンティティに関連があるときは外部キー制約なども生成します。

エンティティを自動生成する方法

  • サブジェネレーター
    • CLI ツール
    • 対話的に必要な情報を入力します
  • JDL-Studio
    • Web アプリ
    • JHipster ドメイン言語(JDL)で ER 図を見ながら必要な情報を記述します
    • ファイルに保存しておきます blog.jdl

エンティティの生成

import-jdl サブコマンドで JDL ファイルからエンティティを生成します。

jhipster import-jdl blog.jdl

ローカル PC でアプリを実行します。 * src/main/resources/config/application-dev.yamlliquibase.contextfaker が追加されているため、ダミーデータが生成されています * ダミーデータを生成しない場合は faker の記述を削除します * 一度生成してしまっている場合は H2 データベースファイルを初期化するため ./mvnw clean を実行します

./mvnw

ブログの作成と記事の作成

ブラウザでアクセスしてデータを登録します。

  • admin のブログを作成します
  • 作成したブログに記事を作成します
  • user のブログを作成します
  • 作成したブログに記事を作成します

ビジネスロジックの追加

  • 目的
    • admin としてログインした状態でブログ一覧ページ http://localhost:8080/blog にアクセスすると、user のブログも見えてしまう状態です。
    • ログインしているユーザーのブログだけが表示されるように、ビジネスロジックを改修します。

プロジェクトのインポートとビルドエラーの解消

プロジェクトを IntelliJ IDEA にインポートします。

次の部分がビルドエラーになるはずなので、アノテーションを追加して解消します。

IntelliJ IDEA の Spring ビルダーが解決できないだけで正常です

src/main/java/org/bitbucket/yujiorama/jhpister_blog/config/LiquibaseConfiguration.java

@@ -35,6 +35,7 @@ public class CacheConfiguration {
                 .build());
     }

+    @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
     @Bean
     public HibernatePropertiesCustomizer hibernatePropertiesCustomizer(javax.cache.CacheManager cacheManager) {
         return hibernateProperties -> hibernateProperties.put(ConfigSettings.CACHE_MANAGER, cacheManager);

src/main/java/org/bitbucket/yujiorama/jhpister_blog/config/DatabaseConfiguration.java

@@ -35,6 +35,7 @@ public class DatabaseConfiguration {
      * @return the H2 database TCP server.
      * @throws SQLException if the server failed to start.
      */
+    @SuppressWarnings("ContextJavaBeanUnresolvedMethodsInspection")
     @Bean(initMethod = "start", destroyMethod = "stop")
     @Profile(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT)
     public Object h2TCPServer() throws SQLException {

src/main/java/org/bitbucket/yujiorama/jhpister_blog/config/CacheConfiguration.java

@@ -29,6 +29,7 @@ public class LiquibaseConfiguration {
         this.env = env;
     }

+    @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
     @Bean
     public SpringLiquibase liquibase(@Qualifier("taskExecutor") Executor executor,
             @LiquibaseDataSource ObjectProvider<DataSource> liquibaseDataSource, LiquibaseProperties liquibaseProperties,q

ブログ一覧ページの改修

BlogResource を改修します。

src/main/java/org/bitbucket/yujiorama/jhpister_blog/web/rest/BlogResource.java

@@ -87,8 +87,8 @@ public class BlogResource {
      */
     @GetMapping("/blogs")
     public List<Blog> getAllBlogs() {
-        log.debug("REST request to get all Blogs");
-        return blogRepository.findAll();
+        log.debug("REST request to get all Blogs posted by current user");
+        return blogRepository.findByUserIsCurrentUser();
     }

     /**

アプリを ./mvnw で実行している場合はソースコードを保存すると Spring Dev Tools の機能で自動的に再起動します。

http://localhost:8080/blog にアクセスすると、ログインしたユーザーのブログだけが表示されるようになります。

記事一覧ページの改修

同じように EntryResource も改修します。

src/main/java/org/bitbucket/yujiorama/jhpister_blog/web/rest/EntryResource.java

@@ -96,12 +97,7 @@ public class EntryResource {
     @GetMapping("/entries")
     public ResponseEntity<List<Entry>> getAllEntries(Pageable pageable, @RequestParam(required = false, defaultValue = "false") boolean eagerload) {
         log.debug("REST request to get a page of Entries");
-        Page<Entry> page;
-        if (eagerload) {
-            page = entryRepository.findAllWithEagerRelationships(pageable);
-        } else {
-            page = entryRepository.findAll(pageable);
-        }
+        Page<Entry> page = entryRepository.findByBlogUserLoginOrderByDateDesc(SecurityUtils.getCurrentUserLogin().orElse(null), pageable);
         HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page);
         return ResponseEntity.ok().headers(headers).body(page.getContent());
     }

src/main/java/org/bitbucket/yujiorama/jhpister_blog/repository/EntryRepository.java

@@ -26,4 +26,6 @@ public interface EntryRepository extends JpaRepository<Entry, Long> {

     @Query("select entry from Entry entry left join fetch entry.tags where entry.id =:id")
     Optional<Entry> findOneWithEagerRelationships(@Param("id") Long id);
+
+    Page<Entry> findByBlogUserLoginOrderByDateDesc(String currentUserLogin, Pageable pageable);
 }

アプリを ./mvnw で実行している場合はソースコードを保存すると Spring Dev Tools の機能で自動的に再起動します。

http://localhost:8080/entry にアクセスすると、ログインしたユーザーの投稿だけが表示されるようになります。

UI 要素の改修

  • 目的
    • ブログなのにコンテンツをただのテキストとして描画してる
    • いい感じに描画する仕組みを追加します。

別のプロンプトで webpackdev サーバーを実行すると、Browsersync がブラウザを操作して http://localhost:9000 へアクセスします。

npm start

html をエスケープしないように改修

記事一覧ページ内容 の html をエスケープしないように改修します。Browsersync がブラウザを操作して自動的に再読み込みするはず。

src/main/webapp/app/entities/entry/entry.component.html

@@ -34,7 +34,7 @@
                 <tr *ngFor="let entry of entries ;trackBy: trackId">
                     <td><a [routerLink]="['/entry', entry.id, 'view']">{{ entry.id }}</a></td>
                     <td>{{ entry.title }}</td>
-                    <td>{{ entry.content }}</td>
+                    <td [innerHTML]="entry.content"></td>
                     <td>{{ entry.date | date:'medium' }}</td>
                     <td>
                         <div *ngIf="entry.blog">

記事一覧ページのレイアウトを変更

記事一覧ページがブログ風になるよう1列のスタックレイアウトに変更します。

src/main/webapp/app/entities/entry/entry.component.html

@@ -18,54 +18,31 @@
         <span jhiTranslate="jhipsterblogApp.entry.home.notFound">No entries found</span>
     </div>

-    <div class="table-responsive" id="entities" *ngIf="entries && entries.length > 0">
-        <table class="table table-striped" aria-describedby="page-heading">
-            <thead>
-                <tr jhiSort [(predicate)]="predicate" [(ascending)]="ascending" [callback]="reset.bind(this)">
-                    <th scope="col"  jhiSortBy="id"><span jhiTranslate="global.field.id">ID</span> <fa-icon icon="sort"></fa-icon></th>
-                    <th scope="col"  jhiSortBy="title"><span jhiTranslate="jhipsterblogApp.entry.title">Title</span> <fa-icon icon="sort"></fa-icon></th>
-                    <th scope="col"  jhiSortBy="content"><span jhiTranslate="jhipsterblogApp.entry.content">Content</span> <fa-icon icon="sort"></fa-icon></th>
-                    <th scope="col"  jhiSortBy="date"><span jhiTranslate="jhipsterblogApp.entry.date">Date</span> <fa-icon icon="sort"></fa-icon></th>
-                    <th scope="col"  jhiSortBy="blog.name"><span jhiTranslate="jhipsterblogApp.entry.blog">Blog</span> <fa-icon icon="sort"></fa-icon></th>
-                    <th scope="col"></th>
-                </tr>
-            </thead>
-            <tbody infinite-scroll (scrolled)="loadPage(page + 1)" [infiniteScrollDisabled]="page >= links['last']" [infiniteScrollDistance]="0">
-                <tr *ngFor="let entry of entries ;trackBy: trackId">
-                    <td><a [routerLink]="['/entry', entry.id, 'view']">{{ entry.id }}</a></td>
-                    <td>{{ entry.title }}</td>
-                    <td>{{ entry.content }}</td>
-                    <td>{{ entry.date | date:'medium' }}</td>
-                    <td>
-                        <div *ngIf="entry.blog">
-                            <a [routerLink]="['/blog', entry.blog?.id, 'view']" >{{ entry.blog?.name }}</a>
-                        </div>
-                    </td>
-                    <td class="text-right">
-                        <div class="btn-group">
-                            <button type="submit"
-                                    [routerLink]="['/entry', entry.id, 'view']"
-                                    class="btn btn-info btn-sm">
-                                <fa-icon icon="eye"></fa-icon>
-                                <span class="d-none d-md-inline" jhiTranslate="entity.action.view">View</span>
-                            </button>
-
-                            <button type="submit"
-                                    [routerLink]="['/entry', entry.id, 'edit']"
-                                    class="btn btn-primary btn-sm">
-                                <fa-icon icon="pencil-alt"></fa-icon>
-                                <span class="d-none d-md-inline" jhiTranslate="entity.action.edit">Edit</span>
-                            </button>
-
-                            <button type="submit" (click)="delete(entry)"
-                                    class="btn btn-danger btn-sm">
-                                <fa-icon icon="times"></fa-icon>
-                                <span class="d-none d-md-inline" jhiTranslate="entity.action.delete">Delete</span>
-                            </button>
-                        </div>
-                    </td>
-                </tr>
-            </tbody>
-        </table>
+    <div class="table-responsive" *ngIf="entries?.length > 0">
+        <div infinite-scroll (scrolled)="loadPage(page + 1)" [infiniteScrollDisabled]="page >= links['last']" [infiniteScrollDistance]="0">
+            <div *ngFor="let entry of entries; trackBy: trackId">
+                <a [routerLink]="['/entry', entry.id, 'view' ]">
+                    <h2>{{entry.title}}</h2>
+                </a>
+                <small>Posted on {{entry.date | date: 'short'}} by {{entry.blog.user.firstName}}</small>
+                <div [innerHTML]="entry.content"></div>
+                <div class="btn-group mb-2 mt-1">
+                    <button type="submit"
+                            [routerLink]="['/entry', entry.id, 'edit']"
+                            class="btn btn-primary btn-sm">
+                        <fa-icon [icon]="'pencil-alt'"></fa-icon>
+                        <span class="d-none d-md-inline" jhiTranslate="entity.action.edit">Edit</span>
+                    </button>
+                    <button type="submit"
+                            [routerLink]="['/', 'entry', { outlets: { popup: entry.id + '/delete'} }]"
+                            replaceUrl="true"
+                            queryParamsHandling="merge"
+                            class="btn btn-danger btn-sm">
+                        <fa-icon [icon]="'times'"></fa-icon>
+                        <span class="d-none d-md-inline" jhiTranslate="entity.action.delete">Delete</span>
+                    </button>
+                </div>
+            </div>
+        </div>
     </div>
 </div>

バックエンド API の安全性を高める

BlogResource へアクセスするときは、ログインしているユーザーのリソースを扱っているかどうかをチェックするようにします。結合テストも修正しましょう。

src/main/java/org/bitbucket/yujiorama/jhpister_blog/web/rest/BlogResource.java

@@ -47,11 +49,14 @@ public class BlogResource {
      * @throws URISyntaxException if the Location URI syntax is incorrect.
      */
     @PostMapping("/blogs")
-    public ResponseEntity<Blog> createBlog(@Valid @RequestBody Blog blog) throws URISyntaxException {
+    public ResponseEntity<?> createBlog(@Valid @RequestBody Blog blog) throws URISyntaxException {
         log.debug("REST request to save Blog : {}", blog);
         if (blog.getId() != null) {
             throw new BadRequestAlertException("A new blog cannot already have an ID", ENTITY_NAME, "idexists");
         }
+        if (!blog.getUser().getLogin().equals(SecurityUtils.getCurrentUserLogin().orElse(""))) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN).body("error.http.403");
+        }
         Blog result = blogRepository.save(blog);
         return ResponseEntity.created(new URI("/api/blogs/" + result.getId()))
             .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, result.getId().toString()))
@@ -68,11 +73,14 @@ public class BlogResource {
      * @throws URISyntaxException if the Location URI syntax is incorrect.
      */
     @PutMapping("/blogs")
-    public ResponseEntity<Blog> updateBlog(@Valid @RequestBody Blog blog) throws URISyntaxException {
+    public ResponseEntity<?> updateBlog(@Valid @RequestBody Blog blog) throws URISyntaxException {
         log.debug("REST request to update Blog : {}", blog);
         if (blog.getId() == null) {
             throw new BadRequestAlertException("Invalid id", ENTITY_NAME, "idnull");
         }
+        if (blog.getUser() != null && !blog.getUser().getLogin().equals(SecurityUtils.getCurrentUserLogin().orElse(""))) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN).body("error.http.403");
+        }
         Blog result = blogRepository.save(blog);
         return ResponseEntity.ok()
             .headers(HeaderUtil.createEntityUpdateAlert(applicationName, true, ENTITY_NAME, blog.getId().toString()))
@@ -97,9 +105,14 @@ public class BlogResource {
      * @return the {@link ResponseEntity} with status {@code 200 (OK)} and with body the blog, or with status {@code 404 (Not Found)}.
      */
     @GetMapping("/blogs/{id}")
-    public ResponseEntity<Blog> getBlog(@PathVariable Long id) {
+    public ResponseEntity<?> getBlog(@PathVariable Long id) {
         log.debug("REST request to get Blog : {}", id);
         Optional<Blog> blog = blogRepository.findById(id);
+        if (blog.isPresent() &&
+            blog.get().getUser() != null &&
+            !blog.get().getUser().getLogin().equals(SecurityUtils.getCurrentUserLogin().orElse(""))) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN).body("error.http.403");
+        }
         return ResponseUtil.wrapOrNotFound(blog);
     }

@@ -110,8 +123,14 @@ public class BlogResource {
      * @return the {@link ResponseEntity} with status {@code 204 (NO_CONTENT)}.
      */
     @DeleteMapping("/blogs/{id}")
-    public ResponseEntity<Void> deleteBlog(@PathVariable Long id) {
+    public ResponseEntity<?> deleteBlog(@PathVariable Long id) {
         log.debug("REST request to delete Blog : {}", id);
+        Optional<Blog> blog = blogRepository.findById(id);
+        if (blog.isPresent() &&
+            blog.get().getUser() != null &&
+            !blog.get().getUser().getLogin().equals(SecurityUtils.getCurrentUserLogin().orElse(""))) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN).body("error.http.403");
+        }
         blogRepository.deleteById(id);
         return ResponseEntity.noContent().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, id.toString())).build();
     }

src/test/java/org/bitbucket/yujiorama/jhpister_blog/web/rest/BlogResourceIT.java

@@ -37,6 +38,8 @@ public class BlogResourceIT {

     @Autowired
     private BlogRepository blogRepository;
+    @Autowired
+    private UserRepository userRepository;

     @Autowired
     private EntityManager em;
@@ -52,10 +55,11 @@ public class BlogResourceIT {
      * This is a static method, as tests for other entities might also need it,
      * if they test an entity which requires the current entity.
      */
-    public static Blog createEntity(EntityManager em) {
+    public Blog createEntity(EntityManager em) {
         Blog blog = new Blog()
             .name(DEFAULT_NAME)
-            .handle(DEFAULT_HANDLE);
+            .handle(DEFAULT_HANDLE)
+            .user(userRepository.findOneByLogin("user").get());
         return blog;
     }
     /**
@@ -78,6 +82,7 @@ public class BlogResourceIT {

     @Test
     @Transactional
+    @WithMockUser
     public void createBlog() throws Exception {
         int databaseSizeBeforeCreate = blogRepository.findAll().size();
         // Create the Blog
@@ -154,6 +159,7 @@ public class BlogResourceIT {

     @Test
     @Transactional
+    @WithMockUser
     public void getAllBlogs() throws Exception {
         // Initialize the database
         blogRepository.saveAndFlush(blog);
@@ -166,9 +172,10 @@ public class BlogResourceIT {
             .andExpect(jsonPath("$.[*].name").value(hasItem(DEFAULT_NAME)))
             .andExpect(jsonPath("$.[*].handle").value(hasItem(DEFAULT_HANDLE)));
     }
-
+
     @Test
     @Transactional
+    @WithMockUser
     public void getBlog() throws Exception {
         // Initialize the database
         blogRepository.saveAndFlush(blog);
@@ -191,6 +198,7 @@ public class BlogResourceIT {

     @Test
     @Transactional
+    @WithMockUser
     public void updateBlog() throws Exception {
         // Initialize the database
         blogRepository.saveAndFlush(blog);
@@ -236,6 +244,7 @@ public class BlogResourceIT {

     @Test
     @Transactional
+    @WithMockUser
     public void deleteBlog() throws Exception {
         // Initialize the database
         blogRepository.saveAndFlush(blog);

いろいろなデプロイ

本番用のビルドでテストが成功することを確認します。

./mvnw -Pprod clean verify

データベースマイグレーション管理ツール dbmate

目的

  • 未公開記事の発掘
  • upとdownを同じファイルに書けるのは無駄なくてよさそう

amacneil/dbmate でデータベースマイグレーションを始めるときのガイドです。

依存関係は少ないし、凝った動作もしないのでなんかよさそう。

仕組み

  • 管理対象データベースに schema_migrations というテーブルを作成し、マイグレーションスクリプトの適用状況を追跡します
  • データベースのローカルオブジェクト (テーブルやビューやシーケンスなど) を管理します
  • データベースのグローバルオブジェクト (ユーザーや権限など) は管理しません
  • データベーススキーマをダンプするときはデータベースソフトウェア固有のコマンドを利用します

準備

amacneil/dbmate のインストール

Go が入ってるなら go install するだけ。

そうでなければリリースアセットから対象OSの実行可能ファイルをダウンロードします。

go install github.com/amacneil/dbmate@latest

サンプルリポジトリのダウンロード

Sakila データベースMySQL (8.0.21) と PostgreSQL (13) のコンテナにロードする docker-compose.yml ファイルを用意しました。

dbmate は 8.x 以上の MySQL に対応しています (5.x には対応してません)

$ git clone https://bitbucket.org/yujiorama/db-migration-base-202009
$ cd db-migration-base-202009
$ docker-compose up -d
$ docker-compose ps
        Name                       Command              State                 Ports
-------------------------------------------------------------------------------------------------
mysql-demo_mysql_1      docker-entrypoint.sh mysqld     Up      0.0.0.0:3306->3306/tcp, 33060/tcp
mysql-demo_postgres_1   docker-entrypoint.sh postgres   Up      0.0.0.0:5432->5432/tcp

後始末するときはこちらを。

# ボリュームも一緒に削除します
docker-compose down -v

ガイド

1 新しい DDL/DML を用意する

new サブコマンドを実行すると、新しい DDL/DML を記述する SQL ファイルを作成します。

引数は SQL ファイル名の一部になります。

$ dbmate --url "mysql://user:password@host:port/db" new create-todo-table
Creating migration: db/migrations/yyyymmddHHMMSS_create-todo-table.sql

SQL ファイルはコメントで 2 つの部分に分かれています。

-- migrate:up


-- migrate:down
  • migrate:up には up サブコマンドで実行する SQL を記述します
  • migrate:down には rollback サブコマンドで実行する SQL を記述します
  • それぞれの部分には何行でも SQL を記述できます

ファイルを作成するディレクトリ名は --migrations-dir オプション、あるいは、環境変数 DBMATE_MIGRATIONS_DIR で指定できます。

環境別にディレクトリを分けるやり方はたぶん事故ります。

ブランチを別にするか、リポジトリを別にしたほうがいいでしょう。

$ dbmate --url "mysql://user:password@host:port/db" --migrations-dir mydb/migrations new create-todo-table
Creating migration: mydb/migrations/20200928093003_create-todo-table.sql

$ DBMATE_MIGRATIONS_DIR=otherdb/migrations dbmate --url "mysql://user:password@host:port/db" new create-todo-table
Creating migration: otherdb/migrations/20200928093003_create-todo-table.sql

2 用意した DDL/DML を適用する

up サブコマンドは SQL ファイルの migrate:up 部分を実行できます。

schema_migrations テーブルを検索して適用済みかどうかを判断するようです。

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila up
Applying: 20200928090841_create-todo-table.sql

status サブコマンドを実行すると適用済みかどうかを確認できます。

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila status
[X] 20200928090841_create-todo-table.sql

Applied: 1
Pending: 0

なお、複数 Pending がある状態で up すると順番に実行していきます。

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila up
Applying: 20200928093936_create-todo-table.sql
Applying: 20200928094214_create-todo-table.sql

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila status
[X] 20200928090841_create-todo-table.sql
[X] 20200928093936_create-todo-table.sql
[X] 20200928094214_create-todo-table.sql

Applied: 3
Pending: 0

3 適用した DDL/DMLロールバックする

rollback サブコマンドは SQL ファイルの migrate:down 部分を実行できます。

最後に適用した SQL ファイル、つまり、最後に Applied になった SQL ファイルを対象にするようです。

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila status
[X] 20200928090841_create-todo-table.sql
[X] 20200928093936_create-todo-table.sql
[ ] 20200928094214_create-todo-table.sql

Applied: 2
Pending: 1

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila rollback
Rolling back: 20200928093936_create-todo-table.sql

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila status
[X] 20200928090841_create-todo-table.sql
[ ] 20200928093936_create-todo-table.sql
[ ] 20200928094214_create-todo-table.sql

Applied: 1
Pending: 2

なお、 migrate:down の部分に構文エラーがあると実行時エラーになります。

たぶん実行時エラーの場合もですが、ロールバックしたことにはなりません。

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila status
[X] 20200928090841_create-todo-table.sql
[X] 20200928093936_create-todo-table.sql
[X] 20200928094214_create-todo-table.sql

Applied: 3
Pending: 0

$ echo '1;' >> db/migrations/20200928094214_create-todo-table.sql

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila rollback
Rolling back: 20200928094214_create-todo-table.sql
Error: Error 1064: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '1' at line 3

$ dbmate --url mysql://user:pass1word@minikube:3306/sakila status
[X] 20200928090841_create-todo-table.sql
[X] 20200928093936_create-todo-table.sql
[X] 20200928094214_create-todo-table.sql

Applied: 3
Pending: 0

4 schema_migrations テーブル

createuprollback を最初に実行するとき、接続先URLで指定したデータベースへ作成します。

$ mysql --user user -p -h minikube sakila
Enter password: *********
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 33
Server version: 8.0.21 MySQL Community Server - GPL

Copyright (c) 2000, 2020, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> desc schema_migrations;
+---------+--------------+------+-----+---------+-------+
| Field   | Type         | Null | Key | Default | Extra |
+---------+--------------+------+-----+---------+-------+
| version | varchar(255) | NO   | PRI | NULL    |       |
+---------+--------------+------+-----+---------+-------+
1 row in set (0.00 sec)

mysql> select * from schema_migrations;
+----------------+
| version        |
+----------------+
| 20200928090841 |
| 20200928093936 |
| 20200928094214 |
+----------------+
3 rows in set (0.00 sec)