こんにちは!スージーです。
前回書いたこちらの続きです
やりたい事
- モデルを1対1で関連付け
- CRUDを実装
React × Rails APIで関連付けをしたモデルのCRUDを実装するにはこんな感じでやるんだなーって感じで見ていただけると幸いです
開発環境
Ruby 2.7.1
Rails 6.0.4
MySQL
node.js 14.8.0
React 17.0.2
参考
やらないこと
- rails
- トランザクションの説明
- リレーションの説明
- エラーハンドリング
まずgem foreman
を入れる
毎回ローカルサーバを立ち上げる時に、rails s
とyarn start
をするのが面倒くさいので
backend $ gem install foreman
touch Procfile
backendディレクトリにProcfile
を作成します
// Procfile
web: bundle exec rails s
npm: cd ../frontend && yarn start
gem foreman
はデフォルトだとrails側が5000
番ポート、client側が5100
番ポートをlistenするのでcors.rb
とclient.js
を修正します
# cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:5100'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
// client.js
const client = applyCaseMiddleware(
axios.create({
baseURL: 'http://localhost:5000/api/v1',
}),
options
);
これで設定完了です
backend $ foreman start
07:06:36 web.1 | started with pid 6341
07:06:36 npm.1 | started with pid 6342
07:06:37 npm.1 | yarn run v1.22.10
07:06:37 npm.1 | $ react-scripts start
07:06:38 web.1 | => Booting Puma
07:06:38 web.1 | => Rails 6.0.4 application starting in development
07:06:38 web.1 | => Run `rails server --help` for more startup options
07:06:39 web.1 | Puma starting in single mode...
07:06:39 web.1 | * Version 4.3.8 (ruby 2.7.1-p83), codename: Mysterious Traveller
07:06:39 web.1 | * Min threads: 5, max threads: 5
07:06:39 web.1 | * Environment: development
07:06:39 web.1 | * Listening on tcp://127.0.0.1:5000
07:06:39 web.1 | * Listening on tcp://[::1]:5000
07:06:39 web.1 | Use Ctrl-C to stop
07:06:45 npm.1 | ℹ 「wds」: Project is running at http://172.20.10.5/
07:06:45 npm.1 | ℹ 「wds」: webpack output is served from
07:06:45 npm.1 | ℹ 「wds」: Content not from webpack is served from /Users/sugawarakouhei/project/local-project/react-form-sample/frontend/public
07:06:45 npm.1 | ℹ 「wds」: 404s will fallback to /
07:06:45 npm.1 | Starting the development server...
web => rails
npm => react
foreman start
コマンド1つで両方のプロセスが実行される事が確認できればOK
modelを作成
post
モデルと1対1(has_one)で関連付けるdetailInfo
モデルを作成します
backend $ rails g model detailInfo
# 日付_create_detail_infos.rb
class CreateDetailInfos < ActiveRecord::Migration[6.0]
def change
create_table :detail_infos do |t|
t.references :post, null: false
t.string :favorite_food, limit: 100
t.string :favorite_toy, limit: 100
t.timestamps
end
end
end
外部キーと他に2つのカラムを持たせます。好きな食べ物
と好きなおもちゃ
です。全くテーブルを分ける必要がないデータ構造ですが、練習なので悪しからず...
# post.rb
class Post < ApplicationRecord
has_one :detail_info, dependent: :destroy
end
dependent: :destroy
は親モデルが物理削除された時に一緒に子モデルも物理削除します。
# detail_iinfo.rb
class DetailInfo < ApplicationRecord
belongs_to :post
end
これで1対1の関係になりました
コントローラーの修正
class Api::V1::PostsController < ApplicationController
def index
render json: Post.all.to_json(include: :detail_info)
end
def show
render json: Post.find(params[:id]).to_json(include: :detail_info)
end
def create
ActiveRecord::Base.transaction do
post = Post.new(post_params)
detail_info = post.build_detail_info(detail_info_params)
if post.save && detail_info.save
render json: post.to_json(include: :detail_info)
else
ender json: { post: post.errors, detail_info: detail_info.errors }, status: 422
end
end
end
def update
ActiveRecord::Base.transaction do
post = Post.find(params[:id])
detail_info = DetailInfo.find_by(post_id: params[:id])
if post.update(post_params) && detail_info.update(detail_info_params)
render json: post.to_json(include: :detail_info)
else
render json: { post: post.errors, detail_info: detail_info.errors }, status: 422
end
end
end
def destroy
post = Post.find(params[:id])
post.destroy
render json: post.to_json(include: :detail_info)
end
private
def post_params
params.require(:post).permit(:name, :neko_type)
end
def detail_info_params
params.require(:detail_info).permit(:favorite_food, :favorite_toy)
end
end
render末尾に.to_json(include: :detail_info)
を追加しました。リレーションを設定したデータを紐付けてjsonを返してくれるようになります。後ほどcurlコマンドを叩いて確認します
createアクション
はbuildメソッド
を使って保存します。1対1のときはbuild_モデル名
で保存します。
あとトランザクションを使い、失敗した時にはrollbackするように処理を囲みます。
複数テーブルへの保存処理にはaccepts_nested_attributes_for(非推奨)やFormオブジェクトを使う方法など存在します。ただこれらをうまく動作させる事ができなかった(筆者の知識が乏しく)のでtransaction
とbuildメソッド
を使い、2つのモデルに登録する処理をcontrollerに書きました(fatコントローラーになってしまいました)
updateアクション
もcreateアクション
同様にtrunsaction
を使い処理を囲みます。findメソッド
でpostモデル
から該当レコードを1件取得します。find_byメソッド
を使い、親モデルと同じid(post_id)を持つ該当レコードをdetail_infoモデル
から1件取得します。
seeds.rb
を修正
post1 = Post.create!(name: 'ニャア', neko_type: 'アメリカンショートヘア')
DetailInfo.create!(post: post1, favorite_food: '魚', favorite_toy: '猫じゃらし')
post2 = Post.create!(name: 'まる', neko_type: 'スコッティシュフォールド')
DetailInfo.create!(post: post2, favorite_food: '野菜', favorite_toy: 'まりたん')
post3 = Post.create!(name: 'むぎ', neko_type: 'スコッティシュフォールド')
DetailInfo.create!(post: post3, favorite_food: '肉', favorite_toy: 'ダンボール')
以前の記事で作成したseeds.rb
を修正しました。post: post1
のように記載して、外部キーを持っている子モデルに対してcreateしています
curlコマンドを叩いて確認してみます
backend $ rails db:migrate:reset
rails db:seed
// 一覧
curl http://localhost:5000/api/v1/posts
[{"id":1,"name":"ニャア","neko_type":"アメリカンショートヘア","created_at":"2021-07-30T23:22:17.084Z","updated_at":"2021-07-30T23:22:17.084Z","detail_info":{"id":1,"post_id":1,"favorite_food":"魚","favorite_toy":"猫じゃらし","created_at":"2021-07-30T23:22:17.106Z","updated_at":"2021-07-30T23:22:17.106Z"}},{"id":2,"name":"まる","neko_type":"スコッティシュフォールド","created_at":"2021-07-30T23:22:17.111Z","updated_at":"2021-07-30T23:22:17.111Z","detail_info":{"id":2,"post_id":2,"favorite_food":"野菜","favorite_toy":"まりたん","created_at":"2021-07-30T23:22:17.116Z","updated_at":"2021-07-30T23:22:17.116Z"}},{"id":3,"name":"むぎ","neko_type":"スコッティシュフォールド","created_at":"2021-07-30T23:22:17.121Z","updated_at":"2021-07-30T23:22:17.121Z","detail_info":{"id":3,"post_id":3,"favorite_food":"肉","favorite_toy":"ダンボール","created_at":"2021-07-30T23:22:17.126Z","updated_at":"2021-07-30T23:22:17.126Z"}}]
// 詳細
curl http://localhost:5000/api/v1/posts/1
{"id":1,"name":"ニャア","neko_type":"アメリカンショートヘア","created_at":"2021-07-30T23:22:17.084Z","updated_at":"2021-07-30T23:22:17.084Z","detail_info":{"id":1,"post_id":1,"favorite_food":"魚","favorite_toy":"猫じゃらし","created_at":"2021-07-30T23:22:17.106Z","updated_at":"2021-07-30T23:22:17.106Z"}}
// 新規作成
curl -X POST "http://localhost:5000/api/v1/posts" -H "Accept: application/json" -H "Content-type: application/json" -d '{"post": {"name": "test","neko_type":"test"},"detail_info":{"favorite_food": "test","favorite_toy":"test"}}'
{"id":4,"name":"test","neko_type":"test","created_at":"2021-08-01T01:33:18.492Z","updated_at":"2021-08-01T01:33:18.492Z","detail_info":{"id":4,"post_id":4,"favorite_food":"test","favorite_toy":"test","created_at":"2021-08-01T01:33:18.496Z","updated_at":"2021-08-01T01:33:18.496Z"}}%
// 更新
curl -X PATCH "http://localhost:5000/api/v1/posts/4" -H "Accept: application/json" -H "Content-type: application/json" -d '{"post": {"name": "test","neko_type":"test"},"detail_info":{"favorite_food": "update3","favorite_toy":"update3"}}'
{"id":4,"name":"test","neko_type":"test","created_at":"2021-08-01T01:33:18.492Z","updated_at":"2021-08-01T01:33:18.492Z","detail_info":{"id":4,"post_id":4,"favorite_food":"update3","favorite_toy":"update3","created_at":"2021-08-01T01:33:18.496Z","updated_at":"2021-08-01T01:34:19.279Z"}}%
// 削除
curl -X DELETE http://localhost:5000/api/v1/posts/4
{"id":4,"name":"test","neko_type":"test","created_at":"2021-08-01T01:33:18.492Z","updated_at":"2021-08-01T01:33:18.492Z","detail_info":{"id":4,"post_id":4,"favorite_food":"update3","favorite_toy":"update3","created_at":"2021-08-01T01:33:18.496Z","updated_at":"2021-08-01T01:34:19.279Z"}}
[{"id": 1 ~~ detailInfo: {"id": 1, "post_id": 1 ~~} }]
オブジェクトの中にdetailInfo
というキーでオブジェクトがネストされたデータ構造になっています。想定通りにデータが入っています
またpost
/ patch
のリクエストを受けてcreateアクション
/ updateアクション
が正常に動いている事が確認できました
delete
のリクエストを受けて親モデルのレコードが削除された時にはモデルに追記したdependent: :destroy
によって子モデルのレコードも削除されている事が確認できました
一覧画面の修正
以前の記事でapiコールの実装は完了しているので、取得したデータの中にあるdetailInfo: {"id": 1, "post_id": 1 ~~}
のオブジェクト内から必要なプロパティを展開し表示します
// List.jsx
import React, { useEffect, useState } from 'react';
// deletePostを追加
import { getList, deletePost } from '../lib/api/post';
import { useHistory, Link } from 'react-router-dom';
const List = () => {
const [dataList, setDataList] = useState([]);
useEffect(() => {
handleGetList();
}, []);
const handleGetList = async () => {
try {
const res = await getList();
console.log(res.data);
setDataList(res.data);
} catch (e) {
console.log(e);
}
};
const history = useHistory();
const handleDelete = async (item) => {
console.log('click', item.id);
try {
const res = await deletePost(item.id);
console.log(res.data);
handleGetList();
} catch (e) {
console.log(e);
}
};
return (
<>
<h1>HOME</h1>
<button onClick={() => history.push('/new')}>新規作成</button>
<table>
<thead>
<tr>
<th>名前</th>
<th>猫種</th>
{/* 追加 */}
<th>好きな食べ物</th>
{/* 追加 */}
<th>好きなおもちゃ</th>
<th colSpan='1'></th>
<th colSpan='1'></th>
<th colSpan='1'></th>
</tr>
</thead>
{dataList.map((item, index) => (
<tbody key={index}>
<tr>
<td>{item.name}</td>
<td>{item.nekoType}</td>
{/* 追加 */}
<td>{item.detailInfo.favoriteFood}</td>
{/* 追加 */}
<td>{item.detailInfo.favoriteToy}</td>
<td>
<Link to={`/edit/${item.id}`}>更新</Link>
</td>
<td>
<Link to={`/post/${item.id}`}>詳細へ</Link>
</td>
<td>
<button onClick={() => handleDelete(item)}>削除</button>
</td>
</tr>
</tbody>
))}
</table>
</>
);
};
export default List;
詳細画面の修正
同じ要領で詳細画面の修正をしていきます。詳細画面ではquery.id
の取得タイミングがrenderingより遅くなってしまうので{data.detailInfo.favoriteFood}
がTypeError: Cannot read property 'favoriteFood' of undefined
と怒られてしまいます。なのでstateに初期値を設定します
// Detail.jsx
import React, { useEffect, useState } from 'react';
import { getDetail } from '../lib/api/post';
import { useHistory, useParams } from 'react-router-dom';
const Detail = () => {
// 修正
const [data, setData] = useState({
name: '',
neko_type: '',
detailInfo: {
favorite_food: '',
favorite_toy: '',
},
});
const query = useParams();
console.log(query.id);
const history = useHistory();
useEffect(() => {
handleGetDetail(query);
}, [query]);
const handleGetDetail = async (query) => {
try {
const res = await getDetail(query.id);
console.log(res.data);
setData(res.data);
} catch (e) {
console.log(e);
}
};
return (
<>
<h1>DETAIL</h1>
<div>ID:{data.id}</div>
<div>名前:{data.name}</div>
<div>猫種:{data.nekoType}</div>
{/* 追加 */}
<div>好きな食べ物:{data.detailInfo.favoriteFood}</div>
{/* 追加 */}
<div>好きなおもちゃ:{data.detailInfo.favoriteToy}</div>
<button onClick={() => history.push('/')}>戻る</button>
</>
);
};
export default Detail;
削除機能(追加無し)
削除機能は変更なしで削除できることが確認できればOK
新規作成の修正
新規作成を修正します
- テキストフィールを
Form.jsx
に追加 -
Submitイベント
でapiに送るパラメータのオブジェクト構造を加工する
// Form.jsx
import React from 'react';
const Form = (props) => {
const { handleChange, handleSubmit, value, buttonType } = props;
return (
<>
<form>
<div>
<label htmlFor='name'>猫の名前:</label>
<input
type='text'
name='name'
id='name'
onChange={(e) => handleChange(e)}
value={value.name}
/>
</div>
<div>
<label htmlFor='nekoType'>猫種</label>
<input
type='text'
name='nekoType'
id='nekoType'
onChange={(e) => handleChange(e)}
value={value.nekoType}
/>
</div>
{/* 追加 */}
<div>
<label htmlFor='nekoType'>好きな食べ物</label>
<input
type='text'
name='favoriteFood'
id='favoriteFood'
onChange={(e) => handleChange(e)}
value={value.favoriteFood}
/>
</div>
<div>
<label htmlFor='nekoType'>好きなおもちゃ</label>
<input
type='text'
name='favoriteToy'
id='favoriteToy'
onChange={(e) => handleChange(e)}
value={value.favoriteToy}
/>
</div>
{/* ここまで */}
<input
type='submit'
value={buttonType}
onClick={(e) => handleSubmit(e)}
/>
</form>
</>
);
};
export default Form;
// New.jsx
import React, { useState } from 'react';
import FormBody from './Form';
import { createPost } from '../lib/api/post';
import { useHistory } from 'react-router-dom';
const New = () => {
const [value, setValue] = useState({});
const history = useHistory();
const handleChange = (e) => {
setValue({
...value,
[e.target.name]: e.target.value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
console.log(value);
// 追加
const params = generateParams();
try {
const res = await createPost(params);
console.log(res);
history.push('/');
} catch (e) {
console.log(e);
}
};
// パラメータのオブジェクト構造を加工
const generateParams = () => {
const params = {
name: value.name,
nekoType: value.nekoType,
// detailInfoというキーでオブジェクトをネストする
detailInfo: {
favoriteFood: value.favoriteFood,
favoriteToy: value.favoriteToy,
},
};
return params;
};
return (
<>
<h1>NEW</h1>
<FormBody
handleChange={handleChange}
handleSubmit={handleSubmit}
value={value}
buttonType='登録'
/>
</>
);
};
export default New;
params
の中身を見てみるとこのようなオブジェクトになっています
登録ボタンを押下してデータが登録できればOK
railsのログを見てみると、パラメータを受け取って2つのinsert
処理が走っている事がわかります。BEGIN
とCOMMIT
に挟まれ処理が実行されているのでtrunsaction
が効いている事も確認できます
10:11:26 web.1 | Started POST "/api/v1/posts" for ::1 at 2021-08-02 10:11:26 +0900
10:11:26 web.1 | Processing by Api::V1::PostsController#create as HTML
10:11:26 web.1 | Parameters: {"name"=>"test", "neko_type"=>"test", "detail_info"=>{"favorite_food"=>"test", "favorite_toy"=>"test"}, "post"=>{"name"=>"test", "neko_type"=>"test"}}
10:11:26 web.1 | (54.6ms) BEGIN
10:11:26 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:15:in `block in create'
10:11:26 web.1 | Post Create (14.8ms) INSERT INTO `posts` (`name`, `neko_type`, `created_at`, `updated_at`) VALUES ('test', 'test', '2021-08-02 01:11:26.894098', '2021-08-02 01:11:26.894098')
10:11:26 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:15:in `block in create'
10:11:27 web.1 | DetailInfo Create (26.6ms) INSERT INTO `detail_infos` (`post_id`, `favorite_food`, `favorite_toy`, `created_at`, `updated_at`) VALUES (4, 'test', 'test', '2021-08-02 01:11:26.977189', '2021-08-02 01:11:26.977189')
10:11:27 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:15:in `block in create'
10:11:27 web.1 | (6.9ms) COMMIT
10:11:27 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:12:in `create'
10:11:27 web.1 | Completed 200 OK in 142ms (Views: 0.4ms | ActiveRecord: 102.9ms | Allocations: 4071)
更新画面を修正
最後に更新処理を修正します
- stateの初期値を修正する
-
Submitイベント
でapiに送るパラメータのオブジェクト構造を加工する
// Edit.jsx
import React, { useEffect, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { updatePost, getDetail } from '../lib/api/post';
import FormBody from './Form';
const Edit = () => {
const [value, setValue] = useState({
name: '',
nekoType: '',
// 追加
favoriteFood: '',
// 追加
favoriteToy: '',
});
const query = useParams();
const history = useHistory();
useEffect(() => {
handleGetData(query);
}, [query]);
const handleGetData = async (query) => {
try {
const res = await getDetail(query.id);
console.log(res.data);
setValue({
name: res.data.name,
nekoType: res.data.nekoType,
// 追加
favoriteFood: res.data.detailInfo.favoriteFood,
// 追加
favoriteToy: res.data.detailInfo.favoriteToy,
});
} catch (e) {
console.log(e);
}
};
const handleChange = (e) => {
setValue({
...value,
[e.target.name]: e.target.value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
// 追加
const params = generateParams();
try {
const res = await updatePost(query.id, params);
console.log(res);
history.push('/');
} catch (e) {
console.log(e);
}
};
// パラメータのオブジェクト構造を加工
const generateParams = () => {
const params = {
name: value.name,
nekoType: value.nekoType,
detailInfo: {
favoriteFood: value.favoriteFood,
favoriteToy: value.favoriteToy,
},
};
return params;
};
return (
<>
<h1>Edit</h1>
<FormBody
handleChange={handleChange}
handleSubmit={handleSubmit}
value={value}
buttonType='更新'
/>
</>
);
};
export default Edit;
修正箇所はNew.jsx
とほとんど同じです
更新ボタンを押下してデータが更新できればOK
railsのログを見てみると、パラメータを受け取って2つのselect
と2つのupdate
処理が走っている事がわかります。id=4
のレコードをpost
テーブルから取得して更新処理をしています。post_id=4
のレコードをdetailInfo
テーブルから取得して更新処理をしています。BEGIN
とCOMMIT
に挟まれ処理が実行されているのでtrunsaction
が効いている事も確認できます
10:16:04 web.1 | Started PATCH "/api/v1/posts/4" for ::1 at 2021-08-02 10:16:04 +0900
10:16:04 web.1 | Processing by Api::V1::PostsController#update as HTML
10:16:04 web.1 | Parameters: {"name"=>"update", "neko_type"=>"update", "detail_info"=>{"favorite_food"=>"udpate", "favorite_toy"=>"update"}, "id"=>"4", "post"=>{"name"=>"update", "neko_type"=>"update"}}
10:16:04 web.1 | (0.2ms) BEGIN
10:16:04 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:25:in `block in update'
10:16:04 web.1 | Post Load (0.6ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`id` = 4 LIMIT 1
10:16:04 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:25:in `block in update'
10:16:04 web.1 | DetailInfo Load (21.6ms) SELECT `detail_infos`.* FROM `detail_infos` WHERE `detail_infos`.`post_id` = 4 LIMIT 1
10:16:04 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:26:in `block in update'
10:16:04 web.1 | Post Update (12.4ms) UPDATE `posts` SET `posts`.`name` = 'update', `posts`.`neko_type` = 'update', `posts`.`updated_at` = '2021-08-02 01:16:04.763790' WHERE `posts`.`id` = 4
10:16:04 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:27:in `block in update'
10:16:04 web.1 | Post Load (14.5ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`id` = 4 LIMIT 1
10:16:04 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:27:in `block in update'
10:16:04 web.1 | DetailInfo Update (1.0ms) UPDATE `detail_infos` SET `detail_infos`.`favorite_food` = 'udpate', `detail_infos`.`favorite_toy` = 'update', `detail_infos`.`updated_at` = '2021-08-02 01:16:04.821784' WHERE `detail_infos`.`id` = 4
10:16:04 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:27:in `block in update'
10:16:04 web.1 | DetailInfo Load (21.8ms) SELECT `detail_infos`.* FROM `detail_infos` WHERE `detail_infos`.`post_id` = 4 LIMIT 1
10:16:04 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:28:in `block in update'
10:16:04 web.1 | (7.4ms) COMMIT
10:16:04 web.1 | ↳ app/controllers/api/v1/posts_controller.rb:24:in `update'
10:16:04 web.1 | Completed 200 OK in 184ms (Views: 0.2ms | ActiveRecord: 79.4ms | Allocations: 6798)
最後に
コントローラがfatになっているので、もっと良い書き方があればご教示ください!