Rails + RSpecで、メモ化しているメソッドをモックしたら RSpec::Mocks::ExpiredTestDoubleError になったので、調べてみた

Rubyでメモ化したい時、 ||= を使って書くことがあります *1

以下のコードであれば、クラスメソッド twitter を使って、クラスインスタンス変数 @twitterApi::External::Twitter.newインスタンスを設定・メモ化しています。

class Api::External::Client
  def self.twitter
    @twitter ||= Api::External::Twitter.new
  end
end

 
そんな中、メモ化しているメソッドをモックしたテストコードを流したところ RSpec::Mocks::ExpiredTestDoubleError でテストが落ちたため、対応方法をメモしておきます。

 
目次

 

環境

 

エラーの再現手順

プロダクションコード

今回はRailsアプリを作って再現をしてみます。

 
まず、TwitterAPIからツイートやリツイートを取得するようなAPIクライアントがあったとします。

また、ここでは実装していませんが、このAPIクライアントを生成(new)する際は色々手間がかかっているものとします。

class Api::External::Twitter
  def tweet
    'foo'
  end

  def retweet
    'bar'
  end
end

 
次に、外部クライアントを取りまとめているクラスがあり、ここの中で各クライアントをインスタンス化しているとします。

上記で書いた通り、Twitterクライアントの生成が大変なので、今回はここのクラスメソッド twitter() でメモ化をしています。

class Api::External::Client
  def self.twitter
    @twitter ||= Api::External::Twitter.new
  end
end

 
これらのクライアントライブラリを使い、RailsアプリでTwitterからツイートやリツイートを取得して返すようなコントローラを作ったとします。

(tweetがindex、showがretweetに割り当てられているのは特に理由はありません。説明の都合上、1つのコントローラで別のメソッドを実装したかっただけです)

class Api::Memorization::TweetsController < ApplicationController
  def index
    render json: { tweet: Api::External::Client.twitter.tweet }
  end

  def show
    render json: { retweet: Api::External::Client.twitter.retweet }
  end
end

 
routes.rbはこんな感じです。

Rails.application.routes.draw do
  namespace :api do
    namespace :memorization do
      resources :tweets, only: [:index, :show]
    end
  end
end

 
Railsアプリを起動しcurlでアクセスしたところ、こんな感じのレスポンスになります。

% curl http://localhost:3000//api/memorization/tweets
{"tweet":"foo"}

% curl http://localhost:3000//api/memorization/tweets/1
{"retweet":"bar"}

 

テストコード

ここまでで正しく動いていそうなことは確認できたため、次はテストコードを書いてみます。

ただ、プロダクションコードではTwitterから直接ツイート・リツイートを取得している想定なため、そのまま使うと毎回Twitter APIを呼び出してしまうことになります。

そこで、ツイート・リツートしているメソッドをモックで差し替えてみます。

 
まずはツイートを取得するメソッドのテストコードを書きます。

require 'rails_helper'

RSpec.describe 'Api::Memorization::TweetsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe '[ツイート]: GET /api/memorization/tweets' do
    before do
      # tweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:tweet).and_return('hello')
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets'

      expect(response_body).to eq({ 'tweet' => 'hello' })
    end
  end
end

 
テストを実行するとパスします。

% bundle exec rspec spec/requests/api/memorization/tweets_controller_spec.rb --example '[ツイート]'
Run options: include {:full_description=>/\[ツイート\]/}
.

Finished in 0.0565 seconds (files took 1.13 seconds to load)
1 example, 0 failures

 
続いて、リツイートの方もテストコードを書きます。

describe '[リツイート]: GET /api/memorization/tweets/1' do
  before do
    # retweetメソッドを差し替え
    twitter = instance_double(Api::External::Twitter)
    allow(Api::External::Twitter).to receive(:new).and_return(twitter)
    allow(twitter).to receive(:retweet).and_return('bye')
  end

  it 'レスポンスが返ってくる' do
    get '/api/memorization/tweets/1'

    expect(response_body).to eq({ 'retweet' => 'bye' })
  end
end

 
作成したテストコードを流してみると、こちらもパスしました。

% bundle exec rspec spec/requests/api/memorization/tweets_controller_spec.rb --example '[リツイート]'

Run options: include {:full_description=>/\[リツイート\]/}
.

Finished in 0.04326 seconds (files took 0.95845 seconds to load)
1 example, 0 failures

 
両方ともパスしたため、最後にすべてのテストコードを実行してみます。

すると、2つ目のテストが失敗してしまいました。

% bundle exec rspec spec/requests/api/memorization/tweets_controller_spec.rb                         
.F

Failures:

  1) Api::Memorization::TweetsController [リツイート]: GET /api/memorization/tweets/1 レスポンスが返ってくる
     Failure/Error: render json: { retweet: Api::External::Client.twitter.retweet }
       #<InstanceDouble(Api::External::Twitter) (anonymous)> was originally created in one example but has leaked into another example and can no longer be used. rspec-mocks' doubles are designed to only last for one example, and you need to create a new one in each example you wish to use it for.

 
RubyMineでの実行結果では、冒頭に以下のメッセージが表示されました。 RSpec::Mocks::ExpiredTestDoubleError のようです。

RSpec::Mocks::ExpiredTestDoubleError: #<InstanceDouble(Api::External::Twitter) (anonymous)> was originally created in one example but has leaked into another example and can no longer be used. rspec-mocks' doubles are designed to only last for one example, and you need to create a new one in each example you wish to use it for.

 

調査

エラーメッセージに

rspec-mocks' doubles are designed to only last for one example, and you need to create a new one in each example you wish to use it for.

とあったことから、メモ化している部分が怪しいと感じました。

 
次に似た事例がないかを調べたところ、以下が見つかりました。どちらもメモ化しているメソッドをモックした時に発生しています。

 

そのため、メモ化しているメソッドをモック時の方法が良くないものと考えられました。

 

対応

上記の記事にある通り、メソッドのメモ化をとりやめれば、テストがパスするようになります。

ただ、今回はメモ化がどうしても必要という前提の時に、どうやってテストを書くかをみていきます。

 

remove_instance_variableでインスタンス変数を削除する

Blogの方で紹介されていた内容です。

テストコードの after にてRubyremove_instance_variable によりメモ化用のインスタンス変数を取り除きます。
Object#remove_instance_variable (Ruby 3.1 リファレンスマニュアル)

require 'rails_helper'

RSpec.describe 'Api::Memorization::TweetsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe '[ツイート]: GET /api/memorization/tweets' do
    before do
      # tweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:tweet).and_return('hello')
    end

    # このafterを追加
    after do
      Api::External::Client.remove_instance_variable('@twitter')
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets'

      expect(response_body).to eq({ 'tweet' => 'hello' })
    end
  end

  describe '[リツイート]: GET /api/memorization/tweets/1' do
    before do
      # retweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:retweet).and_return('bye')
    end

    # このafterも追加
    after do
      Api::External::Client.remove_instance_variable('@twitter')
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets/1'

      expect(response_body).to eq({ 'retweet' => 'bye' })
    end
  end
end

 
こうするとテストがパスします。

 

instance_variable_setでインスタンス変数にnilを設定する

こちらは stackoverflow にて紹介されていた内容です。

テストコードの after にてRubyinstance_variable_set によりメモ化用のインスタンス変数に nil を設定します。

require 'rails_helper'

RSpec.describe 'Api::Memorization::TweetsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe '[ツイート]: GET /api/memorization/tweets' do
    before do
      # tweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:tweet).and_return('hello')
    end

    # このafterを追加
    after do
      Api::External::Client.instance_variable_set('@twitter', nil)
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets'

      expect(response_body).to eq({ 'tweet' => 'hello' })
    end
  end

  describe '[リツイート]: GET /api/memorization/tweets/1' do
    before do
      # retweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:retweet).and_return('bye')
    end

    # このafterも追加
    after do
      Api::External::Client.instance_variable_set('@twitter', nil)
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets/1'

      expect(response_body).to eq({ 'retweet' => 'bye' })
    end
  end
end

 
この方法でもテストがパスします。

 

メモ化メソッドをモックに差し替える

今回のメモ化メソッドを再掲します。

class Api::External::Client
  def self.twitter
    @twitter ||= Api::External::Twitter.new
  end
end

 

今まではメモ化メソッドの中で使っている Api::External::Twitter.new をモックに差し替えてきました。

ただ、よく見ると今回は Api::External::Twitter.new の結果しかメモ化していない単純な内容のため、メモ化メソッド自体をモックに差し替えることもできそうです。

つまり

allow(Api::External::Twitter).to receive(:new).and_return(twitter)

allow(Api::External::Client).to receive(:twitter).and_return(twitter)

へと差し替えます。

全体像はこちら。

require 'rails_helper'

RSpec.describe 'Api::Memorization::TweetsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe '[ツイート]: GET /api/memorization/tweets' do
    before do
      # tweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Client).to receive(:twitter).and_return(twitter) # 差し替え
      allow(twitter).to receive(:tweet).and_return('hello')
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets'

      expect(response_body).to eq({ 'tweet' => 'hello' })
    end
  end

  describe '[リツイート]: GET /api/memorization/tweets/1' do
    before do
      # retweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Client).to receive(:twitter).and_return(twitter) # 差し替え
      allow(twitter).to receive(:retweet).and_return('bye')
    end

    after do
      Api::External::Client.instance_variable_set('@twitter', nil)
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets/1'

      expect(response_body).to eq({ 'retweet' => 'bye' })
    end
  end
end

 
このように差し替えてもテストがパスしました。

 

参考

メモ化について

 

クラスインスタンス変数について

 

ソースコード

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

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

*1:対象のメソッドが nil を返す可能性がある場合は、完全なメモ化にはなりませんが

React Hook Form 7系と MUI 5系を組み合わせたフォームを作ってみた

React Hook Form 7系とMUI 5系を組み合わせてフォームを作る時に、色々悩んだことがあったのでメモを残します。

 
目次

 

環境

  • React 18.1.0
  • React Hook Form 7.31.3
  • MUI 5.8.2
  • React Router 6.3.0
  • Vite 2.9.9

 

controlled component と uncontrolled component について

Reactでフォームを作るときにはコンポーネントcontrolleduncontrolled のどちらであるかを意識します。

そこで、まずは

  • React
  • MUI
  • React Hook Form

の各ライブラリにおいて、それらをどう扱っているかを見ていきます。

 

Reactのフォームでは

ざっくり書くと

  • controlled component
    • Reactのstateでデータを管理
  • uncontrolled component
    • DOMでデータを管理

となります。

 
Reactの公式ドキュメントでは、controlled component については

HTML では <input><textarea> 、そして <select> のようなフォーム要素は通常、自身で状態を保持しており、ユーザの入力に基づいてそれを更新します。React では、変更されうる状態は通常はコンポーネントの state プロパティに保持され、setState() 関数でのみ更新されます。

React の state を “信頼できる唯一の情報源 (single source of truth)” とすることで、上述の 2 つの状態を結合させることができます。そうすることで、フォームをレンダーしている React コンポーネントが、後続するユーザ入力でフォームで起きることも制御できるようになります。このような方法で React によって値が制御される入力フォーム要素は「制御されたコンポーネント」と呼ばれます。

フォーム要素の value 属性が設定されているので、表示される値は常に this.state.value となり、React の state が信頼できる情報源となります。handleChange はキーストロークごとに実行されて React の state を更新するので、表示される値はユーザがタイプするたびに更新されます。

制御されたコンポーネントを使うと、ユーザ入力の値は常に React の state によって制御されるようになります。これによりタイプするコード量は少し増えますが、その値を他の UI 要素に渡したり、他のイベントハンドラからリセットしたりできるようになります。

https://ja.reactjs.org/docs/forms.html#controlled-components

とあります。

 
一方、 uncontrolled component については

非制御コンポーネント (uncontrolled component) はその代替となるものであり、フォームデータを DOM 自身が扱います。

非制御コンポーネントを記述するには、各 state の更新に対してイベントハンドラを書く代わりに、ref を使用して DOM からフォームの値を取得します。

https://ja.reactjs.org/docs/uncontrolled-components.html

とあります。

 
両者の違いについてはWeb上にいくつも情報があるため、ここでは目についたもののリンクだけにとどめます。

 

MUIでは

MUIでは、controlled component な使い方、uncontrolled component な使い方、どちらでもできるようです。

例えば、 TextField コンポーネントについては以下に記載があります。
Uncontrolled vs. Controlled | Text field React component - Material UI

 

React Hook Formでは

React Hook Formでは、基本的には uncontrolled component を扱うようです。

ただ、React Hook Formにはラッパーコンポーネントがあることから、ラッパーコンポーネントを使えば MUI などの controlled component も簡単に扱えるようです。

React Hook Form embraces uncontrolled components and native inputs, however it's hard to avoid working with external controlled component such as React-Select, AntD and Material-UI. This wrapper component will make it easier for you to work with them.

https://react-hook-form.com/api/usecontroller/controller

 
そこで今回は、React Hook Form のラッパーコンポーネント Controller を使って、React Hook Form 7系と MUI 5系を組み合わせたフォームを作ってみることにします。

 

実装

今回はMUIの

  • TextField
  • Radio
  • Select (TextField select)
  • Check

の各コンポーネントを使って実装してみます。

 

フォームを作る

まずはフォームの全体を作ります。

useForm を使い、 control を取得します。これはReact Hook Formの Controller コンポーネントに渡します。

 
また、入力したフォームの値を確認するため

  • handleSubmit
    • submitボタンが押された時をハンドリングするもの
  • getValues
    • フォームの値を取得するもの

も取得します。

 
あとは、入力した値を保持する時の型として

export type FormInput = {
  name: string  // TextField用
  color: string  // Radio用
  shop: string  // TextField select用
  inStock: boolean // Check用
}

も用意します。

 
フォームの全体像は以下となります。

なお、TextFieldなどを使った各コンポーネントについては、今後実装していきます。

export type FormInput = {
  name: string  // TextField用
  color: string  // Radio用
  shop: string  // TextField select用
  inStock: boolean // Check用
}

const Component = (): JSX.Element => {
  const {control, handleSubmit, getValues} = useForm<FormInput>()

  const printWithData = (data: UnpackNestedValue<FormInput>) => {
    console.log('data による取り出し =========>')

    const {name, color, shop, inStock} = data
    console.log(`name: ${name}`)
    console.log(`color: ${color}`)
    console.log(`shop: ${shop}`)
    console.log(`inStock: ${inStock}`)
  }

  const printByGetValues = () => {
    console.log('getValues() による取り出し =========>')

    const {name, color, shop, inStock} = getValues()
    console.log(`name: ${name}`)
    console.log(`color: ${color}`)
    console.log(`shop: ${shop}`)
    console.log(`inStock: ${inStock}`)
  }

  const onSubmit: SubmitHandler<FormInput> = (data) => {
    // dataオブジェクトを使った取り出し
    printWithData(data)

    // getValues を使った取り出し
    printByGetValues()
  }

  const colors: RadioItem[] = [
    { label: '赤', value: 'red' },
    { label: '黄', value: 'yellow' }
  ]

  const shops: SelectItem[] = [
    { label: 'スーパー', value: 'supermarket' },
    { label: '産直所', value: 'farmersMarket' },
  ]

  return (
    <>
      <Box sx={{ mt: 5 }}>
        <h1>データ登録</h1>
        <form onSubmit={handleSubmit(onSubmit)}>
          <Box sx={{ mt: 5 }}>
            <FruitNameField control={control} />
          </Box>

          <Box sx={{ mt: 5 }}>
            <ColorRadioGroup control={control} items={colors} />
          </Box>

          <Box sx={{ mt: 5 }}>
            <ShopSelect control={control} items={shops} />
          </Box>

          <Box sx={{ mt: 5 }}>
            <InStockCheckBox control={control} />
          </Box>

          <Box sx={{ mt: 5 }}>
            <Button type="submit" variant="contained">
              保存
            </Button>
          </Box>
        </form>
      </Box>
    </>
  )
}

export default Component

 

MUIのTextFieldと組み合わせる

先程見た通り、MUIの各コンポーネントと組み合わせるには React Hook Form の Controller コンポーネントを使えば良さそうです。

 
まずは TextField との組み合わせから実装していきます。

MUIと組み合わせる時に定義が必要な Controller コンポーネントprops

  • name
    • フォームの入力項目を識別するのに利用
  • control
    • useForm で取得した control を割り当てる
  • render
  • defaultValue
    • 入力値のデフォルト値

です。

 
なお、 defaultValue を指定しない場合、TextFieldに何かを入力したタイミングで

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components

というWarningがブラウザのコンソールに出力されてしまいます。

 
また、入力値に対するバリデーションが必要な場合には、使用する各コンポーネントにて

により、エラーメッセージを画面へ表示できます。

 
全体像はこんな感じです。

type Props = {
  control: Control<FormInput>
}

const Component = ({control}: Props): JSX.Element => {
  return (
    <>
      <Controller
        control={control}
        name="name"
        defaultValue={""}
        rules={{required: {value: true, message: '入力必須です'}}}
        render={({field, fieldState: {error}}) =>
          (
            <TextField
              {...field}
              label="品種名"
              error={!!error?.message}
              helperText={error?.message}
            />
          )}
      />
    </>
  )
}

export default Component

 

Radio と組み合わせる

次に、MUIのRadioと組み合わせてみます。
Radio buttons React component - Material UI

TextFieldとほとんど変わりませんが、MUI単体で使った時にあった「 RadioGroupvaluedefaultValue を定義する」は不要です。

 
ちなみに、RadioGroupやRadioには error がないため、Controllerの rules によるバリデーションエラーになったとしても、そのままではエラーメッセージを表示できません。

 
そこで、MUIの FormHelperText コンポーネントを使うことで、バリデーションエラーの時にエラーメッセージを表示できるようにします。
FormHelperText API - Material UI

 
全体像は以下の通りです。

export type RadioItem = {
  label: string
  value: string
}

type Props = {
  control: Control<FormInput>
  items: RadioItem[]
}

const Component = ({control, items}: Props): JSX.Element => {
  return (
    <>
      <FormControl>
        <FormLabel></FormLabel>
        <Controller name="color"
                    defaultValue={''}
                    control={control}
                    rules={{required: {value: true, message: '色は選択必須です'}}}
                    render={({field, fieldState: {error}}) => (
                      <>
                        <RadioGroup>
                          {items.map((radio: RadioItem) =>
                            (
                              <FormControlLabel {...field}
                                                key={radio.value}
                                                label={radio.label}
                                                value={radio.value}
                                                control={<Radio />}
                              />
                            )
                          )}
                        </RadioGroup>
                        <FormHelperText error={!!error?.message}>
                          {error?.message}
                        </FormHelperText>
                      </>
                    )}
        />
      </FormControl>
    </>
  )
}

export default Component

 

Select (TextField select) と組み合わせる

フォームのselectを作る場合、MUIでは

のどちらかのコンポーネントを使います。

今回は TextField コンポーネントと見た目を揃えるため、後者で実装します。

 
また、selectの選択肢としてMUIの MenuItem コンポーネントを使います。 MenuItem API - Material UI

 
それ以外は今までの実装と同じです。

export type SelectItem = {
  label: string
  value: string
}

type Props = {
  control: Control<FormInput>
  items: SelectItem[]
}

const Component = ({control, items}: Props): JSX.Element => {
  return (
    <>
      <Controller control={control}
                  name="shop"
                  defaultValue=""
                  rules={{required: {value: true, message: '店舗は選択必須です'}}}
                  render={({field, fieldState: {error}}) => (
                    <>
                      <TextField {...field}
                                 select
                                 sx={{width: 200}}
                                 label="店舗"
                                 error={!!error?.message}
                                 helperText={error?.message}>
                        {items.map((item) => (
                          <MenuItem key={item.value} value={item.value}>
                            {item.label}
                          </MenuItem>
                        ))}
                      </TextField>
                    </>
                  )}
        />
    </>
  )
}

export default Component

 

Checkbox と組み合わせる

Radioのときと同様な実装になります。

また、 Checkbox コンポーネントerror propsがないため、 FormHelperText を使います。
Checkbox API - Material UI

type Props = {
  control: Control<FormInput>
}

const Component = ({control}: Props): JSX.Element => {
  return (
    <>
      <FormControl>
        <Controller name="inStock"
                    defaultValue={false}
                    control={control}
                    rules={{required: {value: true, message: 'チェックを入れてください'}}}
                    render={({field, fieldState: {error}}) => (
                      <>
                        <FormControlLabel {...field}
                          label="在庫あり"
                          control={<Checkbox />}
                        />
                        <FormHelperText error={!!error?.message}>
                          {error?.message}
                        </FormHelperText>
                      </>
                    )}
        />
      </FormControl>
    </>
  )
}

export default Component

 

動作確認

yarn run dev してReactアプリを起動し、React Routerで定義したURL http://localhost:3000/react-hook-form/mui/single-form にアクセスし、動作確認をします。

 
まず、何も入力しないで 保存 ボタンをクリックすると、各入力項目でエラーが表示されます。バリデーションができているようです。

 
次に、各項目に入力して 保存 ボタンをクリックすると、コンソールに入力値(あるいは、valueで指定した値) が出力されます。

onSubmit 関数に渡されてきた値と getValues() のどちらでも同じ値が取得できています。

 

ソースコード

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

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

React + Rails + Vite.js なSPAアプリを、RubyMineを使ってReactとRailsの両方をデバッグしてみた

JetBrainsの資料にある通り、RubyMineではRailsに加えてReactもデバッグできます。

 
Reactのデバッグについては、公式ドキュメントに

Only for applications created with create-react-app.

Debugging of JavaScript code is only supported in Google Chrome and in other Chromium-based browsers.

Debug a React application - React | RubyMine

とあることから、 create-react-app で作ったReactアプリしかデバッグできないようも感じます。

 
ただ、手元で試したところ Vite.jsで作ったReactアプリもデバッグできたため、手順をメモしておきます。

 
ちなみに、JetBrainsのyoutrackには Vite.js に関するものも登録されているため、合わせてご確認ください。
Support for vite : WEB-46507

また、今回メモした内容よりも良い方法をご存じの方がいらっしゃいましたら、教えていただけるとありがたいです。

 
目次

 

環境

以前作成した、React + Rails + Vite.js なSPAアプリを使います。
React + Rails + Vite.js なSPAアプリをモノレポで作ってみた - メモ的な思考的な

 
今回は、ReactやRailsのバージョンは変えず、 Vite.js まわりをアップデートした上で利用します。

  • mac
  • バックエンド
  • フロントエンド
    • TypeScript 4.4.4
    • React.js 17.0.2
    • React Router 5.2.1
    • Vite.js 2.9.1
    • vite-plugin-ruby 3.0.9

 

デバッグするまでの流れ

Railsをdevelopmentで起動するための設定を追加

まずは、Rails を development で起動する設定を行います。

Select Run/Debug Configuration の枠から Edit Configurations... を選択します。

次に、以下の図のようにRailsの起動設定を行います。

なお、RubyMineでRailsプロジェクトを読み込んでいれば設定済かもしれません。

 

Reactを run dev する設定を追加

続いて、同じく Run/Debug Configuration より、Reactを run dev で起動するための設定を追加します。

npm テンプレートにて、プロジェクトの package.json を参照して run dev するような設定とします。

 
なお、今回の package.jsonscripts はこんな感じで、Reactは vite を使って起動するようになっています。

{
  "name": "frontend",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "serve": "vite preview"
  },
  // (略)
}

 

Debugボタンで、RailsとReactを起動

上記で設定したRailsとReactをデバッグ起動します。

Rails

 
React

 

Reactタブの Process Console | Scripts より、Cmd + Shift を押しながらリンクをクリック

続いて、Reactタブにある Process Console | Script タブを開きます。

次に、Consoleで表示されている Local: http://localhost:7031/vite/ *1 のリンクに対し、 Cmd + Shift を押しながらクリックします*2

 
すると、何も設定されていないブラウザが開きます。

ただ、この時点では Local で指定されていたURLを開くので、何も表示されていません。

 
ちなみに、この時点でRubyMineを見ると、 vite タブが新しく追加されています。

 
また、JavaScript Debug の configuration が追加されています。内容はこんな感じです。

 

ブレークポイントを仕込む

今回はRailsとReact、両方にブレークポイントを仕込みます。

Rails

 
React

 

起動したデバッグ用のブラウザに、アプリのURLを入力する

デバッグの準備ができたので、先ほど起動したデバッグ用のブラウザに、今回のアプリのURL http://localhost:7100 を入力します。

すると、Reactの画面が表示されます。

 
しばらくたつと、Railsブレークポイントで止まります。

 
続いて、 Resume Program ボタンを押して次のブレークポイントまで飛ばすと、今度はReactのブレークポイントで止まります。

ブレークポイント時点での変数などの情報も確認できます。

 
以上より、React + Rails + Vite.js な構成でも、RubyMineでReactとRailsデバッグできることが分かりました。

 

ソースコード

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

 
今回試したソースコードは、こちらのプルリクになります。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/pull/2

*1:ポートなどは環境により変わるかもしれません

*2:今回はmacを使っているため、他のOSの場合はショートカットキーが異なるかもしれません

Rails + Committeeで書いたテストコードが TypeError: no implicit conversion of String into Integer で落ちたため対応してみた

OpenAPIスキーマのあるRailsAPIサーバにて、テストコードを Committee + Committer::Rails で書いていたところ、 TypeError: no implicit conversion of String into Integer というエラーが出ました。

エラーを解消するまでに少々悩んだため、メモを残します。

 
目次

 

環境

 

エラーを再現するための環境構築

前回の記事で使用したサーバアプリを元に、環境構築を行います。

 

作成するAPIの仕様

curl

curl 'http://localhost:3001/api/schema/array/items/shops/1'

とリクエストすると、

{"apples":[{"name":"シナノゴールド","color":"黄"},{"name":"ふじ","color":"赤"}]}

というレスポンスが返ってくるようなAPIを作成します。

 

OpenAPIスキーマ

まずはOpenAPIを作成します。

前回の記事のOpenAPIスキーマを流用しますが、適宜 componentsparametersschemas を利用しています。

openapi: 3.0.0
info:
  title: Rails with OpenAPI
  version: 0.0.1
servers:
  - url: http://localhost:3001
components:
  schemas:
    Apple:
      type: object
      properties:
        name:
          type: string
          example: シナノゴールド
        color:
          type: string
          example:parameters:
    ShopID:
      name: shopId
      in: path
      description: 店舗ID
      required: true
      schema:
        type: integer

paths:
  /api/schema/array/items/shops/{shopId}:
    get:
      parameters:
        - $ref: '#/components/parameters/ShopID'
      responses:
        '200':
          description: successful operation
          content:
            application/json:
              schema:
                type: object
                properties:
                  apples:
                    type: array
                    items:
                      - $ref: '#/components/schemas/Apple'

 

コントローラ

続いてコントローラを作成します。

URL末尾が shopId ど動的になっているため、 show メソッドで定義します。

なお、今回モデルやDBの準備は省略したいので、paramsに含まれる id は無視し、常に同じレスポンスを返すようにしています。

class Api::Schema::Array::Items::ShopsController < ApplicationController
  def show
    # parameterのidは使わない
    render json: {
      apples: [
        { name: 'シナノゴールド', color: '' },
        { name: 'ふじ', color: '' },
      ],
    }
  end
end

 

ルーティング

仕様に合わせたルーティングを作成します。

Rails.application.routes.draw do
  namespace :api do
    namespace :schema do
      namespace :array do
        namespace :items do
          resources :shops, only: [:show]
        end
      end
    end
  end
end

 

curlで動作確認

% curl 'http://localhost:3001/api/schema/array/items/shops/1'

とすると、

{"apples":[{"name":"シナノゴールド","color":"黄"},{"name":"ふじ","color":"赤"}]}

が返ってきました。仕様通り作成できました。

 

テストコードの作成と実行

続いて Committee を使ってテストコード /spec/requests/api/schema/array/items/shops_controller_spec.rb を作成します。

require 'rails_helper'

RSpec.describe 'Api::Schema::Array::ItemsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe 'GET /api/schema/array/items/shops/{shopId}' do
    it '正常系' do
      get '/api/schema/array/items/shops/1'

      assert_request_schema_confirm
      assert_response_schema_confirm(200)
    end
  end
end

 
テストを実行すると、エラーで落ちました。

% bundle exec rspec spec/requests/api/schema/array/items/shops_controller_spec.rb
F

Failures:

  1) Api::Schema::Array::Items::ShopsController GET /api/schema/array/items/shops/{shopId} 正常系
     Failure/Error: assert_request_schema_confirm
     
     TypeError:
       no implicit conversion of String into Integer

 

原因

no implicit conversion of String into Integer とあることから、当初はOpenAPIスキーマpath で指定した shopId が誤っていることを疑いました。

しかし、それらしい原因が分かりませんでした。

 
続いて、OpenAPIスキーマの仕様を見ていったところ、DataType Arrayのところで目が止まりました。
Arrays | specification | Data Types

 
Arrayの定義をよく見ると

type: array
items:
  type: string

となっています。

また、 $refs を使う場合は

type: array
items:
  $ref: '#/components/schemas/Pet'

となっています。

 

一方、手元のOpenAPIスキーマをよく見ると

apples:
  type: array
  items:
    - $ref: '#/components/schemas/Apple'

と、 $refs の先頭に - が付いています。

どうやら、 items の定義方法が誤っていたようです。

 
なぜ items の先頭に - が付けてしまったのかを考えたところ、 Mixed-Type Arraysのところで oneOf を使った定義を見つけました。この定義をどこかで見かけたために - を先頭につけてしまったのかもしれません。
https://swagger.io/docs/specification/data-models/data-types/#mixed-array

type: array
items:
  oneOf:
    - $ref: '#/components/schemas/Cat'
    - $ref: '#/components/schemas/Dog'

 

また、Mixed-Type Arraysで誤った例として

# Incorrect
items:
  - type: string
  - type: integer

# Incorrect as well
items:
  type: 
    - string
    - integer

も載っていました。こちらも見落としていたようです。

 

対応

items の定義を修正します。

/api/schema/array/items/shops/{shopId}:
  get:
    summary: OpenAPIスキーマのArrayに関する動作検証用のAPI
    parameters:
      - $ref: '#/components/parameters/ShopID'
    responses:
      '200':
        description: successful operation
        content:
          application/json:
            schema:
              type: object
              properties:
                apples:
                  type: array
                  items:
                    # 修正前
                    # - $ref: '#/components/schemas/Apple'
                    # 修正後
                    $ref: '#/components/schemas/Apple'

 
テストコードを流すと、テストがパスしました。

% bundle exec rspec spec/requests/api/schema/array/items/shops_controller_spec.rb
.

Finished in 0.0461 seconds (files took 0.99509 seconds to load)
1 example, 0 failures

 

余談:他のツールではどのように見えるか

RubyMineの OpenAPI Specifications の場合

Rubyでの開発中にOpenAPIスキーマを書くときは、RubyMineの OpenAPI Specifications プラグインを使っています。
OpenAPI Specifications - IntelliJ IDEs Plugin | Marketplace

 
OpenAPIスキーマを書くときもコードジャンプができたり、補完入力ができるためです。

例えば、こんな感じで $refs の途中まで入力するとサジェストされます。

 
サジェストされたものをEnterで確定すると、 $refs の設定値が自動的に補完・入力されます。

 
ただ、このプラグインを使っていてもエラーが検出できていないっぽいです。

OpenAPIスキーマのプレビューを見たところ、type: object ではなく type: string で認識されているようでした。

 
一方、正しく書いたOpenAPIスキーマの場合は、 type: object で認識されているようです。

 

Swagger Editor (online) の場合

OpenAPIスキーマのエディタとして、オンラインのエディタも提供されています。
https://editor.swagger.io/

 
そこで、誤っている定義を貼り付けてみたところ、エラーが出ていました。

 
 

Swagger Editor をローカルで動かした場合

Swagger Editorでバリデーションするのは良さそうですが、もし仕事で使う場合はオンラインなのがネックです。

そのため、Swagger Editor をローカルで動かしてみて、同じバリデーションが走るかどうかを確認してみます。
Download OpenAPI Editor | Swagger Open Source

 
GithubのREADMEを読むと、公式でDockerイメージが提供されているようですので、そちらを使って起動してみます。
https://github.com/swagger-api/swagger-editor#running-the-image-from-dockerhub

# イメージをダウンロード
% docker pull swaggerapi/swagger-editor

# ローカルの20030 ポートで起動
% docker run -d -p 20030:8080 swaggerapi/swagger-editor

 
20030ポートを開き、先ほどエラーになったOpenAPIスキーマを貼り付けると、無事にエラーとなりました。

 
これにより、OpenAPI Specifications + Swagger Editor (ローカル) で開発できる環境ができました。

 

ソースコード

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

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

Ruby版のOpenAPI generatorで生成したAPIクライアントが、enumの定義にない値をレスポンスで受け取るとエラーになってしまうので、回避策を実施してみた

OpenAPIスキーマenum を使うと、その項目が取り得る値を指定できます。
Enums - DataModels (Schemas) - OpenAPI Guide - swagger

例えば、以下のOpenAPIスキーマのようにレスポンスの型が定義されているAPIの場合、HTTPレスポンスボディの shop が取り得る値は スーパー産直所 になります。

responses:
  '200':
    description: successful operation
    content:
      application/json:
        schema:
          type: object
          properties:
            shop:
              type: string
              enum:
                - スーパー
                - 産直所

 

そんな中、「OpenAPIスキーマは提供されているものの、何らかの理由で enum の定義にない値が返ってくるAPI」があった時に、Ruby版のopenapi-generatorで生成したAPIクライアントがエラーになってしまうことに気づいたため、回避策をメモします。

ちなみに、「 enum の定義にない値が返ってくる場合に、OpenAPIクライアントがエラーとなってしまうこと」は、本来想定していないことだと考えています。そのため、Ruby版のOpenAPI generatorの不具合というよりは仕様な気がします。

 
目次

 

環境

今回はAPIを提供する側(サーバアプリ)とAPIを利用する側(クライアントアプリ) をRailsで作成してみます。

サーバアプリ

  • Rails 7.0.3
    • ポート 3001 で起動
  • テストコード用のgem

 

クライアントアプリ

 

OpenAPIスキーマ

サーバアプリとクライアントアプリ間で共有するOpenAPIスキーマは以下です。

openapi: 3.0.0
info:
  title: Rails with OpenAPI
  version: 0.0.1
servers:
  - url: http://localhost:3001
paths:
  /api/mismatch/fruits:
    get:
      summary: OpenAPIスキーマとレスポンスが乖離しているAPI
      responses:
        '200':
          description: successful operation
          content:
            application/json:
              schema:
                type: object
                properties:
                  tags:
                    type: array
                    items:
                      type: string
                      enum:
                        - 市内
                        - 県内
                  shop:
                    type: string
                    enum:
                      - スーパー
                      - 産直所
                  fruits:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          enum:
                            - りんご
                            - みかん

 

環境構築

まず、サーバアプリとクライアントアプリを作成します。

 

サーバアプリの構築

rails new

--api をつけて rails new します。

--skip-bundle は好みで付けています。後で bundle install を忘れずに行います。

% bundle exec rails new rails_7_0_openapi_server_app --api --skip-bundle

 

コントローラの作成

今回は app/controllers/api/mismatch/fruits_controller.rb として作成します。

この時点ではOpenAPIスキーマenum にある値だけ返すようにします。

class Api::Mismatch::FruitsController < ApplicationController
  def index
    render json: {
      tags: ['市内'],
      shop: '産直所',
      fruits: [
        { name: 'りんご' },
        { name: 'みかん' },
      ]
    }
  end
end

 

routesの作成

コントローラに合わせてルーティングを作成します。

Rails.application.routes.draw do
  namespace :api do
    namespace :mismatch do
      resources :fruits, only: [:index]
    end
  end
end

 

動作確認

サーバアプリを起動後、curlでレスポンスが返ってくればOKです。

% curl 'http://localhost:3001/api/mismatch/fruits'

{"tags":["市内"],"shop":"産直所","fruits":[{"name":"りんご"},{"name":"みかん"}]}

 

クライアントアプリの構築

rails new

こちらも --apirails new します。

% bundle exec rails new rails_7_0_openapi_client_app --api --skip-bundle

 

OpenAPIスキーマからOpenAPIクライアントを生成

続いて、クライアントアプリのディレクトリ内にOpenAPIスキーマ (openapi.yaml) を置き、 OpenAPI generator で Open API クライアントを生成します。

generatorはいくつかの方法で使えますが、今回は OpenAPI generator 公式で提供されているDockerイメージを使います。
1.6 - Docker | OpenAPITools/openapi-generator: OpenAPI Generator allows generation of API client libraries (SDK generation), server stubs, documentation and configuration automatically given an OpenAPI Spec (v2, v3)

 
なお、生成したOpenAPIクライアントは、クライアントアプリのルート直下に作成する openapi ディレクトリ内に置きます。

また、OpenAPIクライアントは gem 形式で作成されます。そのため、Gemfile に記載して bundle install してクライアントアプリに導入します。

 

docker-compose.yaml によるOpenAPIクライアントの生成

公式ドキュメントのように docker run での実行も考えましたが、コマンドを手打ちするのは大変です。

そこで、以下を参考にして、docker-compose で command を実行することで手間を減らすようにします。
OpenAPI + Docker + Nuxt + TypeScriptで快適な開発環境を - inokawablog

 

まず、以下のような docker-compose.yaml を用意します。

version: '3'
services:
  openapi_ruby:
    image: openapitools/openapi-generator-cli:v6.0.0
    volumes:
      - ./:/local
    command: generate -i local/openapi.yaml -g ruby -o local/openapi

 
Dockerの準備ができたので、OpenAPIクライアントを生成します。

なお、実行後にコンテナが残り続けても困るので、 --rm を追加します。
docker-compose run | Docker Documentation

% docker compose run --rm openapi_ruby

[main] INFO  o.o.codegen.DefaultGenerator - Generating with dryRun=false
...
[main] INFO  o.o.codegen.TemplateManager - writing file /local/openapi/.openapi-generator/FILES
################################################################################
# Thanks for using OpenAPI Generator.                                          #
# Please consider donation to help us maintain this project 🙏                 #
# https://opencollective.com/openapi_generator/donate                          #
################################################################################

 

OpenAPIクライアントのbundle install

Gemfileに追記します。

# OpenAPI generatorで生成したRubyクライアント
gem "openapi_client", path: 'openapi'

 
インストールします。

% bundle install

 

コントローラの作成

OpenAPIクライアントができたので、コントローラ (app/controllers/api/mismatch/error/fruits_controller.rb)にて利用してみます。

class Api::Mismatch::Error::FruitsController < ApplicationController
  def index
    api_instance = OpenapiClient::DefaultApi.new

    begin
      #OpenAPIスキーマとレスポンスが乖離しているAPI
      result = api_instance.api_mismatch_fruits_get
      p result
    rescue OpenapiClient::ApiError => e
      puts "Exception when calling DefaultApi->api_mismatch_fruits_get: #{e}"
    end
  end
end

 

routesの作成

Rails.application.routes.draw do
  namespace :api do
    namespace :mismatch do
      namespace :error do
        resources :fruits, only: [:index]
      end
    end
  end
end

 

動作確認

クライアントアプリ/サーバアプリの両方とも起動した状態で、curlを使って動作確認します。

 % curl 'http://localhost:3002/api/mismatch/error/fruits'

 
ログに以下が出力されています。クライアントアプリ経由でサーバアプリのデータが取得できているようです。

Started GET "/api/mismatch/error/fruits" for 127.0.0.1 at 2022-05-28 00:02:24 +0900
Processing by Api::Mismatch::Error::FruitsController#index as */*
ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.198827
#<OpenapiClient::InlineResponse200:0x00000001148b2aa8 @tags=["市内"], @shop="産直所", @fruits=[#<OpenapiClient::InlineResponse200Fruits:0x00000001148b2468 @name="りんご">, #<OpenapiClient::InlineResponse200Fruits:0x00000001148b1f40 @name="みかん">]>
Completed 204 No Content in 209ms (Allocations: 1113)

 

検証:サーバアプリにて、 enum で定義されていない値を返すように修正

ここからが本題です。

作成したアプリを修正していきます。

 

サーバアプリのコントローラを修正

OpenAPIスキーマenumで定義されていない値を返すよう、サーバアプリのコントローラを修正します。

class Api::Mismatch::FruitsController < ApplicationController
  def index
    render json: {
      tags: ['近所'],

      shop: 'コンビニ',

      fruits: [
        { name: 'りんご' },
        { name: 'みかん' },
        { name: 'ぶどう' }
      ]
    }
  end
end

 

動作確認

curlで動作確認したところ、エラーが返ってきました。

% curl 'http://localhost:3002/api/mismatch/error/fruits'

{"status":500,"error":"Internal Server Error" ...

 
クライアントアプリのログを見ると、 shop に定義されていない値が含まれているため、エラーになっていたようです。

Started GET "/api/mismatch/error/fruits" for 127.0.0.1 at 2022-05-28 00:07:51 +0900
Processing by Api::Mismatch::Error::FruitsController#index as */*
ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.176037
Completed 500 Internal Server Error in 188ms (Allocations: 1095)

ArgumentError (invalid value for "shop", must be one of ["スーパー", "産直所"].):

 
そこで、 shop は定義されている値を返すよう、サーバアプリのコントローラを

class Api::Mismatch::FruitsController < ApplicationController
  def index
    render json: {
      # OpenAPIスキーマのenumで定義されていない値の場合
      tags: ['近所'],

      shop: '産直所',  # 変更後

      fruits: [
        { name: 'りんご' },
        { name: 'みかん' },
        { name: 'ぶどう' }
      ],
    }
  end
end

として再度curlを実行したところ、別のエラーになりました。

Started GET "/api/mismatch/error/fruits" for 127.0.0.1 at 2022-05-28 00:17:24 +0900
Processing by Api::Mismatch::Error::FruitsController#index as */*
ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.166197
Completed 500 Internal Server Error in 185ms (Allocations: 1160)

ArgumentError (invalid value for "name", must be one of ["りんご", "みかん"].):

 
さらに、再度コントローラを

class Api::Mismatch::FruitsController < ApplicationController
  def index
    render json: {
      # OpenAPIスキーマのenumで定義されていない値の場合
      tags: ['近所'],

      shop: '産直所',

      fruits: [ 
        # 変更: nameがぶどうな要素を削除
        { name: 'りんご' },
        { name: 'みかん' },
      ],
    }
  end
end

として curl を実行したところ、 tags の値は enum に含まれないままなのに、エラーが出なくなりました。

Started GET "/api/mismatch/error/fruits" for 127.0.0.1 at 2022-05-28 00:18:31 +0900
Processing by Api::Mismatch::Error::FruitsController#index as */*
ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.146364
#<OpenapiClient::InlineResponse200:0x0000000113f66e90 @tags=["近所"], @shop="産直所", @fruits=[#<OpenapiClient::InlineResponse200Fruits:0x0000000113f66490 @name="りんご">, #<OpenapiClient::InlineResponse200Fruits:0x0000000113f65f40 @name="みかん">]>
Completed 204 No Content in 147ms (Allocations: 1100)

 
エラーになる/ならないの違いは何だろうと思い、OpenAPIスキーマを見直します。

schema:
  type: object
  properties:
    tags:
      type: array
      items:
        type: string
        enum:
          - 市内
          - 県内
    shop:
      type: string
      enum:
        - スーパー
        - 産直所
    fruits:
      type: array
      items:
        type: object
        properties:
          name:
            type: string
            enum:
              - りんご
              - みかん

より、

  • type: object なオブジェクトの properties の項目にて enum を指定した場合は、エラーになる
    • 上記の例では shopfruits
  • type: array な要素が type: object でない場合、要素の定義で enum を指定してもエラーにならない
    • 上記の例では tags

なのかもしれないと思いました。

 

問題点

そもそも、OpenAPIスキーマenum で定義されていない値が返ってくるのでは困ってしまいます。

そのため、手元で管理しているAPIであれば、OpenAPIスキーマAPIのレスポンスが一致するよう修正します。

一方、手元で管理していないAPIで発生した場合は、修正されるまでに時間がかかってしまうかもしれません。

 

そこで、OpenAPIスキーマenum で定義されていない値が返ってきたとしてもOpenAPIクライアントではエラーにならないよう、回避策を調べてみることにしました。

 

回避策:APIクライアントでAPIを呼ぶ時 debug_return_type に String を渡す

回避策の調査

まず、APIクライアントのどこでエラーになっているかを調べたところ、 convert_to_type() でのデータ変換時にエラーとなっていました。
https://github.com/OpenAPITools/openapi-generator/blob/v6.0.0/modules/openapi-generator/src/main/resources/ruby-client/api_client.mustache#L162

 
次に、APIクライアントのメソッドを呼んでからエラーになるまでの流れを追いかけたところ、以下のようでした。

 

この流れの中で convert_type() の前にいい感じでデータを返す方法があるか探したところ、「return_typeString だったら、レスポンスボディを直接返す」という処理を見つけました。
https://github.com/OpenAPITools/openapi-generator/blob/v6.0.0/modules/openapi-generator/src/main/resources/ruby-client/api_client.mustache#L105

これより、OpenAPIクライアントのメソッドを呼ぶ時際、 debug_return_typeString を設定することで、Stringなレスポンスボディをそのまま取得できそうでした。

ただ、Stringのままでは扱いづらいので、Stringなレスポンスボディを JSON.parse() すれば良さそうです。

 

回避策の実装

まずは新しくコントローラ(app/controllers/api/mismatch/workaround/fruits_controller.rb)を作り、OpenAPIクライアントのメソッドを呼ぶ時に debug_return_typeString を渡すようにします。

class Api::Mismatch::Workaround::FruitsController < ApplicationController
  def index
    api_instance = OpenapiClient::DefaultApi.new

    begin
      result = api_instance.api_mismatch_fruits_get(debug_return_type: 'String')  # ここ
      p result

      p JSON.parse(result)

    rescue OpenapiClient::ApiError => e
      puts "Exception when calling DefaultApi->api_mismatch_fruits_get: #{e}"
    end
  end
end

 
続いて、追加したコントローラのroutesを作成します。

Rails.application.routes.draw do
  namespace :api do
    namespace :mismatch do
      # ...

      # 追加
      namespace :workaround do
        resources :fruits, only: [:index]
      end
    end
  end
end

 

動作確認

curl

% curl 'http://localhost:3002/api/mismatch/workaround/fruits'

としたところ、クライアントアプリは正常終了しました。また、サーバの情報がログへ出力されていました。

Started GET "/api/mismatch/workaround/fruits" for 127.0.0.1 at 2022-05-28 07:21:53 +0900
Processing by Api::Mismatch::Workaround::FruitsController#index as */*
ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.027292

# p result した内容
"{\"tags\":[\"\xE8\xBF\x91\xE6\x89\x80\"],\"shop\":\"\xE3\x82\xB3\xE3\x83\xB3\xE3\x83\x93\xE3\x83\x8B\",\"fruits\":[{\"name\":\"\xE3\x82\x8A\xE3\x82\x93\xE3\x81\x94\"},{\"name\":\"\xE3\x81\xBF\xE3\x81\x8B\xE3\x82\x93\"},{\"name\":\"\xE3\x81\xB6\xE3\x81\xA9\xE3\x81\x86\"}]}"

# p JSON.parse(result) した内容
{"tags"=>["近所"], "shop"=>"コンビニ", "fruits"=>[{"name"=>"りんご"}, {"name"=>"みかん"}, {"name"=>"ぶどう"}]}


Completed 204 No Content in 30ms (Allocations: 1228)

 
これにより、 enum の定義にない値をレスポンスするAPIがあったとしても、APIクライアントでAPIを呼ぶ時に debug_return_typeString を渡せば良さそうと分かりました。

 

余談:committee-rails を使ってOpenAPIスキーマとレスポンスのズレに気づく

ここから先は余談です。

もし手元にあるAPIの場合は、OpenAPIスキーマと実際のレスポンスのズレに気づきたいです。

そこで、rspec-rails でテストコードを書く時に committeecommittee-rails も組み合わせることで、ズレが発生したら気づけるようにします。

 

環境構築

gemの追加

サーバのRailsアプリにあるGemfileに、以下を追記します。なお、 committee-rails を入れると committee も入ります。

group :development, :test do
  # OpenAPIスキーマをrspecでテストする用
  gem 'rspec-rails'
  gem 'committee-rails'
end

 
追加し終わったら bundle install しておきます。

 

Rspecのセットアップ

rspec-railsのREADMEに従い、 bin/rails generate rspec:install します。
rspec/rspec-rails: RSpec for Rails 5+

% bin/rails generate rspec:install
      create  .rspec
      create  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb

 

rails_helper.rb に必要な設定を追加

committee-railsのREADMEに従い、設定を追加します。
willnet/committee-rails: rails and committee are good friends

RSpec.configure do |config|
# ...
  # committee-railsのオプション
  config.add_setting :committee_options
  config.committee_options = {
    schema_path: Rails.root.join('openapi.yaml').to_s,
    query_hash_key: 'rack.request.query_hash',
    parse_response_by_content_type: false,
  }

  # committee-railsのメソッドを利用可能にする
  include Committee::Rails::Test::Methods
end

 
以上で設定は完了です。

 

テストコードを作成・実行

続いて、テストコードとして Request Spec spec/request/api/mismatch/fruits_controller_spec.rb を作成します。

enumで定義している値のみレスポンスする」のが正常系であるため、以下のテストコードとします。

require 'rails_helper'

RSpec.describe 'Api::Mismatch::FruitsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe 'GET /api/mismatch/error/fruits' do
    it '正常系' do
      get '/api/mismatch/error/fruits'

      assert_request_schema_confirm
      assert_response_schema_confirm(200)
    end
  end
end

 
specを実行するとエラーになります。

% bin/rails spec

F

Failures:

  1) Api::Mismatch::FruitsController GET /api/mismatch/fruits 正常系
     Failure/Error: assert_response_schema_confirm(200)
     
     Committee::InvalidResponse:
       "近所" isn't part of the enum in #/paths/~1api~1mismatch~1fruits/get/responses/200/content/application~1json/schema/properties/ta/items

# ...
     # --- Caused by: ---
     # OpenAPIParser::NotEnumInclude:
     #   "近所" isn't part of the enum in #/paths/~1api~1mismatch~1fruits/get/responses/200/content/application~1json/schema/properties/gs/items
     #   path/to/rails_7_0_openapi_server_app/gems/openapi_parser-0.15.0/lib/openapi_parser/schema_validator.rb:63:in `validate_data'

 
これにより、OpenAPIスキーマAPIのレスポンスが異なる場合は、テストが失敗することで気づけそうです。

 

ソースコード

Githubに上げました。

Railsで、wrap_parametersで追加されたキーに対し、Strong Parametersのrequireやpermitを使ってみた

Rails製のAPI

curl -X POST 'http://localhost:3000/wrap_parameter/outputs' -H "Content-Type: application/json" -d '{"name":"foo", "age": 20}'

なリクエストを投げたところ、ログに

Started POST "/wrap_parameter/outputs" for 127.0.0.1 at 2022-05-23 22:26:50 +0900
Processing by WrapParameter::OutputsController#create as */*
  Parameters: {"name"=>"foo", "age"=>20, "output"=>{"name"=>"foo", "age"=>20}}

と出力されていました。

Parameters の構造をよく見ると

{
   "name" => "foo",
   "age" => 20,
   
   # 追加されている
   "output" => {
      "name" => "foo",
      "age" =>20
   }
}

と、リクエストしたデータ '{"name":"foo", "age": 20}' には無いキー output が自動で追加されていました。

なぜこれが追加されているのか気になったので、調べたときのメモを残します。

 
目次

 

環境

 

wrap_parameterによるキーの自動追加について

自動で追加されるキーについてRailsガイドを探してみたところ、以下の記載がありました。
4.2 JSONパラメータ | Action Controller の概要 - Railsガイド

データの送信先がCompaniesControllerであれば、以下のように:companyというキーでラップされます。

{ name: "acme", address: "123 Carrot Street", company: { name: "acme", address: "123 Carrot Street" } }

 

そのため、自動的にキーが追加されるのは wrap_parameters 機能によるものだったようです。

wrap_parametersの詳細は、APIドキュメントにも記載されています。
ActionController::ParamsWrapper

 

改めて、今回 output というキーが自動追加されたコントローラを見ると

class WrapParameter::OutputsController < ApplicationController
  def create
    # 略

    head :created
  end
end

となっていました。

コントローラ名が OutputsController なため、 output というキーが追加されたと分かりました。

 

wrap_parametersとStrong Parametersについて

ここまでで、「自動でキーを追加したのは wrap_parameters 機能」と分かりました。

次は wrap_parameters で追加されたキーに対して、Strong Parametersの requirepermit を使って値を取り出してみます。

 

準備

今回は前述のコントローラ OutputsController を使って動作確認することにします。

そこで、動作確認しやすくするよう

  • CSRF対策を除外
  • 取り出した値を出力するメソッド write_log を用意

をコントローラに追加します。

class WrapParameter::OutputsController < ApplicationController
  # POSTする時にCSRFで引っかかってしまうので、今回は外しておく
  protect_from_forgery except: :create

  def create
    output_params

    head :created
  end

  private def output_params
    # ここに確認用の処理を書く
  end

  private def write_log(values)
    logger.info('============>')
    logger.info(values)
    logger.info('<============')
  end
end

 
これで準備ができました。

 

requireやpermitを使わずに、paramsから値を取り出す

準備ができたので、リクエストパラメータが含まれる params より値を取り出します。
4 パラメータ | Action Controller の概要 - Railsガイド

まずは requirepermit を使わずに、paramsから値を取り出してみます。

今回は、curl

curl -X POST 'http://localhost:3000/wrap_parameter/outputs' -H "Content-Type: application/json" -d '{"name":"foo", "age": 20}'

と OutputsController にHTTPリクエストし、その結果を取り出してみます。

コントローラでparamsを確認できるような実装をした上で curl を実行したところ、

# すべて
write_log(params)
# => {"name"=>"foo", "age"=>20, "controller"=>"wrap_parameter/outputs", "action"=>"create", "output"=>{"name"=>"foo", "age"=>20}}

# キーを指定
write_log(params[:name])
# => foo

# wrap parametersのキーを指定
write_log(params[:output])
# => {"name"=>"foo", "age"=>20}

という結果が出力されました。

これにより、wrap_parametersで追加されたキーであっても、普通のキー同様値を取り出せることが分かりました。

 

Strong Parametersで取り出す

続いて、Strong Parametersの機能を使って取り出してみます。

requireやpermitで複雑な形のデータを取り出せるか確認するため、curlでネストしているデータをPOSTしてみます。

curl -X POST 'http://localhost:3000/wrap_parameter/outputs' -H "Content-Type: application/json" -d '{"name":"foo", "age": 20, "pages": [1,2,3], "article": {"title": "hello", "authors": [{"name": "bob", "age": 25, "pages": [1,3]}, {"name": "alice", "age": 30, "pages": [2,4]}]}}'

 
curlワンライナーだとどんなデータか分かりづらいため、データ部分のみ整形したのが以下です。

{
   "name":"foo",
   "age":20,
   "pages":[
      1,
      2,
      3
   ],
   "article":{
      "title":"hello",
      "authors":[
         {
            "name":"bob",
            "age":25,
            "pages":[
               1,
               3
            ]
         },
         {
            "name":"alice",
            "age":30,
            "pages":[
               2,
               4
            ]
         }
      ]
   }
}

これを使って確認します。

 

permitだけ使う

まずは permit だけを指定して、値を取り出してみます。

write_log(params.permit(:name, :age))
# => {"name"=>"foo", "age"=>20}

取り出せました。

なお、permitしてないパラメータについては、ログに出力されていました。

Unpermitted parameters: :name, :age, :article, :output. Context: { controller: WrapParameter::OutputsController, action: create, request: #<ActionDispatch::Request:0x000000010ac06380>, params: {"name"=>"foo", "age"=>20, "pages"=>[1, 2, 3], "article"=>{"title"=>"hello", "authors"=>[{"name"=>"bob", "age"=>25, "pages"=>[1, 3]}, {"name"=>"alice", "age"=>30, "pages"=>[2, 4]}]}, "controller"=>"wrap_parameter/outputs", "action"=>"create", "output"=>{"name"=>"foo", "age"=>20, "pages"=>[1, 2, 3], "article"=>{"title"=>"hello", "authors"=>[{"name"=>"bob", "age"=>25, "pages"=>[1, 3]}, {"name"=>"alice", "age"=>30, "pages"=>[2, 4]}]}}} }

 
他に

  • 配列
  • ネストしたオブジェクト
  • 複雑なネスト

を試してみます。

## 配列
write_log(params.permit(pages: []))
# => {"pages"=>[1, 2, 3]}

## ネストしたオブジェクト
write_log(params.permit(article: [:title, :content]))
# => {"article"=>#<ActionController::Parameters {"title"=>"hello"} permitted: true>}

## 複雑なネスト
write_log(params.permit(:name, :age, article: [:title, :content, authors: [:name, pages: []]]))
# => {"name"=>"foo", 
#     "age"=>20,
#     "article"=>#<ActionController::Parameters {
#       "title"=>"hello", 
#       "authors"=>[
#         #<ActionController::Parameters {"name"=>"bob", "pages"=>[1, 3]} permitted: true>,
#         #<ActionController::Parameters {"name"=>"alice", "pages"=>[2, 4]} permitted: true>
#       ]
#     } permitted: true>
#    }

いずれも取得できました。

 

require + permit を使う

最後に、 wrap_parametersで追加されたキーに対して、 require + permit を使って値を取り出してみます。

同じように値が取り出せました。

## フラット
write_log(params.require(:output).permit(:name, :age))
# => {"name"=>"foo", "age"=>20}

## 配列
write_log(params.require(:output).permit(pages: []))
# => {"pages"=>[1, 2, 3]}

## ネストしたオブジェクト
write_log(params.require(:output).permit(article: [:title, :content]))
# => {"article"=>#<ActionController::Parameters {"title"=>"hello"} permitted: true>}

## もう一回ネスト
write_log(params.require(:output).permit(:name, :age, article: [:title, :content, authors: [:name, pages: []]]))
# => {"name"=>"foo", 
#     "age"=>20,
#     "article"=>#<ActionController::Parameters {
#       "title"=>"hello", 
#       "authors"=>[
#         #<ActionController::Parameters {"name"=>"bob", "pages"=>[1, 3]} permitted: true>,
#         #<ActionController::Parameters {"name"=>"alice", "pages"=>[2, 4]} permitted: true>
#       ]
#     } permitted: true>
#    }

 

ソースコード

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

今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/1

Railsで、モデルとマイグレーションファイルのそれぞれにON DELETE CASCADEな設定をして挙動を確認してみた

DB上のテーブル間に外部キー制約があり、「親を消したら、親に関係する子も削除する」みたいな処理を行いたいとします。

この場合、テーブルの外部キー定義で ON DELETE を指定することで対応できます。

 
そんな中、Railsでは「親を消したら、親に関係する子も削除する」をどのように実現するか調べてみたところ、

  • モデルで dependent を指定する
  • マイグレーションファイルの外部キー定義で on_delete: :cascade を指定する

の2つの方法があるようでした。

そこで、それぞれの挙動を確認してみたため、メモを残します。

 
目次

 

環境

  • Rails 7.0.2.3
  • SQLite
    • Railsのドキュメントを読む限り、今回の検証範囲ではDBによる違いはなさそうなので、今回は準備が容易なSQLiteで検証しました

 

テーブルの準備

今回は

  • 親 : 子 = 1 : n
  • 子 : 孫 = 1 : n

な関係を持つテーブルを用意します。

 
ジェネレータでそれぞれのモデルを生成します。

# 親
% bin/rails g model Parent name:string

# 子
% bin/rails g model Child name:string parent:references

# 孫
% bin/rails g model Grandchild name:string child:references

 
続いて、1側のモデルからn側のモデルを参照できるよう、1側のモデルに has_many を追加します。
2.3 has_many関連付け | Active Record の関連付け - Railsガイド

また、親を削除したときにモデルのコールバックが動くかどうかも確認するため、ParentとChildにコールバックを追加します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }  # 追加
  after_destroy -> { puts '[Parent] after destroy' }  # 追加

  has_many :children  # 追加
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }  # 追加
  after_destroy -> { puts '[Child] after destroy' }  # 追加

  belongs_to :parent
  has_many :grandchildren  # 追加
end

 
続いて、初期データとしてfixtureを用意します。

今回は「parents.name == 親1 なデータを削除したときに、子・孫がどうなるか」が確認できるデータを用意します。

parents.yml

parent_1:
  name: 親1

parent_2:
  name: 親2

children.yml

child_1_1:
  name: 親1の子1
  parent: parent_1

child_1_2:
  name: 親1の子2
  parent: parent_1

child_2:
  name: 親2の子
  parent: parent_2

grandchildren.yml

grand_child_1_1_1:
  name: 親1の子1の孫1
  child: child_1_1

grand_child_1_1_2:
  name: 親1の子1の孫2
  child: child_1_1

grand_child_1_2:
  name: 親1の子2の孫
  child: child_1_2

 
以上で準備ができました。

 

各テーブルの外部キーにON DELETE CASCADEがない版の動作確認

まずは各テーブルの外部キーに ON DELETE CASCADE がない版で動作を確認してみます。

 

モデルにdependentなし

ここまでの作業で モデルにdependentなし、外部キーに on_delete なし という状態になっているため、まずはこのパターンで動作を確認してみます。

Railsコンソールで Parent.find_by(name: '親1').destroy を実行してみると、エラーになりました。

>> Parent.find_by(name: '親1').destroy

(3.8ms)  SELECT sqlite_version(*)
Parent Load (0.2ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
TRANSACTION (0.1ms)  begin transaction
Parent Destroy (0.9ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
TRANSACTION (0.3ms)  rollback transaction
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: FOREIGN KEY constraint failed (ActiveRecord::InvalidForeignKey)
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': FOREIGN KEY constraint failed (SQLite3::ConstraintException)

 
SQLiteのドキュメントを見ると

The ON DELETE and ON UPDATE action associated with each foreign key in an SQLite database is one of "NO ACTION", "RESTRICT", "SET NULL", "SET DEFAULT" or "CASCADE". If an action is not explicitly specified, it defaults to "NO ACTION".

https://www.sqlite.org/foreignkeys.html#fk_actions

とありました。

そのため、 NO ACTION

NO ACTION: Configuring "NO ACTION" means just that: when a parent key is modified or deleted from the database, no special action is taken.

になり、Childが参照するParentのidがなくなってしまった結果、Parent - Child 間で整合性が取れなくなったことからエラーになったようです。

 

モデルにdependent: destroy あり

続いて、外部キーの設定は変更しないまま、モデルの has_many のオプション dependent: destroy を指定してみます。

まずは念のためデータを初期化しておきます。

% bin/rails db:fixtures:load

   
次にモデルを変更します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }
  
  has_many :children, dependent: :destroy  # 変更
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }
  
  belongs_to :parent
  has_many :grandchildren, dependent: :destroy  # 変更
end

 
Railsコンソールで実行するとエラーになりませんでした。

ログを見ると、孫 > 子 > 親の順で1つずつSQLを発行して削除していることから、外部キーの制約には引っかからずに消せているようです。

>> Parent.find_by(name: '親1').destroy

   (2.9ms)  SELECT sqlite_version(*)
  Parent Load (0.8ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Load (0.6ms)  SELECT "children".* FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
[Child] before destroy
  Grandchild Load (0.5ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 242255126]]
  Grandchild Destroy (0.4ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 394275835]]
  Child Destroy (0.1ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 242255126]]
[Child] after destroy
[Child] before destroy
  Grandchild Load (0.0ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 393860266]]
  Grandchild Destroy (0.0ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 111204660]]
  Grandchild Destroy (0.1ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 531204236]]
  Child Destroy (0.0ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 393860266]]
[Child] after destroy
  Parent Destroy (0.1ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.7ms)  commit transaction

 

モデルにdependent: delete_all あり

続いて dependent: delete_all を試します。

なお、モデルの設定が has_many だったため dependent に指定するキーは delete_all でしたが、 has_one などは delete になるようです。

 
まずは念のためデータを初期化しておきます。

% bin/rails db:fixtures:load

   
次に、モデルを変更します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }

  has_many :children, dependent: :delete_all  # 変更
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren, dependent: :delete_all  # 変更
end

 
準備ができたため実行するとエラーになります。

Railsガイドによると

:delete_all: 関連付けられたオブジェクトはすべてデータベースから直接削除されます(コールバックは実行されません)。

のため、DBの外部キー制約の設定 (NO ACTION) に従いエラーとなったようです。

>> Parent.find_by(name: '親1').destroy

 Parent Load (0.3ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Delete All (0.9ms)  DELETE FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
  TRANSACTION (0.8ms)  rollback transaction
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: FOREIGN KEY constraint failed (ActiveRecord::InvalidForeignKey)
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': FOREIGN KEY constraint failed (SQLite3::ConstraintException)

 

各テーブルの外部キーにON DELETE CASCADEがある版の動作確認

続いて、各テーブルの外部キーに ON DELETE CASCADE を設定した上で、各動作を確認してみます。

 

マイグレーションファイルによる環境構築

まずは念のためデータを初期化しておきます。

% bin/rails db:fixtures:load

 
次に、外部キー制約の変更を行うためにマイグレーションファイルを生成します。

テーブルに外部キー制約があるため、 1 : n のn側のテーブルに対するマイグレーションファイルを生成します。

# Child用
% bin/rails g migration ChangeFkToChild

# Grandchild用
bin/rails g migration ChangeFkToGrandchild

 
ファイルができたのでマイグレーションファイルに追記していきます。

マイグレーションファイルで列の変更をする場合は change_column が使えそうです。
https://api.rubyonrails.org/v7.0.3/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-change_column

ただ、今回は外部キーの設定なため、使うのは不適切そうでした。また、change_foreign_key のようなメソッドは見当たりませんでした。

そこで、

  • 既存のデータを削除しない
  • 外部キーの削除 > 外部キーの設定 の順で変更する

を満たすマイグレーションファイルを作成します。

 
Child用

class ChangeFkToChild < ActiveRecord::Migration[7.0]
  def change
    # 既存のFKを削除
    remove_foreign_key :children, :parents

    # FKを追加
    add_foreign_key :children, :parents, on_delete: :cascade
  end
end

 
Grandchild用

class ChangeFkToGrandhild < ActiveRecord::Migration[7.0]
  def change
    # 既存のFKを削除
    remove_foreign_key :grandchildren, :children

    # FKを追加
    add_foreign_key :grandchildren, :children, on_delete: :cascade
  end
end

 
マイグレーションファイルの準備ができたので実行します。

% bin/rails db:migrate

== 20220514011956 ChangeFkToChild: migrating ==================================
-- remove_foreign_key(:children, :parents)
   -> 0.0156s
-- add_foreign_key(:children, :parents, {:on_delete=>:cascade})
   -> 0.0130s
== 20220514011956 ChangeFkToChild: migrated (0.0287s) =========================
== 20220514013014 ChangeFkToGrandhild: migrating ==============================
-- remove_foreign_key(:grandchildren, :children)
   -> 0.0112s
-- add_foreign_key(:grandchildren, :children, {:on_delete=>:cascade})
   -> 0.0119s
== 20220514013014 ChangeFkToGrandhild: migrated (0.0233s) =====================

 
マイグレーション後のDDLを見ると、 on delete cascade が追加されていました。

-- auto-generated definition
create table children
(
    id         integer     not null
        primary key,
    name       varchar default NULL,
    parent_id  integer     not null
        constraint fk_rails_554cba9b33
            references parents
            on delete cascade,
    created_at datetime(6) not null,
    updated_at datetime(6) not null
);

create index index_children_on_parent_id
    on children (parent_id);

 
また、データもそのまま残っていました。

>> Child.all
   (3.6ms)  SELECT sqlite_version(*)
  Child Load (0.3ms)  SELECT "children".* FROM "children"
=> [#<Child:0x000000010ab28ff8 id: 242255126, name: "親1の子2", parent_id: 393698370, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>, #<Child:0x000000010ab58758 id: 393860266, name: "親1の子1", parent_id: 393698370, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>, #<Child:0x000000010ab58690 id: 476916307, name: "親2の子", parent_id: 243142138, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>]

 
これで外部キー制約に ON DELETE CASCADE を付けたときの準備ができました。

 

モデルにdependent なし

モデルから dependent 設定を削除します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }

  has_many :children  # dependent 設定なし
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren  # dependent 設定なし
end

 
Railsコンソールで実行すると、 parents テーブル削除の DELETE 文が1つだけログに出ていました。

>> Parent.find_by(name: '親1').destroy

   (3.7ms)  SELECT sqlite_version(*)
  Parent Load (0.2ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.2ms)  begin transaction
  Parent Destroy (0.7ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.8ms)  commit transaction
=> #<Parent:0x000000010b570fb0 id: 393698370, name: "親1", created_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00>

 
次にデータを確認すると、Parent・Child・Grandchild の各テーブルから対象データが削除されていました。

>> Parent.all
  Parent Load (0.2ms)  SELECT "parents".* FROM "parents"
=> [#<Parent:0x000000010b5b9aa8 id: 243142138, name: "親2", created_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00>]
>> Child.all
  Child Load (0.3ms)  SELECT "children".* FROM "children"
=> [#<Child:0x000000010b5f2650 id: 476916307, name: "親2の子", parent_id: 243142138, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>]
>> Grandchild.all
  Grandchild Load (0.2ms)  SELECT "grandchildren".* FROM "grandchildren"
=> []

 

モデルにdependent: destroy あり

まずはデータを初期化します。

% bin/rails db:fixtures:load

 
続いて、モデルの has_manydependent: destroy を追加します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }
  
  has_many :children, dependent: :destroy  # 追加
end

 
Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren, dependent: :destroy  # 追加
end

 
Railsコンソールで実行すると削除されました。外部キー制約に ON DELETE CASCADE は設定されていますが、都度 DELETE文を発行し、孫から消していっています。

>> Parent.find_by(name: '親1').destroy

   (3.7ms)  SELECT sqlite_version(*)
  Parent Load (0.2ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Load (0.1ms)  SELECT "children".* FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
[Child] before destroy
  Grandchild Load (0.1ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 242255126]]
  Grandchild Destroy (0.4ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 394275835]]
  Child Destroy (0.1ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 242255126]]
[Child] after destroy
[Child] before destroy
  Grandchild Load (0.0ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 393860266]]
  Grandchild Destroy (0.0ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 111204660]]
  Grandchild Destroy (0.1ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 531204236]]
  Child Destroy (0.1ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 393860266]]
[Child] after destroy
  Parent Destroy (0.1ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.9ms)  commit transaction
=> #<Parent:0x0000000113b188e8 id: 393698370, name: "親1", created_at: Sat, 14 May 2022 01:36:18.699254000 UTC +00:00, updated_at: Sat, 14 May 2022 01:36:18.699254000 UTC +00:00>

 

モデルにdependent: delete_all あり

まずはデータを初期化します。

% bin/rails db:fixtures:load

 
続いて、モデルの has_many について dependent: delete_all へと追加します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }

  has_many :children, dependent: :delete_all  # 変更
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren, dependent: :delete_all  # 変更
end

 
実行するとデータが削除されました。ログを見ると

  • Parentは、DELETE文の発行とコールバックの実行ログがある
  • Childは、DELETE文の発行のみログがあり、コールバックは実行されていない
  • Grandchildは、なにもログに残っていない

という状態でした。

>> Parent.find_by(name: '親1').destroy

   (3.3ms)  SELECT sqlite_version(*)
  Parent Load (0.5ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Delete All (0.5ms)  DELETE FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
  Parent Destroy (0.1ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.8ms)  commit transaction
=> #<Parent:0x000000010c3e0988 id: 393698370, name: "親1", created_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00, updated_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00>

 
そこでデータを確認すると、テーブルの ON DELETE CASCADE に従い、孫も削除されているようでした。

>> Parent.all
  Parent Load (0.8ms)  SELECT "parents".* FROM "parents"
=> [#<Parent:0x000000010c473328 id: 243142138, name: "親2", created_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00, updated_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00>]
>> Child.all
  Child Load (0.7ms)  SELECT "children".* FROM "children"
=> [#<Child:0x000000010c47a060 id: 476916307, name: "親2の子", parent_id: 243142138, created_at: Sat, 14 May 2022 01:42:00.728159000 UTC +00:00, updated_at: Sat, 14 May 2022 01:42:00.728159000 UTC +00:00>]
>> Grandchild.all
  Grandchild Load (0.6ms)  SELECT "grandchildren".* FROM "grandchildren"
=> []

 

まとめ

外部キーに ON DELETE CASCADE の設定があるかどうかに関わらず、 has_many dependent: destroy は 孫 > 子 > 親 の順番で1つずつDELETEしていました。

もしDELETE文の発行回数を減らすにはマイグレーションファイルの外部キー設定で on_delete: :cascade を指定した上で、

  • has_many には何も指定しない場合、親用のDELETE文を1回発行して削除する
  • has_many dependent: delete_all の場合、親・子に対してDELETEを合計2回発行して削除する

とすれば良さそうでした。

 

ソースコード

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

 
今回のプルリクはこちらです。各段階でコミットしています。
https://github.com/thinkAmi-sandbox/rails_association-sample/pull/2