Spring Data JPAのRepositoryでクエリを自動生成するためのメソッド命名規約

Spring Data JPAで、 JpaRepository を継承したリポジトリインターフェースに対し、特定の命名規則でメソッドを宣言しておくと、 @Query アノテーションなど書かなくても処理を自動生成してくれる。

で、その命名規則、単純な検索処理であれば findAllByXxxIdAndYyyId みたいな感じでかけるのだが、引数でコレクションを渡して検索する場合とかを毎回忘れる。

忘れた回数を数えていたら、今年に入って10回目の忘却を達成してしまったのでメモ。

どこを見ればいいか

こちら: Spring Data JPA - Reference Documentation

Pleiades翻訳版はこちら: Spring Data JPA - リファレンスドキュメント

The JPA module supports defining a query manually as a String or having it being derived from the method name.

とあるので、日本語にすると、「メソッド名派生クエリ」とか、「メソッド名生成クエリ」とかになるのかな?

Pleiades翻訳版のほうだと、「メソッド名から派生したクエリ」になっている。

よく忘れるものをメモ

コレクション渡しでの複数条件検索

${フィールド名}In 、否定形なら ${フィールド名}NotIn を使う。毎回 ${フィールド名}s とか書いてエラーにしてるんだよなぁ。

引数として渡すコレクションが空の場合、 IN () に展開されるため、標準SQLに準拠したRDBMSならSQLエラーになる。

数年前、Spring Boot v1.5+Oracle Databaseを使っていた時、OracleのIN1つあたり1,000件上限問題は、自動では回避してくれなかった気がするが、覚えていない。

1,000件ごとに分割して、複数回実行したことがあるような気がする...

LIKE検索

複数パターンあり。

検索条件として %_ワイルドカードが設定された文字列を渡す場合は ${フィールド名}Like 、否定形なら ${フィールド名}NotLike を使う。

ワイルドカードが設定されていない文字列を渡す場合、前方一致、後方一致、部分一致で3パターンの命名方法が用意されている。

  • 前方一致検索: ${フィールド名}StartingWith で引数が 文字列% となる
  • 後方一致検索: ${フィールド名}EndingWith で引数が %文字列 となる
  • 部分一致検索: ${フィールド名}Containing で引数が %文字列% となる

ざっくり、以下のようなイメージ。

String name = "example";
assert repo.findByNameLike(name + "%").equals(repo.findByNameStartingWith(name));
assert repo.findByNameLike("%" + name).equals(repo.findByNameEndingWith(name));
assert repo.findByNameLike("%" + name + "%").equals(repo.findByNameContaining(name));

GROUP BY

直接 GROUP BY に該当するものはない。

findDistinct... で、SELECT DISTINCT による検索ができるので、単純な重複削除はできそう。

DELETE文

検索だけでなく、deleteBy${フィールド名} で削除メソッドも生成できる。Derived Delete QueriesPleiades翻訳版だと派生削除クエリという模様。

delete 以外に remove も利用可能。 AndOr による複数条件も可能。

また、 @Query で検索以外の処理を行う場合、 @Modifying アノテーションをメソッドに付与する必要があるが、メソッド名による精製では @Modifying 不要。

なお、 @Query("DELETE ...") + @Modifying と、メソッド名から生成された削除処理では、実際の挙動が異なる模様。

@Query による削除では、JPQLから生成されたSQLを直接データベースに実行するため、JPAのライフサイクル・コールバック外の削除となり、 @PreRemove@PostRemove などを付与したメソッドがあっても実行されない。

メソッド名派生による削除処理では、実際は同条件での検索処理が実行され、検索結果のJPA Entityを引数として CrudRepository#delete を実行するという処理になるため、JPAのライフサイクル上で削除が行われ、@PreRemove@PostRemove を付与したメソッドも実行されるとのこと。

振り返り

よくよく見ると、Repositoryを継承したインターフェースなら使える模様。

根本原因である「忘れる」ことについては何も解決していないが、まあ、今後はここ見ればいいからいいか...

Spock v1.3でPowerMockを使う

Spock + Mockitoでユニットテストを書いているときに、finalなクラスをモック化したくなったので、PowerMockを導入。

JUnit 4で使う時と同様、 @RunWith(PowerMockRunner) しただけでは実行時にエラーがでてしまったので、SpockでPowerMockを使う方法をメモ。

環境

Spock 1.3-groovy-2.5、Mockito 3.3.3、PowerMock 2.0.9。

PowerMockは powermock-module-junit4powermock-api-mockito2 を利用。

問題

finalなクラスをモック化したいと思い、まずは test/resources/mockito-extensions/org.mockito.plugins.MockMaker を作成して mock-maker-inline を追加したところ、ローカルでのテストでは成功したが、CIで失敗。

原因調査の時間がなかったため、PowerMockを導入した。

テスト実行にはSpockを利用しているため、 powermock-module-junit4powermock-api-mockito2 を依存関係に追加し、JUnit 4で使う時と同様、 @RunWith(PowerMockRunner) および @PrepareForTest(テスト対象クラス) をテストクラスに付与したが、テストを実行すると以下のエラーが発生する。

Execution failed for task ':test'.
> No tests found for given includes: [テストクラス](filter.includeTestsMatching)

どうやら、テストメソッドがないと判断されている模様。

調査

Stack Overflowに質問があった。

stackoverflow.com

対応

PowerMockRule の利用がベストアンサーとなっているが、 powermock-module-junit4-rule を依存関係に追加し、テストクラスの @RunWith(PowerMockRunner) を削除して @Rule PowerMockRule powerMockRule = new PowerMockRule() を追加してみても、やはりエラーが出てしまう。

ベストアンサーではないが、同じ質問の回答に @PowerMockRunnerDelegate(Sputnik.class) を使うというものがあった。

こちらを試してみたところうまくいった。

import org.junit.runner.RunWith
import org.powermock.api.mockito.PowerMockito
import org.powermock.core.classloader.annotations.PrepareForTest
import org.powermock.modules.junit4.PowerMockRunner
import org.powermock.modules.junit4.PowerMockRunnerDelegate
import org.spockframework.runtime.Sputnik

@RunWith(PowerMockRunner)
@PowerMockRunnerDelegate(Sputnik)
@PrepareForTest(TargetClass)
class TargetClassSpec extends Specification {

  def "テストメソッド"() {
    given:
    def target = PowerMockito.mock(TargetClass)
    PowerMockito.doReturn(true).when(target)...
  }

}

PowerMockを使うと、デフォルトのテストランナーはJUnitになる模様。Spock v1系のテストランナーである Sputnik を明示的に指定することで、SpockでもPowerMockを使用できた。

振り返り

PowerMockRule ではなく @RunWith(PowerMockRunner) を利用しているのは、以前は他のRuleと併用できなかったのと、 powermock-module-junit4-rule を依存性に追加しないと PowerMockRule が使えないから。

irof.hateblo.jp

ずっと @RunWith(PowerMockRunner) を使っているが、JUnitであれば他のRuleと PowerMockRule 、併用できるようになっているのだろうか。

また、5月にリリースされたSpock v2では、Sputnikが削除されている模様。記事内で思いっきり PowerMockRunnerDelegate が例として記述されている。この方法、Spock使いにはおなじみの方法だったんだろうか。

代替手段としては、 @PowerMockRunnerDelegate(JUnitPlatform) に変更し、JUnit4を使ってテストしてくれとのこと。

余談

もともとMockitoを使ったテストが書いてあったクラスに、今回の設定を混ぜ込んだところ、以下の例外が発生した。

Notifications are not supported when all test-instances are created first!
Notifications are not supported for behaviour ALL_TESTINSTANCES_ARE_CREATED_FIRST

Method was too large and after instrumentation exceeded JVM limit. PowerMock modified the method to allow JVM to load the class. You can use PowerMock API to suppress or mock this method behaviour.
java.lang.IllegalAccessException: Method was too large and after instrumentation exceeded JVM limit. PowerMock modified the method to allow JVM to load the class. You can use PowerMock API to suppress or mock this method behaviour.

PowerMockがクラスのバイトコードを書き換えた結果、メソッドのサイズがJVMの上限を超えたらしい。テストクラスを分割して対応した。

stackoverflow.com

Windows 10 Homeでタスクバーに天気と温度が表示されるようになったので消す

サブ機で使っているSurface Pro 7、Windows Updateをしたところ、タスクバーに天気と温度が表示されるようになった。

ホバーでニュースも出てきたりするが、使わないので消す方法をメモ。

要約

  1. タスクバーの、何も表示されていない空き部分を右クリック
  2. メニューから「ニュースと関心事項」をクリック
  3. 「無効にする」をクリック

画像付きの詳細はこちら

環境

Windows 10 Home 20H2 OS build ビルド 19042.1052。

状態

タスクバーに、天気と温度が表示されるようになった。ホバーするとニュースも表示される。

f:id:hepokon365:20210613113507p:plain

タスクバーの天気と温度を消す方法

  1. タスクバーの、何も表示されていない空き部分を右クリック
    f:id:hepokon365:20210613115639j:plain
    赤い枠で囲ったあたり
  2. メニューから「ニュースと関心事項」をクリック f:id:hepokon365:20210613120532j:plain
  3. 「無効にする」をクリック f:id:hepokon365:20210613121012j:plain

これで天気と温度が非表示となる。

f:id:hepokon365:20210613123231j:plain

振り返り

メイン機のWindows 10 Proでは出てこない? Homeだけの機能なんだろうか。

スマートフォンウィジェットみたいな機能ではあるが、使わないよなぁ...

使用すべきでない用語と包括的な用語、およびホワイトボックス/ブラックボックスの置き換えは必要か調べた

社内のドキュメントを読んでいた時に、用語について引っかかった。

まず、「ホワイトリスト」と「ブラックリスト」が使われていた。

これらは前者に肯定的、後者に否定的、すなわち差別的な意味が含まれるため、「Allow List」と「Deny List」、日本語では「許可リスト」と「拒否リスト」に置き換えた。

続いて、「ホワイトボックステスト」と「ブラックボックステスト」が使われていたが、ここで引っかかる。

ホワイトボックスとブラックボックスは、内部構造を把握しているか、していないかの意味で使用されているし、航空機のレコーダーの通称としてブラックボックスが使われてたりしているが、名前を変えようという動きは聞いたことがない。置き換えるのが適切だとしても何と置き換えていいかわからない。

遅ればせながら調べてみたのでメモ。

Inclusive Naming Initiative

このあたりについて調べると、Inclusive Naming Initiativeという組織が昨年発足していた模様。

有害で排他的な用語を、包括的な用語へ置き換えるための取り組みを促進するとのこと。

概要にロードマップの記載あり。ブログによるとKubernetesコミュニティの取り組みから生まれたとのことで、Cloud Native Computing Foundation(CNCF)、CiscoRed HatVMWareIBMらが参加している。また、IETFも参加している。

ブログ内での略称はINI。Windowsの設定ファイル拡張子と被ってるなぁ

置き換えるべき言葉のリストを公開しており、2021/5/30現在、以下が記載されている。

置き換えるべき言葉 代替案
whitelist, blacklist allowlist, denylist
master, slave control plane, control plane node
controller, doer
primary, replica
primary, secondary
master main
original
source
control plane

引用元ページには、それぞれの言葉が、なぜ置き換えるべきなのかの理由などが記載されている。Githubの初期リポジトリ名変更にもリンクあり。

ここにはホワイトボックス/ブラックボックスの記載がないのでさらに調べてみると、IBMのGitHubリポジトリらしきものがあり、善悪の価値観ではないので問題ないと判断してる模様。

IBM Watsonの日本語ページを見ると、現時点ではブラックボックスなど使用している。

f:id:hepokon365:20210531011622p:plain
IBM Watsonのページキャプチャ

その他の参考

もう少し検索していると、以下のページを見つけた。

https://www.duncannisbet.co.uk/removing-harmful-language-from-my-lexicon

イギリスのソフトウェアコンサルタントのページのようで、いくつか用語の置き換え候補、およびそれらの出典が記載されている。

whitebox/blackboxについてもいくつか例があり、個人的にしっくりきたのはclear box/closed box。文脈によってはopen/closeなどでいいかもしれない。

また、「Mob programming」や「Grandfather」、「Whitespace」なども好ましくない用語として記載されている。モブプログラミングやホワイトスペースが好ましくない理由はわからない(ホワイトスペースは「White」が使われているから?)が、グランドファーザー・ルールについては、祖父条項*1に由来するものなので、確かに使うべきではない。

Spring Framework

Java屋なので、Javaの有名どころのライブラリでどうなっているか調べたところ、Spring Frameworkで変更が入っていた。

github.com

「white box/black box」は「clear box/opaque box」に変更してほしいとのリクエストで、white boxは利用箇所がなかったが、black boxは使われていたため修正されていた。

結論

ホワイトボックス/ブラックボックスの置き換えについて、2021/5/30時点では、積極的な置き換えは不要と判断した。

ホワイトリスト/ブラックリストに対する許可リスト/拒否リストのように、置き換えるべき用語が確定していないのが最大の理由。

一方で、ホワイトリスト/ブラックリストの表現を不快に感じる方々が、少なからず存在することも把握できた。仮に置き換えるべき用語が確定・定着したら、積極的にそれらを使用すべきだと思う。

また、すでに置き換えるべき包括的な用語が確定している、INIのリストに記載されている用語の使用は、明確に禁止すべきだろう。

今回は社内向けのドキュメントだったが、社外に公開するドキュメント、特に海外向けの英語のドキュメントなどを作成する場合、注意する必要があると感じた。

振り返り

置き換えるべき言葉のリストに記載されているリンクに、DjangoのPR#2692がある。チケットは#22667

master/slaveleader/follower を経由して primary/replica に変更したものだが、これが出されたのが2014年、コメント欄はなかなか荒れている。

2020年になって、 whitelist/blacklist を変更するPR(#12755, #13031)が出されたが、そちらには否定的なコメントはついていない。

当時の状況は把握おらず、#2692を見るとDjangoのユーザー以外もコメントしていたり、用語も異なるので一概に比較はできないが、こうした取り組みに公然と異を唱える人がいなくなっているあたり、意識は変わっているんだなと感じた。

ちなみに、#2692のこのコメントが好き。

余談

冒頭で「Allow List」と「Deny List」と書いたが、どうもスペースを入れずに「allowlist」や「denylist」と書くらしい。

Android版Google Chrome v91で、グループ化せずにタブを開くメニューを復活させる

AndroidGoogle Chromeがv91になり、以前の記事で使っていたフラグが使用できなくなった。

また、新しいタブを開くとグループ化される状態が復活し、リンクをロングタップしても「新しいタブをグループで開く」しか表示されず、またしてもグループ化せずに開くことができない。タップ回数が増えて非常に不便。

Chromeのフラグをいじってみて、「新しいタブをグループで開く」とは別に「新しいタブを開く」を復活させることはできたので、方法をメモ。

環境

AndroidGoogle Chrome v91.0.4472.77

問題

新しいタブを開くと、グループ化される。リンクを開くときにロングタップしても「新しいタブをグループで開く」となっており、グループ化せずに開くことができない。

前回と同様なので、画像は割愛。

原因

バージョンアップにより、前回変更した「Tab Groups」、「Tab Groups Continuation」、「Tab Groups UI Improvements」のうち、「Tab Groups Continuation」を除く2つのフラグが廃止された。

これにより、タブのグループ化に関する設定変更が無効となった模様。

piunikaweb.com

対応

「新しいタブをグループで開く」を削除することはできなかったが、ロングタップしたときのメニューに「新しいタブを開く」を復活させることはできた。

設定変更手順は以下。

  1. Google Chromeを開き、「検索語句またはウェブアドレスを入力」に chrome://flags を入力して開く
  2. 実験的機能のフラグ管理画面が開くので、画面上部の検索窓に「tab grid」を入力して検索
  3. 「Tab Grid Layout」と、セレクトボックスが表示されるので、セレクトボックスをタップして「Enabled Without auto group」を選択し、右下の「Relaunch」ボタンをタップ
    f:id:hepokon365:20210529161641j:plainf:id:hepokon365:20210529161701j:plainf:id:hepokon365:20210529161616j:plain
  4. Google Chromeが再起動するが、あいかわらず設定は反映されない。設定アプリからGoogle Chromeを強制停止するか、スマートフォンを再起動して、Google Chromeを完全に停止してから再起動させる

これで、ロングタップしたときのメニューに「新しいタブを開く」が表示される。位置は「新しいタブをグループで開く」と、「シークレット タブで開く」の間。

f:id:hepokon365:20210529162250j:plain
設定後のメニュー

振り返り

これまた前回と同様、このフラグ設定も、いつまで使えるか分からない。

UI/UXにかかわる機能は、普通に設定できるようにしてほしいなあ(2回目)。

余談

今回のGoogle Chromeのバージョンアップで、タブを横並びで表示させる方法は無くなった模様。

グリッドレイアウトが嫌なら、Firefoxを使ってみてもいいかも。

f:id:hepokon365:20210529163300j:plain
Firefoxのタブ表示

Chromeと違ってアドオンの制限がないので、広告ブロッカーも導入可能。

自分も普段使いはFirefoxだが、ユーザーエージェントではじいているのか、Firefoxだと「サポートしていないブラウザ」表示などで閲覧できないページがあるので、どうしてもChromeと併用する必要があるのが難点か。

Javadocから作成したXMLを読み込み、クラスと突き合わせてファイル出力してみる

typescript-generatorプラグインでJavadocからTSDocを生成したのち、XMLを直接読み込んだらいろいろできそうだなと思いついた。

Enumの値のJavadocと、フィールドで設定された値を出力したい場合があり、試してみたらできたのでメモ。

やりたいこと

エラーコードをEnumとして管理している。

どういった場合のエラーかをJavadoc、エラーコードをフィールドとして保持しているので、Enumの値に付与されたJavadocと、フィールドとして持っている値をファイル出力したい。

実装例

Java

以下のような列挙型を用意。値の参照のため、 @Getter でgetterを生成している。

package example;

@lombok.Getter
@lombok.RequiredArgsConstructor
public enum ExampleEnum {

    // Javadocなし
    ZERO("0"),
    /***/
    ONE("1"),
    /***/
    TWO("2"),
    /***/
    THREE("3"),

    ;

    private final String value;
}

build.gradle

以下のようなタスクを記述。 xmlDoclet タスクなどは前回と同様のため省略。

task enumToCsv() {
  dependsOn "compileJava", "xmlDoclet"

  doLast {
    def javadocXmlFile = "build/typescript-generator/xml-doclet/javadoc.xml" as File
    def targetClass = "example.ExampleEnum"
    def targetClassPackage = targetClass.substring(0, targetClass.lastIndexOf("."))

    // nameやqualifiedは@でフィールドアクセスしないとnullになる
    // enumをドットに続けて書くとエラーになるため、文字列にしている
    def targetClassNode = new XmlSlurper().parseText(javadocXmlFile.getText("UTF-8"))
        .package.find { it.@name == targetClassPackage }
        ."enum".find { it.@qualified == targetClass }

    // 列挙型の値を参照するため、クラスパスからクラスローダーを生成
    def urls = sourceSets.main.runtimeClasspath.files.collect { it.toURI().toURL() } as URL[]
    def classloader = new URLClassLoader(urls, null as ClassLoader)

    // 列挙型を読み込み、XMLと突き合わせ、MapのListを生成
    def enumClass = classloader.loadClass(targetClass) as Class<Enum>
    def enumContexts = enumClass.enumConstants.findResults { Enum enumValue ->
      def name = enumValue.name()
      def javadocNode = targetClassNode.constant.find { it.@name == name }
      def javadoc = (javadocNode.comment?.text() ?: "").trim()

      // Javadocが未設定であれば無視
      return javadoc ? [
          name   : name,
          value  : enumValue.value,
          javadoc: javadoc,
      ] : null
    }

    // MapからCSVを出力
    def csvText = "name,value,javadoc\r\n" + enumContexts.collect {
      "${it.name},${it.value},${it.javadoc}"
    }.join("\r\n") + "\r\n"

    def csvDir = "build/enum-csv"
    (csvDir as File).mkdirs()

    def csvFile = "${csvDir}/${enumClass.simpleName}.csv" as File
    csvFile.setText(csvText, "MS932")
  }
}

gradle enumToCsv すると、 build/enum-csv/ExampleEnum.csv に以下のファイルが出力される。

name,value,javadoc
ONE,1,一
TWO,2,二
THREE,3,三

振り返り

実際はもっと複雑なクラスだが、Groovy側で加工することで対応できた。

この手の方法(列挙型+Javadoc+フィールド)を使うことがままあり、その都度Excelスプレッドシートで別途管理していることもあったので、こうしたことができるのは個人的にはうれしい。

TypeScriptのインターフェース定義として出力したりと、今後の展開も考えている。

xml-docletが更新停止状態なのが惜しい...

typescript-generatorプラグインでJavadocからTSDocを生成するとWindows環境でエラーとなった

前回の続き。

JavadocからTSDoc変換をできるようにしたが、運用しているとJavadoc出力時にエラーが出る場合があったのでメモ。

Windowsでエラーが発生する

特定の端末で「docletクラスcom.github.markusbernhardt.xmldoclet.XmlDocletが見つかりません」が発生し、Javadoc出力に失敗した。

いずれもWindows端末で、Gradleキャッシュへのパスに全角文字が含まれていた(Windowsのユーザー名が全角だった)。

javadoc.optionsUTF-8で出力されるが、 javadoc コマンドではデフォルトエンコーディングであるWindows-31Jで読み込まれるため、パスが文字化けしてしまいJARの読み込みに失敗していた模様。

javadocコマンドでファイル指定する際には文字コード指定ができないようで、GradleのJavadocタスクでもオプションファイルのエンコーディング指定はなさそう。 options.encoding は指定できるが、出力されるXMLファイルのエンコーディング指定だった。

また、ファイル内にはJavaファイルへのパスも出力されるため、ソースファイルのパスに全角文字が含まれる場合も失敗すると思われる。

プロジェクト保存先に全角文字が含まれないようにし、またJARは以下のように build ディレクトリ配下にコピーするよう変更して対応した。

configurations {
  xmlDoclet
}

dependencies {
  xmlDoclet 'com.github.markusbernhardt:xml-doclet:1.0.5'
}

final def tsOutputDir = 'build/typescript-generator'
final def tsXmlDocletDir = "${tsOutputDir}/xml-doclet"

task xmlDoclet(type: Javadoc) {
  // JARファイルをコピー
  def copyDir = "${tsOutputDir}/jar/doclet"
  (copyDir as File).mkdirs()

  def docletPaths = configurations.xmlDoclet.files.collect {
      def jarFile = new File("${copyDir}/${it.name}")
      jarFile << it.readBytes()
      return jarFile
  }

  source = sourceSets.main.allJava
  destinationDir = (tsXmlDocletDir as File).absoluteFile
  title = null
  options.noTimestamp = false
  options.encoding = 'UTF-8'
  options.showAll()
  options.doclet = 'com.github.markusbernhardt.xmldoclet.XmlDoclet'
  options.docletpath = docletPaths // コピー後のJARを指定
}

振り返り

Windowsのユーザー名に全角文字はやめておいたほうがいいね...