IntelliJ Platform Pluginの開発にて、ApplicationManagerやToolWindowManagerを使って、Actionの中でToolWindow上のlabelを更新してみた

IntelliJ Platform Pluginの開発にて、「ToolWindow上のボタンをクリックすると、ラベルの値が更新される」みたいなことを実装したくなりました。

ボタンとラベルを定義したのと同じファイル内であれば、 actionListener を使って以下のように実装できます。

class UpdateInActionContent {
    var contentPanel : DialogPanel
    lateinit var myLabel: Cell<JLabel>

    init {
        contentPanel = panel {
            row {
                // ラベルの定義
                myLabel = label("Initial content")
                myLabel.component.name = "myLabel"

                // ボタンを定義し、actionListenerにクリックしたときに handleClick メソッドを実行
                button("Run Function") { event -> handleClick(event) }
            }
        }
    }

    private fun handleClick(event: ActionEvent) {
        myLabel.component.text = "handle click"
    }
}

 
ただ、 button()オーバーロードとして、 AnAction を受け取ることもできます。

この場合、AnActionは別クラスとして定義することになるため、 actionListener と同じように実装することができません。

 
そこで、どのようにすれば実現できるか試してみたときのメモを残します。

なお、動作するようにはなったもののこれで正しいのかは自信がないため、詳しい方がいれば教えていただけるとありがたいです。

 
目次

 

環境

 

調査

UIスレッドにあるコンポーネントへの書き込む方法について

公式ドキュメントによると、ToolWindow上のコンポーネントの更新を行う場合、 ApplicationManager.getApplication().invokeLater を使う必要がありそうでした。

 
なお、Pluginのスレッドモデルは 2023.3 の前後で変わったようです。

2023.3 Threading Model Changes Threading model has changed in 2023.3, please make sure to choose the correct version in the tabs below

https://plugins.jetbrains.com/docs/intellij/general-threading-rules.html#read-access

 

ToolWindow上にあるラベルコンポーネントを取得する方法について

公式ドキュメントによると、 ToolWindowManager.getToolWindow() を使うことで、ToolWindowにアクセスできそうでした。
Accessing Tool Window | Tool Windows | IntelliJ Platform Plugin SDK

 

ToolWindowManagerを使うため、ActionEvent ではなく AnActionEvent から project をもらう

ToolWindowManagerインスタンスを取得する getInstance() では project の値が必要です。
Project | IntelliJ Platform Plugin SDK

 
今回、ボタンを押したときにToolWindow上のlabelの値を更新する際、

button("Run Function") { event -> handleClick(event) }

と定義した場合、 handleClick のシグネチャ

private fun handleClick(event: ActionEvent) {}

となります。

ただ、ActionEventクラスには project の値が含まれません。

 
一方、Actionを使うときにオーバーライドが必要な actionPerformed の引数は AnActionEvent 型になることから、 project の値が含まれます。

そのため、以下のような実装で project の値が取得できます。

override fun actionPerformed(e: AnActionEvent) {
    val project = e.project ?: return
}

 
ちなみに e.project でprojectを取得する場合、 null が返ってくることもあります。

nullを回避するためには AnActionEvent にある getRequiredData を使うこともできます。

ただ、ソースコードコメントにある通り、 update メソッドで null ではないかのチェックをする前提で使えます。

Returns not null data by a data key. This method assumes that data has been checked for {@code null} in {@code AnAction#update} method.

 

https://github.com/JetBrains/intellij-community/blob/e3561dcaf64652cafb7ebca270d4a2cb536e5558/platform/editor-ui-api/src/com/intellij/openapi/actionSystem/AnActionEvent.java#L189

 

実装

ToolWindow上のコンテンツ

まずは、ToolWindow上に表示するコンテンツを用意します。

今回は

  • myLabel という name を持つラベル
  • actionListener を定義した Run Function ボタン
  • AnAction を定義した Run Action ボタン

を用意します。

kage com.github.thinkami.hellojetbrainsplugin.ui

import com.github.thinkami.hellojetbrainsplugin.actions.UpdateLabelAction
import com.intellij.openapi.ui.DialogPanel
import com.intellij.ui.dsl.builder.Cell
import com.intellij.ui.dsl.builder.panel
import java.awt.event.ActionEvent
import javax.swing.JLabel

class UpdateInActionContent {
    var contentPanel : DialogPanel
    lateinit var myLabel: Cell<JLabel>

    init {
        contentPanel = panel {
            row {
                myLabel = label("Initial content")
                myLabel.component.name = "myLabel"

                button("Run Function") { event -> handleClick(event) }
                button("Run Action", UpdateLabelAction())
            }
        }
    }

    private fun handleClick(event: ActionEvent) {
        myLabel.component.text = "handle click"
    }
}

 

ToolWindowFactory

続いて、用意したコンテンツを表示するToolWindowFactoryを用意します。

package com.github.thinkami.hellojetbrainsplugin.toolWindow

import com.github.thinkami.hellojetbrainsplugin.ui.UpdateInActionContent
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.content.ContentFactory

class UpdateInActionToolWindowFactory : ToolWindowFactory {
    override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
        val content = ContentFactory.getInstance().createContent(UpdateInActionContent().contentPanel, null, false)
        toolWindow.contentManager.addContent(content)
    }
}

 

Action

Actionでは、ToolWindow上にある myLabel コンポーネントにアクセスし、そのラベルのテキストを更新します。

流れとしては

  • ToolWindowへアクセスするところは ApplicationManager.getApplication().invokeLater で囲む
  • toolWindow.component.components でルートのDialogPanel を取得
  • DialogPanel の components の中に一階層下の labelbutton があるため、 namemyLabel のものに絞り込み、 text を更新

となります。

package com.github.thinkami.hellojetbrainsplugin.actions

import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.ui.DialogPanel
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.testFramework.requireIs
import javax.swing.JLabel

class UpdateLabelAction : AnAction() {
    override fun actionPerformed(e: AnActionEvent) {
        // AnActionEventに含まれる project の値を取得する
        val project = e.project ?: return

        // UIスレッドで非同期に処理を実行する
        ApplicationManager.getApplication().invokeLater {
            // project を元に ToolWindowを取得する
            val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("UpdateInActionToolWindow")
                // ToolWindowが存在しない場合は何も処理しない
                ?: return@invokeLater

            // DialogPanelは1つしかないはず
            val panelComponent = toolWindow.component.components.filterIsInstance<DialogPanel>().first()

            // myLabelというnameを持ったlabelは1つしかないはず
            val labelComponent = panelComponent.components.filterIsInstance<JLabel>().find {
                it.name == "myLabel"
            }

            if (labelComponent != null) {
                labelComponent.text = "updated"
            }
        }
    }
}

 

plugin.xml

extensions に、今回追加した ToolWindow を登録します。

<extensions defaultExtensionNs="com.intellij">
    <toolWindow factoryClass="com.github.thinkami.hellojetbrainsplugin.toolWindow.UpdateInActionToolWindowFactory" id="UpdateInActionToolWindow"/>
</extensions>

 

動作確認

初期表示

initial content が表示されてます。

 

Run Function ボタンをクリック

ラベルが handle click に更新されました。

 

Run Action ボタンをクリック

ラベルが updated に更新されました。

 

その他参考資料

 

ソースコード

Githubにあげました。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin/pull/12

IntelliJ Platform Pluginの開発にて、RubyMineなどのIntelliJ IDEA以外のIDEがRun Pluginで起動するようにしてみた

IntelliJ Platform Plugin Template を使って IntelliJ Platform Pluginを開発している場合、 デフォルトでは Run Plugin すると

な状態で IDE の別インスタンスが起動します。

ただ、

などはどうすればよいか気になったことから、調べたときのメモを残します。

 
目次

 

環境

  • プラグインの開発環境
    • Widnows 11
    • IntelliJ IDEA 2023.3.4 Ultimate Edition
    • IntelliJ Platform Plugin Template 1.12.0
    • Kotlinで実装
  • 起動するIDE
    • RubyMine 2023.2.6、IntelliJ IDEA 2023.2.6 Ultimate
      • Windows上に、JetBrains Toolboxでインストール済
      • 開発環境とは異なるプラットフォームのバージョンを指定できるか確認するため、 2023.2.6 にしています

 
また、今回の実装は、前回の記事の続きとして実装していきます。
IntelliJ Platform Pluginの開発にて、Kotlin UI DSL Version 2 や Swing を使って、ToolWindow上にコンポーネントを表示してみた - メモ的な思考的な

 

起動するIDEをRubyMineに変更する

Run Scriptで起動するIDEをRubyMineへ変更する方法については、IntelliJ Platform Plugin SDKの公式ドキュメントに記載がありました。

 
これらによると、

とすれば良さそうでしたので、ためしてみます。

 

build.gradle.kts の修正

build.gradle.kts を見たところ、 intellij.plugins の設定は以下のようになっていました。

intellij {
    // ...
    // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file.
    plugins = properties("platformPlugins").map { it.split(',').map(String::trim).filter(String::isNotEmpty) }
}

 
ソースコードのコメントに

Uses platformPlugins property from the gradle.properties file.

とあるように、定義済のキーに対する値を gradle.properties へ設定すれば良さそうでした。

 
一方、 runIde.ideDir の定義は存在しなかったため、

  • キーを末尾に追加
    • ただし、値を何も設定しない場合は、デフォルトのIntelliJ IDEAが起動できるようにする
  • intellij.plugins 同様、値については gradle.properties へ設定

とします。

 
なお、 runIde はドキュメントによると intellij と同階層で定義してあったのでその通りにしたところ

[FUNCTION_EXPECTED] Expression 'runIde' cannot be invoked as a function. The function 'invoke()' is not found

というエラーが出て定義できませんでした。

そこで、stackoverflowの回答にある通り、 tasks の中に入れたところエラーが解消されました。
creating Android Studio plugin using IntelliJ runIde can not be invoked - Stack Overflow

 
ちなみに、 build.gradle.kts の書式は

  • Kotlin
    • ideDir.set(file(properties("ideDir")))
  • Groovy
    • ideDir = file(properties("ideDir"))

のどちらでも問題なく動作しました。

 
まとめるとこんな感じになります。

tasks {
    // ...
    runIde {
        // executable other IDE
        // キーに対する値が存在するときだけ、 ideDir を設定
        if (properties("ideDir").get().isNotEmpty()) {
            // Kotlin
            ideDir.set(file(properties("ideDir")))
            // Groovy
            // ideDir = file(properties("ideDir"))
        }
    }
}

 

gradle.properties の修正

続いて、build.gradle.kts で定義したキーに対応する値を gradle.properties へと記載します。

 

platformPlugins の設定

すでに platformPlugins = としてキーが存在しているため、値だけ設定します。

設定する値は、以下に掲載されているRubyプラグインのバージョンになります。
https://plugins.jetbrains.com/plugin/1293-ruby/versions

なお、RubyプラグインのバージョンはRubyMineのバージョンとは異なるので注意が必要です。

 
今回は、RubyMine 2023.2.6 で動作するバージョン 232.10203.2 とともに、以下のように指定します。

platformPlugins = org.jetbrains.plugins.ruby:232.10203.2

 

ideDirの設定

こちらは、RubyMineをインストールしたディレクトリを指定します。

今回の場合、インストール済の RubyMine 2023.2.6 があるディレクトリを指定します。

 
なお、インストールしたディレクトリはバージョン表記ではなくビルド表記なので注意が必要です。

バージョン表記とビルド表記の比較は、以下のページにて確認できます。
https://www.jetbrains.com/ja-jp/ruby/download/other.html

今回使うバージョン 2023.2.6 に対するビルド表記は 232.10300.41 でした。

 
また、runIde.ideDir にて指定すべきディレクトリ表記ですが、macOSの場合は以下のページに記載があります。
https://plugins.jetbrains.com/docs/intellij/rubymine.html#configuring-plugin-projects-targeting-rubymine

一方、今回はWindows環境なので調べてみたところ、手元のJetBrains ToolboxによるRubyMine 2023.2.6 のインストール先は以下でした。
(<USER_NAMEch-N は環境により読み替えてください。)

C:\Users\<USER_NAME>\AppData\Local\JetBrains\Toolbox\apps\RubyMine\<ch-N>\232.10300.41

 
このパスをgradleでビルドするためには、バックスラッシュ \エスケープして設定します。

ideDir =  C:\\Users\\<USER_NAME>\\AppData\\Local\\JetBrains\\Toolbox\\apps\\RubyMine\\<ch-N>\\232.10300.41

 

plugin.xml の修正

最後に、 plugin.xml にてRubyプラグインが必要な旨を末尾に追記します。

<idea-plugin>
    <!-- ファイル末尾 -->
    <depends>com.intellij.modules.ruby</depends>
</idea-plugin>

 

動作確認

以上で準備ができたので、 Run Plugin にて別インスタンスIDEを起動します。

すると、HelloToolWindowプラグインのあるRubyMine 2023.2.6 が起動しました。

 
また、IDEのRuntime versionは、IDEに同梱されている 17.0.9+8-b1166.2 amd64 でした。

 

特定のJBRでRubyMineを起動する

続いて、IDEのRuntime versionを特定のバージョンにして、Run Plugin できるよう設定を変更します。

特定のRuntime versionで実行には、 runIde.jbrVersion を指定すると良さそうです。

 
そこで

  • build.gradle.kts
  • gradle.properties

の2ファイルを修正します。

 

build.gradle.kts に runIde.jbrVersion キーを追加

先ほど追加した runIde キーの末尾に、jbrVersionキーを追加し、値を gradle.properties から取得するようにします。

runIde {
    // 末尾に追加
    if (properties("ideDir").get().isNotEmpty()) {
        jbrVersion.set(properties("jbrVersion"))
    }
}

 

gradle.properties に jbrVersion キーと値を追加

こちらも末尾に追加します。

指定可能なJBRのバージョンは、READMEに記載されています。
https://github.com/JetBrains/JetBrainsRuntime/releases

 
今回は、 2023.2 系の JDK 17 ベースで起動したいことから、Latest JBR にある 17.0.10-b1000.48 のリンクをクリックします。

 
すると Realeses ページに飛ぶので、先頭の Release の次にある 17.0.10b1000.48 を使用します。

jbrVersion = 17.0.10b1000.48

 

動作確認

再度 Run Plugin してRubyMineを起動すると、JBRのバージョンが切り替わっていました。

 

起動するIDEの構成を IntelliJ IDEA Ultimate + 自作プラグイン + Rubyプラグインにする

最後に、IntelliJ IDEA Ultimate でも起動できるよう設定します。

 

gradle.properties の修正

起動するIDEをRubyMineからIntelliJ IDEA Ultimateへ切り替えるだけなので、 gradle.propertiesideDirIntelliJ IDEA Ultimate 2023.2.6 がインストールされているパスへと変更します。

ideDir = C:\\Users\\<UserName>\\AppData\\Local\\JetBrains\\Toolbox\\apps\\IDEA-U\\<ch-N>\\232.10300.40

 

動作確認

再度 Run Pliugin を実行すると、IntelliJ IDEA Ultimate 2023.2.6 が起動しました。

 
プラグインページを見ると、自作プラグインRubyプラグインがインストールされていることがわかります。

 

その他参考資料

JetBrains のビルド番号の情報

Build Number Ranges | IntelliJ Platform Plugin SDK

Gradle IntelliJ Pluginの情報

Gradle IntelliJ Plugin | IntelliJ Platform Plugin SDK

 

ソースコード

Githubにあげました。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin/pull/11

 
なお、gradle.properties の中に、個人のローカルPCのパスを指定しているため、今回のプルリクから gradle.propertiesgradle.properties.example へと差し替えています。

その結果、 gradle.properties が無い影響でCIがエラーになっています

そこで、もし実際に動かす場合には、 gradle.properties.examplegradle.properties ヘリネームし、自分の環境に合わせた設定へと変更する必要があります。

 
(2024/03/19 追記)

この後、gradle.properties.example を使う代わりに、 local.properties ファイルを使うことにしました。詳細は以下の記事に記載しています。
IntelliJ Platform Pluginの開発にて、開発環境にある公開したくない情報を local.properties に定義してビルドする - メモ的な思考的な

(2024/03/19 追記 ここまで)

 
また、「RubyMineのライセンスがないとサンプルコードが動かせない」という事態を避けるため、 plugin.xml の定義はコメント化してあります。

IntelliJ Platform Pluginの開発にて、Kotlin UI DSL Version 2 や Swing を使って、ToolWindow上にコンポーネントを表示してみた

前回、IntelliJ Platform Plugin Templateを使って、JetBrains系IDEプラグイン開発の Hello, world 的なことをやってみました。
IntelliJ Platform Plugin Templateを使って、「エディタのコンテキストメニューからダイアログを表示する」だけのJetBrains系IDEプラグインを作ってみた - メモ的な思考的な

その IntelliJ Platform Plugin Template には、 ToolWindow を表示するサンプルコードが同梱されていました。

サンプルコード を見ると

fun getContent() = JBPanel<JBPanel<*>>().apply {
    val label = JBLabel(MyBundle.message("randomLabel", "?"))

    add(label)
    add(JButton(MyBundle.message("shuffle")).apply {
        addActionListener {
            label.text = MyBundle.message("randomLabel", service.getRandomNumber())
        }
    })
}

という実装だけで、画面を伴う ToolWindow が表示されていました。

一方、これを見ただけではどうやって画面を作ればよいか分かりませんでした。

 
そこで、自分で ToolWindow をイチから作成することで、どのようにして画面上に各部品を置けばよいか把握できたことから、その時のメモを残します。

 
目次

 

環境

  • Widnows 11
  • IntelliJ IDEA 2023.3.4 Ultimate Edition
  • IntelliJ Platform Plugin Template 1.12.0
  • Kotlinで実装

 

ToolWindowを自作する

IntelliJ Platform Plugin SDKのドキュメントを見たところ、以下に ToolWindow の情報がありました。
Tool Windows | IntelliJ Platform Plugin SDK

ただ、ToolWindowの概念的な内容が多く、実際にどのような順で定義していけばよいか分かりませんでした。

 
次に、ToolWindowのページからリンクされていたサンプルコードを見てみました。
https://github.com/JetBrains/intellij-sdk-code-samples/tree/main/tool_window

サンプルコードはJavaでしたが、

  • ToolWindowFactory を継承したFactoryクラス
  • plugin.xml へのエントリ追加

をすれば良さそうでしたので、実際にためしてみます。

 
なお、サンプルコードではFactoryクラスに画面の構成も定義されていました。

ただ、画面の部品が増えると

  • 画面の定義
  • Factoryの定義

が混在して分かりづらくなりそうと考え、今回は画面とFactoryで定義を分けることにします。

 

Kotlin UI DSL Version 2で、画面を定義する

まず、Factoryから呼び出される画面の定義から行います。

IntelliJ Platform Pluginでの画面定義は

The IntelliJ Platform includes a large number of custom Swing components. Using those components in your plugins will ensure that your plugin looks and works consistently with the UI of the rest of the IDE, and can often reduce the code size compared to using the default Swing components.

 
https://plugins.jetbrains.com/docs/intellij/user-interface-components.html

とあるように、JavaSwing で行えるようです。

また、Figmaで画面レイアウトを描くためのUIKitも用意されていました。 https://jetbrains.design/intellij/resources/UI_kit/

引き続き公式ドキュメントを読んでいたところ、 Kotlin UI DSL Version 2 を使うことで、KotlinでSwingを簡潔に書けるような記載がありました。
Kotlin UI DSL Version 2 | IntelliJ Platform Plugin SDK

 
そこで、今回の画面定義は

  • src/main/kotlin/com/github/thinkami/hellojetbrainsplugin/ui/HelloContent.kt ファイルに定義する
  • Kotlin UI DSL Version 2 を使う
  • Factoryに画面定義を渡すため、 JComponent を返す関数を定義する

という方針で実装します。

方針に従った実際のコードは以下で、ToolWindowを開いた時に Hello が表示される想定です。

fun getHelloContent(): JComponent {
    return panel {
        row {
            label("Hello")
        }
    }
}

 

ToolWindowFactory を継承したFactoryクラスを作成

画面定義ができたので、次は IntelliJ Platform Plugin Template で生成されたサンプルコードを参考にして、Factory HelloToolWindowFactory を実装します。

class HelloToolWindowFactory : ToolWindowFactory {
    override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
        val content = ContentFactory.getInstance().createContent(getHelloContent(), null, false)
        toolWindow.contentManager.addContent(content)
    }
}

 

plugin.xml への追記

最後に plugin.xml へ追記し、IDEにHelloToolWindowを表示します。

今回は、 IntelliJ Platform Plugin Template で生成された extensions の下にある toolWindow を使って定義しました。

<extensions defaultExtensionNs="com.intellij">
    <toolWindow factoryClass="com.github.thinkami.hellojetbrainsplugin.toolWindow.HelloToolWindowFactory" id="HelloToolWindow"/>
</extensions>

 

動作確認

Run Plugin を実行し、HelloToolWindow を開くと、 Hello が表示されました。良さそうです。

 

常に自作のToolWindowを表示状態にする

ここまででToolWindowの表示はできたものの、Run Plugin を再度実行すると自作のToolWindowが閉じた状態での表示になってしまいます。

ただ、これだと開発で動作確認を繰り返すときに不便なので、色々試してみました。

すると、

  • Run PluginでIDEが起動する
  • Hello ToolWindow を開く
  • IDEx ボタンで閉じる

を行うことで、常に表示状態にできました。

どこかに設定があるのかもしれませんが、ひとまずこれで良さそうです。

 

JBScrollPane を使い、 ToolWindowをスクロール可能にする

今の実装では、1つのToolWindowに多数のコンポーネントを定義した場合、ToolWindowにスクロールバーが表示されないことから、すべてのコンポーネントを表示できません。

そこで、 JBScrollPane を使い、ToolWindowをスクロール可能にします。

// DSLで生成したものを `p` に入れる
val p = panel {
    row {
        label("Hello")
    }
    // ...
}

// `p` を JBScrollPane でラップして関数の呼び出し元に渡す
return JBScrollPane(p)

 
これにより、スクロールバーが表示されました。以下の画像では、右下に小さくなったスクロールバーが見えます。

 

Kotlin UI DSL Version 2を使って、画面に色々なコンポーネントを表示してみる

ここまでで、ToolWindowの画面に Hello という文字列を表示できるようになりました。

引き続き、 Kotlin UI DSL Version 2 を使って、画面へのコンポーネント表示を色々試してみます。

   

rowの引数にテキストを渡す

Githubにあるサンプルコードの中には row の引数にテキストを渡している箇所もありました。
https://github.com/JetBrains/intellij-community/blob/idea/233.14475.28/platform/platform-impl/src/com/intellij/internal/ui/uiDslShowcase/DemoBasics.kt

そこで、 label とどう違うのかを見てみます。

row("With row args") {
    label("Row args")
}

 
行の冒頭に指定したテキストが表示されるようです。

 

Indentを使う

Indent の有無による表示の違いを見てみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelindent

indent {
    row {
        label("Indented row")
    }
}

 
今回追加した行は、インデントが設定されていました。

 

separatorを使う

続いて、 separator を使って、コンポーネントの区切りを表示してみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelseparator

今回、「rowに引数あり」と「インデントあり」の間に separator() を追加してみます。

// rowに引数あり
row("With row args") {
    label("Row args")
}

// 追加
separator()

// インデントあり
indent {
    row {
        label("Indented row")
    }
}

 
その結果、横線が追加されました。

青矢印で横線を指してみましたが、背景が暗めなので見づらいですね。。

 

Kotlin UI DSL Version 2で使えるSwingのコンポーネントについて

ここまでは label だけを使ってきました。

ただ、以下のデモコードを見たところ、label の他にも textFieldcheckBox なども、DSLにてSwingコンポーネントを表示できそうでした。
https://github.com/JetBrains/intellij-community/blob/idea/233.14475.28/platform/platform-impl/src/com/intellij/internal/ui/uiDslShowcase/DemoComponents.kt

 

Cellを使い、Kotlin UI DSL Version 2に無いSwingのコンポーネント (JBTable) を扱う

Kotlin UI DSL Version 2に無いSwingのコンポーネント、例えば JBScrollPaneJBTable を扱う方法を探したところ、 Row.cell がありました。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowcell

 
そこで、DSLにない JBTableCell で表示してみます。

// データ定義
val data = arrayOf(
    arrayOf("1", "Fuji", "Red"),
    arrayOf("2", "Shinano Gold", "Yellow"),
    arrayOf("3", "Bramley", "Green")
)

val columnNames = arrayOf("ID", "Name", "Color")
val tableModel = DefaultTableModel(data, columnNames)
val table = JBTable(tableModel)
val scrollableTablePanel = JBScrollPane(table)

// cellで表示
row {
    cell(scrollableTablePanel)
}

 
すると、テーブルが表示できました。

 
なお、 JBScrollPane の代わりに Row.scrollCell を使うこともできそうです。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowscrollcell-component

 

checkBoxの状態により、有効/無効や表示/非表示を切り替える

checkBoxvisibleIfenabledIf を組み合わせることで、有効/無効や表示/非表示を切り替えられます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowvisibleifenabledif

lateinit var myCheckBox: com.intellij.ui.dsl.builder.Cell<JBCheckBox>
row {
    myCheckBox = checkBox("Enable option if checked.")
    checkBox("Option by enable").enabledIf(myCheckBox.selected)
    checkBox("Option by visible").visibleIf(myCheckBox.selected)
}

 
チェックが無い状態では、無効・非表示です。

 
チェックすると、有効・表示へと切り替わります。

 

checkBox + rowsRange で、ON/OFFの状態をまとめて切り替える

checkBoxにより切り替わる項目が複数ある場合、 rowsRange を使うと定義が1度で済みます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelrowsrange

ためしに、

  • rowごとに visibleIf を定義する
  • rowsRangeでまとめて visibleIf を定義する

の2つを比べてみます。

// rowsRangeなしの場合
row {
    label("1st row label without rowsRange")
}.visibleIf(myCheckBoxForRowsRange.selected)

row {
    label("2nd row label without rowsRange")
}.visibleIf(myCheckBoxForRowsRange.selected)

// rowsRangeありの場合
rowsRange {
    row {
        label("1st row label with rowsRange")
    }
    row {
        label("2nd row label with rowsRange")
    }
}.visibleIf(myCheckBoxForRowsRange.selected)

 
チェックが無い場合、何も表示されていません。

 
チェックを入れると、複数行が表示されました。

挙動は同じだったため、 rowsRange を使ったほうがまとめて定義できて便利そうでした。

 

ラジオボタン + component + addActionListener で、選択によりラベルのテキストを切り替える

公式ドキュメントの Panel.buttonsGroup には、ラジオボタンをグループ化できるコードが掲載されていました。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelbuttonsgroup

 
そこで、「ラジオボタンの選択を変えたときにラベルのテキストを切り替える」ような機能を実装してみます。

実際には

  • 公式ドキュメントに従い、 buttonsGroup を使ってラジオボタンをグループ化する
  • addActionListenerラジオボタンにイベントを割り当てる
    • イベントの中でテキストを差し替える

とすれば良さそうです。

 
ちなみに、 radioButton DSLで生成されるオブジェクトは Cell でラップされているため、 イベントの割り当ては

  • component プロパティからラップされていないSwingのオブジェクトを取り出す
  • そのSwingオブジェクトに addActionListener でイベントを割り当てる

という手順になります。

なお、 component プロパティから取り出せることが公式ドキュメントには書かれていないため、これも正しいやり方なのかはわかりません。。

 
では順に実装していきます。

まずは、buttonsGroup の中にラジオボタンを定義します。

lateinit var firstRadio: Cell<JBRadioButton>
lateinit var secondRadio: Cell<JBRadioButton>
var selectedRadioValue = "Default message"
buttonsGroup("Select Radio Button") {
    row {
        firstRadio = radioButton("1st choice", "1st selected")
    }
    row {
        secondRadio = radioButton("2nd choice", "2nd selected")
    }
}.bind( {selectedRadioValue}, { selectedRadioValue = it } ) // bindしないと表示できない

 
次にラベルコンポーネントを用意します。あとでイベントを割り当てられるよう、ラベルコンポーネントlateinit の変数に入れておきます。

lateinit var labelForRadioButton: Cell<JLabel>
row {
    labelForRadioButton = label(selectedRadioValue)
}

 
続いて、 ActionListener を使って、イベント発火時の処理を記載します。

今回は、選択したラジオボタンのテキストをラベルに割り当てます。

val myListener = ActionListener { e ->
    val rb = e.source as JBRadioButton
    labelForRadioButton.component.text = rb.text
}

 
最後にラジオボタンcomponent プロパティでラジオボタンオブジェクトを取り出して、 addActionListener でイベントを割り当てます。

// Cell<JBRadioButton> 型なので、 component を使って <> の中を取り出す
firstRadio.component.addActionListener(myListener)
secondRadio.component.addActionListener(myListener)

 
実装が終わったので動作確認します。

最初はデフォルトの文字列が表示されています。

 
ラジオボタンを選択すると、ラジオボタンのテキストがラベルに表示されます。

 

groupにより、項目をグループ化する

続いて、 group によるグループ化を見てみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelgroup

なお、 group のネストも可能です。

group("My Group Title") {
    row("1st row") {
        textField()
    }
    row("2nd row") {
        textField()
    }
}

group("Root Group") {
    row("Root row") { textField() }

    group("Nested Group") {
        row("Nested row") { textField() }
    }
}

 
タイトル付きでグループ化されました。また、ネストしたグループも表示されています。

 

groupRowsRangeで、グループ単位の有効/無効・表示/非表示を切り替える

groupRowsRange を使うことで、「checkBoxkによる有効/無効や表示/非表示の切り替え」をグループ単位でも行なえます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelgrouprowsrange

lateinit var groupCheckbox: com.intellij.ui.dsl.builder.Cell<JBCheckBox>
row {
    groupCheckbox = checkBox("Checkbox for groups")
}
groupRowsRange {
    group("1st Group") {
        row("1st row") { textField() }
    }
    group("2nd Group") {
        row("2nd row") { textField() }
    }
}.visibleIf(groupCheckbox.selected)

 
チェックが無い場合、グループは表示されません。

 
チェックを入れると、グループが表示されるようになりました。

 

collapsibleGroupで開閉できるグループを作る

必要に応じて開閉したいグループを作りたい時は、 collapsibleGroup が使えます。 デフォルトだと最初は閉じた状態になります。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelcollapsiblegroup

もし、最初から開きたい場合は、 expanded = true を設定すれば良いようです。ドキュメントやサンプルコードでは事例を見つけられなかったので、これが正しいやり方かは不明ですが。。

// デフォルトだと、最初は閉じている
collapsibleGroup("Collapsible Group") {
    group("My Group") {
        row("My row") { textField() }
    }
}

// 最初から開いておく
collapsibleGroup("Collapsible Open Group") {
    group("Open Group") {
        row("Open row") { textField() }
    }
}.expanded = true

 
上は閉じていて、下は開いています。

 

Cell.commentでコメントを追加する

ラベル以外で文字列を表示したい場合、 Cell.comment が使えます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#cellcomment

row {
    label("With comment").comment("My comment")
}

row {
    // .text() で textField のデフォルト値を設定する
    textField().comment("Text field comment").text("text property")
}

row("Row args") {
    textField().comment("Text field comment").text("text property")
}

row {
    label("Label args")
    textField().comment("Text field comment").text("text property")
}

 
comment では、コメントの追加対象のコンポーネントの下に、定義したコメントが表示されるようです。

 

Kotlin UI DSL Version 2で、コンポーネントのレイアウトを調整する

ここまででコンポーネントの定義を見てきました。

ここからは、コンポーネントの表示位置などのレイアウトを調整する方法を見ていきます。

 

Row.topGap/bottomGapでコンポーネントの上の余白を調整する

上下の余白を調整したい場合、 topGapbottomGap で調整できます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowtopgapbottomgap

設定可能な値は

  • None (何も設定しない)
  • SMALL
  • MEDIUM

の3種類あるので、それぞれ見てみます。

row {
    label("Default top gap")
}

row {
    label("Small top gap")
}.topGap(TopGap.SMALL)

row {
    label("Medium top gap")
}.topGap(TopGap.MEDIUM)

 
前のコンポーネントとの余白がそれぞれ調整されています。

 

Cell.alignでCellの中のコンポーネントの位置を調整する

Cellの中のコンポーネントの表示位置を調整するには align が使えます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#cell-align

以下の例では、ラベルの表示位置を調整しています。

row {
    label("With alignY-Top").align(AlignY.TOP)
    textField()
}

row {
    label("With alignX and alignY").align(AlignX.RIGHT).align(AlignY.BOTTOM)
    textField()
}

 
ラベルの縦の表示位置が

  • AlignY.TOP は上揃え
  • AlignY.BOTTOM は下揃え

になっています。

 

Cell.resizableColumn で水平方向の余白を自動調整する

resizableColumn の有無による表示差異を見てみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#cellresizablecolumn

row("Without Resizable Column") {
    textField()
    link("My link") {}
}
row("With Resizable Column") {
    textField().resizableColumn()
    link("My link") {}
}

 
表示してみると、「MyLink」の表示位置が調整されていました。

また、ToolWindowの横幅を変えてみると、「MyLink」の位置がいい感じに調整されます。

 

Cell.gap で、同じ行のコンポーネント間の余白を調整する

余白の幅は

  • 指定しない
  • SMALL
  • COLUMNS

の3つを指定できそうだったため、それぞれためしてみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#cellgap

row {
    label("Gap nothing")
    textField()
}

row {
    label("Gap small__").gap(RightGap.SMALL)
    textField()
}

row {
    label("Gap columns").gap(RightGap.COLUMNS)
    textField()
}

 
SMALL よりも何も指定しないほうが、横の余白が広くなるようです。

 

Row.layoutによる表示位置の調整

ここでは、 RowLayout 定数ごとに、

  • Rowの引数がある時
  • Rowの中にlabelがある時

の表示差異を確認してみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowlayout

row("Row args") {
    textField().text("default")
}

row("Row args") {
    textField().text("independent")
}.layout(RowLayout.INDEPENDENT)

row("Row args") {
    textField().text("label aligned")
}.layout(RowLayout.LABEL_ALIGNED)

row("Row args") {
    textField().text("parent grid")
}.layout(RowLayout.PARENT_GRID)

// rowに引数はないが、labelコンポーネントがある場合
row {
    label("Label args")
    textField().text("default")
}

row {
    label("Label args")
    textField().text("independent")
}.layout(RowLayout.INDEPENDENT)

row {
    label("Label args")
    textField().text("label aligned")
}.layout(RowLayout.LABEL_ALIGNED)

row {
    label("Label args")
    textField().text("parent grid")
}.layout(RowLayout.PARENT_GRID)

 
以下のように調整されました。今回の場合、 INDEPENDENT による調整が一番わかりやすいです。

より良い例があるかもしれませんが、今回はいったんここまでにします。

 

Cell.labelの第2引数で、ラベルの表示位置を調整する

Cell.label では、ラベルのテキストの他、第2引数でラベルの表示位置を調整できます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#celllabel

実際に、第2引数の値を変えてみて、どのように表示が変わるか見てみます。

row {
    textField().label("Cell label")
}
row {
    textField().label("Cell label", LabelPosition.TOP)
}
row {
    textField().label("Cell label", LabelPosition.LEFT)
}

 
LabelPosition.TOP を指定した時だけ、続くコンポーネントの上にラベルが表示されました。

 

textFieldの横幅を columns で調整する

ドキュメントには記載されていませんでしたが、入力補完を使っていたときに気づきました。

columns には引数の値として

  • COLUMNS_TINY
  • COLUMNS_SHORT
  • COLUMNS_MEDIUM
  • COLUMNS_LARGE

が取れたので、それぞれためしてみます。

row {
    textField().columns(COLUMNS_TINY)
}
row {
    textField().columns(COLUMNS_SHORT)
}
row {
    textField().columns(COLUMNS_MEDIUM)
}
row {
    textField().columns(COLUMNS_LARGE)
}

 
columsに指定した値ごとに、TextBoxの幅が異なりました。

 

参考資料

Githubの uiDslShowcase

Githubの以下のディレクトリの下に、色々なサンプルコードがありました。
https://github.com/JetBrains/intellij-community/tree/idea/233.14475.28/platform/platform-impl/src/com/intellij/internal/ui/uiDslShowcase

 

リポジトリ intellij-sdk-code-samples

この中には、IntelliJ Platform Plugin SDKのサンプルコードがあります。
https://github.com/JetBrains/intellij-sdk-code-samples

例えば、ToolWindowのサンプルコードはこちらです。
https://github.com/JetBrains/intellij-sdk-code-samples/tree/main/tool_window

ただ、中にはJavaで書かれているサンプルコードもあるので、Kotlinへと変換する必要があります。

 

JetBrains の Platform Blog

深く読んでいませんが、参考になる記事があるかも。
JetBrains Platform | The JetBrains Platform Blog

 

Swingまわり

昔からある機能なので、Web上にもいろいろ記事がありました。

 

ソースコード

Githubにあげました。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin/pull/10

IntelliJ Platform Plugin Templateを使って、「エディタのコンテキストメニューからダイアログを表示する」だけのJetBrains系IDEプラグインを作ってみた

JetBrains系IDEを使って日常的にコードを書いていますが、ふとJetBrains系IDEプラグインを作ってみたくなりました。

とはいえ、JetBrains系IDEプラグインの作り方がよく分からなかったので調べてみたところ、 IntelliJ Platform Plugin Template というリポジトリを使うと簡単に作成できそうだと分かりました。
https://github.com/JetBrains/intellij-platform-plugin-template

そこで、IntelliJ Platform Plugin Template を使って、「コンテキストメニューからダイアログを表示する」だけのHello world的なJetBrains系IDEプラグインを作ってみたことから、メモを残します。

 
目次

 

環境

  • Windows11
    • WSL2上ではなく、Windows11上で開発します
  • IntelliJ IDEA 2023.3.4 Ultimate Edition

なお、ローカルではJava/Kotlinを使った開発をしていないことから、Javaまわりは何もインストールしていない状態でした。

 

IntelliJ Platform Plugin Templateを元にした環境構築

まずは、READMEのGetting startedの内容から始めます。
https://github.com/JetBrains/intellij-platform-plugin-template?tab=readme-ov-file#getting-started

最初にリポジトリUse this template ボタンをクリックし、プラグイン名を hello_jetbrains_plugin とするなど、必要な事項を入力して自分のリポジトリへとcloneしました。

できあがったリポジトリは以下です。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin

 
続いて、 IntelliJ Platform Plugin Template のREADMEに従い、 Get from VCS 機能でソースコードをローカルに clone & 開きます。

今までこの機能を使ったことがなかったのですが、簡単にcloneとセットアップができました。

 
引き続きREADMEに従い、WindowsJavaSDKをインストールします。

バージョンとベンダーを選べますが、ひとまず

  • Java17
  • ベンダーは JetBrains Runtime version 17.0.9
    • 深い意味はなく、とりあえずJetBrainsが提供しているものにしてみました

を選んでおきました。

 
この時点でプラグインを起動できるため、 Run Plugin を実行してみます。

すると、プラグインがインストールされた状態でIntelliJ IDEAが起動しました。

 

コンテキストメニューからダイアログを開く」機能を追加する

IntelliJ Platform Plugin TemplateのREADMEにはプラグインの作り方は記載されていなかったので、別のドキュメントを探すことにしました。

まず、 IntelliJ Platform SDK のドキュメントを見に行きましたが、量に圧倒されてしまいました。
IntelliJ Platform SDK | IntelliJ Platform Plugin SDK

 
そこで、まずは簡単なプラグインを作るところから始めようということで、チュートリアル的に書かれている以下のブログを読みました。

 
上記のブログを読んだところ、「エディタコンテキストメニューからダイアログを表示する」プラグインを作るのが最初の一歩として良さそうに感じましたので、作っていきます。

 
まずは、公式SDKドキュメントに

The action implementation determines the contexts in which an action is available, and its functionality when selected in the UI.

 
https://plugins.jetbrains.com/docs/intellij/basic-action-system.html

とあるような、アクションと呼ばれる機能を作成してみます。

そこで、 src/main/kotlin/com/github/thinkami/hellojetbrainsplugin/actions/HelloAction.kt ファイルを作成し、 AnAction を継承したActionのクラスを実装します。

ダイアログの表示は Messages.showMessageDialog を使うことで実現できます。

package com.github.thinkami.hellojetbrainsplugin.actions

import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.ui.Messages

class HelloAction: AnAction() {
    override fun actionPerformed(e: AnActionEvent) {
        Messages.showMessageDialog(buildString {
            append("ハロー")
        }, "ワールド", null)
    }
}

 
続いて、

Registration determines where an action appears in the IDE UI. Once implemented and registered, an action receives callbacks from the IntelliJ Platform in response to user gestures.

 
https://plugins.jetbrains.com/docs/intellij/basic-action-system.html

のように、アクションを呼び出す方法を定義します。

今回は「エディタのコンテキストメニューから上記のActionを起動する」設定を定義します。

そこで、 src/main/resources/META-INF/plugin.xml を開き、末尾にActionの呼び出しを追加します。

<idea-plugin>
    ...
    </applicationListeners>

    <actions>
        <action id="com.github.thinkami.hellojetbrainsplugin.actions.HelloAction"
                class="com.github.thinkami.hellojetbrainsplugin.actions.HelloAction"
                text="Hello Action"
                description="hello world action">
            <add-to-group group-id="EditorPopupMenu" anchor="first" />
        </action>
    </actions>
</idea-plugin>

 

動作確認

先ほどと同様、 Run Plugin を実行し、IntelliJ IDEAを起動します。

今回の機能はエディタでコンテキストメニューを開く必要があるため、適当なKotlinプロジェクトを探してみます。

すると、 Kotlin Programming Tutorial for Beginners と書かれているリポジトリがありました。
https://github.com/smartherd/KotlinTutorial

今回はこれを使うことにして、 Get from VCS 機能にてローカルにclone・プロジェクトを開きます。

 
適当なkotlinのファイルを開き、エディタ上で右クリックしてコンテキストメニューを表示したところ、 Hello Action というメニューがありました。

 
このメニューをクリックしたところ、Hello world的なダイアログが表示されました。

 
以上より、今回やりたかったことは実現できました。

 

ソースコード

Githubにあげました。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin/pull/9

 

Github Actionsの様子

ちなみに、プルリクを作ったところ、IntelliJ Platform Plugin Template に設定されていたようで、Github ActionsによるCIが自動実行されました。

 
最終的にはこんな感じになりました。

Railsにて、1つのテーブルの複数列で同じテーブルを参照し、参照先を主キーもしくは主キー以外の属性としたい場合に、モデルやマイグレーションの定義方法を調べてみた

Railsを使っている中で、記事のタイトルのようなことがしたくなりました。

例えば、以下のようなことがしたくなりました。

  • applesとcolorsという2テーブルがある
  • applesテーブルには以下の3列があり、いずれもcolorsと関連を持たせたい
    • 列について
      • 果実の色 (fruit_color)
      • 花の色 (flower_color)
      • 葉の色 (leaf_color)
    • 関連について
      • fruit_colorflower_color は、どちらも colors.id に対して外部キー制約をつけたい
      • leaf_color について、列名は leaf_color_name 、参照先は colors.name にしたい
        • 無理に外部キー制約を付けなくても良い

 
そこで、マイグレーション・モデルにどのような設定をすればよいか調べたため、メモを残します。

 
目次

 

環境

 

複数の列の外部キー制約を、同じテーブルに対して行う

前述の例で言えば、「applesテーブルの fruit_colorflower_color の各列から、 colors.id に対して外部キー制約を付けたい」を行いたいときのマイグレーションとモデルの定義を確認します。

 

マイグレーションの定義

今回は一度に定義するのではなく、各ステップごとにマイグレーションを用意します。

まずは apples テーブルがあるとします。

class CreateApples < ActiveRecord::Migration[7.1]
  def change
    create_table :apples do |t|
      t.string :name

      t.timestamps
    end
  end
end

 
次に、 colors テーブルを用意します。

class CreateColors < ActiveRecord::Migration[7.1]
  def change
    create_table :colors do |t|
      t.string :name

      t.timestamps
    end
  end
end

 
続いて、 fruit_color から apples テーブルへの外部キー制約を追加します。

今回は add_reference を使って

な感じで定義します。

class AddColumnToApple < ActiveRecord::Migration[7.1]
  def change
    add_reference :apples, :fruit_color, foreign_key: { to_table: :colors }
  end
end

 
もう一つの列も同様に定義します。

class AddFlowerColorColumnToApple < ActiveRecord::Migration[7.1]
  def change
    add_reference :apples, :flower_color, foreign_key: { to_table: :colors }
  end
end

 

モデルの定義

続いて、モデルに関連付けを定義します。

今回は、関連付け名はデフォルトではなく、 fruit_colorflower_color にしたいことから、 belongs_to のオプション class_nameforeign_key を使って定義します。

class Apple < ApplicationRecord
  # 各リレーションを分かりやすくするため、belongs_to で別名を付けて、 class_name で関連先のモデル名を指定している
  belongs_to :fruit_color, class_name: 'Color', foreign_key: 'fruit_color_id'
  belongs_to :flower_color, class_name: 'Color', foreign_key: 'flower_color_id'
end

 

動作確認

以上でマイグレーションとモデルの定義ができました。

そこで、テストコード (model spec + factory_bot) を書いて動作確認します。

まずはテストデータです。

RSpec.describe Apple, type: :model do
  let!(:yellow_color) { create(:color, :yellow_color) }
  let!(:white_color) { create(:color, :white_color) }

  let!(:shinano_gold) { create(:apple,
                               name: 'シナノゴールド',
                               fruit_color: yellow_color,
                               flower_color: white_color)}
end

 
次に、 apples から colors をたどれるか確認します。今回は、 apples から colors.name を取得できるか確認します。

また、取得方法も

  • ドット(関連付け)で取得
  • eager_load で取得
  • joins + select で取得

のパターンをためせるテストコードを書きます。

describe 'fruit_color' do
  it 'ドットで取得できること' do
    p Apple.find_by(name: 'シナノゴールド').fruit_color.name
    actual = Apple.find_by(name: 'シナノゴールド').fruit_color.name
    expect(actual).to eq('')
  end

  it 'eager_load + ドットで取得できること' do
    actual = Apple.eager_load(:fruit_color).where(name: 'シナノゴールド' ).first.fruit_color.name
    expect(actual).to eq('')
  end

  it 'joins + select で取得できること' do
    actual = Apple.joins(:fruit_color).where(name: 'シナノゴールド').select('colors.name').first
    expect(actual.name).to eq('')
  end
end

describe 'flower_color' do
  it 'ドットで取得できること' do
    actual = Apple.find_by(name: 'シナノゴールド').flower_color.name
    expect(actual).to eq('')
  end

  it 'eager_load + ドットで取得できること' do
    actual = Apple.eager_load(:flower_color).where(name: 'シナノゴールド' ).first.flower_color.name
    expect(actual).to eq('')
  end

  it 'joins + select で取得できること' do
    actual = Apple.joins(:flower_color).where(name: 'シナノゴールド').select('colors.name').first
    expect(actual.name).to eq('')
  end
end

 
テストコードを実行してみると、いずれのテストもパスしました。

 

外部キー制約の参照先を主キー以外の列にも指定できるか

ここまでで、複数の列で同じテーブルを参照するときの外部キー制約をためしてみました。

ただ、データベースによっては外部キー制約を主キー以外にも設定できます。

そこで、Railsの場合にはどのように設定するかを調べてみました。

 

調査

マイグレーションでは「生SQLを書く」以外の方法が分からず

Rails APIのドキュメントを見ながら、主キー以外の列を参照する外部キー制約が付けられるか試してみました。

 
しかし、主キーを参照する前提のようだったため、Rails API を使って記述することはできませんでした。

SQLを書けばいけるかもしれませんが、レールを外れそうだったのと、データベースによってはうまく動作しないようでした。

例えばMySQLの場合、

UNIQUE でないキーを参照する FOREIGN KEY 制約は、標準 SQL ではなく InnoDB拡張機能です。 一方、NDB ストレージエンジンでは、外部キーとして参照される任意のカラムに明示的な一意キー (または主キー) が必要です。

 

一意でないキーまたは NULL 値を含むキーへの外部キー参照の処理は、UPDATE や DELETE CASCADE などの操作に対して適切に定義されていません。 UNIQUE (PRIMARY を含む) および NOT NULL キーのみを参照する外部キーを使用することをお勧めします。

 
https://dev.mysql.com/doc/refman/8.0/ja/ansi-diff-foreign-keys.html

との記載があります。

 
そのため、今回マイグレーションで外部キー制約を付与する、つまりデータベースレイヤでデータを保護するのは諦めました。

 

モデルで belongs_to を使って、外部キー制約なしの関連付けする

前述の通りデータベースレイヤでは諦めましたが、Railsレイヤで行えることがあるかもしれないと思い、調べてみました。

すると、モデルで belongs_to + class_name + foreign_key + primary_key を使えば関連付けができそうでした。

 
気になるのは、「primary_key に主キー以外の項目を設定してもよいのか」ですが、以下の記事やソースコードを見るとうまく動きそうな気がします。

 

実装

では実際にためしてみます。

まずはマイグレーションで、 apples テーブルに string 型の leaf_color_name 列を追加します。

class AddLeafColorColumnToApple < ActiveRecord::Migration[7.1]
  def change
    add_column :apples, :leaf_color_name, :string
  end
end

 
続いて、モデル Apple で、 belongs_to

  • foreign_key に、Appleの属性である leaf_color_name を指定
  • primary_key に、参照先のColorの属性である name を指定

の各オプションを渡して関連付けを定義します。

class Apple < ApplicationRecord
  # name列同士の関連付けをもたせる
  belongs_to :leaf_color, class_name: 'Color', foreign_key: 'leaf_color_name', primary_key: 'name'
end

 

動作確認

では、先ほどの外部キー制約があるときと同様、テストコードを書いて動作を確認してみます。

以下のテストコードを書いて実行したところ、いずれもテストがパスしました。

describe 'leaf_color_name' do
  it 'ドットで取得できること' do
    actual = Apple.find_by(name: 'シナノゴールド').leaf_color.name
    expect(actual).to eq('')
  end

  it 'eager_load + ドットで取得できること' do
    actual = Apple.eager_load(:leaf_color).where(name: 'シナノゴールド' ).first.leaf_color.name
    expect(actual).to eq('')
  end

  it 'joins + select で取得できること' do
    actual = Apple.joins(:leaf_color).where(name: 'シナノゴールド').select('colors.name').first
    expect(actual.name).to eq('')
  end

  it 'joins + pluck で取得できること' do
    actual = Apple.joins(:leaf_color).where(name: 'シナノゴールド').pluck('colors.name').first
    expect(actual).to eq('')
  end
end

 

実際に発行されるSQLを確認

テストはパスしたものの、実際に発行されるSQLのJOINの条件が気になりました。

そこで、JOINが発生する

  • eager_load
  • joins

の各メソッドにて、実際に発行されるSQLを確認してみます。

 

eager_loadのときのSQL

LEFT OUTER JOIN の ON 句で "colors"."name" = "apples"."leaf_color_name" となっていました。

SELECT
  "apples"."id" AS t0_r0,
  "apples"."name" AS t0_r1,
  "apples"."created_at" AS t0_r2,
  "apples"."updated_at" AS t0_r3,
  "apples"."fruit_color_id" AS t0_r4,
  "apples"."flower_color_id" AS t0_r5,
  "apples"."leaf_color_name" AS t0_r6,
  "colors"."id" AS t1_r0,
  "colors"."name" AS t1_r1,
  "colors"."created_at" AS t1_r2,
  "colors"."updated_at" AS t1_r3
FROM
  "apples"
  LEFT OUTER JOIN "colors" ON "colors"."name" = "apples"."leaf_color_name"
WHERE
  "apples"."name" IN ("name", "シナノゴールド")
ORDER BY
  "apples"."id" ASC
LIMIT
  1

 

joinsのときのSQL

こちらも、INNER JOIN の ON 句で "colors"."name" = "apples"."leaf_color_name" となっていました。

SELECT
  "colors"."name"
FROM
  "apples"
  INNER JOIN "colors" ON "colors"."name" = "apples"."leaf_color_name"
WHERE
  "apples"."name" IN ("name", "シナノゴールド")
ORDER BY
  "apples"."id" ASC
LIMIT
  1

 
以上より、主キー以外の属性でも関連付けができました。

 

SQLのJOINが発生するメソッド + select ('*') したときの挙動を確認

ところで本題とはズレるのですが、join系メソッドを調べていた時に以下の記事とGithubへのリンクを見つけました。

 
そのissueやプルリクはまだcloseしていなかったため、Rails 7.1系ではどのような結果になるか試してみたくなりました。

そこでテストコードを書いてみたところ、いずれもパスしました。

describe "applesとcolorsでname列が重複しているときの join系 + select('*')の挙動" do
  context 'eager_loadの時' do
    it 'apple.nameを期待したいが、color.nameになっていること' do
      actual = Apple.eager_load(:fruit_color).where(name: 'シナノゴールド' ).select('*').first

      expect(actual.name).not_to eq('シナノゴールド')
      expect(actual.name).to eq('')
    end
  end

  context 'left_joinsの時' do
    it 'apple.nameを期待したいが、color.nameになっていること' do
      actual = Apple.left_joins(:fruit_color).where(name: 'シナノゴールド' ).select('*').first

      expect(actual.name).not_to eq('シナノゴールド')
      expect(actual.name).to eq('')
    end
  end

  context 'joinsの時' do
    it 'apple.nameを期待したいが、color.nameになっていること' do
      actual = Apple.joins(:fruit_color).where(name: 'シナノゴールド' ).select('*').first

      expect(actual.name).not_to eq('シナノゴールド')
      expect(actual.name).to eq('')
    end
  end
end

 
参照した記事にもある通り、 SQLのJOINが発生するメソッド + select('*') を使うことは無いと思いますが、覚えておいたほうが良いかもしれません。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_1_minimal_app

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_7_1_minimal_app/pull/2

Railsにて、トランザクションの中だとモデルのid列はいつ設定されるのか確認してみた

Railsを使っている中で、「トランザクションの中だとモデルのid列はいつ設定されるのか」が気になったことから、確認してみたときのメモを残します。

目次

 

環境

  • Rails 7.1.3
  • DBはデフォルトのSQLite3

 
また、今回は以下のモデルとマイグレーションを用意します。

apple.rb

class Apple < ApplicationRecord
end

 
モデルに対するマイグレーションファイルはこちら。

class CreateApples < ActiveRecord::Migration[7.1]
  def change
    create_table :apples do |t|
      t.string :name

      t.timestamps
    end
  end
end

 

動作確認

上記のAppleモデルに対して、

の各タイミングで、モデルオブジェクトの id がどのように設定されるかを確認します。

まずは以下の rake task を作成します。

namespace :print_model_id_with_transaction do
  desc 'トランザクション利用時のmodelのidを確認'

  task run: :environment do
    # rake taskだと、`config.active_record.verbose_query_logs = true` であってもSQLログが出ないので、設定しておく
    # ActiveRecord::Base.logger = Logger.new(STDOUT)
    # Rails.logger.level = Logger::DEBUG

    ActiveRecord::Base.transaction do
      apple = Apple.build(name: 'シナノゴールド')
      puts "step1: plan.id => #{apple.id}"
      apple.save
      puts "step2: plan.id => #{apple.id}"

      raise ActiveRecord::Rollback
    end

    apple_without_transaction = Apple.build(name: '奥州ロマン')
    puts "step3: plan.id => #{apple_without_transaction.id}"
    apple_without_transaction.save
    puts "step4: plan.id => #{apple_without_transaction.id}"
  end
end

 
続いて rake task を実行したところ

$ bin/rails print_model_id_with_transaction:run
step1: plan.id => 
step2: plan.id => 1
step3: plan.id => 
step4: plan.id => 1

となりました。

これより、

という挙動と分かりました。トランザクションの中と外で設定されるタイミングは同じなようです。

 
次に、実際に発行されるSQLも見てみます。

今回は rake task で動作確認をしているため、task の先頭に以下を追加します。
rakeタスクでクエリのログを標準出力に出す | このコードわからん

task run: :environment do
  # 追加
  ActiveRecord::Base.logger = Logger.new(STDOUT)
  Rails.logger.level = Logger::DEBUG

  # 以下同じ
  ActiveRecord::Base.transaction do
  ...

 
再度 rake task を実行すると、以下のような感じで表示されました(横長になったので、タイムスタンプ部分を削除しています)。

トランザクションの中では、 commit transaction がなくてもモデルの id 列に値が設定されるようです。

$ bin/rails print_model_id_with_transaction:run
D, :   TRANSACTION (0.1ms)  begin transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:10:in `block (3 levels) in <top (required)>'
step1: plan.id => 
D, :   Apple Create (0.2ms)  INSERT INTO "apples" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"  [["name", "シナノゴールド"], ["creat "2024-02-17 11:22:40.829966"], ["updated_at", "2024-02-17 11:22:40.829966"]]
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:12:in `block (3 levels) in <top (required)>'
step2: plan.id => 2
D, :   TRANSACTION (0.1ms)  rollback transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:9:in `block (2 levels) in <top (required)>'
step3: plan.id => 
D, :   TRANSACTION (0.1ms)  begin transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:20:in `block (2 levels) in <top (required)>'
D, :   Apple Create (1.0ms)  INSERT INTO "apples" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"  [["name", "奥州ロマン"], ["created_a2024-02-17 11:49:59.591712"], ["updated_at", "2024-02-17 11:49:59.591712"]]
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:20:in `block (2 levels) in <top (required)>'
D, :   TRANSACTION (7.3ms)  commit transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:20:in `block (2 levels) in <top (required)>'
step4: plan.id => 2

 

余談:Railsのバージョンにより、トランザクション内での return 等の挙動が変わる

本題とは関係ない内容です。

Railsトランザクションまわりを調べていたところ、Railsのバージョンによりトランザクションの挙動が変わると知りました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_1_minimal_app

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_7_1_minimal_app/pull/1

書籍「Solving Identity Management in Modern Applications - 2nd edition」を読みました

前回、SAML2のSP-initiated フローをためしてみました。
Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみた - メモ的な思考的な

書籍「SAML入門 - かなめりぜ」を読んだり手を動かしたりして、なんとなくSAML2への理解が進んだような気がしました。

 
ただ、もう少し理解を勧めたいと考えて書籍を探したところ、「Solving Identity Management in Modern Applications - 2nd edition」がありました。
Solving Identity Management in Modern Applications: Demystifying OAuth 2, OpenID Connect, and SAML 2 | SpringerLink

1st editionは2019年に出版され、今回読んだ 2nd edition は2022年に出版されたようでした。

SAML2について書かれた本は貴重なため、読んでみました。

 

書籍の概要

SAML2に特化した内容というよりは、目次にある通り

  • 認証認可まわりフレームワークO
    • Auth2やOIDC、SMAL2
  • 権限管理
  • セッション管理
  • ログイン
  • ログアウト
  • アカウント管理
  • アカウントの失効

など、IDまわりの開発~運用まで一通りふれられていました。

また、OAuth2.1 draft などについても書かれており、最新の状況に追随している印象を受けました。

 
一方、ID管理まわりの技術に対するサンプルコードやライブラリ・製品の紹介などがほとんどありませんでした。

そのため、もっと実装寄りのことを学ぶには

  1. この書籍でID管理まわりのキーワードを拾う
  2. 別の資料を読んだり、自分で実装を試してみる

という流れが必要そうと感じました。

 

SAML2 の記載について

この書籍を読む目的である、SAML2に関する情報については

  • 「7. SAML2」
  • 「Back Matter」の中の APPENDIX C SAML2 AUTHENTICATION REQUEST AND RESPONSE

に記載されていました。

以降では、それぞれの内容を軽くメモしておきます。

 

7. SAML2 について

SAML入門同様、SAML認証の各フローやシングルサインオンについて記載されていました。

また、SAMLに関係する内容として、

  • Identity Federation
  • Authentication Brokers

についてもふれられていました。

 
個人的には、 Authentication Brokers について知らなかったので、ためになりました。

ちなみに、読書後に Authentication Brokers についてWebで調べてみたところ、Keycloakの Identity Broker 機能のような印象を受けています。
KeycloakでSAMLログインをテストしたい #テスト - Qiita

 

APPENDIX Cについて

SAML入門と比較すると以下のような感じでした。

  • C1. SAML2 AUTHENTICATION REQUEST
    • SAML入門の「5章 やりとりする内容」あたり
  • C2. SAML2 AUTHENTICATION RESPONSE
    • SAML入門の「5章 やりとりする内容」あたり
  • C3. VALIDATION
    • SAML入門 p55 の 「署名以外にチェックする項目」あたり

 
そのため、「SAML入門でも十分だけど、補助的な資料としてここを読む」ような感じで良さそうでした。

 

おわりに

サンプルコードが無いこともあり、この本だけで各ID管理技術を実装するのは難しく感じる一方で、ID管理の全体像を知ることができて有意義でした。

今回はSAML2を知るために読みましたが、他のID管理技術の内容をざっくりつかみたい時は、また読んでみようと思いました。